A convenient CLI tool to quickly spin up DragonflyBSD virtual machines using QEMU with sensible defaults.
1#!/usr/bin/env -S deno run --allow-run --allow-read --allow-env
2
3import { Command } from "@cliffy/command";
4import { Secret } from "@cliffy/prompt/secret";
5import { readAll } from "@std/io";
6import chalk from "chalk";
7import { Effect, pipe } from "effect";
8import pkg from "./deno.json" with { type: "json" };
9import { initVmFile, mergeConfig, parseVmFile } from "./src/config.ts";
10import { CONFIG_FILE_NAME } from "./src/constants.ts";
11import { createBridgeNetworkIfNeeded } from "./src/network.ts";
12import images from "./src/subcommands/images.ts";
13import inspect from "./src/subcommands/inspect.ts";
14import login from "./src/subcommands/login.ts";
15import logout from "./src/subcommands/logout.ts";
16import logs from "./src/subcommands/logs.ts";
17import ps from "./src/subcommands/ps.ts";
18import pull from "./src/subcommands/pull.ts";
19import push from "./src/subcommands/push.ts";
20import restart from "./src/subcommands/restart.ts";
21import rm from "./src/subcommands/rm.ts";
22import rmi from "./src/subcommands/rmi.ts";
23import run from "./src/subcommands/run.ts";
24import start from "./src/subcommands/start.ts";
25import stop from "./src/subcommands/stop.ts";
26import tag from "./src/subcommands/tag.ts";
27import * as volumes from "./src/subcommands/volume.ts";
28import serve from "./src/api/mod.ts";
29import { getImage } from "./src/images.ts";
30import { getImageArchivePath } from "./src/mod.ts";
31import {
32 createDriveImageIfNeeded,
33 downloadIso,
34 emptyDiskImage,
35 handleInput,
36 isValidISOurl,
37 type Options,
38 runQemu,
39} from "./src/utils.ts";
40
41export * from "./src/mod.ts";
42
43if (import.meta.main) {
44 await new Command()
45 .name("dflybsd-up")
46 .version(pkg.version)
47 .description("Start a DragonflyBSD virtual machine using QEMU")
48 .arguments(
49 "[path-or-url-to-iso-or-version:string]",
50 )
51 .option("-o, --output <path:string>", "Output path for downloaded ISO")
52 .option("-c, --cpu <type:string>", "Type of CPU to emulate", {
53 default: Deno.build.os === "darwin" && Deno.build.arch === "aarch64"
54 ? "max"
55 : "host",
56 })
57 .option("-C, --cpus <number:number>", "Number of CPU cores", {
58 default: 2,
59 })
60 .option("-m, --memory <size:string>", "Amount of memory for the VM", {
61 default: "2G",
62 })
63 .option("-i, --image <path:string>", "Path to VM disk image")
64 .option(
65 "--disk-format <format:string>",
66 "Disk image format (e.g., qcow2, raw)",
67 {
68 default: "raw",
69 },
70 )
71 .option(
72 "--size <size:string>",
73 "Size of the VM disk image to create if it doesn't exist (e.g., 20G)",
74 {
75 default: "20G",
76 },
77 )
78 .option(
79 "-b, --bridge <name:string>",
80 "Name of the network bridge to use for networking (e.g., br0)",
81 )
82 .option(
83 "-d, --detach",
84 "Run VM in the background and print VM name",
85 )
86 .option(
87 "-p, --port-forward <mappings:string>",
88 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
89 )
90 .option(
91 "--install",
92 "Persist changes to the VM disk image",
93 )
94 .example(
95 "Create a default VM configuration file",
96 "dflybsd-up init",
97 )
98 .example(
99 "Default usage",
100 "dflybsd-up",
101 )
102 .example(
103 "Specific version",
104 "dflybsd-up 6.4.2",
105 )
106 .example(
107 "Local ISO file",
108 "dflybsd-up /path/to/dragonflybsd.iso",
109 )
110 .example(
111 "Download URL",
112 "dflybsd-up https://mirror-master.dragonflybsd.org/iso-images/dfly-x86_64-6.4.2_REL.iso",
113 )
114 .example(
115 "List running VMs",
116 "dflybsd-up ps",
117 )
118 .example(
119 "List all VMs",
120 "dflybsd-up ps --all",
121 )
122 .example(
123 "Start a VM",
124 "dflybsd-up start my-vm",
125 )
126 .example(
127 "Stop a VM",
128 "dflybsd-up stop my-vm",
129 )
130 .example(
131 "Inspect a VM",
132 "dflybsd-up inspect my-vm",
133 )
134 .action(async (options: Options, input?: string) => {
135 const program = Effect.gen(function* () {
136 if (input) {
137 const [image, archivePath] = yield* Effect.all([
138 pipe(
139 getImage(input),
140 Effect.catchAll(() => Effect.succeed(null)),
141 ),
142 pipe(
143 getImageArchivePath(input),
144 Effect.catchAll(() => Effect.succeed(null)),
145 ),
146 ]);
147
148 if (image || archivePath) {
149 yield* Effect.tryPromise({
150 try: () => run(input),
151 catch: () => {},
152 });
153 return;
154 }
155 }
156
157 const resolvedInput = handleInput(input);
158 let isoPath: string | null = resolvedInput;
159
160 const config = yield* pipe(
161 parseVmFile(CONFIG_FILE_NAME),
162 Effect.tap(() => Effect.log("Parsed VM configuration file.")),
163 Effect.catchAll(() => Effect.succeed(null)),
164 );
165
166 if (!input && (isValidISOurl(config?.vm?.iso))) {
167 isoPath = yield* downloadIso(config!.vm!.iso!, options);
168 }
169
170 options = yield* mergeConfig(config, options);
171
172 if (input && isValidISOurl(resolvedInput)) {
173 isoPath = yield* downloadIso(resolvedInput, options);
174 }
175
176 if (options.image) {
177 yield* createDriveImageIfNeeded(options);
178 }
179
180 if (!input && options.image) {
181 const isEmpty = yield* emptyDiskImage(options.image);
182 if (!isEmpty) {
183 isoPath = null;
184 }
185 }
186
187 if (options.bridge) {
188 yield* createBridgeNetworkIfNeeded(options.bridge);
189 }
190
191 if (!input && !config?.vm?.iso && !isValidISOurl(isoPath!)) {
192 isoPath = null;
193 }
194
195 if (isValidISOurl(isoPath!)) {
196 isoPath = yield* downloadIso(isoPath!, options);
197 }
198
199 yield* runQemu(isoPath, options);
200 });
201
202 await Effect.runPromise(program);
203 })
204 .command("ps", "List all virtual machines")
205 .option("--all, -a", "Show all virtual machines, including stopped ones")
206 .action(async (options: { all?: unknown }) => {
207 await ps(Boolean(options.all));
208 })
209 .command("start", "Start a virtual machine")
210 .arguments("<vm-name:string>")
211 .option("-c, --cpu <type:string>", "Type of CPU to emulate", {
212 default: Deno.build.os === "darwin" && Deno.build.arch === "aarch64"
213 ? "max"
214 : "host",
215 })
216 .option("-C, --cpus <number:number>", "Number of CPU cores", {
217 default: 2,
218 })
219 .option("-m, --memory <size:string>", "Amount of memory for the VM", {
220 default: "2G",
221 })
222 .option("-i, --image <path:string>", "Path to VM disk image")
223 .option(
224 "--disk-format <format:string>",
225 "Disk image format (e.g., qcow2, raw)",
226 {
227 default: "raw",
228 },
229 )
230 .option(
231 "--size <size:string>",
232 "Size of the VM disk image to create if it doesn't exist (e.g., 20G)",
233 {
234 default: "20G",
235 },
236 )
237 .option(
238 "-b, --bridge <name:string>",
239 "Name of the network bridge to use for networking (e.g., br0)",
240 )
241 .option(
242 "-d, --detach",
243 "Run VM in the background and print VM name",
244 )
245 .option(
246 "-p, --port-forward <mappings:string>",
247 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
248 )
249 .option(
250 "-v, --volume <name:string>",
251 "Name of the volume to attach to the VM, will be created if it doesn't exist",
252 )
253 .action(async (options: unknown, vmName: string) => {
254 await start(vmName, Boolean((options as { detach: boolean }).detach));
255 })
256 .command("stop", "Stop a virtual machine")
257 .arguments("<vm-name:string>")
258 .action(async (_options: unknown, vmName: string) => {
259 await stop(vmName);
260 })
261 .command("inspect", "Inspect a virtual machine")
262 .arguments("<vm-name:string>")
263 .action(async (_options: unknown, vmName: string) => {
264 await inspect(vmName);
265 })
266 .command("rm", "Remove a virtual machine")
267 .arguments("<vm-name:string>")
268 .action(async (_options: unknown, vmName: string) => {
269 await rm(vmName);
270 })
271 .command("logs", "View logs of a virtual machine")
272 .option("--follow, -f", "Follow log output")
273 .arguments("<vm-name:string>")
274 .action(async (options: unknown, vmName: string) => {
275 await logs(vmName, Boolean((options as { follow: boolean }).follow));
276 })
277 .command("restart", "Restart a virtual machine")
278 .arguments("<vm-name:string>")
279 .action(async (_options: unknown, vmName: string) => {
280 await restart(vmName);
281 })
282 .command("init", "Initialize a default VM configuration file")
283 .action(async () => {
284 await Effect.runPromise(initVmFile(CONFIG_FILE_NAME));
285 console.log(
286 `New VM configuration file created at ${
287 chalk.greenBright("./") +
288 chalk.greenBright(CONFIG_FILE_NAME)
289 }`,
290 );
291 console.log(
292 `You can edit this file to customize your VM settings and then start the VM with:`,
293 );
294 console.log(` ${chalk.greenBright(`dflybsd-up`)}`);
295 })
296 .command(
297 "pull",
298 "Pull VM image from an OCI-compliant registry, e.g., ghcr.io, docker hub",
299 )
300 .arguments("<image:string>")
301 .action(async (_options: unknown, image: string) => {
302 await pull(image);
303 })
304 .command(
305 "push",
306 "Push VM image to an OCI-compliant registry, e.g., ghcr.io, docker hub",
307 )
308 .arguments("<image:string>")
309 .action(async (_options: unknown, image: string) => {
310 await push(image);
311 })
312 .command(
313 "tag",
314 "Create a tag 'image' that refers to the VM image of 'vm-name'",
315 )
316 .arguments("<vm-name:string> <image:string>")
317 .action(async (_options: unknown, vmName: string, image: string) => {
318 await tag(vmName, image);
319 })
320 .command(
321 "login",
322 "Authenticate to an OCI-compliant registry, e.g., ghcr.io, docker.io (docker hub), etc.",
323 )
324 .option("-u, --username <username:string>", "Registry username")
325 .arguments("<registry:string>")
326 .action(async (options: unknown, registry: string) => {
327 const username = (options as { username: string }).username;
328
329 let password: string | undefined;
330 const stdinIsTTY = Deno.stdin.isTerminal();
331
332 if (!stdinIsTTY) {
333 const buffer = await readAll(Deno.stdin);
334 password = new TextDecoder().decode(buffer).trim();
335 } else {
336 password = await Secret.prompt("Registry Password: ");
337 }
338
339 console.log(
340 `Authenticating to registry ${chalk.greenBright(registry)} as ${
341 chalk.greenBright(username)
342 }...`,
343 );
344 await login(username, password, registry);
345 })
346 .command("logout", "Logout from an OCI-compliant registry")
347 .arguments("<registry:string>")
348 .action(async (_options: unknown, registry: string) => {
349 await logout(registry);
350 })
351 .command("images", "List all local VM images")
352 .action(async () => {
353 await images();
354 })
355 .command("rmi", "Remove a local VM image")
356 .arguments("<image:string>")
357 .action(async (_options: unknown, image: string) => {
358 await rmi(image);
359 })
360 .command("run", "Create and run a VM from an image")
361 .arguments("<image:string>")
362 .option("-c, --cpu <type:string>", "Type of CPU to emulate", {
363 default: "host",
364 })
365 .option("-C, --cpus <number:number>", "Number of CPU cores", {
366 default: 2,
367 })
368 .option("-m, --memory <size:string>", "Amount of memory for the VM", {
369 default: "2G",
370 })
371 .option(
372 "-b, --bridge <name:string>",
373 "Name of the network bridge to use for networking (e.g., br0)",
374 )
375 .option(
376 "-d, --detach",
377 "Run VM in the background and print VM name",
378 )
379 .option(
380 "-p, --port-forward <mappings:string>",
381 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)",
382 )
383 .option(
384 "-v, --volume <name:string>",
385 "Name of the volume to attach to the VM, will be created if it doesn't exist",
386 )
387 .action(async (_options: unknown, image: string) => {
388 await run(image);
389 })
390 .command("volumes", "List all volumes")
391 .action(async () => {
392 await volumes.list();
393 })
394 .command(
395 "volume",
396 new Command()
397 .command("rm", "Remove a volume")
398 .arguments("<volume-name:string>")
399 .action(async (_options: unknown, volumeName: string) => {
400 await volumes.remove(volumeName);
401 })
402 .command("inspect", "Inspect a volume")
403 .arguments("<volume-name:string>")
404 .action(async (_options: unknown, volumeName: string) => {
405 await volumes.inspect(volumeName);
406 }),
407 )
408 .description("Manage volumes")
409 .command("serve", "Start the dflybsd-up HTTP API server")
410 .option("-p, --port <port:number>", "Port to listen on", { default: 8893 })
411 .action(() => {
412 serve();
413 })
414 .parse(Deno.args);
415}