zerobyte/app/server/utils/restic/operations.ts
2026-01-14 08:23:34 +01:00

333 lines
8.9 KiB
TypeScript

import { type } from "arktype";
import { throttle } from "es-toolkit";
import type { CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic";
import { config as appConfig } from "../../core/config";
import { DEFAULT_EXCLUDES } from "../../core/constants";
import { ResticError } from "../errors";
import { logger } from "../logger";
import { safeSpawn } from "../spawn";
import { buildEnv, buildRepoUrl } from "./config";
import type { BackupProgress } from "./schemas";
import { backupOutputSchema, backupProgressSchema, restoreOutputSchema, snapshotInfoSchema } from "./schemas";
import { addCommonArgs, cleanupTempFile, cleanupTemporaryKeys, ensurePassfile, parseResticJsonOutput } from "./utils";
export const init = async (config: RepositoryConfig) => {
await ensurePassfile();
const repoUrl = buildRepoUrl(config);
logger.info(`Initializing restic repository at ${repoUrl}...`);
const env = await buildEnv(config);
const args = ["init", "--repo", repoUrl];
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
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 };
};
export const backup = async (
config: RepositoryConfig,
source: string,
options?: {
exclude?: string[];
excludeIfPresent?: string[];
include?: string[];
tags?: string[];
oneFileSystem?: boolean;
compressionMode?: CompressionMode;
signal?: AbortSignal;
onProgress?: (progress: BackupProgress) => void;
},
) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"];
if (options?.oneFileSystem) {
args.push("--one-file-system");
}
if (appConfig.resticHostname) {
args.push("--host", appConfig.resticHostname);
}
if (options?.tags && options.tags.length > 0) {
for (const tag of options.tags) {
args.push("--tag", tag);
}
}
let includeFile: string | null = null;
if (options?.include && options.include.length > 0) {
const { createTempFile } = await import("./utils");
includeFile = await createTempFile("zerobyte-restic-include-", options.include.join("\n"));
args.push("--files-from", includeFile);
} else {
args.push(source);
}
for (const exclude of DEFAULT_EXCLUDES) {
args.push("--exclude", exclude);
}
let excludeFile: string | null = null;
if (options?.exclude && options.exclude.length > 0) {
const { createTempFile } = await import("./utils");
excludeFile = await createTempFile("zerobyte-restic-exclude-", options.exclude.join("\n"));
args.push("--exclude-file", excludeFile);
}
if (options?.excludeIfPresent && options.excludeIfPresent.length > 0) {
for (const filename of options.excludeIfPresent) {
args.push("--exclude-if-present", filename);
}
}
addCommonArgs(args, env);
const logData = throttle((data: string) => {
logger.info(data.trim());
}, 5000);
const streamProgress = throttle((data: string) => {
if (options?.onProgress) {
try {
const jsonData = JSON.parse(data);
const progress = backupProgressSchema(jsonData);
if (!(progress instanceof type.errors)) {
options.onProgress(progress);
}
} catch (_) {
// Ignore JSON parse errors for non-JSON lines
}
}
}, 1000);
let stdout = "";
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({
command: "restic",
args,
env,
signal: options?.signal,
onStdout: (data) => {
stdout = data;
logData(data);
if (options?.onProgress) {
streamProgress(data);
}
},
finally: async () => {
await cleanupTempFile(includeFile);
await cleanupTempFile(excludeFile);
await cleanupTemporaryKeys(env);
},
});
if (options?.signal?.aborted) {
logger.warn("Restic backup was aborted by signal.");
return { result: null, exitCode: res.exitCode };
}
if (res.exitCode === 3) {
logger.error(`Restic backup encountered read errors: ${res.stderr}`);
}
if (res.exitCode !== 0 && res.exitCode !== 3) {
logger.error(`Restic backup failed: ${res.stderr}`);
logger.error(`Command executed: restic ${args.join(" ")}`);
throw new ResticError(res.exitCode, res.stderr);
}
const lastLine = (stdout || res.stdout).trim();
const result = parseResticJsonOutput(lastLine || "{}", backupOutputSchema, "Restic backup output");
if (!result) {
return { result: null, exitCode: res.exitCode };
}
return { result, exitCode: res.exitCode };
};
export const restore = async (
config: RepositoryConfig,
snapshotId: string,
target: string,
options?: {
include?: string[];
exclude?: string[];
excludeXattr?: string[];
delete?: boolean;
overwrite?: OverwriteMode;
},
) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
if (options?.overwrite) {
args.push("--overwrite", options.overwrite);
}
if (options?.delete) {
args.push("--delete");
}
if (options?.include?.length) {
for (const pattern of options.include) {
args.push("--include", pattern);
}
}
if (options?.exclude && options.exclude.length > 0) {
for (const pattern of options.exclude) {
args.push("--exclude", pattern);
}
}
if (options?.excludeXattr && options.excludeXattr.length > 0) {
for (const xattr of options.excludeXattr) {
args.push("--exclude-xattr", xattr);
}
}
addCommonArgs(args, env);
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr);
}
const outputLines = res.stdout.trim().split("\n");
const lastLine = outputLines[outputLines.length - 1];
const defaultResult = {
message_type: "summary" as const,
total_files: 0,
files_restored: 0,
files_skipped: 0,
bytes_skipped: 0,
};
if (!lastLine) {
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
return defaultResult;
}
logger.debug(`Restic restore output last line: ${lastLine}`);
const result = parseResticJsonOutput(lastLine, restoreOutputSchema, "Restic restore output");
if (!result) {
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
return defaultResult;
}
logger.info(
`Restic restore completed for snapshot ${snapshotId} to target ${target}: ${result.files_restored} restored, ${result.files_skipped} skipped`,
);
return result;
};
export const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => {
const { tags } = options;
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args = ["--repo", repoUrl, "snapshots"];
if (tags && tags.length > 0) {
for (const tag of tags) {
args.push("--tag", tag);
}
}
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
throw new Error(`Restic snapshots retrieval failed: ${res.stderr}`);
}
const result = snapshotInfoSchema.array()(JSON.parse(res.stdout));
if (result instanceof type.errors) {
logger.error(`Restic snapshots output validation failed: ${result.summary}`);
throw new Error(`Restic snapshots output validation failed: ${result.summary}`);
}
return result;
};
export const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
if (path) {
args.push(path);
}
addCommonArgs(args, env);
const res = await safeSpawn({ command: "restic", args, env });
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr);
}
const stdout = res.stdout;
const lines = stdout
.trim()
.split("\n")
.filter((line) => line.trim());
if (lines.length === 0) {
return { snapshot: null, nodes: [] };
}
const { lsSnapshotInfoSchema, lsNodeSchema } = await import("./schemas");
// First line is snapshot info
const snapshot = parseResticJsonOutput(lines[0] ?? "{}", lsSnapshotInfoSchema, "Restic ls snapshot info");
if (!snapshot) {
throw new Error("Restic ls snapshot info validation failed");
}
// Parse remaining lines as nodes
const nodes: Array<typeof lsNodeSchema.infer> = [];
for (let i = 1; i < lines.length; i++) {
const node = parseResticJsonOutput(lines[i] ?? "{}", lsNodeSchema, "Restic ls node");
if (node) {
nodes.push(node);
}
}
return { snapshot, nodes };
};