A simple command-line tool to start NetBSD virtual machines using QEMU with sensible defaults.
1import _ from "@es-toolkit/es-toolkit/compat";
2import chalk from "chalk";
3import { Data, Effect, pipe } from "effect";
4import { LOGS_DIR } from "../constants.ts";
5import type { VirtualMachine } from "../db.ts";
6import { getInstanceState, updateInstanceState } from "../state.ts";
7import {
8 safeKillQemu,
9 setupFirmwareFilesIfNeeded,
10 setupNATNetworkArgs,
11} from "../utils.ts";
12
13class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{
14 name: string;
15}> {}
16
17class KillQemuError extends Data.TaggedError("KillQemuError")<{
18 vmName: string;
19}> {}
20
21class CommandError extends Data.TaggedError("CommandError")<{
22 cause?: unknown;
23}> {}
24
25const findVm = (name: string) =>
26 pipe(
27 getInstanceState(name),
28 Effect.flatMap((vm) =>
29 vm ? Effect.succeed(vm) : Effect.fail(new VmNotFoundError({ name }))
30 ),
31 );
32
33const killQemu = (vm: VirtualMachine) =>
34 safeKillQemu(vm.pid, Boolean(vm.bridge)).pipe(
35 Effect.flatMap((success) =>
36 success
37 ? Effect.succeed(vm)
38 : Effect.fail(new KillQemuError({ vmName: vm.name }))
39 ),
40 );
41
42const sleep = (ms: number) =>
43 Effect.tryPromise({
44 try: () => new Promise((resolve) => setTimeout(resolve, ms)),
45 catch: (error) => new CommandError({ cause: error }),
46 });
47
48const createLogsDir = () =>
49 Effect.tryPromise({
50 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }),
51 catch: (error) => new CommandError({ cause: error }),
52 });
53
54const setupFirmware = () => setupFirmwareFilesIfNeeded();
55
56const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => {
57 const qemu = Deno.build.arch === "aarch64"
58 ? "qemu-system-aarch64"
59 : "qemu-system-x86_64";
60
61 return Effect.succeed([
62 ..._.compact([vm.bridge && qemu]),
63 ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"],
64 ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [],
65 "-cpu",
66 vm.cpu,
67 "-m",
68 vm.memory,
69 "-smp",
70 vm.cpus.toString(),
71 ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]),
72 "-netdev",
73 vm.bridge
74 ? `bridge,id=net0,br=${vm.bridge}`
75 : setupNATNetworkArgs(vm.portForward),
76 "-device",
77 `e1000,netdev=net0,mac=${vm.macAddress}`,
78 "-nographic",
79 "-monitor",
80 "none",
81 "-chardev",
82 "stdio,id=con0,signal=off",
83 "-serial",
84 "chardev:con0",
85 ...firmwareArgs,
86 ..._.compact(
87 vm.drivePath && [
88 "-drive",
89 `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`,
90 ],
91 ),
92 "-object",
93 "rng-random,filename=/dev/urandom,id=rng0",
94 "-device",
95 "virtio-rng-pci,rng=rng0",
96 ]);
97};
98
99const startQemu = (vm: VirtualMachine, qemuArgs: string[]) => {
100 const qemu = Deno.build.arch === "aarch64"
101 ? "qemu-system-aarch64"
102 : "qemu-system-x86_64";
103
104 const logPath = `${LOGS_DIR}/${vm.name}.log`;
105
106 const fullCommand = vm.bridge
107 ? `sudo ${qemu} ${
108 qemuArgs.slice(1).join(" ")
109 } >> "${logPath}" 2>&1 & echo $!`
110 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
111
112 return Effect.tryPromise({
113 try: async () => {
114 const cmd = new Deno.Command("sh", {
115 args: ["-c", fullCommand],
116 stdin: "null",
117 stdout: "piped",
118 });
119
120 const { stdout } = await cmd.spawn().output();
121 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10);
122 return { qemuPid, logPath };
123 },
124 catch: (error) => new CommandError({ cause: error }),
125 });
126};
127
128const logSuccess = (vm: VirtualMachine, qemuPid: number, logPath: string) =>
129 Effect.sync(() => {
130 console.log(
131 `${chalk.greenBright(vm.name)} restarted with PID ${
132 chalk.greenBright(qemuPid)
133 }.`,
134 );
135 console.log(
136 `Logs are being written to ${chalk.blueBright(logPath)}`,
137 );
138 });
139
140const handleError = (
141 error: VmNotFoundError | KillQemuError | CommandError | Error,
142) =>
143 Effect.sync(() => {
144 if (error instanceof VmNotFoundError) {
145 console.error(
146 `Virtual machine with name or ID ${
147 chalk.greenBright(error.name)
148 } not found.`,
149 );
150 } else if (error instanceof KillQemuError) {
151 console.error(
152 `Failed to stop virtual machine ${chalk.greenBright(error.vmName)}.`,
153 );
154 } else {
155 console.error(`An error occurred: ${error}`);
156 }
157 Deno.exit(1);
158 });
159
160const restartEffect = (name: string) =>
161 pipe(
162 findVm(name),
163 Effect.tap((vm) => Effect.log(`Found VM: ${vm.name}`)),
164 Effect.flatMap(killQemu),
165 Effect.tap((vm) => updateInstanceState(vm.id, "STOPPED")),
166 Effect.flatMap((vm) =>
167 pipe(
168 sleep(2000),
169 Effect.flatMap(() => createLogsDir()),
170 Effect.flatMap(() => setupFirmware()),
171 Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)),
172 Effect.flatMap((qemuArgs) => startQemu(vm, qemuArgs)),
173 Effect.tap(() => sleep(2000)),
174 Effect.flatMap(({ qemuPid, logPath }) =>
175 pipe(
176 updateInstanceState(vm.id, "RUNNING", qemuPid),
177 Effect.flatMap(() => logSuccess(vm, qemuPid, logPath)),
178 Effect.flatMap(() => sleep(2000)),
179 )
180 ),
181 )
182 ),
183 Effect.catchAll(handleError),
184 );
185
186export default async function (name: string) {
187 await Effect.runPromise(restartEffect(name));
188 Deno.exit(0);
189}