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)); -- 2.51.2 From 5db81a69cba690426f7d85e205023a42d65c1d27 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sat, 22 Nov 2025 10:35:44 +0300 Subject: [PATCH 2/4] run format --- src/utils.ts | 141 +++++++++++++++++++++++++-------------------------- 1 file changed, 70 insertions(+), 71 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index b39920b..0869263 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -78,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"), ); }; @@ -104,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._-]+)?$/; @@ -115,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); @@ -124,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 () => { @@ -167,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) => @@ -189,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; } @@ -208,8 +208,8 @@ 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; } @@ -220,8 +220,8 @@ export const downloadIso = (url: string, options: Options) => chalk.blueBright( `Downloading ${ url.endsWith(".iso") ? "ISO" : "image" - } from ${url}...` - ) + } from ${url}...`, + ), ); const cmd = new Deno.Command("curl", { args: ["-L", "-o", outputPath, url], @@ -264,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); } @@ -278,7 +278,7 @@ export const setupFirmwareFilesIfNeeded = () => try: () => Deno.copyFile( `${brewPrefix}/share/qemu/edk2-arm-vars.fd`, - edk2VarsAarch64 + edk2VarsAarch64, ), catch: (error) => new LogCommandError({ cause: error }), }); @@ -323,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); } @@ -369,7 +369,7 @@ export const setupGentooArgs = (imagePath?: string | null, seed?: string) => imagePath && imagePath.endsWith(".qcow2") && imagePath.startsWith( - `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-` + `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, ) ) { return [ @@ -384,7 +384,7 @@ export const setupGentooArgs = (imagePath?: string | null, seed?: string) => export const setupAlpineArgs = ( imagePath?: string | null, - seed: string = "seed.iso" + seed: string = "seed.iso", ) => Effect.sync(() => { if ( @@ -405,7 +405,7 @@ export const setupAlpineArgs = ( export const setupDebianArgs = ( imagePath?: string | null, - seed: string = "seed.iso" + seed: string = "seed.iso", ) => Effect.sync(() => { if ( @@ -426,7 +426,7 @@ export const setupDebianArgs = ( export const setupUbuntuArgs = ( imagePath?: string | null, - seed: string = "seed.iso" + seed: string = "seed.iso", ) => Effect.sync(() => { if ( @@ -447,7 +447,7 @@ export const setupUbuntuArgs = ( export const setupAlmaLinuxArgs = ( imagePath?: string | null, - seed: string = "seed.iso" + seed: string = "seed.iso", ) => Effect.sync(() => { if ( @@ -468,7 +468,7 @@ export const setupAlmaLinuxArgs = ( export const setupRockyLinuxArgs = ( imagePath?: string | null, - seed: string = "seed.iso" + seed: string = "seed.iso", ) => Effect.sync(() => { if ( @@ -491,40 +491,39 @@ 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, - options.seed + options.seed, ); let gentooArgs: string[] = yield* setupGentooArgs( isoPath || options.image, - options.seed + options.seed, ); let alpineArgs: string[] = yield* setupAlpineArgs( isoPath || options.image, - options.seed + options.seed, ); let debianArgs: string[] = yield* setupDebianArgs( isoPath || options.image, - options.seed + options.seed, ); let ubuntuArgs: string[] = yield* setupUbuntuArgs( isoPath || options.image, - options.seed + options.seed, ); let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( isoPath || options.image, - options.seed + options.seed, ); let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( isoPath || options.image, - options.seed + options.seed, ); if (coreosArgs.length > 0 && !isoPath) { @@ -597,7 +596,7 @@ export const runQemu = (isoPath: string | null, options: Options) => options.image && [ "-drive", `file=${options.image},format=${options.diskFormat},if=virtio`, - ] + ], ), ]; @@ -612,9 +611,11 @@ 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({ @@ -640,8 +641,7 @@ 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, @@ -661,7 +661,7 @@ export const runQemu = (isoPath: string | null, options: Options) => }); 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}`); @@ -684,8 +684,7 @@ 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, @@ -794,8 +793,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; } @@ -822,7 +821,7 @@ export const createDriveImageIfNeeded = ({ }); export const fileExists = ( - path: string + path: string, ): Effect.Effect => Effect.try({ try: () => Deno.statSync(path), @@ -830,7 +829,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+))?$/; @@ -838,7 +837,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), ); } @@ -846,7 +845,7 @@ export const constructCoreOSImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match CoreOS naming conventions.", - }) + }), ); }; @@ -875,7 +874,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+))?$/; @@ -883,7 +882,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), ); } @@ -891,13 +890,13 @@ export const constructNixOSImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match NixOS naming conventions.", - }) + }), ); }; export const constructFedoraImageURL = ( image: string, - cloud: boolean = false + cloud: boolean = false, ): Effect.Effect => { // detect with regex if image matches Fedora pattern: fedora const fedoraRegex = /^(fedora)$/; @@ -910,12 +909,12 @@ export const constructFedoraImageURL = ( 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))?$/; @@ -924,8 +923,8 @@ export const constructGentooImageURL = ( return Effect.succeed( GENTOO_IMG_URL.replaceAll("20251116T161545Z", match[3]).replaceAll( "20251116T233105Z", - match[3] - ) + match[3], + ), ); } @@ -937,13 +936,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); @@ -954,7 +953,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]), ); } @@ -966,19 +965,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]), ); } @@ -990,13 +989,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)$/; @@ -1012,13 +1011,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)$/; @@ -1034,13 +1033,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)$/; @@ -1056,6 +1055,6 @@ export const constructRockyLinuxImageURL = ( new InvalidImageNameError({ image, cause: "Image name does not match RockyLinux naming conventions.", - }) + }), ); }; -- 2.51.2 From ccee09d8fba58f9dfff3c0cd2202cc3f984061d9 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sat, 22 Nov 2025 12:44:35 +0300 Subject: [PATCH 3/4] feat: implement seed ISO creation and update related functionality --- src/api/machines.ts | 100 +++++++++++++++++++++++++++++++------------- src/api/utils.ts | 34 +++++++-------- src/constants.ts | 1 + src/types.ts | 59 ++++++++++++++++++++++---- src/xorriso.ts | 77 ++++++++++++++++++---------------- 5 files changed, 182 insertions(+), 89 deletions(-) diff --git a/src/api/machines.ts b/src/api/machines.ts index 479f1e4..44ad489 100644 --- a/src/api/machines.ts +++ b/src/api/machines.ts @@ -1,7 +1,9 @@ +import _ from "@es-toolkit/es-toolkit/compat"; import { createId } from "@paralleldrive/cuid2"; import { Data, Effect, pipe } from "effect"; import { Hono } from "hono"; import Moniker from "moniker"; +import { SEED_DIR } from "../constants.ts"; import { getImage } from "../images.ts"; import { DEFAULT_VERSION, getInstanceState } from "../mod.ts"; import { generateRandomMacAddress } from "../network.ts"; @@ -20,6 +22,7 @@ import { import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts"; import type { NewMachine } from "../types.ts"; import { getVolume } from "../volumes.ts"; +import { createSeedIso } from "../xorriso.ts"; import { createVolumeIfNeeded, handleError, @@ -35,7 +38,7 @@ export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{ }> {} export class RemoveRunningVmError extends Data.TaggedError( - "RemoveRunningVmError", + "RemoveRunningVmError" )<{ id: string; }> {} @@ -49,9 +52,10 @@ app.get("/", (c) => Effect.flatMap((params) => listInstances(params.all === "true" || params.all === "1") ), - presentation(c), - ), - )); + presentation(c) + ) + ) +); app.post("/", (c) => Effect.runPromise( @@ -62,7 +66,7 @@ app.post("/", (c) => const image = yield* getImage(params.image); if (!image) { return yield* Effect.fail( - new ImageNotFoundError({ id: params.image }), + new ImageNotFoundError({ id: params.image }) ); } @@ -70,25 +74,59 @@ app.post("/", (c) => ? yield* createVolumeIfNeeded(image, params.volume) : undefined; + const name = Moniker.choose(); + if (params.users) { + const [tempDir] = yield* Effect.promise(() => + Promise.all([ + Deno.makeTempDir(), + Deno.mkdir(SEED_DIR, { recursive: true }), + ]) + ); + yield* createSeedIso( + `${SEED_DIR}/seed-${name}.iso`, + { + metaData: { + instanceId: params.instanceId || name, + localHostname: params.localHostname || name, + hostname: params.hostname || name, + }, + userData: { + users: params.users.map((user) => ({ + name: user.name, + shell: user.shell, + sudo: user.sudo, + sshAuthorizedKeys: user.sshAuthorizedKeys || [], + })), + sshPwauth: false, + }, + }, + tempDir + ); + } + const macAddress = yield* generateRandomMacAddress(); const id = createId(); yield* saveInstanceState({ id, - name: Moniker.choose(), + name, bridge: params.bridge, macAddress, memory: params.memory || "2G", cpus: params.cpus || 8, cpu: params.cpu || "host", diskSize: "20G", - diskFormat: volume ? "qcow2" : "raw", + diskFormat: volume ? "qcow2" : image.format, portForward: params.portForward ? params.portForward.join(",") : undefined, drivePath: volume ? volume.path : image.path, version: image.tag ?? DEFAULT_VERSION, status: "STOPPED", - seed: params.seed, + seed: _.get( + params, + "seed", + params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined + ), pid: 0, }); @@ -97,18 +135,20 @@ app.post("/", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)), - ), - )); + Effect.catchAll((error) => handleError(error, c)) + ) + ) +); app.get("/:id", (c) => Effect.runPromise( pipe( parseParams(c), Effect.flatMap(({ id }) => getInstanceState(id)), - presentation(c), - ), - )); + presentation(c) + ) + ) +); app.delete("/:id", (c) => Effect.runPromise( @@ -127,9 +167,10 @@ app.delete("/:id", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)), - ), - )); + Effect.catchAll((error) => handleError(error, c)) + ) + ) +); app.post("/:id/start", (c) => Effect.runPromise( @@ -154,7 +195,7 @@ app.post("/:id/start", (c) => ? startRequest.portForward.join(",") : vm.portForward, }, - firmwareArgs, + firmwareArgs ); yield* createLogsDir(); yield* startDetachedQemu(vm.id, vm, qemuArgs); @@ -162,9 +203,10 @@ app.post("/:id/start", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)), - ), - )); + Effect.catchAll((error) => handleError(error, c)) + ) + ) +); app.post("/:id/stop", (c) => Effect.runPromise( @@ -174,9 +216,10 @@ app.post("/:id/stop", (c) => Effect.flatMap(killProcess), Effect.flatMap(updateToStopped), presentation(c), - Effect.catchAll((error) => handleError(error, c)), - ), - )); + Effect.catchAll((error) => handleError(error, c)) + ) + ) +); app.post("/:id/restart", (c) => Effect.runPromise( @@ -203,7 +246,7 @@ app.post("/:id/restart", (c) => ? startRequest.portForward.join(",") : vm.portForward, }, - firmwareArgs, + firmwareArgs ); yield* createLogsDir(); yield* startDetachedQemu(vm.id, vm, qemuArgs); @@ -211,8 +254,9 @@ app.post("/:id/restart", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)), - ), - )); + Effect.catchAll((error) => handleError(error, c)) + ) + ) +); export default app; diff --git a/src/api/utils.ts b/src/api/utils.ts index c7466a7..778848c 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,18 +1,19 @@ import { Data, Effect } from "effect"; import type { Context } from "hono"; +import type { Image, Volume } from "../db.ts"; +import { VmAlreadyRunningError } from "../subcommands/start.ts"; import { type CommandError, StopCommandError, VmNotFoundError, } from "../subcommands/stop.ts"; -import { VmAlreadyRunningError } from "../subcommands/start.ts"; import { MachineParamsSchema, NewMachineSchema, NewVolumeSchema, } from "../types.ts"; -import type { Image, Volume } from "../db.ts"; import { createVolume, getVolume } from "../volumes.ts"; +import { FileSystemError, XorrisoError } from "../xorriso.ts"; import { ImageNotFoundError, RemoveRunningVmError } from "./machines.ts"; export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query()); @@ -36,24 +37,22 @@ export const handleError = ( | VmAlreadyRunningError | ImageNotFoundError | RemoveRunningVmError + | FileSystemError + | XorrisoError | Error, - c: Context, + c: Context ) => Effect.sync(() => { if (error instanceof VmNotFoundError) { - return c.json( - { message: "VM not found", code: "VM_NOT_FOUND" }, - 404, - ); + return c.json({ message: "VM not found", code: "VM_NOT_FOUND" }, 404); } if (error instanceof StopCommandError) { return c.json( { - message: error.message || - `Failed to stop VM ${error.vmName}`, + message: error.message || `Failed to stop VM ${error.vmName}`, code: "STOP_COMMAND_ERROR", }, - 500, + 500 ); } @@ -63,7 +62,7 @@ export const handleError = ( message: error.message || "Failed to parse request body", code: "PARSE_BODY_ERROR", }, - 400, + 400 ); } @@ -73,7 +72,7 @@ export const handleError = ( message: `VM ${error.name} is already running`, code: "VM_ALREADY_RUNNING", }, - 400, + 400 ); } @@ -83,24 +82,23 @@ export const handleError = ( message: `Image ${error.id} not found`, code: "IMAGE_NOT_FOUND", }, - 404, + 404 ); } if (error instanceof RemoveRunningVmError) { return c.json( { - message: - `Cannot remove running VM with ID ${error.id}. Please stop it first.`, + message: `Cannot remove running VM with ID ${error.id}. Please stop it first.`, code: "REMOVE_RUNNING_VM_ERROR", }, - 400, + 400 ); } return c.json( { message: error instanceof Error ? error.message : String(error) }, - 500, + 500 ); }); @@ -133,7 +131,7 @@ export const parseCreateMachineRequest = (c: Context) => export const createVolumeIfNeeded = ( image: Image, volumeName: string, - size?: string, + size?: string ): Effect.Effect => Effect.gen(function* () { const volume = yield* getVolume(volumeName); diff --git a/src/constants.ts b/src/constants.ts index 8b41622..79d5f8a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,6 +5,7 @@ export const EMPTY_DISK_THRESHOLD_KB: number = 100; export const CONFIG_FILE_NAME: string = "vmconfig.toml"; export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`; +export const SEED_DIR: string = `${CONFIG_DIR}/seeds`; export const UBUNTU_ISO_URL: string = Deno.build.arch === "aarch64" ? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso" diff --git a/src/types.ts b/src/types.ts index 586dde2..989f874 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,22 +15,67 @@ export const MachineParamsSchema = z.object({ export type MachineParams = z.infer; export const NewMachineSchema = MachineParamsSchema.extend({ - portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), - cpu: z.string().default("host").optional(), + portForward: z + .array( + z + .string() + .trim() + .regex(/^\d+:\d+$/) + ) + .optional(), + cpu: z.string().trim().default("host").optional(), cpus: z.number().min(1).default(8).optional(), memory: z .string() + .trim() .regex(/^\d+(M|G)$/) .default("2G") .optional(), image: z .string() + .trim() .regex( - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, + /^([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(), + volume: z.string().trim().optional(), + bridge: z.string().trim().optional(), + seed: z.string().trim().optional(), + users: z + .array( + z.object({ + name: z + .string() + .regex(/^[a-zA-Z0-9_-]+$/) + .trim() + .min(1), + shell: z + .string() + .regex( + /^\/(usr\/bin|bin|usr\/local\/bin|usr\/pkg\/bin)\/[a-zA-Z0-9_-]+$/ + ) + .trim() + .default("/bin/bash") + .optional(), + sudo: z + .array(z.string()) + .optional() + .default(["ALL=(ALL) NOPASSWD:ALL"]), + sshAuthorizedKeys: z + .array( + z + .string() + .regex( + /^(ssh-(rsa|ed25519|dss|ecdsa) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?|ecdsa-sha2-nistp(256|384|521) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?)$/ + ) + .trim() + ) + .min(1), + }) + ) + .optional(), + instanceId: z.string().trim().optional(), + localHostname: z.string().trim().optional(), + hostname: z.string().trim().optional(), }); export type NewMachine = z.infer; @@ -40,7 +85,7 @@ export const NewVolumeSchema = z.object({ baseImage: z .string() .regex( - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/ ), size: z .string() diff --git a/src/xorriso.ts b/src/xorriso.ts index f75d86c..fc8f805 100644 --- a/src/xorriso.ts +++ b/src/xorriso.ts @@ -7,6 +7,7 @@ export type Seed = { metaData: { instanceId: string; localHostname: string; + hostname?: string; }; userData: { users: Array<{ @@ -38,7 +39,7 @@ export const snakeCase = (obj: unknown): unknown => { Object.entries(obj).map(([key, value]) => [ _.snakeCase(key), snakeCase(value), - ]), + ]) ); } return obj; @@ -49,35 +50,33 @@ const createSeedDirectory = Effect.tryPromise({ catch: (error) => new FileSystemError(error), }); -const writeMetaData = (seed: Seed) => +const writeMetaData = (seed: Seed, outputPath: string) => Effect.tryPromise({ try: () => Deno.writeTextFile( - "seed/meta-data", + outputPath, stringify(snakeCase(seed.metaData), { flowLevel: -1, lineWidth: -1, - }), + }) ), catch: (error) => new FileSystemError(error), }); -const writeUserData = (seed: Seed) => +const writeUserData = (seed: Seed, outputPath: string) => Effect.tryPromise({ try: () => Deno.writeTextFile( - "seed/user-data", - `#cloud-config\n${ - stringify(snakeCase(seed.userData), { - flowLevel: -1, - lineWidth: -1, - }) - }`, + outputPath, + `#cloud-config\n${stringify(snakeCase(seed.userData), { + flowLevel: -1, + lineWidth: -1, + })}` ), catch: (error) => new FileSystemError(error), }); -const runXorriso = (outputPath: string) => +const runXorriso = (outputPath: string, seedDir: string) => Effect.tryPromise({ try: async () => { const xorriso = new Deno.Command("xorriso", { @@ -90,7 +89,7 @@ const runXorriso = (outputPath: string) => "cidata", "-J", "-R", - "seed", + seedDir, ], stdout: "inherit", stderr: "inherit", @@ -101,11 +100,9 @@ const runXorriso = (outputPath: string) => 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.`, + `xorriso failed with code ${status.code}. Please ensure ${chalk.green( + "xorriso" + )} is installed and accessible in your PATH.` ); } @@ -117,12 +114,12 @@ const runXorriso = (outputPath: string) => null, `Unexpected error: ${ error instanceof Error ? error.message : String(error) - }`, + }` ); }, }); -const runGenisoimage = (outputPath: string) => +const runGenisoimage = (outputPath: string, seedDir: string) => Effect.tryPromise({ try: async () => { const genisoimage = new Deno.Command("genisoimage", { @@ -133,7 +130,7 @@ const runGenisoimage = (outputPath: string) => "cidata", "-joliet", "-rock", - "seed", + seedDir, ], stdout: "inherit", stderr: "inherit", @@ -144,11 +141,11 @@ const runGenisoimage = (outputPath: string) => 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.`, + `genisoimage failed with code ${ + status.code + }. Please ensure ${chalk.green( + "genisoimage" + )} is installed and accessible in your PATH.` ); } @@ -160,22 +157,30 @@ const runGenisoimage = (outputPath: string) => null, `Unexpected error: ${ error instanceof Error ? error.message : String(error) - }`, + }` ); }, }); -export const createSeedIso = (outputPath: string, seed: Seed) => +export const createSeedIso = ( + outputPath: string, + seed: Seed, + seedDir: string = "seed" +) => pipe( createSeedDirectory, Effect.flatMap(() => - Effect.all([writeMetaData(seed), writeUserData(seed)]) - ), - Effect.flatMap(() => Deno.build.os === "linux" - ? runGenisoimage(outputPath) - : runXorriso(outputPath) + Effect.all([ + writeMetaData(seed, `${seedDir}/meta-data`), + writeUserData(seed, `${seedDir}/user-data`), + ]) ), + Effect.flatMap(() => + Deno.build.os === "linux" + ? runGenisoimage(outputPath, seedDir) + : runXorriso(outputPath, seedDir) + ) ); -export default (outputPath: string, seed: Seed) => - Effect.runPromise(createSeedIso(outputPath, seed)); +export default (outputPath: string, seed: Seed, seedDir: string = "seed") => + Effect.runPromise(createSeedIso(outputPath, seed, seedDir)); -- 2.51.2 From 76c6261a21ac17b8fbb211fbce66059ce9624ec1 Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sat, 22 Nov 2025 12:45:18 +0300 Subject: [PATCH 4/4] run format --- src/api/machines.ts | 61 ++++++++++++++++++++------------------------- src/api/utils.ts | 19 +++++++------- src/types.ts | 14 +++++------ src/xorriso.ts | 40 ++++++++++++++++------------- 4 files changed, 66 insertions(+), 68 deletions(-) diff --git a/src/api/machines.ts b/src/api/machines.ts index 44ad489..38ec9ad 100644 --- a/src/api/machines.ts +++ b/src/api/machines.ts @@ -38,7 +38,7 @@ export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{ }> {} export class RemoveRunningVmError extends Data.TaggedError( - "RemoveRunningVmError" + "RemoveRunningVmError", )<{ id: string; }> {} @@ -52,10 +52,9 @@ app.get("/", (c) => Effect.flatMap((params) => listInstances(params.all === "true" || params.all === "1") ), - presentation(c) - ) - ) -); + presentation(c), + ), + )); app.post("/", (c) => Effect.runPromise( @@ -66,7 +65,7 @@ app.post("/", (c) => const image = yield* getImage(params.image); if (!image) { return yield* Effect.fail( - new ImageNotFoundError({ id: params.image }) + new ImageNotFoundError({ id: params.image }), ); } @@ -100,7 +99,7 @@ app.post("/", (c) => sshPwauth: false, }, }, - tempDir + tempDir, ); } @@ -125,7 +124,7 @@ app.post("/", (c) => seed: _.get( params, "seed", - params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined + params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined, ), pid: 0, }); @@ -135,20 +134,18 @@ app.post("/", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)) - ) - ) -); + Effect.catchAll((error) => handleError(error, c)), + ), + )); app.get("/:id", (c) => Effect.runPromise( pipe( parseParams(c), Effect.flatMap(({ id }) => getInstanceState(id)), - presentation(c) - ) - ) -); + presentation(c), + ), + )); app.delete("/:id", (c) => Effect.runPromise( @@ -167,10 +164,9 @@ app.delete("/:id", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)) - ) - ) -); + Effect.catchAll((error) => handleError(error, c)), + ), + )); app.post("/:id/start", (c) => Effect.runPromise( @@ -195,7 +191,7 @@ app.post("/:id/start", (c) => ? startRequest.portForward.join(",") : vm.portForward, }, - firmwareArgs + firmwareArgs, ); yield* createLogsDir(); yield* startDetachedQemu(vm.id, vm, qemuArgs); @@ -203,10 +199,9 @@ app.post("/:id/start", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)) - ) - ) -); + Effect.catchAll((error) => handleError(error, c)), + ), + )); app.post("/:id/stop", (c) => Effect.runPromise( @@ -216,10 +211,9 @@ app.post("/:id/stop", (c) => Effect.flatMap(killProcess), Effect.flatMap(updateToStopped), presentation(c), - Effect.catchAll((error) => handleError(error, c)) - ) - ) -); + Effect.catchAll((error) => handleError(error, c)), + ), + )); app.post("/:id/restart", (c) => Effect.runPromise( @@ -246,7 +240,7 @@ app.post("/:id/restart", (c) => ? startRequest.portForward.join(",") : vm.portForward, }, - firmwareArgs + firmwareArgs, ); yield* createLogsDir(); yield* startDetachedQemu(vm.id, vm, qemuArgs); @@ -254,9 +248,8 @@ app.post("/:id/restart", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)) - ) - ) -); + Effect.catchAll((error) => handleError(error, c)), + ), + )); export default app; diff --git a/src/api/utils.ts b/src/api/utils.ts index 778848c..7bfb2e2 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -40,7 +40,7 @@ export const handleError = ( | FileSystemError | XorrisoError | Error, - c: Context + c: Context, ) => Effect.sync(() => { if (error instanceof VmNotFoundError) { @@ -52,7 +52,7 @@ export const handleError = ( message: error.message || `Failed to stop VM ${error.vmName}`, code: "STOP_COMMAND_ERROR", }, - 500 + 500, ); } @@ -62,7 +62,7 @@ export const handleError = ( message: error.message || "Failed to parse request body", code: "PARSE_BODY_ERROR", }, - 400 + 400, ); } @@ -72,7 +72,7 @@ export const handleError = ( message: `VM ${error.name} is already running`, code: "VM_ALREADY_RUNNING", }, - 400 + 400, ); } @@ -82,23 +82,24 @@ export const handleError = ( message: `Image ${error.id} not found`, code: "IMAGE_NOT_FOUND", }, - 404 + 404, ); } if (error instanceof RemoveRunningVmError) { return c.json( { - message: `Cannot remove running VM with ID ${error.id}. Please stop it first.`, + message: + `Cannot remove running VM with ID ${error.id}. Please stop it first.`, code: "REMOVE_RUNNING_VM_ERROR", }, - 400 + 400, ); } return c.json( { message: error instanceof Error ? error.message : String(error) }, - 500 + 500, ); }); @@ -131,7 +132,7 @@ export const parseCreateMachineRequest = (c: Context) => export const createVolumeIfNeeded = ( image: Image, volumeName: string, - size?: string + size?: string, ): Effect.Effect => Effect.gen(function* () { const volume = yield* getVolume(volumeName); diff --git a/src/types.ts b/src/types.ts index 989f874..4930f3b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -20,7 +20,7 @@ export const NewMachineSchema = MachineParamsSchema.extend({ z .string() .trim() - .regex(/^\d+:\d+$/) + .regex(/^\d+:\d+$/), ) .optional(), cpu: z.string().trim().default("host").optional(), @@ -35,7 +35,7 @@ export const NewMachineSchema = MachineParamsSchema.extend({ .string() .trim() .regex( - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/ + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, ), volume: z.string().trim().optional(), bridge: z.string().trim().optional(), @@ -51,7 +51,7 @@ export const NewMachineSchema = MachineParamsSchema.extend({ shell: z .string() .regex( - /^\/(usr\/bin|bin|usr\/local\/bin|usr\/pkg\/bin)\/[a-zA-Z0-9_-]+$/ + /^\/(usr\/bin|bin|usr\/local\/bin|usr\/pkg\/bin)\/[a-zA-Z0-9_-]+$/, ) .trim() .default("/bin/bash") @@ -65,12 +65,12 @@ export const NewMachineSchema = MachineParamsSchema.extend({ z .string() .regex( - /^(ssh-(rsa|ed25519|dss|ecdsa) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?|ecdsa-sha2-nistp(256|384|521) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?)$/ + /^(ssh-(rsa|ed25519|dss|ecdsa) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?|ecdsa-sha2-nistp(256|384|521) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?)$/, ) - .trim() + .trim(), ) .min(1), - }) + }), ) .optional(), instanceId: z.string().trim().optional(), @@ -85,7 +85,7 @@ export const NewVolumeSchema = z.object({ baseImage: z .string() .regex( - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/ + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, ), size: z .string() diff --git a/src/xorriso.ts b/src/xorriso.ts index fc8f805..4c80976 100644 --- a/src/xorriso.ts +++ b/src/xorriso.ts @@ -39,7 +39,7 @@ export const snakeCase = (obj: unknown): unknown => { Object.entries(obj).map(([key, value]) => [ _.snakeCase(key), snakeCase(value), - ]) + ]), ); } return obj; @@ -58,7 +58,7 @@ const writeMetaData = (seed: Seed, outputPath: string) => stringify(snakeCase(seed.metaData), { flowLevel: -1, lineWidth: -1, - }) + }), ), catch: (error) => new FileSystemError(error), }); @@ -68,10 +68,12 @@ const writeUserData = (seed: Seed, outputPath: string) => try: () => Deno.writeTextFile( outputPath, - `#cloud-config\n${stringify(snakeCase(seed.userData), { - flowLevel: -1, - lineWidth: -1, - })}` + `#cloud-config\n${ + stringify(snakeCase(seed.userData), { + flowLevel: -1, + lineWidth: -1, + }) + }`, ), catch: (error) => new FileSystemError(error), }); @@ -100,9 +102,11 @@ const runXorriso = (outputPath: string, seedDir: string) => 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.` + `xorriso failed with code ${status.code}. Please ensure ${ + chalk.green( + "xorriso", + ) + } is installed and accessible in your PATH.`, ); } @@ -114,7 +118,7 @@ const runXorriso = (outputPath: string, seedDir: string) => null, `Unexpected error: ${ error instanceof Error ? error.message : String(error) - }` + }`, ); }, }); @@ -141,11 +145,11 @@ const runGenisoimage = (outputPath: string, seedDir: string) => 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.` + `genisoimage failed with code ${status.code}. Please ensure ${ + chalk.green( + "genisoimage", + ) + } is installed and accessible in your PATH.`, ); } @@ -157,7 +161,7 @@ const runGenisoimage = (outputPath: string, seedDir: string) => null, `Unexpected error: ${ error instanceof Error ? error.message : String(error) - }` + }`, ); }, }); @@ -165,7 +169,7 @@ const runGenisoimage = (outputPath: string, seedDir: string) => export const createSeedIso = ( outputPath: string, seed: Seed, - seedDir: string = "seed" + seedDir: string = "seed", ) => pipe( createSeedDirectory, @@ -179,7 +183,7 @@ export const createSeedIso = ( Deno.build.os === "linux" ? runGenisoimage(outputPath, seedDir) : runXorriso(outputPath, seedDir) - ) + ), ); export default (outputPath: string, seed: Seed, seedDir: string = "seed") =>