diff --git a/docs/vm_launcher.md b/docs/vm_launcher.md new file mode 100644 index 00000000..7042c44e --- /dev/null +++ b/docs/vm_launcher.md @@ -0,0 +1,21 @@ +# Virtual Machine Launcher + +Virtual Machine support is **experimental** and is only meant to run *one VM at a time* within the BootC extension. + +We launch the virtual machine by using QEMU. + +There are some caveats however: +- The virtual machine is booted as a snapshot and writes data to a /tmp file. The .raw file will remain unmodified. All changes are discarded on shut down. +- VM is shutdown when changing to another page. +- 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. +- VM uses 4GB of RAM by default. + +## Installation + +### 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 52ce7466..259e46fc 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,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 b96bc1e6..b94b8ae9 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 e211798c..bfa1a51f 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 VMManager from './vm-manager'; import examplesCatalog from '../assets/examples.json'; import type { ExamplesList } from '/@shared/src/models/examples'; @@ -52,6 +53,10 @@ export class BootcApiImpl implements BootcApi { return checkPrereqs(await getContainerEngine()); } + async checkVMLaunchPrereqs(build: BootcBuildInfo): Promise { + return new VMManager(build).checkVMLaunchPrereqs(); + } + async buildExists(folder: string, types: BuildType[]): Promise { return buildExists(folder, types); } @@ -60,6 +65,31 @@ export class BootcApiImpl implements BootcApi { return buildDiskImage(build, this.history, overwrite); } + async launchVM(build: BootcBuildInfo): Promise { + try { + await new VMManager(build).launchVM(); + // 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 stopCurrentVM(): Promise { + return await new VMManager().stopCurrentVM(); + } + 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.`, @@ -253,6 +283,10 @@ export class BootcApiImpl implements BootcApi { return isLinux(); } + async isMac(): Promise { + return isMac(); + } + async getUidGid(): Promise { return getUidGid(); } @@ -278,6 +312,11 @@ export class BootcApiImpl implements BootcApi { return undefined; } + // Read from the podman desktop clipboard + async readFromClipboard(): Promise { + return podmanDesktopApi.env.clipboard.readText(); + } + // 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/machine-utils.ts b/packages/backend/src/machine-utils.ts index cf081b09..45a23ccb 100644 --- a/packages/backend/src/machine-utils.ts +++ b/packages/backend/src/machine-utils.ts @@ -22,6 +22,7 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import { satisfies, coerce } from 'semver'; import type { ContainerProviderConnection } from '@podman-desktop/api'; +import { env } from '@podman-desktop/api'; function getMachineProviderEnv(connection: extensionApi.ContainerProviderConnection): string { switch (connection.vmType) { @@ -174,6 +175,18 @@ export function isLinux(): boolean { return linux; } +export function isMac(): boolean { + return env.isMac; +} + +export function isArm(): boolean { + return os.arch() === 'arm64'; +} + +export function isX86(): boolean { + return os.arch() === 'x64'; +} + // 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/backend/src/vm-manager.ts b/packages/backend/src/vm-manager.ts new file mode 100644 index 00000000..5351e9d2 --- /dev/null +++ b/packages/backend/src/vm-manager.ts @@ -0,0 +1,200 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import path from 'node:path'; +import * as extensionApi from '@podman-desktop/api'; +import { isArm, isMac } from './machine-utils'; +import fs from 'node:fs'; +import type { BootcBuildInfo } from '/@shared/src/models/bootc'; + +// Singular pid file location (can only run 1 VM at a time) +// eslint-disable-next-line sonarjs/publicly-writable-directories +const pidFile = '/tmp/qemu-podman-desktop.pid'; + +// MacOS related +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'; + +// Default values for VM's +const hostForwarding = 'hostfwd=tcp::2222-:22'; +const memorySize = '4G'; +const websocketPort = '45252'; +const rawImageLocation = 'image/disk.raw'; + +export default class VMManager { + private build: BootcBuildInfo; + + // Only values needed is the location of the VM file as well as the architecture of the image that + // will be used. + constructor(build?: BootcBuildInfo) { + this.build = build!; + } + + // Launch the VM by generating the appropriate QEMU command and then launching it with process.exec + public async launchVM(): Promise { + const diskImage = this.getDiskImagePath(); + + if (!fs.existsSync(diskImage)) { + throw new Error(`Raw disk image not found: ${diskImage}`); + } + + try { + const command = this.generateLaunchCommand(diskImage); + + if (command.length === 0) { + throw new Error( + 'Unable to generate the launch command for the VM, ensure you are on the appropriate OS and architecture.', + ); + } + + await extensionApi.process.exec('sh', ['-c', `${command.join(' ')}`]); + } catch (e) { + this.handleError(e); + } + } + + // We only support running one VM at at a time, so we kill the process by reading the pid from the universal pid file we use. + public async stopCurrentVM(): Promise { + try { + await extensionApi.process.exec('sh', ['-c', `kill -9 \`cat ${pidFile}\``]); + } catch (e) { + // Ignore if it contains 'No such process' as that means the VM is already stopped / not running. + if (e instanceof Error && 'stderr' in e && typeof e.stderr === 'string' && e.stderr.includes('No such process')) { + return; + } + this.handleError(e); + } + } + + // Prerequisite checks before launching the VM which includes checking if QEMU is installed as well as other OS specific checks. + public async checkVMLaunchPrereqs(): Promise { + const diskImage = this.getDiskImagePath(); + if (!fs.existsSync(diskImage)) { + return `Raw disk image not found at ${diskImage}. Please build a .raw disk image first.`; + } + + if (!this.isArchitectureSupported()) { + return `Unsupported architecture: ${this.build.arch}`; + } + + // Future support for Mac Intel, Linux ARM, Linux X86 and Windows ARM, Windows X86 to be added here. + if (isMac() && isArm()) { + return this.checkMacPrereqs(); + } else { + return 'Unsupported OS. Only MacOS Silicon is supported.'; + } + } + + private getDiskImagePath(): string { + return path.join(this.build.folder, rawImageLocation); + } + + private isArchitectureSupported(): boolean { + return this.build.arch === 'amd64' || this.build.arch === 'arm64'; + } + + private checkMacPrereqs(): string | undefined { + const installDisclaimer = 'Please install qemu via our installation document'; + if (!fs.existsSync(macQemuX86Binary)) { + return `QEMU x86 binary not found at ${macQemuX86Binary}. ${installDisclaimer}`; + } + if (this.build.arch === 'arm64' && !fs.existsSync(macQemuArm64Binary)) { + return `QEMU arm64 binary not found at ${macQemuArm64Binary}. ${installDisclaimer}`; + } + if (this.build.arch === 'arm64' && !fs.existsSync(macQemuArm64Edk2)) { + return `QEMU arm64 edk2-aarch64-code.fd file not found at ${macQemuArm64Edk2}. ${installDisclaimer}`; + } + return undefined; + } + + // Supported: MacOS Silicon + // Unsupported: MacOS Intel, Linux, Windows + private generateLaunchCommand(diskImage: string): string[] { + // Future support for Mac Intel, Linux ARM, Linux X86 and Windows ARM, Windows X86 to be added here. + if (isMac() && isArm()) { + switch (this.build.arch) { + case 'amd64': + return this.generateMacX86Command(diskImage); + case 'arm64': + return this.generateMacArm64Command(diskImage); + } + } + return []; + } + + private generateMacX86Command(diskImage: string): string[] { + return [ + 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', + '-snapshot', + diskImage, + ]; + } + + private generateMacArm64Command(diskImage: string): string[] { + return [ + 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`, + '-snapshot', + diskImage, + ]; + } + + // When running process.exec we should TRY and get stderr which it outputs (sometimes) so we do not get an "exit code 1" error with + // no information. + private handleError(e: unknown): void { + if (e instanceof Error && 'stderr' in e) { + throw new Error(typeof e.stderr === 'string' ? e.stderr : 'Unknown error'); + } else { + throw new Error('Unknown error'); + } + } +} diff --git a/packages/frontend/src/Build.spec.ts b/packages/frontend/src/Build.spec.ts index 47164569..f2e9a2b0 100644 --- a/packages/frontend/src/Build.spec.ts +++ b/packages/frontend/src/Build.spec.ts @@ -92,6 +92,7 @@ const mockImageInspect = { } as unknown as ImageInspectInfo; const mockIsLinux = false; +const mockIsMac = false; vi.mock('./api/client', async () => { return { @@ -100,11 +101,13 @@ 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), generateUniqueBuildID: vi.fn(), buildImage: vi.fn(), + isMac: vi.fn().mockImplementation(() => mockIsMac), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/lib/dashboard/Dashboard.spec.ts b/packages/frontend/src/lib/dashboard/Dashboard.spec.ts index 134d0dfd..fc367a5a 100644 --- a/packages/frontend/src/lib/dashboard/Dashboard.spec.ts +++ b/packages/frontend/src/lib/dashboard/Dashboard.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/disk-image/DiskImageActions.spec.ts b/packages/frontend/src/lib/disk-image/DiskImageActions.spec.ts index 5493e181..8cc26232 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageActions.spec.ts +++ b/packages/frontend/src/lib/disk-image/DiskImageActions.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(DiskImageActions, { 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(DiskImageActions, { 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(DiskImageActions, { object: mockHistoryInfo }); // Click on logs button diff --git a/packages/frontend/src/lib/disk-image/DiskImageActions.svelte b/packages/frontend/src/lib/disk-image/DiskImageActions.svelte index 2ad9c166..aed7e2bc 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageActions.svelte +++ b/packages/frontend/src/lib/disk-image/DiskImageActions.svelte @@ -1,13 +1,16 @@ + +{#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/disk-image/DiskImageColumnActions.spec.ts b/packages/frontend/src/lib/disk-image/DiskImageColumnActions.spec.ts index c38bae5a..483830c3 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageColumnActions.spec.ts +++ b/packages/frontend/src/lib/disk-image/DiskImageColumnActions.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/disk-image/DiskImageConnectionStatus.spec.ts b/packages/frontend/src/lib/disk-image/DiskImageConnectionStatus.spec.ts new file mode 100644 index 00000000..64e76a6f --- /dev/null +++ b/packages/frontend/src/lib/disk-image/DiskImageConnectionStatus.spec.ts @@ -0,0 +1,57 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { render, screen } from '@testing-library/svelte'; +import '@testing-library/jest-dom/vitest'; +import { test, expect } from 'vitest'; +import DiskImageConnectionStatus from './DiskImageConnectionStatus.svelte'; + +test('Expect to show connection status if status is connected', async () => { + render(DiskImageConnectionStatus, { status: 'connected' }); + + // Find span element with connected status + expect(screen.getByText('connected')).toBeDefined(); + + // Find status element, use the inner div of that element to check if it includes bg-[var(--pd-status-connected)] + const statusElement = screen.getByRole('status'); + if (!statusElement) { + throw new Error('Status element not found'); + } + const innerDiv = statusElement.querySelector('div'); + if (!innerDiv) { + throw new Error('Inner div not found'); + } + expect(innerDiv.classList.contains('bg-[var(--pd-status-connected)]')).toBeDefined(); +}); + +test('Expect to show disconnected status if VM error passed in', async () => { + render(DiskImageConnectionStatus, { status: 'VM error' }); + + // Find span element with connected status + expect(screen.getByText('VM error')).toBeDefined(); + + // Find status element, use the inner div of that element to check if it includes bg-[var(--pd-status-disconnected)] + const statusElement = screen.getByRole('status'); + if (!statusElement) { + throw new Error('Status element not found'); + } + const innerDiv = statusElement.querySelector('div'); + if (!innerDiv) { + throw new Error('Inner div not found'); + } + expect(innerDiv.classList.contains('bg-[var(--pd-status-disconnected)]')).toBeDefined(); +}); diff --git a/packages/frontend/src/lib/disk-image/DiskImageConnectionStatus.svelte b/packages/frontend/src/lib/disk-image/DiskImageConnectionStatus.svelte new file mode 100644 index 00000000..e4a09c51 --- /dev/null +++ b/packages/frontend/src/lib/disk-image/DiskImageConnectionStatus.svelte @@ -0,0 +1,16 @@ + + +{#if status} + +{/if} diff --git a/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts b/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts index 7b0f1ffe..4acf5332 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts +++ b/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts @@ -39,6 +39,7 @@ vi.mock('/@/api/client', async () => { return { bootcClient: { listHistoryInfo: vi.fn(), + isMac: vi.fn(), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte b/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte index 6aa95671..63ac262e 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte +++ b/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte @@ -5,11 +5,14 @@ import DiskImageIcon from '/@/lib/DiskImageIcon.svelte'; import DiskImageDetailsBuild from './DiskImageDetailsBuild.svelte'; import Route from '../Route.svelte'; import DiskImageDetailsSummary from './DiskImageDetailsSummary.svelte'; -import { onMount } from 'svelte'; +import { onDestroy, onMount } from 'svelte'; import type { BootcBuildInfo } from '/@shared/src/models/bootc'; import { getTabUrl, isTabSelected } from '../upstream/Util'; import { historyInfo } from '/@/stores/historyInfo'; import { goToDiskImages } from '../navigation'; +import DiskImageDetailsVirtualMachine from './DiskImageDetailsVirtualMachine.svelte'; +import type { Unsubscriber } from 'svelte/store'; +import { bootcClient } from '/@/api/client'; export let id: string; @@ -17,9 +20,17 @@ let diskImage: BootcBuildInfo; let detailsPage: DetailsPage; -onMount(() => { +let historyInfoUnsubscribe: Unsubscriber; + +let isMac = false; + +onMount(async () => { + // See if we are on mac or not for the VM tab + isMac = await bootcClient.isMac(); + + // Subscribe to the history to update the details page const actualId = atob(id); - return historyInfo.subscribe(value => { + historyInfoUnsubscribe = historyInfo.subscribe(value => { const matchingImage = value.find(image => image.id === actualId); if (matchingImage) { try { @@ -33,6 +44,12 @@ onMount(() => { } }); }); + +onDestroy(() => { + if (historyInfoUnsubscribe) { + historyInfoUnsubscribe(); + } +}); { + {#if isMac} + + {/if} @@ -55,5 +78,11 @@ onMount(() => { + + + {#if diskImage?.folder && diskImage?.arch} + + {/if} + diff --git a/packages/frontend/src/lib/disk-image/DiskImageDetailsVirtualMachine.spec.ts b/packages/frontend/src/lib/disk-image/DiskImageDetailsVirtualMachine.spec.ts new file mode 100644 index 00000000..15c73a5b --- /dev/null +++ b/packages/frontend/src/lib/disk-image/DiskImageDetailsVirtualMachine.spec.ts @@ -0,0 +1,130 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import { render, screen, waitFor } from '@testing-library/svelte'; +import { vi, test, expect, beforeAll } from 'vitest'; +import { bootcClient } from '/@/api/client'; +import DiskImageDetailsVirtualMachine from './DiskImageDetailsVirtualMachine.svelte'; +import type { BootcBuildInfo } from '/@shared/src/models/bootc'; + +vi.mock('/@/api/client', async () => { + return { + rpcBrowser: { + subscribe: () => { + return { + unsubscribe: () => {}, + }; + }, + }, + bootcClient: { + listHistoryInfo: vi.fn(), + getConfigurationValue: vi.fn(), + stopCurrentVM: vi.fn(), + checkVMLaunchPrereqs: vi.fn(), + launchVM: vi.fn(), + }, + }; +}); + +beforeAll(() => { + (window as any).ResizeObserver = ResizeObserver; + (window as any).getConfigurationValue = vi.fn().mockResolvedValue(undefined); + (window as any).matchMedia = vi.fn().mockReturnValue({ + addListener: vi.fn(), + }); + + Object.defineProperty(window, 'matchMedia', { + value: () => { + return { + matches: false, + addListener: () => {}, + removeListener: () => {}, + }; + }, + }); +}); + +class ResizeObserver { + observe = vi.fn(); + disconnect = vi.fn(); + unobserve = vi.fn(); +} + +test('Render virtual machine terminal window', async () => { + vi.mocked(bootcClient.getConfigurationValue).mockResolvedValue(14); + + // Use BootcBuildInfo to render the component + const build = { + id: 'id1', + image: 'my-image', + imageId: 'image-id', + tag: 'latest', + engineId: 'podman', + type: ['ami'], + folder: '/bootc', + } as BootcBuildInfo; + + render(DiskImageDetailsVirtualMachine, { build }); + + // Wait for 'launchVM' to have been called + await waitFor(() => { + expect(bootcClient.launchVM).toHaveBeenCalled(); + }); +}); + +test('Show prereqs message if prereq check fails (returns ANY string)', async () => { + vi.mocked(bootcClient.checkVMLaunchPrereqs).mockResolvedValue('Prereq check failed'); + + const build = { + id: 'id1', + image: 'my-image', + imageId: 'image-id', + tag: 'latest', + engineId: 'podman', + type: ['ami'], + folder: '/bootc', + } as BootcBuildInfo; + + render(DiskImageDetailsVirtualMachine, { build }); + + // Expect prereq failure to be shown + await waitFor(() => { + expect(screen.queryByText('Prereq check failed')).toBeDefined(); + }); +}); + +test('Test failed launched VM showing in render', async () => { + vi.mocked(bootcClient.checkVMLaunchPrereqs).mockResolvedValue(undefined); + vi.mocked(bootcClient.launchVM).mockRejectedValue('Failed to launch VM'); + + const build = { + id: 'id1', + image: 'my-image', + imageId: 'image-id', + tag: 'latest', + engineId: 'podman', + type: ['ami'], + folder: '/bootc', + } as BootcBuildInfo; + + render(DiskImageDetailsVirtualMachine, { build }); + + // Expect prereq failure to be shown + await waitFor(() => { + expect(screen.queryByText('Failed to launch VM')).toBeDefined(); + }); +}); diff --git a/packages/frontend/src/lib/disk-image/DiskImageDetailsVirtualMachine.svelte b/packages/frontend/src/lib/disk-image/DiskImageDetailsVirtualMachine.svelte new file mode 100644 index 00000000..161285fa --- /dev/null +++ b/packages/frontend/src/lib/disk-image/DiskImageDetailsVirtualMachine.svelte @@ -0,0 +1,309 @@ + + +{#if vmLaunchPrereqs} + +
+

