tools-confluence: the tools-only bed for confluence (serves 3 tools, no creds)

Sixth green regression bed. Same shape as tools-gitlab: a runtime-only module comes
up under the mesh, serves its full tool surface with no valid credentials (the Servarr
lesson), stays up, binds its serve queues, and gets its scoped broker account.
SUITE_EXIT=0.

Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
This commit is contained in:
2026-09-06 01:54:21 +02:00
parent 0b8f583f75
commit e703a94fcd
2 changed files with 283 additions and 0 deletions
+41
View File
@@ -0,0 +1,41 @@
# One machine that becomes a mesh and is then assigned confluence — a tools-only, outbound-only
# external-SaaS integration (novox/hq ADR 0039), the same shape gitlab proved. confluence has NO
# service container, NO listener, NO provisioner — just a broker-bound runtime that serves the
# module's Confluence tools under a scoped account. It only ever calls out to a Confluence instance.
#
# The sharp point this bed proves is the Servarr lesson: the runtime MUST come up and serve its full
# tool surface even with NO valid Confluence credentials — the lab has no real Confluence, and
# confluence's own token is a mesh-minted own-secret that points at nothing. confluence's client is
# built lazily and never throws at registration, so the runtime logs `[mesh-tools] serving 3 tool(s)`
# and binds its serve queues regardless; a tool would only fail if it were actually invoked.
#
# MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock
# scripts/build-module-runtime.sh confluence builds mesh-runtime-confluence:development into the
# local daemon, which this scenario stocks and serves by digest from its own registry. confluence
# needs no service image — it is tools-only.
scenario: tools-confluence
segments:
hosting:
kind: public
cidr: [192.0.2.0/24]
machines:
anchor:
at: { segment: hosting, address: [192.0.2.10] }
inbound: allow
memory: 3GiB
cpus: 2
images:
# The first-node substrate: store, broker, control.
- postgres:17-alpine
- cloudamqp/lavinmq:latest
- mesh-control:development
# confluence's runtime, built by scripts/build-module-runtime.sh confluence into the local daemon
# and stocked into the scenario's own registry, which is where the host pulls it from. There is no
# service image: confluence is tools-only and outbound-only.
- mesh-runtime-confluence:development
place:
all: [host, runtime]
@@ -0,0 +1,242 @@
/**
* The mesh assigns confluence — a tools-only, outbound-only external-SaaS integration
* (novox/hq ADR 0039), the shape gitlab proved — and its runtime comes up and serves the module's
* full tool surface, with NO valid Confluence credentials.
*
* confluence is the thinnest converted shape: no service container, no listener, no provisioner —
* only a broker-bound runtime that serves the module's Confluence tools under a scoped account
* (ADR 0047). The sharp point (the Servarr lesson) is that the runtime MUST register and serve every
* tool even though the lab has no real Confluence and confluence's own token is a mesh-minted
* own-secret pointing at nothing. confluence's client is built lazily and never throws at
* registration, so:
* - the mesh-runtime-confluence container comes up and STAYS up (no crash on a missing token);
* - it logs `[mesh-tools] serving 3 tool(s)` — the install proof;
* - it binds its serve queues on the broker (e.g. serve.confluence.confluence_search), so the tools
* are actually reachable, not merely loaded;
* - it got its own scoped broker account (anchor-confluence).
* A tool would only fail if it were actually invoked without real creds — which this bed does not do,
* because the point is exactly that serving does not require them.
*
* MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock
* scripts/build-module-runtime.sh confluence builds mesh-runtime-confluence:development into the
* local daemon; scenarios/tools-confluence.yml stocks it. There is no service image.
*/
import { test, before, after } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { loadScenario } from "../../src/declaration/parse.ts";
import { raise } from "../../src/lifecycle/raise.ts";
import { destroy, exec } from "../../src/lifecycle/operate.ts";
import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts";
import { labIsUsable, destroyAll } from "./harness.ts";
const capability = await labIsUsable();
const binary = hostBinaryPath();
const bundle = process.env["MESH_LAB_BUNDLE"] ?? "";
const skip = !capability.usable
? `lab not usable: ${capability.why}`
: !binary || !existsSync(binary)
? "MESH_LAB_HOST_BINARY is not set to a built mesh-host"
: !bundle || !existsSync(bundle)
? "MESH_LAB_BUNDLE is not set to a substrate bundle (mesh-host examples/)"
: false;
const SCENARIO = "tools-confluence";
const MACHINE = "anchor";
let instanceId = "";
/** What the scenario's registry serves, by digest. */
let stocked: string[] = [];
function quote(s: string): string {
return `'${s.replaceAll("'", `'\\''`)}'`;
}
async function on(command: string, timeoutMs?: number): Promise<{ out: string; ok: boolean }> {
const { stdout } = await exec(instanceId, MACHINE, [
"sh", "-c", `exec 2>&1\n${command}\necho "__exit=$?"`,
], timeoutMs);
const marker = stdout.lastIndexOf("__exit=");
if (marker < 0) return { out: stdout, ok: false };
return { out: stdout.slice(0, marker), ok: stdout.slice(marker + 7).trim() === "0" };
}
async function must(command: string, timeoutMs?: number): Promise<string> {
const { out, ok } = await on(command, timeoutMs);
if (!ok) throw new Error(`${MACHINE}: ${command}\n${out}`);
return out;
}
/** The control plane, a container on the node. */
async function mesh(command: string, timeoutMs?: number): Promise<string> {
return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs);
}
/** The pinned reference for one of the scenario's images, by repository. */
function pinned(repository: string): string {
const found = stocked.find((r) => r.slice(r.indexOf("/") + 1, r.indexOf("@")) === repository);
assert.ok(found, `the scenario stocks no ${repository}; it serves ${stocked.join(", ")}`);
return found;
}
/** The substrate bundle, its image references pointed at this scenario's own registry. */
function bundleFor(images: string[]): string {
let text = readFileSync(bundle, "utf8");
for (const ref of images) {
const repository = ref.slice(ref.indexOf("/") + 1, ref.indexOf("@"));
const escaped = repository.replaceAll("/", "\\/").replaceAll(".", "\\.");
text = text.replaceAll(new RegExp(`[A-Za-z0-9_.:-]+\\/${escaped}@sha256:[0-9a-f]+`, "g"), ref);
}
return text;
}
function tokenFrom(said: string): string {
const found = said.split("\n").map((l) => l.trim()).find((l) => l.length > 100 && !l.includes(" "));
assert.ok(found, `no token in:\n${said}`);
return found;
}
async function settled(withinMs = 600_000): Promise<void> {
const until = Date.now() + withinMs;
let last = "";
while (Date.now() < until) {
const asked = await on(`docker exec mesh-control /mesh-control status --json`);
if (asked.ok) {
try {
const state = JSON.parse(asked.out) as {
wrong: { node: string; outcome: string }[];
waiting: { node: string }[];
reported: { node: string; outcome: string; current: boolean }[];
};
const bad = state.wrong.find((w) => w.node === MACHINE);
if (bad) throw new Error(`${MACHINE} did not apply what it was sent: ${bad.outcome}\n${asked.out}`);
const word = state.reported.find((r) => r.node === MACHINE);
if (!state.waiting.some((w) => w.node === MACHINE) && word?.outcome === "applied" && word.current) return;
last = asked.out;
} catch (err) {
if (err instanceof Error && err.message.includes("did not apply")) throw err;
last = asked.out;
}
}
await new Promise((r) => setTimeout(r, 5000));
}
throw new Error(`${MACHINE} never caught up within ${Math.round(withinMs / 1000)}s. Last:\n${last}`);
}
before(async () => {
if (skip) return;
const raised = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {
onProgress: (m) => console.log(`raise: ${m}`),
});
instanceId = raised.instanceId;
stocked = raised.images;
// Raise the substrate — store, broker, control — from the bundle.
await must(`cat > /tmp/substrate.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`);
await must(`${HOST_PATH} apply /tmp/substrate.lock`, 600_000);
const up = await must(`docker ps --format '{{.Names}}'`);
for (const c of ["mesh-store", "mesh-broker", "mesh-control"]) {
assert.match(up, new RegExp(c), `the substrate did not raise ${c}:\n${up}`);
}
// The node joins its own mesh, so it is a node the mesh can assign to, and start the host so it
// applies what it is pushed.
await mesh(`node add ${MACHINE}`);
const token = tokenFrom(await mesh(`token issue --node ${MACHINE}`));
await must(`${HOST_PATH} enrol --token ${quote(token)}`);
await must(`nohup ${HOST_PATH} run > /var/log/mesh-host.log 2>&1 & sleep 3`);
}, { timeout: 1_800_000 });
after(async () => {
if (instanceId) await destroy(instanceId);
await destroyAll(`${SCENARIO}-`);
}, { timeout: 600_000 });
test("the mesh assigns confluence: its tools-only runtime comes up and serves the full tool surface with no valid token", {
skip, timeout: 1_500_000,
}, async () => {
// confluence is tools-only and outbound-only: no service, no listener, no provisioner — just a
// broker-bound runtime serving the module's tools. Its own token is a mesh-minted own-secret; the
// lab has no real Confluence, so the token points at nothing — and that is the case under test: the
// runtime must serve every tool regardless. The runtime container name and shape mirror the
// committed manifest, with the image pinned to what this scenario serves by digest.
const manifest = JSON.stringify({
module: "confluence",
version: "1",
"own-secrets": {
token: "/var/lib/confluence/token",
broker: "/var/lib/mesh/confluence/broker",
},
resources: [
{ id: "mesh-state", type: "directory", path: "/var/lib/mesh/confluence", mode: "0700" },
{ id: "state", type: "directory", path: "/var/lib/confluence", mode: "0700" },
{ id: "config", type: "file", path: "/var/lib/confluence/config.json", merge: "json", content: "{}", mode: "0600" },
{
id: "runtime", type: "container", name: "mesh-runtime-confluence",
image: pinned("mesh-runtime-confluence"), network: "host",
volumes: [
"/var/lib/confluence/config.json:/run/config/config.json:ro",
"/var/lib/confluence/token:/run/secrets/token:ro",
"/var/lib/mesh/confluence/broker:/run/secrets/broker:ro",
],
env: {
MESH_CONFLUENCE_TOKEN_FILE: "/run/secrets/token",
MESH_CONFLUENCE_CONFIG_FILE: "/run/config/config.json",
MESH_BROKER_FILE: "/run/secrets/broker",
},
},
],
});
await must(`printf %s ${quote(manifest)} > /tmp/confluence.json && docker cp /tmp/confluence.json mesh-control:/confluence.json`);
await mesh("module add /confluence.json");
// confluence serves tools, so it is issued a scoped broker account (it emits/consumes nothing else).
const issued = await mesh(`module issue confluence --node ${MACHINE}`);
assert.match(issued, /scoped to what it emits and consumes/, issued);
await mesh(`assign ${MACHINE} confluence`);
// ONE push, ONE convergence.
await mesh(`push ${MACHINE}`);
await settled();
// --- the runtime is up and STABLE — it did not crash on a missing/invalid token -----------------
const running = await must(`docker ps --format '{{.Names}}'`);
assert.match(running, /(^|\n)mesh-runtime-confluence(\n|$)/,
`mesh-runtime-confluence is not running after the push:\n${running}\n---host log---\n${(await on(`tail -40 /var/log/mesh-host.log`)).out}`);
await new Promise((r) => setTimeout(r, 5000));
const state = (await must(`docker inspect -f '{{.State.Running}} {{.RestartCount}}' mesh-runtime-confluence`)).trim();
assert.equal(state, "true 0",
`the confluence runtime is not up and stable — it should serve tools even with no valid token:\n${state}\n` +
`${(await on(`docker logs mesh-runtime-confluence 2>&1 | tail -30`)).out}`);
// --- the install proof: the runtime logs it is serving the full tool surface --------------------
let log = "";
const untilLogged = Date.now() + 60_000;
while (Date.now() < untilLogged) {
log = (await on(`docker logs mesh-runtime-confluence 2>&1`)).out;
if (/\[mesh-tools\] serving \d+ tool\(s\)/.test(log)) break;
await new Promise((r) => setTimeout(r, 3000));
}
const served = log.match(/\[mesh-tools\] serving (\d+) tool\(s\)/);
assert.ok(served, `the confluence runtime never logged it is serving tools:\n${log}`);
assert.equal(Number(served[1]), 3,
`the confluence runtime served ${served?.[1]} tools, expected the full surface of 3:\n${log}`);
// --- the tools are actually reachable: the runtime bound its serve queues on the broker ---------
let served2 = "";
const untilServing = Date.now() + 90_000;
while (Date.now() < untilServing) {
served2 = await must(`docker exec mesh-broker lavinmqctl list_queues name 2>&1 || true`);
if (/serve\.confluence\.confluence_search/.test(served2)) break;
await new Promise((r) => setTimeout(r, 3000));
}
assert.match(served2, /serve\.confluence\.confluence_search/,
`confluence's runtime never bound its serve queue:\n${(await on(`docker logs mesh-runtime-confluence 2>&1 | tail -20`)).out}\n---\n${served2}`);
// --- confluence got its own scoped broker account -----------------------------------------------
const users = await must(`docker exec mesh-broker lavinmqctl list_users 2>&1`);
assert.match(users, /anchor-confluence/, `the scoped account anchor-confluence is not on the broker:\n${users}`);
});