node-tools is a module beside mesh-tools: the runtime as a bundle, and serve is the console (hq ADR 0175, to-be 38 WP3)
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.
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
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")), ["#"]);
|
||||
});
|
||||
Reference in New Issue
Block a user