A simple command-line tool to start NetBSD virtual machines using QEMU with sensible defaults.
1import { parseFlags } from "@cliffy/flags";
2import _ from "@es-toolkit/es-toolkit/compat";
3import { Data, Effect, pipe } from "effect";
4import { LOGS_DIR } from "../constants.ts";
5import type { VirtualMachine, Volume } from "../db.ts";
6import { getImage } from "../images.ts";
7import { getInstanceState, updateInstanceState } from "../state.ts";
8import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts";
9import { createVolume, getVolume } from "../volumes.ts";
10
11export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{
12 name: string;
13}> {}
14
15export class VmAlreadyRunningError
16 extends Data.TaggedError("VmAlreadyRunningError")<{
17 name: string;
18 }> {}
19
20export class CommandError extends Data.TaggedError("CommandError")<{
21 cause?: unknown;
22}> {}
23
24export const findVm = (name: string) =>
25 pipe(
26 getInstanceState(name),
27 Effect.flatMap((vm) =>
28 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name }))
29 ),
30 );
31
32const logStarting = (vm: VirtualMachine) =>
33 Effect.sync(() => {
34 console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`);
35 });
36
37const applyFlags = (vm: VirtualMachine) => Effect.succeed(mergeFlags(vm));
38
39export const setupFirmware = () => setupFirmwareFilesIfNeeded();
40
41export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => {
42 const qemu = Deno.build.arch === "aarch64"
43 ? "qemu-system-aarch64"
44 : "qemu-system-x86_64";
45
46 return Effect.succeed([
47 ..._.compact([vm.bridge && qemu]),
48 ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"],
49 ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [],
50 "-cpu",
51 vm.cpu,
52 "-m",
53 vm.memory,
54 "-smp",
55 vm.cpus.toString(),
56 ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]),
57 "-netdev",
58 vm.bridge
59 ? `bridge,id=net0,br=${vm.bridge}`
60 : setupNATNetworkArgs(vm.portForward),
61 "-device",
62 `e1000,netdev=net0,mac=${vm.macAddress}`,
63 "-nographic",
64 "-monitor",
65 "none",
66 "-chardev",
67 "stdio,id=con0,signal=off",
68 "-serial",
69 "chardev:con0",
70 ...firmwareArgs,
71 ..._.compact(
72 vm.drivePath && [
73 "-drive",
74 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`,
75 ],
76 ),
77 "-object",
78 "rng-random,filename=/dev/urandom,id=rng0",
79 "-device",
80 "virtio-rng-pci,rng=rng0",
81 ]);
82};
83
84export const createLogsDir = () =>
85 Effect.tryPromise({
86 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }),
87 catch: (error) => new CommandError({ cause: error }),
88 });
89
90export const failIfVMRunning = (vm: VirtualMachine) =>
91 Effect.gen(function* () {
92 if (vm.status === "RUNNING") {
93 return yield* Effect.fail(
94 new VmAlreadyRunningError({ name: vm.name }),
95 );
96 }
97 return vm;
98 });
99
100export const startDetachedQemu = (
101 name: string,
102 vm: VirtualMachine,
103 qemuArgs: string[],
104) => {
105 const qemu = Deno.build.arch === "aarch64"
106 ? "qemu-system-aarch64"
107 : "qemu-system-x86_64";
108
109 const logPath = `${LOGS_DIR}/${vm.name}.log`;
110
111 const fullCommand = vm.bridge
112 ? `sudo ${qemu} ${
113 qemuArgs.slice(1).join(" ")
114 } >> "${logPath}" 2>&1 & echo $!`
115 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
116
117 return Effect.tryPromise({
118 try: async () => {
119 const cmd = new Deno.Command("sh", {
120 args: ["-c", fullCommand],
121 stdin: "null",
122 stdout: "piped",
123 });
124
125 const { stdout } = await cmd.spawn().output();
126 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10);
127 return { qemuPid, logPath };
128 },
129 catch: (error) => new CommandError({ cause: error }),
130 }).pipe(
131 Effect.flatMap(({ qemuPid, logPath }) =>
132 pipe(
133 updateInstanceState(name, "RUNNING", qemuPid),
134 Effect.map(() => ({ vm, qemuPid, logPath })),
135 )
136 ),
137 );
138};
139
140const logDetachedSuccess = (
141 { vm, qemuPid, logPath }: {
142 vm: VirtualMachine;
143 qemuPid: number;
144 logPath: string;
145 },
146) =>
147 Effect.sync(() => {
148 console.log(
149 `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`,
150 );
151 console.log(`Logs will be written to: ${logPath}`);
152 });
153
154const startInteractiveQemu = (
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 return Effect.tryPromise({
164 try: async () => {
165 const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, {
166 args: qemuArgs,
167 stdin: "inherit",
168 stdout: "inherit",
169 stderr: "inherit",
170 });
171
172 const child = cmd.spawn();
173
174 await Effect.runPromise(updateInstanceState(name, "RUNNING", child.pid));
175
176 const status = await child.status;
177
178 await Effect.runPromise(updateInstanceState(name, "STOPPED", child.pid));
179
180 return status;
181 },
182 catch: (error) => new CommandError({ cause: error }),
183 });
184};
185
186const handleError = (error: VmNotFoundError | CommandError | Error) =>
187 Effect.sync(() => {
188 if (error instanceof VmNotFoundError) {
189 console.error(
190 `Virtual machine with name or ID ${error.name} not found.`,
191 );
192 } else {
193 console.error(`An error occurred: ${error}`);
194 }
195 Deno.exit(1);
196 });
197
198const createVolumeIfNeeded = (
199 vm: VirtualMachine,
200): Effect.Effect<[VirtualMachine, Volume?], Error, never> =>
201 Effect.gen(function* () {
202 const { flags } = parseFlags(Deno.args);
203 if (!flags.volume) {
204 return [vm];
205 }
206 const volume = yield* getVolume(flags.volume as string);
207 if (volume) {
208 return [vm, volume];
209 }
210
211 if (!vm.drivePath) {
212 throw new Error(
213 `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`,
214 );
215 }
216
217 let image = yield* getImage(vm.drivePath);
218
219 if (!image) {
220 const volume = yield* getVolume(vm.drivePath);
221 if (volume) {
222 image = yield* getImage(volume.baseImageId);
223 }
224 }
225
226 const newVolume = yield* createVolume(flags.volume as string, image!);
227 return [vm, newVolume];
228 });
229
230const startDetachedEffect = (name: string) =>
231 pipe(
232 findVm(name),
233 Effect.tap(logStarting),
234 Effect.flatMap(applyFlags),
235 Effect.flatMap(createVolumeIfNeeded),
236 Effect.flatMap(([vm, volume]) =>
237 pipe(
238 setupFirmware(),
239 Effect.flatMap((firmwareArgs) =>
240 buildQemuArgs({
241 ...vm,
242 drivePath: volume ? volume.path : vm.drivePath,
243 diskFormat: volume ? "qcow2" : vm.diskFormat,
244 }, firmwareArgs)
245 ),
246 Effect.flatMap((qemuArgs) =>
247 pipe(
248 createLogsDir(),
249 Effect.flatMap(() => startDetachedQemu(name, vm, qemuArgs)),
250 Effect.tap(logDetachedSuccess),
251 Effect.map(() => 0), // Exit code 0
252 )
253 ),
254 )
255 ),
256 Effect.catchAll(handleError),
257 );
258
259const startInteractiveEffect = (name: string) =>
260 pipe(
261 findVm(name),
262 Effect.tap(logStarting),
263 Effect.flatMap(applyFlags),
264 Effect.flatMap(createVolumeIfNeeded),
265 Effect.flatMap(([vm, volume]) =>
266 pipe(
267 setupFirmware(),
268 Effect.flatMap((firmwareArgs) =>
269 buildQemuArgs({
270 ...vm,
271 drivePath: volume ? volume.path : vm.drivePath,
272 diskFormat: volume ? "qcow2" : vm.diskFormat,
273 }, firmwareArgs)
274 ),
275 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)),
276 Effect.map((status) => status.success ? 0 : (status.code || 1)),
277 )
278 ),
279 Effect.catchAll(handleError),
280 );
281
282export default async function (name: string, detach: boolean = false) {
283 const exitCode = await Effect.runPromise(
284 detach ? startDetachedEffect(name) : startInteractiveEffect(name),
285 );
286
287 if (detach) {
288 Deno.exit(exitCode);
289 } else if (exitCode !== 0) {
290 Deno.exit(exitCode);
291 }
292}
293
294function mergeFlags(vm: VirtualMachine): VirtualMachine {
295 const { flags } = parseFlags(Deno.args);
296 return {
297 ...vm,
298 memory: (flags.memory || flags.m)
299 ? String(flags.memory || flags.m)
300 : vm.memory,
301 cpus: (flags.cpus || flags.C) ? Number(flags.cpus || flags.C) : vm.cpus,
302 cpu: (flags.cpu || flags.c) ? String(flags.cpu || flags.c) : vm.cpu,
303 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat,
304 portForward: (flags.portForward || flags.p)
305 ? String(flags.portForward || flags.p)
306 : vm.portForward,
307 drivePath: (flags.image || flags.i)
308 ? String(flags.image || flags.i)
309 : vm.drivePath,
310 bridge: (flags.bridge || flags.b)
311 ? String(flags.bridge || flags.b)
312 : vm.bridge,
313 diskSize: (flags.size || flags.s)
314 ? String(flags.size || flags.s)
315 : vm.diskSize,
316 };
317}