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

Merge pull request #6 from tsirysndr/feat/volumes

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

authored by tsiry-sandratraina.com and committed by

GitHub c97df490 1e3ccb8f

+422 -7
+28
main.ts
··· 26 26 import start from "./src/subcommands/start.ts"; 27 27 import stop from "./src/subcommands/stop.ts"; 28 28 import tag from "./src/subcommands/tag.ts"; 29 + import * as volumes from "./src/subcommands/volume.ts"; 29 30 import { 30 31 createDriveImageIfNeeded, 31 32 downloadIso, ··· 237 238 "-p, --port-forward <mappings:string>", 238 239 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 239 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 + ) 240 245 .action(async (options: unknown, vmName: string) => { 241 246 await start(vmName, Boolean((options as { detach: boolean }).detach)); 242 247 }) ··· 367 372 "-p, --port-forward <mappings:string>", 368 373 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 369 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 + ) 370 379 .action(async (_options: unknown, image: string) => { 371 380 await run(image); 372 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") 373 401 .parse(Deno.args); 374 402 }
+1
src/constants.ts
··· 4 4 export const EMPTY_DISK_THRESHOLD_KB: number = 100; 5 5 export const CONFIG_FILE_NAME: string = "vmconfig.toml"; 6 6 export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; 7 + export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`;
+11
src/db.ts
··· 16 16 export type DatabaseSchema = { 17 17 virtual_machines: VirtualMachine; 18 18 images: Image; 19 + volumes: Volume; 19 20 }; 20 21 21 22 export type VirtualMachine = { ··· 34 35 version: string; 35 36 status: STATUS; 36 37 pid: number; 38 + volume?: string; 37 39 createdAt?: string; 38 40 updatedAt?: string; 39 41 }; ··· 46 48 path: string; 47 49 format: string; 48 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; 49 60 createdAt?: string; 50 61 }; 51 62
+74
src/migrations.ts
··· 218 218 }, 219 219 }; 220 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 + 221 295 export const migrateToLatest = async (db: Database): Promise<void> => { 222 296 const migrator = new Migrator({ db, provider: migrationProvider }); 223 297 const { error } = await migrator.migrateToLatest();
+1
src/mod.ts
··· 7 7 export * from "./state.ts"; 8 8 export * from "./types.ts"; 9 9 export * from "./utils.ts"; 10 + export * from "./volumes.ts";
+27 -2
src/subcommands/run.ts
··· 1 1 import { parseFlags } from "@cliffy/flags"; 2 2 import { Effect, pipe } from "effect"; 3 - import type { Image } from "../db.ts"; 3 + import type { Image, Volume } from "../db.ts"; 4 4 import { getImage } from "../images.ts"; 5 5 import { createBridgeNetworkIfNeeded } from "../network.ts"; 6 6 import { pullImage, PullImageError, setupOrasBinary } from "../oras.ts"; 7 7 import { type Options, runQemu, validateImage } from "../utils.ts"; 8 + import { createVolume, getVolume } from "../volumes.ts"; 8 9 9 10 const pullImageOnMissing = ( 10 11 name: string, ··· 28 29 }), 29 30 ); 30 31 31 - const runImage = (image: Image) => 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?]) => 32 48 Effect.gen(function* () { 33 49 console.log(`Running image ${image.repository}...`); 34 50 const options = mergeFlags(image); 35 51 if (options.bridge) { 36 52 yield* createBridgeNetworkIfNeeded(options.bridge); 37 53 } 54 + 55 + if (volume) { 56 + options.image = volume.path; 57 + options.install = true; 58 + options.diskFormat = "qcow2"; 59 + } 60 + 38 61 yield* runQemu(null, options); 39 62 }); 40 63 ··· 46 69 Effect.promise(() => setupOrasBinary()), 47 70 Effect.tap(() => validateImage(image)), 48 71 Effect.flatMap(() => pullImageOnMissing(image)), 72 + Effect.flatMap(createVolumeIfNeeded), 49 73 Effect.flatMap(runImage), 50 74 Effect.catchAll((error) => 51 75 Effect.sync(() => { ··· 69 93 detach: flags.detach, 70 94 install: false, 71 95 diskFormat: image.format, 96 + volume: flags.volume, 72 97 }; 73 98 }
+53 -5
src/subcommands/start.ts
··· 2 2 import _ from "@es-toolkit/es-toolkit/compat"; 3 3 import { Data, Effect, pipe } from "effect"; 4 4 import { LOGS_DIR } from "../constants.ts"; 5 - import type { VirtualMachine } from "../db.ts"; 5 + import type { VirtualMachine, Volume } from "../db.ts"; 6 + import { getImage } from "../images.ts"; 6 7 import { getInstanceState, updateInstanceState } from "../state.ts"; 7 8 import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; 9 + import { createVolume, getVolume } from "../volumes.ts"; 8 10 9 11 class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 10 12 name: string; ··· 174 176 Deno.exit(1); 175 177 }); 176 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 + 177 211 const startDetachedEffect = (name: string) => 178 212 pipe( 179 213 findVm(name), 180 214 Effect.tap(logStarting), 181 215 Effect.flatMap(applyFlags), 182 - Effect.flatMap((vm) => 216 + Effect.flatMap(createVolumeIfNeeded), 217 + Effect.flatMap(([vm, volume]) => 183 218 pipe( 184 219 setupFirmware(), 185 - Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 220 + Effect.flatMap((firmwareArgs) => 221 + buildQemuArgs({ 222 + ...vm, 223 + drivePath: volume ? volume.path : vm.drivePath, 224 + diskFormat: volume ? "qcow2" : vm.diskFormat, 225 + }, firmwareArgs) 226 + ), 186 227 Effect.flatMap((qemuArgs) => 187 228 pipe( 188 229 createLogsDir(), ··· 201 242 findVm(name), 202 243 Effect.tap(logStarting), 203 244 Effect.flatMap(applyFlags), 204 - Effect.flatMap((vm) => 245 + Effect.flatMap(createVolumeIfNeeded), 246 + Effect.flatMap(([vm, volume]) => 205 247 pipe( 206 248 setupFirmware(), 207 - Effect.flatMap((firmwareArgs) => buildQemuArgs(vm, firmwareArgs)), 249 + Effect.flatMap((firmwareArgs) => 250 + buildQemuArgs({ 251 + ...vm, 252 + drivePath: volume ? volume.path : vm.drivePath, 253 + diskFormat: volume ? "qcow2" : vm.diskFormat, 254 + }, firmwareArgs) 255 + ), 208 256 Effect.flatMap((qemuArgs) => startInteractiveQemu(name, vm, qemuArgs)), 209 257 Effect.map((status) => status.success ? 0 : (status.code || 1)), 210 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 22 portForward?: string; 23 23 detach?: boolean; 24 24 install?: boolean; 25 + volume?: string; 25 26 } 26 27 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 + });