diff --git a/Dockerfile b/Dockerfile index ded952f..5160ce0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ ARG BUN_VERSION="1.3.0" FROM oven/bun:${BUN_VERSION}-alpine AS runner_base -RUN apk add --no-cache davfs2=1.6.1-r2 +RUN apk add --no-cache davfs2 restic # ------------------------------ # DEVELOPMENT diff --git a/apps/server/src/core/constants.ts b/apps/server/src/core/constants.ts index 4cde680..4ffc226 100644 --- a/apps/server/src/core/constants.ts +++ b/apps/server/src/core/constants.ts @@ -1,3 +1,4 @@ export const OPERATION_TIMEOUT = 5000; export const VOLUME_MOUNT_BASE = "/var/lib/docker/volumes/ironmount"; export const DATABASE_URL = "/data/ironmount.db"; +export const RESTIC_PASS_FILE = "/data/secrets/restic.pass"; diff --git a/apps/server/src/db/schema.ts b/apps/server/src/db/schema.ts index fe53132..51b70c1 100644 --- a/apps/server/src/db/schema.ts +++ b/apps/server/src/db/schema.ts @@ -38,3 +38,7 @@ export const sessionsTable = sqliteTable("sessions_table", { }); export type Session = typeof sessionsTable.$inferSelect; + +export const repositoriesTable = sqliteTable("repositories_table", { + id: text().primaryKey(), +}); diff --git a/apps/server/src/modules/lifecycle/startup.ts b/apps/server/src/modules/lifecycle/startup.ts index 87c5489..d437c42 100644 --- a/apps/server/src/modules/lifecycle/startup.ts +++ b/apps/server/src/modules/lifecycle/startup.ts @@ -4,8 +4,11 @@ import { db } from "../../db/db"; import { volumesTable } from "../../db/schema"; import { logger } from "../../utils/logger"; import { volumeService } from "../volumes/volume.service"; +import { restic } from "../../utils/restic"; export const startup = async () => { + await restic.ensurePassfile(); + const volumes = await db.query.volumesTable.findMany({ where: or( eq(volumesTable.status, "mounted"), diff --git a/apps/server/src/modules/repositories/repositories.controller.ts b/apps/server/src/modules/repositories/repositories.controller.ts new file mode 100644 index 0000000..e69de29 diff --git a/apps/server/src/modules/repositories/repositories.service.ts b/apps/server/src/modules/repositories/repositories.service.ts new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/server/src/modules/repositories/repositories.service.ts @@ -0,0 +1 @@ + diff --git a/apps/server/src/utils/restic.ts b/apps/server/src/utils/restic.ts new file mode 100644 index 0000000..ab32bcb --- /dev/null +++ b/apps/server/src/utils/restic.ts @@ -0,0 +1,65 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { type } from "arktype"; +import { $ } from "bun"; +import { RESTIC_PASS_FILE } from "../core/constants"; +import { logger } from "./logger"; + +const backupOutputSchema = type({ + message_type: "'summary'", + files_new: "number", + files_changed: "number", + files_unmodified: "number", + dirs_new: "number", + dirs_changed: "number", + dirs_unmodified: "number", + data_blobs: "number", + tree_blobs: "number", + data_added: "number", + total_files_processed: "number", + total_bytes_processed: "number", + total_duration: "number", + snapshot_id: "string", +}); + +const ensurePassfile = async () => { + await fs.mkdir(path.dirname(RESTIC_PASS_FILE), { recursive: true }); + + try { + await fs.access(RESTIC_PASS_FILE); + } catch { + logger.info("Restic passfile not found, creating a new one..."); + await fs.writeFile(RESTIC_PASS_FILE, crypto.randomBytes(32).toString("hex"), { mode: 0o600 }); + } +}; + +const init = async (name: string) => { + const res = + await $`restic init --repo /data/repositories/${name} --password-file /data/secrets/restic.pass --json`.nothrow(); +}; + +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(); + + if (res.exitCode !== 0) { + logger.error(`Restic backup failed: ${res.stderr}`); + throw new Error(`Restic backup failed: ${res.stderr}`); + } + + const result = backupOutputSchema(res.json()); + + if (result instanceof type.errors) { + logger.error(`Restic backup output validation failed: ${result}`); + throw new Error(`Restic backup output validation failed: ${result}`); + } + + return result; +}; + +export const restic = { + ensurePassfile, + init, + backup, +};