assigned-sonarr proves the Servarr detection path: the runtime discovers its API key from the app's config.xml and serves its tools. assigned-grafana proves the settings path: the operator states URL and token as settings, the mesh merges them into the module's config file, and the runtime serves from that with nothing in the manifest. Both green. Claude-Session: https://claude.ai/code/session_01LrgweAeERJYBg88c5cKDzF
217 lines
9.8 KiB
TypeScript
217 lines
9.8 KiB
TypeScript
/**
|
|
* The mesh assigns grafana's tool runtime, configured entirely by the assignment's settings — the
|
|
* ADR 0051 + 0052 case: config is the assignment's, delivered as a settings-merged file the runtime
|
|
* reads, not a credential baked into the manifest.
|
|
*
|
|
* plex/sonarr prove a runtime that self-detects its key from the app's own config. This proves the
|
|
* other half: the operator states grafana's URL and an API token as settings for this node, the
|
|
* control plane merges them into the module's mergeable config file, and the runtime reads that file
|
|
* at start, registers grafana's tools, and serves them under its scoped account. There is no live
|
|
* Grafana — that the serve queue is bound is the proof the settings reached the runtime and its
|
|
* tools loaded from them.
|
|
*
|
|
* MESH_LAB_HOST_BINARY=.../mesh-host MESH_LAB_BUNDLE=.../examples/substrate-first-node.lock
|
|
* scripts/build-module-runtime.sh grafana builds mesh-runtime-grafana:development into the local
|
|
* daemon, which scenarios/grafana-node.yml stocks — so no MESH_LAB_RUNTIME here; the host pulls it.
|
|
*/
|
|
|
|
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 = "grafana-node";
|
|
const MACHINE = "anchor";
|
|
|
|
let instanceId = "";
|
|
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;
|
|
}
|
|
|
|
async function mesh(command: string, timeoutMs?: number): Promise<string> {
|
|
return must(`docker exec mesh-control /mesh-control ${command}`, timeoutMs);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 = 480_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;
|
|
|
|
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}`);
|
|
}
|
|
|
|
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 grafana's runtime, configured by settings, and it serves its tools", {
|
|
skip, timeout: 900_000,
|
|
}, async () => {
|
|
// A grafana manifest with no credential in it: its runtime, and a mergeable config file the
|
|
// settings will fill. This is the whole point of ADR 0051 — the manifest carries defaults and
|
|
// structure, the assignment carries the URL and token.
|
|
const manifest = JSON.stringify({
|
|
module: "grafana",
|
|
version: "1",
|
|
emits: ["module.grafana.alert.firing"],
|
|
"own-secrets": { broker: "/var/lib/mesh/grafana/broker" },
|
|
resources: [
|
|
{ id: "mesh-state", type: "directory", path: "/var/lib/mesh/grafana", mode: "0700" },
|
|
{ id: "config", type: "file", path: "/var/lib/mesh/grafana/config.json", mode: "0600", content: "{}\n", merge: "json" },
|
|
{
|
|
id: "runtime", type: "container", name: "mesh-grafana", image: pinned("mesh-runtime-grafana"),
|
|
network: "host",
|
|
volumes: [
|
|
"/var/lib/mesh/grafana/broker:/run/secrets/broker:ro",
|
|
"/var/lib/mesh/grafana/config.json:/run/config/config.json:ro",
|
|
],
|
|
env: {
|
|
MESH_BROKER_FILE: "/run/secrets/broker",
|
|
MESH_GRAFANA_CONFIG_FILE: "/run/config/config.json",
|
|
},
|
|
},
|
|
],
|
|
});
|
|
await must(`printf %s ${quote(manifest)} > /tmp/grafana.json && docker cp /tmp/grafana.json mesh-control:/grafana.json`);
|
|
await mesh("module add /grafana.json");
|
|
|
|
// The operator states grafana's URL and API token as settings for this node — the config the
|
|
// runtime will read. Nothing about them is in the manifest.
|
|
const settings = JSON.stringify({ url: "http://127.0.0.1:3000", token: "lab-grafana-token" });
|
|
await must(`printf %s ${quote(settings)} > /tmp/grafana-settings.json && docker cp /tmp/grafana-settings.json mesh-control:/grafana-settings.json`);
|
|
await mesh(`settings set grafana /grafana-settings.json --node ${MACHINE}`);
|
|
|
|
const issued = await mesh(`module issue grafana --node ${MACHINE}`);
|
|
assert.match(issued, /scoped to what it emits and consumes/, issued);
|
|
await mesh(`assign ${MACHINE} grafana`);
|
|
await mesh(`push ${MACHINE}`);
|
|
await settled();
|
|
|
|
const running = await must(`docker ps --format '{{.Names}}'`);
|
|
assert.match(running, /mesh-grafana/,
|
|
`grafana's runtime was assigned and is not running:\n${(await on(`tail -30 /var/log/mesh-host.log`)).out}`);
|
|
|
|
// The settings reached the node: the rendered config file carries what was set, not the manifest's
|
|
// empty default.
|
|
const config = await must(`cat /var/lib/mesh/grafana/config.json`);
|
|
assert.match(config, /lab-grafana-token/, `the settings did not merge into the config file:\n${config}`);
|
|
|
|
const credential = await must(`cat /var/lib/mesh/grafana/broker`);
|
|
assert.match(credential, /"url":"amqps:\/\/anchor-grafana:/, `not the scoped account:\n${credential}`);
|
|
assert.doesNotMatch(credential, /guest:guest/, "grafana's runtime holds the broker's own account");
|
|
|
|
// The runtime read that config, built its client from the settings-provided token, registered its
|
|
// tools, and bound their serve queues — the queue on the broker is the proof the settings-config
|
|
// path reached serving, with no credential in the manifest and no live Grafana.
|
|
let served = "";
|
|
const untilServing = Date.now() + 60_000;
|
|
while (Date.now() < untilServing) {
|
|
served = await must(`docker exec mesh-broker lavinmqctl list_queues name 2>&1 || true`);
|
|
if (/serve\.grafana\.grafana_status/.test(served)) break;
|
|
await new Promise((r) => setTimeout(r, 3000));
|
|
}
|
|
assert.match(served, /serve\.grafana\.grafana_status/,
|
|
`grafana's runtime never bound its serve queue (settings not read?):\n` +
|
|
`${(await on(`docker logs mesh-grafana 2>&1 | tail -20`)).out}\n---\n${served}`);
|
|
|
|
const users = await must(`docker exec mesh-broker lavinmqctl list_users 2>&1`);
|
|
assert.match(users, /anchor-grafana/, `the scoped account is not on the broker:\n${users}`);
|
|
});
|