A simple, powerful CLI tool to spin up OpenIndiana virtual machines with QEMU

Implement virtual machine management commands and database integration; add start, stop, inspect, and list functionalities

+3 -2
deno.json
··· 10 10 "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 11 11 "@std/assert": "jsr:@std/assert@1", 12 12 "chalk": "npm:chalk@^5.6.2", 13 - "kysely": "npm:kysely@^0.28.8", 13 + "dayjs": "npm:dayjs@^1.11.19", 14 + "kysely": "npm:kysely@0.27.6", 14 15 "lodash": "npm:lodash@^4.17.21", 15 16 "moniker": "npm:moniker@^0.1.2" 16 17 } 17 - } 18 + }
+7 -5
deno.lock
··· 24 24 "jsr:@std/text@~1.0.7": "1.0.16", 25 25 "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", 26 26 "npm:chalk@^5.6.2": "5.6.2", 27 + "npm:dayjs@^1.11.19": "1.11.19", 28 + "npm:kysely@0.27.6": "0.27.6", 27 29 "npm:kysely@~0.27.2": "0.27.6", 28 - "npm:kysely@~0.28.8": "0.28.8", 29 30 "npm:lodash@^4.17.21": "4.17.21", 30 31 "npm:moniker@~0.1.2": "0.1.2" 31 32 }, ··· 137 138 "chalk@5.6.2": { 138 139 "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" 139 140 }, 141 + "dayjs@1.11.19": { 142 + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" 143 + }, 140 144 "error-causes@3.0.2": { 141 145 "integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==" 142 146 }, 143 147 "kysely@0.27.6": { 144 148 "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" 145 - }, 146 - "kysely@0.28.8": { 147 - "integrity": "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==" 148 149 }, 149 150 "lodash@4.17.21": { 150 151 "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" ··· 162 163 "jsr:@std/assert@1", 163 164 "npm:@paralleldrive/cuid2@^3.0.4", 164 165 "npm:chalk@^5.6.2", 165 - "npm:kysely@~0.28.8", 166 + "npm:dayjs@^1.11.19", 167 + "npm:kysely@0.27.6", 166 168 "npm:lodash@^4.17.21", 167 169 "npm:moniker@~0.1.2" 168 170 ]
+45 -1
main.ts
··· 2 2 3 3 import { Command } from "@cliffy/command"; 4 4 import { createBridgeNetworkIfNeeded } from "./src/network.ts"; 5 + import inspect from "./src/subcommands/inspect.ts"; 6 + import ps from "./src/subcommands/ps.ts"; 7 + import start from "./src/subcommands/start.ts"; 8 + import stop from "./src/subcommands/stop.ts"; 5 9 import { 6 10 createDriveImageIfNeeded, 7 11 downloadIso, 8 12 emptyDiskImage, 9 13 handleInput, 10 - Options, 14 + type Options, 11 15 runQemu, 12 16 } from "./src/utils.ts"; 13 17 ··· 64 68 "Download URL", 65 69 "openindiana-up https://dlc.openindiana.org/isos/hipster/20251026/OI-hipster-text-20251026.iso", 66 70 ) 71 + .example( 72 + "List running VMs", 73 + "openindiana-up ps", 74 + ) 75 + .example( 76 + "List all VMs", 77 + "openindiana-up ps --all", 78 + ) 79 + .example( 80 + "Start a VM", 81 + "openindiana-up start my-vm", 82 + ) 83 + .example( 84 + "Stop a VM", 85 + "openindiana-up stop my-vm", 86 + ) 87 + .example( 88 + "Inspect a VM", 89 + "openindiana-up inspect my-vm", 90 + ) 67 91 .action(async (options: Options, input?: string) => { 68 92 const resolvedInput = handleInput(input); 69 93 let isoPath: string | null = resolvedInput; ··· 88 112 } 89 113 90 114 await runQemu(isoPath, options); 115 + }) 116 + .command("ps", "List all virtual machines") 117 + .option("--all, -a", "Show all virtual machines, including stopped ones") 118 + .action(async (options: { all: boolean }) => { 119 + await ps(options.all); 120 + }) 121 + .command("start", "Start a virtual machine") 122 + .arguments("<vm-name:string>") 123 + .action(async (_options: unknown, vmName: string) => { 124 + await start(vmName); 125 + }) 126 + .command("stop", "Stop a virtual machine") 127 + .arguments("<vm-name:string>") 128 + .action(async (_options: unknown, vmName: string) => { 129 + await stop(vmName); 130 + }) 131 + .command("inspect", "Inspect a virtual machine") 132 + .arguments("<vm-name:string>") 133 + .action(async (_options: unknown, vmName: string) => { 134 + await inspect(vmName); 91 135 }) 92 136 .parse(Deno.args); 93 137 }
+2
src/constants.ts
··· 1 + export const CONFIG_DIR = `${Deno.env.get("HOME")}/.openindiana-up`; 2 + export const DB_PATH = `${CONFIG_DIR}/state.sqlite`;
+11
src/context.ts
··· 1 + import { DB_PATH } from "./constants.ts"; 2 + import { createDb, migrateToLatest } from "./db.ts"; 3 + 4 + export const db = createDb(DB_PATH); 5 + await migrateToLatest(db); 6 + 7 + export const ctx = { 8 + db, 9 + }; 10 + 11 + export type Context = typeof ctx;
+96
src/db.ts
··· 1 + import { Database as Sqlite } from "@db/sqlite"; 2 + import { DenoSqlite3Dialect } from "@soapbox/kysely-deno-sqlite"; 3 + import { 4 + Kysely, 5 + type Migration, 6 + type MigrationProvider, 7 + Migrator, 8 + sql, 9 + } from "kysely"; 10 + import { CONFIG_DIR } from "./constants.ts"; 11 + import type { STATUS } from "./types.ts"; 12 + 13 + export const createDb = (location: string): Database => { 14 + Deno.mkdirSync(CONFIG_DIR, { recursive: true }); 15 + return new Kysely<DatabaseSchema>({ 16 + dialect: new DenoSqlite3Dialect({ 17 + database: new Sqlite(location), 18 + }), 19 + }); 20 + }; 21 + 22 + export type DatabaseSchema = { 23 + virtual_machines: VirtualMachine; 24 + }; 25 + 26 + export type VirtualMachine = { 27 + id: string; 28 + name: string; 29 + bridge?: string; 30 + macAddress: string; 31 + memory: string; 32 + cpus: number; 33 + cpu: string; 34 + diskSize: string; 35 + drivePath?: string; 36 + diskFormat: string; 37 + isoPath?: string; 38 + version: string; 39 + status: STATUS; 40 + pid: number; 41 + createdAt?: string; 42 + updatedAt?: string; 43 + }; 44 + 45 + const migrations: Record<string, Migration> = {}; 46 + 47 + const migrationProvider: MigrationProvider = { 48 + // deno-lint-ignore require-await 49 + async getMigrations() { 50 + return migrations; 51 + }, 52 + }; 53 + 54 + migrations["001"] = { 55 + async up(db: Kysely<unknown>): Promise<void> { 56 + await db.schema 57 + .createTable("virtual_machines") 58 + .addColumn("id", "varchar", (col) => col.primaryKey()) 59 + .addColumn("name", "varchar", (col) => col.notNull().unique()) 60 + .addColumn("bridge", "varchar") 61 + .addColumn("macAddress", "varchar", (col) => col.notNull().unique()) 62 + .addColumn("memory", "varchar", (col) => col.notNull()) 63 + .addColumn("cpus", "integer", (col) => col.notNull()) 64 + .addColumn("cpu", "varchar", (col) => col.notNull()) 65 + .addColumn("diskSize", "varchar", (col) => col.notNull()) 66 + .addColumn("drivePath", "varchar") 67 + .addColumn("version", "varchar", (col) => col.notNull()) 68 + .addColumn("diskFormat", "varchar") 69 + .addColumn("isoPath", "varchar") 70 + .addColumn("status", "varchar", (col) => col.notNull()) 71 + .addColumn("pid", "integer", (col) => col.notNull().unique()) 72 + .addColumn( 73 + "createdAt", 74 + "varchar", 75 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 76 + ) 77 + .addColumn( 78 + "updatedAt", 79 + "varchar", 80 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 81 + ) 82 + .execute(); 83 + }, 84 + 85 + async down(db: Kysely<unknown>): Promise<void> { 86 + await db.schema.dropTable("virtual_machines").execute(); 87 + }, 88 + }; 89 + 90 + export const migrateToLatest = async (db: Database) => { 91 + const migrator = new Migrator({ db, provider: migrationProvider }); 92 + const { error } = await migrator.migrateToLatest(); 93 + if (error) throw error; 94 + }; 95 + 96 + export type Database = Kysely<DatabaseSchema>;
+52
src/state.ts
··· 1 + import { ctx } from "./context.ts"; 2 + import type { VirtualMachine } from "./db.ts"; 3 + import type { STATUS } from "./types.ts"; 4 + 5 + export async function saveInstanceState(vm: VirtualMachine) { 6 + await ctx.db.insertInto("virtual_machines") 7 + .values(vm) 8 + .execute(); 9 + } 10 + 11 + export async function updateInstanceState( 12 + name: string, 13 + status: STATUS, 14 + pid?: number, 15 + ) { 16 + await ctx.db.updateTable("virtual_machines") 17 + .set({ status, pid }) 18 + .where((eb) => 19 + eb.or([ 20 + eb("name", "=", name), 21 + eb("id", "=", name), 22 + ]) 23 + ) 24 + .execute(); 25 + } 26 + 27 + export async function removeInstanceState(name: string) { 28 + await ctx.db.deleteFrom("virtual_machines") 29 + .where((eb) => 30 + eb.or([ 31 + eb("name", "=", name), 32 + eb("id", "=", name), 33 + ]) 34 + ) 35 + .execute(); 36 + } 37 + 38 + export async function getInstanceState( 39 + name: string, 40 + ): Promise<VirtualMachine | undefined> { 41 + const vm = await ctx.db.selectFrom("virtual_machines") 42 + .selectAll() 43 + .where((eb) => 44 + eb.or([ 45 + eb("name", "=", name), 46 + eb("id", "=", name), 47 + ]) 48 + ) 49 + .executeTakeFirst(); 50 + 51 + return vm; 52 + }
+13
src/subcommands/inspect.ts
··· 1 + import { getInstanceState } from "../state.ts"; 2 + 3 + export default async function (name: string) { 4 + const vm = await getInstanceState(name); 5 + if (!vm) { 6 + console.error( 7 + `Virtual machine with name or ID ${name} not found.`, 8 + ); 9 + Deno.exit(1); 10 + } 11 + 12 + console.log(vm); 13 + }
+39
src/subcommands/ps.ts
··· 1 + import { Table } from "@cliffy/table"; 2 + import dayjs from "dayjs"; 3 + import relativeTime from "dayjs/plugin/relativeTime.js"; 4 + import utc from "dayjs/plugin/utc.js"; 5 + import { ctx } from "../context.ts"; 6 + 7 + dayjs.extend(relativeTime); 8 + dayjs.extend(utc); 9 + 10 + export default async function (all: boolean) { 11 + const results = await ctx.db.selectFrom("virtual_machines") 12 + .selectAll() 13 + .where((eb) => { 14 + if (all) { 15 + return eb("id", "!=", ""); 16 + } 17 + return eb("status", "=", "RUNNING"); 18 + }) 19 + .execute(); 20 + 21 + const table: Table = new Table( 22 + ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "MAC", "CREATED"], 23 + ); 24 + 25 + for (const vm of results) { 26 + table.push([ 27 + vm.name, 28 + vm.cpus.toString(), 29 + vm.memory, 30 + vm.status, 31 + vm.pid?.toString() ?? "-", 32 + vm.bridge ?? "-", 33 + vm.macAddress, 34 + dayjs.utc(vm.createdAt).local().fromNow(), 35 + ]); 36 + } 37 + 38 + console.log(table.padding(2).toString()); 39 + }
+61
src/subcommands/start.ts
··· 1 + import _ from "lodash"; 2 + import { getInstanceState, updateInstanceState } from "../state.ts"; 3 + 4 + export default async function (name: string) { 5 + const vm = await getInstanceState(name); 6 + if (!vm) { 7 + console.error( 8 + `Virtual machine with name or ID ${name} not found.`, 9 + ); 10 + Deno.exit(1); 11 + } 12 + 13 + console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 14 + 15 + const cmd = new Deno.Command(vm.bridge ? "sudo" : "qemu-system-x86_64", { 16 + args: [ 17 + ..._.compact([vm.bridge && "qemu-system-x86_64"]), 18 + "-enable-kvm", 19 + "-cpu", 20 + vm.cpu, 21 + "-m", 22 + vm.memory, 23 + "-smp", 24 + vm.cpus.toString(), 25 + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), 26 + "-netdev", 27 + vm.bridge 28 + ? `bridge,id=net0,br=${vm.bridge}` 29 + : "user,id=net0,hostfwd=tcp::2222-:22", 30 + "-device", 31 + `e1000,netdev=net0,mac=${vm.macAddress}`, 32 + "-nographic", 33 + "-monitor", 34 + "none", 35 + "-chardev", 36 + "stdio,id=con0,signal=off", 37 + "-serial", 38 + "chardev:con0", 39 + ..._.compact( 40 + vm.drivePath && [ 41 + "-drive", 42 + `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, 43 + ], 44 + ), 45 + ], 46 + stdin: "inherit", 47 + stdout: "inherit", 48 + stderr: "inherit", 49 + }) 50 + .spawn(); 51 + 52 + await updateInstanceState(name, "RUNNING", cmd.pid); 53 + 54 + const status = await cmd.status; 55 + 56 + await updateInstanceState(name, "STOPPED", cmd.pid); 57 + 58 + if (!status.success) { 59 + Deno.exit(status.code); 60 + } 61 + }
+43
src/subcommands/stop.ts
··· 1 + import chalk from "chalk"; 2 + import _ from "lodash"; 3 + import { getInstanceState, updateInstanceState } from "../state.ts"; 4 + 5 + export default async function (name: string) { 6 + const vm = await getInstanceState(name); 7 + if (!vm) { 8 + console.error( 9 + `Virtual machine with name or ID ${chalk.greenBright(name)} not found.`, 10 + ); 11 + Deno.exit(1); 12 + } 13 + 14 + console.log( 15 + `Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${ 16 + chalk.greenBright(vm.id) 17 + })...`, 18 + ); 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 + }); 30 + 31 + const status = await cmd.spawn().status; 32 + 33 + if (!status.success) { 34 + console.error( 35 + `Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`, 36 + ); 37 + Deno.exit(status.code); 38 + } 39 + 40 + await updateInstanceState(vm.name, "STOPPED"); 41 + 42 + console.log(`Virtual machine ${chalk.greenBright(vm.name)} stopped.`); 43 + }
+1
src/types.ts
··· 1 + export type STATUS = "RUNNING" | "STOPPED";
+23 -2
src/utils.ts
··· 1 + import { createId } from "@paralleldrive/cuid2"; 1 2 import chalk from "chalk"; 2 3 import _ from "lodash"; 4 + import Moniker from "moniker"; 3 5 import { generateRandomMacAddress } from "./network.ts"; 6 + import { saveInstanceState } from "./state.ts"; 4 7 5 8 const DEFAULT_VERSION = "20251026"; 6 9 ··· 90 93 isoPath: string | null, 91 94 options: Options, 92 95 ): Promise<void> { 96 + const macAddress = generateRandomMacAddress(); 93 97 const cmd = new Deno.Command(options.bridge ? "sudo" : "qemu-system-x86_64", { 94 98 args: [ 95 99 ..._.compact([options.bridge && "qemu-system-x86_64"]), ··· 106 110 ? `bridge,id=net0,br=${options.bridge}` 107 111 : "user,id=net0,hostfwd=tcp::2222-:22", 108 112 "-device", 109 - `e1000,netdev=net0,mac=${generateRandomMacAddress()}`, 113 + `e1000,netdev=net0,mac=${macAddress}`, 110 114 "-nographic", 111 115 "-monitor", 112 116 "none", ··· 124 128 stdin: "inherit", 125 129 stdout: "inherit", 126 130 stderr: "inherit", 131 + }).spawn(); 132 + 133 + await saveInstanceState({ 134 + id: createId(), 135 + name: Moniker.choose(), 136 + bridge: options.bridge, 137 + macAddress, 138 + memory: options.memory, 139 + cpus: options.cpus, 140 + cpu: options.cpu, 141 + diskSize: options.size, 142 + diskFormat: options.diskFormat, 143 + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, 144 + drivePath: options.drive ? Deno.realPathSync(options.drive) : undefined, 145 + version: DEFAULT_VERSION, 146 + status: "RUNNING", 147 + pid: cmd.pid, 127 148 }); 128 149 129 - const status = await cmd.spawn().status; 150 + const status = await cmd.status; 130 151 131 152 if (!status.success) { 132 153 Deno.exit(status.code);