Commit

The backup restore drill: rebuild a repository from its bundle chain and compare every ref

Read-only against production: SELECTs on g1t-repos, reads of g1t-backups, git ls-remote of the live repository. Exits 1 on any difference. Its test cuts a full and an incremental bundle with git and drills a local copy.

syntaqxcommitted Parentc233a20Browse files
3 files+398−10/3 viewed
+2−1
1313 "scripts": {
1414 "typecheck": "npm run typecheck --workspaces --if-present",
1515 "deploy": "npm run deploy -w @g1t/web",
16− "test:deploy": "node --test \"scripts/deploy/*.test.mjs\""
16+ "test:deploy": "node --test \"scripts/deploy/*.test.mjs\"",
17+ "test:ops": "node --test \"scripts/ops/*.test.mjs\""
1718 },
1819 "devDependencies": {
1920 "typescript": "^5.9.3",
+254−0
1+#!/usr/bin/env node
2+// The restore drill for nightly backups (docs/ARTIFACTS.md, R11;
3+// services/repos/src/backups.rs). Picks a repository, downloads its
4+// manifest and bundle chain from the g1t-backups bucket, rebuilds the
5+// repository from them in a temporary directory, and compares every ref
6+// with the live repository. Exits 1 on any difference, 2 when it could not
7+// run.
8+//
9+// Read-only: SELECTs against the g1t-repos database, reads of the bucket,
10+// and `git ls-remote` of the live repository. Nothing is written anywhere
11+// but the temporary directory, which is removed unless you pass --keep.
12+//
13+// node scripts/ops/backup-restore-drill.mjs # a repository unchanged since its last backup
14+// node scripts/ops/backup-restore-drill.mjs --repo acme/rocket
15+// node scripts/ops/backup-restore-drill.mjs --repo-id repo_... --bundles ./copy --live /srv/git/acme--rocket.git
16+//
17+// Options:
18+// --repo <workspace/name> the repository; default: one picked at random
19+// among those whose refs have not moved since
20+// their last backup, so any difference is the
21+// backup's.
22+// --repo-id <id> the repository by id (no database needed with --bundles).
23+// --bundles <dir> read the bucket from a local copy (`backups/<id>/...`
24+// under it, as `mc mirror` or `rclone copy` leave it)
25+// instead of R2, e.g. a self-hosted MinIO's.
26+// --live <url or path> the live repository to compare with; default
27+// https://g1t.sh/<workspace>/<name>.git.
28+// --keep keep the temporary directory, and say where it is.
29+//
30+// The live repository is read as G1T_USER with G1T_TOKEN (an access token
31+// with code:read) when they are set, which private repositories need. The
32+// database and the bucket are read through Wrangler, as you are logged in
33+// (`npx wrangler login`), or with CLOUDFLARE_DEPLOY_TOKEN.
34+
35+import { createHash } from "node:crypto";
36+import { mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
37+import { tmpdir } from "node:os";
38+import { join } from "node:path";
39+
40+import { exec, jsonFrom, wranglerEnv } from "../deploy/cloudflare.mjs";
41+import { ROOT } from "../deploy/stack.mjs";
42+
43+const WRANGLER = join(ROOT, "node_modules/wrangler/bin/wrangler.js");
44+const DATABASE = "g1t-repos";
45+const BUCKET = process.env.BACKUP_BUCKET || "g1t-backups";
46+const MANIFEST_VERSION = 1;
47+const STAGING = "refs/drill-staging";
48+
49+/** Runs git; resolves with its output, or throws with what it said. */
50+async function git(args, { cwd, input } = {}) {
51+ const { code, out } = await exec("git", args, { cwd, input });
52+ if (code !== 0) throw new Error(`git ${args.find((arg) => !arg.startsWith("-")) ?? ""} failed: ${out.trim().slice(-600)}`);
53+ return out.trim();
54+}
55+
56+/** `<hash> <name>` lines (for-each-ref) or `<hash>\t<name>` (ls-remote), as a map. Peeled tags are left out. */
57+export function parseRefs(listing) {
58+ const refs = {};
59+ for (const line of listing.split(/\r?\n/)) {
60+ const match = /^([0-9a-f]{40,64})\s+(\S+)$/.exec(line.trim());
61+ if (!match || match[2].endsWith("^{}")) continue;
62+ refs[match[2]] = match[1];
63+ }
64+ return refs;
65+}
66+
67+/** Every ref of the repository in `dir`, and HEAD. */
68+async function refsOf(dir) {
69+ const refs = parseRefs(await git(["for-each-ref", "--format=%(objectname) %(refname)"], { cwd: dir }));
70+ const head = await git(["rev-parse", "--verify", "--quiet", "HEAD"], { cwd: dir }).catch(() => "");
71+ if (head) refs.HEAD = head;
72+ return refs;
73+}
74+
75+/** The refs that differ between `want` and `have`: [{ ref, want, have }], `null` for absent. */
76+export function compareRefs(want, have) {
77+ const names = [...new Set([...Object.keys(want), ...Object.keys(have)])].sort();
78+ return names
79+ .filter((ref) => want[ref] !== have[ref])
80+ .map((ref) => ({ ref, want: want[ref] ?? null, have: have[ref] ?? null }));
81+}
82+
83+/** The branch HEAD should name: one at HEAD's commit, `main` or `master` first. */
84+export function headBranch(refs) {
85+ if (!refs.HEAD) return null;
86+ const branches = Object.keys(refs).filter((ref) => ref.startsWith("refs/heads/") && refs[ref] === refs.HEAD);
87+ return ["refs/heads/main", "refs/heads/master"].find((ref) => branches.includes(ref)) ?? branches.sort()[0] ?? null;
88+}
89+
90+/** Whether a manifest can be read by this drill. */
91+export function readManifest(text) {
92+ const manifest = JSON.parse(text);
93+ if (manifest.version !== MANIFEST_VERSION) throw new Error(`manifest version ${manifest.version}; this drill reads ${MANIFEST_VERSION}`);
94+ if (!Array.isArray(manifest.chain) || manifest.chain.length === 0) throw new Error("the manifest lists no backups");
95+ if (manifest.chain[0].kind !== "full") throw new Error("the chain does not start with a full backup");
96+ return manifest;
97+}
98+
99+/**
100+ * Rebuilds the repository the manifest's chain describes into `dir`, a
101+ * new bare repository: each bundle in order, checked against its size and
102+ * SHA-256 and verified by git, fetched without following tags; then every
103+ * ref set to what the last entry says, and nothing else kept. `fetchObject`
104+ * saves one object of the bucket to a file and resolves with its path.
105+ */
106+export async function restore(manifest, fetchObject, dir, work) {
107+ await git(["init", "--quiet", "--bare", dir]);
108+ for (const entry of manifest.chain) {
109+ if (!entry.key) continue;
110+ const file = await fetchObject(entry.key, join(work, `${entry.id}.bundle`));
111+ const size = statSync(file).size;
112+ if (size !== entry.size) throw new Error(`${entry.key}: ${size} bytes, the manifest says ${entry.size}`);
113+ if (entry.sha256) {
114+ const sha256 = createHash("sha256").update(readFileSync(file)).digest("hex");
115+ if (sha256 !== entry.sha256) throw new Error(`${entry.key}: SHA-256 ${sha256}, the manifest says ${entry.sha256}`);
116+ }
117+ await git(["bundle", "verify", "--quiet", file], { cwd: dir });
118+ await git(["fetch", "--quiet", "--no-tags", file, `+refs/*:${STAGING}/${entry.id}/*`], { cwd: dir });
119+ }
120+ const last = manifest.chain.at(-1).refs;
121+ const updates = Object.entries(last)
122+ .filter(([ref]) => ref !== "HEAD")
123+ .map(([ref, hash]) => `update ${ref} ${hash}\n`)
124+ .join("");
125+ const staged = await git(["for-each-ref", "--format=delete %(refname)", `${STAGING}/`], { cwd: dir });
126+ const commands = [updates.trimEnd(), staged].filter(Boolean).join("\n");
127+ if (commands) await git(["update-ref", "--stdin"], { cwd: dir, input: `${commands}\n` });
128+ const head = headBranch(last);
129+ if (head) await git(["symbolic-ref", "HEAD", head], { cwd: dir });
130+ // Every object every ref reaches is there.
131+ await git(["fsck", "--no-progress", "--connectivity-only"], { cwd: dir });
132+ return refsOf(dir);
133+}
134+
135+// ---------------------------------------------------------------------
136+
137+async function d1(sql) {
138+ const env = { ...wranglerEnv({ ...process.env, CI: "true" }) };
139+ if (!process.env.CLOUDFLARE_DEPLOY_TOKEN) env.CLOUDFLARE_API_TOKEN = "";
140+ const { code, out } = await exec(process.execPath, [WRANGLER, "d1", "execute", DATABASE, "--remote", "--json", "--command", sql], {
141+ cwd: join(ROOT, "services/repos"),
142+ env,
143+ });
144+ if (code !== 0) throw new Error(out.slice(-600));
145+ return jsonFrom(out)[0]?.results ?? [];
146+}
147+
148+const quoted = (text) => `'${String(text).replaceAll("'", "''")}'`;
149+
150+/** The repository to drill, and whether its refs moved since its last backup. */
151+async function pick({ repo, repoId }) {
152+ const select = `SELECT r.id, r.namespace, r.name, r.refs_version, b.refs_version AS backed_version,
153+ coalesce(r.refs_open_until, 0) > coalesce(b.backed_up_ms, 0) AS opened
154+ FROM repo_backups b JOIN repos r ON r.id = b.repo_id
155+ WHERE b.last_entry IS NOT NULL AND r.deleted_at IS NULL`;
156+ let rows;
157+ if (repoId) rows = await d1(`${select} AND r.id = ${quoted(repoId)}`);
158+ else if (repo) {
159+ const [namespace, name] = repo.toLowerCase().split("/");
160+ rows = await d1(`${select} AND r.namespace = ${quoted(namespace)} AND r.name = ${quoted(name)}`);
161+ } else {
162+ rows = await d1(`${select} AND b.refs_version = r.refs_version AND coalesce(r.refs_open_until, 0) <= coalesce(b.backed_up_ms, 0)
163+ ORDER BY random() LIMIT 1`);
164+ }
165+ const row = rows[0];
166+ if (!row) throw new Error(repo || repoId ? `no backup of ${repo ?? repoId}` : "no repository has a backup yet");
167+ return {
168+ id: row.id,
169+ path: `${row.namespace}/${row.name}`,
170+ moved: row.refs_version !== row.backed_version || Boolean(row.opened),
171+ };
172+}
173+
174+/** Saves one object of the bucket to `file`. */
175+function bucketReader(localCopy) {
176+ if (localCopy) return async (key) => join(localCopy, key);
177+ return async (key, file) => {
178+ const env = { ...wranglerEnv({ ...process.env, CI: "true" }) };
179+ if (!process.env.CLOUDFLARE_DEPLOY_TOKEN) env.CLOUDFLARE_API_TOKEN = "";
180+ const { code, out } = await exec(process.execPath, [WRANGLER, "r2", "object", "get", `${BUCKET}/${key}`, "--remote", "--file", file], { env });
181+ if (code !== 0) throw new Error(`${key} could not be read: ${out.slice(-400)}`);
182+ return file;
183+ };
184+}
185+
186+/** The live repository's refs, as a clone would see them. */
187+async function liveRefs(live) {
188+ const args = [];
189+ if (process.env.G1T_TOKEN && /^https?:/.test(live)) {
190+ const user = process.env.G1T_USER || "g1t";
191+ const basic = Buffer.from(`${user}:${process.env.G1T_TOKEN}`).toString("base64");
192+ args.push("-c", `http.extraHeader=Authorization: Basic ${basic}`);
193+ }
194+ return parseRefs(await git([...args, "ls-remote", live]));
195+}
196+
197+async function main() {
198+ const args = process.argv.slice(2);
199+ const option = (name) => {
200+ const at = args.indexOf(name);
201+ return at >= 0 ? args[at + 1] : undefined;
202+ };
203+ const keep = args.includes("--keep");
204+ const localCopy = option("--bundles");
205+ const target =
206+ localCopy && option("--repo-id")
207+ ? { id: option("--repo-id"), path: option("--repo") ?? null, moved: false }
208+ : await pick({ repo: option("--repo"), repoId: option("--repo-id") });
209+ const live = option("--live") ?? (target.path ? `https://g1t.sh/${target.path}.git` : null);
210+ if (!live) throw new Error("say which live repository to compare with: --live, or --repo");
211+
212+ const work = mkdtempSync(join(tmpdir(), "g1t-drill-"));
213+ try {
214+ const read = bucketReader(localCopy);
215+ const manifest = readManifest(readFileSync(await read(`backups/${target.id}/manifest.json`, join(work, "manifest.json")), "utf8"));
216+ const started = Date.now();
217+ const restored = await restore(manifest, read, join(work, "restored.git"), work);
218+ const seconds = ((Date.now() - started) / 1000).toFixed(1);
219+ const last = manifest.chain.at(-1);
220+ const bytes = manifest.chain.reduce((sum, entry) => sum + (entry.size ?? 0), 0);
221+ console.log(`${target.path ?? target.id}: ${manifest.chain.length} backups (${bytes} bytes), the last ${last.created_at}, restored in ${seconds}s`);
222+
223+ const fromChain = compareRefs(last.refs, restored);
224+ const fromLive = compareRefs(await liveRefs(live), restored);
225+ for (const [what, differences] of [
226+ ["the manifest", fromChain],
227+ ["the live repository", fromLive],
228+ ]) {
229+ if (differences.length === 0) {
230+ console.log(` every ref matches ${what} (${Object.keys(restored).length} refs)`);
231+ continue;
232+ }
233+ console.log(` ${differences.length} refs differ from ${what}:`);
234+ for (const { ref, want, have } of differences) console.log(` ${ref}: ${what} ${want ?? "(none)"}, restored ${have ?? "(none)"}`);
235+ }
236+ if (target.moved && fromLive.length > 0) {
237+ console.log(" The repository's refs moved since its last backup, so differences from it may be new work, not a fault.");
238+ }
239+ if (keep) console.log(` kept: ${work}`);
240+ return fromChain.length === 0 && fromLive.length === 0 ? 0 : 1;
241+ } finally {
242+ if (!keep) rmSync(work, { recursive: true, force: true });
243+ }
244+}
245+
246+if (process.argv[1]?.replaceAll("\\", "/").endsWith("scripts/ops/backup-restore-drill.mjs")) {
247+ main().then(
248+ (code) => process.exit(code),
249+ (error) => {
250+ console.error(`drill: ${error.message}`);
251+ process.exit(2);
252+ },
253+ );
254+}
+142−0
1+// The restore drill against a chain of real bundles: a full one and an
2+// incremental one, cut the way the runner's backup mode cuts them
3+// (crates/runner/src/backup.rs), from a local copy of the bucket.
4+
5+import assert from "node:assert/strict";
6+import { execFileSync, spawnSync } from "node:child_process";
7+import { createHash } from "node:crypto";
8+import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
9+import { tmpdir } from "node:os";
10+import { join } from "node:path";
11+import { test } from "node:test";
12+import { fileURLToPath } from "node:url";
13+
14+import { compareRefs, headBranch, parseRefs, readManifest, restore } from "./backup-restore-drill.mjs";
15+
16+const DRILL = fileURLToPath(new URL("./backup-restore-drill.mjs", import.meta.url));
17+const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
18+
19+function commit(dir, file, text) {
20+ writeFileSync(join(dir, file), text);
21+ git(dir, "add", "--all");
22+ git(dir, "-c", "user.name=t", "-c", "user.email=t@example.com", "commit", "--quiet", "-m", text);
23+}
24+
25+function refsOf(dir) {
26+ const refs = parseRefs(git(dir, "for-each-ref", "--format=%(objectname) %(refname)"));
27+ refs.HEAD = git(dir, "rev-parse", "HEAD");
28+ return refs;
29+}
30+
31+/** A bundle of the mirror, leaving out `prerequisites`, as the runner cuts one. */
32+function cut(mirror, prerequisites, out) {
33+ for (const hash of prerequisites) git(mirror, "update-ref", `refs/g1t-backup-prerequisites/${hash}`, hash);
34+ const not = prerequisites.length ? ["--not", "--glob=refs/g1t-backup-prerequisites/*"] : [];
35+ git(mirror, "bundle", "create", "--quiet", out, "--exclude=refs/g1t-backup-prerequisites/*", "--all", ...not);
36+ for (const hash of prerequisites) git(mirror, "update-ref", "-d", `refs/g1t-backup-prerequisites/${hash}`);
37+}
38+
39+function entry(id, kind, key, file, refs, prerequisites) {
40+ return {
41+ id,
42+ kind,
43+ key,
44+ created_at: "2026-10-06T02:53:00.000Z",
45+ refs_version: 1,
46+ refs,
47+ prerequisites,
48+ size: statSync(file).size,
49+ sha256: createHash("sha256").update(readFileSync(file)).digest("hex"),
50+ };
51+}
52+
53+test("refs, HEAD and differences are read as git writes them", () => {
54+ const a = "c71546fcd893ef8b0f57388b65e620d759705dda";
55+ const b = "4807077b296e6edbf410d55e72749d3e1170c291";
56+ assert.deepEqual(parseRefs(`${a}\tHEAD\n${a}\trefs/heads/main\n${b}\trefs/tags/v1\n${a}\trefs/tags/v1^{}\n`), {
57+ HEAD: a,
58+ "refs/heads/main": a,
59+ "refs/tags/v1": b,
60+ });
61+ assert.equal(headBranch({ HEAD: a, "refs/heads/dev": a, "refs/heads/main": a }), "refs/heads/main");
62+ assert.equal(headBranch({ HEAD: a, "refs/heads/dev": a }), "refs/heads/dev");
63+ assert.deepEqual(compareRefs({ x: a, y: b }, { x: a, z: b }), [
64+ { ref: "y", want: b, have: null },
65+ { ref: "z", want: null, have: b },
66+ ]);
67+ assert.throws(() => readManifest(JSON.stringify({ version: 2, chain: [] })), /version 2/);
68+ assert.throws(() => readManifest(JSON.stringify({ version: 1, chain: [{ kind: "incremental" }] })), /full/);
69+});
70+
71+test("a chain restores every ref, and the drill fails once the live repository differs", () => {
72+ const root = mkdtempSync(join(tmpdir(), "g1t-drill-test-"));
73+ try {
74+ const origin = join(root, "origin");
75+ mkdirSync(origin);
76+ git(origin, "init", "--quiet", "--initial-branch=main");
77+ commit(origin, "a.txt", "one");
78+ git(origin, "tag", "-a", "v1", "-m", "v1");
79+ const mirror = join(root, "mirror.git");
80+ const bucket = join(root, "bucket");
81+ const dir = join(bucket, "backups", "repo_1");
82+ mkdirSync(dir, { recursive: true });
83+
84+ git(root, "clone", "--mirror", "--quiet", origin, mirror);
85+ const fullRefs = refsOf(mirror);
86+ cut(mirror, [], join(dir, "1-full.bundle"));
87+ const full = entry("1", "full", "backups/repo_1/1-full.bundle", join(dir, "1-full.bundle"), fullRefs, []);
88+
89+ commit(origin, "b.txt", "two");
90+ git(origin, "branch", "feature");
91+ git(origin, "tag", "-d", "v1");
92+ rmSync(mirror, { recursive: true, force: true });
93+ git(root, "clone", "--mirror", "--quiet", origin, mirror);
94+ const prerequisites = [...new Set(Object.values(fullRefs))].sort();
95+ cut(mirror, prerequisites, join(dir, "2-incr.bundle"));
96+ const incr = entry("2", "incremental", "backups/repo_1/2-incr.bundle", join(dir, "2-incr.bundle"), refsOf(mirror), prerequisites);
97+
98+ const manifest = { version: 1, repo_id: "repo_1", store_key: "acme--rocket", path: null, updated_at: "", chain: [full, incr], previous: [] };
99+ writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest, null, 2));
100+
101+ const run = () =>
102+ spawnSync(process.execPath, [DRILL, "--repo-id", "repo_1", "--bundles", bucket, "--live", origin], { encoding: "utf8" });
103+ const passed = run();
104+ assert.equal(passed.status, 0, passed.stdout + passed.stderr);
105+ assert.match(passed.stdout, /every ref matches the live repository/);
106+
107+ commit(origin, "c.txt", "three");
108+ const failed = run();
109+ assert.equal(failed.status, 1, failed.stdout + failed.stderr);
110+ assert.match(failed.stdout, /refs differ from the live repository/);
111+
112+ // A bundle that is not what the manifest says is refused.
113+ writeFileSync(join(dir, "2-incr.bundle"), "not a bundle");
114+ const broken = run();
115+ assert.equal(broken.status, 2, broken.stdout + broken.stderr);
116+ assert.match(broken.stderr, /bytes, the manifest says|SHA-256/);
117+ } finally {
118+ rmSync(root, { recursive: true, force: true });
119+ }
120+});
121+
122+test("restore keeps only the refs the last entry names", async () => {
123+ const root = mkdtempSync(join(tmpdir(), "g1t-drill-restore-"));
124+ try {
125+ const origin = join(root, "origin");
126+ mkdirSync(origin);
127+ git(origin, "init", "--quiet", "--initial-branch=main");
128+ commit(origin, "a.txt", "one");
129+ git(origin, "branch", "gone");
130+ const mirror = join(root, "mirror.git");
131+ git(root, "clone", "--mirror", "--quiet", origin, mirror);
132+ const bundle = join(root, "1-full.bundle");
133+ cut(mirror, [], bundle);
134+ const refs = refsOf(mirror);
135+ delete refs["refs/heads/gone"];
136+ const manifest = { version: 1, chain: [entry("1", "full", "k", bundle, refs, [])] };
137+ const restored = await restore(manifest, async () => bundle, join(root, "restored.git"), root);
138+ assert.deepEqual(compareRefs(refs, restored), []);
139+ } finally {
140+ rmSync(root, { recursive: true, force: true });
141+ }
142+});