fix(snapshots): use streamed response to avoid 1 MiB buffer limit on large repos (#771)
This commit is contained in:
parent
70c7de1efc
commit
863fbfc5cc
4 changed files with 92 additions and 6 deletions
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||||
|
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
||||||
|
import * as nodeModule from "../../../node";
|
||||||
|
import { snapshots } from "../snapshots";
|
||||||
|
import type { SafeSpawnParams } from "../../../node";
|
||||||
|
import type { ResticDeps } from "../../types";
|
||||||
|
|
||||||
|
const mockDeps: ResticDeps = {
|
||||||
|
resolveSecret: async (s) => s,
|
||||||
|
getOrganizationResticPassword: async () => "org-restic-password",
|
||||||
|
resticCacheDir: "/tmp/restic-cache",
|
||||||
|
resticPassFile: "/tmp/restic.pass",
|
||||||
|
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
backend: "local" as const,
|
||||||
|
path: "/tmp/restic-repo",
|
||||||
|
isExistingRepository: true,
|
||||||
|
customPassword: "custom-password",
|
||||||
|
};
|
||||||
|
|
||||||
|
const getOnStdout = (params: SafeSpawnParams) => {
|
||||||
|
if (params.stdoutMode === "raw") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return params.onStdout;
|
||||||
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("snapshots command", () => {
|
||||||
|
test("parses a streamed multi-line JSON array", async () => {
|
||||||
|
const snapshotsOutput = [
|
||||||
|
{
|
||||||
|
hostname: "host",
|
||||||
|
id: "snapshot-1",
|
||||||
|
paths: ["/data"],
|
||||||
|
short_id: "snapshot-1",
|
||||||
|
tags: ["daily"],
|
||||||
|
time: "2025-01-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
vi.spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||||
|
vi.spyOn(nodeModule, "safeSpawn").mockImplementation((params) => {
|
||||||
|
const onStdout = getOnStdout(params);
|
||||||
|
|
||||||
|
for (const line of JSON.stringify(snapshotsOutput, null, 2).split("\n")) {
|
||||||
|
onStdout?.(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({ exitCode: 0, summary: "", error: "" });
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await snapshots(config, { organizationId: "org-1" }, mockDeps);
|
||||||
|
|
||||||
|
expect(result).toEqual(snapshotsOutput);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,7 +5,7 @@ import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||||
import { resticSnapshotSummarySchema } from "../restic-dto";
|
import { resticSnapshotSummarySchema } from "../restic-dto";
|
||||||
import type { RepositoryConfig } from "../schemas";
|
import type { RepositoryConfig } from "../schemas";
|
||||||
import { logger, safeExec } from "../../node";
|
import { logger, safeSpawn } from "../../node";
|
||||||
import type { ResticDeps } from "../types";
|
import type { ResticDeps } from "../types";
|
||||||
|
|
||||||
const snapshotInfoSchema = z.object({
|
const snapshotInfoSchema = z.object({
|
||||||
|
|
@ -43,15 +43,24 @@ export const snapshots = async (
|
||||||
|
|
||||||
addCommonArgs(args, env, config);
|
addCommonArgs(args, env, config);
|
||||||
|
|
||||||
const res = await safeExec({ command: "restic", args, env });
|
const stdoutLines: string[] = [];
|
||||||
|
const res = await safeSpawn({
|
||||||
|
command: "restic",
|
||||||
|
args,
|
||||||
|
env,
|
||||||
|
onStdout: (line) => {
|
||||||
|
stdoutLines.push(line);
|
||||||
|
},
|
||||||
|
});
|
||||||
await cleanupTemporaryKeys(env, deps);
|
await cleanupTemporaryKeys(env, deps);
|
||||||
|
|
||||||
if (res.exitCode !== 0) {
|
if (res.exitCode !== 0) {
|
||||||
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
const errorMessage = res.stderr || res.error;
|
||||||
throw new Error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
logger.error(`Restic snapshots retrieval failed: ${errorMessage}`);
|
||||||
|
throw new Error(`Restic snapshots retrieval failed: ${errorMessage}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = snapshotInfoSchema.array().safeParse(JSON.parse(res.stdout));
|
const result = snapshotInfoSchema.array().safeParse(JSON.parse(stdoutLines.join("\n")));
|
||||||
|
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
logger.error(`Restic snapshots output validation failed: ${result.error.message}`);
|
logger.error(`Restic snapshots output validation failed: ${result.error.message}`);
|
||||||
|
|
|
||||||
14
packages/core/src/utils/__tests__/spawn.test.ts
Normal file
14
packages/core/src/utils/__tests__/spawn.test.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { safeExec } from "../spawn";
|
||||||
|
|
||||||
|
describe("safeExec", () => {
|
||||||
|
test("falls back to the process error message when stderr is empty", async () => {
|
||||||
|
const result = await safeExec({
|
||||||
|
command: process.execPath,
|
||||||
|
args: ["-e", "process.stdout.write('a'.repeat(2 * 1024 * 1024))"],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.stderr).toContain("stdout maxBuffer length exceeded");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -28,7 +28,7 @@ export const safeExec = async ({ command, args = [], env = {}, ...rest }: ExecPr
|
||||||
return {
|
return {
|
||||||
exitCode: typeof execError.code === "number" ? execError.code : 1,
|
exitCode: typeof execError.code === "number" ? execError.code : 1,
|
||||||
stdout: execError.stdout || "",
|
stdout: execError.stdout || "",
|
||||||
stderr: timedOut ? "Command timed out before completing" : execError.stderr || "",
|
stderr: timedOut ? "Command timed out before completing" : execError.stderr || execError.message || "",
|
||||||
timedOut,
|
timedOut,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue