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