diff --git a/package-lock.json b/package-lock.json index 24cc013..2edf943 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "@hono/node-server": "^2.0.5", "@intentius/chant": "^0.54.0", "@intentius/chant-k8s-client": "^0.54.0", - "@intentius/pinhole": "^0.3.7", + "@intentius/pinhole": "^0.3.8", "hono": "^4.12.34" }, "bin": { @@ -510,9 +510,9 @@ } }, "node_modules/@intentius/pinhole": { - "version": "0.3.7", - "resolved": "https://registry.npmjs.org/@intentius/pinhole/-/pinhole-0.3.7.tgz", - "integrity": "sha512-CUV/UPomPSoVtZZ7+T573C88CsiuhxHhLEPmTF93uX8qZPRbTC9IjTxjYoUm25N3DtCW294NndhqFq8mupEGoA==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@intentius/pinhole/-/pinhole-0.3.8.tgz", + "integrity": "sha512-FMeTu1eKk3kljcYWvcS+9+4InLfElM6HsK66Qq/iw/6+FMxvgYdSPH4XvKs+j192kopErIFN4T1RrlUvMzT4xw==", "dependencies": { "@dagrejs/dagre": "^3.0.0", "@intentius/chant": "^0.52.2" diff --git a/package.json b/package.json index 0fd9794..9c4bded 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,7 @@ "@hono/node-server": "^2.0.5", "@intentius/chant": "^0.54.0", "@intentius/chant-k8s-client": "^0.54.0", - "@intentius/pinhole": "^0.3.7", + "@intentius/pinhole": "^0.3.8", "hono": "^4.12.34" }, "devDependencies": { diff --git a/src/operator.test.ts b/src/operator.test.ts index e8217e9..a934904 100644 --- a/src/operator.test.ts +++ b/src/operator.test.ts @@ -26,6 +26,9 @@ import { operatorHomes, markOperatorHome, operatorHomeBoxMarks, + operatorHomeBoxBadges, + operatorIdentity, + operatorIdentityBadge, OPERATOR_HOME_GLYPH, operatorRead, operatorNote, @@ -779,7 +782,13 @@ function operatorEstate(): GraphIR { namespace: "chant-operator", labels: { ...common, "app.kubernetes.io/component": "converge-tick", "app.kubernetes.io/instance": "staging-converge" }, }, - spec: { schedule: "*/10 * * * *", concurrencyPolicy: "Forbid" }, + spec: { + schedule: "*/10 * * * *", + concurrencyPolicy: "Forbid", + // What OperatorStack really emits: the identity the tick acts with + // (#357), on the pod spec where the cluster reads it. + jobTemplate: { spec: { template: { spec: { serviceAccountName: "staging-converge-sa", restartPolicy: "OnFailure" } } } }, + }, }, }, { @@ -808,7 +817,7 @@ describe("markOperatorHome", () => { namespace: "chant-operator", node: "ns", stack: "chant-operator", - ticks: [{ node: "cron", op: "staging-converge", schedule: "*/10 * * * *" }], + ticks: [{ node: "cron", op: "staging-converge", schedule: "*/10 * * * *", serviceAccount: "staging-converge-sa" }], }, ]); }); @@ -821,6 +830,7 @@ describe("markOperatorHome", () => { namespace: "chant-operator", stack: "chant-operator", ticks: ["staging-converge"], + identity: { kind: "one", name: "staging-converge-sa" }, }); expect(by.get("cron")).toEqual({ role: "tick", @@ -828,12 +838,59 @@ describe("markOperatorHome", () => { stack: "chant-operator", op: "staging-converge", schedule: "*/10 * * * *", + serviceAccount: "staging-converge-sa", }); // The RBAC trio and an unrelated CronJob are left alone. expect(by.get("sa")).toBeUndefined(); expect(by.get("backup")).toBeUndefined(); }); + // #357: who the loop acts as. OperatorStack gives each hosted ConvergeOp its + // OWN ServiceAccount, so the home-level claim has three genuinely different + // answers and the badge says which. + it("reads the identity off the CronJob's pod spec, where the cluster reads it", () => { + const [home] = operatorHomes(operatorEstate()); + expect(home.ticks[0].serviceAccount).toBe("staging-converge-sa"); + expect(operatorIdentity(home)).toEqual({ kind: "one", name: "staging-converge-sa" }); + expect(operatorIdentityBadge(operatorIdentity(home))).toBe("runs as staging-converge-sa"); + }); + + it("says how many when the ticks run as different accounts — the ordinary least-privilege shape", () => { + const ir = operatorEstate(); + const second = JSON.parse(JSON.stringify(ir.nodes.find((n) => n.id === "cron"))) as (typeof ir.nodes)[number]; + second.id = "cron2"; + const meta = second.attrs.metadata as { name: string; labels: Record }; + meta.name = "prod-converge"; + meta.labels["app.kubernetes.io/instance"] = "prod-converge"; + ((second.attrs.spec as Record) as unknown as { jobTemplate: { spec: { template: { spec: { serviceAccountName: string } } } } }).jobTemplate.spec.template.spec.serviceAccountName = "prod-converge-sa"; + const [home] = operatorHomes({ ...ir, nodes: [...ir.nodes, second] }); + expect(home.ticks.map((t) => t.serviceAccount)).toEqual(["prod-converge-sa", "staging-converge-sa"]); + expect(operatorIdentity(home)).toEqual({ kind: "many", count: 2 }); + expect(operatorIdentityBadge(operatorIdentity(home))).toBe("runs as 2 identities"); + }); + + it("stays silent when no tick names an account — below detail 3 that means not read, never `default`", () => { + const ir = operatorEstate(); + const cron = ir.nodes.find((n) => n.id === "cron")!; + delete (cron.attrs.spec as Record).jobTemplate; + const [home] = operatorHomes(ir); + expect(home.ticks[0].serviceAccount).toBeUndefined(); + expect(operatorIdentity(home)).toEqual({ kind: "unknown" }); + expect(operatorIdentityBadge({ kind: "unknown" })).toBeUndefined(); + }); + + it("badges the loop's box by its structural key, and nothing else", () => { + const ir = operatorEstate(); + const boxes = { "chant-operator": "ns-box", apps: "apps-box" }; + expect(operatorHomeBoxBadges(ir, boxes)).toEqual({ "ns-box": "runs as staging-converge-sa" }); + // Absence stays silent (#357's own requirement): no loop, no badge; and a + // loop the projection never boxed gets none rather than inventing a box. + expect(operatorHomeBoxBadges(ir, { apps: "apps-box" })).toEqual({}); + const noSpec = operatorEstate(); + delete (noSpec.nodes.find((n) => n.id === "cron")!.attrs.spec as Record).jobTemplate; + expect(operatorHomeBoxBadges(noSpec, boxes)).toEqual({}); + }); + it("is strictly additive — an estate with no OperatorStack is returned untouched", () => { const plain = { nodes: [{ id: "a", kind: "K8s::Batch::CronJob", lexicon: "k8s", attrs: {} }], edges: [], groups: {} } as unknown as GraphIR; expect(markOperatorHome(plain)).toBe(plain); diff --git a/src/operator.ts b/src/operator.ts index bf606b1..8fe2bbb 100644 --- a/src/operator.ts +++ b/src/operator.ts @@ -501,6 +501,13 @@ export interface OperatorTickJob { op: string; /** The cron expression driving the beat, when the IR carries the spec. */ schedule?: string; + /** The ServiceAccount this tick's pods run as — Kubernetes RBAC, the identity + * the loop acts with (#357). Read from the CronJob's own pod spec + * (`spec.jobTemplate.spec.template.spec.serviceAccountName`), which is what + * the cluster actually uses, rather than matched by label to a ServiceAccount + * card. Absent when the IR is below the tier that carries the spec, or when + * the CronJob names none and the cluster will use `default`. */ + serviceAccount?: string; } /** One namespace that is an operating loop's home. */ @@ -515,6 +522,46 @@ export interface OperatorHome { ticks: OperatorTickJob[]; } +/** What identity the loop in one home runs as, as a home-level claim (#357). + * + * `OperatorStack` gives every hosted ConvergeOp its OWN ServiceAccount, so a + * home with two ticks normally has two identities and there is no single name + * to put on its box. The three cases are genuinely different and a renderer + * should say which it is: + * + * - `one` — every tick runs as the same account, and `name` is it. + * - `many` — the ticks run as `count` different accounts, which is the ordinary + * least-privilege shape; the names are on the tick cards. + * - `unknown` — no tick named an account. Below detail 3 the IR carries no pod + * spec at all, so this is usually "not read" rather than "not set", and it + * must not be reported as `default`. + */ +export interface OperatorIdentity { + kind: "one" | "many" | "unknown"; + /** The account, for `one`. */ + name?: string; + /** How many distinct accounts, for `many`. */ + count?: number; +} + +/** The identity claim for a home — see {@link OperatorIdentity}. */ +export function operatorIdentity(home: Pick): OperatorIdentity { + const names = [...new Set(home.ticks.flatMap((t) => (t.serviceAccount ? [t.serviceAccount] : [])))].sort(); + if (names.length === 0) return { kind: "unknown" }; + if (names.length === 1) return { kind: "one", name: names[0] }; + return { kind: "many", count: names.length }; +} + +/** The badge a home's box carries (#357, pinhole#122): the account when the + * loop runs as one, the count when it runs as several, and nothing at all when + * no tick named one — an absent badge says "not read", which is the honest + * claim below the tier that carries a pod spec. */ +export function operatorIdentityBadge(id: OperatorIdentity): string | undefined { + if (id.kind === "one") return `runs as ${id.name}`; + if (id.kind === "many") return `runs as ${id.count} identities`; + return undefined; +} + /** * Every operating-loop home in a graph, found from chant's own labels. * @@ -547,8 +594,14 @@ export function operatorHomes(ir: GraphIR): OperatorHome[] { delete home.stack; } } - const schedule = str(rec(n.attrs?.spec)?.schedule); - home.ticks.push({ node: n.id, op, ...(schedule ? { schedule } : {}) }); + const spec = rec(n.attrs?.spec); + const schedule = str(spec?.schedule); + // The identity the tick acts with (#357): the cluster reads it here, so + // behold does too — never by matching a ServiceAccount card's labels, which + // would agree with this only by construction and disagree silently when a + // caller overrides `defs.cronJob`. + const serviceAccount = str(rec(rec(rec(rec(spec?.jobTemplate)?.spec)?.template)?.spec)?.serviceAccountName); + home.ticks.push({ node: n.id, op, ...(schedule ? { schedule } : {}), ...(serviceAccount ? { serviceAccount } : {}) }); byNamespace.set(ns, home); } if (!byNamespace.size) return []; @@ -574,6 +627,10 @@ export interface OperatorMark { schedule?: string; /** The ConvergeOps hosted here (`role: "home"`). */ ticks?: string[]; + /** The ServiceAccount a tick runs as (`role: "tick"`), and the home's claim + * about its ticks' identities (`role: "home"`) — #357. */ + serviceAccount?: string; + identity?: OperatorIdentity; } /** @@ -594,6 +651,7 @@ export function markOperatorHome(ir: GraphIR): GraphIR { namespace: home.namespace, ...(home.stack ? { stack: home.stack } : {}), ticks: home.ticks.map((t) => t.op), + identity: operatorIdentity(home), }); } for (const tick of home.ticks) { @@ -603,6 +661,7 @@ export function markOperatorHome(ir: GraphIR): GraphIR { ...(home.stack ? { stack: home.stack } : {}), op: tick.op, ...(tick.schedule ? { schedule: tick.schedule } : {}), + ...(tick.serviceAccount ? { serviceAccount: tick.serviceAccount } : {}), }); } } @@ -660,6 +719,31 @@ export function operatorHomeBoxMarks(ir: GraphIR, namespaceBoxes: Readonly>): Record { + const out: Record = {}; + for (const home of operatorHomes(ir)) { + const box = namespaceBoxes[home.namespace]; + const badge = operatorIdentityBadge(operatorIdentity(home)); + if (box && badge) out[box] = badge; + } + return out; +} + // ── The strip ──────────────────────────────────────────────────────────────── /** How a lease reads. `expired` is its own state, not a shade of `free`: a lease diff --git a/src/render.test.ts b/src/render.test.ts index ab3d039..1df429c 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -481,4 +481,28 @@ describe("renderArchitecture — group marks (pinhole#119, behold#331)", () => { expect(withEmptyOpts).toBe(withNoOpts); expect(withNoOpts).not.toContain(GLYPH_MARKER); }); + + // #357 (pinhole#122): the same plumbing for TEXT. A mark says what kind of + // box this is; a badge says a name the box asserts — here, the identity the + // operating loop runs as. + it("badges the box whose container KEY is in groupBadges, and only that one", () => { + const { svg } = renderArchitecture(archIr, byContainer, { groupBadges: { "namespace prod": "runs as staging-converge-sa" } }); + expect(svg).toContain(">runs as staging-converge-sa"); + expect(svg).toContain('text-anchor="end"'); + const wrongKey = renderArchitecture(archIr, byContainer, { groupBadges: { "namespace other": "runs as x" } }).svg; + expect(wrongKey).toBe(renderArchitecture(archIr, byContainer).svg); + }); + + it("carries a mark and a badge on one box without either displacing the other", () => { + const { svg } = renderArchitecture(archIr, byContainer, { + groupMarks: { "namespace prod": OPERATOR_HOME_GLYPH }, + groupBadges: { "namespace prod": "runs as sa" }, + }); + expect(svg).toContain(GLYPH_MARKER); + expect(svg).toContain(">runs as sa"); + }); + + it("no groupBadges option renders byte-identical, so an estate with no loop is untouched", () => { + expect(renderArchitecture(archIr, byContainer, { groupBadges: {} }).svg).toBe(renderArchitecture(archIr, byContainer).svg); + }); }); diff --git a/src/render.ts b/src/render.ts index 834a7ff..1112ac2 100644 --- a/src/render.ts +++ b/src/render.ts @@ -100,6 +100,11 @@ export function renderArchitecture( * caller never has to know a box's rendered title. Absent = every box * renders exactly as before this option existed. */ groupMarks?: Readonly>; + /** Text badges for boxes, keyed the same way `groupMarks` is (pinhole#122, + * behold#357). A mark says what KIND of box this is; a badge says a name + * the box asserts — the identity an operating loop runs as. Absent = no box + * carries one, and the SVG is byte-identical to before this existed. */ + groupBadges?: Readonly>; } = {}, ): RenderResult { // Spacing nudge by edge density: the projected graph can be nearly complete @@ -114,10 +119,13 @@ export function renderArchitecture( nodesep: Math.round(48 + spread * 48), ranksep: Math.round(60 + spread * 56), }); - if (opts.groupMarks) { + if (opts.groupMarks || opts.groupBadges) { for (const box of layout.groups ?? []) { - const mark = box.id !== undefined ? opts.groupMarks[box.id] : undefined; + if (box.id === undefined) continue; + const mark = opts.groupMarks?.[box.id]; if (mark !== undefined) box.mark = mark; + const badge = opts.groupBadges?.[box.id]; + if (badge !== undefined) box.badge = badge; } } const svg = renderSvg(ir, layout, { diff --git a/src/server.ts b/src/server.ts index 8702a01..5caf547 100644 --- a/src/server.ts +++ b/src/server.ts @@ -96,6 +96,7 @@ import { declaredConvergeOps, markOperatorHome, operatorHomeBoxMarks, + operatorHomeBoxBadges, operatorNote, verdictsForEnv, operatorLogWindow, @@ -1938,7 +1939,7 @@ export function createApp( // detector as `markOperatorHome` above, run again against the box // KEYS the projection just minted — see src/operator.ts's // `operatorHomeBoxMarks`. - const { svg } = renderArchitecture(projected, byContainer, { groupMarks: operatorHomeBoxMarks(ir, namespaceBoxes ?? {}) }); + const { svg } = renderArchitecture(projected, byContainer, { groupMarks: operatorHomeBoxMarks(ir, namespaceBoxes ?? {}), groupBadges: operatorHomeBoxBadges(ir, namespaceBoxes ?? {}) }); const logicalNote = notesFor("logical", projected, undefined, logicalBefore); return c.json({ ir: projected, @@ -2038,7 +2039,7 @@ export function createApp( // never calls `markOperatorHome` on `base` (it returns before the // entity branch below would), so the box mark is derived straight from // `operatorHomes` — the same detector, not a second one. - const { svg } = renderArchitecture(projected, byContainer, { groupMarks: operatorHomeBoxMarks(base, namespaceBoxes ?? {}) }); + const { svg } = renderArchitecture(projected, byContainer, { groupMarks: operatorHomeBoxMarks(base, namespaceBoxes ?? {}), groupBadges: operatorHomeBoxBadges(base, namespaceBoxes ?? {}) }); // `byContainer` rides along (behold#100): the nesting IS the projection's // primary output, and until now it was only observable by reading the // rendered SVG, which is not something an acceptance run can assert on. @@ -2484,7 +2485,7 @@ export function createApp( const { ir: projected, byContainer, namespaceBoxes } = projectTopology(ir, env, boundContext, await estateSourceRoots(query)); // #234's free rider, the logical lens's half (pinhole#119) — see // /api/graph's estate branch. - const { svg } = renderArchitecture(projected, byContainer, { groupMarks: operatorHomeBoxMarks(ir, namespaceBoxes ?? {}) }); + const { svg } = renderArchitecture(projected, byContainer, { groupMarks: operatorHomeBoxMarks(ir, namespaceBoxes ?? {}), groupBadges: operatorHomeBoxBadges(ir, namespaceBoxes ?? {}) }); const note = [ notesFor("logical", projected, undefined, logicalBefore), coverNote, @@ -2607,7 +2608,7 @@ export function createApp( // continuation below would), so the box mark is derived straight from // `operatorHomes` on the same pre-projection IR — the same detector, // not a second one. - const { svg } = renderArchitecture(projected, byContainer, { groupMarks: operatorHomeBoxMarks(projectionInput, namespaceBoxes ?? {}) }); + const { svg } = renderArchitecture(projected, byContainer, { groupMarks: operatorHomeBoxMarks(projectionInput, namespaceBoxes ?? {}), groupBadges: operatorHomeBoxBadges(projectionInput, namespaceBoxes ?? {}) }); // See /api/graph's logical branch — `byContainer` is carried for the // same reason (behold#100). The wrong-tier note (#158) joins here too: // the logical view collapses to near-empty at a wrong tier exactly as