diff --git a/src/api/machines.ts b/src/api/machines.ts index 479f1e4..44ad489 100644 --- a/src/api/machines.ts +++ b/src/api/machines.ts @@ -1,7 +1,9 @@ +import _ from "@es-toolkit/es-toolkit/compat"; import { createId } from "@paralleldrive/cuid2"; import { Data, Effect, pipe } from "effect"; import { Hono } from "hono"; import Moniker from "moniker"; +import { SEED_DIR } from "../constants.ts"; import { getImage } from "../images.ts"; import { DEFAULT_VERSION, getInstanceState } from "../mod.ts"; import { generateRandomMacAddress } from "../network.ts"; @@ -20,6 +22,7 @@ import { import { findVm, killProcess, updateToStopped } from "../subcommands/stop.ts"; import type { NewMachine } from "../types.ts"; import { getVolume } from "../volumes.ts"; +import { createSeedIso } from "../xorriso.ts"; import { createVolumeIfNeeded, handleError, @@ -35,7 +38,7 @@ export class ImageNotFoundError extends Data.TaggedError("ImageNotFoundError")<{ }> {} export class RemoveRunningVmError extends Data.TaggedError( - "RemoveRunningVmError", + "RemoveRunningVmError" )<{ id: string; }> {} @@ -49,9 +52,10 @@ app.get("/", (c) => Effect.flatMap((params) => listInstances(params.all === "true" || params.all === "1") ), - presentation(c), - ), - )); + presentation(c) + ) + ) +); app.post("/", (c) => Effect.runPromise( @@ -62,7 +66,7 @@ app.post("/", (c) => const image = yield* getImage(params.image); if (!image) { return yield* Effect.fail( - new ImageNotFoundError({ id: params.image }), + new ImageNotFoundError({ id: params.image }) ); } @@ -70,25 +74,59 @@ app.post("/", (c) => ? yield* createVolumeIfNeeded(image, params.volume) : undefined; + const name = Moniker.choose(); + if (params.users) { + const [tempDir] = yield* Effect.promise(() => + Promise.all([ + Deno.makeTempDir(), + Deno.mkdir(SEED_DIR, { recursive: true }), + ]) + ); + yield* createSeedIso( + `${SEED_DIR}/seed-${name}.iso`, + { + metaData: { + instanceId: params.instanceId || name, + localHostname: params.localHostname || name, + hostname: params.hostname || name, + }, + userData: { + users: params.users.map((user) => ({ + name: user.name, + shell: user.shell, + sudo: user.sudo, + sshAuthorizedKeys: user.sshAuthorizedKeys || [], + })), + sshPwauth: false, + }, + }, + tempDir + ); + } + const macAddress = yield* generateRandomMacAddress(); const id = createId(); yield* saveInstanceState({ id, - name: Moniker.choose(), + name, bridge: params.bridge, macAddress, memory: params.memory || "2G", cpus: params.cpus || 8, cpu: params.cpu || "host", diskSize: "20G", - diskFormat: volume ? "qcow2" : "raw", + diskFormat: volume ? "qcow2" : image.format, portForward: params.portForward ? params.portForward.join(",") : undefined, drivePath: volume ? volume.path : image.path, version: image.tag ?? DEFAULT_VERSION, status: "STOPPED", - seed: params.seed, + seed: _.get( + params, + "seed", + params.users ? `${SEED_DIR}/seed-${name}.iso` : undefined + ), pid: 0, }); @@ -97,18 +135,20 @@ app.post("/", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)), - ), - )); + Effect.catchAll((error) => handleError(error, c)) + ) + ) +); app.get("/:id", (c) => Effect.runPromise( pipe( parseParams(c), Effect.flatMap(({ id }) => getInstanceState(id)), - presentation(c), - ), - )); + presentation(c) + ) + ) +); app.delete("/:id", (c) => Effect.runPromise( @@ -127,9 +167,10 @@ app.delete("/:id", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)), - ), - )); + Effect.catchAll((error) => handleError(error, c)) + ) + ) +); app.post("/:id/start", (c) => Effect.runPromise( @@ -154,7 +195,7 @@ app.post("/:id/start", (c) => ? startRequest.portForward.join(",") : vm.portForward, }, - firmwareArgs, + firmwareArgs ); yield* createLogsDir(); yield* startDetachedQemu(vm.id, vm, qemuArgs); @@ -162,9 +203,10 @@ app.post("/:id/start", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)), - ), - )); + Effect.catchAll((error) => handleError(error, c)) + ) + ) +); app.post("/:id/stop", (c) => Effect.runPromise( @@ -174,9 +216,10 @@ app.post("/:id/stop", (c) => Effect.flatMap(killProcess), Effect.flatMap(updateToStopped), presentation(c), - Effect.catchAll((error) => handleError(error, c)), - ), - )); + Effect.catchAll((error) => handleError(error, c)) + ) + ) +); app.post("/:id/restart", (c) => Effect.runPromise( @@ -203,7 +246,7 @@ app.post("/:id/restart", (c) => ? startRequest.portForward.join(",") : vm.portForward, }, - firmwareArgs, + firmwareArgs ); yield* createLogsDir(); yield* startDetachedQemu(vm.id, vm, qemuArgs); @@ -211,8 +254,9 @@ app.post("/:id/restart", (c) => }) ), presentation(c), - Effect.catchAll((error) => handleError(error, c)), - ), - )); + Effect.catchAll((error) => handleError(error, c)) + ) + ) +); export default app; diff --git a/src/api/utils.ts b/src/api/utils.ts index c7466a7..778848c 100644 --- a/src/api/utils.ts +++ b/src/api/utils.ts @@ -1,18 +1,19 @@ import { Data, Effect } from "effect"; import type { Context } from "hono"; +import type { Image, Volume } from "../db.ts"; +import { VmAlreadyRunningError } from "../subcommands/start.ts"; 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 { FileSystemError, XorrisoError } from "../xorriso.ts"; import { ImageNotFoundError, RemoveRunningVmError } from "./machines.ts"; export const parseQueryParams = (c: Context) => Effect.succeed(c.req.query()); @@ -36,24 +37,22 @@ export const handleError = ( | VmAlreadyRunningError | ImageNotFoundError | RemoveRunningVmError + | FileSystemError + | XorrisoError | Error, - c: Context, + c: Context ) => Effect.sync(() => { if (error instanceof VmNotFoundError) { - return c.json( - { message: "VM not found", code: "VM_NOT_FOUND" }, - 404, - ); + 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}`, + message: error.message || `Failed to stop VM ${error.vmName}`, code: "STOP_COMMAND_ERROR", }, - 500, + 500 ); } @@ -63,7 +62,7 @@ export const handleError = ( message: error.message || "Failed to parse request body", code: "PARSE_BODY_ERROR", }, - 400, + 400 ); } @@ -73,7 +72,7 @@ export const handleError = ( message: `VM ${error.name} is already running`, code: "VM_ALREADY_RUNNING", }, - 400, + 400 ); } @@ -83,24 +82,23 @@ export const handleError = ( message: `Image ${error.id} not found`, code: "IMAGE_NOT_FOUND", }, - 404, + 404 ); } if (error instanceof RemoveRunningVmError) { return c.json( { - message: - `Cannot remove running VM with ID ${error.id}. Please stop it first.`, + message: `Cannot remove running VM with ID ${error.id}. Please stop it first.`, code: "REMOVE_RUNNING_VM_ERROR", }, - 400, + 400 ); } return c.json( { message: error instanceof Error ? error.message : String(error) }, - 500, + 500 ); }); @@ -133,7 +131,7 @@ export const parseCreateMachineRequest = (c: Context) => export const createVolumeIfNeeded = ( image: Image, volumeName: string, - size?: string, + size?: string ): Effect.Effect => Effect.gen(function* () { const volume = yield* getVolume(volumeName); diff --git a/src/constants.ts b/src/constants.ts index 8b41622..79d5f8a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -5,6 +5,7 @@ export const EMPTY_DISK_THRESHOLD_KB: number = 100; export const CONFIG_FILE_NAME: string = "vmconfig.toml"; export const IMAGE_DIR: string = `${CONFIG_DIR}/images`; export const VOLUME_DIR: string = `${CONFIG_DIR}/volumes`; +export const SEED_DIR: string = `${CONFIG_DIR}/seeds`; export const UBUNTU_ISO_URL: string = Deno.build.arch === "aarch64" ? "https://cdimage.ubuntu.com/releases/24.04/release/ubuntu-24.04.3-live-server-arm64.iso" diff --git a/src/types.ts b/src/types.ts index 586dde2..989f874 100644 --- a/src/types.ts +++ b/src/types.ts @@ -15,22 +15,67 @@ export const MachineParamsSchema = z.object({ 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(), + portForward: z + .array( + z + .string() + .trim() + .regex(/^\d+:\d+$/) + ) + .optional(), + cpu: z.string().trim().default("host").optional(), cpus: z.number().min(1).default(8).optional(), memory: z .string() + .trim() .regex(/^\d+(M|G)$/) .default("2G") .optional(), image: z .string() + .trim() .regex( - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/ ), - volume: z.string().optional(), - bridge: z.string().optional(), - seed: z.string().optional(), + volume: z.string().trim().optional(), + bridge: z.string().trim().optional(), + seed: z.string().trim().optional(), + users: z + .array( + z.object({ + name: z + .string() + .regex(/^[a-zA-Z0-9_-]+$/) + .trim() + .min(1), + shell: z + .string() + .regex( + /^\/(usr\/bin|bin|usr\/local\/bin|usr\/pkg\/bin)\/[a-zA-Z0-9_-]+$/ + ) + .trim() + .default("/bin/bash") + .optional(), + sudo: z + .array(z.string()) + .optional() + .default(["ALL=(ALL) NOPASSWD:ALL"]), + sshAuthorizedKeys: z + .array( + z + .string() + .regex( + /^(ssh-(rsa|ed25519|dss|ecdsa) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?|ecdsa-sha2-nistp(256|384|521) AAAA[0-9A-Za-z+/]+[=]{0,3}( [^\n\r]*)?)$/ + ) + .trim() + ) + .min(1), + }) + ) + .optional(), + instanceId: z.string().trim().optional(), + localHostname: z.string().trim().optional(), + hostname: z.string().trim().optional(), }); export type NewMachine = z.infer; @@ -40,7 +85,7 @@ export const NewVolumeSchema = z.object({ baseImage: z .string() .regex( - /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/, + /^([a-zA-Z0-9\-\.]+\/)?([a-zA-Z0-9\-\.]+\/)?[a-zA-Z0-9\-\.]+(:[\w\.\-]+)?$/ ), size: z .string() diff --git a/src/xorriso.ts b/src/xorriso.ts index f75d86c..fc8f805 100644 --- a/src/xorriso.ts +++ b/src/xorriso.ts @@ -7,6 +7,7 @@ export type Seed = { metaData: { instanceId: string; localHostname: string; + hostname?: string; }; userData: { users: Array<{ @@ -38,7 +39,7 @@ export const snakeCase = (obj: unknown): unknown => { Object.entries(obj).map(([key, value]) => [ _.snakeCase(key), snakeCase(value), - ]), + ]) ); } return obj; @@ -49,35 +50,33 @@ const createSeedDirectory = Effect.tryPromise({ catch: (error) => new FileSystemError(error), }); -const writeMetaData = (seed: Seed) => +const writeMetaData = (seed: Seed, outputPath: string) => Effect.tryPromise({ try: () => Deno.writeTextFile( - "seed/meta-data", + outputPath, stringify(snakeCase(seed.metaData), { flowLevel: -1, lineWidth: -1, - }), + }) ), catch: (error) => new FileSystemError(error), }); -const writeUserData = (seed: Seed) => +const writeUserData = (seed: Seed, outputPath: string) => Effect.tryPromise({ try: () => Deno.writeTextFile( - "seed/user-data", - `#cloud-config\n${ - stringify(snakeCase(seed.userData), { - flowLevel: -1, - lineWidth: -1, - }) - }`, + outputPath, + `#cloud-config\n${stringify(snakeCase(seed.userData), { + flowLevel: -1, + lineWidth: -1, + })}` ), catch: (error) => new FileSystemError(error), }); -const runXorriso = (outputPath: string) => +const runXorriso = (outputPath: string, seedDir: string) => Effect.tryPromise({ try: async () => { const xorriso = new Deno.Command("xorriso", { @@ -90,7 +89,7 @@ const runXorriso = (outputPath: string) => "cidata", "-J", "-R", - "seed", + seedDir, ], stdout: "inherit", stderr: "inherit", @@ -101,11 +100,9 @@ const runXorriso = (outputPath: string) => if (!status.success) { throw new XorrisoError( status.code, - `xorriso failed with code ${status.code}. Please ensure ${ - chalk.green( - "xorriso", - ) - } is installed and accessible in your PATH.`, + `xorriso failed with code ${status.code}. Please ensure ${chalk.green( + "xorriso" + )} is installed and accessible in your PATH.` ); } @@ -117,12 +114,12 @@ const runXorriso = (outputPath: string) => null, `Unexpected error: ${ error instanceof Error ? error.message : String(error) - }`, + }` ); }, }); -const runGenisoimage = (outputPath: string) => +const runGenisoimage = (outputPath: string, seedDir: string) => Effect.tryPromise({ try: async () => { const genisoimage = new Deno.Command("genisoimage", { @@ -133,7 +130,7 @@ const runGenisoimage = (outputPath: string) => "cidata", "-joliet", "-rock", - "seed", + seedDir, ], stdout: "inherit", stderr: "inherit", @@ -144,11 +141,11 @@ const runGenisoimage = (outputPath: string) => if (!status.success) { throw new XorrisoError( status.code, - `genisoimage failed with code ${status.code}. Please ensure ${ - chalk.green( - "genisoimage", - ) - } is installed and accessible in your PATH.`, + `genisoimage failed with code ${ + status.code + }. Please ensure ${chalk.green( + "genisoimage" + )} is installed and accessible in your PATH.` ); } @@ -160,22 +157,30 @@ const runGenisoimage = (outputPath: string) => null, `Unexpected error: ${ error instanceof Error ? error.message : String(error) - }`, + }` ); }, }); -export const createSeedIso = (outputPath: string, seed: Seed) => +export const createSeedIso = ( + outputPath: string, + seed: Seed, + seedDir: string = "seed" +) => pipe( createSeedDirectory, Effect.flatMap(() => - Effect.all([writeMetaData(seed), writeUserData(seed)]) - ), - Effect.flatMap(() => Deno.build.os === "linux" - ? runGenisoimage(outputPath) - : runXorriso(outputPath) + Effect.all([ + writeMetaData(seed, `${seedDir}/meta-data`), + writeUserData(seed, `${seedDir}/user-data`), + ]) ), + Effect.flatMap(() => + Deno.build.os === "linux" + ? runGenisoimage(outputPath, seedDir) + : runXorriso(outputPath, seedDir) + ) ); -export default (outputPath: string, seed: Seed) => - Effect.runPromise(createSeedIso(outputPath, seed)); +export default (outputPath: string, seed: Seed, seedDir: string = "seed") => + Effect.runPromise(createSeedIso(outputPath, seed, seedDir));