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