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

feat: implement HTTP API with image and machine management endpoints

+1
deno.json
··· 23 23 "chalk": "npm:chalk@^5.6.2", 24 24 "dayjs": "npm:dayjs@^1.11.19", 25 25 "effect": "npm:effect@^3.19.2", 26 + "hono": "npm:hono@^4.10.6", 26 27 "kysely": "npm:kysely@0.27.6", 27 28 "moniker": "npm:moniker@^0.1.2" 28 29 }
+5
deno.lock
··· 41 41 "npm:chalk@^5.6.2": "5.6.2", 42 42 "npm:dayjs@^1.11.19": "1.11.19", 43 43 "npm:effect@^3.19.2": "3.19.2", 44 + "npm:hono@^4.10.6": "4.10.6", 44 45 "npm:kysely@0.27.6": "0.27.6", 45 46 "npm:kysely@~0.27.2": "0.27.6", 46 47 "npm:moniker@~0.1.2": "0.1.2" ··· 230 231 "pure-rand" 231 232 ] 232 233 }, 234 + "hono@4.10.6": { 235 + "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==" 236 + }, 233 237 "kysely@0.27.6": { 234 238 "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" 235 239 }, ··· 261 265 "npm:chalk@^5.6.2", 262 266 "npm:dayjs@^1.11.19", 263 267 "npm:effect@^3.19.2", 268 + "npm:hono@^4.10.6", 264 269 "npm:kysely@0.27.6", 265 270 "npm:moniker@~0.1.2" 266 271 ]
+6 -1
main.ts
··· 25 25 import stop from "./src/subcommands/stop.ts"; 26 26 import tag from "./src/subcommands/tag.ts"; 27 27 import * as volumes from "./src/subcommands/volume.ts"; 28 - 28 + import serve from "./src/api/mod.ts"; 29 29 import { getImage } from "./src/images.ts"; 30 30 import { getImageArchivePath } from "./src/mod.ts"; 31 31 import { ··· 406 406 }), 407 407 ) 408 408 .description("Manage volumes") 409 + .command("serve", "Start the dflybsd-up HTTP API server") 410 + .option("-p, --port <port:number>", "Port to listen on", { default: 8893 }) 411 + .action(() => { 412 + serve(); 413 + }) 409 414 .parse(Deno.args); 410 415 }
+34
src/api/images.ts
··· 1 + import { Hono } from "hono"; 2 + import { Effect, pipe } from "effect"; 3 + import { parseParams, presentation } from "./utils.ts"; 4 + import { getImage, listImages } from "../images.ts"; 5 + 6 + const app = new Hono(); 7 + 8 + app.get("/", (c) => 9 + Effect.runPromise( 10 + pipe( 11 + listImages(), 12 + presentation(c), 13 + ), 14 + )); 15 + 16 + app.get("/:id", (c) => 17 + Effect.runPromise( 18 + pipe( 19 + parseParams(c), 20 + Effect.flatMap(({ id }) => getImage(id)), 21 + presentation(c), 22 + ), 23 + )); 24 + 25 + app.post("/", (c) => { 26 + return c.json({ message: "New image created" }); 27 + }); 28 + 29 + app.delete("/:id", (c) => { 30 + const { id } = c.req.param(); 31 + return c.json({ message: `Image with ID ${id} deleted` }); 32 + }); 33 + 34 + export default app;
+219
src/api/machines.ts
··· 1 + import { Hono } from "hono"; 2 + import { Data, Effect, pipe } from "effect"; 3 + import { 4 + createVolumeIfNeeded, 5 + handleError, 6 + parseCreateMachineRequest, 7 + parseParams, 8 + parseQueryParams, 9 + parseStartRequest, 10 + presentation, 11 + } from "./utils.ts"; 12 + import { DEFAULT_VERSION, getInstanceState, LOGS_DIR } from "../mod.ts"; 13 + import { 14 + listInstances, 15 + removeInstanceState, 16 + saveInstanceState, 17 + updateInstanceState, 18 + } from "../state.ts"; 19 + import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts"; 20 + import { 21 + buildDetachedCommand, 22 + buildQemuArgs, 23 + createLogsDir, 24 + failIfVMRunning, 25 + startDetachedQemu, 26 + } from "../subcommands/start.ts"; 27 + import type { NewMachine } from "../types.ts"; 28 + import { createId } from "@paralleldrive/cuid2"; 29 + import { generateRandomMacAddress } from "../network.ts"; 30 + import Moniker from "moniker"; 31 + import { getImage } from "../images.ts"; 32 + 33 + export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{ 34 + id: string; 35 + }> {} 36 + 37 + export class RemoveRunningVmError extends Data.TaggedError( 38 + "RemoveRunningVmError", 39 + )<{ 40 + id: string; 41 + }> {} 42 + 43 + const app = new Hono(); 44 + 45 + app.get("/", (c) => 46 + Effect.runPromise( 47 + pipe( 48 + parseQueryParams(c), 49 + Effect.flatMap((params) => 50 + listInstances( 51 + params.all === "true" || params.all === "1", 52 + ) 53 + ), 54 + presentation(c), 55 + ), 56 + )); 57 + 58 + app.post("/", (c) => 59 + Effect.runPromise( 60 + pipe( 61 + parseCreateMachineRequest(c), 62 + Effect.flatMap((params: NewMachine) => 63 + Effect.gen(function* () { 64 + const image = yield* getImage(params.image); 65 + if (!image) { 66 + return yield* Effect.fail( 67 + new ImageNotFoundError({ id: params.image }), 68 + ); 69 + } 70 + 71 + const volume = params.volume 72 + ? yield* createVolumeIfNeeded(image, params.volume) 73 + : undefined; 74 + 75 + const macAddress = yield* generateRandomMacAddress(); 76 + const id = createId(); 77 + yield* saveInstanceState({ 78 + id, 79 + name: Moniker.choose(), 80 + bridge: params.bridge, 81 + macAddress, 82 + memory: params.memory || "2G", 83 + cpus: params.cpus || 8, 84 + cpu: params.cpu || "host", 85 + diskSize: "20G", 86 + diskFormat: volume ? "qcow2" : "raw", 87 + portForward: params.portForward 88 + ? params.portForward.join(",") 89 + : undefined, 90 + drivePath: volume ? volume.path : image.path, 91 + version: image.tag ?? DEFAULT_VERSION, 92 + status: "STOPPED", 93 + pid: 0, 94 + }); 95 + 96 + const createdVm = yield* findVm(id); 97 + return createdVm; 98 + }) 99 + ), 100 + presentation(c), 101 + Effect.catchAll((error) => handleError(error, c)), 102 + ), 103 + )); 104 + 105 + app.get("/:id", (c) => 106 + Effect.runPromise( 107 + pipe( 108 + parseParams(c), 109 + Effect.flatMap(({ id }) => getInstanceState(id)), 110 + presentation(c), 111 + ), 112 + )); 113 + 114 + app.delete("/:id", (c) => 115 + Effect.runPromise( 116 + pipe( 117 + parseParams(c), 118 + Effect.flatMap(({ id }) => findVm(id)), 119 + Effect.flatMap((vm) => 120 + vm.status === "RUNNING" 121 + ? Effect.fail(new RemoveRunningVmError({ id: vm.id })) 122 + : Effect.succeed(vm) 123 + ), 124 + Effect.flatMap((vm) => 125 + Effect.gen(function* () { 126 + yield* removeInstanceState(vm.id); 127 + return vm; 128 + }) 129 + ), 130 + presentation(c), 131 + Effect.catchAll((error) => handleError(error, c)), 132 + ), 133 + )); 134 + 135 + app.post("/:id/start", (c) => 136 + Effect.runPromise( 137 + pipe( 138 + Effect.all([parseParams(c), parseStartRequest(c)]), 139 + Effect.flatMap(( 140 + [{ id }, startRequest], 141 + ) => Effect.all([findVm(id), Effect.succeed(startRequest)])), 142 + Effect.flatMap(([vm, startRequest]) => 143 + Effect.gen(function* () { 144 + yield* failIfVMRunning(vm); 145 + const mergedVm = { 146 + ...vm, 147 + cpu: String(startRequest.cpu ?? vm.cpu), 148 + cpus: startRequest.cpus ?? vm.cpus, 149 + memory: startRequest.memory ?? vm.memory, 150 + portForward: startRequest.portForward 151 + ? startRequest.portForward.join(",") 152 + : vm.portForward, 153 + }; 154 + const qemuArgs = buildQemuArgs(mergedVm); 155 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 156 + const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath); 157 + 158 + yield* createLogsDir(); 159 + const qemuPid = yield* startDetachedQemu(fullCommand); 160 + yield* updateInstanceState(vm.id, "RUNNING", qemuPid); 161 + return { ...vm, status: "RUNNING" }; 162 + }) 163 + ), 164 + presentation(c), 165 + Effect.catchAll((error) => handleError(error, c)), 166 + ), 167 + )); 168 + 169 + app.post("/:id/stop", (c) => 170 + Effect.runPromise( 171 + pipe( 172 + parseParams(c), 173 + Effect.flatMap(({ id }) => findVm(id)), 174 + Effect.flatMap(killProcess), 175 + Effect.flatMap(updateToStopped), 176 + presentation(c), 177 + Effect.catchAll((error) => handleError(error, c)), 178 + ), 179 + )); 180 + 181 + app.post("/:id/restart", (c) => 182 + Effect.runPromise( 183 + pipe( 184 + parseParams(c), 185 + Effect.flatMap(({ id }) => findVm(id)), 186 + Effect.flatMap(killProcess), 187 + Effect.flatMap(updateToStopped), 188 + Effect.flatMap(() => Effect.all([parseParams(c), parseStartRequest(c)])), 189 + Effect.flatMap(( 190 + [{ id }, startRequest], 191 + ) => Effect.all([findVm(id), Effect.succeed(startRequest)])), 192 + Effect.flatMap(([vm, startRequest]) => 193 + Effect.gen(function* () { 194 + yield* failIfVMRunning(vm); 195 + const mergedVm = { 196 + ...vm, 197 + cpu: String(startRequest.cpu ?? vm.cpu), 198 + cpus: startRequest.cpus ?? vm.cpus, 199 + memory: startRequest.memory ?? vm.memory, 200 + portForward: startRequest.portForward 201 + ? startRequest.portForward.join(",") 202 + : vm.portForward, 203 + }; 204 + const qemuArgs = buildQemuArgs(mergedVm); 205 + const logPath = `${LOGS_DIR}/${vm.name}.log`; 206 + const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath); 207 + 208 + yield* createLogsDir(); 209 + const qemuPid = yield* startDetachedQemu(fullCommand); 210 + yield* updateInstanceState(vm.id, "RUNNING", qemuPid); 211 + return { ...vm, status: "RUNNING" }; 212 + }) 213 + ), 214 + presentation(c), 215 + Effect.catchAll((error) => handleError(error, c)), 216 + ), 217 + )); 218 + 219 + export default app;
+46
src/api/mod.ts
··· 1 + import machines from "./machines.ts"; 2 + import images from "./images.ts"; 3 + import volumes from "./volumes.ts"; 4 + import { Hono } from "hono"; 5 + import { logger } from "hono/logger"; 6 + import { cors } from "hono/cors"; 7 + import { bearerAuth } from "hono/bearer-auth"; 8 + import { parseFlags } from "@cliffy/flags"; 9 + 10 + export { images, machines, volumes }; 11 + 12 + export default function () { 13 + const token = Deno.env.get("DFLYBSD_UP_API_TOKEN") || 14 + crypto.randomUUID(); 15 + const { flags } = parseFlags(Deno.args); 16 + 17 + if (!Deno.env.get("DFLYBSD_UP_API_TOKEN")) { 18 + console.log(`Using API token: ${token}`); 19 + } else { 20 + console.log( 21 + `Using provided API token from environment variable DFLYBSD_UP_API_TOKEN`, 22 + ); 23 + } 24 + 25 + const app = new Hono(); 26 + 27 + app.use(logger()); 28 + app.use(cors()); 29 + 30 + app.use("/images/*", bearerAuth({ token })); 31 + app.use("/machines/*", bearerAuth({ token })); 32 + app.use("/volumes/*", bearerAuth({ token })); 33 + 34 + app.route("/images", images); 35 + app.route("/machines", machines); 36 + app.route("/volumes", volumes); 37 + 38 + const port = Number( 39 + flags.port || flags.p || 40 + (Deno.env.get("DFLYBSD_UP_PORT") 41 + ? Number(Deno.env.get("DFLYBSD_UP_PORT")) 42 + : 8893), 43 + ); 44 + 45 + Deno.serve({ port }, app.fetch); 46 + }
+158
src/api/utils.ts
··· 1 + import { Data, Effect } from "effect"; 2 + import type { Context } from "hono"; 3 + import { 4 + type CommandError, 5 + StopCommandError, 6 + VmNotFoundError, 7 + } from "../subcommands/stop.ts"; 8 + import { VmAlreadyRunningError } from "../subcommands/start.ts"; 9 + import { 10 + MachineParamsSchema, 11 + NewMachineSchema, 12 + NewVolumeSchema, 13 + } from "../types.ts"; 14 + import type { Image, Volume } from "../db.ts"; 15 + import { createVolume, getVolume } from "../volumes.ts"; 16 + import { ImageNotFoundError, RemoveRunningVmError } from "./machines.ts"; 17 + 18 + export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query()); 19 + 20 + export const parseParams = (c: Context) => Effect.succeed(c.req.param()); 21 + 22 + export const presentation = (c: Context) => 23 + Effect.flatMap((data) => Effect.succeed(c.json(data))); 24 + 25 + export class ParseRequestError extends Data.TaggedError("ParseRequestError")<{ 26 + cause?: unknown; 27 + message: string; 28 + }> {} 29 + 30 + export const handleError = ( 31 + error: 32 + | VmNotFoundError 33 + | StopCommandError 34 + | CommandError 35 + | ParseRequestError 36 + | VmAlreadyRunningError 37 + | ImageNotFoundError 38 + | RemoveRunningVmError 39 + | Error, 40 + c: Context, 41 + ) => 42 + Effect.sync(() => { 43 + if (error instanceof VmNotFoundError) { 44 + return c.json( 45 + { message: "VM not found", code: "VM_NOT_FOUND" }, 46 + 404, 47 + ); 48 + } 49 + if (error instanceof StopCommandError) { 50 + return c.json( 51 + { 52 + message: error.message || 53 + `Failed to stop VM ${error.vmName}`, 54 + code: "STOP_COMMAND_ERROR", 55 + }, 56 + 500, 57 + ); 58 + } 59 + 60 + if (error instanceof ParseRequestError) { 61 + return c.json( 62 + { 63 + message: error.message || "Failed to parse request body", 64 + code: "PARSE_BODY_ERROR", 65 + }, 66 + 400, 67 + ); 68 + } 69 + 70 + if (error instanceof VmAlreadyRunningError) { 71 + return c.json( 72 + { 73 + message: `VM ${error.name} is already running`, 74 + code: "VM_ALREADY_RUNNING", 75 + }, 76 + 400, 77 + ); 78 + } 79 + 80 + if (error instanceof ImageNotFoundError) { 81 + return c.json( 82 + { 83 + message: `Image ${error.id} not found`, 84 + code: "IMAGE_NOT_FOUND", 85 + }, 86 + 404, 87 + ); 88 + } 89 + 90 + if (error instanceof RemoveRunningVmError) { 91 + return c.json( 92 + { 93 + message: 94 + `Cannot remove running VM with ID ${error.id}. Please stop it first.`, 95 + code: "REMOVE_RUNNING_VM_ERROR", 96 + }, 97 + 400, 98 + ); 99 + } 100 + 101 + return c.json( 102 + { message: error instanceof Error ? error.message : String(error) }, 103 + 500, 104 + ); 105 + }); 106 + 107 + export const parseStartRequest = (c: Context) => 108 + Effect.tryPromise({ 109 + try: async () => { 110 + const body = await c.req.json(); 111 + return MachineParamsSchema.parse(body); 112 + }, 113 + catch: (error) => 114 + new ParseRequestError({ 115 + cause: error, 116 + message: error instanceof Error ? error.message : String(error), 117 + }), 118 + }); 119 + 120 + export const parseCreateMachineRequest = (c: Context) => 121 + Effect.tryPromise({ 122 + try: async () => { 123 + const body = await c.req.json(); 124 + return NewMachineSchema.parse(body); 125 + }, 126 + catch: (error) => 127 + new ParseRequestError({ 128 + cause: error, 129 + message: error instanceof Error ? error.message : String(error), 130 + }), 131 + }); 132 + 133 + export const createVolumeIfNeeded = ( 134 + image: Image, 135 + volumeName: string, 136 + size?: string, 137 + ): Effect.Effect<Volume, Error, never> => 138 + Effect.gen(function* () { 139 + const volume = yield* getVolume(volumeName); 140 + if (volume) { 141 + return volume; 142 + } 143 + 144 + return yield* createVolume(volumeName, image, size); 145 + }); 146 + 147 + export const parseCreateVolumeRequest = (c: Context) => 148 + Effect.tryPromise({ 149 + try: async () => { 150 + const body = await c.req.json(); 151 + return NewVolumeSchema.parse(body); 152 + }, 153 + catch: (error) => 154 + new ParseRequestError({ 155 + cause: error, 156 + message: error instanceof Error ? error.message : String(error), 157 + }), 158 + });
+71
src/api/volumes.ts
··· 1 + import { Hono } from "hono"; 2 + import { Effect, pipe } from "effect"; 3 + import { 4 + createVolumeIfNeeded, 5 + handleError, 6 + parseCreateVolumeRequest, 7 + parseParams, 8 + presentation, 9 + } from "./utils.ts"; 10 + import { listVolumes } from "../mod.ts"; 11 + import { deleteVolume, getVolume } from "../volumes.ts"; 12 + import type { NewVolume } from "../types.ts"; 13 + import { getImage } from "../images.ts"; 14 + import { ImageNotFoundError } from "./machines.ts"; 15 + 16 + const app = new Hono(); 17 + 18 + app.get("/", (c) => 19 + Effect.runPromise( 20 + pipe( 21 + listVolumes(), 22 + presentation(c), 23 + ), 24 + )); 25 + 26 + app.get("/:id", (c) => 27 + Effect.runPromise( 28 + pipe( 29 + parseParams(c), 30 + Effect.flatMap(({ id }) => getVolume(id)), 31 + presentation(c), 32 + ), 33 + )); 34 + 35 + app.delete("/:id", (c) => 36 + Effect.runPromise( 37 + pipe( 38 + parseParams(c), 39 + Effect.flatMap(({ id }) => 40 + Effect.gen(function* () { 41 + const volume = yield* getVolume(id); 42 + yield* deleteVolume(id); 43 + return volume; 44 + }) 45 + ), 46 + presentation(c), 47 + ), 48 + )); 49 + 50 + app.post("/", (c) => 51 + Effect.runPromise( 52 + pipe( 53 + parseCreateVolumeRequest(c), 54 + Effect.flatMap((params: NewVolume) => 55 + Effect.gen(function* () { 56 + const image = yield* getImage(params.baseImage); 57 + if (!image) { 58 + return yield* Effect.fail( 59 + new ImageNotFoundError({ id: params.baseImage }), 60 + ); 61 + } 62 + 63 + return yield* createVolumeIfNeeded(image, params.name, params.size); 64 + }) 65 + ), 66 + presentation(c), 67 + Effect.catchAll((error) => handleError(error, c)), 68 + ), 69 + )); 70 + 71 + export default app;
+1
src/mod.ts
··· 8 8 export * from "./types.ts"; 9 9 export * from "./utils.ts"; 10 10 export * from "./volumes.ts"; 11 + export * from "./api/mod.ts";
+17
src/state.ts
··· 92 92 }), 93 93 ), 94 94 ); 95 + 96 + export const listInstances = ( 97 + all: boolean, 98 + ): Effect.Effect<VirtualMachine[], DbError, never> => 99 + Effect.tryPromise({ 100 + try: () => 101 + ctx.db.selectFrom("virtual_machines") 102 + .selectAll() 103 + .where((eb) => { 104 + if (all) { 105 + return eb("id", "!=", ""); 106 + } 107 + return eb("status", "=", "RUNNING"); 108 + }) 109 + .execute(), 110 + catch: (error) => new DbError({ cause: error }), 111 + });
+8 -8
src/subcommands/run.ts
··· 84 84 function mergeFlags(image: Image): Options { 85 85 const { flags } = parseFlags(Deno.args); 86 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", 87 + cpu: (flags.cpu || flags.c) ? (flags.cpu || flags.c) : "host", 88 + cpus: (flags.cpus || flags.C) ? (flags.cpus || flags.C) : 2, 89 + memory: (flags.memory || flags.m) ? (flags.memory || flags.m) : "2G", 90 90 image: image.path, 91 - bridge: flags.bridge, 92 - portForward: flags.portForward, 93 - detach: flags.detach, 91 + bridge: flags.bridge || flags.b, 92 + portForward: flags.portForward || flags.p, 93 + detach: flags.detach || flags.d, 94 94 install: false, 95 95 diskFormat: image.format, 96 - size: flags.size ? flags.size : "20G", 97 - volume: flags.volume, 96 + volume: flags.volume || flags.v, 97 + size: flags.size || flags.s, 98 98 }; 99 99 }
+46 -17
src/subcommands/start.ts
··· 1 1 import { parseFlags } from "@cliffy/flags"; 2 2 import _ from "@es-toolkit/es-toolkit/compat"; 3 - import { Effect, pipe } from "effect"; 3 + import { Data, Effect, pipe } from "effect"; 4 4 import { LOGS_DIR } from "../constants.ts"; 5 5 import type { VirtualMachine } from "../db.ts"; 6 6 import { getImage } from "../images.ts"; ··· 8 8 import { setupNATNetworkArgs } from "../utils.ts"; 9 9 import { createVolume, getVolume } from "../volumes.ts"; 10 10 11 + export class VmAlreadyRunningError 12 + extends Data.TaggedError("VmAlreadyRunningError")<{ 13 + name: string; 14 + }> {} 15 + 11 16 const logStartingMessage = (vm: VirtualMachine) => 12 17 Effect.sync(() => { 13 18 console.log(`Starting virtual machine ${vm.name} (ID: ${vm.id})...`); 14 19 }); 15 20 16 - const buildQemuArgs = (vm: VirtualMachine) => [ 21 + export const buildQemuArgs = (vm: VirtualMachine) => [ 17 22 ..._.compact([vm.bridge && "qemu-system-x86_64"]), 18 23 ...Deno.build.os === "linux" ? ["-enable-kvm"] : [], 19 24 "-cpu", ··· 47 52 ), 48 53 ]; 49 54 50 - const createLogsDirectory = () => 55 + export const createLogsDir = () => 51 56 Effect.tryPromise({ 52 57 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 53 58 catch: (cause) => new Error(`Failed to create logs directory: ${cause}`), 54 59 }); 55 60 56 - const buildDetachedCommand = ( 61 + export const buildDetachedCommand = ( 57 62 vm: VirtualMachine, 58 63 qemuArgs: string[], 59 64 logPath: string, ··· 64 69 } >> "${logPath}" 2>&1 & echo $!` 65 70 : `qemu-system-x86_64 ${qemuArgs.join(" ")} >> "${logPath}" 2>&1 & echo $!`; 66 71 67 - const startDetachedQemu = (fullCommand: string) => 72 + export const startDetachedQemu = (fullCommand: string) => 68 73 Effect.tryPromise({ 69 74 try: async () => { 70 75 const cmd = new Deno.Command("sh", { 71 76 args: ["-c", fullCommand], 72 77 stdin: "null", 73 78 stdout: "piped", 74 - }); 79 + }).spawn(); 80 + 81 + await new Promise((resolve) => setTimeout(resolve, 2000)); 75 82 76 - const { stdout } = await cmd.spawn().output(); 83 + const { stdout } = await cmd.output(); 77 84 return parseInt(new TextDecoder().decode(stdout).trim(), 10); 78 85 }, 79 86 catch: (cause) => new Error(`Failed to start QEMU: ${cause}`), ··· 94 101 95 102 const startVirtualMachineDetached = (name: string, vm: VirtualMachine) => 96 103 Effect.gen(function* () { 104 + yield* failIfVMRunning(vm); 97 105 const volume = yield* createVolumeIfNeeded(vm); 98 106 const qemuArgs = buildQemuArgs({ 99 107 ...vm, ··· 103 111 const logPath = `${LOGS_DIR}/${vm.name}.log`; 104 112 const fullCommand = buildDetachedCommand(vm, qemuArgs, logPath); 105 113 106 - return pipe( 107 - createLogsDirectory(), 114 + return yield* pipe( 115 + createLogsDir(), 108 116 Effect.flatMap(() => startDetachedQemu(fullCommand)), 109 117 Effect.flatMap((qemuPid) => 110 118 pipe( ··· 154 162 } 155 163 }); 156 164 165 + export const failIfVMRunning = (vm: VirtualMachine) => 166 + Effect.gen(function* () { 167 + if (vm.status === "RUNNING") { 168 + return yield* Effect.fail( 169 + new VmAlreadyRunningError({ name: vm.name }), 170 + ); 171 + } 172 + return vm; 173 + }); 174 + 157 175 const createVolumeIfNeeded = (vm: VirtualMachine) => 158 176 Effect.gen(function* () { 159 177 const { flags } = parseFlags(Deno.args); ··· 186 204 187 205 const startVirtualMachineAttached = (name: string, vm: VirtualMachine) => { 188 206 return pipe( 189 - createVolumeIfNeeded(vm), 207 + failIfVMRunning(vm), 208 + Effect.flatMap(() => createVolumeIfNeeded(vm)), 190 209 Effect.flatMap((volume) => 191 210 Effect.succeed( 192 211 buildQemuArgs({ ··· 243 262 const { flags } = parseFlags(Deno.args); 244 263 return { 245 264 ...vm, 246 - memory: flags.memory ? String(flags.memory) : vm.memory, 247 - cpus: flags.cpus ? Number(flags.cpus) : vm.cpus, 248 - cpu: flags.cpu ? String(flags.cpu) : vm.cpu, 265 + memory: (flags.memory || flags.m) 266 + ? String(flags.memory || flags.m) 267 + : vm.memory, 268 + cpus: (flags.cpus || flags.C) ? Number(flags.cpus || flags.C) : vm.cpus, 269 + cpu: (flags.cpu || flags.c) ? String(flags.cpu || flags.c) : vm.cpu, 249 270 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 250 - portForward: flags.portForward ? String(flags.portForward) : vm.portForward, 251 - drivePath: flags.image ? String(flags.image) : vm.drivePath, 252 - bridge: flags.bridge ? String(flags.bridge) : vm.bridge, 253 - diskSize: flags.size ? String(flags.size) : vm.diskSize, 271 + portForward: (flags.portForward || flags.p) 272 + ? String(flags.portForward || flags.p) 273 + : vm.portForward, 274 + drivePath: (flags.image || flags.i) 275 + ? String(flags.image || flags.i) 276 + : vm.drivePath, 277 + bridge: (flags.bridge || flags.b) 278 + ? String(flags.bridge || flags.b) 279 + : vm.bridge, 280 + diskSize: (flags.size || flags.s) 281 + ? String(flags.size || flags.s) 282 + : vm.diskSize, 254 283 }; 255 284 }
+7 -7
src/subcommands/stop.ts
··· 4 4 import type { VirtualMachine } from "../db.ts"; 5 5 import { getInstanceState, updateInstanceState } from "../state.ts"; 6 6 7 - class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 7 + export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 8 8 name: string; 9 9 }> {} 10 10 11 - class StopCommandError extends Data.TaggedError("StopCommandError")<{ 11 + export class StopCommandError extends Data.TaggedError("StopCommandError")<{ 12 12 vmName: string; 13 13 exitCode: number; 14 14 }> {} 15 15 16 - class CommandError extends Data.TaggedError("CommandError")<{ 16 + export class CommandError extends Data.TaggedError("CommandError")<{ 17 17 cause?: unknown; 18 18 }> {} 19 19 20 - const findVm = (name: string) => 20 + export const findVm = (name: string) => 21 21 pipe( 22 22 getInstanceState(name), 23 23 Effect.flatMap((vm) => ··· 34 34 ); 35 35 }); 36 36 37 - const killProcess = (vm: VirtualMachine) => 37 + export const killProcess = (vm: VirtualMachine) => 38 38 Effect.tryPromise({ 39 39 try: async () => { 40 40 const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { ··· 63 63 ), 64 64 ); 65 65 66 - const updateToStopped = (vm: VirtualMachine) => 66 + export const updateToStopped = (vm: VirtualMachine) => 67 67 pipe( 68 68 updateInstanceState(vm.name, "STOPPED"), 69 - Effect.map(() => vm), 69 + Effect.map(() => ({ ...vm, status: "STOPPED" } as VirtualMachine)), 70 70 ); 71 71 72 72 const logSuccess = (vm: VirtualMachine) =>
+35
src/types.ts
··· 1 + import z from "@zod/zod"; 2 + 1 3 export type STATUS = "RUNNING" | "STOPPED"; 4 + 5 + export const MachineParamsSchema = z.object({ 6 + portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), 7 + cpu: z.string().optional(), 8 + cpus: z.number().min(1).optional(), 9 + memory: z.string().regex(/^\d+(M|G)$/).optional(), 10 + }); 11 + 12 + export type MachineParams = z.infer<typeof MachineParamsSchema>; 13 + 14 + export const NewMachineSchema = MachineParamsSchema.extend({ 15 + portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), 16 + cpu: z.string().default("host").optional(), 17 + cpus: z.number().min(1).default(8).optional(), 18 + memory: z.string().regex(/^\d+(M|G)$/).default("2G").optional(), 19 + image: z.string().regex( 20 + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, 21 + ), 22 + volume: z.string().optional(), 23 + bridge: z.string().optional(), 24 + }); 25 + 26 + export type NewMachine = z.infer<typeof NewMachineSchema>; 27 + 28 + export const NewVolumeSchema = z.object({ 29 + name: z.string(), 30 + baseImage: z.string().regex( 31 + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, 32 + ), 33 + size: z.string().regex(/^\d+(M|G|T)$/).optional(), 34 + }); 35 + 36 + export type NewVolume = z.infer<typeof NewVolumeSchema>;
+25 -24
src/volumes.ts
··· 74 74 export const createVolume = ( 75 75 name: string, 76 76 baseImage: Image, 77 + size?: string, 77 78 ): Effect.Effect<Volume, VolumeError, never> => 78 79 Effect.tryPromise({ 79 80 try: async () => { 80 81 const path = `${VOLUME_DIR}/${name}.qcow2`; 81 82 82 - if ((await Deno.stat(path).catch(() => false))) { 83 - throw new Error(`Volume with name ${name} already exists`); 83 + if (!(await Deno.stat(path).catch(() => false))) { 84 + await Deno.mkdir(VOLUME_DIR, { recursive: true }); 85 + const qemu = new Deno.Command("qemu-img", { 86 + args: [ 87 + "create", 88 + "-F", 89 + "raw", 90 + "-f", 91 + "qcow2", 92 + "-b", 93 + baseImage.path, 94 + path, 95 + ...(size ? [size] : []), 96 + ], 97 + stdout: "inherit", 98 + stderr: "inherit", 99 + }) 100 + .spawn(); 101 + const status = await qemu.status; 102 + if (!status.success) { 103 + throw new Error( 104 + `Failed to create volume: qemu-img exited with code ${status.code}`, 105 + ); 106 + } 84 107 } 85 108 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 109 ctx.db.insertInto("volumes").values({ 109 110 id: createId(), 110 111 name,