diff --git a/src/utils_test.ts b/src/utils_test.ts index fbb9c93..f0c68fd 100644 --- a/src/utils_test.ts +++ b/src/utils_test.ts @@ -159,7 +159,6 @@ Deno.test("Test valid Debian Image Name", () => { ), ); - const arch = Deno.build.arch === "aarch64" ? "arm64" : "amd64"; assertEquals(url, DEBIAN_ISO_URL); }); -- 2.51.2 From e4fe79561e87e7f2352adccd95aea2b358b44b31 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Thu, 20 Nov 2025 23:34:10 +0300 Subject: [PATCH 2/2] feat: add support for Debian and Ubuntu cloud images with related configurations --- main.ts | 26 ++++++++++++- src/constants.ts | 8 ++++ src/subcommands/restart.ts | 14 +++++++ src/utils.ts | 79 ++++++++++++++++++++++++++++++++++++++ src/utils_test.ts | 38 ++++++++++++++++++ 5 files changed, 164 insertions(+), 1 deletion(-) diff --git a/main.ts b/main.ts index bf98ea5..e73ad2d 100755 --- a/main.ts +++ b/main.ts @@ -36,6 +36,7 @@ import { constructFedoraImageURL, constructGentooImageURL, constructNixOSImageURL, + constructUbuntuImageURL, createDriveImageIfNeeded, downloadIso, emptyDiskImage, @@ -98,6 +99,10 @@ if (import.meta.main) { "--install", "Persist changes to the VM disk image", ) + .option( + "--cloud", + "Use cloud-init for initial configuration (only for compatible images)", + ) .example( "Create a default VM configuration file", "vmx init", @@ -251,7 +256,7 @@ if (import.meta.main) { } const debianImageURL = yield* pipe( - constructDebianImageURL(input), + constructDebianImageURL(input, options.cloud), Effect.catchAll(() => Effect.succeed(null)), ); @@ -269,6 +274,25 @@ if (import.meta.main) { } } + const ubuntuImageURL = yield* pipe( + constructUbuntuImageURL(input, options.cloud), + Effect.catchAll(() => Effect.succeed(null)), + ); + + if (ubuntuImageURL) { + const cached = yield* pipe( + basename(ubuntuImageURL), + fileExists, + Effect.flatMap(() => Effect.succeed(true)), + Effect.catchAll(() => Effect.succeed(false)), + ); + if (!cached) { + isoPath = yield* downloadIso(ubuntuImageURL, options); + } else { + isoPath = basename(ubuntuImageURL); + } + } + const alpineImageURL = yield* pipe( constructAlpineImageURL(input), Effect.catchAll(() => Effect.succeed(null)), diff --git a/src/constants.ts b/src/constants.ts index 117327e..9450693 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -48,3 +48,11 @@ export const ALPINE_ISO_URL: string = Deno.build.arch === "aarch64" .slice(0, 2) .join(".") }/releases/cloud/generic_alpine-${ALPINE_DEFAULT_VERSION}-${Deno.build.arch}-uefi-tiny-r0.qcow2`; + +export const DEBIAN_CLOUD_IMG_URL: string = Deno.build.arch === "aarch64" + ? "https://cdimage.debian.org/images/cloud/trixie/20251117-2299/debian-13-generic-arm64-20251117-2299.qcow2" + : "https://cdimage.debian.org/images/cloud/trixie/20251117-2299/debian-13-generic-amd64-20251117-2299.qcow2"; + +export const UBUNTU_CLOUD_IMG_URL: string = Deno.build.arch === "aarch64" + ? "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img" + : "https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"; diff --git a/src/subcommands/restart.ts b/src/subcommands/restart.ts index ff2a481..381e61a 100644 --- a/src/subcommands/restart.ts +++ b/src/subcommands/restart.ts @@ -8,8 +8,10 @@ import { safeKillQemu, setupAlpineArgs, setupCoreOSArgs, + setupDebianArgs, setupFirmwareFilesIfNeeded, setupNATNetworkArgs, + setupUbuntuArgs, } from "../utils.ts"; class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ @@ -62,6 +64,8 @@ const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); let alpineArgs: string[] = Effect.runSync(setupAlpineArgs(vm.isoPath)); + let debianArgs: string[] = Effect.runSync(setupDebianArgs(vm.isoPath)); + let ubuntuArgs: string[] = Effect.runSync(setupUbuntuArgs(vm.isoPath)); if (coreosArgs.length > 0) { coreosArgs = coreosArgs.slice(2); @@ -71,6 +75,14 @@ const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { alpineArgs = alpineArgs.slice(2); } + if (debianArgs.length > 0) { + debianArgs = debianArgs.slice(2); + } + + if (ubuntuArgs.length > 0) { + ubuntuArgs = ubuntuArgs.slice(2); + } + return Effect.succeed([ ..._.compact([vm.bridge && qemu]), ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), @@ -104,6 +116,8 @@ const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { ), ...coreosArgs, ...alpineArgs, + ...debianArgs, + ...ubuntuArgs, ]); }; diff --git a/src/utils.ts b/src/utils.ts index ec6ee42..38a8c6e 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -7,6 +7,7 @@ import Moniker from "moniker"; import { ALPINE_DEFAULT_VERSION, ALPINE_ISO_URL, + DEBIAN_CLOUD_IMG_URL, DEBIAN_DEFAULT_VERSION, DEBIAN_ISO_URL, EMPTY_DISK_THRESHOLD_KB, @@ -17,6 +18,8 @@ import { LOGS_DIR, NIXOS_DEFAULT_VERSION, NIXOS_ISO_URL, + UBUNTU_CLOUD_IMG_URL, + UBUNTU_ISO_URL, } from "./constants.ts"; import type { Image } from "./db.ts"; import { generateRandomMacAddress } from "./network.ts"; @@ -37,6 +40,7 @@ export interface Options { detach?: boolean; install?: boolean; volume?: string; + cloud?: boolean; } class LogCommandError extends Data.TaggedError("LogCommandError")<{ @@ -377,6 +381,42 @@ export const setupAlpineArgs = (imagePath?: string | null) => return []; }); +export const setupDebianArgs = (imagePath?: string | null) => + Effect.sync(() => { + if ( + imagePath && + imagePath.endsWith(".qcow2") && + imagePath.includes("debian") + ) { + return [ + "-drive", + `file=${imagePath},format=qcow2,if=virtio`, + "-drive", + "if=virtio,file=seed.iso,media=cdrom", + ]; + } + + return []; + }); + +export const setupUbuntuArgs = (imagePath?: string | null) => + Effect.sync(() => { + if ( + imagePath && + imagePath.endsWith(".img") && + imagePath.includes("server-cloudimg") + ) { + return [ + "-drive", + `file=${imagePath},format=qcow2,if=virtio`, + "-drive", + "if=virtio,file=seed.iso,media=cdrom", + ]; + } + + return []; + }); + export const runQemu = (isoPath: string | null, options: Options) => Effect.gen(function* () { const macAddress = yield* generateRandomMacAddress(); @@ -390,6 +430,8 @@ export const runQemu = (isoPath: string | null, options: Options) => let fedoraArgs: string[] = yield* setupFedoraArgs(isoPath || options.image); let gentooArgs: string[] = yield* setupGentooArgs(isoPath || options.image); let alpineArgs: string[] = yield* setupAlpineArgs(isoPath || options.image); + let debianArgs: string[] = yield* setupDebianArgs(isoPath || options.image); + let ubuntuArgs: string[] = yield* setupUbuntuArgs(isoPath || options.image); if (coreosArgs.length > 0 && !isoPath) { coreosArgs = coreosArgs.slice(2); @@ -407,6 +449,14 @@ export const runQemu = (isoPath: string | null, options: Options) => alpineArgs = alpineArgs.slice(2); } + if (debianArgs.length > 0 && !isoPath) { + debianArgs = []; + } + + if (ubuntuArgs.length > 0 && !isoPath) { + ubuntuArgs = []; + } + const qemuArgs = [ ..._.compact([options.bridge && qemu]), ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), @@ -437,6 +487,8 @@ export const runQemu = (isoPath: string | null, options: Options) => ...fedoraArgs, ...gentooArgs, ...alpineArgs, + ...debianArgs, + ...ubuntuArgs, ..._.compact( options.image && [ "-drive", @@ -784,7 +836,12 @@ export const constructGentooImageURL = ( export const constructDebianImageURL = ( image: string, + cloud: boolean = false, ): Effect.Effect => { + if (cloud && image === "debian") { + return Effect.succeed(DEBIAN_CLOUD_IMG_URL); + } + // detect with regex if image matches debian pattern: debian- or debian const debianRegex = /^(debian)(-(\d+\.\d+\.\d+))?$/; const match = image.match(debianRegex); @@ -829,3 +886,25 @@ export const constructAlpineImageURL = ( }), ); }; + +export const constructUbuntuImageURL = ( + image: string, + cloud: boolean = false, +): Effect.Effect => { + // detect with regex if image matches ubuntu pattern: ubuntu + const ubuntuRegex = /^(ubuntu)$/; + const match = image.match(ubuntuRegex); + if (match) { + if (cloud) { + return Effect.succeed(UBUNTU_CLOUD_IMG_URL); + } + return Effect.succeed(UBUNTU_ISO_URL); + } + + return Effect.fail( + new InvalidImageNameError({ + image, + cause: "Image name does not match Ubuntu naming conventions.", + }), + ); +}; diff --git a/src/utils_test.ts b/src/utils_test.ts index f0c68fd..aaa4500 100644 --- a/src/utils_test.ts +++ b/src/utils_test.ts @@ -2,10 +2,13 @@ import { assertEquals } from "@std/assert"; import { Effect, pipe } from "effect"; import { ALPINE_ISO_URL, + DEBIAN_CLOUD_IMG_URL, DEBIAN_ISO_URL, FEDORA_COREOS_IMG_URL, GENTOO_IMG_URL, NIXOS_ISO_URL, + UBUNTU_CLOUD_IMG_URL, + UBUNTU_ISO_URL, } from "./constants.ts"; import { constructAlpineImageURL, @@ -13,6 +16,7 @@ import { constructDebianImageURL, constructGentooImageURL, constructNixOSImageURL, + constructUbuntuImageURL, } from "./utils.ts"; Deno.test("Test Default Fedora CoreOS Image URL", () => { @@ -162,6 +166,17 @@ Deno.test("Test valid Debian Image Name", () => { assertEquals(url, DEBIAN_ISO_URL); }); +Deno.test("Test valid Debian Image Name (Cloud)", () => { + const url = Effect.runSync( + pipe( + constructDebianImageURL("debian", true), + Effect.catchAll((_error) => Effect.succeed(null as string | null)), + ), + ); + + assertEquals(url, DEBIAN_CLOUD_IMG_URL); +}); + Deno.test("Test invalid Debian Image Name", () => { const url = Effect.runSync( pipe( @@ -208,3 +223,26 @@ Deno.test("Test invalid Alpine Image Name", () => { assertEquals(url, null); }); + +// ubuntu +Deno.test("Test valid Ubuntu Image Name", () => { + const url = Effect.runSync( + pipe( + constructUbuntuImageURL("ubuntu"), + Effect.catchAll((_error) => Effect.succeed(null as string | null)), + ), + ); + + assertEquals(url, UBUNTU_ISO_URL); +}); + +Deno.test("Test valid Ubuntu Image Name (Cloud)", () => { + const url = Effect.runSync( + pipe( + constructUbuntuImageURL("ubuntu", true), + Effect.catchAll((_error) => Effect.succeed(null as string | null)), + ), + ); + + assertEquals(url, UBUNTU_CLOUD_IMG_URL); +});