368 lines
10 KiB
TypeScript
368 lines
10 KiB
TypeScript
|
|
import * as plugins from './plugins.js';
|
||
|
|
import type {
|
||
|
|
TVMState,
|
||
|
|
IMicroVMConfig,
|
||
|
|
ISnapshotCreateParams,
|
||
|
|
ISnapshotLoadParams,
|
||
|
|
ITapDevice,
|
||
|
|
} from './interfaces/index.js';
|
||
|
|
import { SmartVMError } from './interfaces/index.js';
|
||
|
|
import { VMConfig } from './classes.vmconfig.js';
|
||
|
|
import { SocketClient } from './classes.socketclient.js';
|
||
|
|
import { FirecrackerProcess } from './classes.firecrackerprocess.js';
|
||
|
|
import { NetworkManager } from './classes.networkmanager.js';
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Represents a single Firecracker MicroVM with full lifecycle management.
|
||
|
|
* State machine: created → configuring → running → paused → stopped
|
||
|
|
*/
|
||
|
|
export class MicroVM {
|
||
|
|
public readonly id: string;
|
||
|
|
public state: TVMState = 'created';
|
||
|
|
|
||
|
|
private vmConfig: VMConfig;
|
||
|
|
private process: FirecrackerProcess | null = null;
|
||
|
|
private socketClient: SocketClient | null = null;
|
||
|
|
private networkManager: NetworkManager;
|
||
|
|
private binaryPath: string;
|
||
|
|
private socketPath: string;
|
||
|
|
private tapDevices: ITapDevice[] = [];
|
||
|
|
|
||
|
|
constructor(
|
||
|
|
id: string,
|
||
|
|
config: IMicroVMConfig,
|
||
|
|
binaryPath: string,
|
||
|
|
socketPath: string,
|
||
|
|
networkManager: NetworkManager,
|
||
|
|
) {
|
||
|
|
this.id = id;
|
||
|
|
this.vmConfig = new VMConfig(config);
|
||
|
|
this.binaryPath = binaryPath;
|
||
|
|
this.socketPath = socketPath;
|
||
|
|
this.networkManager = networkManager;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Assert that the VM is in one of the expected states.
|
||
|
|
*/
|
||
|
|
private assertState(expected: TVMState[], operation: string): void {
|
||
|
|
if (!expected.includes(this.state)) {
|
||
|
|
throw new SmartVMError(
|
||
|
|
`Cannot ${operation}: VM is in state '${this.state}', expected one of [${expected.join(', ')}]`,
|
||
|
|
'INVALID_STATE',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Start the MicroVM.
|
||
|
|
* Validates config, starts the Firecracker process, applies pre-boot configuration, and boots the VM.
|
||
|
|
*/
|
||
|
|
public async start(): Promise<void> {
|
||
|
|
this.assertState(['created'], 'start');
|
||
|
|
|
||
|
|
// Validate configuration
|
||
|
|
const validation = this.vmConfig.validate();
|
||
|
|
if (!validation.valid) {
|
||
|
|
throw new SmartVMError(
|
||
|
|
`Invalid VM configuration: ${validation.errors.join('; ')}`,
|
||
|
|
'INVALID_CONFIG',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
this.state = 'configuring';
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Start the Firecracker process
|
||
|
|
this.process = new FirecrackerProcess({
|
||
|
|
binaryPath: this.binaryPath,
|
||
|
|
socketPath: this.socketPath,
|
||
|
|
});
|
||
|
|
await this.process.start();
|
||
|
|
this.socketClient = this.process.socketClient;
|
||
|
|
|
||
|
|
// Apply pre-boot configuration in order
|
||
|
|
|
||
|
|
// 1. Logger (optional, must be first)
|
||
|
|
const loggerPayload = this.vmConfig.toLoggerPayload();
|
||
|
|
if (loggerPayload) {
|
||
|
|
await this.apiPut('/logger', loggerPayload);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 2. Metrics (optional)
|
||
|
|
const metricsPayload = this.vmConfig.toMetricsPayload();
|
||
|
|
if (metricsPayload) {
|
||
|
|
await this.apiPut('/metrics', metricsPayload);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 3. Machine config
|
||
|
|
await this.apiPut('/machine-config', this.vmConfig.toMachineConfigPayload());
|
||
|
|
|
||
|
|
// 4. Boot source
|
||
|
|
await this.apiPut('/boot-source', this.vmConfig.toBootSourcePayload());
|
||
|
|
|
||
|
|
// 5. Drives
|
||
|
|
if (this.vmConfig.config.drives) {
|
||
|
|
for (const drive of this.vmConfig.config.drives) {
|
||
|
|
const payload = this.vmConfig.toDrivePayload(drive);
|
||
|
|
await this.apiPut(`/drives/${drive.driveId}`, payload);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 6. Network interfaces
|
||
|
|
if (this.vmConfig.config.networkInterfaces) {
|
||
|
|
for (const iface of this.vmConfig.config.networkInterfaces) {
|
||
|
|
// Create TAP device if hostDevName not manually specified
|
||
|
|
if (!iface.hostDevName) {
|
||
|
|
const tap = await this.networkManager.createTapDevice(this.id, iface.ifaceId);
|
||
|
|
this.tapDevices.push(tap);
|
||
|
|
iface.hostDevName = tap.tapName;
|
||
|
|
if (!iface.guestMac) {
|
||
|
|
iface.guestMac = tap.mac;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
const payload = this.vmConfig.toNetworkInterfacePayload(iface);
|
||
|
|
await this.apiPut(`/network-interfaces/${iface.ifaceId}`, payload);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 7. Vsock (optional)
|
||
|
|
const vsockPayload = this.vmConfig.toVsockPayload();
|
||
|
|
if (vsockPayload) {
|
||
|
|
await this.apiPut('/vsock', vsockPayload);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 8. Balloon (optional)
|
||
|
|
const balloonPayload = this.vmConfig.toBalloonPayload();
|
||
|
|
if (balloonPayload) {
|
||
|
|
await this.apiPut('/balloon', balloonPayload);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 9. MMDS config (optional)
|
||
|
|
const mmdsPayload = this.vmConfig.toMmdsConfigPayload();
|
||
|
|
if (mmdsPayload) {
|
||
|
|
await this.apiPut('/mmds/config', mmdsPayload);
|
||
|
|
}
|
||
|
|
|
||
|
|
// Boot the VM
|
||
|
|
await this.apiPut('/actions', { action_type: 'InstanceStart' });
|
||
|
|
|
||
|
|
this.state = 'running';
|
||
|
|
} catch (err) {
|
||
|
|
this.state = 'error';
|
||
|
|
// Clean up on failure
|
||
|
|
await this.cleanup();
|
||
|
|
if (err instanceof SmartVMError) {
|
||
|
|
throw err;
|
||
|
|
}
|
||
|
|
throw new SmartVMError(
|
||
|
|
`Failed to start VM ${this.id}: ${err.message}`,
|
||
|
|
'START_FAILED',
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Pause the running VM.
|
||
|
|
*/
|
||
|
|
public async pause(): Promise<void> {
|
||
|
|
this.assertState(['running'], 'pause');
|
||
|
|
await this.apiPatch('/vm', { state: 'Paused' });
|
||
|
|
this.state = 'paused';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Resume a paused VM.
|
||
|
|
*/
|
||
|
|
public async resume(): Promise<void> {
|
||
|
|
this.assertState(['paused'], 'resume');
|
||
|
|
await this.apiPatch('/vm', { state: 'Resumed' });
|
||
|
|
this.state = 'running';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Stop the VM.
|
||
|
|
* Sends SendCtrlAltDel first for graceful shutdown, then kills the process if needed.
|
||
|
|
*/
|
||
|
|
public async stop(): Promise<void> {
|
||
|
|
this.assertState(['running', 'paused'], 'stop');
|
||
|
|
|
||
|
|
try {
|
||
|
|
// Try graceful shutdown via SendCtrlAltDel
|
||
|
|
await this.apiPut('/actions', { action_type: 'SendCtrlAltDel' });
|
||
|
|
// Wait a bit for the VM to shut down
|
||
|
|
await plugins.smartdelay.delayFor(2000);
|
||
|
|
} catch {
|
||
|
|
// SendCtrlAltDel may fail if the VM is already stopping
|
||
|
|
}
|
||
|
|
|
||
|
|
// Force stop the process
|
||
|
|
if (this.process) {
|
||
|
|
await this.process.stop();
|
||
|
|
}
|
||
|
|
|
||
|
|
this.state = 'stopped';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Create a snapshot of the VM.
|
||
|
|
*/
|
||
|
|
public async createSnapshot(params: ISnapshotCreateParams): Promise<void> {
|
||
|
|
this.assertState(['paused'], 'createSnapshot');
|
||
|
|
await this.apiPut('/snapshot/create', {
|
||
|
|
snapshot_path: params.snapshotPath,
|
||
|
|
mem_file_path: params.memFilePath,
|
||
|
|
snapshot_type: params.snapshotType || 'Full',
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Load a snapshot into the VM.
|
||
|
|
*/
|
||
|
|
public async loadSnapshot(params: ISnapshotLoadParams): Promise<void> {
|
||
|
|
this.assertState(['created', 'configuring'], 'loadSnapshot');
|
||
|
|
await this.apiPut('/snapshot/load', {
|
||
|
|
snapshot_path: params.snapshotPath,
|
||
|
|
mem_file_path: params.memFilePath,
|
||
|
|
enable_diff_snapshots: params.enableDiffSnapshots ?? false,
|
||
|
|
resume_vm: params.resumeVm ?? false,
|
||
|
|
});
|
||
|
|
this.state = params.resumeVm ? 'running' : 'paused';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Set MMDS metadata.
|
||
|
|
*/
|
||
|
|
public async setMetadata(data: Record<string, any>): Promise<void> {
|
||
|
|
this.assertState(['running', 'paused'], 'setMetadata');
|
||
|
|
await this.apiPut('/mmds', data);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get MMDS metadata.
|
||
|
|
*/
|
||
|
|
public async getMetadata(): Promise<any> {
|
||
|
|
this.assertState(['running', 'paused'], 'getMetadata');
|
||
|
|
const response = await this.socketClient!.get('/mmds');
|
||
|
|
return response.body;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update a drive on the running VM (hot-plug path update).
|
||
|
|
*/
|
||
|
|
public async updateDrive(driveId: string, pathOnHost: string): Promise<void> {
|
||
|
|
this.assertState(['running', 'paused'], 'updateDrive');
|
||
|
|
await this.apiPatch(`/drives/${driveId}`, {
|
||
|
|
drive_id: driveId,
|
||
|
|
path_on_host: pathOnHost,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update a network interface rate limiter on the running VM.
|
||
|
|
*/
|
||
|
|
public async updateNetworkInterface(
|
||
|
|
ifaceId: string,
|
||
|
|
update: Record<string, any>,
|
||
|
|
): Promise<void> {
|
||
|
|
this.assertState(['running', 'paused'], 'updateNetworkInterface');
|
||
|
|
await this.apiPatch(`/network-interfaces/${ifaceId}`, update);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Update balloon device on the running VM.
|
||
|
|
*/
|
||
|
|
public async updateBalloon(amountMib: number): Promise<void> {
|
||
|
|
this.assertState(['running', 'paused'], 'updateBalloon');
|
||
|
|
await this.apiPatch('/balloon', { amount_mib: amountMib });
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get VM instance info.
|
||
|
|
*/
|
||
|
|
public async getInfo(): Promise<any> {
|
||
|
|
const response = await this.socketClient!.get('/');
|
||
|
|
return response.body;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get Firecracker version info.
|
||
|
|
*/
|
||
|
|
public async getVersion(): Promise<any> {
|
||
|
|
const response = await this.socketClient!.get('/version');
|
||
|
|
return response.body;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the TAP devices associated with this VM.
|
||
|
|
*/
|
||
|
|
public getTapDevices(): ITapDevice[] {
|
||
|
|
return [...this.tapDevices];
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Get the VMConfig instance for payload inspection.
|
||
|
|
*/
|
||
|
|
public getVMConfig(): VMConfig {
|
||
|
|
return this.vmConfig;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Full cleanup: stop process, remove socket, remove TAP devices.
|
||
|
|
*/
|
||
|
|
public async cleanup(): Promise<void> {
|
||
|
|
// Stop process
|
||
|
|
if (this.process) {
|
||
|
|
await this.process.cleanup();
|
||
|
|
this.process = null;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Remove TAP devices
|
||
|
|
for (const tap of this.tapDevices) {
|
||
|
|
await this.networkManager.removeTapDevice(tap.tapName);
|
||
|
|
}
|
||
|
|
this.tapDevices = [];
|
||
|
|
|
||
|
|
this.socketClient = null;
|
||
|
|
if (this.state !== 'error') {
|
||
|
|
this.state = 'stopped';
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Helper: PUT request with error handling.
|
||
|
|
*/
|
||
|
|
private async apiPut(path: string, body: Record<string, any>): Promise<void> {
|
||
|
|
if (!this.socketClient) {
|
||
|
|
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
|
||
|
|
}
|
||
|
|
const response = await this.socketClient.put(path, body);
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new SmartVMError(
|
||
|
|
`API PUT ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
||
|
|
'API_ERROR',
|
||
|
|
response.statusCode,
|
||
|
|
response.body,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Helper: PATCH request with error handling.
|
||
|
|
*/
|
||
|
|
private async apiPatch(path: string, body: Record<string, any>): Promise<void> {
|
||
|
|
if (!this.socketClient) {
|
||
|
|
throw new SmartVMError('Socket client not initialized', 'NO_CLIENT');
|
||
|
|
}
|
||
|
|
const response = await this.socketClient.patch(path, body);
|
||
|
|
if (!response.ok) {
|
||
|
|
throw new SmartVMError(
|
||
|
|
`API PATCH ${path} failed with status ${response.statusCode}: ${JSON.stringify(response.body)}`,
|
||
|
|
'API_ERROR',
|
||
|
|
response.statusCode,
|
||
|
|
response.body,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|