diff --git a/main.ts b/main.ts index 2659e66..b07c34a 100755 --- a/main.ts +++ b/main.ts @@ -106,6 +106,10 @@ if (import.meta.main) { "--cloud", "Use cloud-init for initial configuration (only for compatible images)", ) + .option( + "--seed ", + "Path to cloud-init seed image (ISO format)", + ) .example( "Create a default VM configuration file", "vmx init", @@ -146,6 +150,23 @@ if (import.meta.main) { const program = Effect.gen(function* () { let isoPath: string | null = null; + if (options.seed) { + const seedExists = yield* pipe( + fileExists(options.seed), + Effect.map(() => true), + Effect.catchAll(() => Effect.succeed(false)), + ); + if (!seedExists) { + console.error(`Seed file ${options.seed} does not exist.`); + console.log( + `Please run ${ + chalk.greenBright(`vmx seed`) + } to create a seed image.`, + ); + Deno.exit(1); + } + } + if (input) { const [image, archivePath] = yield* Effect.all([ getImage(input), @@ -221,7 +242,7 @@ if (import.meta.main) { } const fedoraImageURL = yield* pipe( - constructFedoraImageURL(input), + constructFedoraImageURL(input, options.cloud), Effect.catchAll(() => Effect.succeed(null)), ); @@ -469,6 +490,10 @@ if (import.meta.main) { "-v, --volume ", "Name of the volume to attach to the VM, will be created if it doesn't exist", ) + .option( + "--seed ", + "Path to cloud-init seed image (ISO format)", + ) .action(async (options: unknown, vmName: string) => { await start(vmName, Boolean((options as { detach: boolean }).detach)); }) @@ -607,6 +632,10 @@ if (import.meta.main) { "-s, --size ", "Size of the volume to create if it doesn't exist (e.g., 20G)", ) + .option( + "--seed ", + "Path to cloud-init seed image (ISO format)", + ) .action(async (_options: unknown, image: string) => { await run(image); }) @@ -638,8 +667,9 @@ if (import.meta.main) { "seed", "Seed initial cloud-init user-data and meta-data files for the VM", ) - .action(async () => { - await seed(); + .arguments("[path:string]") + .action(async (_options: unknown, path?: string) => { + await seed(path); }) .parse(Deno.args); } diff --git a/src/api/machines.ts b/src/api/machines.ts index 468e781..479f1e4 100644 --- a/src/api/machines.ts +++ b/src/api/machines.ts @@ -88,6 +88,7 @@ app.post("/", (c) => drivePath: volume ? volume.path : image.path, version: image.tag ?? DEFAULT_VERSION, status: "STOPPED", + seed: params.seed, pid: 0, }); diff --git a/src/config.ts b/src/config.ts index 03331aa..19aefc6 100644 --- a/src/config.ts +++ b/src/config.ts @@ -3,8 +3,8 @@ import _ from "@es-toolkit/es-toolkit/compat"; import * as toml from "@std/toml"; import z from "@zod/zod"; import { Data, Effect } from "effect"; -import type { Options } from "./utils.ts"; import { UBUNTU_ISO_URL } from "./constants.ts"; +import type { Options } from "./utils.ts"; export const VmConfigSchema = z.object({ vm: z @@ -124,5 +124,7 @@ export const mergeConfig = ( bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string, size: _.get(flags, "size", defaultConfig.vm.size!) as string, install: flags.install, + detach: flags.detach || defaultConfig.options.detach!, + seed: _.get(flags, "seed", options.seed) as string | undefined, }); }; diff --git a/src/constants.ts b/src/constants.ts index 0d69e55..8b41622 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -21,6 +21,10 @@ export const NIXOS_ISO_URL: string = 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`; +export const FEDORA_CLOUD_IMG_URL: string = Deno.build.arch === "aarch64" + ? "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-43-1.6.aarch64.qcow2" + : "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2"; + export const GENTOO_IMG_URL: string = Deno.build.arch === "aarch64" ? "https://distfiles.gentoo.org/releases/arm64/autobuilds/20251116T233105Z/di-arm64-console-20251116T233105Z.qcow2" : "https://distfiles.gentoo.org/releases/amd64/autobuilds/20251116T161545Z/di-amd64-console-20251116T161545Z.qcow2"; diff --git a/src/db.ts b/src/db.ts index 33ba49a..3c1aa27 100644 --- a/src/db.ts +++ b/src/db.ts @@ -36,6 +36,7 @@ export type VirtualMachine = { status: STATUS; pid: number; volume?: string; + seed?: string; createdAt?: string; updatedAt?: string; }; diff --git a/src/migrations.ts b/src/migrations.ts index 6deda7f..1e4d1a3 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -95,10 +95,7 @@ migrations["004"] = { }, async down(db: Kysely): Promise { - await db.schema - .alterTable("images") - .dropColumn("format") - .execute(); + await db.schema.alterTable("images").dropColumn("format").execute(); }, }; @@ -211,10 +208,7 @@ migrations["007"] = { .execute(); }, async down(db: Kysely): Promise { - await db.schema - .alterTable("images") - .dropColumn("digest") - .execute(); + await db.schema.alterTable("images").dropColumn("digest").execute(); }, }; @@ -292,6 +286,19 @@ migrations["010"] = { }, }; +migrations["011"] = { + async up(db: Kysely): Promise { + await db.schema + .alterTable("virtual_machines") + .addColumn("seed", "varchar") + .execute(); + }, + + async down(db: Kysely): Promise { + await db.schema.alterTable("virtual_machines").dropColumn("seed").execute(); + }, +}; + export const migrateToLatest = async (db: Database): Promise => { const migrator = new Migrator({ db, provider: migrationProvider }); const { error } = await migrator.migrateToLatest(); diff --git a/src/subcommands/restart.ts b/src/subcommands/restart.ts index 381e61a..23f9bc3 100644 --- a/src/subcommands/restart.ts +++ b/src/subcommands/restart.ts @@ -6,11 +6,15 @@ import type { VirtualMachine } from "../db.ts"; import { getInstanceState, updateInstanceState } from "../state.ts"; import { safeKillQemu, + setupAlmaLinuxArgs, setupAlpineArgs, setupCoreOSArgs, setupDebianArgs, + setupFedoraArgs, setupFirmwareFilesIfNeeded, + setupGentooArgs, setupNATNetworkArgs, + setupRockyLinuxArgs, setupUbuntuArgs, } from "../utils.ts"; @@ -63,26 +67,60 @@ const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { : "qemu-system-x86_64"; 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)); + let alpineArgs: string[] = Effect.runSync( + setupAlpineArgs(vm.isoPath, vm.seed), + ); + let debianArgs: string[] = Effect.runSync( + setupDebianArgs(vm.isoPath, vm.seed), + ); + let ubuntuArgs: string[] = Effect.runSync( + setupUbuntuArgs(vm.isoPath, vm.seed), + ); + let almalinuxArgs: string[] = Effect.runSync( + setupAlmaLinuxArgs(vm.isoPath, vm.seed), + ); + let rockylinuxArgs: string[] = Effect.runSync( + setupRockyLinuxArgs(vm.isoPath, vm.seed), + ); + let gentooArgs: string[] = Effect.runSync( + setupGentooArgs(vm.isoPath, vm.seed), + ); + let fedoraArgs: string[] = Effect.runSync( + setupFedoraArgs(vm.isoPath, vm.seed), + ); if (coreosArgs.length > 0) { coreosArgs = coreosArgs.slice(2); } - if (alpineArgs.length > 0) { + if (alpineArgs.length > 2) { alpineArgs = alpineArgs.slice(2); } - if (debianArgs.length > 0) { + if (debianArgs.length > 2) { debianArgs = debianArgs.slice(2); } - if (ubuntuArgs.length > 0) { + if (ubuntuArgs.length > 2) { ubuntuArgs = ubuntuArgs.slice(2); } + if (almalinuxArgs.length > 2) { + almalinuxArgs = almalinuxArgs.slice(2); + } + + if (rockylinuxArgs.length > 2) { + rockylinuxArgs = rockylinuxArgs.slice(2); + } + + if (gentooArgs.length > 2) { + gentooArgs = gentooArgs.slice(2); + } + + if (fedoraArgs.length > 2) { + fedoraArgs = fedoraArgs.slice(2); + } + return Effect.succeed([ ..._.compact([vm.bridge && qemu]), ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), @@ -118,6 +156,12 @@ const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { ...alpineArgs, ...debianArgs, ...ubuntuArgs, + ...almalinuxArgs, + ...rockylinuxArgs, + ...gentooArgs, + ...fedoraArgs, + ...(vm.seed ? ["-drive", `if=virtio,file=${vm.seed},media=cdrom`] : []), + ...(vm.volume ? [] : ["-snapshot"]), ]); }; diff --git a/src/subcommands/run.ts b/src/subcommands/run.ts index 671b22a..7c38bff 100644 --- a/src/subcommands/run.ts +++ b/src/subcommands/run.ts @@ -92,5 +92,6 @@ function mergeFlags(image: Image): Options { install: false, diskFormat: image.format, volume: flags.volume || flags.v, + seed: flags.seed ? Deno.realPathSync(flags.seed) : undefined, }; } diff --git a/src/subcommands/seed.ts b/src/subcommands/seed.ts index 32de897..bfb2824 100644 --- a/src/subcommands/seed.ts +++ b/src/subcommands/seed.ts @@ -2,68 +2,69 @@ import { Input } from "@cliffy/prompt"; import { Effect } from "effect"; import { createSeedIso } from "../xorriso.ts"; -const seed = Effect.gen(function* () { - const { instanceId, localHostname, name, shell, sudo, sshAuthorizedKeys } = - yield* Effect.promise(async () => { - const instanceId: string = await Input.prompt({ - message: "Instance ID", - minLength: 5, - }); +const seed = (path: string = "seed.iso") => + Effect.gen(function* () { + const { instanceId, localHostname, name, shell, sudo, sshAuthorizedKeys } = + yield* Effect.promise(async () => { + const instanceId: string = await Input.prompt({ + message: "Instance ID", + minLength: 5, + }); - const localHostname: string = await Input.prompt({ - message: "Local Hostname", - minLength: 3, - }); + const localHostname: string = await Input.prompt({ + message: "Local Hostname", + minLength: 3, + }); - const name = await Input.prompt({ - message: "Default User", - minLength: 3, - }); + const name = await Input.prompt({ + message: "Default User", + minLength: 3, + }); - const shell = await Input.prompt({ - message: "User Shell", - default: "/bin/bash", - }); + const shell = await Input.prompt({ + message: "User Shell", + default: "/bin/bash", + }); - const sudo = await Input.prompt({ - message: "Sudo", - default: "ALL=(ALL) NOPASSWD:ALL", - }); + const sudo = await Input.prompt({ + message: "Sudo", + default: "ALL=(ALL) NOPASSWD:ALL", + }); - const sshAuthorizedKeys = await Input.prompt({ - message: "SSH Authorized Keys (comma separated)", - }); + const sshAuthorizedKeys = await Input.prompt({ + message: "SSH Authorized Keys (comma separated)", + }); - return { + return { + instanceId, + localHostname, + name, + shell, + sudo, + sshAuthorizedKeys, + }; + }); + yield* createSeedIso(path, { + metaData: { instanceId, localHostname, - name, - shell, - sudo, - sshAuthorizedKeys, - }; + }, + userData: { + users: [ + { + name, + shell, + sudo: [sudo], + sshAuthorizedKeys: sshAuthorizedKeys + .split(",") + .map((key) => key.trim()), + }, + ], + sshPwauth: false, + }, }); - yield* createSeedIso({ - metaData: { - instanceId, - localHostname, - }, - userData: { - users: [ - { - name, - shell, - sudo: [sudo], - sshAuthorizedKeys: sshAuthorizedKeys - .split(",") - .map((key) => key.trim()), - }, - ], - sshPwauth: false, - }, }); -}); -export default async function () { - await Effect.runPromise(seed); +export default async function (path?: string) { + await Effect.runPromise(seed(path)); } diff --git a/src/subcommands/start.ts b/src/subcommands/start.ts index 56d25ee..e788713 100644 --- a/src/subcommands/start.ts +++ b/src/subcommands/start.ts @@ -6,10 +6,16 @@ import type { VirtualMachine, Volume } from "../db.ts"; import { getImage } from "../images.ts"; import { getInstanceState, updateInstanceState } from "../state.ts"; import { + setupAlmaLinuxArgs, setupAlpineArgs, setupCoreOSArgs, + setupDebianArgs, + setupFedoraArgs, setupFirmwareFilesIfNeeded, + setupGentooArgs, setupNATNetworkArgs, + setupRockyLinuxArgs, + setupUbuntuArgs, } from "../utils.ts"; import { createVolume, getVolume } from "../volumes.ts"; @@ -50,16 +56,60 @@ export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { : "qemu-system-x86_64"; let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); - let alpineArgs: string[] = Effect.runSync(setupAlpineArgs(vm.isoPath)); + let alpineArgs: string[] = Effect.runSync( + setupAlpineArgs(vm.isoPath, vm.seed), + ); + let debianArgs: string[] = Effect.runSync( + setupDebianArgs(vm.isoPath, vm.seed), + ); + let ubuntuArgs: string[] = Effect.runSync( + setupUbuntuArgs(vm.isoPath, vm.seed), + ); + let almalinuxArgs: string[] = Effect.runSync( + setupAlmaLinuxArgs(vm.isoPath, vm.seed), + ); + let rockylinuxArgs: string[] = Effect.runSync( + setupRockyLinuxArgs(vm.isoPath, vm.seed), + ); + let gentooArgs: string[] = Effect.runSync( + setupGentooArgs(vm.isoPath, vm.seed), + ); + let fedoraArgs: string[] = Effect.runSync( + setupFedoraArgs(vm.isoPath, vm.seed), + ); if (coreosArgs.length > 0) { coreosArgs = coreosArgs.slice(2); } - if (alpineArgs.length > 0) { + if (alpineArgs.length > 2) { alpineArgs = alpineArgs.slice(2); } + if (debianArgs.length > 2) { + debianArgs = debianArgs.slice(2); + } + + if (ubuntuArgs.length > 2) { + ubuntuArgs = ubuntuArgs.slice(2); + } + + if (almalinuxArgs.length > 2) { + almalinuxArgs = almalinuxArgs.slice(2); + } + + if (rockylinuxArgs.length > 2) { + rockylinuxArgs = rockylinuxArgs.slice(2); + } + + if (gentooArgs.length > 2) { + gentooArgs = gentooArgs.slice(2); + } + + if (fedoraArgs.length > 2) { + fedoraArgs = fedoraArgs.slice(2); + } + return Effect.succeed([ ..._.compact([vm.bridge && qemu]), ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), @@ -93,6 +143,13 @@ export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { ), ...coreosArgs, ...alpineArgs, + ...debianArgs, + ...ubuntuArgs, + ...almalinuxArgs, + ...rockylinuxArgs, + ...gentooArgs, + ...fedoraArgs, + ...(vm.seed ? ["-drive", `if=virtio,file=${vm.seed},media=cdrom`] : []), ...(vm.volume ? [] : ["-snapshot"]), ]); }; @@ -355,5 +412,6 @@ function mergeFlags(vm: VirtualMachine): VirtualMachine { diskSize: flags.size || flags.s ? String(flags.size || flags.s) : vm.diskSize, + seed: flags.seed ? String(flags.seed) : vm.seed, }; } diff --git a/src/types.ts b/src/types.ts index c3b8e41..586dde2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -6,7 +6,10 @@ export const MachineParamsSchema = z.object({ portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), cpu: z.string().optional(), cpus: z.number().min(1).optional(), - memory: z.string().regex(/^\d+(M|G)$/).optional(), + memory: z + .string() + .regex(/^\d+(M|G)$/) + .optional(), }); export type MachineParams = z.infer; @@ -15,22 +18,34 @@ export const NewMachineSchema = MachineParamsSchema.extend({ portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), cpu: z.string().default("host").optional(), cpus: z.number().min(1).default(8).optional(), - memory: z.string().regex(/^\d+(M|G)$/).default("2G").optional(), - image: z.string().regex( - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, - ), + memory: z + .string() + .regex(/^\d+(M|G)$/) + .default("2G") + .optional(), + image: z + .string() + .regex( + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, + ), volume: z.string().optional(), bridge: z.string().optional(), + seed: z.string().optional(), }); export type NewMachine = z.infer; export const NewVolumeSchema = z.object({ name: z.string(), - baseImage: z.string().regex( - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, - ), - size: z.string().regex(/^\d+(M|G|T)$/).optional(), + baseImage: z + .string() + .regex( + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, + ), + size: z + .string() + .regex(/^\d+(M|G|T)$/) + .optional(), }); export type NewVolume = z.infer; diff --git a/src/utils.ts b/src/utils.ts index 686b562..b39920b 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -12,6 +12,7 @@ import { DEBIAN_DEFAULT_VERSION, DEBIAN_ISO_URL, EMPTY_DISK_THRESHOLD_KB, + FEDORA_CLOUD_IMG_URL, FEDORA_COREOS_DEFAULT_VERSION, FEDORA_COREOS_IMG_URL, FEDORA_IMG_URL, @@ -43,6 +44,7 @@ export interface Options { install?: boolean; volume?: string; cloud?: boolean; + seed?: string; } class LogCommandError extends Data.TaggedError("LogCommandError")<{ @@ -76,7 +78,7 @@ export const getCurrentArch = (): string => { export const isValidISOurl = (url?: string): boolean => { return Boolean( (url?.startsWith("http://") || url?.startsWith("https://")) && - url?.endsWith(".iso"), + url?.endsWith(".iso") ); }; @@ -102,7 +104,7 @@ export const humanFileSize = (blocks: number) => }); export const validateImage = ( - image: string, + image: string ): Effect.Effect => { const regex = /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/; @@ -113,7 +115,7 @@ export const validateImage = ( image, cause: "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.", - }), + }) ); } return Effect.succeed(image); @@ -122,18 +124,18 @@ export const validateImage = ( export const extractTag = (name: string) => pipe( validateImage(name), - Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")), + Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")) ); export const failOnMissingImage = ( - image: Image | undefined, + image: Image | undefined ): Effect.Effect => image ? Effect.succeed(image) : Effect.fail(new NoSuchImageError({ cause: "No such image" })); export const du = ( - path: string, + path: string ): Effect.Effect => Effect.tryPromise({ try: async () => { @@ -165,7 +167,7 @@ export const emptyDiskImage = (path: string) => exists ? Effect.succeed(true) : du(path).pipe(Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB)) - ), + ) ); export const downloadIso = (url: string, options: Options) => @@ -187,8 +189,8 @@ export const downloadIso = (url: string, options: Options) => if (driveSize > EMPTY_DISK_THRESHOLD_KB) { console.log( chalk.yellowBright( - `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`, - ), + `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.` + ) ); return null; } @@ -206,15 +208,21 @@ export const downloadIso = (url: string, options: Options) => if (outputExists) { console.log( chalk.yellowBright( - `File ${outputPath} already exists, skipping download.`, - ), + `File ${outputPath} already exists, skipping download.` + ) ); return outputPath; } yield* Effect.tryPromise({ try: async () => { - console.log(chalk.blueBright(`Downloading ISO from ${url}...`)); + console.log( + chalk.blueBright( + `Downloading ${ + url.endsWith(".iso") ? "ISO" : "image" + } from ${url}...` + ) + ); const cmd = new Deno.Command("curl", { args: ["-L", "-o", outputPath, url], stdin: "inherit", @@ -256,8 +264,8 @@ export const setupFirmwareFilesIfNeeded = () => if (!success) { console.error( chalk.redBright( - "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.", - ), + "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew." + ) ); Deno.exit(1); } @@ -270,7 +278,7 @@ export const setupFirmwareFilesIfNeeded = () => try: () => Deno.copyFile( `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, - edk2VarsAarch64, + edk2VarsAarch64 ), catch: (error) => new LogCommandError({ cause: error }), }); @@ -315,13 +323,13 @@ export const setupCoreOSArgs = (imagePath?: string | null) => const configOK = yield* pipe( fileExists("config.ign"), Effect.flatMap(() => Effect.succeed(true)), - Effect.catchAll(() => Effect.succeed(false)), + Effect.catchAll(() => Effect.succeed(false)) ); if (!configOK) { console.error( chalk.redBright( - "CoreOS image requires a config.ign file in the current directory.", - ), + "CoreOS image requires a config.ign file in the current directory." + ) ); Deno.exit(1); } @@ -337,35 +345,47 @@ export const setupCoreOSArgs = (imagePath?: string | null) => return []; }); -export const setupFedoraArgs = (imagePath?: string | null) => +export const setupFedoraArgs = (imagePath?: string | null, seed?: string) => Effect.sync(() => { if ( imagePath && imagePath.endsWith(".qcow2") && - imagePath.includes("Fedora-Server") + (imagePath.includes("Fedora-Server") || + imagePath.includes("Fedora-Cloud")) ) { - return ["-drive", `file=${imagePath},format=qcow2,if=virtio`]; + return [ + "-drive", + `file=${imagePath},format=qcow2,if=virtio`, + ...(seed ? ["-drive", `if=virtio,file=${seed},media=cdrom`] : []), + ]; } return []; }); -export const setupGentooArgs = (imagePath?: string | null) => +export const setupGentooArgs = (imagePath?: string | null, seed?: string) => Effect.sync(() => { if ( imagePath && imagePath.endsWith(".qcow2") && imagePath.startsWith( - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` ) ) { - return ["-drive", `file=${imagePath},format=qcow2,if=virtio`]; + return [ + "-drive", + `file=${imagePath},format=qcow2,if=virtio`, + ...(seed ? ["-drive", `if=virtio,file=${seed},media=cdrom`] : []), + ]; } return []; }); -export const setupAlpineArgs = (imagePath?: string | null) => +export const setupAlpineArgs = ( + imagePath?: string | null, + seed: string = "seed.iso" +) => Effect.sync(() => { if ( imagePath && @@ -376,14 +396,17 @@ export const setupAlpineArgs = (imagePath?: string | null) => "-drive", `file=${imagePath},format=qcow2,if=virtio`, "-drive", - "if=virtio,file=seed.iso,media=cdrom", + `if=virtio,file=${seed},media=cdrom`, ]; } return []; }); -export const setupDebianArgs = (imagePath?: string | null) => +export const setupDebianArgs = ( + imagePath?: string | null, + seed: string = "seed.iso" +) => Effect.sync(() => { if ( imagePath && @@ -394,14 +417,17 @@ export const setupDebianArgs = (imagePath?: string | null) => "-drive", `file=${imagePath},format=qcow2,if=virtio`, "-drive", - "if=virtio,file=seed.iso,media=cdrom", + `if=virtio,file=${seed},media=cdrom`, ]; } return []; }); -export const setupUbuntuArgs = (imagePath?: string | null) => +export const setupUbuntuArgs = ( + imagePath?: string | null, + seed: string = "seed.iso" +) => Effect.sync(() => { if ( imagePath && @@ -412,14 +438,17 @@ export const setupUbuntuArgs = (imagePath?: string | null) => "-drive", `file=${imagePath},format=qcow2,if=virtio`, "-drive", - "if=virtio,file=seed.iso,media=cdrom", + `if=virtio,file=${seed},media=cdrom`, ]; } return []; }); -export const setupAlmaLinuxArgs = (imagePath?: string | null) => +export const setupAlmaLinuxArgs = ( + imagePath?: string | null, + seed: string = "seed.iso" +) => Effect.sync(() => { if ( imagePath && @@ -430,14 +459,17 @@ export const setupAlmaLinuxArgs = (imagePath?: string | null) => "-drive", `file=${imagePath},format=qcow2,if=virtio`, "-drive", - "if=virtio,file=seed.iso,media=cdrom", + `if=virtio,file=${seed},media=cdrom`, ]; } return []; }); -export const setupRockyLinuxArgs = (imagePath?: string | null) => +export const setupRockyLinuxArgs = ( + imagePath?: string | null, + seed: string = "seed.iso" +) => Effect.sync(() => { if ( imagePath && @@ -448,7 +480,7 @@ export const setupRockyLinuxArgs = (imagePath?: string | null) => "-drive", `file=${imagePath},format=qcow2,if=virtio`, "-drive", - "if=virtio,file=seed.iso,media=cdrom", + `if=virtio,file=${seed},media=cdrom`, ]; } @@ -459,22 +491,40 @@ export const runQemu = (isoPath: string | null, options: Options) => Effect.gen(function* () { const macAddress = yield* generateRandomMacAddress(); - const qemu = Deno.build.arch === "aarch64" - ? "qemu-system-aarch64" - : "qemu-system-x86_64"; + const qemu = + Deno.build.arch === "aarch64" + ? "qemu-system-aarch64" + : "qemu-system-x86_64"; const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); - 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); + let fedoraArgs: string[] = yield* setupFedoraArgs( + isoPath || options.image, + options.seed + ); + let gentooArgs: string[] = yield* setupGentooArgs( + isoPath || options.image, + options.seed + ); + let alpineArgs: string[] = yield* setupAlpineArgs( + isoPath || options.image, + options.seed + ); + let debianArgs: string[] = yield* setupDebianArgs( + isoPath || options.image, + options.seed + ); + let ubuntuArgs: string[] = yield* setupUbuntuArgs( + isoPath || options.image, + options.seed + ); let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( isoPath || options.image, + options.seed ); let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( isoPath || options.image, + options.seed ); if (coreosArgs.length > 0 && !isoPath) { @@ -547,7 +597,7 @@ export const runQemu = (isoPath: string | null, options: Options) => options.image && [ "-drive", `file=${options.image},format=${options.diskFormat},if=virtio`, - ], + ] ), ]; @@ -562,11 +612,9 @@ export const runQemu = (isoPath: string | null, options: Options) => const logPath = `${LOGS_DIR}/${name}.log`; const fullCommand = options.bridge - ? `sudo ${qemu} ${ - qemuArgs + ? `sudo ${qemu} ${qemuArgs .slice(1) - .join(" ") - } >> "${logPath}" 2>&1 & echo $!` + .join(" ")} >> "${logPath}" 2>&1 & echo $!` : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; const { stdout } = yield* Effect.tryPromise({ @@ -592,7 +640,8 @@ export const runQemu = (isoPath: string | null, options: Options) => cpus: options.cpus, cpu: options.cpu, diskSize: options.size || "20G", - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || + diskFormat: + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || options.diskFormat || "raw", portForward: options.portForward, @@ -608,10 +657,11 @@ export const runQemu = (isoPath: string | null, options: Options) => version: DEFAULT_VERSION, status: "RUNNING", pid: qemuPid, + seed: options.seed, }); console.log( - `Virtual machine ${name} started in background (PID: ${qemuPid})`, + `Virtual machine ${name} started in background (PID: ${qemuPid})` ); console.log(`Logs will be written to: ${logPath}`); @@ -634,7 +684,8 @@ export const runQemu = (isoPath: string | null, options: Options) => cpus: options.cpus, cpu: options.cpu, diskSize: options.size || "20G", - diskFormat: (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || + diskFormat: + (isoPath?.endsWith(".qcow2") ? "qcow2" : undefined) || options.diskFormat || "raw", portForward: options.portForward, @@ -650,6 +701,7 @@ export const runQemu = (isoPath: string | null, options: Options) => version: DEFAULT_VERSION, status: "RUNNING", pid: cmd.pid, + seed: options.seed ? Deno.realPathSync(options.seed) : undefined, }); const status = yield* Effect.tryPromise({ @@ -742,8 +794,8 @@ export const createDriveImageIfNeeded = ({ if (pathExists) { console.log( chalk.yellowBright( - `Drive image ${path} already exists, skipping creation.`, - ), + `Drive image ${path} already exists, skipping creation.` + ) ); return; } @@ -770,7 +822,7 @@ export const createDriveImageIfNeeded = ({ }); export const fileExists = ( - path: string, + path: string ): Effect.Effect => Effect.try({ try: () => Deno.statSync(path), @@ -778,7 +830,7 @@ export const fileExists = ( }); export const constructCoreOSImageURL = ( - image: string, + image: string ): Effect.Effect => { // detect with regex if image matches coreos pattern: fedora-coreos or fedora-coreos- or coreos or coreos- const coreosRegex = /^(fedora-coreos|coreos)(-(\d+\.\d+\.\d+\.\d+))?$/; @@ -786,7 +838,7 @@ export const constructCoreOSImageURL = ( if (match) { const version = match[3] || FEDORA_COREOS_DEFAULT_VERSION; return Effect.succeed( - FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version), + FEDORA_COREOS_IMG_URL.replaceAll(FEDORA_COREOS_DEFAULT_VERSION, version) ); } @@ -794,7 +846,7 @@ export const constructCoreOSImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match CoreOS naming conventions.", - }), + }) ); }; @@ -823,7 +875,7 @@ export const extractXz = (path: string | null) => }); export const constructNixOSImageURL = ( - image: string, + image: string ): Effect.Effect => { // detect with regex if image matches NixOS pattern: nixos or nixos- const nixosRegex = /^(nixos)(-(\d+\.\d+))?$/; @@ -831,7 +883,7 @@ export const constructNixOSImageURL = ( if (match) { const version = match[3] || NIXOS_DEFAULT_VERSION; return Effect.succeed( - NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version), + NIXOS_ISO_URL.replaceAll(NIXOS_DEFAULT_VERSION, version) ); } @@ -839,30 +891,31 @@ export const constructNixOSImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match NixOS naming conventions.", - }), + }) ); }; export const constructFedoraImageURL = ( image: string, + cloud: boolean = false ): Effect.Effect => { // detect with regex if image matches Fedora pattern: fedora const fedoraRegex = /^(fedora)$/; const match = image.match(fedoraRegex); if (match) { - return Effect.succeed(FEDORA_IMG_URL); + return Effect.succeed(cloud ? FEDORA_CLOUD_IMG_URL : FEDORA_IMG_URL); } return Effect.fail( new InvalidImageNameError({ image, cause: "Image name does not match Fedora naming conventions.", - }), + }) ); }; export const constructGentooImageURL = ( - image: string, + image: string ): Effect.Effect => { // detect with regex if image matches genroo pattern: gentoo-20251116T161545Z or gentoo const gentooRegex = /^(gentoo)(-(\d{8}T\d{6}Z))?$/; @@ -871,8 +924,8 @@ export const constructGentooImageURL = ( return Effect.succeed( GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( "20251116T233105Z", - match[3], - ), + match[3] + ) ); } @@ -884,13 +937,13 @@ export const constructGentooImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match Gentoo naming conventions.", - }), + }) ); }; export const constructDebianImageURL = ( image: string, - cloud: boolean = false, + cloud: boolean = false ): Effect.Effect => { if (cloud && image === "debian") { return Effect.succeed(DEBIAN_CLOUD_IMG_URL); @@ -901,7 +954,7 @@ export const constructDebianImageURL = ( const match = image.match(debianRegex); if (match?.[3]) { return Effect.succeed( - DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]), + DEBIAN_ISO_URL.replaceAll(DEBIAN_DEFAULT_VERSION, match[3]) ); } @@ -913,19 +966,19 @@ export const constructDebianImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match Debian naming conventions.", - }), + }) ); }; export const constructAlpineImageURL = ( - image: string, + image: string ): Effect.Effect => { // detect with regex if image matches alpine pattern: alpine- or alpine const alpineRegex = /^(alpine)(-(\d+\.\d+(\.\d+)?))?$/; const match = image.match(alpineRegex); if (match?.[3]) { return Effect.succeed( - ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]), + ALPINE_ISO_URL.replaceAll(ALPINE_DEFAULT_VERSION, match[3]) ); } @@ -937,13 +990,13 @@ export const constructAlpineImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match Alpine naming conventions.", - }), + }) ); }; export const constructUbuntuImageURL = ( image: string, - cloud: boolean = false, + cloud: boolean = false ): Effect.Effect => { // detect with regex if image matches ubuntu pattern: ubuntu const ubuntuRegex = /^(ubuntu)$/; @@ -959,13 +1012,13 @@ export const constructUbuntuImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match Ubuntu naming conventions.", - }), + }) ); }; export const constructAlmaLinuxImageURL = ( image: string, - cloud: boolean = false, + cloud: boolean = false ): Effect.Effect => { // detect with regex if image matches almalinux pattern: ubuntu const almaLinuxRegex = /^(almalinux)$/; @@ -981,13 +1034,13 @@ export const constructAlmaLinuxImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match AlmaLinux naming conventions.", - }), + }) ); }; export const constructRockyLinuxImageURL = ( image: string, - cloud: boolean = false, + cloud: boolean = false ): Effect.Effect => { // detect with regex if image matches rockylinux pattern: ubuntu const rockyLinuxRegex = /^(rockylinux)$/; @@ -1003,6 +1056,6 @@ export const constructRockyLinuxImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match RockyLinux naming conventions.", - }), + }) ); }; diff --git a/src/xorriso.ts b/src/xorriso.ts index d5e11f9..f75d86c 100644 --- a/src/xorriso.ts +++ b/src/xorriso.ts @@ -77,101 +77,105 @@ const writeUserData = (seed: Seed) => catch: (error) => new FileSystemError(error), }); -const runXorriso = Effect.tryPromise({ - try: async () => { - const xorriso = new Deno.Command("xorriso", { - args: [ - "-as", - "mkisofs", - "-o", - "seed.iso", - "-V", - "cidata", - "-J", - "-R", - "seed", - ], - stdout: "inherit", - stderr: "inherit", - }).spawn(); - - const status = await xorriso.status; - - if (!status.success) { - throw new XorrisoError( - status.code, - `xorriso failed with code ${status.code}. Please ensure ${ - chalk.green( - "xorriso", - ) - } is installed and accessible in your PATH.`, +const runXorriso = (outputPath: string) => + Effect.tryPromise({ + try: async () => { + const xorriso = new Deno.Command("xorriso", { + args: [ + "-as", + "mkisofs", + "-o", + outputPath, + "-V", + "cidata", + "-J", + "-R", + "seed", + ], + stdout: "inherit", + stderr: "inherit", + }).spawn(); + + const status = await xorriso.status; + + if (!status.success) { + throw new XorrisoError( + status.code, + `xorriso failed with code ${status.code}. Please ensure ${ + chalk.green( + "xorriso", + ) + } is installed and accessible in your PATH.`, + ); + } + + return status; + }, + catch: (error) => { + if (error instanceof XorrisoError) return error; + return new XorrisoError( + null, + `Unexpected error: ${ + error instanceof Error ? error.message : String(error) + }`, ); - } - - return status; - }, - catch: (error) => { - if (error instanceof XorrisoError) return error; - return new XorrisoError( - null, - `Unexpected error: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - }, -}); + }, + }); -const runGenisoimage = Effect.tryPromise({ - try: async () => { - const genisoimage = new Deno.Command("genisoimage", { - args: [ - "-output", - "seed.iso", - "-volid", - "cidata", - "-joliet", - "-rock", - "seed", - ], - stdout: "inherit", - stderr: "inherit", - }).spawn(); - - const status = await genisoimage.status; - - if (!status.success) { - throw new XorrisoError( - status.code, - `genisoimage failed with code ${status.code}. Please ensure ${ - chalk.green( - "genisoimage", - ) - } is installed and accessible in your PATH.`, +const runGenisoimage = (outputPath: string) => + Effect.tryPromise({ + try: async () => { + const genisoimage = new Deno.Command("genisoimage", { + args: [ + "-output", + outputPath, + "-volid", + "cidata", + "-joliet", + "-rock", + "seed", + ], + stdout: "inherit", + stderr: "inherit", + }).spawn(); + + const status = await genisoimage.status; + + if (!status.success) { + throw new XorrisoError( + status.code, + `genisoimage failed with code ${status.code}. Please ensure ${ + chalk.green( + "genisoimage", + ) + } is installed and accessible in your PATH.`, + ); + } + + return status; + }, + catch: (error) => { + if (error instanceof XorrisoError) return error; + return new XorrisoError( + null, + `Unexpected error: ${ + error instanceof Error ? error.message : String(error) + }`, ); - } - - return status; - }, - catch: (error) => { - if (error instanceof XorrisoError) return error; - return new XorrisoError( - null, - `Unexpected error: ${ - error instanceof Error ? error.message : String(error) - }`, - ); - }, -}); + }, + }); -export const createSeedIso = (seed: Seed) => +export const createSeedIso = (outputPath: string, seed: Seed) => pipe( createSeedDirectory, Effect.flatMap(() => Effect.all([writeMetaData(seed), writeUserData(seed)]) ), - Effect.flatMap(() => - Deno.build.os === "linux" ? runGenisoimage : runXorriso + Effect.flatMap(() => Deno.build.os === "linux" + ? runGenisoimage(outputPath) + : runXorriso(outputPath) ), ); -export default (seed: Seed) => Effect.runPromise(createSeedIso(seed)); +export default (outputPath: string, seed: Seed) => + Effect.runPromise(createSeedIso(outputPath, seed));