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

Merged opened this

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

Merged

scout/forge:scout/7486-the-terminal-s-deploys-screen-lists-the → main 2 commits fork 3 threads · 3 unresolved

  • Merged. scout/7486-the-terminal-s-deploys-screen-lists-the was merged into main as 8fac494.

Review

Sign in to review this change.
4 files changed +166 −14
src/apps/egress_report.ts +25
@@ -8,6 +8,7 @@
88 import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
99 import { join } from "node:path";
1010 import { defect } from "../kernel/error.ts";
11 import { hostAllowed } from "./egress.ts";
1112
1213 export interface Reached {
1314 readonly host: string;
@@ -64,3 +65,27 @@ export function noteReached(
6465 writeFileSync(`${file}.new`, JSON.stringify(next));
6566 renameSync(`${file}.new`, file);
6667 }
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 {
5757 } from "../../actions/domain_words.ts";
5858 import { envLabel, envRefusalText, redeployedText, stoppedText } from "../../actions/env_words.ts";
5959 import { trendLine, visitorsLine } from "../../apps/vitals.ts";
60 import { egressModeSentence, undeclaredHosts } from "../../apps/egress_report.ts";
61 import { egressSetting } from "../../core/capacity.ts";
6062 import { listRepoRuns } from "../../actions/runs.ts";
6163 import { carriedByRuns, carriesText } from "../../core/carried.ts";
6264 import { appsRuntime } from "../../apps/runtime.ts";
@@ -74,6 +76,7 @@ import { fill, len, split } from "../ui/layout.ts";
7476 import { emptyState } from "../ui/widgets.ts";
7577 import {
7678 ListState,
79 ago,
7780 confirmScreen,
7881 drawList,
7982 hitList,
@@ -122,6 +125,26 @@ function pullLine(p: AppPullLine): string {
122125 return `#${p.number} ${title} · check ${p.check} · ${p.preview ?? "none"}`;
123126 }
124127
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 [
outdated

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.

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
125148 export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
126149 const list = new RunList(f);
127150 let carries = new Map<string, number[]>();
@@ -130,6 +153,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
130153 let trend = "";
131154 let pace = "";
132155 let side: string[] = [];
156 let egress: string[] = [];
133157 let pulls: AppPullLine[] = [];
134158 let production: Production = { state: "", redeploy: false, stop: false };
135159 /** The app's domains, or undefined for anyone `listAppDomains` refuses. */
@@ -147,6 +171,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
147171 trend = seen === undefined ? "" : trendLine(seen.loadDays);
148172 pace = appPace(f.app, f.user, repo);
149173 side = appSideLines(f.app, f.user, repo);
174 egress = egressLines(f, repo);
150175 pulls = appPullLines(f.app, f.user, repo);
151176 production = readProduction(f, repo);
152177 const listed = listAppDomains(f.app, f.user, repo);
@@ -218,6 +243,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
218243 (trend === "" ? 0 : 1) +
219244 (pace === "" ? 0 : 1) +
220245 side.length +
246 egress.length +
221247 (named === "" ? 0 : 1),
222248 ),
223249 fill(1),
@@ -245,7 +271,7 @@ export function deploysScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
245271 style(c.mutedFg, c.bg),
246272 head!.x + head!.w,
247273 );
248 [visitors, trend, pace, ...side, named]
274 [visitors, trend, pace, ...side, named, ...egress]
249275 .filter((line) => line !== "")
250276 .forEach((line, i) =>
251277 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 @@
44 * to copy into `forge.json` before the forge starts refusing them.
55 */
66
7 import { reachedHosts } from "../../apps/egress_report.ts";
7 import { egressModeSentence, undeclaredHosts } from "../../apps/egress_report.ts";
88 import { egressMode } from "../../apps/contain.ts";
9 import { hostAllowed } from "../../apps/egress.ts";
109 import { html, type Html } from "../html.ts";
1110
1211 export function egressNote(
@@ -17,14 +16,8 @@ export function egressNote(
1716 /** The forge's egress mode now (`core/capacity.ts`). */
1817 mode: "enforce" | "report" = egressMode(),
1918 ): 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);
2320 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)];
2821 return html`<div class="card" id="app-egress">
2922 <p class="small">
3023 This app reached
@@ -34,11 +27,9 @@ export function egressNote(
3427 <span class="muted">(${r.count}×)</span>`,
3528 )},
3629 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)}
4031 Add them to <code>egress</code>:
4132 </p>
42 <pre><code>"egress": ${JSON.stringify(unique)}</code></pre>
33 <pre><code>"egress": ${JSON.stringify(entries)}</code></pre>
4334 </div>`;
4435 }
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs