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 { requireAuth } from "./modules/auth/auth.middleware";
|
||||||
import { driverController } from "./modules/driver/driver.controller";
|
import { driverController } from "./modules/driver/driver.controller";
|
||||||
import { startup } from "./modules/lifecycle/startup";
|
import { startup } from "./modules/lifecycle/startup";
|
||||||
|
import { repositoriesController } from "./modules/repositories/repositories.controller";
|
||||||
import { volumeController } from "./modules/volumes/volume.controller";
|
import { volumeController } from "./modules/volumes/volume.controller";
|
||||||
import { handleServiceError } from "./utils/errors";
|
import { handleServiceError } from "./utils/errors";
|
||||||
import { logger } from "./utils/logger";
|
import { logger } from "./utils/logger";
|
||||||
@@ -21,7 +22,7 @@ export const generalDescriptor = (app: Hono) =>
|
|||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
description: "API for managing volumes",
|
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" }))
|
.get("healthcheck", (c) => c.json({ status: "ok" }))
|
||||||
.route("/api/v1/auth", authController.basePath("/api/v1"))
|
.route("/api/v1/auth", authController.basePath("/api/v1"))
|
||||||
.route("/api/v1/volumes", volumeController.use(requireAuth))
|
.route("/api/v1/volumes", volumeController.use(requireAuth))
|
||||||
|
.route("/api/v1/repositories", repositoriesController.use(requireAuth))
|
||||||
.get("/assets/*", serveStatic({ root: "./assets/frontend" }))
|
.get("/assets/*", serveStatic({ root: "./assets/frontend" }))
|
||||||
.get("*", serveStatic({ path: "./assets/frontend/index.html" }));
|
.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 crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import type { RepositoryConfig } from "@ironmount/schemas";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import { RESTIC_PASS_FILE } from "../core/constants";
|
import { RESTIC_PASS_FILE } from "../core/constants";
|
||||||
@@ -34,14 +35,53 @@ const ensurePassfile = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const init = async (name: string) => {
|
const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||||
const res =
|
switch (config.backend) {
|
||||||
await $`restic init --repo /data/repositories/${name} --password-file /data/secrets/restic.pass --json`.nothrow();
|
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 buildEnv = (config: RepositoryConfig): Record<string, string> => {
|
||||||
const res =
|
const env: Record<string, string> = {};
|
||||||
await $`restic --repo /data/repositories/${repo} backup ${source} --password-file /data/secrets/restic.pass --json`.nothrow();
|
|
||||||
|
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) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic backup failed: ${res.stderr}`);
|
logger.error(`Restic backup failed: ${res.stderr}`);
|
||||||
|
|||||||
@@ -62,24 +62,6 @@ export const REPOSITORY_BACKENDS = {
|
|||||||
|
|
||||||
export type RepositoryBackend = keyof typeof 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({
|
export const s3RepositoryConfigSchema = type({
|
||||||
backend: "'s3'",
|
backend: "'s3'",
|
||||||
endpoint: "string",
|
endpoint: "string",
|
||||||
@@ -88,9 +70,7 @@ export const s3RepositoryConfigSchema = type({
|
|||||||
secretAccessKey: "string",
|
secretAccessKey: "string",
|
||||||
});
|
});
|
||||||
|
|
||||||
export const repositoryConfigSchema = localRepositoryConfigSchema
|
export const repositoryConfigSchema = s3RepositoryConfigSchema;
|
||||||
.or(sftpRepositoryConfigSchema)
|
|
||||||
.or(s3RepositoryConfigSchema);
|
|
||||||
|
|
||||||
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
export type RepositoryConfig = typeof repositoryConfigSchema.infer;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user