Compare commits

..
444 Commits
Author SHA1 Message Date
mesh-tools 7c015b652d Merge pull request 'sdk1: mesh-sdk keeps only what every module shares (ADR 0593)' (#161) from feat/sdk1 into main 2026-11-02 20:41:38 +00:00
jschoubben f02f142af1 Tests put back the package state they set, so a loaded check machine trips no leaked bound
mesh/delivery merged: source.main moved to novox/mesh-sdk@7c015b652d36
mesh/delivery-group group feat/sdk1 merged: every member is merged
mesh/check failed: the composition: the composer at mesh-sdk f02f142af128b336c32a7f0059c5e1606bf9b1dc did not run: failed the composer git.novox.be/no…
The check of release sdk1 failed on TestStatePutWritesAKeyWhole: TestACallIsAskedAgainOnlyWhenItReachedNoOne
left busretry's Bound at 50 ms, and on a loaded machine the state test's first pause overran it. Second failure
of the kind "tests leave package-level state set".

Quick fix: restore busretry's waits in the one test and lengthen the state test's bound.
Thorough fix (chosen, the kind met twice): busretry.Waits sets the waits and returns their restore, used as
t.Cleanup(busretry.Waits(...)) by every test that sets them, each with a bound of seconds where it waits to
succeed; and servesAlone gives every test that serves its own runtime channel and Served, put back after it,
instead of tests clearing the channel an earlier one left. A scan of every test assignment to a package-level
variable found no other unrestored one.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 20:10:58 +01:00
jschoubben 1c7356c247 catalogue: the composition's rules and refusals leave for the node-engine, so mesh-sdk renders no declaration and refuses no composition (ADR 0593 §7)
mesh/delivery-group group feat/sdk1 rejected: composed together: the checker's verdict at the members' heads (428d048dd50a): unit: git.novox.be/novox/mesh-sdk/…
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: git.novox.be/novox/mesh-sdk/go/process.TestStatePutWritesAKeyWhole: a Put through a short blackout: 0, mesh-mail's state send…
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 19:19:25 +01:00
jschoubben f4bbcd7e5e delivery: a composition carries no signature, since the node applies only what it composes itself (ADR 0593 §6)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 19:02:33 +01:00
jschoubben 1c684743f7 Hold mesh-sdk to the packages it keeps, and refuse a fingerprint or composition computed in it (ADR 0593 §1)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:58:28 +01:00
jschoubben 43d499b0b3 Remove what nothing calls once the controller's repository is emptied: facts, the src1 computation, the recreation judge, and the build step's dead helpers (ADR 0593 §11)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:58:28 +01:00
jschoubben 155669e69c catalogue keeps only what a reader or the node-engine's composition uses; the judge's rules are the indexer's own (ADR 0578 §7, ADR 0593 §1)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:54:06 +01:00
jschoubben 3e5228af46 buildstep keeps only the build step's contract; the runner, the build share, the conformance kit and mesh-pack are the Go build module's package (ADR 0593 §1)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:51:24 +01:00
jschoubben b4ade2c502 delivery: the fingerprint's computation, the named tree, the package versions and the step runner leave mesh-sdk for the indexer and the builder, which alone compute them (ADR 0593 §1, §2)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:51:24 +01:00
jschoubben 018243a86c buscompose keeps only the bus's names and shapes; the composer of the user list is the grants part's own (ADR 0593 §1)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:48:13 +01:00
jschoubben 318b0dee69 Remove the composition from mesh-sdk: compose, its bus and runtime readers, takein, validate, the composer's run and command, overlay's generator and delivery's move judge now live in the node-engine, and the placement judge in the assigner (ADR 0593 §1, §6, §7)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:45:00 +01:00
jschoubben 52bd3d087c delivery, catalogue: compose-candidate takes input as a switch and answers unready for a node that cannot compose yet, so the checker decides by the verdict, never by words (ADR 0593 §7, §9)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:34:56 +01:00
jschoubben 029ba98527 pernode: the per-node state's rules over any connection, so the runtime and a process read compositions with one copy of them (ADR 0593 §6)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:29:40 +01:00
jschoubben bb59fd8fa3 delivery: a composition's digest leaves its plan out, as a have, a want and the apply name it by its resources (ADR 0593 §6)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:25:13 +01:00
jschoubben ba82dd64b1 delivery and catalogue import neither overlay nor buildcpu, so a change to the network generator or the build share code rebuilds only their own importers (ADR 0593 §1)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:25:11 +01:00
jschoubben acef75d398 membership: an issued state may be one bucket per node, so the runtime reads every node's compositions for a bundle as one state (ADR 0593 §6)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:24:01 +01:00
jschoubben 8dc610c628 process: read a state its owner keeps one bucket per node as one state, so the provisioner, the assigner and the view read every node's compositions (ADR 0593 §6)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:23:27 +01:00
jschoubben b56841596e Delete the Python, Rust and C bindings, the Go example and the TypeScript seal and contracts exports, which nothing imports, in the release's one mesh-sdk change (ADR 0593 §12)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:21:30 +01:00
jschoubben d158ac52da composer: a request carries its composition input as raw JSON, so the shape stays in mesh-sdk while the composer moves to the node-engine (ADR 0593 §7)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:20:05 +01:00
jschoubben 346dd43228 delivery, catalogue: the node-engine's compose-candidate verb, compositions carrying their plan or refusal, the kept import form and a build's imports on its key, so the engine alone composes and the indexer switches form without a rebuild (ADR 0593 §4, §6, §7)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:19:24 +01:00
jschoubben 04d951d9da catalogue: a module's own object bucket, its reader grant and a stream that keeps its history, so the knowledge base can keep its records on the bus (ADR 0592 §5, §6, §9)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:17:14 +01:00
jschoubben 3d87ddcc9a delivery: the indexer's packages state and the imports a build reports, so only the indexer computes a fingerprint over what a module imports (ADR 0593 §2)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:15:35 +01:00
jschoubben 21dd8881ed buildstep: a check ask runs every level and every package after a failure, so one check names every failure
The operator, 2026-11-02: a failed check showed only its first fault; a group's three faults took three runs.
The change's own packages still run first (issue 1289), and nothing after them is skipped.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 18:13:51 +01:00
mesh-tools d6c87ee1a7 Merge pull request 'compose: an offer of form none composes no credential and holds nothing (issue 1126)' (#159) from fix/1126-provision-credentials into main 2026-11-02 15:31:34 +00:00
jschoubben c459f75730 compose: an offer of form none composes no credential and holds nothing
mesh/delivery merged: source.main moved to novox/mesh-sdk@d6c87ee1a7ae
mesh/delivery-group group fix/1126-provision-credentials merged: every member is merged
mesh/check pass c459f757308a on 036266daff85 | unit 6028 run, 142 kept, 16 skipped | integration 6091 run, 174 kept, 25 skipped | over bound: unit, in…
After the controller, nothing makes a pair row for a new consumer, so a need whose provider offers no credential
(form none) was composed with a stand-in on both ends and the node-engine held the consumer (issue 1126:
mesh-cli on ace, "stands in for the grant of mcp-endpoint"). The vault never mints for form none, so such a need
now composes with no credential: the consumer's need carries none, the provider's grant carries the consumer
and nothing sealed, neither is named foreseen, CredentialsNeeded asks the vault for neither, and no empty grant
file is written.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 15:30:43 +01:00
mesh-tools 036266daff Merge pull request 'Undefine the controller's streams, consumers and lease; provisions carry their plan entry (issue 1126 group B)' (#158) from feat/1126-removal into main 2026-11-02 13:40:48 +00:00
jschoubben abf5eace77 Let the bus tools remove mesh-catalog's and the engines' leftover consumers on EVENTS
mesh/delivery merged: source.main moved to novox/mesh-sdk@036266daff85
mesh/delivery-group group feat/1126-removal merged: every member is merged
mesh/check pass abf5eace7791 on 4a4fac090e9e | unit 6915 run, 1941 kept, 59 skipped | integration 6945 run, 2096 kept, 65 skipped | over bound: unit, …
novox_mesh-catalog follows the ended controller's built-before and each <node>_mesh-host restic's backed-up; no part
composes either and nothing reads them (novox_mesh-catalog 10036 behind, each mesh-host 331 never delivered), so the
removal after the delivery takes them with the controller's (issue 1126).

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 12:25:56 +01:00
jschoubben f569924037 Read past a provision field a later layout adds
Decode refused any field it did not know, so every reader on an older
mesh-sdk refused a key the moment a writer added one (plan, this release).
It now reads past it and drops it; Encode still writes the layout alone, so
no value rides in a provision (issue 1126 review 1).

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 12:18:21 +01:00
jschoubben c94f30d398 Bind a consumer from the plan entry its key carries, not only from the plans
The seat keys a consumer's provisions from its wanted and newest builds but
keeps the plan of its wanted build alone, and the provider helper bound only
from plans: a requirement the newest build adds stayed asked with no word,
and the want waiting for it never moved (mcp-endpoint on ace and shanks).
A provision now carries the plan entry it was composed from; the helper
binds from it where no plan names the key, the plan winning where both do,
and says each key it can bind from neither (issue 1126 review 1).

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 12:17:20 +01:00
jschoubben 729a86b623 Say what the wants' compare and the enrolment tokens answer now, and who composes the bus's users
The upgrade verb's compare answers each node's own composition and the assigner's findings, not
a policy against the controller's; tokens no longer says whether issuing is on, since no lease
gates it; the composed configuration names the grants part, not the controller (ADR 0590).
Issue 1126 B1, B3a.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 11:48:25 +01:00
jschoubben fb13f3cc7e Drop the keeper's and config's take-over verbs, which chose the controller's sources
ADR 0590 clause 3 ends the take-over and the hand-back, and with them compare: the readers follow
the keeper, and config its own sources, with no switch to ask. Issue 1126 B1.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 11:38:42 +01:00
jschoubben 66232896f3 Remove the controller from health, hand acts, announcements and the provider
The take-over key chose between the controller's conditions and the keeper's; ADR 0590 clause 3
ends that choice, so readers hear the keeper (KeeperSeat) and the self-check (SelfCheckSeat)
alone and the key, its version and its two sources go. The controller's hand-act log, its
show verb, its kept announcements and its assignments mirror's name go too, and the hand-act
recorder's unread LeaseAway. Issue 1126 B4b.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 11:37:49 +01:00
jschoubben 8757567a79 Wrap the composition signature's comment
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 11:36:31 +01:00
jschoubben dcb2e6e966 Remove the controller from the delivery contract, and its command line client and hand composer
The controller ended (ADR 0590): the grants issued are the grants part's alone, so its grant value
is Grant and GrantSource goes; a node trusts the vault's key alone, follows the grants part's
user list alone and composes its own or none; the controller's composed and builds bucket names
go. nox calls seat verbs (ADR 0590 clause 7), so the cli package that spoke to the node-engine's
socket goes, and mesh-hand-compose, which wrote the controller's compositions. A have an engine
from before wrote, composes controller, still reads. Issue 1126 B4a, B6.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 11:36:26 +01:00
jschoubben ba1e90fb2d Undefine CONTROL and the controller's consumers, its followed events and its lease reading
The controller ended (ADR 0590): nothing consumes what nodes report on CONTROL, so the mesh no
longer declares the stream, which would make it again before its removal; MeshConsumers is empty,
the controller's consumers and the events it followed go, and the lease holder's reading
(LeaseHolder, LeaseHandedAway) with them. Only the consumers' names stay, for the removal of the
leftovers and the watchman's read of whether controller still stands. Issue 1126 B3b.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 11:33:12 +01:00
jschoubben e240e01d07 Bound an ask's step by how long it stands still, not its age, so a step moving through phases is waited for (issue 1930)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 11:31:38 +01:00
jschoubben 7b034e1db0 Compose the bus's users without the controller, and grant the removal of its leftovers
The controller ended by its unassignment (ADR 0590): its user, its lease's cut and reads, the
operator's hand path to its compositions and its writers' rows go; an enrolling node enrols
through mesh-enrolment alone; the desk's prompt stays the vault's alone (DeskOnly). The golden
lists are now this composer's own, taken first from the controller's with its user removed.
The bus module's tools may remove the controller's leftovers by name, consumers, CONTROL,
ASSIGNMENTS, its 17 buckets and each node's declaration files' bucket, under the word
controller-leftovers (clause 6). The provisioner client reads placement in place of the
assignments mirror. Issue 1126 B3a, B5.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 11:31:33 +01:00
jschoubben 49b307a46d Route a call key naming its machine after '@', and refuse a malformed key before publishing
The lease write asked seat:node-service-manager.stop@novox-be through Conn.Call, which published
the '@' inside the subject: nobody served it and the call timed out after 30 s with nothing said
(issue 774 notes 84-87). Every key now parses in one place, calladdress: Call routes '@node' as
CallOn does, and stdio Ask refuses a key the runtime could not route, before anything is sent.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 11:22:50 +01:00
jschoubben 8295207077 Remove the controller's seat, the lease verbs and placement's adopt and hand-back; refuse any manifest naming the controller
The controller ended by its unassignment (ADR 0590), so its seat, the grants seat's lease write and
hand-back and placement's take-over from it serve nothing. The dependence check becomes strict
(clause 5): a read of the lease, a claim, a require or the module itself is refused like any other
declaration. held and artifacts-gone are the artifact sweep seat's (ADR 0571 §2). Issue 1126 B1, B2.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 11:20:28 +01:00
mesh-tools 4a4fac090e Merge pull request 'The enrolment seat and node-login rotation contract: a joining machine's first tunnel, add-node and remove-node, two logins per node (issue 774)' (#157) from feat/l3-enrol into main 2026-11-02 07:56:24 +00:00
jschoubben dff0ce2abb Say why LeaseHandedAway and LeaseCut are two fields: the cut precedes the holder's write and outlasts it at a hand-back (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@4a4fac090e9e
mesh/delivery-group group feat/l3-enrol merged: every member is merged
mesh/check failed (2 tests): integration: mesh-mail-reader/cmd/mesh-mail-reader.TestAcceptanceOneMatteringMailSendsExactlyOneTellNamingIt: timed out w…
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:46 +01:00
jschoubben c816666f83 Name the enrolment an address is reserved for, from its ask, and the issued event's id in one place
ADR 0589 §6: an address key gains the issue's ask and time, written when the ask is raised. The key is written at the
reservation, before the ask, so both are empty then and both set from the ask; one without the other is refused, as
is no node. EncodeEnrolmentAddress takes node, ask and issued. EnrolmentIssuedID builds the enrolment-issued event's
id, enrolment-issued.<node>.<issue time in Unix nanoseconds>, as the holder names it today. Tested as rows of
TestNodeRecord; no new test function (1438 unit tests).

(issue 774, review round 1b)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:36 +01:00
jschoubben 2d717777b1 Carry the machine's tunnel key on its enrolment, so the holder places it on the operator's yes
The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue. Enrolment gains overlay-key, required at
every stage and refused unless it is 32 bytes in base64. Tested as rows of TestNodeRecord; no new test function (1438
unit tests).

(issue 774, review round 1b)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:36 +01:00
jschoubben f913918a60 Take the tunnel key at issue and place a joining node on the operator's yes, so the hub peers it before redeem
The operator's decision of 2026-11-02 01:05: issue requires overlay_key, the machine's tunnel public key; the vault's
enrolment-token tunnel declares all five fields a token needs (hub_key, hub_endpoint, address, key, range). An
add-node is proposed at the yes with the tunnel key alone; the identity keys come whole at redeem or not at all. A new
remove-node, mesh-enrolment's alone on the same warrant and the proposal's one change, takes a joining node running
nothing off placement when its enrolment ends unconfirmed (RemovedNodes names the key the holder deletes; LayChanges
lays nothing for it and refuses a node that runs modules or holds seats). NodeLogin gains Acts, the hand acts that
warranted its rotations. Tested as rows of TestPlacement, TestNodeRecord and the two seat tests; no new test function
(1438 unit tests).

(issue 774, review round 1b)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:36 +01:00
jschoubben 38a0bbca44 Let enrolment-token take the first tunnel, so the token the vault makes carries it as today
Lead's decision on milestone point 7 (a progressive insight of ADR 0587 §3): mesh-enrolment builds the joining
node's first tunnel at issue and passes it to the vault's enrolment-token as `tunnel`, an object of hub_key,
hub_endpoint and address, each required; the vault puts it in the token (delivery.TokenTunnel). Tested as a row of
TestTheVaultsSeatPromisesItsIntakeVerbs; no new test function (1438 unit tests).

(issue 774, review round 1)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:36 +01:00
jschoubben 79ad8b539c Name the expired enrolment's issued event by its id, and carry a node's login fall back in its health report
Blocker 6: enrolment-expired carries the enrolment-issued event's message id (IssuedID), since a bundle's publish
answers no stream sequence and ADR 0587 §2 asks only that the event be named; it gains a codec refusing no event named.
Blocker 7: NodeHealth gains Fallback (node, the login fallen back from, why, at), from which the watchman raises
node-account-fallback (ADR 0583 §6); a fall back naming another node is refused. A token missing its signer now says
the vault's signing key, not the retired word. Tested as rows of TestNodeRecord and the control records test; no new
test function (1438 unit tests).

(issue 774, review round 1)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:36 +01:00
jschoubben 90a5fc5797 Let placement take an add-node with its enrolment's warrant, so one Approve lets a machine join
ADR 0587 §7: at redeem mesh-enrolment proposes one change, the node with its decided fields (the approved address,
never the hub) and the public halves of its keys, carrying the issue's warrant; mesh-placement checks the warrant
names this node and address and that the caller is mesh-enrolment, and writes the key with no ask of its own
(changing ADR 0567 §2 and §5 for this one change by this one caller). The proposal codec refuses an add-node beside
other changes, without a warrant, or whose warrant names another node or address, and a warrant on any other
proposal; AddNodeRefusal adds the caller's check for the assigner; LayChanges adds the node and refuses one placed
already. Tested as rows of TestPlacement's proposal subtest, no new test function.

(issue 774, 774/S42)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 362dddae40 Compose mesh-enrolment's grants: its bucket written by it alone, the vault's enrolment verbs called by it alone
ADR 0587 §1-§3 (changing ADR 0561 §5) make mesh-enrolment the only caller of the vault's enrolment-token and
node-account and a caller of sign, and §2 its bucket enrolments read by it and by the grants part as a named reader.
The holder calls the vault through its manifest's invokes, in its own name, as any caller does; while the vault serves
either enrolment verb every other principal is denied both, however wide its grant (a runtime's or an agent's `*`),
as ControllerOnly denies the desk's prompt. The writers table gains the open enrolments (mesh-enrolment) and the
node-logins (the vault), each one writer; NamedReaders names the grants part for enrolments. Golden lists unchanged.
Tested as a subtest of TestTheCopiedComposerComposesAsTheControllers, no new test function.

(issue 774, 774/S41)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 9169830210 Grant an enrolling node's user the seat's redeem alone, so from the lease write no token reaches the controller
ADR 0587 §4: the grants part composes enrol.<node> granted only to call mesh-enrolment.redeem and to hear its own
inbox; §6: from the lease write the controller's enrolment subject is not served. The enrolment principal now calls
redeem in its own name while the seat's holder serves it, and keeps mesh.control.enrol only until Records says the
lease is handed away (a field the grants part sets and the controller's composer never does). A user left able to
enrol nowhere is refused by name rather than composed empty. Golden lists unchanged. Tested as a subtest of
TestTheCopiedComposerComposesAsTheControllers, no new test function.

(issue 774, 774/S40)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 8c2ce57eee Compose a node's two logins from the vault's node-logins, so a rotation can switch with both loaded
ADR 0583 §1: a node's account rotates between node.<n> and node.<n>--b, each with the node's permissions and its
inbox _INBOX.node.<n>. Records gains NodeLogins (each node's key of node-logins; the context file's NodeRotating,
renamed because a node that rotated and rotates no more logs in as its second login alone), and Users composes one
node user per login: the one in use and, while it rotates, the other. Each login calls in its own name, which is how
the vault tells the login that confirms (§3). While the vault serves rotate-node, each node reads its own node's key
of node-logins alone and may call node-account-confirm in its own name (§1 insight); the grants part is the state's
one named reader. With no rotation every list is the controller's golden one, unchanged. Tested as a subtest of
TestTheCopiedComposerComposesAsTheControllers, no new test function.

(issue 774, 774/S39)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 69270a30d5 Add the vault's rotation verbs and the state node-logins, so a node's account rotates over two logins on one contract
ADR 0583 §1 (insight 2026-11-01) keeps which login a node uses in the vault's state node-logins, one key per node:
the login in use, the one rotating and the stage (begun, ready, confirmed), with the list revision that held both.
§2, §3 and §5 give the vault's seat rotate-node (with the recorded hand act's id), node-account-confirm (the caller
is the proof) and abandon, optional until the vault serves them; the events rotation-begun, node-account-ready,
rotation-done and the engine's finding node-account-fallback are named beside them. The codec refuses a stage without
a rotation and a rotation to anything but the node's other login; an empty rotating reads as none. Tested as rows of
TestNodeRecord and TestTheVaultsSeatPromisesItsIntakeVerbs, no new test function.

(issue 774, 774/S38)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben d7a00e1f26 Add the vault's enrolment verbs to its seat, so mesh-enrolment asks the vault to mint and sign and holds nothing
ADR 0587 §3 and §5 (changing ADR 0561 §5) have the vault make the token and its hash (enrolment-token), answer it
sealed to the operator key for tokens to relay (sealed, §8), drop both at every end (drop), and mint, answer again
and retire a joining node's account (node-account), with mesh-enrolment their only caller; sign names mesh-enrolment
for a joining node's certificate (with the serving key it presented, since the node has no record yet) and for a
redeem answer. Both verbs optional until the vault that runs serves them. Their answers' shapes are in go/delivery.
Tested as rows of TestTheVaultsSeatPromisesItsIntakeVerbs, no new test function.

(issue 774, 774/S37)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 2ee75954ad Move the joining token's shape into mesh-sdk, so the vault that makes it, mesh-cli and the engine read one type
From the lease write the vault makes the token (ADR 0587 §3), mesh-cli opens and prints it, and the node-engine
parses it: three repositories, so the type leaves mesh-host's internal/identity (which keeps an alias) for
go/delivery, field names unchanged from the controller's token issue, the tunnel kept (the lead's settlement 7).
Encode is added, refusing a token with a part missing. The test reads a token the controller's own code made and
encodes it back to the same line. Paid by merging TestGateBuckets into TestKeysOfPlainNamesAreUnchanged.

(issue 774, 774/S36)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 959f57294a Add the contract of the enrolment state and of redeem, so the holder, the grants part and the engine read one shape
ADR 0587 §2 gives the seat mesh-enrolment the bucket enrolments with keys node.<node> (an Enrolment: node, address,
why, caller, ask, stage, issued, expires, keys digest) and address.<address> (the node's name alone), no secret in
either. The token's life rules (an hour by default, at most a day, §4), the event enrolment-expired's content (§2),
and redeem's call, proof bytes, keys digest and signed answer (§6) live here too, because the holder in
mesh-delivery and the engine in mesh-host both read them. The proof binds the node and its keys, not the token's
secret, which the holder never holds (§1). Tested as rows of TestNodeRecord, no new test function.

(issue 774, 774/S35)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
jschoubben 59b4c7910b Add the seat mesh-enrolment: six verbs and five events, so the module that joins a machine has a role the judge knows
ADR 0587 §1 makes joining a machine a seat held once by the module mesh-enrolment, the vault's only caller for its
enrolment verbs. The seat is in the mesh's own set, its verbs not optional (the seat is new with its holder), and
its events the five that carry no secret. The test is a block in TestHoldingAMeshSeatRequiresServingItsVerbs, no new
test function.

(issue 774, 774/S34)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 01:41:03 +01:00
mesh-tools 5758e9fb57 Merge pull request 'The lease write's contract: the seat mesh-grants, the lease cut, processes follow their membership from state alone (issue 774)' (#156) from feat/l3-removal into main 2026-11-02 00:35:20 +00:00
jschoubben c21d8fd969 Fold the seat mesh-grants' test into the seat-verb test, within the test budget
mesh/delivery merged: source.main moved to novox/mesh-sdk@5758e9fb57ab
mesh/delivery-group group feat/l3-removal merged: every member is merged
mesh/check failed: mesh-build-go: the check of novox/mesh-catalog#590: the ask mvyhumdm860c7f's step on shanks did not end within 30m0s of its start |…
The repository is over its unit budget and a change may not raise the count: the case of the seat mesh-grants
(ADR 0582 clause 1) runs as a subtest of TestTheDeliverySeatPromisesChecks instead of a function of its own.

(issue 774, 774/S1)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:50:37 +01:00
jschoubben bbfda29b49 The watchman hears the lease write's events and the stream ASSIGNMENTS deleted
ADR 0582 clauses 4 and 6 have mesh-grants say lease-altered and removal-failed "as a finding the watchman
raises". WatchmanHears, the one list its grant and its subscriptions come from, gains the seat mesh-grants'
three events and the advisory of ASSIGNMENTS deleted (which, with the controller's consumer deleted that it
hears already, clears a failed removal). Core subjects, read-only, no durable consumer.

Test: rows added to the watchman's subjects in TestTheCopiedComposerComposesAsTheControllers; no new function.

(issue 774, 774/S22)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:37:04 +01:00
jschoubben 3ed41f02d5 The keep list holds the three kept to the write, since this delivery drops every other read of the controller
ADR 0571 §13: the lease write's own delivery drops every entry but the builder's artifacts-gone, the artifact
sweep's collect and the engine's following of the controller's user list. Every module of this group stops
its read, invoke or consume of the controller (pacman, the keeper, mesh-config, the hand-act log's readers, the
watcher, messenger, the assigner, mesh-grants), so the list shrinks to the three and a read put back is
refused in either phase. Supersedes the keep of pacman's read at mesh-delivery 4a84370.

Tests: rows of TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController changed (the keeper's read put back
is refused; the sweep's collect passes); no new test function.

(issue 774, 774/S30)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:33:34 +01:00
jschoubben 09a5eb5783 Delete PlacementProposalChange.BeforeLease: the assigner no longer keeps the controller's assignments in step
It said which placement changes waited for the lease write while the assigner called the controller's assign,
unassign, pin and unpin (ADR 0567 §8). Since the assigner calls no controller verb (774/S25), nothing reads it.

Test: its table in TestPlacement goes with it.

(issue 774, 774/S25)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:32:13 +01:00
jschoubben a4ed4e6742 Stop hearing mesh.assignment: a process follows its membership from the grants part's state alone
ADR 0571 clause 10: the grants part publishes memberships on mesh.assignment until the lease write and stops
then. FollowMembership now reads and watches its own key of the state memberships and nothing else, and refuses
to follow, naming the state, when it cannot read it (there is nothing else to follow, and a silent follow of
nothing is the fault). A module's principal and the runtime lose their subscribe on mesh.assignment; the grants
part's publish grant stays until the write (S21 stops the publishing).

Tests: TestFollowMembershipHearsWhatIsIssuedToTheAccount (integration) edited: no state is a refusal, a
membership put in the state is applied, one published on the subject is not; run locally against its
embedded bus. Rows added to TestTheCopiedComposerComposesAsTheControllers: nobody subscribes mesh.assignment,
and the controller's golden grant of it is the one row the copy now lacks. No new test function.

(issue 774, 774/S5)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:19:15 +01:00
jschoubben d7a008bee5 Move the controller-dependence judge into mesh-sdk, so three repositories run the same lease-write check
ADR 0571 §13 names a test in mesh-catalog, mesh-delivery and mesh-tools that refuses a manifest depending on
the controller beyond the lease and the entries kept. The judge lived in mesh-grants (leasecheck.go); it moves
here whole (the kinds, the phases, KeepList, EndedBy, Dependencies, Judge) with ManifestRefusals(manifests,
phase) for those tests. Kept is renamed KeptDependency (catalogue already has a Kept), and a gap is Unread.
mesh-grants re-exports it in its own commit; the keep list is the one at mesh-delivery 4a84370.

Test: TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController, moved from mesh-grants'
leasecheck_test.go (its manifest rows and the keep-list test, which mesh-grants deletes), with the
conditions row of 774/S4/AC1 added; not a new test.

(issue 774, 774/S4)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:17:13 +01:00
jschoubben feadebd331 Let the lease key name its ask and time, and the user-list key say the lease is handed
ADR 0582 clause 3 writes the key holder "with the why, the ask and the time": LeaseHolder gains ask and at,
which LeaseHandedAway ignores (it reads handed-to alone). The grants part's user-list key gains lease-handed,
so the bus node's engine follows the grants part's list alone once the lease is handed (ADR 0571 clause 8)
and never falls back to the controller's; a key written before reads false.

Tests: a row in the lease table of TestTheCopiedComposerComposesAsTheControllers; the old key (shaped as
EncodeGrantsUserList writes it today) and the flag's round trip added to the existing user-list case in
delivery_test.go; no new test function.

(issue 774, 774/S3)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:15:20 +01:00
jschoubben 6f483725e9 Compose the lease write's cut, so a controller started after the write fails on the lease
ADR 0582 clause 3 composes the bus's user list without the controller's write on the lease key and update of
the lease's bucket. Records.LeaseCut, set by the grants part from the write's second step, drops the
controller's $KV grant on the lease, denies it the bucket's update, purge, delete and message delete (its
$JS.API.> reaches them), and lets the grants part write the key holder alone. The bus module's tools account
may read and update the lease's bucket, for the expiry tool the write and the hand-back call. Without the cut
the lists equal the controller's golden.

Tests: rows added to TestTheCopiedComposerComposesAsTheControllers' tables (the cut on and off; the tools
account's lease grants); no new test function.

(issue 774, 774/S2)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:14:44 +01:00
jschoubben fdbb3d5784 Add the seat mesh-grants, so the grants part can claim the lease write as a seat verb
ADR 0582 clause 1 makes the lease write a verb of the seat mesh-grants: lease-write and lease-hand-back, each
taking a why, and the events lease-handed, lease-altered and removal-failed. The indexer's judge reads seats
through catalogue.SeatNamed, so it accepts mesh-grants' claim once it pins this head.

Test: TestTheGrantsSeatPromisesTheLeaseWrite (new), paid by merging the two ssh tests of filtering_test.go into
TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned.

(issue 774, 774/S1)

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-02 00:12:08 +01:00
mesh-tools ea8c50136c Merge pull request 'Work out a node's declared packages and compose a node once over one read (ADR 0565 §6, ADR 0580; issues 1953, 1954)' (#155) from feat/node-packages into main 2026-11-01 17:02:22 +00:00
jschoubben b2a27ed5ce Merge branch 'feat/compose-once' of /home/jochen/projects/novox/mesh-sdk-compose into feat/node-packages
mesh/delivery merged: source.main moved to novox/mesh-sdk@ea8c50136cc7
mesh/delivery-group group feat/node-packages merged: every member is merged
mesh/check pass b2a27ed5cec4 on 621984614dc6 | unit 8060 run, 706 kept, 51 skipped | integration 8198 run, 749 kept, 56 skipped | over bound: unit, in…
2026-11-01 16:19:45 +01:00
jschoubben 5d26c658f5 Compose a node's plans once per read, resolving each machine once (issue 1954)
A node's plans took 2.6-13.6 s of CPU on the provisioner: one composition
resolved every machine of the mesh some two hundred times (who is on the
private network, and the rest of the mesh, worked out again on every
look), three compositions did so again each, and the holder composed
twice. A composer now works each out once over its unchanging Input;
Composing gives Resolve, Render and Generators one composer;
PlansHeldTwice composes the wanted and the newest builds over one read of
the bus, says where the time went by phase, and the states reader keeps a
build's take-in by its content. WatchedStates takes a process's own
writes into the copy at once, so a holder reads its own states from it.

Measured on a captured novox mesh: 11.7 s to 0.49 s per composition of an
unchanged node, plans and provisions identical on all four nodes.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 16:18:44 +01:00
mesh-tools 621984614d Merge pull request 'Agree a toolchain by strict majority; a node on another version sits out (ADR 0584, issue 1578)' (#154) from fix/toolchain-majority into main 2026-11-01 15:04:07 +00:00
jschoubben 0ef7c0caa4 Work out a node's declared packages and their difference with the mirror in the SDK, so each engine writes its own key (ADR 0565 clause 6, issue 1953)
The engine composes its node's wants itself, so the derivation the controller keeps
privately moves beside the layout: DeclaredPackagesOf gives the mirror's bytes from the
compositions, DiffDeclaredPackages what the node seat's verb packages compares until the
lease write, and EnginePackagesRead names the state as readers declare it (mesh-host.packages,
as mesh-host.data). Each engine may read the controller mirror's key of its own node alone.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 16:02:06 +01:00
jschoubben 67354f8e01 Name the agreed toolchain in a check ask and decline it on a node off that toolchain (ADR 0584, issue 1578)
mesh/delivery merged: source.main moved to novox/mesh-sdk@621984614dc6
mesh/delivery-group group fix/toolchain-majority merged: every member is merged
mesh/check pass 67354f8e019a on cad8250aaa8c | unit 7056 run, 243 kept, 16 skipped | integration 7194 run, 275 kept, 25 skipped | over bound: unit, in…
A check ask named no toolchain, so a node on the minority version could run it and its pass be recorded as run on the agreed one. CheckJob now carries the toolchain; a node on another declines it before it fetches, as for a build ask.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 15:33:43 +01:00
jschoubben 71be15f530 Decline a build ask for another toolchain instead of failing it (ADR 0584 clause 2, issue 1578)
With the toolchain agreed by majority, a node on the minority version can pull an ask for the agreed one. It now declines it (the issue 890 path) for the asker to put again, so it never fails a build.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 15:24:13 +01:00
jschoubben a6c6d936d5 Agree a toolchain by strict majority and name the nodes that sit out (ADR 0584, issue 1578)
One build node on another version stopped every build of the language. The agreed toolchain is now the strict majority's; a tie still waits and names the versions.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 15:20:26 +01:00
mesh-tools cad8250aaa Merge pull request 'A watched reader of bus states; the hand-act recorder records only through mesh-hand-acts (ADR 0580, design 47 L5)' (#153) from feat/l5-l7-main into main 2026-11-01 13:53:07 +00:00
jschoubben 790d1c82d0 A follower joining a watched state hears its current values, and a templates archive is fetched once
mesh/delivery merged: source.main moved to novox/mesh-sdk@cad8250aaa8c
mesh/delivery-group group feat/l5-l7-main merged: every member is merged
mesh/check pass 790d1c82d027 on da41823855a9 | unit 1964 run, 5697 kept, 30 skipped | integration 1999 run, 5829 kept, 38 skipped | over bound: unit, …
A read could start a state's watch before its follower joined, and a change landing in that gap woke nothing (review
of ADR 0580 §2): a joining follower now runs over the copy's current values before any later change. The watched
reader keeps each archive once per URL, and both caches are bounded.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 09:53:17 +01:00
jschoubben 8696aa9ba4 The hand-act recorder records through the seat alone, and the watched reader's reads are proved
The controller's hand-act and warranted are gone (issue 1953 L5b) and the bus refuses them, so the recorder no longer
falls back to them: an act the seat does not take is an error saying nothing was recorded. And ADR 0580 clause 1's
reader is shown reading a build by key each time and a manifest once per digest.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 09:53:17 +01:00
jschoubben db4eb3bbf4 One watched copy of the states a module reads, and the composer's reader over it
A whole state read key by key took the provisioner's composition of one node past 30 s and the assigner's compare past
its call bound (ADR 0580 clause 1): each state is now one watch's current values, kept by its changes; a build is read
by key and a manifest once per digest.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 09:53:17 +01:00
mesh-tools da41823855 Merge pull request 'mesh-sdk keeps the manifest contract and a lenient decoder; the judge leaves it (ADR 0578, 0579, 0573 §2)' (#152) from feat/judge-once into main 2026-11-01 07:53:58 +00:00
jschoubben 8af6e72669 Format streams.go as gofmt writes it, which the group's check refused
mesh/delivery merged: source.main moved to novox/mesh-sdk@da41823855a9
mesh/delivery-group group feat/judge-once merged: every member is merged
mesh/check pass 8af6e7266915 on ff5e00f1f894 | unit 8665 run, 1440 kept, 62 skipped | integration 10038 run, 975 kept, 70 skipped | over bound: unit, …
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 03:22:06 +01:00
jschoubben 62cddf4efc Refuse a manifest judge in mesh-sdk's own check, and never end the data items' read on one module (ADR 0578 §3, §4)
mesh/delivery-group group feat/judge-once rejected: composed together: the checker's verdict at the members' heads (8a8ede2fb2c8): unit: go.gofmt
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: go.gofmt: not as gofmt writes them (run gofmt -w): go/catalogue/streams.go | 62cddf4efc62 on ff5e00f1f894 | unit 0 run, 78 ke…
Review round 1 (issue 1585): L1, DataItemsHeld holds a module by the culprit rule PlansHeld uses, a held module's
data left out, so an item its composition refuses holds that module alone; L2, mesh-sdk's own tests scan its go/
tree as mesh-catalog's checks/sdkjudge does, with the same list of composition's refusals. Two tests merged into one.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 03:07:50 +01:00
jschoubben 19c204273b Let a verdict warn of what a stricter judge found in a manifest it accepted before (ADR 0579 §1)
Issue 1585: ManifestVerdict and the modules value carry Warnings; the finding manifest-outgrown names it.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 02:43:10 +01:00
jschoubben a20bdf7952 Carry mesh package versions in build asks and fingerprints
ADR 0573 §2 has every module build against the latest published version of
each mesh package it uses, chosen by the planner and written by the build,
never pinned by hand (issue 1585, S4). The ask, the fetch request and the
merge preview now carry the packages and their versions, and the new-form
fingerprint takes each version, so a package's trunk merge changes every
dependent's fingerprint. A module using no package keeps its fingerprint.

Folded onto feat/judge-once by builder-1585a: Module keeps both Packages and the verdict fields.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 02:40:12 +01:00
jschoubben 4dfc11b424 Take the reserved stream prefixes out of mesh-sdk with the judge that reads them (ADR 0578 §4)
Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 02:38:05 +01:00
jschoubben 68aaf235d4 Take the judge out of mesh-sdk: it is mesh-delivery's now (ADR 0578 §4, §7)
Issue 1585, S19: the strict parser, every manifest problem function and the judge's tables (192 declarations,
about 5,200 lines) and their 244 tests leave; composition keeps its own refusals and the helpers it uses. The
catalogue's tests decode the manifests they build. ParseManifest, ParseManifestWith, ParseManifestForInputs,
CatalogueProblems and the rest are gone; mesh-delivery-indexer/judge holds them.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 02:36:24 +01:00
jschoubben 25a2cecd98 Let the tree readers decode, give the judge the manifest's data it reads, and stop judging outside the catalogue (ADR 0578 §3, §4)
Issue 1585, S6-S8: ReadTreeManifestWritten and WithTemplatesFrom decode (the judge in mesh-delivery judges what they
answer); Manifest.Predefined, SetPredefined and FormsWritten expose what the moved judge reads; buscompose names its
removals without the catalogue's allow-list; tests outside the catalogue decode, two tree-judging tests moved.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 02:33:19 +01:00
jschoubben 7cab079e2e Merge the set-aside field's three tests into one, so the judge work adds no test to the budget
Issue 1585: mesh-sdk and mesh-delivery are both over 500 unit tests; ADR 0578's tests replace, never add.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 02:20:31 +01:00
jschoubben 9904a03b51 Let the merge preview and the provided manifest decode instead of judging (ADR 0578 §3)
Issue 1585, S12 (mesh-sdk half): buildstep's named paths and delivery.ProvidedManifest read with catalogue.Decode.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 02:18:56 +01:00
jschoubben 7b09de1e55 Let the composer take each manifest's verdict from its input instead of judging (ADR 0578 §5)
Issue 1585, S11 (mesh-sdk half): Case.Verdicts carries the indexer's verdicts; a refused module is held as its
stub and named; an unknown field is set aside and named, never refused.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 01:57:36 +01:00
jschoubben 7631a7d5de Hold a refused manifest in the bus reader instead of ending the whole read (ADR 0578 §3)
Issue 1585, S2: one refused manifest silenced disk-load for 45 hours. frombus decodes leniently, takes the
indexer's verdict, stands a refused module in as its stub and names it in Input.Refused; DataItemsHeld names it.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 01:57:36 +01:00
jschoubben a343557554 Give the indexer's verdicts on manifests a contract readers take (ADR 0578 §1, §3)
Issue 1585, S3: state mesh-delivery-indexer_verdicts keyed by manifest digest, event manifest-refused, a modules
value carrying its verdict, and TakeManifestVerdict reading a missing verdict as accepted. Contract only, no rules.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 01:57:36 +01:00
jschoubben ccb367e68c Decode a manifest leniently, so a field a reader does not know never leaves its module out (ADR 0578 §3, §6)
Issue 1585, S1: catalogue.Decode sets an unknown field aside and names it (SetAside); only a manifest not read past
the field is still left out.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 01:57:36 +01:00
mesh-tools ff5e00f1f8 Merge pull request 'The contract of the node-engine's data items, so disk-load reads what the engine composed (ADR 0577)' (#151) from feat/data-items into main 2026-11-01 00:03:10 +00:00
jschoubben c28c645ca4 Name the node-engine's data items state, so mesh-host writes and disk-load reads one shape (ADR 0577)
mesh/delivery merged: source.main moved to novox/mesh-sdk@ff5e00f1f894
mesh/delivery-group group feat/data-items merged: every member is merged
mesh/check pass c28c645ca406 on 5ae69df8ccde | unit 4510 run, 4259 kept, 49 skipped | integration 4589 run, 4361 kept, 51 skipped | over bound: unit, …
The contract (state name, key form, value) is in delivery; buscompose grants the engine its own
node's keys of mesh-host_data and each node's disk-load a read of its own node's keys alone, the
same way the engine's other states are granted.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-11-01 00:05:38 +01:00
mesh-tools 5ae69df8cc Merge pull request 'A finding's reference is ref, not token, and a held seat carries its own events (issue 1952)' (#150) from fix/l4-quick into main 2026-10-31 21:33:56 +00:00
jschoubben cbbfbf3d2d Let observers' findings and a carried holder's seat events reach the bus (issue 1952)
mesh/delivery merged: source.main moved to novox/mesh-sdk@5ae69df8ccde
mesh/delivery-group group fix/l4-quick merged: every member is merged
mesh/check pass cbbfbf3d2d55 on fea48f19cea5 | unit 2729 run, 4570 kept, 16 skipped | integration 2975 run, 4493 kept, 25 skipped | over bound: unit, …
A finding's field `token` is named like a credential, so the tool runner
refused every finding that carried it; it is `ref` now, and a finding
written under the old name is still read. A module the tool runner
carries got no grant for its own held seat's events, so the self-check
could not say `checked`; a held plain seat's events are now in the
holder's seat traffic, its membership and its runtime's grants.

Quick vs thorough: quick, these fixes for L4's take-over now; the
thorough fix (research 032 / ADR 0240's health view extension) is
decided later.

Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
2026-10-31 21:30:31 +01:00
mesh-tools fea48f19ce Merge pull request 'A contribution carries the machine port of its endpoint, so a provider serving from plans dials what the module binds (issue 774)' (#149) from fix/route-ports into main 2026-10-31 12:56:48 +00:00
jschoubben bb01a656d0 Carry an unassigned port as the declaration publishes it, refusing only a port a module does not listen on (issue 774, research 072 row 34)
mesh/delivery merged: source.main moved to novox/mesh-sdk@fea48f19cea5
mesh/delivery-group group fix/route-ports merged: every member is merged
mesh/check failed: integration: mesh-vault/cmd/mesh-vault.TestTheVaultWholeMintsOnTheBusWhoeverHoldsTheLease: never: the account minted under the cont…
influxdb and unifi publish bare ports that mesh-placement assigns none: the machine runs them at
the software's own number, so a plan refusing them held their whole plan for a route that works.
2026-10-31 13:21:37 +01:00
jschoubben b611022457 Carry the machine-assigned port in a plan's contribution, so a provider serving from plans dials where the module listens (issue 774, research 072 row 34)
route-proxy served every route at the software's own port (gitea's 3000, published at 20000;
grafana's 3000 on another machine, published at 20012) and public sites answered 502. The plan's
contribution now carries the port the placement assigned, the number ${port:N} gives the module,
and a composition for plans refuses a port none assigned by module, node and port.
2026-10-31 13:08:35 +01:00
mesh-tools fbbfae7629 Merge pull request 'Placement keyed per node and module, no ASSIGNMENTS stream, and plan entries carrying a consumer's contribution (issue 774)' (#148) from feat/072-moves into main 2026-10-31 10:30:57 +00:00
jschoubben acbf80ad91 Carry what a consumer contributes in its plan entry as one opaque payload, so a provider serving from the seat's plans reads it with its own contract and the provisioner knows no provision's fields (issue 774, research 072 row 34)
mesh/delivery merged: source.main moved to novox/mesh-sdk@fbbfae7629bf
mesh/delivery-group group feat/072-moves merged: every member is merged
mesh/check failed: shanks: the check of novox/mesh-controller#487 failed: go mod download in modules/route-proxy: go: git.novox.be/novox/mesh-controll…
2026-10-31 09:47:08 +01:00
jschoubben de8e82c06f Say what the controller's lease turns now that the vault mints whoever holds it: grants' consumers, the grant source, retirement and rotation (issue 774) 2026-10-31 09:34:27 +01:00
jschoubben 085f17e935 Let a node composing itself hand a module exactly when the vault judged its plan handed, through one rule (catalogue.AssignmentHands), so the vault and the node never judge postgres's secrets two ways again (issue 774) 2026-10-31 09:29:33 +01:00
jschoubben 2d1d8def75 Take the plan entry's Given and ConsumerAt back out: route-proxy's seat defines its own contract and the provisioner's plan stays generic (the operator, 2026-10-31; issue 774). process.Conn.Read stays. 2026-10-31 09:29:17 +01:00
jschoubben 98d5306d6b Say which machines are inside the private network from placement, for route-proxy, whose membership read goes with the assignments stream (issue 774, research 072 rows 33 and 34) 2026-10-31 09:28:15 +01:00
jschoubben a4e7629742 Carry each contribution, composed, and the consumer's address in its plan entry, and let a process read another module's state, so route-proxy serves routes from the seat's plans alone (issue 774, research 072 row 34) 2026-10-31 09:27:26 +01:00
jschoubben a320f8e2cd Keep placement.<node> in step after each commit, so a reader of the form before alone follows every write and the delivery's members need no order (issue 774, research 072 row 37) 2026-10-31 09:26:17 +01:00
jschoubben 7a9c8570cb Stop declaring and reading the ASSIGNMENTS stream: a runtime reads its membership from the grants part's state, and the stream is deleted at the lease write and must not be made again (issue 774, research 072 row 33) 2026-10-31 09:21:04 +01:00
jschoubben 4c54aec972 Keep the seat mesh-placement's state as one key per module and node, so a node watches its own keys and a change writes only the modules it moves (issue 774, research 072 row 37)
The node key <node>.node is a write's commit: it names each placed module with its value's digest, the
module keys carry the value they replace until it lands, so a reader never composes half a write. Every
reader goes through PlacementKeys/PlacementsOf, which read the legacy placement.<node> too while the
change is delivered.
2026-10-31 09:21:04 +01:00
jschoubben d352fe51b4 Let the bus module's tools delete the stream ASSIGNMENTS at the lease write, the only stream a bus account may delete (issue 774, research 072 row 33) 2026-10-31 09:12:31 +01:00
mesh-tools 712ec9481b Merge pull request 'A node-engine answers any verb of its own node, so a new engine verb needs no grants release (issue 774)' (#147) from fix/engine-seat-any-verb into main 2026-10-31 00:30:54 +00:00
mesh-tools 50ea6f748e Merge pull request 'A node says when it composes again, says what a long pass waits on, and composes on request (issue 774)' (#146) from fix/compose-retries-transient into main 2026-10-31 00:21:31 +00:00
jschoubben 748543c35f Grant a node's engine any verb and event of its node seat on its own node, so a verb added to the contract needs no grants release first (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@712ec9481b40
mesh/delivery-group group fix/engine-seat-any-verb merged: every member is merged
mesh/check checking 748543c35f7f on 712ec9481b40 | novox/mesh-sdk#147 in group fix/engine-seat-any-verb
The grants part composed the engine's subscriptions from catalogue.EngineVerbs at its own mesh-sdk pin, so mesh-host #184's optional verb composition was refused on every node and every engine reopened its link every four minutes. Quick fix: re-pin the grants part. Thorough fix (this): the seat's subjects on the engine's node, any verb, as a module answers mesh.mod.<module>.tool.>; callers stay granted per verb.
2026-10-31 01:05:57 +01:00
jschoubben dfd5d8295e Add the node-engine verb compose to the seat's contract: compose now, the recovery of a failed composition (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@50ea6f748e30
mesh/delivery-group group fix/compose-retries-transient merged: every member is merged
mesh/check being carried to dfd5d8295e0a on 50ea6f748e30 | from dec3bfe7a8cb | novox/mesh-sdk#146 in group fix/compose-retries-transient
2026-10-31 00:53:30 +01:00
mesh-tools dec3bfe7a8 Merge pull request 'A held module keeps what it contributes, a have stuck at waiting-yes clears, and the node-engine shows a composition (issue 774)' (#145) from fix/held-keeps-contributions into main 2026-10-30 23:52:51 +00:00
jschoubben 8ed3b872e0 Leave out a held module's contribution whose grant is not issued, so a provider never applies a stand-in (issue 774, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-sdk@dec3bfe7a8cb
mesh/delivery-group group fix/held-keeps-contributions merged: every member is merged
mesh/check checking 8ed3b872e0ba on dec3bfe7a8cb | novox/mesh-sdk#145 in group fix/held-keeps-contributions
A module held for its own secret keeps its contributions (leaveOwn). When its grant to the provider was not issued
yet, the provider's grants file carried "foreseen: made by the next send" for it, and the applying path passed over
stand-ins of a module already held. Such a stand-in now leaves that provision's contribution out of the held module.
2026-10-31 00:34:44 +01:00
jschoubben 6bce1fb0d2 Add the node-engine verb composition to the seat's contract, and the resources two compositions differ in (issue 774)
A provider waiting for a yes said why only in its have's words; nobody could read the composition that waits beside
what runs. The engine's read verb composition answers that, and ChangedResources lists what a move changes, a
received file naming the consumers that join or leave.
2026-10-31 00:23:42 +01:00
jschoubben 409c56685d Keep a held module's contributions when only its own secret is missing, and say a provider's move as consumers joining or leaving (issue 774)
A module held for an own secret never made (nextcloud's admin on novox, 2026-10-30) was composed as a bare stub, so
its contributions left postgres's grants and keycloak's clients, and their helpers would have retired its login.
The culprit now tries leaving out its own secrets alone before its whole offer.

JudgeMove said a changed received-, bound- or secret- file as "where its data is kept"; it now names the consumers
that join or leave. The judgement stays a move: its yes is what kept nextcloud's login.

Quick fix: leave out own secrets alone before the whole offer. Thorough fix (not here): carry a held module's last
contributions from what runs into its providers' received files whatever part of it fails to compose.
2026-10-31 00:21:33 +01:00
mesh-tools b9ef1e4fcb Merge pull request 'One rule for whether a plan hands its module, so no part reads a provisions-only plan as handed (issue 774)' (#144) from fix/plan-hands into main 2026-10-30 23:18:58 +00:00
jschoubben 17fc9b9b81 Say whether a plan hands its module in one rule, Plan.Hands, so no reader takes a plan naming provisions alone for a handed module (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@b9ef1e4fcb68
mesh/delivery-group group fix/plan-hands merged: every member is merged
mesh/check being carried to 17fc9b9b8115 on b9ef1e4fcb68 | from 125bb9177189 | novox/mesh-sdk#144 in group fix/plan-hands
2026-10-30 23:50:09 +01:00
mesh-tools 125bb91771 Merge pull request 'One rule for which provisions a module waits for, so the assigner, the plans and the provisioner agree and grafana gets its route (issue 774)' (#143) from fix/one-rule-for-provisions into main 2026-10-30 22:33:49 +00:00
jschoubben 6c5a8ef893 Test that the composition marks a need its provider's helper answers, so the consumer's plan keys it (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@125bb9177189
mesh/delivery-group group fix/one-rule-for-provisions merged: every member is merged
mesh/check being carried to 6c5a8ef893b4 on 125bb9177189 | from b74e23f973ab | novox/mesh-sdk#143 in group fix/one-rule-for-provisions
The review found answeredByHelper untested: forced false, every test passed. This row fails then.
2026-10-30 22:29:12 +01:00
jschoubben 91aa7b78fc Key provisions by one rule the plan, the seat and the assigner share (issue 774)
The assigner's rule 8 waited for a key for every `requires`, while the plans named only the needs of handed modules:
every new build of grafana, dnsmasq, mesh-knowledge, mesh-mail and the builders waited for a key nobody would make,
and grafana, not handed, had no plan, so no route key and no route on route-proxy.

catalogue.Keyed is the rule (a need a module answers is keyed when its consumer is handed or the provider's helper
answers it), catalogue.Awaited the same rule from manifests for the assigner, and a module not handed now has a plan
for what a helper answers of it. Handed modules' plans are unchanged.

Quick fix: drop the missing-key check from rule 8. Thorough fix (chosen): one rule in mesh-sdk all three parts use,
since the kind (three parts deciding the same set apart) already drifted once and hid failures as waits.
2026-10-30 22:29:12 +01:00
mesh-tools b74e23f973 Merge pull request 'A module held for a missing secret gets no runtime of its own, so it never refuses its node's whole composition (issue 774)' (#142) from fix/held-module-and-assigner-contract into main 2026-10-30 21:17:38 +00:00
jschoubben eff81f1220 Never refuse a node for a held module's stand-in by its own runtime's rules (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@b74e23f973ab
mesh/delivery-group group fix/held-module-and-assigner-contract merged: every member is merged
mesh/check checking eff81f1220e2 on b74e23f973ab | novox/mesh-sdk#142 in group fix/held-module-and-assigner-contract
On 2026-10-30 at 21:04 mesh-self-check (ace) and mesh-conditions, mesh-healers (novox) were placed before
their bus accounts were issued. Each was held for "needs a secret called broker and none was made"; its bare
stub then lost its own secrets and was refused by ownRuntimes ("runs as its own account and declares no own
secret broker"), a refusal no culprit takes, so 74 modules on ace and 79 on novox failed. A held runtime's stub
met the same kind earlier today ("its build produced 0 bundle(s)").

Quick fix: let the bare stub keep its broker own secret, for this one rule.
Thorough fix (chosen, the fourth of the kind today): a held module's stand-in (Stubbed) has no runtime
composed at all, so no rule about its runtime, bundle or broker can refuse it: ownRuntimes passes it over and
runtimeProcess composes none for a held runtime (what runs of either is carried, as before). A module of its
own account left without its runtime by a held runtime is refused with a typed NoRuntimeToRunError, which makes
the held runtime the culprit, so those modules are held after it and the node composes the rest.
2026-10-30 21:18:06 +01:00
mesh-tools 6a71d29b33 Merge pull request 'A module's bus account not issued yet is foreseen in a node's answer, so a new module can be assigned; a refusal leads with each held module's first reason (issue 774)' (#141) from fix/kept-read-and-foreseen-account into main 2026-10-30 19:49:12 +00:00
jschoubben 4e9621edd6 Lead a refusal of a held module's stand-in with why the module was held, so its first reason is never hidden (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@6a71d29b3331
mesh/delivery-group group fix/kept-read-and-foreseen-account merged: every member is merged
mesh/check pass 4e9621edd664 on 14de8dadb4ce | unit 2719 run, 602 kept, 36 skipped | integration 2731 run, 645 kept, 29 skipped | over bound: unit, in…
The proposal on ace said only "mesh-self-check runs as its own account and declares no own secret broker": the words
of its bare stub, held for "has no bus credential", which no one saw. The second hidden first reason of the day, after
"0 bundle(s)".

Quick fix: log the held map in composeEach. Thorough fix: firstReason leads a refusal no culprit is found for with the
first reason of every held module it names ("<m> is held: <why>; its stand-in is then refused: <err>"), for Compose and
the node's Answer alike; the hold's own why already keeps its first words. Chose thorough.
2026-10-30 20:33:54 +01:00
jschoubben 0cdbdca139 Foresee a bus account not yet issued in a composition asked ahead of the Approve, so a module with its own account can be placed (issue 774)
On 2026-10-30 the placement proposal p-20261030t192428-77dabc was refused on ace and novox: "has no bus credential:
nothing was issued for novox.mesh-conditions" (and mesh-self-check, mesh-healers). The vault mints a bus account from
the grants part's wanted list once the placement is written on the operator's Approve (ADR 0561 §1: "From the lease
write, the vault mints every new bus account and the controller mints none"; ADR 0567 §5: "A placement is written on
the operator's Approve"), so a shadow composition can never hold it, and no new module with its own account could be
placed.

Quick fix: skip the check for the three modules. Thorough fix: the Foreseeing mode foresees an unissued bus account like
any own secret the next step makes; the issue-203 refusal (busCredentialIssued), the controller's send's, is gone, and
the Reading mode still leaves out what was never issued. Chose thorough: it holds for every module with its own account.
2026-10-30 20:33:05 +01:00
jschoubben 14de8dadb4 Merge pull request 'A build not done yet holds its module, never every module of a node, and a refusal names what was held (issue 774)' (#140) from fix/build-not-done-holds-one-module into main
Reviewed-on: #140
2026-10-30 18:53:04 +00:00
jschoubben 9685990a15 Compose a module at the build its want names and hold a running one whose build is not done, so the runtime never loses its bundle (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@14de8dadb4ce
mesh/delivery-group group fix/build-not-done-holds-one-module merged: every member is merged
mesh/check checking 9685990a15ed on 14de8dadb4ce | novox/mesh-sdk#140 in group fix/build-not-done-holds-one-module
At 19:30 on 2026-10-30 node-tools' trunk fingerprint moved to a build not yet done; fromBuilds then composed it from
the indexer's manifest, which names no bundle, and ace, g14 and shanks refused every module ("its build produced 0
bundle(s)") until another input moved after the build landed.

Quick fix: skip the runtime's check while its build is not done. Thorough fix: Compose reads each placed module at the
build its want names (as Scope.KeptAt), and a module that runs whose build is not done is held, keeping what runs (a
held runtime is composed as what runs of it, its running bundle), named with why; a module nothing runs of still
composes from the indexer's manifest as before. Input, PlansHeld and Answer read the trunk's builds as before. Chose
thorough: no running module is ever composed from a source manifest that names no build.
2026-10-30 19:45:32 +01:00
jschoubben d539d7e4eb Take the runtime as a culprit only where its stub composes, so node-tools is never held for an error that names it (issue 774)
The culprit rule held a module whose stub made the composition fail otherwise. The runtime's stub carries no bundle,
so it always fails otherwise: any refusal naming node-tools (or a module whose name holds it) held the runtime, and
the node was then refused whole for the runtime's "0 bundle(s)".

Quick fix: never take node-tools as a culprit. Thorough fix: blames, the rule as one function, takes the runtime only
where its stub composes. Chose thorough: a runtime whose stub composes may still be held alone.
2026-10-30 19:44:08 +01:00
jschoubben c1bc21c35f Say what a node held on the way when it is refused whole, so a held module's own refusal is never hidden (issue 774)
At 19:30 on 2026-10-30 ace, g14 and shanks refused every module with "node-tools … its build produced 0 bundle(s)":
the refusal of node-tools' stand-in, while why node-tools could not be composed was dropped with the hold.

Quick fix: log held in mesh-host before marking. Thorough fix: Compose's node-wide error carries "(held before:
<module>: <why>; …)", so the engine's journal, every have's detail and any caller see the cause. Chose thorough.
2026-10-30 19:43:46 +01:00
jschoubben 16932c93d6 Merge pull request 'A manifest the mesh refuses holds only its own module, never every node's composition (issue 774)' (#139) from fix/one-bad-item-holds-one-module into main
Reviewed-on: #139
2026-10-30 18:30:53 +00:00
jschoubben 754edfae69 Hold only the module whose manifest the mesh refuses, so one bad item no longer fails every node's composition (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@16932c93d672
mesh/delivery-group group fix/one-bad-item-holds-one-module merged: every member is merged
mesh/check checking 754edfae6924 on 16932c93d672 | novox/mesh-sdk#139 in group fix/one-bad-item-holds-one-module
On 2026-10-30 at 19:13 power's bar item (mesh-catalog ca8d6f33) was refused by ParseManifest, and frombus's input
returned that refusal for the whole read: every node-engine marked all its modules failed and no update could land.
The third of a kind that day, after the bus user list and the provisioner's plans (PlansHeld).

Quick fix: skip a manifest that does not parse in input's catalogue loop. Thorough fix: input follows the rule a build
that cannot be read already follows there (refuse + stub), so Compose, Answer and PlansHeld all hold the one module:
held with why on a node it is placed on, read for its offer alone (its unjudged stub, without items) elsewhere. A
manifest the bus does not answer for still ends the read (transient, waited out by the caller), and Input (refuse
unset, the check's whole composition) still refuses. Chose thorough: one place serves all three callers.
2026-10-30 19:24:15 +01:00
jschoubben 4c1384faef Merge pull request 'One bad module no longer drops its machine, too-large values and bus blackouts fail loud or are waited out, watches are never forgotten (issue 774)' (#138) from fix/sweep-2026-10-30 into main
Reviewed-on: #138
2026-10-30 18:12:47 +00:00
jschoubben a37181261c Never ask a call again that its callee answered, whatever its words (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@4c1384faefa0
mesh/delivery-group group fix/sweep-2026-10-30 merged: every member is merged
mesh/check checking a37181261c24 on 4c1384faefa0 | novox/mesh-sdk#138 in group fix/sweep-2026-10-30
The review found that busretry could ask a delivered call again: Conn.request turned a callee's error answer into
errors.New(words), and Undelivered matched the words "no responders" and "jetstream not enabled", so a callee that
answered so during a blackout ran its work twice. A callee's answer is now *busretry.Answered, which Undelivered
never counts: only the transport's own no responders makes a call undelivered. The other busretry sites read or
write through JetStream's API (State, Ask) or the runtime's state verbs, where the words are the transport's.
2026-10-30 18:41:37 +01:00
jschoubben 206f794eb3 Grant a bar's holder the state a contributor's item shows, at its own machine's key (issue 774)
i3status-rust on g14 and shanks was issued nothing to read ("was issued: none", 148 times): ReadsGranted derived a
holder's reads from contributions' data alone, and design 57 L3 moved power's draw block to an item
(mesh-catalog ca8d6f33: shows: state, state: power.draw). ReadsGranted now reads a module's items of a kind the
holder's seat receives too, their attributes in a contribution's data shape, always at the machine's own key
(ADR 0260), never another's. Both callers, the controller's busrecords and mesh-grants' records.go, get it
through ReadsGranted with no change of their own.
2026-10-30 18:35:54 +01:00
jschoubben ef1ec1c935 Name mesh-config among the controller's lease readers (issue 774)
mesh-config reads mesh-controller.lease to take the config over only once the lease is handed away (ADR 0561 §3,
its takeover.go leaseAway), and was refused it as a reader the composer does not name.
2026-10-30 18:26:23 +01:00
jschoubben 942f906222 Judge only the verbs of a mesh seat a module invokes, not its holder (issue 774)
The seat-invoke rule refused mesh-config, mesh-healers and messenger calling the controller's verbs, and
mesh-self-check calling the watchman's seat, over a catalogue that holds no manifest of their holders: the
controller answers its seat itself, and the watchman lives in mesh-delivery. mesh-control's
TestModuleCheckPassesTheCatalogue (the captured mesh-catalog) failed on it. A mesh seat is held by the mesh or by a
module of any repository, so one repository's catalogue cannot say it has no holder: for a mesh seat only the verb
is judged; a seat a module declares is still judged for a claimant. Over the captured catalogue, the live
mesh-catalog alone, and mesh-catalog, mesh-control, mesh-delivery and mesh-host together: no refusal.
2026-10-30 18:25:59 +01:00
jschoubben 61b67d61e2 Export the accounts block's user entry, so a kept user is written in ComposeAccounts' own lines (issue 774)
mesh-grants' refused.go keptEntry wrote a user of the accounts block from grants it keeps by copying
ComposeAccounts' lines; a change to one would leave the other writing a list the bus reads differently.
buscompose.AccountsEntry(user, passwordHash, perms) is now the one writer, and ComposeAccounts uses it. No change
to what is written.
2026-10-30 18:19:20 +01:00
jschoubben 44dbb22ea7 Wait out a bus that answers nothing for a few seconds instead of losing the write (issue 774)
At each mesh-grants restart and user-list reload (16:37:24, 16:55:15) nats-server 2.11's reloadAuthorization left
the mesh's account without its imports, $JS.API.> among them, for a few seconds: requests had no responders,
JetStream calls said it was not enabled, and a build-test result on ace was lost.

Quick fix, chosen as asked; the thorough fix is a reload that keeps the account's imports, in the bus's holder.
The package busretry asks a request again, backing off within about 30 s, only while its error says it reached no
one (no responders, JetStream not enabled, no response from the stream, or either in words): nothing was done, so
even a create or an update is safe to ask again. A timeout or any answer is never asked again. After the bound it
is said on standard error and returned. Used by process.State's requests, process.Conn's calls (asked again only
while $JS.API.INFO says the bus itself answers nothing, so a callee that is not running still fails at once) and
asks, and the bundle's state requests through the runtime.
2026-10-30 18:18:32 +01:00
jschoubben 1dc5d8cb3d Run a watch's handler beside the runtime, and give a refused watch up loudly (issue 774)
The provisioner's watches of gitea.bound, mesh-vault.bound and postgres.bound failed every minute for an hour on
the control-node ("the current values ... did not arrive in 10s") and 1257 changes were refused as "names no
watch of this bundle". Both sides were at fault. The SDK answered a change only once its handler returned, so a
slow handler (Rejudge waits on the lock its composition holds) counted against the runtime's 10 s bound on the
current values; and when the runtime gave up, the SDK forgot the id at once. The runtime (mesh-tools node-tools
internal/bus/state.go StateWatch) stops its watcher on that bound but still drains and delivers the values already
buffered, to an id nobody holds.

Quick fix: a longer runtime bound. Thorough fix here: each state and provision watch has a follower that takes a
change in order and answers at once, its handler on its own goroutine; Watch still returns only once the current
values were handled, and a handler's error is said on standard error. A watch the runtime refuses is said given up
and kept by id, so a change still handed to it is refused saying so. KeptState.Follow asks again with backoff until
taken, the loop four modules wrote for themselves. A log watch keeps its answer after the handler: that is its
back-pressure.
2026-10-30 18:15:51 +01:00
jschoubben 7115b7bf64 Refuse a catalogue where a module invokes a seat verb nothing answers (issue 774)
messenger invoked seat:node-engine.root-free while nothing answered it, and Telegram held every ask that offers an
approval, silently. Nothing judged a seat:<seat>.<verb> invoke beyond its spelling: not that the seat exists, that
it promises the verb, nor that anything holds it.

Quick fix: messenger asks the controller's root-free (mesh-catalog bc271896) and the engine announces its seat as
discovery reads it (mesh-host 7ecf7ba). Thorough fix, chosen because an invoke nothing answers fails only at the
call, silently for whoever waits on it: CatalogueProblems refuses every seat verb a module or one of its processes
invokes whose seat no module declares and the mesh does not define, whose seat does not promise the verb, or whose
seat no module claims (a seat every node's engine holds, ADR 0563 §1, is held). Over some manifests alone a seat
whose holder was not given is a note (IsUndeclaredSeat). Over every manifest of mesh-catalog, mesh-control and
mesh-delivery today it refuses nothing.
2026-10-30 18:11:19 +01:00
jschoubben e703e9b0e2 Judge every read for its reader where the bus's users are composed (issue 774)
Since 13:25 the vault, the assigner, the watchman, the grants part and the artifact store's tools were refused
mesh-controller.lease, and the vault mesh-grants.accounts, though the composer names them those states' readers
(ADR 0547 clause 9): the controller splits reads with its own copy of the reader-less ReadsAnnounced, which knows
no named readers, and Users only moved reads from granted to refused, never back.

Quick fix: the controller calls ReadsAnnouncedBy with the module. Thorough fix, chosen because the kind was met
on four owners and the judgement had two copies: Users judges every read again for its reader against the
announcements in Records (withAnnouncedReads), whatever split the caller made, and the reader-less
ReadsAnnounced is removed so no caller can judge without the reader. A rule over manifests was not added:
ADR 0495 clauses 1 and 5 keep readability out of the manifest and replaced the merge gate's reads check by each
module's announcement test. gitea.bound and disk-load.readings stay refused: their owners announce them not
readable, which their own code fixes.
2026-10-30 18:08:02 +01:00
jschoubben 7b02f63daf Refuse a key-value write too large for its bucket before it is put (issue 774)
mesh-grants' sealed grants and the controller's merge record each met "message size exceeds maximum" (JetStream
10054) only after the put, and a writer that retries looped on a value that can never land; an update over the
limit was even answered as a revision mismatch, which every compare-and-set writer retries.

Quick fix: cap the two values in their modules. Thorough fix, chosen because the kind was met twice and every
module writes through the SDK: the package kvlimit checks a value against its bucket's largest message (read from
its stream and kept) before the put, naming the bucket, the key, the size and the limit, and turns the bus's own
refusal into the same *TooLarge (errors.Is kvlimit.ErrTooLarge), so no writer retries it. The SDK's own writers
use it: process.State (Put, Create, CreateTTL, Update), process.AwaitedBucket, the runtime state of a bundle
(stdio KeptState, against the module state's 256 KiB) and mesh-hand-compose. A value that can outgrow its bucket
is split over keys or kept in the object store with its name in the key.
2026-10-30 18:02:18 +01:00
jschoubben 59311407e2 One bad module must not drop its node: plans are composed module by module, a foreseen own secret fills a file, a built bus manifest keeps its bundle (issue 774)
PlansHeld holds a module whose build cannot be taken in or whose plan refuses, by composeEach's rule, and composes
every other module's plan; sealedFor gives a file the stand-in its own secret's file has (baserow on ace); shipsABundle
reads a built manifest's Bundles (nats on novox).
2026-10-30 17:50:56 +01:00
jschoubben 1d9de5dbb6 Merge pull request 'Grant the provisioner client every machine's plans, as it watches them (issue 774)' (#137) from fix/provisioner-reads-every-plan into main
Reviewed-on: #137
2026-10-30 15:53:30 +00:00
jschoubben af66ccb833 Grant the provisioner client every machine's plans, as it watches them, so g14 and shanks read their plans again (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@1d9de5dbb63b
mesh/delivery-group group fix/provisioner-reads-every-plan merged: every member is merged
mesh/check being carried to af66ccb8335a on 1d9de5dbb63b | from 42988a734b3d | novox/mesh-sdk#137 in group fix/provisioner-reads-every-plan
The client V3 watches the seat's plans whole to bind a provider's consumers (ADR 0373); the grant gave each machine
only its own keys, so its watch was refused and no files were placed. Quick fix, chosen in a recovery: plans join
the buckets a client reads whole (references, never secret values). Thorough fix, queued: the client fills consumers
from the seat's provision keys (ADR 0570 clause 3) and plans go back to its own machine's keys.
2026-10-30 16:40:46 +01:00
jschoubben 42988a734b Merge pull request 'The tool runner never carries the node-engine's state, so the bus user list composes again (issue 774)' (#136) from fix/runner-carries-no-engine-state into main
Reviewed-on: #136
2026-10-30 15:35:51 +00:00
jschoubben 6e78203959 Compose no placed user from the node-engine's manifest, so the tool runner never becomes a second writer of the engine's state (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@42988a734b3d
mesh/delivery-group group fix/runner-carries-no-engine-state merged: every member is merged
mesh/check checking 6e7820395952 on 42988a734b3d | novox/mesh-sdk#136 in group fix/runner-carries-no-engine-state
Second meeting of the engine-state-in-runner kind (mesh-host_acts this
morning, mesh-host_health now); thorough: the runner never carries the
engine module. One predicate, Composed, keeps the engine out of the
placed loop (no user, no process, nothing carried); CarriedByRunner is
the one test of what the runner carries. The engine's grants stay the
node account's, its state created through Records.Engine.
2026-10-30 16:30:27 +01:00
jschoubben d0625eea3d Merge pull request 'One list of credential forms, so no part keeps its own copy (issue 774)' (#135) from fix/credential-forms-one-list into main
Reviewed-on: #135
2026-10-30 15:10:19 +00:00
jschoubben 9379e9a96a Keep one list of credential forms in the catalogue, so no reader of a plan refuses a form another writes (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@d0625eea3d11
mesh/delivery-group group fix/credential-forms-one-list merged: every member is merged
mesh/check checking 9379e9a96ad4 on d0625eea3d11 | novox/mesh-sdk#135 in group fix/credential-forms-one-list
Second meeting of the credential-form list kind: the provisioner client's
own copy lacked "none" and refused every plan naming it, as the
controller's had before. Thorough: one list in mesh-sdk,
catalogue.CredentialForms and IsCredentialForm; provisions' forms are now
the catalogue's constants.
2026-10-30 16:03:14 +01:00
jschoubben 0c99e847a2 Merge pull request 'One codec for the grants part's memberships state, so its writer and readers cannot drift (issue 774)' (#134) from fix/memberships-shape into main
Reviewed-on: #134
2026-10-30 14:42:04 +00:00
jschoubben 83cf97b690 Refuse an old wrapper of a held-back membership, so a reader falls back to the assignments stream
mesh/delivery merged: source.main moved to novox/mesh-sdk@0c99e847a2ae
mesh/delivery-group group fix/memberships-shape merged: every member is merged
mesh/check checking 83cf97b690f5 on 0c99e847a2ae | novox/mesh-sdk#134 in group fix/memberships-shape
The grants part's old build wrote its wrapper for every membership, a held-back one too, with the unpublished
composition inside; the new part never rewrites such a key, so the decoder took the held-back membership as given.
It now reads a wrapper only when its published says published or unchanged. The codec's rows join the round-trip
test (mesh-sdk is over its unit budget). Review round 1 of issue 774's fix.
2026-10-30 15:36:23 +01:00
jschoubben 46d4b4da5e One codec for the grants part's memberships, so its writer and the tool runner cannot drift
The grants part wrote its own wrapper into mesh-grants_memberships while the tool runner read the value as a bare
membership: novox's tool runner served no tool. EncodeGrantsMembership writes the bare membership;
DecodeGrantsMembership and BareGrantsMembership read it, still read the old wrapper for the transition, and refuse a
value naming no node or module instead of answering an empty membership.

Issue 774 (the bus login list, L3). Quick fix: write the bare membership in the grants part alone. Thorough, chosen:
one codec in mesh-sdk both sides use, because a hand-built test fixture agreed with the mistake and hid it.
2026-10-30 15:23:17 +01:00
mesh-tools 2b1e148bf5 Merge pull request 'Design 47's last milestones as one release: the contracts for health, control records, provisioning and history leaving the controller (issues 774, 1126, 1952, 1953, 1954, 1955, 1289)' (#133) from feat/774-l3-reland into main 2026-10-30 11:01:40 +00:00
jschoubben 2da0de75c3 Merge main's parse-only new words into the release, which supersedes them with its full implementation (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@2b1e148bf58e
mesh/delivery-group group feat/774-l3-reland merged: every member is merged
mesh/check being carried to 2da0de75c3f0 on 044ead7db83e | from mesh-catalog 12c2574c9d54 | novox/mesh-sdk#133 in group feat/774-l3-reland
2026-10-30 01:11:16 +01:00
mesh-tools 044ead7db8 Merge pull request 'Read the release's new manifest words and seat names, with no change of meaning, so the running parts accept design 47's manifests (issue 774)' (#132) from fix/774-new-words-first into main 2026-10-30 00:10:04 +00:00
jschoubben 78c9ec8817 Accept the release's new manifest words and know its new seats by name, meaning nothing new yet, so delivery 1's readers parse the release's catalogue before any part reads them (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-sdk@044ead7db83e
mesh/delivery-group group fix/774-new-words-first merged: every member is merged
mesh/check pass 78c9ec881771 on 307b6234e6bb | unit 9174 run, 546 kept, 51 skipped | integration 9952 run, 659 kept, 60 skipped | over bound: unit, in…
2026-10-30 00:35:35 +01:00
jschoubben 6e4a3014cb Keep the provisioner client's needs grant for one release, removed after the lease write, so the client before V3 still writes its needs while its delivery reaches each node (issue 1954) 2026-10-30 00:14:53 +01:00
jschoubben 007b6fd45f Drop stdio.Discover, the self-check's discovery and its read of the withheld hearings, which no probe uses now that the operator dropped D-root, D3 and D-heard (issue 1952) 2026-10-30 00:02:07 +01:00
jschoubben 8fe3467753 Let a provider not ready yet wait and keep serving what it served, so a restarted gitea no longer stops serving its consumers until its keys load (issue 1954) 2026-10-30 00:02:07 +01:00
jschoubben fa5a6aeff5 Let a bundle ask the runtime who answers a verb, and the self-check's own account ask the bus's discovery, so D-root and D3 find holders without the controller (issue 1955) 2026-10-29 23:58:21 +01:00
jschoubben 31e90aaa0f Keep when each data item was first seen and when and why it was retired in its reading, so the self-check's D13 judges a new or retired item from disk-load's state (issue 1955, ADR 0571 clause 3) 2026-10-29 23:56:06 +01:00
jschoubben 7bd0f6c2bd Name the artifact sweep's seat mesh-artifact-sweep for its scope, grant the provisioner client its machine's plans in place of the needs, and keep the grants part's withheld hearings in one layout, so the seat names follow ADR 0121 and the client and the self-check read the bus (issue 1955, ADR 0571 clause 2, ADR 0570 clause 2) 2026-10-29 23:53:51 +01:00
jschoubben f3677eb6f3 Carry the engine's verdict on each account declared never to become root, and its contract, in the node's health report, so the self-check's DA and D-root judge on the machine's own word (issue 1952, ADR 0266)
NodeHealth.Root: each such account's verdict (healthy, unhealthy with its way to root, unknown while the setuid search
runs), the module and resource declaring it. NodeHealth.Contract: the engine's statement contract, so a reader tells an
engine older than the judging. Test: a row in TestTheControlRecordsReadTheirWritersValues, read back byte-equal.
2026-10-29 23:48:08 +01:00
jschoubben 992ea0ab66 Let restic's bundle, not its night's process, write each item's last good night, since the night runs as the operator's account with no bus account (issue 1955, ADR 0436 §1-2) 2026-10-29 23:41:16 +01:00
jschoubben 85f5fd0b22 Fail the grants table test for any row whose user is not composed, so a row can never pass for a missing user (issue 1955) 2026-10-29 23:38:59 +01:00
jschoubben 9f0bb1cfa4 Move restic's last good night per item into the sdk's health layouts, so restic's night process writes it and the self-check reads one layout (issue 1955, ADR 0571 clause 3) 2026-10-29 23:37:37 +01:00
jschoubben 08fc70783a Read the bus through a module's declared states in frombus, with the runtime's adapter in fromruntime, so mesh-provisioner and disk-load compose from one reader instead of two copies (issue 1955) 2026-10-29 23:31:34 +01:00
jschoubben bb993dd606 Keep the builder's history as its own log that readers declare and follow, the memberships as the grants part's state, and refuse a module's own stream, so every module's data is in the bus's predefined forms and readable by its tools (issue 1955, ADR 0571 clauses 1, 4, 5, 7) 2026-10-29 23:28:21 +01:00
jschoubben 196c5b6af2 Compose a node's placed data items from the bus and move the readings layout into the sdk, so disk-load measures without a composed file and restic, mounts, nfs-server and the self-check read one layout (issue 1955, ADR 0571 clause 3) 2026-10-29 23:16:56 +01:00
jschoubben dc78524e38 Let the artifact store's seat holder read the controller's lease, so its sweep starts only once the lease names no controller (issue 1955, ADR 0571 clause 2) 2026-10-29 23:10:46 +01:00
jschoubben f9a496d3e1 Carry each build's repository, directory and commit in the builder's log, so the indexer, the outside-built registrations and the assigner read where a build came from there (issue 1955) 2026-10-29 23:09:47 +01:00
jschoubben 7fdbadc655 Add the seat artifact-store, the seat-holders state with its reader, and the builder's log layout, so the store's sweep, who holds which seat and build history leave the controller (issue 1955, ADR 0571 clauses 1, 2, 4) 2026-10-29 23:09:25 +01:00
jschoubben 6eae0765f4 Name the machines a failing network part points at, so the watchman holds it there as the controller does without any machine's address (issue 774)
NetworkPart.Targets: the hub's machine for "hub", the machine whose tunnel address is each resolver's; empty where the
part is the machine's own. Filled by the node-engine from its tunnel's peers. Test: a row in
TestTheControlRecordsReadTheirWritersValues, read back byte-equal.
2026-10-29 23:02:54 +01:00
jschoubben 94d0de5c19 Move the node's health report layout into the sdk and let each node write only its own key while the watchman reads them, so the six machine alerts leave the controller (issue 774) 2026-10-29 22:58:05 +01:00
jschoubben 58a83249c8 Give a provision the vault itself provides no credential beside it, since its offer is the value (issue 1954, ADR 0570 clause 6) 2026-10-29 22:53:15 +01:00
jschoubben 6b8c694381 Make every offer say its credential's form, pair or none where it names no own secret or issuer, and read no default, so the vault never mints a login for a provider that takes none (issue 1954, ADR 0570) 2026-10-29 22:47:32 +01:00
jschoubben dedd988fc9 Export a bus in memory and a mesh whose plan hands a module with a provision, so mesh-provisioner's whole test composes a real plan (issue 1954 H5) 2026-10-29 22:44:49 +01:00
jschoubben f701db0516 Key a provision's credential as the vault's own provision secret-<provision> and say what a provision depends on, so a provider waits for its credential and its own requirements (issue 1954, ADR 0570 clauses 3, 6) 2026-10-29 22:43:56 +01:00
jschoubben 161fba56ae Hold the provisioner's records to one writer in the credential table: only mesh-provisioner writes provisions and plans, readers only read, and it writes none of the grants part's states (issue 1954 L6-P5, ADR 0570 clauses 1, 8) 2026-10-29 22:42:21 +01:00
jschoubben 692dbf9a17 Bind every provider by mesh-provisioner's plans and provision keys instead of the controller's assignments, act once a provision's dependencies are provided, say the outcome in its entry, and retire a consumer only on the operator's yes (issue 1954 L6-P3, P4, ADR 0570 clauses 2, 3, 5) 2026-10-29 22:41:50 +01:00
jschoubben 66ff33dbd3 Name a provision's credential as the vault's own names it, read each node's provisions by its engine, and give mesh-config's held values their layout in the sdk, so the vault, the engine and the composition checks read one form (issue 1954, issue 1953 D7) 2026-10-29 22:39:29 +01:00
jschoubben 6e39ed1852 Refuse a want's rule past the eighth in the refusals table, now that the eighth rule exists (issue 1954 D1) 2026-10-29 22:37:41 +01:00
jschoubben 56514aa4a2 Add the assigner's eighth rule, every provision the build requires provided, so a want waits on its provisions as ADR 0570 §4 decides (issue 1954 D1) 2026-10-29 22:37:11 +01:00
jschoubben 4a967933b3 Lay out a provision as a key of mesh-provisioner holding a reference and never a value, and a plan as a key of its own, so the holder, the vault, the clients and the providers read one record (issue 1954 L6-P2, ADR 0570 clauses 1, 2) 2026-10-29 22:32:15 +01:00
jschoubben 233b6be5dd Compose each node's provisioning plans from the bus alone, in a Planning mode that reads no secret, so mesh-provisioner composes the plans the controller did (issue 1954 L6-P1, ADR 0570 clause 2) 2026-10-29 22:31:02 +01:00
jschoubben 4204afd240 Name mesh-config's compare, take-over and hand-back on its seat, so callers reach the config switch ADR 0565 decides by the seat (issue 1953) 2026-10-29 22:28:45 +01:00
jschoubben 2d2aa7ba9b Carry the catalogue's predefined list on the bridge's merged message, so the indexer keeps it without the controller's mirror (issue 1953 L5-D1, ADR 0565 §2) 2026-10-29 22:21:12 +01:00
jschoubben 2e5cc4f166 Let a module's log keep its entries for an age, so the hand-act log holds each act 400 days as ADR 0565 decides instead of refusing when full (issue 1953 S-age) 2026-10-29 22:17:50 +01:00
jschoubben bc6c8a6d24 Let a bundle write a key with its own time to live and hear its expiry, so pulses, settles and bounds expire on the bus through the runtime (issue 1952) 2026-10-29 22:14:42 +01:00
jschoubben 20cac82ab7 Give the control records their layouts, a person's policy and its one derivation, and the engine's own packages key, so the assigner, the indexer and the nodes keep them without the controller (issue 1953 L5-S2, S3, S4) 2026-10-29 22:14:01 +01:00
jschoubben fbd00c8943 Refuse a manifest that lists its claimed seat's event among its own emits, so a holder never says a seat event where no consumer hears it (issue 1952, second of a kind) 2026-10-29 22:10:44 +01:00
jschoubben 7df90445ad Add the seats mesh-wants and mesh-hand-acts, the engine's packages verb, the act layout and a recorder that falls back to the controller's log while the seat has no holder, so the control records have homes the lease does not hold (issue 1953 L5-S1, S2, S4) 2026-10-29 22:09:16 +01:00
jschoubben 32f5ce3712 Refuse a tools account secret under a parent the runtime cannot pass, so the rule covers the module's 0700 mesh place too (issue 1952 S5, ADR 0564 Limits 4) 2026-10-29 22:00:43 +01:00
jschoubben 8e3d7a4c3c Give a tools bundle's bus-account secret to the runtime's account and refuse it where that account cannot reach, so the bus tools can read their credential (issue 1952 S5, ADR 0564 Limits 4) 2026-10-29 21:59:11 +01:00
jschoubben b6ef01d6c6 Let the watchman hear the bus module's slow consumers and closed connections and read the provisioner reports, and never the bus's API audit, so S9 and S21 have their facts without the vault's hashes (issue 1952 S5) 2026-10-29 21:57:50 +01:00
jschoubben 7000bd5049 Let a tools bundle declare its own bus account and what it may remove, so the bus tools remove the controller's dead-letter consumer and the node-engine's acts on a declared account with no idle service (issue 1952 S5, ADR 0564 Limits 4) 2026-10-29 21:52:52 +01:00
jschoubben 69fcb6c1c1 Fit the watchman's grant to what L4 does: hear its watchdogs' words and read a source's age and message, never deliver again or drop, so it holds no more than its job (issue 1952 S5) 2026-10-29 21:44:53 +01:00
jschoubben 2a04bf0ac5 Let the watchman read and delete a given-up message only on the streams dead letters come from, each by name and never a bucket's, so keeping dead letters cannot read every bucket (issue 1952 S5) 2026-10-29 21:43:15 +01:00
jschoubben 0b12b62a01 A failure among the change's own tests ends the check ask's level there, so the rest of the suite is not paid for (issue 1289)
Quick fix chosen (in askrun.go, the one place every build module's check ask runs): own-test packages already ran first, but the rest ran after their failure (issue 1921). 1921 still holds past them.
2026-10-29 21:41:19 +01:00
jschoubben 5a47192b57 Grant the health holders what ADR 0564 gives them and nothing more: named reads of the lease and the controller's conditions and hand acts, the watchman's own dead-letter consumer, and the bus tools' removal account (issue 1952 S5) 2026-10-29 21:40:49 +01:00
jschoubben 83d928fe50 Refuse mesh-watcher and mesh-self-check on one node, so the watcher of the self-check never falls silent with it (issue 1952 S8, ADR 0564 clause 6, Limits 5) 2026-10-29 21:37:59 +01:00
jschoubben 544720b00a Read whether a module is being delivered on a node in one place, so the view and the condition keeper judge a delivery alike (issue 1952 S7, ADR 0564 clause 13) 2026-10-29 21:37:59 +01:00
jschoubben 575a05e49e Let a state create a key with its own time to live, so a pulse, a settle or a hold bound expires on the bus with no timer (issue 1952 S6, ADR 0564 clauses 4, 7, 13) 2026-10-29 21:36:11 +01:00
jschoubben 4fff214eeb Give the condition keeper the controller's condition bodies, the findings layout and the take-over key, so readers change only the seat they hear (issue 1952 S2-S4, ADR 0564 clauses 2, 3, 7-9) 2026-10-29 21:35:16 +01:00
jschoubben 187fb63439 Add the four health seats, mesh-conditions, mesh-watchdogs, mesh-self-check and mesh-healers, so the controller's health has homes its lease does not hold (issue 1952 S1, ADR 0564 clause 1) 2026-10-29 21:35:16 +01:00
jschoubben 22c9ddd32b A runtime that already dropped a carried module's tools serves them again, read from what runs and its build (issue 1126, review)
rev-held2: ToolsRunning took a carried module's bundles from the runtime's MESH_TOOL_MODULES alone, and shanks had
already applied the drop of systemd-resolved, so its runs stayed empty and the drop permanent. Now every bundle
archive that runs at its module's BundlePath is a running bundle; one the runtime loads nothing of takes its loads and
words from the module's build (its built bundles, else its build's artifacts, by catalogue.BundleLoads, the rule a
build's resolution keeps, now one function). The runtime process and each archive are matched by their owning module
too, not only by id and path. A row added: the runtime already dropped it.
2026-10-29 21:03:16 +01:00
jschoubben 94cee5d199 A data flag that differs alone moves no data: the judgement reads the data items first, and says the flag loudly (issue 1126)
novox's mongodb and mssql asked a yes for moving their data on a toolchain move: the running side, read back from the
kept declaration, carries no data flag, and judge.go returned moves-data on the flag before it compared the data
directories and files, so a flag mismatch both raised a false ask and hid a real move. Now moves-data only when a
data item (a directory or file within the data places, or a process of a module with data) changes; a flag-only
difference is neither, with a WARNING in its words. One row changed and one added in TestJudgeMove.
2026-10-29 21:03:16 +01:00
jschoubben 52056010fe A module composed as what runs of it serves its tools as they run, so the runtime no longer drops it (issue 1126)
After the take-over of placement on 2026-10-29, shanks's runtime stopped serving the folded systemd-resolved (the
node-resolver seat's verbs answer "nothing serves") and novox's the folded nats. Neither has a want: each runs inside
its node's own composition at a build of the old form (Scope.InOwn), and is composed as Carried of the indexer's
manifest. That manifest is the catalogue's, which no build resolved, so it has no bundles (catalogue/build.go: Bundles
are set only by a build's resolution), and the runtime's MESH_TOOL_MODULES and MESH_TOOL_ENV are made from bundles
(catalogue/runtime.go). The controller composed them from the resolved manifest its store kept.

The kind: a module composed as what runs of it (Stub, Carried: held, or folded at an old build) contributes to the
others from its manifest, not from what runs. For a held module with a new build it is the other way round: its new
bundles are served, which were never delivered.

- The quick fix: read the old-form build's resolved manifest for a folded module. One instance; a held module still
  hands its new bundles.
- The thorough fix, chosen: compose.ToolsRunning reads what the node's runtime serves as it runs (its process's
  MESH_TOOL_MODULES and MESH_TOOL_ENV, each bundle's archive), frombus gives it as Node.RunsTools, and every stubbed
  module of the machine's runtime (held or carried) contributes those bundles, whatever its manifest says.

Tests: two rows (folded, held) added to TestCompositionsAreTheDeclarationSplitByModule; no new test function.
2026-10-29 21:03:16 +01:00
mesh-tools 307b6234e6 Merge pull request 'A held module offers what it runs, and its hold no longer spreads to the modules that read from it (issue 1960)' (#131) from fix/1960-no-hold-spread into main 2026-10-29 15:35:24 +00:00
jschoubben dbcaa29a1a Never hold a module because a module it reads from is held: it composes with what the held one last offered (issue 1960)
mesh/delivery merged: source.main moved to novox/mesh-sdk@307b6234e6bb
mesh/delivery-group group fix/1960-no-hold-spread merged: every member is merged
mesh/check pass dbcaa29a1a38 on 394fec8a63c1 | unit 3318 run, 1951 kept, 38 skipped | integration 3994 run, 2125 kept, 38 skipped | over bound: unit, …
On novox on 2026-10-29 de-spiegel, held for an unset config value, held mailu, route-proxy and nftables ("it reads
what de-spiegel contributes or listens on"), and mailu held postgres. mesh-sdk #128 (issue 1126) stopped the hold
spreading to consumers and collectors but kept dependents(): the provider of what a held module contributes to, and
the filter where it listens, were held behind it, transitively.

- Quick fix: drop dependents() alone. The filter and the proxy would then read a held module's new manifest, which
  may not be what runs (review of the apply, round 2).
- Thorough fix, chosen: no module is held behind another, in the apply and in the answer (dependents, heldBehind and
  the held-behind path of heldAsToday removed). A held module offers what it last offered: its contributions and
  listens from its manifest at the build its own composition runs at (ranOf), else the manifest it is composed from.
  Only the part of its offer that does not compose is left out, by provision (culprit): de-spiegel's routes leave
  route-proxy without them, while mailu keeps its smtp account; the hold names it.

Tests: rows changed in the existing answer test (de-spiegel held, mailu and proxy composed; site held, the filter
composed with what runs; leave-out by provision). No new test function.
2026-10-29 15:54:21 +01:00
mesh-tools 394fec8a63 Merge pull request 'A held module composes only as what runs of it, so its hold no longer spreads to the modules that use it (issue 1126)' (#128) from fix/1126-held-as-runs into main 2026-10-29 00:07:03 +00:00
jschoubben c2999a4e13 A held module is no dependence of its neighbours: its hold stops spreading (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-sdk@394fec8a63c1
mesh/delivery-group group fix/1126-held-as-runs merged: every member is merged
mesh/check pass c2999a4e13cc on d4c61ee8ffd0 | unit 3054 run, 234 kept, 36 skipped | integration 3075 run, 266 kept, 29 skipped | over bound: unit (sl…
novox's seed p-20261028t231141-ec84b4 refused 11 modules "what it composes of ..., held, is not what runs of them":
de-spiegel's hold spread through mailu to postgres, nftables and route-proxy, and from postgres to every consumer, and
gitea, held so, refused the node: "its credential of postgres-database is handed to the provisioner and the module is
not: nothing would fill it". Three rounds each moved the error. The kind: a held module still took part in the
composition as a dependence, so its hold spread.

- The quick fix: let heldAsToday also follow dependsOnHeld's words, and skip the handed refusal for gitea. It moves the
  error once more and leaves every node's applying path holding consumers.
- The thorough fix, chosen: a held module composes only as what runs of it. composeEach no longer compares the
  composition with each held module as its stub (dependsOnHeld removed): a consumer or collector composes against what
  runs of the held module like any other module, and a real difference is still a named difference in the answer, a
  module's own refusal still its refusal. A stub (compose.Stub, catalogue.Manifest.Stubbed) leaves the credentials and
  own secrets it would hand the provisioner out of its plan as they run, never refused for want of a plan. Only what a
  held module's new manifest says to others in their own files is still held behind it (dependents: the provider of
  what it contributes to, the filter where it listens), which heldAsToday already follows.

Tests: rows added to existing tests (a held provider with a binding consumer, which the old code held in the seed's
words; a held feeder of a collector; a held module with a handed credential and own secret). No new test function.
2026-10-29 00:49:00 +01:00
mesh-tools d4c61ee8ff Merge pull request 'The seed leaves a module as it runs today when it is held, failed or at an old-form build, and the modules held only behind it too (issue 1126)' (#127) from fix/1126-seed-oldbuild into main 2026-10-28 23:05:04 +00:00
jschoubben 76f7c4d223 A stub keeps its items and their templates, so a held module still contributes to other modules' collections; it drops only the templates for its own files (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-sdk@d4c61ee8ffd0
mesh/delivery-group group fix/1126-seed-oldbuild merged: every member is merged
mesh/check pass 76f7c4d2238f on d747c1128e42 | unit 3018 run, 270 kept, 36 skipped | integration 3039 run, 302 kept, 29 skipped | over bound: unit (sl…
2026-10-28 23:56:33 +01:00
jschoubben f609be7864 Keep as today what a node keeps today and what is held only behind it, saying how; a stub hands and collects nothing of its own (issue 1126) 2026-10-28 23:49:22 +01:00
mesh-tools d747c1128e Merge pull request 'L3: the vault mints bus accounts, the grants part and the node seat node-engine (issue 774, ADR 0561), rebased onto L2' (#126) from feat/774-vault-mints-sdk into main 2026-10-28 22:27:36 +00:00
mesh-tools f9afe32888 Merge pull request 'A node's answer holds as today what it holds today, and the seed carries its nodes' differences, named (issue 1126)' (#125) from fix/1126-seed-sdk into main 2026-10-28 21:59:48 +00:00
jschoubben 77b48a4f1a Name in the seed's brief the first differing field's value as it runs and as composed, so the operator sees what changes (issue 1126)
mesh/delivery-group group fix/1126-seed-held rejected: composed together: the checker's verdict at the members' heads (63905bd4b284): the composition
mesh/delivery merged: source.main moved to novox/mesh-sdk@f9afe328881f
mesh/check being carried to 77b48a4f1aff on f9afe328881f | from 53f26493697e
2026-10-28 22:26:05 +01:00
jschoubben e84a121b8a Let the seed carry the differences its nodes found, each named in its brief for the operator to approve knowingly; a refusal or silence still fails (issue 1126) 2026-10-28 22:24:57 +01:00
jschoubben 06b13ec1cc Show in a proposal's brief what stays held as each node holds it today (issue 1126)
mesh/delivery-group group fix/1126-seed-held rejected: composed together: the checker's verdict at the members' heads (2b8e3078c117): the composition
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: the composition: the manifest of mesh-delivery-assigner uses what mesh-controller at ba4d15657343d3116bd6657f85e53630a7dbabf2 (mesh…
2026-10-28 22:17:57 +01:00
jschoubben 0830fcb25e Hold that a refused module not held today keeps the answer refused (issue 1126) 2026-10-28 22:17:40 +01:00
jschoubben b03bf4c118 A node's answer holds as today what it holds today, so a seed that reproduces today is the same (issue 1126) 2026-10-28 22:13:37 +01:00
mesh-tools 53f2649369 Merge pull request 'A build puller parked at capacity 0 wakes when its settings file changes, and retries a failed setting follow (issue 734's kind)' (#122) from fix/park-wakes into main 2026-10-28 21:03:13 +00:00
jschoubben 8eafc0c5ce The placement reader names the seat mesh-placement's state, held by the assigner, as L2 moved it (issue 774, ADR 0567)
mesh/delivery-group group feat/774-vault-mints-l2 checking: the checker's verdict is out of date: novox/mesh-delivery@f6df07605dd0 was judged on trunk 5b2e5bc5…
mesh/delivery merged: source.main moved to novox/mesh-sdk@d747c1128e42
mesh/check checking 8eafc0c5ce3c on d747c1128e42
2026-10-28 21:54:04 +01:00
jschoubben 50de0a5a71 The node seat's acts reads one act by its id (issue 774, ADR 0563 §9) 2026-10-28 21:54:04 +01:00
jschoubben 2323ba19db The node-engine's declared state acts: created by the grants part as a placed module's, its manifest placed on every node, and written by each node's engine under its own keys alone (issue 774, ADR 0563 §2, §3) 2026-10-28 21:54:04 +01:00
jschoubben 63607ba169 The vault's secret-ask is said to answer the callers its manifest names (issue 774, ADR 0563 §8) 2026-10-28 21:54:04 +01:00
jschoubben 82c54870ed A module's trusted list of callers in the SDK, so the vault's secret-ask answers the callers its manifest names as the runtime proved them (issue 774, ADR 0563 §8) 2026-10-28 21:54:04 +01:00
jschoubben 79231a9e6c A node's engine answers its own node's node-engine verbs, says the seat's events and asks the operator as node-engine-<node>, and no other account answers them (issue 774, ADR 0563 §1, §3, §6) 2026-10-28 21:54:04 +01:00
jschoubben 222d605f6f The node seat node-engine: held by every node's engine, its six verbs and three events, and no module may claim it (issue 774, ADR 0563 §1) 2026-10-28 21:54:04 +01:00
jschoubben 3a8997c58a Bundle the mesh's authorities by certificate, and check a node's certificate against every one a bundle holds (issue 774, ADR 0550 §2) 2026-10-28 21:54:04 +01:00
jschoubben d6db3f38e6 The vault signs: the contract of its state signers, its verb sign and its event signed, a node's grants to ask it and to read what it trusts, and the authority composed as a bundle (issue 774, ADR 0550 §2) 2026-10-28 21:54:04 +01:00
jschoubben 18a47ac3c7 Placed: a module in the first revision heard of a node's key stands since before it, so the seed's placement does not leave out what already ran (issue 774) 2026-10-28 21:54:04 +01:00
jschoubben 1386219d4d One reader of the catalogue's placement and the toolchain gate over it, in place of the three copies over the controller's assignments (issue 774, ADR 0549 §3, issue 1940); the assigner may read the lease (ADR 0549 §5) 2026-10-28 21:54:04 +01:00
jschoubben 10d4d2f845 Checker seat test: the try replaces both shell commands (issue 774) 2026-10-28 21:54:04 +01:00
jschoubben b3100c55a1 Checker seat: the try's verb also replaces docker run postgres, as the controller's try did (ADR 0547 §10, issue 774) 2026-10-28 21:54:04 +01:00
jschoubben 0062bdc4a6 buscompose: the assigner may read the controller's lease, to choose where grants are read from (issue 774) 2026-10-28 21:54:04 +01:00
jschoubben a1a688b19e delivery: the grants part's grant keys and the source the assigner reads (a1; issue 774)
Why: the grants part writes grants.<module>.<fp> in shadow beside the controller's; the assigner switches source only when the lease is handed away.
2026-10-28 21:54:04 +01:00
jschoubben 54b17164ef Checker seat test: fold into the closed-set seats test, no new test function (issue 774) 2026-10-28 21:54:04 +01:00
jschoubben 3ed9a0e342 Checker seat: the try's end by hand is a verb too (issue 774) 2026-10-28 21:54:04 +01:00
jschoubben 9b5657a3d6 Seat mesh-delivery-checker: the try and its read are verbs of the checker's own seat, replacing sh merge-check.sh (ADR 0547 §10, issue 774) 2026-10-28 21:54:04 +01:00
jschoubben 1d4637e46b Name the vault's verb issue-account on its seat, so its holder may serve it (issue 774, ADR 0561 §9) 2026-10-28 21:54:04 +01:00
jschoubben fefa8b88ea Say when the lease is handed away, let the vault and the grants part read it, and let the grants part create the consumers an account needs, so both turn on at the lease write alone (issue 774, ADR 0561 §3, §6) 2026-10-28 21:54:04 +01:00
jschoubben 8d9b3f779d Build a minted account's value as the controller does, byte for byte, so a runtime reads the vault's unchanged (issue 774, ADR 0561 §2) 2026-10-28 21:54:04 +01:00
jschoubben da506bb3b8 Say which bus accounts the grants part wants, in a state only the vault reads, and what the vault says when it mints one, so the vault can mint from the grants part's word (issue 774, ADR 0561 §2) 2026-10-28 21:54:04 +01:00
mesh-tools af08be36a2 Merge pull request 'A waiting ask hears its step's end even when its own watch goes deaf (four lost checks, 2026-10-28)' (#121) from fix/lost-check into main 2026-10-28 20:40:58 +00:00
mesh-tools 6b43afa2ab Merge pull request 'One predicate for a module the mesh provides itself (mesh-wireguard), used by the placement check (issue 1126)' (#120) from fix/1126-provided into main 2026-10-28 20:39:20 +00:00
jschoubben f91340fe82 buildrun: open a steps watch again that closed having heard nothing, up to 3 closes in a row
mesh/delivery merged: source.main moved to novox/mesh-sdk@af08be36a27a
mesh/delivery-group group fix/lost-check merged: every member is merged
mesh/check checking f91340fe82ef on 6b43afa2ab38 | novox/mesh-sdk#121 in group fix/lost-check
A watch that closed at once was left for good, so an ask whose own watch
went deaf was read only at a reconnect (review of fix/lost-check).
2026-10-28 20:35:02 +01:00
jschoubben 6aeb401095 buildrun: read an ask's step at every step end of its build module, so a deaf watch loses no end
mesh-catalog #553's verdict stayed running after its step passed on shanks
at 19:09:31Z on 2026-10-28: the watch of that one step key, opened at
18:46:05Z, heard neither the start nor the end and never closed. HeardEnds
stands between Asker and each ask's watch: nudged at each step end of the
same build module (HearEnds: one watch per steps state) and at a reconnect,
every waited ask reads its keys now; a watch that closes is opened again, at
most 3 times. The checker and the builder both wait through it.

Tests: a section of the asker's test (deaf, nudged by another module and by
its own, a watch that closes); counts unchanged.
2026-10-28 20:29:30 +01:00
jschoubben 5228a5782d Name the modules the mesh provides itself in one predicate, so the seed's check takes the private network as the composer and the grants part do (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-sdk@6b43afa2ab38
mesh/delivery-group group fix/1126-provided merged: every member is merged
mesh/check checking 5228a5782d5c on d7c790d2422c | novox/mesh-sdk#120 in group fix/1126-provided
2026-10-28 20:12:51 +01:00
jschoubben 9e5666b4d6 Settings watch: arm again when the kernel drops it (IN_IGNORED), said once; the test waits on the arm, not a sleep
mesh/delivery merged: source.main moved to novox/mesh-sdk@53f26493697e
mesh/delivery-group group fix/park-wakes merged: every member is merged
mesh/check pass 9e5666b4d6f5 on af08be36a27a | unit 5960 run, 332 kept, 18 skipped | integration 6012 run, 365 kept, 30 skipped | over bound: unit, in…
2026-10-28 20:08:21 +01:00
jschoubben c006aa8242 Build puller: wake on a settings file write, retry a failed config follow at the next event, say a capacity of 0 and why 2026-10-28 20:01:46 +01:00
mesh-tools d7c790d242 Merge pull request 'The seat mesh-placement's contract, and node grants for its states (issue 1126, delivery 1 of 2)' (#119) from feat/1126-grants-first into main 2026-10-28 18:21:33 +00:00
jschoubben 38dbe1d3a3 Let a node agent hear the creation of the seat's placement and candidates, so it binds them with no timer (ADR 0567 §9, issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-sdk@d7c790d2422c
mesh/delivery-group group feat/1126-grants-first merged: every member is merged
mesh/check pass 38dbe1d3a388 on 98f2f2406921 | unit 1909 run, 715 kept, 3 skipped | integration 1931 run, 813 kept, 3 skipped | over bound: unit (slow…
2026-10-28 18:47:13 +01:00
jschoubben a2dbcf670b Bound a proposal's answers and ask by pulse keys whose expiry the holder hears, and name a hand-back proposal, so no timer waits (ADR 0567 §4, §7, issue 1126) 2026-10-28 18:43:39 +01:00
jschoubben e987a8280b Let a module's config values be proposed before the lease write: only a layer's places and accesses wait (ADR 0567 §13, issue 1126) 2026-10-28 18:42:05 +01:00
jschoubben 45f5f76811 Keep the placement ask among the assigner's asks: its Approve's level, not its kind, needs the proof (ADR 0567 §5, issue 1126) 2026-10-28 18:30:00 +01:00
jschoubben 4b4a173d84 Let withdraw mark a proposal for its holder to end, since the holder's process alone says the seat's events (ADR 0567 §2, issue 1126) 2026-10-28 18:21:59 +01:00
jschoubben 0282229fd9 Name the placement ask and a proposal's again and warrant, so the holder asks through the teller as its other asks (ADR 0567 §5, §10, issue 1126) 2026-10-28 18:20:27 +01:00
jschoubben 5a2a7eebfd Hold that a check's node names reach the digest tool too, so a kept verdict misses when they change (ADR 0567 §12, issue 1126) 2026-10-28 18:17:08 +01:00
jschoubben 004bbc2ce2 Carry every node's name on a check ask to the names check, so no repository lists them (ADR 0567 §12, issue 1126) 2026-10-28 18:13:57 +01:00
jschoubben fed64292f0 Read build capacity as an integer and build hours as one window, default 0: no repository names a node (ADR 0567 §12, issue 1126) 2026-10-28 18:13:18 +01:00
jschoubben 7a82384e21 Grant a node's agent the seat's placement and candidates, so it composes from the seat's state (ADR 0567 §3, §9, issue 1126) 2026-10-28 18:12:09 +01:00
jschoubben 7c565f2aa5 Move Intended and JudgeAnswers into mesh-sdk for a proposal: a difference is shown, only a refusal or silence fails (ADR 0567 §4, issue 1126) 2026-10-28 18:11:18 +01:00
jschoubben edc6f269f3 Name the seat's states, candidates by proposal and the proposal itself, so readers and the holder share one contract (ADR 0567 §1-§6, issue 1126) 2026-10-28 18:09:38 +01:00
jschoubben 902e3c9e96 Refuse placement files on a forge message and delete placement-seed: no repository holds placement (ADR 0567 §11, issue 1126) 2026-10-28 18:09:38 +01:00
jschoubben c16e6213e1 Define the seat mesh-placement with its seven verbs, so placement has one contract whoever holds it (ADR 0567, issue 1126) 2026-10-28 18:05:16 +01:00
mesh-tools 98f2f24069 Merge pull request 'A node holds the one module it cannot compose and applies the rest, as its shadow already answers (group fix/1126-apply-culprit, issue 1126)' (#118) from fix/1126-apply-culprit into main 2026-10-28 16:34:46 +00:00
jschoubben 0de3006415 A module refused alone is held, composed as what runs of it, on the node that applies as in its answer: one rule for both paths (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-sdk@98f2f2406921
mesh/delivery-group group fix/1126-apply-culprit merged: every member is merged
mesh/check pass 0de30064156a on 7aedd28f01dc | unit 2667 run, 590 kept, 36 skipped | integration 2676 run, 633 kept, 29 skipped | over bound: unit (sl…
The applying path (frombus.Compose) composed with no culprit, so one module's
refusal came back as the whole node's: novox's apply would have refused all its
modules for de-spiegel's unset route names. composeEach is now the one rule
Compose and Answer both follow:
- a module whose stub changes the refusal is held (compose.Node.Held); in the
  applying path a stand-in for a module placed here holds that module; only a
  refusal of no single module refuses the node;
- a held module is composed from what runs of it (compose.Carried of its own
  composition in Running) and carried in the node's own wherever that runs it,
  folded or leaving the fold, so the node's own apply never removes it (issue
  1945 by a new path) and what the others read of it is what runs;
- none of a held module's own composition is in what is applied;
- a module whose composition differs when the held ones are composed as stubs
  is held too; the node's own differing refuses the node;
- since a held module is composed from its new manifest, what reads that
  manifest is held with it: the providers of what it contributes to (the
  proxy its routes) and, when it listens, the filter's holder.

Quick fix weighed: a culprit loop in Compose alone. Thorough, chosen: one
shared function, since the kind met twice (seed-8 shadow, now the apply), and
review rounds 1 and 2 found its leaks.
2026-10-28 17:22:35 +01:00
mesh-tools 7aedd28f01 Merge pull request 'A module's own refusal refuses that module, not the node; contributions say unset values in config words; a folded module owns its manifest's paths (issue 1126, group fix/1126-seed-8)' (#117) from fix/1126-seed-8 into main 2026-10-28 13:27:05 +00:00
jschoubben f387581b6b A module's own refusal refuses that module, not the node: a stub whose contributions still fail the same is tried bare (issue 1126, seed-8: novox refused the whole candidate for de-spiegel's unset route names)
mesh/delivery merged: source.main moved to novox/mesh-sdk@7aedd28f01dc
mesh/delivery-group group fix/1126-seed-8 merged: every member is merged
mesh/check pass f387581b6bfb on 3d021445f743 | unit 3018 run, 234 kept, 36 skipped | integration 3038 run, 266 kept, 29 skipped | over bound: unit (sl…
2026-10-28 14:07:59 +01:00
jschoubben eda82b1598 A folded module owns its manifest's paths, as the controller tells node-files them, not every path it runs at (issue 1126, seed-8: f1-circuits, snake and txt-game shifted node-tools' owned paths on ace) 2026-10-28 14:07:59 +01:00
jschoubben 06742d0448 A contribution's unset required value is refused in its module's own form's words, as a file's is (issue 1126, seed-8: de-spiegel in the config form was told ${setting:…} and settings set) 2026-10-28 14:07:59 +01:00
mesh-tools 3d021445f7 Merge pull request 'A build module may say its node is not eligible, so asks go to a node that can build (group fix/ace-tun)' (#116) from fix/ace-tun into main 2026-10-28 13:07:40 +00:00
jschoubben 1618837ea9 A build seat's puller takes nothing while its module finds its node cannot build, and says why
mesh/delivery merged: source.main moved to novox/mesh-sdk@3d021445f743
mesh/delivery-group group fix/ace-tun merged: every member is merged
mesh/check pass 1618837ea9fe on 6097e4f3e040 | carried from mesh-catalog 25f615456423 | unit 2293 run, 3984 kept, 16 skipped | integration 2331 run, 4…
A node whose module proves a need of the machine missing (ace's /dev/net/tun, 2026-10-28) pulled
image asks and failed each one; judged ineligible, its capacity is 0 with the reason, as build hours
are, so the asks go to a node that can build them (issue 1126 note).
2026-10-28 12:50:16 +01:00
mesh-tools 6097e4f3e0 Merge pull request 'A file's secrets are sealed values entry by entry, masked and named per secret (issue 1126; group fix/1126-seed-same-3)' (#115) from fix/1126-seed-same-3 into main 2026-10-28 11:40:35 +00:00
jschoubben 8520a4c099 Only a file's secrets are split entry by entry when masked and named: a vault box under vault-sealed is one masked word and one path, judged whole by what it opens to (issue 1126, C1; review of seed-same-3, round 1)
mesh/delivery merged: source.main moved to novox/mesh-sdk@6097e4f3e040
mesh/delivery-group group fix/1126-seed-same-3 merged: every member is merged
mesh/check pass 8520a4c09960 on 79eb58efe4b0 | carried from mesh-catalog 774fcdfa93ad | unit 2188 run, 1064 kept, 36 skipped | integration 2197 run, 1…
2026-10-28 12:22:37 +01:00
jschoubben 70de9429e5 A file's secrets are sealed values entry by entry: each a slot of its own, kept where it opens to the value that runs, masked and named per secret (issue 1126, C1; the seed-7 probe: about 45 files on ace and novox differed by their secrets' bytes alone) 2026-10-28 12:02:39 +01:00
mesh-tools 79eb58efe4 Merge pull request 'Compose as the controller does, so a node's shadow can answer same (issue 1126, group fix/1126-seed-same)' (#114) from fix/1126-seed-same into main 2026-10-28 09:46:24 +00:00
jschoubben 3028c7ec4e A drifted own secret is read from a resource id as a node runs it, its module's name first (only-office.needs-jwt), so the node agent finds what to hand the vault (issue 1126, cause C4; review round 2)
mesh/delivery merged: source.main moved to novox/mesh-sdk@79eb58efe4b0
mesh/delivery-group group fix/1126-seed-same merged: every member is merged
mesh/check failed: integration: github.com/novox/mesh-host/cmd/mesh-host.TestNatsANodeThePlacementNamesComposesAndAppliesItsOwn: the node did not comp…
delivery.OwnNeedName reads <module>.needs-<name> and the bare needs-<name>; OwnValuesDiffer and the node agent use it.
The fixtures carry the id shape captured from novox's shadow answer.
2026-10-28 10:00:29 +01:00
jschoubben 1062b33d32 A node's shadow answer counts the values that drifted from the vault's, and names the own secrets among them (issue 1126, cause C4)
delivery.OwnValuesDiffer, ValuesDiffer and WithValuesDiffer read the shown bodies masked as another value; a candidate
answer carries values_differ. The node agent hands the vault those own secrets' running values through move-in's
replace_running, so no seat verb and no bus grant changes here.
2026-10-28 09:49:03 +01:00
jschoubben 16b8d8a1ee Compose as the controller does, so a node's shadow can answer same (issue 1126, C1 C2 C3 C5 C6, T1)
Seals are reused and compared by slot (resource, field), never across resources;
one rule says which resolved config values are laid; a seat path that steps back
is owned by the seat it steps back for, whatever the seat order; a folded module
of an old-form build is composed from what runs of it; the operator export's file
is carried as it runs; a differing resource names its differing JSON paths, found
before its bodies are cut, and an answer is fitted to the node's key.
2026-10-28 09:36:36 +01:00
mesh-tools be77f66563 Merge pull request 'frombus: blame only the modules a refusal names, and say which providers another node cannot run (issue 1126, group fix/1126-shadow-bodies)' (#113) from fix/1126-shadow-bodies into main 2026-10-28 07:46:23 +00:00
jschoubben 362a3f1378 A refusal for want of a provider names the modules another node cannot run in this composition, and a refusal naming no placed module is answered at once (issue 1126, probe #532)
mesh/delivery merged: source.main moved to novox/mesh-sdk@be77f6656326
mesh/delivery-group group fix/1126-shadow-bodies merged: every member is merged
mesh/check pass 362a3f1378aa on 03da552cb331 | unit 2860 run, 1141 kept, 36 skipped | integration 2887 run, 1241 kept, 29 skipped | over bound: unit (…
ace and g14 answered 'nothing in this mesh provides artifact-store … assign
distribution to a node' while novox runs it: a module another node cannot run
in this composition (catalogue Resolution.Unhostable) offers nothing, silently.
The refusal now names each such module and what it lacks, and each node left
out. And the culprit search composed the whole mesh once per placed module for a
refusal no stub changes (ace's answer took 7m46s): only the modules the refusal
names are tried, at most 8.
2026-10-28 01:39:07 +01:00
mesh-tools 03da552cb3 Merge pull request 'Shadow answers show both bodies of each difference (secrets masked); a folded module is judged inside; the bus membership is carried (issue 1126, group fix/1126-shadow-shows)' (#112) from fix/1126-shadow-shows into main 2026-10-28 00:21:47 +00:00
jschoubben 4e0c57771e Bound the bodies an answer shows, naming the rest (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-sdk@03da552cb331
mesh/delivery-group group fix/1126-shadow-shows merged: every member is merged
mesh/check pass 4e0c57771e8c on 47eb1009921d | unit 2980 run, 270 kept, 36 skipped | integration 2999 run, 302 kept, 29 skipped | over bound: unit (sl…
2026-10-28 01:09:02 +01:00
jschoubben b31c4580aa A candidate answer shows every differing resource, masked; a module folded in the node's own is judged there; the node's own carries its bus membership as it runs it (issue 1126, seed #531)
novox's answer to seed #531 named 15 resources by id alone, so why each differed
was guessed. ModuleDiffers.Bodies keeps every differing resource with both
bodies, each value under a key a secret could be in masked (the same, another
seal of the same value, another value). The five old-form modules read
'removed' because the agent also lists them by module from the declaration kept
before the switch; they are judged inside the node's own. And the node's own
lacked bus-membership, which only the controller composes from its store: the
membership the node runs is carried, until the grants part hands it sealed.
2026-10-28 01:08:49 +01:00
mesh-tools 47eb100992 Merge pull request 'frombus: a stub keeps its module's directories; compose returns nothing once its run ended (issue 1126, group fix/1126-switch-safe)' (#111) from fix/1126-switch-safe into main 2026-10-27 23:52:19 +00:00
jschoubben 7359d187db A stub keeps its module's directories, so the places its own secrets and data name still resolve (issue 1126: novox refused nats's ${dir:mesh-state})
mesh/delivery merged: source.main moved to novox/mesh-sdk@47eb1009921d
mesh/delivery-group group fix/1126-switch-safe merged: every member is merged
mesh/check pass 7359d187db8d on 47b1dcf08f85 | unit 2992 run, 1008 kept, 36 skipped | integration 3025 run, 1102 kept, 29 skipped | over bound: unit (…
2026-10-28 00:21:45 +01:00
mesh-tools 47b1dcf08f Merge pull request 'Shadow answers name every refusal, carry old-form builds as they run, and compare a deleted placement (issue 1126, group fix/1126-shadow-all)' (#110) from fix/1126-shadow-all into main 2026-10-27 22:44:08 +00:00
jschoubben e32d777530 A deleting head's answer compares the controller's compositions with what runs, and is refused only without returning; fold the shadow's tests into TestInput (issue 1126, review of the shadow, round 1)
mesh/delivery merged: source.main moved to novox/mesh-sdk@47b1dcf08f85
mesh/delivery-group group fix/1126-shadow-all merged: every member is merged
mesh/check checking e32d77753024 on d48365aed010 | novox/mesh-sdk#110 in group fix/1126-shadow-all
2026-10-27 23:32:31 +01:00
jschoubben 472d5f3144 A node's have says whose compositions it applies, and its answer to a head deleting its placement says it returns to the controller's (issue 1126, the seed's revert)
The check of the seed's revert (mesh-catalog #526) failed on 'would stop
running node': the shadow answered a deleted placement as everything removed,
while the node keeps what runs. HaveNode.Composes and ComposesWhy say whether a
node applies its own compositions or the controller's; CandidateAnswer.Returns
answers a deleted placement, verdict same, naming nothing composed.
2026-10-27 23:22:54 +01:00
jschoubben 2fd5ffdd62 A node's answer names every module it cannot compose, a folded module of an old-form build is left to the node as it runs, and no store reached says why (issue 1126, the seed's shadow)
The seed's shadow answers named the first refusal only (ace: baserow's archive,
novox: de-spiegel), so each round showed one more. A build that cannot be read or
taken in, and a module the composition refuses, are now each composed as a stub
and named. A module folded into the node's own composition whose manifest names a
build (de-spiegel, nats, photos, systemd-resolved: haves naming a build-... id)
runs a build only the controller holds: it is composed as a stub and its
resources are carried as the node runs them.
2026-10-27 23:17:05 +01:00
mesh-tools d48365aed0 Merge pull request 'frombus: a node composing itself takes its modules from their builds and reads what providers elsewhere serve (issue 1126, group fix/1126-node-creds)' (#109) from fix/1126-node-creds into main 2026-10-27 20:32:30 +00:00
jschoubben 7a8e9f9706 Wait for a done key whose artifact names no reference, as the controller's take-in does, and hold the fold guard with a row (issue 1126, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-sdk@d48365aed010
mesh/delivery-group group fix/1126-node-creds merged: every member is merged
mesh/check checking 7a8e9f970648 on d48365aed010 | novox/mesh-sdk#109 in group fix/1126-node-creds
2026-10-27 21:12:13 +01:00
jschoubben d7a758894a Leave a module the node runs inside its own composition to its manifest, as the fold composes it, so taking builds in moves no folded module (issue 1126) 2026-10-27 21:11:20 +01:00
jschoubben 04de3e5da2 Compose a node's own modules from their builds and read what providers elsewhere serve, so a self-composing node hands moved modules and settles served settings as the controller does (issue 1126)
frombus composed every module from the indexer's raw manifest: no moved module
was handed, so the node stood in for own secrets the vault leaves to the
provisioner (the seed's refusals on all four nodes), and a provider elsewhere
had no config values, so mailu's served domain refused shanks.

- takein: the controller's take-in of a done key (artifacts, launchers, the
  templates archive's texts, Resolve) as one SDK rule; catalogue.Launchers holds
  the launcher rule buildstep now delegates to.
- frombus: each module placed on the composing node, and each kept at another
  build (Scope.KeptAt, a fingerprint), composes from its built manifest; another
  node's modules give the keys their served values fill (Reader.Served).
- buscompose: Declared.Served and Principal.ServedReads grant every node the read
  of exactly those keys of mesh-config_resolved.
- compose.ArtifactStoreAddress: the store as the node's composition routes to it.
2026-10-27 21:11:20 +01:00
mesh-tools 9f92233a28 Merge pull request 'process: a module's own bus connection, subjects and durable consumer in one helper (issue 1242)' (#108) from fix/1242-bus-helper into main 2026-10-27 19:49:19 +00:00
jschoubben 8ad9372b19 Name another module's own-stream subject in the SDK too (issue 1242)
mesh/delivery merged: source.main moved to novox/mesh-sdk@9f92233a2890
mesh/delivery-group group fix/1242-bus-helper merged: every member is merged
mesh/check checking 8ad9372b1952 on a262c7dbe0ff | novox/mesh-sdk#108 in group fix/1242-bus-helper
2026-10-27 20:38:20 +01:00
jschoubben 3236cbad43 Give a module's own credential its dial, subjects and durable consumer in mesh-sdk, so a part no longer hand-writes them (issue 1242) 2026-10-27 20:36:14 +01:00
mesh-tools a262c7dbe0 Merge pull request 'A node folds a placed module with no new-form build into its own composition, as the controller does, so the tunnel and the bus are not dropped when nodes compose themselves (issue 1126)' (#107) from fix/1126-fold into main 2026-10-27 08:09:03 +00:00
jschoubben 05b6783103 Fold a module no new-form build names into the node's own composition, as the controller does, so a self-composing node keeps the tunnel and the bus (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-sdk@a262c7dbe0ff
mesh/delivery-group group fix/1126-fold merged: every member is merged
mesh/check checking 05b678310366 on 56de5ba84010 | novox/mesh-sdk#107 in group fix/1126-fold
mesh-wireguard (built by nobody) and every module a node runs inside its own
composition (Node.InOwn, from frombus.InOwn of what it runs) were cut out as
skipped: every node's shadow answered differs to the placement seed, and a node
composing itself after it would drop their resources. compositions now folds
them in place, module taken off, filled paths with them, as mesh-controller's
compositionsOf does; a want naming a new-form build still moves one out.
2026-10-27 08:50:26 +01:00
mesh-tools 56de5ba840 Merge pull request 'A placement change is answered by the nodes it places: the candidate contract, composing a given placement, and replicated seats in the placement check (issue 1126, ADR 0559)' (#106) from feat/1126-l2b into main 2026-10-27 07:21:03 +00:00
mesh-tools 43eef46c41 Merge pull request 'A build step names the process that runs it, so a restarted build module clears what a gone process left running (issue 1890)' (#105) from fix/1890-step-owner into main 2026-10-27 07:19:36 +00:00
jschoubben cadd7b1b2f Ask the vault in a candidate answer only for credentials of modules the node runs, and refuse a stand-in of a running module, so a placement that adds a module is answered as added (issue 1126, ADR 0559 §4)
mesh/delivery merged: source.main moved to novox/mesh-sdk@56de5ba84010
mesh/delivery-group group feat/1126-l2b merged: every member is merged
mesh/check being carried to cadd7b1b2f8f on 43eef46c4121 | from b0cbc4e6aca8, mesh-catalog 64530a8dcb08 | novox/mesh-sdk#106 in group feat/1126-l2b
2026-10-27 02:43:10 +01:00
jschoubben 062c29c3a2 Judge a node's own composition in its candidate answer only where it runs one, as its agent applies it only on a want (issue 1126) 2026-10-27 02:27:04 +01:00
jschoubben a37d2622ed Name the words of a composition failed only for an answer not heard, and what an answer's revision is, so the assigner and the checker judge a late answer alike (issue 1126) 2026-10-27 02:22:59 +01:00
jschoubben ba398e2460 Answer a placement candidate as ADR 0559 has it: added and removed modules, per-module refusals, both digests of a difference, and a node's read of every candidate key (issue 1126) 2026-10-27 02:19:27 +01:00
jschoubben 82f10c3bc9 Let frombus and the composer compose with a given placement and compare a node's compositions with what it runs, so a placement candidate can be answered and checked (issue 1126) 2026-10-27 02:12:11 +01:00
jschoubben 7e99c354ac Give a catalogue pull request's placement a candidate state and each node an answer in its own have, so a placement change is checked by the nodes it places before it merges (issue 1126) 2026-10-27 02:07:47 +01:00
jschoubben 8e81d87610 Let a replicated mesh seat be held on several nodes in CheckPlacements, so the seed of today's placement is not refused (issue 1126) 2026-10-27 02:04:01 +01:00
jochen db1007ffdd Delete a gone process's ask key instead of ending it, so its redelivery still answers the asker (issue 1890)
mesh/delivery merged: source.main moved to novox/mesh-sdk@43eef46c4121
mesh/delivery-group group fix/1890-step-owner merged: every member is merged
mesh/check pass db1007ffdda1 on b0cbc4e6aca8 | unit 6014 run, 233 kept, 16 skipped | integration 6067 run, 265 kept, 24 skipped | over bound: unit, in…
ADR 0525 §6 has the stream redeliver an ask whose node stopped, and the
asker takes the first end it reads: ending the key at a restart failed
checks a redelivery would still run. The sweep now deletes such a key,
guarded by the revision read, so the asker keeps waiting and the stale
key leaves the counts of what runs; a hand-started step of a gone owner
is still ended at once. A missing boot id is said once at start.
2026-10-27 01:29:48 +01:00
jochen 981a35b598 End a step left by a previous process at once: every step names its owner (issue 1890)
A delivery that replaced mesh-build-go left a check ask's key running, and
the new process's start sweep spared it as an ask younger than the step
bound, so it held a build slot until a hand end (issue 1890, note 21).
Each step now records its owner (boot id, pid, process start); the sweep
ends a running step of another owner at once and says its owner, while a
key with no owner keeps the earlier rule. The conformance kit gains the
left-running case every build module runs through its own start sweep.
2026-10-27 01:19:29 +01:00
mesh-tools b0cbc4e6ac Merge pull request 'A build step's failure contract: go -json and TAP parsers and a conformance kit every build module runs, so a verdict names the failing test (issue 1901)' (#104) from fix/1901-failure-contract into main 2026-10-26 23:44:13 +00:00
jschoubben a2759ec92e Merge main into fix/1901-failure-contract: the capacity tests stay one function, so the SDK's test count does not grow (issue 1901)
mesh/delivery merged: source.main moved to novox/mesh-sdk@b0cbc4e6aca8
mesh/delivery-group group fix/1901-failure-contract merged: every member is merged
mesh/check checking a2759ec92ec9 on f178323b5790 | novox/mesh-sdk#104 in group fix/1901-failure-contract
2026-10-27 00:39:10 +01:00
mesh-tools f178323b57 Merge pull request 'The mesh-vault seat promises own, the verb L2 gave the vault (issue 1126)' (#103) from fix/1126-vault-seat-own into main 2026-10-26 22:00:44 +00:00
mesh-tools 684acb922d Merge pull request 'Delivery records are true at the source: the layouts for what a repository's merges reached (issue 1280)' (#102) from fix/delivery-records-true into main 2026-10-26 21:48:43 +00:00
jschoubben e78e881ecb The vault's seat promises own, which L2's vault serves and its claim names, so the controller's catalogue check passes (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-sdk@f178323b5790
mesh/delivery-group group fix/1126-vault-seat-own merged: every member is merged
mesh/check failed: unit: github.com/novox/mesh-controller/cmd/mesh-controller.TestModuleCheckPassesTheCatalogue: the catalogue does not pass its own c…
mesh-catalog's mesh-vault claims its seat serving own (a machine's node-engine asks its own machine's secrets and
pair credentials sealed to its stated key), and buscompose already grants that call to node principals
(AsksItsOwnSecrets, VaultOwn); only the seat's verb list lacked it, so a claim naming it was refused.
2026-10-26 22:38:58 +01:00
jschoubben 68d65e6bbf Merge remote-tracking branch 'origin/main' into fix/delivery-records-true
mesh/delivery merged: source.main moved to novox/mesh-sdk@684acb922d99
mesh/delivery-group group fix/delivery-records-true merged: every member is merged
mesh/check checking 68d65e6bbf61 on 823d52051f03 | novox/mesh-sdk#102 in group fix/delivery-records-true
2026-10-26 22:19:13 +01:00
mesh-tools 823d52051f Merge pull request 'Nodes compose: placement, node records and one composer input for three callers (issue 1126)' (#98) from feat/1126-nodes-compose into main 2026-10-26 21:11:05 +00:00
jschoubben b4310dfb8e Merge remote-tracking branch 'origin/main' into fix/delivery-records-true 2026-10-26 22:04:42 +01:00
jschoubben 9796444201 Merge remote-tracking branch 'origin/main' into feat/1126-nodes-compose
mesh/delivery merged: source.main moved to novox/mesh-sdk@823d52051f03
mesh/delivery-group group feat/1126-nodes-compose merged: every member is merged
mesh/check checking 979644420117 on 7187beaf042b | novox/mesh-sdk#98 in group feat/1126-nodes-compose
# Conflicts:
#	test/standing.test.ts
2026-10-26 22:03:26 +01:00
mesh-tools 7187beaf04 Merge pull request 'The provider loop's tests wait on what they mean, not on the clock (issue 1901 note 27)' (#101) from fix/standing-test-races into main 2026-10-26 20:58:44 +00:00
jschoubben 5f45175ed0 Merge remote-tracking branch 'origin/main' into feat/1126-nodes-compose 2026-10-26 21:56:47 +01:00
jschoubben 18e541c1ba The standing test allows 10 ms between the loop's clock and the test's stamp of an announcement
mesh/delivery merged: source.main moved to novox/mesh-sdk@7187beaf042b
mesh/check pass 18e541c1badc on 7187beaf042b | carried from 2cbe21937153 | a compiled program is not refused: mesh-build-node reported no programs | u…
2026-10-26 21:55:46 +01:00
jschoubben cf930388f9 The standing test's runs last 0.6 of failingAfterMs of clock as well as two attempts, so a success that does not reset a run is caught; the addition test holds a thousand stable passes, so 'first pass' is proved (review of the standing test races) 2026-10-26 21:49:03 +01:00
jschoubben 36ae0c691b The standing and retirement tests wait on what they mean, not on a slept fraction of a bound: announcements are timed from the clock at the moment they are said, short runs are counted in attempts, an addition is proved by a hold it must not wait for (the second timing race of one kind in a day; issue 1126) 2026-10-26 21:44:06 +01:00
mesh-tools 2cbe219371 Merge pull request 'A build seat's capacity follows its CPU share and its build hours; asks say their phases (issues 1890, 1806)' (#100) from fix/capacity-follows-cpu-share into main 2026-10-26 20:33:28 +00:00
jschoubben 3a095a66be The standing test keeps c failing alone after its ask is dropped, so its only recovery is its retirement: clearing every failure at once raced the loop's read of the new list under load (issue 1126; the hidden failure of try-1793042418286545971, issue 1901) 2026-10-26 21:33:20 +01:00
jschoubben 805b4d9a35 Merge main into fix/capacity-follows-cpu-share: the build modules' binary refusal (sdk #99) beside the capacity work
mesh/delivery merged: source.main moved to novox/mesh-sdk@2cbe21937153
mesh/delivery-group group fix/capacity-follows-cpu-share merged: every member is merged
mesh/check checking 805b4d9a3536 on dae8e288d2e1 | novox/mesh-sdk#100 in group fix/capacity-follows-cpu-share
2026-10-26 21:26:58 +01:00
jschoubben d00dac140b A build module takes asks only within its node's build hours
mesh/delivery-group group fix/capacity-follows-cpu-share rejected: composed together: the checker's verdict at the members' heads (63694fe9222d): unit: ..test
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: ..test: ✔ a transient empty list for four passes retires nothing; five stable passes retire, data kept (418.253369ms) | d00da…
The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00. A build-hours setting names a
window per node in its local time (crossing midnight allowed, absent = any hour); outside it the
capacity judged by is 0, running steps finish, and the puller wakes on one timer at the next edge.
The change is said in the log and in capacity.<node> with the window and when it opens.
2026-10-26 21:21:37 +01:00
jschoubben e44cbaf57f A module with no floor holds no head back; a merge among its changes reads not yet on the machines (review of issue 1280, ADR 0556 clause 2) 2026-10-26 21:12:48 +01:00
jschoubben 1f70c4c0aa A settle ask's group begins with a colon, which no branch name holds, so a branch named settle/... still merges on its Approve (review of issue 1280) 2026-10-26 21:12:31 +01:00
jschoubben 5ecda46124 A settle ask's group and the act its options bind, shared by the gate and the teller (ADR 0556 clause 5) 2026-10-26 21:12:31 +01:00
jschoubben aa8e9afc7c A module's floor counts every running have, healthy or not; health stays in its words (ADR 0556 clause 2, insight) 2026-10-26 21:12:31 +01:00
jschoubben 54a8afac73 The view's reach carries the reached head and each change's source revision; nothing records a merge as delivered (ADR 0556 v8) 2026-10-26 21:12:31 +01:00
jschoubben 43de3c937d Layouts of the indexer's taken and the view's reach, and the delivery seat's settle verb, so whether a merge is on the machines can be derived (issue 1280, ADR 0556) 2026-10-26 21:12:31 +01:00
jschoubben 03dce12378 The standing test keeps c failing alone after its ask is dropped, so its only recovery is its retirement: clearing every failure at once raced the loop's read of the new list under load (issue 1126; the hidden failure of try-1793042418286545971, issue 1901)
mesh/delivery-group group feat/1126-nodes-compose rejected: composed together: the checker's verdict at the members' heads (0d06ff386dce): ace
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: ace: novox/mesh-sdk#98 does not merge into the trunk: files in conflict: test/standing.test.ts | 03dce1237803 on 7187beaf042b | uni…
2026-10-26 20:33:23 +01:00
mesh-tools dae8e288d2 Merge pull request 'A change that adds a compiled binary is refused, in any repository (issue 1577)' (#99) from fix/1577-checker-refuses-binaries into main 2026-10-26 19:32:25 +00:00
jschoubben dd9d9d5b5f Compare haves with SameHave in the round-trip tests, now that a have carries a list (issue 1126)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking dd9d9d5b5f01 on dae8e288d2e1 | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 20:20:12 +01:00
jschoubben 2c0ab3a592 A have carries the values a self-composing node's running composition holds sealed to its key, so its composer reuses them after a restart (review of L2, round 2; issue 1126, ADR 0547 §3)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (4 tests): unit: go.go vet: # git.novox.be/novox/mesh-sdk/go/delivery [git.novox.be/novox/mesh-sdk/go/delivery.test] | 2c0ab3a59288 …
2026-10-26 20:19:54 +01:00
jschoubben 47a66e3c0d Take a crashed file's cause only from what the run wrote after the last test, give a subtest's panic to that subtest, and keep the SDK's test count (issue 1901, review round 1) 2026-10-26 20:17:42 +01:00
jschoubben 3968849b1b Review of L2: the export is a placement file at its version with its modules (S1); a node asks the vault only when a module placed on it declares an own secret, so one with none composes whether or not the vault answers (H8's failure) (issue 1126)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 3968849b1b36 on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 20:08:32 +01:00
jschoubben 02f50bd9d6 A build seat's capacity follows its node's build CPU, and a running ask says its phases
Since the build CPU cap (issue 1806) shanks' mesh-build-go kept capacity 3 inside a 12-core slice:
three full checks got about 4 cores each and ran past the 30-minute step bound (issue 1890).
The puller now judges by min(configured, floor(slice quota cores / cores-per-step)), the quota
read live from its cgroup, says it on change and writes capacity.<node> to the pauses state.
A run's phases (fetch and merge, digest, tests) are logged with the ask id and written to its
running step key; Asker.Wait hands every step value to a WithStepSeen hook.
2026-10-26 19:52:54 +01:00
jschoubben 81ec7bd7de Drop the failure contract's unused version constant: a result names its build module with By (issue 1901) 2026-10-26 19:50:17 +01:00
jschoubben fe11db9293 MergeInto also names programs a commit of the branch added and a later one removed: the merge commit would keep the blob in history (issue 1577)
mesh/delivery merged: source.main moved to novox/mesh-sdk@dae8e288d2e1
mesh/delivery-group group fix/1577-checker-refuses-binaries merged: every member is merged
mesh/check checking fe11db929397 on a7a1fbd34e1d | novox/mesh-sdk#99 in group fix/1577-checker-refuses-binaries
2026-10-26 19:47:17 +01:00
jschoubben e40dd0f56a One failure contract for a build step's failed test, its parsers and a conformance kit every build module runs (issue 1901)
Each build module parsed its own test runner's output, so a fix in one never reached the others: a failed node --test
run was worded by a passing line of its first file. buildstep.Failure names the test, its file, its cause and its own
output; ReadGoTest (moved from mesh-build-go) and ReadTAP fill it; package conformance holds every build module to it
with runner output captured with the build nodes' own toolchains.
2026-10-26 19:45:35 +01:00
jschoubben 9713c3023a Placement carries a settings-form module's config values, which mesh-config does not hold, and the export writes them (issue 1126, ADR 0555 §1): the media modules on one node would compose without them
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: ace: novox/mesh-catalog#505 does not merge into the trunk: files in conflict: .gitignore | 9713c3023a18 on a7a1fbd34e1d | unit 1245…
2026-10-26 19:38:37 +01:00
jschoubben 53c2def1a5 VaultSecretChanged is a variable (its seat is), and gofmt (issue 1126)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: git.novox.be/novox/mesh-catalog/checks/nobinaries.TestTheCatalogueHoldsNoCompiledProgram: modules/mesh-config/cmd/mesh-config…
2026-10-26 19:25:19 +01:00
jschoubben f55b34febc A node composing its own hears the vault's secret-changed, names and generations only (issue 1126, ADR 0547 §2)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (6 tests): unit: go.gofmt: not as gofmt writes them (run gofmt -w): go/buscompose/buscompose_test.go | f55b34febc2d on a7a1fbd34e1d …
2026-10-26 19:25:09 +01:00
jschoubben 944a35d200 A node's record carries the mesh's own certificate as the machine holds it, and frombus composes with the composing node's (issue 1126, ADR 0555 §3)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: shanks: novox/mesh-catalog#505: checks/placement/go.mod names mesh-sdk 91413de33516, which is neither on mesh-sdk's trunk nor the g…
2026-10-26 19:23:06 +01:00
jschoubben 7d81e8326f frombus gives the composing node's core credentials as the vault's boxes sealed to it, from the same own secrets (issue 1126, ADR 0349 §16, ADR 0550 §5) 2026-10-26 19:22:58 +01:00
jschoubben 91413de335 compose names the pair credentials a node's composition reads as consumer and provider, and frombus asks them sealed before it composes (issue 1126, ADR 0550 §5)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: g14: novox/mesh-catalog#505: checks/placement/go.mod names mesh-sdk f1d7c7377dc4, which is neither on mesh-sdk's trunk nor the grou…
2026-10-26 19:18:46 +01:00
jschoubben 6447a1c6d4 placement-seed writes each node's first placement file from the controller's export, checked before anything is written, once (issue 1126, ADR 0549 §4, ADR 0555 §6)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: shanks: novox/mesh-catalog#505: checks/placement/go.mod names mesh-sdk f1d7c7377dc4, which is neither on mesh-sdk's trunk nor the g…
2026-10-26 19:17:45 +01:00
jschoubben 4d5f430224 A want the node composes itself says so (Self) and names the build alone; every other want names its composition as before, so no caller of the old form changes (issue 1126, ADR 0547 §2)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: ace: novox/mesh-catalog#505: checks/placement/go.mod names mesh-sdk f1d7c7377dc4, which is neither on mesh-sdk's trunk nor the grou…
2026-10-26 19:13:56 +01:00
jschoubben dfe3ba9475 frombus exports a node's placement from a composition input, Input's inverse for what a person decides, and the controller's seat declares placement-export (issue 1126, ADR 0555 §6)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking dfe3ba94750c on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 19:11:34 +01:00
jschoubben f1d7c7377d frombus reads the composing node's own secrets sealed to it, and node principals may ask the vault's own in their own name (issue 1126, ADR 0550 §5)
mesh/delivery-group group feat/1126-nodes-compose checking: 4 of 5 member(s) ready
mesh/delivery closed: a newer head of the same pull request
mesh/check checking f1d7c7377dc4 on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 19:03:00 +01:00
jschoubben 22bc4dc8ef MergeInto names the changed files that are compiled programs, read in the workspace, so a check can refuse build output (issue 1577) 2026-10-26 18:59:35 +01:00
jschoubben b72de2b75a A node applying what it composed refuses a composition that stands in for anything its input lacks, the bus's user list alone held out (issue 1126, ADR 0547 §2) 2026-10-26 18:59:05 +01:00
jschoubben 6df0dfe208 Node principals read what they compose their own modules from; the composer copy is held per user to the controller's grants plus exactly these (issue 1126, ADR 0547 §2, ADR 0549 §3)
mesh/delivery-group group feat/1126-nodes-compose rejected: composed together: the checker's verdict at the members' heads (922da6d6ae76): forge
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 6df0dfe208c6 on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 18:44:06 +01:00
jschoubben 9532b78724 A node's own want names no composition from the second milestone: the node composes its own (issue 1126, ADR 0547 §2) 2026-10-26 18:41:33 +01:00
jschoubben e49309d61c A want names the build alone from the second milestone, and the yes a person gave; a have waits for that yes with its judgement (issue 1126, ADR 0547 §2, §3) 2026-10-26 18:39:46 +01:00
jschoubben 9789c8379a Node principals read every node's own facts, where its record rides; the composer copy is checked to grant all the controller's grants and only that and the issue 1790 rows more (issue 1126, ADR 0555 §3)
mesh/delivery-group group feat/1126-nodes-compose checking: 1 of 3 member(s) ready
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 9789c8379a74 on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 18:33:36 +01:00
jschoubben 2b0689f654 A node's record rides in its own have key, <node>.node: no bucket, writer or grant of its own, and its key's change is what readers hear (issue 1126, ADR 0555 §3) 2026-10-26 18:29:45 +01:00
jschoubben 5b1e990eb4 The tunnel found at adoption and the names of the hub's peers are kept in the placement; whether the hub took it over and who enrolled under a peer's key are read from placement and records, as the controller reads them (issue 1126, ADR 0555) 2026-10-26 18:26:11 +01:00
jschoubben a431746c0e What a person decides of a node lives in its placement file and what the machine observes in its record; two nodes on one address or two hubs are refused (issue 1126, K2) 2026-10-26 18:17:59 +01:00
jschoubben 5db53e738f Name the catalogue repository whose placement files are read (issue 1126, ADR 0549 §1) 2026-10-26 18:10:25 +01:00
jschoubben 9f7f50de8d A merged message of the catalogue carries its placement files, so the indexer reads placement at the merge without asking the forge again (issue 1126, ADR 0549 §2) 2026-10-26 18:08:59 +01:00
jschoubben bdd4c7b875 The judgement of a move is a pure function in mesh-sdk, so a node agent judges what it composed against what it runs (issue 1126, ADR 0547 §3, ADR 0377 §3) 2026-10-26 18:07:20 +01:00
jschoubben e982072443 frombus fills the composition's input from placement, node records, the indexer's manifests and mesh-config's slice, so one composer serves the node agent, the check and the grants part (issue 1126, ADR 0547 §2, ADR 0549 §3) 2026-10-26 18:06:04 +01:00
jschoubben 146cd858a3 Placement and the node record have one layout in mesh-sdk, so the indexer, the node agents and the composition check read them alike (issue 1126, ADR 0549 §1-§2, ADR 0547 §2) 2026-10-26 18:02:13 +01:00
mesh-tools a7a1fbd34e Merge pull request 'A module waits for a state created after it starts (issue 1790)' (#97) from fix/1790-state-appears into main 2026-10-26 14:47:31 +00:00
mesh-tools 1888d1d230 Merge pull request 'A git or ssh-keygen failure keeps its own words (forgekey.Said)' (#96) from fix/fetch-reads-all into main 2026-10-26 14:47:25 +00:00
mesh-tools 960d6c34d2 Merge pull request 'The composer gives build modules the node's share: idle, capped builds (issue 1806, ADR 0553/0554, change B)' (#95) from fix/build-cpu-yields-compose into main 2026-10-26 14:31:13 +00:00
jschoubben ff81bdf8cc Keep git's own words in a fetch's reason: a bare "error:" line no longer stands for the forge's refusal
mesh/delivery merged: source.main moved to novox/mesh-sdk@1888d1d230b3
mesh/delivery-group group fix/fetch-reads-all merged: every member is merged
mesh/check pass ff81bdf8cc8f on 960d6c34d25e | unit 5815 run, 345 kept, 18 skipped | integration 5603 run, 640 kept, 29 skipped | over bound: unit, in…
The mirror of jschoubben/car-hunter on ace said only "git -C: error:": git writes a bare
"error:" before the forge's message, and the reason kept the first non-empty line. forgekey.Said
keeps every line that says something, leaves bare prefixes out, joins them and bounds them at
600 bytes; every git and ssh-keygen reason of buildstep and forgekey goes through it, and the
mirror's commands are named by their verb, not by -C.
2026-10-26 15:26:29 +01:00
jschoubben 65ee43f075 Merge remote-tracking branch 'origin/main' into fix/1790-state-appears
mesh/delivery merged: source.main moved to novox/mesh-sdk@a7a1fbd34e1d
mesh/delivery-group group fix/1790-state-appears merged: every member is merged
mesh/check being carried to 65ee43f07583 on a7a1fbd34e1d | from 1888d1d230b3, mesh-delivery feef96ed160e
2026-10-26 15:24:34 +01:00
jschoubben c3e8fc4ba6 process: an awaited bucket says loudly once when the bus refuses hearing its creation, and hears it again and binds at each reconnect (issue 1790, review) 2026-10-26 15:24:33 +01:00
mesh-tools 75be8acbad Merge pull request 'Build seats yield: the decoder, buildcpu and NodeLimits (issue 1806, ADR 0553/0554, change A)' (#94) from fix/build-cpu-yields into main 2026-10-26 14:22:47 +00:00
mesh-tools d103ed88ab Merge pull request 'forgekey: a command starts in a directory that exists (issue 734)' (#93) from fix/daemon-dir-kept into main 2026-10-26 14:15:57 +00:00
jschoubben 3ac644baef process: gofmt the integration fixture (issue 1790) 2026-10-26 15:13:25 +01:00
jschoubben fd7d93333e Keep a failed toolchain probe a loud refusal; only the command directory is fixed (issue 734, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-sdk@d103ed88ab17
mesh/delivery-group group fix/daemon-dir-kept merged: every member is merged
mesh/check checking fd7d93333e6b on 21252194d530 | novox/mesh-sdk#93 in group fix/daemon-dir-kept
Declining an ask whose probe failed would turn a truly broken toolchain into a
wait of up to the decline bound that then fails with an empty "needs a node
with" reason. With the node-engine no longer deleting a running build and
commands started in a directory that exists, the getwd cause is gone, so the
guard's refusal stays as it was. The decline refactor goes with it.
2026-10-26 15:11:04 +01:00
jschoubben d515c71e1c process: a bucket missing at a part's start is bound when the bus says it was created, keeping what was written meanwhile; buscompose grants each principal the creation of the buckets it may bind (issue 1790)
Since the grants part creates a manifest's buckets after a delivery (ADR 0547), a module restarted by that
delivery can open a new state before it exists; OptionalBucket answered nil and the part never looked again
(mesh-delivery's checker and builder, state hearing, 2026-10-26). AwaitBucket binds on the server's
STREAM.CREATED advisory, or at the next use, and writes the latest value kept per key. The copied composer's
agreement test leaves that one row out: the controller's list is no longer loaded since the take-over.
2026-10-26 15:11:00 +01:00
jschoubben 36061b5c56 Start commands in a directory that exists, and decline an ask whose toolchain could not be read (issue 734)
A build module runs in the directory its build was unpacked in. When the
node-engine replaced that build, every command the module started inherited a
deleted working directory, and the toolchain probe's failure became a final
refusal of the check that asked it (three checks on ace on 2026-10-26).

forgekey: ExecRunnerClean starts its command in the HOME it is given, or the
temporary directory; the other runners keep the process's directory while it
exists and use the temporary directory once it is gone.

buildstep: a guard whose probe failed answers a Refusal with Again set; the
puller declines such an ask (delivery.StepDeclined) so its asker puts it again
for another node or a later pull, instead of ending it refused. A version
mismatch stays a refusal.
2026-10-26 15:01:57 +01:00
jschoubben 4ef49d0391 Merge fix/build-cpu-yields into the composer half
mesh/delivery merged: source.main moved to novox/mesh-sdk@960d6c34d25e
mesh/check checking 4ef49d039195 on 960d6c34d25e
2026-10-26 14:46:11 +01:00
jschoubben 17b7cc480d Merge main into fix/build-cpu-yields
mesh/delivery merged: source.main moved to novox/mesh-sdk@75be8acbad4d
mesh/delivery-group group fix/build-cpu-yields merged: every member is merged
mesh/check checking 17b7cc480d6d on d103ed88ab17 | novox/mesh-sdk#94 in group fix/build-cpu-yields
2026-10-26 14:46:01 +01:00
jschoubben 6ac8c5c229 The composer gives a build seat's holder its node's one share, the node-engine's build-cpu-share (ADR 0553 §3, §4)
The second half of ADR 0553, merged only once the node-engine that decodes
yields runs on every node (§5). The share is the node-engine's own config as
the node's layers give it; each step divides it by its own module's capacity.
A share out of range bounds builds at the default and never refuses the
node's composition. A change of the share recomposes the build runtimes, which
restart once.
2026-10-26 14:42:20 +01:00
mesh-tools 21252194d5 Merge pull request 'A check ask can name needs and packages, and a node that lacks a need declines it (issue 890, ADR 0552)' (#91) from feat/890-placement into main 2026-10-26 13:40:35 +00:00
mesh-tools 545a0c9cb4 Merge pull request 'An asker of the build seats hears when its ask is given up, and the seat's users may read their own dead letters (issue 734)' (#92) from fix/734-lost-ask into main 2026-10-26 13:40:30 +00:00
jschoubben 527d87613e Without the composer's share a step has no thread limit; with it, the share over the module's own capacity (ADR 0553 round 2)
Review round 2: change A must not slow builds before any slice exists, so a
module given no share sets no GOMAXPROCS. The slice's quota bounds the total;
threads only size one step, from the node's share over the module's own
capacity (shanks: 24 x 50% / 3 = 4). The share is the node-engine's own config.
2026-10-26 14:39:39 +01:00
jschoubben 36c92861d2 Merge main into fix/build-cpu-yields 2026-10-26 14:22:26 +01:00
jschoubben 852ba81c5a Build seats yield to one shared slice per node; the composer half waits for the engines (ADR 0553, issue 1806)
Review round 1: the decoder type ships before any composer can emit it, so a
routine SDK bump never sends yields to an engine that refuses them; the
composer half moves to a later change. Every yielding process of a node goes
into one top-level slice, meshyield.slice, so the three build modules share one
quota instead of taking one each; the share is one node value, node-tools'
build-cpu-share, and each step's threads divide it by every build slot of the
node (words from the composer, a conservative fallback without them).
2026-10-26 14:20:05 +01:00
jschoubben 3203358c4c buildrun: an asker keeps whether it hears DEAD_LETTERS, and its health tool answers from it (issue 734, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-sdk@545a0c9cb43b
mesh/delivery-group group fix/734-lost-ask merged: every member is merged
mesh/check being carried to 3203358c4cb5 on 2aae6f5580f2 | from mesh-delivery c8b26735c8cf | novox/mesh-sdk#92 in group fix/734-lost-ask
A deaf asker must be loud: SayHearing keeps each Hearing in the asker's state hearing, and HearingHealth answers the
module's health tool from it, unhealthy with why while DEAD_LETTERS is not heard.
2026-10-26 14:18:41 +01:00
jschoubben 9b01c94a9e buildrun: the asker hears DEAD_LETTERS again on its next reconnect or ask, in the background, and says whether it hears; buscompose grants only what a pulled ordered consumer needs (issue 734, review round 1)
An asker started before its grant was composed was refused once and stayed deaf until it started again, silently.
DeadLetterHearing tries in the background and again on an event (Again: a reconnect, an ask waited for while deaf), and
tells each change of Hearing so the module's health tool can say it. DeadLetterReads drops the flow control a pull
consumer does not use and deletes one consumer by name.
2026-10-26 14:16:58 +01:00
jschoubben 33395f68b3 Merge main (issue 1890's step records) into feat/890-placement: a decline begins its step with what it is
mesh/delivery merged: source.main moved to novox/mesh-sdk@21252194d530
mesh/delivery-group group feat/890-placement merged: every member is merged
mesh/check checking 33395f68b309 on 2aae6f5580f2 | novox/mesh-sdk#91 in group feat/890-placement
2026-10-26 14:05:30 +01:00
jschoubben 45564e69a6 A placed check ask names the packages it tests, so the first ask's other packages are not run or counted again (issue 890, ADR 0552 §4; review round 1) 2026-10-26 14:01:05 +01:00
jschoubben d96b9fd660 buildrun: an ask the build seat gave up on ends its asker's wait as failed with why, for every asker of the build seats (issue 734)
The checker's try and the builder's lanes waited for ever on an ask given up during a bus reload: no build node writes a
step for it. LostAskWaiter hands the asker a failed step on the node bus when DEAD_LETTERS keeps the ask's copy
(HearDeadLetters, granted by DeadLetterReads), and under NotTakenBound when no node took the ask within the bound.
2026-10-26 14:00:46 +01:00
mesh-tools 2aae6f5580 Merge pull request 'buildstep: a build module ends the steps a previous process left running, and a step says what it is (issue 1890)' (#90) from fix/1890-left-running-steps into main 2026-10-26 12:57:31 +00:00
jschoubben 6b84973142 Build steps: an ask key past the step bound is ended, at start and on the module's round (issue 1890 review 2)
mesh/delivery merged: source.main moved to novox/mesh-sdk@2aae6f5580f2
mesh/delivery-group group fix/1890-left-running-steps merged: every member is merged
mesh/check pass 6b8497314297 on 2aae6f5580f2 | carried from b4cd95e7c4a4, mesh-catalog a4f5df797b68 | unit 3132 run, 3022 kept, 7 skipped | integratio…
2026-10-26 13:51:54 +01:00
jschoubben e7f0dc612e buscompose: a user of a seat pulled by kind reads its seat's dead letters, so an asker hears an ask given up (issue 734)
A check ask given up during a bus reload left its try running for ever: the asker had no grant to learn that
DEAD_LETTERS holds it. DeadLetterReads grants an ordered consumer on DEAD_LETTERS filtered to the seat stream's copies,
and nothing else of the stream.
2026-10-26 13:51:35 +01:00
jschoubben 76deab6c67 gofmt the cap's test (issue 1806) 2026-10-26 13:49:09 +01:00
jschoubben e66c6fea62 Build steps: test the ask key with a real ask id (issue 1890) 2026-10-26 13:43:05 +01:00
jschoubben 6b1c7a5613 Build steps: a restart leaves pulled asks to the stream, ends steps by compare-and-set, and step_end refuses a step this process started (issue 1890 review 1) 2026-10-26 13:42:50 +01:00
jschoubben 04902ff24a Build seats yield: idle priority and a per-node processor cap on their runtimes, threads per step within it (issue 1806)
The operator's desktop slows while it builds: the Go build runtime took 1650% of
shanks' 24 processors with nothing holding it back. A build seat's holder now
runs its runtime at idle priority under its node's cpu-cap share (declaration
Yields, composed from Seat.Yields and the module's cpu-cap setting), and
buildcpu/buildstep give each step its part of that cap in threads.
2026-10-26 13:37:00 +01:00
jschoubben bb71310337 Let a check ask name what its node needs, decline it where that is lacking, and leave unmet packages to such a node (issue 890)
A check ask's packages may need something only some build nodes have (mesh-build-node's integration tests read the
Node build module's installed toolchain). An ask now names its Needs; a holder lacking one ends the ask's step refused
as declined and drops the message for its asker to put again, skipping that worker until its next pull wait; a level's
packages whose declared needs the node lacks are left untested and named unmet, for the asker to place.
2026-10-26 13:35:12 +01:00
jschoubben 18e0a01c54 Build steps: EndStaleAtStart waits for the runtime and ends the left-running steps (issue 1890) 2026-10-26 13:25:58 +01:00
jschoubben ddafe1b1aa Build steps: a step records what it is, a restart ends the steps it left running, a tool ends one (issue 1890) 2026-10-26 13:25:13 +01:00
mesh-tools b4cd95e7c4 Merge pull request 'Take-over of the bus's user list by the grants part: sealed list layout, followed-list have, handed hashes, named readers (issue 1945)' (#89) from feat/1945-user-list-switch into main 2026-10-26 12:21:39 +00:00
mesh-tools 89c5838176 Merge pull request 'Delivery layouts for ADR 0548: merge turns, carried verdicts and the siblings a verdict read (issue 568)' (#88) from feat/548-one-check into main 2026-10-26 12:01:17 +00:00
jschoubben c2a410bb63 The have's user list says why the bus node does not watch the grants part's key (review of the switch, issue 1945)
mesh/delivery merged: source.main moved to novox/mesh-sdk@b4cd95e7c4a4
mesh/delivery-group group feat/1945-user-list-switch merged: every member is merged
mesh/check checking c2a410bb6376 on 89c5838176b2 | novox/mesh-sdk#89 in group feat/1945-user-list-switch
2026-10-26 12:25:27 +01:00
jschoubben af66adc180 Bring in the adoption handing (fix/1945-adopt-hashes 17478994): the have's user list carries Handed beside Follows and Key; a state that names readers is not Readable to a composer that knows no readers (review of the switch, issue 1945) 2026-10-26 12:23:07 +01:00
jschoubben 9c61918ac3 delivery: a turn never holds the check of heads that wait in it, so two approved merges in one turn never hold each other's carry (ADR 0548 §3; issue 568, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-sdk@89c5838176b2
mesh/delivery-group group feat/548-one-check merged: every member is merged
mesh/check checking 9c61918ac36f on 85fa8511c4b3 | novox/mesh-sdk#88 in group feat/548-one-check
2026-10-26 12:12:53 +01:00
jschoubben 83ee7769d8 Make the grants part's state in the switch's test as the part makes a declared state, by its stream (issue 1945) 2026-10-26 12:00:29 +01:00
jschoubben 175a5567b8 The bus node follows the grants part's sealed user list, and only the grants part reads the vault's hashes (ADR 0547 clauses 7, 9; issue 1945)
The grants part writes its list as an object of its own bucket, sealed to the bus node's key, and names it in
user-list.sealed; the node-engine of the node holding mesh-broker is granted that one key and an object by name, the
grants part its bucket (the controller's copy carries the same grants, byte for byte, golden regenerated there).
A state may name its readers (announce, NamedReaders): mesh-vault.bus-accounts is read by mesh-grants alone.
2026-10-26 11:53:11 +01:00
jschoubben 1747899419 delivery: the bus node hands the loaded list's hashes to the vault, sealed, in the bus module's have (ADR 0547 clause 9, issue 1945)
The vault holds no value for the controller's own account, the node accounts and the enrolled devices, so it cannot
hash them; the one part that has their hashes is the node-engine of the bus node, in the list the bus loaded. The have
of the bus module now carries that list's hashes sealed to the vault's key (UserListWritten.Handed), which every reader
of the have bucket sees as a count, a list digest and a key fingerprint, and only the vault opens. HandedAccountsOf
reads them from a composed list, HandAccounts seals, OpenHanded opens and refuses a box sealed to another key.
2026-10-26 11:28:38 +01:00
jschoubben f5c233a928 delivery: a verdict names the siblings it read and the commits it was carried from, and a composition its fingerprint set, so a trunk or sibling move is carried, not judged again (ADR 0548 §5, §6; issue 568 S2) 2026-10-26 11:25:07 +01:00
jschoubben 0abe480dba delivery: the gate's merge turns, so the checker holds new checks off a repository an approved merge waits on (ADR 0548 §3, §4; issue 568 S1) 2026-10-26 11:24:06 +01:00
mesh-tools 85fa8511c4 Merge pull request 'Build steps: run every test step of a level and name every failure in the verdict (issue 1921)' (#87) from fix/1921-every-failure-r into main 2026-10-26 10:18:05 +00:00
jschoubben 79649c3fa9 Run every test step of a check's level after a failure, and let a verdict name every failed test (issue 1921)
mesh/delivery merged: source.main moved to novox/mesh-sdk@85fa8511c4b3
mesh/delivery-group group fix/1921-every-failure-r merged: every member is merged
mesh/check checking 79649c3fa9f4 on c3544b6a17a2 | novox/mesh-sdk#87 in group fix/1921-every-failure-r
A check ask stopped its level at the first test step that did not pass,
so the packages after the changed ones never ran and a check found one
fault per round. Every step now runs; the level still fails, and no
later level runs (ADR 0484 clause 13). The verdict gains Failures, every
failed test with its first line, at most MaxFailures and a count.
2026-10-26 10:57:02 +01:00
mesh-tools c3544b6a17 Merge pull request 'buscompose: the grants part may create a placed module's declared buckets, logs and streams, creation only (issue 1790, ADR 0547 clause 1 exception)' (#86) from fix/1790-grants-create-buckets into main 2026-10-26 08:17:16 +00:00
jschoubben 136fa8b2a8 Say how a declared bucket and module stream are created, so the grants part makes the same stream the controller does
mesh/delivery merged: source.main moved to novox/mesh-sdk@c3544b6a17a2
mesh/delivery-group group fix/1790-grants-create-buckets merged: every member is merged
mesh/check checking 136fa8b2a896 on 095d41fa3e81 | novox/mesh-sdk#86 in group fix/1790-grants-create-buckets
BucketStreamConfig and ModuleStreamConfig are the stream configurations the controller's
CreateOrUpdateKeyValue (BucketConfig) and StreamConfigFor assert at its start, pure, for the
grants part's create of a bucket or stream a manifest declares and the bus lacks. A log already
has Log.Config. Never used for an update.

Issue: 1790
2026-10-26 09:04:43 +01:00
jschoubben 8f6ee2fd18 Let the grants part create the streams manifests declare, so a new module state no longer waits for a controller start
The copied bus composer grants mesh-grants' principal $JS.API.STREAM.CREATE.<stream> and
$JS.API.STREAM.INFO.<stream> for each bucket (KV_<module>_<state>), log (LOG_<module>_<log>) and
module stream a placed module's manifest declares, each by its own name: NATS wildcards are whole
tokens, so nothing narrower than an exact name exists and nothing wider is granted. The writers
table lets the grants part create, never update or delete. The operator allowed this one grant
on 2026-10-26 08:54, beside the memberships grant of 22:31 (ADR 0547 clause 1 and 5).

The expected lists were written again by the controller's own internal/broker with the same
change (mesh-control fix/1790-grants-create-buckets); a fixture now places mesh-grants beside the
builder's state, log and stream.

Issue: 1790
2026-10-26 09:01:32 +01:00
jschoubben 095d41fa3e Merge pull request 'buscompose: the controller's bus composer in the SDK, byte-equal to the controller's, with the mesh-grants grant (ADR 0547)' (#85) from fix/1945-memberships into main
Reviewed-on: #85
2026-10-25 22:43:57 +00:00
jschoubben b7b5c6ef27 The copied composer grants the grants part reading every membership, and its memberships are held to the controller's too (ADR 0547 §5, review of mesh-grants)
mesh/delivery merged: source.main moved to novox/mesh-sdk@095d41fa3e81
mesh/delivery-group group fix/1945-memberships merged: every member is merged
mesh/check failed: integration: github.com/novox/mesh-controller/cmd/mesh-controller.TestTheDeclarationsStreamGoesOnceEveryNodeRunsTheNewEngine: with …
The grants part reads each module's last membership before it publishes, so it needs the direct get on every
membership subject, as the controller's composer now grants (fix/1945-mesh-grants-publishes-memberships). The equality
test compares every membership the fixtures issue with the controller's MembershipFor, written by its own code.
2026-10-25 22:50:21 +01:00
mesh-tools 7ac4493ce9 Merge pull request 'process: open a part's buckets as essential (stop, naming the grant) or optional (log what is not heard, keep running) (issue 1945)' (#84) from fix/1945-ask-again-and-why into main 2026-10-25 21:43:08 +00:00
jschoubben 881da7f599 The copied bus composer grants the grants part publishing memberships, as the controller's does (ADR 0547 §5)
The same line as mesh-controller f67d1553; the fixtures name a mesh-grants module and the lists were written again by
the controller's own composer on that branch, so the copy is held byte-equal to it.
2026-10-25 22:33:20 +01:00
mesh-tools 3db051be57 Merge pull request 'A node's have says which bus user list it wrote: delivery.Have's user-list field (ADR 0547 bridge, issue 1945)' (#83) from fix/1945-user-list-follows into main 2026-10-25 21:16:03 +00:00
jschoubben e0d1de0475 process: a part opens what it cannot work without as essential, the rest as optional (issue 1945)
mesh/delivery merged: source.main moved to novox/mesh-sdk@7ac4493ce9ab
mesh/delivery-group group fix/1945-ask-again-and-why merged: every member is merged
mesh/check pass e0d1de047585 on 3db051be57ca | unit 1642 run, 752 kept, 2 skipped | integration 1710 run, 803 kept, 2 skipped | over bound: unit, inte…
The delivery builder exited twice on 2026-10-25 for a bucket the bus
refused at start, though it could build without it: a grant reaches the
bus with a delivery, so a refusal at start is often only a grant not
there yet. EssentialBucket stops a start naming the bucket and its grant;
OptionalBucket and NotWatched say what is not heard and go on. Here so
the seven delivery parts take one copy, not seven.
2026-10-25 22:12:20 +01:00
jschoubben b5f8604d03 Copy the controller's bus composer into mesh-sdk as buscompose, held byte for byte to the controller's lists (issue 1377, ADR 0547 §5)
The grants part composes the bus's user list with the composer the controller uses (ADR 0547 §5), copied rather than
moved: the controller is not changed and keeps its own until it loses the lease. Left out: the bus client, the broker's
address from the environment and the client's two methods; the lease's two subjects are copied in.

One table test holds the copy to four meshes' lists and the server golden, written by the controller's own
internal/broker from the same fixtures (testdata/controller-user-lists.json).
2026-10-25 21:54:58 +01:00
jschoubben 0be0ffd615 Compare the have's user list by what it says, so a have is not written again for the same list (ADR 0547 §8)
mesh/delivery merged: source.main moved to novox/mesh-sdk@3db051be57ca
mesh/delivery-group group fix/1945-user-list-follows merged: every member is merged
mesh/check pass 0be0ffd6159e on db26f7e8b6f5 | unit 1467 run, 1696 kept, 7 skipped | integration 1471 run, 1740 kept, 6 skipped | over bound: unit, in…
2026-10-25 21:47:30 +01:00
jschoubben f63fa86c74 Say in the bus module's have the user list its node wrote alone, so the bridge of ADR 0547 §8 reports what the bus loads (issue 1945) 2026-10-25 21:42:13 +01:00
mesh-tools db26f7e8b6 Merge pull request 'The put's words: delivery.Put, a want's put and hold marks, a grant that says which node's accounts it names (ADR 0546)' (#82) from feat/put-version into main 2026-10-25 20:35:28 +00:00
jschoubben d4ae4b6790 Say the put's tools, not a seat's verbs, in the put's words (ADR 0546)
mesh/delivery merged: source.main moved to novox/mesh-sdk@db26f7e8b6f5
mesh/check pass d4ae4b6790b8 on db26f7e8b6f5 | unit 1102 run, 680 kept, 2 skipped | integration 1103 run, 722 kept, 2 skipped | over bound: unit (slow…
2026-10-25 21:08:10 +01:00
jschoubben 2f6741b7f8 No seat and no compose verb for the put: the controller takes no change, so the assigner serves the put as its own tools from compositions the controller already writes (ADR 0546) 2026-10-25 21:07:58 +01:00
jschoubben 20a6dc4920 A grant says whether it names a module's accounts on a node, for the assigner and the compose verb (ADR 0546) 2026-10-25 20:50:51 +01:00
jschoubben c4f37c279a The assigner's seat with put, release and puts, the controller's compose verb, a put's key and value, and the view answers no option that authorises (ADR 0546) 2026-10-25 20:49:29 +01:00
jschoubben 7f89cb51de Merge pull request 'Keep a build node's pause in a state of its own, and pull as not paused when the pause cannot be read (steps bucket full, 2026-10-25)' (#81) from fix/steps-bucket-ages into main
Reviewed-on: #81
2026-10-25 18:06:32 +00:00
jschoubben 518dae5ec9 Pull as not paused when the pause cannot be read, and say so once, instead of ending the puller silently
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/delivery-group group fix/steps-bucket-ages held: a member is held: novox/mesh-sdk@518dae5ec9c8 (merged without a passing check: only a person decides what…
mesh/check checking 518dae5ec9c8 on 7f89cb51de15 | novox/mesh-sdk#81 in group fix/steps-bucket-ages
A Load error ended Pull, and the modules run it in a goroutine whose error nobody reads: every
build node would stop pulling when the new pauses state could not be read (or when Pull reached
restore before Serve). A node kept from building is held by capacity 0, not by a pause.
2026-10-25 18:56:17 +01:00
jschoubben e8f8ef63fa Keep a build node's pause in a pauses state of its own, so the steps state can drop its old keys without ending a pause
The steps state of the build modules filled to the bus's 64 MiB cap (27,370 keys of mesh-build-go,
none ever removed) and refused a passed check's end. Its keys get an age in the catalogue; the
pause key lived among them and would then have ended by itself. Puller.PausePut writes it to
delivery.PausesState instead; Put stays the fallback for a module not yet moved.
2026-10-25 18:51:04 +01:00
jschoubben 4f4d5ca9c9 Merge pull request 'Wake a parked build puller when its capacity changes, so a node raised from capacity 0 takes asks (issue 1887)' (#80) from fix/puller-wakes-on-capacity into main
Reviewed-on: #80
2026-10-25 17:10:33 +00:00
jschoubben a8fbabbfc3 Fold the puller-wake and Served tests into existing tests to stay within the test budget (issue 1887)
mesh/delivery merged: source.main moved to novox/mesh-sdk@4f4d5ca9c956
mesh/delivery-group group fix/puller-wakes-on-capacity merged: every member is merged
mesh/check pass a8fbabbfc373 on 4f4d5ca9c956 | unit 1102 run, 679 kept, 2 skipped | integration 1103 run, 721 kept, 2 skipped | over bound: unit, inte…
2026-10-25 17:44:03 +01:00
jschoubben b9911dc1c1 Start the puller's capacity watch once Serve has the runtime's channel, since a build module pulls before it serves (issue 1887)
A watch asked before Serve is refused, which would have left every build
module on its settings file again. stdio.Served closes when Serve has the
channel; WatchConfigServed waits on it, and Pull starts the watch beside its
loop.
2026-10-25 17:26:10 +01:00
jschoubben e6b2df6506 Wake a parked build puller on its capacity's own change, so a node started at capacity 0 takes asks once raised (issue 1887)
g14's Go builder sat idle at capacity 2 while delivery builds waited: a puller
with no room waited on its wake alone, which only a pause, a resume or a step's
end gives, and it read its capacity from the settings file only when woken.
The puller now follows the module's config value capacity through the
runtime's watch of what mesh-config resolved (stdio.WatchConfig), uses the
value it hands, and wakes on each change: no timer, no file watch, and no wait
for the node-engine to fill the file again. It says when it parks and when it
wakes, with its capacity.
2026-10-25 17:23:28 +01:00
587 changed files with 19742 additions and 60630 deletions
-1
View File
@@ -1 +0,0 @@
example
-8
View File
@@ -1,8 +0,0 @@
CFLAGS ?= -O2 -Wall -Wextra
JANSSON := $(shell pkg-config --cflags --libs jansson)
example: example.c mesh_sdk.c mesh_sdk.h
$(CC) $(CFLAGS) -o $@ example.c mesh_sdk.c $(JANSSON)
clean:
rm -f example
-9
View File
@@ -1,9 +0,0 @@
# mesh-sdk for C
The skeleton (novox/hq ADR 0188): `mesh_serve(name, tools, count)` speaks MCP over stdio to the
node's tool runtime. A `mesh_tool` is a name, a description, an argument schema and a function
returning JSON. A tool named `<seat>.<verb>` is the seat's implementation. JSON is jansson's, the
one dependency.
What it holds, under ADR 0039's test: the stdio loop and the tool type. Nothing else — no bus, no
client, nothing of any module's. `make` and pipe a `tools/list` request into `./example`.
-27
View File
@@ -1,27 +0,0 @@
/* A tools bundle in C: one tool of its own and one seat verb, served over stdio. */
#include "mesh_sdk.h"
#include <stdio.h>
static json_t *greet(json_t *args, char **error) {
(void)error;
const char *who = json_string_value(json_object_get(args, "who"));
char greeting[128];
snprintf(greeting, sizeof greeting, "hello %s", who ? who : "world");
return json_pack("{s:s,s:s}", "greeting", greeting, "language", "c");
}
static json_t *on(json_t *args, char **error) {
(void)args; (void)error;
return json_pack("{s:b,s:s}", "on", 1, "language", "c");
}
int main(void) {
json_t *who = json_pack("{s:{s:s,s:s}}", "who", "type", "string", "description", "whom");
const mesh_tool tools[] = {
{"greet", "say hello", who, greet},
{"node-lamp.on", "the seat's verb", NULL, on},
};
int rc = mesh_serve("c-example", tools, 2);
json_decref(who);
return rc;
}
-83
View File
@@ -1,83 +0,0 @@
#include "mesh_sdk.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static void say(json_t *m) {
json_object_set_new(m, "jsonrpc", json_string("2.0"));
char *text = json_dumps(m, JSON_COMPACT);
fprintf(stdout, "%s\n", text);
fflush(stdout);
free(text);
json_decref(m);
}
static json_t *as_schema(json_t *given) {
if (!given || !json_is_object(given) || json_object_size(given) == 0)
return json_pack("{s:s,s:{}}", "type", "object", "properties");
if (json_object_get(given, "type") || json_object_get(given, "properties"))
return json_incref(given);
return json_pack("{s:s,s:O}", "type", "object", "properties", given);
}
int mesh_serve(const char *name, const mesh_tool *tools, size_t count) {
char *line = NULL;
size_t cap = 0;
ssize_t got;
while ((got = getline(&line, &cap, stdin)) >= 0) {
json_error_t err;
json_t *req = json_loads(line, 0, &err);
if (!req) continue;
json_t *id = json_object_get(req, "id");
const char *method = json_string_value(json_object_get(req, "method"));
json_t *params = json_object_get(req, "params");
if (!method) { json_decref(req); continue; }
if (strcmp(method, "initialize") == 0) {
say(json_pack("{s:O,s:{s:s,s:{s:{}},s:{s:s,s:s}}}", "id", id ? id : json_null(), "result",
"protocolVersion", MESH_SDK_PROTOCOL, "capabilities", "tools",
"serverInfo", "name", name, "version", "1"));
} else if (strcmp(method, "notifications/initialized") == 0) {
} else if (strcmp(method, "ping") == 0) {
if (id) say(json_pack("{s:O,s:{}}", "id", id, "result"));
} else if (strcmp(method, "tools/list") == 0) {
json_t *listed = json_array();
for (size_t i = 0; i < count; i++)
json_array_append_new(listed, json_pack("{s:s,s:s,s:o}", "name", tools[i].name,
"description", tools[i].description, "inputSchema", as_schema(tools[i].input)));
say(json_pack("{s:O,s:{s:o}}", "id", id ? id : json_null(), "result", "tools", listed));
} else if (strcmp(method, "tools/call") == 0) {
const char *wanted = params ? json_string_value(json_object_get(params, "name")) : NULL;
const mesh_tool *tool = NULL;
for (size_t i = 0; wanted && i < count; i++)
if (strcmp(tools[i].name, wanted) == 0) tool = &tools[i];
if (!tool) {
char message[256];
snprintf(message, sizeof message, "%s has no tool %s", name, wanted ? wanted : "");
say(json_pack("{s:O,s:{s:i,s:s}}", "id", id ? id : json_null(), "error", "code", -32602, "message", message));
} else {
json_t *args = params ? json_object_get(params, "arguments") : NULL;
json_t *empty = args ? NULL : json_object();
char *error = NULL;
json_t *result = tool->run(args ? args : empty, &error);
if (empty) json_decref(empty);
if (result) {
char *text = json_dumps(result, JSON_COMPACT);
say(json_pack("{s:O,s:{s:[{s:s,s:s}]}}", "id", id ? id : json_null(), "result", "content", "type", "text", "text", text));
free(text);
json_decref(result);
} else {
say(json_pack("{s:O,s:{s:[{s:s,s:s}],s:b}}", "id", id ? id : json_null(), "result", "content",
"type", "text", "text", error ? error : "the tool failed", "isError", 1));
free(error);
}
}
} else if (id) {
char message[256];
snprintf(message, sizeof message, "no %s", method);
say(json_pack("{s:O,s:{s:i,s:s}}", "id", id, "error", "code", -32601, "message", message));
}
json_decref(req);
}
free(line);
return ferror(stdin) ? -1 : 0;
}
-29
View File
@@ -1,29 +0,0 @@
/* The Novox Mesh SDK for C — the skeleton (novox/hq ADR 0188).
*
* A tools bundle is a process the node's runtime launches and speaks MCP over stdio to:
* `initialize`, `tools/list` once, `tools/call` per call, newline-framed JSON-RPC on stdin and
* stdout. This is the loop, so a bundle writes its tools and nothing else. A tool named
* `<seat>.<verb>` is the module's implementation of that seat's verb; any other name is the
* module's own. JSON is jansson's.
*
* No transport here: the bus, the subjects and the memberships are the runtime's (ADR 0039, 0175).
* stdout is the protocol; log to stderr. */
#ifndef MESH_SDK_H
#define MESH_SDK_H
#include <jansson.h>
#define MESH_SDK_PROTOCOL "2025-03-26"
/* One tool. `run` returns a new JSON value (the answer) or NULL with *error set to a message the
* caller frees. `input` is the schema of the arguments, or NULL for a tool that takes nothing. */
typedef struct {
const char *name;
const char *description;
json_t *input;
json_t *(*run)(json_t *args, char **error);
} mesh_tool;
/* Serve these tools on stdin/stdout until stdin closes. Returns 0, or -1 on an I/O fault. */
int mesh_serve(const char *name, const mesh_tool *tools, size_t count);
#endif
+1 -1
View File
@@ -16,6 +16,6 @@ at the same version as the TypeScript package.
What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol
`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one `mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one
answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here and the TypeScript `@novox/mesh-sdk/seal` makes. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it. answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here. Nothing else — no bus, no client, nothing of any module's.
From release 4a of design 47 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4) it also holds the composition of a machine's declaration, over a plain input and with nothing behind it: `declaration` and `validate`, the node-engine's parser and its validator, copied from mesh-host; `catalogue`, the manifests, their resolution and rendering, and `overlay`, the private network's generator, copied from mesh-controller; `compose`, the controller's planFor, renderingFor and declarationWith as functions of an `Input` (the composition never allocates, makes, keeps or writes: what a send would do it names as foreseen or returns); `sdkversion`, which mesh-sdk version a program was built with; and, in `delivery`, the layouts of the keys the builder, the checker and the indexer write (the source fingerprint's new form, the builds, the verdicts and kept verdicts, the manifests by digest). None of them holds a bus, a store or a vault client, which `go list -deps` of `compose` shows. From release 4a of design 47 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4) it also holds the composition of a machine's declaration, over a plain input and with nothing behind it: `declaration` and `validate`, the node-engine's parser and its validator, copied from mesh-host; `catalogue`, the manifests, their resolution and rendering, and `overlay`, the private network's generator, copied from mesh-controller; `compose`, the controller's planFor, renderingFor and declarationWith as functions of an `Input` (the composition never allocates, makes, keeps or writes: what a send would do it names as foreseen or returns); `sdkversion`, which mesh-sdk version a program was built with; and, in `delivery`, the layouts of the keys the builder, the checker and the indexer write (the source fingerprint's new form, the builds, the verdicts and kept verdicts, the manifests by digest). None of them holds a bus, a store or a vault client, which `go list -deps` of `compose` shows.
+32 -6
View File
@@ -24,16 +24,13 @@ const Event = "states"
// account may already publish to and the events stream keeps. // account may already publish to and the events stream keeps.
func Subject(module string) string { return "mesh.mod." + module + ".event." + Event } func Subject(module string) string { return "mesh.mod." + module + ".event." + Event }
// SubjectAll is every module's announcement, what the controller consumes. // SubjectAll is every module's announcement.
const SubjectAll = "mesh.mod.*.event." + Event const SubjectAll = "mesh.mod.*.event." + Event
// KeptBucket is the controller's bucket of the last announcement of each module, by Key.
const KeptBucket = "mesh-controller_states-announced"
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// Key is module's key in KeptBucket: the module itself, which must be one plain name, since a module's name is part of // Key is module's key where the last announcement of each module is kept: the module itself, which must be one plain name, since a module's name is part of
// its buckets' names (catalogue.StateProblems). // its buckets' names (the indexer's judge, novox/hq ADR 0578 §1).
func Key(module string) (string, error) { func Key(module string) (string, error) {
if !name.MatchString(module) { if !name.MatchString(module) {
return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module) return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module)
@@ -46,6 +43,10 @@ func Key(module string) (string, error) {
type State struct { type State struct {
Name string `json:"name"` Name string `json:"name"`
Readable bool `json:"readable,omitempty"` Readable bool `json:"readable,omitempty"`
// Readers, when named, are the only modules that may read the state (novox/hq ADR 0547 clause 9, issue 1945): a
// state readable by some and not all, such as the vault's bus account hashes. Only ReadableBy grants them; Readable,
// which a composer that knows no readers asks, stays false for it, so such a composer grants nobody.
Readers []string `json:"readers,omitempty"`
} }
// States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted // States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted
@@ -67,6 +68,26 @@ func (s States) Keeps(state string) bool {
return false return false
} }
// ReadableBy says whether the module announced the state readable by reader: readable, and reader among the readers
// when it names any.
func (s States) ReadableBy(state, reader string) bool {
for _, k := range s.States {
if k.Name != state {
continue
}
if len(k.Readers) > 0 {
for _, r := range k.Readers {
if r == reader {
return true
}
}
return false
}
return k.Readable
}
return false
}
// Readable says whether the module announced the state readable by others. // Readable says whether the module announced the state readable by others.
func (s States) Readable(state string) bool { func (s States) Readable(state string) bool {
for _, k := range s.States { for _, k := range s.States {
@@ -93,6 +114,11 @@ func (s *States) check() error {
return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name) return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name)
} }
seen[k.Name] = true seen[k.Name] = true
for _, r := range k.Readers {
if !name.MatchString(r) {
return fmt.Errorf("announce: %s names %q as a reader of %s, and a reader is a module: lower-case letters, digits and hyphens", s.Module, r, k.Name)
}
}
} }
return nil return nil
} }
+9 -5
View File
@@ -9,7 +9,8 @@ import (
// own event subject, in a versioned value the controller keeps by module. // own event subject, in a versioned value the controller keeps by module.
func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) { func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{ data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{
{Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"}}}) {Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"},
{Name: "hashes", Readers: []string{"mesh-grants"}}}})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -24,21 +25,23 @@ func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
for _, s := range back.States { for _, s := range back.States {
names = append(names, s.Name) names = append(names, s.Name)
} }
if strings.Join(names, ",") != "assignments,own,plans-building" { if strings.Join(names, ",") != "assignments,hashes,own,plans-building" {
t.Fatalf("the states are not sorted: %v", names) t.Fatalf("the states are not sorted: %v", names)
} }
if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") { if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") {
t.Fatal("Readable does not say what was announced readable") t.Fatal("Readable does not say what was announced readable")
} }
// Named readers (ADR 0547 clause 9): readable by them alone, and not readable to a composer that knows no readers.
if back.Readable("hashes") || !back.ReadableBy("hashes", "mesh-grants") || back.ReadableBy("hashes", "spy") ||
!back.ReadableBy("plans-building", "spy") || back.ReadableBy("own", "spy") {
t.Fatal("ReadableBy does not say who was named")
}
if !back.Keeps("own") || back.Keeps("absent") { if !back.Keeps("own") || back.Keeps("absent") {
t.Fatal("Keeps does not say what was announced") t.Fatal("Keeps does not say what was announced")
} }
if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" { if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" {
t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll) t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll)
} }
if KeptBucket != "mesh-controller_states-announced" {
t.Fatalf("the kept bucket is %q", KeptBucket)
}
if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" { if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" {
t.Fatalf("Key = %q, %v", k, err) t.Fatalf("Key = %q, %v", k, err)
} }
@@ -57,6 +60,7 @@ func TestAnAnnouncementRefusesWhatNoBucketCouldBe(t *testing.T) {
{States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`}, {States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`},
{States{Module: "a", States: []State{{Name: ""}}}, `""`}, {States{Module: "a", States: []State{{Name: ""}}}, `""`},
{States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"}, {States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"},
{States{Module: "a", States: []State{{Name: "x", Readers: []string{"b.c"}}}}, `"b.c"`},
} { } {
if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) { if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says) t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says)
+5
View File
@@ -571,3 +571,8 @@ type ProofAnswer struct {
Accepted bool `json:"accepted"` Accepted bool `json:"accepted"`
Words string `json:"words"` Words string `json:"words"`
} }
// EngineAsker is the asker name a node's engine asks the operator under (novox/hq ADR 0563 §3, which changes ADR 0259
// §2 to admit a node's engine as an asker): `node-engine-<node>`, the last token of the by-caller subjects granted to that
// node's engine account alone, so no engine acts on another node's warrant.
func EngineAsker(node string) string { return "node-engine-" + node }
-421
View File
@@ -1,421 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"fmt"
"path"
"slices"
"sort"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **One ask, one whole job** (novox/hq ADR 0525 §3). A build module runs a pulled ask with its own step tools' bodies,
// the same ones its request and reply tools run (ADR 0525 §8), in the order the job names: a build ask fetches, then
// builds or copies each artifact; a check ask fetches, digests the level, takes the kept verdicts and tests the rest,
// the packages whose tests the change touches first. The answers are the tools' own, whole, so the asker judges them as
// it judged the tools' answers.
// StepFunc is one step tool's body: its arguments in, its answer out.
type StepFunc func(ctx context.Context, args map[string]any) (any, error)
// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's.
type AskSteps struct {
Fetch, Build, Copy, Digest, Test StepFunc
// Toolchain is the toolchain the module builds with now; a build ask naming another is refused. Nil checks nothing
// (an image or an archive names none).
Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error)
// Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is
// kept and how many tests it counted. Nil takes none.
Kept func(ctx context.Context, level, digest string) (tests int, found bool, err error)
// Release removes a workspace once the ask's steps on it ended (the module's release tool's body); nil leaves it to
// the module's sweep.
Release func(workspace string) error
Logf func(format string, args ...any)
}
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) {
if ask.Build != nil {
return steps.build(ctx, *ask.Build)
}
if ask.Check != nil {
return steps.check(ctx, *ask.Check)
}
return nil, Refuse("the ask %s holds no job", ask.ID)
}
func raw(v any) (json.RawMessage, error) {
if r, ok := v.(json.RawMessage); ok {
return r, nil
}
return json.Marshal(v)
}
// fetched is what an ask reads of a fetch's answer.
type fetched struct {
Workspace string `json:"workspace"`
Merge string `json:"merge"`
Conflicts []string `json:"conflicts"`
Changed []string `json:"changed"`
Modules []string `json:"modules"`
}
func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessage, fetched, error) {
if s.Fetch == nil {
return nil, fetched{}, Refuse("this build module fetches nothing")
}
out, err := s.Fetch(ctx, args)
if err != nil {
return nil, fetched{}, err
}
r, err := raw(out)
if err != nil {
return nil, fetched{}, err
}
var f fetched
if err := json.Unmarshal(r, &f); err != nil || f.Workspace == "" {
return r, f, fmt.Errorf("the fetch answered no workspace: %s", r)
}
return r, f, nil
}
func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) {
answer := delivery.BuildAnswer{}
if job.Toolchain != nil && s.Toolchain != nil {
tc, err := s.Toolchain(ctx)
if err != nil {
return nil, err
}
if tc.Version != job.Toolchain.Version || tc.Recipe != job.Toolchain.Recipe {
return nil, Refuse("the ask is for %s (recipe %s) and this node builds with %s (recipe %s)",
job.Toolchain.Version, job.Toolchain.Recipe, tc.Version, tc.Recipe)
}
answer.Toolchain = &tc
}
var f fetched
defer func() { s.release(f.Workspace) }()
for _, a := range job.Artifacts {
if a.From == "" && f.Workspace == "" {
r, got, err := s.fetch(ctx, job.Fetch)
if err != nil {
return nil, err
}
if _, candidate := job.Fetch["base"]; candidate {
// A candidate is built from its head merged onto the trunk; a head that does not merge leaves the tree at the
// trunk, and building that would store the trunk's build under the candidate's fingerprint.
if len(got.Conflicts) > 0 {
return nil, fmt.Errorf("the head does not merge into the trunk: files in conflict: %s", strings.Join(got.Conflicts, ", "))
}
if got.Merge == "" {
return nil, fmt.Errorf("the fetch with a base answered no merge: %s", r)
}
}
f = got
}
var (
out any
err error
)
if a.From != "" {
if s.Copy == nil {
return nil, Refuse("this build module copies no artifact (%s)", a.Artifact)
}
out, err = s.Copy(ctx, map[string]any{"module": job.Module, "artifact": a.Artifact, "from": a.From})
} else {
if s.Build == nil {
return nil, Refuse("this build module builds no artifact (%s)", a.Artifact)
}
args := map[string]any{"workspace": f.Workspace, "module": job.Path, "artifact": a.Artifact}
if job.Publish {
args["publish"] = true
}
out, err = s.Build(ctx, args)
}
if err != nil {
return nil, fmt.Errorf("%s: %w", a.Artifact, err)
}
r, err := raw(out)
if err != nil {
return nil, err
}
var built struct {
Digest string `json:"digest"`
Reference string `json:"reference"`
}
if err := json.Unmarshal(r, &built); err != nil || built.Digest == "" {
return nil, fmt.Errorf("the build of %s answered no digest: %s", a.Artifact, r)
}
answer.Artifacts = append(answer.Artifacts, delivery.AskBuilt{Artifact: a.Artifact, Digest: built.Digest, Reference: built.Reference})
}
return answer, nil
}
func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) {
r, f, err := s.fetch(ctx, job.Fetch)
if err != nil {
return nil, err
}
defer s.release(f.Workspace)
answer := delivery.CheckAnswer{Fetch: r}
if len(f.Conflicts) > 0 {
return answer, nil // the asker reads the conflict from the fetch's answer: a verdict, not an error
}
if s.Test == nil {
return nil, Refuse("this build module tests nothing")
}
runner := ""
if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) {
runner = job.Runner
}
// The levels run side by side (issue 1921, try speed S10; the operator's yes to D1, ADR 0541): the integration level
// does not wait for the unit level. They are answered in their order, so the first level that did not pass is still
// the first failure the asker reads (ADR 0484 clause 13); the levels after it are kept in the answer for the log.
type ran struct {
la delivery.LevelAnswer
passed bool
err error
}
runs := make([]ran, len(job.Levels))
var wg sync.WaitGroup
for i, level := range job.Levels {
wg.Add(1)
go func() {
defer wg.Done()
runs[i].la = delivery.LevelAnswer{Level: level}
runs[i].passed, runs[i].err = s.level(ctx, job, level, f, runner, &runs[i].la)
}()
}
wg.Wait()
failed := false
for _, r := range runs {
if r.err != nil {
if failed {
continue // a level after the first failure that could not run: the failure is the answer
}
answer.Levels = append(answer.Levels, r.la)
return nil, r.err
}
answer.Levels = append(answer.Levels, r.la)
failed = failed || !r.passed
}
return answer, nil
}
// level runs one level of a check ask and says whether it passed.
func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string, f fetched, runner string, la *delivery.LevelAnswer) (bool, error) {
withRunner := func(args map[string]any) map[string]any {
if runner != "" {
args["runner"] = runner
}
return args
}
test := func(args map[string]any) (bool, error) {
out, err := s.Test(ctx, args)
if err != nil {
return false, err
}
r, err := raw(out)
if err != nil {
return false, err
}
la.Tests = append(la.Tests, r)
var t struct {
Passed bool `json:"passed"`
}
_ = json.Unmarshal(r, &t)
return t.Passed, nil
}
// Every test step of the level runs, also after one that did not pass, so one check names every failing test of the
// level and not only the first (issue 1921); the level as a whole passes only when each of its steps did.
all := true
if s.Digest == nil {
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
for _, priority := range []bool{true, false} {
for _, dir := range f.Modules {
if changesTestIn(f.Changed, dir) != priority {
continue
}
passed, err := test(withRunner(map[string]any{"workspace": f.Workspace, "module": dir, "level": level}))
if err != nil {
return false, fmt.Errorf("the %s level of %s: %w", level, dir, err)
}
all = all && passed
}
}
return all, nil
}
todo, whole, keepRunner := s.plan(ctx, job, level, f, runner, withRunner, la)
if !keepRunner {
runner = "" // an installed module that ignores the runner judges the level as it judges any other (ADR 0535 §2)
}
for _, priority := range []bool{true, false} {
args := withRunner(map[string]any{"workspace": f.Workspace, "level": level})
if job.Run != "" {
args["run"] = job.Run
}
if whole {
if priority {
continue // nothing to put first: the level runs whole after
}
} else {
var run []string
for _, p := range todo {
if changesTestOf(f.Changed, p) == priority {
run = append(run, p)
}
}
if len(run) == 0 {
continue
}
args["packages"] = run
}
passed, err := test(args)
if err != nil {
return false, fmt.Errorf("the %s level: %w", level, err)
}
all = all && passed // the packages left still run (issue 1921)
}
return all, nil
}
// release removes an ask's workspace, saying a failure: the module's sweep removes it later.
func (s AskSteps) release(workspace string) {
if workspace == "" || s.Release == nil {
return
}
if err := s.Release(workspace); err != nil && s.Logf != nil {
s.Logf("the workspace %s is left to the sweep: %v", workspace, err)
}
}
// plan digests the level and takes the kept verdicts: the packages left to test, or all when the level runs whole, and
// whether the runner stays. A try of some tests runs whole, digesting nothing (a partial run proves no package). Under the
// candidate's runner the digest is asked as the candidate: when the candidate answers it, every package runs and nothing
// is digested for keeping (ADR 0535 §4); when the installed module answers it (it does not take the runner), the level is
// planned as any other and the runner is dropped. The level runs whole and takes nothing when the digest names no package
// or does not cover the siblings the fetch placed (ADR 0444, L5).
func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string, f fetched, runner string, withRunner func(map[string]any) map[string]any, la *delivery.LevelAnswer) ([]string, bool, bool) {
if job.Run != "" {
return nil, true, true
}
out, err := s.Digest(ctx, withRunner(map[string]any{"workspace": f.Workspace, "level": level}))
if err != nil {
if s.Logf != nil {
s.Logf("no digest of the %s level, so it runs whole: %v", level, err)
}
return nil, true, true
}
r, err := raw(out)
if err != nil {
return nil, true, true
}
var d delivery.LevelDigest // packages kept as a sorted list, never keyed by a package path (issue 1930)
if err := json.Unmarshal(r, &d); err != nil {
return nil, true, true
}
if runner != "" {
if strings.HasPrefix(d.JudgedBy, "candidate ") {
la.Judged = d.JudgedBy
return nil, true, true // the candidate decides which verdicts would be taken: all run, nothing kept
}
runner = ""
}
if len(d.Packages) == 0 || !slices.Equal(d.Beside, besideOf(job.Fetch)) {
return nil, true, runner != "" // a digest that names nothing or not the siblings keys no verdict: the level runs whole
}
la.Digest = &d
var todo []string
for _, pd := range d.Packages {
p := pd.Package
if job.Kept && s.Kept != nil {
tests, found, err := s.Kept(ctx, level, pd.Digest)
if err != nil && s.Logf != nil {
s.Logf("the kept verdict of %s at %s cannot be read, so it runs: %v", p, level, err)
}
if err == nil && found {
la.Kept = append(la.Kept, delivery.KeptTaken{Package: p, Digest: pd.Digest, Tests: tests})
continue
}
}
todo = append(todo, p)
}
return todo, false, false
}
// besideOf are the siblings a fetch's arguments place, as a digest echoes them: `<owner/repo> <commit>`, sorted.
func besideOf(fetch map[string]any) []string {
raw, err := json.Marshal(fetch["beside"])
if err != nil {
return nil
}
var refs []struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
}
_ = json.Unmarshal(raw, &refs)
var out []string
for _, r := range refs {
out = append(out, r.Repository+" "+r.Commit)
}
sort.Strings(out)
return out
}
// changesUnder says whether a path the merge changes is under prefix.
func changesUnder(changed []string, prefix string) bool {
for _, p := range changed {
if strings.HasPrefix(p, prefix) {
return true
}
}
return false
}
// isTestFile says whether a repository-relative path is a test file: Go's _test.go, and a TypeScript module's .test.
// and .spec. files and test directories.
func isTestFile(p string) bool {
switch {
case strings.HasSuffix(p, "_test.go"):
return true
case strings.Contains(p, ".test.") || strings.Contains(p, ".spec."):
return true
}
for _, seg := range strings.Split(path.Dir(p), "/") {
if seg == "__tests__" || seg == "test" || seg == "tests" {
return true
}
}
return false
}
// changesTestOf says whether the merge changes a test file of the Go package with the import path pkg: the file's
// directory and the import path agree on their last segments.
func changesTestOf(changed []string, pkg string) bool {
for _, f := range changed {
if !strings.HasSuffix(f, "_test.go") {
continue
}
dir := path.Dir(f)
if dir == pkg || strings.HasSuffix(pkg, "/"+dir) || strings.HasSuffix(dir, "/"+pkg) {
return true
}
}
return false
}
// changesTestIn says whether the merge changes a test file inside the directory of a Node module.
func changesTestIn(changed []string, dir string) bool {
for _, f := range changed {
if !isTestFile(f) || strings.HasSuffix(f, "_test.go") {
continue
}
if dir == "." || dir == "" || strings.HasPrefix(f, strings.TrimSuffix(dir, "/")+"/") {
return true
}
}
return false
}
-60
View File
@@ -1,60 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1921/S10 (the operator's yes to D1, ADR 0541): a check's levels run side by side: the integration level has started
// before the unit level ends. The answer holds the levels in their order, so with the unit level failing it still names
// the unit level first; the integration level's answer is kept after it for the log.
func TestACheckRunsItsLevelsSideBySideAndAnswersThemInOrder(t *testing.T) {
integrationStarted := make(chan struct{})
var once sync.Once
var mu sync.Mutex
unitWaited := false
steps := AskSteps{
Fetch: func(context.Context, map[string]any) (any, error) {
return map[string]any{"workspace": "/w", "modules": []string{"."}}, nil
},
Test: func(ctx context.Context, args map[string]any) (any, error) {
switch args["level"] {
case delivery.LevelUnit:
select {
case <-integrationStarted:
mu.Lock()
unitWaited = true
mu.Unlock()
case <-time.After(2 * time.Second):
}
return map[string]any{"passed": false, "level": "unit"}, nil
default:
once.Do(func() { close(integrationStarted) })
return map[string]any{"passed": true, "level": "integration"}, nil
}
},
}
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
mu.Lock()
defer mu.Unlock()
if !unitWaited {
t.Error("the integration level did not start while the unit level ran")
}
a := out.(delivery.CheckAnswer)
if len(a.Levels) != 2 || a.Levels[0].Level != delivery.LevelUnit || a.Levels[1].Level != delivery.LevelIntegration {
t.Fatalf("levels %+v, want unit then integration", a.Levels)
}
var first struct{ Passed bool }
if err := json.Unmarshal(a.Levels[0].Tests[0], &first); err != nil || first.Passed {
t.Errorf("the first level's result %s, want the unit level's failure", a.Levels[0].Tests[0])
}
}
-255
View File
@@ -1,255 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
const runCommit = "0123456789abcdef0123456789abcdef01234567"
// recorder is a build module's step tools that record each call.
type recorder struct {
mu sync.Mutex // a check's levels run side by side (issue 1921, S10)
calls []string
fetch map[string]any
tests map[string]bool // package or module directory -> passed
digests map[string]string
}
func (r *recorder) steps() AskSteps {
call := func(name string, answer func(args map[string]any) any) StepFunc {
return func(ctx context.Context, args map[string]any) (any, error) {
b, _ := json.Marshal(args)
r.mu.Lock()
r.calls = append(r.calls, name+" "+string(b))
r.mu.Unlock()
return answer(args), nil
}
}
return AskSteps{
Fetch: call("fetch", func(map[string]any) any { return r.fetch }),
Build: call("build", func(args map[string]any) any {
return map[string]any{"digest": "sha256:" + args["artifact"].(string), "reference": "artifact-store://" + args["artifact"].(string)}
}),
Copy: call("copy", func(args map[string]any) any { return map[string]any{"digest": "sha256:copied"} }),
Digest: call("digest", func(map[string]any) any { return map[string]any{"level": "unit", "packages": r.digests} }),
Test: call("test", func(args map[string]any) any {
passed := true
if pkgs, ok := args["packages"].([]string); ok {
for _, p := range pkgs {
passed = passed && r.tests[p]
}
}
if dir, ok := args["module"].(string); ok {
passed = r.tests[dir]
}
return map[string]any{"passed": passed, "tests": 1}
}),
Kept: func(ctx context.Context, level, digest string) (int, bool, error) { return 7, digest == "d-kept", nil },
}
}
func ask(job any) delivery.StepAsk {
a := delivery.StepAsk{ID: NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "t", At: time.Now()}
switch j := job.(type) {
case delivery.BuildJob:
a.Build = &j
case delivery.CheckJob:
a.Check = &j
}
return a
}
// 1887/A3: a build ask fetches once, builds each artifact from the workspace with publish, copies a pinned one without a
// fetch, and answers each artifact's digest and reference.
func TestRunAskBuilds(t *testing.T) {
r := &recorder{fetch: map[string]any{"workspace": "/w/1"}}
steps := r.steps()
var released []string
steps.Release = func(ws string) error { released = append(released, ws); return nil }
out, err := RunAsk(context.Background(), steps, ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}))
if err != nil {
t.Fatal(err)
}
got := strings.Join(r.calls, "\n")
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
fetch {"commit":"` + runCommit + `","repository":"novox/x"}
build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"}
build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
if got != want {
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
}
if len(released) != 1 || released[0] != "/w/1" {
t.Errorf("released %v, want the one workspace", released)
}
a := out.(delivery.BuildAnswer)
if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" {
t.Fatalf("answer %+v", a)
}
}
// A build ask for another toolchain is refused, so its step ends refused and the asker reads why.
func TestRunAskRefusesAnotherToolchain(t *testing.T) {
r := &recorder{fetch: map[string]any{"workspace": "/w"}}
s := r.steps()
s.Toolchain = func(context.Context) (delivery.ToolchainUsed, error) {
return delivery.ToolchainUsed{Version: "go1.27", Recipe: "3"}, nil
}
_, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
var ref *Refusal
if !errors.As(err, &ref) || len(r.calls) != 0 {
t.Fatalf("err %v, calls %v", err, r.calls)
}
}
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
// verdicts, tests the packages whose tests changed first, then the rest also after a failure (issue 1921); the levels run
// side by side and are answered in order, the first that did not pass first (ADR 0484 clause 13; issue 1921 S10).
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
r := &recorder{
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b", "x/pkg/k": "d-kept"},
tests: map[string]bool{"x/pkg/a": true, "x/pkg/b": false},
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration}, Kept: true,
Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "base": runCommit}}))
if err != nil {
t.Fatal(err)
}
var unit []string
for _, c := range r.calls {
if !strings.Contains(c, `"level":"integration"`) {
unit = append(unit, c)
}
}
got := strings.Join(unit, "\n")
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
digest {"level":"unit","workspace":"/w"}
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
if got != want {
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
}
a := out.(delivery.CheckAnswer)
if len(a.Levels) != 2 || a.Levels[1].Level != delivery.LevelIntegration {
t.Fatalf("levels %+v, want the unit level first, the integration level after it", a.Levels)
}
u := a.Levels[0]
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
t.Fatalf("the unit level %+v", u)
}
}
// ADR 0535 §2, §4: a merge that changes the build module's own code is judged by the candidate's runner: its digest says
// the candidate ran, and every package runs with nothing kept; an installed module that ignores the runner is planned as
// any other, the runner dropped. A digest that does not cover the fetch's siblings runs the level whole too.
func TestRunAskRunsWholeUnderTheRunnerOrWithoutTheSiblings(t *testing.T) {
for _, tc := range []struct{ judged, want string }{
{"candidate 999", `digest {"level":"unit","runner":"workspace","workspace":"/w"}
test {"level":"unit","runner":"workspace","workspace":"/w"}`},
{"installed", `digest {"level":"unit","runner":"workspace","workspace":"/w"}`},
} {
r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": []string{"modules/mesh-build-go/x.go"}},
digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}}
steps := r.steps()
digest := steps.Digest
steps.Digest = func(ctx context.Context, args map[string]any) (any, error) {
_, _ = digest(ctx, args)
return map[string]any{"level": "unit", "packages": r.digests, "judged_by": tc.judged}, nil
}
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
if got := strings.Join(r.calls[1:], "\n"); got != tc.want {
t.Errorf("judged by %s: calls\n%s\nwant\n%s", tc.judged, got, tc.want)
}
l := out.(delivery.CheckAnswer).Levels[0]
if tc.judged == "installed" && (len(l.Kept) != 1 || l.Judged != "") {
t.Errorf("an installed module's level %+v, want the kept verdict taken", l)
}
if tc.judged != "installed" && (len(l.Kept) != 0 || l.Digest != nil || l.Judged != tc.judged) {
t.Errorf("the candidate's level %+v, want nothing kept and the program named", l)
}
}
r := &recorder{fetch: map[string]any{"workspace": "/w"}, digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "beside": []any{map[string]any{"repository": "novox/mesh-sdk", "commit": runCommit}}}}))
if err != nil {
t.Fatal(err)
}
if l := out.(delivery.CheckAnswer).Levels[0]; len(l.Kept) != 0 || l.Digest != nil {
t.Fatalf("a digest without the siblings kept %+v", l)
}
}
// A Node check tests each module directory, the one whose tests changed first; a conflict ends the ask with the fetch
// alone, a verdict for the asker.
func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
tests: map[string]bool{"a": true, "b": true}}
s := r.steps()
s.Digest = nil
if _, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil {
t.Fatal(err)
}
if fmt.Sprint(r.calls[1:]) != `[test {"level":"unit","module":"b","workspace":"/w"} test {"level":"unit","module":"a","workspace":"/w"}]` {
t.Fatalf("calls %v", r.calls)
}
r = &recorder{fetch: map[string]any{"workspace": "/w", "conflicts": []string{"go.mod"}}}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil || len(r.calls) != 1 || len(out.(delivery.CheckAnswer).Levels) != 0 {
t.Fatalf("a conflict: %v %v", err, r.calls)
}
}
// Issue 1921: a failure in the packages whose tests changed does not stop the level: the other packages still run, so two
// failing packages, one changed and one not, are two test results of the one level, and the level does not pass. A Node
// check runs every module directory after one fails too.
func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
r := &recorder{
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"},
tests: map[string]bool{"x/pkg/a": false, "x/pkg/b": false},
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelIntegration, delivery.LevelUnit},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
a := out.(delivery.CheckAnswer)
if len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 || a.Levels[0].Level != delivery.LevelIntegration {
t.Fatalf("levels %+v, want the first level with both packages' results, in the order asked", a.Levels)
}
for i, raw := range a.Levels[0].Tests {
var res struct{ Passed bool }
if err := json.Unmarshal(raw, &res); err != nil || res.Passed {
t.Errorf("result %d %s, want a failure", i, raw)
}
}
r = &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
tests: map[string]bool{"a": true, "b": false}}
s := r.steps()
s.Digest = nil
out, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
if a := out.(delivery.CheckAnswer); len(r.calls) != 5 || len(a.Levels) != 2 || len(a.Levels[0].Tests) != 2 || a.Levels[0].Level != delivery.LevelUnit {
t.Fatalf("calls %v, levels %+v: want both directories of each level, the unit level first", r.calls, a.Levels)
}
}
-170
View File
@@ -1,170 +0,0 @@
package buildstep
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"syscall"
"time"
)
// RegistryWait is how long a push waits for an artifact store that refuses connections, longer than its nightly
// collection holds it still (about a minute and a half, issue 457); RegistryFirstPause is the first pause, doubling
// to RegistryMostPause. Variables so a test shortens them.
var (
RegistryWait = 5 * time.Minute
RegistryFirstPause = time.Second
RegistryMostPause = 10 * time.Second
)
// BlobReference is where a pushed bundle or archive is, as a done key names it and the controller delivers from
// (novox/hq ADR 0498 clause 1): artifact-store://<module>/<artifact>/blobs/<digest>, the store's address left out
// (issue 102).
func BlobReference(module, artifact, digest string) string {
return "artifact-store://" + module + "/" + artifact + "/blobs/" + digest
}
// PushBlob puts body into the artifact store at registry (host:port) as a blob of repository (<module>/<artifact>) by
// its digest (`sha256:` and hex), from the build module's own process, after the build step ended (novox/hq ADR 0498
// clauses 1 and 2): a blob the store already holds is done without an upload; otherwise it is uploaded (POST
// blobs/uploads/, then PUT with the digest). A store refusing connections is waited out up to RegistryWait; any other
// refusal fails, naming the store's status and its own words, for the caller's `not stored:` error. Bytes that are not
// their digest are refused before anything is sent.
func PushBlob(ctx context.Context, client *http.Client, registry, repository, digest string, body []byte) error {
sum := sha256.Sum256(body)
if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest {
return fmt.Errorf("the blob is %s, not the digest %s it was to be pushed as", got, digest)
}
if client == nil {
client = http.DefaultClient
}
base := "http://" + registry + "/v2/" + repository + "/blobs/"
var present bool
if err := waitForStore(ctx, registry, func() error {
var err error
present, err = blobPresent(ctx, client, base+digest)
return err
}); err != nil {
return err
}
if present {
return nil
}
var location string
if err := waitForStore(ctx, registry, func() error {
var err error
location, err = startUpload(ctx, client, base+"uploads/")
return err
}); err != nil {
return err
}
target, err := url.Parse(base)
if err != nil {
return err
}
loc, err := target.Parse(location)
if err != nil {
return fmt.Errorf("the store answered the upload's location %q, which is not a URL: %w", location, err)
}
q := loc.Query()
q.Set("digest", digest)
loc.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, loc.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("putting %s of %s: %w", digest, repository, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return storeSaid(resp, "putting "+digest+" of "+repository)
}
return nil
}
func blobPresent(ctx context.Context, client *http.Client, at string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, at, nil)
if err != nil {
return false, err
}
resp, err := client.Do(req)
if err != nil {
return false, err
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
}
return false, fmt.Errorf("asking the store for %s: it answered %s", at, resp.Status)
}
func startUpload(ctx context.Context, client *http.Client, at string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, at, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
return "", storeSaid(resp, "starting an upload at "+at)
}
location := resp.Header.Get("Location")
if location == "" {
return "", fmt.Errorf("starting an upload at %s: the store named no location", at)
}
return location, nil
}
// storeSaid is the store's refusal: its status and the first of its own words.
func storeSaid(resp *http.Response, what string) error {
words, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("%s: the store answered %s: %s", what, resp.Status, strings.TrimSpace(string(words)))
}
// refused is whether an error is a connection refused.
func refused(err error) bool {
return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused"))
}
// waitForStore runs try, and while it fails because the store refuses connections, tries again with a growing pause
// until RegistryWait has passed. A refusal is the one error waited for: nothing was sent, so trying again does nothing
// twice.
func waitForStore(ctx context.Context, registry string, try func() error) error {
err := try()
if !refused(err) {
return err
}
started, pause := time.Now(), RegistryFirstPause
for {
left := RegistryWait - time.Since(started)
if left <= 0 {
return fmt.Errorf("the artifact store at %s refused every connection for %s, longer than its nightly collection holds it still, so it is down, not paused: %w",
registry, time.Since(started).Round(time.Millisecond), err)
}
select {
case <-ctx.Done():
return fmt.Errorf("stopped while waiting for the artifact store at %s: %w (last: %v)", registry, ctx.Err(), err)
case <-time.After(min(pause, left)):
}
pause = min(pause*2, RegistryMostPause)
if err = try(); !refused(err) {
return err
}
}
}
-151
View File
@@ -1,151 +0,0 @@
package buildstep
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
func digestOfBytes(b []byte) string {
h := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(h[:])
}
// registry is an artifact store as far as a blob push needs one: HEAD a blob, start an upload, PUT it with its digest.
type registry struct {
mu sync.Mutex
blobs map[string][]byte
calls []string
put int // the status a PUT answers, 201 when 0
}
func (r *registry) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, req.Method+" "+req.URL.Path)
switch {
case req.Method == http.MethodHead && strings.Contains(req.URL.Path, "/blobs/sha256:"):
if _, ok := r.blobs[req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]]; ok {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
case req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/blobs/uploads/"):
w.Header().Set("Location", strings.TrimSuffix(req.URL.Path, "/")+"/u1?state=x")
w.WriteHeader(http.StatusAccepted)
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/blobs/uploads/u1"):
if r.put != 0 {
w.WriteHeader(r.put)
_, _ = io.WriteString(w, `{"errors":[{"code":"DIGEST_INVALID","message":"provided digest did not match uploaded content"}]}`)
return
}
body, _ := io.ReadAll(req.Body)
d := req.URL.Query().Get("digest")
if req.URL.Query().Get("state") != "x" || d != digestOfBytes(body) {
w.WriteHeader(http.StatusBadRequest)
return
}
r.blobs[d] = body
w.WriteHeader(http.StatusCreated)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// A done build's bundle or archive is pushed to the artifact store by its build module (novox/hq ADR 0498 clauses 1
// and 2): a blob the store already holds is done with no upload; one it lacks is uploaded by digest; its reference is
// the artifact-store form the builder's key and the controller check.
func TestABlobIsPushedOnceByItsDigest(t *testing.T) {
reg := &registry{blobs: map[string][]byte{}}
srv := httptest.NewServer(reg)
defer srv.Close()
addr := strings.TrimPrefix(srv.URL, "http://")
body := []byte("the packed bundle")
d := digestOfBytes(body)
if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil {
t.Fatal(err)
}
if string(reg.blobs[d]) != string(body) {
t.Fatalf("the store holds %q", reg.blobs[d])
}
reg.calls = nil
if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil {
t.Fatal(err)
}
if len(reg.calls) != 1 || !strings.HasPrefix(reg.calls[0], "HEAD ") {
t.Fatalf("a blob the store holds was uploaded again: %v", reg.calls)
}
ref := BlobReference("mail", "code", d)
if ref != "artifact-store://mail/code/blobs/"+d {
t.Fatalf("BlobReference = %q", ref)
}
b := delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("4", 64),
Artifacts: []delivery.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: d, Reference: ref}}}
if _, err := delivery.EncodeBuilderBuild(b); err != nil {
t.Fatalf("the key refuses the reference BlobReference gives: %v", err)
}
}
// A store that refuses the upload fails the push naming the store's own words; bytes that are not the digest are
// refused before anything is sent.
func TestAPushTheStoreRefusesSaysTheStoresWords(t *testing.T) {
reg := &registry{blobs: map[string][]byte{}, put: http.StatusBadRequest}
srv := httptest.NewServer(reg)
defer srv.Close()
addr := strings.TrimPrefix(srv.URL, "http://")
body := []byte("x")
err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes(body), body)
if err == nil || !strings.Contains(err.Error(), "provided digest did not match") || !strings.Contains(err.Error(), "400") {
t.Fatalf("a refused upload said %v", err)
}
reg.calls = nil
err = PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes([]byte("other")), body)
if err == nil || len(reg.calls) != 0 {
t.Fatalf("bytes that are not their digest: %v, calls %v", err, reg.calls)
}
}
// A store refusing connections (its nightly collection holds it still) is waited out, up to RegistryWait (issue 457).
func TestAPushWaitsOutAStoreRefusingConnections(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := l.Addr().String()
l.Close() // refusing now
reg := &registry{blobs: map[string][]byte{}}
go func() {
time.Sleep(300 * time.Millisecond)
l2, err := net.Listen("tcp", addr)
if err != nil {
return
}
_ = http.Serve(l2, reg)
}()
beforeWait, beforePause := RegistryWait, RegistryFirstPause
RegistryWait, RegistryFirstPause = 3*time.Second, 100*time.Millisecond
defer func() { RegistryWait, RegistryFirstPause = beforeWait, beforePause }()
body := []byte("late")
if err := PushBlob(context.Background(), http.DefaultClient, addr, "mail/code", digestOfBytes(body), body); err != nil {
t.Fatalf("a store that came back was not waited for: %v", err)
}
l3, _ := net.Listen("tcp", "127.0.0.1:0")
gone := l3.Addr().String()
l3.Close()
RegistryWait = 300 * time.Millisecond
err = PushBlob(context.Background(), http.DefaultClient, gone, "mail/code", digestOfBytes(body), body)
if err == nil || !strings.Contains(err.Error(), "refused every connection") {
t.Fatalf("a store that stayed down said %v", err)
}
}
+4 -49
View File
@@ -1,52 +1,7 @@
package buildstep package buildstep
import ( // CapacitySetting is the build modules' setting of the node's build capacity (novox/hq ADR 0525 §5, ADR 0567 §12): how
"encoding/json" // many asks the node takes at once, an integer config value set on each node's layer of mesh-config. No repository names
"fmt" // a node: each build module declares it with the default 0, which pulls nothing, and the controller (or mesh-config)
"os" // lays the node's value into the module's settings file.
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
)
// CapacitySetting is the build modules' setting of each node's build capacity (novox/hq ADR 0525 §5): `<node>=<asks at
// once>` pairs separated by commas, as the builder's was (ADR 0485 §1). Each build module declares it with
// DefaultCapacities as its default, and the controller lays it into the module's settings file.
const CapacitySetting = "capacity" const CapacitySetting = "capacity"
// DefaultCapacities is the setting's default, the builder's before R4g: the control-node (novox) and the laptop (g14)
// build nothing.
const DefaultCapacities = "shanks=3,ace=2,g14=0,novox=0"
// NodeCapacity is node's build capacity, read from the module's settings file at path at every call, so a change takes
// effect at the next pull; with no path it reads fallback. A node the setting does not name runs one ask at a time (as
// the builder's did). A file or a setting that cannot be read gives 0 and says why: pulling on a setting nobody can read
// could build on a node that must not.
func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func() int {
say := func(format string, args ...any) {
if logf != nil {
logf(format, args...)
}
}
return func() int {
setting := fallback
if path != "" {
raw, err := os.ReadFile(path)
if err != nil {
say("the build capacity cannot be read, so %s pulls nothing: %v", node, err)
return 0
}
var s map[string]any
if err := json.Unmarshal(raw, &s); err != nil {
say("the settings file %s is not JSON, so %s pulls nothing: %v", path, node, err)
return 0
}
setting, _ = s[CapacitySetting].(string)
}
caps, err := buildrun.ParseCapacity(setting)
if err != nil {
say("%v, so %s pulls nothing", fmt.Errorf("the build capacity: %w", err), node)
return 0
}
return (&buildrun.Places{Capacity: caps}).CapacityOf(node)
}
}
-38
View File
@@ -1,38 +0,0 @@
package buildstep
import (
"os"
"path/filepath"
"testing"
)
// 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls
// nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing.
func TestNodeCapacity(t *testing.T) {
if got := NodeCapacity("", DefaultCapacities, "novox", nil)(); got != 0 {
t.Errorf("novox = %d by default, want 0", got)
}
if got := NodeCapacity("", DefaultCapacities, "shanks", nil)(); got != 3 {
t.Errorf("shanks = %d by default, want 3", got)
}
file := filepath.Join(t.TempDir(), "settings.json")
os.WriteFile(file, []byte(`{"capacity":"shanks=1,ace=2"}`), 0o600)
capacity := NodeCapacity(file, DefaultCapacities, "shanks", nil)
if got := capacity(); got != 1 {
t.Errorf("shanks = %d from the file, want 1", got)
}
os.WriteFile(file, []byte(`{"capacity":"shanks=4"}`), 0o600)
if got := capacity(); got != 4 {
t.Errorf("shanks = %d after the change, want 4", got)
}
if got := NodeCapacity(file, "", "pi", nil)(); got != 1 {
t.Errorf("a node not named = %d, want 1", got)
}
os.WriteFile(file, []byte(`{"capacity":"shanks=lots"}`), 0o600)
if got := capacity(); got != 0 {
t.Errorf("an unreadable setting = %d, want 0", got)
}
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), DefaultCapacities, "shanks", nil)(); got != 0 {
t.Errorf("a missing file = %d, want 0", got)
}
}
-234
View File
@@ -1,234 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// credentialField is the node runtime's rule for a state value (mesh-tools node-tools/internal/bus/state.go, novox/hq
// ADR 0201), copied whole: the first field anywhere in the value whose name, lower-cased and without - _ and spaces, ends
// in a credential word, or "". A step's end is refused when it answers a field.
func credentialField(value json.RawMessage) string {
endings := []string{"password", "passwd", "secret", "token", "credential", "credentials",
"authorization", "apikey", "privatekey", "accesskey", "cookie"}
var v any
if json.Unmarshal(value, &v) != nil {
return ""
}
var walk func(any) string
walk = func(v any) string {
switch t := v.(type) {
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 {
norm := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(k))
for _, end := range endings {
if strings.HasSuffix(norm, end) {
return k
}
}
if found := walk(t[k]); found != "" {
return found
}
}
case []any:
for _, x := range t {
if found := walk(x); found != "" {
return found
}
}
}
return ""
}
return walk(v)
}
// runtimeState is a steps state that refuses a value as the node runtime does, and keeps every value it took.
type runtimeState struct {
mu sync.Mutex
took map[string][]delivery.Step
refused int
}
func newRuntimeState() *runtimeState { return &runtimeState{took: map[string][]delivery.Step{}} }
func (s *runtimeState) put(key string, value any) (uint64, error) {
raw, _ := value.(json.RawMessage)
if f := credentialField(raw); f != "" {
s.mu.Lock()
s.refused++
s.mu.Unlock()
return 0, fmt.Errorf("steps.%s carries a field %q, which names a credential", key, f)
}
st, err := delivery.DecodeStep(raw)
if err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.took[key] = append(s.took[key], st)
return uint64(len(s.took[key])), nil
}
func (s *runtimeState) last(key string) (delivery.Step, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t := s.took[key]
if len(t) == 0 {
return delivery.Step{}, false
}
return t[len(t)-1], true
}
func fastEnds(t *testing.T) {
attempts, retry := EndAttempts, EndRetry
EndAttempts, EndRetry = 2, time.Millisecond
t.Cleanup(func() { EndAttempts, EndRetry = attempts, retry })
}
// 1930/AC1: given a check of a repository with the package .../internal/token, when its ask's answer is stored as the
// step's end, then no field is keyed by a package path: the runtime's credential rule takes the value, and the package
// and its digest read back from the stored end.
func TestADigestOfAPackageNamedTokenIsStoredAsAList(t *testing.T) {
fastEnds(t)
r := &recorder{
fetch: map[string]any{"workspace": "/w"},
digests: map[string]string{"x/internal/token": "d-token", "x/pkg/a": "d-a"},
tests: map[string]bool{"x/internal/token": true, "x/pkg/a": true},
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
old, _ := json.Marshal(map[string]any{"levels": []any{map[string]any{"level": "unit", "digest": map[string]any{"packages": r.digests}}}})
if credentialField(old) != "x/internal/token" {
t.Fatalf("the rule does not refuse the form before issue 1930 (%s): the test proves nothing", old)
}
st := newRuntimeState()
if err := finish(st.put, "shanks.a1", delivery.Step{ID: "a1", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, out, nil, t.Logf); err != nil {
t.Fatal(err)
}
s, ok := st.last("shanks.a1")
if !ok || s.State != delivery.StepPassed || st.refused != 0 {
t.Fatalf("the end %+v (written %v, refused %d), want it passed and taken at once", s, ok, st.refused)
}
var a delivery.CheckAnswer
if err := json.Unmarshal(s.Result, &a); err != nil {
t.Fatal(err)
}
if len(a.Levels) != 1 || a.Levels[0].Digest == nil {
t.Fatalf("levels %+v, want the unit level with its digest", a.Levels)
}
want := []delivery.PackageDigest{{Package: "x/internal/token", Digest: "d-token"}, {Package: "x/pkg/a", Digest: "d-a"}}
if got := a.Levels[0].Digest.Packages; fmt.Sprint(got) != fmt.Sprint(want) {
t.Errorf("packages %v, want %v", got, want)
}
if !strings.Contains(string(s.Result), `"package":"x/internal/token"`) {
t.Errorf("the stored end does not name the package as a value: %s", s.Result)
}
}
// 1930/AC2: given an end the state refuses every time, when finish writes it, then it logs the refusal and writes a small
// failed end without the result that names the write's error; and when no end can be written at all it answers an error.
func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) {
fastEnds(t)
big := errors.New("the value carries a field \"x/internal/token\", which names a credential")
var refused int
var stored []delivery.Step
put := func(key string, value any) (uint64, error) {
st, _ := delivery.DecodeStep(value.(json.RawMessage))
if len(st.Result) > 0 {
refused++
return 0, big
}
stored = append(stored, st)
return 1, nil
}
var logged []string
logf := func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }
err := finish(put, "shanks.a2", delivery.Step{ID: "a2", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, map[string]any{"levels": 1}, nil, logf)
if err != nil {
t.Fatalf("finish answered %v, want the small end written", err)
}
if refused != EndAttempts || len(stored) != 1 {
t.Fatalf("refused %d, stored %d, want %d refusals and one small end", refused, len(stored), EndAttempts)
}
s := stored[0]
if s.State != delivery.StepFailed || len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") ||
!strings.Contains(s.Reason, big.Error()) || !strings.Contains(s.Reason, "passed") {
t.Errorf("the small end %+v, want failed, no result, naming the write's error and that it passed", s)
}
if len(logged) == 0 || !strings.Contains(logged[0], big.Error()) {
t.Errorf("logged %q, want the refusal said", logged)
}
none := func(string, any) (uint64, error) { return 0, errors.New("the bus is gone") }
if err := finish(none, "shanks.a3", delivery.Step{ID: "a3", Tool: "check ask", Node: "shanks", Started: time.Now()}, nil, nil, logf); err == nil || !strings.Contains(err.Error(), "the bus is gone") {
t.Errorf("finish with no write possible answered %v, want the error", err)
}
}
// 1930/AC2: given an ask whose end the state refuses, when it runs, then the ask is acknowledged only once its small
// failed end is written; when no end can be written at all, it is given back, never acknowledged as done.
func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) {
fastEnds(t)
for _, tc := range []struct {
name string
smallOK bool
wantAcked bool
}{{"the small end written", true, true}, {"no end written", false, false}} {
t.Run(tc.name, func(t *testing.T) {
src := NewFakeAsks()
a := testAsk(delivery.AskCheck)
_ = src.PublishAsk(a)
var mu sync.Mutex
var ends []delivery.Step
put := func(key string, value any) (uint64, error) {
st, err := delivery.DecodeStep(value.(json.RawMessage))
if err != nil {
return 0, err
}
if st.State != delivery.StepRunning && (len(st.Result) > 0 || !tc.smallOK) {
return 0, errors.New("refused")
}
mu.Lock()
ends = append(ends, st)
mu.Unlock()
return 1, nil
}
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Logf: t.Logf,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return map[string]any{"ran": true}, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the ask's fate", func() bool { ac, tm, nk := src.Fates(); return len(ac)+len(tm)+len(nk) > 0 })
cancel()
acked, termed, naked := src.Fates()
if tc.wantAcked && (len(acked) != 1 || len(naked) != 0) || !tc.wantAcked && (len(acked) != 0 || len(naked) == 0) || len(termed) != 0 {
t.Fatalf("acked %v naked %v termed %v", acked, naked, termed)
}
mu.Lock()
defer mu.Unlock()
if tc.smallOK {
last := ends[len(ends)-1]
if last.State != delivery.StepFailed || !strings.Contains(last.Reason, "refused") {
t.Errorf("the end %+v, want the small failed end", last)
}
}
})
}
}
+5
View File
@@ -0,0 +1,5 @@
// Package buildstep is the contract of a build step every delivery part reads (novox/hq ADR 0593 §1): a step's id, the
// fetch request, a step a previous process left running and its end, a failure's words, a module tree's shape, the
// capacity setting and the launchers' names. The runner the build modules share is the Go build module's own package
// (mesh-catalog modules/mesh-build-go/buildstep), which imports this one.
package buildstep
+141
View File
@@ -0,0 +1,141 @@
package buildstep
import (
"fmt"
"strings"
"git.novox.be/novox/mesh-sdk/go/sdkversion"
)
// Failure is one test that failed, or a package or file that failed with no test to name (a build that failed, a test
// program that panicked, a test file that did not load). The JSON names are the ones mesh-build-go and the checker have
// always used; File is new and an older reader ignores it.
type Failure struct {
// Package is the Go package's import path, or the TypeScript package's directory in the workspace.
Package string `json:"package"`
// Test names the failing test, or the tests that were running when the program stopped; "" when none can be named.
Test string `json:"test,omitempty"`
// File is where the failing test is (a Go test's file and line is in its Cause; node names the test's file:line:col).
File string `json:"file,omitempty"`
// Cause is the one line that says why: the assertion's or the error's first line, a "panic:" line, a build's first
// error, or words saying how the program stopped. **Never empty, and never a passing line** (PassingLine).
Cause string `json:"cause,omitempty"`
// Output is the failing test's own output, its last lines up to OutputBytes, Cause first: an older checker reads
// only its first line.
Output string `json:"output,omitempty"`
}
// OutputBytes is the most a failure keeps of its output, and LineBytes the most of one line: four kilobytes holds a long
// assertion message and stays far under a bus message's or a verdict's bound.
const (
OutputBytes = 4096
LineBytes = 1024
)
// By names a build module and the SDK it was built with, for a result's "by": the checker names it when a failure hides
// its cause ("the build module hid the failure (<by>)").
func By(module string) string {
return module + " with mesh-sdk " + sdkversion.Version()
}
// PassingLine says a line is a test runner's word for a test or a package that passed: node's '✔', TAP's "ok N" (at any
// indent), go test's "ok \t<pkg>", "PASS" and "--- PASS". A line of a test's own that starts "ok " is none of these. Such a line is never a failure's cause, nor part of a failure's output.
func PassingLine(l string) bool {
if strings.HasPrefix(l, "ok \t") { // go test's package line, at column 0
return true
}
t := strings.TrimSpace(l)
switch {
case t == "PASS", strings.HasPrefix(t, "✔"), strings.HasPrefix(t, "--- PASS"):
return true
}
return tapOk(t)
}
// Hidden says how a failure hides why it failed, or "" when it does not: it has no cause, or its cause is a passing line.
// The checker says it loudly (issue 1901): a build module that sends such a failure does not keep the contract.
func Hidden(f Failure) string {
c := strings.TrimSpace(f.Cause)
switch {
case c == "":
return "it sent no cause"
case PassingLine(c):
return fmt.Sprintf("its cause is a passing line (%q)", CutLine(c))
}
return ""
}
// Clip joins the last lines of a failure's output that fit OutputBytes, each cut to LineBytes, and says how many earlier
// lines it left out. **Its first line is the cause**, put ahead when the kept lines do not start with it, or, with no
// cause and lines left out, the first non-empty line, then the marker: an older checker reads only the first line.
// Passing lines are dropped.
func Clip(lines []string, cause string) string {
own := make([]string, 0, len(lines))
for _, l := range lines {
if !PassingLine(l) {
own = append(own, l)
}
}
lines = own
size, from := 0, len(lines)
for from > 0 {
n := len(CutLine(lines[from-1])) + 1
if size+n > OutputBytes && from < len(lines) {
break
}
size += n
from--
}
kept := make([]string, 0, len(lines)-from+2)
head := strings.TrimSpace(cause)
for i := 0; head == "" && i < from; i++ {
head = strings.TrimSpace(lines[i])
}
if head != "" && (from > 0 || len(lines) == 0 || strings.TrimSpace(lines[from]) != head) {
kept = append(kept, CutLine(head))
}
if from > 0 {
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
}
for _, l := range lines[from:] {
kept = append(kept, CutLine(l))
}
return strings.Join(kept, "\n")
}
// CutLine cuts a line to LineBytes, on a character boundary.
func CutLine(l string) string {
if len(l) <= LineBytes {
return l
}
return strings.ToValidUTF8(l[:LineBytes], "") + "..."
}
// ConsoleFailure is the failure of a command that is no test runner (go vet, gofmt, tsc, a build script): its cause is the
// first line that names an error (tsc's "error TS", go vet's "file.go:12:3: ..."), else the first line that is not a
// heading ("# <package>", npm's "> script"), and its output the command's lines without passing ones.
func ConsoleFailure(pkg, test, out string) Failure {
var lines []string
for _, l := range strings.Split(strings.ReplaceAll(stripANSI(out), "\r\n", "\n"), "\n") {
if t := strings.TrimSpace(l); t != "" && !PassingLine(t) {
lines = append(lines, strings.TrimRight(l, " \t"))
}
}
cause := ""
for _, l := range lines {
if t := strings.TrimSpace(l); strings.Contains(t, "error") || strings.Contains(t, "Error") || goPosition.MatchString(t) {
cause = t
break
}
}
for _, l := range lines {
if t := strings.TrimSpace(l); cause == "" && !strings.HasPrefix(t, "# ") && !strings.HasPrefix(t, "> ") && !strings.HasPrefix(t, "npm ") {
cause = t
}
}
if cause == "" {
cause = test + " failed and wrote nothing that says why"
}
cause = CutLine(cause)
return Failure{Package: pkg, Test: test, Cause: cause, Output: Clip(lines, cause)}
}
-201
View File
@@ -1,201 +0,0 @@
package buildstep
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Option changes FetchCommit.
type Option func(*fetchOptions)
type fetchOptions struct {
allowFile bool
mirror string
}
// AllowFileRemote lets FetchCommit take a file:// remote. It is for a test's local repository; production
// code does not pass it, and FetchCommit then refuses anything but ssh://.
func AllowFileRemote() Option { return func(o *fetchOptions) { o.allowFile = true } }
// WithMirror makes FetchCommit (and MergeInto) borrow objects from mirror, a bare repository that UpdateMirror keeps
// in the build module's own cache (novox/hq ADR 0536 §2; issue 890): the workspace's .git/objects/info/alternates
// names the mirror's objects directory, so git transfers only the objects the mirror lacks. It is a plain file, never
// a symlink. mirror must be an absolute path of an existing directory; the workspace never writes to it.
func WithMirror(mirror string) Option { return func(o *fetchOptions) { o.mirror = mirror } }
// useMirror writes the alternates file of the repository in dir, when a mirror is set.
func useMirror(o fetchOptions, dir string) error {
if o.mirror == "" {
return nil
}
info := filepath.Join(dir, ".git", "objects", "info")
if err := os.MkdirAll(info, 0o755); err != nil {
return fmt.Errorf("buildstep: the mirror cannot be used: %w", err)
}
line := filepath.Join(o.mirror, "objects") + "\n"
if err := os.WriteFile(filepath.Join(info, "alternates"), []byte(line), 0o644); err != nil {
return fmt.Errorf("buildstep: the mirror cannot be used: %w", err)
}
return nil
}
var (
commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
refOK = regexp.MustCompile(`^refs/[A-Za-z0-9._/-]+$`)
)
// FetchCommit fetches one commit of a forge repository into dir, an empty directory, and checks it out detached
// (novox/hq ADR 0466 §4, §5; issue 1474). remote is ssh://<ssh name>/<owner>/<repo>.git, the ssh name being the
// one the build module's templates set up (ADR 0409 §1); commit is a full 40-hex sha; ref is optional, and names
// the pull request's head (refs/pull/<n>/head) when the commit is only there. Every git command runs through run
// as `git -C dir ...` with GIT_TERMINAL_PROMPT=0 and no credential helper, so nothing is asked for and no
// credential is used. A remote that is not ssh://, or one with user information, and a commit that is not 40
// hex digits are refused before anything runs.
func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, ref string, opts ...Option) error {
var o fetchOptions
for _, opt := range opts {
opt(&o)
}
if err := checkFetch(o, dir, remote, commit, ref); err != nil {
return err
}
env := []string{"GIT_TERMINAL_PROMPT=0"}
git := func(args ...string) (string, error) {
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
r := run(ctx, env, "git", full...)
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
}
return r.Stdout, nil
}
if _, err := git("init", "-q"); err != nil {
return err
}
if err := useMirror(o, dir); err != nil {
return err
}
if _, err := git("remote", "add", "origin", remote); err != nil {
return err
}
if _, err := git("fetch", "--no-tags", "--depth", "1", "origin", commit); err != nil {
if ref == "" {
return err
}
if _, rerr := git("fetch", "--no-tags", "origin", ref); rerr != nil {
return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr)
}
if _, verr := git("cat-file", "-e", commit+"^{commit}"); verr != nil {
return fmt.Errorf("the commit %s is not in %s: %w", commit, ref, verr)
}
}
if _, err := git("checkout", "-q", "--detach", commit); err != nil {
return err
}
out, err := git("rev-parse", "HEAD")
if err != nil {
return err
}
if head := strings.TrimSpace(out); head != commit {
return fmt.Errorf("the checkout is at %s, not at the commit %s", head, commit)
}
return nil
}
// UpdateMirror brings the bare repository mirror to commit: it makes the mirror when it is absent (git init --bare,
// origin set to remote), then fetches the commit, or the ref when only the ref holds it, with the same rules as
// FetchCommit (no prompt, no credential helper, ssh:// or an allowed file:// remote, a 40-hex commit). The mirror
// keeps every commit it was brought to, so later fetches through WithMirror transfer only what is new. Callers
// serialise updates of one mirror.
func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, commit, ref string, opts ...Option) error {
var o fetchOptions
for _, opt := range opts {
opt(&o)
}
if !filepath.IsAbs(mirror) {
return fmt.Errorf("buildstep: the mirror %q is not an absolute path", mirror)
}
if err := checkFetch(o, mirror, remote, commit, ref); err != nil {
return err
}
env := []string{"GIT_TERMINAL_PROMPT=0"}
git := func(args ...string) error {
r := run(ctx, env, "git", append([]string{"-c", "credential.helper="}, args...)...)
if r.Status != 0 || r.Err != "" {
return fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
}
return nil
}
in := func(args ...string) error { return git(append([]string{"-C", mirror}, args...)...) }
if _, err := os.Stat(filepath.Join(mirror, "HEAD")); err != nil {
if err := git("init", "-q", "--bare", mirror); err != nil {
return err
}
if err := in("remote", "add", "origin", remote); err != nil {
return err
}
} else if err := in("remote", "set-url", "origin", remote); err != nil {
return err
}
if err := in("fetch", "--no-tags", "origin", commit); err != nil {
if ref == "" {
return err
}
if rerr := in("fetch", "--no-tags", "origin", ref); rerr != nil {
return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr)
}
}
if err := in("cat-file", "-e", commit+"^{commit}"); err != nil {
return fmt.Errorf("the commit %s is not in the mirror: %w", commit, err)
}
return nil
}
func checkFetch(o fetchOptions, dir, remote, commit, ref string) error {
if dir == "" {
return fmt.Errorf("buildstep: no workspace directory")
}
if o.mirror != "" {
if !filepath.IsAbs(o.mirror) {
return fmt.Errorf("buildstep: the mirror %q is not an absolute path", o.mirror)
}
if fi, err := os.Stat(o.mirror); err != nil || !fi.IsDir() {
return fmt.Errorf("buildstep: the mirror %q is not an existing directory", o.mirror)
}
}
if !commitOK.MatchString(commit) {
return fmt.Errorf("buildstep: %q is not a full commit (40 hex digits)", commit)
}
if ref != "" && !refOK.MatchString(ref) {
return fmt.Errorf("buildstep: %q is not a ref (refs/...)", ref)
}
u, err := url.Parse(remote)
if err != nil {
return fmt.Errorf("buildstep: the remote is not a URL: %w", err)
}
if u.User != nil {
return fmt.Errorf("buildstep: the remote carries user information; the key is the credential")
}
switch {
case u.Scheme == "ssh" && u.Host != "":
case u.Scheme == "file" && o.allowFile:
default:
return fmt.Errorf("buildstep: the remote %q is not ssh://", remote)
}
return nil
}
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
-59
View File
@@ -1,59 +0,0 @@
//go:build integration
package buildstep
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
func gitIn(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid"}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
// 1474/AC5: a local bare repository with a commit only under refs/pull/1/head (a file:// remote, allowed by the
// test-only option): the workspace's HEAD is the commit. The forge itself is proven live after the build
// modules' delivery (ADR 0466 §5).
func TestFetchCommitFromAPullRefOnly(t *testing.T) {
root := t.TempDir()
bare := filepath.Join(root, "r.git")
work := filepath.Join(root, "work")
for _, d := range []string{bare, work} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
gitIn(t, bare, "init", "-q", "--bare")
gitIn(t, work, "init", "-q")
if err := os.WriteFile(filepath.Join(work, "f"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
gitIn(t, work, "add", "f")
gitIn(t, work, "commit", "-q", "-m", "pull request head")
commit := gitIn(t, work, "rev-parse", "HEAD")
gitIn(t, work, "push", "-q", bare, "HEAD:refs/pull/1/head")
ws := filepath.Join(root, "ws")
if err := os.MkdirAll(ws, 0o755); err != nil {
t.Fatal(err)
}
err := FetchCommit(context.Background(), forgekey.ExecRunner, ws, "file://"+bare, commit, "refs/pull/1/head", AllowFileRemote())
if err != nil {
t.Fatal(err)
}
if head := gitIn(t, ws, "rev-parse", "HEAD"); head != commit {
t.Errorf("HEAD = %s, want %s", head, commit)
}
}
-137
View File
@@ -1,137 +0,0 @@
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
const sha = "0123456789abcdef0123456789abcdef01234567"
// fakeGit records every command and answers by a rule.
type fakeGit struct {
calls []string
envs [][]string
rule func(args string) forgekey.Ran
}
func (f *fakeGit) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
line := name + " " + strings.Join(args, " ")
f.calls = append(f.calls, line)
f.envs = append(f.envs, env)
if f.rule != nil {
if r := f.rule(line); r.Status != 0 || r.Stdout != "" || r.Err != "" {
return r
}
}
if len(args) > 4 && args[4] == "rev-parse" {
return forgekey.Ran{Stdout: sha + "\n"}
}
return forgekey.Ran{}
}
const pre = "git -c credential.helper= -C /w "
// 1474/AC1: the git commands are exactly the sequence, none with a credential or an https remote.
func TestFetchCommitRunsTheSequence(t *testing.T) {
f := &fakeGit{}
if err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/novox/mesh-sdk.git", sha, ""); err != nil {
t.Fatal(err)
}
want := []string{
pre + "init -q",
pre + "remote add origin ssh://mesh-forge/novox/mesh-sdk.git",
pre + "fetch --no-tags --depth 1 origin " + sha,
pre + "checkout -q --detach " + sha,
pre + "rev-parse HEAD",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
t.Errorf("commands:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
}
for i, c := range f.calls {
if strings.Contains(c, "https://") || strings.Contains(c, "@") || !strings.Contains(c, "credential.helper= ") {
t.Errorf("command %q carries a credential or https", c)
}
if len(f.envs[i]) != 1 || f.envs[i][0] != "GIT_TERMINAL_PROMPT=0" {
t.Errorf("env %v", f.envs[i])
}
}
}
// 1474/AC2: the depth-1 fetch fails and a pull ref is given: the ref is fetched and the commit verified; with no
// ref, the fetch's failure is the answer.
func TestFetchCommitFallsBackToThePullRef(t *testing.T) {
f := &fakeGit{rule: func(l string) forgekey.Ran {
if strings.Contains(l, "--depth 1") {
return forgekey.Ran{Status: 128, Stderr: "fatal: couldn't find remote ref " + sha + "\n"}
}
return forgekey.Ran{}
}}
if err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err != nil {
t.Fatal(err)
}
want := []string{pre + "fetch --no-tags origin refs/pull/1/head", pre + "cat-file -e " + sha + "^{commit}", pre + "checkout -q --detach " + sha}
got := strings.Join(f.calls[3:6], "\n")
if got != strings.Join(want, "\n") {
t.Errorf("after the failed fetch:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
}
g := &fakeGit{rule: f.rule}
err := FetchCommit(context.Background(), g.run, "/w", "ssh://mesh-forge/o/r.git", sha, "")
if err == nil || !strings.Contains(err.Error(), "couldn't find remote ref") || len(g.calls) != 3 {
t.Errorf("without a ref: %v after %d commands", err, len(g.calls))
}
h := &fakeGit{rule: func(l string) forgekey.Ran {
if strings.Contains(l, "--depth 1") || strings.Contains(l, "cat-file") {
return forgekey.Ran{Status: 1, Stderr: "no"}
}
return forgekey.Ran{}
}}
if err := FetchCommit(context.Background(), h.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err == nil {
t.Error("a commit that is not in the ref was accepted")
}
}
// 1474/AC3: HEAD differs from the commit after the checkout: an error naming both.
func TestFetchCommitAnswersAHeadThatIsNotTheCommit(t *testing.T) {
other := strings.Repeat("a", 40)
f := &fakeGit{rule: func(l string) forgekey.Ran {
if strings.HasSuffix(l, "rev-parse HEAD") {
return forgekey.Ran{Stdout: other + "\n"}
}
return forgekey.Ran{}
}}
err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", sha, "")
if err == nil || !strings.Contains(err.Error(), other) || !strings.Contains(err.Error(), sha) {
t.Errorf("error = %v, want both shas", err)
}
}
// 1474/AC4: an https remote, user information, a file remote without the option, or a commit that is not 40
// hex digits is refused before anything runs.
func TestFetchCommitRefusesBeforeRunningAnything(t *testing.T) {
for _, c := range []struct{ remote, commit, ref string }{
{"https://forge.example/o/r.git", sha, ""},
{"http://forge.example/o/r.git", sha, ""},
{"ssh://git@mesh-forge/o/r.git", sha, ""},
{"ssh://user:pw@mesh-forge/o/r.git", sha, ""},
{"file:///tmp/r.git", sha, ""},
{"/tmp/r.git", sha, ""},
{"ssh://mesh-forge/o/r.git", "abc123", ""},
{"ssh://mesh-forge/o/r.git", strings.ToUpper(sha), ""},
{"ssh://mesh-forge/o/r.git", "main", ""},
{"ssh://mesh-forge/o/r.git", sha, "--upload-pack=x"},
} {
f := &fakeGit{}
if err := FetchCommit(context.Background(), f.run, "/w", c.remote, c.commit, c.ref); err == nil || len(f.calls) != 0 {
t.Errorf("%+v: err %v, %d commands", c, err, len(f.calls))
}
}
f := &fakeGit{}
if err := FetchCommit(context.Background(), f.run, "/w", "file:///tmp/r.git", sha, "", AllowFileRemote()); err != nil {
t.Errorf("a file remote with the option: %v", err)
}
}
-52
View File
@@ -1,52 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Guard is a build module's check that the toolchain installed on its node is the one its recipe builds with
// (novox/hq ADR 0469 §1; issue 1473). The same guard serves ADR 0470 §4's postgres major for the integration
// level, with its own Probe and Want.
type Guard struct {
// Language is the toolchain's key part: go or typescript (delivery.ToolchainKey).
Language string
// Want is the toolchain version the recipe builds with, and Recipe the recipe's own version. The indexer
// compares both texts as they are written here.
Want, Recipe string
// Probe reads the installed version (go version, node --version).
Probe func(ctx context.Context) (installed string, err error)
Node string
// Put is the toolchains state's Put (stdio.State(delivery.ToolchainsState).Put).
Put Put
}
// Check probes the installed version, writes it to <node>.<language> of the toolchains state every time it runs,
// and answers nil when it is the recipe's. Otherwise it answers a *Refusal naming both versions; the installed
// version is written even then, so the indexer sees that builders disagree. A probe that fails is a *Refusal
// naming its failure, and nothing is written. A write that fails is an error that is not a Refusal.
func (g Guard) Check(ctx context.Context) error {
installed, err := g.Probe(ctx)
if err != nil {
return &Refusal{Text: fmt.Sprintf("the installed %s version could not be read: %v", g.Language, err), Want: g.Want}
}
key, err := delivery.ToolchainKey(g.Node, g.Language)
if err != nil {
return err
}
raw, err := delivery.EncodeToolchain(delivery.Toolchain{Language: g.Language, Version: installed, Recipe: g.Recipe, At: now().UTC()})
if err != nil {
return err
}
if _, err := g.Put(key, json.RawMessage(raw)); err != nil {
return fmt.Errorf("buildstep: writing the toolchain %s: %w", key, err)
}
if installed != g.Want {
return &Refusal{Text: fmt.Sprintf("%s %s installed, the recipe builds with %s %s", g.Language, installed, g.Language, g.Want),
Want: g.Want, Have: installed}
}
return nil
}
-79
View File
@@ -1,79 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
type toolchainKeys map[string]delivery.Toolchain
func (k toolchainKeys) put(key string, value any) (uint64, error) {
tc, err := delivery.DecodeToolchain(value.(json.RawMessage))
if err != nil {
return 0, err
}
k[key] = tc
return uint64(len(k)), nil
}
func guardFor(k toolchainKeys, installed string, probeErr error) Guard {
return Guard{Language: "go", Want: "1.27.0", Recipe: "r7", Node: "ace", Put: k.put,
Probe: func(context.Context) (string, error) { return installed, probeErr }}
}
// 1473/AC1: the probe answers the recipe's version: nil, and <node>.go holds that version and the recipe's.
func TestCheckPassesAndWritesTheToolchain(t *testing.T) {
k := toolchainKeys{}
if err := guardFor(k, "1.27.0", nil).Check(context.Background()); err != nil {
t.Fatal(err)
}
tc, ok := k["ace.go"]
if !ok || tc.Version != "1.27.0" || tc.Recipe != "r7" || tc.Language != "go" || tc.At.IsZero() {
t.Errorf("keys = %+v", k)
}
}
// 1473/AC2: another version: a Refusal naming both, and the key holds the installed version.
func TestCheckRefusesAnotherVersionAndWritesTheInstalledOne(t *testing.T) {
k := toolchainKeys{}
err := guardFor(k, "1.27.1", nil).Check(context.Background())
var ref *Refusal
if !errors.As(err, &ref) {
t.Fatalf("Check = %v, want a Refusal", err)
}
if ref.Text != "go 1.27.1 installed, the recipe builds with go 1.27.0" || ref.Want != "1.27.0" || ref.Have != "1.27.1" {
t.Errorf("refusal = %+v", ref)
}
if k["ace.go"].Version != "1.27.1" {
t.Errorf("the key holds %q, want the installed 1.27.1", k["ace.go"].Version)
}
}
// 1473/AC3: a probe that fails: a Refusal naming the failure, and nothing put.
func TestCheckRefusesAFailedProbeAndPutsNothing(t *testing.T) {
k := toolchainKeys{}
err := guardFor(k, "", errors.New("go: not found")).Check(context.Background())
var ref *Refusal
if !errors.As(err, &ref) || !strings.Contains(ref.Text, "go: not found") {
t.Fatalf("Check = %v, want a Refusal naming the probe's failure", err)
}
if len(k) != 0 {
t.Errorf("put %v", k)
}
}
// A failed write is an error, not a refusal: the step fails instead of refusing.
func TestCheckAnswersAFailedWriteAsAnError(t *testing.T) {
g := guardFor(toolchainKeys{}, "1.27.0", nil)
g.Put = func(string, any) (uint64, error) { return 0, errors.New("no grant") }
err := g.Check(context.Background())
var ref *Refusal
if err == nil || errors.As(err, &ref) {
t.Errorf("Check = %v", err)
}
}
+19
View File
@@ -0,0 +1,19 @@
package buildstep
import (
"regexp"
"strings"
)
// goPosition matches a line that starts with a Go file's position, as go vet and the compiler write an error.
var goPosition = regexp.MustCompile(`^\S+\.go:\d+(:\d+)?: `)
var ansiEscape = regexp.MustCompile("\x1b\\[[0-9;]*m")
func stripANSI(s string) string { return ansiEscape.ReplaceAllString(s, "") }
// tapOk says a line is a passing test's "ok N - name", indented or not.
func tapOk(l string) bool {
rest, ok := strings.CutPrefix(strings.TrimSpace(l), "ok ")
return ok && rest != "" && rest[0] >= '0' && rest[0] <= '9'
}
-279
View File
@@ -1,279 +0,0 @@
package buildstep
import (
"context"
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Bubblewrap is the program a run step runs in (novox/hq ADR 0448): a build module declares its package, which needs
// unprivileged user namespaces (issue 1308).
const Bubblewrap = "bwrap"
// Isolation is how a run step's command is shut off (novox/hq ADR 0448; issue 1309, the lead's decision L2 of R4e: one
// runner in the SDK for every build module). The fetch step holds the forge key and the network and runs none of the
// change's code; the run step runs the change's build and tests in bubblewrap with:
//
// - no network: its own network namespace, with only a loopback, so a test may start a bus or a store of its own but
// resolves no name and reaches no socket of the machine;
// - none of the account: not its home, not the forge key, not a bus login, not the runtime directory (/run), since
// none of them is bound; HOME is a directory in the step's own /tmp unless Env names one;
// - an environment of exactly Env and the runner's env: nothing of the build module's own passes;
// - the system's programs and libraries read-only, and of /etc only what a program needs to run (users, libraries,
// certificates, time zone), with a hosts file naming only the loopback;
// - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step
// downloaded) read-only;
// - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone
// (WriteCaches), so a candidate's code never changes what a later build reads;
// - with a CacheLayer, what one command writes into the caches kept for the later commands of the same ask (issue 1921,
// the try speed-up): the vet, the unit tests and the integration compile of one ask compile each package once.
type Isolation struct {
Workspace string
Writable []string
ReadOnly []string
Caches []string
WriteCaches bool
// CacheLayer is a directory of the ask's own, in its workspace and gone with it: each command writes the caches into
// a layer of its own beneath it (overlayfs refuses an upper layer two mounts share, and an ask's commands run side by
// side), which Isolated moves into the ask's kept layer when the command ends; every later command reads the kept
// layer above the shared cache. The shared cache stays unchanged. Empty: what a command writes is thrown away.
CacheLayer string
Env []string
// Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback.
Hosts string
// run is the directory of this command's own writable layers under CacheLayer; Isolated makes one per command.
run string
}
// etcNeeded is what of /etc a program in the step may read: who the users are, where the libraries are, the
// certificates, the time zone. Not resolv.conf, not hosts: the step resolves no name.
var etcNeeded = []string{"/etc/passwd", "/etc/group", "/etc/nsswitch.conf", "/etc/ld.so.cache", "/etc/ld.so.conf",
"/etc/ld.so.conf.d", "/etc/ssl", "/etc/ca-certificates", "/etc/pki", "/etc/localtime", "/etc/os-release", "/etc/alternatives"}
// systemArgs binds the system's programs and libraries read-only: /usr, and /bin, /sbin, /lib, /lib64 as the symlinks
// they are on a merged-usr system, or bound when they are directories. A variable so a unit test fixes it.
var systemArgs = func() []string {
args := []string{"--ro-bind", "/usr", "/usr"}
for _, d := range []string{"/bin", "/sbin", "/lib", "/lib64"} {
info, err := os.Lstat(d)
switch {
case err != nil:
case info.Mode()&os.ModeSymlink != 0:
if target, err := os.Readlink(d); err == nil {
args = append(args, "--symlink", target, d)
}
case info.IsDir():
args = append(args, "--ro-bind", d, d)
}
}
for _, e := range etcNeeded {
args = append(args, "--ro-bind-try", e, e)
}
return args
}
// stepHome is the HOME of a step that is given none: inside its own /tmp, gone with it.
const stepHome = "/tmp/home"
func checkDir(what, dir string) error {
if dir == "" || !strings.HasPrefix(dir, "/") || path.Clean(dir) != dir || dir == "/" {
return fmt.Errorf("buildstep: the %s %q is not an absolute, clean directory other than the root", what, dir)
}
return nil
}
// Command is the bubblewrap command line that runs name with args shut off as iso says, env added to iso.Env. It
// refuses a directory that is not absolute and clean, or is the root, and an environment word without "=".
func (iso Isolation) Command(name string, args ...string) ([]string, error) {
return iso.command(nil, name, args...)
}
func (iso Isolation) command(env []string, name string, args ...string) ([]string, error) {
if err := checkDir("workspace", iso.Workspace); err != nil {
return nil, err
}
for _, group := range []struct {
what string
dirs []string
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}, {"cache layer", layerDirs(iso.CacheLayer)}} {
for _, d := range group.dirs {
if err := checkDir(group.what, d); err != nil {
return nil, err
}
}
}
words := append(append([]string{}, iso.Env...), env...)
home := false
for _, w := range words {
k, _, ok := strings.Cut(w, "=")
if !ok || k == "" {
return nil, fmt.Errorf("buildstep: the environment word %q is not NAME=value", w)
}
home = home || k == "HOME"
}
argv := []string{"--unshare-all", "--die-with-parent", "--new-session", "--clearenv",
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", stepHome}
argv = append(argv, systemArgs()...)
for _, d := range iso.ReadOnly {
argv = append(argv, "--ro-bind", d, d)
}
argv = append(argv, iso.cacheArgs()...)
argv = append(argv, "--bind", iso.Workspace, iso.Workspace)
for _, d := range iso.Writable {
argv = append(argv, "--bind", d, d)
}
if iso.Hosts != "" {
argv = append(argv, "--ro-bind", iso.Hosts, "/etc/hosts")
}
for _, w := range words {
k, v, _ := strings.Cut(w, "=")
argv = append(argv, "--setenv", k, v)
}
if !home {
argv = append(argv, "--setenv", "HOME", stepHome)
}
argv = append(argv, "--chdir", iso.Workspace, "--", name)
return append(argv, args...), nil
}
// cacheArgs binds the shared warm caches (S18): for a trunk build read-write, so the next build is warm; for any other
// read-only beneath an overlay whose writes go to a tmpfs of the step's own, so the step writes as a build does and
// nothing it wrote outlives it (bubblewrap's --tmp-overlay, an overlay mount in the step's user namespace). With a
// CacheLayer the ask's kept layer lies above the shared cache and the command's writes go to its own layer on disk
// (--overlay), which Isolated keeps for the ask when the command ends.
func (iso Isolation) cacheArgs() []string {
var argv []string
for i, c := range iso.Caches {
switch {
case iso.WriteCaches:
argv = append(argv, "--bind", c, c)
case iso.CacheLayer != "":
upper, work := iso.runLayer(i)
argv = append(argv, "--overlay-src", c, "--overlay-src", keptLayer(iso.CacheLayer, i), "--overlay", upper, work, c)
default:
argv = append(argv, "--overlay-src", c, "--tmp-overlay", c)
}
}
return argv
}
func layerDirs(layer string) []string {
if layer == "" {
return nil
}
return []string{layer}
}
// keptLayer is where the ask keeps what its commands wrote into the cache i.
func keptLayer(layer string, i int) string { return filepath.Join(layer, "kept", strconv.Itoa(i)) }
// runLayer is the upper and work directory of this command's writes into the cache i. Command, which makes no
// directory, names the layer "run"; Isolated gives each command one of its own.
func (iso Isolation) runLayer(i int) (upper, work string) {
run := iso.run
if run == "" {
run = filepath.Join(iso.CacheLayer, "run")
}
return filepath.Join(run, strconv.Itoa(i), "upper"), filepath.Join(run, strconv.Itoa(i), "work")
}
// layerLocks hold one mutex per cache layer, so two commands ending at once keep their writes one after the other.
var layerLocks sync.Map
// prepareLayer makes the kept layers and a run directory of the command's own under the cache layer.
func (iso *Isolation) prepareLayer() error {
if iso.CacheLayer == "" || iso.WriteCaches || len(iso.Caches) == 0 {
return nil
}
if err := os.MkdirAll(filepath.Join(iso.CacheLayer, "runs"), 0o700); err != nil {
return err
}
run, err := os.MkdirTemp(filepath.Join(iso.CacheLayer, "runs"), "run-")
if err != nil {
return err
}
iso.run = run
for i := range iso.Caches {
upper, work := iso.runLayer(i)
for _, d := range []string{keptLayer(iso.CacheLayer, i), upper, work} {
if err := os.MkdirAll(d, 0o700); err != nil {
return err
}
}
}
return nil
}
// keepLayer moves what the command wrote into the caches into the ask's kept layers, and removes its run directory.
// A file the command wrote replaces the kept one (a cache's newest write wins); a whiteout (a file the command
// deleted) is not kept, since a cache entry deleted by one command is no fault of a later one. A write that cannot be
// kept only means a later command builds it again.
func (iso Isolation) keepLayer() {
if iso.run == "" {
return
}
defer os.RemoveAll(iso.run)
mu, _ := layerLocks.LoadOrStore(iso.CacheLayer, &sync.Mutex{})
mu.(*sync.Mutex).Lock()
defer mu.(*sync.Mutex).Unlock()
for i := range iso.Caches {
upper, _ := iso.runLayer(i)
kept := keptLayer(iso.CacheLayer, i)
filepath.WalkDir(upper, func(p string, d fs.DirEntry, err error) error {
if err != nil || p == upper {
return nil
}
rel, _ := filepath.Rel(upper, p)
to := filepath.Join(kept, rel)
switch {
case d.IsDir():
if fi, err := os.Lstat(to); err == nil && !fi.IsDir() {
os.Remove(to)
}
os.MkdirAll(to, 0o700)
case d.Type().IsRegular():
os.Rename(p, to)
}
return nil
})
}
}
// loopbackHosts is the hosts file of a step: the loopback and nothing else.
const loopbackHosts = "127.0.0.1 localhost\n::1 localhost\n"
// Isolated is run with every command shut off as iso says (Command), run's env going inside the step: the build modules'
// run steps call their test, build and digest commands through it, never run itself. Each command gets a hosts file of
// its own, naming only the loopback, removed when it ends.
func Isolated(run forgekey.Runner, iso Isolation) forgekey.Runner {
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
dir, err := os.MkdirTemp("", "step-hosts-")
if err != nil {
return forgekey.Ran{Status: 1, Err: "the step's hosts file: " + err.Error()}
}
defer os.RemoveAll(dir)
hosts := filepath.Join(dir, "hosts")
if err := os.WriteFile(hosts, []byte(loopbackHosts), 0o644); err != nil {
return forgekey.Ran{Status: 1, Err: "the step's hosts file: " + err.Error()}
}
iso := iso
iso.Hosts = hosts
if err := iso.prepareLayer(); err != nil {
return forgekey.Ran{Status: 1, Err: "the step's cache layer: " + err.Error()}
}
defer iso.keepLayer()
argv, err := iso.command(env, name, args...)
if err != nil {
return forgekey.Ran{Status: 1, Err: err.Error()}
}
return run(ctx, nil, Bubblewrap, argv...)
}
}
-30
View File
@@ -1,30 +0,0 @@
package buildstep
import "testing"
// The shared warm caches are read under a throwaway writable layer (novox/hq ADR 0448; issue 1309): a candidate's step
// writes into them as a build does, and nothing it writes outlives it or reaches a later build; only a trunk build
// writes them.
func TestACandidatesStepWritesTheCachesOnlyIntoAThrowawayLayer(t *testing.T) {
fixedSystem(t)
iso := Isolation{Workspace: "/w", Caches: []string{"/var/cache/go-build", "/var/cache/npm"}}
argv, err := iso.Command("go", "test")
if err != nil {
t.Fatal(err)
}
for _, c := range iso.Caches {
if !holds(argv, "--overlay-src", c, "--tmp-overlay", c) {
t.Errorf("the cache %s is not under a throwaway layer: %v", c, argv)
}
if holds(argv, "--bind", c, c) {
t.Errorf("a candidate's step writes the cache %s: %v", c, argv)
}
}
iso.WriteCaches = true
argv, _ = iso.Command("go", "build")
for _, c := range iso.Caches {
if !holds(argv, "--bind", c, c) || holds(argv, "--tmp-overlay", c) {
t.Errorf("a trunk build does not write the cache %s: %v", c, argv)
}
}
}
-102
View File
@@ -1,102 +0,0 @@
//go:build integration && isolation
package buildstep
import (
"context"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 1309/S19, the issue's proof: a run step, in bubblewrap as Isolated runs it, reads no key and no login, resolves no
// name, reaches no TCP or unix socket of the machine, finds no home of the account, and leaves the shared cache it
// wrote unchanged; the workspace it writes keeps what it wrote. It needs bubblewrap and unprivileged user namespaces
// (issue 1308) on the machine that runs it, and fails naming them when they are not there.
func TestARunStepReachesNothingAndLeavesTheCachesUnchanged(t *testing.T) {
if _, err := exec.LookPath(Bubblewrap); err != nil {
t.Fatalf("bubblewrap is not installed here, so the isolation of a run step cannot be proved (issue 1309): %v", err)
}
root := t.TempDir()
account := filepath.Join(root, "account")
key, login := filepath.Join(account, ".ssh", "id_ed25519"), filepath.Join(account, "bus-login.json")
writeFile(t, filepath.Dir(key), filepath.Base(key), "PRIVATE KEY")
writeFile(t, account, "bus-login.json", `{"password":"secret"}`)
cacheDir := filepath.Join(root, "cache")
writeFile(t, cacheDir, "warm", "warm\n")
workspace := filepath.Join(root, "work", "tree")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatal(err)
}
probe := filepath.Join(workspace, "probe")
if out, err := exec.Command("go", "build", "-o", probe, "./testdata/isolated-probe").CombinedOutput(); err != nil {
t.Fatalf("building the probe: %v\n%s", err, out)
}
tcp, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer tcp.Close()
socket := filepath.Join(account, "bus.sock")
unix, err := net.Listen("unix", socket)
if err != nil {
t.Fatal(err)
}
defer unix.Close()
run := Isolated(forgekey.ExecRunnerFor(0), Isolation{Workspace: workspace, Caches: []string{cacheDir}})
r := run(context.Background(), []string{"PATH=/usr/bin:/bin"}, probe, key, login, tcp.Addr().String(), socket, "localhost.localdomain.invalid", filepath.Join(cacheDir, "warm"))
if r.Status != 0 || r.Err != "" || strings.TrimSpace(r.Stdout) != "shut off" {
t.Fatalf("the run step was not shut off (status %d, %s):\n%s%s", r.Status, r.Err, r.Stdout, r.Stderr)
}
if b, _ := os.ReadFile(filepath.Join(cacheDir, "warm")); string(b) != "warm\n" {
t.Fatalf("the step changed the shared cache: %q", b)
}
// The workspace is the step's own: what it writes there stays.
r = run(context.Background(), nil, "/bin/sh", "-c", "echo built > out")
if b, err := os.ReadFile(filepath.Join(workspace, "out")); r.Status != 0 || err != nil || string(b) != "built\n" {
t.Fatalf("the step's write to its workspace did not stay (status %d, %s%s): %q %v", r.Status, r.Stderr, r.Err, b, err)
}
}
// 1921/S2: in one ask (one cache layer), a second go build of the same package does not compile it again, and the
// shared cache is unchanged afterwards.
func TestASecondBuildOfTheAskFindsTheFirstsCompiledAndTheSharedCacheIsUnchanged(t *testing.T) {
if _, err := exec.LookPath(Bubblewrap); err != nil {
t.Fatalf("bubblewrap is not installed here (issue 1309): %v", err)
}
goBin, err := exec.LookPath("go")
if err != nil {
t.Fatal(err)
}
root := t.TempDir()
shared := filepath.Join(root, "gobuild")
if err := os.MkdirAll(shared, 0o700); err != nil {
t.Fatal(err)
}
workspace := filepath.Join(root, "ws")
tree := filepath.Join(workspace, "tree")
writeFile(t, tree, "go.mod", "module probe\n\ngo 1.22\n")
writeFile(t, tree, "probe.go", "package probe\n\nfunc Probe() int { return 42 }\n")
run := Isolated(forgekey.ExecRunnerFor(0), Isolation{Workspace: workspace, Caches: []string{shared},
CacheLayer: filepath.Join(workspace, "cachelayer"), ReadOnly: []string{filepath.Dir(filepath.Dir(goBin))}})
env := []string{"PATH=" + filepath.Dir(goBin) + ":/usr/bin:/bin", "GOCACHE=" + shared, "GOTOOLCHAIN=local", "GOFLAGS=-mod=mod"}
for i, want := range []bool{true, false} {
r := run(context.Background(), env, "go", "-C", tree, "build", "-x", "./...")
if r.Status != 0 || r.Err != "" {
t.Fatalf("build %d: status %d %s\n%s", i+1, r.Status, r.Err, r.Stderr)
}
if compiled := strings.Contains(r.Stderr, "/compile "); compiled != want {
t.Errorf("build %d compiled the package: %v, want %v\n%s", i+1, compiled, want, r.Stderr)
}
}
if entries, _ := os.ReadDir(shared); len(entries) != 0 {
t.Errorf("the shared cache was written: %d entries", len(entries))
}
}
-110
View File
@@ -1,110 +0,0 @@
package buildstep
import (
"context"
"os"
"path/filepath"
"sync"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// overlayOf answers the upper directory a command line gives the cache c, "" when it gives none.
func overlayOf(argv []string, c string) string {
for i := 0; i+3 < len(argv); i++ {
if argv[i] == "--overlay" && argv[i+3] == c {
return argv[i+1]
}
}
return ""
}
// 1921/S2: with a cache layer, a command reads the ask's kept layer above the shared cache and writes into a layer on
// disk of its own, never into the shared cache and never into a throwaway tmpfs.
func TestACommandWithACacheLayerReadsTheKeptLayerAndWritesItsOwn(t *testing.T) {
fixedSystem(t)
iso := Isolation{Workspace: "/w", Caches: []string{"/cache/gomod", "/cache/gobuild"}, CacheLayer: "/w/cachelayer"}
argv, err := iso.Command("go", "vet")
if err != nil {
t.Fatal(err)
}
for i, c := range iso.Caches {
if !holds(argv, "--overlay-src", c, "--overlay-src", keptLayer(iso.CacheLayer, i), "--overlay") {
t.Errorf("the cache %s is not read beneath the ask's kept layer: %v", c, argv)
}
if up := overlayOf(argv, c); up == "" || filepath.Dir(filepath.Dir(filepath.Dir(up))) != "/w/cachelayer" {
t.Errorf("the cache %s is written into %q, not a layer of the ask's own: %v", c, up, argv)
}
if holds(argv, "--tmp-overlay", c) || holds(argv, "--bind", c, c) {
t.Errorf("the cache %s is thrown away or written: %v", c, argv)
}
}
if _, err := (Isolation{Workspace: "/w", CacheLayer: "cachelayer"}).Command("go"); err == nil {
t.Error("a relative cache layer was taken")
}
}
// 1921/S2: what one command wrote into the caches is kept for the ask's later commands, the shared cache stays
// unchanged, two commands at once write into layers of their own (overlayfs refuses an upper layer two mounts share),
// and a command's own layer is gone when it ends.
func TestWhatOneCommandCompiledIsReadByTheNextOfTheSameAsk(t *testing.T) {
fixedSystem(t)
root := t.TempDir()
shared := filepath.Join(root, "gobuild")
if err := os.MkdirAll(shared, 0o700); err != nil {
t.Fatal(err)
}
layer := filepath.Join(root, "ws", "cachelayer")
var mu sync.Mutex
uppers := map[string]bool{}
gate := make(chan struct{})
var started sync.WaitGroup
started.Add(2)
inner := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
up := overlayOf(args, shared)
mu.Lock()
uppers[up] = true
mu.Unlock()
if holds(args, "--", "go", "build") {
started.Done()
<-gate // both commands are running before either ends
os.MkdirAll(filepath.Join(up, "ab"), 0o700)
os.WriteFile(filepath.Join(up, "ab", args[len(args)-1]+"-d"), []byte("compiled"), 0o600)
}
return forgekey.Ran{}
}
run := Isolated(inner, Isolation{Workspace: filepath.Join(root, "ws"), Caches: []string{shared}, CacheLayer: layer})
var wg sync.WaitGroup
for _, pkg := range []string{"one", "two"} {
wg.Add(1)
go func() { defer wg.Done(); run(context.Background(), nil, "go", "build", pkg) }()
}
started.Wait()
close(gate)
wg.Wait()
if len(uppers) != 2 {
t.Fatalf("two commands at once wrote into %d layers, want 2 of their own: %v", len(uppers), uppers)
}
for _, pkg := range []string{"one", "two"} {
if b, err := os.ReadFile(filepath.Join(keptLayer(layer, 0), "ab", pkg+"-d")); err != nil || string(b) != "compiled" {
t.Errorf("what the build of %s compiled is not kept for the ask: %q %v", pkg, b, err)
}
}
for up := range uppers {
if _, err := os.Stat(up); !os.IsNotExist(err) {
t.Errorf("the command's own layer %s outlived it: %v", up, err)
}
}
var next []string
Isolated(func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
next = args
return forgekey.Ran{}
}, Isolation{Workspace: filepath.Join(root, "ws"), Caches: []string{shared}, CacheLayer: layer})(context.Background(), nil, "go", "test")
if !holds(next, "--overlay-src", shared, "--overlay-src", keptLayer(layer, 0)) {
t.Errorf("the next command does not read the kept layer: %v", next)
}
if entries, _ := os.ReadDir(shared); len(entries) != 0 {
t.Errorf("the shared cache was written: %v", entries)
}
}
-115
View File
@@ -1,115 +0,0 @@
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// pairs says whether argv holds the words of want one after another.
func holds(argv []string, want ...string) bool {
for i := 0; i+len(want) <= len(argv); i++ {
match := true
for j, w := range want {
if argv[i+j] != w {
match = false
break
}
}
if match {
return true
}
}
return false
}
func fixedSystem(t *testing.T) {
t.Helper()
before := systemArgs
systemArgs = func() []string { return []string{"--ro-bind", "/usr", "/usr", "--symlink", "usr/bin", "/bin"} }
t.Cleanup(func() { systemArgs = before })
}
// A run step's command runs in bubblewrap (novox/hq ADR 0448; issue 1309): no network, no namespace shared, an
// environment of exactly what the step is given, the workspace the one tree it writes, the toolchain read-only, and
// nothing else of the machine's: not the account's home, not the forge key or a bus login, not the runtime directory,
// and no name service.
func TestARunStepRunsShutOffInBubblewrap(t *testing.T) {
fixedSystem(t)
iso := Isolation{Workspace: "/work/ab12/tree", ReadOnly: []string{"/usr/lib/go"}, Writable: []string{"/work/ab12/out"},
Env: []string{"GOFLAGS=-mod=mod", "PATH=/usr/bin"}, Hosts: "/work/ab12/hosts"}
argv, err := iso.Command("go", "test", "./...")
if err != nil {
t.Fatal(err)
}
for _, want := range [][]string{
{"--unshare-all"}, {"--die-with-parent"}, {"--new-session"}, {"--clearenv"},
{"--proc", "/proc"}, {"--dev", "/dev"}, {"--tmpfs", "/tmp"},
{"--ro-bind", "/usr", "/usr"}, {"--symlink", "usr/bin", "/bin"},
{"--ro-bind", "/usr/lib/go", "/usr/lib/go"},
{"--bind", "/work/ab12/tree", "/work/ab12/tree"}, {"--bind", "/work/ab12/out", "/work/ab12/out"},
{"--ro-bind", "/work/ab12/hosts", "/etc/hosts"},
{"--setenv", "GOFLAGS", "-mod=mod"}, {"--setenv", "PATH", "/usr/bin"}, {"--setenv", "HOME", "/tmp/home"},
{"--chdir", "/work/ab12/tree"},
{"--", "go", "test", "./..."},
} {
if !holds(argv, want...) {
t.Errorf("the command has no %v: %v", want, argv)
}
}
if holds(argv, "--share-net") || strings.Contains(strings.Join(argv, " "), "resolv.conf") || strings.Contains(strings.Join(argv, " "), "/run") {
t.Errorf("the command reaches the network, the name service or the runtime directory: %v", argv)
}
// The workspace is mounted after /tmp's tmpfs, so a workspace under /tmp is not hidden by it.
if strings.Index(strings.Join(argv, "\x00"), "--tmpfs\x00/tmp") > strings.Index(strings.Join(argv, "\x00"), "--bind\x00/work/ab12/tree") {
t.Errorf("the tmpfs of /tmp comes after the workspace: %v", argv)
}
// A HOME the step is given is its own.
iso.Env = append(iso.Env, "HOME=/work/ab12/home")
argv, _ = iso.Command("true")
if holds(argv, "--setenv", "HOME", "/tmp/home") || !holds(argv, "--setenv", "HOME", "/work/ab12/home") {
t.Errorf("the step's own HOME was replaced: %v", argv)
}
}
func TestAnIsolationNamesNoPathItCannotPin(t *testing.T) {
for _, iso := range []Isolation{
{Workspace: ""},
{Workspace: "tree"},
{Workspace: "/"},
{Workspace: "/w", ReadOnly: []string{"relative"}},
{Workspace: "/w", Writable: []string{"/"}},
{Workspace: "/w", Caches: []string{"/a/../b"}},
{Workspace: "/w", Env: []string{"NOEQUALS"}},
} {
if _, err := iso.Command("true"); err == nil {
t.Errorf("%+v was accepted", iso)
}
}
}
// Isolated runs every command through bwrap, with the step's environment inside and a hosts file that names only the
// loopback, removed after.
func TestIsolatedRunsEachCommandThroughBubblewrap(t *testing.T) {
fixedSystem(t)
var ran []string
var hosts string
inner := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
ran = append([]string{name}, args...)
for i, a := range args {
if a == "/etc/hosts" && i > 0 {
hosts = args[i-1]
}
}
return forgekey.Ran{Stdout: "ok"}
}
r := Isolated(inner, Isolation{Workspace: "/w"})(context.Background(), []string{"A=1"}, "go", "build")
if r.Stdout != "ok" || ran[0] != Bubblewrap || !holds(ran, "--setenv", "A", "1") || !holds(ran, "--", "go", "build") {
t.Fatalf("ran %v: %+v", ran, r)
}
if hosts == "" {
t.Fatalf("no hosts file was bound: %v", ran)
}
}
+6 -16
View File
@@ -1,24 +1,14 @@
package buildstep package buildstep
import "strings" import "git.novox.be/novox/mesh-sdk/go/catalogue"
// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as // LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as
// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says. // index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says.
const LauncherSuffix = ".serve.mjs" const LauncherSuffix = catalogue.LauncherSuffix
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root: for TypeScript // Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root
// every entrypoint `x.js` is started by `x.serve.mjs` beside it; an entrypoint that is not JavaScript and every other // (catalogue.Launchers): the one rule mesh-build-node, build-agent, the controller and a node taking in a build share
// language, whose build is already executable, get none. The one rule mesh-build-node, build-agent and the controller // (R4e, issue 1126), so the name a build writes is the name a reader expects.
// share (R4e), so the name a build writes is the name a reader expects.
func Launchers(language string, entrypoints []string) map[string]string { func Launchers(language string, entrypoints []string) map[string]string {
out := map[string]string{} return catalogue.Launchers(language, entrypoints)
if language != "typescript" {
return out
}
for _, entry := range entrypoints {
if strings.HasSuffix(entry, ".js") {
out[entry] = strings.TrimSuffix(entry, ".js") + LauncherSuffix
}
}
return out
} }
-124
View File
@@ -1,124 +0,0 @@
package buildstep
import (
"context"
"fmt"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// MergeResult is what MergeInto made of a head and a trunk commit: the trunk commit (Base) and the head, whole, and the
// merge commit and the paths the merge changes against the trunk commit; or, when the head does not merge cleanly, the
// files in conflict, with Merge and Changed empty.
type MergeResult struct {
Base, Head, Merge string
Conflicts []string
Changed []string
}
// MergeInto makes, in the workspace dir that FetchCommit left at commit, the merge of commit into the trunk commit
// base, as the forge's default merge would (a merge commit, never a fast-forward), and leaves the workspace at the merge
// (novox/hq ADR 0484 clause 3, ADR 0323). It is the one merge the build modules' fetch steps make, with the identity
// and flags of the controller's check merge (mesh-controller internal/builder/check_merge.go): a name nobody owns, no
// signature, no hook, nothing of the machine's git configuration that changes a merge. Nothing is pushed.
//
// A merge needs both histories, so it makes the fetch whole (the shallow clone FetchCommit made is deepened) and
// fetches base by its sha: from the mirror WithMirror names, when it holds base (UpdateMirror to base first), else
// from remote. A head that does not merge cleanly is an answer, not an error: the files in conflict, the
// merge undone and the workspace left at base. A head already in base answers a Merge equal to Base and no changed
// paths. An error is that the merge could not be tried: a base or commit that is not a full sha, no base, a workspace
// not at commit, or a fetch or git command that failed. remote and the options are those of FetchCommit; the runner's
// bound must allow a whole fetch.
func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, commit string, opts ...Option) (MergeResult, error) {
var o fetchOptions
for _, opt := range opts {
opt(&o)
}
res := MergeResult{Base: base, Head: commit}
if base == "" {
return res, fmt.Errorf("buildstep: no base: a merge needs the trunk commit it is made onto")
}
if err := checkFetch(o, dir, remote, base, ""); err != nil {
return res, err
}
if err := checkFetch(o, dir, remote, commit, ""); err != nil {
return res, err
}
if err := useMirror(o, dir); err != nil {
return res, err
}
env := []string{"GIT_TERMINAL_PROMPT=0"}
git := func(args ...string) (string, error) {
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
r := run(ctx, env, "git", full...)
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("git %s: %s", args[0], firstLine(r.Err+" "+r.Stderr))
}
return r.Stdout, nil
}
at, err := git("rev-parse", "HEAD")
if err != nil {
return res, err
}
if head := strings.TrimSpace(at); head != commit {
return res, fmt.Errorf("buildstep: the workspace is at %s, not at the commit %s", head, commit)
}
shallow, err := git("rev-parse", "--is-shallow-repository")
if err != nil {
return res, err
}
fetchFrom := func(source string) error {
fetch := []string{"fetch", "--no-tags", source, base}
if strings.TrimSpace(shallow) == "true" {
fetch = []string{"fetch", "--no-tags", "--unshallow", source, base}
}
_, err := git(fetch...)
return err
}
// With a mirror brought to the trunk commit, the history is deepened from it, a local fetch (issue 1921, try speed
// S6); a mirror that cannot answer leaves it to the forge, as before.
if o.mirror == "" || fetchFrom(o.mirror) != nil {
if err := fetchFrom("origin"); err != nil {
return res, fmt.Errorf("the trunk commit %s: %w", base, err)
}
}
if _, err := git("checkout", "-q", "--detach", base); err != nil {
return res, fmt.Errorf("the trunk commit %s cannot be checked out: %w", base, err)
}
message := fmt.Sprintf("Check the merge of %s into %s (made for a check, never pushed)", short(commit), short(base))
args := append(append([]string{}, mergeIdentity...), "merge", "--no-ff", "--no-edit", "--no-verify", "--quiet", "-m", message, commit)
if _, err := git(args...); err != nil {
out, _ := git("diff", "--name-only", "--diff-filter=U")
res.Conflicts = strings.Fields(out)
_, _ = git("merge", "--abort")
if len(res.Conflicts) == 0 {
return res, fmt.Errorf("the head %s could not be merged into the trunk commit %s: %w", short(commit), short(base), err)
}
return res, nil
}
merge, err := git("rev-parse", "HEAD")
if err != nil {
return res, fmt.Errorf("the merge cannot be read: %w", err)
}
res.Merge = strings.TrimSpace(merge)
changed, err := git("diff", "--name-only", "--no-renames", base, res.Merge)
if err != nil {
return res, fmt.Errorf("the paths the merge changes cannot be read: %w", err)
}
res.Changed = strings.Fields(changed)
return res, nil
}
// mergeIdentity is who a check's merge is made as: it is never pushed, so it names nobody's account. No hook of the
// clone's or the build machine's git configuration runs, and no setting there changes the merge (the controller's
// check merge, internal/builder/check_merge.go).
var mergeIdentity = []string{"-c", "user.name=mesh merge check", "-c", "user.email=merge-check@mesh.invalid",
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "-c", "merge.verifySignatures=false"}
func short(commit string) string {
if len(commit) > 12 {
return commit[:12]
}
return commit
}
-202
View File
@@ -1,202 +0,0 @@
//go:build integration
package buildstep
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// The merge on real git repositories starts git processes, so it is an integration test (the unit level keeps its bound,
// issue 895); the unit tests of merge_test.go prove the commands and the refusals with a fake runner.
func realGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
p := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// mergeRig is a local bare repository standing in for the forge, a work repository that pushes to it, and an empty
// workspace: the fetch step's shape (FetchCommit of the head into the workspace, then MergeInto).
type mergeRig struct {
t *testing.T
bare, work, ws string
fork, trunk string // the commit both histories share, and the trunk's head
remote string
}
func newMergeRig(t *testing.T) *mergeRig {
root := t.TempDir()
r := &mergeRig{t: t, bare: filepath.Join(root, "r.git"), work: filepath.Join(root, "work"), ws: filepath.Join(root, "ws")}
for _, d := range []string{r.bare, r.work, r.ws} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
r.remote = "file://" + r.bare
realGit(t, r.bare, "init", "-q", "--bare")
realGit(t, r.work, "init", "-q", "-b", "main")
writeFile(t, r.work, "a.txt", "one\ntwo\nthree\n")
writeFile(t, r.work, "modules/m/module.json", "{}\n")
realGit(t, r.work, "add", ".")
realGit(t, r.work, "commit", "-q", "-m", "fork point")
r.fork = realGit(t, r.work, "rev-parse", "HEAD")
return r
}
// head makes a commit on a branch off the fork point, pushes it as the pull request's head ref and answers its sha.
func (r *mergeRig) head(files map[string]string) string {
realGit(r.t, r.work, "checkout", "-q", "-B", "pr", r.fork)
for n, c := range files {
writeFile(r.t, r.work, n, c)
}
realGit(r.t, r.work, "add", ".")
realGit(r.t, r.work, "commit", "-q", "-m", "head")
h := realGit(r.t, r.work, "rev-parse", "HEAD")
realGit(r.t, r.work, "push", "-q", r.bare, "HEAD:refs/pull/1/head")
return h
}
// advance moves the trunk past the fork point with a commit changing files, pushes it and answers its sha.
func (r *mergeRig) advance(files map[string]string) string {
realGit(r.t, r.work, "checkout", "-q", "-B", "main", r.fork)
for n, c := range files {
writeFile(r.t, r.work, n, c)
}
realGit(r.t, r.work, "add", ".")
realGit(r.t, r.work, "commit", "-q", "-m", "trunk moves")
r.trunk = realGit(r.t, r.work, "rev-parse", "HEAD")
realGit(r.t, r.work, "push", "-q", "-f", r.bare, "HEAD:refs/heads/main")
return r.trunk
}
func (r *mergeRig) fetch(head string) {
if err := FetchCommit(context.Background(), forgekey.ExecRunner, r.ws, r.remote, head, "refs/pull/1/head", AllowFileRemote()); err != nil {
r.t.Fatal(err)
}
}
// 1674/AC1: a clean merge answers the merge commit and the changed files, and leaves the workspace at the merge, which
// holds both sides' changes.
func TestMergeIntoACleanMergeAnswersTheMergeCommitAndTheChangedFiles(t *testing.T) {
r := newMergeRig(t)
head := r.head(map[string]string{"b.txt": "head\n"})
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
r.fetch(head)
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
if err != nil {
t.Fatal(err)
}
if res.Base != base || res.Head != head || len(res.Merge) != 40 || res.Merge == base || res.Merge == head || len(res.Conflicts) != 0 {
t.Fatalf("result = %+v", res)
}
if !reflect.DeepEqual(res.Changed, []string{"b.txt"}) {
t.Errorf("changed = %v, want only what the head adds to the trunk: b.txt", res.Changed)
}
if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != res.Merge {
t.Errorf("the workspace is at %s, not at the merge %s", at, res.Merge)
}
for f, want := range map[string]string{"b.txt": "head\n", "a.txt": "one\ntwo\nthree\nfour\n"} {
if got, _ := os.ReadFile(filepath.Join(r.ws, f)); string(got) != want {
t.Errorf("%s = %q, want %q", f, got, want)
}
}
if parents := strings.Fields(realGit(t, r.ws, "rev-list", "--parents", "-n", "1", res.Merge)); len(parents) != 3 || parents[1] != base || parents[2] != head {
t.Errorf("the merge commit's parents = %v, want the trunk commit and the head", parents)
}
if author := realGit(t, r.ws, "log", "-1", "--format=%an <%ae>", res.Merge); author != "mesh merge check <merge-check@mesh.invalid>" {
t.Errorf("the merge is made as %q", author)
}
}
// 1674/AC2: a conflict is an answer: the files in conflict, no merge commit, and the workspace left at the trunk commit.
func TestMergeIntoAConflictAnswersTheFilesAndLeavesTheTreeAtTheTrunk(t *testing.T) {
r := newMergeRig(t)
head := r.head(map[string]string{"a.txt": "one\nHEAD\nthree\n", "c.txt": "c\n"})
base := r.advance(map[string]string{"a.txt": "one\nTRUNK\nthree\n"})
r.fetch(head)
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
if err != nil {
t.Fatalf("a conflict is an answer, not an error: %v", err)
}
if !reflect.DeepEqual(res.Conflicts, []string{"a.txt"}) || res.Merge != "" || len(res.Changed) != 0 || res.Base != base || res.Head != head {
t.Fatalf("result = %+v", res)
}
if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != base {
t.Errorf("the workspace is at %s, not at the trunk commit %s", at, base)
}
if got, _ := os.ReadFile(filepath.Join(r.ws, "a.txt")); string(got) != "one\nTRUNK\nthree\n" {
t.Errorf("a.txt = %q, want the trunk's", got)
}
if st := realGit(t, r.ws, "status", "--porcelain"); st != "" {
t.Errorf("the tree is not clean after the aborted merge: %q", st)
}
if _, err := os.Stat(filepath.Join(r.ws, "c.txt")); err == nil {
t.Error("a file of the head is in a tree left at the trunk")
}
}
// A head already in the trunk merges into nothing: the merge is the trunk commit and no path changes.
func TestMergeIntoAHeadAlreadyInTheTrunkChangesNothing(t *testing.T) {
r := newMergeRig(t)
head := r.head(map[string]string{"b.txt": "head\n"})
r.fork = head // the trunk continues from the head
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
r.fetch(head)
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
if err != nil || res.Merge != base || len(res.Changed) != 0 || len(res.Conflicts) != 0 {
t.Errorf("result = %+v, %v", res, err)
}
}
// 1921/S6: with a mirror brought to the head and to the trunk commit, the merge asks the forge for nothing: it succeeds
// with the forge gone.
func TestMergeIntoWithAMirrorAtTheTrunkNeedsNoForge(t *testing.T) {
r := newMergeRig(t)
head := r.head(map[string]string{"b.txt": "head\n"})
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
mirror := filepath.Join(t.TempDir(), "mirror.git")
if err := os.MkdirAll(mirror, 0o755); err != nil {
t.Fatal(err)
}
for _, c := range []string{head, base} {
if err := UpdateMirror(context.Background(), forgekey.ExecRunner, mirror, r.remote, c, "", AllowFileRemote()); err != nil {
t.Fatal(err)
}
}
if err := FetchCommit(context.Background(), forgekey.ExecRunner, r.ws, r.remote, head, "", AllowFileRemote(), WithMirror(mirror)); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(r.bare); err != nil { // the forge is gone
t.Fatal(err)
}
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote(), WithMirror(mirror))
if err != nil {
t.Fatalf("the merge asked the forge: %v", err)
}
if len(res.Merge) != 40 || !reflect.DeepEqual(res.Changed, []string{"b.txt"}) {
t.Errorf("result = %+v", res)
}
}
-110
View File
@@ -1,110 +0,0 @@
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 1674/AC3: no base is refused before anything runs; so is a base or a commit that is not a full sha, a workspace that
// is not at the commit, and a remote that is not ssh://.
func TestMergeIntoRefusesWhatIsNotAMerge(t *testing.T) {
f := &fakeGit{}
for name, c := range map[string]struct{ remote, base, commit string }{
"no base": {"ssh://mesh-forge/o/r.git", "", sha},
"a short base": {"ssh://mesh-forge/o/r.git", "abc123", sha},
"a branch as base": {"ssh://mesh-forge/o/r.git", "main", sha},
"a short commit": {"ssh://mesh-forge/o/r.git", sha, "abc"},
"an https remote": {"https://forge.example/o/r.git", sha, sha},
} {
_, err := MergeInto(context.Background(), f.run, "/w", c.remote, c.base, c.commit)
if err == nil {
t.Errorf("%s accepted", name)
}
}
if len(f.calls) != 0 {
t.Errorf("git ran for a refused merge: %v", f.calls)
}
g := &fakeGit{rule: func(l string) forgekey.Ran {
if strings.Contains(l, "rev-parse HEAD") {
return forgekey.Ran{Stdout: strings.Repeat("f", 40) + "\n"}
}
return forgekey.Ran{}
}}
_, err := MergeInto(context.Background(), g.run, "/w", "ssh://mesh-forge/o/r.git", strings.Repeat("a", 40), sha)
if err == nil || !strings.Contains(err.Error(), "not at the commit") || len(g.calls) != 1 {
t.Errorf("a workspace at another commit: %v after %d commands", err, len(g.calls))
}
}
// 1674/AC4: the merge is made with the check merge's identity and flags, hooks off, and fetches with no credential.
func TestMergeIntoRunsTheCheckMergesCommands(t *testing.T) {
base := strings.Repeat("b", 40)
f := &fakeGit{rule: func(l string) forgekey.Ran {
switch {
case strings.HasSuffix(l, "rev-parse HEAD"):
return forgekey.Ran{Stdout: sha + "\n"}
case strings.Contains(l, "--is-shallow-repository"):
return forgekey.Ran{Stdout: "true\n"}
}
return forgekey.Ran{}
}}
if _, err := MergeInto(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", base, sha); err != nil {
t.Fatal(err)
}
all := strings.Join(f.calls, "\n")
for _, want := range []string{
pre + "fetch --no-tags --unshallow origin " + base,
pre + "checkout -q --detach " + base,
"user.name=mesh merge check", "user.email=merge-check@mesh.invalid", "commit.gpgsign=false", "core.hooksPath=/dev/null",
"merge --no-ff --no-edit --no-verify --quiet -m",
pre + "diff --name-only --no-renames " + base,
} {
if !strings.Contains(all, want) {
t.Errorf("the commands lack %q:\n%s", want, all)
}
}
for _, c := range f.calls {
if strings.Contains(c, "https://") || !strings.Contains(c, "credential.helper= ") {
t.Errorf("command %q carries a credential or https", c)
}
}
}
// 1921/S6: with a mirror that holds the trunk commit, the merge deepens the workspace from the mirror, a local fetch,
// and asks the forge nothing; a mirror that cannot answer falls back to the forge.
func TestMergeIntoWithAMirrorFetchesTheTrunkFromTheMirror(t *testing.T) {
base := strings.Repeat("b", 40)
mirror := t.TempDir()
answer := func(failMirror bool) *fakeGit {
return &fakeGit{rule: func(l string) forgekey.Ran {
switch {
case strings.HasSuffix(l, "rev-parse HEAD"):
return forgekey.Ran{Stdout: sha + "\n"}
case strings.Contains(l, "--is-shallow-repository"):
return forgekey.Ran{Stdout: "true\n"}
case failMirror && strings.Contains(l, " fetch ") && strings.Contains(l, mirror):
return forgekey.Ran{Status: 128, Stderr: "fatal: not our ref"}
}
return forgekey.Ran{}
}}
}
f := answer(false)
dir := t.TempDir()
if _, err := MergeInto(context.Background(), f.run, dir, "ssh://mesh-forge/o/r.git", base, sha, WithMirror(mirror)); err != nil {
t.Fatal(err)
}
all := strings.Join(f.calls, "\n")
if !strings.Contains(all, "fetch --no-tags --unshallow "+mirror+" "+base) || strings.Contains(all, "--unshallow origin") {
t.Errorf("the trunk was not fetched from the mirror alone:\n%s", all)
}
g := answer(true)
if _, err := MergeInto(context.Background(), g.run, t.TempDir(), "ssh://mesh-forge/o/r.git", base, sha, WithMirror(mirror)); err != nil {
t.Fatal(err)
}
if all := strings.Join(g.calls, "\n"); !strings.Contains(all, "fetch --no-tags --unshallow origin "+base) {
t.Errorf("a mirror that could not answer did not fall back to the forge:\n%s", all)
}
}
-65
View File
@@ -1,65 +0,0 @@
//go:build integration
package buildstep
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Issue 890 C1 (AC3): a mirror brought to two commits in turn; a fetch through it checks out each commit, the
// alternates name the mirror, and no symlink is in the workspace.
func TestMirrorServesTwoCommitsWithoutSymlinks(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "src.git")
work := filepath.Join(root, "work")
for _, d := range []string{src, work} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
gitIn(t, src, "init", "-q", "--bare")
gitIn(t, work, "init", "-q")
var commits []string
for _, c := range []string{"one", "two"} {
if err := os.WriteFile(filepath.Join(work, "f"), []byte(c), 0o644); err != nil {
t.Fatal(err)
}
gitIn(t, work, "add", "f")
gitIn(t, work, "commit", "-q", "-m", c)
commits = append(commits, gitIn(t, work, "rev-parse", "HEAD"))
gitIn(t, work, "push", "-q", src, "HEAD:refs/heads/main")
}
remote := "file://" + src
mirror := filepath.Join(root, "mirror.git")
for i, commit := range commits {
if err := UpdateMirror(context.Background(), forgekey.ExecRunner, mirror, remote, commit, "", AllowFileRemote()); err != nil {
t.Fatal(err)
}
ws := filepath.Join(root, "ws"+string(rune('0'+i)))
if err := os.MkdirAll(ws, 0o755); err != nil {
t.Fatal(err)
}
if err := FetchCommit(context.Background(), forgekey.ExecRunner, ws, remote, commit, "", AllowFileRemote(), WithMirror(mirror)); err != nil {
t.Fatal(err)
}
if head := gitIn(t, ws, "rev-parse", "HEAD"); head != commit {
t.Errorf("HEAD = %s, want %s", head, commit)
}
b, _ := os.ReadFile(filepath.Join(ws, ".git", "objects", "info", "alternates"))
if !strings.Contains(string(b), filepath.Join(mirror, "objects")) {
t.Errorf("alternates %q", b)
}
_ = filepath.Walk(ws, func(p string, fi os.FileInfo, err error) error {
if err == nil && fi.Mode()&os.ModeSymlink != 0 {
t.Errorf("symlink %s", p)
}
return nil
})
}
}
-106
View File
@@ -1,106 +0,0 @@
package buildstep
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Issue 890 C1 (ADR 0536 §2): a fetch through a bare mirror names the mirror in .git/objects/info/alternates, a
// plain file, and never links it.
func TestFetchCommitWithMirrorWritesAlternates(t *testing.T) {
mirror := t.TempDir()
ws := t.TempDir()
f := &fakeGit{}
// the fake's init does not create .git; make the objects directory as git init would.
if err := os.MkdirAll(filepath.Join(ws, ".git", "objects", "info"), 0o755); err != nil {
t.Fatal(err)
}
err := FetchCommit(context.Background(), f.run, ws, "ssh://mesh-forge/novox/mesh-sdk.git", sha, "", WithMirror(mirror))
if err != nil {
t.Fatal(err)
}
p := filepath.Join(ws, ".git", "objects", "info", "alternates")
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
if got, want := strings.TrimSpace(string(b)), filepath.Join(mirror, "objects"); got != want {
t.Errorf("alternates %q, want %q", got, want)
}
if fi, err := os.Lstat(p); err != nil || !fi.Mode().IsRegular() {
t.Errorf("alternates is not a plain file: %v %v", fi, err)
}
// the alternates exist before the fetch runs
if len(f.calls) < 3 || !strings.Contains(f.calls[2], "fetch") {
t.Errorf("calls %v", f.calls)
}
}
func TestWithMirrorRefusesARelativeOrMissingMirror(t *testing.T) {
for _, m := range []string{"rel/mirror", filepath.Join(t.TempDir(), "absent")} {
f := &fakeGit{}
err := FetchCommit(context.Background(), f.run, t.TempDir(), "ssh://mesh-forge/novox/x.git", sha, "", WithMirror(m))
if err == nil || len(f.calls) != 0 {
t.Errorf("mirror %q: err %v, calls %v", m, err, f.calls)
}
}
}
func TestUpdateMirrorInitsBareThenFetches(t *testing.T) {
mirror := filepath.Join(t.TempDir(), "mirror.git")
f := &fakeGit{}
if err := UpdateMirror(context.Background(), f.run, mirror, "ssh://mesh-forge/novox/x.git", sha, ""); err != nil {
t.Fatal(err)
}
pm := "git -c credential.helper= -C " + mirror + " "
want := []string{
"git -c credential.helper= init -q --bare " + mirror,
pm + "remote add origin ssh://mesh-forge/novox/x.git",
pm + "fetch --no-tags origin " + sha,
pm + "cat-file -e " + sha + "^{commit}",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
t.Errorf("commands:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
}
}
func TestUpdateMirrorKeepsAnExistingMirrorAndFallsBackToTheRef(t *testing.T) {
mirror := t.TempDir() // exists: no init
if err := os.WriteFile(filepath.Join(mirror, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeGit{}
f.rule = func(args string) (r forgekey.Ran) {
if strings.HasSuffix(args, "origin "+sha) {
r.Status = 1
r.Stderr = "not our ref"
}
return
}
if err := UpdateMirror(context.Background(), f.run, mirror, "ssh://mesh-forge/novox/x.git", sha, "refs/pull/3/head"); err != nil {
t.Fatal(err)
}
all := strings.Join(f.calls, "\n")
if strings.Contains(all, "init") || strings.Contains(all, "remote add") || !strings.Contains(all, "fetch --no-tags origin refs/pull/3/head") {
t.Errorf("commands:\n%s", all)
}
}
func TestUpdateMirrorRefusesBeforeRunning(t *testing.T) {
f := &fakeGit{}
for _, c := range []struct{ remote, commit string }{
{"https://x/y.git", sha}, {"ssh://u@h/o/r.git", sha}, {"ssh://h/o/r.git", "abc"},
} {
if err := UpdateMirror(context.Background(), f.run, t.TempDir(), c.remote, c.commit, ""); err == nil {
t.Errorf("%v accepted", c)
}
}
if len(f.calls) != 0 {
t.Errorf("ran %v", f.calls)
}
}
-71
View File
@@ -1,71 +0,0 @@
package buildstep
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
)
// narrowKept are the files of a module's directory a narrowed build keeps beside its named paths: its manifest and
// its lock files, each an input of the module's key (ModuleTree's Manifest and Locks). package.json and go.mod are
// not kept: neither is in the named tree, so a build that needs one names it among its artifact's paths, which puts
// it in the key (novox/hq ADR 0499 clause 4).
var narrowKept = append([]string{"module.json"}, lockFiles...)
// Narrow leaves in the fetched tree only what a build of an artifact that names its paths may see (novox/hq ADR 0499
// clause 4): r.Dir's named paths, its manifest and lock files, and .git; everything else is removed, so a file outside
// them cannot change what is built without changing its key. A named path absent at the tree is an error naming it,
// and nothing is removed then. With no paths in r it changes nothing. Both build modules' fetch steps call it.
func Narrow(tree string, r FetchRequest) error {
if len(r.Paths) == 0 {
return nil
}
keep := map[string]bool{".git": true}
for _, p := range r.Paths {
rel := path.Join(r.Dir, p)
if strings.HasPrefix(rel, "../") || rel == ".." || path.IsAbs(p) {
return fmt.Errorf("the named path %q leaves the module's directory", p)
}
if _, err := os.Lstat(filepath.Join(tree, filepath.FromSlash(rel))); err != nil {
return fmt.Errorf("the named path %q is not in the fetched tree: %w", p, err)
}
keep[rel] = true
}
for _, f := range narrowKept {
keep[path.Join(r.Dir, f)] = true
}
return prune(tree, "", keep)
}
// prune removes from dir (rel within the tree) every entry that is neither kept nor on the way to one.
func prune(root, rel string, keep map[string]bool) error {
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
return err
}
for _, e := range entries {
p := path.Join(rel, e.Name())
if keep[p] {
continue
}
ancestor := false
for k := range keep {
if strings.HasPrefix(k, p+"/") {
ancestor = true
break
}
}
if ancestor && e.IsDir() {
if err := prune(root, p, keep); err != nil {
return err
}
continue
}
if err := os.RemoveAll(filepath.Join(root, filepath.FromSlash(p))); err != nil {
return err
}
}
return nil
}
-82
View File
@@ -1,82 +0,0 @@
package buildstep
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
func filesUnder(t *testing.T, root string) []string {
t.Helper()
var out []string
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
rel, _ := filepath.Rel(root, p)
out = append(out, filepath.ToSlash(rel))
}
return nil
})
sort.Strings(out)
return out
}
// A build of an artifact that names its paths sees only those paths, its manifest and its lock files (novox/hq ADR 0499
// clause 4): Narrow removes everything else of the fetched tree but .git, package.json and go.mod among it, since
// neither is in the named tree nor among the lock files; with no paths it changes nothing.
func TestNarrowLeavesOnlyTheNamedPathsTheManifestAndTheLocks(t *testing.T) {
tree := t.TempDir()
for _, f := range []string{".git/HEAD", "module.json", "package.json", "package-lock.json", "go.sum", "src/index.ts",
"src/deep/a.ts", "test/a.test.ts", "tsconfig.json", "go/delivery/x.go", "README.md", "other/module.json"} {
writeTreeFile(t, tree, f, "x")
}
if err := Narrow(tree, FetchRequest{}); err != nil {
t.Fatal(err)
}
if got := len(filesUnder(t, tree)); got != 12 {
t.Fatalf("a request without paths changed the tree: %d files", got)
}
if err := Narrow(tree, FetchRequest{Dir: ".", Paths: []string{"src", "test", "tsconfig.json"}}); err != nil {
t.Fatal(err)
}
want := ".git/HEAD,go.sum,module.json,package-lock.json,src/deep/a.ts,src/index.ts,test/a.test.ts,tsconfig.json"
if got := strings.Join(filesUnder(t, tree), ","); got != want {
t.Fatalf("the narrowed tree holds %s\nwant %s", got, want)
}
}
func TestNarrowKeepsAModuleDirectorysPathsAndFailsOnAnAbsentOne(t *testing.T) {
tree := t.TempDir()
for _, f := range []string{".git/config", "modules/a/module.json", "modules/a/go.mod", "modules/a/src/x.ts",
"modules/a/notes.md", "modules/b/module.json", "root.txt"} {
writeTreeFile(t, tree, f, "x")
}
err := Narrow(tree, FetchRequest{Dir: "modules/a", Paths: []string{"src", "gone"}})
if err == nil || !strings.Contains(err.Error(), `"gone"`) {
t.Fatalf("an absent named path gave %v", err)
}
if len(filesUnder(t, tree)) != 7 {
t.Fatal("a refused narrowing removed files")
}
if err := Narrow(tree, FetchRequest{Dir: "modules/a", Paths: []string{"src"}}); err != nil {
t.Fatal(err)
}
if got := strings.Join(filesUnder(t, tree), ","); got != ".git/config,modules/a/module.json,modules/a/src/x.ts" {
t.Fatalf("the narrowed tree holds %s", got)
}
}
// A build that needs package.json or go.mod names it among its paths, and so keys on it (ADR 0499 clause 4).
func TestNarrowKeepsAManifestOfTheLanguageOnlyWhenNamed(t *testing.T) {
tree := t.TempDir()
for _, f := range []string{"module.json", "package.json", "go.mod", "src/a.ts"} {
writeTreeFile(t, tree, f, "x")
}
if err := Narrow(tree, FetchRequest{Dir: ".", Paths: []string{"src", "package.json"}}); err != nil {
t.Fatal(err)
}
if got := strings.Join(filesUnder(t, tree), ","); got != "module.json,package.json,src/a.ts" {
t.Fatalf("the narrowed tree holds %s", got)
}
}
-100
View File
@@ -1,100 +0,0 @@
package buildstep
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
)
// Pack tars and gzips a directory the way the build agent does, byte for byte, and answers the archive and its
// digest, "sha256:" and the hex of the archive's bytes (issue 1479; novox/hq ADR 0405 §7, ADR 0453 §7). It is a
// copy of mesh-controller's internal/builder/builder.go pack (origin/main 073ccc29, :1351-1425), which stays
// where it is until the build agent goes: a build module's digest is compared with the build agent's for one
// fingerprint, so the bytes must be equal.
//
// Deterministically: regular files only (a symlink or a device is refused), sorted relative paths with
// slashes, each header carrying its name, permission bits and size and nothing else, the gzip default writer.
func Pack(root string) (archive []byte, digest string, err error) {
raw, err := pack(root)
if err != nil {
return nil, "", err
}
sum := sha256.Sum256(raw)
return raw, "sha256:" + hex.EncodeToString(sum[:]), nil
}
func pack(root string) ([]byte, error) {
info, err := os.Stat(root)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("%s is not a directory", root)
}
var paths []string
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || !info.Mode().IsRegular() {
// Only files. A symlink or a device in an archive is refused by the host that unpacks
// it, so putting one in would build something that cannot be applied.
if !info.IsDir() && !info.Mode().IsRegular() {
return fmt.Errorf("%s is neither a file nor a directory, and an archive carries "+
"only those", path)
}
return nil
}
paths = append(paths, path)
return nil
})
if err != nil {
return nil, err
}
// filepath.Walk walks in lexical order; the sort is kept as the build agent keeps it, since an archive
// whose digest changes because the traversal did is a silent failure.
sort.Strings(paths)
var out bytes.Buffer
zipped := gzip.NewWriter(&out)
writer := tar.NewWriter(zipped)
for _, path := range paths {
body, err := os.ReadFile(path)
if err != nil {
return nil, err
}
relative, err := filepath.Rel(root, path)
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
mode := int64(info.Mode().Perm())
if err := writer.WriteHeader(&tar.Header{
Name: filepath.ToSlash(relative), Mode: mode, Size: int64(len(body)),
Typeflag: tar.TypeReg,
// Everything else left at its zero value on purpose: no times, owners or names.
}); err != nil {
return nil, err
}
if _, err := writer.Write(body); err != nil {
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
if err := zipped.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
-84
View File
@@ -1,84 +0,0 @@
package buildstep
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"testing"
"time"
)
// fixture writes three files in two directories (modes 0644 and 0755).
func fixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
for _, f := range []struct {
p string
mode os.FileMode
body string
}{{"a/x.txt", 0o644, "x\n"}, {"a/run.sh", 0o755, "#!/bin/sh\necho hi\n"}, {"b/c/y.txt", 0o644, "why\n"}} {
path := filepath.Join(root, f.p)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(f.body), f.mode); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, f.mode); err != nil {
t.Fatal(err)
}
}
return root
}
// 1479/AC1: the tar stream inside the archive equals the bytes mesh-controller's pack makes for this tree.
// testdata/pack-golden.tar is the uncompressed stream, made once by running mesh-controller
// internal/builder/builder.go pack (origin/main 073ccc29, :1351-1425) on this fixture (above, modes set by chmod)
// and gunzipping its archive. The gzip layer is not compared byte for byte, since compress/gzip's output differs
// between Go versions; its header is checked to carry no name, no comment and no time.
func TestPackEqualsTheBuildAgentsArchive(t *testing.T) {
golden, err := os.ReadFile("testdata/pack-golden.tar")
if err != nil {
t.Fatal(err)
}
archive, digest, err := Pack(fixture(t))
if err != nil {
t.Fatal(err)
}
zr, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
t.Fatal(err)
}
if zr.Name != "" || zr.Comment != "" || !zr.ModTime.IsZero() {
t.Errorf("gzip header = name %q, comment %q, time %v; want none", zr.Name, zr.Comment, zr.ModTime)
}
tarBytes, err := io.ReadAll(zr)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(tarBytes, golden) {
t.Errorf("the tar stream is %d bytes, the build agent's %d, and they differ", len(tarBytes), len(golden))
}
sum := sha256.Sum256(archive)
if digest != "sha256:"+hex.EncodeToString(sum[:]) {
t.Errorf("digest = %s, not the sha256 of the archive", digest)
}
}
// 1479/AC2: touching every file's time changes nothing.
func TestPackDigestIgnoresFileTimes(t *testing.T) {
root := fixture(t)
_, first, err := Pack(root)
if err != nil {
t.Fatal(err)
}
later := time.Now().Add(48 * time.Hour)
filepath.Walk(root, func(p string, _ os.FileInfo, _ error) error { return os.Chtimes(p, later, later) })
if _, second, err := Pack(root); err != nil || second != first {
t.Errorf("digests %s and %s (%v)", first, second, err)
}
}
-25
View File
@@ -1,25 +0,0 @@
//go:build unix
package buildstep
import (
"path/filepath"
"strings"
"syscall"
"testing"
)
// 1479/AC3: anything but a file or a directory is refused, naming it. The rule of this repository is that no
// test makes a symlink, so the non-regular entry is a named pipe: Pack refuses a symlink on the same branch
// (filepath.Walk reports it by lstat as not regular).
func TestPackRefusesAnEntryThatIsNotAFile(t *testing.T) {
root := fixture(t)
pipe := filepath.Join(root, "a", "pipe")
if err := syscall.Mkfifo(pipe, 0o644); err != nil {
t.Fatal(err)
}
_, _, err := Pack(root)
if err == nil || !strings.Contains(err.Error(), pipe) {
t.Errorf("Pack = %v, want a refusal naming %s", err, pipe)
}
}
-350
View File
@@ -1,350 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **The pull loop the build modules share** (novox/hq ADR 0525 §4-§6, ADR 0536 §4; issue 1887). A build module on a node
// whose build capacity is above zero pulls the asks of its language's seat: from the delivery worker first, then check,
// then recheck, holding at most its capacity at once. Each ask runs as a step under the ask's own id, so its end is the
// key <node>.<ask id> in the module's steps state, which the asker watches; the ask is acknowledged when that end is
// written, kept alive while it runs by an in-progress signal, and redelivered by the stream to another node if this one
// stops. No part keeps a timer for an ask: the pull's own wait and the stream's acknowledgement wait are the bus's.
// Pulled is one ask a source handed over, with what decides its fate on the bus.
type Pulled struct {
// Kind is the worker it came from, filled by the puller.
Kind string
Data []byte
// Delivered is how many times the bus has handed it over, this time included.
Delivered uint64
// Ack ends it: it is never delivered again. InProgress tells the bus it is still being worked, restarting its
// acknowledgement wait. Nak hands it back to be delivered again at once (to this node or another). Term drops it.
Ack, InProgress, Nak, Term func() error
}
// AskSource is where a puller takes asks from: the seat's workers on the bus (JetStreamAsks) or a test double
// (FakeAsks).
type AskSource interface {
// Next takes one ask of kind: with wait 0 only one already waiting, else waiting for one at most wait. It answers
// nil and no error when none came.
Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error)
}
// Defaults of a Puller.
var (
// DefaultPullWait is how long one pull of the delivery worker waits for an ask while every worker is empty: the
// pull's own wait (as process.Consume's), after which the check and recheck workers are looked at again.
DefaultPullWait = 5 * time.Second
// DefaultHeartbeat is how often a running ask tells the bus it is still being worked; under the seat worker's
// acknowledgement wait of 60 s (mesh-controller internal/broker HolderConsumerFor).
DefaultHeartbeat = 20 * time.Second
)
// Puller pulls a build seat's asks and runs them.
type Puller struct {
Source AskSource
Language string // the seat's language: delivery.AskGo, AskNode or AskImage
Node string // the node this holder runs on, the first part of its step keys
Put Put // writes the module's steps state
// Capacity is how many asks the node runs at once; read before every pull, so a change takes effect at the next
// one. 0 pulls nothing.
Capacity func() int
// Run runs one ask's job; a *Refusal ends its step refused. Its result is the step's result.
Run func(ctx context.Context, ask delivery.StepAsk) (any, error)
// PullWait and Heartbeat default to DefaultPullWait and DefaultHeartbeat.
PullWait time.Duration
Heartbeat time.Duration
Logf func(format string, args ...any)
// Load reads one key of the module's steps state (nil and no error when it is absent). When set, Pull reads the
// node's pause key (delivery.PauseKey) before it pulls, and Pause and Resume write it with Put, so a pause outlives
// a restart.
Load func(key string) ([]byte, error)
mu sync.Mutex
inHand int
running map[string]RunningAsk
paused bool
wake chan struct{}
once sync.Once
}
func (p *Puller) init() {
p.once.Do(func() { p.wake = make(chan struct{}, 1) })
}
func (p *Puller) logf(format string, args ...any) {
if p.Logf != nil {
p.Logf(format, args...)
}
}
// Wake makes a waiting puller look again: after a capacity change, a resume, or a step's end.
func (p *Puller) Wake() {
p.init()
select {
case p.wake <- struct{}{}:
default:
}
}
// Pause stops the puller taking asks; the asks it holds run to their end. by names who paused it. With Load set the
// pause is written on the node's pause key first, and an error there leaves the puller as it was.
func (p *Puller) Pause(by string) error { return p.setPaused(true, by) }
// Resume lets the puller take asks again, written as Pause's is.
func (p *Puller) Resume(by string) error { return p.setPaused(false, by) }
func (p *Puller) setPaused(paused bool, by string) error {
if p.Load != nil {
key, err := delivery.PauseKey(p.Node)
if err != nil {
return err
}
raw, err := delivery.EncodePause(delivery.Pause{Paused: paused, By: by, At: now()})
if err != nil {
return err
}
if _, err := p.Put(key, json.RawMessage(raw)); err != nil {
return fmt.Errorf("buildstep: writing the pause of %s: %w", p.Node, err)
}
}
p.mu.Lock()
p.paused = paused
p.mu.Unlock()
p.Wake()
return nil
}
// restore reads the node's pause key, when Load is set.
func (p *Puller) restore() error {
if p.Load == nil {
return nil
}
key, err := delivery.PauseKey(p.Node)
if err != nil {
return err
}
raw, err := p.Load(key)
if err != nil {
return fmt.Errorf("buildstep: reading the pause of %s: %w", p.Node, err)
}
if raw == nil {
return nil
}
v, err := delivery.DecodePause(raw)
if err != nil {
return fmt.Errorf("buildstep: the pause of %s: %w", p.Node, err)
}
p.mu.Lock()
p.paused = v.Paused
p.mu.Unlock()
return nil
}
// Paused says whether the puller takes no asks.
func (p *Puller) Paused() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.paused
}
// InHand is how many asks run now.
func (p *Puller) InHand() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.inHand
}
// RunningAsk is an ask a puller runs now, as its seat's current verb says it.
type RunningAsk struct {
ID string `json:"id"`
Kind string `json:"kind"`
Module string `json:"module,omitempty"`
Started time.Time `json:"started"`
}
// Running are the asks the puller runs now, oldest first.
func (p *Puller) Running() []RunningAsk {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]RunningAsk, 0, len(p.running))
for _, r := range p.running {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })
return out
}
// room says whether the puller may take one more ask now.
func (p *Puller) room() bool {
capacity := 0
if p.Capacity != nil {
capacity = p.Capacity()
}
p.mu.Lock()
defer p.mu.Unlock()
return !p.paused && p.inHand < capacity
}
// Pull takes and runs asks until ctx ends. An ask still running then is neither ended nor acknowledged: the stream
// delivers it again, to another node or to this one restarted.
func (p *Puller) Pull(ctx context.Context) error {
p.init()
if p.Source == nil || p.Put == nil || p.Run == nil {
return errors.New("buildstep: a puller needs its source, its steps' put and its run")
}
if err := p.restore(); err != nil {
return err // pulling on a pause that cannot be read could build on a node paused on purpose
}
wait := p.PullWait
if wait <= 0 {
wait = DefaultPullWait
}
for {
if err := ctx.Err(); err != nil {
return err
}
if !p.room() {
select {
case <-ctx.Done():
return ctx.Err()
case <-p.wake:
}
continue
}
msg, err := p.next(ctx, wait)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
p.logf("the %s asks cannot be pulled: %v", p.Language, err)
select { // the pull's own wait before the next try, as an empty pull's
case <-ctx.Done():
return ctx.Err()
case <-p.wake:
case <-time.After(wait):
}
continue
}
if msg == nil {
continue
}
p.take(ctx, msg)
}
}
// next is one ask from the workers in their order: one already waiting on delivery, check or recheck, and when none
// waits, a pull of the delivery worker that waits for one.
func (p *Puller) next(ctx context.Context, wait time.Duration) (*Pulled, error) {
for _, kind := range delivery.AskKinds {
msg, err := p.Source.Next(ctx, kind, 0)
if err != nil || msg != nil {
return withKind(msg, kind), err
}
}
msg, err := p.Source.Next(ctx, delivery.AskDelivery, wait)
return withKind(msg, delivery.AskDelivery), err
}
func withKind(msg *Pulled, kind string) *Pulled {
if msg != nil {
msg.Kind = kind
}
return msg
}
// take starts one ask: it decodes it, refuses one that is not of this seat, and runs it as a step under its own id.
func (p *Puller) take(ctx context.Context, msg *Pulled) {
kind := msg.Kind
ask, err := delivery.DecodeStepAsk(msg.Data)
if err == nil && (ask.Language != p.Language || (kind != "" && ask.Kind != kind)) {
err = fmt.Errorf("the ask %s is a %s ask of %s, pulled from the %s %s worker", ask.ID, ask.Kind, ask.Language, p.Language, kind)
}
if err != nil {
// An ask that is not one runs nowhere: delivering it again would only fail again.
p.logf("an ask dropped: %v", err)
_ = msg.Term()
return
}
step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID)
if err != nil {
p.logf("the ask %s cannot start on %s: %v", ask.ID, p.Node, err)
_ = msg.Nak()
return
}
if msg.Delivered > delivery.AskRuns {
// Run AskRuns times and never ended: each run took its node down or died with it. It is run no more, and said
// (issue 1907): the asker's key fails with why.
why := fmt.Errorf("the ask was taken %d times and no run of it ended: each took its build node down or died with it, so it is run no more (poison)", msg.Delivered-1)
p.logf("the ask %s is ended on %s: %v", ask.ID, p.Node, why)
_ = finish(p.Put, key, step, nil, why, p.Logf) // run no more either way: the asker's bound says it then
_ = msg.Term()
return
}
module := ""
if ask.Build != nil {
module = ask.Build.Module
}
p.mu.Lock()
p.inHand++
if p.running == nil {
p.running = map[string]RunningAsk{}
}
p.running[ask.ID] = RunningAsk{ID: ask.ID, Kind: ask.Kind, Module: module, Started: step.Started}
p.mu.Unlock()
go p.run(ctx, msg, ask, key, step)
}
func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step) {
defer func() {
p.mu.Lock()
p.inHand--
delete(p.running, ask.ID)
p.mu.Unlock()
p.Wake()
}()
beat := p.Heartbeat
if beat <= 0 {
beat = DefaultHeartbeat
}
done := make(chan struct{})
go func() {
t := time.NewTicker(beat)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
if msg.InProgress != nil {
_ = msg.InProgress()
}
}
}
}()
result, err := protect(ctx, func(ctx context.Context) (any, error) { return p.Run(ctx, ask) })
close(done)
if ctx.Err() != nil {
// The process stops: the ask is neither ended nor acknowledged here, so the stream gives it to a node that runs.
return
}
if ferr := finish(p.Put, key, step, result, err, p.Logf); ferr != nil {
// No end was written: the ask is not acknowledged as done, so the stream gives it again and a run that can
// write its end (or the poison rule, issue 1907) ends it where its asker sees it (issue 1930).
p.logf("the ask %s ran on %s and no end of it could be written, so it is given back: %v", ask.ID, p.Node, ferr)
_ = msg.Nak()
return
}
if aerr := msg.Ack(); aerr != nil {
p.logf("the ask %s ended on %s and its acknowledgement failed: %v", ask.ID, p.Node, aerr)
}
}
-102
View File
@@ -1,102 +0,0 @@
package buildstep
import (
"context"
"fmt"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// JetStreamAsks is a build seat's workers on the bus: the shared pull consumer of each kind on the seat's stream
// (delivery.AskStream, AskWorker), which the broker makes when the seat is declared; a holder only binds them.
type JetStreamAsks struct {
JS jetstream.JetStream
Language string
mu sync.Mutex
cons map[string]jetstream.Consumer
}
func (s *JetStreamAsks) consumer(ctx context.Context, kind string) (jetstream.Consumer, error) {
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.cons[kind]; ok {
return c, nil
}
stream, err := delivery.AskStream(s.Language)
if err != nil {
return nil, err
}
worker, err := delivery.AskWorker(s.Language, kind)
if err != nil {
return nil, err
}
c, err := s.JS.Consumer(ctx, stream, worker)
if err != nil {
return nil, fmt.Errorf("binding the %s worker %s on %s: %w", kind, worker, stream, err)
}
if s.cons == nil {
s.cons = map[string]jetstream.Consumer{}
}
s.cons[kind] = c
return c, nil
}
// Next is AskSource's: a fetch of one with no wait when wait is 0, else one that waits at most wait.
func (s *JetStreamAsks) Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error) {
c, err := s.consumer(ctx, kind)
if err != nil {
return nil, err
}
var batch jetstream.MessageBatch
if wait <= 0 {
batch, err = c.FetchNoWait(1)
} else {
batch, err = c.Fetch(1, jetstream.FetchMaxWait(wait))
}
if err != nil {
return nil, err
}
select {
case msg, ok := <-batch.Messages():
if !ok {
return nil, batch.Error()
}
var delivered uint64 = 1
if meta, err := msg.Metadata(); err == nil && meta != nil {
delivered = meta.NumDelivered
}
return &Pulled{Data: append([]byte(nil), msg.Data()...), Delivered: delivered,
Ack: msg.Ack, InProgress: msg.InProgress, Nak: msg.Nak, Term: msg.Term}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// DeclareAskWorkers makes language's seat stream and its three workers on a bus with no broker: a test's (the build
// modules' integration tests start one). On the mesh the broker declares them from the seat, as it declares every
// seat's stream (mesh-controller internal/broker SeatStreams); a holder never does.
func DeclareAskWorkers(ctx context.Context, js jetstream.JetStream, language string, ackWait time.Duration) error {
stream, err := delivery.AskStream(language)
if err != nil {
return err
}
seat, _ := delivery.BuildSeat(language)
if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{"mesh.seat." + seat + ".accept.>"},
Retention: jetstream.WorkQueuePolicy}); err != nil {
return fmt.Errorf("the stream %s: %w", stream, err)
}
for _, kind := range delivery.AskKinds {
worker, _ := delivery.AskWorker(language, kind)
subject, _ := delivery.AskSubject(language, kind)
if _, err := js.CreateOrUpdateConsumer(ctx, stream, jetstream.ConsumerConfig{Durable: worker, FilterSubject: subject,
AckPolicy: jetstream.AckExplicitPolicy, AckWait: ackWait, MaxDeliver: 5}); err != nil {
return fmt.Errorf("the worker %s: %w", worker, err)
}
}
return nil
}
-113
View File
@@ -1,113 +0,0 @@
package buildstep
import (
"context"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// FakeAsks is a test double of a seat's workers for the build modules' unit tests: Publish puts an ask on a kind's
// worker, Next hands them out in their order, and the fates the puller gives them are counted. Safe for concurrent use.
type FakeAsks struct {
mu sync.Mutex
waiting map[string][][]byte
added chan struct{}
// Acked, Termed and Naked are the asks' ids or data by fate, InProgress how many heartbeats were given.
Acked, Termed, Naked []string
InProgress int
// Delivered, when set, is the delivery count every ask handed over says (1 when 0): a test of a poisoned ask sets it.
Delivered uint64
}
// NewFakeAsks is an empty double.
func NewFakeAsks() *FakeAsks {
return &FakeAsks{waiting: map[string][][]byte{}, added: make(chan struct{}, 1)}
}
// Publish puts an encoded ask on kind's worker.
func (f *FakeAsks) Publish(kind string, data []byte) {
f.mu.Lock()
f.waiting[kind] = append(f.waiting[kind], data)
f.mu.Unlock()
select {
case f.added <- struct{}{}:
default:
}
}
// PublishAsk encodes a and puts it on its kind's worker.
func (f *FakeAsks) PublishAsk(a delivery.StepAsk) error {
raw, err := delivery.EncodeStepAsk(a)
if err != nil {
return err
}
f.Publish(a.Kind, raw)
return nil
}
// Waiting is how many asks wait on kind's worker.
func (f *FakeAsks) Waiting(kind string) int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.waiting[kind])
}
// Fates is a copy of the acked, termed and naked lists.
func (f *FakeAsks) Fates() (acked, termed, naked []string) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.Acked...), append([]string(nil), f.Termed...), append([]string(nil), f.Naked...)
}
// Next is AskSource's.
func (f *FakeAsks) Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error) {
var deadline <-chan time.Time
if wait > 0 {
t := time.NewTimer(wait)
defer t.Stop()
deadline = t.C
}
for {
f.mu.Lock()
if q := f.waiting[kind]; len(q) > 0 {
data := q[0]
f.waiting[kind] = q[1:]
f.mu.Unlock()
return f.pulled(data), nil
}
f.mu.Unlock()
if wait <= 0 {
return nil, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-deadline:
return nil, nil
case <-f.added:
}
}
}
func (f *FakeAsks) pulled(data []byte) *Pulled {
id := string(data)
if a, err := delivery.DecodeStepAsk(data); err == nil {
id = a.ID
}
fate := func(list *[]string) func() error {
return func() error {
f.mu.Lock()
*list = append(*list, id)
f.mu.Unlock()
return nil
}
}
delivered := f.Delivered
if delivered == 0 {
delivered = 1
}
return &Pulled{Data: data, Delivered: delivered, Ack: fate(&f.Acked), Nak: fate(&f.Naked), Term: fate(&f.Termed),
InProgress: func() error { f.mu.Lock(); f.InProgress++; f.mu.Unlock(); return nil }}
}
-144
View File
@@ -1,144 +0,0 @@
//go:build integration
package buildstep
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
func askBus(t *testing.T, ackWait time.Duration) jetstream.JetStream {
t.Helper()
nc, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
if err := DeclareAskWorkers(context.Background(), js, delivery.AskGo, ackWait); err != nil {
t.Fatal(err)
}
return js
}
func publishAsk(t *testing.T, js jetstream.JetStream, a delivery.StepAsk) {
t.Helper()
raw, err := delivery.EncodeStepAsk(a)
if err != nil {
t.Fatal(err)
}
subject, _ := delivery.AskSubject(a.Language, a.Kind)
if _, err := js.Publish(context.Background(), subject, raw); err != nil {
t.Fatal(err)
}
}
// 1887/A2a, 1887/AC-order, 1887/AC-pull on the real server: a check ask published before a delivery ask runs after it;
// with capacity 2 and three asks two run, the third waits in the stream and starts when one ends.
func TestPullOrderAndCapacityOnTheBus(t *testing.T) {
js := askBus(t, 30*time.Second)
check, first, second := testAsk(delivery.AskCheck), testAsk(delivery.AskDelivery), testAsk(delivery.AskDelivery)
publishAsk(t, js, check)
publishAsk(t, js, first)
publishAsk(t, js, second)
release := make(chan struct{})
var mu sync.Mutex
var order []string
st := newSteps()
p := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "shanks", Put: st.put,
Capacity: func() int { return 2 }, PullWait: 200 * time.Millisecond,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
mu.Lock()
order = append(order, a.ID)
mu.Unlock()
<-release
return nil, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "two in hand", func() bool { return p.InHand() == 2 })
time.Sleep(500 * time.Millisecond)
mu.Lock()
if len(order) != 2 || order[0] != first.ID || order[1] != second.ID {
t.Fatalf("ran %v, want the two delivery asks %s %s first", order, first.ID, second.ID)
}
mu.Unlock()
release <- struct{}{}
waitFor(t, "the check taken", func() bool { mu.Lock(); defer mu.Unlock(); return len(order) == 3 })
close(release)
waitFor(t, "every step ended", func() bool {
for _, a := range []delivery.StepAsk{check, first, second} {
key, _ := delivery.AskStepKey("shanks", a.ID)
if s, ok := st.get(key); !ok || s.State != delivery.StepPassed {
return false
}
}
return true
})
info, err := js.Stream(ctx, "SEAT_NODE_BUILD_GO")
if err != nil {
t.Fatal(err)
}
waitFor(t, "the stream empty (every ask acknowledged)", func() bool {
si, err := info.Info(ctx)
return err == nil && si.State.Msgs == 0
})
}
// 1887/A2c, ADR 0525 §6: a killed puller's ask is redelivered by the stream's acknowledgement wait to the other node,
// whose step key names the reference.
func TestPullRedeliversAKilledPullersAsk(t *testing.T) {
js := askBus(t, 2*time.Second)
a := testAsk(delivery.AskDelivery)
publishAsk(t, js, a)
st := newSteps()
taken := make(chan struct{})
dying := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "ace", Put: st.put,
Capacity: func() int { return 1 }, PullWait: 200 * time.Millisecond, Heartbeat: time.Hour,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
close(taken)
<-ctx.Done()
return nil, ctx.Err()
}}
kill, killed := context.WithCancel(context.Background())
go dying.Pull(kill)
<-taken
killed()
other := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "shanks", Put: st.put,
Capacity: func() int { return 1 }, PullWait: 200 * time.Millisecond,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
return delivery.BuildAnswer{Artifacts: []delivery.AskBuilt{{Artifact: "bundle", Digest: "sha256:00", Reference: "artifact-store://x"}}}, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go other.Pull(ctx)
key, _ := delivery.AskStepKey("shanks", a.ID)
deadline := time.Now().Add(10 * time.Second)
for {
if s, ok := st.get(key); ok && s.State == delivery.StepPassed {
if want := `artifact-store://x`; !contains(string(s.Result), want) {
t.Fatalf("the step's result %s names no %s", s.Result, want)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("the killed puller's ask was not taken by shanks within 10 s")
}
time.Sleep(20 * time.Millisecond)
}
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
-44
View File
@@ -1,44 +0,0 @@
package buildstep
import (
"context"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// SeatAsks is a build seat's workers as a tools bundle reaches them: through its runtime's seat pull and settle
// (stdio.SeatPull, SeatSettle; issue 1887 A11), one message per pull, only from the workers the module's membership
// lists.
type SeatAsks struct {
Language string
// Pull and Settle are stdio.SeatPull and stdio.SeatSettle when nil; a test gives its own.
Pull func(worker string, wait time.Duration) (*stdio.Pulled, error)
Settle func(id, fate string) error
}
// Next is AskSource's.
func (s SeatAsks) Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error) {
pull, settle := s.Pull, s.Settle
if pull == nil {
pull = stdio.SeatPull
}
if settle == nil {
settle = stdio.SeatSettle
}
worker, err := delivery.AskWorker(s.Language, kind)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
p, err := pull(worker, wait)
if err != nil || p == nil {
return nil, err
}
fate := func(f string) func() error { return func() error { return settle(p.ID, f) } }
return &Pulled{Data: p.Body, Delivered: p.Delivered, Ack: fate(stdio.SettleAck), InProgress: fate(stdio.SettleProgress),
Nak: fate(stdio.SettleNak), Term: fate(stdio.SettleTerm)}, nil
}
-40
View File
@@ -1,40 +0,0 @@
package buildstep
import (
"context"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1887/A11: a bundle's source pulls the kind's worker by its seat's name and settles each ask by the runtime's id.
func TestSeatAsksPullsTheKindsWorkerAndSettlesByID(t *testing.T) {
var pulled []string
var settled []string
src := SeatAsks{Language: delivery.AskGo,
Pull: func(worker string, wait time.Duration) (*stdio.Pulled, error) {
pulled = append(pulled, worker)
if worker != "SEAT_NODE_BUILD_GO_CHECK_worker" {
return nil, nil
}
return &stdio.Pulled{ID: "p9", Worker: worker, Body: []byte(`{"v":1}`), Delivered: 2}, nil
},
Settle: func(id, fate string) error { settled = append(settled, id+" "+fate); return nil }}
if p, err := src.Next(context.Background(), delivery.AskDelivery, 0); err != nil || p != nil {
t.Fatalf("delivery = %+v, %v", p, err)
}
p, err := src.Next(context.Background(), delivery.AskCheck, time.Second)
if err != nil || p == nil || string(p.Data) != `{"v":1}` || p.Delivered != 2 {
t.Fatalf("check = %+v, %v", p, err)
}
_ = p.InProgress()
_ = p.Ack()
if len(pulled) != 2 || pulled[0] != "SEAT_NODE_BUILD_GO_DELIVERY_worker" {
t.Errorf("pulled %v", pulled)
}
if len(settled) != 2 || settled[0] != "p9 progress" || settled[1] != "p9 ack" {
t.Errorf("settled %v", settled)
}
}
-287
View File
@@ -1,287 +0,0 @@
package buildstep
import (
"context"
"encoding/json"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// steps records a steps state's puts: the last value of each key.
type steps struct {
mu sync.Mutex
keys map[string]delivery.Step
}
func newSteps() *steps { return &steps{keys: map[string]delivery.Step{}} }
func (s *steps) put(key string, value any) (uint64, error) {
raw, _ := value.(json.RawMessage)
st, err := delivery.DecodeStep(raw)
if err != nil {
return 0, err
}
s.mu.Lock()
s.keys[key] = st
s.mu.Unlock()
return 1, nil
}
func (s *steps) get(key string) (delivery.Step, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st, ok := s.keys[key]
return st, ok
}
func testAsk(kind string) delivery.StepAsk {
id := NewID()
a := delivery.StepAsk{ID: id, Kind: kind, Language: delivery.AskGo, Asker: "mesh-delivery-builder", At: time.Now()}
fetch := map[string]any{"repository": "novox/x", "commit": "0123456789abcdef0123456789abcdef01234567"}
if kind == delivery.AskDelivery {
b := buildJobFixture
b.Fetch = fetch
a.Build = &b
} else {
a.Check = &delivery.CheckJob{Fetch: fetch, Levels: []string{delivery.LevelUnit}}
}
return a
}
var buildJobFixture = delivery.BuildJob{Module: "m", Path: ".", Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("waited 2 s for %s", what)
}
time.Sleep(time.Millisecond)
}
}
// 1887/A2a: given a check ask published before a delivery ask and one slot, the delivery ask runs first.
func TestPullTakesDeliveryBeforeCheck(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
check, recheck, deliver := testAsk(delivery.AskCheck), testAsk(delivery.AskRecheck), testAsk(delivery.AskDelivery)
for _, a := range []delivery.StepAsk{recheck, check, deliver} {
if err := src.PublishAsk(a); err != nil {
t.Fatal(err)
}
}
var mu sync.Mutex
var order []string
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
mu.Lock()
order = append(order, a.Kind)
mu.Unlock()
return map[string]string{"ran": a.ID}, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "three asks acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 })
mu.Lock()
defer mu.Unlock()
if want := []string{delivery.AskDelivery, delivery.AskCheck, delivery.AskRecheck}; len(order) != 3 || order[0] != want[0] || order[1] != want[1] || order[2] != want[2] {
t.Fatalf("order = %v, want %v", order, want)
}
key, _ := delivery.AskStepKey("shanks", deliver.ID)
s, ok := st.get(key)
if !ok || s.State != delivery.StepPassed || string(s.Result) != `{"ran":"`+deliver.ID+`"}` {
t.Fatalf("the step %s = %+v (%v)", key, s, ok)
}
}
// 1887/A2a, 1887/AC-pull: given capacity 2 and three asks waiting, two run and the third waits, and it starts when
// one ends, with no timer.
func TestPullHoldsAtMostItsCapacity(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
for i := 0; i < 3; i++ {
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
}
release := make(chan struct{})
var mu sync.Mutex
started := 0
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 2 },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
mu.Lock()
started++
mu.Unlock()
<-release
return nil, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "two in hand", func() bool { return p.InHand() == 2 })
time.Sleep(30 * time.Millisecond) // three pull waits: the third must still wait
if p.InHand() != 2 || src.Waiting(delivery.AskDelivery) != 1 {
t.Fatalf("in hand %d, waiting %d; want 2 and 1", p.InHand(), src.Waiting(delivery.AskDelivery))
}
release <- struct{}{}
waitFor(t, "the third taken", func() bool { return src.Waiting(delivery.AskDelivery) == 0 })
// Taken from the source comes a moment before its run starts: wait for the start, not only the take.
waitFor(t, "the third started", func() bool { mu.Lock(); defer mu.Unlock(); return started == 3 })
close(release)
waitFor(t, "all acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 })
}
// An ask that is not one, or not this seat's, is dropped and runs nowhere.
func TestPullDropsWhatIsNoAsk(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
src.Publish(delivery.AskDelivery, []byte(`{"v":1}`))
other := testAsk(delivery.AskDelivery)
other.Language = delivery.AskNode
_ = src.PublishAsk(other)
ran := false
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "two termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 2 })
if ran {
t.Fatal("a wrong ask ran")
}
}
// load reads the recorded steps state's raw values: the pause key is not a step, so it is kept apart.
type kv struct {
mu sync.Mutex
vals map[string][]byte
}
func (k *kv) put(key string, value any) (uint64, error) {
raw, _ := value.(json.RawMessage)
k.mu.Lock()
defer k.mu.Unlock()
if k.vals == nil {
k.vals = map[string][]byte{}
}
k.vals[key] = append([]byte(nil), raw...)
return 1, nil
}
func (k *kv) load(key string) ([]byte, error) {
k.mu.Lock()
defer k.mu.Unlock()
return k.vals[key], nil
}
// 1887/A2b, 1887/AC-zero: at capacity 0 the puller takes nothing; a capacity raised takes effect at the next pull.
func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
src, st := NewFakeAsks(), &kv{}
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
var mu sync.Mutex
capacity := 0
p := &Puller{Source: src, Language: delivery.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond,
Capacity: func() int { mu.Lock(); defer mu.Unlock(); return capacity },
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
time.Sleep(50 * time.Millisecond)
if src.Waiting(delivery.AskDelivery) != 1 {
t.Fatal("a node at capacity 0 took an ask")
}
mu.Lock()
capacity = 1
mu.Unlock()
p.Wake()
waitFor(t, "the ask taken at capacity 1", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
}
// 1887/A2b: a paused puller takes nothing, its pause is kept on the pause key, and a puller started again on that state
// stays paused until resumed.
func TestPullPauseOutlivesARestart(t *testing.T) {
src, st := NewFakeAsks(), &kv{}
run := func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Load: st.load, PullWait: 10 * time.Millisecond,
Capacity: func() int { return 2 }, Run: run}
if err := p.Pause("the operator"); err != nil {
t.Fatal(err)
}
key, _ := delivery.PauseKey("shanks")
raw, _ := st.load(key)
if v, err := delivery.DecodePause(raw); err != nil || !v.Paused || v.By != "the operator" {
t.Fatalf("the pause key holds %s (%v)", raw, err)
}
// The restart: a new puller on the same state.
again := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Load: st.load, PullWait: 10 * time.Millisecond,
Capacity: func() int { return 2 }, Run: run}
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go again.Pull(ctx)
time.Sleep(50 * time.Millisecond)
if !again.Paused() || src.Waiting(delivery.AskDelivery) != 1 {
t.Fatalf("restarted: paused %v, waiting %d; want paused and 1", again.Paused(), src.Waiting(delivery.AskDelivery))
}
if err := again.Resume("the operator"); err != nil {
t.Fatal(err)
}
waitFor(t, "the ask taken after the resume", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
}
// 1887/A2c: a long step tells the bus it is still being worked; a holder that stops mid-step neither ends nor
// acknowledges its ask, so the stream gives it to another node.
func TestPullHeartbeatAndStop(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
a := testAsk(delivery.AskDelivery)
_ = src.PublishAsk(a)
running := make(chan struct{})
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Heartbeat: 5 * time.Millisecond,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
close(running)
<-ctx.Done()
return nil, ctx.Err()
}}
ctx, cancel := context.WithCancel(context.Background())
stopped := make(chan struct{})
go func() { p.Pull(ctx); close(stopped) }()
<-running
waitFor(t, "two heartbeats", func() bool { src.mu.Lock(); defer src.mu.Unlock(); return src.InProgress >= 2 })
cancel()
<-stopped
waitFor(t, "the step's goroutine gone", func() bool { return p.InHand() == 0 })
acked, termed, naked := src.Fates()
if len(acked)+len(termed)+len(naked) != 0 {
t.Fatalf("a stopped holder gave its ask a fate: acked %v termed %v naked %v", acked, termed, naked)
}
key, _ := delivery.AskStepKey("shanks", a.ID)
if s, ok := st.get(key); !ok || s.State != delivery.StepRunning {
t.Fatalf("the step %s = %+v, want left running", key, s)
}
}
// 1907: an ask taken past AskRuns (each run took its node down) is run no more: its step ends failed saying why, so the
// asker's key fails, and it is ended on the stream, not offered to another node.
func TestAPoisonedAskIsSaidAndRunNoMore(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
src.Delivered = delivery.AskRuns + 1
a := testAsk(delivery.AskDelivery)
_ = src.PublishAsk(a)
ran := false
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the ask termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 1 })
key, _ := delivery.AskStepKey("shanks", a.ID)
s, ok := st.get(key)
if ran || !ok || s.State != delivery.StepFailed || !strings.Contains(s.Reason, "taken 3 times") || !strings.Contains(s.Reason, "poison") {
t.Fatalf("ran %v, step %+v", ran, s)
}
if acked, _, naked := src.Fates(); len(acked)+len(naked) != 0 {
t.Errorf("acked %v naked %v, want it termed alone", acked, naked)
}
}
-18
View File
@@ -1,18 +0,0 @@
package buildstep
import "fmt"
// Refusal is a step that is not run on purpose, as against one that failed: the toolchain installed is not the
// recipe's, the integration level's postgres major is not the one it is built for (novox/hq ADR 0469 §1, ADR 0470
// §4; issue 1473). A step's run returns one and the runner ends the step refused with its text.
type Refusal struct {
// Text is the reason, in words the operator reads.
Text string
// Want is the version the recipe asks for and Have the one found; either may be empty.
Want, Have string
}
func (r *Refusal) Error() string { return r.Text }
// Refuse is a Refusal with its text formatted.
func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} }
+48
View File
@@ -2,9 +2,12 @@ package buildstep
import ( import (
"fmt" "fmt"
"maps"
"path" "path"
"regexp" "regexp"
"strings" "strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
var ( var (
@@ -27,6 +30,10 @@ type FetchRequest struct {
Base string `json:"base,omitempty"` Base string `json:"base,omitempty"`
Dir string `json:"dir,omitempty"` Dir string `json:"dir,omitempty"`
Paths []string `json:"paths,omitempty"` Paths []string `json:"paths,omitempty"`
// Packages are the version of each mesh package the build or the check builds against (delivery.StepAsk.Packages,
// novox/hq ADR 0573 §2, §4): the build module writes each into the workspace's go.mod and go.sum, whatever version
// the repository names, checksum-verified, and names them in its log.
Packages delivery.PackageVersions `json:"packages,omitempty"`
} }
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input. // FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
@@ -35,6 +42,8 @@ func FetchRequestProperties() map[string]any {
"dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"}, "dir": map[string]any{"type": "string", "description": "the module's directory in the repository (\".\" for its root), with paths"},
"paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"}, "description": "the paths the module names, relative to dir, from the indexer's kept inputs: the build sees only these, the manifest and its lock files"},
"packages": map[string]any{"type": "object", "additionalProperties": map[string]any{"type": "string"},
"description": "optional: the version (a full commit) of each mesh package to build against, by its module path, whatever version go.mod names (novox/hq ADR 0573 §2); the builder and the checker fill it"},
} }
} }
@@ -49,6 +58,12 @@ func (r FetchRequest) Args() map[string]any {
if len(r.Paths) > 0 { if len(r.Paths) > 0 {
args["paths"] = append([]string(nil), r.Paths...) args["paths"] = append([]string(nil), r.Paths...)
} }
if len(r.Packages) > 0 {
args["packages"] = map[string]any{}
for p, v := range r.Packages {
args["packages"].(map[string]any)[p] = v
}
}
return args return args
} }
@@ -78,9 +93,42 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
default: default:
return FetchRequest{}, fmt.Errorf("paths is a list of strings") return FetchRequest{}, fmt.Errorf("paths is a list of strings")
} }
packages, err := PackagesArg(args["packages"])
if err != nil {
return FetchRequest{}, err
}
r.Packages = packages
return r, r.Check() return r, r.Check()
} }
// PackagesArg reads a tool call's packages argument, an object of strings (as JSON gives it) or the map an ask gives;
// nil for none.
func PackagesArg(v any) (delivery.PackageVersions, error) {
out := delivery.PackageVersions{}
switch v := v.(type) {
case nil:
return nil, nil
case delivery.PackageVersions:
maps.Copy(out, v)
case map[string]string:
maps.Copy(out, v)
case map[string]any:
for p, version := range v {
s, ok := version.(string)
if !ok {
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
}
out[p] = s
}
default:
return nil, fmt.Errorf("packages is an object of versions (strings), by package")
}
if len(out) == 0 {
return nil, nil
}
return out, out.Check()
}
// Check is what ParseFetchRequest refuses. // Check is what ParseFetchRequest refuses.
func (r FetchRequest) Check() error { func (r FetchRequest) Check() error {
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") { if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
+9 -2
View File
@@ -10,11 +10,15 @@ import (
// from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495 // from the indexer's kept inputs and the build module reads them from the call, never from the manifest (ADR 0495
// clause 1). The request survives the trip through a tool call's arguments. // clause 1). The request survives the trip through a tool call's arguments.
func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) { func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
// And the versions of the mesh packages to build against (novox/hq ADR 0573 §2; issue 1585 S4).
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head", req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
Dir: ".", Paths: []string{"package.json", "src"}} Dir: ".", Paths: []string{"package.json", "src"}, Packages: map[string]string{"git.novox.be/novox/mesh-sdk/go": strings.Repeat("d", 40)}}
args := req.Args() args := req.Args()
// A tool call's arguments arrive as JSON: lists of any. // A tool call's arguments arrive as JSON: lists of any, objects of any.
args["paths"] = []any{"package.json", "src"} args["paths"] = []any{"package.json", "src"}
if p, ok := args["packages"].(map[string]any); !ok || p["git.novox.be/novox/mesh-sdk/go"] != strings.Repeat("d", 40) {
t.Fatalf("the packages are not in the call's arguments: %v", args["packages"])
}
got, err := ParseFetchRequest(args) got, err := ParseFetchRequest(args)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -42,6 +46,9 @@ func TestAFetchRequestCarriesTheNamedPathsThroughTheToolCall(t *testing.T) {
{map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"}, {map[string]any{"dir": "../a", "paths": []any{"src"}}, "leaves the repository"},
{map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"}, {map[string]any{"dir": "modules/a", "paths": []any{3}}, "paths is a list of strings"},
{map[string]any{"dir": "modules/a"}, "names a module directory and no path"}, {map[string]any{"dir": "modules/a"}, "names a module directory and no path"},
{map[string]any{"packages": map[string]any{"git.novox.be/novox/mesh-sdk/go": "v0.1.7"}}, "no full commit"},
{map[string]any{"packages": map[string]any{"a b": strings.Repeat("d", 40)}}, "no module path"},
{map[string]any{"packages": []any{"x"}}, "packages is an object"},
} { } {
args := map[string]any{} args := map[string]any{}
for k, v := range ok { for k, v := range ok {
-40
View File
@@ -1,40 +0,0 @@
package buildstep
import (
stdio "git.novox.be/novox/mesh-sdk/go"
)
// SeatTools are a build seat's verbs on one node, as its holder serves them (novox/hq ADR 0525 §10; mesh-sdk catalogue
// buildSeatVerbs): `<seat>.current` (what the node runs, its capacity, whether it is paused), `<seat>.pause` and
// `<seat>.resume`, the pause kept on the node's pause key by the puller. what names the asks in the descriptions ("Go").
func SeatTools(seat, what string, p *Puller) []stdio.Tool {
verb := func(name, description string, run func() (any, error)) stdio.Tool {
return stdio.Tool{Name: seat + "." + name, Description: description,
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(map[string]any) (any, error) { return run() }}
}
current := func() (any, error) {
capacity := 0
if p.Capacity != nil {
capacity = p.Capacity()
}
return map[string]any{"node": p.Node, "capacity": capacity, "running": p.Running(), "paused": p.Paused()}, nil
}
return []stdio.Tool{
verb("current", "The "+what+" asks this node runs now (each one's id, kind and when it started), its build capacity, "+
"and whether it is paused.", current),
verb("pause", "Take no new "+what+" ask on this node until resumed; an ask running here ends in its place. Kept "+
"across a restart (the node's pause key).", func() (any, error) {
if err := p.Pause("the seat's pause verb"); err != nil {
return nil, err
}
return current()
}),
verb("resume", "Take "+what+" asks again on this node.", func() (any, error) {
if err := p.Resume("the seat's resume verb"); err != nil {
return nil, err
}
return current()
}),
}
}
-52
View File
@@ -1,52 +0,0 @@
package buildstep
import (
"context"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1887/A9a, ADR 0525 §10: a build seat's verbs are <seat>.current, .pause and .resume; current names the ask running,
// pause stops the puller taking asks, resume lets it take them again.
func TestSeatToolsCurrentPauseResume(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
release := make(chan struct{})
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { <-release; return nil, nil }}
tools := map[string]func(map[string]any) (any, error){}
for _, tool := range SeatTools("node-build-go", "Go", p) {
tools[tool.Name] = tool.Run
}
for _, name := range []string{"node-build-go.current", "node-build-go.pause", "node-build-go.resume"} {
if tools[name] == nil {
t.Fatalf("no %s", name)
}
}
a := testAsk(delivery.AskDelivery)
_ = src.PublishAsk(a)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the ask running", func() bool { return p.InHand() == 1 })
out, _ := tools["node-build-go.current"](nil)
cur := out.(map[string]any)
running := cur["running"].([]RunningAsk)
if len(running) != 1 || running[0].ID != a.ID || running[0].Module != "m" || cur["capacity"] != 1 || cur["paused"] != false {
t.Fatalf("current = %+v", cur)
}
if out, err := tools["node-build-go.pause"](nil); err != nil || out.(map[string]any)["paused"] != true {
t.Fatalf("pause = %v, %v", out, err)
}
close(release)
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
time.Sleep(50 * time.Millisecond)
if src.Waiting(delivery.AskDelivery) != 1 {
t.Fatal("a paused node took an ask")
}
if _, err := tools["node-build-go.resume"](nil); err != nil {
t.Fatal(err)
}
waitFor(t, "the ask taken after the resume", func() bool { return src.Waiting(delivery.AskDelivery) == 0 })
}
+330
View File
@@ -0,0 +1,330 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"os"
"strconv"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// RestartReason is why a step a previous process left running is ended.
const RestartReason = "the build module restarted while it ran"
// Boot is when this process began. A step with no owner (written by an older build) started at or after it is taken
// for this process's, which may still run it, so EndOne refuses it.
var Boot = time.Now()
// Owner is this process as a step's owner (delivery.Step.Owner): `<boot id>.<pid>.<start>`, the node's boot id
// (/proc/sys/kernel/random/boot_id), the pid and Boot in milliseconds, base 36. The start keeps two processes apart
// that a reboot or a pid namespace gave the same pid. A running step whose owner is another is a previous process's.
var Owner = ownerOf(bootID, os.Getpid(), Boot)
// bootID is the node's boot id as Owner holds it; noBootID when it could not be read, which EndStaleAtStart says once.
var bootID = readBootID()
const noBootID = "no-boot-id"
func ownerOf(bootID string, pid int, start time.Time) string {
return bootID + "." + strconv.Itoa(pid) + "." + strconv.FormatInt(start.UnixMilli(), 36)
}
// readBootID is the node's boot id, or noBootID where it cannot be read (the pid and the start then tell the processes
// apart).
func readBootID() string {
b, err := os.ReadFile("/proc/sys/kernel/random/boot_id")
if id := strings.TrimSpace(string(b)); err == nil && id != "" {
return id
}
return noBootID
}
// Scan reads and writes a steps state with its revisions: its keys, a key's stored value and revision (nil and 0 for
// none), a write that lands only while the key is still at the revision read (KeptState.Update), and a delete of a key
// still at the revision read; both fail with stdio.ErrChanged when another writer was first.
type Scan struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, uint64, error)
Update func(key string, value any, revision uint64) (uint64, error)
Delete func(key string, revision uint64) error
}
// ScanOf is the Scan of a state the process keeps.
//
// Its Delete reads the key again and deletes it only while it is still at the revision read: the runtime's
// mesh/state.delete takes no revision yet, so a write landing between that read and the delete (a few milliseconds) is
// deleted with it. For a step key that is a redelivered ask begun on this node in that moment: its running key leaves
// the steps state until its run writes its end, which its asker still reads (a watch takes puts only).
func ScanOf(s stdio.KeptState) Scan {
get := func(key string) (json.RawMessage, uint64, error) {
e, err := s.Get(key)
if err != nil || e == nil {
return nil, 0, err
}
return e.Value, e.Revision, nil
}
return Scan{Keys: s.Keys, Update: s.Update, Get: get, Delete: func(key string, revision uint64) error {
_, rev, err := get(key)
if err != nil {
return err
}
if rev != revision {
return stdio.ErrChanged
}
return s.Delete(key)
}}
}
// isAsk says a step is a pulled ask's: its key is <node>.<ask id> and its tool is "<kind> ask" (a hand-started step
// has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was
// running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key.
// A key a gone process owns is deleted, never ended (EndStale); one with no owner is ended only once the asker's bound
// has passed (delivery.StepBound), when it is nobody's.
func isAsk(key string, s delivery.Step) bool {
_, _, err := delivery.ParseAskStepKey(key)
return err == nil && strings.HasSuffix(s.Tool, " ask")
}
// AskWhat says what an ask is, in one line: its kind, and its repository, head and levels, or its module and
// fingerprint.
func AskWhat(a delivery.StepAsk) string {
short := func(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
}
str := func(m map[string]any, k string) string { v, _ := m[k].(string); return v }
parts := []string{a.Kind + " ask"}
switch {
case a.Check != nil:
parts = append(parts, str(a.Check.Fetch, "repository")+"@"+short(str(a.Check.Fetch, "commit")), "levels "+strings.Join(a.Check.Levels, ","))
if a.Check.Run != "" {
parts = append(parts, "run "+a.Check.Run)
}
case a.Build != nil:
parts = append(parts, "module "+a.Build.Module)
if r := str(a.Build.Fetch, "repository"); r != "" {
parts = append(parts, r+"@"+short(str(a.Build.Fetch, "commit")))
}
if a.Build.FP != "" {
parts = append(parts, "fp "+short(a.Build.FP))
}
case a.Compose != nil:
parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit")))
}
return strings.Join(parts, ", ")
}
// endRunning writes step ended failed with why, only while its key is still at revision.
func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why string) error {
step.State, step.Reason, step.Ended, step.Result = delivery.StepFailed, why, now().UTC(), nil
raw, err := delivery.EncodeStep(step)
if err != nil {
return err
}
_, err = scan.Update(key, json.RawMessage(raw), revision)
return err
}
// EndStale ends this node's running steps that no process of this module runs any more, and answers their ids. Only keys
// of node are read; a value that is no step is left alone.
// - A hand-started step with an owner (delivery.Step.Owner) that is not this process (Owner) is ended failed with
// RestartReason at once, whatever its age: its process is gone and nothing redelivers it, so nothing else would
// write its end (issue 1890, note 21).
// - A pulled ask's step with an owner that is not this process is deleted, not ended (novox/hq ADR 0525 §6): the
// stream redelivers an ask whose node stopped (the puller neither ends nor acknowledges one at its stop), and its
// asker takes the first end it reads, so an end written here would fail a check the redelivery would still run. Its
// asker's watch takes puts only and keeps waiting for the redelivery's key, its bound the backstop; the stale running
// key leaves the steps state, and with it the delivery view's and the checker's count of what runs (note 21: it
// held one of a build node's two slots).
// - A step owned by this process is left: it may run now. A redelivered ask begun on this node is owned by it.
// - A step with no owner (written by an older build) keeps the earlier rule: a hand-started step started before
// `before` (the start of this process) is ended; a pulled ask is left to the stream's redelivery (isAsk) until its
// start is older than delivery.StepBound, and then ended "past the step bound: its asker stopped waiting at <time>".
//
// Each end and delete is logged, naming the step, its owner and why. A key written by another writer since it was read
// (a redelivered ask's start, a real end) is skipped.
func EndStale(scan Scan, node string, before time.Time, logf func(string, ...any)) ([]string, error) {
keys, err := scan.Keys()
if err != nil {
return nil, fmt.Errorf("buildstep: listing the steps of %s: %w", node, err)
}
var ended []string
for _, k := range keys {
n, _, perr := delivery.ParseStepKey(k)
if perr != nil || n != node {
continue
}
raw, rev, err := scan.Get(k)
if err != nil {
return ended, fmt.Errorf("buildstep: reading the step %s: %w", k, err)
}
if len(raw) == 0 {
continue
}
step, err := delivery.DecodeStep(raw)
if err != nil || step.State != delivery.StepRunning {
continue
}
why, said := staleWhy(k, step, before)
if why == "" {
continue
}
done := "ended"
if step.Owner != "" && isAsk(k, step) {
done = "deleted, left to the stream's redelivery"
if scan.Delete == nil {
err = fmt.Errorf("the steps state takes no delete")
} else {
err = scan.Delete(k, rev)
}
} else {
err = endRunning(scan, k, step, rev, why)
}
if err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return ended, fmt.Errorf("buildstep: ending the step %s: %w", k, err)
}
if logf != nil {
logf("the step %s (%s, started %s) was left running and is %s: %s; %s", k, step.Tool,
step.Started.UTC().Format(time.RFC3339), done, why, said)
}
ended = append(ended, step.ID)
}
return ended, nil
}
// staleWhy is why a running step is ended (or, for an ask with an owner, deleted) by EndStale and what the log says of
// its owner, or "" to leave it.
func staleWhy(key string, step delivery.Step, before time.Time) (why, said string) {
switch {
case step.Owner == Owner:
return "", ""
case step.Owner != "":
return RestartReason, "its owner " + step.Owner + " is not this process " + Owner
case isAsk(key, step):
if !step.Started.Add(delivery.StepBound).Before(now()) {
return "", ""
}
return "past the step bound: its asker stopped waiting at " + step.Started.Add(delivery.StepBound).UTC().Format("2006-01-02T15:04:05Z"),
"it names no owner (an older build wrote it)"
case step.Started.Before(before):
return RestartReason, "it names no owner (an older build wrote it) and started before this process"
}
return "", ""
}
// EndOne ends this node's running step id (or the key <node>.<id>) as failed with why. A step that is not running is
// refused, and so is one this process owns (Owner; for a step with no owner, one started since Boot: it may still run,
// whether a pulled ask or a step a tool started), one live reports as held, and one whose key changed since it was read.
func EndOne(scan Scan, node, id, why string, live func(id string) bool) (delivery.Step, error) {
id = strings.TrimSpace(id)
if n, rest, ok := strings.Cut(id, "."); ok {
if n != node {
return delivery.Step{}, fmt.Errorf("the step %q is of node %q, this module runs on %q", id, n, node)
}
id = rest
}
if strings.TrimSpace(why) == "" {
return delivery.Step{}, fmt.Errorf("say why the step is ended (why)")
}
key, err := delivery.StepKey(node, id)
if err != nil {
return delivery.Step{}, err
}
raw, rev, err := scan.Get(key)
if err != nil {
return delivery.Step{}, err
}
if len(raw) == 0 {
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
}
step, err := delivery.DecodeStep(raw)
if err != nil {
return delivery.Step{}, err
}
if step.State != delivery.StepRunning {
return delivery.Step{}, fmt.Errorf("the step %s is %s, not running", key, step.State)
}
if step.Owner == Owner || (step.Owner == "" && !step.Started.Before(Boot)) || (live != nil && live(id)) {
return delivery.Step{}, fmt.Errorf("the step %s was started by this process and may run now; only a step a previous process left is ended", key)
}
if err := endRunning(scan, key, step, rev, why); err != nil {
if errors.Is(err, stdio.ErrChanged) {
return delivery.Step{}, fmt.Errorf("the step %s changed while it was being ended; read it again", key)
}
return delivery.Step{}, err
}
step.State, step.Reason = delivery.StepFailed, why
return step, nil
}
// StaleAttempts and StaleRetry bound EndStaleAtStart's tries: the steps state is readable once the runtime serves.
var (
StaleAttempts = 6
StaleRetry = 5 * time.Second
)
// StaleServed is what EndStaleAtStart waits for before it reads the steps state: the runtime serving (stdio.Served).
// The conformance kit's left-running case (conformance.LeftRunning) hands it a closed channel, to run a module's own
// start sweep in its test.
var StaleServed = stdio.Served
// EndStaleAtStart is what a build module runs once at its start, in a goroutine: it waits for the runtime to serve,
// then ends this node's steps a previous process left running (EndStale), trying again when the state cannot be read
// yet. A step names its owner, so `before` (the start of this process) matters only for a step with no owner, written
// by an older build: such a step started before it is a previous process's. When the node's boot id cannot be read it
// says so once: Owner then tells processes apart by their pid and start only.
func EndStaleAtStart(ctx context.Context, scan Scan, node string, before time.Time, logf func(string, ...any)) {
select {
case <-ctx.Done():
return
case <-StaleServed():
}
if bootID == noBootID && logf != nil {
logf("the boot id of %s cannot be read (/proc/sys/kernel/random/boot_id): its steps' owner is %s, told apart by pid and start only", node, Owner)
}
for attempt := 1; ; attempt++ {
ended, err := EndStale(scan, node, before, logf)
if err == nil {
if logf != nil {
logf("%d steps of %s left running by a previous process ended", len(ended), node)
}
return
}
if logf != nil {
logf("ending the steps left running (try %d of %d): %v", attempt, StaleAttempts, err)
}
if attempt >= StaleAttempts {
return
}
select {
case <-ctx.Done():
return
case <-time.After(StaleRetry):
}
}
}
// EndStaleRound is EndStale as the module's periodic round runs it: at once, and only when the runtime serves (a round
// before that is skipped, the start's EndStaleAtStart and the next round cover it). It ends the steps a previous
// process left and the asks past delivery.StepBound.
func EndStaleRound(scan Scan, node string, logf func(string, ...any)) {
select {
case <-stdio.Served():
default:
return
}
if _, err := EndStale(scan, node, Boot, logf); err != nil && logf != nil {
logf("ending the steps left running: %v", err)
}
}
+162
View File
@@ -0,0 +1,162 @@
package buildstep
import (
"encoding/json"
"fmt"
"strings"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// scanOf is the steps a keys double holds, last value per key, its revision the count of its puts; a write through
// Update lands only at the revision read, as the state's does. race, when set, runs once before the next Update's check.
func scanOf(k *keys, race func(key string)) Scan {
return Scan{
Keys: func() ([]string, error) {
k.mu.Lock()
defer k.mu.Unlock()
var out []string
for key := range k.puts {
out = append(out, key)
}
return out, nil
},
Get: func(key string) (json.RawMessage, uint64, error) {
k.mu.Lock()
defer k.mu.Unlock()
s, ok := k.puts[key]
if !ok {
return nil, 0, nil
}
raw, err := delivery.EncodeStep(s[len(s)-1])
return raw, uint64(len(s)), err
},
Delete: func(key string, revision uint64) error {
k.mu.Lock()
defer k.mu.Unlock()
if uint64(len(k.puts[key])) != revision {
return stdio.ErrChanged
}
delete(k.puts, key)
return nil
},
Update: func(key string, value any, revision uint64) (uint64, error) {
if race != nil {
race(key)
}
k.mu.Lock()
n := uint64(len(k.puts[key]))
k.mu.Unlock()
if n != revision {
return 0, stdio.ErrChanged
}
return k.put(key, value)
},
}
}
// 1890 and 1890/AC6: EndStale ends this node's hand-started running steps of another owner at once, whatever their age,
// and deletes its asks of another owner (the stream redelivers them, ADR 0525 §6); of
// the steps with no owner (an older build's), the hand-started ones left running before the process began and the asks
// past the step bound (a younger ask is the stream's to redeliver); never a step this process owns or a key written
// meanwhile. EndOne ends one by hand, never one this process owns or started.
func TestEndStaleAndEndOne(t *testing.T) {
k := newKeys()
boot := time.Now()
oldBoot := Boot
Boot = boot
defer func() { Boot = oldBoot }()
gone := ownerOf("b00t", 4242, boot.Add(-time.Hour))
put := func(node, id, tool, state string, started time.Time, owner string) {
key, _ := delivery.StepKey(node, id)
_, _ = k.put(key, json.RawMessage(mustStep(t, delivery.Step{ID: id, Tool: tool, Node: node, State: state, Started: started, Owner: owner})))
}
put("ace", "old1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), "")
put("ace", "mjk3l9x0abcdef", "check ask", delivery.StepRunning, boot.Add(-time.Hour), "")
put("ace", "mjk3l9x0abcdee", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), "")
put("ace", "mjk3l9x0abcded", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute), gone) // note 21's ghost
put("ace", "gone2", "build_go_test", delivery.StepRunning, boot.Add(time.Second), gone)
put("ace", "mine1", "check ask", delivery.StepRunning, boot.Add(-time.Hour), Owner)
put("ace", "raced1", "build_go_test", delivery.StepRunning, boot.Add(-time.Hour), "")
put("ace", "new1", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), "")
put("ace", "done1", "build_go_fetch", delivery.StepPassed, boot.Add(-time.Hour), gone)
put("g14", "other1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour), gone)
// raced1 is ended by its own run between the read and the write.
raced := false
scan := scanOf(k, func(key string) {
if key == "ace.raced1" && !raced {
raced = true
put("ace", "raced1", "build_go_test", delivery.StepPassed, boot.Add(-time.Hour), "")
}
})
var said []string
ended, err := EndStale(scan, "ace", boot, func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) })
if err != nil || len(ended) != 4 {
t.Fatalf("ended %v, %v", ended, err)
}
if _, held := k.puts["ace.mjk3l9x0abcded"]; held {
t.Errorf("the ghost ask's key is still held: %+v", k.last("ace.mjk3l9x0abcded"))
}
for _, key := range []string{"ace.old1", "ace.gone2"} {
if s := k.last(key); s.State != delivery.StepFailed || s.Reason != RestartReason || s.Ended.IsZero() {
t.Errorf("%s = %+v", key, s)
}
}
if s := k.last("ace.mjk3l9x0abcdef"); s.State != delivery.StepFailed || !strings.HasPrefix(s.Reason, "past the step bound: its asker stopped waiting at ") {
t.Errorf("the old ask = %+v", s)
}
if log := strings.Join(said, "\n"); !strings.Contains(log, "ace.mjk3l9x0abcded") || !strings.Contains(log, "deleted, left to the stream's redelivery") || !strings.Contains(log, "its owner "+gone+" is not this process "+Owner) {
t.Errorf("the log does not name the ghost and its owner:\n%s", log)
}
for key, state := range map[string]string{"ace.mjk3l9x0abcdee": delivery.StepRunning, "ace.mine1": delivery.StepRunning, "ace.raced1": delivery.StepPassed,
"ace.new1": delivery.StepRunning, "ace.done1": delivery.StepPassed, "g14.other1": delivery.StepRunning} {
if s := k.last(key); s.State != state {
t.Errorf("%s = %s, want %s", key, s.State, state)
}
}
// EndOne: refuses without why, a held step, one this process owns or (with no owner) started, a step not running and
// another node's; ends a step a previous process left.
for _, c := range []struct{ id, why string }{{"old1", ""}, {"new1", "stuck"}, {"mine1", "stuck"}, {"done1", "x"}, {"g14.other1", "x"}} {
if _, err := EndOne(scan, "ace", c.id, c.why, nil); err == nil {
t.Errorf("ended %s with why %q", c.id, c.why)
}
}
put("ace", "old2", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second), gone)
if _, err := EndOne(scan, "ace", "old2", "x", func(string) bool { return true }); err == nil {
t.Error("ended a held step")
}
if _, err := EndOne(scan, "ace", "ace.old2", "stuck for good", nil); err != nil {
t.Fatal(err)
}
if s := k.last("ace.old2"); s.State != delivery.StepFailed || s.Reason != "stuck for good" || s.Owner != gone {
t.Errorf("old2 = %+v", s)
}
if a, b := ownerOf("b", 1, boot), ownerOf("b", 1, boot.Add(time.Second)); a == b {
t.Errorf("two starts of one pid share the owner %q", a)
}
}
func mustStep(t *testing.T, s delivery.Step) []byte {
t.Helper()
raw, err := delivery.EncodeStep(s)
if err != nil {
t.Fatal(err)
}
return raw
}
// 1890: AskWhat names the repository, head and levels of a check ask and the module of a build ask.
func TestAskWhat(t *testing.T) {
check := delivery.StepAsk{Kind: "check", Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": "0123456789abcdef0123"}, Levels: []string{"unit", "integration"}}}
if got, want := AskWhat(check), "check ask, novox/mesh-sdk@0123456789ab, levels unit,integration"; got != want {
t.Errorf("check = %q", got)
}
build := delivery.StepAsk{Kind: "delivery", Build: &delivery.BuildJob{Module: "mesh-x", FP: "abcdef0123456789"}}
if got, want := AskWhat(build), "delivery ask, module mesh-x, fp abcdef012345"; got != want {
t.Errorf("build = %q", got)
}
}
+2 -126
View File
@@ -1,30 +1,14 @@
// Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once // Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once
// and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard // and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard
// (issue 1473) and the fetch of one commit over SSH (issue 1474). // (issue 1473), the fetch of one commit over SSH (issue 1474), and the failure contract of a test level with the parsers
// of go test -json and node --test's TAP that fill it (failure.go, issue 1901; its conformance kit is package conformance).
package buildstep package buildstep
import ( import (
"context"
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"strconv" "strconv"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Put writes one key of a state: stdio.State(delivery.StepsState).Put's shape (go/state.go).
type Put func(key string, value any) (uint64, error)
// EndAttempts and EndRetry bound how a step's end is written: a write that fails is tried again, since the end
// is the only way the step's reader learns it is over.
var (
EndAttempts = 5
EndRetry = 2 * time.Second
) )
var now = time.Now var now = time.Now
@@ -36,111 +20,3 @@ func NewID() string {
_, _ = rand.Read(b[:]) _, _ = rand.Read(b[:])
return strconv.FormatInt(now().UnixMilli(), 36) + hex.EncodeToString(b[:]) return strconv.FormatInt(now().UnixMilli(), 36) + hex.EncodeToString(b[:])
} }
// Start begins a step of tool on node and answers its id at once: the key <node>.<id> holds state running when
// Start returns. run is then called in a goroutine of its own, under ctx (the process's, not the tool call's: the
// call is cut at 30 s, the step is not). When it returns, the key is written passed with run's result, failed with
// the error's text, or refused when the error is a *Refusal; a panic in run ends the step failed, naming it, and
// the process goes on. If the running write fails, nothing was started and Start answers the error.
func Start(ctx context.Context, put Put, node, tool string, run func(ctx context.Context) (result any, err error)) (string, error) {
step, key, err := begin(put, node, tool, NewID())
if err != nil {
return "", err
}
go end(ctx, put, key, step, run)
return step.ID, nil
}
// begin writes the step running under id.
func begin(put Put, node, tool, id string) (delivery.Step, string, error) {
step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC()}
key, err := delivery.StepKey(node, step.ID)
if err != nil {
return delivery.Step{}, "", err
}
if err := write(put, key, step); err != nil {
return delivery.Step{}, "", fmt.Errorf("buildstep: writing the running step %s: %w", key, err)
}
return step, key, nil
}
// end runs run and writes the step's end.
func end(ctx context.Context, put Put, key string, step delivery.Step, run func(ctx context.Context) (any, error)) {
result, err := protect(ctx, run)
_ = finish(put, key, step, result, err, log.Printf) // finish says a refused end itself
}
// finish writes the step's end from run's result or error: passed, failed or refused (a *Refusal). A write that fails is
// tried EndAttempts times. When the whole end is refused every time (a result the state will not take: issue 1930), the
// refusal is logged and a small failed end without the result is written instead, naming the write's error, so the
// step's reader learns it is over and why. finish answers an error only when no end could be written at all, and then
// the step's ask is not acknowledged as done.
func finish(put Put, key string, step delivery.Step, result any, err error, logf func(format string, args ...any)) error {
step.Ended = now().UTC()
switch ref := (*Refusal)(nil); {
case err == nil:
step.State = delivery.StepPassed
if result != nil {
raw, merr := json.Marshal(result)
if merr != nil {
step.State, step.Reason = delivery.StepFailed, "the result is not JSON: "+merr.Error()
} else {
step.Result = raw
}
}
case errors.As(err, &ref):
step.State, step.Reason = delivery.StepRefused, err.Error()
default:
step.State, step.Reason = delivery.StepFailed, err.Error()
}
werr := writeEnd(put, key, step)
if werr == nil {
return nil
}
if logf != nil {
logf("the end of the step %s (%s) could not be written in %d tries: %v", key, step.State, EndAttempts, werr)
}
ended := step.State
why := fmt.Sprintf("the step ended %s, and its end could not be written: %v", ended, werr)
if step.Reason != "" {
why = fmt.Sprintf("the step ended %s (%s), and its end could not be written: %v", ended, step.Reason, werr)
}
step.State, step.Reason, step.Result = delivery.StepFailed, why, nil
if ferr := writeEnd(put, key, step); ferr != nil {
if logf != nil {
logf("the failed end of the step %s could not be written either: %v", key, ferr)
}
return fmt.Errorf("buildstep: no end of the step %s could be written: %w", key, ferr)
}
return nil
}
// writeEnd writes the step's end, trying EndAttempts times.
func writeEnd(put Put, key string, step delivery.Step) error {
for attempt := 1; ; attempt++ {
werr := write(put, key, step)
if werr == nil || attempt >= EndAttempts {
return werr
}
time.Sleep(EndRetry)
}
}
// protect is run with a panic turned into the error that ends the step.
func protect(ctx context.Context, run func(ctx context.Context) (any, error)) (result any, err error) {
defer func() {
if p := recover(); p != nil {
result, err = nil, fmt.Errorf("the step panicked: %v", p)
}
}()
return run(ctx)
}
func write(put Put, key string, step delivery.Step) error {
raw, err := delivery.EncodeStep(step)
if err != nil {
return err
}
_, err = put(key, json.RawMessage(raw))
return err
}
-82
View File
@@ -1,9 +1,7 @@
package buildstep package buildstep
import ( import (
"context"
"encoding/json" "encoding/json"
"errors"
"sync" "sync"
"testing" "testing"
"time" "time"
@@ -53,71 +51,6 @@ func (k *keys) waitEnd(t *testing.T) string {
} }
} }
// 1471/AC1: a run that blocks: Start answers an id within 100 ms and the key holds running.
func TestStartAnswersAtOnceWithTheStepRunning(t *testing.T) {
k := newKeys()
release := make(chan struct{})
defer close(release)
began := time.Now()
id, err := Start(context.Background(), k.put, "ace", "fetch", func(ctx context.Context) (any, error) {
<-release
return nil, nil
})
if err != nil {
t.Fatal(err)
}
if d := time.Since(began); d > 100*time.Millisecond {
t.Errorf("Start took %v", d)
}
s := k.last("ace." + id)
if s.State != delivery.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() {
t.Errorf("step = %+v", s)
}
}
// 1471/AC2 and 1473/AC4: a result, an error and a Refusal end the step passed, failed and refused.
func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) {
for _, c := range []struct {
name string
run func(context.Context) (any, error)
state string
reason string
result string
}{
{"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, delivery.StepPassed, "", `{"sha":"abc"}`},
{"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, delivery.StepFailed, "exit 2", ""},
{"refusal", func(context.Context) (any, error) {
return nil, &Refusal{Text: "go 1.27.1 installed, the recipe builds with go 1.27.0", Want: "1.27.0", Have: "1.27.1"}
}, delivery.StepRefused, "go 1.27.1 installed, the recipe builds with go 1.27.0", ""},
} {
t.Run(c.name, func(t *testing.T) {
k := newKeys()
id, err := Start(context.Background(), k.put, "ace", "test", c.run)
if err != nil {
t.Fatal(err)
}
if got := k.waitEnd(t); got != "ace."+id {
t.Fatalf("ended key %q", got)
}
s := k.last("ace." + id)
if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() {
t.Errorf("step = %+v", s)
}
})
}
}
// 1471/AC3: a panic in run ends the step failed, naming it, and the test process goes on.
func TestAPanicEndsTheStepFailed(t *testing.T) {
k := newKeys()
id, _ := Start(context.Background(), k.put, "ace", "build", func(context.Context) (any, error) { panic("boom") })
k.waitEnd(t)
s := k.last("ace." + id)
if s.State != delivery.StepFailed || s.Reason != "the step panicked: boom" {
t.Errorf("step = %+v", s)
}
}
// 1471/AC5: ids made in one and the same millisecond differ, and each is one key token. The clock is fixed // 1471/AC5: ids made in one and the same millisecond differ, and each is one key token. The clock is fixed
// while no step runs, so no goroutine reads it meanwhile. // while no step runs, so no goroutine reads it meanwhile.
func TestTwoIDsInOneMillisecondDiffer(t *testing.T) { func TestTwoIDsInOneMillisecondDiffer(t *testing.T) {
@@ -137,18 +70,3 @@ func TestTwoIDsInOneMillisecondDiffer(t *testing.T) {
} }
} }
} }
// A first write that fails starts nothing.
func TestStartAnswersTheErrorWhenTheRunningWriteFails(t *testing.T) {
ran := make(chan struct{}, 1)
_, err := Start(context.Background(), func(string, any) (uint64, error) { return 0, errors.New("no grant") }, "ace", "fetch",
func(context.Context) (any, error) { ran <- struct{}{}; return nil, nil })
if err == nil {
t.Fatal("Start answered no error")
}
select {
case <-ran:
t.Error("run was called")
case <-time.After(50 * time.Millisecond):
}
}
-49
View File
@@ -1,49 +0,0 @@
// The probe a run step's isolation is proved with (novox/hq ADR 0448; issue 1309, S19): run inside the step, it says
// on stdout every way it reached what a step must not, and "shut off" when it reached none. Its arguments: the files
// it must not read (the forge key, a bus login), a TCP address and a unix socket of the machine it must not reach, a
// name it must not resolve, and a cache file it writes, which must be unchanged after the step.
package main
import (
"fmt"
"net"
"os"
"time"
)
func main() {
if len(os.Args) != 7 {
fmt.Println("usage: probe <key> <login> <tcp address> <unix socket> <name> <cache file>")
os.Exit(2)
}
key, login, tcp, socket, name, cache := os.Args[1], os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6]
reached := 0
say := func(f string, a ...any) { reached++; fmt.Printf(f+"\n", a...) }
for _, f := range []string{key, login} {
if _, err := os.ReadFile(f); err == nil {
say("read %s", f)
}
}
if c, err := net.DialTimeout("tcp", tcp, 2*time.Second); err == nil {
c.Close()
say("reached %s", tcp)
}
if c, err := net.DialTimeout("unix", socket, 2*time.Second); err == nil {
c.Close()
say("reached %s", socket)
}
if addrs, err := net.LookupHost(name); err == nil {
say("resolved %s to %v", name, addrs)
}
if home, err := os.UserHomeDir(); err == nil {
if entries, err := os.ReadDir(home); err == nil && len(entries) > 0 {
say("the home %s holds %d entries", home, len(entries))
}
}
if err := os.WriteFile(cache, []byte("written by the step\n"), 0o644); err != nil {
say("could not write the cache under its throwaway layer: %v", err)
}
if reached == 0 {
fmt.Println("shut off")
}
}
Binary file not shown.
+7 -115
View File
@@ -1,123 +1,15 @@
package buildstep package buildstep
import ( // ModuleTree is what a merge holds of one module directory, as a build module's preview answers it (novox/hq ADR 0484
"context" // clause 3, as ADR 0593 §2 changes it): its git tree id, the sha256 of its module.json, the sha256 of each lock file it
"crypto/sha256" // has (go.sum, package-lock.json), and, of a module whose every artifact names its paths, each named path's git object
"encoding/hex" // id at the commit. The indexer alone computes from them (the named tree, the fingerprint); the build module's runner
"fmt" // (mesh-build-go's buildstep.ModuleTrees) fills them.
"os"
"path"
"path/filepath"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// ModuleTree is what a merge holds of one module directory: its tree id, the sha256 of its module.json and the sha256
// of each lock file it has (go.sum, package-lock.json), from which the checker and the indexer take the module's inputs
// (novox/hq ADR 0484 clause 3).
type ModuleTree struct { type ModuleTree struct {
Tree string `json:"tree"` Tree string `json:"tree"`
Manifest string `json:"manifest"` Manifest string `json:"manifest"`
Locks map[string]string `json:"locks,omitempty"` Locks map[string]string `json:"locks,omitempty"`
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
Paths map[string]string `json:"paths,omitempty"` Paths map[string]string `json:"paths,omitempty"`
// Packages are no longer filled (ADR 0593 §2): kept so a preview written before still decodes.
Packages []string `json:"packages,omitempty"`
} }
var lockFiles = []string{"go.sum", "package-lock.json"}
// ModuleTrees are the trees, in commit, of every directory of the workspace dir that holds a module.json, by directory
// (the repository's root is "."); for a module whose every artifact names its paths, the named tree of those paths and
// their objects (novox/hq ADR 0499 clause 2). The workspace is at commit, so the manifests and the lock files are read from it. env is
// the environment git runs with (nil for none). Both build modules' fetch steps answer it for a merge.
func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, commit string) (map[string]ModuleTree, error) {
r := run(ctx, env, "git", "-C", dir, "ls-files", "--", "module.json", "*/module.json")
if r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
}
trees := map[string]ModuleTree{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l == "" {
continue
}
mdir := filepath.ToSlash(filepath.Dir(l))
spec := commit + ":" + mdir
if mdir == "." {
spec = commit + "^{tree}"
}
t := run(ctx, env, "git", "-C", dir, "rev-parse", spec)
if t.Status != 0 || t.Err != "" {
return nil, fmt.Errorf("git rev-parse %s: %s", spec, firstLine(t.Err+" "+t.Stderr))
}
mt := ModuleTree{Tree: strings.TrimSpace(t.Stdout)}
raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(l)))
if err != nil {
return nil, err
}
mt.Manifest = sha256Hex(raw)
if err := narrow(ctx, run, env, dir, commit, mdir, raw, &mt); err != nil {
return nil, err
}
for _, lock := range lockFiles {
if raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(mdir), lock)); err == nil {
if mt.Locks == nil {
mt.Locks = map[string]string{}
}
mt.Locks[lock] = sha256Hex(raw)
}
}
trees[mdir] = mt
}
return trees, nil
}
// narrow sets mt's Paths and its named Tree when the module's manifest names paths on every artifact (novox/hq ADR 0499
// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview reads the manifest here, as
// the indexer does, with catalogue.ParseManifestForInputs (issue 1751), and no other build step reads paths from one (ADR 0495 clause 1). A manifest that does not parse
// is not narrowed: the indexer gives it none. A named path absent at commit fails, naming the module and the path.
func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, mdir string, raw []byte, mt *ModuleTree) error {
m, err := catalogue.ParseManifestForInputs(raw)
if err != nil {
return nil
}
paths, every := m.NamedPaths()
if !every {
return nil
}
full := make([]string, len(paths))
for i, p := range paths {
full[i] = path.Join(mdir, p)
}
r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, full...)...)
if r.Status != 0 || r.Err != "" {
return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, firstLine(r.Err+" "+r.Stderr))
}
objects := map[string]string{}
for _, l := range strings.Split(r.Stdout, "\x00") {
meta, name, ok := strings.Cut(l, "\t")
fields := strings.Fields(meta)
if !ok || len(fields) != 3 {
continue
}
objects[name] = fields[2]
}
mt.Paths = map[string]string{}
for i, p := range paths {
object := objects[full[i]]
if object == "" {
return fmt.Errorf("the module %s names the path %q, which is not in %s", mdir, p, commit)
}
mt.Paths[p] = object
}
named, err := delivery.NamedTree(mt.Manifest, mt.Paths)
if err != nil {
return fmt.Errorf("the module %s: %w", mdir, err)
}
mt.Tree = named
return nil
}
func sha256Hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
-63
View File
@@ -1,63 +0,0 @@
//go:build integration
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// On a real repository shaped like mesh-sdk (novox/hq ADR 0499 clauses 2 and 6), a commit touching only the Go package
// leaves the TypeScript package's tree value equal, and a commit touching its source changes it; a module that names no
// path keeps its directory's tree id.
func TestAGoOnlyCommitLeavesTheNamedTreeEqualOnRealGit(t *testing.T) {
repo := t.TempDir()
realGit(t, repo, "init", "-q", "-b", "main")
writeFile(t, repo, "module.json", `{"module":"mesh-sdk","version":"1","build":{"artifacts":[`+
`{"name":"lib","kind":"package","language":"typescript","paths":["src","test","package.json","package-lock.json","tsconfig.json"]}]}}`)
writeFile(t, repo, "src/index.ts", "export const a = 1\n")
writeFile(t, repo, "test/a.test.ts", "// a\n")
writeFile(t, repo, "package.json", "{}\n")
writeFile(t, repo, "package-lock.json", "{}\n")
writeFile(t, repo, "tsconfig.json", "{}\n")
writeFile(t, repo, "go/delivery/x.go", "package delivery\n")
writeFile(t, repo, "other/module.json", `{"module":"other","version":"1"}`)
writeFile(t, repo, "other/a.txt", "a\n")
commit := func(msg string) string {
realGit(t, repo, "add", "-A")
realGit(t, repo, "commit", "-q", "-m", msg)
return realGit(t, repo, "rev-parse", "HEAD")
}
trees := func(c string) map[string]ModuleTree {
got, err := ModuleTrees(context.Background(), forgekey.ExecRunner, nil, repo, c)
if err != nil {
t.Fatal(err)
}
return got
}
first := trees(commit("first"))
if !strings.HasPrefix(first["."].Tree, "named:") || len(first["."].Paths) != 5 {
t.Fatalf("the root module was not narrowed: %+v", first["."])
}
if want := realGit(t, repo, "rev-parse", "HEAD:other"); first["other"].Tree != want || first["other"].Paths != nil {
t.Fatalf("a module naming no path: %+v, want its directory's tree %s", first["other"], want)
}
writeFile(t, repo, "go/delivery/x.go", "package delivery\n\nconst B = 2\n")
goOnly := trees(commit("go only"))
if goOnly["."].Tree != first["."].Tree {
t.Fatalf("a Go-only commit changed the TypeScript package's tree: %s, was %s", goOnly["."].Tree, first["."].Tree)
}
writeFile(t, repo, "src/index.ts", "export const a = 2\n")
src := trees(commit("src"))
if src["."].Tree == first["."].Tree {
t.Fatal("a commit to src/ left the TypeScript package's tree equal")
}
if src["."].Paths["src"] != realGit(t, repo, "rev-parse", "HEAD:src") {
t.Fatalf("the object of src is %s, git says %s", src["."].Paths["src"], realGit(t, repo, "rev-parse", "HEAD:src"))
}
}
-113
View File
@@ -1,113 +0,0 @@
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// A module whose every artifact names its paths takes its tree from them (novox/hq ADR 0499 clause 2): ModuleTrees asks
// git for each named path's object at the commit, keeps them in Paths and answers their delivery.NamedTree as Tree; a
// module naming none keeps its directory's tree, as before.
func TestModuleTreesNarrowsAModuleWhoseArtifactsNameTheirPaths(t *testing.T) {
root := t.TempDir()
sdk := `{"module":"mesh-sdk","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","package.json"]}]}}`
writeTreeFile(t, root, "module.json", sdk)
writeTreeFile(t, root, "modules/b/module.json", `{"module":"b","version":"1"}`)
merge := strings.Repeat("c", 40)
var lsTree []string
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "module.json\nmodules/b/module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "tree-of-" + args[3] + "\n"}
case "ls-tree":
lsTree = append(lsTree, strings.Join(args[4:], " "))
return forgekey.Ran{Stdout: "100644 blob " + strings.Repeat("b", 40) + "\tpackage.json\x00" +
"040000 tree " + strings.Repeat("a", 40) + "\tsrc\x00"}
}
return forgekey.Ran{Status: 1, Stderr: "unexpected " + args[2]}
}
got, err := ModuleTrees(context.Background(), run, nil, root, merge)
if err != nil {
t.Fatal(err)
}
paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)}
want, _ := delivery.NamedTree(hexOf(sdk), paths)
if g := got["."]; g.Tree != want || len(g.Paths) != 2 || g.Paths["src"] != paths["src"] || g.Paths["package.json"] != paths["package.json"] {
t.Fatalf("the root module = %+v, want tree %s and paths %v", g, want, paths)
}
if len(lsTree) != 1 || lsTree[0] != merge+" -- package.json src" {
t.Fatalf("git ls-tree ran as %v", lsTree)
}
if g := got["modules/b"]; g.Tree != "tree-of-"+merge+":modules/b" || g.Paths != nil {
t.Fatalf("a module naming no path was narrowed: %+v", g)
}
}
// A named path absent at the commit fails the answer, naming the module and the path: a tree computed without it would
// pin a build source that does not exist.
func TestModuleTreesFailsNamingANamedPathAbsentAtTheCommit(t *testing.T) {
root := t.TempDir()
writeTreeFile(t, root, "modules/a/module.json", `{"module":"a","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","gone"]}]}}`)
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "modules/a/module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "t\n"}
case "ls-tree":
if strings.Join(args[4:], " ") != strings.Repeat("c", 40)+" -- modules/a/gone modules/a/src" {
return forgekey.Ran{Status: 1, Stderr: "wrong arguments " + strings.Join(args[3:], " ")}
}
return forgekey.Ran{Stdout: "040000 tree " + strings.Repeat("a", 40) + "\tmodules/a/src\x00"}
}
return forgekey.Ran{Status: 1}
}
_, err := ModuleTrees(context.Background(), run, nil, root, strings.Repeat("c", 40))
if err == nil || !strings.Contains(err.Error(), `"gone"`) || !strings.Contains(err.Error(), "modules/a") {
t.Fatalf("an absent named path gave %v", err)
}
}
// A manifest whose config value falls back to a predefined key is narrowed here as the indexer narrows it: both read it
// with catalogue.ParseManifestForInputs (issue 1751), so a build module holding no published list computes the same
// tree (ADR 0499 clause 2).
func TestModuleTreesNarrowsAManifestFallingBackToAPredefinedKey(t *testing.T) {
was := catalogue.ReplacePublished(nil) // the build modules' case: no published list
defer catalogue.ReplacePublished(was)
root := t.TempDir()
raw := `{"module":"xterm","version":"1","config":{"size":{"type":"number","description":"The text size.","predefined":"font-size","default":12,"runtime":true}},` +
`"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src"]}]}}`
if _, err := catalogue.ParseManifest([]byte(raw)); err == nil {
t.Fatal("ParseManifest reads the manifest with no list; the test proves nothing")
}
if _, err := catalogue.ParseManifestForInputs([]byte(raw)); err != nil {
t.Fatalf("the indexer's read refuses the manifest: %v", err)
}
writeTreeFile(t, root, "module.json", raw)
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "whole\n"}
case "ls-tree":
return forgekey.Ran{Stdout: "040000 tree " + strings.Repeat("a", 40) + "\tsrc\x00"}
}
return forgekey.Ran{Status: 1}
}
got, err := ModuleTrees(context.Background(), run, nil, root, strings.Repeat("c", 40))
if err != nil {
t.Fatal(err)
}
want, _ := delivery.NamedTree(hexOf(raw), map[string]string{"src": strings.Repeat("a", 40)})
if g := got["."]; g.Tree != want || g.Paths["src"] == "" {
t.Fatalf("the module = %+v, want the named tree %s", g, want)
}
}
-93
View File
@@ -1,93 +0,0 @@
package buildstep
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
func hexOf(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func writeTreeFile(t *testing.T, root, name, content string) {
t.Helper()
p := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// ModuleTrees answers, for each directory holding a module.json (the root as "."), the tree id git gives, the sha256 of
// module.json and of each lock file present, and reads them from the workspace.
func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T) {
root := t.TempDir()
writeTreeFile(t, root, "module.json", `{"root":1}`)
writeTreeFile(t, root, "go.sum", "rootsum")
writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`)
writeTreeFile(t, root, "modules/a/go.sum", "asum")
writeTreeFile(t, root, "modules/a/package-lock.json", "alock")
writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`)
merge := strings.Repeat("c", 40)
var calls []string
var envs [][]string
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
calls = append(calls, name+" "+strings.Join(args[2:], " "))
envs = append(envs, env)
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "module.json\nmodules/a/module.json\nmodules/b/module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "tree-of-" + args[3] + "\n"}
}
return forgekey.Ran{Status: 1, Stderr: "unexpected"}
}
got, err := ModuleTrees(context.Background(), run, []string{"X=1"}, root, merge)
if err != nil {
t.Fatal(err)
}
want := map[string]ModuleTree{
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}},
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("trees = %+v\nwant %+v", got, want)
}
if got["modules/b"].Locks != nil {
t.Error("a module with no lock file carries a lock map")
}
for _, e := range envs {
if len(e) != 1 || e[0] != "X=1" {
t.Errorf("git ran with env %v, want the one given", e)
}
}
}
func TestModuleTreesFailsNamingTheGitCommandOrTheUnreadableManifest(t *testing.T) {
root := t.TempDir()
fail := func(on string) forgekey.Runner {
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
if args[2] == on {
return forgekey.Ran{Status: 128, Stderr: "fatal: no\n"}
}
return forgekey.Ran{Stdout: "modules/a/module.json\n"}
}
}
for _, on := range []string{"ls-files", "rev-parse"} {
if _, err := ModuleTrees(context.Background(), fail(on), nil, root, sha); err == nil || !strings.Contains(err.Error(), on) || !strings.Contains(err.Error(), "fatal: no") {
t.Errorf("%s failing: %v", on, err)
}
}
if _, err := ModuleTrees(context.Background(), fail("none"), nil, root, sha); err == nil {
t.Error("a module.json that is not in the workspace was read")
}
}
-160
View File
@@ -1,160 +0,0 @@
package buildstep
// A build module's room to work and its workspaces (novox/hq issue 1839, ADR 0509): a step is refused while the
// filesystem it writes to has less free than the floor, and a workspace is removed when its caller releases it, or,
// unheld, once its last use is past a bound (a lost caller's), never while a running step holds it.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"sync"
"syscall"
"time"
)
// FreeSpaceFloor is the free space a build module needs on its work and cache filesystems to take a step: the floor
// build-agent keeps (mesh-controller internal/builder/floor.go, issue 1834).
const FreeSpaceFloor uint64 = 20 << 30
// UnheldBound is how long a workspace no step holds is kept after its last use, when its caller never released it
// (ADR 0509: a lost caller's workspace goes after two hours).
const UnheldBound = 2 * time.Hour
// FreeBytes reads the bytes an unprivileged writer can still use on the filesystem holding path.
func FreeBytes(path string) (uint64, error) {
var s syscall.Statfs_t
if err := syscall.Statfs(path, &s); err != nil {
return 0, err
}
return s.Bavail * uint64(s.Bsize), nil
}
// Floor refuses a step while a filesystem it writes to has less free than Bytes. A zero Bytes checks nothing.
type Floor struct {
// Node is named in the refusal.
Node string
Bytes uint64
// Free reads a path's free bytes; FreeBytes when nil. A test answers for a disk it does not have.
Free func(path string) (uint64, error)
}
// Check answers a *Refusal, "refused: <node> has N GiB free under the floor of F GiB (on <path>)", when any path's
// filesystem has less free than the floor, or its free space cannot be read; nil otherwise. A path not made yet is
// read at its nearest folder that is; an empty path is skipped.
func (f Floor) Check(paths ...string) error {
if f.Bytes == 0 {
return nil
}
free := f.Free
if free == nil {
free = FreeBytes
}
for _, p := range paths {
if p == "" {
continue
}
at := p
for {
if _, err := os.Stat(at); err == nil || filepath.Dir(at) == at {
break
}
at = filepath.Dir(at)
}
n, err := free(at)
if err != nil {
return &Refusal{Text: fmt.Sprintf("refused: the free space of %s on %s cannot be read: %v", p, f.Node, err)}
}
if n < f.Bytes {
return &Refusal{Text: fmt.Sprintf("refused: %s has %d GiB free under the floor of %d GiB (on %s)", f.Node, n>>30, f.Bytes>>30, p),
Want: fmt.Sprintf("%d GiB", f.Bytes>>30), Have: fmt.Sprintf("%d GiB", n>>30)}
}
}
return nil
}
var workspaceID = regexp.MustCompile(`^[a-z0-9]+$`)
// Workspaces are a build module's workspaces, each <Dir>/<id>, and which of them running steps hold.
type Workspaces struct {
Dir string
mu sync.Mutex
held map[string]int
}
// Hold marks the workspace held by a running step until the answered func is called; both mark it used now, so the
// sweep counts its bound from the step's end. An id that is not a workspace's holds nothing.
func (w *Workspaces) Hold(id string) func() {
if !workspaceID.MatchString(id) {
return func() {}
}
w.mu.Lock()
if w.held == nil {
w.held = map[string]int{}
}
w.held[id]++
w.mu.Unlock()
w.touch(id)
var once sync.Once
return func() {
once.Do(func() {
w.touch(id)
w.mu.Lock()
if w.held[id]--; w.held[id] <= 0 {
delete(w.held, id)
}
w.mu.Unlock()
})
}
}
func (w *Workspaces) touch(id string) {
now := time.Now()
_ = os.Chtimes(filepath.Join(w.Dir, id), now, now) // a workspace not made yet has nothing to mark
}
// Release removes the workspace at once: its caller's chain of steps has ended. One already gone is no error; one a
// running step holds is refused and kept; an id that is not a workspace's is refused.
func (w *Workspaces) Release(id string) error {
if !workspaceID.MatchString(id) {
return fmt.Errorf("%q is not a workspace id", id)
}
w.mu.Lock()
defer w.mu.Unlock()
if w.held[id] > 0 {
return fmt.Errorf("the workspace %s is held by a running step", id)
}
if err := os.RemoveAll(filepath.Join(w.Dir, id)); err != nil {
return fmt.Errorf("the workspace %s could not be removed: %w", id, err)
}
return nil
}
// Sweep removes every workspace no step holds whose last use is older than bound, and answers the ids it removed,
// sorted. It is the safety net for a caller that never released its workspace.
func (w *Workspaces) Sweep(now time.Time, bound time.Duration) []string {
entries, err := os.ReadDir(w.Dir)
if err != nil {
return nil
}
removed := []string{}
w.mu.Lock()
defer w.mu.Unlock()
for _, e := range entries {
if !e.IsDir() || !workspaceID.MatchString(e.Name()) || w.held[e.Name()] > 0 {
continue
}
info, err := e.Info()
if err != nil || now.Sub(info.ModTime()) < bound {
continue
}
if os.RemoveAll(filepath.Join(w.Dir, e.Name())) == nil {
removed = append(removed, e.Name())
}
}
sort.Strings(removed)
return removed
}
-113
View File
@@ -1,113 +0,0 @@
package buildstep
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
// 1839/AC1: a filesystem under the floor refuses the step, naming the node, what is free and the floor.
func TestAFilesystemUnderTheFloorRefusesNamingNodeFreeAndFloor(t *testing.T) {
dir := t.TempDir()
f := Floor{Node: "ace", Bytes: 20 << 30, Free: func(string) (uint64, error) { return 4<<30 + 5, nil }}
err := f.Check(dir)
var ref *Refusal
if !errors.As(err, &ref) {
t.Fatalf("Check = %v, want a *Refusal", err)
}
if want := "refused: ace has 4 GiB free under the floor of 20 GiB (on " + dir + ")"; err.Error() != want {
t.Errorf("Check = %q, want %q", err, want)
}
}
// 1839/AC1: at or above the floor, and with no floor set, nothing is refused.
func TestAtTheFloorOrWithNoFloorNothingIsRefused(t *testing.T) {
dir := t.TempDir()
if err := (Floor{Node: "ace", Bytes: 20 << 30, Free: func(string) (uint64, error) { return 20 << 30, nil }}).Check(dir); err != nil {
t.Errorf("at the floor: %v", err)
}
if err := (Floor{Node: "ace", Free: func(string) (uint64, error) { return 0, nil }}).Check(dir); err != nil {
t.Errorf("no floor: %v", err)
}
}
// 1839/AC1: a path not made yet is read at its nearest folder that is; a free space that cannot be read is a refusal.
func TestAPathNotMadeIsReadAtItsParentAndAnUnreadableOneRefuses(t *testing.T) {
dir := t.TempDir()
var read string
f := Floor{Node: "ace", Bytes: 1, Free: func(p string) (uint64, error) { read = p; return 2, nil }}
if err := f.Check(filepath.Join(dir, "not", "made")); err != nil || read != dir {
t.Errorf("Check = %v, read %q, want nil and %q", err, read, dir)
}
f.Free = func(string) (uint64, error) { return 0, errors.New("statfs: boom") }
var ref *Refusal
if err := f.Check(dir); !errors.As(err, &ref) {
t.Errorf("Check = %v, want a refusal", err)
}
}
func makeWorkspace(t *testing.T, w *Workspaces, id string, age time.Duration) string {
t.Helper()
p := filepath.Join(w.Dir, id)
if err := os.MkdirAll(filepath.Join(p, "tree"), 0o700); err != nil {
t.Fatal(err)
}
at := time.Now().Add(-age)
if err := os.Chtimes(p, at, at); err != nil {
t.Fatal(err)
}
return p
}
func gone(p string) bool { _, err := os.Stat(p); return os.IsNotExist(err) }
// 1839/AC2: a released workspace is gone at once; one already gone is no error; one a running step holds is refused
// and kept; an id that is not a workspace's is refused.
func TestReleaseRemovesAWorkspaceAtOnce(t *testing.T) {
w := &Workspaces{Dir: t.TempDir()}
p := makeWorkspace(t, w, "abc1", 0)
if err := w.Release("abc1"); err != nil || !gone(p) {
t.Fatalf("Release = %v, gone %v", err, gone(p))
}
if err := w.Release("abc1"); err != nil {
t.Errorf("a second release = %v, want nil", err)
}
held := makeWorkspace(t, w, "abc2", 0)
done := w.Hold("abc2")
if err := w.Release("abc2"); err == nil || gone(held) {
t.Errorf("Release of a held workspace = %v, gone %v; want refused and kept", err, gone(held))
}
done()
if err := w.Release("abc2"); err != nil || !gone(held) {
t.Errorf("Release after the hold ended = %v, gone %v", err, gone(held))
}
if err := w.Release("../etc"); err == nil {
t.Errorf("Release of ../etc answered nil")
}
}
// 1839/AC3: the sweep removes a workspace no step holds whose last use is past the bound, and keeps a held one and a
// recent one; a hold ending counts as a use.
func TestSweepRemovesUnheldWorkspacesPastTheBound(t *testing.T) {
w := &Workspaces{Dir: t.TempDir()}
old := makeWorkspace(t, w, "old1", 3*time.Hour)
heldOld := makeWorkspace(t, w, "held1", 3*time.Hour)
recent := makeWorkspace(t, w, "new1", time.Minute)
if err := os.WriteFile(filepath.Join(w.Dir, "not-a-workspace.txt"), nil, 0o600); err != nil {
t.Fatal(err)
}
done := w.Hold("held1")
removed := w.Sweep(time.Now(), 2*time.Hour)
if !gone(old) || gone(heldOld) || gone(recent) {
t.Errorf("after the sweep: old gone %v, held kept %v, recent kept %v", gone(old), !gone(heldOld), !gone(recent))
}
if len(removed) != 1 || removed[0] != "old1" {
t.Errorf("Sweep answered %v, want [old1]", removed)
}
done()
if w.Sweep(time.Now(), 2*time.Hour); gone(heldOld) {
t.Errorf("a workspace whose hold just ended was swept: its end is a use")
}
}
+169
View File
@@ -0,0 +1,169 @@
package buscompose
import (
"encoding/json"
"fmt"
"slices"
"strings"
"time"
)
// AccountsState is the grants part's state of wanted accounts, by bus user.
const AccountsState = "accounts"
// GrantsAccounts is that state as a reader names it (`<owner>.<name>`).
const GrantsAccounts = GrantsModule + "." + AccountsState
// AccountIssuedEvent is what the vault says when it minted an account: AccountIssued, never a value or a hash.
const AccountIssuedEvent = "account-issued"
// AccountIssuedSubject is where it says it, as the runtime addresses mesh-vault's events.
const AccountIssuedSubject = "mesh.mod." + VaultSeat + ".event." + AccountIssuedEvent
// ClaimedSeat is one seat a module claims, as its runtime needs it (the controller's seatClaimed): the name, the scope (a
// node-scoped seat's verb carries the machine) and the verbs the runtime serves for it.
type ClaimedSeat struct {
Seat string `json:"seat"`
Scope string `json:"scope"`
Serves []string `json:"serves,omitempty"`
}
// WantedAccount is one key of the grants part's state `accounts`: an account the mesh needs, without its password.
type WantedAccount struct {
User string `json:"user"`
// Kind is KindModule (KindNodeTools for the runtime, named as a module is), KindProcess or KindProvisioner.
Kind Kind `json:"kind"`
Node string `json:"node"`
Module string `json:"module"`
// Process is a KindProcess account's process id.
Process string `json:"process,omitempty"`
// URL is the bus's address as that node reaches it, `nats://<host>:<port>`; Fingerprint the bus certificate's.
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
// Claims are a module's claims with the verbs each seat promises: none for a process or a provisioner client.
Claims []ClaimedSeat `json:"claims,omitempty"`
// At is when the grants part wrote the key: not compared (Same).
At time.Time `json:"at"`
}
// mintedKinds are the kinds of account the vault mints (ADR 0561 §1).
var mintedKinds = []Kind{KindModule, KindNodeTools, KindProcess, KindProvisioner}
// principal is the account as the composer names it.
func (w WantedAccount) principal() Principal {
return Principal{Kind: w.Kind, Node: w.Node, Module: w.Module, Process: w.Process}
}
// Valid says why w is not an account the vault may mint, or nil: a kind it mints, every name a subject token, the user
// the one the composer derives from them, and an address to reach the bus at.
func (w WantedAccount) Valid() error {
if !slices.Contains(mintedKinds, w.Kind) {
return fmt.Errorf("%s is a %s account, which the vault never mints (ADR 0561 §4)", w.User, w.Kind)
}
for what, v := range map[string]string{"node": w.Node, "module": w.Module} {
if !safeSubject.MatchString(v) {
return fmt.Errorf("%s: the %s %q is not a name the mesh writes", w.User, what, v)
}
}
if w.Kind == KindProcess && !safeSubject.MatchString(w.Process) {
return fmt.Errorf("%s: the process %q is not a name the mesh writes", w.User, w.Process)
}
if w.Kind != KindProcess && w.Process != "" {
return fmt.Errorf("%s: only a process's account names a process", w.User)
}
if want := w.principal().Username(); w.User != want {
return fmt.Errorf("%s is not the user of a %s account of %s on %s (that is %s)", w.User, w.Kind, w.Module, w.Node, want)
}
if !strings.HasPrefix(w.URL, "nats://") || len(w.URL) == len("nats://") {
return fmt.Errorf("%s names no bus address (url, nats://<host>:<port>)", w.User)
}
if len(w.Claims) > 0 && w.Kind != KindModule && w.Kind != KindNodeTools {
return fmt.Errorf("%s: a %s account holds no seat, so it carries no claims", w.User, w.Kind)
}
return nil
}
// Same says whether two wanted accounts want the same account: every field but when it was written.
func (w WantedAccount) Same(o WantedAccount) bool {
a, b := w, o
a.At, b.At = time.Time{}, time.Time{}
ra, _ := json.Marshal(a)
rb, _ := json.Marshal(b)
return string(ra) == string(rb)
}
// OwnSecret is the module's own secret the account is held in: `broker`, `broker-<process id>` or `provisioner-broker`
// (the controller's names, ADR 0436 §2, ADR 0349 §11).
func (w WantedAccount) OwnSecret() string {
switch w.Kind {
case KindProcess:
return "broker-" + w.Process
case KindProvisioner:
return "provisioner-broker"
}
return "broker"
}
// ReadWantedAccount reads the key user of the state: refused when it is not a valid wanted account of that user.
func ReadWantedAccount(user string, raw []byte) (WantedAccount, error) {
var w WantedAccount
if err := json.Unmarshal(raw, &w); err != nil {
return WantedAccount{}, fmt.Errorf("%s %s is not a wanted account: %w", GrantsAccounts, user, err)
}
if w.User != user {
return WantedAccount{}, fmt.Errorf("%s %s names the user %q", GrantsAccounts, user, w.User)
}
return w, w.Valid()
}
// AccountIssued is the vault's account-issued: which account it minted, and its generation. Never a value or a hash.
type AccountIssued struct {
User string `json:"user"`
Kind Kind `json:"kind"`
Node string `json:"node"`
Module string `json:"module"`
Process string `json:"process,omitempty"`
Generation int `json:"generation"`
At time.Time `json:"at"`
}
// HeldCredential is the value the account's holder reads, exactly as the controller builds it today (issueWith,
// sealProcess and sealProvisionerClient): a module's `{url, fingerprint, node, module, user, password, claims}`, a
// process's without claims, a provisioner client's without module or claims; an empty fingerprint or claims left out.
func HeldCredential(w WantedAccount, password string) ([]byte, error) {
if err := w.Valid(); err != nil {
return nil, err
}
if password == "" {
return nil, fmt.Errorf("%s: an account is held with its password", w.User)
}
switch w.Kind {
case KindProcess:
return json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
Module string `json:"module"`
User string `json:"user"`
Password string `json:"password"`
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password})
case KindProvisioner:
return json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
User string `json:"user"`
Password string `json:"password"`
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, User: w.User, Password: password})
}
return json.Marshal(struct {
URL string `json:"url"`
Fingerprint string `json:"fingerprint,omitempty"`
Node string `json:"node"`
Module string `json:"module"`
User string `json:"user"`
Password string `json:"password"`
Claims []ClaimedSeat `json:"claims,omitempty"`
}{URL: w.URL, Fingerprint: w.Fingerprint, Node: w.Node, Module: w.Module, User: w.User, Password: password,
Claims: w.Claims})
}
+8
View File
@@ -0,0 +1,8 @@
package buscompose
import (
"git.novox.be/novox/mesh-sdk/go/declaration"
)
// DeclarationFilesBucket is the prefix of the buckets' names, which mesh-sdk fixes for the node-engine and the controller alike.
const DeclarationFilesBucket = declaration.FileBucketPrefix
+59
View File
@@ -0,0 +1,59 @@
package buscompose
// A Consumer is a durable subscription the controller creates on a module's behalf. A module
// declares what it reacts to, never how delivery works, so it does not name these and cannot
// misconfigure them.
type Consumer struct {
Name string
Stream string
// Filters are the subjects this consumer receives. One consumer per module with several
// filters, rather than one per consumed event: its ack subject is derived from its name, and
// a module with five consumers would need five ack permissions to ack its own deliveries.
Filters []string
// Queue is the queue group, set for a seat's worker so that "exactly one holder" survives a
// seat later being relaxed to several. Authority and delivery are kept separate on purpose.
Queue string
// Push asks the server to deliver to a subject rather than wait to be pulled.
//
// For the mesh's own consumer, where the controller wants every message to arrive in the one
// loop it already runs: pulling would mean a second goroutine fetching batches and handing
// them over, and a loop that acts on one message at a time is the property the store window
// depends on. A queue group implies this, because a group has nothing to pull from.
Push bool
// TakesOverFrom names a consumer of the same stream whose filter this one takes over (issue 973): made while
// that consumer still filters this one's subject, it starts after what that consumer has acknowledged, so a
// message the old one held unacknowledged is not lost to the change. Otherwise it starts as FromNow says.
TakesOverFrom string
// DeliverSubject, when set, is where a push consumer delivers instead of DeliverSubjectFor's.
DeliverSubject string
// AckWaitSeconds before an unacknowledged delivery is redelivered.
AckWaitSeconds int
// MaxDeliver is how often a message is handed over before the consumer gives it up; zero for no
// bound. What a consumer gives up is kept in DEAD_LETTERS by the controller (novox/hq issue 330).
MaxDeliver int
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
// the server's default, which is many. **One, for a consumer handled one at a time**
// (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a
// heartbeat, but everything handed over behind it times out unacknowledged and comes back —
// and a merge that came back rebuilt what it had just built, five times over on 2026-09-30.
// With one outstanding, the server holds the rest, and the heartbeat is keeping the message.
MaxAckPending int
// FromNow makes a consumer that does not exist yet start at the stream's end rather than its
// beginning (novox/hq issue 248). For a consumer that reacts to announcements — a merge, a build's
// outcome — on a stream that keeps a week of them: the server's default, everything the stream
// holds, replays every merge and every build of that week as if it had just happened. A consumer
// that exists keeps where it is, whatever this says; only its making is decided here.
FromNow bool
Why string
// Resettable says why this consumer may be re-made to deliver from now by the mesh itself, with
// nobody asked (novox/hq to-be 45 §7, healer H4): what a reset drops, something else catches up.
// Empty for every consumer where nothing would — a module's, whose events would be lost to it.
// Not a property of the consumer on the bus: nothing here is sent to the server.
Resettable string
}
// ModuleConsumer is one module's durable consumer, with the module and node it is for.
type ModuleConsumer struct {
Node, Module string
Consumer Consumer
}
+5
View File
@@ -0,0 +1,5 @@
// Package buscompose is the bus's names and shapes every part reads (novox/hq ADR 0593 §1): the mesh's streams and
// dead letters, a module's log, stream and buckets as the bus names them, the account a part asks for, a principal's
// shape, and the grants part's memberships key. The composer of the bus's user list, which reads these and makes every
// user's permissions, is the grants part's own (mesh-delivery modules/mesh-grants internal/buscompose).
package buscompose
+12
View File
@@ -0,0 +1,12 @@
package buscompose
// EngineModule is the node-engine's manifest's module name, and EngineActsState its state the acts asked through the
// node seat and its own asks are kept in (ADR 0563 §2, §3: "the engine's own state"): the bucket mesh-host_acts, keys
// `<node>.act.<id>`, `<node>.open.<act>.<digest>` and `<node>.ask.<id>`, each written by that node's engine alone.
const (
EngineModule = "mesh-host"
EngineActsState = "acts"
)
// EngineActsBucket is that state's bucket.
var EngineActsBucket = BucketName(EngineModule, EngineActsState)
+99
View File
@@ -0,0 +1,99 @@
package buscompose
import (
"fmt"
"slices"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/health"
)
// ControllerLeftoverConsumers are the ended controller's durable consumers left on the bus, one leftover of the module
// mesh-delivery placed nowhere (issue 1126, milestone section 3), and mesh-catalog's of the controller's built-before,
// which its manifest no longer consumes and no part composes; each `<stream>.<consumer>`.
var ControllerLeftoverConsumers = []string{
"EVENTS." + ControllerName, "EVENTS." + MergesConsumerName, "EVENTS.novox_mesh-delivery", "EVENTS.novox_mesh-catalog",
"CONTROL." + ControllerName, DeadLetterNoticesStream + "." + ControllerName,
}
// ControllerLeftoverConsumersOn are ControllerLeftoverConsumers and, for each node given, the consumer the controller
// made for the node's engine (`EVENTS.<node>_mesh-host`, of restic's backed-up): the engine hears that event without a
// durable consumer, and the grants part composes none for it, so nothing reads it (issue 1126).
func ControllerLeftoverConsumersOn(nodes []string) []string {
out := slices.Clone(ControllerLeftoverConsumers)
for _, n := range nodes {
if safeSubject.MatchString(n) {
out = append(out, "EVENTS."+n+"_"+EngineModule)
}
}
return out
}
// ControllerLeftoverBuckets are the ended controller's buckets, each by its local name (`KV_mesh-controller_<name>`).
var ControllerLeftoverBuckets = []string{"asked", "assignments", "builds", "calls", "checks-running", "composed",
"condition-history", "conditions", "declared-config", "declared-packages", "delivery-merges", "grants", "hand-acts",
"lease", "predefined", "rollouts", "states-announced"}
// ControllerLeftoverStreams are the ended controller's streams, by their names: CONTROL, ASSIGNMENTS, each of its buckets'
// and, for each node given, the declaration files' object bucket the controller wrote (ADR 0486 §2).
func ControllerLeftoverStreams(nodes []string) []string {
out := []string{"CONTROL", AssignmentsStream}
for _, b := range ControllerLeftoverBuckets {
out = append(out, "KV_"+BucketName(catalogue.EndedControllerName, b))
}
for _, n := range nodes {
if safeSubject.MatchString(n) {
out = append(out, "OBJ_"+DeclarationFilesBucket+n)
}
}
return out
}
// BusRemovalGrants are the whole authority of an account whose bus declares removals (ADR 0564 Limits 4, ADR 0590 clause
// 6), each removal its grant: the node-engine's acts bucket read (STREAM.INFO with a subject filter lists what a removal
// would take, so the tools read before they remove) and its keys removed, which nothing else may empty when it fills; and
// the ended controller's leftovers, each consumer and each stream read and deleted by its name, for the nodes given (the
// declaration files' object buckets). A removal outside the set is refused, as the indexer's judge refuses it in a
// manifest (novox/hq ADR 0578 §1).
func BusRemovalGrants(removes, nodes []string) ([]string, error) {
var out []string
for _, r := range removes {
switch r {
case catalogue.RemovesEngineActs:
out = append(out, "$JS.API.STREAM.INFO.KV_"+EngineActsBucket, "$KV."+EngineActsBucket+".>")
case catalogue.RemovesControllerLeftovers:
for _, c := range ControllerLeftoverConsumersOn(nodes) {
out = append(out, "$JS.API.CONSUMER.INFO."+c, "$JS.API.CONSUMER.DELETE."+c)
}
for _, s := range ControllerLeftoverStreams(nodes) {
out = append(out, "$JS.API.STREAM.INFO."+s, "$JS.API.STREAM.DELETE."+s)
}
default:
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4, ADR 0590)", r,
strings.Join([]string{catalogue.RemovesEngineActs, catalogue.RemovesControllerLeftovers}, ", "))
}
}
return out, nil
}
// WatchmanHears are the core subjects the watchman subscribes, with no durable consumer (ADR 0564 clauses 4, 5 and
// Limits 3, 11): the power seat's words (S1, ADR 0211), the providers' failing and recovered words (S8), each tool
// runner's beat (S11), the self-check's checked (S10), a consumer deleted and the bus module's slow consumers and closed
// connections (S9), the provisioner clients' reports (S21), the dead letters kept, and the lease write's events. Never the bus's API audit
// (`$JS.EVENT.ADVISORY.API`): it carries every API call's request and answer in the account, the vault's hashes among
// them (the lead, 2026-10-29).
func WatchmanHears() []string {
return []string{
"mesh.mod.power.event.*",
"mesh.mod.*.event.provisioner.failing",
"mesh.mod.*.event.provisioner.recovered",
"mesh.control.*.tools-alive",
"mesh.seat.mesh-self-check.event." + health.EventChecked,
"$JS.EVENT.ADVISORY.CONSUMER.DELETED.>",
"mesh.mod.nats.event.bus.slow-consumer",
"mesh.mod.nats.event.bus.connection-closed",
ProvAppliedSubjects,
deadLetterPrefix + ">",
}
}
+87
View File
@@ -0,0 +1,87 @@
package buscompose
import (
"fmt"
"time"
"github.com/nats-io/nats.go/jetstream"
)
// The mesh's caps on a log: what an entry's value may weigh, what a message on the log's stream may
// weigh — the value and its headers, which the server counts in a message's size, so a value of the
// full size still fits with the expected-last-sequence header an append carries — and what a log holds
// when its module says nothing and at most.
const (
LogMaxEntryBytes = 256 * 1024
LogMaxMessageBytes = LogMaxEntryBytes + 4*1024
LogDefaultMiB = 1024
)
// A Log is one module's declared log as the bus holds it.
type Log struct {
Module string
Name string
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
MaxMiB int
// MaxAgeDays is how long an entry is kept; zero is as long as the log (catalogue.LogDeclaration).
MaxAgeDays int
}
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
const LogStreamPrefix = "LOG_"
// LogStreamName is the stream a module's log lives in: `LOG_<module>_<name>`. The module and the
// local name are each one token with no underscore, so two modules can never derive one stream.
func LogStreamName(module, name string) string { return LogStreamPrefix + module + "_" + name }
// LogSubject is the subject a log's entries are published under, without the key: an entry for key K
// is on `<LogSubject>.<K>`.
func LogSubject(module, name string) string { return "mesh.log." + module + "." + name }
// Stream is this log's stream name.
func (l Log) Stream() string { return LogStreamName(l.Module, l.Name) }
// Subject is this log's subject, without the key.
func (l Log) Subject() string { return LogSubject(l.Module, l.Name) }
// MaxBytes is this log's cap in bytes.
func (l Log) MaxBytes() int64 {
mib := l.MaxMiB
if mib <= 0 {
mib = LogDefaultMiB
}
return int64(mib) * 1024 * 1024
}
// Why is carried into the server's description of the stream, so somebody reading the server's own
// state finds whose it is and why it is kept.
func (l Log) Why() string {
return fmt.Sprintf("%s's log %q (novox/hq ADR 0297): its record of operations, one entry per operation "+
"under its key, appended by %s alone and kept as long as the log; never removed by the mesh, because "+
"it is data", l.Module, l.Name, l.Module)
}
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
// limits, with no maximum age unless the log says one (ADR 0565 §10) and no cap per key, whose message is an entry's value and 4 KiB of
// headers at most, that refuses a new entry when full rather than
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
// is how the runtime reads it.
func (l Log) Config() jetstream.StreamConfig {
return jetstream.StreamConfig{
Name: l.Stream(),
Description: l.Why(),
Subjects: []string{l.Subject() + ".>"},
Storage: jetstream.FileStorage,
Retention: jetstream.LimitsPolicy,
MaxAge: time.Duration(l.MaxAgeDays) * 24 * time.Hour,
MaxMsgs: -1,
MaxMsgsPerSubject: -1,
MaxBytes: l.MaxBytes(),
MaxMsgSize: LogMaxMessageBytes,
Discard: jetstream.DiscardNew,
AllowDirect: true,
DenyDelete: true,
DenyPurge: true,
Replicas: 1,
}
}
+109
View File
@@ -0,0 +1,109 @@
package buscompose
import (
"fmt"
"sort"
)
// moduleStreamPrefix starts every subject a module's own stream takes.
const moduleStreamPrefix = "mesh.stream."
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
func ModuleStreamSubject(module, event string) string {
return moduleStreamPrefix + module + ".event." + event
}
// A ModuleStream is one module's declared stream as the bus holds it.
type ModuleStream struct {
Module string
// Name is its name on the bus, `MAIL`.
Name string
// Events are the events it carries, as the bus spells them (WirePattern).
Events []string
MaxAgeDays int
MaxMiB int
// Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4).
Restricted bool
// WorkQueue says this stream holds ADR 0436 §6's work-queue retention: a message is removed once a
// consumer acknowledges it, and the bus refuses a new one when full rather than dropping its oldest.
WorkQueue bool
// Duplicates is the work-queue's duplicate window in hours (novox/hq ADR 0436 §6); meaningless unless
// WorkQueue.
Duplicates int
}
// Subjects are the subjects the stream takes.
func (s ModuleStream) Subjects() []string {
var out []string
for _, e := range s.Events {
out = append(out, ModuleStreamSubject(s.Module, e))
}
sort.Strings(out)
return out
}
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
// answers for anything older.
func (s ModuleStream) Stream() Stream {
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
if s.Restricted {
why += "; heard only by the consumers its trusted config value grants"
}
if s.WorkQueue {
why += "; a work-queue (novox/hq ADR 0436 §6): a message is removed once its one consumer " +
"acknowledges it, and a full stream refuses a new one rather than dropping what it holds"
}
out := Stream{
Name: s.Name,
Subjects: s.Subjects(),
Retention: RetentionLimits,
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
Why: why,
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
Restricted: s.Restricted,
}
if s.WorkQueue {
out.Retention = RetentionWorkQueue
out.DiscardNew = true
out.DuplicatesSeconds = s.Duplicates * 60 * 60
// Said on the bus too, reusing the one flag a restricted stream's dead letters already age and hide
// by (novox/hq ADR 0436 §6, ADR 0376 §3-4): a work-queue stream's grant is always empty (ADR 0436
// §6 refuses one that carries a grant), so this never collides with a trusted-config restriction.
out.Restricted = true
// And its own metadata, apart from the bus's work-queue retention SEAT_* and other mesh-internal
// streams share without being one of this clause's: only this flag says a dead letter kept from the
// stream is drop-only (novox/hq ADR 0436 §6), never the stream's Retention alone.
out.WorkQueue = true
}
return out
}
// QueueOwner is the one process of a module that is the durable consumer of one of its own work-queue
// streams (novox/hq ADR 0436 §1, §2, §6): named in that process's bus.queues, and checked at registration
// to be the only one (the indexer's judge, novox/hq ADR 0578 §1).
type QueueOwner struct {
// Process is the process resource's id — its bus account's name, with the node and module (novox/hq ADR
// 0436 §2).
Process string
// Stream is the work-queue stream's name on the bus.
Stream string
// MaxDeliver bounds how many times a message is handed over before it is given up as a dead letter
// (novox/hq ADR 0436 §6).
MaxDeliver int
}
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
// within the stream and, for a restricted stream, within what the provider's trusted config value grants it
// (ADR 0363 §5). Composed only with at least one subject.
type Heard struct {
Stream string
Emitter string
Filters []string
// Restricted says the stream is restricted by its provider's trusted config value (ADR 0363 §4): then it
// is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears).
Restricted bool
}
+21
View File
@@ -0,0 +1,21 @@
package buscompose
import (
"testing"
"git.novox.be/novox/mesh-sdk/go/membership"
)
// The names this package keeps for the grants part's composer (mesh-grants internal/buscompose, ADR 0593 §1) agree with
// what the runtime reads: the memberships bucket, and a log's stream and subject.
func TestTheBusNamesAgree(t *testing.T) {
if GrantsMembershipsBucket != membership.GrantsBucket {
t.Fatalf("the memberships bucket is %q here and %q in membership", GrantsMembershipsBucket, membership.GrantsBucket)
}
if MembershipKey("ace", "netcheck") != "ace.netcheck" {
t.Fatal("a membership's key is not <node>.<module>")
}
if LogStreamName("mesh-delivery-builder", "build-log") == "" || LogSubject("mesh-delivery-builder", "build-log") == "" {
t.Fatal("a log has no stream or subject")
}
}
+329
View File
@@ -0,0 +1,329 @@
// Composing the bus's own configuration.
//
// An account is *composed*, never called for: the controller writes accounts, users and
// per-subject permissions into one file the host keeps current, and the server reloads it in
// place (novox/hq ADR 0106 — never through a management API; design 25 §4).
//
// Everything here is pure. Given the principals, it returns the file's text — so the whole of the
// mesh's authority model is testable as strings, with no server.
//
// **Permissions are per subject, so a module's own name is the server's to enforce.** ADR 0042
// reserves a module's origin — it publishes only under its own name — and here that is a refusal
// rather than something a library promises.
package buscompose
import (
"regexp"
"time"
)
// A Kind is what a principal is, which decides the shape of its authority rather than its
// contents: a module's comes from its declaration, a host's from its node, and the controller's
// and the enrolment user's are fixed.
type Kind string
const (
KindModule Kind = "module"
KindNode Kind = "node"
KindEnrolment Kind = "enrolment"
// KindPerson is somebody reaching the mesh's tools from a workstation (design 25 §7). Its
// authority is a list of tools and nothing else — not control, not declarations, not builds,
// and no ability to answer anything, because a person asks.
KindPerson Kind = "person"
// KindNodeTools is a machine's tool runtime (novox/hq ADR 0175, to-be 38): one process per
// node, on the host side, serving every assigned module's tools and every held seat's verbs.
// Its authority is the union of what the modules it carries would each have had for their
// tools — and nothing of what they consume, because tools are what it runs, not reactions.
KindNodeTools Kind = "node-tools"
// KindProvisioner is a machine's provisioner client (novox/hq ADR 0349 clauses 6 and 11, design 57 P3): a
// service of the module holding NodeProvisionerSeat, outside the tool runner, under an account of its own so
// the vault can tell it from an agent. Its authority is provisionerGrants and nothing else.
KindProvisioner Kind = "provisioner"
// KindProcess is one process of a module that declares bus (novox/hq ADR 0436 §1, §2): a service of that
// module, outside the tool runner and outside the module's own account, under a bus account scoped to
// exactly what its bus says — never root, never the operator account, and never able to answer a tool
// call or a seat's verb (§3). One per process and machine.
KindProcess Kind = "process"
)
// Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it
// emits (novox/hq ADR 0118, design 29 §5).
type Seat struct {
Name string
// Scope is where the seat has one holder. A node-scoped seat's tool carries the node in its
// subject, because one subject reaching six machines' holders is not an address
// (novox/hq ADR 0132, design 33 §4). Empty reads as mesh.
Scope string
Accepts []string
Emits []string
Serves []string
Versions []string // protocol versions served beside the current one; empty for v1 only
// Kinded says the seat is a kinded bench (novox/hq ADR 0234 §2, ADR 0259 §3): each holder claims one
// kind, and its verbs' subjects carry it. Kind is the kind this principal's claim names, for a seat it
// holds.
Kinded bool
Kind string
// ByCaller are the accepts and emits whose last token names the calling module (ADR 0259 §3).
ByCaller []string
// Proofs are the seat's proof verbs: core request and reply, never on a stream (ADR 0259 §3).
Proofs []string
// Records are the holder's buckets, by their full name, each user reads under its own name.
Records []string
// Capabilities are what this principal's claim of a kinded bench promises (ADR 0234 §2).
Capabilities []string
// DeclaredBy is the module that declares the seat. On a kinded bench it alone submits work to a kind
// and answers its proofs (novox/hq ADR 0259 §8): the router, not any user or watcher of the bench.
DeclaredBy string
// PulledByKind says each accepted kind is a worker of its own on the seat's queue, filtered to that kind, which
// every holder pulls in the order Accepts lists them: the build seats' (novox/hq ADR 0525 §2, ADR 0536 §4).
PulledByKind bool
}
// A Principal is one user of the bus. Its permissions are derived from what it declares and
// nothing else (novox/hq ADR 0043), over the three namespaces of design 29 §2: its own, the seats
// it holds, and the seats it uses.
type Principal struct {
Kind Kind
Node string
Module string
// Process is the process resource's id, for a KindProcess principal alone (novox/hq ADR 0436 §2):
// Username and inbox are <node>.<module>.<process id>.
Process string
// FilesFor are the nodes whose declaration-file buckets (novox/hq ADR 0486 §2) this principal may remove, for the bus
// module's tools removing the ended controller's leftovers alone (ADR 0590 clause 6): every node of the mesh, one
// bucket each.
FilesFor []string
Emits []string
Consumes []string
Serves []string
Holds []Seat
Uses []Seat
// Watches are seats whose events this principal consumes. Separate from Consumes because a
// role's event lives under the seat's namespace and not a module's, and this package cannot tell
// a seat's name from a module's by looking at it — whoever resolved the declaration can, and
// does (novox/hq ADR 0121).
//
// **Found by a consumer reading nothing.** The catalogue consumes the build machine's outcome;
// with that name read as a module's, its subscription pointed at `mesh.mod.mesh-build-machine.…`,
// a namespace no such module owns. Every service started and the graph stayed empty.
Watches []Seat
// Carries are the modules whose tools this principal serves, for a KindNodeTools principal
// (novox/hq ADR 0175): every module assigned to its node, as each declares itself. Its
// serving authority is the union of theirs — each module's own tool namespace and each held
// seat's verbs on this node — derived from the same declarations the modules' own principals
// are, so the runtime can serve nothing a module could not have served for itself.
Carries []Declared
// Invokes are the tools this principal may call, as `<module>.<tool>`; a single `*` is every
// tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so
// (novox/hq ADR 0152) — the console's does, and nothing else's.
//
// **A list, not a role.** A person is not a module and holds no seat: nothing is addressed
// to them, nothing is delivered to them, and they have no durable consumer to acknowledge.
// What they have is permission to ask. A module that invokes gains exactly the same
// permission and nothing beside it.
Invokes []string
// AsksIssued are, for the vault's holder, the tools it asks in its own name for a credential a provider's software
// issued, each `<module>.<tool>` (novox/hq ADR 0305, issue 723, vaultCalls): on the subject that names its caller,
// so the tool knows the vault asks.
AsksIssued []string
// AsksTheDesk says this principal is the vault's holder, which asks the desks' secret prompt in its own name
// (novox/hq ADR 0349 clause 9, issue 723): `node-launcher.secret` on any machine, on the subject that names
// its caller, and nothing else of that seat. With AsksIssued on the same principal, the vault's whole reach
// outside its own seat: it asks each issuing provider's tool in its own name (clause 10).
AsksTheDesk bool
// State is the local names of the state this principal's module keeps, and Reads the state of
// others it reads as `<module>.<name>` (novox/hq ADR 0201): a bucket each, kept by the owner's
// instances and read by whoever declares it.
State []string
Reads []string
// PerMachine is the local names of its state keyed by machine, reaching the key named for Node and
// no other; KeyedReads the keys of others' state it reads one key at a time (novox/hq ADR 0260).
PerMachine []string
KeyedReads []KeyedRead
// Logs is the local names of the logs this principal's module keeps (novox/hq ADR 0297).
Logs []string
// ReadLogs are, of Reads, those naming another module's log (novox/hq ADR 0571 clause 1).
ReadLogs []string
// Streams are the streams this principal's module owns, and Hears the module streams it hears with the
// subjects it was granted (novox/hq ADR 0363).
Streams []ModuleStream
Hears []Heard
// Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436
// §1, §2, §6); for a KindProcess principal, its own alone.
Queues []QueueOwner
// Asks says a KindProcess principal reads the records of its module's own asks (novox/hq ADR 0436 §1),
// on whichever seat of Uses keeps them (novox/hq ADR 0259 §3) — never set for any other kind.
Asks bool
// SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its
// whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing
// else — not its declarations, which the node's tool runtime serves for it.
SnapshotsTheBus bool
// Removes are, for a process or tools bundle account whose bus declares them, what it may remove from the bus
// (novox/hq ADR 0564 Limits 4): its whole authority is then BusRemovalGrants of them.
Removes []string
// DeadLetterSources are, for the watchman, the streams a dead letter it keeps may come from, by name
// (deadLetterSourcesOf, novox/hq ADR 0564 clause 5): never a bucket's.
DeadLetterSources []string
// Checks are the tools a machine principal's node-engine asks as a declared health check, each
// `<module>.<tool>` (novox/hq ADR 0240, to-be 48 §3): asked of the instance on its own machine and
// nowhere else.
Checks []string
// MovesIntoTheVault is a machine principal whose node-engine may ask the vault's `move-in` in its own name (design
// 57's V3, issue 676): set while the vault's holder serves it, so the engine can hand in the values sealed to its
// machine, which nothing else can open.
MovesIntoTheVault bool
// StatesItsKeyToTheVault is a machine principal whose node-engine may ask the vault's `node-key` in its own name
// (issue 786, novox/hq ADR 0372): set while the vault's holder serves it. That one verb, and nothing else new.
StatesItsKeyToTheVault bool
// AsksItsOwnSecrets is a machine principal whose node-engine may ask the vault's `own` in its own name (novox/hq
// ADR 0547 §2, ADR 0550 §5, issue 1126): its modules' own secrets sealed to its key, for the compositions it makes
// itself. Set while the vault's holder serves it.
AsksItsOwnSecrets bool
// AsksTheVaultToSign is a machine principal whose node-engine may ask the vault's `sign` in its own name, read the
// vault's public signing halves (`signers`) and the controller's lease key (novox/hq ADR 0550 §2): its own node's
// certificate, the keys it trusts, and the lease write that drops the controller's key. Set while the vault's holder
// serves `sign`.
AsksTheVaultToSign bool
// ServedReads are, for a machine principal, the keys of mesh-config's resolved values that other nodes' modules serve
// from (`<node>.<module>.<key>`, catalogue.ServedSettings): what every consumer is told, and so what a node composing
// its own modules reads of a provider elsewhere (issue 1126), each key alone. Sorted, none twice.
ServedReads []string
// FollowsGrantsUserList is the machine principal of the node holding mesh-broker: its node-engine reads the grants
// part's sealed user list, one key and an object by name (novox/hq ADR 0547 clause 7, GrantsUserListFollows).
FollowsGrantsUserList bool
// BoundBuckets are the providers' buckets of entries, `<module>_bound`, a provisioner client reads its own
// machine's keys of (mesh-sdk go/provider, issue 679): every bucket named bound of a module the mesh places.
BoundBuckets []string
// CreatesStreams are the streams the grants part's principal alone may create (novox/hq ADR 0547 §5; the operator,
// 2026-10-26 08:54): the stream of every bucket, log and stream a manifest of a module placed anywhere declares,
// by name. Created only, never updated or deleted: a new module state is on the bus when the grants part hears its
// manifest, not at the controller's next start (issue 1790).
CreatesStreams []string
// CreatesConsumers are the durable consumers the grants part's principal alone may create beside the controller
// (novox/hq ADR 0561 §6): each placed module's on EVENTS and each process's on its work queue (AccountConsumers), by
// stream and name. Created only, never updated or deleted.
CreatesConsumers []ModuleConsumer
// Login is a node principal's login when it is not `node.<n>`: its second login, `node.<n>--b`, composed beside or
// in place of the first as the vault's node-logins says (novox/hq ADR 0583 §1, issue 774). Each login is granted
// the node's permissions and the node's inbox `_INBOX.node.<n>`; what it calls, it calls in its own name, so the
// vault tells the login that confirms a rotation from the one it replaces (§3).
Login string
// RotatesItsLogin is a machine principal whose node-engine reads its own node's key of the vault's node-logins,
// and no other key, and confirms a switch with node-account-confirm in its own name (novox/hq ADR 0583 §1 insight,
// §3). Set while the vault's holder serves rotate-node.
RotatesItsLogin bool
// Redeems is an enrolment principal that may call the seat mesh-enrolment's redeem in its own name, and nothing else
// of it (novox/hq ADR 0587 §4): set while the seat's holder serves redeem. LeaseHandedAway drops the controller's
// enrolment subject from it, which is served no more from the lease write (§6).
Redeems bool
LeaseHandedAway bool
// VaultEnrols says the vault's holder serves an enrolment verb (VerbsTheEnrolmentAsksTheVault): every principal but
// the module mesh-enrolment and its processes is then denied them, however wide its grant (ADR 0587 §1).
VaultEnrols bool
// PasswordHash is the bcrypt hash the mesh minted. The plaintext is sealed to the principal
// and never appears here: this file is written to a node's disk and read by a server, and a
// secret that can be read from a configuration file is a secret with a wider blast radius
// than the one it protects (novox/hq design 29 §10).
PasswordHash string
}
// enrolmentPrefix is the space every enrolling node's user and inbox live under, so the one place the
// controller may answer an enrolment is derived from the same constant the user is named from.
const enrolmentPrefix = "enrol"
// safeSubject refuses anything that would change the meaning of a subject rather than sit inside
// one. A name carrying a dot would silently widen a permission by adding a token; a name carrying
// `>` or `*` would widen it to a wildcard, which is the whole authority model gone.
var safeSubject = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// Username is how a principal is named to the server. The node is part of it, so the same module
// on two machines holds two users, each sealed to its own — the rule management.go already
// applies, kept.
func (p Principal) Username() string {
switch p.Kind {
case KindPerson:
return "person." + p.Module
case KindModule, KindNodeTools:
// The runtime is named exactly as the module it stands for would have been: the mesh
// issues its credential through the same path a module's takes (`module issue`), and
// that path knows the node and the module, not the kind.
return p.Node + "." + p.Module
case KindProcess:
// One account per process and machine (novox/hq ADR 0436 §2): `<machine>.<module>.<process id>`,
// never the module's own name alone, which is a different account with a different secret.
return p.Node + "." + p.Module + "." + p.Process
case KindNode:
if p.Login != "" {
return p.Login
}
return "node." + p.Node
case KindProvisioner:
// Named for the machine, as a node is: one client per machine, whichever module runs it, and never
// `<node>.<module>`, which is the module's own account or the tool runner's.
return "provisioner." + p.Node
case KindEnrolment:
// Per token, not one shared user. **The inbox is the reason**: with a single `enrolment`
// user every machine enrolling at once could read every other's answer, and an answer
// carries that node's credentials sealed to it. Design 25 §6 says the inbox a token
// derives, and a permission belongs to a user, so the user is per token.
//
// Named after the node, which **is** the token's id: a token is issued for a node record,
// the mesh holds one live claim per record, and the node's name is the one identifier both
// sides already have before anything else is agreed. It is also exactly what the other
// transport does, where the account is named after the node and the secret is its password.
return enrolmentPrefix + "." + p.Node
}
return ""
}
// inbox is a principal's own reply space. No user is ever granted a bare `_INBOX.>` (design 25
// §4): with one account, inbox privacy is the permission list or it is nothing, so each user's
// inbox is derived from its own identity and its permissions name that prefix and no other.
//
// A node's two logins share the node's inbox (novox/hq ADR 0583 §1): its answers reach it whichever login it dials as.
func (p Principal) inbox() string {
if p.Kind == KindNode {
return "_INBOX.node." + p.Node + ".>"
}
return "_INBOX." + p.Username() + ".>"
}
// ResponseTTL is how long the bus lets a principal answer a request it received. Its one answer has
// to come inside this, and a seat's holder answers within link.AnswerWithin — inside it by design.
// **A broker reloading its user list forgets every answer it was about to permit**, whatever this
// says (novox/hq issue 265): a call that is still running when the list reloads has its answer
// refused, which is why a holder answers before it does what can reload it.
const ResponseTTL = time.Minute
// GrantsModule is the one module whose principal publishes the modules' memberships (novox/hq ADR 0547 §5): the grants
// part, which composes them from the manifests and placement. It defines no stream and is granted no other subject here.
const GrantsModule = "mesh-grants"
// The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `<node>.<module>`, its
// membership (Membership) bare, as the assignments stream carries it, written only when it differs. A module's runtime
// reads its own key, the tool runner its node's; the grants part alone writes it. Both sides go through
// membership.EncodeGrantsMembership and membership.DecodeGrantsMembership (or BareGrantsMembership for a reader with a
// type of its own), never json of a type of their own: the grants part once wrote its wrapper here and every reader
// served nothing (issue 774). The decoder still reads that wrapper, for the transition.
const (
GrantsMembershipsState = "memberships"
GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState
)
// MembershipKey is an assignment's key in the grants part's memberships: `<node>.<module>`.
func MembershipKey(node, module string) string { return node + "." + module }
+8
View File
@@ -0,0 +1,8 @@
package buscompose
import (
"git.novox.be/novox/mesh-sdk/go/membership"
)
// AssignmentsStream holds every assignment's membership, the newest per subject.
const AssignmentsStream = membership.AssignmentsStream
+7
View File
@@ -0,0 +1,7 @@
package buscompose
// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is
// composed exactly as before them.
func (s Seat) isNewTraffic() bool {
return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 || s.PulledByKind
}
+51
View File
@@ -0,0 +1,51 @@
package buscompose
import (
"fmt"
)
// The mesh's caps on a bucket, the same for every module: a value is a piece of state, not a file,
// and a bucket that grew without bound would be one module filling the bus's disk for everyone.
const (
StateMaxValueBytes = 256 * 1024
)
// A Bucket is one module's declared state as the bus holds it.
type Bucket struct {
Module string
Name string
// History is how many values a key keeps; zero is one.
History int
// TTLSeconds is how long a value lives; zero is until replaced or deleted.
TTLSeconds int
// PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR
// 0260), and is granted that key and no other.
PerMachine bool
// KeyTTL says the bucket allows a time to live per key (novox/hq ADR 0433 §1): its owner writes each key with its
// own, and the server writes a delete marker, kept KeyTTLMarkerTTL, when it ends.
KeyTTL bool
}
// BucketName is the bucket a module's state lives in: the module and the local name joined by an
// underscore, which neither may contain, so two modules can never derive one bucket.
func BucketName(module, name string) string { return module + "_" + name }
// Bucket is this bucket's name on the bus.
func (b Bucket) Bucket() string { return BucketName(b.Module, b.Name) }
// Why is carried into the server's description of the bucket, so somebody reading the server's
// own state finds whose it is and why it is kept.
func (b Bucket) Why() string {
return fmt.Sprintf("%s's state %q (novox/hq ADR 0201): its current value per key, written by %s, "+
"read by whatever declares it reads it; kept when %s is unassigned, because it is data",
b.Module, b.Name, b.Module, b.Module)
}
// KeyedRead is one key of another module's state a principal reads, and no other key of it.
type KeyedRead struct {
Read string `json:"read"`
Key string `json:"key"`
}
// ResolvedBucket is mesh-config's state of resolved values, keyed `<node>.<module>.<key>`.
const ResolvedBucket = "mesh-config_resolved"
+196
View File
@@ -0,0 +1,196 @@
package buscompose
import (
"strings"
)
// Retention is how a stream decides what to keep, which is the whole of what distinguishes the
// mesh's four relationships on the wire (design 29 §4).
type Retention string
const (
// RetentionWorkQueue: a message is removed once a consumer acknowledges it. Exactly one
// worker does the work, and a worker that dies has its message redelivered.
RetentionWorkQueue Retention = "workqueue"
// RetentionLastPerSubject: only the newest message on each subject survives. This is the
// state shape — a node that was away gets exactly the current declaration and nothing older.
RetentionLastPerSubject Retention = "last_per_subject"
// RetentionLimits: kept until it ages or the stream fills. Events, where a subscriber that
// was down catches up and nobody is obliged to act.
RetentionLimits Retention = "limits"
)
// A Stream is one of the mesh's own, as the controller asserts it.
type Stream struct {
Name string
Subjects []string
Retention Retention
// MaxAge in seconds, zero for unbounded. Per stream — JetStream has no per-subject age,
// which is why differing retention between modules would mean a stream each.
MaxAge int
// MaxMsgsPerSubject caps each subject independently, so one noisy emitter cannot push
// another's events out of a shared stream. Verified: with a cap of 3, ten messages on one
// subject and one on another leave four in the stream, not three.
MaxMsgsPerSubject int
// Why is carried into the assertion so an operator reading the server's own state finds the
// reason there, rather than only in a repository they may not have.
Why string
// Direct lets a client read a subject's last message without a consumer, which is how a
// runtime reads its own membership with no JetStream API beyond one request (ADR 0160).
Direct bool
// MaxBytes bounds the stream's size, zero for unbounded. With DiscardNew a full stream refuses
// what comes next rather than dropping what it holds: the publisher is told, and says so.
MaxBytes int64
DiscardNew bool
// DuplicatesSeconds is the window in which a message id published twice is kept once; zero for the
// server's default (two minutes).
DuplicatesSeconds int
// AllowMsgTTL lets a message carry a time to live of its own (`Nats-TTL`), for what must not outlive the
// stream it came from (novox/hq ADR 0376 §3, §5).
AllowMsgTTL bool
// Restricted marks a module's stream restricted by a grant (novox/hq ADR 0363 §4), in the stream's own
// metadata, so whoever keeps one of its messages reads it from the bus (ADR 0376 §3).
Restricted bool
// WorkQueue marks a module stream declared retention: work-queue (novox/hq ADR 0436 §6), in the
// stream's own metadata, so a dead letter kept from it is read as drop-only from the bus, by the
// declaration and not by the bus's work-queue retention alone — which SEAT_* and other mesh-internal
// streams share without being one of ADR 0436's, and whose dead letters are still delivered again
// (ADR 0264 §4).
WorkQueue bool
}
// RestrictedMetadata is the key a restricted stream's metadata says it under.
const RestrictedMetadata = "mesh-restricted"
// WorkQueueMetadata is the key a module stream declared retention: work-queue (novox/hq ADR 0436 §6) says
// it under: never set by a mesh-internal work-queue stream (a seat's, a check's), only by Stream.WorkQueue.
const WorkQueueMetadata = "mesh-work-queue"
// What a durable consumer gave up on is kept (novox/hq issue 330, design 25 §3).
//
// **The server says it and keeps it; the controller copies it.** A consumer that handed a message over
// as often as it may stops offering it and publishes `$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES` with
// the stream and the message's sequence. DeadLetterNoticesStream captures those advisories as the
// server publishes them, so one said while no controller listens is still there when one starts. The
// controller's consumer on it fetches the given-up message by its sequence, while the source stream
// still holds it, and keeps a copy in DeadLettersStream under DeadLetterSubject, with the consumer,
// the subject, how often it was handed over and when it was given up. It stays there until a person
// delivers it again or drops it, with why; a condition is open for as long as it does.
const (
DeadLetterNoticesStream = "DEAD_LETTER_NOTICES"
DeadLettersStream = "DEAD_LETTERS"
// MaxDeliveriesAdvisories is the subject the server says a given-up message on.
MaxDeliveriesAdvisories = "$JS.EVENT.ADVISORY.CONSUMER.MAX_DELIVERIES.>"
deadLetterPrefix = "mesh.events.dead."
againPrefix = "mesh.again."
// DeadLettersBytes bounds the kept copies. Full, the stream refuses the next copy, which is said
// as the consumer's condition; it never drops one it holds.
DeadLettersBytes = 256 << 20
)
// DeadLetterSubject is where a message one consumer gave up on is kept: `mesh.events.dead.<stream>.<consumer>`.
func DeadLetterSubject(stream, consumer string) string {
return deadLetterPrefix + stream + "." + consumer
}
// DeadLetterOf is the stream and consumer a kept message's subject names; false for any other subject.
func DeadLetterOf(subject string) (stream, consumer string, ok bool) {
rest, found := strings.CutPrefix(subject, deadLetterPrefix)
if !found {
return "", "", false
}
stream, consumer, ok = strings.Cut(rest, ".")
return stream, consumer, ok && stream != "" && consumer != "" && !strings.Contains(consumer, ".")
}
// MeshStreams is the foundation set, in the order a person reads it.
//
// **EVENTS filters on the `event` token**, which is the reason that token exists. A module's
// namespace carries both its events and its tool calls; a filter of `mesh.mod.*.>` would persist
// every tool invocation in the mesh, and a tool call must never be persisted (design 25 §3 keeps
// tools on core NATS, where a lost call is a timeout the caller already handles).
func MeshStreams() []Stream {
return []Stream{
// CONTROL, the ended controller's work queue of what nodes reported, is no longer the mesh's (novox/hq ADR 0590
// clause 6): not declared here, which would make it again; the bus module's tools remove it.
{
Name: "NODES",
Subjects: []string{"mesh.node.*.declare"},
Retention: RetentionLastPerSubject,
Why: "one declaration per node, always the newest; a node that sees sequence n refuses " +
"n-1 by construction (issue 107)",
},
// The assignments stream (ADR 0160) is no longer the mesh's: a runtime reads its membership from the grants
// part's state memberships (ADR 0571 clause 7), and the bus module's tools delete the stream at the lease write
// (research 072 row 33), so it is not declared here, which would make it again.
{
Name: EventsStream,
// A seat's own events ride here too: they are 1:many like any event, and the
// `event` token keeps them clear of both the seat's work queue (`accept`) and its
// tools (`tool`), which must not be persisted.
// And an event given up on, delivered again to the one consumer that gave it up
// (novox/hq issue 330): under `mesh.again.<consumer>.`, which only that consumer filters.
Subjects: []string{"mesh.mod.*.event.>", "mesh.seat.*.event.>", againPrefix + ">"},
Retention: RetentionLimits,
MaxAge: 7 * 24 * 60 * 60,
MaxMsgsPerSubject: 10000,
// A restricted dead letter delivered again keeps what is left of its stream's age (ADR 0376 §5).
AllowMsgTTL: true,
Why: "a subscriber that was down catches up; tool traffic under the same prefix is " +
"excluded by the event token; per-subject caps keep a noisy emitter from " +
"evicting a quiet one without splitting the stream",
},
{
Name: DeadLetterNoticesStream,
Subjects: []string{MaxDeliveriesAdvisories},
Retention: RetentionWorkQueue,
MaxAge: 7 * 24 * 60 * 60,
Why: "the server's word that a consumer gave up on a message, kept until the controller has " +
"copied the message into DEAD_LETTERS (novox/hq issue 330); a week, the longest the source " +
"streams keep what they are about",
},
{
Name: DeadLettersStream,
Subjects: []string{deadLetterPrefix + ">"},
Retention: RetentionLimits,
MaxBytes: DeadLettersBytes,
DiscardNew: true,
DuplicatesSeconds: 24 * 60 * 60,
AllowMsgTTL: true,
Why: "every message a consumer gave up on, with its consumer, subject, deliveries and when, kept " +
"until a person delivers it again or drops it with why (novox/hq issue 330); no age but a restricted " +
"stream's message, which ages out with its stream (ADR 0376), and full it refuses the next copy " +
"rather than drop one it holds",
},
{
Name: ProvAppliedStream,
Subjects: []string{ProvAppliedSubjects},
Retention: RetentionLastPerSubject,
Direct: true,
MaxAge: 7 * 24 * 60 * 60,
Why: "each machine's provisioner client's newest word on each module's files, applied, waiting or " +
"failed with the reason (novox/hq ADR 0349 §4): what a walk's end and a gate wait for (ADR 0362, " +
"ADR 0349 §15), read by subject; a week, as the events are",
},
}
}
// ProvAppliedStream keeps what each machine's provisioner client last said of each module's files, newest per
// subject (novox/hq ADR 0349 §4, ADR 0362, design 57 P6): `prov.applied.<node>.<module>` lies outside `mesh.*`, so
// no other stream captures it, and a report said while no controller listened is still there to be read.
const ProvAppliedStream = "PROV_APPLIED"
// ProvAppliedSubjects is every machine's client's reports: `prov.applied.<node>.<module>`, a module's name dots and
// all (ProvisionerAppliedSubject).
const ProvAppliedSubjects = "prov.applied.>"
// The ended controller's consumers' names (novox/hq ADR 0590 clause 6): kept only so its leftovers are named for their
// removal (ControllerLeftoverConsumers) and the watchman can read whether `controller` on DEAD_LETTER_NOTICES still
// stands.
const (
ControllerName = "controller"
MergesConsumerName = "controller-merges"
)
// EventsStream holds every module's and every role's events, a build's log among them.
const EventsStream = "EVENTS"
+105
View File
@@ -0,0 +1,105 @@
package buscompose
// Declared is one module on one node, as composing its authority needs it.
type Declared struct {
Module string
Emits []string
Consumes []string
Serves []string
// Served are the config keys its served values and zone name fill from its settings (catalogue.ServedSettings): what
// every node may read of its resolved values where it runs (Principal.ServedReads, issue 1126).
Served []string
// Holds are the seats this module claims, with the protocol each seat declares. A seat the
// mesh defines for itself declares no protocol, so holding one grants nothing on the bus —
// which is right: those seats are about who does a job, not about who may say what.
Holds []Seat
// Uses are the seats this module sends to.
Uses []Seat
// Issued are the tools that answer a credential its software issued, one per offer that says so (novox/hq
// ADR 0305): the controller is granted to ask each in its own name, and no other tool so.
Issued []string
// Watches are the seats whose events it consumes.
Watches []Seat
// Invokes are the tools it calls, `<module>.<tool>` or `*` (novox/hq ADR 0152).
Invokes []string
// Waits are the event families a session may wait on through the tool runner, the tool runner's module's
// alone (novox/hq ADR 0367 §2): its account subscribes them for a call, through no consumer.
Waits []Wait
// State is the state it keeps, each a bucket its instances write (novox/hq ADR 0201).
State []Bucket
// Logs are the logs it keeps, each a stream its instances append to (novox/hq ADR 0297).
Logs []Log
// Streams are the streams it owns, each its events' one place (novox/hq ADR 0363).
Streams []ModuleStream
// Queues are its own work-queue streams and the one process each is the consumer of (novox/hq ADR 0436
// §1, §2, §6): one durable consumer per entry, named `<node>_<module>_<process id>`.
Queues []QueueOwner
// Hears are the module streams it hears on this machine, each with the subjects it was granted
// (novox/hq ADR 0363 §5); HearsAStream says its `consumes` names a module that owns a stream, granted
// or not, so its membership is issued again when a grant changes.
Hears []Heard
HearsAStream bool
// Reads are other modules' state it reads, each `<module>.<name>` (novox/hq ADR 0201).
Reads []string
// ReadsRefused are the reads its manifest names that the owner's announcement does not make readable (novox/hq ADR
// 0495 clause 3, issue 1790): granted nothing, kept to be said.
ReadsRefused []string
// ReadLogs are, of its reads, those naming another module's log (`<module>.<log>`, novox/hq ADR 0571 clause 1): filled
// by Users and PlacementsOf from the logs the modules placed anywhere declare, never by a caller.
ReadLogs []string
// KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is
// granted for the pieces the modules beside it offer (novox/hq ADR 0260).
KeyedReads []KeyedRead
// NoAccount says the module declares no own secret named broker, so no account could ever be
// delivered to it and nothing can connect as it (novox/hq issue 195). Said in the negative so a
// record that does not say is composed as it always was.
NoAccount bool
// SnapshotsTheBus says the module holds mesh-broker — it is the bus — and so is the one module
// granted the snapshot API, to copy the bus's streams for the night's backup (novox/hq ADR 0235).
SnapshotsTheBus bool
// Checks are the module's own tools its health asks, each `<module>.<tool>` (novox/hq ADR 0240, to-be
// 48 §3): the machine's node-engine asks them of its own node tools, and is granted that and no more.
Checks []string
// Loads says the module has a compiled bundle whose entrypoints the machine's tool runtime loads and runs
// (novox/hq ADR 0175; issue 1329): a helper that reaches the bus on the runner's account, so the runner
// must carry the module although it declares no tools.
Loads bool
// RunsAs is the account the module runs as in a runtime of its own (novox/hq ADR 0259 §8): it is never
// carried by the machine's runtime, and reaches the bus on its own account.
RunsAs string
// ProvisionsNode says the module runs the machine's provisioner client: it claims NodeProvisionerSeat and
// declares the own secret ProvisionerSecret its account is delivered in (novox/hq ADR 0349 clause 11, design 57
// P3). The client is given a bus account of its own, never the module's or the tool runner's.
ProvisionsNode bool
// Processes are this module's own processes that declare bus (novox/hq ADR 0436 §1), each getting a
// principal of its own: granted exactly what its bus says, and nothing of this module's other grants —
// composed whether or not the module itself has an account (NoAccount), because a process account is
// delivered in its own secret, broker-<process id>, never the module's `broker`.
Processes []ProcessPrincipal
}
// ProcessPrincipal is one process of a module that declares bus (novox/hq ADR 0436 §1, §2), as the broker
// composes its own account from: its own emits, its own work-queue consumers, its own state, whether it
// reads the router's records of its module's own asks, and the tools and seat verbs it calls.
type ProcessPrincipal struct {
// Process is the process resource's id, naming the account <node>.<module>.<process id>.
Process string
// Emits are the events this process may publish, named as the module's own Emits are.
Emits []string
// Queues are the module's own work-queue streams this process is the one consumer of.
Queues []QueueOwner
// Removes are what its account may remove from the bus, as its bus declares them (the indexer's judge refuses any other; novox/hq
// ADR 0564 Limits 4): a tools bundle's account, whose whole authority is then BusRemovalGrants.
Removes []string
// State is the module's own state, by local name, this process reads and writes.
State []string
// Asks says whether this process reads the records of its module's own asks (novox/hq ADR 0259 §3), on
// whichever seats the module uses that keep them (the router's, operator-channel, among others).
Asks bool
// Invokes are the tools and seat verbs this process calls, each <module>.<tool> or a role's
// seat:<seat>.<verb>, or * for every tool on the mesh.
Invokes []string
}
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
const VaultSeat = "mesh-vault"
+18
View File
@@ -0,0 +1,18 @@
package buscompose
// Wait is one event family the tool runner's module declares: its name as the manifest gives it, and the
// emitter — a seat or a module — and the event, as the inventory resolved them.
type Wait struct {
// Name is the family as the manifest names it, `<emitter>.<event>`: what a session asks for.
Name string
Emitter string
// Seat says the emitter is a seat, whose events live under the seat's namespace (novox/hq ADR 0121).
Seat bool
// Event is the event, with any further token the subject carries: `decided.claude-code`, the asker of a
// seat that names it last (ADR 0259 §3).
Event string
// Last names what the seat's event subject ends in — "kind" for a kinded bench, "asker" for a seat naming
// its caller (ADR 0259 §3) — and is empty for an event that ends at its name. A family needing it is waited
// on with it given, and refused without.
Last string
}
+86
View File
@@ -0,0 +1,86 @@
// Package busretry waits out a bus that answers nothing for a few seconds (issue 774, the sweep of 2026-10-30: bus
// blackouts at every user-list reload). nats-server 2.11's reloadAuthorization rebuilds the accounts, and for a few
// seconds the mesh's account has none of its imports, $JS.API.> among them: every request is answered "no responders"
// and every JetStream call "jetstream not enabled". A write or a call made in that window failed, and was lost.
//
// Only an error that says the request reached no one is waited out (Undelivered): nothing was done, so asking again
// is safe even for a write that is not idempotent. A timeout, or any answer, is never asked again: the request may
// have been delivered. The wait is bounded (Bound, about 30 s, backing off), and said on standard error when it ends
// without an answer.
//
// This is the quick fix. The thorough one is a reload that keeps the account's imports, in the bus's holder.
package busretry
import (
"errors"
"fmt"
"os"
"strings"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Bound is how long a request that reached no one is asked again; FirstWait the first pause, doubled to MostWait.
var (
Bound = 30 * time.Second
FirstWait = 250 * time.Millisecond
MostWait = 4 * time.Second
)
// Waits sets Bound, FirstWait and MostWait and returns what puts the earlier values back. They are state every caller
// in a process shares, so a test sets them only through it, as t.Cleanup(busretry.Waits(...)): a test that set them
// and left them set shortened the next test's bound to milliseconds, and a loaded check machine overran it (the check
// of release sdk1, 2026-11-02). A bound a test waits on to succeed is seconds: no slow machine reaches it.
func Waits(bound, first, most time.Duration) (restore func()) {
was := [3]time.Duration{Bound, FirstWait, MostWait}
Bound, FirstWait, MostWait = bound, first, most
return func() { Bound, FirstWait, MostWait = was[0], was[1], was[2] }
}
// Undelivered says err means the request reached no one: no responders, JetStream not enabled (for the account), or
// no response from the stream after the client's own retries on no responders; also said in words by a runtime that
// passed the error on.
func Undelivered(err error) bool {
var answered *Answered
if err == nil || errors.As(err, &answered) {
return false
}
if errors.Is(err, nats.ErrNoResponders) || errors.Is(err, jetstream.ErrJetStreamNotEnabled) ||
errors.Is(err, jetstream.ErrJetStreamNotEnabledForAccount) || errors.Is(err, jetstream.ErrNoStreamResponse) ||
errors.Is(err, nats.ErrJetStreamNotEnabled) || errors.Is(err, nats.ErrJetStreamNotEnabledForAccount) {
return true
}
words := strings.ToLower(err.Error())
return strings.Contains(words, "no responders") || strings.Contains(words, "jetstream not enabled")
}
// Answered is an error a callee answered with: the request was delivered, whatever its words say ("jetstream not
// enabled" in a callee's own answer), so Undelivered never counts it and Do never asks it again.
type Answered struct{ Words string }
func (a *Answered) Error() string { return a.Words }
// Do runs try, and again while its error says the request reached no one, until Bound has passed. blackout, when
// given, is asked after each such error and Do stops waiting when it says the bus is not blacked out: a call nobody
// answers because its callee is not running fails at once, as before. what names the request in the error.
func Do[T any](what string, try func() (T, error), blackout func() bool) (T, error) {
v, err := try()
if !Undelivered(err) || (blackout != nil && !blackout()) {
return v, err
}
start, wait := time.Now(), FirstWait
for time.Since(start) < Bound {
time.Sleep(wait)
wait = min(wait*2, MostWait)
v, err = try()
if !Undelivered(err) || (blackout != nil && !blackout()) {
return v, err
}
}
err = fmt.Errorf("%s: the bus answered nothing for %s, asked again until then (a user-list reload drops the "+
"account's imports for a few seconds; this was longer): %w", what, time.Since(start).Round(time.Second), err)
fmt.Fprintf(os.Stderr, "[mesh-sdk] %v\n", err)
return v, err
}
+54
View File
@@ -0,0 +1,54 @@
package busretry
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// Issue 774 (bus blackouts at every user-list reload): only a request that reached no one is asked again, within the
// bound; a blackout probe that says the bus answers ends the wait at once.
func TestABusThatAnswersNothingIsWaitedOut(t *testing.T) {
for _, row := range []struct {
name string
errs []error // what each try answers; the last repeats
blackout func() bool
tries int // at least
atMost int
delivered bool
}{
{"no responders, then an answer", []error{nats.ErrNoResponders, nats.ErrNoResponders, nil}, nil, 3, 3, true},
{"jetstream not enabled, then an answer", []error{jetstream.ErrJetStreamNotEnabled, nil}, nil, 2, 2, true},
{"said in words by a runtime", []error{errors.New("nats: no responders available for request"), nil}, nil, 2, 2, true},
{"a timeout may have been delivered: never asked again", []error{context.DeadlineExceeded}, nil, 1, 1, false},
{"an answer that refuses is an answer", []error{errors.New("refused")}, nil, 1, 1, false},
{"nobody answers on a bus that answers: the callee is not running", []error{nats.ErrNoResponders}, func() bool { return false }, 1, 1, false},
{"blacked out past the bound: said", []error{jetstream.ErrJetStreamNotEnabledForAccount}, func() bool { return true }, 2, 1000, false},
} {
// A row that ends in an answer waits on a bound of seconds, which no slow machine reaches; only the row that
// runs past it has a short one, and asks only that it was asked again at least once.
bound := 10 * time.Second
if strings.HasPrefix(row.name, "blacked out") {
bound = 100 * time.Millisecond
}
t.Cleanup(Waits(bound, time.Millisecond, 4*time.Millisecond))
tries := 0
_, err := Do("the test", func() (int, error) {
e := row.errs[min(tries, len(row.errs)-1)]
tries++
return tries, e
}, row.blackout)
if tries < row.tries || tries > row.atMost || (err == nil) != row.delivered {
t.Errorf("%s: %d tries, %v", row.name, tries, err)
}
if strings.HasPrefix(row.name, "blacked out") && (!Undelivered(err) || !strings.Contains(fmt.Sprint(err), "the bus answered nothing")) {
t.Errorf("%s: %v", row.name, err)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
// Package calladdress reads the key a module calls a tool or a seat verb by, as a module's invokes names it, with the
// machine it is asked of after '@': `seat:<seat>.<verb>[@<node>]` or `<module>.<tool>[@<node>]` (novox/hq ADR 0159,
// ADR 0469 §3). Every caller in the SDK parses its key here before it publishes anything, so a malformed key fails at
// once and says why, never as a call nobody serves and a timeout (novox/hq issue 774, the lease write's stop asked on
// `...call.stop@novox-be...`).
package calladdress
import (
"fmt"
"strings"
)
// Address is a parsed key: a seat and its verb, or a module and its tool, and the machine named, if any.
type Address struct {
Seat, Verb string
Module, Tool string
Node string
}
// Key is the address as a module's invokes names it, without the machine.
func (a Address) Key() string {
if a.Seat != "" {
return "seat:" + a.Seat + "." + a.Verb
}
return a.Module + "." + a.Tool
}
// Parse reads a key, refusing one with more than one '@', a node that is not one subject token, or a seat, verb, module
// or tool that holds a wildcard, white space or (but a tool) a dot.
func Parse(key string) (Address, error) {
name, node, err := cutNode(key)
if err != nil {
return Address{}, err
}
if rest, ok := strings.CutPrefix(name, "seat:"); ok {
seat, verb, ok := strings.Cut(rest, ".")
if !ok || seat == "" || verb == "" {
return Address{}, fmt.Errorf("%q names no seat and verb (seat:<seat>.<verb>)", key)
}
if err := tokens(key, seat, verb); err != nil {
return Address{}, err
}
return Address{Seat: seat, Verb: verb, Node: node}, nil
}
module, tool, ok := strings.Cut(name, ".")
if !ok || module == "" || tool == "" {
return Address{}, fmt.Errorf("%q is not <module>.<tool>, and does not start seat:<seat>.<verb>", key)
}
if err := tokens(key, module); err != nil {
return Address{}, err
}
if err := checkTool(key, tool); err != nil {
return Address{}, err
}
return Address{Module: module, Tool: tool, Node: node}, nil
}
// CheckOwn is Parse for a caller that may also name a tool of its own module by its bare name (the runtime's mesh/ask).
func CheckOwn(key string) error {
name, _, err := cutNode(key)
if err != nil {
return err
}
if !strings.HasPrefix(name, "seat:") && !strings.Contains(name, ".") {
return tokens(key, name)
}
_, err = Parse(key)
return err
}
// CheckNode refuses a node name that is not one subject token.
func CheckNode(node string) error {
if node == "" || strings.ContainsAny(node, ".*>@ \t\r\n") {
return fmt.Errorf("%q is not a node name (one subject token: no '.', '*', '>', '@' or space)", node)
}
return nil
}
func cutNode(key string) (name, node string, err error) {
name, node, at := strings.Cut(key, "@")
if !at {
return key, "", nil
}
if strings.Contains(node, "@") {
return "", "", fmt.Errorf("%q holds more than one '@': a key names at most one machine", key)
}
if err := CheckNode(node); err != nil {
return "", "", fmt.Errorf("%q: %w", key, err)
}
return name, node, nil
}
func tokens(key string, parts ...string) error {
for _, p := range parts {
if p == "" || strings.ContainsAny(p, ".*> \t\r\n") {
return fmt.Errorf("%q: %q is not one subject token (no '.', '*', '>' or space)", key, p)
}
}
return nil
}
// checkTool refuses a tool name with a wildcard or white space; a dot is kept, as the tool runner serves it.
func checkTool(key, tool string) error {
if strings.ContainsAny(tool, "*> \t\r\n") || strings.HasPrefix(tool, ".") || strings.HasSuffix(tool, ".") {
return fmt.Errorf("%q: %q is not one subject token (no '*', '>' or space)", key, tool)
}
return nil
}
+56
View File
@@ -0,0 +1,56 @@
package calladdress
import (
"strings"
"testing"
)
// Issue 774 notes 84-87 (the lease write's stop asked of `seat:node-service-manager.stop@novox-be` published on a subject
// nobody serves and timed out after 30 s): a key names its machine after '@', and every other malformed key is refused
// before anything is published, naming why.
func TestAKeyIsParsedOrRefusedBeforeItIsPublished(t *testing.T) {
for _, row := range []struct {
key, want string // want: "seat|<seat>|<verb>|<node>", "mod|<module>|<tool>|<node>", or a refusal's words
}{
{"seat:node-service-manager.stop@novox", "seat|node-service-manager|stop|novox"},
{"seat:operator-channel.tell", "seat|operator-channel|tell|"},
{"mesh-build-go.fetch@ace", "mod|mesh-build-go|fetch|ace"},
{"nats.nats_users", "mod|nats|nats_users|"},
{"seat:node-service-manager.stop@novox@g14", "more than one '@'"},
{"seat:node-service-manager.stop@", "not a node name"},
{"seat:node-service-manager.stop@nov.ox", "not a node name"},
{"seat:node-files.*", "not one subject token"},
{"mesh-build-go.fe tch", "not one subject token"},
{"mesh>build.fetch", "not one subject token"},
{"seat:noverb", "names no seat and verb"},
{"nodot", "is not <module>.<tool>"},
{"", "is not <module>.<tool>"},
} {
a, err := Parse(row.key)
got := ""
switch {
case err != nil:
got = err.Error()
if !strings.Contains(got, row.want) {
t.Errorf("Parse(%q) refused %q, want %q", row.key, got, row.want)
}
continue
case a.Seat != "":
got = "seat|" + a.Seat + "|" + a.Verb + "|" + a.Node
default:
got = "mod|" + a.Module + "|" + a.Tool + "|" + a.Node
}
if got != row.want {
t.Errorf("Parse(%q) = %s, want %s", row.key, got, row.want)
}
}
if a, err := Parse("seat:node-files.read@g14"); err != nil || a.Key() != "seat:node-files.read" {
t.Errorf("the key without its machine: %q, %v", a.Key(), err)
}
if err := CheckOwn("list@ace"); err != nil {
t.Errorf("a module's own tool by its bare name was refused: %v", err)
}
if err := CheckOwn("li*st"); err == nil {
t.Error("a bare name that is not one token was accepted")
}
}
+16
View File
@@ -11,6 +11,7 @@ import (
// callWith serves one tool that answers the caller CallerOf reads from its arguments, sends it one tools/call // callWith serves one tool that answers the caller CallerOf reads from its arguments, sends it one tools/call
// with the params given, and returns the caller it answered. // with the params given, and returns the caller it answered.
func callWith(t *testing.T, params map[string]any) string { func callWith(t *testing.T, params map[string]any) string {
servesAlone(t)
t.Helper() t.Helper()
inR, inW := io.Pipe() inR, inW := io.Pipe()
outR, outW := io.Pipe() outR, outW := io.Pipe()
@@ -75,6 +76,21 @@ func TestACallerInTheArgumentsIsNeverTheCaller(t *testing.T) {
if got != "shanks.node-tools" { if got != "shanks.node-tools" {
t.Fatalf("with a caller in the arguments the tool read %q, want the runtime's", got) t.Fatalf("with a caller in the arguments the tool read %q, want the runtime's", got)
} }
// A module's trusted list of callers (issue 774, ADR 0563 §8) grants the callers it names as the runtime proved them:
// a module on any machine, <machine>.<module> exactly, person.<name> exactly; no wildcard, never one unproven.
callers, err := ParseCallers([]byte(`{"callers":["node-tools","ace.claude-code","person.jo","*"]}`))
if err != nil {
t.Fatal(err)
}
for caller, want := range map[string]bool{"shanks.node-tools": true, "ace.claude-code": true, "g14.claude-code": false,
"person.jo": true, "person.node-tools": false, Unproven: false, "": false, "novox.mesh-vault": false} {
if callers.Allows(caller) != want {
t.Errorf("%q allowed: %v, want %v", caller, !want, want)
}
}
if _, err := ParseCallers([]byte(`{"callers":["x"],"more":1}`)); err == nil {
t.Error("a list with a field it does not know was read")
}
} }
// A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is // A runtime that names no caller — one from before issue 365, or a call on the subject that names none — is
+48
View File
@@ -0,0 +1,48 @@
package stdio
import (
"bytes"
"encoding/json"
"fmt"
"strings"
)
// Callers is a module's trusted list of who may call a verb of its (novox/hq ADR 0459 §2, ADR 0563 §8), as the mesh
// writes a list setting into a file the module reads: `{"callers": [...]}`. An entry is a module's name (that module on
// any machine), exactly <machine>.<module>, or exactly person.<name>. Each caller is the runtime's proof (CallerOf), never
// an argument; nobody beyond the list is granted, a wildcard is no entry, and an unproven caller never matches.
type Callers []string
// ParseCallers reads a callers file: the list under `callers`, and nothing else.
func ParseCallers(raw []byte) (Callers, error) {
var f struct {
Callers Callers `json:"callers"`
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.DisallowUnknownFields()
if err := dec.Decode(&f); err != nil {
return nil, fmt.Errorf("the callers setting is not a list of callers: %v", err)
}
return f.Callers, nil
}
// Allows says whether caller, as the runtime proved it, is one the list names.
func (c Callers) Allows(caller string) bool {
if caller == "" || caller == Unproven {
return false
}
for _, entry := range c {
if entry == "" || strings.ContainsAny(entry, "*> ") {
continue
}
if entry == caller {
return true
}
if !strings.Contains(entry, ".") {
if _, module, ok := strings.Cut(caller, "."); ok && module == entry && !strings.HasPrefix(caller, "person.") {
return true
}
}
}
return false
}
+1 -90
View File
@@ -1,8 +1,6 @@
package catalogue package catalogue
import ( import (
"encoding/json"
"strings"
"testing" "testing"
) )
@@ -31,7 +29,7 @@ func aServiceModule(t *testing.T) Manifest {
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch", "build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
"from": "cmd/svc", "binary": "svc"}]} "from": "cmd/svc", "binary": "svc"}]}
}` }`
m, err := ParseManifest([]byte(raw)) m, err := decodeForTest([]byte(raw))
if err != nil { if err != nil {
t.Fatalf("the service's manifest is refused: %v", err) t.Fatalf("the service's manifest is refused: %v", err)
} }
@@ -43,17 +41,6 @@ func aServiceModule(t *testing.T) Manifest {
return resolved return resolved
} }
func composeTheService(t *testing.T, with Rendering) []map[string]any {
t.Helper()
with.Needed = map[string]map[string]string{"svc": {"store": "sealed-store"}}
with.ArtifactStore = "anchor.internal:5100"
out, err := Resolution{Node: "anchor", Modules: []Manifest{aServiceModule(t)}}.Declaration(with)
if err != nil {
t.Fatalf("the service does not compose: %v", err)
}
return out
}
func indexOf(out []map[string]any, id string) int { func indexOf(out []map[string]any, id string) int {
for i, r := range out { for i, r := range out {
if r["id"] == id { if r["id"] == id {
@@ -88,79 +75,3 @@ func TestABundleItsOwnProcessRunsIsNotServedByTheRuntime(t *testing.T) {
t.Fatalf("a tools bundle nothing runs is no longer served: %v", loads) t.Fatalf("a tools bundle nothing runs is no longer served: %v", loads)
} }
} }
// The account is created before anything is given to it. Its secrets are mesh-computed and so
// placed before the module's own resources; given to a user the machine did not have yet, they were
// refused on the first apply and the process started without them.
func TestAModulesAccountComesBeforeWhatBelongsToIt(t *testing.T) {
out := composeTheService(t, Rendering{})
account, secret := indexOf(out, "svc.account"), indexOf(out, "svc."+NeedID("store"))
if account < 0 || secret < 0 {
t.Fatalf("the account or the secret is missing: %v", out)
}
if account > secret {
t.Fatalf("the secret owned by svc is written before svc exists: account at %d, secret at %d",
account, secret)
}
if owner := out[secret]["owner"]; owner != "svc" {
t.Errorf("the secret belongs to %v, not the account its process runs as", owner)
}
}
// The process is the module's program; its preparation is the same program asked to prepare, as a
// step before it — with the same account and environment, and handing nothing over.
func TestAProcessIsPreparedByItsOwnProgram(t *testing.T) {
out := composeTheService(t, Rendering{})
step, process := indexOf(out, "svc.service-prepare"), indexOf(out, "svc.service")
if step < 0 || process < 0 || step > process {
t.Fatalf("the preparation is not a step before the process (%d, %d): %v", step, process, out)
}
s := out[step]
if s["type"] != "process" || s["run-once"] != true || s["name"] != "svc-prepare" {
t.Errorf("the preparation is not a run-once process: %v", s)
}
if run, _ := json.Marshal(s["run"]); string(run) != `["./svc","prepare"]` {
t.Errorf("the preparation runs %s", run)
}
if s["user"] != "svc" || s["source"] != out[process]["source"] {
t.Errorf("the preparation does not run the same bundle as the same account: %v", s)
}
if env, _ := s["env"].(map[string]any); env["SVC_STORE_FILE"] == nil {
t.Errorf("the preparation is not given the process's environment: %v", s["env"])
}
if _, has := s["replaces"]; has {
t.Errorf("the preparation would hand over what the process replaces: %v", s)
}
if _, has := s["args"]; has {
t.Errorf("the preparation carries a container's args: %v", s)
}
}
// What the process replaces is named as the host recorded it, `<module>.<id>`; unprefixed, the host
// matches nothing and removes the container first, as before.
func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) {
out := composeTheService(t, Rendering{})
p := out[indexOf(out, "svc.service")]
if got, _ := json.Marshal(p["replaces"]); string(got) != `["svc.server"]` {
t.Fatalf("the process replaces %s", got)
}
}
func TestWhatReplacesMayNameIsRefusedNearItsAuthor(t *testing.T) {
for what, resource := range map[string]string{
"a container": `{"id":"c","type":"container","name":"c","image":"x@` + aServiceDigest + `","replaces":["old"]}`,
"a step": `{"id":"p","type":"process","name":"p","run":["./p"],"run-once":true,"replaces":["old"]}`,
"something declared": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["p"]}`,
"another module's": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["other.old"]}`,
"not a list": `{"id":"p","type":"process","name":"p","run":["./p"],"replaces":"old"}`,
} {
raw := `{"module":"m","version":"1","resources":[` + resource + `]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "replace") {
t.Errorf("replaces on %s was accepted: %v", what, err)
}
}
ok := `{"module":"m","version":"1","resources":[{"id":"p","type":"process","name":"p","run":["./p"],"replaces":["old"]}]}`
if _, err := ParseManifest([]byte(ok)); err != nil {
t.Errorf("a process replacing what its module no longer declares was refused: %v", err)
}
}
-108
View File
@@ -1,108 +0,0 @@
package catalogue
import (
"strings"
"testing"
)
// The fault 04-ISSUES/036 records: a media stack is several modules — a library server, the
// acquisition managers, a download client — that must share directories on one machine. Written
// as owned `directory` resources, two of them on one node are refused as rivalrous owners, which
// is right in general and wrong here: sharing those directories is the whole point of the stack.
//
// Declared as accesses to an operator-owned path (novox/hq ADR 0051), they co-resolve. An access
// is not a resource and never enters the duplicate-owner map, so this is the exact case the
// resolver refuses above when the same path is owned — and does not, when it is merely accessed.
func TestTwoModulesAccessingOnePathCoResolve(t *testing.T) {
a := mod("a", nil, nil, nil)
a.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessReadWrite}}
b := mod("b", nil, nil, nil)
b.Accesses = []Access{{Path: "/services/media/downloads", Mode: AccessRead}}
got, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{})
if err != nil {
t.Fatalf("two modules accessing one operator-owned path were refused: %v", err)
}
// And each accessing module's grant reaches the machine as its own `access` resource, so the
// host can find the path present or refuse — both naming the one operator-owned path.
var accesses int
for _, r := range mustDeclare(t, got) {
if r["type"] == "access" && r["path"] == "/services/media/downloads" {
accesses++
}
}
if accesses != 2 {
t.Errorf("expected both modules' accesses in the declaration, got %d", accesses)
}
}
// The mirror of the case above, so the sharing vocabulary does not weaken the rule it sits beside:
// two modules OWNING one path is still refused, exactly as before accesses existed.
func TestTwoModulesOwningOnePathAreStillRefused(t *testing.T) {
a := mod("a", nil, nil, nil)
a.Resources = []map[string]any{
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
b := mod("b", nil, nil, nil)
b.Resources = []map[string]any{
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
_, err := Resolve(shelf(a, b), []string{"a", "b"}, workstation(), World{})
if err == nil {
t.Fatal("two modules owning the same directory were both assigned")
}
if !strings.Contains(err.Error(), "/services/media/movies") {
t.Errorf("the refusal does not name the path: %v", err)
}
}
// Shared data is the operator's, owned by no module (novox/hq ADR 0051). A module owning the path
// another module was told to expect the operator to provide would create and chown it — and the
// two intentions cannot both hold, so it is refused by name. This is what keeps the distinction
// machine-checkable rather than a convention nobody enforces.
func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) {
owns := mod("owns", nil, nil, nil)
owns.Resources = []map[string]any{
{"id": "lib", "type": "directory", "path": "/services/media/movies", "mode": "0755"}}
uses := mod("uses", nil, nil, nil)
uses.Accesses = []Access{{Path: "/services/media/movies", Mode: AccessRead}}
_, err := Resolve(shelf(owns, uses), []string{"owns", "uses"}, workstation(), World{})
if err == nil {
t.Fatal("a module owning a path another accesses was allowed")
}
if !strings.Contains(err.Error(), "/services/media/movies") ||
!strings.Contains(err.Error(), "the operator's") {
t.Errorf("the refusal does not explain the ownership conflict: %v", err)
}
}
// A manifest states an access's extent, the way a listening port states its source. An absolute
// path and a mode of read or read-write; anything else is refused rather than acted on wrongly.
func TestAnAccessIsValidated(t *testing.T) {
good := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"read-write"}]}`)
if _, err := ParseManifest(good); err != nil {
t.Fatalf("a valid access was refused: %v", err)
}
// Mode is optional and narrows to read — the safe default, because the danger with an access
// is being given more than was meant, not less.
bare := []byte(`{"module":"m","accesses":[{"path":"/services/media"}]}`)
m, err := ParseManifest(bare)
if err != nil {
t.Fatalf("an access with no mode was refused: %v", err)
}
if m.Accesses[0].At() != AccessRead {
t.Errorf("an access with no mode is %q, want %q", m.Accesses[0].At(), AccessRead)
}
relative := []byte(`{"module":"m","accesses":[{"path":"services/media","mode":"read"}]}`)
if _, err := ParseManifest(relative); err == nil {
t.Error("a relative access path was accepted")
}
wrong := []byte(`{"module":"m","accesses":[{"path":"/services/media","mode":"append"}]}`)
if _, err := ParseManifest(wrong); err == nil {
t.Error("an access with an unknown mode was accepted")
}
}
-174
View File
@@ -2,18 +2,9 @@ package catalogue
import ( import (
"fmt" "fmt"
"sort"
"strconv"
"strings" "strings"
) )
// What an adopted node is declared in place of a filter (novox/hq ADR 0100).
//
// On an adopted node the firewall found on the machine stays in force: the mesh loads no table
// that drops by default or holds an accept. What the mesh needs reachable is declared as
// openings, which the host converges through the found firewall in its own terms; and the mesh
// guards its own foundation ports itself, in a table that only refuses.
// AdoptionPrefix is the id prefix of what the mesh declares of its own on an adopted node. It is // AdoptionPrefix is the id prefix of what the mesh declares of its own on an adopted node. It is
// never a module's, so none of it is ever held as found. // never a module's, so none of it is ever held as found.
const AdoptionPrefix = "adoption." const AdoptionPrefix = "adoption."
@@ -39,87 +30,9 @@ const (
GuardUnitPath = "/etc/systemd/system/" + GuardUnit GuardUnitPath = "/etc/systemd/system/" + GuardUnit
) )
// GuardID, GuardUnitID and GuardRunningID are the guard's resource identities. The installer
// raises the same three on an adopted genesis, so the first push finds them already there.
func GuardID() string { return AdoptionPrefix + "guard" }
func GuardUnitID() string { return AdoptionPrefix + "guard-unit" }
func GuardRunningID() string { return AdoptionPrefix + "guard-running" }
// GuardPackageID is the tool that loads the guard, declared first: a node joining adopted has
// no filter module and may have no nft at all, and a table nothing can load guards nothing.
func GuardPackageID() string { return AdoptionPrefix + "guard-package" }
// GuardPackage is the package that carries nft. // GuardPackage is the package that carries nft.
const GuardPackage = "nftables" const GuardPackage = "nftables"
// OpeningID is an opening's resource identity: its protocol, port and path say what it is.
func OpeningID(protocol string, port int, path string) string {
return fmt.Sprintf("%sopening-%s-%d-%s", AdoptionPrefix, protocol, port, path)
}
// Openings are what the mesh needs reachable on an adopted node, from the same inputs as the
// filter it would load were the node converged, each from where that filter would admit it.
//
// `rules` is Filtering's answer — every module's listens, the hub's port, the per-node exposure —
// and `foundation` is the ports the mesh itself needs, from everywhere. A rule for this machine
// only opens nothing. `published` maps a machine port a container publishes to the container's
// port: a published port is forwarded, not received, so its opening names the forwarded path and
// the port the packet is forwarded to.
func Openings(rules []Rule, foundation []int, published map[string]map[int]int) []map[string]any {
type key struct {
protocol string
port int
}
from := map[key]string{}
var order []key
widen := func(k key, f string) {
was, seen := from[k]
if !seen {
order = append(order, k)
}
if !seen || was != OpeningFromEverywhere {
from[k] = f
}
}
for _, rule := range rules {
switch rule.From {
case FromEverywhere:
widen(key{rule.Protocol, rule.Port}, OpeningFromEverywhere)
case FromMesh:
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 {
widen(key{"tcp", port}, OpeningFromEverywhere)
}
sort.Slice(order, func(a, b int) bool {
if order[a].port != order[b].port {
return order[a].port < order[b].port
}
return order[a].protocol < order[b].protocol
})
out := make([]map[string]any, 0, len(order))
for _, k := range order {
opening := map[string]any{"type": "opening", "port": k.port, "protocol": k.protocol,
"from": from[k]}
if to, forwarded := published[k.protocol][k.port]; forwarded {
opening["id"] = OpeningID(k.protocol, k.port, PathForwarded)
opening["path"] = PathForwarded
opening["to"] = to
} else {
opening["id"] = OpeningID(k.protocol, k.port, PathIncoming)
opening["path"] = PathIncoming
}
out = append(out, opening)
}
return out
}
// Published is every port the given containers publish on the machine, by protocol and machine // Published is every port the given containers publish on the machine, by protocol and machine
// port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off // port, mapped to the container's own port. A mapping bound to loopback is left out: nothing off
// the machine reaches it, forwarded or not. // the machine reaches it, forwarded or not.
@@ -153,90 +66,3 @@ func Published(resources []map[string]any) map[string]map[int]int {
} }
return out return out
} }
// AsGuard renders the mesh's refusal-only table for the given machine ports.
//
// It passes everything by default and holds nothing but a refusal, so it cannot close anything
// the machine serves; and it is the mesh's own table, so the found firewall reloading does not
// touch it. It refuses only packets addressed to this machine, and the ports except from the
// machine itself — its loopback and the container runtime's own networks — and from the private
// network, known by the interface a packet arrives on and never by its source address. At
// prerouting, ahead of the runtime's destination translation, so it matches the port the packet
// was sent to; in the inet family, so both address families.
//
// **The machine's own interfaces are named, where the derived filter names address ranges.** The
// filter accepts the container runtime's networks by CIDR; this excludes its bridges by name — lo,
// docker0, the br-* a compose network gets, and the mesh's own mesh0. A runtime whose bridge is
// named anything else (a podman or libvirt bridge, or a docker network created with a fixed name)
// would have its containers' traffic to a guarded port refused, which reads as the port being
// down. Names rather than addresses is deliberate: a source address can be claimed by whoever
// sends the packet, and this table exists to refuse what the found firewall never sees. Widening
// it means adding names here and in the installer's copy together, which the golden test holds to
// one text.
//
// The same text the installer raises on an adopted genesis; a test holds both to it.
func AsGuard(ports []int) string {
sorted := append([]int{}, ports...)
sort.Ints(sorted)
listed := make([]string, len(sorted))
for i, p := range sorted {
listed[i] = strconv.Itoa(p)
}
var b strings.Builder
b.WriteString("table inet mesh_guard {}\n")
b.WriteString("delete table inet mesh_guard\n")
b.WriteString("table inet mesh_guard {\n")
b.WriteString("\tchain prerouting {\n")
b.WriteString("\t\ttype filter hook prerouting priority raw; policy accept;\n")
// Only packets addressed to this machine: traffic it routes for others — a predecessor's hub,
// say — is never the guard's business (novox/hq ADR 0103).
fmt.Fprintf(&b, "\t\tfib daddr type local iifname != \"lo\" iifname != \"docker0\" "+
"iifname != \"br-*\" iifname != \"mesh0\" tcp dport { %s } drop\n",
strings.Join(listed, ", "))
b.WriteString("\t}\n")
b.WriteString("}\n")
return b.String()
}
// GuardUnitText is the unit that loads the guard. Stopping it deletes only its own table: never
// a flush, which would take the container runtime's rules and the found firewall with it.
func GuardUnitText() string {
return "[Unit]\n" +
"Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)\n" +
// Early, before the network is up, and without the default dependencies that would
// order it after the network; stopped at shutdown like any unit.
"DefaultDependencies=no\n" +
"Wants=network-pre.target\n" +
"Before=network-pre.target shutdown.target\n" +
"Conflicts=shutdown.target\n" +
"\n" +
"[Service]\n" +
"Type=oneshot\n" +
"RemainAfterExit=yes\n" +
"ExecStart=nft -f " + GuardPath + "\n" +
"ExecReload=nft -f " + GuardPath + "\n" +
"ExecStop=nft delete table inet mesh_guard\n" +
"\n" +
"[Install]\n" +
"WantedBy=multi-user.target\n"
}
// GuardResources are the guard as four resources of the existing kinds: the tool that loads it,
// the table, the unit, and the unit running — reloaded when the table changes, so the new table
// replaces the old in one `nft -f` through the unit's ExecReload with no moment unguarded, and
// restarted only when the unit itself changes. Nothing when there is nothing to guard: an empty
// set is not a table nft loads.
func GuardResources(ports []int) []map[string]any {
if len(ports) == 0 {
return nil
}
return []map[string]any{
{"id": GuardPackageID(), "type": "package", "package": GuardPackage},
{"id": GuardID(), "type": "file", "path": GuardPath, "content": AsGuard(ports),
"mode": "0644"},
{"id": GuardUnitID(), "type": "file", "path": GuardUnitPath, "content": GuardUnitText(),
"mode": "0644"},
{"id": GuardRunningID(), "type": "service", "unit": GuardUnit, "state": "running",
"boot": "enabled", "restart-on": []any{GuardUnitID()}, "reload-on": []any{GuardID()}},
}
}
-622
View File
@@ -1,10 +1,7 @@
package catalogue package catalogue
import ( import (
"encoding/json"
"reflect" "reflect"
"slices"
"strings"
"testing" "testing"
) )
@@ -15,14 +12,6 @@ import (
// hub is the private network's generator on the hub: it opens the hub's port from anywhere. // hub is the private network's generator on the hub: it opens the hub's port from anywhere.
type hub struct{} type hub struct{}
func (hub) Resources(string) ([]map[string]any, bool, error) {
return []map[string]any{{"id": "config", "type": "file", "path": "/etc/wireguard/mesh0.conf",
"content": "[Interface]\n"}}, true, nil
}
func (hub) Listens(string) ([]Listening, error) {
return []Listening{{Port: 51820, Protocol: "udp", From: FromEverywhere}}, nil
}
// anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network, // anAdoptedAnchor is the control-node's set: the store, the bus, the registry, the private network,
// a served module and the filter module. // a served module and the filter module.
func anAdoptedAnchor() Resolution { func anAdoptedAnchor() Resolution {
@@ -51,24 +40,6 @@ func anAdoptedAnchor() Resolution {
}} }}
} }
func anchorRendering(adopted bool) Rendering {
return Rendering{
Generators: map[string]Generator{"overlay": hub{}},
Ports: map[string]map[int]int{"distribution": {5000: 5000}, "hello-web": {8080: 20001}},
Settings: SettingsBy{"distribution": {{From: "node anchor",
Placement: map[string]any{ExposeSetting: map[string]any{"5000": FromEverywhere}}}}},
Mesh: []string{"10.42.0.1"},
Foundation: []int{5671},
// What the machine reported faces outside, which every rule in the filter is written
// around (novox/hq ADR 0140).
OutwardLinks: []string{"eth0"},
TunnelInterface: "mesh0",
Adopted: adopted,
// Genesis takes the foundation's modules.
Taken: map[string]bool{"postgres": true, "lavinmq": true},
}
}
func byID(resources []map[string]any) map[string]map[string]any { func byID(resources []map[string]any) map[string]map[string]any {
out := map[string]map[string]any{} out := map[string]map[string]any{}
for _, r := range resources { for _, r := range resources {
@@ -77,320 +48,6 @@ func byID(resources []map[string]any) map[string]map[string]any {
return out return out
} }
func TestAnAdoptedNodeIsDeclaredOpeningsFromTheSameInputsAsTheFilter(t *testing.T) {
composed, err := anAdoptedAnchor().Compose(anchorRendering(true))
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
want := map[string]map[string]any{
// The hub's port, from anywhere, received.
"adoption.opening-udp-51820-incoming": {"port": 51820, "protocol": "udp",
"from": "everywhere", "path": "incoming"},
// The store's port from the private network only, and forwarded: a container publishes it.
"adoption.opening-tcp-5432-forwarded": {"port": 5432, "protocol": "tcp", "from": "mesh",
"path": "forwarded", "to": 5432},
// The bus from anywhere: a node enrols over it before it has a private address.
"adoption.opening-tcp-5671-forwarded": {"port": 5671, "protocol": "tcp",
"from": "everywhere", "path": "forwarded", "to": 5671},
"adoption.opening-tcp-5672-forwarded": {"port": 5672, "protocol": "tcp", "from": "mesh",
"path": "forwarded", "to": 5672},
// The registry from anywhere, by its node's exposure setting.
"adoption.opening-tcp-5000-forwarded": {"port": 5000, "protocol": "tcp",
"from": "everywhere", "path": "forwarded", "to": 5000},
// A published port names the machine port and the container port it is forwarded to.
"adoption.opening-tcp-20001-forwarded": {"port": 20001, "protocol": "tcp",
"from": "everywhere", "path": "forwarded", "to": 8080},
}
for id, fields := range want {
opening, ok := got[id]
if !ok {
t.Errorf("no %s among %v", id, keys(got))
continue
}
if opening["type"] != "opening" {
t.Errorf("%s is a %v", id, opening["type"])
}
for k, v := range fields {
if opening[k] != v {
t.Errorf("%s: %s is %v, want %v", id, k, opening[k], v)
}
}
}
for id := range got {
if strings.HasPrefix(id, "adoption.opening-") && want[id] == nil {
t.Errorf("an opening nothing asked for: %s", id)
}
}
// A port for this machine only opens nothing, and the management port is not opened at all.
for id := range got {
if strings.Contains(id, "-9000-") || strings.Contains(id, "-15672-") {
t.Errorf("%s is opened", id)
}
}
// And openings come first, in the order the machine applies them.
if !strings.HasPrefix(composed.Resources[0]["id"].(string), "adoption.opening-") {
t.Errorf("openings are not first: %v", composed.Resources[0]["id"])
}
}
func TestAnAdoptedNodeLoadsNoFilterOfTheMeshs(t *testing.T) {
composed, err := anAdoptedAnchor().Compose(anchorRendering(true))
if err != nil {
t.Fatal(err)
}
for _, r := range composed.Resources {
if r["path"] == "/etc/nftables.conf" || strings.HasPrefix(r["id"].(string), "nftables.") {
t.Fatalf("an adopted node is declared the filter module's %v", r["id"])
}
if content, _ := r["content"].(string); strings.Contains(content, "policy drop") {
t.Fatalf("an adopted node is declared a table that drops by default: %v", r["id"])
}
// Nothing but refusals: the only accept in the guard is its policy.
if content, _ := r["content"].(string); r["id"] == GuardID() &&
strings.Count(content, "accept") != 1 {
t.Fatalf("the guard holds an accept:\n%s", content)
}
}
got := byID(composed.Resources)
guard := got[GuardID()]
if guard == nil || got[GuardUnitID()] == nil || got[GuardRunningID()] == nil {
t.Fatalf("no guard: %v", keys(got))
}
if guard["content"] != AsGuard([]int{5432, 5672, 15672}) {
t.Fatalf("the guard does not guard the store, the broker and its management port:\n%s",
guard["content"])
}
// The tool that loads it comes first, and the table after it: a node joining adopted has no
// filter module and may have no nft.
pkg, table := -1, -1
for i, r := range composed.Resources {
switch r["id"] {
case GuardPackageID():
pkg = i
if r["type"] != "package" || r["package"] != "nftables" {
t.Fatalf("the guard's package is %v", r)
}
case GuardID():
table = i
}
}
if pkg < 0 || pkg > table {
t.Fatalf("nftables is not declared before the guard's table (%d, %d)", pkg, table)
}
// A changed table is reloaded — one `nft -f`, atomic — never restarted, which would delete the
// table and leave the ports unguarded until it is loaded again. Only a changed unit restarts.
if !reflect.DeepEqual(got[GuardRunningID()]["reload-on"], []any{GuardID()}) ||
!reflect.DeepEqual(got[GuardRunningID()]["restart-on"], []any{GuardUnitID()}) {
t.Fatalf("the guard is not reloaded on its table and restarted on its unit: %v",
got[GuardRunningID()])
}
// Nothing of the mesh's own is anybody's to hold.
for id, module := range composed.Owner {
if strings.HasPrefix(id, AdoptionPrefix) {
t.Fatalf("%s is owned by %s", id, module)
}
}
}
func TestAConvergedNodeIsDeclaredItsFilterAndNoOpenings(t *testing.T) {
composed, err := anAdoptedAnchor().Compose(anchorRendering(false))
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
if got["nftables.filtering"] == nil || got["nftables.load"] == nil {
t.Fatalf("a converged node lost its filter: %v", keys(got))
}
for id := range got {
if strings.HasPrefix(id, AdoptionPrefix) {
t.Fatalf("a converged node is declared %s", id)
}
}
plain, err := anAdoptedAnchor().Declaration(anchorRendering(false))
if err != nil {
t.Fatal(err)
}
a, _ := json.Marshal(plain)
b, _ := json.Marshal(composed.Resources)
if string(a) != string(b) {
t.Fatal("Compose and Declaration disagree on a converged node")
}
}
// The table the installer raises and the controller declares, character for character.
func TestTheGuardIsExactlyThisTable(t *testing.T) {
const golden = `table inet mesh_guard {}
delete table inet mesh_guard
table inet mesh_guard {
chain prerouting {
type filter hook prerouting priority raw; policy accept;
fib daddr type local iifname != "lo" iifname != "docker0" iifname != "br-*" iifname != "mesh0" tcp dport { 5432, 15672 } drop
}
}
`
if got := AsGuard([]int{15672, 5432}); got != golden {
t.Fatalf("the guard changed:\n%s", got)
}
const unit = `[Unit]
Description=The mesh's guard: refuses its own ports from outside (novox/hq ADR 0100)
DefaultDependencies=no
Wants=network-pre.target
Before=network-pre.target shutdown.target
Conflicts=shutdown.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=nft -f /etc/mesh/guard.nft
ExecReload=nft -f /etc/mesh/guard.nft
ExecStop=nft delete table inet mesh_guard
[Install]
WantedBy=multi-user.target
`
if got := GuardUnitText(); got != unit {
t.Fatalf("the guard's unit changed:\n%s", got)
}
if GuardResources(nil) != nil {
t.Fatal("a guard with nothing to guard is an empty set nft refuses to load")
}
}
func TestAGuardedPortMustBeAPort(t *testing.T) {
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[5432]}`)); err != nil {
t.Fatalf("guards is refused: %v", err)
}
if _, err := ParseManifest([]byte(`{"module":"postgres","guards":[0]}`)); err == nil {
t.Fatal("guarding port 0 was accepted")
}
}
func keys[V any](m map[string]V) []string {
return sortedKeys(m)
}
// novox/hq ADR 0100: the foundation's ports are the node's. Given 5433 for the store, every place
// that uses the port reads it from there: the container, the filter, the openings, the guard.
func TestAGivenPortIsUsedEverywhereThePortIs(t *testing.T) {
given := map[string]map[int]int{"postgres": {5432: 5433}, "lavinmq": {15672: 15673}}
for _, adopted := range []bool{true, false} {
with := anchorRendering(adopted)
with.Given = given
with.Ports["postgres"] = map[int]int{5432: 5433}
composed, err := anAdoptedAnchor().Compose(with)
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
if ports := got["postgres.server"]["ports"]; !reflect.DeepEqual(ports, []any{"5433:5432"}) {
t.Fatalf("the store's container publishes %v", ports)
}
if ports := got["lavinmq.server"]["ports"]; !reflect.DeepEqual(ports,
[]any{"5671:5671", "5672:5672", "127.0.0.1:15673:15672"}) {
t.Fatalf("the broker's container publishes %v", ports)
}
if !adopted {
filter, _ := got["nftables.filtering"]["content"].(string)
if !strings.Contains(filter, "tcp dport 5433 accept") || strings.Contains(filter, "5432") {
t.Fatalf("the filter does not use the given port:\n%s", filter)
}
continue
}
if o := got["adoption.opening-tcp-5433-forwarded"]; o == nil || o["to"] != 5432 {
t.Fatalf("no opening for the given port: %v", keys(got))
}
if guard := got[GuardID()]["content"]; guard != AsGuard([]int{5433, 5672, 15673}) {
t.Fatalf("the guard does not guard the given ports:\n%s", guard)
}
}
}
func TestAGivenPortIsTheNodesAndReachesSomething(t *testing.T) {
store := anAdoptedAnchor().Modules[1]
node := func(v any) []Layer {
return []Layer{{From: "anchor", Placement: map[string]any{PortsSetting: v}}}
}
if got, err := GivenPorts(store, node(map[string]any{"5432": float64(5433)})); err != nil ||
got[5432] != 5433 {
t.Fatalf("a node's given port was not read: %v %v", got, err)
}
if _, err := GivenPorts(store, []Layer{{From: MeshWideLayer,
Placement: map[string]any{PortsSetting: map[string]any{"5432": float64(5433)}}}}); err == nil {
t.Fatal("a port given for the whole mesh was accepted")
}
if _, err := GivenPorts(store, node(map[string]any{"6000": float64(6001)})); err == nil {
t.Fatal("a port the module neither listens on, publishes nor guards was given")
}
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(70000)})); err == nil {
t.Fatal("a machine port that is not a port was given")
}
if _, err := GivenPorts(store, node(map[string]any{"5432": float64(22)})); err == nil {
t.Fatal("ssh's port was given")
}
broker := anAdoptedAnchor().Modules[2]
if _, err := GivenPorts(broker, node(map[string]any{"5671": float64(5700),
"5672": float64(5700)})); err == nil {
t.Fatal("one machine port was given for two of the module's ports")
}
if stray := UnusedSettings(store, node(map[string]any{"5432": float64(5433)})); len(stray) != 0 {
t.Fatalf("a given port is called stray: %v", stray)
}
}
// novox/hq ADR 0103: the guard is derived, and from taken modules only — every machine port a taken
// module publishes that the filter admits from the private network only, and the ports its
// manifest guards. A module assigned but not taken is not guarded: its port may still be the
// predecessor's.
func TestTheGuardIsDerivedFromTakenModulesOnly(t *testing.T) {
guardOf := func(with Rendering) string {
t.Helper()
composed, err := anAdoptedAnchor().Compose(with)
if err != nil {
t.Fatal(err)
}
content, _ := byID(composed.Resources)[GuardID()]["content"].(string)
return content
}
// The broker taken, the store not: the broker's plain port follows from its listens (from
// the mesh, published), its management port from its manifest; the store is not guarded, and
// neither is the bus, which the mesh needs from everywhere.
with := anchorRendering(true)
with.Taken = map[string]bool{"lavinmq": true}
if got := guardOf(with); got != AsGuard([]int{5672, 15672}) {
t.Fatalf("the guard is not the taken broker's ports:\n%s", got)
}
// A taken module publishing a port admitted from everywhere is not guarded; one admitted from
// the mesh is. The registry is exposed everywhere on this node, and hello-web listens from
// everywhere.
with.Taken = map[string]bool{"distribution": true, "hello-web": true}
if got := guardOf(with); got != "" {
t.Fatalf("a port admitted from everywhere is guarded:\n%s", got)
}
with.Settings = nil
if got := guardOf(with); got != AsGuard([]int{5000}) {
t.Fatalf("the registry, from the mesh only, is not guarded:\n%s", got)
}
// Nothing taken, nothing guarded — and no guard at all rather than an empty set.
with = anchorRendering(true)
with.Taken = nil
if got := guardOf(with); got != "" {
t.Fatalf("an untaken store is guarded:\n%s", got)
}
// A given port is followed: where the machine put it is what is refused.
with = anchorRendering(true)
with.Given = map[string]map[int]int{"lavinmq": {5672: 5682, 15672: 15673}}
with.Ports["lavinmq"] = map[int]int{5671: 5671, 5672: 5682}
if got := guardOf(with); got != AsGuard([]int{5432, 5682, 15673}) {
t.Fatalf("the guard does not follow the given ports:\n%s", got)
}
}
// A mapping bound to loopback is not published to anything off the machine — in either address // A mapping bound to loopback is not published to anything off the machine — in either address
// family — and an address's own colons never shift the ports. // family — and an address's own colons never shift the ports.
func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) { func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) {
@@ -402,282 +59,3 @@ func TestPublishedLeavesOutLoopbackInBothFamilies(t *testing.T) {
t.Fatalf("published is %v, want %v", got, want) t.Fatalf("published is %v, want %v", got, want)
} }
} }
// novox/hq ADR 0038: only a published port is the mesh's to move. A module that binds the machine
// itself listens where its software was told to, so giving it a machine port is refused.
func TestAGivenPortIsRefusedForAPortNoContainerPublishes(t *testing.T) {
onTheMachine := Manifest{Module: "daemon",
Listens: []Listening{{Port: 9000, From: FromMesh}}, Guards: []int{9000}}
layers := []Layer{{From: "node anchor",
Placement: map[string]any{PortsSetting: map[string]any{"9000": float64(9100)}}}}
_, err := GivenPorts(onTheMachine, layers)
if err == nil || !strings.Contains(err.Error(), "does not publish") {
t.Fatalf("a port no container publishes was given: %v", err)
}
}
// novox/hq ADR 0103: a guarded port this node is told to open to everyone is opened, not guarded.
// An opening from everywhere beside a guard dropping it is one statement refusing the other.
func TestAGuardedPortOpenedToEveryoneIsNotGuarded(t *testing.T) {
with := anchorRendering(true)
with.Settings["postgres"] = []Layer{{From: "node anchor",
Placement: map[string]any{ExposeSetting: map[string]any{"5432": FromEverywhere}}}}
composed, err := anAdoptedAnchor().Compose(with)
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
if o := got["adoption.opening-tcp-5432-forwarded"]; o == nil || o["from"] != OpeningFromEverywhere {
t.Fatalf("the store's port is not opened to everyone: %v", o)
}
if guard, _ := got[GuardID()]["content"].(string); guard != AsGuard([]int{5672, 15672}) {
t.Fatalf("a port opened to everyone is still guarded:\n%s", guard)
}
}
// A module that publishes its ssh port the long way — `2222:22`, because the machine's own daemon
// holds 22 — and says it listens on the machine side of that mapping, which is what anything
// reaching it dials.
func aForge() Manifest {
return Manifest{Module: "forge",
Provides: []Offer{{Name: "git-over-ssh", Scope: ScopeMesh}},
Serves: map[string]map[string]any{"git-over-ssh": {"port": 2222}},
Listens: []Listening{{Port: 3000, From: FromMesh}, {Port: 2222, From: FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "forge",
"ports": []any{"3000", "2222:22"}}}}
}
// portsAsThePlanWould is where this machine puts each of a module's ports, derived the way
// cmd/mesh-controller/plan.go derives it: a given port first, looked up by the port the module
// says it listens on, and otherwise wherever the manifest's own mapping already put it. Written
// here because everything below — the filter, the openings, the guard, what a consumer is told —
// reads that map, and a given port that the lookup does not find moves the container's mapping
// and nothing else.
//
// It stands in for the plan only where the plan does not allocate: a port the manifest already
// placed, or one a node was given. For a short form with no given port the real plan asks the
// inventory for a machine port and may come back with one from the pool, which needs a store and
// is what cmd/mesh-controller's own tests exercise. So an assertion here about such a port asserts
// this helper, not the mesh; keep the assertions to the ports under test.
func portsAsThePlanWould(m Manifest, given map[int]int) map[int]int {
out := map[int]int{}
for _, l := range m.Listens {
if at, is := given[l.Port]; is {
out[l.Port] = at
continue
}
at, _ := m.MachineSide(l.Port)
out[l.Port] = at
}
return out
}
// novox/hq ADR 0100 and 0038: the machine side of a long-form mapping is a port the module
// publishes, so a node may move it — and a module may name a mapping by either end.
func TestAGivenPortNamesEitherEndOfWhatTheModulePublishes(t *testing.T) {
forge := aForge()
node := func(v any) []Layer {
return []Layer{{From: "node anchor", Placement: map[string]any{PortsSetting: v}}}
}
// The machine side — the number this module says it listens on, and the one the predecessor
// had somewhere else. Answered under 2222, the end the module itself names, which is what
// every reader of this map asks for. One entry, not two: a second key for the same answer is
// an entry another mapping's reader could find instead.
given, err := GivenPorts(forge, node(map[string]any{"2222": float64(222)}))
if err != nil {
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
}
if want := map[int]int{2222: 222}; !reflect.DeepEqual(given, want) {
t.Fatalf("the forge was given %v; the mapping it names is filed under %v", given, want)
}
// The container's own port names the same mapping and means the same thing — and is filed
// under the same name, because the module's name for it has not changed.
if inside, err := GivenPorts(forge, node(map[string]any{"22": float64(222)})); err != nil ||
!reflect.DeepEqual(inside, map[int]int{2222: 222}) {
t.Fatalf("the container's end of the mapping was given %v: %v", inside, err)
}
// A short form is published on the number it names, and is unchanged by any of this.
if short, err := GivenPorts(forge, node(map[string]any{"3000": float64(2999)})); err != nil ||
short[3000] != 2999 {
t.Fatalf("the short form was given %v: %v", short, err)
}
// A port no container publishes is still refused, in the same words.
if _, err := GivenPorts(forge, node(map[string]any{"9000": float64(9100)})); err == nil ||
!strings.Contains(err.Error(), "does not publish") {
t.Fatalf("a port the forge does not publish was given: %v", err)
}
// And the two ends of one mapping given two different numbers is one setting contradicting
// the other: the machine publishes it once.
if _, err := GivenPorts(forge, node(map[string]any{
"2222": float64(222), "22": float64(300)})); err == nil ||
!strings.Contains(err.Error(), "one mapping") {
t.Fatalf("the two ends of one mapping were given different ports: %v", err)
}
// Said at both ends with the same number, it is still said twice, and refused where every
// other repeated machine port is — as the inventory refuses it when the setting is stored,
// which is the layer that sees it first.
if _, err := GivenPorts(forge, node(map[string]any{
"2222": float64(222), "22": float64(222)})); err == nil ||
!strings.Contains(err.Error(), "to both its 22 and its 2222") {
t.Fatalf("one mapping given one machine port at both ends: %v", err)
}
// A number that names two different mappings names neither: which one to move is not said.
twice := aForge()
twice.Resources[0]["ports"] = []any{"22", "2222:22"}
if _, err := GivenPorts(twice, node(map[string]any{"22": float64(222)})); err == nil ||
!strings.Contains(err.Error(), "twice") {
t.Fatalf("a number naming two of the module's mappings was accepted: %v", err)
}
// And the same refusal when the number naming two mappings is not the one the setting used
// but the one the answer would be filed under. Here `80` is the module's own name for a
// mapping, and two mappings wear it; filing an answer there is one container's port standing
// where the other's is read, and both containers then publish it.
shared := Manifest{Module: "gallery",
Listens: []Listening{{Port: 80, From: FromMesh}},
Resources: []map[string]any{
{"id": "a", "type": "container", "name": "a", "ports": []any{"4001:80"}},
{"id": "b", "type": "container", "name": "b", "ports": []any{"4002:80"}}}}
if _, err := GivenPorts(shared, node(map[string]any{"4001": float64(1234)})); err == nil ||
!strings.Contains(err.Error(), "twice") {
t.Fatalf("two containers were put on one machine port: %v", err)
}
// A module whose mappings chain — one's machine side is another's container port — keeps them
// apart, because each is filed under the port the module names it by and neither name is
// shared. Given both, each moves on its own and neither overwrites the other.
chained := Manifest{Module: "chain",
Listens: []Listening{{Port: 80, From: FromMesh}, {Port: 9090, From: FromMesh}},
Resources: []map[string]any{{"id": "server", "type": "container", "name": "chain",
"ports": []any{"8080:80", "9090:8080"}}}}
both, err := GivenPorts(chained, node(map[string]any{"80": float64(1234), "9090": float64(5678)}))
if err != nil || !reflect.DeepEqual(both, map[int]int{80: 1234, 9090: 5678}) {
t.Fatalf("chained mappings were given %v: %v", both, err)
}
if moved := givenOuter("8080:80", both); moved != "1234:80" {
t.Fatalf("the first mapping moved to %q, and it was given 1234", moved)
}
if moved := givenOuter("9090:8080", both); moved != "5678:8080" {
t.Fatalf("the second mapping moved to %q, and it was given 5678", moved)
}
}
// And the number reaches everything derived from it. The fault this is written against moved the
// container's mapping alone: the filter opened the port the software had left, the adopted node's
// opening named it too, the guard refused it, and a consumer was sent to it.
func TestAGivenMachineSideReachesTheFilterTheOpeningAndTheConsumer(t *testing.T) {
forge := aForge()
given, err := GivenPorts(forge, []Layer{{From: "node anchor",
Placement: map[string]any{PortsSetting: map[string]any{"2222": float64(222)}}}})
if err != nil {
t.Fatalf("the machine side of a mapping cannot be given a port: %v", err)
}
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
with := Rendering{
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, given)},
Given: map[string]map[int]int{"forge": given},
Mesh: []string{"10.77.0.1"},
Adopted: true,
OutwardLinks: []string{"eth0"},
TunnelInterface: "mesh0",
Taken: map[string]bool{"forge": true},
}
// What the runtime is handed: the machine's own port on the outside, the container's within.
composed, err := r.Compose(with)
if err != nil {
t.Fatalf("the forge does not compose: %v", err)
}
got := byID(composed.Resources)
if ports := got["forge.server"]["ports"]; !reflect.DeepEqual(ports, []any{"3000:3000", "222:22"}) {
t.Fatalf("the forge's container publishes %v", ports)
}
// What the filter would open, were the node converged.
rules, err := r.Rules(with)
if err != nil {
t.Fatal(err)
}
var opened []int
for _, rule := range rules {
opened = append(opened, rule.Port)
}
// Only the moved port is asserted: the forge's other port is a short form the real plan would
// allocate rather than read off the manifest, so its number here is portsAsThePlanWould's and
// not the mesh's.
if !slices.Contains(opened, 222) || slices.Contains(opened, 2222) {
t.Fatalf("the filter opens %v, not where the machine puts the forge", opened)
}
// And what it is declared instead, adopted: an opening on the machine's port, naming the
// container's port on the forwarded path — a published port is forwarded, never received.
opening := got[OpeningID("tcp", 222, PathForwarded)]
if opening == nil || opening["to"] != 22 || opening["from"] != OpeningFromMesh {
t.Fatalf("no opening for the port this node gave the forge: %v", keys(got))
}
for id := range got {
if strings.HasPrefix(id, "adoption.opening-tcp-2222-") {
t.Errorf("an opening for the port the forge was moved off: %s", id)
}
}
// The guard refuses it where the machine put it, and nothing where it used to be.
guard, _ := got[GuardID()]["content"].(string)
if !strings.Contains(guard, "222") || strings.Contains(guard, "2222") {
t.Fatalf("the guard does not follow the given port:\n%s", guard)
}
// And a consumer is sent to the same number, which is read from what the module serves.
if told := ServedOn(forge, "git-over-ssh", with.Ports["forge"])["port"]; told != 222 {
t.Fatalf("a consumer is told the forge answers on %v", told)
}
}
// And the mapping itself moves under either name, because Rendering.Given is a map anybody
// composing a declaration hands in: keyed by the machine side, which is what a module declaring
// 2222 calls its port, only the outside moves and the container's own port stays as written.
func TestAMappingIsMovedUnderEitherOfItsNames(t *testing.T) {
for _, c := range []struct {
written string
given map[int]int
want string
}{
{"2222:22", map[int]int{2222: 222}, "222:22"},
{"2222:22", map[int]int{22: 222}, "222:22"},
{"127.0.0.1:15672:15672/tcp", map[int]int{15672: 15673}, "127.0.0.1:15673:15672/tcp"},
{"2222:22", map[int]int{3000: 2999}, "2222:22"},
{"2222:22", nil, "2222:22"},
} {
if got := givenOuter(c.written, c.given); got != c.want {
t.Errorf("%s given %v is published as %s, want %s", c.written, c.given, got, c.want)
}
}
}
// The same on a node that was given nothing, which is where the derivation was never at fault:
// a long-form mapping is published where the manifest put it, and an adopted node opens that port
// on the forwarded path like any other. What broke it was the number reaching only the mapping.
func TestALongFormPortIsOpenedWhereTheManifestPublishesIt(t *testing.T) {
forge := aForge()
r := Resolution{Node: "anchor", Modules: []Manifest{forge}}
composed, err := r.Compose(Rendering{
Ports: map[string]map[int]int{"forge": portsAsThePlanWould(forge, nil)},
Mesh: []string{"10.77.0.1"},
Adopted: true,
OutwardLinks: []string{"eth0"},
TunnelInterface: "mesh0",
})
if err != nil {
t.Fatal(err)
}
got := byID(composed.Resources)
opening := got[OpeningID("tcp", 2222, PathForwarded)]
if opening == nil || opening["to"] != 22 {
t.Fatalf("no opening for the forge's published ssh port: %v", keys(got))
}
}
-119
View File
@@ -1,119 +0,0 @@
package catalogue
import (
"testing"
)
// The account agents run as (novox/hq ADR 0266): a module names it as a machine fact — the agent account
// where the node names one, the operator's otherwise — and asks the node-engine to judge it never to become
// root only where it is the agents' own.
func TestTheAgentAccountFactFallsBackToTheOperatorAndIsNeverRootOnlyWhenItsOwn(t *testing.T) {
facts := machineFacts(Resolution{Node: "anchor", Account: "ops"}, nil, "")
if facts["agent-account"] != "ops" || facts["agent-home"] != "/home/ops" || facts["agent-root"] != "" {
t.Errorf("with no agent account named, agents run as the operator: %v", facts)
}
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AccountHome: "/srv/ops"}, nil, "")
if facts["agent-home"] != "/srv/ops" {
t.Errorf("the operator's stated home is the agent's home when they are one account: %v", facts)
}
facts = machineFacts(Resolution{Node: "anchor", Account: "ops", AgentAccount: "agent"}, nil, "")
if facts["agent-account"] != "agent" || facts["agent-home"] != "/home/agent" || facts["agent-root"] != RootNever {
t.Errorf("a named agent account is the agents', never root: %v", facts)
}
if facts["account"] != "ops" {
t.Errorf("the operator account is still the operator's: %v", facts)
}
facts = machineFacts(Resolution{Node: "anchor", AgentAccount: "agent", AgentAccountHome: "/var/lib/agent"}, nil, "")
if facts["agent-home"] != "/var/lib/agent" || facts["agent-root"] != RootNever {
t.Errorf("an agent account with a stated home on a machine with no operator: %v", facts)
}
if _, has := machineFacts(Resolution{Node: "anchor"}, nil, "")["agent-account"]; has {
t.Error("a machine with no account at all names an agent account")
}
}
// The agent's module, in the shape the catalogue's declares it: the account, never root where it is its
// own; its directory under that home, owned by it.
const agentModule = `{"module": "agent", "version": "1", "resources": [
{"id": "account", "type": "user", "name": "${machine:agent-account}", "home": "${machine:agent-home}",
"root": "${machine:agent-root}"},
{"id": "home", "type": "directory", "path": "${machine:agent-home}/.agent", "mode": "0700",
"owner": "${machine:agent-account}"}
]}`
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
m, err := ParseManifest([]byte(agentModule))
if err != nil {
t.Fatal(err)
}
compose := func(r Resolution, with Rendering) (user, home map[string]any) {
t.Helper()
r.Node, r.Modules = "anchor", []Manifest{m}
out, err := r.Declaration(with)
if err != nil {
t.Fatal(err)
}
return fileNamed(out, "agent.account"), fileNamed(out, "agent.home")
}
user, home := compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{JudgesRoot: true})
if user["name"] != "agent" || user[RootField] != RootNever || user["home"] != "/home/agent" {
t.Errorf("an engine that judges root is sent the agent account never to become root: %v", user)
}
if home["path"] != "/home/agent/.agent" || home["owner"] != "agent" {
t.Errorf("the agent's directory is under its own home, its own: %v", home)
}
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent", AgentAccountHome: "/srv/agent"}, Rendering{JudgesRoot: true})
if user["home"] != "/srv/agent" {
t.Errorf("an agent account named with a home of its own is made there: %v", user)
}
user, _ = compose(Resolution{Account: "ops", AgentAccount: "agent"}, Rendering{})
if _, sent := user[RootField]; sent || user["name"] != "agent" {
t.Errorf("an older engine, which parses strictly, is sent root: %v", user)
}
user, home = compose(Resolution{Account: "ops"}, Rendering{JudgesRoot: true})
if _, sent := user[RootField]; sent || user["name"] != "ops" {
t.Errorf("where agents run as the operator, root asserts nothing and is not sent: %v", user)
}
if home["path"] != "/home/ops/.agent" || home["owner"] != "ops" {
t.Errorf("with no agent account, the agent's directory is the operator's: %v", home)
}
}
func TestTheRuntimeIsToldTheAgentAccount(t *testing.T) {
with := Rendering{ArtifactStore: "anchor.internal:5101",
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
envOf := func(r Resolution) map[string]string {
t.Helper()
r.Node, r.Modules = "anchor", []Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)}
out, err := r.Declaration(with)
if err != nil {
t.Fatal(err)
}
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
if process == nil {
t.Fatal("no runtime process was composed")
}
return process["env"].(map[string]string)
}
env := envOf(Resolution{Account: "ops", AgentAccount: "agent"})
if env[RuntimeAgentAccount] != "agent" || env[RuntimeAgentHome] != "/home/agent" || env[RuntimeOperatorAccount] != "ops" {
t.Errorf("the runtime is not told whom agents run as: %v", env)
}
env = envOf(Resolution{Account: "ops"})
if env[RuntimeAgentAccount] != "ops" || env[RuntimeAgentHome] != "/home/ops" {
t.Errorf("with no agent account, agents run as the operator: %v", env)
}
env = envOf(Resolution{})
if _, set := env[RuntimeAgentAccount]; set {
t.Errorf("a machine with no account names an agent account: %v", env)
}
if problems := bundleEnvProblems("x", Artifact{Name: "b", Kind: ArtifactBundle, Loads: []string{"x"},
Env: map[string]string{RuntimeAgentAccount: "me"}}); len(problems) == 0 {
t.Error("a bundle may tell the runtime whom agents run as")
}
}
@@ -14,22 +14,6 @@ import (
// the old wire protocol asks for the one server being retired, so both directions are refused at the // the old wire protocol asks for the one server being retired, so both directions are refused at the
// parser — this is judged from the manifest alone, no store needed. // parser — this is judged from the manifest alone, no store needed.
func TestAManifestProvidingAmqpIsRefused(t *testing.T) {
raw := []byte(`{"module":"old-broker","version":"1","provides":[{"name":"amqp","scope":"mesh"}]}`)
_, err := ParseManifest(raw)
if err == nil || !strings.Contains(err.Error(), `provides "amqp", which is not a provision`) {
t.Fatalf("a module providing amqp was not refused, or not for the reason: %v", err)
}
}
func TestAManifestRequiringAmqpIsRefused(t *testing.T) {
raw := []byte(`{"module":"forwarder","version":"1","requires":["amqp"]}`)
_, err := ParseManifest(raw)
if err == nil || !strings.Contains(err.Error(), `requires "amqp", which is not a provision`) {
t.Fatalf("a module requiring amqp was not refused, or not for the reason: %v", err)
}
}
// And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under // And the catalogue (internal/beside) names it nowhere — the three modules that did are removed under
// design 28 task 5.4, not converted. // design 28 task 5.4, not converted.
func TestNoCatalogueManifestNamesAmqp(t *testing.T) { func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
-18
View File
@@ -1,18 +0,0 @@
package catalogue
import "testing"
// A claim written before the rename still holds (novox/hq ADR 0122, ADR 0156): with the store's
// aliases loaded, the former name resolves to the seat.
func TestTheArtifactStoresFormerNameResolvesToIt(t *testing.T) {
was := aliases
t.Cleanup(func() { aliases = was })
UseAliases(map[string]string{"the-artifact-store": "mesh-artifact-store"})
seat, known := SeatNamed("the-artifact-store")
if !known || seat.Name != "mesh-artifact-store" || seat.Delivers != "artifact-store" {
t.Fatalf("the former name did not resolve: %+v %v", seat, known)
}
if _, known := SeatNamed("mesh-artifact-store"); !known {
t.Fatal("the seat is not in the set under its name")
}
}
-38
View File
@@ -1,38 +0,0 @@
package catalogue
import (
"strings"
"testing"
)
// The artifact store's seat is one per mesh, read from the catalogue beside this checkout.
//
// **A second store anywhere is refused by name, not discovered as a consumer failure.** The seat
// was node-scoped, so a second `distribution` on another machine resolved cleanly there — and a
// node-scoped requirement with one candidate installs that candidate on the node, so anything that
// required the store's presence beside it would have raised a fresh, empty store on the wrong
// machine. Only afterwards did the mesh notice: `artifact-store` offered by two nodes, and every
// consumer elsewhere refusing to choose. The claim says it first, where the second store is
// assigned.
func TestASecondArtifactStoreAnywhereIsRefusedByName(t *testing.T) {
store := catalogueManifest(t, "distribution")
// The first store resolves as it always has.
if _, err := Resolve(shelf(store), []string{"distribution"}, workstation(), World{}); err != nil {
t.Fatalf("the store alone does not resolve: %v", err)
}
// A second one, on any other machine, is refused — and the refusal names the seat.
elsewhere := World{Held: []Held{{Claim: "mesh-artifact-store", Scope: ScopeMesh,
Node: "anchor", Module: "distribution"}}}
other := workstation()
other.Name = "laptop"
_, err := Resolve(shelf(store), []string{"distribution"}, other, elsewhere)
if err == nil {
t.Fatal("a second store was accepted on another machine; it would offer artifact-store a " +
"second time and every consumer elsewhere would refuse to choose")
}
if !strings.Contains(err.Error(), "mesh-artifact-store") || !strings.Contains(err.Error(), "one per mesh") {
t.Fatalf("refused without naming the seat: %v", err)
}
}
+43
View File
@@ -0,0 +1,43 @@
package catalogue
// ArtifactSweepSeat is the seat artifact-store-tools holds. Not the seat mesh-artifact-store, the store itself held by
// distribution, and not the provision artifact-store it delivers.
const ArtifactSweepSeat = "mesh-artifact-sweep"
// artifactSweepVerbs are the seat's verbs, with the controller's arguments so a caller changes only the seat it asks.
func artifactSweepVerbs() []Verb {
return []Verb{
{Name: "artifacts", Description: "Every artifact the mesh made that lives in its artifact store, each with its " +
"repository, digest, kind (image or archive) and state: kept, and why (a want names it, or one of the five " +
"newest builds of its module in the builder's log), eligible (made, kept for no reason, not yet let go), or " +
"unrecorded (in the store and named by nothing the mesh made: never removed). Reads only.",
Input: schema(map[string]string{
"repository": "only this repository's references, as <module>/<artifact> (the counts stay whole)",
"collected": "\"true\": list the collected references too (counted always)",
}, nil, "collected")},
{Name: "held", Description: "Whether the artifact store holds each reference given, asked with one HEAD each: " +
"held, gone (the store answered 404) or unknown with why. The builder asks it before it reuses a done key. " +
"Reads only.",
Input: listed(schema(map[string]string{
"references": "the artifacts' references, artifact-store://<module>/<artifact>/blobs/sha256:<hex>: a list, " +
"or one text separated by commas",
}, []string{"references"}), "references")},
{Name: "collect", Description: "The artifact store's sweep, now: what is eligible is let go of. Without confirm a " +
"dry run: what would be let go, nothing held or deleted. With confirm and why: every kept archive held first, " +
"then each eligible artifact let go of, oldest first, an index with the platform manifests no kept index " +
"names, each deletion recorded in the hand-act log. Bounded by most (500 by default, at most 5000) and 45 " +
"seconds; what is left is said. Never touches a digest the mesh did not make.",
Input: schema(map[string]string{
"confirm": "\"true\": let go of what is eligible (needs why); without it nothing is done",
"why": "with confirm: why, required and recorded",
"most": "let go of at most this many (500 by default, at most 5000)",
}, nil, "confirm")},
{Name: "mirrors", Description: "The bases the mesh copied into its artifact store, each kept (a kept build stood on " +
"it), eligible (only builds no longer kept stood on it) or unrecorded (no build the builder's log holds stood on " +
"it: never removed), by what the builder's log says each build stood on. Records nothing by hand.",
Input: schema(map[string]string{}, nil)},
{Name: "images", Description: "Every container image one machine's want and have name, each with the resources " +
"naming it: what the machine keeps when it prunes its images. Reads only.",
Input: schema(map[string]string{"node": "the machine's name"}, []string{"node"})},
}
}
-87
View File
@@ -1,30 +1,9 @@
package catalogue package catalogue
import ( import (
"fmt"
"strings" "strings"
) )
// What the mesh built, named by what it is rather than by where it was pushed.
//
// **An image is recorded by its digest and its path; the registry's address is a route to it**
// (novox/hq 04-ISSUES/102). A build used to be recorded as `<registry>:<port>/<module>/<artifact>@sha256:…`
// — the reference the builder pushed to, kept whole — and every declaration carried that literal.
// Move the registry's port, or the registry, and every fresh pull of a mesh image fails: a new
// node, a recreate after eviction. The digest is the identity; the address is the node's setting
// for the module that serves the artifact store, and it is read from there when a reference is
// composed, never written into a record.
//
// So a kept reference has a scheme of the mesh's own, named after the provision that answers it:
//
// artifact-store://<module>/<artifact>@sha256:<hex> an image
// artifact-store://<module>/<artifact>/blobs/sha256:<hex> an archive
//
// No runtime knows the scheme. That is the point of it being one: a reference that leaks to a
// machine uncomposed is refused by the runtime as malformed, in front of whoever sent it, rather
// than pulled from a public registry that happens to have a repository by that name — which is
// what an address-less `<module>/<artifact>@sha256:…` would be.
// ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without // ArtifactStoreScheme marks a reference to something in the mesh's artifact store, kept without
// the store's address. // the store's address.
const ArtifactStoreScheme = ArtifactStoreProvision + "://" const ArtifactStoreScheme = ArtifactStoreProvision + "://"
@@ -77,69 +56,3 @@ func Routed(reference, address string) string {
} }
return address + "/" + path return address + "/" + path
} }
// Rerouted is a reference the mesh recorded, whichever way it was recorded, as a machine fetches
// it now: a kept one composed with the store's address, and one recorded before references were
// kept without their address — the builder's own `<host>/<path>@sha256:…` — re-routed to where
// the store is now. Only for references that are the mesh's own: everything a build record
// holds is, by construction.
func Rerouted(reference, address string) string {
return Routed(Recorded(reference), address)
}
// artifactsInto composes the artifact store's address into a resource's `image` and `source`.
//
// **Composed here, at the last moment before a machine, and stored nowhere.** A kept reference is
// routed through the store as this network reaches it now. A reference recorded with an address
// before references were kept without one is re-routed the same way — but only when the mesh
// built it (`with.Built` names every `<module>/<artifact>` it has), because a module may run an
// image from a public registry under its own name and that one is exactly where it says.
//
// A kept reference with no store to route it through is refused: sent as it is, the runtime would
// refuse the scheme on the machine, one push away from the reason.
//
// **What this does not reach: the images genesis pinned.** The installer builds the control plane
// and the builder before the mesh exists, pushes them itself and pins their manifests to
// `<registry>:<port>/mesh-controller@…` and `<registry>:<port>/mesh-builder@…` — single-segment
// repositories with no build record, so `with.Built` does not name them and they are left as
// written until each is rebuilt through the mesh, which records it by digest and path. Until then
// a registry that moves strands exactly those two on a recreate, and the control plane's is the
// one that cannot be repaired through the mesh. Rebuild both through `build` before moving the
// store (novox/hq 04-ISSUES/102, finding F4).
func artifactsInto(resource map[string]any, module string, with Rendering) error {
for _, key := range []string{"image", "source"} {
written, ok := resource[key].(string)
if !ok {
continue
}
if _, kept := InArtifactStore(written); kept {
if with.ArtifactStore == "" {
return fmt.Errorf(
"%s's %v names %s, which is in the mesh's artifact store, and this mesh has no "+
"artifact store on its network to fetch it from — nothing assigned offers "+
"%s, or the machine offering it is not on the private network",
module, resource["id"], written, ArtifactStoreProvision)
}
resource[key] = Routed(written, with.ArtifactStore)
continue
}
if with.ArtifactStore == "" {
continue
}
recorded := Recorded(written)
if recorded == written {
continue
}
path, _ := InArtifactStore(recorded)
repository := path
if at := strings.IndexAny(path, "@"); at >= 0 {
repository = path[:at]
} else if blobs := strings.Index(path, "/blobs/"); blobs >= 0 {
repository = path[:blobs]
}
if with.Built[repository] {
resource[key] = Routed(recorded, with.ArtifactStore)
}
}
return nil
}
-108
View File
@@ -27,111 +27,3 @@ func TestAReferenceIsRecordedWithoutTheStoresAddress(t *testing.T) {
} }
} }
} }
func TestARecordedReferenceIsRoutedThroughTheStoreAsItIsNow(t *testing.T) {
if got := Routed(ArtifactStoreScheme+"gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest {
t.Errorf("an image is fetched as %q", got)
}
if got := Routed(ArtifactStoreScheme+"gitea/config/blobs/"+digest, "anchor.internal:5101"); got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
t.Errorf("an archive is fetched as %q", got)
}
if got := Routed("gitea/gitea@"+digest, "anchor.internal:5101"); got != "gitea/gitea@"+digest {
t.Errorf("a reference that is not the store's was routed: %q", got)
}
// One recorded before references were kept without their address follows the store too.
if got := Rerouted("anchor.internal:5100/gitea/server@"+digest, "anchor.internal:5101"); got != "anchor.internal:5101/gitea/server@"+digest {
t.Errorf("a reference recorded with the old address stays there: %q", got)
}
}
// **The address is composed into a declaration, and the record never carries it.**
func TestAnImageTheMeshBuiltIsRoutedThroughTheStoreWhenDeclared(t *testing.T) {
m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "mesh-gitea", "artifact": "server"},
{"id": "config", "type": "archive", "path": "/etc/gitea", "artifact": "config"},
{"id": "cache", "type": "container", "name": "mesh-gitea-cache", "image": "valkey/valkey@" + digest},
}, Build: &Build{Artifacts: []Artifact{
{Name: "server", Kind: ArtifactImage, From: "Dockerfile"},
{Name: "config", Kind: ArtifactArchive, From: "config"},
}}}
resolved, err := m.Resolve([]Built{
{Name: "server", Kind: ArtifactImage, Reference: ArtifactStoreScheme + "gitea/server@" + digest},
{Name: "config", Kind: ArtifactArchive, Reference: ArtifactStoreScheme + "gitea/config/blobs/" + digest, Digest: digest},
})
if err != nil {
t.Fatal(err)
}
r := Resolution{Node: "anchor", Modules: []Manifest{resolved}}
out, err := r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101"})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "gitea.server")["image"]; got != "anchor.internal:5101/gitea/server@"+digest {
t.Errorf("the image the mesh built is fetched as %v", got)
}
if got := fileNamed(out, "gitea.config")["source"]; got != "http://anchor.internal:5101/v2/gitea/config/blobs/"+digest {
t.Errorf("the archive the mesh built is fetched from %v", got)
}
if got := fileNamed(out, "gitea.cache")["image"]; got != "valkey/valkey@"+digest {
t.Errorf("an image from a public registry was routed through the store: %v", got)
}
// The manifest the mesh holds still says what it is, not where it was fetched from.
if got := resolved.Resources[0]["image"]; got != ArtifactStoreScheme+"gitea/server@"+digest {
t.Errorf("composing wrote the address into the catalogue's copy: %v", got)
}
// And the store moves: the same record, another address, without a rebuild.
out, err = r.Declaration(Rendering{ArtifactStore: "laptop.internal:5000"})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "gitea.server")["image"]; got != "laptop.internal:5000/gitea/server@"+digest {
t.Errorf("after the store moved, the image is still fetched as %v", got)
}
}
func TestAnImageInTheStoreWithNoStoreToFetchItFromIsRefused(t *testing.T) {
m := Manifest{Module: "gitea", Version: "1", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "mesh-gitea",
"image": ArtifactStoreScheme + "gitea/server@" + digest},
}}
_, err := Resolution{Node: "anchor", Modules: []Manifest{m}}.Declaration(Rendering{})
if err == nil || !strings.Contains(err.Error(), "no artifact store") {
t.Fatalf("a reference nothing can fetch was sent to a machine: %v", err)
}
}
// **A reference recorded with an address before this follows the store too** — when the mesh
// built it. A module running an image straight from a public registry under its own name is left
// exactly where it says: `quay.io/keycloak/keycloak` is not the mesh's, whatever it is called.
func TestAReferenceRecordedWithAnAddressFollowsTheStoreWhenTheMeshBuiltIt(t *testing.T) {
m := Manifest{Module: "keycloak", Version: "1", Resources: []map[string]any{
{"id": "server", "type": "container", "name": "mesh-keycloak",
"image": "anchor.internal:5100/keycloak/server@" + digest},
{"id": "upstream", "type": "container", "name": "mesh-keycloak-upstream",
"image": "quay.io/keycloak/keycloak@" + digest},
}}
r := Resolution{Node: "anchor", Modules: []Manifest{m}}
out, err := r.Declaration(Rendering{
ArtifactStore: "anchor.internal:5101",
Built: map[string]bool{"keycloak/server": true},
})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5101/keycloak/server@"+digest {
t.Errorf("an image the mesh built, recorded with the old address, is fetched as %v", got)
}
if got := fileNamed(out, "keycloak.upstream")["image"]; got != "quay.io/keycloak/keycloak@"+digest {
t.Errorf("a public image was re-routed through the store: %v", got)
}
// Nothing known to be built: nothing re-routed, nothing refused.
out, err = r.Declaration(Rendering{ArtifactStore: "anchor.internal:5101"})
if err != nil {
t.Fatal(err)
}
if got := fileNamed(out, "keycloak.server")["image"]; got != "anchor.internal:5100/keycloak/server@"+digest {
t.Errorf("with no build record, a reference was rewritten: %v", got)
}
}
-49
View File
@@ -1,53 +1,4 @@
package catalogue package catalogue
import (
"fmt"
"regexp"
)
// What the machine's modules back up is placement (design 57 L3, issue 794). The holder of node-backup names it as
// `${backup:lines}` (what to run and which paths to keep) and `${backup:data}` (every item with its class and
// protection): the text `${contribution:node-backup:backup}` and `${contribution:node-backup:data}` render, from the
// same function, derived from each placed module's declared data (novox/hq ADR 0233). A handed holder has the text in
// its plan's placement for its client to fill; one not handed has the controller fill it where it stands.
// BackupNamespace is the placeholder kind of the machine's backup lines and data. // BackupNamespace is the placeholder kind of the machine's backup lines and data.
const BackupNamespace = "backup" const BackupNamespace = "backup"
// ofBackup is where a holder places them: ${backup:<key>}. Loose inside the braces, so a misspelt key is found and
// refused rather than written out as text.
var ofBackup = regexp.MustCompile(`\$\{backup:([^}]*)\}`)
// backupKinds is the contribution kind each key of the backup placement renders.
var backupKinds = map[string]string{"lines": BackupKindBackup, "data": BackupKindData}
// backupPlacer is the machine's modules the backup placement is composed over, as the old contributions are.
type backupPlacer struct {
modules []Manifest
with Rendering
facts map[string]string
caps map[string]bool
unplaced *[]string
}
// placed is the text of one key for a holder, or what is wrong with naming it.
func (b backupPlacer) placed(holder Manifest, key string, item bool) (string, string) {
kind, known := backupKinds[key]
switch {
case item:
return "", "an item is kept per item; the machine's backup lines are placed only in a file"
case !known:
return "", fmt.Sprintf("the backup placement has `lines` and `data`, not %q", key)
case !holder.ClaimsSeat(BackupSeat):
return "", fmt.Sprintf("%s does not claim %s; the machine's backup lines are placed by its holder alone "+
"(novox/hq ADR 0212)", holder.Module, BackupSeat)
}
text, left, err := seatContributions(b.modules, holder, BackupSeat+":"+kind, b.with, b.facts, b.caps)
if b.unplaced != nil {
*b.unplaced = append(*b.unplaced, left...)
}
if err != nil {
return "", err.Error()
}
return text, ""
}

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