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

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request #7 from tsirysndr/feat/http-api

feat: Implement HTTP API with image and machine management

authored by

Tsiry Sandratraina and committed by
GitHub
526b0e93 c23f453f

+672 -45
+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 ]
+6
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 + .option("-p, --port <port:number>", "Port to listen on", { default: 8890 }) 404 + .action(() => { 405 + serve(); 406 + }) 401 407 .parse(Deno.args); 402 408 }
+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;
+207
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 } from "../mod.ts"; 13 + import { 14 + listInstances, 15 + removeInstanceState, 16 + saveInstanceState, 17 + } from "../state.ts"; 18 + import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts"; 19 + import { 20 + buildQemuArgs, 21 + createLogsDir, 22 + failIfVMRunning, 23 + setupFirmware, 24 + startDetachedQemu, 25 + } from "../subcommands/start.ts"; 26 + import type { NewMachine } from "../types.ts"; 27 + import { createId } from "@paralleldrive/cuid2"; 28 + import { generateRandomMacAddress } from "../network.ts"; 29 + import Moniker from "moniker"; 30 + import { getImage } from "../images.ts"; 31 + 32 + export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{ 33 + id: string; 34 + }> {} 35 + 36 + export class RemoveRunningVmError extends Data.TaggedError( 37 + "RemoveRunningVmError", 38 + )<{ 39 + id: string; 40 + }> {} 41 + 42 + const app = new Hono(); 43 + 44 + app.get("/", (c) => 45 + Effect.runPromise( 46 + pipe( 47 + parseQueryParams(c), 48 + Effect.flatMap((params) => 49 + listInstances( 50 + params.all === "true" || params.all === "1", 51 + ) 52 + ), 53 + presentation(c), 54 + ), 55 + )); 56 + 57 + app.post("/", (c) => 58 + Effect.runPromise( 59 + pipe( 60 + parseCreateMachineRequest(c), 61 + Effect.flatMap((params: NewMachine) => 62 + Effect.gen(function* () { 63 + const image = yield* getImage(params.image); 64 + if (!image) { 65 + return yield* Effect.fail( 66 + new ImageNotFoundError({ id: params.image }), 67 + ); 68 + } 69 + 70 + const volume = params.volume 71 + ? yield* createVolumeIfNeeded(image, params.volume) 72 + : undefined; 73 + 74 + const macAddress = yield* generateRandomMacAddress(); 75 + const id = createId(); 76 + yield* saveInstanceState({ 77 + id, 78 + name: Moniker.choose(), 79 + bridge: params.bridge, 80 + macAddress, 81 + memory: params.memory || "2G", 82 + cpus: params.cpus || 8, 83 + cpu: params.cpu || "host", 84 + diskSize: "20G", 85 + diskFormat: volume ? "qcow2" : "raw", 86 + portForward: params.portForward 87 + ? params.portForward.join(",") 88 + : undefined, 89 + drivePath: volume ? volume.path : image.path, 90 + version: image.tag ?? DEFAULT_VERSION, 91 + status: "STOPPED", 92 + pid: 0, 93 + }); 94 + 95 + const createdVm = yield* findVm(id); 96 + return createdVm; 97 + }) 98 + ), 99 + presentation(c), 100 + Effect.catchAll((error) => handleError(error, c)), 101 + ), 102 + )); 103 + 104 + app.get("/:id", (c) => 105 + Effect.runPromise( 106 + pipe( 107 + parseParams(c), 108 + Effect.flatMap(({ id }) => getInstanceState(id)), 109 + presentation(c), 110 + ), 111 + )); 112 + 113 + app.delete("/:id", (c) => 114 + Effect.runPromise( 115 + pipe( 116 + parseParams(c), 117 + Effect.flatMap(({ id }) => findVm(id)), 118 + Effect.flatMap((vm) => 119 + vm.status === "RUNNING" 120 + ? Effect.fail(new RemoveRunningVmError({ id: vm.id })) 121 + : Effect.succeed(vm) 122 + ), 123 + Effect.flatMap((vm) => 124 + Effect.gen(function* () { 125 + yield* removeInstanceState(vm.id); 126 + return vm; 127 + }) 128 + ), 129 + presentation(c), 130 + Effect.catchAll((error) => handleError(error, c)), 131 + ), 132 + )); 133 + 134 + app.post("/:id/start", (c) => 135 + Effect.runPromise( 136 + pipe( 137 + Effect.all([parseParams(c), parseStartRequest(c)]), 138 + Effect.flatMap(( 139 + [{ id }, startRequest], 140 + ) => Effect.all([findVm(id), Effect.succeed(startRequest)])), 141 + Effect.flatMap(([vm, startRequest]) => 142 + Effect.gen(function* () { 143 + yield* failIfVMRunning(vm); 144 + const firmwareArgs = yield* setupFirmware(); 145 + const qemuArgs = yield* buildQemuArgs({ 146 + ...vm, 147 + cpu: String(startRequest.cpus ?? vm.cpu), 148 + memory: startRequest.memory ?? vm.memory, 149 + portForward: startRequest.portForward 150 + ? startRequest.portForward.join(",") 151 + : vm.portForward, 152 + }, firmwareArgs); 153 + yield* createLogsDir(); 154 + yield* startDetachedQemu(vm.id, vm, qemuArgs); 155 + return { ...vm, status: "RUNNING" }; 156 + }) 157 + ), 158 + presentation(c), 159 + Effect.catchAll((error) => handleError(error, c)), 160 + ), 161 + )); 162 + 163 + app.post("/:id/stop", (c) => 164 + Effect.runPromise( 165 + pipe( 166 + parseParams(c), 167 + Effect.flatMap(({ id }) => findVm(id)), 168 + Effect.flatMap(killProcess), 169 + Effect.flatMap(updateToStopped), 170 + presentation(c), 171 + Effect.catchAll((error) => handleError(error, c)), 172 + ), 173 + )); 174 + 175 + app.post("/:id/restart", (c) => 176 + Effect.runPromise( 177 + pipe( 178 + parseParams(c), 179 + Effect.flatMap(({ id }) => findVm(id)), 180 + Effect.flatMap(killProcess), 181 + Effect.flatMap(updateToStopped), 182 + Effect.flatMap(() => Effect.all([parseParams(c), parseStartRequest(c)])), 183 + Effect.flatMap(( 184 + [{ id }, startRequest], 185 + ) => Effect.all([findVm(id), Effect.succeed(startRequest)])), 186 + Effect.flatMap(([vm, startRequest]) => 187 + Effect.gen(function* () { 188 + const firmwareArgs = yield* setupFirmware(); 189 + const qemuArgs = yield* buildQemuArgs({ 190 + ...vm, 191 + cpu: String(startRequest.cpus ?? vm.cpu), 192 + memory: startRequest.memory ?? vm.memory, 193 + portForward: startRequest.portForward 194 + ? startRequest.portForward.join(",") 195 + : vm.portForward, 196 + }, firmwareArgs); 197 + yield* createLogsDir(); 198 + yield* startDetachedQemu(vm.id, vm, qemuArgs); 199 + return { ...vm, status: "RUNNING" }; 200 + }) 201 + ), 202 + presentation(c), 203 + Effect.catchAll((error) => handleError(error, c)), 204 + ), 205 + )); 206 + 207 + export default app;
+44
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 default function () { 11 + const token = Deno.env.get("FREEBSD_UP_API_TOKEN") || 12 + crypto.randomUUID(); 13 + const { flags } = parseFlags(Deno.args); 14 + 15 + if (!Deno.env.get("FREEBSD_UP_API_TOKEN")) { 16 + console.log(`Using API token: ${token}`); 17 + } else { 18 + console.log( 19 + `Using provided API token from environment variable FREEBSD_UP_API_TOKEN`, 20 + ); 21 + } 22 + 23 + const app = new Hono(); 24 + 25 + app.use(logger()); 26 + app.use(cors()); 27 + 28 + app.use("/images/*", bearerAuth({ token })); 29 + app.use("/machines/*", bearerAuth({ token })); 30 + app.use("/volumes/*", bearerAuth({ token })); 31 + 32 + app.route("/images", images); 33 + app.route("/machines", machines); 34 + app.route("/volumes", volumes); 35 + 36 + const port = Number( 37 + flags.port || flags.p || 38 + (Deno.env.get("FREEBSD_UP_PORT") 39 + ? Number(Deno.env.get("FREEBSD_UP_PORT")) 40 + : 8890), 41 + ); 42 + 43 + Deno.serve({ port }, app.fetch); 44 + }
+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;
+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 + }
+39 -10
src/subcommands/start.ts
··· 8 8 import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; 9 9 import { createVolume, getVolume } from "../volumes.ts"; 10 10 11 - class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 11 + export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 12 12 name: string; 13 13 }> {} 14 14 15 - class CommandError extends Data.TaggedError("CommandError")<{ 15 + export class VmAlreadyRunningError 16 + extends Data.TaggedError("VmAlreadyRunningError")<{ 17 + name: string; 18 + }> {} 19 + 20 + export class CommandError extends Data.TaggedError("CommandError")<{ 16 21 cause?: unknown; 17 22 }> {} 18 23 ··· 31 36 32 37 const applyFlags = (vm: VirtualMachine) => Effect.succeed(mergeFlags(vm)); 33 38 34 - const setupFirmware = () => setupFirmwareFilesIfNeeded(); 39 + export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 35 40 36 - const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 41 + export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 37 42 const qemu = Deno.build.arch === "aarch64" 38 43 ? "qemu-system-aarch64" 39 44 : "qemu-system-x86_64"; ··· 72 77 ]); 73 78 }; 74 79 75 - const createLogsDir = () => 80 + export const createLogsDir = () => 76 81 Effect.tryPromise({ 77 82 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 78 83 catch: (error) => new CommandError({ cause: error }), 79 84 }); 80 85 81 - const startDetachedQemu = ( 86 + export const startDetachedQemu = ( 82 87 name: string, 83 88 vm: VirtualMachine, 84 89 qemuArgs: string[], ··· 99 104 try: async () => { 100 105 const cmd = new Deno.Command("sh", { 101 106 args: ["-c", fullCommand], 102 - stdin: "null", 107 + stdin: "piped", 103 108 stdout: "piped", 104 - }); 109 + }) 110 + .spawn(); 111 + 112 + // Wait 2 seconds and send "1" to boot normally 113 + setTimeout(async () => { 114 + try { 115 + const writer = cmd.stdin.getWriter(); 116 + await writer.write(new TextEncoder().encode("1\n")); 117 + await writer.close(); 118 + } catch { 119 + // Ignore errors if stdin is already closed 120 + } 121 + }, 2000); 105 122 106 - const { stdout } = await cmd.spawn().output(); 123 + const { stdout } = await cmd.output(); 107 124 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 108 125 return { qemuPid, logPath }; 109 126 }, ··· 176 193 Deno.exit(1); 177 194 }); 178 195 179 - const createVolumeIfNeeded = ( 196 + export const createVolumeIfNeeded = ( 180 197 vm: VirtualMachine, 181 198 ): Effect.Effect<[VirtualMachine, Volume?], Error, never> => 182 199 Effect.gen(function* () { ··· 208 225 return [vm, newVolume]; 209 226 }); 210 227 228 + export const failIfVMRunning = (vm: VirtualMachine) => 229 + Effect.gen(function* () { 230 + if (vm.status === "RUNNING") { 231 + return yield* Effect.fail( 232 + new VmAlreadyRunningError({ name: vm.name }), 233 + ); 234 + } 235 + return vm; 236 + }); 237 + 211 238 const startDetachedEffect = (name: string) => 212 239 pipe( 213 240 findVm(name), 241 + Effect.flatMap(failIfVMRunning), 214 242 Effect.tap(logStarting), 215 243 Effect.flatMap(applyFlags), 216 244 Effect.flatMap(createVolumeIfNeeded), ··· 240 268 const startInteractiveEffect = (name: string) => 241 269 pipe( 242 270 findVm(name), 271 + Effect.flatMap(failIfVMRunning), 243 272 Effect.tap(logStarting), 244 273 Effect.flatMap(applyFlags), 245 274 Effect.flatMap(createVolumeIfNeeded),
+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 });
+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,