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

run format

+94 -91
+9 -7
src/constants.ts
··· 6 6 export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; 7 7 export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`; 8 8 9 - export const UBUNTU_ISO_URL: string = 10 - Deno.build.arch === "aarch64" 11 - ? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso" 12 - : "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-live-server-amd64.iso"; 9 + export const UBUNTU_ISO_URL: string = Deno.build.arch === "aarch64" 10 + ? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso" 11 + : "https://releases.ubuntu.com/24.04.3/ubuntu-24.04.3-live-server-amd64.iso"; 13 12 14 13 export const FEDORA_COREOS_DEFAULT_VERSION: string = "43.20251024.3.0"; 15 - export const FEDORA_COREOS_IMG_URL: string = `https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/${FEDORA_COREOS_DEFAULT_VERSION}/${Deno.build.arch}/fedora-coreos-${FEDORA_COREOS_DEFAULT_VERSION}-qemu.${Deno.build.arch}.qcow2.xz`; 14 + export const FEDORA_COREOS_IMG_URL: string = 15 + `https://builds.coreos.fedoraproject.org/prod/streams/stable/builds/${FEDORA_COREOS_DEFAULT_VERSION}/${Deno.build.arch}/fedora-coreos-${FEDORA_COREOS_DEFAULT_VERSION}-qemu.${Deno.build.arch}.qcow2.xz`; 16 16 17 17 export const NIXOS_DEFAULT_VERSION: string = "25.05"; 18 - export const NIXOS_ISO_URL: string = `https://channels.nixos.org/nixos-${NIXOS_DEFAULT_VERSION}/latest-nixos-minimal-${Deno.build.arch}-linux.iso`; 18 + export const NIXOS_ISO_URL: string = 19 + `https://channels.nixos.org/nixos-${NIXOS_DEFAULT_VERSION}/latest-nixos-minimal-${Deno.build.arch}-linux.iso`; 19 20 20 - export const FEDORA_IMG_URL: string = `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`; 21 + export const FEDORA_IMG_URL: string = 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`;
+46 -44
src/subcommands/start.ts
··· 17 17 }> {} 18 18 19 19 export class VmAlreadyRunningError extends Data.TaggedError( 20 - "VmAlreadyRunningError" 20 + "VmAlreadyRunningError", 21 21 )<{ 22 22 name: string; 23 23 }> {} ··· 31 31 getInstanceState(name), 32 32 Effect.flatMap((vm) => 33 33 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name })) 34 - ) 34 + ), 35 35 ); 36 36 37 37 const logStarting = (vm: VirtualMachine) => ··· 44 44 export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 45 45 46 46 export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 47 - const qemu = 48 - Deno.build.arch === "aarch64" 49 - ? "qemu-system-aarch64" 50 - : "qemu-system-x86_64"; 47 + const qemu = Deno.build.arch === "aarch64" 48 + ? "qemu-system-aarch64" 49 + : "qemu-system-x86_64"; 51 50 52 51 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 53 52 ··· 84 83 vm.drivePath && [ 85 84 "-drive", 86 85 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 87 - ] 86 + ], 88 87 ), 89 88 ...coreosArgs, 90 89 ...(vm.volume ? [] : ["-snapshot"]), ··· 100 99 export const startDetachedQemu = ( 101 100 name: string, 102 101 vm: VirtualMachine, 103 - qemuArgs: string[] 102 + qemuArgs: string[], 104 103 ) => { 105 - const qemu = 106 - Deno.build.arch === "aarch64" 107 - ? "qemu-system-aarch64" 108 - : "qemu-system-x86_64"; 104 + const qemu = Deno.build.arch === "aarch64" 105 + ? "qemu-system-aarch64" 106 + : "qemu-system-x86_64"; 109 107 110 108 const logPath = `${LOGS_DIR}/${vm.name}.log`; 111 109 112 110 const fullCommand = vm.bridge 113 - ? `sudo ${qemu} ${qemuArgs 111 + ? `sudo ${qemu} ${ 112 + qemuArgs 114 113 .slice(1) 115 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 114 + .join(" ") 115 + } >> "${logPath}" 2>&1 & echo $!` 116 116 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 117 117 118 118 return Effect.tryPromise({ ··· 143 143 Effect.flatMap(({ qemuPid, logPath }) => 144 144 pipe( 145 145 updateInstanceState(name, "RUNNING", qemuPid), 146 - Effect.map(() => ({ vm, qemuPid, logPath })) 146 + Effect.map(() => ({ vm, qemuPid, logPath })), 147 147 ) 148 - ) 148 + ), 149 149 ); 150 150 }; 151 151 ··· 160 160 }) => 161 161 Effect.sync(() => { 162 162 console.log( 163 - `Virtual machine ${vm.name} started in background (PID: ${qemuPid})` 163 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 164 164 ); 165 165 console.log(`Logs will be written to: ${logPath}`); 166 166 }); ··· 168 168 const startInteractiveQemu = ( 169 169 name: string, 170 170 vm: VirtualMachine, 171 - qemuArgs: string[] 171 + qemuArgs: string[], 172 172 ) => { 173 - const qemu = 174 - Deno.build.arch === "aarch64" 175 - ? "qemu-system-aarch64" 176 - : "qemu-system-x86_64"; 173 + const qemu = Deno.build.arch === "aarch64" 174 + ? "qemu-system-aarch64" 175 + : "qemu-system-x86_64"; 177 176 178 177 return Effect.tryPromise({ 179 178 try: async () => { ··· 209 208 }); 210 209 211 210 export const createVolumeIfNeeded = ( 212 - vm: VirtualMachine 211 + vm: VirtualMachine, 213 212 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 214 213 Effect.gen(function* () { 215 214 const { flags } = parseFlags(Deno.args); ··· 224 223 225 224 if (!vm.drivePath) { 226 225 throw new Error( 227 - `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.` 226 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 228 227 ); 229 228 } 230 229 ··· 267 266 diskFormat: volume ? "qcow2" : vm.diskFormat, 268 267 volume: volume?.path, 269 268 }, 270 - firmwareArgs 269 + firmwareArgs, 271 270 ) 272 271 ), 273 272 Effect.flatMap((qemuArgs) => ··· 277 276 startDetachedQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 278 277 ), 279 278 Effect.tap(logDetachedSuccess), 280 - Effect.map(() => 0) // Exit code 0 279 + Effect.map(() => 0), // Exit code 0 281 280 ) 282 - ) 281 + ), 283 282 ) 284 283 ), 285 - Effect.catchAll(handleError) 284 + Effect.catchAll(handleError), 286 285 ); 287 286 288 287 const startInteractiveEffect = (name: string) => ··· 303 302 diskFormat: volume ? "qcow2" : vm.diskFormat, 304 303 volume: volume?.path, 305 304 }, 306 - firmwareArgs 305 + firmwareArgs, 307 306 ) 308 307 ), 309 308 Effect.flatMap((qemuArgs) => 310 309 startInteractiveQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 311 310 ), 312 - Effect.map((status) => (status.success ? 0 : status.code || 1)) 311 + Effect.map((status) => (status.success ? 0 : status.code || 1)), 313 312 ) 314 313 ), 315 - Effect.catchAll(handleError) 314 + Effect.catchAll(handleError), 316 315 ); 317 316 318 317 export default async function (name: string, detach: boolean = false) { 319 318 const exitCode = await Effect.runPromise( 320 - detach ? startDetachedEffect(name) : startInteractiveEffect(name) 319 + detach ? startDetachedEffect(name) : startInteractiveEffect(name), 321 320 ); 322 321 323 322 if (detach) { ··· 331 330 const { flags } = parseFlags(Deno.args); 332 331 return { 333 332 ...vm, 334 - memory: 335 - flags.memory || flags.m ? String(flags.memory || flags.m) : vm.memory, 333 + memory: flags.memory || flags.m 334 + ? String(flags.memory || flags.m) 335 + : vm.memory, 336 336 cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus, 337 337 cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu, 338 338 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 339 - portForward: 340 - flags.portForward || flags.p 341 - ? String(flags.portForward || flags.p) 342 - : vm.portForward, 343 - drivePath: 344 - flags.image || flags.i ? String(flags.image || flags.i) : vm.drivePath, 345 - bridge: 346 - flags.bridge || flags.b ? String(flags.bridge || flags.b) : vm.bridge, 347 - diskSize: 348 - flags.size || flags.s ? String(flags.size || flags.s) : vm.diskSize, 339 + portForward: flags.portForward || flags.p 340 + ? String(flags.portForward || flags.p) 341 + : vm.portForward, 342 + drivePath: flags.image || flags.i 343 + ? String(flags.image || flags.i) 344 + : vm.drivePath, 345 + bridge: flags.bridge || flags.b 346 + ? String(flags.bridge || flags.b) 347 + : vm.bridge, 348 + diskSize: flags.size || flags.s 349 + ? String(flags.size || flags.s) 350 + : vm.diskSize, 349 351 }; 350 352 }
+39 -40
src/utils.ts
··· 65 65 export const isValidISOurl = (url?: string): boolean => { 66 66 return Boolean( 67 67 (url?.startsWith("http://") || url?.startsWith("https://")) && 68 - url?.endsWith(".iso") 68 + url?.endsWith(".iso"), 69 69 ); 70 70 }; 71 71 ··· 91 91 }); 92 92 93 93 export const validateImage = ( 94 - image: string 94 + image: string, 95 95 ): Effect.Effect<string, InvalidImageNameError, never> => { 96 96 const regex = 97 97 /^(?:[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 102 image, 103 103 cause: 104 104 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 105 - }) 105 + }), 106 106 ); 107 107 } 108 108 return Effect.succeed(image); ··· 111 111 export const extractTag = (name: string) => 112 112 pipe( 113 113 validateImage(name), 114 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 114 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 115 115 ); 116 116 117 117 export const failOnMissingImage = ( 118 - image: Image | undefined 118 + image: Image | undefined, 119 119 ): Effect.Effect<Image, Error, never> => 120 120 image 121 121 ? Effect.succeed(image) 122 122 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 123 123 124 124 export const du = ( 125 - path: string 125 + path: string, 126 126 ): Effect.Effect<number, LogCommandError, never> => 127 127 Effect.tryPromise({ 128 128 try: async () => { ··· 154 154 exists 155 155 ? Effect.succeed(true) 156 156 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 157 - ) 157 + ), 158 158 ); 159 159 160 160 export const downloadIso = (url: string, options: Options) => ··· 176 176 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 177 177 console.log( 178 178 chalk.yellowBright( 179 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 180 - ) 179 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 180 + ), 181 181 ); 182 182 return null; 183 183 } ··· 195 195 if (outputExists) { 196 196 console.log( 197 197 chalk.yellowBright( 198 - `File ${outputPath} already exists, skipping download.` 199 - ) 198 + `File ${outputPath} already exists, skipping download.`, 199 + ), 200 200 ); 201 201 return outputPath; 202 202 } ··· 245 245 if (!success) { 246 246 console.error( 247 247 chalk.redBright( 248 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 249 - ) 248 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 249 + ), 250 250 ); 251 251 Deno.exit(1); 252 252 } ··· 259 259 try: () => 260 260 Deno.copyFile( 261 261 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 262 - edk2VarsAarch64 262 + edk2VarsAarch64, 263 263 ), 264 264 catch: (error) => new LogCommandError({ cause: error }), 265 265 }); ··· 304 304 const configOK = yield* pipe( 305 305 fileExists("config.ign"), 306 306 Effect.flatMap(() => Effect.succeed(true)), 307 - Effect.catchAll(() => Effect.succeed(false)) 307 + Effect.catchAll(() => Effect.succeed(false)), 308 308 ); 309 309 if (!configOK) { 310 310 console.error( 311 311 chalk.redBright( 312 - "CoreOS image requires a config.ign file in the current directory." 313 - ) 312 + "CoreOS image requires a config.ign file in the current directory.", 313 + ), 314 314 ); 315 315 Deno.exit(1); 316 316 } ··· 343 343 Effect.gen(function* () { 344 344 const macAddress = yield* generateRandomMacAddress(); 345 345 346 - const qemu = 347 - Deno.build.arch === "aarch64" 348 - ? "qemu-system-aarch64" 349 - : "qemu-system-x86_64"; 346 + const qemu = Deno.build.arch === "aarch64" 347 + ? "qemu-system-aarch64" 348 + : "qemu-system-x86_64"; 350 349 351 350 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 352 351 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); ··· 392 391 options.image && [ 393 392 "-drive", 394 393 `file=${options.image},format=${options.diskFormat},if=virtio`, 395 - ] 394 + ], 396 395 ), 397 396 ]; 398 397 ··· 407 406 const logPath = `${LOGS_DIR}/${name}.log`; 408 407 409 408 const fullCommand = options.bridge 410 - ? `sudo ${qemu} ${qemuArgs 409 + ? `sudo ${qemu} ${ 410 + qemuArgs 411 411 .slice(1) 412 - .join(" ")} >> "${logPath}" 2>&1 & echo $!` 412 + .join(" ") 413 + } >> "${logPath}" 2>&1 & echo $!` 413 414 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 414 415 415 416 const { stdout } = yield* Effect.tryPromise({ ··· 435 436 cpus: options.cpus, 436 437 cpu: options.cpu, 437 438 diskSize: options.size || "20G", 438 - diskFormat: 439 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 439 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 440 440 options.diskFormat || 441 441 "raw", 442 442 portForward: options.portForward, ··· 455 455 }); 456 456 457 457 console.log( 458 - `Virtual machine ${name} started in background (PID: ${qemuPid})` 458 + `Virtual machine ${name} started in background (PID: ${qemuPid})`, 459 459 ); 460 460 console.log(`Logs will be written to: ${logPath}`); 461 461 ··· 478 478 cpus: options.cpus, 479 479 cpu: options.cpu, 480 480 diskSize: options.size || "20G", 481 - diskFormat: 482 - (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 481 + diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 483 482 options.diskFormat || 484 483 "raw", 485 484 portForward: options.portForward, ··· 587 586 if (pathExists) { 588 587 console.log( 589 588 chalk.yellowBright( 590 - `Drive image ${path} already exists, skipping creation.` 591 - ) 589 + `Drive image ${path} already exists, skipping creation.`, 590 + ), 592 591 ); 593 592 return; 594 593 } ··· 615 614 }); 616 615 617 616 export const fileExists = ( 618 - path: string 617 + path: string, 619 618 ): Effect.Effect<void, NoSuchFileError, never> => 620 619 Effect.try({ 621 620 try: () => Deno.statSync(path), ··· 623 622 }); 624 623 625 624 export const constructCoreOSImageURL = ( 626 - image: string 625 + image: string, 627 626 ): Effect.Effect<string, InvalidImageNameError, never> => { 628 627 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 629 628 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 631 630 if (match) { 632 631 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 633 632 return Effect.succeed( 634 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 633 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 635 634 ); 636 635 } 637 636 ··· 639 638 new InvalidImageNameError({ 640 639 image, 641 640 cause: "Image name does not match CoreOS naming conventions.", 642 - }) 641 + }), 643 642 ); 644 643 }; 645 644 ··· 668 667 }); 669 668 670 669 export const constructNixOSImageURL = ( 671 - image: string 670 + image: string, 672 671 ): Effect.Effect<string, InvalidImageNameError, never> => { 673 672 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 674 673 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 676 675 if (match) { 677 676 const version = match[3] || NIXOS_DEFAULT_VERSION; 678 677 return Effect.succeed( 679 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 678 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 680 679 ); 681 680 } 682 681 ··· 684 683 new InvalidImageNameError({ 685 684 image, 686 685 cause: "Image name does not match NixOS naming conventions.", 687 - }) 686 + }), 688 687 ); 689 688 }; 690 689 691 690 export const constructFedoraImageURL = ( 692 - image: string 691 + image: string, 693 692 ): Effect.Effect<string, InvalidImageNameError, never> => { 694 693 // detect with regex if image matches Fedora pattern: fedora 695 694 const fedoraRegex = /^(fedora)$/; ··· 702 701 new InvalidImageNameError({ 703 702 image, 704 703 cause: "Image name does not match Fedora naming conventions.", 705 - }) 704 + }), 706 705 ); 707 706 };