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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8867-every-preview-of-the-forge-shows-resilie → main 4 commits fork 2 threads · 2 unresolved
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 @@ | ||
| 4 | 4 | ], |
| 5 | 5 | "app": { |
| 6 | 6 | "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"], | |
| 8 | 8 | "run": "bun run src/main.ts", |
| 9 | 9 | "install": "", |
| 10 | 10 | "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 @@ | ||
| 1 | 1 | /** |
| 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 | |
| 3 | 4 | * where the off-box mirror is, the newest snapshots, then one line per |
| 4 | 5 | * THREATS.md protection with its standing in the last gate, as /resilience |
| 5 | 6 | * says it. Enter, Esc or q closes it. |
| @@ -30,6 +31,7 @@ import { | ||
| 30 | 31 | type DestinationInput, |
| 31 | 32 | } from "../../actions/destination.ts"; |
| 32 | 33 | import { forgetMirror, saveMirror } from "../../actions/mirror.ts"; |
| 34 | import { backupsLine } from "../../snapshot/backups_line.ts"; | |
| 33 | 35 | import { backupVerdict } from "../../snapshot/bucket.ts"; |
| 34 | 36 | import { lastDrill } from "../../snapshot/drills.ts"; |
| 35 | 37 | import { resolveDestination } from "../../snapshot/destination.ts"; |
| @@ -251,7 +253,7 @@ export function resilienceScreen(f: Session, ctx: Ctx): Screen { | ||
| 251 | 253 | const out: string[] = []; |
| 252 | 254 | if (asking !== undefined) out.push(QUESTION[asking], ""); |
| 253 | 255 | else if (said !== "") out.push(...wrap(said, INNER), ""); |
| 254 | out.push(...verdictLines(f), ""); | |
| 256 | out.push(...wrap(backupsLine(f.app).says, INNER), ...verdictLines(f), ""); | |
| 255 | 257 | out.push(...wrap(destinationLine(f), INNER), ...wrap(mirrorLine(f), INNER)); |
| 256 | 258 | if (note !== "") out.push(...wrap(note, INNER)); |
| 257 | 259 | 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 @@ | ||
| 3 | 3 | * or "no test yet" — naming the test file it cites, judged by the last full |
| 4 | 4 | * gate's per-file results (`checks/gate_last.ts`). A cited file the last |
| 5 | 5 | * 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. | |
| 6 | 9 | */ |
| 7 | 10 | |
| 8 | 11 | import { readResilience } from "../../checks/resilience.ts"; |
| 9 | 12 | import type { Standing } from "../../core/threats.ts"; |
| 10 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | import { backupsLine } from "../../snapshot/backups_line.ts"; | |
| 11 | 15 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 12 | 16 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 13 | import { html } from "../html.ts"; | |
| 17 | import { html, type Html } from "../html.ts"; | |
| 14 | 18 | import { page } from "../layout.ts"; |
| 15 | 19 | import { badge, card } from "../ui.ts"; |
| 16 | 20 | |
| 17 | 21 | const TONE = { green: "success", red: "danger", untested: "default", "no test yet": "default" } as const; |
| 18 | 22 | |
| 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 |
||
| 19 | 33 | // GET /resilience: a forge admin's, since live red lines name what to attack. |
| 20 | 34 | export function resiliencePage(ctx: Ctx): Response { |
| 21 | 35 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| @@ -31,6 +45,7 @@ export function resiliencePage(ctx: Ctx): Response { | ||
| 31 | 45 | the test file it cites in the last full gate. |
| 32 | 46 | </p> |
| 33 | 47 | </div> |
| 48 | ${backupsLineHtml(ctx)} | |
| 34 | 49 | <p id="tally"> |
| 35 | 50 | ${String(count("green"))} green · ${String(count("red"))} red · |
| 36 | 51 | ${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", () => { | ||
| 63 | 63 | for (const bad of ["flow", "//evil.example", "/a/../b", 7]) |
| 64 | 64 | expect(manifest({ looks: ["/"], looksAsOwner: [bad] }).ok).toBe(false); |
| 65 | 65 | }); |
| 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 | }); | |
| 66 | 71 | }); |
| 67 | 72 | |
| 68 | 73 | describe("the preview seed", () => { |
| @@ -205,6 +210,25 @@ describe("a preview hears its parent's admin as the preview admin", () => { | ||
| 205 | 210 | expect(turned.status).toBe(403); |
| 206 | 211 | }); |
| 207 | 212 | |
| 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 | ||
| 208 | 232 | test("production, never handed the key, hears nobody", async () => { |
| 209 | 233 | const production = await forge(undefined); |
| 210 | 234 | expect((await flow(production, identity("admin"))).status).not.toBe(200); |
new above
offline
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.