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

authored by tsiry-sandratraina.com and committed by

GitHub 2c0f81e7 f9d367e6

+164 -2
+25 -1
main.ts
··· 36 constructFedoraImageURL, 37 constructGentooImageURL, 38 constructNixOSImageURL, 39 createDriveImageIfNeeded, 40 downloadIso, 41 emptyDiskImage, ··· 97 .option( 98 "--install", 99 "Persist changes to the VM disk image", 100 ) 101 .example( 102 "Create a default VM configuration file", ··· 251 } 252 253 const debianImageURL = yield* pipe( 254 - constructDebianImageURL(input), 255 Effect.catchAll(() => Effect.succeed(null)), 256 ); 257 ··· 266 isoPath = yield* downloadIso(debianImageURL, options); 267 } else { 268 isoPath = basename(debianImageURL); 269 } 270 } 271
··· 36 constructFedoraImageURL, 37 constructGentooImageURL, 38 constructNixOSImageURL, 39 + constructUbuntuImageURL, 40 createDriveImageIfNeeded, 41 downloadIso, 42 emptyDiskImage, ··· 98 .option( 99 "--install", 100 "Persist changes to the VM disk image", 101 + ) 102 + .option( 103 + "--cloud", 104 + "Use cloud-init for initial configuration (only for compatible images)", 105 ) 106 .example( 107 "Create a default VM configuration file", ··· 256 } 257 258 const debianImageURL = yield* pipe( 259 + constructDebianImageURL(input, options.cloud), 260 Effect.catchAll(() => Effect.succeed(null)), 261 ); 262 ··· 271 isoPath = yield* downloadIso(debianImageURL, options); 272 } else { 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); 293 } 294 } 295
+8
src/constants.ts
··· 48 .slice(0, 2) 49 .join(".") 50 }/releases/cloud/generic_alpine-${ALPINE_DEFAULT_VERSION}-${Deno.build.arch}-uefi-tiny-r0.qcow2`;
··· 48 .slice(0, 2) 49 .join(".") 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 safeKillQemu, 9 setupAlpineArgs, 10 setupCoreOSArgs, 11 setupFirmwareFilesIfNeeded, 12 setupNATNetworkArgs, 13 } from "../utils.ts"; 14 15 class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ ··· 62 63 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 64 let alpineArgs: string[] = Effect.runSync(setupAlpineArgs(vm.isoPath)); 65 66 if (coreosArgs.length > 0) { 67 coreosArgs = coreosArgs.slice(2); ··· 71 alpineArgs = alpineArgs.slice(2); 72 } 73 74 return Effect.succeed([ 75 ..._.compact([vm.bridge && qemu]), 76 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), ··· 104 ), 105 ...coreosArgs, 106 ...alpineArgs, 107 ]); 108 }; 109
··· 8 safeKillQemu, 9 setupAlpineArgs, 10 setupCoreOSArgs, 11 + setupDebianArgs, 12 setupFirmwareFilesIfNeeded, 13 setupNATNetworkArgs, 14 + setupUbuntuArgs, 15 } from "../utils.ts"; 16 17 class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ ··· 64 65 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 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)); 69 70 if (coreosArgs.length > 0) { 71 coreosArgs = coreosArgs.slice(2); ··· 75 alpineArgs = alpineArgs.slice(2); 76 } 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 + 86 return Effect.succeed([ 87 ..._.compact([vm.bridge && qemu]), 88 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), ··· 116 ), 117 ...coreosArgs, 118 ...alpineArgs, 119 + ...debianArgs, 120 + ...ubuntuArgs, 121 ]); 122 }; 123
+79
src/utils.ts
··· 7 import { 8 ALPINE_DEFAULT_VERSION, 9 ALPINE_ISO_URL, 10 DEBIAN_DEFAULT_VERSION, 11 DEBIAN_ISO_URL, 12 EMPTY_DISK_THRESHOLD_KB, ··· 17 LOGS_DIR, 18 NIXOS_DEFAULT_VERSION, 19 NIXOS_ISO_URL, 20 } from "./constants.ts"; 21 import type { Image } from "./db.ts"; 22 import { generateRandomMacAddress } from "./network.ts"; ··· 37 detach?: boolean; 38 install?: boolean; 39 volume?: string; 40 } 41 42 class LogCommandError extends Data.TaggedError("LogCommandError")<{ ··· 365 imagePath && 366 imagePath.endsWith(".qcow2") && 367 imagePath.includes("alpine") 368 ) { 369 return [ 370 "-drive", ··· 390 let fedoraArgs: string[] = yield* setupFedoraArgs(isoPath || options.image); 391 let gentooArgs: string[] = yield* setupGentooArgs(isoPath || options.image); 392 let alpineArgs: string[] = yield* setupAlpineArgs(isoPath || options.image); 393 394 if (coreosArgs.length > 0 && !isoPath) { 395 coreosArgs = coreosArgs.slice(2); ··· 405 406 if (alpineArgs.length > 0 && !isoPath) { 407 alpineArgs = alpineArgs.slice(2); 408 } 409 410 const qemuArgs = [ ··· 437 ...fedoraArgs, 438 ...gentooArgs, 439 ...alpineArgs, 440 ..._.compact( 441 options.image && [ 442 "-drive", ··· 784 785 export const constructDebianImageURL = ( 786 image: string, 787 ): Effect.Effect<string, InvalidImageNameError, never> => { 788 // detect with regex if image matches debian pattern: debian-<version> or debian 789 const debianRegex = /^(debian)(-(\d+\.\d+\.\d+))?$/; 790 const match = image.match(debianRegex); ··· 829 }), 830 ); 831 };
··· 7 import { 8 ALPINE_DEFAULT_VERSION, 9 ALPINE_ISO_URL, 10 + DEBIAN_CLOUD_IMG_URL, 11 DEBIAN_DEFAULT_VERSION, 12 DEBIAN_ISO_URL, 13 EMPTY_DISK_THRESHOLD_KB, ··· 18 LOGS_DIR, 19 NIXOS_DEFAULT_VERSION, 20 NIXOS_ISO_URL, 21 + UBUNTU_CLOUD_IMG_URL, 22 + UBUNTU_ISO_URL, 23 } from "./constants.ts"; 24 import type { Image } from "./db.ts"; 25 import { generateRandomMacAddress } from "./network.ts"; ··· 40 detach?: boolean; 41 install?: boolean; 42 volume?: string; 43 + cloud?: boolean; 44 } 45 46 class LogCommandError extends Data.TaggedError("LogCommandError")<{ ··· 369 imagePath && 370 imagePath.endsWith(".qcow2") && 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") 408 ) { 409 return [ 410 "-drive", ··· 430 let fedoraArgs: string[] = yield* setupFedoraArgs(isoPath || options.image); 431 let gentooArgs: string[] = yield* setupGentooArgs(isoPath || options.image); 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); 435 436 if (coreosArgs.length > 0 && !isoPath) { 437 coreosArgs = coreosArgs.slice(2); ··· 447 448 if (alpineArgs.length > 0 && !isoPath) { 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 = []; 458 } 459 460 const qemuArgs = [ ··· 487 ...fedoraArgs, 488 ...gentooArgs, 489 ...alpineArgs, 490 + ...debianArgs, 491 + ...ubuntuArgs, 492 ..._.compact( 493 options.image && [ 494 "-drive", ··· 836 837 export const constructDebianImageURL = ( 838 image: string, 839 + cloud: boolean = false, 840 ): Effect.Effect<string, InvalidImageNameError, never> => { 841 + if (cloud && image === "debian") { 842 + return Effect.succeed(DEBIAN_CLOUD_IMG_URL); 843 + } 844 + 845 // detect with regex if image matches debian pattern: debian-<version> or debian 846 const debianRegex = /^(debian)(-(\d+\.\d+\.\d+))?$/; 847 const match = image.match(debianRegex); ··· 886 }), 887 ); 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 import { Effect, pipe } from "effect"; 3 import { 4 ALPINE_ISO_URL, 5 DEBIAN_ISO_URL, 6 FEDORA_COREOS_IMG_URL, 7 GENTOO_IMG_URL, 8 NIXOS_ISO_URL, 9 } from "./constants.ts"; 10 import { 11 constructAlpineImageURL, ··· 13 constructDebianImageURL, 14 constructGentooImageURL, 15 constructNixOSImageURL, 16 } from "./utils.ts"; 17 18 Deno.test("Test Default Fedora CoreOS Image URL", () => { ··· 159 ), 160 ); 161 162 - const arch = Deno.build.arch === "aarch64" ? "arm64" : "amd64"; 163 assertEquals(url, DEBIAN_ISO_URL); 164 }); 165 166 Deno.test("Test invalid Debian Image Name", () => { ··· 209 210 assertEquals(url, null); 211 });
··· 2 import { Effect, pipe } from "effect"; 3 import { 4 ALPINE_ISO_URL, 5 + DEBIAN_CLOUD_IMG_URL, 6 DEBIAN_ISO_URL, 7 FEDORA_COREOS_IMG_URL, 8 GENTOO_IMG_URL, 9 NIXOS_ISO_URL, 10 + UBUNTU_CLOUD_IMG_URL, 11 + UBUNTU_ISO_URL, 12 } from "./constants.ts"; 13 import { 14 constructAlpineImageURL, ··· 16 constructDebianImageURL, 17 constructGentooImageURL, 18 constructNixOSImageURL, 19 + constructUbuntuImageURL, 20 } from "./utils.ts"; 21 22 Deno.test("Test Default Fedora CoreOS Image URL", () => { ··· 163 ), 164 ); 165 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); 178 }); 179 180 Deno.test("Test invalid Debian Image Name", () => { ··· 223 224 assertEquals(url, null); 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 + });