Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
313 lines
12 KiB
TypeScript
313 lines
12 KiB
TypeScript
import { execFileSync, spawnSync } from "node:child_process";
|
|
import {
|
|
chmodSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
writeFileSync,
|
|
} from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, "../../..");
|
|
const temporaryDirectories: string[] = [];
|
|
|
|
afterEach(() => {
|
|
for (const directory of temporaryDirectories.splice(0)) {
|
|
rmSync(directory, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
function write(file: string, content: string) {
|
|
mkdirSync(path.dirname(file), { recursive: true });
|
|
writeFileSync(file, content);
|
|
}
|
|
|
|
function fixture() {
|
|
const root = mkdtempSync(path.join(os.tmpdir(), "rakazo-backup-"));
|
|
temporaryDirectories.push(root);
|
|
const checkout = path.join(root, "checkout with spaces");
|
|
const snapshots = path.join(root, "snapshots");
|
|
const bin = path.join(root, "bin");
|
|
for (const script of [
|
|
"scripts/backup.sh",
|
|
"scripts/restore.sh",
|
|
"infra/compose/backup-prod.sh",
|
|
]) {
|
|
let source = readFileSync(path.join(repoRoot, script), "utf8");
|
|
if (script.endsWith("backup-prod.sh")) {
|
|
// Relocate only the fixed output directory: never write to real host backups.
|
|
expect(source).toContain('BACKUP_ROOT="/var/backups/rakazo"');
|
|
source = source.replace('BACKUP_ROOT="/var/backups/rakazo"', `BACKUP_ROOT="${snapshots}"`);
|
|
}
|
|
write(path.join(checkout, script), source);
|
|
}
|
|
write(path.join(checkout, ".env"), "EXAMPLE_SETTING=fake-value\n");
|
|
write(path.join(root, "appdata/home.txt"), "example bot home");
|
|
const docker = path.join(bin, "docker");
|
|
write(
|
|
docker,
|
|
`#!/usr/bin/env -S node --
|
|
const fs = require("node:fs");
|
|
const args = process.argv.slice(2);
|
|
fs.appendFileSync(process.env.COMMAND_LOG, JSON.stringify(args) + "\\n");
|
|
if (args[0] === "inspect") {
|
|
console.log(process.env.FAKE_APPDATA);
|
|
} else if (args[0] !== "compose") {
|
|
process.exit(90);
|
|
} else if (args.includes("pg_dump")) {
|
|
if (process.env.FAIL_DUMP === "1") process.exit(2);
|
|
console.log("CREATE TABLE example (id integer);");
|
|
} else if (args.includes("sh")) {
|
|
if (!args.at(-1).startsWith("pg_dump ")) process.exit(91);
|
|
if (process.env.FAIL_DUMP === "1") process.exit(2);
|
|
console.log("fake custom-format dump");
|
|
} else if (args.includes("psql")) {
|
|
fs.writeFileSync(process.env.SQL_INPUT, fs.readFileSync(0));
|
|
if (process.env.FAIL_SQL === "1") {
|
|
console.error("ERROR: relation example already exists");
|
|
// psql normally exits successfully after SQL errors unless explicitly told to stop.
|
|
process.exit(args.includes("--set=ON_ERROR_STOP=on") ? 3 : 0);
|
|
}
|
|
} else if (args.includes("pg_restore")) {
|
|
fs.readFileSync(0);
|
|
if (process.env.FAIL_VERIFY === "1") process.exit(1);
|
|
} else if (args.includes("ps")) {
|
|
console.log("example-api-container");
|
|
} else if (!args.includes("up") && !args.includes("pg_isready")) {
|
|
process.exit(92);
|
|
}
|
|
`,
|
|
);
|
|
chmodSync(docker, 0o755);
|
|
const tar = path.join(bin, "tar");
|
|
write(
|
|
tar,
|
|
`#!/bin/bash
|
|
set -eu
|
|
if [[ "$1" == "-czf" && "$*" == *" -C "* && -n "\${FAIL_TAR:-}" ]]; then
|
|
echo "simulated archive read failure" >&2
|
|
exit "$FAIL_TAR"
|
|
fi
|
|
/usr/bin/tar "$@"
|
|
if [[ "$1" == "-czf" && "\${CHANGED_TAR:-0}" == "1" ]]; then
|
|
echo "file changed as we read it" >&2
|
|
exit 1
|
|
fi
|
|
`,
|
|
);
|
|
chmodSync(tar, 0o755);
|
|
const checksum = path.join(bin, "sha256sum");
|
|
write(
|
|
checksum,
|
|
`#!/usr/bin/env -S node --
|
|
const fs = require("node:fs");
|
|
const crypto = require("node:crypto");
|
|
for (const file of process.argv.slice(2)) {
|
|
console.log(crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") + " " + file);
|
|
}
|
|
`,
|
|
);
|
|
chmodSync(checksum, 0o755);
|
|
// No inherited deployment settings, credentials, or executable search paths.
|
|
const env = {
|
|
PATH: [bin, path.dirname(process.execPath), "/usr/bin", "/bin"].join(path.delimiter),
|
|
COMMAND_LOG: path.join(root, "commands.jsonl"),
|
|
SQL_INPUT: path.join(root, "input.sql"),
|
|
FAKE_APPDATA: path.join(root, "appdata"),
|
|
};
|
|
return {
|
|
root,
|
|
checkout,
|
|
snapshots,
|
|
env,
|
|
run(script: string, args: string[] = [], overrides: Record<string, string> = {}) {
|
|
const result = spawnSync("/bin/bash", [path.join(checkout, script), ...args], {
|
|
cwd: root,
|
|
env: { ...env, ...overrides },
|
|
encoding: "utf8",
|
|
timeout: 10_000,
|
|
});
|
|
expect(result.error).toBeUndefined();
|
|
expect(result.signal).toBeNull();
|
|
return result;
|
|
},
|
|
commands(): string[][] {
|
|
if (!existsSync(env.COMMAND_LOG)) return [];
|
|
return readFileSync(env.COMMAND_LOG, "utf8")
|
|
.trim()
|
|
.split("\n")
|
|
.map((line) => JSON.parse(line));
|
|
},
|
|
};
|
|
}
|
|
|
|
function contents(archive: string) {
|
|
return execFileSync("/usr/bin/tar", ["-tzf", archive], { encoding: "utf8" });
|
|
}
|
|
|
|
describe("development backup failures", () => {
|
|
it.each([false, true])("archives data with a root env file present: %s", (hasEnv) => {
|
|
const f = fixture();
|
|
if (!hasEnv) rmSync(path.join(f.checkout, ".env"));
|
|
write(path.join(f.checkout, "data/home.txt"), "example home");
|
|
expect(f.run("scripts/backup.sh", ["example"]).status).toBe(0);
|
|
const output = path.join(f.checkout, "backups/example");
|
|
expect(readFileSync(path.join(output, "rakazo.sql"), "utf8")).toContain("CREATE TABLE");
|
|
expect(contents(path.join(output, "homes.tgz"))).toContain("data/home.txt");
|
|
expect(f.commands()[0].includes("--env-file")).toBe(hasEnv);
|
|
if (hasEnv) expect(f.commands()[0]).toContain(path.join(f.checkout, ".env"));
|
|
});
|
|
|
|
it.each(["absent", "skipped"])("allows an empty archive when data is %s", (scenario) => {
|
|
const f = fixture();
|
|
if (scenario === "skipped") write(path.join(f.checkout, "data/home.txt"), "example home");
|
|
const result = f.run("scripts/backup.sh", ["example"], {
|
|
RAKAZO_BACKUP_SKIP_HOMES: scenario === "skipped" ? "1" : "0",
|
|
});
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(contents(path.join(f.checkout, "backups/example/homes.tgz"))).toBe("");
|
|
});
|
|
|
|
it.each(["file", "broken link"])("rejects a data path that is a %s", (kind) => {
|
|
const f = fixture();
|
|
const data = path.join(f.checkout, "data");
|
|
if (kind === "file") write(data, "not a directory");
|
|
else symlinkSync(path.join(f.root, "missing"), data);
|
|
const result = f.run("scripts/backup.sh", ["example"]);
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stdout).not.toContain("Backup written");
|
|
});
|
|
|
|
it.each([1, 2])("propagates tar exit %s without replacing the failed archive", (code) => {
|
|
const f = fixture();
|
|
write(path.join(f.checkout, "data/home.txt"), "example home");
|
|
const result = f.run("scripts/backup.sh", ["example"], { FAIL_TAR: String(code) });
|
|
expect(result.status).toBe(code);
|
|
expect(result.stderr).toContain("simulated archive read failure");
|
|
expect(result.stdout).not.toContain("Backup written");
|
|
expect(existsSync(path.join(f.checkout, "backups/example/homes.tgz"))).toBe(false);
|
|
});
|
|
|
|
it("does not archive files or report success after pg_dump fails", () => {
|
|
const f = fixture();
|
|
const result = f.run("scripts/backup.sh", ["example"], { FAIL_DUMP: "1" });
|
|
expect(result.status).toBe(2);
|
|
expect(result.stdout).not.toContain("Backup written");
|
|
expect(existsSync(path.join(f.checkout, "backups/example/homes.tgz"))).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("development restore failures", () => {
|
|
function restoreFixture() {
|
|
const f = fixture();
|
|
write(path.join(f.checkout, "data/home.txt"), "backed-up home");
|
|
expect(f.run("scripts/backup.sh", ["example"]).status).toBe(0);
|
|
write(path.join(f.checkout, "data/home.txt"), "current home");
|
|
return f;
|
|
}
|
|
|
|
it.each([false, true])(
|
|
"restores files and starts services only after SQL succeeds: %s",
|
|
(fail) => {
|
|
const f = restoreFixture();
|
|
const result = f.run("scripts/restore.sh", [path.join(f.checkout, "backups/example")], {
|
|
FAIL_SQL: fail ? "1" : "0",
|
|
});
|
|
expect(result.status).toBe(fail ? 3 : 0);
|
|
const command = f.commands().find((args) => args.includes("psql"));
|
|
expect(command).toEqual(
|
|
expect.arrayContaining([
|
|
"-X",
|
|
"--set=ON_ERROR_STOP=on",
|
|
"--single-transaction",
|
|
"--file=-",
|
|
]),
|
|
);
|
|
expect(readFileSync(f.env.SQL_INPUT, "utf8")).toContain("CREATE TABLE example");
|
|
expect(readFileSync(path.join(f.checkout, "data/home.txt"), "utf8")).toBe(
|
|
fail ? "current home" : "backed-up home",
|
|
);
|
|
expect(f.commands().some((args) => args.slice(-2).join(" ") === "up -d")).toBe(!fail);
|
|
expect(result.stdout.includes("Restore complete")).toBe(!fail);
|
|
if (fail) expect(result.stderr).toContain("already exists");
|
|
},
|
|
);
|
|
|
|
it("fails rather than starting services after an invalid homes archive", () => {
|
|
const f = restoreFixture();
|
|
write(path.join(f.checkout, "backups/example/homes.tgz"), "invalid archive");
|
|
const result = f.run("scripts/restore.sh", [path.join(f.checkout, "backups/example")]);
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stdout).not.toContain("Restore complete");
|
|
expect(f.commands().some((args) => args.slice(-2).join(" ") === "up -d")).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("production backup deployment and archive behavior", () => {
|
|
it.each([false, true])("uses the configured deployment directory: %s", (custom) => {
|
|
const f = fixture();
|
|
const deployment = custom ? f.checkout : "/srv/rakazo";
|
|
const result = f.run(
|
|
"infra/compose/backup-prod.sh",
|
|
[],
|
|
custom ? { RAKAZO_DEPLOY_DIR: deployment } : {},
|
|
);
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stdout).toContain("Verified Rakazo backup");
|
|
for (const command of f.commands().filter((args) => args[0] === "compose")) {
|
|
expect(command.slice(0, 5)).toEqual([
|
|
"compose",
|
|
"--env-file",
|
|
path.join(deployment, ".env"),
|
|
"-f",
|
|
path.join(deployment, "infra/compose/docker-compose.prod.yml"),
|
|
]);
|
|
}
|
|
});
|
|
|
|
it("provides optional systemd configuration for installed copies", () => {
|
|
const service = readFileSync(
|
|
path.join(repoRoot, "infra/systemd/rakazo-backup.service"),
|
|
"utf8",
|
|
);
|
|
expect(service).toContain("EnvironmentFile=-/etc/rakazo/backup.env");
|
|
});
|
|
|
|
it("rejects relative deployment paths before any backup commands", () => {
|
|
const f = fixture();
|
|
const result = f.run("infra/compose/backup-prod.sh", [], { RAKAZO_DEPLOY_DIR: "relative" });
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stderr).toContain("absolute path");
|
|
expect(f.commands()).toEqual([]);
|
|
expect(existsSync(f.snapshots)).toBe(false);
|
|
});
|
|
|
|
it("preserves host archiving and the live-file warning allowance from PR #618", () => {
|
|
const f = fixture();
|
|
const result = f.run("infra/compose/backup-prod.sh", [], { CHANGED_TAR: "1" });
|
|
expect(result.status, result.stderr).toBe(0);
|
|
expect(result.stderr).toContain("file changed as we read it");
|
|
expect(result.stdout).toContain("Verified Rakazo backup");
|
|
const snapshot = result.stdout.trim().split("written to ")[1];
|
|
expect(contents(path.join(snapshot, "appdata.tgz"))).toContain("home.txt");
|
|
expect(existsSync(path.join(snapshot, "SHA256SUMS"))).toBe(true);
|
|
expect(f.commands().some((args) => args[0] === "inspect")).toBe(true);
|
|
expect(f.commands().some((args) => args.includes("tar"))).toBe(false);
|
|
});
|
|
|
|
it.each([{ FAIL_TAR: "2" }, { FAIL_DUMP: "1" }, { FAIL_VERIFY: "1" }])(
|
|
"does not report a verified snapshot after failure: %j",
|
|
(failure) => {
|
|
const f = fixture();
|
|
const result = f.run("infra/compose/backup-prod.sh", [], failure);
|
|
expect(result.status).not.toBe(0);
|
|
expect(result.stdout).not.toContain("Verified Rakazo backup");
|
|
},
|
|
);
|
|
});
|