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 #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 30, 2024
1 parent 7b17a94 commit 7f889c4
Show file tree
Hide file tree
Showing 22 changed files with 620 additions and 11 deletions.
15 changes: 15 additions & 0 deletions docs/qemu_install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# QEMU Install

Virtual Machine support is **experimental** and is only meant to run *one VM at a time* within the BootC extension.

Below are installation instructions on how to get started.

We currently only support macOS.

## 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 @@ -65,6 +65,7 @@
"vitest": "^2.0.2"
},
"dependencies": {
"@xterm/addon-attach": "^0.11.0",
"js-yaml": "^4.1.0"
},
"packageManager": "[email protected]+sha512.73a29afa36a0d092ece5271de5177ecbf8318d454ecd701343131b8ebc0c1a91c487da46ab77c8e596d6acf1461e3594ced4becedf8921b074fbd8653ed7051c"
Expand Down
1 change: 1 addition & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"@vitest/coverage-v8": "^2.0.2",
"@xterm/xterm": "^5.5.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-attach": "^0.11.0",
"eslint": "^8.57.1",
"eslint-import-resolver-custom-alias": "^1.3.2",
"eslint-import-resolver-typescript": "^3.6.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
1 change: 1 addition & 0 deletions packages/frontend/src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getRouterState } from './api/client';
import Homepage from './Homepage.svelte';
import { rpcBrowser } from '/@/api/client';
import { Messages } from '/@shared/src/messages/Messages';
import VM from './lib/disk-image/DiskImageDetailsVirtualMachine.svelte';
import DiskImageDetails from './lib/disk-image/DiskImageDetails.svelte';
router.mode.hash();
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
20 changes: 20 additions & 0 deletions packages/frontend/src/VMConnectionStatus.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script lang="ts">
import Label from './lib/upstream/Label.svelte';
export let status: string;
function getClassColor(): string {
if (
status.includes('Connection closed') ||
status.includes('Connection error') ||
status.includes('VM launch error')
) {
return 'bg-[var(--pd-status-disconnected)]';
}
return 'bg-[var(--pd-status-connected)]';
}
</script>

{#if status}
<Label role="status" name={status}><div class="w-2 h-2 {getClassColor()} rounded-full mx-1"></div></Label>
{/if}
4 changes: 4 additions & 0 deletions packages/frontend/src/lib/BootcActions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ vi.mock('../api/client', async () => {
return {
bootcClient: {
deleteBuilds: vi.fn(),
isMac: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
Expand Down Expand Up @@ -52,13 +53,15 @@ beforeEach(() => {
});

test('Renders Delete Build button', async () => {
vi.mocked(bootcClient.isMac).mockResolvedValue(false);
render(BootcActions, { 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);
render(BootcActions, { object: mockHistoryInfo });

// spy on deleteBuild function
Expand All @@ -72,6 +75,7 @@ test('Test clicking on delete button', async () => {
});

test('Test clicking on logs button', async () => {
vi.mocked(bootcClient.isMac).mockResolvedValue(false);
render(BootcActions, { object: mockHistoryInfo });

// Click on logs button
Expand Down
18 changes: 17 additions & 1 deletion packages/frontend/src/lib/BootcActions.svelte
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
<script lang="ts">
import type { BootcBuildInfo } from '/@shared/src/models/bootc';
import ListItemButtonIcon from './upstream/ListItemButtonIcon.svelte';
import { faFileAlt, faTrash } from '@fortawesome/free-solid-svg-icons';
import { faFileAlt, faTrash, faTerminal } from '@fortawesome/free-solid-svg-icons';
import { router } from 'tinro';
import { bootcClient } from '../api/client';
import { onMount } from 'svelte';
export let object: BootcBuildInfo;
export let detailed = false;
let isMac = false;
// Delete the build
async function deleteBuild(): Promise<void> {
await bootcClient.deleteBuilds([object]);
Expand All @@ -17,7 +20,20 @@ async function deleteBuild(): Promise<void> {
async function gotoLogs(): Promise<void> {
router.goto(`/details/${btoa(object.id)}/build`);
}
async function gotoVM(): Promise<void> {
router.goto(`/details/${btoa(object.id)}/vm`);
}
onMount(async () => {
isMac = await bootcClient.isMac();
});
</script>

<!-- Only show the Terminal button if object.arch actually exists or else we will not be able to pass in the architecture information to the build correctly.
Only show if on macOS as well as that is the only option we support at the moment -->
{#if object.arch && isMac}
<ListItemButtonIcon title="Launch VM" onClick={() => gotoVM()} detailed={detailed} icon={faTerminal} />
{/if}
<ListItemButtonIcon title="Build Logs" onClick={() => gotoLogs()} detailed={detailed} icon={faFileAlt} />
<ListItemButtonIcon title="Delete Build" onClick={() => deleteBuild()} detailed={detailed} icon={faTrash} />
3 changes: 3 additions & 0 deletions packages/frontend/src/lib/BootcColumnActions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const mockHistoryInfo: BootcBuildInfo = {

vi.mock('../api/client', async () => {
return {
bootcClient: {
isMac: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
return {
Expand Down
Loading

0 comments on commit 7f889c4

Please sign in to comment.