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

feat: add support for Fedora image handling and caching in download process

+159 -91
+31 -1
main.ts
··· 31 31 import tag from "./src/subcommands/tag.ts"; 32 32 import * as volumes from "./src/subcommands/volume.ts"; 33 33 import { 34 + constructFedoraImageURL, 34 35 constructNixOSImageURL, 35 36 createDriveImageIfNeeded, 36 37 downloadIso, ··· 195 196 ); 196 197 197 198 if (nixOSIsoURL) { 198 - isoPath = yield* downloadIso(nixOSIsoURL, options); 199 + const cached = yield* pipe( 200 + basename(nixOSIsoURL), 201 + fileExists, 202 + Effect.flatMap(() => Effect.succeed(true)), 203 + Effect.catchAll(() => Effect.succeed(false)), 204 + ); 205 + if (!cached) { 206 + isoPath = yield* downloadIso(nixOSIsoURL, options); 207 + } else { 208 + isoPath = basename(nixOSIsoURL); 209 + } 210 + } 211 + 212 + const fedoraImageURL = yield* pipe( 213 + constructFedoraImageURL(input), 214 + Effect.catchAll(() => Effect.succeed(null)), 215 + ); 216 + 217 + if (fedoraImageURL) { 218 + const cached = yield* pipe( 219 + basename(fedoraImageURL), 220 + fileExists, 221 + Effect.flatMap(() => Effect.succeed(true)), 222 + Effect.catchAll(() => Effect.succeed(false)), 223 + ); 224 + if (!cached) { 225 + isoPath = yield* downloadIso(fedoraImageURL, options); 226 + } else { 227 + isoPath = basename(fedoraImageURL); 228 + } 199 229 } 200 230 } 201 231
+8 -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 = 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"; 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"; 12 13 13 14 export const FEDORA_COREOS_DEFAULT_VERSION: string = "43.20251024.3.0"; 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`; 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`; 16 16 17 17 export const NIXOS_DEFAULT_VERSION: string = "25.05"; 18 - export const NIXOS_ISO_URL: string = 19 - `https://channels.nixos.org/nixos-${NIXOS_DEFAULT_VERSION}/latest-nixos-minimal-${Deno.build.arch}-linux.iso`; 18 + export const NIXOS_ISO_URL: string = `https://channels.nixos.org/nixos-${NIXOS_DEFAULT_VERSION}/latest-nixos-minimal-${Deno.build.arch}-linux.iso`; 19 + 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`;
+44 -46
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 = Deno.build.arch === "aarch64" 48 - ? "qemu-system-aarch64" 49 - : "qemu-system-x86_64"; 47 + const qemu = 48 + Deno.build.arch === "aarch64" 49 + ? "qemu-system-aarch64" 50 + : "qemu-system-x86_64"; 50 51 51 52 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 52 53 ··· 83 84 vm.drivePath && [ 84 85 "-drive", 85 86 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 86 - ], 87 + ] 87 88 ), 88 89 ...coreosArgs, 89 90 ...(vm.volume ? [] : ["-snapshot"]), ··· 99 100 export const startDetachedQemu = ( 100 101 name: string, 101 102 vm: VirtualMachine, 102 - qemuArgs: string[], 103 + qemuArgs: string[] 103 104 ) => { 104 - const qemu = Deno.build.arch === "aarch64" 105 - ? "qemu-system-aarch64" 106 - : "qemu-system-x86_64"; 105 + const qemu = 106 + Deno.build.arch === "aarch64" 107 + ? "qemu-system-aarch64" 108 + : "qemu-system-x86_64"; 107 109 108 110 const logPath = `${LOGS_DIR}/${vm.name}.log`; 109 111 110 112 const fullCommand = vm.bridge 111 - ? `sudo ${qemu} ${ 112 - qemuArgs 113 + ? `sudo ${qemu} ${qemuArgs 113 114 .slice(1) 114 - .join(" ") 115 - } >> "${logPath}" 2>&1 & echo $!` 115 + .join(" ")} >> "${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 = Deno.build.arch === "aarch64" 174 - ? "qemu-system-aarch64" 175 - : "qemu-system-x86_64"; 173 + const qemu = 174 + Deno.build.arch === "aarch64" 175 + ? "qemu-system-aarch64" 176 + : "qemu-system-x86_64"; 176 177 177 178 return Effect.tryPromise({ 178 179 try: async () => { ··· 208 209 }); 209 210 210 211 export const createVolumeIfNeeded = ( 211 - vm: VirtualMachine, 212 + vm: VirtualMachine 212 213 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 213 214 Effect.gen(function* () { 214 215 const { flags } = parseFlags(Deno.args); ··· 223 224 224 225 if (!vm.drivePath) { 225 226 throw new Error( 226 - `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 227 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.` 227 228 ); 228 229 } 229 230 ··· 266 267 diskFormat: volume ? "qcow2" : vm.diskFormat, 267 268 volume: volume?.path, 268 269 }, 269 - firmwareArgs, 270 + firmwareArgs 270 271 ) 271 272 ), 272 273 Effect.flatMap((qemuArgs) => ··· 276 277 startDetachedQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 277 278 ), 278 279 Effect.tap(logDetachedSuccess), 279 - Effect.map(() => 0), // Exit code 0 280 + Effect.map(() => 0) // Exit code 0 280 281 ) 281 - ), 282 + ) 282 283 ) 283 284 ), 284 - Effect.catchAll(handleError), 285 + Effect.catchAll(handleError) 285 286 ); 286 287 287 288 const startInteractiveEffect = (name: string) => ··· 302 303 diskFormat: volume ? "qcow2" : vm.diskFormat, 303 304 volume: volume?.path, 304 305 }, 305 - firmwareArgs, 306 + firmwareArgs 306 307 ) 307 308 ), 308 309 Effect.flatMap((qemuArgs) => 309 310 startInteractiveQemu(name, { ...vm, volume: volume?.path }, qemuArgs) 310 311 ), 311 - Effect.map((status) => (status.success ? 0 : status.code || 1)), 312 + Effect.map((status) => (status.success ? 0 : status.code || 1)) 312 313 ) 313 314 ), 314 - Effect.catchAll(handleError), 315 + Effect.catchAll(handleError) 315 316 ); 316 317 317 318 export default async function (name: string, detach: boolean = false) { 318 319 const exitCode = await Effect.runPromise( 319 - detach ? startDetachedEffect(name) : startInteractiveEffect(name), 320 + detach ? startDetachedEffect(name) : startInteractiveEffect(name) 320 321 ); 321 322 322 323 if (detach) { ··· 330 331 const { flags } = parseFlags(Deno.args); 331 332 return { 332 333 ...vm, 333 - memory: flags.memory || flags.m 334 - ? String(flags.memory || flags.m) 335 - : vm.memory, 334 + memory: 335 + flags.memory || flags.m ? String(flags.memory || flags.m) : 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: 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, 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, 351 349 }; 352 350 }
+76 -37
src/utils.ts
··· 8 8 EMPTY_DISK_THRESHOLD_KB, 9 9 FEDORA_COREOS_DEFAULT_VERSION, 10 10 FEDORA_COREOS_IMG_URL, 11 + FEDORA_IMG_URL, 11 12 LOGS_DIR, 12 13 NIXOS_DEFAULT_VERSION, 13 14 NIXOS_ISO_URL, ··· 64 65 export const isValidISOurl = (url?: string): boolean => { 65 66 return Boolean( 66 67 (url?.startsWith("http://") || url?.startsWith("https://")) && 67 - url?.endsWith(".iso"), 68 + url?.endsWith(".iso") 68 69 ); 69 70 }; 70 71 ··· 90 91 }); 91 92 92 93 export const validateImage = ( 93 - image: string, 94 + image: string 94 95 ): Effect.Effect<string, InvalidImageNameError, never> => { 95 96 const regex = 96 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._-]+)?$/; ··· 101 102 image, 102 103 cause: 103 104 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", 104 - }), 105 + }) 105 106 ); 106 107 } 107 108 return Effect.succeed(image); ··· 110 111 export const extractTag = (name: string) => 111 112 pipe( 112 113 validateImage(name), 113 - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), 114 + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) 114 115 ); 115 116 116 117 export const failOnMissingImage = ( 117 - image: Image | undefined, 118 + image: Image | undefined 118 119 ): Effect.Effect<Image, Error, never> => 119 120 image 120 121 ? Effect.succeed(image) 121 122 : Effect.fail(new NoSuchImageError({ cause: "No such image" })); 122 123 123 124 export const du = ( 124 - path: string, 125 + path: string 125 126 ): Effect.Effect<number, LogCommandError, never> => 126 127 Effect.tryPromise({ 127 128 try: async () => { ··· 153 154 exists 154 155 ? Effect.succeed(true) 155 156 : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) 156 - ), 157 + ) 157 158 ); 158 159 159 160 export const downloadIso = (url: string, options: Options) => ··· 175 176 if (driveSize > EMPTY_DISK_THRESHOLD_KB) { 176 177 console.log( 177 178 chalk.yellowBright( 178 - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, 179 - ), 179 + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` 180 + ) 180 181 ); 181 182 return null; 182 183 } ··· 194 195 if (outputExists) { 195 196 console.log( 196 197 chalk.yellowBright( 197 - `File ${outputPath} already exists, skipping download.`, 198 - ), 198 + `File ${outputPath} already exists, skipping download.` 199 + ) 199 200 ); 200 201 return outputPath; 201 202 } ··· 244 245 if (!success) { 245 246 console.error( 246 247 chalk.redBright( 247 - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", 248 - ), 248 + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." 249 + ) 249 250 ); 250 251 Deno.exit(1); 251 252 } ··· 258 259 try: () => 259 260 Deno.copyFile( 260 261 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, 261 - edk2VarsAarch64, 262 + edk2VarsAarch64 262 263 ), 263 264 catch: (error) => new LogCommandError({ cause: error }), 264 265 }); ··· 303 304 const configOK = yield* pipe( 304 305 fileExists("config.ign"), 305 306 Effect.flatMap(() => Effect.succeed(true)), 306 - Effect.catchAll(() => Effect.succeed(false)), 307 + Effect.catchAll(() => Effect.succeed(false)) 307 308 ); 308 309 if (!configOK) { 309 310 console.error( 310 311 chalk.redBright( 311 - "CoreOS image requires a config.ign file in the current directory.", 312 - ), 312 + "CoreOS image requires a config.ign file in the current directory." 313 + ) 313 314 ); 314 315 Deno.exit(1); 315 316 } ··· 325 326 return []; 326 327 }); 327 328 329 + export const setupFedoraArgs = (imagePath?: string | null) => 330 + Effect.sync(() => { 331 + if ( 332 + imagePath && 333 + imagePath.endsWith(".qcow2") && 334 + imagePath.includes("Fedora-Server") 335 + ) { 336 + return ["-drive", `file=${imagePath},format=qcow2,if=virtio`]; 337 + } 338 + 339 + return []; 340 + }); 341 + 328 342 export const runQemu = (isoPath: string | null, options: Options) => 329 343 Effect.gen(function* () { 330 344 const macAddress = yield* generateRandomMacAddress(); 331 345 332 - const qemu = Deno.build.arch === "aarch64" 333 - ? "qemu-system-aarch64" 334 - : "qemu-system-x86_64"; 346 + const qemu = 347 + Deno.build.arch === "aarch64" 348 + ? "qemu-system-aarch64" 349 + : "qemu-system-x86_64"; 335 350 336 351 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 337 352 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); 353 + let fedoraArgs: string[] = yield* setupFedoraArgs(isoPath || options.image); 338 354 339 355 if (coreosArgs.length > 0 && !isoPath) { 340 356 coreosArgs = coreosArgs.slice(2); 341 357 } 342 358 359 + if (fedoraArgs.length > 0 && !isoPath) { 360 + fedoraArgs = []; 361 + } 362 + 343 363 const qemuArgs = [ 344 364 ..._.compact([options.bridge && qemu]), 345 365 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), ··· 367 387 "chardev:con0", 368 388 ...firmwareFiles, 369 389 ...coreosArgs, 390 + ...fedoraArgs, 370 391 ..._.compact( 371 392 options.image && [ 372 393 "-drive", 373 394 `file=${options.image},format=${options.diskFormat},if=virtio`, 374 - ], 395 + ] 375 396 ), 376 397 ]; 377 398 ··· 386 407 const logPath = `${LOGS_DIR}/${name}.log`; 387 408 388 409 const fullCommand = options.bridge 389 - ? `sudo ${qemu} ${ 390 - qemuArgs 410 + ? `sudo ${qemu} ${qemuArgs 391 411 .slice(1) 392 - .join(" ") 393 - } >> "${logPath}" 2>&1 & echo $!` 412 + .join(" ")} >> "${logPath}" 2>&1 & echo $!` 394 413 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 395 414 396 415 const { stdout } = yield* Effect.tryPromise({ ··· 416 435 cpus: options.cpus, 417 436 cpu: options.cpu, 418 437 diskSize: options.size || "20G", 419 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 438 + diskFormat: 439 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 420 440 options.diskFormat || 421 441 "raw", 422 442 portForward: options.portForward, ··· 435 455 }); 436 456 437 457 console.log( 438 - `Virtual machine ${name} started in background (PID: ${qemuPid})`, 458 + `Virtual machine ${name} started in background (PID: ${qemuPid})` 439 459 ); 440 460 console.log(`Logs will be written to: ${logPath}`); 441 461 ··· 458 478 cpus: options.cpus, 459 479 cpu: options.cpu, 460 480 diskSize: options.size || "20G", 461 - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 481 + diskFormat: 482 + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || 462 483 options.diskFormat || 463 484 "raw", 464 485 portForward: options.portForward, ··· 566 587 if (pathExists) { 567 588 console.log( 568 589 chalk.yellowBright( 569 - `Drive image ${path} already exists, skipping creation.`, 570 - ), 590 + `Drive image ${path} already exists, skipping creation.` 591 + ) 571 592 ); 572 593 return; 573 594 } ··· 594 615 }); 595 616 596 617 export const fileExists = ( 597 - path: string, 618 + path: string 598 619 ): Effect.Effect<void, NoSuchFileError, never> => 599 620 Effect.try({ 600 621 try: () => Deno.statSync(path), ··· 602 623 }); 603 624 604 625 export const constructCoreOSImageURL = ( 605 - image: string, 626 + image: string 606 627 ): Effect.Effect<string, InvalidImageNameError, never> => { 607 628 // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos-<version> or coreos or coreos-<version> 608 629 const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; ··· 610 631 if (match) { 611 632 const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; 612 633 return Effect.succeed( 613 - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), 634 + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) 614 635 ); 615 636 } 616 637 ··· 618 639 new InvalidImageNameError({ 619 640 image, 620 641 cause: "Image name does not match CoreOS naming conventions.", 621 - }), 642 + }) 622 643 ); 623 644 }; 624 645 ··· 647 668 }); 648 669 649 670 export const constructNixOSImageURL = ( 650 - image: string, 671 + image: string 651 672 ): Effect.Effect<string, InvalidImageNameError, never> => { 652 673 // detect with regex if image matches NixOS pattern: nixos or nixos-<version> 653 674 const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; ··· 655 676 if (match) { 656 677 const version = match[3] || NIXOS_DEFAULT_VERSION; 657 678 return Effect.succeed( 658 - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), 679 + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) 659 680 ); 660 681 } 661 682 ··· 663 684 new InvalidImageNameError({ 664 685 image, 665 686 cause: "Image name does not match NixOS naming conventions.", 666 - }), 687 + }) 688 + ); 689 + }; 690 + 691 + export const constructFedoraImageURL = ( 692 + image: string 693 + ): Effect.Effect<string, InvalidImageNameError, never> => { 694 + // detect with regex if image matches Fedora pattern: fedora 695 + const fedoraRegex = /^(fedora)$/; 696 + const match = image.match(fedoraRegex); 697 + if (match) { 698 + return Effect.succeed(FEDORA_IMG_URL); 699 + } 700 + 701 + return Effect.fail( 702 + new InvalidImageNameError({ 703 + image, 704 + cause: "Image name does not match Fedora naming conventions.", 705 + }) 667 706 ); 668 707 };