From 56ffd0724c68b02dba18b052e5ec76d43718c6a7 Mon Sep 17 00:00:00 2001 From: Charlie Drage Date: Mon, 18 Nov 2024 15:39:48 -0500 Subject: [PATCH] feat: add build config configurator ### What does this PR do? * Adds automated way to create a build config json file * Added to build page ### Screenshot / video of UI ### What issues does this PR fix or reference? Closes https://github.com/podman-desktop/extension-bootc/issues/827 ### How to test this PR? Use build config and configure / create your own custom build config with users, filesystem, etc. --- packages/backend/src/build-disk-image.spec.ts | 93 +++++++- packages/backend/src/build-disk-image.ts | 43 +++- packages/frontend/src/Build.svelte | 212 ++++++++++++++++-- packages/shared/src/models/bootc.ts | 30 +++ 4 files changed, 363 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/build-disk-image.spec.ts b/packages/backend/src/build-disk-image.spec.ts index 204b9702..f72f14ff 100644 --- a/packages/backend/src/build-disk-image.spec.ts +++ b/packages/backend/src/build-disk-image.spec.ts @@ -24,11 +24,12 @@ import { createPodmanCLIRunCommand, getBuilder, getUnusedName, + createBuildConfigJSON, } from './build-disk-image'; import { bootcImageBuilderCentos, bootcImageBuilderRHEL } from './constants'; import type { ContainerInfo, Configuration } from '@podman-desktop/api'; import { containerEngine } from '@podman-desktop/api'; -import type { BootcBuildInfo } from '/@shared/src/models/bootc'; +import type { BootcBuildInfo, BuildConfig } from '/@shared/src/models/bootc'; import * as fs from 'node:fs'; import path, { resolve } from 'node:path'; @@ -470,3 +471,93 @@ test('test chown works when passed into createBuilderImageOptions', async () => expect(options.Cmd).toContain('--chown'); expect(options.Cmd).toContain(build.chown); }); + +test('test createBuildConfigJSON function works when passing in a build config with user, filesystem and kernel', async () => { + const buildConfig = { + user: [ + { + name: 'test-user', + // eslint-disable-next-line sonarjs/no-hardcoded-credentials + password: 'test-password', + key: 'test-key', + groups: ['test-group'], + }, + ], + filesystem: [ + { + mountpoint: '/test/mountpoint', + minsize: '1GB', + }, + ], + kernel: { + append: 'test-append', + }, + } as BuildConfig; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const buildConfigJson: Record = createBuildConfigJSON(buildConfig); + expect(buildConfigJson).toBeDefined(); + + // buildConfigJson is Record, but check that the first one is 'customnizations' + const keys = Object.keys(buildConfigJson); + expect(keys[0]).toEqual('customizations'); + + // Check that user, filesystem and kernel are included in the JSON + expect(buildConfigJson.customizations).toBeDefined(); + expect(buildConfigJson?.customizations?.user[0]).toBeDefined(); + expect(buildConfigJson?.customizations?.filesystem[0]).toBeDefined(); + expect(buildConfigJson?.customizations?.kernel).toBeDefined(); +}); + +test('test building with a buildConfig JSON file that a temporary file for buildconfig is passed to binds', async () => { + const buildConfig = { + user: [ + { + name: 'test-user', + // eslint-disable-next-line sonarjs/no-hardcoded-credentials + password: 'test-password', + key: 'test-key', + groups: ['test-group'], + }, + ], + filesystem: [ + { + mountpoint: '/test/mountpoint', + minsize: '1GB', + }, + ], + kernel: { + append: 'test-append', + }, + } as BuildConfig; + + const name = 'test123-bootc-image-builder'; + const build = { + image: 'test-image', + tag: 'latest', + type: ['raw'], + arch: 'amd64', + folder: '/foo/bar/qemutest4', + buildConfig: buildConfig, + } as BootcBuildInfo; + + // Spy on fs.writeFileSync to make sure it is called + vi.mock('node:fs'); + vi.spyOn(fs, 'writeFileSync'); + + const options = createBuilderImageOptions(name, build); + + // Expect writeFileSync was called + expect(fs.writeFileSync).toHaveBeenCalled(); + + // Expect that options.HostConfig.Binds includes a buildconfig file + expect(options).toBeDefined(); + expect(options.HostConfig).toBeDefined(); + expect(options.HostConfig?.Binds).toBeDefined(); + if (options.HostConfig?.Binds) { + expect(options.HostConfig.Binds.length).toEqual(3); + expect(options.HostConfig.Binds[0]).toEqual(build.folder + ':/output/'); + expect(options.HostConfig.Binds[1]).toEqual('/var/lib/containers/storage:/var/lib/containers/storage'); + expect(options.HostConfig.Binds[2]).toContain('config.json:ro'); + } +}); diff --git a/packages/backend/src/build-disk-image.ts b/packages/backend/src/build-disk-image.ts index d0c1d979..48c3a881 100644 --- a/packages/backend/src/build-disk-image.ts +++ b/packages/backend/src/build-disk-image.ts @@ -23,7 +23,7 @@ import path, { resolve } from 'node:path'; import os from 'node:os'; import * as containerUtils from './container-utils'; import { bootcImageBuilder, bootcImageBuilderCentos, bootcImageBuilderRHEL } from './constants'; -import type { BootcBuildInfo, BuildType } from '/@shared/src/models/bootc'; +import type { BootcBuildInfo, BuildConfig, BuildType } from '/@shared/src/models/bootc'; import type { History } from './history'; import * as machineUtils from './machine-utils'; import { getConfigurationValue, telemetryLogger } from './extension'; @@ -465,6 +465,27 @@ export function createBuilderImageOptions( } } + // Check if build.buildConfig has ANYTHING defined, make sure it is not empty. + if (build.buildConfig) { + const buildConfig = createBuildConfigJSON(build.buildConfig); + + // Make sure that cutomizations is exists and is not empty before adding it to the container. + if (buildConfig.customizations && Object.keys(buildConfig.customizations).length > 0) { + // Create a temporary path to store the buildConfig JSON + // with a temporary name + // eslint-disable-next-line sonarjs/pseudo-random + const buildConfigPath = path.join(os.tmpdir(), `${Math.floor(Math.random() * 100000)}.json`); + + // Write the buildConfig JSON to the temporary file with JSON + fs.writeFileSync(buildConfigPath, JSON.stringify(buildConfig, undefined, 2)); + + // Add the mount to the configuration file + if (options.HostConfig?.Binds) { + options.HostConfig.Binds.push(buildConfigPath + ':/config.json:ro'); + } + } + } + // If there is the chown in build, add the --chown flag to the command with the value in chown if (build.chown) { cmd.push('--chown', build.chown); @@ -473,6 +494,26 @@ export function createBuilderImageOptions( return options; } +// Function that takes in BuildConfig and creates a JSON object out of the contents. +// We will then return it as "cutomizations" which is required by bootc-image-builder +export function createBuildConfigJSON(buildConfig: BuildConfig): Record { + const config: Record = {}; + + if (buildConfig.user && buildConfig.user.length > 0) { + config.user = buildConfig.user; + } + + if (buildConfig.filesystem && buildConfig.filesystem.length > 0) { + config.filesystem = buildConfig.filesystem; + } + + if (buildConfig.kernel?.append) { + config.kernel = buildConfig.kernel; + } + + return { customizations: config }; +} + // Creates a command that will be used to build the image on Linux. This includes adding the transfer-to-root script as well as the actual build command. // we also export to the log file during this process too. export function linuxBuildCommand( diff --git a/packages/frontend/src/Build.svelte b/packages/frontend/src/Build.svelte index 0045659b..bbd77d16 100644 --- a/packages/frontend/src/Build.svelte +++ b/packages/frontend/src/Build.svelte @@ -6,9 +6,11 @@ import { faCube, faQuestionCircle, faTriangleExclamation, + faMinusCircle, + faPlusCircle, } from '@fortawesome/free-solid-svg-icons'; import { bootcClient } from './api/client'; -import type { BootcBuildInfo, BuildType } from '/@shared/src/models/bootc'; +import type { BootcBuildInfo, BuildType, BuildConfig } from '/@shared/src/models/bootc'; import Fa from 'svelte-fa'; import { onMount } from 'svelte'; import type { ImageInfo, ManifestInspectInfo } from '@podman-desktop/api'; @@ -60,12 +62,25 @@ let awsAmiName: string = ''; let awsBucket: string = ''; let awsRegion: string = ''; +// Build Config related, we only support one entry for now +let buildConfigUsers: { name: string; password: string; key: string; groups: string }[] = [ + { name: '', password: '', key: '', groups: '' }, +]; +let buildConfigFilesystems: { mountpoint: string; minsize: string }[] = [{ mountpoint: '', minsize: '' }]; +let buildConfigKernelArguments: string; + // Show/hide advanced options let showAdvanced = false; // State to show/hide advanced options function toggleAdvanced() { showAdvanced = !showAdvanced; } +// Show/hide build config options +let showBuildConfig = false; +function toggleBuildConfig() { + showBuildConfig = !showBuildConfig; +} + function findImage(repoTag: string): ImageInfo | undefined { return bootcAvailableImages.find( image => image.RepoTags && image.RepoTags.length > 0 && image.RepoTags[0] === repoTag, @@ -203,6 +218,34 @@ async function buildBootcImage() { // The build options const image = findImage(selectedImage); + // Per bootc-image-builder spec, users with empty names are not valid + if (buildConfigUsers) { + buildConfigUsers = buildConfigUsers.filter(user => user.name); + } + + // Per bootc-image-builder spec, filesystems with empty mountmounts are not valid + if (buildConfigFilesystems) { + buildConfigFilesystems = buildConfigFilesystems.filter(filesystem => filesystem.mountpoint); + } + + // In the UI we accept comma deliminated, however the spec must require an array, so we convert any users.groups to an array. + let convertedBuildConfigUsers = buildConfigUsers.map(user => { + return { + ...user, + groups: user.groups.split(',').map(group => group.trim()), + }; + }); + + // Final object, remove any empty strings / null / undefined values as bootc-image-builder + // does not accept empty strings / null / undefined values / ignore them. + const buildConfig = removeEmptyStrings({ + user: convertedBuildConfigUsers, + filesystem: buildConfigFilesystems, + kernel: { + append: buildConfigKernelArguments, + }, + }) as BuildConfig; + const buildOptions: BootcBuildInfo = { id: buildID, image: buildImageName, @@ -210,6 +253,8 @@ async function buildBootcImage() { tag: selectedImage.split(':')[1], engineId: image?.engineId ?? '', folder: buildFolder, + // If all the entries are empty, we will not provide the buildConfig + buildConfig, buildConfigFilePath: buildConfigFile, type: buildType, arch: buildArch, @@ -297,6 +342,39 @@ function cleanup() { errorFormValidation = ''; } +function addUser() { + buildConfigUsers = [...buildConfigUsers, { name: '', password: '', key: '', groups: '' }]; +} + +function deleteUser(index: number) { + buildConfigUsers = buildConfigUsers.filter((_, i) => i !== index); +} + +function addFilesystem() { + buildConfigFilesystems = [...buildConfigFilesystems, { mountpoint: '', minsize: '' }]; +} + +function deleteFilesystem(index: number) { + buildConfigFilesystems = buildConfigFilesystems.filter((_, i) => i !== index); +} + +// Remove any empty strings in the object before passing it in to the backend +// this is useful as we are using "bind:input" with groups / form fields and the first entry will always be blank when submitting +// this will remove any empty strings from the object before passing it in. +function removeEmptyStrings(obj: any): any { + if (Array.isArray(obj)) { + return obj.map(removeEmptyStrings); // Recurse for each item in arrays + } else if (typeof obj === 'object' && obj !== null) { + return Object.entries(obj) + .filter(([_, value]) => value !== '' && value !== undefined) // Filter out entries with empty string or undefined values + .reduce((acc, [key, value]) => { + acc[key] = removeEmptyStrings(value); // Recurse for nested objects/arrays + return acc; + }, {} as any); + } + return obj; +} + onMount(async () => { isLinux = await bootcClient.isLinux(); const images = await bootcClient.listBootcImages(); @@ -684,14 +762,115 @@ $: if (availableArchitectures) { Advanced Options + aria-label="build-config-options" + on:click={toggleBuildConfig} + >Build config - {#if showAdvanced} + {#if showBuildConfig} +

