From b043df51069693491c7cdfcb3e5e525b295690e2 Mon Sep 17 00:00:00 2001 From: Charlie Drage Date: Tue, 30 Jul 2024 12:14:54 -0400 Subject: [PATCH] feat: use native podman build for linux ### What does this PR do? * Switches to using native podman building for Linux rather than using podman machine * Tested against using a manifest as well as a normal image. * Uses CLI commands the equivalant of doing `sudo podman run`. PD does not support running / viewing / using sudo root connections. So we use the CLI instead * Uses CLI commands for saving the image / importing as well. The reasoning is that importing requires `sudo` / privileged and retrieving via image ID does not work for saving via the API. ### Screenshot / video of UI ### What issues does this PR fix or reference? Closes https://github.com/containers/podman-desktop-extension-bootc/issues/623 ### How to test this PR? 1. Try on Linux (Fedora 40 or above) 2. Go to build and it should ask for credentials after a few moments of building 3. Successful image build Signed-off-by: Charlie Drage --- README.md | 18 +- packages/backend/src/api-impl.ts | 10 +- packages/backend/src/build-disk-image.spec.ts | 52 ++-- packages/backend/src/build-disk-image.ts | 240 ++++++++++++------ packages/backend/src/history.spec.ts | 4 + packages/backend/src/machine-utils.spec.ts | 8 +- packages/backend/src/machine-utils.ts | 2 +- packages/frontend/src/Build.spec.ts | 29 ++- packages/frontend/src/Build.svelte | 50 +++- packages/frontend/src/Homepage.spec.ts | 2 + .../frontend/src/lib/BootcActions.spec.ts | 1 + .../src/lib/BootcColumnActions.spec.ts | 1 + .../src/lib/BootcFolderColumn.spec.ts | 1 + .../frontend/src/lib/BootcImageColumn.spec.ts | 1 + packages/shared/src/BootcAPI.ts | 3 +- packages/shared/src/models/bootc.ts | 1 + 16 files changed, 297 insertions(+), 126 deletions(-) diff --git a/README.md b/README.md index d4632997..773d0a55 100644 --- a/README.md +++ b/README.md @@ -116,7 +116,7 @@ The list above is what is supported by the underlying `bootc-image-builder` tech ## Requirements -### Requirement 1. Software and hardware requirements +### Prerequisites: Software and hardware requirements **OS:** @@ -126,7 +126,9 @@ Compatible on Windows, macOS & Linux * [Podman Desktop 1.10.0+](https://github.com/containers/podman-desktop) * [Podman 5.0.1+](https://github.com/containers/podman) -### Requirement 2. Rootful mode on Podman Machine +### Podman Machine (macOS / Windows) + +Podman Machine is required for macOS and Windows in order to run Podman as well as utilize filesystem privileges to build a disk image. Podman Machine requirements: * **Rootful mode enabled** @@ -144,14 +146,14 @@ Or set when initially creating a Podman Machine via Podman Desktop: ![rootful setup](https://raw.githubusercontent.com/containers/podman-desktop-extension-bootc/main/docs/img/rootful_setup.png) -**Linux users:** +### Escalated Privileges (Linux) -On Linux, you are unable to create a Podman Machine through the GUI of Podman Desktop, to create a rootful Podman Machine you can run the following commands: +During the build process, **you will be asked to enter your credentials** so that the bootc extension may run a `sudo podman run` underlying CLI command. + +Podman Desktop is ran as the logged-in user. However, bootc-image-builder requires escalated / sudo privileges to run a rootful container. + +You can find more information about what specific commands are being ran from the console logs of Podman Desktop. -```sh -podman machine init --memory 6144 --rootful -podman machine start -``` ## Installation diff --git a/packages/backend/src/api-impl.ts b/packages/backend/src/api-impl.ts index bc00e023..6652011d 100644 --- a/packages/backend/src/api-impl.ts +++ b/packages/backend/src/api-impl.ts @@ -25,7 +25,7 @@ import { History } from './history'; import * as containerUtils from './container-utils'; import { Messages } from '/@shared/src/messages/Messages'; import { telemetryLogger } from './extension'; -import { checkPrereqs } from './machine-utils'; +import { checkPodmanMachinePrereqs, isLinux } from './machine-utils'; export class BootcApiImpl implements BootcApi { private history: History; @@ -39,8 +39,8 @@ export class BootcApiImpl implements BootcApi { this.webview = webview; } - async checkPrereqs(): Promise { - return checkPrereqs(); + async checkPodmanMachinePrereqs(): Promise { + return checkPodmanMachinePrereqs(); } async buildExists(folder: string, types: BuildType[]): Promise { @@ -240,6 +240,10 @@ export class BootcApiImpl implements BootcApi { telemetryLogger.logError(eventName, data); } + async isLinux(): Promise { + return isLinux(); + } + // The API does not allow callbacks through the RPC, so instead // we send "notify" messages to the frontend to trigger a refresh // this method is internal and meant to be used by the API implementation diff --git a/packages/backend/src/build-disk-image.spec.ts b/packages/backend/src/build-disk-image.spec.ts index d282a94a..98a422c6 100644 --- a/packages/backend/src/build-disk-image.spec.ts +++ b/packages/backend/src/build-disk-image.spec.ts @@ -21,7 +21,7 @@ import os from 'node:os'; import { buildExists, createBuilderImageOptions, - createPodmanRunCommand, + createPodmanCLIRunCommand, getBuilder, getUnusedName, } from './build-disk-image'; @@ -279,7 +279,7 @@ test('check uses Centos builder', async () => { expect(builder).toEqual(bootcImageBuilderCentos); }); -test('create podman run command', async () => { +test('create podman run CLI command', async () => { const name = 'test123-bootc-image-builder'; const build = { image: 'test-image', @@ -290,25 +290,35 @@ test('create podman run command', async () => { } as BootcBuildInfo; const options = createBuilderImageOptions(name, build); - const command = createPodmanRunCommand(options); - - const expectedCommand = `podman run \\ - --name test123-bootc-image-builder \\ - --tty \\ - --privileged \\ - --security-opt label=type:unconfined_t \\ - -v /Users/cdrage/bootc/qemutest4:/output/ \\ - -v /var/lib/containers/storage:/var/lib/containers/storage \\ - --label bootc.image.builder=true \\ - ${bootcImageBuilderCentos} \\ - test-image:latest \\ - --output \\ - /output/ \\ - --local \\ - --type \\ - raw \\ - --target-arch \\ - amd64`; + const command = createPodmanCLIRunCommand(options); + + // Expect an array of the above + const expectedCommand = [ + 'podman', + 'run', + '--rm', + '--name', + 'test123-bootc-image-builder', + '--tty', + '--privileged', + '--security-opt', + 'label=type:unconfined_t', + '-v', + '/Users/cdrage/bootc/qemutest4:/output/', + '-v', + '/var/lib/containers/storage:/var/lib/containers/storage', + '--label', + 'bootc.image.builder=true', + 'quay.io/centos-bootc/bootc-image-builder:latest-1720185748', + 'test-image:latest', + '--output', + '/output/', + '--local', + '--type', + 'raw', + '--target-arch', + 'amd64', + ]; expect(command).toEqual(expectedCommand); }); diff --git a/packages/backend/src/build-disk-image.ts b/packages/backend/src/build-disk-image.ts index b9bf5c47..7fe1e130 100644 --- a/packages/backend/src/build-disk-image.ts +++ b/packages/backend/src/build-disk-image.ts @@ -53,7 +53,7 @@ export async function buildExists(folder: string, types: BuildType[]) { } export async function buildDiskImage(build: BootcBuildInfo, history: History, overwrite?: boolean): Promise { - const prereqs = await machineUtils.checkPrereqs(); + const prereqs = await machineUtils.checkPodmanMachinePrereqs(); if (prereqs) { await extensionApi.window.showErrorMessage(prereqs); throw new Error(prereqs); @@ -140,7 +140,9 @@ export async function buildDiskImage(build: BootcBuildInfo, history: History, ov const buildImageContainer = createBuilderImageOptions(containerName, build, builder); logData += JSON.stringify(buildImageContainer, undefined, 2); logData += '\n----------\n'; - logData += createPodmanRunCommand(buildImageContainer); + // Output new line with `\` added at end for each in the array. + // logData += createPodmanCLIRunCommand(buildImageContainer); + logData += createPodmanCLIRunCommand(buildImageContainer).join(' \\\n'); logData += '\n----------\n'; try { await fs.promises.writeFile(logPath, logData); @@ -153,64 +155,121 @@ export async function buildDiskImage(build: BootcBuildInfo, history: History, ov return; } try { - // Step 1. Pull bootcImageBuilder - // Pull the bootcImageBuilder since that - // is what is being used to build images within BootC - // Do progress report here so it doesn't look like it's stuck - // since we are going to pull an image - progress.report({ increment: 4 }); - if (buildImageContainer.Image) { - await containerUtils.pullImage(buildImageContainer.Image); + /* LINUX BUILD SUPPORT INFORMATION + * Linux will use the CLI directly in order to build without having to use podman machine. + * The reasoning is that we require sudo / escalated privileges support in order for bootc-image-builder to work. + * In the below code, we transfer the current non-root image, to the 'sudo' root image directory, then ask for + * escalated privileges to run the build command. + * + * This is a short-term solution until we have either non-root building support in bootc-image-builder or an alternative + * solution to the problem. + */ + if (machineUtils.isLinux()) { + console.log( + 'Linux OS detected. Using Linux build support to build the image. This will include escalated privileges / asking for password.', + ); + + // Set as 'running' before we start the build. + build.status = 'running'; + await history.addOrUpdateBuildInfo(build); + + // Create random name for the image to be imported as. + // of the id of the image + a random number and .tar + // must create a random one each time to avoid conflicts when transfering. + const imagePath = path.join( + '/tmp', + `${build.imageId.replace('sha256:', '')}-${Math.floor(Math.random() * 100000)}.tar`, + ); + + // Step 1. Save the image to a tar file on the hosts /tmp/ directory. + console.log('Linux build support: Exporting image to: ', imagePath); + // Note: It is **VERY** important that we save it based upon the ID and NOT the name, or else it may + // use the image that is based upon a different OS (ex. amd64, vs arm64), or even the manifest "root" image, + // so instead we will save and transfer based on imageID. + // Trying the built-in save functionality of the PD API does not work correctly at the moment with saving image ID's. + const { + command: saveCommand, + stdout: saveStdout, + stderr: saveStderr, + } = await extensionApi.process.exec('podman', ['save', '-o', imagePath, build.imageId]); + console.log( + `Linux build support: Save command: ${saveCommand}\nstdout: ${saveStdout}\nstderr: ${saveStderr}`, + ); + // No 'safe' way to report information at the moment, so we will just increment by 50% as it's done + // in two steps anyways. We cannot get a callback of the progress of the exec command yet. + progress.report({ increment: 50 }); + + // Step 2. Run the command to import and build the image in one command. + const command = linuxBuildCommand(buildImageContainer, build, logPath, imagePath); + console.log('Linux build support: Running command: ', command); + const { + command: buildCommand, + stdout: buildStdout, + stderr: buildStderr, + } = await extensionApi.process.exec('sh', ['-c', `${command}`], { isAdmin: true }); + console.log( + `Linux build support: Build command: ${buildCommand}\nstdout: ${buildStdout}\nstderr: ${buildStderr}`, + ); } else { - throw new Error('No image to pull'); - } - - // Step 2. Check if there are any previous builds and remove them - progress.report({ increment: 5 }); - if (buildImageContainer.name) { - await containerUtils.removeContainerIfExists(build.engineId, buildImageContainer.name); - } else { - throw new Error('No container name to remove'); - } - - // Step 3. Create and start the container for the actual build - progress.report({ increment: 6 }); - build.status = 'running'; - await history.addOrUpdateBuildInfo(build); - const containerId = await containerUtils.createAndStartContainer(build.engineId, buildImageContainer); + // Step 1. Pull bootcImageBuilder + // Pull the bootcImageBuilder since that + // is what is being used to build images within BootC + // Do progress report here so it doesn't look like it's stuck + // since we are going to pull an image + progress.report({ increment: 4 }); + if (buildImageContainer.Image) { + await containerUtils.pullImage(buildImageContainer.Image); + } else { + throw new Error('No image to pull'); + } - // Update the history with the container id that was used to build the image - build.buildContainerId = containerId; - await history.addOrUpdateBuildInfo(build); + // Step 2. Check if there are any previous builds and remove them + progress.report({ increment: 5 }); + if (buildImageContainer.name) { + await containerUtils.removeContainerIfExists(build.engineId, buildImageContainer.name); + } else { + throw new Error('No container name to remove'); + } - // Step 3.1 Since we have started the container, we can now go get the logs - await logContainer(build.engineId, containerId, progress, data => { - // update the log file asyncronously - fs.promises.appendFile(logPath, data).catch((error: unknown) => { - console.debug('Could not write bootc build log: ', error); + // Step 3. Create and start the container for the actual build + progress.report({ increment: 6 }); + build.status = 'running'; + await history.addOrUpdateBuildInfo(build); + const containerId = await containerUtils.createAndStartContainer(build.engineId, buildImageContainer); + + // Update the history with the container id that was used to build the image + build.buildContainerId = containerId; + await history.addOrUpdateBuildInfo(build); + + // Step 3.1 Since we have started the container, we can now go get the logs + await logContainer(build.engineId, containerId, progress, data => { + // update the log file asyncronously + fs.promises.appendFile(logPath, data).catch((error: unknown) => { + console.debug('Could not write bootc build log: ', error); + }); }); - }); - - // Step 4. Wait for the container to exit - // This function will ensure it exits with a zero exit code - // if it does not, it will error out. - progress.report({ increment: 7 }); - - try { - await containerUtils.waitForContainerToExit(containerId); - } catch (error) { - // If we error out, BUT the container does not exist in the history, we will silently error - // as it's possible that the container was removed by the user during the build cycle / deleted from history. - - // Check if history has an entry with a containerId - const historyExists = history.getHistory().some(info => info.buildContainerId === containerId); - if (!historyExists) { - console.error( - `Container ${build.buildContainerId} for build ${build.image}:${build.arch} has errored out, but there is no container history. This is likely due to the container being removed intentionally during the build cycle. Ignore this. Error: ${error}`, - ); - return; - } else { - throw error; + + // Step 4. Wait for the container to exit + // This function will ensure it exits with a zero exit code + // if it does not, it will error out. + progress.report({ increment: 7 }); + + try { + await containerUtils.waitForContainerToExit(containerId); + } catch (error) { + // If we error out, BUT the container does not exist in the history, we will silently error + // as it's possible that the container was removed by the user during the build cycle / deleted from history. + + // Check if history has an entry with a containerId + const historyExists = history.getHistory().some(info => info.buildContainerId === containerId); + if (!historyExists) { + console.error( + `Container ${build.buildContainerId} for build ${build.image}:${build.arch} has errored out, but there is no container history. This is likely due to the container being removed intentionally during the build cycle. Ignore this. Error: ${error}`, + ); + return; + } else { + throw error; + } } } @@ -228,7 +287,9 @@ export async function buildDiskImage(build: BootcBuildInfo, history: History, ov // ########### // Regardless what happens, we will need to clean up what we started (if anything) // which could be containers, volumes, images, etc. - if (buildImageContainer.name) { + + // Only do this on mac or windows, as linux uses the CLI directly and with --rm so no need to remove container / volumes after. + if (buildImageContainer.name && !machineUtils.isLinux()) { await containerUtils.removeContainerAndVolumes(build.engineId, buildImageContainer.name); } } @@ -398,53 +459,88 @@ export function createBuilderImageOptions( return options; } -export function createPodmanRunCommand(options: ContainerCreateOptions): string { - let command = 'podman run \\'; +// 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( + options: ContainerCreateOptions, + build: BootcBuildInfo, + logPath: string, + imagePath: string, +): string { + if (!options.name) { + throw new Error('Container name is required'); + } + + // Create the script that we will use to transfer the image to the root user + const transferToRoot = transferUserImageToRoot(imagePath, build.imageId, build.image, build.tag); + + // Create the CLI command that will be used to run the the actual build. + const run = createPodmanCLIRunCommand(options); + + // Combine the commands so that this will be ran in one individual sudo-prompt command. This is needed to avoid asking for credentials + // multiple times. + // We add >> ${logPath} 2>&1 to ensure that the output is written to the log file as we are not using the API for streaming the logs. + return `${transferToRoot} && ${run.join(' ')} >> ${logPath} 2>&1`; +} + +// Transfer the image from the 'normal' user to the root user. +// MUST be just the ID, as that is the only thing preserved (no name or tag) when importing +// after importing we must rename to the correct name and tag. +export function transferUserImageToRoot(path: string, imageId: string, imageName: string, imageTag: string): string { + // Remove the 'sha256:' from the imageId as that is not needed when importing. + imageId = imageId.replace('sha256:', ''); + + // This is the "recommended" way to transfer between root and non-root without confliction (prompting for overriding image, problems with transfer, etc.). + // We will cat the /tmp file to podman import and rename at the same time, this allows a seamless transition to the image being built with bootc-image-builder by + // just supplying the name and tag. + return `podman load --input ${path} && podman tag ${imageId} ${imageName}:${imageTag}`; +} + +// LINUX SUPPORT. +// this is itended to be ran with `--rm` as well to auto-remove after. +export function createPodmanCLIRunCommand(options: ContainerCreateOptions): string[] { + // --rm to make it temporary. + const command = ['podman', 'run', '--rm']; if (options.name) { - command += `\n --name ${options.name} \\`; + command.push('--name', options.name); } if (options.Tty) { - command += `\n --tty \\`; + command.push('--tty'); } if (options.HostConfig?.Privileged) { - command += `\n --privileged \\`; + command.push('--privileged'); } if (options.HostConfig?.SecurityOpt) { options.HostConfig.SecurityOpt.forEach(opt => { - command += `\n --security-opt ${opt} \\`; + command.push('--security-opt', opt); }); } if (options.HostConfig?.Binds) { options.HostConfig.Binds.forEach(bind => { - command += `\n -v ${bind} \\`; + command.push('-v', bind); }); } if (options.Labels) { for (const [key, value] of Object.entries(options.Labels)) { - command += `\n --label ${key}=${value} \\`; + command.push('--label', `${key}=${value}`); } } if (options.Image) { - command += `\n ${options.Image} \\`; + command.push(options.Image); } if (options.Cmd) { options.Cmd.forEach(cmd => { - command += `\n ${cmd} \\`; + command.push(cmd); }); } - // Remove the trailing backslash - if (command.endsWith(' \\')) { - command = command.slice(0, -2); - } - return command; } diff --git a/packages/backend/src/history.spec.ts b/packages/backend/src/history.spec.ts index 236049ce..422187f8 100644 --- a/packages/backend/src/history.spec.ts +++ b/packages/backend/src/history.spec.ts @@ -43,6 +43,7 @@ describe('History class tests', () => { await history.addOrUpdateBuildInfo({ id: 'exampleName', image: 'exampleImageName', + imageId: 'exampleImageId', tag: 'exampleTag', engineId: 'exampleEngineId', type: ['iso'], @@ -71,6 +72,7 @@ describe('History class tests', () => { await history.addOrUpdateBuildInfo({ id: 'name1', image: 'exampleName0', + imageId: 'exampleImageId0', tag: 'exampleTag0', engineId: 'exampleEngineId0', type: ['iso'], @@ -82,6 +84,7 @@ describe('History class tests', () => { await history.addOrUpdateBuildInfo({ id: 'name1', image: 'exampleName1', + imageId: 'exampleImageId1', tag: 'exampleTag1', engineId: 'exampleEngineId1', type: ['iso'], @@ -93,6 +96,7 @@ describe('History class tests', () => { await history.addOrUpdateBuildInfo({ id: 'name1', image: 'exampleName2', + imageId: 'exampleImageId2', tag: 'exampleTag2', engineId: 'exampleEngineId2', type: ['iso'], diff --git a/packages/backend/src/machine-utils.spec.ts b/packages/backend/src/machine-utils.spec.ts index 2ee961c1..8aa4f678 100644 --- a/packages/backend/src/machine-utils.spec.ts +++ b/packages/backend/src/machine-utils.spec.ts @@ -247,7 +247,9 @@ test('Fail prereq if not Podman v5', async () => { Promise.resolve({ stdout: JSON.stringify(fakeMachineInfoJSON) } as extensionApi.RunResult), ); - expect(await machineUtils.checkPrereqs()).toEqual('Podman v5.0 or higher is required to build disk images.'); + expect(await machineUtils.checkPodmanMachinePrereqs()).toEqual( + 'Podman v5.0 or higher is required to build disk images.', + ); }); test('Fail prereq if not rootful', async () => { @@ -270,7 +272,7 @@ test('Fail prereq if not rootful', async () => { Promise.resolve(JSON.stringify({ HostUser: { Rootful: false } })), ); - expect(await machineUtils.checkPrereqs()).toEqual( + expect(await machineUtils.checkPodmanMachinePrereqs()).toEqual( 'The podman machine is not set as rootful. Please recreate the podman machine with rootful privileges set and try again.', ); }); @@ -298,5 +300,5 @@ test('Pass prereq if rootful v5 machine', async () => { vi.spyOn(fs.promises, 'readFile').mockResolvedValueOnce(JSON.stringify({ HostUser: { Rootful: true } })); await expect(machineUtils.isPodmanMachineRootful()).resolves.toBe(true); - expect(await machineUtils.checkPrereqs()).toEqual(undefined); + expect(await machineUtils.checkPodmanMachinePrereqs()).toEqual(undefined); }); diff --git a/packages/backend/src/machine-utils.ts b/packages/backend/src/machine-utils.ts index 979addb5..c7d8c3c8 100644 --- a/packages/backend/src/machine-utils.ts +++ b/packages/backend/src/machine-utils.ts @@ -96,7 +96,7 @@ export async function isPodmanV5Machine() { } } -export async function checkPrereqs(): Promise { +export async function checkPodmanMachinePrereqs(): Promise { const isPodmanV5 = await isPodmanV5Machine(); if (!isPodmanV5) { return 'Podman v5.0 or higher is required to build disk images.'; diff --git a/packages/frontend/src/Build.spec.ts b/packages/frontend/src/Build.spec.ts index d650b78d..01fe8d41 100644 --- a/packages/frontend/src/Build.spec.ts +++ b/packages/frontend/src/Build.spec.ts @@ -26,6 +26,7 @@ import { bootcClient } from './api/client'; const mockHistoryInfo: BootcBuildInfo[] = [ { id: 'name1', + imageId: 'sha256:image1', image: 'image1', engineId: 'engine1', tag: 'latest', @@ -36,6 +37,7 @@ const mockHistoryInfo: BootcBuildInfo[] = [ { id: 'name2', image: 'image2', + imageId: 'sha256:image', engineId: 'engine2', tag: 'latest', type: ['iso'], @@ -84,15 +86,18 @@ const mockImageInspect = { Architecture: 'amd64', } as unknown as ImageInspectInfo; +const mockIsLinux = false; + vi.mock('./api/client', async () => { return { bootcClient: { - checkPrereqs: vi.fn(), + checkPodmanMachinePrereqs: vi.fn(), buildExists: vi.fn(), listHistoryInfo: vi.fn(), listBootcImages: vi.fn(), inspectImage: vi.fn(), inspectManifest: vi.fn(), + isLinux: vi.fn().mockImplementation(() => mockIsLinux), }, rpcBrowser: { subscribe: () => { @@ -109,7 +114,7 @@ test('Render shows correct images and history', async () => { vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages); vi.mocked(bootcClient.buildExists).mockResolvedValue(false); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(undefined); render(Build); // Wait until children length is 2 meaning it's fully rendered / propagated the changes @@ -154,7 +159,7 @@ test('Check that preselecting an image works', async () => { vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages); vi.mocked(bootcClient.buildExists).mockResolvedValue(false); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(undefined); render(Build, { imageName: 'image2', imageTag: 'latest' }); // Wait until children length is 2 meaning it's fully rendered / propagated the changes @@ -179,7 +184,7 @@ test('Check that prereq validation works', async () => { const prereq = 'Something is missing'; vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(prereq); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(prereq); vi.mocked(bootcClient.buildExists).mockResolvedValue(false); render(Build); @@ -201,7 +206,7 @@ test('Check that prereq validation works', async () => { test('Check that overwriting an existing build works', async () => { vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(undefined); vi.mocked(bootcClient.buildExists).mockResolvedValue(true); // Mock the inspectImage to return 'amd64' as the architecture so it's selected / we can test the override function @@ -296,7 +301,7 @@ const fakedImageInspect: ImageInspectInfo = { test('Test that arm64 is disabled in form if inspectImage returns no arm64', async () => { vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(undefined); vi.mocked(bootcClient.buildExists).mockResolvedValue(false); vi.mocked(bootcClient.inspectImage).mockResolvedValue(fakedImageInspect); @@ -325,7 +330,7 @@ test('In the rare case that Architecture from inspectImage is blank, do not sele vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(undefined); vi.mocked(bootcClient.buildExists).mockResolvedValue(false); vi.mocked(bootcClient.inspectImage).mockResolvedValue(fakeImageNoArchitecture); @@ -370,7 +375,7 @@ test('Do not show an image if it has no repotags and has isManifest as false', a vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockedImages); vi.mocked(bootcClient.buildExists).mockResolvedValue(false); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(undefined); render(Build); // Wait until children length is 1 @@ -391,7 +396,7 @@ test('Do not show an image if it has no repotags and has isManifest as false', a test('If inspectImage fails, do not select any architecture / make them available', async () => { vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockBootcImages); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(undefined); vi.mocked(bootcClient.buildExists).mockResolvedValue(false); vi.mocked(bootcClient.inspectImage).mockRejectedValue('Error'); @@ -483,7 +488,7 @@ test('Show the image if isManifest: true and Labels is empty', async () => { vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockedImages); vi.mocked(bootcClient.buildExists).mockResolvedValue(false); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(undefined); render(Build); waitFor(() => { @@ -607,7 +612,7 @@ test('have amd64 and arm64 NOT disabled (opacity-50) if inspectManifest contains vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue(mockedImages); vi.mocked(bootcClient.buildExists).mockResolvedValue(false); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(undefined); render(Build); waitFor(() => { @@ -680,7 +685,7 @@ test('if a manifest is created that has the label "6.8.9-300.fc40.aarch64" in as vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue(mockHistoryInfo); vi.mocked(bootcClient.listBootcImages).mockResolvedValue([mockFedoraImage]); vi.mocked(bootcClient.buildExists).mockResolvedValue(false); - vi.mocked(bootcClient.checkPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.checkPodmanMachinePrereqs).mockResolvedValue(undefined); render(Build); diff --git a/packages/frontend/src/Build.svelte b/packages/frontend/src/Build.svelte index b40abd0f..7e6c6047 100644 --- a/packages/frontend/src/Build.svelte +++ b/packages/frontend/src/Build.svelte @@ -50,6 +50,7 @@ let errorFormValidation: string | undefined = undefined; // SPECIFICALLY fedora, where we **need** to select the filesystem, as it is not auto-selected. // this boolean will be set to true if the selected image is Fedora and shown as a warning to the user. let fedoraDetected = false; +let isLinux: boolean; // AWS Related let awsAmiName: string = ''; @@ -69,6 +70,7 @@ function findImage(repoTag: string): ImageInfo | undefined { } // Find images associated to the manifest +// optionally, filter by the archiecture. async function findImagesAssociatedToManifest(manifest: ManifestInspectInfo): Promise { const images = await bootcClient.listAllImages(); return images.filter(image => { @@ -123,11 +125,14 @@ async function fillArchitectures(historyInfo: BootcBuildInfo[]) { } async function validate() { - let prereqs = await bootcClient.checkPrereqs(); - if (prereqs) { - errorFormValidation = prereqs; - existingBuild = false; - return; + // Prereqs checks Podman Machine status and is not required on Linux + if (!isLinux) { + let prereqs = await bootcClient.checkPodmanMachinePrereqs(); + if (prereqs) { + errorFormValidation = prereqs; + existingBuild = false; + return; + } } if (!selectedImage) { @@ -179,9 +184,11 @@ async function buildBootcImage() { // The build options const image = findImage(selectedImage); + const buildOptions: BootcBuildInfo = { id: buildID, image: buildImageName, + imageId: image?.Id ?? '', tag: selectedImage.split(':')[1], engineId: image?.engineId ?? '', folder: buildFolder, @@ -194,6 +201,31 @@ async function buildBootcImage() { awsRegion: awsRegion, }; + // If manifest is detected, we will instead use the child image ID, as that is the correct one associated to the selection. This is needed + // for Linux support as we are transfering the image to the root podman connection and an ID is needed. + if (image?.isManifest) { + try { + const manifest = await bootcClient.inspectManifest(image); + const foundImages = await findImagesAssociatedToManifest(manifest); + + // Inspect each image and find the image that matches the buildArch + for (const foundImage of foundImages) { + const inspectedImage = await bootcClient.inspectImage(foundImage); + if (inspectedImage.Architecture === buildArch) { + buildOptions.imageId = foundImage.Id; + break; + } + } + + // If no matching architecture found, throw an error + if (!buildOptions.imageId) { + throw new Error(`No matching architecture found for ${buildArch}`); + } + } catch (error) { + console.error('Error inspecting manifest to retrieve image ID', error); + } + } + buildInProgress = true; try { // Do not await.. just start the build. @@ -251,6 +283,7 @@ function cleanup() { } onMount(async () => { + isLinux = await bootcClient.isLinux(); const images = await bootcClient.listBootcImages(); // filter to images that have a repo tag here, to avoid doing it everywhere @@ -710,6 +743,13 @@ export function goToHomePage(): void { {:else} + + {#if isLinux} +

