Public: anyone can read this; the crew can change it.
Each backup lane says what it backs up and who reads it after spin-down, and a lane without that line says so on the backup tile #9078
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/9067-each-backup-lane-says-what-it-backs-up-a → main 3 commits fork 3 threads · 3 unresolved
7 files
changed +262
−27
docs/design/areas/backups.md +2
| @@ -1,3 +1,5 @@ | ||
| 1 | 1 | # Backups |
| 2 | 2 | |
| 3 | 3 | - `.restore-command` — the one-line restore command on /settings/backups: a `<pre>` that wraps, scrolls with the keyboard (`tabindex="0"`) and takes its height from its text, so a one-liner is not a log pane. |
| 4 | ||
| 5 | - The lanes card on /settings/backups adds no class: each lane is a `row()` in a `.setting-list`, its lead glyph (✓, !, –) coloured by the row's state (green is `open`, red `closed`, retired `draft`), a retired lane also carrying an outline `badge`, so no state is colour alone. |
docs/features/backup-truth.md +15 −15
| @@ -17,7 +17,9 @@ the forge's configuration, never in this repository: | ||
| 17 | 17 | |
| 18 | 18 | json |
| 19 | 19 | [{ "name": "backup/postgres", "endpoint": "https://s3.example.com", "bucket": "platform-backups", |
| 20 | "prefix": "postgres/", "region": "us-east-1", "accessKey": "…", "secretKey": "…" }] | |
| 20 | "prefix": "postgres/", "region": "us-east-1", "accessKey": "…", "secretKey": "…", | |
| 21 | "backsUp": "the platform's Postgres dump, nightly", | |
| 22 | "afterSpinDown": "ops reads it from the bucket with the restore runbook" }] | |
| 21 | 23 | |
| 22 | 24 | |
| 23 | 25 | - **List-only keys.** `src/snapshot/lanes.ts` only lists; give each lane keys |
| @@ -36,6 +38,12 @@ the forge's configuration, never in this repository: | ||
| 36 | 38 | - **Nothing unsafe printed.** Errors are scrubbed of keys, and every key or |
| 37 | 39 | error the store wrote has its control bytes replaced with `?` before it |
| 38 | 40 | reaches a terminal or an MCP client. |
| 41 | - **A line per lane (#9067).** `backsUp` (one sentence) and `afterSpinDown` (who | |
| 42 | reads it once the platform is gone, or `retired`) are optional strings of at | |
| 43 | most 200 characters, control bytes replaced. A lane missing either shows | |
| 44 | `No line yet: say what it backs up and who reads it after spin-down` beside | |
| 45 | its newest object: information, never red. A lane marked `retired` is listed, | |
| 46 | never read, and never turns the tile red. | |
| 39 | 47 | - **Where to see it.** MCP `backups` → `verdict.lanes`; the terminal's |
| 40 | 48 | Resilience dialog; home's backup tile reads the same verdict. |
| 41 | 49 | |
| @@ -103,20 +111,12 @@ Owner's answer: _pending_. Until it is written, the dump stays where it is. | ||
| 103 | 111 | |
| 104 | 112 | ### A line per lane |
| 105 | 113 | |
| 106 | The lane list lives in the forge's configuration, never in the repository, so | |
| 107 | the names are filled in from the Resilience dialog (`verdict.lanes` in the MCP | |
| 108 | `backups` tool), one row each. Every row says what it backs up and one of: | |
| 109 | **read after spin-down** (who, how) or **retired** (on what date, by whom). | |
| 110 | ||
| 111 | | Lane | Backs up | After spin-down | | |
| 112 | | --- | --- | --- | | |
| 113 | | `primary` | the forge's snapshot, platform bucket | the app's bucket; read by the in-process check | | |
| 114 | | `mirror` | the same snapshot, B2 | B2; read by the in-process check and `scripts/backup-probe.ts` | | |
| 115 | | each `backup/<name>` | _fill in from the dialog_ | _read after spin-down, or retired_ | | |
| 116 | ||
| 117 | A lane with no reader after spin-down is retired, and removed from | |
| 118 | `FORGE_BACKUP_LANES` in the same step, so the tile does not go red for a lane | |
| 119 | nobody keeps. | |
| 114 | Each lane's `backsUp` and `afterSpinDown` now live beside it in | |
| 115 | `FORGE_BACKUP_LANES` (the forge's own `primary` and `mirror` say theirs in | |
| 116 | `src/snapshot/bucket.ts`) and show next to its newest object in MCP `backups` → | |
| 117 | `verdict.lanes`, the Resilience dialog and `/settings/backups`. A lane with no | |
| 118 | reader after spin-down is marked `retired` there, so the tile does not go red | |
| 119 | for a lane nobody keeps. | |
| 120 | 120 | |
| 121 | 121 | ### Restore drill with the platform unreachable |
| 122 | 122 |
src/snapshot/bucket.ts +39 −4
| @@ -41,11 +41,38 @@ export interface LaneRead { | ||
| 41 | 41 | readonly error?: string; |
| 42 | 42 | /** When this read ended. */ |
| 43 | 43 | readonly readAt: string; |
| 44 | /** What the lane backs up, one sentence, from the configuration (#9067). */ | |
| 45 | readonly backsUp?: string; | |
| 46 | /** Who reads the lane once the platform is gone, or `retired`. */ | |
| 47 | readonly afterSpinDown?: string; | |
| 44 | 48 | } |
| 45 | 49 | |
| 46 | export interface LaneVerdict extends LaneRead { | |
| 47 | readonly state: "green" | "red"; | |
| 50 | /** `retired` in any case: the lane is listed, never read, never red. */ | |
| 51 | export const isRetired = (afterSpinDown: string | undefined): boolean => afterSpinDown?.trim().toLowerCase() === "retired"; | |
| 52 | ||
| 53 | /** The forge's own lanes say their line here; the platform's say it in `FORGE_BACKUP_LANES`. */ | |
| 54 | const OWN_LINES: Record<string, { backsUp: string; afterSpinDown: string }> = { | |
| 55 | primary: { backsUp: "the forge's snapshot, platform bucket", afterSpinDown: "the app's bucket; read by the in-process check" }, | |
| 56 | mirror: { backsUp: "the same snapshot, B2", afterSpinDown: "B2; read by the in-process check and scripts/backup-probe.ts" }, | |
| 57 | }; | |
| 58 | ||
| 59 | /** Shown on a lane that has no line: information, never red. */ | |
| 60 | export const NO_LINE = "No line yet: say what it backs up and who reads it after spin-down"; | |
| 61 | ||
| 62 | /** The two values beside the newest object: each as given, the missing one named; `NO_LINE` when neither is. */ | |
| 63 | export function laneLine(backsUp: string | null, afterSpinDown: string | null): string { | |
| 64 | if (backsUp === null && afterSpinDown === null) return NO_LINE; | |
| 65 | return `backs up ${backsUp === null ? "(not said)" : printable(backsUp)}; after spin-down: ${afterSpinDown === null ? "(not said)" : printable(afterSpinDown)}`; | |
| 66 | } | |
| 67 | ||
| 68 | export interface LaneVerdict extends Omit<LaneRead, "backsUp" | "afterSpinDown"> { | |
| 69 | readonly state: "green" | "red" | "retired"; | |
| 48 | 70 | readonly says: string; |
| 71 | /** Both values, or null where the lane has none. */ | |
| 72 | readonly backsUp: string | null; | |
| 73 | readonly afterSpinDown: string | null; | |
| 74 | /** What to show beside the newest object: the two values (a missing one named), or `NO_LINE`. */ | |
| 75 | readonly line: string; | |
| 49 | 76 | } |
| 50 | 77 | |
| 51 | 78 | export interface BackupVerdict { |
| @@ -216,6 +243,14 @@ export function judge( | ||
| 216 | 243 | // Every word a store or the configuration supplied is made printable before it joins the line. |
| 217 | 244 | const lane = printable(r.lane); |
| 218 | 245 | const where = printable(r.where); |
| 246 | const own = OWN_LINES[r.lane]; | |
| 247 | const backsUp = r.backsUp ?? own?.backsUp ?? null; | |
| 248 | const afterSpinDown = r.afterSpinDown ?? own?.afterSpinDown ?? null; | |
| 249 | const line = laneLine(backsUp, afterSpinDown); | |
| 250 | if (isRetired(afterSpinDown ?? undefined)) { | |
| 251 | lanes.push({ ...r, backsUp, afterSpinDown, state: "retired", says: `${lane} retired${backsUp === null ? "" : `: ${printable(backsUp)}`}`, line }); | |
| 252 | continue; | |
| 253 | } | |
| 219 | 254 | if (r.error !== undefined) says = `${lane} (${where}) cannot be read: ${printable(r.error)}`; |
| 220 | 255 | else if (r.newestAt === null) says = `${lane} (${where}) holds no snapshot`; |
| 221 | 256 | else { |
| @@ -232,9 +267,9 @@ export function judge( | ||
| 232 | 267 | causes.push(r.error !== undefined ? "unreadable" : r.newestAt === null ? "empty" : "late"); |
| 233 | 268 | reasons.push(`${says[0]!.toUpperCase()}${says.slice(1)}.`); |
| 234 | 269 | } |
| 235 | lanes.push({ ...r, state: red ? "red" : "green", says }); | |
| 270 | lanes.push({ ...r, backsUp, afterSpinDown, state: red ? "red" : "green", says, line }); | |
| 236 | 271 | } |
| 237 | if (state !== undefined && state.lanes.length === 0) { | |
| 272 | if (state !== undefined && !lanes.some((l) => l.state !== "retired")) { | |
| 238 | 273 | causes.push("no destination"); |
| 239 | 274 | reasons.push("No place is given for snapshots; none is being taken."); |
| 240 | 275 | } |
src/snapshot/lanes.ts +47 −5
| @@ -24,7 +24,7 @@ | ||
| 24 | 24 | * error is one line with the lane's keys struck out (`scrubError`). |
| 25 | 25 | */ |
| 26 | 26 | |
| 27 | import { LATE_MS, printable, scrubError, type LaneRead } from "./bucket.ts"; | |
| 27 | import { LATE_MS, isRetired, printable, scrubError, type LaneRead } from "./bucket.ts"; | |
| 28 | 28 | import { isOffBox } from "./off_box.ts"; |
| 29 | 29 | import { s3Where, type S3Target } from "./store.ts"; |
| 30 | 30 | |
| @@ -161,10 +161,27 @@ export function s3Lister(target: S3Target): Lister { | ||
| 161 | 161 | const NAME = /^[A-Za-z0-9][A-Za-z0-9 ._:/-]{0,63}$/; |
| 162 | 162 | const BUCKET = /^[A-Za-z0-9][A-Za-z0-9._-]{0,62}$/; |
| 163 | 163 | |
| 164 | /** The longest `backsUp` or `afterSpinDown` a lane may carry. */ | |
| 165 | export const LINE_MAX = 200; | |
| 166 | ||
| 167 | /** What a lane says of itself: what it backs up, and who reads it once the platform is gone (or `retired`). */ | |
| 168 | export interface LaneLine { | |
| 169 | readonly backsUp?: string | undefined; | |
| 170 | readonly afterSpinDown?: string | undefined; | |
| 171 | } | |
| 172 | ||
| 173 | /** One optional sentence: a string of at most `LINE_MAX`, control bytes replaced; `false` when it is not one. */ | |
| 174 | function sentence(v: unknown): string | undefined | false { | |
| 175 | if (v === undefined || v === null) return undefined; | |
| 176 | if (typeof v !== "string" || v.length > LINE_MAX) return false; | |
| 177 | const t = printable(v).trim(); | |
| 178 | return t === "" ? undefined : t; | |
| 179 | } | |
| 180 | ||
| 164 | 181 | const text = (v: unknown): string | undefined => (typeof v === "string" && v.trim() !== "" ? v.trim() : undefined); |
| 165 | 182 | |
| 166 | 183 | /** One declared lane, or why it is refused; the sentence never carries a value but the name. */ |
| 167 | function declared(raw: unknown, i: number): { name: string; target: S3Target; prefix: string } | { name: string; refused: string } { | |
| 184 | function declared(raw: unknown, i: number): { name: string; target: S3Target; prefix: string; line: LaneLine } | { name: string; refused: string } { | |
| 168 | 185 | const o = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}; |
| 169 | 186 | const given = text(o["name"]); |
| 170 | 187 | const name = given !== undefined && NAME.test(given) ? given : `lane ${i + 1}`; |
| @@ -190,11 +207,20 @@ function declared(raw: unknown, i: number): { name: string; target: S3Target; pr | ||
| 190 | 207 | const region = o["region"] === undefined ? undefined : text(o["region"]); |
| 191 | 208 | if (o["region"] !== undefined && (region === undefined || !/^[A-Za-z0-9-]{1,32}$/.test(region))) |
| 192 | 209 | return { name, refused: "its region is not a region name" }; |
| 193 | return { name, prefix, target: { endpoint: url.href.replace(/\/$/, ""), bucket, accessKeyId, secretAccessKey, region } }; | |
| 210 | const backsUp = sentence(o["backsUp"]); | |
| 211 | const afterSpinDown = sentence(o["afterSpinDown"]); | |
| 212 | if (backsUp === false || afterSpinDown === false) | |
| 213 | return { name, refused: `its backsUp or afterSpinDown is not a string of at most ${LINE_MAX} characters` }; | |
| 214 | return { | |
| 215 | name, | |
| 216 | prefix, | |
| 217 | line: { backsUp, afterSpinDown }, | |
| 218 | target: { endpoint: url.href.replace(/\/$/, ""), bucket, accessKeyId, secretAccessKey, region }, | |
| 219 | }; | |
| 194 | 220 | } |
| 195 | 221 | |
| 196 | 222 | export interface Declared { |
| 197 | readonly lanes: readonly { name: string; target: S3Target; prefix: string }[]; | |
| 223 | readonly lanes: readonly { name: string; target: S3Target; prefix: string; line: LaneLine }[]; | |
| 198 | 224 | /** Lanes the configuration names but that do not read whole: each a red lane on the screen. */ |
| 199 | 225 | readonly refused: readonly { name: string; says: string }[]; |
| 200 | 226 | } |
| @@ -239,7 +265,23 @@ export async function readDeclaredLanes( | ||
| 239 | 265 | ): Promise<LaneRead[]> { |
| 240 | 266 | const { lanes, refused } = parseLanes(env["FORGE_BACKUP_LANES"]); |
| 241 | 267 | const read = await Promise.all( |
| 242 | lanes.map((d) => readObjectLane({ name: d.name, prefix: d.prefix, lister: listerFor(d.target), target: d.target }, now)), | |
| 268 | lanes.map(async (d): Promise<LaneRead> => { | |
| 269 | const line = { ...(d.line.backsUp === undefined ? {} : { backsUp: d.line.backsUp }), ...(d.line.afterSpinDown === undefined ? {} : { afterSpinDown: d.line.afterSpinDown }) }; | |
| 270 | // A retired lane is listed, never read: its store may be gone. | |
| 271 | if (isRetired(d.line.afterSpinDown)) | |
| 272 | return { | |
| 273 | lane: d.name, | |
| 274 | where: s3Where(d.target), | |
| 275 | offBox: false, | |
| 276 | newestAt: null, | |
| 277 | newestName: null, | |
| 278 | newestBytes: null, | |
| 279 | count: 0, | |
| 280 | readAt: new Date(now).toISOString(), | |
| 281 | ...line, | |
| 282 | }; | |
| 283 | return { ...(await readObjectLane({ name: d.name, prefix: d.prefix, lister: listerFor(d.target), target: d.target }, now)), ...line }; | |
| 284 | }), | |
| 243 | 285 | ); |
| 244 | 286 | return [ |
| 245 | 287 | ...read, |
src/tui/screens/resilience.ts +1 −1
| @@ -108,7 +108,7 @@ export function verdictLines(f: Session): string[] { | ||
| 108 | 108 | return [ |
| 109 | 109 | ...wrap(`Backups ${mark}: ${v.line}`, INNER), |
| 110 | 110 | ...wrap(` ${v.checkLine}`, INNER), |
| 111 | ...v.lanes.flatMap((l) => wrap(` ${l.state === "red" ? "!" : " "} ${l.says}`, INNER)), | |
| 111 | ...v.lanes.flatMap((l) => wrap(` ${l.state === "red" ? "!" : " "} ${l.says}`, INNER).concat(wrap(` ${l.line}`, INNER))), | |
New terminal line, no test. Add one asserting verdictLines carries the line for a lane. |
||
| 112 | 112 | ...[...v.reasons, ...v.unknowns].flatMap((r) => wrap(` - ${r}`, INNER)), |
| 113 | 113 | ]; |
| 114 | 114 | } |
src/web/pages/backups.ts +30 −1
| @@ -26,6 +26,7 @@ import { resolveDestination, sealedSet } from "../../snapshot/destination.ts"; | ||
| 26 | 26 | import { keyOf } from "../../snapshot/snapshot.ts"; |
| 27 | 27 | import { formatMinutes, formatSpan, timeByPart, type DrillResult } from "../../snapshot/drill.ts"; |
| 28 | 28 | import { lastDrill } from "../../snapshot/drills.ts"; |
| 29 | import { backupVerdict } from "../../snapshot/bucket.ts"; | |
| 29 | 30 | import type { Store } from "../../snapshot/store.ts"; |
| 30 | 31 | import { snapshotsOf, type SnapshotsHandle } from "../../snapshot/service.ts"; |
| 31 | 32 | import { drillNow, mirrorNow, snapshotNow } from "../../actions/backups_now.ts"; |
| @@ -338,7 +339,7 @@ async function movingPart( | ||
| 338 | 339 | const restore = snapshots[0] |
| 339 | 340 | ? html`<pre class="restore-command" tabindex="0" aria-label="Restore command">bun run src/snapshot/restore.ts ${snapshots[0].name} /path/to/empty/data</pre>` |
| 340 | 341 | : null; |
| 341 | return html`<div id="backups-live" data-live="backup.*" data-live-src="/settings/backups/live">${recovery}${card( | |
| 342 | return html`<div id="backups-live" data-live="backup.*" data-live-src="/settings/backups/live">${recovery}${lanesCard(ctx)}${card( | |
| 342 | 343 | html`<p> |
| 343 | 344 | Every night at 08:00 UTC the forge writes what cannot be rebuilt — its |
| 344 | 345 | database, its keys, every repository, every app's production data, the |
| @@ -635,6 +636,34 @@ export async function backupsNow(ctx: Ctx): Promise<Response> { | ||
| 635 | 636 | ); |
| 636 | 637 | } |
| 637 | 638 | |
| 639 | /** | |
| 640 | * Each lane beside its newest object and its line (#9067); a lane without a | |
| 641 | * line says so, as information. Nothing when no lane has been read. The | |
| 642 | * verdict is the in-memory read the tile uses: no disk read on the live path. | |
| 643 | */ | |
outdated
Hand-rolled list markup instead of the named list row; and the retired state has no styling. Use row()/.setting-list per DESIGN.md §5 and add a test rendering this card. |
||
| 644 | export function lanesCard(ctx: Ctx): Html | "" { | |
| 645 | const lanes = backupVerdict(ctx.app.config.dataDir, lastDrill(ctx.app.db)).lanes; | |
| 646 | if (lanes.length === 0) return ""; | |
| 647 | // The glyph is the mark that is not colour alone: ok, !, and a dash for a lane that is retired. | |
| 648 | const mark = { green: "✓", red: "!", retired: "–" } as const; | |
| 649 | const lead = { green: "open", red: "closed", retired: "draft" } as const; | |
| 650 | return card( | |
| 651 | html`<ul id="backup-lanes" class="list setting-list"> | |
| 652 | ${lanes.map((l) => | |
| 653 | row({ | |
| 654 | lead: html`<span aria-hidden="true">${mark[l.state]}</span>`, | |
| 655 | state: lead[l.state], | |
| 656 | title: l.says, | |
| 657 | meta: html`${l.line}`, | |
| 658 | ...(l.state === "retired" ? { trail: badge("retired", { variant: "outline" }) } : {}), | |
| 659 | data: { lane: l.lane, "lane-state": l.state }, | |
| 660 | }), | |
| 661 | )} | |
| 662 | </ul>`, | |
| 663 | { head: html`<strong>Lanes</strong>` }, | |
| 664 | ); | |
| 665 | } | |
| 666 | ||
| 638 | 667 | /** POST /settings/backups/drill — restore the newest snapshot, keep what came of it. */ |
| 639 | 668 | export async function backupsDrill(ctx: Ctx): Promise<Response> { |
| 640 | 669 | if (!ctx.auth.user) return signInFirst(ctx); |
test/backup-lanes.test.ts +128 −1
| @@ -25,6 +25,7 @@ import { | ||
| 25 | 25 | type LanePage, |
| 26 | 26 | type Lister, |
| 27 | 27 | } from "../src/snapshot/lanes.ts"; |
| 28 | import { startSnapshots, type SnapshotsHandle } from "../src/snapshot/service.ts"; | |
| 28 | 29 | import { dirStore } from "../src/snapshot/store.ts"; |
| 29 | 30 | import { verdictLines } from "../src/tui/screens/resilience.ts"; |
| 30 | 31 | |
| @@ -193,7 +194,7 @@ describe("configuration", () => { | ||
| 193 | 194 | const got = parseLanes(JSON.stringify([lane])); |
| 194 | 195 | expect(got.refused).toEqual([]); |
| 195 | 196 | expect(got.lanes).toEqual([ |
| 196 | { name: "backup/postgres", prefix: "pg/", target: { endpoint: "https://s3.test", bucket: "backups", accessKeyId: ACCESS, secretAccessKey: SECRET, region: undefined } }, | |
| 197 | { name: "backup/postgres", prefix: "pg/", line: { backsUp: undefined, afterSpinDown: undefined }, target: { endpoint: "https://s3.test", bucket: "backups", accessKeyId: ACCESS, secretAccessKey: SECRET, region: undefined } }, | |
| 197 | 198 | ]); |
| 198 | 199 | }); |
| 199 | 200 | |
| @@ -367,3 +368,129 @@ describe("the S3 lister asks for one delimited page", () => { | ||
| 367 | 368 | }); |
| 368 | 369 | }); |
| 369 | 370 | |
| 371 | describe("a line per lane (#9067)", () => { | |
| 372 | const lane = { name: "backup/postgres", endpoint: "https://s3.test", bucket: "backups", prefix: "pg/", accessKey: ACCESS, secretKey: SECRET }; | |
| 373 | const fresh = { "pg/2026-11-02T10:00:00Z/dump": { bytes: 10, at: NOW - H } }; | |
| 374 | const env = (lanes: object[]) => ({ FORGE_BACKUP_LANES: JSON.stringify(lanes) }); | |
| 375 | const verdictOf = async (lanes: object[], listerFor?: (t: unknown) => Lister) => { | |
| 376 | const read = await readDeclaredLanes(env(lanes), NOW, listerFor as never ?? (() => fakeStore(fresh).lister)); | |
| 377 | return judge({ lanes: read, checkedAt: new Date(NOW).toISOString() }, undefined, NOW); | |
| 378 | }; | |
| 379 | ||
| 380 | test("a lane with both values shows them", async () => { | |
| 381 | const v = await verdictOf([{ ...lane, backsUp: "the postgres dump", afterSpinDown: "ops, from B2" }]); | |
| 382 | expect(v.lanes[0]!.line).toBe("backs up the postgres dump; after spin-down: ops, from B2"); | |
| 383 | expect(v.lanes[0]!.state).toBe("green"); | |
| 384 | }); | |
| 385 | ||
| 386 | test("a lane with neither says there is no line, and stays green", async () => { | |
| 387 | const v = await verdictOf([lane]); | |
| 388 | expect(v.lanes[0]!.line).toBe("No line yet: say what it backs up and who reads it after spin-down"); | |
| 389 | expect(v.state).not.toBe("red"); | |
| 390 | expect(v.lanes[0]!.state).toBe("green"); | |
| 391 | }); | |
| 392 | ||
| 393 | test("a lane with one value shows it and names the missing one", async () => { | |
| 394 | const a = await verdictOf([{ ...lane, backsUp: "the postgres dump" }]); | |
| 395 | expect(a.lanes[0]!.line).toBe("backs up the postgres dump; after spin-down: (not said)"); | |
| 396 | const b = await verdictOf([{ ...lane, afterSpinDown: "ops, from B2" }]); | |
| 397 | expect(b.lanes[0]!.line).toBe("backs up (not said); after spin-down: ops, from B2"); | |
| 398 | }); | |
| 399 | ||
| 400 | test("values are bounded, typed and printable; no key reaches the line", async () => { | |
| 401 | expect(parseLanes(JSON.stringify([{ ...lane, backsUp: 7 }])).refused).toHaveLength(1); | |
| 402 | expect(parseLanes(JSON.stringify([{ ...lane, afterSpinDown: "x".repeat(201) }])).refused).toHaveLength(1); | |
| 403 | const v = await verdictOf([{ ...lane, backsUp: "a\u001b[31mb", afterSpinDown: "ops" }]); | |
| 404 | expect(v.lanes[0]!.line).not.toContain("\u001b"); | |
| 405 | expect(JSON.stringify(v)).not.toContain(SECRET); | |
| 406 | }); | |
| 407 | ||
| 408 | test("a retired lane with an unreachable store is listed, never read, and the verdict stays green", async () => { | |
| 409 | const boom: Lister = { where: "gone.test/b", async page() { throw new Error("connection refused"); } }; | |
| 410 | const v = await verdictOf( | |
| 411 | [lane, { ...lane, name: "backup/old", bucket: "oldb", backsUp: "the old thing", afterSpinDown: "retired" }], | |
| 412 | (t: any) => (t.bucket === "oldb" ? boom : fakeStore(fresh).lister), | |
| 413 | ); | |
| 414 | const old = v.lanes.find((l) => l.lane === "backup/old")!; | |
| 415 | expect(old.state).toBe("retired"); | |
| 416 | expect(old.says).toContain("retired"); | |
| 417 | expect(v.reasons).toEqual([]); | |
| 418 | expect(v.state).not.toBe("red"); | |
| 419 | }); | |
| 420 | ||
| 421 | test("a retired lane's store is never asked", async () => { | |
| 422 | let asked = 0; | |
| 423 | const read = await readDeclaredLanes(env([{ ...lane, afterSpinDown: "Retired" }]), NOW, () => { | |
| 424 | asked++; | |
| 425 | return fakeStore(fresh).lister; | |
| 426 | }); | |
| 427 | expect(read[0]!.error).toBeUndefined(); | |
| 428 | expect(asked).toBe(0); | |
| 429 | expect(read[0]!.newestAt).toBeNull(); | |
| 430 | }); | |
| 431 | }); | |
| 432 | ||
| 433 | describe("the lanes in the web door and the terminal (#9067)", () => { | |
| 434 | let root = ""; | |
| 435 | let app: App; | |
| 436 | let servers: Servers; | |
| 437 | let stop: () => void; | |
| 438 | let svc: SnapshotsHandle; | |
| 439 | const fresh = { "pg/2026-11-02T10:00:00Z/dump": { bytes: 10, at: Date.now() - H } }; | |
| 440 | const lane = { name: "backup/pg", endpoint: "https://s3.test", bucket: "backups", prefix: "pg/", accessKey: ACCESS, secretKey: SECRET }; | |
| 441 | ||
| 442 | beforeAll(async () => { | |
| 443 | root = await mkdtemp(join(tmpdir(), "forge-lanes-door-")); | |
| 444 | app = createApp({ dataDir: root, port: 0 }); | |
| 445 | const u = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 446 | if (!u.ok) throw new Error(u.error); | |
| 447 | // The page draws only where a snapshot service is running. | |
| 448 | svc = startSnapshots(app, dirStore(join(root, "store")), { version: "t", nightly: false }); | |
| 449 | servers = await serve(app); | |
| 450 | const env = { | |
| 451 | FORGE_BACKUP_LANES: JSON.stringify([ | |
| 452 | { ...lane, backsUp: "the postgres dump", afterSpinDown: "ops, from B2" }, | |
| 453 | { ...lane, name: "backup/bare", bucket: "bare" }, | |
| 454 | { ...lane, name: "backup/old", bucket: "old", backsUp: "the old thing", afterSpinDown: "retired" }, | |
| 455 | ]), | |
| 456 | }; | |
| 457 | const w = watchBucket(root, () => [], 3_600_000, 3_600_000, () => [], (now) => readDeclaredLanes(env, now, () => fakeStore(fresh).lister)); | |
| 458 | stop = w.stop; | |
| 459 | await w.pass(); | |
| 460 | }); | |
| 461 | afterAll(async () => { | |
| 462 | stop(); | |
| 463 | await svc.stop(); | |
| 464 | await servers.stop(); | |
| 465 | await rm(root, { recursive: true, force: true }); | |
| 466 | }); | |
| 467 | ||
| 468 | test("/settings/backups lists each lane as a row, with its line, the missing line said, and a retired mark", async () => { | |
| 469 | const r = await fetch(`${servers.url}/settings/backups`, { headers: { cookie: await login() } }); | |
| 470 | const t = await r.text(); | |
| 471 | const li = (name: string) => t.split("<li").find((x) => x.includes(`data-lane="${name}"`)) ?? ""; | |
| 472 | expect(li("backup/pg")).toContain("backs up the postgres dump; after spin-down: ops, from B2"); | |
| 473 | expect(li("backup/pg")).toContain('data-lane-state="green"'); | |
| 474 | expect(li("backup/bare")).toContain("No line yet: say what it backs up and who reads it after spin-down"); | |
| 475 | expect(li("backup/old")).toContain('data-lane-state="retired"'); | |
| 476 | expect(li("backup/old")).toContain("retired"); | |
| 477 | expect(li("backup/old")).toContain('class="badge"'); | |
| 478 | expect(t).toContain('class="list setting-list"'); | |
| 479 | }); | |
| 480 | ||
| 481 | test("the terminal's verdict lines carry each lane's line", () => { | |
| 482 | const text = verdictLines({ app } as never).join("\n"); | |
| 483 | expect(text).toContain("backs up the postgres dump; after spin-down: ops, from B2"); | |
| 484 | expect(text).toContain("No line yet"); | |
| 485 | }); | |
| 486 | ||
| 487 | async function login(): Promise<string> { | |
| 488 | const r = await fetch(`${servers.url}/signin`, { | |
| 489 | method: "POST", | |
| 490 | headers: { origin: servers.url }, | |
| 491 | body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }), | |
| 492 | redirect: "manual", | |
| 493 | }); | |
| 494 | return (r.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 495 | } | |
| 496 | }); |
new above
offline
A lane with only one value set shows NO_LINE and hides what it did say. Show the given value and name which one is missing.