+ Supplying the following fields will create a build config file that contains the build options for the + disk image. Customizations include user, password, SSH keys and kickstart files. More information can + be found in the bootc-image-builder documentation. +

+ +
+ Users +
+ + {#each buildConfigUsers as user, index} +
+ + + + + + + + + +
+ {/each} + +
+ Filesystems +
+ + {#each buildConfigFilesystems as filesystem, index} +
+ + + + +
+ {/each} + +
+ Kernel +
+ + + +
+ File +
+
-
getBuildConfigFile()}>Browse...

- The build configuration file is a TOML or JSON file that contains the build options for the disk - image. Customizations include user, password, SSH keys and kickstart files. More information can be - found in the bootc-image-builder documentation. + This will override any above user-specific input and use the supplied file only.

- + {/if} + +
+ + + + Advanced options + + {#if showAdvanced} {#if isLinux}
diff --git a/packages/shared/src/models/bootc.ts b/packages/shared/src/models/bootc.ts index 55b3fb24..afddf53d 100644 --- a/packages/shared/src/models/bootc.ts +++ b/packages/shared/src/models/bootc.ts @@ -18,6 +18,35 @@ export type BuildType = 'qcow2' | 'ami' | 'raw' | 'vmdk' | 'anaconda-iso' | 'vhd'; +// Follows https://github.com/osbuild/bootc-image-builder?tab=readme-ov-file#-build-config convention +// users = array +// filesystems = array +// kernel = mapping +export interface BuildConfig { + user?: BuildConfigUser[]; + filesystem?: BuildConfigFilesystem[]; + kernel?: BuildConfigKernel; + // In the future: + // * Add installer.kickstart https://github.com/osbuild/bootc-image-builder?tab=readme-ov-file#anaconda-iso-installer-options-installer-mapping + // * Add anaconda iso modules https://github.com/osbuild/bootc-image-builder?tab=readme-ov-file#anaconda-iso-installer-modules +} + +export interface BuildConfigUser { + name: string; + password?: string; + key?: string; + groups?: string[]; +} + +export interface BuildConfigFilesystem { + mountpoint: string; + minsize: string; +} + +export interface BuildConfigKernel { + append: string; +} + export interface BootcBuildInfo { id: string; image: string; @@ -27,6 +56,7 @@ export interface BootcBuildInfo { type: BuildType[]; folder: string; chown?: string; + buildConfig?: BuildConfig; buildConfigFilePath?: string; filesystem?: string; arch?: string;