2024-04-23 09:57:02 -04:00
|
|
|
import { execa, execaCommand, ExecaChildProcess } from 'execa';
|
|
|
|
|
import { EventEmitter } from 'events';
|
2024-07-10 22:20:12 -04:00
|
|
|
import { QmpClient, IQmpClientWriter, QmpEvent } from './QmpClient.js';
|
2024-04-23 09:57:02 -04:00
|
|
|
import { QemuDisplay } from './QemuDisplay.js';
|
|
|
|
|
import { unlink } from 'node:fs/promises';
|
|
|
|
|
|
|
|
|
|
import * as Shared from '@cvmts/shared';
|
2024-07-10 22:20:12 -04:00
|
|
|
import { Socket, connect } from 'net';
|
2024-07-14 19:04:19 -04:00
|
|
|
import { Readable, Stream, Writable } from 'stream';
|
2024-04-23 09:57:02 -04:00
|
|
|
|
|
|
|
|
export enum VMState {
|
|
|
|
|
Stopped,
|
|
|
|
|
Starting,
|
|
|
|
|
Started,
|
|
|
|
|
Stopping
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type QemuVmDefinition = {
|
2024-07-11 03:24:22 -04:00
|
|
|
id: string;
|
|
|
|
|
command: string;
|
|
|
|
|
snapshot: boolean;
|
2024-04-23 09:57:02 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/// Temporary path base (for UNIX sockets/etc.)
|
|
|
|
|
const kVmTmpPathBase = `/tmp`;
|
|
|
|
|
|
2024-07-14 19:04:19 -04:00
|
|
|
// writer implementation for process standard I/O
|
|
|
|
|
class StdioWriter implements IQmpClientWriter {
|
|
|
|
|
stdout;
|
|
|
|
|
stdin;
|
2024-07-10 22:20:12 -04:00
|
|
|
client;
|
2024-07-11 03:24:22 -04:00
|
|
|
|
2024-07-14 19:04:19 -04:00
|
|
|
constructor(stdout: Readable, stdin: Writable, client: QmpClient) {
|
|
|
|
|
this.stdout = stdout;
|
|
|
|
|
this.stdin = stdin;
|
2024-07-11 03:24:22 -04:00
|
|
|
this.client = client;
|
|
|
|
|
|
2024-07-14 19:04:19 -04:00
|
|
|
this.stdout.on('data', (data) => {
|
2024-07-11 03:24:22 -04:00
|
|
|
this.client.feed(data);
|
|
|
|
|
});
|
2024-07-10 22:20:12 -04:00
|
|
|
}
|
2024-07-11 03:24:22 -04:00
|
|
|
|
2024-07-10 22:20:12 -04:00
|
|
|
writeSome(buffer: Buffer) {
|
2024-07-14 19:04:19 -04:00
|
|
|
this.stdin.write(buffer);
|
2024-07-10 22:20:12 -04:00
|
|
|
}
|
2024-07-11 03:24:22 -04:00
|
|
|
}
|
2024-07-10 22:20:12 -04:00
|
|
|
|
2024-04-23 09:57:02 -04:00
|
|
|
export class QemuVM extends EventEmitter {
|
|
|
|
|
private state = VMState.Stopped;
|
|
|
|
|
|
2024-07-11 20:33:50 -04:00
|
|
|
// QMP stuff.
|
2024-07-10 22:20:12 -04:00
|
|
|
private qmpInstance: QmpClient = new QmpClient();
|
2024-04-23 09:57:02 -04:00
|
|
|
|
|
|
|
|
private qemuProcess: ExecaChildProcess | null = null;
|
|
|
|
|
|
2024-07-11 20:33:50 -04:00
|
|
|
private display: QemuDisplay | null = null;
|
2024-04-23 09:57:02 -04:00
|
|
|
private definition: QemuVmDefinition;
|
|
|
|
|
private addedAdditionalArguments = false;
|
|
|
|
|
|
|
|
|
|
private logger: Shared.Logger;
|
|
|
|
|
|
|
|
|
|
constructor(def: QemuVmDefinition) {
|
|
|
|
|
super();
|
|
|
|
|
this.definition = def;
|
|
|
|
|
this.logger = new Shared.Logger(`CVMTS.QEMU.QemuVM/${this.definition.id}`);
|
|
|
|
|
|
2024-07-10 22:20:12 -04:00
|
|
|
let self = this;
|
|
|
|
|
|
|
|
|
|
// Handle the STOP event sent when using -no-shutdown
|
|
|
|
|
this.qmpInstance.on(QmpEvent.Stop, async () => {
|
|
|
|
|
await self.qmpInstance.execute('system_reset');
|
2024-07-11 03:24:22 -04:00
|
|
|
});
|
2024-07-10 22:20:12 -04:00
|
|
|
|
|
|
|
|
this.qmpInstance.on(QmpEvent.Reset, async () => {
|
|
|
|
|
await self.qmpInstance.execute('cont');
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.qmpInstance.on('connected', async () => {
|
|
|
|
|
self.VMLog().Info('QMP ready');
|
|
|
|
|
|
|
|
|
|
this.display = new QemuDisplay(this.GetVncPath());
|
|
|
|
|
|
2024-07-11 20:33:50 -04:00
|
|
|
self.display?.on('connected', () => {
|
|
|
|
|
// The VM can now be considered started
|
|
|
|
|
self.VMLog().Info("Display connected");
|
|
|
|
|
self.SetState(VMState.Started);
|
|
|
|
|
})
|
|
|
|
|
|
2024-07-14 19:04:19 -04:00
|
|
|
// now that QMP has connected, connect to the display
|
2024-07-11 20:33:50 -04:00
|
|
|
self.display?.Connect();
|
2024-07-10 22:20:12 -04:00
|
|
|
});
|
2024-04-23 09:57:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Start() {
|
|
|
|
|
// Don't start while either trying to start or starting.
|
2024-06-23 02:40:13 -04:00
|
|
|
//if (this.state == VMState.Started || this.state == VMState.Starting) return;
|
2024-07-11 03:24:22 -04:00
|
|
|
if (this.qemuProcess) return;
|
2024-04-23 09:57:02 -04:00
|
|
|
|
|
|
|
|
let cmd = this.definition.command;
|
|
|
|
|
|
2024-06-23 02:40:13 -04:00
|
|
|
// Build additional command line statements to enable qmp/vnc over unix sockets
|
2024-04-24 03:50:17 -04:00
|
|
|
if (!this.addedAdditionalArguments) {
|
2024-04-23 09:57:02 -04:00
|
|
|
cmd += ' -no-shutdown';
|
2024-06-19 18:03:10 -04:00
|
|
|
if (this.definition.snapshot) cmd += ' -snapshot';
|
2024-07-14 19:04:19 -04:00
|
|
|
cmd += ` -qmp stdio -vnc unix:${this.GetVncPath()}`;
|
2024-04-23 09:57:02 -04:00
|
|
|
this.definition.command = cmd;
|
|
|
|
|
this.addedAdditionalArguments = true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.StartQemu(cmd);
|
|
|
|
|
}
|
|
|
|
|
|
2024-07-11 03:24:22 -04:00
|
|
|
SnapshotsSupported(): boolean {
|
2024-06-19 18:03:10 -04:00
|
|
|
return this.definition.snapshot;
|
2024-06-11 13:46:24 -04:00
|
|
|
}
|
|
|
|
|
|
2024-07-11 03:24:22 -04:00
|
|
|
async Reboot(): Promise<void> {
|
2024-06-11 13:46:24 -04:00
|
|
|
await this.MonitorCommand('system_reset');
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-23 09:57:02 -04:00
|
|
|
async Stop() {
|
2024-07-11 03:24:22 -04:00
|
|
|
this.AssertState(VMState.Started, 'cannot use QemuVM#Stop on a non-started VM');
|
|
|
|
|
|
|
|
|
|
// Indicate we're stopping, so we don't erroneously start trying to restart everything we're going to tear down.
|
2024-04-23 09:57:02 -04:00
|
|
|
this.SetState(VMState.Stopping);
|
|
|
|
|
|
2024-07-11 03:24:22 -04:00
|
|
|
// Stop the QEMU process, which will bring down everything else.
|
2024-04-23 09:57:02 -04:00
|
|
|
await this.StopQemu();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async Reset() {
|
2024-07-11 03:24:22 -04:00
|
|
|
this.AssertState(VMState.Started, 'cannot use QemuVM#Reset on a non-started VM');
|
|
|
|
|
await this.StopQemu();
|
2024-04-23 09:57:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async QmpCommand(command: string, args: any | null): Promise<any> {
|
2024-07-10 22:20:12 -04:00
|
|
|
return await this.qmpInstance?.execute(command, args);
|
2024-04-23 09:57:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async MonitorCommand(command: string) {
|
|
|
|
|
this.AssertState(VMState.Started, 'cannot use QemuVM#MonitorCommand on a non-started VM');
|
2024-07-11 03:24:22 -04:00
|
|
|
let result = await this.QmpCommand('human-monitor-command', {
|
2024-04-23 09:57:02 -04:00
|
|
|
'command-line': command
|
|
|
|
|
});
|
2024-07-11 03:24:22 -04:00
|
|
|
if (result == null) result = '';
|
|
|
|
|
return result;
|
2024-04-23 09:57:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async ChangeRemovableMedia(deviceName: string, imagePath: string): Promise<void> {
|
|
|
|
|
this.AssertState(VMState.Started, 'cannot use QemuVM#ChangeRemovableMedia on a non-started VM');
|
|
|
|
|
// N.B: if this throws, the code which called this should handle the error accordingly
|
|
|
|
|
await this.QmpCommand('blockdev-change-medium', {
|
|
|
|
|
device: deviceName, // techinically deprecated, but I don't feel like figuring out QOM path just for a simple function
|
|
|
|
|
filename: imagePath
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async EjectRemovableMedia(deviceName: string) {
|
|
|
|
|
this.AssertState(VMState.Started, 'cannot use QemuVM#EjectRemovableMedia on a non-started VM');
|
|
|
|
|
await this.QmpCommand('eject', {
|
|
|
|
|
device: deviceName
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
GetDisplay() {
|
2024-07-10 18:43:35 -04:00
|
|
|
return this.display!;
|
2024-04-23 09:57:02 -04:00
|
|
|
}
|
|
|
|
|
|
2024-07-11 20:33:50 -04:00
|
|
|
GetState() {
|
|
|
|
|
return this.state;
|
|
|
|
|
}
|
|
|
|
|
|
2024-04-23 09:57:02 -04:00
|
|
|
/// Private fun bits :)
|
|
|
|
|
|
|
|
|
|
private VMLog() {
|
|
|
|
|
return this.logger;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private AssertState(stateShouldBe: VMState, message: string) {
|
|
|
|
|
if (this.state !== stateShouldBe) throw new Error(message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private SetState(state: VMState) {
|
|
|
|
|
this.state = state;
|
|
|
|
|
this.emit('statechange', this.state);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private GetQmpPath() {
|
|
|
|
|
return `${kVmTmpPathBase}/cvmts-${this.definition.id}-mon`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private GetVncPath() {
|
|
|
|
|
return `${kVmTmpPathBase}/cvmts-${this.definition.id}-vnc`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async StartQemu(split: string) {
|
|
|
|
|
let self = this;
|
|
|
|
|
|
|
|
|
|
this.SetState(VMState.Starting);
|
|
|
|
|
|
2024-06-19 23:30:29 -04:00
|
|
|
this.VMLog().Info(`Starting QEMU with command \"${split}\"`);
|
|
|
|
|
|
2024-04-23 09:57:02 -04:00
|
|
|
// Start QEMU
|
2024-07-14 19:04:19 -04:00
|
|
|
this.qemuProcess = execaCommand(split, {
|
|
|
|
|
stdin: 'pipe',
|
|
|
|
|
stdout: 'pipe',
|
|
|
|
|
stderr: 'pipe'
|
|
|
|
|
});
|
2024-04-23 09:57:02 -04:00
|
|
|
|
2024-06-23 02:56:17 -04:00
|
|
|
this.qemuProcess.stderr?.on('data', (data) => {
|
2024-07-11 03:24:22 -04:00
|
|
|
self.VMLog().Error('QEMU stderr: {0}', data.toString('utf8'));
|
|
|
|
|
});
|
2024-06-23 02:56:17 -04:00
|
|
|
|
2024-04-23 09:57:02 -04:00
|
|
|
this.qemuProcess.on('spawn', async () => {
|
2024-07-11 03:24:22 -04:00
|
|
|
self.VMLog().Info('QEMU started');
|
2024-07-14 19:04:19 -04:00
|
|
|
await self.QmpStdioInit();
|
2024-04-23 09:57:02 -04:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
this.qemuProcess.on('exit', async (code) => {
|
2024-07-11 03:24:22 -04:00
|
|
|
self.VMLog().Info('QEMU process exited');
|
|
|
|
|
|
|
|
|
|
// Disconnect from the display and QMP connections.
|
|
|
|
|
await self.DisconnectDisplay();
|
2024-04-23 09:57:02 -04:00
|
|
|
|
2024-07-14 19:04:19 -04:00
|
|
|
self.qmpInstance.reset();
|
|
|
|
|
self.qmpInstance.setWriter(null);
|
2024-06-19 23:30:29 -04:00
|
|
|
|
2024-07-14 19:04:19 -04:00
|
|
|
// Remove the VNC UDS socket.
|
2024-06-19 23:30:29 -04:00
|
|
|
try {
|
2024-07-14 19:04:19 -04:00
|
|
|
await unlink(this.GetVncPath());
|
2024-07-11 03:24:22 -04:00
|
|
|
} catch (_) {}
|
2024-06-19 23:30:29 -04:00
|
|
|
|
2024-04-23 09:57:02 -04:00
|
|
|
if (self.state != VMState.Stopping) {
|
|
|
|
|
if (code == 0) {
|
2024-06-19 23:30:29 -04:00
|
|
|
// Wait a bit and restart QEMU.
|
2024-07-11 03:33:19 -04:00
|
|
|
await Shared.Sleep(500);
|
2024-04-23 09:57:02 -04:00
|
|
|
await self.StartQemu(split);
|
|
|
|
|
} else {
|
|
|
|
|
self.VMLog().Error('QEMU exited with a non-zero exit code. This usually means an error in the command line. Stopping VM.');
|
2024-07-11 03:24:22 -04:00
|
|
|
// Note that we've already tore down everything upon entry to this event handler; therefore
|
|
|
|
|
// we can simply set the state and move on.
|
|
|
|
|
this.SetState(VMState.Stopped);
|
2024-04-23 09:57:02 -04:00
|
|
|
}
|
|
|
|
|
} else {
|
2024-06-19 23:30:29 -04:00
|
|
|
// Indicate we have stopped.
|
2024-04-23 09:57:02 -04:00
|
|
|
this.SetState(VMState.Stopped);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async StopQemu() {
|
2024-06-23 02:40:13 -04:00
|
|
|
if (this.qemuProcess) {
|
|
|
|
|
this.qemuProcess?.kill('SIGTERM');
|
|
|
|
|
this.qemuProcess = null;
|
|
|
|
|
}
|
2024-04-23 09:57:02 -04:00
|
|
|
}
|
|
|
|
|
|
2024-07-14 19:04:19 -04:00
|
|
|
private async QmpStdioInit() {
|
2024-04-23 09:57:02 -04:00
|
|
|
let self = this;
|
|
|
|
|
|
2024-07-14 19:04:19 -04:00
|
|
|
self.VMLog().Info("Initializing QMP over stdio");
|
2024-07-11 20:33:50 -04:00
|
|
|
|
2024-07-14 19:04:19 -04:00
|
|
|
// Setup the QMP client.
|
|
|
|
|
let writer = new StdioWriter(this.qemuProcess?.stdout!, this.qemuProcess?.stdin!, self.qmpInstance);
|
|
|
|
|
self.qmpInstance.reset();
|
|
|
|
|
self.qmpInstance.setWriter(writer);
|
2024-04-23 09:57:02 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async DisconnectDisplay() {
|
|
|
|
|
try {
|
|
|
|
|
this.display?.Disconnect();
|
2024-07-10 22:20:12 -04:00
|
|
|
this.display = null;
|
2024-04-23 09:57:02 -04:00
|
|
|
} catch (err) {
|
|
|
|
|
// oh well lol
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|