diff --git a/test/integration/coupled-pair-spike.test.ts b/test/integration/coupled-pair-spike.test.ts new file mode 100644 index 0000000..34dc048 --- /dev/null +++ b/test/integration/coupled-pair-spike.test.ts @@ -0,0 +1,227 @@ +/** + * SPIKE for novox/hq 04-ISSUES/066 — a partly applied declaration leaves a mixed state. + * + * The apply is deliberately not a transaction: every resource is attempted, every failure reported, + * and a failed gate stops what follows it (issue 011, ADR 0053). The question 066 asks is whether a + * pairing exists whose half-state is harmful. This bed makes one, on purpose, and RECORDS what the + * machine is observed doing in it — it is evidence for a decision, not a rule being enforced. + * + * The pair: a config file and a long-lived container that serves the file's content as it was when + * the container started, with a run-once gate between them that validates the file. First push: + * the file says "v1", the gate passes, the service serves v1. Second push: the file says "v2" and + * the gate is made to refuse it. The file is applied before the gate (declaration order), the gate + * fails, the service after it is left as it was — so the machine has v2 on disk and serves v1, and + * reports the push as failed. That is the mixed state. Whether it is harmful is what a person + * decides from this; whether a `together` grouping should exist is what the decision would say. + * + * When such a grouping lands, this bed is where it is proven: the assertions below flip. + */ + +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, foundationBundle, onTheMachine } from "./harness.ts"; +import type { HeldImage } from "../../src/pinning.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 foundation bundle (mesh-host examples/)" + : false; + +const SCENARIO = "schedule-tick"; // a bare node, as the tick bed uses +const MACHINE = "anchor"; + +let instanceId = ""; +/** The mesh's own images, as the machines hold them. */ +let held: HeldImage[] = []; + +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 { + 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 { + return must(`docker exec mesh-controller /mesh-controller ${command}`, timeoutMs); +} + +/** The reference a manifest should carry, once this scenario has been raised. */ +/** What a manifest's image reference becomes on the machine — ours by ID, everything else as written. */ +function pinned(reference: string): string { + return onTheMachine(reference, held); +} + +/** The foundation bundle: ours by the ID the machine holds, everything else upstream. */ +function bundleFor(images: HeldImage[]): string { + return foundationBundle(bundle, images); +} + +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; +} + +/** + * How many lines the tick has written so far — tolerant of the run log not existing yet. + * + * The scheduled container appends one line per fire with `date >> /data/runs.log`, and the data + * directory is mounted from /var/lib/schedtest on the machine, so counting newlines there counts + * fires. `wc -l` on an absent file is an error, so a missing file reads as 0 rather than throwing — + * which is exactly the pre-first-fire state. + */ +async function tickLines(): Promise { + const { out } = await on(`wc -l < /var/lib/schedtest/runs.log 2>/dev/null || echo 0`); + const n = Number.parseInt(out.trim(), 10); + return Number.isFinite(n) ? n : 0; +} + +async function settled(withinMs = 600_000): Promise { + const until = Date.now() + withinMs; + let last = ""; + while (Date.now() < until) { + const asked = await on(`docker exec mesh-controller /mesh-controller 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; + held = raised.images; + + // Raise the foundation — store, broker, control — from the bundle. + await must(`cat > /tmp/foundation.lock <<'MESHBUNDLE'\n${bundleFor(raised.images)}\nMESHBUNDLE`); + await must(`${HOST_PATH} apply /tmp/foundation.lock`, 600_000); + const up = await must(`docker ps --format '{{.Names}}'`); + for (const c of ["mesh-store", "mesh-broker", "mesh-controller"]) { + assert.match(up, new RegExp(c), `the foundation 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 AND fires scheduled steps off its clock (the daemon holds one + // Scheduler for the life of the process — novox/hq ADR 0053). + 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 }); + +function coupled(content: string, gateAccepts: boolean): string { + return JSON.stringify({ + module: "coupled", version: "1", + resources: [ + { id: "state", type: "directory", path: "/var/lib/coupled", mode: "0755" }, + { id: "config", type: "file", path: "/var/lib/coupled/config", mode: "0644", content: content + "\n" }, + // The gate: validates the file. Made to pass or fail from the manifest, which is the whole + // point — a real validator refusing a real bad config is exactly this shape. + { + id: "validate", type: "container", name: "coupled-validate", image: pinned("alpine"), "run-once": true, + volumes: ["/var/lib/coupled:/data:ro"], + args: ["sh", "-c", gateAccepts ? "test -s /data/config" : "echo 'config refused by the validator' >&2; exit 1"], + }, + // The service: reads the file ONCE at start and serves that for its life, the way most + // servers read their configuration. + { + id: "service", type: "container", name: "coupled-service", image: pinned("alpine"), network: "host", + volumes: ["/var/lib/coupled:/data:ro"], + args: ["sh", "-c", "v=$(cat /data/config); while true; do printf 'HTTP/1.1 200 OK\\r\\nContent-Length: %s\\r\\n\\r\\n%s' \"${#v}\" \"$v\" | nc -l -p 8099; done"], + "restart-on": ["config"], + }, + ], + }); +} + +async function served(): Promise { + return (await on(`curl -s --max-time 3 http://127.0.0.1:8099/ || true`)).out.trim(); +} + +test("a coupled pair half-applied: the file moved, the gate refused, the service serves the old file — the machine is observed in the mixed state", { + skip, timeout: 900_000, +}, async () => { + await must(`printf %s ${quote(coupled("v1", true))} > /tmp/coupled.json && docker cp /tmp/coupled.json mesh-controller:/coupled.json`); + await mesh("module add /coupled.json"); + await mesh(`assign ${MACHINE} coupled`); + await mesh(`push ${MACHINE}`); + await settled(); + let first = ""; + for (let i = 0; i < 20 && first !== "v1"; i++) { + first = await served(); + if (first !== "v1") await new Promise((r) => setTimeout(r, 2000)); + } + assert.equal(first, "v1", "the service does not serve the first config"); + + // The change: a new file the validator refuses. Registered again under the same name so the + // mesh sends a new declaration; the file is applied, the gate fails, the service stays. + await must(`printf %s ${quote(coupled("v2", false))} > /tmp/coupled.json && docker cp /tmp/coupled.json mesh-controller:/coupled.json`); + await mesh("module add /coupled.json"); + const pushed = await on(`docker exec mesh-controller /mesh-controller push ${MACHINE}`); + // The push is sent; what the machine did with it is read from its report. + let report = ""; + for (let i = 0; i < 30; i++) { + const asked = await on(`docker exec mesh-controller /mesh-controller status --json`); + report = asked.out; + if (/"outcome":\s*"failed"/.test(report)) break; + await new Promise((r) => setTimeout(r, 3000)); + } + assert.match(report, /"outcome":\s*"failed"/, `the machine did not report a failed apply:\n${pushed.out}\n${report}`); + + // THE OBSERVATION. The file on disk is the new one; the service still serves the old one. + const onDisk = (await must(`cat /var/lib/coupled/config`)).trim(); + const answered = await served(); + assert.equal(onDisk, "v2", "the file was not applied before the gate"); + assert.equal(answered, "v1", `the service was restarted onto a config the validator refused: ${answered}`); + console.log(`OBSERVED: config on disk = ${onDisk}, service serves = ${answered}, report = failed — the mixed state of novox/hq 04-ISSUES/066`); +});