A Docker-like CLI and HTTP API for managing headless VMs
1import { Effect } from "effect";
2import type { Context } from "hono";
3import type { Image, Volume } from "../db.ts";
4import {
5 type CommandError,
6 ImageNotFoundError,
7 ParseRequestError,
8 RemoveRunningVmError,
9 StopCommandError,
10 VmAlreadyRunningError,
11 VmNotFoundError,
12} from "../errors.ts";
13import {
14 MachineParamsSchema,
15 NewImageSchema,
16 NewMachineSchema,
17 NewVolumeSchema,
18} from "../types.ts";
19import { createVolume, getVolume } from "../volumes.ts";
20import type { FileSystemError, XorrisoError } from "../xorriso.ts";
21
22export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query());
23
24export const parseParams = (c: Context) => Effect.succeed(c.req.param());
25
26const convertBigIntToNumber = (obj: unknown): unknown => {
27 if (typeof obj === "bigint") {
28 return Number(obj);
29 }
30 if (Array.isArray(obj)) {
31 return obj.map(convertBigIntToNumber);
32 }
33 if (obj !== null && typeof obj === "object") {
34 return Object.fromEntries(
35 Object.entries(obj).map(([key, value]) => [
36 key,
37 convertBigIntToNumber(value),
38 ]),
39 );
40 }
41 return obj;
42};
43
44export const presentation = (c: Context) =>
45 Effect.flatMap((data) => Effect.succeed(c.json(convertBigIntToNumber(data))));
46
47export const handleError = (
48 error:
49 | VmNotFoundError
50 | StopCommandError
51 | CommandError
52 | ParseRequestError
53 | VmAlreadyRunningError
54 | ImageNotFoundError
55 | RemoveRunningVmError
56 | FileSystemError
57 | XorrisoError
58 | Error,
59 c: Context,
60) =>
61 Effect.sync(() => {
62 if (error instanceof VmNotFoundError) {
63 return c.json({ message: "VM not found", code: "VM_NOT_FOUND" }, 404);
64 }
65 if (error instanceof StopCommandError) {
66 return c.json(
67 {
68 message: error.message || `Failed to stop VM ${error.vmName}`,
69 code: "STOP_COMMAND_ERROR",
70 },
71 500,
72 );
73 }
74
75 if (error instanceof ParseRequestError) {
76 return c.json(
77 {
78 message: error.message || "Failed to parse request body",
79 code: "PARSE_BODY_ERROR",
80 },
81 400,
82 );
83 }
84
85 if (error instanceof VmAlreadyRunningError) {
86 return c.json(
87 {
88 message: `VM ${error.name} is already running`,
89 code: "VM_ALREADY_RUNNING",
90 },
91 400,
92 );
93 }
94
95 if (error instanceof ImageNotFoundError) {
96 return c.json(
97 {
98 message: `Image ${error.id} not found`,
99 code: "IMAGE_NOT_FOUND",
100 },
101 404,
102 );
103 }
104
105 if (error instanceof RemoveRunningVmError) {
106 return c.json(
107 {
108 message:
109 `Cannot remove running VM with ID ${error.id}. Please stop it first.`,
110 code: "REMOVE_RUNNING_VM_ERROR",
111 },
112 400,
113 );
114 }
115
116 return c.json(
117 { message: error instanceof Error ? error.message : String(error) },
118 500,
119 );
120 });
121
122export const parseStartRequest = (c: Context) =>
123 Effect.tryPromise({
124 try: async () => {
125 const body = await c.req.json();
126 return MachineParamsSchema.parse(body);
127 },
128 catch: (error) =>
129 new ParseRequestError({
130 cause: error,
131 message: error instanceof Error ? error.message : String(error),
132 }),
133 });
134
135export const parseCreateMachineRequest = (c: Context) =>
136 Effect.tryPromise({
137 try: async () => {
138 const body = await c.req.json();
139 return NewMachineSchema.parse(body);
140 },
141 catch: (error) =>
142 new ParseRequestError({
143 cause: error,
144 message: error instanceof Error ? error.message : String(error),
145 }),
146 });
147
148export const parseCreateImageRequest = (c: Context) =>
149 Effect.tryPromise({
150 try: async () => {
151 const body = await c.req.json();
152 return NewImageSchema.parse(body);
153 },
154 catch: (error) =>
155 new ParseRequestError({
156 cause: error,
157 message: error instanceof Error ? error.message : String(error),
158 }),
159 });
160
161export const createVolumeIfNeeded = (
162 image: Image,
163 volumeName: string,
164 size?: string,
165): Effect.Effect<Volume, Error, never> =>
166 Effect.gen(function* () {
167 const volume = yield* getVolume(volumeName);
168 if (volume) {
169 return volume;
170 }
171
172 return yield* createVolume(volumeName, image, size);
173 });
174
175export const parseCreateVolumeRequest = (c: Context) =>
176 Effect.tryPromise({
177 try: async () => {
178 const body = await c.req.json();
179 return NewVolumeSchema.parse(body);
180 },
181 catch: (error) =>
182 new ParseRequestError({
183 cause: error,
184 message: error instanceof Error ? error.message : String(error),
185 }),
186 });