A simple, zero-configuration script to quickly boot FreeBSD ISO images using QEMU

feat: add volume management functionality with create, list, inspect, and remove commands

+422 -7
+28
main.ts
··· 26 import start from "./src/subcommands/start.ts"; 27 import stop from "./src/subcommands/stop.ts"; 28 import tag from "./src/subcommands/tag.ts"; 29 import { 30 createDriveImageIfNeeded, 31 downloadIso, ··· 237 "-p, --port-forward <mappings:string>", 238 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 239 ) 240 .action(async (options: unknown, vmName: string) => { 241 await start(vmName, Boolean((options as { detach: boolean }).detach)); 242 }) ··· 367 "-p, --port-forward <mappings:string>", 368 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 369 ) 370 .action(async (_options: unknown, image: string) => { 371 await run(image); 372 }) 373 .parse(Deno.args); 374 }
··· 26 import start from "./src/subcommands/start.ts"; 27 import stop from "./src/subcommands/stop.ts"; 28 import tag from "./src/subcommands/tag.ts"; 29 + import * as volumes from "./src/subcommands/volume.ts"; 30 import { 31 createDriveImageIfNeeded, 32 downloadIso, ··· 238 "-p, --port-forward <mappings:string>", 239 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 240 ) 241 + .option( 242 + "-v, --volume <name:string>", 243 + "Name of the volume to attach to the VM, will be created if it doesn't exist", 244 + ) 245 .action(async (options: unknown, vmName: string) => { 246 await start(vmName, Boolean((options as { detach: boolean }).detach)); 247 }) ··· 372 "-p, --port-forward <mappings:string>", 373 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 374 ) 375 + .option( 376 + "-v, --volume <name:string>", 377 + "Name of the volume to attach to the VM, will be created if it doesn't exist", 378 + ) 379 .action(async (_options: unknown, image: string) => { 380 await run(image); 381 }) 382 + .command("volumes", "List all volumes") 383 + .action(async () => { 384 + await volumes.list(); 385 + }) 386 + .command( 387 + "volume", 388 + new Command() 389 + .command("rm", "Remove a volume") 390 + .arguments("<volume-name:string>") 391 + .action(async (_options: unknown, volumeName: string) => { 392 + await volumes.remove(volumeName); 393 + }) 394 + .command("inspect", "Inspect a volume") 395 + .arguments("<volume-name:string>") 396 + .action(async (_options: unknown, volumeName: string) => { 397 + await volumes.inspect(volumeName); 398 + }), 399 + ) 400 + .description("Manage volumes") 401 .parse(Deno.args); 402 }
+1
src/constants.ts
··· 4 export const EMPTY_DISK_THRESHOLD_KB: number = 100; 5 export const CONFIG_FILE_NAME: string = "vmconfig.toml"; 6 export const IMAGE_DIR: string = `${CONFIG_DIR}/images`;
··· 4 export const EMPTY_DISK_THRESHOLD_KB: number = 100; 5 export const CONFIG_FILE_NAME: string = "vmconfig.toml"; 6 export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; 7 + export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`;
+11
src/db.ts
··· 16 export type DatabaseSchema = { 17 virtual_machines: VirtualMachine; 18 images: Image; 19 }; 20 21 export type VirtualMachine = { ··· 34 version: string; 35 status: STATUS; 36 pid: number; 37 createdAt?: string; 38 updatedAt?: string; 39 }; ··· 46 path: string; 47 format: string; 48 digest?: string; 49 createdAt?: string; 50 }; 51
··· 16 export type DatabaseSchema = { 17 virtual_machines: VirtualMachine; 18 images: Image; 19 + volumes: Volume; 20 }; 21 22 export type VirtualMachine = { ··· 35 version: string; 36 status: STATUS; 37 pid: number; 38 + volume?: string; 39 createdAt?: string; 40 updatedAt?: string; 41 }; ··· 48 path: string; 49 format: string; 50 digest?: string; 51 + createdAt?: string; 52 + }; 53 + 54 + export type Volume = { 55 + id: string; 56 + name: string; 57 + baseImageId: string; 58 + path: string; 59 + size?: string; 60 createdAt?: string; 61 }; 62
+74
src/migrations.ts
··· 218 }, 219 }; 220 221 export const migrateToLatest = async (db: Database): Promise<void> => { 222 const migrator = new Migrator({ db, provider: migrationProvider }); 223 const { error } = await migrator.migrateToLatest();
··· 218 }, 219 }; 220 221 + migrations["008"] = { 222 + async up(db: Kysely<unknown>): Promise<void> { 223 + await db.schema 224 + .createTable("volumes") 225 + .addColumn("id", "varchar", (col) => col.primaryKey()) 226 + .addColumn("name", "varchar", (col) => col.notNull().unique()) 227 + .addColumn( 228 + "baseImageId", 229 + "varchar", 230 + (col) => col.notNull().references("images.id").onDelete("cascade"), 231 + ) 232 + .addColumn("path", "varchar", (col) => col.notNull()) 233 + .addColumn( 234 + "createdAt", 235 + "varchar", 236 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 237 + ) 238 + .execute(); 239 + }, 240 + 241 + async down(db: Kysely<unknown>): Promise<void> { 242 + await db.schema.dropTable("volumes").execute(); 243 + }, 244 + }; 245 + 246 + migrations["009"] = { 247 + async up(db: Kysely<unknown>): Promise<void> { 248 + await db.schema 249 + .createTable("volumes_new") 250 + .addColumn("id", "varchar", (col) => col.primaryKey()) 251 + .addColumn("name", "varchar", (col) => col.notNull().unique()) 252 + .addColumn( 253 + "baseImageId", 254 + "varchar", 255 + (col) => col.notNull().references("images.id").onDelete("cascade"), 256 + ) 257 + .addColumn("path", "varchar", (col) => col.notNull()) 258 + .addColumn( 259 + "createdAt", 260 + "varchar", 261 + (col) => col.notNull().defaultTo(sql`CURRENT_TIMESTAMP`), 262 + ) 263 + .execute(); 264 + 265 + await sql` 266 + INSERT INTO volumes_new (id, name, baseImageId, path, createdAt) 267 + SELECT id, name, baseImageId, path, createdAt FROM volumes 268 + `.execute(db); 269 + 270 + await db.schema.dropTable("volumes").execute(); 271 + await sql`ALTER TABLE volumes_new RENAME TO volumes`.execute(db); 272 + }, 273 + 274 + async down(db: Kysely<unknown>): Promise<void> { 275 + await db.schema.dropTable("volumes").execute(); 276 + }, 277 + }; 278 + 279 + migrations["010"] = { 280 + async up(db: Kysely<unknown>): Promise<void> { 281 + await db.schema 282 + .alterTable("virtual_machines") 283 + .addColumn("volume", "varchar") 284 + .execute(); 285 + }, 286 + 287 + async down(db: Kysely<unknown>): Promise<void> { 288 + await db.schema 289 + .alterTable("virtual_machines") 290 + .dropColumn("volume") 291 + .execute(); 292 + }, 293 + }; 294 + 295 export const migrateToLatest = async (db: Database): Promise<void> => { 296 const migrator = new Migrator({ db, provider: migrationProvider }); 297 const { error } = await migrator.migrateToLatest();
+1
src/mod.ts
··· 7 export * from "./state.ts"; 8 export * from "./types.ts"; 9 export * from "./utils.ts";
··· 7 export * from "./state.ts"; 8 export * from "./types.ts"; 9 export * from "./utils.ts"; 10 + export * from "./volumes.ts";
+27 -2
src/subcommands/run.ts
··· 1 import { parseFlags } from "@cliffy/flags"; 2 import { Effect, pipe } from "effect"; 3 - import type { Image } from "../db.ts"; 4 import { getImage } from "../images.ts"; 5 import { createBridgeNetworkIfNeeded } from "../network.ts"; 6 import { pullImage, PullImageError, setupOrasBinary } from "../oras.ts"; 7 import { type Options, runQemu, validateImage } from "../utils.ts"; 8 9 const pullImageOnMissing = ( 10 name: string, ··· 28 }), 29 ); 30 31 - const runImage = (image: Image) => 32 Effect.gen(function* () { 33 console.log(`Running image ${image.repository}...`); 34 const options = mergeFlags(image); 35 if (options.bridge) { 36 yield* createBridgeNetworkIfNeeded(options.bridge); 37 } 38 yield* runQemu(null, options); 39 }); 40 ··· 46 Effect.promise(() => setupOrasBinary()), 47 Effect.tap(() => validateImage(image)), 48 Effect.flatMap(() => pullImageOnMissing(image)), 49 Effect.flatMap(runImage), 50 Effect.catchAll((error) => 51 Effect.sync(() => { ··· 69 detach: flags.detach, 70 install: false, 71 diskFormat: image.format, 72 }; 73 }
··· 1 import { parseFlags } from "@cliffy/flags"; 2 import { Effect, pipe } from "effect"; 3 + import type { Image, Volume } from "../db.ts"; 4 import { getImage } from "../images.ts"; 5 import { createBridgeNetworkIfNeeded } from "../network.ts"; 6 import { pullImage, PullImageError, setupOrasBinary } from "../oras.ts"; 7 import { type Options, runQemu, validateImage } from "../utils.ts"; 8 + import { createVolume, getVolume } from "../volumes.ts"; 9 10 const pullImageOnMissing = ( 11 name: string, ··· 29 }), 30 ); 31 32 + const createVolumeIfNeeded = ( 33 + image: Image, 34 + ): Effect.Effect<[Image, Volume?], Error, never> => 35 + parseFlags(Deno.args).flags.volume 36 + ? Effect.gen(function* () { 37 + const volumeName = parseFlags(Deno.args).flags.volume as string; 38 + const volume = yield* getVolume(volumeName); 39 + if (volume) { 40 + return [image, volume]; 41 + } 42 + const newVolume = yield* createVolume(volumeName, image); 43 + return [image, newVolume]; 44 + }) 45 + : Effect.succeed([image]); 46 + 47 + const runImage = ([image, volume]: [Image, Volume?]) => 48 Effect.gen(function* () { 49 console.log(`Running image ${image.repository}...`); 50 const options = mergeFlags(image); 51 if (options.bridge) { 52 yield* createBridgeNetworkIfNeeded(options.bridge); 53 } 54 + 55 + if (volume) { 56 + options.image = volume.path; 57 + options.install = true; 58 + options.diskFormat = "qcow2"; 59 + } 60 + 61 yield* runQemu(null, options); 62 }); 63 ··· 69 Effect.promise(() => setupOrasBinary()), 70 Effect.tap(() => validateImage(image)), 71 Effect.flatMap(() => pullImageOnMissing(image)), 72 + Effect.flatMap(createVolumeIfNeeded), 73 Effect.flatMap(runImage), 74 Effect.catchAll((error) => 75 Effect.sync(() => { ··· 93 detach: flags.detach, 94 install: false, 95 diskFormat: image.format, 96 + volume: flags.volume, 97 }; 98 }
+53 -5
src/subcommands/start.ts
··· 2 import _ from "@es-toolkit/es-toolkit/compat"; 3 import { Data, Effect, pipe } from "effect"; 4 import { LOGS_DIR } from "../constants.ts"; 5 - import type { VirtualMachine } from "../db.ts"; 6 import { getInstanceState, updateInstanceState } from "../state.ts"; 7 import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; 8 9 class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 10 name: string; ··· 174 Deno.exit(1); 175 }); 176 177 const startDetachedEffect = (name: string) => 178 pipe( 179 findVm(name), 180 Effect.tap(logStarting), 181 Effect.flatMap(applyFlags), 182 - Effect.flatMap((vm) => 183 pipe( 184 setupFirmware(), 185 - Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 186 Effect.flatMap((qemuArgs) => 187 pipe( 188 createLogsDir(), ··· 201 findVm(name), 202 Effect.tap(logStarting), 203 Effect.flatMap(applyFlags), 204 - Effect.flatMap((vm) => 205 pipe( 206 setupFirmware(), 207 - Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 208 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 209 Effect.map((status) => status.success ? 0 : (status.code || 1)), 210 )
··· 2 import _ from "@es-toolkit/es-toolkit/compat"; 3 import { Data, Effect, pipe } from "effect"; 4 import { LOGS_DIR } from "../constants.ts"; 5 + import type { VirtualMachine, Volume } from "../db.ts"; 6 + import { getImage } from "../images.ts"; 7 import { getInstanceState, updateInstanceState } from "../state.ts"; 8 import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; 9 + import { createVolume, getVolume } from "../volumes.ts"; 10 11 class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 12 name: string; ··· 176 Deno.exit(1); 177 }); 178 179 + const createVolumeIfNeeded = ( 180 + vm: VirtualMachine, 181 + ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 182 + Effect.gen(function* () { 183 + const { flags } = parseFlags(Deno.args); 184 + if (!flags.volume) { 185 + return [vm]; 186 + } 187 + const volume = yield* getVolume(flags.volume as string); 188 + if (volume) { 189 + return [vm, volume]; 190 + } 191 + 192 + if (!vm.drivePath) { 193 + throw new Error( 194 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 195 + ); 196 + } 197 + 198 + let image = yield* getImage(vm.drivePath); 199 + 200 + if (!image) { 201 + const volume = yield* getVolume(vm.drivePath); 202 + if (volume) { 203 + image = yield* getImage(volume.baseImageId); 204 + } 205 + } 206 + 207 + const newVolume = yield* createVolume(flags.volume as string, image!); 208 + return [vm, newVolume]; 209 + }); 210 + 211 const startDetachedEffect = (name: string) => 212 pipe( 213 findVm(name), 214 Effect.tap(logStarting), 215 Effect.flatMap(applyFlags), 216 + Effect.flatMap(createVolumeIfNeeded), 217 + Effect.flatMap(([vm, volume]) => 218 pipe( 219 setupFirmware(), 220 + Effect.flatMap((firmwareArgs) => 221 + buildQemuArgs({ 222 + ...vm, 223 + drivePath: volume ? volume.path : vm.drivePath, 224 + diskFormat: volume ? "qcow2" : vm.diskFormat, 225 + }, firmwareArgs) 226 + ), 227 Effect.flatMap((qemuArgs) => 228 pipe( 229 createLogsDir(), ··· 242 findVm(name), 243 Effect.tap(logStarting), 244 Effect.flatMap(applyFlags), 245 + Effect.flatMap(createVolumeIfNeeded), 246 + Effect.flatMap(([vm, volume]) => 247 pipe( 248 setupFirmware(), 249 + Effect.flatMap((firmwareArgs) => 250 + buildQemuArgs({ 251 + ...vm, 252 + drivePath: volume ? volume.path : vm.drivePath, 253 + diskFormat: volume ? "qcow2" : vm.diskFormat, 254 + }, firmwareArgs) 255 + ), 256 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 257 Effect.map((status) => status.success ? 0 : (status.code || 1)), 258 )
+98
src/subcommands/volume.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 { Effect, pipe } from "effect"; 6 + import type { Volume } from "../db.ts"; 7 + import type { DbError } from "../mod.ts"; 8 + import { deleteVolume, getVolume, listVolumes } from "../volumes.ts"; 9 + 10 + dayjs.extend(relativeTime); 11 + dayjs.extend(utc); 12 + 13 + const createTable = () => 14 + Effect.succeed( 15 + new Table( 16 + ["NAME", "VOLUME ID", "CREATED"], 17 + ), 18 + ); 19 + 20 + const populateTable = (table: Table, volumes: Volume[]) => 21 + Effect.sync(() => { 22 + for (const volume of volumes) { 23 + table.push([ 24 + volume.name, 25 + volume.id, 26 + dayjs.utc(volume.createdAt).local().fromNow(), 27 + ]); 28 + } 29 + return table; 30 + }); 31 + 32 + const displayTable = (table: Table) => 33 + Effect.sync(() => { 34 + console.log(table.padding(2).toString()); 35 + }); 36 + 37 + const handleError = (error: DbError | Error) => 38 + Effect.sync(() => { 39 + console.error(`Failed to fetch volumes: ${error}`); 40 + Deno.exit(1); 41 + }); 42 + 43 + const lsEffect = () => 44 + pipe( 45 + Effect.all([listVolumes(), createTable()]), 46 + Effect.flatMap(([volumes, table]) => populateTable(table, volumes)), 47 + Effect.flatMap(displayTable), 48 + Effect.catchAll(handleError), 49 + ); 50 + 51 + export async function list() { 52 + await Effect.runPromise(lsEffect()); 53 + } 54 + 55 + export async function remove(name: string) { 56 + await Effect.runPromise( 57 + pipe( 58 + getVolume(name), 59 + Effect.flatMap((volume) => 60 + volume 61 + ? deleteVolume(volume.id) 62 + : Effect.fail(new Error(`Volume with name or ID ${name} not found.`)) 63 + ), 64 + Effect.tap(() => 65 + Effect.sync(() => { 66 + console.log(`Volume ${name} deleted successfully.`); 67 + }) 68 + ), 69 + Effect.catchAll((error) => 70 + Effect.sync(() => { 71 + console.error(`An error occurred: ${error}`); 72 + Deno.exit(1); 73 + }) 74 + ), 75 + ), 76 + ); 77 + } 78 + 79 + export async function inspect(name: string) { 80 + await Effect.runPromise( 81 + pipe( 82 + getVolume(name), 83 + Effect.flatMap((volume) => 84 + volume 85 + ? Effect.sync(() => { 86 + console.log(volume); 87 + }) 88 + : Effect.fail(new Error(`Volume with name or ID ${name} not found.`)) 89 + ), 90 + Effect.catchAll((error) => 91 + Effect.sync(() => { 92 + console.error(`An error occurred: ${error}`); 93 + Deno.exit(1); 94 + }) 95 + ), 96 + ), 97 + ); 98 + }
+1
src/utils.ts
··· 22 portForward?: string; 23 detach?: boolean; 24 install?: boolean; 25 } 26 27 class LogCommandError extends Data.TaggedError("LogCommandError")<{
··· 22 portForward?: string; 23 detach?: boolean; 24 install?: boolean; 25 + volume?: string; 26 } 27 28 class LogCommandError extends Data.TaggedError("LogCommandError")<{
+128
src/volumes.ts
···
··· 1 + import { createId } from "@paralleldrive/cuid2"; 2 + import { Data, Effect } from "effect"; 3 + import type { DeleteResult, InsertResult } from "kysely"; 4 + import { VOLUME_DIR } from "./constants.ts"; 5 + import { ctx } from "./context.ts"; 6 + import type { Image, Volume } from "./db.ts"; 7 + 8 + export class VolumeError extends Data.TaggedError("VolumeError")<{ 9 + message?: unknown; 10 + }> {} 11 + 12 + export const listVolumes = () => 13 + Effect.tryPromise({ 14 + try: () => ctx.db.selectFrom("volumes").selectAll().execute(), 15 + catch: (error) => 16 + new VolumeError({ 17 + message: error instanceof Error ? error.message : String(error), 18 + }), 19 + }); 20 + 21 + export const getVolume = ( 22 + id: string, 23 + ): Effect.Effect<Volume | undefined, VolumeError, never> => 24 + Effect.tryPromise({ 25 + try: () => 26 + ctx.db 27 + .selectFrom("volumes") 28 + .selectAll() 29 + .where((eb) => 30 + eb.or([ 31 + eb("name", "=", id), 32 + eb("id", "=", id), 33 + eb("path", "=", id), 34 + ]) 35 + ) 36 + .executeTakeFirst(), 37 + catch: (error) => 38 + new VolumeError({ 39 + message: error instanceof Error ? error.message : String(error), 40 + }), 41 + }); 42 + 43 + export const saveVolume = ( 44 + volume: Volume, 45 + ): Effect.Effect<InsertResult[], VolumeError, never> => 46 + Effect.tryPromise({ 47 + try: () => 48 + ctx.db.insertInto("volumes") 49 + .values(volume) 50 + .execute(), 51 + catch: (error) => 52 + new VolumeError({ 53 + message: error instanceof Error ? error.message : String(error), 54 + }), 55 + }); 56 + 57 + export const deleteVolume = ( 58 + id: string, 59 + ): Effect.Effect<DeleteResult[], VolumeError, never> => 60 + Effect.tryPromise({ 61 + try: () => 62 + ctx.db.deleteFrom("volumes").where((eb) => 63 + eb.or([ 64 + eb("name", "=", id), 65 + eb("id", "=", id), 66 + ]) 67 + ).execute(), 68 + catch: (error) => 69 + new VolumeError({ 70 + message: error instanceof Error ? error.message : String(error), 71 + }), 72 + }); 73 + 74 + export const createVolume = ( 75 + name: string, 76 + baseImage: Image, 77 + ): Effect.Effect<Volume, VolumeError, never> => 78 + Effect.tryPromise({ 79 + try: async () => { 80 + const path = `${VOLUME_DIR}/${name}.qcow2`; 81 + 82 + if ((await Deno.stat(path).catch(() => false))) { 83 + throw new Error(`Volume with name ${name} already exists`); 84 + } 85 + 86 + await Deno.mkdir(VOLUME_DIR, { recursive: true }); 87 + const qemu = new Deno.Command("qemu-img", { 88 + args: [ 89 + "create", 90 + "-F", 91 + "raw", 92 + "-f", 93 + "qcow2", 94 + "-b", 95 + baseImage.path, 96 + path, 97 + ], 98 + stdout: "inherit", 99 + stderr: "inherit", 100 + }) 101 + .spawn(); 102 + const status = await qemu.status; 103 + if (!status.success) { 104 + throw new Error( 105 + `Failed to create volume: qemu-img exited with code ${status.code}`, 106 + ); 107 + } 108 + ctx.db.insertInto("volumes").values({ 109 + id: createId(), 110 + name, 111 + path, 112 + baseImageId: baseImage.id, 113 + }).execute(); 114 + const volume = await ctx.db 115 + .selectFrom("volumes") 116 + .selectAll() 117 + .where("name", "=", name) 118 + .executeTakeFirst(); 119 + if (!volume) { 120 + throw new Error("Failed to create volume"); 121 + } 122 + return volume; 123 + }, 124 + catch: (error) => 125 + new VolumeError({ 126 + message: error instanceof Error ? error.message : String(error), 127 + }), 128 + });