+ For Linux users during the build, you will be asked for your credentials in order to run an escalated + privileged build prompt for the build process. +

+ {/if} {/if} {/if} diff --git a/packages/frontend/src/Homepage.spec.ts b/packages/frontend/src/Homepage.spec.ts index d28e624c..0ee88716 100644 --- a/packages/frontend/src/Homepage.spec.ts +++ b/packages/frontend/src/Homepage.spec.ts @@ -26,6 +26,7 @@ const mockHistoryInfo: BootcBuildInfo[] = [ { id: 'name1', image: 'image1', + imageId: 'sha256:imageId1', engineId: 'engine1', tag: 'latest', type: ['iso'], @@ -35,6 +36,7 @@ const mockHistoryInfo: BootcBuildInfo[] = [ { id: 'name2', image: 'image2', + imageId: 'sha256:imageId2', engineId: 'engine2', tag: 'latest', type: ['iso'], diff --git a/packages/frontend/src/lib/BootcActions.spec.ts b/packages/frontend/src/lib/BootcActions.spec.ts index 86951775..d26a6e71 100644 --- a/packages/frontend/src/lib/BootcActions.spec.ts +++ b/packages/frontend/src/lib/BootcActions.spec.ts @@ -39,6 +39,7 @@ vi.mock('../api/client', async () => { const mockHistoryInfo: BootcBuildInfo = { id: 'name1', image: 'image1', + imageId: 'sha256:imageId1', engineId: 'engine1', tag: 'latest', type: ['iso'], diff --git a/packages/frontend/src/lib/BootcColumnActions.spec.ts b/packages/frontend/src/lib/BootcColumnActions.spec.ts index 47b6fff2..724f26dd 100644 --- a/packages/frontend/src/lib/BootcColumnActions.spec.ts +++ b/packages/frontend/src/lib/BootcColumnActions.spec.ts @@ -23,6 +23,7 @@ import BootcColumnActions from './BootcColumnActions.svelte'; const mockHistoryInfo: BootcBuildInfo = { id: 'name1', image: 'image1', + imageId: 'sha256:imageId1', engineId: 'engine1', tag: 'latest', type: ['iso'], diff --git a/packages/frontend/src/lib/BootcFolderColumn.spec.ts b/packages/frontend/src/lib/BootcFolderColumn.spec.ts index 65ff1509..2a3284d8 100644 --- a/packages/frontend/src/lib/BootcFolderColumn.spec.ts +++ b/packages/frontend/src/lib/BootcFolderColumn.spec.ts @@ -23,6 +23,7 @@ import BootcFolderColumn from './BootcFolderColumn.svelte'; const mockHistoryInfo: BootcBuildInfo = { id: 'name1', image: 'image1', + imageId: 'sha256:imageId1', engineId: 'engine1', tag: 'latest', type: ['iso'], diff --git a/packages/frontend/src/lib/BootcImageColumn.spec.ts b/packages/frontend/src/lib/BootcImageColumn.spec.ts index 34198f76..811848f1 100644 --- a/packages/frontend/src/lib/BootcImageColumn.spec.ts +++ b/packages/frontend/src/lib/BootcImageColumn.spec.ts @@ -23,6 +23,7 @@ import BootcImageColumn from './BootcImageColumn.svelte'; const mockHistoryInfo: BootcBuildInfo = { id: 'name1', image: 'image1', + imageId: 'sha256:imageId1', engineId: 'engine1', tag: 'latest', type: ['iso'], diff --git a/packages/shared/src/BootcAPI.ts b/packages/shared/src/BootcAPI.ts index fc777b94..5d9569f9 100644 --- a/packages/shared/src/BootcAPI.ts +++ b/packages/shared/src/BootcAPI.ts @@ -20,7 +20,7 @@ import type { BootcBuildInfo, BuildType } from './models/bootc'; import type { ImageInfo, ImageInspectInfo, ManifestInspectInfo } from '@podman-desktop/api'; export abstract class BootcApi { - abstract checkPrereqs(): Promise; + abstract checkPodmanMachinePrereqs(): Promise; abstract buildExists(folder: string, types: BuildType[]): Promise; abstract buildImage(build: BootcBuildInfo, overwrite?: boolean): Promise; abstract pullImage(image: string): Promise; @@ -35,6 +35,7 @@ export abstract class BootcApi { abstract openFolder(folder: string): Promise; abstract generateUniqueBuildID(name: string): Promise; abstract openLink(link: string): Promise; + abstract isLinux(): Promise; abstract telemetryLogUsage(eventName: string, data?: Record | undefined): Promise; abstract telemetryLogError(eventName: string, data?: Record | undefined): Promise; } diff --git a/packages/shared/src/models/bootc.ts b/packages/shared/src/models/bootc.ts index 7181cd31..23e5800e 100644 --- a/packages/shared/src/models/bootc.ts +++ b/packages/shared/src/models/bootc.ts @@ -21,6 +21,7 @@ export type BuildType = 'qcow2' | 'ami' | 'raw' | 'vmdk' | 'iso'; export interface BootcBuildInfo { id: string; image: string; + imageId: string; tag: string; engineId: string; type: BuildType[];