Public: anyone can read this; the crew can change it.

A hosted child's admin sees process-wide counters that belong to its parent and siblings #10588

Merged opened this in answer to

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

Merged

scout/forge:scout/10517-a-hosted-child-s-admin-sees-process-wide → main 3 commits fork 3 threads · 3 unresolved

  • Merged. scout/10517-a-hosted-child-s-admin-sees-process-wide was merged into main as 028ab92.

Review

Sign in to review this change.
7 files changed +230 −18
src/actions/capacity.ts +15 −7
@@ -22,7 +22,14 @@ import {
2222 type CapacityChange,
2323 type CapacityKey,
2424 } from "../core/capacity.ts";
25 import { egressTotals, npmBytesLastHour, npmTopRepoLastHour, type EgressTotal } from "../apps/egress.ts";
25 import type { EgressTotal } from "../apps/egress.ts";
26 import {
27 egressTotalsFor,
28 liveCounters,
29 npmBytesFor,
30 npmTopRepoFor,
31 type ProcessCounters,
32 } from "../spawn/process_counters.ts";
2633 import { getRepoById } from "../core/repos.ts";
2734 import { selfSwapState, type SelfSwap } from "../release/swap.ts";
2835 import { signinReach, signsInWithoutEdge } from "../core/accounts.ts";
@@ -69,14 +76,15 @@ export interface EgressHour {
6976 readonly destinations: readonly EgressTotal[];
7077 }
7178
72 export function egressHour(db: Database, now = Date.now()): EgressHour {
73 const top = npmTopRepoLastHour(now);
74 const repo = top === undefined ? undefined : getRepoById(db, top.repoId);
79 /** This hour's egress as `app` may read it: none of it on a hosted child (#10517). */
80 export function egressHour(app: App, now = Date.now(), read: ProcessCounters = liveCounters): EgressHour {
81 const top = npmTopRepoFor(app, now, read);
82 const repo = top === undefined ? undefined : getRepoById(app.db, top.repoId);
7583 return {
76 npmBytesLastHour: npmBytesLastHour(now),
84 npmBytesLastHour: npmBytesFor(app, now, read),
7785 npmTopRepoLastHour:
7886 top === undefined || repo === undefined ? null : { repo: `${repo.ownerName}/${repo.name}`, bytes: top.bytes },
79 destinations: egressTotals(now).slice(0, 10),
87 destinations: egressTotalsFor(app, now, read).slice(0, 10),
8088 };
8189 }
8290
@@ -119,7 +127,7 @@ export function listCapacity(
119127 ...changedOf(app.db, key),
120128 })),
121129 changes: capacityChanges(app.db),
122 egress: egressHour(app.db),
130 egress: egressHour(app),
123131 selfSwap: selfSwapState(app.config.selfSwap === true),
124132 });
125133 }
src/http/api.ts +2 −2
@@ -386,7 +386,7 @@ import { upstreamRoutes } from "./api/upstream.ts";
386386 import type { Router } from "./router.ts";
387387 import { clientAddress } from "./peer.ts";
388388 import { manifestFor } from "../actions/here.ts";
389 import { egressSnapshot, egressTotals } from "../apps/egress.ts";
389 import { egressRowsFor, egressTotalsFor } from "../spawn/process_counters.ts";
390390 import { egressMetrics } from "../apps/egress_metrics.ts";
391391 import { forgeMetrics } from "../metrics/forge.ts";
392392 import { appSources } from "../metrics/app_sources.ts";
@@ -3368,7 +3368,7 @@ export function apiRoutes(router: Router<Handler>): void {
33683368 router.add("GET", "/metrics", (ctx) => {
33693369 const denied = accountAdmin(ctx, "ro");
33703370 if (denied) return denied;
3371 return new Response(egressMetrics(egressSnapshot(), egressTotals()) + forgeMetrics(ctx.app.db, appSources(ctx.app)), {
3371 return new Response(egressMetrics(egressRowsFor(ctx.app), egressTotalsFor(ctx.app)) + forgeMetrics(ctx.app.db, appSources(ctx.app)), {
33723372 headers: {
33733373 "content-type": "text/plain; version=0.0.4; charset=utf-8",
33743374 "cache-control": "no-store",
src/metrics/app_sources.ts +2 −2
@@ -4,9 +4,9 @@
44 * watch has already read. It starts nothing and reads no process.
55 */
66 import type { App } from "../app.ts";
7 import { cellCounters } from "../apps/contain.ts";
87 import { appsRuntime } from "../apps/runtime.ts";
98 import { listApps } from "../core/apps.ts";
9 import { cellCountersFor } from "../spawn/process_counters.ts";
1010 import type { AppSources } from "./forge.ts";
1111
1212 export function appSources(app: App): AppSources {
@@ -19,6 +19,6 @@ export function appSources(app: App): AppSources {
1919 private: a.access === "private",
2020 envs: runtime.status(a).map((e) => ({ env: e.env, state: e.state })),
2121 })),
22 cells: cellCounters(),
22 cells: cellCountersFor(app),
2323 };
2424 }
src/spawn/process_counters.ts added +61
@@ -0,0 +1,61 @@
1 /**
2 * The counters this process keeps once for every App in it, as one App may
3 * read them (#10517). The egress table and totals and the cell watch are
4 * per process, not per App, and their labels name the parent's and every
5 * hosted child's repositories and apps, private ones too. A hosted child's
6 * admin is its owner, not this machine's, so a hosted child reads none of
7 * them: its `/metrics`, capacity page and terminal egress screen show
8 * nothing rather than its parent's and siblings' rows. The parent reads
9 * them whole, as before.
10 *
11 * Every reader takes the counters to read, the live ones by default, so a
12 * test can hand it rows without a proxy socket or a watched process.
13 */
14
15 import type { App } from "../app.ts";
16 import { cellCounters, type CellCounter } from "../apps/contain.ts";
17 import {
18 egressSnapshot,
19 egressTotals,
20 npmBytesLastHour,
21 npmTopRepoLastHour,
22 type EgressRow,
23 type EgressTotal,
24 type NpmTopRepo,
25 } from "../apps/egress.ts";
26 import { isHostedChild } from "./hosted-mark.ts";
27
28 /** Where the process-wide counters are read from. */
29 export interface ProcessCounters {
30 readonly egressRows: () => EgressRow[];
31 readonly egressTotals: (now: number) => EgressTotal[];
32 readonly npmBytes: (now: number) => number;
33 readonly npmTopRepo: (now: number) => NpmTopRepo | undefined;
34 readonly cells: () => CellCounter[];
35 }
36
37 export const liveCounters: ProcessCounters = {
38 egressRows: egressSnapshot,
39 egressTotals,
40 npmBytes: npmBytesLastHour,
41 npmTopRepo: npmTopRepoLastHour,
42 cells: cellCounters,
43 };
44
45 export const egressRowsFor = (app: App, read: ProcessCounters = liveCounters): EgressRow[] =>
46 isHostedChild(app) ? [] : read.egressRows();
47
48 export const egressTotalsFor = (app: App, now = Date.now(), read: ProcessCounters = liveCounters): EgressTotal[] =>
49 isHostedChild(app) ? [] : read.egressTotals(now);
50
51 export const npmBytesFor = (app: App, now = Date.now(), read: ProcessCounters = liveCounters): number =>
52 isHostedChild(app) ? 0 : read.npmBytes(now);
53
54 export const npmTopRepoFor = (
55 app: App,
56 now = Date.now(),
57 read: ProcessCounters = liveCounters,
58 ): NpmTopRepo | undefined => (isHostedChild(app) ? undefined : read.npmTopRepo(now));
59
60 export const cellCountersFor = (app: App, read: ProcessCounters = liveCounters): CellCounter[] =>
61 isHostedChild(app) ? [] : read.cells();
src/tui/screens/egress.ts +6 −5
@@ -12,7 +12,7 @@ import {
1212 type EgressRow,
1313 } from "../../actions/egress_grants.ts";
1414 import { egressHour } from "../../actions/capacity.ts";
15 import { egressSnapshot } from "../../apps/egress.ts";
15 import { egressRowsFor, liveCounters, type ProcessCounters } from "../../spawn/process_counters.ts";
1616 import { bytes } from "../../system/format.ts";
1717 import type { Repo } from "../../core/repos.ts";
1818 import type { Ctx, Screen } from "../app/app.ts";
@@ -120,14 +120,15 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
120120 * Capacity page's card, for a site admin, under the bytes the npm registry
121121 * sent over the last hour (#7546) and the repository it sent most to (#8382),
122122 * both from `egressHour`, as the page and MCP read them. Read again on every draw, so it moves as
123 * connections do; anyone else is offered nothing.
123 * connections do; anyone else is offered nothing. `read` is where the
124 * counters come from, the live ones but in a test.
124125 */
125 export function connectionsScreen(f: Session, ctx: Ctx): Screen {
126 export function connectionsScreen(f: Session, ctx: Ctx, read: ProcessCounters = liveCounters): Screen {
126127 const state = new ListState();
127128 const lines = (): ListRow[] =>
128129 f.user?.isAdmin !== true
129130 ? []
outdated

The terminal door is changed but untested. Add a test that a hosted child's admin sees no parent connection rows here, and that the parent's admin does.

130 : egressSnapshot().map((r) => ({
131 : egressRowsFor(f.app, read).map((r) => ({
131132 title: r.label === "" ? "(unlabelled)" : r.label,
132133 meta: `${r.destination} · ${r.open} open · ${bytes(r.bytesIn)} in · ${bytes(r.bytesOut)} out`,
133134 }));
@@ -140,7 +141,7 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen {
140141 state.clamp(rows.length);
141142 const [npm, head, list] = split(area, "v", [len(1), len(1), fill(1)]);
142143 if (f.user?.isAdmin === true) {
143 const hour = egressHour(f.app.db);
144 const hour = egressHour(f.app, Date.now(), read);
144145 const top = hour.npmTopRepoLastHour;
145146 const most = top === null ? "." : `, most to ${top.repo} (${bytes(top.bytes)}).`;
146147 const sent = `The npm registry sent ${bytes(hour.npmBytesLastHour)} over the last hour${most}`;
src/web/pages/capacity.ts +2 −2
@@ -9,7 +9,7 @@
99 import { changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
1010 import { CAPACITY, belowCeiling, type CapacityKey } from "../../core/capacity.ts";
1111 import { bytes } from "../../system/format.ts";
12 import { egressSnapshot } from "../../apps/egress.ts";
12 import { egressRowsFor } from "../../spawn/process_counters.ts";
1313 import type { Ctx } from "../../http/context.ts";
1414 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
1515 import { notFoundPage, signInFirst } from "../chrome.ts";
@@ -162,7 +162,7 @@ export function capacityPage(ctx: Ctx): Response {
162162 : ""}.
163163 </p>`,
164164 );
165 const open = egressSnapshot();
165 const open = egressRowsFor(ctx.app);
166166 // The bun cache at work (#7546): a warm cache keeps this near zero.
167167 const npm = html`The npm registry sent
168168 <span class="mono">${bytes(egress.npmBytesLastHour)}</span> over the last hour${
test/hosted-child-counters.test.ts added +142
@@ -0,0 +1,142 @@
1 /**
2 * The egress table and the cell watch are kept once per process, and a
3 * hosted child is a second App in its parent's process whose admin is its
4 * owner (#10517): its `/metrics`, capacity listing and terminal
5 * connections screen name nothing of the parent's destinations,
6 * repositories or cells; the parent's still do. The gate is tested on
7 * injected counters, so no proxy socket or watched process is needed.
8 */
9
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { listCapacity } from "../src/actions/capacity.ts";
15 import { createRepo } from "../src/actions/repos.ts";
16 import { getUserByName } from "../src/core/users.ts";
17 import { createApp, type App } from "../src/app.ts";
18 import { meterEgress, NPM_REGISTRY, recordEgress, resetEgressTotals } from "../src/apps/egress.ts";
19 import { serve, type Servers } from "../src/http/server.ts";
20 import { markHostedChild } from "../src/spawn/hosted-mark.ts";
21 import {
22 cellCountersFor,
23 egressRowsFor,
24 egressTotalsFor,
25 npmBytesFor,
26 npmTopRepoFor,
27 type ProcessCounters,
28 } from "../src/spawn/process_counters.ts";
29 import type { Ctx } from "../src/tui/app/app.ts";
30 import { connectionsScreen } from "../src/tui/screens/egress.ts";
31 import type { Session } from "../src/tui/session.ts";
32 import { Screen as Surface } from "../src/tui/term/screen.ts";
33 import { theme } from "../src/tui/ui/theme.ts";
34 import { adminCookie, seedAdmin } from "./helpers/admin.ts";
35
36 const DEST = "parent-only-destination.example:443";
37 const CELL = "app:parent-secret-cell/production";
38 const NOW = 1_800_000_000_000;
39 const OPEN = { label: "app:parent-secret-app/production", destination: DEST, open: 2, bytesIn: 100, bytesOut: 10 };
40 /** Counters as the proxy and the cell watch would hold them, with no socket or process behind them. */
41 const fake: ProcessCounters = {
42 egressRows: () => [OPEN],
43 egressTotals: () => [{ destination: DEST, bytesIn: 1_000, bytesOut: 10, bytesInLastHour: 1_000 }],
44 npmBytes: () => 2_000_000,
45 npmTopRepo: () => ({ repoId: "r1", bytes: 2_000_000 }),
46 cells: () => [{ key: 1, label: CELL, cpuUsec: 0, memoryBytes: 0, cores: null }],
47 };
48
49 let dir: string;
50 let parent: App;
51 let child: App;
52 let parentServers: Servers;
53 let childServers: Servers;
54 let parentCookie: string;
55 let childCookie: string;
56
57 beforeAll(async () => {
58 dir = await mkdtemp(join(tmpdir(), "hosted-counters-"));
59 parent = createApp({ dataDir: join(dir, "parent"), port: 0 });
60 child = createApp({ dataDir: join(dir, "parent", "children", "kid"), port: 0 });
61 markHostedChild(child, parent.config.dataDir);
62 parentServers = await serve(parent);
63 childServers = await serve(child);
64 const owner = await seedAdmin(parent, { username: "alice", email: "[email protected]", password: "a-good-passphrase" });
65 if (!owner.ok) throw new Error(owner.error);
66 parentCookie = await adminCookie(parent, { username: "bob", email: "[email protected]", password: "a-good-passphrase" });
67 childCookie = await adminCookie(child, { username: "kidowner", email: "[email protected]", password: "a-good-passphrase" });
68 const repo = await createRepo(parent, owner.value, { name: "parent-private-repo", visibility: "private" });
69 if (!repo.ok) throw new Error(String(repo.error));
70 resetEgressTotals();
71 const meter = meterEgress("check:p", repo.value.id);
72 recordEgress("check:p", NPM_REGISTRY, 2_000_000, 0);
73 recordEgress("check:p", DEST, 1_000, 10);
74 meter.stop();
75 }, 60_000);
76
77 afterAll(async () => {
78 resetEgressTotals();
79 childServers.stop();
80 parentServers.stop();
81 child.close();
82 parent.close();
83 await rm(dir, { recursive: true, force: true });
84 });
85
86 const get = async (s: Servers, path: string, cookie: string): Promise<string> => {
87 const r = await fetch(`${s.url}${path}`, { headers: { cookie } });
outdated

This test passes without the fix: the page doesn't render destinations, and the parent's repo id doesn't resolve in the child's db. Make it assert something the fix changes, such as the open-connections rows, or remove it.

88 expect(r.status).toBe(200);
89 return r.text();
90 };
91
92 describe("a hosted child's admin views", () => {
93 test("/metrics names nothing of the parent's; the parent's names its destination", async () => {
94 expect(await get(parentServers, "/metrics", parentCookie)).toContain(DEST);
95 const body = await get(childServers, "/metrics", childCookie);
96 expect(body).not.toContain(DEST);
97 expect(body).not.toContain(NPM_REGISTRY);
98 });
99
outdated

Spawning sleep 30 and polling up to 4 s is a flake and a slow-test risk. Prefer a test of cellCountersFor with an injected or registered counter and no process.

100 test("the capacity listing the MCP tool reads holds none of the parent's egress", async () => {
101 const admin = (a: App, name: string) => getUserByName(a.db, name)!;
102 const mine = listCapacity(parent, admin(parent, "bob"));
103 const theirs = listCapacity(child, admin(child, "kidowner"));
104 if (!mine.ok || !theirs.ok) throw new Error("not an admin");
105 expect(mine.value.egress.destinations.map((d) => d.destination)).toContain(DEST);
106 expect(mine.value.egress.npmTopRepoLastHour?.repo).toBe("alice/parent-private-repo");
107 expect(theirs.value.egress).toEqual({ npmBytesLastHour: 0, npmTopRepoLastHour: null, destinations: [] });
108 });
109
110 test("the open-connections rows, totals, npm bytes and top repository are the parent's alone", () => {
111 expect(egressRowsFor(parent, fake)).toEqual([OPEN]);
112 expect(egressRowsFor(child, fake)).toEqual([]);
113 expect(egressTotalsFor(parent, NOW, fake).map((t) => t.destination)).toEqual([DEST]);
114 expect(egressTotalsFor(child, NOW, fake)).toEqual([]);
115 expect(npmBytesFor(parent, NOW, fake)).toBe(2_000_000);
116 expect(npmBytesFor(child, NOW, fake)).toBe(0);
117 expect(npmTopRepoFor(parent, NOW, fake)).toEqual({ repoId: "r1", bytes: 2_000_000 });
118 expect(npmTopRepoFor(child, NOW, fake)).toBeUndefined();
119 });
120
121 test("the cell watch is the parent's alone", () => {
122 expect(cellCountersFor(parent, fake).map((c) => c.label)).toEqual([CELL]);
123 expect(cellCountersFor(child, fake)).toEqual([]);
124 });
125
126 test("the terminal's connections screen shows the child's admin none of the parent's connections", () => {
127 const shown = (a: App, name: string): string => {
128 const ctx = { theme: theme("dark"), invalidate() {} } as unknown as Ctx;
129 const f = { app: a, db: a.db, user: getUserByName(a.db, name)! } as unknown as Session;
130 const s = new Surface(160, 12);
131 connectionsScreen(f, ctx, fake).render({ x: 0, y: 0, w: 160, h: 12 }, s);
132 return s.toText();
133 };
134 const mine = shown(parent, "bob");
135 expect(mine).toContain(OPEN.label);
136 expect(mine).toContain(DEST);
137 const theirs = shown(child, "kidowner");
138 expect(theirs).not.toContain(OPEN.label);
139 expect(theirs).not.toContain(DEST);
140 expect(theirs).toContain("No app holds an egress connection open.");
141 });
142 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs