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

run format

+70 -71
+70 -71
src/utils.ts
··· 78 78 export const isValidISOurl = (url?: string): boolean => { 79 79 return Boolean( 80 80 (url?.startsWith("http://") || url?.startsWith("https://")) && 81 - url?.endsWith(".iso") 81 + url?.endsWith(".iso"), 82 82 ); 83 83 }; 84 84 ··· 104 104 }); 105 105 106 106 export const validateImage = ( 107 - image: string 107 + image: string, 108 108 ): Effect.Effect<string, InvalidImageNameError, never> => { 109 109 const regex = 110 110 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; ··· 115 115 image, 116 116 cause: 117 117 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 118 - }) 118 + }), 119 119 ); 120 120 } 121 121 return Effect.succeed(image); ··· 124 124 export const extractTag = (name: string) => 125 125 pipe( 126 126 validateImage(name), 127 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 127 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 128 128 ); 129 129 130 130 export const failOnMissingImage = ( 131 - image: Image | undefined 131 + image: Image | undefined, 132 132 ): Effect.Effect<Image, Error, never> => 133 133 image 134 134 ? Effect.succeed(image) 135 135 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 136 136 137 137 export const du = ( 138 - path: string 138 + path: string, 139 139 ): Effect.Effect<number, LogCommandError, never> => 140 140 Effect.tryPromise({ 141 141 try: async () => { ··· 167 167 exists 168 168 ? Effect.succeed(true) 169 169 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 170 - ) 170 + ), 171 171 ); 172 172 173 173 export const downloadIso = (url: string, options: Options) => ··· 189 189 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 190 190 console.log( 191 191 chalk.yellowBright( 192 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 193 - ) 192 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 193 + ), 194 194 ); 195 195 return null; 196 196 } ··· 208 208 if (outputExists) { 209 209 console.log( 210 210 chalk.yellowBright( 211 - `File ${outputPath} already exists, skipping download.` 212 - ) 211 + `File ${outputPath} already exists, skipping download.`, 212 + ), 213 213 ); 214 214 return outputPath; 215 215 } ··· 220 220 chalk.blueBright( 221 221 `Downloading ${ 222 222 url.endsWith(".iso") ? "ISO" : "image" 223 - } from ${url}...` 224 - ) 223 + } from ${url}...`, 224 + ), 225 225 ); 226 226 const cmd = new Deno.Command("curl", { 227 227 args: ["-L", "-o", outputPath, url], ··· 264 264 if (!success) { 265 265 console.error( 266 266 chalk.redBright( 267 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 268 - ) 267 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 268 + ), 269 269 ); 270 270 Deno.exit(1); 271 271 } ··· 278 278 try: () => 279 279 Deno.copyFile( 280 280 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 281 - edk2VarsAarch64 281 + edk2VarsAarch64, 282 282 ), 283 283 catch: (error) => new LogCommandError({ cause: error }), 284 284 }); ··· 323 323 const configOK = yield* pipe( 324 324 fileExists("config.ign"), 325 325 Effect.flatMap(() => Effect.succeed(true)), 326 - Effect.catchAll(() => Effect.succeed(false)) 326 + Effect.catchAll(() => Effect.succeed(false)), 327 327 ); 328 328 if (!configOK) { 329 329 console.error( 330 330 chalk.redBright( 331 - "CoreOS image requires a config.ign file in the current directory." 332 - ) 331 + "CoreOS image requires a config.ign file in the current directory.", 332 + ), 333 333 ); 334 334 Deno.exit(1); 335 335 } ··· 369 369 imagePath && 370 370 imagePath.endsWith(".qcow2") && 371 371 imagePath.startsWith( 372 - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` 372 + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, 373 373 ) 374 374 ) { 375 375 return [ ··· 384 384 385 385 export const setupAlpineArgs = ( 386 386 imagePath?: string | null, 387 - seed: string = "seed.iso" 387 + seed: string = "seed.iso", 388 388 ) => 389 389 Effect.sync(() => { 390 390 if ( ··· 405 405 406 406 export const setupDebianArgs = ( 407 407 imagePath?: string | null, 408 - seed: string = "seed.iso" 408 + seed: string = "seed.iso", 409 409 ) => 410 410 Effect.sync(() => { 411 411 if ( ··· 426 426 427 427 export const setupUbuntuArgs = ( 428 428 imagePath?: string | null, 429 - seed: string = "seed.iso" 429 + seed: string = "seed.iso", 430 430 ) => 431 431 Effect.sync(() => { 432 432 if ( ··· 447 447 448 448 export const setupAlmaLinuxArgs = ( 449 449 imagePath?: string | null, 450 - seed: string = "seed.iso" 450 + seed: string = "seed.iso", 451 451 ) => 452 452 Effect.sync(() => { 453 453 if ( ··· 468 468 469 469 export const setupRockyLinuxArgs = ( 470 470 imagePath?: string | null, 471 - seed: string = "seed.iso" 471 + seed: string = "seed.iso", 472 472 ) => 473 473 Effect.sync(() => { 474 474 if ( ··· 491 491 Effect.gen(function* () { 492 492 const macAddress = yield* generateRandomMacAddress(); 493 493 494 - const qemu = 495 - Deno.build.arch === "aarch64" 496 - ? "qemu-system-aarch64" 497 - : "qemu-system-x86_64"; 494 + const qemu = Deno.build.arch === "aarch64" 495 + ? "qemu-system-aarch64" 496 + : "qemu-system-x86_64"; 498 497 499 498 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 500 499 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); 501 500 let fedoraArgs: string[] = yield* setupFedoraArgs( 502 501 isoPath || options.image, 503 - options.seed 502 + options.seed, 504 503 ); 505 504 let gentooArgs: string[] = yield* setupGentooArgs( 506 505 isoPath || options.image, 507 - options.seed 506 + options.seed, 508 507 ); 509 508 let alpineArgs: string[] = yield* setupAlpineArgs( 510 509 isoPath || options.image, 511 - options.seed 510 + options.seed, 512 511 ); 513 512 let debianArgs: string[] = yield* setupDebianArgs( 514 513 isoPath || options.image, 515 - options.seed 514 + options.seed, 516 515 ); 517 516 let ubuntuArgs: string[] = yield* setupUbuntuArgs( 518 517 isoPath || options.image, 519 - options.seed 518 + options.seed, 520 519 ); 521 520 let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( 522 521 isoPath || options.image, 523 - options.seed 522 + options.seed, 524 523 ); 525 524 let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( 526 525 isoPath || options.image, 527 - options.seed 526 + options.seed, 528 527 ); 529 528 530 529 if (coreosArgs.length > 0 && !isoPath) { ··· 597 596 options.image && [ 598 597 "-drive", 599 598 `file=${options.image},format=${options.diskFormat},if=virtio`, 600 - ] 599 + ], 601 600 ), 602 601 ]; 603 602 ··· 612 611 const logPath = `${LOGS_DIR}/${name}.log`; 613 612 614 613 const fullCommand = options.bridge 615 - ? `sudo ${qemu} ${qemuArgs 614 + ? `sudo ${qemu} ${ 615 + qemuArgs 616 616 .slice(1) 617 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 617 + .join(" ") 618 + } >> "${logPath}" 2>&1 & echo $!` 618 619 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 619 620 620 621 const { stdout } = yield* Effect.tryPromise({ ··· 640 641 cpus: options.cpus, 641 642 cpu: options.cpu, 642 643 diskSize: options.size || "20G", 643 - diskFormat: 644 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 644 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 645 645 options.diskFormat || 646 646 "raw", 647 647 portForward: options.portForward, ··· 661 661 }); 662 662 663 663 console.log( 664 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 664 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 665 665 ); 666 666 console.log(`Logs will be written to: ${logPath}`); 667 667 ··· 684 684 cpus: options.cpus, 685 685 cpu: options.cpu, 686 686 diskSize: options.size || "20G", 687 - diskFormat: 688 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 687 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 689 688 options.diskFormat || 690 689 "raw", 691 690 portForward: options.portForward, ··· 794 793 if (pathExists) { 795 794 console.log( 796 795 chalk.yellowBright( 797 - `Drive image ${path} already exists, skipping creation.` 798 - ) 796 + `Drive image ${path} already exists, skipping creation.`, 797 + ), 799 798 ); 800 799 return; 801 800 } ··· 822 821 }); 823 822 824 823 export const fileExists = ( 825 - path: string 824 + path: string, 826 825 ): Effect.Effect<void, NoSuchFileError, never> => 827 826 Effect.try({ 828 827 try: () => Deno.statSync(path), ··· 830 829 }); 831 830 832 831 export const constructCoreOSImageURL = ( 833 - image: string 832 + image: string, 834 833 ): Effect.Effect<string, InvalidImageNameError, never> => { 835 834 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 836 835 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 838 837 if (match) { 839 838 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 840 839 return Effect.succeed( 841 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 840 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 842 841 ); 843 842 } 844 843 ··· 846 845 new InvalidImageNameError({ 847 846 image, 848 847 cause: "Image name does not match CoreOS naming conventions.", 849 - }) 848 + }), 850 849 ); 851 850 }; 852 851 ··· 875 874 }); 876 875 877 876 export const constructNixOSImageURL = ( 878 - image: string 877 + image: string, 879 878 ): Effect.Effect<string, InvalidImageNameError, never> => { 880 879 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 881 880 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 883 882 if (match) { 884 883 const version = match[3] || NIXOS_DEFAULT_VERSION; 885 884 return Effect.succeed( 886 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 885 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 887 886 ); 888 887 } 889 888 ··· 891 890 new InvalidImageNameError({ 892 891 image, 893 892 cause: "Image name does not match NixOS naming conventions.", 894 - }) 893 + }), 895 894 ); 896 895 }; 897 896 898 897 export const constructFedoraImageURL = ( 899 898 image: string, 900 - cloud: boolean = false 899 + cloud: boolean = false, 901 900 ): Effect.Effect<string, InvalidImageNameError, never> => { 902 901 // detect with regex if image matches Fedora pattern: fedora 903 902 const fedoraRegex = /^(fedora)$/; ··· 910 909 new InvalidImageNameError({ 911 910 image, 912 911 cause: "Image name does not match Fedora naming conventions.", 913 - }) 912 + }), 914 913 ); 915 914 }; 916 915 917 916 export const constructGentooImageURL = ( 918 - image: string 917 + image: string, 919 918 ): Effect.Effect<string, InvalidImageNameError, never> => { 920 919 // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo 921 920 const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; ··· 924 923 return Effect.succeed( 925 924 GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( 926 925 "20251116T233105Z", 927 - match[3] 928 - ) 926 + match[3], 927 + ), 929 928 ); 930 929 } 931 930 ··· 937 936 new InvalidImageNameError({ 938 937 image, 939 938 cause: "Image name does not match Gentoo naming conventions.", 940 - }) 939 + }), 941 940 ); 942 941 }; 943 942 944 943 export const constructDebianImageURL = ( 945 944 image: string, 946 - cloud: boolean = false 945 + cloud: boolean = false, 947 946 ): Effect.Effect<string, InvalidImageNameError, never> => { 948 947 if (cloud && image === "debian") { 949 948 return Effect.succeed(DEBIAN_CLOUD_IMG_URL); ··· 954 953 const match = image.match(debianRegex); 955 954 if (match?.[3]) { 956 955 return Effect.succeed( 957 - DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]) 956 + DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]), 958 957 ); 959 958 } 960 959 ··· 966 965 new InvalidImageNameError({ 967 966 image, 968 967 cause: "Image name does not match Debian naming conventions.", 969 - }) 968 + }), 970 969 ); 971 970 }; 972 971 973 972 export const constructAlpineImageURL = ( 974 - image: string 973 + image: string, 975 974 ): Effect.Effect<string, InvalidImageNameError, never> => { 976 975 // detect with regex if image matches alpine pattern: alpine-<version> or alpine 977 976 const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; 978 977 const match = image.match(alpineRegex); 979 978 if (match?.[3]) { 980 979 return Effect.succeed( 981 - ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]) 980 + ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]), 982 981 ); 983 982 } 984 983 ··· 990 989 new InvalidImageNameError({ 991 990 image, 992 991 cause: "Image name does not match Alpine naming conventions.", 993 - }) 992 + }), 994 993 ); 995 994 }; 996 995 997 996 export const constructUbuntuImageURL = ( 998 997 image: string, 999 - cloud: boolean = false 998 + cloud: boolean = false, 1000 999 ): Effect.Effect<string, InvalidImageNameError, never> => { 1001 1000 // detect with regex if image matches ubuntu pattern: ubuntu 1002 1001 const ubuntuRegex = /^(ubuntu)$/; ··· 1012 1011 new InvalidImageNameError({ 1013 1012 image, 1014 1013 cause: "Image name does not match Ubuntu naming conventions.", 1015 - }) 1014 + }), 1016 1015 ); 1017 1016 }; 1018 1017 1019 1018 export const constructAlmaLinuxImageURL = ( 1020 1019 image: string, 1021 - cloud: boolean = false 1020 + cloud: boolean = false, 1022 1021 ): Effect.Effect<string, InvalidImageNameError, never> => { 1023 1022 // detect with regex if image matches almalinux pattern: ubuntu 1024 1023 const almaLinuxRegex = /^(almalinux)$/; ··· 1034 1033 new InvalidImageNameError({ 1035 1034 image, 1036 1035 cause: "Image name does not match AlmaLinux naming conventions.", 1037 - }) 1036 + }), 1038 1037 ); 1039 1038 }; 1040 1039 1041 1040 export const constructRockyLinuxImageURL = ( 1042 1041 image: string, 1043 - cloud: boolean = false 1042 + cloud: boolean = false, 1044 1043 ): Effect.Effect<string, InvalidImageNameError, never> => { 1045 1044 // detect with regex if image matches rockylinux pattern: ubuntu 1046 1045 const rockyLinuxRegex = /^(rockylinux)$/; ··· 1056 1055 new InvalidImageNameError({ 1057 1056 image, 1058 1057 cause: "Image name does not match RockyLinux naming conventions.", 1059 - }) 1058 + }), 1060 1059 ); 1061 1060 };