From 3650820b8e95fa463f3b5ed36d164aa351285f3f Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Tue, 21 Jan 2025 12:09:05 +0100 Subject: [PATCH 01/10] constants: use .cache for blueprints `.cache` is for writing, `.local/share` is technically static data. --- src/constants.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/constants.ts b/src/constants.ts index af41711c4..3da6cbcf1 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -277,5 +277,4 @@ export const PAGINATION_LIMIT = 10; export const PAGINATION_COUNT = 0; export const SEARCH_INPUT = ''; -export const BLUEPRINTS_DIR = - '.local/share/cockpit/cockpit-image-builder/blueprints'; +export const BLUEPRINTS_DIR = '.cache/cockpit-image-builder/'; From e30b3538e7b83592c850648ab2580c175ceab356 Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Tue, 21 Jan 2025 12:11:02 +0100 Subject: [PATCH 02/10] store/cockpitApi: support building images from blueprint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structure of the local cache is now: ``` └── blueprint └── blueprint.json └── image1 └── image2 └── blueprint2 └── blueprint2.json └── image1 ``` Building an image reads the blueprint, and creates a new image file under the relevant blueprint folder, which contains the image request. The image request that's sent off to composer and the request that's saved differs slightly in the upload structures. --- .../helpers/onPremToHostedBlueprintMapper.tsx | 123 +++++++++++++++++- src/store/backendApi.ts | 4 + src/store/cockpitApi.ts | 91 ++++++++++++- 3 files changed, 209 insertions(+), 9 deletions(-) diff --git a/src/Components/Blueprints/helpers/onPremToHostedBlueprintMapper.tsx b/src/Components/Blueprints/helpers/onPremToHostedBlueprintMapper.tsx index 993c0a506..aa3ab462c 100644 --- a/src/Components/Blueprints/helpers/onPremToHostedBlueprintMapper.tsx +++ b/src/Components/Blueprints/helpers/onPremToHostedBlueprintMapper.tsx @@ -1,6 +1,7 @@ import { BlueprintExportResponse, Container, + CreateBlueprintRequest, Directory, Distributions, Fdo, @@ -20,7 +21,7 @@ export type BlueprintOnPrem = { description?: string; packages?: PackagesOnPrem[]; groups?: GroupsPackagesOnPrem[]; - distro: Distributions; + distro?: Distributions; customizations?: CustomizationsOnPrem; containers?: Container[]; }; @@ -85,7 +86,7 @@ export type UserOnPrem = { export type GroupOnPrem = { name: string; - gid: number; + gid: number | undefined; }; export type SshKeyOnPrem = { @@ -115,7 +116,7 @@ export const mapOnPremToHosted = ( return { name: blueprint.name, description: blueprint.description || '', - distribution: blueprint.distro, + distribution: blueprint.distro!, customizations: { ...blueprint.customizations, containers: blueprint.containers, @@ -176,3 +177,119 @@ export const mapOnPremToHosted = ( }, }; }; + +export const mapHostedToOnPrem = ( + blueprint: CreateBlueprintRequest +): BlueprintOnPrem => { + const result: BlueprintOnPrem = { + name: blueprint.name, + customizations: {}, + }; + + if (blueprint.customizations?.packages) { + result.packages = blueprint.customizations.packages.map((pkg) => { + return { + name: pkg, + version: '*', + }; + }); + } + + if (blueprint.customizations?.containers) { + result.containers = blueprint.customizations.containers; + } + + if (blueprint.customizations?.directories) { + result.customizations!.directories = blueprint.customizations.directories; + } + + if (blueprint.customizations?.files) { + result.customizations!.files = blueprint.customizations.files; + } + + if (blueprint.customizations?.openscap) { + result.customizations!.openscap = blueprint.customizations.openscap; + } + + if (blueprint.customizations?.filesystem) { + result.customizations!.filesystem = blueprint.customizations.filesystem.map( + (fs) => { + return { + mountpoint: fs.mountpoint, + minsize: fs.min_size, + }; + } + ); + } + + if (blueprint.customizations?.users) { + result.customizations!.user = blueprint.customizations.users.map((u) => { + return { + name: u.name, + key: u.ssh_key || '', + }; + }); + } + + if (blueprint.customizations?.services) { + result.customizations!.services = blueprint.customizations.services; + } + + if (blueprint.customizations?.hostname) { + result.customizations!.hostname = blueprint.customizations.hostname; + } + + if (blueprint.customizations?.kernel) { + result.customizations!.kernel = blueprint.customizations.kernel; + } + + if (blueprint.customizations?.groups) { + result.customizations!.groups = blueprint.customizations.groups.map((g) => { + return { + name: g.name, + gid: g.gid, + }; + }); + } + + if (blueprint.customizations?.timezone) { + result.customizations!.timezone = blueprint.customizations.timezone; + } + + if (blueprint.customizations?.locale) { + result.customizations!.locale = blueprint.customizations.locale; + } + + if (blueprint.customizations?.firewall) { + result.customizations!.firewall = blueprint.customizations.firewall; + } + + if (blueprint.customizations?.installation_device) { + result.customizations!.installation_device = + blueprint.customizations.installation_device; + } + + if (blueprint.customizations?.fdo) { + result.customizations!.fdo = blueprint.customizations.fdo; + } + + if (blueprint.customizations?.ignition) { + result.customizations!.ignition = blueprint.customizations.ignition; + } + + if (blueprint.customizations?.partitioning_mode) { + result.customizations!.partitioning_mode = + blueprint.customizations.partitioning_mode; + } + + if (blueprint.customizations?.fips) { + result.customizations!.fips = + blueprint.customizations.fips?.enabled || false; + } + + if (blueprint.customizations?.installer) { + result.customizations!.installer = blueprint.customizations.installer; + } + + return result; +}; diff --git a/src/store/backendApi.ts b/src/store/backendApi.ts index aae3f9f80..e21030805 100644 --- a/src/store/backendApi.ts +++ b/src/store/backendApi.ts @@ -36,6 +36,10 @@ export const useListSnapshotsByDateMutation = process.env.IS_ON_PREMISE ? cockpitQueries.useListSnapshotsByDateMutation : useContentSourcesListSnapshotsByDateMutation; +export const useComposeBlueprintMutation = process.env.IS_ON_PREMISE + ? cockpitQueries.useComposeBlueprintMutation + : imageBuilderQueries.useComposeBlueprintMutation; + export const useBackendPrefetch = process.env.IS_ON_PREMISE ? cockpitApi.usePrefetch : imageBuilderApi.usePrefetch; diff --git a/src/store/cockpitApi.ts b/src/store/cockpitApi.ts index ede0ba044..35c7db613 100644 --- a/src/store/cockpitApi.ts +++ b/src/store/cockpitApi.ts @@ -8,7 +8,6 @@ import path from 'path'; // 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 { ListSnapshotsByDateApiArg, @@ -16,6 +15,9 @@ import { } from './contentSourcesApi'; import { emptyCockpitApi } from './emptyCockpitApi'; import { + ComposeBlueprintApiResponse, + ComposeBlueprintApiArg, + CreateBlueprintRequest, GetArchitecturesApiResponse, GetArchitecturesApiArg, GetBlueprintsApiArg, @@ -29,9 +31,10 @@ import { GetBlueprintApiArg, CreateBlueprintApiResponse, CreateBlueprintApiArg, + ComposeResponse, } from './imageBuilderApi'; -import { mapOnPremToHosted } from '../Components/Blueprints/helpers/onPremToHostedBlueprintMapper'; +import { mapHostedToOnPrem } from '../Components/Blueprints/helpers/onPremToHostedBlueprintMapper'; import { BLUEPRINTS_DIR } from '../constants'; const getBlueprintsPath = async () => { @@ -125,16 +128,17 @@ export const cockpitApi = emptyCockpitApi.injectEndpoints({ const entries = Object.entries(info?.entries || {}); let blueprints: BlueprintItem[] = await Promise.all( entries.map(async ([filename]) => { - const file = cockpit.file(path.join(blueprintsDir, filename)); + const file = cockpit.file( + path.join(blueprintsDir, filename, `${filename}.json`) + ); const contents = await file.read(); - const parsed = toml.parse(contents); + const parsed = JSON.parse(contents); file.close(); - const blueprint = mapOnPremToHosted(parsed); const version = (parsed.version as number) ?? 1; return { - ...blueprint, + ...parsed, id: filename as string, version: version, last_modified_at: Date.now().toString(), @@ -249,6 +253,80 @@ export const cockpitApi = emptyCockpitApi.injectEndpoints({ }, }), }), + composeBlueprint: builder.mutation< + ComposeBlueprintApiResponse, + ComposeBlueprintApiArg + >({ + queryFn: async ({ id: filename }) => { + 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 cloudapi = cockpit.http('/run/cloudapi/api.socket', { + superuser: 'try', + }); + + 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 resp = await cloudapi.post( + '/api/image-builder-composer/v2/compose', + composeReq, + { + 'content-type': 'application/json', + } + ); + const composeResp = JSON.parse(resp); + await cockpit + .file(path.join(blueprintsDir, filename, composeResp.id)) + .replace(JSON.stringify(saveReq)); + composes.push({ id: composeResp.id }); + } + return { + data: composes, + }; + } catch (error) { + return { error }; + } + }, + }), }; }, }); @@ -262,4 +340,5 @@ export const { useDeleteBlueprintMutation, useGetOscapProfilesQuery, useListSnapshotsByDateMutation, + useComposeBlueprintMutation, } = cockpitApi; From cb0d04a09adba8b234dd4894e0a5b2a0457a38c1 Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Tue, 21 Jan 2025 14:40:33 +0100 Subject: [PATCH 03/10] Blueprints/BuildImagesButton: switch to mixed api The mixed api supports both the service and on-prem. --- src/Components/Blueprints/BuildImagesButton.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Components/Blueprints/BuildImagesButton.tsx b/src/Components/Blueprints/BuildImagesButton.tsx index c486322a8..9cd7b5c83 100644 --- a/src/Components/Blueprints/BuildImagesButton.tsx +++ b/src/Components/Blueprints/BuildImagesButton.tsx @@ -19,13 +19,13 @@ import { addNotification } from '@redhat-cloud-services/frontend-components-noti import { skipToken } from '@reduxjs/toolkit/query'; import { targetOptions } from '../../constants'; -import { useGetBlueprintQuery } from '../../store/backendApi'; -import { selectSelectedBlueprintId } from '../../store/BlueprintSlice'; -import { useAppDispatch, useAppSelector } from '../../store/hooks'; import { - ImageTypes, + useGetBlueprintQuery, useComposeBlueprintMutation, -} from '../../store/imageBuilderApi'; +} from '../../store/backendApi'; +import { selectSelectedBlueprintId } from '../../store/BlueprintSlice'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { ImageTypes } from '../../store/imageBuilderApi'; type BuildImagesButtonPropTypes = { // default children is 'Build images' From b14bfe7eb3411dc3cd2257f4455afdb4e1fe0db3 Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Wed, 22 Jan 2025 13:18:37 +0100 Subject: [PATCH 04/10] store/cockpitApi: support getting composes Reads all of the image entries under the cache. --- src/store/backendApi.ts | 8 ++++ src/store/cockpitApi.ts | 93 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/src/store/backendApi.ts b/src/store/backendApi.ts index e21030805..3abb6c0f2 100644 --- a/src/store/backendApi.ts +++ b/src/store/backendApi.ts @@ -40,6 +40,14 @@ export const useComposeBlueprintMutation = process.env.IS_ON_PREMISE ? cockpitQueries.useComposeBlueprintMutation : imageBuilderQueries.useComposeBlueprintMutation; +export const useGetComposesQuery = process.env.IS_ON_PREMISE + ? cockpitQueries.useGetComposesQuery + : imageBuilderQueries.useGetComposesQuery; + +export const useGetBlueprintComposesQuery = process.env.IS_ON_PREMISE + ? cockpitQueries.useGetBlueprintComposesQuery + : imageBuilderQueries.useGetBlueprintComposesQuery; + export const useBackendPrefetch = process.env.IS_ON_PREMISE ? cockpitApi.usePrefetch : imageBuilderApi.usePrefetch; diff --git a/src/store/cockpitApi.ts b/src/store/cockpitApi.ts index 35c7db613..801a6c80a 100644 --- a/src/store/cockpitApi.ts +++ b/src/store/cockpitApi.ts @@ -18,10 +18,15 @@ import { ComposeBlueprintApiResponse, ComposeBlueprintApiArg, CreateBlueprintRequest, + ComposesResponseItem, GetArchitecturesApiResponse, GetArchitecturesApiArg, GetBlueprintsApiArg, GetBlueprintsApiResponse, + GetBlueprintComposesApiArg, + GetBlueprintComposesApiResponse, + GetComposesApiArg, + GetComposesApiResponse, DeleteBlueprintApiResponse, DeleteBlueprintApiArg, BlueprintItem, @@ -45,6 +50,37 @@ const getBlueprintsPath = async () => { 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 = emptyCockpitApi.injectEndpoints({ endpoints: (builder) => { return { @@ -327,6 +363,61 @@ export const cockpitApi = emptyCockpitApi.injectEndpoints({ } }, }), + 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 }; + } + }, + }), }; }, }); @@ -341,4 +432,6 @@ export const { useGetOscapProfilesQuery, useListSnapshotsByDateMutation, useComposeBlueprintMutation, + useGetComposesQuery, + useGetBlueprintComposesQuery, } = cockpitApi; From b5e4c8d34f32841022b2257bcd503ecc58ff93fd Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Wed, 22 Jan 2025 13:19:25 +0100 Subject: [PATCH 05/10] ImagesTable: switch to mixed api for getting composes --- src/Components/ImagesTable/ImagesTable.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/Components/ImagesTable/ImagesTable.tsx b/src/Components/ImagesTable/ImagesTable.tsx index 8e9f8ffcf..a105155b2 100644 --- a/src/Components/ImagesTable/ImagesTable.tsx +++ b/src/Components/ImagesTable/ImagesTable.tsx @@ -49,7 +49,11 @@ import { SEARCH_INPUT, STATUS_POLLING_INTERVAL, } from '../../constants'; -import { useGetBlueprintsQuery } from '../../store/backendApi'; +import { + useGetComposesQuery, + useGetBlueprintsQuery, + useGetBlueprintComposesQuery, +} from '../../store/backendApi'; import { selectBlueprintSearchInput, selectBlueprintVersionFilter, @@ -66,8 +70,6 @@ import { ComposeStatus, GetBlueprintComposesApiArg, GetBlueprintsApiArg, - useGetBlueprintComposesQuery, - useGetComposesQuery, useGetComposeStatusQuery, } from '../../store/imageBuilderApi'; import { resolveRelPath } from '../../Utilities/path'; @@ -298,7 +300,8 @@ const ImagesTableRow = ({ compose, rowIndex }: ImagesTableRowPropTypes) => { } }, [setPollingInterval, composeStatus]); - const type = compose.request.image_requests[0].upload_request.type; + const type = + compose.request?.image_requests[0]?.upload_request?.type || 'local'; switch (type) { case 'aws': @@ -317,6 +320,8 @@ const ImagesTableRow = ({ compose, rowIndex }: ImagesTableRowPropTypes) => { return ; case 'aws.s3': return ; + case 'local': + return ; } }; From edc67f47e2854771a2e005b846e8918dd9ae776b Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Thu, 23 Jan 2025 13:18:32 +0100 Subject: [PATCH 06/10] store/cockpitApi: compose status support Finds the relevant compose, asks for its status and reads the original compose request from disk. --- src/Components/ImagesTable/ImageDetails.tsx | 35 +++++++++++++++- src/Components/ImagesTable/ImagesTable.tsx | 39 ++++++++++++++---- src/Components/ImagesTable/Instance.tsx | 30 +++++++++++++- src/Components/ImagesTable/Status.tsx | 29 +++++++++++++- src/store/backendApi.ts | 4 ++ src/store/cockpitApi.ts | 44 +++++++++++++++++++++ 6 files changed, 171 insertions(+), 10 deletions(-) diff --git a/src/Components/ImagesTable/ImageDetails.tsx b/src/Components/ImagesTable/ImageDetails.tsx index f735be8d5..6d051e385 100644 --- a/src/Components/ImagesTable/ImageDetails.tsx +++ b/src/Components/ImagesTable/ImageDetails.tsx @@ -15,11 +15,11 @@ import { ExternalLinkAltIcon } from '@patternfly/react-icons'; import ClonesTable from './ClonesTable'; +import { useGetComposeStatusQuery } from '../../store/backendApi'; import { extractProvisioningList } from '../../store/helpers'; import { ComposesResponseItem, GcpUploadRequestOptions, - useGetComposeStatusQuery, } from '../../store/imageBuilderApi'; import { useGetSourceListQuery } from '../../store/provisioningApi'; import { @@ -478,3 +478,36 @@ export const AwsS3Details = ({ compose }: AwsS3DetailsPropTypes) => { ); }; + +type LocalDetailsPropTypes = { + compose: ComposesResponseItem; +}; + +export const LocalDetails = ({ compose }: LocalDetailsPropTypes) => { + return ( + <> +
+ Build Information +
+ + + UUID + + + {compose.id} + + + Architecture + + {compose.request.image_requests[0].architecture} + + + + + ); +}; diff --git a/src/Components/ImagesTable/ImagesTable.tsx b/src/Components/ImagesTable/ImagesTable.tsx index a105155b2..0221ac84d 100644 --- a/src/Components/ImagesTable/ImagesTable.tsx +++ b/src/Components/ImagesTable/ImagesTable.tsx @@ -33,12 +33,18 @@ import { AwsS3Details, AzureDetails, GcpDetails, + LocalDetails, OciDetails, } from './ImageDetails'; import ImagesTableToolbar from './ImagesTableToolbar'; -import { AwsS3Instance, CloudInstance, OciInstance } from './Instance'; +import { + AwsS3Instance, + CloudInstance, + OciInstance, + LocalInstance, +} from './Instance'; import Release from './Release'; -import { ExpiringStatus, CloudStatus } from './Status'; +import { ExpiringStatus, CloudStatus, LocalStatus } from './Status'; import { AwsTarget, Target } from './Target'; import { @@ -50,6 +56,7 @@ import { STATUS_POLLING_INTERVAL, } from '../../constants'; import { + useGetComposeStatusQuery, useGetComposesQuery, useGetBlueprintsQuery, useGetBlueprintComposesQuery, @@ -70,7 +77,6 @@ import { ComposeStatus, GetBlueprintComposesApiArg, GetBlueprintsApiArg, - useGetComposeStatusQuery, } from '../../store/imageBuilderApi'; import { resolveRelPath } from '../../Utilities/path'; import { @@ -300,10 +306,9 @@ const ImagesTableRow = ({ compose, rowIndex }: ImagesTableRowPropTypes) => { } }, [setPollingInterval, composeStatus]); - const type = - compose.request?.image_requests[0]?.upload_request?.type || 'local'; + const type = compose.request?.image_requests[0]?.upload_request?.type; - switch (type) { + switch (type as string) { case 'aws': return ( { case 'aws.s3': return ; case 'local': - return ; + return ; } }; @@ -463,6 +468,26 @@ const AwsRow = ({ compose, composeStatus, rowIndex }: AwsRowPropTypes) => { ); }; +type LocalRowPropTypes = { + compose: ComposesResponseItem; + rowIndex: number; +}; + +const LocalRow = ({ compose, rowIndex }: LocalRowPropTypes) => { + const details = ; + const instance = ; + const status = ; + return ( + + ); +}; + type RowPropTypes = { compose: ComposesResponseItem; rowIndex: number; diff --git a/src/Components/ImagesTable/Instance.tsx b/src/Components/ImagesTable/Instance.tsx index 24f1cc921..ddccbe928 100644 --- a/src/Components/ImagesTable/Instance.tsx +++ b/src/Components/ImagesTable/Instance.tsx @@ -27,6 +27,7 @@ import { SEARCH_INPUT, } from '../../constants'; import { useGetBlueprintsQuery } from '../../store/backendApi'; +import { useGetComposeStatusQuery } from '../../store/backendApi'; import { selectSelectedBlueprintId, selectBlueprintSearchInput, @@ -37,7 +38,6 @@ import { ComposesResponseItem, ComposeStatus, ImageTypes, - useGetComposeStatusQuery, } from '../../store/imageBuilderApi'; import { isAwsUploadRequestOptions, @@ -403,3 +403,31 @@ export const AwsS3Instance = ({ ); } }; + +type LocalInstancePropTypes = { + compose: ComposesResponseItem; +}; + +export const LocalInstance = ({ compose }: LocalInstancePropTypes) => { + const { data: composeStatus, isSuccess } = useGetComposeStatusQuery({ + composeId: compose.id, + }); + if (!isSuccess) { + return ; + } + + // Hacky to define the type here, but local upload is not available in + // the image builder api, only in the composer api. + type LocalUploadStatusOptions = { + filename: string; + }; + const status = composeStatus?.image_status.status; + const options = composeStatus?.image_status.upload_status + ?.options as unknown as LocalUploadStatusOptions; + + if (status !== 'success') { + return <>; + } + + return
Filepath to disk: {options.filename}
; +}; diff --git a/src/Components/ImagesTable/Status.tsx b/src/Components/ImagesTable/Status.tsx index 454398fcf..82faf4892 100644 --- a/src/Components/ImagesTable/Status.tsx +++ b/src/Components/ImagesTable/Status.tsx @@ -27,13 +27,13 @@ import { AWS_S3_EXPIRATION_TIME_IN_HOURS, OCI_STORAGE_EXPIRATION_TIME_IN_DAYS, } from '../../constants'; +import { useGetComposeStatusQuery } from '../../store/backendApi'; import { ClonesResponseItem, ComposeStatus, ComposeStatusError, ComposesResponseItem, UploadStatus, - useGetComposeStatusQuery, } from '../../store/imageBuilderApi'; type StatusClonePropTypes = { @@ -213,6 +213,33 @@ export const ExpiringStatus = ({ } }; +type LocalStatusPropTypes = { + compose: ComposesResponseItem; +}; + +export const LocalStatus = ({ compose }: LocalStatusPropTypes) => { + const { data: composeStatus, isSuccess } = useGetComposeStatusQuery({ + composeId: compose.id, + }); + + if (!isSuccess) { + return ; + } + + const status = composeStatus?.image_status.status || 'failure'; + + if (status === 'failure') { + return ( + + ); + } + return ; +}; + const statuses = { failure: { icon: , diff --git a/src/store/backendApi.ts b/src/store/backendApi.ts index 3abb6c0f2..792ec36fc 100644 --- a/src/store/backendApi.ts +++ b/src/store/backendApi.ts @@ -48,6 +48,10 @@ export const useGetBlueprintComposesQuery = process.env.IS_ON_PREMISE ? cockpitQueries.useGetBlueprintComposesQuery : imageBuilderQueries.useGetBlueprintComposesQuery; +export const useGetComposeStatusQuery = process.env.IS_ON_PREMISE + ? cockpitQueries.useGetComposeStatusQuery + : imageBuilderQueries.useGetComposeStatusQuery; + export const useBackendPrefetch = process.env.IS_ON_PREMISE ? cockpitApi.usePrefetch : imageBuilderApi.usePrefetch; diff --git a/src/store/cockpitApi.ts b/src/store/cockpitApi.ts index 801a6c80a..9dccb41c4 100644 --- a/src/store/cockpitApi.ts +++ b/src/store/cockpitApi.ts @@ -27,6 +27,8 @@ import { GetBlueprintComposesApiResponse, GetComposesApiArg, GetComposesApiResponse, + GetComposeStatusApiArg, + GetComposeStatusApiResponse, DeleteBlueprintApiResponse, DeleteBlueprintApiArg, BlueprintItem, @@ -418,6 +420,47 @@ export const cockpitApi = emptyCockpitApi.injectEndpoints({ } }, }), + getComposeStatus: builder.query< + GetComposeStatusApiResponse, + GetComposeStatusApiArg + >({ + queryFn: async (queryArg) => { + try { + const cloudapi = cockpit.http('/run/cloudapi/api.socket', { + superuser: 'require', + }); + const resp = JSON.parse( + await cloudapi.get( + `/api/image-builder-composer/v2/composes/${queryArg.composeId}` + ) + ); + 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.image_status, + request: JSON.parse(request), + }, + }; + } + return { + data: { + image_status: '', + request: {}, + }, + }; + } catch (error) { + return { error }; + } + }, + }), }; }, }); @@ -434,4 +477,5 @@ export const { useComposeBlueprintMutation, useGetComposesQuery, useGetBlueprintComposesQuery, + useGetComposeStatusQuery, } = cockpitApi; From 04b3820c4a7a944db31fc1a8b81579dfa49b9fe3 Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Thu, 23 Jan 2025 16:27:09 +0100 Subject: [PATCH 07/10] src/test/mocks: add cockpit mocks Adds `mtime` tot fsinfo, and `replace` to `cockpit.file`. --- src/test/mocks/cockpit/fsinfo.ts | 2 ++ src/test/mocks/cockpit/index.ts | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/test/mocks/cockpit/fsinfo.ts b/src/test/mocks/cockpit/fsinfo.ts index 167790389..856c39abc 100644 --- a/src/test/mocks/cockpit/fsinfo.ts +++ b/src/test/mocks/cockpit/fsinfo.ts @@ -2,6 +2,7 @@ type fileinfo = { entries?: Record; + mtime: number; }; export const fsinfo = ( @@ -12,6 +13,7 @@ export const fsinfo = ( return new Promise((resolve) => { resolve({ entries: {}, + mtime: 1, }); }); }; diff --git a/src/test/mocks/cockpit/index.ts b/src/test/mocks/cockpit/index.ts index 01ade2495..c0c00749f 100644 --- a/src/test/mocks/cockpit/index.ts +++ b/src/test/mocks/cockpit/index.ts @@ -20,6 +20,7 @@ export default { }); }, close: () => {}, + replace: (contents: string) => {}, }; }, spawn: (args: string[], attributes: object): Promise => { @@ -27,4 +28,14 @@ export default { resolve(''); }); }, + http: (address: string, options: object) => { + return { + get: (path?: string, headers?: object): string => { + return ''; + }, + post: (path: string, data: object, headers?: object): string => { + return ''; + }, + }; + }, }; From 1919fb8b51f13777303f68e42425eed3b5b88776 Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Fri, 24 Jan 2025 13:45:23 +0100 Subject: [PATCH 08/10] store/cockpitApi: invalidate cache after building an image With the custom query functions the cached data from getComposes and getBlueprintComposes doesn't get invalidated. Luckily RTKQuery provides a mechanism to invalidate the cache after a mutation. --- src/store/emptyCockpitApi.ts | 1 + src/store/enhancedCockpitApi.ts | 11 ++++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/store/emptyCockpitApi.ts b/src/store/emptyCockpitApi.ts index a499f468e..b9b041977 100644 --- a/src/store/emptyCockpitApi.ts +++ b/src/store/emptyCockpitApi.ts @@ -3,5 +3,6 @@ import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; export const emptyCockpitApi = createApi({ reducerPath: 'cockpitApi', baseQuery: fetchBaseQuery({ baseUrl: '/api' }), + tagTypes: ['Composes'], endpoints: () => ({}), }); diff --git a/src/store/enhancedCockpitApi.ts b/src/store/enhancedCockpitApi.ts index 044e6cb11..8303cb7fb 100644 --- a/src/store/enhancedCockpitApi.ts +++ b/src/store/enhancedCockpitApi.ts @@ -4,7 +4,7 @@ import { cockpitApi } from './cockpitApi'; import { errorMessage } from './enhancedImageBuilderApi'; const enhancedApi = cockpitApi.enhanceEndpoints({ - addTagTypes: ['Blueprints'], + addTagTypes: ['Blueprints', 'Composes'], endpoints: { getBlueprints: { providesTags: () => { @@ -36,6 +36,15 @@ const enhancedApi = cockpitApi.enhanceEndpoints({ }); }, }, + composeBlueprint: { + invalidatesTags: [{ type: 'Composes' }], + }, + getComposes: { + providesTags: [{ type: 'Composes' }], + }, + getBlueprintComposes: { + providesTags: [{ type: 'Composes' }], + }, }, }); From 65f824b75cc9d66985112640f33f198b9eabe56a Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Tue, 28 Jan 2025 13:47:29 +0100 Subject: [PATCH 09/10] store/cockpitApi: write blueprints to disk --- src/store/cockpitApi.ts | 41 +++++++++++++++++---------------- src/store/enhancedCockpitApi.ts | 3 +++ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/store/cockpitApi.ts b/src/store/cockpitApi.ts index 9dccb41c4..a7e8bd40c 100644 --- a/src/store/cockpitApi.ts +++ b/src/store/cockpitApi.ts @@ -8,6 +8,7 @@ import path from 'path'; // We also needed to create an alias in vitest to make this work. import cockpit from 'cockpit'; import { fsinfo } from 'cockpit/fsinfo'; +import { v4 as uuidv4 } from 'uuid'; import { ListSnapshotsByDateApiArg, @@ -125,21 +126,18 @@ export const cockpitApi = emptyCockpitApi.injectEndpoints({ queryFn: async ({ id, version }) => { try { const blueprintsDir = await getBlueprintsPath(); - const file = cockpit.file(path.join(blueprintsDir, id)); - - const contents = await file.read(); - const parsed = toml.parse(contents); - file.close(); - - const blueprint = mapOnPremToHosted(parsed); - + 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: { - ...blueprint, + ...parsed, id, - version, - last_modified_at: Date.now().toString(), - image_requests: [], + version: version, + last_modified_at: new Date(bpInfo!.mtime * 1000).toString(), }, }; } catch (error) { @@ -169,16 +167,12 @@ export const cockpitApi = emptyCockpitApi.injectEndpoints({ const file = cockpit.file( path.join(blueprintsDir, filename, `${filename}.json`) ); - const contents = await file.read(); const parsed = JSON.parse(contents); - file.close(); - - const version = (parsed.version as number) ?? 1; return { ...parsed, id: filename as string, - version: version, + version: 1, last_modified_at: Date.now().toString(), }; }) @@ -231,12 +225,19 @@ export const cockpitApi = emptyCockpitApi.injectEndpoints({ CreateBlueprintApiResponse, CreateBlueprintApiArg >({ - queryFn: async () => { - // TODO: actually save the result to file + queryFn: async ({ createBlueprintRequest: blueprintReq }) => { try { + const id = uuidv4(); + const blueprintsDir = await getBlueprintsPath(); + await cockpit.spawn(['mkdir', id], { + directory: blueprintsDir, + }); + await cockpit + .file(path.join(blueprintsDir, id, `${id}.json`)) + .replace(JSON.stringify(blueprintReq)); return { data: { - id: '', + id: id, }, }; } catch (error) { diff --git a/src/store/enhancedCockpitApi.ts b/src/store/enhancedCockpitApi.ts index 8303cb7fb..2ccdcd063 100644 --- a/src/store/enhancedCockpitApi.ts +++ b/src/store/enhancedCockpitApi.ts @@ -11,6 +11,9 @@ const enhancedApi = cockpitApi.enhanceEndpoints({ return [{ type: 'Blueprints' }]; }, }, + createBlueprint: { + invalidatesTags: [{ type: 'Blueprints' }], + }, deleteBlueprint: { invalidatesTags: [{ type: 'Blueprints' }], onQueryStarted: async (_, { dispatch, queryFulfilled }) => { From df0fbcc35c7bb772c8c448f12bb5a59d0d4fa7b9 Mon Sep 17 00:00:00 2001 From: Sanne Raymaekers Date: Tue, 28 Jan 2025 18:51:24 +0100 Subject: [PATCH 10/10] store/cockpitApi: hide cloud targets for now --- src/store/cockpitApi.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/store/cockpitApi.ts b/src/store/cockpitApi.ts index a7e8bd40c..0539d3f15 100644 --- a/src/store/cockpitApi.ts +++ b/src/store/cockpitApi.ts @@ -99,15 +99,12 @@ export const cockpitApi = emptyCockpitApi.injectEndpoints({ data: [ { arch: 'aarch64', - image_types: ['aws', 'guest-image', 'image-installer'], + image_types: ['guest-image', 'image-installer'], repositories: [], }, { arch: 'x86_64', image_types: [ - 'aws', - 'gcp', - 'azure', 'rhel-edge-commit', 'rhel-edge-installer', 'edge-commit', @@ -115,6 +112,7 @@ export const cockpitApi = emptyCockpitApi.injectEndpoints({ 'guest-image', 'image-installer', 'vsphere', + 'vsphere-ova', ], repositories: [], },