-
Notifications
You must be signed in to change notification settings - Fork 16
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 | ||
``` |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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'; | ||
|
||
|
@@ -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); | ||
} | ||
|
@@ -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(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do not return await here, return the promise |
||
} | ||
|
||
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.`, | ||
|
@@ -253,6 +283,10 @@ export class BootcApiImpl implements BootcApi { | |
return isLinux(); | ||
} | ||
|
||
async isMac(): Promise<boolean> { | ||
return isMac(); | ||
} | ||
|
||
async getUidGid(): Promise<string> { | ||
return getUidGid(); | ||
} | ||
|
@@ -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 | ||
|
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!; | ||
Comment on lines
+41
to
+46
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. please don't do that it can't be optional and then you're saying there is a value with ! it will make unexpected runtime failures |
||
} | ||
|
||
// 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 { | ||
Comment on lines
+97
to
+99
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it doesn't scale well there, the idea was to have multiple instances like you have an abstract base, then a MacArm, a MacIntel that has a preReqs method to fill |
||
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'; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should be part of the Mac implementation |
||
} | ||
|
||
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()) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. if we had proper method in macArm implementation, we would not need the is and is |
||
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'); | ||
} | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
do not need to return anything
it's an async method being void so just remove