A spike for issue 066: a coupled pair half-applied, and what the machine is observed doing

A config file, a run-once gate that validates it, and a service that reads it once at
start. The second push changes the file and makes the gate refuse it: the file is
applied, the gate fails, the service stays — v2 on disk, v1 served, the push
reported failed. Evidence for the decision 066 asks for, not a rule enforced.
This commit is contained in:
2026-09-21 21:55:45 +02:00
parent ab5b0d11da
commit 8a3e7dbd1b
+227
View File
@@ -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<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-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<number> {
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<void> {
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<string> {
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`);
});