From 06001a64ea7122935a4b30a95cc30c3ce38286fb Mon Sep 17 00:00:00 2001 From: Charlie Drage Date: Mon, 16 Dec 2024 12:05:31 -0500 Subject: [PATCH] feat: add linux VM experimental support ### What does this PR do? * Adds ability to test out VM for Linux users * Arm and AMD64 support. * Requires QEMU installed (there are prereqs as well as documentation that outlines it) ### Screenshot / video of UI ### What issues does this PR fix or reference? Closes https://github.com/podman-desktop/extension-bootc/issues/878 ### How to test this PR? 1. Be on Linux 2. Install QEMU (see documentation) 3. Build an image 4. Click on the Virtual Machine tab after building (or the Launch VM button). Signed-off-by: Charlie Drage --- docs/vm_launcher.md | 6 +- packages/backend/src/api-impl.ts | 6 +- packages/backend/src/vm-manager.spec.ts | 150 ++++++++++++++++++ packages/backend/src/vm-manager.ts | 105 +++++++++++- packages/frontend/src/Build.spec.ts | 2 + .../src/lib/dashboard/Dashboard.spec.ts | 1 + .../lib/disk-image/DiskImageActions.spec.ts | 8 +- .../lib/disk-image/DiskImageActions.svelte | 6 +- .../disk-image/DiskImageColumnActions.spec.ts | 1 + .../lib/disk-image/DiskImageDetails.spec.ts | 3 +- .../lib/disk-image/DiskImageDetails.svelte | 8 +- .../DiskImageDetailsVirtualMachine.spec.ts | 2 + .../src/lib/disk-image/DiskImagesList.spec.ts | 1 + packages/shared/src/BootcAPI.ts | 1 + 14 files changed, 285 insertions(+), 15 deletions(-) create mode 100644 packages/backend/src/vm-manager.spec.ts diff --git a/docs/vm_launcher.md b/docs/vm_launcher.md index 7042c44e..5d8703e7 100644 --- a/docs/vm_launcher.md +++ b/docs/vm_launcher.md @@ -18,4 +18,8 @@ Install QEMU on macOS by running the following with `brew`: ```sh brew install qemu -``` \ No newline at end of file +``` + +### Linux + +Install QEMU by [following the QEMU guide for your distribution](https://www.qemu.org/download/#linux). \ No newline at end of file diff --git a/packages/backend/src/api-impl.ts b/packages/backend/src/api-impl.ts index 5d952d3a..8051db2a 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, isLinux, isMac, getUidGid } from './machine-utils'; +import { checkPrereqs, isLinux, isMac, isWindows, getUidGid } from './machine-utils'; import * as fs from 'node:fs'; import path from 'node:path'; import { getContainerEngine } from './container-utils'; @@ -286,6 +286,10 @@ export class BootcApiImpl implements BootcApi { return isMac(); } + async isWindows(): Promise { + return isWindows(); + } + async getUidGid(): Promise { return getUidGid(); } diff --git a/packages/backend/src/vm-manager.spec.ts b/packages/backend/src/vm-manager.spec.ts new file mode 100644 index 00000000..a94012a8 --- /dev/null +++ b/packages/backend/src/vm-manager.spec.ts @@ -0,0 +1,150 @@ +/********************************************************************** + * 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 { beforeEach, expect, test, vi } from 'vitest'; +import { createVMManager, stopCurrentVM } from './vm-manager'; +import { isLinux, isMac, isArm } from './machine-utils'; +import type { BootcBuildInfo } from '/@shared/src/models/bootc'; +import * as extensionApi from '@podman-desktop/api'; +import fs from 'node:fs'; + +// Mock the functions from machine-utils +vi.mock('./machine-utils', () => ({ + isWindows: vi.fn(), + isLinux: vi.fn(), + isMac: vi.fn(), + isArm: vi.fn(), + isX86: vi.fn(), +})); +vi.mock('node:fs'); +vi.mock('@podman-desktop/api', async () => ({ + process: { + exec: vi.fn(), + }, + env: { + isLinux: vi.fn(), + isMac: vi.fn(), + isArm: vi.fn(), + }, +})); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +test('createVMManager: should create a MacArmNativeVMManager for macOS ARM build', () => { + const build = { + id: '1', + image: 'test-image', + imageId: '1', + tag: 'latest', + type: ['raw'], + folder: '/path/to/folder', + arch: 'arm64', + } as BootcBuildInfo; + + // Mock isMac and isArm to return true + vi.mocked(isMac).mockReturnValue(true); + vi.mocked(isArm).mockReturnValue(true); + + const vmManager = createVMManager(build); + expect(vmManager.constructor.name).toBe('MacArmNativeVMManager'); +}); + +test('createVMManager: should create a MacArmX86VMManager for macOS x86 build', () => { + const build = { + id: '2', + image: 'test-image', + imageId: '2', + tag: 'latest', + type: ['raw'], + folder: '/path/to/folder', + arch: 'amd64', + } as BootcBuildInfo; + + // Mock isMac to return true + vi.mocked(isMac).mockReturnValue(true); + vi.mocked(isArm).mockReturnValue(true); + + const vmManager = createVMManager(build); + expect(vmManager.constructor.name).toBe('MacArmX86VMManager'); +}); + +test('createVMManager: should create a LinuxX86VMManager for Linux x86 build', () => { + const build = { + id: '2', + image: 'test-image', + imageId: '2', + tag: 'latest', + type: ['raw'], + folder: '/path/to/folder', + arch: 'amd64', + } as BootcBuildInfo; + + // Mock isLinux to return true + vi.mocked(isMac).mockReturnValue(false); + vi.mocked(isArm).mockReturnValue(false); + vi.mocked(isLinux).mockReturnValue(true); + + const vmManager = createVMManager(build); + expect(vmManager.constructor.name).toBe('LinuxX86VMManager'); +}); + +test('createVMManager: should create a LinuxArmVMManager for Linux ARM build', () => { + const build = { + id: '2', + image: 'test-image', + imageId: '2', + tag: 'latest', + type: ['raw'], + folder: '/path/to/folder', + arch: 'arm64', + } as BootcBuildInfo; + + // Mock isLinux to return true + vi.mocked(isMac).mockReturnValue(false); + vi.mocked(isArm).mockReturnValue(false); + vi.mocked(isLinux).mockReturnValue(true); + + const vmManager = createVMManager(build); + expect(vmManager.constructor.name).toBe('LinuxArmVMManager'); +}); + +test('createVMManager: should throw an error for unsupported OS/architecture', () => { + const build = { + id: '3', + image: 'test-image', + imageId: '3', + tag: 'latest', + type: ['raw'], + folder: '/path/to/folder', + arch: 'asdf', + } as BootcBuildInfo; + + // Arch is explicitly set to an unsupported value (asdf) + expect(() => createVMManager(build)).toThrow('Unsupported OS or architecture'); +}); + +test('stopCurrentVM: should call kill command with the pid from pidfile', async () => { + vi.spyOn(fs.promises, 'readFile').mockResolvedValueOnce('1234'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(extensionApi.process, 'exec').mockResolvedValueOnce({ stdout: '' } as any); + + await stopCurrentVM(); + expect(extensionApi.process.exec).toHaveBeenCalledWith('sh', ['-c', 'kill -9 `cat /tmp/qemu-podman-desktop.pid`']); +}); diff --git a/packages/backend/src/vm-manager.ts b/packages/backend/src/vm-manager.ts index 374c5835..e9706bce 100644 --- a/packages/backend/src/vm-manager.ts +++ b/packages/backend/src/vm-manager.ts @@ -18,7 +18,7 @@ import path from 'node:path'; import * as extensionApi from '@podman-desktop/api'; -import { isArm, isMac } from './machine-utils'; +import { isArm, isLinux, isMac } from './machine-utils'; import fs from 'node:fs'; import type { BootcBuildInfo } from '/@shared/src/models/bootc'; @@ -31,6 +31,13 @@ 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'; +// Linux related +// Context: on linux, since we are in a flatpak environment, we let podman desktop handle where the qemu +// binary is, so we just need to call qemu-system-aarch64 instead of the full path +// this is not an issue with the mac version since we are not in a containerized environment and we explicitly need the brew version. +const linuxQemuArm64Binary = 'qemu-system-aarch64'; +const linuxQemuX86Binary = 'qemu-system-x86_64'; + // Default values for VM's const hostForwarding = 'hostfwd=tcp::2222-:22'; const memorySize = '4G'; @@ -173,6 +180,96 @@ class MacArmX86VMManager extends VMManagerBase { } } +class LinuxArmVMManager extends VMManagerBase { + 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.build.arch !== 'arm64') { + return `Unsupported architecture: ${this.build.arch}`; + } + + const installDisclaimer = 'Please install qemu via your package manager.'; + try { + await extensionApi.process.exec(linuxQemuArm64Binary, ['--version']); + } catch { + return `Unable to run "${linuxQemuArm64Binary} --version". ${installDisclaimer}`; + } + return undefined; + } + + protected generateLaunchCommand(diskImage: string): string[] { + return [ + linuxQemuArm64Binary, + '-m', + memorySize, + '-nographic', + '-M', + 'virt', + '-cpu', + 'max', + '-smp', + '4', + '-serial', + `websocket:127.0.0.1:${websocketPort},server,nowait`, + '-pidfile', + pidFile, + '-netdev', + `user,id=usernet,${hostForwarding}`, + '-device', + 'virtio-net,netdev=usernet', + '-snapshot', + diskImage, + ]; + } +} + +class LinuxX86VMManager extends VMManagerBase { + 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.build.arch !== 'amd64') { + return `Unsupported architecture: ${this.build.arch}`; + } + + const installDisclaimer = 'Please install qemu via your package manager.'; + try { + await extensionApi.process.exec(linuxQemuX86Binary, ['--version']); + } catch { + return `Unable to run "${linuxQemuX86Binary} --version". ${installDisclaimer}`; + } + return undefined; + } + + protected generateLaunchCommand(diskImage: string): string[] { + return [ + linuxQemuX86Binary, + '-m', + memorySize, + '-nographic', + '-cpu', + 'Broadwell-v4', + '-smp', + '4', + '-serial', + `websocket:127.0.0.1:${websocketPort},server,nowait`, + '-pidfile', + pidFile, + '-netdev', + `user,id=usernet,${hostForwarding}`, + '-device', + 'e1000,netdev=usernet', + '-snapshot', + diskImage, + ]; + } +} + // Factory function to create the appropriate VM Manager export function createVMManager(build: BootcBuildInfo): VMManagerBase { // Only thing that we support is Mac M1 at the moment @@ -182,6 +279,12 @@ export function createVMManager(build: BootcBuildInfo): VMManagerBase { } else if (build.arch === 'amd64') { return new MacArmX86VMManager(build); } + } else if (isLinux()) { + if (build.arch === 'arm64') { + return new LinuxArmVMManager(build); + } else if (build.arch === 'amd64') { + return new LinuxX86VMManager(build); + } } throw new Error('Unsupported OS or architecture'); } diff --git a/packages/frontend/src/Build.spec.ts b/packages/frontend/src/Build.spec.ts index f2e9a2b0..aa30718c 100644 --- a/packages/frontend/src/Build.spec.ts +++ b/packages/frontend/src/Build.spec.ts @@ -93,6 +93,7 @@ const mockImageInspect = { const mockIsLinux = false; const mockIsMac = false; +const mockIsWindows = false; vi.mock('./api/client', async () => { return { @@ -108,6 +109,7 @@ vi.mock('./api/client', async () => { generateUniqueBuildID: vi.fn(), buildImage: vi.fn(), isMac: vi.fn().mockImplementation(() => mockIsMac), + isWindows: vi.fn().mockImplementation(() => mockIsWindows), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/lib/dashboard/Dashboard.spec.ts b/packages/frontend/src/lib/dashboard/Dashboard.spec.ts index fc367a5a..ec00cf22 100644 --- a/packages/frontend/src/lib/dashboard/Dashboard.spec.ts +++ b/packages/frontend/src/lib/dashboard/Dashboard.spec.ts @@ -52,6 +52,7 @@ vi.mock('../../api/client', async () => { listBootcImages: vi.fn(), pullImage: vi.fn(), isMac: vi.fn(), + isWindows: 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 8cc26232..c67d4798 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageActions.spec.ts +++ b/packages/frontend/src/lib/disk-image/DiskImageActions.spec.ts @@ -25,7 +25,7 @@ vi.mock('/@/api/client', async () => { return { bootcClient: { deleteBuilds: vi.fn(), - isMac: vi.fn(), + isWindows: vi.fn(), }, rpcBrowser: { subscribe: () => { @@ -53,7 +53,7 @@ beforeEach(() => { }); test('Renders Delete Build button', async () => { - vi.mocked(bootcClient.isMac).mockResolvedValue(false); + vi.mocked(bootcClient.isWindows).mockResolvedValue(false); render(DiskImageActions, { object: mockHistoryInfo }); const deleteButton = screen.getAllByRole('button', { name: 'Delete Build' })[0]; @@ -61,7 +61,7 @@ test('Renders Delete Build button', async () => { }); test('Test clicking on delete button', async () => { - vi.mocked(bootcClient.isMac).mockResolvedValue(false); + vi.mocked(bootcClient.isWindows).mockResolvedValue(false); render(DiskImageActions, { object: mockHistoryInfo }); // spy on deleteBuild function @@ -75,7 +75,7 @@ test('Test clicking on delete button', async () => { }); test('Test clicking on logs button', async () => { - vi.mocked(bootcClient.isMac).mockResolvedValue(false); + vi.mocked(bootcClient.isWindows).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 aed7e2bc..b635a659 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageActions.svelte +++ b/packages/frontend/src/lib/disk-image/DiskImageActions.svelte @@ -9,7 +9,7 @@ import { onMount } from 'svelte'; export let object: BootcBuildInfo; export let detailed = false; -let isMac = false; +let isWindows = false; // Delete the build async function deleteBuild(): Promise { @@ -26,13 +26,13 @@ async function gotoVM(): Promise { } onMount(async () => { - isMac = await bootcClient.isMac(); + isWindows = await bootcClient.isWindows(); }); -{#if object.arch && isMac} +{#if object.arch && !isWindows} gotoVM()} detailed={detailed} icon={faTerminal} /> {/if} gotoLogs()} detailed={detailed} icon={faFileAlt} /> diff --git a/packages/frontend/src/lib/disk-image/DiskImageColumnActions.spec.ts b/packages/frontend/src/lib/disk-image/DiskImageColumnActions.spec.ts index 483830c3..19c82bfd 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageColumnActions.spec.ts +++ b/packages/frontend/src/lib/disk-image/DiskImageColumnActions.spec.ts @@ -35,6 +35,7 @@ vi.mock('/@/api/client', async () => { return { bootcClient: { isMac: vi.fn(), + isWindows: vi.fn(), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts b/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts index 4acf5332..38b112c9 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts +++ b/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts @@ -39,7 +39,7 @@ vi.mock('/@/api/client', async () => { return { bootcClient: { listHistoryInfo: vi.fn(), - isMac: vi.fn(), + isWindows: vi.fn(), }, rpcBrowser: { subscribe: () => { @@ -57,6 +57,7 @@ beforeEach(() => { test('Confirm renders disk image details', async () => { vi.mocked(bootcClient.listHistoryInfo).mockResolvedValue([image]); + vi.mocked(bootcClient.isWindows).mockResolvedValue(false); render(DiskImageDetails, { id: btoa(image.id) }); diff --git a/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte b/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte index 63ac262e..ab6b3011 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte +++ b/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte @@ -22,11 +22,11 @@ let detailsPage: DetailsPage; let historyInfoUnsubscribe: Unsubscriber; -let isMac = false; +let isWindows = false; onMount(async () => { - // See if we are on mac or not for the VM tab - isMac = await bootcClient.isMac(); + // See if we are on mac or linux or not for the VM tab + isWindows = await bootcClient.isWindows(); // Subscribe to the history to update the details page const actualId = atob(id); @@ -64,7 +64,7 @@ onDestroy(() => { - {#if isMac} + {#if !isWindows} { stopCurrentVM: vi.fn(), checkVMLaunchPrereqs: vi.fn(), launchVM: vi.fn(), + isMac: vi.fn(), + isWindows: vi.fn(), }, }; }); diff --git a/packages/frontend/src/lib/disk-image/DiskImagesList.spec.ts b/packages/frontend/src/lib/disk-image/DiskImagesList.spec.ts index 6782a8fd..8ee86cf8 100644 --- a/packages/frontend/src/lib/disk-image/DiskImagesList.spec.ts +++ b/packages/frontend/src/lib/disk-image/DiskImagesList.spec.ts @@ -53,6 +53,7 @@ vi.mock('/@/api/client', async () => { deleteBuilds: vi.fn(), telemetryLogUsage: vi.fn(), isMac: vi.fn(), + isWindows: vi.fn(), }, rpcBrowser: { subscribe: () => { diff --git a/packages/shared/src/BootcAPI.ts b/packages/shared/src/BootcAPI.ts index 18045d2a..d345453b 100644 --- a/packages/shared/src/BootcAPI.ts +++ b/packages/shared/src/BootcAPI.ts @@ -40,6 +40,7 @@ export abstract class BootcApi { abstract openLink(link: string): Promise; abstract isLinux(): Promise; abstract isMac(): Promise; + abstract isWindows(): Promise; abstract getUidGid(): Promise; abstract getExamples(): Promise; abstract loadLogsFromFolder(folder: string): Promise;