diff --git a/modules/mesh-catalog/heard.ts b/modules/mesh-catalog/heard.ts new file mode 100644 index 00000000..4dab234f --- /dev/null +++ b/modules/mesh-catalog/heard.ts @@ -0,0 +1,96 @@ +// What the catalogue makes of a build outcome it hears (novox/hq issue 712). +// +// Kept apart from index.ts, which subscribes at its top level, so the decision can be tested without a bus. + +import type { Made } from "./store.js"; + +/** What the build seat says when it has built something, and what the control plane replays. */ +export interface Built { + module?: string; + commit?: string; + repository?: string; + path?: string; + ref?: string; + manifest?: unknown; + /** What this build published, each pinned as anything else would name it. */ + made?: Made[]; + /** + * Every artifact this was built on top of, so the edge is derived rather than declared. + * + * References, not module names: the builder sees a pinned image and cannot see which module + * produced it. Turning that into an edge between module-versions is this module's job. + */ + against?: string[]; + /** + * This is history, not news — the mesh re-announcing a build this catalogue was not there for. + * + * Registered exactly as any other, and announced as nothing. A module that moved months ago is + * not something anything should act on now: emitting `upgraded` would have the control plane + * decide about a rollout, and `rebuild-needed` would ask for builds of things that are already + * current. + */ + replay?: boolean; + /** Why the build failed; a failed build made no module-version. */ + failed?: string; + /** A dry run is looked at, never taken in (novox/hq issue 240). */ + "dry-run"?: boolean; + /** A pull request's merge check or a branch tried before one (to-be 45 §9, issue 686): nothing merged. */ + "check-outcome"?: unknown; + check?: unknown; + /** The repository's default branch as the build seat read the forge at the build (ADR 0238); empty from a seat older than the rule. */ + trunk?: string; + /** Whether the commit is on `trunk`. */ + "on-trunk"?: boolean; + /** The branches the commit is on. */ + branches?: string[]; +} + +/** The events a build outcome reaches this module on: the build seat's, as it happens, and the control plane's replay. */ +export const BUILD_EVENTS = [ + // **The build seat that builds now** (novox/hq ADR 0190). Until issue 712 the catalogue heard only the + // retired seat below, so every build after the handover reached the graph only through the replay this + // module asks for when it starts: a merge built and pushed stayed out of the catalogue until its next start. + "node-build-agent.built", + // The retired build role, while a machine still serves it (ADR 0190's handover). Goes with the seat row. + "mesh-build-machine.built", + // What the mesh already held, when this module asks what it missed (novox/hq ADR 0134). + "mesh-controller.built-before", +] as const; + +/** + * Whether a build outcome is a module-version to place in the graph, and why not when it is not. + * + * It mirrors two of the rules the controller keeps before it registers a build (mesh-controller + * cmd/mesh-controller/main.go `builds.Built`, build.go `publishable`): a merge check, a try and a dry run are + * never published, and **only a commit on its repository's trunk is** (ADR 0238) — a branch built through + * the `build` verb is checked, never a module's version. Where the seat names no trunk (one older than the + * rule) it is placed, as the controller lets it through. A failure made nothing and is passed over too. + * + * Not mirrored, because the catalogue cannot see what they read: the branch a module follows when it is not + * the default one (the controller's `follows`; such a module's builds off the default branch are passed over + * here and reach the graph by the replay), ADR 0266's rule that a module registers only from the repository + * it is built from, a superseded request, a build in the scratch namespace, one that failed its gate, and a + * build whose source is unchanged. Each needs the controller's records. This is safe for what the mesh + * sends, which is chosen from the controller's own module table and never from this graph; that the graph + * can still hold a build the controller refused under those rules is a follow-up (hq issue 712). + */ +export function placeable(body: Built): + | { placed: "yes"; module: string; commit: string } + | { placed: "no"; why: string; fault: boolean } { + if (body["check-outcome"] != null || body.check != null) { + return { placed: "no", why: "a merge check or a try: nothing of it merged", fault: false }; + } + if (body["dry-run"]) { + return { placed: "no", why: "a dry run is looked at, never taken in", fault: false }; + } + if (body.failed) { + return { placed: "no", why: "a failed build made no module-version", fault: false }; + } + if (body.trunk && !body["on-trunk"] && !(body.branches ?? []).includes(body.trunk)) { + return { placed: "no", why: `the commit is not on its repository's trunk ${body.trunk}: checked, never published (ADR 0238)`, fault: false }; + } + if (!body.module || !body.commit) { + return { placed: "no", why: "it named no module or no commit", fault: true }; + } + return { placed: "yes", module: body.module, commit: body.commit }; +} diff --git a/modules/mesh-catalog/index.ts b/modules/mesh-catalog/index.ts index 74f218e8..13a98d2d 100644 --- a/modules/mesh-catalog/index.ts +++ b/modules/mesh-catalog/index.ts @@ -10,7 +10,8 @@ // diamond need no special handling, and nothing holds a plan. import { on, emit } from "@novox/mesh-sdk/events"; -import { Graph, type Made } from "./store.js"; +import { Graph } from "./store.js"; +import { BUILD_EVENTS, placeable, type Built } from "./heard.js"; const graph = Graph.fromEnv(); @@ -21,54 +22,29 @@ const graph = Graph.fromEnv(); // would block the very apply that brings the overlay up — stopped being true when a step's failure // became this module's business and not the machine's (ADR 0136). -/** What the builder says when it has built something. */ -interface Built { - module?: string; - commit?: string; - repository?: string; - path?: string; - ref?: string; - manifest?: unknown; - /** What this build published, each pinned as anything else would name it. */ - made?: Made[]; - /** - * Every artifact this was built on top of, so the edge is derived rather than declared. - * - * References, not module names: the builder sees a pinned image and cannot see which module - * produced it. Turning that into an edge between module-versions is this module's job. - */ - against?: string[]; - /** - * This is history, not news — the mesh re-announcing a build this catalogue was not there for. - * - * Registered exactly as any other, and announced as nothing. A module that moved months ago is - * not something anything should act on now: emitting `upgraded` would have the control plane - * decide about a rollout, and `rebuild-needed` would ask for builds of things that are already - * current. - */ - replay?: boolean; -} - /** * What a build means for the graph, wherever it came from. * * Two emitters say the same thing and neither is a mistake: the build machine says it as it happens, * and the control plane says what it already held when this module asks what it missed * (novox/hq ADR 0134). A replay is marked as one in its body, so nothing acts on a module that moved - * months ago — see `replay` above. + * months ago — see `replay` in heard.ts. */ const placeTheBuild = async (event: { body: unknown }): Promise => { const body = (event.body ?? {}) as Built; - if (!body.module || !body.commit) { - // Said rather than dropped: a build that announced itself without saying what it built is a - // fault in the builder, and a silent discard here would make it look like a missing event. - console.error("mesh-catalog: a build event named no module or no commit; ignored", body); + const placing = placeable(body); + if (placing.placed === "no") { + // A check, a try, a dry run and a failure are the build seat's ordinary outcomes: passed over. One + // that names no module or commit is a fault in the builder, said rather than dropped, so it does + // not look like a missing event. + if (placing.fault) console.error(`mesh-catalog: a build event was not placed: ${placing.why}`, body); return; } + const { module, commit } = placing; const { upgraded, previous } = await graph.register({ - module: body.module, - commit: body.commit, + module, + commit, repository: body.repository ?? "", path: body.path ?? "", ref: body.ref ?? "", @@ -80,7 +56,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise => { if (body.replay) return; await emit("registered", { - module: body.module, commit: body.commit, upgraded, + module, commit, upgraded, }); // **A rebuild that changed nothing is not an upgrade.** Announcing it would ripple outward @@ -88,7 +64,7 @@ const placeTheBuild = async (event: { body: unknown }): Promise => { if (!upgraded) return; await emit("upgraded", { - module: body.module, commit: body.commit, previous, + module, commit, previous, }); // What can be built now — stale, and waiting on nothing that is itself stale. @@ -101,9 +77,9 @@ const placeTheBuild = async (event: { body: unknown }): Promise => { } }; -// As it happens, and what the mesh already held when this module asked what it missed. -await on("mesh-build-machine.built", placeTheBuild); -await on("mesh-controller.built-before", placeTheBuild); +// As it happens — from the build seat that builds now, and the retired one while it still answers — and +// what the mesh already held when this module asked what it missed (novox/hq issue 712). +for (const heard of BUILD_EVENTS) await on(heard, placeTheBuild); // **And ask for what was built before this catalogue existed** (novox/hq 04-ISSUES/050). // diff --git a/modules/mesh-catalog/module.json b/modules/mesh-catalog/module.json index b5f69958..75f0a6d0 100644 --- a/modules/mesh-catalog/module.json +++ b/modules/mesh-catalog/module.json @@ -26,6 +26,7 @@ "postgres-database": "${dir:state}/database.secret" }, "consumes": [ + "node-build-agent.built", "mesh-build-machine.built", "mesh-controller.built-before" ], diff --git a/modules/mesh-catalog/test/heard.test.ts b/modules/mesh-catalog/test/heard.test.ts new file mode 100644 index 00000000..e00daa1b --- /dev/null +++ b/modules/mesh-catalog/test/heard.test.ts @@ -0,0 +1,72 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; + +import { BUILD_EVENTS, placeable } from "../heard.js"; + +// A walk's build as the build seat says it (mesh-controller's link.BuildResult), here mailu's of novox/hq issue 712. +const walkBuild = { + id: "build-1791997333800041716", + repository: "http://novox.internal:20000/novox/mesh-catalog.git", + path: "modules/mailu", + ref: "main", + on: "shanks", + trunk: "main", + "on-trunk": true, + branches: ["main"], + module: "mailu", + commit: "e1c8965ddbf1cf9af9cf1eed7a72ae9ffa6d5a87", + manifest: { module: "mailu", unhealthy: { severity: "urgent", why: "mail stops" } }, + made: [{ name: "code", kind: "bundle", reference: "artifact-store://mailu/code/blobs/sha256:d041" }], +}; + +test("the catalogue hears the build seat that builds now, not only the retired one (issue 712)", async () => { + assert.ok(BUILD_EVENTS.includes("node-build-agent.built")); + assert.ok(BUILD_EVENTS.includes("mesh-controller.built-before")); + // Subscribing is not enough: the mesh grants and routes only what the manifest consumes. + const manifest = JSON.parse(await readFile(new URL("../module.json", import.meta.url), "utf8")); + for (const heard of BUILD_EVENTS) assert.ok(manifest.consumes.includes(heard), `${heard} is not consumed`); +}); + +test("a walk's build is placed, under its module and commit", () => { + assert.deepEqual(placeable(walkBuild), { placed: "yes", module: "mailu", commit: walkBuild.commit }); + // A walk that took the check's build of the same key says so, and is placed all the same (ADR 0350 §2). + assert.equal(placeable({ ...walkBuild, reused: "the check's build" } as never).placed, "yes"); +}); + +test("a merge check, a try, a dry run and a failure are passed over without a fault", () => { + for (const [body, why] of [ + [{ ...walkBuild, "check-outcome": { verdict: "passed" } }, "merge check"], + [{ ...walkBuild, check: { try: true } }, "merge check or a try"], + [{ ...walkBuild, "dry-run": true }, "dry run"], + [{ ...walkBuild, failed: "exit status 1" }, "failed"], + ] as const) { + const placing = placeable(body); + assert.equal(placing.placed, "no"); + if (placing.placed === "no") { + assert.match(placing.why, new RegExp(why)); + assert.equal(placing.fault, false); + } + } +}); + +test("an outcome that names no module or commit is said as the builder's fault", () => { + const placing = placeable({ repository: walkBuild.repository, commit: walkBuild.commit }); + assert.equal(placing.placed, "no"); + if (placing.placed === "no") assert.equal(placing.fault, true); +}); + +test("a branch built through the build verb is passed over; a commit on the trunk, or from a seat that names none, is placed (ADR 0238)", () => { + const branch = { ...walkBuild, ref: "feature", "on-trunk": false, branches: ["feature"] }; + const placing = placeable(branch); + assert.equal(placing.placed, "no"); + if (placing.placed === "no") { + assert.match(placing.why, /not on its repository's trunk main/); + assert.equal(placing.fault, false); + } + // On the trunk by its branches although the seat did not set on-trunk. + assert.equal(placeable({ ...branch, branches: ["feature", "main"] }).placed, "yes"); + // A seat older than the rule says no trunk: placed, as the controller lets it through. + const { trunk, "on-trunk": _on, branches, ...older } = walkBuild; + assert.equal(placeable(older).placed, "yes"); +}); diff --git a/modules/mesh-catalog/tsconfig.json b/modules/mesh-catalog/tsconfig.json index 4e95d4fa..c1eaae10 100644 --- a/modules/mesh-catalog/tsconfig.json +++ b/modules/mesh-catalog/tsconfig.json @@ -11,6 +11,7 @@ "include": [ "pg.d.ts", "store.ts", + "heard.ts", "index.ts", "tools/index.ts", "prepare/index.ts"