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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10517-a-hosted-child-s-admin-sees-process-wide → main 3 commits fork 3 threads · 3 unresolved
7 files
changed +230
−18
src/actions/capacity.ts +15 −7
| @@ -22,7 +22,14 @@ import { | ||
| 22 | 22 | type CapacityChange, |
| 23 | 23 | type CapacityKey, |
| 24 | 24 | } 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"; | |
| 26 | 33 | import { getRepoById } from "../core/repos.ts"; |
| 27 | 34 | import { selfSwapState, type SelfSwap } from "../release/swap.ts"; |
| 28 | 35 | import { signinReach, signsInWithoutEdge } from "../core/accounts.ts"; |
| @@ -69,14 +76,15 @@ export interface EgressHour { | ||
| 69 | 76 | readonly destinations: readonly EgressTotal[]; |
| 70 | 77 | } |
| 71 | 78 | |
| 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); | |
| 75 | 83 | return { |
| 76 | npmBytesLastHour: npmBytesLastHour(now), | |
| 84 | npmBytesLastHour: npmBytesFor(app, now, read), | |
| 77 | 85 | npmTopRepoLastHour: |
| 78 | 86 | 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), | |
| 80 | 88 | }; |
| 81 | 89 | } |
| 82 | 90 | |
| @@ -119,7 +127,7 @@ export function listCapacity( | ||
| 119 | 127 | ...changedOf(app.db, key), |
| 120 | 128 | })), |
| 121 | 129 | changes: capacityChanges(app.db), |
| 122 | egress: egressHour(app.db), | |
| 130 | egress: egressHour(app), | |
| 123 | 131 | selfSwap: selfSwapState(app.config.selfSwap === true), |
| 124 | 132 | }); |
| 125 | 133 | } |
src/http/api.ts +2 −2
| @@ -386,7 +386,7 @@ import { upstreamRoutes } from "./api/upstream.ts"; | ||
| 386 | 386 | import type { Router } from "./router.ts"; |
| 387 | 387 | import { clientAddress } from "./peer.ts"; |
| 388 | 388 | import { manifestFor } from "../actions/here.ts"; |
| 389 | import { egressSnapshot, egressTotals } from "../apps/egress.ts"; | |
| 389 | import { egressRowsFor, egressTotalsFor } from "../spawn/process_counters.ts"; | |
| 390 | 390 | import { egressMetrics } from "../apps/egress_metrics.ts"; |
| 391 | 391 | import { forgeMetrics } from "../metrics/forge.ts"; |
| 392 | 392 | import { appSources } from "../metrics/app_sources.ts"; |
| @@ -3368,7 +3368,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3368 | 3368 | router.add("GET", "/metrics", (ctx) => { |
| 3369 | 3369 | const denied = accountAdmin(ctx, "ro"); |
| 3370 | 3370 | 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)), { | |
| 3372 | 3372 | headers: { |
| 3373 | 3373 | "content-type": "text/plain; version=0.0.4; charset=utf-8", |
| 3374 | 3374 | "cache-control": "no-store", |
src/metrics/app_sources.ts +2 −2
| @@ -4,9 +4,9 @@ | ||
| 4 | 4 | * watch has already read. It starts nothing and reads no process. |
| 5 | 5 | */ |
| 6 | 6 | import type { App } from "../app.ts"; |
| 7 | import { cellCounters } from "../apps/contain.ts"; | |
| 8 | 7 | import { appsRuntime } from "../apps/runtime.ts"; |
| 9 | 8 | import { listApps } from "../core/apps.ts"; |
| 9 | import { cellCountersFor } from "../spawn/process_counters.ts"; | |
| 10 | 10 | import type { AppSources } from "./forge.ts"; |
| 11 | 11 | |
| 12 | 12 | export function appSources(app: App): AppSources { |
| @@ -19,6 +19,6 @@ export function appSources(app: App): AppSources { | ||
| 19 | 19 | private: a.access === "private", |
| 20 | 20 | envs: runtime.status(a).map((e) => ({ env: e.env, state: e.state })), |
| 21 | 21 | })), |
| 22 | cells: cellCounters(), | |
| 22 | cells: cellCountersFor(app), | |
| 23 | 23 | }; |
| 24 | 24 | } |
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 { | ||
| 12 | 12 | type EgressRow, |
| 13 | 13 | } from "../../actions/egress_grants.ts"; |
| 14 | 14 | import { egressHour } from "../../actions/capacity.ts"; |
| 15 | import { egressSnapshot } from "../../apps/egress.ts"; | |
| 15 | import { egressRowsFor, liveCounters, type ProcessCounters } from "../../spawn/process_counters.ts"; | |
| 16 | 16 | import { bytes } from "../../system/format.ts"; |
| 17 | 17 | import type { Repo } from "../../core/repos.ts"; |
| 18 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -120,14 +120,15 @@ export function egressScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 120 | 120 | * Capacity page's card, for a site admin, under the bytes the npm registry |
| 121 | 121 | * sent over the last hour (#7546) and the repository it sent most to (#8382), |
| 122 | 122 | * 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. | |
| 124 | 125 | */ |
| 125 | export function connectionsScreen(f: Session, ctx: Ctx): Screen { | |
| 126 | export function connectionsScreen(f: Session, ctx: Ctx, read: ProcessCounters = liveCounters): Screen { | |
| 126 | 127 | const state = new ListState(); |
| 127 | 128 | const lines = (): ListRow[] => |
| 128 | 129 | f.user?.isAdmin !== true |
| 129 | 130 | ? [] |
| 130 | : egressSnapshot().map((r) => ({ | |
| 131 | : egressRowsFor(f.app, read).map((r) => ({ | |
| 131 | 132 | title: r.label === "" ? "(unlabelled)" : r.label, |
| 132 | 133 | meta: `${r.destination} · ${r.open} open · ${bytes(r.bytesIn)} in · ${bytes(r.bytesOut)} out`, |
| 133 | 134 | })); |
| @@ -140,7 +141,7 @@ export function connectionsScreen(f: Session, ctx: Ctx): Screen { | ||
| 140 | 141 | state.clamp(rows.length); |
| 141 | 142 | const [npm, head, list] = split(area, "v", [len(1), len(1), fill(1)]); |
| 142 | 143 | if (f.user?.isAdmin === true) { |
| 143 | const hour = egressHour(f.app.db); | |
| 144 | const hour = egressHour(f.app, Date.now(), read); | |
| 144 | 145 | const top = hour.npmTopRepoLastHour; |
| 145 | 146 | const most = top === null ? "." : `, most to ${top.repo} (${bytes(top.bytes)}).`; |
| 146 | 147 | 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 @@ | ||
| 9 | 9 | import { changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; |
| 10 | 10 | import { CAPACITY, belowCeiling, type CapacityKey } from "../../core/capacity.ts"; |
| 11 | 11 | import { bytes } from "../../system/format.ts"; |
| 12 | import { egressSnapshot } from "../../apps/egress.ts"; | |
| 12 | import { egressRowsFor } from "../../spawn/process_counters.ts"; | |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| 14 | 14 | import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts"; |
| 15 | 15 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| @@ -162,7 +162,7 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 162 | 162 | : ""}. |
| 163 | 163 | </p>`, |
| 164 | 164 | ); |
| 165 | const open = egressSnapshot(); | |
| 165 | const open = egressRowsFor(ctx.app); | |
| 166 | 166 | // The bun cache at work (#7546): a warm cache keeps this near zero. |
| 167 | 167 | const npm = html`The npm registry sent |
| 168 | 168 | <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 |
||
| 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 | }); |
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.