Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

236 linesCodeBlameRaw
1// The fallback path end to end: bundles cut as the
2// runner cuts them, restored into a git store's root, served by the git
3// store itself (deploy/self-host/gitstore/server.mjs, read-only), pushed to
4// while it serves, and reconciled back into the "live" repository.
5
6import assert from "node:assert/strict";
7import { execFileSync, spawn, spawnSync } from "node:child_process";
8import { createHash } from "node:crypto";
9import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
10import { tmpdir } from "node:os";
11import { join } from "node:path";
12import { test } from "node:test";
13import { fileURLToPath } from "node:url";
14
15import { parseRefs } from "./backup-restore-drill.mjs";
16import { CONFLICT_PREFIX, changedSince, locate, metaFor, reconcilePlan, repoDir } from "./restore-to-gitstore.mjs";
17
18const TOOL = fileURLToPath(new URL("./restore-to-gitstore.mjs", import.meta.url));
19const SERVER = fileURLToPath(new URL("../../deploy/self-host/gitstore/server.mjs", import.meta.url));
20const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim();
21const tool = (...args) => spawnSync(process.execPath, [TOOL, ...args], { encoding: "utf8" });
22
23function commit(dir, file, text) {
24 writeFileSync(join(dir, file), text);
25 git(dir, "add", "--all");
26 git(dir, "-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", "commit", "--quiet", "-m", text);
27}
28
29function refsOf(dir) {
30 const refs = parseRefs(git(dir, "for-each-ref", "--format=%(objectname) %(refname)"));
31 refs.HEAD = git(dir, "rev-parse", "HEAD");
32 return refs;
33}
34
35/** A bucket holding one full backup of `origin` as `repo_1`, as the runner and repos service leave it. */
36function backUp(root, origin, id, storeKey) {
37 const mirror = join(root, `${id}-mirror.git`);
38 git(root, "clone", "--mirror", "--quiet", origin, mirror);
39 const dir = join(root, "bucket", "backups", id);
40 mkdirSync(dir, { recursive: true });
41 const bundle = join(dir, "1-full.bundle");
42 git(mirror, "bundle", "create", "--quiet", bundle, "--all");
43 const entry = {
44 id: "20261006T025300Z",
45 kind: "full",
46 key: `backups/${id}/1-full.bundle`,
47 created_at: "2026-10-06T02:53:00.000Z",
48 refs_version: 1,
49 refs: refsOf(mirror),
50 prerequisites: [],
51 size: statSync(bundle).size,
52 sha256: createHash("sha256").update(readFileSync(bundle)).digest("hex"),
53 };
54 const manifest = { version: 1, repo_id: id, store_key: storeKey, path: { namespace: "acme", name: "rocket" }, updated_at: "", chain: [entry], previous: [] };
55 writeFileSync(join(dir, "manifest.json"), JSON.stringify(manifest));
56 return join(root, "bucket");
57}
58
59/** The git store, serving `root`, until `stop()`. */
60async function serve(root, { readOnly }) {
61 const port = 41000 + Math.floor(Math.random() * 2000);
62 const secret = "0123456789abcdef0123456789abcdef";
63 const child = spawn(process.execPath, [SERVER], {
64 env: {
65 ...process.env,
66 GITSTORE_ROOT: root,
67 GITSTORE_PORT: String(port),
68 GITSTORE_SECRET: secret,
69 GITSTORE_URL: `http://127.0.0.1:${port}`,
70 GITSTORE_READ_ONLY: readOnly ? "1" : "",
71 },
72 stdio: "ignore",
73 });
74 const url = `http://127.0.0.1:${port}`;
75 for (let tries = 0; tries < 100; tries++) {
76 try {
77 if ((await fetch(`${url}/healthz`)).ok) break;
78 } catch {}
79 await new Promise((resolve) => setTimeout(resolve, 100));
80 }
81 const api = async (method, path, body) => {
82 const response = await fetch(`${url}/api/repos${path}`, {
83 method,
84 headers: { "x-gitstore-secret": secret, ...(body ? { "content-type": "application/json" } : {}) },
85 body: body ? JSON.stringify(body) : undefined,
86 });
87 return { status: response.status, json: await response.json().catch(() => ({})) };
88 };
89 return { url, secret, api, stop: () => child.kill() };
90}
91
92test("keys, plans and what a restore records", () => {
93 assert.deepEqual(locate("acme--rocket"), { namespace: "g1t", name: "acme--rocket" });
94 assert.deepEqual(locate("g1t-us-1/pulls--pul_1"), { namespace: "g1t-us-1", name: "pulls--pul_1" });
95 assert.throws(() => locate("../x"), /not a store key/);
96 assert.equal(repoDir("/srv", "g1t", "acme--rocket").replaceAll("\\", "/"), "/srv/g1t/acme--rocket.git");
97 const a = "a".repeat(40);
98 const b = "b".repeat(40);
99 const c = "c".repeat(40);
100 const changes = changedSince({ "refs/heads/main": a, "refs/heads/old": a }, { "refs/heads/main": b, "refs/heads/new": c });
101 assert.deepEqual(changes, [
102 { ref: "refs/heads/main", base: a, now: b },
103 { ref: "refs/heads/new", base: null, now: c },
104 { ref: "refs/heads/old", base: a, now: null },
105 ]);
106 // Live still as backed up: moved. Live has it: nothing. Live moved too: kept beside.
107 assert.deepEqual(
108 reconcilePlan(changes, { "refs/heads/main": a, "refs/heads/old": c }).map((step) => [step.ref, step.action, step.kept ?? null]),
109 [
110 ["refs/heads/main", "move", null],
111 ["refs/heads/new", "move", null],
112 ["refs/heads/old", "conflict", null],
113 ],
114 );
115 assert.equal(reconcilePlan(changes, { "refs/heads/main": c })[0].kept, `${CONFLICT_PREFIX}heads/main`);
116 assert.equal(reconcilePlan(changes, { "refs/heads/main": b })[0].action, "same");
117 const manifest = { chain: [{ id: "e1", created_at: "t", refs: { HEAD: a, "refs/heads/main": a } }] };
118 const meta = metaFor({ id: "repo_1" }, manifest, "now", { id: "kept", createdAt: "then" });
119 assert.equal(meta.id, "kept");
120 assert.deepEqual(meta.restored.refs, { "refs/heads/main": a });
121 assert.equal(meta.restored.entry, "e1");
122});
123
124test("restored repositories are served read-only, and pushes taken later are reconciled", async () => {
125 const root = mkdtempSync(join(tmpdir(), "g1t-fallback-test-"));
126 let server = null;
127 try {
128 const origin = join(root, "origin");
129 mkdirSync(origin);
130 git(origin, "init", "--quiet", "--initial-branch=main");
131 commit(origin, "a.txt", "one");
132 git(origin, "-c", "user.name=t", "-c", "user.email=t@example.com", "tag", "-a", "v1", "-m", "v1");
133 const bucket = backUp(root, origin, "repo_1", "g1t-us-1/acme--rocket");
134 const index = join(root, "index.json");
135 writeFileSync(
136 index,
137 JSON.stringify([
138 { id: "repo_1", store: "g1t-us-1/acme--rocket", path: "acme/rocket" },
139 { id: "repo_2", store: "acme--gone", path: "acme/gone" },
140 ]),
141 );
142 const store = join(root, "store");
143
144 const first = tool("restore", "--into", store, "--bundles", bucket, "--index", index);
145 assert.equal(first.status, 0, first.stdout + first.stderr);
146 assert.match(first.stdout, /1 restored, 0 already current, 1 without a backup/);
147 const dir = repoDir(store, "g1t-us-1", "acme--rocket");
148 assert.ok(existsSync(join(dir, "g1t.json")));
149 assert.equal(git(dir, "rev-parse", "refs/heads/main"), git(origin, "rev-parse", "main"));
150 // Again: nothing to do.
151 const again = tool("restore", "--into", store, "--bundles", bucket, "--index", index);
152 assert.match(again.stdout, /0 restored, 1 already current/);
153 // Only a namespace asked for.
154 assert.match(tool("restore", "--into", store, "--bundles", bucket, "--index", index, "--namespace", "g1t-eu").stdout, /^0 repositories/m);
155
156 // Served as the repos service reads it: a namespaced key, a token, git.
157 server = await serve(store, { readOnly: true });
158 const key = encodeURIComponent("g1t-us-1/acme--rocket");
159 const info = await server.api("GET", `/${key}`);
160 assert.equal(info.status, 200);
161 assert.equal(info.json.remote, `${server.url}/git/g1t-us-1/acme--rocket.git`);
162 const read = await server.api("POST", `/${key}/tokens`, { scope: "read", ttl: 600 });
163 assert.equal(read.status, 200);
164 const header = `http.extraHeader=Authorization: Bearer ${read.json.plaintext}`;
165 const clone = join(root, "clone");
166 git(root, "-c", header, "clone", "--quiet", info.json.remote, clone);
167 assert.equal(git(clone, "rev-parse", "HEAD"), git(origin, "rev-parse", "main"));
168 // Read-only: no write token, no new repository.
169 assert.equal((await server.api("POST", `/${key}/tokens`, { scope: "write" })).json.code, "READ_ONLY");
170 assert.equal((await server.api("POST", "", { name: "g1t-us-1/new" })).json.code, "READ_ONLY");
171 assert.equal((await server.api("GET", `/${encodeURIComponent("../etc")}`)).json.code, "INVALID_REPO_NAME");
172 server.stop();
173 server = null;
174
175 // While it served with writes allowed, a push came in.
176 assert.match(tool("changed", "--into", store).stdout, /^0 repositories/m);
177 commit(clone, "b.txt", "pushed to the fallback");
178 git(clone, "push", "--quiet", dir, "HEAD:refs/heads/main", "HEAD:refs/heads/during");
179 const changed = tool("changed", "--into", store);
180 assert.match(changed.stdout, /g1t-us-1\/acme--rocket \(repo_1\)/);
181 assert.match(changed.stdout, /refs\/heads\/during/);
182
183 // The live repository still as backed up: the push goes back as it is.
184 const live = join(root, "live");
185 mkdirSync(join(live, "g1t-us-1"), { recursive: true });
186 git(root, "clone", "--bare", "--quiet", origin, repoDir(live, "g1t-us-1", "acme--rocket"));
187 const reconciled = tool("reconcile", "--into", store, "--live-root", live, "--no-bump");
188 assert.equal(reconciled.status, 0, reconciled.stdout + reconciled.stderr);
189 const liveDir = repoDir(live, "g1t-us-1", "acme--rocket");
190 assert.equal(git(liveDir, "rev-parse", "refs/heads/main"), git(clone, "rev-parse", "HEAD"));
191 assert.equal(git(liveDir, "rev-parse", "refs/heads/during"), git(clone, "rev-parse", "HEAD"));
192
193 // Moved on both sides: the live one stays, the fallback's goes beside it.
194 rmSync(liveDir, { recursive: true, force: true });
195 commit(origin, "c.txt", "pushed to Artifacts before the outage, after the backup");
196 git(root, "clone", "--bare", "--quiet", origin, liveDir);
197 const conflicted = tool("reconcile", "--into", store, "--live-root", live, "--no-bump");
198 assert.equal(conflicted.status, 3, conflicted.stdout + conflicted.stderr);
199 assert.match(conflicted.stdout, /moved on both sides/);
200 assert.equal(git(liveDir, "rev-parse", "refs/heads/main"), git(origin, "rev-parse", "main"));
201 assert.equal(git(liveDir, "rev-parse", "refs/fallback/heads/main"), git(clone, "rev-parse", "HEAD"));
202 } finally {
203 server?.stop();
204 rmSync(root, { recursive: true, force: true });
205 }
206});
207
208test("a git store over HTTP is restored into through its API", async () => {
209 const root = mkdtempSync(join(tmpdir(), "g1t-fallback-http-"));
210 let server = null;
211 try {
212 const origin = join(root, "origin");
213 mkdirSync(origin);
214 git(origin, "init", "--quiet", "--initial-branch=main");
215 commit(origin, "a.txt", "one");
216 git(origin, "branch", "dev");
217 const bucket = backUp(root, origin, "repo_1", "acme--rocket");
218 const index = join(root, "index.json");
219 writeFileSync(index, JSON.stringify([{ id: "repo_1", store: "acme--rocket", default_branch: "main" }]));
220 server = await serve(join(root, "store"), { readOnly: false });
221 const run = spawn(process.execPath, [TOOL, "restore", "--gitstore", server.url, "--bundles", bucket, "--index", index], {
222 env: { ...process.env, GITSTORE_SECRET: server.secret },
223 });
224 let out = "";
225 run.stdout.on("data", (chunk) => (out += chunk));
226 run.stderr.on("data", (chunk) => (out += chunk));
227 const status = await new Promise((resolve) => run.on("close", resolve));
228 assert.equal(status, 0, out);
229 // The default namespace's repositories are kept under it.
230 const dir = repoDir(join(root, "store"), "g1t", "acme--rocket");
231 assert.equal(git(dir, "rev-parse", "refs/heads/dev"), git(origin, "rev-parse", "dev"));
232 } finally {
233 server?.stop();
234 rmSync(root, { recursive: true, force: true });
235 }
236});