From 6e8aa4b465841605e386fa7914f4b90f0db2f7e9 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Sat, 18 Oct 2025 14:23:42 +0200 Subject: [PATCH] feat: repositories controller and service for crd --- apps/server/src/index.ts | 4 +- .../repositories/repositories.controller.ts | 55 ++++++++ .../modules/repositories/repositories.dto.ts | 124 ++++++++++++++++++ .../repositories/repositories.service.ts | 103 +++++++++++++++ apps/server/src/utils/restic.ts | 52 +++++++- packages/schemas/src/index.ts | 22 +--- 6 files changed, 332 insertions(+), 28 deletions(-) create mode 100644 apps/server/src/modules/repositories/repositories.dto.ts diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index d5fcb49..2543f30 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -9,6 +9,7 @@ import { authController } from "./modules/auth/auth.controller"; import { requireAuth } from "./modules/auth/auth.middleware"; import { driverController } from "./modules/driver/driver.controller"; import { startup } from "./modules/lifecycle/startup"; +import { repositoriesController } from "./modules/repositories/repositories.controller"; import { volumeController } from "./modules/volumes/volume.controller"; import { handleServiceError } from "./utils/errors"; import { logger } from "./utils/logger"; @@ -21,7 +22,7 @@ export const generalDescriptor = (app: Hono) => version: "1.0.0", description: "API for managing volumes", }, - servers: [{ url: "http://localhost:4096", description: "Development Server" }], + servers: [{ url: "http://192.168.2.42:4096", description: "Development Server" }], }, }); @@ -37,6 +38,7 @@ const app = new Hono() .get("healthcheck", (c) => c.json({ status: "ok" })) .route("/api/v1/auth", authController.basePath("/api/v1")) .route("/api/v1/volumes", volumeController.use(requireAuth)) + .route("/api/v1/repositories", repositoriesController.use(requireAuth)) .get("/assets/*", serveStatic({ root: "./assets/frontend" })) .get("*", serveStatic({ path: "./assets/frontend/index.html" })); diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts index e69de29..2f8acde 100644 --- a/apps/server/src/modules/repositories/repositories.controller.ts +++ b/apps/server/src/modules/repositories/repositories.controller.ts @@ -0,0 +1,55 @@ +import { Hono } from "hono"; +import { validator } from "hono-openapi"; +import { + createRepositoryBody, + createRepositoryDto, + deleteRepositoryDto, + getRepositoryDto, + type GetRepositoryResponseDto, + type ListRepositoriesResponseDto, + listRepositoriesDto, +} from "./repositories.dto"; +import { repositoriesService } from "./repositories.service"; + +export const repositoriesController = new Hono() + .get("/", listRepositoriesDto, async (c) => { + const repositories = await repositoriesService.listRepositories(); + + const response = { + repositories: repositories.map((repository) => ({ + ...repository, + updatedAt: repository.updatedAt.getTime(), + createdAt: repository.createdAt.getTime(), + lastChecked: repository.lastChecked?.getTime() ?? null, + })), + } satisfies ListRepositoriesResponseDto; + + return c.json(response, 200); + }) + .post("/", createRepositoryDto, validator("json", createRepositoryBody), async (c) => { + const body = c.req.valid("json"); + const res = await repositoriesService.createRepository(body.name, body.config, body.compressionMode); + + return c.json({ message: "Repository created", repository: res.repository }, 201); + }) + .get("/:name", getRepositoryDto, async (c) => { + const { name } = c.req.param(); + const res = await repositoriesService.getRepository(name); + + const response = { + repository: { + ...res.repository, + createdAt: res.repository.createdAt.getTime(), + updatedAt: res.repository.updatedAt.getTime(), + lastChecked: res.repository.lastChecked?.getTime() ?? null, + }, + } satisfies GetRepositoryResponseDto; + + return c.json(response, 200); + }) + .delete("/:name", deleteRepositoryDto, async (c) => { + const { name } = c.req.param(); + await repositoriesService.deleteRepository(name); + + return c.json({ message: "Repository deleted" }, 200); + }); diff --git a/apps/server/src/modules/repositories/repositories.dto.ts b/apps/server/src/modules/repositories/repositories.dto.ts new file mode 100644 index 0000000..e9fdeda --- /dev/null +++ b/apps/server/src/modules/repositories/repositories.dto.ts @@ -0,0 +1,124 @@ +import { repositoryConfigSchema } from "@ironmount/schemas"; +import { type } from "arktype"; +import { describeRoute, resolver } from "hono-openapi"; + +const repositorySchema = type({ + id: "string", + name: "string", + backend: type.enumerated("local", "sftp", "s3"), + config: repositoryConfigSchema, + compressionMode: type.enumerated("off", "auto", "fastest", "better", "max").or("null"), + status: type.enumerated("healthy", "error", "unknown").or("null"), + lastChecked: "number | null", + lastError: "string | null", + createdAt: "number", + updatedAt: "number", +}); + +export type RepositoryDto = typeof repositorySchema.infer; + +/** + * List all repositories + */ +export const listRepositoriesResponse = type({ + repositories: repositorySchema.array(), +}); +export type ListRepositoriesResponseDto = typeof listRepositoriesResponse.infer; + +export const listRepositoriesDto = describeRoute({ + description: "List all repositories", + tags: ["Repositories"], + operationId: "listRepositories", + responses: { + 200: { + description: "List of repositories", + content: { + "application/json": { + schema: resolver(listRepositoriesResponse), + }, + }, + }, + }, +}); + +/** + * Create a new repository + */ +export const createRepositoryBody = type({ + name: "string", + config: repositoryConfigSchema, + "compressionMode?": type.enumerated("off", "auto", "fastest", "better", "max"), +}); + +export type CreateRepositoryBody = typeof createRepositoryBody.infer; + +export const createRepositoryResponse = type({ + message: "string", + repository: type({ + id: "string", + name: "string", + }), +}); + +export const createRepositoryDto = describeRoute({ + description: "Create a new restic repository", + operationId: "createRepository", + tags: ["Repositories"], + responses: { + 201: { + description: "Repository created successfully", + content: { + "application/json": { + schema: resolver(createRepositoryResponse), + }, + }, + }, + }, +}); + +/** + * Get a single repository + */ +export const getRepositoryResponse = type({ + repository: repositorySchema, +}); +export type GetRepositoryResponseDto = typeof getRepositoryResponse.infer; + +export const getRepositoryDto = describeRoute({ + description: "Get a single repository by name", + tags: ["Repositories"], + operationId: "getRepository", + responses: { + 200: { + description: "Repository details", + content: { + "application/json": { + schema: resolver(getRepositoryResponse), + }, + }, + }, + }, +}); + +/** + * Delete a repository + */ +export const deleteRepositoryResponse = type({ + message: "string", +}); + +export const deleteRepositoryDto = describeRoute({ + description: "Delete a repository", + tags: ["Repositories"], + operationId: "deleteRepository", + responses: { + 200: { + description: "Repository deleted successfully", + content: { + "application/json": { + schema: resolver(deleteRepositoryResponse), + }, + }, + }, + }, +}); diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts index 8b13789..e9f8a70 100644 --- a/apps/server/src/modules/repositories/repositories.service.ts +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -1 +1,104 @@ +import crypto from "node:crypto"; +import type { CompressionMode, RepositoryConfig } from "@ironmount/schemas"; +import { eq } from "drizzle-orm"; +import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; +import slugify from "slugify"; +import { db } from "../../db/db"; +import { repositoriesTable } from "../../db/schema"; +import { toMessage } from "../../utils/errors"; +import { restic } from "../../utils/restic"; +const listRepositories = async () => { + const repositories = await db.query.repositoriesTable.findMany({}); + return repositories; +}; + +const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => { + const slug = slugify(name, { lower: true, strict: true }); + + const existing = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.name, slug), + }); + + if (existing) { + throw new ConflictError("Repository with this name already exists"); + } + + const id = crypto.randomUUID(); + + const [created] = await db + .insert(repositoriesTable) + .values({ + id, + name: slug, + backend: config.backend, + config, + compressionMode: compressionMode ?? "auto", + status: "unknown", + }) + .returning(); + + if (!created) { + throw new InternalServerError("Failed to create repository"); + } + + const { success, error } = await restic.init(config); + + if (success) { + await db + .update(repositoriesTable) + .set({ + status: "healthy", + lastChecked: new Date(), + lastError: null, + }) + .where(eq(repositoriesTable.id, id)); + + return { repository: created, status: 201 }; + } + + const errorMessage = toMessage(error); + await db + .update(repositoriesTable) + .set({ + status: "error", + lastError: errorMessage, + lastChecked: new Date(), + }) + .where(eq(repositoriesTable.id, id)); + + throw new InternalServerError(`Failed to initialize repository: ${errorMessage}`); +}; + +const getRepository = async (name: string) => { + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.name, name), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + return { repository }; +}; + +const deleteRepository = async (name: string) => { + const repository = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.name, name), + }); + + if (!repository) { + throw new NotFoundError("Repository not found"); + } + + // TODO: Add cleanup logic for the actual restic repository files + + await db.delete(repositoriesTable).where(eq(repositoriesTable.name, name)); +}; + +export const repositoriesService = { + listRepositories, + createRepository, + getRepository, + deleteRepository, +}; diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts index ab32bcb..cb24402 100644 --- a/apps/server/src/utils/restic.ts +++ b/apps/server/src/utils/restic.ts @@ -1,6 +1,7 @@ import crypto from "node:crypto"; import fs from "node:fs/promises"; import path from "node:path"; +import type { RepositoryConfig } from "@ironmount/schemas"; import { type } from "arktype"; import { $ } from "bun"; import { RESTIC_PASS_FILE } from "../core/constants"; @@ -34,14 +35,53 @@ const ensurePassfile = async () => { } }; -const init = async (name: string) => { - const res = - await $`restic init --repo /data/repositories/${name} --password-file /data/secrets/restic.pass --json`.nothrow(); +const buildRepoUrl = (config: RepositoryConfig): string => { + switch (config.backend) { + case "s3": + return `s3:${config.endpoint}/${config.bucket}`; + default: { + throw new Error(`Unsupported repository backend: ${JSON.stringify(config)}`); + } + } }; -const backup = async (repo: string, source: string) => { - const res = - await $`restic --repo /data/repositories/${repo} backup ${source} --password-file /data/secrets/restic.pass --json`.nothrow(); +const buildEnv = (config: RepositoryConfig): Record => { + const env: Record = {}; + + switch (config.backend) { + case "s3": + env.AWS_ACCESS_KEY_ID = config.accessKeyId; + env.AWS_SECRET_ACCESS_KEY = config.secretAccessKey; + break; + } + + return env; +}; + +const init = async (config: RepositoryConfig) => { + await ensurePassfile(); + + const repoUrl = buildRepoUrl(config); + const env = buildEnv(config); + + const res = await $`restic init --repo ${repoUrl} --password-file ${RESTIC_PASS_FILE} --json`.env(env).nothrow(); + + if (res.exitCode !== 0) { + logger.error(`Restic init failed: ${res.stderr}`); + return { success: false, error: res.stderr }; + } + + logger.info(`Restic repository initialized: ${repoUrl}`); + return { success: true, error: null }; +}; + +const backup = async (config: RepositoryConfig, source: string) => { + const repoUrl = buildRepoUrl(config); + const env = buildEnv(config); + + const res = await $`restic --repo ${repoUrl} backup ${source} --password-file /data/secrets/restic.pass --json` + .env(env) + .nothrow(); if (res.exitCode !== 0) { logger.error(`Restic backup failed: ${res.stderr}`); diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts index 3d647c5..99a5fa5 100644 --- a/packages/schemas/src/index.ts +++ b/packages/schemas/src/index.ts @@ -62,24 +62,6 @@ export const REPOSITORY_BACKENDS = { export type RepositoryBackend = keyof typeof REPOSITORY_BACKENDS; -export const localRepositoryConfigSchema = type({ - backend: "'local'", - path: "string", - password: "string", -}); - -export const sftpRepositoryConfigSchema = type({ - backend: "'sftp'", - host: "string", - user: "string", - port: type("string.integer").or(type("number")).to("1 <= number <= 65535").default(22), - path: "string", - sftpPassword: "string?", - sftpPrivateKey: "string?", - sftpCommand: "string?", - sftpArgs: "string?", -}); - export const s3RepositoryConfigSchema = type({ backend: "'s3'", endpoint: "string", @@ -88,9 +70,7 @@ export const s3RepositoryConfigSchema = type({ secretAccessKey: "string", }); -export const repositoryConfigSchema = localRepositoryConfigSchema - .or(sftpRepositoryConfigSchema) - .or(s3RepositoryConfigSchema); +export const repositoryConfigSchema = s3RepositoryConfigSchema; export type RepositoryConfig = typeof repositoryConfigSchema.infer;