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

Merge pull request #8 from tsirysndr/feat/seed

feat: add seed functionality for virtual machines

+514 -203
+33 -3
main.ts
··· 106 106 "--cloud", 107 107 "Use cloud-init for initial configuration (only for compatible images)", 108 108 ) 109 + .option( 110 + "--seed <path:string>", 111 + "Path to cloud-init seed image (ISO format)", 112 + ) 109 113 .example( 110 114 "Create a default VM configuration file", 111 115 "vmx init", ··· 146 150 const program = Effect.gen(function* () { 147 151 let isoPath: string | null = null; 148 152 153 + if (options.seed) { 154 + const seedExists = yield* pipe( 155 + fileExists(options.seed), 156 + Effect.map(() => true), 157 + Effect.catchAll(() => Effect.succeed(false)), 158 + ); 159 + if (!seedExists) { 160 + console.error(`Seed file ${options.seed} does not exist.`); 161 + console.log( 162 + `Please run ${ 163 + chalk.greenBright(`vmx seed`) 164 + } to create a seed image.`, 165 + ); 166 + Deno.exit(1); 167 + } 168 + } 169 + 149 170 if (input) { 150 171 const [image, archivePath] = yield* Effect.all([ 151 172 getImage(input), ··· 221 242 } 222 243 223 244 const fedoraImageURL = yield* pipe( 224 - constructFedoraImageURL(input), 245 + constructFedoraImageURL(input, options.cloud), 225 246 Effect.catchAll(() => Effect.succeed(null)), 226 247 ); 227 248 ··· 468 489 .option( 469 490 "-v, --volume <name:string>", 470 491 "Name of the volume to attach to the VM, will be created if it doesn't exist", 492 + ) 493 + .option( 494 + "--seed <path:string>", 495 + "Path to cloud-init seed image (ISO format)", 471 496 ) 472 497 .action(async (options: unknown, vmName: string) => { 473 498 await start(vmName, Boolean((options as { detach: boolean }).detach)); ··· 607 632 "-s, --size <size:string>", 608 633 "Size of the volume to create if it doesn't exist (e.g., 20G)", 609 634 ) 635 + .option( 636 + "--seed <path:string>", 637 + "Path to cloud-init seed image (ISO format)", 638 + ) 610 639 .action(async (_options: unknown, image: string) => { 611 640 await run(image); 612 641 }) ··· 638 667 "seed", 639 668 "Seed initial cloud-init user-data and meta-data files for the VM", 640 669 ) 641 - .action(async () => { 642 - await seed(); 670 + .arguments("[path:string]") 671 + .action(async (_options: unknown, path?: string) => { 672 + await seed(path); 643 673 }) 644 674 .parse(Deno.args); 645 675 }
+40 -2
src/api/machines.ts
··· 1 + import _ from "@es-toolkit/es-toolkit/compat"; 1 2 import { createId } from "@paralleldrive/cuid2"; 2 3 import { Data, Effect, pipe } from "effect"; 3 4 import { Hono } from "hono"; 4 5 import Moniker from "moniker"; 6 + import { SEED_DIR } from "../constants.ts"; 5 7 import { getImage } from "../images.ts"; 6 8 import { DEFAULT_VERSION, getInstanceState } from "../mod.ts"; 7 9 import { generateRandomMacAddress } from "../network.ts"; ··· 20 22 import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts"; 21 23 import type { NewMachine } from "../types.ts"; 22 24 import { getVolume } from "../volumes.ts"; 25 + import { createSeedIso } from "../xorriso.ts"; 23 26 import { 24 27 createVolumeIfNeeded, 25 28 handleError, ··· 70 73 ? yield* createVolumeIfNeeded(image, params.volume) 71 74 : undefined; 72 75 76 + const name = Moniker.choose(); 77 + if (params.users) { 78 + const [tempDir] = yield* Effect.promise(() => 79 + Promise.all([ 80 + Deno.makeTempDir(), 81 + Deno.mkdir(SEED_DIR, { recursive: true }), 82 + ]) 83 + ); 84 + yield* createSeedIso( 85 + `${SEED_DIR}/seed-${name}.iso`, 86 + { 87 + metaData: { 88 + instanceId: params.instanceId || name, 89 + localHostname: params.localHostname || name, 90 + hostname: params.hostname || name, 91 + }, 92 + userData: { 93 + users: params.users.map((user) => ({ 94 + name: user.name, 95 + shell: user.shell, 96 + sudo: user.sudo, 97 + sshAuthorizedKeys: user.sshAuthorizedKeys || [], 98 + })), 99 + sshPwauth: false, 100 + }, 101 + }, 102 + tempDir, 103 + ); 104 + } 105 + 73 106 const macAddress = yield* generateRandomMacAddress(); 74 107 const id = createId(); 75 108 yield* saveInstanceState({ 76 109 id, 77 - name: Moniker.choose(), 110 + name, 78 111 bridge: params.bridge, 79 112 macAddress, 80 113 memory: params.memory || "2G", 81 114 cpus: params.cpus || 8, 82 115 cpu: params.cpu || "host", 83 116 diskSize: "20G", 84 - diskFormat: volume ? "qcow2" : "raw", 117 + diskFormat: volume ? "qcow2" : image.format, 85 118 portForward: params.portForward 86 119 ? params.portForward.join(",") 87 120 : undefined, 88 121 drivePath: volume ? volume.path : image.path, 89 122 version: image.tag ?? DEFAULT_VERSION, 90 123 status: "STOPPED", 124 + seed: _.get( 125 + params, 126 + "seed", 127 + params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined, 128 + ), 91 129 pid: 0, 92 130 }); 93 131
+7 -8
src/api/utils.ts
··· 1 1 import { Data, Effect } from "effect"; 2 2 import type { Context } from "hono"; 3 + import type { Image, Volume } from "../db.ts"; 4 + import { VmAlreadyRunningError } from "../subcommands/start.ts"; 3 5 import { 4 6 type CommandError, 5 7 StopCommandError, 6 8 VmNotFoundError, 7 9 } from "../subcommands/stop.ts"; 8 - import { VmAlreadyRunningError } from "../subcommands/start.ts"; 9 10 import { 10 11 MachineParamsSchema, 11 12 NewMachineSchema, 12 13 NewVolumeSchema, 13 14 } from "../types.ts"; 14 - import type { Image, Volume } from "../db.ts"; 15 15 import { createVolume, getVolume } from "../volumes.ts"; 16 + import { FileSystemError, XorrisoError } from "../xorriso.ts"; 16 17 import { ImageNotFoundError, RemoveRunningVmError } from "./machines.ts"; 17 18 18 19 export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query()); ··· 36 37 | VmAlreadyRunningError 37 38 | ImageNotFoundError 38 39 | RemoveRunningVmError 40 + | FileSystemError 41 + | XorrisoError 39 42 | Error, 40 43 c: Context, 41 44 ) => 42 45 Effect.sync(() => { 43 46 if (error instanceof VmNotFoundError) { 44 - return c.json( 45 - { message: "VM not found", code: "VM_NOT_FOUND" }, 46 - 404, 47 - ); 47 + return c.json({ message: "VM not found", code: "VM_NOT_FOUND" }, 404); 48 48 } 49 49 if (error instanceof StopCommandError) { 50 50 return c.json( 51 51 { 52 - message: error.message || 53 - `Failed to stop VM ${error.vmName}`, 52 + message: error.message || `Failed to stop VM ${error.vmName}`, 54 53 code: "STOP_COMMAND_ERROR", 55 54 }, 56 55 500,
+3 -1
src/config.ts
··· 3 3 import * as toml from "@std/toml"; 4 4 import z from "@zod/zod"; 5 5 import { Data, Effect } from "effect"; 6 - import type { Options } from "./utils.ts"; 7 6 import { UBUNTU_ISO_URL } from "./constants.ts"; 7 + import type { Options } from "./utils.ts"; 8 8 9 9 export const VmConfigSchema = z.object({ 10 10 vm: z ··· 124 124 bridge: _.get(flags, "bridge", defaultConfig.network.bridge!) as string, 125 125 size: _.get(flags, "size", defaultConfig.vm.size!) as string, 126 126 install: flags.install, 127 + detach: flags.detach || defaultConfig.options.detach!, 128 + seed: _.get(flags, "seed", options.seed) as string | undefined, 127 129 }); 128 130 };
+5
src/constants.ts
··· 5 5 export const CONFIG_FILE_NAME: string = "vmconfig.toml"; 6 6 export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; 7 7 export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`; 8 + export const SEED_DIR: string = `${CONFIG_DIR}/seeds`; 8 9 9 10 export const UBUNTU_ISO_URL: string = Deno.build.arch === "aarch64" 10 11 ? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso" ··· 20 21 21 22 export const FEDORA_IMG_URL: string = 22 23 `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`; 24 + 25 + export const FEDORA_CLOUD_IMG_URL: string = Deno.build.arch === "aarch64" 26 + ? "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/aarch64/images/Fedora-Cloud-Base-Generic-43-1.6.aarch64.qcow2" 27 + : "https://download.fedoraproject.org/pub/fedora/linux/releases/43/Cloud/x86_64/images/Fedora-Cloud-Base-Generic-43-1.6.x86_64.qcow2"; 23 28 24 29 export const GENTOO_IMG_URL: string = Deno.build.arch === "aarch64" 25 30 ? "https://distfiles.gentoo.org/releases/arm64/autobuilds/20251116T233105Z/di-arm64-console-20251116T233105Z.qcow2"
+1
src/db.ts
··· 36 36 status: STATUS; 37 37 pid: number; 38 38 volume?: string; 39 + seed?: string; 39 40 createdAt?: string; 40 41 updatedAt?: string; 41 42 };
+15 -8
src/migrations.ts
··· 95 95 }, 96 96 97 97 async down(db: Kysely<unknown>): Promise<void> { 98 - await db.schema 99 - .alterTable("images") 100 - .dropColumn("format") 101 - .execute(); 98 + await db.schema.alterTable("images").dropColumn("format").execute(); 102 99 }, 103 100 }; 104 101 ··· 211 208 .execute(); 212 209 }, 213 210 async down(db: Kysely<unknown>): Promise<void> { 214 - await db.schema 215 - .alterTable("images") 216 - .dropColumn("digest") 217 - .execute(); 211 + await db.schema.alterTable("images").dropColumn("digest").execute(); 218 212 }, 219 213 }; 220 214 ··· 289 283 .alterTable("virtual_machines") 290 284 .dropColumn("volume") 291 285 .execute(); 286 + }, 287 + }; 288 + 289 + migrations["011"] = { 290 + async up(db: Kysely<unknown>): Promise<void> { 291 + await db.schema 292 + .alterTable("virtual_machines") 293 + .addColumn("seed", "varchar") 294 + .execute(); 295 + }, 296 + 297 + async down(db: Kysely<unknown>): Promise<void> { 298 + await db.schema.alterTable("virtual_machines").dropColumn("seed").execute(); 292 299 }, 293 300 }; 294 301
+50 -6
src/subcommands/restart.ts
··· 6 6 import { getInstanceState, updateInstanceState } from "../state.ts"; 7 7 import { 8 8 safeKillQemu, 9 + setupAlmaLinuxArgs, 9 10 setupAlpineArgs, 10 11 setupCoreOSArgs, 11 12 setupDebianArgs, 13 + setupFedoraArgs, 12 14 setupFirmwareFilesIfNeeded, 15 + setupGentooArgs, 13 16 setupNATNetworkArgs, 17 + setupRockyLinuxArgs, 14 18 setupUbuntuArgs, 15 19 } from "../utils.ts"; 16 20 ··· 63 67 : "qemu-system-x86_64"; 64 68 65 69 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 66 - let alpineArgs: string[] = Effect.runSync(setupAlpineArgs(vm.isoPath)); 67 - let debianArgs: string[] = Effect.runSync(setupDebianArgs(vm.isoPath)); 68 - let ubuntuArgs: string[] = Effect.runSync(setupUbuntuArgs(vm.isoPath)); 70 + let alpineArgs: string[] = Effect.runSync( 71 + setupAlpineArgs(vm.isoPath, vm.seed), 72 + ); 73 + let debianArgs: string[] = Effect.runSync( 74 + setupDebianArgs(vm.isoPath, vm.seed), 75 + ); 76 + let ubuntuArgs: string[] = Effect.runSync( 77 + setupUbuntuArgs(vm.isoPath, vm.seed), 78 + ); 79 + let almalinuxArgs: string[] = Effect.runSync( 80 + setupAlmaLinuxArgs(vm.isoPath, vm.seed), 81 + ); 82 + let rockylinuxArgs: string[] = Effect.runSync( 83 + setupRockyLinuxArgs(vm.isoPath, vm.seed), 84 + ); 85 + let gentooArgs: string[] = Effect.runSync( 86 + setupGentooArgs(vm.isoPath, vm.seed), 87 + ); 88 + let fedoraArgs: string[] = Effect.runSync( 89 + setupFedoraArgs(vm.isoPath, vm.seed), 90 + ); 69 91 70 92 if (coreosArgs.length > 0) { 71 93 coreosArgs = coreosArgs.slice(2); 72 94 } 73 95 74 - if (alpineArgs.length > 0) { 96 + if (alpineArgs.length > 2) { 75 97 alpineArgs = alpineArgs.slice(2); 76 98 } 77 99 78 - if (debianArgs.length > 0) { 100 + if (debianArgs.length > 2) { 79 101 debianArgs = debianArgs.slice(2); 80 102 } 81 103 82 - if (ubuntuArgs.length > 0) { 104 + if (ubuntuArgs.length > 2) { 83 105 ubuntuArgs = ubuntuArgs.slice(2); 106 + } 107 + 108 + if (almalinuxArgs.length > 2) { 109 + almalinuxArgs = almalinuxArgs.slice(2); 110 + } 111 + 112 + if (rockylinuxArgs.length > 2) { 113 + rockylinuxArgs = rockylinuxArgs.slice(2); 114 + } 115 + 116 + if (gentooArgs.length > 2) { 117 + gentooArgs = gentooArgs.slice(2); 118 + } 119 + 120 + if (fedoraArgs.length > 2) { 121 + fedoraArgs = fedoraArgs.slice(2); 84 122 } 85 123 86 124 return Effect.succeed([ ··· 118 156 ...alpineArgs, 119 157 ...debianArgs, 120 158 ...ubuntuArgs, 159 + ...almalinuxArgs, 160 + ...rockylinuxArgs, 161 + ...gentooArgs, 162 + ...fedoraArgs, 163 + ...(vm.seed ? ["-drive", `if=virtio,file=${vm.seed},media=cdrom`] : []), 164 + ...(vm.volume ? [] : ["-snapshot"]), 121 165 ]); 122 166 }; 123 167
+1
src/subcommands/run.ts
··· 92 92 install: false, 93 93 diskFormat: image.format, 94 94 volume: flags.volume || flags.v, 95 + seed: flags.seed ? Deno.realPathSync(flags.seed) : undefined, 95 96 }; 96 97 }
+54 -53
src/subcommands/seed.ts
··· 2 2 import { Effect } from "effect"; 3 3 import { createSeedIso } from "../xorriso.ts"; 4 4 5 - const seed = Effect.gen(function* () { 6 - const { instanceId, localHostname, name, shell, sudo, sshAuthorizedKeys } = 7 - yield* Effect.promise(async () => { 8 - const instanceId: string = await Input.prompt({ 9 - message: "Instance ID", 10 - minLength: 5, 11 - }); 5 + const seed = (path: string = "seed.iso") => 6 + Effect.gen(function* () { 7 + const { instanceId, localHostname, name, shell, sudo, sshAuthorizedKeys } = 8 + yield* Effect.promise(async () => { 9 + const instanceId: string = await Input.prompt({ 10 + message: "Instance ID", 11 + minLength: 5, 12 + }); 12 13 13 - const localHostname: string = await Input.prompt({ 14 - message: "Local Hostname", 15 - minLength: 3, 16 - }); 14 + const localHostname: string = await Input.prompt({ 15 + message: "Local Hostname", 16 + minLength: 3, 17 + }); 17 18 18 - const name = await Input.prompt({ 19 - message: "Default User", 20 - minLength: 3, 21 - }); 19 + const name = await Input.prompt({ 20 + message: "Default User", 21 + minLength: 3, 22 + }); 22 23 23 - const shell = await Input.prompt({ 24 - message: "User Shell", 25 - default: "/bin/bash", 26 - }); 24 + const shell = await Input.prompt({ 25 + message: "User Shell", 26 + default: "/bin/bash", 27 + }); 28 + 29 + const sudo = await Input.prompt({ 30 + message: "Sudo", 31 + default: "ALL=(ALL) NOPASSWD:ALL", 32 + }); 27 33 28 - const sudo = await Input.prompt({ 29 - message: "Sudo", 30 - default: "ALL=(ALL) NOPASSWD:ALL", 31 - }); 34 + const sshAuthorizedKeys = await Input.prompt({ 35 + message: "SSH Authorized Keys (comma separated)", 36 + }); 32 37 33 - const sshAuthorizedKeys = await Input.prompt({ 34 - message: "SSH Authorized Keys (comma separated)", 38 + return { 39 + instanceId, 40 + localHostname, 41 + name, 42 + shell, 43 + sudo, 44 + sshAuthorizedKeys, 45 + }; 35 46 }); 36 - 37 - return { 47 + yield* createSeedIso(path, { 48 + metaData: { 38 49 instanceId, 39 50 localHostname, 40 - name, 41 - shell, 42 - sudo, 43 - sshAuthorizedKeys, 44 - }; 51 + }, 52 + userData: { 53 + users: [ 54 + { 55 + name, 56 + shell, 57 + sudo: [sudo], 58 + sshAuthorizedKeys: sshAuthorizedKeys 59 + .split(",") 60 + .map((key) => key.trim()), 61 + }, 62 + ], 63 + sshPwauth: false, 64 + }, 45 65 }); 46 - yield* createSeedIso({ 47 - metaData: { 48 - instanceId, 49 - localHostname, 50 - }, 51 - userData: { 52 - users: [ 53 - { 54 - name, 55 - shell, 56 - sudo: [sudo], 57 - sshAuthorizedKeys: sshAuthorizedKeys 58 - .split(",") 59 - .map((key) => key.trim()), 60 - }, 61 - ], 62 - sshPwauth: false, 63 - }, 64 66 }); 65 - }); 66 67 67 - export default async function () { 68 - await Effect.runPromise(seed); 68 + export default async function (path?: string) { 69 + await Effect.runPromise(seed(path)); 69 70 }
+60 -2
src/subcommands/start.ts
··· 6 6 import { getImage } from "../images.ts"; 7 7 import { getInstanceState, updateInstanceState } from "../state.ts"; 8 8 import { 9 + setupAlmaLinuxArgs, 9 10 setupAlpineArgs, 10 11 setupCoreOSArgs, 12 + setupDebianArgs, 13 + setupFedoraArgs, 11 14 setupFirmwareFilesIfNeeded, 15 + setupGentooArgs, 12 16 setupNATNetworkArgs, 17 + setupRockyLinuxArgs, 18 + setupUbuntuArgs, 13 19 } from "../utils.ts"; 14 20 import { createVolume, getVolume } from "../volumes.ts"; 15 21 ··· 50 56 : "qemu-system-x86_64"; 51 57 52 58 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath)); 53 - let alpineArgs: string[] = Effect.runSync(setupAlpineArgs(vm.isoPath)); 59 + let alpineArgs: string[] = Effect.runSync( 60 + setupAlpineArgs(vm.isoPath, vm.seed), 61 + ); 62 + let debianArgs: string[] = Effect.runSync( 63 + setupDebianArgs(vm.isoPath, vm.seed), 64 + ); 65 + let ubuntuArgs: string[] = Effect.runSync( 66 + setupUbuntuArgs(vm.isoPath, vm.seed), 67 + ); 68 + let almalinuxArgs: string[] = Effect.runSync( 69 + setupAlmaLinuxArgs(vm.isoPath, vm.seed), 70 + ); 71 + let rockylinuxArgs: string[] = Effect.runSync( 72 + setupRockyLinuxArgs(vm.isoPath, vm.seed), 73 + ); 74 + let gentooArgs: string[] = Effect.runSync( 75 + setupGentooArgs(vm.isoPath, vm.seed), 76 + ); 77 + let fedoraArgs: string[] = Effect.runSync( 78 + setupFedoraArgs(vm.isoPath, vm.seed), 79 + ); 54 80 55 81 if (coreosArgs.length > 0) { 56 82 coreosArgs = coreosArgs.slice(2); 57 83 } 58 84 59 - if (alpineArgs.length > 0) { 85 + if (alpineArgs.length > 2) { 60 86 alpineArgs = alpineArgs.slice(2); 61 87 } 62 88 89 + if (debianArgs.length > 2) { 90 + debianArgs = debianArgs.slice(2); 91 + } 92 + 93 + if (ubuntuArgs.length > 2) { 94 + ubuntuArgs = ubuntuArgs.slice(2); 95 + } 96 + 97 + if (almalinuxArgs.length > 2) { 98 + almalinuxArgs = almalinuxArgs.slice(2); 99 + } 100 + 101 + if (rockylinuxArgs.length > 2) { 102 + rockylinuxArgs = rockylinuxArgs.slice(2); 103 + } 104 + 105 + if (gentooArgs.length > 2) { 106 + gentooArgs = gentooArgs.slice(2); 107 + } 108 + 109 + if (fedoraArgs.length > 2) { 110 + fedoraArgs = fedoraArgs.slice(2); 111 + } 112 + 63 113 return Effect.succeed([ 64 114 ..._.compact([vm.bridge && qemu]), 65 115 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]), ··· 93 143 ), 94 144 ...coreosArgs, 95 145 ...alpineArgs, 146 + ...debianArgs, 147 + ...ubuntuArgs, 148 + ...almalinuxArgs, 149 + ...rockylinuxArgs, 150 + ...gentooArgs, 151 + ...fedoraArgs, 152 + ...(vm.seed ? ["-drive", `if=virtio,file=${vm.seed},media=cdrom`] : []), 96 153 ...(vm.volume ? [] : ["-snapshot"]), 97 154 ]); 98 155 }; ··· 355 412 diskSize: flags.size || flags.s 356 413 ? String(flags.size || flags.s) 357 414 : vm.diskSize, 415 + seed: flags.seed ? String(flags.seed) : vm.seed, 358 416 }; 359 417 }
+73 -13
src/types.ts
··· 6 6 portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), 7 7 cpu: z.string().optional(), 8 8 cpus: z.number().min(1).optional(), 9 - memory: z.string().regex(/^\d+(M|G)$/).optional(), 9 + memory: z 10 + .string() 11 + .regex(/^\d+(M|G)$/) 12 + .optional(), 10 13 }); 11 14 12 15 export type MachineParams = z.infer<typeof MachineParamsSchema>; 13 16 14 17 export const NewMachineSchema = MachineParamsSchema.extend({ 15 - portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), 16 - cpu: z.string().default("host").optional(), 18 + portForward: z 19 + .array( 20 + z 21 + .string() 22 + .trim() 23 + .regex(/^\d+:\d+$/), 24 + ) 25 + .optional(), 26 + cpu: z.string().trim().default("host").optional(), 17 27 cpus: z.number().min(1).default(8).optional(), 18 - memory: z.string().regex(/^\d+(M|G)$/).default("2G").optional(), 19 - image: z.string().regex( 20 - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, 21 - ), 22 - volume: z.string().optional(), 23 - bridge: z.string().optional(), 28 + memory: z 29 + .string() 30 + .trim() 31 + .regex(/^\d+(M|G)$/) 32 + .default("2G") 33 + .optional(), 34 + image: z 35 + .string() 36 + .trim() 37 + .regex( 38 + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, 39 + ), 40 + volume: z.string().trim().optional(), 41 + bridge: z.string().trim().optional(), 42 + seed: z.string().trim().optional(), 43 + users: z 44 + .array( 45 + z.object({ 46 + name: z 47 + .string() 48 + .regex(/^[a-zA-Z0-9_-]+$/) 49 + .trim() 50 + .min(1), 51 + shell: z 52 + .string() 53 + .regex( 54 + /^\/(usr\/bin|bin|usr\/local\/bin|usr\/pkg\/bin)\/[a-zA-Z0-9_-]+$/, 55 + ) 56 + .trim() 57 + .default("/bin/bash") 58 + .optional(), 59 + sudo: z 60 + .array(z.string()) 61 + .optional() 62 + .default(["ALL=(ALL) NOPASSWD:ALL"]), 63 + sshAuthorizedKeys: z 64 + .array( 65 + z 66 + .string() 67 + .regex( 68 + /^(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]*)?)$/, 69 + ) 70 + .trim(), 71 + ) 72 + .min(1), 73 + }), 74 + ) 75 + .optional(), 76 + instanceId: z.string().trim().optional(), 77 + localHostname: z.string().trim().optional(), 78 + hostname: z.string().trim().optional(), 24 79 }); 25 80 26 81 export type NewMachine = z.infer<typeof NewMachineSchema>; 27 82 28 83 export const NewVolumeSchema = z.object({ 29 84 name: z.string(), 30 - baseImage: z.string().regex( 31 - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, 32 - ), 33 - size: z.string().regex(/^\d+(M|G|T)$/).optional(), 85 + baseImage: z 86 + .string() 87 + .regex( 88 + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, 89 + ), 90 + size: z 91 + .string() 92 + .regex(/^\d+(M|G|T)$/) 93 + .optional(), 34 94 }); 35 95 36 96 export type NewVolume = z.infer<typeof NewVolumeSchema>;
+74 -22
src/utils.ts
··· 12 12 DEBIAN_DEFAULT_VERSION, 13 13 DEBIAN_ISO_URL, 14 14 EMPTY_DISK_THRESHOLD_KB, 15 + FEDORA_CLOUD_IMG_URL, 15 16 FEDORA_COREOS_DEFAULT_VERSION, 16 17 FEDORA_COREOS_IMG_URL, 17 18 FEDORA_IMG_URL, ··· 43 44 install?: boolean; 44 45 volume?: string; 45 46 cloud?: boolean; 47 + seed?: string; 46 48 } 47 49 48 50 class LogCommandError extends Data.TaggedError("LogCommandError")<{ ··· 214 216 215 217 yield* Effect.tryPromise({ 216 218 try: async () => { 217 - console.log(chalk.blueBright(`Downloading ISO from ${url}...`)); 219 + console.log( 220 + chalk.blueBright( 221 + `Downloading ${ 222 + url.endsWith(".iso") ? "ISO" : "image" 223 + } from ${url}...`, 224 + ), 225 + ); 218 226 const cmd = new Deno.Command("curl", { 219 227 args: ["-L", "-o", outputPath, url], 220 228 stdin: "inherit", ··· 337 345 return []; 338 346 }); 339 347 340 - export const setupFedoraArgs = (imagePath?: string | null) => 348 + export const setupFedoraArgs = (imagePath?: string | null, seed?: string) => 341 349 Effect.sync(() => { 342 350 if ( 343 351 imagePath && 344 352 imagePath.endsWith(".qcow2") && 345 - imagePath.includes("Fedora-Server") 353 + (imagePath.includes("Fedora-Server") || 354 + imagePath.includes("Fedora-Cloud")) 346 355 ) { 347 - return ["-drive", `file=${imagePath},format=qcow2,if=virtio`]; 356 + return [ 357 + "-drive", 358 + `file=${imagePath},format=qcow2,if=virtio`, 359 + ...(seed ? ["-drive", `if=virtio,file=${seed},media=cdrom`] : []), 360 + ]; 348 361 } 349 362 350 363 return []; 351 364 }); 352 365 353 - export const setupGentooArgs = (imagePath?: string | null) => 366 + export const setupGentooArgs = (imagePath?: string | null, seed?: string) => 354 367 Effect.sync(() => { 355 368 if ( 356 369 imagePath && ··· 359 372 `di-${Deno.build.arch === "aarch64" ? "arm64" : "amd64"}-console-`, 360 373 ) 361 374 ) { 362 - return ["-drive", `file=${imagePath},format=qcow2,if=virtio`]; 375 + return [ 376 + "-drive", 377 + `file=${imagePath},format=qcow2,if=virtio`, 378 + ...(seed ? ["-drive", `if=virtio,file=${seed},media=cdrom`] : []), 379 + ]; 363 380 } 364 381 365 382 return []; 366 383 }); 367 384 368 - export const setupAlpineArgs = (imagePath?: string | null) => 385 + export const setupAlpineArgs = ( 386 + imagePath?: string | null, 387 + seed: string = "seed.iso", 388 + ) => 369 389 Effect.sync(() => { 370 390 if ( 371 391 imagePath && ··· 376 396 "-drive", 377 397 `file=${imagePath},format=qcow2,if=virtio`, 378 398 "-drive", 379 - "if=virtio,file=seed.iso,media=cdrom", 399 + `if=virtio,file=${seed},media=cdrom`, 380 400 ]; 381 401 } 382 402 383 403 return []; 384 404 }); 385 405 386 - export const setupDebianArgs = (imagePath?: string | null) => 406 + export const setupDebianArgs = ( 407 + imagePath?: string | null, 408 + seed: string = "seed.iso", 409 + ) => 387 410 Effect.sync(() => { 388 411 if ( 389 412 imagePath && ··· 394 417 "-drive", 395 418 `file=${imagePath},format=qcow2,if=virtio`, 396 419 "-drive", 397 - "if=virtio,file=seed.iso,media=cdrom", 420 + `if=virtio,file=${seed},media=cdrom`, 398 421 ]; 399 422 } 400 423 401 424 return []; 402 425 }); 403 426 404 - export const setupUbuntuArgs = (imagePath?: string | null) => 427 + export const setupUbuntuArgs = ( 428 + imagePath?: string | null, 429 + seed: string = "seed.iso", 430 + ) => 405 431 Effect.sync(() => { 406 432 if ( 407 433 imagePath && ··· 412 438 "-drive", 413 439 `file=${imagePath},format=qcow2,if=virtio`, 414 440 "-drive", 415 - "if=virtio,file=seed.iso,media=cdrom", 441 + `if=virtio,file=${seed},media=cdrom`, 416 442 ]; 417 443 } 418 444 419 445 return []; 420 446 }); 421 447 422 - export const setupAlmaLinuxArgs = (imagePath?: string | null) => 448 + export const setupAlmaLinuxArgs = ( 449 + imagePath?: string | null, 450 + seed: string = "seed.iso", 451 + ) => 423 452 Effect.sync(() => { 424 453 if ( 425 454 imagePath && ··· 430 459 "-drive", 431 460 `file=${imagePath},format=qcow2,if=virtio`, 432 461 "-drive", 433 - "if=virtio,file=seed.iso,media=cdrom", 462 + `if=virtio,file=${seed},media=cdrom`, 434 463 ]; 435 464 } 436 465 437 466 return []; 438 467 }); 439 468 440 - export const setupRockyLinuxArgs = (imagePath?: string | null) => 469 + export const setupRockyLinuxArgs = ( 470 + imagePath?: string | null, 471 + seed: string = "seed.iso", 472 + ) => 441 473 Effect.sync(() => { 442 474 if ( 443 475 imagePath && ··· 448 480 "-drive", 449 481 `file=${imagePath},format=qcow2,if=virtio`, 450 482 "-drive", 451 - "if=virtio,file=seed.iso,media=cdrom", 483 + `if=virtio,file=${seed},media=cdrom`, 452 484 ]; 453 485 } 454 486 ··· 465 497 466 498 const firmwareFiles = yield* setupFirmwareFilesIfNeeded(); 467 499 let coreosArgs: string[] = yield* setupCoreOSArgs(isoPath || options.image); 468 - let fedoraArgs: string[] = yield* setupFedoraArgs(isoPath || options.image); 469 - let gentooArgs: string[] = yield* setupGentooArgs(isoPath || options.image); 470 - let alpineArgs: string[] = yield* setupAlpineArgs(isoPath || options.image); 471 - let debianArgs: string[] = yield* setupDebianArgs(isoPath || options.image); 472 - let ubuntuArgs: string[] = yield* setupUbuntuArgs(isoPath || options.image); 500 + let fedoraArgs: string[] = yield* setupFedoraArgs( 501 + isoPath || options.image, 502 + options.seed, 503 + ); 504 + let gentooArgs: string[] = yield* setupGentooArgs( 505 + isoPath || options.image, 506 + options.seed, 507 + ); 508 + let alpineArgs: string[] = yield* setupAlpineArgs( 509 + isoPath || options.image, 510 + options.seed, 511 + ); 512 + let debianArgs: string[] = yield* setupDebianArgs( 513 + isoPath || options.image, 514 + options.seed, 515 + ); 516 + let ubuntuArgs: string[] = yield* setupUbuntuArgs( 517 + isoPath || options.image, 518 + options.seed, 519 + ); 473 520 let almalinuxArgs: string[] = yield* setupAlmaLinuxArgs( 474 521 isoPath || options.image, 522 + options.seed, 475 523 ); 476 524 let rockylinuxArgs: string[] = yield* setupRockyLinuxArgs( 477 525 isoPath || options.image, 526 + options.seed, 478 527 ); 479 528 480 529 if (coreosArgs.length > 0 && !isoPath) { ··· 608 657 version: DEFAULT_VERSION, 609 658 status: "RUNNING", 610 659 pid: qemuPid, 660 + seed: options.seed, 611 661 }); 612 662 613 663 console.log( ··· 650 700 version: DEFAULT_VERSION, 651 701 status: "RUNNING", 652 702 pid: cmd.pid, 703 + seed: options.seed ? Deno.realPathSync(options.seed) : undefined, 653 704 }); 654 705 655 706 const status = yield* Effect.tryPromise({ ··· 845 896 846 897 export const constructFedoraImageURL = ( 847 898 image: string, 899 + cloud: boolean = false, 848 900 ): Effect.Effect<string, InvalidImageNameError, never> => { 849 901 // detect with regex if image matches Fedora pattern: fedora 850 902 const fedoraRegex = /^(fedora)$/; 851 903 const match = image.match(fedoraRegex); 852 904 if (match) { 853 - return Effect.succeed(FEDORA_IMG_URL); 905 + return Effect.succeed(cloud ? FEDORA_CLOUD_IMG_URL : FEDORA_IMG_URL); 854 906 } 855 907 856 908 return Effect.fail(
+98 -85
src/xorriso.ts
··· 7 7 metaData: { 8 8 instanceId: string; 9 9 localHostname: string; 10 + hostname?: string; 10 11 }; 11 12 userData: { 12 13 users: Array<{ ··· 49 50 catch: (error) => new FileSystemError(error), 50 51 }); 51 52 52 - const writeMetaData = (seed: Seed) => 53 + const writeMetaData = (seed: Seed, outputPath: string) => 53 54 Effect.tryPromise({ 54 55 try: () => 55 56 Deno.writeTextFile( 56 - "seed/meta-data", 57 + outputPath, 57 58 stringify(snakeCase(seed.metaData), { 58 59 flowLevel: -1, 59 60 lineWidth: -1, ··· 62 63 catch: (error) => new FileSystemError(error), 63 64 }); 64 65 65 - const writeUserData = (seed: Seed) => 66 + const writeUserData = (seed: Seed, outputPath: string) => 66 67 Effect.tryPromise({ 67 68 try: () => 68 69 Deno.writeTextFile( 69 - "seed/user-data", 70 + outputPath, 70 71 `#cloud-config\n${ 71 72 stringify(snakeCase(seed.userData), { 72 73 flowLevel: -1, ··· 77 78 catch: (error) => new FileSystemError(error), 78 79 }); 79 80 80 - const runXorriso = Effect.tryPromise({ 81 - try: async () => { 82 - const xorriso = new Deno.Command("xorriso", { 83 - args: [ 84 - "-as", 85 - "mkisofs", 86 - "-o", 87 - "seed.iso", 88 - "-V", 89 - "cidata", 90 - "-J", 91 - "-R", 92 - "seed", 93 - ], 94 - stdout: "inherit", 95 - stderr: "inherit", 96 - }).spawn(); 81 + const runXorriso = (outputPath: string, seedDir: string) => 82 + Effect.tryPromise({ 83 + try: async () => { 84 + const xorriso = new Deno.Command("xorriso", { 85 + args: [ 86 + "-as", 87 + "mkisofs", 88 + "-o", 89 + outputPath, 90 + "-V", 91 + "cidata", 92 + "-J", 93 + "-R", 94 + seedDir, 95 + ], 96 + stdout: "inherit", 97 + stderr: "inherit", 98 + }).spawn(); 97 99 98 - const status = await xorriso.status; 100 + const status = await xorriso.status; 99 101 100 - if (!status.success) { 101 - throw new XorrisoError( 102 - status.code, 103 - `xorriso failed with code ${status.code}. Please ensure ${ 104 - chalk.green( 105 - "xorriso", 106 - ) 107 - } is installed and accessible in your PATH.`, 102 + if (!status.success) { 103 + throw new XorrisoError( 104 + status.code, 105 + `xorriso failed with code ${status.code}. Please ensure ${ 106 + chalk.green( 107 + "xorriso", 108 + ) 109 + } is installed and accessible in your PATH.`, 110 + ); 111 + } 112 + 113 + return status; 114 + }, 115 + catch: (error) => { 116 + if (error instanceof XorrisoError) return error; 117 + return new XorrisoError( 118 + null, 119 + `Unexpected error: ${ 120 + error instanceof Error ? error.message : String(error) 121 + }`, 108 122 ); 109 - } 123 + }, 124 + }); 110 125 111 - return status; 112 - }, 113 - catch: (error) => { 114 - if (error instanceof XorrisoError) return error; 115 - return new XorrisoError( 116 - null, 117 - `Unexpected error: ${ 118 - error instanceof Error ? error.message : String(error) 119 - }`, 120 - ); 121 - }, 122 - }); 126 + const runGenisoimage = (outputPath: string, seedDir: string) => 127 + Effect.tryPromise({ 128 + try: async () => { 129 + const genisoimage = new Deno.Command("genisoimage", { 130 + args: [ 131 + "-output", 132 + outputPath, 133 + "-volid", 134 + "cidata", 135 + "-joliet", 136 + "-rock", 137 + seedDir, 138 + ], 139 + stdout: "inherit", 140 + stderr: "inherit", 141 + }).spawn(); 123 142 124 - const runGenisoimage = Effect.tryPromise({ 125 - try: async () => { 126 - const genisoimage = new Deno.Command("genisoimage", { 127 - args: [ 128 - "-output", 129 - "seed.iso", 130 - "-volid", 131 - "cidata", 132 - "-joliet", 133 - "-rock", 134 - "seed", 135 - ], 136 - stdout: "inherit", 137 - stderr: "inherit", 138 - }).spawn(); 143 + const status = await genisoimage.status; 139 144 140 - const status = await genisoimage.status; 145 + if (!status.success) { 146 + throw new XorrisoError( 147 + status.code, 148 + `genisoimage failed with code ${status.code}. Please ensure ${ 149 + chalk.green( 150 + "genisoimage", 151 + ) 152 + } is installed and accessible in your PATH.`, 153 + ); 154 + } 141 155 142 - if (!status.success) { 143 - throw new XorrisoError( 144 - status.code, 145 - `genisoimage failed with code ${status.code}. Please ensure ${ 146 - chalk.green( 147 - "genisoimage", 148 - ) 149 - } is installed and accessible in your PATH.`, 156 + return status; 157 + }, 158 + catch: (error) => { 159 + if (error instanceof XorrisoError) return error; 160 + return new XorrisoError( 161 + null, 162 + `Unexpected error: ${ 163 + error instanceof Error ? error.message : String(error) 164 + }`, 150 165 ); 151 - } 166 + }, 167 + }); 152 168 153 - return status; 154 - }, 155 - catch: (error) => { 156 - if (error instanceof XorrisoError) return error; 157 - return new XorrisoError( 158 - null, 159 - `Unexpected error: ${ 160 - error instanceof Error ? error.message : String(error) 161 - }`, 162 - ); 163 - }, 164 - }); 165 - 166 - export const createSeedIso = (seed: Seed) => 169 + export const createSeedIso = ( 170 + outputPath: string, 171 + seed: Seed, 172 + seedDir: string = "seed", 173 + ) => 167 174 pipe( 168 175 createSeedDirectory, 169 176 Effect.flatMap(() => 170 - Effect.all([writeMetaData(seed), writeUserData(seed)]) 177 + Effect.all([ 178 + writeMetaData(seed, `${seedDir}/meta-data`), 179 + writeUserData(seed, `${seedDir}/user-data`), 180 + ]) 171 181 ), 172 182 Effect.flatMap(() => 173 - Deno.build.os === "linux" ? runGenisoimage : runXorriso 183 + Deno.build.os === "linux" 184 + ? runGenisoimage(outputPath, seedDir) 185 + : runXorriso(outputPath, seedDir) 174 186 ), 175 187 ); 176 188 177 - export default (seed: Seed) => Effect.runPromise(createSeedIso(seed)); 189 + export default (outputPath: string, seed: Seed, seedDir: string = "seed") => 190 + Effect.runPromise(createSeedIso(outputPath, seed, seedDir));