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

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