A simple command-line tool to start NetBSD virtual machines using QEMU with sensible defaults.

Merge pull request #6 from tsirysndr/feat/http-api

feat: implement HTTP API with images, machines, and volumes management

authored by tsiry-sandratraina.com and committed by

GitHub ee6329d3 4f15fe05

+664 -54
+2 -1
.gitignore
··· 1 1 *.iso 2 2 *.img 3 - vmconfig.toml 3 + vmconfig.toml 4 + *.fd
+2 -1
deno.json
··· 4 4 "exports": "./main.ts", 5 5 "license": "MPL-2.0", 6 6 "tasks": { 7 - "dev": "deno run --watch main.ts" 7 + "dev": "deno run --env-file=.env -A --watch main.ts" 8 8 }, 9 9 "imports": { 10 10 "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", ··· 23 23 "chalk": "npm:chalk@^5.6.2", 24 24 "dayjs": "npm:dayjs@^1.11.19", 25 25 "effect": "npm:effect@^3.19.2", 26 + "hono": "npm:hono@^4.10.6", 26 27 "kysely": "npm:kysely@0.27.6", 27 28 "moniker": "npm:moniker@^0.1.2" 28 29 }
+5
deno.lock
··· 41 41 "npm:chalk@^5.6.2": "5.6.2", 42 42 "npm:dayjs@^1.11.19": "1.11.19", 43 43 "npm:effect@^3.19.2": "3.19.2", 44 + "npm:hono@^4.10.6": "4.10.6", 44 45 "npm:kysely@0.27.6": "0.27.6", 45 46 "npm:kysely@~0.27.2": "0.27.6", 46 47 "npm:moniker@~0.1.2": "0.1.2" ··· 230 231 "pure-rand" 231 232 ] 232 233 }, 234 + "hono@4.10.6": { 235 + "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==" 236 + }, 233 237 "kysely@0.27.6": { 234 238 "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" 235 239 }, ··· 261 265 "npm:chalk@^5.6.2", 262 266 "npm:dayjs@^1.11.19", 263 267 "npm:effect@^3.19.2", 268 + "npm:hono@^4.10.6", 264 269 "npm:kysely@0.27.6", 265 270 "npm:moniker@~0.1.2" 266 271 ]
+7
main.ts
··· 27 27 import stop from "./src/subcommands/stop.ts"; 28 28 import tag from "./src/subcommands/tag.ts"; 29 29 import * as volumes from "./src/subcommands/volume.ts"; 30 + import serve from "./src/api/mod.ts"; 30 31 import { 31 32 createDriveImageIfNeeded, 32 33 downloadIso, ··· 401 402 await volumes.inspect(volumeName); 402 403 }), 403 404 ) 405 + .description("Manage volumes") 406 + .command("serve", "Start the NetBSD-Up HTTP API server") 407 + .option("-p, --port <port:number>", "Port to listen on", { default: 8892 }) 408 + .action(() => { 409 + serve(); 410 + }) 404 411 .parse(Deno.args); 405 412 }
+34
src/api/images.ts
··· 1 + import { Hono } from "hono"; 2 + import { Effect, pipe } from "effect"; 3 + import { parseParams, presentation } from "./utils.ts"; 4 + import { getImage, listImages } from "../images.ts"; 5 + 6 + const app = new Hono(); 7 + 8 + app.get("/", (c) => 9 + Effect.runPromise( 10 + pipe( 11 + listImages(), 12 + presentation(c), 13 + ), 14 + )); 15 + 16 + app.get("/:id", (c) => 17 + Effect.runPromise( 18 + pipe( 19 + parseParams(c), 20 + Effect.flatMap(({ id }) => getImage(id)), 21 + presentation(c), 22 + ), 23 + )); 24 + 25 + app.post("/", (c) => { 26 + return c.json({ message: "New image created" }); 27 + }); 28 + 29 + app.delete("/:id", (c) => { 30 + const { id } = c.req.param(); 31 + return c.json({ message: `Image with ID ${id} deleted` }); 32 + }); 33 + 34 + export default app;
+208
src/api/machines.ts
··· 1 + import { Hono } from "hono"; 2 + import { Data, Effect, pipe } from "effect"; 3 + import { 4 + createVolumeIfNeeded, 5 + handleError, 6 + parseCreateMachineRequest, 7 + parseParams, 8 + parseQueryParams, 9 + parseStartRequest, 10 + presentation, 11 + } from "./utils.ts"; 12 + import { DEFAULT_VERSION, getInstanceState } from "../mod.ts"; 13 + import { 14 + listInstances, 15 + removeInstanceState, 16 + saveInstanceState, 17 + } from "../state.ts"; 18 + import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts"; 19 + import { 20 + buildQemuArgs, 21 + createLogsDir, 22 + failIfVMRunning, 23 + setupFirmware, 24 + startDetachedQemu, 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 + }> {} 41 + 42 + const app = new Hono(); 43 + 44 + app.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 + 57 + app.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 + 104 + app.get("/:id", (c) => 105 + Effect.runPromise( 106 + pipe( 107 + parseParams(c), 108 + Effect.flatMap(({ id }) => getInstanceState(id)), 109 + presentation(c), 110 + ), 111 + )); 112 + 113 + app.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 + 134 + app.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 + 164 + app.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 + 176 + app.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 + 208 + export default app;
+46
src/api/mod.ts
··· 1 + import machines from "./machines.ts"; 2 + import images from "./images.ts"; 3 + import volumes from "./volumes.ts"; 4 + import { Hono } from "hono"; 5 + import { logger } from "hono/logger"; 6 + import { cors } from "hono/cors"; 7 + import { bearerAuth } from "hono/bearer-auth"; 8 + import { parseFlags } from "@cliffy/flags"; 9 + 10 + export { images, machines, volumes }; 11 + 12 + export default function () { 13 + const token = Deno.env.get("NETBSD_UP_API_TOKEN") || 14 + crypto.randomUUID(); 15 + const { flags } = parseFlags(Deno.args); 16 + 17 + if (!Deno.env.get("NETBSD_UP_API_TOKEN")) { 18 + console.log(`Using API token: ${token}`); 19 + } else { 20 + console.log( 21 + `Using provided API token from environment variable NETBSD_UP_API_TOKEN`, 22 + ); 23 + } 24 + 25 + const app = new Hono(); 26 + 27 + app.use(logger()); 28 + app.use(cors()); 29 + 30 + app.use("/images/*", bearerAuth({ token })); 31 + app.use("/machines/*", bearerAuth({ token })); 32 + app.use("/volumes/*", bearerAuth({ token })); 33 + 34 + app.route("/images", images); 35 + app.route("/machines", machines); 36 + app.route("/volumes", volumes); 37 + 38 + const port = Number( 39 + flags.port || flags.p || 40 + (Deno.env.get("NETBSD_UP_PORT") 41 + ? Number(Deno.env.get("NETBSD_UP_PORT")) 42 + : 8892), 43 + ); 44 + 45 + Deno.serve({ port }, app.fetch); 46 + }
+158
src/api/utils.ts
··· 1 + import { Data, Effect } from "effect"; 2 + import type { Context } from "hono"; 3 + import { 4 + type CommandError, 5 + StopCommandError, 6 + VmNotFoundError, 7 + } from "../subcommands/stop.ts"; 8 + import { VmAlreadyRunningError } from "../subcommands/start.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"; 17 + 18 + export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query()); 19 + 20 + export const parseParams = (c: Context) => Effect.succeed(c.req.param()); 21 + 22 + export const presentation = (c: Context) => 23 + Effect.flatMap((data) => Effect.succeed(c.json(data))); 24 + 25 + export class ParseRequestError extends Data.TaggedError("ParseRequestError")<{ 26 + cause?: unknown; 27 + message: string; 28 + }> {} 29 + 30 + export const handleError = ( 31 + error: 32 + | VmNotFoundError 33 + | StopCommandError 34 + | CommandError 35 + | ParseRequestError 36 + | VmAlreadyRunningError 37 + | ImageNotFoundError 38 + | RemoveRunningVmError 39 + | Error, 40 + c: Context, 41 + ) => 42 + Effect.sync(() => { 43 + if (error instanceof VmNotFoundError) { 44 + return c.json( 45 + { message: "VM not found", code: "VM_NOT_FOUND" }, 46 + 404, 47 + ); 48 + } 49 + if (error instanceof StopCommandError) { 50 + return c.json( 51 + { 52 + message: error.message || 53 + `Failed to stop VM ${error.vmName}`, 54 + code: "STOP_COMMAND_ERROR", 55 + }, 56 + 500, 57 + ); 58 + } 59 + 60 + if (error instanceof ParseRequestError) { 61 + return c.json( 62 + { 63 + message: error.message || "Failed to parse request body", 64 + code: "PARSE_BODY_ERROR", 65 + }, 66 + 400, 67 + ); 68 + } 69 + 70 + if (error instanceof VmAlreadyRunningError) { 71 + return c.json( 72 + { 73 + message: `VM ${error.name} is already running`, 74 + code: "VM_ALREADY_RUNNING", 75 + }, 76 + 400, 77 + ); 78 + } 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 + 101 + return c.json( 102 + { message: error instanceof Error ? error.message : String(error) }, 103 + 500, 104 + ); 105 + }); 106 + 107 + export const parseStartRequest = (c: Context) => 108 + Effect.tryPromise({ 109 + try: async () => { 110 + const body = await c.req.json(); 111 + return MachineParamsSchema.parse(body); 112 + }, 113 + catch: (error) => 114 + new ParseRequestError({ 115 + cause: error, 116 + message: error instanceof Error ? error.message : String(error), 117 + }), 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 + });
+71
src/api/volumes.ts
··· 1 + import { Hono } from "hono"; 2 + import { Effect, pipe } from "effect"; 3 + import { 4 + createVolumeIfNeeded, 5 + handleError, 6 + parseCreateVolumeRequest, 7 + parseParams, 8 + presentation, 9 + } from "./utils.ts"; 10 + import { listVolumes } from "../mod.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"; 15 + 16 + const app = new Hono(); 17 + 18 + app.get("/", (c) => 19 + Effect.runPromise( 20 + pipe( 21 + listVolumes(), 22 + presentation(c), 23 + ), 24 + )); 25 + 26 + app.get("/:id", (c) => 27 + Effect.runPromise( 28 + pipe( 29 + parseParams(c), 30 + Effect.flatMap(({ id }) => getVolume(id)), 31 + presentation(c), 32 + ), 33 + )); 34 + 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 + )); 49 + 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 + )); 70 + 71 + export default app;
+1
src/mod.ts
··· 8 8 export * from "./types.ts"; 9 9 export * from "./utils.ts"; 10 10 export * from "./volumes.ts"; 11 + export * from "./api/mod.ts";
+17
src/state.ts
··· 73 73 .executeTakeFirst(), 74 74 catch: (error) => new DbError({ cause: error }), 75 75 }); 76 + 77 + export const listInstances = ( 78 + all: boolean, 79 + ): Effect.Effect<VirtualMachine[], DbError, never> => 80 + Effect.tryPromise({ 81 + try: () => 82 + ctx.db.selectFrom("virtual_machines") 83 + .selectAll() 84 + .where((eb) => { 85 + if (all) { 86 + return eb("id", "!=", ""); 87 + } 88 + return eb("status", "=", "RUNNING"); 89 + }) 90 + .execute(), 91 + catch: (error) => new DbError({ cause: error }), 92 + });
+7 -7
src/subcommands/run.ts
··· 84 84 function mergeFlags(image: Image): Options { 85 85 const { flags } = parseFlags(Deno.args); 86 86 return { 87 - cpu: flags.cpu ? flags.cpu : "host", 88 - cpus: flags.cpus ? flags.cpus : 2, 89 - memory: flags.memory ? flags.memory : "2G", 87 + cpu: (flags.cpu || flags.c) ? (flags.cpu || flags.c) : "host", 88 + cpus: (flags.cpus || flags.C) ? (flags.cpus || flags.C) : 2, 89 + memory: (flags.memory || flags.m) ? (flags.memory || flags.m) : "2G", 90 90 image: image.path, 91 - bridge: flags.bridge, 92 - portForward: flags.portForward, 93 - detach: flags.detach, 91 + bridge: flags.bridge || flags.b, 92 + portForward: flags.portForward || flags.p, 93 + detach: flags.detach || flags.d, 94 94 install: false, 95 95 diskFormat: image.format, 96 - volume: flags.volume, 96 + volume: flags.volume || flags.v, 97 97 }; 98 98 }
+39 -14
src/subcommands/start.ts
··· 8 8 import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; 9 9 import { createVolume, getVolume } from "../volumes.ts"; 10 10 11 - class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 11 + export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 12 12 name: string; 13 13 }> {} 14 14 15 - class CommandError extends Data.TaggedError("CommandError")<{ 15 + export class VmAlreadyRunningError 16 + extends Data.TaggedError("VmAlreadyRunningError")<{ 17 + name: string; 18 + }> {} 19 + 20 + export class CommandError extends Data.TaggedError("CommandError")<{ 16 21 cause?: unknown; 17 22 }> {} 18 23 19 - const findVm = (name: string) => 24 + export const findVm = (name: string) => 20 25 pipe( 21 26 getInstanceState(name), 22 27 Effect.flatMap((vm) => ··· 31 36 32 37 const applyFlags = (vm: VirtualMachine) => Effect.succeed(mergeFlags(vm)); 33 38 34 - const setupFirmware = () => setupFirmwareFilesIfNeeded(); 39 + export const setupFirmware = () => setupFirmwareFilesIfNeeded(); 35 40 36 - const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 41 + export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { 37 42 const qemu = Deno.build.arch === "aarch64" 38 43 ? "qemu-system-aarch64" 39 44 : "qemu-system-x86_64"; ··· 76 81 ]); 77 82 }; 78 83 79 - const createLogsDir = () => 84 + export const createLogsDir = () => 80 85 Effect.tryPromise({ 81 86 try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), 82 87 catch: (error) => new CommandError({ cause: error }), 83 88 }); 84 89 85 - const startDetachedQemu = ( 90 + export const failIfVMRunning = (vm: VirtualMachine) => 91 + Effect.gen(function* () { 92 + if (vm.status === "RUNNING") { 93 + return yield* Effect.fail( 94 + new VmAlreadyRunningError({ name: vm.name }), 95 + ); 96 + } 97 + return vm; 98 + }); 99 + 100 + export const startDetachedQemu = ( 86 101 name: string, 87 102 vm: VirtualMachine, 88 103 qemuArgs: string[], ··· 280 295 const { flags } = parseFlags(Deno.args); 281 296 return { 282 297 ...vm, 283 - memory: flags.memory ? String(flags.memory) : vm.memory, 284 - cpus: flags.cpus ? Number(flags.cpus) : vm.cpus, 285 - cpu: flags.cpu ? String(flags.cpu) : vm.cpu, 298 + memory: (flags.memory || flags.m) 299 + ? String(flags.memory || flags.m) 300 + : vm.memory, 301 + cpus: (flags.cpus || flags.C) ? Number(flags.cpus || flags.C) : vm.cpus, 302 + cpu: (flags.cpu || flags.c) ? String(flags.cpu || flags.c) : vm.cpu, 286 303 diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, 287 - portForward: flags.portForward ? String(flags.portForward) : vm.portForward, 288 - drivePath: flags.image ? String(flags.image) : vm.drivePath, 289 - bridge: flags.bridge ? String(flags.bridge) : vm.bridge, 290 - diskSize: flags.size ? String(flags.size) : vm.diskSize, 304 + portForward: (flags.portForward || flags.p) 305 + ? String(flags.portForward || flags.p) 306 + : vm.portForward, 307 + drivePath: (flags.image || flags.i) 308 + ? String(flags.image || flags.i) 309 + : vm.drivePath, 310 + bridge: (flags.bridge || flags.b) 311 + ? String(flags.bridge || flags.b) 312 + : vm.bridge, 313 + diskSize: (flags.size || flags.s) 314 + ? String(flags.size || flags.s) 315 + : vm.diskSize, 291 316 }; 292 317 }
+7 -7
src/subcommands/stop.ts
··· 4 4 import type { VirtualMachine } from "../db.ts"; 5 5 import { getInstanceState, updateInstanceState } from "../state.ts"; 6 6 7 - class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 7 + export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ 8 8 name: string; 9 9 }> {} 10 10 11 - class StopCommandError extends Data.TaggedError("StopCommandError")<{ 11 + export class StopCommandError extends Data.TaggedError("StopCommandError")<{ 12 12 vmName: string; 13 13 exitCode: number; 14 14 }> {} 15 15 16 - class CommandError extends Data.TaggedError("CommandError")<{ 16 + export class CommandError extends Data.TaggedError("CommandError")<{ 17 17 cause?: unknown; 18 18 }> {} 19 19 20 - const findVm = (name: string) => 20 + export const findVm = (name: string) => 21 21 pipe( 22 22 getInstanceState(name), 23 23 Effect.flatMap((vm) => ··· 34 34 ); 35 35 }); 36 36 37 - const killProcess = (vm: VirtualMachine) => 37 + export const killProcess = (vm: VirtualMachine) => 38 38 Effect.tryPromise({ 39 39 try: async () => { 40 40 const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { ··· 63 63 ), 64 64 ); 65 65 66 - const updateToStopped = (vm: VirtualMachine) => 66 + export const updateToStopped = (vm: VirtualMachine) => 67 67 pipe( 68 68 updateInstanceState(vm.name, "STOPPED"), 69 - Effect.map(() => vm), 69 + Effect.map(() => ({ ...vm, status: "STOPPED" } as VirtualMachine)), 70 70 ); 71 71 72 72 const logSuccess = (vm: VirtualMachine) =>
+35
src/types.ts
··· 1 + import z from "@zod/zod"; 2 + 1 3 export type STATUS = "RUNNING" | "STOPPED"; 4 + 5 + export const MachineParamsSchema = z.object({ 6 + portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), 7 + cpu: z.string().optional(), 8 + cpus: z.number().min(1).optional(), 9 + memory: z.string().regex(/^\d+(M|G)$/).optional(), 10 + }); 11 + 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,