feat: repositories controller and service for crd

This commit is contained in:
Nicolas Meienberger
2025-10-18 14:23:42 +02:00
parent ad54948a69
commit 6e8aa4b465
6 changed files with 332 additions and 28 deletions

View File

@@ -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" }));

View File

@@ -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);
});

View File

@@ -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),
},
},
},
},
});

View File

@@ -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,
};

View File

@@ -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<string, string> => {
const env: Record<string, string> = {};
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}`);

View File

@@ -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;