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({ 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({ 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;