Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
61 changes: 59 additions & 2 deletions src/operator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ import {
operatorHomes,
markOperatorHome,
operatorHomeBoxMarks,
operatorHomeBoxBadges,
operatorIdentity,
operatorIdentityBadge,
OPERATOR_HOME_GLYPH,
operatorRead,
operatorNote,
Expand Down Expand Up @@ -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" } } } },
},
},
},
{
Expand Down Expand Up @@ -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" }],
},
]);
});
Expand All @@ -821,19 +830,67 @@ 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",
namespace: "chant-operator",
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<string, string> };
meta.name = "prod-converge";
meta.labels["app.kubernetes.io/instance"] = "prod-converge";
((second.attrs.spec as Record<string, never>) 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<string, unknown>).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<string, unknown>).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);
Expand Down
88 changes: 86 additions & 2 deletions src/operator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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<OperatorHome, "ticks">): 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.
*
Expand Down Expand Up @@ -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 [];
Expand All @@ -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;
}

/**
Expand All @@ -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) {
Expand All @@ -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 } : {}),
});
}
}
Expand Down Expand Up @@ -660,6 +719,31 @@ export function operatorHomeBoxMarks(ir: GraphIR, namespaceBoxes: Readonly<Recor
return out;
}

/**
* The identity badge for each operating-loop box (#357), keyed the same way
* `operatorHomeBoxMarks` keys its glyphs.
*
* The glyph says *this namespace is a loop's home*; this says **who it acts
* as** — the question "who is allowed to do this", answered off the picture
* instead of by opening a card. The name is Kubernetes RBAC: the ServiceAccount
* the tick's pods run under, which is what the cluster reads.
*
* Absence stays silent, which is #357's own requirement. A namespace with no
* loop gets nothing; a loop whose ticks named no account gets nothing rather
* than `default`, because below the tier that carries a pod spec "no account
* named" means the IR was not read deeply enough, not that the cluster will use
* `default`.
*/
export function operatorHomeBoxBadges(ir: GraphIR, namespaceBoxes: Readonly<Record<string, string>>): Record<string, string> {
const out: Record<string, string> = {};
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
Expand Down
24 changes: 24 additions & 0 deletions src/render.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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</text>");
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</text>");
});

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);
});
});
12 changes: 10 additions & 2 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string | GlyphSpec>>;
/** 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<Record<string, string>>;
} = {},
): RenderResult {
// Spacing nudge by edge density: the projected graph can be nearly complete
Expand All @@ -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, {
Expand Down
9 changes: 5 additions & 4 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
declaredConvergeOps,
markOperatorHome,
operatorHomeBoxMarks,
operatorHomeBoxBadges,
operatorNote,
verdictsForEnv,
operatorLogWindow,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down