mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat: repositories controller and service for crd
This commit is contained in:
@@ -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" }));
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
124
apps/server/src/modules/repositories/repositories.dto.ts
Normal file
124
apps/server/src/modules/repositories/repositories.dto.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user