feat: partial success warning status (#74)

* feat: report partial backups with warnings

* chore: rebase

* chore: remove un-used size prop
This commit is contained in:
Nico
2025-11-26 19:02:29 +01:00
committed by GitHub
parent f8363a6c71
commit d190d9c8cd
15 changed files with 102 additions and 42 deletions

View File

@@ -17,3 +17,25 @@ export const toMessage = (err: unknown): string => {
const message = err instanceof Error ? err.message : String(err);
return sanitizeSensitiveData(message);
};
const resticErrorCodes: Record<number, string> = {
1: "Command failed: An error occurred while executing the command.",
2: "Go runtime error: A runtime error occurred in the Go program.",
3: "Backup could not read all files: Some files could not be read during backup.",
10: "Repository not found: The specified repository could not be found.",
11: "Failed to lock repository: Unable to acquire a lock on the repository. Try to run doctor on the repository.",
12: "Wrong repository password: The provided password for the repository is incorrect.",
130: "Backup interrupted: The backup process was interrupted.",
};
export class ResticError extends Error {
code: number;
constructor(code: number, stderr: string) {
const message = resticErrorCodes[code] || `Unknown restic error with code ${code}`;
super(`${message}\n${stderr}`);
this.code = code;
this.name = "ResticError";
}
}

View File

@@ -10,6 +10,7 @@ import { cryptoUtils } from "./crypto";
import type { RetentionPolicy } from "../modules/backups/backups.dto";
import { safeSpawn } from "./spawn";
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
import { ResticError } from "./errors";
const backupOutputSchema = type({
message_type: "'summary'",
@@ -313,34 +314,41 @@ const backup = async (
streamProgress(data);
}
},
onStderr: (error) => {
logger.error(error.trim());
},
finally: async () => {
includeFile && (await fs.unlink(includeFile).catch(() => {}));
await cleanupTemporaryKeys(config, env);
},
});
if (res.exitCode !== 0) {
logger.error(`Restic backup failed: ${res.stderr}`);
if (res.exitCode === 3) {
logger.error(`Restic backup encountered read errors: ${res.stderr.toString()}`);
}
if (res.exitCode !== 0 && res.exitCode !== 3) {
logger.error(`Restic backup failed: ${res.stderr.toString()}`);
logger.error(`Command executed: restic ${args.join(" ")}`);
throw new Error(`Restic backup failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
const lastLine = stdout.trim();
const resSummary = JSON.parse(lastLine ?? "{}");
let summaryLine = "";
try {
const resSummary = JSON.parse(lastLine ?? "{}");
summaryLine = resSummary;
} catch (_) {
logger.warn("Failed to parse restic backup output JSON summary.", lastLine);
summaryLine = "{}";
}
const result = backupOutputSchema(resSummary);
const result = backupOutputSchema(summaryLine);
if (result instanceof type.errors) {
logger.error(`Restic backup output validation failed: ${result}`);
throw new Error(`Restic backup output validation failed: ${result}`);
return { result: null, exitCode: res.exitCode };
}
return result;
return { result, exitCode: res.exitCode };
};
const restoreOutputSchema = type({
@@ -404,7 +412,7 @@ const restore = async (
if (res.exitCode !== 0) {
logger.error(`Restic restore failed: ${res.stderr}`);
throw new Error(`Restic restore failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
const stdout = res.text();
@@ -517,7 +525,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
if (res.exitCode !== 0) {
logger.error(`Restic forget failed: ${res.stderr}`);
throw new Error(`Restic forget failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
return { success: true };
@@ -535,7 +543,7 @@ const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
if (res.exitCode !== 0) {
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
throw new Error(`Failed to delete snapshot: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
return { success: true };
@@ -585,7 +593,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
if (res.exitCode !== 0) {
logger.error(`Restic ls failed: ${res.stderr}`);
throw new Error(`Restic ls failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
// The output is a stream of JSON objects, first is snapshot info, rest are file/dir nodes
@@ -636,7 +644,7 @@ const unlock = async (config: RepositoryConfig) => {
if (res.exitCode !== 0) {
logger.error(`Restic unlock failed: ${res.stderr}`);
throw new Error(`Restic unlock failed: ${res.stderr}`);
throw new ResticError(res.exitCode, res.stderr.toString());
}
logger.info(`Restic unlock succeeded for repository: ${repoUrl}`);
@@ -697,7 +705,7 @@ const repairIndex = async (config: RepositoryConfig) => {
if (res.exitCode !== 0) {
logger.error(`Restic repair index failed: ${stderr}`);
throw new Error(`Restic repair index failed: ${stderr}`);
throw new ResticError(res.exitCode, stderr);
}
logger.info(`Restic repair index completed for repository: ${repoUrl}`);

View File

@@ -41,9 +41,8 @@ export const safeSpawn = (params: Params) => {
child.stderr.on("data", (data) => {
if (callbacks.onStderr) {
callbacks.onStderr(data.toString());
} else {
stderrData += data.toString();
}
stderrData += data.toString();
});
child.on("error", async (error) => {