184 lines
4.6 KiB
TypeScript
184 lines
4.6 KiB
TypeScript
import { type ChildProcess } from "node:child_process";
|
|
import { mkdirSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { randomBytes } from "node:crypto";
|
|
import { CONFIG } from "./config.js";
|
|
import type { VMConfig, RunResult, RunOptions } from "./types.js";
|
|
import * as api from "./firecracker-api.js";
|
|
import { allocateIp, releaseIp, deleteTap } from "./network.js";
|
|
import {
|
|
ensureBaseImage,
|
|
ensureSshKeypair,
|
|
createRunCopy,
|
|
injectSshKey,
|
|
deleteRunCopy,
|
|
} from "./rootfs.js";
|
|
import { waitForSsh, execCommand } from "./ssh.js";
|
|
import { registerVm, unregisterVm } from "./cleanup.js";
|
|
import { snapshotExists } from "./snapshot.js";
|
|
import {
|
|
setupNetwork,
|
|
spawnFirecracker,
|
|
bootVM,
|
|
killFirecracker,
|
|
} from "./firecracker-vm.js";
|
|
|
|
function log(verbose: boolean, msg: string) {
|
|
if (verbose) process.stderr.write(`[fireclaw] ${msg}\n`);
|
|
}
|
|
|
|
export class VMInstance {
|
|
private config: VMConfig;
|
|
private process: ChildProcess | null = null;
|
|
private octet = 0;
|
|
|
|
constructor(config: VMConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
static async run(
|
|
command: string,
|
|
opts: RunOptions = {}
|
|
): Promise<RunResult> {
|
|
if (!opts.noSnapshot && snapshotExists()) {
|
|
return VMInstance.runFromSnapshot(command, opts);
|
|
}
|
|
return VMInstance.runColdBoot(command, opts);
|
|
}
|
|
|
|
private static async runFromSnapshot(
|
|
command: string,
|
|
opts: RunOptions
|
|
): Promise<RunResult> {
|
|
const id = `fc-snap-${randomBytes(3).toString("hex")}`;
|
|
const verbose = opts.verbose ?? false;
|
|
const timeoutMs = opts.timeout ?? CONFIG.vm.defaultTimeoutMs;
|
|
const snap = CONFIG.snapshot;
|
|
|
|
mkdirSync(CONFIG.socketDir, { recursive: true });
|
|
|
|
const config: VMConfig = {
|
|
id,
|
|
guestIp: snap.ip,
|
|
tapDevice: snap.tapDevice,
|
|
socketPath: join(CONFIG.socketDir, `${id}.sock`),
|
|
rootfsPath: "",
|
|
timeoutMs,
|
|
verbose,
|
|
};
|
|
|
|
const vm = new VMInstance(config);
|
|
vm.octet = 0;
|
|
registerVm(vm);
|
|
|
|
try {
|
|
log(verbose, `VM ${id}: restoring from snapshot...`);
|
|
setupNetwork(snap.tapDevice);
|
|
|
|
vm.process = await spawnFirecracker(config.socketPath);
|
|
await api.putSnapshotLoad(
|
|
config.socketPath,
|
|
snap.statePath,
|
|
snap.memPath
|
|
);
|
|
await api.patchVm(config.socketPath, "Resumed");
|
|
|
|
log(verbose, `VM ${id}: resumed, waiting for SSH...`);
|
|
await waitForSsh(snap.ip);
|
|
|
|
log(verbose, `VM ${id}: executing command...`);
|
|
const result = await execCommand(snap.ip, command, timeoutMs, verbose);
|
|
|
|
log(
|
|
verbose,
|
|
`VM ${id}: done (exit=${result.exitCode}, ${result.durationMs}ms)`
|
|
);
|
|
return result;
|
|
} finally {
|
|
await vm.destroy();
|
|
unregisterVm(vm);
|
|
}
|
|
}
|
|
|
|
private static async runColdBoot(
|
|
command: string,
|
|
opts: RunOptions
|
|
): Promise<RunResult> {
|
|
const id = `fc-${randomBytes(3).toString("hex")}`;
|
|
const verbose = opts.verbose ?? false;
|
|
const timeoutMs = opts.timeout ?? CONFIG.vm.defaultTimeoutMs;
|
|
|
|
ensureBaseImage();
|
|
ensureSshKeypair();
|
|
|
|
const { ip, octet } = allocateIp();
|
|
const tapDevice = `fctap${octet}`;
|
|
|
|
const config: VMConfig = {
|
|
id,
|
|
guestIp: ip,
|
|
tapDevice,
|
|
socketPath: join(CONFIG.socketDir, `${id}.sock`),
|
|
rootfsPath: "",
|
|
timeoutMs,
|
|
verbose,
|
|
};
|
|
|
|
const vm = new VMInstance(config);
|
|
vm.octet = octet;
|
|
registerVm(vm);
|
|
|
|
try {
|
|
log(verbose, `VM ${id}: preparing rootfs...`);
|
|
config.rootfsPath = createRunCopy(id);
|
|
injectSshKey(config.rootfsPath);
|
|
|
|
log(verbose, `VM ${id}: creating tap ${tapDevice}...`);
|
|
setupNetwork(tapDevice);
|
|
|
|
log(verbose, `VM ${id}: booting...`);
|
|
vm.process = await spawnFirecracker(config.socketPath);
|
|
await bootVM({
|
|
socketPath: config.socketPath,
|
|
rootfsPath: config.rootfsPath,
|
|
tapDevice,
|
|
ip,
|
|
octet,
|
|
vcpu: opts.vcpu,
|
|
mem: opts.mem,
|
|
});
|
|
|
|
log(verbose, `VM ${id}: waiting for SSH at ${ip}...`);
|
|
await waitForSsh(ip);
|
|
|
|
log(verbose, `VM ${id}: executing command...`);
|
|
const result = await execCommand(ip, command, timeoutMs, verbose);
|
|
|
|
log(
|
|
verbose,
|
|
`VM ${id}: done (exit=${result.exitCode}, ${result.durationMs}ms)`
|
|
);
|
|
return result;
|
|
} finally {
|
|
await vm.destroy();
|
|
unregisterVm(vm);
|
|
}
|
|
}
|
|
|
|
async destroy() {
|
|
const { config } = this;
|
|
log(config.verbose, `VM ${config.id}: cleaning up...`);
|
|
|
|
await killFirecracker(this.process, config.socketPath);
|
|
deleteTap(config.tapDevice);
|
|
|
|
if (this.octet > 0) {
|
|
releaseIp(this.octet);
|
|
}
|
|
|
|
if (config.rootfsPath) {
|
|
deleteRunCopy(config.rootfsPath);
|
|
}
|
|
}
|
|
}
|