A Docker-like CLI and HTTP API for managing headless VMs
1import _ from "@es-toolkit/es-toolkit/compat";
2import { createId } from "@paralleldrive/cuid2";
3import { Effect, pipe } from "effect";
4import { Hono } from "hono";
5import Moniker from "moniker";
6import { SEED_DIR } from "../constants.ts";
7import { ImageNotFoundError, RemoveRunningVmError } from "../errors.ts";
8import { getImage } from "../images.ts";
9import { generateRandomMacAddress } from "../network.ts";
10import {
11 getInstanceState,
12 listInstances,
13 removeInstanceState,
14 saveInstanceState,
15} from "../state.ts";
16import {
17 buildQemuArgs,
18 createLogsDir,
19 failIfVMRunning,
20 setupFirmware,
21 startDetachedQemu,
22} from "../subcommands/start.ts";
23import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts";
24import type { NewMachine } from "../types.ts";
25import { getVolume } from "../volumes.ts";
26import { createSeedIso } from "../xorriso.ts";
27import {
28 createVolumeIfNeeded,
29 handleError,
30 parseCreateMachineRequest,
31 parseParams,
32 parseQueryParams,
33 parseStartRequest,
34 presentation,
35} from "./utils.ts";
36
37const app = new Hono();
38
39app.get("/", (c) =>
40 Effect.runPromise(
41 pipe(
42 parseQueryParams(c),
43 Effect.flatMap((params) =>
44 listInstances(params.all === "true" || params.all === "1")
45 ),
46 presentation(c),
47 ),
48 ));
49
50app.post("/", (c) =>
51 Effect.runPromise(
52 pipe(
53 parseCreateMachineRequest(c),
54 Effect.flatMap((params: NewMachine) =>
55 Effect.gen(function* () {
56 const image = yield* getImage(params.image);
57 if (!image) {
58 return yield* Effect.fail(
59 new ImageNotFoundError({ id: params.image }),
60 );
61 }
62
63 const volume = params.volume
64 ? yield* createVolumeIfNeeded(image, params.volume)
65 : undefined;
66
67 const name = Moniker.choose();
68 if (params.users) {
69 const [tempDir] = yield* Effect.promise(() =>
70 Promise.all([
71 Deno.makeTempDir(),
72 Deno.mkdir(SEED_DIR, { recursive: true }),
73 ])
74 );
75 yield* createSeedIso(
76 `${SEED_DIR}/seed-${name}.iso`,
77 {
78 metaData: {
79 instanceId: params.instanceId || name,
80 localHostname: params.localHostname || name,
81 hostname: params.hostname || name,
82 },
83 userData: {
84 users: params.users.map((user) => ({
85 name: user.name,
86 shell: user.shell,
87 sudo: user.sudo,
88 sshAuthorizedKeys: user.sshAuthorizedKeys || [],
89 })),
90 sshPwauth: false,
91 },
92 },
93 tempDir,
94 );
95 }
96
97 const macAddress = yield* generateRandomMacAddress();
98 const id = createId();
99 yield* saveInstanceState({
100 id,
101 name,
102 bridge: params.bridge,
103 macAddress,
104 memory: params.memory || "2G",
105 cpus: params.cpus || 8,
106 cpu: params.cpu || "host",
107 diskSize: "20G",
108 diskFormat: volume ? "qcow2" : image.format,
109 portForward: params.portForward
110 ? params.portForward.join(",")
111 : undefined,
112 drivePath: volume ? volume.path : image.path,
113 version: image.tag,
114 status: "STOPPED",
115 seed: _.get(
116 params,
117 "seed",
118 params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined,
119 ),
120 pid: 0,
121 });
122
123 const createdVm = yield* findVm(id);
124 return createdVm;
125 })
126 ),
127 presentation(c),
128 Effect.catchAll((error) => handleError(error, c)),
129 ),
130 ));
131
132app.get("/:id", (c) =>
133 Effect.runPromise(
134 pipe(
135 parseParams(c),
136 Effect.flatMap(({ id }) => getInstanceState(id)),
137 presentation(c),
138 ),
139 ));
140
141app.delete("/:id", (c) =>
142 Effect.runPromise(
143 pipe(
144 parseParams(c),
145 Effect.flatMap(({ id }) => findVm(id)),
146 Effect.flatMap((vm) =>
147 vm.status === "RUNNING"
148 ? Effect.fail(new RemoveRunningVmError({ id: vm.id }))
149 : Effect.succeed(vm)
150 ),
151 Effect.flatMap((vm) =>
152 Effect.gen(function* () {
153 yield* removeInstanceState(vm.id);
154 return vm;
155 })
156 ),
157 presentation(c),
158 Effect.catchAll((error) => handleError(error, c)),
159 ),
160 ));
161
162app.post("/:id/start", (c) =>
163 Effect.runPromise(
164 pipe(
165 Effect.all([parseParams(c), parseStartRequest(c)]),
166 Effect.flatMap(([{ id }, startRequest]) =>
167 Effect.all([findVm(id), Effect.succeed(startRequest)])
168 ),
169 Effect.flatMap(([vm, startRequest]) =>
170 Effect.gen(function* () {
171 yield* failIfVMRunning(vm);
172 const volume = yield* getVolume(vm.drivePath || "");
173 const firmwareArgs = yield* setupFirmware();
174 vm.volume = volume ? volume.path : undefined;
175 const qemuArgs = yield* buildQemuArgs(
176 {
177 ...vm,
178 cpu: String(startRequest.cpu ?? vm.cpu),
179 cpus: startRequest.cpus ?? vm.cpus,
180 memory: startRequest.memory ?? vm.memory,
181 portForward: startRequest.portForward
182 ? startRequest.portForward.join(",")
183 : vm.portForward,
184 },
185 firmwareArgs,
186 );
187 yield* createLogsDir();
188 yield* startDetachedQemu(vm.id, vm, qemuArgs);
189 return { ...vm, status: "RUNNING" };
190 })
191 ),
192 presentation(c),
193 Effect.catchAll((error) => handleError(error, c)),
194 ),
195 ));
196
197app.post("/:id/stop", (c) =>
198 Effect.runPromise(
199 pipe(
200 parseParams(c),
201 Effect.flatMap(({ id }) => findVm(id)),
202 Effect.flatMap(killProcess),
203 Effect.flatMap(updateToStopped),
204 presentation(c),
205 Effect.catchAll((error) => handleError(error, c)),
206 ),
207 ));
208
209app.post("/:id/restart", (c) =>
210 Effect.runPromise(
211 pipe(
212 parseParams(c),
213 Effect.flatMap(({ id }) => findVm(id)),
214 Effect.flatMap(killProcess),
215 Effect.flatMap(updateToStopped),
216 Effect.flatMap(() => Effect.all([parseParams(c), parseStartRequest(c)])),
217 Effect.flatMap(([{ id }, startRequest]) =>
218 Effect.all([findVm(id), Effect.succeed(startRequest)])
219 ),
220 Effect.flatMap(([vm, startRequest]) =>
221 Effect.gen(function* () {
222 const firmwareArgs = yield* setupFirmware();
223 const volume = yield* getVolume(vm.drivePath || "");
224 vm.volume = volume ? volume.path : undefined;
225 const qemuArgs = yield* buildQemuArgs(
226 {
227 ...vm,
228 cpu: String(startRequest.cpus ?? vm.cpu),
229 memory: startRequest.memory ?? vm.memory,
230 portForward: startRequest.portForward
231 ? startRequest.portForward.join(",")
232 : vm.portForward,
233 },
234 firmwareArgs,
235 );
236 yield* createLogsDir();
237 yield* startDetachedQemu(vm.id, vm, qemuArgs);
238 return { ...vm, status: "RUNNING" };
239 })
240 ),
241 presentation(c),
242 Effect.catchAll((error) => handleError(error, c)),
243 ),
244 ));
245
246export default app;