diff --git a/deno.json b/deno.json index 2633a6b..8cd2321 100644 --- a/deno.json +++ b/deno.json @@ -10,8 +10,9 @@ "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", "@std/assert": "jsr:@std/assert@1", "chalk": "npm:chalk@^5.6.2", - "kysely": "npm:kysely@^0.28.8", + "dayjs": "npm:dayjs@^1.11.19", + "kysely": "npm:kysely@0.27.6", "lodash": "npm:lodash@^4.17.21", "moniker": "npm:moniker@^0.1.2" } -} +} \ No newline at end of file diff --git a/deno.lock b/deno.lock index 2c45da2..cc66fc1 100644 --- a/deno.lock +++ b/deno.lock @@ -24,8 +24,9 @@ "jsr:@std/text@~1.0.7": "1.0.16", "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", "npm:chalk@^5.6.2": "5.6.2", + "npm:dayjs@^1.11.19": "1.11.19", + "npm:kysely@0.27.6": "0.27.6", "npm:kysely@~0.27.2": "0.27.6", - "npm:kysely@~0.28.8": "0.28.8", "npm:lodash@^4.17.21": "4.17.21", "npm:moniker@~0.1.2": "0.1.2" }, @@ -137,15 +138,15 @@ "chalk@5.6.2": { "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==" }, + "dayjs@1.11.19": { + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==" + }, "error-causes@3.0.2": { "integrity": "sha512-i0B8zq1dHL6mM85FGoxaJnVtx6LD5nL2v0hlpGdntg5FOSyzQ46c9lmz5qx0xRS2+PWHGOHcYxGIBC5Le2dRMw==" }, "kysely@0.27.6": { "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" }, - "kysely@0.28.8": { - "integrity": "sha512-QUOgl5ZrS9IRuhq5FvOKFSsD/3+IA6MLE81/bOOTRA/YQpKDza2sFdN5g6JCB9BOpqMJDGefLCQ9F12hRS13TA==" - }, "lodash@4.17.21": { "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, @@ -162,7 +163,8 @@ "jsr:@std/assert@1", "npm:@paralleldrive/cuid2@^3.0.4", "npm:chalk@^5.6.2", - "npm:kysely@~0.28.8", + "npm:dayjs@^1.11.19", + "npm:kysely@0.27.6", "npm:lodash@^4.17.21", "npm:moniker@~0.1.2" ] diff --git a/main.ts b/main.ts index 9eb0308..c5574ba 100755 --- a/main.ts +++ b/main.ts @@ -2,12 +2,16 @@ import { Command } from "@cliffy/command"; import { createBridgeNetworkIfNeeded } from "./src/network.ts"; +import inspect from "./src/subcommands/inspect.ts"; +import ps from "./src/subcommands/ps.ts"; +import start from "./src/subcommands/start.ts"; +import stop from "./src/subcommands/stop.ts"; import { createDriveImageIfNeeded, downloadIso, emptyDiskImage, handleInput, - Options, + type Options, runQemu, } from "./src/utils.ts"; @@ -64,6 +68,26 @@ if (import.meta.main) { "Download URL", "netbsd-up https://cdn.netbsd.org/pub/NetBSD/images/10.1/NetBSD-10.1-amd64.iso", ) + .example( + "List running VMs", + "netbsd-up ps", + ) + .example( + "List all VMs", + "netbsd-up ps --all", + ) + .example( + "Start a VM", + "netbsd-up start my-vm", + ) + .example( + "Stop a VM", + "netbsd-up stop my-vm", + ) + .example( + "Inspect a VM", + "netbsd-up inspect my-vm", + ) .action(async (options: Options, input?: string) => { const resolvedInput = handleInput(input); let isoPath: string | null = resolvedInput; @@ -89,5 +113,25 @@ if (import.meta.main) { await runQemu(isoPath, options); }) + .command("ps", "List all virtual machines") + .option("--all, -a", "Show all virtual machines, including stopped ones") + .action(async (options: { all: boolean }) => { + await ps(options.all); + }) + .command("start", "Start a virtual machine") + .arguments("") + .action(async (_options: unknown, vmName: string) => { + await start(vmName); + }) + .command("stop", "Stop a virtual machine") + .arguments("") + .action(async (_options: unknown, vmName: string) => { + await stop(vmName); + }) + .command("inspect", "Inspect a virtual machine") + .arguments("") + .action(async (_options: unknown, vmName: string) => { + await inspect(vmName); + }) .parse(Deno.args); } diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..92cb7fc --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,2 @@ +export const CONFIG_DIR = `${Deno.env.get("HOME")}/.netbsd-up`; +export const DB_PATH = `${CONFIG_DIR}/state.sqlite`; diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..0d10fcd --- /dev/null +++ b/src/context.ts @@ -0,0 +1,11 @@ +import { DB_PATH } from "./constants.ts"; +import { createDb, migrateToLatest } from "./db.ts"; + +export const db = createDb(DB_PATH); +await migrateToLatest(db); + +export const ctx = { + db, +}; + +export type Context = typeof ctx; diff --git a/src/db.ts b/src/db.ts new file mode 100644 index 0000000..5950825 --- /dev/null +++ b/src/db.ts @@ -0,0 +1,96 @@ +import { Database as Sqlite } from "@db/sqlite"; +import { DenoSqlite3Dialect } from "@soapbox/kysely-deno-sqlite"; +import { + Kysely, + type Migration, + type MigrationProvider, + Migrator, + sql, +} from "kysely"; +import { CONFIG_DIR } from "./constants.ts"; +import type { STATUS } from "./types.ts"; + +export const createDb = (location: string): Database => { + Deno.mkdirSync(CONFIG_DIR, { recursive: true }); + return new Kysely({ + dialect: new DenoSqlite3Dialect({ + database: new Sqlite(location), + }), + }); +}; + +export type DatabaseSchema = { + virtual_machines: VirtualMachine; +}; + +export type VirtualMachine = { + id: string; + name: string; + bridge?: string; + macAddress: string; + memory: string; + cpus: number; + cpu: string; + diskSize: string; + drivePath?: string; + diskFormat: string; + isoPath?: string; + version: string; + status: STATUS; + pid: number; + createdAt?: string; + updatedAt?: string; +}; + +const migrations: Record = {}; + +const migrationProvider: MigrationProvider = { + // deno-lint-ignore require-await + async getMigrations() { + return migrations; + }, +}; + +migrations["001"] = { + async up(db: Kysely): Promise { + await db.schema + .createTable("virtual_machines") + .addColumn("id", "varchar", (col) => col.primaryKey()) + .addColumn("name", "varchar", (col) => col.notNull().unique()) + .addColumn("bridge", "varchar") + .addColumn("macAddress", "varchar", (col) => col.notNull().unique()) + .addColumn("memory", "varchar", (col) => col.notNull()) + .addColumn("cpus", "integer", (col) => col.notNull()) + .addColumn("cpu", "varchar", (col) => col.notNull()) + .addColumn("diskSize", "varchar", (col) => col.notNull()) + .addColumn("drivePath", "varchar") + .addColumn("version", "varchar", (col) => col.notNull()) + .addColumn("diskFormat", "varchar") + .addColumn("isoPath", "varchar") + .addColumn("status", "varchar", (col) => col.notNull()) + .addColumn("pid", "integer", (col) => col.notNull().unique()) + .addColumn( + "createdAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), + ) + .addColumn( + "updatedAt", + "varchar", + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), + ) + .execute(); + }, + + async down(db: Kysely): Promise { + await db.schema.dropTable("virtual_machines").execute(); + }, +}; + +export const migrateToLatest = async (db: Database) => { + const migrator = new Migrator({ db, provider: migrationProvider }); + const { error } = await migrator.migrateToLatest(); + if (error) throw error; +}; + +export type Database = Kysely; diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..064dde9 --- /dev/null +++ b/src/state.ts @@ -0,0 +1,52 @@ +import { ctx } from "./context.ts"; +import type { VirtualMachine } from "./db.ts"; +import type { STATUS } from "./types.ts"; + +export async function saveInstanceState(vm: VirtualMachine) { + await ctx.db.insertInto("virtual_machines") + .values(vm) + .execute(); +} + +export async function updateInstanceState( + name: string, + status: STATUS, + pid?: number, +) { + await ctx.db.updateTable("virtual_machines") + .set({ status, pid }) + .where((eb) => + eb.or([ + eb("name", "=", name), + eb("id", "=", name), + ]) + ) + .execute(); +} + +export async function removeInstanceState(name: string) { + await ctx.db.deleteFrom("virtual_machines") + .where((eb) => + eb.or([ + eb("name", "=", name), + eb("id", "=", name), + ]) + ) + .execute(); +} + +export async function getInstanceState( + name: string, +): Promise { + const vm = await ctx.db.selectFrom("virtual_machines") + .selectAll() + .where((eb) => + eb.or([ + eb("name", "=", name), + eb("id", "=", name), + ]) + ) + .executeTakeFirst(); + + return vm; +} diff --git a/src/subcommands/inspect.ts b/src/subcommands/inspect.ts new file mode 100644 index 0000000..d8264f2 --- /dev/null +++ b/src/subcommands/inspect.ts @@ -0,0 +1,13 @@ +import { getInstanceState } from "../state.ts"; + +export default async function (name: string) { + const vm = await getInstanceState(name); + if (!vm) { + console.error( + `Virtual machine with name or ID ${name} not found.`, + ); + Deno.exit(1); + } + + console.log(vm); +} diff --git a/src/subcommands/ps.ts b/src/subcommands/ps.ts new file mode 100644 index 0000000..0f5ed1d --- /dev/null +++ b/src/subcommands/ps.ts @@ -0,0 +1,39 @@ +import { Table } from "@cliffy/table"; +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime.js"; +import utc from "dayjs/plugin/utc.js"; +import { ctx } from "../context.ts"; + +dayjs.extend(relativeTime); +dayjs.extend(utc); + +export default async function (all: boolean) { + const results = await ctx.db.selectFrom("virtual_machines") + .selectAll() + .where((eb) => { + if (all) { + return eb("id", "!=", ""); + } + return eb("status", "=", "RUNNING"); + }) + .execute(); + + const table: Table = new Table( + ["NAME", "VCPU", "MEMORY", "STATUS", "PID", "BRIDGE", "MAC", "CREATED"], + ); + + for (const vm of results) { + table.push([ + vm.name, + vm.cpus.toString(), + vm.memory, + vm.status, + vm.pid?.toString() ?? "-", + vm.bridge ?? "-", + vm.macAddress, + dayjs.utc(vm.createdAt).local().fromNow(), + ]); + } + + console.log(table.padding(2).toString()); +} diff --git a/src/subcommands/start.ts b/src/subcommands/start.ts new file mode 100644 index 0000000..703b135 --- /dev/null +++ b/src/subcommands/start.ts @@ -0,0 +1,61 @@ +import _ from "lodash"; +import { getInstanceState, updateInstanceState } from "../state.ts"; + +export default async function (name: string) { + const vm = await getInstanceState(name); + if (!vm) { + console.error( + `Virtual machine with name or ID ${name} not found.`, + ); + Deno.exit(1); + } + + console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); + + const cmd = new Deno.Command(vm.bridge ? "sudo" : "qemu-system-x86_64", { + args: [ + ..._.compact([vm.bridge && "qemu-system-x86_64"]), + "-enable-kvm", + "-cpu", + vm.cpu, + "-m", + vm.memory, + "-smp", + vm.cpus.toString(), + ..._.compact([vm.isoPath && "-cdrom", vm.isoPath]), + "-netdev", + vm.bridge + ? `bridge,id=net0,br=${vm.bridge}` + : "user,id=net0,hostfwd=tcp::2222-:22", + "-device", + `e1000,netdev=net0,mac=${vm.macAddress}`, + "-nographic", + "-monitor", + "none", + "-chardev", + "stdio,id=con0,signal=off", + "-serial", + "chardev:con0", + ..._.compact( + vm.drivePath && [ + "-drive", + `file=${vm.drivePath},format=${vm.diskFormat},if=virtio`, + ], + ), + ], + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }) + .spawn(); + + await updateInstanceState(name, "RUNNING", cmd.pid); + + const status = await cmd.status; + + await updateInstanceState(name, "STOPPED", cmd.pid); + + if (!status.success) { + Deno.exit(status.code); + } +} diff --git a/src/subcommands/stop.ts b/src/subcommands/stop.ts new file mode 100644 index 0000000..44a46a6 --- /dev/null +++ b/src/subcommands/stop.ts @@ -0,0 +1,43 @@ +import chalk from "chalk"; +import _ from "lodash"; +import { getInstanceState, updateInstanceState } from "../state.ts"; + +export default async function (name: string) { + const vm = await getInstanceState(name); + if (!vm) { + console.error( + `Virtual machine with name or ID ${chalk.greenBright(name)} not found.`, + ); + Deno.exit(1); + } + + console.log( + `Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${ + chalk.greenBright(vm.id) + })...`, + ); + + const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { + args: [ + ..._.compact([vm.bridge && "kill"]), + "-TERM", + vm.pid.toString(), + ], + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }); + + const status = await cmd.spawn().status; + + if (!status.success) { + console.error( + `Failed to stop virtual machine ${chalk.greenBright(vm.name)}.`, + ); + Deno.exit(status.code); + } + + await updateInstanceState(vm.name, "STOPPED"); + + console.log(`Virtual machine ${chalk.greenBright(vm.name)} stopped.`); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..e036400 --- /dev/null +++ b/src/types.ts @@ -0,0 +1 @@ +export type STATUS = "RUNNING" | "STOPPED"; diff --git a/src/utils.ts b/src/utils.ts index 98da007..64ff0fd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -1,6 +1,9 @@ +import { createId } from "@paralleldrive/cuid2"; import chalk from "chalk"; import _ from "lodash"; +import Moniker from "moniker"; import { generateRandomMacAddress } from "./network.ts"; +import { saveInstanceState } from "./state.ts"; const DEFAULT_VERSION = "10.1"; @@ -11,7 +14,7 @@ export interface Options { memory: string; drive?: string; diskFormat: string; - size?: string; + size: string; bridge?: string; } @@ -90,6 +93,7 @@ export async function runQemu( isoPath: string | null, options: Options, ): Promise { + const macAddress = generateRandomMacAddress(); const cmd = new Deno.Command(options.bridge ? "sudo" : "qemu-system-x86_64", { args: [ ..._.compact([options.bridge && "qemu-system-x86_64"]), @@ -106,7 +110,7 @@ export async function runQemu( ? `bridge,id=net0,br=${options.bridge}` : "user,id=net0,hostfwd=tcp::2222-:22", "-device", - `e1000,netdev=net0,mac=${generateRandomMacAddress()}`, + `e1000,netdev=net0,mac=${macAddress}`, "-nographic", "-monitor", "none", @@ -124,9 +128,26 @@ export async function runQemu( stdin: "inherit", stdout: "inherit", stderr: "inherit", + }).spawn(); + + await saveInstanceState({ + id: createId(), + name: Moniker.choose(), + bridge: options.bridge, + macAddress, + memory: options.memory, + cpus: options.cpus, + cpu: options.cpu, + diskSize: options.size, + diskFormat: options.diskFormat, + isoPath: isoPath ? Deno.realPathSync(isoPath) : undefined, + drivePath: options.drive ? Deno.realPathSync(options.drive) : undefined, + version: DEFAULT_VERSION, + status: "RUNNING", + pid: cmd.pid, }); - const status = await cmd.spawn().status; + const status = await cmd.status; if (!status.success) { Deno.exit(status.code);