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