A convenient CLI tool to quickly spin up DragonflyBSD virtual machines using QEMU with sensible defaults.
1import _ from "@es-toolkit/es-toolkit/compat";
2import chalk from "chalk";
3import { Effect, pipe } from "effect";
4import { LOGS_DIR } from "../constants.ts";
5import type { VirtualMachine } from "../db.ts";
6import { getInstanceStateOrFail, updateInstanceState } from "../state.ts";
7import { safeKillQemu, setupNATNetworkArgs } from "../utils.ts";
8
9const killQemuProcess = (vm: VirtualMachine) =>
10 pipe(
11 safeKillQemu(vm.pid, Boolean(vm.bridge)),
12 Effect.flatMap((success) => {
13 if (!success) {
14 return Effect.fail(
15 new Error(`Failed to stop virtual machine ${vm.name}`),
16 );
17 }
18 return Effect.succeed(vm);
19 }),
20 );
21
22const markInstanceAsStopped = (vm: VirtualMachine) =>
23 updateInstanceState(vm.id, "STOPPED");
24
25const waitForDelay = (ms: number) =>
26 Effect.tryPromise({
27 try: () => new Promise((resolve) => setTimeout(resolve, ms)),
28 catch: () => new Error("Sleep failed"),
29 });
30
31const createLogsDirectory = () =>
32 Effect.tryPromise({
33 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }),
34 catch: (cause) => new Error(`Failed to create logs directory: ${cause}`),
35 });
36
37const buildQemuArgs = (vm: VirtualMachine) => [
38 ..._.compact([vm.bridge && "qemu-system-x86_64"]),
39 ...Deno.build.os === "linux" ? ["-enable-kvm"] : [],
40 "-cpu",
41 vm.cpu,
42 "-m",
43 vm.memory,
44 "-smp",
45 vm.cpus.toString(),
46 ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]),
47 "-netdev",
48 vm.bridge
49 ? `bridge,id=net0,br=${vm.bridge}`
50 : setupNATNetworkArgs(vm.portForward),
51 "-device",
52 `e1000,netdev=net0,mac=${vm.macAddress}`,
53 "-display",
54 "none",
55 "-vga",
56 "none",
57 "-monitor",
58 "none",
59 "-chardev",
60 "stdio,id=con0,signal=off",
61 "-serial",
62 "chardev:con0",
63 ..._.compact(
64 vm.drivePath && [
65 "-drive",
66 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`,
67 ],
68 ),
69];
70
71const buildQemuCommand = (vm: VirtualMachine, logPath: string) => {
72 const qemuArgs = buildQemuArgs(vm);
73 return vm.bridge
74 ? `sudo qemu-system-x86_64 ${
75 qemuArgs.slice(1).join(" ")
76 } >> "${logPath}" 2>&1 & echo $!`
77 : `qemu-system-x86_64 ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
78};
79
80const startQemuProcess = (fullCommand: string) =>
81 Effect.tryPromise({
82 try: async () => {
83 const cmd = new Deno.Command("sh", {
84 args: ["-c", fullCommand],
85 stdin: "null",
86 stdout: "piped",
87 });
88
89 const { stdout } = await cmd.spawn().output();
90 return parseInt(new TextDecoder().decode(stdout).trim(), 10);
91 },
92 catch: (cause) => new Error(`Failed to start QEMU: ${cause}`),
93 });
94
95const markInstanceAsRunning = (vm: VirtualMachine, qemuPid: number) =>
96 updateInstanceState(vm.id, "RUNNING", qemuPid);
97
98const logRestartSuccess = (
99 vm: VirtualMachine,
100 qemuPid: number,
101 logPath: string,
102) =>
103 Effect.sync(() => {
104 console.log(
105 `${chalk.greenBright(vm.name)} restarted with PID ${
106 chalk.greenBright(qemuPid)
107 }.`,
108 );
109 console.log(
110 `Logs are being written to ${chalk.blueBright(logPath)}`,
111 );
112 });
113
114const startVirtualMachineProcess = (vm: VirtualMachine) => {
115 const logPath = `${LOGS_DIR}/${vm.name}.log`;
116 const fullCommand = buildQemuCommand(vm, logPath);
117
118 return pipe(
119 startQemuProcess(fullCommand),
120 Effect.flatMap((qemuPid) =>
121 pipe(
122 waitForDelay(2000),
123 Effect.flatMap(() => markInstanceAsRunning(vm, qemuPid)),
124 Effect.flatMap(() => logRestartSuccess(vm, qemuPid, logPath)),
125 )
126 ),
127 );
128};
129
130const restartVirtualMachine = (name: string) =>
131 pipe(
132 getInstanceStateOrFail(name),
133 Effect.flatMap((vm) =>
134 pipe(
135 killQemuProcess(vm),
136 Effect.flatMap(() => markInstanceAsStopped(vm)),
137 Effect.flatMap(() => waitForDelay(2000)),
138 Effect.flatMap(() => createLogsDirectory()),
139 Effect.flatMap(() => startVirtualMachineProcess(vm)),
140 )
141 ),
142 );
143
144export default async function (name: string) {
145 const program = pipe(
146 restartVirtualMachine(name),
147 Effect.catchTags({
148 InstanceNotFoundError: (_error) =>
149 Effect.sync(() => {
150 console.error(
151 `Virtual machine with name or ID ${
152 chalk.greenBright(name)
153 } not found.`,
154 );
155 Deno.exit(1);
156 }),
157 }),
158 Effect.catchAll((error) =>
159 Effect.sync(() => {
160 console.error(`Error: ${error.message}`);
161 Deno.exit(1);
162 })
163 ),
164 );
165
166 await Effect.runPromise(program);
167
168 await new Promise((resolve) => setTimeout(resolve, 2000));
169 Deno.exit(0);
170}