A simple command-line tool to start NetBSD virtual machines using QEMU with sensible defaults.
1import _ from "@es-toolkit/es-toolkit/compat";
2import { createId } from "@paralleldrive/cuid2";
3import chalk from "chalk";
4import { Data, Effect, pipe } from "effect";
5import Moniker from "moniker";
6import { EMPTY_DISK_THRESHOLD_KB, LOGS_DIR } from "./constants.ts";
7import type { Image } from "./db.ts";
8import { generateRandomMacAddress } from "./network.ts";
9import { saveInstanceState, updateInstanceState } from "./state.ts";
10
11export const DEFAULT_VERSION = "10.1";
12
13export interface Options {
14 output?: string;
15 cpu: string;
16 cpus: number;
17 memory: string;
18 image?: string;
19 diskFormat?: string;
20 size?: string;
21 bridge?: string;
22 portForward?: string;
23 detach?: boolean;
24 install?: boolean;
25 volume?: string;
26}
27
28class LogCommandError extends Data.TaggedError("LogCommandError")<{
29 cause?: unknown;
30}> {}
31
32class InvalidImageNameError extends Data.TaggedError("InvalidImageNameError")<{
33 image: string;
34 cause?: unknown;
35}> {}
36
37class NoSuchImageError extends Data.TaggedError("NoSuchImageError")<{
38 cause: string;
39}> {}
40
41export const getCurrentArch = (): string => {
42 switch (Deno.build.arch) {
43 case "x86_64":
44 return "amd64";
45 case "aarch64":
46 return "arm64";
47 default:
48 return Deno.build.arch;
49 }
50};
51
52export const isValidISOurl = (url?: string): boolean => {
53 return Boolean(
54 (url?.startsWith("http://") || url?.startsWith("https://")) &&
55 url?.endsWith(".iso"),
56 );
57};
58
59export const humanFileSize = (blocks: number) =>
60 Effect.sync(() => {
61 const blockSize = 512; // bytes per block
62 let bytes = blocks * blockSize;
63 const thresh = 1024;
64
65 if (Math.abs(bytes) < thresh) {
66 return `${bytes}B`;
67 }
68
69 const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
70 let u = -1;
71
72 do {
73 bytes /= thresh;
74 ++u;
75 } while (Math.abs(bytes) >= thresh && u < units.length - 1);
76
77 return `${bytes.toFixed(1)}${units[u]}`;
78 });
79
80export const validateImage = (
81 image: string,
82): Effect.Effect<string, InvalidImageNameError, never> => {
83 const regex =
84 /^(?:[a-zA-Z0-9.-]+(?:\.[a-zA-Z0-9.-]+)*\/)?[a-z0-9]+(?:[._-][a-z0-9]+)*\/[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[a-zA-Z0-9._-]+)?$/;
85
86 if (!regex.test(image)) {
87 return Effect.fail(
88 new InvalidImageNameError({
89 image,
90 cause:
91 "Image name does not conform to expected format. Should be in the format 'repository/name:tag'.",
92 }),
93 );
94 }
95 return Effect.succeed(image);
96};
97
98export const extractTag = (name: string) =>
99 pipe(
100 validateImage(name),
101 Effect.flatMap((image) => Effect.succeed(image.split(":")[1] || "latest")),
102 );
103
104export const failOnMissingImage = (
105 image: Image | undefined,
106): Effect.Effect<Image, Error, never> =>
107 image
108 ? Effect.succeed(image)
109 : Effect.fail(new NoSuchImageError({ cause: "No such image" }));
110
111export const du = (
112 path: string,
113): Effect.Effect<number, LogCommandError, never> =>
114 Effect.tryPromise({
115 try: async () => {
116 const cmd = new Deno.Command("du", {
117 args: [path],
118 stdout: "piped",
119 stderr: "inherit",
120 });
121
122 const { stdout } = await cmd.spawn().output();
123 const output = new TextDecoder().decode(stdout).trim();
124 const size = parseInt(output.split("\t")[0], 10);
125 return size;
126 },
127 catch: (error) => new LogCommandError({ cause: error }),
128 });
129
130export const emptyDiskImage = (path: string) =>
131 Effect.tryPromise({
132 try: async () => {
133 if (!await Deno.stat(path).catch(() => false)) {
134 return true;
135 }
136 return false;
137 },
138 catch: (error) => new LogCommandError({ cause: error }),
139 }).pipe(
140 Effect.flatMap((exists) =>
141 exists ? Effect.succeed(true) : du(path).pipe(
142 Effect.map((size) => size < EMPTY_DISK_THRESHOLD_KB),
143 )
144 ),
145 );
146
147export const downloadIso = (
148 url: string,
149 options: Options,
150) =>
151 Effect.gen(function* () {
152 const filename = url.split("/").pop()!;
153 const outputPath = options.output ?? filename;
154
155 if (options.image) {
156 const imageExists = yield* Effect.tryPromise({
157 try: () =>
158 Deno.stat(options.image!).then(() => true).catch(() => false),
159 catch: (error) => new LogCommandError({ cause: error }),
160 });
161
162 if (imageExists) {
163 const driveSize = yield* du(options.image);
164 if (driveSize > EMPTY_DISK_THRESHOLD_KB) {
165 console.log(
166 chalk.yellowBright(
167 `Drive image ${options.image} is not empty (size: ${driveSize} KB), skipping ISO download to avoid overwriting existing data.`,
168 ),
169 );
170 return null;
171 }
172 }
173 }
174
175 const outputExists = yield* Effect.tryPromise({
176 try: () => Deno.stat(outputPath).then(() => true).catch(() => false),
177 catch: (error) => new LogCommandError({ cause: error }),
178 });
179
180 if (outputExists) {
181 console.log(
182 chalk.yellowBright(
183 `File ${outputPath} already exists, skipping download.`,
184 ),
185 );
186 return outputPath;
187 }
188
189 yield* Effect.tryPromise({
190 try: async () => {
191 const cmd = new Deno.Command("curl", {
192 args: ["-L", "-o", outputPath, url],
193 stdin: "inherit",
194 stdout: "inherit",
195 stderr: "inherit",
196 });
197
198 const status = await cmd.spawn().status;
199 if (!status.success) {
200 console.error(chalk.redBright("Failed to download ISO image."));
201 Deno.exit(status.code);
202 }
203 },
204 catch: (error) => new LogCommandError({ cause: error }),
205 });
206
207 console.log(chalk.greenBright(`Downloaded ISO to ${outputPath}`));
208 return outputPath;
209 });
210
211export function constructDownloadUrl(version: string): string {
212 let arch = "amd64";
213
214 if (Deno.build.arch === "aarch64") {
215 arch = "evbarm-aarch64";
216 }
217
218 return `https://cdn.netbsd.org/pub/NetBSD/images/${version}/NetBSD-${version}-${arch}.iso`;
219}
220
221export const setupFirmwareFilesIfNeeded = () =>
222 Effect.gen(function* () {
223 if (Deno.build.arch !== "aarch64") {
224 return [];
225 }
226
227 const { stdout, success } = yield* Effect.tryPromise({
228 try: async () => {
229 const brewCmd = new Deno.Command("brew", {
230 args: ["--prefix", "qemu"],
231 stdout: "piped",
232 stderr: "inherit",
233 });
234 return await brewCmd.spawn().output();
235 },
236 catch: (error) => new LogCommandError({ cause: error }),
237 });
238
239 if (!success) {
240 console.error(
241 chalk.redBright(
242 "Failed to get QEMU prefix from Homebrew. Ensure QEMU is installed via Homebrew.",
243 ),
244 );
245 Deno.exit(1);
246 }
247
248 const brewPrefix = new TextDecoder().decode(stdout).trim();
249 const edk2Aarch64 = `${brewPrefix}/share/qemu/edk2-aarch64-code.fd`;
250 const edk2VarsAarch64 = "./edk2-arm-vars.fd";
251
252 yield* Effect.tryPromise({
253 try: () =>
254 Deno.copyFile(
255 `${brewPrefix}/share/qemu/edk2-arm-vars.fd`,
256 edk2VarsAarch64,
257 ),
258 catch: (error) => new LogCommandError({ cause: error }),
259 });
260
261 return [
262 "-drive",
263 `if=pflash,format=raw,file=${edk2Aarch64},readonly=on`,
264 "-drive",
265 `if=pflash,format=raw,file=${edk2VarsAarch64}`,
266 ];
267 });
268
269export function setupPortForwardingArgs(portForward?: string): string {
270 if (!portForward) {
271 return "";
272 }
273
274 const forwards = portForward.split(",").map((pair) => {
275 const [hostPort, guestPort] = pair.split(":");
276 return `hostfwd=tcp::${hostPort}-:${guestPort}`;
277 });
278
279 return forwards.join(",");
280}
281
282export function setupNATNetworkArgs(portForward?: string): string {
283 if (!portForward) {
284 return "user,id=net0";
285 }
286
287 const portForwarding = setupPortForwardingArgs(portForward);
288 return `user,id=net0,${portForwarding}`;
289}
290
291export const runQemu = (
292 isoPath: string | null,
293 options: Options,
294) =>
295 Effect.gen(function* () {
296 const macAddress = yield* generateRandomMacAddress();
297
298 const qemu = Deno.build.arch === "aarch64"
299 ? "qemu-system-aarch64"
300 : "qemu-system-x86_64";
301
302 const firmwareFiles = yield* setupFirmwareFilesIfNeeded();
303
304 const qemuArgs = [
305 ..._.compact([options.bridge && qemu]),
306 ...Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"],
307 ...Deno.build.arch === "aarch64" ? ["-machine", "virt,highmem=on"] : [],
308 "-cpu",
309 options.cpu,
310 "-m",
311 options.memory,
312 "-smp",
313 options.cpus.toString(),
314 ..._.compact([isoPath && "-cdrom", isoPath]),
315 "-netdev",
316 options.bridge
317 ? `bridge,id=net0,br=${options.bridge}`
318 : setupNATNetworkArgs(options.portForward),
319 "-device",
320 `e1000,netdev=net0,mac=${macAddress}`,
321 ...(options.install ? [] : ["-snapshot"]),
322 "-nographic",
323 "-monitor",
324 "none",
325 "-chardev",
326 "stdio,id=con0,signal=off",
327 "-serial",
328 "chardev:con0",
329 ...firmwareFiles,
330 ..._.compact(
331 options.image && [
332 "-drive",
333 `file=${options.image},format=${options.diskFormat},if=virtio`,
334 ],
335 ),
336 "-object",
337 "rng-random,filename=/dev/urandom,id=rng0",
338 "-device",
339 "virtio-rng-pci,rng=rng0",
340 ];
341
342 const name = Moniker.choose();
343
344 if (options.detach) {
345 yield* Effect.tryPromise({
346 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }),
347 catch: (error) => new LogCommandError({ cause: error }),
348 });
349
350 const logPath = `${LOGS_DIR}/${name}.log`;
351
352 const fullCommand = options.bridge
353 ? `sudo ${qemu} ${
354 qemuArgs.slice(1).join(" ")
355 } >> "${logPath}" 2>&1 & echo $!`
356 : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`;
357
358 const { stdout } = yield* Effect.tryPromise({
359 try: async () => {
360 const cmd = new Deno.Command("sh", {
361 args: ["-c", fullCommand],
362 stdin: "null",
363 stdout: "piped",
364 });
365 return await cmd.spawn().output();
366 },
367 catch: (error) => new LogCommandError({ cause: error }),
368 });
369
370 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10);
371
372 yield* saveInstanceState({
373 id: createId(),
374 name,
375 bridge: options.bridge,
376 macAddress,
377 memory: options.memory,
378 cpus: options.cpus,
379 cpu: options.cpu,
380 diskSize: options.size || "20G",
381 diskFormat: options.diskFormat || "raw",
382 portForward: options.portForward,
383 isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined,
384 drivePath: options.image ? Deno.realPathSync(options.image) : undefined,
385 version: DEFAULT_VERSION,
386 status: "RUNNING",
387 pid: qemuPid,
388 });
389
390 console.log(
391 `Virtual machine ${name} started in background (PID: ${qemuPid})`,
392 );
393 console.log(`Logs will be written to: ${logPath}`);
394
395 // Exit successfully while keeping VM running in background
396 Deno.exit(0);
397 } else {
398 const cmd = new Deno.Command(options.bridge ? "sudo" : qemu, {
399 args: qemuArgs,
400 stdin: "inherit",
401 stdout: "inherit",
402 stderr: "inherit",
403 })
404 .spawn();
405
406 yield* saveInstanceState({
407 id: createId(),
408 name,
409 bridge: options.bridge,
410 macAddress,
411 memory: options.memory,
412 cpus: options.cpus,
413 cpu: options.cpu,
414 diskSize: options.size || "20G",
415 diskFormat: options.diskFormat || "raw",
416 portForward: options.portForward,
417 isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined,
418 drivePath: options.image ? Deno.realPathSync(options.image) : undefined,
419 version: DEFAULT_VERSION,
420 status: "RUNNING",
421 pid: cmd.pid,
422 });
423
424 const status = yield* Effect.tryPromise({
425 try: () => cmd.status,
426 catch: (error) => new LogCommandError({ cause: error }),
427 });
428
429 yield* updateInstanceState(name, "STOPPED");
430
431 if (!status.success) {
432 Deno.exit(status.code);
433 }
434 }
435 });
436
437export function handleInput(input?: string): string {
438 if (!input) {
439 console.log(
440 chalk.blueBright(
441 `No ISO path provided, defaulting to ${chalk.cyan("NetBSD")} ${
442 chalk.cyan(DEFAULT_VERSION)
443 }...`,
444 ),
445 );
446 return constructDownloadUrl(DEFAULT_VERSION);
447 }
448
449 const versionRegex = /^\d{1,2}\.\d{1,2}$/;
450
451 if (versionRegex.test(input)) {
452 console.log(
453 chalk.blueBright(
454 `Detected version ${chalk.cyan(input)}, constructing download URL...`,
455 ),
456 );
457 return constructDownloadUrl(input);
458 }
459
460 return input;
461}
462
463export const safeKillQemu = (
464 pid: number,
465 useSudo: boolean = false,
466) =>
467 Effect.gen(function* () {
468 const killArgs = useSudo
469 ? ["sudo", "kill", "-TERM", pid.toString()]
470 : ["kill", "-TERM", pid.toString()];
471
472 const termStatus = yield* Effect.tryPromise({
473 try: async () => {
474 const termCmd = new Deno.Command(killArgs[0], {
475 args: killArgs.slice(1),
476 stdout: "null",
477 stderr: "null",
478 });
479 return await termCmd.spawn().status;
480 },
481 catch: (error) => new LogCommandError({ cause: error }),
482 });
483
484 if (termStatus.success) {
485 yield* Effect.tryPromise({
486 try: () => new Promise((resolve) => setTimeout(resolve, 3000)),
487 catch: (error) => new LogCommandError({ cause: error }),
488 });
489
490 const checkStatus = yield* Effect.tryPromise({
491 try: async () => {
492 const checkCmd = new Deno.Command("kill", {
493 args: ["-0", pid.toString()],
494 stdout: "null",
495 stderr: "null",
496 });
497 return await checkCmd.spawn().status;
498 },
499 catch: (error) => new LogCommandError({ cause: error }),
500 });
501
502 if (!checkStatus.success) {
503 return true;
504 }
505 }
506
507 const killKillArgs = useSudo
508 ? ["sudo", "kill", "-KILL", pid.toString()]
509 : ["kill", "-KILL", pid.toString()];
510
511 const killStatus = yield* Effect.tryPromise({
512 try: async () => {
513 const killCmd = new Deno.Command(killKillArgs[0], {
514 args: killKillArgs.slice(1),
515 stdout: "null",
516 stderr: "null",
517 });
518 return await killCmd.spawn().status;
519 },
520 catch: (error) => new LogCommandError({ cause: error }),
521 });
522
523 return killStatus.success;
524 });
525
526export const createDriveImageIfNeeded = (
527 {
528 image: path,
529 diskFormat: format,
530 size,
531 }: Options,
532) =>
533 Effect.gen(function* () {
534 const pathExists = yield* Effect.tryPromise({
535 try: () => Deno.stat(path!).then(() => true).catch(() => false),
536 catch: (error) => new LogCommandError({ cause: error }),
537 });
538
539 if (pathExists) {
540 console.log(
541 chalk.yellowBright(
542 `Drive image ${path} already exists, skipping creation.`,
543 ),
544 );
545 return;
546 }
547
548 const status = yield* Effect.tryPromise({
549 try: async () => {
550 const cmd = new Deno.Command("qemu-img", {
551 args: ["create", "-f", format || "raw", path!, size!],
552 stdin: "inherit",
553 stdout: "inherit",
554 stderr: "inherit",
555 });
556 return await cmd.spawn().status;
557 },
558 catch: (error) => new LogCommandError({ cause: error }),
559 });
560
561 if (!status.success) {
562 console.error(chalk.redBright("Failed to create drive image."));
563 Deno.exit(status.code);
564 }
565
566 console.log(chalk.greenBright(`Created drive image at ${path}`));
567 });