+ View our guide for further information on completing the prerequisites: Virtual Machine Launcher BootC Guide. +

+
+
+{:else if vmLaunchError} + + +
+

+ View our guide for further information on troubleshooting steps: Virtual Machine Launcher BootC Guide. If you are still experiencing issues, please open an issue on our + GitHub repository. +

+
+
+{:else if noLogs} + +{/if} + +
+ +
+
+
diff --git a/packages/frontend/src/lib/disk-image/DiskImagesList.spec.ts b/packages/frontend/src/lib/disk-image/DiskImagesList.spec.ts index 56ccd694..6782a8fd 100644 --- a/packages/frontend/src/lib/disk-image/DiskImagesList.spec.ts +++ b/packages/frontend/src/lib/disk-image/DiskImagesList.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/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 29ce0516..18045d2a 100644 --- a/packages/shared/src/BootcAPI.ts +++ b/packages/shared/src/BootcAPI.ts @@ -22,6 +22,8 @@ import type { ExamplesList } from './models/examples'; export abstract class BootcApi { abstract checkPrereqs(): Promise; + abstract checkVMLaunchPrereqs(build: BootcBuildInfo): Promise; + abstract launchVM(build: BootcBuildInfo): Promise; abstract buildExists(folder: string, types: BuildType[]): Promise; abstract buildImage(build: BootcBuildInfo, overwrite?: boolean): Promise; abstract pullImage(image: string): Promise; @@ -37,10 +39,13 @@ export abstract class BootcApi { abstract generateUniqueBuildID(name: string): Promise; abstract openLink(link: string): Promise; abstract isLinux(): Promise; + abstract isMac(): Promise; abstract getUidGid(): Promise; abstract getExamples(): Promise; abstract loadLogsFromFolder(folder: string): Promise; abstract getConfigurationValue(config: string, section: string): Promise; + abstract readFromClipboard(): Promise; + abstract stopCurrentVM(): 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..a8e0a87d 100644 --- a/packages/shared/src/messages/MessageProxy.ts +++ b/packages/shared/src/messages/MessageProxy.ts @@ -19,6 +19,8 @@ import type { Webview } from '@podman-desktop/api'; +const specialChannels = ['launchVM']; + export interface IMessage { id: number; channel: string; @@ -198,12 +200,15 @@ 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. + if (!specialChannels.includes(channel)) { + 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 41d65abe..4e16a264 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 @@ -104,6 +107,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) @@ -1716,6 +1722,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: @@ -6074,6 +6085,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