A simple, zero-configuration script to quickly boot FreeBSD ISO images using QEMU
1import { Hono } from "hono";
2import { Data, Effect, pipe } from "effect";
3import {
4 createVolumeIfNeeded,
5 handleError,
6 parseCreateMachineRequest,
7 parseParams,
8 parseQueryParams,
9 parseStartRequest,
10 presentation,
11} from "./utils.ts";
12import { DEFAULT_VERSION, getInstanceState } from "../mod.ts";
13import {
14 listInstances,
15 removeInstanceState,
16 saveInstanceState,
17} from "../state.ts";
18import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts";
19import {
20 buildQemuArgs,
21 createLogsDir,
22 failIfVMRunning,
23 setupFirmware,
24 startDetachedQemu,
25} from "../subcommands/start.ts";
26import type { NewMachine } from "../types.ts";
27import { createId } from "@paralleldrive/cuid2";
28import { generateRandomMacAddress } from "../network.ts";
29import Moniker from "moniker";
30import { getImage } from "../images.ts";
31
32export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{
33 id: string;
34}> {}
35
36export class RemoveRunningVmError extends Data.TaggedError(
37 "RemoveRunningVmError",
38)<{
39 id: string;
40}> {}
41
42const app = new Hono();
43
44app.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
57app.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
104app.get("/:id", (c) =>
105 Effect.runPromise(
106 pipe(
107 parseParams(c),
108 Effect.flatMap(({ id }) => getInstanceState(id)),
109 presentation(c),
110 ),
111 ));
112
113app.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
134app.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.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 }, firmwareArgs);
154 yield* createLogsDir();
155 yield* startDetachedQemu(vm.id, vm, qemuArgs);
156 return { ...vm, status: "RUNNING" };
157 })
158 ),
159 presentation(c),
160 Effect.catchAll((error) => handleError(error, c)),
161 ),
162 ));
163
164app.post("/:id/stop", (c) =>
165 Effect.runPromise(
166 pipe(
167 parseParams(c),
168 Effect.flatMap(({ id }) => findVm(id)),
169 Effect.flatMap(killProcess),
170 Effect.flatMap(updateToStopped),
171 presentation(c),
172 Effect.catchAll((error) => handleError(error, c)),
173 ),
174 ));
175
176app.post("/:id/restart", (c) =>
177 Effect.runPromise(
178 pipe(
179 parseParams(c),
180 Effect.flatMap(({ id }) => findVm(id)),
181 Effect.flatMap(killProcess),
182 Effect.flatMap(updateToStopped),
183 Effect.flatMap(() => Effect.all([parseParams(c), parseStartRequest(c)])),
184 Effect.flatMap((
185 [{ id }, startRequest],
186 ) => Effect.all([findVm(id), Effect.succeed(startRequest)])),
187 Effect.flatMap(([vm, startRequest]) =>
188 Effect.gen(function* () {
189 const firmwareArgs = yield* setupFirmware();
190 const qemuArgs = yield* buildQemuArgs({
191 ...vm,
192 cpu: String(startRequest.cpus ?? vm.cpu),
193 memory: startRequest.memory ?? vm.memory,
194 portForward: startRequest.portForward
195 ? startRequest.portForward.join(",")
196 : vm.portForward,
197 }, firmwareArgs);
198 yield* createLogsDir();
199 yield* startDetachedQemu(vm.id, vm, qemuArgs);
200 return { ...vm, status: "RUNNING" };
201 })
202 ),
203 presentation(c),
204 Effect.catchAll((error) => handleError(error, c)),
205 ),
206 ));
207
208export default app;