Public: anyone can read this; the crew can change it.
Everything the forge needs to be reachable and to release is written down, with where it runs: today six of those things run on the DigitalOcean node #11692
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/11685-everything-the-forge-needs-to-be-reachab → main 7 commits fork
16 files
changed +1198
−5
AGENTS.md +2
| @@ -25,6 +25,8 @@ State shared between modules through process memory, not the database, is named | ||
| 25 | 25 | |
| 26 | 26 | `src/core/transfer.ts`: the old owner/name pairs a transfer left behind (`repo_redirect`), held per database after the first request reads them and dropped by `transferRepo`, so a request under a path nothing moved from costs no statement (#569). |
| 27 | 27 | |
| 28 | `src/system/footing.ts`: `footing`, the last reading of what the forge stands on from the kube API (#11685), held 60 s so /system, its terminal screen (`src/tui/screens/footing.ts`, palette entry for admins) and the MCP `system` tool (`kind: footing`) share one read; a probe that throws is not retried for 60 s; one reading, gone on restart. | |
| 29 | ||
| 28 | 30 | `src/apps/timer_fire.ts`: `inFlight`, the ids of timers being fired in this process, so a slow app's timer is never fired twice at once; at most the timers due, gone when each settles. The timers themselves are rows (`timer`), read at every boot, never memory. |
| 29 | 31 | |
| 30 | 32 | ## Awake ceiling and the boot |
docs/MCP.md +1 −1
| @@ -209,7 +209,7 @@ bytes in `tools/list`. After changing a tool run `bun scripts/mcp-table.ts`; | ||
| 209 | 209 | | `subscription_wake` | — | Admin: wake a resting subscription | 168 | |
| 210 | 210 | | `subscriptions` | — | Claude accounts: scopes, budgets | 158 | |
| 211 | 211 | | `sync` | — | Sync a mirror now; `originId` reconnects it | 197 | |
| 212 | | `system` | `csp`, `routes` | The forge machine now: CPU, memory, disk, network | 286 | | |
| 212 | | `system` | `csp`, `routes`, `footing` | The forge machine now: CPU, memory, disk, network | 296 | | |
| 213 | 213 | | `take_back` | — | Open the pull that undoes a shipped pull | 219 | |
| 214 | 214 | | `take_updates` | — | Take the parent forge's updates into a fork | 176 | |
| 215 | 215 | | `teams` | — | Crew teams for person admins: charter, dial (0–100) and member agents | 338 | |
docs/OPERATING.md +63
| @@ -89,6 +89,69 @@ Unset, only this machine may say so, and the named header is never read. Everyth | ||
| 89 | 89 | instance _is_ lives under the data directory — the SQLite index, the bare |
| 90 | 90 | repos, and the SSH host key, in plain layout. Tar it up and it moves. |
| 91 | 91 | |
| 92 | ## What the forge stands on | |
| 93 | ||
| 94 | Each thing the forge needs to answer a request, sign a person in, build, | |
| 95 | release and back up, the machine it runs on, what happens to the forge if | |
| 96 | that machine dies, and the issue that removes the dependency. The list is the | |
| 97 | steward's read-only inventory of both machines on 2026-11-12 (#11685), the day | |
| 98 | pg-grand took the forge off the internet for 24 minutes while the forge itself | |
| 99 | ran fine on the vxrail, and the day the owner decided to drop pg-grand | |
| 100 | (#11686). Every row on pg-grand is a way that machine takes the forge | |
| 101 | down. | |
| 102 | ||
| 103 | <!-- footing:start — written by footingTable() in src/system/footing.ts; test/footing.test.ts holds it equal --> | |
| 104 | - **pg-grand**: DigitalOcean. The k3s server, a single control plane with no HA. The owner decided on 2026-11-12 to drop it. | |
| 105 | - **vxrail**: A k3s agent joined to pg-grand. The forge runs here. | |
| 106 | ||
| 107 | | Needed to | Piece | Runs on | If that machine dies | Breaks | Removed by | | |
| 108 | |---|---|---|---|---|---| | |
| 109 | | (a) answer a request | The forge's pod (git.open-platform.sh/trevato-apps/forge:<version>) and its data, PVC forge-data-vxrail (60 Gi) | vxrail | The forge is down, and its data is on that disk. | — | — | | |
| 110 | | (a) answer a request | Cloudflare tunnel carrying forge.open-platform.sh (standby/cloudflared), moved to the vxrail by hand on 2026-11-12; its last-applied manifest still says 0 replicas, so a re-render moves it back | vxrail | It goes with the forge, which runs beside it. | — | #8948, #11686 | | |
| 111 | | (a) answer a request | The vxrail's own tunnel (standby/cloudflared-forge-home, 2 replicas): it too sends *.open-platform.sh to Traefik; only forge-home-test.open-platform.sh and arm--forge-trevato-apps.open-platform.sh go straight to the forge | vxrail | It goes with the forge, which runs beside it. | — | — | | |
| 112 | | (a) answer a request | Traefik (kube-system/traefik): every public request goes tunnel → Traefik → the forge's Service | pg-grand | The forge is off the internet; it keeps running inside. | 1 | #11686, #8946 | | |
| 113 | | (a) answer a request | Child and app hosts, Traefik IngressRoutes forge-children (<name>--forge.open-platform.sh), forge-apps (*--forge-trevato-apps.open-platform.sh) and forge-old-host, and game.cheffryjames.com and www.cheffryjames.com in the forge's namespace | pg-grand | No child forge, app or custom domain answers. | 1 | #11686, #8946 | | |
| 114 | | (a) answer a request | forge-ssh, a host socket proxy (:2222 → the forge's ssh service) | pg-grand | git over ssh and the terminal stop answering. | 7 | #11686 | | |
| 115 | | (a) answer a request | The k3s API server (the vxrail is an agent joined to it) | pg-grand | Running pods keep running, but nothing reschedules, Services stop updating, and no release rolls or Job runs. | 5 | #11686 | | |
| 116 | | (b) sign a person in | Sign-in edge: a forwardAuth middleware on the forge's Ingress, answered by mcp/plat-auth | pg-grand | Every request through the edge answers 500, /healthz too. | 2 | #11686 | | |
| 117 | | (b) sign a person in | forge-signup-gate, the pod /signup is routed to | pg-grand | No one can sign up. | 3 | #11686 | | |
| 118 | | (c) build | Forgejo (git.open-platform.sh) and its Actions, which build the release image | pg-grand | No release image is built. | 4 | #11686, #8941 | | |
| 119 | | (c) build | Forgejo's database, a single instance under CloudNativePG (cnpg-system) | pg-grand | Forgejo stops, and with it builds. | 4 | #11686, #8941 | | |
| 120 | | (c) build | forgejo-runner-vxrail, a Forgejo Actions runner | vxrail | It goes with the forge, which runs beside it. | — | — | | |
| 121 | | (d) release | Forgejo's registry (git.open-platform.sh), which the forge's image is pulled from | pg-grand | A pod that restarts on a node without the image cached cannot start; nothing new is released. | 4 | #11686, #8941, #8942 | | |
| 122 | | (d) release | registry-node, a registry on the vxrail | vxrail | It goes with the forge, which runs beside it. | — | — | | |
| 123 | | (d) release | forge-release-tick, a host timer every 5 min | pg-grand | Nothing is released; production keeps the release it has. | 7 | #11686, #8942 | | |
| 124 | | (d) release | standby-reconcile, a host timer every 2 min that applies the forge's manifests from Forgejo; Flux's Kustomization for the forge is suspended | pg-grand | A changed manifest is not applied; the running pod is untouched. | 7 | #8948, #11686 | | |
| 125 | | (d) release | Host timers forge-watchdog, forge-github-mirror and forge-prune-images | pg-grand | The watchdog, the GitHub mirror and image pruning stop. | 7 | #11686 | | |
| 126 | | (e) back up | The backup namespace's CronJobs (FORGE_BACKUP_LANES) | pg-grand | No new backup is taken. | 6 | #11686 | | |
| 127 | | (e) back up | MinIO, where the backup lanes store the forge's backups | pg-grand | No new backup is stored, and those it holds go with the machine. | 6 | #11686 | | |
| 128 | ||
| 129 | If pg-grand were switched off now, the forge breaks in this order (the Breaks column): | |
| 130 | ||
| 131 | 1. Traefik: nothing public reaches the forge | |
| 132 | 2. plat-auth: no one signs in | |
| 133 | 3. the signup gate: no one signs up | |
| 134 | 4. image pulls and releases | |
| 135 | 5. the control plane: nothing reschedules, Services stop updating | |
| 136 | 6. local backups | |
| 137 | 7. the host timers | |
| 138 | ||
| 139 | Also on pg-grand, not in the forge's path: Forgejo and its database, the platform's mcp/plat-auth and www, the CloudNativePG operator with the tenant apps' databases, their local-path volumes pinned to that node, and MinIO. | |
| 140 | <!-- footing:end --> | |
| 141 | ||
| 142 | The same list is read from the live cluster on `/system`, under "What the | |
| 143 | forge stands on", on the terminal's screen of that name, and from the MCP | |
| 144 | `system` tool with `{ kind: "footing" }`; how to read each is in | |
| 145 | [](features/footing.md). The probe | |
| 146 | (`src/system/footing.ts`) asks the kube API, with the forge's own service | |
| 147 | account, for the forge's node, the nodes labelled control-plane, and the pods | |
| 148 | in each namespace above; it never lists every pod in the cluster. What the | |
| 149 | cluster cannot show keeps its declared machine and says why. To see every | |
| 150 | row from the cluster, the forge's service account needs `get` on its own pod, | |
| 151 | `list` on `nodes`, and `list` on `pods` in those namespaces (#8948). The | |
| 152 | reading is held 60 s, a page waits at most 0.8 s for it, and a probe that | |
| 153 | fails is not retried for a minute. | |
| 154 | ||
| 92 | 155 | ## Health |
| 93 | 156 | |
| 94 | 157 | `GET /api/v1/health` (and the MCP `health` tool) answer a site admin with |
docs/features/footing.md added +58
| @@ -0,0 +1,58 @@ | ||
| 1 | # What the forge stands on (#11685) | |
| 2 | ||
| 3 | The pieces the forge needs to answer a request, sign a person in, build, | |
| 4 | release and back up, and the machine each one runs on. Any piece that is not | |
| 5 | on the forge's own machine is a way that machine can take the forge down. The | |
| 6 | declared list is the table in [](../OPERATING.md#what-the-forge-stands-on); | |
| 7 | the three doors below read it against the live cluster. | |
| 8 | ||
| 9 | All three are for a forge admin who is a person (the MCP tool: a forge admin). | |
| 10 | None of them waits on the cluster: they show the last reading, held 60 s, and | |
| 11 | the declared list while the first one runs. | |
| 12 | ||
| 13 | ## On /system | |
| 14 | ||
| 15 | Under **What the forge stands on**, the summary says how many pieces run on | |
| 16 | another machine. Open it to see: | |
| 17 | ||
| 18 | - where the forge runs (its pod's node), or why that was not read; | |
| 19 | - each piece on another machine, in the order switching pg-grand off would | |
| 20 | break the forge, with what it is needed for, where it runs, what happens if | |
| 21 | that machine stops, its place in that order, and the issues that remove it; | |
| 22 | - the pieces on the forge's own machine, by name; | |
| 23 | - what each machine is. | |
| 24 | ||
| 25 | "Runs on" is a node name when the cluster showed the piece's pod there. It | |
| 26 | reads `pg-grand (declared: …)` when the cluster could not show it, with the | |
| 27 | reason: a systemd unit on a host, a CronJob with no pod between runs, a | |
| 28 | namespace the service account may not list, or a pod whose namespace is not | |
| 29 | recorded. A declared piece is judged by its declared machine. | |
| 30 | ||
| 31 | ## In the terminal | |
| 32 | ||
| 33 | Open the palette and choose **What the forge stands on** (it follows | |
| 34 | **Main-thread stalls**). The screen lists the same rows as the page: the | |
| 35 | forge's node, then each piece on another machine in break order with its | |
| 36 | step, need, machine and what happens if it dies, then the steps themselves and | |
| 37 | the pieces on the forge's own machine. `j`/`k` scroll, `esc` closes. | |
| 38 | ||
| 39 | ## Over MCP | |
| 40 | ||
| 41 | json | |
| 42 | { "name": "system", "arguments": { "kind": "footing" } } | |
| 43 | ||
| 44 | ||
| 45 | answers `forgeNode`, `unread` (why the forge's node was not read, or null), | |
| 46 | `at`, and per piece `{ id, need, what, machine, seen, risk, ifDies, issues, | |
| 47 | breaks }`. `seen` is `{ by: "cluster", nodes }` when the cluster showed it and | |
| 48 | `{ by: "declared", why }` otherwise; `risk` is true when the piece is not on | |
| 49 | the forge's machine. `risks` repeats those pieces as one line each, in break | |
| 50 | order. Anyone but a forge admin is refused. | |
| 51 | ||
| 52 | ## Keeping it true | |
| 53 | ||
| 54 | The list is `FOOTING` in `src/system/footing.ts`; `test/footing.test.ts` holds | |
| 55 | the OPERATING.md table equal to `footingTable()`. Change the list there, then | |
| 56 | paste the output of | |
| 57 | `bun -e 'import {footingTable} from "./src/system/footing.ts"; console.log(footingTable())'` | |
| 58 | between the markers in OPERATING.md. |
src/http/mcp/tools/system.ts +10 −4
| @@ -7,16 +7,17 @@ import { stallHour, TOP_ROUTES } from "../../../metrics/stalls.ts"; | ||
| 7 | 7 | import { lagMinutes, loopWatch, perfRecord } from "../../../perf/live.ts"; |
| 8 | 8 | import { LAG_BAR_MS, overBar } from "../../../perf/lag_minutes.ts"; |
| 9 | 9 | import { slowestRoutes } from "../../../perf/watch.ts"; |
| 10 | import { footing, riskLines } from "../../../system/footing.ts"; | |
| 10 | 11 | import { good, bad, type Tool } from "../shared.ts"; |
| 11 | 12 | |
| 12 | 13 | export const tool: Tool = { |
| 13 | 14 | name: "system", |
| 14 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast, `{ kind: \"routes\" }`: the 10 slowest budgeted routes as {route, p95Ms, budgetMs, n, slowestPhase}, over budget first; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only", | |
| 15 | doc: "the machine the forge runs on, now: CPU, load, memory, disk, network, processes, `stalls`: the last hour's main-thread stalls by route (`<route> (in flight)` when still running at the wake), or `job:<name>`, as {cause, ms, count}, `readings`: the last 20 minutes' worst loop lag as {at, ms, what} beside `barMs` (100) and `over`, and `readers`: who polls the telemetry and how fast, `{ kind: \"routes\" }`: the 10 slowest budgeted routes as {route, p95Ms, budgetMs, n, slowestPhase}, over budget first; a forge admin, or an agent granted `telemetry:read`. `{ kind: \"csp\" }`: the last 24 h of Content-Security-Policy violations browsers reported, `{ directive, n, newest }` per directive (`newest` the blocked host last counted), most first, counted per UTC hour from `since`, and `scriptErrors` `{ page, where, message, n, last }`, as the security page reads them; a forge admin only. `{ kind: \"footing\" }`: what the forge stands on (#11685), as /system's \"What the forge stands on\": `forgeNode`, and per piece `{ id, need, what, machine, seen, risk, ifDies, issues }` read from the live cluster, with `risks`, one line per piece not on the forge's machine; a forge admin only", | |
| 15 | 16 | description: |
| 16 | 17 | "The forge machine now: CPU, memory, disk, network. Admin or telemetry:read; `kind: csp`: 24 h of CSP violations (admin).", |
| 17 | inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp", "routes"] } } }, | |
| 18 | inputSchema: { type: "object", properties: { kind: { type: "string", enum: ["csp", "routes", "footing"] } } }, | |
| 18 | 19 | writes: false, |
| 19 | run(ctx, user, args) { | |
| 20 | async run(ctx, user, args) { | |
| 20 | 21 | if (args["kind"] !== undefined) { |
| 21 | 22 | if (args["kind"] === "routes") { |
| 22 | 23 | const reader = telemetryReader(ctx.app, user); |
| @@ -24,7 +25,12 @@ export const tool: Tool = { | ||
| 24 | 25 | noteRead(ctx.app, user, reader, "system"); |
| 25 | 26 | return good({ routes: slowestRoutes(perfRecord, Date.now()) }); |
| 26 | 27 | } |
| 27 | if (args["kind"] !== "csp") return bad("invalid", "kind is csp, routes, or absent for the machine"); | |
| 28 | if (args["kind"] === "footing") { | |
| 29 | if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads what it stands on"); | |
| 30 | const f = await footing.read(); | |
| 31 | return good({ ...f, risks: riskLines(f) }); | |
| 32 | } | |
| 33 | if (args["kind"] !== "csp") return bad("invalid", "kind is csp, routes, footing, or absent for the machine"); | |
| 28 | 34 | if (!user.isAdmin) return bad("forbidden", "only an admin of this forge reads its CSP violations"); |
| 29 | 35 | const now = new Date(); |
| 30 | 36 | return good({ hours: 24, since: windowStart(24, now).toISOString(), directives: violationsByDirective(ctx.app.db, 24, now), scriptErrors: scriptErrorCounts(ctx.app.db, 24, now) }); |
src/perf/record.ts +6
| @@ -50,6 +50,8 @@ export interface PerfRecord { | ||
| 50 | 50 | /** When `pattern` was first seen in this process, undefined if never. */ |
| 51 | 51 | first(pattern: string): number | undefined; |
| 52 | 52 | patterns(): string[]; |
| 53 | /** Forget every route's samples (not the minute ledger), as `loopWatch.clear` does its stalls: for a test that reads the record whole. */ | |
| 54 | clear(): void; | |
| 53 | 55 | /** Who used the answering time, by the minute: exact however the samples are thinned. */ |
| 54 | 56 | readonly ledger: TrafficLedger; |
| 55 | 57 | } |
| @@ -100,6 +102,10 @@ export function createPerfRecord(valid: (key: string) => boolean = validPattern) | ||
| 100 | 102 | traffic: (pattern, since) => (real.get(pattern) ?? []).filter((s) => s.at >= since), |
| 101 | 103 | first: (pattern) => firsts.get(pattern), |
| 102 | 104 | patterns: () => [...real.keys()], |
| 105 | clear() { | |
| 106 | real.clear(); | |
| 107 | firsts.clear(); | |
| 108 | }, | |
| 103 | 109 | }; |
| 104 | 110 | } |
| 105 | 111 |
src/system/footing.ts added +524
| @@ -0,0 +1,524 @@ | ||
| 1 | /** | |
| 2 | * What the forge stands on (#11685): every piece the forge needs to answer a | |
| 3 | * request, sign a person in, build, release and back up, where each was | |
| 4 | * found running, and what happens to the forge if that machine dies. | |
| 5 | * | |
| 6 | * `FOOTING` is the declared list, from the steward's read-only inventory of | |
| 7 | * both machines on 2026-11-12, the day a full disk on pg-grand (DigitalOcean) | |
| 8 | * took the forge off the internet for 24 minutes while the forge itself ran on | |
| 9 | * the vxrail, and the owner decided to drop pg-grand. `docs/OPERATING.md` carries the same list as | |
| 10 | * a table (`footingTable` writes it; `test/footing.test.ts` holds the doc to | |
| 11 | * it), so the two cannot drift. | |
| 12 | * | |
| 13 | * `probeFooting` reads the live cluster over the kube API with the forge's | |
| 14 | * own service account: the forge's node (its own pod's `spec.nodeName`), the | |
| 15 | * control-plane nodes, and the node of each piece's pods. A piece the cluster | |
| 16 | * cannot show (a systemd unit on a host, a CronJob with no pod now, a | |
| 17 | * namespace the service account may not list) keeps its declared machine and | |
| 18 | * says so. Any required piece not on the forge's own machine is a risk line. | |
| 19 | * Only namespaced pod lists are asked for, never the whole cluster's. | |
| 20 | * | |
| 21 | * `footing` holds the last reading for 60 s in process memory (named in | |
| 22 | * AGENTS.md), so /system, its terminal screen (`src/tui/screens/footing.ts`) | |
| 23 | * and the MCP answer share one read and a page never waits on the cluster for | |
| 24 | * more than `WAIT_MS`; a probe that throws is not retried for 60 s. | |
| 25 | * | |
| 26 | * The descriptions carry no figure that goes stale (disk fill, sizes, counts): | |
| 27 | * those were true on one day and are not re-read here. | |
| 28 | */ | |
| 29 | import { serviceAccount, type Credentials } from "../cluster/jobs.ts"; | |
| 30 | ||
| 31 | export type Need = "answer" | "sign-in" | "build" | "release" | "backup"; | |
| 32 | ||
| 33 | /** The machines in the forge's path, by the name the steward's inventory gives them. */ | |
| 34 | export type Machine = "pg-grand" | "vxrail"; | |
| 35 | ||
| 36 | /** The machine the forge itself runs on, as declared. */ | |
| 37 | export const FORGE_MACHINE: Machine = "vxrail"; | |
| 38 | ||
| 39 | /** What each machine is, from the steward's read-only inventory of 2026-11-12. */ | |
| 40 | export const MACHINES: Record<Machine, string> = { | |
| 41 | "pg-grand": "DigitalOcean. The k3s server, a single control plane with no HA. The owner decided on 2026-11-12 to drop it.", | |
| 42 | vxrail: "A k3s agent joined to pg-grand. The forge runs here.", | |
| 43 | }; | |
| 44 | ||
| 45 | export type Find = | |
| 46 | /** Pods of one workload (Deployment, StatefulSet, DaemonSet) in a namespace, or every pod (`*`); namespace `""` is the forge's own. */ | |
| 47 | | { readonly kind: "pods"; readonly namespace: string; readonly workload: string } | |
| 48 | /** The forge's own pod. */ | |
| 49 | | { readonly kind: "self" } | |
| 50 | /** The Kubernetes API server: the nodes labelled control-plane. */ | |
| 51 | | { readonly kind: "control-plane" } | |
| 52 | /** Not in the cluster: a systemd unit on a host; the cluster cannot show it, so the declared machine stands. */ | |
| 53 | | { readonly kind: "host"; readonly unit: string } | |
| 54 | /** A pod whose namespace is not recorded: not looked for, since that would list every pod in the cluster. */ | |
| 55 | | { readonly kind: "unlocated" }; | |
| 56 | ||
| 57 | export interface Piece { | |
| 58 | readonly id: string; | |
| 59 | readonly need: Need; | |
| 60 | readonly what: string; | |
| 61 | readonly find: Find; | |
| 62 | /** Where it ran when the list was written. */ | |
| 63 | readonly machine: Machine; | |
| 64 | /** What happens to the forge if that machine dies. */ | |
| 65 | readonly ifDies: string; | |
| 66 | /** The issues that remove the dependency; none for a piece on the forge's own machine. */ | |
| 67 | readonly issues: readonly number[]; | |
| 68 | /** With pg-grand switched off, the step at which this piece fails the forge (1 first). */ | |
| 69 | readonly breaks?: number; | |
| 70 | } | |
| 71 | ||
| 72 | export const NEEDS: readonly Need[] = ["answer", "sign-in", "build", "release", "backup"]; | |
| 73 | ||
| 74 | /** The steps in which the forge breaks if pg-grand is switched off now, in order. */ | |
| 75 | export const BREAK_STEPS: readonly string[] = [ | |
| 76 | "Traefik: nothing public reaches the forge", | |
| 77 | "plat-auth: no one signs in", | |
| 78 | "the signup gate: no one signs up", | |
| 79 | "image pulls and releases", | |
| 80 | "the control plane: nothing reschedules, Services stop updating", | |
| 81 | "local backups", | |
| 82 | "the host timers", | |
| 83 | ]; | |
| 84 | ||
| 85 | const SAME_DEATH = "It goes with the forge, which runs beside it."; | |
| 86 | ||
| 87 | export const FOOTING: readonly Piece[] = [ | |
| 88 | { | |
| 89 | id: "forge", | |
| 90 | need: "answer", | |
| 91 | what: "The forge's pod (git.open-platform.sh/trevato-apps/forge:<version>) and its data, PVC forge-data-vxrail (60 Gi)", | |
| 92 | find: { kind: "self" }, | |
| 93 | machine: "vxrail", | |
| 94 | ifDies: "The forge is down, and its data is on that disk.", | |
| 95 | issues: [], | |
| 96 | }, | |
| 97 | { | |
| 98 | id: "tunnel", | |
| 99 | need: "answer", | |
| 100 | what: "Cloudflare tunnel carrying forge.open-platform.sh (standby/cloudflared), moved to the vxrail by hand on 2026-11-12; its last-applied manifest still says 0 replicas, so a re-render moves it back", | |
| 101 | find: { kind: "pods", namespace: "standby", workload: "cloudflared" }, | |
| 102 | machine: "vxrail", | |
| 103 | ifDies: SAME_DEATH, | |
| 104 | issues: [8948, 11686], | |
| 105 | }, | |
| 106 | { | |
| 107 | id: "tunnel-home", | |
| 108 | need: "answer", | |
| 109 | what: "The vxrail's own tunnel (standby/cloudflared-forge-home, 2 replicas): it too sends *.open-platform.sh to Traefik; only forge-home-test.open-platform.sh and arm--forge-trevato-apps.open-platform.sh go straight to the forge", | |
| 110 | find: { kind: "pods", namespace: "standby", workload: "cloudflared-forge-home" }, | |
| 111 | machine: "vxrail", | |
| 112 | ifDies: SAME_DEATH, | |
| 113 | issues: [], | |
| 114 | }, | |
| 115 | { | |
| 116 | id: "traefik", | |
| 117 | need: "answer", | |
| 118 | what: "Traefik (kube-system/traefik): every public request goes tunnel → Traefik → the forge's Service", | |
| 119 | find: { kind: "pods", namespace: "kube-system", workload: "traefik" }, | |
| 120 | machine: "pg-grand", | |
| 121 | ifDies: "The forge is off the internet; it keeps running inside.", | |
| 122 | issues: [11686, 8946], | |
| 123 | breaks: 1, | |
| 124 | }, | |
| 125 | { | |
| 126 | id: "hosts", | |
| 127 | need: "answer", | |
| 128 | what: "Child and app hosts, Traefik IngressRoutes forge-children (<name>--forge.open-platform.sh), forge-apps (*--forge-trevato-apps.open-platform.sh) and forge-old-host, and game.cheffryjames.com and www.cheffryjames.com in the forge's namespace", | |
| 129 | find: { kind: "pods", namespace: "kube-system", workload: "traefik" }, | |
| 130 | machine: "pg-grand", | |
| 131 | ifDies: "No child forge, app or custom domain answers.", | |
| 132 | issues: [11686, 8946], | |
| 133 | breaks: 1, | |
| 134 | }, | |
| 135 | { | |
| 136 | id: "forge-ssh", | |
| 137 | need: "answer", | |
| 138 | what: "forge-ssh, a host socket proxy (:2222 → the forge's ssh service)", | |
| 139 | find: { kind: "host", unit: "forge-ssh" }, | |
| 140 | machine: "pg-grand", | |
| 141 | ifDies: "git over ssh and the terminal stop answering.", | |
| 142 | issues: [11686], | |
| 143 | breaks: 7, | |
| 144 | }, | |
| 145 | { | |
| 146 | id: "control-plane", | |
| 147 | need: "answer", | |
| 148 | what: "The k3s API server (the vxrail is an agent joined to it)", | |
| 149 | find: { kind: "control-plane" }, | |
| 150 | machine: "pg-grand", | |
| 151 | ifDies: "Running pods keep running, but nothing reschedules, Services stop updating, and no release rolls or Job runs.", | |
| 152 | issues: [11686], | |
| 153 | breaks: 5, | |
| 154 | }, | |
| 155 | { | |
| 156 | id: "plat-auth", | |
| 157 | need: "sign-in", | |
| 158 | what: "Sign-in edge: a forwardAuth middleware on the forge's Ingress, answered by mcp/plat-auth", | |
| 159 | find: { kind: "pods", namespace: "mcp", workload: "plat-auth" }, | |
| 160 | machine: "pg-grand", | |
| 161 | ifDies: "Every request through the edge answers 500, /healthz too.", | |
| 162 | issues: [11686], | |
| 163 | breaks: 2, | |
| 164 | }, | |
| 165 | { | |
| 166 | id: "signup-gate", | |
| 167 | need: "sign-in", | |
| 168 | what: "forge-signup-gate, the pod /signup is routed to", | |
| 169 | // An Ingress routes only to a Service in its own namespace: the forge's. | |
| 170 | find: { kind: "pods", namespace: "", workload: "forge-signup-gate" }, | |
| 171 | machine: "pg-grand", | |
| 172 | ifDies: "No one can sign up.", | |
| 173 | issues: [11686], | |
| 174 | breaks: 3, | |
| 175 | }, | |
| 176 | { | |
| 177 | id: "forgejo", | |
| 178 | need: "build", | |
| 179 | what: "Forgejo (git.open-platform.sh) and its Actions, which build the release image", | |
| 180 | find: { kind: "pods", namespace: "forgejo", workload: "*" }, | |
| 181 | machine: "pg-grand", | |
| 182 | ifDies: "No release image is built.", | |
| 183 | issues: [11686, 8941], | |
| 184 | breaks: 4, | |
| 185 | }, | |
| 186 | { | |
| 187 | id: "cnpg", | |
| 188 | need: "build", | |
| 189 | what: "Forgejo's database, a single instance under CloudNativePG (cnpg-system)", | |
| 190 | find: { kind: "pods", namespace: "cnpg-system", workload: "*" }, | |
| 191 | machine: "pg-grand", | |
| 192 | ifDies: "Forgejo stops, and with it builds.", | |
| 193 | issues: [11686, 8941], | |
| 194 | breaks: 4, | |
| 195 | }, | |
| 196 | { | |
| 197 | id: "runner", | |
| 198 | need: "build", | |
| 199 | what: "forgejo-runner-vxrail, a Forgejo Actions runner", | |
| 200 | find: { kind: "unlocated" }, | |
| 201 | machine: "vxrail", | |
| 202 | ifDies: SAME_DEATH, | |
| 203 | issues: [], | |
| 204 | }, | |
| 205 | { | |
| 206 | id: "registry", | |
| 207 | need: "release", | |
| 208 | what: "Forgejo's registry (git.open-platform.sh), which the forge's image is pulled from", | |
| 209 | find: { kind: "pods", namespace: "forgejo", workload: "*" }, | |
| 210 | machine: "pg-grand", | |
| 211 | ifDies: "A pod that restarts on a node without the image cached cannot start; nothing new is released.", | |
| 212 | issues: [11686, 8941, 8942], | |
| 213 | breaks: 4, | |
| 214 | }, | |
| 215 | { | |
| 216 | id: "registry-node", | |
| 217 | need: "release", | |
| 218 | what: "registry-node, a registry on the vxrail", | |
| 219 | find: { kind: "unlocated" }, | |
| 220 | machine: "vxrail", | |
| 221 | ifDies: SAME_DEATH, | |
| 222 | issues: [], | |
| 223 | }, | |
| 224 | { | |
| 225 | id: "release-tick", | |
| 226 | need: "release", | |
| 227 | what: "forge-release-tick, a host timer every 5 min", | |
| 228 | find: { kind: "host", unit: "forge-release-tick" }, | |
| 229 | machine: "pg-grand", | |
| 230 | ifDies: "Nothing is released; production keeps the release it has.", | |
| 231 | issues: [11686, 8942], | |
| 232 | breaks: 7, | |
| 233 | }, | |
| 234 | { | |
| 235 | id: "standby-reconcile", | |
| 236 | need: "release", | |
| 237 | what: "standby-reconcile, a host timer every 2 min that applies the forge's manifests from Forgejo; Flux's Kustomization for the forge is suspended", | |
| 238 | find: { kind: "host", unit: "standby-reconcile" }, | |
| 239 | machine: "pg-grand", | |
| 240 | ifDies: "A changed manifest is not applied; the running pod is untouched.", | |
| 241 | issues: [8948, 11686], | |
| 242 | breaks: 7, | |
| 243 | }, | |
| 244 | { | |
| 245 | id: "host-timers", | |
| 246 | need: "release", | |
| 247 | what: "Host timers forge-watchdog, forge-github-mirror and forge-prune-images", | |
| 248 | find: { kind: "host", unit: "forge-watchdog" }, | |
| 249 | machine: "pg-grand", | |
| 250 | ifDies: "The watchdog, the GitHub mirror and image pruning stop.", | |
| 251 | issues: [11686], | |
| 252 | breaks: 7, | |
| 253 | }, | |
| 254 | { | |
| 255 | id: "backup-lanes", | |
| 256 | need: "backup", | |
| 257 | what: "The backup namespace's CronJobs (FORGE_BACKUP_LANES)", | |
| 258 | find: { kind: "pods", namespace: "backup", workload: "*" }, | |
| 259 | machine: "pg-grand", | |
| 260 | ifDies: "No new backup is taken.", | |
| 261 | issues: [11686], | |
| 262 | breaks: 6, | |
| 263 | }, | |
| 264 | { | |
| 265 | id: "minio", | |
| 266 | need: "backup", | |
| 267 | what: "MinIO, where the backup lanes store the forge's backups", | |
| 268 | find: { kind: "pods", namespace: "minio", workload: "*" }, | |
| 269 | machine: "pg-grand", | |
| 270 | ifDies: "No new backup is stored, and those it holds go with the machine.", | |
| 271 | issues: [11686], | |
| 272 | breaks: 6, | |
| 273 | }, | |
| 274 | ]; | |
| 275 | ||
| 276 | /** On pg-grand, not in the forge's path: what dropping the machine takes besides. */ | |
| 277 | export const ALSO_ON_PG_GRAND = | |
| 278 | "Forgejo and its database, the platform's mcp/plat-auth and www, the CloudNativePG operator with the tenant apps' databases, their local-path volumes pinned to that node, and MinIO."; | |
| 279 | ||
| 280 | /** How each piece was placed: seen on nodes, or the declared machine kept and why. */ | |
| 281 | export type Seen = | |
| 282 | | { readonly by: "cluster"; readonly nodes: readonly string[] } | |
| 283 | | { readonly by: "declared"; readonly why: string }; | |
| 284 | ||
| 285 | export interface FootingRow extends Piece { | |
| 286 | readonly seen: Seen; | |
| 287 | /** True when the piece is not on the forge's own machine (or cannot be shown to be). */ | |
| 288 | readonly risk: boolean; | |
| 289 | } | |
| 290 | ||
| 291 | export interface Footing { | |
| 292 | /** The forge's own node; null outside a cluster or when its pod cannot be read. */ | |
| 293 | readonly forgeNode: string | null; | |
| 294 | /** Why the cluster was not read, when it was not. */ | |
| 295 | readonly unread: string | null; | |
| 296 | readonly rows: readonly FootingRow[]; | |
| 297 | readonly at: string; | |
| 298 | } | |
| 299 | ||
| 300 | /** A pod belongs to a workload: `<w>-<rs>-<id>` (Deployment), `<w>-<n>` (StatefulSet), `<w>-<id>` (DaemonSet). */ | |
| 301 | export function ownedBy(pod: string, workload: string): boolean { | |
| 302 | if (workload === "*") return true; | |
| 303 | if (!pod.startsWith(`${workload}-`)) return false; | |
| 304 | const rest = pod.slice(workload.length + 1); | |
| 305 | return /^[a-z0-9]{5,10}-[a-z0-9]{5}$/.test(rest) || /^\d+$/.test(rest) || /^[a-z0-9]{5}$/.test(rest); | |
| 306 | } | |
| 307 | ||
| 308 | /** Rows for the declared list alone: every piece placed on its declared machine. */ | |
| 309 | export function declaredFooting(why: string, at = new Date()): Footing { | |
| 310 | return { | |
| 311 | forgeNode: null, | |
| 312 | unread: why, | |
| 313 | at: at.toISOString(), | |
| 314 | rows: FOOTING.map((p) => ({ ...p, seen: { by: "declared", why }, risk: p.machine !== FORGE_MACHINE })), | |
| 315 | }; | |
| 316 | } | |
| 317 | ||
| 318 | type KubeFetch = (url: string, init: RequestInit & { tls?: { ca: string } }) => Promise<Response>; | |
| 319 | ||
| 320 | export interface ProbeOptions { | |
| 321 | fetch?: KubeFetch; | |
| 322 | /** The forge's service account, or how to read it (it throws outside a cluster). */ | |
| 323 | credentials?: Credentials | (() => Credentials); | |
| 324 | /** The forge's own pod name; the kubelet sets HOSTNAME to it. */ | |
| 325 | podName?: string; | |
| 326 | now?: () => Date; | |
| 327 | } | |
| 328 | ||
| 329 | type PodList = { items?: { metadata?: { name?: string }; spec?: { nodeName?: string }; status?: { phase?: string } }[] }; | |
| 330 | type NodeList = { items?: { metadata?: { name?: string; labels?: Record<string, string> } }[] }; | |
| 331 | ||
| 332 | const CONTROL_LABELS = ["node-role.kubernetes.io/control-plane", "node-role.kubernetes.io/master"]; | |
| 333 | ||
| 334 | /** Read the live cluster; never throws. Outside a cluster, the declared list with why. */ | |
| 335 | export async function probeFooting(opts: ProbeOptions = {}): Promise<Footing> { | |
| 336 | const now = opts.now ?? (() => new Date()); | |
| 337 | let cred: Credentials; | |
| 338 | try { | |
| 339 | const c = opts.credentials ?? serviceAccount; | |
| 340 | cred = typeof c === "function" ? c() : c; | |
| 341 | } catch (e) { | |
| 342 | return declaredFooting(`not read: ${e instanceof Error ? e.message : String(e)}`, now()); | |
| 343 | } | |
| 344 | const doFetch: KubeFetch = opts.fetch ?? ((u, i) => fetch(u, i as RequestInit)); | |
| 345 | const get = async <T>(path: string): Promise<{ ok: true; value: T } | { ok: false; status: number | string }> => { | |
| 346 | try { | |
| 347 | const res = await doFetch(`${cred.server}${path}`, { | |
| 348 | method: "GET", | |
| 349 | headers: { authorization: `Bearer ${cred.token}`, accept: "application/json" }, | |
| 350 | signal: AbortSignal.timeout(3000), | |
| 351 | ...(cred.ca ? { tls: { ca: cred.ca } } : {}), | |
| 352 | }); | |
| 353 | if (!res.ok) return { ok: false, status: res.status }; | |
| 354 | return { ok: true, value: (await res.json()) as T }; | |
| 355 | } catch (e) { | |
| 356 | return { ok: false, status: e instanceof Error ? e.message : "unreachable" }; | |
| 357 | } | |
| 358 | }; | |
| 359 | const refused = (s: number | string, what: string): string => | |
| 360 | s === 403 ? `the forge's service account may not read ${what}` : `${what} not read (${s})`; | |
| 361 | ||
| 362 | const podName = opts.podName ?? process.env["HOSTNAME"] ?? ""; | |
| 363 | const self = podName | |
| 364 | ? await get<{ spec?: { nodeName?: string } }>(`/api/v1/namespaces/${encodeURIComponent(cred.namespace)}/pods/${encodeURIComponent(podName)}`) | |
| 365 | : null; | |
| 366 | const forgeNode = self?.ok ? (self.value.spec?.nodeName ?? null) : null; | |
| 367 | ||
| 368 | const nodes = await get<NodeList>("/api/v1/nodes"); | |
| 369 | const nsOf = (ns: string): string => ns || cred.namespace; | |
| 370 | const namespaces = [...new Set(FOOTING.flatMap((p) => (p.find.kind === "pods" ? [nsOf(p.find.namespace)] : [])))]; | |
| 371 | const pods = new Map( | |
| 372 | await Promise.all(namespaces.map(async (ns) => [ns, await get<PodList>(`/api/v1/namespaces/${encodeURIComponent(ns)}/pods`)] as const)), | |
| 373 | ); | |
| 374 | const controlNodes = nodes.ok | |
| 375 | ? (nodes.value.items ?? []).filter((n) => CONTROL_LABELS.some((l) => n.metadata?.labels?.[l] !== undefined)).map((n) => n.metadata?.name ?? "").filter(Boolean) | |
| 376 | : null; | |
| 377 | ||
| 378 | const place = (p: Piece): Seen => { | |
| 379 | const f = p.find; | |
| 380 | if (f.kind === "self") { | |
| 381 | if (forgeNode !== null) return { by: "cluster", nodes: [forgeNode] }; | |
| 382 | return { by: "declared", why: self === null ? "the forge's pod name is unknown" : self.ok ? "its pod has no node yet" : refused(self.status, "its own pod") }; | |
| 383 | } | |
| 384 | if (f.kind === "control-plane") { | |
| 385 | if (!nodes.ok) return { by: "declared", why: refused(nodes.status, "nodes") }; | |
| 386 | return controlNodes!.length ? { by: "cluster", nodes: controlNodes! } : { by: "declared", why: "no node is labelled control-plane" }; | |
| 387 | } | |
| 388 | // Nothing in the cluster sees a systemd unit: the declared machine stands, said as such. | |
| 389 | if (f.kind === "host") return { by: "declared", why: `${f.unit} is a systemd unit on a host; the cluster cannot show it` }; | |
| 390 | if (f.kind === "unlocated") return { by: "declared", why: "its namespace is not recorded, and the forge does not list every pod in the cluster" }; | |
| 391 | const where = nsOf(f.namespace); | |
| 392 | const list = pods.get(where)!; | |
| 393 | if (!list.ok) return { by: "declared", why: refused(list.status, `pods in ${where}`) }; | |
| 394 | const on = (list.value.items ?? []) | |
| 395 | .filter((i) => ownedBy(i.metadata?.name ?? "", f.workload) && i.status?.phase !== "Succeeded" && i.status?.phase !== "Failed") | |
| 396 | .map((i) => i.spec?.nodeName ?? "") | |
| 397 | .filter(Boolean); | |
| 398 | return on.length ? { by: "cluster", nodes: [...new Set(on)].sort() } : { by: "declared", why: `no pod running in ${where} now` }; | |
| 399 | }; | |
| 400 | ||
| 401 | const rows = FOOTING.map((p): FootingRow => { | |
| 402 | const seen = place(p); | |
| 403 | const risk = | |
| 404 | seen.by === "cluster" && forgeNode !== null | |
| 405 | ? seen.nodes.some((n) => n !== forgeNode) | |
| 406 | : p.machine !== FORGE_MACHINE; | |
| 407 | return { ...p, seen, risk }; | |
| 408 | }); | |
| 409 | const unread = | |
| 410 | forgeNode !== null ? null | |
| 411 | : self === null ? "the forge's pod name is unknown (HOSTNAME unset)" | |
| 412 | : self.ok ? "the forge's own pod has no node yet" | |
| 413 | : refused(self.status, "its own pod"); | |
| 414 | return { forgeNode, unread, rows, at: now().toISOString() }; | |
| 415 | } | |
| 416 | ||
| 417 | /** Where a row was placed, in words. */ | |
| 418 | export function whereWords(r: FootingRow): string { | |
| 419 | return r.seen.by === "cluster" ? r.seen.nodes.join(", ") : `${r.machine} (declared: ${r.seen.why})`; | |
| 420 | } | |
| 421 | ||
| 422 | /** The rows off the forge's machine, in the order pg-grand switched off would break the forge; ties keep the list's order. */ | |
| 423 | export function offMachine(f: Footing): FootingRow[] { | |
| 424 | return f.rows | |
| 425 | .filter((r) => r.risk) | |
| 426 | .map((r, i) => ({ r, i })) | |
| 427 | .sort((a, b) => (a.r.breaks ?? 99) - (b.r.breaks ?? 99) || a.i - b.i) | |
| 428 | .map(({ r }) => r); | |
| 429 | } | |
| 430 | ||
| 431 | /** The risk lines: one per required piece off the forge's machine, in the order they would break it. */ | |
| 432 | export function riskLines(f: Footing): string[] { | |
| 433 | return offMachine(f).map((r) => `${r.need}: ${r.what} on ${whereWords(r)}. ${r.ifDies}`); | |
| 434 | } | |
| 435 | ||
| 436 | const FRESH_MS = 60_000; | |
| 437 | /** After a probe that throws, the next is not started for this long, so a broken cluster costs one try a minute. */ | |
| 438 | const BACKOFF_MS = 60_000; | |
| 439 | const WAIT_MS = 800; | |
| 440 | ||
| 441 | /** The last reading shared by /system, its terminal screen and the MCP answer. */ | |
| 442 | export function footingCache(probe: () => Promise<Footing> = () => probeFooting(), clock: () => number = Date.now) { | |
| 443 | let last: { at: number; value: Footing } | null = null; | |
| 444 | let pending: Promise<Footing> | null = null; | |
| 445 | let failedAt: number | null = null; | |
| 446 | const stale = (): boolean => !last || clock() - last.at > FRESH_MS; | |
| 447 | const backingOff = (): boolean => failedAt !== null && clock() - failedAt < BACKOFF_MS; | |
| 448 | const refresh = (): Promise<Footing> => { | |
| 449 | pending ??= probe() | |
| 450 | .then((value) => { | |
| 451 | last = { at: clock(), value }; | |
| 452 | failedAt = null; | |
| 453 | return value; | |
| 454 | }) | |
| 455 | .catch((e: unknown) => { | |
| 456 | failedAt = clock(); | |
| 457 | throw e; | |
| 458 | }) | |
| 459 | .finally(() => { | |
| 460 | pending = null; | |
| 461 | }); | |
| 462 | return pending; | |
| 463 | }; | |
| 464 | return { | |
| 465 | /** The last reading, refreshed in the background when older than 60 s; null before the first. */ | |
| 466 | peek(): Footing | null { | |
| 467 | if (stale() && !backingOff()) void refresh().catch(() => {}); | |
| 468 | return last?.value ?? null; | |
| 469 | }, | |
| 470 | /** A fresh-enough reading, waiting at most `waitMs` for the cluster; the declared list when it is slower. */ | |
| 471 | async read(waitMs = WAIT_MS): Promise<Footing> { | |
| 472 | if (!stale()) return last!.value; | |
| 473 | if (backingOff()) return last?.value ?? declaredFooting("the cluster did not answer; it is asked again within a minute"); | |
| 474 | let deadline: ReturnType<typeof setTimeout> | undefined; | |
| 475 | const timer = new Promise<null>((r) => { | |
| 476 | deadline = setTimeout(() => r(null), waitMs); | |
| 477 | }); | |
| 478 | try { | |
| 479 | const got = await Promise.race([refresh().catch(() => null), timer]); | |
| 480 | return got ?? last?.value ?? declaredFooting("the cluster did not answer in time; reload in a moment"); | |
| 481 | } finally { | |
| 482 | clearTimeout(deadline); | |
| 483 | } | |
| 484 | }, | |
| 485 | }; | |
| 486 | } | |
| 487 | ||
| 488 | export const footing = footingCache(); | |
| 489 | ||
| 490 | const NEED_WORDS: Record<Need, string> = { | |
| 491 | answer: "(a) answer a request", | |
| 492 | "sign-in": "(b) sign a person in", | |
| 493 | build: "(c) build", | |
| 494 | release: "(d) release", | |
| 495 | backup: "(e) back up", | |
| 496 | }; | |
| 497 | ||
| 498 | /** Markdown text: a pipe would end a cell, and `<name>` would read as a tag. */ | |
| 499 | const cell = (s: string): string => s.replaceAll("|", "\\|").replaceAll("<", "<").replaceAll(">", ">"); | |
| 500 | ||
| 501 | /** What `docs/OPERATING.md` carries between its markers, written from `MACHINES`, `FOOTING` and `BREAK_STEPS`. */ | |
| 502 | export function footingTable(): string { | |
| 503 | const machines = (Object.keys(MACHINES) as Machine[]).map((m) => `- **${m}**: ${cell(MACHINES[m])}`); | |
| 504 | const head = "| Needed to | Piece | Runs on | If that machine dies | Breaks | Removed by |\n|---|---|---|---|---|---|"; | |
| 505 | const lines = NEEDS.flatMap((need) => | |
| 506 | FOOTING.filter((p) => p.need === need).map( | |
| 507 | (p) => | |
| 508 | `| ${NEED_WORDS[need]} | ${cell(p.what)} | ${p.machine} | ${cell(p.ifDies)} | ${p.breaks ?? "—"} | ${p.issues.map((n) => `#${n}`).join(", ") || "—"} |`, | |
| 509 | ), | |
| 510 | ); | |
| 511 | const steps = BREAK_STEPS.map((s, i) => `${i + 1}. ${cell(s)}`); | |
| 512 | return [ | |
| 513 | ...machines, | |
| 514 | "", | |
| 515 | head, | |
| 516 | ...lines, | |
| 517 | "", | |
| 518 | "If pg-grand were switched off now, the forge breaks in this order (the Breaks column):", | |
| 519 | "", | |
| 520 | ...steps, | |
| 521 | "", | |
| 522 | `Also on pg-grand, not in the forge's path: ${cell(ALSO_ON_PG_GRAND)}`, | |
| 523 | ].join("\n"); | |
| 524 | } |
src/tui/screens/entries.ts +2
| @@ -57,6 +57,7 @@ import { entry as plans } from "./plans.ts"; | ||
| 57 | 57 | import { entry as economics } from "./economics.ts"; |
| 58 | 58 | import { entry as money } from "./money.ts"; |
| 59 | 59 | import { entry as loopStalls } from "./loopstalls.ts"; |
| 60 | import { entry as footing } from "./footing.ts"; | |
| 60 | 61 | import { entry as waiting } from "./waiting.ts"; |
| 61 | 62 | import { entry as slowPagesEntry } from "./slowpages.ts"; |
| 62 | 63 | import { entry as maintenance } from "./maintenance.ts"; |
| @@ -122,6 +123,7 @@ export const ENTRIES: readonly PaletteEntry[] = [ | ||
| 122 | 123 | homePins, |
| 123 | 124 | system, |
| 124 | 125 | loopStalls, |
| 126 | footing, | |
| 125 | 127 | waiting, |
| 126 | 128 | slowPagesEntry, |
| 127 | 129 | telemetry, |
src/tui/screens/footing.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * What the forge stands on, in the terminal (#11685): the rows /system shows | |
| 3 | * under the same heading. First where the forge runs (or why that was not | |
| 4 | * read), then each piece on another machine, in the order switching pg-grand | |
| 5 | * off would break the forge: its step, what it is needed for, the piece, the | |
| 6 | * machine it was found on, and what happens if that machine dies. Last, the | |
| 7 | * pieces on the forge's own machine, by name. The last reading | |
| 8 | * (`footing.peek`), so the screen never waits on the cluster; the declared | |
| 9 | * list while the first read runs. The gate is /system's: a forge admin who is | |
| 10 | * a person. Read-only. | |
| 11 | */ | |
| 12 | ||
| 13 | import { BREAK_STEPS, declaredFooting, footing, offMachine, whereWords, type Footing } from "../../system/footing.ts"; | |
| 14 | import type { Ctx, Screen } from "../app/app.ts"; | |
| 15 | import { binding } from "../app/keys.ts"; | |
| 16 | import type { Session } from "../session.ts"; | |
| 17 | import { style } from "../term/style.ts"; | |
| 18 | import { pad } from "../term/width.ts"; | |
| 19 | import { Scroll, drawLines, hitScroll } from "./common.ts"; | |
| 20 | import type { PaletteEntry } from "./entries.ts"; | |
| 21 | ||
| 22 | const mayRead = (session: Session): boolean => session.user?.isAdmin === true && session.user.kind !== "agent"; | |
| 23 | ||
| 24 | const current = (): Footing => footing.peek() ?? declaredFooting("the cluster is being read; reopen in a moment"); | |
| 25 | ||
| 26 | /** The screen's lines; empty for anyone the gate refuses. */ | |
| 27 | export function footingLines(session: Session, f: Footing = current()): string[] { | |
| 28 | if (!mayRead(session)) return []; | |
| 29 | const off = offMachine(f); | |
| 30 | const home = f.rows.filter((r) => !r.risk); | |
| 31 | const where = | |
| 32 | f.forgeNode === null ? `The forge's node was not read (${f.unread ?? "unknown"}); machines are as declared.` : `The forge runs on ${f.forgeNode}.`; | |
| 33 | const lines = [where, ""]; | |
| 34 | if (off.length === 0) lines.push("Every piece runs on the forge's own machine."); | |
| 35 | else { | |
| 36 | lines.push(`${String(off.length)} of ${String(f.rows.length)} pieces run on another machine. With pg-grand off, they fail in this order:`); | |
| 37 | for (const r of off) { | |
| 38 | const step = r.breaks === undefined ? "-" : String(r.breaks); | |
| 39 | lines.push("", `${pad(step, 3, "right")} ${pad(r.need, 8)} ${r.what}`); | |
| 40 | lines.push(`${pad("", 15)}on ${whereWords(r)}`, `${pad("", 15)}if it dies: ${r.ifDies}`); | |
| 41 | } | |
| 42 | lines.push("", "The steps:", ...BREAK_STEPS.map((s, i) => `${pad(String(i + 1), 3, "right")} ${s}`)); | |
| 43 | } | |
| 44 | if (home.length > 0) lines.push("", `On the forge's own machine: ${home.map((r) => r.id).join(", ")}`); | |
| 45 | return lines; | |
| 46 | } | |
| 47 | ||
| 48 | export function footingScreen(session: Session, ctx: Ctx): Screen { | |
| 49 | const scroll = new Scroll(); | |
| 50 | return { | |
| 51 | title: "What the forge stands on", | |
| 52 | keys: [binding(["j", "k"], "scroll"), binding(["esc", "q"], "back")], | |
| 53 | render(area, s) { | |
| 54 | const c = ctx.theme.c; | |
| 55 | const lines = footingLines(session); | |
| 56 | drawLines(s, area, c, scroll, lines.map((text) => [{ text, style: style(/^\s*\d+ {2}/.test(text) || text === "" ? c.fg : c.mutedFg, c.bg) }])); | |
| 57 | hitScroll(ctx, area, scroll, lines.length); | |
| 58 | }, | |
| 59 | key(e) { | |
| 60 | if (scroll.key(e, footingLines(session).length)) return true; | |
| 61 | if (e.key === "esc" || e.key === "q") { | |
| 62 | ctx.pop(); | |
| 63 | return true; | |
| 64 | } | |
| 65 | return false; | |
| 66 | }, | |
| 67 | }; | |
| 68 | } | |
| 69 | ||
| 70 | export const entry: PaletteEntry = { | |
| 71 | id: "footing", | |
| 72 | title: "What the forge stands on", | |
| 73 | hint: "each piece on another machine, in the order it would break the forge", | |
| 74 | after: ["stalls", "system"], | |
| 75 | offered: mayRead, | |
| 76 | run: (session, ctx) => ctx.push(footingScreen(session, ctx)), | |
| 77 | }; |
src/web/pages/system.ts +36
| @@ -47,6 +47,7 @@ import { KEPT_PAGE_BYTES, keptPageStats } from "./kept_page.ts"; | ||
| 47 | 47 | import { COLUMN_BYTES, PARTS_BYTES, keptPartsStats, treeCacheStats } from "./tree_cache.ts"; |
| 48 | 48 | import { featureHealth, type FeatureHealth } from "../../system/feature_health.ts"; |
| 49 | 49 | import { egressReportedLine } from "../../system/egress_line.ts"; |
| 50 | import { BREAK_STEPS, MACHINES, declaredFooting, footing, offMachine, whereWords, type Footing, type Machine } from "../../system/footing.ts"; | |
| 50 | 51 | import { badge, chartBox, disclosure, meter, pill, row, time } from "../ui.ts"; |
| 51 | 52 | import { childDirOf, listChildren } from "../../actions/spawn.ts"; |
| 52 | 53 | import { childrenUse, realSelf, selfUse, shareOf, sizeWords, useWords, type ChildUseRow, type SelfUse } from "../../spawn/usage.ts"; |
| @@ -204,6 +205,38 @@ export function featuresSection(rows: readonly FeatureHealth[]): Html { | ||
| 204 | 205 | <p class="small"><a href="/settings/features">Settings › Features</a></p></section>`; |
| 205 | 206 | } |
| 206 | 207 | |
| 208 | /** | |
| 209 | * What the forge stands on (#11685): each piece it needs to answer, sign in, | |
| 210 | * build, release and back up, and where it was found. The summary gives the | |
| 211 | * one count; inside, each piece on another machine, in the order switching | |
| 212 | * pg-grand off would break the forge, one fact a line; then the pieces on the | |
| 213 | * forge's own machine, by name. `system/footing.ts`'s rows. | |
| 214 | */ | |
| 215 | export function footingSection(f: Footing): Html { | |
| 216 | const off = offMachine(f); | |
| 217 | const home = f.rows.filter((r) => !r.risk); | |
| 218 | // First sight (#11111): the heading and one count stay; the rows open behind one disclosure. | |
| 219 | const count = off.length === 0 ? "Every piece runs on the forge's own machine" : `${off.length} of ${f.rows.length} pieces run on another machine`; | |
| 220 | return html`<section class="settings-block" id="sys-footing" data-off="${String(off.length)}" data-forge-node="${f.forgeNode ?? ""}"><h2>What the forge stands on</h2> | |
| 221 | ${disclosure(count, html`<p class="small">${f.forgeNode === null ? html`The forge's node was not read (${f.unread ?? "unknown"}), so each piece shows the machine written in <code>docs/OPERATING.md</code>.` : html`The forge runs on <span class="mono">${f.forgeNode}</span>.`} | |
| 222 | ${off.length === 0 ? null : "If that machine stops, each piece below stops with it; they are listed in the order the forge would lose them."}</p> | |
| 223 | ${off.length === 0 ? null : html`<ul class="list" aria-label="Pieces on another machine">${off.map((r) => | |
| 224 | row({ | |
| 225 | title: r.what, | |
| 226 | facts: [ | |
| 227 | { label: "Needed to", value: r.need }, | |
| 228 | { label: "Runs on", value: html`<span class="mono">${whereWords(r)}</span>` }, | |
| 229 | { label: "If it stops", value: r.ifDies }, | |
| 230 | ...(r.breaks ? [{ label: "Order", value: `${r.breaks} of ${BREAK_STEPS.length}: ${BREAK_STEPS[r.breaks - 1]}` }] : []), | |
| 231 | ...(r.issues.length ? [{ label: "Fixed by", value: r.issues.map((n) => `#${n}`).join(", ") }] : []), | |
| 232 | ], | |
| 233 | data: { piece: r.id, off: "true", seen: r.seen.by, ...(r.breaks ? { breaks: String(r.breaks) } : {}) }, | |
| 234 | }), | |
| 235 | )}</ul>`} | |
| 236 | ${home.length === 0 ? null : html`<p class="small muted">On the forge's own machine: ${home.map((r, i) => html`${i ? ", " : ""}<span data-piece="${r.id}" data-off="false">${r.what}</span>`)}.</p>`} | |
| 237 | ${(Object.keys(MACHINES) as Machine[]).map((m) => html`<p class="small muted" data-machine="${m}"><span class="mono">${m}</span>: ${MACHINES[m]}</p>`)}`, { id: "sys-footing-more" })}</section>`; | |
| 238 | } | |
| 239 | ||
| 207 | 240 | /** The last hour's stalls by route, most milliseconds first; the rows are `stallHour`'s. */ |
| 208 | 241 | export function stallSection(rows: readonly RouteStall[], worst: Html = html``): Html { |
| 209 | 242 | return html`<section class="settings-block"><h2>Main-thread stalls, last hour</h2>${ |
| @@ -370,6 +403,8 @@ export async function systemPage(ctx: Ctx): Promise<Response> { | ||
| 370 | 403 | const stalls = timePhase(ctx.request, "stalls", () => stallHour(loopWatch.tally(performance.now()))); |
| 371 | 404 | const nowMono = performance.now(); |
| 372 | 405 | const worst = stallBlame.worst(nowMono, HOUR_MS, WORST_STALLS); |
| 406 | // Never waits on the cluster: the last reading, or the declared rows while the first read runs behind. | |
| 407 | const standsOn = footing.peek() ?? declaredFooting("the cluster is being read; reload in a moment"); | |
| 373 | 408 | const rendering = performance.now(); |
| 374 | 409 | const r = regions(v); |
| 375 | 410 | const at = (id: string): Html => raw(r[id]!); |
| @@ -403,6 +438,7 @@ export async function systemPage(ctx: Ctx): Promise<Response> { | ||
| 403 | 438 | <div class="js-only small muted">Sort by <button class="btn" data-size="sm" type="button" data-sort="cpu" aria-pressed="true"><kbd>c</kbd> CPU</button> |
| 404 | 439 | <button class="btn" data-size="sm" type="button" data-sort="mem" aria-pressed="false"><kbd>m</kbd> memory</button></div> |
| 405 | 440 | <div id="sys-procs" data-live-hold>${at("sys-procs")}</div></section> |
| 441 | ${footingSection(standsOn)} | |
| 406 | 442 | ${featuresSection(timePhase(ctx.request, "features", () => featureHealth(ctx.app, reader)))} |
| 407 | 443 | ${lagSection(lagMinutes.readings())} |
| 408 | 444 | <div id="sys-stalls">${stallSection(stalls, worstStallsTable(worst, nowMono, Date.now()))}</div> |
test/footing.test.ts added +404
| @@ -0,0 +1,404 @@ | ||
| 1 | /** | |
| 2 | * What the forge stands on (#11685): the table in docs/OPERATING.md is the | |
| 3 | * declared list exactly; the probe places each piece on the node the kube API | |
| 4 | * shows it on and marks every piece off the forge's node a risk; what the | |
| 5 | * cluster cannot show keeps its declared machine and says why; /system shows | |
| 6 | * it under one heading, and the MCP `system` tool answers it to an admin only. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, readFile, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { createToken } from "../src/core/tokens.ts"; | |
| 14 | import { createUser } from "../src/core/users.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | import { | |
| 17 | BREAK_STEPS, | |
| 18 | FOOTING, | |
| 19 | declaredFooting, | |
| 20 | footingCache, | |
| 21 | footingTable, | |
| 22 | ownedBy, | |
| 23 | probeFooting, | |
| 24 | riskLines, | |
| 25 | type Footing, | |
| 26 | } from "../src/system/footing.ts"; | |
| 27 | import { footingSection } from "../src/web/pages/system.ts"; | |
| 28 | import { getUserByName, type User } from "../src/core/users.ts"; | |
| 29 | import { createForgeApp, forgeCommands, Session } from "../src/tui/forge.ts"; | |
| 30 | import { footingLines, footingScreen } from "../src/tui/screens/footing.ts"; | |
| 31 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 32 | import { settleApp } from "./helpers/tui.ts"; | |
| 33 | import { quietLogs } from "./helpers/globals.ts"; | |
| 34 | ||
| 35 | quietLogs(); | |
| 36 | ||
| 37 | const cred = { server: "https://kube.test", token: "t", namespace: "forge" }; | |
| 38 | const pod = (name: string, node: string, phase = "Running") => ({ metadata: { name }, spec: { nodeName: node }, status: { phase } }); | |
| 39 | ||
| 40 | /** | |
| 41 | * A fake kube API as the steward found it on 2026-11-12: the forge and both tunnels on vxrail-1, | |
| 42 | * the rest on pg-grand, minio forbidden; Traefik moved to vxrail-1. | |
| 43 | */ | |
| 44 | function fakeKube(over: Record<string, unknown> = {}) { | |
| 45 | const answers: Record<string, unknown> = { | |
| 46 | "/api/v1/namespaces/forge/pods/forge-7d9f8c6b5-abcde": { spec: { nodeName: "vxrail-1" } }, | |
| 47 | "/api/v1/nodes": { | |
| 48 | items: [ | |
| 49 | { metadata: { name: "pg-grand", labels: { "node-role.kubernetes.io/control-plane": "true" } } }, | |
| 50 | { metadata: { name: "vxrail-1", labels: { "plat.sh/site": "vxrail" } } }, | |
| 51 | ], | |
| 52 | }, | |
| 53 | "/api/v1/namespaces/standby/pods": { | |
| 54 | items: [pod("cloudflared-6c7b9d8f4-x2x9z", "vxrail-1"), pod("cloudflared-forge-home-5f6d7c8b9-q1w2e", "vxrail-1"), pod("cloudflared-forge-home-5f6d7c8b9-r3t4y", "vxrail-1")], | |
| 55 | }, | |
| 56 | "/api/v1/namespaces/forge/pods": { items: [pod("forge-7d9f8c6b5-abcde", "vxrail-1"), pod("forge-signup-gate-6d5c4b3a2-sgate", "pg-grand")] }, | |
| 57 | "/api/v1/namespaces/kube-system/pods": { items: [pod("traefik-5b8d9c7f6-zzzzz", "vxrail-1"), pod("coredns-1234567890-aaaaa", "pg-grand")] }, | |
| 58 | "/api/v1/namespaces/mcp/pods": { items: [pod("plat-auth-7f8e9d6c5-abcde", "pg-grand")] }, | |
| 59 | "/api/v1/namespaces/forgejo/pods": { items: [pod("forgejo-0", "pg-grand"), pod("forgejo-db-1", "pg-grand")] }, | |
| 60 | "/api/v1/namespaces/flux-system/pods": { items: [pod("source-controller-6b5d4c3f2-qwert", "pg-grand")] }, | |
| 61 | "/api/v1/namespaces/backup/pods": { items: [pod("lane-forge-29000000-abcde", "pg-grand", "Succeeded")] }, | |
| 62 | "/api/v1/namespaces/minio/pods": 403, | |
| 63 | "/api/v1/namespaces/cnpg-system/pods": { items: [pod("cnpg-controller-manager-7d8f9b6c5-mnbvc", "pg-grand")] }, | |
| 64 | ...over, | |
| 65 | }; | |
| 66 | const seen: string[] = []; | |
| 67 | const fetch = async (url: string, init: RequestInit) => { | |
| 68 | const path = url.slice(cred.server.length); | |
| 69 | seen.push(path); | |
| 70 | expect((init.headers as Record<string, string>)["authorization"]).toBe("Bearer t"); | |
| 71 | expect(init.method).toBe("GET"); | |
| 72 | const a = answers[path]; | |
| 73 | if (a === undefined) return new Response("{}", { status: 404 }); | |
| 74 | if (typeof a === "number") return new Response("{}", { status: a }); | |
| 75 | return Response.json(a); | |
| 76 | }; | |
| 77 | return { fetch, seen }; | |
| 78 | } | |
| 79 | ||
| 80 | const byId = (f: Footing, id: string) => f.rows.find((r) => r.id === id)!; | |
| 81 | ||
| 82 | describe("the table in docs/OPERATING.md", () => { | |
| 83 | test("is footingTable() exactly, between the markers", async () => { | |
| 84 | const doc = await readFile(join(import.meta.dir, "..", "docs", "OPERATING.md"), "utf8"); | |
| 85 | const m = doc.match(/<!-- footing:start[^>]*-->\n([\s\S]*?)\n<!-- footing:end -->/); | |
| 86 | expect(m).not.toBeNull(); | |
| 87 | expect(m![1]).toBe(footingTable()); | |
| 88 | expect(doc).toContain("## What the forge stands on"); | |
| 89 | }); | |
| 90 | test("every need (a) to (e) has a piece; every pg-grand piece names an issue that removes it and the step it breaks at", () => { | |
| 91 | for (const need of ["answer", "sign-in", "build", "release", "backup"]) expect(FOOTING.some((p) => p.need === need)).toBe(true); | |
| 92 | for (const p of FOOTING.filter((p) => p.machine === "pg-grand")) { | |
| 93 | expect(p.issues.length).toBeGreaterThan(0); | |
| 94 | expect(p.breaks! >= 1 && p.breaks! <= BREAK_STEPS.length).toBe(true); | |
| 95 | } | |
| 96 | for (const p of FOOTING.filter((p) => p.machine === "vxrail")) expect(p.breaks).toBeUndefined(); | |
| 97 | // Every step of the break order has a piece behind it. | |
| 98 | for (let i = 1; i <= BREAK_STEPS.length; i++) expect(FOOTING.some((p) => p.breaks === i)).toBe(true); | |
| 99 | expect(new Set(FOOTING.map((p) => p.id)).size).toBe(FOOTING.length); | |
| 100 | }); | |
| 101 | test("the doc names both machines and escapes what markdown would read as a tag", async () => { | |
| 102 | const t = footingTable(); | |
| 103 | expect(t).toContain("- **pg-grand**: DigitalOcean."); | |
| 104 | expect(t).toContain("- **vxrail**: "); | |
| 105 | expect(t).toContain("forge:<version>"); | |
| 106 | expect(t).not.toContain("<version>"); | |
| 107 | expect(t).toContain("1. Traefik: nothing public reaches the forge"); | |
| 108 | // No figure that goes stale is written as if current: no disk fill, sizes or counts of the day. | |
| 109 | expect(t).not.toMatch(/% used|\b\d+(\.\d+)? (GB|TB)\b|\b\d+ (single-instance )?databases\b|\b\d+ (local-path )?volumes\b/); | |
| 110 | }); | |
| 111 | }); | |
| 112 | ||
| 113 | describe("ownedBy", () => { | |
| 114 | test("a Deployment's, StatefulSet's and DaemonSet's pods; not a longer workload's", () => { | |
| 115 | expect(ownedBy("cloudflared-6c7b9d8f4-x2x9z", "cloudflared")).toBe(true); | |
| 116 | expect(ownedBy("forgejo-0", "forgejo")).toBe(true); | |
| 117 | expect(ownedBy("traefik-abcde", "traefik")).toBe(true); | |
| 118 | expect(ownedBy("cloudflared-forge-home-5f6d7c8b9-q1w2e", "cloudflared")).toBe(false); | |
| 119 | expect(ownedBy("plat-auth-x", "plat-auth")).toBe(false); | |
| 120 | expect(ownedBy("anything", "*")).toBe(true); | |
| 121 | }); | |
| 122 | }); | |
| 123 | ||
| 124 | describe("probeFooting", () => { | |
| 125 | test("places each piece on the node it runs on; off the forge's node is a risk", async () => { | |
| 126 | const kube = fakeKube(); | |
| 127 | const f = await probeFooting({ fetch: kube.fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde", now: () => new Date("2026-11-12T12:00:00Z") }); | |
| 128 | expect(f.forgeNode).toBe("vxrail-1"); | |
| 129 | expect(f.unread).toBeNull(); | |
| 130 | expect(f.at).toBe("2026-11-12T12:00:00.000Z"); | |
| 131 | // The forge's own pod, and the tunnel moved to the vxrail by hand: on the forge's node. | |
| 132 | expect(byId(f, "forge")).toMatchObject({ seen: { by: "cluster", nodes: ["vxrail-1"] }, risk: false }); | |
| 133 | expect(byId(f, "tunnel")).toMatchObject({ seen: { by: "cluster", nodes: ["vxrail-1"] }, risk: false }); | |
| 134 | expect(byId(f, "tunnel-home")).toMatchObject({ seen: { by: "cluster", nodes: ["vxrail-1"] }, risk: false }); | |
| 135 | // The signup gate is looked for in the forge's own namespace (an Ingress routes only within its own). | |
| 136 | expect(byId(f, "signup-gate")).toMatchObject({ seen: { by: "cluster", nodes: ["pg-grand"] }, risk: true }); | |
| 137 | // A pod whose namespace is not recorded is not looked for: no list of every pod in the cluster. | |
| 138 | expect(byId(f, "runner")).toMatchObject({ seen: { by: "declared", why: "its namespace is not recorded, and the forge does not list every pod in the cluster" }, risk: false }); | |
| 139 | expect(byId(f, "registry-node")).toMatchObject({ seen: { by: "declared" }, risk: false }); | |
| 140 | expect(kube.seen).not.toContain("/api/v1/pods"); | |
| 141 | expect(kube.seen.every((p) => p === "/api/v1/nodes" || p.startsWith("/api/v1/namespaces/"))).toBe(true); | |
| 142 | // Traefik moved to the forge's node: no longer a risk, whatever the declared list said. | |
| 143 | expect(byId(f, "traefik")).toMatchObject({ seen: { by: "cluster", nodes: ["vxrail-1"] }, risk: false }); | |
| 144 | expect(byId(f, "hosts").risk).toBe(false); | |
| 145 | expect(byId(f, "control-plane")).toMatchObject({ seen: { by: "cluster", nodes: ["pg-grand"] }, risk: true }); | |
| 146 | // Nothing in the cluster sees a systemd unit: declared, said why, judged by the declared machine. | |
| 147 | expect(byId(f, "release-tick")).toMatchObject({ seen: { by: "declared", why: "forge-release-tick is a systemd unit on a host; the cluster cannot show it" }, risk: true }); | |
| 148 | expect(byId(f, "standby-reconcile")).toMatchObject({ seen: { by: "declared" }, risk: true }); | |
| 149 | for (const id of ["forge-ssh", "release-tick", "standby-reconcile", "host-timers"]) expect(byId(f, id).seen.by).toBe("declared"); | |
| 150 | expect(byId(f, "plat-auth").risk).toBe(true); | |
| 151 | // Forbidden: the declared machine is kept and the reason named. | |
| 152 | expect(byId(f, "minio").seen).toEqual({ by: "declared", why: "the forge's service account may not read pods in minio" }); | |
| 153 | expect(byId(f, "minio").risk).toBe(true); | |
| 154 | // A CronJob between runs: only a finished pod, so declared. | |
| 155 | expect(byId(f, "backup-lanes").seen).toEqual({ by: "declared", why: "no pod running in backup now" }); | |
| 156 | // Each namespace is listed once although two pieces live in forgejo. | |
| 157 | expect(kube.seen.filter((p) => p === "/api/v1/namespaces/forgejo/pods").length).toBe(1); | |
| 158 | const off = ["forge-ssh", "control-plane", "plat-auth", "signup-gate", "forgejo", "cnpg", "registry", "release-tick", "standby-reconcile", "host-timers", "backup-lanes", "minio"]; | |
| 159 | expect(f.rows.filter((r) => r.risk).map((r) => r.id).sort()).toEqual([...off].sort()); | |
| 160 | // In the order pg-grand switched off would break the forge: sign-in first, host timers last. | |
| 161 | const lines = riskLines(f); | |
| 162 | expect(lines.length).toBe(off.length); | |
| 163 | expect(lines[0]).toStartWith("sign-in: Sign-in edge"); | |
| 164 | expect(lines[0]).toContain(" on pg-grand. Every request through the edge answers 500, /healthz too."); | |
| 165 | expect(lines[1]).toStartWith("sign-in: forge-signup-gate"); | |
| 166 | expect(lines.at(-1)).toStartWith("release: Host timers forge-watchdog"); | |
| 167 | }); | |
| 168 | ||
| 169 | test("with every piece on the forge's node, there is no risk line", async () => { | |
| 170 | const everyone = (names: string[]) => ({ items: names.map((n) => pod(n, "pg-grand")) }); | |
| 171 | const kube = fakeKube({ | |
| 172 | "/api/v1/namespaces/forge/pods/forge-7d9f8c6b5-abcde": { spec: { nodeName: "pg-grand" } }, | |
| 173 | "/api/v1/namespaces/standby/pods": everyone(["cloudflared-6c7b9d8f4-x2x9z", "cloudflared-forge-home-5f6d7c8b9-q1w2e"]), | |
| 174 | "/api/v1/namespaces/forge/pods": everyone(["forge-signup-gate-6d5c4b3a2-sgate"]), | |
| 175 | "/api/v1/namespaces/minio/pods": everyone(["minio-0"]), | |
| 176 | "/api/v1/namespaces/backup/pods": everyone(["lane-forge-29000000-abcde"]), | |
| 177 | "/api/v1/namespaces/kube-system/pods": everyone(["traefik-5b8d9c7f6-zzzzz"]), | |
| 178 | }); | |
| 179 | // Every piece the cluster can show is on the forge's node; the host units are declared on pg-grand, so they remain. | |
| 180 | const f = await probeFooting({ fetch: kube.fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde" }); | |
| 181 | expect(f.rows.filter((r) => r.risk).map((r) => r.id).sort()).toEqual(["forge-ssh", "host-timers", "release-tick", "standby-reconcile"]); | |
| 182 | expect(f.rows.filter((r) => r.risk).every((r) => r.seen.by === "declared")).toBe(true); | |
| 183 | }); | |
| 184 | ||
| 185 | test("the forge's own namespace refused: the signup gate keeps its declared machine and says why", async () => { | |
| 186 | const kube = fakeKube({ "/api/v1/namespaces/forge/pods": 403 }); | |
| 187 | const f = await probeFooting({ fetch: kube.fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde" }); | |
| 188 | expect(byId(f, "signup-gate")).toMatchObject({ seen: { by: "declared", why: "the forge's service account may not read pods in forge" }, risk: true }); | |
| 189 | }); | |
| 190 | ||
| 191 | test("outside a cluster: the declared list, said so, every pg-grand row a risk", async () => { | |
| 192 | let fetched = 0; | |
| 193 | const f = await probeFooting({ | |
| 194 | podName: "x", | |
| 195 | credentials: () => { | |
| 196 | throw new Error("not running in a cluster"); | |
| 197 | }, | |
| 198 | fetch: async () => { | |
| 199 | fetched++; | |
| 200 | return Response.json({}); | |
| 201 | }, | |
| 202 | }); | |
| 203 | expect(fetched).toBe(0); | |
| 204 | expect(f.forgeNode).toBeNull(); | |
| 205 | expect(f.unread).toBe("not read: not running in a cluster"); | |
| 206 | expect(f.rows.every((r) => r.seen.by === "declared" && r.risk === (r.machine !== "vxrail"))).toBe(true); | |
| 207 | }); | |
| 208 | ||
| 209 | test("the forge's own pod unreadable: its node is unknown and the declared machines decide risk", async () => { | |
| 210 | const kube = fakeKube({ "/api/v1/namespaces/forge/pods/forge-7d9f8c6b5-abcde": 403 }); | |
| 211 | const f = await probeFooting({ fetch: kube.fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde" }); | |
| 212 | expect(f.forgeNode).toBeNull(); | |
| 213 | expect(f.unread).toBe("the forge's service account may not read its own pod"); | |
| 214 | expect(byId(f, "traefik").risk).toBe(true); | |
| 215 | expect(byId(f, "forge")).toMatchObject({ seen: { by: "declared", why: "the forge's service account may not read its own pod" }, risk: false }); | |
| 216 | }); | |
| 217 | }); | |
| 218 | ||
| 219 | describe("footingCache", () => { | |
| 220 | test("a slow cluster gives the declared list at once, and the reading once it lands", async () => { | |
| 221 | let release!: (f: Footing) => void; | |
| 222 | const live = declaredFooting("live", new Date(0)); | |
| 223 | let calls = 0; | |
| 224 | const cache = footingCache(() => { | |
| 225 | calls++; | |
| 226 | return new Promise<Footing>((r) => (release = r)); | |
| 227 | }); | |
| 228 | expect(cache.peek()).toBeNull(); | |
| 229 | const first = await cache.read(5); | |
| 230 | expect(first.unread).toBe("the cluster did not answer in time; reload in a moment"); | |
| 231 | expect(calls).toBe(1); // peek's refresh is the one read waited on | |
| 232 | release(live); | |
| 233 | await Bun.sleep(0); | |
| 234 | expect(cache.peek()).toBe(live); | |
| 235 | expect(await cache.read(5)).toBe(live); | |
| 236 | expect(calls).toBe(1); | |
| 237 | }); | |
| 238 | ||
| 239 | test("a probe that throws is not retried for 60 s, by peek or by read", async () => { | |
| 240 | let now = 1_000_000; | |
| 241 | let calls = 0; | |
| 242 | const cache = footingCache(async () => { | |
| 243 | calls++; | |
| 244 | throw new Error("kube down"); | |
| 245 | }, () => now); | |
| 246 | expect(cache.peek()).toBeNull(); | |
| 247 | await Bun.sleep(0); | |
| 248 | expect(calls).toBe(1); | |
| 249 | now += 30_000; | |
| 250 | expect(cache.peek()).toBeNull(); | |
| 251 | expect((await cache.read(5)).unread).toBe("the cluster did not answer; it is asked again within a minute"); | |
| 252 | await Bun.sleep(0); | |
| 253 | expect(calls).toBe(1); | |
| 254 | now += 30_001; | |
| 255 | cache.peek(); | |
| 256 | await Bun.sleep(0); | |
| 257 | expect(calls).toBe(2); | |
| 258 | }); | |
| 259 | ||
| 260 | test("a fast read leaves no deadline timer behind", async () => { | |
| 261 | const live = declaredFooting("live", new Date(0)); | |
| 262 | const cleared: unknown[] = []; | |
| 263 | const real = globalThis.clearTimeout; | |
| 264 | globalThis.clearTimeout = ((t: Parameters<typeof clearTimeout>[0]) => { | |
| 265 | cleared.push(t); | |
| 266 | real(t); | |
| 267 | }) as typeof clearTimeout; | |
| 268 | try { | |
| 269 | expect(await footingCache(async () => live).read(60_000)).toBe(live); | |
| 270 | } finally { | |
| 271 | globalThis.clearTimeout = real; | |
| 272 | } | |
| 273 | expect(cleared.length).toBe(1); | |
| 274 | expect(cleared[0]).toBeDefined(); | |
| 275 | }); | |
| 276 | }); | |
| 277 | ||
| 278 | describe("where it shows", () => { | |
| 279 | let dir = ""; | |
| 280 | let app: App; | |
| 281 | let servers: Servers; | |
| 282 | const tok: Record<string, string> = {}; | |
| 283 | beforeAll(async () => { | |
| 284 | dir = await mkdtemp(join(tmpdir(), "forge-footing-")); | |
| 285 | app = createApp({ dataDir: dir, port: 0 }); | |
| 286 | servers = await serve(app); | |
| 287 | for (const [username, first] of [["alice", true], ["bob", false]] as const) { | |
| 288 | const r = await createUser(app.db, { origin: "seed", trusted: true, username, email: `${username}@x.test`, password: "a-good-passphrase", ...(first ? { adminIfFirst: true } : {}) }); | |
| 289 | if (!r.ok) throw new Error(r.error); | |
| 290 | tok[username] = createToken(app.db, r.value.id, "t", "admin").token; | |
| 291 | } | |
| 292 | }, 30_000); | |
| 293 | afterAll(async () => { | |
| 294 | servers.stop(); | |
| 295 | app.close(); | |
| 296 | await rm(dir, { recursive: true, force: true }); | |
| 297 | }); | |
| 298 | ||
| 299 | test("the section has one heading, says the count once, and a row of facts per piece off the forge's machine, escaped", async () => { | |
| 300 | const kube = fakeKube(); | |
| 301 | const f = await probeFooting({ fetch: kube.fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde" }); | |
| 302 | const out = String(footingSection(f)); | |
| 303 | const off = f.rows.filter((r) => r.risk).length; | |
| 304 | expect(out.match(/<h2>What the forge stands on<\/h2>/g)?.length).toBe(1); | |
| 305 | expect(out).toContain(`data-off="${off}"`); | |
| 306 | expect(out.match(new RegExp(`${off} of ${f.rows.length}`, "g"))?.length).toBe(1); | |
| 307 | expect(out).toContain('data-piece="traefik" data-off="false"'); | |
| 308 | expect(out).toMatch(/data-piece="plat-auth" data-off="true"/); | |
| 309 | expect(out).toContain('data-breaks="2"'); | |
| 310 | // Off-machine rows come in break order, each fact its own dt/dd. | |
| 311 | const order = [...out.matchAll(/data-piece="([a-z-]+)" data-off="true"/g)].map((m) => m[1]); | |
| 312 | expect(order.slice(0, 2)).toEqual(["plat-auth", "signup-gate"]); | |
| 313 | expect(out).toContain("<dt>If it stops</dt>"); | |
| 314 | // Plain words, one colour: no badge, and neither "risk" nor "footing" on the page. | |
| 315 | expect(out).not.toContain("badge"); | |
| 316 | expect(out.replace(/<[^>]*>/g, "")).not.toMatch(/\brisk|footing/i); | |
| 317 | expect(out).toContain('data-machine="pg-grand"'); | |
| 318 | expect(out).toContain("forge:<version>"); | |
| 319 | expect(out).toContain("The forge runs on <span class=\"mono\">vxrail-1</span>."); | |
| 320 | const hostile = { ...f, forgeNode: "<img src=x>" }; | |
| 321 | expect(String(footingSection(hostile))).not.toContain("<img src=x>"); | |
| 322 | }); | |
| 323 | ||
| 324 | test("the owner's /system shows it", async () => { | |
| 325 | const res = await fetch(`${servers.url}/system`, { headers: { authorization: `Bearer ${tok["alice"]}` } }); | |
| 326 | expect(res.status).toBe(200); | |
| 327 | const body = await res.text(); | |
| 328 | expect(body).toContain("<h2>What the forge stands on</h2>"); | |
| 329 | expect(body).toContain('id="sys-footing"'); | |
| 330 | }); | |
| 331 | ||
| 332 | test("/system keeps the rows behind one closed disclosure and never waits on the cluster", async () => { | |
| 333 | const t0 = performance.now(); | |
| 334 | const res = await fetch(`${servers.url}/system`, { headers: { authorization: `Bearer ${tok["alice"]}` } }); | |
| 335 | const body = await res.text(); | |
| 336 | expect(performance.now() - t0).toBeLessThan(800); | |
| 337 | const section = body.slice(body.indexOf('id="sys-footing"'), body.indexOf("</section>", body.indexOf('id="sys-footing"'))); | |
| 338 | expect(section).toMatch(/<details id="sys-footing-more" class="disclosure">/); | |
| 339 | expect(section).not.toMatch(/<details[^>]*\bopen\b/); | |
| 340 | expect(section.slice(section.indexOf("<details")).match(/data-piece=/g)?.length).toBe(FOOTING.length); | |
| 341 | }); | |
| 342 | ||
| 343 | const identity = (user?: User) => ({ sshUsername: user?.username ?? "git", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user }); | |
| 344 | ||
| 345 | test("the terminal screen gives an admin the rows off the forge's machine in break order, with node and if-dies", async () => { | |
| 346 | const f = await probeFooting({ fetch: fakeKube().fetch, credentials: cred, podName: "forge-7d9f8c6b5-abcde" }); | |
| 347 | const lines = footingLines(new Session(app, identity(getUserByName(app.db, "alice")!)), f); | |
| 348 | expect(lines[0]).toBe("The forge runs on vxrail-1."); | |
| 349 | const off = f.rows.filter((r) => r.risk); | |
| 350 | expect(lines).toContain(`${off.length} of ${f.rows.length} pieces run on another machine. With pg-grand off, they fail in this order:`); | |
| 351 | const steps = lines.filter((l) => /^ {0,2}\d+ {2}(answer|sign-in|build|release|backup)\b/.test(l)); | |
| 352 | expect(steps.length).toBe(off.length); | |
| 353 | expect(steps[0]).toMatch(/^ {2}2 {2}sign-in\s+Sign-in edge/); | |
| 354 | expect(steps.map((l) => Number(l.trim().split(/\s+/)[0]))).toEqual([...steps.map((l) => Number(l.trim().split(/\s+/)[0]))].sort((a, b) => a - b)); | |
| 355 | expect(lines).toContain(`${" ".repeat(15)}if it dies: Every request through the edge answers 500, /healthz too.`); | |
| 356 | expect(lines).toContain(`${" ".repeat(15)}on pg-grand (declared: forge-release-tick is a systemd unit on a host; the cluster cannot show it)`); | |
| 357 | expect(lines.at(-1)).toStartWith("On the forge's own machine: forge, tunnel, tunnel-home, traefik, hosts"); | |
| 358 | }); | |
| 359 | ||
| 360 | test("a guest, a signed-in non-admin and nobody get no lines and no door", () => { | |
| 361 | const f = declaredFooting("x"); | |
| 362 | const doors = (user?: User): string[] => { | |
| 363 | const { app: tui, session } = createForgeApp(app, identity(user), { terminal: headlessTerminal(80, 24), exitHooks: false }); | |
| 364 | const ids = forgeCommands(session)(tui.ctx).map((c) => c.id); | |
| 365 | tui.stop?.(); | |
| 366 | return ids; | |
| 367 | }; | |
| 368 | const alice = getUserByName(app.db, "alice")!; | |
| 369 | expect(doors(alice)).toContain("footing"); | |
| 370 | for (const who of [getUserByName(app.db, "bob")!, undefined]) { | |
| 371 | expect(footingLines(new Session(app, identity(who)), f)).toEqual([]); | |
| 372 | expect(doors(who)).not.toContain("footing"); | |
| 373 | } | |
| 374 | }); | |
| 375 | ||
| 376 | test("the screen draws for an admin without waiting on the cluster", async () => { | |
| 377 | const { app: tui, session } = createForgeApp(app, identity(getUserByName(app.db, "alice")!), { terminal: headlessTerminal(120, 50), exitHooks: false }); | |
| 378 | tui.start(); | |
| 379 | tui.ctx.push(footingScreen(session, tui.ctx)); | |
| 380 | await settleApp(tui); | |
| 381 | const text = tui.screen.toText(); | |
| 382 | expect(text).toContain("What the forge stands on"); | |
| 383 | expect(text).toContain("pieces run on another machine"); | |
| 384 | tui.stop?.(); | |
| 385 | }); | |
| 386 | ||
| 387 | const mcp = async (who: string) => { | |
| 388 | const res = await fetch(`${servers.url}/mcp`, { | |
| 389 | method: "POST", | |
| 390 | headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${tok[who]}` }, | |
| 391 | body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "system", arguments: { kind: "footing" } } }), | |
| 392 | }); | |
| 393 | return (await res.json()) as any; | |
| 394 | }; | |
| 395 | ||
| 396 | test("the MCP system tool answers it to an admin, with the risk lines, and refuses anyone else", async () => { | |
| 397 | const out = JSON.parse((await mcp("alice")).result.content[0].text); | |
| 398 | expect(out.rows.map((r: { id: string }) => r.id)).toEqual(FOOTING.map((p) => p.id)); | |
| 399 | expect(Array.isArray(out.risks)).toBe(true); | |
| 400 | expect(out.risks.length).toBe(out.rows.filter((r: { risk: boolean }) => r.risk).length); | |
| 401 | const refused = await mcp("bob"); | |
| 402 | expect(JSON.stringify(refused)).toContain("only an admin of this forge reads what it stands on"); | |
| 403 | }); | |
| 404 | }); |
test/mcp-system-routes.test.ts +2
| @@ -47,6 +47,8 @@ const call = async (token: string) => { | ||
| 47 | 47 | }; |
| 48 | 48 | |
| 49 | 49 | test("routes lists the over-budget route first, with its budget and slowest phase; a non-admin is refused", async () => { |
| 50 | // perfRecord is the process's own, and a shard-mate's requests leave samples (and patterns, up to the cap) in it. | |
| 51 | perfRecord.clear(); | |
| 50 | 52 | const at = Date.now(); |
| 51 | 53 | for (let i = 0; i < 25; i++) { |
| 52 | 54 | perfRecord.add("/boards", { at, ms: 100, signedIn: true, phases: [{ name: "app", ms: 100, hit: false }] }); |
test/palette-roster.test.ts +1
| @@ -118,6 +118,7 @@ test("a site-admin person is offered the whole roster", () => { | ||
| 118 | 118 | ["disks", "Disks", "machines' disk readings: 30-day write rate and projected rated-TBW date"], |
| 119 | 119 | ["telemetry", "Telemetry", "which agents may read flow and system; enter flips the grant"], |
| 120 | 120 | ["stalls", "Main-thread stalls", "the last hour's stall ms by route"], |
| 121 | ["footing", "What the forge stands on", "each piece on another machine, in the order it would break the forge"], | |
| 121 | 122 | ["capacity", "Capacity", "concurrency and guards, live; e changes a settable one"], |
| 122 | 123 | ["resilience", "Resilience", "where snapshots go and the mirror; each protection green, red or untested"], |
| 123 | 124 | ["maintenance", "Maintenance", "prepare the forge for a power-off, check the drain, or end maintenance"], |
test/perf-aggregate.test.ts +10
| @@ -16,6 +16,16 @@ test("memory is capped", () => { | ||
| 16 | 16 | for (let i = 0; i < MAX_SAMPLES + 50; i++) r.add("/x", { at, ms: 1, signedIn: true, phases: [] }); |
| 17 | 17 | expect(r.traffic("/x", 0).length).toBeLessThanOrEqual(MAX_SAMPLES); |
| 18 | 18 | }); |
| 19 | test("clear forgets every pattern's samples and first sight, and the record fills again", () => { | |
| 20 | const r = createPerfRecord(); | |
| 21 | r.add("/x", { at, ms: 1, signedIn: true, phases: [] }); | |
| 22 | r.clear(); | |
| 23 | expect(r.patterns()).toEqual([]); | |
| 24 | expect(r.traffic("/x", 0)).toEqual([]); | |
| 25 | expect(r.first("/x")).toBeUndefined(); | |
| 26 | r.add("/x", { at: at + 1, ms: 2, signedIn: true, phases: [] }); | |
| 27 | expect(r.first("/x")).toBe(at + 1); | |
| 28 | }); | |
| 19 | 29 | test("a Server-Timing header becomes phases, hits marked", () => { |
| 20 | 30 | expect(parseServerTiming('app;dur=5.0, kpis;dur=4400.0;desc="hit"')).toEqual([ |
| 21 | 31 | { name: "app", ms: 5, hit: false }, |
test/system-parts.test.ts +1
| @@ -52,6 +52,7 @@ test("one <h2> per part, each in a settings-block, and no card", async () => { | ||
| 52 | 52 | "Machine", |
| 53 | 53 | "Disks and network", |
| 54 | 54 | "Processes", |
| 55 | "What the forge stands on", | |
| 55 | 56 | "Loop lag, last 20 minutes", |
| 56 | 57 | "Main-thread stalls, last hour", |
| 57 | 58 | "Telemetry readers", |
test/timers-named.test.ts +1
| @@ -124,6 +124,7 @@ const ALLOWED: readonly Allowed[] = [ | ||
| 124 | 124 | ["src/snapshot/drill.ts", "() => reject("], |
| 125 | 125 | ["src/snapshot/store.ts", "() => reject("], |
| 126 | 126 | ["src/snapshot/lanes.ts", "() => reject("], |
| 127 | ["src/system/footing.ts", "() => r(null)"], | |
| 127 | 128 | ["src/release/devtools.ts", "() => { if (pending.delete(n))"], |
| 128 | 129 | ["src/release/devtools.ts", "() => { waiting.set(event"], |
| 129 | 130 | ["src/release/prove.ts", "() => resolve(\"hung\")"], |
new above
offline