Skip to content

Commit

Permalink
feat: add linux VM experimental support
Browse files Browse the repository at this point in the history
### 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

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

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

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

Closes podman-desktop#878

### How to test this PR?

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

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 <[email protected]>
  • Loading branch information
cdrage committed Dec 16, 2024
1 parent 9e2048e commit 06001a6
Show file tree
Hide file tree
Showing 14 changed files with 285 additions and 15 deletions.
6 changes: 5 additions & 1 deletion docs/vm_launcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,4 +18,8 @@ Install QEMU on macOS by running the following with `brew`:

```sh
brew install qemu
```
```

### Linux

Install QEMU by [following the QEMU guide for your distribution](https://www.qemu.org/download/#linux).
6 changes: 5 additions & 1 deletion packages/backend/src/api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -286,6 +286,10 @@ export class BootcApiImpl implements BootcApi {
return isMac();
}

async isWindows(): Promise<boolean> {
return isWindows();
}

async getUidGid(): Promise<string> {
return getUidGid();
}
Expand Down
150 changes: 150 additions & 0 deletions packages/backend/src/vm-manager.spec.ts
Original file line number Diff line number Diff line change
@@ -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`']);
});
105 changes: 104 additions & 1 deletion packages/backend/src/vm-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -173,6 +180,96 @@ class MacArmX86VMManager extends VMManagerBase {
}
}

class LinuxArmVMManager extends VMManagerBase {
public async checkVMLaunchPrereqs(): Promise<string | undefined> {
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<string | undefined> {
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
Expand All @@ -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');
}
Expand Down
2 changes: 2 additions & 0 deletions packages/frontend/src/Build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ const mockImageInspect = {

const mockIsLinux = false;
const mockIsMac = false;
const mockIsWindows = false;

vi.mock('./api/client', async () => {
return {
Expand All @@ -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: () => {
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/lib/dashboard/Dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ vi.mock('../../api/client', async () => {
listBootcImages: vi.fn(),
pullImage: vi.fn(),
isMac: vi.fn(),
isWindows: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/frontend/src/lib/disk-image/DiskImageActions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ vi.mock('/@/api/client', async () => {
return {
bootcClient: {
deleteBuilds: vi.fn(),
isMac: vi.fn(),
isWindows: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
Expand Down Expand Up @@ -53,15 +53,15 @@ 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];
expect(deleteButton).not.toBeNull();
});

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
Expand All @@ -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
Expand Down
Loading

0 comments on commit 06001a6

Please sign in to comment.