A simple CLI tool to spin up OpenBSD virtual machines using QEMU with minimal fuss.

Add VM management commands: start, stop, restart, and logs; enhance options for port forwarding and detach

+369 -72
+24 -2
main.ts
··· 3 3 import { Command } from "@cliffy/command"; 4 4 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 5 5 import inspect from "./src/subcommands/inspect.ts"; 6 + import logs from "./src/subcommands/logs.ts"; 6 7 import ps from "./src/subcommands/ps.ts"; 8 + import restart from "./src/subcommands/restart.ts"; 7 9 import rm from "./src/subcommands/rm.ts"; 8 10 import start from "./src/subcommands/start.ts"; 9 11 import stop from "./src/subcommands/stop.ts"; ··· 54 56 .option( 55 57 "-b, --bridge <name:string>", 56 58 "Name of the network bridge to use for networking (e.g., br0)", 59 + ) 60 + .option( 61 + "-d, --detach", 62 + "Run VM in the background and print VM name", 63 + ) 64 + .option( 65 + "-p, --port-forward <mappings:string>", 66 + "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 57 67 ) 58 68 .example( 59 69 "Default usage", ··· 127 137 }) 128 138 .command("start", "Start a virtual machine") 129 139 .arguments("<vm-name:string>") 130 - .action(async (_options: unknown, vmName: string) => { 131 - await start(vmName); 140 + .option("--detach, -d", "Run VM in the background and print VM name") 141 + .action(async (options: unknown, vmName: string) => { 142 + await start(vmName, Boolean((options as { detach: boolean }).detach)); 132 143 }) 133 144 .command("stop", "Stop a virtual machine") 134 145 .arguments("<vm-name:string>") ··· 144 155 .arguments("<vm-name:string>") 145 156 .action(async (_options: unknown, vmName: string) => { 146 157 await rm(vmName); 158 + }) 159 + .command("logs", "View logs of a virtual machine") 160 + .option("--follow, -f", "Follow log output") 161 + .arguments("<vm-name:string>") 162 + .action(async (options: unknown, vmName: string) => { 163 + await logs(vmName, Boolean((options as { follow: boolean }).follow)); 164 + }) 165 + .command("restart", "Restart a virtual machine") 166 + .arguments("<vm-name:string>") 167 + .action(async (_options: unknown, vmName: string) => { 168 + await restart(vmName); 147 169 }) 148 170 .parse(Deno.args); 149 171 }
+1
src/constants.ts
··· 1 1 export const CONFIG_DIR = `${Deno.env.get("HOME")}/.openbsd-up`; 2 2 export const DB_PATH = `${CONFIG_DIR}/state.sqlite`; 3 + export const LOGS_DIR: string = `${CONFIG_DIR}/logs`;
+18 -1
src/db.ts
··· 35 35 drivePath?: string; 36 36 diskFormat: string; 37 37 isoPath?: string; 38 + portForward?: string; 38 39 version: string; 39 40 status: STATUS; 40 41 pid: number; ··· 68 69 .addColumn("diskFormat", "varchar") 69 70 .addColumn("isoPath", "varchar") 70 71 .addColumn("status", "varchar", (col) => col.notNull()) 71 - .addColumn("pid", "integer", (col) => col.notNull().unique()) 72 + .addColumn("pid", "integer") 72 73 .addColumn( 73 74 "createdAt", 74 75 "varchar", ··· 84 85 85 86 async down(db: Kysely<unknown>): Promise<void> { 86 87 await db.schema.dropTable("virtual_machines").execute(); 88 + }, 89 + }; 90 + 91 + migrations["002"] = { 92 + async up(db: Kysely<unknown>): Promise<void> { 93 + await db.schema 94 + .alterTable("virtual_machines") 95 + .addColumn("portForward", "varchar") 96 + .execute(); 97 + }, 98 + 99 + async down(db: Kysely<unknown>): Promise<void> { 100 + await db.schema 101 + .alterTable("virtual_machines") 102 + .dropColumn("portForward") 103 + .execute(); 87 104 }, 88 105 }; 89 106
+5 -1
src/state.ts
··· 14 14 pid?: number, 15 15 ) { 16 16 await ctx.db.updateTable("virtual_machines") 17 - .set({ status, pid }) 17 + .set({ 18 + status, 19 + pid, 20 + updatedAt: new Date().toISOString(), 21 + }) 18 22 .where((eb) => 19 23 eb.or([ 20 24 eb("name", "=", name),
+23
src/subcommands/logs.ts
··· 1 + import { LOGS_DIR } from "../constants.ts"; 2 + 3 + export default async function (name: string, follow: boolean) { 4 + await Deno.mkdir(LOGS_DIR, { recursive: true }); 5 + const logPath = `${LOGS_DIR}/${name}.log`; 6 + 7 + const cmd = new Deno.Command(follow ? "tail" : "cat", { 8 + args: [ 9 + ...(follow ? ["-n", "100", "-f"] : []), 10 + logPath, 11 + ], 12 + stdin: "inherit", 13 + stdout: "inherit", 14 + stderr: "inherit", 15 + }); 16 + 17 + const status = await cmd.spawn().status; 18 + 19 + if (!status.success) { 20 + console.error(`Failed to view logs for virtual machine ${name}.`); 21 + Deno.exit(status.code); 22 + } 23 + }
+29 -3
src/subcommands/ps.ts
··· 3 3 import relativeTime from "dayjs/plugin/relativeTime.js"; 4 4 import utc from "dayjs/plugin/utc.js"; 5 5 import { ctx } from "../context.ts"; 6 + import type { VirtualMachine } from "../db.ts"; 6 7 7 8 dayjs.extend(relativeTime); 8 9 dayjs.extend(utc); ··· 19 20 .execute(); 20 21 21 22 const table: Table = new Table( 22 - ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "MAC", "CREATED"], 23 + ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "PORTS", "CREATED"], 23 24 ); 24 25 25 26 for (const vm of results) { ··· 27 28 vm.name, 28 29 vm.cpus.toString(), 29 30 vm.memory, 30 - vm.status, 31 + formatStatus(vm), 31 32 vm.pid?.toString() ?? "-", 32 33 vm.bridge ?? "-", 33 - vm.macAddress, 34 + formatPorts(vm.portForward), 34 35 dayjs.utc(vm.createdAt).local().fromNow(), 35 36 ]); 36 37 } 37 38 38 39 console.log(table.padding(2).toString()); 39 40 } 41 + 42 + function formatStatus(vm: VirtualMachine) { 43 + switch (vm.status) { 44 + case "RUNNING": 45 + return `Up ${ 46 + dayjs.utc(vm.updatedAt).local().fromNow().replace("ago", "") 47 + }`; 48 + case "STOPPED": 49 + return `Exited ${dayjs.utc(vm.updatedAt).local().fromNow()}`; 50 + default: 51 + return vm.status; 52 + } 53 + } 54 + 55 + function formatPorts(portForward?: string) { 56 + if (!portForward) { 57 + return "-"; 58 + } 59 + 60 + const mappings = portForward.split(","); 61 + return mappings.map((mapping) => { 62 + const [hostPort, guestPort] = mapping.split(":"); 63 + return `${hostPort}->${guestPort}`; 64 + }).join(", "); 65 + }
+107
src/subcommands/restart.ts
··· 1 + import chalk from "chalk"; 2 + import _ from "lodash"; 3 + import { LOGS_DIR } from "../constants.ts"; 4 + import { getInstanceState, updateInstanceState } from "../state.ts"; 5 + import { 6 + safeKillQemu, 7 + setupFirmwareFilesIfNeeded, 8 + setupNATNetworkArgs, 9 + } from "../utils.ts"; 10 + 11 + export default async function (name: string) { 12 + const vm = await getInstanceState(name); 13 + if (!vm) { 14 + console.error( 15 + `Virtual machine with name or ID ${chalk.greenBright(name)} not found.`, 16 + ); 17 + Deno.exit(1); 18 + } 19 + 20 + const success = await safeKillQemu(vm.pid, Boolean(vm.bridge)); 21 + 22 + if (!success) { 23 + console.error( 24 + `Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`, 25 + ); 26 + Deno.exit(1); 27 + } 28 + await updateInstanceState(vm.id, "STOPPED"); 29 + 30 + await new Promise((resolve) => setTimeout(resolve, 2000)); 31 + 32 + await Deno.mkdir(LOGS_DIR, { recursive: true }); 33 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 34 + 35 + const qemu = Deno.build.arch === "aarch64" 36 + ? "qemu-system-aarch64" 37 + : "qemu-system-x86_64"; 38 + 39 + const qemuArgs = [ 40 + ..._.compact([vm.bridge && qemu]), 41 + ..._.compact( 42 + Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 43 + ), 44 + ..._.compact( 45 + Deno.build.arch === "aarch64" && ["-machine", "virt,highmem=on"], 46 + ), 47 + "-cpu", 48 + vm.cpu, 49 + "-m", 50 + vm.memory, 51 + "-smp", 52 + vm.cpus.toString(), 53 + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 54 + "-netdev", 55 + vm.bridge 56 + ? `bridge,id=net0,br=${vm.bridge}` 57 + : setupNATNetworkArgs(vm.portForward), 58 + "-device", 59 + `e1000,netdev=net0,mac=${vm.macAddress}`, 60 + "-nographic", 61 + "-monitor", 62 + "none", 63 + "-chardev", 64 + "stdio,id=con0,signal=off", 65 + "-serial", 66 + "chardev:con0", 67 + ...await setupFirmwareFilesIfNeeded(), 68 + ..._.compact( 69 + vm.drivePath && [ 70 + "-drive", 71 + `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 72 + ], 73 + ), 74 + ]; 75 + 76 + const fullCommand = vm.bridge 77 + ? `sudo ${qemu} ${ 78 + qemuArgs.slice(1).join(" ") 79 + } >> "${logPath}" 2>&1 & echo $!` 80 + : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 81 + 82 + const cmd = new Deno.Command("sh", { 83 + args: ["-c", fullCommand], 84 + stdin: "null", 85 + stdout: "piped", 86 + }); 87 + 88 + const { stdout } = await cmd.spawn().output(); 89 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 90 + 91 + await new Promise((resolve) => setTimeout(resolve, 2000)); 92 + 93 + await updateInstanceState(vm.id, "RUNNING", qemuPid); 94 + 95 + console.log( 96 + `${chalk.greenBright(vm.name)} restarted with PID ${ 97 + chalk.greenBright(qemuPid) 98 + }.`, 99 + ); 100 + console.log( 101 + `Logs are being written to ${chalk.blueBright(logPath)}`, 102 + ); 103 + 104 + await new Promise((resolve) => setTimeout(resolve, 2000)); 105 + 106 + Deno.exit(0); 107 + }
+82 -49
src/subcommands/start.ts
··· 1 1 import _ from "lodash"; 2 + import { LOGS_DIR } from "../constants.ts"; 2 3 import { getInstanceState, updateInstanceState } from "../state.ts"; 3 - import { setupFirmwareFilesIfNeeded } from "../utils.ts"; 4 + import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; 4 5 5 - export default async function (name: string) { 6 + export default async function (name: string, detach: boolean = false) { 6 7 const vm = await getInstanceState(name); 7 8 if (!vm) { 8 9 console.error( ··· 17 18 ? "qemu-system-aarch64" 18 19 : "qemu-system-x86_64"; 19 20 20 - const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, { 21 - args: [ 22 - ..._.compact([vm.bridge && qemu]), 23 - ..._.compact( 24 - Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 25 - ), 26 - ..._.compact( 27 - Deno.build.arch === "aarch64" && ["-machine", "virt,highmem=on"], 28 - ), 29 - "-cpu", 30 - vm.cpu, 31 - "-m", 32 - vm.memory, 33 - "-smp", 34 - vm.cpus.toString(), 35 - ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 36 - "-netdev", 37 - vm.bridge 38 - ? `bridge,id=net0,br=${vm.bridge}` 39 - : "user,id=net0,hostfwd=tcp::2222-:22", 40 - "-device", 41 - `e1000,netdev=net0,mac=${vm.macAddress}`, 42 - "-nographic", 43 - "-monitor", 44 - "none", 45 - "-chardev", 46 - "stdio,id=con0,signal=off", 47 - "-serial", 48 - "chardev:con0", 49 - ...await setupFirmwareFilesIfNeeded(), 50 - ..._.compact( 51 - vm.drivePath && [ 52 - "-drive", 53 - `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 54 - ], 55 - ), 56 - ], 57 - stdin: "inherit", 58 - stdout: "inherit", 59 - stderr: "inherit", 60 - }) 61 - .spawn(); 21 + const qemuArgs = [ 22 + ..._.compact([vm.bridge && qemu]), 23 + ..._.compact( 24 + Deno.build.os === "darwin" ? ["-accel", "hvf"] : ["-enable-kvm"], 25 + ), 26 + ..._.compact( 27 + Deno.build.arch === "aarch64" && ["-machine", "virt,highmem=on"], 28 + ), 29 + "-cpu", 30 + vm.cpu, 31 + "-m", 32 + vm.memory, 33 + "-smp", 34 + vm.cpus.toString(), 35 + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 36 + "-netdev", 37 + vm.bridge 38 + ? `bridge,id=net0,br=${vm.bridge}` 39 + : setupNATNetworkArgs(vm.portForward), 40 + "-device", 41 + `e1000,netdev=net0,mac=${vm.macAddress}`, 42 + "-nographic", 43 + "-monitor", 44 + "none", 45 + "-chardev", 46 + "stdio,id=con0,signal=off", 47 + "-serial", 48 + "chardev:con0", 49 + ...await setupFirmwareFilesIfNeeded(), 50 + ..._.compact( 51 + vm.drivePath && [ 52 + "-drive", 53 + `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 54 + ], 55 + ), 56 + ]; 57 + 58 + if (detach) { 59 + await Deno.mkdir(LOGS_DIR, { recursive: true }); 60 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 61 + 62 + const fullCommand = vm.bridge 63 + ? `sudo ${qemu} ${ 64 + qemuArgs.slice(1).join(" ") 65 + } >> "${logPath}" 2>&1 & echo $!` 66 + : `${qemu} ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 67 + 68 + const cmd = new Deno.Command("sh", { 69 + args: ["-c", fullCommand], 70 + stdin: "null", 71 + stdout: "piped", 72 + }); 73 + 74 + const { stdout } = await cmd.spawn().output(); 75 + const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 76 + 77 + await updateInstanceState(name, "RUNNING", qemuPid); 78 + 79 + console.log( 80 + `Virtual machine ${vm.name} started in background (PID: ${qemuPid})`, 81 + ); 82 + console.log(`Logs will be written to: ${logPath}`); 83 + 84 + // Exit successfully while keeping VM running in background 85 + Deno.exit(0); 86 + } else { 87 + const cmd = new Deno.Command(vm.bridge ? "sudo" : qemu, { 88 + args: qemuArgs, 89 + stdin: "inherit", 90 + stdout: "inherit", 91 + stderr: "inherit", 92 + }); 62 93 63 - await updateInstanceState(name, "RUNNING", cmd.pid); 94 + const child = cmd.spawn(); 95 + await updateInstanceState(name, "RUNNING", child.pid); 64 96 65 - const status = await cmd.status; 97 + const status = await child.status; 66 98 67 - await updateInstanceState(name, "STOPPED", cmd.pid); 99 + await updateInstanceState(name, "STOPPED", child.pid); 68 100 69 - if (!status.success) { 70 - Deno.exit(status.code); 101 + if (!status.success) { 102 + Deno.exit(status.code); 103 + } 71 104 } 72 105 }
+4 -15
src/subcommands/stop.ts
··· 1 1 import chalk from "chalk"; 2 - import _ from "lodash"; 3 2 import { getInstanceState, updateInstanceState } from "../state.ts"; 3 + import { safeKillQemu } from "../utils.ts"; 4 4 5 5 export default async function (name: string) { 6 6 const vm = await getInstanceState(name); ··· 17 17 })...`, 18 18 ); 19 19 20 - const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { 21 - args: [ 22 - ..._.compact([vm.bridge && "kill"]), 23 - "-TERM", 24 - vm.pid.toString(), 25 - ], 26 - stdin: "inherit", 27 - stdout: "inherit", 28 - stderr: "inherit", 29 - }); 20 + const success = await safeKillQemu(vm.pid, Boolean(vm.bridge)); 30 21 31 - const status = await cmd.spawn().status; 32 - 33 - if (!status.success) { 22 + if (!success) { 34 23 console.error( 35 24 `Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`, 36 25 ); 37 - Deno.exit(status.code); 26 + Deno.exit(1); 38 27 } 39 28 40 29 await updateInstanceState(vm.name, "STOPPED");
+76 -1
src/utils.ts
··· 16 16 diskFormat: string; 17 17 size: string; 18 18 bridge?: string; 19 + portForward?: string; 20 + detach?: boolean; 19 21 } 20 22 21 23 async function du(path: string): Promise<number> { ··· 135 137 ]; 136 138 } 137 139 140 + export function setupPortForwardingArgs(portForward?: string): string { 141 + if (!portForward) { 142 + return ""; 143 + } 144 + 145 + const forwards = portForward.split(",").map((pair) => { 146 + const [hostPort, guestPort] = pair.split(":"); 147 + return `hostfwd=tcp::${hostPort}-:${guestPort}`; 148 + }); 149 + 150 + return forwards.join(","); 151 + } 152 + 153 + export function setupNATNetworkArgs(portForward?: string): string { 154 + if (!portForward) { 155 + return "user,id=net0"; 156 + } 157 + 158 + const portForwarding = setupPortForwardingArgs(portForward); 159 + return `user,id=net0,${portForwarding}`; 160 + } 161 + 138 162 export async function runQemu( 139 163 isoPath: string | null, 140 164 options: Options, ··· 164 188 "-netdev", 165 189 options.bridge 166 190 ? `bridge,id=net0,br=${options.bridge}` 167 - : "user,id=net0,hostfwd=tcp::2222-:22", 191 + : setupNATNetworkArgs(options.portForward), 168 192 "-device", 169 193 `e1000,netdev=net0,mac=${macAddress}`, 170 194 "-nographic", ··· 199 223 cpu: options.cpu, 200 224 diskSize: options.size, 201 225 diskFormat: options.diskFormat, 226 + portForward: options.portForward, 202 227 isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 203 228 drivePath: options.image ? Deno.realPathSync(options.image) : undefined, 204 229 version: DEFAULT_VERSION, ··· 235 260 } 236 261 237 262 return input; 263 + } 264 + 265 + export async function safeKillQemu( 266 + pid: number, 267 + useSudo: boolean = false, 268 + ): Promise<boolean> { 269 + // First try SIGTERM 270 + const killArgs = useSudo 271 + ? ["sudo", "kill", "-TERM", pid.toString()] 272 + : ["kill", "-TERM", pid.toString()]; 273 + 274 + const termCmd = new Deno.Command(killArgs[0], { 275 + args: killArgs.slice(1), 276 + stdout: "null", 277 + stderr: "null", 278 + }); 279 + 280 + const termStatus = await termCmd.spawn().status; 281 + 282 + if (termStatus.success) { 283 + // Wait a bit for graceful shutdown 284 + await new Promise((resolve) => setTimeout(resolve, 3000)); 285 + 286 + // Check if process still exists 287 + const checkCmd = new Deno.Command("kill", { 288 + args: ["-0", pid.toString()], 289 + stdout: "null", 290 + stderr: "null", 291 + }); 292 + 293 + const checkStatus = await checkCmd.spawn().status; 294 + if (!checkStatus.success) { 295 + // Process is gone, success 296 + return true; 297 + } 298 + } 299 + 300 + // If SIGTERM didn't work, try SIGKILL 301 + const killKillArgs = useSudo 302 + ? ["sudo", "kill", "-KILL", pid.toString()] 303 + : ["kill", "-KILL", pid.toString()]; 304 + 305 + const killCmd = new Deno.Command(killKillArgs[0], { 306 + args: killKillArgs.slice(1), 307 + stdout: "null", 308 + stderr: "null", 309 + }); 310 + 311 + const killStatus = await killCmd.spawn().status; 312 + return killStatus.success; 238 313 } 239 314 240 315 export async function createDriveImageIfNeeded(