One-node bed: SDK-by-version, rename, and the store/broker upgrade proofs #27
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"scenario": "one-node-mesh",
|
||||
"established": 14,
|
||||
"of": 18,
|
||||
"steps": [
|
||||
{
|
||||
"code": "R1",
|
||||
"title": "a bare machine becomes a mesh of one, raised by the installer",
|
||||
"status": "pass",
|
||||
"seconds": 132,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "R2",
|
||||
"title": "the substrate is up — a store and a broker of the mesh's own",
|
||||
"status": "pass",
|
||||
"seconds": 1,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "R3",
|
||||
"title": "the control plane is one this mesh built, not one it was handed",
|
||||
"status": "pass",
|
||||
"seconds": 0,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "R4",
|
||||
"title": "the pivot finished — what raised the mesh is gone",
|
||||
"status": "pass",
|
||||
"seconds": 0,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "R5",
|
||||
"title": "the registry serves this mesh its own images",
|
||||
"status": "pass",
|
||||
"seconds": 0,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "R6",
|
||||
"title": "the machine is enrolled, and an agent is running on it",
|
||||
"status": "pass",
|
||||
"seconds": 0,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "R7",
|
||||
"title": "the builder is installed as a module, with an account",
|
||||
"status": "pass",
|
||||
"seconds": 0,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "P1",
|
||||
"title": "the mesh builds the shared base from source",
|
||||
"status": "pass",
|
||||
"seconds": 84,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "P2",
|
||||
"title": "the mesh builds and runs a store of its own",
|
||||
"status": "pass",
|
||||
"seconds": 40,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "P3",
|
||||
"title": "the mesh builds and runs its own catalogue",
|
||||
"status": "pass",
|
||||
"seconds": 34,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "P4",
|
||||
"title": "the mesh rebuilds its own control plane from source",
|
||||
"status": "pass",
|
||||
"seconds": 36,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "U1",
|
||||
"title": "the mesh builds a module standing on that base",
|
||||
"status": "pass",
|
||||
"seconds": 14,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "U2",
|
||||
"title": "the mesh runs a broker for that module to talk to",
|
||||
"status": "pass",
|
||||
"seconds": 24,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "U3",
|
||||
"title": "the anchor runs the module the mesh built",
|
||||
"status": "pass",
|
||||
"seconds": 6,
|
||||
"why": ""
|
||||
},
|
||||
{
|
||||
"code": "V1",
|
||||
"title": "the mesh can describe itself, and what it says is true",
|
||||
"status": "fail",
|
||||
"seconds": 2,
|
||||
"why": "the catalogue does not know about mesh-tools:\n{\"modules\":[{\"module\":\"amqp-ping\",\"commit\":\"af1e3afa495bac11e74c2d76cb2cf7a78167f2f6\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/amqp-ping\"},{\"module\":\"lavinmq\",\"commit\":\"af1e3afa495bac11e74c2d76cb2cf7a78167f2f6\",\"repository\":\"https://git.novox.be/novox/mesh-catalog.git\",\"path\":\"modules/lavinmq\"},{\"module\":\"mesh-control\",\"commit\":\"5062c36fc9efe159aa9706c0ca2c873351ef1ce0\",\"repository\":\"https://git.novox.be/novox/mesh-control.git\",\"path\":\"\"}]}\n"
|
||||
},
|
||||
{
|
||||
"code": "V2",
|
||||
"title": "the machine's networking is what the modules asked for",
|
||||
"status": "skip",
|
||||
"seconds": 0,
|
||||
"why": "not attempted — V1 (the mesh can describe itself, and what it says is true) did not succeed"
|
||||
},
|
||||
{
|
||||
"code": "E1",
|
||||
"title": "a change to a module's source reaches the machine on its own",
|
||||
"status": "skip",
|
||||
"seconds": 0,
|
||||
"why": "not attempted — V2 (the machine's networking is what the modules asked for) did not succeed"
|
||||
},
|
||||
{
|
||||
"code": "E2",
|
||||
"title": "the mesh comes back after the machine reboots",
|
||||
"status": "skip",
|
||||
"seconds": 0,
|
||||
"why": "not attempted — E1 (a change to a module's source reaches the machine on its own) did not succeed"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -83,6 +83,8 @@ const PROVIDER = { module: "lavinmq", repo: "mesh-catalog", path: "modules/lavin
|
||||
* what a change reaches, or what must be rebuilt. It ran anyway, which is the point: "the mesh is
|
||||
* up" was being read off genesis finishing.
|
||||
*/
|
||||
/** The one word that puts a mesh on a private network and gives its machines names. */
|
||||
const NETWORK_MODULE = "networking";
|
||||
const STORE = { module: "postgres", repo: "mesh-catalog", path: "modules/postgres", container: "mesh-postgres" };
|
||||
const CATALOGUE = { module: "mesh-catalog", repo: "mesh-catalog", path: "modules/mesh-catalog", container: "mesh-catalog" };
|
||||
/** The control plane, rebuilt from its own repository — the step that ends the installer's tenure. */
|
||||
@@ -113,9 +115,11 @@ const BASE_BUILT = "the mesh builds the shared base from source";
|
||||
const STORE_RUNS = "the mesh builds and runs a store of its own";
|
||||
const CATALOGUE_RUNS = "the mesh builds and runs its own catalogue";
|
||||
const CONTROL_REBUILT = "the mesh rebuilds its own control plane from source";
|
||||
const NETWORKED = "the mesh puts itself on a private network, and its machine has a name";
|
||||
const MODULE_BUILT = "the mesh builds a module standing on that base";
|
||||
const ANCHOR_RUNS = "the anchor runs the module the mesh built";
|
||||
const DESCRIBES = "the mesh can describe itself, and what it says is true";
|
||||
const DESCRIBES = "the control plane can describe the mesh, and what it says is true";
|
||||
const CATALOGUED = "the catalogue holds every module this mesh built";
|
||||
const NETWORK = "the machine's networking is what the modules asked for";
|
||||
const FOLLOWS = "a change to a module's source reaches the machine on its own";
|
||||
const SURVIVES = "the mesh comes back after the machine reboots";
|
||||
@@ -371,11 +375,13 @@ const PLAN: { code: string; title: string }[] = [
|
||||
{ code: "P2", title: STORE_RUNS },
|
||||
{ code: "P3", title: CATALOGUE_RUNS },
|
||||
{ code: "P4", title: CONTROL_REBUILT },
|
||||
{ code: "N1", title: NETWORKED },
|
||||
{ code: "U1", title: MODULE_BUILT },
|
||||
{ code: "U2", title: NEEDS },
|
||||
{ code: "U3", title: ANCHOR_RUNS },
|
||||
{ code: "V1", title: DESCRIBES },
|
||||
{ code: "V2", title: NETWORK },
|
||||
{ code: "V2", title: CATALOGUED },
|
||||
{ code: "V3", title: NETWORK },
|
||||
{ code: "E1", title: FOLLOWS },
|
||||
{ code: "E2", title: SURVIVES },
|
||||
];
|
||||
@@ -580,8 +586,38 @@ before(async () => {
|
||||
return `${built}\n${rolled}`;
|
||||
});
|
||||
|
||||
// ---- THE MESH'S OWN NETWORKING ---------------------------------------------------------------
|
||||
//
|
||||
// **Four modules the control plane computes were assigned to nothing, and nothing complained.**
|
||||
// `networking`, `mesh-wireguard`, `mesh-names` and `mesh-resolver` all existed as records that
|
||||
// had never been placed on a machine — so no names were written, no private network was raised,
|
||||
// and `/etc/hosts` held nothing. A module is a definition until it is assigned; being generated
|
||||
// by the control plane does not place it.
|
||||
//
|
||||
// One word, by design: `networking` has no files of its own and is requirements only, so
|
||||
// assigning it finds one answer to each and takes them. The day the catalogue holds a second VPN
|
||||
// there are two answers, the mesh refuses and names both, and choosing is assigning the one you
|
||||
// want.
|
||||
await step("N1", NETWORKED, CONTROL_REBUILT, async () => {
|
||||
await mesh(`assign ${CONTROL} ${NETWORK_MODULE}`);
|
||||
await mesh(`push ${CONTROL}`, 600_000);
|
||||
// What it was assigned for. A machine on a private network with no name on it has had the
|
||||
// harder half done and the visible half not.
|
||||
const deadline = Date.now() + 120_000;
|
||||
let hosts = "";
|
||||
while (Date.now() < deadline) {
|
||||
hosts = (await on(CONTROL, `cat /etc/hosts`)).out;
|
||||
if (/\.internal/.test(hosts)) break;
|
||||
await new Promise((r) => setTimeout(r, 5_000));
|
||||
}
|
||||
assert.match(hosts, /\.internal/,
|
||||
`${NETWORK_MODULE} is assigned and no machine has a name:\n${hosts}`);
|
||||
const modules = await mesh("module list");
|
||||
return `${hosts.trim()}\n\n${modules.trim()}`;
|
||||
});
|
||||
|
||||
// ---- 7..8. SOMETHING TO RUN ---------------------------------------------------------------
|
||||
await step("U1", MODULE_BUILT, CONTROL_REBUILT, async () => {
|
||||
await step("U1", MODULE_BUILT, NETWORKED, 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`,
|
||||
@@ -660,18 +696,24 @@ before(async () => {
|
||||
said.push(` plan every image pinned, no placeholders`);
|
||||
|
||||
// And the catalogue, ASKED rather than observed. These five questions are what it exists for.
|
||||
return said.join("\n");
|
||||
});
|
||||
|
||||
// ---- V2. AND THE CATALOGUE HOLDS WHAT WAS BUILT -----------------------------------------------
|
||||
//
|
||||
// **Split from the step above, because they are two claims and only one of them fails.** The
|
||||
// control plane describing the mesh correctly and the catalogue holding a complete record of it
|
||||
// are different things, and bundling them meant one open fault stopped three later steps from
|
||||
// ever being attempted.
|
||||
await step("V2", CATALOGUED, DESCRIBES, async () => {
|
||||
// **Asked as an operator would have to, which turns out to be nobody.**
|
||||
//
|
||||
// The obvious move — run the invoke inside the catalogue's own container — is refused by the
|
||||
// broker: `User 'anchor-mesh-catalog' doesn't have permissions to queue 'amq.gen-…'`. A
|
||||
// module's account is scoped to what it declares it emits and consumes, and calling a tool
|
||||
// needs a temporary reply queue, which that scope does not cover. So a module can SERVE tools
|
||||
// broker: a module's account is scoped to what it declares it emits and consumes, and calling
|
||||
// a tool needs a temporary reply queue that scope does not cover. So a module can SERVE tools
|
||||
// and cannot CALL them, and nothing issues an account to anyone who wants to ask (novox/hq
|
||||
// issue 049).
|
||||
//
|
||||
// Until that is decided, the caller is the substrate's own admin account over the broker's
|
||||
// loopback — the bootstrap case `mesh-tools` documents, reached the way genesis reaches a
|
||||
// substrate container, by joining its network namespace.
|
||||
// issue 049). Until that is decided the caller is the substrate's bootstrap admin over the
|
||||
// broker's loopback, reached by joining its network namespace.
|
||||
const image = (await on(CONTROL,
|
||||
`docker inspect -f '{{.Config.Image}}' mesh-catalog`)).out.trim();
|
||||
const ask = async (tool: string, args = "{}") =>
|
||||
@@ -682,30 +724,32 @@ before(async () => {
|
||||
120_000);
|
||||
|
||||
const held = await ask("catalog_modules");
|
||||
for (const m of MUST_HOLD) {
|
||||
if (m === "registry" || m === "builder" || m === "mesh-control") continue; // carried, not built here
|
||||
assert.ok(held.includes(m), `the catalogue does not know about ${m}:\n${held}`);
|
||||
}
|
||||
// Compared against what the control plane ordered, rather than against a list written here: a
|
||||
// catalogue cannot know what it was never told, so it must be measured against something that
|
||||
// does. novox/hq issue 050 — on a fresh mesh the modules built before the catalogue existed
|
||||
// are exactly the ones it needed in order to exist, so the hole is always the foundation.
|
||||
const missing = MUST_HOLD.filter((m) =>
|
||||
!["registry", "builder"].includes(m) && !held.includes(m));
|
||||
assert.deepEqual(missing, [],
|
||||
`the catalogue does not hold ${missing.join(", ")} — the mesh built them and its own ` +
|
||||
`record has no trace of it (novox/hq issue 050):\n${held}`);
|
||||
assert.doesNotMatch(held, /sha256:0{64}/, `the catalogue holds a placeholder version`);
|
||||
said.push(` catalog_modules every built module, each with a version this mesh made`);
|
||||
|
||||
const provides = await ask("catalog_provides", JSON.stringify({ provision: "amqp" }));
|
||||
assert.ok(provides.includes(PROVIDER.module),
|
||||
`the catalogue cannot say what provides amqp, which is the question it exists to answer:\n${provides}`);
|
||||
said.push(` catalog_provides amqp is answered by ${PROVIDER.module}`);
|
||||
`the catalogue cannot say what provides amqp, which is a question it exists for:\n${provides}`);
|
||||
|
||||
const stale = await ask("catalog_stale");
|
||||
said.push(` catalog_stale ${stale.trim().slice(0, 120)}`);
|
||||
return said.join("\n");
|
||||
return `${held}\n${provides}\n${stale}`;
|
||||
});
|
||||
|
||||
// ---- 10. AND ITS NETWORKING IS WHAT WAS ASKED FOR ---------------------------------------------
|
||||
// ---- V3. AND ITS NETWORKING IS WHAT WAS ASKED FOR ---------------------------------------------
|
||||
//
|
||||
// **Left out of this test entirely until it was pointed out**, which is hard to defend: the
|
||||
// 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("V2", NETWORK, DESCRIBES, async () => {
|
||||
await step("V3", NETWORK, DESCRIBES, async () => {
|
||||
const said: string[] = [];
|
||||
|
||||
const ruleset = (await on(CONTROL, `nft list table inet mesh 2>&1`)).out;
|
||||
@@ -811,10 +855,12 @@ for (const name of [
|
||||
STORE_RUNS,
|
||||
CATALOGUE_RUNS,
|
||||
CONTROL_REBUILT,
|
||||
NETWORKED,
|
||||
MODULE_BUILT,
|
||||
NEEDS,
|
||||
ANCHOR_RUNS,
|
||||
DESCRIBES,
|
||||
CATALOGUED,
|
||||
NETWORK,
|
||||
FOLLOWS,
|
||||
SURVIVES,
|
||||
|
||||
Reference in New Issue
Block a user