A convenient CLI tool to quickly spin up DragonflyBSD virtual machines using QEMU with sensible defaults.
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, LOGS_DIR } from "../mod.ts";
13import {
14 listInstances,
15 removeInstanceState,
16 saveInstanceState,
17 updateInstanceState,
18} from "../state.ts";
19import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts";
20import {
21 buildDetachedCommand,
22 buildQemuArgs,
23 createLogsDir,
24 failIfVMRunning,
25 startDetachedQemu,
26} from "../subcommands/start.ts";
27import type { NewMachine } from "../types.ts";
28import { createId } from "@paralleldrive/cuid2";
29import { generateRandomMacAddress } from "../network.ts";
30import Moniker from "moniker";
31import { getImage } from "../images.ts";
32
33export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{
34 id: string;
35}> {}
36
37export class RemoveRunningVmError extends Data.TaggedError(
38 "RemoveRunningVmError",
39)<{
40 id: string;
41}> {}
42
43const app = new Hono();
44
45app.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
58app.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
105app.get("/:id", (c) =>
106 Effect.runPromise(
107 pipe(
108 parseParams(c),
109 Effect.flatMap(({ id }) => getInstanceState(id)),
110 presentation(c),
111 ),
112 ));
113
114app.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
135app.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
169app.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
181app.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
219export default app;