Skip to content

Commit

Permalink
feat: add launch VM button
Browse files Browse the repository at this point in the history
### What does this PR do?

* Using QEMU we add a feature to "Launch VM" in the background
* Uses websockets, qemu as well as our xterm.js library to achieve this
* Launches in "snapshot" mode so no data is written to .raw file so the
  file can be easily re-used

### 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 containers#813

### How to test this PR?

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

1. Be on macOS silicon
2. `brew install qemu`
3. Build a bootc container image
4. Press launch VM button in actions bar

Signed-off-by: Charlie Drage <[email protected]>
  • Loading branch information
cdrage committed Sep 27, 2024
1 parent 201fce8 commit c1e9589
Show file tree
Hide file tree
Showing 20 changed files with 618 additions and 13 deletions.
11 changes: 11 additions & 0 deletions docs/qemu_install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# QEMU Install

WIP

## macOS

Install QEMU on macOS by running the following with `brew`:

```sh
brew install qemu
```
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"vitest": "^2.0.2"
},
"dependencies": {
"@xterm/addon-attach": "^0.11.0",
"js-yaml": "^4.1.0"
},
"packageManager": "[email protected]+sha512.73a29afa36a0d092ece5271de5177ecbf8318d454ecd701343131b8ebc0c1a91c487da46ab77c8e596d6acf1461e3594ced4becedf8921b074fbd8653ed7051c"
Expand Down
5 changes: 3 additions & 2 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,15 @@
"watch": "vite --mode development build -w"
},
"devDependencies": {
"@podman-desktop/api": "1.12.0",
"@podman-desktop/api": "1.12.0",
"@types/node": "^20",
"@typescript-eslint/eslint-plugin": "^8.7.0",
"@typescript-eslint/parser": "^6.16.0",
"@vitest/coverage-v8": "^2.0.2",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"eslint": "^8.57.1",
"@xterm/addon-attach": "^0.11.0",
"eslint": "^8.56.0",
"eslint-import-resolver-custom-alias": "^1.3.2",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-etc": "^2.0.3",
Expand Down
33 changes: 32 additions & 1 deletion packages/backend/src/api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 { launchVM, stopVM } from './launch-vm';

export class BootcApiImpl implements BootcApi {
private history: History;
Expand All @@ -54,6 +55,32 @@ export class BootcApiImpl implements BootcApi {
return buildDiskImage(build, this.history, overwrite);
}

async launchVM(folder: string, architecture: string): Promise<void> {
console.log('going to launch vm: ', folder);
try {
await launchVM(folder, architecture);
// 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 stopVM(): Promise<void> {
return stopVM();
}

async deleteBuilds(builds: BootcBuildInfo[]): Promise<void> {
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.`,
Expand Down Expand Up @@ -247,6 +274,10 @@ export class BootcApiImpl implements BootcApi {
return isLinux();
}

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

async getUidGid(): Promise<string> {
return getUidGid();
}
Expand Down
152 changes: 152 additions & 0 deletions packages/backend/src/launch-vm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
import path from 'node:path';
import * as extensionApi from '@podman-desktop/api';
import { isMac } from './machine-utils';
import fs from 'node:fs';

// Ignore the following line as this is where we will be storing the pid file
// similar to other projects that use pid files in /tmp
// eslint-disable-next-line sonarjs/publicly-writable-directories
const pidFile = '/tmp/qemu-podman-desktop.pid';

// Must use "homebrew" qemu binaries on macOS
// as they are found to be the most stable and reliable for the project
// as well as containing the necessary "edk2-aarch64-code.fd" file
// it is not advised to use the qemu binaries from qemu.org due to edk2-aarch64-code.fd not being included.
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';

// Host port forwarding for VM we will by default port forward 22 on the bootable container
// to :2222 on the host
const hostForwarding = 'hostfwd=tcp::2222-:22';

// Default memory size for the VM and websocket port location
const memorySize = '4G';
const websocketPort = '45252';

// Raw image location
const rawImageLocation = 'image/disk.raw';

export async function launchVM(folder: string, architecture: string): Promise<void> {
// Will ONLY work with RAW images located at image/disk.raw which is the default output location
const diskImage = path.join(folder, rawImageLocation);

// Check to see that the disk image exists before continuing
if (!fs.existsSync(diskImage)) {
throw new Error(`Raw disk image not found: ${diskImage}`);
}

// Before launching, make sure that we stop any previously running VM's and ignore any errors when stopping
try {
await stopVM();
} catch (e) {
console.error('Error stopping VM, it may have already been stopped: ', e);
}

// Generate the launch command and then run process.exec
try {
const command = generateLaunchCommand(diskImage, architecture);

// If generateLaunchCommand returns an empty array, then we are not able to launch the VM
// so simply error out and return
if (command.length === 0) {
throw new Error(
'Unable to generate the launch command for the VM, must be on the appropriate OS (mac or linux) and architecture (x86_64 or aarch64)',
);
}

// Execute the command
await extensionApi.process.exec('sh', ['-c', `${command.join(' ')}`]);
} catch (e) {
// Output the stderr information if it exists as that helps with debugging
// why the command could not run.
if (e instanceof Error && 'stderr' in e) {
console.error('Error launching VM: ', e.stderr);
} else {
console.error('Error launching VM: ', e);
}
throw e;
}
}

// Stop VM by killing the process with the pid file (/tmp/qemu-podman-desktop.pid)
export async function stopVM(): Promise<void> {
try {
await extensionApi.process.exec('sh', ['-c', `kill -9 \`cat ${pidFile}\``]);
} catch (e) {
if (e instanceof Error && 'stderr' in e) {
console.error('Error stopping VM: ', e.stderr);
} else {
console.error('Error stopping VM: ', e);
}
}
}

