Compare commits

...
Author SHA1 Message Date
mesh-admin 1601cb1743 Merge pull request 'A walk's first-machine gate waits for a busy container, and mesh-builder busy drains the agent (issue 594, hq ADR 0335)' (#283) from feat/335-a-busy-container-is-replaced-when-idle into main 2026-10-13 22:20:55 +00:00
jschoubben 873b19566a Read the delivery group's order again, now the catalogue and hq left it
mesh/delivery-group group feat/335-a-busy-container-is-replaced-when-idle delivering: 0 of 2 delivered
mesh/delivery delivering: 0 machine step(s) passed
mesh/merge-gate head 873b19566a82 merged into trunk 72214ae989b3 — pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus…
mesh/repo-check head 873b19566a82 merged into trunk 72214ae989b3 — pass: its merge-check.sh passed
2026-10-14 00:08:44 +02:00
jschoubben b0753e2aac Let a drain lapse, bound the gate's wait on the controller's clock, keep a busy wait out of apply time
mesh/delivery superseded: a newer head of the same pull request
mesh/delivery-group group feat/335-a-busy-container-is-replaced-when-idle checking: 1 of 2 member(s) ready
mesh/merge-gate head b0753e2aac95 merged into trunk 72214ae989b3 — pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus…
mesh/repo-check head b0753e2aac95 merged into trunk 72214ae989b3 — pass: its merge-check.sh passed
Review of #283: a drain whose replacement was withdrawn held the agent for
ever with its health green; the gate trusted the engine's until; a machine
of the rest waiting at the bound read as silent and its apply time held the
wait; an unknown mesh-builder word exited 0; plan said nothing of a busy an
older engine is not sent; and a node with a report and no send broke the
reading of every last report (the store test's NULL).
2026-10-14 00:07:31 +02:00
jschoubben a69c0e50dc Merge origin/main into feat/335, and pin mesh-host at #98's new head 2026-10-14 00:01:21 +02:00
mesh-admin 72214ae989 Merge pull request 'One build per change: the check builds what its merge moves, the walk takes it by key (ADR 0350, issue 649)' (#311) from feat/350-one-build-per-change into main 2026-10-13 21:30:25 +00:00
jschoubben 5c606fd1d4 Take only a check build its verdict names, and keep a build another check still names
mesh/merge-gate head 5c606fd1d48b merged into trunk 372faebf2824 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 5c606fd1d48b merged into trunk 372faebf2824 — pass: its merge-check.sh passed (run 2)
mesh/delivery delivered
mesh/delivery-group group feat/350-one-build-per-change delivered: every member is delivered
The artifact store takes writes from anything on its network, so a record
planted under another merge's key could have been registered: the walk now
takes a scratch build only when the build seat's own verdict for one of its
pull requests names its key and digests. Deleting by digest also took builds
another check shared (review of #311; novox/hq ADR 0350).
2026-10-13 23:16:55 +02:00
mesh-admin 372faebf28 Merge pull request 'The controller passes its first-node gate on one reading (ADR 0351)' (#310) from feat/351-the-controller-passes-on-one-reading into main 2026-10-13 21:14:27 +00:00
jschoubben 675466a8c3 Hold the store tests' modules as the settle time did, and fault a dip after any healthy reading
mesh/delivery delivered
mesh/merge-gate head 675466a8c35f merged into trunk bff5b7f0e4f8 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 675466a8c35f merged into trunk bff5b7f0e4f8 — pass: its merge-check.sh passed
Two tier tests relied on the two-minute settle to keep a module unjudged; the
witness window and the gate's spacing now hold it. A healthy reading taken
before the controller's floor counted no pass, so a dip after it did not take
the two-minute pass away (novox/hq ADR 0352 decision 3).
2026-10-13 23:07:15 +02:00
jschoubben cd49b50c40 Build a change once: the check builds what its merge moves, the walk takes it by key
mesh/merge-gate out of date: judged against trunk bff5b7f0e4f8, the trunk is now 372faebf2824; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk bff5b7f0e4f8, the trunk is now 372faebf2824; judged again on a recheck or when it is merged
mesh/delivery-group group feat/350-one-build-per-change checking: composed check out of date: novox/mesh-controller@cd49b50c4075 was judged against trunk bff5b…
mesh/delivery superseded: a newer head of the same pull request
A merge built every moved module a second time although its check had judged
the same tree (issue 649). The check now builds into the scratch namespace
under a reuse key (source fingerprint plus the builder's own digest), and the
walk of the trunk commit copies a build of the same key by digest instead of
building, saying per module why when it cannot (novox/hq ADR 0350).
2026-10-13 23:04:40 +02:00
jschoubben 41eefc2f8c Test that a healthy node-engine passes at two minutes on one reading
mesh/merge-gate head 41eefc2f8c3e merged into trunk bff5b7f0e4f8 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 41eefc2f8c3e merged into trunk bff5b7f0e4f8 — fail: its merge-check.sh failed: --- FAIL: TestACoreBehindWhileTheNodeToolsSettleFailsNo…
mesh/delivery superseded: a newer head of the same pull request
2026-10-13 22:58:37 +02:00
jschoubben c3946e5d72 Pass the node-engine and resolver on their third reading, at most two minutes
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head c3946e5d72c2 merged into trunk bff5b7f0e4f8 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head c3946e5d72c2 merged into trunk bff5b7f0e4f8 — fail: its merge-check.sh failed: --- FAIL: TestACoreBehindWhileTheNodeToolsSettleFailsNo…
The operator wants these gates as fast as they can be: counting readings only after
two minutes added about 80 s to every node-engine rollout for no safety. Count
from the send, pass on the third healthy reading, and pass a module healthy at
every reading at two minutes on what it has (novox/hq ADR 0352).
2026-10-13 22:55:46 +02:00
jschoubben 524295926f Pass the controller's first-node gate on one reading after the witness window
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head 524295926f76 merged into trunk bff5b7f0e4f8 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 524295926f76 merged into trunk bff5b7f0e4f8 — fail: its merge-check.sh failed: --- FAIL: TestACoreBehindWhileTheNodeToolsSettleFailsNo…
The controller runs on one machine, so three readings over two minutes guarded no
other machine and held every walk behind it. Judge each module by its own rule:
one reading for the controller and the tool runner once the node-engine's witness
window has ended, one for mesh-delivery, three for the node-engine and the
resolver with the first two minutes after the send (novox/hq ADR 0351). The
gate's verdict names the rule it applied.
2026-10-13 22:39:32 +02:00
mesh-admin bff5b7f0e4 Merge pull request 'A settings proposal reads at a glance, its values whole where the sender is proven (issues 383 and 639)' (#304) from fix/383-an-ask-reads-at-a-glance into main 2026-10-13 19:22:06 +00:00
mesh-admin cef22a2526 Merge pull request 'A provider serves its consumers its own composed names (ADR 0346, issue 624)' (#306) from feat/346-a-provider-serves-its-own-names into main 2026-10-13 19:11:16 +00:00
jochen deef9421a2 A change's line the router would refuse is named with its value withheld, or refused at propose (the review of #304)
mesh/merge-gate head deef9421a276 merged into trunk cef22a25264b — pass: builds build-agent, mesh-controller, route-proxy → ace, g14, novox, shanks; no bus…
mesh/repo-check head deef9421a276 merged into trunk cef22a25264b — pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/383-an-ask-reads-at-a-glance delivered: every member is delivered
{a:1, b:2, c:3, password:hunter2} was proposed with "+ password: hunter2" in its Details: the router
withheld the line unnamed and Approve set a value the operator never saw (ADR 0347 §1, §3, §4). Each line is
now judged as the router judges it, masked under the content rule and whole under the secret rule; a line
refused is said "+ password (value withheld)", and any line the router would still refuse is refused at
propose in the controller's words, never echoing a secret-shaped key. SDK at mesh-sdk 5933eb7 (no two asks
digest the same).
2026-10-13 20:45:25 +02:00
jochen 3c19e9ee93 A settings proposal's summary says the layer is sent at once by a walk (ADR 0344 with ADR 0345)
Rebased on #305: Approve now sends the layer by a walk, so the summary's "takes it at its next push" was
false. The summary says it is set and sent at once by a walk to where it reaches, one machine at a time,
each judged; the rest of what Approve does (onApprove) leads the Details, beside every key whole.
2026-10-13 20:45:25 +02:00
jochen 3d94f87bb0 The glance's cut says only what the note holds there: whole beside the whole details, else in the note (ADR 0347) 2026-10-13 20:45:25 +02:00
jochen 4644f25edd A settings proposal carries every key with its whole value in its Details (issue 383)
The review of #304: with more than three keys the ask showed only the names of the rest, a long value was
cut without a word, and the Details held no key, so Approve bound values the operator never saw. The
Details now list every key uncut — masked, and whole in WholeDetails for a channel that proves who
answers — before who proposed it and the fingerprint; the glance says what it cut. Vendors mesh-sdk at
f474544 and captures its conformance events there.
2026-10-13 20:45:25 +02:00
jochen c58e0a571e A settings proposal reads at a glance, its values whole where the sender is proven (issues 383 and 639)
The operator could not read the settings proposals on the phone: the change, the fingerprint and a how-to
paragraph in one wall of plain text, and the values masked as ‹path› and ‹address›, so the approval was
blind.

The ask is now a summary of what changes where ("Changes 3 setting(s) of mounts on shanks; approved, the
layer is set at once and shanks takes it at its next push."), then at most three points, one key each (more
keys: the first two and the names of the rest). Who proposed it, the fingerprint and how to read it whole
are its Details, which the router notes after the answers. The change is carried twice: under the content
rule, and whole (asks.Ask.Whole), shown only on a channel that proves who answers, a value shaped like a
secret withheld in both (from the earlier work on issue 383, #202).

Vendors mesh-sdk at 9588871 (fix/639-an-ask-reads-at-a-glance: Message.Note, asks.Glance), and captures its
conformance events at that commit.
2026-10-13 20:45:25 +02:00
mesh-admin 332fbaa9b1 Merge pull request 'settings: no layer replaces a value the vault fills, at any depth (issue 610)' (#308) from fix/610-no-layer-replaces-a-vault-secret into main 2026-10-13 18:33:41 +00:00
jochen 7a0c219f96 List the provider-elsewhere store test, read a machine's domain as its profile, reuse the settings read once, and refuse a nested own name (hq ADR 0346, review 228)
mesh/merge-gate head 7a0c219f961e merged into trunk 332fbaa9b175 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 7a0c219f961e merged into trunk 332fbaa9b175 — pass: its merge-check.sh passed
mesh/delivery delivered
2026-10-13 20:32:51 +02:00
jochen 985423ae61 Say that a provider's own name is not on its consumers' entries (hq ADR 0346) 2026-10-13 20:29:17 +02:00
jochen cfacda2625 Let a provider serve its consumers its own composed names (hq ADR 0346, issue 624)
The forge writes every package tarball at its public name, and its
consumers had no source for that name but a second copy kept by hand,
though the mesh composes it for the forge's route. A served value may now
name the provider's own names; a name its machine does not compose leaves
the key out, and the plan says so.
2026-10-13 20:29:17 +02:00
mesh-admin bf6beec465 settings: no layer replaces a value the vault fills, at any depth (issue 610)
mesh/merge-gate head bf6beec465c2 merged into trunk 9f3aa91e812b — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head bf6beec465c2 merged into trunk 9f3aa91e812b — pass: its merge-check.sh passed
mesh/delivery delivered
A mergeable file's protected keys were checked at the top level only, so a layer could still set searxng's server.secret_key under the server key its file takes. The merge now refuses any layer value that would replace a ${secret:} placeholder in the file's own content, at any depth, whatever the module's protected list says.
2026-10-13 17:43:24 +00:00
mesh-admin 9f3aa91e81 Merge pull request 'List the settings walk's store tests, and drop a refusal check that never matched (issue 641 review)' (#307) from fix/641-list-the-settings-store-tests into main 2026-10-13 17:35:00 +00:00
jochen 4bdcef3f73 List the settings walk's store tests, and drop a refusal check that never matched (issue 641 review)
mesh/merge-gate head 4bdcef3f73b5 merged into trunk 8ddf8b700558 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 4bdcef3f73b5 merged into trunk 8ddf8b700558 — pass: its merge-check.sh passed
mesh/delivery delivered
A store test not named in testdata/store-tests skips unseen without a store
(issue 459). The check after the send could not match: the send turns its
refusals into words; the composition before it already fails the walk.
2026-10-13 19:24:02 +02:00
mesh-admin 8ddf8b7005 Merge pull request 'Send an approved settings layer by a walk at once, so the operator's Approve takes effect (issue 641, ADR 0344)' (#305) from fix/641-an-approved-setting-is-sent into main 2026-10-13 17:22:20 +00:00
jochen 8bbb1b19ae A settings gate keeps the module's build, and a walk that cannot keep a build fails (issue 641 review)
mesh/merge-gate head 8bbb1b19ae63 merged into trunk 6f1a05366c1d — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 8bbb1b19ae63 merged into trunk 6f1a05366c1d — pass: its merge-check.sh passed
mesh/delivery delivered
Without a build on the gate, a merge walk sending the machine a new build of
the module was read as the settings send's own report. A refusal for a last
send not known, or a kept build gone, never clears, so retrying it every
tick held the walk of waiting builds; it now fails the walk with why. Every
failure names the machines passed over as away (ADR 0344 §6).
2026-10-13 17:33:07 +02:00
jochen e32ca00aed Test that a settings send moves no ungated build waiting on its machine (issue 641, ADR 0344 §3)
mesh/merge-gate head e32ca00aed9d merged into trunk 6f1a05366c1d — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head e32ca00aed9d merged into trunk 6f1a05366c1d — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
2026-10-13 16:55:49 +02:00
jochen 1219ec8229 A settings walk sends the layer alone, every build kept, and stops before a data move (issue 641, ADR 0344)
mesh/merge-gate head 1219ec822943 merged into trunk 6f1a05366c1d — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 1219ec822943 merged into trunk 6f1a05366c1d — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
A judged send carries what waits on its machine, which would have released
builds held after a failed walk without a person's word. The settings walk
now keeps every build of its machine, refuses a send that would move a
running module's data, and never says delivered when it reached no machine.
2026-10-13 16:50:10 +02:00
jochen e324f5d821 Send an approved settings layer by a walk at once, so the operator's Approve is the act that takes effect (issue 641, ADR 0344)
mesh/merge-gate head e324f5d82162 merged into trunk 6f1a05366c1d — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head e324f5d82162 merged into trunk 6f1a05366c1d — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The warrant set the layer and left the push as a second act nobody took: the
operator approved a betterbird change on Telegram and nothing reached g14.
Now the warrant opens a settings walk: the machines the layer reaches, one at
a time, the module judged on each at its gate, its end told once through
plan-moved. The ask and the proposer's answer say so instead of naming push.
2026-10-13 16:39:47 +02:00
mesh-admin 6f1a05366c Merge pull request 'Admit a port from the machine's own local network, and never a public subnet (issue 154, ADR 0340)' (#299) from fix/154-local-network into main 2026-10-13 13:07:11 +00:00
mesh-admin 0a1dc5f93f Merge pull request 'seat: a push to a named machine waits for the machine's answer, so its call says how it went (issue 629)' (#301) from fix/629-a-push-says-how-it-went into main 2026-10-13 12:44:51 +00:00
mesh-admin ae0b384497 push-wait is untrusted, so it is set in one call like any preference (issue 629)
mesh/merge-gate head ae0b384497c2 merged into trunk f3875a6b6019 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head ae0b384497c2 merged into trunk f3875a6b6019 — pass: its merge-check.sh passed
mesh/delivery delivered
The gate warned that the push file asks for a setting without saying whether it is trusted, which makes push-wait terminal-only.
2026-10-13 12:38:31 +00:00
mesh-admin f3875a6b60 Merge pull request 'catalogue: say in the comments that a package may be declared by several modules (issue 628)' (#300) from fix/628-packages-are-shared into main 2026-10-13 12:38:30 +00:00
mesh-admin f09c5ebdec push: the wait is the call's or the controller's setting push-wait, never a length in the code (issue 629)
mesh/merge-gate head f09c5ebdec6c merged into trunk 584aa2d7d7a5 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head f09c5ebdec6c merged into trunk 584aa2d7d7a5 — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The operator's rule: a wait is never hard-coded, at most a default is configured. The push verb takes `wait`; without it the controller's setting `push-wait` (module default 2m) applies; a setting that cannot be read waits for nothing, as a push did before, and says so.
2026-10-13 12:09:43 +00:00
mesh-admin 6d2e4c299a seat: a push to a named machine waits for the machine's answer, so its call says how it went (issue 629)
mesh/merge-gate head 6d2e4c299a34 merged into trunk 584aa2d7d7a5 — pass: builds mesh-controller → novox; no bus step; every machine composes with the chang…
mesh/repo-check head 6d2e4c299a34 merged into trunk 584aa2d7d7a5 — pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
The seat ran `push <node> --wait 0`, so a push's call ended at "sent" and nobody learned whether the machine applied it. The caller is answered before a push starts (answersFirst, issue 265), so waiting holds nobody: the call now waits up to two minutes for the machine's own report and keeps it, applied, refused with the machine's reason, or each failed resource with its error.
2026-10-13 12:03:53 +00:00
mesh-admin b654b320a1 catalogue: say in the comments that a package may be declared by several modules (issue 628)
mesh/merge-gate head b654b320a18a merged into trunk 584aa2d7d7a5 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head b654b320a18a merged into trunk 584aa2d7d7a5 — pass: its merge-check.sh passed
mesh/delivery delivered
The resolver follows ADR 0303, but four comments still stated ADR 0210's retired rule that two modules declaring one package collide, and an agent took them as the rule. What stays exclusive is what a module owns: a path, a unit, a name, and a package declared present by one module and absent by another.
2026-10-13 11:52:17 +00:00
jochen 4cc8588afb Test that a global IPv6 subnet is public and never counts as the local network (issue 154, ADR 0340)
mesh/merge-gate head 4cc8588afb87 merged into trunk 0a1dc5f93fcb — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head 4cc8588afb87 merged into trunk 0a1dc5f93fcb — pass: its merge-check.sh passed
mesh/delivery delivered
mesh/delivery-group group fix/154-local-network delivered: every member is delivered
The review asked for the IPv6 side of the rule that local-network cannot open a port to the internet.
2026-10-13 13:20:50 +02:00
mesh-admin 584aa2d7d7 Merge pull request 'Never make a credential only a person can give, and give it once per provider (hq issue 164, ADR 0337)' (#298) from fix/164-an-accepted-credential-is-never-minted into main 2026-10-13 09:52:07 +00:00
jochen c9dd07f919 Never make a credential only a person can give, and give it once per provider (hq issue 164, ADR 0337)
mesh/merge-gate head c9dd07f919a2 merged into trunk 404b4409e70e — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head c9dd07f919a2 merged into trunk 404b4409e70e — pass: its merge-check.sh passed (run 2)
mesh/delivery delivered
A Servarr app's API key, an indexer's key and a downloader's password are values the provider's
software holds; the pair path made a random value for every consumer of them, sealed it to both ends
and reported success, and the provider refused it. An offer may now say `"credential": {"issued-by":
"outside"}`: SecretFor never makes a value for it, the plan reads such a pair as given or not given
and sends the consumer no credential file until a person gives it, without refusing its machine;
remakeShared and SharedSecretFor never make a provider's own secret issued outside either.
`secret accept <provider's node> <provider module> <provision>` seals one value to every consumer that
binds the provision from that provider, the provider and the operator, under one generation; the
per-pair form is refused for such a provision, and rotate names the accept. A consumer may wait for a
provision's credential: the controller excuses the wait only when the store bears it out, and its
needs-operator condition names the one accept line.

Adds the replay of issue 164 (TestReplay164), written with only what the controller had before.
2026-10-13 11:37:31 +02:00
mesh-admin 404b4409e7 Merge pull request 'A built module keeps its build edges: a new SDK plans the toolchain and its TypeScript bundles (hq issue 614)' (#296) from fix/614-a-built-module-keeps-its-build-edges into main 2026-10-13 09:35:26 +00:00
jochen 6e6cd5e26d Admit a port from the machine's own local network, and never a public subnet (issue 154, ADR 0340)
mesh/delivery-group group fix/154-local-network checking: 0 of 2 member(s) ready
mesh/merge-gate out of date: judged against trunk 1826b68a22b5, the trunk is now 404b4409e70e; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 1826b68a22b5, the trunk is now 404b4409e70e; judged again on a recheck or when it is merged
mesh/delivery superseded: a newer head of the same pull request
A local service could only be declared public, which says the internet. local-network is a source and a reach: the private network and the private networks the machine reported on a link facing outside, judged here, shown by plan and node, and said in the rule set when there are none.
2026-10-13 11:20:22 +02:00
jochen e658a14296 Read a built module's build edges from what is kept of it, so a new SDK rebuilds its toolchain and bundles (hq issue 614)
mesh/merge-gate head e658a14296f9 merged into trunk 1826b68a22b5 — pass: builds build-agent, mesh-controller → ace, g14, novox, shanks; no bus step; every …
mesh/repo-check head e658a14296f9 merged into trunk 1826b68a22b5 — pass: its merge-check.sh passed
mesh/delivery delivered
A registered manifest carries no build, so mesh-tools' build.on naming the
SDK's package and every bundle's toolchain were edges only before a module was
first built: mesh-sdk 0.1.14 planned "sent nowhere". The relation now reads a
package base from the recorded name@version against the package the mesh holds,
and the toolchain from the bundles the resolved manifest keeps. Only records the
mesh already keeps are read, so it holds for every module built before it and a
rollback reads the same records. Replay R614.
2026-10-13 11:08:17 +02:00
jschoubben b1546ed28a Hold a walk's first-machine gate while its busy container finishes its build
mesh/delivery superseded: a newer head of the same pull request
mesh/delivery-group group feat/335-a-busy-container-is-replaced-when-idle rejected: its order contradicts itself: novox/mesh-catalog@190608193764, novox/mesh-c…
mesh/merge-gate out of date: judged against trunk 56d6313262e8, the trunk is now cbac703e817d; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 56d6313262e8, the trunk is now cbac703e817d; judged again on a recheck or when it is merged
A merge into mesh-controller replaced every build agent and threw away the
checks in flight (issue 594). The manifest's container busy is checked and
sent only to an engine that reads it; a report's busy waits are kept, the
gate starts at the report that says the container replaced, and the wait is
a phase of the delivery time. mesh-builder busy drains the agent and answers
while a build is in hand (novox/hq ADR 0335).
2026-10-13 00:53:10 +02:00
111 changed files with 9341 additions and 334 deletions
+146
View File
@@ -0,0 +1,146 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// **The build agent says whether it is busy, and being asked drains it** (novox/hq ADR 0335 §1, §5). Its
// container declares `busy` as `mesh-builder busy`: the node-engine runs it inside the container before it
// replaces it. It exits 0 while a build is in hand and 1 when none is; and from the first ask the agent takes
// no new build request, so its answer ends once the build in hand ends — a replaced builder finishes the check
// it runs, instead of the build queue handing it out again from the start (issue 594).
//
// Two marks in the container's own temporary directory, as the ready mark (ready.go): a new container starts
// without them, and a restarted one removes them before it takes work again.
//
// - in hand: written by the agent when it claims a request, before it reads whether it is drained; removed
// when the build ends.
// - drained: written by the ask, before it reads whether a build is in hand.
//
// Each side writes its own mark before it reads the other's, so an ask racing a fetch sees the build in hand,
// or the agent sees the drain and hands the request back; never neither.
func inHandMark() string { return filepath.Join(os.TempDir(), "mesh-builder.in-hand") }
func drainMark() string { return filepath.Join(os.TempDir(), "mesh-builder.drained") }
// The drain lapses when no ask has come for drainLapse: the node-engine asks every 20 seconds for as long as it
// really waits, so an agent whose replacement was withdrawn — the declaration put back, the walk stopped —
// takes work again rather than staying drained for ever (review of ADR 0335). And an agent drained for longer
// than the node-engine's bound and the slack says it is not ready: the replacement it was drained for never
// came, which is a fault to see.
const (
drainLapse = 2 * time.Minute
drainTooLong = 15*time.Minute + 5*time.Minute
)
// drainedAt is when the drain began and when it was last asked, from its mark; false when there is no mark.
func drainedAt() (began, asked time.Time, ok bool) {
info, err := os.Stat(drainMark())
if err != nil {
return time.Time{}, time.Time{}, false
}
asked = info.ModTime()
began = asked
if raw, err := os.ReadFile(drainMark()); err == nil {
if t, err := time.Parse(time.RFC3339, strings.TrimSpace(string(raw))); err == nil && !t.After(asked) {
began = t
}
}
return began, asked, true
}
// drained says the agent was asked whether it is busy within drainLapse: it takes no new build request.
func drained() bool { return drainedSince(time.Now()) }
func drainedSince(now time.Time) bool {
_, asked, ok := drainedAt()
return ok && now.Sub(asked) < drainLapse
}
// drainedTooLong is the health check's second half: nil unless the agent has been drained, asked all along, for
// longer than its node-engine's bound and the slack.
func drainedTooLong(now time.Time) error {
began, asked, ok := drainedAt()
if !ok || now.Sub(asked) >= drainLapse || now.Sub(began) <= drainTooLong {
return nil
}
return fmt.Errorf("drained since %s and still asked: the replacement its node-engine waits for has not come "+
"within %s (novox/hq ADR 0335)", began.UTC().Format(time.RFC3339), drainTooLong)
}
// claim marks a request in hand, unless the agent is drained: then nothing is in hand, and the request goes
// back to the queue.
func claim() bool {
// Made, never written over: one build at a time, and a mark already there is a build in hand.
f, err := os.OpenFile(inHandMark(), os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
// Unmarked, an ask would say idle while this build runs: the build is not taken.
fmt.Fprintf(os.Stderr, "cannot mark a build in hand (%s), so it is handed back: %v\n", inHandMark(), err)
return false
}
_, err = f.WriteString(time.Now().UTC().Format(time.RFC3339) + "\n")
if closeErr := f.Close(); err == nil {
err = closeErr
}
if err != nil {
_ = os.Remove(inHandMark())
fmt.Fprintf(os.Stderr, "cannot mark a build in hand (%s), so it is handed back: %v\n", inHandMark(), err)
return false
}
if drained() {
_ = os.Remove(inHandMark())
fmt.Fprintln(os.Stderr, "asked whether busy, so this agent takes no new build: handed back")
return false
}
return true
}
// holding says what is in hand, for the ask to print: written once the request is read.
func holding(what string) {
_ = os.WriteFile(inHandMark(), []byte(time.Now().UTC().Format(time.RFC3339)+" "+what+"\n"), 0o644)
}
// released ends the build in hand.
func released() {
if err := os.Remove(inHandMark()); err != nil && !errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(os.Stderr, "cannot remove the mark of the build in hand (%s): %v\n", inHandMark(), err)
}
}
// undrained clears both marks at the start of a holder: a container started again is asked afresh.
func undrained() error {
for _, mark := range []string{drainMark(), inHandMark()} {
if err := os.Remove(mark); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("cannot remove %s: %w", mark, err)
}
}
return nil
}
// busy is the ask: it drains the agent, then says whether a build is in hand. Nil — exit 0 — while one is.
func busy() (string, error) {
// The mark keeps when the drain began while it is asked within drainLapse, and is written again on every
// ask: its time is the last ask.
began := time.Now().UTC()
if was, _, ok := drainedAt(); ok && drained() {
began = was.UTC()
}
if err := os.WriteFile(drainMark(), []byte(began.Format(time.RFC3339)+"\n"), 0o644); err != nil {
// Not drained, it could take the next build: said, and answered busy only for what is in hand now.
fmt.Fprintf(os.Stderr, "cannot mark this agent drained (%s): %v\n", drainMark(), err)
}
held, err := os.ReadFile(inHandMark())
if err != nil {
return "", errors.New("idle: no build in hand, and this agent takes no new one")
}
since, what, _ := strings.Cut(strings.TrimSpace(string(held)), " ")
if what == "" {
what = "a build"
}
return fmt.Sprintf("busy: %s, in hand since %s; taking no new build", what, since), nil
}
+162
View File
@@ -0,0 +1,162 @@
package main
import (
"os"
"strings"
"sync"
"testing"
"time"
)
// `mesh-builder busy` answers 0 while a build is in hand and non-zero when idle, and after its first ask the
// agent takes no new build request (novox/hq ADR 0335 §5).
func TestBusyAnswersWhileABuildIsInHandAndDrainsTheAgent(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
if err := undrained(); err != nil {
t.Fatal(err)
}
if drained() {
t.Fatal("an agent never asked is drained")
}
// A build is claimed and runs.
if !claim() {
t.Fatal("an agent never asked refused a build")
}
holding("novox/mesh-controller (check-280)")
said, err := busy()
if err != nil || !strings.Contains(said, "novox/mesh-controller (check-280)") {
t.Fatalf("an agent with a build in hand did not answer busy with what it holds: %q %v", said, err)
}
// From that ask on it takes nothing new: the fetch is not made, and one fetched is handed back.
if !drained() {
t.Fatal("the ask did not drain the agent")
}
if claim() {
t.Fatal("a drained agent took a new build")
}
// The build in hand is still in hand after the refused claim, and busy goes on saying so.
if _, err := busy(); err != nil {
t.Fatalf("a refused claim ended the build in hand: %v", err)
}
// It ends: idle, and still drained.
released()
if _, err := busy(); err == nil {
t.Fatal("an agent with nothing in hand answered busy")
}
if claim() {
t.Fatal("a drained agent took a build after its last one ended")
}
// Started again, it is asked afresh.
if err := undrained(); err != nil {
t.Fatal(err)
}
if drained() || !claim() {
t.Fatal("a holder started again is still drained")
}
released()
}
// An ask racing a fetch sees the build in hand, or the agent sees the drain and hands the request back: never
// neither, which would replace the container under a build just taken.
func TestAnAskRacingAFetchNeverMissesTheBuild(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
for i := 0; i < 200; i++ {
if err := undrained(); err != nil {
t.Fatal(err)
}
var claimed bool
var askErr error
var wg sync.WaitGroup
wg.Add(2)
go func() { defer wg.Done(); claimed = claim() }()
go func() { defer wg.Done(); _, askErr = busy() }()
wg.Wait()
if claimed && askErr != nil {
t.Fatalf("round %d: the agent took a build and the ask said idle", i)
}
released()
}
}
// A drain no longer asked lapses: an agent whose replacement was withdrawn after the first ask (a put-back, a
// revert, a stopped walk) takes work again after drainLapse, rather than staying drained with its health green.
func TestADrainNoLongerAskedLapses(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
if err := undrained(); err != nil {
t.Fatal(err)
}
if _, err := busy(); err == nil {
t.Fatal("an idle agent answered busy")
}
if !drained() || claim() {
t.Fatal("an agent just asked takes work")
}
// The last ask was three minutes ago.
old := time.Now().Add(-3 * time.Minute)
if err := os.Chtimes(drainMark(), old, old); err != nil {
t.Fatal(err)
}
if drained() {
t.Fatal("a drain no longer asked for three minutes still holds")
}
if !claim() {
t.Fatal("an agent whose drain lapsed takes no work")
}
released()
}
// An agent drained, and asked all along, for longer than its node-engine's bound and the slack is not ready: the
// replacement it was drained for never came. One asked again after a lapse starts a new drain.
func TestAnAgentDrainedTooLongIsNotReady(t *testing.T) {
t.Setenv("TMPDIR", t.TempDir())
if err := undrained(); err != nil {
t.Fatal(err)
}
taking("mesh-build-machine")
if _, err := busy(); err == nil {
t.Fatal("an idle agent answered busy")
}
now := time.Now()
if err := drainedTooLong(now); err != nil {
t.Fatalf("an agent drained a moment ago is not ready: %v", err)
}
// The drain began 25 minutes ago, and the node-engine asked a moment ago.
began := now.Add(-25 * time.Minute).UTC().Format(time.RFC3339)
if err := os.WriteFile(drainMark(), []byte(began+"\n"), 0o644); err != nil {
t.Fatal(err)
}
err := drainedTooLong(time.Now())
if err == nil || !strings.Contains(err.Error(), "has not come") {
t.Fatalf("an agent drained for 25 minutes reads ready: %v", err)
}
// Each ask keeps when the drain began.
if _, err := busy(); err == nil {
t.Fatal("an idle agent answered busy")
}
if err := drainedTooLong(time.Now()); err == nil {
t.Fatal("an ask started the drain again")
}
// The asks stopped: the drain lapsed, and so did the fault.
old := time.Now().Add(-3 * time.Minute)
if err := os.Chtimes(drainMark(), old, old); err != nil {
t.Fatal(err)
}
if err := drainedTooLong(time.Now()); err != nil {
t.Fatalf("a lapsed drain still reads not ready: %v", err)
}
}
// An ask this build does not know exits non-zero: an older image asked `busy` reads idle, not busy for the
// node-engine's whole bound.
func TestAnUnknownCommandFails(t *testing.T) {
was := os.Args
t.Cleanup(func() { os.Args = was })
os.Args = []string{"mesh-builder", "some-later-word"}
if err := run(); err == nil {
t.Fatal("an unknown command exited 0")
}
os.Args = []string{"mesh-builder", "help"}
if err := run(); err != nil {
t.Fatalf("help failed: %v", err)
}
}
+31
View File
@@ -41,6 +41,9 @@ type holder struct {
say func(link.HolderState) error say func(link.HolderState) error
// remove runs what a kill needs outside the build: the containers left behind. // remove runs what a kill needs outside the build: the containers left behind.
remove builder.Runner remove builder.Runner
// scratch is where check builds are kept (novox/hq ADR 0350 §5): what the week's sweep lets go of. Nil where
// this machine publishes to no artifact store.
scratch builder.Scratch
mu sync.Mutex mu sync.Mutex
paused bool paused bool
@@ -131,6 +134,34 @@ func (h *holder) stateWhilePaused(ctx context.Context, every time.Duration) {
} }
} }
// expireChecks lets go of every check build older than a week (novox/hq ADR 0350 §5), at start and every while
// after. Every holder sweeps the one store; what one let go of, another finds gone.
func (h *holder) expireChecks(ctx context.Context, every time.Duration, now func() time.Time) {
sweep := func() {
if h.scratch == nil {
return
}
n, err := h.scratch.Expire(ctx, builder.CheckBuildsKept, now())
switch {
case err != nil:
fmt.Fprintf(os.Stderr, "check builds older than %s: %d let go of, and not all: %v\n", builder.CheckBuildsKept, n, err)
case n > 0:
fmt.Fprintf(os.Stderr, "check builds older than %s: %d check(s)' builds let go of\n", builder.CheckBuildsKept, n)
}
}
sweep()
tick := time.NewTicker(every)
defer tick.Stop()
for {
select {
case <-ctx.Done():
return
case <-tick.C:
sweep()
}
}
}
// begin records the build this machine took, with the cancel that ends it. // begin records the build this machine took, with the cancel that ends it.
func (h *holder) begin(request link.BuildRequest, cancel context.CancelFunc) *running { func (h *holder) begin(request link.BuildRequest, cancel context.CancelFunc) *running {
r := &running{request: request, started: time.Now(), cancel: cancel, done: make(chan struct{})} r := &running{request: request, started: time.Now(), cancel: cancel, done: make(chan struct{})}
+108 -7
View File
@@ -75,6 +75,12 @@ digest of their own configuration. The result is printed as JSON.
exits 0 once the agent running in this container is bound to its build work and pulls it, and exits 0 once the agent running in this container is bound to its build work and pulls it, and
1 before: its container's health check. 1 before: its container's health check.
mesh-builder busy
exits 0 while the agent running in this container has a build in hand, and 1 when it has none;
from the first ask the agent takes no new build: its container's busy command, asked before the
container is replaced (novox/hq ADR 0335).
` `
func run() error { func run() error {
@@ -86,17 +92,41 @@ func run() error {
case "build": case "build":
return buildOnce(context.Background(), os.Args[2:]) return buildOnce(context.Background(), os.Args[2:])
case "ready": case "ready":
// The container's health check (novox/hq issue 544): exits non-zero until the agent takes work. // The container's health check (novox/hq issue 544): exits non-zero until the agent takes work, and
return ready() // once it has been drained longer than its node-engine would wait to replace it (ADR 0335).
default: if err := ready(); err != nil {
return err
}
return drainedTooLong(time.Now())
case "busy":
// The container's busy command (novox/hq ADR 0335): drains the agent, and exits non-zero once
// nothing is in hand.
said, err := busy()
if err != nil {
return err
}
fmt.Println(said)
return nil
case "help", "-h", "--help":
fmt.Print(usage) fmt.Print(usage)
return nil return nil
default:
// **An ask this build does not know fails** (novox/hq ADR 0335): an image older than `busy`, asked
// it, must read as idle — exiting 0 would hold its container for the node-engine's whole bound.
fmt.Fprint(os.Stderr, usage)
return fmt.Errorf("%q is not a command this mesh-builder knows", os.Args[1])
} }
} }
if err := notReady(); err != nil { if err := notReady(); err != nil {
return err return err
} }
// **The builder's own version is part of every reuse key** (novox/hq ADR 0350 §1): a builder that builds
// differently never takes a build another one made.
builder.Version = builder.SelfVersion(version)
if err := undrained(); err != nil {
return err
}
credential, err := brokerFrom() credential, err := brokerFrom()
if err != nil { if err != nil {
return err return err
@@ -149,8 +179,13 @@ func run() error {
// **What an earlier holder here left goes before anything is taken** (novox/hq issue 306): a holder // **What an earlier holder here left goes before anything is taken** (novox/hq issue 306): a holder
// recreated mid-check left the check's store and bus running, and the ask went to another machine. // recreated mid-check left the check's store and bus running, and the ask went to another machine.
h.removeLeftBehind() h.removeLeftBehind()
machine := link.MachineOverNATSWith(js, on, seat, link.MachineOptions{Paused: h.Paused, // Paused or drained, it takes nothing new (novox/hq ADR 0219, ADR 0335): a drained agent finishes the build
Taking: func() { taking(seat) }}) // in hand and then says idle, and its container is replaced between builds.
machine := link.MachineOverNATSWith(js, on, seat, link.MachineOptions{
Paused: func() bool { return h.Paused() || drained() },
Taking: func() { taking(seat) },
Claim: claim,
})
defer machine.Close() defer machine.Close()
// The seat's verbs, on this machine's subjects. Only the seat that declares them: the retired // The seat's verbs, on this machine's subjects. Only the seat that declares them: the retired
@@ -174,8 +209,13 @@ func run() error {
fmt.Fprintf(os.Stderr, "building for the mesh, publishing to %s\n", registry) fmt.Fprintf(os.Stderr, "building for the mesh, publishing to %s\n", registry)
publisher := builder.Registry{Address: registry, Run: builder.Command} publisher := builder.Registry{Address: registry, Run: builder.Command}
// Check builds are let go of after a week (novox/hq ADR 0350 §5).
h.scratch = publisher
go h.expireChecks(ctx, time.Hour, time.Now)
return machine.Take(ctx, func(ctx context.Context, work link.Build) { return machine.Take(ctx, func(ctx context.Context, work link.Build) {
defer released()
holding(work.Request().Repository + " (" + work.Request().ID + ")")
answer(ctx, publisher, on, workspace, work, h) answer(ctx, publisher, on, workspace, work, h)
}) })
} }
@@ -262,6 +302,7 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
if err == nil { if err == nil {
say("packages", saysRegistry(npmrc)) say("packages", saysRegistry(npmrc))
} }
registryHere, isRegistry := publisher.(builder.Registry)
var built builder.Result var built builder.Result
if request.Check != nil { if request.Check != nil {
// **A pull request's merge check, not a build** (novox/hq to-be 45 §9): nothing is built, // **A pull request's merge check, not a build** (novox/hq to-be 45 §9): nothing is built,
@@ -271,12 +312,21 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
if r, ok := publisher.(builder.Registry); ok { if r, ok := publisher.(builder.Registry); ok {
registry = r.Address registry = r.Address
} }
spec := checkSpecOf(request)
// What the merge would move, built by the check for the walk to take (novox/hq ADR 0350 §1): into the
// scratch namespace. **Never with the package registry's credential**: this is code nobody has approved
// yet, and a recipe could read the credential it is handed (issue 462). A module that resolves packages
// at build time has no reuse key anyway, so nothing is lost by refusing it here.
if isRegistry && len(spec.Builds) > 0 {
spec.BuildWith = &builder.CheckBuilding{Publish: publisher, Scratch: registryHere, Held: request.Held,
Seats: request.Seats}
}
var v builder.CheckVerdict var v builder.CheckVerdict
v, err = builder.Check(building, builder.Command, checkSpecOf(request), workspace, registry, forgeFrom(), say) v, err = builder.Check(building, builder.Command, spec, workspace, registry, forgeFrom(), say)
if err == nil { if err == nil {
result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report, result.Check = &link.CheckOutcome{Verdict: v.Verdict, Summary: v.Summary, Report: v.Report,
Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo), Took: v.Took.Round(time.Second).String(), Gate: layerOf(v.Gate), RepoCheck: layerOf(v.Repo),
Head: v.Head, Trunk: v.Trunk, Beside: v.Beside} Head: v.Head, Trunk: v.Trunk, Beside: v.Beside, Built: checkBuiltOf(v.Built)}
// What each of a group's heads was judged as (novox/hq ADR 0323 §6). // What each of a group's heads was judged as (novox/hq ADR 0323 §6).
for _, m := range v.Members { for _, m := range v.Members {
result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo, result.Check.Members = append(result.Check.Members, link.CheckedMember{Owner: m.Owner, Repo: m.Repo,
@@ -289,10 +339,31 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
// after a clone that then fails at npm ci. // after a clone that then fails at npm ci.
// Every container it starts is labelled with its id, so a kill finds what outlived the // Every container it starts is labelled with its id, so a kill finds what outlived the
// docker client (ADR 0219). // docker client (ADR 0219).
if request.Reuse != nil && isRegistry && !request.DryRun {
// **A walk's build takes the check's build of the same key** (novox/hq ADR 0350 §2).
reuse := builder.Reuse{Scratch: registryHere}
for _, p := range request.Reuse.Pulls {
reuse.Pulls = append(reuse.Pulls, builder.PullRef{Owner: p.Owner, Repo: p.Repo, Number: p.Number})
}
// Only what a verdict names is taken (ADR 0350 §2).
for _, b := range request.Reuse.Builds {
named := builder.NamedBuild{Pull: builder.PullRef{Owner: b.Pull.Owner, Repo: b.Pull.Repo,
Number: b.Pull.Number}, Check: b.Check, Module: b.Built.Module, Key: b.Built.Key,
Digests: map[string]string{}}
for _, m := range b.Built.Made {
named.Digests[m.Name] = builder.DigestOf(m.Reference)
}
reuse.Named = append(reuse.Named, named)
}
built, err = builder.BuildReusing(building, builder.Labelled(builder.Command, request.ID), publisher, reuse,
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
forgeFrom(), say, request.Seats)
} else {
built, err = builder.Build(building, builder.Labelled(builder.Command, request.ID), publisher, built, err = builder.Build(building, builder.Labelled(builder.Command, request.ID), publisher,
request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc, request.Repository, request.Path, request.Ref, workspace, request.Held, npmrc,
forgeFrom(), say, request.Seats) forgeFrom(), say, request.Seats)
} }
}
// What it copied into the store, whatever became of the build (novox/hq ADR 0257). // What it copied into the store, whatever became of the build (novox/hq ADR 0257).
result.Mirrored = built.Mirrored result.Mirrored = built.Mirrored
// Only a build the kill ended: the kill came before its work did. One that finished — built, or // Only a build the kill ended: the kill came before its work did. One that finished — built, or
@@ -327,6 +398,19 @@ func answer(ctx context.Context, publisher builder.Publisher, on, workspace stri
} }
result.Against = built.Against result.Against = built.Against
result.SourceFingerprint = built.Source result.SourceFingerprint = built.Source
// What the walk did instead of building, or why it built (novox/hq ADR 0350 §3, §4).
result.ReuseKey = built.ReuseKey
if built.Reused != nil {
f := built.Reused.From
result.Reused = built.Reused.Words()
result.ReusedFrom = &link.ReusedFrom{Pull: link.PullRef{Owner: f.Pull.Owner, Repo: f.Pull.Repo,
Number: f.Pull.Number}, Head: f.Head, Trunk: f.Trunk, Check: f.Check, Key: f.Key}
} else if request.Reuse != nil {
result.BuiltWhy = built.Why
if result.BuiltWhy == "" {
result.BuiltWhy = "it has no artifact to build"
}
}
result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches result.Trunk, result.OnTrunk, result.Branches = built.Trunk, built.OnTrunk, built.Branches
for _, r := range built.Read { for _, r := range built.Read {
result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref}) result.Read = append(result.Read, link.ReadRepository{Repository: r.Repository, Ref: r.Ref})
@@ -377,6 +461,10 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
for dir, b := range c.Beside { for dir, b := range c.Beside {
spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref} spec.Beside[dir] = builder.Beside{Repository: b.Repository, Ref: b.Ref}
} }
for _, b := range c.Builds {
spec.Builds = append(spec.Builds, builder.CheckBuildSpec{Module: b.Module, Repository: b.Repository, Path: b.Path,
Ref: b.Ref, Merged: b.Merged, Tier: b.Tier, Skip: b.Skip})
}
for _, m := range c.Members { for _, m := range c.Members {
spec.Group = append(spec.Group, builder.GroupHead{Owner: m.Owner, Repo: m.Repo, Repository: m.Repository, spec.Group = append(spec.Group, builder.GroupHead{Owner: m.Owner, Repo: m.Repo, Repository: m.Repository,
Ref: m.Ref, Paths: m.Paths, Base: m.Base}) Ref: m.Ref, Paths: m.Paths, Base: m.Base})
@@ -384,6 +472,19 @@ func checkSpecOf(request link.BuildRequest) builder.CheckSpec {
return spec return spec
} }
// checkBuiltOf is a check's builds for its merge as the outcome carries them (novox/hq ADR 0350 §1).
func checkBuiltOf(built []builder.CheckBuilt) []link.CheckBuilt {
var out []link.CheckBuilt
for _, b := range built {
c := link.CheckBuilt{Module: b.Module, Key: b.Key, NotBuilt: b.NotBuilt, Took: b.Took.Round(time.Second).String()}
for _, m := range b.Made {
c.Made = append(c.Made, link.MadeArtifact{Name: m.Name, Kind: m.Kind, Reference: m.Reference})
}
out = append(out, c)
}
return out
}
// layerOf is one layer of a check as the outcome carries it. // layerOf is one layer of a check as the outcome carries it.
func layerOf(l *builder.Layer) *link.CheckLayer { func layerOf(l *builder.Layer) *link.CheckLayer {
if l == nil { if l == nil {
+1
View File
@@ -16,6 +16,7 @@ func TestBuilderDiagnosticsStayOffStdout(t *testing.T) {
"version)": true, // --version "version)": true, // --version
`"stopping")`: true, // the loop.s shutdown line `"stopping")`: true, // the loop.s shutdown line
"usage)": true, // --help text, for a human "usage)": true, // --help text, for a human
"said)": true, // busy: the answer its node-engine reads (novox/hq ADR 0335)
} }
for _, file := range []string{"once.go", "main.go"} { for _, file := range []string{"once.go", "main.go"} {
src, err := os.ReadFile(file) src, err := os.ReadFile(file)
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"context"
"fmt"
"sort"
"strings"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// A provision whose credential only a person outside the mesh can give (novox/hq ADR 0337, issue 164, issue 165):
// given once for its provider, at the controller's terminal, and sealed in one act to every consumer that binds the
// provision from that provider now. The value is never read back, printed or kept.
// outsideOffer is the provider module's own name when `secret accept <node> <module> <name>` names a provision that
// module offers with a credential issued outside the mesh, and "" when it names anything else (an own secret, or a
// pair with --provider).
func outsideOffer(ctx context.Context, module, name, provider string) (string, error) {
if provider != "" {
return "", nil
}
open, err := openStores(ctx)
if err != nil {
return "", err
}
defer open.Close()
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return "", err
}
if m, known := shelf[module]; known && m.IssuedOutsideOf(name) {
return module, nil
}
return "", nil
}
// offeredOutsideBy is a module that offers the provision with a credential issued outside the mesh, or "".
func offeredOutsideBy(ctx context.Context, provision string) (string, error) {
open, err := openStores(ctx)
if err != nil {
return "", err
}
defer open.Close()
shelf, err := open.inventory.Catalogue(ctx)
if err != nil {
return "", err
}
module, _ := catalogue.OffersIssuedOutside(shelf, provision)
return module, nil
}
// consumersFrom is every consumer that binds a provision from one provider now, found by resolving every machine as
// its next send would, and the machines that could not be resolved, with why.
func consumersFrom(ctx context.Context, open *stores, provider, providerModule, provision string) (
[]inventory.PairEnd, []string, error) {
nodes, err := open.inventory.Nodes(ctx)
if err != nil {
return nil, nil, err
}
var out []inventory.PairEnd
var unresolved []string
for _, n := range nodes {
plan, _, err := planFor(ctx, open, n.Name)
if err != nil {
unresolved = append(unresolved, fmt.Sprintf("%s: %s", n.Name, oneLine(err.Error())))
continue
}
out = append(out, bindsFrom(plan, n.Name, provider, providerModule, provision)...)
}
return out, unresolved, nil
}
// bindsFrom is every consumer on one machine whose resolution answers the provision from that provider. Pure.
func bindsFrom(plan catalogue.Resolution, machine, provider, providerModule, provision string) []inventory.PairEnd {
var out []inventory.PairEnd
for _, need := range plan.Needs {
if need.ByRecord || need.Name != provision || need.From != provider {
continue
}
if need.Module != "" && need.Module != providerModule {
continue
}
out = append(out, inventory.PairEnd{Consumer: machine, ConsumerModule: need.For, Local: need.Local})
}
sort.Slice(out, func(i, j int) bool {
return out[i].ConsumerModule+"\x00"+out[i].Local < out[j].ConsumerModule+"\x00"+out[j].Local
})
return out
}
// acceptForProvision gives a provision's credential issued outside the mesh, once for its provider (ADR 0337 §3).
func acceptForProvision(ctx context.Context, provider, providerModule, provision, value string) error {
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
consumers, unresolved, err := consumersFrom(ctx, open, provider, providerModule, provision)
if err != nil {
return err
}
accepted, err := open.inventory.AcceptSecretForProvision(ctx, provision, provider, providerModule, value, consumers)
value = ""
if err != nil {
return err
}
fmt.Print(acceptedSaid(provider, providerModule, provision, accepted, unresolved))
if len(accepted.Sealed) == 0 {
return nil
}
if err := sendTo(ctx, open, accepted.Machines); err != nil {
return fmt.Errorf("%s is sealed and could not be sent: %w; `push %s` sends it", provision, err,
strings.Join(accepted.Machines, "`, `push "))
}
fmt.Printf(" sent %s; each consumer starts again on it\n", strings.Join(accepted.Machines, ", "))
return nil
}
// acceptedSaid is what one accept for a provider did, in words that never carry the value. Pure.
func acceptedSaid(provider, providerModule, provision string, a inventory.ProvisionAccepted, unresolved []string) string {
var b strings.Builder
if len(a.Sealed) == 0 {
fmt.Fprintf(&b, "%s from %s on %s: no consumer binds it now, so nothing was sealed; a consumer that binds "+
"later waits for it, and this line given again seals it to every consumer then\n", provision,
providerModule, provider)
} else {
var names []string
for _, c := range a.Sealed {
names = append(names, fmt.Sprintf("%s on %s%s", c.ConsumerModule, c.Consumer, asLocal(c.Local)))
}
fmt.Fprintf(&b, "%s from %s on %s is given, sealed to %s and to %s\n", provision, providerModule, provider,
strings.Join(names, ", "), provider)
fmt.Fprintf(&b, " the mesh cannot read it back, never makes one of its own for it, and will not rotate it\n")
}
for _, c := range a.Unsealable {
fmt.Fprintf(&b, " not sealed to %s on %s: no sealing key on that machine; give it again once it has one\n",
c.ConsumerModule, c.Consumer)
}
for _, u := range unresolved {
fmt.Fprintf(&b, " not known whether a consumer on %s binds it, which did not resolve; give it again once it does\n", u)
}
return b.String()
}
+3 -2
View File
@@ -203,8 +203,9 @@ func seatDependenciesOnAssign(ctx context.Context, open *stores, node string, mo
if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil { if _, err := catalogue.AssignRefusal(shelf, node, assigned, adding); err != nil {
return nil, nil, err return nil, nil, err
} }
// Two modules declaring one package, path or unit is refused before anything is recorded // Two modules declaring one path or unit, or one package present and absent, is refused before anything
// (novox/hq ADR 0210, 04-ISSUES/235): kept, the node would not resolve until one came off again. // is recorded (novox/hq ADR 0210, ADR 0303, 04-ISSUES/235): kept, the node would not resolve until one came
// off again. Two modules declaring one package present is not a collision (ADR 0303, issue 628).
if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil { if err := catalogue.CollisionRefusal(shelf, node, assigned, adding); err != nil {
return nil, nil, err return nil, nil, err
} }
+15 -1
View File
@@ -722,7 +722,7 @@ func converge(ctx context.Context, open *stores, node string, yes bool, digest s
return "", err return "", err
} }
derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh, derived := derivedFilter{rules: rules, foundation: with.Foundation, mesh: with.Mesh,
outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks} outward: plan.PublicDomain != "", outwardLinks: with.OutwardLinks, local: with.LocalNetworks}
filtering, err := inv.FilteringOf(ctx, node) filtering, err := inv.FilteringOf(ctx, node)
if err != nil { if err != nil {
return "", err return "", err
@@ -930,6 +930,9 @@ type derivedFilter struct {
// The filter constrains what arrives on them; everything arriving elsewhere is this machine's // The filter constrains what arrives on them; everything arriving elsewhere is this machine's
// own guest and is not filtered. // own guest and is not filtered.
outwardLinks []string outwardLinks []string
// local is the machine's local networks, which a port from `local-network` stays open to (novox/hq
// ADR 0340).
local []string
} }
// closesOutside is what a narrowing from everywhere to the private network is called: it closes. // closesOutside is what a narrowing from everywhere to the private network is called: it closes.
@@ -977,6 +980,17 @@ func (d derivedFilter) fate(r inventory.Reach) string {
if !onMesh { if !onMesh {
return fmt.Sprintf("%s — declared by %s from the mesh only", closesOutside, by) return fmt.Sprintf("%s — declared by %s from the mesh only", closesOutside, by)
} }
case catalogue.FromLocalNetwork:
// The private network and this machine's local networks (novox/hq ADR 0340): it closes to
// everything else outside, and says which local networks stay.
if !onMesh {
if len(d.local) == 0 {
return fmt.Sprintf("%s — declared by %s from the local network, and this machine "+
"reported none", closesOutside, by)
}
return fmt.Sprintf("WILL CLOSE to everything outside the private network and %s — declared "+
"by %s from the local network", strings.Join(d.local, ", "), by)
}
} }
return fmt.Sprintf("declared by %s (from %s)", by, rule.From) return fmt.Sprintf("declared by %s (from %s)", by, rule.From)
} }
+18
View File
@@ -15,6 +15,7 @@ import (
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/link"
@@ -483,6 +484,7 @@ func buildOneAsked(ctx context.Context, source buildSource, path, ref string, wa
Seats: seatBases(ctx), Seats: seatBases(ctx),
DryRun: dryRun, DryRun: dryRun,
} }
request.Reuse = reuseAsked(ctx, dryRun)
fmt.Printf("asked for %s", source) fmt.Printf("asked for %s", source)
if source.Seat != "" { if source.Seat != "" {
fmt.Printf(" (%s)", repository) fmt.Printf(" (%s)", repository)
@@ -669,6 +671,12 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On, return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %w", result.On,
manifest.Module, short(result.Commit), err) manifest.Module, short(result.Commit), err)
} }
// **Never a build in the scratch namespace** (novox/hq ADR 0239 §9, ADR 0350 §2): what a check builds is
// registered only as the walk's copy in the module's own namespace.
if scratch := inScratch(result.Made); scratch != "" {
return manifest, kept, fmt.Errorf("%s built %s (%s), recorded and not registered: %s is in the scratch "+
"namespace, which only a walk's copy leaves (ADR 0350)", result.On, manifest.Module, short(result.Commit), scratch)
}
// **A build that failed its gate is never registered again** (novox/hq ADR 0236): an outcome heard // **A build that failed its gate is never registered again** (novox/hq ADR 0236): an outcome heard
// twice, or replayed, would otherwise make the build a rollback put back what the module is again, // twice, or replayed, would otherwise make the build a rollback put back what the module is again,
// and the next push would send it. // and the next push would send it.
@@ -714,6 +722,16 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu
return manifest, kept, nil return manifest, kept, nil
} }
// inScratch is the first artifact a build made into the scratch namespace; empty when none is.
func inScratch(made []link.MadeArtifact) string {
for _, m := range made {
if path, kept := catalogue.InArtifactStore(catalogue.Recorded(m.Reference)); kept && strings.HasPrefix(path, builder.ScratchPrefix) {
return m.Reference
}
}
return ""
}
// errOffTheTrunk is a build of a commit off its repository's trunk, which is never published. // errOffTheTrunk is a build of a commit off its repository's trunk, which is never published.
var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " + var errOffTheTrunk = errors.New("the commit is not on its repository's trunk: a commit off the trunk is checked, " +
"never published (ADR 0238) — merge it, and the merge builds it") "never published (ADR 0238) — merge it, and the merge builds it")
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"fmt"
"time"
"github.com/novox/mesh-controller/internal/inventory"
)
// A first-node gate waits for a busy container (novox/hq ADR 0335 §4).
//
// When a machine reports, on what the gate sent it, that a container of a judged module was left running
// because it said busy, the judging has not started: the build is not yet what runs there. The walk says it
// waits for a busy container on that machine, counts no pass and no bound, and starts the judging — its
// Since — at the report that no longer lists the container waiting. The wait is the node-engine's, bounded
// at 15 minutes; the controller's own bound (busyGateBound past the engine's) is for an engine that stopped
// reporting while it waited, so no walk waits for ever. An engine older than the field never lists one, and
// its gate starts as before.
// busyGateSlack is how long past the engine's bound the controller still waits for the report that says the
// container replaced: a pass of the engine (its asks every 20 seconds) and the report's way here.
var busyGateSlack = 5 * time.Minute
// busyEngineBound is the node-engine's bound on a wait (novox/hq ADR 0335 §3), counted again here on this
// controller's clock from when it first read the wait.
const busyEngineBound = 15 * time.Minute
// busyGateEnds is when the gate stops waiting: the engine's own word on its bound, or this controller's count of
// it from when it first read the wait, whichever is sooner, and the slack — so neither a clock ahead on the
// machine nor a wrong word from it holds a walk longer.
func busyGateEnds(b *inventory.GateBusy) time.Time {
until := b.Seen.Add(busyEngineBound)
if !b.Until.IsZero() && b.Until.Before(until) {
until = b.Until
}
return until.Add(busyGateSlack)
}
// busyWait decides, before a judging, whether the gate waits for a busy container on a machine it judges:
// true while it waits, the gate saying so. False when nothing waits, or no longer: a wait that ended moves the
// gate's Since to its end. Pure but for the gate it is handed.
func busyWait(g *inventory.PlanGate, pairs []judged, f gateFacts, now time.Time) bool {
if g.Busy != nil && g.Busy.Ended != nil {
return false
}
for _, j := range pairs {
b, waits := busyOn(j, f)
if !waits {
continue
}
if g.Busy == nil {
g.Busy = &inventory.GateBusy{Machine: j.node, Module: j.module, Container: b.Container, Since: b.Since,
Seen: now}
}
if g.Busy.Seen.IsZero() {
g.Busy.Seen = now
}
g.Busy.Until, g.Busy.Said = b.Until, b.Said
if now.After(busyGateEnds(g.Busy)) {
// The engine said it would have replaced it by now: the judging starts, and finds the module not
// yet healthy while the machine still lists it waiting (busyStill), until the gate's own bound.
end := now
g.Busy.Ended, g.Busy.PastBound = &end, true
g.Since, g.Passes, g.LastPass, g.Healthy, g.HealthyAt, g.Applied = &end, 0, nil, nil, nil, nil
return false
}
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, g.Busy.Words(), nil
g.Read(now, false, g.Busy.Words())
return true
}
if g.Busy == nil {
return false
}
// Replaced: the judging starts at the report that said so.
end := now
if r, said := f.reports[g.Busy.Machine]; said && r.At != nil && r.At.After(g.Busy.Since) && r.At.Before(now) {
end = r.At.UTC()
}
g.Busy.Ended = &end
g.Since, g.Passes, g.LastPass, g.Last, g.Healthy, g.HealthyAt, g.Applied = &end, 0, nil, "", nil, nil, nil
return false
}
// busyOn is the container of a judged module its machine last reported, on what the gate sent it, as left
// running because it said busy.
func busyOn(j judged, f gateFacts) (inventory.BusyWait, bool) {
r, said := f.reports[j.node]
if !said || r.At == nil || !f.reportedOn(j.node, r) {
return inventory.BusyWait{}, false
}
return inventory.WaitingFor(r.Busy, j.module)
}
// busyStill is a judged module whose container its machine still lists waiting once the gate stopped waiting
// for it: not yet what was sent, so not healthy, whatever its health says of the container still running.
func busyStill(j judged, f gateFacts) (string, bool) {
b, waits := busyOn(j, f)
if !waits {
return "", false
}
return fmt.Sprintf("%s still runs its busy container %s, past its node-engine's bound of %s",
j.node, b.Container, b.Until.UTC().Format(time.RFC3339)), true
}
+148
View File
@@ -0,0 +1,148 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// A walk whose module's container waits as busy on its first machine says so, and starts that machine's
// first-machine gate only once the machine reports the container replaced (novox/hq ADR 0335 §4).
func TestAGateWaitsForABusyContainerAndStartsWhenItIsReplaced(t *testing.T) {
sent := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
g := &inventory.PlanGate{Machines: []string{"novox"}, Since: &sent,
Sent: map[string]inventory.SentDeclaration{"novox": {Digest: "d1", Sequence: 7}}}
pairs := []judged{{module: "build-agent", node: "novox"}, {module: "mesh-controller", node: "novox"}}
reported := sent.Add(20 * time.Second)
waiting := []inventory.BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent",
State: inventory.BusyWaiting, Since: reported, Until: reported.Add(15 * time.Minute),
Said: "busy: novox/mesh-controller (check of #280), in hand since 00:58:00Z"}}
facts := func(at time.Time, busy []inventory.BusyWait) gateFacts {
// As judgeMoves hands it: the report held against the gate's own send.
return gateFacts{sent: g.Sent, reports: map[string]inventory.Reported{"novox": {Node: "novox",
Outcome: inventory.OutcomeApplied, At: &at, Declared: "d1", ReportedSequence: 7, Busy: busy}}}
}
now := reported.Add(time.Minute)
if !busyWait(g, pairs, facts(reported, waiting), now) {
t.Fatal("a gate whose module's container waits as busy judged it")
}
if !g.Since.Equal(sent) || g.Busy == nil || g.Busy.Machine != "novox" || g.Busy.Ended != nil {
t.Fatalf("the gate does not keep the wait: %+v %+v", g.Since, g.Busy)
}
for _, said := range []string{gateLine(g), g.Last} {
if !strings.Contains(said, "waits for a busy container on novox") {
t.Errorf("the walk does not say it waits for a busy container there: %s", said)
}
}
if !strings.Contains(g.Last, "check of #280") {
t.Errorf("the wait does not say what the container holds: %s", g.Last)
}
// Ten minutes on, still busy, within the engine's bound: still waiting, nothing judged, no bound run.
now = reported.Add(10 * time.Minute)
if !busyWait(g, pairs, facts(reported.Add(9*time.Minute), waiting), now) || g.Passes != 0 {
t.Fatalf("the gate stopped waiting within the bound: %+v", g)
}
// The machine reports it replaced: the judging starts at that report.
replacedAt := reported.Add(12 * time.Minute)
now = replacedAt.Add(5 * time.Second)
replaced := []inventory.BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent",
State: "replaced-when-idle", Since: reported, Until: reported.Add(15 * time.Minute)}}
if busyWait(g, pairs, facts(replacedAt, replaced), now) {
t.Fatal("the gate still waits after the container was replaced")
}
if g.Since == nil || !g.Since.Equal(replacedAt) || g.Busy.Ended == nil || !g.Busy.Ended.Equal(replacedAt) {
t.Fatalf("the gate does not start at the report that said it replaced: since %v, %+v", g.Since, g.Busy)
}
if strings.Contains(gateLine(g), "waits for a busy container") {
t.Errorf("a gate judging still says it waits: %s", gateLine(g))
}
// And a later report listing it waiting again does not hold a gate that started.
if busyWait(g, pairs, facts(now, waiting), now.Add(time.Minute)) {
t.Error("a gate that started waits again")
}
}
// An engine that stops reporting while it waits holds no walk for ever: past the engine's bound and the slack,
// the judging starts, and finds the module not yet healthy while its container is still listed waiting.
func TestAGateWaitsNoLongerThanTheEnginesBound(t *testing.T) {
sent := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
g := &inventory.PlanGate{Machines: []string{"novox"}, Since: &sent}
pairs := []judged{{module: "build-agent", node: "novox"}}
waiting := []inventory.BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent",
State: inventory.BusyWaiting, Since: sent, Until: sent.Add(15 * time.Minute)}}
f := gateFacts{reports: map[string]inventory.Reported{"novox": {Node: "novox", Outcome: inventory.OutcomeApplied,
At: &sent, Current: true, Busy: waiting}}}
now := sent.Add(15*time.Minute + busyGateSlack + time.Second)
if busyWait(g, pairs, f, now) {
t.Fatal("a gate waited past the engine's bound and the slack")
}
if !g.Busy.PastBound || !g.Since.Equal(now) {
t.Fatalf("the gate does not start, said past the bound: %+v %v", g.Busy, g.Since)
}
if why, still := busyStill(pairs[0], f); !still || !strings.Contains(why, "past its node-engine's bound") {
t.Fatalf("a module still running its busy container reads as what was sent: %q", why)
}
}
// A report from an engine older than the field lists nothing: the gate starts at the send, as before; and a
// report not about the gate's send says nothing of it.
func TestAGateOfAnEngineThatNeverWaitsStartsAsBefore(t *testing.T) {
sent := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
g := &inventory.PlanGate{Machines: []string{"ace"}, Since: &sent,
Sent: map[string]inventory.SentDeclaration{"ace": {Digest: "d2", Sequence: 9}}}
pairs := []judged{{module: "build-agent", node: "ace"}}
at := sent.Add(time.Minute)
older := gateFacts{sent: g.Sent, reports: map[string]inventory.Reported{"ace": {Node: "ace", Outcome: inventory.OutcomeApplied,
At: &at, Declared: "d2", ReportedSequence: 9}}}
if busyWait(g, pairs, older, at) || g.Busy != nil || !g.Since.Equal(sent) {
t.Fatalf("a gate on an engine that never waits did not start at the send: %+v %v", g.Busy, g.Since)
}
elsewhere := gateFacts{sent: g.Sent, reports: map[string]inventory.Reported{"ace": {Node: "ace", Outcome: inventory.OutcomeApplied,
At: &at, Declared: "d1", ReportedSequence: 8, Busy: []inventory.BusyWait{{ID: "build-agent.server",
State: inventory.BusyWaiting, Until: at.Add(time.Hour)}}}}}
if busyWait(g, pairs, elsewhere, at) {
t.Fatal("a wait reported about an earlier declaration held the gate")
}
}
// The wait is bounded on this controller's clock: an engine whose clock runs an hour ahead, saying it waits until
// then, holds the gate no longer than 15 minutes and the slack from when the controller first read the wait.
func TestAGateWaitIsBoundedOnTheControllersClock(t *testing.T) {
seen := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
g := &inventory.PlanGate{Machines: []string{"novox"}, Since: &seen}
pairs := []judged{{module: "build-agent", node: "novox"}}
ahead := seen.Add(time.Hour)
f := gateFacts{reports: map[string]inventory.Reported{"novox": {Node: "novox", Outcome: inventory.OutcomeApplied,
At: &seen, Current: true, Busy: []inventory.BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent",
State: inventory.BusyWaiting, Since: ahead, Until: ahead.Add(15 * time.Minute)}}}}}
if !busyWait(g, pairs, f, seen) || !g.Busy.Seen.Equal(seen) {
t.Fatalf("the gate did not start waiting, seen now: %+v", g.Busy)
}
if !busyWait(g, pairs, f, seen.Add(15*time.Minute+busyGateSlack-time.Second)) {
t.Fatal("the gate stopped waiting within the bound on its own clock")
}
if busyWait(g, pairs, f, seen.Add(15*time.Minute+busyGateSlack+time.Second)) || !g.Busy.PastBound {
t.Fatalf("an engine's later word held the gate past the controller's bound: %+v", g.Busy)
}
}
// `plan` says, for a machine whose node-engine is older than the field, each container whose busy is not sent.
func TestAPlanSaysABusyAnOlderEngineIsNotSent(t *testing.T) {
modules := []catalogue.Manifest{{Module: "build-agent", Resources: []map[string]any{
{"id": "server", "type": "container", "busy": map[string]any{"command": "mesh-builder busy"}},
{"id": "agent-env", "type": "file"}}}}
lines := busyNotSent(modules, false)
if len(lines) != 1 || !strings.Contains(lines[0], "build-agent.server declares busy") ||
!strings.Contains(lines[0], "replaced at once") {
t.Fatalf("the plan does not say the busy not sent: %v", lines)
}
if lines := busyNotSent(modules, true); len(lines) != 0 {
t.Fatalf("a busy sent is said as not sent: %v", lines)
}
}
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"strconv"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/inventory"
)
// A pull request's check builds go when it closes (novox/hq ADR 0350 §5): the build seat keeps them in the artifact
// store's scratch namespace for the walk of its merge to take. **Let go of where the moment is heard**: the
// controller ends the walk answering a merge — after that walk took what it reuses — and the delivery's owner, which
// hears the forge say a pull request closed unmerged, asks the `forget-checks` verb; both let go through the store,
// as the controller's own collection does. The build seat's holders let go of anything a week old on their own,
// so a word lost here leaves a week's builds, never more.
// checkBuildsIn is the scratch namespace as the controller reaches the artifact store.
func checkBuildsIn(ctx context.Context, inv *inventory.Inventory) (builder.Scratch, error) {
shelf, err := inv.Catalogue(ctx)
if err != nil {
return nil, err
}
address, err := artifactStoreAddress(ctx, inv, shelf, "")
if err != nil {
return nil, err
}
if address == "" {
return nil, errors.New("the artifact store is not on the mesh's network")
}
return builder.Registry{Address: address}, nil
}
// forgetChecks lets go of a pull request's check builds and answers what it did.
var forgetChecks = func(ctx context.Context, inv *inventory.Inventory, pull builder.PullRef) (string, error) {
scratch, err := checkBuildsIn(ctx, inv)
if err != nil {
return "", err
}
n, err := scratch.Forget(ctx, pull)
if err != nil {
return "", fmt.Errorf("%s: %d check(s)' builds let go of, and not all: %w", pull, n, err)
}
return fmt.Sprintf("%s: %d check(s)' builds let go of", pull, n), nil
}
// forgetChecksOfWalk lets go, on the save that ends a walk, of the check builds of every pull request whose merge
// it answers: the walk took what it reuses. Said, never fatal.
func forgetChecksOfWalk(ctx context.Context, inv *inventory.Inventory, p inventory.Plan) {
if p.WalkSaid != inventory.WalkEnded || p.Delivery == nil {
return
}
for _, pull := range reuseAskOf(&p).Pulls {
said, err := forgetChecks(ctx, inv, builder.PullRef{Owner: pull.Owner, Repo: pull.Repo, Number: pull.Number})
if err != nil {
fmt.Fprintf(os.Stderr, "%s: its check builds stay until the week's sweep: %v\n", p.ID, err)
continue
}
fmt.Printf("%s: %s\n", p.ID, said)
}
}
// forgetChecksCommand is `forget-checks <owner> <repo> <number>`: the verb the delivery's owner asks when the forge
// says a pull request closed unmerged (novox/hq ADR 0350 §5).
func forgetChecksCommand(ctx context.Context, args []string) error {
if len(args) != 3 {
return errors.New("forget-checks <owner> <repo> <number>")
}
number, err := strconv.Atoi(args[2])
if err != nil || number <= 0 {
return fmt.Errorf("%q is not a pull request's number", args[2])
}
open, err := openStores(ctx)
if err != nil {
return err
}
defer open.Close()
said, err := forgetChecks(ctx, open.inventory, builder.PullRef{Owner: args[0], Repo: args[1], Number: number})
if err != nil {
return err
}
fmt.Println(said)
return nil
}
+216
View File
@@ -0,0 +1,216 @@
package main
import (
"context"
"encoding/json"
"errors"
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// One build per change (novox/hq ADR 0350), as the controller asks it and takes it in.
// The check of a change is asked to build what a merge of it would move, in the walk's order: a module of the
// repository checked from the merge, one standing on it from its own repository as the walk builds it, and one
// that reads the repository through a context not at all. A change that moves no module builds nothing.
func TestACheckIsAskedToBuildWhatTheMergeWouldMove(t *testing.T) {
entries := []inventory.Entry{
{Manifest: catalogue.Manifest{Module: "app"}, Source: inventory.Source{Repository: "novox/shop", Seat: "git", Path: "modules/app"}},
{Manifest: catalogue.Manifest{Module: "web"}, Source: inventory.Source{Repository: "novox/web", Seat: "git", Ref: "main"}},
{Manifest: catalogue.Manifest{Module: "docs"}, Source: inventory.Source{Repository: "novox/docs", Seat: "git"}},
}
p := link.PullUpdated{Owner: "novox", Repo: "shop", Number: 7, Base: "main", Commit: "abc"}
scope := checkScope{Reach: mergeReach{Packaging: entries[2:], Plan: inventory.Plan{Tiers: [][]string{{"app", "docs"}, {"web"}}}}}
clone := func(s inventory.Source) (string, error) { return "http://forge.invalid/" + s.Repository + ".git", nil }
got := checkBuildsOf(p, scope, entries, clone)
want := []link.CheckBuild{
{Module: "app", Path: "modules/app", Merged: true, Tier: 0},
{Module: "docs", Tier: 0, Skip: "its build reads novox/shop through a context, which a check cannot hand the merge"},
{Module: "web", Repository: "http://forge.invalid/novox/web.git", Ref: "main", Tier: 1},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("the check is asked to build\n%+v\nwant\n%+v", got, want)
}
if got := checkBuildsOf(p, checkScope{}, entries, clone); got != nil {
t.Fatalf("a change that moves no module asks for builds: %+v", got)
}
}
// A walk's ask carries its leave to take a check's build, naming the pull requests of the merges it answers; a dry
// run never does, and neither does an ask that is not a walk's.
func TestOnlyAWalksAskTakesAChecksBuildAndADryRunNever(t *testing.T) {
p := &inventory.Plan{Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{
{Repository: "novox/shop", Number: 7}, {Repository: "http://forge.invalid/novox/web.git", Number: 3},
{Repository: "novox/shop", Number: 7}, {Repository: "novox/shop"}}}}
asked := reuseAskOf(p)
if want := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}, {Owner: "novox", Repo: "web", Number: 3}}; !reflect.DeepEqual(asked.Pulls, want) {
t.Fatalf("the walk names %+v", asked.Pulls)
}
walk := withReuse(t.Context(), asked)
if reuseAsked(walk, false) != asked {
t.Fatal("a walk's ask does not carry its leave")
}
if reuseAsked(walk, true) != nil {
t.Fatal("a dry run carries a leave to take a check's build")
}
if reuseAsked(t.Context(), false) != nil {
t.Fatal("an ask that is not a walk's carries a leave")
}
}
// A walk ended lets go of its merges' check builds — only once it ended, after it took what it reuses.
func TestAWalkEndedLetsGoOfItsMergesCheckBuilds(t *testing.T) {
var forgot []builder.PullRef
was := forgetChecks
forgetChecks = func(_ context.Context, _ *inventory.Inventory, pull builder.PullRef) (string, error) {
forgot = append(forgot, pull)
return "", nil
}
t.Cleanup(func() { forgetChecks = was })
p := inventory.Plan{ID: "plan-1", Delivery: &inventory.PlanDelivery{Merges: []inventory.PlanMerge{
{Repository: "novox/shop", Number: 7}}}}
p.WalkSaid = inventory.WalkStarted
forgetChecksOfWalk(t.Context(), nil, p)
p.WalkSaid = ""
forgetChecksOfWalk(t.Context(), nil, p)
if len(forgot) != 0 {
t.Fatalf("a walk not ended let go of %v, which it may still take", forgot)
}
p.WalkSaid = inventory.WalkEnded
forgetChecksOfWalk(t.Context(), nil, p)
if want := []builder.PullRef{{Owner: "novox", Repo: "shop", Number: 7}}; !reflect.DeepEqual(forgot, want) {
t.Fatalf("the walk ended let go of %v", forgot)
}
}
// A build in the scratch namespace is never registered: only the walk's copy in the module's own namespace is.
func TestABuildInTheScratchNamespaceIsNeverRegistered(t *testing.T) {
if got := inScratch([]link.MadeArtifact{{Name: "server", Reference: "store.internal:5000/app/server@sha256:aa"}}); got != "" {
t.Fatalf("a module's own build reads as a scratch build: %s", got)
}
scratch := "store.internal:5000/scratch/app/server@sha256:aa"
if got := inScratch([]link.MadeArtifact{{Name: "server", Reference: scratch}}); got != scratch {
t.Fatalf("a scratch build was not found: %q", got)
}
if got := inScratch([]link.MadeArtifact{{Name: "look", Reference: "http://store.internal:5000/v2/scratch/app/look/blobs/sha256:bb"}}); got == "" {
t.Fatal("a scratch archive was not found")
}
}
// The walk says per module what it did: "reused from …" or "built: <why>" — and a build seat from before says
// neither, which is said.
func TestAWalksBuildSaysWhetherItReusedOrBuilt(t *testing.T) {
if got := (link.BuildResult{Reused: "reused from the check of novox/shop#7"}).MadeWords(); got != "reused from the check of novox/shop#7" {
t.Fatalf("said %q", got)
}
if got := (link.BuildResult{BuiltWhy: "no check built it"}).MadeWords(); got != "built: no check built it" {
t.Fatalf("said %q", got)
}
if got := (link.BuildResult{}).MadeWords(); !strings.Contains(got, "predates ADR 0350") {
t.Fatalf("said %q", got)
}
}
// A walk's module the check's build stood in for is registered by the digest the check built — the copy in the
// module's namespace — and the walk keeps "reused from …" for it; its first-node gate judges that registered
// build as it judges one the walk built. A scratch reference is refused. Needs the store (MESH_TEST_POSTGRES), and
// is named in testdata/store-tests.
func TestAReusedCheckBuildIsRegisteredByItsDigestAndGatedAsABuiltOne(t *testing.T) {
g := aGateMesh(t)
ctx := t.Context()
inv := g.open.inventory
gateEvery = 0
digest := "sha256:" + strings.Repeat("c", 64)
reused := func(id, reference string) link.BuildResult {
manifest := mustJSON(t, catalogue.Manifest{Module: "app", Version: "c3", Resources: []map[string]any{
{"id": "svc", "type": "container", "name": "app", "image": reference}}})
return link.BuildResult{ID: id, Repository: "novox/mesh-catalog", Path: "modules/app", Ref: "main", On: "anchor",
Commit: "c3", Manifest: manifest, Trunk: "main", OnTrunk: true, Branches: []string{"main"},
Made: []link.MadeArtifact{{Name: "server", Kind: "image", Reference: reference}},
Source: &link.SourceOnSeat{Seat: "git", Repository: "novox/mesh-catalog"},
Reused: "reused from the check of novox/mesh-catalog#9 at head abcdefabcdef (merged into trunk 123456123456): server " + digest,
ReusedFrom: &link.ReusedFrom{Pull: link.PullRef{Owner: "novox", Repo: "mesh-catalog", Number: 9},
Head: "abcdefabcdef", Trunk: "123456123456", Key: "r1:" + strings.Repeat("1", 64)}}
}
if _, _, err := takeIn(ctx, inv, reused(link.NewBuildID(time.Now()), "store.internal:5000/scratch/app/server@"+digest)); err == nil ||
!strings.Contains(err.Error(), "scratch") {
t.Fatalf("a build in the scratch namespace was registered: %v", err)
}
id := link.NewBuildID(time.Now().Add(time.Second))
result := reused(id, "store.internal:5000/app/server@"+digest)
if _, _, err := takeIn(ctx, inv, result); err != nil {
t.Fatal(err)
}
current, err := inv.CurrentBuilds(ctx)
if err != nil || current["app"].Commit != "c3" {
t.Fatalf("the reused build is not what app is registered at: %+v %v", current["app"], err)
}
shelf, err := inv.Catalogue(ctx)
if err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(shelf["app"].Resources)
if !strings.Contains(string(raw), "app/server@"+digest) {
t.Fatalf("app is registered as %s, not by the check's digest in its own namespace", raw)
}
// The walk asked it: the walk keeps what its build did, and its gate judges the registered build.
asked := time.Now().UTC()
p := g.plan(t)
p.Commit = "c3"
p.Modules["app"] = &inventory.PlanModule{State: "asked", AskedAt: &asked, Build: id}
if err := inv.SavePlan(ctx, &p); err != nil {
t.Fatal(err)
}
planBuiltSaying(ctx, g.open, "app", "c3", "", asked, id, result.MadeWords())
p = g.plan(t)
if m := p.Modules["app"]; m.State != "built" || !strings.HasPrefix(m.Made, "reused from the check of novox/mesh-catalog#9") {
t.Fatalf("the walk keeps %+v", m)
}
for i := 0; i < 6; i++ {
advancePlans(ctx, g.open)
}
p = g.plan(t)
gate := p.Modules["app"].Gate
if gate == nil || gate.To != "c3" || gate.Verdict != inventory.GatePassed {
t.Fatalf("the gate judged %+v, not the reused build", gate)
}
if sent, _, _ := inv.SentBuilds(ctx, "anchor"); sent["app"] != "c3" {
t.Fatalf("the first machine was sent %v, not the reused build", sent)
}
}
// A walk's leave names the builds its own pull requests' verdicts made, and none when they cannot be read: the
// builder takes nothing a verdict does not name (novox/hq ADR 0350 §2).
func TestAWalksLeaveNamesTheBuildsItsPullRequestsVerdictsMade(t *testing.T) {
was := checkedBuildsOf
t.Cleanup(func() { checkedBuildsOf = was })
var asked []link.PullRef
named := []link.CheckedBuild{{Pull: link.PullRef{Owner: "novox", Repo: "shop", Number: 7}, Check: "build-1",
Built: link.CheckBuilt{Module: "app", Key: "r1:1"}}}
checkedBuildsOf = func(_ context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
asked = pulls
return named, nil
}
pulls := []link.PullRef{{Owner: "novox", Repo: "shop", Number: 7}}
if got := reuseWithVerdicts(t.Context(), &link.ReuseAsk{Pulls: pulls}); !reflect.DeepEqual(got.Builds, named) ||
!reflect.DeepEqual(asked, pulls) {
t.Fatalf("the leave names %+v, read for %+v", got.Builds, asked)
}
checkedBuildsOf = func(context.Context, []link.PullRef) ([]link.CheckedBuild, error) {
return nil, errors.New("no bus")
}
if got := reuseWithVerdicts(t.Context(), &link.ReuseAsk{Pulls: pulls}); got.Builds != nil {
t.Fatalf("verdicts that could not be read named %+v", got.Builds)
}
}
+51 -1
View File
@@ -282,10 +282,59 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
Source: sourceOnSeat(source), Source: sourceOnSeat(source),
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base, Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New, Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New,
Manifests: scope.Manifests, Judge: scope.Judge}, Manifests: scope.Manifests, Judge: scope.Judge, Builds: checkBuildsOf(p, scope, entries, clone)},
}, nil }, nil
} }
// checkBuildsOf are the modules a merge of the change would build, in the walk's order, as the check builds them
// for the walk to take (novox/hq ADR 0350 §1): a module built from the repository checked, at the branch it merges
// into, from the merge the check makes; any other — a module standing on one — from its own repository at the
// branch it follows, as the walk builds it. A module whose build reads the repository checked through a context,
// or that the merge deletes, cannot be built as the walk would: said, and nothing after it is built. A change that
// moves no module builds nothing.
func checkBuildsOf(p link.PullUpdated, scope checkScope, entries []inventory.Entry,
clone func(inventory.Source) (string, error)) []link.CheckBuild {
if len(scope.Reach.Plan.Tiers) == 0 {
return nil
}
m := link.SourceMoved{Owner: p.Owner, Repo: p.Repo, Base: p.Base, CloneURL: p.CloneURL}
byName := map[string]inventory.Entry{}
for _, e := range entries {
byName[e.Manifest.Module] = e
}
skip := map[string]string{}
for _, e := range scope.Reach.Packaging {
skip[e.Manifest.Module] = "its build reads " + p.Owner + "/" + p.Repo + " through a context, which a check " +
"cannot hand the merge"
}
for _, e := range scope.Reach.Deleted {
skip[e.Manifest.Module] = "the merge deletes it"
}
var out []link.CheckBuild
for tier, names := range scope.Reach.Plan.Tiers {
for _, name := range names {
e, known := byName[name]
b := link.CheckBuild{Module: name, Tier: tier, Skip: skip[name]}
switch {
case !known:
b.Skip = "the catalogue does not hold it"
case b.Skip != "":
case sourceIs(e.Source, m):
b.Merged, b.Path = true, e.Source.Path
default:
url, err := clone(e.Source)
if err != nil {
b.Skip = "its repository cannot be cloned: " + err.Error()
break
}
b.Repository, b.Path, b.Ref = url, e.Source.Path, followedBranch(e.Source.Ref)
}
out = append(out, b)
}
}
return out
}
// besideRefs is the ref each repository is cloned at beside a check, by the directory it is found under: // besideRefs is the ref each repository is cloned at beside a check, by the directory it is found under:
// a core repository at the commit the mesh runs of the module built from it (`running`) — but the // a core repository at the commit the mesh runs of the module built from it (`running`) — but the
// catalogue, whose checkout beside is what tests read its files from, at its main, what the next merge // catalogue, whose checkout beside is what tests read its files from, at its main, what the next merge
@@ -382,6 +431,7 @@ func checkedOf(result link.BuildResult) link.Checked {
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and the commits of the // What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and the commits of the
// repositories read beside it. // repositories read beside it.
c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took c.Trunk, c.Beside, c.Took = result.Check.Trunk, result.Check.Beside, result.Check.Took
c.Built = result.Check.Built
if c.Gate == nil { if c.Gate == nil {
// A build seat from before the layers: its verdict is the gate's. // A build seat from before the layers: its verdict is the gate's.
c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary} c.Gate = &link.CheckLayer{Verdict: c.Verdict, Summary: c.Summary}
+7 -3
View File
@@ -81,9 +81,13 @@ func TestAFixGoesThroughPastADirectoryFoundBefore(t *testing.T) {
} }
return f, err return f, err
} }
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound }) // The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
gateSettle, gateEvery, gateBound = 0, 0, 300*time.Millisecond wasWindow := gateWitnessWindow
gateWitnessWindow = 0
t.Cleanup(func() { gateWitnessWindow = wasWindow })
t.Cleanup(func() { gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound })
gateWithin, gateEvery, gateBound = time.Hour, 0, 300*time.Millisecond
deadline := time.Now().Add(5 * time.Second) deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
advancePlans(ctx, b.open) advancePlans(ctx, b.open)
+199 -36
View File
@@ -28,9 +28,12 @@ import (
// that machine reported it applied. A build that applies and then does nothing, crashes, serves no // that machine reported it applied. A build that applies and then does nothing, crashes, serves no
// tools, or breaks the machine's word to the mesh passed that test. Now the first machine is judged by // tools, or breaks the machine's word to the mesh passed that test. Now the first machine is judged by
// the component's health — the core's health definitions, or a module's own — within ten minutes of the // the component's health — the core's health definitions, or a module's own — within ten minutes of the
// apply: a core module passing three times over at least two minutes, a leaf module once, as soon as the // apply, each module by its rule (gateRuleOf): the node-engine and the mesh's resolver passing on their third
// node-engine states it healthy (novox/hq ADR 0282 decision 4 as ADR 0304 decision 3 changed it). Only then // healthy reading, and never later than two minutes after the send while healthy; the controller and the tool runner once, no earlier than the end of the
// are the rest sent. // node-engine's witness window for it; mesh-delivery and a leaf module once, as soon as the node-engine states
// it healthy (novox/hq ADR 0351 and ADR 0352, changing ADR 0236 decision 3, ADR 0282 decision 4 and ADR 0304
// decision 3).
// Only then are the rest sent.
// //
// **A failing gate stops the plan and puts the previous build back there.** The build is marked failed // **A failing gate stops the plan and puts the previous build back there.** The build is marked failed
// at its gate — registration refuses it and nothing sends it again on its own — the module's registered // at its gate — registration refuses it and nothing sends it again on its own — the module's registered
@@ -41,22 +44,30 @@ import (
// The gate's bounds (to-be 45 §8). Variables so a test can judge in a second, not in minutes. // The gate's bounds (to-be 45 §8). Variables so a test can judge in a second, not in minutes.
var ( var (
// gateSettle is how long the first machine must stay healthy, at the least. // gateWithin is the longest a module under the three-reading rule that stays healthy is held: at it, the
gateSettle = 2 * time.Minute // gate passes on the healthy readings it has, the newest healthy and none since the send not (novox/hq ADR
// 0352). Readings gateEvery apart, the first at the apply, put three inside it.
gateWithin = 2 * time.Minute
// gateBound is how long after the apply the build may take to become healthy. // gateBound is how long after the apply the build may take to become healthy.
gateBound = 10 * time.Minute gateBound = 10 * time.Minute
// gatePasses is how many consecutive judgings must find a core module healthy, gateEvery apart at the // gatePasses is how many consecutive judgings must find such a core module healthy, gateEvery apart at
// least, after gateSettle. gateLeafPasses is a leaf module's: one, taken once the node-engine states every // the least, counted from the send: it passes on the third (novox/hq ADR 0352). gateLeafPasses is a leaf module's: one, taken once the
// resource of it healthy by its own check, with no settle (novox/hq ADR 0282 decision 4 as ADR 0304 // node-engine states every resource of it healthy by its own check, with no settle (novox/hq ADR 0282
// decision 3 changed it, issue 524). // decision 4 as ADR 0304 decision 3 changed it, issue 524).
gatePasses = 3 gatePasses = 3
gateLeafPasses = 1 gateLeafPasses = 1
gateLeafSettles = false gateLeafSettles = false
gateEvery = 40 * time.Second gateEvery = 40 * time.Second
// gateWitnessWindow is the node-engine's witness window for a new controller or tool runner, from its
// start (ADR 0236 decision 27, lease.ControllerWithin and lease.NodeToolsWithin): a reading of either
// counts only after it has ended, so the build is judged by the witness and the gate both (novox/hq ADR
// 0351 decisions 1 and 4).
gateWitnessWindow = max(lease.ControllerWithin, lease.NodeToolsWithin)
) )
// coreModule is whether a module is judged as the core (novox/hq ADR 0282 decision 1): one on the controller's // coreModule is whether a module is judged as the core (novox/hq ADR 0282 decision 1): one on the controller's
// own path, or one holding the mesh's resolver. Every other module is a leaf. // own path, or one holding the mesh's resolver. Every other module is a leaf. Its class sets the module's
// delivery budget; how many readings its gate takes is gateRuleOf's.
func coreModule(module string, m catalogue.Manifest) bool { func coreModule(module string, m catalogue.Manifest) bool {
if _, own := onTheControllersPath[module]; own { if _, own := onTheControllersPath[module]; own {
return true return true
@@ -69,12 +80,72 @@ func coreModule(module string, m catalogue.Manifest) bool {
return coreComponent(module) != "" return coreComponent(module) != ""
} }
// gateWants is how many passes a module needs, and whether it must stay healthy gateSettle first. // gateRule is how a module's first-node gate judges it: passes healthy readings, gateEvery apart at the
func gateWants(module string, m catalogue.Manifest) (passes int, settles bool) { // least, counted from the send or, where witnessed, from the end of the node-engine's witness window for it;
if coreModule(module, m) { // and, where within is set, passed at within after the send on fewer, if it was healthy at every reading
return gatePasses, true // (novox/hq ADR 0352). said names the rule in the gate's text.
type gateRule struct {
passes int
within time.Duration
witnessed bool
said string
} }
return gateLeafPasses, gateLeafSettles
// gateRuleOf is a module's gate rule (novox/hq ADR 0351): a core module keeps three readings only where its
// build, broken on every machine, could not receive its own fix — the node-engine, which applies every send,
// and the mesh's resolver, which gives every machine the bus's name (decisions 2, 3 and 6); the bus is never
// walked through a gate but kept so (decision 7). The controller, run on one machine, and the tool runner,
// whose fix the node-engine applies, are judged by one reading after the witness window (decisions 1 and 4);
// mesh-delivery, whose fix goes the controller's own path, by one (decision 5); a leaf module by one (ADR 0304
// decision 3).
func gateRuleOf(module string, m catalogue.Manifest) gateRule {
switch {
case coreComponent(module) == lease.ComponentController:
return gateRule{passes: 1, witnessed: true, said: "the controller's rule: one reading once it is ready, " +
"after the node-engine's witness window (novox/hq ADR 0351)"}
case coreComponent(module) == lease.ComponentNodeTools:
return gateRule{passes: 1, witnessed: true, said: "the tool runner's rule: one reading once it answers, " +
"after the node-engine's witness window (novox/hq ADR 0351)"}
case module == catalogue.DeliverySeat:
return gateRule{passes: 1, said: "mesh-delivery's rule: one reading (novox/hq ADR 0351)"}
case coreModule(module, m):
return gateRule{passes: gatePasses, within: gateWithin, said: fmt.Sprintf("the core's rule: %d readings, "+
"or healthy throughout %s after the send (novox/hq ADR 0352)", gatePasses, gateWithin)}
}
r := gateRule{passes: gateLeafPasses, said: "a leaf module's rule: one reading (novox/hq ADR 0304)"}
if gateLeafSettles {
r.within = gateWithin
r.said = fmt.Sprintf("a leaf module's rule, judged as the core: %d readings, or healthy throughout %s after the send",
gateLeafPasses, gateWithin)
}
return r
}
// gateFloor is the earliest a module's reading counts by its rule: the send and, for a witnessed module, the
// end of the node-engine's witness window on every machine judged (novox/hq ADR 0351 decisions 1 and 4). A
// module under the three-reading rule counts from the send (novox/hq ADR 0352). The window opens when the node-engine starts the build, which is before the machine reports it
// applied (applied, as the gate first read it) and, for the controller, before the new controller took the
// lease (holder): the later of those, the send's time at the least, plus the window, is never before its end.
// A controller that starts again takes the lease again, and so moves its floor on. Pure.
func gateFloor(module string, rule gateRule, since time.Time, machines []string, applied map[string]time.Time,
holder *lease.Holder) time.Time {
floor := since
if !rule.witnessed {
return floor
}
for _, n := range machines {
start := since
if at, read := applied[n]; read && at.After(start) {
start = at
}
if coreComponent(module) == lease.ComponentController && holder != nil && holder.Taken.After(start) {
start = holder.Taken
}
if end := start.Add(gateWitnessWindow); end.After(floor) {
floor = end
}
}
return floor
} }
// gateProbe is the registry's row for the gate's verdicts and the witnesses' rollbacks: its conditions // gateProbe is the registry's row for the gate's verdicts and the witnesses' rollbacks: its conditions
@@ -501,6 +572,13 @@ func judgeGate(ctx context.Context, open *stores, p *inventory.Plan, module stri
return judgeMoves(ctx, open, g, pairs, now) return judgeMoves(ctx, open, g, pairs, now)
} }
// dueAtTheMost says a gate healthy since its send has reached gateWithin and not been judged since: the one
// judging that may come sooner than gateEvery after the last pass (novox/hq ADR 0352).
func dueAtTheMost(g *inventory.PlanGate, now time.Time) bool {
most := g.Since.Add(gateWithin)
return !g.Faulted && g.LastPass != nil && !now.Before(most) && g.LastPass.Before(most)
}
// judged is one module on one machine, as a gate judges it. // judged is one module on one machine, as a gate judges it.
type judged struct{ module, node string } type judged struct{ module, node string }
@@ -511,7 +589,9 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
if g.Verdict != "" { if g.Verdict != "" {
return g.Verdict, nil return g.Verdict, nil
} }
if g.LastPass != nil && now.Sub(*g.LastPass) < gateEvery { // A judging comes gateEvery after the last pass, and once at gateWithin after the send whatever the spacing:
// a module healthy throughout is never held past it (novox/hq ADR 0352).
if g.LastPass != nil && now.Sub(*g.LastPass) < gateEvery && !dueAtTheMost(g, now) {
return "", nil return "", nil
} }
for _, c := range g.Carried { for _, c := range g.Carried {
@@ -544,6 +624,11 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
facts.sentBuilds[j.node] = builds facts.sentBuilds[j.node] = builds
} }
} }
// **A busy container on a judged machine holds the judging before it starts** (novox/hq ADR 0335 §4): the
// walk says it waits there, and its gate starts at the report that says the container replaced.
if busyWait(g, pairs, facts, now) {
return "", nil
}
// **What is wrong with a machine itself is the machine's** (novox/hq issue 281): read once for each // **What is wrong with a machine itself is the machine's** (novox/hq issue 281): read once for each
// machine judged, apart from what is wrong with a module there, and never pinned on the module the // machine judged, apart from what is wrong with a module there, and never pinned on the module the
// gate happens to be kept on. // gate happens to be kept on.
@@ -568,6 +653,9 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
for _, j := range pairs { for _, j := range pairs {
w := words[j.node] w := words[j.node]
h, said := judgeHealth(j.module, coreComponent(j.module), shelf[j.module], j.node, *g.Since, w.facts) h, said := judgeHealth(j.module, coreComponent(j.module), shelf[j.module], j.node, *g.Since, w.facts)
if why, still := busyStill(j, facts); still && (h == healthGood || h == healthPerson) {
h, said = healthNotYet, why
}
if h == healthGood || h == healthPerson { if h == healthGood || h == healthPerson {
if on, named := w.on[j.module]; named { if on, named := w.on[j.module]; named {
h, said = healthNotYet, on h, said = healthNotYet, on
@@ -608,6 +696,51 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
why = said why = said
} }
} }
// **Each module counts its readings by its own rule** (novox/hq ADR 0351): from when the gate first read
// each machine report its send applied, and who holds the controller lease, the earliest its reading counts.
if g.Applied == nil {
g.Applied = map[string]time.Time{}
}
for node := range byMachine {
r, said := facts.reports[node]
if _, read := g.Applied[node]; !read && said && r.At != nil && r.Outcome == inventory.OutcomeApplied &&
facts.reportedOn(node, r) {
g.Applied[node] = *r.At
}
}
rules, floors := map[string]gateRule{}, map[string]time.Time{}
for _, m := range modules {
var on []string
for _, j := range pairs {
if j.module == m {
on = append(on, j.node)
}
}
rules[m] = gateRuleOf(m, shelf[m])
floors[m] = gateFloor(m, rules[m], *g.Since, on, g.Applied, facts.holder)
}
tallyGate(g, gateJudging{modules: modules, reading: reading, waits: waits, worst: worst, why: why,
rules: rules, floors: floors}, now)
return g.Verdict, nil
}
// gateJudging is one judging's readings, as tallyGate counts them: the modules judged, each one's reading
// (the worst of its machines'), its waits for a person, the send's worst reading and why, and each module's
// rule and the earliest its reading counts.
type gateJudging struct {
modules []string
reading map[string]health
waits map[string]string
worst health
why string
rules map[string]gateRule
floors map[string]time.Time
}
// tallyGate counts one judging into the gate: a pass counted, a pass missed (and why), or the verdict. Pure but
// for the gate it writes.
func tallyGate(g *inventory.PlanGate, j gateJudging, now time.Time) {
modules, reading, waits, worst, why := j.modules, j.reading, j.waits, j.worst, j.why
if g.Healthy == nil { if g.Healthy == nil {
g.Healthy = map[string]int{} g.Healthy = map[string]int{}
} }
@@ -623,38 +756,53 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
delete(g.HealthyAt, m) delete(g.HealthyAt, m)
continue continue
} }
// **A pass is counted only gateEvery after the one before** (issue 318 review): a send judged more
// often while another module beside it is not yet healthy counts no faster.
if at, counted := g.HealthyAt[m]; !counted || now.Sub(at) >= gateEvery {
g.Healthy[m]++
g.HealthyAt[m] = now
}
if w := waits[m]; w != "" { if w := waits[m]; w != "" {
if g.Waits == nil { if g.Waits == nil {
g.Waits = map[string]string{} g.Waits = map[string]string{}
} }
g.Waits[m] = w g.Waits[m] = w
} }
// **A reading counts only from the module's floor** (novox/hq ADR 0351 decisions 1 and 4): the
// controller's and the tool runner's after the witness window, any other module's from the send.
if now.Before(j.floors[m]) {
continue
} }
settled := now.Sub(*g.Since) >= gateSettle // **A pass is counted only gateEvery after the one before** (issue 318 review): a send judged more
// What the send needs is what its most demanding module needs: a send carrying a core module is judged // often while another module beside it is not yet healthy counts no faster.
// as the core. if at, counted := g.HealthyAt[m]; !counted || now.Sub(at) >= gateEvery {
wants, settles := 0, false g.Healthy[m]++
g.HealthyAt[m] = now
}
}
// What the send needs is what its most demanding module needs — the most readings, then the latest
// floor: a send carrying the node-engine is judged by its three readings, or by being healthy throughout
// its two minutes (novox/hq ADR 0352).
wants, floor, within, said := 0, time.Time{}, time.Duration(0), ""
for _, m := range modules { for _, m := range modules {
p, s := gateWants(m, shelf[m]) r, f := j.rules[m], j.floors[m]
wants, settles = max(wants, p), settles || s if r.passes > wants || (r.passes == wants && f.After(floor)) {
wants, within, said = r.passes, r.within, r.said
}
if f.After(floor) {
floor = f
}
} }
if len(modules) == 0 { if len(modules) == 0 {
// A send judged on no machine of its modules (its first machine not running them) has no class to // A send judged on no machine of its modules (its first machine not running them) has no class to
// be judged by: it is judged as the core, never passed at once without a reading. // be judged by: it is judged as the core, never passed at once without a reading.
wants, settles = gatePasses, true wants, floor, within = gatePasses, *g.Since, gateWithin
said = fmt.Sprintf("the core's rule: %d readings, or healthy throughout %s after the send", gatePasses, gateWithin)
}
// heldLongEnough is a rule's most: within after the send, healthy at every reading since it.
heldLongEnough := func(within time.Duration) bool {
return within > 0 && !g.Faulted && !now.Before(g.Since.Add(within))
} }
g.Wants = wants g.Wants = wants
// A module's own pass is its own class's: a leaf beside a core module that fails keeps its build after // A module's own pass is its own rule's: a leaf beside a core module that fails keeps its build after
// its one reading, and only what did not pass is put back (issue 318's rule, by class since issue 524). // its one reading, and only what did not pass is put back (issue 318's rule, by class since issue 524).
passedAlone := func(m string) bool { passedAlone := func(m string) bool {
p, s := gateWants(m, shelf[m]) r := j.rules[m]
return (settled || !s) && reading[m] <= healthPerson && g.Healthy[m] >= p return reading[m] <= healthPerson && (g.Healthy[m] >= r.passes || (g.Healthy[m] >= 1 && heldLongEnough(r.within)))
} }
// fail decides the send failed: what passed on its own keeps its pass, everything else is put back. // fail decides the send failed: what passed on its own keeps its pass, everything else is put back.
fail := func(why string) { fail := func(why string) {
@@ -671,6 +819,7 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
pastBound := now.Sub(*g.Since) > gateBound pastBound := now.Sub(*g.Since) > gateBound
switch { switch {
case worst == healthBroken: case worst == healthBroken:
g.Faulted = true
g.Read(now, false, g.BrokenWhy) g.Read(now, false, g.BrokenWhy)
var judging []string var judging []string
for _, m := range modules { for _, m := range modules {
@@ -689,25 +838,36 @@ func judgeMoves(ctx context.Context, open *stores, g *inventory.PlanGate, pairs
case worst == healthWaiting: case worst == healthWaiting:
// Waiting on a provider that is unhealthy: not a pass, and not a failure at the bound either — // Waiting on a provider that is unhealthy: not a pass, and not a failure at the bound either —
// the provider's own condition says what is wrong (ADR 0240 rule 5). // the provider's own condition says what is wrong (ADR 0240 rule 5).
g.Faulted = g.Faulted || g.HealthySeen // not healthy after a healthy reading; before one, still starting
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing
g.Read(now, false, why) g.Read(now, false, why)
case worst == healthNotYet: case worst == healthNotYet:
g.Faulted = g.Faulted || g.HealthySeen // not healthy after a healthy reading; before one, still starting
g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing g.Passes, g.LastPass, g.Last, g.Failing = 0, nil, why, failing
g.Read(now, false, why) g.Read(now, false, why)
if pastBound { if pastBound {
fail(fmt.Sprintf("not healthy within %s of its apply: %s", gateBound, why)) fail(fmt.Sprintf("not healthy within %s of its apply: %s", gateBound, why))
} }
case now.Before(floor):
// Healthy before its rule lets a reading count: not a pass yet, and not a fault (novox/hq ADR 0351).
g.HealthySeen = true
// The bound still holds: a module that cannot be read healthy after its floor fails at it.
g.Last, g.Failing = fmt.Sprintf("healthy, and a reading counts from %s by %s",
floor.UTC().Format(time.RFC3339), said), nil
if pastBound {
fail(fmt.Sprintf("not judged within %s of its apply: %s", gateBound, g.Last))
}
default: default:
// Healthy, or waiting for a person (ADR 0254): a pass, the wait carried along in the verdict. // Healthy, or waiting for a person (ADR 0254): a pass, the wait carried along in the verdict.
g.HealthySeen = true
g.Passes++ g.Passes++
g.LastPass, g.Last, g.Failing = &now, "", nil g.LastPass, g.Last, g.Failing = &now, "", nil
g.Read(now, true, "") g.Read(now, true, "")
if g.Passes >= wants && (settled || !settles) { if g.Passes >= wants || heldLongEnough(within) {
decide(g, inventory.GatePassed, fmt.Sprintf("healthy %d times over %s", g.Passes, decide(g, inventory.GatePassed, fmt.Sprintf("healthy %d times over %s, by %s", g.Passes,
now.Sub(*g.Since).Round(time.Second))+waitsSaid(g.Waits), now) now.Sub(*g.Since).Round(time.Second), said)+waitsSaid(g.Waits), now)
} }
} }
return g.Verdict, nil
} }
// judgedCommits is the commit of each module a gate judges: the gate's own To for its module, and each // judgedCommits is the commit of each module a gate judges: the gate's own To for its module, and each
@@ -1372,6 +1532,9 @@ func gateLine(g *inventory.PlanGate) string {
case inventory.GateFailed: case inventory.GateFailed:
what = "FAILED" what = "FAILED"
} }
if g.Verdict == "" && g.Waiting() {
what = "waits for a busy container on " + g.Busy.Machine
}
line := fmt.Sprintf("gate on %s: %s", strings.Join(g.Machines, ", "), what) line := fmt.Sprintf("gate on %s: %s", strings.Join(g.Machines, ", "), what)
if g.Component != "" { if g.Component != "" {
line += " (core: " + g.Component + ")" line += " (core: " + g.Component + ")"
+11 -7
View File
@@ -21,11 +21,15 @@ import (
// wait, and a provider waiting for a login says who waits on it. // wait, and a provider waiting for a login says who waits on it.
// withGateBounds sets the gate's bounds for one test. // withGateBounds sets the gate's bounds for one test.
func withGateBounds(t *testing.T, settle, every, bound time.Duration) { func withGateBounds(t *testing.T, within, every, bound time.Duration) {
t.Helper() t.Helper()
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
gateSettle, gateEvery, gateBound = settle, every, bound // The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound }) wasWindow := gateWitnessWindow
gateWitnessWindow = 0
t.Cleanup(func() { gateWitnessWindow = wasWindow })
gateWithin, gateEvery, gateBound = within, every, bound
t.Cleanup(func() { gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound })
} }
// factsOn is a judging's facts for one machine that applied its send: the node tools answer, and what it says // factsOn is a judging's facts for one machine that applied its send: the node tools answer, and what it says
@@ -56,7 +60,7 @@ func TestAFaultBeforeTheSettleTimeWaitsForTheModuleBesideIt(t *testing.T) {
judgeLeavesAsTheCore(t) judgeLeavesAsTheCore(t)
open := aMesh(t) open := aMesh(t)
ctx := t.Context() ctx := t.Context()
withGateBounds(t, 150*time.Millisecond, 20*time.Millisecond, 10*time.Second) withGateBounds(t, time.Hour, 20*time.Millisecond, 10*time.Second)
since := time.Now().Add(-time.Second) since := time.Now().Add(-time.Second)
base := factsOn(t, "laptop", since, healthyContainer("app")) base := factsOn(t, "laptop", since, healthyContainer("app"))
wasGather := gatherGateFacts wasGather := gatherGateFacts
@@ -97,7 +101,7 @@ const broker_runtime = "node-tools"
func TestAPassIsCountedNoFasterThanTheGatesSpacing(t *testing.T) { func TestAPassIsCountedNoFasterThanTheGatesSpacing(t *testing.T) {
open := aMesh(t) open := aMesh(t)
ctx := t.Context() ctx := t.Context()
withGateBounds(t, 0, time.Hour, time.Hour) withGateBounds(t, time.Hour, time.Hour, time.Hour)
since := time.Now().Add(-time.Minute) since := time.Now().Add(-time.Minute)
base := factsOn(t, "laptop", since, healthyContainer("app"), inventory.ResourceHealth{Module: "late", base := factsOn(t, "laptop", since, healthyContainer("app"), inventory.ResourceHealth{Module: "late",
Resource: "late.web", Kind: "container", Target: "late", State: link.StateUnhealthy, Reason: "down"}) Resource: "late.web", Kind: "container", Target: "late", State: link.StateUnhealthy, Reason: "down"})
@@ -376,7 +380,7 @@ func TestABrokenModuleIsPutBackAtOnceAndTheOneBesideItGetsItsOwnVerdict(t *testi
f.health = map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now, Resources: []inventory.ResourceHealth{app}}} f.health = map[string]inventory.NodeHealth{"laptop": {Node: "laptop", HeardAt: now, Resources: []inventory.ResourceHealth{app}}}
return f, nil return f, nil
} }
withGateBounds(t, 100*time.Millisecond, 0, 10*time.Second) withGateBounds(t, time.Hour, 0, 10*time.Second)
release := func() inventory.Plan { release := func() inventory.Plan {
t.Helper() t.Helper()
+338
View File
@@ -0,0 +1,338 @@
package main
import (
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/lease"
)
// The first-node gate's readings by module (novox/hq ADR 0351, ADR 0352): the controller and the tool runner pass
// on one reading after the node-engine's witness window, mesh-delivery on one, the node-engine and the mesh's
// resolver on their third healthy reading and never later than two minutes while healthy; the bus is kept so.
// withTheGatesTimes sets the gate's production bounds for a test, whatever another test left.
func withTheGatesTimes(t *testing.T) {
t.Helper()
wasWithin, wasEvery, wasBound, wasWindow := gateWithin, gateEvery, gateBound, gateWitnessWindow
wasPasses, wasLeafPasses, wasLeafSettles := gatePasses, gateLeafPasses, gateLeafSettles
gateWithin, gateEvery, gateBound, gateWitnessWindow = 2*time.Minute, 40*time.Second, 10*time.Minute, 60*time.Second
gatePasses, gateLeafPasses, gateLeafSettles = 3, 1, false
t.Cleanup(func() {
gateWithin, gateEvery, gateBound, gateWitnessWindow = wasWithin, wasEvery, wasBound, wasWindow
gatePasses, gateLeafPasses, gateLeafSettles = wasPasses, wasLeafPasses, wasLeafSettles
})
}
func TestEachModuleHasItsGateRule(t *testing.T) {
withTheGatesTimes(t)
for _, c := range []struct {
module string
claims []catalogue.Claim
passes int
within time.Duration
witnessed bool
said string
}{
{"mesh-controller", nil, 1, 0, true, "the controller's rule"},
{"node-tools", nil, 1, 0, true, "the tool runner's rule"},
{catalogue.DeliverySeat, nil, 1, 0, false, "mesh-delivery's rule"},
{"mesh-host", nil, 3, 2 * time.Minute, false, "the core's rule"},
{"dnsmasq", []catalogue.Claim{{Name: resolverSeat}}, 3, 2 * time.Minute, false, "the core's rule"},
{"nats", nil, 3, 2 * time.Minute, false, "the core's rule"},
{"app", nil, 1, 0, false, "a leaf module's rule"},
} {
r := gateRuleOf(c.module, catalogue.Manifest{Module: c.module, Claims: c.claims})
if r.passes != c.passes || r.within != c.within || r.witnessed != c.witnessed || !strings.HasPrefix(r.said, c.said) {
t.Errorf("%s: %+v; want %d readings, within %s, witnessed %v, said %q", c.module, r, c.passes, c.within,
c.witnessed, c.said)
}
}
}
// Every module that was core stays core for its delivery budget and its walk: only its readings change.
func TestTheCoreStaysTheCore(t *testing.T) {
for _, m := range []string{"mesh-controller", "mesh-host", "node-tools", "nats", catalogue.DeliverySeat} {
if !coreModule(m, catalogue.Manifest{Module: m}) {
t.Errorf("%s is no longer judged as the core for its class", m)
}
}
if !coreModule("dnsmasq", catalogue.Manifest{Module: "dnsmasq", Claims: []catalogue.Claim{{Name: resolverSeat}}}) {
t.Error("the resolver's holder is no longer judged as the core for its class")
}
}
// gateOn judges one module on one machine, healthy at every reading.
type gateOn struct {
module, node string
g *inventory.PlanGate
holder *lease.Holder
}
func aGateOn(module, node string, since time.Time) *gateOn {
return &gateOn{module: module, node: node, g: &inventory.PlanGate{Machines: []string{node}, Since: &since,
Applied: map[string]time.Time{}}}
}
func (o *gateOn) read(at time.Time, h health, why string) {
r := gateRuleOf(o.module, catalogue.Manifest{Module: o.module})
floor := gateFloor(o.module, r, *o.g.Since, []string{o.node}, o.g.Applied, o.holder)
tallyGate(o.g, gateJudging{modules: []string{o.module}, reading: map[string]health{o.module: h},
worst: h, why: why, rules: map[string]gateRule{o.module: r}, floors: map[string]time.Time{o.module: floor}}, at)
}
func TestTheControllerPassesOnOneReadingAfterTheWitnessWindow(t *testing.T) {
withTheGatesTimes(t)
sent := time.Date(2026, 10, 13, 20, 0, 0, 0, time.UTC)
o := aGateOn("mesh-controller", "novox", sent)
o.g.Applied["novox"] = sent.Add(15 * time.Second)
o.holder = &lease.Holder{Instance: "new", Taken: sent.Add(20 * time.Second)}
// Ready, but inside the witness window, which opened before it took the lease: not counted.
for _, after := range []time.Duration{30 * time.Second, 60 * time.Second, 79 * time.Second} {
o.read(sent.Add(after), healthGood, "")
if o.g.Verdict != "" || o.g.Passes != 0 || !strings.Contains(o.g.Last, "counts from") {
t.Fatalf("at %s: %+v; want no reading counted inside the witness window", after, o.g)
}
}
o.read(sent.Add(80*time.Second), healthGood, "")
if o.g.Verdict != inventory.GatePassed || o.g.Passes != 1 || o.g.Wants != 1 {
t.Fatalf("after the witness window: %+v; want passed on one reading", o.g)
}
if !strings.Contains(o.g.Why, "healthy 1 times") || !strings.Contains(o.g.Why, "the controller's rule") ||
!strings.Contains(o.g.Why, "ADR 0351") {
t.Errorf("the verdict says %q; want the rule it applied", o.g.Why)
}
}
// A controller that starts again takes the lease again: its witness window starts again, and so does the
// gate's wait.
func TestAControllerThatStartsAgainWaitsOutItsNewWindow(t *testing.T) {
withTheGatesTimes(t)
sent := time.Now()
o := aGateOn("mesh-controller", "novox", sent)
o.g.Applied["novox"] = sent.Add(10 * time.Second)
o.holder = &lease.Holder{Instance: "again", Taken: sent.Add(70 * time.Second)}
o.read(sent.Add(90*time.Second), healthGood, "")
if o.g.Verdict != "" {
t.Fatalf("passed %s after a start the witness still judges: %+v", 20*time.Second, o.g)
}
o.read(sent.Add(130*time.Second), healthGood, "")
if o.g.Verdict != inventory.GatePassed {
t.Fatalf("not passed after the new window: %+v", o.g)
}
}
// Without the lease's word (a controller carried by another gate), the window counts from the applied report,
// and from the send at the least.
func TestTheWitnessWindowCountsFromTheSendAtTheLeast(t *testing.T) {
withTheGatesTimes(t)
sent := time.Now()
r := gateRuleOf("mesh-controller", catalogue.Manifest{})
if f := gateFloor("mesh-controller", r, sent, []string{"novox"}, nil, nil); !f.Equal(sent.Add(time.Minute)) {
t.Errorf("floor %s after the send; want 1m", f.Sub(sent))
}
if f := gateFloor("node-tools", gateRuleOf("node-tools", catalogue.Manifest{}), sent, []string{"ace"},
map[string]time.Time{"ace": sent.Add(12 * time.Second)}, nil); !f.Equal(sent.Add(72 * time.Second)) {
t.Errorf("the tool runner's floor %s after the send; want 1m12s", f.Sub(sent))
}
}
// A controller that a witness put back, or that never becomes ready, fails its gate as before.
func TestAControllerThatFailsIsStillFailed(t *testing.T) {
withTheGatesTimes(t)
sent := time.Now()
o := aGateOn("mesh-controller", "novox", sent)
o.read(sent.Add(30*time.Second), healthBroken, "the witness on novox judged the controller and rolled-back")
if o.g.Verdict != inventory.GateFailed {
t.Fatalf("a controller put back by its witness: %+v; want failed", o.g)
}
o = aGateOn("mesh-controller", "novox", sent)
o.read(sent.Add(5*time.Minute), healthNotYet, "the controller holding the lease does not say it is ready")
if o.g.Verdict != "" {
t.Fatalf("failed before the bound: %+v", o.g)
}
o.read(sent.Add(10*time.Minute+time.Second), healthNotYet, "the controller holding the lease does not say it is ready")
if o.g.Verdict != inventory.GateFailed || !strings.Contains(o.g.Why, "not healthy within") {
t.Fatalf("never ready: %+v; want failed at the bound", o.g)
}
}
// threeReadings judges a module under the three-reading rule on one machine, the send at sent.
func threeReadings(t *testing.T, module string, sent time.Time) (*inventory.PlanGate, func(time.Duration, health)) {
t.Helper()
g := &inventory.PlanGate{Machines: []string{"ace"}, Since: &sent}
rule := gateRuleOf(module, catalogue.Manifest{Module: module, Claims: []catalogue.Claim{{Name: resolverSeat}}})
floor := gateFloor(module, rule, sent, []string{"ace"}, nil, nil)
return g, func(after time.Duration, h health) {
why := ""
if h != healthGood {
why = "the node-engine reports another build"
}
tallyGate(g, gateJudging{modules: []string{module}, reading: map[string]health{module: h}, worst: h, why: why,
rules: map[string]gateRule{module: rule}, floors: map[string]time.Time{module: floor}}, sent.Add(after))
}
}
// A healthy node-engine or resolver passes at its third healthy reading, 40 s apart from the apply: before two
// minutes (novox/hq ADR 0352).
func TestTheNodeEngineAndTheResolverPassAtTheirThirdReading(t *testing.T) {
withTheGatesTimes(t)
for _, module := range []string{"mesh-host", "dnsmasq"} {
g, judge := threeReadings(t, module, time.Now())
judge(15*time.Second, healthGood)
judge(55*time.Second, healthGood)
if g.Verdict != "" || g.Passes != 2 {
t.Fatalf("%s: after two readings %+v; want still judging", module, g)
}
judge(95*time.Second, healthGood)
if g.Verdict != inventory.GatePassed || g.Passes != 3 || g.Wants != 3 ||
!strings.Contains(g.Why, "over 1m35s") || !strings.Contains(g.Why, "the core's rule: 3 readings") {
t.Fatalf("%s: after three readings %+v; want passed at 1m35s by the core's rule", module, g)
}
}
}
// One healthy throughout whose readings came late passes at two minutes on the readings it has; the judging at
// two minutes comes sooner than the spacing after the last pass.
func TestAHealthyNodeEngineIsNeverHeldPastTwoMinutes(t *testing.T) {
withTheGatesTimes(t)
sent := time.Now()
last := sent.Add(100 * time.Second)
held := &inventory.PlanGate{Since: &sent, LastPass: &last}
if dueAtTheMost(held, sent.Add(119*time.Second)) || !dueAtTheMost(held, sent.Add(2*time.Minute)) {
t.Fatal("the judging at two minutes is not due, or is due before")
}
held.Faulted = true
if dueAtTheMost(held, sent.Add(2*time.Minute)) {
t.Fatal("a send that was not healthy at a reading is judged at two minutes")
}
g, judge := threeReadings(t, "mesh-host", time.Now())
judge(100*time.Second, healthGood)
judge(2*time.Minute, healthGood)
if g.Verdict != inventory.GatePassed || g.Passes != 2 {
t.Fatalf("late readings, healthy throughout, at two minutes: %+v; want passed on two", g)
}
g, judge = threeReadings(t, "mesh-host", time.Now())
judge(2*time.Minute, healthGood)
if g.Verdict != inventory.GatePassed || g.Passes != 1 {
t.Fatalf("one healthy reading, at two minutes: %+v; want passed on it", g)
}
}
// A reading not yet healthy before the first healthy one is the build still starting: the two minutes still hold.
func TestANodeEngineStillStartingKeepsItsTwoMinutes(t *testing.T) {
withTheGatesTimes(t)
g, judge := threeReadings(t, "mesh-host", time.Now())
judge(10*time.Second, healthNotYet)
judge(100*time.Second, healthGood)
judge(2*time.Minute, healthGood)
if g.Verdict != inventory.GatePassed || g.Faulted {
t.Fatalf("%+v; want passed at two minutes", g)
}
}
// A reading that is not healthy after a healthy one takes the two minutes away: the module needs its three readings, and one
// never healthy fails at the bound and is put back; one found broken fails.
func TestANodeEngineThatFailsAReadingIsNotPassedAtTwoMinutes(t *testing.T) {
withTheGatesTimes(t)
g, judge := threeReadings(t, "mesh-host", time.Now())
judge(20*time.Second, healthNotYet)
judge(60*time.Second, healthGood)
judge(100*time.Second, healthGood)
judge(2*time.Minute+10*time.Second, healthGood)
if g.Verdict != inventory.GatePassed || g.Passes != 3 {
t.Fatalf("%+v; want passed only on its third healthy reading", g)
}
g, judge = threeReadings(t, "mesh-host", time.Now())
judge(60*time.Second, healthGood)
judge(100*time.Second, healthNotYet)
judge(2*time.Minute, healthGood)
if g.Verdict != "" || !g.Faulted {
t.Fatalf("passed at two minutes after a reading that was not healthy: %+v", g)
}
judge(10*time.Minute+time.Second, healthNotYet)
if g.Verdict != inventory.GateFailed || !strings.Contains(g.Why, "not healthy within") {
t.Fatalf("never healthy again: %+v; want failed at the bound", g)
}
g, judge = threeReadings(t, "mesh-host", time.Now())
judge(60*time.Second, healthGood)
judge(100*time.Second, healthBroken)
if g.Verdict != inventory.GateFailed {
t.Fatalf("a broken reading: %+v; want failed, to be put back", g)
}
}
// The tool runner passes on one reading after its witness window; mesh-delivery on one, at once.
func TestTheToolRunnerAndMeshDeliveryPassOnOneReading(t *testing.T) {
withTheGatesTimes(t)
sent := time.Now()
o := aGateOn("node-tools", "ace", sent)
o.g.Applied["ace"] = sent.Add(10 * time.Second)
o.read(sent.Add(30*time.Second), healthGood, "")
if o.g.Verdict != "" {
t.Fatalf("the tool runner passed inside its witness window: %+v", o.g)
}
o.read(sent.Add(70*time.Second), healthGood, "")
if o.g.Verdict != inventory.GatePassed || o.g.Passes != 1 || !strings.Contains(o.g.Why, "the tool runner's rule") {
t.Fatalf("the tool runner: %+v; want passed on one reading", o.g)
}
o = aGateOn(catalogue.DeliverySeat, "novox", sent)
o.read(sent.Add(5*time.Second), healthGood, "")
if o.g.Verdict != inventory.GatePassed || o.g.Passes != 1 || !strings.Contains(o.g.Why, "mesh-delivery's rule") {
t.Fatalf("mesh-delivery: %+v; want passed on one reading", o.g)
}
}
// A dip after a healthy reading taken before the controller's witness floor is a fault: the send is not passed at
// two minutes (novox/hq ADR 0352 decision 3).
func TestADipAfterAnUncountedHealthyReadingIsAFault(t *testing.T) {
withTheGatesTimes(t)
sent := time.Now()
g := &inventory.PlanGate{Machines: []string{"novox"}, Since: &sent}
modules := []string{"mesh-controller", "mesh-host"}
rules, floors := map[string]gateRule{}, map[string]time.Time{}
for _, m := range modules {
rules[m] = gateRuleOf(m, catalogue.Manifest{Module: m})
}
floors["mesh-host"] = sent
floors["mesh-controller"] = sent.Add(90 * time.Second)
judge := func(after time.Duration, h health) {
tallyGate(g, gateJudging{modules: modules, reading: map[string]health{"mesh-controller": h, "mesh-host": h},
worst: h, why: "not yet", rules: rules, floors: floors}, sent.Add(after))
}
judge(30*time.Second, healthGood)
if g.Passes != 0 {
t.Fatalf("a reading before the controller's floor was counted: %+v", g)
}
judge(70*time.Second, healthNotYet)
judge(2*time.Minute, healthGood)
if !g.Faulted || g.Verdict != "" {
t.Fatalf("%+v; want the dip a fault, and no pass at two minutes", g)
}
}
// A send carrying the controller and the node-engine is judged by the node-engine's three readings.
func TestASendIsJudgedByItsMostDemandingRule(t *testing.T) {
withTheGatesTimes(t)
sent := time.Now()
g := &inventory.PlanGate{Machines: []string{"novox"}, Since: &sent}
modules := []string{"mesh-controller", "mesh-host"}
rules, floors := map[string]gateRule{}, map[string]time.Time{}
for _, m := range modules {
rules[m] = gateRuleOf(m, catalogue.Manifest{Module: m})
floors[m] = gateFloor(m, rules[m], sent, []string{"novox"}, nil, nil)
}
for _, after := range []time.Duration{70 * time.Second, 110 * time.Second} {
tallyGate(g, gateJudging{modules: modules, reading: map[string]health{"mesh-controller": healthGood,
"mesh-host": healthGood}, worst: healthGood, rules: rules, floors: floors}, sent.Add(after))
}
if g.Verdict != "" || g.Wants != 3 || g.Passes != 2 || g.Healthy["mesh-controller"] != 2 {
t.Fatalf("%+v; want the send judged by the node-engine's three readings, the controller's own counted", g)
}
}
+8 -29
View File
@@ -124,10 +124,14 @@ func aGateMesh(t *testing.T) *gateMesh {
} }
t.Cleanup(func() { gatherGateFacts = wasGather }) t.Cleanup(func() { gatherGateFacts = wasGather })
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
wasWindow := gateWitnessWindow
gateWitnessWindow = 0
t.Cleanup(func() { gateWitnessWindow = wasWindow })
// One judging per advance until a test says otherwise: a pass waits gateEvery for the next. // One judging per advance until a test says otherwise: a pass waits gateEvery for the next.
gateSettle, gateEvery = 0, time.Hour gateWithin, gateEvery = time.Hour, time.Hour
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound }) t.Cleanup(func() { gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound })
built := time.Now().UTC() built := time.Now().UTC()
plan := inventory.Plan{ID: "plan-gate", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2", plan := inventory.Plan{ID: "plan-gate", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2",
@@ -272,7 +276,7 @@ func TestABuildThatPassesItsGateRollsEverywhereUnattended(t *testing.T) {
func TestALeafModulePassesOnOneHealthyReading(t *testing.T) { func TestALeafModulePassesOnOneHealthyReading(t *testing.T) {
g := aGateMesh(t) g := aGateMesh(t)
ctx := t.Context() ctx := t.Context()
gateSettle = time.Hour // a core module could not pass for an hour gateWithin = time.Hour // a core module could not pass on fewer readings for an hour
advancePlans(ctx, g.open) // the first machine is sent the new build advancePlans(ctx, g.open) // the first machine is sent the new build
advancePlans(ctx, g.open) // one healthy judging advancePlans(ctx, g.open) // one healthy judging
p := g.plan(t) p := g.plan(t)
@@ -285,31 +289,6 @@ func TestALeafModulePassesOnOneHealthyReading(t *testing.T) {
} }
} }
// Which modules are judged as the core: those on the controller's own path and the resolver's holder, three
// readings after the settle time; every other module one (novox/hq ADR 0282 decision 1, ADR 0304 decision 3).
func TestAModuleIsJudgedByItsClass(t *testing.T) {
for _, c := range []struct {
module string
claims []catalogue.Claim
passes int
settles bool
}{
{"app", nil, 1, false},
{"nzbget", nil, 1, false},
{"mesh-controller", nil, 3, true},
{"mesh-host", nil, 3, true},
{"node-tools", nil, 3, true},
{"nats", nil, 3, true},
{catalogue.DeliverySeat, nil, 3, true},
{"dnsmasq", []catalogue.Claim{{Name: resolverSeat}}, 3, true},
} {
p, s := gateWants(c.module, catalogue.Manifest{Module: c.module, Claims: c.claims})
if p != c.passes || s != c.settles {
t.Errorf("%s: %d passes, settles %v; want %d, %v", c.module, p, s, c.passes, c.settles)
}
}
}
// A first machine that never becomes healthy fails its gate at the bound, and is put back. // A first machine that never becomes healthy fails its gate at the bound, and is put back.
func TestABuildNeverHealthyFailsAtTheBound(t *testing.T) { func TestABuildNeverHealthyFailsAtTheBound(t *testing.T) {
judgeLeavesAsTheCore(t) judgeLeavesAsTheCore(t)
+1 -1
View File
@@ -55,7 +55,7 @@ func TestARepairByHandCarriesItsReason(t *testing.T) {
want string want string
}{ }{
{"push", map[string]any{"node": "anchor", "why": "stuck", "cause": "sent-not-reported"}, {"push", map[string]any{"node": "anchor", "why": "stuck", "cause": "sent-not-reported"},
"push anchor --wait 0 --why stuck --cause sent-not-reported"}, "push anchor --wait 0s --why stuck --cause sent-not-reported"},
{"plans", map[string]any{"close": "plan-1", "why": "the report will not come"}, {"plans", map[string]any{"close": "plan-1", "why": "the report will not come"},
"plans close plan-1 --why the report will not come"}, "plans close plan-1 --why the report will not come"},
{"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"}, {"plans", map[string]any{"retry": "plan-1"}, "plans retry plan-1"},
+67
View File
@@ -0,0 +1,67 @@
package main
import (
"context"
"fmt"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/overlay"
)
// localNetworksOf is a machine's attached networks judged (novox/hq ADR 0340): which count as its local
// network, from what it last said of itself and the links it said face outside it. A machine that has
// said none judges to nothing, and a port reached from `local-network` is then admitted from the private
// network only, and said so.
func localNetworksOf(ctx context.Context, inv *inventory.Inventory, node string) ([]catalogue.LocalNetwork, error) {
account, had, err := inv.MachineOf(ctx, node)
if err != nil {
return nil, err
}
outward, err := inv.OutwardLinksOf(ctx, node)
if err != nil {
return nil, err
}
return judgedNetworks(account, had, outward, overlay.Interface), nil
}
// judgedNetworks is the judging alone, from what was read.
func judgedNetworks(account inventory.MachineAccount, had bool, outward []string, tunnel string) []catalogue.LocalNetwork {
if !had || account.Machine == nil {
return nil
}
attached := make([]catalogue.AttachedNetwork, 0, len(account.Machine.Networks))
for _, n := range account.Machine.Networks {
attached = append(attached, catalogue.AttachedNetwork{Link: n.Link, Network: n.Network})
}
return catalogue.JudgeLocalNetworks(attached, outward, tunnel)
}
// localNetworkLines is what `node` shows of a machine's attached networks (ADR 0340 §8): each one, with
// whether it counts as the local network and why, or why none is shown.
func localNetworkLines(account inventory.MachineAccount, had bool, judged []catalogue.LocalNetwork) []string {
out := []string{" its local network (what a port reached from local-network is admitted from):"}
switch {
case !had || account.Machine == nil:
return append(out, " not said — this machine has not reported what it is attached to")
case len(account.Machine.Networks) == 0:
why := account.Machine.Unknown["networks"]
if why == "" {
why = "its node-engine is older than the one that says it"
}
return append(out, " not said — "+why)
}
counted := 0
for _, j := range judged {
verdict := "not counted"
if j.Counts {
verdict = "counts"
counted++
}
out = append(out, fmt.Sprintf(" %-22s on %-10s %s: %s", j.Network, j.Link, verdict, j.Why))
}
if counted == 0 {
out = append(out, " none counts: a port reached from local-network is admitted from the private network only")
}
return out
}
+65
View File
@@ -0,0 +1,65 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// **node shows what the local network resolved to** (novox/hq ADR 0340 §8): every attached network,
// whether it counts, and why the others do not — the public subnet named as never counted.
func TestNodeShowsWhichAttachedNetworksCountAndWhy(t *testing.T) {
account := inventory.MachineAccount{Architecture: "amd64", Kernel: "linux", Machine: &inventory.MadeOf{
Networks: []inventory.AttachedNetwork{
{Link: "eth0", Network: "192.168.1.10/24"},
{Link: "eth1", Network: "203.0.113.7/24"},
{Link: "wg-mesh", Network: "10.10.0.4/24"},
}}}
judged := judgedNetworks(account, true, []string{"eth0", "eth1"}, "wg-mesh")
text := strings.Join(localNetworkLines(account, true, judged), "\n")
for _, want := range []string{
"192.168.1.0/24", "counts: in a private range (RFC 1918, 192.168.0.0/16)",
"203.0.113.0/24", "not counted: public, never counted",
"10.10.0.0/24", "the private network's own link",
} {
if !strings.Contains(text, want) {
t.Errorf("node does not say %q:\n%s", want, text)
}
}
}
// A machine that said none, or whose node-engine is older, is said as that — never as an empty list
// that reads like a machine on no network.
func TestNodeSaysWhenNoNetworkWasSaid(t *testing.T) {
older := inventory.MachineAccount{Machine: &inventory.MadeOf{}}
if text := strings.Join(localNetworkLines(older, true, nil), "\n"); !strings.Contains(text, "older than the one that says it") {
t.Errorf("an older node-engine is not said:\n%s", text)
}
public := inventory.MachineAccount{Machine: &inventory.MadeOf{
Networks: []inventory.AttachedNetwork{{Link: "eth0", Network: "198.51.100.9/24"}}}}
judged := judgedNetworks(public, true, []string{"eth0"}, "wg-mesh")
if text := strings.Join(localNetworkLines(public, true, judged), "\n"); !strings.Contains(text, "none counts") {
t.Errorf("a machine on a public network only is not said to have no local network:\n%s", text)
}
if text := strings.Join(localNetworkLines(inventory.MachineAccount{}, false, nil), "\n"); !strings.Contains(text, "not said") {
t.Errorf("a machine that never reported is not said:\n%s", text)
}
}
// The preview of a flip says what a port from the local network keeps: the local networks it stays
// open to, or that the machine reported none (ADR 0340 §6).
func TestTheFlipPreviewSaysWhatALocalNetworkPortKeeps(t *testing.T) {
rules := []catalogue.Rule{{Port: 1883, Protocol: "tcp", From: catalogue.FromLocalNetwork, Because: []string{"mosquitto"}}}
at := inventory.Reach{Protocol: "tcp", Address: "0.0.0.0", Port: 1883}
with := derivedFilter{rules: rules, mesh: []string{"10.10.0.1"}, outwardLinks: []string{"eth0"},
local: []string{"192.168.1.0/24"}}
if fate := with.fate(at); !strings.Contains(fate, "192.168.1.0/24") {
t.Errorf("the preview says %q, without the local network it stays open to", fate)
}
with.local = nil
if fate := with.fate(at); !strings.Contains(fate, "reported none") {
t.Errorf("the preview says %q, without saying no local network was reported", fate)
}
}
+9 -1
View File
@@ -104,6 +104,8 @@ func run() error {
return replayCommand(ctx, args[1:]) return replayCommand(ctx, args[1:])
case "kill": case "kill":
return killCommand(ctx, args[1:]) return killCommand(ctx, args[1:])
case "forget-checks":
return forgetChecksCommand(ctx, args[1:])
case "pause", "resume": case "pause", "resume":
return pauseCommand(ctx, args[0], args[1:]) return pauseCommand(ctx, args[0], args[1:])
case "collection": case "collection":
@@ -429,8 +431,14 @@ func (b builds) Built(ctx context.Context, result link.BuildResult) error {
} }
fmt.Printf("%s: %s %s registered, built on %s from %s\n", fmt.Printf("%s: %s %s registered, built on %s from %s\n",
result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit)) result.ID, manifest.Module, manifest.Version, result.On, short(result.Commit))
// What the walk's build did, kept on the walk whose ask it answers (novox/hq ADR 0350 §4); a build seat from
// before it says nothing, and that is what is kept.
made := result.MadeWords()
if result.Reused != "" || result.BuiltWhy != "" {
fmt.Printf("%s: %s %s\n", result.ID, manifest.Module, made)
}
saysWhenThePolicyActs(ctx, b.inv, manifest.Module) saysWhenThePolicyActs(ctx, b.inv, manifest.Module)
planBuilt(ctx, b.open, manifest.Module, result.Commit, "", asked, result.ID) planBuiltSaying(ctx, b.open, manifest.Module, result.Commit, "", asked, result.ID, made)
// A module registered may be one a machine is now behind: `status` is composed again. // A module registered may be one a machine is now behind: `status` is composed again.
statusFrom.nudge() statusFrom.nudge()
return nil return nil
+1 -1
View File
@@ -75,7 +75,7 @@ func stateHealth(ctx context.Context, inv *inventory.Inventory, k *conditions.Ke
State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts, State: r.State, Reason: r.Reason, Since: r.Since, Streak: r.Streak, Restarts: r.Restarts,
Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root} Check: r.Check, Needs: r.Needs, Account: r.Account, Root: r.Root}
for _, w := range r.Waits { for _, w := range r.Waits {
kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, What: w.What}) kept.Waits = append(kept.Waits, inventory.Wait{Part: w.Part, Secret: w.Secret, Setting: w.Setting, Provision: w.Provision, What: w.What})
} }
resources = append(resources, kept) resources = append(resources, kept)
} }
+1 -1
View File
@@ -684,7 +684,7 @@ func artifactStoreOnNetwork(ctx context.Context, inv *inventory.Inventory,
if !offers { if !offers {
continue continue
} }
serves, err := servedOnNode(ctx, inv, machine, m, catalogue.ArtifactStoreProvision) serves, _, err := servedOnNode(ctx, inv, machine, m, catalogue.ArtifactStoreProvision)
if err != nil { if err != nil {
return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err) return "", "", false, fmt.Errorf("reading where %s puts the artifact store: %w", machine, err)
} }
+11
View File
@@ -19,6 +19,7 @@ import (
"github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/overlay"
"github.com/novox/mesh-controller/internal/token" "github.com/novox/mesh-controller/internal/token"
) )
@@ -884,6 +885,16 @@ func showNode(ctx context.Context, stored *stores, name string) error {
for _, line := range machineLines(account, had, time.Now()) { for _, line := range machineLines(account, had, time.Now()) {
fmt.Println(line) fmt.Println(line)
} }
// And which of the networks it is attached to count as its local network, and why the others
// do not (novox/hq ADR 0340 §8).
outward, err := inv.OutwardLinksOf(ctx, name)
if err != nil {
fmt.Printf(" its local network could NOT be judged: %v\n", err)
} else {
for _, line := range localNetworkLines(account, had, judgedNetworks(account, had, outward, overlay.Interface)) {
fmt.Println(line)
}
}
} }
held, err := inv.Profile(ctx, name) held, err := inv.Profile(ctx, name)
+138 -13
View File
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"fmt" "fmt"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -44,6 +45,28 @@ func needsOperatorKey(module, node string) string {
type operatorWaitFacts struct { type operatorWaitFacts struct {
manifests map[string]catalogue.Manifest manifests map[string]catalogue.Manifest
given map[string]map[string]time.Time given map[string]map[string]time.Time
// outside is, per provision some module offers with a credential issued outside the mesh, that provider module
// and the machines it is assigned to (novox/hq ADR 0337); pairsGiven, per "<module>@<machine>", the provisions
// it holds a value a person gave for, sealed to the keys both ends have now. Absent is not known: no wait for a
// provision of it is excused.
outside map[string]outsideProvider
pairsGiven map[string]map[string]bool
}
// outsideProvider is the module offering a provision whose credential only a person gives, and where it runs.
type outsideProvider struct {
Module string
Machines []string
}
// acceptLine is the one line that gives an outside provision's credential for every consumer of its provider: on
// the provider's machine when it runs on one, named as <machine> when on several or none.
func (p outsideProvider) acceptLine(provision string) string {
machine := "<its machine>"
if len(p.Machines) == 1 {
machine = p.Machines[0]
}
return inventory.AcceptLine(machine, p.Module, provision)
} }
// checkWait is nil when a wait is excused, and otherwise why not, in words. Pure. // checkWait is nil when a wait is excused, and otherwise why not, in words. Pure.
@@ -52,9 +75,18 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
if !known { if !known {
return fmt.Errorf("says it waits for %s, and the mesh holds no manifest of %s to check it against", waitNames(w), module) return fmt.Errorf("says it waits for %s, and the mesh holds no manifest of %s to check it against", waitNames(w), module)
} }
named := 0
for _, n := range []string{w.Secret, w.Setting, w.Provision} {
if n != "" {
named++
}
}
switch { switch {
case w.Secret != "" && w.Setting != "", w.Secret == "" && w.Setting == "": case named != 1:
return fmt.Errorf("says it waits, naming %s, where a wait names one secret or one setting", waitNames(w)) return fmt.Errorf("says it waits, naming %s, where a wait names one secret, one setting or one provision",
waitNames(w))
case w.Provision != "":
return checkProvisionWait(module, machine, w, m, f)
case w.Setting != "": case w.Setting != "":
if _, declared := m.Settings[w.Setting]; !declared { if _, declared := m.Settings[w.Setting]; !declared {
return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module) return fmt.Errorf("says it waits for the setting %s, which %s does not declare", w.Setting, module)
@@ -80,21 +112,47 @@ func checkWait(module, machine string, w inventory.Wait, f operatorWaitFacts) er
return nil return nil
} }
// checkProvisionWait excuses a wait for a provision's credential only when the store bears it out (novox/hq ADR 0337
// §6): the module requires the provision, a provider offers it with a credential issued outside the mesh, and the
// module on this machine holds no value a person gave for it under the keys both ends have now.
func checkProvisionWait(module, machine string, w inventory.Wait, m catalogue.Manifest, f operatorWaitFacts) error {
if !slices.Contains(m.Requires, w.Provision) {
return fmt.Errorf("says it waits for the provision %s, which %s does not require", w.Provision, module)
}
if _, outside := f.outside[w.Provision]; !outside {
return fmt.Errorf("says it waits for the provision %s, whose credential the mesh gives itself: only a "+
"credential issued outside the mesh waits for the operator", w.Provision)
}
given, readable := f.pairsGiven[module+"@"+machine]
if !readable {
return fmt.Errorf("says it waits for the provision %s, and what was given on %s could not be read", w.Provision, machine)
}
if given[w.Provision] {
return fmt.Errorf("says it waits for the provision %s, whose credential was given", w.Provision)
}
return nil
}
// waitRefusedPrefix opens every reason checkWait gives, so the words of a refused wait are told from a check's own. // waitRefusedPrefix opens every reason checkWait gives, so the words of a refused wait are told from a check's own.
const waitRefusedPrefix = "says it waits" const waitRefusedPrefix = "says it waits"
// waitNames is what a wait names, as "the secret x" or "the setting y". // waitNames is what a wait names, as "the secret x" or "the setting y".
func waitNames(w inventory.Wait) string { func waitNames(w inventory.Wait) string {
switch { var names []string
case w.Secret != "" && w.Setting != "": if w.Secret != "" {
return "the secret " + w.Secret + " and the setting " + w.Setting names = append(names, "the secret "+w.Secret)
case w.Secret != "":
return "the secret " + w.Secret
case w.Setting != "":
return "the setting " + w.Setting
} }
if w.Setting != "" {
names = append(names, "the setting "+w.Setting)
}
if w.Provision != "" {
names = append(names, "the provision "+w.Provision)
}
if len(names) == 0 {
return "nothing" return "nothing"
} }
return strings.Join(names, " and ")
}
// checkWaiting reads one machine's resources against the facts: every waiting resource whose waits all check out is // checkWaiting reads one machine's resources against the facts: every waiting resource whose waits all check out is
// kept as said; one with a wait that does not, or with no wait at all, is answered as unhealthy with why. Pure; the // kept as said; one with a wait that does not, or with no wait at all, is answered as unhealthy with why. Pure; the
@@ -107,11 +165,17 @@ func checkWaiting(machine string, rs []inventory.ResourceHealth, f operatorWaitF
if len(r.Waits) == 0 { if len(r.Waits) == 0 {
why = fmt.Errorf("says it waits, and names nothing it waits for") why = fmt.Errorf("says it waits, and names nothing it waits for")
} }
waits := make([]inventory.Wait, 0, len(r.Waits))
for _, w := range r.Waits { for _, w := range r.Waits {
if why == nil { if why == nil {
why = checkWait(r.Module, machine, w, f) why = checkWait(r.Module, machine, w, f)
} }
if p, outside := f.outside[w.Provision]; outside && w.Provision != "" {
w.Act = p.acceptLine(w.Provision)
} }
waits = append(waits, w)
}
r.Waits = waits
if why != nil { if why != nil {
r.State, r.Reason = link.StateUnhealthy, why.Error() r.State, r.Reason = link.StateUnhealthy, why.Error()
} }
@@ -150,6 +214,10 @@ func operatorWaitSaid(module, machine string, waits []inventory.Wait) string {
if w.Secret != "" { if w.Secret != "" {
part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret) part += fmt.Sprintf(", given with `nox secret ask %s %s %s`", machine, module, w.Secret)
} }
if w.Provision != "" && w.Act != "" {
// One line for the provider, which seals it to every consumer of it (ADR 0337 §3, §7).
part += fmt.Sprintf(", given for every consumer with `%s`", w.Act)
}
parts = append(parts, part+")") parts = append(parts, part+")")
} }
return fmt.Sprintf("%s on %s waits for the operator: %s", module, machine, strings.Join(parts, "; ")) return fmt.Sprintf("%s on %s waits for the operator: %s", module, machine, strings.Join(parts, "; "))
@@ -172,7 +240,7 @@ func needsOperatorObservation(module, node string, waits []inventory.Wait, rs []
func needsOperatorWords(module, node string, waits []inventory.Wait) words { func needsOperatorWords(module, node string, waits []inventory.Wait) words {
var acts []string var acts []string
seen := map[string]bool{} seen := map[string]bool{}
secret := false secret, provision := false, false
for _, w := range waits { for _, w := range waits {
var act string var act string
switch { switch {
@@ -180,6 +248,9 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words {
act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true act, secret = fmt.Sprintf("type %s at %s's desk prompt", w.What, node), true
case w.Setting != "": case w.Setting != "":
act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node) act = fmt.Sprintf("approve %s of %s on %s when it is proposed to you", w.Setting, module, node)
case w.Provision != "":
// ADR 0337 §7: "give <what> of <provider> on <machine>"; the line is in the summary.
act, provision = "give "+w.What, true
} }
if act != "" && !seen[act] { if act != "" && !seen[act] {
seen[act] = true seen[act] = true
@@ -197,6 +268,10 @@ func needsOperatorWords(module, node string, waits []inventory.Wait) words {
explanation += " A hidden prompt opens at the desk when the secret is asked for, and what you type there " + explanation += " A hidden prompt opens at the desk when the secret is asked for, and what you type there " +
"is sealed to the machine." "is sealed to the machine."
} }
if provision {
explanation += " A key only you can give is given once for the app that issued it, and reaches every " +
"module that uses it."
}
explanation += " Its update is in place and nothing was undone; it carries on by itself once it is given." explanation += " Its update is in place and nothing was undone; it carries on by itself once it is given."
return words{ return words{
Headline: fmt.Sprintf("%s waits for you on %s", module, node), Headline: fmt.Sprintf("%s waits for you on %s", module, node),
@@ -217,6 +292,12 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
if f.given == nil { if f.given == nil {
f.given = map[string]map[string]time.Time{} f.given = map[string]map[string]time.Time{}
} }
if f.outside == nil {
f.outside = map[string]outsideProvider{}
}
if f.pairsGiven == nil {
f.pairsGiven = map[string]map[string]bool{}
}
if inv == nil { if inv == nil {
return return
} }
@@ -238,13 +319,57 @@ func readWaitFacts(ctx context.Context, inv *inventory.Inventory, machine string
} }
} }
} }
if _, read := f.given[module+"@"+machine]; read { if _, read := f.given[module+"@"+machine]; !read {
continue
}
if given, err := inv.GivenOwnSecrets(ctx, machine, module); err == nil { if given, err := inv.GivenOwnSecrets(ctx, machine, module); err == nil {
f.given[module+"@"+machine] = given f.given[module+"@"+machine] = given
} }
} }
if _, read := f.pairsGiven[module+"@"+machine]; !read {
if given, err := inv.GivenPairsOf(ctx, machine, module); err == nil {
f.pairsGiven[module+"@"+machine] = given
}
}
// The provisions it requires whose credential only a person gives, and where their provider runs (ADR 0337).
m, known := f.manifests[module]
if !known {
continue
}
for _, provision := range m.Requires {
if _, read := f.outside[provision]; read {
continue
}
if shelf == nil {
var err error
if shelf, err = inv.Catalogue(ctx); err != nil {
shelf = map[string]catalogue.Manifest{}
}
}
provider, outside := catalogue.OffersIssuedOutside(shelf, provision)
if !outside {
continue
}
f.outside[provision] = outsideProvider{Module: provider, Machines: machinesRunning(ctx, inv, provider)}
}
}
}
// machinesRunning is every machine a module is assigned to, by name; a read that fails is left out.
func machinesRunning(ctx context.Context, inv *inventory.Inventory, module string) []string {
nodes, err := inv.Nodes(ctx)
if err != nil {
// Only the summary's accept line reads it, which then names the provider's machine "<its machine>".
// empty-on-error: no wait is excused or refused by it
return nil
}
var out []string
for _, n := range nodes {
assigned, err := inv.Assigned(ctx, n.Name)
if err == nil && slices.Contains(assigned, module) {
out = append(out, n.Name)
}
}
sort.Strings(out)
return out
} }
// waitingModules is every module with a waiting resource in a statement. // waitingModules is every module with a waiting resource in a statement.
+2 -2
View File
@@ -55,8 +55,8 @@ func TestAWaitIsExcusedOnlyWhenItChecksOut(t *testing.T) {
{"a secret the mesh makes", inventory.Wait{Part: "p", Secret: "made", What: "w"}, factsGiven(nil), "mesh makes itself"}, {"a secret the mesh makes", inventory.Wait{Part: "p", Secret: "made", What: "w"}, factsGiven(nil), "mesh makes itself"},
{"the bus account", inventory.Wait{Part: "p", Secret: "broker", What: "w"}, factsGiven(nil), "mesh makes itself"}, {"the bus account", inventory.Wait{Part: "p", Secret: "broker", What: "w"}, factsGiven(nil), "mesh makes itself"},
{"a setting not declared", inventory.Wait{Part: "p", Setting: "logins", What: "w"}, factsGiven(nil), "does not declare"}, {"a setting not declared", inventory.Wait{Part: "p", Setting: "logins", What: "w"}, factsGiven(nil), "does not declare"},
{"both", inventory.Wait{Part: "p", Secret: "licence", Setting: "smb-users", What: "w"}, factsGiven(nil), "one secret or one setting"}, {"both", inventory.Wait{Part: "p", Secret: "licence", Setting: "smb-users", What: "w"}, factsGiven(nil), "one secret, one setting or one provision"},
{"neither", inventory.Wait{Part: "p", What: "w"}, factsGiven(nil), "one secret or one setting"}, {"neither", inventory.Wait{Part: "p", What: "w"}, factsGiven(nil), "one secret, one setting or one provision"},
{"no manifest known", passwordWait, operatorWaitFacts{}, "no manifest"}, {"no manifest known", passwordWait, operatorWaitFacts{}, "no manifest"},
{"what was given cannot be read", passwordWait, {"what was given cannot be read", passwordWait,
operatorWaitFacts{manifests: map[string]catalogue.Manifest{"mounts": mountsManifest}}, "could not be read"}, operatorWaitFacts{manifests: map[string]catalogue.Manifest{"mounts": mountsManifest}}, "could not be read"},
@@ -0,0 +1,83 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// One accept for a provider reaches every consumer that binds the provision from it, and each consumer's send
// places it; the per-pair form naming a consumer is refused for such a provision, naming the provider's form; a
// value the mesh made before the rule is not placed (novox/hq ADR 0337 §2, §3, §4, issue 164, issue 165). Needs the
// store (MESH_TEST_POSTGRES), and is named in testdata/store-tests.
func TestOneAcceptForAProviderIsPlacedForEveryConsumer(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
// Before the rule: tv's offer said nothing of its credential, and the send made one per pair.
register(t, open, catalogue.Manifest{Module: "tv", Version: "1",
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh}}})
for _, m := range []string{"requests", "indexer"} {
register(t, open, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"tv-api"},
Secrets: map[string]string{"tv-api": "/var/lib/mesh/" + m + "/tv-api.secret"}})
}
for _, a := range [][2]string{{"anchor", "tv"}, {"laptop", "requests"}, {"laptop", "indexer"}} {
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
t.Fatal(err)
}
}
placed := func(module string) bool {
t.Helper()
plan, settings, err := planFor(ctx, open, "laptop")
if err != nil {
t.Fatalf("laptop was refused: %v", err)
}
declared, err := declarationFor(ctx, open, "laptop", plan, settings)
if err != nil {
t.Fatalf("laptop did not compose: %v", err)
}
for _, r := range declared.Resources {
if r["path"] == "/var/lib/mesh/"+module+"/tv-api.secret" && r["sealed"] != nil {
return true
}
}
return false
}
if !placed("requests") || !placed("indexer") {
t.Fatal("before the rule, the send made and placed a credential per pair")
}
// The offer now says only a person gives it: the values the mesh made are not placed.
register(t, open, catalogue.Manifest{Module: "tv", Version: "2",
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh,
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}})
if placed("requests") || placed("indexer") {
t.Fatal("a credential nobody gave was placed")
}
// The per-pair form is refused, naming the provider's form, and reads nothing.
file := filepath.Join(t.TempDir(), "key")
if err := os.WriteFile(file, []byte("the app's key\n"), 0o600); err != nil {
t.Fatal(err)
}
err := secretCommand(ctx, []string{"accept", "laptop", "requests", "tv-api", "--provider", "anchor", "--from", file})
if err == nil || !strings.Contains(err.Error(), "secret accept anchor tv tv-api") {
t.Fatalf("the per-pair form was not refused naming the provider's: %v", err)
}
consumers, unresolved, err := consumersFrom(ctx, open, "anchor", "tv", "tv-api")
if err != nil || len(unresolved) != 0 {
t.Fatalf("the consumers of tv on anchor could not be found: %v %v", err, unresolved)
}
if len(consumers) != 2 {
t.Fatalf("the consumers of tv on anchor are %+v; want requests and indexer on laptop", consumers)
}
if _, err := open.inventory.AcceptSecretForProvision(ctx, "tv-api", "anchor", "tv", "the app's key", consumers); err != nil {
t.Fatal(err)
}
if !placed("requests") || !placed("indexer") {
t.Fatal("the value given once for the provider was not placed for every consumer")
}
}
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link"
)
// A consumer may wait for a provision's credential only a person gives (novox/hq ADR 0337 §5–§7, issue 164).
var requestsManifest = catalogue.Manifest{Module: "requests", Version: "1", Requires: []string{"tv-api", "db"},
Secrets: map[string]string{"tv-api": "/s/tv-api", "db": "/s/db"}}
var tvKeyWait = inventory.Wait{Part: "the tv library", Provision: "tv-api", What: "the API key of tv"}
func outsideFacts(given map[string]bool) operatorWaitFacts {
return operatorWaitFacts{manifests: map[string]catalogue.Manifest{"requests": requestsManifest},
outside: map[string]outsideProvider{"tv-api": {Module: "tv", Machines: []string{"media"}}},
pairsGiven: map[string]map[string]bool{"requests@laptop": given}}
}
// §6: excused only when the module requires the provision, its provider says it is issued outside, and no value a
// person gave is held for the pair under the keys both ends have now.
func TestAWaitForAProvisionIsExcusedOnlyWhenTheStoreBearsItOut(t *testing.T) {
for _, c := range []struct {
name string
w inventory.Wait
f operatorWaitFacts
says string
}{
{"required, issued outside, not given", tvKeyWait, outsideFacts(nil), ""},
{"given", tvKeyWait, outsideFacts(map[string]bool{"tv-api": true}), "was given"},
{"not required", inventory.Wait{Part: "p", Provision: "music-api", What: "w"}, outsideFacts(nil), "does not require"},
{"a credential the mesh gives itself", inventory.Wait{Part: "p", Provision: "db", What: "w"}, outsideFacts(nil),
"mesh gives itself"},
{"what was given cannot be read", tvKeyWait, operatorWaitFacts{
manifests: map[string]catalogue.Manifest{"requests": requestsManifest},
outside: map[string]outsideProvider{"tv-api": {Module: "tv"}}}, "could not be read"},
{"a provision and a secret", inventory.Wait{Part: "p", Provision: "tv-api", Secret: "x", What: "w"},
outsideFacts(nil), "one secret, one setting or one provision"},
} {
err := checkWait("requests", "laptop", c.w, c.f)
switch {
case c.says == "" && err != nil:
t.Errorf("%s: refused: %v", c.name, err)
case c.says != "" && (err == nil || !strings.Contains(err.Error(), c.says)):
t.Errorf("%s: %v; want a refusal saying %q", c.name, err, c.says)
}
}
}
// §7: an excused wait is the consumer's needs-operator condition, in plain words, its summary naming the one line
// that gives the credential for every consumer of the provider; the wait as kept is not changed.
func TestAWaitForAProvisionNamesTheOneAcceptForItsProvider(t *testing.T) {
r := inventory.ResourceHealth{Module: "requests", Resource: "requests.app", Kind: "container", Target: "requests",
State: link.StateWaiting, Check: "tool", Waits: []inventory.Wait{tvKeyWait}}
kept := []inventory.ResourceHealth{r}
got := checkWaiting("laptop", kept, outsideFacts(nil))
if got[0].State != link.StateWaiting {
t.Fatalf("an excused wait for a provision was judged: %+v", got[0])
}
if kept[0].Waits[0].Act != "" {
t.Fatal("checking the wait changed the statement as kept")
}
waits, waiting := operatorWait("requests", got)
if !waiting {
t.Fatal("the module does not read as waiting for the operator")
}
o := needsOperatorObservation("requests", "laptop", waits, got)
if !strings.Contains(o.Summary, "secret accept media tv tv-api") {
t.Fatalf("the summary does not name the accept for the provider: %q", o.Summary)
}
if !strings.HasPrefix(o.Needs, "give the API key of tv") {
t.Fatalf("the needs do not name the act: %q", o.Needs)
}
if why, ok := conditions.PlainWords(conditions.Words{Headline: o.Headline, Explanation: o.Explanation,
Resolved: o.Resolved, Needs: o.Needs, Actions: o.Actions}, "laptop"); !ok {
t.Fatalf("the condition is not in plain words: %s", why)
}
// A provider on several machines, or none: the line names its machine as a place to fill.
if line := (outsideProvider{Module: "tv", Machines: []string{"a", "b"}}).acceptLine("tv-api"); line !=
"secret accept <its machine> tv tv-api" {
t.Fatalf("a provider on two machines reads %q", line)
}
}
// §3: the consumers one accept reaches are those whose resolution answers the provision from that provider.
func TestTheConsumersOfAProviderAreThoseItAnswers(t *testing.T) {
plan := catalogue.Resolution{Needs: []catalogue.Needed{
{Name: "tv-api", From: "media", Module: "tv", For: "requests"},
{Name: "tv-api", From: "media", Module: "tv", For: "indexer", Local: "main"},
{Name: "tv-api", From: "other", Module: "tv", For: "elsewhere"},
{Name: "tv-api", From: "media", Module: "another-tv", For: "not-this-module"},
{Name: "db", From: "media", Module: "tv", For: "requests"},
{Name: "tv-api", ByRecord: true, For: "by-record"},
}}
got := bindsFrom(plan, "laptop", "media", "tv", "tv-api")
if len(got) != 2 || got[0].ConsumerModule != "indexer" || got[0].Local != "main" || got[1].ConsumerModule != "requests" {
t.Fatalf("the consumers from tv on media are %+v", got)
}
said := acceptedSaid("media", "tv", "tv-api", inventory.ProvisionAccepted{
Sealed: []inventory.PairEnd{{Consumer: "laptop", ConsumerModule: "requests"}},
Unsealable: []inventory.PairEnd{{Consumer: "new", ConsumerModule: "indexer"}}}, []string{"down: refused"})
for _, want := range []string{"sealed to requests on laptop", "no sealing key", "down: refused"} {
if !strings.Contains(said, want) {
t.Errorf("what the accept says lacks %q:\n%s", want, said)
}
}
}
+113 -9
View File
@@ -182,12 +182,21 @@ func planFor(ctx context.Context, open *stores, nodeName string) (catalogue.Reso
} }
var secret inventory.Secret var secret inventory.Secret
var err error var err error
if n.SharedOwn != "" { if outsideModule, outside := issuedOutsideBy(shelf, n); outside {
// Only a person can give it (novox/hq ADR 0337, issue 164): read as given, never made.
secret, err = inv.GivenPairSecret(ctx, n.Name, nodeName, n.For, n.From, outsideModule, n.Local)
} else if n.SharedOwn != "" {
// The provider's one credential, sealed to this consumer too (novox/hq ADR 0158). // The provider's one credential, sealed to this consumer too (novox/hq ADR 0158).
secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn) secret, err = inv.SharedSecretFor(ctx, n.Name, nodeName, n.For, n.From, providerModuleOf(resolved, open, ctx, n), n.Local, n.SharedOwn)
} else { } else {
secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local) secret, err = inv.SecretFor(ctx, n.Name, nodeName, n.For, n.From, n.Local)
} }
if inventory.IsNotGiven(err) {
// **Not given, and the rest of the consumer runs** (ADR 0337 §4): no credential file, so the module can
// tell it was not given and says it waits for it; the machine is not refused for what only a person can
// give, and nothing the mesh made stands in for it.
continue
}
if err != nil { if err != nil {
// Said rather than skipped. A machine that resolves cleanly and receives no // Said rather than skipped. A machine that resolves cleanly and receives no
// credential is one that will fail to authenticate at some later, less obvious // credential is one that will fail to authenticate at some later, less obvious
@@ -280,9 +289,14 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
continue continue
} }
caps, _ := inv.ProfileOf(ctx, n.Name) caps, _ := inv.ProfileOf(ctx, n.Name)
// Its public domain too (novox/hq ADR 0346): a provider elsewhere serves its own composed names,
// and composes them as its own plan does, under its own domain.
// Read as its profile is: a machine whose domain cannot be read (removed since the list was read)
// composes no public name, which leaves the key out and says so, and fails no other machine's plan.
domain, _ := inv.PublicDomainOf(ctx, n.Name)
others = append(others, candidate{ others = append(others, candidate{
catalogue.Node{Name: n.Name, Site: siteOf[n.Name], Capabilities: caps, catalogue.Node{Name: n.Name, Site: siteOf[n.Name], Capabilities: caps,
At: onNetwork[n.Name]}, theirs}) At: onNetwork[n.Name], PublicDomain: domain}, theirs})
} }
offered := map[string][]catalogue.Provider{} offered := map[string][]catalogue.Provider{}
@@ -301,12 +315,18 @@ func theRestOfTheMesh(ctx context.Context, inv *inventory.Inventory,
// What that module says a consumer needs to know, with that node's settings on // What that module says a consumer needs to know, with that node's settings on
// it: a port somebody moved on the provider is a port its consumers must be told // it: a port somebody moved on the provider is a port its consumers must be told
// about, and the two coming from different places is how they come to disagree. // about, and the two coming from different places is how they come to disagree.
serves, err := servedOnNode(ctx, inv, o.node.Name, m, name) serves, layers, err := servedOnNode(ctx, inv, o.node.Name, m, name)
if err != nil {
return catalogue.World{}, err
}
// And its own composed names, as that node composes them (novox/hq ADR 0346): a name it
// does not compose leaves the key out, said under each consumer's need.
serves, leftOut, err := got.ServedOwnNames(m, name, serves, catalogue.WithDefaults(m, layers))
if err != nil { if err != nil {
return catalogue.World{}, err return catalogue.World{}, err
} }
offered[name] = append(offered[name], catalogue.Provider{ offered[name] = append(offered[name], catalogue.Provider{
Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module}) Node: o.node.Name, At: o.node.At, Serves: serves, Module: m.Module, LeftOut: leftOut})
} }
} }
} }
@@ -442,6 +462,12 @@ func declarationWith(ctx context.Context, open *stores, node string,
if err != nil { if err != nil {
return sendable{}, err return sendable{}, err
} }
// Which ports the filter admits from the local network, and from which networks, for plan to say
// (novox/hq ADR 0340 §8). The same rules the composition rendered, never sent.
rules, err := plan.Rules(with)
if err != nil {
return sendable{}, err
}
// And what was taken on it, said in every declaration it is sent from this one place. // And what was taken on it, said in every declaration it is sent from this one place.
adoption, err := adoptionOf(ctx, open.inventory, record, plan, composed) adoption, err := adoptionOf(ctx, open.inventory, record, plan, composed)
if err != nil { if err != nil {
@@ -451,7 +477,8 @@ func declarationWith(ctx context.Context, open *stores, node string,
Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers, Received: composed.Received, Mesh: with.Mesh, BusUsers: with.BusUsers,
LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld, LeftOut: sortedKeysOf(composed.LeftOut), leftOutWhy: composed.LeftOut, withheld: with.Withheld,
unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced, unbound: with.Unbound, foreseen: composed.Foreseen, unplaced: composed.Unplaced,
modules: namedModules(plan, composed.LeftOut)} modules: namedModules(plan, composed.LeftOut),
localNetwork: catalogue.LocalNetworkPorts(rules, with.LocalNetworks)}
// And which build of each module it carries, for the send to record (novox/hq issue 259, ADR // And which build of each module it carries, for the send to record (novox/hq issue 259, ADR
// 0221). Read only on the send path: a question about what would be sent records nothing. // 0221). Read only on the send path: a question about what would be sent records nothing.
if choosing == Allocating { if choosing == Allocating {
@@ -960,6 +987,12 @@ func renderingFor(ctx context.Context, open *stores, node string,
if err != nil { if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err return catalogue.Rendering{}, inventory.Node{}, err
} }
// And which of the networks it is attached to count as its local network (novox/hq ADR 0340):
// private ranges only, on a link facing outside it. Reported by the machine, never set.
local, err := localNetworksOf(ctx, inv, node)
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
// Where this machine reaches each mesh seat's holder, for ${seat:<seat>:reach} (novox/hq ADR // Where this machine reaches each mesh seat's holder, for ${seat:<seat>:reach} (novox/hq ADR
// 0222): the artifact store as this network reaches it, which the container runtime is told to // 0222): the artifact store as this network reaches it, which the container runtime is told to
// trust (ADR 0082). The same address composed into every reference the mesh built. // trust (ADR 0082). The same address composed into every reference the mesh built.
@@ -975,15 +1008,21 @@ func renderingFor(ctx context.Context, open *stores, node string,
if err != nil { if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err return catalogue.Rendering{}, inventory.Node{}, err
} }
readsBusy, err := engineReadsBusy(ctx, inv, node)
if err != nil {
return catalogue.Rendering{}, inventory.Node{}, err
}
return catalogue.Rendering{ return catalogue.Rendering{
ReadsHealth: readsHealth, ReadsHealth: readsHealth,
JudgesRoot: judgesRoot, JudgesRoot: judgesRoot,
ReadsBusy: readsBusy,
BusMembership: memberships[node], BusMembership: memberships[node],
Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports, Settings: settings, Generators: gens, Grants: grants, Needed: needed, Foreseen: foreseen, Ports: ports,
Certificate: certificate, Authority: authority, Mesh: private, Names: names, Certificate: certificate, Authority: authority, Mesh: private, Names: names,
Machines: machines, Zones: zones, Holders: replicas, Machines: machines, Zones: zones, Holders: replicas,
Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation, Suffix: overlay.Suffix(), MeshRange: meshRange, TunnelInterface: overlay.Interface, Accounts: accounts, Foundation: foundation,
Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks, Kept: kept, Adopted: record.Adopted, OutwardLinks: outwardLinks,
LocalNetworks: catalogue.CountedLocalNetworks(local),
Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built, Given: given, Taken: taken, Seats: seats, ArtifactStore: artifactStore, SeatReach: reach, Built: built,
BusUsers: busUsers, Withheld: withheld, Unbound: unbound, BusUsers: busUsers, Withheld: withheld, Unbound: unbound,
}, record, nil }, record, nil
@@ -1010,6 +1049,35 @@ func engineJudgesRoot(ctx context.Context, inv *inventory.Inventory, node string
return had && stated.Contract >= link.RootContract, nil return had && stated.Contract >= link.RootContract, nil
} }
// engineReadsBusy says whether a machine's node-engine reads a container's `busy` (novox/hq ADR 0335), by its
// own newest statement, for the same reason as engineReadsHealth: an older engine parses strictly.
func engineReadsBusy(ctx context.Context, inv *inventory.Inventory, node string) (bool, error) {
stated, had, err := inv.HealthOf(ctx, node)
if err != nil {
return false, err
}
return had && stated.Contract >= link.BusyContract, nil
}
// busyNotSent is, for a node-engine that does not read a container's busy, each container whose module declares
// one: not sent it, so replaced at once while it may hold work (novox/hq ADR 0335). Nothing for one that reads it.
func busyNotSent(modules []catalogue.Manifest, reads bool) []string {
if reads {
return nil
}
var out []string
for _, m := range modules {
for _, r := range m.Resources {
if _, has := r[catalogue.BusyField]; has {
out = append(out, fmt.Sprintf("%s.%v declares busy, and this node-engine does not read it yet (its "+
"statement's contract is below %d): not sent, so the container is replaced at once, busy or not "+
"(novox/hq ADR 0335)", m.Module, r["id"], link.BusyContract))
}
}
}
return out
}
// zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR // zonesInTheMesh is every zone a module in the mesh declares, where the mesh placed it (novox/hq ADR
// 0199): the zone settled from that node's settings, the node's private address, the port the // 0199): the zone settled from that node's settings, the node's private address, the port the
// answering listen is published on there. // answering listen is published on there.
@@ -1403,10 +1471,29 @@ func planCommand(ctx context.Context, args []string) error {
// in this output would have told anybody that. // in this output would have told anybody that.
for _, n := range plan.Needs { for _, n := range plan.Needs {
fmt.Printf(" needs %s from %s, for %s\n", n.Name, n.From, n.For) fmt.Printf(" needs %s from %s, for %s\n", n.Name, n.From, n.For)
// And a served key the provider left out, because its machine composes no name it asks for
// (novox/hq ADR 0346 §3).
for _, line := range n.LeftOut {
fmt.Printf(" %s\n", line)
}
}
// What the providers on this machine leave out of what they serve, and why: said where the remedy
// is (novox/hq ADR 0346 §3).
for _, line := range plan.ServedLeftOut(settings) {
fmt.Printf("%s: %s\n", args[0], line)
} }
for _, line := range unboundLines(plan) { for _, line := range unboundLines(plan) {
fmt.Println(line) fmt.Println(line)
} }
// And a container's busy that this machine's node-engine is too old to be sent (novox/hq ADR 0335): its
// container is replaced at once, as before. A statement that cannot be read is said as such.
if reads, err := engineReadsBusy(ctx, open.inventory, args[0]); err != nil {
fmt.Printf("%s: whether its node-engine reads a container's busy could not be read: %v\n", args[0], err)
} else {
for _, line := range busyNotSent(plan.Modules, reads) {
fmt.Printf("%s: %s\n", args[0], line)
}
}
declared, err := declarationFor(ctx, open, args[0], plan, settings) declared, err := declarationFor(ctx, open, args[0], plan, settings)
if err != nil { if err != nil {
return err return err
@@ -1417,6 +1504,11 @@ func planCommand(ctx context.Context, args []string) error {
fmt.Printf(" %-20s settings from %s\n", module, layer.From) fmt.Printf(" %-20s settings from %s\n", module, layer.From)
} }
} }
// What each port reached from the local network resolved to on this machine (novox/hq ADR 0340 §8),
// or that it resolved to none.
for _, p := range declared.localNetwork {
fmt.Println(" " + p.Said())
}
fmt.Printf("\n%d resource(s)\n", len(resources)) fmt.Printf("\n%d resource(s)\n", len(resources))
if *show { if *show {
@@ -1538,19 +1630,20 @@ func managerPublicKeyFor(
// artifact store's trust and the references composed through it (04-ISSUES/102). Unreadable // artifact store's trust and the references composed through it (04-ISSUES/102). Unreadable
// given ports are that node's refusal to report, not this reader's. // given ports are that node's refusal to report, not this reader's.
func servedOnNode(ctx context.Context, inv *inventory.Inventory, node string, func servedOnNode(ctx context.Context, inv *inventory.Inventory, node string,
m catalogue.Manifest, provision string) (map[string]any, error) { m catalogue.Manifest, provision string) (map[string]any, []catalogue.Layer, error) {
ports, layers, err := portsGivenOn(ctx, inv, node, m) ports, layers, err := portsGivenOn(ctx, inv, node, m)
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
serves := catalogue.ServedOn(m, provision, ports) serves := catalogue.ServedOn(m, provision, ports)
if len(serves) > 0 { if len(serves) > 0 {
serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers)) serves, err = catalogue.Settle(serves, catalogue.WithDefaults(m, layers))
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
} }
return serves, nil // The layers too, read once, for what is filled after (novox/hq ADR 0346).
return serves, layers, nil
} }
// portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the // portsGivenOn is where a node puts a module's ports — assigned, then given over them — and the
@@ -1742,6 +1835,17 @@ func busUserList(ctx context.Context, inv *inventory.Inventory,
// providerModuleOf is which module answers a need on the providing node: the one in this node's // providerModuleOf is which module answers a need on the providing node: the one in this node's
// own set when the provider is here, else the one the catalogue says offers it. // own set when the provider is here, else the one the catalogue says offers it.
// issuedOutsideBy is whether a need's provision is offered by its provider with a credential issued outside the
// mesh (novox/hq ADR 0337), and that provider module: the one answering it when known, else any that offers it so.
func issuedOutsideBy(shelf map[string]catalogue.Manifest, n catalogue.Needed) (string, bool) {
if n.Module != "" {
if m, known := shelf[n.Module]; known {
return n.Module, m.IssuedOutsideOf(n.Name)
}
}
return catalogue.OffersIssuedOutside(shelf, n.Name)
}
func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string { func providerModuleOf(resolved catalogue.Resolution, open *stores, ctx context.Context, n catalogue.Needed) string {
for _, m := range resolved.Modules { for _, m := range resolved.Modules {
if _, shared := m.SharedCredentialOf(n.Name); shared { if _, shared := m.SharedCredentialOf(n.Name); shared {
+246 -64
View File
@@ -18,8 +18,9 @@ package main
// it beside the layer (`settings` says it back), and records the warrant in the hand-act log on the bus, where a // it beside the layer (`settings` says it back), and records the warrant in the hand-act log on the bus, where a
// person's decisions are read; the router edits the ask on every channel to its outcome. No seat event of its // person's decisions are read; the router edits the ask on every channel to its outcome. No seat event of its
// own: nothing consumes one, and the installer's first user list in the node-engine's repository names every // own: nothing consumes one, and the installer's first user list in the node-engine's repository names every
// controller event, so one would cost a node-engine change for a fact without a reader. The push afterwards is a // controller event, so one would cost a node-engine change for a fact without a reader. No push is left for
// separate act, as it is for a layer set at the terminal. // afterwards: the warrant that sets the layer opens the walk that sends it (novox/hq ADR 0344, settings_walk.go),
// so the operator's one Approve is the act that makes the change take effect.
// //
// **What the operator reads** is the module, the machine, each key with its exact new value and, for a changed // **What the operator reads** is the module, the machine, each key with its exact new value and, for a changed
// key, the value it replaces. A value that may not leave the mesh (an address, a path, a secret's shape: the // key, the value it replaces. A value that may not leave the mesh (an address, a path, a secret's shape: the
@@ -27,6 +28,13 @@ package main
// ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the // ask says so, and the whole is read with `settings proposals <id>` — whose fingerprint must be the one on the
// phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left // phone. Fail closed: a proposal nothing can carry to the operator is refused at once, in words, and never left
// waiting for an answer that cannot come. // waiting for an answer that cannot come.
//
// **On a channel that proves who answers, the values are shown WHOLE** (novox/hq issue 383, the operator's
// decision of 2026-10-10): a mount point, a share name or a private address is the thing being approved and must
// be readable, so the ask carries them whole beside the explanation (asks.Ask.Whole), the router shows that only
// on a kind that verifies its sender, and only a value shaped like a secret is withheld there. The message is the
// headline, one line per key, who proposed it and when; the fingerprint and how to read the proposal whole are
// the Details answer's (asks.Ask.Details). The masking stays for conditions and for channels that prove nothing.
import ( import (
"context" "context"
@@ -47,6 +55,7 @@ import (
"git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/asks"
"github.com/novox/mesh-controller/internal/conditions" "github.com/novox/mesh-controller/internal/conditions"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/link"
"github.com/novox/mesh-controller/internal/outward" "github.com/novox/mesh-controller/internal/outward"
) )
@@ -154,47 +163,48 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
if len([]rune(headline)) > asks.HeadlineLength { if len([]rune(headline)) > asks.HeadlineLength {
headline = verb + " settings?" headline = verb + " settings?"
} }
shown, whole := p.change(machines) // The message reads at a glance (issues 383 and 639): a summary of what changes where, then at most three
var b strings.Builder // points, a key each; every key with its whole value, who proposed it, the fingerprint and how to read it at the
if p.Clear { // terminal are its Details, which the router notes after the answers.
fmt.Fprintf(&b, "Clear the settings of %s on %s, back to what the module says?\n\n", p.Module, p.where()) shown, shownAll, shownWhole := p.change(machines, false)
} else { whole, wholeAll, _ := p.change(machines, true)
fmt.Fprintf(&b, "Set the settings of %s on %s to these values?\n\n", p.Module, p.where()) summary := p.summary()
compose := func(points []string) string {
return strings.Join(append([]string{summary}, points...), "\n")
} }
fmt.Fprintf(&b, "Proposed by %s, at %s. ", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan")) // The Details: every setting it sets, one line each, uncut — whole where the sender is proven (WholeDetails),
switch { // masked elsewhere — then who proposed it, the fingerprint and how to read it at the terminal.
case p.Clear && p.HadLayer: details := func(lines []string) string {
b.WriteString("The layer it removes:\n\n") var d strings.Builder
case p.Clear: d.WriteString(strings.Join(lines, "\n") + "\n")
b.WriteString("There is no layer to remove; approving changes nothing.") d.WriteString(p.onApprove() + "\n")
case !p.HadLayer: fmt.Fprintf(&d, "Proposed by %s at %s. Fingerprint %s.\n", sayable(p.From, machines), p.At.Local().Format("15:04 on 2 Jan"),
b.WriteString("There is no layer yet; this is the whole of it:\n\n") fingerprint(p.Digest))
default: if !shownWhole {
b.WriteString("The layer is replaced whole; what changes against it:\n\n") d.WriteString("Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.\n")
} }
b.WriteString(shown) fmt.Fprintf(&d, "Read it whole, with this fingerprint: settings proposals %s at the controller's terminal, or the "+
fmt.Fprintf(&b, "\n\nFingerprint %s.", fingerprint(p.Digest)) "controller's settings verb with proposal %s through the mesh MCP server.", id, id)
if !whole { return d.String()
b.WriteString(" Parts shown as ‹address›, ‹path› or ‹withheld› may not leave the mesh: read it whole, with " +
"this fingerprint, through the mesh MCP server (mesh-controller.settings, proposal " + id + ") or with " +
"mesh-cli settings proposals " + id + ".")
} }
if p.Clear { q := asks.Ask{ID: id, Headline: headline, Explanation: compose(shown), Who: asks.Operator,
b.WriteString(" Approved, the layer is removed at once and the machine takes it at its next push.")
} else {
b.WriteString(" Approved, the layer is set at once and the machine takes it at its next push.")
}
q := asks.Ask{ID: id, Headline: headline, Explanation: b.String(), Who: asks.Operator,
Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes", Expires: p.At.Add(askApproveFor), OnExpiry: "the proposal is discarded; nothing changes",
About: p.about()} About: p.about(), Details: details(shownAll)}
// Only where a value was masked: an ask whose values all pass the content rule shows the same everywhere.
if w := compose(whole); w != q.Explanation {
q.Whole = w
}
if w := details(wholeAll); w != q.Details {
q.WholeDetails = w
}
options := map[string]int{} options := map[string]int{}
for i, act := range p.actions(id) { for i, act := range p.actions(id) {
binds, _ := asks.ActDigest(boundAct(act)) binds, _ := asks.ActDigest(boundAct(act))
oid := optionID(act.Label) oid := optionID(act.Label)
options[oid] = i options[oid] = i
does := "the settings are set; nothing is pushed yet" does := "the settings are set and sent to " + p.reaches() + " at once, each machine judged"
if p.Clear { if p.Clear {
does = "the layer is removed; nothing is pushed yet" does = "the layer is removed and that is sent to " + p.reaches() + " at once, each machine judged"
} }
if act.Arguments["answer"] == answerDecline { if act.Arguments["answer"] == answerDecline {
does = "nothing changes; the proposal is discarded" does = "nothing changes; the proposal is discarded"
@@ -205,55 +215,141 @@ func (p settingsProposal) ask(id string, machines []string) (asks.Ask, map[strin
return q, options return q, options
} }
// shownMost is the most of a change the phone is shown, in bytes; the rest is read whole with `settings proposals`. // summary is the proposal's first line, what changes where: how many settings, how many stay, and that Approve sends
const shownMost = 1400 // the layer at once by a walk, one machine at a time (novox/hq ADR 0344); the rest of what Approve does
// (onApprove) is the Details'.
func (p settingsProposal) summary() string {
if p.Clear {
if !p.HadLayer {
return "There is no layer to remove; approving changes nothing."
}
return fmt.Sprintf("Removes the %d setting(s) of %s set on %s; approved, the layer is removed and that is sent at once "+
"by a walk to %s, one machine at a time, each judged.", len(leaves(p.Before, "")), p.Module, p.where(), p.reaches())
}
added, changed, removed := settingsChange(p.Before, p.Values)
n := len(added) + len(changed) + len(removed)
unchanged := len(leaves(p.Values, "")) - len(added) - len(changed)
if n == 0 {
return fmt.Sprintf("Nothing changes: the %d setting(s) of %s on %s are as they stand.", unchanged, p.Module, p.where())
}
stay := ""
if unchanged > 0 {
stay = fmt.Sprintf(", and %d stay as they are", unchanged)
}
return fmt.Sprintf("Changes %d setting(s) of %s on %s%s; approved, the layer is set and sent at once by a walk to %s, "+
"one machine at a time, each judged.", n, p.Module, p.where(), stay, p.reaches())
}
// change is what changes, line by line, each value shown under the content rule, and whether every value was // change is what changes: as at most asks.MostPoints points for the glance, as every line whole for the note, and
// shown whole: "+ key: value" added, "~ key: value (was: old)" changed, "- key (was: old)" removed, and the // whether every value was shown exactly: "+ key: value"
// count of keys unchanged. // added, "~ key: value (was: old)" changed, "- key (was: old)" removed. Each value is shown under the content rule
func (p settingsProposal) change(machines []string) (string, bool) { // (sayableValue), or — exact, for a channel that proves who answers — as it is, withheld only when shaped like a
// secret (wholeValue). More changes than points: the first two, and the keys of the rest; a point longer than a
// phone shows at a glance is cut and says so. The note carries every line uncut (the review of #304: Approve sets
// exactly what the operator can read).
func (p settingsProposal) change(machines []string, exact bool) ([]string, []string, bool) {
whole := true whole := true
say := func(v any) string { say := func(v any) string {
s, w := sayableValue(v, machines) s, w := sayableValue(v, machines)
if exact {
s, w = wholeValue(v, machines)
}
whole = whole && w whole = whole && w
return s return s
} }
var lines []string type line struct{ key, text string }
var lines []line
if p.Clear { if p.Clear {
was := leaves(p.Before, "") was := leaves(p.Before, "")
for _, k := range layerKeys(was) { for _, k := range layerKeys(was) {
lines = append(lines, fmt.Sprintf("- %s: %s", k, say(was[k]))) lines = append(lines, line{k, fmt.Sprintf("- %s: %s", k, say(was[k]))})
} }
} else { } else {
added, changed, removed := settingsChange(p.Before, p.Values) added, changed, removed := settingsChange(p.Before, p.Values)
was, now := leaves(p.Before, ""), leaves(p.Values, "") was, now := leaves(p.Before, ""), leaves(p.Values, "")
for _, k := range added { for _, k := range added {
lines = append(lines, fmt.Sprintf("+ %s: %s", k, say(now[k]))) lines = append(lines, line{k, fmt.Sprintf("+ %s: %s", k, say(now[k]))})
} }
for _, k := range changed { for _, k := range changed {
lines = append(lines, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k]))) lines = append(lines, line{k, fmt.Sprintf("~ %s: %s (was: %s)", k, say(now[k]), say(was[k]))})
} }
for _, k := range removed { for _, k := range removed {
lines = append(lines, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))) lines = append(lines, line{k, fmt.Sprintf("- %s (was: %s)", k, say(was[k]))})
}
unchanged := len(now) - len(added) - len(changed)
switch {
case len(lines) == 0 && unchanged > 0:
lines = append(lines, fmt.Sprintf("= nothing changes: the %d key(s) are as they stand", unchanged))
case unchanged > 0:
lines = append(lines, fmt.Sprintf("= %d key(s) unchanged", unchanged))
} }
} }
out := strings.Join(lines, "\n") // **Each line is judged as the router will judge it** (the review of #304, ADR 0347 §1 and §3): a line the
if len(out) > shownMost { // content rule (masked) or the secret rule (whole) would refuse — a value given to a secret's name, say
cut := shownMost // "+ password: hunter2" — is said with its key still named and its value withheld, never left for the
for cut > 0 && out[cut] != '\n' { // router to withhold whole and unnamed. A line refused even so is the propose's refusal (judgeLines).
cut-- for i, l := range lines {
if lineLeaves(l.text, exact, machines) {
continue
} }
out = out[:cut] + fmt.Sprintf("\n… NOT SHOWN IN FULL here: %d more bytes", len(strings.Join(lines, "\n"))-cut) lines[i].text = l.text[:2] + l.key + " (value withheld)"
whole = false whole = false
} }
return out, whole all := make([]string, len(lines))
for i, l := range lines {
all[i] = l.text
}
// What the note holds of a value: whole where the glance is the whole one (shown only where the sender is
// proven, with the whole details beside it), under the content rule elsewhere — so the marker says no more than
// the note it points to (the review of ADR 0347).
inNote := "in the note"
if exact {
inNote = "whole in the note"
}
var out []string
for i, l := range lines {
if len(lines) > asks.MostPoints && i == asks.MostPoints-1 {
var rest []string
for _, r := range lines[i:] {
rest = append(rest, r.key)
}
out = append(out, fmt.Sprintf("and %d more, each %s: %s", len(rest), inNote, strings.Join(rest, ", ")))
break
}
out = append(out, l.text)
}
// A point too long for a glance is cut, and says so: its value is the note's.
cut := "… (cut: " + inNote + ")"
for i, l := range out {
if r := []rune(l); len(r) > asks.PointLength {
out[i] = string(r[:asks.PointLength-len([]rune(cut))]) + cut
}
}
return out, all, whole
}
// lineLeaves says whether a line may leave the mesh as the router judges it: under the content rule for the masked
// form, the secret rule alone for the whole one.
func lineLeaves(line string, exact bool, machines []string) bool {
if exact {
_, ok := outward.Secret(line, machines...)
return ok
}
_, ok := outward.Check(line, machines...)
return ok
}
// judgeLines is the first line of an ask's details the router would refuse, or withhold, and why: masked under
// the content rule line by line, whole under the secret rule. Empty when every line may leave.
func judgeLines(details string, exact bool, machines []string) (string, string) {
for _, line := range strings.Split(details, "\n") {
if strings.TrimSpace(line) == "" {
continue
}
if exact {
if refusal, ok := outward.Secret(line, machines...); !ok {
return line, refusal.String()
}
continue
}
if refusal, ok := outward.Check(line, machines...); !ok {
return line, refusal.String()
}
}
return "", ""
} }
func layerKeys(m map[string]any) []string { func layerKeys(m map[string]any) []string {
@@ -300,6 +396,27 @@ func sayableValue(v any, machines []string) (string, bool) {
return out, out == text return out, out == text
} }
// wholeValue is a value as a channel that proves who answers is shown it (asks.Ask.Whole), and whether it was
// shown whole: as it is, unless it is shaped like a secret (outward.Secret), which is withheld whole — never in
// part, so no half of a key reaches the phone.
func wholeValue(v any, machines []string) (string, bool) {
text, ok := v.(string)
if !ok {
raw, err := json.Marshal(v)
if err != nil {
return markWithheld, false
}
text = string(raw)
}
if text == "" {
return `""`, true
}
if _, ok := outward.Secret(text, machines...); !ok {
return markWithheld, false
}
return text, true
}
// sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make // sayable is a text with what may not leave the mesh replaced in place by a marker; what the markers cannot make
// pass is withheld whole. // pass is withheld whole.
func sayable(text string, machines []string) string { func sayable(text string, machines []string) string {
@@ -432,6 +549,23 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error)
return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+ return refuse("the ask's words would carry %s, which may not leave the mesh, and the change could not be "+
"shown without it; %s", refusal, atTheTerminalInstead(in)) "shown without it; %s", refusal, atTheTerminalInstead(in))
} }
// The whole words are shown only where the sender is proven, and never a secret's shape: wholeValue withholds
// one, and the router would refuse the ask if one were left, so it is refused here first, in words.
if refusal, ok := outward.Secret(q.Whole, machines...); !ok {
return refuse("the change shown whole would carry %s, which may not leave the mesh on any channel; %s",
refusal, atTheTerminalInstead(in))
}
// So are the details (ADR 0347 §1, §3): every line the router would withhold from the note, or refuse the ask
// for, is refused here, in the controller's own words, naming the line's key — Approve never sets a value the
// operator was shown nothing of.
if line, why := judgeLines(q.Details, false, machines); line != "" {
return refuse("the change's line %q would carry %s, which the operator's note may not show; %s",
keyOfLine(line), why, atTheTerminalInstead(in))
}
if line, why := judgeLines(q.WholeDetails, true, machines); line != "" {
return refuse("the change's line %q shown whole would carry %s, which may not leave the mesh on any channel; %s",
keyOfLine(line), why, atTheTerminalInstead(in))
}
// Fail closed, before anything is kept: no router, no grant, no channel means no ask. // Fail closed, before anything is kept: no router, no grant, no channel means no ask.
if pr.routerHere != nil { if pr.routerHere != nil {
here, err := pr.routerHere(ctx) here, err := pr.routerHere(ctx)
@@ -519,15 +653,29 @@ func (pr proposer) propose(ctx context.Context, in proposeInput) (string, error)
fingerprint(p.Digest)) fingerprint(p.Digest))
fmt.Fprintf(&b, " until %s nothing changes: the layer is set only on Approve, and discarded on Decline or at expiry\n", fmt.Fprintf(&b, " until %s nothing changes: the layer is set only on Approve, and discarded on Decline or at expiry\n",
q.Expires.Local().Format("2006-01-02 15:04")) q.Expires.Local().Format("2006-01-02 15:04"))
fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, `push %s` sends it", id, pushWord(p.Node)) fmt.Fprintf(&b, " `settings proposals %s` shows it whole; once approved, a walk sends it to %s at once, one machine at "+
"a time, each judged, and its end is told as every walk's is (novox/hq ADR 0344)", id, p.reaches())
return b.String(), nil return b.String(), nil
} }
func pushWord(node string) string { // reaches is the machines an approved layer is sent to, in words: the one it names, or every machine running the
if node == "" { // module.
return "--behind" func (p settingsProposal) reaches() string {
if p.Node == "" {
return "every machine running " + p.Module
} }
return node return p.Node
}
// onApprove is what the ask says Approve does (novox/hq ADR 0344 §4): the one Approve sets the layer and sends it.
func (p settingsProposal) onApprove() string {
what := "the layer is set"
if p.Clear {
what = "the layer is removed"
}
return fmt.Sprintf("Approved, %s and sent at once to %s, one machine at a time, each judged, every build there "+
"kept; its end is told as every walk's is. A module that reads its settings only when it starts takes them at "+
"its next start.", what, p.reaches())
} }
func newProposalID() string { func newProposalID() string {
@@ -788,6 +936,40 @@ func setLayerIn(open *stores) setOnWarrant {
changed = "nothing changed" changed = "nothing changed"
} }
} }
return changed + " (ask " + askID + ")", nil // **And sends it** (novox/hq ADR 0344): the layer the operator approved is walked to the machines it reaches
// at once. A walk that cannot be opened is said as the act's failure — the layer is set and nothing sends it.
walked, err := openSettingsWalk(ctx, open, inventory.PlanSettings{Module: p.Module, Node: p.Node, Ask: askID,
Change: changed})
if err != nil {
return "", fmt.Errorf("%s (ask %s) is set, and the walk that sends it could not be opened: %v — `push %s` sends it",
changed, askID, err, pushTo(p.Node))
}
return changed + " (ask " + askID + "); " + walked, nil
} }
} }
// pushTo is the push that sends a layer by hand when its walk could not: the machine, or every one behind.
func pushTo(node string) string {
if node == "" {
return "--behind"
}
return node
}
// keyOfLine is what a refusal names of a line: the key of a change's line ("+ key: …"), or the line's first words.
func keyOfLine(line string) string {
if len(line) > 2 && strings.ContainsRune("+~-=", rune(line[0])) && line[1] == ' ' {
rest := line[2:]
if i := strings.IndexAny(rest, ": "); i > 0 {
rest = rest[:i]
}
if _, ok := outward.Secret(rest); !ok {
return "whose key is shaped like a secret"
}
return rest
}
if r := []rune(line); len(r) > 40 {
return string(r[:40]) + "…"
}
return line
}
+246 -13
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"slices" "slices"
@@ -116,24 +117,43 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
if q.Options[0].Binds == q.Options[1].Binds { if q.Options[0].Binds == q.Options[1].Binds {
t.Error("Approve and Decline bind the same act") t.Error("Approve and Decline bind the same act")
} }
for _, line := range []string{ // The message reads at a glance (issues 383 and 639): a summary, then one point per key — and nothing else:
"Set the settings of mounts on shanks to these values?", // who proposed it, the fingerprint and the how-to are the Details, which the router notes after the answers.
"Proposed by g14/claude-code, through the mesh-controller seat, at " + r.now.Local().Format("15:04 on 2 Jan") + ".", summary := "Changes 3 setting(s) of mounts on shanks; approved, the layer is set and sent at once by a walk to shanks, one machine at a time, each judged."
"+ sources: recalbox=‹address›@‹path›:ro", if q.Explanation != summary+"\n+ sources: recalbox=‹address›@‹path›:ro\n~ shares: library=‹path› (was: none)\n- old (was: x)" {
"~ shares: library=‹path› (was: none)", t.Errorf("the explanation:\n%s", q.Explanation)
"- old (was: x)",
"Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
"read it whole, with this fingerprint",
} {
if !strings.Contains(q.Explanation, line) {
t.Errorf("the explanation lacks %q:\n%s", line, q.Explanation)
} }
if err := asks.Glance(q.Explanation); err != nil {
t.Error(err)
} }
for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} { for _, leak := range []string{"nas.lan", "/mnt/recalbox", "/mnt/library", "smb://"} {
if strings.Contains(q.Explanation, leak) { if strings.Contains(q.Explanation, leak) {
t.Errorf("the explanation carries %q, which may not leave the mesh", leak) t.Errorf("the explanation carries %q, which may not leave the mesh", leak)
} }
} }
// Where the sender is proven, the values whole: the mount point and the share are what is approved.
if q.Whole != summary+"\n+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox:ro\n~ shares: library=/mnt/library (was: none)\n- old (was: x)" ||
asks.Glance(q.Whole) != nil {
t.Errorf("the whole words:\n%s", q.Whole)
}
for _, line := range []string{
"Proposed by g14/claude-code, through the mesh-controller seat at " + r.now.Local().Format("15:04 on 2 Jan") +
". Fingerprint " + fingerprint(layerDigest(mountsSources)) + ".",
"Where who answers is not proven, its values are shown as ‹address›, ‹path› or ‹withheld›.",
"Read it whole, with this fingerprint: settings proposals " + kept(r).ID + " at the controller's terminal",
} {
if !strings.Contains(q.Details, line) {
t.Errorf("Details lack %q:\n%s", line, q.Details)
}
}
if refusal, ok := outward.Check(q.Details, "anchor", "laptop", "shanks"); !ok {
t.Errorf("the router would withhold a line of the details: %s", refusal)
}
for _, howTo := range []string{"ingerprint", "settings proposals", "settings verb", "not proven", "Proposed by"} {
if strings.Contains(q.Explanation, howTo) || strings.Contains(q.Whole, howTo) {
t.Errorf("the message carries the how-to %q", howTo)
}
}
all := []string{q.Headline, q.Explanation, q.OnExpiry} all := []string{q.Headline, q.Explanation, q.OnExpiry}
for _, o := range q.Options { for _, o := range q.Options {
all = append(all, o.Label, o.Does) all = append(all, o.Label, o.Does)
@@ -141,6 +161,9 @@ func TestAProposalAsksAtTheLevelApproveWithTheExactChange(t *testing.T) {
if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok { if refusal, ok := outward.Check(strings.Join(all, "\n"), "anchor", "laptop", "shanks"); !ok {
t.Errorf("the router would refuse the ask: %s", refusal) t.Errorf("the router would refuse the ask: %s", refusal)
} }
if refusal, ok := outward.Secret(q.Whole, "anchor", "laptop", "shanks"); !ok {
t.Errorf("the router would refuse the whole words: %s", refusal)
}
// Kept as the controller's own ask, about no condition, with the proposal whole and its digests. // Kept as the controller's own ask, about no condition, with the proposal whole and its digests.
kept := r.theProposal(t) kept := r.theProposal(t)
p := kept.Proposal p := kept.Proposal
@@ -173,11 +196,98 @@ func TestAMeshWideLayerIsProposed(t *testing.T) {
} }
q := r.askSent(t) q := r.askSent(t)
if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" || if q.Headline != "Set notes on the whole mesh?" || q.About != "settings.notes.mesh" ||
!strings.Contains(q.Explanation, "Set the settings of notes on the whole mesh to these values?") { q.Explanation != "Changes 1 setting(s) of notes on the whole mesh; approved, the layer is set and sent at once by a walk to every machine running notes, one machine at a time, each judged.\n+ x: 1" ||
q.Whole != "" {
t.Errorf("%+v", q) t.Errorf("%+v", q)
} }
} }
// kept is the one proposal the rig keeps.
func kept(r *proposerRig) asked {
for _, a := range r.store {
if a.Proposal != nil {
return a
}
}
return asked{}
}
// The switch between the masked and the whole change (issue 383): the same change, under the content rule and
// exact, differs in the masked values alone; a value shaped like a secret is withheld in both, whole, with its
// key still named; and a change no value of which is masked carries no whole words of its own.
func TestAProposalShowsItsValuesWholeOnlyWhereTheSenderIsProvenAndNeverASecret(t *testing.T) {
machines := []string{"anchor", "laptop", "shanks"}
r := newProposerRig(t)
values := map[string]any{"shares": "media=/storage/media", "sources": "recalbox=smb://nas.lan/recalbox@/mnt/recalbox", "font": "Inter 13"}
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
t.Fatal(err)
}
q := r.askSent(t)
masked, _, _ := kept(r).Proposal.change(machines, false)
whole, _, _ := kept(r).Proposal.change(machines, true)
if !strings.HasSuffix(q.Explanation, "\n"+strings.Join(masked, "\n")) || !strings.HasSuffix(q.Whole, "\n"+strings.Join(whole, "\n")) {
t.Fatalf("the ask does not carry the change masked and whole:\n%s\n--\n%s", q.Explanation, q.Whole)
}
if want := []string{"+ font: Inter 13", "+ shares: media=‹path›", "+ sources: recalbox=‹address›@‹path›"}; strings.Join(masked, "\n") != strings.Join(want, "\n") {
t.Errorf("masked: %q", masked)
}
if want := []string{"+ font: Inter 13", "+ shares: media=/storage/media", "+ sources: recalbox=smb://nas.lan/recalbox@/mnt/recalbox"}; strings.Join(whole, "\n") != strings.Join(want, "\n") {
t.Errorf("whole: %q", whole)
}
if _, ok := outward.Secret(q.Whole, "shanks"); !ok {
t.Error("the router would refuse the whole words")
}
// Mutation: the masked and the whole change differ in exactly the lines whose value was masked.
differ := 0
for i := range masked {
if masked[i] != whole[i] {
differ++
if !strings.Contains(masked[i], "‹") || strings.Contains(whole[i], "‹") {
t.Errorf("the lines differ otherwise than by the mask:\n%s\n%s", masked[i], whole[i])
}
}
}
if differ != 2 {
t.Errorf("%d lines differ, and the mask covered 2", differ)
}
// A value shaped like a secret is withheld in both, whole, with its key still named.
r1 := newProposerRig(t)
secrets := map[string]any{"github": "ghp_abcdefghijklmnopqrstuvwxyz0123456789", "cert": "-----BEGIN CERTIFICATE-----"}
if _, err := r1.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: secrets}); err != nil {
t.Fatal(err)
}
q1 := r1.askSent(t)
for _, line := range []string{"+ cert: ‹withheld›", "+ github: ‹withheld›"} {
if !strings.Contains(q1.Explanation, line) {
t.Errorf("lacks %q:\n%s", line, q1.Explanation)
}
}
for _, secret := range []string{"ghp_", "BEGIN CERTIFICATE"} {
if strings.Contains(q1.Explanation, secret) || strings.Contains(q1.Whole, secret) || strings.Contains(q1.Details, secret) {
t.Errorf("the secret %q reaches the phone", secret)
}
}
// More keys than points (issue 639): the first two, then the keys of the rest, read whole at the terminal.
r3 := newProposerRig(t)
five := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "e": "5"}
if _, err := r3.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: five}); err != nil {
t.Fatal(err)
}
if q3 := r3.askSent(t); !strings.HasSuffix(q3.Explanation, "\n+ a: 1\n+ b: 2\nand 3 more, each in the note: c, d, e") || asks.Glance(q3.Explanation) != nil ||
!strings.Contains(q3.Details, "settings proposals") {
t.Errorf("five keys:\n%s", q3.Explanation)
}
// No value masked: the message is the same everywhere, and the ask says no whole words.
r2 := newProposerRig(t)
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "dunst", node: "laptop", values: map[string]any{"font-size": 13, "width": 500}}); err != nil {
t.Fatal(err)
}
if q2 := r2.askSent(t); q2.Whole != "" || !strings.HasSuffix(q2.Explanation, "\n+ font-size: 13\n+ width: 500") ||
strings.Contains(q2.Details, "not proven") {
t.Errorf("%+v", q2)
}
}
// A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing. // A proposal expired unanswered is kept so by the reconciling, and a warrant for it afterwards sets nothing.
func TestAnExpiredProposalIsKeptExpired(t *testing.T) { func TestAnExpiredProposalIsKeptExpired(t *testing.T) {
r := newAskerRig(t) r := newAskerRig(t)
@@ -205,7 +315,8 @@ func TestAClearIsProposedAndShowsWhatItRemoves(t *testing.T) {
} }
q := r.askSent(t) q := r.askSent(t)
if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") || if q.Headline != "Clear notes on laptop?" || !strings.Contains(q.Explanation, "- places.data.owner: 1001:1001") ||
!strings.Contains(q.Explanation, "- places.data.path: ‹path›") { !strings.Contains(q.Explanation, "- places.data.path: ‹path›") || !strings.Contains(q.Whole, "- places.data.path: /srv/notes") ||
!strings.HasPrefix(q.Explanation, "Removes the 2 setting(s) of notes set on laptop; approved, the layer is removed and that is sent at once by a walk to laptop, one machine at a time") {
t.Errorf("%+v", q) t.Errorf("%+v", q)
} }
if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 { if p := r.theProposal(t).Proposal; !p.Clear || p.Digest != layerDigest(r.before) || len(r.judged) != 0 {
@@ -744,3 +855,125 @@ func captureStdout(t *testing.T, f func()) string {
_ = w.Close() _ = w.Close()
return <-done return <-done
} }
// The review of #304: Approve sets exactly what the operator can read. A proposal of five keys, one value 300
// characters long, shows two keys at a glance, says what it cut, and carries every key with its whole value in its
// Details for a channel that proves who answers (WholeDetails), and every key masked for one that does not.
func TestEveryKeyAndItsWholeValueReachTheOperator(t *testing.T) {
var long strings.Builder
long.WriteString("library=/storage")
for i := 0; long.Len() < 300; i++ {
long.WriteString(fmt.Sprintf("/album-%02d", i))
}
value := long.String()[:300]
values := map[string]any{"a": "1", "b": "2", "c": "3", "d": "4", "shares": value}
r := newProposerRig(t)
if _, err := r.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: values}); err != nil {
t.Fatal(err)
}
q := r.askSent(t)
if err := q.Check(r.now); err != nil {
t.Fatal(err)
}
// What the operator reads on a proving channel: the whole words, then the note's whole details.
visible := q.Whole + "\n" + q.WholeDetails
if q.Whole == "" {
visible = q.Explanation + "\n" + q.WholeDetails
}
for k, v := range values {
if !strings.Contains(visible, "+ "+k+": "+v.(string)) {
t.Errorf("the operator never reads %s whole:\n%s", k, visible)
}
}
// The masked glance points to a note that holds the values masked, and says no more; the whole glance, shown only
// beside the whole details, says they are whole there.
if asks.Glance(q.Explanation) != nil || !strings.HasSuffix(q.Explanation, "\nand 3 more, each in the note: c, d, shares") ||
strings.Contains(q.Explanation, "whole in the note") || !strings.HasSuffix(q.Whole, "\nand 3 more, each whole in the note: c, d, shares") {
t.Errorf("the glance:\n%s", q.Explanation)
}
for k := range values {
if !strings.Contains(q.Details, "+ "+k+": ") {
t.Errorf("the masked details lack %s:\n%s", k, q.Details)
}
}
if strings.Contains(q.Details, "/storage") {
t.Errorf("the masked details show a path:\n%s", q.Details)
}
if refusal, ok := outward.Check(q.Details, "shanks"); !ok {
t.Errorf("the router would withhold the masked details: %s", refusal)
}
if _, ok := outward.Secret(q.WholeDetails, "shanks"); !ok {
t.Error("the router would refuse the whole details")
}
// A value too long for a glance is cut there, and says so.
r2 := newProposerRig(t)
if _, err := r2.pr.propose(context.Background(), proposeInput{module: "mounts", node: "shanks", values: map[string]any{"shares": value}}); err != nil {
t.Fatal(err)
}
q2 := r2.askSent(t)
// The cut point is 160 characters, its marker inside the bound.
if last := q2.Whole[strings.LastIndex(q2.Whole, "\n")+1:]; len([]rune(last)) != asks.PointLength {
t.Errorf("the cut point is %d characters, not %d: %q", len([]rune(last)), asks.PointLength, last)
}
if !strings.HasSuffix(q2.Whole, "… (cut: whole in the note)") || !strings.HasSuffix(q2.Explanation, "‹path›") && !strings.HasSuffix(q2.Explanation, "… (cut: in the note)") || !strings.Contains(q2.WholeDetails, "+ shares: "+value) || asks.Glance(q2.Whole) != nil {
t.Errorf("the cut is not said, or the note lacks the whole:\n%s\n--\n%s", q2.Whole, q2.WholeDetails)
}
}
// The review of #304: a value given to a secret's name, past the second key, is said with its key named and its
// value withheld, in the note on every channel; the router has nothing left to withhold unnamed or refuse. A line
// the router would refuse even so is refused at propose, in the controller's words.
func TestASecretNamedKeyIsNamedAndItsValueWithheld(t *testing.T) {
r := newProposerRig(t)
values := map[string]any{"a": "1", "b": "2", "c": "3", "password": "hunter2"}
if _, err := r.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop", values: values}); err != nil {
t.Fatal(err)
}
q := r.askSent(t)
for _, form := range []string{q.Details, q.WholeDetails, q.Explanation, q.Whole} {
if strings.Contains(form, "hunter2") {
t.Errorf("the value reaches the phone:\n%s", form)
}
}
if !strings.Contains(q.Details, "+ password (value withheld)") {
t.Errorf("the key is not named with its value withheld:\n%s", q.Details)
}
if q.WholeDetails != "" && !strings.Contains(q.WholeDetails, "+ password (value withheld)") {
t.Errorf("the whole details do not name the key:\n%s", q.WholeDetails)
}
if line, why := judgeLines(q.Details, false, nil); line != "" {
t.Errorf("the router would withhold %q: %s", line, why)
}
if line, why := judgeLines(q.WholeDetails, true, nil); line != "" {
t.Errorf("the router would refuse %q: %s", line, why)
}
if !strings.HasSuffix(q.Explanation, "\nand 2 more, each in the note: c, password") {
t.Errorf("the glance:\n%s", q.Explanation)
}
// A key shaped like a secret cannot be named on any channel: refused at propose, in the controller's words, and
// the key itself not echoed.
r2 := newProposerRig(t)
secretKey := "ghp_abcdefghijklmnopqrstuvwxyz0123456789"
_, err := r2.pr.propose(context.Background(), proposeInput{module: "notes", node: "laptop",
values: map[string]any{"a": "1", "b": "2", secretKey: "3"}})
if err == nil || !strings.Contains(err.Error(), "Nothing was proposed") || strings.Contains(err.Error(), secretKey) {
t.Errorf("a secret-shaped key: %v", err)
}
if len(r2.sent) != 0 {
t.Errorf("the refused proposal was asked")
}
}
// keyOfLine names a change's key, and never one shaped like a secret.
func TestARefusalNamesTheKeyAndNeverASecret(t *testing.T) {
for line, want := range map[string]string{
"+ password (value withheld)": "password",
"~ shares: ‹path› (was: none)": "shares",
"+ ghp_abcdefghijklmnopqrstuvwxyz0123456789: 1": "whose key is shaped like a secret",
"Proposed by g14 at 15:04.": "Proposed by g14 at 15:04.",
} {
if got := keyOfLine(line); got != want {
t.Errorf("%q: %q, want %q", line, got, want)
}
}
}
+15 -2
View File
@@ -249,7 +249,13 @@ func serve(ctx context.Context) (err error) {
// (novox/hq to-be 45 §9). // (novox/hq to-be 45 §9).
checkEvents = bus checkEvents = bus
// And every walk this controller keeps in a new state, for the delivery it walks (novox/hq ADR 0239). // And every walk this controller keeps in a new state, for the delivery it walks (novox/hq ADR 0239).
inventory.PlanSaved = func(_ *inventory.Inventory, p inventory.Plan) { sayPlanMoved(ctx, bus, open.inventory, p) } inventory.PlanSaved = func(_ *inventory.Inventory, p inventory.Plan) {
sayPlanMoved(ctx, bus, open.inventory, p)
// A walk ended has taken what it reuses: its merges' check builds go (novox/hq ADR 0350 §5).
if p.WalkSaid == inventory.WalkEnded {
go forgetChecksOfWalk(context.WithoutCancel(ctx), open.inventory, p)
}
}
if err := server.Checks(following{open}); err != nil { if err := server.Checks(following{open}); err != nil {
return err return err
} }
@@ -740,7 +746,14 @@ func waitForApplied(ctx context.Context, inv *inventory.Inventory, node, digest
fmt.Printf("%s applied it\n", node) fmt.Printf("%s applied it\n", node)
return nil return nil
case inventory.OutcomeFailed: case inventory.OutcomeFailed:
return fmt.Errorf("%s applied what it was sent but %d resource(s) failed", node, len(doing.Failed)) // Each failed resource by id with the machine's own words, so the answer says what to fix
// without a second look (novox/hq issue 629).
var each []string
for _, f := range doing.Failed {
each = append(each, f.ID+": "+f.Error)
}
return fmt.Errorf("%s applied what it was sent but %d resource(s) failed: %s", node, len(doing.Failed),
strings.Join(each, "; "))
case inventory.OutcomeRefused: case inventory.OutcomeRefused:
return fmt.Errorf("%s refused what it was sent: %s", node, doing.Refused) return fmt.Errorf("%s refused what it was sent: %s", node, doing.Refused)
} }
+30 -1
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"sort" "sort"
@@ -83,6 +84,27 @@ func everyBuildKept(ctx context.Context, node string) bool {
return on != "" && on == node return on != "" && on == node
} }
type keepForSettingsKey struct{}
// keepingBuildsForSettings is a context whose sends compose every module of one machine — recorded or rolling out —
// at the build that machine was last sent, as keepingEveryBuild does, for a settings walk's send (novox/hq ADR 0344):
// the layer the operator approved, and nothing of any module's new code. A build held for a person, or waiting
// for a gate, stays where it is; the walk that is its own carries it.
func keepingBuildsForSettings(ctx context.Context, node string) context.Context {
return context.WithValue(ctx, keepForSettingsKey{}, node)
}
// errSettingsCannotKeep is a settings walk's send refused because a build of its machine cannot be kept: what the
// machine was last sent is not known, or a build it runs is gone from the records. It does not clear by itself, so
// the walk fails on it rather than trying again (ADR 0344).
var errSettingsCannotKeep = errors.New("the settings cannot be sent with every build kept")
// settingsKeptOn is whether this context is a settings walk's send to this machine.
func settingsKeptOn(ctx context.Context, node string) bool {
on, _ := ctx.Value(keepForSettingsKey{}).(string)
return on != "" && on == node
}
// recordedKept is, for a send under keepingRecorded, every recorded module the machine was last sent a // recordedKept is, for a send under keepingRecorded, every recorded module the machine was last sent a
// build of that the mesh's build is not identical to: module → the commit it keeps. Nil when the context // build of that the mesh's build is not identical to: module → the commit it keeps. Nil when the context
// keeps nothing, or when what the machine was last sent is not known (it is then held whole elsewhere — // keeps nothing, or when what the machine was last sent is not known (it is then held whole elsewhere —
@@ -92,7 +114,7 @@ func everyBuildKept(ctx context.Context, node string) bool {
// 490), and a machine whose last send is not known refuses the send: there is nothing to keep it at, and // 490), and a machine whose last send is not known refuses the send: there is nothing to keep it at, and
// composing the mesh's builds would be the very move the grants step must not make. // composing the mesh's builds would be the very move the grants step must not make.
func recordedKept(ctx context.Context, open *stores, node string) (map[string]string, error) { func recordedKept(ctx context.Context, open *stores, node string) (map[string]string, error) {
every := everyBuildKept(ctx, node) every := everyBuildKept(ctx, node) || settingsKeptOn(ctx, node)
skip, on := keptExcept(ctx) skip, on := keptExcept(ctx)
if !on && !every { if !on && !every {
return nil, nil return nil, nil
@@ -106,6 +128,9 @@ func recordedKept(ctx context.Context, open *stores, node string) (map[string]st
return nil, err return nil, err
} }
if !known { if !known {
if settingsKeptOn(ctx, node) {
return nil, fmt.Errorf("%w: what %s was last sent is not known (novox/hq ADR 0344)", errSettingsCannotKeep, node)
}
if every { if every {
return nil, fmt.Errorf("what %s was last sent is not known, so the bus's user list cannot be sent "+ return nil, fmt.Errorf("what %s was last sent is not known, so the bus's user list cannot be sent "+
"there alone with every build kept (novox/hq issue 490)", node) "there alone with every build kept (novox/hq issue 490)", node)
@@ -166,6 +191,10 @@ func keepRecorded(ctx context.Context, open *stores, node string, shelf map[stri
if err != nil { if err != nil {
return nil, err return nil, err
} }
if !found && settingsKeptOn(ctx, node) {
return nil, fmt.Errorf("%w: %s runs %s's build %s, which the build records no longer hold (novox/hq ADR 0344)",
errSettingsCannotKeep, node, m, short(kept[m]))
}
if !found && everyBuildKept(ctx, node) { if !found && everyBuildKept(ctx, node) {
return nil, fmt.Errorf("%s runs %s's build %s, which the build records no longer hold: the bus's user "+ return nil, fmt.Errorf("%s runs %s's build %s, which the build records no longer hold: the bus's user "+
"list cannot be sent there alone with it kept (novox/hq issue 490)", node, m, short(kept[m])) "list cannot be sent there alone with it kept (novox/hq issue 490)", node, m, short(kept[m]))
+10 -1
View File
@@ -616,6 +616,10 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
} }
for _, p := range plans { for _, p := range plans {
if p.Release != nil { if p.Release != nil {
if by != "" && p.Release.Settings != nil {
return nil, fmt.Errorf("%s is sending %s; what waits is released once it ends — `plans %s` says where it is",
p.ID, p.Repository, p.ID)
}
if by != "" { if by != "" {
return nil, fmt.Errorf("%s is already releasing what waits; `plans %s` says where it is", p.ID, p.ID) return nil, fmt.Errorf("%s is already releasing what waits; `plans %s` says where it is", p.ID, p.ID)
} }
@@ -641,7 +645,9 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
return nil, err return nil, err
} }
for _, p := range recent { for _, p := range recent {
if p.Release == nil { // A settings walk that failed holds nothing back (novox/hq ADR 0344 §6): its failure is its module's
// settings on one machine, said on its own, not a build no gate passed.
if p.Release == nil || p.Release.Settings != nil {
continue continue
} }
if p.State == inventory.PlanFailed { if p.State == inventory.PlanFailed {
@@ -702,6 +708,9 @@ func releaseBacklog(ctx context.Context, open *stores, by string) (*inventory.Pl
// gate, or the machine being judged judged once more; the plan done when every machine is. // gate, or the machine being judged judged once more; the plan done when every machine is.
func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) { func advanceRelease(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) {
r := p.Release r := p.Release
if r.Settings != nil {
return advanceSettingsWalk(ctx, open, p)
}
now := time.Now().UTC() now := time.Now().UTC()
if r.Gate == nil { if r.Gate == nil {
heard, err := releaseHeard(ctx, open) heard, err := releaseHeard(ctx, open)
+92 -2
View File
@@ -13,6 +13,7 @@ import (
"github.com/nats-io/nats.go" "github.com/nats-io/nats.go"
"github.com/novox/mesh-controller/internal/builder"
"github.com/novox/mesh-controller/internal/inventory" "github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/link"
) )
@@ -313,6 +314,86 @@ func init() {
} }
} }
// reuseKey carries a walk's leave to take a check's build from askModule to the ask it makes (novox/hq ADR 0350 §2).
type reuseKey struct{}
// withReuse is ctx carrying a walk's leave to take a check's build.
func withReuse(ctx context.Context, r *link.ReuseAsk) context.Context {
return context.WithValue(ctx, reuseKey{}, r)
}
// reuseIn is the leave ctx carries; nil for every ask but a walk's.
func reuseIn(ctx context.Context) *link.ReuseAsk {
r, _ := ctx.Value(reuseKey{}).(*link.ReuseAsk)
return r
}
// reuseAsked is the leave an ask carries: a walk's, and never a dry run's — a dry run registers nothing, so it
// takes nothing either (novox/hq ADR 0350 §2, §6).
func reuseAsked(ctx context.Context, dryRun bool) *link.ReuseAsk {
if dryRun {
return nil
}
return reuseIn(ctx)
}
// reuseAskOf is a walk's leave to take a check's build: the pull requests of the merges it answers.
func reuseAskOf(p *inventory.Plan) *link.ReuseAsk {
r := &link.ReuseAsk{}
if p.Delivery == nil {
return r
}
seen := map[link.PullRef]bool{}
for _, m := range p.Delivery.Merges {
pull, ok := pullOf(m)
if !ok {
continue
}
if !seen[pull] {
seen[pull] = true
r.Pulls = append(r.Pulls, pull)
}
}
return r
}
// checkedBuildsOf are the builds the build seat's checks of those pull requests said they made for their merges,
// within the week check builds are kept (novox/hq ADR 0350 §2): read from the seat's own outcomes on the bus. A
// variable so a test needs no bus.
var checkedBuildsOf = func(ctx context.Context, pulls []link.PullRef) ([]link.CheckedBuild, error) {
if len(pulls) == 0 {
return nil, nil
}
js, err := aBus()
if err != nil {
return nil, err
}
defer js.Close()
return link.ReadCheckBuilds(ctx, js, buildSeatHeld(ctx), pulls, time.Now().Add(-builder.CheckBuildsKept))
}
// reuseWithVerdicts is a walk's leave with the builds its pull requests' verdicts name: the only builds the builder
// may take (ADR 0350 §2). When they cannot be read the leave names none, and the walk builds, saying why.
func reuseWithVerdicts(ctx context.Context, r *link.ReuseAsk) *link.ReuseAsk {
builds, err := checkedBuildsOf(ctx, r.Pulls)
if err != nil {
fmt.Printf(" the checks' builds of %d pull request(s) could not be read, so the walk builds: %v\n", len(r.Pulls), err)
return r
}
r.Builds = builds
return r
}
// pullOf is a merge's pull request: owner and repository, the last two parts of however the repository is spelled
// (owner/repo, a path on the git seat, a URL), and its number. False when the merge names none.
func pullOf(m inventory.PlanMerge) (link.PullRef, bool) {
parts := strings.Split(strings.Trim(strings.TrimSuffix(m.Repository, ".git"), "/"), "/")
if len(parts) < 2 || m.Number <= 0 || parts[len(parts)-2] == "" || parts[len(parts)-1] == "" {
return link.PullRef{}, false
}
return link.PullRef{Owner: parts[len(parts)-2], Repo: parts[len(parts)-1], Number: m.Number}, true
}
// askModule asks the build machine for one module of a plan and marks it asked, with the id it was // askModule asks the build machine for one module of a plan and marks it asked, with the id it was
// asked under (novox/hq ADR 0219) — or failed, with the plan, when it could not be asked. // asked under (novox/hq ADR 0219) — or failed, with the plan, when it could not be asked.
func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[string]inventory.Entry) { func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[string]inventory.Entry) {
@@ -340,7 +421,9 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
if p.Delivery != nil && p.Delivery.Alone && carried != "" { if p.Delivery != nil && p.Delivery.Alone && carried != "" {
ref = carried ref = carried
} }
id, err := askABuild(ctx, source, e.Source.Path, ref) // **The walk takes the check's build of the same key** (novox/hq ADR 0350 §2): its ask says so, with the pull
// requests it answers, whose checks name the part of a differing key.
id, err := askABuild(withReuse(ctx, reuseWithVerdicts(ctx, reuseAskOf(p))), source, e.Source.Path, ref)
if err != nil { if err != nil {
state.State = "failed" state.State = "failed"
state.Why = err.Error() state.Why = err.Error()
@@ -359,7 +442,7 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
state.State = "asked" state.State = "asked"
state.AskedAt = &now state.AskedAt = &now
state.Build = id state.Build = id
state.Why, state.Commit, state.BuiltAt = "", "", nil state.Why, state.Commit, state.BuiltAt, state.Made = "", "", nil, ""
} }
// planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and // planBuilt marks a module built (or failed) in every open plan whose current tier holds it, and
@@ -371,6 +454,12 @@ func askModule(ctx context.Context, p *inventory.Plan, name string, byName map[s
// would send machines, and the next tier, what the later merge replaced. asked is zero when the // would send machines, and the next tier, what the later merge replaced. asked is zero when the
// build's request time is not known, and such an outcome is taken as before. // build's request time is not known, and such an outcome is taken as before.
func planBuilt(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id string) { func planBuilt(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id string) {
planBuiltSaying(ctx, open, module, commit, failed, asked, id, "")
}
// planBuiltSaying is planBuilt keeping what the build did — "reused from <check build>" or "built: <why>" (novox/hq
// ADR 0350 §4) — on the module of the walk it answers.
func planBuiltSaying(ctx context.Context, open *stores, module, commit, failed string, asked time.Time, id, made string) {
inv := open.inventory inv := open.inventory
// One controller works the plans at a time (novox/hq issue 213); an outcome waits its turn rather // One controller works the plans at a time (novox/hq issue 213); an outcome waits its turn rather
// than write over what the holder is about to save. Not taken, it is still in the build records, // than write over what the holder is about to save. Not taken, it is still in the build records,
@@ -436,6 +525,7 @@ func planBuilt(ctx context.Context, open *stores, module, commit, failed string,
state.State = "built" state.State = "built"
state.BuiltAt = &now state.BuiltAt = &now
state.Commit = commit state.Commit = commit
state.Made = made
} }
if err := inv.SavePlan(ctx, p); err != nil { if err := inv.SavePlan(ctx, p); err != nil {
fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err) fmt.Printf("%s: cannot keep the plan: %v\n", p.ID, err)
+7 -3
View File
@@ -133,11 +133,15 @@ func aBacklog(t *testing.T) *backlogMesh {
} }
return f, nil return f, nil
} }
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
gateSettle, gateEvery = 0, time.Hour // The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
wasWindow := gateWitnessWindow
gateWitnessWindow = 0
t.Cleanup(func() { gateWitnessWindow = wasWindow })
gateWithin, gateEvery = time.Hour, time.Hour
t.Cleanup(func() { t.Cleanup(func() {
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound
}) })
return b return b
} }
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// TestReplay164 replays novox/hq issue 164 (found provisioning the media machine's modules, re-checked
// 2026-10-08): a Servarr app's API key, an indexer's key and a downloader's password are values the provider's
// software holds and only a person can give, and for a consumer of such a provision the send made a random pair
// credential, sealed it to both ends and placed it; the provider refused it, and a consumer retrying it could be
// banned by the provider. The outcome asserted (ADR 0337): for a provision whose offer says its credential is
// issued outside the mesh, the send makes no value and places none, the consumer's machine still composes, and a
// value a person gave for the pair is placed. Written with only what the controller had before its fix —
// register, assign, planFor, declarationFor, AcceptSecretForPair and the inventory's secret reads — so it is laid
// over the commit before. Needs the store (MESH_TEST_POSTGRES), and is named in testdata/store-tests.
func TestReplay164(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
const secretFile = "/var/lib/mesh/requests/tv-api.secret"
register(t, open, catalogue.Manifest{Module: "tv", Version: "1",
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh,
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}},
Serves: map[string]map[string]any{"tv-api": {"port": 8989}}})
register(t, open, catalogue.Manifest{Module: "requests", Version: "1", Requires: []string{"tv-api"},
Secrets: map[string]string{"tv-api": secretFile}})
for _, a := range [][2]string{{"anchor", "tv"}, {"laptop", "requests"}} {
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
t.Fatal(err)
}
}
sent := func() []map[string]any {
t.Helper()
plan, settings, err := planFor(ctx, open, "laptop")
if err != nil {
t.Fatalf("the consumer's machine was refused for a credential only a person gives: %v", err)
}
declared, err := declarationFor(ctx, open, "laptop", plan, settings)
if err != nil {
t.Fatalf("the send to laptop did not compose: %v", err)
}
return declared.Resources
}
placed := func(resources []map[string]any) map[string]any {
for _, r := range resources {
if r["path"] == secretFile && r["sealed"] != nil {
return r
}
}
return nil
}
if got := placed(sent()); got != nil {
t.Errorf("the send placed a credential for tv-api (%v), which only a person can give and nobody gave", got["id"])
}
held, err := open.inventory.SecretsOf(ctx, "laptop", "requests")
if err != nil {
t.Fatal(err)
}
for _, s := range held {
if s.Name == "tv-api" {
t.Fatalf("the mesh holds a value for tv-api that nobody gave (origin %q)", s.Origin)
}
}
// Given by a person for the pair, it is placed, as given.
if err := open.inventory.AcceptSecretForPair(ctx, "tv-api", "laptop", "requests", "anchor", "", "the app's key"); err != nil {
t.Fatal(err)
}
if placed(sent()) == nil {
t.Fatal("the value a person gave for tv-api was not placed")
}
}
+7 -3
View File
@@ -112,10 +112,14 @@ func TestReplay318(t *testing.T) {
} }
wasMoves, wasHeard, wasSend, wasGather := machineMoves, releaseHeard, sendRollout, gatherGateFacts wasMoves, wasHeard, wasSend, wasGather := machineMoves, releaseHeard, sendRollout, gatherGateFacts
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
wasWindow := gateWitnessWindow
gateWitnessWindow = 0
t.Cleanup(func() { gateWitnessWindow = wasWindow })
t.Cleanup(func() { t.Cleanup(func() {
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound
}) })
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) { machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
modules, err := open.inventory.Assigned(ctx, node) modules, err := open.inventory.Assigned(ctx, node)
@@ -184,7 +188,7 @@ func TestReplay318(t *testing.T) {
return f, nil return f, nil
} }
// The bound is reached at the first judging that is not a pass: what happened at 11:21 happens at once. // The bound is reached at the first judging that is not a pass: what happened at 11:21 happens at once.
gateSettle, gateEvery, gateBound = 0, 0, 0 gateWithin, gateEvery, gateBound = time.Hour, 0, 0
for i := 0; i < 12; i++ { for i := 0; i < 12; i++ {
advancePlans(ctx, open) advancePlans(ctx, open)
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// **A new SDK plans the toolchain, and then every bundle compiled in it** (novox/hq issue 614), reasoned
// as `mesh-controller.delivery-plan` reasons it: the modules registered as a build registers them —
// resolved, so carrying no `build` — their builds recorded with what they stood on and what they made,
// the graph read by theGraph and the change scoped by pullScope. On 2026-10-13 mesh-sdk 0.1.14 planned
// "builds mesh-sdk, sent nowhere": mesh-tools' `build.on` naming the SDK's package and the providers'
// TypeScript bundles were edges only while their manifests were unbuilt.
//
// **The replay of issue 614** (mesh-lab R614): written only with what the controller had before its fix — a
// store, modules registered and builds recorded, theGraph and pullScope — so it is laid over the commit before
// and fails there with the plan of that day, [[mesh-sdk]]. Needs a store: MESH_TEST_POSTGRES.
func TestReplay614ANewSDKPlansTheToolchainAndThenItsBundles(t *testing.T) {
inv := inventory.ForTest(t)
ctx := t.Context()
asked := time.Now().Add(-time.Hour)
digest := func(c string) string { return "@sha256:" + strings.Repeat(c, 64) }
register := func(m catalogue.Manifest, repository, path string, against []string, made []inventory.Artifact) {
t.Helper()
if err := inv.RegisterModule(ctx, m, inventory.Source{Repository: repository, Seat: "git", Path: path,
Ref: "main", BuiltFrom: "old", Asked: asked}); err != nil {
t.Fatal(err)
}
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-" + m.Module, Repository: repository, Ref: "main",
Module: m.Module, Commit: "old", On: "builder", Path: path, Against: against, Made: made, Asked: asked}); err != nil {
t.Fatal(err)
}
}
bundle := func(module, language string) catalogue.Manifest {
return catalogue.Manifest{Module: module, Version: "1", Bundles: []catalogue.Bundle{{
Name: "code", Language: language, Source: catalogue.ArtifactStoreScheme + module + "/code" + digest("2"),
Entrypoints: []string{"tools/index.js"}}}}
}
register(catalogue.Manifest{Module: "build-agent", Version: "1",
Claims: []catalogue.Claim{{Name: "node-build-agent", Scope: catalogue.ScopeNode}}},
"novox/mesh-catalog", "modules/build-agent", nil, nil)
register(catalogue.Manifest{Module: "mesh-sdk", Version: "1"}, "novox/mesh-sdk", "", nil,
[]inventory.Artifact{{Name: "lib", Kind: "package", Reference: "@novox/mesh-sdk@0.1.13"}})
register(catalogue.Manifest{Module: "mesh-tools", Version: "1"}, "novox/mesh-tools", "",
[]string{"@novox/mesh-sdk@0.1.13", catalogue.ArtifactStoreScheme + "upstream/docker.io/library/node" + digest("0")},
[]inventory.Artifact{{Name: "build", Kind: "image", Reference: catalogue.ArtifactStoreScheme + "mesh-tools/build" + digest("1")}})
register(catalogue.Manifest{Module: "mesh-tools-go", Version: "1"}, "novox/mesh-tools", "go", nil, nil)
register(bundle("minio", "typescript"), "novox/mesh-catalog", "modules/minio", nil, nil)
register(bundle("umami", "typescript"), "novox/mesh-catalog", "modules/umami", nil, nil)
register(bundle("dnsmasq", "go"), "novox/mesh-catalog", "modules/dnsmasq", nil, nil)
entries, read, edges, err := theGraph(ctx, inv)
if err != nil {
t.Fatal(err)
}
m := orderMember{ID: "novox/mesh-sdk@new", Repository: "novox/mesh-sdk", Head: "new",
Paths: []string{"src/provisioner/index.ts", "package.json"}}
s := pullScope(m.pull(), entries, read, edges)
want := [][]string{{"mesh-sdk"}, {"mesh-tools"}, {"minio", "umami"}}
if !reflect.DeepEqual(s.Reach.Plan.Tiers, want) {
t.Fatalf("a new SDK plans %v, want %v (edges %v)", s.Reach.Plan.Tiers, want, edges)
}
if !reflect.DeepEqual(s.Dependents, []string{"mesh-tools", "minio", "umami"}) {
t.Errorf("dependents %v", s.Dependents)
}
}
+7 -3
View File
@@ -248,9 +248,13 @@ func TestReplayCrashLoopFailsItsGateOnTheFirstMachine(t *testing.T) {
return names, nil return names, nil
} }
t.Cleanup(func() { sendRollout = wasSend }) t.Cleanup(func() { sendRollout = wasSend })
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
gateSettle, gateEvery = 0, 0 // The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound }) wasWindow := gateWitnessWindow
gateWitnessWindow = 0
t.Cleanup(func() { gateWitnessWindow = wasWindow })
gateWithin, gateEvery = time.Hour, 0
t.Cleanup(func() { gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound })
built := time.Now().UTC() built := time.Now().UTC()
plan := inventory.Plan{ID: "plan-crashloop", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2", plan := inventory.Plan{ID: "plan-crashloop", Repository: "novox/mesh-catalog", Branch: "main", Commit: "c2",
+59 -4
View File
@@ -13,6 +13,7 @@ import (
"slices" "slices"
"sort" "sort"
"strings" "strings"
"time"
"github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/broker"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
@@ -451,6 +452,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
case "queue": case "queue":
// As data, the table its output (novox/hq issue 455). // As data, the table its output (novox/hq issue 455).
return []string{"queue", "--json"}, nil return []string{"queue", "--json"}, nil
case "forget-checks":
if err := need("owner", "repo", "number"); err != nil {
return nil, err
}
return []string{"forget-checks", str("owner"), str("repo"), str("number")}, nil
case "cancel", "kill": case "cancel", "kill":
if err := need("id"); err != nil { if err := need("id"); err != nil {
return nil, err return nil, err
@@ -519,9 +525,13 @@ func (a *verbArguments) commandLine() ([]string, error) {
} }
return []string{"unpin", str("node"), str("provision")}, nil return []string{"unpin", str("node"), str("provision")}, nil
case "push": case "push":
// Sent and not waited for: the asker reads `status` for what the machine did, which is // A push to a named machine waits for that machine's own answer, so the call's record says
// what a person at a shell does too. A tool call that blocked for a push's whole apply would // how it went: applied, refused with the machine's reason, or which resources failed and why
// time out on every machine that takes a minute, and say nothing about the ones that did not. // (novox/hq issue 629). How long is the call's `wait`, else the controller's setting `push-wait`;
// neither given waits for nothing, as before. The caller is answered before the push starts
// (answersFirst) and the machines are released once sent, so the wait holds nobody; it is what
// `calls` then shows. A sweep (--behind) does not wait: the slowest machine would hold back
// what is said of the others.
// A push through the seat is a push by hand, and says why (novox/hq to-be 45 §7). // A push through the seat is a push by hand, and says why (novox/hq to-be 45 §7).
if err := need("why"); err != nil { if err := need("why"); err != nil {
return nil, fmt.Errorf("%w: a push by hand is a repair, recorded in the hand-act log with why", err) return nil, fmt.Errorf("%w: a push by hand is a repair, recorded in the hand-act log with why", err)
@@ -534,7 +544,11 @@ func (a *verbArguments) commandLine() ([]string, error) {
// behind is not read here: given with a machine, it is refused as passed over — naming // behind is not read here: given with a machine, it is refused as passed over — naming
// a machine and asking for every machine behind are two requests, and guessing one // a machine and asking for every machine behind are two requests, and guessing one
// would push a machine nobody named, or not push one somebody did. // would push a machine nobody named, or not push one somebody did.
argv := []string{"push", n, "--wait", "0"} wait, err := pushWait(str("wait"))
if err != nil {
return nil, err
}
argv := []string{"push", n, "--wait", wait.String()}
// A running module's data moving is sent only when said, per module (novox/hq ADR 0217). // A running module's data moving is sent only when said, per module (novox/hq ADR 0217).
if m := str("move"); m != "" { if m := str("move"); m != "" {
argv = append(argv, "--move", m) argv = append(argv, "--move", m)
@@ -1328,6 +1342,47 @@ var inProcess = map[string]bool{"tools": true, "calls": true, "doctor": true,
// 0259 §8): the router asks it before an approval. // 0259 §8): the router asks it before an approval.
"root-free": true} "root-free": true}
// pushFileVar names the file the controller's push settings are written to (module.json, `push-wait`).
const pushFileVar = "MESH_PUSH_FILE"
// pushWait is how long a push to a named machine waits for its answer (novox/hq issue 629): the call's own
// `wait` when given, else the controller's setting `push-wait`, else nothing. No length is written here: the
// setting's default is the module's, and a setting that cannot be read waits for nothing, as a push did
// before, and is said.
func pushWait(given string) (time.Duration, error) {
if strings.TrimSpace(given) != "" {
d, ok := durationSetting(given)
if !ok {
return 0, fmt.Errorf("push: wait %q is no duration: say it as \"90s\", \"2m\", or \"0\" to wait for nothing", given)
}
return d, nil
}
path := os.Getenv(pushFileVar)
if path == "" {
return 0, nil
}
raw, err := os.ReadFile(path)
if err != nil {
fmt.Fprintf(os.Stderr, "the push settings cannot be read (%v); a push waits for nothing\n", err)
return 0, nil
}
var values map[string]any
if err := json.Unmarshal(raw, &values); err != nil {
fmt.Fprintf(os.Stderr, "the push settings are not JSON (%v); a push waits for nothing\n", err)
return 0, nil
}
v, has := values["push-wait"]
if !has || v == "" {
return 0, nil
}
d, ok := durationSetting(v)
if !ok {
fmt.Fprintf(os.Stderr, "the setting push-wait=%v is no duration; a push waits for nothing\n", v)
return 0, nil
}
return d, nil
}
// answersFirst is a command line whose caller is answered before it runs: a push, by its verb or // answersFirst is a command line whose caller is answered before it runs: a push, by its verb or
// through `command`. A push sends the machine holding the bus first when its user list changed, the // through `command`. A push sends the machine holding the bus first when its user list changed, the
// broker reloads, and a reload forgets every answer the bus was about to permit — so an answer // broker reloads, and a reload forgets every answer the bus was about to permit — so an answer
+1 -1
View File
@@ -146,7 +146,7 @@ func TestAnArgumentAVerbDoesNotDeclareIsRefused(t *testing.T) {
// naming one beside behind is refused rather than one of the two guessed. // naming one beside behind is refused rather than one of the two guessed.
func TestAPushIsOneMachineOrSaysItIsTheWholeMesh(t *testing.T) { func TestAPushIsOneMachineOrSaysItIsTheWholeMesh(t *testing.T) {
argv, err := argvFor("push", map[string]any{"node": "g1", "why": "w"}) argv, err := argvFor("push", map[string]any{"node": "g1", "why": "w"})
if err != nil || strings.Join(argv, " ") != "push g1 --wait 0 --why w" { if err != nil || strings.Join(argv, " ") != "push g1 --wait 0s --why w" {
t.Fatalf("a named push: %v %v", argv, err) t.Fatalf("a named push: %v %v", argv, err)
} }
for _, args := range []map[string]any{{"why": "w"}, {"behind": "true", "why": "w"}} { for _, args := range []map[string]any{{"why": "w"}, {"behind": "true", "why": "w"}} {
+45 -4
View File
@@ -5,6 +5,8 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/novox/mesh-controller/internal/link" "github.com/novox/mesh-controller/internal/link"
"os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -164,12 +166,14 @@ func TestAVerbMissingWhatItNeedsIsRefused(t *testing.T) {
} }
} }
// A push and a build are sent, not waited for: the asker reads status, or the build's log by its // Acts do not block the call. A build is sent, not waited for: the asker reads the build's log by its id.
// id, for what happened. A repository given as a forge path is said to be one (issue 176). // A push answers its caller before it starts (answersFirst), so a named push waits for the machine's own
// answer without holding anybody, and the call's record says how it went (novox/hq issue 629). A
// repository given as a forge path is said to be one (issue 176).
func TestActsDoNotBlockTheCall(t *testing.T) { func TestActsDoNotBlockTheCall(t *testing.T) {
argv, _ := argvFor("push", map[string]any{"node": "one", "why": "w"}) argv, _ := argvFor("push", map[string]any{"node": "one", "why": "w"})
if strings.Join(argv, " ") != "push one --wait 0 --why w" { if strings.Join(argv, " ") != "push one --wait 0s --why w" || !answersFirst(argv) {
t.Fatalf("push waits: %v", argv) t.Fatalf("a named push with no setting waits, or holds its caller: %v", argv)
} }
argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"}) argv, _ = argvFor("build", map[string]any{"repository": "novox/x", "path": "modules/x"})
if strings.Join(argv, " ") != "build novox/x --wait 0 --self --path modules/x" { if strings.Join(argv, " ") != "build novox/x --wait 0 --self --path modules/x" {
@@ -460,3 +464,40 @@ func TestAQuotedValueRoundTrips(t *testing.T) {
} }
} }
} }
// A named push waits as long as the call says, else as long as the controller's setting push-wait says, else
// not at all: no length is the code's own (novox/hq issue 629).
func TestAPushWaitsAsTheCallOrTheSettingSays(t *testing.T) {
file := filepath.Join(t.TempDir(), "push.json")
if err := os.WriteFile(file, []byte(`{"push-wait": "2m"}`), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv(pushFileVar, file)
argv, err := argvFor("push", map[string]any{"node": "one", "why": "w"})
if err != nil || strings.Join(argv, " ") != "push one --wait 2m0s --why w" {
t.Fatalf("the setting was not the default: %v %v", argv, err)
}
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "30s"})
if err != nil || strings.Join(argv, " ") != "push one --wait 30s --why w" {
t.Fatalf("the call's wait did not win over the setting: %v %v", argv, err)
}
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "0"})
if err != nil || strings.Join(argv, " ") != "push one --wait 0s --why w" {
t.Fatalf("a wait of 0 did not wait for nothing: %v %v", argv, err)
}
if _, err := argvFor("push", map[string]any{"node": "one", "why": "w", "wait": "soon"}); err == nil ||
!strings.Contains(err.Error(), "no duration") {
t.Fatalf("a wait that is no duration was taken: %v", err)
}
if err := os.WriteFile(file, []byte(`{"push-wait": "later"}`), 0o600); err != nil {
t.Fatal(err)
}
argv, err = argvFor("push", map[string]any{"node": "one", "why": "w"})
if err != nil || strings.Join(argv, " ") != "push one --wait 0s --why w" {
t.Fatalf("an unreadable setting did not fall back to waiting for nothing: %v %v", argv, err)
}
argv, _ = argvFor("push", map[string]any{"why": "w"})
if strings.Join(argv, " ") != "push --behind --wait 0 --why w" {
t.Fatalf("a sweep waits: %v", argv)
}
}
+32
View File
@@ -79,6 +79,37 @@ func secretCommand(ctx context.Context, args []string) error {
return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+ return fmt.Errorf("a secret's value is given at the controller's terminal or at the desk (`give`), never "+
"through a verb (this line came through %q): nothing was read or sealed", verb) "through a verb (this line came through %q): nothing was read or sealed", verb)
} }
// **A provision's credential issued outside the mesh is given once, for its provider** (novox/hq ADR 0337 §3,
// issue 165): `secret accept <provider's machine> <provider module> <provision>`, sealed to every consumer of
// that provider; the per-pair form naming a consumer is refused for it, naming this one.
outsideAt, err := outsideOffer(ctx, module, name, *provider)
if err != nil {
return err
}
if outsideAt == "" && *provider != "" {
if offered, err := offeredOutsideBy(ctx, name); err != nil {
return err
} else if offered != "" {
return fmt.Errorf("%s is issued outside the mesh and given once for its provider, which seals it to "+
"every consumer of it (ADR 0337): `secret accept %s %s %s`; nothing was read or sealed",
name, *provider, offered, name)
}
}
if outsideAt != "" {
if *desk != "" || *local != "" {
return errors.New("a provision's credential issued outside the mesh is given at the controller's " +
"terminal, for every consumer of its provider; it takes neither --at-desk nor --local")
}
value, err := valueFor(node, module, name, *from)
if err != nil {
return err
}
value = asSupplied(value)
if value == "" {
return errors.New("there is nothing to seal")
}
return acceptForProvision(ctx, node, module, name, value)
}
if *desk != "" { if *desk != "" {
if *from != "" || *provider != "" { if *from != "" || *provider != "" {
return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider") return errors.New("--at-desk gives a module's own secret, and takes neither --from nor --provider")
@@ -173,6 +204,7 @@ const secretUsage = "secret rotate <node> <module> <name> [--why <text> [--cause
"secret ask <node> <module> <name> [--at <machine>]\n" + "secret ask <node> <module> <name> [--at <machine>]\n" +
"secret issued <provision>\n" + "secret issued <provision>\n" +
"secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" + "secret accept <node> <module> <name> [--from <file> | --at-desk <machine>] [--provider <node> [--local <name>]]\n" +
"secret accept <provider's node> <provider module> <provision> [--from <file>] (a credential issued outside the mesh, for every consumer)\n" +
"secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" + "secret recover <node> <module> <name> --key <operator-key> [--out <file>] [--from-export <file>] [--provider <node>]\n" +
"secret export [--out <file>]" "secret export [--out <file>]"
+3
View File
@@ -40,6 +40,9 @@ type sendable struct {
actingEpoch uint64 actingEpoch uint64
// modules are the modules this declaration names, for the record of the send; never on the wire. // modules are the modules this declaration names, for the record of the send; never on the wire.
modules []string modules []string
// localNetwork is every port admitted from the local network and what it resolved to (novox/hq ADR
// 0340 §8), for plan to say; never on the wire.
localNetwork []catalogue.LocalNetworkPort
// Adoption is nil for a converged node, and then the body is byte for byte what it was before // Adoption is nil for a converged node, and then the body is byte for byte what it was before
// adoption existed: an older host parses the envelope strictly and would refuse the key. // adoption existed: an older host parses the envelope strictly and would refuse the key.
Adoption *adoptionEnvelope Adoption *adoptionEnvelope
@@ -0,0 +1,60 @@
package main
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// novox/hq ADR 0346, issue 624: a provider on another machine serves its consumers its own composed
// names, as that machine composes them; one it does not compose is left out, said under the need.
func TestAProviderElsewhereServesItsOwnComposedName(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
forge, err := catalogue.ParseManifest([]byte(`{"module":"forge","version":"1","requires":["route"],
"provides":[{"name":"npm-package-registry","scope":"mesh"}],
"contributes":{"route":{"web":{"label":"git","port":3000}}},
"serves":{"npm-package-registry":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/",
"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"}}}`))
if err != nil {
t.Fatal(err)
}
register(t, open, catalogue.Manifest{Module: "proxy", Version: "1", Provides: catalogue.FromAnywhere("route"),
Receives: map[string]string{"route": "/var/lib/proxy/routes.json"}})
register(t, open, forge)
register(t, open, catalogue.Manifest{Module: "lab", Version: "1", Requires: []string{"npm-package-registry"},
Binds: map[string]string{"npm-package-registry": "/var/lib/lab/package-registry.json"}})
for _, a := range [][2]string{{"anchor", "proxy"}, {"anchor", "forge"}, {"laptop", "lab"}} {
if _, err := assign(ctx, open, a[0], a[1]); err != nil {
t.Fatalf("assign %s %s: %v", a[0], a[1], err)
}
}
if err := open.inventory.SetPublicDomain(ctx, "anchor", "example.test"); err != nil {
t.Fatal(err)
}
plan, _, err := planFor(ctx, open, "laptop")
if err != nil {
t.Fatal(err)
}
n := need(t, plan, "lab", "npm-package-registry")
if n.Serves["public-root"] != "https://git.example.test/api/packages/novox/npm/" || len(n.LeftOut) != 0 {
t.Fatalf("the consumer elsewhere is served the forge's public root: %v, left out %v", n.Serves, n.LeftOut)
}
if err := open.inventory.SetPublicDomain(ctx, "anchor", ""); err != nil {
t.Fatal(err)
}
plan, _, err = planFor(ctx, open, "laptop")
if err != nil {
t.Fatal(err)
}
n = need(t, plan, "lab", "npm-package-registry")
if _, there := n.Serves["public-root"]; there {
t.Fatalf("anchor composes no public name, so the key is left out: %v", n.Serves)
}
if len(n.LeftOut) != 1 || !strings.Contains(n.LeftOut[0], `without "public-root"`) {
t.Fatalf("and the need says so: %v", n.LeftOut)
}
}
+281
View File
@@ -0,0 +1,281 @@
package main
import (
"context"
"errors"
"fmt"
"slices"
"sort"
"strings"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
)
// An approved settings change is sent by a walk at once, and the operator's one Approve is its push (novox/hq ADR
// 0344, issue 641).
//
// On 2026-10-13 the operator approved a betterbird proposal on Telegram and nothing happened: the warrant set the
// layer, and the push stayed a separate act (ADR 0277 §18) that nobody took. Now the warrant that sets the layer
// opens a **settings walk**: the walk ADR 0236 opens for builds waiting for a gate, with a layer in place of the
// builds. Its order is every machine running the module that the layer reaches, the control node last; each is
// sent and its module judged there, from the machine's report on that send, before the next is sent. The send is
// the layer alone: every module there stays at the build its machine was last sent (keepingBuildsForSettings), so a
// build held for a person (ADR 0242) or waiting for a gate stays where it is — a policy of `record` holds a build,
// never a setting — and a send that would move a running module's data stops the walk (ADR 0217 §3). The walk says its start and end on `plan-moved` (ADR 0308), and the holder of the operator's
// channel tells its end once. A failure stops the walk and leaves the layer as approved; it holds back nothing
// else.
// settingsWalkPrefix names a settings walk's id.
const settingsWalkPrefix = "settings-"
// settingsWalkFor is what a settings walk says it is for, where a merge's walk says its repository.
func settingsWalkFor(s inventory.PlanSettings) string {
where := s.Node
if where == "" {
where = "the whole mesh"
}
return fmt.Sprintf("the settings of %s on %s", s.Module, where)
}
// settingsWalkOrder is the machines a layer reaches, in the order a settings walk sends them: every machine
// running the module, or the one the layer names when it runs it there, sorted, the control node last. Pure.
func settingsWalkOrder(node string, running, controllers []string) []string {
var order, last []string
for _, n := range running {
if node != "" && n != node {
continue
}
if slices.Contains(controllers, n) {
last = append(last, n)
} else {
order = append(order, n)
}
}
sort.Strings(order)
sort.Strings(last)
return append(order, last...)
}
// openSettingsWalk opens the walk that sends an approved layer, and answers the words of what it does: the
// machines it sends, or that no machine runs the module and nothing is sent. Called on the operator's warrant,
// after the layer is set.
var openSettingsWalk = func(ctx context.Context, open *stores, s inventory.PlanSettings) (string, error) {
inv := open.inventory
running, err := inv.Running(ctx, s.Module)
if err != nil {
return "", err
}
controllers, err := inv.Running(ctx, catalogue.ControllerSeatName)
if err != nil {
return "", err
}
order := settingsWalkOrder(s.Node, running, controllers)
if len(order) == 0 {
return fmt.Sprintf("no machine runs %s there, so there is nothing to send", s.Module), nil
}
release, err := inv.HoldPlans(ctx, true)
if err != nil {
return "", err
}
defer release()
now := time.Now().UTC()
p := inventory.Plan{ID: fmt.Sprintf("%s%d", settingsWalkPrefix, now.UnixNano()), Repository: settingsWalkFor(s),
Created: now, State: inventory.PlanRolling, Tiers: [][]string{{s.Module}}, Modules: map[string]*inventory.PlanModule{},
Release: &inventory.PlanRelease{Order: order, Settings: &s},
Note: fmt.Sprintf("approved (ask %s): sent to %s, one machine at a time, each judged", s.Ask,
strings.Join(order, ", "))}
if err := inv.SavePlan(ctx, &p); err != nil {
return "", err
}
fmt.Printf("%s: %s — %s\n", p.ID, p.Repository, p.Note)
return fmt.Sprintf("%s sends it to %s, one machine at a time, each judged", p.ID, strings.Join(order, ", ")), nil
}
// settingsDataMoves is what a settings walk's send to a machine would move of a running module's data: composed as
// that send composes it, every build kept, and compared with what the machine was last sent (ADR 0217 §3).
var settingsDataMoves = func(ctx context.Context, open *stores, node string) ([]dataMove, error) {
keeping := keepingBuildsForSettings(keepingRecorded(ctx), node)
gens, err := generators(keeping, open)
if err != nil {
return nil, err
}
plan, settings, err := planFor(keeping, open, node)
if err != nil {
return nil, err
}
declared, err := declarationWith(keeping, open, node, plan, settings, gens, Reading)
if err != nil {
return nil, err
}
body, err := declared.Body()
if err != nil {
return nil, err
}
return heldMoves(keeping, open.inventory, node, body, nil)
}
// judgingOn is the open walk, other than the one named, whose gate is judging on a machine; empty when none is. A
// settings walk sends a machine only when no other walk judges there: its send would set aside the declaration
// that walk's gate reads the machine's report against.
var judgingOn = func(ctx context.Context, open *stores, self, node string) (string, error) {
plans, err := open.inventory.OpenPlans(ctx)
if err != nil {
return "", err
}
return judgingIn(plans, self, node), nil
}
// judgingIn is judgingOn over plans read. Pure.
func judgingIn(plans []inventory.Plan, self, node string) string {
judging := func(g *inventory.PlanGate) bool {
return g != nil && g.Verdict == "" && slices.Contains(g.Machines, node)
}
for _, p := range plans {
if p.ID == self || !p.Open() {
continue
}
if p.Release != nil {
if judging(p.Release.Gate) {
return p.ID
}
continue
}
for _, s := range p.Modules {
if s != nil && judging(s.Gate) {
return p.ID
}
}
}
return ""
}
// advanceSettingsWalk takes one step of a settings walk: the next machine sent and its module judged there, or the
// machine being judged judged once more; done when every machine is.
func advanceSettingsWalk(ctx context.Context, open *stores, p *inventory.Plan) (bool, error) {
r, s := p.Release, p.Release.Settings
now := time.Now().UTC()
wait := func(note string) (bool, error) {
changed := p.Note != note
p.Note = note
return changed, nil
}
if r.Gate == nil {
heard, err := releaseHeard(ctx, open)
if err != nil {
return false, err
}
for r.Next < len(r.Order) {
node := r.Order[r.Next]
if !heard[node] {
r.Skipped = append(r.Skipped, node)
r.Next++
continue
}
if id, err := judgingOn(ctx, open, p.ID, node); err != nil {
return false, err
} else if id != "" {
return wait(fmt.Sprintf("waiting before %s: %s is judging there", node, id))
}
// **A running module's data that would move is a person's word** (ADR 0217 §3): the walk stops before
// that machine, failed and said, and `push --move` sends it.
held, err := settingsDataMoves(ctx, open, node)
if errors.Is(err, errSettingsCannotKeep) {
return failBefore(p, node, err.Error())
}
if err != nil {
return false, fmt.Errorf("whether %s's data would move could not be read: %w", node, err)
}
if len(held) > 0 {
var said strings.Builder
sayDataHeld(&said, node, held)
return failBefore(p, node, "a running module's data would move there — "+
strings.Join(strings.Fields(said.String()), " "))
}
// A build that cannot be kept is refused by the composition above, which is the send's own, and fails the
// walk there: the send flattens its refusals into words, so it is not looked for again here.
sent, grants, err := sendJudged(keepingBuildsForSettings(ctx, node), open, node)
if err != nil {
return false, fmt.Errorf("sending %s %s: %w", node, settingsWalkFor(*s), err)
}
// **The module's build is the one the gate judges** (issue 641 review): kept by this send, it is what a
// crossing send — a merge's walk sending the machine a new build of the module — supersedes, so this gate
// never reads that send's report as its own.
kept := ""
if builds, known, err := open.inventory.SentBuilds(ctx, node); err == nil && known {
kept = builds[s.Module]
}
r.Gate = &inventory.PlanGate{Component: coreComponent(s.Module), Machines: sent, Since: &now,
From: kept, To: kept, Sent: sentNow(ctx, open.inventory, sent), Grants: grants}
p.Note = fmt.Sprintf("sent %s %s (ask %s), every build there kept; judging %s there", node,
settingsWalkFor(*s), s.Ask, s.Module)
if said := grantsSaid(grants); said != "" {
p.Note += "; " + said
}
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
p.State, p.Tier = inventory.PlanDone, len(p.Tiers)
p.Note = fmt.Sprintf("%s (ask %s) delivered on %s", settingsWalkFor(*s), s.Ask, strings.Join(r.Done, ", "))
if len(r.Done) == 0 {
// Sent nowhere is not delivered: said as the walk's failure, so the operator is not told it landed.
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("%s (ask %s) was sent to no machine", settingsWalkFor(*s), s.Ask)
}
if len(r.Skipped) > 0 {
p.Note += "; not heard from, left as they were, their next send carries it: " + strings.Join(r.Skipped, ", ")
}
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
g := r.Gate
node := r.Order[r.Next]
verdict, err := judgeMoves(ctx, open, g, []judged{{module: s.Module, node: node}}, now)
if err != nil {
return false, err
}
switch verdict {
case "":
return wait(fmt.Sprintf("judging %s on %s: %s", s.Module, node, gateLine(g)))
case inventory.GatePassed:
r.Done = append(r.Done, node)
r.Next++
r.Gate = nil
return true, nil
case inventory.GateSuperseded:
p.State = inventory.PlanSuperseded
p.Note = fmt.Sprintf("superseded on %s: %s — nothing judged, nothing put back; the layer is set, and the "+
"machines not yet sent take it at their next send", node, g.Why)
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("%s failed its gate on %s: %s — the layer stays as the operator approved it, and the one it "+
"replaced is in `settings %s` history", settingsWalkFor(*s), node, g.Why, s.Module) + unreached(r)
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
// failBefore ends a settings walk failed before it sent a machine, saying why, and that the layer stays.
func failBefore(p *inventory.Plan, node, why string) (bool, error) {
r, s := p.Release, p.Release.Settings
p.State = inventory.PlanFailed
p.Note = fmt.Sprintf("%s not sent to %s: %s; the layer stays as the operator approved it", settingsWalkFor(*s),
node, why) + unreached(r)
fmt.Printf("%s: %s\n", p.ID, p.Note)
return true, nil
}
// unreached is what a stopped settings walk left without the layer, in words: the machines after the one it stopped
// at, and those passed over as away (ADR 0344 §6). Empty when there are none.
func unreached(r *inventory.PlanRelease) string {
var out string
if r.Next+1 < len(r.Order) {
out += "; not sent: " + strings.Join(r.Order[r.Next+1:], ", ")
}
if len(r.Skipped) > 0 {
out += "; not heard from, left as they were: " + strings.Join(r.Skipped, ", ")
}
return out
}
+572
View File
@@ -0,0 +1,572 @@
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/inventory"
"github.com/novox/mesh-controller/internal/lease"
)
// novox/hq ADR 0344, issue 641: the operator's one Approve on a settings proposal is the act that makes it take
// effect. The warrant sets the layer and opens a settings walk, which sends it to the machines it reaches one at a
// time, judging the module on each; the ask says so before the Approve, and nothing names a push.
// The order: every machine running the module, or the one the layer names, the control node last.
func TestASettingsWalkGoesToTheMachinesTheLayerReachesTheControlNodeLast(t *testing.T) {
running := []string{"shanks", "novox", "g14", "ace"}
for _, c := range []struct {
node string
want []string
}{
{"", []string{"ace", "g14", "shanks", "novox"}},
{"g14", []string{"g14"}},
{"novox", []string{"novox"}},
{"elsewhere", nil},
} {
if got := settingsWalkOrder(c.node, running, []string{"novox"}); !reflect.DeepEqual(got, c.want) {
t.Errorf("a layer on %q goes to %v, want %v", c.node, got, c.want)
}
}
}
// A settings walk sends a machine only when no other walk judges there.
func TestASettingsWalkWaitsWhileAnotherWalkJudgesOnTheMachine(t *testing.T) {
judging := &inventory.PlanGate{Machines: []string{"g14"}}
passed := &inventory.PlanGate{Machines: []string{"g14"}, Verdict: inventory.GatePassed}
plans := []inventory.Plan{
{ID: "settings-1", State: inventory.PlanRolling, Release: &inventory.PlanRelease{Gate: judging}},
{ID: "release-2", State: inventory.PlanRolling, Release: &inventory.PlanRelease{Gate: passed}},
{ID: "plan-3", State: inventory.PlanRolling, Modules: map[string]*inventory.PlanModule{"app": {Gate: judging}}},
{ID: "plan-4", State: inventory.PlanDone, Modules: map[string]*inventory.PlanModule{"app": {Gate: judging}}},
}
if id := judgingIn(plans, "settings-1", "g14"); id != "plan-3" {
t.Errorf("on g14 %q judges, want plan-3 (itself, a passed gate and an ended walk are not judging)", id)
}
if id := judgingIn(plans[1:2], "x", "g14"); id != "" {
t.Errorf("a passed gate judges: %q", id)
}
if id := judgingIn(plans, "x", "shanks"); id != "" {
t.Errorf("on shanks %q judges", id)
}
}
// The ask says, before the Approve, that the layer is sent at once and judged, and that its end is told as every
// walk's is; neither the ask nor the proposer's answer names a push any more.
func TestTheAskSaysAnApprovedLayerIsSentAtOnce(t *testing.T) {
for _, c := range []struct {
in proposeInput
where string
had bool
}{
{proposeInput{module: "mounts", node: "shanks", values: mountsSources}, "shanks", false},
{proposeInput{module: "notes", values: map[string]any{"x": "1"}}, "every machine running notes", true},
{proposeInput{module: "notes", node: "laptop", clear: true}, "laptop", true},
} {
r := newProposerRig(t)
if c.had {
r.before, r.had = map[string]any{"x": "0"}, true
}
words, err := r.pr.propose(context.Background(), c.in)
if err != nil {
t.Fatal(err)
}
q := r.askSent(t)
if err := q.Check(r.now); err != nil {
t.Fatalf("the ask is refused: %v", err)
}
// At a glance (ADR 0345): the summary says it is sent at once by a walk, one machine at a time; the rest of
// what Approve does is the Details', which the router notes after the answers.
if !strings.Contains(q.Explanation, "sent at once by a walk to "+c.where+", one machine at a time, each judged") ||
!strings.Contains(q.Details, "and sent at once to "+c.where+", one machine at a time, each judged, "+
"every build there kept; its end is told as every walk's is") {
t.Errorf("the ask does not say the layer is sent at once to %s:\n%s", c.where, q.Explanation)
}
approve := q.Options[0]
if approve.Label != "Approve" || !strings.Contains(approve.Does, "sent to "+c.where+" at once") {
t.Errorf("Approve does %q", approve.Does)
}
if !strings.Contains(words, "a walk sends it to "+c.where+" at once") {
t.Errorf("the proposer's answer:\n%s", words)
}
for _, said := range []string{q.Explanation, q.Whole, approve.Does, words} {
if strings.Contains(said, "push") {
t.Errorf("a push is still named, as if a second act were needed:\n%s", said)
}
}
}
}
// ---- on the real stores -----------------------------------------------------------------------------
// The warrant sets the layer and opens the walk that sends it, naming the machines it reaches; a layer for a
// machine that does not run the module opens none and says so.
func TestAnApprovedLayerOpensTheWalkThatSendsIt(t *testing.T) {
open := aMesh(t)
ctx := t.Context()
register(t, open, catalogue.Manifest{Module: "notes", Version: "1",
Resources: []map[string]any{{"id": "rc", "type": "file", "path": "/etc/notes.conf", "mode": "0644",
"content": "x = ${setting:x}\n"}}})
if _, err := assign(ctx, open, "laptop", "notes"); err != nil {
t.Fatal(err)
}
// A layer set by hand opens no walk: it still waits for a push (ADR 0344 §7).
if err := atTheTerminal(t, "settings", "set", "notes", `{"x":"0"}`, "--node", "laptop"); err != nil {
t.Fatal(err)
}
if plans, _ := open.inventory.OpenPlans(ctx); len(plans) != 0 {
t.Fatalf("a layer set at the terminal opened %d walk(s)", len(plans))
}
set := setLayerIn(open)
now := time.Now()
values := map[string]any{"x": "1"}
p := settingsProposal{Module: "notes", Values: values, From: "g14/claude-code", At: now,
Digest: layerDigest(values), BeforeDigest: layerDigest(nil)}
words, err := set(ctx, p, "s9", "approved by the operator (ask s9)")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(words, "+ x (ask s9); settings-") || !strings.Contains(words, "sends it to laptop, one machine at a time") {
t.Errorf("the warrant's words: %q", words)
}
plans, err := open.inventory.OpenPlans(ctx)
if err != nil || len(plans) != 1 {
t.Fatalf("open walks: %+v %v", plans, err)
}
w := plans[0]
if w.Release == nil || w.Release.Settings == nil || w.Release.Settings.Module != "notes" || w.Release.Settings.Ask != "s9" ||
!reflect.DeepEqual(w.Release.Order, []string{"laptop"}) || w.Repository != "the settings of notes on the whole mesh" ||
!reflect.DeepEqual(w.Tiers, [][]string{{"notes"}}) {
t.Fatalf("the settings walk: %+v %+v", w, w.Release)
}
// A machine's layer where the module does not run: set, and nothing to send.
q := settingsProposal{Module: "notes", Node: "anchor", Values: values, From: "g14/claude-code", At: now,
Digest: layerDigest(values), BeforeDigest: layerDigest(nil)}
words, err = set(ctx, q, "s10", "approved by the operator (ask s10)")
if err != nil || !strings.Contains(words, "no machine runs notes there, so there is nothing to send") {
t.Fatalf("a layer nobody runs: %q %v", words, err)
}
if plans, _ := open.inventory.OpenPlans(ctx); len(plans) != 1 {
t.Fatalf("a walk was opened for a machine that does not run the module: %d open", len(plans))
}
// A walk that cannot be opened is the act's failure, said with the push that sends it by hand.
was := openSettingsWalk
openSettingsWalk = func(context.Context, *stores, inventory.PlanSettings) (string, error) {
return "", fmt.Errorf("the plans are not to be had")
}
t.Cleanup(func() { openSettingsWalk = was })
again := map[string]any{"x": "2"}
byHand := map[string]any{"x": "0"}
r := settingsProposal{Module: "notes", Node: "laptop", Values: again, Before: byHand, HadLayer: true,
From: "g14/claude-code", At: now, Digest: layerDigest(again), BeforeDigest: layerDigest(byHand)}
if _, err := set(ctx, r, "s11", "approved"); err == nil || !strings.Contains(err.Error(), "is set, and the walk that "+
"sends it could not be opened: the plans are not to be had — `push laptop` sends it") {
t.Errorf("an unopened walk: %v", err)
}
}
// walkMesh is a mesh where `app` runs c1 on anchor and laptop and nothing waits for a gate: every send applied at
// once, recorded, and judged from the reports, with a machine named broken failing what it was sent.
type walkMesh struct {
open *stores
sent [][]string
broken map[string]bool
// kept says, per send, whether it kept every build of its machine; moves is the data a machine's send would move.
kept []bool
moves map[string][]dataMove
heard map[string]bool
}
func aSettledMesh(t *testing.T) *walkMesh {
t.Helper()
open := aMesh(t)
ctx := t.Context()
inv := open.inventory
w := &walkMesh{open: open, broken: map[string]bool{}, moves: map[string][]dataMove{},
heard: map[string]bool{"anchor": true, "laptop": true}}
withConditionsInMemory(t)
was := doctorFrom
doctorFrom = nil
t.Cleanup(func() { doctorFrom = was })
old := time.Now().Add(-2 * time.Hour)
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c1", Module: "app", Commit: "c1",
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: old, At: old,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-1"}}}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c1", Head: "c1", Asked: old}); err != nil {
t.Fatal(err)
}
for _, n := range []string{"anchor", "laptop"} {
if _, err := inv.Assign(ctx, n, "app"); err != nil {
t.Fatal(err)
}
if err := inv.RecordSent(ctx, nodeID(t, open, n), "d-"+n, map[string]string{"app": "c1"}); err != nil {
t.Fatal(err)
}
}
wasMoves, wasHeard, wasSend, wasGather, wasData := machineMoves, releaseHeard, sendRollout, gatherGateFacts, settingsDataMoves
settingsDataMoves = func(_ context.Context, _ *stores, node string) ([]dataMove, error) { return w.moves[node], nil }
machineMoves = func(ctx context.Context, open *stores, f moveFacts, node string, all bool) ([]inventory.CarriedMove, error) {
modules, err := open.inventory.Assigned(ctx, node)
if err != nil {
return nil, err
}
sent, known, err := open.inventory.SentBuilds(ctx, node)
if err != nil {
return nil, err
}
return f.moves(node, modules, sent, known, all), nil
}
releaseHeard = func(context.Context, *stores) (map[string]bool, error) { return w.heard, nil }
n := 0
sendRollout = func(ctx context.Context, open *stores, names []string) ([]string, error) {
w.sent = append(w.sent, append([]string(nil), names...))
w.kept = append(w.kept, len(names) == 1 && settingsKeptOn(ctx, names[0]))
current, err := open.inventory.CurrentBuilds(ctx)
if err != nil {
return nil, err
}
for _, node := range names {
modules, _ := open.inventory.Assigned(ctx, node)
carried := map[string]string{}
for _, m := range modules {
carried[m] = current[m].Commit
}
n++
digest := fmt.Sprintf("d-%s-%d", node, n)
if err := open.inventory.RecordSent(ctx, nodeID(t, open, node), digest, carried); err != nil {
return nil, err
}
if _, err := open.inventory.RecordDoing(ctx, nodeID(t, open, node), inventory.Doing{Node: node,
Outcome: inventory.OutcomeApplied, Declared: digest, Applied: 1, At: time.Now()}); err != nil {
return nil, err
}
}
return names, nil
}
gatherGateFacts = func(ctx context.Context, open *stores, component string) (gateFacts, error) {
f := gateFacts{now: time.Now(), reports: map[string]inventory.Reported{}, engines: map[string]string{},
rolledBack: map[string][]lease.Rollback{}, served: map[string]served{}}
reports, err := open.inventory.LastReports(ctx)
if err != nil {
return f, err
}
for _, r := range reports {
if w.broken[r.Node] {
r.Outcome = inventory.OutcomeFailed
}
f.reports[r.Node] = r
}
return f, nil
}
wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
wasWindow := gateWitnessWindow
gateWitnessWindow = 0
t.Cleanup(func() { gateWitnessWindow = wasWindow })
gateWithin, gateEvery = time.Hour, time.Hour
t.Cleanup(func() {
machineMoves, releaseHeard, sendRollout, gatherGateFacts = wasMoves, wasHeard, wasSend, wasGather
settingsDataMoves = wasData
gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound
})
return w
}
func (w *walkMesh) walk(t *testing.T) inventory.Plan {
t.Helper()
plans, err := w.open.inventory.RecentPlans(t.Context(), 10)
if err != nil {
t.Fatal(err)
}
for _, p := range plans {
if p.Release != nil && p.Release.Settings != nil {
return p
}
}
t.Fatal("no settings walk")
return inventory.Plan{}
}
// The walk sends the first machine alone and judges the module there before it sends the next, and ends
// delivered on both; nothing waited for a gate, so each send carried the layer alone.
func TestASettingsWalkSendsOneMachineAtATimeEachJudged(t *testing.T) {
judgeLeavesAsTheCore(t)
w := aSettledMesh(t)
ctx := t.Context()
words, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s1", Change: "~ x"})
if err != nil || !strings.Contains(words, "sends it to anchor, laptop") {
t.Fatalf("%q %v", words, err)
}
advancePlans(ctx, w.open)
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
t.Fatalf("sent %v: the first machine alone, before it is judged", w.sent)
}
p := w.walk(t)
if p.Release.Gate == nil || p.Release.Next != 0 || !strings.Contains(p.Note, "judging app on anchor") {
t.Fatalf("the walk after its first send: %s %+v", p.Note, p.Release)
}
gateEvery = 0
for i := 0; i < 6; i++ {
advancePlans(ctx, w.open)
}
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}, {"laptop"}}) {
t.Fatalf("sent %v", w.sent)
}
p = w.walk(t)
if p.State != inventory.PlanDone || !reflect.DeepEqual(p.Release.Done, []string{"anchor", "laptop"}) ||
p.Outcome() != inventory.WalkDelivered || !strings.Contains(p.Note, "delivered on anchor, laptop") {
t.Fatalf("the walk is %s: %s %+v", p.State, p.Note, p.Release)
}
// It said its start and its end, so the operator is told once (ADR 0308).
if p.Times == nil || p.Times.Started == nil || p.Times.Ended == nil {
t.Fatalf("the walk's start and end are not marked: %+v", p.Times)
}
// Nothing waits, and no walk of waiting builds opened beside it.
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r != nil {
t.Fatalf("%+v %v", r, err)
}
}
// A machine that fails what it was sent stops the walk there: the next is not sent, the layer stays, and the
// failure holds back no walk of waiting builds.
func TestAFailedSettingsWalkStopsAndHoldsNothingBack(t *testing.T) {
w := aSettledMesh(t)
ctx := t.Context()
inv := w.open.inventory
w.broken["anchor"] = true
if err := inv.KeepSettings(ctx, "", "app", map[string]any{"x": "1"}); err != nil {
t.Fatal(err)
}
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s2"}); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
advancePlans(ctx, w.open)
}
p := w.walk(t)
if p.State != inventory.PlanFailed || p.Outcome() != inventory.WalkFailed ||
!strings.Contains(p.Note, "the settings of app on the whole mesh failed its gate on anchor") ||
!strings.Contains(p.Note, "the layer stays as the operator approved it") || !strings.Contains(p.Note, "not sent: laptop") {
t.Fatalf("the walk is %s: %s", p.State, p.Note)
}
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
t.Fatalf("sent %v: anchor alone, nothing to laptop", w.sent)
}
if layer, has, err := inv.Layer(ctx, "", "app"); err != nil || !has || layer["x"] != "1" {
t.Fatalf("the layer after the failure: %v %v %v", layer, has, err)
}
// A build that waits for a gate is still walked: the failed settings walk holds nothing back.
w.broken["anchor"] = false
now := time.Now().Add(-time.Minute)
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
t.Fatal(err)
}
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r == nil {
t.Fatalf("the waiting build is held back by a failed settings walk: %+v %v", r, err)
}
}
// Each send of a settings walk keeps every build of its machine: the layer goes, and a build held for a person or
// waiting for a gate stays where it is (ADR 0344 §3).
func TestASettingsWalkSendsTheLayerAloneEveryBuildKept(t *testing.T) {
w := aSettledMesh(t)
ctx := t.Context()
inv := w.open.inventory
gateEvery = 0
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s3"}); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
advancePlans(ctx, w.open)
}
if !reflect.DeepEqual(w.kept, []bool{true, true}) || w.walk(t).State != inventory.PlanDone {
t.Fatalf("the sends kept every build: %v; the walk: %s", w.kept, w.walk(t).Note)
}
// What such a send keeps, on the store: a rolled-out build waiting there is kept at the one last sent.
now := time.Now().Add(-time.Minute)
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
t.Fatal(err)
}
kept, err := recordedKept(keepingBuildsForSettings(ctx, "anchor"), w.open, "anchor")
if err != nil || kept["app"] != "c1" {
t.Fatalf("a settings send to anchor keeps %v (%v), want app at c1", kept, err)
}
if kept, _ := recordedKept(ctx, w.open, "anchor"); kept["app"] != "" {
t.Fatalf("an ordinary send keeps a rolled-out build: %v", kept)
}
// And so the ungated build waiting on anchor is no move of the settings send's: nothing for a gate to see.
f, err := readMoveFacts(ctx, inv)
if err != nil {
t.Fatal(err)
}
waiting, _ := machineMoves(ctx, w.open, f, "anchor", false)
if len(waiting) != 1 || waiting[0].Module != "app" {
t.Fatalf("anchor waits for %+v, want app's c2", waiting)
}
if moved, err := withoutKept(keepingBuildsForSettings(ctx, "anchor"), w.open, "anchor", waiting); err != nil || len(moved) != 0 {
t.Fatalf("a settings send to anchor would move %+v (%v)", moved, err)
}
}
// A machine whose send would move a running module's data stops the walk there, failed and said with the push that
// sends it on a person's word (ADR 0217 §3); one not heard from is passed over and named; and a walk that reached
// no machine is not said delivered.
func TestASettingsWalkStopsBeforeADataMoveAndPassesOverAMachineAway(t *testing.T) {
w := aSettledMesh(t)
ctx := t.Context()
gateEvery = 0
w.moves["laptop"] = []dataMove{{Container: "app-server", Inside: "/data", From: "/srv/a", To: "/srv/b"}}
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s4"}); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
advancePlans(ctx, w.open)
}
p := w.walk(t)
if p.State != inventory.PlanFailed || !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) ||
!strings.Contains(p.Note, "not sent to laptop: a running module's data would move there") ||
!strings.Contains(p.Note, "push laptop --move app") {
t.Fatalf("sent %v; the walk is %s: %s", w.sent, p.State, p.Note)
}
v := aSettledMesh(t)
v.heard = map[string]bool{"laptop": true}
gateEvery = 0
if _, err := openSettingsWalk(ctx, v.open, inventory.PlanSettings{Module: "app", Ask: "s5"}); err != nil {
t.Fatal(err)
}
for i := 0; i < 4; i++ {
advancePlans(ctx, v.open)
}
p = v.walk(t)
if p.State != inventory.PlanDone || !reflect.DeepEqual(p.Release.Skipped, []string{"anchor"}) ||
!strings.Contains(p.Note, "delivered on laptop; not heard from, left as they were, their next send carries it: anchor") {
t.Fatalf("the walk with anchor away is %s: %s", p.State, p.Note)
}
u := aSettledMesh(t)
u.heard = map[string]bool{}
if _, err := openSettingsWalk(ctx, u.open, inventory.PlanSettings{Module: "app", Ask: "s6"}); err != nil {
t.Fatal(err)
}
advancePlans(ctx, u.open)
if p := u.walk(t); p.State != inventory.PlanFailed || p.Outcome() != inventory.WalkFailed ||
!strings.Contains(p.Note, "was sent to no machine") || len(u.sent) != 0 {
t.Fatalf("a walk that reached nobody is %s: %s", p.State, p.Note)
}
}
// The gate keeps the module's build its send kept, so a crossing send — a merge's walk sending the machine a new
// build of the module while the settings gate judges there — supersedes it, and its report is never read as the
// settings send's (issue 641 review, finding 1).
func TestACrossingSendSupersedesASettingsGate(t *testing.T) {
judgeLeavesAsTheCore(t)
w := aSettledMesh(t)
ctx := t.Context()
inv := w.open.inventory
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s7"}); err != nil {
t.Fatal(err)
}
advancePlans(ctx, w.open)
p := w.walk(t)
if g := p.Release.Gate; g == nil || g.To != "c1" || g.From != "c1" {
t.Fatalf("the settings gate judges no build: %+v", p.Release.Gate)
}
// Another walk sends anchor app's c2 while the settings gate judges there.
now := time.Now().Add(-time.Minute)
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
t.Fatal(err)
}
if err := inv.RecordSent(ctx, nodeID(t, w.open, "anchor"), "d-merge", map[string]string{"app": "c2"}); err != nil {
t.Fatal(err)
}
if _, err := inv.RecordDoing(ctx, nodeID(t, w.open, "anchor"), inventory.Doing{Node: "anchor",
Outcome: inventory.OutcomeApplied, Declared: "d-merge", Applied: 1, At: time.Now()}); err != nil {
t.Fatal(err)
}
gateEvery = 0
advancePlans(ctx, w.open)
p = w.walk(t)
if p.State != inventory.PlanSuperseded || p.Release.Gate == nil || p.Release.Gate.Verdict != inventory.GateSuperseded ||
!strings.Contains(p.Note, "superseded on anchor") || len(p.Release.Done) != 0 {
t.Fatalf("the settings walk after a crossing send is %s: %s %+v", p.State, p.Note, p.Release)
}
if !reflect.DeepEqual(w.sent, [][]string{{"anchor"}}) {
t.Fatalf("sent %v: laptop was sent after a superseded judging", w.sent)
}
}
// A send refusal that cannot clear by itself — what the machine was last sent is not known, or a build it runs is
// gone — fails the walk with why, rather than keeping it open and holding the walk of waiting builds; and a
// failure names the machines passed over as away (issue 641 review, findings 2 and 3).
func TestASettingsWalkFailsOnARefusalThatCannotClear(t *testing.T) {
w := aSettledMesh(t)
ctx := t.Context()
inv := w.open.inventory
// What a send to a machine never sent can keep: nothing, and said as the refusal the walk fails on.
if _, err := inv.AddNode(ctx, "fresh"); err != nil {
t.Fatal(err)
}
if _, err := recordedKept(keepingBuildsForSettings(ctx, "fresh"), w.open, "fresh"); !errors.Is(err, errSettingsCannotKeep) {
t.Fatalf("a settings send to a machine never sent: %v", err)
}
w.heard = map[string]bool{"laptop": true}
settingsDataMoves = func(_ context.Context, _ *stores, node string) ([]dataMove, error) {
return nil, fmt.Errorf("%w: what %s was last sent is not known", errSettingsCannotKeep, node)
}
if _, err := openSettingsWalk(ctx, w.open, inventory.PlanSettings{Module: "app", Ask: "s8"}); err != nil {
t.Fatal(err)
}
advancePlans(ctx, w.open)
p := w.walk(t)
if p.State != inventory.PlanFailed || len(w.sent) != 0 ||
!strings.Contains(p.Note, "not sent to laptop: the settings cannot be sent with every build kept: what laptop was last sent is not known") ||
!strings.Contains(p.Note, "not heard from, left as they were: anchor") {
t.Fatalf("the walk is %s: %s", p.State, p.Note)
}
// It is not open, so a build waiting for a gate is walked.
now := time.Now().Add(-time.Minute)
manifest, _ := json.Marshal(catalogue.Manifest{Module: "app", Version: "1"})
if err := inv.RecordBuild(ctx, inventory.Build{ID: "build-app-c2", Module: "app", Commit: "c2",
Repository: "novox/mesh-catalog", Path: "modules/app", Manifest: manifest, Asked: now, At: now,
Made: []inventory.Artifact{{Name: "x", Kind: "bundle", Reference: "sha256:app-2"}}}); err != nil {
t.Fatal(err)
}
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "app", Version: "1"}, inventory.Source{
Repository: "novox/mesh-catalog", Seat: "git", Path: "modules/app", BuiltFrom: "c2", Head: "c2", Asked: now}); err != nil {
t.Fatal(err)
}
if r, err := releaseBacklog(ctx, w.open, ""); err != nil || r == nil {
t.Fatalf("the waiting build is held by the failed settings walk: %+v %v", r, err)
}
}
+25 -14
View File
@@ -130,10 +130,14 @@ func aTierMesh(t *testing.T, modules ...string) *tierMesh {
} }
t.Cleanup(func() { gatherGateFacts = wasGather }) t.Cleanup(func() { gatherGateFacts = wasGather })
wasSettle, wasEvery, wasBound := gateSettle, gateEvery, gateBound wasWithin, wasEvery, wasBound := gateWithin, gateEvery, gateBound
// The witness window is the node-engine's, from a start no test makes (novox/hq ADR 0351).
wasWindow := gateWitnessWindow
gateWitnessWindow = 0
t.Cleanup(func() { gateWitnessWindow = wasWindow })
// One judging per advance until a test says otherwise: a pass waits gateEvery for the next. // One judging per advance until a test says otherwise: a pass waits gateEvery for the next.
gateSettle, gateEvery = 0, time.Hour gateWithin, gateEvery = time.Hour, time.Hour
t.Cleanup(func() { gateSettle, gateEvery, gateBound = wasSettle, wasEvery, wasBound }) t.Cleanup(func() { gateWithin, gateEvery, gateBound = wasWithin, wasEvery, wasBound })
built := time.Now().UTC() built := time.Now().UTC()
states := map[string]*inventory.PlanModule{} states := map[string]*inventory.PlanModule{}
@@ -222,6 +226,9 @@ func TestACoreBehindWhileTheNodeToolsSettleFailsNoOtherModule(t *testing.T) {
tm := aTierMesh(t, "app1", "app2", broker.RuntimeModule) tm := aTierMesh(t, "app1", "app2", broker.RuntimeModule)
ctx := t.Context() ctx := t.Context()
inv := tm.open.inventory inv := tm.open.inventory
// The node tools are held inside their witness window, which the node-engine keeps while they settle: their
// one reading does not count yet, so the core behind raised meanwhile is theirs (novox/hq ADR 0351 decision 4).
gateWitnessWindow = time.Hour
advancePlans(ctx, tm.open) advancePlans(ctx, tm.open)
if len(tm.sent) != 1 { if len(tm.sent) != 1 {
@@ -405,7 +412,10 @@ func TestATierPutsABrokenModuleBackAtOnce(t *testing.T) {
} }
return f, err return f, err
} }
gateEvery, gateSettle, gateBound = 0, 100*time.Millisecond, 10*time.Second // app1, judged as the core, counts a reading at most every 100 ms: it cannot have its three in the
// judgings that find the node tools broken, and is judged to its own pass after them (the hold the
// settle time gave before novox/hq ADR 0352 counted readings from the send).
gateEvery, gateWithin, gateBound = 100*time.Millisecond, time.Hour, 10*time.Second
for i := 0; i < 20 && len(seen) < 2; i++ { for i := 0; i < 20 && len(seen) < 2; i++ {
advancePlans(ctx, tm.open) advancePlans(ctx, tm.open)
} }
@@ -440,15 +450,15 @@ func TestATierPutsABrokenModuleBackAtOnce(t *testing.T) {
} }
} }
// **A send carrying a core module waits for the core's readings** (issue 524 review): the leaf beside the node // **A send carrying the tool runner waits for its witness window** (issue 524 review, novox/hq ADR 0351
// tools has its one reading at once, but the send is judged as the core: not before the settle time, and three // decision 4): the leaf beside the node tools has its one reading at once, but the send is judged by the tool
// readings. // runner's rule: one reading, not before the node-engine's witness window for it has ended.
func TestASendCarryingTheCoreIsJudgedAsTheCore(t *testing.T) { func TestASendCarryingTheToolRunnerWaitsForItsWitnessWindow(t *testing.T) {
tm := aTierMesh(t, broker.RuntimeModule, "app1") tm := aTierMesh(t, broker.RuntimeModule, "app1")
ctx := t.Context() ctx := t.Context()
// One reading counted per advance, as every test with a gate left open does: a gate judged with no time // One reading counted per advance, as every test with a gate left open does: a gate judged with no time
// between readings would count a pass at every look and never let an advance rest. // between readings would count a pass at every look and never let an advance rest.
gateEvery, gateSettle = time.Hour, time.Hour gateEvery, gateWitnessWindow = time.Hour, time.Hour
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
advancePlans(ctx, tm.open) advancePlans(ctx, tm.open)
} }
@@ -457,10 +467,10 @@ func TestASendCarryingTheCoreIsJudgedAsTheCore(t *testing.T) {
if lead == nil { if lead == nil {
lead = p.Modules["app1"].Gate lead = p.Modules["app1"].Gate
} }
if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}}) || !p.Open() || lead == nil || lead.Verdict != "" || lead.Wants != gatePasses { if !reflect.DeepEqual(tm.sent, [][]string{{"anchor"}}) || !p.Open() || lead == nil || lead.Verdict != "" || lead.Wants != 1 {
t.Fatalf("passed before the core's settle time: sent %v, plan %s (%s), gate %+v", tm.sent, p.State, p.Note, lead) t.Fatalf("passed inside the witness window: sent %v, plan %s (%s), gate %+v", tm.sent, p.State, p.Note, lead)
} }
gateEvery, gateSettle = 0, 0 gateEvery, gateWitnessWindow = 0, 0
for i := 0; i < 6; i++ { for i := 0; i < 6; i++ {
advancePlans(ctx, tm.open) advancePlans(ctx, tm.open)
} }
@@ -469,8 +479,9 @@ func TestASendCarryingTheCoreIsJudgedAsTheCore(t *testing.T) {
t.Fatalf("the plan is %s (%s), sent %v", p.State, p.Note, tm.sent) t.Fatalf("the plan is %s (%s), sent %v", p.State, p.Note, tm.sent)
} }
for _, m := range []string{broker.RuntimeModule, "app1"} { for _, m := range []string{broker.RuntimeModule, "app1"} {
if g := p.Modules[m].Gate; g != nil && (g.Verdict != inventory.GatePassed || g.Passes < gatePasses) { if g := p.Modules[m].Gate; g != nil && (g.Verdict != inventory.GatePassed || g.Passes < 1 ||
t.Fatalf("%s's gate: %+v; want passed on %d readings", m, g, gatePasses) !strings.Contains(g.Why, "the tool runner's rule")) {
t.Fatalf("%s's gate: %+v; want passed on one reading by the tool runner's rule", m, g)
} }
} }
} }
+1 -1
View File
@@ -160,7 +160,7 @@ func TestTheVerbsCarryReadReplaceAndMove(t *testing.T) {
{"settings", map[string]any{"module": "plex", "history": "true"}, []string{"settings", "show", "plex", "--history"}}, {"settings", map[string]any{"module": "plex", "history": "true"}, []string{"settings", "show", "plex", "--history"}},
{"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}}, {"settings", map[string]any{"module": "plex", "values": "{}", "replace": "true"}, []string{"settings", "set", "plex", "{}", "--replace"}},
{"push", map[string]any{"node": "home", "move": "plex", "why": "w"}, {"push", map[string]any{"node": "home", "move": "plex", "why": "w"},
[]string{"push", "home", "--wait", "0", "--move", "plex", "--why", "w"}}, []string{"push", "home", "--wait", "0s", "--move", "plex", "--why", "w"}},
{"push", map[string]any{"why": "w"}, []string{"push", "--behind", "--wait", "0", "--why", "w"}}, {"push", map[string]any{"why": "w"}, []string{"push", "--behind", "--wait", "0", "--why", "w"}},
{"plan", map[string]any{"node": "home", "diff": "true"}, []string{"plan", "home", "--diff"}}, {"plan", map[string]any{"node": "home", "diff": "true"}, []string{"plan", "home", "--diff"}},
} { } {
+2 -2
View File
@@ -3,7 +3,7 @@ module github.com/novox/mesh-controller
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44
github.com/jackc/pgx/v5 v5.10.0 github.com/jackc/pgx/v5 v5.10.0
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
@@ -36,4 +36,4 @@ require (
// committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and // committed. Every build (the build agent's `go build`, the Dockerfile) compiles from vendor/ and
// fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without // fetches nothing; go refuses to build when vendor/ and this file disagree, so a pin moved without
// `go mod vendor` fails loudly, at once, everywhere. // `go mod vendor` fails loudly, at once, everywhere.
replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f replace github.com/novox/mesh-host => git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc
+8 -2
View File
@@ -1,7 +1,13 @@
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f h1:8N5OW2mdTNIck2pe4EciTYX5NsrPsHrTLENGNIWYNTU= git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc h1:h5v2AbdjM/clMY1YxS9p7VuyYtIjkcw85w9Oz1cOYQc=
git.novox.be/novox/mesh-host v0.0.0-20261011092230-20d5af9b2d5f/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c= git.novox.be/novox/mesh-host v0.0.0-20261013220009-b9a293f8c8bc/go.mod h1:tcTK4LMs1d6JpZUwy3hSHMFG/MIuDr/XFs7/nbeaW/c=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c h1:l5onJwoIeH8yE/PjVlEeoPyXBsHp2NQiWLllz2fwX7s=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261010191001-33917f91ac3c/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629 h1:7TYFV1Q6Mcz+lsLP/sds87tysET4rKCn3rRIQV8gNmA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013141520-9588871d6629/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d h1:HEJyqorKktrkJPeeJdwEo0kdrJjH3fm/YVS9MGftCqY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013145622-f4745447ba6d/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44 h1:r5xIo5qWOXQdCSMxvqJy8kV4U+P/QWXFK6971Ha6wU8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013182924-5933eb757d44/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+91
View File
@@ -0,0 +1,91 @@
package artifacts
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
)
// Listing and letting go of tagged documents: the check builds the scratch namespace keeps (novox/hq ADR 0350
// §5). A check build is found by its tag, and the build seat's holder lets each go when its pull request closes
// or it is seven days old, so the store keeps a week of check builds and no more.
// Tags is every tag repository holds, in the store's order. None for a repository the store does not hold.
func (s Store) Tags(ctx context.Context, repository string) ([]string, error) {
if s.Address == "" {
return nil, fmt.Errorf("this mesh has no artifact store on its network to list %s in", repository)
}
var tags []string
next := "http://" + s.Address + "/v2/" + repository + "/tags/list?n=1000"
for next != "" {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil)
if err != nil {
return nil, err
}
response, err := s.client().Do(request)
if err != nil {
return nil, fmt.Errorf("cannot reach the artifact store at %s: %w", s.Address, err)
}
switch response.StatusCode {
case http.StatusOK:
case http.StatusNotFound:
response.Body.Close()
return tags, nil
default:
response.Body.Close()
return nil, fmt.Errorf("the artifact store answered %s for the tags of %s", response.Status, repository)
}
var page struct {
Tags []string `json:"tags"`
}
err = json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(&page)
link := response.Header.Get("Link")
response.Body.Close()
if err != nil {
return nil, fmt.Errorf("the tags of %s could not be read: %w", repository, err)
}
tags = append(tags, page.Tags...)
next = nextPage(link, s.Address)
}
return tags, nil
}
// nextPage is the registry's `Link: </v2/…>; rel="next"`, made whole; empty when there is no next page.
func nextPage(link, address string) string {
if !strings.Contains(link, `rel="next"`) {
return ""
}
start, end := strings.Index(link, "<"), strings.Index(link, ">")
if start < 0 || end <= start+1 {
return ""
}
target := link[start+1 : end]
if strings.HasPrefix(target, "/") {
return "http://" + address + target
}
return target
}
// DeleteTagged lets go of the document tag names in repository: its manifest, by digest, so the next
// collection takes its layer. Gone when the tag names nothing.
func (s Store) DeleteTagged(ctx context.Context, repository, tag string) error {
if s.Address == "" {
return fmt.Errorf("this mesh has no artifact store on its network to let go of %s:%s in", repository, tag)
}
digest, err := s.manifestDigest(ctx, repository, tag)
if err != nil {
return err
}
if digest == "" {
return Gone
}
err = s.remove(ctx, s.url(repository, "manifests", digest), repository+":"+tag)
if errors.Is(err, Gone) {
return Gone
}
return err
}
+192 -3
View File
@@ -91,6 +91,16 @@ type Result struct {
// (novox/hq issue 280). // (novox/hq issue 280).
Source string Source string
// ReuseKey is the build's reuse key (novox/hq ADR 0350 §1): its source fingerprint's parts with the builder's
// version, and ReuseParts those parts; empty, with NoKey saying why, where the source does not pin the build.
ReuseKey string
ReuseParts map[string]string
NoKey string
// Reused is the check build a walk took instead of building (ADR 0350 §2), and Why, when it built, why it did
// not take one (§3). Both empty for a build not asked to reuse.
Reused *Reused
Why string
// Sources are what this build was made from, as files (novox/hq ADR 0267 rule 1): per repository, the // Sources are what this build was made from, as files (novox/hq ADR 0267 rule 1): per repository, the
// entries a changed file is tested against (SourceHolds). The module's own repository has an empty // entries a changed file is tested against (SourceHolds). The module's own repository has an empty
// Repository. Said only for a build of a commit on the trunk, and only for a repository whose every // Repository. Said only for a build of a commit on the trunk, and only for a repository whose every
@@ -136,14 +146,44 @@ func Build(ctx context.Context, run Runner, publish Publisher,
// one record that it is there. // one record that it is there.
var mirrored []string var mirrored []string
result, err := build(ctx, run, publish, repository, path, ref, workspace, held, npmrc, forge, log, result, err := build(ctx, run, publish, repository, path, ref, workspace, held, npmrc, forge, log,
&mirrored, seats...) &mirrored, nil, seats...)
result.Mirrored = mirrored
return result, err
}
// Reuse is what a walk's build may take instead of building (novox/hq ADR 0350 §2): the scratch namespace's check
// builds, and the pull requests the walk answers, whose checks name a differing key.
type Reuse struct {
Scratch Scratch
Pulls []PullRef
// Named are the builds the verdicts of the walk's own pull requests name (novox/hq ADR 0350 §2): a scratch build
// is taken only when one of them names its key and every digest. The record in the store alone is never enough.
Named []NamedBuild
// keyFirst is a check's build (§1): its key is taken before anything is built, and a module with none is not
// built at all — nothing could ever take it.
keyFirst bool
}
// errNoKey is a check's build of a module whose source does not pin it: not built (ADR 0350 §1).
var errNoKey = errors.New("it has no reuse key")
// BuildReusing is Build for the walk of a trunk commit (novox/hq ADR 0350 §2): before building, it takes the reuse
// key of the commit's tree, and where a check build holds the same key it copies that build into the module's
// namespace by digest and answers with it, building nothing. Otherwise it builds, and Result.Why says why (§3).
// Only a commit on its trunk takes a check build: anything else builds, as Build does.
func BuildReusing(ctx context.Context, run Runner, publish Publisher, reuse Reuse,
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
forge GitCredential, log Log, seats ...map[string]string) (Result, error) {
var mirrored []string
result, err := build(ctx, run, publish, repository, path, ref, workspace, held, npmrc, forge, log,
&mirrored, &reuse, seats...)
result.Mirrored = mirrored result.Mirrored = mirrored
return result, err return result, err
} }
func build(ctx context.Context, run Runner, publish Publisher, func build(ctx context.Context, run Runner, publish Publisher,
repository, path, ref, workspace string, held map[string]string, npmrc Npmrc, repository, path, ref, workspace string, held map[string]string, npmrc Npmrc,
forge GitCredential, log Log, mirrored *[]string, seats ...map[string]string) (Result, error) { forge GitCredential, log Log, mirrored *[]string, reuse *Reuse, seats ...map[string]string) (Result, error) {
// The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers // The clone base of each seat a context may name (novox/hq ADR 0155); variadic so the callers
// that hand none — tests of everything but contexts — read as they did. // that hand none — tests of everything but contexts — read as they did.
var seatBases map[string]string var seatBases map[string]string
@@ -284,6 +324,9 @@ func build(ctx context.Context, run Runner, publish Publisher,
var built []catalogue.Built var built []catalogue.Built
// stoodOn is every base the build was handed, as resolved — the edges the catalogue derives. // stoodOn is every base the build was handed, as resolved — the edges the catalogue derives.
var stoodOn []string var stoodOn []string
// reused is the check build a walk took, and builtWhy why it built instead (novox/hq ADR 0350).
var reused *Reused
builtWhy := ""
if manifest.Build != nil { if manifest.Build != nil {
// What this module said it stands on, answered with what this mesh actually holds. Done // What this module said it stands on, answered with what this mesh actually holds. Done
// before anything is built, so a missing base is refused in front of the person who can // before anything is built, so a missing base is refused in front of the person who can
@@ -327,7 +370,35 @@ func build(ctx context.Context, run Runner, publish Publisher,
// Ordered, so two builds of one commit do the same work in the same sequence and their // Ordered, so two builds of one commit do the same work in the same sequence and their
// logs can be compared. // logs can be compared.
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name }) sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
// **A walk takes the check's build of the same key** (novox/hq ADR 0350 §2): only a commit on its trunk, and
// only when asked — a check, a dry run, `build --ref` and `rebuild` never are. Its key is taken by the same
// steps a build takes, stopped before anything is built; what it takes is copied by digest.
if reuse != nil && reuse.keyFirst {
_, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args,
held, npmrcPath, npmrc, seatBases, src, say)
if err == nil && key.Key == "" {
err = fmt.Errorf("%w: %s", errNoKey, key.Why)
}
if err != nil {
say("reuse", "not built for the merge: %v", err)
return Result{Commit: commit, NoKey: err.Error()}, err
}
} else if reuse != nil && reuse.Scratch != nil {
taken, keyed, why := takeACheckBuild(ctx, run, publish, *reuse, manifest, within, workspace, commit,
credentials, artifacts, args, held, npmrcPath, npmrc, seatBases, src, onTrunk, say)
if taken != nil {
built = taken.Made
src = keyed
reused = taken
} else {
builtWhy = why
say("reuse", "built: %s", why)
}
}
for _, a := range artifacts { for _, a := range artifacts {
if reused != nil {
break
}
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a)) say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, src, say) made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, src, say)
if err != nil { if err != nil {
@@ -363,9 +434,107 @@ func build(ctx context.Context, run Runner, publish Publisher,
say("source", "%d path(s) of %s", len(s.Paths), where) say("source", "%d path(s) of %s", len(s.Paths), where)
} }
} }
// The reuse key (novox/hq ADR 0350 §1): what a check keeps its build under, and what a walk takes one by.
key := keyOf(src, manifest, Version)
return Result{Manifest: resolved, Commit: commit, Built: built, return Result{Manifest: resolved, Commit: commit, Built: built,
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint, Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint,
Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources}, nil Trunk: trunk, OnTrunk: onTrunk, Branches: branches, Sources: sources,
ReuseKey: key.Key, ReuseParts: key.Parts, NoKey: key.Why, Reused: reused, Why: builtWhy}, nil
}
// takeACheckBuild is the walk's look for a check build of its key (novox/hq ADR 0350 §2, §3): the key taken by
// the steps a build takes, each artifact stopped before it is built; the build kept under it copied by digest into
// the module's namespace. Answers what it took and the source inputs its key was taken from, or why it builds. A
// copy that fails is said, and the walk builds (§3): it never fails the walk on its own.
func takeACheckBuild(ctx context.Context, run Runner, publish Publisher, reuse Reuse, manifest catalogue.Manifest,
within, workspace, commit, credentials string, artifacts []catalogue.Artifact, args []string,
held map[string]string, npmrcPath string, npmrc Npmrc, seats map[string]string, src *sourceInputs, onTrunk bool,
say func(step, format string, args ...any)) (*Reused, *sourceInputs, string) {
if !onTrunk {
return nil, nil, "the commit is not on its trunk, and only a trunk commit's walk takes a check's build"
}
keyed, key, err := keyFor(ctx, run, publish, manifest, within, workspace, commit, credentials, artifacts, args,
held, npmrcPath, npmrc, seats, src, say)
if err != nil {
return nil, nil, err.Error()
}
if key.Key == "" {
return nil, nil, "it has no reuse key: " + key.Why
}
found, ok, err := reuse.Scratch.Find(ctx, manifest.Module, key.Key)
if err != nil {
return nil, nil, "the scratch namespace could not be read: " + err.Error()
}
if !ok {
return nil, nil, notKeptWhy(ctx, reuse, manifest.Module, key)
}
// **Only a build a verdict names** (ADR 0350 §2): the store takes writes from anything on the network, so a
// record is believed only when a check verdict of the walk's own pull requests names its key and its digests.
if why := unnamed(reuse.Named, manifest.Module, key.Key, found); why != "" {
return nil, nil, why
}
taken := &Reused{From: found}
for _, made := range found.Made {
copied, err := reuse.Scratch.Take(ctx, manifest.Module, made)
if err != nil {
why := fmt.Sprintf("copying %s's %s from %s failed (%v)", manifest.Module, made.Name, found.Said(), err)
say("reuse", "%s; built instead", why)
return nil, nil, why
}
taken.Made = append(taken.Made, copied)
}
// Every artifact the manifest declares, or none: a check build missing one is not this build.
if len(taken.Made) != len(artifacts) {
return nil, nil, fmt.Sprintf("%s holds %d artifact(s) of %s, which declares %d", found.Said(), len(taken.Made),
manifest.Module, len(artifacts))
}
say("reuse", "%s", taken.Words())
return taken, keyed, ""
}
// keyFor takes a build's reuse key by the steps a build takes, each artifact stopped before anything is built or
// published (ADR 0350 §2): its own copy of the source inputs, so the build's are untouched.
func keyFor(ctx context.Context, run Runner, publish Publisher, manifest catalogue.Manifest,
within, workspace, commit, credentials string, artifacts []catalogue.Artifact, args []string,
held map[string]string, npmrcPath string, npmrc Npmrc, seats map[string]string, src *sourceInputs,
say func(step, format string, args ...any)) (*sourceInputs, reuseKey, error) {
keyed := src.copy()
keyed.onlyKey = true
for _, a := range artifacts {
if _, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held,
npmrcPath, npmrc, seats, keyed, say); err != nil {
return nil, reuseKey{}, fmt.Errorf("its reuse key could not be taken: %w", err)
}
}
keyed.onlyKey = false
return keyed, keyOf(keyed, manifest, Version), nil
}
// notKeptWhy is why no check build has the key (§3): no check of the walk's pull requests built the module, the
// key differs — naming the part — or the build it made is gone.
func notKeptWhy(ctx context.Context, reuse Reuse, module string, key reuseKey) string {
if len(reuse.Pulls) == 0 {
return "no check built it with this key"
}
builds, err := reuse.Scratch.OfPulls(ctx, reuse.Pulls)
if err != nil {
return "no check build has this key, and which checks built it could not be read: " + err.Error()
}
for _, b := range builds {
if b.Module != module {
continue
}
if b.Key == key.Key {
return b.Said() + " built it with this key, and it was removed"
}
return "the key differs from " + b.Said() + "'s in " + strings.Join(differing(b.Parts, key.Parts), ", ")
}
var pulls []string
for _, p := range reuse.Pulls {
pulls = append(pulls, p.String())
}
return "no check of " + strings.Join(pulls, ", ") + " built it (a module its check did not move, or a check " +
"that did not finish)"
} }
// branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`. // branchesHolding is every branch of a fresh clone's origin the commit is on, without `origin/`.
@@ -754,6 +923,10 @@ func one(ctx context.Context, run Runner, publish Publisher,
switch a.Kind { switch a.Kind {
case catalogue.ArtifactUpstream: case catalogue.ArtifactUpstream:
// Its key is the image it copies, read from the manifest (keyOf): nothing to take here.
if src.keyOnly() {
return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil
}
// Mirrored, not built: copied under a name of the mesh's own, so what a machine fetches is // Mirrored, not built: copied under a name of the mesh's own, so what a machine fetches is
// pinned by a digest this registry assigned rather than by a tag somebody else can move. // pinned by a digest this registry assigned rather than by a tag somebody else can move.
// //
@@ -882,6 +1055,10 @@ func one(ctx context.Context, run Runner, publish Publisher,
} else { } else {
src.ownWhole() src.ownWhole()
} }
// Taken for a reuse key (novox/hq ADR 0350 §2): everything its fingerprint reads is read; nothing is built.
if src.keyOnly() {
return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil
}
invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...) invocation := append([]string{"build", "-f", recipePath, "-t", local}, args...)
if a.Target != "" { if a.Target != "" {
invocation = append(invocation, "--target", a.Target) invocation = append(invocation, "--target", a.Target)
@@ -949,6 +1126,9 @@ func one(ctx context.Context, run Runner, publish Publisher,
src.notPinned(a.Name + " resolves packages of its own at build time") src.notPinned(a.Name + " resolves packages of its own at build time")
} }
} }
if src.keyOnly() {
return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil
}
// The module's own packages first, where the compiler and the bundler resolve them from // The module's own packages first, where the compiler and the bundler resolve them from
// (dependencies.go); nothing at all for a module whose package.json names only the SDK. // (dependencies.go); nothing at all for a module whose package.json names only the SDK.
if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil { if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil {
@@ -992,6 +1172,9 @@ func one(ctx context.Context, run Runner, publish Publisher,
say("package", "building and publishing %s (%s)", a.Name, a.Language) say("package", "building and publishing %s (%s)", a.Name, a.Language)
src.ownWhole() src.ownWhole()
src.notPinned(a.Name + " is a package, built from what the registry holds when it is built") src.notPinned(a.Name + " is a package, built from what the registry holds when it is built")
if src.keyOnly() {
return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil
}
reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say) reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say)
if err != nil { if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: publishing %s failed: %w", module, a.Name, err) return catalogue.Built{}, fmt.Errorf("%s: publishing %s failed: %w", module, a.Name, err)
@@ -1000,6 +1183,12 @@ func one(ctx context.Context, run Runner, publish Publisher,
return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil return catalogue.Built{Name: a.Name, Kind: a.Kind, Reference: reference}, nil
case catalogue.ArtifactArchive: case catalogue.ArtifactArchive:
if src.keyOnly() {
if a.URL == "" {
src.ownHas(cleanEntry(a.From) + "/**")
}
return catalogue.Built{Name: a.Name, Kind: a.Kind}, nil
}
var body []byte var body []byte
var err error var err error
if a.URL != "" { if a.URL != "" {
+36
View File
@@ -98,6 +98,11 @@ type CheckSpec struct {
// and composed with it by the gate as one future state. The repository's own check is not run for a // and composed with it by the gate as one future state. The repository's own check is not run for a
// group: each member's pull request runs its own. // group: each member's pull request runs its own.
Group []GroupHead Group []GroupHead
// Builds are the modules the delivery plan moves, in the walk's order, and BuildWith what they are built with
// (novox/hq ADR 0350 §1): built from the merge the check judged into the scratch namespace once it has judged,
// for the walk to take. None for a group's composed check, whose members' own checks build.
Builds []CheckBuildSpec
BuildWith *CheckBuilding
} }
// GroupHead is one other head of a delivery group's composed check. // GroupHead is one other head of a delivery group's composed check.
@@ -155,6 +160,8 @@ type CheckVerdict struct {
// Members are a delivery group's other heads as the composed check judged them, each with the trunk // Members are a delivery group's other heads as the composed check judged them, each with the trunk
// commit it was merged into (ADR 0323 §6). // commit it was merged into (ADR 0323 §6).
Members []JudgedHead Members []JudgedHead
// Built are the builds it made for its merge (novox/hq ADR 0350 §1), each with its key, or why not.
Built []CheckBuilt
} }
// Layer is one layer of a check, judged. // Layer is one layer of a check, judged.
@@ -572,9 +579,38 @@ func Check(ctx context.Context, run Runner, spec CheckSpec, workspace, registry
v.Report, v.Took = redact.redact(withWhatFailed(out.String(), v.Repo)), time.Since(began) v.Report, v.Took = redact.redact(withWhatFailed(out.String(), v.Repo)), time.Since(began)
say("check", "gate %s — %s; repository %s — %s (%s)", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary, say("check", "gate %s — %s; repository %s — %s (%s)", strings.ToUpper(v.Gate.Verdict), v.Gate.Summary,
strings.ToUpper(v.Repo.Verdict), v.Repo.Summary, v.Took.Round(time.Second)) strings.ToUpper(v.Repo.Verdict), v.Repo.Summary, v.Took.Round(time.Second))
// **What the merge would move, built once, here** (novox/hq ADR 0350 §1): after the judging, from the merge it
// judged, into the scratch namespace — so the walk of the merge commit takes it rather than building again.
if len(spec.Builds) > 0 && spec.BuildWith != nil && len(spec.Group) == 0 {
if why := skipsTheBuilds(v); why != "" {
say("check", "%s", why)
} else {
began := time.Now()
v.Built = buildForTheMerge(ctx, run, spec, *spec.BuildWith, workspace, tree, judged.Merge, judged, forge,
func(step, message string) {
if log != nil {
log(step, redact.redact(message))
}
}, say)
say("check", "built for the merge: %d of %d module(s) (%s)", builtCount(v.Built), len(v.Built),
time.Since(began).Round(time.Second))
}
}
return named(v), nil return named(v), nil
} }
// builtCount is how many of a check's builds for its merge were built.
func builtCount(built []CheckBuilt) int {
n := 0
for _, b := range built {
if b.NotBuilt == "" {
n++
}
}
return n
}
// ownCheck runs the repository's own check, part by part, each in its toolchain's own container: the // ownCheck runs the repository's own check, part by part, each in its toolchain's own container: the
// layer's verdict. Nil when the check was ended from outside (not past its bound), which the caller // layer's verdict. Nil when the check was ended from outside (not past its bound), which the caller
// answers as the context's error. // answers as the context's error.
+151
View File
@@ -0,0 +1,151 @@
package builder
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
)
// The builds a merge check makes for its merge (novox/hq ADR 0350 §1): every artifact of every module the delivery
// plan moves, built from the merged tree the check judged into the scratch namespace, kept under its reuse key, so
// the walk of the merge commit takes it instead of building again. Built after the check has judged, and only when
// it did not fail: a change that fails is not merged, and nothing it built would be taken.
// CheckBuildSpec is one module a check builds for its merge, in the walk's order (Tier).
type CheckBuildSpec struct {
Module string
// Repository, Path and Ref are where it is built from; Merged says from the merge the check made instead — the
// module is built from the repository checked, at the branch merged into.
Repository string
Path string
Ref string
Merged bool
Tier int
// Skip says why it cannot be built as the walk would build it: not built, and nothing after it.
Skip string
}
// CheckBuilding is what a check builds with: where it publishes and keeps its builds, and what a build is handed.
type CheckBuilding struct {
Publish Publisher
Scratch Scratch
Held map[string]string
Npmrc Npmrc
Seats map[string]string
}
// CheckBuilt is one module a check built for its merge, as its verdict lists it (§1): its key and its artifacts, or
// why it was not built.
type CheckBuilt struct {
Module string
Key string
Made []catalogue.Built
// NotBuilt says why it was not: no key, a build that failed, or a module after one of those.
NotBuilt string
Took time.Duration
}
// skipsTheBuilds is why a check builds nothing for its merge; empty when it builds.
func skipsTheBuilds(v CheckVerdict) string {
for _, l := range []*Layer{v.Gate, v.Repo} {
if l != nil && (l.Verdict == "fail" || l.Verdict == "error") {
return "the check did not pass, so nothing is built for its merge"
}
}
return ""
}
// buildForTheMerge builds each module the plan moves, tier by tier, into the scratch namespace, and keeps what it
// built under its keys. A module standing after one not built is not built either: what it stands on in the walk
// is not what it would stand on here. tree is the check's clone and merge the merge commit it made there.
func buildForTheMerge(ctx context.Context, run Runner, spec CheckSpec, with CheckBuilding, workspace, tree, merge string,
judged CheckVerdict, forge GitCredential, log Log, say func(step, format string, args ...any)) []CheckBuilt {
if len(spec.Builds) == 0 || with.Publish == nil || with.Scratch == nil {
return nil
}
ref := merge
if ref == "" {
ref = spec.Ref
}
held := map[string]string{}
for k, v := range with.Held {
held[k] = v
}
var out []CheckBuilt
var kept []CheckBuild
stopAt, stopWhy := -1, ""
for _, b := range spec.Builds {
began := time.Now()
if stopAt >= 0 && b.Tier > stopAt {
out = append(out, CheckBuilt{Module: b.Module, NotBuilt: stopWhy})
continue
}
if b.Skip != "" {
say("build", "%s not built for the merge: %s", b.Module, b.Skip)
out = append(out, CheckBuilt{Module: b.Module, NotBuilt: b.Skip})
if stopAt < 0 || b.Tier < stopAt {
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
}
continue
}
repository, at := b.Repository, b.Ref
if b.Merged {
repository, at = tree, ref
}
say("build", "for the merge: %s (tier %d)", b.Module, b.Tier)
var mirrored []string
result, err := build(ctx, run, scratchPublisher{to: with.Publish}, repository, b.Path, at,
filepath.Join(workspace, "for-the-merge"), held, with.Npmrc, forge, log, &mirrored, &Reuse{keyFirst: true},
with.Seats)
built := CheckBuilt{Module: b.Module, Key: result.ReuseKey, Took: time.Since(began)}
switch {
case errors.Is(err, errNoKey):
built.NotBuilt = err.Error()
case err != nil:
built.NotBuilt = "its build failed: " + err.Error()
case result.ReuseKey == "":
built.NotBuilt = "it has no reuse key: " + result.NoKey
}
if built.NotBuilt != "" {
say("build", "%s not built for the merge: %s", b.Module, built.NotBuilt)
out = append(out, built)
if stopAt < 0 || b.Tier < stopAt {
stopAt, stopWhy = b.Tier, "it stands after "+b.Module+", which was not built for the merge"
}
continue
}
built.Made = result.Built
for _, m := range result.Built {
held[b.Module+"/"+m.Name] = m.Reference
}
say("build", "%s built for the merge under %s (%s)", b.Module, twelve(result.ReuseKey[3:]), built.Took.Round(time.Second))
out = append(out, built)
if len(result.Built) > 0 {
kept = append(kept, CheckBuild{Key: result.ReuseKey, Module: b.Module, Parts: result.ReuseParts,
Made: result.Built, Pull: PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number},
Head: judged.Head, Trunk: judged.Trunk, Check: spec.ID, At: time.Now().UTC()})
}
}
_ = os.RemoveAll(filepath.Join(workspace, "for-the-merge"))
if len(kept) > 0 {
if err := with.Scratch.Keep(ctx, PullRef{Owner: spec.Owner, Repo: spec.Repo, Number: spec.Number}, spec.ID,
time.Now().UTC(), kept); err != nil {
// Built and not findable: the walk builds, and says no check built it. Said, never the check's verdict.
say("build", "the builds for the merge could not be kept, and the walk will build: %v", err)
if err := with.Scratch.Discard(ctx, kept); err != nil {
say("build", "and what they made could not all be let go of: %v", err)
}
for i := range out {
if out[i].NotBuilt == "" {
out[i].NotBuilt = fmt.Sprintf("built, and could not be kept: %v", err)
}
}
}
}
return out
}
+608
View File
@@ -0,0 +1,608 @@
package builder
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/novox/mesh-controller/internal/artifacts"
"github.com/novox/mesh-controller/internal/catalogue"
)
// One build per change (novox/hq ADR 0350).
//
// **The merge check builds what the merge would move** (§1): every artifact of every module the delivery plan
// moves, from the merged tree it checks, into the artifact store's scratch namespace — never a module's own —
// kept under a **reuse key**: the source fingerprint as the build takes it (the module's tree, its contexts' trees,
// its bases and toolchains by digest; issue 280) with the builder's own version and, for an upstream artifact, the
// image it copies. **The walk of a trunk commit takes that build** (§2): before building, it takes the reuse key
// of the trunk commit's tree the same way, and where a check build holds the same key it copies it by digest into
// the module's namespace and answers with that, building nothing. Otherwise it builds and says why (§3): no check
// built it, the key differs (naming the part), or the check build is gone; a copy that fails is said and built.
//
// **What the key names is what a fresh build could differ by.** The tree is every file the recipe and the
// compiler see; the contexts are the other repositories it reads, by tree; the bases and toolchains are what it
// stands on, by digest; the builder's version is how it is built. A build whose source does not pin it — one that
// resolves packages from the mesh's registry when it is built, or copies an upstream image named by tag — has no
// key, and is always built.
// Version is this builder's own version, as the reuse key takes it: the digest of the running executable, so any
// change to how it builds is a change of key (§1). Set once by the build seat's holder (SelfVersion); empty — no
// key, every build its own — when it cannot be said.
var Version string
// SelfVersion is the version the reuse key takes: the running executable's digest, or the version stamped at build
// when the executable cannot be read. Empty when neither says anything ("development" says nothing: every unstamped
// builder would read as one).
func SelfVersion(stamped string) string {
if exe, err := os.Executable(); err == nil {
if f, err := os.Open(exe); err == nil {
defer f.Close()
sum := sha256.New()
if _, err := io.Copy(sum, f); err == nil {
return "exe:sha256:" + hex.EncodeToString(sum.Sum(nil))
}
}
}
if stamped == "" || stamped == "development" {
return ""
}
return stamped
}
// ScratchPrefix is the namespace a check builds into (novox/hq ADR 0239 §9, ADR 0350 §1): `scratch/<module>/<artifact>`.
// Registration refuses a reference into it; only the walk's copy, in the module's own namespace, is registered.
const ScratchPrefix = "scratch/"
// checksRepository holds one document per check that built: which builds it made, of which pull request, when.
// What the holder lets go of by (§5).
const checksRepository = ScratchPrefix + "checks"
// reuseTagPrefix names a check build's record under its module's scratch repository: `r1-<hex>`.
const reuseTagPrefix = "r1-"
// CheckBuildsKept is how long a check build is kept in the scratch namespace (§5).
const CheckBuildsKept = 7 * 24 * time.Hour
// The parts of a reuse key, as the walk names the one that differs (§3).
const (
partModule = "the module"
partTree = "the build source"
partBases = "a base image"
partBuilder = "the builder's version"
partContext = "a context"
partTool = "a toolchain"
partUpstream = "an upstream image"
)
// reuseKey is a build's reuse key and the parts it was made from; Key empty, with why, when it has none.
type reuseKey struct {
Key string
Parts map[string]string
Why string
}
// keyOf is the reuse key of what src says the build was made from, built by version (ADR 0350 §1).
func keyOf(src *sourceInputs, manifest catalogue.Manifest, version string) reuseKey {
if src == nil || src.unpinned != "" || src.tree == "" {
why := "its source does not pin the build"
if src != nil && src.unpinned != "" {
why = src.unpinned
} else if src != nil && src.tree == "" {
why = "its tree could not be named"
}
return reuseKey{Why: why}
}
if version == "" {
return reuseKey{Why: "the builder's version cannot be said"}
}
parts := map[string]string{partModule: src.module, partTree: src.tree, partBuilder: version}
var bases []string
seen := map[string]bool{}
for _, b := range src.bases {
if d := referenceDigest(b); !seen[d] {
seen[d] = true
bases = append(bases, d)
}
}
sort.Strings(bases)
parts[partBases] = strings.Join(bases, " ")
for a, t := range src.contexts {
parts[partContext+" "+a] = t
}
for a, t := range src.toolchains {
parts[partTool+" "+a] = t
}
// An upstream artifact is copied, not built: what it copies is in module.json, and so in the tree — but a tag
// names whatever its registry says today, so only an image pinned by digest is the same image tomorrow.
if manifest.Build != nil {
for _, a := range manifest.Build.Artifacts {
if a.Kind != catalogue.ArtifactUpstream {
continue
}
if !strings.Contains(a.From, "@sha256:") {
return reuseKey{Why: fmt.Sprintf("%s copies %s, named by tag, which can move", a.Name, a.From)}
}
parts[partUpstream+" "+a.Name] = a.From
}
}
lines := make([]string, 0, len(parts))
for k, v := range parts {
lines = append(lines, k+"="+v)
}
sort.Strings(lines)
sum := sha256.Sum256([]byte(strings.Join(lines, "\n")))
return reuseKey{Key: "r1:" + hex.EncodeToString(sum[:]), Parts: parts}
}
// tagOf is the tag a key's record is kept under.
func tagOf(key string) string { return reuseTagPrefix + strings.TrimPrefix(key, "r1:") }
// differing names the parts of two keys that differ, in a fixed order: what the walk says when it builds (§3).
func differing(was, now map[string]string) []string {
names := map[string]bool{}
for k := range was {
names[k] = true
}
for k := range now {
names[k] = true
}
var out []string
for k := range names {
if was[k] != now[k] {
out = append(out, k)
}
}
sort.Strings(out)
return out
}
// PullRef names a pull request.
type PullRef struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
}
func (p PullRef) String() string { return fmt.Sprintf("%s/%s#%d", p.Owner, p.Repo, p.Number) }
// slug is the pull request as a tag's first part: owner.repo.number, every other character a dash.
func (p PullRef) slug() string {
clean := func(s string) string {
return strings.Map(func(r rune) rune {
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-' {
return r
}
return '-'
}, s)
}
return clean(p.Owner) + "." + clean(p.Repo) + "." + strconv.Itoa(p.Number)
}
// CheckBuild is one module a check built into the scratch namespace, kept under its reuse key (§1).
type CheckBuild struct {
Key string `json:"key"`
Module string `json:"module"`
Parts map[string]string `json:"parts"`
// Made are its artifacts, in the scratch namespace.
Made []catalogue.Built `json:"made"`
// Pull is the pull request checked, Head its head and Trunk the trunk commit it was merged into; Check the
// check's ask; At when it was built.
Pull PullRef `json:"pull"`
Head string `json:"head,omitempty"`
Trunk string `json:"trunk,omitempty"`
Check string `json:"check"`
At time.Time `json:"at"`
}
// Said is the check build as the walk names it: "the check of owner/repo#n at head H (trunk T)".
func (c CheckBuild) Said() string {
s := "the check of " + c.Pull.String()
if c.Head != "" {
s += " at head " + twelve(c.Head)
}
if c.Trunk != "" {
s += " (merged into trunk " + twelve(c.Trunk) + ")"
}
return s
}
// checkIndex is one check's document under checksRepository: every build it made.
type checkIndex struct {
Pull PullRef `json:"pull"`
Check string `json:"check"`
At time.Time `json:"at"`
Builds []CheckBuild `json:"builds"`
}
// Scratch is where check builds are kept and taken from: the artifact store's scratch namespace (ADR 0350). The
// Registry is one; a test's is a map.
type Scratch interface {
// Keep records a check's builds: each under its module and key, and the check's own document.
Keep(ctx context.Context, pull PullRef, check string, at time.Time, builds []CheckBuild) error
// Find is the check build kept under a module's key; false when none is.
Find(ctx context.Context, module, key string) (CheckBuild, bool, error)
// OfPulls are the check builds kept for those pull requests, newest first: what the walk names a differing
// key from.
OfPulls(ctx context.Context, pulls []PullRef) ([]CheckBuild, error)
// Take copies one artifact of a check build by digest into the module's own namespace, and answers it there.
Take(ctx context.Context, module string, made catalogue.Built) (catalogue.Built, error)
// Forget lets go of every check build of a pull request, and Expire of every check older than the bound;
// each answers how many checks it let go of.
Forget(ctx context.Context, pull PullRef) (int, error)
Expire(ctx context.Context, older time.Duration, now time.Time) (int, error)
// Discard lets go of what builds a check could not keep made: nothing would ever find them.
Discard(ctx context.Context, builds []CheckBuild) error
}
// scratchPublisher is a Publisher into the scratch namespace: what a check builds is never under a module's
// own name (ADR 0239 §9). A base the build stands on is copied where every build copies it.
type scratchPublisher struct{ to Publisher }
func (s scratchPublisher) PublishImage(ctx context.Context, localTag, repository string) (string, error) {
return s.to.PublishImage(ctx, localTag, ScratchPrefix+repository)
}
func (s scratchPublisher) PublishArchive(ctx context.Context, repository string, body []byte, digest string) (string, error) {
return s.to.PublishArchive(ctx, ScratchPrefix+repository, body, digest)
}
func (s scratchPublisher) MirrorImage(ctx context.Context, from, repository string) (string, error) {
m, can := s.to.(Mirrorer)
if !can {
return "", errors.New("the artifact store here cannot copy an upstream image")
}
return m.MirrorImage(ctx, from, ScratchPrefix+repository)
}
func (s scratchPublisher) MirrorBase(ctx context.Context, from, repository, former string) (string, error) {
if m, can := s.to.(BaseMirrorer); can {
return m.MirrorBase(ctx, from, repository, former)
}
if m, can := s.to.(Mirrorer); can {
return m.MirrorImage(ctx, from, repository)
}
return "", errors.New("the artifact store here cannot copy a base")
}
// The registry as the scratch namespace.
func (r Registry) store() artifacts.Store {
return artifacts.Store{Address: r.Address, HTTP: r.client()}
}
const recordMedia = "application/vnd.novox.mesh.check-build.v1+json"
// Keep puts each build's record under its module's scratch repository, by key, and the check's document.
func (r Registry) Keep(ctx context.Context, pull PullRef, check string, at time.Time, builds []CheckBuild) error {
store := r.store()
for _, b := range builds {
body, err := json.Marshal(b)
if err != nil {
return err
}
if _, err := store.PutTagged(ctx, ScratchPrefix+b.Module, tagOf(b.Key), recordMedia, body); err != nil {
return fmt.Errorf("keeping %s's check build: %w", b.Module, err)
}
}
body, err := json.Marshal(checkIndex{Pull: pull, Check: check, At: at, Builds: builds})
if err != nil {
return err
}
if _, err := store.PutTagged(ctx, checksRepository, pull.slug()+"."+check, recordMedia, body); err != nil {
return fmt.Errorf("keeping which builds the check of %s made: %w", pull, err)
}
return nil
}
// Find reads the record kept under a module's key.
func (r Registry) Find(ctx context.Context, module, key string) (CheckBuild, bool, error) {
body, _, err := r.store().GetTagged(ctx, ScratchPrefix+module, tagOf(key))
if errors.Is(err, artifacts.ErrNoTag) {
return CheckBuild{}, false, nil
}
if err != nil {
return CheckBuild{}, false, err
}
var b CheckBuild
if err := json.Unmarshal(body, &b); err != nil {
return CheckBuild{}, false, fmt.Errorf("%s's check build under %s is not one: %w", module, tagOf(key), err)
}
if b.Key != key || b.Module != module {
return CheckBuild{}, false, nil
}
return b, true, nil
}
// checks are the check documents kept, by tag; only those of the pull requests named when any are.
func (r Registry) checks(ctx context.Context, pulls []PullRef) (map[string]checkIndex, error) {
store := r.store()
tags, err := store.Tags(ctx, checksRepository)
if err != nil {
return nil, err
}
wanted := map[string]PullRef{}
for _, p := range pulls {
wanted[p.slug()+"."] = p
}
out := map[string]checkIndex{}
for _, tag := range tags {
if len(pulls) > 0 {
match := false
for prefix := range wanted {
match = match || strings.HasPrefix(tag, prefix)
}
if !match {
continue
}
}
body, _, err := store.GetTagged(ctx, checksRepository, tag)
if errors.Is(err, artifacts.ErrNoTag) {
continue
}
if err != nil {
return nil, err
}
var c checkIndex
if err := json.Unmarshal(body, &c); err != nil {
continue // not a document this wrote: left to nobody, said by nothing — it names no build
}
if len(pulls) > 0 {
if p := wanted[c.Pull.slug()+"."]; p != c.Pull {
continue // a slug two pull requests share: only the one named
}
}
out[tag] = c
}
return out, nil
}
// OfPulls are the check builds of those pull requests, newest first.
func (r Registry) OfPulls(ctx context.Context, pulls []PullRef) ([]CheckBuild, error) {
if len(pulls) == 0 {
return nil, nil
}
checks, err := r.checks(ctx, pulls)
if err != nil {
return nil, err
}
var out []CheckBuild
for _, c := range checks {
out = append(out, c.Builds...)
}
sort.SliceStable(out, func(i, j int) bool { return out[i].At.After(out[j].At) })
return out, nil
}
// Take copies one artifact of a check build into the module's namespace by digest: an image's manifests as they
// are, so the digest registered is the digest the check built; an archive's blob mounted across, and held.
func (r Registry) Take(ctx context.Context, module string, made catalogue.Built) (catalogue.Built, error) {
repository := module + "/" + made.Name
from := ScratchPrefix + repository
if made.Digest != "" || strings.Contains(made.Reference, "/blobs/") {
digest := made.Digest
if digest == "" {
_, digest, _ = strings.Cut(made.Reference, "/blobs/")
}
where := upstream{base: "http://" + r.Address, repository: from, reference: digest}
if err := r.copyBlob(ctx, &source{client: r.client(), mountFrom: from}, where, digest, repository); err != nil {
return catalogue.Built{}, err
}
size, err := r.blobSize(ctx, repository, digest)
if err != nil {
return catalogue.Built{}, err
}
if err := r.hold(ctx, repository, digest, int(size)); err != nil {
return catalogue.Built{}, err
}
return catalogue.Built{Name: made.Name, Kind: made.Kind, Digest: digest, Launchers: made.Launchers,
Reference: "http://" + r.Address + "/v2/" + repository + "/blobs/" + digest}, nil
}
_, digest, pinned := strings.Cut(made.Reference, "@")
if !pinned || !strings.HasPrefix(digest, "sha256:") {
return catalogue.Built{}, fmt.Errorf("%s's check build %s is not pinned by digest", module, made.Reference)
}
reference, err := r.MirrorBase(ctx, r.Address+"/"+from+"@"+digest, repository, from)
if err != nil {
return catalogue.Built{}, err
}
if !strings.HasSuffix(reference, "@"+digest) {
return catalogue.Built{}, fmt.Errorf("%s's check build was copied as %s, not as %s", module, reference, digest)
}
return catalogue.Built{Name: made.Name, Kind: made.Kind, Reference: reference, Launchers: made.Launchers}, nil
}
// blobSize is the size the store says a blob has.
func (r Registry) blobSize(ctx context.Context, repository, digest string) (int64, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodHead, "http://"+r.Address+"/v2/"+repository+"/blobs/"+digest, nil)
if err != nil {
return 0, err
}
response, err := r.client().Do(request)
if err != nil {
return 0, err
}
response.Body.Close()
if response.StatusCode != http.StatusOK || response.ContentLength < 0 {
return 0, fmt.Errorf("%s/blobs/%s: %s", repository, digest, response.Status)
}
return response.ContentLength, nil
}
// Forget lets go of every check build of a pull request (§5): what each check built in the scratch namespace, the
// record under its key when it is still that check's, and the check's document.
func (r Registry) Forget(ctx context.Context, pull PullRef) (int, error) {
all, err := r.checks(ctx, nil)
if err != nil {
return 0, err
}
mine := map[string]checkIndex{}
for tag, c := range all {
if c.Pull == pull {
mine[tag] = c
}
}
return r.letGo(ctx, mine, all)
}
// Expire lets go of every check older than the bound (§5).
func (r Registry) Expire(ctx context.Context, older time.Duration, now time.Time) (int, error) {
checks, err := r.checks(ctx, nil)
if err != nil {
return 0, err
}
old := map[string]checkIndex{}
for tag, c := range checks {
if now.Sub(c.At) > older {
old[tag] = c
}
}
return r.letGo(ctx, old, checks)
}
// Discard lets go of builds no check document names.
func (r Registry) Discard(ctx context.Context, builds []CheckBuild) error {
all, err := r.checks(ctx, nil)
if err != nil {
return err
}
_, err = r.letGo(ctx, map[string]checkIndex{"": {Builds: builds}}, all)
return err
}
// letGo lets go of each check's builds, then its document; a check whose builds could not all be let go of keeps
// its document, so the next sweep asks again. **A build another check still names stays**: deletion is by digest,
// and two checks that built one digest in one repository — a check asked again, a dependent two pull requests move —
// share it; it goes with the last check that names it. all are every check kept, the ones going among them.
func (r Registry) letGo(ctx context.Context, checks, all map[string]checkIndex) (int, error) {
store := r.store()
stays := map[string]bool{}
for tag, c := range all {
if _, going := checks[tag]; going {
continue
}
for _, b := range c.Builds {
for _, m := range b.Made {
stays[catalogue.Recorded(m.Reference)] = true
}
}
}
gone := 0
var failed []string
for tag, c := range checks {
ok := true
for _, b := range c.Builds {
for _, m := range b.Made {
reference := catalogue.Recorded(m.Reference)
if path, kept := catalogue.InArtifactStore(reference); !kept || !strings.HasPrefix(path, ScratchPrefix) {
continue // never anything outside the scratch namespace
}
if stays[reference] {
continue // another check still names it
}
if err := store.LetGo(ctx, reference); err != nil && !errors.Is(err, artifacts.Gone) {
ok = false
failed = append(failed, fmt.Sprintf("%s: %v", m.Reference, err))
}
}
// The record under the key, only while it is still this check's: a later check of the same key
// replaced it, and that one is its own check's to let go of.
if kept, found, err := r.Find(ctx, b.Module, b.Key); err == nil && found && kept.Check == c.Check {
if err := store.DeleteTagged(ctx, ScratchPrefix+b.Module, tagOf(b.Key)); err != nil && !errors.Is(err, artifacts.Gone) {
ok = false
failed = append(failed, fmt.Sprintf("%s's record: %v", b.Module, err))
}
}
}
if !ok {
continue
}
if tag == "" {
gone++
continue
}
if err := store.DeleteTagged(ctx, checksRepository, tag); err != nil && !errors.Is(err, artifacts.Gone) {
failed = append(failed, fmt.Sprintf("%s: %v", tag, err))
continue
}
gone++
}
if len(failed) > 0 {
sort.Strings(failed)
return gone, fmt.Errorf("not let go of: %s", strings.Join(failed, "; "))
}
return gone, nil
}
// Reused is what the walk took from a check instead of building (§2, §4).
type Reused struct {
From CheckBuild
// Made are the copies in the module's namespace: what is registered.
Made []catalogue.Built
}
// Words is the walk's word for a module it took: "reused from <check build>", with each digest (§4).
func (r Reused) Words() string {
var digests []string
for _, m := range r.Made {
d := m.Digest
if d == "" {
_, d, _ = strings.Cut(m.Reference, "@")
}
digests = append(digests, m.Name+" "+d)
}
return "reused from " + r.From.Said() + ": " + strings.Join(digests, ", ")
}
// NamedBuild is a build a check's verdict names (ADR 0350 §2): its module, key and each artifact's digest.
type NamedBuild struct {
Pull PullRef
Check string
Module string
Key string
Digests map[string]string
}
// DigestOf is the digest a reference names: an image's after `@`, an archive's after `/blobs/`.
func DigestOf(reference string) string {
if _, d, ok := strings.Cut(reference, "/blobs/"); ok {
return d
}
if _, d, ok := strings.Cut(reference, "@"); ok {
return d
}
return ""
}
// unnamed is why a record found under a key is not taken: no verdict of the walk's pull requests names that module,
// key and every one of its digests. Empty when one does.
func unnamed(named []NamedBuild, module, key string, found CheckBuild) string {
for _, n := range named {
if n.Module != module || n.Key != key || n.Check != found.Check || len(n.Digests) != len(found.Made) {
continue
}
same := true
for _, m := range found.Made {
d := m.Digest
if d == "" {
d = DigestOf(m.Reference)
}
same = same && d != "" && n.Digests[m.Name] == d
}
if same {
return ""
}
}
return "a build is kept under this key, and no check verdict of the walk's pull requests names it with its " +
"digests: not taken"
}
+627
View File
@@ -0,0 +1,627 @@
package builder
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/novox/mesh-controller/internal/catalogue"
)
// One build per change (novox/hq ADR 0350): the merge check builds what the merge would move into the scratch
// namespace under its reuse key, and the walk of the trunk commit takes that build by digest instead of building.
// aScratch is the scratch namespace in memory: check builds by module and key, and what the walk took.
type aScratch struct {
builds map[string]CheckBuild
took []string
failTake bool
}
func newScratch() *aScratch { return &aScratch{builds: map[string]CheckBuild{}} }
func (s *aScratch) Keep(_ context.Context, _ PullRef, _ string, _ time.Time, builds []CheckBuild) error {
for _, b := range builds {
s.builds[b.Module+" "+b.Key] = b
}
return nil
}
func (s *aScratch) Find(_ context.Context, module, key string) (CheckBuild, bool, error) {
b, ok := s.builds[module+" "+key]
return b, ok, nil
}
func (s *aScratch) OfPulls(_ context.Context, pulls []PullRef) ([]CheckBuild, error) {
var out []CheckBuild
for _, b := range s.builds {
if slices.Contains(pulls, b.Pull) {
out = append(out, b)
}
}
return out, nil
}
func (s *aScratch) Take(_ context.Context, module string, made catalogue.Built) (catalogue.Built, error) {
if s.failTake {
return catalogue.Built{}, errors.New("the store refused the mount")
}
s.took = append(s.took, made.Reference)
made.Reference = strings.Replace(made.Reference, ScratchPrefix, "", 1)
return made, nil
}
func (s *aScratch) Forget(context.Context, PullRef) (int, error) { return 0, nil }
func (s *aScratch) Expire(context.Context, time.Duration, time.Time) (int, error) { return 0, nil }
func (s *aScratch) Discard(context.Context, []CheckBuild) error { return nil }
const reusedModule = `{"module":"meshboard","version":"1",
"build":{"artifacts":[
{"name":"server","kind":"image","from":"Dockerfile"},
{"name":"look","kind":"archive","from":"files"}]},
"resources":[
{"id":"svc","type":"container","name":"meshboard","artifact":"server"},
{"id":"theme","type":"archive","path":"/opt/meshboard","artifact":"look"}]}`
// aModuleAt is a repository holding the module under mod/, whose directory git names as tree, on its trunk.
func aModuleAt(t *testing.T, tree string) (*recorded, Runner, string) {
t.Helper()
r := &recorded{tree: tree, contents: map[string]string{"mod/" + ManifestName: reusedModule,
"mod/Dockerfile": "FROM scratch", "mod/files/theme.conf": "dark"}}
run := func(ctx context.Context, dir, name string, args ...string) (string, error) {
if name == "git" && len(args) > 0 && args[0] == "symbolic-ref" {
r.ran = append(r.ran, name+" "+strings.Join(args, " "))
return "origin/main\n", nil
}
return r.run(ctx, dir, name, args...)
}
return r, run, t.TempDir()
}
func dockerBuilt(r *recorded) bool {
for _, line := range r.ran {
if strings.HasPrefix(line, "docker build") {
return true
}
}
return false
}
var aPull = PullRef{Owner: "novox", Repo: "meshboard", Number: 7}
// aCheckBuilt is the check of aPull building the module from a tree, into the scratch namespace.
func aCheckBuilt(t *testing.T, scratch *aScratch, tree string) CheckBuilt {
t.Helper()
r, run, workspace := aModuleAt(t, tree)
spec := CheckSpec{ID: "build-1", Owner: aPull.Owner, Repo: aPull.Repo, Number: aPull.Number,
Builds: []CheckBuildSpec{{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod",
Ref: "main"}}}
built := buildForTheMerge(t.Context(), run, spec, CheckBuilding{Publish: r, Scratch: scratch}, workspace, "", "",
CheckVerdict{Head: "abcdefabcdef", Trunk: "123456123456"}, GitCredential{}, nil, logging(nil))
if len(built) != 1 || built[0].NotBuilt != "" || built[0].Key == "" {
t.Fatalf("the check did not build the module for its merge: %+v", built)
}
if !dockerBuilt(r) {
t.Fatal("the check built no image")
}
for repository := range r.images {
if !strings.HasPrefix(repository, ScratchPrefix) {
t.Fatalf("the check published %s outside the scratch namespace", repository)
}
}
for repository := range r.archives {
if !strings.HasPrefix(repository, ScratchPrefix) {
t.Fatalf("the check published %s outside the scratch namespace", repository)
}
}
return built[0]
}
// named is the walk's leave naming what a check's verdict says it built, as the controller reads it.
func named(c CheckBuilt) []NamedBuild {
n := NamedBuild{Pull: aPull, Check: "build-1", Module: c.Module, Key: c.Key, Digests: map[string]string{}}
for _, m := range c.Made {
d := m.Digest
if d == "" {
d = DigestOf(m.Reference)
}
n.Digests[m.Name] = d
}
return []NamedBuild{n}
}
func withVersion(t *testing.T, v string) {
t.Helper()
was := Version
Version = v
t.Cleanup(func() { Version = was })
}
// A merge whose reuse key equals its check's builds nothing again: the walk copies the check's build by digest
// and registers that digest.
func TestAWalkWhoseKeyIsItsChecksBuildsNothingAndRegistersTheChecksDigest(t *testing.T) {
withVersion(t, "builder-1")
scratch := newScratch()
checked := aCheckBuilt(t, scratch, "tree-one")
r, run, workspace := aModuleAt(t, "tree-one")
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: named(checked)},
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if dockerBuilt(r) || len(r.images) > 0 || len(r.archives) > 0 {
t.Fatalf("the walk built again: ran %v, published %v %v", r.ran, r.images, r.archives)
}
if got.Reused == nil || got.Why != "" {
t.Fatalf("the walk did not say it reused the check's build: reused %+v, why %q", got.Reused, got.Why)
}
if got.ReuseKey != checked.Key {
t.Fatalf("the walk's key %s is not the check's %s", got.ReuseKey, checked.Key)
}
if len(scratch.took) != 2 {
t.Fatalf("the walk copied %v, not the check's two artifacts", scratch.took)
}
for _, made := range checked.Made {
registered := strings.Replace(made.Reference, ScratchPrefix, "", 1)
found := false
for _, b := range got.Built {
found = found || (b.Reference == registered && b.Digest == made.Digest)
}
if !found {
t.Fatalf("the walk registers %+v, not the check's %s copied", got.Built, made.Reference)
}
}
if image, _ := got.Manifest.Resources[0]["image"].(string); image != "registry.invalid/meshboard/server@sha256:"+strings.Repeat("a", 64) {
t.Fatalf("the manifest pins %q, not the check's digest in the module's namespace", image)
}
words := got.Reused.Words()
if !strings.HasPrefix(words, "reused from the check of novox/meshboard#7 at head abcdefabcdef (merged into trunk 123456123456)") ||
!strings.Contains(words, "sha256:") {
t.Fatalf("the walk says %q", words)
}
}
// A record no verdict names is refused and built instead, and the walk says so: the store takes writes from anything
// on its network, so a record planted under another merge's key is never registered.
func TestARecordNoVerdictNamesIsRefusedAndBuilt(t *testing.T) {
withVersion(t, "builder-1")
scratch := newScratch()
checked := aCheckBuilt(t, scratch, "tree-one")
for name, leave := range map[string][]NamedBuild{
"no verdict": nil,
"another digest": func() []NamedBuild {
n := named(checked)
n[0].Digests["server"] = "sha256:" + strings.Repeat("e", 64)
return n
}(),
"another check": func() []NamedBuild { n := named(checked); n[0].Check = "build-9"; return n }(),
} {
t.Run(name, func(t *testing.T) {
scratch.took = nil
r, run, workspace := aModuleAt(t, "tree-one")
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: leave},
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if got.Reused != nil || len(scratch.took) > 0 || !dockerBuilt(r) {
t.Fatalf("a record no verdict names was taken: %+v, copied %v", got.Reused, scratch.took)
}
if !strings.Contains(got.Why, "no check verdict of the walk's pull requests names it") {
t.Fatalf("the walk says %q", got.Why)
}
})
}
}
// A differing key builds, and says which part differs.
func TestAWalkWhoseKeyDiffersBuildsAndSaysWhy(t *testing.T) {
withVersion(t, "builder-1")
scratch := newScratch()
checked := aCheckBuilt(t, scratch, "tree-one")
r, run, workspace := aModuleAt(t, "tree-two")
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: named(checked)},
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if !dockerBuilt(r) || got.Reused != nil {
t.Fatalf("a walk whose source differs did not build: reused %+v", got.Reused)
}
if !strings.Contains(got.Why, "the key differs") || !strings.Contains(got.Why, partTree) {
t.Fatalf("the walk says %q, not which part of the key differs", got.Why)
}
// And a builder of another version builds too, saying so.
withVersion(t, "builder-2")
r, run, workspace = aModuleAt(t, "tree-one")
got, err = BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: named(checked)},
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if !dockerBuilt(r) || !strings.Contains(got.Why, partBuilder) {
t.Fatalf("a newer builder took the older one's build, or did not say why not: %q", got.Why)
}
// No check of the walk's pull requests built it: said.
r, run, workspace = aModuleAt(t, "tree-one")
got, _ = BuildReusing(t.Context(), run, r, Reuse{Scratch: newScratch(), Pulls: []PullRef{aPull}},
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
if !dockerBuilt(r) || !strings.Contains(got.Why, "no check of novox/meshboard#7 built it") {
t.Fatalf("the walk says %q", got.Why)
}
}
// A copy that fails is said and falls back to a build; it never fails the walk.
func TestAFailedCopyOfAChecksBuildFallsBackToBuilding(t *testing.T) {
withVersion(t, "builder-1")
scratch := newScratch()
checked := aCheckBuilt(t, scratch, "tree-one")
scratch.failTake = true
r, run, workspace := aModuleAt(t, "tree-one")
got, err := BuildReusing(t.Context(), run, r, Reuse{Scratch: scratch, Pulls: []PullRef{aPull}, Named: named(checked)},
"https://forge.invalid/meshboard.git", "mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatalf("a failed copy failed the walk: %v", err)
}
if !dockerBuilt(r) || got.Reused != nil || len(r.images) == 0 {
t.Fatal("a failed copy did not fall back to a build")
}
if !strings.Contains(got.Why, "copying meshboard's") || !strings.Contains(got.Why, "refused the mount") {
t.Fatalf("the walk says %q", got.Why)
}
for repository := range r.images {
if strings.HasPrefix(repository, ScratchPrefix) {
t.Fatalf("the walk's own build went into the scratch namespace: %s", repository)
}
}
}
// Only a walk takes a check's build: a plain build — a check's, a dry run's, `build --ref`'s — builds, and a commit
// off the trunk never takes one.
func TestOnlyAWalkOfATrunkCommitTakesAChecksBuild(t *testing.T) {
withVersion(t, "builder-1")
scratch := newScratch()
checked := aCheckBuilt(t, scratch, "tree-one")
r, run, workspace := aModuleAt(t, "tree-one")
if _, err := Build(t.Context(), run, r, "https://forge.invalid/meshboard.git", "mod", "main", workspace, nil,
Npmrc{}, GitCredential{}, nil); err != nil {
t.Fatal(err)
}
if !dockerBuilt(r) || len(scratch.took) > 0 {
t.Fatal("a build not asked to reuse took a check's build")
}
r, _, workspace = aModuleAt(t, "tree-one") // no trunk said: off it
got, err := BuildReusing(t.Context(), r.run, r, Reuse{Scratch: scratch, Named: named(checked)}, "https://forge.invalid/meshboard.git",
"mod", "main", workspace, nil, Npmrc{}, GitCredential{}, nil)
if err != nil {
t.Fatal(err)
}
if !dockerBuilt(r) || got.Reused != nil || !strings.Contains(got.Why, "not on its trunk") {
t.Fatalf("a commit off the trunk took a check's build: %q", got.Why)
}
}
// A check that did not pass builds nothing for its merge, and one asked to build nothing builds nothing.
func TestACheckThatFailedBuildsNothingForItsMerge(t *testing.T) {
if skipsTheBuilds(CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "fail"}}) == "" {
t.Fatal("a check whose repository layer failed builds for its merge")
}
if skipsTheBuilds(CheckVerdict{Gate: &Layer{Verdict: "pass"}, Repo: &Layer{Verdict: "warning"}}) != "" {
t.Fatal("a check that passed with a warning builds nothing for its merge")
}
r, run, workspace := aModuleAt(t, "tree-one")
if built := buildForTheMerge(t.Context(), run, CheckSpec{ID: "build-1"}, CheckBuilding{Publish: r, Scratch: newScratch()},
workspace, "", "", CheckVerdict{}, GitCredential{}, nil, logging(nil)); built != nil || len(r.ran) > 0 {
t.Fatalf("a check moving no module built %v, ran %v", built, r.ran)
}
}
// A module whose source does not pin its build has no key and is not built by the check; what stands after it
// is not built either.
func TestACheckBuildsNoModuleWithoutAKeyNorWhatStandsAfterIt(t *testing.T) {
withVersion(t, "builder-1")
r, run, workspace := aModuleAt(t, "tree-one")
r.contents["mod/"+ManifestName] = `{"module":"meshboard","version":"1","build":{"artifacts":[
{"name":"mirror","kind":"upstream","from":"docker.io/library/alpine:3"}]}}`
spec := CheckSpec{ID: "build-1", Owner: "novox", Repo: "meshboard", Number: 7, Builds: []CheckBuildSpec{
{Module: "meshboard", Repository: "https://forge.invalid/meshboard.git", Path: "mod", Tier: 0},
{Module: "after", Repository: "https://forge.invalid/after.git", Tier: 1}}}
built := buildForTheMerge(t.Context(), run, spec, CheckBuilding{Publish: r, Scratch: newScratch()}, workspace, "", "",
CheckVerdict{}, GitCredential{}, nil, logging(nil))
if len(built) != 2 || !strings.Contains(built[0].NotBuilt, "named by tag") ||
!strings.Contains(built[1].NotBuilt, "stands after meshboard") {
t.Fatalf("built %+v", built)
}
if len(r.images) > 0 {
t.Fatalf("a module without a key was published: %v", r.images)
}
}
// What the key names: every input a fresh build could differ by.
func TestTheReuseKeyNamesEveryInputOfTheBuild(t *testing.T) {
src := newSourceInputs("m")
src.tree = "t1"
src.bases = []string{"store:5000/b/x@sha256:" + strings.Repeat("b", 64)}
src.contexts["server"] = "ctx1"
src.toolchains["tools"] = "go sha256:c 1234"
manifest := catalogue.Manifest{Module: "m"}
base := keyOf(src, manifest, "v1")
if base.Key == "" {
t.Fatalf("no key: %s", base.Why)
}
for name, change := range map[string]func(*sourceInputs) string{
partTree: func(s *sourceInputs) string { s.tree = "t2"; return "v1" },
partBases: func(s *sourceInputs) string {
s.bases = []string{"other@sha256:" + strings.Repeat("c", 64)}
return "v1"
},
partContext: func(s *sourceInputs) string { s.contexts["server"] = "ctx2"; return "v1" },
partTool: func(s *sourceInputs) string { s.toolchains["tools"] = "go sha256:d 1234"; return "v1" },
partBuilder: func(*sourceInputs) string { return "v2" },
} {
s := src.copy()
version := change(s)
if k := keyOf(s, manifest, version); k.Key == base.Key {
t.Errorf("a change of %s left the key the same", name)
}
}
// A base copied to another address is the same base: the key names it by digest.
moved := src.copy()
moved.bases = []string{"elsewhere:5000/scratch/b/x@sha256:" + strings.Repeat("b", 64)}
if keyOf(moved, manifest, "v1").Key != base.Key {
t.Error("the same base at another address changed the key")
}
if k := keyOf(src, manifest, ""); k.Key != "" {
t.Error("a builder whose version cannot be said keyed its build")
}
unpinned := src.copy()
unpinned.notPinned("it resolves packages from the mesh's registry at build time")
if k := keyOf(unpinned, manifest, "v1"); k.Key != "" || !strings.Contains(k.Why, "registry") {
t.Errorf("a build its source does not pin was keyed: %+v", k)
}
}
// aTaggingRegistry is the in-memory registry with tags and their listing, as the store's scratch namespace needs.
type aTaggingRegistry struct {
*aRegistryOfRepositories
tags map[string]map[string]string // repository → tag → manifest digest
}
func (m *aTaggingRegistry) serve() http.Handler {
under := m.handler()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if repository, ok := strings.CutSuffix(strings.TrimPrefix(r.URL.Path, "/v2/"), "/tags/list"); ok {
m.mu.Lock()
var tags []string
for tag := range m.tags[repository] {
tags = append(tags, tag)
}
m.mu.Unlock()
if tags == nil {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"name": repository, "tags": tags})
return
}
repository, kind, rest := splitPath(r.URL.Path)
if kind == "blobs" && r.Method == http.MethodHead {
m.mu.Lock()
body, linked := m.blobs[rest], m.links[repository][rest]
m.mu.Unlock()
if !linked {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
w.WriteHeader(http.StatusOK)
return
}
if kind != "manifests" {
under.ServeHTTP(w, r)
return
}
if !strings.HasPrefix(rest, "sha256:") {
m.mu.Lock()
switch r.Method {
case http.MethodPut:
body, _ := readAll(r)
if m.tags[repository] == nil {
m.tags[repository] = map[string]string{}
}
m.tags[repository][rest] = digestOf(body)
m.manifests[repository+"@"+digestOf(body)] = body
m.mu.Unlock()
w.WriteHeader(http.StatusCreated)
return
default:
digest, ok := m.tags[repository][rest]
body, held := m.manifests[repository+"@"+digest]
m.mu.Unlock()
if !ok || !held {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Content-Type", mediaTypeOf(body))
if r.Method == http.MethodGet {
_, _ = w.Write(body)
return
}
w.WriteHeader(http.StatusOK)
return
}
}
if r.Method == http.MethodDelete {
m.mu.Lock()
for _, tags := range m.tags {
for tag, digest := range tags {
if digest == rest {
delete(tags, tag)
}
}
}
m.mu.Unlock()
}
under.ServeHTTP(w, r)
})
}
// A check's builds go after seven days, and when its pull request closes or is merged; the walk's copy in the
// module's own namespace stays.
func TestCheckBuildsExpireAfterSevenDaysAndGoWhenTheirPullRequestCloses(t *testing.T) {
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
server := httptest.NewServer(m.serve())
defer server.Close()
address := strings.TrimPrefix(server.URL, "http://")
registry := Registry{Address: address}
ctx := t.Context()
now := time.Date(2026, 10, 13, 20, 0, 0, 0, time.UTC)
image := func(repository, body string) string {
digest := digestOf([]byte(body))
m.manifests[repository+"@"+digest] = []byte(body)
return address + "/" + repository + "@" + digest
}
check := func(pull PullRef, id string, at time.Time, key string) string {
reference := image("scratch/meshboard/server", `{"mediaType":"`+mediaManifestOCI+`","layers":[],"n":"`+id+`"}`)
build := CheckBuild{Key: key, Module: "meshboard", Made: []catalogue.Built{{Name: "server", Kind: "image",
Reference: reference}}, Pull: pull, Check: id, At: at}
if err := registry.Keep(ctx, pull, id, at, []CheckBuild{build}); err != nil {
t.Fatal(err)
}
return reference
}
held := func(reference string) bool {
_, digest, _ := strings.Cut(reference, "@")
_, ok := m.manifests["scratch/meshboard/server@"+digest]
return ok
}
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
old := check(aPull, "build-1", now.Add(-8*24*time.Hour), "r1:"+strings.Repeat("1", 64))
fresh := check(aPull, "build-2", now.Add(-time.Hour), "r1:"+strings.Repeat("2", 64))
others := check(other, "build-3", now.Add(-time.Hour), "r1:"+strings.Repeat("3", 64))
walked := image("meshboard/server", `{"mediaType":"`+mediaManifestOCI+`","layers":[],"n":"walk"}`)
if _, found, err := registry.Find(ctx, "meshboard", "r1:"+strings.Repeat("2", 64)); err != nil || !found {
t.Fatalf("a check build kept is not found by its key: %v %v", found, err)
}
n, err := registry.Expire(ctx, CheckBuildsKept, now)
if err != nil || n != 1 {
t.Fatalf("expired %d checks (%v), want the one eight days old", n, err)
}
if held(old) || !held(fresh) || !held(others) {
t.Fatalf("after the week's sweep: old %v, fresh %v, another's %v", held(old), held(fresh), held(others))
}
if _, found, _ := registry.Find(ctx, "meshboard", "r1:"+strings.Repeat("1", 64)); found {
t.Fatal("the expired check build's record is still found")
}
n, err = registry.Forget(ctx, aPull)
if err != nil || n != 1 {
t.Fatalf("forgot %d checks of %s (%v)", n, aPull, err)
}
if held(fresh) || !held(others) {
t.Fatalf("after %s closed: its build held %v, another pull request's %v", aPull, held(fresh), held(others))
}
if _, ok := m.manifests["meshboard/server@"+strings.SplitN(walked, "@", 2)[1]]; !ok {
t.Fatal("letting go of check builds took the walk's copy in the module's namespace")
}
if left, _ := registry.OfPulls(ctx, []PullRef{aPull}); len(left) != 0 {
t.Fatalf("%s still has check builds: %+v", aPull, left)
}
}
// The walk copies a check build by digest: the digest registered is the digest the check built, and the copy is
// in the module's own namespace.
func TestAChecksBuildIsCopiedByDigestIntoTheModulesNamespace(t *testing.T) {
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
server := httptest.NewServer(m.serve())
defer server.Close()
address := strings.TrimPrefix(server.URL, "http://")
registry := Registry{Address: address}
layer := []byte("layer")
m.blobs[digestOf(layer)] = layer
m.link("scratch/meshboard/server", digestOf(layer))
manifest := []byte(`{"mediaType":"` + mediaManifestOCI + `","layers":[{"digest":"` + digestOf(layer) + `","size":5}]}`)
m.manifests["scratch/meshboard/server@"+digestOf(manifest)] = manifest
archive := []byte("archive")
m.blobs[digestOf(archive)] = archive
m.link("scratch/meshboard/look", digestOf(archive))
got, err := registry.Take(t.Context(), "meshboard", catalogue.Built{Name: "server", Kind: "image",
Reference: address + "/scratch/meshboard/server@" + digestOf(manifest)})
if err != nil {
t.Fatal(err)
}
if got.Reference != address+"/meshboard/server@"+digestOf(manifest) {
t.Fatalf("copied as %s", got.Reference)
}
if m.uploads != 0 || m.mounts != 1 {
t.Fatalf("the image's layer was moved (%d uploads, %d mounts), not mounted", m.uploads, m.mounts)
}
got, err = registry.Take(t.Context(), "meshboard", catalogue.Built{Name: "look", Kind: "archive",
Digest: digestOf(archive), Reference: "http://" + address + "/v2/scratch/meshboard/look/blobs/" + digestOf(archive)})
if err != nil {
t.Fatal(err)
}
if got.Digest != digestOf(archive) || got.Reference != "http://"+address+"/v2/meshboard/look/blobs/"+digestOf(archive) ||
!m.links["meshboard/look"][digestOf(archive)] {
t.Fatalf("the archive was copied as %+v", got)
}
}
// Two checks that built one digest in one repository share it: letting go of one check's builds keeps it while the
// other still names it, and the last to go takes it.
func TestABuildTwoChecksShareGoesWithTheLastOfThem(t *testing.T) {
m := &aTaggingRegistry{aRegistryOfRepositories: newRegistryOfRepositories(), tags: map[string]map[string]string{}}
server := httptest.NewServer(m.serve())
defer server.Close()
address := strings.TrimPrefix(server.URL, "http://")
registry := Registry{Address: address}
ctx := t.Context()
now := time.Date(2026, 10, 13, 20, 0, 0, 0, time.UTC)
body := `{"mediaType":"` + mediaManifestOCI + `","layers":[]}`
digest := digestOf([]byte(body))
m.manifests["scratch/meshboard/server@"+digest] = []byte(body)
reference := address + "/scratch/meshboard/server@" + digest
other := PullRef{Owner: "novox", Repo: "meshboard", Number: 8}
for i, pull := range []PullRef{aPull, other} {
id := "build-" + strconv.Itoa(i+1)
b := CheckBuild{Key: "r1:" + strings.Repeat(strconv.Itoa(i+1), 64), Module: "meshboard", Pull: pull, Check: id,
At: now, Made: []catalogue.Built{{Name: "server", Kind: "image", Reference: reference}}}
if err := registry.Keep(ctx, pull, id, now, []CheckBuild{b}); err != nil {
t.Fatal(err)
}
}
held := func() bool { _, ok := m.manifests["scratch/meshboard/server@"+digest]; return ok }
if n, err := registry.Forget(ctx, aPull); err != nil || n != 1 {
t.Fatalf("forgot %d (%v)", n, err)
}
if !held() {
t.Fatal("letting go of one check's builds took a build another check still names")
}
if n, err := registry.Forget(ctx, other); err != nil || n != 1 {
t.Fatalf("forgot %d (%v)", n, err)
}
if held() {
t.Fatal("the build stayed after the last check naming it went")
}
}
+37
View File
@@ -64,6 +64,43 @@ type sourceInputs struct {
// contextsAtHead is false once a context was cloned at something other than its trunk's head: its // contextsAtHead is false once a context was cloned at something other than its trunk's head: its
// closure is not the one the next merge there meets, and the build says no build source. // closure is not the one the next merge there meets, and the build says no build source.
contextsAtHead bool contextsAtHead bool
// onlyKey says the inputs are being taken for a reuse key (novox/hq ADR 0350 §2): each artifact goes as far
// as what its source fingerprint reads, and nothing is built or published.
onlyKey bool
}
// keyOnly is whether these inputs are only being taken for a reuse key.
func (s *sourceInputs) keyOnly() bool { return s != nil && s.onlyKey }
// copy is the inputs taken so far, apart: a walk's key is taken from them without touching the build's own.
func (s *sourceInputs) copy() *sourceInputs {
c := newSourceInputs(s.module)
c.tree, c.unpinned, c.prefix, c.ownIsAll, c.contextsAtHead = s.tree, s.unpinned, s.prefix, s.ownIsAll, s.contextsAtHead
c.bases = append([]string{}, s.bases...)
for k, v := range s.contexts {
c.contexts[k] = v
}
for k, v := range s.toolchains {
c.toolchains[k] = v
}
for k, v := range s.own {
c.own[k] = v
}
for k, v := range s.read {
if v == nil {
c.read[k] = nil
continue
}
set := map[string]bool{}
for e := range v {
set[e] = true
}
c.read[k] = set
}
for k, v := range s.readAs {
c.readAs[k] = v
}
return c
} }
// contextOffHead says a context was not its trunk's head. // contextOffHead says a context was not its trunk's head.
+5
View File
@@ -87,6 +87,11 @@ func Openings(rules []Rule, foundation []int, published map[string]map[int]int)
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere) widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
case FromMesh: case FromMesh:
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh) widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
case FromLocalNetwork:
// The private network's part only (novox/hq ADR 0340). An adopted node's found firewall
// stays, and it is what admits the machine's local network; an opening has no word for a
// local network, and widening it to everywhere would open the port to the internet.
widen(key{rule.Protocol, rule.Port}, OpeningFromMesh)
} }
} }
for _, port := range foundation { for _, port := range foundation {
+1
View File
@@ -127,6 +127,7 @@ func boundNeed(n Needed, bound Chosen, catalogue map[string]Manifest, node Node,
identity = pm.IdentityBoundOf(n.Name) identity = pm.IdentityBoundOf(n.Name)
} }
held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity held.From, held.At, held.Module, held.Serves, held.Identity = p.Node, p.At, p.Module, p.Serves, identity
held.LeftOut = p.LeftOut
return held, true, "" return held, true, ""
} }
+124
View File
@@ -0,0 +1,124 @@
package catalogue
import (
"fmt"
"sort"
"strings"
"time"
)
// A long-running container may say it is busy (novox/hq ADR 0335 §1).
//
// **Beside its health, on the container**: a command run inside it, by its shell, as an exec health check
// is, that exits 0 while the container holds work a replacement would lose and non-zero when it is idle;
// and how long one ask may take. Being asked is also its notice that it is about to be replaced: from the
// first ask it takes no new work. The node-engine asks before it replaces the container, leaves it running
// while it says busy, and replaces it on the first pass it says idle, or at 15 minutes — the engine's bound,
// never the module's.
//
// Sent only to a node-engine that reads it (link.BusyContract): an older, strict engine refuses a field it
// does not know, whole, and to it the container is replaced at once, as before.
// BusyField is the resource field.
const BusyField = "busy"
// The bounds of one ask (ADR 0335), the node-engine's own, held here near the author.
const (
BusyTimeoutDefault = 10 * time.Second
BusyTimeoutCeiling = time.Minute
)
// Busy is one container's busy, read.
type Busy struct {
Command string
Timeout time.Duration
}
// ReadBusy reads a resource's `busy` field, its default applied. False when it carries none.
func ReadBusy(r map[string]any) (Busy, bool, []string) {
raw, present := r[BusyField]
if !present {
return Busy{}, false, nil
}
id := fmt.Sprint(r["id"])
fields, ok := raw.(map[string]any)
if !ok {
return Busy{}, true, []string{fmt.Sprintf("%s: busy is %T; it is an object with a command", id, raw)}
}
var problems []string
keys := make([]string, 0, len(fields))
for k := range fields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if k != "command" && k != "timeout" {
problems = append(problems, fmt.Sprintf("%s: its busy says %q; busy has a command and a timeout", id, k))
}
}
b := Busy{Timeout: BusyTimeoutDefault}
if v, has := fields["command"]; has {
s, ok := v.(string)
if !ok {
problems = append(problems, fmt.Sprintf("%s: its busy's command is %T; it is text", id, v))
}
b.Command = strings.TrimSpace(s)
}
if v, has := fields["timeout"]; has {
s, _ := v.(string)
d, err := time.ParseDuration(strings.TrimSpace(s))
switch {
case err != nil:
problems = append(problems, fmt.Sprintf("%s: its busy's timeout is %v; it is a duration such as \"10s\"", id, v))
case d <= 0 || d > BusyTimeoutCeiling:
problems = append(problems, fmt.Sprintf("%s: an ask of its busy takes more than nothing and at most %s, not %s",
id, BusyTimeoutCeiling, d))
default:
b.Timeout = d
}
}
return b, true, problems
}
// busyProblems is everything wrong with a manifest's `busy` fields, in the manifest's words: a command on
// one line, a timeout within its bounds, and only on a container that stays up.
func busyProblems(m Manifest) []string {
var problems []string
for _, r := range m.Resources {
b, has, read := ReadBusy(r)
if !has {
continue
}
where := m.Module + ": " + fmt.Sprint(r["id"])
for _, p := range read {
problems = append(problems, m.Module+": "+p)
}
if fmt.Sprint(r["type"]) != "container" || !LongRunning(r) {
problems = append(problems, fmt.Sprintf("%s declares busy and is not a container that stays up: busy is "+
"asked of a running container before it is replaced (ADR 0335)", where))
}
if b.Command == "" {
problems = append(problems, where+"'s busy names no command: one run inside the container, exiting 0 "+
"while it is busy")
}
if strings.ContainsAny(b.Command, "\n\r") {
problems = append(problems, where+"'s busy command is one line")
}
}
return problems
}
// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
// container is replaced at once, as before.
func busyInto(resource map[string]any, with Rendering) {
if _, has := resource[BusyField]; !has {
return
}
if !with.ReadsBusy {
delete(resource, BusyField)
return
}
b, _, _ := ReadBusy(resource)
resource[BusyField] = map[string]any{"command": b.Command, "timeout": b.Timeout.String()}
}
+94
View File
@@ -0,0 +1,94 @@
package catalogue
import (
"encoding/json"
"strings"
"testing"
"github.com/novox/mesh-host/validate"
)
// A long-running container may say it is busy (novox/hq ADR 0335 §1): `module check` holds the field to its
// shape near its author, and it is sent only to a node-engine that reads it, in words the engine's own
// validator accepts.
func busyManifest(t *testing.T, on string, busy any) []byte {
t.Helper()
extra := map[string]any{"id": "agent", "type": "container", "name": "app-agent",
"image": "registry.example/app" + healthDigest}
var m map[string]any
if err := json.Unmarshal(healthManifest(t, "", nil, extra), &m); err != nil {
t.Fatal(err)
}
for _, r := range m["resources"].([]any) {
if r := r.(map[string]any); r["id"] == on {
r[BusyField] = busy
}
}
out, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
return out
}
func TestABusyIsHeldToItsShape(t *testing.T) {
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil {
t.Fatalf("a well-formed busy was refused: %v", err)
}
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil {
t.Fatalf("a busy with its timeout was refused: %v", err)
}
for name, c := range map[string]struct {
on string
busy any
says string
}{
"text": {"agent", "mesh-builder busy", "it is an object"},
"no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"},
"two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"},
"a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"},
"an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`},
"on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"},
"on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"},
} {
_, err := ParseManifest(busyManifest(t, c.on, c.busy))
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err)
}
}
}
func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
m, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
if err != nil {
t.Fatal(err)
}
compose := func(with Rendering) []map[string]any {
out, err := Resolution{Node: "laptop", Modules: []Manifest{m}}.Declaration(with)
if err != nil {
t.Fatal(err)
}
return out
}
older := compose(Rendering{})
if b, sent := older[indexOfID(older, "app.agent")][BusyField]; sent {
t.Fatalf("busy was sent to an engine that does not read it: %v", b)
}
newer := compose(Rendering{ReadsBusy: true})
got, _ := newer[indexOfID(newer, "app.agent")][BusyField].(map[string]any)
if got["command"] != "mesh-builder busy" || got["timeout"] != "10s" {
t.Fatalf("busy composed as %v", got)
}
// And the engine's own validator takes the container with it.
body, err := json.Marshal(map[string]any{"declaration": validate.Version, "resources": newer})
if err != nil {
t.Fatal(err)
}
if problems := validate.Declaration(body); len(problems) > 0 {
t.Fatalf("the node-engine's validator refuses the composed busy: %v", problems)
}
if _, has := m.Resources[len(m.Resources)-1][BusyField].(map[string]any)["timeout"]; has {
t.Error("composing wrote the timeout into the catalogue's own manifest")
}
}
@@ -135,6 +135,9 @@ func CheckServes(m Manifest) []string {
for _, key := range sortedAnyKeys(m.Serves[provision]) { for _, key := range sortedAnyKeys(m.Serves[provision]) {
text, ok := m.Serves[provision][key].(string) text, ok := m.Serves[provision][key].(string)
if !ok { if !ok {
// A provider's own name is filled only in a value of its own (novox/hq ADR 0346): one
// nested in a list or an object would be served as the placeholder, unsaid.
problems = append(problems, nestedOwnNames(m, provision, key, m.Serves[provision][key])...)
continue continue
} }
// A probe identity, because what is checked is the shape of the statement and not // A probe identity, because what is checked is the shape of the statement and not
@@ -143,6 +146,8 @@ func CheckServes(m Manifest) []string {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err)) "%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
} }
// And the provider's own names it may serve (novox/hq ADR 0346).
problems = append(problems, checkServedOwnNames(m, provision, key, text)...)
// A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own // A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own
// bound has to keep the identity inside one (ADR 0225). // bound has to keep the identity inside one (ADR 0225).
if strings.Contains(text, "${consumer:as:dns}") { if strings.Contains(text, "${consumer:as:dns}") {
+24 -5
View File
@@ -135,6 +135,12 @@ type Rendering struct {
// composes no filter for it rather than writing a rule around a link with no name. // composes no filter for it rather than writing a rule around a link with no name.
OutwardLinks []string OutwardLinks []string
// LocalNetworks is this machine's local networks (novox/hq ADR 0340): the networks it reported on a
// link facing outside it that lie wholly in a private range (JudgeLocalNetworks), which a port
// reached from `local-network` is admitted from beside the private network. Empty when it reported
// none, and the rule set then says so rather than widening the port or dropping it.
LocalNetworks []string
// TunnelInterface is the interface the mesh's private network runs on, named here rather than // TunnelInterface is the interface the mesh's private network runs on, named here rather than
// imported because the overlay package rests on this one. Traffic arriving on it is the mesh's, // imported because the overlay package rests on this one. Traffic arriving on it is the mesh's,
// not this machine's own guest, so the filter admits it only by a rule. // not this machine's own guest, so the filter admits it only by a rule.
@@ -246,6 +252,11 @@ type Rendering struct {
// statement's contract is link.RootContract or later). To an older, strict engine the field is not // statement's contract is link.RootContract or later). To an older, strict engine the field is not
// sent, and the account it names is not judged — which the self-check says, as not judged. // sent, and the account it names is not judged — which the self-check says, as not judged.
JudgesRoot bool JudgesRoot bool
// ReadsBusy says this machine's node-engine reads a container's `busy` (novox/hq ADR 0335: its statement's
// contract is link.BusyContract or later). To an older, strict engine the field is not sent, and the
// container is replaced at once, as before.
ReadsBusy bool
} }
// RootField is a user resource's field saying the account must never become root without a person // RootField is a user resource's field saying the account must never become root without a person
@@ -527,8 +538,8 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
"`node show %s` says whether it has. Until then %s is not sent, and the machine "+ "`node show %s` says whether it has. Until then %s is not sent, and the machine "+
"keeps the filter it has", r.Node, r.Node, filters) "keeps the filter it has", r.Node, r.Node, filters)
} }
filtering := AsNftables(rules, with.Mesh, r.PublicDomain != "", with.Foundation, filtering := AsNftablesWithLocal(rules, with.Mesh, r.PublicDomain != "", with.Foundation,
with.OutwardLinks, with.TunnelInterface) with.OutwardLinks, with.TunnelInterface, with.LocalNetworks)
// **A variable two modules set is refused whether or not anything places it** (novox/hq ADR // **A variable two modules set is refused whether or not anything places it** (novox/hq ADR
// 0203 §5): the account has one environment, and a machine whose holder arrives later should not // 0203 §5): the account has one environment, and a machine whose holder arrives later should not
@@ -1056,6 +1067,8 @@ func (r Resolution) compose(with Rendering, owner map[string]string,
// How it is ready, in the node-engine's words: its endpoint as the port this machine // How it is ready, in the node-engine's words: its endpoint as the port this machine
// published it on — or not sent at all to an engine older than the field (ADR 0240). // published it on — or not sent at all to an engine older than the field (ADR 0240).
healthInto(copied, m, with) healthInto(copied, m, with)
// And whether it is busy (novox/hq ADR 0335): sent only to an engine that asks it.
busyInto(copied, with)
// And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine // And a user's `root` (novox/hq ADR 0266): sent only when it asserts something, to an engine
// that judges it. // that judges it.
rootInto(copied, with) rootInto(copied, with)
@@ -2033,7 +2046,13 @@ func (r Resolution) servedOnThisMachine(provision string, with Rendering) (map[s
if err != nil { if err != nil {
return nil, false, fmt.Errorf("%s serving %s: %w", m.Module, provision, err) return nil, false, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
} }
return settled, true, nil // And the provider's own names, as this machine composes them (novox/hq ADR 0346); one it does
// not compose leaves its key out, said by the plan on this machine (ServedLeftOut).
named, _, err := r.ServedOwnNames(m, provision, settled, WithDefaults(m, with.Settings[m.Module]))
if err != nil {
return nil, false, fmt.Errorf("%s serving %s: %w", m.Module, provision, err)
}
return named, true, nil
} }
return nil, false, nil return nil, false, nil
} }
@@ -2551,8 +2570,8 @@ func onlyGroups(r map[string]any) bool {
// generatedHere is what each computed module's generator answers for this machine, by module — // generatedHere is what each computed module's generator answers for this machine, by module —
// absent for a module whose machine is not yet part of what it generates — held to the rule every // absent for a module whose machine is not yet part of what it generates — held to the rule every
// module is held to: no two modules on a machine declare one path, unit, name or package (novox/hq // module is held to: no two modules on a machine declare one path, unit or name, nor one package present
// issue 190, step 5; ADR 0222). // and absent (novox/hq issue 190, step 5; ADR 0222, ADR 0303).
// //
// Resolution checks the catalogue's manifests, and a computed module's manifest has none of the // Resolution checks the catalogue's manifests, and a computed module's manifest has none of the
// resources it will declare — they exist only once its generator has answered for this machine, // resources it will declare — they exist only once its generator has answered for this machine,
+63 -6
View File
@@ -152,9 +152,10 @@ func Exposure(m Manifest, layers []Layer) (map[int]string, error) {
m.Module, port) m.Module, port)
} }
source, ok := value.(string) source, ok := value.(string)
if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine) { if !ok || (source != FromMesh && source != FromEverywhere && source != FromMachine &&
return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q or %q", source != FromLocalNetwork) {
m.Module, port, value, FromMesh, FromEverywhere, FromMachine) return nil, fmt.Errorf("%s exposes port %d as %v; it is %q, %q, %q or %q",
m.Module, port, value, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork)
} }
out[port] = source out[port] = source
} }
@@ -172,7 +173,9 @@ func Exposure(m Manifest, layers []Layer) (map[int]string, error) {
// the appearance-of-restriction this mechanism exists to remove. The narrower rule's sources are // the appearance-of-restriction this mechanism exists to remove. The narrower rule's sources are
// carried onto the wider one, so the module that wanted it is still named. // carried onto the wider one, so the module that wanted it is still named.
func widest(rules []Rule) []Rule { func widest(rules []Rule) []Rule {
reach := map[string]int{FromMachine: 0, FromMesh: 1, FromEverywhere: 2} // The local network is the private network and the machine's own local networks (novox/hq ADR
// 0340 §4), so it covers `mesh` and is covered by `anywhere`.
reach := map[string]int{FromMachine: 0, FromMesh: 1, FromLocalNetwork: 2, FromEverywhere: 3}
var out []Rule var out []Rule
for _, rule := range rules { for _, rule := range rules {
covered := false covered := false
@@ -232,6 +235,15 @@ const SSHPort = 22
// any module asks for, and neither may be derived away. // any module asks for, and neither may be derived away.
func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int, func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
outwardLinks []string, tunnel string) string { outwardLinks []string, tunnel string) string {
return AsNftablesWithLocal(rules, mesh, outward, foundation, outwardLinks, tunnel, nil)
}
// AsNftablesWithLocal is AsNftables with the machine's local networks: the networks it reported on a
// link facing outside that count (novox/hq ADR 0340, JudgeLocalNetworks), which a rule from
// `local-network` admits on its own port alone, beside the private network. None is not widened to
// anywhere and not dropped silently: the rule set says the port wanted the local network and found none.
func AsNftablesWithLocal(rules []Rule, mesh []string, outward bool, foundation []int,
outwardLinks []string, tunnel string, local []string) string {
// The links that are not this machine's own: the ones facing outside, and the mesh's tunnel. // The links that are not this machine's own: the ones facing outside, and the mesh's tunnel.
// Traffic arriving on any of them is admitted only by a rule below; traffic arriving anywhere // Traffic arriving on any of them is admitted only by a rule below; traffic arriving anywhere
// else is this machine's own guest and is not something the mesh has a position on. // else is this machine's own guest and is not something the mesh has a position on.
@@ -394,6 +406,31 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n", b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n",
strings.Join(six, ", "), rule.Protocol, rule.Port)) strings.Join(six, ", "), rule.Protocol, rule.Port))
} }
case FromLocalNetwork:
// The private network and the machine's own local networks, on this port alone (novox/hq
// ADR 0340 §5).
four, six := byFamily(mesh)
lfour, lsix := byFamily(local)
four, six = append(four, lfour...), append(six, lsix...)
if len(lfour) == 0 && len(lsix) == 0 {
b.WriteString(fmt.Sprintf(
"\t\t# %s/%d wanted the local network, and this machine reported no private network "+
"on a link facing outside; admitted from no local network\n", rule.Protocol, rule.Port))
}
if len(four) == 0 && len(six) == 0 {
b.WriteString(fmt.Sprintf(
"\t\t# %s/%d wanted the local network, and this node knows neither mesh addresses nor a "+
"local network; closed\n", rule.Protocol, rule.Port))
break
}
if len(four) > 0 {
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } %s dport %d accept\n",
strings.Join(four, ", "), rule.Protocol, rule.Port))
}
if len(six) > 0 {
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } %s dport %d accept\n",
strings.Join(six, ", "), rule.Protocol, rule.Port))
}
case FromEverywhere: case FromEverywhere:
b.WriteString(fmt.Sprintf("\t\t%s dport %d accept\n", rule.Protocol, rule.Port)) b.WriteString(fmt.Sprintf("\t\t%s dport %d accept\n", rule.Protocol, rule.Port))
} }
@@ -487,6 +524,18 @@ func AsNftables(rules []Rule, mesh []string, outward bool, foundation []int,
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n", b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n",
strings.Join(six, ", "), rule.Port)) strings.Join(six, ", "), rule.Port))
} }
case FromLocalNetwork:
four, six := byFamily(mesh)
lfour, lsix := byFamily(local)
four, six = append(four, lfour...), append(six, lsix...)
if len(four) > 0 {
b.WriteString(fmt.Sprintf("\t\tip saddr { %s } ct original proto-dst %d accept\n",
strings.Join(four, ", "), rule.Port))
}
if len(six) > 0 {
b.WriteString(fmt.Sprintf("\t\tip6 saddr { %s } ct original proto-dst %d accept\n",
strings.Join(six, ", "), rule.Port))
}
case FromEverywhere: case FromEverywhere:
b.WriteString(fmt.Sprintf("\t\tct original proto-dst %d accept\n", rule.Port)) b.WriteString(fmt.Sprintf("\t\tct original proto-dst %d accept\n", rule.Port))
} }
@@ -793,10 +842,14 @@ const (
// are a subset of anywhere. What differs is the names, which is the whole reason reach is not // are a subset of anywhere. What differs is the names, which is the whole reason reach is not
// simply the filter's vocabulary with nicer words. // simply the filter's vocabulary with nicer words.
ReachBoth = "both" ReachBoth = "both"
// ReachLocalNetwork is `internal` with the machine's own local networks added to what the filter
// opens (novox/hq ADR 0340 §4): the internal name and not the public one, the mesh's own authority,
// and the port opened to the private network and to the private networks the machine is attached to.
ReachLocalNetwork = "local-network"
) )
// reaches is every value, in the order a refusal lists them. // reaches is every value, in the order a refusal lists them.
var reaches = []string{ReachMachine, ReachInternal, ReachPublic, ReachBoth} var reaches = []string{ReachMachine, ReachInternal, ReachPublic, ReachBoth, ReachLocalNetwork}
// RoutedPorts are the ports a module serves through a proxy, taken from its route contributions. // RoutedPorts are the ports a module serves through a proxy, taken from its route contributions.
// //
@@ -845,6 +898,8 @@ func FilterSource(reach string) (string, bool) {
return FromMesh, true return FromMesh, true
case ReachPublic, ReachBoth: case ReachPublic, ReachBoth:
return FromEverywhere, true return FromEverywhere, true
case ReachLocalNetwork:
return FromLocalNetwork, true
default: default:
return "", false return "", false
} }
@@ -854,7 +909,9 @@ func FilterSource(reach string) (string, bool) {
func WantsPublicName(reach string) bool { return reach == ReachPublic || reach == ReachBoth } func WantsPublicName(reach string) bool { return reach == ReachPublic || reach == ReachBoth }
// WantsInternalName is whether a reach asks for the route's internal name to be composed. // WantsInternalName is whether a reach asks for the route's internal name to be composed.
func WantsInternalName(reach string) bool { return reach == ReachInternal || reach == ReachBoth } func WantsInternalName(reach string) bool {
return reach == ReachInternal || reach == ReachBoth || reach == ReachLocalNetwork
}
// Reaches reads a module's per-node reach settings: declared port → how far it reaches. // Reaches reads a module's per-node reach settings: declared port → how far it reaches.
// //
@@ -6,7 +6,8 @@ import (
) )
// novox/hq issue 190, step 5 (ADR 0222): what the mesh computes for a module is held to the rule // novox/hq issue 190, step 5 (ADR 0222): what the mesh computes for a module is held to the rule
// every module is — no two modules on a machine declare one path, unit, name or package. The // every module is — no two modules on a machine declare one path, unit or name (a package may be declared by
// several, novox/hq ADR 0303). The
// private network once declared the container runtime's file and service beside the runtime's own // private network once declared the container runtime's file and service beside the runtime's own
// module, and nothing refused it, because the collision check only ever saw catalogue manifests. // module, and nothing refused it, because the collision check only ever saw catalogue manifests.
+2 -2
View File
@@ -25,13 +25,13 @@ func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) {
} }
for want, raw := range map[string]string{ for want, raw := range map[string]string{
"the one issuer an offer may name": `{"module":"d","version":"1", "the issuers an offer may name": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`, "provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`,
"names no tool that answers it": `{"module":"d","version":"1", "names no tool that answers it": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`, "provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`,
"names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}}, "names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}},
"provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`, "provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`,
"the one issuer an offer may name ": `{"module":"d","version":"1", "the issuers an offer may name ": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`, "provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`,
} { } {
want = strings.TrimSpace(want) want = strings.TrimSpace(want)
+46
View File
@@ -0,0 +1,46 @@
package catalogue
import (
"strings"
"testing"
)
// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164):
// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside
// either. An offer that says `software` keeps ADR 0305's shape.
func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"tv","version":"1",
"provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}],
"serves":{"tv-api":{"port":8989}}}`))
if err != nil {
t.Fatal(err)
}
if !m.IssuedOutsideOf("tv-api") {
t.Fatal("the offer's credential issued outside was not read")
}
if m.IssuedOutsideOf("other") {
t.Fatal("a provision the module does not offer reads as issued outside")
}
if _, issued := m.IssuedCredentialOf("tv-api"); issued {
t.Fatal("a credential issued outside was read as issued by the software")
}
if _, shared := m.SharedCredentialOf("tv-api"); shared {
t.Fatal("a credential issued outside was read as an own secret shared")
}
if who, offered := OffersIssuedOutside(map[string]Manifest{"tv": m}, "tv-api"); !offered || who != "tv" {
t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered)
}
for want, raw := range map[string]string{
"names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}},
"provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`,
"names the tool": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`,
"the issuers an offer may name": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`,
} {
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("expected a refusal saying %q, got %v", want, err)
}
}
}
+154
View File
@@ -0,0 +1,154 @@
package catalogue
import (
"fmt"
"net/netip"
"slices"
"sort"
"strings"
)
// AttachedNetwork is one network a machine said it is directly attached to: the link it is on and the
// network as address and prefix (novox/hq ADR 0340 §7), as the node-engine reports it among its facts.
type AttachedNetwork struct {
Link string `json:"link"`
Network string `json:"network"`
}
// LocalNetwork is one attached network judged: whether it counts as this machine's local network for a
// port reached from `local-network`, and why — said for one that counts as well as for one that does
// not, so `node` can show which counted and why the others did not (ADR 0340 §8).
type LocalNetwork struct {
Link string
Network string
Counts bool
Why string
}
// privateRanges are the only ranges a local network may lie in (ADR 0340 §2): RFC 1918 for IPv4 and the
// unique local addresses for IPv6. A network counts only when it lies wholly inside one of them.
var privateRanges = []struct {
prefix netip.Prefix
name string
}{
{netip.MustParsePrefix("10.0.0.0/8"), "a private range (RFC 1918, 10.0.0.0/8)"},
{netip.MustParsePrefix("172.16.0.0/12"), "a private range (RFC 1918, 172.16.0.0/12)"},
{netip.MustParsePrefix("192.168.0.0/16"), "a private range (RFC 1918, 192.168.0.0/16)"},
{netip.MustParsePrefix("fc00::/7"), "the IPv6 unique local range (fc00::/7)"},
}
// JudgeLocalNetworks says, for each network a machine reported, whether it counts as its local network.
//
// **A public subnet never counts** (ADR 0340 §2): a network counts only when it lies wholly in a private
// range, so `local-network` can never open a port to the internet — a machine sitting on a public /24 has
// that /24 judged and refused, never rendered. A link-local network does not count either. And only a
// link the machine reported as facing outside it counts (§3): the private network's own link and a
// container bridge are this machine's own, not its local network, whatever their addresses.
//
// Sorted by link and then network, one entry per (link, network), so what `node` shows and what the
// filter is rendered from do not depend on the order a machine happened to say them in.
func JudgeLocalNetworks(attached []AttachedNetwork, outward []string, tunnel string) []LocalNetwork {
seen := map[[2]string]bool{}
var out []LocalNetwork
for _, a := range attached {
key := [2]string{a.Link, a.Network}
if seen[key] {
continue
}
seen[key] = true
out = append(out, judgeOne(a, outward, tunnel))
}
sort.Slice(out, func(i, j int) bool {
if out[i].Link != out[j].Link {
return out[i].Link < out[j].Link
}
return out[i].Network < out[j].Network
})
return out
}
func judgeOne(a AttachedNetwork, outward []string, tunnel string) LocalNetwork {
j := LocalNetwork{Link: a.Link, Network: a.Network}
prefix, err := netip.ParsePrefix(strings.TrimSpace(a.Network))
if err != nil {
j.Why = "not a network the mesh can read (" + err.Error() + ")"
return j
}
prefix = prefix.Masked()
j.Network = prefix.String()
switch {
case tunnel != "" && a.Link == tunnel:
j.Why = "the private network's own link, not the local network"
return j
case !slices.Contains(outward, a.Link):
j.Why = fmt.Sprintf("%s does not face outside this machine (a bridge or a link of its own)", a.Link)
return j
case prefix.Addr().IsLoopback():
j.Why = "loopback"
return j
case prefix.Addr().IsLinkLocalUnicast():
j.Why = "link-local, never counted"
return j
}
for _, r := range privateRanges {
if r.prefix.Contains(prefix.Addr()) && prefix.Bits() >= r.prefix.Bits() {
j.Counts = true
j.Why = "in " + r.name
return j
}
}
if prefix.Addr().IsPrivate() {
// Overlaps a private range without lying wholly inside it (a /7 around 10.0.0.0/8, say).
j.Why = "not wholly in a private range, so never counted"
return j
}
j.Why = "public, never counted: local-network may not open a port to the internet"
return j
}
// CountedLocalNetworks is the networks that count, as the filter renders them: sorted, without repeats.
func CountedLocalNetworks(judged []LocalNetwork) []string {
var out []string
for _, j := range judged {
if j.Counts && !slices.Contains(out, j.Network) {
out = append(out, j.Network)
}
}
sort.Strings(out)
return out
}
// LocalNetworkPort is one port the filter admits from `local-network`, and what that resolved to on this
// machine (ADR 0340 §8): what `plan` shows for it.
type LocalNetworkPort struct {
Port int
Protocol string
Because []string
// Networks is the local networks it is admitted from; empty when the machine reported none (§6).
Networks []string
}
// LocalNetworkPorts is every rule admitted from `local-network`, with the networks it resolved to.
func LocalNetworkPorts(rules []Rule, networks []string) []LocalNetworkPort {
var out []LocalNetworkPort
for _, rule := range rules {
if rule.From != FromLocalNetwork {
continue
}
out = append(out, LocalNetworkPort{Port: rule.Port, Protocol: rule.Protocol,
Because: rule.Because, Networks: networks})
}
return out
}
// Said is how `plan` says one such port: the networks, or that there are none and the port is admitted
// from the private network only.
func (p LocalNetworkPort) Said() string {
by := strings.Join(p.Because, ", ")
if len(p.Networks) == 0 {
return fmt.Sprintf("%s/%d (%s) from the local network: none — this machine reported no private "+
"network on a link facing outside, so it is admitted from the private network only", p.Protocol, p.Port, by)
}
return fmt.Sprintf("%s/%d (%s) from the local network: %s, and the private network", p.Protocol, p.Port, by,
strings.Join(p.Networks, ", "))
}
+195
View File
@@ -0,0 +1,195 @@
package catalogue
import (
"strings"
"testing"
)
// The home server's case (novox/hq issue 154): it sits on 192.168.1.0/24, and also on a public /24 a
// provider handed it, beside the private network's own link and a container bridge.
var homeServer = []AttachedNetwork{
{Link: "eth0", Network: "192.168.1.10/24"},
{Link: "eth1", Network: "203.0.113.7/24"},
{Link: "eth0", Network: "fe80::1/64"},
{Link: "eth0", Network: "fd12:3456:789a::5/64"},
{Link: "wg-mesh", Network: "10.10.0.4/24"},
{Link: "docker0", Network: "172.17.0.1/16"},
}
func judged(t *testing.T) []LocalNetwork {
t.Helper()
return JudgeLocalNetworks(homeServer, []string{"eth0", "eth1"}, "wg-mesh")
}
// **A public subnet never counts** (ADR 0340 §2), and only a private one on a link facing outside does
// (§3): the tunnel's and a bridge's are this machine's own, whatever their addresses.
func TestOnlyAPrivateNetworkOnALinkFacingOutsideCounts(t *testing.T) {
got := CountedLocalNetworks(judged(t))
want := []string{"192.168.1.0/24", "fd12:3456:789a::/64"}
if strings.Join(got, " ") != strings.Join(want, " ") {
t.Fatalf("counted %v, want %v", got, want)
}
for _, j := range judged(t) {
if j.Why == "" {
t.Errorf("%s on %s is judged without a why", j.Network, j.Link)
}
switch j.Network {
case "203.0.113.0/24":
if j.Counts || !strings.Contains(j.Why, "public") {
t.Errorf("the public subnet is judged %v (%s), want never counted as public", j.Counts, j.Why)
}
case "fe80::/64":
if j.Counts || !strings.Contains(j.Why, "link-local") {
t.Errorf("a link-local network is judged %v (%s)", j.Counts, j.Why)
}
case "10.10.0.0/24", "172.17.0.0/16":
if j.Counts {
t.Errorf("%s on %s counts, and it is not the local network", j.Network, j.Link)
}
}
}
}
// A network overlapping a private range without lying wholly inside it does not count either, so a
// wide prefix around a private range cannot carry the internet with it.
func TestANetworkNotWhollyInAPrivateRangeDoesNotCount(t *testing.T) {
for _, n := range []string{"10.0.0.0/7", "172.0.0.0/8", "100.64.0.0/10", "0.0.0.0/0", "::/0"} {
got := JudgeLocalNetworks([]AttachedNetwork{{Link: "eth0", Network: n}}, []string{"eth0"}, "")
if len(got) != 1 || got[0].Counts {
t.Errorf("%s counts as a local network: %+v", n, got)
}
}
}
// **The filter admits the local network on that port's rule alone** (§5), beside the private network,
// in both chains, and never the public subnet.
func TestALocalNetworkPortIsAdmittedFromItsNetworksAndTheMesh(t *testing.T) {
rules := []Rule{
{Port: 1883, Protocol: "tcp", From: FromLocalNetwork, Because: []string{"mosquitto"}},
{Port: 5432, Protocol: "tcp", From: FromMesh, Because: []string{"postgres"}},
}
local := CountedLocalNetworks(judged(t))
out := AsNftablesWithLocal(rules, []string{"10.10.0.1", "10.10.0.4"}, false, nil,
[]string{"eth0", "eth1"}, "wg-mesh", local)
for _, want := range []string{
"ip saddr { 10.10.0.1, 10.10.0.4, 192.168.1.0/24 } tcp dport 1883 accept",
"ip6 saddr { fd12:3456:789a::/64 } tcp dport 1883 accept",
"ip saddr { 10.10.0.1, 10.10.0.4, 192.168.1.0/24 } ct original proto-dst 1883 accept",
// The mesh-only port is not widened by the machine having a local network.
"ip saddr { 10.10.0.1, 10.10.0.4 } tcp dport 5432 accept",
} {
if !strings.Contains(out, want) {
t.Errorf("the rule set lacks %q:\n%s", want, out)
}
}
if strings.Contains(out, "203.0.113") {
t.Errorf("the public subnet reached the rule set:\n%s", out)
}
if strings.Contains(out, "tcp dport 1883 accept\n") && !strings.Contains(out, "saddr") {
t.Errorf("1883 is open to anywhere:\n%s", out)
}
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "tcp dport 1883 accept" {
t.Fatalf("1883 is open to anywhere:\n%s", out)
}
}
}
// **Nothing resolved is closed and said** (§6): a machine that reported no private network admits the
// port from the private network only, and the rule set says it found none — never widened to anywhere.
func TestNoLocalNetworkIsSaidAndNotWidened(t *testing.T) {
rules := []Rule{{Port: 1883, Protocol: "tcp", From: FromLocalNetwork, Because: []string{"mosquitto"}}}
out := AsNftablesWithLocal(rules, []string{"10.10.0.1"}, false, nil, []string{"eth0"}, "wg-mesh", nil)
if !strings.Contains(out, "1883 wanted the local network, and this machine reported no private network") {
t.Errorf("the rule set does not say the local network resolved to nothing:\n%s", out)
}
if !strings.Contains(out, "ip saddr { 10.10.0.1 } tcp dport 1883 accept") {
t.Errorf("the port is not admitted from the private network:\n%s", out)
}
for _, line := range strings.Split(out, "\n") {
if strings.TrimSpace(line) == "tcp dport 1883 accept" {
t.Fatalf("an unresolved local network was widened to anywhere:\n%s", out)
}
}
said := LocalNetworkPorts(rules, nil)[0].Said()
if !strings.Contains(said, "none") {
t.Errorf("plan says %q, want it to say no local network was found", said)
}
}
// `local-network` is a source a manifest may name (§1, changing ADR 0045 §3), and an unknown source is
// still refused.
func TestAManifestMayListenFromTheLocalNetwork(t *testing.T) {
if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1",
"listens":[{"port":1883,"from":"local-network"}]}`)); err != nil {
t.Fatalf("a listen from the local network is refused: %v", err)
}
_, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1",
"listens":[{"port":1883,"from":"lan"}]}`))
if err == nil || !strings.Contains(err.Error(), `from "lan"`) {
t.Fatalf("a source the mesh cannot render is not refused: %v", err)
}
}
// **A fourth reach** (§4, changing ADR 0138 §4): `internal` with the local networks added to what the
// filter opens — the internal name, not the public one.
func TestTheLocalNetworkReachIsInternalWithTheLocalNetworkAdded(t *testing.T) {
public, internal := namesFor(t, aRoutedWeb(), reachSet(ReachLocalNetwork))
if public != "" || internal != "app.anchor.internal" {
t.Fatalf("names are %q and %q, want the internal name only", public, internal)
}
bare := aRoutedWeb()
bare.Contributes = nil
r := Resolution{Node: "anchor", Modules: []Manifest{bare}}
rules, err := r.Rules(Rendering{Settings: reachSet(ReachLocalNetwork)})
if err != nil {
t.Fatalf("rules: %v", err)
}
if len(rules) != 1 || rules[0].From != FromLocalNetwork {
t.Fatalf("reach local-network made the filter say %+v", rules)
}
}
// Two modules wanting one port, one from the mesh and one from the local network, render one rule from
// the local network: it covers the mesh, and anywhere covers it.
func TestTheLocalNetworkSitsBetweenTheMeshAndAnywhere(t *testing.T) {
got := widest([]Rule{
{Port: 53, Protocol: "udp", From: FromMesh, Because: []string{"a"}},
{Port: 53, Protocol: "udp", From: FromLocalNetwork, Because: []string{"b"}},
})
if len(got) != 1 || got[0].From != FromLocalNetwork || len(got[0].Because) != 2 {
t.Fatalf("got %+v", got)
}
got = widest(append(got, Rule{Port: 53, Protocol: "udp", From: FromEverywhere, Because: []string{"c"}}))
if len(got) != 1 || got[0].From != FromEverywhere {
t.Fatalf("got %+v", got)
}
}
// On an adopted node the opening is the private network's part only: the found firewall admits the
// local network, and an opening that widened to everywhere would open the port to the internet.
func TestAnAdoptedNodeOpensTheLocalNetworkPortToTheMeshOnly(t *testing.T) {
out := Openings([]Rule{{Port: 1883, Protocol: "tcp", From: FromLocalNetwork}}, nil, nil)
if len(out) != 1 || out[0]["from"] != OpeningFromMesh {
t.Fatalf("openings %v", out)
}
}
// A global unicast IPv6 subnet on a link facing outside is public, and never counts: the IPv6 side of the
// rule that `local-network` cannot open a port to the internet (ADR 0340 §2).
func TestAGlobalIPv6SubnetIsPublicAndNeverCounts(t *testing.T) {
got := JudgeLocalNetworks([]AttachedNetwork{{Link: "eth0", Network: "2001:db8:1234::7/48"}}, []string{"eth0"}, "wg-mesh")
if len(got) != 1 {
t.Fatalf("judged %+v", got)
}
if got[0].Counts {
t.Fatalf("the global IPv6 subnet %s counts as a local network", got[0].Network)
}
if got[0].Network != "2001:db8:1234::/48" || !strings.Contains(got[0].Why, "public, never counted") {
t.Fatalf("judged %s with why %q, want it named public and never counted", got[0].Network, got[0].Why)
}
if n := CountedLocalNetworks(got); len(n) != 0 {
t.Fatalf("counted %v", n)
}
}
+60 -9
View File
@@ -269,7 +269,8 @@ func KeepsConsumerData(catalogue map[string]Manifest, provision string) bool {
func (o Offer) MachineReach() bool { return o.Reach == ReachMachine } func (o Offer) MachineReach() bool { return o.Reach == ReachMachine }
// OfferCredential names which of the provider's own secrets a provision's consumers receive — or, with // OfferCredential names which of the provider's own secrets a provision's consumers receive — or, with
// IssuedBy, that the provider's software issues the provision's one credential itself (novox/hq ADR 0305, // IssuedBy "outside", that only a person can give the provision's credential (novox/hq ADR 0337, issue 164) — or,
// with IssuedBy "software", that the provider's software issues the provision's one credential itself (novox/hq ADR 0305,
// issue 526): a media server's sign-in token from its vendor, which the mesh can neither make nor rotate. // issue 526): a media server's sign-in token from its vendor, which the mesh can neither make nor rotate.
// Tool is then the provider's own tool that answers that value, sealed to a key made for the controller's // Tool is then the provider's own tool that answers that value, sealed to a key made for the controller's
// one call, and to the controller alone. // one call, and to the controller alone.
@@ -293,6 +294,34 @@ func (m Manifest) IssuedCredentialOf(provision string) (string, bool) {
return "", false return "", false
} }
// IssuedOutsideOf is whether this module's offer of a provision says only a person outside the mesh can give its
// credential (novox/hq ADR 0337): the mesh never makes a value for it, and a consumer holds one only once a person
// gave it with `secret accept`.
func (m Manifest) IssuedOutsideOf(provision string) bool {
for _, o := range m.Provides {
if o.Name == provision && o.Credential != nil && o.Credential.IssuedBy == IssuedOutside {
return true
}
}
return false
}
// OffersIssuedOutside is whether any module of a catalogue offers the provision with a credential issued outside
// the mesh (novox/hq ADR 0337), and the first such module by name.
func OffersIssuedOutside(shelf map[string]Manifest, provision string) (string, bool) {
var names []string
for name, m := range shelf {
if m.IssuedOutsideOf(provision) {
names = append(names, name)
}
}
if len(names) == 0 {
return "", false
}
sort.Strings(names)
return names[0], true
}
// SharedCredentialOf is the own secret an offer of this module names as the provision's credential, // SharedCredentialOf is the own secret an offer of this module names as the provision's credential,
// and whether it names one. // and whether it names one.
func (m Manifest) SharedCredentialOf(provision string) (string, bool) { func (m Manifest) SharedCredentialOf(provision string) (string, bool) {
@@ -1136,6 +1165,12 @@ const (
FromEverywhere = "anywhere" FromEverywhere = "anywhere"
// FromMachine is this machine only — a port bound for something else on the same host. // FromMachine is this machine only — a port bound for something else on the same host.
FromMachine = "machine" FromMachine = "machine"
// FromLocalNetwork is the private network and the networks this machine is directly attached to
// through its uplink, as it reports them, and only those wholly in a private range (novox/hq ADR
// 0340): a local service reached by devices that will never be nodes, said as exactly that rather
// than as `anywhere`. A public subnet the machine sits on never counts, so it cannot open a port to
// the internet.
FromLocalNetwork = "local-network"
) )
// At is this port's protocol, with the default applied. // At is this port's protocol, with the default applied.
@@ -1647,14 +1682,28 @@ func ParseManifest(raw []byte) (Manifest, error) {
"makes is %d", m.Module, p, id.Max, shortest)) "makes is %d", m.Module, p, id.Max, shortest))
} }
} }
if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") { if c := offer.Credential; c != nil && c.IssuedBy == IssuedOutside {
// Only a person can give it (novox/hq ADR 0337 §1, issue 164): the software holds a value the mesh can
// neither make nor fetch, so the offer names nothing that would answer it — no own secret, no tool.
switch {
case c.Own != "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential issued outside the mesh and names its own secret %q too; a "+
"person gives the value with `secret accept`, so the offer names no own secret (ADR 0337)",
m.Module, p, c.Own))
case c.Tool != "":
problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential issued outside the mesh and names the tool %q; only a "+
"person gives it, so the offer names no tool (ADR 0337)", m.Module, p, c.Tool))
}
} else if c := offer.Credential; c != nil && (c.IssuedBy != "" || c.Tool != "") {
// The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own // The software issues it (novox/hq ADR 0305): named by the tool that answers it, never by an own
// secret, which would be a second copy for the mesh to keep in step with the software's. // secret, which would be a second copy for the mesh to keep in step with the software's.
switch { switch {
case c.IssuedBy != IssuedBySoftware: case c.IssuedBy != IssuedBySoftware:
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential issued by %q; the one issuer an offer may name is %q (ADR 0305)", "%s provides %q with a credential issued by %q; the issuers an offer may name are %q and %q "+
m.Module, p, c.IssuedBy, IssuedBySoftware)) "(ADR 0305, ADR 0337)", m.Module, p, c.IssuedBy, IssuedBySoftware, IssuedOutside))
case c.Own != "": case c.Own != "":
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s provides %q with a credential its software issues and names its own secret %q too; the "+ "%s provides %q with a credential its software issues and names its own secret %q too; the "+
@@ -1886,17 +1935,17 @@ func ParseManifest(raw []byte) (Manifest, error) {
"%s listens on port %d, which is not a port", m.Module, l.Port)) "%s listens on port %d, which is not a port", m.Module, l.Port))
} }
switch l.From { switch l.From {
case FromMesh, FromEverywhere, FromMachine: case FromMesh, FromEverywhere, FromMachine, FromLocalNetwork:
case "": case "":
// The fault this field exists to prevent. A rule with no source is open, and a // The fault this field exists to prevent. A rule with no source is open, and a
// manifest that omitted it would read as a restriction and be none. // manifest that omitted it would read as a restriction and be none.
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s listens on %d and does not say from where; it is %q, %q or %q", "%s listens on %d and does not say from where; it is %q, %q, %q or %q",
m.Module, l.Port, FromMesh, FromEverywhere, FromMachine)) m.Module, l.Port, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork))
default: default:
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
"%s listens on %d from %q; it is %q, %q or %q", "%s listens on %d from %q; it is %q, %q, %q or %q",
m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine)) m.Module, l.Port, l.From, FromMesh, FromEverywhere, FromMachine, FromLocalNetwork))
} }
if p := l.At(); p != "tcp" && p != "udp" { if p := l.At(); p != "tcp" && p != "udp" {
problems = append(problems, fmt.Sprintf( problems = append(problems, fmt.Sprintf(
@@ -2287,6 +2336,8 @@ func ParseManifest(raw []byte) (Manifest, error) {
// How each long-running resource is ready (novox/hq ADR 0240 rule 2): said near its author, in the // How each long-running resource is ready (novox/hq ADR 0240 rule 2): said near its author, in the
// words the node-engine would refuse it in far away. // words the node-engine would refuse it in far away.
problems = append(problems, healthProblems(m)...) problems = append(problems, healthProblems(m)...)
// And whether a container is busy (novox/hq ADR 0335), likewise.
problems = append(problems, busyProblems(m)...)
if len(problems) > 0 { if len(problems) > 0 {
sort.Strings(problems) sort.Strings(problems)
return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s", return Manifest{}, fmt.Errorf("this manifest cannot be used:\n - %s",
+7 -1
View File
@@ -111,6 +111,9 @@ type Provider struct {
// (novox/hq to-be 23), and the pair is what tells the holder of a seat apart from another module // (novox/hq to-be 23), and the pair is what tells the holder of a seat apart from another module
// providing the same thing (ADR 0110). // providing the same thing (ADR 0110).
Module string Module string
// LeftOut says each served key left out of Serves because that node composes no name it asks
// for (novox/hq ADR 0346 §3): said under each consumer's need.
LeftOut []string
} }
// Held is a claim somebody already has, used for the scopes wider than one node. // Held is a claim somebody already has, used for the scopes wider than one node.
@@ -230,6 +233,9 @@ type Needed struct {
// state, not a consumer missing its key. Set by the plan, which is the only layer that knows a // state, not a consumer missing its key. Set by the plan, which is the only layer that knows a
// licence's manager; empty for every consumer. // licence's manager; empty for every consumer.
Manager bool Manager bool
// LeftOut says each served key the provider left out, because its machine composes no name the
// value asks for (novox/hq ADR 0346 §3).
LeftOut []string
// Module is the providing module on From, empty for a need answered by a record or by nothing // Module is the providing module on From, empty for a need answered by a record or by nothing
// that serves it. A provider is a (node, module) pair (novox/hq to-be 23), and a binding to data // that serves it. A provider is a (node, module) pair (novox/hq to-be 23), and a binding to data
// is a binding to the pair (ADR 0232). // is a binding to the pair (ADR 0232).
@@ -582,7 +588,7 @@ func Resolve(catalogue map[string]Manifest, assigned []string, node Node, world
bound = pm.IdentityBoundOf(want) bound = pm.IdentityBoundOf(want)
} }
needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, Module: p.Module, needs = append(needs, Needed{Name: want, From: p.Node, At: p.At, Module: p.Module,
Serves: p.Serves, For: because[want], SharedOwn: shared, Identity: bound}) Serves: p.Serves, For: because[want], SharedOwn: shared, Identity: bound, LeftOut: p.LeftOut})
} }
switch { switch {
case world.Unchecked: case world.Unchecked:
+3 -2
View File
@@ -331,12 +331,13 @@ func UnassignRefusal(catalogue map[string]Manifest, node string, assigned, remov
} }
// CollisionRefusal is why assigning `adding` beside `assigned` is refused for what two modules // CollisionRefusal is why assigning `adding` beside `assigned` is refused for what two modules
// would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235). // would both declare, or nothing (novox/hq ADR 0210 §1, 04-ISSUES/235): one path, unit or name, or a package one
// declares present and another absent. A package several modules declare present is no collision (ADR 0303).
// //
// **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the // **Refused, not kept like an unresolved provision.** An assignment is otherwise kept when the
// node does not resolve, because assignment is not an ordering: a consumer's provider can follow. // node does not resolve, because assignment is not an ordering: a consumer's provider can follow.
// A collision is not an order anything can complete — no further assignment makes two owners of one // A collision is not an order anything can complete — no further assignment makes two owners of one
// package one owner — and kept, it leaves the node unresolvable, so the next push of anything drops // file one owner — and kept, it leaves the node unresolvable, so the next push of anything drops
// it from the mesh. Only collisions involving a module being added are refused; one already on the // it from the mesh. Only collisions involving a module being added are refused; one already on the
// node is `status`'s, and refusing an unrelated assignment for it would block its own remedy. // node is `status`'s, and refusing an unrelated assignment for it would block its own remedy.
func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error { func CollisionRefusal(catalogue map[string]Manifest, node string, assigned, adding []string) error {
+202
View File
@@ -0,0 +1,202 @@
package catalogue
import (
"fmt"
"sort"
"strings"
)
// A provider may tell its consumers its own composed names (novox/hq ADR 0346, issue 624).
//
// **The name is already the mesh's.** A module contributes a label to a requirement — a route — and
// the mesh joins it with the providing machine's domains; the module reads the result in its own files
// as `${bound:<requirement>:name}` (novox/hq 04-ISSUES/122). Its consumers could not be told it: a
// served value named only the consumer (ADR 0202) and settings. The forge's package registry writes
// every tarball at its public name, and a consumer that must trust that name had no source for it but a
// second copy kept by hand. So a served value may say `${bound:<requirement>:<name>}` for a requirement
// the provider contributes to, and the mesh fills it from the same composition the provider's own files
// and its proxy receive.
//
// **A name the providing machine does not compose leaves the key out** (ADR 0346 §3): a machine with
// no public domain has no public name to give, and an empty value or a refusal would be worse than a
// consumer told nothing — a refused provider machine takes every provision it offers down with it. The
// plan says which key was left out, and why, on the provider's machine and under each consumer's need.
// ownNameFacts are what a served value may name of the provider's own composition: the names, as its
// own files read them (withOwnNames).
var ownNameFacts = []string{"name", "internal-name"}
// requirementsContributedTo is every requirement m contributes to, sorted.
func requirementsContributedTo(m Manifest) []string {
seen := map[string]bool{}
for to := range m.Contributes {
seen[to] = true
}
for to := range m.ContributesMany {
seen[to] = true
}
out := make([]string, 0, len(seen))
for to := range seen {
out = append(out, to)
}
sort.Strings(out)
return out
}
// ownNameKeys is every name a served value of m may say for one requirement it contributes to:
// `name` and `internal-name`, and `name-<local>` and `internal-name-<local>` for each local name it
// contributes under.
func ownNameKeys(m Manifest, to string) []string {
keys := append([]string{}, ownNameFacts...)
for _, local := range sortedKeys(m.ContributesMany[to]) {
for _, fact := range ownNameFacts {
keys = append(keys, fact+"-"+local)
}
}
return keys
}
// ServedOwnNames fills the provider's own composed names into what it serves one provision's
// consumers, from this resolution's composition of the provider's machine (ADR 0346).
//
// A value naming a name this machine does not compose is left out, with its key, and said. The map is
// copied only when something changes, as ServedTo does: the caller's may be the manifest's.
func (r Resolution) ServedOwnNames(m Manifest, provision string, serves map[string]any, layers []Layer) (
map[string]any, []string, error) {
var asking []string
for _, key := range sortedAnyKeys(serves) {
if text, ok := serves[key].(string); ok && bound.MatchString(text) {
asking = append(asking, key)
}
}
if len(asking) == 0 {
return serves, nil, nil
}
own := map[string]map[string]string{}
for _, to := range requirementsContributedTo(m) {
names, err := r.ownNames(m, to, layers)
if err != nil {
return nil, nil, err
}
values := map[string]string{}
withOwnNames(values, names)
own[to] = values
}
out := make(map[string]any, len(serves))
for k, v := range serves {
out[k] = v
}
var said []string
for _, key := range asking {
text := serves[key].(string)
var missing string
filled := bound.ReplaceAllStringFunc(text, func(token string) string {
parts := bound.FindStringSubmatch(token)
if value := own[parts[1]][parts[2]]; value != "" && parts[3] == "" {
return value
}
if missing == "" {
missing = token
}
return token
})
if missing != "" {
delete(out, key)
said = append(said, fmt.Sprintf(
"%s serves %s without %q: it names %s, and %s composes no such name for %s (it has no "+
"public domain, or the endpoint is not reached publicly) (novox/hq ADR 0346)",
m.Module, provision, key, missing, orHere(r.Node), m.Module))
continue
}
out[key] = filled
}
return out, said, nil
}
func orHere(node string) string {
if node == "" {
return "this machine"
}
return node
}
// ServedLeftOut is what the providers in this node's own set leave out of what they serve, because
// this machine composes no name a served value asks for (ADR 0346 §3): said on the provider's machine,
// where the remedy is.
func (r Resolution) ServedLeftOut(settings SettingsBy) []string {
var said []string
for _, m := range r.Modules {
for _, provision := range sortedServes(m.Serves) {
_, lines, err := r.ServedOwnNames(m, provision, m.Serves[provision], WithDefaults(m, settings[m.Module]))
if err != nil {
said = append(said, fmt.Sprintf("%s serves %s, and its own names could not be composed: %v",
m.Module, provision, err))
continue
}
said = append(said, lines...)
}
}
return said
}
// checkServedOwnNames refuses a served `${bound:…}` that names a requirement the module does not
// contribute to, a fact other than its own names, or a local name it does not contribute under; and
// one with a default, since a served value is left out rather than given one (ADR 0346 §3, §4).
func checkServedOwnNames(m Manifest, provision, key, text string) []string {
var problems []string
contributes := requirementsContributedTo(m)
for _, parts := range bound.FindAllStringSubmatch(text, -1) {
to, fact, fallback := parts[1], parts[2], parts[3]
says := fmt.Sprintf("%s serves %s, and the value it serves as %q says %s", m.Module, provision, key, parts[0])
switch {
case fallback != "":
problems = append(problems, says+": a served name takes no default; one the machine does not "+
"compose leaves the key out (novox/hq ADR 0346)")
case !oneOf(contributes, to):
problems = append(problems, fmt.Sprintf(
"%s, and %s contributes to %s: a served value names only the provider's own names "+
"(novox/hq ADR 0346)", says, m.Module, orNothing(quoted(contributes))))
case !oneOf(ownNameKeys(m, to), fact):
problems = append(problems, fmt.Sprintf("%s, and what %s is called through %s is %s (novox/hq ADR 0346)",
says, m.Module, to, strings.Join(ownNameKeys(m, to), ", ")))
}
}
return problems
}
func quoted(values []string) []string {
out := make([]string, len(values))
for i, v := range values {
out[i] = fmt.Sprintf("%q", v)
}
return out
}
// nestedOwnNames refuses a `${bound:…}` inside a served list or object: only a string value of its own is
// filled (ServedOwnNames), so a nested one would reach the consumer as the placeholder (ADR 0346 §5).
func nestedOwnNames(m Manifest, provision, key string, value any) []string {
var found []string
var walk func(v any)
walk = func(v any) {
switch x := v.(type) {
case string:
found = append(found, bound.FindAllString(x, -1)...)
case []any:
for _, e := range x {
walk(e)
}
case map[string]any:
for _, k := range sortedAnyKeys(x) {
walk(x[k])
}
}
}
walk(value)
if len(found) == 0 {
return nil
}
return []string{fmt.Sprintf(
"%s serves %s, and the value it serves as %q holds %s inside a list or an object: a provider's own "+
"name is filled only in a string value of its own (novox/hq ADR 0346)",
m.Module, provision, key, strings.Join(found, ", "))}
}
+168
View File
@@ -0,0 +1,168 @@
package catalogue
import (
"strings"
"testing"
)
// novox/hq ADR 0346, issue 624: the forge's package registry writes every tarball at its public name,
// and the lab, its consumer, must trust that name and no other. The name is the mesh's own composition
// of the forge's route, which the forge's files read as ${bound:route:name-web}; a consumer can now be
// served it.
const aRegistryForge = `{"module":"forge","version":"1","requires":["route"],
"provides":[{"name":"npm-package-registry","scope":"mesh"}],
"contributes":{"route":{"web":{"label":"git","port":3000}}},
"serves":{"npm-package-registry":{"scheme":"http","port":3000,"npm-path":"/api/packages/novox/npm/",
"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"}}}`
func aForgeManifest(t *testing.T) Manifest {
t.Helper()
m, err := ParseManifest([]byte(aRegistryForge))
if err != nil {
t.Fatal(err)
}
return m
}
func TestAProviderServesItsOwnComposedName(t *testing.T) {
m := aForgeManifest(t)
r := Resolution{Node: "home", PublicDomain: "example.org"}
got, said, err := r.ServedOwnNames(m, "npm-package-registry", m.Serves["npm-package-registry"], nil)
if err != nil {
t.Fatal(err)
}
if got["public-root"] != "https://git.example.org/api/packages/novox/npm/" || len(said) != 0 {
t.Fatalf("the public root is the route's composed name: %v, said %v", got, said)
}
if got["npm-path"] != "/api/packages/novox/npm/" || got["port"] != float64(3000) {
t.Fatalf("the rest is served as it was: %v", got)
}
if m.Serves["npm-package-registry"]["public-root"] != "https://${bound:route:name-web}/api/packages/novox/npm/" {
t.Fatal("the manifest's own map was rewritten")
}
}
func TestANameTheMachineDoesNotComposeLeavesTheKeyOutAndSaysSo(t *testing.T) {
m := aForgeManifest(t)
r := Resolution{Node: "home"} // no public domain
got, said, err := r.ServedOwnNames(m, "npm-package-registry", m.Serves["npm-package-registry"], nil)
if err != nil {
t.Fatal(err)
}
if _, there := got["public-root"]; there {
t.Fatalf("a name not composed is left out, never written as the placeholder or empty: %v", got)
}
if got["npm-path"] == nil {
t.Fatalf("only the key that names it is left out: %v", got)
}
if len(said) != 1 || !strings.Contains(said[0], `forge serves npm-package-registry without "public-root"`) ||
!strings.Contains(said[0], "home composes no such name") {
t.Fatalf("said: %v", said)
}
if lines := (Resolution{Node: "home", Modules: []Manifest{m}}).ServedLeftOut(nil); len(lines) != 1 {
t.Fatalf("the plan on the provider's machine says it: %v", lines)
}
if lines := (Resolution{Node: "home", PublicDomain: "example.org", Modules: []Manifest{m}}).ServedLeftOut(nil); len(lines) != 0 {
t.Fatalf("nothing to say when the name is composed: %v", lines)
}
}
// On the provider's own machine the consumer is served the same name (ADR 0346 §2).
func TestAConsumerBesideTheProviderIsServedItsName(t *testing.T) {
m := aForgeManifest(t)
consumer := Manifest{Module: "lab", Version: "1", Requires: []string{"npm-package-registry"},
Binds: map[string]string{"npm-package-registry": "/var/lib/lab/package-registry.json"}}
node := workstation()
node.PublicDomain = "example.org"
got, err := Resolve(shelf(minimalRouteProxy(), m, consumer), []string{"route-proxy", "forge", "lab"}, node, World{})
if err != nil {
t.Fatal(err)
}
served, answered, err := got.servedOnThisMachine("npm-package-registry", Rendering{})
if err != nil || !answered {
t.Fatalf("answered %v: %v", answered, err)
}
if served["public-root"] != "https://git.example.org/api/packages/novox/npm/" {
t.Fatalf("served beside the provider: %v", served)
}
out, err := got.Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
var binding string
for _, r := range out {
if r["path"] == "/var/lib/lab/package-registry.json" {
binding, _ = r["content"].(string)
}
}
if !strings.Contains(binding, `"public-root": "https://git.example.org/api/packages/novox/npm/"`) &&
!strings.Contains(binding, `"public-root":"https://git.example.org/api/packages/novox/npm/"`) {
t.Fatalf("the consumer's binding carries the name:\n%s", binding)
}
if strings.Contains(binding, "${bound:") {
t.Fatalf("a placeholder reached the consumer:\n%s", binding)
}
}
// From another machine, the provider's served values carry what that machine composed, and a key it
// left out is said on the consumer's need (ADR 0346 §2, §3).
func TestAConsumerElsewhereIsToldWhatTheProviderLeftOut(t *testing.T) {
consumer := Manifest{Module: "lab", Version: "1", Requires: []string{"npm-package-registry"},
Binds: map[string]string{"npm-package-registry": "/var/lib/lab/package-registry.json"}}
node := workstation()
node.At = "workstation.internal"
world := World{Offered: map[string][]Provider{"npm-package-registry": {{Node: "home", At: "home.internal",
Module: "forge", Serves: map[string]any{"port": 3000},
LeftOut: []string{`forge serves npm-package-registry without "public-root"`}}}}}
got, err := Resolve(shelf(aForgeManifest(t), consumer), []string{"lab"}, node, world)
if err != nil {
t.Fatal(err)
}
for _, n := range got.Needs {
if n.Name == "npm-package-registry" {
if len(n.LeftOut) != 1 {
t.Fatalf("the need carries what was left out: %+v", n)
}
return
}
}
t.Fatalf("no need: %+v", got.Needs)
}
func TestAServedNameIsCheckedWhenTheDefinitionIsRead(t *testing.T) {
for bad, want := range map[string]string{
`"public-root":"https://${bound:route:name-api}/x"`: "what forge is called through route is name, internal-name, name-web, internal-name-web",
`"public-root":"https://${bound:postgres-database:name}/x"`: `forge contributes to "route"`,
`"public-root":"https://${bound:route:name-web|default:x}/x"`: "takes no default",
`"public-root":"https://${bound:route:at}/x"`: "what forge is called through route",
} {
raw := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`, bad, 1)
_, err := ParseManifest([]byte(raw))
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: want a refusal saying %q, got %v", bad, want, err)
}
}
nested := strings.Replace(aRegistryForge, `"public-root":"https://${bound:route:name-web}/api/packages/novox/npm/"`,
`"roots":["https://${bound:route:name-web}/x"]`, 1)
if _, err := ParseManifest([]byte(nested)); err == nil || !strings.Contains(err.Error(), "inside a list or an object") {
t.Errorf("a nested own name is refused, not served as the placeholder: %v", err)
}
if _, err := ParseManifest([]byte(aRegistryForge)); err != nil {
t.Fatalf("its own name is accepted: %v", err)
}
}
// The provider's contributions file carries no own name on a consumer's entry (ADR 0346 §4): only what
// is derived for that consumer is there (ADR 0202 §5), and the provider reads its own names in its files.
func TestAProvidersOwnNameIsNotOnItsConsumersEntry(t *testing.T) {
m := aForgeManifest(t)
r := Resolution{Node: "home", PublicDomain: "example.org", Modules: []Manifest{m}}
derived, err := r.derivedFor("npm-package-registry", "lab_home", "lab", "", nil)
if err != nil {
t.Fatal(err)
}
if derived != nil {
t.Fatalf("a consumer's entry carries only what was derived for it: %v", derived)
}
}
+61
View File
@@ -192,6 +192,16 @@ func settle(base map[string]any, layers []Layer, protected map[string]bool, what
"%s sets %q on %v, and that module keeps %q for itself — it is not settable", "%s sets %q on %v, and that module keeps %q for itself — it is not settable",
layer.From, key, what, key) layer.From, key, what, key)
} }
// **No layer replaces a secret the vault fills, at any depth** (novox/hq issue 610): a
// mergeable file's `${secret:<name>}` is the vault's value, and a layer that reached it —
// `server.secret_key` under a `server` the file takes — would put a value the operator or an
// agent chose where the vault's belongs. Judged against the file's own content, so it holds
// whatever a module's `protected` list says.
if at := secretReplacedBy(base[key], value, key); at != "" {
return nil, fmt.Errorf(
"%s sets %q on %v, which the vault fills (${secret:…}); a secret comes from the vault, "+
"never from a setting (novox/hq issues 342, 610)", layer.From, at, what)
}
merged[key] = mergeValue(merged[key], value) merged[key] = mergeValue(merged[key], value)
} }
} }
@@ -576,3 +586,54 @@ func JudgeSettings(m Manifest, layers []Layer, adopted bool) error {
} }
return nil return nil
} }
// secretReplacedBy is the path in a mergeable file's own content that a layer's value would replace while the
// vault fills it — a string holding `${secret:` — or "" when the value leaves every such string as it is
// (novox/hq issue 610). Maps merge key by key (mergeValue), so only the keys the value names are judged; any
// other value replaces the whole of what it lands on.
func secretReplacedBy(base, value any, at string) string {
if baseMap, isMap := base.(map[string]any); isMap {
if valueMap, also := value.(map[string]any); also {
keys := make([]string, 0, len(valueMap))
for k := range valueMap {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if found := secretReplacedBy(baseMap[k], valueMap[k], at+"."+k); found != "" {
return found
}
}
return ""
}
}
return secretIn(base, at)
}
// secretIn is the first path under v holding a vault placeholder, or "".
func secretIn(v any, at string) string {
switch t := v.(type) {
case string:
if strings.Contains(t, "${secret:") {
return at
}
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if found := secretIn(t[k], at+"."+k); found != "" {
return found
}
}
case []any:
for i, item := range t {
if found := secretIn(item, fmt.Sprintf("%s[%d]", at, i)); found != "" {
return found
}
}
}
return ""
}
+26
View File
@@ -234,3 +234,29 @@ func TestAServedValueMayBeTheOperators(t *testing.T) {
t.Errorf("a setting a served fact asks for is not stray: %v", unused) t.Errorf("a setting a served fact asks for is not stray: %v", unused)
} }
} }
// No layer replaces a value the vault fills, at any depth (novox/hq issue 610): searxng's server.secret_key
// sits under `server`, a key its file takes, and a layer reaching it was let through by a top-level-only check.
func TestNoLayerReplacesAVaultSecretAtAnyDepth(t *testing.T) {
file := map[string]any{"id": "settings", "type": "file", "merge": MergeJSON,
"content": `{"server": {"secret_key": "${secret:secret}", "port": 8080}, "ui": {"theme": "simple"}, "token": "${secret:token}"}`}
layer := func(values map[string]any) []Layer { return []Layer{{From: "anchor", Values: values}} }
merged, err := ApplySettings(file, layer(map[string]any{"server": map[string]any{"port": float64(9000)}, "ui": map[string]any{"theme": "dark"}}))
if err != nil {
t.Fatalf("a key beside the secret was refused: %v", err)
}
if c := merged["content"].(string); !strings.Contains(c, `"secret_key": "${secret:secret}"`) || !strings.Contains(c, `"port": 9000`) {
t.Fatalf("the secret or the setting did not land as meant:\n%s", c)
}
for name, values := range map[string]map[string]any{
"the nested secret": {"server": map[string]any{"secret_key": "chosen"}},
"the whole map holding it": {"server": "elsewhere"},
"a top-level secret": {"token": "chosen"},
"the secret, emptied": {"server": map[string]any{"secret_key": ""}},
} {
if _, err := ApplySettings(file, layer(values)); err == nil || !strings.Contains(err.Error(), "vault") {
t.Errorf("%s: %v, want a refusal naming the vault", name, err)
}
}
}
+10 -2
View File
@@ -217,14 +217,16 @@ var ControllerVerbs = []Verb{
Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})}, Input: schema(map[string]string{"node": "the machine's name", "provision": "the provision"}, []string{"node", "provision"})},
{Name: "push", Description: "Send one machine everything it should be. With no machine named it is a push of the " + {Name: "push", Description: "Send one machine everything it should be. With no machine named it is a push of the " +
"WHOLE mesh — every machine that is behind — and the answer says so first; behind says that outright. " + "WHOLE mesh — every machine that is behind — and the answer says so first; behind says that outright. " +
"Answers at once that it is running, with a call id: `calls` with that id says what it sent " + "Answers at once that it is running, with a call id: `calls` with that id says what it sent and, for a " +
"(a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " + "machine named, how that machine applied it — applied, refused with its reason, or each failed resource — " +
"once it reports within wait (a push can reload the bus, which then refuses any answer still to come). A push by hand is a repair, " +
"and says why: recorded in the hand-act log (novox/hq to-be 45 §7). A machine whose push would give a " + "and says why: recorded in the hand-act log (novox/hq to-be 45 §7). A machine whose push would give a " +
"running module's data another directory is held and named, the others sent (novox/hq ADR 0217): read " + "running module's data another directory is held and named, the others sent (novox/hq ADR 0217): read " +
"`plan` with diff, and name the machine with move to send it.", "`plan` with diff, and name the machine with move to send it.",
Input: schema(map[string]string{ Input: schema(map[string]string{
"node": "the machine's name; without it, every machine that is behind", "node": "the machine's name; without it, every machine that is behind",
"move": "with node: the modules whose data may move with this push, comma-separated (optional)", "move": "with node: the modules whose data may move with this push, comma-separated (optional)",
"wait": "with node: how long the call waits for the machine to report how it applied what it was sent, as \"90s\" or \"2m\"; \"0\" waits for nothing; without it, the controller's setting push-wait (optional)",
"behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node", "behind": "\"true\": every machine that is behind, the whole mesh — the same as naming none, said outright; not with node",
"why": "why this is pushed by hand: recorded in the hand-act log", "why": "why this is pushed by hand: recorded in the hand-act log",
"cause": "the cause in a word, or a condition's kind — the word a second push for the same reason uses (optional)", "cause": "the cause in a word, or a condition's kind — the word a second push for the same reason uses (optional)",
@@ -360,6 +362,12 @@ var ControllerVerbs = []Verb{
"register": "\"true\" to register what it builds", "register": "\"true\" to register what it builds",
"older": "\"true\", with register: even though a newer build of the module is registered", "older": "\"true\", with register: even though a newer build of the module is registered",
}, []string{"id"}, "register", "older")}, }, []string{"id"}, "register", "older")},
// novox/hq ADR 0350 §5: asked by the delivery's owner when the forge says a pull request closed unmerged.
{Name: "forget-checks", Description: "Let go of every build a pull request's merge checks made for its merge in " +
"the artifact store's scratch namespace (novox/hq ADR 0350 §5): asked when it closed unmerged; a merged one's go " +
"when the walk answering its merge ends, and any check build goes after a week.",
Input: schema(map[string]string{"owner": "the repository's owner", "repo": "the repository",
"number": "the pull request's number"}, []string{"owner", "repo", "number"})},
{Name: "kill", Description: "End a build where it runs: the machine that took it stops its commands and containers and " + {Name: "kill", Description: "End a build where it runs: the machine that took it stops its commands and containers and " +
"announces it failed, killed by hand — settled, never handed to another machine.", "announces it failed, killed by hand — settled, never handed to another machine.",
Input: schema(map[string]string{"id": "the build's id"}, []string{"id"})}, Input: schema(map[string]string{"id": "the build's id"}, []string{"id"})},
+197
View File
@@ -0,0 +1,197 @@
package inventory
import (
"strings"
"testing"
"time"
)
// A walk whose first machine waited for a busy container counts the wait as a phase of its own, between the
// send to the first machine and the judging, and its delivery time holds it (novox/hq ADR 0335 §4): the
// recorded walk again, its judging started only at 17:54:07, a minute after the send, when the machine said
// the container replaced.
func TestABusyWaitIsAPhaseOfTheDeliveryTime(t *testing.T) {
raw := strings.Replace(recordedWalk, `"gate": {"machines": ["novox"], "since": "2026-10-10T17:53:07.287136827Z",`,
`"gate": {"machines": ["novox"], "since": "2026-10-10T17:54:07.287136827Z",
"busy": {"machine": "novox", "module": "mesh-delivery", "container": "mesh-delivery",
"since": "2026-10-10T17:53:07Z", "until": "2026-10-10T18:08:07Z", "ended": "2026-10-10T17:54:07.287136827Z"},`, 1)
w := walkOf(t, raw).WalkPhases(time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC), nil)
sumsUp(t, w)
if w.TotalMS != 282894 {
t.Fatalf("the wait changed the delivery's total: %dms", w.TotalMS)
}
busy := phase(w, PhaseBusy, 0)
if busy.State != PhaseMeasured || busy.TookMS != 60000 {
t.Fatalf("the busy wait is not a measured phase of a minute: %+v", busy)
}
if judged := phase(w, PhaseJudge, 0); judged.TookMS != 148607-60000 {
t.Fatalf("the judging still counts from the send, not from the replacement: %+v", judged)
}
var names []string
for _, ph := range w.Phases {
names = append(names, ph.Name)
}
if !strings.Contains(strings.Join(names, " "), PhaseSend+" "+PhaseBusy+" "+PhaseJudge) {
t.Fatalf("the busy wait is not between the send and the judging: %v", names)
}
}
// While the wait goes on, the walk says it waits for a busy container there; a walk where nothing waited has no
// such phase.
func TestAnOpenBusyWaitIsSaid(t *testing.T) {
raw := strings.Replace(recordedWalk, `"gate": {"machines": ["novox"], "since": "2026-10-10T17:53:07.287136827Z", "passes": 3,
"verdict": "passed", "judged_at": "2026-10-10T17:55:35.894985053Z"}`,
`"gate": {"machines": ["novox"], "since": "2026-10-10T17:53:07.287136827Z",
"busy": {"machine": "novox", "module": "mesh-delivery", "container": "mesh-delivery",
"since": "2026-10-10T17:53:07Z", "until": "2026-10-10T18:08:07Z"}}`, 1)
raw = strings.Replace(raw, `"state": "done"`, `"state": "rolling"`, 1)
p := walkOf(t, raw)
if !p.Modules["mesh-delivery"].Gate.Waiting() {
t.Fatal("a gate whose busy wait has not ended does not say it waits")
}
w := p.WalkPhases(time.Date(2026, 10, 10, 17, 58, 0, 0, time.UTC), nil)
busy := phase(w, PhaseBusy, 0)
if busy.State != PhaseUnknown || !strings.Contains(busy.Said, "waits for a busy container on novox") {
t.Fatalf("an open busy wait is not said: %+v", busy)
}
if plain := walkOf(t, recordedWalk).WalkPhases(time.Date(2026, 10, 10, 18, 0, 0, 0, time.UTC), nil); phase(plain, PhaseBusy, 0).Name != "" {
t.Fatalf("a walk where nothing waited has a busy phase: %+v", plain.Phases)
}
}
// A container a report lists waiting is its module's by its id; its output alone is not a new account.
func TestABusyContainerIsItsModules(t *testing.T) {
list := []BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: BusyWaiting, Said: "a"},
{ID: "web.server", State: "replaced-when-idle"}}
if b, ok := WaitingFor(list, "build-agent"); !ok || b.Container != "mesh-build-agent" {
t.Fatalf("build-agent's container is not found waiting: %+v", b)
}
if _, ok := WaitingFor(list, "web"); ok {
t.Error("a container replaced reads as waiting")
}
if _, ok := WaitingFor(list, "build"); ok {
t.Error("another module's container is found by a prefix of its name")
}
again := []BusyWait{{ID: "build-agent.server", State: BusyWaiting, Said: "b, a minute on"}, list[1]}
if !sameBusy(list, again) {
t.Error("a wait whose output changed is a new account")
}
if sameBusy(list, list[1:]) || !(Doing{Busy: list}).Waiting() || (Doing{Busy: list[1:]}).Waiting() {
t.Error("a wait that ended is not a new account")
}
}
// A machine's report of a busy container is kept with its last account and read back beside it, and its
// starting and its end are news; its output alone is not (novox/hq ADR 0335). Needs the store.
func TestAReportOfABusyContainerIsKeptAndReadBack(t *testing.T) {
inv, node := aNodeWithModules(t)
ctx := t.Context()
record, err := inv.NodeByName(ctx, node)
if err != nil {
t.Fatal(err)
}
since := time.Date(2026, 10, 13, 1, 0, 0, 0, time.UTC)
waiting := []BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: BusyWaiting,
Since: since, Until: since.Add(15 * time.Minute), Said: "busy: a check"}}
keep := func(busy []BusyWait) bool {
t.Helper()
news, err := inv.RecordOrderedDoing(ctx, record.ID, Doing{Outcome: OutcomeApplied, Declared: "d1", Applied: 1,
Busy: busy}, ReportOrder{Epoch: 1, Sequence: 1}, func(ReportOrder) bool { return false })
if err != nil {
t.Fatal(err)
}
return news
}
read := func() []BusyWait {
t.Helper()
reports, err := inv.LastReports(ctx)
if err != nil {
t.Fatal(err)
}
for _, r := range reports {
if r.Node == node {
return r.Busy
}
}
t.Fatal("the machine's report is not read back")
return nil
}
keep(nil)
if got := read(); len(got) != 0 {
t.Fatalf("a report with nothing busy reads back %+v", got)
}
if !keep(waiting) {
t.Error("a container starting to wait is not news")
}
got := read()
if b, ok := WaitingFor(got, "build-agent"); !ok || !b.Since.Equal(since) || b.Said != "busy: a check" {
t.Fatalf("the wait is not read back: %+v", got)
}
again := []BusyWait{waiting[0]}
again[0].Said = "busy: a check, a minute on"
if keep(again) {
t.Error("a wait whose output changed is news")
}
if !keep([]BusyWait{{ID: "build-agent.server", Container: "mesh-build-agent", State: "replaced-when-idle",
Since: since, Until: since.Add(15 * time.Minute)}}) {
t.Error("a container replaced after it waited is not news")
}
if _, ok := WaitingFor(read(), "build-agent"); ok {
t.Error("a container replaced still reads as waiting")
}
}
// A machine of the rest that answered and waits for a busy container is never read as silent, its wait is kept
// out of its apply time, and once replaced its report is the end of the wait (novox/hq ADR 0335).
func TestARestMachineWaitingForABusyContainerIsNeverSilent(t *testing.T) {
c := walk582(t)
shanks := c.walk.Modules["build-agent"].Rest["shanks"]
ref := shanks.Digest + "@" + shanks.At.Format("2006-01-02T15:04:05.000000")
answered := shanks.At.Add(15 * time.Second)
// shanks answered, and said a container waits: its apply row is the answer, marked busy.
c.rows["shanks"][0].Detail = ApplyBusy
at := c.judged.Add(20 * time.Minute) // past ApplySilentAfter from its send
w := c.walk.WalkPhases(at, c.lookup)
var got RestReport
for _, r := range w.Rest {
if r.Node == "shanks" {
got = r
}
}
if got.State != RestBusy || slicesContains(w.Silent, "shanks") {
t.Fatalf("a machine waiting for a busy container reads %q (silent: %v)", got.State, w.Silent)
}
if w.End != nil || !strings.Contains(phase(w, PhaseApply, -1).Said, "waits for a busy container on shanks") {
t.Fatalf("the walk ended, or does not say it waits: %+v", phase(w, PhaseApply, -1))
}
// 17 minutes on, shanks says it replaced the container: its wait is its own duration, and its report is then.
c.rows["shanks"] = append(c.rows["shanks"], Duration{Kind: DurationBusy, Subject: "shanks", Node: "shanks", Ref: ref,
Started: answered, Took: 17 * time.Minute})
w = c.walk.WalkPhases(c.judged.Add(time.Hour), c.lookup)
sumsUp(t, w)
for _, r := range w.Rest {
if r.Node == "shanks" {
got = r
}
}
if got.State != RestApplied || got.Busy != "17m0s" || !got.Reported.Equal(answered.Add(17*time.Minute)) {
t.Fatalf("a machine replaced after its wait reads %+v", got)
}
if slicesContains(w.Silent, "shanks") || !w.End.Equal(answered.Add(17*time.Minute)) {
t.Fatalf("the walk read shanks as silent, or ended elsewhere: %v %v", w.Silent, w.End)
}
// And the apply time itself is the answer's, never the wait's.
if r, _ := FirstReportOf(c.rows["shanks"], shanks, c.walkSent); !r.Answered.Equal(answered) || r.Busy != 17*time.Minute {
t.Fatalf("the busy wait is not kept apart from the apply: %+v", r)
}
}
func slicesContains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
+64 -1
View File
@@ -58,7 +58,42 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
return dependenciesOf(entries, against, nil), nil held, err := i.Held(ctx)
if err != nil {
return nil, err
}
return dependenciesWith(entries, against, held), nil
}
// packagesHeld is, by package name, the module whose artifact the mesh holds as that package: a
// `package` artifact's reference is `<name>@<version>` (the builder's publishPackage), never an
// artifact-store reference, so a build that stood on it recorded the package's name and version.
func packagesHeld(held map[string]string) map[string]string {
out := map[string]string{}
for key, reference := range held {
module, _, ok := strings.Cut(key, "/")
if !ok {
continue
}
if name := packageName(reference); name != "" {
out[name] = module
}
}
return out
}
// packageName is the name of a `<name>@<version>` package reference, or "" for anything else: a
// kept or pinned image (`…@sha256:…`), a URL, or a reference with no version.
func packageName(reference string) string {
if strings.HasPrefix(reference, catalogue.ArtifactStoreScheme) || strings.Contains(reference, "@sha256:") ||
strings.Contains(reference, "://") {
return ""
}
at := strings.LastIndex(reference, "@")
if at <= 0 || at == len(reference)-1 {
return ""
}
return reference[:at]
} }
// dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue. // dependenciesOf is Dependencies over what was read, so a test can hand it a catalogue.
@@ -66,6 +101,20 @@ func (i *Inventory) Dependencies(ctx context.Context) ([]Edge, error) {
// `read` draws no edge (novox/hq ADR 0267 rule 4): what a build read moves it through its build source, in // `read` draws no edge (novox/hq ADR 0267 rule 4): what a build read moves it through its build source, in
// the planner, never through the relation. // the planner, never through the relation.
func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][]ReadRepository) []Edge { func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][]ReadRepository) []Edge {
return dependenciesWith(entries, against, nil)
}
// dependenciesWith is the relation with the artifacts the mesh holds, "<module>/<artifact>" to its
// reference (Held), so a package a build stood on is read as its module's.
//
// **A built manifest carries no `build`** (catalogue.Resolve drops it), so what a module declared it
// is built on is read from what is kept of it once it is built (novox/hq issue 614): the bases its
// newest build was handed (`against`) — an artifact-store reference, or a package's `<name>@<version>`
// — and the language of every bundle it compiled (`Bundles`). Read only from records the mesh already
// keeps, so it holds for every module built before it, and a controller rolled back to one without it
// reads the same records as before.
func dependenciesWith(entries []Entry, against map[string][]string, held map[string]string) []Edge {
packages := packagesHeld(held)
known := map[string]bool{} known := map[string]bool{}
var builders []string var builders []string
for _, e := range entries { for _, e := range entries {
@@ -111,6 +160,13 @@ func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][
} }
} }
} }
// **And once it is built**, from the bundles its resolved manifest keeps (novox/hq issue 614): the
// `build` it was declared with is gone by then, and a toolchain that moved rebuilt none of them.
for _, b := range e.Manifest.Bundles {
if chain, err := builder.ToolchainFor(b.Language); err == nil {
add(name, chain.Base, EdgeStandsOn)
}
}
for _, ref := range against[name] { for _, ref := range against[name] {
if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok { if rest, ok := strings.CutPrefix(ref, catalogue.ArtifactStoreScheme); ok {
// A copy of an upstream image is no module's artifact (novox/hq ADR 0257): its // A copy of an upstream image is no module's artifact (novox/hq ADR 0257): its
@@ -121,6 +177,13 @@ func dependenciesOf(entries []Entry, against map[string][]string, _ map[string][
if base, _, found := strings.Cut(rest, "/"); found { if base, _, found := strings.Cut(rest, "/"); found {
add(name, base, EdgeStandsOn) add(name, base, EdgeStandsOn)
} }
continue
}
// A package base (`build.on` naming a module's `package` artifact) is recorded as the
// package's name and version (novox/hq issue 614): the module that publishes that package
// is what it stood on, whichever version the mesh holds now.
if base, ok := packages[packageName(ref)]; ok {
add(name, base, EdgeStandsOn)
} }
} }
if e.Source.Repository != "" { if e.Source.Repository != "" {
+72
View File
@@ -1,6 +1,7 @@
package inventory package inventory
import ( import (
"strings"
"testing" "testing"
"github.com/novox/mesh-controller/internal/catalogue" "github.com/novox/mesh-controller/internal/catalogue"
@@ -174,3 +175,74 @@ func TestACopyOfAnUpstreamImageIsNoEdge(t *testing.T) {
} }
} }
} }
// **A built module keeps its build edges** (novox/hq issue 614). A manifest the mesh registers from a
// build carries no `build` (catalogue.Resolve drops it), so mesh-tools' `build.on` naming mesh-sdk's
// `lib` package and a provider's TypeScript bundle drew no edge once built, and a new SDK rebuilt
// neither. The package base is read from what the build was handed (`@novox/mesh-sdk@0.1.13`, the
// package's name whichever version the mesh holds now), the toolchain from the bundles it keeps.
func TestABuiltModuleKeepsItsBuildEdges(t *testing.T) {
built := func(name string, bundles ...catalogue.Bundle) Entry {
return Entry{Manifest: catalogue.Manifest{Module: name, Bundles: bundles},
Source: Source{Repository: "http://forge/novox/" + name + ".git"}}
}
entries := []Entry{
built("mesh-sdk"),
built("mesh-tools"),
built("mesh-tools-go"),
built("minio", catalogue.Bundle{Name: "code", Language: "typescript"}),
built("umami", catalogue.Bundle{Name: "code", Language: "TypeScript"}),
built("dnsmasq", catalogue.Bundle{Name: "tools", Language: "go"}),
built("plain"),
}
against := map[string][]string{
"mesh-tools": {"@novox/mesh-sdk@0.1.13",
catalogue.ArtifactStoreScheme + "upstream/docker.io/library/node@sha256:" + strings.Repeat("0", 64)},
// A package nobody in the mesh publishes is no edge.
"plain": {"left-pad@1.3.0"},
}
held := map[string]string{
"mesh-sdk/lib": "@novox/mesh-sdk@0.1.14",
"mesh-tools/runtime": catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:" + strings.Repeat("1", 64),
}
got := dependenciesWith(entries, against, held)
has := func(from, to string) bool {
for _, e := range got {
if e.From == from && e.To == to && e.Kind == EdgeStandsOn {
return true
}
}
return false
}
for _, want := range [][2]string{
{"mesh-tools", "mesh-sdk"},
{"minio", "mesh-tools"},
{"umami", "mesh-tools"},
{"dnsmasq", "mesh-tools-go"},
} {
if !has(want[0], want[1]) {
t.Errorf("no %s stands-on %s: %+v", want[0], want[1], got)
}
}
for _, e := range got {
if e.From == "plain" && e.Kind != EdgeBuiltBy {
t.Errorf("an unknown package drew an edge: %+v", e)
}
}
}
func TestAPackageNameIsReadOnlyFromAPackageReference(t *testing.T) {
for ref, want := range map[string]string{
"@novox/mesh-sdk@0.1.14": "@novox/mesh-sdk",
"left-pad@1.3.0": "left-pad",
"@novox/mesh-sdk": "",
"novox.internal:5100/mesh-tools/build@sha256:" + strings.Repeat("0", 64): "",
catalogue.ArtifactStoreScheme + "mesh-tools/build@sha256:0": "",
"http://store/v2/x/blobs/sha256:0": "",
"": "",
} {
if got := packageName(ref); got != want {
t.Errorf("packageName(%q) = %q, want %q", ref, got, want)
}
}
}
+29 -1
View File
@@ -20,10 +20,19 @@ const (
// DurationWalkPhase is one phase of an ended walk, per class (novox/hq ADR 0282 decision 6): subject // DurationWalkPhase is one phase of an ended walk, per class (novox/hq ADR 0282 decision 6): subject
// "<class>/<phase>", and "<class>/total" for its merge to every machine running it. // "<class>/<phase>", and "<class>/total" for its merge to every machine running it.
DurationWalkPhase = "walk-phase" DurationWalkPhase = "walk-phase"
// DurationBusy is a machine's wait for a busy container (novox/hq ADR 0335): from its first report of a
// send, which said a container waits because it is busy, to the report that says it replaced — the same
// send's ref as its `apply` row, whose detail is ApplyBusy. Its own measure, never in the apply's.
DurationBusy = "busy-wait"
) )
// ApplyBusy is the detail of an `apply` duration whose report said a container waits because it is busy: the
// machine answered, and is not yet running what it was sent (novox/hq ADR 0335).
const ApplyBusy = "busy"
// DurationKinds are every kind, in the order `durations` shows them. // DurationKinds are every kind, in the order `durations` shows them.
var DurationKinds = []string{DurationApply, DurationHeartbeatGap, DurationPlanTier, DurationBuild, DurationWalkPhase} var DurationKinds = []string{DurationApply, DurationHeartbeatGap, DurationPlanTier, DurationBuild, DurationWalkPhase,
DurationBusy}
// DurationsKeptFor is how long a duration is kept: long enough to set a bound from, and to correct it // DurationsKeptFor is how long a duration is kept: long enough to set a bound from, and to correct it
// in Phase 1's first live week. // in Phase 1's first live week.
@@ -70,6 +79,25 @@ func (i *Inventory) RecordApplyDuration(ctx context.Context, node, declared, out
return err return err
} }
// RecordBusyDuration measures a machine's wait for a busy container, at the report that says it replaced
// (novox/hq ADR 0335): from the end of its `apply` duration of the send it was last sent, which said it waited, to
// now. Nothing when that send's first report did not say a wait, and once per send.
func (i *Inventory) RecordBusyDuration(ctx context.Context, node, declared string) error {
if declared == "" {
return nil
}
_, err := i.store.Pool().Exec(ctx,
`insert into duration (kind, subject, node, ref, started, took_ms, detail)
select $1, d.subject, d.node, d.ref, d.started + d.took_ms * interval '1 millisecond',
greatest(0, (extract(epoch from (now() - (d.started + d.took_ms * interval '1 millisecond'))) * 1000)::bigint), ''
from duration d join node n on n.name = d.node
where d.kind = $2 and d.subject = $3 and d.detail = $5 and n.sent = $4 and n.sent_at is not null
and d.ref = n.sent || '@' || to_char(n.sent_at at time zone 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US')
on conflict do nothing`,
DurationBusy, DurationApply, node, declared, ApplyBusy)
return err
}
// RecordHeartbeatGap measures the silence before a machine's word: from the last word heard before // RecordHeartbeatGap measures the silence before a machine's word: from the last word heard before
// it, which the caller read before recording this one. // it, which the caller read before recording this one.
func (i *Inventory) RecordHeartbeatGap(ctx context.Context, node string, before time.Time) error { func (i *Inventory) RecordHeartbeatGap(ctx context.Context, node string, before time.Time) error {
+6
View File
@@ -44,7 +44,13 @@ type Wait struct {
Part string `json:"part"` Part string `json:"part"`
Secret string `json:"secret,omitempty"` Secret string `json:"secret,omitempty"`
Setting string `json:"setting,omitempty"` Setting string `json:"setting,omitempty"`
// Provision is a provision the module requires whose credential only a person can give and nobody gave yet
// (novox/hq ADR 0337 §5): a wait names exactly one of Secret, Setting or Provision.
Provision string `json:"provision,omitempty"`
What string `json:"what"` What string `json:"what"`
// Act is the line that gives a provision's credential, filled by the controller when it excuses the wait
// (ADR 0337 §7); never said by a machine.
Act string `json:"act,omitempty"`
} }
// NodeHealth is a machine's newest statement, as kept. // NodeHealth is a machine's newest statement, as kept.
+9
View File
@@ -36,9 +36,18 @@ type MadeOf struct {
BootedAt *time.Time `json:"booted_at"` BootedAt *time.Time `json:"booted_at"`
Timezone string `json:"timezone"` Timezone string `json:"timezone"`
Filesystems []MountedFilesystem `json:"filesystems"` Filesystems []MountedFilesystem `json:"filesystems"`
// Networks is every network the machine is directly attached to on a link facing outside it, as
// link and network (novox/hq ADR 0340 §7): what a port reached from `local-network` resolves from.
Networks []AttachedNetwork `json:"networks"`
Unknown map[string]string `json:"unknown"` Unknown map[string]string `json:"unknown"`
} }
// AttachedNetwork is one network a machine said it is attached to, and the link it is on.
type AttachedNetwork struct {
Link string `json:"link"`
Network string `json:"network"`
}
// MountedFilesystem is one local filesystem a machine has mounted. // MountedFilesystem is one local filesystem a machine has mounted.
type MountedFilesystem struct { type MountedFilesystem struct {
Mount string `json:"mount"` Mount string `json:"mount"`
+15
View File
@@ -40,3 +40,18 @@ func TestAProfileWithoutTheFactsIsAnOlderNodeEngineNotNothing(t *testing.T) {
t.Error("a node that never reported read as having reported") t.Error("a node that never reported read as having reported")
} }
} }
// The networks a machine is attached to (novox/hq ADR 0340 §7), in the node-engine's words: what a port
// reached from `local-network` resolves from.
func TestAMachinesAttachedNetworksAreRead(t *testing.T) {
a, had, err := machineFrom([]byte(`{"architecture":"amd64","kernel":"linux",
"machine":{"observed_at":"2026-10-13T09:00:00Z",
"networks":[{"link":"eth0","network":"192.168.1.0/24"},{"link":"eth1","network":"203.0.113.0/24"}]}}`))
if err != nil || !had || a.Machine == nil {
t.Fatalf("%+v had %v err %v", a, had, err)
}
n := a.Machine.Networks
if len(n) != 2 || n[0].Link != "eth0" || n[0].Network != "192.168.1.0/24" || n[1].Network != "203.0.113.0/24" {
t.Fatalf("networks read as %+v", n)
}
}
@@ -0,0 +1,11 @@
-- A machine's report says which container it left running because it said busy, and which it replaced
-- after it waited (novox/hq ADR 0335, issue 594).
--
-- A send that replaced the build agent killed the check it ran, and the queue started it again elsewhere.
-- A container may now declare `busy`; the node-engine asks it before replacing it, leaves it running while it
-- says busy, within 15 minutes, and says so in its report's `busy`: [{"id", "container", "state", "since",
-- "until", "said"}], state `waiting`, `replaced-when-idle` or `replaced-at-bound`. Kept with the machine's
-- last account, so a walk's first-machine gate waits while its module's container is listed waiting — across
-- a restart of the controller, which a merge into mesh-controller makes in the same walk that moves the build
-- agent. Empty from an engine that never waits.
alter table node_report add column busy jsonb not null default '[]';
+86 -11
View File
@@ -10,6 +10,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"regexp" "regexp"
"slices"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -846,6 +847,46 @@ type Doing struct {
// machine doing what it was told. // machine doing what it was told.
Since *time.Time Since *time.Time
Times int Times int
// Busy is every container the apply left running because it said busy, and every one it replaced after
// it waited (novox/hq ADR 0335).
Busy []BusyWait
}
// BusyWait is one container a busy command held, as the machine reported it (novox/hq ADR 0335): its
// resource id (`<module>.<resource>`), its name, its state (link.BusyWaiting, BusyReplacedIdle or
// BusyReplacedAtBound), since when it waits, until when at the latest, and the last output of its command.
type BusyWait struct {
ID string `json:"id"`
Container string `json:"container"`
State string `json:"state"`
Since time.Time `json:"since"`
Until time.Time `json:"until"`
Said string `json:"said,omitempty"`
}
// BusyWaiting is the state of a container left running because it said busy (link.BusyWaiting).
const BusyWaiting = "waiting"
// WaitingFor is the container of a module that waits because it said busy, and whether one does: a
// resource whose id is `<module>.<resource>`.
func WaitingFor(busy []BusyWait, module string) (BusyWait, bool) {
for _, b := range busy {
if b.State == BusyWaiting && strings.HasPrefix(b.ID, module+".") {
return b, true
}
}
return BusyWait{}, false
}
// Waiting says whether any container waits because it said busy.
func (d Doing) Waiting() bool {
for _, b := range d.Busy {
if b.State == BusyWaiting {
return true
}
}
return false
} }
// FailedResource is one thing a node could not do. // FailedResource is one thing a node could not do.
@@ -873,6 +914,20 @@ func (d Doing) Stuck() bool { return d.Wrong() && d.Times >= StuckAfter && d.Sin
// the same failed resources BY ID. Not by the host's words: an error that carries a duration, a // the same failed resources BY ID. Not by the host's words: an error that carries a duration, a
// counter or a temporary path would read as new on every report, and the resource looping on it — // counter or a temporary path would read as new on every report, and the resource looping on it —
// which is what stuck is for — would never be said to be (novox/hq 04-ISSUES/065). // which is what stuck is for — would never be said to be (novox/hq 04-ISSUES/065).
// sameBusy says two reports hold the same containers in the same state of a busy wait: its output, which
// may change on every ask, is not compared.
func sameBusy(a, b []BusyWait) bool {
key := func(list []BusyWait) []string {
out := make([]string, 0, len(list))
for _, w := range list {
out = append(out, w.ID+"="+w.State)
}
sort.Strings(out)
return out
}
return slices.Equal(key(a), key(b))
}
func sameFailure(a, b Doing) bool { func sameFailure(a, b Doing) bool {
if a.Outcome != b.Outcome || a.Refused != b.Refused || len(a.Failed) != len(b.Failed) { if a.Outcome != b.Outcome || a.Refused != b.Refused || len(a.Failed) != len(b.Failed) {
return false return false
@@ -937,13 +992,20 @@ func recordDoing(ctx context.Context, q queries, node string, d Doing) (news boo
if err != nil { if err != nil {
return false, err return false, err
} }
if d.Busy == nil {
d.Busy = []BusyWait{}
}
busy, err := json.Marshal(d.Busy)
if err != nil {
return false, err
}
var before Doing var before Doing
var beforeFailed []byte var beforeFailed, beforeBusy []byte
found := q.QueryRow(ctx, found := q.QueryRow(ctx,
`select outcome, refused, failed, failing_since, failures, coalesce(declared,'') `select outcome, refused, failed, failing_since, failures, coalesce(declared,''), busy
from node_report where node = $1`, from node_report where node = $1`,
node).Scan(&before.Outcome, &before.Refused, &beforeFailed, &before.Since, &before.Times, node).Scan(&before.Outcome, &before.Refused, &beforeFailed, &before.Since, &before.Times,
&before.Declared) &before.Declared, &beforeBusy)
switch { switch {
case errors.Is(found, pgx.ErrNoRows): case errors.Is(found, pgx.ErrNoRows):
news = true news = true
@@ -953,7 +1015,13 @@ func recordDoing(ctx context.Context, q queries, node string, d Doing) (news boo
if err := json.Unmarshal(beforeFailed, &before.Failed); err != nil { if err := json.Unmarshal(beforeFailed, &before.Failed); err != nil {
return false, err return false, err
} }
news = before.Outcome != d.Outcome || before.Declared != d.Declared || !sameFailure(before, d) if err := json.Unmarshal(beforeBusy, &before.Busy); err != nil {
return false, err
}
// A container that starts or ends a wait because it said busy is news (novox/hq ADR 0335): the walk
// waiting on this machine is told by it.
news = before.Outcome != d.Outcome || before.Declared != d.Declared || !sameFailure(before, d) ||
!sameBusy(before.Busy, d.Busy)
} }
var since *time.Time var since *time.Time
times := 0 times := 0
@@ -966,13 +1034,13 @@ func recordDoing(ctx context.Context, q queries, node string, d Doing) (news boo
} }
_, err = q.Exec(ctx, _, err = q.Exec(ctx,
`insert into node_report (node, outcome, refused, failed, applied, at, declared, `insert into node_report (node, outcome, refused, failed, applied, at, declared,
failing_since, failures) failing_since, failures, busy)
values ($1, $2, $3, $4, $5, now(), $6, $7, $8) values ($1, $2, $3, $4, $5, now(), $6, $7, $8, $9)
on conflict (node) do update set outcome = excluded.outcome, refused = excluded.refused, on conflict (node) do update set outcome = excluded.outcome, refused = excluded.refused,
failed = excluded.failed, applied = excluded.applied, at = excluded.at, failed = excluded.failed, applied = excluded.applied, at = excluded.at,
declared = excluded.declared, declared = excluded.declared,
failing_since = excluded.failing_since, failures = excluded.failures`, failing_since = excluded.failing_since, failures = excluded.failures, busy = excluded.busy`,
node, d.Outcome, d.Refused, failed, d.Applied, d.Declared, since, times) node, d.Outcome, d.Refused, failed, d.Applied, d.Declared, since, times, busy)
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -1059,6 +1127,8 @@ type Reported struct {
// gate holds against the send it made, rather than against the send made last (novox/hq issue 352). // gate holds against the send it made, rather than against the send made last (novox/hq issue 352).
Declared string Declared string
ReportedSequence int64 ReportedSequence int64
// Busy is what the last report said of containers held by their busy command (novox/hq ADR 0335).
Busy []BusyWait
} }
// SentDeclaration is what one send carried to a machine, as a gate keeps it: the declaration's digest and // SentDeclaration is what one send carried to a machine, as a gate keeps it: the declaration's digest and
@@ -1112,8 +1182,8 @@ func (i *Inventory) SentTo(ctx context.Context, name string) (SentDeclaration, b
func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) { func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) {
rows, err := i.store.Pool().Query(ctx, rows, err := i.store.Pool().Query(ctx,
`select n.name, coalesce(r.outcome, ''), r.at, n.sent_at, `select n.name, coalesce(r.outcome, ''), r.at, n.sent_at,
r.declared is not null and r.declared <> '' and r.declared = n.sent, coalesce(r.declared is not null and r.declared <> '' and r.declared = n.sent, false),
coalesce(r.declared, ''), coalesce(r.reported_sequence, 0) coalesce(r.declared, ''), coalesce(r.reported_sequence, 0), coalesce(r.busy, '[]')
from node n left join node_report r on r.node = n.id from node n left join node_report r on r.node = n.id
order by n.name`) order by n.name`)
if err != nil { if err != nil {
@@ -1123,7 +1193,12 @@ func (i *Inventory) LastReports(ctx context.Context) ([]Reported, error) {
var out []Reported var out []Reported
for rows.Next() { for rows.Next() {
var r Reported var r Reported
if err := rows.Scan(&r.Node, &r.Outcome, &r.At, &r.Sent, &r.Current, &r.Declared, &r.ReportedSequence); err != nil { var busy []byte
if err := rows.Scan(&r.Node, &r.Outcome, &r.At, &r.Sent, &r.Current, &r.Declared, &r.ReportedSequence,
&busy); err != nil {
return nil, err
}
if err := json.Unmarshal(busy, &r.Busy); err != nil {
return nil, err return nil, err
} }
out = append(out, r) out = append(out, r)
+252
View File
@@ -0,0 +1,252 @@
package inventory
import (
"context"
"errors"
"fmt"
"sort"
"github.com/jackc/pgx/v5"
"github.com/novox/mesh-controller/internal/catalogue"
"github.com/novox/mesh-controller/internal/secrets"
)
// A provision whose credential only a person outside the mesh can give (novox/hq ADR 0337, issue 164): a Servarr
// app's API key, an indexer's key, a downloader's password. The provider's software holds the value; the mesh can
// neither make one that would work nor fetch it. So the mesh never makes one (§2), a person gives it once per
// provider and it is sealed to every consumer of that provider (§3), and until then a consumer holds no credential
// for it and is told so (§4), and the rest of it runs.
// NotGivenError is a pair credential only a person can give, which no person gave for this pair under the keys its
// two ends have now: the consumer receives no credential for it, and waits (ADR 0337 §4, §8). It is not a fault of
// the machine, so the plan does not refuse the machine for it.
type NotGivenError struct {
Provision, Consumer, ConsumerModule, Provider, ProviderModule, Local string
// Own is set when the credential is the provider's own secret issued outside (ADR 0158, ADR 0228), and names it.
Own string
// Why is why no value is held, in words that never carry one.
Why string
}
func (e *NotGivenError) Error() string {
if e.Own != "" {
return fmt.Sprintf("%s on %s holds no %s from %s: %s; give it with `secret accept %s %s %s`",
e.ConsumerModule, e.Consumer, e.Provision, e.Provider, e.Why, e.Provider, e.ProviderModule, e.Own)
}
return fmt.Sprintf("%s on %s holds no %s from %s: %s; it is issued outside the mesh and given with `%s`",
e.ConsumerModule, e.Consumer, e.Provision, e.Provider, e.Why, acceptLine(e.Provider, e.ProviderModule, e.Provision))
}
// IsNotGiven is whether an error is a credential only a person gives, not given yet.
func IsNotGiven(err error) bool {
var ng *NotGivenError
return errors.As(err, &ng)
}
// acceptLine is the one line that gives a provision's credential issued outside, for every consumer of a provider.
func acceptLine(provider, providerModule, provision string) string {
if providerModule == "" {
providerModule = "<provider module>"
}
return fmt.Sprintf("secret accept %s %s %s", provider, providerModule, provision)
}
// AcceptLine is acceptLine, for the controller's words.
func AcceptLine(provider, providerModule, provision string) string {
return acceptLine(provider, providerModule, provision)
}
// issuedOutside is whether a module in the catalogue offers the provision with a credential issued outside the
// mesh, and which.
func (i *Inventory) issuedOutside(ctx context.Context, provision string) (string, bool, error) {
shelf, err := i.Catalogue(ctx)
if err != nil {
return "", false, err
}
module, outside := catalogue.OffersIssuedOutside(shelf, provision)
return module, outside, nil
}
// ownIssuedOutside is whether a provider's own secret says only an outside party issues it (ADR 0228).
func (i *Inventory) ownIssuedOutside(ctx context.Context, providerModule, own string) (string, bool, error) {
if providerModule == "" {
return own, false, nil
}
m, err := i.declared(ctx, providerModule)
if err != nil {
return own, false, err
}
return own, m.OwnSecrets[own].IssuedBy == catalogue.IssuedOutside, nil
}
// GivenPairSecret is the credential of a pair whose provision is issued outside the mesh, as a person gave it, and
// never one the mesh makes (ADR 0337 §2, §4). It writes nothing. A value is answered only when a person gave it
// (`accepted`) and it is sealed to the keys both ends have now; otherwise a NotGivenError says why — never given, or
// given under a key one end no longer has (§8), or a value the mesh made before the rule, which is not given and
// never delivered again (§2).
func (i *Inventory) GivenPairSecret(ctx context.Context, name, consumer, consumerModule, provider, providerModule,
local string) (Secret, error) {
consumerKey, err := i.SealingKeyOf(ctx, consumer)
if err != nil {
return Secret{}, err
}
providerKey, err := i.SealingKeyOf(ctx, provider)
if err != nil {
return Secret{}, err
}
consumerNode, err := i.NodeByName(ctx, consumer)
if err != nil {
return Secret{}, err
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return Secret{}, err
}
notGiven := func(why string) (Secret, error) {
return Secret{}, &NotGivenError{Provision: name, Consumer: consumer, ConsumerModule: consumerModule,
Provider: provider, ProviderModule: providerModule, Local: local, Why: why}
}
var held Secret
err = i.store.Pool().QueryRow(ctx,
`select for_consumer, for_provider, consumer_key, provider_key, origin from secret
where name = $1 and consumer = $2 and consumer_module = $3 and provider = $4 and local = $5`,
name, consumerNode.ID, consumerModule, providerNode.ID, local).
Scan(&held.ForConsumer, &held.ForProvider, &held.ConsumerKey, &held.ProviderKey, &held.Origin)
switch {
case errors.Is(err, pgx.ErrNoRows):
return notGiven("nobody gave it")
case err != nil:
return Secret{}, err
case held.Origin != OriginAccepted:
return notGiven("the value held was made by the mesh, which " + providerModule + "'s software would refuse")
case consumerKey == "" || providerKey == "" || held.ConsumerKey != consumerKey || held.ProviderKey != providerKey:
return notGiven("it was given under a sealing key one end no longer has")
}
held.Name, held.Consumer, held.Provider = name, consumer, provider
held.ConsumerModule, held.Local = consumerModule, local
return held, nil
}
// PairEnd is one consumer of a provision from one provider: the machine, the module there and its local name.
type PairEnd struct {
Consumer, ConsumerModule, Local string
}
// ProvisionAccepted is what one accept for a provider sealed (ADR 0337 §3): the consumers it reached, those it could
// not (no sealing key on their machine), and the machines to send.
type ProvisionAccepted struct {
Sealed, Unsealable []PairEnd
Machines []string
}
// AcceptSecretForProvision seals one value a person gave for a provision issued outside the mesh to the provider's
// machine, to every consumer named, and to the operator, in one act under one generation, each row `accepted`
// (ADR 0337 §3, issue 165); the plaintext is not kept. The consumers are every one that binds the provision from
// this provider now, which the caller finds by resolving every machine. A consumer whose machine has no sealing key
// is not sealed to, and said.
func (i *Inventory) AcceptSecretForProvision(ctx context.Context, provision, provider, providerModule, value string,
consumers []PairEnd) (ProvisionAccepted, error) {
m, err := i.declared(ctx, providerModule)
if err != nil {
return ProvisionAccepted{}, err
}
if !m.IssuedOutsideOf(provision) {
return ProvisionAccepted{}, fmt.Errorf("%s does not offer %q with a credential issued outside the mesh "+
"(\"credential\": {\"issued-by\": \"outside\"}, ADR 0337), so its consumers' credentials are not given "+
"once for the provider", providerModule, provision)
}
if value == "" {
return ProvisionAccepted{}, errors.New("there is nothing to seal")
}
providerNode, err := i.NodeByName(ctx, provider)
if err != nil {
return ProvisionAccepted{}, err
}
providerKey, err := i.SealingKeyOf(ctx, provider)
if err != nil {
return ProvisionAccepted{}, err
}
if providerKey == "" {
return ProvisionAccepted{}, fmt.Errorf("%s has no sealing key, so nothing can be sealed to it; a node joins to get one", provider)
}
forOperator, operatorKey, err := i.operatorSeal(ctx, value)
if err != nil {
return ProvisionAccepted{}, err
}
generation := secrets.Stamp()
var out ProvisionAccepted
machines := map[string]bool{provider: true}
tx, err := i.store.Pool().Begin(ctx)
if err != nil {
return ProvisionAccepted{}, err
}
defer func() { _ = tx.Rollback(ctx) }()
for _, c := range consumers {
consumerNode, err := i.NodeByName(ctx, c.Consumer)
if err != nil {
return ProvisionAccepted{}, err
}
consumerKey, err := i.SealingKeyOf(ctx, c.Consumer)
if err != nil {
return ProvisionAccepted{}, err
}
if consumerKey == "" {
out.Unsealable = append(out.Unsealable, c)
continue
}
sealed, err := secrets.Accept(value, consumerKey, providerKey)
if err != nil {
return ProvisionAccepted{}, err
}
if _, err := tx.Exec(ctx,
`insert into secret (name, consumer, consumer_module, provider, for_consumer, for_provider,
consumer_key, provider_key, operator_sealed, operator_key, origin, local, generation)
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
on conflict (name, local, consumer, consumer_module, provider) do update set
for_consumer = excluded.for_consumer, for_provider = excluded.for_provider,
consumer_key = excluded.consumer_key, provider_key = excluded.provider_key,
created_at = now(), origin = excluded.origin, generation = excluded.generation,
operator_sealed = excluded.operator_sealed, operator_key = excluded.operator_key`,
provision, consumerNode.ID, c.ConsumerModule, providerNode.ID,
sealed.ForConsumer, sealed.ForProvider, sealed.ConsumerKey, sealed.ProviderKey,
forOperator, operatorKey, OriginAccepted, c.Local, generation); err != nil {
return ProvisionAccepted{}, err
}
out.Sealed = append(out.Sealed, c)
machines[c.Consumer] = true
}
if err := tx.Commit(ctx); err != nil {
return ProvisionAccepted{}, err
}
for n := range machines {
out.Machines = append(out.Machines, n)
}
sort.Strings(out.Machines)
return out, nil
}
// GivenPairsOf is every provision a module on a machine holds a value a person gave for, sealed to the keys both
// ends have now: what a wait for a provision's credential is checked against (ADR 0337 §6).
func (i *Inventory) GivenPairsOf(ctx context.Context, machine, module string) (map[string]bool, error) {
rows, err := i.store.Pool().Query(ctx,
`select s.name
from secret s
join node c on c.id = s.consumer
join node p on p.id = s.provider
where c.name = $1 and s.consumer_module = $2 and s.origin = 'accepted'
and s.consumer_key = c.sealing_key and s.provider_key = p.sealing_key`, machine, module)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]bool{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
out[name] = true
}
return out, rows.Err()
}
+195
View File
@@ -0,0 +1,195 @@
package inventory
import (
"strings"
"testing"
"github.com/novox/mesh-controller/internal/catalogue"
)
// outsideProvision registers a provider offering tv-api with a credential issued outside the mesh (novox/hq ADR
// 0337 §1) and two consumers requiring it, on twoNodesWithKeys' machines.
func outsideProvision(t *testing.T, inv *Inventory) {
t.Helper()
ctx := t.Context()
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "tv", Version: "1",
Provides: []catalogue.Offer{{Name: "tv-api", Scope: catalogue.ScopeMesh,
Credential: &catalogue.OfferCredential{IssuedBy: catalogue.IssuedOutside}}}}, Source{}); err != nil {
t.Fatal(err)
}
for _, m := range []string{"requests", "indexer"} {
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: m, Version: "1", Requires: []string{"tv-api"},
Secrets: map[string]string{"tv-api": "/run/tv-api.secret"}}, Source{}); err != nil {
t.Fatal(err)
}
}
}
// pairRows is how many pair rows the store holds for tv-api.
func pairRows(t *testing.T, inv *Inventory) int {
t.Helper()
var n int
if err := inv.store.Pool().QueryRow(t.Context(), `select count(*) from secret where name = 'tv-api'`).Scan(&n); err != nil {
t.Fatal(err)
}
return n
}
// A credential only a person can give is never made (ADR 0337 §2, issue 164): not for a pair with no row, which is
// what a newly bound consumer has, and not after a key change; a value the mesh made before the rule is not given.
func TestACredentialIssuedOutsideIsNeverMade(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
outsideProvision(t, inv)
_, err := inv.SecretFor(ctx, "tv-api", "consumer", "requests", "provider", "")
if !IsNotGiven(err) {
t.Fatalf("SecretFor made or refused a credential only a person gives, rather than saying it was not given: %v", err)
}
if !strings.Contains(err.Error(), "secret accept provider tv tv-api") {
t.Fatalf("not given does not name the one line that gives it: %v", err)
}
if n := pairRows(t, inv); n != 0 {
t.Fatalf("the store holds %d rows for tv-api, which nobody gave", n)
}
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); !IsNotGiven(err) {
t.Fatalf("a pair nobody gave read as given: %v", err)
}
if n := pairRows(t, inv); n != 0 {
t.Fatalf("reading the pair wrote %d rows", n)
}
// A value the mesh made before the rule (the minted value of issue 164) is not given.
if _, err := inv.store.Pool().Exec(ctx, `insert into secret (name, consumer, consumer_module, provider,
for_consumer, for_provider, consumer_key, provider_key, local)
select 'tv-api', c.id, 'indexer', p.id, 'x', 'y', c.sealing_key, p.sealing_key, ''
from node c, node p where c.name = 'consumer' and p.name = 'provider'`); err != nil {
t.Fatal(err)
}
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "indexer", "provider", "tv", ""); !IsNotGiven(err) ||
!strings.Contains(err.Error(), "made by the mesh") {
t.Fatalf("a value the mesh made before the rule read as given: %v", err)
}
// Given, then a key changes: not given, and nothing is made in its place (ADR 0337 §8).
if _, err := inv.AcceptSecretForProvision(ctx, "tv-api", "provider", "tv", "the app's key",
[]PairEnd{{Consumer: "consumer", ConsumerModule: "requests"}}); err != nil {
t.Fatal(err)
}
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); err != nil {
t.Fatalf("the value given does not read as given: %v", err)
}
cn, _ := inv.NodeByName(ctx, "consumer")
key, _ := aSealingKey(t)
if err := inv.RecordSealingKey(ctx, cn.ID, key); err != nil {
t.Fatal(err)
}
if _, err := inv.GivenPairSecret(ctx, "tv-api", "consumer", "requests", "provider", "tv", ""); !IsNotGiven(err) ||
!strings.Contains(err.Error(), "sealing key") {
t.Fatalf("a value given under a key the consumer no longer has read as given: %v", err)
}
var origin string
if err := inv.store.Pool().QueryRow(ctx, `select origin from secret where name = 'tv-api' and consumer_module = 'requests'`).
Scan(&origin); err != nil || origin != OriginAccepted {
t.Fatalf("the given row was replaced (origin %q, %v)", origin, err)
}
// Rotate refuses it, naming the accept (ADR 0337 §9).
err = inv.RotateSecret(ctx, "tv-api", "consumer", "requests", "provider", "")
if err == nil || !strings.Contains(err.Error(), "secret accept provider tv tv-api") {
t.Fatalf("rotate of a credential issued outside was not refused naming the accept: %v", err)
}
}
// One accept for a provider is sealed to every consumer named, to the provider and to the operator, under one
// generation, each row accepted (ADR 0337 §3, issue 165); a consumer with no sealing key is said, not sealed to.
func TestOneAcceptForAProviderIsSealedToEveryConsumer(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
outsideProvision(t, inv)
operatorKey, operatorOpens := aSealingKey(t)
if _, err := inv.SetOperatorKey(ctx, operatorKey); err != nil {
t.Fatal(err)
}
consumerKey, consumerOpens := aSealingKey(t)
cn, _ := inv.NodeByName(ctx, "consumer")
if err := inv.RecordSealingKey(ctx, cn.ID, consumerKey); err != nil {
t.Fatal(err)
}
if _, err := inv.AddNode(ctx, "keyless"); err != nil {
t.Fatal(err)
}
accepted, err := inv.AcceptSecretForProvision(ctx, "tv-api", "provider", "tv", "the app's key", []PairEnd{
{Consumer: "consumer", ConsumerModule: "requests"}, {Consumer: "consumer", ConsumerModule: "indexer"},
{Consumer: "keyless", ConsumerModule: "requests"}})
if err != nil {
t.Fatal(err)
}
if len(accepted.Sealed) != 2 || len(accepted.Unsealable) != 1 || accepted.Unsealable[0].Consumer != "keyless" {
t.Fatalf("the accept did not seal to both consumers with keys and say the keyless one: %+v", accepted)
}
if strings.Join(accepted.Machines, ",") != "consumer,provider" {
t.Fatalf("the machines to send are the provider's and every consumer's sealed to: %v", accepted.Machines)
}
rows, err := inv.store.Pool().Query(ctx, `select consumer_module, origin, coalesce(generation, ''), for_consumer,
coalesce(operator_sealed, '') from secret where name = 'tv-api'`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
generations := map[string]bool{}
seen := 0
for rows.Next() {
var module, origin, generation, forConsumer, forOperator string
if err := rows.Scan(&module, &origin, &generation, &forConsumer, &forOperator); err != nil {
t.Fatal(err)
}
seen++
if origin != OriginAccepted || generation == "" {
t.Fatalf("%s's row is not accepted under a generation (origin %q)", module, origin)
}
generations[generation] = true
if opened, ok := consumerOpens(forConsumer); !ok || string(opened) != "the app's key" {
t.Fatalf("%s's copy does not open to the value given", module)
}
if opened, ok := operatorOpens(forOperator); !ok || string(opened) != "the app's key" {
t.Fatalf("%s's operator copy does not open to the value given", module)
}
}
if seen != 2 || len(generations) != 1 {
t.Fatalf("%d rows under %d generations; want 2 under one", seen, len(generations))
}
given, err := inv.GivenPairsOf(ctx, "consumer", "indexer")
if err != nil || !given["tv-api"] {
t.Fatalf("the provision given does not read as given for indexer (%v)", err)
}
// Only an offer that says so is given this way.
if _, err := inv.AcceptSecretForProvision(ctx, "secret", "provider", "gitea", "x", nil); err == nil {
t.Fatal("a provision not issued outside was given once for its provider")
}
}
// The provider's own secret issued outside the mesh, named as a provision's shared credential (ADR 0158, ADR 0228),
// is never made for a consumer that binds: the consumer is told it was not given (ADR 0337 §2, §4).
func TestASharedOwnSecretIssuedOutsideIsNeverMade(t *testing.T) {
inv, ctx := twoNodesWithKeys(t)
if err := inv.RegisterModule(ctx, catalogue.Manifest{Module: "downloader", Version: "1",
OwnSecrets: catalogue.OwnSecrets{"password": {Path: "/run/password", Taken: catalogue.TakenAtStart,
IssuedBy: catalogue.IssuedOutside}},
Provides: []catalogue.Offer{{Name: "downloader-api", Scope: catalogue.ScopeMesh,
Credential: &catalogue.OfferCredential{Own: "password"}}}}, Source{}); err != nil {
t.Fatal(err)
}
_, err := inv.SharedSecretFor(ctx, "downloader-api", "consumer", "gitea", "provider", "downloader", "", "password")
if !IsNotGiven(err) {
t.Fatalf("a shared own secret issued outside was made or refused rather than not given: %v", err)
}
var n int
if err := inv.store.Pool().QueryRow(ctx, `select count(*) from module_secret where module = 'downloader'`).Scan(&n); err != nil || n != 0 {
t.Fatalf("the mesh made %d values of an own secret issued outside (%v)", n, err)
}
if err := inv.remakeShared(ctx, nil, "", "downloader", "password", "", nil, "", "", ""); err == nil ||
!strings.Contains(err.Error(), "issued outside") {
t.Fatalf("remakeShared made a value of an own secret issued outside: %v", err)
}
}
+67 -2
View File
@@ -286,6 +286,9 @@ type PlanModule struct {
FirstAt *time.Time `json:"first_at,omitempty"` FirstAt *time.Time `json:"first_at,omitempty"`
Commit string `json:"commit,omitempty"` Commit string `json:"commit,omitempty"`
Why string `json:"why,omitempty"` Why string `json:"why,omitempty"`
// Made is what the walk's build of it did (novox/hq ADR 0350 §4): "reused from <check build>", naming the pull
// request's head, the trunk commit and the digests, or "built: <why>". Said in `plan-moved` and kept here.
Made string `json:"made,omitempty"`
// Build is the id of the build the plan asked for this module (novox/hq ADR 0219), so the plan // Build is the id of the build the plan asked for this module (novox/hq ADR 0219), so the plan
// matches its outcome by id — the one thing every outcome echoes, a failed one that never learnt // matches its outcome by id — the one thing every outcome echoes, a failed one that never learnt
// its module's name included. Empty in a plan from before it was kept, which is matched by // its module's name included. Empty in a plan from before it was kept, which is matched by
@@ -323,13 +326,25 @@ type PlanGate struct {
// machine's report is held against. Absent on a gate kept before it was, which reads the report // machine's report is held against. Absent on a gate kept before it was, which reads the report
// against the send made last, as before. // against the send made last, as before.
Sent map[string]SentDeclaration `json:"sent,omitempty"` Sent map[string]SentDeclaration `json:"sent,omitempty"`
// Applied is, per machine, the time of the first report the gate read of its send applied: the
// node-engine started the build before it, so a witness window counted from it ends no earlier than the
// node-engine's own (novox/hq ADR 0351 decisions 1 and 4).
Applied map[string]time.Time `json:"applied,omitempty"`
// Passes counts the consecutive judgings that found it healthy, LastPass the newest; a judging that // Passes counts the consecutive judgings that found it healthy, LastPass the newest; a judging that
// does not resets them. // does not resets them.
Passes int `json:"passes,omitempty"` Passes int `json:"passes,omitempty"`
// Wants is how many passes the send needs: a core module's three, a leaf module's one (novox/hq ADR 0282 // Wants is how many passes the send needs: three for the node-engine or the mesh's resolver, or fewer at two
// decision 4 as ADR 0304 decision 3 changed it). Zero in a gate judged before it was said: three. // minutes when healthy throughout, one for any other module (novox/hq ADR 0351 and ADR 0352, changing ADR
// 0282 decision 4 and ADR 0304 decision 3). Zero in a gate
// judged before it was said: three.
Wants int `json:"wants,omitempty"` Wants int `json:"wants,omitempty"`
LastPass *time.Time `json:"last_pass,omitempty"` LastPass *time.Time `json:"last_pass,omitempty"`
// Faulted says a judging found the send broken, or not healthy after a healthy reading: a module under the three-reading
// rule then passes on its readings alone, never at its two minutes (novox/hq ADR 0352).
Faulted bool `json:"faulted,omitempty"`
// HealthySeen says a judging found the send healthy, counted or not: one taken before a module's floor
// counts too, so a dip after it is a fault (novox/hq ADR 0352 decision 3).
HealthySeen bool `json:"healthy_seen,omitempty"`
// Last is what the newest judging found wanting, while it still may pass. // Last is what the newest judging found wanting, while it still may pass.
Last string `json:"last,omitempty"` Last string `json:"last,omitempty"`
// Verdict is empty while judging, then passed or failed, with Why, at JudgedAt, Took after Since. // Verdict is empty while judging, then passed or failed, with Why, at JudgedAt, Took after Since.
@@ -372,6 +387,42 @@ type PlanGate struct {
// alone to the machine holding the bus, every build there kept, so what the send newly grants is on the // alone to the machine holding the bus, every build there kept, so what the send newly grants is on the
// bus before the gate judges it. Nil when the list did not change, or the gate's machine holds the bus. // bus before the gate judges it. Nil when the list did not change, or the gate's machine holds the bus.
Grants *GrantsStep `json:"grants,omitempty"` Grants *GrantsStep `json:"grants,omitempty"`
// Busy is the wait for a busy container on a judged machine (novox/hq ADR 0335 §4): while the machine
// reports its module's container left running because it said busy, the judging waits and has not
// started; once it reports the container replaced, Since is moved to then. Nil when nothing waited.
Busy *GateBusy `json:"busy,omitempty"`
}
// GateBusy is a first-node gate's wait for a busy container (novox/hq ADR 0335 §4).
type GateBusy struct {
Machine string `json:"machine"`
Module string `json:"module"`
Container string `json:"container"`
// Since is when the machine's first apply that would have replaced it ran, Until when its node-engine
// replaces it at the latest; Ended when the machine reported it replaced and the judging began — or when
// the controller stopped waiting past the engine's bound, which PastBound says.
Since time.Time `json:"since"`
Until time.Time `json:"until"`
// Seen is when this controller first read the wait, on its own clock: the gate's bound runs from it, never
// from the engine's word alone.
Seen time.Time `json:"seen,omitzero"`
Ended *time.Time `json:"ended,omitempty"`
PastBound bool `json:"past_bound,omitempty"`
// Said is the last output of its busy command, as the machine reported it.
Said string `json:"said,omitempty"`
}
// Waiting says the gate waits for a busy container and has not started.
func (g *PlanGate) Waiting() bool { return g != nil && g.Busy != nil && g.Busy.Ended == nil }
// Words is the wait in a line, as the walk says it.
func (b *GateBusy) Words() string {
said := ""
if b.Said != "" {
said = "; its busy command says: " + b.Said
}
return fmt.Sprintf("waits for a busy container on %s: %s holds work, and is replaced once it says idle, by %s "+
"at the latest (novox/hq ADR 0335)%s", b.Machine, b.Container, b.Until.UTC().Format(time.RFC3339), said)
} }
// GrantsStep is the bus's user list sent to the machine holding the bus ahead of a gated send (novox/hq // GrantsStep is the bus's user list sent to the machine holding the bus ahead of a gated send (novox/hq
@@ -433,6 +484,20 @@ type PlanRelease struct {
Skipped []string `json:"skipped,omitempty"` Skipped []string `json:"skipped,omitempty"`
// By is the person who released it, empty when the mesh did. // By is the person who released it, empty when the mesh did.
By string `json:"by,omitempty"` By string `json:"by,omitempty"`
// Settings is set on a settings walk (novox/hq ADR 0344): not the builds waiting for a gate, but a settings
// layer the operator approved, sent to the machines it reaches one at a time, its module judged on each.
Settings *PlanSettings `json:"settings,omitempty"`
}
// PlanSettings is what a settings walk sends (novox/hq ADR 0344): the module whose layer the operator approved, the
// layer's scope, the ask that approved it and what changed, in words.
type PlanSettings struct {
Module string `json:"module"`
// Node is the machine the layer is for, empty for the whole mesh.
Node string `json:"node,omitempty"`
// Ask is the proposal the operator approved, and Change what its layer changed ("~ prefs").
Ask string `json:"ask"`
Change string `json:"change,omitempty"`
} }
// The states a plan passes through. // The states a plan passes through.
+39
View File
@@ -104,6 +104,21 @@ func (i *Inventory) SecretFor(ctx context.Context, name, consumer, consumerModul
consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local)) consumerModule, name, provider, consumer, consumerModule, name, provider, localFlag(local))
} }
// **Never made where only a person can give it** (novox/hq ADR 0337 §2, issue 164): a provision whose offer
// says `issued-by: outside` gets no value of the mesh's, which its provider's software would refuse. Asked
// here, on the one path that makes a value, so no caller can have one made by not knowing; the plan reads
// such a pair with GivenPairSecret, which never makes one and delivers only a value a person gave.
why := "nobody gave it"
if err == nil {
why = "the value held was sealed to a key one end no longer has"
}
if providerModule, outside, oerr := i.issuedOutside(ctx, name); oerr != nil {
return Secret{}, oerr
} else if outside {
return Secret{}, &NotGivenError{Provision: name, Consumer: consumer, ConsumerModule: consumerModule,
Provider: provider, ProviderModule: providerModule, Local: local, Why: why}
}
// And to the operator, when the mesh has one (novox/hq ADR 0085, amended): the third copy that // And to the operator, when the mesh has one (novox/hq ADR 0085, amended): the third copy that
// makes a vault-provided secret recoverable, and nothing the mesh can open. // makes a vault-provided secret recoverable, and nothing the mesh can open.
operator, err := i.OperatorKey(ctx) operator, err := i.OperatorKey(ctx)
@@ -239,6 +254,16 @@ func (i *Inventory) RotateSecret(ctx context.Context, name, consumer, consumerMo
`select origin from secret where name = $1 and consumer = $2 and consumer_module = $3 `select origin from secret where name = $1 and consumer = $2 and consumer_module = $3
and provider = $4 and local = $5`, and provider = $4 and local = $5`,
name, consumerNode.ID, consumerModule, providerNode.ID, local).Scan(&origin) name, consumerNode.ID, consumerModule, providerNode.ID, local).Scan(&origin)
if providerModule, outside, oerr := i.issuedOutside(ctx, name); oerr != nil {
return oerr
} else if outside {
// Only a person can give it, so only a person can give its next value (novox/hq ADR 0337 §9, ADR 0092 §7):
// deleting the row would leave the consumer waiting for nothing new.
return fmt.Errorf(
"%s is issued outside the mesh: %s's software holds it and the mesh cannot make its replacement. "+
"Give the new value with `%s`, which seals it to every consumer of %s on %s",
name, providerModule, acceptLine(provider, providerModule, name), providerModule, provider)
}
if err == nil && origin == OriginAccepted { if err == nil && origin == OriginAccepted {
return fmt.Errorf( return fmt.Errorf(
"%s's %q credential from %s was accepted from a person, and the mesh cannot make "+ "%s's %q credential from %s was accepted from a person, and the mesh cannot make "+
@@ -886,6 +911,15 @@ func (i *Inventory) SharedSecretFor(ctx context.Context, provision, consumer, co
held.ConsumerModule, held.Local = consumerModule, local held.ConsumerModule, held.Local = consumerModule, local
return held, nil return held, nil
} }
if own, outside, err := i.ownIssuedOutside(ctx, providerModule, own); err != nil {
return Secret{}, err
} else if outside && (ownOrigin == nil || *ownOrigin != OriginAccepted) {
// The provider's own secret only an outside party issues, which nobody gave (ADR 0228, issue 401): the
// mesh makes no value for it here either, and the consumer is told it was not given (ADR 0337 §4).
return Secret{}, &NotGivenError{Provision: provision, Consumer: consumer, ConsumerModule: consumerModule,
Provider: provider, ProviderModule: providerModule, Local: local, Own: own,
Why: "nobody gave " + providerModule + "'s own secret " + own + ", which is issued outside the mesh"}
}
if ownOrigin != nil && *ownOrigin == OriginAccepted { if ownOrigin != nil && *ownOrigin == OriginAccepted {
return Secret{}, fmt.Errorf( return Secret{}, fmt.Errorf(
"%s on %s needs %s from %s, whose credential is %s's own secret %q — a value given to the "+ "%s on %s needs %s from %s, whose credential is %s's own secret %q — a value given to the "+
@@ -912,6 +946,11 @@ func (i *Inventory) remakeShared(ctx context.Context, providerID any, providerKe
if len(provisions) == 0 { if len(provisions) == 0 {
return fmt.Errorf("%s names no provision whose credential is its own secret %q", providerModule, own) return fmt.Errorf("%s names no provision whose credential is its own secret %q", providerModule, own)
} }
if m.OwnSecrets[own].IssuedBy == catalogue.IssuedOutside {
// Never made (novox/hq ADR 0228, ADR 0337 §2): only a person can give it, with `secret accept`.
return fmt.Errorf("%s's own secret %q is issued outside the mesh, so the mesh makes no value for it; a "+
"person gives it with `secret accept <machine> %s %s`", providerModule, own, providerModule, own)
}
operator, err := i.OperatorKey(ctx) operator, err := i.OperatorKey(ctx)
if err != nil { if err != nil {
return err return err
+78 -17
View File
@@ -26,6 +26,7 @@ const (
PhaseBetween = "between-tiers" // one tier's end to the next tier's ask PhaseBetween = "between-tiers" // one tier's end to the next tier's ask
PhaseBuild = "build" // a tier asked to its last module built PhaseBuild = "build" // a tier asked to its last module built
PhaseSend = "send-first" // built to sent to the first machines PhaseSend = "send-first" // built to sent to the first machines
PhaseBusy = "busy-wait" // sent first to a busy container there replaced, when one waited (ADR 0335)
PhaseJudge = "judgement" // sent first to the first-node gate's last verdict: its readings PhaseJudge = "judgement" // sent first to the first-node gate's last verdict: its readings
PhaseRest = "send-rest" // judged to sent to the rest PhaseRest = "send-rest" // judged to sent to the rest
PhaseApply = "apply" // the last send to every machine of the rest reporting the build applied PhaseApply = "apply" // the last send to every machine of the rest reporting the build applied
@@ -90,6 +91,11 @@ type WalkPhase struct {
type AppliedReport struct { type AppliedReport struct {
At time.Time At time.Time
Outcome string Outcome string
// Answered is, for a machine that waited for a busy container (novox/hq ADR 0335), its first report of the
// send; Busy how long it then waited; Waiting that it still waits, At being its first report then.
Answered time.Time
Busy time.Duration
Waiting bool
} }
// AppliedLookup answers a machine's report of the send the walk made to it: send is what the walk kept of it, and // AppliedLookup answers a machine's report of the send the walk made to it: send is what the walk kept of it, and
@@ -108,9 +114,13 @@ type RestReport struct {
Reported *time.Time `json:"reported,omitempty"` Reported *time.Time `json:"reported,omitempty"`
Took string `json:"took,omitempty"` Took string `json:"took,omitempty"`
Outcome string `json:"outcome,omitempty"` Outcome string `json:"outcome,omitempty"`
// State is applied, failed, waiting (not yet heard, within ApplySilentAfter) or silent (not heard within it: // Busy is how long it waited for a busy container before it ran the build (novox/hq ADR 0335): inside Took,
// listed, never moving the walk's end). // never read as the machine being silent.
Busy string `json:"busy,omitempty"`
// State is applied, failed, waiting (not yet heard, within ApplySilentAfter), busy (it answered and waits for
// a busy container: never silent) or silent (not heard within it: listed, never moving the walk's end).
State string `json:"state"` State string `json:"state"`
busy time.Duration
} }
// The states of a machine of the rest. // The states of a machine of the rest.
@@ -118,6 +128,7 @@ const (
RestApplied = "applied" RestApplied = "applied"
RestFailed = "failed" RestFailed = "failed"
RestWaiting = "waiting" RestWaiting = "waiting"
RestBusy = "busy"
RestSilent = "silent" RestSilent = "silent"
) )
@@ -300,8 +311,11 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
if t > p.Tier || (t == p.Tier && p.Tier < len(p.Tiers) && !askedAnyOf(p, tier)) { if t > p.Tier || (t == p.Tier && p.Tier < len(p.Tiers) && !askedAnyOf(p, tier)) {
break break
} }
var asked, built, first, judged, rest *time.Time var asked, built, first, judged, rest, busyEnded *time.Time
allBuilt, anyFirst, allJudged, allRest := true, false, true, true allBuilt, anyFirst, allJudged, allRest := true, false, true, true
// A busy container on a first machine holds the judging until it is replaced (novox/hq ADR 0335 §4): a
// phase of its own, only in a tier where one waited, counted in the delivery time and never exempted.
anyBusy, allBusyEnded, busyOn := false, true, ""
for _, m := range tier { for _, m := range tier {
s := p.Modules[m] s := p.Modules[m]
if s == nil { if s == nil {
@@ -319,6 +333,14 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
if s.FirstAt != nil { if s.FirstAt != nil {
anyFirst = true anyFirst = true
first = earliest(first, s.FirstAt) first = earliest(first, s.FirstAt)
if s.Gate != nil && s.Gate.Busy != nil {
anyBusy = true
if s.Gate.Busy.Ended == nil {
allBusyEnded, busyOn = false, s.Gate.Busy.Machine
} else {
busyEnded = latest(busyEnded, s.Gate.Busy.Ended)
}
}
if s.Gate != nil { if s.Gate != nil {
if s.Gate.JudgedAt == nil { if s.Gate.JudgedAt == nil {
allJudged = false allJudged = false
@@ -349,7 +371,15 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
if !allJudged { if !allJudged {
judged = nil judged = nil
} }
out = append(out, point{phase: PhaseSend, tier: t, at: first}, point{phase: PhaseJudge, tier: t, at: judged}) out = append(out, point{phase: PhaseSend, tier: t, at: first})
if anyBusy {
said := ""
if !allBusyEnded {
busyEnded, said = nil, "waits for a busy container on "+busyOn
}
out = append(out, point{phase: PhaseBusy, tier: t, at: busyEnded, said: said})
}
out = append(out, point{phase: PhaseJudge, tier: t, at: judged})
} else { } else {
out = append(out, point{phase: PhaseSend, tier: t, none: true, said: "no first machine: nothing to judge"}, out = append(out, point{phase: PhaseSend, tier: t, none: true, said: "no first machine: nothing to judge"},
point{phase: PhaseJudge, tier: t, none: true, said: "no first machine: nothing to judge"}) point{phase: PhaseJudge, tier: t, none: true, said: "no first machine: nothing to judge"})
@@ -393,6 +423,13 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
if r, ok := applied(s.node, s.what, s.walkSent); ok { if r, ok := applied(s.node, s.what, s.walkSent); ok {
got := r.At got := r.At
rr.Reported, rr.Outcome, rr.Took = &got, r.Outcome, words(r.At.Sub(at)) rr.Reported, rr.Outcome, rr.Took = &got, r.Outcome, words(r.At.Sub(at))
if r.Busy > 0 {
rr.Busy = words(r.Busy)
rr.busy = r.Busy
}
if r.Waiting {
rr.Reported, rr.Took, rr.State = nil, "", RestBusy
}
} }
reports[key] = rr reports[key] = rr
order = append(order, key) order = append(order, key)
@@ -409,16 +446,20 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
if later.Node == rr.Node && later.Sent.After(rr.Sent) && later.Reported != nil && if later.Node == rr.Node && later.Sent.After(rr.Sent) && later.Reported != nil &&
(rr.Reported == nil || later.Reported.Before(*rr.Reported)) { (rr.Reported == nil || later.Reported.Before(*rr.Reported)) {
got := *later.Reported got := *later.Reported
rr.Reported, rr.Outcome, rr.Took = &got, later.Outcome, words(got.Sub(rr.Sent)) rr.Reported, rr.Outcome, rr.Took, rr.State = &got, later.Outcome, words(got.Sub(rr.Sent)), ""
} }
} }
} }
var end *time.Time var end *time.Time
var waiting, failed []string var waiting, failed, busy []string
for _, k := range order { for _, k := range order {
rr := reports[k] rr := reports[k]
switch { switch {
case rr.Reported == nil && now.Sub(rr.Sent) > ApplySilentAfter, rr.Reported != nil && rr.Reported.Sub(rr.Sent) > ApplySilentAfter: case rr.State == RestBusy:
// It answered and waits for a busy container: never silent, and the walk's end waits for it.
busy = appendOnce(busy, rr.Node)
case rr.Reported == nil && now.Sub(rr.Sent) > ApplySilentAfter,
rr.Reported != nil && rr.Reported.Sub(rr.Sent)-rr.busy > ApplySilentAfter:
rr.State = RestSilent rr.State = RestSilent
w.Silent = appendOnce(w.Silent, rr.Node) w.Silent = appendOnce(w.Silent, rr.Node)
case rr.Reported == nil: case rr.Reported == nil:
@@ -443,9 +484,15 @@ func (p Plan) points(now time.Time, applied AppliedLookup, w *WalkPhases) []poin
switch { switch {
case len(failed) > 0: case len(failed) > 0:
return append(out, point{phase: PhaseApply, tier: -1, said: "not applied on " + strings.Join(failed, ", ")}) return append(out, point{phase: PhaseApply, tier: -1, said: "not applied on " + strings.Join(failed, ", ")})
case len(waiting) > 0: case len(waiting) > 0 || len(busy) > 0:
return append(out, point{phase: PhaseApply, tier: -1, said: "waiting for " + strings.Join(waiting, ", ") + var said []string
" to report it applied"}) if len(waiting) > 0 {
said = append(said, "waiting for "+strings.Join(waiting, ", ")+" to report it applied")
}
if len(busy) > 0 {
said = append(said, "waits for a busy container on "+strings.Join(busy, ", "))
}
return append(out, point{phase: PhaseApply, tier: -1, said: strings.Join(said, "; ")})
case end == nil: case end == nil:
return append(out, point{phase: PhaseApply, tier: -1, said: "no machine of the rest heard from: " + return append(out, point{phase: PhaseApply, tier: -1, said: "no machine of the rest heard from: " +
strings.Join(w.Silent, ", ")}) strings.Join(w.Silent, ", ")})
@@ -536,19 +583,19 @@ func words(d time.Duration) string {
func (i *Inventory) FirstAppliedAfter(ctx context.Context, node string, send SentDeclaration, walkSent time.Time) (AppliedReport, bool, error) { func (i *Inventory) FirstAppliedAfter(ctx context.Context, node string, send SentDeclaration, walkSent time.Time) (AppliedReport, bool, error) {
from, to := applyRowsBetween(send, walkSent) from, to := applyRowsBetween(send, walkSent)
rows, err := i.store.Pool().Query(ctx, rows, err := i.store.Pool().Query(ctx,
`select ref, started, took_ms, detail from duration `select kind, ref, started, took_ms, detail from duration
where kind = $1 and subject = $2 and started >= $3 and started < $4 where subject = $2 and ((kind = $1 and started >= $3 and started < $4) or (kind = $5 and started >= $3))
order by started`, order by started`,
DurationApply, node, from, to) DurationApply, node, from, to, DurationBusy)
if err != nil { if err != nil {
return AppliedReport{}, false, err return AppliedReport{}, false, err
} }
defer rows.Close() defer rows.Close()
var ds []Duration var ds []Duration
for rows.Next() { for rows.Next() {
d := Duration{Kind: DurationApply, Subject: node, Node: node} d := Duration{Subject: node, Node: node}
var ms int64 var ms int64
if err := rows.Scan(&d.Ref, &d.Started, &ms, &d.Detail); err != nil { if err := rows.Scan(&d.Kind, &d.Ref, &d.Started, &ms, &d.Detail); err != nil {
return AppliedReport{}, false, err return AppliedReport{}, false, err
} }
d.Took = time.Duration(ms) * time.Millisecond d.Took = time.Duration(ms) * time.Millisecond
@@ -583,16 +630,30 @@ func applyRowsBetween(send SentDeclaration, walkSent time.Time) (time.Time, time
// before the walk's record of its send, as it was read before. // before the walk's record of its send, as it was read before.
// //
// A send past ApplySilentAfter is never read: its machine slept, and is listed silent (ADR 0282 decision 1). // A send past ApplySilentAfter is never read: its machine slept, and is listed silent (ADR 0282 decision 1).
//
// **A first report that said a container waits because it is busy** (novox/hq ADR 0335) is the machine's answer,
// and not yet the build running: its report is the end of its busy wait, the `busy-wait` duration of the same
// send, with the wait said apart (Busy). While none is kept the machine is still waiting (Waiting), never silent.
func FirstReportOf(ds []Duration, send SentDeclaration, walkSent time.Time) (AppliedReport, bool) { func FirstReportOf(ds []Duration, send SentDeclaration, walkSent time.Time) (AppliedReport, bool) {
from, to := applyRowsBetween(send, walkSent) from, to := applyRowsBetween(send, walkSent)
for _, d := range ds { for _, d := range ds {
if d.Started.Before(from) || !d.Started.Before(to) { if d.Kind == DurationBusy || d.Started.Before(from) || !d.Started.Before(to) {
continue continue
} }
if send.Digest != "" && !strings.HasPrefix(d.Ref, send.Digest+"@") { if send.Digest != "" && !strings.HasPrefix(d.Ref, send.Digest+"@") {
continue continue
} }
return AppliedReport{At: d.Started.Add(d.Took).UTC(), Outcome: d.Detail}, true answered := d.Started.Add(d.Took).UTC()
if d.Detail != ApplyBusy {
return AppliedReport{At: answered, Outcome: d.Detail}, true
}
for _, b := range ds {
if b.Kind == DurationBusy && b.Ref == d.Ref {
return AppliedReport{At: b.Started.Add(b.Took).UTC(), Outcome: OutcomeApplied, Answered: answered,
Busy: b.Took}, true
}
}
return AppliedReport{At: answered, Outcome: OutcomeApplied, Answered: answered, Waiting: true}, true
} }
return AppliedReport{}, false return AppliedReport{}, false
} }
+92
View File
@@ -92,6 +92,38 @@ type BuildRequest struct {
// facts snapshot, raises the throwaway stores the check needs, runs the repository's own // facts snapshot, raises the throwaway stores the check needs, runs the repository's own
// merge-check.sh and answers its verdict. Nothing is built, published or registered. // merge-check.sh and answers its verdict. Nothing is built, published or registered.
Check *CheckRequest `json:"check,omitempty"` Check *CheckRequest `json:"check,omitempty"`
// Reuse makes this ask a walk's build of a trunk commit (novox/hq ADR 0350 §2): the builder takes the reuse key
// of the commit's tree and, where a check build holds the same key, copies it by digest into the module's
// namespace instead of building. Pulls are the pull requests the walk answers, whose checks name the part of a
// differing key. Nil for every other ask — a check, a dry run, `build --ref`, `rebuild`, `replay` — which never
// takes a check's build. A builder from before it ignores it and builds.
Reuse *ReuseAsk `json:"reuse,omitempty"`
}
// ReuseAsk is a walk's leave to take a check's build (novox/hq ADR 0350 §2).
//
// **Only a build a verdict names is taken.** Builds are the builds the build seat said, in the outcomes of its checks
// of the walk's own pull requests, that it made for their merges: each key with its artifacts' digests. The record
// in the scratch namespace is not trusted alone — the artifact store takes writes from anything on the network, a
// check runs a pull request's own script on that network, and a record planted under another merge's key would
// otherwise be registered. The builder takes a scratch build only when a build here names its key and every digest.
type ReuseAsk struct {
Pulls []PullRef `json:"pulls,omitempty"`
Builds []CheckedBuild `json:"builds,omitempty"`
}
// CheckedBuild is one build a check's verdict names: the pull request it checked, its ask, and the build.
type CheckedBuild struct {
Pull PullRef `json:"pull"`
Check string `json:"check"`
Built CheckBuilt `json:"built"`
}
// PullRef names a pull request.
type PullRef struct {
Owner string `json:"owner"`
Repo string `json:"repo"`
Number int `json:"number"`
} }
// CheckRequest is what a merge check needs beyond the repository and its head. // CheckRequest is what a merge check needs beyond the repository and its head.
@@ -128,6 +160,34 @@ type CheckRequest struct {
// repository's own check is not run for a group: each member's pull request runs its own. // repository's own check is not run for a group: each member's pull request runs its own.
Group string `json:"group,omitempty"` Group string `json:"group,omitempty"`
Members []GroupMember `json:"members,omitempty"` Members []GroupMember `json:"members,omitempty"`
// Builds are the modules the delivery plan moves, in the walk's order (novox/hq ADR 0350 §1): the check builds
// each, from the merge it judged, into the scratch namespace once it has judged and not failed, for the walk to
// take. A build seat from before it builds nothing, and the walk builds as before.
Builds []CheckBuild `json:"builds,omitempty"`
}
// CheckBuild is one module a check builds for its merge: from the merge it made when Merged — a module built from
// the repository checked, at the branch merged into — else from Repository at Ref, as the walk would.
type CheckBuild struct {
Module string `json:"module"`
Repository string `json:"repository,omitempty"`
Path string `json:"path,omitempty"`
Ref string `json:"ref,omitempty"`
Merged bool `json:"merged,omitempty"`
Tier int `json:"tier"`
// Skip says why the check cannot build it as the walk would — a module that reads the repository checked
// through a context, which a check cannot hand the merge — and nothing after it is built either.
Skip string `json:"skip,omitempty"`
}
// CheckBuilt is one build a check made for its merge, as its verdict lists it (novox/hq ADR 0350 §1): the reuse key
// and the artifacts, in the scratch namespace — or why it was not built.
type CheckBuilt struct {
Module string `json:"module"`
Key string `json:"key,omitempty"`
Made []MadeArtifact `json:"made,omitempty"`
NotBuilt string `json:"not-built,omitempty"`
Took string `json:"took,omitempty"`
} }
// GroupMember is one other head of a delivery group's composed check. // GroupMember is one other head of a delivery group's composed check.
@@ -175,6 +235,8 @@ type CheckOutcome struct {
Trunk string `json:"trunk,omitempty"` Trunk string `json:"trunk,omitempty"`
Beside map[string]string `json:"beside,omitempty"` Beside map[string]string `json:"beside,omitempty"`
Members []CheckedMember `json:"members,omitempty"` Members []CheckedMember `json:"members,omitempty"`
// Built are the builds it made for its merge (novox/hq ADR 0350 §1).
Built []CheckBuilt `json:"built,omitempty"`
} }
// SourceOnSeat names a repository by the seat whose holder serves it and its path there. // SourceOnSeat names a repository by the seat whose holder serves it and its path there.
@@ -264,6 +326,14 @@ type BuildResult struct {
// Failed is why, when it did. // Failed is why, when it did.
Failed string `json:"failed,omitempty"` Failed string `json:"failed,omitempty"`
// ReuseKey is the build's reuse key (novox/hq ADR 0350 §1). Reused, on a walk's build that took a check's build
// instead of building, is "reused from <check build>" with its digests, and ReusedFrom which; BuiltWhy, on one
// that built, why it took none (§3, §4). Both empty from a builder that predates it.
ReuseKey string `json:"reuse-key,omitempty"`
Reused string `json:"reused,omitempty"`
ReusedFrom *ReusedFrom `json:"reused-from,omitempty"`
BuiltWhy string `json:"built-why,omitempty"`
// Source is the request's, echoed: the seat form of the repository, for whoever registers. // Source is the request's, echoed: the seat form of the repository, for whoever registers.
Source *SourceOnSeat `json:"source,omitempty"` Source *SourceOnSeat `json:"source,omitempty"`
@@ -300,3 +370,25 @@ type MadeArtifact struct {
Kind string `json:"kind"` Kind string `json:"kind"`
Reference string `json:"reference"` Reference string `json:"reference"`
} }
// ReusedFrom is the check build a walk's build took (novox/hq ADR 0350 §4): its pull request, the head and the trunk
// commit the check merged it into, the check's ask, and its key.
type ReusedFrom struct {
Pull PullRef `json:"pull"`
Head string `json:"head,omitempty"`
Trunk string `json:"trunk,omitempty"`
Check string `json:"check,omitempty"`
Key string `json:"key"`
}
// MadeWords is what a walk says a module's build did (novox/hq ADR 0350 §4): "reused from <check build>", or "built:
// <why>". A build seat from before it says neither, and that is said.
func (r BuildResult) MadeWords() string {
switch {
case r.Reused != "":
return r.Reused
case r.BuiltWhy != "":
return "built: " + r.BuiltWhy
}
return "built: the build seat did not say whether a check's build could be taken (it predates ADR 0350)"
}
+11
View File
@@ -175,6 +175,11 @@ type MachineOptions struct {
// can take a build, which is what its container's health check reads (novox/hq issue 544). Nil is // can take a build, which is what its container's health check reads (novox/hq issue 544). Nil is
// told nothing. // told nothing.
Taking func() Taking func()
// Claim is asked once a request is fetched, just before it is handed over: false hands it back to the
// queue at once, for another holder. A holder being drained says so here (novox/hq ADR 0335): it marks
// the request in hand first and reads its drain after, so an ask of `busy` racing the fetch sees one or
// the other, never neither. Nil claims every one.
Claim func() bool
} }
// MachineOverNATS takes build work from the current build role. // MachineOverNATS takes build work from the current build role.
@@ -317,6 +322,12 @@ func (m *natsMachine) Take(ctx context.Context, do func(context.Context, Build))
endCancelled(ctx, build) endCancelled(ctx, build)
continue continue
} }
// **Drained in the moment it was fetched** (novox/hq ADR 0335): handed back at once, as a pause
// landing then is.
if m.opts.Claim != nil && !m.opts.Claim() {
_ = msg.Nak()
continue
}
// A build outlives the acknowledgement window many times over; said while it runs, // A build outlives the acknowledgement window many times over; said while it runs,
// as the controller says it for its own long handlers, so the server neither hands // as the controller says it for its own long handlers, so the server neither hands
// the ask to a second machine nor counts the wait against its deliveries. // the ask to a second machine nor counts the wait against its deliveries.
+39
View File
@@ -0,0 +1,39 @@
package link
import (
"encoding/json"
"testing"
"time"
)
// The builds a walk may take are read from the build seat's own outcomes of its checks (novox/hq ADR 0350 §2): only
// checks of the walk's pull requests, only builds made, newest first.
func TestNatsTheBuildsAWalkMayTakeAreTheChecksOutcomesOfItsPullRequests(t *testing.T) {
js := aBus(t)
since := time.Now().Add(-time.Minute)
say := func(r BuildResult) {
body, _ := json.Marshal(r)
if _, err := js.Context().Publish(BuildOutcomeOf(TheBuildMachine), body); err != nil {
t.Fatal(err)
}
}
mine := &CheckRequest{Owner: "novox", Repo: "shop", Number: 7}
built := CheckBuilt{Module: "app", Key: "r1:1", Made: []MadeArtifact{{Name: "server", Reference: "s/app/server@sha256:a"}}}
say(BuildResult{ID: "build-1", Checked: mine, Check: &CheckOutcome{Verdict: "pass", Built: []CheckBuilt{built,
{Module: "web", NotBuilt: "it has no reuse key"}}}})
say(BuildResult{ID: "build-2", Checked: &CheckRequest{Owner: "novox", Repo: "shop", Number: 8},
Check: &CheckOutcome{Verdict: "pass", Built: []CheckBuilt{built}}})
say(BuildResult{ID: "build-3", Repository: "novox/shop", Commit: "c0ffee"}) // a build, not a check
newer := built
newer.Key = "r1:2"
say(BuildResult{ID: "build-4", Checked: mine, Check: &CheckOutcome{Verdict: "pass", Built: []CheckBuilt{newer}}})
got, err := ReadCheckBuilds(t.Context(), js, TheBuildMachine, []PullRef{{Owner: "novox", Repo: "shop", Number: 7}}, since)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Check != "build-4" || got[0].Built.Key != "r1:2" || got[1].Check != "build-1" ||
got[1].Pull.Number != 7 {
t.Fatalf("read %+v", got)
}
}
+45 -1
View File
@@ -11,6 +11,7 @@ import (
"fmt" "fmt"
"log" "log"
"sort" "sort"
"strings"
"time" "time"
"github.com/novox/mesh-controller/internal/broker" "github.com/novox/mesh-controller/internal/broker"
@@ -418,6 +419,12 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
for id, why := range report.Failed { for id, why := range report.Failed {
doing.Failed = append(doing.Failed, inventory.FailedResource{ID: id, Error: why}) doing.Failed = append(doing.Failed, inventory.FailedResource{ID: id, Error: why})
} }
// And a container left running because it said busy, or replaced after it waited (novox/hq ADR 0335):
// kept with the account, for the walk whose first-machine gate waits on it.
for _, b := range report.Busy {
doing.Busy = append(doing.Busy, inventory.BusyWait{ID: b.ID, Container: b.Container, State: b.State,
Since: b.Since, Until: b.Until, Said: b.Said})
}
// Ordered, so two readings of one failure are the same reading. // Ordered, so two readings of one failure are the same reading.
sort.Slice(doing.Failed, func(i, j int) bool { return doing.Failed[i].ID < doing.Failed[j].ID }) sort.Slice(doing.Failed, func(i, j int) bool { return doing.Failed[i].ID < doing.Failed[j].ID })
@@ -492,7 +499,22 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
} }
// And how long the machine took, from the send to this first account of it (novox/hq to-be 45 // And how long the machine took, from the send to this first account of it (novox/hq to-be 45
// Phase 0): what the sent-not-reported bound will be set from. Said if lost, never a failure. // Phase 0): what the sent-not-reported bound will be set from. Said if lost, never a failure.
if err := e.Inventory.RecordApplyDuration(ctx, report.Node, report.Declared, doing.Outcome); err != nil { //
// **A wait for a busy container is its own measure** (novox/hq ADR 0335): the first report that says one
// waits is the machine's answer, kept as its apply with the detail ApplyBusy; the report that says the
// container replaced ends the wait, kept as a `busy-wait` of the same send — so the apply's time holds no
// busy time, and the walk reads the machine as answered and waiting, never as silent.
outcome := doing.Outcome
if doing.Waiting() {
outcome = inventory.ApplyBusy
log.Printf("%s applied declaration %s and leaves a busy container running until it says idle: %s",
report.Node, short(report.Declared), busyWords(doing.Busy))
} else if replacedAfterWaiting(doing.Busy) {
if err := e.Inventory.RecordBusyDuration(ctx, report.Node, report.Declared); err != nil {
log.Printf("how long %s waited for a busy container could not be recorded: %v", report.Node, err)
}
}
if err := e.Inventory.RecordApplyDuration(ctx, report.Node, report.Declared, outcome); err != nil {
log.Printf("how long %s took to apply could not be recorded: %v", report.Node, err) log.Printf("how long %s took to apply could not be recorded: %v", report.Node, err)
} }
@@ -504,3 +526,25 @@ func (e Enrolment) Heard(ctx context.Context, report Report) (news bool, err err
} }
return news, e.Inventory.RecordOwned(ctx, node.ID, report.Applied) return news, e.Inventory.RecordOwned(ctx, node.ID, report.Applied)
} }
// busyWords is every container waiting because it said busy, in a line (novox/hq ADR 0335).
func busyWords(busy []inventory.BusyWait) string {
var out []string
for _, b := range busy {
if b.State == inventory.BusyWaiting {
out = append(out, fmt.Sprintf("%s since %s, at the latest until %s", b.Container,
b.Since.UTC().Format(time.RFC3339), b.Until.UTC().Format(time.RFC3339)))
}
}
return strings.Join(out, "; ")
}
// replacedAfterWaiting says a report replaced a container that had waited because it was busy.
func replacedAfterWaiting(busy []inventory.BusyWait) bool {
for _, b := range busy {
if b.State != inventory.BusyWaiting {
return true
}
}
return false
}
+3
View File
@@ -290,6 +290,9 @@ type Checked struct {
Trunk string `json:"trunk,omitempty"` Trunk string `json:"trunk,omitempty"`
Beside map[string]string `json:"beside,omitempty"` Beside map[string]string `json:"beside,omitempty"`
Took string `json:"took,omitempty"` Took string `json:"took,omitempty"`
// Built are the builds the check made for its merge, each with its reuse key and digests, or why not
// (novox/hq ADR 0350 §1): what the walk of the merge takes instead of building.
Built []CheckBuilt `json:"built,omitempty"`
} }
// CheckedMember is one head a group's composed check judged, and the trunk commit it was merged into (novox/hq // CheckedMember is one head a group's composed check judged, and the trunk commit it was merged into (novox/hq

Some files were not shown because too many files have changed in this diff Show More