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