Skip to content

Commit

Permalink
feat: refactor of build logic / more history info / quickpicks (#175)
Browse files Browse the repository at this point in the history
### What does this PR do?

In order to add the "quickpick" logic in a separate file, it was
beneficial to also refactor what history information we also save.

In this PR we:
* Separate the "quickpick" functionality into a separate file, so we may
  use the build disk image function separately
* Additional history information being saved such as build container id,
  timestamp, etc.

### Screenshot / video of UI

<!-- If this PR is changing UI, please include
screenshots or screencasts showing the difference -->

N/A, internal only. Build functionality should work as intended.

### What issues does this PR fix or reference?

<!-- Include any related issues from Podman Desktop
repository (or from another issue tracker). -->

Closes #173
Closes #174

### How to test this PR?

<!-- Please explain steps to reproduce -->

Build a disk image, it should continue as normal.

Check:
`~/.local/share/containers/podman-desktop/extensions-storage/redhat.bootc/history.json`
for the 'updated history info'

Signed-off-by: Charlie Drage <[email protected]>
  • Loading branch information
cdrage authored Mar 8, 2024
1 parent b54bfb4 commit affe0bf
Show file tree
Hide file tree
Showing 10 changed files with 571 additions and 163 deletions.
251 changes: 148 additions & 103 deletions packages/backend/src/build-disk-image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,123 +18,100 @@

import type { ContainerCreateOptions } from '@podman-desktop/api';
import * as extensionApi from '@podman-desktop/api';
import * as os from 'node:os';
import * as fs from 'node:fs';
import { resolve } from 'node:path';
import * as containerUtils from './container-utils';
import * as machineUtils from './machine-utils';
import { bootcImageBuilderContainerName, bootcImageBuilderName } from './constants';
import type { BootcBuildInfo } from '@shared/src/models/bootc';
import type { History } from './history';
import * as machineUtils from './machine-utils';

const telemetryLogger = extensionApi.env.createTelemetryLogger();

export async function buildDiskImage(imageData: unknown, history: History) {
// Before we do ANYTHING, we should be checking to see if the podman machine is rootful or not
// as that's a requirement for bootc-image-builder to work correctly.
const isRootful = await machineUtils.isPodmanMachineRootful();
if (!isRootful) {
await extensionApi.window.showErrorMessage(
'The podman machine is not set as rootful. Please recreate the podman machine with rootful privileges set and try again.',
);
return;
}

const image = imageData as { name: string; engineId: string; tag: string };
export async function buildDiskImage(build: BootcBuildInfo, history: History): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const telemetryData: Record<string, any> = {};
let errorMessage: string;

const selection = await extensionApi.window.showQuickPick(
[
{ label: 'QCOW2', detail: 'QEMU image (.qcow2)', format: 'qcow2' },
{ label: 'AMI', detail: 'Amazon Machine Image (.ami)', format: 'ami' },
{ label: 'RAW', detail: 'Raw image (.raw) with an MBR or GPT partition table', format: 'raw' },
{ label: 'ISO', detail: 'ISO standard disk image (.iso) for flashing media and using EFI', format: 'iso' },
],
{
title: 'Select the type of disk image to create',
},
);
if (!selection) {
telemetryData.canceled = true;
telemetryLogger.logUsage('buildDiskImage', telemetryData);
return;
const requiredFields = [
{ field: 'name', message: 'Bootc image name is required.' },
{ field: 'tag', message: 'Bootc image tag is required.' },
{ field: 'type', message: 'Bootc image type is required.' },
{ field: 'engineId', message: 'Bootc image engineId is required.' },
{ field: 'folder', message: 'Bootc image folder is required.' },
{ field: 'arch', message: 'Bootc image architecture is required.' },
];

// VALIDATION CHECKS
for (const { field, message } of requiredFields) {
if (!build[field]) {
await extensionApi.window.showErrorMessage(message);
throw new Error(message);
}
}
const selectedType = selection.format;
telemetryData.imageType = selectedType;

const selectionArch = await extensionApi.window.showQuickPick(
[
{ label: 'ARM64', detail: 'ARM® aarch64 systems', arch: 'arm64' },
{ label: 'AMD64', detail: 'Intel and AMD x86_64 systems', arch: 'amd64' },
],
{
title: 'Select the architecture',
},
);
if (!selectionArch) {
telemetryData.canceled = true;
telemetryLogger.logUsage('buildDiskImage', telemetryData);
return;

const isRootful = await machineUtils.isPodmanMachineRootful();
if (!isRootful) {
const errorMessage =
'The podman machine is not set as rootful. Please recreate the podman machine with rootful privileges set and try again.';
await extensionApi.window.showErrorMessage(errorMessage);
throw new Error('The podman machine is not set as rootful.');
}
const selectedArch = selectionArch.arch;
telemetryData.arch = selectedArch;

const location = history.getLastLocation() || os.homedir();
const selectedFolder = await extensionApi.window.showInputBox({
prompt: 'Select the folder to generate disk' + selectedType + ' into',
value: location,
ignoreFocusOut: true,
});
if (!selectedFolder) {
telemetryData.canceled = true;
telemetryLogger.logUsage('buildDiskImage', telemetryData);
return;

let imageName = ''; // Initialize imageName as an empty string

// Check build.type and assign imageName accordingly
if (build.type === 'qcow2') {
imageName = 'qcow2/disk.qcow2';
} else if (build.type === 'ami') {
imageName = 'image/disk.raw';
} else if (build.type === 'raw') {
imageName = 'image/disk.raw';
} else if (build.type === 'iso') {
imageName = 'bootiso/disk.iso';
} else {
// If build.type is not one of the expected values, show an error and return
const errorMessage = 'Invalid image format selected.';
await extensionApi.window.showErrorMessage(errorMessage);
throw new Error(errorMessage);
}
// Make this into a map that 'qcow2' -> 'disk.qcow2'
// and 'ami' -> 'qcow2/disk.raw'
// and 'raw' -> 'image/disk.raw'
// and 'iso' -> 'bootiso/disk.iso'
// and then use that to build the path
const imageNameMap = {
qcow2: 'qcow2/disk.qcow2',
ami: 'image/disk.raw',
raw: 'image/disk.raw',
iso: 'bootiso/disk.iso',
};
let imagePath = '';
imagePath = resolve(selectedFolder, imageNameMap[selectedType]);

const imagePath = resolve(build.folder, imageName);

if (
fs.existsSync(imagePath) &&
(await extensionApi.window.showWarningMessage('File already exists, do you want to overwrite?', 'Yes', 'No')) ===
'No'
) {
telemetryData.overwrite = true;
telemetryData.canceled = true;
telemetryLogger.logUsage('buildDiskImage', telemetryData);
return;
}

// store this path for later
await history.addImageBuild(image.name, selectedType, selectedFolder);
// Add the 'history' information before we start the build
// this will be improved in the future to add more information
build.status = 'creating';
await history.addOrUpdateBuildInfo(build);

// After resolving all the information, adding it to the history, finally telemetry the data.
telemetryData.build = build;
telemetryLogger.logUsage('buildDiskImage', telemetryData);

// "Returning" withProgress allows PD to handle the task in the background with building.
return extensionApi.window.withProgress(
{ location: extensionApi.ProgressLocation.TASK_WIDGET, title: 'Building disk image ' + image.name },
{ location: extensionApi.ProgressLocation.TASK_WIDGET, title: 'Building disk image ' + build.name },
async progress => {
const buildContainerName = image.name.split('/').pop() + bootcImageBuilderContainerName;
let successful: boolean;
let errorMessage: string;
const buildContainerName = build.name.split('/').pop() + bootcImageBuilderContainerName;
let successful: boolean = false;
let logData: string = 'Build Image Log --------\n';
logData += 'Image: ' + image.name + '\n';
logData += 'Type: ' + selectedType + '\n';
logData += 'Folder: ' + selectedFolder + '\n';
logData += 'Image: ' + build.name + '\n';
logData += 'Type: ' + build.type + '\n';
logData += 'Folder: ' + build.folder + '\n';
logData += '----------\n';

// Create log folder
if (!fs.existsSync(selectedFolder)) {
fs.mkdirSync(selectedFolder, { recursive: true });
if (!fs.existsSync(build.folder)) {
await fs.promises.mkdir(build.folder, { recursive: true });
}
const logPath = resolve(selectedFolder, 'image-build.log');
const logPath = resolve(build.folder, 'image-build.log');
if (fs.existsSync(logPath)) {
fs.unlinkSync(logPath);
}
Expand All @@ -145,52 +122,85 @@ export async function buildDiskImage(imageData: unknown, history: History) {
const containerName = await getUnusedName(buildContainerName);
const buildImageContainer = createBuilderImageOptions(
containerName,
image.name + ':' + image.tag,
selectedType,
selectedArch,
selectedFolder,
`${build.name}:${build.tag}`,
build.type,
build.arch,
build.folder,
imagePath,
);
logData += JSON.stringify(buildImageContainer, undefined, 2);
logData += '\n----------\n';

if (!buildImageContainer) {
await extensionApi.window.showErrorMessage('Error creating container options.');
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 });
await containerUtils.pullImage(buildImageContainer.Image);

// delete previous copies of the image (in case we have upgraded it)
await containerUtils.deleteOldImages(image.engineId, buildImageContainer.Image);
if (buildImageContainer.Image) {
await containerUtils.pullImage(buildImageContainer.Image);
} else {
throw new Error('No image to pull');
}

// Step 2. Check if there are any previous builds and remove them
progress.report({ increment: 5 });
await containerUtils.removeContainerIfExists(image.engineId, buildImageContainer.name);
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 });
const containerId = await containerUtils.createAndStartContainer(image.engineId, buildImageContainer);
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(image, containerId, progress, data => {
await logContainer(build.engineId, containerId, progress, data => {
logData += data;
});

// 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 });
await containerUtils.waitForContainerToExit(containerId);

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.name}:${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;
}
}

// If we get here, the container has exited with a zero exit code
// it's successful as well so we will write the log file
successful = true;
telemetryData.success = true;
} catch (error) {
errorMessage = error.message;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: unknown) {
errorMessage = (error as Error).message;
console.error(error);
telemetryData.error = error;
} finally {
Expand All @@ -206,33 +216,68 @@ export async function buildDiskImage(imageData: unknown, history: History) {
// ###########
// Regardless what happens, we will need to clean up what we started (if anything)
// which could be containers, volumes, images, etc.
await containerUtils.removeContainerAndVolumes(image.engineId, buildImageContainer.name);
if (buildImageContainer.name) {
await containerUtils.removeContainerAndVolumes(build.engineId, buildImageContainer.name);
}
}

// Mark the task as completed
progress.report({ increment: -1 });
telemetryLogger.logUsage('buildDiskImage', telemetryData);

if (successful) {
try {
// Update the image build status to success
build.status = 'success';
await history.addOrUpdateBuildInfo(build);
} catch (e) {
// If for any reason there is an error.. (example, unable to write to history file)
// we do not want to stop the notification to the user, so
// just output this to console and continue.
console.error('Error updating image build status to success', e);
}

// Notify the user that the image has been built successfully
await extensionApi.window.showInformationMessage(
`Success! Your Bootable OS Container has been succesfully created to ${imagePath}`,
'OK',
);
} else {
try {
// Update the image build status to error
build.status = 'error';
await history.addOrUpdateBuildInfo(build);
} catch (e) {
// Same as above, do not want to block other parts of the build
// so just output to console.
console.error(`Error updating image build ${build.name}:${build.tag} status to error: ${e}`);
}
if (!errorMessage.endsWith('.')) {
errorMessage += '.';
}

// Notify on an error
await extensionApi.window.showErrorMessage(
`There was an error building the image: ${errorMessage} Check logs at ${logPath}`,
'OK',
);

// Make sure we still throw an error even after displaying an error message.
throw new Error(errorMessage);
}
},
);
}

async function logContainer(image, containerId: string, progress, callback: (data: string) => void): Promise<void> {
await extensionApi.containerEngine.logsContainer(image.engineId, containerId, (_name: string, data: string) => {
async function logContainer(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
engineId: any,
containerId: string,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
progress: any,
callback: (data: string) => void,
): Promise<void> {
await extensionApi.containerEngine.logsContainer(engineId, containerId, (_name: string, data: string) => {
if (data) {
callback(data);
// look for specific output to mark incremental progress
Expand Down
15 changes: 13 additions & 2 deletions packages/backend/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import * as extensionApi from '@podman-desktop/api';
import { launchVFKit } from './launch-vfkit';
import { buildDiskImage } from './build-disk-image';
import { History } from './history';
import { bootcBuildOptionSelection } from './quickpicks';

export async function activate(extensionContext: ExtensionContext): Promise<void> {
console.log('starting bootc extension');
Expand All @@ -32,9 +33,19 @@ export async function activate(extensionContext: ExtensionContext): Promise<void
extensionApi.commands.registerCommand('bootc.vfkit', async container => {
await launchVFKit(container);
}),

extensionApi.commands.registerCommand('bootc.image.build', async image => {
await buildDiskImage(image, history);
const selections = await bootcBuildOptionSelection(history);
await buildDiskImage(
{
name: image.name,
tag: image.tag,
engineId: image.engineId,
type: selections.type,
folder: selections.folder,
arch: selections.arch,
},
history,
);
}),
);
}
Expand Down
Loading

0 comments on commit affe0bf

Please sign in to comment.