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

Every preview of the forge shows /resilience and /system as the owner sees them #8871

Merged opened this

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

Merged

mason/forge:mason/8867-every-preview-of-the-forge-shows-resilie → main 4 commits fork 2 threads · 2 unresolved

  • Merged. mason/8867-every-preview-of-the-forge-shows-resilie was merged into main as 10dacd5.

Review

Sign in to review this change.
7 files changed +224 −4
docs/features/resilience-backups.md added +33
@@ -0,0 +1,33 @@
1 # The backups line on Resilience (#8867)
2
3 `/resilience` and the terminal's Resilience dialog (palette: Resilience,
4 admins) both open with one line on backups. Both read it from
5 `src/snapshot/backups_line.ts`, so the web page and the terminal use the same
6 words.
7
8 - **With a store:** `Snapshots go to <where>.` Here `<where>` is the store's
9 own name for itself: `s3://…` for a bucket, the path for a directory. It
10 never includes a key. On the web, the line links to Backups, which lists
11 the snapshots.
12 - **With none:** `Backups are off here: this forge was given no bucket to
13 keep snapshots in.` This is muted text, not an alarm: a preview and a forge
14 on a desk have no bucket on purpose. On the web, the line links to Backups,
15 which says how to give the forge one.
16 - **An empty name counts as none.** A store whose `where` is blank reads as
17 off. Without this rule the line would end in a bare "Snapshots go to .".
18
19 ## Giving a forge a bucket
20
21 The first of these that is set wins (`src/snapshot/destination.ts`):
22
23 1. **Sealed in the forge.** On the web, `/settings/backups` takes an
24 endpoint, bucket, region and two keys. In the terminal, the Resilience
25 dialog's `d` asks for the same five fields. The keys are sealed and are
26 never shown again. `t` tests the destination.
27 2. **A directory:** `FORGE_SNAPSHOT_DIR=/path` in the forge's environment.
28 3. **The host's bucket:** `S3_ENDPOINT`, `S3_BUCKET`, `S3_ACCESS_KEY`,
29 `S3_SECRET_KEY` and optionally `S3_REGION`, which a host-provisioned forge is
30 given.
31
32 The line describes the store that the running process holds. After you save
33 a destination, the next use resolves it again and the line names it.
forge.json +1 −1
@@ -4,7 +4,7 @@
44 ],
55 "app": {
66 "looks": ["/", "/forges", "/family", "/front", "/explore", "/board", "/notes", "/feed", "/review", "/new", "/audit", "/releases", "/apps", "/repos", "/map", "/system", "/talk", "/flow", "/resilience", "/settings", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets"],
7 "looksAsOwner": ["/", "/flow", "/talk", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets"],
7 "looksAsOwner": ["/", "/flow", "/talk", "/system", "/resilience", "/preview-admin/sample", "/preview-admin/sample/pulls/3", "/preview-admin/sample/next", "/preview-admin/sample/bets"],
88 "run": "bun run src/main.ts",
99 "install": "",
1010 "health": "/healthz",
src/snapshot/backups_line.ts added +26
@@ -0,0 +1,26 @@
1 /**
2 * The one line on backups that /resilience and the terminal's Resilience
3 * dialog both say: where snapshots go, or that they are off here and why.
4 * Plain text from the store this process holds; never the store's secrets
5 * (`Store.where` is the store's own page-safe name for itself).
6 */
7
8 import type { App } from "../app.ts";
9 import { snapshotsOf } from "./service.ts";
10
11 export const BACKUPS_OFF = "Backups are off here: this forge was given no bucket to keep snapshots in.";
12
13 /** `where` is set only when a store is in effect and names itself. */
14 export type BackupsLine = { readonly where?: string; readonly says: string };
15
16 /**
17 * A store whose `where` is empty counts as none: no store builds one today
18 * (a bucket's is `s3://…`, a directory's its path), but a line saying
19 * "Snapshots go to ." would be worse than saying they are off.
20 */
21 export function backupsLineOf(store: { readonly where: string } | undefined): BackupsLine {
22 const where = store?.where.trim() ?? "";
23 return where === "" ? { says: BACKUPS_OFF } : { where, says: `Snapshots go to ${where}.` };
24 }
25
26 export const backupsLine = (app: App): BackupsLine => backupsLineOf(snapshotsOf(app)?.store);
src/tui/screens/resilience.ts +4 −2
@@ -1,5 +1,6 @@
11 /**
2 * The Resilience command's dialog, a forge admin's: where snapshots go now and
2 * The Resilience command's dialog, a forge admin's: the backups line /resilience
3 * opens with (`snapshot/backups_line.ts`), where snapshots go now and
34 * where the off-box mirror is, the newest snapshots, then one line per
45 * THREATS.md protection with its standing in the last gate, as /resilience
56 * says it. Enter, Esc or q closes it.
@@ -30,6 +31,7 @@ import {
3031 type DestinationInput,
3132 } from "../../actions/destination.ts";
3233 import { forgetMirror, saveMirror } from "../../actions/mirror.ts";
34 import { backupsLine } from "../../snapshot/backups_line.ts";
3335 import { backupVerdict } from "../../snapshot/bucket.ts";
3436 import { lastDrill } from "../../snapshot/drills.ts";
3537 import { resolveDestination } from "../../snapshot/destination.ts";
@@ -251,7 +253,7 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen {
251253 const out: string[] = [];
252254 if (asking !== undefined) out.push(QUESTION[asking], "");
253255 else if (said !== "") out.push(...wrap(said, INNER), "");
254 out.push(...verdictLines(f), "");
256 out.push(...wrap(backupsLine(f.app).says, INNER), ...verdictLines(f), "");
255257 out.push(...wrap(destinationLine(f), INNER), ...wrap(mirrorLine(f), INNER));
256258 if (note !== "") out.push(...wrap(note, INNER));
257259 if (mayChange()) out.push("d sets where snapshots go, x forgets it.", "m sets the mirror, f forgets it.", "t tests where snapshots go.");
src/web/pages/resilience.ts +16 −1
@@ -3,19 +3,33 @@
33 * or "no test yet" — naming the test file it cites, judged by the last full
44 * gate's per-file results (`checks/gate_last.ts`). A cited file the last
55 * gate did not run reads "untested", never green.
6 *
7 * Above them, one line on backups: where snapshots go, or — on a preview or
8 * any forge given no bucket — that they are off here and why, said plainly.
69 */
710
811 import { readResilience } from "../../checks/resilience.ts";
912 import type { Standing } from "../../core/threats.ts";
1013 import type { Ctx } from "../../http/context.ts";
14 import { backupsLine } from "../../snapshot/backups_line.ts";
1115 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1216 import { notFoundPage, signInFirst } from "../chrome.ts";
13 import { html } from "../html.ts";
17 import { html, type Html } from "../html.ts";
1418 import { page } from "../layout.ts";
1519 import { badge, card } from "../ui.ts";
1620
1721 const TONE = { green: "success", red: "danger", untested: "default", "no test yet": "default" } as const;
1822

This wording lives only in the web page. Move the sentence into a shared module and use it from src/tui/screens/resilience.ts too, so the terminal says the same thing.

23 /** The shared backups line (`snapshot/backups_line.ts`), with where to act on it. */
24 export function backupsLineHtml(ctx: Ctx): Html {
25 const line = backupsLine(ctx.app);
26 if (line.where === undefined)
27 return html`<p id="backups-line" class="muted">
28 ${line.says} <a href="/settings/backups">Backups</a> says how to give it one.
29 </p>`;
30 return html`<p id="backups-line">${line.says} <a href="/settings/backups">Backups</a> lists them.</p>`;
31 }
32
outdated

The branch that names a store is untested. Add a test with a store present, and one for an empty where.

1933 // GET /resilience: a forge admin's, since live red lines name what to attack.
2034 export function resiliencePage(ctx: Ctx): Response {
2135 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
@@ -31,6 +45,7 @@ export function resiliencePage(ctx: Ctx): Response {
3145 the test file it cites in the last full gate.
3246 </p>
3347 </div>
48 ${backupsLineHtml(ctx)}
3449 <p id="tally">
3550 ${String(count("green"))} green · ${String(count("red"))} red ·
3651 ${String(count("untested"))} untested · ${String(count("no test yet"))} with no test yet
test/backups-line.test.ts added +120
@@ -0,0 +1,120 @@
1 /**
2 * The one backups line (#8867), said the same at both doors: /resilience and
3 * the terminal's Resilience dialog read `snapshot/backups_line.ts`, so a forge
4 * given no bucket says so in either, and one given a store names it.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { createUser, getUserByName } from "../src/core/users.ts";
12 import { serve, type Servers } from "../src/http/server.ts";
13 import { BACKUPS_OFF, backupsLineOf } from "../src/snapshot/backups_line.ts";
14 import { startSnapshots } from "../src/snapshot/service.ts";
15 import { dirStore } from "../src/snapshot/store.ts";
16 import { createForgeApp } from "../src/tui/forge.ts";
17 import { headlessTerminal } from "../src/tui/term/terminal.ts";
18 import { quietLogs } from "./helpers/globals.ts";
19 import { settleApp } from "./helpers/tui.ts";
20
21 quietLogs();
22
23 describe("backupsLineOf", () => {
24 test("no store says backups are off, and why", () => {
25 expect(backupsLineOf(undefined)).toEqual({ says: BACKUPS_OFF });
26 });
27
28 test("a store names where snapshots go", () => {
29 expect(backupsLineOf({ where: "s3://bucket/forge" })).toEqual({
30 where: "s3://bucket/forge",
31 says: "Snapshots go to s3://bucket/forge.",
32 });
33 });
34
35 test("a store whose where is empty or blank counts as none", () => {
36 for (const where of ["", " "]) expect(backupsLineOf({ where })).toEqual({ says: BACKUPS_OFF });
37 });
38 });
39
40 describe("the backups line at both doors", () => {
41 let dataDir: string;
42 let forge: App;
43 let servers: Servers;
44
45 beforeAll(async () => {
46 dataDir = await mkdtemp(join(tmpdir(), "forge-backups-line-"));
47 forge = createApp({ dataDir, port: 0 });
48 servers = await serve(forge);
49 const made = await createUser(forge.db, {
50 origin: "seed",
51 trusted: true,
52 username: "alice",
53 email: "[email protected]",
54 password: "a-good-passphrase",
55 adminIfFirst: true,
56 });
57 if (!made.ok) throw new Error(made.error);
58 });
59
60 afterAll(async () => {
61 servers.stop();
62 forge.close();
63 await rm(dataDir, { recursive: true, force: true });
64 });
65
66 const login = async (): Promise<string> => {
67 const res = await fetch(`${servers.url}/signin`, {
68 method: "POST",
69 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
70 body: "username=alice&password=a-good-passphrase",
71 redirect: "manual",
72 });
73 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
74 };
75
76 const webLine = async (): Promise<string> => {
77 const res = await fetch(`${servers.url}/resilience`, { headers: { cookie: await login() } });
78 expect(res.status).toBe(200);
79 const page = await res.text();
80 return page.slice(page.indexOf('id="backups-line"'), page.indexOf("</p>", page.indexOf('id="backups-line"')));
81 };
82
83 const terminalText = async (): Promise<string> => {
84 const terminal = headlessTerminal(140, 40);
85 const user = getUserByName(forge.db, "alice")!;
86 const { app } = createForgeApp(forge, { sshUsername: "t", remoteAddress: "127.0.0.1", user }, { terminal, exitHooks: false });
87 app.start();
88 const key = (k: string, ctrl = false) => app.dispatch({ type: "key", key: k, ctrl, alt: false, shift: false });
89 key("k", true);
90 await settleApp(app);
91 for (const ch of "Resilience") key(ch);
92 await settleApp(app);
93 key("enter");
94 await settleApp(app);
95 // The dialog wraps long lines: compare its words with the borders and breaks taken out.
96 const text = app.screen.toText().replace(/[│\s]/g, "");
97 app.stop();
98 return text;
99 };
100
101 test("with no store, both say backups are off and why", async () => {
102 expect(await webLine()).toContain(BACKUPS_OFF);
103 expect(await terminalText()).toContain(BACKUPS_OFF.replace(/\s/g, ""));
104 });
105
106 test("with a store, both name where snapshots go and neither says off", async () => {
107 const where = join(dataDir, "snapshots");
108 const svc = startSnapshots(forge, dirStore(where), { version: "t", nightly: false });
109 try {
110 const web = await webLine();
111 expect(web).toContain(`Snapshots go to ${where}.`);
112 expect(web).not.toContain("Backups are off");
113 const tty = await terminalText();
114 expect(tty).toContain(`Snapshotsgoto${where}.`);
115 expect(tty).not.toContain("Backupsareoff");
116 } finally {
117 await svc.stop();
118 }
119 });
120 });
test/looks-owner.test.ts +24
@@ -63,6 +63,11 @@ describe("app.looksAsOwner in forge.json", () => {
6363 for (const bad of ["flow", "//evil.example", "/a/../b", 7])
6464 expect(manifest({ looks: ["/"], looksAsOwner: [bad] }).ok).toBe(false);
6565 });
66
67 test("the forge's own looks take its admin pages as the owner", () => {
68 const own = parseManifest(readFileSync(join(import.meta.dir, "..", "forge.json"), "utf8"), undefined);
69 expect(own.ok && own.value.looksAsOwner).toEqual(expect.arrayContaining(["/resilience", "/system"]));
70 });
6671 });
6772
6873 describe("the preview seed", () => {
@@ -205,6 +210,25 @@ describe("a preview hears its parent's admin as the preview admin", () => {
205210 expect(turned.status).toBe(403);
206211 });
207212
213 test("/resilience and /system answer the preview admin, with no store said in one line and no credential in the page", async () => {
214 const preview = servers[0]!;
215 const headers = identity("admin");
216 for (const path of ["/resilience", "/system"]) {
217 const res = await fetch(`${preview.url}${path}`, { headers, redirect: "manual" });
218 expect(res.status).toBe(200);
219 expect(res.headers.get("set-cookie") ?? "").not.toContain("forge_session");
220 const page = await res.text();
221 for (const secret of [KEY, headers["x-forge-signature"], headers["x-forge-identity"]])
222 expect(page).not.toContain(secret);
223 const sessions = apps[0]!.db.query<{ id: string }, []>("SELECT id FROM session").all();
224 for (const { id } of sessions) expect(page).not.toContain(id);
225 if (path === "/resilience") {
226 expect(page).toContain("Backups are off here: this forge was given no bucket to keep snapshots in.");
227 expect(page).not.toMatch(/\bat .+\.ts:\d+/);
228 }
229 }
230 }, 30_000);
231
208232 test("production, never handed the key, hears nobody", async () => {
209233 const production = await forge(undefined);
210234 expect((await flow(production, identity("admin"))).status).not.toBe(200);

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs