One repository, two modules (ADR 0069). `node-tools/` holds the runtime — its code, tests, package and the manifest of the module the controller composes a process for on every machine it is assigned to: a bundle of `src/main.js`, the interpreter as a package, a place for the node's credential, the loopback port the console declared, and leave to call every tool. Nothing about how it runs: which bundles to load, where the credential is and whose machine it is are the controller's to compose (WP2). The root module `mesh-tools` keeps the two images TypeScript bundles are compiled in and a module's own service may run in; it is no longer how tools reach a node. As node-tools, `serve` is also the console (ADR 0175 §6): the same process answers MCP on loopback for whoever is on the machine, through which the tools it serves can be called. A module's own runtime in a container keeps serving without a listener. The toolchain image now carries /app/runtime — a package.json saying the compiled files are ES modules and the production node_modules — for the builder to copy into every TypeScript bundle, so a bundle unpacked on a machine starts (ADR 0188 §5; the builder's side is the controller's). Proven here by compiling node-tools with the toolchain's exact flags and starting the result. The AMQP probe script is gone with the bus it probed.
81 lines
4.6 KiB
TypeScript
81 lines
4.6 KiB
TypeScript
import { spawn } from "node:child_process";
|
|
import { test } from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { fatalBrokerReason, PinMismatchError, topicMatches } from "../src/broker-nats.ts";
|
|
|
|
// novox/hq issue 058 (and its review): serve mode retries a broker that is not up yet, but must
|
|
// give up at once on a failure waiting cannot fix — otherwise a permanent fault loops for ever
|
|
// disguised as "not reachable". This is the classifier that draws the line; the bed cannot test it
|
|
// (it starts the consumer only after the broker is up), so it is proven here.
|
|
|
|
test("a broker that is not up yet is retryable, not fatal", () => {
|
|
for (const err of [
|
|
Object.assign(new Error("connect ECONNREFUSED 10.42.0.1:5671"), { code: "ECONNREFUSED" }),
|
|
Object.assign(new Error("connect ETIMEDOUT"), { code: "ETIMEDOUT" }),
|
|
Object.assign(new Error("getaddrinfo EAI_AGAIN anchor.internal"), { code: "EAI_AGAIN" }),
|
|
new Error("timed out fetching the broker's certificate"),
|
|
]) {
|
|
assert.equal(fatalBrokerReason(err), null, `should retry: ${(err as Error).message}`);
|
|
}
|
|
});
|
|
|
|
test("a certificate that does not match the pin is fatal, by type not by message", () => {
|
|
// Typed, so rewording the message cannot turn an impostor back into an infinite retry.
|
|
assert.notEqual(fatalBrokerReason(new PinMismatchError("anything at all")), null);
|
|
// A plain Error with pin-ish words is NOT treated as the pin case — only the type is.
|
|
assert.equal(fatalBrokerReason(new Error("the pinned value was fine")), null);
|
|
});
|
|
|
|
test("a malformed broker URL is fatal — it never parses on the next try", () => {
|
|
assert.notEqual(fatalBrokerReason(Object.assign(new Error("Invalid URL"), { code: "ERR_INVALID_URL" })), null);
|
|
assert.notEqual(fatalBrokerReason(new Error("Invalid URL: not-a-url")), null);
|
|
});
|
|
|
|
test("a refused login is fatal — a wrong or revoked credential, not an absent broker", () => {
|
|
// The bus refuses a login in its own words; each is final, because the next try says the same.
|
|
for (const msg of ["Authorization Violation", "nats: user authentication expired", "Permissions Violation for Subscription to \"x\""]) {
|
|
assert.notEqual(fatalBrokerReason(new Error(msg)), null, `should be fatal: ${msg}`);
|
|
}
|
|
});
|
|
|
|
test("a non-Error value does not crash the classifier", () => {
|
|
assert.equal(fatalBrokerReason("just a string"), null);
|
|
assert.equal(fatalBrokerReason(undefined), null);
|
|
});
|
|
|
|
// **A module asked to prepare its state and naming nothing is a failure, not a no-op** (novox/hq
|
|
// ADR 0135). The mesh asks this only of a module whose manifest says it prepares something, so an
|
|
// image that names nothing was built wrong, and exiting 0 would let that version serve against a
|
|
// state nobody shaped.
|
|
test("preparing with nothing named fails rather than passing quietly", async () => {
|
|
const runtime = new URL("../dist/main.js", import.meta.url).pathname;
|
|
const ran = await new Promise<{ code: number | null; said: string }>((resolve) => {
|
|
const child = spawn(process.execPath, [runtime, "prepare"], {
|
|
env: { ...process.env, MESH_PREPARE: "" },
|
|
});
|
|
let said = "";
|
|
child.stderr.on("data", (chunk) => (said += String(chunk)));
|
|
child.on("close", (code) => resolve({ code, said }));
|
|
});
|
|
assert.notEqual(ran.code, 0, "a module that prepares nothing exited 0, so its version would serve");
|
|
assert.match(ran.said, /MESH_PREPARE/);
|
|
});
|
|
|
|
// **One durable consumer feeds one reader, however many patterns a module registers.**
|
|
//
|
|
// A module has exactly one consumer, so two readers of it would each take half the messages — and a
|
|
// reader that received one its own pattern does not match acknowledges it, which is right for a
|
|
// filter wider than anything registered and silent loss when it is another handler's. The matching is
|
|
// therefore pure and tested as such: what a message is for is decided by the patterns registered, not
|
|
// by which reader happened to fetch it.
|
|
test("a message is for every pattern that matches it, and nothing else", () => {
|
|
const registered = ["mesh-build-machine.built", "mesh-controller.built-before"];
|
|
const matched = (key: string) => registered.filter((p) => topicMatches(p, key));
|
|
assert.deepEqual(matched("mesh-build-machine.built"), ["mesh-build-machine.built"]);
|
|
assert.deepEqual(matched("mesh-controller.built-before"), ["mesh-controller.built-before"]);
|
|
// Nothing registered for it: the consumer's filter is the controller's and may be wider.
|
|
assert.deepEqual(matched("mesh-catalog.upgraded"), []);
|
|
// And a handler that asked for everything gets both, which is what the audit logger does.
|
|
assert.deepEqual(["#"].filter((p) => topicMatches(p, "mesh-controller.built-before")), ["#"]);
|
|
});
|