A plan with codes, stated up front and reported against
Steps were identified by their own sentences, so 'which one failed' meant reading prose, and rewording a step silently made it a different step with no history. Each now carries a stable code: R for raising the mesh, P for it being able to produce, U for something being used on it, V for verifying what it says about itself, E for enduring — a change following on its own, and coming back after the machine stops. The plan is data, printed before anything is attempted, so a reader knows what the run intends to establish rather than inferring it from what happens to be printed. The run ends with a table and a JSON report, and distinguishes SKIP from FAIL: a step whose dependency failed was never asked, which is not the same as a step that was asked and said no. Claude-Session: https://claude.ai/code/session_01D6qtiYU3P9jk3pnAXyAFyx
This commit is contained in:
@@ -38,7 +38,7 @@
|
||||
*/
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { loadScenario } from "../../src/declaration/parse.ts";
|
||||
import { raise } from "../../src/lifecycle/raise.ts";
|
||||
@@ -294,27 +294,45 @@ function tokenFrom(said: string): string {
|
||||
|
||||
// ---- steps, recorded rather than thrown --------------------------------------------------------
|
||||
|
||||
interface Step { ok: boolean; why: string; said: string }
|
||||
interface Step {
|
||||
code: string; title: string; ok: boolean; why: string; said: string; seconds: number;
|
||||
}
|
||||
const steps = new Map<string, Step>();
|
||||
const order: string[] = [];
|
||||
|
||||
/** Run a step, remember what it said, and never throw. A step whose predecessor failed is skipped. */
|
||||
async function step(name: string, after_: string | null, fn: () => Promise<string>): Promise<void> {
|
||||
order.push(name);
|
||||
if (after_ && !steps.get(after_)?.ok) {
|
||||
steps.set(name, { ok: false, why: `not attempted — "${after_}" did not succeed`, said: "" });
|
||||
console.log(`SKIPPED ${name}`);
|
||||
/**
|
||||
* Run one step of the plan, remember what it said, and never throw.
|
||||
*
|
||||
* **Each step carries a code, and the code is the point.** A step used to be identified by its own
|
||||
* sentence, so "which one failed" meant reading prose, and rewording a step silently made it a
|
||||
* different step with no history. A code is stable, sorts, and can be pointed at: "V1 failed" says
|
||||
* something that a whole sentence does not.
|
||||
*
|
||||
* A step whose dependency did not succeed is not attempted — its answer would be meaningless and
|
||||
* its failure would be attributed to the wrong cause. The run still continues to the end, so the
|
||||
* report says what was established and what was never asked, which are different things.
|
||||
*/
|
||||
async function step(
|
||||
code: string, title: string, needs: string | null, fn: () => Promise<string>,
|
||||
): Promise<void> {
|
||||
order.push(title);
|
||||
if (needs && !steps.get(needs)?.ok) {
|
||||
steps.set(title, { code, title, ok: false, seconds: 0, said: "",
|
||||
why: `not attempted — ${steps.get(needs)?.code ?? "?"} (${needs}) did not succeed` });
|
||||
console.log(`[${code}] SKIP ${title}`);
|
||||
return;
|
||||
}
|
||||
console.log(`\n======== ${name} ========`);
|
||||
console.log(`\n[${code}] ---- ${title} ----`);
|
||||
const began = Date.now();
|
||||
const took = () => Math.round((Date.now() - began) / 1000);
|
||||
try {
|
||||
const said = await fn();
|
||||
steps.set(name, { ok: true, why: "", said });
|
||||
console.log(`OK ${name}`);
|
||||
steps.set(title, { code, title, ok: true, why: "", said, seconds: took() });
|
||||
console.log(`[${code}] PASS ${title} (${took()}s)`);
|
||||
} catch (err) {
|
||||
const why = (err as Error).message;
|
||||
steps.set(name, { ok: false, why, said: "" });
|
||||
console.log(`FAILED ${name}\n${why.split("\n").slice(0, 25).join("\n")}`);
|
||||
steps.set(title, { code, title, ok: false, why, said: "", seconds: took() });
|
||||
console.log(`[${code}] FAIL ${title} (${took()}s)\n${why.split("\n").slice(0, 25).join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,8 +346,75 @@ function report(name: string): string {
|
||||
return `${s.why}\n\nWhere this bed got to:\n${lines.join("\n")}`;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* The plan, as data.
|
||||
*
|
||||
* Stated before any of it is attempted, so a reader knows what the run is trying to establish
|
||||
* rather than inferring it from whatever happens to be printed. Codes are stable; titles may be
|
||||
* reworded without the step losing its identity.
|
||||
*
|
||||
* Five phases, and the order is the argument: a mesh is RAISED, then it must be able to PRODUCE,
|
||||
* then something is USED on it, then it is asked to describe itself and its networking is
|
||||
* VERIFIED, and finally it has to ENDURE — a change following on its own, and coming back after
|
||||
* the machine stops.
|
||||
*/
|
||||
const PLAN: { code: string; title: string }[] = [
|
||||
{ code: "R1", title: GENESIS },
|
||||
{ code: "R2", title: SUBSTRATE },
|
||||
{ code: "R3", title: BUILT_CP },
|
||||
{ code: "R4", title: PIVOTED },
|
||||
{ code: "R5", title: HAS_REGISTRY },
|
||||
{ code: "R6", title: ENROLLED },
|
||||
{ code: "R7", title: HAS_BUILDER },
|
||||
{ code: "P1", title: BASE_BUILT },
|
||||
{ code: "P2", title: STORE_RUNS },
|
||||
{ code: "P3", title: CATALOGUE_RUNS },
|
||||
{ code: "P4", title: CONTROL_REBUILT },
|
||||
{ code: "U1", title: MODULE_BUILT },
|
||||
{ code: "U2", title: NEEDS },
|
||||
{ code: "U3", title: ANCHOR_RUNS },
|
||||
{ code: "V1", title: DESCRIBES },
|
||||
{ code: "V2", title: NETWORK },
|
||||
{ code: "E1", title: FOLLOWS },
|
||||
{ code: "E2", title: SURVIVES },
|
||||
];
|
||||
|
||||
/** What this run intends to establish, said before any of it is attempted. */
|
||||
function statePlan(): void {
|
||||
console.log(`\n================ THE PLAN ================`);
|
||||
for (const s of PLAN) console.log(` ${s.code.padEnd(3)} ${s.title}`);
|
||||
console.log(` ${PLAN.length} steps. A step whose dependency fails is not attempted.\n`);
|
||||
}
|
||||
|
||||
/** The run's verdict: a table, and a file something other than a person can read. */
|
||||
function stateOutcome(): void {
|
||||
console.log(`\n================ WHAT THIS MESH DID FOR ITSELF ================`);
|
||||
let established = 0;
|
||||
for (const title of order) {
|
||||
const s = steps.get(title);
|
||||
if (!s) continue;
|
||||
if (s.ok) established++;
|
||||
const mark = s.ok ? "PASS" : s.why.startsWith("not attempted") ? "SKIP" : "FAIL";
|
||||
console.log(` ${s.code.padEnd(3)} ${mark} ${title}${s.seconds ? ` (${s.seconds}s)` : ""}`);
|
||||
}
|
||||
console.log(` ${established}/${PLAN.length} established.`);
|
||||
const where = process.env["MESH_LAB_REPORT"] ?? "one-node-mesh-report.json";
|
||||
try {
|
||||
writeFileSync(where, JSON.stringify({ scenario: SCENARIO, established, of: PLAN.length,
|
||||
steps: PLAN.map(({ code, title }) => {
|
||||
const s = steps.get(title);
|
||||
return { code, title, status: !s ? "never-ran"
|
||||
: s.ok ? "pass" : s.why.startsWith("not attempted") ? "skip" : "fail",
|
||||
seconds: s?.seconds ?? 0, why: s?.ok ? "" : s?.why ?? "" };
|
||||
}) }, null, 2));
|
||||
console.log(` report written to ${where}`);
|
||||
} catch { /* a report that cannot be written must not fail a run that passed */ }
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
if (skip) return;
|
||||
statePlan();
|
||||
|
||||
const bed = await raise(loadScenario(`scenarios/${SCENARIO}.yml`), {
|
||||
onProgress: (m) => console.log(`raise: ${m}`),
|
||||
@@ -344,7 +429,7 @@ before(async () => {
|
||||
//
|
||||
// The shared description, the same one `genesis-single` calls. The bundle is the TEMPLATE with
|
||||
// nothing held: no image is pre-resolved, because none is here to resolve to.
|
||||
await step("a bare machine becomes a mesh of one, raised by the installer", null, async () => {
|
||||
await step("R1", GENESIS, null, async () => {
|
||||
try {
|
||||
raised = await genesis({
|
||||
instanceId,
|
||||
@@ -381,7 +466,7 @@ before(async () => {
|
||||
// saying it published an image and the registry serving one are different facts, and it is the
|
||||
// second that matters.
|
||||
|
||||
await step(SUBSTRATE, GENESIS, async () => {
|
||||
await step("R2", SUBSTRATE, GENESIS, async () => {
|
||||
await waitForContainer(CONTROL, "mesh-store", 120);
|
||||
await waitForContainer(CONTROL, "mesh-broker", 120);
|
||||
return (await on(CONTROL, `docker ps --format '{{.Names}}\t{{.Status}}'`)).out;
|
||||
@@ -390,7 +475,7 @@ before(async () => {
|
||||
// **The whole reason the installer carries a builder.** A carried control-plane image would
|
||||
// satisfy "a control plane is running" equally well, which is what makes this worth asserting:
|
||||
// the image has to be one this mesh's own registry serves.
|
||||
await step(BUILT_CP, GENESIS, async () => {
|
||||
await step("R3", BUILT_CP, GENESIS, async () => {
|
||||
const image = (await on(CONTROL,
|
||||
`docker inspect -f '{{.Config.Image}}' mesh-control 2>&1`)).out.trim();
|
||||
assert.match(image, /@sha256:[0-9a-f]{64}/,
|
||||
@@ -400,14 +485,14 @@ before(async () => {
|
||||
return image;
|
||||
});
|
||||
|
||||
await step(PIVOTED, BUILT_CP, async () => {
|
||||
await step("R4", PIVOTED, BUILT_CP, async () => {
|
||||
const ps = (await on(CONTROL, `docker ps -a --format '{{.Names}}'`)).out;
|
||||
assert.doesNotMatch(ps, /^temp-mesh-control$/m,
|
||||
`the temporary control plane is still here, so the pivot did not finish:\n${ps}`);
|
||||
return ps;
|
||||
});
|
||||
|
||||
await step(HAS_REGISTRY, SUBSTRATE, async () => {
|
||||
await step("R5", HAS_REGISTRY, SUBSTRATE, async () => {
|
||||
await waitForContainer(CONTROL, "mesh-registry", 120);
|
||||
const held = (await on(CONTROL,
|
||||
`curl -sS --max-time 15 http://127.0.0.1:5000/v2/_catalog`)).out;
|
||||
@@ -416,7 +501,7 @@ before(async () => {
|
||||
return held.trim();
|
||||
});
|
||||
|
||||
await step(ENROLLED, GENESIS, async () => {
|
||||
await step("R6", ENROLLED, GENESIS, async () => {
|
||||
const nodes = await mesh("node list");
|
||||
assert.match(nodes, new RegExp(`^${CONTROL}\\b`, "m"),
|
||||
`the mesh has not heard from the machine it is running on:\n${nodes}`);
|
||||
@@ -426,7 +511,7 @@ before(async () => {
|
||||
return `${nodes.trim()}\n${agent}`;
|
||||
});
|
||||
|
||||
await step(HAS_BUILDER, HAS_REGISTRY, async () => {
|
||||
await step("R7", HAS_BUILDER, HAS_REGISTRY, async () => {
|
||||
await waitForContainer(CONTROL, "mesh-builder", 120);
|
||||
const modules = await mesh("module list");
|
||||
assert.match(modules, /^builder\b/m,
|
||||
@@ -449,7 +534,7 @@ before(async () => {
|
||||
// The toolchain and runtime every module with code of its own stands on. It is a module, and it
|
||||
// is built like one — cloned from the forge by the builder installing put here, compiled on the
|
||||
// machine, published into the mesh's own registry.
|
||||
await step(BASE_BUILT, HAS_BUILDER, async () => {
|
||||
await step("P1", BASE_BUILT, HAS_BUILDER, async () => {
|
||||
// Registered from the manifest the builder will also read, so what the mesh holds and what it
|
||||
// builds are the same description of the same module.
|
||||
//
|
||||
@@ -468,7 +553,7 @@ before(async () => {
|
||||
// plane to keep its own records in, the way it raises a broker — plumbing, not a module the mesh
|
||||
// has any record of, so it provides nothing to anything. A module that wants a database wants a
|
||||
// provider in the graph, and the catalogue below is the first thing to want one.
|
||||
await step(STORE_RUNS, BASE_BUILT, () => bringUp(STORE));
|
||||
await step("P2", STORE_RUNS, BASE_BUILT, () => bringUp(STORE));
|
||||
|
||||
// And the catalogue, which was missing from this test altogether.
|
||||
//
|
||||
@@ -476,7 +561,7 @@ before(async () => {
|
||||
// of, what a change to one reaches, or what must be rebuilt. A mesh in that state still runs,
|
||||
// which is exactly how its absence went unnoticed — "the mesh is up" was being read off the
|
||||
// installer finishing rather than off the mesh being able to answer anything.
|
||||
await step(CATALOGUE_RUNS, STORE_RUNS, () => bringUp(CATALOGUE));
|
||||
await step("P3", CATALOGUE_RUNS, STORE_RUNS, () => bringUp(CATALOGUE));
|
||||
|
||||
// The control plane, rebuilt from its own repository and rolled out.
|
||||
//
|
||||
@@ -484,7 +569,7 @@ before(async () => {
|
||||
// MODULE PATH — build, notice the version moved, roll it out — is a different claim: it is the
|
||||
// moment the mesh stops depending on the installer for anything, and the first time the thing
|
||||
// that performs an upgrade performs one on itself.
|
||||
await step(CONTROL_REBUILT, CATALOGUE_RUNS, async () => {
|
||||
await step("P4", CONTROL_REBUILT, CATALOGUE_RUNS, async () => {
|
||||
const built = await mesh(
|
||||
`build ${forgeUrl(CONTROL_PLANE.repo)} --ref ${sourceRef} --wait 1200s`, 1_500_000);
|
||||
assert.doesNotMatch(built, /failed/i, built);
|
||||
@@ -496,7 +581,7 @@ before(async () => {
|
||||
});
|
||||
|
||||
// ---- 7..8. SOMETHING TO RUN ---------------------------------------------------------------
|
||||
await step(MODULE_BUILT, CONTROL_REBUILT, async () => {
|
||||
await step("U1", MODULE_BUILT, CONTROL_REBUILT, async () => {
|
||||
await registerModule(MODULE.module, resolve(catalogDir, MODULE.module, "module.json"));
|
||||
const built = await mesh(
|
||||
`build ${forgeUrl(MODULE.repo)} --path ${MODULE.path} --ref ${refFor(MODULE.repo)} --wait 1200s`,
|
||||
@@ -520,11 +605,11 @@ before(async () => {
|
||||
// but the module is not only the broker: it carries a run-once bootstrap that writes the broker's
|
||||
// configuration, a provisioner that grants each consumer its own vhost and user, tools and an
|
||||
// event consumer, all of it its own code.
|
||||
await step(NEEDS, MODULE_BUILT, () => bringUp(PROVIDER));
|
||||
await step("U2", NEEDS, MODULE_BUILT, () => bringUp(PROVIDER));
|
||||
|
||||
// The machine that built it. This is the case every earlier proof covered, and it is here as the
|
||||
// control for the last step: if this fails, that one's failure says nothing about fetching.
|
||||
await step(ANCHOR_RUNS, NEEDS, async () => {
|
||||
await step("U3", ANCHOR_RUNS, NEEDS, async () => {
|
||||
await mesh(`module issue ${MODULE.module} --node ${CONTROL}`);
|
||||
await mesh(`assign ${CONTROL} ${MODULE.module}`);
|
||||
await mesh(`push ${CONTROL}`, 600_000);
|
||||
@@ -538,7 +623,7 @@ before(async () => {
|
||||
// module running. The mesh holds a graph — nodes, what each is assigned, what capabilities each
|
||||
// has, which claims are occupied, what each module is configured with, which provisions exist and
|
||||
// who holds them — and none of it was ever asked a question.
|
||||
await step(DESCRIBES, ANCHOR_RUNS, async () => {
|
||||
await step("V1", DESCRIBES, ANCHOR_RUNS, async () => {
|
||||
const said: string[] = [];
|
||||
|
||||
// The mesh's own verdict on itself. Nothing wrong, nothing waiting, nothing behind.
|
||||
@@ -604,7 +689,7 @@ before(async () => {
|
||||
// firewall is generated from what modules declare they listen on, and a firewall that opens the
|
||||
// wrong set is either a service nobody can reach or a port nobody meant to publish. Neither shows
|
||||
// up as a failed container.
|
||||
await step(NETWORK, DESCRIBES, async () => {
|
||||
await step("V2", NETWORK, DESCRIBES, async () => {
|
||||
const said: string[] = [];
|
||||
|
||||
const ruleset = (await on(CONTROL, `nft list table inet mesh 2>&1`)).out;
|
||||
@@ -639,7 +724,7 @@ before(async () => {
|
||||
// The whole point of the mesh, and the capability the migration depends on: move a module's
|
||||
// source and the running copy follows, with nobody driving the steps. Everything above is
|
||||
// machinery; this is what the machinery is for.
|
||||
await step(FOLLOWS, NETWORK, async () => {
|
||||
await step("E1", FOLLOWS, NETWORK, async () => {
|
||||
const before = await mesh(`builds ${MODULE.module}`);
|
||||
const wasPinned = before.match(/sha256:[0-9a-f]{64}/)?.[0] ?? "";
|
||||
assert.ok(wasPinned, `nothing is pinned to rebuild from:\n${before}`);
|
||||
@@ -670,7 +755,7 @@ before(async () => {
|
||||
// something to move real services onto — and the installer is explicit that a host started the
|
||||
// way the lab starts it does not survive a reboot, which makes this the check that says whether
|
||||
// that matters.
|
||||
await step(SURVIVES, FOLLOWS, async () => {
|
||||
await step("E2", SURVIVES, FOLLOWS, async () => {
|
||||
await restartMachine(CONTROL);
|
||||
const missing: string[] = [];
|
||||
for (const container of MUST_RUN) {
|
||||
@@ -686,8 +771,7 @@ before(async () => {
|
||||
return ps;
|
||||
});
|
||||
|
||||
console.log(`\n================ WHAT THIS MESH DID FOR ITSELF ================`);
|
||||
for (const n of order) console.log(` ${steps.get(n)?.ok ? "PASS" : "FAIL"} ${n}`);
|
||||
stateOutcome();
|
||||
}, { timeout: 7_200_000 });
|
||||
|
||||
after(async () => {
|
||||
|
||||
Reference in New Issue
Block a user