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