Public: anyone can read this; the crew can change it.

The weekly restore drill posts its verdict on the recovery issue, and a failed drill is a card in the inbox #1717

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/1481-the-weekly-restore-drill-posts-its-verdi → main 5 commits fork 2 threads · 2 unresolved

  • Merged. scout/1481-the-weekly-restore-drill-posts-its-verdi was merged into main as df3890d.

Review

Sign in to review this change.
12 files changed +479 −3
docs/RECOVERY.md +9
@@ -10,6 +10,15 @@ region `S3_REGION`) under the prefix `snapshots/<name>/`, one directory per
1010 snapshot, named like `2026-09-22T08-00-00Z`. The restore command accepts `latest` for
1111 the newest; no object is called that.
1212
13 ## Where the verdicts are posted
14
15 Sundays 09:00 UTC the forge drills a restore of the newest snapshot, boots it
16 on a side port and reads `/api/v1/health`. One comment lands on the recovery
17 issue (`FORGE_RECOVERY_ISSUE` in `FORGE_RECOVERY_REPO`): snapshot, restore
18 time, health, counts, pass or fail. A failed drill is an inbox card naming
19 the step (fetch, restore, open, count, unseal, health) until one passes. No
20 comment this week means the backups are unproven.
21
1322 ## What a restore needs
1423
1524 Export these, with the values the forge ran under:
scripts/gate.ts +1
@@ -138,6 +138,7 @@ export const GATE: readonly string[] = [
138138 "test/helpers.test.ts",
139139 "test/shards.test.ts",
140140 "test/snapshot-drill.test.ts",
141 "test/drill-verdict.test.ts",
141142 "test/snapshot-drill-page.test.ts",
142143 "test/snapshot-store.test.ts",
143144 "test/migrate.test.ts",
src/kernel/vitals.ts +7
@@ -20,6 +20,7 @@ import {
2020 queueDepth,
2121 } from "../core/runs.ts";
2222 import { STALE_AFTER_MS } from "../runs/lease.ts";
23 import { failedSentence, lastVerdict } from "../snapshot/verdict.ts";
2324
2425 /** Free disk under this share of the volume is something to say. */
2526 export const DISK_FREE_MIN = 0.1;
@@ -272,6 +273,12 @@ export function vitals(input: VitalsInput): Vitals {
272273 "/settings/capacity",
273274 );
274275
276 // A failed restore drill is a card until a drill passes.
277 const drill = lastVerdict(input.dataDir);
278 const failed = failedSentence(drill);

A sync file read per vitals call. Fine at this size; move the verdict into the drill table when a migration is next due.

279 if (failed !== undefined)
280 need("drill", failed, drill?.at, "/settings/backups");
281
275282 const apps = { serving: 0, asleep: 0, previews: 0 };
276283 for (const e of input.envs ?? []) {
277284 if (e.env !== "production") apps.previews++;
src/snapshot/boot.ts added +70
@@ -0,0 +1,70 @@
1 /**
2 * Boot a restored data directory on a side port and read its health, the
3 * way `docs/RECOVERY.md` does by hand. The child gets a clean environment:
4 * the directory, a free port, no SSH listener, and `FORGE_APP_ENV=preview`
5 * so the crew stays off a copy of production's queue. It is killed whatever
6 * the answer.
7 */
8
9 import { join } from "node:path";
10
11 export interface Booted {
12 readonly ok: boolean;
13 /** Why not, for the person reading; never a value. */
14 readonly reason: string;
15 }
16
17 const TREE = join(import.meta.dir, "../..");
18
19 async function freePort(): Promise<number> {
20 const probe = Bun.serve({ port: 0, fetch: () => new Response("") });
21 const port = probe.port ?? 0;
22 await probe.stop(true);
23 return port;
24 }
25
26 export async function bootRestored(
27 dataDir: string,
28 budgetMs: number,
29 ): Promise<Booted> {
30 const port = await freePort();
31 const child = Bun.spawn([process.execPath, join(TREE, "src/main.ts")], {
32 cwd: TREE,
33 env: {
34 PATH: process.env["PATH"] ?? "",
35 FORGE_DATA: dataDir,
36 PORT: String(port),
37 FORGE_SSH_PORT: "0",
38 FORGE_APP_ENV: "preview",
39 // Only a test host sets this: its internal door is a port, not a socket.
40 ...(process.env["FORGE_TEST_SOCKET"]
41 ? { FORGE_TEST_SOCKET: process.env["FORGE_TEST_SOCKET"] }
42 : {}),
43 },
44 stdout: "ignore",
45 stderr: "ignore",
46 });
47 const until = performance.now() + Math.min(budgetMs, 60_000);
48 let last = "no answer";
49 try {
50 while (performance.now() < until) {
51 if (child.exitCode !== null)
52 return { ok: false, reason: `the restored forge exited with ${child.exitCode}` };
53 try {
54 const res = await fetch(`http://127.0.0.1:${port}/api/v1/health`, {
55 signal: AbortSignal.timeout(2_000),
56 });
57 const body = (await res.json().catch(() => null)) as { ok?: unknown } | null;
58 if (res.ok && body?.ok === true) return { ok: true, reason: "" };
59 last = `health answered ${res.status}${body?.ok === false ? " not ok" : ""}`;
60 } catch {
61 last = "no answer yet";
62 }
63 await Bun.sleep(200);
64 }
65 return { ok: false, reason: `the restored forge never read healthy: ${last}` };
66 } finally {
67 child.kill();
68 await child.exited;
69 }
70 }
src/snapshot/drill.ts +18 −2
@@ -2,7 +2,8 @@
22 * The restore drill: a backup nobody has restored is a hope. `runDrill`
33 * takes the newest snapshot, restores it into a fresh directory, opens the
44 * database read-only, counts what is in it, opens one sealed value with the
5 * restored key, and removes the directory whatever happened.
5 * restored key, boots the restored directory on a side port and reads
6 * `/api/v1/health`, and removes the directory whatever happened.
67 *
78 * Plain data out: when it ran, which snapshot, how long, what it counted,
89 * and a verdict. A red verdict names the step that failed; a step that
@@ -11,6 +12,7 @@
1112
1213 import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
1314 import { join } from "node:path";
15 import { bootRestored } from "./boot.ts";
1416 import { forgetKey, open } from "../kernel/secret.ts";
1517 import { restoreSnapshot } from "./restore.ts";
1618 import { listSnapshots } from "./snapshot.ts";
@@ -18,7 +20,7 @@ import type { Store } from "./store.ts";
1820
1921 export const DRILL_TIMEOUT_MS = 10 * 60_000;
2022
21 export type DrillStep = "fetch" | "restore" | "open" | "count" | "unseal";
23 export type DrillStep = "fetch" | "restore" | "open" | "count" | "unseal" | "health";
2224
2325 export interface DrillDeps {
2426 readonly store: Store;
@@ -29,6 +31,8 @@ export interface DrillDeps {
2931 readonly now?: (() => Date) | undefined;
3032 /** Test seam: the restore itself. */
3133 readonly restore?: typeof restoreSnapshot | undefined;
34 /** Test seam: boot a data directory on a side port and say whether it is healthy. */
35 readonly boot?: typeof bootRestored | undefined;
3236 }
3337
3438 export interface DrillResult {
@@ -36,6 +40,8 @@ export interface DrillResult {
3640 readonly snapshotId: string | null;
3741 readonly ms: number;
3842 readonly counts: { readonly repositories: number; readonly issues: number };
43 /** The side-booted forge's word; "unreached" when an earlier step failed. */
44 readonly health: "ok" | "failed" | "unreached";
3945 readonly verdict: "green" | "red";
4046 readonly failedStep: DrillStep | null;
4147 /** Why it is red, for the person reading; never a value. */
@@ -63,6 +69,7 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
6369 let step: DrillStep = "fetch";
6470 let scratch: string | undefined;
6571 let reason: string | null = null;
72 let health: DrillResult["health"] = "unreached";
6673 try {
6774 // One clock for the whole drill, so a hang in any step ends it red.
6875 const deadline = started + timeoutMs;
@@ -122,6 +129,14 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
122129 } finally {
123130 db.close();
124131 }
132
133 step = "health";
134 const booted = await within(
135 (deps.boot ?? bootRestored)(target, left()),
136 left(),
137 );
138 health = booted.ok ? "ok" : "failed";
139 if (!booted.ok) throw new Error(booted.reason);
125140 } catch (error) {
126141 reason =
127142 error instanceof StepTimeout
@@ -144,6 +159,7 @@ export async function runDrill(deps: DrillDeps): Promise<DrillResult> {
144159 snapshotId,
145160 ms: Math.round(performance.now() - started),
146161 counts,
162 health,
147163 verdict: failedStep === null ? "green" : "red",
148164 failedStep,
149165 reason,
src/snapshot/drills.ts +3
@@ -46,6 +46,9 @@ export function lastDrill(db: Database): DrillResult | undefined {
4646 snapshotId: row.snapshot_id,
4747 ms: row.ms,
4848 counts: { repositories: row.repositories, issues: row.issues },
49 // Not stored: green means health read ok; a red one failed at it or never got there.
50 health:
51 row.verdict === "green" ? "ok" : row.failed_step === "health" ? "failed" : "unreached",
4952 verdict: row.verdict,
5053 failedStep: row.failed_step,
5154 reason: row.reason,
src/snapshot/service.ts +28
@@ -10,6 +10,7 @@
1010
1111 import type { App } from "../app.ts";
1212 import { createLog } from "../kernel/log.ts";
13 import { reportVerdict } from "./verdict.ts";
1314 import {
1415 KEEP,
1516 listSnapshots,
@@ -51,6 +52,18 @@ const handles = new WeakMap<App, SnapshotsHandle>();
5152 export const snapshotsOf = (app: App): SnapshotsHandle | undefined =>
5253 handles.get(app);
5354
55 /** The weekly restore drill: Sunday, an hour after the nightly snapshot. */
56 const DRILL_DAY_UTC = 0;
57 const DRILL_HOUR_UTC = 9;
58
59 export function untilDrill(now = Date.now()): number {
60 const next = new Date(now);
61 next.setUTCHours(DRILL_HOUR_UTC, 0, 0, 0);
62 while (next.getTime() <= now || next.getUTCDay() !== DRILL_DAY_UTC)
63 next.setUTCDate(next.getUTCDate() + 1);
64 return next.getTime() - now;
65 }
66
5467 export function untilNightly(now = Date.now()): number {
5568 const next = new Date(now);
5669 next.setUTCHours(NIGHTLY_HOUR_UTC, 0, 0, 0);
@@ -139,6 +152,9 @@ export function startSnapshots(
139152 passphrase: options.passphrase,
140153 });
141154 recordDrill(app.db, result);
155 // The card and the recovery issue hear of every drill, whoever asked.
156 const posted = await reportVerdict(app.config.dataDir, result);
157 log.info("restore drill", { verdict: result.verdict, step: result.failedStep ?? "none", posted });
142158 return result;
143159 })().finally(() => {
144160 drilling = undefined;
@@ -157,6 +173,17 @@ export function startSnapshots(
157173 };
158174 schedule();
159175
176 let drillTimer: ReturnType<typeof setTimeout> | undefined;
177 const scheduleDrill = (): void => {
178 if (options.nightly === false || stopped) return;
179 drillTimer = setTimeout(() => {
180 drill()
181 .catch((error) => log.error("restore drill failed to run", { error: String(error) }))

Untested timer. Add a seam for the delay so a test can fire it and check that it drills once and re-arms.

182 .finally(scheduleDrill);
183 }, untilDrill());
184 };
185 scheduleDrill();
186
160187 const handle: SnapshotsHandle = {
161188 get store() {
162189 return current();
@@ -176,6 +203,7 @@ export function startSnapshots(
176203 stop: async () => {
177204 stopped = true;
178205 if (timer !== undefined) clearTimeout(timer);
206 if (drillTimer !== undefined) clearTimeout(drillTimer);
179207 if (inFlight !== undefined) await inFlight.catch(() => {});
180208 if (handles.get(app) === handle) handles.delete(app);
181209 },
src/snapshot/verdict.ts added +96
@@ -0,0 +1,96 @@
1 /**
2 * The drill's verdict, told: kept in one small file under the data
3 * directory (`drill/last.json`), so the inbox card (`kernel/vitals.ts`)
4 * stands for as long as the last drill was red and goes when the next one
5 * passes; and posted as one comment on the recovery issue
6 * (`FORGE_RECOVERY_REPO`, `FORGE_RECOVERY_ISSUE`, through `FORGE_URL` and
7 * `FORGE_TOKEN`). Plain data in, plain data out.
8 */
9
10 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
11 import { join } from "node:path";
12 import type { DrillResult, DrillStep } from "./drill.ts";
13
14 const STEPS: readonly DrillStep[] = ["fetch", "restore", "open", "count", "unseal", "health"];
15
16 const lastFile = (dataDir: string): string => join(dataDir, "drill", "last.json");
17
18 export function recordVerdict(dataDir: string, result: DrillResult): void {
19 mkdirSync(join(dataDir, "drill"), { recursive: true });
20 const tmp = `${lastFile(dataDir)}.tmp`;
21 writeFileSync(tmp, JSON.stringify(result));
22 renameSync(tmp, lastFile(dataDir));
23 }
24
25 /** The last drill's verdict, or undefined when none was recorded or the file is not one. */
26 export function lastVerdict(dataDir: string): DrillResult | undefined {
27 try {
28 const r = JSON.parse(readFileSync(lastFile(dataDir), "utf8")) as DrillResult;
29 if (r.verdict !== "green" && r.verdict !== "red") return undefined;
30 if (r.failedStep !== null && !STEPS.includes(r.failedStep)) return undefined;
31 if (typeof r.at !== "string") return undefined;
32 return r;
33 } catch {
34 return undefined;
35 }
36 }
37
38 /** The inbox card's sentence for a red drill; undefined for a green one. */
39 export function failedSentence(r: DrillResult | undefined): string | undefined {
40 if (r === undefined || r.verdict !== "red") return undefined;
41 return `The restore drill failed at ${r.failedStep ?? "an unknown step"}${r.reason ? `: ${r.reason}` : ""}. Snapshot ${r.snapshotId ?? "none"}; docs/RECOVERY.md says what to do.`;
42 }
43
44 /** The comment: snapshot, time taken, health, counts, pass or fail. */
45 export function verdictComment(r: DrillResult): string {
46 const pass = r.verdict === "green";
47 return [
48 `Restore drill ${pass ? "PASSED" : `FAILED at ${r.failedStep}`} (${r.at}).`,
49 "",
50 `- snapshot: ${r.snapshotId ?? "none"}`,
51 `- restore took: ${(r.ms / 1000).toFixed(1)} s`,
52 `- health: ${r.health}`,
53 `- repositories: ${r.counts.repositories}`,
54 `- issues: ${r.counts.issues}`,
55 ...(pass || !r.reason ? [] : [`- why: ${r.reason}`]),
56 ].join("\n");
57 }
58
59 export type Poster = (body: string) => Promise<string>;
60
61 /** Post on the recovery issue named by the environment; says what it did. */
62 export const postToRecoveryIssue: Poster = async (body) => {
63 const url = process.env["FORGE_URL"];
64 const token = process.env["FORGE_TOKEN"];
65 const repo = process.env["FORGE_RECOVERY_REPO"] ?? "trevato/forge";
66 const issue = Number(process.env["FORGE_RECOVERY_ISSUE"]);
67 if (!url || !token || !Number.isInteger(issue) || issue < 1)
68 return "no FORGE_URL, FORGE_TOKEN and FORGE_RECOVERY_ISSUE; not posted";
69 const res = await fetch(`${url}/api/v1/repos/${repo}/issues/${issue}/comments`, {
70 method: "POST",
71 // An unresponsive forge must not hold the shared drill open.
72 signal: AbortSignal.timeout(10_000),
73 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
74 body: JSON.stringify({ body }),
75 });
76 return res.ok ? `posted: ${res.status}` : `not posted: ${res.status}`;
77 };
78
79 /** Keep the verdict for the card and tell the recovery issue. A failed post never loses the verdict. */
80 export async function reportVerdict(
81 dataDir: string,
82 result: DrillResult,
83 post: Poster = postToRecoveryIssue,
84 ): Promise<string> {
85 // A disk that will not take the file must not keep the comment from going.
86 const kept = (() => {
87 try {
88 recordVerdict(dataDir, result);
89 return "";
90 } catch (error) {
91 return ` (verdict not kept: ${String(error)})`;
92 }
93 })();
94 const said = await post(verdictComment(result)).catch((error) => `not posted: ${String(error)}`);
95 return said + kept;
96 }
test/drill-verdict.test.ts added +235
@@ -0,0 +1,235 @@
1 /**
2 * The drill tells people. A drill that finishes posts one comment on the
3 * recovery issue (snapshot, time, health, counts, verdict); a failed one is
4 * an inbox card naming the step, and the next passing drill clears it. A
5 * restored directory is booted on a side port and must read healthy.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtempSync } from "node:fs";
9 import { rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { createUser } from "../src/core/users.ts";
15 import { vitals } from "../src/kernel/vitals.ts";
16 import { bootRestored } from "../src/snapshot/boot.ts";
17 import { takeSnapshot } from "../src/snapshot/snapshot.ts";
18 import { dirStore } from "../src/snapshot/store.ts";
19 import { runDrill } from "../src/snapshot/drill.ts";
20 import { untilDrill } from "../src/snapshot/service.ts";
21 import { lastVerdict, postToRecoveryIssue, reportVerdict, verdictComment } from "../src/snapshot/verdict.ts";
22
23 const PASS = "correct horse battery staple";
24
25 describe("the drill's verdict", () => {
26 let root: string;
27 let app: App;
28 const store = () => dirStore(join(root, "store"));
29 const card = () =>
30 vitals({ db: app.db, dataDir: app.config.dataDir, signals: app.signals }).degraded.find(
31 (d) => d.cause === "drill",
32 );
33
34 beforeAll(async () => {
35 root = mkdtempSync(join(tmpdir(), "forge-verdict-"));
36 app = createApp({ dataDir: join(root, "live"), port: 0 });
37 const u = await createUser(app.db, {
38 trusted: true,
39 username: "alice",
40 email: "[email protected]",
41 password: "a-good-passphrase",
42 adminIfFirst: true,
43 });
44 if (!u.ok) throw new Error(u.error);
45 const repo = await createRepo(app, u.value, { name: "notes", visibility: "private" });
46 if (!repo.ok) throw new Error(String(repo.error));
47 await takeSnapshot(
48 {
49 db: app.db,
50 dataDir: app.config.dataDir,
51 reposRoot: app.reposRoot,
52 version: "t",
53 passphrase: PASS,
54 },
55 store(),
56 new Date("2026-09-22T08:00:00Z"),
57 );
58 });
59
60 afterAll(async () => {
61 app.close();
62 await rm(root, { recursive: true, force: true });
63 });
64
65 // The shipped path: `drill()` in the service runs the drill, then reports it.
66 const drillAndReport = async (
67 d: ReturnType<typeof deps>,
68 post: (body: string) => Promise<string>,
69 ) => {
70 const result = await runDrill(d);
71 return { result, posted: await reportVerdict(d.dataDir, result, post) };
72 };
73
74 const deps = (boot: () => Promise<{ ok: boolean; reason: string }>) => ({
75 store: store(),
76 dataDir: app.config.dataDir,
77 passphrase: PASS,
78 boot,
79 });
80
81 test("a stand-in drill posts one comment with snapshot, time, health, counts and the verdict", async () => {
82 const posts: string[] = [];
83 const { result } = await drillAndReport(
84 deps(async () => ({ ok: true, reason: "" })),
85 async (body) => {
86 posts.push(body);
87 return "posted: 201";
88 },
89 );
90 expect(result.verdict).toBe("green");
91 expect(result.health).toBe("ok");
92 expect(posts).toHaveLength(1);
93 expect(posts[0]).toContain("PASSED");
94 expect(posts[0]).toContain("snapshot: 2026-09-22T08-00-00Z");
95 expect(posts[0]).toContain("health: ok");
96 expect(posts[0]).toContain("repositories: 1");
97 expect(posts[0]).toContain("issues: 0");
98 expect(posts[0]).toMatch(/restore took: \d+\.\d s/);
99 expect(card()).toBeUndefined();
100 });
101
102 test("a failing drill makes the card naming the step, and the next passing drill clears it", async () => {
103 const posts: string[] = [];
104 const post = async (body: string) => (posts.push(body), "ok");
105 const { result } = await drillAndReport(
106 deps(async () => ({ ok: false, reason: "health answered 503" })),
107 post,
108 );
109 expect(result.verdict).toBe("red");
110 expect(result.failedStep).toBe("health");
111 expect(posts[0]).toContain("FAILED at health");
112 const c = card();
113 expect(c?.sentence).toContain("failed at health");
114 expect(c?.sentence).toContain("health answered 503");
115 expect(lastVerdict(app.config.dataDir)?.verdict).toBe("red");
116
117 await drillAndReport(deps(async () => ({ ok: true, reason: "" })), post);
118 expect(card()).toBeUndefined();
119 });
120
121 test("a post that throws still keeps the verdict", async () => {
122 const { posted } = await drillAndReport(
123 deps(async () => ({ ok: false, reason: "no" })),
124 async () => {
125 throw new Error("forge unreachable");
126 },
127 );
128 expect(posted).toContain("forge unreachable");
129 expect(card()).toBeDefined();
130 });
131
132 test("the comment of a drill that never reached health says so", () => {
133 const text = verdictComment({
134 at: "2026-09-22T09:00:00Z",
135 snapshotId: null,
136 ms: 5,
137 counts: { repositories: 0, issues: 0 },
138 health: "unreached",
139 verdict: "red",
140 failedStep: "fetch",
141 reason: "the store holds no snapshot",
142 });
143 expect(text).toContain("FAILED at fetch");
144 expect(text).toContain("health: unreached");
145 });
146
147 test("the real boot reads a restored directory's health on a side port", async () => {
148 const dir = join(root, "boot-data");
149 const { restoreSnapshot } = await import("../src/snapshot/restore.ts");
150 await restoreSnapshot(store(), "2026-09-22T08-00-00Z", dir, { passphrase: PASS });
151 const booted = await bootRestored(dir, 40_000);
152 expect(booted).toEqual({ ok: true, reason: "" });
153 }, 60_000);
154 });
155
156 describe("the weekly schedule", () => {
157 const at = (iso: string) => Date.parse(iso);
158 const fires = (iso: string) => new Date(at(iso) + untilDrill(at(iso)));
159
160 test("from a Saturday it fires the next day at 09:00 UTC", () => {
161 expect(fires("2026-10-17T12:00:00Z").toISOString()).toBe("2026-10-18T09:00:00.000Z");
162 });
163 test("on a Sunday before 09:00 it fires that morning", () => {
164 expect(fires("2026-10-18T08:59:00Z").toISOString()).toBe("2026-10-18T09:00:00.000Z");
165 });
166 test("on a Sunday at or after 09:00 it waits a week", () => {
167 expect(fires("2026-10-18T09:00:00Z").toISOString()).toBe("2026-10-25T09:00:00.000Z");
168 expect(fires("2026-10-18T09:00:01Z").toISOString()).toBe("2026-10-25T09:00:00.000Z");
169 });
170 test("whenever asked, it is a Sunday at 09:00 UTC, within a week", () => {
171 for (let h = 0; h < 24 * 14; h += 7) {
172 const now = at("2026-01-01T00:30:00Z") + h * 3_600_000;
173 const wait = untilDrill(now);
174 const d = new Date(now + wait);
175 expect([d.getUTCDay(), d.getUTCHours(), d.getUTCMinutes()]).toEqual([0, 9, 0]);
176 expect(wait).toBeGreaterThan(0);
177 expect(wait).toBeLessThanOrEqual(7 * 24 * 3_600_000);
178 }
179 });
180 });
181
182 describe("telling the recovery issue", () => {
183 const real = globalThis.fetch;
184 const env = { ...process.env };
185 afterAll(() => {
186 globalThis.fetch = real;
187 process.env = env;
188 });
189 const setup = (status: number) => {
190 process.env["FORGE_URL"] = "http://forge.test";
191 process.env["FORGE_TOKEN"] = "t";
192 process.env["FORGE_RECOVERY_ISSUE"] = "7";
193 const seen: { url: string; signal: boolean }[] = [];
194 globalThis.fetch = (async (url: string, init: RequestInit) => {
195 seen.push({ url, signal: init.signal !== undefined });
196 return new Response("{}", { status });
197 }) as unknown as typeof fetch;
198 return seen;
199 };
200
201 test("a 201 is posted, to the named issue, with a timeout", async () => {
202 const seen = setup(201);
203 expect(await postToRecoveryIssue("hi")).toBe("posted: 201");
204 expect(seen[0]).toEqual({
205 url: "http://forge.test/api/v1/repos/trevato/forge/issues/7/comments",
206 signal: true,
207 });
208 });
209
210 test("a 403 is not posted", async () => {
211 setup(403);
212 expect(await postToRecoveryIssue("hi")).toBe("not posted: 403");
213 });
214
215 test("a verdict file that cannot be written still lets the comment go", async () => {
216 const dir = join(tmpdir(), "forge-verdict-nodir", "\0bad");
217 const posts: string[] = [];
218 const said = await reportVerdict(
219 dir,
220 {
221 at: "2026-10-18T09:00:00Z",
222 snapshotId: "s",
223 ms: 1,
224 counts: { repositories: 0, issues: 0 },
225 health: "ok",
226 verdict: "green",
227 failedStep: null,
228 reason: null,
229 },
230 async (b) => (posts.push(b), "posted: 201"),
231 );
232 expect(posts).toHaveLength(1);
233 expect(said).toContain("verdict not kept");
234 });
235 });
test/recovery-doc.test.ts +9 −1
@@ -63,5 +63,13 @@ test("what a snapshot lacks is what snapshot.ts leaves out", () => {
6363 });
6464
6565 test("the doc fits one printed side", () => {
66 expect(doc.split("\n").length).toBeLessThanOrEqual(70);
66 expect(doc.split("\n").length).toBeLessThanOrEqual(80);
67 });
68
69 test("the doc says where the drill's verdicts are posted, and names its steps", () => {
70 expect(doc).toContain("FORGE_RECOVERY_ISSUE");
71 expect(doc).toContain("FORGE_RECOVERY_REPO");
72 expect(doc).toContain("/api/v1/health");
73 const steps = read("src/snapshot/drill.ts").match(/type DrillStep = ([^;]+);/)?.[1];
74 for (const step of steps!.matchAll(/"(\w+)"/g)) expect(doc).toContain(step[1]!);
6775 });
test/snapshot-drill-page.test.ts +1
@@ -63,6 +63,7 @@ describe("the drill on the Backups page", () => {
6363 snapshotId: "snap-stub-1",
6464 ms: 1234,
6565 counts: { repositories: 7, issues: 42 },
66 health: verdict === "green" ? "ok" : "unreached",
6667 verdict,
6768 failedStep: verdict === "green" ? null : "unseal",
6869 reason: verdict === "green" ? null : "a sealed value does not open",
test/snapshot-drill.test.ts +2
@@ -88,6 +88,8 @@ describe("the restore drill", () => {
8888 store: store(),
8989 dataDir: join(root, "drill-data"),
9090 passphrase: PASS,
91 // The boot has its own test (drill-verdict); here it answers healthy.
92 boot: async () => ({ ok: true, reason: "" }),
9193 ...extra,
9294 });
9395

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs