mirror of
https://github.com/nicotsx/zerobyte.git
synced 2026-04-20 23:09:16 -04:00
fix(restic): treat all flag-like snap id args as positional args
This commit is contained in:
@@ -98,6 +98,18 @@ describe("backup command", () => {
|
||||
expect(hasFlag("--files-from")).toBe(false);
|
||||
});
|
||||
|
||||
test("treats flag-like source paths as positional args", async () => {
|
||||
const { getArgs } = setup();
|
||||
const source = "--help";
|
||||
|
||||
await backup(config, source, { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
const separatorIndex = getArgs().indexOf("--");
|
||||
expect(separatorIndex).toBeGreaterThan(-1);
|
||||
expect(getArgs()[separatorIndex + 1]).toBe(source);
|
||||
expect(getArgs().at(-1)).toBe(source);
|
||||
});
|
||||
|
||||
test("uses --files-from instead of source path when include list is provided", async () => {
|
||||
const { hasFlag, getArgs } = setup();
|
||||
await backup(
|
||||
|
||||
67
packages/core/src/restic/commands/__tests__/copy.test.ts
Normal file
67
packages/core/src/restic/commands/__tests__/copy.test.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
||||
import * as nodeModule from "../../../node";
|
||||
import { copy } from "../copy";
|
||||
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 sourceConfig = {
|
||||
backend: "local" as const,
|
||||
path: "/tmp/source-repo",
|
||||
isExistingRepository: true,
|
||||
customPassword: "source-password",
|
||||
};
|
||||
|
||||
const destConfig = {
|
||||
backend: "local" as const,
|
||||
path: "/tmp/dest-repo",
|
||||
isExistingRepository: true,
|
||||
customPassword: "dest-password",
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||
capturedArgs = args ?? [];
|
||||
return { exitCode: 0, stdout: "copied", stderr: "", timedOut: false };
|
||||
});
|
||||
|
||||
return {
|
||||
getArgs: () => capturedArgs,
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("copy command", () => {
|
||||
test("treats flag-like snapshot IDs as positional args", async () => {
|
||||
const { getArgs } = setup();
|
||||
const snapshotId = "--help";
|
||||
|
||||
await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotId, tag: "daily" }, mockDeps);
|
||||
|
||||
expect(getArgs()).toEqual([
|
||||
"--repo",
|
||||
"/tmp/dest-repo",
|
||||
"copy",
|
||||
"--from-repo",
|
||||
"/tmp/source-repo",
|
||||
"--tag",
|
||||
"daily",
|
||||
"--json",
|
||||
"--",
|
||||
snapshotId,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
||||
import * as nodeModule from "../../../node";
|
||||
import { deleteSnapshots } from "../delete-snapshots";
|
||||
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 setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||
capturedArgs = args ?? [];
|
||||
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
|
||||
});
|
||||
|
||||
return {
|
||||
getArgs: () => capturedArgs,
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("deleteSnapshots command", () => {
|
||||
test("treats flag-like snapshot IDs as positional args", async () => {
|
||||
const { getArgs } = setup();
|
||||
const snapshotIds = ["--help", "--password-command=sh -c 'id'"];
|
||||
|
||||
await deleteSnapshots(config, snapshotIds, "org-1", mockDeps);
|
||||
|
||||
expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "forget", "--prune", "--json", "--", ...snapshotIds]);
|
||||
});
|
||||
});
|
||||
62
packages/core/src/restic/commands/__tests__/dump.test.ts
Normal file
62
packages/core/src/restic/commands/__tests__/dump.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { spawn } from "node:child_process";
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
||||
import * as nodeModule from "../../../node";
|
||||
import { dump } from "../dump";
|
||||
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 setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(nodeModule, "safeSpawn").mockImplementation((params) => {
|
||||
capturedArgs = params.args;
|
||||
const child = { stdout: new PassThrough() } as unknown as ReturnType<typeof spawn>;
|
||||
params.onSpawn?.(child);
|
||||
return Promise.resolve({ exitCode: 0, summary: "", error: "" });
|
||||
});
|
||||
|
||||
return {
|
||||
getArgs: () => capturedArgs,
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("dump command", () => {
|
||||
test("treats snapshot reference as a positional arg", async () => {
|
||||
const { getArgs } = setup();
|
||||
|
||||
const result = await dump(config, "--help", { organizationId: "org-1", path: "folder/file.txt" }, mockDeps);
|
||||
await result.completion;
|
||||
|
||||
expect(getArgs()).toEqual([
|
||||
"--repo",
|
||||
"/tmp/restic-repo",
|
||||
"dump",
|
||||
"--archive",
|
||||
"tar",
|
||||
"--",
|
||||
"--help",
|
||||
"/folder/file.txt",
|
||||
]);
|
||||
});
|
||||
});
|
||||
63
packages/core/src/restic/commands/__tests__/ls.test.ts
Normal file
63
packages/core/src/restic/commands/__tests__/ls.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
||||
import * as spawnModule from "../../../utils/spawn";
|
||||
import { ls } from "../ls";
|
||||
import type { ResticDeps } from "../../types";
|
||||
import type { SafeSpawnParams } from "../../../utils/spawn";
|
||||
|
||||
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 snapshotLine = JSON.stringify({
|
||||
time: "2025-01-01T00:00:00Z",
|
||||
tree: "abc",
|
||||
paths: ["/"],
|
||||
hostname: "host",
|
||||
id: "id",
|
||||
short_id: "short",
|
||||
struct_type: "snapshot",
|
||||
message_type: "snapshot",
|
||||
});
|
||||
|
||||
const setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => {
|
||||
capturedArgs = params.args;
|
||||
params.onStdout?.(snapshotLine);
|
||||
return Promise.resolve({ exitCode: 0, summary: snapshotLine, error: "" });
|
||||
});
|
||||
|
||||
return {
|
||||
getArgs: () => capturedArgs,
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("ls command", () => {
|
||||
test("treats flag-like snapshot and path values as positional args", async () => {
|
||||
const { getArgs } = setup();
|
||||
const snapshotId = "--password-command=sh -c 'id'";
|
||||
const path = "--help";
|
||||
|
||||
await ls(config, snapshotId, "org-1", path, undefined, mockDeps);
|
||||
|
||||
expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "ls", "--long", "--json", "--", snapshotId, path]);
|
||||
});
|
||||
});
|
||||
@@ -39,11 +39,11 @@ const setup = () => {
|
||||
});
|
||||
|
||||
const getRestoreArg = () => {
|
||||
const restoreIndex = capturedArgs.indexOf("restore");
|
||||
if (restoreIndex < 0 || !capturedArgs[restoreIndex + 1]) {
|
||||
throw new Error("Expected restore argument after restore command");
|
||||
const separatorIndex = capturedArgs.indexOf("--");
|
||||
if (separatorIndex < 0 || !capturedArgs[separatorIndex + 1]) {
|
||||
throw new Error("Expected restore argument after separator");
|
||||
}
|
||||
return capturedArgs[restoreIndex + 1]!;
|
||||
return capturedArgs[separatorIndex + 1]!;
|
||||
};
|
||||
|
||||
const getOptionValues = (option: string): string[] => {
|
||||
@@ -160,4 +160,31 @@ describe("restore command", () => {
|
||||
expect(getOptionValues("--include")).toEqual([]);
|
||||
expect(getArgs()).not.toContain("");
|
||||
});
|
||||
|
||||
test("treats flag-like snapshot IDs as positional restore args", async () => {
|
||||
const { getArgs, getRestoreArg } = setup();
|
||||
|
||||
await restore(
|
||||
config,
|
||||
"--help",
|
||||
"/tmp/restore-target",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data");
|
||||
expect(getArgs()).toEqual([
|
||||
"--repo",
|
||||
"/tmp/restic-repo",
|
||||
"restore",
|
||||
"--target",
|
||||
"/tmp/restore-target",
|
||||
"--json",
|
||||
"--",
|
||||
"--help:/var/lib/zerobyte/volumes/vol123/_data",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
||||
import * as nodeModule from "../../../node";
|
||||
import { tagSnapshots } from "../tag-snapshots";
|
||||
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 setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
|
||||
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
|
||||
capturedArgs = args ?? [];
|
||||
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
|
||||
});
|
||||
|
||||
return {
|
||||
getArgs: () => capturedArgs,
|
||||
};
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore();
|
||||
});
|
||||
|
||||
describe("tagSnapshots command", () => {
|
||||
test("treats flag-like snapshot IDs as positional args", async () => {
|
||||
const { getArgs } = setup();
|
||||
const snapshotIds = ["--help", "--password-command=sh -c 'id'"];
|
||||
|
||||
await tagSnapshots(config, snapshotIds, { add: ["keep"] }, "org-1", mockDeps);
|
||||
|
||||
expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "tag", "--add", "keep", "--json", "--", ...snapshotIds]);
|
||||
});
|
||||
});
|
||||
@@ -50,6 +50,8 @@ export const backup = async (
|
||||
}
|
||||
|
||||
let includeFile: string | null = null;
|
||||
const usesSourceArg = !options.include || options.include.length === 0;
|
||||
|
||||
if (options.include && options.include.length > 0) {
|
||||
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-"));
|
||||
includeFile = path.join(tmp, "include.txt");
|
||||
@@ -57,8 +59,6 @@ export const backup = async (
|
||||
await fs.writeFile(includeFile, options.include.join("\n"), "utf-8");
|
||||
|
||||
args.push("--files-from", includeFile);
|
||||
} else {
|
||||
args.push(source);
|
||||
}
|
||||
|
||||
for (const exclude of deps.defaultExcludes) {
|
||||
@@ -94,6 +94,10 @@ export const backup = async (
|
||||
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
if (usesSourceArg) {
|
||||
args.push("--", source);
|
||||
}
|
||||
|
||||
const logData = throttle((data: string) => {
|
||||
logger.info(data.trim());
|
||||
}, 5000);
|
||||
|
||||
@@ -32,12 +32,6 @@ export const copy = async (
|
||||
args.push("--tag", options.tag);
|
||||
}
|
||||
|
||||
if (options.snapshotId) {
|
||||
args.push(options.snapshotId);
|
||||
} else {
|
||||
args.push("latest");
|
||||
}
|
||||
|
||||
addCommonArgs(args, env, destConfig, { skipBandwidth: true });
|
||||
|
||||
const sourceDownloadLimit = formatBandwidthLimit(sourceConfig.downloadLimit);
|
||||
@@ -51,6 +45,8 @@ export const copy = async (
|
||||
args.push("--limit-upload", destUploadLimit);
|
||||
}
|
||||
|
||||
args.push("--", options.snapshotId ?? "latest");
|
||||
|
||||
logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`);
|
||||
logger.debug(`Executing: restic ${args.join(" ")}`);
|
||||
|
||||
|
||||
@@ -20,8 +20,9 @@ export const deleteSnapshots = async (
|
||||
throw new Error("No snapshot IDs provided for deletion.");
|
||||
}
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"];
|
||||
const args: string[] = ["--repo", repoUrl, "forget", "--prune"];
|
||||
addCommonArgs(args, env, config);
|
||||
args.push("--", ...snapshotIds);
|
||||
|
||||
const res = await safeExec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
@@ -32,13 +32,14 @@ export const dump = async (
|
||||
const env = await buildEnv(config, options.organizationId, deps);
|
||||
const pathToDump = normalizeDumpPath(options.path);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "dump", snapshotRef, pathToDump];
|
||||
const args: string[] = ["--repo", repoUrl, "dump"];
|
||||
|
||||
if (options.archive !== false) {
|
||||
args.push("--archive", "tar");
|
||||
}
|
||||
|
||||
addCommonArgs(args, env, config, { includeJson: false });
|
||||
args.push("--", snapshotRef, pathToDump);
|
||||
|
||||
logger.debug(`Executing: restic ${args.join(" ")}`);
|
||||
|
||||
|
||||
@@ -60,14 +60,15 @@ export const ls = async (
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, organizationId, deps);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
|
||||
const args: string[] = ["--repo", repoUrl, "ls", "--long"];
|
||||
|
||||
addCommonArgs(args, env, config);
|
||||
args.push("--", snapshotId);
|
||||
|
||||
if (path) {
|
||||
args.push(path);
|
||||
}
|
||||
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
let snapshot: LsSnapshotInfo | null = null;
|
||||
const nodes: LsNode[] = [];
|
||||
let totalNodes = 0;
|
||||
@@ -77,6 +78,8 @@ export const ls = async (
|
||||
const offset = Math.max(options?.offset ?? 0, 0);
|
||||
const limit = Math.min(Math.max(options?.limit ?? 500, 1), 500);
|
||||
|
||||
logger.debug(`Running restic ls with args: ${args.join(" ")}`);
|
||||
|
||||
const res = await safeSpawn({
|
||||
command: "restic",
|
||||
args,
|
||||
|
||||
@@ -57,7 +57,7 @@ export const restore = async (
|
||||
restoreArg = `${snapshotId}:${commonAncestor}`;
|
||||
}
|
||||
|
||||
const args = ["--repo", repoUrl, "restore", restoreArg, "--target", target];
|
||||
const args = ["--repo", repoUrl, "restore", "--target", target];
|
||||
|
||||
if (options.overwrite) {
|
||||
args.push("--overwrite", options.overwrite);
|
||||
@@ -95,6 +95,7 @@ export const restore = async (
|
||||
}
|
||||
|
||||
addCommonArgs(args, env, config);
|
||||
args.push("--", restoreArg);
|
||||
|
||||
const streamProgress = throttle((data: string) => {
|
||||
if (options.onProgress) {
|
||||
|
||||
@@ -21,7 +21,7 @@ export const tagSnapshots = async (
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, organizationId, deps);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "tag", ...snapshotIds];
|
||||
const args: string[] = ["--repo", repoUrl, "tag"];
|
||||
|
||||
if (tags.add) {
|
||||
for (const tag of tags.add) {
|
||||
@@ -42,6 +42,7 @@ export const tagSnapshots = async (
|
||||
}
|
||||
|
||||
addCommonArgs(args, env, config);
|
||||
args.push("--", ...snapshotIds);
|
||||
|
||||
const res = await safeExec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
Reference in New Issue
Block a user