A simple command-line tool to start NetBSD virtual machines using QEMU with sensible defaults.
at main 317 lines 8.8 kB view raw
1import { parseFlags } from "@cliffy/flags"; 2import _ from "@es-toolkit/es-toolkit/compat"; 3import { Data, Effect, pipe } from "effect"; 4import { LOGS_DIR } from "../constants.ts"; 5import type { VirtualMachine, Volume } from "../db.ts"; 6import { getImage } from "../images.ts"; 7import { getInstanceState, updateInstanceState } from "../state.ts"; 8import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; 9import { createVolume, getVolume } from "../volumes.ts"; 10 11export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 12 name: string; 13}> {} 14 15export class VmAlreadyRunningError 16 extends Data.TaggedError("VmAlreadyRunningError")<{ 17 name: string; 18 }> {} 19 20export class CommandError extends Data.TaggedError("CommandError")<{ 21 cause?: unknown; 22}> {} 23 24export const findVm = (name: string) => 25 pipe( 26 getInstanceState(name), 27 Effect.flatMap((vm) => 28 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 29 ), 30 ); 31 32const logStarting = (vm: VirtualMachine) => 33 Effect.sync(() => { 34 console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 35 }); 36 37const applyFlags = (vm: VirtualMachine) => Effect.succeed(mergeFlags(vm)); 38 39export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 40 41export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 42 const qemu = Deno.build.arch === "aarch64" 43 ? "qemu-system-aarch64" 44 : "qemu-system-x86_64"; 45 46 return Effect.succeed([ 47 ..._.compact([vm.bridge && qemu]), 48 ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 49 ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 50 "-cpu", 51 vm.cpu, 52 "-m", 53 vm.memory, 54 "-smp", 55 vm.cpus.toString(), 56 ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 57 "-netdev", 58 vm.bridge 59 ? `bridge,id=net0,br=${vm.bridge}` 60 : setupNATNetworkArgs(vm.portForward), 61 "-device", 62 `e1000,netdev=net0,mac=${vm.macAddress}`, 63 "-nographic", 64 "-monitor", 65 "none", 66 "-chardev", 67 "stdio,id=con0,signal=off", 68 "-serial", 69 "chardev:con0", 70 ...firmwareArgs, 71 ..._.compact( 72 vm.drivePath && [ 73 "-drive", 74 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 75 ], 76 ), 77 "-object", 78 "rng-random,filename=/dev/urandom,id=rng0", 79 "-device", 80 "virtio-rng-pci,rng=rng0", 81 ]); 82}; 83 84export const createLogsDir = () => 85 Effect.tryPromise({ 86 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 87 catch: (error) => new CommandError({ cause: error }), 88 }); 89 90export const failIfVMRunning = (vm: VirtualMachine) => 91 Effect.gen(function* () { 92 if (vm.status === "RUNNING") { 93 return yield* Effect.fail( 94 new VmAlreadyRunningError({ name: vm.name }), 95 ); 96 } 97 return vm; 98 }); 99 100export const startDetachedQemu = ( 101 name: string, 102 vm: VirtualMachine, 103 qemuArgs: string[], 104) => { 105 const qemu = Deno.build.arch === "aarch64" 106 ? "qemu-system-aarch64" 107 : "qemu-system-x86_64"; 108 109 const logPath = `${LOGS_DIR}/${vm.name}.log`; 110 111 const fullCommand = vm.bridge 112 ? `sudo ${qemu} ${ 113 qemuArgs.slice(1).join(" ") 114 } >> "${logPath}" 2>&1 & echo $!` 115 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 116 117 return Effect.tryPromise({ 118 try: async () => { 119 const cmd = new Deno.Command("sh", { 120 args: ["-c", fullCommand], 121 stdin: "null", 122 stdout: "piped", 123 }); 124 125 const { stdout } = await cmd.spawn().output(); 126 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 127 return { qemuPid, logPath }; 128 }, 129 catch: (error) => new CommandError({ cause: error }), 130 }).pipe( 131 Effect.flatMap(({ qemuPid, logPath }) => 132 pipe( 133 updateInstanceState(name, "RUNNING", qemuPid), 134 Effect.map(() => ({ vm, qemuPid, logPath })), 135 ) 136 ), 137 ); 138}; 139 140const logDetachedSuccess = ( 141 { vm, qemuPid, logPath }: { 142 vm: VirtualMachine; 143 qemuPid: number; 144 logPath: string; 145 }, 146) => 147 Effect.sync(() => { 148 console.log( 149 `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 150 ); 151 console.log(`Logs will be written to: ${logPath}`); 152 }); 153 154const startInteractiveQemu = ( 155 name: string, 156 vm: VirtualMachine, 157 qemuArgs: string[], 158) => { 159 const qemu = Deno.build.arch === "aarch64" 160 ? "qemu-system-aarch64" 161 : "qemu-system-x86_64"; 162 163 return Effect.tryPromise({ 164 try: async () => { 165 const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, { 166 args: qemuArgs, 167 stdin: "inherit", 168 stdout: "inherit", 169 stderr: "inherit", 170 }); 171 172 const child = cmd.spawn(); 173 174 await Effect.runPromise(updateInstanceState(name, "RUNNING", child.pid)); 175 176 const status = await child.status; 177 178 await Effect.runPromise(updateInstanceState(name, "STOPPED", child.pid)); 179 180 return status; 181 }, 182 catch: (error) => new CommandError({ cause: error }), 183 }); 184}; 185 186const handleError = (error: VmNotFoundError | CommandError | Error) => 187 Effect.sync(() => { 188 if (error instanceof VmNotFoundError) { 189 console.error( 190 `Virtual machine with name or ID ${error.name} not found.`, 191 ); 192 } else { 193 console.error(`An error occurred: ${error}`); 194 } 195 Deno.exit(1); 196 }); 197 198const createVolumeIfNeeded = ( 199 vm: VirtualMachine, 200): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 201 Effect.gen(function* () { 202 const { flags } = parseFlags(Deno.args); 203 if (!flags.volume) { 204 return [vm]; 205 } 206 const volume = yield* getVolume(flags.volume as string); 207 if (volume) { 208 return [vm, volume]; 209 } 210 211 if (!vm.drivePath) { 212 throw new Error( 213 `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 214 ); 215 } 216 217 let image = yield* getImage(vm.drivePath); 218 219 if (!image) { 220 const volume = yield* getVolume(vm.drivePath); 221 if (volume) { 222 image = yield* getImage(volume.baseImageId); 223 } 224 } 225 226 const newVolume = yield* createVolume(flags.volume as string, image!); 227 return [vm, newVolume]; 228 }); 229 230const startDetachedEffect = (name: string) => 231 pipe( 232 findVm(name), 233 Effect.tap(logStarting), 234 Effect.flatMap(applyFlags), 235 Effect.flatMap(createVolumeIfNeeded), 236 Effect.flatMap(([vm, volume]) => 237 pipe( 238 setupFirmware(), 239 Effect.flatMap((firmwareArgs) => 240 buildQemuArgs({ 241 ...vm, 242 drivePath: volume ? volume.path : vm.drivePath, 243 diskFormat: volume ? "qcow2" : vm.diskFormat, 244 }, firmwareArgs) 245 ), 246 Effect.flatMap((qemuArgs) => 247 pipe( 248 createLogsDir(), 249 Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)), 250 Effect.tap(logDetachedSuccess), 251 Effect.map(() => 0), // Exit code 0 252 ) 253 ), 254 ) 255 ), 256 Effect.catchAll(handleError), 257 ); 258 259const startInteractiveEffect = (name: string) => 260 pipe( 261 findVm(name), 262 Effect.tap(logStarting), 263 Effect.flatMap(applyFlags), 264 Effect.flatMap(createVolumeIfNeeded), 265 Effect.flatMap(([vm, volume]) => 266 pipe( 267 setupFirmware(), 268 Effect.flatMap((firmwareArgs) => 269 buildQemuArgs({ 270 ...vm, 271 drivePath: volume ? volume.path : vm.drivePath, 272 diskFormat: volume ? "qcow2" : vm.diskFormat, 273 }, firmwareArgs) 274 ), 275 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 276 Effect.map((status) => status.success ? 0 : (status.code || 1)), 277 ) 278 ), 279 Effect.catchAll(handleError), 280 ); 281 282export default async function (name: string, detach: boolean = false) { 283 const exitCode = await Effect.runPromise( 284 detach ? startDetachedEffect(name) : startInteractiveEffect(name), 285 ); 286 287 if (detach) { 288 Deno.exit(exitCode); 289 } else if (exitCode !== 0) { 290 Deno.exit(exitCode); 291 } 292} 293 294function mergeFlags(vm: VirtualMachine): VirtualMachine { 295 const { flags } = parseFlags(Deno.args); 296 return { 297 ...vm, 298 memory: (flags.memory || flags.m) 299 ? String(flags.memory || flags.m) 300 : vm.memory, 301 cpus: (flags.cpus || flags.C) ? Number(flags.cpus || flags.C) : vm.cpus, 302 cpu: (flags.cpu || flags.c) ? String(flags.cpu || flags.c) : vm.cpu, 303 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 304 portForward: (flags.portForward || flags.p) 305 ? String(flags.portForward || flags.p) 306 : vm.portForward, 307 drivePath: (flags.image || flags.i) 308 ? String(flags.image || flags.i) 309 : vm.drivePath, 310 bridge: (flags.bridge || flags.b) 311 ? String(flags.bridge || flags.b) 312 : vm.bridge, 313 diskSize: (flags.size || flags.s) 314 ? String(flags.size || flags.s) 315 : vm.diskSize, 316 }; 317}