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

Restore drill, step 2: each drill is kept as a row, and the Backups page shows the last one in a line with a Run a drill button for admins #1707

Merged opened this

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

Merged

scout/forge:scout/1668-restore-drill-step-2-each-drill-is-kept → main 2 commits fork 2 threads · 2 unresolved

  • Merged. scout/1668-restore-drill-step-2-each-drill-is-kept was merged into main as b1d3aba.

Review

Sign in to review this change.
9 files changed +319 −0
README.md +5
@@ -442,6 +442,11 @@ huge one); restore unpacks them. While one runs, progress shows on
442442 **Settings → Backups** and as a line in the log every 500 objects or minute.
443443 The terminal has no backups view, so it has no readout.
444444
445 **Run a drill** on the same page (admins only) restores the newest snapshot
446 into scratch, opens it, counts what is there and unseals a value; the page
447 waits for it, up to 10 minutes, and the last drill shows there as one line.
448 Only one runs at a time. See `docs/SNAPSHOT.md`.
449
445450 `docs/EXTRACT.md` is the forge on a machine of its own, and the platform it
446451 grew up in switched off: what a host owes it, the moves in order, and what is
447452 given up.
docs/SNAPSHOT.md +14
@@ -70,6 +70,20 @@ Delete the old ones from the bucket if the bucket is not trusted.
7070 The store is `Bun.S3Client` or a directory behind one small interface
7171 (`src/snapshot/store.ts`): put, get, list, delete. Nothing else.
7272
73 ## The restore drill
74
75 A backup nobody has restored is a hope. **Settings → Backups → Run a drill**
76 (admins only; anyone else gets 403) restores the newest snapshot into a
77 scratch directory under the data directory, opens the database read-only,
78 counts repositories and issues, opens one sealed value with the restored key,
79 and removes the scratch whatever happened. The page waits until it finishes,
80 for up to 10 minutes; a step that hangs past that is red and names the step.
81 One drill runs at a time: a second click, or a retry after a proxy timeout,
82 joins the drill already running rather than starting another restore. Every
83 run is a row in the `drill` table, and the last one shows as a line on the
84 Backups page: when, which snapshot, how long, the counts, and green or the
85 step that failed.
86
7387 ## Restore
7488
7589 The printable card, seven lines, is [RESTORE.md](RESTORE.md); a test runs it.
scripts/gate.ts +1
@@ -132,6 +132,7 @@ export const GATE: readonly string[] = [
132132 "test/helpers.test.ts",
133133 "test/shards.test.ts",
134134 "test/snapshot-drill.test.ts",
135 "test/snapshot-drill-page.test.ts",
135136 "test/snapshot-store.test.ts",
136137 "test/migrate.test.ts",
137138 "test/schema-additive.test.ts",
src/core/schema.ts +15
@@ -1655,6 +1655,21 @@ export const migrations: readonly string[] = [
16551655 CREATE UNIQUE INDEX degraded_span_open ON degraded_span(cause) WHERE closed_at IS NULL;
16561656 CREATE INDEX degraded_span_closed ON degraded_span(closed_at) WHERE closed_at IS NOT NULL;
16571657 `,
1658 // 80 — restore drills (#1668): one row per run of `snapshot/drill.ts`,
1659 // kept so the Backups page can show the last. `at` is an ISO time.
1660 `
1661 CREATE TABLE drill (
1662 id INTEGER PRIMARY KEY AUTOINCREMENT,
1663 at TEXT NOT NULL,
1664 snapshot_id TEXT,
1665 ms INTEGER NOT NULL,
1666 repositories INTEGER NOT NULL,
1667 issues INTEGER NOT NULL,
1668 verdict TEXT NOT NULL CHECK (verdict IN ('green', 'red')),
1669 failed_step TEXT,
1670 reason TEXT
1671 );
1672 `,
16581673 ];
16591674
16601675 /**
src/http/server.ts +2
@@ -96,6 +96,7 @@ import { secretDelete, secretSet, secretsPage } from "../web/pages/secrets.ts";
9696 import {
9797 backupsClear,
9898 backupsDestination,
99 backupsDrill,
99100 backupsNow,
100101 backupsPage,
101102 backupsTest,
@@ -671,6 +672,7 @@ export function buildRouter(): Router<Handler> {
671672 router.add("POST", "/admin/limits", limitsSet);
672673 router.add("GET", "/settings/backups", backupsPage);
673674 router.add("POST", "/settings/backups/now", backupsNow);
675 router.add("POST", "/settings/backups/drill", backupsDrill);
674676 router.add("POST", "/settings/backups/destination", backupsDestination);
675677 router.add("POST", "/settings/backups/test", backupsTest);
676678 router.add("POST", "/settings/backups/clear", backupsClear);
src/snapshot/drills.ts added +53
@@ -0,0 +1,53 @@
1 /**
2 * The drills, kept: every run of `runDrill` is a row, so the Backups page
3 * can say when the forge last proved a snapshot restores, and what failed
4 * when it did not. Plain data in, plain data out.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import type { DrillResult, DrillStep } from "./drill.ts";
9
10 interface Row {
11 at: string;
12 snapshot_id: string | null;
13 ms: number;
14 repositories: number;
15 issues: number;
16 verdict: "green" | "red";
17 failed_step: DrillStep | null;
18 reason: string | null;
19 }
20
21 export function recordDrill(db: Database, result: DrillResult): void {
22 db.query(
23 `INSERT INTO drill (at, snapshot_id, ms, repositories, issues, verdict, failed_step, reason)
24 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
25 ).run(
26 result.at,
27 result.snapshotId,
28 result.ms,
29 result.counts.repositories,
30 result.counts.issues,
31 result.verdict,
32 result.failedStep,
33 result.reason,
34 );
35 }
36
37 /** The newest drill, or undefined when none has run. */
38 export function lastDrill(db: Database): DrillResult | undefined {
39 const row = db
40 .query<Row, []>("SELECT * FROM drill ORDER BY id DESC LIMIT 1")
41 .get();
42 return row === null
43 ? undefined
44 : {
45 at: row.at,
46 snapshotId: row.snapshot_id,
47 ms: row.ms,
48 counts: { repositories: row.repositories, issues: row.issues },
49 verdict: row.verdict,
50 failedStep: row.failed_step,
51 reason: row.reason,
52 };
53 }
src/snapshot/service.ts +29
@@ -18,6 +18,8 @@ import {
1818 type Manifest,
1919 type Progress,
2020 } from "./snapshot.ts";
21 import { runDrill, type DrillDeps, type DrillResult } from "./drill.ts";
22 import { recordDrill } from "./drills.ts";
2123 import type { Store } from "./store.ts";
2224
2325 const log = createLog("snapshots");
@@ -38,6 +40,8 @@ export interface SnapshotsHandle {
3840 readonly list: () => Promise<Manifest[]>;
3941 /** The last one taken by this process, and what it said. */
4042 readonly last: () => { manifest?: Manifest; error?: string; at?: string };
43 /** Drill the newest snapshot now and keep the result; waits for any snapshot in flight. */
44 readonly drill: () => Promise<DrillResult>;
4145 /** Forget the resolved store; the next use resolves again (a new destination was saved). */
4246 readonly forget: () => void;
4347 readonly stop: () => Promise<void>;
@@ -61,6 +65,8 @@ export function startSnapshots(
6165 readonly version: string;
6266 readonly nightly?: boolean;
6367 readonly passphrase?: string | undefined;
68 /** Test seam: the drill itself. */
69 readonly drill?: ((deps: DrillDeps) => Promise<DrillResult>) | undefined;
6470 } = {
6571 version: "0",
6672 },
@@ -118,6 +124,28 @@ export function startSnapshots(
118124 return inFlight;
119125 };
120126
127 // Single-flight: a restore of every repository is heavy, so a second call
outdated

Nothing stops a second call from starting while the first is still running, so two clicks mean two full restores side by side. Hold the running promise, the way take holds inFlight, and give it to any caller that arrives while it runs. Clear it in a finally.

128 // while one runs joins it instead of filling the disk with another.
129 let drilling: Promise<DrillResult> | undefined;
130 const drill = (): Promise<DrillResult> => {
131 drilling ??= (async (): Promise<DrillResult> => {
132 await (inFlight ?? Promise.resolve()).catch(() => {});
133 const store = current();
134 if (store === undefined)
135 throw new Error("this forge has nowhere to drill from");
136 const result = await (options.drill ?? runDrill)({
137 store,
138 dataDir: app.config.dataDir,
139 passphrase: options.passphrase,
140 });
141 recordDrill(app.db, result);
142 return result;
143 })().finally(() => {
144 drilling = undefined;
145 });
146 return drilling;
147 };
148
121149 let timer: ReturnType<typeof setTimeout> | undefined;
122150 const schedule = (): void => {
123151 if (options.nightly === false || stopped) return;
@@ -135,6 +163,7 @@ export function startSnapshots(
135163 },
136164 wraps: !!options.passphrase,
137165 now: take,
166 drill,
138167 list: async () => {
139168 const store = current();
140169 return store === undefined ? [] : listSnapshots(store);
src/web/pages/backups.ts +60
@@ -16,6 +16,7 @@ import {
1616 } from "../../actions/destination.ts";
1717 import { resolveDestination, sealedSet } from "../../snapshot/destination.ts";
1818 import { keyOf } from "../../snapshot/snapshot.ts";
19 import { lastDrill } from "../../snapshot/drills.ts";
1920 import { snapshotsOf } from "../../snapshot/service.ts";
2021 import type { Ctx } from "../../http/context.ts";
2122 import { notFoundPage, settingsLayout, signInFirst } from "../chrome.ts";
@@ -85,6 +86,31 @@ function keyNote(
8586 </p>`;
8687 }
8788
89 /** The last drill in one line, and the button to run another. */
90 function drillLine(ctx: Ctx): Html {
91 const d = lastDrill(ctx.app.db);
92 const line =
93 d === undefined
94 ? html`<p class="backups-drill">No restore drill has run yet.</p>`
95 : d.verdict === "green"
96 ? html`<p class="backups-drill">
97 ${icon("check")} Last drill ${time(d.at)}: snapshot
98 <code>${d.snapshotId ?? ""}</code> restored green in ${d.ms} ms —
99 ${d.counts.repositories} repositories, ${d.counts.issues} issues.
100 </p>`
101 : html`<p class="backups-error backups-drill">
102 ${icon("alert")} Last drill ${time(d.at)}: snapshot
103 <code>${d.snapshotId ?? "none"}</code> failed at
104 ${d.failedStep ?? "an unknown step"} after ${d.ms} ms${d.reason
105 ? html`: ${d.reason}`
106 : ""}.
107 </p>`;
108 return html`${line}
109 <form method="post" action="/settings/backups/drill" class="backups-now">
110 ${btn("Run a drill", { type: "submit", icon: "clock" })}
111 </form>`;
112 }
113
88114 export async function backupsPage(ctx: Ctx): Promise<Response> {
89115 const denied = requireAdmin(ctx);
90116 if (denied) return denied;
@@ -147,6 +173,7 @@ bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>`
147173 ${icon("alert")} The store could not be listed just now: ${error}
148174 </p>`,
149175 )}
176 ${drillLine(ctx)}
150177 <form method="post" action="/settings/backups/now" class="backups-now">
151178 ${btn("Snapshot now", {
152179 type: "submit",
@@ -367,3 +394,36 @@ export async function backupsNow(ctx: Ctx): Promise<Response> {
367394 });
368395 }
369396 }
397
398 /** POST /settings/backups/drill — restore the newest snapshot, keep what came of it. */
399 export async function backupsDrill(ctx: Ctx): Promise<Response> {
400 if (!ctx.auth.user) return signInFirst(ctx);
401 if (!ctx.auth.user.isAdmin)
402 return new Response("Forbidden", {
403 status: 403,
404 headers: { "content-type": "text/plain; charset=utf-8" },
405 });
406 const form = await readForm(ctx, "admin");
407 if (form instanceof Response) return form;
408 const svc = snapshotsOf(ctx.app);
409 if (svc === undefined || svc.store === undefined)
410 return flashTo(ctx, "/settings/backups", {
411 tone: "danger",
412 message: "This forge has no snapshots to drill.",
413 });
414 try {
415 const d = await svc.drill();

This waits up to DRILL_TIMEOUT_MS (10 minutes) inside the request. The proxy will likely time out first, the admin will likely click again, and with no single-flight that starts a second restore. Once drill() is single-flight, the retry joins the drill already running, which is acceptable for this step.

416 return flashTo(
417 ctx,
418 "/settings/backups",
419 d.verdict === "green"
420 ? { message: "The drill restored green." }
421 : { tone: "danger", message: `The drill failed at ${d.failedStep}.` },
422 );
423 } catch (error) {
424 return flashTo(ctx, "/settings/backups", {
425 tone: "danger",
426 message: `The drill could not run: ${String(error)}`,
427 });
428 }
429 }
test/snapshot-drill-page.test.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * The drill on the Backups page: an admin's POST runs a (stubbed) drill and
3 * keeps it as a row, and the page shows the last one in a line; a non-admin
4 * gets 403 and nothing runs; a cross-origin POST is refused.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtempSync, rmSync } from "node:fs";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createUser } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import type { DrillResult } from "../src/snapshot/drill.ts";
14 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
15 import { dirStore } from "../src/snapshot/store.ts";
16 import { quietLogs } from "./helpers/globals.ts";
17
18 quietLogs();
19
20 describe("the drill on the Backups page", () => {
21 let root = "";
22 let app: App;
23 let svc: SnapshotsHandle;
24 let servers: Servers;
25 let ran = 0;
26 let gate: Promise<void> | undefined;
27 let verdict: "green" | "red" = "green";
28
29 const login = async (username: string, password: string) => {
30 const r = await fetch(`${servers.url}/signin`, {
31 method: "POST",
32 headers: { origin: servers.url },
33 body: new URLSearchParams({ username, password }),
34 redirect: "manual",
35 });
36 return (r.headers.get("set-cookie") ?? "").split(";")[0]!;
37 };
38
39 beforeAll(async () => {
40 root = mkdtempSync(join(tmpdir(), "forge-drillpage-"));
41 app = createApp({ dataDir: root, port: 0 });
42 for (const [username, password, adminIfFirst] of [
43 ["alice", "a-good-passphrase", true],
44 ["bob", "another-good-passphrase", false],
45 ] as const) {
46 const u = await createUser(app.db, {
47 trusted: true,
48 username,
49 email: `${username}@forge.local`,
50 password,
51 adminIfFirst,
52 });
53 if (!u.ok) throw new Error(u.error);
54 }
55 svc = startSnapshots(app, dirStore(join(root, "store")), {
56 version: "t",
57 nightly: false,
58 drill: async (): Promise<DrillResult> => {
59 ran++;
60 await gate;
61 return {
62 at: new Date().toISOString(),
63 snapshotId: "snap-stub-1",
64 ms: 1234,
65 counts: { repositories: 7, issues: 42 },
66 verdict,
67 failedStep: verdict === "green" ? null : "unseal",
68 reason: verdict === "green" ? null : "a sealed value does not open",
69 };
70 },
71 });
72 servers = await serve(app);
73 });
74 afterAll(async () => {
75 servers.stop();
76 await svc.stop();
77 app.close();
78 rmSync(root, { recursive: true, force: true });
79 });
80
81 const post = (cookie: string, origin = servers.url) =>
82 fetch(`${servers.url}/settings/backups/drill`, {
83 method: "POST",
84 headers: { cookie, origin },
85 redirect: "manual",
86 });
87 const page = async (cookie: string) =>
88 (await fetch(`${servers.url}/settings/backups`, { headers: { cookie } })).text();
89
90 test("an admin runs a drill and the line shows it", async () => {
91 const cookie = await login("alice", "a-good-passphrase");
92 const before = await page(cookie);
93 expect(before).toContain("No restore drill has run yet");
94 expect(before).toContain("Run a drill");
95 expect((await post(cookie)).status).toBe(303);
96 expect(ran).toBe(1);
97 const html = await page(cookie);
98 expect(html).toContain("snap-stub-1");
99 expect(html).toContain("7 repositories, 42 issues");
100 expect(html).toContain("1234 ms");
101 expect(app.db.query("SELECT count(*) AS n FROM drill").get()).toEqual({ n: 1 });
102 });
103
104 test("a red drill names the failing step", async () => {
105 const cookie = await login("alice", "a-good-passphrase");
106 verdict = "red";
107 await post(cookie);
108 const html = await page(cookie);
109 expect(html).toContain("failed at");
110 expect(html).toContain("unseal");
111 expect(app.db.query("SELECT count(*) AS n FROM drill").get()).toEqual({ n: 2 });
112 });
113
114 test("a non-admin is refused, and so is a cross-origin post", async () => {
115 const before = ran;
116 const bob = await login("bob", "another-good-passphrase");
117 expect((await post(bob)).status).toBe(403);
118 const alice = await login("alice", "a-good-passphrase");
119 expect((await post(alice, "https://evil.example")).status).toBeGreaterThanOrEqual(400);
120 expect(ran).toBe(before);
121 });
122
123 test("two drills at once are one restore", async () => {
124 const alice = await login("alice", "a-good-passphrase");
125 let release!: () => void;
126 gate = new Promise<void>((r) => (release = r));
127 const before = ran;
128 const rows = () =>
129 (app.db.query("SELECT count(*) AS n FROM drill").get() as { n: number }).n;
130 const rowsBefore = rows();
131 const both = Promise.all([post(alice), post(alice)]);
132 await Bun.sleep(200);
133 release();
134 const [a, b] = await both;
135 gate = undefined;
136 expect([a.status, b.status]).toEqual([303, 303]);
137 expect(ran - before).toBe(1);
138 expect(rows() - rowsBefore).toBe(1);
139 });
140 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs