A convenient CLI tool to quickly spin up DragonflyBSD 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 } from "../db.ts";
6import { getImage } from "../images.ts";
7import { getInstanceStateOrFail, updateInstanceState } from "../state.ts";
8import { setupNATNetworkArgs } from "../utils.ts";
9import { createVolume, getVolume } from "../volumes.ts";
10
11export class VmAlreadyRunningError
12 extends Data.TaggedError("VmAlreadyRunningError")<{
13 name: string;
14 }> {}
15
16const logStartingMessage = (vm: VirtualMachine) =>
17 Effect.sync(() => {
18 console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`);
19 });
20
21export const buildQemuArgs = (vm: VirtualMachine) => [
22 ..._.compact([vm.bridge && "qemu-system-x86_64"]),
23 ...Deno.build.os === "linux" ? ["-enable-kvm"] : [],
24 "-cpu",
25 vm.cpu,
26 "-m",
27 vm.memory,
28 "-smp",
29 vm.cpus.toString(),
30 ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]),
31 "-netdev",
32 vm.bridge
33 ? `bridge,id=net0,br=${vm.bridge}`
34 : setupNATNetworkArgs(vm.portForward),
35 "-device",
36 `e1000,netdev=net0,mac=${vm.macAddress}`,
37 "-display",
38 "none",
39 "-vga",
40 "none",
41 "-monitor",
42 "none",
43 "-chardev",
44 "stdio,id=con0,signal=off",
45 "-serial",
46 "chardev:con0",
47 ..._.compact(
48 vm.drivePath && [
49 "-drive",
50 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`,
51 ],
52 ),
53];
54
55export const createLogsDir = () =>
56 Effect.tryPromise({
57 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }),
58 catch: (cause) => new Error(`Failed to create logs directory: ${cause}`),
59 });
60
61export const buildDetachedCommand = (
62 vm: VirtualMachine,
63 qemuArgs: string[],
64 logPath: string,
65) =>
66 vm.bridge
67 ? `sudo qemu-system-x86_64 ${
68 qemuArgs.slice(1).join(" ")
69 } >> "${logPath}" 2>&1 & echo $!`
70 : `qemu-system-x86_64 ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
71
72export const startDetachedQemu = (fullCommand: string) =>
73 Effect.tryPromise({
74 try: async () => {
75 const cmd = new Deno.Command("sh", {
76 args: ["-c", fullCommand],
77 stdin: "null",
78 stdout: "piped",
79 }).spawn();
80
81 await new Promise((resolve) => setTimeout(resolve, 2000));
82
83 const { stdout } = await cmd.output();
84 return parseInt(new TextDecoder().decode(stdout).trim(), 10);
85 },
86 catch: (cause) => new Error(`Failed to start QEMU: ${cause}`),
87 });
88
89const logDetachedSuccess = (
90 vm: VirtualMachine,
91 qemuPid: number,
92 logPath: string,
93) =>
94 Effect.sync(() => {
95 console.log(
96 `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`,
97 );
98 console.log(`Logs will be written to: ${logPath}`);
99 Deno.exit(0);
100 });
101
102const startVirtualMachineDetached = (name: string, vm: VirtualMachine) =>
103 Effect.gen(function* () {
104 yield* failIfVMRunning(vm);
105 const volume = yield* createVolumeIfNeeded(vm);
106 const qemuArgs = buildQemuArgs({
107 ...vm,
108 drivePath: volume ? volume.path : vm.drivePath,
109 diskFormat: volume ? "qcow2" : vm.diskFormat,
110 });
111 const logPath = `${LOGS_DIR}/${vm.name}.log`;
112 const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath);
113
114 return yield* pipe(
115 createLogsDir(),
116 Effect.flatMap(() => startDetachedQemu(fullCommand)),
117 Effect.flatMap((qemuPid) =>
118 pipe(
119 updateInstanceState(name, "RUNNING", qemuPid),
120 Effect.flatMap(() => logDetachedSuccess(vm, qemuPid, logPath)),
121 )
122 ),
123 );
124 });
125
126const startAttachedQemu = (
127 name: string,
128 vm: VirtualMachine,
129 qemuArgs: string[],
130) =>
131 Effect.tryPromise({
132 try: async () => {
133 const cmd = new Deno.Command(
134 vm.bridge ? "sudo" : "qemu-system-x86_64",
135 {
136 args: qemuArgs,
137 stdin: "inherit",
138 stdout: "inherit",
139 stderr: "inherit",
140 },
141 );
142
143 const child = cmd.spawn();
144 await Effect.runPromise(
145 updateInstanceState(name, "RUNNING", child.pid),
146 );
147
148 const status = await child.status;
149 await Effect.runPromise(
150 updateInstanceState(name, "STOPPED", child.pid),
151 );
152
153 return status;
154 },
155 catch: (cause) => new Error(`Failed to run QEMU: ${cause}`),
156 });
157
158const validateQemuExit = (status: Deno.CommandStatus) =>
159 Effect.sync(() => {
160 if (!status.success) {
161 throw new Error(`QEMU exited with code ${status.code}`);
162 }
163 });
164
165export const failIfVMRunning = (vm: VirtualMachine) =>
166 Effect.gen(function* () {
167 if (vm.status === "RUNNING") {
168 return yield* Effect.fail(
169 new VmAlreadyRunningError({ name: vm.name }),
170 );
171 }
172 return vm;
173 });
174
175const createVolumeIfNeeded = (vm: VirtualMachine) =>
176 Effect.gen(function* () {
177 const { flags } = parseFlags(Deno.args);
178 if (!flags.volume) {
179 return;
180 }
181 const volume = yield* getVolume(flags.volume as string);
182 if (volume) {
183 return volume;
184 }
185
186 if (!vm.drivePath) {
187 throw new Error(
188 `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`,
189 );
190 }
191
192 let image = yield* getImage(vm.drivePath);
193
194 if (!image) {
195 const volume = yield* getVolume(vm.drivePath);
196 if (volume) {
197 image = yield* getImage(volume.baseImageId);
198 }
199 }
200
201 const newVolume = yield* createVolume(flags.volume as string, image!);
202 return newVolume;
203 });
204
205const startVirtualMachineAttached = (name: string, vm: VirtualMachine) => {
206 return pipe(
207 failIfVMRunning(vm),
208 Effect.flatMap(() => createVolumeIfNeeded(vm)),
209 Effect.flatMap((volume) =>
210 Effect.succeed(
211 buildQemuArgs({
212 ...vm,
213 drivePath: volume ? volume.path : vm.drivePath,
214 diskFormat: volume ? "qcow2" : vm.diskFormat,
215 }),
216 )
217 ),
218 Effect.flatMap((qemuArgs) => startAttachedQemu(name, vm, qemuArgs)),
219 Effect.flatMap(validateQemuExit),
220 );
221};
222
223const startVirtualMachine = (name: string, detach: boolean = false) =>
224 pipe(
225 getInstanceStateOrFail(name),
226 Effect.flatMap((vm) => {
227 const mergedVm = mergeFlags(vm);
228
229 return pipe(
230 logStartingMessage(mergedVm),
231 Effect.flatMap(() =>
232 detach
233 ? startVirtualMachineDetached(name, mergedVm)
234 : startVirtualMachineAttached(name, mergedVm)
235 ),
236 );
237 }),
238 );
239
240export default async function (name: string, detach: boolean = false) {
241 const program = pipe(
242 startVirtualMachine(name, detach),
243 Effect.catchTags({
244 InstanceNotFoundError: (_error) =>
245 Effect.sync(() => {
246 console.error(`Virtual machine with name or ID ${name} not found.`);
247 Deno.exit(1);
248 }),
249 }),
250 Effect.catchAll((error) =>
251 Effect.sync(() => {
252 console.error(`Error: ${String(error)}`);
253 Deno.exit(1);
254 })
255 ),
256 );
257
258 await Effect.runPromise(program);
259}
260
261function mergeFlags(vm: VirtualMachine): VirtualMachine {
262 const { flags } = parseFlags(Deno.args);
263 return {
264 ...vm,
265 memory: (flags.memory || flags.m)
266 ? String(flags.memory || flags.m)
267 : vm.memory,
268 cpus: (flags.cpus || flags.C) ? Number(flags.cpus || flags.C) : vm.cpus,
269 cpu: (flags.cpu || flags.c) ? String(flags.cpu || flags.c) : vm.cpu,
270 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat,
271 portForward: (flags.portForward || flags.p)
272 ? String(flags.portForward || flags.p)
273 : vm.portForward,
274 drivePath: (flags.image || flags.i)
275 ? String(flags.image || flags.i)
276 : vm.drivePath,
277 bridge: (flags.bridge || flags.b)
278 ? String(flags.bridge || flags.b)
279 : vm.bridge,
280 diskSize: (flags.size || flags.s)
281 ? String(flags.size || flags.s)
282 : vm.diskSize,
283 };
284}