A Docker-like CLI and HTTP API for managing headless VMs
1import { parseFlags } from "@cliffy/flags";
2import _ from "@es-toolkit/es-toolkit/compat";
3import { Effect, pipe } from "effect";
4import { LOGS_DIR } from "../constants.ts";
5import type { VirtualMachine, Volume } from "../db.ts";
6import {
7 CommandError,
8 VmAlreadyRunningError,
9 VmNotFoundError,
10} from "../errors.ts";
11import { getImage } from "../images.ts";
12import { getInstanceState, updateInstanceState } from "../state.ts";
13import {
14 setupAlmaLinuxArgs,
15 setupAlpineArgs,
16 setupCoreOSArgs,
17 setupDebianArgs,
18 setupFedoraArgs,
19 setupFirmwareFilesIfNeeded,
20 setupGentooArgs,
21 setupNATNetworkArgs,
22 setupRockyLinuxArgs,
23 setupUbuntuArgs,
24} from "../utils.ts";
25import { createVolume, getVolume } from "../volumes.ts";
26
27const findVm = (name: string) =>
28 pipe(
29 getInstanceState(name),
30 Effect.flatMap((vm) =>
31 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name }))
32 ),
33 );
34
35const logStarting = (vm: VirtualMachine) =>
36 Effect.sync(() => {
37 console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`);
38 });
39
40const applyFlags = (vm: VirtualMachine) => Effect.succeed(mergeFlags(vm));
41
42export const setupFirmware = () => setupFirmwareFilesIfNeeded();
43
44export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => {
45 const qemu = Deno.build.arch === "aarch64"
46 ? "qemu-system-aarch64"
47 : "qemu-system-x86_64";
48
49 let coreosArgs: string[] = Effect.runSync(setupCoreOSArgs(vm.drivePath));
50 let alpineArgs: string[] = Effect.runSync(
51 setupAlpineArgs(vm.isoPath, vm.seed),
52 );
53 let debianArgs: string[] = Effect.runSync(
54 setupDebianArgs(vm.isoPath, vm.seed),
55 );
56 let ubuntuArgs: string[] = Effect.runSync(
57 setupUbuntuArgs(vm.isoPath, vm.seed),
58 );
59 let almalinuxArgs: string[] = Effect.runSync(
60 setupAlmaLinuxArgs(vm.isoPath, vm.seed),
61 );
62 let rockylinuxArgs: string[] = Effect.runSync(
63 setupRockyLinuxArgs(vm.isoPath, vm.seed),
64 );
65 let gentooArgs: string[] = Effect.runSync(
66 setupGentooArgs(vm.isoPath, vm.seed),
67 );
68 let fedoraArgs: string[] = Effect.runSync(
69 setupFedoraArgs(vm.isoPath, vm.seed),
70 );
71
72 if (coreosArgs.length > 0) {
73 coreosArgs = coreosArgs.slice(2);
74 }
75
76 if (alpineArgs.length > 2) {
77 alpineArgs = alpineArgs.slice(2);
78 }
79
80 if (debianArgs.length > 2) {
81 debianArgs = debianArgs.slice(2);
82 }
83
84 if (ubuntuArgs.length > 2) {
85 ubuntuArgs = ubuntuArgs.slice(2);
86 }
87
88 if (almalinuxArgs.length > 2) {
89 almalinuxArgs = almalinuxArgs.slice(2);
90 }
91
92 if (rockylinuxArgs.length > 2) {
93 rockylinuxArgs = rockylinuxArgs.slice(2);
94 }
95
96 if (gentooArgs.length > 2) {
97 gentooArgs = gentooArgs.slice(2);
98 }
99
100 if (fedoraArgs.length > 2) {
101 fedoraArgs = fedoraArgs.slice(2);
102 }
103
104 return Effect.succeed([
105 ..._.compact([vm.bridge && qemu]),
106 ...(Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"]),
107 ...(Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : []),
108 "-cpu",
109 vm.cpu,
110 "-m",
111 vm.memory,
112 "-smp",
113 vm.cpus.toString(),
114 ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]),
115 "-netdev",
116 vm.bridge
117 ? `bridge,id=net0,br=${vm.bridge}`
118 : setupNATNetworkArgs(vm.portForward),
119 "-device",
120 `e1000,netdev=net0,mac=${vm.macAddress}`,
121 "-nographic",
122 "-monitor",
123 "none",
124 "-chardev",
125 "stdio,id=con0,signal=off",
126 "-serial",
127 "chardev:con0",
128 ...firmwareArgs,
129 ..._.compact(
130 vm.drivePath && [
131 "-drive",
132 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`,
133 ],
134 ),
135 ...coreosArgs,
136 ...alpineArgs,
137 ...debianArgs,
138 ...ubuntuArgs,
139 ...almalinuxArgs,
140 ...rockylinuxArgs,
141 ...gentooArgs,
142 ...fedoraArgs,
143 ...(vm.seed ? ["-drive", `if=virtio,file=${vm.seed},media=cdrom`] : []),
144 ...(vm.volume ? [] : ["-snapshot"]),
145 ]);
146};
147
148export const createLogsDir = () =>
149 Effect.tryPromise({
150 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }),
151 catch: (error) => new CommandError({ cause: error }),
152 });
153
154export const startDetachedQemu = (
155 name: string,
156 vm: VirtualMachine,
157 qemuArgs: string[],
158) => {
159 const qemu = Deno.build.arch === "aarch64"
160 ? "qemu-system-aarch64"
161 : "qemu-system-x86_64";
162
163 const logPath = `${LOGS_DIR}/${vm.name}.log`;
164
165 const fullCommand = vm.bridge
166 ? `sudo ${qemu} ${
167 qemuArgs
168 .slice(1)
169 .join(" ")
170 } >> "${logPath}" 2>&1 & echo $!`
171 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
172
173 return Effect.tryPromise({
174 try: async () => {
175 const cmd = new Deno.Command("sh", {
176 args: ["-c", fullCommand],
177 stdin: "piped",
178 stdout: "piped",
179 }).spawn();
180
181 // Wait 2 seconds and send "1" to boot normally
182 setTimeout(async () => {
183 try {
184 const writer = cmd.stdin.getWriter();
185 await writer.write(new TextEncoder().encode("1\n"));
186 await writer.close();
187 } catch {
188 // Ignore errors if stdin is already closed
189 }
190 }, 2000);
191
192 const { stdout } = await cmd.output();
193 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10);
194 return { qemuPid, logPath };
195 },
196 catch: (error) => new CommandError({ cause: error }),
197 }).pipe(
198 Effect.flatMap(({ qemuPid, logPath }) =>
199 pipe(
200 updateInstanceState(name, "RUNNING", qemuPid),
201 Effect.map(() => ({ vm, qemuPid, logPath })),
202 )
203 ),
204 );
205};
206
207const logDetachedSuccess = ({
208 vm,
209 qemuPid,
210 logPath,
211}: {
212 vm: VirtualMachine;
213 qemuPid: number;
214 logPath: string;
215}) =>
216 Effect.sync(() => {
217 console.log(
218 `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`,
219 );
220 console.log(`Logs will be written to: ${logPath}`);
221 });
222
223const startInteractiveQemu = (
224 name: string,
225 vm: VirtualMachine,
226 qemuArgs: string[],
227) => {
228 const qemu = Deno.build.arch === "aarch64"
229 ? "qemu-system-aarch64"
230 : "qemu-system-x86_64";
231
232 return Effect.tryPromise({
233 try: async () => {
234 const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, {
235 args: qemuArgs,
236 stdin: "inherit",
237 stdout: "inherit",
238 stderr: "inherit",
239 });
240
241 const child = cmd.spawn();
242
243 await Effect.runPromise(updateInstanceState(name, "RUNNING", child.pid));
244
245 const status = await child.status;
246
247 await Effect.runPromise(updateInstanceState(name, "STOPPED", child.pid));
248
249 return status;
250 },
251 catch: (error) => new CommandError({ cause: error }),
252 });
253};
254
255const handleError = (error: VmNotFoundError | CommandError | Error) =>
256 Effect.sync(() => {
257 if (error instanceof VmNotFoundError) {
258 console.error(`Virtual machine with name or ID ${error.name} not found.`);
259 } else {
260 console.error(`An error occurred: ${error}`);
261 }
262 Deno.exit(1);
263 });
264
265export const createVolumeIfNeeded = (
266 vm: VirtualMachine,
267): Effect.Effect<[VirtualMachine, Volume?], Error, never> =>
268 Effect.gen(function* () {
269 const { flags } = parseFlags(Deno.args);
270 if (!flags.volume) {
271 console.log("No volume flag provided, proceeding without volume.");
272 return [vm];
273 }
274 const volume = yield* getVolume(flags.volume as string);
275 if (volume) {
276 return [vm, volume];
277 }
278
279 if (!vm.drivePath) {
280 throw new Error(
281 `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`,
282 );
283 }
284
285 let image = yield* getImage(vm.drivePath);
286
287 if (!image) {
288 const volume = yield* getVolume(vm.drivePath);
289 if (volume) {
290 image = yield* getImage(volume.baseImageId);
291 }
292 }
293
294 const newVolume = yield* createVolume(flags.volume as string, image!);
295 return [vm, newVolume];
296 });
297
298export const failIfVMRunning = (vm: VirtualMachine) =>
299 Effect.gen(function* () {
300 if (vm.status === "RUNNING") {
301 return yield* Effect.fail(new VmAlreadyRunningError({ name: vm.name }));
302 }
303 return vm;
304 });
305
306const startDetachedEffect = (name: string) =>
307 pipe(
308 findVm(name),
309 Effect.flatMap(failIfVMRunning),
310 Effect.tap(logStarting),
311 Effect.flatMap(applyFlags),
312 Effect.flatMap(createVolumeIfNeeded),
313 Effect.flatMap(([vm, volume]) =>
314 pipe(
315 setupFirmware(),
316 Effect.flatMap((firmwareArgs) =>
317 buildQemuArgs(
318 {
319 ...vm,
320 drivePath: volume ? volume.path : vm.drivePath,
321 diskFormat: volume ? "qcow2" : vm.diskFormat,
322 volume: volume?.path,
323 },
324 firmwareArgs,
325 )
326 ),
327 Effect.flatMap((qemuArgs) =>
328 pipe(
329 createLogsDir(),
330 Effect.flatMap(() =>
331 startDetachedQemu(name, { ...vm, volume: volume?.path }, qemuArgs)
332 ),
333 Effect.tap(logDetachedSuccess),
334 Effect.map(() => 0), // Exit code 0
335 )
336 ),
337 )
338 ),
339 Effect.catchAll(handleError),
340 );
341
342const startInteractiveEffect = (name: string) =>
343 pipe(
344 findVm(name),
345 Effect.flatMap(failIfVMRunning),
346 Effect.tap(logStarting),
347 Effect.flatMap(applyFlags),
348 Effect.flatMap(createVolumeIfNeeded),
349 Effect.flatMap(([vm, volume]) =>
350 pipe(
351 setupFirmware(),
352 Effect.flatMap((firmwareArgs) =>
353 buildQemuArgs(
354 {
355 ...vm,
356 drivePath: volume ? volume.path : vm.drivePath,
357 diskFormat: volume ? "qcow2" : vm.diskFormat,
358 volume: volume?.path,
359 },
360 firmwareArgs,
361 )
362 ),
363 Effect.flatMap((qemuArgs) =>
364 startInteractiveQemu(name, { ...vm, volume: volume?.path }, qemuArgs)
365 ),
366 Effect.map((status) => (status.success ? 0 : status.code || 1)),
367 )
368 ),
369 Effect.catchAll(handleError),
370 );
371
372export default async function (name: string, detach: boolean = false) {
373 const exitCode = await Effect.runPromise(
374 detach ? startDetachedEffect(name) : startInteractiveEffect(name),
375 );
376
377 if (detach) {
378 Deno.exit(exitCode);
379 } else if (exitCode !== 0) {
380 Deno.exit(exitCode);
381 }
382}
383
384function mergeFlags(vm: VirtualMachine): VirtualMachine {
385 const { flags } = parseFlags(Deno.args);
386 return {
387 ...vm,
388 memory: flags.memory || flags.m
389 ? String(flags.memory || flags.m)
390 : vm.memory,
391 cpus: flags.cpus || flags.C ? Number(flags.cpus || flags.C) : vm.cpus,
392 cpu: flags.cpu || flags.c ? String(flags.cpu || flags.c) : vm.cpu,
393 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat,
394 portForward: flags.portForward || flags.p
395 ? String(flags.portForward || flags.p)
396 : vm.portForward,
397 drivePath: flags.image || flags.i
398 ? String(flags.image || flags.i)
399 : vm.drivePath,
400 bridge: flags.bridge || flags.b
401 ? String(flags.bridge || flags.b)
402 : vm.bridge,
403 diskSize: flags.size || flags.s
404 ? String(flags.size || flags.s)
405 : vm.diskSize,
406 seed: flags.seed ? String(flags.seed) : vm.seed,
407 };
408}