A simple command-line tool to start NetBSD virtual machines using QEMU with sensible defaults.
at main 567 lines 16 kB view raw
1import _ from "@es-toolkit/es-toolkit/compat"; 2import { createId } from "@paralleldrive/cuid2"; 3import chalk from "chalk"; 4import { Data, Effect, pipe } from "effect"; 5import Moniker from "moniker"; 6import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts"; 7import type { Image } from "./db.ts"; 8import { generateRandomMacAddress } from "./network.ts"; 9import { saveInstanceState, updateInstanceState } from "./state.ts"; 10 11export const DEFAULT_VERSION = "10.1"; 12 13export interface Options { 14 output?: string; 15 cpu: string; 16 cpus: number; 17 memory: string; 18 image?: string; 19 diskFormat?: string; 20 size?: string; 21 bridge?: string; 22 portForward?: string; 23 detach?: boolean; 24 install?: boolean; 25 volume?: string; 26} 27 28class LogCommandError extends Data.TaggedError("LogCommandError")<{ 29 cause?: unknown; 30}> {} 31 32class InvalidImageNameError extends Data.TaggedError("InvalidImageNameError")<{ 33 image: string; 34 cause?: unknown; 35}> {} 36 37class NoSuchImageError extends Data.TaggedError("NoSuchImageError")<{ 38 cause: string; 39}> {} 40 41export const getCurrentArch = (): string => { 42 switch (Deno.build.arch) { 43 case "x86_64": 44 return "amd64"; 45 case "aarch64": 46 return "arm64"; 47 default: 48 return Deno.build.arch; 49 } 50}; 51 52export const isValidISOurl = (url?: string): boolean => { 53 return Boolean( 54 (url?.startsWith("http://") || url?.startsWith("https://")) && 55 url?.endsWith(".iso"), 56 ); 57}; 58 59export const humanFileSize = (blocks: number) => 60 Effect.sync(() => { 61 const blockSize = 512; // bytes per block 62 let bytes = blocks * blockSize; 63 const thresh = 1024; 64 65 if (Math.abs(bytes) < thresh) { 66 return `${bytes}B`; 67 } 68 69 const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 70 let u = -1; 71 72 do { 73 bytes /= thresh; 74 ++u; 75 } while (Math.abs(bytes) >= thresh && u < units.length - 1); 76 77 return `${bytes.toFixed(1)}${units[u]}`; 78 }); 79 80export const validateImage = ( 81 image: string, 82): Effect.Effect<string, InvalidImageNameError, never> => { 83 const regex = 84 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; 85 86 if (!regex.test(image)) { 87 return Effect.fail( 88 new InvalidImageNameError({ 89 image, 90 cause: 91 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 92 }), 93 ); 94 } 95 return Effect.succeed(image); 96}; 97 98export const extractTag = (name: string) => 99 pipe( 100 validateImage(name), 101 Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 102 ); 103 104export const failOnMissingImage = ( 105 image: Image | undefined, 106): Effect.Effect<Image, Error, never> => 107 image 108 ? Effect.succeed(image) 109 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 110 111export const du = ( 112 path: string, 113): Effect.Effect<number, LogCommandError, never> => 114 Effect.tryPromise({ 115 try: async () => { 116 const cmd = new Deno.Command("du", { 117 args: [path], 118 stdout: "piped", 119 stderr: "inherit", 120 }); 121 122 const { stdout } = await cmd.spawn().output(); 123 const output = new TextDecoder().decode(stdout).trim(); 124 const size = parseInt(output.split("\t")[0], 10); 125 return size; 126 }, 127 catch: (error) => new LogCommandError({ cause: error }), 128 }); 129 130export const emptyDiskImage = (path: string) => 131 Effect.tryPromise({ 132 try: async () => { 133 if (!await Deno.stat(path).catch(() => false)) { 134 return true; 135 } 136 return false; 137 }, 138 catch: (error) => new LogCommandError({ cause: error }), 139 }).pipe( 140 Effect.flatMap((exists) => 141 exists ? Effect.succeed(true) : du(path).pipe( 142 Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB), 143 ) 144 ), 145 ); 146 147export const downloadIso = ( 148 url: string, 149 options: Options, 150) => 151 Effect.gen(function* () { 152 const filename = url.split("/").pop()!; 153 const outputPath = options.output ?? filename; 154 155 if (options.image) { 156 const imageExists = yield* Effect.tryPromise({ 157 try: () => 158 Deno.stat(options.image!).then(() => true).catch(() => false), 159 catch: (error) => new LogCommandError({ cause: error }), 160 }); 161 162 if (imageExists) { 163 const driveSize = yield* du(options.image); 164 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 165 console.log( 166 chalk.yellowBright( 167 `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 168 ), 169 ); 170 return null; 171 } 172 } 173 } 174 175 const outputExists = yield* Effect.tryPromise({ 176 try: () => Deno.stat(outputPath).then(() => true).catch(() => false), 177 catch: (error) => new LogCommandError({ cause: error }), 178 }); 179 180 if (outputExists) { 181 console.log( 182 chalk.yellowBright( 183 `File ${outputPath} already exists, skipping download.`, 184 ), 185 ); 186 return outputPath; 187 } 188 189 yield* Effect.tryPromise({ 190 try: async () => { 191 const cmd = new Deno.Command("curl", { 192 args: ["-L", "-o", outputPath, url], 193 stdin: "inherit", 194 stdout: "inherit", 195 stderr: "inherit", 196 }); 197 198 const status = await cmd.spawn().status; 199 if (!status.success) { 200 console.error(chalk.redBright("Failed to download ISO image.")); 201 Deno.exit(status.code); 202 } 203 }, 204 catch: (error) => new LogCommandError({ cause: error }), 205 }); 206 207 console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`)); 208 return outputPath; 209 }); 210 211export function constructDownloadUrl(version: string): string { 212 let arch = "amd64"; 213 214 if (Deno.build.arch === "aarch64") { 215 arch = "evbarm-aarch64"; 216 } 217 218 return `https://cdn.netbsd.org/pub/NetBSD/images/${version}/NetBSD-${version}-${arch}.iso`; 219} 220 221export const setupFirmwareFilesIfNeeded = () => 222 Effect.gen(function* () { 223 if (Deno.build.arch !== "aarch64") { 224 return []; 225 } 226 227 const { stdout, success } = yield* Effect.tryPromise({ 228 try: async () => { 229 const brewCmd = new Deno.Command("brew", { 230 args: ["--prefix", "qemu"], 231 stdout: "piped", 232 stderr: "inherit", 233 }); 234 return await brewCmd.spawn().output(); 235 }, 236 catch: (error) => new LogCommandError({ cause: error }), 237 }); 238 239 if (!success) { 240 console.error( 241 chalk.redBright( 242 "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 243 ), 244 ); 245 Deno.exit(1); 246 } 247 248 const brewPrefix = new TextDecoder().decode(stdout).trim(); 249 const edk2Aarch64 = `${brewPrefix}/share/qemu/edk2-aarch64-code.fd`; 250 const edk2VarsAarch64 = "./edk2-arm-vars.fd"; 251 252 yield* Effect.tryPromise({ 253 try: () => 254 Deno.copyFile( 255 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 256 edk2VarsAarch64, 257 ), 258 catch: (error) => new LogCommandError({ cause: error }), 259 }); 260 261 return [ 262 "-drive", 263 `if=pflash,format=raw,file=${edk2Aarch64},readonly=on`, 264 "-drive", 265 `if=pflash,format=raw,file=${edk2VarsAarch64}`, 266 ]; 267 }); 268 269export function setupPortForwardingArgs(portForward?: string): string { 270 if (!portForward) { 271 return ""; 272 } 273 274 const forwards = portForward.split(",").map((pair) => { 275 const [hostPort, guestPort] = pair.split(":"); 276 return `hostfwd=tcp::${hostPort}-:${guestPort}`; 277 }); 278 279 return forwards.join(","); 280} 281 282export function setupNATNetworkArgs(portForward?: string): string { 283 if (!portForward) { 284 return "user,id=net0"; 285 } 286 287 const portForwarding = setupPortForwardingArgs(portForward); 288 return `user,id=net0,${portForwarding}`; 289} 290 291export const runQemu = ( 292 isoPath: string | null, 293 options: Options, 294) => 295 Effect.gen(function* () { 296 const macAddress = yield* generateRandomMacAddress(); 297 298 const qemu = Deno.build.arch === "aarch64" 299 ? "qemu-system-aarch64" 300 : "qemu-system-x86_64"; 301 302 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 303 304 const qemuArgs = [ 305 ..._.compact([options.bridge && qemu]), 306 ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 307 ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [], 308 "-cpu", 309 options.cpu, 310 "-m", 311 options.memory, 312 "-smp", 313 options.cpus.toString(), 314 ..._.compact([isoPath && "-cdrom", isoPath]), 315 "-netdev", 316 options.bridge 317 ? `bridge,id=net0,br=${options.bridge}` 318 : setupNATNetworkArgs(options.portForward), 319 "-device", 320 `e1000,netdev=net0,mac=${macAddress}`, 321 ...(options.install ? [] : ["-snapshot"]), 322 "-nographic", 323 "-monitor", 324 "none", 325 "-chardev", 326 "stdio,id=con0,signal=off", 327 "-serial", 328 "chardev:con0", 329 ...firmwareFiles, 330 ..._.compact( 331 options.image && [ 332 "-drive", 333 `file=${options.image},format=${options.diskFormat},if=virtio`, 334 ], 335 ), 336 "-object", 337 "rng-random,filename=/dev/urandom,id=rng0", 338 "-device", 339 "virtio-rng-pci,rng=rng0", 340 ]; 341 342 const name = Moniker.choose(); 343 344 if (options.detach) { 345 yield* Effect.tryPromise({ 346 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 347 catch: (error) => new LogCommandError({ cause: error }), 348 }); 349 350 const logPath = `${LOGS_DIR}/${name}.log`; 351 352 const fullCommand = options.bridge 353 ? `sudo ${qemu} ${ 354 qemuArgs.slice(1).join(" ") 355 } >> "${logPath}" 2>&1 & echo $!` 356 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 357 358 const { stdout } = yield* Effect.tryPromise({ 359 try: async () => { 360 const cmd = new Deno.Command("sh", { 361 args: ["-c", fullCommand], 362 stdin: "null", 363 stdout: "piped", 364 }); 365 return await cmd.spawn().output(); 366 }, 367 catch: (error) => new LogCommandError({ cause: error }), 368 }); 369 370 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 371 372 yield* saveInstanceState({ 373 id: createId(), 374 name, 375 bridge: options.bridge, 376 macAddress, 377 memory: options.memory, 378 cpus: options.cpus, 379 cpu: options.cpu, 380 diskSize: options.size || "20G", 381 diskFormat: options.diskFormat || "raw", 382 portForward: options.portForward, 383 isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 384 drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 385 version: DEFAULT_VERSION, 386 status: "RUNNING", 387 pid: qemuPid, 388 }); 389 390 console.log( 391 `Virtual machine ${name} started in background (PID: ${qemuPid})`, 392 ); 393 console.log(`Logs will be written to: ${logPath}`); 394 395 // Exit successfully while keeping VM running in background 396 Deno.exit(0); 397 } else { 398 const cmd = new Deno.Command(options.bridge ? "sudo" : qemu, { 399 args: qemuArgs, 400 stdin: "inherit", 401 stdout: "inherit", 402 stderr: "inherit", 403 }) 404 .spawn(); 405 406 yield* saveInstanceState({ 407 id: createId(), 408 name, 409 bridge: options.bridge, 410 macAddress, 411 memory: options.memory, 412 cpus: options.cpus, 413 cpu: options.cpu, 414 diskSize: options.size || "20G", 415 diskFormat: options.diskFormat || "raw", 416 portForward: options.portForward, 417 isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 418 drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 419 version: DEFAULT_VERSION, 420 status: "RUNNING", 421 pid: cmd.pid, 422 }); 423 424 const status = yield* Effect.tryPromise({ 425 try: () => cmd.status, 426 catch: (error) => new LogCommandError({ cause: error }), 427 }); 428 429 yield* updateInstanceState(name, "STOPPED"); 430 431 if (!status.success) { 432 Deno.exit(status.code); 433 } 434 } 435 }); 436 437export function handleInput(input?: string): string { 438 if (!input) { 439 console.log( 440 chalk.blueBright( 441 `No ISO path provided, defaulting to ${chalk.cyan("NetBSD")} ${ 442 chalk.cyan(DEFAULT_VERSION) 443 }...`, 444 ), 445 ); 446 return constructDownloadUrl(DEFAULT_VERSION); 447 } 448 449 const versionRegex = /^\d{1,2}\.\d{1,2}$/; 450 451 if (versionRegex.test(input)) { 452 console.log( 453 chalk.blueBright( 454 `Detected version ${chalk.cyan(input)}, constructing download URL...`, 455 ), 456 ); 457 return constructDownloadUrl(input); 458 } 459 460 return input; 461} 462 463export const safeKillQemu = ( 464 pid: number, 465 useSudo: boolean = false, 466) => 467 Effect.gen(function* () { 468 const killArgs = useSudo 469 ? ["sudo", "kill", "-TERM", pid.toString()] 470 : ["kill", "-TERM", pid.toString()]; 471 472 const termStatus = yield* Effect.tryPromise({ 473 try: async () => { 474 const termCmd = new Deno.Command(killArgs[0], { 475 args: killArgs.slice(1), 476 stdout: "null", 477 stderr: "null", 478 }); 479 return await termCmd.spawn().status; 480 }, 481 catch: (error) => new LogCommandError({ cause: error }), 482 }); 483 484 if (termStatus.success) { 485 yield* Effect.tryPromise({ 486 try: () => new Promise((resolve) => setTimeout(resolve, 3000)), 487 catch: (error) => new LogCommandError({ cause: error }), 488 }); 489 490 const checkStatus = yield* Effect.tryPromise({ 491 try: async () => { 492 const checkCmd = new Deno.Command("kill", { 493 args: ["-0", pid.toString()], 494 stdout: "null", 495 stderr: "null", 496 }); 497 return await checkCmd.spawn().status; 498 }, 499 catch: (error) => new LogCommandError({ cause: error }), 500 }); 501 502 if (!checkStatus.success) { 503 return true; 504 } 505 } 506 507 const killKillArgs = useSudo 508 ? ["sudo", "kill", "-KILL", pid.toString()] 509 : ["kill", "-KILL", pid.toString()]; 510 511 const killStatus = yield* Effect.tryPromise({ 512 try: async () => { 513 const killCmd = new Deno.Command(killKillArgs[0], { 514 args: killKillArgs.slice(1), 515 stdout: "null", 516 stderr: "null", 517 }); 518 return await killCmd.spawn().status; 519 }, 520 catch: (error) => new LogCommandError({ cause: error }), 521 }); 522 523 return killStatus.success; 524 }); 525 526export const createDriveImageIfNeeded = ( 527 { 528 image: path, 529 diskFormat: format, 530 size, 531 }: Options, 532) => 533 Effect.gen(function* () { 534 const pathExists = yield* Effect.tryPromise({ 535 try: () => Deno.stat(path!).then(() => true).catch(() => false), 536 catch: (error) => new LogCommandError({ cause: error }), 537 }); 538 539 if (pathExists) { 540 console.log( 541 chalk.yellowBright( 542 `Drive image ${path} already exists, skipping creation.`, 543 ), 544 ); 545 return; 546 } 547 548 const status = yield* Effect.tryPromise({ 549 try: async () => { 550 const cmd = new Deno.Command("qemu-img", { 551 args: ["create", "-f", format || "raw", path!, size!], 552 stdin: "inherit", 553 stdout: "inherit", 554 stderr: "inherit", 555 }); 556 return await cmd.spawn().status; 557 }, 558 catch: (error) => new LogCommandError({ cause: error }), 559 }); 560 561 if (!status.success) { 562 console.error(chalk.redBright("Failed to create drive image.")); 563 Deno.exit(status.code); 564 } 565 566 console.log(chalk.greenBright(`Created drive image at ${path}`)); 567 });