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

feat: implement HTTP API with image and machine management, including CRUD operations and error handling

+375 -16
+3 -1
.gitignore
··· 1 1 *.iso 2 2 *.img 3 - vmconfig.toml 3 + vmconfig.toml 4 + .env 5 + *.fd
+3 -1
deno.json
··· 4 4 "exports": "./main.ts", 5 5 "license": "MPL-2.0", 6 6 "tasks": { 7 - "dev": "deno run --watch main.ts" 7 + "dev": "deno run --env-file=.env -A --watch main.ts" 8 8 }, 9 9 "imports": { 10 10 "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", ··· 13 13 "@cliffy/table": "jsr:@cliffy/table@^1.0.0-rc.8", 14 14 "@db/sqlite": "jsr:@db/sqlite@^0.12.0", 15 15 "@es-toolkit/es-toolkit": "jsr:@es-toolkit/es-toolkit@^1.41.0", 16 + "@hono/swagger-ui": "npm:@hono/swagger-ui@^0.5.2", 16 17 "@paralleldrive/cuid2": "npm:@paralleldrive/cuid2@^3.0.4", 17 18 "@soapbox/kysely-deno-sqlite": "jsr:@soapbox/kysely-deno-sqlite@^2.2.0", 18 19 "@std/assert": "jsr:@std/assert@1", ··· 23 24 "chalk": "npm:chalk@^5.6.2", 24 25 "dayjs": "npm:dayjs@^1.11.19", 25 26 "effect": "npm:effect@^3.19.2", 27 + "hono": "npm:hono@^4.10.6", 26 28 "kysely": "npm:kysely@0.27.6", 27 29 "moniker": "npm:moniker@^0.1.2" 28 30 }
+13
deno.lock
··· 38 38 "jsr:@std/toml@^1.0.11": "1.0.11", 39 39 "jsr:@zod/zod@*": "4.1.12", 40 40 "jsr:@zod/zod@^4.1.12": "4.1.12", 41 + "npm:@hono/swagger-ui@~0.5.2": "0.5.2_hono@4.10.6", 41 42 "npm:@paralleldrive/cuid2@^3.0.4": "3.0.4", 42 43 "npm:@types/node@*": "24.2.0", 43 44 "npm:chalk@^5.6.2": "5.6.2", 44 45 "npm:dayjs@^1.11.19": "1.11.19", 45 46 "npm:effect@^3.19.2": "3.19.2", 47 + "npm:hono@^4.10.6": "4.10.6", 46 48 "npm:kysely@0.27.6": "0.27.6", 47 49 "npm:kysely@~0.27.2": "0.27.6", 48 50 "npm:moniker@~0.1.2": "0.1.2" ··· 189 191 } 190 192 }, 191 193 "npm": { 194 + "@hono/swagger-ui@0.5.2_hono@4.10.6": { 195 + "integrity": "sha512-7wxLKdb8h7JTdZ+K8DJNE3KXQMIpJejkBTQjrYlUWF28Z1PGOKw6kUykARe5NTfueIN37jbyG/sBYsbzXzG53A==", 196 + "dependencies": [ 197 + "hono" 198 + ] 199 + }, 192 200 "@noble/hashes@2.0.1": { 193 201 "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==" 194 202 }, ··· 235 243 "pure-rand" 236 244 ] 237 245 }, 246 + "hono@4.10.6": { 247 + "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==" 248 + }, 238 249 "kysely@0.27.6": { 239 250 "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" 240 251 }, ··· 262 273 "jsr:@std/path@^1.1.2", 263 274 "jsr:@std/toml@^1.0.11", 264 275 "jsr:@zod/zod@^4.1.12", 276 + "npm:@hono/swagger-ui@~0.5.2", 265 277 "npm:@paralleldrive/cuid2@^3.0.4", 266 278 "npm:chalk@^5.6.2", 267 279 "npm:dayjs@^1.11.19", 268 280 "npm:effect@^3.19.2", 281 + "npm:hono@^4.10.6", 269 282 "npm:kysely@0.27.6", 270 283 "npm:moniker@~0.1.2" 271 284 ]
+5
main.ts
··· 36 36 type Options, 37 37 runQemu, 38 38 } from "./src/utils.ts"; 39 + import serve from "./src/subcommands/serve.ts"; 39 40 40 41 export * from "./src/mod.ts"; 41 42 ··· 398 399 }), 399 400 ) 400 401 .description("Manage volumes") 402 + .command("serve", "Start the FreeBSD-Up HTTP API server") 403 + .action(() => { 404 + serve(); 405 + }) 401 406 .parse(Deno.args); 402 407 }
+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;
+184
src/api/machines.ts
··· 1 + import { type Context, Hono } from "hono"; 2 + import { Data, Effect, pipe } from "effect"; 3 + import { parseParams, parseQueryParams, presentation } from "./utils.ts"; 4 + import { getInstanceState } from "../mod.ts"; 5 + import { listInstances } from "../state.ts"; 6 + import { 7 + type CommandError, 8 + findVm, 9 + killProcess, 10 + StopCommandError, 11 + updateToStopped, 12 + VmNotFoundError, 13 + } from "../subcommands/stop.ts"; 14 + import { 15 + buildQemuArgs, 16 + createLogsDir, 17 + setupFirmware, 18 + startDetachedQemu, 19 + } from "../subcommands/start.ts"; 20 + import { MachineParamsSchema } from "../types.ts"; 21 + 22 + class ParseRequestError extends Data.TaggedError("ParseRequestError")<{ 23 + cause?: unknown; 24 + message: string; 25 + }> {} 26 + 27 + export const handleError = ( 28 + error: 29 + | VmNotFoundError 30 + | StopCommandError 31 + | CommandError 32 + | ParseRequestError 33 + | Error, 34 + c: Context, 35 + ) => 36 + Effect.sync(() => { 37 + if (error instanceof VmNotFoundError) { 38 + return c.json( 39 + { message: "VM not found", code: "VM_NOT_FOUND" }, 40 + 404, 41 + ); 42 + } 43 + if (error instanceof StopCommandError) { 44 + return c.json( 45 + { 46 + message: error.message || 47 + `Failed to stop VM ${error.vmName}`, 48 + code: "STOP_COMMAND_ERROR", 49 + }, 50 + 500, 51 + ); 52 + } 53 + 54 + if (error instanceof ParseRequestError) { 55 + return c.json( 56 + { 57 + message: error.message || "Failed to parse request body", 58 + code: "PARSE_BODY_ERROR", 59 + }, 60 + 400, 61 + ); 62 + } 63 + 64 + return c.json( 65 + { message: error instanceof Error ? error.message : String(error) }, 66 + 500, 67 + ); 68 + }); 69 + 70 + const parseStartRequest = (c: Context) => 71 + Effect.tryPromise({ 72 + try: async () => { 73 + const body = await c.req.json(); 74 + return MachineParamsSchema.parse(body); 75 + }, 76 + catch: (error) => 77 + new ParseRequestError({ 78 + cause: error, 79 + message: error instanceof Error ? error.message : String(error), 80 + }), 81 + }); 82 + 83 + const app = new Hono(); 84 + 85 + app.get("/", (c) => 86 + Effect.runPromise( 87 + pipe( 88 + parseQueryParams(c), 89 + Effect.flatMap((params) => listInstances(!!params.all)), 90 + presentation(c), 91 + ), 92 + )); 93 + 94 + app.post("/", (c) => { 95 + return c.json({ message: "New machine created" }); 96 + }); 97 + 98 + app.get("/:id", (c) => 99 + Effect.runPromise( 100 + pipe( 101 + parseParams(c), 102 + Effect.flatMap(({ id }) => getInstanceState(id)), 103 + presentation(c), 104 + ), 105 + )); 106 + 107 + app.delete("/:id", (c) => { 108 + const { id } = c.req.param(); 109 + return c.json({ message: `Machine with ID ${id} deleted` }); 110 + }); 111 + 112 + app.post("/:id/start", (c) => 113 + Effect.runPromise( 114 + pipe( 115 + Effect.all([parseParams(c), parseStartRequest(c)]), 116 + Effect.flatMap(( 117 + [{ id }, startRequest], 118 + ) => Effect.all([findVm(id), Effect.succeed(startRequest)])), 119 + Effect.flatMap(([vm, startRequest]) => 120 + Effect.gen(function* () { 121 + const firmwareArgs = yield* setupFirmware(); 122 + const qemuArgs = yield* buildQemuArgs({ 123 + ...vm, 124 + cpu: String(startRequest.cpus ?? vm.cpu), 125 + memory: startRequest.memory ?? vm.memory, 126 + portForward: startRequest.portForward 127 + ? startRequest.portForward.join(",") 128 + : vm.portForward, 129 + }, firmwareArgs); 130 + yield* createLogsDir(); 131 + yield* startDetachedQemu(vm.id, vm, qemuArgs); 132 + return { ...vm, status: "RUNNING" }; 133 + }) 134 + ), 135 + presentation(c), 136 + Effect.catchAll((error) => handleError(error, c)), 137 + ), 138 + )); 139 + 140 + app.post("/:id/stop", (c) => 141 + Effect.runPromise( 142 + pipe( 143 + parseParams(c), 144 + Effect.flatMap(({ id }) => findVm(id)), 145 + Effect.flatMap(killProcess), 146 + Effect.flatMap(updateToStopped), 147 + presentation(c), 148 + Effect.catchAll((error) => handleError(error, c)), 149 + ), 150 + )); 151 + 152 + app.post("/:id/restart", (c) => 153 + Effect.runPromise( 154 + pipe( 155 + parseParams(c), 156 + Effect.flatMap(({ id }) => findVm(id)), 157 + Effect.flatMap(killProcess), 158 + Effect.flatMap(updateToStopped), 159 + Effect.flatMap(() => Effect.all([parseParams(c), parseStartRequest(c)])), 160 + Effect.flatMap(( 161 + [{ id }, startRequest], 162 + ) => Effect.all([findVm(id), Effect.succeed(startRequest)])), 163 + Effect.flatMap(([vm, startRequest]) => 164 + Effect.gen(function* () { 165 + const firmwareArgs = yield* setupFirmware(); 166 + const qemuArgs = yield* buildQemuArgs({ 167 + ...vm, 168 + cpu: String(startRequest.cpus ?? vm.cpu), 169 + memory: startRequest.memory ?? vm.memory, 170 + portForward: startRequest.portForward 171 + ? startRequest.portForward.join(",") 172 + : vm.portForward, 173 + }, firmwareArgs); 174 + yield* createLogsDir(); 175 + yield* startDetachedQemu(vm.id, vm, qemuArgs); 176 + return { ...vm, status: "RUNNING" }; 177 + }) 178 + ), 179 + presentation(c), 180 + Effect.catchAll((error) => handleError(error, c)), 181 + ), 182 + )); 183 + 184 + export default app;
+39
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 + 9 + export default function () { 10 + const token = Deno.env.get("FREEBSD_UP_API_TOKEN") || 11 + crypto.randomUUID(); 12 + 13 + if (!Deno.env.get("FREEBSD_UP_API_TOKEN")) { 14 + console.log(`Using API token: ${token}`); 15 + } else { 16 + console.log( 17 + `Using provided API token from environment variable FREEBSD_UP_API_TOKEN`, 18 + ); 19 + } 20 + 21 + const app = new Hono(); 22 + 23 + app.use(logger()); 24 + app.use(cors()); 25 + 26 + app.use("/images/*", bearerAuth({ token })); 27 + app.use("/machines/*", bearerAuth({ token })); 28 + app.use("/volumes/*", bearerAuth({ token })); 29 + 30 + app.route("/images", images); 31 + app.route("/machines", machines); 32 + app.route("/volumes", volumes); 33 + 34 + const port = Deno.env.get("FREEBSD_UP_PORT") 35 + ? Number(Deno.env.get("FREEBSD_UP_PORT")) 36 + : 8890; 37 + 38 + Deno.serve({ port }, app.fetch); 39 + }
+9
src/api/utils.ts
··· 1 + import { Effect } from "effect"; 2 + import type { Context } from "hono"; 3 + 4 + export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query()); 5 + 6 + export const parseParams = (c: Context) => Effect.succeed(c.req.param()); 7 + 8 + export const presentation = (c: Context) => 9 + Effect.flatMap((data) => Effect.succeed(c.json(data)));
+35
src/api/volumes.ts
··· 1 + import { Hono } from "hono"; 2 + import { Effect, pipe } from "effect"; 3 + import { parseParams, presentation } from "./utils.ts"; 4 + import { listVolumes } from "../mod.ts"; 5 + import { getVolume } from "../volumes.ts"; 6 + 7 + const app = new Hono(); 8 + 9 + app.get("/", (c) => 10 + Effect.runPromise( 11 + pipe( 12 + listVolumes(), 13 + presentation(c), 14 + ), 15 + )); 16 + 17 + app.get("/:id", (c) => 18 + Effect.runPromise( 19 + pipe( 20 + parseParams(c), 21 + Effect.flatMap(({ id }) => getVolume(id)), 22 + presentation(c), 23 + ), 24 + )); 25 + 26 + app.post("/", (c) => { 27 + return c.json({ message: "New volume created" }); 28 + }); 29 + 30 + app.delete("/:id", (c) => { 31 + const { id } = c.req.param(); 32 + return c.json({ message: `Volume with ID ${id} deleted` }); 33 + }); 34 + 35 + export default app;
+17
src/state.ts
··· 73 73 .executeTakeFirst(), 74 74 catch: (error) => new DbError({ cause: error }), 75 75 }); 76 + 77 + export const listInstances = ( 78 + all: boolean, 79 + ): Effect.Effect<VirtualMachine[], DbError, never> => 80 + Effect.tryPromise({ 81 + try: () => 82 + ctx.db.selectFrom("virtual_machines") 83 + .selectAll() 84 + .where((eb) => { 85 + if (all) { 86 + return eb("id", "!=", ""); 87 + } 88 + return eb("status", "=", "RUNNING"); 89 + }) 90 + .execute(), 91 + catch: (error) => new DbError({ cause: error }), 92 + });
+5
src/subcommands/serve.ts
··· 1 + import api from "../api/mod.ts"; 2 + 3 + export default function () { 4 + api(); 5 + }
+5 -5
src/subcommands/start.ts
··· 31 31 32 32 const applyFlags = (vm: VirtualMachine) => Effect.succeed(mergeFlags(vm)); 33 33 34 - const setupFirmware = () => setupFirmwareFilesIfNeeded(); 34 + export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 35 35 36 - const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 36 + export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 37 37 const qemu = Deno.build.arch === "aarch64" 38 38 ? "qemu-system-aarch64" 39 39 : "qemu-system-x86_64"; ··· 72 72 ]); 73 73 }; 74 74 75 - const createLogsDir = () => 75 + export const createLogsDir = () => 76 76 Effect.tryPromise({ 77 77 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 78 78 catch: (error) => new CommandError({ cause: error }), 79 79 }); 80 80 81 - const startDetachedQemu = ( 81 + export const startDetachedQemu = ( 82 82 name: string, 83 83 vm: VirtualMachine, 84 84 qemuArgs: string[], ··· 176 176 Deno.exit(1); 177 177 }); 178 178 179 - const createVolumeIfNeeded = ( 179 + export const createVolumeIfNeeded = ( 180 180 vm: VirtualMachine, 181 181 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 182 182 Effect.gen(function* () {
+12 -9
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 + message?: string; 14 15 }> {} 15 16 16 - class CommandError extends Data.TaggedError("CommandError")<{ 17 + export class CommandError extends Data.TaggedError("CommandError")<{ 17 18 cause?: unknown; 18 19 }> {} 19 20 20 - const findVm = (name: string) => 21 + export const findVm = (name: string) => 21 22 pipe( 22 23 getInstanceState(name), 23 24 Effect.flatMap((vm) => ··· 25 26 ), 26 27 ); 27 28 28 - const logStopping = (vm: VirtualMachine) => 29 + export const logStopping = (vm: VirtualMachine) => 29 30 Effect.sync(() => { 30 31 console.log( 31 32 `Stopping virtual machine ${chalk.greenBright(vm.name)} (ID: ${ ··· 34 35 ); 35 36 }); 36 37 37 - const killProcess = (vm: VirtualMachine) => 38 + export const killProcess = (vm: VirtualMachine) => 38 39 Effect.tryPromise({ 39 40 try: async () => { 40 41 const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { ··· 58 59 new StopCommandError({ 59 60 vmName: vm.name, 60 61 exitCode: status.code || 1, 62 + message: 63 + `Failed to stop VM ${vm.name}, exited with code ${status.code}`, 61 64 }), 62 65 ) 63 66 ), 64 67 ); 65 68 66 - const updateToStopped = (vm: VirtualMachine) => 69 + export const updateToStopped = (vm: VirtualMachine) => 67 70 pipe( 68 71 updateInstanceState(vm.name, "STOPPED"), 69 - Effect.map(() => vm), 72 + Effect.map(() => ({ ...vm, status: "STOPPED" } as VirtualMachine)), 70 73 ); 71 74 72 - const logSuccess = (vm: VirtualMachine) => 75 + export const logSuccess = (vm: VirtualMachine) => 73 76 Effect.sync(() => { 74 77 console.log(`Virtual machine ${chalk.greenBright(vm.name)} stopped.`); 75 78 });
+11
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>;