A Docker-like CLI and HTTP API for managing headless VMs
at main 675 lines 22 kB view raw
1#!/usr/bin/env -S deno run --allow-run --allow-read --allow-env 2 3import { Command } from "@cliffy/command"; 4import { Secret } from "@cliffy/prompt"; 5import { readAll } from "@std/io"; 6import { basename } from "@std/path"; 7import chalk from "chalk"; 8import { Effect, pipe } from "effect"; 9import pkg from "./deno.json" with { type: "json" }; 10import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts"; 11import { CONFIG_FILE_NAME } from "./src/constants.ts"; 12import { NoSuchFileError } from "./src/errors.ts"; 13import { getImage } from "./src/images.ts"; 14import { constructCoreOSImageURL } from "./src/mod.ts"; 15import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 16import { getImageArchivePath } from "./src/oras.ts"; 17import images from "./src/subcommands/images.ts"; 18import inspect from "./src/subcommands/inspect.ts"; 19import login from "./src/subcommands/login.ts"; 20import logout from "./src/subcommands/logout.ts"; 21import logs from "./src/subcommands/logs.ts"; 22import ps from "./src/subcommands/ps.ts"; 23import pull from "./src/subcommands/pull.ts"; 24import push from "./src/subcommands/push.ts"; 25import restart from "./src/subcommands/restart.ts"; 26import rm from "./src/subcommands/rm.ts"; 27import rmi from "./src/subcommands/rmi.ts"; 28import run from "./src/subcommands/run.ts"; 29import seed from "./src/subcommands/seed.ts"; 30import serve from "./src/subcommands/serve.ts"; 31import start from "./src/subcommands/start.ts"; 32import stop from "./src/subcommands/stop.ts"; 33import tag from "./src/subcommands/tag.ts"; 34import * as volumes from "./src/subcommands/volume.ts"; 35import { 36 constructAlmaLinuxImageURL, 37 constructAlpineImageURL, 38 constructDebianImageURL, 39 constructFedoraImageURL, 40 constructGentooImageURL, 41 constructNixOSImageURL, 42 constructRockyLinuxImageURL, 43 constructUbuntuImageURL, 44 createDriveImageIfNeeded, 45 downloadIso, 46 emptyDiskImage, 47 extractXz, 48 fileExists, 49 isValidISOurl, 50 type Options, 51 runQemu, 52} from "./src/utils.ts"; 53 54export * from "./src/mod.ts"; 55 56if (import.meta.main) { 57 await new Command() 58 .name("vmx") 59 .version(pkg.version) 60 .description("Manage and run headless VMs using QEMU") 61 .arguments( 62 "[path-or-url-to-iso:string]", 63 ) 64 .option("-o, --output <path:string>", "Output path for downloaded ISO") 65 .option("-c, --cpu <type:string>", "Type of CPU to emulate", { 66 default: "host", 67 }) 68 .option("-C, --cpus <number:number>", "Number of CPU cores", { 69 default: 2, 70 }) 71 .option("-m, --memory <size:string>", "Amount of memory for the VM", { 72 default: "2G", 73 }) 74 .option("-i, --image <path:string>", "Path to VM disk image") 75 .option( 76 "--disk-format <format:string>", 77 "Disk image format (e.g., qcow2, raw)", 78 { 79 default: "raw", 80 }, 81 ) 82 .option( 83 "-s, --size <size:string>", 84 "Size of the disk image to create if it doesn't exist (e.g., 20G)", 85 { 86 default: "20G", 87 }, 88 ) 89 .option( 90 "-b, --bridge <name:string>", 91 "Name of the network bridge to use for networking (e.g., br0)", 92 ) 93 .option( 94 "-d, --detach", 95 "Run VM in the background and print VM name", 96 ) 97 .option( 98 "-p, --port-forward <mappings:string>", 99 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 100 ) 101 .option( 102 "--install", 103 "Persist changes to the VM disk image", 104 ) 105 .option( 106 "--cloud", 107 "Use cloud-init for initial configuration (only for compatible images)", 108 ) 109 .option( 110 "--seed <path:string>", 111 "Path to cloud-init seed image (ISO format)", 112 ) 113 .example( 114 "Create a default VM configuration file", 115 "vmx init", 116 ) 117 .example( 118 "Local ISO file", 119 "vmx /path/to/image.iso", 120 ) 121 .example( 122 "Download URL", 123 "vmx https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso", 124 ) 125 .example( 126 "From OCI Registry", 127 "vmx ghcr.io/tsirysndr/ubuntu:24.04", 128 ) 129 .example( 130 "List running VMs", 131 "vmx ps", 132 ) 133 .example( 134 "List all VMs", 135 "vmx ps --all", 136 ) 137 .example( 138 "Start a VM", 139 "vmx start my-vm", 140 ) 141 .example( 142 "Stop a VM", 143 "vmx stop my-vm", 144 ) 145 .example( 146 "Inspect a VM", 147 "vmx inspect my-vm", 148 ) 149 .action(async (options: Options, input?: string) => { 150 const program = Effect.gen(function* () { 151 let isoPath: string | null = null; 152 153 if (options.seed) { 154 const seedExists = yield* pipe( 155 fileExists(options.seed), 156 Effect.map(() => true), 157 Effect.catchAll(() => Effect.succeed(false)), 158 ); 159 if (!seedExists) { 160 console.error(`Seed file ${options.seed} does not exist.`); 161 console.log( 162 `Please run ${ 163 chalk.greenBright(`vmx seed`) 164 } to create a seed image.`, 165 ); 166 Deno.exit(1); 167 } 168 } 169 170 if (input) { 171 const [image, archivePath] = yield* Effect.all([ 172 getImage(input), 173 pipe( 174 getImageArchivePath(input), 175 Effect.catchAll(() => Effect.succeed(null)), 176 ), 177 ]); 178 179 if (image || archivePath) { 180 yield* Effect.tryPromise({ 181 try: () => run(input), 182 catch: () => {}, 183 }); 184 return; 185 } 186 187 if (isValidISOurl(input)) { 188 isoPath = yield* downloadIso(input, options); 189 } 190 191 if ( 192 yield* pipe( 193 fileExists(input), 194 Effect.map(() => true), 195 Effect.catchAll(() => Effect.succeed(false)), 196 ) 197 ) { 198 if (input.endsWith(".iso")) { 199 isoPath = input; 200 } 201 } 202 203 const coreOSImageURL = yield* pipe( 204 constructCoreOSImageURL(input), 205 Effect.catchAll(() => Effect.succeed(null)), 206 ); 207 208 if (coreOSImageURL) { 209 const cached = yield* pipe( 210 basename(coreOSImageURL).replace(".xz", ""), 211 fileExists, 212 Effect.flatMap(() => Effect.succeed(true)), 213 Effect.catchAll(() => Effect.succeed(false)), 214 ); 215 if (!cached) { 216 isoPath = yield* pipe( 217 downloadIso(coreOSImageURL, options), 218 Effect.flatMap((xz) => extractXz(xz)), 219 ); 220 } else { 221 isoPath = basename(coreOSImageURL).replace(".xz", ""); 222 } 223 } 224 225 const nixOSIsoURL = yield* pipe( 226 constructNixOSImageURL(input), 227 Effect.catchAll(() => Effect.succeed(null)), 228 ); 229 230 if (nixOSIsoURL) { 231 const cached = yield* pipe( 232 basename(nixOSIsoURL), 233 fileExists, 234 Effect.flatMap(() => Effect.succeed(true)), 235 Effect.catchAll(() => Effect.succeed(false)), 236 ); 237 if (!cached) { 238 isoPath = yield* downloadIso(nixOSIsoURL, options); 239 } else { 240 isoPath = basename(nixOSIsoURL); 241 } 242 } 243 244 const fedoraImageURL = yield* pipe( 245 constructFedoraImageURL(input, options.cloud), 246 Effect.catchAll(() => Effect.succeed(null)), 247 ); 248 249 if (fedoraImageURL) { 250 const cached = yield* pipe( 251 basename(fedoraImageURL), 252 fileExists, 253 Effect.flatMap(() => Effect.succeed(true)), 254 Effect.catchAll(() => Effect.succeed(false)), 255 ); 256 if (!cached) { 257 isoPath = yield* downloadIso(fedoraImageURL, options); 258 } else { 259 isoPath = basename(fedoraImageURL); 260 } 261 } 262 263 const gentooImageURL = yield* pipe( 264 constructGentooImageURL(input), 265 Effect.catchAll(() => Effect.succeed(null)), 266 ); 267 268 if (gentooImageURL) { 269 const cached = yield* pipe( 270 basename(gentooImageURL), 271 fileExists, 272 Effect.flatMap(() => Effect.succeed(true)), 273 Effect.catchAll(() => Effect.succeed(false)), 274 ); 275 if (!cached) { 276 isoPath = yield* downloadIso(gentooImageURL, options); 277 } else { 278 isoPath = basename(gentooImageURL); 279 } 280 } 281 282 const debianImageURL = yield* pipe( 283 constructDebianImageURL(input, options.cloud), 284 Effect.catchAll(() => Effect.succeed(null)), 285 ); 286 287 if (debianImageURL) { 288 const cached = yield* pipe( 289 basename(debianImageURL), 290 fileExists, 291 Effect.flatMap(() => Effect.succeed(true)), 292 Effect.catchAll(() => Effect.succeed(false)), 293 ); 294 if (!cached) { 295 isoPath = yield* downloadIso(debianImageURL, options); 296 } else { 297 isoPath = basename(debianImageURL); 298 } 299 } 300 301 const ubuntuImageURL = yield* pipe( 302 constructUbuntuImageURL(input, options.cloud), 303 Effect.catchAll(() => Effect.succeed(null)), 304 ); 305 306 if (ubuntuImageURL) { 307 const cached = yield* pipe( 308 basename(ubuntuImageURL), 309 fileExists, 310 Effect.flatMap(() => Effect.succeed(true)), 311 Effect.catchAll(() => Effect.succeed(false)), 312 ); 313 if (!cached) { 314 isoPath = yield* downloadIso(ubuntuImageURL, options); 315 } else { 316 isoPath = basename(ubuntuImageURL); 317 } 318 } 319 320 const alpineImageURL = yield* pipe( 321 constructAlpineImageURL(input), 322 Effect.catchAll(() => Effect.succeed(null)), 323 ); 324 325 if (alpineImageURL) { 326 const cached = yield* pipe( 327 basename(alpineImageURL), 328 fileExists, 329 Effect.flatMap(() => Effect.succeed(true)), 330 Effect.catchAll(() => Effect.succeed(false)), 331 ); 332 if (!cached) { 333 isoPath = yield* downloadIso(alpineImageURL, options); 334 } else { 335 isoPath = basename(alpineImageURL); 336 } 337 } 338 339 const almalinuxImageURL = yield* pipe( 340 constructAlmaLinuxImageURL(input), 341 Effect.catchAll(() => Effect.succeed(null)), 342 ); 343 344 if (almalinuxImageURL) { 345 const cached = yield* pipe( 346 basename(almalinuxImageURL), 347 fileExists, 348 Effect.flatMap(() => Effect.succeed(true)), 349 Effect.catchAll(() => Effect.succeed(false)), 350 ); 351 if (!cached) { 352 isoPath = yield* downloadIso(almalinuxImageURL, options); 353 } else { 354 isoPath = basename(almalinuxImageURL); 355 } 356 } 357 358 const rockylinuxImageURL = yield* pipe( 359 constructRockyLinuxImageURL(input), 360 Effect.catchAll(() => Effect.succeed(null)), 361 ); 362 363 if (rockylinuxImageURL) { 364 const cached = yield* pipe( 365 basename(rockylinuxImageURL), 366 fileExists, 367 Effect.flatMap(() => Effect.succeed(true)), 368 Effect.catchAll(() => Effect.succeed(false)), 369 ); 370 if (!cached) { 371 isoPath = yield* downloadIso(rockylinuxImageURL, options); 372 } else { 373 isoPath = basename(rockylinuxImageURL); 374 } 375 } 376 } 377 378 const config = yield* pipe( 379 fileExists(CONFIG_FILE_NAME), 380 Effect.flatMap(() => parseVmFile(CONFIG_FILE_NAME)), 381 Effect.tap(() => Effect.log("Parsed VM configuration file.")), 382 Effect.catchAll((error) => { 383 if (error instanceof NoSuchFileError) { 384 console.log( 385 chalk.yellowBright(`No vmconfig.toml file found, please run:`), 386 chalk.greenBright("vmx init"), 387 ); 388 Deno.exit(1); 389 } 390 return Effect.fail(error); 391 }), 392 ); 393 394 if (!input && (isValidISOurl(config?.vm?.iso))) { 395 isoPath = yield* downloadIso(config!.vm!.iso!, options); 396 } 397 398 if (!input && config?.vm?.iso) { 399 const coreOSImageURL = yield* pipe( 400 constructCoreOSImageURL(config.vm.iso), 401 Effect.catchAll(() => Effect.succeed(null)), 402 ); 403 404 if (coreOSImageURL) { 405 const cached = yield* pipe( 406 basename(coreOSImageURL).replace(".xz", ""), 407 fileExists, 408 Effect.flatMap(() => Effect.succeed(true)), 409 Effect.catchAll(() => Effect.succeed(false)), 410 ); 411 if (!cached) { 412 const xz = yield* downloadIso(coreOSImageURL, options); 413 isoPath = yield* extractXz(xz); 414 } else { 415 isoPath = basename(coreOSImageURL).replace(".xz", ""); 416 } 417 } 418 } 419 420 options = yield* mergeConfig(config, options); 421 422 if (options.image) { 423 yield* createDriveImageIfNeeded(options); 424 } 425 426 if (!input && options.image) { 427 const isEmpty = yield* emptyDiskImage(options.image); 428 if (!isEmpty) { 429 isoPath = null; 430 } 431 } 432 433 if (options.bridge) { 434 yield* createBridgeNetworkIfNeeded(options.bridge); 435 } 436 437 if (!input && !config?.vm?.iso && isValidISOurl(isoPath!)) { 438 isoPath = null; 439 } 440 441 yield* runQemu(isoPath, options); 442 }); 443 444 await Effect.runPromise(program); 445 }) 446 .command("ps", "List all virtual machines") 447 .option("--all, -a", "Show all virtual machines, including stopped ones") 448 .action(async (options: { all?: unknown }) => { 449 await ps(Boolean(options.all)); 450 }) 451 .command("start", "Start a virtual machine") 452 .arguments("<vm-name:string>") 453 .option("-c, --cpu <type:string>", "Type of CPU to emulate", { 454 default: "host", 455 }) 456 .option("-C, --cpus <number:number>", "Number of CPU cores", { 457 default: 2, 458 }) 459 .option("-m, --memory <size:string>", "Amount of memory for the VM", { 460 default: "2G", 461 }) 462 .option("-i, --image <path:string>", "Path to VM disk image") 463 .option( 464 "--disk-format <format:string>", 465 "Disk image format (e.g., qcow2, raw)", 466 { 467 default: "raw", 468 }, 469 ) 470 .option( 471 "--size <size:string>", 472 "Size of the VM disk image to create if it doesn't exist (e.g., 20G)", 473 { 474 default: "20G", 475 }, 476 ) 477 .option( 478 "-b, --bridge <name:string>", 479 "Name of the network bridge to use for networking (e.g., br0)", 480 ) 481 .option( 482 "-d, --detach", 483 "Run VM in the background and print VM name", 484 ) 485 .option( 486 "-p, --port-forward <mappings:string>", 487 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 488 ) 489 .option( 490 "-v, --volume <name:string>", 491 "Name of the volume to attach to the VM, will be created if it doesn't exist", 492 ) 493 .option( 494 "--seed <path:string>", 495 "Path to cloud-init seed image (ISO format)", 496 ) 497 .action(async (options: unknown, vmName: string) => { 498 await start(vmName, Boolean((options as { detach: boolean }).detach)); 499 }) 500 .command("stop", "Stop a virtual machine") 501 .arguments("<vm-name:string>") 502 .action(async (_options: unknown, vmName: string) => { 503 await stop(vmName); 504 }) 505 .command("inspect", "Inspect a virtual machine") 506 .arguments("<vm-name:string>") 507 .action(async (_options: unknown, vmName: string) => { 508 await inspect(vmName); 509 }) 510 .command("rm", "Remove a virtual machine") 511 .arguments("<vm-name:string>") 512 .action(async (_options: unknown, vmName: string) => { 513 await rm(vmName); 514 }) 515 .command("logs", "View logs of a virtual machine") 516 .option("--follow, -f", "Follow log output") 517 .arguments("<vm-name:string>") 518 .action(async (options: unknown, vmName: string) => { 519 await logs(vmName, Boolean((options as { follow: boolean }).follow)); 520 }) 521 .command("restart", "Restart a virtual machine") 522 .arguments("<vm-name:string>") 523 .action(async (_options: unknown, vmName: string) => { 524 await restart(vmName); 525 }) 526 .command("init", "Initialize a default VM configuration file") 527 .action(async () => { 528 await Effect.runPromise(initVmFile(CONFIG_FILE_NAME)); 529 console.log( 530 `New VM configuration file created at ${ 531 chalk.greenBright("./") + 532 chalk.greenBright(CONFIG_FILE_NAME) 533 }`, 534 ); 535 console.log( 536 `You can edit this file to customize your VM settings and then start the VM with:`, 537 ); 538 console.log(` ${chalk.greenBright(`vmx`)}`); 539 }) 540 .command( 541 "pull", 542 "Pull VM image from an OCI-compliant registry, e.g., ghcr.io, docker hub", 543 ) 544 .arguments("<image:string>") 545 .action(async (_options: unknown, image: string) => { 546 await pull(image); 547 }) 548 .command( 549 "push", 550 "Push VM image to an OCI-compliant registry, e.g., ghcr.io, docker hub", 551 ) 552 .arguments("<image:string>") 553 .action(async (_options: unknown, image: string) => { 554 await push(image); 555 }) 556 .command( 557 "tag", 558 "Create a tag 'image' that refers to the VM image of 'vm-name'", 559 ) 560 .arguments("<vm-name:string> <image:string>") 561 .action(async (_options: unknown, vmName: string, image: string) => { 562 await tag(vmName, image); 563 }) 564 .command( 565 "login", 566 "Authenticate to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.", 567 ) 568 .option("-u, --username <username:string>", "Registry username") 569 .arguments("<registry:string>") 570 .action(async (options: unknown, registry: string) => { 571 const username = (options as { username: string }).username; 572 573 let password: string | undefined; 574 const stdinIsTTY = Deno.stdin.isTerminal(); 575 576 if (!stdinIsTTY) { 577 const buffer = await readAll(Deno.stdin); 578 password = new TextDecoder().decode(buffer).trim(); 579 } else { 580 password = await Secret.prompt("Registry Password: "); 581 } 582 583 console.log( 584 `Authenticating to registry ${chalk.greenBright(registry)} as ${ 585 chalk.greenBright(username) 586 }...`, 587 ); 588 await login(username, password, registry); 589 }) 590 .command("logout", "Logout from an OCI-compliant registry") 591 .arguments("<registry:string>") 592 .action(async (_options: unknown, registry: string) => { 593 await logout(registry); 594 }) 595 .command("images", "List all local VM images") 596 .action(async () => { 597 await images(); 598 }) 599 .command("rmi", "Remove a local VM image") 600 .arguments("<image:string>") 601 .action(async (_options: unknown, image: string) => { 602 await rmi(image); 603 }) 604 .command("run", "Create and run a VM from an image") 605 .arguments("<image:string>") 606 .option("-c, --cpu <type:string>", "Type of CPU to emulate", { 607 default: "host", 608 }) 609 .option("-C, --cpus <number:number>", "Number of CPU cores", { 610 default: 2, 611 }) 612 .option("-m, --memory <size:string>", "Amount of memory for the VM", { 613 default: "2G", 614 }) 615 .option( 616 "-b, --bridge <name:string>", 617 "Name of the network bridge to use for networking (e.g., br0)", 618 ) 619 .option( 620 "-d, --detach", 621 "Run VM in the background and print VM name", 622 ) 623 .option( 624 "-p, --port-forward <mappings:string>", 625 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 626 ) 627 .option( 628 "-v, --volume <name:string>", 629 "Name of the volume to attach to the VM, will be created if it doesn't exist", 630 ) 631 .option( 632 "-s, --size <size:string>", 633 "Size of the volume to create if it doesn't exist (e.g., 20G)", 634 ) 635 .option( 636 "--seed <path:string>", 637 "Path to cloud-init seed image (ISO format)", 638 ) 639 .action(async (_options: unknown, image: string) => { 640 await run(image); 641 }) 642 .command("volumes", "List all volumes") 643 .action(async () => { 644 await volumes.list(); 645 }) 646 .command( 647 "volume", 648 new Command() 649 .command("rm", "Remove a volume") 650 .arguments("<volume-name:string>") 651 .action(async (_options: unknown, volumeName: string) => { 652 await volumes.remove(volumeName); 653 }) 654 .command("inspect", "Inspect a volume") 655 .arguments("<volume-name:string>") 656 .action(async (_options: unknown, volumeName: string) => { 657 await volumes.inspect(volumeName); 658 }), 659 ) 660 .description("Manage volumes") 661 .command("serve", "Start the HTTP API server") 662 .option("-p, --port <port:number>", "Port to listen on", { default: 8889 }) 663 .action(() => { 664 serve(); 665 }) 666 .command( 667 "seed", 668 "Seed initial cloud-init user-data and meta-data files for the VM", 669 ) 670 .arguments("[path:string]") 671 .action(async (_options: unknown, path?: string) => { 672 await seed(path); 673 }) 674 .parse(Deno.args); 675}