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;