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