Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add launch VM button #865

Merged
merged 6 commits into from
Oct 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions docs/vm_launcher.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# Virtual Machine Launcher

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

We launch the virtual machine by using QEMU.

There are some caveats however:
- The virtual machine is booted as a snapshot and writes data to a /tmp file. The .raw file will remain unmodified. All changes are discarded on shut down.
- VM is shutdown when changing to another page.
- Port 22 is forwarded to 2222 locally for SSH testing. The VM may be accessed by using ssh localhost -p 2222 on an external terminal.
- VM uses 4GB of RAM by default.

## Installation

### 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": "pnpm@9.10.0+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
41 changes: 40 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 VMManager from './vm-manager';
import examplesCatalog from '../assets/examples.json';
import type { ExamplesList } from '/@shared/src/models/examples';

Expand All @@ -52,6 +53,10 @@ export class BootcApiImpl implements BootcApi {
return checkPrereqs(await getContainerEngine());
}

async checkVMLaunchPrereqs(build: BootcBuildInfo): Promise<string | undefined> {
return new VMManager(build).checkVMLaunchPrereqs();
}

async buildExists(folder: string, types: BuildType[]): Promise<boolean> {
return buildExists(folder, types);
}
Expand All @@ -60,6 +65,31 @@ export class BootcApiImpl implements BootcApi {
return buildDiskImage(build, this.history, overwrite);
}

async launchVM(build: BootcBuildInfo): Promise<void> {
try {
await new VMManager(build).launchVM();
// 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 stopCurrentVM(): Promise<void> {
return await new VMManager().stopCurrentVM();
}

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 @@ -253,6 +283,10 @@ export class BootcApiImpl implements BootcApi {
return isLinux();
}

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

async getUidGid(): Promise<string> {
return getUidGid();
}
Expand All @@ -278,6 +312,11 @@ export class BootcApiImpl implements BootcApi {
return undefined;
}

// Read from the podman desktop clipboard
async readFromClipboard(): Promise<string> {
return podmanDesktopApi.env.clipboard.readText();
}

// The API does not allow callbacks through the RPC, so instead
// we send "notify" messages to the frontend to trigger a refresh
// this method is internal and meant to be used by the API implementation
Expand Down
13 changes: 13 additions & 0 deletions packages/backend/src/machine-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import * as path from 'node:path';
import * as fs from 'node:fs';
import { satisfies, coerce } from 'semver';
import type { ContainerProviderConnection } from '@podman-desktop/api';
import { env } from '@podman-desktop/api';

function getMachineProviderEnv(connection: extensionApi.ContainerProviderConnection): string {
switch (connection.vmType) {
Expand Down Expand Up @@ -174,6 +175,18 @@ export function isLinux(): boolean {
return linux;
}

export function isMac(): boolean {
return env.isMac;
}

export function isArm(): boolean {
return os.arch() === 'arm64';
}

export function isX86(): boolean {
return os.arch() === 'x64';
}

// 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
200 changes: 200 additions & 0 deletions packages/backend/src/vm-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**********************************************************************
* 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 path from 'node:path';
import * as extensionApi from '@podman-desktop/api';
import { isArm, isMac } from './machine-utils';
import fs from 'node:fs';
import type { BootcBuildInfo } from '/@shared/src/models/bootc';

// Singular pid file location (can only run 1 VM at a time)
// eslint-disable-next-line sonarjs/publicly-writable-directories
const pidFile = '/tmp/qemu-podman-desktop.pid';

// MacOS related
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';

// Default values for VM's
const hostForwarding = 'hostfwd=tcp::2222-:22';
const memorySize = '4G';
const websocketPort = '45252';
const rawImageLocation = 'image/disk.raw';

export default class VMManager {
private build: BootcBuildInfo;

// Only values needed is the location of the VM file as well as the architecture of the image that
// will be used.
constructor(build?: BootcBuildInfo) {
this.build = build!;
}

// Launch the VM by generating the appropriate QEMU command and then launching it with process.exec
public async launchVM(): Promise<void> {
const diskImage = this.getDiskImagePath();

if (!fs.existsSync(diskImage)) {
throw new Error(`Raw disk image not found: ${diskImage}`);
}

try {
const command = this.generateLaunchCommand(diskImage);

if (command.length === 0) {
throw new Error(
'Unable to generate the launch command for the VM, ensure you are on the appropriate OS and architecture.',
);
}

await extensionApi.process.exec('sh', ['-c', `${command.join(' ')}`]);
} catch (e) {
this.handleError(e);
}
}

// We only support running one VM at at a time, so we kill the process by reading the pid from the universal pid file we use.
public async stopCurrentVM(): Promise<void> {
try {
await extensionApi.process.exec('sh', ['-c', `kill -9 \`cat ${pidFile}\``]);
} catch (e) {
// Ignore if it contains 'No such process' as that means the VM is already stopped / not running.
if (e instanceof Error && 'stderr' in e && typeof e.stderr === 'string' && e.stderr.includes('No such process')) {
return;
}
this.handleError(e);
}
}

// Prerequisite checks before launching the VM which includes checking if QEMU is installed as well as other OS specific checks.
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.isArchitectureSupported()) {
return `Unsupported architecture: ${this.build.arch}`;
}

// Future support for Mac Intel, Linux ARM, Linux X86 and Windows ARM, Windows X86 to be added here.
if (isMac() && isArm()) {
return this.checkMacPrereqs();
} else {
return 'Unsupported OS. Only MacOS Silicon is supported.';
}
}

private getDiskImagePath(): string {
return path.join(this.build.folder, rawImageLocation);
}

private isArchitectureSupported(): boolean {
return this.build.arch === 'amd64' || this.build.arch === 'arm64';
}

private checkMacPrereqs(): string | undefined {
const installDisclaimer = 'Please install qemu via our installation document';
if (!fs.existsSync(macQemuX86Binary)) {
return `QEMU x86 binary not found at ${macQemuX86Binary}. ${installDisclaimer}`;
}
if (this.build.arch === 'arm64' && !fs.existsSync(macQemuArm64Binary)) {
return `QEMU arm64 binary not found at ${macQemuArm64Binary}. ${installDisclaimer}`;
}
if (this.build.arch === 'arm64' && !fs.existsSync(macQemuArm64Edk2)) {
return `QEMU arm64 edk2-aarch64-code.fd file not found at ${macQemuArm64Edk2}. ${installDisclaimer}`;
}
return undefined;
}

// Supported: MacOS Silicon
// Unsupported: MacOS Intel, Linux, Windows
private generateLaunchCommand(diskImage: string): string[] {
// Future support for Mac Intel, Linux ARM, Linux X86 and Windows ARM, Windows X86 to be added here.
if (isMac() && isArm()) {
switch (this.build.arch) {
case 'amd64':
return this.generateMacX86Command(diskImage);
case 'arm64':
return this.generateMacArm64Command(diskImage);
}
}
return [];
}

private generateMacX86Command(diskImage: string): string[] {
return [
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',
'-snapshot',
diskImage,
];
}

private generateMacArm64Command(diskImage: string): string[] {
return [
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`,
'-snapshot',
diskImage,
];
}

// When running process.exec we should TRY and get stderr which it outputs (sometimes) so we do not get an "exit code 1" error with
// no information.
private handleError(e: unknown): void {
if (e instanceof Error && 'stderr' in e) {
throw new Error(typeof e.stderr === 'string' ? e.stderr : 'Unknown error');
} else {
throw new Error('Unknown error');
}
}
}
3 changes: 3 additions & 0 deletions packages/frontend/src/Build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const mockImageInspect = {
} as unknown as ImageInspectInfo;

const mockIsLinux = false;
const mockIsMac = false;

vi.mock('./api/client', async () => {
return {
Expand All @@ -100,11 +101,13 @@ 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),
generateUniqueBuildID: vi.fn(),
buildImage: vi.fn(),
isMac: vi.fn().mockImplementation(() => mockIsMac),
},
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 @@ -51,6 +51,7 @@ vi.mock('../../api/client', async () => {
listHistoryInfo: vi.fn(),
listBootcImages: vi.fn(),
pullImage: vi.fn(),
isMac: vi.fn(),
},
rpcBrowser: {
subscribe: () => {
Expand Down
Loading