A Docker-like CLI and HTTP API for managing headless VMs
at main 190 lines 4.6 kB view raw
1import _ from "@es-toolkit/es-toolkit/compat"; 2import { stringify } from "@std/yaml"; 3import chalk from "chalk"; 4import { Effect, pipe } from "effect"; 5 6export type Seed = { 7 metaData: { 8 instanceId: string; 9 localHostname: string; 10 hostname?: string; 11 }; 12 userData: { 13 users: Array<{ 14 name: string; 15 shell?: string; 16 sudo: string[]; 17 sshAuthorizedKeys: string[]; 18 }>; 19 sshPwauth: boolean; 20 packages?: string[]; 21 }; 22}; 23 24export class FileSystemError { 25 readonly _tag = "FileSystemError"; 26 constructor(readonly error: unknown) {} 27} 28 29export class XorrisoError { 30 readonly _tag = "XorrisoError"; 31 constructor(readonly code: number | null, readonly message: string) {} 32} 33 34export const snakeCase = (obj: unknown): unknown => { 35 if (Array.isArray(obj)) { 36 return obj.map(snakeCase); 37 } else if (obj !== null && typeof obj === "object") { 38 return Object.fromEntries( 39 Object.entries(obj).map(([key, value]) => [ 40 _.snakeCase(key), 41 snakeCase(value), 42 ]), 43 ); 44 } 45 return obj; 46}; 47 48const createSeedDirectory = Effect.tryPromise({ 49 try: () => Deno.mkdir("seed", { recursive: true }), 50 catch: (error) => new FileSystemError(error), 51}); 52 53const writeMetaData = (seed: Seed, outputPath: string) => 54 Effect.tryPromise({ 55 try: () => 56 Deno.writeTextFile( 57 outputPath, 58 stringify(snakeCase(seed.metaData), { 59 flowLevel: -1, 60 lineWidth: -1, 61 }), 62 ), 63 catch: (error) => new FileSystemError(error), 64 }); 65 66const writeUserData = (seed: Seed, outputPath: string) => 67 Effect.tryPromise({ 68 try: () => 69 Deno.writeTextFile( 70 outputPath, 71 `#cloud-config\n${ 72 stringify(snakeCase(seed.userData), { 73 flowLevel: -1, 74 lineWidth: -1, 75 }) 76 }`, 77 ), 78 catch: (error) => new FileSystemError(error), 79 }); 80 81const 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(); 99 100 const status = await xorriso.status; 101 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 }`, 122 ); 123 }, 124 }); 125 126const 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(); 142 143 const status = await genisoimage.status; 144 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 } 155 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 }`, 165 ); 166 }, 167 }); 168 169export const createSeedIso = ( 170 outputPath: string, 171 seed: Seed, 172 seedDir: string = "seed", 173) => 174 pipe( 175 createSeedDirectory, 176 Effect.flatMap(() => 177 Effect.all([ 178 writeMetaData(seed, `${seedDir}/meta-data`), 179 writeUserData(seed, `${seedDir}/user-data`), 180 ]) 181 ), 182 Effect.flatMap(() => 183 Deno.build.os === "linux" 184 ? runGenisoimage(outputPath, seedDir) 185 : runXorriso(outputPath, seedDir) 186 ), 187 ); 188 189export default (outputPath: string, seed: Seed, seedDir: string = "seed") => 190 Effect.runPromise(createSeedIso(outputPath, seed, seedDir));