A Docker-like CLI and HTTP API for managing headless VMs
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));