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

Merge pull request #7 from tsirysndr/feat/cloud-init

feat: add support for Debian and Ubuntu cloud images with related configurations

+164 -2
+25 -1
main.ts
··· 36 36 constructFedoraImageURL, 37 37 constructGentooImageURL, 38 38 constructNixOSImageURL, 39 + constructUbuntuImageURL, 39 40 createDriveImageIfNeeded, 40 41 downloadIso, 41 42 emptyDiskImage, ··· 97 98 .option( 98 99 "--install", 99 100 "Persist changes to the VM disk image", 101 + ) 102 + .option( 103 + "--cloud", 104 + "Use cloud-init for initial configuration (only for compatible images)", 100 105 ) 101 106 .example( 102 107 "Create a default VM configuration file", ··· 251 256 } 252 257 253 258 const debianImageURL = yield* pipe( 254 - constructDebianImageURL(input), 259 + constructDebianImageURL(input, options.cloud), 255 260 Effect.catchAll(() => Effect.succeed(null)), 256 261 ); 257 262 ··· 266 271 isoPath = yield* downloadIso(debianImageURL, options); 267 272 } else { 268 273 isoPath = basename(debianImageURL); 274 + } 275 + } 276 + 277 + const ubuntuImageURL = yield* pipe( 278 + constructUbuntuImageURL(input, options.cloud), 279 + Effect.catchAll(() => Effect.succeed(null)), 280 + ); 281 + 282 + if (ubuntuImageURL) { 283 + const cached = yield* pipe( 284 + basename(ubuntuImageURL), 285 + fileExists, 286 + Effect.flatMap(() => Effect.succeed(true)), 287 + Effect.catchAll(() => Effect.succeed(false)), 288 + ); 289 + if (!cached) { 290 + isoPath = yield* downloadIso(ubuntuImageURL, options); 291 + } else { 292 + isoPath = basename(ubuntuImageURL); 269 293 } 270 294 } 271 295
+8
src/constants.ts
··· 48 48 .slice(0, 2) 49 49 .join(".") 50 50 }/releases/cloud/generic_alpine-${ALPINE_DEFAULT_VERSION}-${Deno.build.arch}-uefi-tiny-r0.qcow2`; 51 + 52 + export const DEBIAN_CLOUD_IMG_URL: string = Deno.build.arch === "aarch64" 53 + ? "https://cdimage.debian.org/images/cloud/trixie/20251117-2299/debian-13-generic-arm64-20251117-2299.qcow2" 54 + : "https://cdimage.debian.org/images/cloud/trixie/20251117-2299/debian-13-generic-amd64-20251117-2299.qcow2"; 55 + 56 + export const UBUNTU_CLOUD_IMG_URL: string = Deno.build.arch === "aarch64" 57 + ? "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img" 58 + : "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img";
+14
src/subcommands/restart.ts
··· 8 8 safeKillQemu, 9 9 setupAlpineArgs, 10 10 setupCoreOSArgs, 11 + setupDebianArgs, 11 12 setupFirmwareFilesIfNeeded, 12 13 setupNATNetworkArgs, 14 + setupUbuntuArgs, 13 15 } from "../utils.ts"; 14 16 15 17 class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ ··· 62 64 63 65 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 64 66 let alpineArgs: string[] = Effect.runSync(setupAlpineArgs(vm.isoPath)); 67 + let debianArgs: string[] = Effect.runSync(setupDebianArgs(vm.isoPath)); 68 + let ubuntuArgs: string[] = Effect.runSync(setupUbuntuArgs(vm.isoPath)); 65 69 66 70 if (coreosArgs.length > 0) { 67 71 coreosArgs = coreosArgs.slice(2); ··· 71 75 alpineArgs = alpineArgs.slice(2); 72 76 } 73 77 78 + if (debianArgs.length > 0) { 79 + debianArgs = debianArgs.slice(2); 80 + } 81 + 82 + if (ubuntuArgs.length > 0) { 83 + ubuntuArgs = ubuntuArgs.slice(2); 84 + } 85 + 74 86 return Effect.succeed([ 75 87 ..._.compact([vm.bridge && qemu]), 76 88 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), ··· 104 116 ), 105 117 ...coreosArgs, 106 118 ...alpineArgs, 119 + ...debianArgs, 120 + ...ubuntuArgs, 107 121 ]); 108 122 }; 109 123
+79
src/utils.ts
··· 7 7 import { 8 8 ALPINE_DEFAULT_VERSION, 9 9 ALPINE_ISO_URL, 10 + DEBIAN_CLOUD_IMG_URL, 10 11 DEBIAN_DEFAULT_VERSION, 11 12 DEBIAN_ISO_URL, 12 13 EMPTY_DISK_THRESHOLD_KB, ··· 17 18 LOGS_DIR, 18 19 NIXOS_DEFAULT_VERSION, 19 20 NIXOS_ISO_URL, 21 + UBUNTU_CLOUD_IMG_URL, 22 + UBUNTU_ISO_URL, 20 23 } from "./constants.ts"; 21 24 import type { Image } from "./db.ts"; 22 25 import { generateRandomMacAddress } from "./network.ts"; ··· 37 40 detach?: boolean; 38 41 install?: boolean; 39 42 volume?: string; 43 + cloud?: boolean; 40 44 } 41 45 42 46 class LogCommandError extends Data.TaggedError("LogCommandError")<{ ··· 365 369 imagePath && 366 370 imagePath.endsWith(".qcow2") && 367 371 imagePath.includes("alpine") 372 + ) { 373 + return [ 374 + "-drive", 375 + `file=${imagePath},format=qcow2,if=virtio`, 376 + "-drive", 377 + "if=virtio,file=seed.iso,media=cdrom", 378 + ]; 379 + } 380 + 381 + return []; 382 + }); 383 + 384 + export const setupDebianArgs = (imagePath?: string | null) => 385 + Effect.sync(() => { 386 + if ( 387 + imagePath && 388 + imagePath.endsWith(".qcow2") && 389 + imagePath.includes("debian") 390 + ) { 391 + return [ 392 + "-drive", 393 + `file=${imagePath},format=qcow2,if=virtio`, 394 + "-drive", 395 + "if=virtio,file=seed.iso,media=cdrom", 396 + ]; 397 + } 398 + 399 + return []; 400 + }); 401 + 402 + export const setupUbuntuArgs = (imagePath?: string | null) => 403 + Effect.sync(() => { 404 + if ( 405 + imagePath && 406 + imagePath.endsWith(".img") && 407 + imagePath.includes("server-cloudimg") 368 408 ) { 369 409 return [ 370 410 "-drive", ··· 390 430 let fedoraArgs: string[] = yield* setupFedoraArgs(isoPath || options.image); 391 431 let gentooArgs: string[] = yield* setupGentooArgs(isoPath || options.image); 392 432 let alpineArgs: string[] = yield* setupAlpineArgs(isoPath || options.image); 433 + let debianArgs: string[] = yield* setupDebianArgs(isoPath || options.image); 434 + let ubuntuArgs: string[] = yield* setupUbuntuArgs(isoPath || options.image); 393 435 394 436 if (coreosArgs.length > 0 && !isoPath) { 395 437 coreosArgs = coreosArgs.slice(2); ··· 405 447 406 448 if (alpineArgs.length > 0 && !isoPath) { 407 449 alpineArgs = alpineArgs.slice(2); 450 + } 451 + 452 + if (debianArgs.length > 0 && !isoPath) { 453 + debianArgs = []; 454 + } 455 + 456 + if (ubuntuArgs.length > 0 && !isoPath) { 457 + ubuntuArgs = []; 408 458 } 409 459 410 460 const qemuArgs = [ ··· 437 487 ...fedoraArgs, 438 488 ...gentooArgs, 439 489 ...alpineArgs, 490 + ...debianArgs, 491 + ...ubuntuArgs, 440 492 ..._.compact( 441 493 options.image && [ 442 494 "-drive", ··· 784 836 785 837 export const constructDebianImageURL = ( 786 838 image: string, 839 + cloud: boolean = false, 787 840 ): Effect.Effect<string, InvalidImageNameError, never> => { 841 + if (cloud && image === "debian") { 842 + return Effect.succeed(DEBIAN_CLOUD_IMG_URL); 843 + } 844 + 788 845 // detect with regex if image matches debian pattern: debian-<version> or debian 789 846 const debianRegex = /^(debian)(-(\d+\.\d+\.\d+))?$/; 790 847 const match = image.match(debianRegex); ··· 829 886 }), 830 887 ); 831 888 }; 889 + 890 + export const constructUbuntuImageURL = ( 891 + image: string, 892 + cloud: boolean = false, 893 + ): Effect.Effect<string, InvalidImageNameError, never> => { 894 + // detect with regex if image matches ubuntu pattern: ubuntu 895 + const ubuntuRegex = /^(ubuntu)$/; 896 + const match = image.match(ubuntuRegex); 897 + if (match) { 898 + if (cloud) { 899 + return Effect.succeed(UBUNTU_CLOUD_IMG_URL); 900 + } 901 + return Effect.succeed(UBUNTU_ISO_URL); 902 + } 903 + 904 + return Effect.fail( 905 + new InvalidImageNameError({ 906 + image, 907 + cause: "Image name does not match Ubuntu naming conventions.", 908 + }), 909 + ); 910 + };
+38 -1
src/utils_test.ts
··· 2 2 import { Effect, pipe } from "effect"; 3 3 import { 4 4 ALPINE_ISO_URL, 5 + DEBIAN_CLOUD_IMG_URL, 5 6 DEBIAN_ISO_URL, 6 7 FEDORA_COREOS_IMG_URL, 7 8 GENTOO_IMG_URL, 8 9 NIXOS_ISO_URL, 10 + UBUNTU_CLOUD_IMG_URL, 11 + UBUNTU_ISO_URL, 9 12 } from "./constants.ts"; 10 13 import { 11 14 constructAlpineImageURL, ··· 13 16 constructDebianImageURL, 14 17 constructGentooImageURL, 15 18 constructNixOSImageURL, 19 + constructUbuntuImageURL, 16 20 } from "./utils.ts"; 17 21 18 22 Deno.test("Test Default Fedora CoreOS Image URL", () => { ··· 159 163 ), 160 164 ); 161 165 162 - const arch = Deno.build.arch === "aarch64" ? "arm64" : "amd64"; 163 166 assertEquals(url, DEBIAN_ISO_URL); 167 + }); 168 + 169 + Deno.test("Test valid Debian Image Name (Cloud)", () => { 170 + const url = Effect.runSync( 171 + pipe( 172 + constructDebianImageURL("debian", true), 173 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 174 + ), 175 + ); 176 + 177 + assertEquals(url, DEBIAN_CLOUD_IMG_URL); 164 178 }); 165 179 166 180 Deno.test("Test invalid Debian Image Name", () => { ··· 209 223 210 224 assertEquals(url, null); 211 225 }); 226 + 227 + // ubuntu 228 + Deno.test("Test valid Ubuntu Image Name", () => { 229 + const url = Effect.runSync( 230 + pipe( 231 + constructUbuntuImageURL("ubuntu"), 232 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 233 + ), 234 + ); 235 + 236 + assertEquals(url, UBUNTU_ISO_URL); 237 + }); 238 + 239 + Deno.test("Test valid Ubuntu Image Name (Cloud)", () => { 240 + const url = Effect.runSync( 241 + pipe( 242 + constructUbuntuImageURL("ubuntu", true), 243 + Effect.catchAll((_error) => Effect.succeed(null as string | null)), 244 + ), 245 + ); 246 + 247 + assertEquals(url, UBUNTU_CLOUD_IMG_URL); 248 + });