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

feat: enhance HTTP API with machine and volume management, including error handling and request parsing

+270 -47
+90 -10
src/api/machines.ts
··· 1 1 import { Hono } from "hono"; 2 - import { Effect, pipe } from "effect"; 2 + import { Data, Effect, pipe } from "effect"; 3 3 import { 4 + createVolumeIfNeeded, 4 5 handleError, 6 + parseCreateMachineRequest, 5 7 parseParams, 6 8 parseQueryParams, 7 9 parseStartRequest, 8 10 presentation, 9 11 } from "./utils.ts"; 10 - import { getInstanceState } from "../mod.ts"; 11 - import { listInstances } from "../state.ts"; 12 + import { DEFAULT_VERSION, getInstanceState } from "../mod.ts"; 13 + import { 14 + listInstances, 15 + removeInstanceState, 16 + saveInstanceState, 17 + } from "../state.ts"; 12 18 import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts"; 13 19 import { 14 20 buildQemuArgs, ··· 17 23 setupFirmware, 18 24 startDetachedQemu, 19 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 + }> {} 20 41 21 42 const app = new Hono(); 22 43 ··· 29 50 ), 30 51 )); 31 52 32 - app.post("/", (c) => { 33 - return c.json({ message: "New machine created" }); 34 - }); 53 + app.post("/", (c) => 54 + Effect.runPromise( 55 + pipe( 56 + parseCreateMachineRequest(c), 57 + Effect.flatMap((params: NewMachine) => 58 + Effect.gen(function* () { 59 + const image = yield* getImage(params.image); 60 + if (!image) { 61 + return yield* Effect.fail( 62 + new ImageNotFoundError({ id: params.image }), 63 + ); 64 + } 65 + 66 + const volume = params.volume 67 + ? yield* createVolumeIfNeeded(image, params.volume) 68 + : undefined; 69 + 70 + const macAddress = yield* generateRandomMacAddress(); 71 + const id = createId(); 72 + yield* saveInstanceState({ 73 + id, 74 + name: Moniker.choose(), 75 + bridge: params.bridge, 76 + macAddress, 77 + memory: params.memory || "2G", 78 + cpus: params.cpus || 8, 79 + cpu: params.cpu || "host", 80 + diskSize: "20G", 81 + diskFormat: volume ? "qcow2" : "raw", 82 + portForward: params.portForward 83 + ? params.portForward.join(",") 84 + : undefined, 85 + drivePath: volume ? volume.path : image.path, 86 + version: image.tag ?? DEFAULT_VERSION, 87 + status: "STOPPED", 88 + pid: 0, 89 + }); 90 + 91 + const createdVm = yield* findVm(id); 92 + return createdVm; 93 + }) 94 + ), 95 + presentation(c), 96 + Effect.catchAll((error) => handleError(error, c)), 97 + ), 98 + )); 35 99 36 100 app.get("/:id", (c) => 37 101 Effect.runPromise( ··· 42 106 ), 43 107 )); 44 108 45 - app.delete("/:id", (c) => { 46 - const { id } = c.req.param(); 47 - return c.json({ message: `Machine with ID ${id} deleted` }); 48 - }); 109 + app.delete("/:id", (c) => 110 + Effect.runPromise( 111 + pipe( 112 + parseParams(c), 113 + Effect.flatMap(({ id }) => findVm(id)), 114 + Effect.flatMap((vm) => 115 + vm.status === "RUNNING" 116 + ? Effect.fail(new RemoveRunningVmError({ id: vm.id })) 117 + : Effect.succeed(vm) 118 + ), 119 + Effect.flatMap((vm) => 120 + Effect.gen(function* () { 121 + yield* removeInstanceState(vm.id); 122 + return vm; 123 + }) 124 + ), 125 + presentation(c), 126 + Effect.catchAll((error) => handleError(error, c)), 127 + ), 128 + )); 49 129 50 130 app.post("/:id/start", (c) => 51 131 Effect.runPromise(
+71 -1
src/api/utils.ts
··· 6 6 VmNotFoundError, 7 7 } from "../subcommands/stop.ts"; 8 8 import { VmAlreadyRunningError } from "../subcommands/start.ts"; 9 - import { MachineParamsSchema } from "../types.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"; 10 17 11 18 export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query()); 12 19 ··· 27 34 | CommandError 28 35 | ParseRequestError 29 36 | VmAlreadyRunningError 37 + | ImageNotFoundError 38 + | RemoveRunningVmError 30 39 | Error, 31 40 c: Context, 32 41 ) => ··· 68 77 ); 69 78 } 70 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 + 71 101 return c.json( 72 102 { message: error instanceof Error ? error.message : String(error) }, 73 103 500, ··· 86 116 message: error instanceof Error ? error.message : String(error), 87 117 }), 88 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 + });
+45 -9
src/api/volumes.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { Effect, pipe } from "effect"; 3 - import { parseParams, presentation } from "./utils.ts"; 3 + import { 4 + createVolumeIfNeeded, 5 + handleError, 6 + parseCreateVolumeRequest, 7 + parseParams, 8 + presentation, 9 + } from "./utils.ts"; 4 10 import { listVolumes } from "../mod.ts"; 5 - import { getVolume } from "../volumes.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"; 6 15 7 16 const app = new Hono(); 8 17 ··· 23 32 ), 24 33 )); 25 34 26 - app.post("/", (c) => { 27 - return c.json({ message: "New volume created" }); 28 - }); 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 + )); 29 49 30 - app.delete("/:id", (c) => { 31 - const { id } = c.req.param(); 32 - return c.json({ message: `Volume with ID ${id} deleted` }); 33 - }); 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 + )); 34 70 35 71 export default app;
+15 -3
src/subcommands/start.ts
··· 104 104 try: async () => { 105 105 const cmd = new Deno.Command("sh", { 106 106 args: ["-c", fullCommand], 107 - stdin: "null", 107 + stdin: "piped", 108 108 stdout: "piped", 109 - }); 109 + }) 110 + .spawn(); 110 111 111 - const { stdout } = await cmd.spawn().output(); 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); 122 + 123 + const { stdout } = await cmd.output(); 112 124 const qemuPid = parseInt(new TextDecoder().decode(stdout).trim(), 10); 113 125 return { qemuPid, logPath }; 114 126 },
+24
src/types.ts
··· 10 10 }); 11 11 12 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,