// Generate launch command for qemu
// this all depends on what architecture we are launching as well as
// operating system
function generateLaunchCommand(diskImage: string, architecture: string): string[] {
let command: string[] = [];
switch (architecture) {
// Case for anything amd64
case 'amd64':
if (isMac()) {
command = [
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',
// Make sure we always have snapshot here as we don't want to modify the original image
'-snapshot',
diskImage,
];
}
break;

// For any arm64 images
case 'arm64':
if (isMac()) {
command = [
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`,
// Make sure we always have snapshot here as we don't want to modify the original image
'-snapshot',
diskImage,
];
}
break;
default:
break;
}
return command;
}
5 changes: 5 additions & 0 deletions packages/backend/src/machine-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ export function isLinux(): boolean {
return linux;
}

const darwin = os.platform() === 'darwin';
export function isMac(): boolean {
return darwin;
}

// 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
Expand Down
7 changes: 7 additions & 0 deletions packages/frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Homepage from './Homepage.svelte';
import { rpcBrowser } from '/@/api/client';
import { Messages } from '/@shared/src/messages/Messages';
import Logs from './Logs.svelte';
import VM from './VM.svelte';
router.mode.hash();
Expand All @@ -36,6 +37,12 @@ onMount(() => {
<Route path="/build" breadcrumb="Build">
<Build />
</Route>
<Route path="/vm/:base64BuildImageName/:base64FolderLocation/:base64Architecture" breadcrumb="vm" let:meta>
<VM
base64BuildImageName={meta.params.base64BuildImageName}
base64FolderLocation={meta.params.base64FolderLocation}
base64Architecture={meta.params.base64Architecture} />
</Route>
<Route path="/logs/:base64BuildImageName/:base64FolderLocation" breadcrumb="Logs" let:meta>
<Logs
base64BuildImageName={meta.params.base64BuildImageName}
Expand Down
3 changes: 3 additions & 0 deletions packages/frontend/src/Build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ const mockImageInspect = {
} as unknown as ImageInspectInfo;

const mockIsLinux = false;
const mockIsMac = false;

vi.mock('./api/client', async () => {
return {
Expand All @@ -97,9 +98,11 @@ 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),
isMac: vi.fn().mockImplementation(() => mockIsMac),
},
rpcBrowser: {
subscribe: () => {
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/src/Homepage.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(),
deleteBuilds: vi.fn(),
telemetryLogUsage: vi.fn(),
isMac: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
Expand Down
Loading

0 comments on commit c1e9589

Please sign in to comment.