diff --git a/.gitignore b/.gitignore index e6e7771..12c098d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ *.iso *.img -vmconfig.toml \ No newline at end of file +vmconfig.toml +.env \ No newline at end of file diff --git a/deno.json b/deno.json index 0bca470..374f423 100644 --- a/deno.json +++ b/deno.json @@ -4,7 +4,7 @@ "exports": "./main.ts", "license": "MPL-2.0", "tasks": { - "dev": "deno run --watch main.ts" + "dev": "deno run --env-file=.env -A --watch main.ts" }, "imports": { "@cliffy/command": "jsr:@cliffy/command@^1.0.0-rc.8", @@ -23,6 +23,7 @@ "chalk": "npm:chalk@^5.6.2", "dayjs": "npm:dayjs@^1.11.19", "effect": "npm:effect@^3.19.2", + "hono": "npm:hono@^4.10.6", "kysely": "npm:kysely@0.27.6", "moniker": "npm:moniker@^0.1.2" } diff --git a/deno.lock b/deno.lock index 21faec5..f7bb83b 100644 --- a/deno.lock +++ b/deno.lock @@ -41,6 +41,7 @@ "npm:chalk@^5.6.2": "5.6.2", "npm:dayjs@^1.11.19": "1.11.19", "npm:effect@^3.19.2": "3.19.2", + "npm:hono@^4.10.6": "4.10.6", "npm:kysely@0.27.6": "0.27.6", "npm:kysely@~0.27.2": "0.27.6", "npm:moniker@~0.1.2": "0.1.2" @@ -230,6 +231,9 @@ "pure-rand" ] }, + "hono@4.10.6": { + "integrity": "sha512-BIdolzGpDO9MQ4nu3AUuDwHZZ+KViNm+EZ75Ae55eMXMqLVhDFqEMXxtUe9Qh8hjL+pIna/frs2j6Y2yD5Ua/g==" + }, "kysely@0.27.6": { "integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==" }, @@ -261,6 +265,7 @@ "npm:chalk@^5.6.2", "npm:dayjs@^1.11.19", "npm:effect@^3.19.2", + "npm:hono@^4.10.6", "npm:kysely@0.27.6", "npm:moniker@~0.1.2" ] diff --git a/main.ts b/main.ts index 550dbf6..450167f 100755 --- a/main.ts +++ b/main.ts @@ -27,6 +27,7 @@ import start from "./src/subcommands/start.ts"; import stop from "./src/subcommands/stop.ts"; import tag from "./src/subcommands/tag.ts"; import * as volumes from "./src/subcommands/volume.ts"; +import serve from "./src/api/mod.ts"; import { createDriveImageIfNeeded, downloadIso, @@ -397,5 +398,11 @@ if (import.meta.main) { await volumes.inspect(volumeName); }), ) + .description("Manage volumes") + .command("serve", "Start the OpenBSD-Up HTTP API server") + .option("-p, --port ", "Port to listen on", { default: 8891 }) + .action(() => { + serve(); + }) .parse(Deno.args); } diff --git a/src/api/images.ts b/src/api/images.ts new file mode 100644 index 0000000..b707f20 --- /dev/null +++ b/src/api/images.ts @@ -0,0 +1,34 @@ +import { Hono } from "hono"; +import { Effect, pipe } from "effect"; +import { parseParams, presentation } from "./utils.ts"; +import { getImage, listImages } from "../images.ts"; + +const app = new Hono(); + +app.get("/", (c) => + Effect.runPromise( + pipe( + listImages(), + presentation(c), + ), + )); + +app.get("/:id", (c) => + Effect.runPromise( + pipe( + parseParams(c), + Effect.flatMap(({ id }) => getImage(id)), + presentation(c), + ), + )); + +app.post("/", (c) => { + return c.json({ message: "New image created" }); +}); + +app.delete("/:id", (c) => { + const { id } = c.req.param(); + return c.json({ message: `Image with ID ${id} deleted` }); +}); + +export default app; diff --git a/src/api/machines.ts b/src/api/machines.ts new file mode 100644 index 0000000..a3b158a --- /dev/null +++ b/src/api/machines.ts @@ -0,0 +1,207 @@ +import { Hono } from "hono"; +import { Data, Effect, pipe } from "effect"; +import { + createVolumeIfNeeded, + handleError, + parseCreateMachineRequest, + parseParams, + parseQueryParams, + parseStartRequest, + presentation, +} from "./utils.ts"; +import { DEFAULT_VERSION, getInstanceState } from "../mod.ts"; +import { + listInstances, + removeInstanceState, + saveInstanceState, +} from "../state.ts"; +import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts"; +import { + buildQemuArgs, + createLogsDir, + failIfVMRunning, + setupFirmware, + startDetachedQemu, +} from "../subcommands/start.ts"; +import type { NewMachine } from "../types.ts"; +import { createId } from "@paralleldrive/cuid2"; +import { generateRandomMacAddress } from "../network.ts"; +import Moniker from "moniker"; +import { getImage } from "../images.ts"; + +export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{ + id: string; +}> {} + +export class RemoveRunningVmError extends Data.TaggedError( + "RemoveRunningVmError", +)<{ + id: string; +}> {} + +const app = new Hono(); + +app.get("/", (c) => + Effect.runPromise( + pipe( + parseQueryParams(c), + Effect.flatMap((params) => + listInstances( + params.all === "true" || params.all === "1", + ) + ), + presentation(c), + ), + )); + +app.post("/", (c) => + Effect.runPromise( + pipe( + parseCreateMachineRequest(c), + Effect.flatMap((params: NewMachine) => + Effect.gen(function* () { + const image = yield* getImage(params.image); + if (!image) { + return yield* Effect.fail( + new ImageNotFoundError({ id: params.image }), + ); + } + + const volume = params.volume + ? yield* createVolumeIfNeeded(image, params.volume) + : undefined; + + const macAddress = yield* generateRandomMacAddress(); + const id = createId(); + yield* saveInstanceState({ + id, + name: Moniker.choose(), + bridge: params.bridge, + macAddress, + memory: params.memory || "2G", + cpus: params.cpus || 8, + cpu: params.cpu || "host", + diskSize: "20G", + diskFormat: volume ? "qcow2" : "raw", + portForward: params.portForward + ? params.portForward.join(",") + : undefined, + drivePath: volume ? volume.path : image.path, + version: image.tag ?? DEFAULT_VERSION, + status: "STOPPED", + pid: 0, + }); + + const createdVm = yield* findVm(id); + return createdVm; + }) + ), + presentation(c), + Effect.catchAll((error) => handleError(error, c)), + ), + )); + +app.get("/:id", (c) => + Effect.runPromise( + pipe( + parseParams(c), + Effect.flatMap(({ id }) => getInstanceState(id)), + presentation(c), + ), + )); + +app.delete("/:id", (c) => + Effect.runPromise( + pipe( + parseParams(c), + Effect.flatMap(({ id }) => findVm(id)), + Effect.flatMap((vm) => + vm.status === "RUNNING" + ? Effect.fail(new RemoveRunningVmError({ id: vm.id })) + : Effect.succeed(vm) + ), + Effect.flatMap((vm) => + Effect.gen(function* () { + yield* removeInstanceState(vm.id); + return vm; + }) + ), + presentation(c), + Effect.catchAll((error) => handleError(error, c)), + ), + )); + +app.post("/:id/start", (c) => + Effect.runPromise( + pipe( + Effect.all([parseParams(c), parseStartRequest(c)]), + Effect.flatMap(( + [{ id }, startRequest], + ) => Effect.all([findVm(id), Effect.succeed(startRequest)])), + Effect.flatMap(([vm, startRequest]) => + Effect.gen(function* () { + yield* failIfVMRunning(vm); + const firmwareArgs = yield* setupFirmware(); + const qemuArgs = yield* buildQemuArgs({ + ...vm, + cpu: String(startRequest.cpus ?? vm.cpu), + memory: startRequest.memory ?? vm.memory, + portForward: startRequest.portForward + ? startRequest.portForward.join(",") + : vm.portForward, + }, firmwareArgs); + yield* createLogsDir(); + yield* startDetachedQemu(vm.id, vm, qemuArgs); + return { ...vm, status: "RUNNING" }; + }) + ), + presentation(c), + Effect.catchAll((error) => handleError(error, c)), + ), + )); + +app.post("/:id/stop", (c) => + Effect.runPromise( + pipe( + parseParams(c), + Effect.flatMap(({ id }) => findVm(id)), + Effect.flatMap(killProcess), + Effect.flatMap(updateToStopped), + presentation(c), + Effect.catchAll((error) => handleError(error, c)), + ), + )); + +app.post("/:id/restart", (c) => + Effect.runPromise( + pipe( + parseParams(c), + Effect.flatMap(({ id }) => findVm(id)), + Effect.flatMap(killProcess), + Effect.flatMap(updateToStopped), + Effect.flatMap(() => Effect.all([parseParams(c), parseStartRequest(c)])), + Effect.flatMap(( + [{ id }, startRequest], + ) => Effect.all([findVm(id), Effect.succeed(startRequest)])), + Effect.flatMap(([vm, startRequest]) => + Effect.gen(function* () { + const firmwareArgs = yield* setupFirmware(); + const qemuArgs = yield* buildQemuArgs({ + ...vm, + cpu: String(startRequest.cpus ?? vm.cpu), + memory: startRequest.memory ?? vm.memory, + portForward: startRequest.portForward + ? startRequest.portForward.join(",") + : vm.portForward, + }, firmwareArgs); + yield* createLogsDir(); + yield* startDetachedQemu(vm.id, vm, qemuArgs); + return { ...vm, status: "RUNNING" }; + }) + ), + presentation(c), + Effect.catchAll((error) => handleError(error, c)), + ), + )); + +export default app; diff --git a/src/api/mod.ts b/src/api/mod.ts new file mode 100644 index 0000000..9bd7642 --- /dev/null +++ b/src/api/mod.ts @@ -0,0 +1,46 @@ +import machines from "./machines.ts"; +import images from "./images.ts"; +import volumes from "./volumes.ts"; +import { Hono } from "hono"; +import { logger } from "hono/logger"; +import { cors } from "hono/cors"; +import { bearerAuth } from "hono/bearer-auth"; +import { parseFlags } from "@cliffy/flags"; + +export { images, machines, volumes }; + +export default function () { + const token = Deno.env.get("OPENBSD_UP_API_TOKEN") || + crypto.randomUUID(); + const { flags } = parseFlags(Deno.args); + + if (!Deno.env.get("OPENBSD_UP_API_TOKEN")) { + console.log(`Using API token: ${token}`); + } else { + console.log( + `Using provided API token from environment variable OPENBSD_UP_API_TOKEN`, + ); + } + + const app = new Hono(); + + app.use(logger()); + app.use(cors()); + + app.use("/images/*", bearerAuth({ token })); + app.use("/machines/*", bearerAuth({ token })); + app.use("/volumes/*", bearerAuth({ token })); + + app.route("/images", images); + app.route("/machines", machines); + app.route("/volumes", volumes); + + const port = Number( + flags.port || flags.p || + (Deno.env.get("OPENBSD_UP_PORT") + ? Number(Deno.env.get("OPENBSD_UP_PORT")) + : 8891), + ); + + Deno.serve({ port }, app.fetch); +} diff --git a/src/api/utils.ts b/src/api/utils.ts new file mode 100644 index 0000000..c7466a7 --- /dev/null +++ b/src/api/utils.ts @@ -0,0 +1,158 @@ +import { Data, Effect } from "effect"; +import type { Context } from "hono"; +import { + type CommandError, + StopCommandError, + VmNotFoundError, +} from "../subcommands/stop.ts"; +import { VmAlreadyRunningError } from "../subcommands/start.ts"; +import { + MachineParamsSchema, + NewMachineSchema, + NewVolumeSchema, +} from "../types.ts"; +import type { Image, Volume } from "../db.ts"; +import { createVolume, getVolume } from "../volumes.ts"; +import { ImageNotFoundError, RemoveRunningVmError } from "./machines.ts"; + +export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query()); + +export const parseParams = (c: Context) => Effect.succeed(c.req.param()); + +export const presentation = (c: Context) => + Effect.flatMap((data) => Effect.succeed(c.json(data))); + +export class ParseRequestError extends Data.TaggedError("ParseRequestError")<{ + cause?: unknown; + message: string; +}> {} + +export const handleError = ( + error: + | VmNotFoundError + | StopCommandError + | CommandError + | ParseRequestError + | VmAlreadyRunningError + | ImageNotFoundError + | RemoveRunningVmError + | Error, + c: Context, +) => + Effect.sync(() => { + if (error instanceof VmNotFoundError) { + return c.json( + { message: "VM not found", code: "VM_NOT_FOUND" }, + 404, + ); + } + if (error instanceof StopCommandError) { + return c.json( + { + message: error.message || + `Failed to stop VM ${error.vmName}`, + code: "STOP_COMMAND_ERROR", + }, + 500, + ); + } + + if (error instanceof ParseRequestError) { + return c.json( + { + message: error.message || "Failed to parse request body", + code: "PARSE_BODY_ERROR", + }, + 400, + ); + } + + if (error instanceof VmAlreadyRunningError) { + return c.json( + { + message: `VM ${error.name} is already running`, + code: "VM_ALREADY_RUNNING", + }, + 400, + ); + } + + if (error instanceof ImageNotFoundError) { + return c.json( + { + message: `Image ${error.id} not found`, + code: "IMAGE_NOT_FOUND", + }, + 404, + ); + } + + if (error instanceof RemoveRunningVmError) { + return c.json( + { + message: + `Cannot remove running VM with ID ${error.id}. Please stop it first.`, + code: "REMOVE_RUNNING_VM_ERROR", + }, + 400, + ); + } + + return c.json( + { message: error instanceof Error ? error.message : String(error) }, + 500, + ); + }); + +export const parseStartRequest = (c: Context) => + Effect.tryPromise({ + try: async () => { + const body = await c.req.json(); + return MachineParamsSchema.parse(body); + }, + catch: (error) => + new ParseRequestError({ + cause: error, + message: error instanceof Error ? error.message : String(error), + }), + }); + +export const parseCreateMachineRequest = (c: Context) => + Effect.tryPromise({ + try: async () => { + const body = await c.req.json(); + return NewMachineSchema.parse(body); + }, + catch: (error) => + new ParseRequestError({ + cause: error, + message: error instanceof Error ? error.message : String(error), + }), + }); + +export const createVolumeIfNeeded = ( + image: Image, + volumeName: string, + size?: string, +): Effect.Effect => + Effect.gen(function* () { + const volume = yield* getVolume(volumeName); + if (volume) { + return volume; + } + + return yield* createVolume(volumeName, image, size); + }); + +export const parseCreateVolumeRequest = (c: Context) => + Effect.tryPromise({ + try: async () => { + const body = await c.req.json(); + return NewVolumeSchema.parse(body); + }, + catch: (error) => + new ParseRequestError({ + cause: error, + message: error instanceof Error ? error.message : String(error), + }), + }); diff --git a/src/api/volumes.ts b/src/api/volumes.ts new file mode 100644 index 0000000..3971782 --- /dev/null +++ b/src/api/volumes.ts @@ -0,0 +1,71 @@ +import { Hono } from "hono"; +import { Effect, pipe } from "effect"; +import { + createVolumeIfNeeded, + handleError, + parseCreateVolumeRequest, + parseParams, + presentation, +} from "./utils.ts"; +import { listVolumes } from "../mod.ts"; +import { deleteVolume, getVolume } from "../volumes.ts"; +import type { NewVolume } from "../types.ts"; +import { getImage } from "../images.ts"; +import { ImageNotFoundError } from "./machines.ts"; + +const app = new Hono(); + +app.get("/", (c) => + Effect.runPromise( + pipe( + listVolumes(), + presentation(c), + ), + )); + +app.get("/:id", (c) => + Effect.runPromise( + pipe( + parseParams(c), + Effect.flatMap(({ id }) => getVolume(id)), + presentation(c), + ), + )); + +app.delete("/:id", (c) => + Effect.runPromise( + pipe( + parseParams(c), + Effect.flatMap(({ id }) => + Effect.gen(function* () { + const volume = yield* getVolume(id); + yield* deleteVolume(id); + return volume; + }) + ), + presentation(c), + ), + )); + +app.post("/", (c) => + Effect.runPromise( + pipe( + parseCreateVolumeRequest(c), + Effect.flatMap((params: NewVolume) => + Effect.gen(function* () { + const image = yield* getImage(params.baseImage); + if (!image) { + return yield* Effect.fail( + new ImageNotFoundError({ id: params.baseImage }), + ); + } + + return yield* createVolumeIfNeeded(image, params.name, params.size); + }) + ), + presentation(c), + Effect.catchAll((error) => handleError(error, c)), + ), + )); + +export default app; diff --git a/src/mod.ts b/src/mod.ts index ab803af..8728277 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -8,3 +8,4 @@ export * from "./state.ts"; export * from "./types.ts"; export * from "./utils.ts"; export * from "./volumes.ts"; +export * from "./api/mod.ts"; diff --git a/src/state.ts b/src/state.ts index f17d121..11aa1f4 100644 --- a/src/state.ts +++ b/src/state.ts @@ -74,3 +74,20 @@ export const getInstanceState = ( .executeTakeFirst(), catch: (error) => new DbError({ cause: error }), }); + +export const listInstances = ( + all: boolean, +): Effect.Effect => + Effect.tryPromise({ + try: () => + ctx.db.selectFrom("virtual_machines") + .selectAll() + .where((eb) => { + if (all) { + return eb("id", "!=", ""); + } + return eb("status", "=", "RUNNING"); + }) + .execute(), + catch: (error) => new DbError({ cause: error }), + }); diff --git a/src/subcommands/run.ts b/src/subcommands/run.ts index e9400be..9633176 100644 --- a/src/subcommands/run.ts +++ b/src/subcommands/run.ts @@ -84,15 +84,15 @@ export default async function ( function mergeFlags(image: Image): Options { const { flags } = parseFlags(Deno.args); return { - cpu: flags.cpu ? flags.cpu : "host", - cpus: flags.cpus ? flags.cpus : 2, - memory: flags.memory ? flags.memory : "2G", + cpu: (flags.cpu || flags.c) ? (flags.cpu || flags.c) : "host", + cpus: (flags.cpus || flags.C) ? (flags.cpus || flags.C) : 2, + memory: (flags.memory || flags.m) ? (flags.memory || flags.m) : "2G", image: image.path, - bridge: flags.bridge, - portForward: flags.portForward, - detach: flags.detach, + bridge: flags.bridge || flags.b, + portForward: flags.portForward || flags.p, + detach: flags.detach || flags.d, install: false, diskFormat: image.format, - volume: flags.volume, + volume: flags.volume || flags.v, }; } diff --git a/src/subcommands/start.ts b/src/subcommands/start.ts index a07c074..d07ca33 100644 --- a/src/subcommands/start.ts +++ b/src/subcommands/start.ts @@ -8,11 +8,16 @@ import { getInstanceState, updateInstanceState } from "../state.ts"; import { setupFirmwareFilesIfNeeded, setupNATNetworkArgs } from "../utils.ts"; import { createVolume, getVolume } from "../volumes.ts"; -class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ +export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ name: string; }> {} -class CommandError extends Data.TaggedError("CommandError")<{ +export class VmAlreadyRunningError + extends Data.TaggedError("VmAlreadyRunningError")<{ + name: string; + }> {} + +export class CommandError extends Data.TaggedError("CommandError")<{ cause?: unknown; }> {} @@ -31,9 +36,9 @@ const logStarting = (vm: VirtualMachine) => const applyFlags = (vm: VirtualMachine) => Effect.succeed(mergeFlags(vm)); -const setupFirmware = () => setupFirmwareFilesIfNeeded(); +export const setupFirmware = () => setupFirmwareFilesIfNeeded(); -const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { +export const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { const qemu = Deno.build.arch === "aarch64" ? "qemu-system-aarch64" : "qemu-system-x86_64"; @@ -72,13 +77,23 @@ const buildQemuArgs = (vm: VirtualMachine, firmwareArgs: string[]) => { ]); }; -const createLogsDir = () => +export const createLogsDir = () => Effect.tryPromise({ try: () => Deno.mkdir(LOGS_DIR, { recursive: true }), catch: (error) => new CommandError({ cause: error }), }); -const startDetachedQemu = ( +export const failIfVMRunning = (vm: VirtualMachine) => + Effect.gen(function* () { + if (vm.status === "RUNNING") { + return yield* Effect.fail( + new VmAlreadyRunningError({ name: vm.name }), + ); + } + return vm; + }); + +export const startDetachedQemu = ( name: string, vm: VirtualMachine, qemuArgs: string[], @@ -276,13 +291,23 @@ function mergeFlags(vm: VirtualMachine): VirtualMachine { const { flags } = parseFlags(Deno.args); return { ...vm, - memory: flags.memory ? String(flags.memory) : vm.memory, - cpus: flags.cpus ? Number(flags.cpus) : vm.cpus, - cpu: flags.cpu ? String(flags.cpu) : vm.cpu, + memory: (flags.memory || flags.m) + ? String(flags.memory || flags.m) + : vm.memory, + cpus: (flags.cpus || flags.C) ? Number(flags.cpus || flags.C) : vm.cpus, + cpu: (flags.cpu || flags.c) ? String(flags.cpu || flags.c) : vm.cpu, diskFormat: flags.diskFormat ? String(flags.diskFormat) : vm.diskFormat, - portForward: flags.portForward ? String(flags.portForward) : vm.portForward, - drivePath: flags.image ? String(flags.image) : vm.drivePath, - bridge: flags.bridge ? String(flags.bridge) : vm.bridge, - diskSize: flags.size ? String(flags.size) : vm.diskSize, + portForward: (flags.portForward || flags.p) + ? String(flags.portForward || flags.p) + : vm.portForward, + drivePath: (flags.image || flags.i) + ? String(flags.image || flags.i) + : vm.drivePath, + bridge: (flags.bridge || flags.b) + ? String(flags.bridge || flags.b) + : vm.bridge, + diskSize: (flags.size || flags.s) + ? String(flags.size || flags.s) + : vm.diskSize, }; } diff --git a/src/subcommands/stop.ts b/src/subcommands/stop.ts index e947133..c87b11b 100644 --- a/src/subcommands/stop.ts +++ b/src/subcommands/stop.ts @@ -4,20 +4,20 @@ import { Data, Effect, pipe } from "effect"; import type { VirtualMachine } from "../db.ts"; import { getInstanceState, updateInstanceState } from "../state.ts"; -class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ +export class VmNotFoundError extends Data.TaggedError("VmNotFoundError")<{ name: string; }> {} -class StopCommandError extends Data.TaggedError("StopCommandError")<{ +export class StopCommandError extends Data.TaggedError("StopCommandError")<{ vmName: string; exitCode: number; }> {} -class CommandError extends Data.TaggedError("CommandError")<{ +export class CommandError extends Data.TaggedError("CommandError")<{ cause?: unknown; }> {} -const findVm = (name: string) => +export const findVm = (name: string) => pipe( getInstanceState(name), Effect.flatMap((vm) => @@ -34,7 +34,7 @@ const logStopping = (vm: VirtualMachine) => ); }); -const killProcess = (vm: VirtualMachine) => +export const killProcess = (vm: VirtualMachine) => Effect.tryPromise({ try: async () => { const cmd = new Deno.Command(vm.bridge ? "sudo" : "kill", { @@ -63,7 +63,7 @@ const killProcess = (vm: VirtualMachine) => ), ); -const updateToStopped = (vm: VirtualMachine) => +export const updateToStopped = (vm: VirtualMachine) => pipe( updateInstanceState(vm.name, "STOPPED"), Effect.map(() => vm), diff --git a/src/types.ts b/src/types.ts index e036400..c3b8e41 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1 +1,36 @@ +import z from "@zod/zod"; + export type STATUS = "RUNNING" | "STOPPED"; + +export const MachineParamsSchema = z.object({ + portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), + cpu: z.string().optional(), + cpus: z.number().min(1).optional(), + memory: z.string().regex(/^\d+(M|G)$/).optional(), +}); + +export type MachineParams = z.infer; + +export const NewMachineSchema = MachineParamsSchema.extend({ + portForward: z.array(z.string().regex(/^\d+:\d+$/)).optional(), + cpu: z.string().default("host").optional(), + cpus: z.number().min(1).default(8).optional(), + memory: z.string().regex(/^\d+(M|G)$/).default("2G").optional(), + image: z.string().regex( + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, + ), + volume: z.string().optional(), + bridge: z.string().optional(), +}); + +export type NewMachine = z.infer; + +export const NewVolumeSchema = z.object({ + name: z.string(), + baseImage: z.string().regex( + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, + ), + size: z.string().regex(/^\d+(M|G|T)$/).optional(), +}); + +export type NewVolume = z.infer; -- 2.51.2 From a2f97aef3e0e9d7e045cdb8ec1028ec00cf9ee7d Mon Sep 17 00:00:00 2001 From: Tsiry Sandratraina Date: Sun, 16 Nov 2025 09:07:25 +0300 Subject: [PATCH 2/2] Fix CPU and memory handling in start machine endpoint; update volume creation logic to check for existence before creation --- src/api/machines.ts | 3 ++- src/volumes.ts | 49 +++++++++++++++++++++++---------------------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/src/api/machines.ts b/src/api/machines.ts index a3b158a..c49e7e8 100644 --- a/src/api/machines.ts +++ b/src/api/machines.ts @@ -144,7 +144,8 @@ app.post("/:id/start", (c) => const firmwareArgs = yield* setupFirmware(); const qemuArgs = yield* buildQemuArgs({ ...vm, - cpu: String(startRequest.cpus ?? vm.cpu), + cpu: String(startRequest.cpu ?? vm.cpu), + cpus: startRequest.cpus ?? vm.cpus, memory: startRequest.memory ?? vm.memory, portForward: startRequest.portForward ? startRequest.portForward.join(",") diff --git a/src/volumes.ts b/src/volumes.ts index ea15669..19e02e4 100644 --- a/src/volumes.ts +++ b/src/volumes.ts @@ -74,37 +74,38 @@ export const deleteVolume = ( export const createVolume = ( name: string, baseImage: Image, + size?: string, ): Effect.Effect => Effect.tryPromise({ try: async () => { const path = `${VOLUME_DIR}/${name}.qcow2`; - if ((await Deno.stat(path).catch(() => false))) { - throw new Error(`Volume with name ${name} already exists`); + if (!(await Deno.stat(path).catch(() => false))) { + await Deno.mkdir(VOLUME_DIR, { recursive: true }); + const qemu = new Deno.Command("qemu-img", { + args: [ + "create", + "-F", + "raw", + "-f", + "qcow2", + "-b", + baseImage.path, + path, + ...(size ? [size] : []), + ], + stdout: "inherit", + stderr: "inherit", + }) + .spawn(); + const status = await qemu.status; + if (!status.success) { + throw new Error( + `Failed to create volume: qemu-img exited with code ${status.code}`, + ); + } } - await Deno.mkdir(VOLUME_DIR, { recursive: true }); - const qemu = new Deno.Command("qemu-img", { - args: [ - "create", - "-F", - "raw", - "-f", - "qcow2", - "-b", - baseImage.path, - path, - ], - stdout: "inherit", - stderr: "inherit", - }) - .spawn(); - const status = await qemu.status; - if (!status.success) { - throw new Error( - `Failed to create volume: qemu-img exited with code ${status.code}`, - ); - } ctx.db.insertInto("volumes").values({ id: createId(), name,