A convenient CLI tool to quickly spin up DragonflyBSD virtual machines using QEMU with sensible defaults.
at main 13 kB view raw
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}