A Docker-like CLI and HTTP API for managing headless VMs

feat: add support for Gentoo image handling and URL construction

+176 -57
+20
main.ts
··· 32 32 import * as volumes from "./src/subcommands/volume.ts"; 33 33 import { 34 34 constructFedoraImageURL, 35 + constructGentooImageURL, 35 36 constructNixOSImageURL, 36 37 createDriveImageIfNeeded, 37 38 downloadIso, ··· 225 226 isoPath = yield* downloadIso(fedoraImageURL, options); 226 227 } else { 227 228 isoPath = basename(fedoraImageURL); 229 + } 230 + } 231 + 232 + const gentooImageURL = yield* pipe( 233 + constructGentooImageURL(input), 234 + Effect.catchAll(() => Effect.succeed(null)), 235 + ); 236 + 237 + if (gentooImageURL) { 238 + const cached = yield* pipe( 239 + basename(gentooImageURL), 240 + fileExists, 241 + Effect.flatMap(() => Effect.succeed(true)), 242 + Effect.catchAll(() => Effect.succeed(false)), 243 + ); 244 + if (!cached) { 245 + isoPath = yield* downloadIso(gentooImageURL, options); 246 + } else { 247 + isoPath = basename(gentooImageURL); 228 248 } 229 249 } 230 250 }
+4
src/constants.ts
··· 20 20 21 21 export const FEDORA_IMG_URL: string = 22 22 `https://download.fedoraproject.org/pub/fedora/linux/releases/43/Server/${Deno.build.arch}/images/Fedora-Server-Guest-Generic-43-1.6.${Deno.build.arch}.qcow2`; 23 + 24 + export const GENTOO_IMG_URL: string = Deno.build.arch === "aarch64" 25 + ? "https://distfiles.gentoo.org/releases/arm64/autobuilds/20251116T233105Z/di-arm64-console-20251116T233105Z.qcow2" 26 + : "https://distfiles.gentoo.org/releases/amd64/autobuilds/20251116T161545Z/di-amd64-console-20251116T161545Z.qcow2";
+89 -39
src/utils.ts
··· 9 9 FEDORA_COREOS_DEFAULT_VERSION, 10 10 FEDORA_COREOS_IMG_URL, 11 11 FEDORA_IMG_URL, 12 + GENTOO_IMG_URL, 12 13 LOGS_DIR, 13 14 NIXOS_DEFAULT_VERSION, 14 15 NIXOS_ISO_URL, ··· 65 66 export const isValidISOurl = (url?: string): boolean => { 66 67 return Boolean( 67 68 (url?.startsWith("http://") || url?.startsWith("https://")) && 68 - url?.endsWith(".iso"), 69 + url?.endsWith(".iso") 69 70 ); 70 71 }; 71 72 ··· 91 92 }); 92 93 93 94 export const validateImage = ( 94 - image: string, 95 + image: string 95 96 ): Effect.Effect<string, InvalidImageNameError, never> => { 96 97 const regex = 97 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._-]+)?$/; ··· 102 103 image, 103 104 cause: 104 105 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 105 - }), 106 + }) 106 107 ); 107 108 } 108 109 return Effect.succeed(image); ··· 111 112 export const extractTag = (name: string) => 112 113 pipe( 113 114 validateImage(name), 114 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 115 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 115 116 ); 116 117 117 118 export const failOnMissingImage = ( 118 - image: Image | undefined, 119 + image: Image | undefined 119 120 ): Effect.Effect<Image, Error, never> => 120 121 image 121 122 ? Effect.succeed(image) 122 123 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 123 124 124 125 export const du = ( 125 - path: string, 126 + path: string 126 127 ): Effect.Effect<number, LogCommandError, never> => 127 128 Effect.tryPromise({ 128 129 try: async () => { ··· 154 155 exists 155 156 ? Effect.succeed(true) 156 157 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 157 - ), 158 + ) 158 159 ); 159 160 160 161 export const downloadIso = (url: string, options: Options) => ··· 176 177 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 177 178 console.log( 178 179 chalk.yellowBright( 179 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 180 - ), 180 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 181 + ) 181 182 ); 182 183 return null; 183 184 } ··· 195 196 if (outputExists) { 196 197 console.log( 197 198 chalk.yellowBright( 198 - `File ${outputPath} already exists, skipping download.`, 199 - ), 199 + `File ${outputPath} already exists, skipping download.` 200 + ) 200 201 ); 201 202 return outputPath; 202 203 } ··· 245 246 if (!success) { 246 247 console.error( 247 248 chalk.redBright( 248 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 249 - ), 249 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 250 + ) 250 251 ); 251 252 Deno.exit(1); 252 253 } ··· 259 260 try: () => 260 261 Deno.copyFile( 261 262 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 262 - edk2VarsAarch64, 263 + edk2VarsAarch64 263 264 ), 264 265 catch: (error) => new LogCommandError({ cause: error }), 265 266 }); ··· 304 305 const configOK = yield* pipe( 305 306 fileExists("config.ign"), 306 307 Effect.flatMap(() => Effect.succeed(true)), 307 - Effect.catchAll(() => Effect.succeed(false)), 308 + Effect.catchAll(() => Effect.succeed(false)) 308 309 ); 309 310 if (!configOK) { 310 311 console.error( 311 312 chalk.redBright( 312 - "CoreOS image requires a config.ign file in the current directory.", 313 - ), 313 + "CoreOS image requires a config.ign file in the current directory." 314 + ) 314 315 ); 315 316 Deno.exit(1); 316 317 } ··· 339 340 return []; 340 341 }); 341 342 343 + export const setupGentooArgs = (imagePath?: string | null) => 344 + Effect.sync(() => { 345 + if ( 346 + imagePath && 347 + imagePath.endsWith(".qcow2") && 348 + imagePath.startsWith( 349 + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` 350 + ) 351 + ) { 352 + return ["-drive", `file=${imagePath},format=qcow2,if=virtio`]; 353 + } 354 + 355 + return []; 356 + }); 357 + 342 358 export const runQemu = (isoPath: string | null, options: Options) => 343 359 Effect.gen(function* () { 344 360 const macAddress = yield* generateRandomMacAddress(); 345 361 346 - const qemu = Deno.build.arch === "aarch64" 347 - ? "qemu-system-aarch64" 348 - : "qemu-system-x86_64"; 362 + const qemu = 363 + Deno.build.arch === "aarch64" 364 + ? "qemu-system-aarch64" 365 + : "qemu-system-x86_64"; 349 366 350 367 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 351 368 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); 352 369 let fedoraArgs: string[] = yield* setupFedoraArgs(isoPath || options.image); 370 + let gentooArgs: string[] = yield* setupGentooArgs(isoPath || options.image); 353 371 354 372 if (coreosArgs.length > 0 && !isoPath) { 355 373 coreosArgs = coreosArgs.slice(2); ··· 357 375 358 376 if (fedoraArgs.length > 0 && !isoPath) { 359 377 fedoraArgs = []; 378 + } 379 + 380 + if (gentooArgs.length > 0 && !isoPath) { 381 + gentooArgs = []; 360 382 } 361 383 362 384 const qemuArgs = [ ··· 387 409 ...firmwareFiles, 388 410 ...coreosArgs, 389 411 ...fedoraArgs, 412 + ...gentooArgs, 390 413 ..._.compact( 391 414 options.image && [ 392 415 "-drive", 393 416 `file=${options.image},format=${options.diskFormat},if=virtio`, 394 - ], 417 + ] 395 418 ), 396 419 ]; 397 420 ··· 406 429 const logPath = `${LOGS_DIR}/${name}.log`; 407 430 408 431 const fullCommand = options.bridge 409 - ? `sudo ${qemu} ${ 410 - qemuArgs 432 + ? `sudo ${qemu} ${qemuArgs 411 433 .slice(1) 412 - .join(" ") 413 - } >> "${logPath}" 2>&1 & echo $!` 434 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 414 435 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 415 436 416 437 const { stdout } = yield* Effect.tryPromise({ ··· 436 457 cpus: options.cpus, 437 458 cpu: options.cpu, 438 459 diskSize: options.size || "20G", 439 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 460 + diskFormat: 461 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 440 462 options.diskFormat || 441 463 "raw", 442 464 portForward: options.portForward, ··· 455 477 }); 456 478 457 479 console.log( 458 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 480 + `Virtual machine ${name} started in background (PID: ${qemuPid})` 459 481 ); 460 482 console.log(`Logs will be written to: ${logPath}`); 461 483 ··· 478 500 cpus: options.cpus, 479 501 cpu: options.cpu, 480 502 diskSize: options.size || "20G", 481 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 503 + diskFormat: 504 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 482 505 options.diskFormat || 483 506 "raw", 484 507 portForward: options.portForward, ··· 586 609 if (pathExists) { 587 610 console.log( 588 611 chalk.yellowBright( 589 - `Drive image ${path} already exists, skipping creation.`, 590 - ), 612 + `Drive image ${path} already exists, skipping creation.` 613 + ) 591 614 ); 592 615 return; 593 616 } ··· 614 637 }); 615 638 616 639 export const fileExists = ( 617 - path: string, 640 + path: string 618 641 ): Effect.Effect<void, NoSuchFileError, never> => 619 642 Effect.try({ 620 643 try: () => Deno.statSync(path), ··· 622 645 }); 623 646 624 647 export const constructCoreOSImageURL = ( 625 - image: string, 648 + image: string 626 649 ): Effect.Effect<string, InvalidImageNameError, never> => { 627 650 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 628 651 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 630 653 if (match) { 631 654 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 632 655 return Effect.succeed( 633 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 656 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 634 657 ); 635 658 } 636 659 ··· 638 661 new InvalidImageNameError({ 639 662 image, 640 663 cause: "Image name does not match CoreOS naming conventions.", 641 - }), 664 + }) 642 665 ); 643 666 }; 644 667 ··· 667 690 }); 668 691 669 692 export const constructNixOSImageURL = ( 670 - image: string, 693 + image: string 671 694 ): Effect.Effect<string, InvalidImageNameError, never> => { 672 695 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 673 696 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 675 698 if (match) { 676 699 const version = match[3] || NIXOS_DEFAULT_VERSION; 677 700 return Effect.succeed( 678 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 701 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 679 702 ); 680 703 } 681 704 ··· 683 706 new InvalidImageNameError({ 684 707 image, 685 708 cause: "Image name does not match NixOS naming conventions.", 686 - }), 709 + }) 687 710 ); 688 711 }; 689 712 690 713 export const constructFedoraImageURL = ( 691 - image: string, 714 + image: string 692 715 ): Effect.Effect<string, InvalidImageNameError, never> => { 693 716 // detect with regex if image matches Fedora pattern: fedora 694 717 const fedoraRegex = /^(fedora)$/; ··· 701 724 new InvalidImageNameError({ 702 725 image, 703 726 cause: "Image name does not match Fedora naming conventions.", 704 - }), 727 + }) 728 + ); 729 + }; 730 + 731 + export const constructGentooImageURL = ( 732 + image: string 733 + ): Effect.Effect<string, InvalidImageNameError, never> => { 734 + // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 735 + const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; 736 + const match = image.match(gentooRegex); 737 + if (match?.[3]) { 738 + return Effect.succeed( 739 + GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 740 + "20251116T233105Z", 741 + match[3] 742 + ) 743 + ); 744 + } 745 + 746 + if (match) { 747 + return Effect.succeed(GENTOO_IMG_URL); 748 + } 749 + 750 + return Effect.fail( 751 + new InvalidImageNameError({ 752 + image, 753 + cause: "Image name does not match Gentoo naming conventions.", 754 + }) 705 755 ); 706 756 };
+63 -18
src/utils_test.ts
··· 1 1 import { assertEquals } from "@std/assert"; 2 2 import { Effect, pipe } from "effect"; 3 - import { FEDORA_COREOS_IMG_URL, NIXOS_ISO_URL } from "./constants.ts"; 4 - import { constructCoreOSImageURL, constructNixOSImageURL } from "./utils.ts"; 3 + import { 4 + FEDORA_COREOS_IMG_URL, 5 + GENTOO_IMG_URL, 6 + NIXOS_ISO_URL, 7 + } from "./constants.ts"; 8 + import { 9 + constructCoreOSImageURL, 10 + constructGentooImageURL, 11 + constructNixOSImageURL, 12 + } from "./utils.ts"; 5 13 6 14 Deno.test("Test Default Fedora CoreOS Image URL", () => { 7 15 const url = Effect.runSync( 8 16 pipe( 9 17 constructCoreOSImageURL("fedora-coreos"), 10 - Effect.catchAll((_error) => Effect.succeed(null as string | null)), 11 - ), 18 + Effect.catchAll((_error) => Effect.succeed(null as string | null)) 19 + ) 12 20 ); 13 21 14 22 assertEquals(url, FEDORA_COREOS_IMG_URL); ··· 18 26 const url = Effect.runSync( 19 27 pipe( 20 28 constructCoreOSImageURL("coreos"), 21 - Effect.catchAll((_error) => Effect.succeed(null as string | null)), 22 - ), 29 + Effect.catchAll((_error) => Effect.succeed(null as string | null)) 30 + ) 23 31 ); 24 32 25 33 assertEquals(url, FEDORA_COREOS_IMG_URL); ··· 29 37 const url = Effect.runSync( 30 38 pipe( 31 39 constructCoreOSImageURL("fedora-coreos-43.20251024.2.0"), 32 - Effect.catchAll((_error) => Effect.succeed(null as string | null)), 33 - ), 40 + Effect.catchAll((_error) => Effect.succeed(null as string | null)) 41 + ) 34 42 ); 35 43 36 44 assertEquals( 37 45 url, 38 46 "https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/43.20251024.2.0/" + 39 - `${Deno.build.arch}/fedora-coreos-43.20251024.2.0-qemu.${Deno.build.arch}.qcow2.xz`, 47 + `${Deno.build.arch}/fedora-coreos-43.20251024.2.0-qemu.${Deno.build.arch}.qcow2.xz` 40 48 ); 41 49 }); 42 50 ··· 44 52 const url = Effect.runSync( 45 53 pipe( 46 54 constructCoreOSImageURL("fedora-coreos-latest"), 47 - Effect.catchAll((_error) => Effect.succeed(null as string | null)), 48 - ), 55 + Effect.catchAll((_error) => Effect.succeed(null as string | null)) 56 + ) 49 57 ); 50 58 51 59 assertEquals(url, null); ··· 55 63 const url = Effect.runSync( 56 64 pipe( 57 65 constructNixOSImageURL("nixos"), 58 - Effect.catchAll((_error) => Effect.succeed(null as string | null)), 59 - ), 66 + Effect.catchAll((_error) => Effect.succeed(null as string | null)) 67 + ) 60 68 ); 61 69 62 70 assertEquals(url, NIXOS_ISO_URL); ··· 66 74 const url = Effect.runSync( 67 75 pipe( 68 76 constructNixOSImageURL("nixos-24.05"), 69 - Effect.catchAll((_error) => Effect.succeed(null as string | null)), 70 - ), 77 + Effect.catchAll((_error) => Effect.succeed(null as string | null)) 78 + ) 71 79 ); 72 80 73 81 assertEquals( 74 82 url, 75 - `https://channels.nixos.org/nixos-24.05/latest-nixos-minimal-${Deno.build.arch}-linux.iso`, 83 + `https://channels.nixos.org/nixos-24.05/latest-nixos-minimal-${Deno.build.arch}-linux.iso` 76 84 ); 77 85 }); 78 86 ··· 80 88 const url = Effect.runSync( 81 89 pipe( 82 90 constructNixOSImageURL("nixos-latest"), 83 - Effect.catchAll((_error) => Effect.succeed(null as string | null)), 84 - ), 91 + Effect.catchAll((_error) => Effect.succeed(null as string | null)) 92 + ) 93 + ); 94 + 95 + assertEquals(url, null); 96 + }); 97 + 98 + Deno.test("Test valid Gentoo Image Name", () => { 99 + const url = Effect.runSync( 100 + pipe( 101 + constructGentooImageURL("gentoo-20251116T161545Z"), 102 + Effect.catchAll((_error) => Effect.succeed(null as string | null)) 103 + ) 104 + ); 105 + 106 + const arch = Deno.build.arch === "aarch64" ? "arm64" : "amd64"; 107 + assertEquals( 108 + url, 109 + `https://distfiles.gentoo.org/releases/${arch}/autobuilds/20251116T161545Z/di-${arch}-console-20251116T161545Z.qcow2` 110 + ); 111 + }); 112 + 113 + Deno.test("Test valid Gentoo Image Name", () => { 114 + const url = Effect.runSync( 115 + pipe( 116 + constructGentooImageURL("gentoo"), 117 + Effect.catchAll((_error) => Effect.succeed(null as string | null)) 118 + ) 119 + ); 120 + 121 + assertEquals(url, GENTOO_IMG_URL); 122 + }); 123 + 124 + Deno.test("Test invalid Gentoo Image Name", () => { 125 + const url = Effect.runSync( 126 + pipe( 127 + constructGentooImageURL("gentoo-latest"), 128 + Effect.catchAll((_error) => Effect.succeed(null as string | null)) 129 + ) 85 130 ); 86 131 87 132 assertEquals(url, null);