debian-image-builder-frontend/src/store/cockpit/cockpitApi.ts
Gianluca Zuccarelli c7cd9e8de3 store/cockpitApi: query for oscap customizations
Use the oscap & scap-security guide packages on the host to get the
customizations for an OpenSCAP profile item.
2025-03-31 18:02:11 -05:00

606 lines
18 KiB
TypeScript

import path from 'path';
// Note: for the on-prem version of the frontend we have configured
// this so that we check `node_modules` and `pkg/lib` for packages.
// To get around this for the hosted service, we have configured
// the `tsconfig` to stubs of the `cockpit` and `cockpit/fsinfo`
// modules. These stubs are in the `src/test/mocks/cockpit` directory.
// We also needed to create an alias in vitest to make this work.
import cockpit from 'cockpit';
import { fsinfo } from 'cockpit/fsinfo';
import TOML from 'toml';
import { v4 as uuidv4 } from 'uuid';
// We have to work around RTK query here, since it doesn't like splitting
// out the same api into two separate apis. So, instead, we can just
// inherit/import the `contentSourcesApi` and build on top of that.
// This is fine since all the api endpoints for on-prem should query
// the same unix socket. This allows us to split out the code a little
// bit so that the `cockpitApi` doesn't become a monolith.
import { contentSourcesApi } from './contentSourcesApi';
import {
mapHostedToOnPrem,
mapOnPremToHosted,
} from '../../Components/Blueprints/helpers/onPremToHostedBlueprintMapper';
import { BLUEPRINTS_DIR } from '../../constants';
import {
ComposeBlueprintApiResponse,
ComposeBlueprintApiArg,
CreateBlueprintRequest,
ComposesResponseItem,
GetArchitecturesApiResponse,
GetArchitecturesApiArg,
GetBlueprintsApiArg,
GetBlueprintsApiResponse,
GetBlueprintComposesApiArg,
GetBlueprintComposesApiResponse,
GetComposesApiArg,
GetComposesApiResponse,
GetComposeStatusApiArg,
GetComposeStatusApiResponse,
DeleteBlueprintApiResponse,
DeleteBlueprintApiArg,
BlueprintItem,
GetOscapProfilesApiArg,
GetOscapProfilesApiResponse,
GetBlueprintApiResponse,
GetBlueprintApiArg,
CreateBlueprintApiResponse,
CreateBlueprintApiArg,
ComposeResponse,
UpdateBlueprintApiResponse,
UpdateBlueprintApiArg,
DistributionProfileItem,
GetOscapCustomizationsApiResponse,
GetOscapCustomizationsApiArg,
} from '../service/imageBuilderApi';
const lookupDatastreamDistro = (distribution: string) => {
if (distribution.startsWith('fedora')) {
return 'fedora';
}
if (distribution === 'centos-9') {
return 'cs9';
}
if (distribution === 'centos-10') {
return 'cs10';
}
if (distribution === 'rhel-9') {
return 'rhel9';
}
if (distribution === 'rhel-10') {
return 'rhel10';
}
throw 'Unknown distribution';
};
const getBlueprintsPath = async () => {
const user = await cockpit.user();
// we will use the user's `.local` directory
// to save blueprints used for on-prem
return `${user.home}/${BLUEPRINTS_DIR}`;
};
const readComposes = async (bpID: string) => {
const blueprintsDir = await getBlueprintsPath();
let composes: ComposesResponseItem[] = [];
const bpInfo = await fsinfo(
path.join(blueprintsDir, bpID),
['entries', 'mtime'],
{
superuser: 'try',
}
);
const bpEntries = Object.entries(bpInfo?.entries || {});
for (const entry of bpEntries) {
if (entry[0] === `${bpID}.json`) {
continue;
}
const composeReq = await cockpit
.file(path.join(blueprintsDir, bpID, entry[0]))
.read();
composes = [
...composes,
{
id: entry[0],
request: JSON.parse(composeReq),
created_at: new Date(entry[1]!.mtime * 1000).toString(),
blueprint_id: bpID,
},
];
}
return composes;
};
export const cockpitApi = contentSourcesApi.injectEndpoints({
endpoints: (builder) => {
return {
getArchitectures: builder.query<
GetArchitecturesApiResponse,
GetArchitecturesApiArg
>({
queryFn: () => {
// TODO: this is hardcoded for now, but we may need to query
// the cloudapi endpoint on the composer socket to get the
// available information
return {
data: [
{
arch: 'aarch64',
image_types: ['guest-image', 'image-installer'],
repositories: [],
},
{
arch: 'x86_64',
image_types: [
'rhel-edge-commit',
'rhel-edge-installer',
'edge-commit',
'edge-installer',
'guest-image',
'image-installer',
'vsphere',
'vsphere-ova',
],
repositories: [],
},
],
};
},
}),
getBlueprint: builder.query<GetBlueprintApiResponse, GetBlueprintApiArg>({
queryFn: async ({ id, version }) => {
try {
const blueprintsDir = await getBlueprintsPath();
const bpPath = path.join(blueprintsDir, id, `${id}.json`);
const bpInfo = await fsinfo(bpPath, ['mtime'], {
superuser: 'try',
});
const contents = await cockpit.file(bpPath).read();
const parsed = JSON.parse(contents);
return {
data: {
...parsed,
id,
version: version,
last_modified_at: new Date(bpInfo!.mtime * 1000).toString(),
},
};
} catch (error) {
return { error };
}
},
}),
getBlueprints: builder.query<
GetBlueprintsApiResponse,
GetBlueprintsApiArg
>({
queryFn: async (queryArgs) => {
try {
const { name, search, offset, limit } = queryArgs;
const blueprintsDir = await getBlueprintsPath();
// we probably don't need any more information other
// than the entries from the directory
const info = await fsinfo(blueprintsDir, ['entries'], {
superuser: 'try',
});
const entries = Object.entries(info?.entries || {});
let blueprints: BlueprintItem[] = await Promise.all(
entries.map(async ([filename]) => {
const file = cockpit.file(
path.join(blueprintsDir, filename, `${filename}.json`)
);
const contents = await file.read();
const parsed = JSON.parse(contents);
return {
...parsed,
id: filename as string,
version: 1,
last_modified_at: Date.now().toString(),
};
})
);
blueprints = blueprints.filter((blueprint) => {
if (name) {
return blueprint.name === name;
}
if (search) {
// TODO: maybe add other params to the search filter
return blueprint.name.includes(search);
}
return true;
});
let paginatedBlueprints = blueprints;
if (offset !== undefined && limit !== undefined) {
paginatedBlueprints = blueprints.slice(offset, offset + limit);
}
let first = '';
let last = '';
if (blueprints.length > 0) {
first = blueprints[0].id;
last = blueprints[blueprints.length - 1].id;
}
return {
data: {
meta: { count: blueprints.length },
links: {
// These are kind of meaningless for the on-prem
// version
first: first,
last: last,
},
data: paginatedBlueprints,
},
};
} catch (error) {
return { error };
}
},
}),
createBlueprint: builder.mutation<
CreateBlueprintApiResponse,
CreateBlueprintApiArg
>({
queryFn: async ({ createBlueprintRequest: blueprintReq }) => {
try {
const id = uuidv4();
const blueprintsDir = await getBlueprintsPath();
await cockpit.spawn(
['mkdir', '-p', path.join(blueprintsDir, id)],
{}
);
await cockpit
.file(path.join(blueprintsDir, id, `${id}.json`))
.replace(JSON.stringify(blueprintReq));
return {
data: {
id: id,
},
};
} catch (error) {
return { error };
}
},
}),
updateBlueprint: builder.mutation<
UpdateBlueprintApiResponse,
UpdateBlueprintApiArg
>({
queryFn: async ({ id: id, createBlueprintRequest: blueprintReq }) => {
try {
const blueprintsDir = await getBlueprintsPath();
await cockpit
.file(path.join(blueprintsDir, id, `${id}.json`))
.replace(JSON.stringify(blueprintReq));
return {
data: {
id: id,
},
};
} catch (error) {
return { error };
}
},
}),
deleteBlueprint: builder.mutation<
DeleteBlueprintApiResponse,
DeleteBlueprintApiArg
>({
queryFn: async ({ id: filename }) => {
try {
const blueprintsDir = await getBlueprintsPath();
const filepath = path.join(blueprintsDir, filename);
await cockpit.spawn(['rm', '-r', filepath], {
superuser: 'try',
});
return {
data: {},
};
} catch (error) {
return { error };
}
},
}),
getOscapProfiles: builder.query<
GetOscapProfilesApiResponse,
GetOscapProfilesApiArg
>({
queryFn: async ({ distribution }) => {
try {
const dsDistro = lookupDatastreamDistro(distribution);
const result = (await cockpit.spawn(
[
'oscap',
'info',
'--profiles',
`/usr/share/xml/scap/ssg/content/ssg-${dsDistro}-ds.xml`,
],
{
superuser: 'try',
}
)) as string;
const profiles = result
.split('\n')
.filter((profile) => profile !== '')
.map((profile) => profile.split(':')[0])
.map((profile) => profile as DistributionProfileItem);
return {
data: profiles,
};
} catch (error) {
return { error };
}
},
}),
getOscapCustomizations: builder.query<
GetOscapCustomizationsApiResponse,
GetOscapCustomizationsApiArg
>({
queryFn: async ({ distribution, profile }) => {
try {
const dsDistro = lookupDatastreamDistro(distribution);
let result = (await cockpit.spawn(
[
'oscap',
'xccdf',
'generate',
'fix',
'--fix-type',
'blueprint',
'--profile',
profile,
`/usr/share/xml/scap/ssg/content/ssg-${dsDistro}-ds.xml`,
],
{
superuser: 'try',
}
)) as string;
const parsed = TOML.parse(result);
const blueprint = mapOnPremToHosted(parsed);
result = (await cockpit.spawn(
[
'oscap',
'info',
'--profile',
profile,
`/usr/share/xml/scap/ssg/content/ssg-${dsDistro}-ds.xml`,
],
{
superuser: 'try',
}
)) as string;
const descriptionLine = result
.split('\n')
.filter((s) => s.includes('Description: '));
const description =
descriptionLine.length > 0
? descriptionLine[0].split('Description: ')[1]
: '';
return {
data: {
...blueprint.customizations,
openscap: {
profile_id: profile,
// the profile name is stored in the description
profile_name: blueprint.description,
profile_description: description,
},
},
};
} catch (error) {
return { error };
}
},
}),
composeBlueprint: builder.mutation<
ComposeBlueprintApiResponse,
ComposeBlueprintApiArg
>({
queryFn: async ({ id: filename }, _, __, baseQuery) => {
try {
const blueprintsDir = await getBlueprintsPath();
const file = cockpit.file(
path.join(blueprintsDir, filename, `${filename}.json`)
);
const contents = await file.read();
const parsed = JSON.parse(contents);
const createBPReq = parsed as CreateBlueprintRequest;
const blueprint = mapHostedToOnPrem(createBPReq);
const composes: ComposeResponse[] = [];
for (const ir of parsed.image_requests) {
const composeReq = {
distribution: createBPReq.distribution,
blueprint: blueprint,
image_requests: [
{
architecture: ir.architecture,
image_type: ir.image_type,
repositories: [],
upload_targets: [
{
type: 'local',
upload_options: {},
},
],
},
],
};
const saveReq = {
distribution: createBPReq.distribution,
blueprint: parsed,
image_requests: [
{
architecture: ir.architecture,
image_type: ir.image_type,
repositories: [],
upload_request: {
type: 'local',
options: {},
},
},
],
};
const composeResp = await baseQuery({
url: '/compose',
method: 'POST',
body: JSON.stringify(composeReq),
headers: {
'content-type': 'application/json',
},
});
await cockpit
.file(path.join(blueprintsDir, filename, composeResp.data?.id))
.replace(JSON.stringify(saveReq));
composes.push({ id: composeResp.data?.id });
}
return {
data: composes,
};
} catch (error) {
return { error };
}
},
}),
getComposes: builder.query<GetComposesApiResponse, GetComposesApiArg>({
queryFn: async () => {
try {
const blueprintsDir = await getBlueprintsPath();
const info = await fsinfo(blueprintsDir, ['entries'], {
superuser: 'try',
});
let composes: ComposesResponseItem[] = [];
const entries = Object.entries(info?.entries || {});
for (const entry of entries) {
composes = composes.concat(await readComposes(entry[0]));
}
return {
data: {
meta: {
count: composes.length,
},
links: {
first: composes.length > 0 ? composes[0].id : '',
last:
composes.length > 0 ? composes[composes.length - 1].id : '',
},
data: composes,
},
};
} catch (error) {
return { error };
}
},
}),
getBlueprintComposes: builder.query<
GetBlueprintComposesApiResponse,
GetBlueprintComposesApiArg
>({
queryFn: async (queryArgs) => {
try {
const composes = await readComposes(queryArgs.id);
return {
data: {
meta: {
count: composes.length,
},
links: {
first: composes.length > 0 ? composes[0].id : '',
last:
composes.length > 0 ? composes[composes.length - 1].id : '',
},
data: composes,
},
};
} catch (error) {
return { error };
}
},
}),
getComposeStatus: builder.query<
GetComposeStatusApiResponse,
GetComposeStatusApiArg
>({
queryFn: async (queryArg, _, __, baseQuery) => {
try {
const resp = await baseQuery({
url: `/composes/${queryArg.composeId}`,
method: 'GET',
});
const blueprintsDir = await getBlueprintsPath();
const info = await fsinfo(blueprintsDir, ['entries'], {
superuser: 'try',
});
const entries = Object.entries(info?.entries || {});
for (const bpEntry of entries) {
const request = await cockpit
.file(path.join(blueprintsDir, bpEntry[0], queryArg.composeId))
.read();
return {
data: {
image_status: resp.data?.image_status,
request: JSON.parse(request),
},
};
}
return {
data: {
image_status: '',
request: {},
},
};
} catch (error) {
return { error };
}
},
}),
};
},
// since we are inheriting some endpoints,
// we want to make sure that we don't override
// any existing endpoints.
overrideExisting: 'throw',
});
export const {
useGetArchitecturesQuery,
useGetBlueprintQuery,
useGetBlueprintsQuery,
useLazyGetBlueprintsQuery,
useCreateBlueprintMutation,
useUpdateBlueprintMutation,
useDeleteBlueprintMutation,
useGetOscapProfilesQuery,
useGetOscapCustomizationsQuery,
useLazyGetOscapCustomizationsQuery,
useComposeBlueprintMutation,
useGetComposesQuery,
useGetBlueprintComposesQuery,
useGetComposeStatusQuery,
} = cockpitApi;