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

feat: implement seed command for cloud-init user-data and meta-data generation

+256
+1
deno.json
··· 20 20 "@std/io": "jsr:@std/io@^0.225.2", 21 21 "@std/path": "jsr:@std/path@^1.1.2", 22 22 "@std/toml": "jsr:@std/toml@^1.0.11", 23 + "@std/yaml": "jsr:@std/yaml@^1.0.10", 23 24 "@zod/zod": "jsr:@zod/zod@^4.1.12", 24 25 "chalk": "npm:chalk@^5.6.2", 25 26 "dayjs": "npm:dayjs@^1.11.19",
+5
deno.lock
··· 35 35 "jsr:@std/path@~1.0.6": "1.0.9", 36 36 "jsr:@std/text@~1.0.7": "1.0.16", 37 37 "jsr:@std/toml@^1.0.11": "1.0.11", 38 + "jsr:@std/yaml@^1.0.10": "1.0.10", 38 39 "jsr:@zod/zod@^4.1.12": "4.1.12", 39 40 "npm:@hono/swagger-ui@~0.5.2": "0.5.2_hono@4.10.6", 40 41 "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", ··· 180 181 "jsr:@std/collections" 181 182 ] 182 183 }, 184 + "@std/yaml@1.0.10": { 185 + "integrity": "245706ea3511cc50c8c6d00339c23ea2ffa27bd2c7ea5445338f8feff31fa58e" 186 + }, 183 187 "@zod/zod@4.1.12": { 184 188 "integrity": "5876ed4c6d44673faf5120f0a461a2ada2eb6c735329d3ebaf5ba1fc08387695" 185 189 } ··· 257 261 "jsr:@std/io@~0.225.2", 258 262 "jsr:@std/path@^1.1.2", 259 263 "jsr:@std/toml@^1.0.11", 264 + "jsr:@std/yaml@^1.0.10", 260 265 "jsr:@zod/zod@^4.1.12", 261 266 "npm:@hono/swagger-ui@~0.5.2", 262 267 "npm:@paralleldrive/cuid2@^3.0.4",
+8
main.ts
··· 25 25 import rm from "./src/subcommands/rm.ts"; 26 26 import rmi from "./src/subcommands/rmi.ts"; 27 27 import run from "./src/subcommands/run.ts"; 28 + import seed from "./src/subcommands/seed.ts"; 28 29 import serve from "./src/subcommands/serve.ts"; 29 30 import start from "./src/subcommands/start.ts"; 30 31 import stop from "./src/subcommands/stop.ts"; ··· 592 593 .option("-p, --port <port:number>", "Port to listen on", { default: 8889 }) 593 594 .action(() => { 594 595 serve(); 596 + }) 597 + .command( 598 + "seed", 599 + "Seed initial cloud-init user-data and meta-data files for the VM", 600 + ) 601 + .action(async () => { 602 + await seed(); 595 603 }) 596 604 .parse(Deno.args); 597 605 }
+69
src/subcommands/seed.ts
··· 1 + import { Input } from "@cliffy/prompt"; 2 + import { Effect } from "effect"; 3 + import { createSeedIso } from "../xorriso.ts"; 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 + }); 12 + 13 + const localHostname: string = await Input.prompt({ 14 + message: "Local Hostname", 15 + minLength: 3, 16 + }); 17 + 18 + const name = await Input.prompt({ 19 + message: "Default User", 20 + minLength: 3, 21 + }); 22 + 23 + const shell = await Input.prompt({ 24 + message: "User Shell", 25 + default: "/bin/bash", 26 + }); 27 + 28 + const sudo = await Input.prompt({ 29 + message: "Sudo", 30 + default: "ALL=(ALL) NOPASSWD:ALL", 31 + }); 32 + 33 + const sshAuthorizedKeys = await Input.prompt({ 34 + message: "SSH Authorized Keys (comma separated)", 35 + }); 36 + 37 + return { 38 + instanceId, 39 + localHostname, 40 + name, 41 + shell, 42 + sudo, 43 + sshAuthorizedKeys, 44 + }; 45 + }); 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 + }); 65 + }); 66 + 67 + export default async function () { 68 + await Effect.runPromise(seed); 69 + }
+129
src/xorriso.ts
··· 1 + import _ from "@es-toolkit/es-toolkit/compat"; 2 + import { stringify } from "@std/yaml"; 3 + import chalk from "chalk"; 4 + import { Effect, pipe } from "effect"; 5 + 6 + export type Seed = { 7 + metaData: { 8 + instanceId: string; 9 + localHostname: string; 10 + }; 11 + userData: { 12 + users: Array<{ 13 + name: string; 14 + shell?: string; 15 + sudo: string[]; 16 + sshAuthorizedKeys: string[]; 17 + }>; 18 + sshPwauth: boolean; 19 + packages?: string[]; 20 + }; 21 + }; 22 + 23 + export class FileSystemError { 24 + readonly _tag = "FileSystemError"; 25 + constructor(readonly error: unknown) {} 26 + } 27 + 28 + export class XorrisoError { 29 + readonly _tag = "XorrisoError"; 30 + constructor(readonly code: number | null, readonly message: string) {} 31 + } 32 + 33 + export const snakeCase = (obj: unknown): unknown => { 34 + if (Array.isArray(obj)) { 35 + return obj.map(snakeCase); 36 + } else if (obj !== null && typeof obj === "object") { 37 + return Object.fromEntries( 38 + Object.entries(obj).map(([key, value]) => [ 39 + _.snakeCase(key), 40 + snakeCase(value), 41 + ]) 42 + ); 43 + } 44 + return obj; 45 + }; 46 + 47 + const createSeedDirectory = Effect.tryPromise({ 48 + try: () => Deno.mkdir("seed", { recursive: true }), 49 + catch: (error) => new FileSystemError(error), 50 + }); 51 + 52 + const writeMetaData = (seed: Seed) => 53 + Effect.tryPromise({ 54 + try: () => 55 + Deno.writeTextFile( 56 + "seed/meta-data", 57 + stringify(snakeCase(seed.metaData), { 58 + flowLevel: -1, 59 + lineWidth: -1, 60 + }) 61 + ), 62 + catch: (error) => new FileSystemError(error), 63 + }); 64 + 65 + const writeUserData = (seed: Seed) => 66 + Effect.tryPromise({ 67 + try: () => 68 + Deno.writeTextFile( 69 + "seed/user-data", 70 + `#cloud-config\n${stringify(snakeCase(seed.userData), { 71 + flowLevel: -1, 72 + lineWidth: -1, 73 + })}` 74 + ), 75 + catch: (error) => new FileSystemError(error), 76 + }); 77 + 78 + const runXorriso = Effect.tryPromise({ 79 + try: async () => { 80 + const xorriso = new Deno.Command("xorriso", { 81 + args: [ 82 + "-as", 83 + "mkisofs", 84 + "-o", 85 + "seed.iso", 86 + "-V", 87 + "cidata", 88 + "-J", 89 + "-R", 90 + "seed", 91 + ], 92 + stdout: "inherit", 93 + stderr: "inherit", 94 + }).spawn(); 95 + 96 + const status = await xorriso.status; 97 + 98 + if (!status.success) { 99 + throw new XorrisoError( 100 + status.code, 101 + `xorriso failed with code ${status.code}. Please ensure ${chalk.green( 102 + "xorriso" 103 + )} is installed and accessible in your PATH.` 104 + ); 105 + } 106 + 107 + return status; 108 + }, 109 + catch: (error) => { 110 + if (error instanceof XorrisoError) return error; 111 + return new XorrisoError( 112 + null, 113 + `Unexpected error: ${ 114 + error instanceof Error ? error.message : String(error) 115 + }` 116 + ); 117 + }, 118 + }); 119 + 120 + export const createSeedIso = (seed: Seed) => 121 + pipe( 122 + createSeedDirectory, 123 + Effect.flatMap(() => 124 + Effect.all([writeMetaData(seed), writeUserData(seed)]) 125 + ), 126 + Effect.flatMap(() => runXorriso) 127 + ); 128 + 129 + export default (seed: Seed) => Effect.runPromise(createSeedIso(seed));
+44
src/xorriso_test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { stringify } from "@std/yaml"; 3 + import { snakeCase } from "./xorriso.ts"; 4 + 5 + Deno.test("Serialize Seed Data to YAML", () => { 6 + const seed = { 7 + metaData: { 8 + instanceId: "vmx-12345", 9 + localHostname: "vmx-test", 10 + }, 11 + userData: { 12 + users: [ 13 + { 14 + name: "testuser", 15 + shell: "/bin/bash", 16 + sudo: ["ALL=(ALL) NOPASSWD:ALL"], 17 + sshAuthorizedKeys: ["ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAr..."], 18 + }, 19 + ], 20 + sshPwauth: false, 21 + packages: ["curl", "git"], 22 + }, 23 + }; 24 + 25 + const expectedMetaDataYAML = `instance_id: vmx-12345 26 + local_hostname: vmx-test 27 + `; 28 + 29 + const expectedUserDataYAML = `users: 30 + - name: testuser 31 + shell: /bin/bash 32 + sudo: 33 + - 'ALL=(ALL) NOPASSWD:ALL' 34 + ssh_authorized_keys: 35 + - ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAr... 36 + ssh_pwauth: false 37 + packages: 38 + - curl 39 + - git 40 + `; 41 + 42 + assertEquals(stringify(snakeCase(seed.metaData)), expectedMetaDataYAML); 43 + assertEquals(stringify(snakeCase(seed.userData)), expectedUserDataYAML); 44 + });