From 9dbe951c4afd892d70218d9ece0a59cc691c9688 Mon Sep 17 00:00:00 2001 From: Charlie Drage Date: Fri, 27 Sep 2024 11:07:54 -0400 Subject: [PATCH] feat: add launch VM button ### What does this PR do? * Using QEMU we add a feature to "Launch VM" in the background * Uses websockets, qemu as well as our xterm.js library to achieve this * Launches in "snapshot" mode so no data is written to .raw file so the file can be easily re-used ### Screenshot / video of UI ### What issues does this PR fix or reference? Closes https://github.com/containers/podman-desktop-extension-bootc/issues/813 ### How to test this PR? 1. Be on macOS silicon 2. `brew install qemu` 3. Build a bootc container image 4. Press launch VM button in actions bar Signed-off-by: Charlie Drage --- docs/qemu_install.md | 11 + package.json | 1 + packages/backend/package.json | 1 + packages/backend/src/api-impl.ts | 33 +- packages/backend/src/launch-vm.ts | 152 +++++++++ packages/backend/src/machine-utils.ts | 5 + packages/frontend/src/App.svelte | 7 + packages/frontend/src/Build.spec.ts | 3 + packages/frontend/src/Homepage.spec.ts | 1 + packages/frontend/src/VM.svelte | 305 ++++++++++++++++++ .../frontend/src/VMConnectionStatus.svelte | 20 ++ .../frontend/src/lib/BootcActions.spec.ts | 4 + packages/frontend/src/lib/BootcActions.svelte | 24 +- .../src/lib/BootcColumnActions.spec.ts | 3 + .../frontend/src/lib/BootcEmptyScreen.spec.ts | 1 + .../frontend/src/lib/upstream/Label.svelte | 19 ++ packages/shared/src/BootcAPI.ts | 3 + packages/shared/src/messages/MessageProxy.ts | 16 +- packages/shared/src/messages/Messages.ts | 1 + pnpm-lock.yaml | 17 +- 20 files changed, 616 insertions(+), 11 deletions(-) create mode 100644 docs/qemu_install.md create mode 100644 packages/backend/src/launch-vm.ts create mode 100644 packages/frontend/src/VM.svelte create mode 100644 packages/frontend/src/VMConnectionStatus.svelte create mode 100644 packages/frontend/src/lib/upstream/Label.svelte diff --git a/docs/qemu_install.md b/docs/qemu_install.md new file mode 100644 index 00000000..32f95fa9 --- /dev/null +++ b/docs/qemu_install.md @@ -0,0 +1,11 @@ +# QEMU Install + +WIP + +## macOS + +Install QEMU on macOS by running the following with `brew`: + +```sh +brew install qemu +``` \ No newline at end of file diff --git a/package.json b/package.json index 8a8ec734..2610f9b3 100644 --- a/package.json +++ b/package.json @@ -67,6 +67,7 @@ "vitest": "^2.0.2" }, "dependencies": { + "@xterm/addon-attach": "^0.11.0", "js-yaml": "^4.1.0" }, "packageManager": "pnpm@9.10.0+sha512.73a29afa36a0d092ece5271de5177ecbf8318d454ecd701343131b8ebc0c1a91c487da46ab77c8e596d6acf1461e3594ced4becedf8921b074fbd8653ed7051c" diff --git a/packages/backend/package.json b/packages/backend/package.json index 38cfd578..ff0d07f5 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -102,6 +102,7 @@ "@vitest/coverage-v8": "^2.0.2", "@xterm/xterm": "^5.5.0", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-attach": "^0.11.0", "eslint": "^8.57.1", "eslint-import-resolver-custom-alias": "^1.3.2", "eslint-import-resolver-typescript": "^3.6.3", diff --git a/packages/backend/src/api-impl.ts b/packages/backend/src/api-impl.ts index 4dae6874..fd17d93d 100644 --- a/packages/backend/src/api-impl.ts +++ b/packages/backend/src/api-impl.ts @@ -25,10 +25,11 @@ import { History } from './history'; import * as containerUtils from './container-utils'; import { Messages } from '/@shared/src/messages/Messages'; import { telemetryLogger } from './extension'; -import { checkPrereqs, isLinux, getUidGid } from './machine-utils'; +import { checkPrereqs, isLinux, isMac, getUidGid } from './machine-utils'; import * as fs from 'node:fs'; import path from 'node:path'; import { getContainerEngine } from './container-utils'; +import { launchVM, stopVM } from './launch-vm'; export class BootcApiImpl implements BootcApi { private history: History; @@ -54,6 +55,32 @@ export class BootcApiImpl implements BootcApi { return buildDiskImage(build, this.history, overwrite); } + async launchVM(folder: string, architecture: string): Promise { + console.log('going to launch vm: ', folder); + try { + await launchVM(folder, architecture); + // Notify it has successfully launched + await this.notify(Messages.MSG_VM_LAUNCH_ERROR, { success: 'Launched!', error: '' }); + } catch (e) { + // Make sure that we are able to display the "stderr" information if it exists as that actually shows + // the error when running the command. + let errorMessage: string; + if (e instanceof Error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + errorMessage = `${e.message} ${'stderr' in e ? (e as any).stderr : ''}`; + } else { + errorMessage = String(e); + } + await this.notify(Messages.MSG_VM_LAUNCH_ERROR, { success: '', error: errorMessage }); + } + return Promise.resolve(); + } + + // Stop VM by pid file on the system + async stopVM(): Promise { + return stopVM(); + } + async deleteBuilds(builds: BootcBuildInfo[]): Promise { const response = await podmanDesktopApi.window.showWarningMessage( `Are you sure you want to remove the selected disk images from the build history? This will remove the history of the build as well as remove any lingering build containers.`, @@ -247,6 +274,10 @@ export class BootcApiImpl implements BootcApi { return isLinux(); } + async isMac(): Promise { + return isMac(); + } + async getUidGid(): Promise { return getUidGid(); } diff --git a/packages/backend/src/launch-vm.ts b/packages/backend/src/launch-vm.ts new file mode 100644 index 00000000..07a1b208 --- /dev/null +++ b/packages/backend/src/launch-vm.ts @@ -0,0 +1,152 @@ +import path from 'node:path'; +import * as extensionApi from '@podman-desktop/api'; +import { isMac } from './machine-utils'; +import fs from 'node:fs'; + +// Ignore the following line as this is where we will be storing the pid file +// similar to other projects that use pid files in /tmp +// eslint-disable-next-line sonarjs/publicly-writable-directories +const pidFile = '/tmp/qemu-podman-desktop.pid'; + +// Must use "homebrew" qemu binaries on macOS +// as they are found to be the most stable and reliable for the project +// as well as containing the necessary "edk2-aarch64-code.fd" file +// it is not advised to use the qemu binaries from qemu.org due to edk2-aarch64-code.fd not being included. +const macQemuArm64Binary = '/opt/homebrew/bin/qemu-system-aarch64'; +const macQemuArm64Edk2 = '/opt/homebrew/share/qemu/edk2-aarch64-code.fd'; +const macQemuX86Binary = '/opt/homebrew/bin/qemu-system-x86_64'; + +// Host port forwarding for VM we will by default port forward 22 on the bootable container +// to :2222 on the host +const hostForwarding = 'hostfwd=tcp::2222-:22'; + +// Default memory size for the VM and websocket port location +const memorySize = '4G'; +const websocketPort = '45252'; + +// Raw image location +const rawImageLocation = 'image/disk.raw'; + +export async function launchVM(folder: string, architecture: string): Promise { + // Will ONLY work with RAW images located at image/disk.raw which is the default output location + const diskImage = path.join(folder, rawImageLocation); + + // Check to see that the disk image exists before continuing + if (!fs.existsSync(diskImage)) { + throw new Error(`Raw disk image not found: ${diskImage}`); + } + + // Before launching, make sure that we stop any previously running VM's and ignore any errors when stopping + try { + await stopVM(); + } catch (e) { + console.error('Error stopping VM, it may have already been stopped: ', e); + } + + // Generate the launch command and then run process.exec + try { + const command = generateLaunchCommand(diskImage, architecture); + + // If generateLaunchCommand returns an empty array, then we are not able to launch the VM + // so simply error out and return + if (command.length === 0) { + throw new Error( + 'Unable to generate the launch command for the VM, must be on the appropriate OS (mac or linux) and architecture (x86_64 or aarch64)', + ); + } + + // Execute the command + await extensionApi.process.exec('sh', ['-c', `${command.join(' ')}`]); + } catch (e) { + // Output the stderr information if it exists as that helps with debugging + // why the command could not run. + if (e instanceof Error && 'stderr' in e) { + console.error('Error launching VM: ', e.stderr); + } else { + console.error('Error launching VM: ', e); + } + throw e; + } +} + +// Stop VM by killing the process with the pid file (/tmp/qemu-podman-desktop.pid) +export async function stopVM(): Promise { + try { + await extensionApi.process.exec('sh', ['-c', `kill -9 \`cat ${pidFile}\``]); + } catch (e) { + if (e instanceof Error && 'stderr' in e) { + console.error('Error stopping VM: ', e.stderr); + } else { + console.error('Error stopping VM: ', e); + } + } +} + +// Generate launch command for qemu +// this all depends on what architecture we are launching as well as +// operating system +function generateLaunchCommand(diskImage: string, architecture: string): string[] { + let command: string[] = []; + switch (architecture) { + // Case for anything amd64 + case 'amd64': + if (isMac()) { + command = [ + macQemuX86Binary, + '-m', + memorySize, + '-nographic', + '-cpu', + 'Broadwell-v4', + '-pidfile', + pidFile, + '-serial', + `websocket:127.0.0.1:${websocketPort},server,nowait`, + '-netdev', + `user,id=mynet0,${hostForwarding}`, + '-device', + 'e1000,netdev=mynet0', + // Make sure we always have snapshot here as we don't want to modify the original image + '-snapshot', + diskImage, + ]; + } + break; + + // For any arm64 images + case 'arm64': + if (isMac()) { + command = [ + macQemuArm64Binary, + '-m', + memorySize, + '-nographic', + '-M', + 'virt', + '-accel', + 'hvf', + '-cpu', + 'host', + '-smp', + '4', + '-serial', + `websocket:127.0.0.1:${websocketPort},server,nowait`, + '-pidfile', + pidFile, + '-netdev', + `user,id=usernet,${hostForwarding}`, + '-device', + 'virtio-net,netdev=usernet', + '-drive', + `file=${macQemuArm64Edk2},format=raw,if=pflash,readonly=on`, + // Make sure we always have snapshot here as we don't want to modify the original image + '-snapshot', + diskImage, + ]; + } + break; + default: + break; + } + return command; +} diff --git a/packages/backend/src/machine-utils.ts b/packages/backend/src/machine-utils.ts index cf081b09..ba9413f0 100644 --- a/packages/backend/src/machine-utils.ts +++ b/packages/backend/src/machine-utils.ts @@ -174,6 +174,11 @@ export function isLinux(): boolean { return linux; } +const darwin = os.platform() === 'darwin'; +export function isMac(): boolean { + return darwin; +} + // Get the GID and UID of the current user and return in the format gid:uid // in order for this to work, we must get this information from process.exec // since there is no native way via node diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index eda7e464..3dca0dc1 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -10,6 +10,7 @@ import Homepage from './Homepage.svelte'; import { rpcBrowser } from '/@/api/client'; import { Messages } from '/@shared/src/messages/Messages'; import Logs from './Logs.svelte'; +import VM from './VM.svelte'; router.mode.hash(); @@ -36,6 +37,12 @@ onMount(() => { + + + { return { @@ -97,9 +98,11 @@ vi.mock('./api/client', async () => { buildExists: vi.fn(), listHistoryInfo: vi.fn(), listBootcImages: vi.fn(), + listAllImages: vi.fn(), inspectImage: vi.fn(), inspectManifest: vi.fn(), isLinux: vi.fn().mockImplementation(() => mockIsLinux), + isMac: vi.fn().mockImplementation(() => mockIsMac), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/Homepage.spec.ts b/packages/frontend/src/Homepage.spec.ts index 56d2fa85..0c7831a4 100644 --- a/packages/frontend/src/Homepage.spec.ts +++ b/packages/frontend/src/Homepage.spec.ts @@ -52,6 +52,7 @@ vi.mock('./api/client', async () => { listBootcImages: vi.fn(), deleteBuilds: vi.fn(), telemetryLogUsage: vi.fn(), + isMac: vi.fn(), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/VM.svelte b/packages/frontend/src/VM.svelte new file mode 100644 index 00000000..ed0b8626 --- /dev/null +++ b/packages/frontend/src/VM.svelte @@ -0,0 +1,305 @@ + + + + + +
+ +
+
+ +
+
+ {#if noLogs} + +
+ Prerequisites: +

+ QEMU is used as CLI tool used to launch your virtual machine. Before proceeding with booting, please ensure + that qemu is installed correctly by following our QEMU installation guide. +

+
+
+ Important information: +
    +
  1. + • The virtual machine is booted as a snapshot and writes data to a /tmp file. The + .raw file will remain unmodified. +
  2. +
  3. + • Port 22 is forwarded to 2222 locally for SSH testing. The VM may be accessed by using ssh localhost -p 2222 on an external terminal. +
  4. +
  5. • VM uses 4GB of RAM by default.
  6. +
+
+

+ This feature is EXPERIMENTAL and only works with RAW images, please open a GitHub issue for any bugs. +

+
+ + {#if vmLaunchError} +
+ +
+ {/if} +
+ {/if} +
+
diff --git a/packages/frontend/src/VMConnectionStatus.svelte b/packages/frontend/src/VMConnectionStatus.svelte new file mode 100644 index 00000000..5279572c --- /dev/null +++ b/packages/frontend/src/VMConnectionStatus.svelte @@ -0,0 +1,20 @@ + + +{#if status} + +{/if} diff --git a/packages/frontend/src/lib/BootcActions.spec.ts b/packages/frontend/src/lib/BootcActions.spec.ts index b97cd13a..931c047e 100644 --- a/packages/frontend/src/lib/BootcActions.spec.ts +++ b/packages/frontend/src/lib/BootcActions.spec.ts @@ -25,6 +25,7 @@ vi.mock('../api/client', async () => { return { bootcClient: { deleteBuilds: vi.fn(), + isMac: vi.fn(), }, rpcBrowser: { subscribe: () => { @@ -52,6 +53,7 @@ beforeEach(() => { }); test('Renders Delete Build button', async () => { + vi.mocked(bootcClient.isMac).mockResolvedValue(false); render(BootcActions, { object: mockHistoryInfo }); const deleteButton = screen.getAllByRole('button', { name: 'Delete Build' })[0]; @@ -59,6 +61,7 @@ test('Renders Delete Build button', async () => { }); test('Test clicking on delete button', async () => { + vi.mocked(bootcClient.isMac).mockResolvedValue(false); render(BootcActions, { object: mockHistoryInfo }); // spy on deleteBuild function @@ -72,6 +75,7 @@ test('Test clicking on delete button', async () => { }); test('Test clicking on logs button', async () => { + vi.mocked(bootcClient.isMac).mockResolvedValue(false); render(BootcActions, { object: mockHistoryInfo }); // Click on logs button diff --git a/packages/frontend/src/lib/BootcActions.svelte b/packages/frontend/src/lib/BootcActions.svelte index fded053d..8d55a41f 100644 --- a/packages/frontend/src/lib/BootcActions.svelte +++ b/packages/frontend/src/lib/BootcActions.svelte @@ -1,13 +1,19 @@ + +{#if object.arch && isMac} + gotoVM()} detailed={detailed} icon={faTerminal} /> +{/if} gotoLogs()} detailed={detailed} icon={faFileAlt} /> deleteBuild()} detailed={detailed} icon={faTrash} /> diff --git a/packages/frontend/src/lib/BootcColumnActions.spec.ts b/packages/frontend/src/lib/BootcColumnActions.spec.ts index 441f3c0e..e5eff6d0 100644 --- a/packages/frontend/src/lib/BootcColumnActions.spec.ts +++ b/packages/frontend/src/lib/BootcColumnActions.spec.ts @@ -33,6 +33,9 @@ const mockHistoryInfo: BootcBuildInfo = { vi.mock('../api/client', async () => { return { + bootcClient: { + isMac: vi.fn(), + }, rpcBrowser: { subscribe: () => { return { diff --git a/packages/frontend/src/lib/BootcEmptyScreen.spec.ts b/packages/frontend/src/lib/BootcEmptyScreen.spec.ts index 0cd5eb1b..5c81ccf8 100644 --- a/packages/frontend/src/lib/BootcEmptyScreen.spec.ts +++ b/packages/frontend/src/lib/BootcEmptyScreen.spec.ts @@ -51,6 +51,7 @@ vi.mock('../api/client', async () => { listHistoryInfo: vi.fn(), listBootcImages: vi.fn(), pullImage: vi.fn(), + isMac: vi.fn(), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/lib/upstream/Label.svelte b/packages/frontend/src/lib/upstream/Label.svelte new file mode 100644 index 00000000..2910d06a --- /dev/null +++ b/packages/frontend/src/lib/upstream/Label.svelte @@ -0,0 +1,19 @@ + + + +
+ + + {name} + +
+
diff --git a/packages/shared/src/BootcAPI.ts b/packages/shared/src/BootcAPI.ts index da1dd966..0d836d2f 100644 --- a/packages/shared/src/BootcAPI.ts +++ b/packages/shared/src/BootcAPI.ts @@ -36,9 +36,12 @@ export abstract class BootcApi { abstract generateUniqueBuildID(name: string): Promise; abstract openLink(link: string): Promise; abstract isLinux(): Promise; + abstract isMac(): Promise; abstract getUidGid(): Promise; abstract loadLogsFromFolder(folder: string): Promise; abstract getConfigurationValue(config: string, section: string): Promise; + abstract launchVM(folder: string, architecture: string): Promise; + abstract stopVM(): Promise; abstract telemetryLogUsage(eventName: string, data?: Record | undefined): Promise; abstract telemetryLogError(eventName: string, data?: Record | undefined): Promise; } diff --git a/packages/shared/src/messages/MessageProxy.ts b/packages/shared/src/messages/MessageProxy.ts index 20500a70..739dd96b 100644 --- a/packages/shared/src/messages/MessageProxy.ts +++ b/packages/shared/src/messages/MessageProxy.ts @@ -198,12 +198,16 @@ export class RpcBrowser { args: args, } as IMessageRequest); - setTimeout(() => { - const { reject } = this.promises.get(requestId) ?? {}; - if (!reject) return; - reject(new Error('Timeout')); - this.promises.delete(requestId); - }, 10000); // 10 seconds + // Add a timeout of 10 seconds for each call. However, if there is any "special" call that should not have a timeout, we can add a check here. + console.log('channel', channel); + if (!channel.includes('launchVM')) { + setTimeout(() => { + const { reject } = this.promises.get(requestId) ?? {}; + if (!reject) return; + reject(new Error('Timeout')); + this.promises.delete(requestId); + }, 10000); // 10 seconds + } // Create a Promise return promise; diff --git a/packages/shared/src/messages/Messages.ts b/packages/shared/src/messages/Messages.ts index a59e8e46..cf58ab6c 100644 --- a/packages/shared/src/messages/Messages.ts +++ b/packages/shared/src/messages/Messages.ts @@ -20,4 +20,5 @@ export enum Messages { MSG_HISTORY_UPDATE = 'history-update', MSG_IMAGE_PULL_UPDATE = 'image-pull-update', // Responsible for any pull updates MSG_NAVIGATE_BUILD = 'navigate-build', + MSG_VM_LAUNCH_ERROR = 'vm-launch-error', } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18c9b001..ddabb5e3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@xterm/addon-attach': + specifier: ^0.11.0 + version: 0.11.0(@xterm/xterm@5.5.0) js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -110,6 +113,9 @@ importers: '@vitest/coverage-v8': specifier: ^2.0.2 version: 2.0.5(vitest@2.0.5(@types/node@20.16.5)(jsdom@25.0.1)) + '@xterm/addon-attach': + specifier: ^0.11.0 + version: 0.11.0(@xterm/xterm@5.5.0) '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -117,7 +123,7 @@ importers: specifier: ^5.5.0 version: 5.5.0 eslint: - specifier: ^8.57.1 + specifier: ^8.56.0 version: 8.57.1 eslint-import-resolver-custom-alias: specifier: ^1.3.2 @@ -1704,6 +1710,11 @@ packages: '@vitest/utils@2.0.5': resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} + '@xterm/addon-attach@0.11.0': + resolution: {integrity: sha512-JboCN0QAY6ZLY/SSB/Zl2cQ5zW1Eh4X3fH7BnuR1NB7xGRhzbqU2Npmpiw/3zFlxDaU88vtKzok44JKi2L2V2Q==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + '@xterm/addon-fit@0.10.0': resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} peerDependencies: @@ -6027,6 +6038,10 @@ snapshots: loupe: 3.1.1 tinyrainbow: 1.2.0 + '@xterm/addon-attach@0.11.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': dependencies: '@xterm/xterm': 5.5.0