Public: anyone can read this; the crew can change it.
The terminal's deploys screen lists the hosts an app's code reached without declaring them, while egress is only reported, as the app's page does #7493
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7486-the-terminal-s-deploys-screen-lists-the → main 2 commits fork 3 threads · 3 unresolved
4 files
changed +166
−14
src/apps/egress_report.ts +25
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { defect } from "../kernel/error.ts"; |
| 11 | import { hostAllowed } from "./egress.ts"; | |
| 11 | 12 | |
| 12 | 13 | export interface Reached { |
| 13 | 14 | readonly host: string; |
| @@ -64,3 +65,27 @@ export function noteReached( | ||
| 64 | 65 | writeFileSync(`${file}.new`, JSON.stringify(next)); |
| 65 | 66 | renameSync(`${file}.new`, file); |
| 66 | 67 | } |
| 68 | ||
| 69 | /** | |
| 70 | * What `repoId`'s code reached that `declared` does not allow, and the | |
| 71 | * `egress` entries to add to `forge.json` (a port is named unless 80 or 443). | |
| 72 | * The app's page and the terminal both say it from here. | |
| 73 | */ | |
| 74 | export function undeclaredHosts( | |
| 75 | dataDir: string, | |
| 76 | repoId: string, | |
| 77 | declared: readonly string[], | |
| 78 | ): { reached: Reached[]; entries: string[] } { | |
| 79 | const reached = reachedHosts(dataDir, repoId).filter((r) => !hostAllowed(declared, r.host, r.port)); | |
| 80 | const entries = [ | |
| 81 | ...new Set(reached.map((r) => (r.port === 80 || r.port === 443 ? r.host : `${r.host}:${r.port}`))), | |
| 82 | ]; | |
| 83 | return { reached, entries }; | |
| 84 | } | |
| 85 | ||
| 86 | /** Whether those calls still work, by the forge's egress mode. */ | |
| 87 | export function egressModeSentence(mode: "enforce" | "report"): string { | |
| 88 | return mode === "report" | |
| 89 | ? "They were let through while egress is only reported; once it is enforced they are refused." | |
| 90 | : "They were let through while egress was only reported; they are refused now."; | |
| 91 | } |
src/tui/screens/deploys.ts +27 −1
| @@ -57,6 +57,8 @@ import { | ||
| 57 | 57 | } from "../../actions/domain_words.ts"; |
| 58 | 58 | import { envLabel, envRefusalText, redeployedText, stoppedText } from "../../actions/env_words.ts"; |
| 59 | 59 | import { trendLine, visitorsLine } from "../../apps/vitals.ts"; |
| 60 | import { egressModeSentence, undeclaredHosts } from "../../apps/egress_report.ts"; | |
| 61 | import { egressSetting } from "../../core/capacity.ts"; | |
| 60 | 62 | import { listRepoRuns } from "../../actions/runs.ts"; |
| 61 | 63 | import { carriedByRuns, carriesText } from "../../core/carried.ts"; |
| 62 | 64 | import { appsRuntime } from "../../apps/runtime.ts"; |
| @@ -74,6 +76,7 @@ import { fill, len, split } from "../ui/layout.ts"; | ||
| 74 | 76 | import { emptyState } from "../ui/widgets.ts"; |
| 75 | 77 | import { |
| 76 | 78 | ListState, |
| 79 | ago, | |
| 77 | 80 | confirmScreen, |
| 78 | 81 | drawList, |
| 79 | 82 | hitList, |
| @@ -122,6 +125,26 @@ function pullLine(p: AppPullLine): string { | ||
| 122 | 125 | return `#${p.number} ${title} · check ${p.check} · ${p.preview ?? "none"}`; |
| 123 | 126 | } |
| 124 | 127 | |
| 128 | /** | |
| 129 | * "Reached, not declared": the hosts the app's code called that `forge.json` | |
| 130 | * does not declare, as the app's page says them, then the line to add them. | |
| 131 | * Only a writer is told; nothing is drawn with none. | |
| 132 | */ | |
| 133 | function egressLines(f: Session, repo: Repo): string[] { | |
| 134 | if (!canWrite(f.db, repo, f.user)) return []; | |
| 135 | const record = getAppByRepo(f.db, repo.id); | |
| 136 | if (record === undefined) return []; | |
| 137 | const declared = appsRuntime(f.app)?.liveFor(record, "production")?.cell?.egress ?? []; | |
| 138 | const { reached, entries } = undeclaredHosts(f.app.config.dataDir, repo.id, declared); | |
| 139 | if (reached.length === 0) return []; | |
| 140 | return [ | |
| 141 | "Reached, not declared", | |
outdated
Add a line saying whether these were let through (egress only reported) or are refused now, as src/web/studio/egress.ts:36-40 does with egressMode(). Without it the group does not say what the issue title asks. |
||
| 142 | ...reached.map((r) => ` ${r.host}:${r.port} · ${r.count}× · last seen ${ago(r.last)}`), | |
| 143 | egressModeSentence(egressSetting(f.db)), | |
| 144 | `Add them to the egress list in forge.json: ${JSON.stringify(entries)}`, | |
| 145 | ]; | |
| 146 | } | |
| 147 | ||
| 125 | 148 | export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { |
| 126 | 149 | const list = new RunList(f); |
| 127 | 150 | let carries = new Map<string, number[]>(); |
| @@ -130,6 +153,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 130 | 153 | let trend = ""; |
| 131 | 154 | let pace = ""; |
| 132 | 155 | let side: string[] = []; |
| 156 | let egress: string[] = []; | |
| 133 | 157 | let pulls: AppPullLine[] = []; |
| 134 | 158 | let production: Production = { state: "", redeploy: false, stop: false }; |
| 135 | 159 | /** The app's domains, or undefined for anyone `listAppDomains` refuses. */ |
| @@ -147,6 +171,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 147 | 171 | trend = seen === undefined ? "" : trendLine(seen.loadDays); |
| 148 | 172 | pace = appPace(f.app, f.user, repo); |
| 149 | 173 | side = appSideLines(f.app, f.user, repo); |
| 174 | egress = egressLines(f, repo); | |
| 150 | 175 | pulls = appPullLines(f.app, f.user, repo); |
| 151 | 176 | production = readProduction(f, repo); |
| 152 | 177 | const listed = listAppDomains(f.app, f.user, repo); |
| @@ -218,6 +243,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 218 | 243 | (trend === "" ? 0 : 1) + |
| 219 | 244 | (pace === "" ? 0 : 1) + |
| 220 | 245 | side.length + |
| 246 | egress.length + | |
| 221 | 247 | (named === "" ? 0 : 1), |
| 222 | 248 | ), |
| 223 | 249 | fill(1), |
| @@ -245,7 +271,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 245 | 271 | style(c.mutedFg, c.bg), |
| 246 | 272 | head!.x + head!.w, |
| 247 | 273 | ); |
| 248 | [visitors, trend, pace, ...side, named] | |
| 274 | [visitors, trend, pace, ...side, named, ...egress] | |
| 249 | 275 | .filter((line) => line !== "") |
| 250 | 276 | .forEach((line, i) => |
| 251 | 277 | s.text(head!.x, head!.y + 1 + i, truncate(line, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w), |
src/web/studio/egress.ts +4 −13
| @@ -4,9 +4,8 @@ | ||
| 4 | 4 | * to copy into `forge.json` before the forge starts refusing them. |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { reachedHosts } from "../../apps/egress_report.ts"; | |
| 7 | import { egressModeSentence, undeclaredHosts } from "../../apps/egress_report.ts"; | |
| 8 | 8 | import { egressMode } from "../../apps/contain.ts"; |
| 9 | import { hostAllowed } from "../../apps/egress.ts"; | |
| 10 | 9 | import { html, type Html } from "../html.ts"; |
| 11 | 10 | |
| 12 | 11 | export function egressNote( |
| @@ -17,14 +16,8 @@ export function egressNote( | ||
| 17 | 16 | /** The forge's egress mode now (`core/capacity.ts`). */ |
| 18 | 17 | mode: "enforce" | "report" = egressMode(), |
| 19 | 18 | ): Html | null { |
| 20 | const reached = reachedHosts(dataDir, repoId).filter( | |
| 21 | (r) => !hostAllowed(declared, r.host, r.port), | |
| 22 | ); | |
| 19 | const { reached, entries } = undeclaredHosts(dataDir, repoId, declared); | |
| 23 | 20 | if (reached.length === 0) return null; |
| 24 | const entries = reached.map((r) => | |
| 25 | r.port === 80 || r.port === 443 ? r.host : `${r.host}:${r.port}`, | |
| 26 | ); | |
| 27 | const unique = [...new Set(entries)]; | |
| 28 | 21 | return html`<div class="card" id="app-egress"> |
| 29 | 22 | <p class="small"> |
| 30 | 23 | This app reached |
| @@ -34,11 +27,9 @@ export function egressNote( | ||
| 34 | 27 | <span class="muted">(${r.count}×)</span>`, |
| 35 | 28 | )}, |
| 36 | 29 | which <code>forge.json</code> does not declare. |
| 37 | ${mode === "report" | |
| 38 | ? "They were let through while egress is only reported; once it is enforced they are refused." | |
| 39 | : "They were let through while egress was only reported; they are refused now."} | |
| 30 | ${egressModeSentence(mode)} | |
| 40 | 31 | Add them to <code>egress</code>: |
| 41 | 32 | </p> |
| 42 | <pre><code>"egress": ${JSON.stringify(unique)}</code></pre> | |
| 33 | <pre><code>"egress": ${JSON.stringify(entries)}</code></pre> | |
| 43 | 34 | </div>`; |
| 44 | 35 | } |
test/tui-deploys-egress.test.ts added +110
| @@ -0,0 +1,110 @@ | ||
| 1 | /** | |
| 2 | * The deploys screen's "Reached, not declared": the hosts an app's code | |
| 3 | * reached without declaring them, for a writer only, as the app's page says. | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { signUp } from "../src/actions/users.ts"; | |
| 12 | import { createApp, type App as Forge } from "../src/app.ts"; | |
| 13 | import { noteReached } from "../src/apps/egress_report.ts"; | |
| 14 | import { createApp as createAppRecord } from "../src/core/apps.ts"; | |
| 15 | import type { Repo } from "../src/core/repos.ts"; | |
| 16 | import type { User } from "../src/core/users.ts"; | |
| 17 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 18 | import { deploysScreen } from "../src/tui/screens/deploys.ts"; | |
| 19 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 20 | import { quietLogs } from "./helpers/globals.ts"; | |
| 21 | import { settleApp } from "./helpers/tui.ts"; | |
| 22 | import { fixtureSlot } from "./helpers/slot.ts"; | |
| 23 | import { seedAdmin } from "./helpers/admin.ts"; | |
| 24 | ||
| 25 | quietLogs(); | |
| 26 | ||
| 27 | let dir: string; | |
| 28 | let forge: Forge; | |
| 29 | let alice: User; | |
| 30 | let bob: User; | |
| 31 | let repo: Repo; | |
| 32 | ||
| 33 | beforeAll(async () => { | |
| 34 | dir = await mkdtemp(join(tmpdir(), "forge-tui-deploys-egress-")); | |
| 35 | forge = createApp({ dataDir: dir, port: 0 }); | |
| 36 | const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); | |
| 37 | if (!a.ok) throw new Error("signup failed"); | |
| 38 | alice = a.value; | |
| 39 | const b = await signUp( | |
| 40 | forge, | |
| 41 | { username: "bob", email: "[email protected]", password: "a-long-passphrase" }, | |
| 42 | { slot: fixtureSlot(forge) }, | |
| 43 | ); | |
| 44 | if (!b.ok) throw new Error("signup failed"); | |
| 45 | bob = b.value; | |
| 46 | const r = await createRepo(forge, alice, { name: "notes", visibility: "public" }); | |
| 47 | if (!r.ok) throw new Error("repo failed"); | |
| 48 | repo = r.value; | |
| 49 | const made = createAppRecord(forge.db, { repoId: repo.id, slug: "notes", access: "public" }); | |
| 50 | if (!made.ok) throw new Error(made.error); | |
| 51 | }); | |
| 52 | ||
| 53 | afterAll(async () => { | |
| 54 | forge?.close?.(); | |
| 55 | await rm(dir, { recursive: true, force: true }); | |
| 56 | }); | |
| 57 | ||
| 58 | const frame = async (who: User | null): Promise<string> => { | |
| 59 | const { app, session } = createForgeApp( | |
| 60 | forge, | |
| 61 | { sshUsername: who?.username ?? "guest", remoteAddress: "127.0.0.1", ...(who === null ? {} : { user: who }) }, | |
| 62 | { terminal: headlessTerminal(200, 40), exitHooks: false }, | |
| 63 | ); | |
| 64 | app.start(); | |
| 65 | await settleApp(app); | |
| 66 | app.push(deploysScreen(session, app.ctx, repo)); | |
| 67 | await settleApp(app); | |
| 68 | const text = app.screen.toText(); | |
| 69 | app.stop(); | |
| 70 | return text; | |
| 71 | }; | |
| 72 | ||
| 73 | test("nothing is drawn while nothing was reached", async () => { | |
| 74 | expect(await frame(alice)).not.toContain("Reached, not declared"); | |
| 75 | }); | |
| 76 | ||
| 77 | test("a writer sees one row per reached host and the line to add them; a reader and a guest see none", async () => { | |
| 78 | noteReached(dir, repo.id, "api.example.com", 443); | |
| 79 | noteReached(dir, repo.id, "api.example.com", 443); | |
| 80 | noteReached(dir, repo.id, "db.example.com", 5432); | |
| 81 | const text = await frame(alice); | |
| 82 | expect(text).toContain("Reached, not declared"); | |
| 83 | expect(text).toContain("api.example.com:443 · 2× · last seen just now"); | |
| 84 | expect(text).toContain("db.example.com:5432 · 1× · last seen just now"); | |
| 85 | expect(text).toContain('forge.json: ["db.example.com:5432","api.example.com"]'); | |
| 86 | // reachedHosts lists the most recent first, so db (noted last) precedes api. | |
outdated
Assert the mode sentence for both report and enforce. Also, the expected array order (db before api) comes from reachedHosts ordering, so note that or sort it explicitly. |
||
| 87 | for (const who of [bob, null]) { | |
| 88 | const seen = await frame(who); | |
| 89 | expect(seen).not.toContain("Reached, not declared"); | |
| 90 | expect(seen).not.toContain("api.example.com"); | |
| 91 | } | |
| 92 | }); | |
| 93 | ||
| 94 | const REPORT = "They were let through while egress is only reported; once it is enforced they are refused."; | |
| 95 | const ENFORCE = "They were let through while egress was only reported; they are refused now."; | |
| 96 | ||
| 97 | test("it says whether the calls still work, by the forge's egress mode, in the page's words", async () => { | |
| 98 | noteReached(dir, repo.id, "api.example.com", 443); | |
| 99 | const setMode = (value: string): void => { | |
| 100 | forge.db.run("INSERT OR REPLACE INTO forge_setting (key, value, updated_by, updated_at) VALUES ('cells.egress', ?1, ?2, ?3)", [value, alice.id, new Date().toISOString()]); | |
| 101 | }; | |
| 102 | setMode("report"); | |
| 103 | const report = await frame(alice); | |
| 104 | expect(report).toContain(REPORT); | |
| 105 | expect(report).not.toContain(ENFORCE); | |
| 106 | setMode("enforce"); | |
| 107 | const enforce = await frame(alice); | |
| 108 | expect(enforce).toContain(ENFORCE); | |
| 109 | expect(enforce).not.toContain(REPORT); | |
| 110 | }); |
new above
offline
This repeats egressNote's filter and entries logic. Share one helper with src/web/studio/egress.ts so the page and the terminal cannot disagree.