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

run format

+73 -74
+3 -3
README.md
··· 59 59 - **Multiple Disk Formats** - Support for qcow2 and raw disk images 60 60 - **Automatic Provisioning** - Volumes are created automatically from base 61 61 images or attached to VMs 62 - - **Flexible Sizing** - Configurable disk sizes for different workloads 63 - (e.g., `-s 40G`) 62 + - **Flexible Sizing** - Configurable disk sizes for different workloads (e.g., 63 + `-s 40G`) 64 64 - **Volume Attachment** - Attach volumes to VMs with `-v` flag 65 65 66 66 ### ☁️ Cloud-Init Support ··· 522 522 vmx alpine -m 512M -C 1 523 523 vmx fedora-coreos 524 524 vmx nixos-m 4G -C 2 525 - vmx rockylinux -p 2222:22 -d 525 + vmx rockylinux -p 2222:22 -d 526 526 ``` 527 527 528 528 ## License
+70 -71
src/utils.ts
··· 67 67 export const isValidISOurl = (url?: string): boolean => { 68 68 return Boolean( 69 69 (url?.startsWith("http://") || url?.startsWith("https://")) && 70 - url?.endsWith(".iso") 70 + url?.endsWith(".iso"), 71 71 ); 72 72 }; 73 73 ··· 93 93 }); 94 94 95 95 export const validateImage = ( 96 - image: string 96 + image: string, 97 97 ): Effect.Effect<string, InvalidImageNameError, never> => { 98 98 const regex = 99 99 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 104 104 image, 105 105 cause: 106 106 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 107 - }) 107 + }), 108 108 ); 109 109 } 110 110 return Effect.succeed(image); ··· 113 113 export const extractTag = (name: string) => 114 114 pipe( 115 115 validateImage(name), 116 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 116 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 117 117 ); 118 118 119 119 export const failOnMissingImage = ( 120 - image: Image | undefined 120 + image: Image | undefined, 121 121 ): Effect.Effect<Image, Error, never> => 122 122 image 123 123 ? Effect.succeed(image) 124 124 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 125 125 126 126 export const du = ( 127 - path: string 127 + path: string, 128 128 ): Effect.Effect<number, LogCommandError, never> => 129 129 Effect.tryPromise({ 130 130 try: async () => { ··· 156 156 exists 157 157 ? Effect.succeed(true) 158 158 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 159 - ) 159 + ), 160 160 ); 161 161 162 162 export const downloadIso = (url: string, options: Options) => ··· 178 178 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 179 179 console.log( 180 180 chalk.yellowBright( 181 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 182 - ) 181 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 182 + ), 183 183 ); 184 184 return null; 185 185 } ··· 197 197 if (outputExists) { 198 198 console.log( 199 199 chalk.yellowBright( 200 - `File ${outputPath} already exists, skipping download.` 201 - ) 200 + `File ${outputPath} already exists, skipping download.`, 201 + ), 202 202 ); 203 203 return outputPath; 204 204 } ··· 209 209 chalk.blueBright( 210 210 `Downloading ${ 211 211 url.endsWith(".iso") ? "ISO" : "image" 212 - } from ${url}...` 213 - ) 212 + } from ${url}...`, 213 + ), 214 214 ); 215 215 const cmd = new Deno.Command("curl", { 216 216 args: ["-L", "-o", outputPath, url], ··· 253 253 if (!success) { 254 254 console.error( 255 255 chalk.redBright( 256 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 257 - ) 256 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 257 + ), 258 258 ); 259 259 Deno.exit(1); 260 260 } ··· 267 267 try: () => 268 268 Deno.copyFile( 269 269 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 270 - edk2VarsAarch64 270 + edk2VarsAarch64, 271 271 ), 272 272 catch: (error) => new LogCommandError({ cause: error }), 273 273 }); ··· 312 312 const configOK = yield* pipe( 313 313 fileExists("config.ign"), 314 314 Effect.flatMap(() => Effect.succeed(true)), 315 - Effect.catchAll(() => Effect.succeed(false)) 315 + Effect.catchAll(() => Effect.succeed(false)), 316 316 ); 317 317 if (!configOK) { 318 318 console.error( 319 319 chalk.redBright( 320 - "CoreOS image requires a config.ign file in the current directory." 321 - ) 320 + "CoreOS image requires a config.ign file in the current directory.", 321 + ), 322 322 ); 323 323 Deno.exit(1); 324 324 } ··· 358 358 imagePath && 359 359 imagePath.endsWith(".qcow2") && 360 360 imagePath.startsWith( 361 - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` 361 + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, 362 362 ) 363 363 ) { 364 364 return [ ··· 373 373 374 374 export const setupAlpineArgs = ( 375 375 imagePath?: string | null, 376 - seed: string = "seed.iso" 376 + seed: string = "seed.iso", 377 377 ) => 378 378 Effect.sync(() => { 379 379 if ( ··· 394 394 395 395 export const setupDebianArgs = ( 396 396 imagePath?: string | null, 397 - seed: string = "seed.iso" 397 + seed: string = "seed.iso", 398 398 ) => 399 399 Effect.sync(() => { 400 400 if ( ··· 415 415 416 416 export const setupUbuntuArgs = ( 417 417 imagePath?: string | null, 418 - seed: string = "seed.iso" 418 + seed: string = "seed.iso", 419 419 ) => 420 420 Effect.sync(() => { 421 421 if ( ··· 436 436 437 437 export const setupAlmaLinuxArgs = ( 438 438 imagePath?: string | null, 439 - seed: string = "seed.iso" 439 + seed: string = "seed.iso", 440 440 ) => 441 441 Effect.sync(() => { 442 442 if ( ··· 457 457 458 458 export const setupRockyLinuxArgs = ( 459 459 imagePath?: string | null, 460 - seed: string = "seed.iso" 460 + seed: string = "seed.iso", 461 461 ) => 462 462 Effect.sync(() => { 463 463 if ( ··· 480 480 Effect.gen(function* () { 481 481 const macAddress = yield* generateRandomMacAddress(); 482 482 483 - const qemu = 484 - Deno.build.arch === "aarch64" 485 - ? "qemu-system-aarch64" 486 - : "qemu-system-x86_64"; 483 + const qemu = Deno.build.arch === "aarch64" 484 + ? "qemu-system-aarch64" 485 + : "qemu-system-x86_64"; 487 486 488 487 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 489 488 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); 490 489 let fedoraArgs: string[] = yield* setupFedoraArgs( 491 490 isoPath || options.image, 492 - options.seed 491 + options.seed, 493 492 ); 494 493 let gentooArgs: string[] = yield* setupGentooArgs( 495 494 isoPath || options.image, 496 - options.seed 495 + options.seed, 497 496 ); 498 497 let alpineArgs: string[] = yield* setupAlpineArgs( 499 498 isoPath || options.image, 500 - options.seed 499 + options.seed, 501 500 ); 502 501 let debianArgs: string[] = yield* setupDebianArgs( 503 502 isoPath || options.image, 504 - options.seed 503 + options.seed, 505 504 ); 506 505 let ubuntuArgs: string[] = yield* setupUbuntuArgs( 507 506 isoPath || options.image, 508 - options.seed 507 + options.seed, 509 508 ); 510 509 let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( 511 510 isoPath || options.image, 512 - options.seed 511 + options.seed, 513 512 ); 514 513 let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( 515 514 isoPath || options.image, 516 - options.seed 515 + options.seed, 517 516 ); 518 517 519 518 if (coreosArgs.length > 0 && !isoPath) { ··· 586 585 options.image && [ 587 586 "-drive", 588 587 `file=${options.image},format=${options.diskFormat},if=virtio`, 589 - ] 588 + ], 590 589 ), 591 590 ]; 592 591 ··· 601 600 const logPath = `${LOGS_DIR}/${name}.log`; 602 601 603 602 const fullCommand = options.bridge 604 - ? `sudo ${qemu} ${qemuArgs 603 + ? `sudo ${qemu} ${ 604 + qemuArgs 605 605 .slice(1) 606 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 606 + .join(" ") 607 + } >> "${logPath}" 2>&1 & echo $!` 607 608 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 608 609 609 610 const { stdout } = yield* Effect.tryPromise({ ··· 629 630 cpus: options.cpus, 630 631 cpu: options.cpu, 631 632 diskSize: options.size || "20G", 632 - diskFormat: 633 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 633 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 634 634 options.diskFormat || 635 635 "raw", 636 636 portForward: options.portForward, ··· 650 650 }); 651 651 652 652 console.log( 653 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 653 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 654 654 ); 655 655 console.log(`Logs will be written to: ${logPath}`); 656 656 ··· 673 673 cpus: options.cpus, 674 674 cpu: options.cpu, 675 675 diskSize: options.size || "20G", 676 - diskFormat: 677 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 676 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 678 677 options.diskFormat || 679 678 "raw", 680 679 portForward: options.portForward, ··· 783 782 if (pathExists) { 784 783 console.log( 785 784 chalk.yellowBright( 786 - `Drive image ${path} already exists, skipping creation.` 787 - ) 785 + `Drive image ${path} already exists, skipping creation.`, 786 + ), 788 787 ); 789 788 return; 790 789 } ··· 811 810 }); 812 811 813 812 export const fileExists = ( 814 - path: string 813 + path: string, 815 814 ): Effect.Effect<void, NoSuchFileError, never> => 816 815 Effect.try({ 817 816 try: () => Deno.statSync(path), ··· 819 818 }); 820 819 821 820 export const constructCoreOSImageURL = ( 822 - image: string 821 + image: string, 823 822 ): Effect.Effect<string, InvalidImageNameError, never> => { 824 823 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 825 824 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 827 826 if (match) { 828 827 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 829 828 return Effect.succeed( 830 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 829 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 831 830 ); 832 831 } 833 832 ··· 835 834 new InvalidImageNameError({ 836 835 image, 837 836 cause: "Image name does not match CoreOS naming conventions.", 838 - }) 837 + }), 839 838 ); 840 839 }; 841 840 ··· 864 863 }); 865 864 866 865 export const constructNixOSImageURL = ( 867 - image: string 866 + image: string, 868 867 ): Effect.Effect<string, InvalidImageNameError, never> => { 869 868 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 870 869 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 872 871 if (match) { 873 872 const version = match[3] || NIXOS_DEFAULT_VERSION; 874 873 return Effect.succeed( 875 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 874 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 876 875 ); 877 876 } 878 877 ··· 880 879 new InvalidImageNameError({ 881 880 image, 882 881 cause: "Image name does not match NixOS naming conventions.", 883 - }) 882 + }), 884 883 ); 885 884 }; 886 885 887 886 export const constructFedoraImageURL = ( 888 887 image: string, 889 - cloud: boolean = false 888 + cloud: boolean = false, 890 889 ): Effect.Effect<string, InvalidImageNameError, never> => { 891 890 // detect with regex if image matches Fedora pattern: fedora 892 891 const fedoraRegex = /^(fedora)$/; ··· 899 898 new InvalidImageNameError({ 900 899 image, 901 900 cause: "Image name does not match Fedora naming conventions.", 902 - }) 901 + }), 903 902 ); 904 903 }; 905 904 906 905 export const constructGentooImageURL = ( 907 - image: string 906 + image: string, 908 907 ): Effect.Effect<string, InvalidImageNameError, never> => { 909 908 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 910 909 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; ··· 913 912 return Effect.succeed( 914 913 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 915 914 "20251116T233105Z", 916 - match[3] 917 - ) 915 + match[3], 916 + ), 918 917 ); 919 918 } 920 919 ··· 926 925 new InvalidImageNameError({ 927 926 image, 928 927 cause: "Image name does not match Gentoo naming conventions.", 929 - }) 928 + }), 930 929 ); 931 930 }; 932 931 933 932 export const constructDebianImageURL = ( 934 933 image: string, 935 - cloud: boolean = false 934 + cloud: boolean = false, 936 935 ): Effect.Effect<string, InvalidImageNameError, never> => { 937 936 if (cloud && image === "debian") { 938 937 return Effect.succeed(DEBIAN_CLOUD_IMG_URL); ··· 943 942 const match = image.match(debianRegex); 944 943 if (match?.[3]) { 945 944 return Effect.succeed( 946 - DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]) 945 + DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]), 947 946 ); 948 947 } 949 948 ··· 955 954 new InvalidImageNameError({ 956 955 image, 957 956 cause: "Image name does not match Debian naming conventions.", 958 - }) 957 + }), 959 958 ); 960 959 }; 961 960 962 961 export const constructAlpineImageURL = ( 963 - image: string 962 + image: string, 964 963 ): Effect.Effect<string, InvalidImageNameError, never> => { 965 964 // detect with regex if image matches alpine pattern: alpine-<version> or alpine 966 965 const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; 967 966 const match = image.match(alpineRegex); 968 967 if (match?.[3]) { 969 968 return Effect.succeed( 970 - ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]) 969 + ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]), 971 970 ); 972 971 } 973 972 ··· 979 978 new InvalidImageNameError({ 980 979 image, 981 980 cause: "Image name does not match Alpine naming conventions.", 982 - }) 981 + }), 983 982 ); 984 983 }; 985 984 986 985 export const constructUbuntuImageURL = ( 987 986 image: string, 988 - cloud: boolean = false 987 + cloud: boolean = false, 989 988 ): Effect.Effect<string, InvalidImageNameError, never> => { 990 989 // detect with regex if image matches ubuntu pattern: ubuntu 991 990 const ubuntuRegex = /^(ubuntu)$/; ··· 1001 1000 new InvalidImageNameError({ 1002 1001 image, 1003 1002 cause: "Image name does not match Ubuntu naming conventions.", 1004 - }) 1003 + }), 1005 1004 ); 1006 1005 }; 1007 1006 1008 1007 export const constructAlmaLinuxImageURL = ( 1009 1008 image: string, 1010 - cloud: boolean = false 1009 + cloud: boolean = false, 1011 1010 ): Effect.Effect<string, InvalidImageNameError, never> => { 1012 1011 // detect with regex if image matches almalinux pattern: almalinux, almalinux 1013 1012 const almaLinuxRegex = /^(almalinux|alma)$/; ··· 1023 1022 new InvalidImageNameError({ 1024 1023 image, 1025 1024 cause: "Image name does not match AlmaLinux naming conventions.", 1026 - }) 1025 + }), 1027 1026 ); 1028 1027 }; 1029 1028 1030 1029 export const constructRockyLinuxImageURL = ( 1031 1030 image: string, 1032 - cloud: boolean = false 1031 + cloud: boolean = false, 1033 1032 ): Effect.Effect<string, InvalidImageNameError, never> => { 1034 1033 // detect with regex if image matches rockylinux pattern: rocky. rockylinux 1035 1034 const rockyLinuxRegex = /^(rockylinux|rocky)$/; ··· 1045 1044 new InvalidImageNameError({ 1046 1045 image, 1047 1046 cause: "Image name does not match RockyLinux naming conventions.", 1048 - }) 1047 + }), 1049 1048 ); 1050 1049 };