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

An admin runs a restore drill from the terminal's Resilience dialog with `r`, after a y/n, and reads the verdict there in the Backups page's words #8018

Merged opened this

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

Merged

scout/forge:scout/8007-an-admin-runs-a-restore-drill-from-the-t → main 2 commits fork 1 thread · 1 unresolved

  • Merged. scout/8007-an-admin-runs-a-restore-drill-from-the-t was merged into main as 4cd014d.

Review

Sign in to review this change.
5 files changed +256 −39
docs/TOUR.md +1 −1
@@ -213,7 +213,7 @@ A writer of the app's repository redeploys production from the terminal's **Depl
213213
214214 The app's admin also sees its own domains on that screen, one line with each domain's state (`live` or `waiting for DNS`), and presses `n` for the domains screen: `+` adds one, `c` checks the selected one's DNS proof now and says the record still needed, `-` removes it after `remove <host>? y/n`. They are the settings page's and MCP `app_domains`' actions (`src/actions/domains.ts`), answered in the page's words (`src/actions/domain_words.ts`); anyone `listAppDomains` refuses sees neither the domains nor `n`.
215215
216 A forge admin takes a snapshot from the terminal's **Resilience** dialog (`ctrl+k`, Resilience) with `n` and brings the off-box mirror level with `l`, each after a `y/n` asked on the dialog's first line. They are the Backups page's buttons' and MCP `snapshot_now`'s and `mirror_now`'s actions (`src/actions/backups_now.ts`), so all three doors say the same words; the dialog lists the three newest snapshots, newest first (or that they could not be read), and re-reads them after each. While one runs, neither key is offered. Nothing names a key or a secret. Anyone else has no Resilience command, and the actions refuse them with `not_found`.
216 A forge admin takes a snapshot from the terminal's **Resilience** dialog (`ctrl+k`, Resilience) with `n`, brings the off-box mirror level with `l` and runs a restore drill with `r`, each after a `y/n` asked on the dialog's first line. They are the Backups page's buttons' and MCP `snapshot_now`'s and `mirror_now`'s actions (`src/actions/backups_now.ts`, `drillNow` for the drill), so every door says the same words, and the drill's verdict is the Backups page's; the dialog lists the three newest snapshots, newest first (or that they could not be read), and re-reads them after each. The three keys are shown only to a forge admin, and while one action runs none of them is offered. Nothing names a key or a secret. Anyone else has no Resilience command, and the actions refuse them with `not_found`.
217217
218218 The `app_vital` board source (`src/boards/sources.ts`) charts those reports: one row per app per day with `app` (the app's id), `day`, `load_p75`, `inp_p75` (ms) and `reports` (rows kept that day), the percentiles nearest-rank per app per day as `reach` does. `max` or `avg` over several days summarises daily percentiles, not the percentile of the whole range. Its rows belong to the app's repository, so a query sees only the repos in its scope. Today boards query with `repoIds: "all"` (`src/web/pages/boards.ts`) and only a site admin reaches them; no non-admin scope exists, so nothing yet applies the settings page's `canAdmin` gate per repository, and whoever builds one must. Days older than the 90-day trim are gone, not zero. The slug and the visitor hash are not columns. No dependency was added. Left out: a starting board for it; pick `app_vital` in the `/boards` source picker.
219219
src/actions/backups_now.ts +37 −4
@@ -1,14 +1,16 @@
11 /**
2 * The Backups page's two buttons as actions: take a snapshot now, and bring
3 * the off-box mirror level now. A forge admin's only; anyone else is told
4 * `not_found`. Each answer carries `says`, worded as the page's flash, so
5 * MCP `snapshot_now` / `mirror_now` and the terminal say the same thing.
2 * The Backups page's three buttons as actions: take a snapshot now, bring
3 * the off-box mirror level now, and run a restore drill now. A forge
outdated

Rewrap this comment line; it is now far longer than its neighbours.

4 * admin's only; anyone else is told `not_found`. Each answer carries
5 * `says`, worded as the page's flash, so MCP `snapshot_now` / `mirror_now`
6 * and the terminal say the same thing.
67 * Nothing here ever names a key or a secret: only the store's name.
78 */
89
910 import type { App } from "../app.ts";
1011 import type { User } from "../core/users.ts";
1112 import { err, ok, type Result } from "../kernel/result.ts";
13 import type { DrillStep } from "../snapshot/drill.ts";
1214 import { snapshotsOf } from "../snapshot/service.ts";
1315 import { fileSize } from "../web/ui.ts";
1416
@@ -85,6 +87,37 @@ export async function mirrorNow(
8587 });
8688 }
8789
90 export interface DrillRun {
91 readonly verdict: "green" | "red";
92 readonly failedStep: DrillStep | null;
93 readonly says: string;
94 }
95
96 export type DrillRefusal =
97 | { readonly code: "not_found"; readonly says: string }
98 | { readonly code: "no_store"; readonly says: string }
99 | { readonly code: "failed"; readonly says: string };
100
101 /** Run a restore drill now; waits for any in flight. A red drill is an answer, not a refusal. */
102 export async function drillNow(
103 app: App,
104 actor: User | undefined,
105 ): Promise<Result<DrillRun, DrillRefusal>> {
106 if (!isAdmin(actor)) return err({ code: "not_found", says: "Only a forge admin runs a restore drill." });
107 const svc = snapshotsOf(app);
108 if (svc?.store === undefined) return err({ code: "no_store", says: "This forge has no snapshots to drill." });
109 try {
110 const d = await svc.drill();
111 return ok({
112 verdict: d.verdict,
113 failedStep: d.failedStep,
114 says: d.verdict === "green" ? "The drill restored green." : `The drill failed at ${d.failedStep}.`,
115 });
116 } catch (error) {
117 return err({ code: "failed", says: `The drill could not run: ${String(error)}` });
118 }
119 }
120
88121 /**
89122 * The newest snapshots first, one line each: `name · size`; empty when there
90123 * is no store. A store that cannot be listed is `unreadable`, never an empty
src/tui/screens/resilience.ts +37 −12
@@ -4,11 +4,11 @@
44 * THREATS.md protection with its standing in the last gate, as /resilience
55 * says it. Enter, Esc or q closes it.
66 *
7 * A forge admin takes a snapshot now with `n` and brings the mirror level with
8 * `l`, each after a `y/n` asked on the dialog's first line, through
9 * `actions/backups_now.ts` — MCP `snapshot_now`'s and `mirror_now`'s actions —
10 * and the answer is said there in the page's words. While one runs, neither
11 * key is offered.
7 * A forge admin takes a snapshot now with `n`, brings the mirror level with
8 * `l` and runs a restore drill with `r`, each after a `y/n` asked on the
9 * dialog's first line, through `actions/backups_now.ts` — MCP `snapshot_now`'s
10 * and `mirror_now`'s actions, and the Backups page's drill — and the answer is
11 * said there in the page's words. While one runs, none of the keys is offered.
1212 *
1313 * For an admin who is a person, `d` sets the destination and `x` forgets it
1414 * after a confirm, through `actions/destination.ts`; `m` seals the mirror and
@@ -19,7 +19,7 @@
1919 * A key is never drawn, kept past the save, or put in a note.
2020 */
2121
22 import { mirrorNow, recentSnapshots, snapshotNow } from "../../actions/backups_now.ts";
22 import { drillNow, mirrorNow, recentSnapshots, snapshotNow } from "../../actions/backups_now.ts";
2323 import { readResilience } from "../../checks/resilience.ts";
2424 import {
2525 forgetDestination,
@@ -87,21 +87,36 @@ type Target = {
8787 readonly save: (input: DestinationInput) => string;
8888 };
8989
90 type Asking = "snapshot" | "mirror";
90 type Asking = "snapshot" | "mirror" | "drill";
9191
9292 const QUESTION: Record<Asking, string> = {
9393 snapshot: "take a snapshot now? y/n",
9494 mirror: "bring the mirror level now? y/n",
95 drill: "run a restore drill now? y/n",
9596 };
9697
98 const RUNNING: Record<Asking, string> = {
99 snapshot: "Taking a snapshot…",
100 mirror: "Bringing the mirror level…",
101 drill: "Running a restore drill…",
102 };
103
104 const KEY: ReadonlyMap<string, Asking> = new Map([
105 ["n", "snapshot"],
106 ["l", "mirror"],
107 ["r", "drill"],
108 ]);
109
97110 export function resilienceScreen(f: Session, ctx: Ctx): Screen {
98111 // One rule for the destination and the mirror: a site admin who is a person.
99112 const mayChange = (): boolean => mayChangeDestination(f.user);
100113 const refusedAll = "Where snapshots go is a forge admin's.";
114 // The actions refuse anyone else too; the keys are not offered to them.
115 const mayAct = (): boolean => f.user?.isAdmin === true;
101116 /** The newest snapshots, `unreadable` when the store could not be listed; undefined until read. */
102117 let snapshots: string[] | "unreadable" | undefined;
103118 let asking: Asking | undefined;
104 // While an action runs, `n` and `l` are ignored: a second would queue behind it.
119 // While an action runs, `n`, `l` and `r` are ignored: a second would queue behind it.
105120 let busy = false;
106121 let said = "";
107122 let note = "";
@@ -119,13 +134,16 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
119134
120135 const act = (what: Asking): void => {
121136 busy = true;
122 said = what === "snapshot" ? "Taking a snapshot…" : "Bringing the mirror level…";
137 said = RUNNING[what];
123138 ctx.invalidate();
124139 runAsync(ctx, async () => {
125140 try {
126141 if (what === "snapshot") {
127142 const taken = await snapshotNow(f.app, f.user);
128143 said = taken.ok ? taken.value.says : taken.error.says;
144 } else if (what === "drill") {
145 const ran = await drillNow(f.app, f.user);
146 said = ran.ok ? ran.value.says : ran.error.says;
129147 } else {
130148 const level = await mirrorNow(f.app, f.user);
131149 said = level.ok ? level.value.says : level.error.says;
@@ -237,7 +255,13 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
237255 get keys() {
238256 if (asking !== undefined) return [binding(["y"], "yes"), binding(["n", "esc"], "no")];
239257 return [
240 ...(busy ? [] : [binding(["n"], "take a snapshot now"), binding(["l"], "bring the mirror level now")]),
258 ...(busy || !mayAct()
259 ? []
260 : [
261 binding(["n"], "take a snapshot now"),
262 binding(["l"], "bring the mirror level now"),
263 binding(["r"], "run a restore drill"),
264 ]),
241265 ...(mayChange()
242266 ? [
243267 binding(["d"], "set destination"),
@@ -269,8 +293,9 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
269293 if (e.key === "y") act(was);
270294 return true;
271295 }
272 if ((e.key === "n" || e.key === "l") && !busy) {
273 asking = e.key === "n" ? "snapshot" : "mirror";
296 const ask = KEY.get(e.key);
297 if (ask !== undefined && !busy && mayAct()) {
298 asking = ask;
274299 said = "";
275300 } else if (e.key === "enter" || e.key === "esc" || e.key === "q") ctx.pop();
276301 else if (!mayChange()) return true;
src/web/pages/backups.ts +9 −22
@@ -28,7 +28,7 @@ import { formatMinutes, formatSpan, timeByPart, type DrillResult } from "../../s
2828 import { lastDrill } from "../../snapshot/drills.ts";
2929 import type { Store } from "../../snapshot/store.ts";
3030 import { snapshotsOf, type SnapshotsHandle } from "../../snapshot/service.ts";
31 import { mirrorNow, snapshotNow } from "../../actions/backups_now.ts";
31 import { drillNow, mirrorNow, snapshotNow } from "../../actions/backups_now.ts";
3232 import { lastSnapshotOutcome } from "../../snapshot/outcome.ts";
3333 import type { Ctx } from "../../http/context.ts";
3434 import {
@@ -645,27 +645,14 @@ export async function backupsDrill(ctx: Ctx): Promise<Response> {
645645 });
646646 const form = await readForm(ctx, "admin");
647647 if (form instanceof Response) return form;
648 const svc = snapshotsOf(ctx.app);
649 if (svc === undefined || svc.store === undefined)
650 return flashTo(ctx, "/settings/backups", {
651 tone: "danger",
652 message: "This forge has no snapshots to drill.",
653 });
654 try {
655 const d = await svc.drill();
656 return flashTo(
657 ctx,
658 "/settings/backups",
659 d.verdict === "green"
660 ? { message: "The drill restored green." }
661 : { tone: "danger", message: `The drill failed at ${d.failedStep}.` },
662 );
663 } catch (error) {
664 return flashTo(ctx, "/settings/backups", {
665 tone: "danger",
666 message: `The drill could not run: ${String(error)}`,
667 });
668 }
648 const ran = await drillNow(ctx.app, ctx.auth.user);
649 return flashTo(
650 ctx,
651 "/settings/backups",
652 ran.ok && ran.value.verdict === "green"
653 ? { message: ran.value.says }
654 : { tone: "danger", message: ran.ok ? ran.value.says : ran.error.says },
655 );
669656 }
670657
671658 /** POST /settings/backups/mirror — seal the mirror. */
test/tui-resilience-drill.test.ts added +172
@@ -0,0 +1,172 @@
1 /**
2 * The terminal's Resilience dialog runs a restore drill with `r` after a y/n
3 * (issue #8007, pull #8018), through `drillNow` — the Backups page's action — and says the
4 * verdict in the page's words. The drill itself is stubbed: a real one
5 * restores a snapshot and can take minutes.
6 */
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App as Forge } from "../src/app.ts";
12 import { createUser, getUserByName } from "../src/core/users.ts";
13 import { drillNow } from "../src/actions/backups_now.ts";
14 import type { DrillResult } from "../src/snapshot/drill.ts";
15 import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts";
16 import { dirStore } from "../src/snapshot/store.ts";
17 import { createForgeApp } from "../src/tui/forge.ts";
18 import type { TuiIdentity } from "../src/tui/session.ts";
19 import { headlessTerminal } from "../src/tui/term/terminal.ts";
20 import { quietLogs } from "./helpers/globals.ts";
21 import { settleApp } from "./helpers/tui.ts";
22
23 quietLogs();
24
25 const GREEN: DrillResult = {
26 at: new Date(0).toISOString(),
27 snapshotId: "s1",
28 ms: 1,
29 counts: { repositories: 0, issues: 0, users: 2, pulls: 0, apps: 0 },
30 health: "ok",
31 verdict: "green",
32 failedStep: null,
33 phases: [],
34 budgetMs: 1000,
35 reason: null,
36 };
37
38 describe("a restore drill from the terminal's Resilience dialog", () => {
39 let dataDir: string;
40 let forge: Forge;
41 let svc: SnapshotsHandle;
42 let drills = 0;
43 let verdict: DrillResult = GREEN;
44
45 function boot(username: string) {
46 const terminal = headlessTerminal(140, 40);
47 const user = getUserByName(forge.db, username)!;
48 const identity: TuiIdentity = { sshUsername: "t", remoteAddress: "127.0.0.1", user };
49 const { app } = createForgeApp(forge, identity, { terminal, exitHooks: false });
50 app.start();
51 const send = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
52 const press = async (...keys: string[]) => {
53 for (const k of keys) send(k);
54 await settleApp(app);
55 };
56 const open = async () => {
57 send("k", true);
58 await settleApp(app);
59 for (const ch of "Resilience") send(ch);
60 await settleApp(app);
61 const listed = app.screen.toText();
62 await press("enter");
63 return listed;
64 };
65 const waitFor = async (text: string) => {
66 for (let i = 0; i < 100 && !app.screen.toText().includes(text); i++) {
67 await Bun.sleep(20);
68 await press();
69 }
70 };
71 return { text: () => app.screen.toText(), press, open, waitFor, stop: () => app.stop() };
72 }
73
74 const start = (store: boolean) =>
75 startSnapshots(forge, store ? dirStore(join(dataDir, "store")) : () => undefined, {
76 version: "t",
77 nightly: false,
78 drill: async () => {
79 drills++;
80 return verdict;
81 },
82 });
83
84 beforeAll(async () => {
85 dataDir = await mkdtemp(join(tmpdir(), "forge-tui-resilience-drill-"));
86 forge = createApp({ dataDir, port: 0 });
87 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
88 const made = await createUser(forge.db, {
89 origin: "seed",
90 trusted: true,
91 username,
92 email: `${username}@forge.local`,
93 password: "a-good-passphrase",
94 adminIfFirst,
95 });
96 if (!made.ok) throw new Error(made.error);
97 }
98 svc = start(true);
99 }, 30_000);
100
101 afterAll(async () => {
102 await svc.stop();
103 forge.close();
104 await rm(dataDir, { recursive: true, force: true });
105 });
106
107 test("an admin is offered r, and r then y says the drill restored green", async () => {
108 const h = boot("alice");
109 await h.open();
110 expect(h.text()).toContain("r run a restore drill");
111 await h.press("r");
112 expect(h.text()).toContain("run a restore drill now? y/n");
113 expect(h.text()).not.toContain("r run a restore drill");
114 const before = drills;
115 await h.press("y");
116 await h.waitFor("The drill restored green.");
117 expect(h.text()).toContain("The drill restored green.");
118 expect(drills).toBe(before + 1);
119 h.stop();
120 });
121
122 test("a red drill names the step it failed at", async () => {
123 verdict = { ...GREEN, verdict: "red", failedStep: "restore", health: "unreached" };
124 const h = boot("alice");
125 await h.open();
126 await h.press("r", "y");
127 await h.waitFor("The drill failed at restore.");
128 expect(h.text()).toContain("The drill failed at restore.");
129 verdict = GREEN;
130 h.stop();
131 });
132
133 test("r then n, or esc, runs nothing", async () => {
134 const h = boot("alice");
135 await h.open();
136 const before = drills;
137 await h.press("r", "n");
138 expect(h.text()).not.toContain("run a restore drill now? y/n");
139 await h.press("r", "esc");
140 expect(h.text()).not.toContain("run a restore drill now? y/n");
141 expect(h.text()).toContain("Snapshots, newest first:");
142 await Bun.sleep(20);
143 expect(drills).toBe(before);
144 h.stop();
145 });
146
147 test("a non-admin is not offered the dialog, and drillNow refuses them", async () => {
148 const h = boot("bob");
149 expect(await h.open()).not.toContain("each protection green, red or untested");
150 expect(h.text()).not.toContain("r run a restore drill");
151 h.stop();
152 const before = drills;
153 const ran = await drillNow(forge, getUserByName(forge.db, "bob")!);
154 expect(ran.ok).toBe(false);
155 if (!ran.ok) expect(ran.error.code).toBe("not_found");
156 expect(drills).toBe(before);
157 });
158
159 test("a forge with no store says it has no snapshots to drill", async () => {
160 await svc.stop();
161 svc = start(false);
162 const h = boot("alice");
163 await h.open();
164 await h.press("r", "y");
165 await h.waitFor("no snapshots to drill");
166 expect(h.text()).toContain("This forge has no snapshots to drill.");
167 const ran = await drillNow(forge, getUserByName(forge.db, "alice")!);
168 expect(ran.ok).toBe(false);
169 if (!ran.ok) expect(ran.error.code).toBe("no_store");
170 h.stop();
171 });
172 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs