Skip to content

Commit

Permalink
refactor: update vm manager + code suggestions
Browse files Browse the repository at this point in the history
### What does this PR do?

* Updates the VM manager so that it is more modular
* Fixes suggested code changes
* Updates DiskImageDetailsVirtualMachine check for websocket with a
  timeout.

### Screenshot / video of UI

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

N/A

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

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

Closes podman-desktop#953

### How to test this PR?

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

Everything should work like normal launching a VM for arm or amd64

Signed-off-by: Charlie Drage <[email protected]>
  • Loading branch information
cdrage committed Oct 23, 2024
1 parent b5b5e0a commit 4a7cfd4
Show file tree
Hide file tree
Showing 3 changed files with 102 additions and 86 deletions.
9 changes: 4 additions & 5 deletions packages/backend/src/api-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ 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 { createVMManager, stopCurrentVM } from './vm-manager';
import examplesCatalog from '../assets/examples.json';
import type { ExamplesList } from '/@shared/src/models/examples';

Expand All @@ -54,7 +54,7 @@ export class BootcApiImpl implements BootcApi {
}

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

async buildExists(folder: string, types: BuildType[]): Promise<boolean> {
Expand All @@ -67,7 +67,7 @@ export class BootcApiImpl implements BootcApi {

async launchVM(build: BootcBuildInfo): Promise<void> {
try {
await new VMManager(build).launchVM();
await createVMManager(build).launchVM();
// Notify it has successfully launched
await this.notify(Messages.MSG_VM_LAUNCH_ERROR, { success: 'Launched!', error: '' });
} catch (e) {
Expand All @@ -82,12 +82,11 @@ export class BootcApiImpl implements BootcApi {
}
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();
return stopCurrentVM();
}

async deleteBuilds(builds: BootcBuildInfo[]): Promise<void> {
Expand Down
172 changes: 91 additions & 81 deletions packages/backend/src/vm-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,16 +37,18 @@ const memorySize = '4G';
const websocketPort = '45252';
const rawImageLocation = 'image/disk.raw';

export default class VMManager {
private build: BootcBuildInfo;
// Abstract base class
export abstract class VMManagerBase {
protected 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!;
constructor(build: BootcBuildInfo) {
this.build = build;
}

// Launch the VM by generating the appropriate QEMU command and then launching it with process.exec
public abstract checkVMLaunchPrereqs(): Promise<string | undefined>;

protected abstract generateLaunchCommand(diskImage: string): string[];

public async launchVM(): Promise<void> {
const diskImage = this.getDiskImagePath();

Expand All @@ -65,112 +67,98 @@ export default class VMManager {

await extensionApi.process.exec('sh', ['-c', `${command.join(' ')}`]);
} catch (e) {
this.handleError(e);
handleStdError(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);
}
protected getDiskImagePath(): string {
return path.join(this.build.folder, rawImageLocation);
}
}

// Prerequisite checks before launching the VM which includes checking if QEMU is installed as well as other OS specific checks.
// Mac ARM VM Manager
class MacArmNativeVMManager 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.isArchitectureSupported()) {
if (this.build.arch !== 'arm64') {
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)) {
if (!fs.existsSync(macQemuArm64Binary)) {
return `QEMU arm64 binary not found at ${macQemuArm64Binary}. ${installDisclaimer}`;
}
if (this.build.arch === 'arm64' && !fs.existsSync(macQemuArm64Edk2)) {
if (!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[] {
protected generateLaunchCommand(diskImage: string): string[] {
return [
macQemuX86Binary,
macQemuArm64Binary,
'-m',
memorySize,
'-nographic',
'-M',
'virt',
'-accel',
'hvf',
'-cpu',
'Broadwell-v4',
'-pidfile',
pidFile,
'host',
'-smp',
'4',
'-serial',
`websocket:127.0.0.1:${websocketPort},server,nowait`,
'-pidfile',
pidFile,
'-netdev',
`user,id=mynet0,${hostForwarding}`,
`user,id=usernet,${hostForwarding}`,
'-device',
'e1000,netdev=mynet0',
'virtio-net,netdev=usernet',
'-drive',
`file=${macQemuArm64Edk2},format=raw,if=pflash,readonly=on`,
'-snapshot',
diskImage,
];
}
}

// Mac ARM running x86 images VM Manager
class MacArmX86VMManager 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 our installation document';
if (!fs.existsSync(macQemuX86Binary)) {
return `QEMU x86 binary not found at ${macQemuX86Binary}. ${installDisclaimer}`;
}
return undefined;
}

private generateMacArm64Command(diskImage: string): string[] {
protected generateLaunchCommand(diskImage: string): string[] {
return [
macQemuArm64Binary,
macQemuX86Binary,
'-m',
memorySize,
'-nographic',
'-M',
'virt',
'-cpu',
'qemu64',
'-machine',
'q35',
'-accel',
'hvf',
'-cpu',
'host',
'-smp',
'4',
'-serial',
Expand All @@ -180,21 +168,43 @@ export default class VMManager {
'-netdev',
`user,id=usernet,${hostForwarding}`,
'-device',
'virtio-net,netdev=usernet',
'-drive',
`file=${macQemuArm64Edk2},format=raw,if=pflash,readonly=on`,
'e1000,netdev=usernet',
'-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');
// 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
if (isMac() && isArm()) {
if (build.arch === 'arm64') {
return new MacArmNativeVMManager(build);
} else if (build.arch === 'amd64') {
return new MacArmX86VMManager(build);
}
}
throw new Error('Unsupported OS or architecture');
}

// Function to stop the current VM
export async function stopCurrentVM(): Promise<void> {
try {
await extensionApi.process.exec('sh', ['-c', `kill -9 \`cat ${pidFile}\``]);
} catch (e) {
if (e instanceof Error && 'stderr' in e && typeof e.stderr === 'string' && e.stderr.includes('No such process')) {
return;
}
handleStdError(e);
}
}

// Error handling function
function handleStdError(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');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -187,7 +187,14 @@ async function launchVM(build: BootcBuildInfo): Promise<void> {
// To avoid a blank terminal wait until terminal has logs and and then show it
// logs.terminal.buffer.normal will contain the "ascii cursor" with a value of 1 until there is more logs.
// we wait until buffer.normal.length is more than 1.
const startTime = Date.now();
const timeout = 30000; // 30 seconds
while (logsTerminal.buffer.normal.length < 1) {
if (Date.now() - startTime > timeout) {
console.error('Timeout waiting for terminal logs');
break;
}
await new Promise(resolve => setTimeout(resolve, 500));
}
Expand Down

0 comments on commit 4a7cfd4

Please sign in to comment.