A Docker-like CLI and HTTP API for managing headless VMs
at main 246 lines 7.4 kB view raw
1import _ from "@es-toolkit/es-toolkit/compat"; 2import { createId } from "@paralleldrive/cuid2"; 3import { Effect, pipe } from "effect"; 4import { Hono } from "hono"; 5import Moniker from "moniker"; 6import { SEED_DIR } from "../constants.ts"; 7import { ImageNotFoundError, RemoveRunningVmError } from "../errors.ts"; 8import { getImage } from "../images.ts"; 9import { generateRandomMacAddress } from "../network.ts"; 10import { 11 getInstanceState, 12 listInstances, 13 removeInstanceState, 14 saveInstanceState, 15} from "../state.ts"; 16import { 17 buildQemuArgs, 18 createLogsDir, 19 failIfVMRunning, 20 setupFirmware, 21 startDetachedQemu, 22} from "../subcommands/start.ts"; 23import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts"; 24import type { NewMachine } from "../types.ts"; 25import { getVolume } from "../volumes.ts"; 26import { createSeedIso } from "../xorriso.ts"; 27import { 28 createVolumeIfNeeded, 29 handleError, 30 parseCreateMachineRequest, 31 parseParams, 32 parseQueryParams, 33 parseStartRequest, 34 presentation, 35} from "./utils.ts"; 36 37const app = new Hono(); 38 39app.get("/", (c) => 40 Effect.runPromise( 41 pipe( 42 parseQueryParams(c), 43 Effect.flatMap((params) => 44 listInstances(params.all === "true" || params.all === "1") 45 ), 46 presentation(c), 47 ), 48 )); 49 50app.post("/", (c) => 51 Effect.runPromise( 52 pipe( 53 parseCreateMachineRequest(c), 54 Effect.flatMap((params: NewMachine) => 55 Effect.gen(function* () { 56 const image = yield* getImage(params.image); 57 if (!image) { 58 return yield* Effect.fail( 59 new ImageNotFoundError({ id: params.image }), 60 ); 61 } 62 63 const volume = params.volume 64 ? yield* createVolumeIfNeeded(image, params.volume) 65 : undefined; 66 67 const name = Moniker.choose(); 68 if (params.users) { 69 const [tempDir] = yield* Effect.promise(() => 70 Promise.all([ 71 Deno.makeTempDir(), 72 Deno.mkdir(SEED_DIR, { recursive: true }), 73 ]) 74 ); 75 yield* createSeedIso( 76 `${SEED_DIR}/seed-${name}.iso`, 77 { 78 metaData: { 79 instanceId: params.instanceId || name, 80 localHostname: params.localHostname || name, 81 hostname: params.hostname || name, 82 }, 83 userData: { 84 users: params.users.map((user) => ({ 85 name: user.name, 86 shell: user.shell, 87 sudo: user.sudo, 88 sshAuthorizedKeys: user.sshAuthorizedKeys || [], 89 })), 90 sshPwauth: false, 91 }, 92 }, 93 tempDir, 94 ); 95 } 96 97 const macAddress = yield* generateRandomMacAddress(); 98 const id = createId(); 99 yield* saveInstanceState({ 100 id, 101 name, 102 bridge: params.bridge, 103 macAddress, 104 memory: params.memory || "2G", 105 cpus: params.cpus || 8, 106 cpu: params.cpu || "host", 107 diskSize: "20G", 108 diskFormat: volume ? "qcow2" : image.format, 109 portForward: params.portForward 110 ? params.portForward.join(",") 111 : undefined, 112 drivePath: volume ? volume.path : image.path, 113 version: image.tag, 114 status: "STOPPED", 115 seed: _.get( 116 params, 117 "seed", 118 params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined, 119 ), 120 pid: 0, 121 }); 122 123 const createdVm = yield* findVm(id); 124 return createdVm; 125 }) 126 ), 127 presentation(c), 128 Effect.catchAll((error) => handleError(error, c)), 129 ), 130 )); 131 132app.get("/:id", (c) => 133 Effect.runPromise( 134 pipe( 135 parseParams(c), 136 Effect.flatMap(({ id }) => getInstanceState(id)), 137 presentation(c), 138 ), 139 )); 140 141app.delete("/:id", (c) => 142 Effect.runPromise( 143 pipe( 144 parseParams(c), 145 Effect.flatMap(({ id }) => findVm(id)), 146 Effect.flatMap((vm) => 147 vm.status === "RUNNING" 148 ? Effect.fail(new RemoveRunningVmError({ id: vm.id })) 149 : Effect.succeed(vm) 150 ), 151 Effect.flatMap((vm) => 152 Effect.gen(function* () { 153 yield* removeInstanceState(vm.id); 154 return vm; 155 }) 156 ), 157 presentation(c), 158 Effect.catchAll((error) => handleError(error, c)), 159 ), 160 )); 161 162app.post("/:id/start", (c) => 163 Effect.runPromise( 164 pipe( 165 Effect.all([parseParams(c), parseStartRequest(c)]), 166 Effect.flatMap(([{ id }, startRequest]) => 167 Effect.all([findVm(id), Effect.succeed(startRequest)]) 168 ), 169 Effect.flatMap(([vm, startRequest]) => 170 Effect.gen(function* () { 171 yield* failIfVMRunning(vm); 172 const volume = yield* getVolume(vm.drivePath || ""); 173 const firmwareArgs = yield* setupFirmware(); 174 vm.volume = volume ? volume.path : undefined; 175 const qemuArgs = yield* buildQemuArgs( 176 { 177 ...vm, 178 cpu: String(startRequest.cpu ?? vm.cpu), 179 cpus: startRequest.cpus ?? vm.cpus, 180 memory: startRequest.memory ?? vm.memory, 181 portForward: startRequest.portForward 182 ? startRequest.portForward.join(",") 183 : vm.portForward, 184 }, 185 firmwareArgs, 186 ); 187 yield* createLogsDir(); 188 yield* startDetachedQemu(vm.id, vm, qemuArgs); 189 return { ...vm, status: "RUNNING" }; 190 }) 191 ), 192 presentation(c), 193 Effect.catchAll((error) => handleError(error, c)), 194 ), 195 )); 196 197app.post("/:id/stop", (c) => 198 Effect.runPromise( 199 pipe( 200 parseParams(c), 201 Effect.flatMap(({ id }) => findVm(id)), 202 Effect.flatMap(killProcess), 203 Effect.flatMap(updateToStopped), 204 presentation(c), 205 Effect.catchAll((error) => handleError(error, c)), 206 ), 207 )); 208 209app.post("/:id/restart", (c) => 210 Effect.runPromise( 211 pipe( 212 parseParams(c), 213 Effect.flatMap(({ id }) => findVm(id)), 214 Effect.flatMap(killProcess), 215 Effect.flatMap(updateToStopped), 216 Effect.flatMap(() => Effect.all([parseParams(c), parseStartRequest(c)])), 217 Effect.flatMap(([{ id }, startRequest]) => 218 Effect.all([findVm(id), Effect.succeed(startRequest)]) 219 ), 220 Effect.flatMap(([vm, startRequest]) => 221 Effect.gen(function* () { 222 const firmwareArgs = yield* setupFirmware(); 223 const volume = yield* getVolume(vm.drivePath || ""); 224 vm.volume = volume ? volume.path : undefined; 225 const qemuArgs = yield* buildQemuArgs( 226 { 227 ...vm, 228 cpu: String(startRequest.cpus ?? vm.cpu), 229 memory: startRequest.memory ?? vm.memory, 230 portForward: startRequest.portForward 231 ? startRequest.portForward.join(",") 232 : vm.portForward, 233 }, 234 firmwareArgs, 235 ); 236 yield* createLogsDir(); 237 yield* startDetachedQemu(vm.id, vm, qemuArgs); 238 return { ...vm, status: "RUNNING" }; 239 }) 240 ), 241 presentation(c), 242 Effect.catchAll((error) => handleError(error, c)), 243 ), 244 )); 245 246export default app;