store/cockpitApi: support building images from blueprint

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.
This commit is contained in:
Sanne Raymaekers 2025-01-21 12:11:02 +01:00
parent e4538826fd
commit d13276eeee
3 changed files with 209 additions and 9 deletions

View file

@ -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;
};

View file

@ -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;

View file

@ -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;