A convenient CLI tool to quickly spin up DragonflyBSD virtual machines using QEMU with sensible defaults.
at main 284 lines 7.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 } from "../db.ts"; 6import { getImage } from "../images.ts"; 7import { getInstanceStateOrFail, updateInstanceState } from "../state.ts"; 8import { setupNATNetworkArgs } from "../utils.ts"; 9import { createVolume, getVolume } from "../volumes.ts"; 10 11export class VmAlreadyRunningError 12 extends Data.TaggedError("VmAlreadyRunningError")<{ 13 name: string; 14 }> {} 15 16const logStartingMessage = (vm: VirtualMachine) => 17 Effect.sync(() => { 18 console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 19 }); 20 21export const buildQemuArgs = (vm: VirtualMachine) => [ 22 ..._.compact([vm.bridge && "qemu-system-x86_64"]), 23 ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 24 "-cpu", 25 vm.cpu, 26 "-m", 27 vm.memory, 28 "-smp", 29 vm.cpus.toString(), 30 ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 31 "-netdev", 32 vm.bridge 33 ? `bridge,id=net0,br=${vm.bridge}` 34 : setupNATNetworkArgs(vm.portForward), 35 "-device", 36 `e1000,netdev=net0,mac=${vm.macAddress}`, 37 "-display", 38 "none", 39 "-vga", 40 "none", 41 "-monitor", 42 "none", 43 "-chardev", 44 "stdio,id=con0,signal=off", 45 "-serial", 46 "chardev:con0", 47 ..._.compact( 48 vm.drivePath && [ 49 "-drive", 50 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 51 ], 52 ), 53]; 54 55export const createLogsDir = () => 56 Effect.tryPromise({ 57 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 58 catch: (cause) => new Error(`Failed to create logs directory: ${cause}`), 59 }); 60 61export const buildDetachedCommand = ( 62 vm: VirtualMachine, 63 qemuArgs: string[], 64 logPath: string, 65) => 66 vm.bridge 67 ? `sudo qemu-system-x86_64 ${ 68 qemuArgs.slice(1).join(" ") 69 } >> "${logPath}" 2>&1 & echo $!` 70 : `qemu-system-x86_64 ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 71 72export const startDetachedQemu = (fullCommand: string) => 73 Effect.tryPromise({ 74 try: async () => { 75 const cmd = new Deno.Command("sh", { 76 args: ["-c", fullCommand], 77 stdin: "null", 78 stdout: "piped", 79 }).spawn(); 80 81 await new Promise((resolve) => setTimeout(resolve, 2000)); 82 83 const { stdout } = await cmd.output(); 84 return parseInt(new TextDecoder().decode(stdout).trim(), 10); 85 }, 86 catch: (cause) => new Error(`Failed to start QEMU: ${cause}`), 87 }); 88 89const logDetachedSuccess = ( 90 vm: VirtualMachine, 91 qemuPid: number, 92 logPath: string, 93) => 94 Effect.sync(() => { 95 console.log( 96 `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 97 ); 98 console.log(`Logs will be written to: ${logPath}`); 99 Deno.exit(0); 100 }); 101 102const startVirtualMachineDetached = (name: string, vm: VirtualMachine) => 103 Effect.gen(function* () { 104 yield* failIfVMRunning(vm); 105 const volume = yield* createVolumeIfNeeded(vm); 106 const qemuArgs = buildQemuArgs({ 107 ...vm, 108 drivePath: volume ? volume.path : vm.drivePath, 109 diskFormat: volume ? "qcow2" : vm.diskFormat, 110 }); 111 const logPath = `${LOGS_DIR}/${vm.name}.log`; 112 const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath); 113 114 return yield* pipe( 115 createLogsDir(), 116 Effect.flatMap(() => startDetachedQemu(fullCommand)), 117 Effect.flatMap((qemuPid) => 118 pipe( 119 updateInstanceState(name, "RUNNING", qemuPid), 120 Effect.flatMap(() => logDetachedSuccess(vm, qemuPid, logPath)), 121 ) 122 ), 123 ); 124 }); 125 126const startAttachedQemu = ( 127 name: string, 128 vm: VirtualMachine, 129 qemuArgs: string[], 130) => 131 Effect.tryPromise({ 132 try: async () => { 133 const cmd = new Deno.Command( 134 vm.bridge ? "sudo" : "qemu-system-x86_64", 135 { 136 args: qemuArgs, 137 stdin: "inherit", 138 stdout: "inherit", 139 stderr: "inherit", 140 }, 141 ); 142 143 const child = cmd.spawn(); 144 await Effect.runPromise( 145 updateInstanceState(name, "RUNNING", child.pid), 146 ); 147 148 const status = await child.status; 149 await Effect.runPromise( 150 updateInstanceState(name, "STOPPED", child.pid), 151 ); 152 153 return status; 154 }, 155 catch: (cause) => new Error(`Failed to run QEMU: ${cause}`), 156 }); 157 158const validateQemuExit = (status: Deno.CommandStatus) => 159 Effect.sync(() => { 160 if (!status.success) { 161 throw new Error(`QEMU exited with code ${status.code}`); 162 } 163 }); 164 165export const failIfVMRunning = (vm: VirtualMachine) => 166 Effect.gen(function* () { 167 if (vm.status === "RUNNING") { 168 return yield* Effect.fail( 169 new VmAlreadyRunningError({ name: vm.name }), 170 ); 171 } 172 return vm; 173 }); 174 175const createVolumeIfNeeded = (vm: VirtualMachine) => 176 Effect.gen(function* () { 177 const { flags } = parseFlags(Deno.args); 178 if (!flags.volume) { 179 return; 180 } 181 const volume = yield* getVolume(flags.volume as string); 182 if (volume) { 183 return volume; 184 } 185 186 if (!vm.drivePath) { 187 throw new Error( 188 `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 189 ); 190 } 191 192 let image = yield* getImage(vm.drivePath); 193 194 if (!image) { 195 const volume = yield* getVolume(vm.drivePath); 196 if (volume) { 197 image = yield* getImage(volume.baseImageId); 198 } 199 } 200 201 const newVolume = yield* createVolume(flags.volume as string, image!); 202 return newVolume; 203 }); 204 205const startVirtualMachineAttached = (name: string, vm: VirtualMachine) => { 206 return pipe( 207 failIfVMRunning(vm), 208 Effect.flatMap(() => createVolumeIfNeeded(vm)), 209 Effect.flatMap((volume) => 210 Effect.succeed( 211 buildQemuArgs({ 212 ...vm, 213 drivePath: volume ? volume.path : vm.drivePath, 214 diskFormat: volume ? "qcow2" : vm.diskFormat, 215 }), 216 ) 217 ), 218 Effect.flatMap((qemuArgs) => startAttachedQemu(name, vm, qemuArgs)), 219 Effect.flatMap(validateQemuExit), 220 ); 221}; 222 223const startVirtualMachine = (name: string, detach: boolean = false) => 224 pipe( 225 getInstanceStateOrFail(name), 226 Effect.flatMap((vm) => { 227 const mergedVm = mergeFlags(vm); 228 229 return pipe( 230 logStartingMessage(mergedVm), 231 Effect.flatMap(() => 232 detach 233 ? startVirtualMachineDetached(name, mergedVm) 234 : startVirtualMachineAttached(name, mergedVm) 235 ), 236 ); 237 }), 238 ); 239 240export default async function (name: string, detach: boolean = false) { 241 const program = pipe( 242 startVirtualMachine(name, detach), 243 Effect.catchTags({ 244 InstanceNotFoundError: (_error) => 245 Effect.sync(() => { 246 console.error(`Virtual machine with name or ID ${name} not found.`); 247 Deno.exit(1); 248 }), 249 }), 250 Effect.catchAll((error) => 251 Effect.sync(() => { 252 console.error(`Error: ${String(error)}`); 253 Deno.exit(1); 254 }) 255 ), 256 ); 257 258 await Effect.runPromise(program); 259} 260 261function mergeFlags(vm: VirtualMachine): VirtualMachine { 262 const { flags } = parseFlags(Deno.args); 263 return { 264 ...vm, 265 memory: (flags.memory || flags.m) 266 ? String(flags.memory || flags.m) 267 : vm.memory, 268 cpus: (flags.cpus || flags.C) ? Number(flags.cpus || flags.C) : vm.cpus, 269 cpu: (flags.cpu || flags.c) ? String(flags.cpu || flags.c) : vm.cpu, 270 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 271 portForward: (flags.portForward || flags.p) 272 ? String(flags.portForward || flags.p) 273 : vm.portForward, 274 drivePath: (flags.image || flags.i) 275 ? String(flags.image || flags.i) 276 : vm.drivePath, 277 bridge: (flags.bridge || flags.b) 278 ? String(flags.bridge || flags.b) 279 : vm.bridge, 280 diskSize: (flags.size || flags.s) 281 ? String(flags.size || flags.s) 282 : vm.diskSize, 283 }; 284}