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

feat: add support for AlmaLinux and Rocky Linux image URLs and related functions

+225 -52
+40
main.ts
··· 32 32 import tag from "./src/subcommands/tag.ts"; 33 33 import * as volumes from "./src/subcommands/volume.ts"; 34 34 import { 35 + constructAlmaLinuxImageURL, 35 36 constructAlpineImageURL, 36 37 constructDebianImageURL, 37 38 constructFedoraImageURL, 38 39 constructGentooImageURL, 39 40 constructNixOSImageURL, 41 + constructRockyLinuxImageURL, 40 42 constructUbuntuImageURL, 41 43 createDriveImageIfNeeded, 42 44 downloadIso, ··· 310 312 isoPath = yield* downloadIso(alpineImageURL, options); 311 313 } else { 312 314 isoPath = basename(alpineImageURL); 315 + } 316 + } 317 + 318 + const almalinuxImageURL = yield* pipe( 319 + constructAlmaLinuxImageURL(input), 320 + Effect.catchAll(() => Effect.succeed(null)), 321 + ); 322 + 323 + if (almalinuxImageURL) { 324 + const cached = yield* pipe( 325 + basename(almalinuxImageURL), 326 + fileExists, 327 + Effect.flatMap(() => Effect.succeed(true)), 328 + Effect.catchAll(() => Effect.succeed(false)), 329 + ); 330 + if (!cached) { 331 + isoPath = yield* downloadIso(almalinuxImageURL, options); 332 + } else { 333 + isoPath = basename(almalinuxImageURL); 334 + } 335 + } 336 + 337 + const rockylinuxImageURL = yield* pipe( 338 + constructRockyLinuxImageURL(input), 339 + Effect.catchAll(() => Effect.succeed(null)), 340 + ); 341 + 342 + if (rockylinuxImageURL) { 343 + const cached = yield* pipe( 344 + basename(rockylinuxImageURL), 345 + fileExists, 346 + Effect.flatMap(() => Effect.succeed(true)), 347 + Effect.catchAll(() => Effect.succeed(false)), 348 + ); 349 + if (!cached) { 350 + isoPath = yield* downloadIso(rockylinuxImageURL, options); 351 + } else { 352 + isoPath = basename(rockylinuxImageURL); 313 353 } 314 354 } 315 355 }
+8
src/constants.ts
··· 56 56 export const UBUNTU_CLOUD_IMG_URL: string = Deno.build.arch === "aarch64" 57 57 ? "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img" 58 58 : "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"; 59 + 60 + export const ALMA_LINUX_IMG_URL: string = Deno.build.arch === "aarch64" 61 + ? "https://repo.almalinux.org/almalinux/10/cloud/aarch64/images/AlmaLinux-10-GenericCloud-latest.aarch64.qcow2" 62 + : "https://repo.almalinux.org/almalinux/10/cloud/x86_64/images/AlmaLinux-10-GenericCloud-latest.x86_64.qcow2"; 63 + 64 + export const ROCKY_LINUX_IMG_URL: string = Deno.build.arch === "aarch64" 65 + ? "https://dl.rockylinux.org/pub/rocky/9/images/aarch64/Rocky-9-GenericCloud.latest.aarch64.qcow2" 66 + : "https://dl.rockylinux.org/pub/rocky/9/images/x86_64/Rocky-9-GenericCloud.latest.x86_64.qcow2";
+151 -52
src/utils.ts
··· 5 5 import { Data, Effect, pipe } from "effect"; 6 6 import Moniker from "moniker"; 7 7 import { 8 + ALMA_LINUX_IMG_URL, 8 9 ALPINE_DEFAULT_VERSION, 9 10 ALPINE_ISO_URL, 10 11 DEBIAN_CLOUD_IMG_URL, ··· 18 19 LOGS_DIR, 19 20 NIXOS_DEFAULT_VERSION, 20 21 NIXOS_ISO_URL, 22 + ROCKY_LINUX_IMG_URL, 21 23 UBUNTU_CLOUD_IMG_URL, 22 24 UBUNTU_ISO_URL, 23 25 } from "./constants.ts"; ··· 74 76 export const isValidISOurl = (url?: string): boolean => { 75 77 return Boolean( 76 78 (url?.startsWith("http://") || url?.startsWith("https://")) && 77 - url?.endsWith(".iso"), 79 + url?.endsWith(".iso") 78 80 ); 79 81 }; 80 82 ··· 100 102 }); 101 103 102 104 export const validateImage = ( 103 - image: string, 105 + image: string 104 106 ): Effect.Effect<string, InvalidImageNameError, never> => { 105 107 const regex = 106 108 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 111 113 image, 112 114 cause: 113 115 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 114 - }), 116 + }) 115 117 ); 116 118 } 117 119 return Effect.succeed(image); ··· 120 122 export const extractTag = (name: string) => 121 123 pipe( 122 124 validateImage(name), 123 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 125 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 124 126 ); 125 127 126 128 export const failOnMissingImage = ( 127 - image: Image | undefined, 129 + image: Image | undefined 128 130 ): Effect.Effect<Image, Error, never> => 129 131 image 130 132 ? Effect.succeed(image) 131 133 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 132 134 133 135 export const du = ( 134 - path: string, 136 + path: string 135 137 ): Effect.Effect<number, LogCommandError, never> => 136 138 Effect.tryPromise({ 137 139 try: async () => { ··· 163 165 exists 164 166 ? Effect.succeed(true) 165 167 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 166 - ), 168 + ) 167 169 ); 168 170 169 171 export const downloadIso = (url: string, options: Options) => ··· 185 187 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 186 188 console.log( 187 189 chalk.yellowBright( 188 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 189 - ), 190 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 191 + ) 190 192 ); 191 193 return null; 192 194 } ··· 204 206 if (outputExists) { 205 207 console.log( 206 208 chalk.yellowBright( 207 - `File ${outputPath} already exists, skipping download.`, 208 - ), 209 + `File ${outputPath} already exists, skipping download.` 210 + ) 209 211 ); 210 212 return outputPath; 211 213 } ··· 254 256 if (!success) { 255 257 console.error( 256 258 chalk.redBright( 257 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 258 - ), 259 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 260 + ) 259 261 ); 260 262 Deno.exit(1); 261 263 } ··· 268 270 try: () => 269 271 Deno.copyFile( 270 272 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 271 - edk2VarsAarch64, 273 + edk2VarsAarch64 272 274 ), 273 275 catch: (error) => new LogCommandError({ cause: error }), 274 276 }); ··· 313 315 const configOK = yield* pipe( 314 316 fileExists("config.ign"), 315 317 Effect.flatMap(() => Effect.succeed(true)), 316 - Effect.catchAll(() => Effect.succeed(false)), 318 + Effect.catchAll(() => Effect.succeed(false)) 317 319 ); 318 320 if (!configOK) { 319 321 console.error( 320 322 chalk.redBright( 321 - "CoreOS image requires a config.ign file in the current directory.", 322 - ), 323 + "CoreOS image requires a config.ign file in the current directory." 324 + ) 323 325 ); 324 326 Deno.exit(1); 325 327 } ··· 354 356 imagePath && 355 357 imagePath.endsWith(".qcow2") && 356 358 imagePath.startsWith( 357 - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, 359 + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` 358 360 ) 359 361 ) { 360 362 return ["-drive", `file=${imagePath},format=qcow2,if=virtio`]; ··· 417 419 return []; 418 420 }); 419 421 422 + export const setupAlmaLinuxArgs = (imagePath?: string | null) => 423 + Effect.sync(() => { 424 + if ( 425 + imagePath && 426 + imagePath.endsWith(".qcow2") && 427 + imagePath.includes("AlmaLinux") 428 + ) { 429 + return [ 430 + "-drive", 431 + `file=${imagePath},format=qcow2,if=virtio`, 432 + "-drive", 433 + "if=virtio,file=seed.iso,media=cdrom", 434 + ]; 435 + } 436 + 437 + return []; 438 + }); 439 + 440 + export const setupRockyLinuxArgs = (imagePath?: string | null) => 441 + Effect.sync(() => { 442 + if ( 443 + imagePath && 444 + imagePath.endsWith(".qcow2") && 445 + imagePath.includes("Rocky") 446 + ) { 447 + return [ 448 + "-drive", 449 + `file=${imagePath},format=qcow2,if=virtio`, 450 + "-drive", 451 + "if=virtio,file=seed.iso,media=cdrom", 452 + ]; 453 + } 454 + 455 + return []; 456 + }); 457 + 420 458 export const runQemu = (isoPath: string | null, options: Options) => 421 459 Effect.gen(function* () { 422 460 const macAddress = yield* generateRandomMacAddress(); 423 461 424 - const qemu = Deno.build.arch === "aarch64" 425 - ? "qemu-system-aarch64" 426 - : "qemu-system-x86_64"; 462 + const qemu = 463 + Deno.build.arch === "aarch64" 464 + ? "qemu-system-aarch64" 465 + : "qemu-system-x86_64"; 427 466 428 467 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 429 468 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); ··· 432 471 let alpineArgs: string[] = yield* setupAlpineArgs(isoPath || options.image); 433 472 let debianArgs: string[] = yield* setupDebianArgs(isoPath || options.image); 434 473 let ubuntuArgs: string[] = yield* setupUbuntuArgs(isoPath || options.image); 474 + let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( 475 + isoPath || options.image 476 + ); 477 + let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( 478 + isoPath || options.image 479 + ); 435 480 436 481 if (coreosArgs.length > 0 && !isoPath) { 437 482 coreosArgs = coreosArgs.slice(2); ··· 457 502 ubuntuArgs = []; 458 503 } 459 504 505 + if (almalinuxArgs.length > 0 && !isoPath) { 506 + almalinuxArgs = []; 507 + } 508 + 509 + if (rockylinuxArgs.length > 0 && !isoPath) { 510 + rockylinuxArgs = []; 511 + } 512 + 460 513 const qemuArgs = [ 461 514 ..._.compact([options.bridge && qemu]), 462 515 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), ··· 489 542 ...alpineArgs, 490 543 ...debianArgs, 491 544 ...ubuntuArgs, 545 + ...almalinuxArgs, 546 + ...rockylinuxArgs, 492 547 ..._.compact( 493 548 options.image && [ 494 549 "-drive", 495 550 `file=${options.image},format=${options.diskFormat},if=virtio`, 496 - ], 551 + ] 497 552 ), 498 553 ]; 499 554 ··· 508 563 const logPath = `${LOGS_DIR}/${name}.log`; 509 564 510 565 const fullCommand = options.bridge 511 - ? `sudo ${qemu} ${ 512 - qemuArgs 566 + ? `sudo ${qemu} ${qemuArgs 513 567 .slice(1) 514 - .join(" ") 515 - } >> "${logPath}" 2>&1 & echo $!` 568 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 516 569 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 517 570 518 571 const { stdout } = yield* Effect.tryPromise({ ··· 538 591 cpus: options.cpus, 539 592 cpu: options.cpu, 540 593 diskSize: options.size || "20G", 541 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 594 + diskFormat: 595 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 542 596 options.diskFormat || 543 597 "raw", 544 598 portForward: options.portForward, ··· 557 611 }); 558 612 559 613 console.log( 560 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 614 + `Virtual machine ${name} started in background (PID: ${qemuPid})` 561 615 ); 562 616 console.log(`Logs will be written to: ${logPath}`); 563 617 ··· 580 634 cpus: options.cpus, 581 635 cpu: options.cpu, 582 636 diskSize: options.size || "20G", 583 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 637 + diskFormat: 638 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 584 639 options.diskFormat || 585 640 "raw", 586 641 portForward: options.portForward, ··· 688 743 if (pathExists) { 689 744 console.log( 690 745 chalk.yellowBright( 691 - `Drive image ${path} already exists, skipping creation.`, 692 - ), 746 + `Drive image ${path} already exists, skipping creation.` 747 + ) 693 748 ); 694 749 return; 695 750 } ··· 716 771 }); 717 772 718 773 export const fileExists = ( 719 - path: string, 774 + path: string 720 775 ): Effect.Effect<void, NoSuchFileError, never> => 721 776 Effect.try({ 722 777 try: () => Deno.statSync(path), ··· 724 779 }); 725 780 726 781 export const constructCoreOSImageURL = ( 727 - image: string, 782 + image: string 728 783 ): Effect.Effect<string, InvalidImageNameError, never> => { 729 784 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 730 785 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 732 787 if (match) { 733 788 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 734 789 return Effect.succeed( 735 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 790 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 736 791 ); 737 792 } 738 793 ··· 740 795 new InvalidImageNameError({ 741 796 image, 742 797 cause: "Image name does not match CoreOS naming conventions.", 743 - }), 798 + }) 744 799 ); 745 800 }; 746 801 ··· 769 824 }); 770 825 771 826 export const constructNixOSImageURL = ( 772 - image: string, 827 + image: string 773 828 ): Effect.Effect<string, InvalidImageNameError, never> => { 774 829 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 775 830 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 777 832 if (match) { 778 833 const version = match[3] || NIXOS_DEFAULT_VERSION; 779 834 return Effect.succeed( 780 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 835 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 781 836 ); 782 837 } 783 838 ··· 785 840 new InvalidImageNameError({ 786 841 image, 787 842 cause: "Image name does not match NixOS naming conventions.", 788 - }), 843 + }) 789 844 ); 790 845 }; 791 846 792 847 export const constructFedoraImageURL = ( 793 - image: string, 848 + image: string 794 849 ): Effect.Effect<string, InvalidImageNameError, never> => { 795 850 // detect with regex if image matches Fedora pattern: fedora 796 851 const fedoraRegex = /^(fedora)$/; ··· 803 858 new InvalidImageNameError({ 804 859 image, 805 860 cause: "Image name does not match Fedora naming conventions.", 806 - }), 861 + }) 807 862 ); 808 863 }; 809 864 810 865 export const constructGentooImageURL = ( 811 - image: string, 866 + image: string 812 867 ): Effect.Effect<string, InvalidImageNameError, never> => { 813 868 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 814 869 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; ··· 817 872 return Effect.succeed( 818 873 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 819 874 "20251116T233105Z", 820 - match[3], 821 - ), 875 + match[3] 876 + ) 822 877 ); 823 878 } 824 879 ··· 830 885 new InvalidImageNameError({ 831 886 image, 832 887 cause: "Image name does not match Gentoo naming conventions.", 833 - }), 888 + }) 834 889 ); 835 890 }; 836 891 837 892 export const constructDebianImageURL = ( 838 893 image: string, 839 - cloud: boolean = false, 894 + cloud: boolean = false 840 895 ): Effect.Effect<string, InvalidImageNameError, never> => { 841 896 if (cloud && image === "debian") { 842 897 return Effect.succeed(DEBIAN_CLOUD_IMG_URL); ··· 847 902 const match = image.match(debianRegex); 848 903 if (match?.[3]) { 849 904 return Effect.succeed( 850 - DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]), 905 + DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]) 851 906 ); 852 907 } 853 908 ··· 859 914 new InvalidImageNameError({ 860 915 image, 861 916 cause: "Image name does not match Debian naming conventions.", 862 - }), 917 + }) 863 918 ); 864 919 }; 865 920 866 921 export const constructAlpineImageURL = ( 867 - image: string, 922 + image: string 868 923 ): Effect.Effect<string, InvalidImageNameError, never> => { 869 924 // detect with regex if image matches alpine pattern: alpine-<version> or alpine 870 925 const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; 871 926 const match = image.match(alpineRegex); 872 927 if (match?.[3]) { 873 928 return Effect.succeed( 874 - ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]), 929 + ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]) 875 930 ); 876 931 } 877 932 ··· 883 938 new InvalidImageNameError({ 884 939 image, 885 940 cause: "Image name does not match Alpine naming conventions.", 886 - }), 941 + }) 887 942 ); 888 943 }; 889 944 890 945 export const constructUbuntuImageURL = ( 891 946 image: string, 892 - cloud: boolean = false, 947 + cloud: boolean = false 893 948 ): Effect.Effect<string, InvalidImageNameError, never> => { 894 949 // detect with regex if image matches ubuntu pattern: ubuntu 895 950 const ubuntuRegex = /^(ubuntu)$/; ··· 905 960 new InvalidImageNameError({ 906 961 image, 907 962 cause: "Image name does not match Ubuntu naming conventions.", 908 - }), 963 + }) 964 + ); 965 + }; 966 + 967 + export const constructAlmaLinuxImageURL = ( 968 + image: string, 969 + cloud: boolean = false 970 + ): Effect.Effect<string, InvalidImageNameError, never> => { 971 + // detect with regex if image matches almalinux pattern: ubuntu 972 + const almaLinuxRegex = /^(almalinux)$/; 973 + const match = image.match(almaLinuxRegex); 974 + if (match) { 975 + if (cloud) { 976 + return Effect.succeed(ALMA_LINUX_IMG_URL); 977 + } 978 + return Effect.succeed(ALMA_LINUX_IMG_URL); 979 + } 980 + 981 + return Effect.fail( 982 + new InvalidImageNameError({ 983 + image, 984 + cause: "Image name does not match AlmaLinux naming conventions.", 985 + }) 986 + ); 987 + }; 988 + 989 + export const constructRockyLinuxImageURL = ( 990 + image: string, 991 + cloud: boolean = false 992 + ): Effect.Effect<string, InvalidImageNameError, never> => { 993 + // detect with regex if image matches rockylinux pattern: ubuntu 994 + const rockyLinuxRegex = /^(rockylinux)$/; 995 + const match = image.match(rockyLinuxRegex); 996 + if (match) { 997 + if (cloud) { 998 + return Effect.succeed(ROCKY_LINUX_IMG_URL); 999 + } 1000 + return Effect.succeed(ROCKY_LINUX_IMG_URL); 1001 + } 1002 + 1003 + return Effect.fail( 1004 + new InvalidImageNameError({ 1005 + image, 1006 + cause: "Image name does not match RockyLinux naming conventions.", 1007 + }) 909 1008 ); 910 1009 };
+26
src/utils_test.ts
··· 1 1 import { assertEquals } from "@std/assert"; 2 2 import { Effect, pipe } from "effect"; 3 3 import { 4 + ALMA_LINUX_IMG_URL, 4 5 ALPINE_ISO_URL, 5 6 DEBIAN_CLOUD_IMG_URL, 6 7 DEBIAN_ISO_URL, 7 8 FEDORA_COREOS_IMG_URL, 8 9 GENTOO_IMG_URL, 9 10 NIXOS_ISO_URL, 11 + ROCKY_LINUX_IMG_URL, 10 12 UBUNTU_CLOUD_IMG_URL, 11 13 UBUNTU_ISO_URL, 12 14 } from "./constants.ts"; 13 15 import { 16 + constructAlmaLinuxImageURL, 14 17 constructAlpineImageURL, 15 18 constructCoreOSImageURL, 16 19 constructDebianImageURL, 17 20 constructGentooImageURL, 18 21 constructNixOSImageURL, 22 + constructRockyLinuxImageURL, 19 23 constructUbuntuImageURL, 20 24 } from "./utils.ts"; 21 25 ··· 246 250 247 251 assertEquals(url, UBUNTU_CLOUD_IMG_URL); 248 252 }); 253 + 254 + Deno.test("Test valid AlmaLinux Image Name", () => { 255 + const url = Effect.runSync( 256 + pipe( 257 + constructAlmaLinuxImageURL("almalinux"), 258 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 259 + ), 260 + ); 261 + 262 + assertEquals(url, ALMA_LINUX_IMG_URL); 263 + }); 264 + 265 + Deno.test("Test valid RockyLinux Image Name", () => { 266 + const url = Effect.runSync( 267 + pipe( 268 + constructRockyLinuxImageURL("rockylinux"), 269 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 270 + ), 271 + ); 272 + 273 + assertEquals(url, ROCKY_LINUX_IMG_URL); 274 + });