A convenient CLI tool to quickly spin up DragonflyBSD virtual machines using QEMU with sensible defaults.

Merge pull request #5 from tsirysndr/feat/volumes

feat: add support for volumes

authored by tsiry-sandratraina.com and committed by

GitHub b656f0c7 ffa2bb78

+443 -28
+28
main.ts
··· 24 import start from "./src/subcommands/start.ts"; 25 import stop from "./src/subcommands/stop.ts"; 26 import tag from "./src/subcommands/tag.ts"; 27 28 import { getImage } from "./src/images.ts"; 29 import { getImageArchivePath } from "./src/mod.ts"; ··· 245 "-p, --port-forward <mappings:string>", 246 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 247 ) 248 .action(async (options: unknown, vmName: string) => { 249 await start(vmName, Boolean((options as { detach: boolean }).detach)); 250 }) ··· 375 "-p, --port-forward <mappings:string>", 376 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 377 ) 378 .action(async (_options: unknown, image: string) => { 379 await run(image); 380 }) 381 .parse(Deno.args); 382 }
··· 24 import start from "./src/subcommands/start.ts"; 25 import stop from "./src/subcommands/stop.ts"; 26 import tag from "./src/subcommands/tag.ts"; 27 + import * as volumes from "./src/subcommands/volume.ts"; 28 29 import { getImage } from "./src/images.ts"; 30 import { getImageArchivePath } from "./src/mod.ts"; ··· 246 "-p, --port-forward <mappings:string>", 247 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 248 ) 249 + .option( 250 + "-v, --volume <name:string>", 251 + "Name of the volume to attach to the VM, will be created if it doesn't exist", 252 + ) 253 .action(async (options: unknown, vmName: string) => { 254 await start(vmName, Boolean((options as { detach: boolean }).detach)); 255 }) ··· 380 "-p, --port-forward <mappings:string>", 381 "Port forwarding rules in the format hostPort:guestPort (comma-separated for multiple)", 382 ) 383 + .option( 384 + "-v, --volume <name:string>", 385 + "Name of the volume to attach to the VM, will be created if it doesn't exist", 386 + ) 387 .action(async (_options: unknown, image: string) => { 388 await run(image); 389 }) 390 + .command("volumes", "List all volumes") 391 + .action(async () => { 392 + await volumes.list(); 393 + }) 394 + .command( 395 + "volume", 396 + new Command() 397 + .command("rm", "Remove a volume") 398 + .arguments("<volume-name:string>") 399 + .action(async (_options: unknown, volumeName: string) => { 400 + await volumes.remove(volumeName); 401 + }) 402 + .command("inspect", "Inspect a volume") 403 + .arguments("<volume-name:string>") 404 + .action(async (_options: unknown, volumeName: string) => { 405 + await volumes.inspect(volumeName); 406 + }), 407 + ) 408 + .description("Manage volumes") 409 .parse(Deno.args); 410 }
+1
src/constants.ts
··· 4 export const EMPTY_DISK_THRESHOLD_KB = 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 = 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
+1
src/images.ts
··· 32 ]), 33 eb("id", "=", id), 34 eb("digest", "=", id), 35 ]) 36 ) 37 .executeTakeFirst(),
··· 32 ]), 33 eb("id", "=", id), 34 eb("digest", "=", id), 35 + eb("path", "=", id), 36 ]) 37 ) 38 .executeTakeFirst(),
+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";
+7 -6
src/subcommands/inspect.ts
··· 1 import { Data, Effect, pipe } from "effect"; 2 - import type { Image, VirtualMachine } from "../db.ts"; 3 import { getImage } from "../images.ts"; 4 import { getInstanceState } from "../state.ts"; 5 6 class ItemNotFoundError extends Data.TaggedError("ItemNotFoundError")<{ 7 name: string; ··· 9 10 const find = (name: string) => 11 pipe( 12 - Effect.all([getInstanceState(name), getImage(name)]), 13 - Effect.flatMap(([vm, image]) => 14 - vm || image 15 - ? Effect.succeed(vm || image) 16 : Effect.fail(new ItemNotFoundError({ name })) 17 ), 18 ); 19 20 - const display = (vm: VirtualMachine | Image | undefined) => 21 Effect.sync(() => { 22 console.log(vm); 23 });
··· 1 import { Data, Effect, pipe } from "effect"; 2 + import type { Image, VirtualMachine, Volume } from "../db.ts"; 3 import { getImage } from "../images.ts"; 4 import { getInstanceState } from "../state.ts"; 5 + import { getVolume } from "../volumes.ts"; 6 7 class ItemNotFoundError extends Data.TaggedError("ItemNotFoundError")<{ 8 name: string; ··· 10 11 const find = (name: string) => 12 pipe( 13 + Effect.all([getInstanceState(name), getImage(name), getVolume(name)]), 14 + Effect.flatMap(([vm, image, volume]) => 15 + vm || image || volume 16 + ? Effect.succeed(vm || image || volume) 17 : Effect.fail(new ItemNotFoundError({ name })) 18 ), 19 ); 20 21 + const display = (vm: VirtualMachine | Image | Volume | undefined) => 22 Effect.sync(() => { 23 console.log(vm); 24 });
+29 -4
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(() => { 52 - console.error(`Failed to run image: ${error.cause} ${image}`); 53 Deno.exit(1); 54 }) 55 ), ··· 60 function mergeFlags(image: Image): Options { 61 const { flags } = parseFlags(Deno.args); 62 return { 63 - cpu: flags.cpu ? flags.cpu : "host", 64 cpus: flags.cpus ? flags.cpus : 2, 65 memory: flags.memory ? flags.memory : "2G", 66 image: image.path, ··· 70 install: false, 71 diskFormat: image.format, 72 size: flags.size ? flags.size : "20G", 73 }; 74 }
··· 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(() => { 76 + console.error(`Failed to run image: ${error.message} ${image}`); 77 Deno.exit(1); 78 }) 79 ), ··· 84 function mergeFlags(image: Image): Options { 85 const { flags } = parseFlags(Deno.args); 86 return { 87 + cpu: flags.cpu ? flags.cpu : Deno.build.arch === "aarch64" ? "max" : "host", 88 cpus: flags.cpus ? flags.cpus : 2, 89 memory: flags.memory ? flags.memory : "2G", 90 image: image.path, ··· 94 install: false, 95 diskFormat: image.format, 96 size: flags.size ? flags.size : "20G", 97 + volume: flags.volume, 98 }; 99 }
+64 -18
src/subcommands/start.ts
··· 3 import { Effect, pipe } from "effect"; 4 import { LOGS_DIR } from "../constants.ts"; 5 import type { VirtualMachine } from "../db.ts"; 6 import { getInstanceStateOrFail, updateInstanceState } from "../state.ts"; 7 import { setupNATNetworkArgs } from "../utils.ts"; 8 9 const logStartingMessage = (vm: VirtualMachine) => 10 Effect.sync(() => { ··· 90 Deno.exit(0); 91 }); 92 93 - const startVirtualMachineDetached = (name: string, vm: VirtualMachine) => { 94 - const qemuArgs = buildQemuArgs(vm); 95 - const logPath = `${LOGS_DIR}/${vm.name}.log`; 96 - const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath); 97 98 - return pipe( 99 - createLogsDirectory(), 100 - Effect.flatMap(() => startDetachedQemu(fullCommand)), 101 - Effect.flatMap((qemuPid) => 102 - pipe( 103 - updateInstanceState(name, "RUNNING", qemuPid), 104 - Effect.flatMap(() => logDetachedSuccess(vm, qemuPid, logPath)), 105 - ) 106 - ), 107 - ); 108 - }; 109 110 const startAttachedQemu = ( 111 name: string, ··· 146 } 147 }); 148 149 - const startVirtualMachineAttached = (name: string, vm: VirtualMachine) => { 150 - const qemuArgs = buildQemuArgs(vm); 151 152 return pipe( 153 - startAttachedQemu(name, vm, qemuArgs), 154 Effect.flatMap(validateQemuExit), 155 ); 156 };
··· 3 import { Effect, pipe } from "effect"; 4 import { LOGS_DIR } from "../constants.ts"; 5 import type { VirtualMachine } from "../db.ts"; 6 + import { getImage } from "../images.ts"; 7 import { getInstanceStateOrFail, updateInstanceState } from "../state.ts"; 8 import { setupNATNetworkArgs } from "../utils.ts"; 9 + import { createVolume, getVolume } from "../volumes.ts"; 10 11 const logStartingMessage = (vm: VirtualMachine) => 12 Effect.sync(() => { ··· 92 Deno.exit(0); 93 }); 94 95 + const startVirtualMachineDetached = (name: string, vm: VirtualMachine) => 96 + Effect.gen(function* () { 97 + const volume = yield* createVolumeIfNeeded(vm); 98 + const qemuArgs = buildQemuArgs({ 99 + ...vm, 100 + drivePath: volume ? volume.path : vm.drivePath, 101 + diskFormat: volume ? "qcow2" : vm.diskFormat, 102 + }); 103 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 104 + const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath); 105 106 + return pipe( 107 + createLogsDirectory(), 108 + Effect.flatMap(() => startDetachedQemu(fullCommand)), 109 + Effect.flatMap((qemuPid) => 110 + pipe( 111 + updateInstanceState(name, "RUNNING", qemuPid), 112 + Effect.flatMap(() => logDetachedSuccess(vm, qemuPid, logPath)), 113 + ) 114 + ), 115 + ); 116 + }); 117 118 const startAttachedQemu = ( 119 name: string, ··· 154 } 155 }); 156 157 + const createVolumeIfNeeded = (vm: VirtualMachine) => 158 + Effect.gen(function* () { 159 + const { flags } = parseFlags(Deno.args); 160 + if (!flags.volume) { 161 + return; 162 + } 163 + const volume = yield* getVolume(flags.volume as string); 164 + if (volume) { 165 + return volume; 166 + } 167 168 + if (!vm.drivePath) { 169 + throw new Error( 170 + `Cannot create volume: Virtual machine ${vm.name} has no drivePath defined.`, 171 + ); 172 + } 173 + 174 + let image = yield* getImage(vm.drivePath); 175 + 176 + if (!image) { 177 + const volume = yield* getVolume(vm.drivePath); 178 + if (volume) { 179 + image = yield* getImage(volume.baseImageId); 180 + } 181 + } 182 + 183 + const newVolume = yield* createVolume(flags.volume as string, image!); 184 + return newVolume; 185 + }); 186 + 187 + const startVirtualMachineAttached = (name: string, vm: VirtualMachine) => { 188 return pipe( 189 + createVolumeIfNeeded(vm), 190 + Effect.flatMap((volume) => 191 + Effect.succeed( 192 + buildQemuArgs({ 193 + ...vm, 194 + drivePath: volume ? volume.path : vm.drivePath, 195 + diskFormat: volume ? "qcow2" : vm.diskFormat, 196 + }), 197 + ) 198 + ), 199 + Effect.flatMap((qemuArgs) => startAttachedQemu(name, vm, qemuArgs)), 200 Effect.flatMap(validateQemuExit), 201 ); 202 };
+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
··· 49 portForward?: string; 50 detach?: boolean; 51 install?: boolean; 52 } 53 54 export const getCurrentArch = (): string => {
··· 49 portForward?: string; 50 detach?: boolean; 51 install?: boolean; 52 + volume?: string; 53 } 54 55 export const getCurrentArch = (): string => {
+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 + });