Skip to content

Commit 06001a6

Browse files
committed
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 <!-- 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 #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]>
1 parent 9e2048e commit 06001a6

14 files changed

+285
-15
lines changed

docs/vm_launcher.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,8 @@ Install QEMU on macOS by running the following with `brew`:
1818

1919
```sh
2020
brew install qemu
21-
```
21+
```
22+
23+
### Linux
24+
25+
Install QEMU by [following the QEMU guide for your distribution](https://www.qemu.org/download/#linux).

packages/backend/src/api-impl.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { History } from './history';
2525
import * as containerUtils from './container-utils';
2626
import { Messages } from '/@shared/src/messages/Messages';
2727
import { telemetryLogger } from './extension';
28-
import { checkPrereqs, isLinux, isMac, getUidGid } from './machine-utils';
28+
import { checkPrereqs, isLinux, isMac, isWindows, getUidGid } from './machine-utils';
2929
import * as fs from 'node:fs';
3030
import path from 'node:path';
3131
import { getContainerEngine } from './container-utils';
@@ -286,6 +286,10 @@ export class BootcApiImpl implements BootcApi {
286286
return isMac();
287287
}
288288

289+
async isWindows(): Promise<boolean> {
290+
return isWindows();
291+
}
292+
289293
async getUidGid(): Promise<string> {
290294
return getUidGid();
291295
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
/**********************************************************************
2+
* Copyright (C) 2024 Red Hat, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*
16+
* SPDX-License-Identifier: Apache-2.0
17+
***********************************************************************/
18+
19+
import { beforeEach, expect, test, vi } from 'vitest';
20+
import { createVMManager, stopCurrentVM } from './vm-manager';
21+
import { isLinux, isMac, isArm } from './machine-utils';
22+
import type { BootcBuildInfo } from '/@shared/src/models/bootc';
23+
import * as extensionApi from '@podman-desktop/api';
24+
import fs from 'node:fs';
25+
26+
// Mock the functions from machine-utils
27+
vi.mock('./machine-utils', () => ({
28+
isWindows: vi.fn(),
29+
isLinux: vi.fn(),
30+
isMac: vi.fn(),
31+
isArm: vi.fn(),
32+
isX86: vi.fn(),
33+
}));
34+
vi.mock('node:fs');
35+
vi.mock('@podman-desktop/api', async () => ({
36+
process: {
37+
exec: vi.fn(),
38+
},
39+
env: {
40+
isLinux: vi.fn(),
41+
isMac: vi.fn(),
42+
isArm: vi.fn(),
43+
},
44+
}));
45+
46+
beforeEach(() => {
47+
vi.clearAllMocks();
48+
});
49+
50+
test('createVMManager: should create a MacArmNativeVMManager for macOS ARM build', () => {
51+
const build = {
52+
id: '1',
53+
image: 'test-image',
54+
imageId: '1',
55+
tag: 'latest',
56+
type: ['raw'],
57+
folder: '/path/to/folder',
58+
arch: 'arm64',
59+
} as BootcBuildInfo;
60+
61+
// Mock isMac and isArm to return true
62+
vi.mocked(isMac).mockReturnValue(true);
63+
vi.mocked(isArm).mockReturnValue(true);
64+
65+
const vmManager = createVMManager(build);
66+
expect(vmManager.constructor.name).toBe('MacArmNativeVMManager');
67+
});
68+
69+
test('createVMManager: should create a MacArmX86VMManager for macOS x86 build', () => {
70+
const build = {
71+
id: '2',
72+
image: 'test-image',
73+
imageId: '2',
74+
tag: 'latest',
75+
type: ['raw'],
76+
folder: '/path/to/folder',
77+
arch: 'amd64',
78+
} as BootcBuildInfo;
79+
80+
// Mock isMac to return true
81+
vi.mocked(isMac).mockReturnValue(true);
82+
vi.mocked(isArm).mockReturnValue(true);
83+
84+
const vmManager = createVMManager(build);
85+
expect(vmManager.constructor.name).toBe('MacArmX86VMManager');
86+
});
87+
88+
test('createVMManager: should create a LinuxX86VMManager for Linux x86 build', () => {
89+
const build = {
90+
id: '2',
91+
image: 'test-image',
92+
imageId: '2',
93+
tag: 'latest',
94+
type: ['raw'],
95+
folder: '/path/to/folder',
96+
arch: 'amd64',
97+
} as BootcBuildInfo;
98+
99+
// Mock isLinux to return true
100+
vi.mocked(isMac).mockReturnValue(false);
101+
vi.mocked(isArm).mockReturnValue(false);
102+
vi.mocked(isLinux).mockReturnValue(true);
103+
104+
const vmManager = createVMManager(build);
105+
expect(vmManager.constructor.name).toBe('LinuxX86VMManager');
106+
});
107+
108+
test('createVMManager: should create a LinuxArmVMManager for Linux ARM build', () => {
109+
const build = {
110+
id: '2',
111+
image: 'test-image',
112+
imageId: '2',
113+
tag: 'latest',
114+
type: ['raw'],
115+
folder: '/path/to/folder',
116+
arch: 'arm64',
117+
} as BootcBuildInfo;
118+
119+
// Mock isLinux to return true
120+
vi.mocked(isMac).mockReturnValue(false);
121+
vi.mocked(isArm).mockReturnValue(false);
122+
vi.mocked(isLinux).mockReturnValue(true);
123+
124+
const vmManager = createVMManager(build);
125+
expect(vmManager.constructor.name).toBe('LinuxArmVMManager');
126+
});
127+
128+
test('createVMManager: should throw an error for unsupported OS/architecture', () => {
129+
const build = {
130+
id: '3',
131+
image: 'test-image',
132+
imageId: '3',
133+
tag: 'latest',
134+
type: ['raw'],
135+
folder: '/path/to/folder',
136+
arch: 'asdf',
137+
} as BootcBuildInfo;
138+
139+
// Arch is explicitly set to an unsupported value (asdf)
140+
expect(() => createVMManager(build)).toThrow('Unsupported OS or architecture');
141+
});
142+
143+
test('stopCurrentVM: should call kill command with the pid from pidfile', async () => {
144+
vi.spyOn(fs.promises, 'readFile').mockResolvedValueOnce('1234');
145+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
146+
vi.spyOn(extensionApi.process, 'exec').mockResolvedValueOnce({ stdout: '' } as any);
147+
148+
await stopCurrentVM();
149+
expect(extensionApi.process.exec).toHaveBeenCalledWith('sh', ['-c', 'kill -9 `cat /tmp/qemu-podman-desktop.pid`']);
150+
});

packages/backend/src/vm-manager.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818

1919
import path from 'node:path';
2020
import * as extensionApi from '@podman-desktop/api';
21-
import { isArm, isMac } from './machine-utils';
21+
import { isArm, isLinux, isMac } from './machine-utils';
2222
import fs from 'node:fs';
2323
import type { BootcBuildInfo } from '/@shared/src/models/bootc';
2424

@@ -31,6 +31,13 @@ const macQemuArm64Binary = '/opt/homebrew/bin/qemu-system-aarch64';
3131
const macQemuArm64Edk2 = '/opt/homebrew/share/qemu/edk2-aarch64-code.fd';
3232
const macQemuX86Binary = '/opt/homebrew/bin/qemu-system-x86_64';
3333

34+
// Linux related
35+
// Context: on linux, since we are in a flatpak environment, we let podman desktop handle where the qemu
36+
// binary is, so we just need to call qemu-system-aarch64 instead of the full path
37+
// this is not an issue with the mac version since we are not in a containerized environment and we explicitly need the brew version.
38+
const linuxQemuArm64Binary = 'qemu-system-aarch64';
39+
const linuxQemuX86Binary = 'qemu-system-x86_64';
40+
3441
// Default values for VM's
3542
const hostForwarding = 'hostfwd=tcp::2222-:22';
3643
const memorySize = '4G';
@@ -173,6 +180,96 @@ class MacArmX86VMManager extends VMManagerBase {
173180
}
174181
}
175182

183+
class LinuxArmVMManager extends VMManagerBase {
184+
public async checkVMLaunchPrereqs(): Promise<string | undefined> {
185+
const diskImage = this.getDiskImagePath();
186+
if (!fs.existsSync(diskImage)) {
187+
return `Raw disk image not found at ${diskImage}. Please build a .raw disk image first.`;
188+
}
189+
190+
if (this.build.arch !== 'arm64') {
191+
return `Unsupported architecture: ${this.build.arch}`;
192+
}
193+
194+
const installDisclaimer = 'Please install qemu via your package manager.';
195+
try {
196+
await extensionApi.process.exec(linuxQemuArm64Binary, ['--version']);
197+
} catch {
198+
return `Unable to run "${linuxQemuArm64Binary} --version". ${installDisclaimer}`;
199+
}
200+
return undefined;
201+
}
202+
203+
protected generateLaunchCommand(diskImage: string): string[] {
204+
return [
205+
linuxQemuArm64Binary,
206+
'-m',
207+
memorySize,
208+
'-nographic',
209+
'-M',
210+
'virt',
211+
'-cpu',
212+
'max',
213+
'-smp',
214+
'4',
215+
'-serial',
216+
`websocket:127.0.0.1:${websocketPort},server,nowait`,
217+
'-pidfile',
218+
pidFile,
219+
'-netdev',
220+
`user,id=usernet,${hostForwarding}`,
221+
'-device',
222+
'virtio-net,netdev=usernet',
223+
'-snapshot',
224+
diskImage,
225+
];
226+
}
227+
}
228+
229+
class LinuxX86VMManager extends VMManagerBase {
230+
public async checkVMLaunchPrereqs(): Promise<string | undefined> {
231+
const diskImage = this.getDiskImagePath();
232+
if (!fs.existsSync(diskImage)) {
233+
return `Raw disk image not found at ${diskImage}. Please build a .raw disk image first.`;
234+
}
235+
236+
if (this.build.arch !== 'amd64') {
237+
return `Unsupported architecture: ${this.build.arch}`;
238+
}
239+
240+
const installDisclaimer = 'Please install qemu via your package manager.';
241+
try {
242+
await extensionApi.process.exec(linuxQemuX86Binary, ['--version']);
243+
} catch {
244+
return `Unable to run "${linuxQemuX86Binary} --version". ${installDisclaimer}`;
245+
}
246+
return undefined;
247+
}
248+
249+
protected generateLaunchCommand(diskImage: string): string[] {
250+
return [
251+
linuxQemuX86Binary,
252+
'-m',
253+
memorySize,
254+
'-nographic',
255+
'-cpu',
256+
'Broadwell-v4',
257+
'-smp',
258+
'4',
259+
'-serial',
260+
`websocket:127.0.0.1:${websocketPort},server,nowait`,
261+
'-pidfile',
262+
pidFile,
263+
'-netdev',
264+
`user,id=usernet,${hostForwarding}`,
265+
'-device',
266+
'e1000,netdev=usernet',
267+
'-snapshot',
268+
diskImage,
269+
];
270+
}
271+
}
272+
176273
// Factory function to create the appropriate VM Manager
177274
export function createVMManager(build: BootcBuildInfo): VMManagerBase {
178275
// Only thing that we support is Mac M1 at the moment
@@ -182,6 +279,12 @@ export function createVMManager(build: BootcBuildInfo): VMManagerBase {
182279
} else if (build.arch === 'amd64') {
183280
return new MacArmX86VMManager(build);
184281
}
282+
} else if (isLinux()) {
283+
if (build.arch === 'arm64') {
284+
return new LinuxArmVMManager(build);
285+
} else if (build.arch === 'amd64') {
286+
return new LinuxX86VMManager(build);
287+
}
185288
}
186289
throw new Error('Unsupported OS or architecture');
187290
}

packages/frontend/src/Build.spec.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ const mockImageInspect = {
9393

9494
const mockIsLinux = false;
9595
const mockIsMac = false;
96+
const mockIsWindows = false;
9697

9798
vi.mock('./api/client', async () => {
9899
return {
@@ -108,6 +109,7 @@ vi.mock('./api/client', async () => {
108109
generateUniqueBuildID: vi.fn(),
109110
buildImage: vi.fn(),
110111
isMac: vi.fn().mockImplementation(() => mockIsMac),
112+
isWindows: vi.fn().mockImplementation(() => mockIsWindows),
111113
},
112114
rpcBrowser: {
113115
subscribe: () => {

packages/frontend/src/lib/dashboard/Dashboard.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ vi.mock('../../api/client', async () => {
5252
listBootcImages: vi.fn(),
5353
pullImage: vi.fn(),
5454
isMac: vi.fn(),
55+
isWindows: vi.fn(),
5556
},
5657
rpcBrowser: {
5758
subscribe: () => {

packages/frontend/src/lib/disk-image/DiskImageActions.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ vi.mock('/@/api/client', async () => {
2525
return {
2626
bootcClient: {
2727
deleteBuilds: vi.fn(),
28-
isMac: vi.fn(),
28+
isWindows: vi.fn(),
2929
},
3030
rpcBrowser: {
3131
subscribe: () => {
@@ -53,15 +53,15 @@ beforeEach(() => {
5353
});
5454

5555
test('Renders Delete Build button', async () => {
56-
vi.mocked(bootcClient.isMac).mockResolvedValue(false);
56+
vi.mocked(bootcClient.isWindows).mockResolvedValue(false);
5757
render(DiskImageActions, { object: mockHistoryInfo });
5858

5959
const deleteButton = screen.getAllByRole('button', { name: 'Delete Build' })[0];
6060
expect(deleteButton).not.toBeNull();
6161
});
6262

6363
test('Test clicking on delete button', async () => {
64-
vi.mocked(bootcClient.isMac).mockResolvedValue(false);
64+
vi.mocked(bootcClient.isWindows).mockResolvedValue(false);
6565
render(DiskImageActions, { object: mockHistoryInfo });
6666

6767
// spy on deleteBuild function
@@ -75,7 +75,7 @@ test('Test clicking on delete button', async () => {
7575
});
7676

7777
test('Test clicking on logs button', async () => {
78-
vi.mocked(bootcClient.isMac).mockResolvedValue(false);
78+
vi.mocked(bootcClient.isWindows).mockResolvedValue(false);
7979
render(DiskImageActions, { object: mockHistoryInfo });
8080

8181
// Click on logs button

0 commit comments

Comments
 (0)