zerobyte/packages/core/src/restic/commands/backup.ts

246 lines
7.2 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { Data, Effect } from "effect";
import { throttle } from "es-toolkit";
import type { CompressionMode, RepositoryConfig } from "../schemas";
import { type ResticBackupProgressDto, resticBackupOutputSchema, resticBackupProgressSchema } from "../restic-dto";
import { addCommonArgs } from "../helpers/add-common-args";
import { buildEnv } from "../helpers/build-env";
import { buildRepoUrl } from "../helpers/build-repo-url";
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
import { validateCustomResticParams } from "../helpers/validate-custom-params";
import { createResticError, isResticError } from "../error";
import { logger, safeSpawn } from "../../node";
import type { ResticDeps } from "../types";
import { hasUnsupportedPathCharacter, toMessage } from "../../utils";
class ResticBackupCommandError extends Data.TaggedError("ResticBackupCommandError")<{
cause: unknown;
message: string;
}> {}
const validatePatterns = (entries: string[], optionName: string) => {
for (const entry of entries) {
if (hasUnsupportedPathCharacter(entry)) {
throw new Error(`${optionName} contains an unsupported path character: ${entry}`);
}
}
};
export const backup = (
config: RepositoryConfig,
source: string,
options: {
organizationId: string;
exclude?: string[];
excludeIfPresent?: string[];
includePaths?: string[];
includePatterns?: string[];
tags?: string[];
oneFileSystem?: boolean;
compressionMode?: CompressionMode;
signal?: AbortSignal;
onProgress?: (progress: ResticBackupProgressDto) => void;
customResticParams?: string[];
},
deps: ResticDeps,
) => {
return Effect.tryPromise({
try: async () => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, options.organizationId, deps);
const args: string[] = ["--repo", repoUrl, "backup", "--compression", options.compressionMode ?? "auto"];
if (options.oneFileSystem) {
args.push("--one-file-system");
}
if (deps.hostname) {
args.push("--host", deps.hostname);
}
if (options.tags && options.tags.length > 0) {
for (const tag of options.tags) {
args.push("--tag", tag);
}
}
let includeFile: string | null = null;
let rawIncludeFile: string | null = null;
const usesSourceArg =
(!options.includePaths || options.includePaths.length === 0) &&
(!options.includePatterns || options.includePatterns.length === 0);
if (options.includePatterns?.length) {
validatePatterns(options.includePatterns, "includePatterns");
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-"));
includeFile = path.join(tmp, "include.txt");
await fs.writeFile(includeFile, options.includePatterns.join("\n"), "utf-8");
args.push("--files-from", includeFile);
}
if (options.includePaths?.length) {
validatePatterns(options.includePaths, "includePaths");
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-raw-"));
rawIncludeFile = path.join(tmp, "include.raw");
await fs.writeFile(rawIncludeFile, Buffer.from(`${options.includePaths.join("\0")}\0`, "utf-8"));
args.push("--files-from-raw", rawIncludeFile);
}
for (const exclude of deps.defaultExcludes) {
args.push("--exclude", exclude);
}
let excludeFile: string | null = null;
if (options.exclude?.length) {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-exclude-"));
excludeFile = path.join(tmp, "exclude.txt");
await fs.writeFile(excludeFile, options.exclude.join("\n"), "utf-8");
args.push("--exclude-file", excludeFile);
}
if (options.excludeIfPresent?.length) {
for (const filename of options.excludeIfPresent) {
args.push("--exclude-if-present", filename);
}
}
if (options.customResticParams?.length) {
const validationError = validateCustomResticParams(options.customResticParams);
if (validationError) {
throw new Error(`Invalid customResticParams: ${validationError}`);
}
for (const param of options.customResticParams) {
const tokens = param.trim().split(/\s+/).filter(Boolean);
args.push(...tokens);
}
}
addCommonArgs(args, env, config);
if (usesSourceArg) {
args.push("--", source);
}
const logData = throttle((data: string) => {
logger.info(data.trim());
}, 5000);
const stderrLines: string[] = [];
const streamProgress = throttle((data: string) => {
if (options.onProgress) {
try {
const jsonData = JSON.parse(data);
if (jsonData.message_type !== "status") {
return;
}
const progressResult = resticBackupProgressSchema.safeParse(jsonData);
if (progressResult.success) {
options.onProgress(progressResult.data);
} else {
logger.error(progressResult.error.message);
}
} catch {
// Ignore JSON parse errors for non-JSON lines
}
}
}, 1000);
logger.debug(`Executing: restic ${args.join(" ")}`);
const res = await safeSpawn({
command: "restic",
args,
env,
signal: options.signal,
onStdout: (data) => {
logData(data);
if (options.onProgress) {
streamProgress(data);
}
},
onStderr: (error) => {
const line = error.trim();
if (line.length > 0) {
stderrLines.push(line);
logger.error(`restic stderr: ${line}`);
}
},
});
if (includeFile) {
await fs.unlink(includeFile).catch(() => {});
}
if (rawIncludeFile) {
await fs.unlink(rawIncludeFile).catch(() => {});
}
if (excludeFile) {
await fs.unlink(excludeFile).catch(() => {});
}
await cleanupTemporaryKeys(env, deps);
if (options.signal?.aborted) {
logger.warn("Restic backup was aborted by signal.");
return { result: null, exitCode: res.exitCode, warningDetails: "Backup was stopped by the user" };
}
if (res.exitCode === 3) {
logger.error(`Restic backup encountered read errors: ${res.error}`);
}
if (res.exitCode !== 0 && res.exitCode !== 3) {
logger.error(`Restic backup failed: ${res.error}`);
logger.error(`Command executed: restic ${args.join(" ")}`);
throw createResticError(res.exitCode, stderrLines.join("\n") || res.stderr || res.error);
}
const lastLine = res.summary.trim();
let summaryLine: unknown = {};
try {
summaryLine = JSON.parse(lastLine ?? "{}");
} catch {
logger.warn("Failed to parse restic backup output JSON summary.", lastLine);
summaryLine = {};
}
logger.debug(`Restic backup output last line: ${JSON.stringify(summaryLine)}`);
const result = resticBackupOutputSchema.safeParse(summaryLine);
if (!result.success) {
logger.error(`Restic backup output validation failed: ${result.error.message}`);
return {
result: null,
exitCode: res.exitCode,
warningDetails: stderrLines.length > 0 ? stderrLines.join("\n") : null,
};
}
return {
result: result.data,
exitCode: res.exitCode,
warningDetails: stderrLines.length > 0 ? stderrLines.join("\n") : null,
};
},
catch: (error) => {
if (isResticError(error)) {
return error;
}
return new ResticBackupCommandError({
cause: error,
message: toMessage(error),
});
},
});
};