Compare commits

...
Author SHA1 Message Date
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
245 changed files with 4862 additions and 11891 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ const KeptBucket = "mesh-controller_states-announced"
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
// its buckets' names (catalogue.StateProblems).
// its buckets' names (the indexer's judge, novox/hq ADR 0578 §1).
func Key(module string) (string, error) {
if !name.MatchString(module) {
return "", fmt.Errorf("announce: %q is not a module that keeps state: lower-case letters, digits and hyphens", module)
+46 -13
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"path"
"slices"
"sort"
@@ -24,7 +25,7 @@ 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
// Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). 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
@@ -42,14 +43,30 @@ type AskSteps struct {
// 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)
job := *ask.Build
job.Fetch = withPackages(job.Fetch, ask.Packages)
return steps.build(ctx, job)
}
if ask.Check != nil {
return steps.check(ctx, *ask.Check)
job := *ask.Check
job.Fetch = withPackages(job.Fetch, ask.Packages)
return steps.check(ctx, job)
}
return nil, Refuse("the ask %s holds no job", ask.ID)
}
// withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module
// writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that
// fetches nothing, is left as it is.
func withPackages(fetch map[string]any, packages delivery.PackageVersions) map[string]any {
if len(packages) == 0 || fetch == nil {
return fetch
}
out := maps.Clone(fetch)
out["packages"] = maps.Clone(packages)
return out
}
func raw(v any) (json.RawMessage, error) {
if r, ok := v.(json.RawMessage); ok {
return r, nil
@@ -90,19 +107,32 @@ func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessa
return r, f, nil
}
// onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when
// either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and
// sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2).
func (s AskSteps) onToolchain(ctx context.Context, want *delivery.ToolchainUsed) (*delivery.ToolchainUsed, error) {
if want == nil || s.Toolchain == nil {
return nil, nil
}
tc, err := s.Toolchain(ctx)
if err != nil {
return nil, err
}
if tc.Version != want.Version || tc.Recipe != want.Recipe {
return nil, &Declining{Lacks: []string{fmt.Sprintf("the agreed toolchain %s (recipe %s)", want.Version, want.Recipe)},
Text: fmt.Sprintf("the ask is for %s (recipe %s) and this node builds with %s (recipe %s): it sits out, and the ask is left to a node on the agreed toolchain",
want.Version, want.Recipe, tc.Version, tc.Recipe)}
}
return &tc, 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
tc, err := s.onToolchain(ctx, job.Toolchain)
if err != nil {
return nil, err
}
answer.Toolchain = tc
var f fetched
defer func() { s.release(f.Workspace) }()
for _, a := range job.Artifacts {
@@ -164,6 +194,9 @@ func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error)
}
func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) {
if _, err := s.onToolchain(ctx, job.Toolchain); err != nil {
return nil, err
}
r, f, err := s.fetch(ctx, job.Fetch)
if err != nil {
return nil, err
+21 -9
View File
@@ -74,15 +74,21 @@ func TestRunAskBuilds(t *testing.T) {
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,
// Issue 1585: the ask's package versions reach the fetch, and the ask's own fetch arguments stay as they were.
a := 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"}}}))
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}})
a.Packages = delivery.PackageVersions{"git.novox.be/novox/mesh-sdk/go": runCommit}
out, err := RunAsk(context.Background(), steps, a)
if err != nil {
t.Fatal(err)
}
if _, put := a.Build.Fetch["packages"]; put {
t.Error("the ask's fetch arguments were changed")
}
got := strings.Join(r.calls, "\n")
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
fetch {"commit":"` + runCommit + `","repository":"novox/x"}
fetch {"commit":"` + runCommit + `","packages":{"git.novox.be/novox/mesh-sdk/go":"` + 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 {
@@ -91,13 +97,13 @@ build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
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)
answer := out.(delivery.BuildAnswer)
if len(answer.Artifacts) != 3 || answer.Artifacts[1].Reference != "artifact-store://bundle" {
t.Fatalf("answer %+v", answer)
}
}
// A build ask for another toolchain is refused, so its step ends refused and the asker reads why.
// A build ask for another toolchain is declined (ADR 0584 clause 2), so the node sits out and the asker puts it again.
func TestRunAskRefusesAnotherToolchain(t *testing.T) {
r := &recorder{fetch: map[string]any{"workspace": "/w"}}
s := r.steps()
@@ -106,10 +112,16 @@ func TestRunAskRefusesAnotherToolchain(t *testing.T) {
}
_, 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 {
var dec *Declining
if !errors.As(err, &dec) || len(dec.Lacks) != 1 || len(r.calls) != 0 {
t.Fatalf("err %v, calls %v", err, r.calls)
}
// A check ask names the toolchain too (ADR 0584), and a node on another declines it before it fetches.
_, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}, Levels: []string{delivery.LevelUnit},
Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
if !errors.As(err, &dec) || len(r.calls) != 0 {
t.Fatalf("a check ask: 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
+27
View File
@@ -538,6 +538,28 @@ func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) {
p.mu.Unlock()
}
// declineRun ends the step of an ask that began and then found this node should not run it (Declining), as decline does
// before it begins: refused as declined, the message dropped for its asker to put again, and the worker it came from left
// alone until the next pull's wait.
func (p *Puller) declineRun(msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step, d *Declining) {
result, _ := json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: d.Lacks})
step.Ended = now().UTC()
step.State, step.Reason, step.Result = delivery.StepRefused, d.Text, result
if err := writeEnd(p.Put, key, step); err != nil {
p.logf("the ask %s cannot be declined on %s, so it is given back: %v", ask.ID, p.Node, err)
_ = msg.Nak()
return
}
p.logf("the ask %s is declined on %s: %s", ask.ID, p.Node, d.Text)
_ = msg.Term()
p.mu.Lock()
if p.declined == nil {
p.declined = map[string]bool{}
}
p.declined[msg.Kind] = true
p.mu.Unlock()
}
func withKind(msg *Pulled, kind string) *Pulled {
if msg != nil {
msg.Kind = kind
@@ -625,6 +647,11 @@ func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key
})
step = ph.end() // its end keeps the phase it ended in
close(done)
var dec *Declining
if errors.As(err, &dec) && ctx.Err() == nil {
p.declineRun(msg, ask, key, step, dec)
return
}
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
+15
View File
@@ -230,6 +230,21 @@ func TestPullDropsWhatIsNoAsk(t *testing.T) {
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
go q.Pull(ctx)
waitFor(t, "the placed ask acked where its need is met", func() bool { a, _, _ := has.Fates(); return len(a) == 1 })
// A run that finds its node is not on the agreed toolchain declines the ask after it began (ADR 0584 clause 2); a node on it builds.
sit, sitSteps := NewFakeAsks(), newSteps()
toolchain := testAsk(delivery.AskDelivery)
_ = sit.PublishAsk(toolchain)
s2 := &Puller{Source: sit, Language: delivery.AskGo, Node: "ace", Put: sitSteps.put, Capacity: func() int { return 1 }, PullWait: 10 * time.Millisecond,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
return nil, &Declining{Text: "the ask is for 26.13.0 and this node builds with 26.12.1", Lacks: []string{"the agreed toolchain 26.13.0 (recipe 3)"}}
}}
go s2.Pull(ctx)
waitFor(t, "the ask termed on the node that sits out", func() bool { _, tm, _ := sit.Fates(); return len(tm) == 1 })
sk, _ := delivery.AskStepKey("ace", toolchain.ID)
ss, _ := sitSteps.get(sk)
if d, ok := delivery.DeclinedOf(ss); !ok || len(d.Lacks) != 1 || !strings.Contains(ss.Reason, "26.12.1") {
t.Fatalf("the sitting-out step = %+v", ss)
}
}
// load reads the recorded steps state's raw values: the pause key is not a step, so it is kept apart.
+11
View File
@@ -16,3 +16,14 @@ 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...)} }
// Declining is a step a node does not run because another node should: it ends refused as declined (delivery.StepDeclined),
// the ask's message is dropped, and the asker puts the ask again under a new id (issue 890). A build ask naming a
// toolchain other than this node's is one (novox/hq ADR 0584 clause 2): a node that is not in the toolchain majority
// sits out and never fails a build.
type Declining struct {
Text string
Lacks []string // what this node lacks, in words the operator reads
}
func (d *Declining) Error() string { return d.Text }
+48
View File
@@ -2,9 +2,12 @@ package buildstep
import (
"fmt"
"maps"
"path"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
var (
@@ -27,6 +30,10 @@ type FetchRequest struct {
Base string `json:"base,omitempty"`
Dir string `json:"dir,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.
@@ -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"},
"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"},
"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 {
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
}
@@ -78,9 +93,42 @@ func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
default:
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()
}
// 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.
func (r FetchRequest) Check() error {
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
// clause 1). The request survives the trip through a tool call's arguments.
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",
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()
// 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"}
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)
if err != nil {
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": "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{"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{}
for k, v := range ok {
+10 -4
View File
@@ -25,6 +25,8 @@ type ModuleTree struct {
// 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"`
// Packages are the mesh packages the directory's go.sum names (delivery.MeshPackagesOf, novox/hq ADR 0573 §2).
Packages []string `json:"packages,omitempty"`
}
var lockFiles = []string{"go.sum", "package-lock.json"}
@@ -67,6 +69,9 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
mt.Locks = map[string]string{}
}
mt.Locks[lock] = sha256Hex(raw)
if lock == "go.sum" {
mt.Packages = delivery.MeshPackagesOf(raw) // the mesh packages it uses (novox/hq ADR 0573 §2)
}
}
}
trees[mdir] = mt
@@ -75,11 +80,12 @@ func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, co
}
// 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.
// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview decodes the manifest here
// (catalogue.Decode: a build step judges no manifest, novox/hq ADR 0578 §3), and no other build step reads paths from one
// (ADR 0495 clause 1). A manifest that does not decode 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)
m, err := catalogue.Decode(raw)
if err != nil {
return nil
}
+5 -8
View File
@@ -75,20 +75,17 @@ func TestModuleTreesFailsNamingANamedPathAbsentAtTheCommit(t *testing.T) {
}
}
// 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).
// A manifest whose config value falls back to a predefined key is narrowed here: the merge preview decodes it and judges
// nothing (novox/hq ADR 0578 §3), so a build module holding no published list computes the tree the indexer does (ADR
// 0499 clause 2; issue 1751).
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)
if _, err := catalogue.Decode([]byte(raw)); err != nil {
t.Fatalf("the preview'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 {
+5 -3
View File
@@ -33,7 +33,8 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
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")
asum := "git.novox.be/novox/mesh-sdk/go v0.1.7 h1:x=\ngit.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:y=\ngithub.com/nats-io/nats.go v1.0.0 h1:z=\n"
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)
@@ -55,8 +56,9 @@ func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T)
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")}},
".": {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")},
Packages: []string{"git.novox.be/novox/mesh-sdk/go"}}, // issue 1585: the mesh packages its go.sum names
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
}
if !reflect.DeepEqual(got, want) {
+4
View File
@@ -219,6 +219,10 @@ type LeaseHolder struct {
// HandedBy is the credential that wrote it (the operator's), and Why its reason.
HandedBy string `json:"handed-by,omitempty"`
Why string `json:"why,omitempty"`
// Ask is the operator's ask whose warrant handed it (novox/hq ADR 0582 clause 3: "with the why, the ask and the
// time"), and At when it was written. Neither turns the lease: LeaseHandedAway reads HandedTo alone.
Ask string `json:"ask,omitempty"`
At time.Time `json:"at,omitzero"`
}
// LeaseHandedAway reads the lease key as a getter answered it (its value, whether it was there, the read's error) and
+234 -7
View File
@@ -3,6 +3,7 @@ package buscompose
import (
"encoding/json"
"errors"
"maps"
"os"
"path/filepath"
"reflect"
@@ -96,6 +97,9 @@ func engineBeyond(user, g string) bool {
strings.Contains(g, "KV_"+EngineActsBucket) || strings.HasPrefix(g, "$KV."+EngineActsBucket+"."+node+".") ||
// And its node's declared packages (novox/hq ADR 0565 clause 6).
strings.Contains(g, "KV_"+EnginePackagesBucket) || g == "$KV."+EnginePackagesBucket+"."+node ||
// And, read, the controller's mirror's key of its node, until the lease write (novox/hq ADR 0565 clause 6).
strings.Contains(g, "KV_"+DeclaredPackagesBucket) && !strings.HasPrefix(g, "$KV.") &&
(!strings.Contains(g, "$KV.") || strings.HasSuffix(g, "$KV."+DeclaredPackagesBucket+"."+node)) ||
// And its own health report (issue 774).
strings.Contains(g, "KV_"+delivery.NodeHealthBucket) || g == "$KV."+delivery.NodeHealthBucket+"."+node ||
// And the provisions of its node's modules, read (novox/hq ADR 0570 clause 6).
@@ -158,8 +162,11 @@ func grantsAsTheControllers(t *testing.T, got, want string) {
intended := beyondTheControllers(user)
for g := range was {
// A runtime's direct get of its membership from the assignments stream went with the stream (research 072
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7).
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+strings.SplitN(user, ".", 2)[0]+".") {
// row 33): it reads the grants part's state memberships instead (ADR 0571 clause 7). And it no longer hears
// the subject, where the grants part stops publishing at the lease write (ADR 0571 clause 10, issue 774).
node := strings.SplitN(user, ".", 2)[0]
if !now[g] && !strings.HasPrefix(g, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+node+".") &&
!strings.HasPrefix(g, "mesh.assignment."+node+".") {
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
}
}
@@ -413,6 +420,156 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
}
}
})
// 774/S39 (ADR 0583 §1): while a node rotates, both its logins are users with the node's permissions and its one
// inbox, each calling in its own name; every node reads its own node-logins key alone; with no rotation the list
// is the one above (AC2: the controller's golden lists, unchanged).
t.Run("a node rotating has two logins of one node", func(t *testing.T) {
vault := Seat{Name: VaultSeat, Serves: []string{"rotate-node", "node-account-confirm", "sign", "own"}}
since := time.Date(2026, 11, 2, 1, 0, 0, 0, time.UTC)
users, err := Users(Records{Nodes: []string{"ace", "g14"},
Assigned: map[string][]Declared{"ace": {{Module: "mesh-vault", Holds: []Seat{vault}}}},
NodeLogins: map[string]delivery.NodeLogin{
"g14": {Node: "g14", Login: "node.g14", Rotating: "node.g14--b", Stage: delivery.RotationBegun, Since: since},
// A node that rotated before and rotates no more logs in as its second login alone.
"ace": {Node: "ace", Login: "node.ace--b", Since: since}}})
if err != nil {
t.Fatal(err)
}
perms := map[string]Permissions{}
for _, u := range users {
if u.Kind == KindNode {
p, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
perms[u.Username()] = p
}
}
if len(perms) != 3 || perms["node.g14"].Publish == nil || perms["node.g14--b"].Publish == nil || perms["node.ace--b"].Publish == nil {
t.Fatalf("the node users are %v", slices.Sorted(maps.Keys(perms)))
}
// Equal, but for the caller each names in its calls (the bus's proof of which login called, ADR 0583 §3).
own := func(p Permissions, login string) Permissions {
swap := func(in []string) []string {
out := make([]string, len(in))
for i, s := range in {
out[i] = strings.ReplaceAll(s, CallerToken(login), "<caller>")
}
return out
}
return Permissions{Publish: swap(p.Publish), PublishDeny: swap(p.PublishDeny), Subscribe: swap(p.Subscribe),
AllowResponses: p.AllowResponses}
}
if a, b := own(perms["node.g14"], "node.g14"), own(perms["node.g14--b"], "node.g14--b"); !reflect.DeepEqual(a, b) {
t.Errorf("the two logins' permissions differ:\n%v\n%v", a, b)
}
for login, p := range perms {
node, _ := delivery.LoginNode(login)
if !slices.Contains(p.Subscribe, "_INBOX.node."+node+".>") || slices.Contains(p.Subscribe, "_INBOX."+login+".>") && login != "node."+node {
t.Errorf("%s's inbox: %v", login, p.Subscribe)
}
}
// AC3: g14 reads node-logins key g14 and not ace, and writes neither; it confirms in its own login's name.
get := func(key string) string {
return "$JS.API.DIRECT.GET.KV_" + delivery.NodeLoginsBucket + ".$KV." + delivery.NodeLoginsBucket + "." + key
}
confirm := func(login string) string {
return "mesh.seat.mesh-vault.call.node-account-confirm." + CallerToken(login)
}
for subject, want := range map[string]bool{get("g14"): true, get("ace"): false, "$KV." + delivery.NodeLoginsBucket + ".g14": false,
confirm("node.g14--b"): true, confirm("node.g14"): false} {
if MayPublish(perms["node.g14--b"], subject) != want {
t.Errorf("node.g14--b may publish %s: %v, want %v", subject, !want, want)
}
}
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "g14"}); MayPublish(plain, get("g14")) {
t.Error("a node reads node-logins while the vault does not rotate")
}
// And nothing but the grants part names node-logins among its reads.
if !ReaderMay(GrantsModule, delivery.NodeLoginsRead) || ReaderMay("shop", delivery.NodeLoginsRead) {
t.Error("node-logins' named readers")
}
})
// 774/S40/AC1 (ADR 0587 §4, §6): from the lease write an enrolling node's user may publish the redeem call in its
// own name and nothing else, and subscribe its own inbox alone; before it, the controller's subject beside.
t.Run("an enrolling node redeems at the seat alone", func(t *testing.T) {
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
for _, handed := range []bool{true, false} {
users, err := Users(Records{Nodes: []string{"ace"}, Enrolling: []string{"lab"}, LeaseHandedAway: handed,
Assigned: map[string][]Declared{"ace": {{Module: "mesh-enrolment", Holds: []Seat{holder}}}}})
if err != nil {
t.Fatal(err)
}
var lab *Principal
for i := range users {
if users[i].Username() == "enrol.lab" {
lab = &users[i]
}
}
if lab == nil {
t.Fatal("no user enrol.lab")
}
perms, err := PermissionsFor(*lab)
if err != nil {
t.Fatal(err)
}
want := []string{"mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
if !handed {
want = []string{"mesh.control.enrol", "mesh.seat.mesh-enrolment.call.redeem.enrol~lab"}
}
if !reflect.DeepEqual(perms.Publish, want) || !reflect.DeepEqual(perms.Subscribe, []string{"_INBOX.enrol.lab.>"}) ||
perms.AllowResponses {
t.Errorf("handed %v: enrol.lab publishes %v, subscribes %v, answers %v", handed, perms.Publish, perms.Subscribe,
perms.AllowResponses)
}
}
// No holder serving redeem and the lease handed: the user may enrol nowhere, refused by name.
if _, err := PermissionsFor(Principal{Kind: KindEnrolment, Node: "lab", LeaseHandedAway: true}); err == nil {
t.Error("an enrolment user that may enrol nowhere was composed")
}
})
// 774/S41/AC1 (ADR 0587 §1-§3, ADR 0561 §5): mesh-enrolment writes its bucket (with per-key expiry) and calls the
// vault's enrolment-token, node-account and sign in its own name; no other module or runtime may call those two,
// however wide its grant; and its bucket is read by the grants part alone besides it.
t.Run("mesh-enrolment alone asks the vault to enrol", func(t *testing.T) {
vault := Seat{Name: VaultSeat, Serves: []string{"sign", "enrolment-token", "node-account"}}
holder := Seat{Name: catalogue.EnrolmentSeat, Serves: []string{"issue", "redeem", "confirm", "revoke", "tokens", "again"}}
calls := []string{"seat:mesh-vault.enrolment-token", "seat:mesh-vault.node-account", "seat:mesh-vault.sign"}
users, err := Users(Records{Nodes: []string{"ace"}, Assigned: map[string][]Declared{"ace": {
{Module: "mesh-vault", Holds: []Seat{vault}},
// One process with a bus account of its own (ADR 0587 §1): never carried by the tool runner.
{Module: "mesh-enrolment", RunsAs: "mesh-enrolment", Holds: []Seat{holder}, Invokes: calls,
State: []Bucket{{Module: "mesh-enrolment", Name: "enrolments", KeyTTL: true}}},
{Module: "shop", Invokes: calls[:2]},
{Module: RuntimeModule}, {Module: "agent-tools", Invokes: []string{"*"}}}}})
if err != nil {
t.Fatal(err)
}
call := func(verb, user string) string { return "mesh.seat.mesh-vault.call." + verb + "." + CallerToken(user) }
for _, u := range users {
perms, err := PermissionsFor(u)
if err != nil {
t.Fatal(err)
}
name := u.Username()
for _, verb := range []string{"enrolment-token", "node-account"} {
enrols := name == "ace.mesh-enrolment"
if MayPublish(perms, call(verb, name)) != enrols || (!enrols && MayPublish(perms, "mesh.seat.mesh-vault.tool."+verb)) {
t.Errorf("%s may call %s: %v", name, verb, !enrols)
}
}
if name == "ace.mesh-enrolment" && (!MayPublish(perms, call("sign", name)) ||
!MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") || !MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".address.10.0.0.7")) {
t.Errorf("mesh-enrolment does not sign or write its bucket: %v", perms.Publish)
}
if name != "ace.mesh-enrolment" && MayPublish(perms, "$KV."+delivery.EnrolmentsBucket+".node.lab") {
t.Errorf("%s writes the enrolments", name)
}
}
if !ReaderMay(GrantsModule, delivery.EnrolmentsRead) || ReaderMay("shop", delivery.EnrolmentsRead) {
t.Error("the enrolments' named readers")
}
})
t.Run("the controller's golden server configuration", func(t *testing.T) {
seat := Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
got, err := Compose(
@@ -558,6 +715,9 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
}{
{"a controller holds it", `{"instance":"novox/1234/2026-10-27T20:00:00Z","epoch":57}`, true, nil, false},
{"handed away", `{"handed-to":"the operator's hand-over","handed-by":"person.jochen"}`, true, nil, true},
// ADR 0582 clause 3 (774/S3): the write names the ask and the time, which turn nothing.
{"handed to mesh-grants with its ask and time", `{"handed-to":"mesh-grants","handed-by":"person.jochen","why":"w",` +
`"ask":"lease-write-1","at":"2026-11-02T10:00:00Z"}`, true, nil, true},
{"expired or deleted", ``, false, nil, false},
{"unreadable", ``, false, errors.New("refused"), false},
{"not a holder", `[1]`, true, nil, false},
@@ -633,6 +793,48 @@ func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
t.Errorf("the grants part creates consumers as %v", u.CreatesConsumers)
}
}
// ADR 0582 clause 3 (issue 774, 774/S2): the lease write's cut takes from the controller the lease's key and every
// update, purge and delete of its bucket, and gives the grants part the key holder alone; without it nothing
// changes (the golden lists above are composed without it).
lease := "$KV." + LeaseBucket + ".holder"
for _, cut := range []bool{false, true} {
users, err := Users(Records{Nodes: []string{"a"}, Assigned: map[string][]Declared{"a": {{Module: GrantsModule}}},
LeaseCut: cut})
if err != nil {
t.Fatal(err)
}
for _, c := range []struct {
kind Kind
subject string
may bool
}{
{KindController, lease, !cut},
{KindController, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, !cut},
{KindController, "$JS.API.STREAM.PURGE.KV_" + LeaseBucket, !cut},
{KindController, "$JS.API.STREAM.DELETE.KV_" + LeaseBucket, !cut},
{KindController, "$JS.API.STREAM.MSG.DELETE.KV_" + LeaseBucket, !cut},
// It still reads the lease, and keeps its other buckets.
{KindController, "$JS.API.DIRECT.GET.KV_" + LeaseBucket + ".$KV." + LeaseBucket + ".holder", true},
{KindController, "$KV." + CallsBucket + ".x", true},
{KindController, "$JS.API.STREAM.UPDATE.KV_" + CallsBucket, true},
{KindModule, lease, cut},
{KindModule, "$KV." + LeaseBucket + ".other", false},
{KindModule, "$JS.API.STREAM.UPDATE.KV_" + LeaseBucket, false},
} {
for _, u := range users {
if u.Kind != c.kind || (c.kind == KindModule && u.Module != GrantsModule) {
continue
}
perms, err := PermissionsFor(u)
if err != nil {
t.Fatalf("cut %v: %s: %v", cut, u.Username(), err)
}
if MayPublish(perms, c.subject) != c.may {
t.Errorf("cut %v: %s may publish %s: %v, want %v", cut, u.Username(), c.subject, !c.may, c.may)
}
}
}
}
// 1952 S5 (novox/hq ADR 0564 clauses 2, 5, 8; Limits 2, 4, 8): the health holders' credentials.
healthChecks(t)
q := ConsumerConfig(QueueConsumer(Principal{Kind: KindProcess, Node: "a", Module: "m", Process: "p"},
@@ -740,7 +942,9 @@ func healthChecks(t *testing.T) {
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$JS.API.CONSUMER.INFO.DEAD_LETTER_NOTICES.controller",
"$KV.mesh-host_acts.g14.a1", "$JS.API.STREAM.INFO.KV_mesh-host_acts",
// Research 072 row 33: the ASSIGNMENTS stream is read and removed at the lease write.
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
"$JS.API.STREAM.INFO.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS",
// ADR 0582 clause 3 (issue 774, 774/S2): the lease's bucket's expiry, read and set by its tool.
"$JS.API.STREAM.INFO.KV_mesh-controller_lease", "$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
}
mayNot := map[string][]string{
// The keeper writes no findings and no attempts; no observer writes the keeper's buckets (clause 2).
@@ -783,8 +987,13 @@ func healthChecks(t *testing.T) {
// outside the bus module).
"a.nats.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.other", "$JS.API.CONSUMER.DELETE.EVENTS.a_m",
"$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.KV_mesh-host_acts", "$JS.API.STREAM.PURGE.KV_mesh-host_acts",
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X"},
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
"$JS.API.STREAM.DELETE.EVENTS", "$JS.API.STREAM.PURGE.ASSIGNMENTS", "$JS.API.STREAM.DELETE.ASSIGNMENTS_X",
// It updates the lease's bucket alone, and neither purges nor deletes it nor writes its key (ADR 0582 clause 3).
"$JS.API.STREAM.UPDATE.KV_mesh-host_acts", "$JS.API.STREAM.UPDATE.KV_mesh-controller_calls", "$JS.API.STREAM.UPDATE.EVENTS",
"$JS.API.STREAM.PURGE.KV_mesh-controller_lease", "$JS.API.STREAM.DELETE.KV_mesh-controller_lease",
"$KV.mesh-controller_lease.holder"},
"a.nats.other": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1",
"$JS.API.STREAM.UPDATE.KV_mesh-controller_lease"},
"a.nats": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-host_acts.g14.a1"},
"a.shop.nats-tools": {"$JS.API.CONSUMER.DELETE.DEAD_LETTER_NOTICES.controller", "$KV.mesh-conditions_conditions.x", "$JS.API.STREAM.DELETE.ASSIGNMENTS"},
}
@@ -815,12 +1024,27 @@ func healthChecks(t *testing.T) {
MayPublish(perms, "$KV.mesh-host_health.b")) {
t.Errorf("the watchman reads the health reports %v, %v", MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-host_health.$KV.mesh-host_health.b"), perms.Publish)
}
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b")) {
// ADR 0565 clause 6: and it reads the controller's mirror's key of its own node alone, and writes none of it.
if u.Kind == KindNode && (!MayPublish(perms, "$KV.mesh-host_packages.a") || MayPublish(perms, "$KV.mesh-host_packages.b") ||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.a") ||
MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-controller_declared-packages.$KV.mesh-controller_declared-packages.b") ||
MayPublish(perms, "$KV.mesh-controller_declared-packages.a")) {
t.Errorf("node %s writes the packages keys %v", u.Node, perms.Publish)
}
if u.Kind == KindModule && MayPublish(perms, "$KV.mesh-delivery-assigner_policies.x") {
t.Errorf("%s writes the assigner's policies", u.Username())
}
// ADR 0571 clause 10 (issue 774, 774/S5/AC2): nobody hears memberships on mesh.assignment; they are read from
// the grants part's state.
if MaySubscribe(perms, "mesh.assignment.a.restic") {
t.Errorf("%s hears mesh.assignment", u.Username())
}
}
// Nor the runtime carrying the node's modules, which reads each one's membership from the state.
if perms, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "a", Module: RuntimeModule,
Carries: []Declared{{Module: "restic"}}}); err != nil || MaySubscribe(perms, "mesh.assignment.a.restic") ||
!MayPublish(perms, "$JS.API.DIRECT.GET.KV_mesh-grants_memberships.$KV.mesh-grants_memberships.a.restic") {
t.Errorf("the runtime hears mesh.assignment or reads no membership: %v", err)
}
// 1953 S-age (ADR 0565 §10): a log that says an age keeps its entries that long and no longer; one that says none, as
// long as the log (ADR 0297).
@@ -878,7 +1102,10 @@ func healthChecks(t *testing.T) {
// It hears its watchdogs' words on core subjects (Limits 3, 11).
for _, subject := range []string{"mesh.mod.power.event.sleeping", "mesh.control.g14.tools-alive",
"mesh.seat.mesh-self-check.event.checked", "mesh.mod.keycloak.event.provisioner.failing",
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak"} {
"mesh.mod.nats.event.bus.slow-consumer", "prov.applied.g14.keycloak",
// ADR 0582 clauses 4, 6 (issue 774, 774/S22): the lease write's events, and ASSIGNMENTS deleted.
"mesh.seat.mesh-grants.event.lease-altered", "mesh.seat.mesh-grants.event.removal-failed",
"mesh.seat.mesh-grants.event.lease-handed", "$JS.EVENT.ADVISORY.STREAM.DELETED.ASSIGNMENTS"} {
if !MaySubscribe(perms, subject) {
t.Errorf("the watchman may not hear %s", subject)
}
+3 -1
View File
@@ -74,9 +74,11 @@ func engineGrants(p Principal) (pub, sub []string) {
if own, err := NodeKeysGrants(EngineDataBucket, p.Node); err == nil {
pub = append(pub, own...)
}
// And its node's declared packages, its own key alone (ADR 0565 clause 6).
// And its node's declared packages, its own key alone (ADR 0565 clause 6); and, read, the controller's mirror's key
// of its node, which the node seat's verb packages compares it with until the lease write ends the mirror.
if safeSubject.MatchString(p.Node) {
pub = append(pub, EnginePackagesGrants(p.Node)...)
pub = append(pub, readGrants(DeclaredPackagesBucket, p.Node)...)
}
return append(pub, tp...), append(sub, ts...)
}
+10 -3
View File
@@ -42,7 +42,7 @@ var (
// write's step, clause 5); 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; the ASSIGNMENTS stream read and deleted whole (the lease
// write, research 072 row 33). A
// removal outside the set is refused, as the catalogue refuses it (catalogue.BusRemovals).
// removal outside the set is refused, as the indexer's judge refuses it in a manifest (novox/hq ADR 0578 §1).
func BusRemovalGrants(removes []string) ([]string, error) {
var out []string
for _, r := range removes {
@@ -56,7 +56,8 @@ func BusRemovalGrants(removes []string) ([]string, error) {
out = append(out, "$JS.API.STREAM.INFO."+AssignmentsStream, "$JS.API.STREAM.DELETE."+AssignmentsStream)
default:
return nil, fmt.Errorf("a bus account removes %q, which is not one of %s (novox/hq ADR 0564 Limits 4)", r,
strings.Join(catalogue.BusRemovals, ", "))
strings.Join([]string{catalogue.RemovesControllerDeadLetterConsumer, catalogue.RemovesEngineActs,
catalogue.RemovesAssignmentsStream}, ", "))
}
}
return out, nil
@@ -113,7 +114,7 @@ func WatchmanDeadLetterGrants(sources []string) []string {
// 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), and the dead letters kept. Never the bus's API audit
// 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 {
@@ -128,6 +129,12 @@ func WatchmanHears() []string {
"mesh.mod.nats.event.bus.connection-closed",
ProvAppliedSubjects,
deadLetterPrefix + ">",
// And the lease write's (ADR 0582 clauses 4, 6; issue 774): the seat mesh-grants' three events, and the stream
// ASSIGNMENTS deleted, which with the controller's consumer deleted clears a removal that failed.
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseHanded,
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventLeaseAltered,
"mesh.seat." + catalogue.GrantsSeat + ".event." + catalogue.GrantsEventRemovalFailed,
"$JS.EVENT.ADVISORY.STREAM.DELETED." + AssignmentsStream,
}
}
+41
View File
@@ -0,0 +1,41 @@
package buscompose
import "slices"
// **The lease write's grants** (novox/hq ADR 0582 clause 3, issue 774). Composed with Records.LeaseCut, the list takes
// from the controller its write of the lease's key and every update, purge and delete of the lease's bucket, and gives
// the grants part the key `holder`, which it creates naming itself and deletes at a hand-back (clause 5). Whatever the
// records, the bus module's tools account may read and update the lease's bucket, for the tool that removes and
// restores its expiry; no other bucket's.
// leaseStream is the lease bucket's stream.
var leaseStream = "KV_" + LeaseBucket
// LeaseHolderWrite is the grants part's write of the lease's one key, from the cut.
func LeaseHolderWrite() string { return "$KV." + LeaseBucket + "." + leaseKey }
// LeaseCutDenied are the controller's subjects of the lease's bucket that its `$JS.API.>` reaches and the cut refuses:
// the bucket's update (its expiry), purge and delete, and a message's delete (the key's last value).
func LeaseCutDenied() []string {
return []string{"$JS.API.STREAM.UPDATE." + leaseStream, "$JS.API.STREAM.PURGE." + leaseStream,
"$JS.API.STREAM.DELETE." + leaseStream, "$JS.API.STREAM.MSG.DELETE." + leaseStream}
}
// LeaseExpiryGrants are the bus module's tools account's read and update of the lease's bucket, for its expiry.
func LeaseExpiryGrants() []string {
return []string{"$JS.API.STREAM.INFO." + leaseStream, "$JS.API.STREAM.UPDATE." + leaseStream}
}
// writesTheLease is the controller holding the lease, or, from the cut, the grants part writing its key holder alone.
func writesTheLease(p Principal, pattern string) bool {
if p.Kind == KindController {
return true
}
return p.Kind == KindModule && p.Module == GrantsModule && p.LeaseCut && pattern == LeaseHolderWrite()
}
// setsLeaseExpiry is the bus module's tools account updating the lease's bucket: the writers table's one exemption from
// stream definitions being the controller's beside ASSIGNMENTS' removal (ADR 0582 clause 3).
func setsLeaseExpiry(p Principal, pattern string) bool {
return p.Kind == KindProcess && len(p.Removes) > 0 && slices.Contains(LeaseExpiryGrants(), pattern)
}
+1 -1
View File
@@ -108,7 +108,7 @@ func (s ModuleStream) Stream() Stream {
// 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 (catalogue.WorkQueueStreamProblems).
// 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).
+131 -10
View File
@@ -22,6 +22,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -201,6 +202,9 @@ type Principal struct {
// 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
// LeaseCut is Records.LeaseCut on the controller and the grants part (novox/hq ADR 0582 clause 3): the controller
// loses its write of the lease, and the grants part may write the key `holder`.
LeaseCut bool
// 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
@@ -250,6 +254,26 @@ type Principal struct {
// 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
@@ -300,6 +324,22 @@ func ControllerOnly() []string {
return out
}
// VerbsTheEnrolmentAsksTheVault are the vault's verbs the seat mesh-enrolment's holder alone calls (novox/hq ADR 0587
// §1, §3; ADR 0561 §5): a token and its hash made and dropped, a joining node's account minted and retired.
var VerbsTheEnrolmentAsksTheVault = []SeatVerb{{Seat: VaultSeat, Verb: delivery.EnrolmentTokenVerb},
{Seat: VaultSeat, Verb: delivery.NodeAccountVerb}}
// EnrolmentOnly are the subjects the holder of mesh-enrolment alone may publish, however wide another's grant: the
// vault's two enrolment verbs, by the tool's and the caller-named shape (Principal.VaultEnrols denies them).
func EnrolmentOnly() []string {
var out []string
for _, v := range VerbsTheEnrolmentAsksTheVault {
out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb, "mesh.seat."+v.Seat+".tool."+v.Verb+".>",
"mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+".>")
}
return out
}
// MayPublish says whether permissions let a principal publish one subject: an allow covers it and no deny does.
func MayPublish(perms Permissions, subject string) bool {
for _, d := range perms.PublishDeny {
@@ -377,6 +417,33 @@ var VaultOwn = SeatVerb{Seat: "mesh-vault", Verb: "own"}
// ADR 0550 §2); granted as Principal.AsksTheVaultToSign says.
var VaultSign = SeatVerb{Seat: "mesh-vault", Verb: delivery.SignVerb}
// VaultRotateNode is the vault's verb that begins a node's account rotation; while its holder serves it, every node
// principal reads its own node's key of node-logins (novox/hq ADR 0583 §1, §2). VaultNodeAccountConfirm is the verb a
// node-engine confirms a switch with, in its new login's name (§3).
var (
VaultRotateNode = SeatVerb{Seat: "mesh-vault", Verb: delivery.RotateNodeVerb}
VaultNodeAccountConfirm = SeatVerb{Seat: "mesh-vault", Verb: delivery.NodeAccountConfirmVerb}
)
// EnrolmentRedeem is the seat mesh-enrolment's verb a joining node-engine calls as enrol.<node> (novox/hq ADR 0587 §4,
// §6).
var EnrolmentRedeem = SeatVerb{Seat: catalogue.EnrolmentSeat, Verb: catalogue.EnrolmentVerbRedeem}
// EnrolmentRedeemCall is the one subject an enrolment user may publish once it enrols through the seat: redeem, in its
// own name.
func EnrolmentRedeemCall(p Principal) string {
return "mesh.seat." + EnrolmentRedeem.Seat + "." + CallKind + "." + EnrolmentRedeem.Verb + "." + CallerToken(p.Username())
}
// NodeLoginsReads is what a node's engine is granted of the vault's node-logins: its own node's key, read and watched,
// and no other (novox/hq ADR 0583 §1 insight).
func NodeLoginsReads(node string) []string {
if !safeSubject.MatchString(node) {
return nil
}
return readGrants(delivery.NodeLoginsBucket, node)
}
// VerbsTheControllerActsOnAWarrant are the other seat verbs the controller calls when the operator's warrant
// chooses them (novox/hq ADR 0259): a machine's service restarted, and a walk started or stopped through the
// controller's own `plans`, a machine's held builds sent by its own `push` and the builds held after a failed release
@@ -525,6 +592,9 @@ func (p Principal) Username() string {
// 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 KindController:
return "controller"
@@ -584,7 +654,14 @@ func AskAccessActSubject(node string) string { return "mesh.node." + node + ".as
// 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.
func (p Principal) inbox() string { return "_INBOX." + p.Username() + ".>" }
//
// 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() + ".>"
}
// Permissions is what a principal may publish and subscribe, and whether it may answer.
type Permissions struct {
@@ -668,6 +745,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
if err != nil {
return Permissions{}, err
}
// And the lease's bucket's expiry, read and set, by the bus module's tool the grants part calls at the lease
// write and its hand-back (novox/hq ADR 0582 clauses 3, 5): the one bucket whose stream it may update.
pub = append(pub, LeaseExpiryGrants()...)
sort.Strings(pub)
if err := CheckWriters(p, pub); err != nil {
return Permissions{}, err
@@ -801,6 +881,9 @@ func PermissionsFor(p Principal) (Permissions, error) {
if bucket == delivery.HaveBucket {
continue // each node's node-engine writes its own keys of it, the controller none (ADR 0429 §1)
}
if bucket == LeaseBucket && p.LeaseCut {
continue // the lease write took it (novox/hq ADR 0582 clause 3): a controller started fails on the lease
}
pub = append(pub, "$KV."+bucket+".>")
}
// **And the object bucket a declaration's large files travel in** (novox/hq ADR 0486 §2, issue 1697): the
@@ -880,7 +963,20 @@ func PermissionsFor(p Principal) (Permissions, error) {
"enrolment: a token is issued for a node record, and that record's name is " +
"the token's id")
}
pub = []string{"mesh.control.enrol"}
// **From the lease write a node enrols through the seat mesh-enrolment alone** (novox/hq ADR 0587 §4, §6): its
// user calls redeem in its own name, while the seat's holder serves it, and the controller's enrolment subject is
// kept only while the controller holds the lease.
pub = nil
if !p.LeaseHandedAway {
pub = append(pub, "mesh.control.enrol")
}
if p.Redeems {
pub = append(pub, EnrolmentRedeemCall(p))
}
if len(pub) == 0 {
return Permissions{}, fmt.Errorf("the enrolment user of %s may enrol nowhere: the lease is handed away and "+
"no holder of %s serves %s", p.Node, EnrolmentRedeem.Seat, EnrolmentRedeem.Verb)
}
sub = []string{p.inbox()}
case KindNode:
@@ -994,6 +1090,13 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, readGrants(delivery.SignersBucket, "")...)
pub = append(pub, readGrants(LeaseBucket, leaseKey)...)
}
// And, while the vault rotates node accounts, its own node's key of node-logins, read and watched, and no other
// key (novox/hq ADR 0583 §1 insight): the login it dials as; and the confirmation of a switch in its own name (§3).
if p.RotatesItsLogin {
pub = append(pub, NodeLoginsReads(p.Node)...)
pub = append(pub, "mesh.seat."+VaultNodeAccountConfirm.Seat+"."+CallKind+"."+VaultNodeAccountConfirm.Verb+"."+
CallerToken(p.Username()))
}
// And its node seat, node-engine, and its asks to the operator in its own name (novox/hq ADR 0563 §1, §3).
ep, es := engineGrants(p)
pub = append(pub, ep...)
@@ -1026,6 +1129,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
pub = append(pub, CreateConsumerGrants(p.CreatesConsumers)...)
// And its own object bucket, where it writes the user list sealed to the bus node (ADR 0547 clause 7).
pub = append(pub, GrantsUserListWrites()...)
// And, from the lease write's cut, the lease's one key, which it creates naming itself and deletes at a
// hand-back (novox/hq ADR 0582 clauses 3, 5): never the bucket's other keys or its stream.
if p.LeaseCut {
pub = append(pub, LeaseHolderWrite())
}
}
// The view alone reads the build seats' workers for the backlog of asks (issue 1887 A8a).
if p.Module == ViewModule {
@@ -1061,10 +1169,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
sub = append(sub, announcing(announced...)...)
// Its own membership (ADR 0160): the one subject a runtime derives for itself, heard live. Nothing else's.
sub = append(sub, MembershipSubject(p.Node, p.Module))
// And from the grants part's state memberships, its own key (novox/hq ADR 0571 clause 7): its one read of it,
// since the assignments stream's direct get went with the stream (research 072 row 33).
// Its own membership (ADR 0160), from the grants part's state memberships, its own key (novox/hq ADR 0571 clause
// 7): its one read of it, since the assignments stream's direct get went with the stream (research 072 row 33),
// and no longer heard on its subject, where the grants part stops publishing at the lease write (ADR 0571 clause
// 10, issue 774).
pub = append(pub, readGrants(GrantsMembershipsBucket, MembershipKey(p.Node, p.Module))...)
// 1b. The tools it calls, if its manifest says it calls any (novox/hq ADR 0152). The same
@@ -1226,10 +1334,10 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
}
// Every assigned module's membership on this node (ADR 0160): one per module, heard live and read from the
// grants part's state memberships (ADR 0571 clause 7). This node's and no other's — the one token
// that varies is the module, so the pattern is the machine's own assignments.
sub = append(sub, "mesh.assignment."+p.Node+".*")
// Every assigned module's membership on this node (ADR 0160): one per module, read and watched from the
// grants part's state memberships (ADR 0571 clause 7), and no longer heard on `mesh.assignment`, where the grants
// part stops publishing at the lease write (ADR 0571 clause 10, issue 774). This node's and no other's — the
// one token that varies is the module, so the pattern is the machine's own assignments.
pub = append(pub, readGrants(GrantsMembershipsBucket, p.Node+".*")...)
// And that it is there (novox/hq to-be 45 §3, S11): its own heartbeat, under its machine's
// name and no other's, on core NATS like the host's.
@@ -1408,6 +1516,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
// What the controller alone may publish is denied to everybody else whose grant reaches it.
var deny []string
// And from the lease write's cut, the lease's bucket to the controller, whose `$JS.API.>` reaches it (novox/hq ADR
// 0582 clause 3): a controller started by mistake fails on the lease instead of taking it back.
if p.Kind == KindController && p.LeaseCut {
deny = append(deny, LeaseCutDenied()...)
}
if p.Kind != KindController {
for _, only := range ControllerOnly() {
for _, a := range pub {
@@ -1421,6 +1534,14 @@ func PermissionsFor(p Principal) (Permissions, error) {
}
}
}
// And the vault's enrolment verbs to every principal but mesh-enrolment's (novox/hq ADR 0587 §1).
if p.VaultEnrols && !((p.Kind == KindModule || p.Kind == KindProcess) && p.Module == delivery.EnrolmentModule) {
for _, only := range EnrolmentOnly() {
if slices.ContainsFunc(pub, func(a string) bool { return SubjectsOverlap(a, only) }) {
deny = append(deny, only)
}
}
}
return Permissions{
Publish: pub,
PublishDeny: deny,
+6 -1
View File
@@ -104,7 +104,12 @@ var NamedReaders = map[string][]string{VaultBusAccounts: {GrantsModule}, GrantsA
ControllerConditions: {health.KeeperModule}, ControllerHandActs: {health.HealersModule, handacts.Module}, HealersWould: {},
// ADR 0577 clause 1: the node-engine's data items are read by disk-load alone, each node's its own node's keys
// (NodeKeyedReads); the engine announces nothing of its own, so its readers are named here.
delivery.EngineDataRead: {health.DiskLoadModule}}
delivery.EngineDataRead: {health.DiskLoadModule},
// ADR 0583 §1 insight: every node's login is read by the grants part alone, each engine its own node's key through its
// machine principal (NodeLoginsReads), never through `reads`.
delivery.NodeLoginsRead: {GrantsModule},
// ADR 0587 §2: the open enrolments are read by their holder and, as a named reader, by the grants part.
delivery.EnrolmentsRead: {GrantsModule}}
// ReaderMay says whether module may read the state read (`<owner>.<name>`) by the named readers: always, for a state
// that names none.
+59 -9
View File
@@ -2,10 +2,12 @@ package buscompose
import (
"fmt"
"git.novox.be/novox/mesh-sdk/go/announce"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Every user the composed file should contain, derived from what the mesh knows.
@@ -108,7 +110,7 @@ type ProcessPrincipal struct {
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 (catalogue.BusRemovals; novox/hq
// 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.
@@ -129,7 +131,8 @@ type Records struct {
Assigned map[string][]Declared
// Enrolling is every node with a live token — one enrolment user each, because the inbox an
// answer goes to is scoped to the token and a shared one is one machine reading another's
// sealed credentials (design 25 §6).
// sealed credentials (design 25 §6). The grants part fills it from the seat mesh-enrolment's keys at live or
// redeeming (novox/hq ADR 0587 §4), the controller from its store.
Enrolling []string
// People is each person's name against the tools they may invoke, `*` for an administrator.
People map[string][]string
@@ -145,9 +148,22 @@ type Records struct {
// grants part as a placed module's are; it adds no principal, since each node's engine speaks as the node's account.
// Nil where its manifest was not read.
Engine *Declared
// LeaseHandedAway says the controller's lease names a holder that is no controller (ADR 0582 §3), as the grants part
// reads it: an enrolment user then enrols through the seat mesh-enrolment alone (novox/hq ADR 0587 §6). The
// controller's composer never sets it. It is not LeaseCut: the cut is composed before the holder is written, so the
// grants part may write it, and outlasts the holder at a hand-back; the two are true together only in between.
LeaseHandedAway bool
// NodeLogins is each node's key of the vault's node-logins (novox/hq ADR 0583 §1): the login in use and the one
// rotating, each composed as a user of the node's permissions. A node absent logs in as `node.<n>` alone.
NodeLogins map[string]delivery.NodeLogin
// Announced is each module's last announcement of the states it keeps (novox/hq ADR 0495, issue 1790), as the
// controller keeps it; a module absent has announced nothing yet. Nil where none was read.
Announced map[string]announce.States
// LeaseCut composes the list of the lease write (novox/hq ADR 0582 clause 3, issue 774): the controller may neither
// write the lease's key nor update, purge or delete its bucket, and the grants part may write the key `holder`.
// Set by the grants part from the write's second step until a hand-back composes without it (clause 5); the
// controller's composer never sets it.
LeaseCut bool
}
// Users is every user the composed file should contain, in the order it will be written.
@@ -157,7 +173,7 @@ type Records struct {
// correct.
func Users(r Records) ([]Principal, error) {
out := []Principal{{Kind: KindController, Uses: asksSeatOf(r), AsksIssued: issuedOf(r), AsksTheVault: vaultVerbsOf(r),
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes)}}
AsksTheConfig: servedOf(r, VerbsTheControllerAsksTheConfig), FilesFor: sortedCopy(r.Nodes), LeaseCut: r.LeaseCut}}
served := servedReadsOf(r)
logs := DeclaredLogs(r)
@@ -175,10 +191,14 @@ func Users(r Records) ([]Principal, error) {
}
checks = append(checks, d.Checks...)
}
out = append(out, Principal{Kind: KindNode, Node: node, WitnessesController: witness, Checks: checks,
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node)})
// **One user per login the node has** (novox/hq ADR 0583 §1): the one in use, and while it rotates the other too,
// each with the node's permissions.
for _, login := range nodeLoginsOf(r, node) {
out = append(out, Principal{Kind: KindNode, Node: node, Login: login, WitnessesController: witness, Checks: checks,
MovesIntoTheVault: servesMoveIn(r), StatesItsKeyToTheVault: servesVerb(r, VaultNodeKey),
AsksItsOwnSecrets: servesVerb(r, VaultOwn), AsksTheVaultToSign: servesVerb(r, VaultSign), Uses: asksSeatOf(r),
FollowsGrantsUserList: bus, ServedReads: servedOn(served, node), RotatesItsLogin: servesVerb(r, VaultRotateNode)})
}
// **And the machine's provisioner client, on an account of its own** (novox/hq ADR 0349 clause 11): one
// per machine that runs it, delivered to the module running it.
for _, d := range r.Assigned[node] {
@@ -246,6 +266,7 @@ func Users(r Records) ([]Principal, error) {
}
if d.Module == GrantsModule {
out[len(out)-1].CreatesStreams = declaredStreamsOf(r)
out[len(out)-1].LeaseCut = r.LeaseCut
grants = len(out) - 1
}
// **The vault's holder asks the desks and the issuing providers itself** (issue 723), under the
@@ -269,7 +290,8 @@ func Users(r Records) ([]Principal, error) {
}
}
for _, node := range sortedCopy(r.Enrolling) {
out = append(out, Principal{Kind: KindEnrolment, Node: node})
out = append(out, Principal{Kind: KindEnrolment, Node: node, Redeems: servesVerb(r, EnrolmentRedeem),
LeaseHandedAway: r.LeaseHandedAway})
}
for _, person := range sortedNames(r.People) {
p := Principal{Kind: KindPerson, Module: person, Invokes: r.People[person]}
@@ -280,6 +302,13 @@ func Users(r Records) ([]Principal, error) {
out = append(out, p)
}
// The vault's enrolment verbs are mesh-enrolment's alone, however wide another's grant (novox/hq ADR 0587 §1).
if slices.ContainsFunc(VerbsTheEnrolmentAsksTheVault, func(v SeatVerb) bool { return servesVerb(r, v) }) {
for i := range out {
out[i].VaultEnrols = true
}
}
// The grants part creates the durable consumers the accounts need (novox/hq ADR 0561 §6), by stream and name.
if grants >= 0 {
out[grants].CreatesConsumers = AccountConsumers(out)
@@ -379,6 +408,27 @@ func asksSeatOf(r Records) []Seat {
return nil
}
// nodeLoginsOf are the logins composed for node, as Principal.Login names them ("" for `node.<n>`): the one its
// node-logins key says is in use and the one rotating, in that order; `node.<n>` alone when the key is absent or does
// not hold (a key naming another node's login is no login of this node's).
func nodeLoginsOf(r Records, node string) []string {
l, ok := r.NodeLogins[node]
if !ok || l.Node != node || (l.Login != delivery.FirstLogin(node) && l.Login != delivery.SecondLogin(node)) {
return []string{""}
}
login := func(name string) string {
if name == delivery.FirstLogin(node) {
return ""
}
return name
}
out := []string{login(l.Login)}
if l.Rotates() && l.Rotating == delivery.OtherLogin(node, l.Login) {
out = append(out, login(l.Rotating))
}
return out
}
// VaultSeat is the vault's seat: its holder alone asks the desks and the issuing providers in its own name.
const VaultSeat = "mesh-vault"
+29 -4
View File
@@ -43,7 +43,7 @@ func createsDeclared(p Principal, pattern string) bool {
if isController(p, pattern) {
return true
}
if removesAssignmentsStream(p, pattern) {
if removesAssignmentsStream(p, pattern) || setsLeaseExpiry(p, pattern) {
return true
}
name, ok := strings.CutPrefix(pattern, "$JS.API.STREAM.CREATE.")
@@ -96,6 +96,19 @@ func ownModule(p Principal, pattern string) bool {
return false
}
// moduleNamed is the writer that is module: its own principal, a process of it, or the node tools carrying it.
func moduleNamed(module string) func(Principal, string) bool {
return func(p Principal, _ string) bool {
switch p.Kind {
case KindModule, KindProcess:
return p.Module == module
case KindNodeTools:
return slices.ContainsFunc(p.Carries, func(d Declared) bool { return d.Module == module })
}
return false
}
}
// ownAnnouncer is a module, a process of it or the node tools carrying it saying what that module keeps, or the
// controller saying what it keeps itself (novox/hq ADR 0495 clause 2): each under its own name alone.
func ownAnnouncer(p Principal, pattern string) bool {
@@ -158,7 +171,8 @@ var WritersTable = []WriterRow{
// subject is this machine's engine's alone.
Subjects: []string{"mesh.control.*.report", "mesh.control.*.health", "mesh.control.*.cli"}, Writes: ownMachine},
{State: "the controller lease", Writer: "the controller instance holding it", KeptIn: "key-value " + LeaseBucket,
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: isController},
Others: "a candidate waits", Subjects: kvOf(LeaseBucket), Writes: writesTheLease,
Shared: "from the lease write's cut the controller writes it no more and the grants part writes its key holder alone (novox/hq ADR 0582 clause 3)"},
// The merges mesh-delivery announced, for a moved module's registration (ADR 0420 §7), written by the lease holder.
{State: "the merges mesh-delivery announced", Writer: "controller (lease holder)",
KeptIn: "key-value " + DeliveryMergesBucket, Others: "read at a moved module's registration",
@@ -188,7 +202,7 @@ var WritersTable = []WriterRow{
Subjects: []string{"$JS.API.STREAM.CREATE.>", "$JS.API.STREAM.UPDATE.>", "$JS.API.STREAM.DELETE.>",
"$JS.API.CONSUMER.DURABLE.CREATE.>"},
Writes: createsDeclared,
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33)"},
Shared: "the grants part creates a stream a manifest declares, by its name, and never updates or deletes one (novox/hq ADR 0547 §5; the operator, 2026-10-26 08:54); the bus module's tools account deletes the stream ASSIGNMENTS at the lease write, and no other (research 072 row 33), and updates the lease's bucket for its expiry alone (novox/hq ADR 0582 clause 3)"},
{State: "builds and their outcomes", Writer: "the build seat's holder", KeptIn: "its own state",
Others: "the controller asks",
Subjects: []string{"mesh.seat.node-build-agent.event.built", "mesh.seat.mesh-build-machine.event.built"},
@@ -217,6 +231,16 @@ var WritersTable = []WriterRow{
Writes: ownModule},
{State: "the operator-channel's open messages", Writer: "the seat's holder", KeptIn: "its own key-value state",
Others: "—"},
// The seat mesh-enrolment's open enrolments (novox/hq ADR 0587 §2, issue 774): its holder alone writes them, each a
// compare-and-set at the revision read; the grants part reads them as a named reader.
{State: "the open enrolments", Writer: "the seat mesh-enrolment's holder, compare-and-set",
KeptIn: "key-value " + delivery.EnrolmentsBucket + ", with per-key expiry",
Others: "the grants part reads them (a named reader) and composes each enrol.<node> user from them",
Subjects: kvOf(delivery.EnrolmentsBucket), Writes: moduleNamed(delivery.EnrolmentModule)},
// Which login each node uses (novox/hq ADR 0583 §1 insight): the vault alone writes it.
{State: "a node's logins", Writer: "the vault", KeptIn: "key-value " + delivery.NodeLoginsBucket,
Others: "the grants part reads every key, each node's engine its own node's key alone",
Subjects: kvOf(delivery.NodeLoginsBucket), Writes: moduleNamed(delivery.VaultModule)},
{State: "the facts snapshot", Writer: "controller", KeptIn: "the artifact store, facts/latest",
Others: "the build seat reads"},
// What a consumer gave up on (novox/hq issue 330): copied by the controller from the server's notice,
@@ -288,7 +312,8 @@ var WritersTable = []WriterRow{
KeptIn: "key-value " + PredefinedBucket, Others: "read through `reads` as mesh-controller." + MirrorPredefined,
Subjects: kvOf(PredefinedBucket), Writes: isController},
{State: "the declared-packages mirror", Writer: "controller (lease holder), from the composition of each node, only on a change",
KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages,
KeptIn: "key-value " + DeclaredPackagesBucket, Others: "read through `reads` as mesh-controller." + MirrorDeclaredPackages +
", and each node's engine its node's key, until the lease write (ADR 0565 clause 6)",
Subjects: kvOf(DeclaredPackagesBucket), Writes: isController},
// What a machine's provisioner client placed of a module's files (novox/hq ADR 0349 §4, design 57 P6): its own
// machine's word alone, which a walk's end and a gate wait for (ADR 0362, ADR 0349 §15). Kept newest per module
+1 -21
View File
@@ -2,7 +2,6 @@ package catalogue
import (
"encoding/json"
"strings"
"testing"
)
@@ -31,7 +30,7 @@ func aServiceModule(t *testing.T) Manifest {
"build": {"artifacts": [{"name": "code", "kind": "bundle", "language": "go", "system": "arch",
"from": "cmd/svc", "binary": "svc"}]}
}`
m, err := ParseManifest([]byte(raw))
m, err := decodeForTest([]byte(raw))
if err != nil {
t.Fatalf("the service's manifest is refused: %v", err)
}
@@ -145,22 +144,3 @@ func TestWhatAProcessReplacesIsNamedAsTheHostRecordedIt(t *testing.T) {
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)
}
}
-30
View File
@@ -76,33 +76,3 @@ func TestAModuleMayNotOwnWhatAnotherAccesses(t *testing.T) {
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")
}
}
-9
View File
@@ -258,15 +258,6 @@ WantedBy=multi-user.target
}
}
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)
}
+1 -35
View File
@@ -43,7 +43,7 @@ const agentModule = `{"module": "agent", "version": "1", "resources": [
]}`
func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing.T) {
m, err := ParseManifest([]byte(agentModule))
m, err := decodeForTest([]byte(agentModule))
if err != nil {
t.Fatal(err)
}
@@ -83,37 +83,3 @@ func TestTheAgentAccountIsDeclaredNeverRootOnlyToAnEngineThatJudgesIt(t *testing
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
// 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
// design 28 task 5.4, not converted.
func TestNoCatalogueManifestNamesAmqp(t *testing.T) {
+2 -14
View File
@@ -5,28 +5,16 @@ import (
"testing"
)
// A backup contribution written by hand still names its module's own directories; one it does not
// declare is refused where it is written rather than reaching the holder as the literal text. (The
// catalogue check refuses a hand-written backup line at all since ADR 0233; parsing still reads one,
// because a module on the shelf was written before.)
func TestABackupNamingAnUndeclaredDirectoryIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"pg","version":"1",
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:nowhere}"}]}`))
if err == nil || !strings.Contains(err.Error(), "nowhere") {
t.Fatalf("a backup of an undeclared directory was accepted: %v", err)
}
}
// A module on the shelf from before ADR 0233, which writes its backup lines by hand and declares no
// data, is still backed up: its lines are placed as written, each module's under a comment naming it.
func TestHandWrittenBackupLinesOfAnOlderModuleArePlaced(t *testing.T) {
pg, err := ParseManifest([]byte(`{"module":"pg","version":"1",
pg, err := decodeForTest([]byte(`{"module":"pg","version":"1",
"resources":[{"id":"dumps","type":"directory","path":"/srv/pg/dumps","mode":"0700"}],
"contributions":[{"seat":"node-backup","kind":"backup","content":"run pg-dump-all\npath ${dir:dumps}"}]}`))
if err != nil {
t.Fatal(err)
}
mail, err := ParseManifest([]byte(`{"module":"mail","version":"1",
mail, err := decodeForTest([]byte(`{"module":"mail","version":"1",
"resources":[{"id":"spool","type":"directory","mode":"0700"}],
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:spool}"}]}`))
if err != nil {
+2 -24
View File
@@ -1,34 +1,12 @@
package catalogue
import (
"strings"
"testing"
)
// The module that provides the artifact store may not be delivered through it.
//
// Building publishes to the store and the builder will not start without one, so a module that
// provides the store and also builds something asks the mesh to put an artifact into the thing
// that artifact is needed to create. On a mesh new enough to have no registry, that is a build
// that never returns (novox/hq 04-ISSUES/029).
func TestTheArtifactStoreCannotBeDeliveredThroughItself(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"registry","version":"1",` +
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
`"build":{"artifacts":[{"name":"registry","kind":"upstream","from":"registry:2"}]},` +
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
`"artifact":"registry","ports":["5000:5000"]}]}`))
if err == nil {
t.Fatal("a registry module that builds its own image was accepted; the build has " +
"nowhere to publish until the module it belongs to is already running")
}
if !strings.Contains(err.Error(), "artifact-store") {
t.Fatalf("refused without naming the provision the cycle turns on: %v", err)
}
}
// Naming the image directly is the way out, and must stay accepted.
func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"registry","version":"1",` +
_, err := decodeForTest([]byte(`{"module":"registry","version":"1",` +
`"provides":[{"name":"artifact-store","scope":"mesh"}],` +
`"resources":[{"id":"store","type":"container","name":"mesh-registry",` +
`"image":"registry@sha256:` +
@@ -41,7 +19,7 @@ func TestAnArtifactStoreThatNamesItsImageIsAccepted(t *testing.T) {
// And an ordinary module still builds whatever it likes.
func TestAModuleThatDoesNotProvideTheStoreStillBuilds(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"forge","version":"1",` +
_, err := decodeForTest([]byte(`{"module":"forge","version":"1",` +
`"build":{"artifacts":[{"name":"forge","kind":"upstream","from":"gitea/gitea:1.22"}]},` +
`"resources":[{"id":"run","type":"container","name":"forge","artifact":"forge"}]}`))
if err != nil {
+1 -36
View File
@@ -174,7 +174,7 @@ func TestALocalRequirementIsStillAnsweredLocally(t *testing.T) {
func TestTheShortFormStillMeansHere(t *testing.T) {
// `"provides": ["shell"]` must keep meaning what it meant, or every existing manifest
// silently changes meaning.
m, err := ParseManifest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
m, err := decodeForTest([]byte(`{"module":"zsh","version":"1","provides":["shell"]}`))
if err != nil {
t.Fatal(err)
}
@@ -183,19 +183,6 @@ func TestTheShortFormStillMeansHere(t *testing.T) {
}
}
func TestASiteScopedProvisionIsRefused(t *testing.T) {
// Meaningful for a claim — one DHCP server per segment — and not yet meaningful for a
// provision, because nothing knows how to reach "the one at my site".
_, err := ParseManifest([]byte(
`{"module":"dns","version":"1","provides":[{"name":"resolver","scope":"site"}]}`))
if err == nil {
t.Fatal("a site-scoped provision was accepted")
}
if !strings.Contains(err.Error(), "site") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
// Being told, which is the difference between knowing and being able to.
func boundShelf() map[string]Manifest {
@@ -308,25 +295,3 @@ func TestBindingSomethingAnsweredHereWritesNothing(t *testing.T) {
}
}
}
func TestBindingWhatYouDoNotRequireIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"app","version":"1",
"binds":{"postgres-database":"/etc/app/db.json"}}`))
if err == nil {
t.Fatal("a module was told about something it never asked for")
}
if !strings.Contains(err.Error(), "does not require") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
func TestServingWhatYouDoNotProvideIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"app","version":"1",
"serves":{"postgres-database":{"port":5432}}}`))
if err == nil {
t.Fatal("a module served something it does not provide")
}
if !strings.Contains(err.Error(), "does not provide") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
+3 -3
View File
@@ -88,7 +88,7 @@ func TestAMalformedShareIsRefused(t *testing.T) {
"other require": `{"name":"route","shares":[{"to":"a","methods":["get"]}]}`,
}
for name, entry := range cases {
_, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`))
_, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":[` + entry + `]}`))
if err == nil {
t.Errorf("%s: accepted", name)
continue
@@ -98,7 +98,7 @@ func TestAMalformedShareIsRefused(t *testing.T) {
}
}
// The stored form is held to the same rules.
_, err := ParseManifest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"],
_, err := decodeForTest([]byte(`{"module":"files","version":"1","requires":["s3-bucket"],
"requires-shares":{"s3-bucket":[{"to":"a","methods":[]}]}}`))
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
t.Errorf("the stored form was accepted: %v", err)
@@ -169,7 +169,7 @@ func TestChangingOnlyTheSharesDoesNotRecreateTheConsumer(t *testing.T) {
// 1591/AC5: a consumer whose contributes to s3-bucket names `shares` itself is refused.
func TestAConsumerCannotContributeSharesItself(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"files","version":"1",
_, err := decodeForTest([]byte(`{"module":"files","version":"1",
"contributes":{"s3-bucket":{"shares":"x"}},
"requires":[{"name":"s3-bucket","shares":[{"to":"a","methods":["get"]}]}]}`))
if err == nil || !strings.Contains(err.Error(), "ADR 0468 §8") {
-350
View File
@@ -2,10 +2,7 @@ package catalogue
import (
"fmt"
"net"
"net/url"
"path"
"regexp"
"sort"
"strings"
)
@@ -237,152 +234,6 @@ func withVersion(value any, version string) any {
return value
}
// checkBuild is the manifest's own account of what it builds.
func (b *Build) problems(module string) []string {
if b == nil {
return nil
}
var problems []string
seen := map[string]bool{}
for _, a := range b.Artifacts {
if a.Name == "" {
problems = append(problems, module+" builds an artifact with no name")
continue
}
if seen[a.Name] {
problems = append(problems, fmt.Sprintf(
"%s builds two artifacts called %q, and a resource naming it could mean either",
module, a.Name))
}
seen[a.Name] = true
switch a.Kind {
case ArtifactImage, ArtifactArchive, ArtifactUpstream, ArtifactBundle, ArtifactPackage:
default:
problems = append(problems, fmt.Sprintf(
"%s: %q is a %q, and an artifact is %q, %q, %q or %q",
module, a.Name, a.Kind, ArtifactImage, ArtifactArchive, ArtifactUpstream,
ArtifactBundle+", "+ArtifactPackage))
}
// **A bundle is built from the module itself, so it says a language instead.** Everything
// else names what it is built from: a Dockerfile, a directory, somebody else's reference.
// A bundle's source is the module's own directory by definition, and what it needs to say
// is which compiler — because the mesh chooses that, and cannot choose for a module that
// has not said.
if len(a.External) > 0 && (a.Kind != ArtifactBundle || a.Language != "typescript") {
problems = append(problems, fmt.Sprintf(
"%s: %q names packages it keeps external, and only a TypeScript bundle is bundled into "+
"one file with some kept out (novox/hq ADR 0193)", module, a.Name))
}
if len(a.Env) > 0 && a.Kind != ArtifactBundle {
problems = append(problems, fmt.Sprintf(
"%s: %q is a %q and says what it is given (env). Only a bundle the node's runtime "+
"serves is given words (novox/hq ADR 0192); a container says its own environment",
module, a.Name, a.Kind))
}
if a.Kind == ArtifactBundle || a.Kind == ArtifactPackage {
// **Except for a language that compiles to a binary, where it names which one**
// (novox/hq 04-ISSUES/142). A bundle in an interpreted language is the module's own
// directory compiled whole, and naming a source would be describing its own build. A
// repository written in a compiled language holds several commands — the host and its
// bootstrap live in one, and the mesh needs the host — and "the module's own directory"
// is then not a package at all. So the compiled case may say which package, and says
// the module root by saying nothing.
if a.From != "" && !compilesToABinary(a.Language) {
problems = append(problems, fmt.Sprintf(
"%s: %q is a bundle and names what it is built from (%q). A bundle is built "+
"from the module's own directory; what it says is the language",
module, a.Name, a.From))
}
if strings.TrimSpace(a.Language) == "" {
problems = append(problems, fmt.Sprintf(
"%s: %q is a bundle and says no language, so nothing can choose a compiler "+
"for it", module, a.Name))
}
problems = append(problems, bundleEnvProblems(module, a)...)
// What the runtime loads is among what was compiled (ADR 0175): a name here that is
// not an entrypoint is a file the bundle does not contain, and the runtime would
// fail to import it on every machine rather than here.
for _, load := range a.Loads {
found := false
for _, e := range a.Entrypoints {
found = found || e == load
}
// A bundle compiled to a binary is one executable: the runtime loads that or nothing
// (novox/hq ADR 0193).
if bin := BinaryOf(a); bin != "" {
found = load == bin
}
if !found {
problems = append(problems, fmt.Sprintf(
"%s: %q says the runtime loads %q, which is not among its entrypoints — "+
"what is loaded is compiled, so it is named there too", module, a.Name, load))
}
}
// **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary
// is pinned to one operating system at link time so a host refuses to touch a machine
// it was not built for (novox/hq ADR 0005); an artifact that says nothing would be
// compiled for whatever the build machine happened to be, which reads as portable and
// is not.
if compiled := compilesToABinary(a.Language); compiled && strings.TrimSpace(a.System) == "" {
problems = append(problems, fmt.Sprintf(
"%s: %q is compiled to a binary and says no system, so it would be built for "+
"whatever the build machine happens to be. Declare one artifact per "+
"system: %s", module, a.Name, spokenSystems()))
} else if !compiled && strings.TrimSpace(a.System) != "" {
problems = append(problems, fmt.Sprintf(
"%s: %q names the system %q and is written in %q, which compiles to code that "+
"runs anywhere — a system that decides nothing reads as though it did",
module, a.Name, a.System, a.Language))
} else if compiled && !knownSystem(a.System) {
problems = append(problems, fmt.Sprintf(
"%s: %q is built for %q, and a system is %s",
module, a.Name, a.System, spokenSystems()))
}
} else {
if a.From == "" && a.URL == "" {
problems = append(problems, fmt.Sprintf(
"%s: %q says nothing about what it is built from", module, a.Name))
}
if a.Language != "" {
problems = append(problems, fmt.Sprintf(
"%s: %q is a %q and names a language. Only a bundle is compiled by the mesh; "+
"everything else brings its own recipe", module, a.Name, a.Kind))
}
}
if a.Compiles != "" {
if a.Kind != ArtifactImage {
problems = append(problems, fmt.Sprintf(
"%s: %q is a %q and names a Go package it compiles. Only an image's recipe is "+
"told which; a bundle names its package in from (novox/hq ADR 0267)", module, a.Name, a.Kind))
} else if c := strings.TrimSpace(a.Compiles); strings.HasPrefix(c, "/") || c == ".." ||
strings.HasPrefix(c, "../") || strings.Contains(c, "/../") || strings.HasSuffix(c, "/..") {
problems = append(problems, fmt.Sprintf(
"%s: %q compiles %q, which leaves the tree it is built in", module, a.Name, a.Compiles))
}
}
// An upstream image is named, not read from the repository, so the path rule does not
// apply to it — and applying it anyway would refuse every reference with a registry host
// in it.
if a.Kind != ArtifactUpstream &&
(strings.HasPrefix(a.From, "/") || strings.Contains(a.From, "..")) {
// A build reads its own repository and nothing else. A path leaving it would make
// what gets built depend on whatever happens to be on the machine building it.
problems = append(problems, fmt.Sprintf(
"%s: %q is built from %q, which is outside its own repository",
module, a.Name, a.From))
}
if a.Kind == ArtifactUpstream && !strings.Contains(a.From, ":") {
// Without a tag or digest, what gets mirrored is whatever `latest` means today, and
// a module pinned to that is not pinned.
problems = append(problems, fmt.Sprintf(
"%s: %q mirrors %q, which names no tag or digest", module, a.Name, a.From))
}
problems = append(problems, upstreamFileProblems(module, a)...)
problems = append(problems, pathsProblems(module, a)...)
}
return problems
}
// NamedPaths are the paths a module's fingerprint takes its tree from (novox/hq ADR 0499 clause 2): the union of the
// paths its artifacts name, cleaned and sorted, and every true only when the module builds something and every artifact
// names paths; otherwise none, and the module keeps its directory's tree.
@@ -410,122 +261,6 @@ func (m Manifest) NamedPaths() (paths []string, every bool) {
return paths, true
}
// pathsProblems is what is wrong with an artifact's `paths` (novox/hq ADR 0499 clause 1): named on a kind whose build
// source is derived or named otherwise, or a path that leaves the module's directory, is its whole directory, or is a
// pattern.
func pathsProblems(module string, a Artifact) []string {
if a.Paths == nil {
return nil
}
switch {
case a.Kind == ArtifactBundle && a.Language == "go":
return []string{fmt.Sprintf("%s: %q is a Go program and names paths: its build source is derived from the "+
"package it compiles (novox/hq ADR 0267 clause 1, ADR 0499 clause 1)", module, a.Name)}
case a.Kind != ArtifactBundle && a.Kind != ArtifactPackage:
return []string{fmt.Sprintf("%s: %q is a %q and names paths; only a package or a bundle in a language other "+
"than Go names the paths it is built from (novox/hq ADR 0499 clause 1)", module, a.Name, a.Kind)}
case len(a.Paths) == 0:
return []string{fmt.Sprintf("%s: %q names no path in paths; leave the word out to build from the module's "+
"directory", module, a.Name)}
}
var problems []string
for _, p := range a.Paths {
clean := path.Clean(p)
switch {
case strings.TrimSpace(p) == "":
problems = append(problems, fmt.Sprintf("%s: %q names an empty path", module, a.Name))
case strings.HasPrefix(p, "/") || clean == ".." || strings.HasPrefix(clean, "../"):
problems = append(problems, fmt.Sprintf("%s: %q names the path %q, which leaves the module's directory",
module, a.Name, p))
case clean == ".":
problems = append(problems, fmt.Sprintf("%s: %q names the module's whole directory (%q) as a path; leave "+
"the word out for that", module, a.Name, p))
case strings.ContainsAny(p, "*?[]{}\\"):
problems = append(problems, fmt.Sprintf("%s: %q names %q, which is a pattern; paths names files and "+
"directories", module, a.Name, p))
}
}
return problems
}
// reservedSuffixes are names no public vendor publishes under: an installation's own and documentation's.
var reservedSuffixes = []string{".internal", ".local", ".lan", ".home", ".corp", ".localhost", ".home.arpa",
".test", ".invalid", ".example", ".intranet", ".private"}
// notAPublicHost says why a host is not a public vendor's name, or "".
func notAPublicHost(host string) string {
h := strings.ToLower(strings.TrimSuffix(host, "."))
if net.ParseIP(h) != nil {
return "an address rather than a name"
}
if !strings.Contains(h, ".") {
return "a name with no domain"
}
for _, s := range reservedSuffixes {
if strings.HasSuffix(h, s) {
return "a name under " + s + ", which is nobody's public domain"
}
}
return ""
}
// sha256Hex is a pinned sha256 as a manifest writes it: 64 lower-case hexadecimal characters.
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// upstreamFileProblems is what is wrong with an archive pinned by URL (novox/hq ADR 0316 §2, §6): it is an
// archive, fetched over https, pinned by a sha256 and a version, and it names no directory of the repository
// besides; a file name or a directory within is a plain path that stays where it is put.
func upstreamFileProblems(module string, a Artifact) []string {
pinned := a.URL != "" || a.SHA256 != "" || a.Version != "" || a.File != "" || a.Within != ""
if !pinned {
return nil
}
var problems []string
say := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf("%s: %q ", module, a.Name)+fmt.Sprintf(format, args...))
}
if a.Kind != ArtifactArchive {
say("is a %q and names an upstream file (url, sha256, version, file, within): only an archive is "+
"mirrored from one (novox/hq ADR 0316)", a.Kind)
return problems
}
if a.URL == "" {
say("names a sha256, a version, a file or a directory within, and no url to fetch it from")
return problems
}
if a.From != "" {
say("is built from %q and fetched from %s: an archive is one or the other", a.From, a.URL)
}
if u, err := url.Parse(a.URL); err != nil || u.Scheme != "https" || u.Host == "" {
say("is fetched from %q, and an upstream file is fetched over https from a named host", a.URL)
} else if why := notAPublicHost(u.Hostname()); why != "" {
say("is fetched from %s, %s: an upstream file is somebody else's release, published under a public name", a.URL, why)
}
if !sha256Hex.MatchString(a.SHA256) {
say("is fetched from %s without a pinned sha256 (64 lower-case hexadecimal characters): what that "+
"address serves changes under the mesh, and only the digest says it is what was reviewed", a.URL)
}
if strings.TrimSpace(a.Version) == "" {
say("pins %s and says no version, so a person reading the pin cannot tell which release it is", a.URL)
}
if a.File != "" && a.Within != "" {
say("names a file (%q) and a directory within (%q): a file is kept whole, a directory is taken out "+
"of an archive", a.File, a.Within)
}
for field, p := range map[string]string{"file": a.File, "within": a.Within} {
if p == "" {
continue
}
clean := path.Clean(p)
if clean != p || strings.HasPrefix(p, "/") || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") ||
(field == "file" && strings.Contains(p, "/")) {
say("names %s %q, which is not a plain relative %s", field, p, map[string]string{
"file": "file name", "within": "directory"}[field])
}
}
return problems
}
// oneOrOther keeps the message readable for one artifact and for several, because a message that
// says "neither" about one thing reads as a bug in the message.
func oneOrOther(n int) string {
@@ -535,30 +270,6 @@ func oneOrOther(n int) string {
return "them"
}
// Systems the mesh builds binaries for, which is the set a host may be pinned to (novox/hq ADR 0005).
//
// **A closed list, and the host's own, not the compiler's.** These are not the values a Go toolchain
// would call an operating system — the difference between two of them is a C library, not a kernel.
// They are what a machine reports itself to be and what a host is linked to refuse, so the list that
// matters is the one the host understands.
var systems = []string{"alpine", "android", "arch"}
// knownSystem is whether the mesh builds for it.
func knownSystem(system string) bool {
want := strings.ToLower(strings.TrimSpace(system))
for _, s := range systems {
if s == want {
return true
}
}
return false
}
// spokenSystems is the list as a refusal says it, so a reader is one edit from right.
func spokenSystems() string {
return strings.Join(systems, ", ")
}
// compilesToABinary is whether this language's bundle is a binary for one operating system rather
// than code that runs wherever its interpreter does.
//
@@ -599,42 +310,6 @@ func versionOf(digest string) string {
return hex
}
// bundleEnvWords are the words the runtime sets for itself; a bundle that named one would be
// telling the runtime what it is, which is the mesh's to say (novox/hq ADR 0192).
var bundleEnvWords = map[string]bool{
RuntimeToolModules: true, RuntimeBrokerFile: true, RuntimeOperatorAccount: true,
RuntimeOperatorHome: true, RuntimeToolEnv: true, RuntimeAgentAccount: true, RuntimeAgentHome: true,
}
// bundleEnvProblems says what is wrong with what a bundle says it is given (novox/hq ADR 0192):
// a value is a path or a constant written with the references a container's environment may use
// for a place or a port, and never a secret's content or another module's binding — a secret
// reaches a tool as a file whose path is named.
func bundleEnvProblems(module string, a Artifact) []string {
if len(a.Env) == 0 {
return nil
}
var problems []string
for _, word := range sortedKeys(a.Env) {
value := a.Env[word]
if bundleEnvWords[word] {
problems = append(problems, fmt.Sprintf(
"%s: %q gives itself %s, which the node's runtime sets for itself; a bundle is "+
"given its own words beside the runtime's, never in place of them (novox/hq ADR 0192)",
module, a.Name, word))
}
rest := ofPort.ReplaceAllString(dirRef.ReplaceAllString(value, ""), "")
if strings.Contains(rest, "${") {
problems = append(problems, fmt.Sprintf(
"%s: %q gives %s the value %q. A bundle's word is a path or a constant, written with "+
"${dir:…} and ${port:…} only; a secret reaches a tool as a file the mesh places, "+
"named by its path, never as its content (novox/hq ADR 0192)",
module, a.Name, word, value))
}
}
return problems
}
func copyWords(in map[string]string) map[string]string {
if len(in) == 0 {
return nil
@@ -675,31 +350,6 @@ func runByAResource(m Manifest) map[string]bool {
return named
}
// undeliveredBundles says which of a module's bundles nothing would ever put on a machine (novox/hq
// 04-ISSUES/216). A bundle reaches a machine three ways: the node's runtime serves it (it says
// `loads`, or its module declares `tools`), a resource names it (a process, a step, an archive), or
// it is the runtime itself. One reached by none of them was built, recorded and pushed as success,
// and was simply absent — seven modules' tools went missing that way on 2026-10-03. Refused here,
// naming the field that would deliver it.
func undeliveredBundles(m Manifest) []string {
if m.Build == nil || m.Module == RuntimeModule {
return nil
}
named := runByAResource(m)
var problems []string
for _, a := range m.Build.Artifacts {
if a.Kind != ArtifactBundle || named[a.Name] || len(a.Loads) > 0 || len(m.Tools) > 0 {
continue
}
problems = append(problems, fmt.Sprintf(
"%s: the bundle %q would be built and never reach a machine: nothing loads it, runs it or "+
"unpacks it. A tools bundle says `loads` (the entrypoints the node's runtime serves) or its "+
"module lists its `tools`; a daemon or a step is a resource naming it (novox/hq 04-ISSUES/216)",
m.Module, a.Name))
}
return problems
}
// BundleLoads is what the runtime loads of the bundle artifact a of m (run: the bundles one of m's own resources runs,
// runByAResource): what the artifact said, else every entrypoint of a module that declares tools, else nothing; a
// bundle compiled to a binary is served by its binary. The rule a build's resolution keeps on Bundle.Loads, and what a
-74
View File
@@ -1,82 +1,8 @@
package catalogue
import (
"strings"
"testing"
)
// bundleFor is a manifest whose one artifact is a bundle in the given language and system.
func bundleFor(language, system string) Manifest {
return Manifest{Module: "a-component", Build: &Build{Artifacts: []Artifact{
{Name: "binary", Kind: ArtifactBundle, Language: language, System: system},
}}}
}
func problemsOf(t *testing.T, m Manifest) string {
t.Helper()
return strings.Join(m.Build.problems(m.Module), "\n")
}
// **A language that compiles to a binary must say which system.**
//
// A binary is pinned to one operating system at link time, so a host refuses to touch a machine it
// was not built for. An artifact that says nothing would be compiled for whatever the build machine
// happened to be — which reads as portable and is not, and is the fault this check exists for.
func TestABinaryMustSayWhichSystemItIsFor(t *testing.T) {
got := problemsOf(t, bundleFor("go", ""))
if !strings.Contains(got, "says no system") {
t.Fatalf("a compiled bundle with no system was accepted:\n%s", got)
}
// And the refusal names what it could have said, so a reader is one edit from right.
for _, system := range []string{"alpine", "android", "arch"} {
if !strings.Contains(got, system) {
t.Fatalf("the refusal does not name %q as a choice:\n%s", system, got)
}
}
}
func TestABinaryThatNamesASystemIsAccepted(t *testing.T) {
if got := problemsOf(t, bundleFor("go", "arch")); got != "" {
t.Fatalf("a compiled bundle naming a system was refused:\n%s", got)
}
}
// A system the mesh does not build for is refused where it is written. These are the host's own
// names, not a compiler's: the difference between two of them is a C library rather than a kernel,
// so a value that looks like an operating system to a toolchain is still wrong here.
func TestASystemTheMeshDoesNotBuildForIsRefused(t *testing.T) {
for _, wrong := range []string{"linux", "debian", "darwin"} {
got := problemsOf(t, bundleFor("go", wrong))
if !strings.Contains(got, "and a system is") {
t.Fatalf("%q was accepted as a system:\n%s", wrong, got)
}
}
}
// **And a language that runs anywhere must not name one.** A system that decides nothing reads as
// though it did, which is the same fault as a restriction that restricts nothing (novox/hq ADR 0045).
func TestAPortableBundleMayNotNameASystem(t *testing.T) {
got := problemsOf(t, bundleFor("typescript", "arch"))
if !strings.Contains(got, "runs anywhere") {
t.Fatalf("a portable bundle was allowed to name a system:\n%s", got)
}
}
func TestAPortableBundleNamingNoSystemIsAccepted(t *testing.T) {
if got := problemsOf(t, bundleFor("typescript", "")); got != "" {
t.Fatalf("an ordinary bundle was refused:\n%s", got)
}
}
// One component, one artifact per system: the shape the mesh's own binaries are declared in, and the
// reason the target is the artifact's rather than the recipe's.
func TestOneArtifactPerSystemIsAccepted(t *testing.T) {
m := Manifest{Module: "the-host", Build: &Build{Artifacts: []Artifact{
{Name: "arch", Kind: ArtifactBundle, Language: "go", System: "arch"},
{Name: "alpine", Kind: ArtifactBundle, Language: "go", System: "alpine"},
{Name: "android", Kind: ArtifactBundle, Language: "go", System: "android"},
}}}
if got := problemsOf(t, m); got != "" {
t.Fatalf("one artifact per system was refused:\n%s", got)
}
}
+1 -82
View File
@@ -108,68 +108,8 @@ func TestAModuleThatBuildsNothingIsOrdinary(t *testing.T) {
}
}
func TestTwoArtifactsWithOneNameAreRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
{"name":"x","kind":"image","from":"Dockerfile"},
{"name":"x","kind":"archive","from":"files"}]}}`))
if err == nil {
t.Fatal("two artifacts with one name were accepted")
}
if !strings.Contains(err.Error(), "could mean either") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
func TestAnArtifactBuiltFromOutsideItsRepositoryIsRefused(t *testing.T) {
// A build reads its own repository and nothing else. A path leaving it would make what gets
// built depend on whatever happens to be on the machine doing the building.
for _, from := range []string{"/etc/passwd", "../elsewhere"} {
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
{"name":"x","kind":"archive","from":"` + from + `"}]}}`))
if err == nil {
t.Fatalf("%q was accepted as a build input", from)
}
if !strings.Contains(err.Error(), "outside its own repository") {
t.Fatalf("refused for the wrong reason: %v", err)
}
}
}
func TestAnArtifactOfAnUnknownKindIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
{"name":"x","kind":"binary","from":"main.go"}]}}`))
if err == nil {
t.Fatal("an artifact of an unknown kind was accepted")
}
}
func TestAPackageParsesLikeABundleAndNeedsALanguage(t *testing.T) {
// The SDK's shape: a package built from the module's own directory, naming a language.
m, err := ParseManifest([]byte(`{"module":"mesh-sdk","version":"1","slug":"sdk",
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
"resources":[]}`))
if err != nil {
t.Fatalf("the SDK's package manifest did not parse: %v", err)
}
if m.Build.Artifacts[0].Kind != ArtifactPackage {
t.Fatalf("expected a package artifact, got %q", m.Build.Artifacts[0].Kind)
}
// A package that names what it is built from is refused, exactly as a bundle is: it is built
// from the module's own directory.
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
{"name":"lib","kind":"package","language":"typescript","from":"Dockerfile"}]}}`)); err == nil {
t.Fatal("a package naming a source was accepted")
}
// A package with no language cannot choose a toolchain.
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
{"name":"lib","kind":"package"}]}}`)); err == nil {
t.Fatal("a package with no language was accepted")
}
}
func TestAResourceNamingAPackageIsRefused(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"a","version":"1","slug":"a",
m, err := decodeForTest([]byte(`{"module":"a","version":"1","slug":"a",
"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript"}]},
"resources":[{"id":"svc","type":"container","name":"a","artifact":"lib"}]}`))
if err != nil {
@@ -180,24 +120,3 @@ func TestAResourceNamingAPackageIsRefused(t *testing.T) {
t.Fatal("a resource backed by a package was accepted; a package is not a resource")
}
}
// An image names the Go package its recipe compiles (novox/hq ADR 0267): within the tree it is built in,
// and only an image says one.
func TestOnlyAnImageNamesThePackageItCompilesWithinItsTree(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[
{"name":"x","kind":"image","from":"Dockerfile","compiles":"cmd/x"}]}}`))
if err != nil || m.Build.Artifacts[0].Compiles != "cmd/x" {
t.Fatalf("an image naming its package was refused or lost it: %v", err)
}
for _, c := range []struct{ artifact, says string }{
{`{"name":"x","kind":"archive","from":"files","compiles":"cmd/x"}`, "Only an image"},
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"../x"}`, "leaves the tree"},
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"/x"}`, "leaves the tree"},
{`{"name":"x","kind":"image","from":"Dockerfile","compiles":"a/../../x"}`, "leaves the tree"},
} {
_, err := ParseManifest([]byte(`{"module":"a","version":"1","build":{"artifacts":[` + c.artifact + `]}}`))
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: %v, wanted a refusal saying %q", c.artifact, err, c.says)
}
}
}
+1 -50
View File
@@ -1,7 +1,6 @@
package catalogue
import (
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -10,54 +9,6 @@ import (
"git.novox.be/novox/mesh-sdk/go/internal/beside"
)
// The module holding mesh-broker is the bus, and its account is granted the bus's snapshot API and
// nothing else (novox/hq ADR 0235). Anything it declared to say or hear on the bus would be granted
// nothing, so it is refused at the parser rather than silently dropped.
func TestTheBussAccountSaysNothingOnTheBus(t *testing.T) {
raw := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
"emits":["something.happened"]}`)
_, err := ParseManifest(raw)
if err == nil || !strings.Contains(err.Error(), "granted the bus's snapshot API and nothing else") {
t.Fatalf("a bus module declaring what it emits was not refused, or not for the reason: %v", err)
}
// Its tools bundle says what it emits (novox/hq ADR 0357): the node's runtime carries the bundle and is
// granted the module's events, so a bus module that ships one may declare them.
bundled := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
"emits":["bus.slow-consumer"],"tools":["bus_streams"],
"build":{"artifacts":[{"name":"tools","kind":"bundle","language":"go","system":"arch","from":"cmd/t","binary":"t"}]}}`)
m, err := ParseManifest(bundled)
if err != nil {
t.Fatalf("a bus module whose tools bundle declares what it emits was refused: %v", err)
}
// And so does its built manifest, its bundle in Bundles and its build read away (issue 774: nats's built manifest
// was refused, and with it every plan of the control-node).
built, err := m.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, Reference: "store/tools:1", Digest: "sha256:1"}})
if err != nil {
t.Fatal(err)
}
raw, err = json.Marshal(built)
if err != nil {
t.Fatal(err)
}
if _, err := ParseManifestForInputs(raw); err != nil {
t.Fatalf("the built manifest of a bus module whose tools bundle declares what it emits was refused: %v", err)
}
quiet := []byte(`{"module":"bus","version":"1","provides":[{"name":"mesh-bus","scope":"mesh"}],
"claims":[{"name":"mesh-broker","scope":"mesh"}],"own-secrets":{"broker":"/run/broker"},
"tools":["bus_streams"]}`)
m, err = ParseManifest(quiet)
if err != nil {
t.Fatalf("a bus module with an account and tools was refused: %v", err)
}
if !m.ClaimsSeat(BrokerSeat) {
t.Fatal("it does not read as holding the broker seat")
}
}
// The catalogue's bus protects its streams by the snapshot, not as live files: its streams' item is a
// dump into its snapshots, it has the account the dump runs as, and the dump runs the snapshot
// program in the bus's own container.
@@ -66,7 +17,7 @@ func TestTheCataloguesBusIsBackedUpBySnapshot(t *testing.T) {
if err != nil {
t.Fatal(err)
}
m, err := ParseManifest(raw)
m, err := decodeForTest(raw)
if err != nil {
t.Fatal(err)
}
-28
View File
@@ -80,34 +80,6 @@ func ReadBusy(r map[string]any) (Busy, bool, []string) {
return b, true, problems
}
// busyProblems is everything wrong with a manifest's `busy` fields, in the manifest's words: a command on
// one line, a timeout within its bounds, and only on a container that stays up.
func busyProblems(m Manifest) []string {
var problems []string
for _, r := range m.Resources {
b, has, read := ReadBusy(r)
if !has {
continue
}
where := m.Module + ": " + fmt.Sprint(r["id"])
for _, p := range read {
problems = append(problems, m.Module+": "+p)
}
if fmt.Sprint(r["type"]) != "container" || !LongRunning(r) {
problems = append(problems, fmt.Sprintf("%s declares busy and is not a container that stays up: busy is "+
"asked of a running container before it is replaced (ADR 0335)", where))
}
if b.Command == "" {
problems = append(problems, where+"'s busy names no command: one run inside the container, exiting 0 "+
"while it is busy")
}
if strings.ContainsAny(b.Command, "\n\r") {
problems = append(problems, where+"'s busy command is one line")
}
}
return problems
}
// busyInto composes a container's `busy` into the node-engine's words, or takes it away: for an engine that
// reads it, the command and its timeout written out; for one that does not, the field is not sent, and the
// container is replaced at once, as before.
+1 -29
View File
@@ -2,7 +2,6 @@ package catalogue
import (
"encoding/json"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/validate"
@@ -32,35 +31,8 @@ func busyManifest(t *testing.T, on string, busy any) []byte {
return out
}
func TestABusyIsHeldToItsShape(t *testing.T) {
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"})); err != nil {
t.Fatalf("a well-formed busy was refused: %v", err)
}
if _, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "x", "timeout": "30s"})); err != nil {
t.Fatalf("a busy with its timeout was refused: %v", err)
}
for name, c := range map[string]struct {
on string
busy any
says string
}{
"text": {"agent", "mesh-builder busy", "it is an object"},
"no command": {"agent", map[string]any{"timeout": "5s"}, "names no command"},
"two lines": {"agent", map[string]any{"command": "a\nb"}, "one line"},
"a long timeout": {"agent", map[string]any{"command": "x", "timeout": "5m"}, "at most 1m0s"},
"an unknown key": {"agent", map[string]any{"command": "x", "every": "1m"}, `says "every"`},
"on a step": {"seed", map[string]any{"command": "x"}, "not a container that stays up"},
"on a service": {"daemon", map[string]any{"command": "x"}, "not a container that stays up"},
} {
_, err := ParseManifest(busyManifest(t, c.on, c.busy))
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: want a refusal saying %q, got %v", name, c.says, err)
}
}
}
func TestABusyIsSentOnlyToAnEngineThatReadsIt(t *testing.T) {
m, err := ParseManifest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
m, err := decodeForTest(busyManifest(t, "agent", map[string]any{"command": "mesh-builder busy"}))
if err != nil {
t.Fatal(err)
}
-76
View File
@@ -2,7 +2,6 @@ package catalogue
import (
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/internal/beside"
@@ -45,78 +44,3 @@ func TestEveryCatalogueManifestParses(t *testing.T) {
t.Fatal("no endpoint in the catalogue is named, so this proved nothing")
}
}
// TestNoCatalogueManifestNamesAnInstallation is ADR 0112's check, run over the real catalogue: no
// definition names a domain or a public address the mesh acts on, and every value that must for now
// carries its reason (novox/hq ADR 0155, issue 134). The list it prints is the one that shrinks.
func TestNoCatalogueManifestNamesAnInstallation(t *testing.T) {
_, found := catalogueManifests(t)
var named []string
for _, r := range found {
m, err := r.manifest, r.err
if err != nil {
t.Errorf("%s: %v", r.path, err)
continue
}
named = append(named, InstallationProblems(m)...)
}
if len(named) > 0 {
t.Fatalf("%d value(s) name an installation:\n %s", len(named), strings.Join(named, "\n "))
}
}
// TestEveryCatalogueModuleDeclaresItsData is ADR 0233's check over the real catalogue (it replaced ADR
// 0214's store list): every provider that grants says what it keeps for its consumers, nothing writes a
// backup line by hand, every directory a container writes is declared, and every irreplaceable item is
// backed up — so a store added is a store backed up, without a list of stores to keep in step.
func TestEveryCatalogueModuleDeclaresItsData(t *testing.T) {
_, found := catalogueManifests(t)
shelf := Shelf{}
granting := 0
for _, r := range found {
m, err := r.manifest, r.err
if err != nil {
continue // TestEveryCatalogueManifestParses says why
}
if len(m.Grants) > 0 {
granting++
}
shelf[m.Module] = m
}
for _, problem := range DataProblems(shelf) {
t.Error(problem)
}
if granting == 0 {
t.Fatal("no module in the catalogue grants a provision, so this proved nothing")
}
}
// TestEveryCatalogueIdentityFitsWhatItRequires is ADR 0225's check over the real catalogue: every
// module's identity, on the longest machine name, fits the bound of every provision it wants. An
// overflow fails here, in the pull request that introduces it, rather than on the provider's machine
// the first time a real machine's name meets the module's (issue 263).
func TestEveryCatalogueIdentityFitsWhatItRequires(t *testing.T) {
_, found := catalogueManifests(t)
shelf := Shelf{}
for _, r := range found {
m, err := r.manifest, r.err
if err != nil {
t.Fatalf("%s: %v", r.path, err)
}
shelf[m.Module] = m
}
for _, p := range IdentityProblems(shelf, DefaultLongestMachine) {
t.Error(p)
}
// The night it was found: the resolver provision bounds nothing, and the object store still 20.
if dns, ok := shelf["dnsmasq"]; ok {
if b := dns.IdentityBoundOf("wildcard-resolution"); b.Bounded() {
t.Errorf("the resolver provision bounds its consumers' identities: %+v", b)
}
}
if store, ok := shelf["minio"]; ok {
if b := store.IdentityBoundOf("s3-bucket"); b.Max != 20 {
t.Errorf("the object store's access key is not bounded at 20: %+v", b)
}
}
}
+1 -210
View File
@@ -113,12 +113,7 @@ var oldCollectionNamespaces = []string{"shell", "environment", "contribution"}
var (
// ofCollected is where a collector places a collection. Loose inside the braces, so a misspelt name is refused.
ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
// collectionName is words joined by colons, so an old namespace's placeholder names its collection as it reads.
collectionName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*(:[a-z0-9][a-z0-9-]*)*$`)
// itemName, attributeKey and attributeValue are the words an item's name and attributes are made of; none holds
// a colon, a comma or an equals sign, which tell a selector from a collection's name.
itemName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
ofCollected = regexp.MustCompile(`\$\{collected:([^}]*)\}`)
attributeKey = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
attributeValue = regexp.MustCompile(`^[a-z0-9][a-z0-9_.-]*$`)
)
@@ -242,190 +237,6 @@ func placerOfOld(name string) string {
return canonicalSeat(seat)
}
// collectionProblems is what is wrong with a module's collections and items, from the manifest alone.
func collectionProblems(m Manifest) []string {
var problems []string
declared := map[string]bool{}
for i, c := range m.Collections {
switch {
case !collectionName.MatchString(c.Name):
problems = append(problems, fmt.Sprintf("%s's collection %d is %q, which is not a collection's name: "+
"words of lower-case letters, digits and hyphens, joined by colons", m.Module, i+1, c.Name))
case declared[c.Name]:
problems = append(problems, fmt.Sprintf("%s collects %s twice", m.Module, c.Name))
}
if _, old, _ := oldCollection(c.Name, false); old {
problems = append(problems, fmt.Sprintf("%s collects %s, which is read from its placeholder: a module "+
"places it as ${%s} where it holds the seat that places it, and declares nothing", m.Module, c.Name,
c.Name))
}
if strings.ContainsAny(c.Comment, "\n\r") {
problems = append(problems, fmt.Sprintf("%s's collection %s has a comment holding a line break",
m.Module, c.Name))
}
declared[c.Name] = true
}
// A collection declared and placed nowhere is a word read by nothing; judged where every file's text is here.
if !m.Templated() {
named := map[string]bool{}
for _, r := range m.Resources {
if text, ok := r["content"].(string); ok {
for _, f := range ofCollected.FindAllStringSubmatch(text, -1) {
base, _ := splitSelector(f[1])
named[base] = true
}
}
}
for _, c := range m.Collections {
if !named[c.Name] && collectionName.MatchString(c.Name) {
problems = append(problems, fmt.Sprintf("%s collects %s and no file of its places it as "+
"${collected:%s}", m.Module, c.Name, c.Name))
}
}
}
contributed := map[string]bool{}
for i, it := range m.Items {
where := fmt.Sprintf("%s's item %d", m.Module, i+1)
if !collectionName.MatchString(it.Collection) {
problems = append(problems, fmt.Sprintf("%s is to %q, which is not a collection's name", where,
it.Collection))
} else if _, old, wrong := oldCollection(it.Collection, true); old && wrong != nil {
problems = append(problems, fmt.Sprintf("%s: %v", where, wrong))
}
switch {
case it.Name != "" && !itemName.MatchString(it.Name):
problems = append(problems, fmt.Sprintf("%s: the name %q is not a name: lower-case letters, digits and "+
"hyphens", where, it.Name))
case contributed[it.key()]:
problems = append(problems, fmt.Sprintf("%s contributes to %s twice; a module contributes one item to "+
"a collection, or several each with a name of its own", m.Module, it.key()))
case it.Name == "" && slices.ContainsFunc(m.Items, func(o Item) bool {
return o.Collection == it.Collection && o.Name != ""
}):
problems = append(problems, fmt.Sprintf("%s contributes several items to %s and %s has no name; each "+
"of several items has a name of its own", m.Module, it.Collection, where))
}
contributed[it.key()] = true
problems = append(problems, itemAttributeProblems(m.Module, where, it)...)
if it.Collection == EnvironmentCollection {
problems = append(problems, environmentItemProblems(where, it)...)
}
switch {
case it.Template == "" && it.Content == "":
problems = append(problems, fmt.Sprintf("%s names no template: an item is a template of the module's "+
"own, \"template\": \"%s/<name>\" (novox/hq ADR 0349 §13)", where, m.TemplatesDir()))
case it.Template != "" && it.Content != "":
problems = append(problems, fmt.Sprintf("%s names its template and says its content too", where))
case it.Template != "":
if wrong := itemTemplateProblem(m, it.Template); wrong != "" {
problems = append(problems, fmt.Sprintf("%s: %s", where, wrong))
}
// **Filled by the provisioner's client alone**: the controller renders no item.
if !m.Moved {
problems = append(problems, fmt.Sprintf("%s: an item is filled by the provisioner's client of the "+
"node its contributor is placed on, so a module contributing one is marked moved "+
"(\"moved\": true; novox/hq ADR 0349 §13, §17)", where))
}
}
if it.Content != "" {
problems = append(problems, itemTextProblems(m.Module, it)...)
}
}
return problems
}
// itemAttributeProblems is what is wrong with an item's attributes and capability. The attributes of an item of a kind
// received as data are the fields its shape places by (and `order`), each with a value the shape takes.
func itemAttributeProblems(module, where string, it Item) []string {
var problems []string
for _, k := range sortedKeys(it.Attributes) {
v := it.Attributes[k]
switch {
case !attributeKey.MatchString(k):
problems = append(problems, fmt.Sprintf("%s: the attribute %q is not a name: lower-case letters, digits "+
"and hyphens", where, k))
continue
case !attributeFits(it.Collection, k, v):
problems = append(problems, fmt.Sprintf("%s: %s=%q is not a value: lower-case letters, digits, hyphens, "+
"dots and underscores (an environment variable's name, letters, digits and underscores)", where, k, v))
continue
}
if n, err := strconv.Atoi(v); k == OrderAttribute && (err != nil || n < 0 || n > 99) {
problems = append(problems, fmt.Sprintf("%s: order=%q is not a whole number from 0 to 99", where, v))
}
}
if namespace, key, _ := strings.Cut(it.Collection, ":"); namespace == "contribution" {
seat, kind, _ := strings.Cut(key, ":")
if _, r, ok := receivable(seat, kind); ok && r.Shape != nil {
for _, k := range sortedKeys(it.Attributes) {
var place *PlaceField
for i := range r.Shape.Places {
if r.Shape.Places[i].Field == k {
place = &r.Shape.Places[i]
}
}
switch {
case k == OrderAttribute:
case place == nil:
problems = append(problems, fmt.Sprintf("%s: %s is placed by %s, and %q is none of them", where,
it.Collection, placeFields(r), k))
case !oneOf(place.Values, it.Attributes[k]):
problems = append(problems, fmt.Sprintf("%s: %s=%s; %s is one of %s", where, k, it.Attributes[k], k,
strings.Join(place.Values, ", ")))
}
}
}
}
switch {
case it.IfCapability == "":
case !capabilityName.MatchString(it.IfCapability):
problems = append(problems, fmt.Sprintf("%s: if-capability %q is not a capability's name", where, it.IfCapability))
case !oneOf(KnownCapabilities, it.IfCapability):
problems = append(problems, fmt.Sprintf("%s: if-capability %q is one no machine reports; the node-engine "+
"detects %s", where, it.IfCapability, strings.Join(KnownCapabilities, ", ")))
}
return problems
}
// AttributeFits is whether an item's attribute is of the shape its collection allows: words of lower case, except
// the environment's `name`, which is a variable's name (design 57 L3, issue 795). The same rule is mesh-catalog's
// provisioner client's, and the inventory's.
func attributeFits(collection, k, v string) bool {
if collection == EnvironmentCollection && k == "name" {
return variableName.MatchString(v)
}
return attributeKey.MatchString(k) && attributeValue.MatchString(v)
}
// environmentItemProblems is what is wrong with an item of the environment collection: typed by `kind`, a variable
// with its `name`, a PATH entry `at` the start or the end, and no attribute besides `order`.
func environmentItemProblems(where string, it Item) []string {
var problems []string
for _, k := range sortedKeys(it.Attributes) {
if !slices.Contains([]string{"kind", "name", "at", OrderAttribute}, k) {
problems = append(problems, fmt.Sprintf("%s: %s is typed by kind, name and at, and %q is none of them",
where, it.Collection, k))
}
}
switch kind := it.Attributes["kind"]; kind {
case "variable":
if it.Attributes["name"] == "" || it.Attributes["at"] != "" {
problems = append(problems, fmt.Sprintf("%s: a variable names itself with name=<NAME> and has no at", where))
} else if it.Attributes["name"] == "PATH" {
problems = append(problems, fmt.Sprintf("%s: PATH is not a variable; a PATH entry is kind=path", where))
}
case "path":
if it.Attributes["name"] != "" || (it.Attributes["at"] != PathAtStart && it.Attributes["at"] != PathAtEnd) {
problems = append(problems, fmt.Sprintf("%s: a PATH entry is at=%s or at=%s and has no name", where,
PathAtStart, PathAtEnd))
}
default:
problems = append(problems, fmt.Sprintf("%s: an item of %s is kind=variable or kind=path, not %q", where,
it.Collection, kind))
}
return problems
}
// itemTemplateProblem is what is wrong with an item's template path, or "".
func itemTemplateProblem(m Manifest, name string) string {
dir := m.TemplatesDir()
@@ -437,26 +248,6 @@ func itemTemplateProblem(m Manifest, name string) string {
return ""
}
// itemTextProblems is what an item's text may not name: a secret or a bound value, since an item is kept in a
// bucket and no secret is (ADR 0349 clause 7), and a collection, since an item is not a collector. And every
// placeholder no pass fills, as in a file's content.
func itemTextProblems(module string, it Item) []string {
var problems []string
for _, f := range templatePlaceholder.FindAllStringSubmatch(it.Content, -1) {
namespace, key := f[1], f[2]
switch _, collection := collectionOfPlaceholder(namespace, key); {
case namespace == "secret" || namespace == "bound":
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is kept in a bucket, where "+
"no secret is (novox/hq ADR 0349 §7)", module, it.Collection, f[0]))
case collection:
problems = append(problems, fmt.Sprintf("%s's item of %s names %s: an item is not a collector",
module, it.Collection, f[0]))
}
}
return append(problems, unconsumedPlaceholders(module, map[string]any{"id": "item:" + it.key(),
"type": "file", "content": it.Content})...)
}
// isOld is whether a collection's name is an old namespace's.
func isOld(name string) bool {
_, old, _ := oldCollection(name, true)
+1 -28
View File
@@ -69,7 +69,7 @@ func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testi
raw := strings.Replace(barCollector, `"template":"files/bars"`, `"content":"`+placeholder+`"`, 1)
raw = strings.Replace(raw, `"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},`, "", 1)
raw = strings.Replace(raw, `"moved":true,`, "", 1)
m, err := ParseManifest([]byte(raw))
m, err := decodeForTest([]byte(raw))
if err == nil {
_, err = (Resolution{Node: "n", Modules: []Manifest{m}}).Declaration(Rendering{})
}
@@ -82,33 +82,6 @@ func TestASelectorIsRefusedWhenMalformedOrNamingACollectionNotCollected(t *testi
}
}
func TestItemNamesAttributesAndCapabilitiesAreJudged(t *testing.T) {
item := func(extra string) string {
return `{"module":"p","moved":true,"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},` +
`"items":[` + extra + `]}`
}
for items, want := range map[string]string{
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","template":"files/b"}`: "has no name",
`{"collection":"c","name":"a","template":"files/a"},{"collection":"c","name":"a","template":"files/b"}`: "twice",
`{"collection":"c","name":"Big","template":"files/a"}`: "is not a name",
`{"collection":"c","attributes":{"order":"100"},"template":"files/a"}`: "0 to 99",
`{"collection":"c","attributes":{"bar":"a b"},"template":"files/a"}`: "is not a value",
`{"collection":"c","if-capability":"batery","template":"files/a"}`: "no machine reports",
`{"collection":"contribution:node-bar:block","attributes":{"bar":"left"},"template":"files/a"}`: "bar=left",
`{"collection":"contribution:node-bar:block","attributes":{"colour":"red"},"template":"files/a"}`: `"colour" is none of them`,
`{"collection":"c","name":"a","template":"files/a","colour":"red"}`: "colour",
} {
_, err := ParseManifest([]byte(item(items)))
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s: %v, want %q", items, err, want)
}
}
ok := item(`{"collection":"contribution:node-bar:block","name":"a","attributes":{"bar":"top","place":"status","order":"5"},"if-capability":"battery","template":"files/a"}`)
if _, err := ParseManifest([]byte(ok)); err != nil {
t.Fatal(err)
}
}
// A moved contributor's items, selected by the old placeholder's pair, render in the old order (order, module, item
// name) and with the same lines as the contributions they replace.
func TestASelectedMovedItemRendersIntoTheOldBarPlaceholderAsTheContributionDid(t *testing.T) {
+1 -37
View File
@@ -37,7 +37,7 @@ func collectionsOn(t *testing.T) Resolution {
t.Helper()
zsh := held(t, collectorZsh, builtWith("zsh", map[string]string{"zshrc": zshrcText}))
prompt := held(t, contributorP10k, builtWith("prompt", map[string]string{"zsh": contributorText}))
aliases, err := ParseManifest([]byte(oldFormAliases))
aliases, err := decodeForTest([]byte(oldFormAliases))
if err != nil {
t.Fatal(err)
}
@@ -141,39 +141,3 @@ func TestAMovedContributorsItemRendersAsItsOldFormDid(t *testing.T) {
}
}
}
func TestCollectionsAndItemsAreJudged(t *testing.T) {
for name, c := range map[string]struct{ raw, says string }{
"an item of a module not moved": {`{"module":"m","version":"1",
"items":[{"collection":"shell:zsh:normal","template":"files/x"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is marked moved"},
"two items to one collection": {`{"module":"m","version":"1","moved":true,
"items":[{"collection":"a","template":"files/x"},{"collection":"a","template":"files/y"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "contributes to a twice"},
"an item to no slot": {`{"module":"m","version":"1","moved":true,
"items":[{"collection":"shell:zsh:middle","template":"files/x"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is no slot"},
"an item outside the templates": {`{"module":"m","version":"1","moved":true,
"items":[{"collection":"a","template":"x"}],
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]}}`, "is not a file under files/"},
"an item naming a secret": {`{"module":"m","version":"1",
"items":[{"collection":"a","content":"t=${secret:token}"}]}`, "where no secret is"},
"a collection not declared": {`{"module":"m","version":"1",
"resources":[{"id":"f","type":"file","path":"/f","content":"${collected:a}"}]}`, "does not collect"},
"an old namespace declared": {`{"module":"m","version":"1","collections":[{"name":"shell:zsh:normal"}],
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "read from its placeholder"},
"a collection placed nowhere": {`{"module":"m","version":"1","collections":[{"name":"a"}],
"resources":[{"id":"f","type":"file","path":"/f","content":"x"}]}`, "no file of its places it"},
"a collection in a path": {`{"module":"m","version":"1","collections":[{"name":"a"}],
"resources":[{"id":"f","type":"file","path":"/${collected:a}","content":"${collected:a}"}]}`,
"only in a file's content"},
} {
_, err := ParseManifest([]byte(c.raw))
if err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%s: %v, want it to say %q", name, err, c.says)
}
}
if _, err := ParseManifest([]byte(collectorZsh)); err != nil {
t.Errorf("a collector: %v", err)
}
}
-13
View File
@@ -104,19 +104,6 @@ func TestAGeneratorThisControlPlaneDoesNotHaveIsRefused(t *testing.T) {
}
}
func TestAModuleIsEitherWrittenOrComputedNotBoth(t *testing.T) {
// Otherwise nobody could say where a given file on a machine came from.
_, err := ParseManifest([]byte(`{"module":"mesh-network","version":"1",
"computed":"mesh-network",
"resources":[{"id":"a","type":"package","package":"wireguard-tools"}]}`))
if err == nil {
t.Fatal("a module that both ships files and has them computed was accepted")
}
if !strings.Contains(err.Error(), "one or the other") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
func TestSettingsApplyToAComputedModuleToo(t *testing.T) {
// Being computed is about where the files come from, not about whether they are configurable.
// A line drawn there would be arbitrary and nobody could predict it.
-266
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"regexp"
"slices"
"strings"
)
// A module declares its config (novox/hq ADR 0348 §1, §4, §5, §6, §8, §11; design 57 package K1, issue 660).
@@ -123,26 +122,6 @@ func toConfigForm(raw []byte) ([]byte, error) {
return raw, nil
}
// inConfigWords is a refusal said of a module in the config form in its words: `${config:<key>}` and
// takes-config, where the rules shared with the settings form say `${setting:<key>}` and takes-settings.
func inConfigWords(problem string) string {
problem = settingRef.ReplaceAllStringFunc(problem, func(ref string) string {
return "${config:" + settingRef.FindStringSubmatch(ref)[1] + "}"
})
return strings.ReplaceAll(problem, TakesSettingsField, TakesConfigField)
}
// MixedFormProblems is a manifest using both forms, said with the words of each (ADR 0348 §1).
func MixedFormProblems(m Manifest) []string {
if len(m.forms.config) == 0 || len(m.forms.settings) == 0 {
return nil
}
return []string{fmt.Sprintf("%s mixes the config form (%s) with the settings form (%s): a module uses one form "+
"or the other, never both; write %s, %s and ${config:<key>} throughout (novox/hq ADR 0348 §1)",
m.Module, strings.Join(m.forms.config, ", "), strings.Join(m.forms.settings, ", "),
ConfigField, TakesConfigField)}
}
// configDeclaration is the config form's declaration as written: nothing of the settings form's, and nothing
// unsaid.
type configDeclaration struct {
@@ -171,166 +150,6 @@ func (d SettingDeclaration) MarshalJSON() ([]byte, error) {
Default: d.Default, Required: d.Required, Runtime: d.Runtime})
}
// configShaped is whether a declaration says anything of the config form's.
func configShaped(d SettingDeclaration) bool {
return d.Type != "" || d.Description != "" || len(d.Enum) > 0 || d.Minimum != nil || d.Maximum != nil ||
d.Pattern != "" || d.Trusted || d.Predefined != "" || d.Required || d.Runtime
}
// TypedSettingProblems is a declaration under `settings` that says what only `config` says: a value with a type,
// a description and its validation is declared in the config form (ADR 0348 §1, §4).
func TypedSettingProblems(m Manifest) []string {
var problems []string
for _, key := range sortedSettingKeys(m.Settings) {
if configShaped(m.Settings[key]) {
problems = append(problems, fmt.Sprintf("%s's setting %q says its type, description or validation "+
"under %s: a value declared with those is declared under config, with ${config:<key>} and "+
"takes-config, never under %s (novox/hq ADR 0348 §1, §4)", m.Module, key, SettingsField, SettingsField))
}
}
return problems
}
// ConfigProblems is every way a module in the config form declares its config wrongly, each said by name with
// what to do (ADR 0348 How it is checked, design 57 package K1).
func ConfigProblems(m Manifest) []string {
if !m.DeclaresConfig() {
return nil
}
var problems []string
placed := configPlaced(m)
fallsBack := fileFallsBack(m)
renamed := renamedTaken(m)
for _, key := range sortedSettingKeys(m.Settings) {
d := m.Settings[key]
say := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf("%s's config value %q: ", m.Module, key)+fmt.Sprintf(format, args...))
}
if !settingRef.MatchString("${setting:" + key + "}") {
say("not a usable key: lower-case letters, digits, dots, dashes and underscores")
continue
}
if meshWords[key] || accountFacts[key] {
say("%q is placement, which the mesh decides, never a config value (novox/hq ADR 0348 §11)", key)
continue
}
if d.Kind != "" || d.Why != "" {
say("kind and why are the settings form's; under config a value says its type and its description " +
"(novox/hq ADR 0348 §1, §4)")
}
switch d.Type {
case ConfigNumber, ConfigString, ConfigBoolean:
case ConfigObject, ConfigList:
if !d.Trusted {
say("is %s and not trusted: an object or a list decides more than one value can say, so it is "+
"trusted, \"trusted\": true, or split into numbers, strings and booleans (novox/hq ADR 0348 §4)", d.Type)
}
case "":
say("no type: say \"type\": number, string or boolean, or object or list when trusted (novox/hq ADR 0348 §4)")
default:
say("type %q is none of number, string, boolean, object and list (novox/hq ADR 0348 §4)", d.Type)
}
if strings.TrimSpace(d.Description) == "" {
say("no description: say in a sentence what the value is, including its unit (novox/hq ADR 0348 §4)")
}
switch {
case d.Required && d.Default != nil:
say("both a default and required: a required value is the operator's own and has no default; give " +
"one or the other (novox/hq ADR 0348 §4)")
case !d.Required && d.Default == nil:
say("neither a default nor required: give a default, or say \"required\": true for a value that is " +
"the operator's own (novox/hq ADR 0348 §4)")
}
if as := renamed[key]; as != "" && d.Required {
say("is laid into its file as %q, and only when it is set, so the file and its code fall back when "+
"it is not: it is never required; give it the file's default, the empty value of its type "+
"(novox/hq issue 751)", as)
}
for _, wrong := range validationProblems(d) {
say("%s", wrong)
}
if word := operatorsWord(key); word != "" && d.Default != nil && !fallsBack[key] {
say("a key naming %q is the operator's value: declare it \"required\": true, with no default, "+
"unless a mergeable file takes it and no placeholder asks for it, where the file falls back "+
"(novox/hq ADR 0348 §4, ADR 0112, issue 735)", word)
}
if d.Predefined != "" {
p, listed := m.predefined[d.Predefined]
switch {
case m.predefined == nil:
say("falls back to the predefined key %q, and the list of predefined keys (%s at the root of "+
"the catalogue's repository) is not present where it is judged: a value falls back only to a "+
"key on that list; judge it in the catalogue, or leave \"predefined\" out (novox/hq ADR 0348 §7)",
d.Predefined, PredefinedFile)
case !listed:
say("falls back to %q, which is not on the list of predefined keys: name a key on it, or leave "+
"\"predefined\" out (novox/hq ADR 0348 §7)", d.Predefined)
default:
if p.Type != d.Type {
say("falls back to the predefined key %q, which is a %s, and this value is a %s: a value falls "+
"back only to a key of its own type (novox/hq ADR 0348 §4)", d.Predefined, p.Type, d.Type)
}
if p.Trusted && !d.Trusted {
say("falls back to the trusted predefined key %q and is not trusted itself: a value that falls "+
"back to a trusted key is trusted, \"trusted\": true (novox/hq ADR 0348 §8)", d.Predefined)
}
}
}
// A template's text is not here yet. Resolve fills it from the build, WithTemplatesFrom from the tree; neither
// judges again itself, but WithTemplatesFrom parses its result again, and a resolved build is parsed again
// where it is registered, and this is judged then.
if !placed[key] && !d.Runtime && !m.Templated() {
say("placed nowhere: no file says ${config:%s} or takes it under %s, and it is not marked "+
"\"runtime\": true for a value the module's code reads itself (novox/hq ADR 0348 §5)", key, TakesConfigField)
}
}
used := settingKeysUsedBy(m)
for _, key := range sortedKeys(used) {
if _, declared := m.Settings[key]; !declared {
problems = append(problems, fmt.Sprintf("%s says ${config:%s} and declares no such value under %s: "+
"declare %q with its type, description and a default or required (novox/hq ADR 0348 §4, §5)",
m.Module, key, ConfigField, key))
}
}
problems = append(problems, configTakesProblems(m)...)
problems = append(problems, reflectsProblems(m)...)
return problems
}
// configPlaced is every value a file, a unit, a directory's owner, a contribution or a served fact asks for, and
// every value a mergeable file takes.
func configPlaced(m Manifest) map[string]bool {
placed := settingKeysUsedBy(m)
for _, r := range m.Resources {
for _, k := range takenKeyList(r) {
placed[k] = true
}
}
return placed
}
// fileFallsBack is every value a mergeable file takes and no placeholder asks for. Unset, such a value is simply
// not laid over the file: the file's own content, or the module's code reading the file, falls back. So one named
// after the operator's value (a url, a user, an address) is not `required`, and its default is the file's: its
// value in the content, else the empty value of its type, never laid over the file (the operator's decision on
// issue 664, issue 735). A placeholder has no such fallback: its default would be filled in where the operator's
// value belongs (ADR 0112).
func fileFallsBack(m Manifest) map[string]bool {
asked := settingKeysUsedBy(m)
out := map[string]bool{}
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how == "" {
continue
}
for _, k := range takenKeyList(r) {
if !asked[k] {
out[k] = true
}
}
}
return out
}
// renamedTaken is every value a mergeable file takes under a name of its own, with that name (issue 751).
func renamedTaken(m Manifest) map[string]string {
out := map[string]string{}
@@ -417,88 +236,3 @@ func ofType(v any, kind string) bool {
}
return false
}
// configTakesProblems is what ADR 0348 §11 adds for a module in the config form: an account fact in
// takes-config (the mesh's own words are refused for both forms by TakenSettingsProblems), and a mergeable file
// naming no keys where none of the module's does (where one does, TakenSettingsProblems names every one that
// does not).
func configTakesProblems(m Manifest) []string {
var problems, silent []string
naming := false
for _, r := range m.Resources {
if how, _ := r["merge"].(string); how == "" {
continue
}
if _, said := r[TakesSettingsField]; !said {
silent = append(silent, fmt.Sprint(r["id"]))
continue
}
naming = true
for _, k := range takenKeyList(r) {
_, declared := m.Settings[k]
switch {
case accountFacts[k]:
problems = append(problems, fmt.Sprintf("%s's %v names %q in %s, and %q is an account fact the "+
"mesh places, never a config value (novox/hq ADR 0348 §11)", m.Module, r["id"], k,
TakesConfigField, k))
case meshWords[k]:
// Refused for both forms by TakenSettingsProblems, in its words.
case !declared:
problems = append(problems, fmt.Sprintf("%s's %v names %q in %s and declares no such value under "+
"%s: a value a file takes is declared like every other, with its type, description and a "+
"default or required, or the config verb could never set it (novox/hq ADR 0348 §4, §11)",
m.Module, r["id"], k, TakesConfigField, ConfigField))
}
}
}
if !naming {
for _, id := range silent {
problems = append(problems, fmt.Sprintf("%s's mergeable %s names no %s: a module that declares config "+
"names the keys each mergeable file takes, an empty list for none (novox/hq ADR 0348 §11)",
m.Module, id, TakesConfigField))
}
}
return problems
}
// meshMadeIDs are the resources the mesh composes into a module's own set, which its `restart-on` may name
// without declaring them; meshMadePrefixes are those named after what they are for.
var (
meshMadeIDs = []string{ComposedJailsID(), FilteringID(), CertificateID(), AuthorityID(), BusUsersID()}
meshMadePrefixes = []string{NeedID(""), SecretID(""), strings.TrimSuffix(GrantID("", ""), "-"), BoundID(""), ReceivedID(""), AccessID("/")}
)
// reflectsProblems is a `reload-on` or `restart-on` naming a file the module does not declare (ADR 0348 §6): not
// one of its own resources, not one the mesh composes for it, and not another module's, which is named
// `<module>.<id>`.
func reflectsProblems(m Manifest) []string {
own := map[string]bool{}
for _, r := range m.Resources {
own[fmt.Sprint(r["id"])] = true
}
meshMade := func(id string) bool {
if slices.Contains(meshMadeIDs, id) {
return true
}
for _, p := range meshMadePrefixes {
if strings.HasPrefix(id, p) && len(id) > len(p) {
return true
}
}
return false
}
var problems []string
for _, r := range m.Resources {
for _, field := range []string{"reload-on", "restart-on"} {
for _, id := range stringsOf(r[field]) {
if strings.Contains(id, ".") || own[id] || meshMade(id) {
continue
}
problems = append(problems, fmt.Sprintf("%s's %v names %q in its %s, and %s declares no such "+
"file: name a file of its own, or another module's as <module>.<id> (novox/hq ADR 0348 §6)",
m.Module, r["id"], id, field, m.Module))
}
}
}
return problems
}
+1 -37
View File
@@ -143,43 +143,7 @@ func TestARenamedEntryIsWrittenBackAsItWasSaid(t *testing.T) {
if !strings.Contains(string(raw), `"takes-config":["position","theme",{"as":"NOTIFIER_URL","key":"url"}]`) {
t.Fatalf("written back as %s", raw)
}
if _, err := ParseManifest(raw); err != nil {
if _, err := decodeForTest(raw); err != nil {
t.Fatalf("read again: %v", err)
}
}
func TestARenamedEntryIsRefusedWhereItCouldNotHold(t *testing.T) {
entry := func(m map[string]any, e any) map[string]any {
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", e}
return m
}
refused(t, entry(renaming(), map[string]any{"key": "url"}), "notifier's rules", `"key" and "as"`)
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL", "when": "set"}), `"key" and "as"`)
refused(t, entry(renaming(), map[string]any{"key": "URL", "as": "NOTIFIER_URL"}), "not a usable key")
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER URL"}), "not a usable name")
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "endpoints"}), `"endpoints"`, "the mesh reads")
refused(t, entry(renaming(), map[string]any{"key": "url", "as": "theme"}), `"theme"`, "twice")
refused(t, entry(renaming(), map[string]any{"key": "theme", "as": "THEME"}), `"theme"`, "twice")
refused(t, entry(renaming(), map[string]any{"key": "nothing", "as": "NOTHING"}), `"nothing"`, "declares no such value")
kept := entry(renaming(), map[string]any{"key": "url", "as": "NOTIFIER_URL"})
resourceOf(kept, "rules")["protected"] = []any{"NOTIFIER_URL"}
refused(t, kept, `"NOTIFIER_URL"`, "protected")
// Laid only when set, so the code's own fallback holds: never required.
required := renaming()
declaration(required, "url")["required"] = true
delete(declaration(required, "url"), "default")
refused(t, required, `"url"`, "only when it is set", "never required")
}
func TestARenamedEntryIsTheConfigFormsOnly(t *testing.T) {
m := map[string]any{
"module": "notifier", "version": "1",
"resources": []any{
map[string]any{"id": "rules", "type": "file", "path": "/x/rules.json", "merge": "json", "content": "{}",
"takes-settings": []any{map[string]any{"key": "url", "as": "NOTIFIER_URL"}}},
},
}
refused(t, m, "notifier's rules", "config form")
}
+3 -234
View File
@@ -63,7 +63,7 @@ func parseRaw(t *testing.T, m map[string]any) (Manifest, error) {
if err != nil {
t.Fatal(err)
}
return ParseManifest(raw)
return decodeForTest(raw)
}
// refused is that the manifest is refused with every one of these words.
@@ -97,66 +97,6 @@ func TestAModuleInTheConfigFormRegisters(t *testing.T) {
}
}
// Rule 1: a value without type or description.
func TestConfigRefusesAValueWithoutTypeOrDescription(t *testing.T) {
m := inConfigForm()
delete(declaration(m, "width"), "type")
refused(t, m, `notifier's config value "width"`, "no type")
m = inConfigForm()
declaration(m, "width")["type"] = "colour"
refused(t, m, `"width"`, `type "colour"`)
m = inConfigForm()
delete(declaration(m, "width"), "description")
refused(t, m, `"width"`, "no description")
}
// Rule 2: a value with neither a default nor required.
func TestConfigRefusesAValueWithNeitherDefaultNorRequired(t *testing.T) {
m := inConfigForm()
delete(declaration(m, "width"), "default")
refused(t, m, `"width"`, "neither a default nor required")
m = inConfigForm()
declaration(m, "sender")["default"] = "noreply@example.org"
refused(t, m, `"sender"`, "both a default and required")
}
// Rule 3: a default failing its validation.
func TestConfigRefusesADefaultFailingItsValidation(t *testing.T) {
for _, c := range []struct {
key string
value any
said string
}{
{"font-size", 4, "below its minimum"},
{"font-size", 41, "above its maximum"},
{"font-size", "10", "not a number"},
{"position", "bottom", "not one of"},
{"theme", "Dark", "does not match"},
} {
m := inConfigForm()
declaration(m, c.key)["default"] = c.value
refused(t, m, `"`+c.key+`"`, c.said)
}
}
// Rule 4: an object or a list not trusted.
func TestConfigRefusesAnObjectOrListNotTrusted(t *testing.T) {
for _, kind := range []string{"object", "list"} {
m := inConfigForm()
def := any(map[string]any{"a": 1})
if kind == "list" {
def = []any{"a"}
}
m["config"].(map[string]any)["rules"] = map[string]any{"type": kind, "description": "the rules, as " + kind,
"default": def, "runtime": true}
refused(t, m, `"rules"`, "is "+kind+" and not trusted")
m["config"].(map[string]any)["rules"].(map[string]any)["trusted"] = true
if _, err := parseRaw(t, m); err != nil {
t.Fatalf("a trusted %s is refused: %v", kind, err)
}
}
}
// withPredefined is the list of predefined keys the mesh published, for the length of a test.
func withPredefined(t *testing.T, keys PredefinedList) {
t.Helper()
@@ -164,127 +104,6 @@ func withPredefined(t *testing.T, keys PredefinedList) {
t.Cleanup(func() { ReplacePublished(was) })
}
// Rule 5: a predefined key not on the list, or of another type. Where no list is present, a value naming one is
// refused saying so.
func TestConfigRefusesAPredefinedKeyNotOnTheListOrOfAnotherType(t *testing.T) {
withPredefined(t, nil)
m := inConfigForm()
declaration(m, "font-size")["predefined"] = "font-size"
refused(t, m, `"font-size"`, "the list of predefined keys", "is not present")
withPredefined(t, map[string]PredefinedKey{"font-size": {Type: "number"}, "accent": {Type: "string"}})
if _, err := parseRaw(t, m); err != nil {
t.Fatalf("a value falling back to a listed key of its type is refused: %v", err)
}
m = inConfigForm()
declaration(m, "font-size")["predefined"] = "font-scale"
refused(t, m, `"font-size"`, `"font-scale"`, "not on the list")
m = inConfigForm()
declaration(m, "font-size")["predefined"] = "accent"
refused(t, m, `"font-size"`, `"accent"`, "is a string")
}
// Rule 6: an untrusted value falling back to a trusted predefined key.
func TestConfigRefusesAnUntrustedValueFallingBackToATrustedKey(t *testing.T) {
withPredefined(t, nil)
m := inConfigForm()
declaration(m, "theme")["predefined"] = "theme"
refused(t, m, `"theme"`, "is not present")
withPredefined(t, map[string]PredefinedKey{"theme": {Type: "string", Trusted: true}})
refused(t, m, `"theme"`, "trusted", "is not")
declaration(m, "theme")["trusted"] = true
if _, err := parseRaw(t, m); err != nil {
t.Fatalf("a trusted value falling back to a trusted key is refused: %v", err)
}
}
// Rule 7: a value neither placed in a template nor read at runtime.
func TestConfigRefusesAValueNeitherPlacedNorReadAtRuntime(t *testing.T) {
m := inConfigForm()
delete(declaration(m, "history"), "runtime")
refused(t, m, `"history"`, "${config:history}", `"runtime": true`)
// Taken by a mergeable file is placed.
m = inConfigForm()
if _, err := parseRaw(t, m); err != nil {
t.Fatal(err)
}
}
// Rule 8: a placeholder naming an undeclared value.
func TestConfigRefusesAPlaceholderNamingAnUndeclaredValue(t *testing.T) {
m := inConfigForm()
resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${config:width}\n" +
"from = ${config:sender}\ncolour = ${config:colour}\n"
refused(t, m, "${config:colour}", "declares no such value")
}
// Rule 9: a reload-on or restart-on naming a file the module does not declare.
func TestConfigRefusesAReloadOrRestartOnNamingAnUndeclaredFile(t *testing.T) {
m := inConfigForm()
resourceOf(m, "notifier")["reload-on"] = []any{"configuration", "dunstrc"}
refused(t, m, "reload-on", `"dunstrc"`, "declares no such")
m = inConfigForm()
resourceOf(m, "notifier")["restart-on"] = []any{"rule"}
refused(t, m, "restart-on", `"rule"`)
// Another module's file, and one the mesh makes for the module, are not the module's to declare.
m = inConfigForm()
resourceOf(m, "notifier")["restart-on"] = []any{"rules", "resolver.fact-zones", "secret-mail", "composed-jails"}
if _, err := parseRaw(t, m); err != nil {
t.Fatalf("refused: %v", err)
}
}
// Rule 10: a moved module's mergeable file not naming its keys under takes-config.
func TestConfigRefusesAMergeableFileNotNamingItsKeys(t *testing.T) {
m := inConfigForm()
delete(resourceOf(m, "rules"), "takes-config")
// position and theme are then placed nowhere; say they are read at runtime so only the file's rule speaks.
declaration(m, "position")["runtime"] = true
declaration(m, "theme")["runtime"] = true
refused(t, m, "rules", "names no takes-config")
}
// Rule 11: a mesh word, puid or pgid in takes-config.
func TestConfigRefusesAMeshWordOrAnAccountFactInTakesConfig(t *testing.T) {
for _, word := range []string{"ports", "expose", "reach", "endpoints", "places", "accesses", "networks", "puid", "pgid"} {
m := inConfigForm()
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", word}
refused(t, m, `"`+word+`"`, "takes-config")
}
}
// A module uses one form or the other, never both.
func TestAManifestMixingTheConfigAndSettingsFormsIsRefused(t *testing.T) {
m := inConfigForm()
resourceOf(m, "configuration")["content"] = "font = ${config:font-size}\nwidth = ${setting:width}\nfrom = ${config:sender}\n"
refused(t, m, "mixes", "${setting:", "config")
m = inConfigForm()
delete(resourceOf(m, "rules"), "takes-config")
resourceOf(m, "rules")["takes-settings"] = []any{"position", "theme"}
refused(t, m, "mixes", "takes-settings")
m = inConfigForm()
m["settings"] = map[string]any{"colour": map[string]any{"kind": "preference", "default": "red", "why": "red"}}
refused(t, m, "mixes", "settings")
}
// The settings form keeps its own declaration; the config form's has no kind or why.
func TestEachFormKeepsItsOwnDeclaration(t *testing.T) {
m := inConfigForm()
declaration(m, "width")["why"] = "fits a title"
refused(t, m, `"width"`, "why")
err := parsed(t, Manifest{Module: "notifier",
Settings: map[string]SettingDeclaration{"width": {Kind: KindPreference, Default: float64(250),
Why: "fits a title", Type: "number"}},
Resources: []map[string]any{{"id": "c", "type": "file", "path": "/x", "content": "${setting:width}"}}})
if err == nil || !strings.Contains(err.Error(), "under config") {
t.Fatalf("a typed value under settings: %v", err)
}
}
// What the mesh holds is what was written: a module in the config form is written back in it, and read again
// the same.
func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) {
@@ -306,7 +125,7 @@ func TestAConfigManifestIsWrittenBackInItsOwnForm(t *testing.T) {
t.Fatalf("written back with %s: %s", unsaid, raw)
}
}
again, err := ParseManifest(raw)
again, err := decodeForTest(raw)
if err != nil {
t.Fatal(err)
}
@@ -365,7 +184,7 @@ const configDunst = `{"module": "dunst", "version": "1",
func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.T) {
composed := func(manifest string, layers []Layer) []byte {
t.Helper()
m, err := ParseManifest([]byte(manifest))
m, err := decodeForTest([]byte(manifest))
if err != nil {
t.Fatal(err)
}
@@ -404,14 +223,6 @@ func TestAModuleRewrittenFromSettingsToConfigComposesByteIdentically(t *testing.
}
}
// A key a mergeable file takes is a config value like every other: one not declared under config is refused by
// name (ADR 0348 §4, review of mesh-controller#320).
func TestConfigRefusesATakesConfigKeyNotDeclared(t *testing.T) {
m := inConfigForm()
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "urgency"}
refused(t, m, `"urgency"`, "takes-config", "declares no such value under config")
}
// A required value nothing sets is refused at composition in the config form's words: ${config:<key>} and the
// config verb, never `settings set` (review of mesh-controller#320). The settings form's words are unchanged.
func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
@@ -466,36 +277,6 @@ func TestAnUnsetRequiredValueIsRefusedInTheConfigFormsWords(t *testing.T) {
}
}
// A value named after the operator's own (a url, a user, an address) is `required` where nothing falls back when
// it is unset; where a mergeable file takes it and no placeholder asks for it, the file's own content (or the
// module's code reading the file) falls back, so it keeps a default and stays optional, as it is today: the
// operator's decision on issue 664's second question (issue 735).
func TestAnOperatorNamedValueAFileFallsBackOnMayHaveADefault(t *testing.T) {
m := inConfigForm()
m["config"].(map[string]any)["url"] = map[string]any{"type": "string",
"description": "the address the notifier posts to, a URL; empty for the one the file's software picks",
"default": ""}
resourceOf(m, "rules")["takes-config"] = []any{"position", "theme", "url"}
if _, err := parseRaw(t, m); err != nil {
t.Fatalf("a taken value named after the operator's, with a default, was refused: %v", err)
}
// A placeholder asking for it has no fallback but the default, which would put a value in the definition
// where the operator's belongs (ADR 0112).
asked := inConfigForm()
asked["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL", "default": ""}
resourceOf(asked, "rules")["takes-config"] = []any{"position", "theme", "url"}
resourceOf(asked, "configuration")["content"] = "font = Inter ${config:font-size}\nwidth = ${config:width}\n" +
"from = ${config:sender}\npost = ${config:url}\n"
refused(t, asked, `"url"`, `a key naming "url" is the operator's value`)
// Read by the module's code alone, nothing in the definition says it falls back.
runtime := inConfigForm()
runtime["config"].(map[string]any)["url"] = map[string]any{"type": "string", "description": "a URL",
"default": "", "runtime": true}
refused(t, runtime, `"url"`, `a key naming "url" is the operator's value`)
}
// 1658/AC1: a value whose only placement is ${config:key} in an item's text is placed.
func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) {
m := map[string]any{
@@ -509,15 +290,3 @@ func TestConfigCountsAnItemsTextAsAPlacement(t *testing.T) {
t.Fatalf("a value placed only in an item's text is refused: %v", err)
}
}
// 1658/AC2: a declared value placed nowhere, with an item that does not say it, is still refused.
func TestConfigStillRefusesAValueNoItemPlaces(t *testing.T) {
m := map[string]any{
"module": "m", "version": "1",
"config": map[string]any{
"k": map[string]any{"type": "string", "description": "a word nothing says", "default": "x"},
},
"items": []any{map[string]any{"collection": "a", "content": "k = fixed\n"}},
}
refused(t, m, `"k"`, "placed nowhere")
}
-49
View File
@@ -124,55 +124,6 @@ func asDNSLabel(as string) string {
return strings.ReplaceAll(as, "_", "-")
}
// CheckServes refuses a `serves` block that names a consumer fact or an alphabet the mesh does not
// have, when the definition is parsed rather than when a consumer is resolved.
//
// A provision nobody consumes yet still has its rule read: a definition that would be refused the
// first time somebody required it is a definition that is wrong now.
func CheckServes(m Manifest) []string {
var problems []string
for _, provision := range sortedServes(m.Serves) {
for _, key := range sortedAnyKeys(m.Serves[provision]) {
text, ok := m.Serves[provision][key].(string)
if !ok {
// A provider's own name is filled only in a value of its own (novox/hq ADR 0346): one
// nested in a list or an object would be served as the placeholder, unsaid.
problems = append(problems, nestedOwnNames(m, provision, key, m.Serves[provision][key])...)
continue
}
// A probe identity, because what is checked is the shape of the statement and not
// what any consumer is called.
if _, err := consumerInto(text, "mesh_node_module"); err != nil {
problems = append(problems, fmt.Sprintf(
"%s serves %s, and the value it serves as %q %s", m.Module, provision, key, err))
}
// And the provider's own names it may serve (novox/hq ADR 0346).
problems = append(problems, checkServedOwnNames(m, provision, key, text)...)
// A label longer than DNS keeps is not truncated here (asDNSLabel), so the offer's own
// bound has to keep the identity inside one (ADR 0225).
if strings.Contains(text, "${consumer:as:dns}") {
if b := m.IdentityBoundOf(provision); !b.Bounded() || b.Max > dnsLabelLimit {
problems = append(problems, fmt.Sprintf(
"%s serves %s's consumers their identity as a DNS label in %q, and bounds "+
"that identity at %s: a label keeps %d — state an `identity` of at most %d",
m.Module, provision, key, boundWords(b), dnsLabelLimit, dnsLabelLimit))
}
}
}
}
return problems
}
// dnsLabelLimit is the longest DNS label (RFC 1035 §2.3.4).
const dnsLabelLimit = 63
func boundWords(b IdentityBound) string {
if !b.Bounded() {
return "nothing"
}
return fmt.Sprintf("%d", b.Max)
}
func sortedServes(serves map[string]map[string]any) []string {
out := make([]string, 0, len(serves))
for k := range serves {
+2 -2
View File
@@ -10,7 +10,7 @@ import (
func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4
for _, key := range []string{"from", "start", "deliver"} {
raw := `{"module":"audit","version":"1","consumes":[{"event":"shop.order.placed","` + key + `":"beginning"}]}`
_, err := ParseManifest([]byte(raw))
_, err := decodeForTest([]byte(raw))
if err == nil {
t.Fatalf("a consumes entry with %q was accepted", key)
}
@@ -18,7 +18,7 @@ func TestAConsumesEntryAskingWhereItStartsIsRefused(t *testing.T) { // 1279/AC4
t.Errorf("the refusal for %q does not name the key and the field: %v", key, err)
}
}
if _, err := ParseManifest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil {
if _, err := decodeForTest([]byte(`{"module":"audit","version":"1","consumes":["shop.order.placed"]}`)); err != nil {
t.Errorf("a plain consumes entry was refused: %v", err)
}
}
-24
View File
@@ -218,30 +218,6 @@ func TestASettingReachesAContributionOnlyWhereItDeclaresTheKey(t *testing.T) {
}
}
func TestReceivingWhatYouDoNotProvideIsRefused(t *testing.T) {
// It would create a file nobody ever writes to, on a machine where nothing asked for it.
_, err := ParseManifest([]byte(`{"module":"traefik","version":"1",
"receives":{"reverse-proxy":"/etc/traefik/mesh.json"}}`))
if err == nil {
t.Fatal("a module received contributions to something it does not provide")
}
if !strings.Contains(err.Error(), "does not provide") {
t.Fatalf("unhelpful refusal: %v", err)
}
}
func TestAnEmptyContributionIsRefused(t *testing.T) {
// Either a mistake or a requirement written the long way round, and both are better said.
_, err := ParseManifest([]byte(`{"module":"board","version":"1",
"contributes":{"reverse-proxy":{}}}`))
if err == nil {
t.Fatal("a module contributed nothing and was accepted")
}
if !strings.Contains(err.Error(), "require it") {
t.Fatalf("the refusal does not say what to do instead: %v", err)
}
}
// A provision answered from anywhere in the mesh has consumers on other machines, and the
// provider has to know who they are. Contributions were node-local until this, which meant the
// one case that most needed them was the one they did not reach.
@@ -113,24 +113,3 @@ func TestAPackageDeclaredPresentAndAbsentIsRefusedNamingBoth(t *testing.T) {
t.Errorf("an unrelated assignment is refused for a contradiction already there: %v", err)
}
}
func TestCodeForAPowerMomentDependsOnThePowerSeat(t *testing.T) {
for _, moment := range powerMoments {
m := mod("laptop", nil, nil, nil)
m.Shell = []ShellCode{{For: moment, Slot: "normal", Code: "true"}}
if got := DependsOn(nil, m); !reflect.DeepEqual(got, []string{PowerSeat}) {
t.Errorf("code for %s depends on %v, want %s", moment, got, PowerSeat)
}
if p := m.shellProblems(); len(p) != 0 {
t.Errorf("code for %s is refused: %v", moment, p)
}
}
m := mod("laptop", nil, nil, nil)
m.Shell = []ShellCode{{For: "after-lunch", Slot: "normal", Code: "true"}}
if p := m.shellProblems(); len(p) == 0 {
t.Error("code for a moment that does not exist was accepted")
}
if s, ok := SeatNamed(PowerSeat); !ok || s.Scope != ScopeNode {
t.Errorf("%s is not a node seat of the mesh's own: %+v %v", PowerSeat, s, ok)
}
}
+238
View File
@@ -0,0 +1,238 @@
package catalogue
import (
"fmt"
"slices"
"sort"
"strings"
)
// **What still depends on the controller, and the lease write's check** (novox/hq ADR 0571 §13, ADR 0582 clause 2,
// issue 774): every declaration of a manifest other than the controller's on the controller (a read of one of its
// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, a use of its seat), judged
// against the named, temporary list of what another accepted record keeps until the lease write. The grants part's
// grants_lease_check runs it over every manifest the indexer holds; ManifestRefusals runs it over a repository's
// manifests, in the test ADR 0571 §13 names in mesh-catalog, mesh-delivery and mesh-tools. A read of the lease key is no
// dependency: it names the lease's holder, whoever that is, and parts read it to learn that the write happened.
// ControllerLease is the controller's lease as a reader names it (`<module>.<state>`): no dependency.
const ControllerLease = ControllerSeatName + ".lease"
// The kinds of a dependency on the controller, as a manifest declares them.
const (
DependsRead = "read"
DependsInvoke = "invoke"
DependsConsume = "consume"
DependsUses = "uses"
DependsWaits = "waits"
)
// The phases of the lease write's check (ADR 0571 §13): before the lease write's own delivery the whole named list is
// allowed; that delivery drops every entry but the three kept to the write itself, and its check runs with those alone.
const (
PhaseBefore = "before-delivery"
PhaseWrite = "write"
)
// Dependency is one declaration of a manifest other than the controller's on the controller: a read of one of its
// states, an invoke of one of its verbs or tools, a consume or a wait on one of its events, or a send to its seat.
type Dependency struct {
Module string `json:"module"`
// By is the account declaring it when it is not the module's own: `process <id>` or `tools <binary>`.
By string `json:"by,omitempty"`
Kind string `json:"kind"`
What string `json:"what"`
// Kept is the record that keeps it until the lease write, from the named list; empty when none does.
Kept string `json:"kept,omitempty"`
// ToTheWrite says it is one of the three kept to the write itself, which the lease write's own delivery keeps.
ToTheWrite bool `json:"to_the_write,omitempty"`
}
// KeptDependency is one entry of the named, temporary list ADR 0571 §13 allows: a read, invoke or consume another
// accepted record keeps until the lease write, with that record. ToTheWrite marks the three kept to the write itself.
type KeptDependency struct {
Module, Kind, What, Record string
ToTheWrite bool
}
// KeepList is the named list. Each entry names the clause that keeps it; a dependency not on it fails the check. It
// holds the three kept to the write itself and nothing else: the lease write's own delivery dropped every other entry
// (ADR 0571 §13; issue 774), so its check runs with these alone in either phase. The third, the bus node agent's
// following of the controller's user list (ADR 0571 §8, ADR 0547 §8), is no manifest's declaration but the
// node-engine's own, so no manifest matches it; it stays named here so the list reads as the record has it.
var KeepList = []KeptDependency{
{Module: "mesh-delivery-builder", Kind: DependsConsume, What: "mesh-controller.artifacts-gone", Record: "ADR 0571 §4, §13", ToTheWrite: true},
{Module: "artifact-store-tools", Kind: DependsInvoke, What: "seat:mesh-controller.collect", Record: "ADR 0571 §4, §13", ToTheWrite: true},
{Module: "node-engine", Kind: DependsRead, What: "the controller's user list of the bus module's composition", Record: "ADR 0571 §8, §13; ADR 0547 §8", ToTheWrite: true},
}
// EndedBy names, for a declaration no entry keeps, the record that already ended it, so a refusal says where it moved.
// The controller's `grants` is one: ADR 0571 §9 has no manifest read it from L7's delivery, which changes ADR 0567 §10
// and ADR 0547 §5's shadow compare, so neither the assigner's nor the grants part's read of it is kept.
var EndedBy = map[string]string{
DependsRead + " mesh-controller.grants": "ADR 0571 §9: no manifest reads the controller's grants; the grants part's key is the one read",
DependsInvoke + " seat:mesh-controller.hand-act": "ADR 0565 §11: every recorder calls mesh-hand-acts",
DependsInvoke + " seat:mesh-controller.warranted": "ADR 0565 §11: every recorder calls mesh-hand-acts",
DependsInvoke + " seat:mesh-controller.root-free": "ADR 0571 §11: each machine's engine answers root-free (seat:node-engine.root-free@machine)",
DependsInvoke + " seat:mesh-controller.held": "ADR 0571 §2: held is the seat mesh-artifact-sweep's",
}
// keptFor is the list's entry for one dependency, or nil.
func keptFor(d Dependency) *KeptDependency {
for i, k := range KeepList {
if k.Module == d.Module && k.Kind == d.Kind && k.What == d.What {
return &KeepList[i]
}
}
return nil
}
// onController says whether a name `<owner>.<rest>` is the controller's.
func onController(name string) bool { return strings.HasPrefix(name, ControllerSeatName+".") }
// invokesController says whether an invoke names a verb of the controller's seat or a tool of its module. `*` names none:
// it is the console's grant to every tool, judged by no record as a dependency.
func invokesController(invoke string) bool {
if seat, _, ok := SeatInvoke(invoke); ok {
return seat == ControllerSeatName
}
return onController(invoke)
}
// Dependencies are every declaration on the controller in the manifests given, by module, each with the list's entry
// that keeps it. The controller's own manifest is not judged, nor a read of its lease key: it names the lease's holder,
// whoever that is, and parts read it to learn that the write happened (ADR 0571 §13).
func Dependencies(manifests map[string]Manifest) []Dependency {
var out []Dependency
add := func(module, by, kind, what string) {
d := Dependency{Module: module, By: by, Kind: kind, What: what}
if k := keptFor(d); k != nil {
d.Kept, d.ToTheWrite = k.Record, k.ToTheWrite
}
if !slices.Contains(out, d) {
out = append(out, d)
}
}
for name, m := range manifests {
if name == ControllerSeatName || m.Module == ControllerSeatName {
continue
}
for _, r := range m.Reads {
if onController(r) && r != ControllerLease {
add(name, "", DependsRead, r)
}
}
for _, i := range m.Invokes {
if invokesController(i) {
add(name, "", DependsInvoke, i)
}
}
for _, c := range m.Consumes {
if onController(c) {
add(name, "", DependsConsume, c)
}
}
for _, w := range m.Waits {
if onController(w) {
add(name, "", DependsWaits, w)
}
}
for _, u := range m.Uses {
if u == ControllerSeatName {
add(name, "", DependsUses, u)
}
}
for _, a := range m.BusAccounts() {
by := "process " + a.ID
if a.Tools {
by = "tools " + a.ID
}
for _, i := range a.Bus.Invokes {
if invokesController(i) {
add(name, by, DependsInvoke, i)
}
}
}
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i], out[j]
if a.Module != b.Module {
return a.Module < b.Module
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
if a.What != b.What {
return a.What < b.What
}
return a.By < b.By
})
return out
}
// Verdict is the lease write's check in one phase.
type Verdict struct {
Phase string `json:"phase"`
Passes bool `json:"passes"`
Reasons []string `json:"reasons"`
}
// Coverage is how much of the mesh's manifests the dependencies were derived from: the manifests read, the modules the
// indexer holds (-1 when its keys could not be listed, which Unread then names), and each manifest that could not be
// read.
type Coverage struct {
Read, Indexed int
Unread []Unread
}
// Unread is one manifest, or one listing of them, that could not be read, and why.
type Unread struct {
Input string `json:"input"`
Why string `json:"why"`
}
// Judge runs the lease write's check over the dependencies (ADR 0571 §13) in one phase: before the lease write's own
// delivery, every dependency must be on the named list; at the write, only the three kept to the write itself may stay.
// It passes only over every module the indexer holds: no manifest read, fewer read than the indexer holds, or one that
// could not be read fails it, since what was not read could hide a dependency. A lease already handed away leaves
// nothing to write.
func Judge(deps []Dependency, phase string, cov Coverage, handedAway bool, leaseSays string) Verdict {
v := Verdict{Phase: phase, Reasons: []string{}}
if handedAway {
v.Reasons = append(v.Reasons, "there is nothing to write: "+leaseSays)
}
switch {
case cov.Read == 0:
v.Reasons = append(v.Reasons, fmt.Sprintf("no manifest was read from the indexer (it holds %d modules): with nothing read, nothing on the controller can be judged absent", max(cov.Indexed, 0)))
case cov.Indexed >= 0 && cov.Read < cov.Indexed:
v.Reasons = append(v.Reasons, fmt.Sprintf("only %d of the %d modules the indexer holds had their manifest read, so what the others declare on the controller is not known", cov.Read, cov.Indexed))
}
for _, d := range deps {
who := d.Module
if d.By != "" {
who += " (" + d.By + ")"
}
switch {
case d.Kept == "":
why := fmt.Sprintf("%s declares %s %s, which no record keeps until the lease write (ADR 0571 §13)", who, d.Kind, d.What)
if ended := EndedBy[d.Kind+" "+d.What]; ended != "" {
why += "; ended by " + ended
}
v.Reasons = append(v.Reasons, why)
case phase == PhaseWrite && !d.ToTheWrite:
v.Reasons = append(v.Reasons, fmt.Sprintf("%s declares %s %s, kept by %s only until the lease write's own delivery, which drops it", who, d.Kind, d.What, d.Kept))
}
}
for _, g := range cov.Unread {
v.Reasons = append(v.Reasons, fmt.Sprintf("%s could not be read (%s), so what it declares on the controller is not known", g.Input, g.Why))
}
v.Passes = len(v.Reasons) == 0
return v
}
// ManifestRefusals is the lease write's check over a repository's manifests, by module name, in one phase (PhaseBefore
// or PhaseWrite): each reason the check gives, none when it passes. Every manifest given counts as read: the caller reads
// them from its own tree, and none given fails it.
func ManifestRefusals(manifests map[string]Manifest, phase string) []string {
return Judge(Dependencies(manifests), phase, Coverage{Read: len(manifests), Indexed: -1}, false, "").Reasons
}
+103
View File
@@ -0,0 +1,103 @@
package catalogue
import (
"slices"
"strings"
"testing"
)
// Issue 774 (ADR 0571 §13, ADR 0582 clause 2): the lease write's check judges every manifest's dependency on the
// controller against the named list, at the write (the three kept to it alone) and before its delivery. Moved with the
// judge from mesh-grants' leasecheck_test.go (TestTheLeaseCheckJudgesEveryDependencyOnTheController's manifest rows and
// TestTheKeepListNamesItsRecordsAndThreeToTheWrite), whose manifests were mesh-grants' fixtures; the conditions row is
// mesh-conditions' manifest at mesh-catalog ce3bedd5 (module.json:31-32).
func TestTheLeaseWritesCheckJudgesEveryDependencyOnTheController(t *testing.T) {
toTheWrite := 0
for _, k := range KeepList {
if !strings.HasPrefix(k.Record, "ADR ") || k.Module == "" || k.What == "" {
t.Errorf("entry %+v names no record, module or declaration", k)
}
if k.ToTheWrite {
toTheWrite++
}
}
if toTheWrite != 3 || len(KeepList) != 3 {
t.Errorf("%d entries, %d kept to the write itself: ADR 0571 §13 keeps the three alone from the write's delivery", len(KeepList), toTheWrite)
}
type want struct {
deps []string // "<module> <kind> <what> <kept by>", kept by the record or "unkept"
write string // a reason the check at the write names, or "" when it passes
before string // a reason the check before the delivery names, or "" when it passes
}
for _, row := range []struct {
name string
manifests map[string]string
want want
}{
// 774/S4/AC2.
{"a read of the lease alone is no dependency, and the controller's own manifest is not judged",
map[string]string{"mod-l": `{"module":"mod-l","version":"1","reads":["mesh-controller.lease"]}`,
"mesh-controller": `{"module":"mesh-controller","version":"1","invokes":["seat:mesh-controller.status"]}`},
want{}},
// 774/S4/AC1, 774/S30/AC1: the keeper's read of the controller's conditions put back is refused in either phase,
// since the lease write's delivery dropped its entry (it was kept by ADR 0564 §9 before).
{"the keeper's read of the controller's conditions, put back, is refused",
map[string]string{"mesh-conditions": `{"module":"mesh-conditions","version":"1","reads":["mesh-controller.conditions","mesh-controller.lease"]}`},
want{deps: []string{"mesh-conditions read mesh-controller.conditions unkept"},
write: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write",
before: "mesh-conditions declares read mesh-controller.conditions, which no record keeps until the lease write"}},
// 774/S30/AC1: the three kept to the write pass in either phase.
{"the artifact sweep's collect is kept to the write itself",
map[string]string{"artifact-store-tools": `{"module":"artifact-store-tools","version":"1","invokes":["seat:mesh-controller.collect"]}`},
want{deps: []string{"artifact-store-tools invoke seat:mesh-controller.collect kept by ADR 0571 §4, §13"}}},
{"the builder's consume of artifacts-gone is kept to the write itself",
map[string]string{"mesh-delivery-builder": `{"module":"mesh-delivery-builder","version":"1","consumes":["mesh-controller.artifacts-gone"]}`},
want{deps: []string{"mesh-delivery-builder consume mesh-controller.artifacts-gone kept by ADR 0571 §4, §13"}}},
{"a read no record keeps fails both phases, named",
map[string]string{"mod-u": `{"module":"mod-u","version":"1","reads":["mesh-controller.declared-packages"]}`},
want{deps: []string{"mod-u read mesh-controller.declared-packages unkept"},
write: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write",
before: "mod-u declares read mesh-controller.declared-packages, which no record keeps until the lease write"}},
{"a seat verb and a module tool of the controller's, a consume and a wait are each one; every tool (*) is none",
map[string]string{"mod-i": `{"module":"mod-i","version":"1","invokes":["seat:mesh-controller.root-free","mesh-controller.status","*","nats.nats_users"],` +
`"consumes":["mesh-controller.checked","mod-x.thing"],"uses":["mesh-controller"]}`,
"node-tools": `{"module":"node-tools","version":"1","waits":["mesh-controller.checked"]}`},
want{deps: []string{"mod-i consume mesh-controller.checked unkept", "mod-i invoke mesh-controller.status unkept",
"mod-i invoke seat:mesh-controller.root-free unkept", "mod-i uses mesh-controller unkept", "node-tools waits mesh-controller.checked unkept"},
write: "mod-i declares invoke seat:mesh-controller.root-free, which no record keeps until the lease write (ADR 0571 §13); ended by ADR 0571 §11",
before: "node-tools declares waits mesh-controller.checked"}},
{"a process's own invoke of the controller is its module's dependency, named by the process",
map[string]string{"mod-p": `{"module":"mod-p","version":"1","resources":[{"id":"account","type":"user","name":"mod-p"},{"id":"worker","type":"process","run":["./w"],"user":"mod-p",` +
`"bus":{"invokes":["seat:mesh-controller.held"]}}]}`},
want{deps: []string{"mod-p invoke seat:mesh-controller.held unkept"},
write: "mod-p (process worker) declares invoke seat:mesh-controller.held", before: "mod-p (process worker)"}},
{"no manifest fails both phases", map[string]string{}, want{write: "no manifest was read", before: "no manifest was read"}},
} {
manifests := map[string]Manifest{}
for name, raw := range row.manifests {
m, err := Decode([]byte(raw))
if err != nil {
t.Fatalf("%s: %s: %v", row.name, name, err)
}
manifests[name] = m
}
var deps []string
for _, d := range Dependencies(manifests) {
kept := "unkept"
if d.Kept != "" {
kept = "kept by " + d.Kept
}
deps = append(deps, d.Module+" "+d.Kind+" "+d.What+" "+kept)
}
if !slices.Equal(deps, row.want.deps) {
t.Errorf("%s: dependencies %q, want %q", row.name, deps, row.want.deps)
}
for phase, reason := range map[string]string{PhaseWrite: row.want.write, PhaseBefore: row.want.before} {
got := ManifestRefusals(manifests, phase)
if (reason == "") != (len(got) == 0) || !slices.ContainsFunc(got, func(r string) bool { return strings.Contains(r, reason) }) && reason != "" {
t.Errorf("%s: at %s refused %q, want %q", row.name, phase, got, reason)
}
}
}
}
+1 -1
View File
@@ -33,7 +33,7 @@ const provisionerClient = `{"module":"mesh-provisioner-client","version":"1",
func clientOn(t *testing.T) Resolution {
t.Helper()
m, err := ParseManifest([]byte(provisionerClient))
m, err := decodeForTest([]byte(provisionerClient))
if err != nil {
t.Fatal(err)
}
-77
View File
@@ -13,80 +13,3 @@ package catalogue
// The ordinary five fields — minute, hour, day-of-month, month, day-of-week — with `*`, lists
// (`,`), ranges (`-`) and steps (`/`). No names (jan, mon): a numeric cron is what the host
// evaluates, and refusing a name here is refusing it near whoever wrote it.
import (
"fmt"
"strconv"
"strings"
)
// validateCron reports why a string is not a five-field cron expression, or nil if it is one.
func validateCron(expr string) error {
fields := strings.Fields(expr)
if len(fields) != 5 {
return fmt.Errorf(
"a schedule is a five-field cron expression (minute hour day-of-month month "+
"day-of-week), and this has %d field(s)", len(fields))
}
bounds := []struct {
name string
min, max int
}{
{"minute", 0, 59},
{"hour", 0, 23},
{"day-of-month", 1, 31},
{"month", 1, 12},
{"day-of-week", 0, 7}, // 0 and 7 are both Sunday, the convention every cron keeps
}
for i, b := range bounds {
if err := validateCronField(fields[i], b.min, b.max); err != nil {
return fmt.Errorf("%s field: %w", b.name, err)
}
}
return nil
}
// validateCronField checks one field's elements are within range and well-formed.
func validateCronField(spec string, min, max int) error {
if spec == "" {
return fmt.Errorf("is empty")
}
for _, part := range strings.Split(spec, ",") {
if part == "" {
return fmt.Errorf("%q has an empty element between commas", spec)
}
rangePart := part
if slash := strings.IndexByte(part, '/'); slash >= 0 {
rangePart = part[:slash]
n, err := strconv.Atoi(part[slash+1:])
if err != nil || n < 1 {
return fmt.Errorf("step in %q is not a positive number", part)
}
}
switch {
case rangePart == "*":
// Any value; nothing to bound.
case strings.IndexByte(rangePart, '-') >= 0:
dash := strings.IndexByte(rangePart, '-')
lo, errLo := strconv.Atoi(rangePart[:dash])
hi, errHi := strconv.Atoi(rangePart[dash+1:])
if errLo != nil || errHi != nil {
return fmt.Errorf("range %q is not two numbers", rangePart)
}
if lo < min || hi > max || lo > hi {
return fmt.Errorf("range %q is outside the allowed %d-%d", part, min, max)
}
default:
v, err := strconv.Atoi(rangePart)
if err != nil {
return fmt.Errorf("%q is not a number", rangePart)
}
if v < min || v > max {
return fmt.Errorf("%q is outside the allowed range %d-%d", part, min, max)
}
}
}
return nil
}
-364
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
@@ -305,182 +304,6 @@ func keepsByClass(class string) bool {
return class == ClassIrreplaceable || class == ClassValuable || class == ClassRebuildable
}
// dataID is what an item's id may be: it is a token in a condition's key and a word on a line.
var dataID = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// dataPathRef is an item's path: one of the module's directories or accesses, and optionally a path
// beneath it.
var dataPathRef = regexp.MustCompile(`^\$\{(dir|access):([a-z0-9][a-z0-9-]*)\}(/[^\s$]*)?$`)
// dataProblems is what is wrong with the `data` section itself, from the manifest alone: judged at
// registration as every other per-manifest problem is. Whether a module declares what it should is
// the catalogue check's (DataProblems), because a module already running was written before it.
func (m Manifest) dataProblems() []string {
if m.Data == nil {
return nil
}
var problems []string
say := func(format string, args ...any) {
problems = append(problems, fmt.Sprintf("%s's data: ", m.Module)+fmt.Sprintf(format, args...))
}
dirs := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
dirs[fmt.Sprint(r["id"])] = true
}
}
accesses := map[string]bool{}
for _, a := range m.Accesses {
if a.ID != "" {
accesses[a.ID] = true
}
}
ids := map[string]DataItem{}
paths := map[string]string{}
for i, it := range m.Data.Own {
label := it.ID
if label == "" {
label = fmt.Sprintf("item %d", i+1)
}
if !dataID.MatchString(it.ID) {
say("%s has no usable id: lower-case letters, digits and dashes", label)
} else if _, twice := ids[it.ID]; twice {
say("%s is declared twice", it.ID)
}
ids[it.ID] = it
ref := dataPathRef.FindStringSubmatch(it.Path)
switch {
case ref == nil:
say("%s's path %q is not one of the module's directories or accesses: ${dir:<id>} or ${access:<id>}, "+
"or a path beneath one (a definition names no machine path, novox/hq ADR 0112)", label, it.Path)
case ref[1] == "dir" && !dirs[ref[2]]:
say("%s's path names ${dir:%s}, and %s declares no directory %q", label, ref[2], m.Module, ref[2])
case ref[1] == "access" && !accesses[ref[2]]:
say("%s's path names ${access:%s}, and %s declares no access %q", label, ref[2], m.Module, ref[2])
case strings.Contains(it.Path, ".."):
say("%s's path %q climbs out of its directory", label, it.Path)
}
if other, twice := paths[it.Path]; twice && it.Path != "" {
say("%s and %s name the same path %s", other, label, it.Path)
}
paths[it.Path] = label
switch it.Class {
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache:
case ClassNone:
say("%s is class none, which only a provision keeping nothing of its consumers' is; own data "+
"that is disposable is cache", label)
default:
say("%s's class %q is not one the mesh protects by: irreplaceable, valuable, rebuildable or cache",
label, it.Class)
}
if it.Class == ClassIrreplaceable && !it.BackedUp() && strings.TrimSpace(it.Redundancy) == "" {
say("%s is irreplaceable and is neither backed up nor said to be protected by redundancy; the only "+
"copy of something is protected one way or the other (novox/hq ADR 0233) — copy it, dump it into "+
"another item, or say `redundancy` with why that is enough", label)
}
if it.Class == ClassCache && it.Backup != nil && !it.Backup.None {
say("%s is a cache and asks to be backed up; a cache is disposable, or it is not a cache", label)
}
if it.Class == ClassCache && it.Redundancy != "" {
say("%s is a cache and says it is protected by redundancy; a cache is not protected", label)
}
switch it.Measure {
case "", MeasureWalk, MeasureDataset, MeasureShallow:
default:
say("%s's measure %q is walk, dataset or shallow", label, it.Measure)
}
if _, err := ParseDataDuration(it.Within); err != nil {
say("%s's within: %v", label, err)
}
if _, err := ParseDataDuration(it.Active); err != nil {
say("%s's active: %v", label, err)
}
if it.Within != "" && !it.BackedUp() {
say("%s states within, and is not backed up", label)
}
if it.Active != "" && !Watched(it.Class) {
say("%s is %s and expects writes; only irreplaceable and valuable data is watched", label, it.Class)
}
}
// Dumps go into an item of the same module, declared, and not into themselves.
for _, it := range m.Data.Own {
if it.Backup == nil || it.Backup.Copy || it.Backup.None {
continue
}
switch into, ok := ids[it.Backup.Into]; {
case strings.TrimSpace(it.Backup.Dump) == "":
say("%s's backup names no dump command", it.ID)
case it.Backup.Into == "":
say("%s's dump says no item it writes into", it.ID)
case !ok:
say("%s's dump writes into %q, which is not one of the module's data items", it.ID, it.Backup.Into)
case into.ID == it.ID:
say("%s's dump writes into itself; a dump is copied from where it lands", it.ID)
case into.Class == ClassCache:
say("%s's dump writes into %s, a cache; what is copied must not be disposable", it.ID, into.ID)
}
if it.Backup.Dump != "" {
declared := map[string]string{}
for d := range dirs {
declared[d] = ""
}
if _, err := dirFill(it.Backup.Dump, declared, m.Module); err != nil {
say("%s's dump: %v", it.ID, err)
}
}
}
provided := map[string]bool{}
for _, o := range m.Provides {
provided[o.Name] = true
}
wants := map[string]bool{}
for _, w := range m.Wants() {
wants[w] = true
}
for _, provision := range sortedKeys(m.Data.Consumers) {
c := m.Data.Consumers[provision]
if !provided[provision] {
say("says what it keeps for the consumers of %q, which it does not provide", provision)
}
switch c.Class {
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache, ClassNone:
default:
say("its consumers' %s is class %q; one of irreplaceable, valuable, rebuildable, cache or none", provision, c.Class)
}
switch {
case c.Class == ClassNone && c.In != "":
say("its consumers' %s keeps nothing and says where it is kept (%s)", provision, c.In)
case keepsByClass(c.Class) && c.In == "":
say("its consumers' %s is %s and says nowhere it lives: `in` names one of the module's items, or a "+
"provision it requires", provision, c.Class)
case c.In != "":
if own, ok := ids[c.In]; ok {
if c.Class == ClassIrreplaceable && !own.BackedUp() && own.Redundancy == "" {
say("its consumers' %s is irreplaceable and lives in %s, which is not protected", provision, c.In)
}
if classRank[own.Class] < classRank[c.Class] {
say("its consumers' %s is %s and lives in %s, which is only %s", provision, c.Class, c.In, own.Class)
}
} else if !wants[c.In] {
say("its consumers' %s lives in %q, which is neither one of its data items nor a provision it "+
"requires", provision, c.In)
}
}
}
for _, provision := range sortedKeys(m.Data.KeptBy) {
k := m.Data.KeptBy[provision]
if !wants[provision] {
say("says it keeps data with the provider of %q, which it does not require", provision)
}
switch k.Class {
case ClassIrreplaceable, ClassValuable, ClassRebuildable, ClassCache:
default:
say("what it keeps with %s is class %q; one of irreplaceable, valuable, rebuildable or cache", provision, k.Class)
}
}
return problems
}
// KeepsConsumerData is whether this module, providing a provision, keeps what each consumer writes
// there (novox/hq ADR 0232, ADR 0233): whether a consumer bound to it is bound to its data.
//
@@ -596,196 +419,9 @@ func (m Manifest) allContributions() []SeatContribution {
// --- the catalogue check ------------------------------------------------------------------------
// DataProblems is what the catalogue check refuses about the data a module declares (novox/hq ADR
// 0233), judged over the manifests given together:
//
// - a provider that grants a provision says what it keeps for that provision's consumers;
// - nobody says it on the offer any more (`keeps-consumer-data`): the data section is the one place;
// - nobody writes a backup line by hand: it is derived from the data section;
// - a directory a container writes, mounted whole, is a data item of some class;
// - consumer data said to live in a required provision lives where that provision's provider keeps
// its consumers' data, as preciously, when the provider is given;
// - data a consumer keeps with a provider as irreplaceable is protected there, when the provider is
// given.
//
// **The check's, not registration's**, as ADR 0214's backup rule was: a module already on the shelf
// was written before the rule, and refusing it there would refuse the very providers whose data the
// rule protects. Each module meets it where it is written.
func DataProblems(shelf Shelf) []string {
var problems []string
for _, name := range shelfOrder(shelf) {
m := shelf[name]
for _, provision := range sortedKeys(m.Grants) {
if _, said := m.ConsumerDataOf(provision); !said {
problems = append(problems, fmt.Sprintf(
"%s grants %s and does not say what it keeps for its consumers: data.consumers.%s with a "+
"class — irreplaceable, valuable, rebuildable, cache, or none (novox/hq ADR 0233)", name, provision, provision))
}
}
for _, o := range m.Provides {
if o.KeepsConsumerData != nil {
problems = append(problems, fmt.Sprintf(
"%s says keeps-consumer-data on its offer of %s; it is said once, in data.consumers.%s, with a "+
"class (novox/hq ADR 0233)", name, o.Name, o.Name))
}
}
for i, c := range m.Contributions {
if s, known := SeatNamed(c.Seat); known && s.Name == BackupSeat {
problems = append(problems, fmt.Sprintf(
"%s's contribution %d is a backup line written by hand; backups are derived from the data "+
"section — declare the data, with its class and how it is protected (novox/hq ADR 0233)", name, i+1))
}
}
for _, dir := range writtenDirectories(m) {
if !coversDirectory(m, dir) {
problems = append(problems, fmt.Sprintf(
"%s mounts its directory %q into a container to be written, and declares no data in it: "+
"data.own with a class — cache if it is disposable (novox/hq ADR 0233)", name, dir))
}
}
if m.Data == nil {
continue
}
for _, provision := range sortedKeys(m.Data.Consumers) {
c := m.Data.Consumers[provision]
if c.In == "" {
continue
}
if _, own := m.DataItem(c.In); own {
continue
}
for _, pname := range shelfOrder(shelf) {
p := shelf[pname]
pc, said := p.ConsumerDataOf(c.In)
if !said || !providesName(p, c.In) {
continue
}
if classRank[pc.Class] < classRank[c.Class] {
problems = append(problems, fmt.Sprintf(
"%s keeps its consumers' %s, %s, in %s — and %s keeps its consumers' %s as %s, "+
"so it is not protected as %s", name, provision, c.Class, c.In, pname, c.In, pc.Class, c.Class))
}
}
}
for _, provision := range sortedKeys(m.Data.KeptBy) {
k := m.Data.KeptBy[provision]
if k.Class != ClassIrreplaceable {
continue
}
for _, pname := range shelfOrder(shelf) {
p := shelf[pname]
pc, said := p.ConsumerDataOf(provision)
if !said || !providesName(p, provision) {
continue
}
if !keepsByClass(pc.Class) {
problems = append(problems, fmt.Sprintf(
"%s keeps irreplaceable data with %s, and %s keeps its consumers' %s as %s — nothing of it is "+
"protected there", name, provision, pname, provision, pc.Class))
continue
}
if in, own := p.DataItem(pc.In); own && !in.BackedUp() && in.Redundancy == "" {
problems = append(problems, fmt.Sprintf(
"%s keeps irreplaceable data with %s, and %s keeps it in %s, which is neither backed up nor "+
"on declared redundancy", name, provision, pname, pc.In))
}
}
}
}
return problems
}
func providesName(m Manifest, provision string) bool {
for _, o := range m.Provides {
if o.Name == provision {
return true
}
}
return false
}
// writtenDirectories are the module's directories a container mounts whole and may write: a volume
// `${dir:<id>}:<target>` without `:ro`, or a directory stated by an absolute path mounted the same
// way. A file beneath a directory, or a read-only mount, is configuration the mesh wrote.
func writtenDirectories(m Manifest) []string {
absolute := map[string]string{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "directory" {
continue
}
if p, ok := r["path"].(string); ok && strings.HasPrefix(p, "/") {
absolute[strings.TrimRight(p, "/")] = fmt.Sprint(r["id"])
}
}
seen := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "container" {
continue
}
vols, _ := r["volumes"].([]any)
for _, v := range vols {
s, _ := v.(string)
mount := volumeMount.FindStringSubmatch(s)
if mount == nil || volumeReadOnly(mount[3]) {
continue
}
src := strings.TrimRight(mount[1], "/")
if ref := dirPlain.FindStringSubmatch(src); ref != nil {
seen[ref[1]] = true
} else if id, ok := absolute[src]; ok {
seen[id] = true
}
}
}
out := make([]string, 0, len(seen))
for id := range seen {
out = append(out, id)
}
sort.Strings(out)
return out
}
// volumeMount is a container's volume, `<source>:<target>[:<options>]`, its source possibly a
// `${dir:<id>}` — whose own colon is not the separator.
var volumeMount = regexp.MustCompile(`^(\$\{(?:dir|access):[a-z0-9][a-z0-9-]*\}[^:]*|[^:]+):([^:]+)(?::(.*))?$`)
// volumeReadOnly is whether a volume's options mount it read-only.
func volumeReadOnly(options string) bool {
for _, o := range strings.Split(options, ",") {
if strings.TrimSpace(o) == "ro" {
return true
}
}
return false
}
// dirPlain is a whole directory, `${dir:<id>}` and nothing after it.
var dirPlain = regexp.MustCompile(`^\$\{dir:([a-z0-9][a-z0-9-]*)\}$`)
// coversDirectory is whether a data item names the directory, a path within it, or a directory it
// is placed beneath.
func coversDirectory(m Manifest, dir string) bool {
parents := map[string]string{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) != "directory" {
continue
}
if p, ok := r["path"].(string); ok {
if ref := dirRef.FindStringSubmatch(p); ref != nil && strings.HasPrefix(p, "${dir:") {
parents[fmt.Sprint(r["id"])] = ref[1]
}
}
}
for _, it := range m.DataItems() {
ref := dataPathRef.FindStringSubmatch(it.Path)
if ref == nil || ref[1] != "dir" {
continue
}
for d, hops := dir, 0; d != "" && hops < 4; d, hops = parents[d], hops+1 {
if ref[2] == d {
return true
}
}
}
return false
}
+1 -120
View File
@@ -2,7 +2,6 @@ package catalogue
import (
"slices"
"strings"
"testing"
)
@@ -24,111 +23,13 @@ const declaredStore = `{"module":"pg","version":"1",
func mustParse(t *testing.T, raw string) Manifest {
t.Helper()
m, err := ParseManifest([]byte(raw))
m, err := decodeForTest([]byte(raw))
if err != nil {
t.Fatalf("refused: %v", err)
}
return m
}
func TestADeclaredStoreParsesAndPassesTheCheck(t *testing.T) {
m := mustParse(t, declaredStore)
if problems := DataProblems(Shelf{"pg": m}); len(problems) > 0 {
t.Fatalf("a store declaring its data was refused: %v", problems)
}
if !m.KeepsConsumerData("postgres-database") {
t.Fatal("an irreplaceable consumer class does not keep the consumer's data")
}
if it, _ := m.DataItem("store"); !it.BackedUp() || it.BackupWithin() != DefaultBackupWithin || it.ActiveWithin().Hours() != 24 {
t.Fatalf("the store item reads %+v", it)
}
if it, _ := m.DataItem("dumps"); it.BackedUp() {
t.Fatal("a rebuildable item that says none is backed up")
}
}
// Every rule of the section itself, refused at parse in the words of the module.
func TestTheDataSectionIsRefusedWhereItIsWrong(t *testing.T) {
for _, c := range []struct{ name, data, want string }{
{"a class it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"precious"}]}`, "not one the mesh protects by"},
{"an access it does not have", `{"own":[{"id":"a","path":"${access:media}","class":"irreplaceable","redundancy":"an array"}]}`, "declares no access"},
{"irreplaceable and protected by nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":"none"}]}`, "protected one way or the other"},
{"a cache on redundancy", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","redundancy":"an array"}]}`, "a cache is not protected"},
{"kept with something not required", `{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}`, "does not require"},
{"a measure it does not know", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","measure":"du"}]}`, "walk, dataset or shallow"},
{"a cache expecting writes", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","active":"1d"}]}`, "only irreplaceable and valuable"},
{"a machine path", `{"own":[{"id":"a","path":"/srv/a","class":"cache"}]}`, "names no machine path"},
{"a directory it does not declare", `{"own":[{"id":"a","path":"${dir:nowhere}","class":"cache"}]}`, "declares no directory"},
{"a cache backed up", `{"own":[{"id":"a","path":"${dir:d}","class":"cache","backup":"copy"}]}`, "disposable"},
{"a dump into nothing", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"b"}}]}`, "not one of the module's data items"},
{"a dump into itself", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","backup":{"dump":"x","into":"a"}}]}`, "into itself"},
{"a bad duration", `{"own":[{"id":"a","path":"${dir:d}","class":"irreplaceable","within":"soon"}]}`, "not a duration"},
{"the same id twice", `{"own":[{"id":"a","path":"${dir:d}","class":"cache"},{"id":"a","path":"${dir:d}/x","class":"cache"}]}`, "declared twice"},
{"own data of class none", `{"own":[{"id":"a","path":"${dir:d}","class":"none"}]}`, "only a provision"},
{"consumers of something not provided", `{"consumers":{"s3-bucket":{"class":"cache"}}}`, "does not provide"},
{"consumer data that lives nowhere", `{"consumers":{"q":{"class":"irreplaceable"}}}`, "says nowhere it lives"},
{"consumer data in an item less precious", `{"own":[{"id":"a","path":"${dir:d}","class":"rebuildable"}],"consumers":{"q":{"class":"irreplaceable","in":"a"}}}`, "which is only rebuildable"},
{"consumer data in something unknown", `{"consumers":{"q":{"class":"rebuildable","in":"elsewhere"}}}`, "neither one of its data items"},
} {
raw := `{"module":"m","version":"1","provides":["q"],"resources":[{"id":"d","type":"directory","mode":"0700"}],"data":` + c.data + `}`
_, err := ParseManifest([]byte(raw))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v, want %q", c.name, err, c.want)
}
}
}
// The catalogue check: a granting provider says what it keeps, says it in one place, writes no backup
// line by hand, and every directory a container writes is declared (novox/hq ADR 0233).
func TestTheCatalogueCheckAsksEveryModuleForItsData(t *testing.T) {
unsaid := mustParse(t, `{"module":"q","version":"1","provides":[{"name":"queue","scope":"mesh"}],
"grants":{"queue":"${dir:g}"},"resources":[{"id":"g","type":"directory","mode":"0700"}]}`)
onTheOffer := mustParse(t, `{"module":"r","version":"1","provides":[{"name":"resolver","scope":"mesh","keeps-consumer-data":false}]}`)
byHand := mustParse(t, `{"module":"h","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"}],
"contributions":[{"seat":"node-backup","kind":"backup","content":"path ${dir:d}"}]}`)
writes := mustParse(t, `{"module":"w","version":"1","resources":[{"id":"d","type":"directory","mode":"0700"},
{"id":"c","type":"directory","mode":"0700"},
{"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data","${dir:c}:/etc/app:ro","${dir:d}/f:/f"]}]}`)
problems := strings.Join(DataProblems(Shelf{"q": unsaid, "r": onTheOffer, "h": byHand, "w": writes}), "\n")
for _, want := range []string{
"q grants queue and does not say what it keeps",
"r says keeps-consumer-data on its offer",
"h's contribution 1 is a backup line written by hand",
`w mounts its directory "d" into a container to be written`,
} {
if !strings.Contains(problems, want) {
t.Errorf("the check does not say %q:\n%s", want, problems)
}
}
if strings.Contains(problems, `"c"`) {
t.Errorf("a read-only mount was taken for written data:\n%s", problems)
}
// The same module declaring the directory, as a cache, passes.
declared := mustParse(t, `{"module":"w","version":"1","data":{"own":[{"id":"d","path":"${dir:d}","class":"cache"}]},
"resources":[{"id":"d","type":"directory","mode":"0700"},
{"id":"app","type":"container","image":"x@sha256:aa","volumes":["${dir:d}:/data"]}]}`)
if p := DataProblems(Shelf{"w": declared}); len(p) > 0 {
t.Fatalf("a declared directory is still refused: %v", p)
}
}
// Consumer data said to live in a provision the module requires is protected only as well as that
// provision's provider protects its consumers' data.
func TestConsumerDataInARequiredProvisionIsJudgedAgainstItsProvider(t *testing.T) {
idp := mustParse(t, `{"module":"idp","version":"1","requires":["postgres-database"],
"provides":[{"name":"oidc-client","scope":"mesh"}],"grants":{"oidc-client":"${dir:g}"},
"resources":[{"id":"g","type":"directory","mode":"0700"}],
"data":{"consumers":{"oidc-client":{"class":"irreplaceable","in":"postgres-database"}}}}`)
cachy := mustParse(t, strings.Replace(declaredStore, `"consumers":{"postgres-database":{"class":"irreplaceable","in":"store"}}`,
`"consumers":{"postgres-database":{"class":"cache"}}`, 1))
if p := strings.Join(DataProblems(Shelf{"idp": idp, "pg": cachy}), "\n"); !strings.Contains(p, "not protected as irreplaceable") {
t.Fatalf("irreplaceable data kept in a cache passed: %s", p)
}
if p := DataProblems(Shelf{"idp": idp, "pg": mustParse(t, declaredStore)}); len(p) > 0 {
t.Fatalf("refused against a provider that keeps its consumers' data: %v", p)
}
}
// What a binding is sticky by follows the class (ADR 0232, generalised by 0233): a cache's consumers
// move freely, a store's do not; an older definition saying nothing still follows its grant.
func TestKeepingConsumerDataFollowsTheClass(t *testing.T) {
@@ -215,23 +116,3 @@ func TestTheBackupHoldersLinesAreDerivedFromTheData(t *testing.T) {
t.Fatalf("an operator's path reads %+v", it)
}
}
// Data a consumer keeps with a provider as irreplaceable must be protected there: the check refuses a
// provider that keeps its consumers' data as a cache, or in an item with no protection.
func TestIrreplaceableDataKeptWithAProviderMustBeProtectedThere(t *testing.T) {
photos := mustParse(t, `{"module":"photos","version":"1","requires":["s3-bucket"],
"data":{"kept-by":{"s3-bucket":{"class":"irreplaceable"}}}}`)
store := func(backup string) Manifest {
return mustParse(t, `{"module":"objects","version":"1","provides":[{"name":"s3-bucket","scope":"mesh"}],
"grants":{"s3-bucket":"${dir:g}"},
"data":{"own":[{"id":"data","path":"${dir:data}","class":"valuable","backup":"`+backup+`"}],
"consumers":{"s3-bucket":{"class":"valuable","in":"data"}}},
"resources":[{"id":"g","type":"directory","mode":"0700"},{"id":"data","type":"directory","mode":"0700"}]}`)
}
if p := DataProblems(Shelf{"photos": photos, "objects": store("copy")}); len(p) > 0 {
t.Fatalf("a provider backing its consumers' data up was refused: %v", p)
}
if p := strings.Join(DataProblems(Shelf{"photos": photos, "objects": store("none")}), "\n"); !strings.Contains(p, "neither backed up") {
t.Fatalf("irreplaceable photos in an unprotected store passed: %s", p)
}
}
+42
View File
@@ -0,0 +1,42 @@
package catalogue
import (
"encoding/json"
"strings"
)
// Decode reads a manifest leniently, as every part but the judge reads one (novox/hq ADR 0578 §3): every field the
// contract knows as it declares it, and a field it does not know set aside and named (SetAside), never refused. It
// judges nothing: no name, seat, bus or catalogue rule is applied, since the indexer judged the manifest once and its
// verdict is in the indexer's state (delivery.VerdictsBucket); a reader takes the verdict beside it
// (delivery.TakeVerdict). An error is only bytes that are no manifest: not JSON, or a field of the wrong shape.
func Decode(raw []byte) (Manifest, error) {
var m Manifest
if err := json.Unmarshal(raw, &m); err != nil {
return Manifest{}, err
}
return m, nil
}
// SetAside names what Decode set aside of this manifest: each field it does not know, as the decoder words the first
// and with the path of every one it read without, or "" when it knew every field. A reader says it in its own words
// (its journal, its finding) and reads the rest; the part that needs the field takes the newer package (ADR 0578 §6).
func (m Manifest) SetAside() string { return m.unknown }
// ReadNoFurther is whether Decode could not read past a field it does not know, so the manifest holds its name and
// version alone: a reader holds such a module, as it holds a refused one, and names why (SetAside).
func (m Manifest) ReadNoFurther() bool { return strings.Contains(m.unknown, readNoFurther) }
// readNoFurther marks, in what a manifest set aside, that it was not read past it.
const readNoFurther = "(read no further: "
// Predefined is the list of predefined keys the manifest was read with (ReadTreeManifestWritten, or the judge's
// SetPredefined), nil when none: data of the contract a judge reads, never a rule.
func (m Manifest) Predefined() PredefinedList { return m.predefined }
// SetPredefined keeps the list of predefined keys a manifest is read with, for what reads it after (the judge).
func (m *Manifest) SetPredefined(list PredefinedList) { m.predefined = list }
// FormsWritten is the fields the manifest's config was written with, in the config form (`config`, `takes-config`,
// `${config:…}`) and in the settings form (novox/hq ADR 0348 §1): data a judge reads to say a manifest mixes them.
func (m Manifest) FormsWritten() (config, settings []string) { return m.forms.config, m.forms.settings }
+12
View File
@@ -0,0 +1,12 @@
package catalogue
// decodeForTest builds a manifest for a test as every reader reads one (Decode); the judge's own tests are in
// mesh-delivery's judge (novox/hq ADR 0578 §4).
func decodeForTest(raw []byte) (Manifest, error) { return Decode(raw) }
// decodeWithForTest is decodeForTest keeping the list of predefined keys it was read with.
func decodeWithForTest(raw []byte, list PredefinedList) (Manifest, error) {
m, err := Decode(raw)
m.SetPredefined(list)
return m, err
}
-25
View File
@@ -1,7 +1,5 @@
package catalogue
import "fmt"
// The delivery's owner (novox/hq ADR 0239, to-be 47): one holder for the mesh, the module mesh-delivery,
// which owns a delivery — one commit in one repository, from its pull request's head to every machine —
// and a delivery group, and asks the controller for every act. A seat of the mesh's own, so the role has
@@ -107,29 +105,6 @@ func SeatSays(emits []string, event string) bool {
return false
}
// seatEventProblems refuses a module that lists among its own `emits` an event a seat it claims says (the lead,
// 2026-10-29, issue 1952, the second of a kind: the self-check's `checked` and mesh-hand-acts' `act-recorded`). A
// consumer of `<seat>.<event>` hears the seat's subject (SeatSays), so the holder says it there, with stdio.SeatPublish
// on `mesh.seat.<seat>.event.<event>`; said through stdio.Emit as the module's own, it lands where nobody listens. Caught
// here, when the manifest is read at the build, not when nothing is heard on the machines.
func seatEventProblems(m Manifest) []string {
var problems []string
for _, c := range m.Claims {
seat, known := SeatNamed(c.Name)
if !known || len(seat.Emits) == 0 {
continue
}
for _, e := range m.Emits {
if SeatSays(seat.Emits, e) {
problems = append(problems, fmt.Sprintf("%s lists %q among its own emits, and the seat %s it claims says it: "+
"say it on the seat with stdio.SeatPublish(\"mesh.seat.%s.event.%s\", ...) and leave it out of emits, or "+
"no consumer of %s.%s hears it", m.Module, e, seat.Name, seat.Name, e, seat.Name, e))
}
}
}
return problems
}
// topicMatches is whether an event's name matches a declared pattern, `*` one dot-separated segment and
// `>` or `**` the rest.
func topicMatches(pattern, event string) bool {
+1
View File
@@ -9,6 +9,7 @@ import (
// The delivery seat answers "what did the mesh's checks say of this pull request?" as a verb of its own,
// `checks` (novox/hq ADR 0239): read by repository and a pull request's number or a commit.
func TestTheDeliverySeatPromisesChecks(t *testing.T) {
t.Run("the seat mesh-grants promises the lease write", grantsSeatPromisesTheLeaseWrite)
seat, ok := SeatNamed(DeliverySeat)
if !ok {
t.Fatal("the delivery seat is not in the set")
-26
View File
@@ -193,32 +193,6 @@ func TestASettingComposesWithADerivedValue(t *testing.T) {
}
}
// A fact or an alphabet the mesh does not have is refused where the definition is, not where a
// consumer happens to be resolved — and the refusal says what may be said instead.
func TestAServedValueNamingSomethingTheMeshDoesNotHaveIsRefused(t *testing.T) {
for _, c := range []struct{ value, says string }{
{"${consumer:node}", "as"},
{"${consumer:as:punycode}", "dns"},
} {
m := store()
m.Serves["s3-bucket"]["bucket"] = c.value
raw, err := json.Marshal(m)
if err != nil {
t.Fatal(err)
}
_, err = ParseManifest(raw)
if err == nil {
t.Fatalf("%s was accepted", c.value)
}
if !strings.Contains(err.Error(), c.value) {
t.Errorf("the refusal of %s does not quote it: %v", c.value, err)
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("the refusal of %s does not say what may be said (%q): %v", c.value, c.says, err)
}
}
}
// `dns` is checked against an identity the mesh actually mints, not an invented string.
func TestTheDNSAlphabetIsTheMintedIdentityWithItsSeparatorRewritten(t *testing.T) {
as := ConsumerIdentity("anchor", IdentitySource("ncloud", "nextcloud"))
-111
View File
@@ -106,14 +106,6 @@ func dirsFor(m Manifest, with Rendering) map[string]string {
return dirs
}
// placedOrAbsolute says a path is usable where the mesh needs one: absolute already, or
// beginning with a placed reference — resolution makes it absolute before anything reads it.
// (unknownDirRefs is what checks the reference names a real directory.)
func placedOrAbsolute(path string) bool {
return strings.HasPrefix(path, "/") ||
(strings.HasPrefix(path, "${dir:") && dirRef.MatchString(path))
}
// dirFill resolves every ${dir:…} in one string, or refuses a reference naming no directory.
func dirFill(s string, dirs map[string]string, module string) (string, error) {
var missing error
@@ -260,109 +252,6 @@ func dirInto(resource map[string]any, dirs map[string]string, module string) err
return nil
}
// unknownDirRefs is every ${dir:…} in the definition that names no directory the definition
// declares — refused where the author is, not at composition on some later day (the same
// near-versus-far reasoning as the host's strict parse).
func (m Manifest) unknownDirRefs() []string {
declared := map[string]bool{}
for _, r := range m.Resources {
if fmt.Sprint(r["type"]) == "directory" {
declared[fmt.Sprint(r["id"])] = true
}
}
referenced := func(s string) []string {
var ids []string
for _, match := range dirRef.FindAllStringSubmatch(s, -1) {
ids = append(ids, match[1])
}
return ids
}
var problems []string
for _, r := range m.Resources {
place, said := r["place"].(string)
if !said {
continue
}
if fmt.Sprint(r["type"]) != "directory" {
problems = append(problems, fmt.Sprintf(
"%s says place on %v, which is not a directory — only a directory is placed",
m.Module, r["id"]))
continue
}
if path, stated := r["path"].(string); stated && path != "" {
problems = append(problems, fmt.Sprintf(
"%s states both path and place on %v — a stated path IS the placement",
m.Module, r["id"]))
}
if place != placeOwn && place != placeMesh {
problems = append(problems, fmt.Sprintf(
"%s says place %q on %v, and the places are %q — the assignment's own root — and "+
"%q — where the mesh keeps what it writes for the module",
m.Module, place, r["id"], placeOwn, placeMesh))
}
}
seen := map[string]bool{}
refuse := func(id string, where any) {
if declared[id] || seen[id] {
return
}
seen[id] = true
problems = append(problems, fmt.Sprintf(
"%s says ${dir:%s} in %v, and declares no directory %q — a reference the mesh "+
"cannot place would reach the machine as a literal path",
m.Module, id, where, id))
}
for _, r := range m.Resources {
for _, field := range []string{"path", "content"} {
if s, ok := r[field].(string); ok {
for _, id := range referenced(s) {
refuse(id, r["id"])
}
}
}
for _, field := range []string{"volumes", "env-file"} {
if list, ok := r[field].([]any); ok {
for _, v := range list {
if s, ok := v.(string); ok {
for _, id := range referenced(s) {
refuse(id, r["id"])
}
}
}
}
}
if env, ok := r["env"].(map[string]any); ok {
for _, v := range env {
if s, ok := v.(string); ok {
for _, id := range referenced(s) {
refuse(id, r["id"])
}
}
}
}
}
maps := map[string]map[string]string{
"receives": m.Receives, "binds": m.Binds, "secrets": m.Secrets,
"own-secrets": m.OwnSecrets.Paths(), "grants": m.Grants,
}
for field, entries := range maps {
for _, value := range entries {
for _, id := range referenced(value) {
refuse(id, field)
}
}
}
for to, locals := range m.SecretsMany {
for _, value := range locals {
for _, id := range referenced(value) {
refuse(id, "secrets."+to)
}
}
}
sort.Strings(problems)
return problems
}
func namesOfDirs(dirs map[string]string) []string {
var names []string
for id := range dirs {
-51
View File
@@ -110,18 +110,6 @@ func TestFillingForOneNodeLeaksIntoNoOther(t *testing.T) {
}
}
func TestAReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) {
m := placedModule()
m.Resources[2]["volumes"] = []any{"${dir:date}:/data"} // a typo, the likely shape
problems := m.unknownDirRefs()
if len(problems) != 1 || !strings.Contains(problems[0], `${dir:date}`) {
t.Fatalf("a reference naming no directory is a manifest problem; got %v", problems)
}
if got := placedModule().unknownDirRefs(); len(got) != 0 {
t.Fatalf("a correct definition has none; got %v", got)
}
}
func TestAReferenceToNoDirectoryRefusesAtCompositionToo(t *testing.T) {
m := placedModule()
container := shallowCopy(m.Resources[2])
@@ -188,39 +176,6 @@ func TestTheManifestsMapsArePlaced(t *testing.T) {
}
}
func TestAMapReferenceToNoDirectoryRefusesAtTheManifest(t *testing.T) {
m := Manifest{
Module: "photos",
Resources: []map[string]any{{"id": "state", "type": "directory", "place": "."}},
Binds: map[string]string{"route": "${dir:stat}/route.json"},
}
problems := m.unknownDirRefs()
if len(problems) != 1 || !strings.Contains(problems[0], `${dir:stat}`) {
t.Fatalf("a map naming no directory is a manifest problem; got %v", problems)
}
}
func TestPlaceIsValidatedAtTheManifest(t *testing.T) {
both := Manifest{Module: "x", Resources: []map[string]any{
{"id": "d", "type": "directory", "place": ".", "path": "/somewhere"},
}}
if got := both.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "both path and place") {
t.Fatalf("path beside place refuses; got %v", got)
}
elsewhere := Manifest{Module: "x", Resources: []map[string]any{
{"id": "f", "type": "file", "place": ".", "path": "/somewhere", "content": ""},
}}
if got := elsewhere.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], "not a directory") {
t.Fatalf("place on a file refuses; got %v", got)
}
wrong := Manifest{Module: "x", Resources: []map[string]any{
{"id": "d", "type": "directory", "place": "sub/dir"},
}}
if got := wrong.unknownDirRefs(); len(got) != 1 || !strings.Contains(got[0], `the places are "."`) {
t.Fatalf("a place that is neither root refuses; got %v", got)
}
}
func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
// novox/hq issue 174. What the mesh writes for a module — its bus credential, its bindings —
// is the mesh's plumbing under <root>/mesh/<module>, and the definition names it by id.
@@ -234,9 +189,6 @@ func TestTheMeshsDirectoryForAModuleIsAPlace(t *testing.T) {
OwnSecrets: OwnSecrets{"broker": {Path: "${dir:mesh-state}/broker"}},
Binds: map[string]string{"route": "${dir:state}/route.json"},
}
if got := m.unknownDirRefs(); len(got) != 0 {
t.Fatalf("place %q is a place; got %v", "mesh", got)
}
dirs := dirsFor(m, Rendering{})
if dirs["mesh-state"] != "/var/lib/mesh/umami" || dirs["state"] != "/var/lib/umami" {
t.Fatalf("the mesh's directory sits beside the module's, not in it; got %v", dirs)
@@ -301,9 +253,6 @@ func TestADirectoryBeneathAPlacedOneIsPlacedWithIt(t *testing.T) {
{"id": "server", "type": "container", "image": "x@sha256:aa",
"volumes": []any{"${dir:runtime-state}:/data"}},
}}
if got := m.unknownDirRefs(); len(got) != 0 {
t.Fatalf("a path beneath a placed directory is well formed; got %v", got)
}
dirs := dirsFor(m, Rendering{DataRoot: "/srv"})
if dirs["runtime-state"] != "/srv/mesh/gitea/state" {
t.Fatalf("the subdirectory follows the placed one; got %v", dirs)
-47
View File
@@ -1,7 +1,6 @@
package catalogue
import (
"strings"
"testing"
)
@@ -100,52 +99,6 @@ func TestTwoEndpointsOfDifferentShapesAreConfiguredSeparately(t *testing.T) {
}
}
// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is
// written rather than resolving to no port and serving nothing.
func TestARouteNamingAnEndpointTheModuleLacksIsRefused(t *testing.T) {
m := aMediaServer()
m.Contributes["route"][RouteEndpoint] = "absent"
got := strings.Join(RouteProblems(m), "\n")
if !strings.Contains(got, "does not declare") {
t.Fatalf("a route naming an absent endpoint was accepted:\n%s", got)
}
}
// **Two endpoints called the same would make an assignment configure whichever was read last.** The
// point of a name is that it identifies one thing.
func TestTwoEndpointsWithOneNameAreRefused(t *testing.T) {
m := Manifest{Module: "twice", Listens: []Listening{
{Name: "web", Port: 80, From: FromMesh},
{Name: "web", Port: 8080, From: FromMesh},
}}
got := strings.Join(endpointNameProblems(m), "\n")
if !strings.Contains(got, "could mean either") {
t.Fatalf("two endpoints with one name were accepted:\n%s", got)
}
}
// A name that is not a name is refused where it is written: it ends up in something a person types.
func TestAnEndpointNameIsHeldToItsShape(t *testing.T) {
for _, wrong := range []string{"Web", "web port", "3000", "-web", "web_surface"} {
m := Manifest{Module: "odd", Listens: []Listening{{Name: wrong, Port: 80, From: FromMesh}}}
if got := strings.Join(endpointNameProblems(m), "\n"); !strings.Contains(got, "a name is lowercase") {
t.Fatalf("%q was accepted as an endpoint name:\n%s", wrong, got)
}
}
}
// **Every endpoint in the catalogue is unnamed today, and must stay valid.** The word ships one
// release before anything uses it.
func TestAnUnnamedEndpointIsStillValid(t *testing.T) {
m := Manifest{Module: "ordinary", Listens: []Listening{{Port: 443, From: FromEverywhere}}}
if got := endpointNameProblems(m); len(got) != 0 {
t.Fatalf("an unnamed endpoint was refused: %v", got)
}
if got := RouteProblems(m); len(got) != 0 {
t.Fatalf("a module with no route was refused: %v", got)
}
}
// **A route that names an endpoint still carries that endpoint's port.**
//
// Everything downstream reads the port: the provider is told where to reach the consumer, and the
-70
View File
@@ -1,71 +1 @@
package catalogue
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"slices"
"strconv"
"testing"
"git.novox.be/novox/mesh-sdk/go/internal/beside"
)
// The controller refuses a definition's resolved path by the node-engine's own rules, no more (novox/hq issue
// 496): the same lists, read here from mesh-host's internal/apply/placement_guard.go — in a merge check the clone
// beside it at the commit the mesh runs, elsewhere the copy captured in testdata/beside. When the engine's lists
// move, this fails until the controller's move with them, so the gate never refuses what the engine applies nor
// passes what it refuses by path alone.
func TestTheResolvedPathRulesAreTheNodeEnginesOwn(t *testing.T) {
file := filepath.Join(beside.Dir(t, "mesh-host"), "internal", "apply", "placement_guard.go")
parsed, err := parser.ParseFile(token.NewFileSet(), file, nil, 0)
if err != nil {
t.Fatalf("the node-engine's guard does not parse: %v", err)
}
lists := map[string][]string{}
consts := map[string]string{}
ast.Inspect(parsed, func(n ast.Node) bool {
spec, ok := n.(*ast.ValueSpec)
if !ok {
return true
}
for i, name := range spec.Names {
if i >= len(spec.Values) {
continue
}
switch v := spec.Values[i].(type) {
case *ast.CompositeLit:
for _, elt := range v.Elts {
if lit, ok := elt.(*ast.BasicLit); ok && lit.Kind == token.STRING {
s, _ := strconv.Unquote(lit.Value)
lists[name.Name] = append(lists[name.Name], s)
}
}
case *ast.BasicLit:
if v.Kind == token.STRING {
consts[name.Name], _ = strconv.Unquote(v.Value)
}
}
}
return true
})
for name, ours := range map[string][]string{
"protectedRoots": protectedRoots, "forbiddenBelow": forbiddenBelow, "engineTrees": engineTrees,
} {
theirs := lists[name]
if len(theirs) == 0 {
t.Errorf("the node-engine's guard names no %s any more; read it and say where its rule went", name)
continue
}
a, b := slices.Clone(ours), slices.Clone(theirs)
slices.Sort(a)
slices.Sort(b)
if !slices.Equal(a, b) {
t.Errorf("%s differs from the node-engine's:\n controller %v\n node-engine %v", name, a, b)
}
}
if consts["engineModule"] != engineModule {
t.Errorf("the node-engine's own module is %q there and %q here", consts["engineModule"], engineModule)
}
}
+96
View File
@@ -0,0 +1,96 @@
package catalogue
// **Joining a machine is the seat mesh-enrolment, held once** (novox/hq ADR 0587 §1, issue 774): held for the mesh by
// the module mesh-enrolment, the only caller the vault's manifest names for its enrolment-token and node-account verbs
// and a caller it names for `sign`. Six verbs, five events, none carrying a secret or anything derived from the token:
// the one hash an event carries is the digest of the public keys a node presented. It mints and keeps no secret and
// holds no signing key. Its state and its key layout are go/delivery's (enrolment.go).
// EnrolmentSeat is the seat mesh-enrolment.
const EnrolmentSeat = "mesh-enrolment"
// The seat's six verbs (ADR 0587 §1, §4-§8).
const (
EnrolmentVerbIssue = "issue"
EnrolmentVerbRedeem = "redeem"
EnrolmentVerbConfirm = "confirm"
EnrolmentVerbRevoke = "revoke"
EnrolmentVerbTokens = "tokens"
EnrolmentVerbAgain = "again"
)
// The seat's five events (ADR 0587 §1, §2, §5): said by its holder on the seat, each carrying an enrolment key's whole
// content except enrolment-expired, which names the node and the enrolment-issued event that carried it.
const (
EnrolmentIssuedEvent = "enrolment-issued"
NodeEnrolledEvent = "node-enrolled"
EnrolmentRefusedEvent = "enrolment-refused"
EnrolmentRevokedEvent = "enrolment-revoked"
EnrolmentExpiredEvent = "enrolment-expired"
)
// EnrolmentEvents are the seat's events, in the order an enrolment says them; the last four each end one.
var EnrolmentEvents = []string{EnrolmentIssuedEvent, NodeEnrolledEvent, EnrolmentRefusedEvent, EnrolmentRevokedEvent,
EnrolmentExpiredEvent}
// enrolmentDecision is the record that made the seat.
const enrolmentDecision = "novox/hq ADR 0587"
// enrolmentVerbs are the seat's verbs: what reads all it holds and does by hand all it does (ADR 0587 §8). Not optional:
// the seat is new with its holder, so no holder of before serves fewer.
func enrolmentVerbs() []Verb {
str := func(description string) map[string]any {
return map[string]any{"type": "string", "description": description}
}
return []Verb{
{Name: EnrolmentVerbIssue, Description: "Issue a one-time token for a new machine, on the operator's Approve: " +
"refused at once, naming every finding, for a name that is not a node name or that a node record, a placement " +
"key or an open enrolment holds, and for every call before the controller's lease is handed away. Otherwise " +
"reserves the lowest free private-network address, asks the operator under the seat's own name and, on the " +
"warrant, adds the node to placement with its tunnel key, asks the vault for the token and says enrolment-issued. " +
"The token itself is read only through tokens.",
Input: schema(map[string]string{
"node": "the new machine's name in the mesh",
"why": "why it joins, shown to the operator with the ask",
"expires": "how long the token lasts, a duration such as 1h or 24h: one hour when not given, at most 24 hours",
// The operator's decision of 2026-11-02 01:05: the tunnel key is given at issue, so on the Approve the node
// enters placement with it and the hub peers it before redeem.
"overlay_key": "the new machine's private network (tunnel) public key, as `nox-mesh-host key` prints it",
}, []string{"node", "why", "overlay_key"})},
{Name: EnrolmentVerbRedeem, Description: "Spend a token: asked by a joining node-engine dialled as enrol.<node> " +
"alone, with the public halves of its keys and its identity key's proof over them. Spends the token, asks the " +
"vault for the node's account and certificate, writes the node into placement, and answers the account sealed " +
"to the node, its certificate and the vault's public half, signed by the vault. Asked again with the same keys " +
"it finishes what is not done and answers the same; with other keys it is refused.",
Input: map[string]any{"type": "object", "additionalProperties": false,
"required": []string{"node", "public_key", "sealing_key", "proof"},
"properties": map[string]any{
"node": str("the node the token was issued for"),
"public_key": str("the identity key's public half, base64"),
"overlay_key": str("the private network key's public half"),
"sealing_key": str("the sealing key's public half, which the account is sealed to"),
"serving_key": str("the serving key's public half, which the certificate binds"),
"profile": map[string]any{"type": "object", "description": "what the machine is, as its record says it"},
"tunnel": map[string]any{"type": "object", "description": "a tunnel found on the machine: everything but its private key"},
"proof": str("the identity key's signature over the redeem's proof bytes (delivery.RedeemProof), base64"),
}}},
{Name: EnrolmentVerbConfirm, Description: "Confirm an enrolment: asked by the node-engine dialled as its own " +
"node login alone, the runtime's proof of that caller being the confirmation. Says node-enrolled and ends the " +
"enrolment.",
Input: schema(map[string]string{}, nil)},
{Name: EnrolmentVerbRevoke, Description: "End an enrolment at any stage, with why, without an Approve: withdraws " +
"an open ask, deletes the node's key and its address, has the vault retire an account it already minted for " +
"it, records the act as a hand act and says enrolment-revoked.",
Input: schema(map[string]string{"node": "the node", "why": "why, recorded with the hand act"},
[]string{"node", "why"})},
{Name: EnrolmentVerbTokens, Description: "Read every open enrolment, or one: its stage, expiry, ask and revision, " +
"the bucket's fill against its cap, and whether issuing is on and why (the lease). For a node at live, its " +
"token sealed to the operator key, asked of the vault at that moment and relayed as the vault sealed it. With " +
"history, the ended enrolments from the seat's events, as far back as the events are kept.",
Input: schema(map[string]string{"node": "one node", "history": "\"true\": the ended enrolments too"},
nil, "history")},
{Name: EnrolmentVerbAgain, Description: "Raise an issue's ask again when the operator's channel does not hold it, " +
"and nothing more.",
Input: schema(map[string]string{"node": "the node whose ask is raised again"}, []string{"node"})},
}
}
-98
View File
@@ -114,64 +114,6 @@ var (
ofContributed = regexp.MustCompile(`\$\{(shell|contribution|backup):([^}]*)\}`)
)
// variableName is a POSIX shell variable name, which is also what environment.d accepts.
var variableName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
// environmentProblems is what is wrong with this module's environment contribution, from the
// manifest alone.
func (m Manifest) environmentProblems() []string {
if m.Environment == nil {
return nil
}
var problems []string
for _, n := range sortedKeys(m.Environment.Variables) {
switch {
case !variableName.MatchString(n):
problems = append(problems, fmt.Sprintf(
"%s sets the variable %q, which is not a name a shell accepts: a letter or an "+
"underscore, then letters, digits and underscores", m.Module, n))
continue
case n == "PATH":
// PATH is the one variable every module shares, so no module may set it whole: a second
// setter would replace the first's entries, and the account's own PATH with them.
problems = append(problems, fmt.Sprintf(
"%s sets PATH as a variable; a module adds an entry under environment.path, at the "+
"start or the end, and PATH is composed from every module's (novox/hq ADR 0203)", m.Module))
continue
}
if why := literalProblem(m.Environment.Variables[n]); why != "" {
problems = append(problems, fmt.Sprintf(
"%s sets %s to %q, which %s — %s", m.Module, n, m.Environment.Variables[n], why, literalRule))
}
}
seen := map[string]bool{}
for i, p := range m.Environment.Path {
switch {
case p.Entry == "":
problems = append(problems, fmt.Sprintf("%s's PATH entry %d names no directory", m.Module, i+1))
case strings.Contains(ofMachine.ReplaceAllString(p.Entry, ""), ":"):
// A colon is PATH's own separator, so an entry holding one is two entries, and the
// check that it is already present would look for the wrong thing.
problems = append(problems, fmt.Sprintf(
"%s puts %q on PATH, which holds a colon, PATH's own separator", m.Module, p.Entry))
case seen[p.Entry]:
problems = append(problems, fmt.Sprintf("%s puts %q on PATH twice", m.Module, p.Entry))
default:
if why := literalProblem(p.Entry); why != "" {
problems = append(problems, fmt.Sprintf(
"%s puts %q on PATH, which %s — %s", m.Module, p.Entry, why, literalRule))
}
}
seen[p.Entry] = true
if p.At != PathAtStart && p.At != PathAtEnd {
problems = append(problems, fmt.Sprintf(
"%s puts %q on PATH at %q; an entry goes at %q or %q of the account's PATH",
m.Module, p.Entry, p.At, PathAtStart, PathAtEnd))
}
}
return problems
}
// literalRule is why a value must be literal, said with every refusal of one.
const literalRule = "a value is literal, so a POSIX shell and the service manager read it alike, and " +
"names the machine only through the mesh's own ${machine:…} facts (novox/hq ADR 0203)"
@@ -195,46 +137,6 @@ func literalProblem(v string) string {
return ""
}
// shellProblems is what is wrong with this module's shell code, from the manifest alone. The code
// itself is not judged: it is the shell's syntax, which the controller does not read.
func (m Manifest) shellProblems() []string {
var problems []string
for i, c := range m.Shell {
if !oneOf(contributionTargets(), c.For) {
problems = append(problems, fmt.Sprintf(
"%s's shell code %d is for %q; the shells are %s, the session's files %s, and the power "+
"moments %s", m.Module, i+1, c.For, strings.Join(knownShells, ", "),
strings.Join(sessionFiles, ", "), strings.Join(powerMoments, ", ")))
}
if !oneOf(knownSlots, c.Slot) {
problems = append(problems, fmt.Sprintf(
"%s's shell code %d goes in the slot %q; the slots are %s", m.Module, i+1, c.Slot,
strings.Join(knownSlots, ", ")))
}
if strings.TrimSpace(c.Code) == "" {
problems = append(problems, fmt.Sprintf("%s's shell code %d has no code", m.Module, i+1))
}
}
return problems
}
// contributionPlaceholderProblems is every place this module's resources name the environment or
// the shell's code and may not — judged from the manifest, so the catalogue check refuses it before
// a mesh does, and again at composition in the same words.
func (m Manifest) contributionPlaceholderProblems() []string {
var problems []string
for _, r := range m.Resources {
problems = append(problems, placeholderProblems(m, r)...)
problems = append(problems, seatPlaceholderProblems(m, r)...)
problems = append(problems, collectedPlaceholderProblems(m, r)...)
problems = append(problems, consumersPlaceholderProblems(m, r)...)
// And every placeholder no pass fills where it stands: a misspelt namespace, a key its
// namespace cannot take, or a field its pass does not read (novox/hq issue 231).
problems = append(problems, unconsumedPlaceholders(m.Module, r)...)
}
return problems
}
// placeholderProblems is what is wrong with one resource's ${environment:…} and ${shell:…}.
//
// **The seat authorises it, not the placeholder** (novox/hq ADR 0203 §5, ADR 0204 §3), as the seat
-122
View File
@@ -276,102 +276,6 @@ func TestTheEnvironmentHolderPlacesBothRenderings(t *testing.T) {
}
}
// ADR 0203 §5 and ADR 0204 §3: a placeholder outside the seat's holder is refused — by the parser,
// which is what the catalogue check and registration run, and again at composition, in the same words.
func TestAPlaceholderOutsideTheHolderIsRefused(t *testing.T) {
for _, c := range []struct{ content, want string }{
{"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"},
{"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"},
} {
raw := `{"module":"toolchain","resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("the catalogue check accepted %s outside its holder: %v", c.content, err)
}
m := Manifest{Module: "toolchain", Resources: []map[string]any{
{"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}}
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{m}}
if _, err := r.Declaration(Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("composition accepted %s outside its holder: %v", c.content, err)
}
}
}
// A key nobody renders is refused, not left in the file as a literal.
func TestAnUnknownPlaceholderKeyIsRefused(t *testing.T) {
for _, c := range []struct{ content, want string }{
{"${environment:foo}", "names ${environment:foo}; the environment is ${environment:posix} or ${environment:systemd}"},
{"${shell:zsh:middle}", "names ${shell:zsh:middle}; shell code is ${shell:<shell>:<slot>}"},
{"${shell:tcsh:first}", "names ${shell:tcsh:first}; shell code is ${shell:<shell>:<slot>}"},
{"${shell:zsh}", "names ${shell:zsh}; shell code is ${shell:<shell>:<slot>}"},
} {
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"},{"name":"node-login-shell","scope":"node"}],` +
`"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` + c.content + `"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s was accepted: %v", c.content, err)
}
}
// And outside a file's content, where nothing could be placed.
raw := `{"module":"holder","claims":[{"name":"node-environment","scope":"node"}],` +
`"resources":[{"id":"rc","type":"file","path":"/etc/${environment:posix}","content":"x"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil ||
!strings.Contains(err.Error(), "names ${environment:posix} in its path; the environment and the shell's code are placed only in a file's content") {
t.Errorf("a placeholder in a path was accepted: %v", err)
}
}
// What ADR 0203 §2 allows a contribution to say, refused at parse when it says anything else.
func TestAMalformedEnvironmentIsRefusedAtParse(t *testing.T) {
for _, c := range []struct{ environment, want string }{
{`{"variables":{"1X":"a"}}`, `tool sets the variable "1X", which is not a name a shell accepts`},
{`{"variables":{"MY-VAR":"a"}}`, `tool sets the variable "MY-VAR", which is not a name a shell accepts`},
{`{"variables":{"PATH":"/bin"}}`, `tool sets PATH as a variable; a module adds an entry under environment.path`},
{`{"variables":{"A":"$HOME/x"}}`, `tool sets A to "$HOME/x", which holds a $ that is not one of the machine's ${machine:…} facts`},
{`{"variables":{"A":"${HOME}/x"}}`, `tool sets A to "${HOME}/x", which holds a $`},
{`{"variables":{"A":"it's"}}`, `tool sets A to "it's", which holds a quote`},
{`{"variables":{"A":"say \"hi\""}}`, `which holds a quote`},
{`{"variables":{"A":"a\\b"}}`, `which holds a backslash`},
{`{"variables":{"A":"a\nb"}}`, `which holds a line break`},
{`{"variables":{"A":"a\u0000b"}}`, `which holds a NUL`},
{`{"path":[{"entry":"","at":"start"}]}`, `tool's PATH entry 1 names no directory`},
{`{"path":[{"entry":"/a:/b","at":"start"}]}`, `tool puts "/a:/b" on PATH, which holds a colon`},
{`{"path":[{"entry":"$HOME/bin","at":"start"}]}`, `tool puts "$HOME/bin" on PATH, which holds a $`},
{`{"path":[{"entry":"/a","at":"middle"}]}`, `tool puts "/a" on PATH at "middle"; an entry goes at "start" or "end"`},
{`{"path":[{"entry":"/a"}]}`, `tool puts "/a" on PATH at ""`},
{`{"path":[{"entry":"/a","at":"start"},{"entry":"/a","at":"end"}]}`, `tool puts "/a" on PATH twice`},
{`{"variables":{"A":"x"},"paths":[]}`, `unknown field "paths"`},
} {
_, err := ParseManifest([]byte(`{"module":"tool","environment":` + c.environment + `}`))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: want %q, got %v", c.environment, c.want, err)
}
}
// What is allowed: a literal, and the machine's own facts.
if _, err := ParseManifest([]byte(`{"module":"tool","environment":{` +
`"variables":{"GOPATH":"${machine:account-home}/go","DISABLE_X":"1","ANSWER":"a b+c=d"},` +
`"path":[{"entry":"${machine:account-home}/go/bin","at":"start"},{"entry":"/opt/x","at":"end"}]}}`)); err != nil {
t.Fatalf("a well-formed environment was refused: %v", err)
}
}
func TestMalformedShellCodeIsRefusedAtParse(t *testing.T) {
for _, c := range []struct{ shell, want string }{
{`[{"for":"tcsh","slot":"normal","code":"x"}]`, `tool's shell code 1 is for "tcsh"; the shells are zsh, bash, fish`},
{`[{"for":"zsh","slot":"middle","code":"x"}]`, `tool's shell code 1 goes in the slot "middle"; the slots are first, normal, last`},
{`[{"for":"zsh","slot":"last","code":"x"},{"for":"zsh","slot":"last","code":" \n"}]`, `tool's shell code 2 has no code`},
{`[{"for":"zsh","slot":"last","code":"x","order":1}]`, `unknown field "order"`},
} {
_, err := ParseManifest([]byte(`{"module":"tool","shell":` + c.shell + `}`))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: want %q, got %v", c.shell, c.want, err)
}
}
// The code itself is never judged: a shell's own `${…}` is not the mesh's.
if _, err := ParseManifest([]byte(`{"module":"tool","shell":[{"for":"zsh","slot":"first",` +
`"code":"source \"${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh\""}]}`)); err != nil {
t.Fatalf("shell code was judged as if it were the mesh's: %v", err)
}
}
// ADR 0203 §1 and ADR 0204 §1: both seats are the mesh's own, held once per machine; the login
// shell's contract is `execute`, described and with a schema an agent can call.
func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) {
@@ -396,29 +300,3 @@ func TestTheSeatTableCarriesTheEnvironmentAndTheLoginShell(t *testing.T) {
t.Fatalf("execute takes no timeout: %v", props)
}
}
// ADR 0204 §1: the login shell is the mesh's, so no module declares it — neither under the mesh's
// name nor under the name a module gave it before.
func TestNoModuleMayDeclareTheLoginShell(t *testing.T) {
for _, n := range []string{"login-shell", "node-login-shell", "node-environment"} {
raw := `{"module":"zsh","seats":[{"name":"` + n + `","scope":"node","serves":["execute"]}],"tools":["execute"]}`
_, err := ParseManifest([]byte(raw))
if err == nil {
t.Errorf("a module declaring %q was accepted", n)
}
}
got := strings.Join(declaredSeatProblems(Manifest{Module: "zsh",
DefinesSeats: []SeatDeclaration{{Name: "login-shell", Scope: ScopeNode}}}), "; ")
if !strings.Contains(got, `zsh declares a seat named "login-shell"; the login shell is the mesh's own seat node-login-shell`) {
t.Fatalf("declaring login-shell was not refused by name: %q", got)
}
// And a shell module claiming the mesh's seat, serving execute, is what the seat is for.
m, err := ParseManifest([]byte(`{"module":"zsh","tools":["execute"],` +
`"claims":[{"name":"node-login-shell","scope":"node"}]}`))
if err != nil {
t.Fatal(err)
}
if err := CanHold(m, Seat{Name: LoginShellSeat, Scope: ScopeNode, Serves: loginShellVerbs()}); err != nil {
t.Fatalf("a shell module claiming the seat cannot hold it: %v", err)
}
}
+1 -25
View File
@@ -129,31 +129,7 @@ func TestTheEnvironmentOfTheCatalogueIsTheSameHandedOrNot(t *testing.T) {
}
}
func TestAnEnvironmentItemIsTypedAndNamesWhatItNeeds(t *testing.T) {
item := func(attrs map[string]string) []string {
return environmentItemProblems("x", Item{Collection: "environment", Attributes: attrs})
}
for _, ok := range []map[string]string{
{"kind": "variable", "name": "GTK_THEME"},
{"kind": "path", "at": "end", "order": "10"},
} {
if p := item(ok); len(p) != 0 {
t.Errorf("%v is refused: %v", ok, p)
}
}
for _, bad := range []map[string]string{
nil, {"kind": "other"}, {"kind": "variable"}, {"kind": "variable", "name": "PATH"},
{"kind": "path"}, {"kind": "path", "at": "middle"}, {"kind": "path", "at": "start", "name": "X"},
{"kind": "variable", "name": "A", "color": "red"},
} {
if p := item(bad); len(p) == 0 {
t.Errorf("%v is taken", bad)
}
}
if !attributeFits("environment", "name", "GTK_THEME") || attributeFits("shell:zsh:normal", "name", "GTK_THEME") ||
attributeFits("environment", "kind", "Variable") {
t.Error("an upper-case name is the environment's name alone")
}
func TestTwoModulesSettingOneVariableAreRefused(t *testing.T) {
// A module that sets a variable twice, in two modules, is refused as the old form refused it.
a := Manifest{Module: "a", Items: []Item{{Collection: "environment", Content: "1\n",
Attributes: map[string]string{"kind": "variable", "name": "X"}}}}
-127
View File
@@ -1,10 +1,8 @@
package catalogue
import (
"fmt"
"regexp"
"slices"
"strings"
)
// What a module may call an event, and what a consumer may ask for.
@@ -36,131 +34,6 @@ const (
TheRest = "**"
)
// EventProblems is what is wrong with a manifest's events.
//
// Refused at registration, because the alternative is a module that installs, starts, connects and
// reacts to nothing — and every log line says it is fine.
func EventProblems(m Manifest) []string {
var problems []string
for _, e := range m.Emits {
if was, stale := staleEventForm(e, m.Module); stale {
problems = append(problems, fmt.Sprintf(
"%s emits %q, which is the old bus's routing key. An event is named locally now, so "+
"write %q — the mesh derives the subject (novox/hq design 29 §1)",
m.Module, was, strings.TrimPrefix(was, "module."+m.Module+".")))
continue
}
if strings.HasPrefix(e, "module.") {
problems = append(problems, fmt.Sprintf(
"%s emits %q: `module.` is reserved, because it is how the old bus spelled a "+
"routing key and an event named that way derives into a namespace nobody owns",
m.Module, e))
continue
}
if err := localName(e); err != nil {
problems = append(problems, fmt.Sprintf("%s emits %q: %v", m.Module, e, err))
continue
}
// **Its own name, never another's.** The bus enforces that a namespace belongs to the module
// it is named for, so an event named for somebody else cannot be published at all. If the
// event is about a role rather than about this module, it belongs on the seat: a name that
// is stable across whoever fills it (04-ISSUES/127).
if first, _, split := strings.Cut(e, "."); split && isAModuleNameOtherThan(first, m.Module) {
problems = append(problems, fmt.Sprintf(
"%s emits %q, which reads as another module's event. A module publishes under its "+
"own name only. If this is about a role rather than about %s, declare it on that "+
"seat, where the name survives the holder changing",
m.Module, e, m.Module))
}
}
for _, c := range m.Consumes {
if strings.HasPrefix(c, "module.") {
problems = append(problems, fmt.Sprintf(
"%s consumes %q, which is the old bus's pattern. A consumed event names its emitter "+
"and the event: write %q", m.Module, c, strings.TrimPrefix(c, "module.")))
continue
}
if c == "#" {
problems = append(problems, fmt.Sprintf(
"%s consumes %q, which is the old bus's wildcard for everything. Write %q",
m.Module, c, TheRest))
continue
}
if err := consumePattern(c); err != nil {
problems = append(problems, fmt.Sprintf("%s consumes %q: %v", m.Module, c, err))
}
}
return problems
}
// staleEventForm says an emitted name is this module's own old routing key, and what it was.
func staleEventForm(event, module string) (string, bool) {
return event, module != "" && strings.HasPrefix(event, "module."+module+".")
}
// isAModuleNameOtherThan says a first token names some module of this mesh that is not this one.
//
// Only the mesh's own seats and the catalogue could answer this properly, and neither is reachable
// from a parser given one manifest. So this catches the case that actually happened — a name that
// is a *provision* the mesh defines, which is where "another module's event" comes from in practice
// — and the whole-catalogue check catches the rest.
func isAModuleNameOtherThan(first, module string) bool {
if first == module || first == "" {
return false
}
if _, isASeat := SeatNamed(first); isASeat {
return true
}
if _, isASeat := SeatDelivering(first); isASeat {
return true
}
return false
}
// localName checks one event name: dot-separated names, no wildcards, nothing else.
func localName(event string) error {
if event == "" {
return fmt.Errorf("an event needs a name")
}
for _, part := range strings.Split(event, ".") {
if part == OneName || part == TheRest {
return fmt.Errorf("an emitted event names one event, so it carries no wildcard")
}
if !eventName.MatchString(part) {
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
}
}
return nil
}
// consumePattern checks a consumed pattern: the emitter, then the event, with wildcards.
func consumePattern(pattern string) error {
if pattern == "" {
return fmt.Errorf("a consumed event needs an emitter and an event")
}
parts := strings.Split(pattern, ".")
for i, part := range parts {
switch {
case part == TheRest:
if i != len(parts)-1 {
return fmt.Errorf("%q stands for the rest of a name, so nothing may follow it", TheRest)
}
case part == OneName:
case !eventName.MatchString(part):
return fmt.Errorf("%q is not a usable name: lower-case letters, digits and dashes", part)
}
}
// `**` alone is every event from every module, which the audit logger wants and says plainly.
if len(parts) == 1 && parts[0] != TheRest {
return fmt.Errorf(
"%q names an emitter and no event. Write <emitter>.<event>, or %q for every event",
pattern, TheRest)
}
return nil
}
// The events a provider says about its consumers (novox/hq ADR 0224): a consumer it has failed
// without one success for minutes, and that consumer succeeding again or being withdrawn. The
// controller follows them from every module and `status` names a consumer failing until it recovers.
-10
View File
@@ -98,16 +98,6 @@ func filesVerbs() []Verb {
)
}
// FilesHolderProblems is what a holder of node-files is held to (novox/hq ADR 0394 §2): it runs as the tool
// runner's account, the node's operator account, so it declares no runs-as.
func FilesHolderProblems(m Manifest) []string {
if !m.ClaimsSeat(FilesSeat) || m.RunsAs == "" {
return nil
}
return []string{fmt.Sprintf("%s claims %s and declares runs-as %q: its holder runs as the tool runner's account, "+
"the node's operator account (novox/hq ADR 0394 §2)", m.Module, FilesSeat, m.RunsAs)}
}
// ownedPaths is every path a module owns on this node, as the holder is told (novox/hq ADR 0394 §5): the
// paths every seat declares (ADR 0287) with the module holding the seat here, and the paths and files each
// module's manifest places (declaredPaths). A seat's path wins where both name one.
-14
View File
@@ -46,20 +46,6 @@ func TestNodeFilesIsANodeSeatWithEveryVerbOfADR0394(t *testing.T) {
}
}
// issue 900, ADR 0394 §2: a holder of node-files runs as the tool runner's account, so it declares no runs-as.
func TestAHolderOfNodeFilesDeclaringRunsAsIsRefused(t *testing.T) {
p := FilesHolderProblems(Manifest{Module: "files", RunsAs: "mesh-files", Claims: []Claim{{Name: FilesSeat}}})
if len(p) != 1 || !strings.Contains(p[0], "0394") || !strings.Contains(p[0], "runs-as") {
t.Errorf("a holder declaring runs-as was not refused: %v", p)
}
if p := FilesHolderProblems(Manifest{Module: "files", Claims: []Claim{{Name: FilesSeat}}}); len(p) != 0 {
t.Errorf("a holder declaring none was refused: %v", p)
}
if p := FilesHolderProblems(Manifest{Module: "other", RunsAs: "x"}); len(p) != 0 {
t.Errorf("a non-holder was judged: %v", p)
}
}
// issue 900, ADR 0394 §4: the holder is told the secret places and the paths modules own on its node.
func TestTheHolderOfNodeFilesIsToldSecretPlacesAndOwnedPaths(t *testing.T) {
files := Manifest{Module: "files", Version: "1", Claims: []Claim{{Name: FilesSeat}},
-26
View File
@@ -1000,32 +1000,6 @@ func Reaches(m Manifest, layers []Layer) (map[int]string, error) {
// route that names the endpoint says what it means, and the mesh looks the port up.
const RouteEndpoint = "endpoint"
// RouteProblems holds a module's route contributions to naming an endpoint it actually has.
//
// A route naming an endpoint the module does not declare reaches nothing, and is refused where it is
// written rather than resolving to no port and serving nothing — the fault this repository names most
// often, a declaration that reads as though it did something.
func RouteProblems(m Manifest) []string {
var problems []string
check := func(where string, values map[string]any) {
name, ok := values[RouteEndpoint].(string)
if !ok || strings.TrimSpace(name) == "" {
return
}
if _, found := EndpointPort(m, name); !found {
problems = append(problems, fmt.Sprintf(
"%s routes %s to the endpoint %q, which it does not declare", m.Module, where, name))
}
}
if values, ok := m.Contributes["route"]; ok {
check("a name", values)
}
for local, values := range m.ContributesMany["route"] {
check(local, values)
}
return problems
}
// EndpointsSetting is the settings key that configures a module's endpoints by name, per node
// (novox/hq ADR 0138):
//
+4 -45
View File
@@ -7,22 +7,9 @@ import (
"testing"
)
// A manifest key nothing reads is worse than a key that is refused: five HAL manifests carried
// `scope:`, which read as a restriction and restricted nothing (novox/hq 04-ISSUES/003). The host's
// declaration parser has always refused unknown keys; manifests never did.
func TestAManifestKeyNothingReadsIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"web","scope":"mesh"}`))
if err == nil {
t.Fatal("a manifest with a key nothing reads was accepted, which is how scope: survived")
}
if !strings.Contains(err.Error(), "scope") {
t.Fatalf("refused without naming the key, which leaves the author guessing: %v", err)
}
}
// The same discipline must not refuse a manifest that is merely ordinary.
func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) {
m, err := ParseManifest([]byte(
m, err := decodeForTest([]byte(
`{"module":"web","listens":[{"port":443,"from":"anywhere","why":"the site"}]}`))
if err != nil {
t.Fatalf("an ordinary manifest was refused: %v", err)
@@ -32,24 +19,6 @@ func TestAnOrdinaryManifestIsStillAccepted(t *testing.T) {
}
}
// A rule with no source is open, and must say so rather than appear to restrict something.
func TestAPortWithNoSourceIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443}]}`))
if err == nil {
t.Fatal("a port that never said who may reach it was accepted; it reads as a restriction")
}
if !strings.Contains(err.Error(), "from where") {
t.Fatalf("refused without saying what is missing: %v", err)
}
}
func TestASourceNobodyDefinedIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"web","listens":[{"port":443,"from":"lan"}]}`))
if err == nil {
t.Fatal("a source the mesh cannot render was accepted, and would be silently dropped")
}
}
// The derivation is over the whole node, not one module.
func TestTheRuleSetIsEveryAssignedModulesPorts(t *testing.T) {
r := Resolution{Modules: []Manifest{
@@ -357,13 +326,6 @@ func TestTheModuleAskingForTheRuleSetGetsEveryModulesPorts(t *testing.T) {
}
}
func TestAskingForTheRuleSetWithNowhereToPutItIsRefused(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"firewall","filtering":{"into":""}}`))
if err == nil {
t.Fatal("a module asked for the rule set and named no path; it would receive nothing")
}
}
// nftables matches the two address families separately, and one set holding both is a syntax
// error — so the file fails to load, the service reports a fault, and the machine filters nothing.
func TestAMeshOnBothAddressFamiliesRendersBoth(t *testing.T) {
@@ -782,12 +744,9 @@ func TestSSHIsOpenFromTheMeshEvenWhenNothingIsAssigned(t *testing.T) {
if strings.Contains(nft, "\t\ttcp dport 22 accept") {
t.Fatalf("ssh is open to everywhere on a machine that faces inward only:\n%s", nft)
}
}
// And from outside as well, on a machine that faces outward — because that is the way in when the
// private network is the thing that broke.
func TestSSHIsOpenFromOutsideOnAMachineThatFacesIt(t *testing.T) {
nft := AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
// And from outside as well, on a machine that faces outward — because that is the way in when the
// private network is the thing that broke.
nft = AsNftables(nil, []string{"198.51.100.2"}, true, nil, nil, "mesh0")
if !strings.Contains(nft, "\t\ttcp dport 22 accept") {
t.Fatalf("a machine reachable from outside does not answer ssh there:\n%s", nft)
}
-31
View File
@@ -1,37 +1,6 @@
package catalogue
import "testing"
// A bundle is the module's own directory compiled whole, and naming a source would be describing its
// own build. That holds for an interpreted language and cannot hold for a compiled one: a repository
// written in Go carries several commands — the host and its bootstrap live in one — and "the module's
// own directory" is then not a package at all (novox/hq 04-ISSUES/142).
func TestAGoBundleMayNameItsCommand(t *testing.T) {
b := &Build{Artifacts: []Artifact{{
Name: "host-arch", Kind: ArtifactBundle, Language: "go", System: "arch",
From: "cmd/mesh-host",
}}}
if p := b.problems("mesh-host"); len(p) != 0 {
t.Fatalf("a go bundle naming its command was refused: %v", p)
}
}
func TestAnInterpretedBundleStillMayNotNameASource(t *testing.T) {
b := &Build{Artifacts: []Artifact{{
Name: "tools", Kind: ArtifactBundle, Language: "typescript", From: "src",
}}}
p := b.problems("something")
if len(p) == 0 {
t.Fatal("an interpreted bundle naming what it is built from was accepted")
}
}
func TestACompiledBundleStillMustSayItsSystem(t *testing.T) {
b := &Build{Artifacts: []Artifact{{
Name: "host", Kind: ArtifactBundle, Language: "go", From: "cmd/mesh-host",
}}}
if len(b.problems("mesh-host")) == 0 {
t.Fatal("a compiled bundle with no system was accepted")
}
}
+53
View File
@@ -0,0 +1,53 @@
package catalogue
// **The lease write, a verb of the seat `mesh-grants`** (novox/hq ADR 0582 clause 1, issue 774): the grants part
// holds the seat once for the mesh and, on the operator's warrant, hands the controller's lease to itself and back.
// It serves two verbs and says three events, none carrying a secret:
//
// - lease-handed: the lease names mesh-grants, written on the operator's warrant (clause 3);
// - lease-altered: the lease key or its bucket's expiry differs from what mesh-grants wrote (clause 4);
// - removal-failed: the day-later removal of the controller's consumer or stream failed (clause 6).
// GrantsSeat is the seat the grants part (mesh-grants) holds.
const GrantsSeat = "mesh-grants"
// The seat's verbs.
const (
GrantsVerbLeaseWrite = "lease-write"
GrantsVerbLeaseHandBack = "lease-hand-back"
)
// The seat's events.
const (
GrantsEventLeaseHanded = "lease-handed"
GrantsEventLeaseAltered = "lease-altered"
GrantsEventRemovalFailed = "removal-failed"
)
// grantsDecision is the record that made the seat.
const grantsDecision = "novox/hq ADR 0582"
// grantsEvents are the seat's events, in the order the write meets them.
func grantsEvents() []string {
return []string{GrantsEventLeaseHanded, GrantsEventLeaseAltered, GrantsEventRemovalFailed}
}
// grantsVerbs are the seat's verbs (ADR 0582 clauses 1, 2, 3, 5).
func grantsVerbs() []Verb {
return []Verb{
{Name: GrantsVerbLeaseWrite, Description: "Hand the controller's lease to mesh-grants, kept for good. Refused, " +
"naming the check, until the lease check at phase write passes and every condition it does not judge reads " +
"green. Otherwise the operator is asked once; on the warrant the checks are read again, the controller's " +
"service is stopped, the bus's user list is composed without the controller's write on the lease, the lease's " +
"expiry is removed and the key holder names mesh-grants, said as lease-handed and recorded as a hand act. A no " +
"or an expiry of the ask acts on nothing.",
Input: schema(map[string]string{"why": "why the lease is handed now, shown to the operator and kept"},
[]string{"why"})},
{Name: GrantsVerbLeaseHandBack, Description: "Give the lease back to the controller, for a day after the write: " +
"asked and warranted the same way, it restores the lease's fifteen-second expiry, deletes the key holder, " +
"gives the controller its rights back once the list is loaded, and starts the controller. Refused once the " +
"day-later removal ran: the controller would start without its consumer and stream.",
Input: schema(map[string]string{"why": "why the lease goes back, shown to the operator and kept"},
[]string{"why"})},
}
}
+68
View File
@@ -0,0 +1,68 @@
package catalogue
import (
"reflect"
"strings"
"testing"
)
// The seat mesh-grants (novox/hq ADR 0582 clause 1, issue 774, 774/S1/AC2): its holder serves lease-write and
// lease-hand-back, both a condition of holding, and nothing else; the seat says lease-handed, lease-altered and
// removal-failed. A case of TestTheDeliverySeatPromisesChecks, the seat-verb test, within the test budget.
func grantsSeatPromisesTheLeaseWrite(t *testing.T) {
seat, ok := SeatNamed(GrantsSeat)
if !ok {
t.Fatal("the seat mesh-grants is not in the set")
}
if seat.Scope != ScopeMesh || seat.Delivers != "" {
t.Errorf("mesh-grants is at scope %q delivering %q, want mesh and nothing", seat.Scope, seat.Delivers)
}
if got, want := VerbNames(seat.Serves), []string{"lease-write", "lease-hand-back"}; !reflect.DeepEqual(got, want) {
t.Errorf("mesh-grants serves %v, want %v", got, want)
}
if want := []string{"lease-handed", "lease-altered", "removal-failed"}; !reflect.DeepEqual(seat.Emits, want) {
t.Errorf("mesh-grants says %v, want %v", seat.Emits, want)
}
for _, v := range seat.Serves {
if v.Optional {
t.Errorf("%s is optional: the holder ships with the seat, so every verb is a condition of holding", v.Name)
}
props, _ := v.Input["properties"].(map[string]any)
if req, _ := v.Input["required"].([]string); len(props) != 1 || !reflect.DeepEqual(req, []string{"why"}) {
t.Errorf("%s takes %v requiring %v, want only a why", v.Name, props, req)
}
}
claim := func(serves ...string) Manifest {
return Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeMesh, Serves: serves}}}
}
for _, c := range []struct {
name string
m Manifest
refuse string // empty: held
}{
{"both verbs", claim("lease-write", "lease-hand-back"), ""},
{"without the hand-back", claim("lease-write"), "lease-hand-back"},
{"a verb the seat does not promise", claim("lease-write", "lease-hand-back", "lease-take"), "lease-take"},
{"at node scope", Manifest{Module: "mesh-grants", Claims: []Claim{{Name: GrantsSeat, Scope: ScopeNode,
Serves: []string{"lease-write", "lease-hand-back"}}}}, "scope"},
} {
err := CanHold(c.m, seat)
switch {
case c.refuse == "" && err != nil:
t.Errorf("%s: refused: %v", c.name, err)
case c.refuse != "" && (err == nil || !strings.Contains(err.Error(), c.refuse)):
t.Errorf("%s: %v, want a refusal naming %q", c.name, err, c.refuse)
}
}
// No other seat changed: the set is the one before it with mesh-grants added beside mesh-hand-acts.
var names []string
for _, s := range DefaultSeats() {
names = append(names, s.Name)
}
at := strings.Index(strings.Join(names, " "), HandActsSeat+" "+GrantsSeat+" ")
if at < 0 {
t.Errorf("mesh-grants does not follow mesh-hand-acts in the seat set: %v", names)
}
}
-85
View File
@@ -61,14 +61,6 @@ func TestTheGraphicalSessionsSeatsAreTheMeshsOwnWithTheirVerbs(t *testing.T) {
}
}
// §2: a module may claim one of them, and may not declare it as its own.
func TestNoModuleMayDeclareAGraphicalSessionSeat(t *testing.T) {
raw := `{"module":"xorg","seats":[{"name":"node-display-server","scope":"node"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "mesh's own namespace") {
t.Fatalf("a module declared node-display-server as its own: %v", err)
}
}
func displayServer(name, display string, claims ...string) Manifest {
m := Manifest{Module: name, Provides: []Offer{{Name: display, Reach: ReachMachine}}}
for _, c := range claims {
@@ -126,29 +118,6 @@ func TestAMachineReachRequirementIsNotPulledInNorAnsweredFromAnotherNode(t *test
}
}
func TestTheMachinesReachIsAProvisionsOnlyReachAndIsNodeScoped(t *testing.T) {
for _, c := range []struct{ provides, want string }{
{`{"name":"x11-display","reach":"internal"}`, `with reach "internal"; a provision's reach is "machine" or nothing`},
{`{"name":"x11-display","scope":"mesh","reach":"machine"}`, `at scope "mesh" with the machine's reach`},
} {
_, err := ParseManifest([]byte(`{"module":"xorg","provides":[` + c.provides + `]}`))
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: want %q, got %v", c.provides, c.want, err)
}
}
m, err := ParseManifest([]byte(`{"module":"xorg","provides":[{"name":"x11-display","reach":"machine"}]}`))
if err != nil {
t.Fatal(err)
}
if !m.Provides[0].MachineReach() {
t.Fatal("the reach was not read")
}
back, _ := json.Marshal(m.Provides[0])
if string(back) != `{"name":"x11-display","reach":"machine"}` {
t.Errorf("written back as %s", back)
}
}
func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) {
cat := shelf(windowManager(), displayServer("xorg", "x11-display"),
Manifest{Module: "fake-x", Provides: Offers("x11-display")})
@@ -157,57 +126,3 @@ func TestACatalogueDisagreeingAboutAProvisionsReachIsRefused(t *testing.T) {
t.Fatalf("a provision with and without the machine's reach gave %v", err)
}
}
// §4: xinitrc and xresources slots, placed by the display server's holder alone.
func TestTheSessionsFilesArePlacedByTheDisplayServersHolderAlone(t *testing.T) {
xorg := Manifest{Module: "xorg", Claims: []Claim{{Name: DisplayServerSeat}},
Shell: []ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}},
Resources: []map[string]any{
{"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc",
"content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"},
{"id": "xresources", "type": "file", "path": "/home/op/.Xresources",
"content": "${shell:xresources:normal}"},
}}
i3 := Manifest{Module: "i3", Shell: []ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}}
theme := Manifest{Module: "theme", Shell: []ShellCode{
{For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"},
{For: "zsh", Slot: "normal", Code: "not for the session"},
}}
r := Resolution{Node: "workstation", Account: "op", Modules: []Manifest{xorg, i3, theme}}
out, err := r.Declaration(Rendering{})
if err != nil {
t.Fatal(err)
}
by := map[string]any{}
for _, res := range out {
by[res["id"].(string)] = res["content"]
}
if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" {
t.Errorf("the .xinitrc is %q", got)
}
if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" {
t.Errorf("the .Xresources is %q", got)
}
// The contributions parse; the placeholders parse only in the holder.
if _, err := ParseManifest([]byte(`{"module":"i3","shell":[{"for":"xinitrc","slot":"last","code":"exec i3"},` +
`{"for":"xresources","slot":"normal","code":"i3.font: x"}]}`)); err != nil {
t.Fatalf("a session contribution was refused: %v", err)
}
for _, c := range []struct{ claims, content, want string }{
{``, "${shell:xinitrc:normal}", "does not claim node-display-server; every module's xinitrc is placed by the display server's holder alone"},
{`{"name":"node-login-shell"}`, "${shell:xresources:normal}", "does not claim node-display-server"},
{`{"name":"node-display-server"}`, "${shell:zsh:normal}", "does not claim node-login-shell"},
{`{"name":"node-display-server"}`, "${shell:xsession:normal}", "the session's file one of xinitrc, xresources"},
} {
raw := `{"module":"holder","claims":[` + c.claims + `],"resources":[{"id":"rc","type":"file","path":"/etc/rc","content":"` +
c.content + `"}]}`
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s with claims [%s]: want %q, got %v", c.content, c.claims, c.want, err)
}
}
if _, err := ParseManifest([]byte(`{"module":"xorg","claims":[{"name":"node-display-server"}],` +
`"resources":[{"id":"rc","type":"file","path":"/home/op/.xinitrc","content":"${shell:xinitrc:last}"}]}`)); err != nil {
t.Errorf("the display server's holder could not place the session's slots: %v", err)
}
}
-20
View File
@@ -58,26 +58,6 @@ func TestAGroupThatIsNotAWholeNumberAboveZeroIsRefused(t *testing.T) {
}
}
// A manifest in the config form registers with the group a config value fills, and the value is placed (a container's
// groups count as a place for it); a placeholder in any other shape is refused where the manifest is read.
func TestAManifestWhoseContainerTakesItsGroupFromConfigRegisters(t *testing.T) {
m := map[string]any{
"module": "n8n", "version": "1",
"config": map[string]any{"media-gid": map[string]any{"type": "number", "minimum": 1, "required": true,
"description": "the group that owns the media library on this machine"}},
"resources": []any{aContainerWithGroups("${config:media-gid}")},
}
if _, err := parseRaw(t, m); err != nil {
t.Fatalf("refused: %v", err)
}
for _, shape := range []string{"x${config:media-gid}", "${config:media-gid}:1", "${config:other}"} {
m["resources"] = []any{aContainerWithGroups(shape)}
if _, err := parseRaw(t, m); err == nil {
t.Errorf("groups %q registered", shape)
}
}
}
// 1636/AC3 composed for a machine, the way a push writes it: n8n's group is the number the machine's config value
// says, written as a number, and the module is left out by name when the machine says none.
func TestAComposedMachineGetsTheGroupTheConfigValueNames(t *testing.T) {
-37
View File
@@ -305,43 +305,6 @@ func takesOf(m Manifest, id string) string {
return takes
}
// movedProblems is what is wrong with a module's mark: a moved module must name a template (as written) or carry
// what its build kept (as held), and Handed is the build's word, never a repository's.
func movedProblems(m Manifest) []string {
switch {
case m.Handed != nil && !m.Moved:
return []string{fmt.Sprintf("%s carries what a moved module's build keeps (handed) and is not marked moved",
m.Module)}
case m.Handed != nil && m.Build != nil:
return []string{fmt.Sprintf("%s says handed in its repository: that is what its build keeps, never "+
"written by hand (novox/hq ADR 0349 §2)", m.Module)}
case m.Moved && m.Handed == nil && !m.Templated():
return []string{fmt.Sprintf("%s is marked moved and names no template: a moved module's files are filled "+
"from template files of its own, `\"template\": \"%s/<name>\"` (novox/hq ADR 0349 §2)", m.Module,
m.TemplatesDir())}
}
if !m.Moved {
return nil
}
// **A moved module's secrets are the vault's, handed to its machine's client** (design 57 V4): a secret the
// mesh makes, by name. A family's members and a value issued outside the mesh are given by a person and may be
// absent, which the client cannot tell from one the vault does not hold yet; they move with a package of their
// own.
var problems []string
if fams := m.OwnSecrets.Families(); len(fams) > 0 {
problems = append(problems, fmt.Sprintf("%s is marked moved and keeps a secret family (%s): a moved module's "+
"secrets are handed by the vault by name, and a family's members are not yet (design 57 V4)", m.Module,
strings.Join(fams, ", ")))
}
for _, name := range sortedKeys(m.OwnSecrets.Plain()) {
if m.OwnSecrets[name].IssuedBy == IssuedOutside {
problems = append(problems, fmt.Sprintf("%s is marked moved and its secret %s is issued outside the mesh: "+
"a value that may not be given is not yet handed by the vault (design 57 V4)", m.Module, name))
}
}
return problems
}
// handedBy is what a moved module's build keeps, from its templates archive as built, or nil for a module not
// moved.
func handedBy(m Manifest, archive Built) *Handed {
+1 -32
View File
@@ -1,8 +1,6 @@
package catalogue
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -50,7 +48,7 @@ func TestWhatAConsumersPlaceholderMayNotBeIsRefused(t *testing.T) {
"a file the program keeps": {strings.Replace(handedProvider, `"mode":"0600","template"`,
`"mode":"0600","create-once":true,"template"`, 1), consumersText, "owned"},
} {
m, err := ParseManifest([]byte(c.raw))
m, err := decodeForTest([]byte(c.raw))
if err != nil {
t.Fatalf("%s: %v", why, err)
}
@@ -103,32 +101,3 @@ func TestAProvidersConsumersFileIsLeftOutWhereItIsNotHanded(t *testing.T) {
t.Fatalf("handed: said %q, filled %v", c.HandedOnly, c.Filled)
}
}
// What `module check` and the merge gate judge: the provider read from its tree, its text inline. Judged by the same
// rules, a provider naming a provision it does not provide is refused there too, and one that does composes.
func TestAProviderReadFromItsTreeIsJudgedByTheSameRules(t *testing.T) {
read := func(text string) (Manifest, error) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "files", "consumers.json"), []byte(text), 0o644); err != nil {
t.Fatal(err)
}
m, err := ParseManifest([]byte(handedProvider))
if err != nil {
t.Fatal(err)
}
return WithTemplatesFrom(m, dir)
}
filled, err := read(consumersText)
if err != nil {
t.Fatalf("a provider read from its tree is refused: %v", err)
}
if _, err := notifierOn(t, filled).Compose(handingTo("store:5000")); err != nil {
t.Fatalf("a provider read from its tree does not compose where it would be handed: %v", err)
}
if _, err := read("${consumers:cache}"); err == nil || !strings.Contains(err.Error(), "does not provide") {
t.Fatalf("a provider read from its tree naming a provision it does not provide: %v", err)
}
}
+1 -63
View File
@@ -116,68 +116,6 @@ func TestAHandedModulesIssuedCredentialIsItsProvisionsOneEntry(t *testing.T) {
}
}
// Each entry names the credential form the provider's offer declares (issue 768), from the provider's manifest, so
// the vault makes a first value in that form for a provider it holds no row of: one of its own secrets shared by
// every consumer, a login per consumer when the offer says no credential, issued by its software, or given by a
// person (the offer's own secret issued outside).
func TestAHandedModulesPlanNamesTheCredentialFormOfItsProvidersOffer(t *testing.T) {
storeOffering := func(credential string) Manifest {
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` +
credential + `}],"serves":{"postgres-database":{"port":5432}}`
if strings.Contains(credential, `"own"`) {
raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"},` +
`"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}`
}
return mustParse(t, raw+`}`)
}
for credential, want := range map[string]string{
// No default (the lead, 2026-10-29): an offer that says nothing has no form, and the catalogue refuses it.
``: "",
`,"credential":{"form":"pair"}`: CredentialFormPair,
`,"credential":{"form":"none"}`: CredentialFormNone,
`,"credential":{"own":"admin"}`: CredentialFormShared,
`,"credential":{"own":"token"}`: CredentialFormGiven,
`,"credential":{"issued-by":"outside"}`: CredentialFormGiven,
`,"credential":{"issued-by":"software","tool":"store_issued_token"}`: CredentialFormIssued,
} {
node := workstation()
node.At = "workstation.internal"
consumer := notifierConsumer(t, true, false)
r, err := Resolve(shelf(consumer, storeOffering(credential)), []string{consumer.Module}, node, twoStores("anchor"))
if err != nil {
t.Fatal(err)
}
r.Account = "op"
c, _ := composeWith(t, r, handingTo("store:5000"))
got := c.Plans["notify"].Provisions
if len(got) != 1 || got[0].CredentialForm != want {
t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want)
}
// Every form a plan names is in the one list each reader checks (issue 774); an undeclared one is not.
if IsCredentialForm(want) != (want != "") {
t.Errorf("the form %q of an offer with %q: in CredentialForms %v", want, credential, IsCredentialForm(want))
}
}
if forms := strings.Join(CredentialForms(), ","); forms != "shared,pair,issued,given,none" || IsCredentialForm("maybe") {
t.Errorf("the credential forms are %s, and maybe is one: %v", forms, IsCredentialForm("maybe"))
}
if got := (Manifest{Module: "x"}).CredentialFormOf("postgres-database"); got != "" {
t.Errorf("a module offering nothing has the credential form %q", got)
}
// The catalogue refuses an offer saying no form, and a form beside an own secret or not pair or none.
for credential, refused := range map[string]string{``: "says no credential form", `,"credential":{}`: "says no credential form",
`,"credential":{"form":"pair","own":"admin"}`: "beside an own secret", `,"credential":{"form":"maybe"}`: `not "pair" or "none"`,
`,"credential":{"form":"none"}`: ""} {
raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + credential + `}]}`
var m Manifest
_ = json.Unmarshal([]byte(raw), &m)
problems := strings.Join(CredentialFormProblems(m), "; ")
if (refused == "") != (problems == "") || !strings.Contains(problems, refused) {
t.Errorf("%q: %q, want %q", credential, problems, refused)
}
}
}
// A consumer's plan entry carries what its manifest contributes to that provision (issue 776), so the provider's
// helper learns a database's name from the bus; a consumer contributing nothing has the entry it had.
func TestAHandedModulesPlanEntryCarriesWhatItContributesToItsProvider(t *testing.T) {
@@ -258,7 +196,7 @@ func TestAnOfferAnsweredByTheHelperIsReadAndHandsNoFile(t *testing.T) {
if err != nil || !strings.Contains(string(raw), `"answered-by":"helper"`) {
t.Fatalf("written back as %s: %v", raw, err)
}
if _, err := ParseManifest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil {
if _, err := decodeForTest([]byte(strings.Replace(with, `"helper"`, `"harness"`, 1))); err == nil {
t.Fatal("an unknown answerer was accepted")
}
c1, before := composeWith(t, notifierOn(t, held(t, without, builtNotifier())), handingTo("store:5000"))
+1 -1
View File
@@ -22,7 +22,7 @@ func providing(raw string) string {
// onWorkstation resolves m on the workstation either assigned beside desk, or pulled in by desk alone.
func onWorkstation(t *testing.T, m Manifest, assigned bool) Resolution {
t.Helper()
desk, err := ParseManifest([]byte(pulledInBy))
desk, err := decodeForTest([]byte(pulledInBy))
if err != nil {
t.Fatal(err)
}
-31
View File
@@ -123,37 +123,6 @@ func TestAMovedModulesSecretsAreSealedAsBeforeWhereItIsNotHanded(t *testing.T) {
}
}
func TestWhatAHandedModuleCannotHaveFromTheVaultIsRefused(t *testing.T) {
// A template naming a credential the module requires: a binding's, which moves with its provider.
requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1)
r, err := Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")),
Manifest{Module: "db", Version: "1", Provides: []Offer{{Name: "database", Scope: ScopeNode}}}),
[]string{"bot"}, workstation(), World{})
if err != nil {
t.Fatal(err)
}
r.Account = "op"
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") {
t.Errorf("a template naming a required credential: %v", err)
}
// A file with its text inline naming a secret, in a handed module.
inline := strings.Replace(botMoved(), `{"id":"daemon"`,
`{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1)
r = notifierOn(t, held(t, inline, builtBot(botText)))
if _, err := r.Compose(handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "keep it in a template") {
t.Errorf("an inline file naming a secret in a handed module: %v", err)
}
// A family and a secret issued outside the mesh are not handed by name yet: the mark is refused.
for why, raw := range map[string]string{
"a family": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":"${dir:state}/token","key-*":"${dir:state}/keys"}`, 1),
"issued outside": strings.Replace(botMoved(), `"own-secrets":{"token":"${dir:state}/token"}`, `"own-secrets":{"token":{"path":"${dir:state}/token","issued-by":"outside"}}`, 1),
} {
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "design 57 V4") {
t.Errorf("a moved module with %s: %v", why, err)
}
}
}
// A credential of what a handed module requires that the vault keeps for it alone (issue 757): the forge's read
// token for one agent, issued by the forge's software per holder. Where the module is handed, its file is declared
// placed and without a value, and the plan names it with its provision and provider; anywhere else, and for a
+1 -19
View File
@@ -141,24 +141,6 @@ func TestAMovedModulesTemplatedFileIsHandedToTheProvisioner(t *testing.T) {
}
}
func TestAMovedModulesMarkIsJudged(t *testing.T) {
for name, raw := range map[string]string{
"marked moved and naming no template": `{"module":"bare","version":"1","moved":true,
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
"handed written in a repository": `{"module":"bare","version":"1","moved":true,
"handed":{"source":"s","digest":"d","templates":{}},
"build":{"artifacts":[{"name":"files","kind":"archive","from":"files"}]},
"resources":[{"id":"conf","type":"file","path":"/etc/bare","template":"files/bare"}]}`,
"handed and not moved": `{"module":"bare","version":"1",
"handed":{"source":"s","digest":"d","templates":{}},
"resources":[{"id":"conf","type":"file","path":"/etc/bare","content":"x"}]}`,
} {
if _, err := ParseManifest([]byte(raw)); err == nil {
t.Errorf("%s: accepted", name)
}
}
}
// What `module check` and the merge gate judge: a moved module read from its tree, its text inline. It is still
// marked moved, and composes as before, since nothing of its build's archive is there to hand.
func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) {
@@ -169,7 +151,7 @@ func TestAMovedModuleReadFromItsTreeIsJudgedAsItComposesUnhanded(t *testing.T) {
if err := os.WriteFile(filepath.Join(dir, "files", "rc"), []byte(notifierText), 0o644); err != nil {
t.Fatal(err)
}
m, err := ParseManifest([]byte(notifierMoved()))
m, err := decodeForTest([]byte(notifierMoved()))
if err != nil {
t.Fatal(err)
}
-201
View File
@@ -174,207 +174,6 @@ func ReadHealth(r map[string]any) (Health, bool, []string) {
return h, true, problems
}
// healthProblems is everything wrong with a manifest's `health` fields, in the manifest's words (to-be
// 48 §8): a field on something that does not stay up, a kind its resource cannot have, an endpoint the
// module does not declare, a port or an address, a timing outside its bounds, a tool the module does not
// serve, and a tool check with no check of another kind beside it on the module.
func healthProblems(m Manifest) []string {
var problems []string
endpoints := map[string]Listening{}
for _, l := range m.Listens {
if n := strings.TrimSpace(l.Name); n != "" {
endpoints[n] = l
}
}
tools := map[string]bool{}
for _, t := range m.Tools {
tools[t] = true
}
wants := map[string]bool{}
for _, w := range m.Wants() {
wants[w] = true
}
var toolChecks []string
otherKinds := 0
resources := m.Resources
// A module's own runtime is judged as the process the controller writes for it (novox/hq ADR 0366 §1),
// and only a module that runs as its own account has one.
own, hasOwn := OwnRuntimeHealth(m)
if hasOwn {
if m.RunsAs == "" {
problems = append(problems, fmt.Sprintf("%s declares runtime-health and runs as no account of its own "+
"(runs-as): only a module served by a runtime of its own has that runtime's health to declare "+
"(novox/hq ADR 0366 §1)", m.Module))
hasOwn = false
} else {
resources = append(append([]map[string]any(nil), m.Resources...), own)
}
}
for i, r := range resources {
h, has, read := ReadHealth(r)
if !has {
continue
}
ownRuntime := hasOwn && i == len(resources)-1
id, typ := fmt.Sprint(r["id"]), fmt.Sprint(r["type"])
where := m.Module + ": " + id
if ownRuntime {
where = m.Module + ": its runtime-health"
}
for _, p := range read {
problems = append(problems, m.Module+": "+p)
}
if !LongRunning(r) {
problems = append(problems, fmt.Sprintf("%s declares health and does not stay up: a step, anything on a "+
"schedule and a service not stated running are judged by their step and their schedule (ADR 0240 rule 1)", where))
continue
}
switch h.Kind {
case HealthRuntime, HealthExec:
if typ != "container" {
problems = append(problems, fmt.Sprintf("%s is a %s and its health is %q, which only a container has: "+
"the runtime runs it inside the container", where, typ, h.Kind))
}
case HealthUnit:
if typ == "container" {
problems = append(problems, fmt.Sprintf("%s is a container and its health is %q, which is a service's "+
"or a process's own readiness", where, h.Kind))
}
case HealthHTTP, HealthTCP, HealthTool:
case "":
problems = append(problems, fmt.Sprintf("%s declares health with no kind: %s", where, healthKindsWords()))
default:
problems = append(problems, fmt.Sprintf("%s declares health of kind %q: %s", where, h.Kind, healthKindsWords()))
}
switch h.Kind {
case HealthHTTP, HealthTCP:
l, declared := endpoints[h.Endpoint]
switch {
case h.Endpoint == "":
problems = append(problems, fmt.Sprintf("%s's %s check names no endpoint: it names one the module "+
"declares under listens, by its name", where, h.Kind))
case !declared:
problems = append(problems, fmt.Sprintf("%s's %s check names the endpoint %q, which %s does not declare "+
"under listens (%s)", where, h.Kind, h.Endpoint, m.Module, namedEndpointsWords(endpoints)))
case l.At() != "tcp":
problems = append(problems, fmt.Sprintf("%s's %s check names %q, which is %s: a check connects over tcp",
where, h.Kind, h.Endpoint, l.At()))
}
default:
if h.Endpoint != "" {
problems = append(problems, fmt.Sprintf("%s's %s check names an endpoint, which only an http or tcp check "+
"looks at", where, h.Kind))
}
}
if h.Kind != HealthHTTP && (h.Path != "" || h.Status != 0 || h.Body != "" || h.Scheme != "") {
problems = append(problems, fmt.Sprintf("%s's %s check says a path, a status, a body or a scheme, which "+
"only an http check has", where, h.Kind))
}
if h.Kind == HealthHTTP {
if h.Path != "" && !strings.HasPrefix(h.Path, "/") {
problems = append(problems, fmt.Sprintf("%s's http check asks %q; a path starts with /", where, h.Path))
}
if h.Status != 0 && (h.Status < 100 || h.Status > 599) {
problems = append(problems, fmt.Sprintf("%s's http check expects status %d, which is not one", where, h.Status))
}
if h.Scheme != "" && h.Scheme != "http" && h.Scheme != "https" {
problems = append(problems, fmt.Sprintf("%s's http check is over %q; it is http or https", where, h.Scheme))
}
}
if (h.Command != "") != (h.Kind == HealthExec) {
if h.Kind == HealthExec {
problems = append(problems, fmt.Sprintf("%s's exec check says no command", where))
} else {
problems = append(problems, fmt.Sprintf("%s's %s check says a command, which only an exec check runs",
where, h.Kind))
}
}
if h.Kind == HealthTool {
switch {
case h.Tool == "":
problems = append(problems, fmt.Sprintf("%s's tool check names no tool", where))
case !tools[h.Tool]:
problems = append(problems, fmt.Sprintf("%s's tool check asks %q, which %s does not serve (its tools: %s)",
where, h.Tool, m.Module, orNoneWords(m.Tools)))
}
// Its own runtime being alive, judged by the node-engine, is the check the module does not run
// itself that this tool check stands beside (novox/hq ADR 0366 §2).
if !ownRuntime {
toolChecks = append(toolChecks, id)
}
} else {
if h.Tool != "" {
problems = append(problems, fmt.Sprintf("%s's %s check names a tool, which only a tool check asks", where, h.Kind))
}
if h.Kind != "" {
otherKinds++
}
}
if h.Needs != "" && !wants[h.Needs] {
problems = append(problems, fmt.Sprintf("%s's check needs %q, which %s does not require: a check names "+
"the provision it exercises, among those the module requires", where, h.Needs, m.Module))
}
problems = append(problems, healthTimingProblems(where, h)...)
}
if len(toolChecks) > 0 && otherKinds == 0 {
problems = append(problems, fmt.Sprintf("%s judges itself only by its own tool (%s): a tool check is for "+
"function no endpoint shows, and only beside a check of another kind the module does not run itself "+
"(ADR 0227 rule 8, ADR 0240 rule 2)", m.Module, strings.Join(toolChecks, ", ")))
}
return problems
}
// healthTimingProblems holds a check's timing to its bounds.
func healthTimingProblems(where string, h Health) []string {
var problems []string
if h.Interval < HealthIntervalFloor {
problems = append(problems, fmt.Sprintf("%s looks every %s; a check looks no more often than every %s — "+
"the mesh is a guest on the machine (ADR 0240 rule 2)", where, h.Interval, HealthIntervalFloor))
}
if h.Timeout <= 0 || h.Timeout >= h.Interval {
problems = append(problems, fmt.Sprintf("%s gives a look %s, which must be more than nothing and under its "+
"interval of %s", where, h.Timeout, h.Interval))
}
if h.Looks < HealthLooksFloor {
problems = append(problems, fmt.Sprintf("%s is unhealthy after %d failing look(s); it is at least %d — one "+
"look can be wrong (issue 277)", where, h.Looks, HealthLooksFloor))
}
if h.Grace < 0 {
problems = append(problems, fmt.Sprintf("%s has a grace of %s", where, h.Grace))
}
if h.Looks >= HealthLooksFloor && h.Interval >= HealthIntervalFloor {
if took := h.Grace + time.Duration(h.Looks)*h.Interval; took > HealthWithin {
problems = append(problems, fmt.Sprintf("%s is said unhealthy at the earliest %s after it starts (a grace "+
"of %s and %d looks every %s); it is at most %s, so a resource broken from its start is said within "+
"the gate's bound", where, took, h.Grace, h.Looks, h.Interval, HealthWithin))
}
}
return problems
}
func healthKindsWords() string {
return "a check is runtime (the image's own, adopted by name), http, tcp, exec, unit or tool"
}
func namedEndpointsWords(endpoints map[string]Listening) string {
if len(endpoints) == 0 {
return "it names none"
}
names := make([]string, 0, len(endpoints))
for n := range endpoints {
names = append(names, n)
}
sort.Strings(names)
return "it names " + strings.Join(names, ", ")
}
func orNoneWords(names []string) string {
if len(names) == 0 {
return "none"
}
return strings.Join(names, ", ")
}
// Undeclared is every long-running resource of the manifest without `health`, by id (ADR 0240 rule 8):
// what the catalogue's count counts.
func Undeclared(m Manifest) []string {
+7 -61
View File
@@ -53,78 +53,24 @@ func TestAWellFormedHealthIsAccepted(t *testing.T) {
{"kind": "runtime"},
{"kind": "exec", "command": "pg_isready -q", "grace": "4m", "looks": 2, "interval": "30s"},
} {
if _, err := ParseManifest(healthManifest(t, "server", h)); err != nil {
if _, err := decodeForTest(healthManifest(t, "server", h)); err != nil {
t.Errorf("%v was refused: %v", h, err)
}
}
if _, err := ParseManifest(healthManifest(t, "daemon", map[string]any{"kind": "unit"})); err != nil {
if _, err := decodeForTest(healthManifest(t, "daemon", map[string]any{"kind": "unit"})); err != nil {
t.Errorf("a service's own readiness was refused: %v", err)
}
// A tool beside a check of another kind on the module.
raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"},
map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running",
"health": map[string]any{"kind": "tool", "tool": "app_status"}})
if _, err := ParseManifest(raw); err != nil {
if _, err := decodeForTest(raw); err != nil {
t.Errorf("a tool check beside an http check was refused: %v", err)
}
}
func TestEveryOutOfBoundsHealthIsRefusedByName(t *testing.T) {
cases := []struct {
name string
on string
health map[string]any
says string
}{
{"no kind", "server", map[string]any{"endpoint": "web"}, "with no kind"},
{"an unknown kind", "server", map[string]any{"kind": "ping"}, `of kind "ping"`},
{"a port", "server", map[string]any{"kind": "tcp", "port": 8080}, "names a port"},
{"an address", "server", map[string]any{"kind": "http", "endpoint": "web", "address": "127.0.0.1"}, "names a address"},
{"a url", "server", map[string]any{"kind": "http", "url": "http://localhost:8080/"}, "names a url"},
{"an unknown key", "server", map[string]any{"kind": "tcp", "endpoint": "web", "retries": 3}, `"retries"`},
{"no endpoint", "server", map[string]any{"kind": "http"}, "names no endpoint"},
{"an undeclared endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "admin"}, "does not declare"},
{"a udp endpoint", "server", map[string]any{"kind": "tcp", "endpoint": "beacon"}, "a check connects over tcp"},
{"an interval under the floor", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "5s", "timeout": "1s"}, "no more often than every 10s"},
{"a timeout of the interval", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "10s", "timeout": "10s"}, "under its interval"},
{"one failing look", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": 1}, "at least 2"},
{"a grace and looks past five minutes", "server", map[string]any{"kind": "tcp", "endpoint": "web", "grace": "4m", "looks": 3, "interval": "30s"}, "at most 5m0s"},
{"a duration that is not one", "server", map[string]any{"kind": "tcp", "endpoint": "web", "interval": "often"}, "is a duration"},
{"looks that are not a number", "server", map[string]any{"kind": "tcp", "endpoint": "web", "looks": "three"}, "whole number"},
{"a path without a slash", "server", map[string]any{"kind": "http", "endpoint": "web", "path": "health"}, "starts with /"},
{"a status that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "status": 700}, "is not one"},
{"a scheme that is not one", "server", map[string]any{"kind": "http", "endpoint": "web", "scheme": "ftp"}, "http or https"},
{"a status on a tcp check", "server", map[string]any{"kind": "tcp", "endpoint": "web", "status": 200}, "only an http check has"},
{"an exec with no command", "server", map[string]any{"kind": "exec"}, "says no command"},
{"a command on an http check", "server", map[string]any{"kind": "http", "endpoint": "web", "command": "true"}, "only an exec check runs"},
{"a runtime check on a service", "daemon", map[string]any{"kind": "runtime"}, "only a container has"},
{"a unit check on a container", "server", map[string]any{"kind": "unit"}, "a service's or a process's"},
{"a tool the module does not serve", "server", map[string]any{"kind": "tool", "tool": "app_admin"}, "does not serve"},
{"a tool check alone", "server", map[string]any{"kind": "tool", "tool": "app_status"}, "judges itself only by its own tool"},
{"needs not required", "server", map[string]any{"kind": "tcp", "endpoint": "web", "needs": "redis"}, "does not require"},
{"health on a step", "seed", map[string]any{"kind": "runtime"}, "does not stay up"},
{"health on a schedule", "sweep", map[string]any{"kind": "runtime"}, "does not stay up"},
{"health that is not an object", "server", nil, "is an object"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
raw := healthManifest(t, c.on, c.health)
if c.health == nil {
raw = []byte(strings.Replace(string(raw), `"name":"app-server"`, `"name":"app-server","health":"tcp"`, 1))
}
_, err := ParseManifest(raw)
if err == nil {
t.Fatalf("%s was accepted", c.name)
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("refused, but not for saying %q: %v", c.says, err)
}
})
}
}
func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) {
m, err := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz",
m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web", "path": "/healthz",
"needs": "postgres-database"}))
if err != nil {
t.Fatal(err)
@@ -163,7 +109,7 @@ func TestHealthIsComposedAsThePortThisMachineGaveTheEndpoint(t *testing.T) {
}
func TestTheUndeclaredAreTheLongRunningWithoutHealth(t *testing.T) {
m, err := ParseManifest(healthManifest(t, "server", map[string]any{"kind": "runtime"}))
m, err := decodeForTest(healthManifest(t, "server", map[string]any{"kind": "runtime"}))
if err != nil {
t.Fatal(err)
}
@@ -176,7 +122,7 @@ func TestATooledHealthIsGrantedToTheEngine(t *testing.T) {
raw := healthManifest(t, "server", map[string]any{"kind": "http", "endpoint": "web"},
map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running",
"health": map[string]any{"kind": "tool", "tool": "app_status"}})
m, err := ParseManifest(raw)
m, err := decodeForTest(raw)
if err != nil {
t.Fatal(err)
}
@@ -194,7 +140,7 @@ func TestEveryComposedHealthIsOneTheNodeEngineTakes(t *testing.T) {
{"kind": "runtime"},
{"kind": "exec", "command": "pg_isready -q"},
} {
m, err := ParseManifest(healthManifest(t, "server", h,
m, err := decodeForTest(healthManifest(t, "server", h,
map[string]any{"id": "admin", "type": "service", "unit": "app-admin.service", "state": "running",
"health": map[string]any{"kind": "tool", "tool": "app_status"}}))
if err != nil {
-44
View File
@@ -174,47 +174,3 @@ func (r Resolution) Overflowing() []Overflow {
// passes on every machine that mesh has. `module check --longest-machine-name` says another mesh's;
// a mesh that names a longer machine raises this in the same change (ADR 0225).
const DefaultLongestMachine = 6
// IdentityProblems is every module whose identity would overflow a provision it wants, on a machine
// whose name is `longestMachine` characters — judged before merge, over the catalogue alone, so the
// pull request that introduces an overflow is the one refused (novox/hq ADR 0225, issue 263). A
// provision no module in the shelf offers is not judged: its bound is not known here.
func IdentityProblems(shelf Shelf, longestMachine int) []string {
offeredBy := map[string][]string{}
for _, name := range shelfOrder(shelf) {
for _, o := range shelf[name].Offers() {
offeredBy[o] = append(offeredBy[o], name)
}
}
machine := strings.Repeat("n", longestMachine)
var problems []string
for _, name := range shelfOrder(shelf) {
m := shelf[name]
source := IdentitySource(m.Slug, m.Module)
for _, want := range m.Wants() {
// The tightest bound among the modules offering it: whichever one answers on a given
// machine, the identity has to fit it.
tightest, by := IdentityBound{}, ""
for _, provider := range offeredBy[want] {
if provider == name {
continue // a module answering its own requirement is not its own consumer
}
b := shelf[provider].IdentityBoundOf(want)
if b.Bounded() && (!tightest.Bounded() || b.Max < tightest.Max) {
tightest, by = b, provider
}
}
if CheckIdentityWithin(machine, source, tightest) == nil {
continue
}
got := ConsumerIdentity(machine, source)
problems = append(problems, fmt.Sprintf(
"%s wants %s, and %s keeps its consumers' identities in %s of at most %d characters: "+
"on a machine with a %d-character name it is identified as %q, %d — give %s a "+
"`slug` of at most %d characters",
name, want, by, tightest.In, tightest.Max, longestMachine, got, len(got), name,
tightest.Max-len(IdentityPrefix)-longestMachine-1))
}
}
return problems
}
-106
View File
@@ -1,7 +1,6 @@
package catalogue
import (
"encoding/json"
"strings"
"testing"
)
@@ -61,97 +60,6 @@ func TestAnUnstatedBoundFollowsWhatTheProviderIsTold(t *testing.T) {
}
}
// The field reads as written and writes back the same, and refuses what says nothing.
func TestAnOffersIdentityIsParsedStrictly(t *testing.T) {
for _, raw := range []string{
`{"name":"s3-bucket","scope":"mesh","identity":{"max":20,"in":"an S3 access key"}}`,
`{"name":"wildcard-resolution","scope":"mesh","identity":false}`,
`{"name":"redis-cache","scope":"mesh","identity":{"in":"a Redis ACL user"}}`,
} {
var o Offer
if err := json.Unmarshal([]byte(raw), &o); err != nil {
t.Fatalf("%s: %v", raw, err)
}
back, err := json.Marshal(o)
if err != nil || string(back) != raw {
t.Errorf("did not round-trip:\n%s\n%s (%v)", raw, back, err)
}
}
for _, raw := range []string{
`{"name":"x","identity":true}`,
`{"name":"x","identity":{"max":20,"in":"y","most":3}}`,
} {
var o Offer
if err := json.Unmarshal([]byte(raw), &o); err == nil {
t.Errorf("accepted %s", raw)
}
}
bad := Manifest{Module: "bad", Version: "1", Provides: []Offer{
{Name: "unsaid", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 20}},
{Name: "tiny", Scope: ScopeMesh, Identity: &OfferIdentity{Max: 4, In: "nothing usable"}},
}}
raw, err := json.Marshal(bad)
if err != nil {
t.Fatal(err)
}
_, err = ParseManifest(raw)
if err == nil || !strings.Contains(err.Error(), "without saying what keeps them") ||
!strings.Contains(err.Error(), "the shortest the mesh makes") {
t.Fatalf("a bound with no `in`, or too short for any identity, was accepted: %v", err)
}
}
// Refused before merge: the catalogue check judges each module's identity, on the longest machine
// name, against the bound of every provision it wants — and names the module and the slug to set.
func TestTheCatalogueCheckRefusesAnIdentityThatOverflowsWhatItRequires(t *testing.T) {
shelf := Shelf{
"objects": {Module: "objects", Provides: []Offer{{Name: "s3-bucket", Scope: ScopeMesh,
Identity: &OfferIdentity{Max: 20, In: "an S3 access key"}}},
Receives: map[string]string{"s3-bucket": "/var/lib/mesh/objects/mesh.json"}},
"photoalbum": {Module: "photoalbum", Requires: []string{"s3-bucket"}},
"files": {Module: "files", Requires: []string{"s3-bucket"}},
}
problems := IdentityProblems(shelf, 6)
if len(problems) != 1 || !strings.Contains(problems[0], "photoalbum wants s3-bucket") ||
!strings.Contains(problems[0], `"mesh_nnnnnn_photoalbum", 22`) ||
!strings.Contains(problems[0], "`slug` of at most 8 characters") {
t.Fatalf("one overflow, named with its remedy, was expected: %q", problems)
}
// A longer machine name refuses more: the check is about the mesh's machines, not one.
if got := IdentityProblems(shelf, 10); len(got) != 2 {
t.Fatalf("on a 10-character name both overflow (mesh_nnnnnnnnnn_files is 21): %q", got)
}
// And a slug is the remedy it names.
album := shelf["photoalbum"]
album.Slug = "album"
shelf["photoalbum"] = album
if got := IdentityProblems(shelf, 6); len(got) != 0 {
t.Fatalf("a slug that fits is still refused: %q", got)
}
}
// Tonight's case (issue 263): networkmanager, no slug, requiring the mesh's resolver provision on a
// machine with a six-character name. Under ADR 0049's one bound it was refused, and its provider's
// whole machine with it; the resolver keeps no name, so it is not refused at all.
func TestARequirementOnAKeylessProvisionComposesWithALongName(t *testing.T) {
shelf := Shelf{
"resolver": {Module: "resolver", Provides: []Offer{{Name: "wildcard-resolution", Scope: ScopeMesh}}},
"networkmanager": {Module: "networkmanager", Requires: []string{"wildcard-resolution"}},
}
if CheckIdentity("laptop", "networkmanager") == nil {
t.Fatal("the regression is not reproduced: mesh_laptop_networkmanager fits the old global bound")
}
if got := IdentityProblems(shelf, 6); len(got) != 0 {
t.Fatalf("a requirement on a keyless provision was refused for its length: %q", got)
}
r := Resolution{Node: "laptop", Modules: []Manifest{shelf["networkmanager"]},
Needs: []Needed{{Name: "wildcard-resolution", From: "anchor", For: "networkmanager",
Identity: shelf["resolver"].IdentityBoundOf("wildcard-resolution")}}}
if got := r.Overflowing(); len(got) != 0 {
t.Fatalf("a keyless requirement is reported as overflowing: %+v", got)
}
}
// The consumer's side of the same judgement the provider's composition makes: what `status` says.
func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) {
bound := IdentityBound{Max: 20, In: "an S3 access key"}
@@ -174,17 +82,3 @@ func TestAnOverflowingRequirementIsNamedFromTheConsumersSide(t *testing.T) {
t.Fatalf("the overflow does not say what keeps it or the remedy: %s", said)
}
}
// An identity served as a DNS label is never truncated, so the offer's bound must keep it in one.
func TestAnIdentityServedAsADNSLabelIsBoundedToOne(t *testing.T) {
serves := map[string]map[string]any{"bucket": {"name": "${consumer:as:dns}"}}
wide := Manifest{Module: "wide", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh,
Identity: &OfferIdentity{Max: 255, In: "a client id"}}}}
if got := CheckServes(wide); len(got) != 1 || !strings.Contains(got[0], "a label keeps 63") {
t.Fatalf("a 255-character bound on a DNS label passed: %q", got)
}
unsaid := Manifest{Module: "unsaid", Serves: serves, Provides: []Offer{{Name: "bucket", Scope: ScopeMesh}}}
if got := CheckServes(unsaid); len(got) != 0 {
t.Fatalf("the default bound (20) on a DNS label was refused: %q", got)
}
}
-209
View File
@@ -1,14 +1,5 @@
package catalogue
import (
"encoding/json"
"fmt"
"net"
"regexp"
"sort"
"strings"
)
// A definition names no installation (novox/hq ADR 0112, ADR 0155, issues 122 and 134).
//
// A module definition holds what is true of the module everywhere; what is particular to one mesh —
@@ -34,203 +25,3 @@ import (
// NamesOnPurpose is the catalogue-level word a resource carries for the names it means to name:
// each name mapped to its reason. The host never sees it.
const NamesOnPurpose = "names-on-purpose"
// prose is every key whose value the mesh never reads.
var prose = map[string]bool{"why": true, "description": true}
// Registries the world runs, which an image may name because an image reference must say where it
// is pulled from. Anything else in an image reference is a registry of some installation.
var worldsRegistries = map[string]bool{
"docker.io": true, "registry-1.docker.io": true, "index.docker.io": true, "ghcr.io": true,
"quay.io": true, "gcr.io": true, "registry.k8s.io": true, "k8s.gcr.io": true,
"mcr.microsoft.com": true, "lscr.io": true, "public.ecr.aws": true, "registry.gitlab.com": true,
"codeberg.org": true, "cgr.dev": true,
}
// Services the world runs that a definition may name as a policy default, the way it may name a
// public resolver: the public certificate authorities' ACME directories. Anything else a served
// fact or a file names is somebody's installation.
var worldsServices = map[string]bool{
"acme-v02.api.letsencrypt.org": true, "acme-staging-v02.api.letsencrypt.org": true,
"api.buypass.com": true, "api.test4.buypass.no": true, "dv.acme-v02.api.pki.goog": true,
"acme.zerossl.com": true,
}
// Resolvers the world runs, which a machine's resolver may forward to as a policy default.
var worldsResolvers = map[string]bool{
"1.1.1.1": true, "1.0.0.1": true, "8.8.8.8": true, "8.8.4.4": true, "9.9.9.9": true,
"149.112.112.112": true, "208.67.222.222": true, "208.67.220.220": true,
}
// hostname is a dotted name whose last label is a top-level domain a real installation would have.
// Not every dotted token: `module.json`, `index.html` and `docker.sock` are dotted and name nothing.
// Boundaries are checked by hand rather than in the pattern, because two names one character apart
// — `a.example.tld,b.example.tld` — would otherwise share the delimiter and the second would be lost.
var hostname = regexp.MustCompile(
`(?i)(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+` +
`(?:be|nl|de|fr|uk|eu|com|net|org|io|dev|app|cloud|site|online|me|co|ch|at|lu|` +
`internal|example|tld|test|invalid)`)
// upstreamVersion is where an artifact pinned by URL says which release it is (Artifact.Version).
var upstreamVersion = regexp.MustCompile(`^build\.artifacts\[[0-9]+\]\.version$`)
// address is a dotted quad.
var address = regexp.MustCompile(`(?:[0-9]{1,3}\.){3}[0-9]{1,3}`)
// isName is whether a byte may be part of a name; a match bordered by one is a longer token.
func isName(b byte) bool {
return b == '.' || b == '-' || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z') || (b >= '0' && b <= '9')
}
// standalone are the matches of re in value that are whole tokens, not parts of a longer one.
func standalone(re *regexp.Regexp, value string) []string {
var out []string
for _, span := range re.FindAllStringIndex(value, -1) {
if span[0] > 0 && isName(value[span[0]-1]) {
continue
}
if span[1] < len(value) && isName(value[span[1]]) {
continue
}
out = append(out, value[span[0]:span[1]])
}
return out
}
// InstallationProblems is every value of a definition that names an installation, in the
// definition's own words: where it is, and what it names.
func InstallationProblems(m Manifest) []string {
raw, err := json.Marshal(m)
if err != nil {
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
}
var tree any
if err := json.Unmarshal(raw, &tree); err != nil {
return []string{fmt.Sprintf("%s could not be read back: %v", m.Module, err)}
}
var problems []string
// The module's own name is a value too: a module named after the domain it serves is a
// definition that can only be installed there (issue 134).
for _, name := range namesIn(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s is named after %s, and a module is named for what it is, not for where it runs", m.Module, name))
}
walk(tree, "", nil, func(at string, value string, meant map[string]bool, isImage bool) {
for _, name := range namesIn(value) {
if (isImage && worldsRegistries[strings.ToLower(name)]) || meant[name] {
continue
}
problems = append(problems, fmt.Sprintf("%s names %s at %s", m.Module, name, at))
}
for _, ip := range addressesIn(value) {
// An upstream pin's version may have four dotted numbers (Owl 1.5.6.1), which is no address; a name
// in it is still judged above. Its URL is judged whole: the vendor's host goes through the artifact's
// names-on-purpose like any name a definition means (novox/hq ADR 0316).
if meant[ip] || upstreamVersion.MatchString(at) {
continue
}
problems = append(problems, fmt.Sprintf("%s names the public address %s at %s", m.Module, ip, at))
}
})
sort.Strings(problems)
return problems
}
// walk visits every string in the tree with its path, the names the enclosing resource means to
// name (with a reason), and whether it is an image reference.
func walk(node any, at string, meant map[string]bool, visit func(at, value string, meant map[string]bool, isImage bool)) {
switch v := node.(type) {
case map[string]any:
if declared, has := v[NamesOnPurpose].(map[string]any); has {
widened := map[string]bool{}
for name := range meant {
widened[name] = true
}
for name, reason := range declared {
if r, ok := reason.(string); ok && strings.TrimSpace(r) != "" {
widened[strings.ToLower(name)] = true
}
}
meant = widened
}
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
// Prose is a string a person reads. A key that is called `why` or `description` and holds
// anything else — a setting of that name, whose default the mesh writes — is walked like any
// other (novox/hq ADR 0262).
if _, isString := v[k].(string); (prose[k] && isString) || k == NamesOnPurpose || (at == "" && k == "module") {
continue
}
child := at + "." + k
if at == "" {
child = k
}
if s, isString := v[k].(string); isString {
visit(child, s, meant, k == "image")
continue
}
walk(v[k], child, meant, visit)
}
case []any:
for i, item := range v {
child := fmt.Sprintf("%s[%d]", at, i)
if s, isString := item.(string); isString {
visit(child, s, meant, false)
continue
}
walk(item, child, meant, visit)
}
}
}
// namesIn is every hostname in a value that could belong to an installation.
func namesIn(value string) []string {
var out []string
for _, found := range standalone(hostname, value) {
name := strings.ToLower(found)
switch {
case strings.HasSuffix(name, ".docker.internal"):
// The container runtime's alias for its own host: every machine running it has one.
case worldsServices[name]:
// A public authority named as a policy default, true of any mesh that wants it.
case name == "example.tld", strings.HasSuffix(name, ".example.tld"),
name == "example.com", name == "example.net", name == "example.org",
strings.HasSuffix(name, ".example.com"), strings.HasSuffix(name, ".example.net"),
strings.HasSuffix(name, ".example.org"), strings.HasSuffix(name, ".example"),
strings.HasSuffix(name, ".test"), strings.HasSuffix(name, ".invalid"):
// Documentation names, which is what a definition's own example should use.
default:
out = append(out, name)
}
}
return out
}
// addressesIn is every public address in a value: not a private range, loopback, link-local, the
// unspecified address, a documentation range, or a resolver the world runs.
func addressesIn(value string) []string {
var out []string
for _, found := range standalone(address, value) {
ip := net.ParseIP(found)
if ip == nil || ip.IsPrivate() || ip.IsLoopback() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || ip.IsMulticast() || worldsResolvers[found] || documentation(ip) {
continue
}
out = append(out, found)
}
return out
}
func documentation(ip net.IP) bool {
for _, cidr := range []string{"192.0.2.0/24", "198.51.100.0/24", "203.0.113.0/24", "100.64.0.0/10"} {
_, block, _ := net.ParseCIDR(cidr)
if block.Contains(ip) {
return true
}
}
return false
}
-92
View File
@@ -1,93 +1 @@
package catalogue
import (
"strings"
"testing"
)
// A definition names no installation (novox/hq ADR 0112, ADR 0155). What the mesh acts on is judged;
// prose is not; the world's registries and resolvers are the world's; a declared exception is a
// reason a reader sees.
func TestADefinitionNamingAnInstallationIsNamedBack(t *testing.T) {
m := Manifest{Module: "idp", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "quay.io/keycloak/keycloak@sha256:aa",
"env": map[string]any{"KC_HOSTNAME": "https://login.mesh-one.be"}},
{"id": "env", "type": "file", "content": "REAL_IP_FROM=192.168.1.0/24,127.0.0.0/8,203.0.113.7,51.15.22.9\n"},
}, Listens: []Listening{{Port: 8080, From: FromMesh, Why: "the login page; login.mesh-one.be is a route grant"}}}
got := strings.Join(InstallationProblems(m), "\n")
for _, want := range []string{
"idp names login.mesh-one.be at resources[0].env.KC_HOSTNAME",
"idp names the public address 51.15.22.9 at resources[1].content",
} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in:\n%s", want, got)
}
}
for _, mustNot := range []string{"quay.io", "why", "203.0.113.7", "192.168.1.0", "127.0.0.0"} {
if strings.Contains(got, mustNot) {
t.Errorf("%q was reported and should not be:\n%s", mustNot, got)
}
}
}
func TestTheWorldsNamesAreNotAnInstallations(t *testing.T) {
m := Manifest{Module: "resolver", Resources: []map[string]any{
{"id": "conf", "type": "file", "content": "server=1.1.1.1\nserver=8.8.8.8\nlisten=127.0.0.55\n"},
{"id": "proxy", "type": "container", "image": "docker.io/library/traefik@sha256:bb"},
{"id": "adapter", "type": "file", "content": "{\"machine\": \"host.docker.internal\"}\n"},
{"id": "doc", "type": "file", "content": "root = https://git.example.tld/\n"},
}}
if got := InstallationProblems(m); len(got) != 0 {
t.Fatalf("the world's names were reported: %v", got)
}
}
func TestANameMeantOnPurposeIsDeclaredWithItsReason(t *testing.T) {
// The federation's public directory in a homeserver's config: the world's, said so, and a name
// the map does not cover is still reported.
m := Manifest{Module: "homeserver", Resources: []map[string]any{
{"id": "conf", "type": "file", "content": "trusted_key_servers: matrix.org\nwell_known: https://mesh-one.be\n",
NamesOnPurpose: map[string]any{"matrix.org": "the federation's public key server, the world's"}},
}}
got := InstallationProblems(m)
if len(got) != 1 || !strings.Contains(got[0], "mesh-one.be") {
t.Fatalf("got %v", got)
}
}
func TestAnImageFromAnInstallationsRegistryNeedsAReason(t *testing.T) {
bare := Manifest{Module: "site", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc"},
}}
if got := InstallationProblems(bare); len(got) != 1 || !strings.Contains(got[0], "registry.mesh-one.be") {
t.Fatalf("an image on an installation's registry was not named: %v", got)
}
excepted := Manifest{Module: "site", Resources: []map[string]any{
{"id": "server", "type": "container", "image": "registry.mesh-one.be/org/site@sha256:cc",
NamesOnPurpose: map[string]any{"registry.mesh-one.be": "built outside the mesh until the site's repository is a build source here"}},
}}
if got := InstallationProblems(excepted); len(got) != 0 {
t.Fatalf("a declared exception was still reported: %v", got)
}
}
func TestAModuleNamedAfterADomainIsNamedBack(t *testing.T) {
got := InstallationProblems(Manifest{Module: "mesh-one.be"})
if len(got) != 1 || !strings.Contains(got[0], "named after mesh-one.be") {
t.Fatalf("got %v", got)
}
}
func TestABuildContextOnASeatNamesNoForge(t *testing.T) {
m := Manifest{Module: "packager", Build: &Build{Artifacts: []Artifact{
{Name: "server", Kind: "image", From: "Dockerfile",
Context: &ArtifactContext{Seat: "git", Repository: "org/controller", Ref: "main"}},
}}}
if got := InstallationProblems(m); len(got) != 0 {
t.Fatalf("a context on a seat was reported: %v", got)
}
m.Build.Artifacts[0].Context = &ArtifactContext{Repository: "https://git.mesh-one.be/org/controller.git"}
if got := InstallationProblems(m); len(got) != 1 {
t.Fatalf("a context by URL was not reported: %v", got)
}
}
+1 -14
View File
@@ -1,14 +1,13 @@
package catalogue
import (
"strings"
"testing"
)
// A manifest may say which tools its module calls (novox/hq ADR 0152), and the parser accepts the
// two shapes the grant has: a named tool, and every tool.
func TestAManifestMaySayWhatItInvokes(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1",` +
m, err := decodeForTest([]byte(`{"module":"mesh-console","version":"1",` +
`"invokes":["mesh-catalog.catalog_modules","*"]}`))
if err != nil {
t.Fatal(err)
@@ -17,15 +16,3 @@ func TestAManifestMaySayWhatItInvokes(t *testing.T) {
t.Fatalf("invokes not read: %v", m.Invokes)
}
}
// An entry that names a module and no tool is refused at parse, in the manifest's words, rather than
// at the composition of the bus's user list where it would stop the file for everybody.
func TestAnInvokeThatNamesNoToolIsRefusedAtParse(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"mesh-console","version":"1","invokes":["shop"]}`))
if err == nil {
t.Fatal("an invoke naming no tool was accepted")
}
if !strings.Contains(err.Error(), `invokes "shop", which does not name a tool`) {
t.Fatalf("refused for the wrong reason: %v", err)
}
}
-41
View File
@@ -1,42 +1 @@
package catalogue
import (
"strings"
"testing"
)
// A provision's credential may be issued by its provider's software (novox/hq ADR 0305, issue 526): the offer
// names the tool that answers it and no own secret, and every other shape is refused at parse.
func TestAnOfferMaySayItsSoftwareIssuesTheCredential(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"media","version":"1",
"provides":[{"name":"media-api","scope":"mesh","credential":{"issued-by":"software","tool":"media_issued_credential"}}],
"serves":{"media-api":{"port":32400}}}`))
if err != nil {
t.Fatal(err)
}
if tool, issued := m.IssuedCredentialOf("media-api"); !issued || tool != "media_issued_credential" {
t.Fatalf("the offer's issued credential was not read: %q %v", tool, issued)
}
if _, shared := m.SharedCredentialOf("media-api"); shared {
t.Fatal("an issued credential was read as an own secret shared")
}
if _, issued := m.IssuedCredentialOf("other"); issued {
t.Fatal("a provision the module does not offer reads as issued")
}
for want, raw := range map[string]string{
"the issuers an offer may name": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"vendor","tool":"d_token"}}]}`,
"names no tool that answers it": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"software"}}]}`,
"names its own secret": `{"module":"d","version":"1","own-secrets":{"token":{"path":"/p","taken":"at-start"}},
"provides":[{"name":"d-api","credential":{"issued-by":"software","tool":"d_token","own":"token"}}]}`,
"the issuers an offer may name ": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"tool":"d_token"}}]}`,
} {
want = strings.TrimSpace(want)
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("expected a refusal saying %q, got %v", want, err)
}
}
}
-45
View File
@@ -1,46 +1 @@
package catalogue
import (
"strings"
"testing"
)
// A provision's credential may be one only a person outside the mesh can give (novox/hq ADR 0337 §1, issue 164):
// the offer says `issued-by: outside` and names neither an own secret nor a tool, and the parser refuses it beside
// either. An offer that says `software` keeps ADR 0305's shape.
func TestAnOfferMaySayAPersonOutsideTheMeshIssuesItsCredential(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"tv","version":"1",
"provides":[{"name":"tv-api","scope":"mesh","credential":{"issued-by":"outside"}}],
"serves":{"tv-api":{"port":8989}}}`))
if err != nil {
t.Fatal(err)
}
if !m.IssuedOutsideOf("tv-api") {
t.Fatal("the offer's credential issued outside was not read")
}
if m.IssuedOutsideOf("other") {
t.Fatal("a provision the module does not offer reads as issued outside")
}
if _, issued := m.IssuedCredentialOf("tv-api"); issued {
t.Fatal("a credential issued outside was read as issued by the software")
}
if _, shared := m.SharedCredentialOf("tv-api"); shared {
t.Fatal("a credential issued outside was read as an own secret shared")
}
if who, offered := OffersIssuedOutside(map[string]Manifest{"tv": m}, "tv-api"); !offered || who != "tv" {
t.Fatalf("the catalogue does not say tv offers tv-api issued outside: %q %v", who, offered)
}
for want, raw := range map[string]string{
"names its own secret": `{"module":"d","version":"1","own-secrets":{"key":{"path":"/p","taken":"at-start"}},
"provides":[{"name":"d-api","credential":{"issued-by":"outside","own":"key"}}]}`,
"names the tool": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"outside","tool":"d_key"}}]}`,
"the issuers an offer may name": `{"module":"d","version":"1",
"provides":[{"name":"d-api","credential":{"issued-by":"a-person"}}]}`,
} {
if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("expected a refusal saying %q, got %v", want, err)
}
}
}
-48
View File
@@ -1,49 +1 @@
package catalogue
import (
"strings"
"testing"
)
// A jail whose pattern names <HOST> twice is refused by name: fail2ban would read it as a duplicate
// capture group and refuse its whole configuration, leaving the machine with no bans at all — which
// is what happened on the control node the day this was built (novox/hq ADR 0179).
func TestAJailsPatternNamesTheHostOncePerShape(t *testing.T) {
twice := Manifest{Module: "route-proxy", Jails: []Jail{{
Name: "route-proxy",
Failregex: `^.*(?:refused for <HOST>:\d+|asked from <HOST>:\d+)$`,
Jail: "backend = systemd",
}}}
problems := twice.jailProblems()
if len(problems) != 1 || !strings.Contains(problems[0], "names <HOST> 2 times") {
t.Fatalf("a pattern naming <HOST> twice was not refused by name: %v", problems)
}
if !strings.Contains(problems[0], "no bans at all") {
t.Errorf("the refusal does not say what it costs: %q", problems[0])
}
// The same two shapes, one pattern each, is how it is written — and is accepted.
split := Manifest{Module: "route-proxy", Jails: []Jail{{
Name: "route-proxy",
Failregex: "^.*refused for <HOST>:\\d+$\n ^.*asked from <HOST>:\\d+$",
Jail: "backend = systemd",
}}}
if problems := split.jailProblems(); len(problems) != 0 {
t.Fatalf("one <HOST> per pattern was refused: %v", problems)
}
}
// A jail with no name, no pattern, or a name another jail of the same module already took is refused.
func TestAJailIsNamedOnceAndSaysWhatAFailureLooksLike(t *testing.T) {
m := Manifest{Module: "mailu", Jails: []Jail{
{Name: "", Failregex: "^.*rip=<HOST>$"},
{Name: "mailu-front", Failregex: " "},
{Name: "mailu-front", Failregex: "^.*rip=<HOST>$"},
}}
problems := strings.Join(m.jailProblems(), "\n")
for _, want := range []string{"a jail with no name", "says nothing a failed attempt looks like", "two jails called"} {
if !strings.Contains(problems, want) {
t.Errorf("missing %q in:\n%s", want, problems)
}
}
}
-10
View File
@@ -56,13 +56,3 @@ func TestOnlyAModuleThatKeepsGetsTheExport(t *testing.T) {
}
}
}
func TestKeepsMustBeAnAbsolutePath(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"root"}`))
if err == nil || !strings.Contains(err.Error(), "keeps") {
t.Fatalf("a relative keeps path was not refused: %v", err)
}
if _, err := ParseManifest([]byte(`{"module":"mesh-vault","version":"1","keeps":"/var/lib/mesh-vault/root"}`)); err != nil {
t.Fatalf("an absolute keeps path was refused: %v", err)
}
}
-130
View File
@@ -1,7 +1,6 @@
package catalogue
import (
"strings"
"testing"
)
@@ -25,62 +24,6 @@ func aChannel(module, kind string) Manifest {
{Name: "channel", Scope: ScopeMesh, Kind: kind}, {Name: "intake", Scope: ScopeMesh, Kind: kind}}}
}
func TestTwoChannelsOfDifferentKindsHoldTheBenches(t *testing.T) {
shelf := Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
"desk-channel": aChannel("desk-channel", "desktop")}
if got := problemsFor(t, shelf); got != "" {
t.Fatalf("two kinds were refused: %s", got)
}
for _, m := range shelf {
if got := declaredSeatProblems(m); len(got) > 0 {
t.Fatalf("%s: %v", m.Module, got)
}
}
}
func TestASecondClaimOfOneKindIsRefused(t *testing.T) {
got := problemsFor(t, Shelf{"messenger": router(), "telegram": aChannel("telegram", "telegram"),
"telegram-two": aChannel("telegram-two", "telegram")})
if !strings.Contains(got, `of kind "telegram", which telegram already claims`) {
t.Fatalf("a second holder of one kind stood: %s", got)
}
}
func TestAKindedBenchNeedsAKindAndNoOtherSeatTakesOne(t *testing.T) {
got := problemsFor(t, Shelf{"messenger": router(), "nameless": aChannel("nameless", "")})
if !strings.Contains(got, "claims the kinded bench channel and names no kind") {
t.Fatalf("a claim without a kind stood: %s", got)
}
odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Kind: "telegram"}}}
got = problemsFor(t, Shelf{"messenger": router(), "odd": odd})
if !strings.Contains(got, "only a kinded bench takes a kind") {
t.Fatalf("a kind on a seat that is not kinded stood: %s", got)
}
dotted := problemsFor(t, Shelf{"messenger": router(), "dotted": aChannel("dotted", "a.b")})
if !strings.Contains(dotted, "not a usable name") {
t.Fatalf("a kind that would widen a subject stood: %s", dotted)
}
}
func TestOnlyChannelAndIntakeAreKinded(t *testing.T) {
m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "pager", Kinded: true, Accepts: []string{"page"}}}}
if got := strings.Join(declaredSeatProblems(m), "; "); !strings.Contains(got, "only channel and intake are kinded") {
t.Fatalf("another kinded bench was declared: %s", got)
}
}
func TestTheNewRulesAreHeldToWhatTheSeatSays(t *testing.T) {
m := Manifest{Module: "x", DefinesSeats: []SeatDeclaration{{Name: "thing", Accepts: []string{"do"},
ByCaller: []string{"undo"}, Proofs: []string{"code"}, Records: []string{"nothing"}}}}
got := strings.Join(declaredSeatProblems(m), "; ")
for _, want := range []string{"names thing.undo by its caller, which the seat neither accepts nor emits",
"declares proofs on thing, which is not kinded", `read its records "nothing", which it keeps no state of`} {
if !strings.Contains(got, want) {
t.Errorf("not refused: %q in %s", want, got)
}
}
}
// Two kinds on one machine are two holders, and one kind on two machines is a second claimant.
func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) {
modules := []Manifest{aChannel("telegram", "telegram"), aChannel("desk-channel", "desktop")}
@@ -93,76 +36,3 @@ func TestEachKindIsItsOwnHolderWhenResolved(t *testing.T) {
t.Fatal("one kind held on two machines was not refused")
}
}
// A channel's capabilities come from the fixed vocabulary, and only a kinded claim carries any.
func TestCapabilitiesAreTheVocabularysAndOnlyOnAKindedClaim(t *testing.T) {
good := aChannel("telegram", "telegram")
good.Claims[0].Capabilities = []string{"deliver", "choice", "verified-sender", "max-length:4096"}
good.RunsAs = "telegram"
if got := problemsFor(t, Shelf{"messenger": router(), "telegram": good}); got != "" {
t.Fatalf("the vocabulary was refused: %s", got)
}
bad := aChannel("telegram", "telegram")
bad.Claims[0].Capabilities = []string{"trusted", "max-length:lots"}
got := problemsFor(t, Shelf{"messenger": router(), "telegram": bad})
for _, w := range []string{`"trusted"`, `"max-length:lots"`} {
if !strings.Contains(got, w+", which channel-capabilities/1 does not have") {
t.Errorf("%s was not refused: %s", w, got)
}
}
odd := Manifest{Module: "odd", Claims: []Claim{{Name: "operator-channel", Scope: ScopeMesh, Capabilities: []string{"deliver"}}}}
if got := problemsFor(t, Shelf{"messenger": router(), "odd": odd}); !strings.Contains(got, "only a kinded bench's claim carries them") {
t.Errorf("capabilities on a seat that is not kinded stood: %s", got)
}
}
// novox/hq ADR 0259 §8: a module saying warrants, or speaking for a kind that proves its sender, runs as an
// account of its own — never carried by the machine's runtime, which runs as the operator's account.
func TestATrustedHolderMustRunAsAnAccountOfItsOwn(t *testing.T) {
r := router()
r.RunsAs = ""
if got := problemsFor(t, Shelf{"messenger": r}); !strings.Contains(got, "messenger must run as an account of its own") {
t.Errorf("a router on the machine's runtime stood: %s", got)
}
tg := aChannel("telegram", "telegram")
tg.Claims[0].Capabilities = []string{"choice", "verified-sender"}
if got := problemsFor(t, Shelf{"messenger": router(), "telegram": tg}); !strings.Contains(got, "telegram must run as an account of its own") {
t.Errorf("a verified channel on the machine's runtime stood: %s", got)
}
desk := aChannel("desk-channel", "desktop")
desk.Claims[0].Capabilities = []string{"choice"}
if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": desk}); got != "" {
t.Errorf("a channel proving nothing was held to it: %s", got)
}
// A kind that is `private` shows a link's code, which links an account as the operator: its holder is
// trusted with it, so it runs as its own account too (the confirmation review of 2026-10-09).
private := aChannel("desk-channel", "desktop")
private.Claims[0].Capabilities = []string{"choice", "private"}
if got := problemsFor(t, Shelf{"messenger": router(), "desk-channel": private}); !strings.Contains(got, "desk-channel must run as an account of its own") {
t.Errorf("a private channel on the machine's runtime stood: %s", got)
}
}
func TestRunsAsIsAnAccountOfTheModulesOwn(t *testing.T) {
ok := Manifest{Module: "telegram", RunsAs: "telegram", SecretsOwner: "telegram",
OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/telegram/broker"}},
Resources: []map[string]any{{"id": "account", "type": "user", "name": "telegram"}}}
if got := RunsAsProblems(ok); len(got) != 0 {
t.Fatalf("a sound runs-as was refused: %v", got)
}
for want, change := range map[string]func(*Manifest){
"never root": func(m *Manifest) { m.RunsAs, m.SecretsOwner = "root", "root" },
"not an account name": func(m *Manifest) { m.RunsAs = "${machine:account}" },
"which it does not make": func(m *Manifest) { m.Resources = nil },
"declares no own secret": func(m *Manifest) { m.OwnSecrets = nil },
"they are the account's own": func(m *Manifest) { m.SecretsOwner = "" },
} {
m := ok
m.Resources = append([]map[string]any(nil), ok.Resources...)
m.OwnSecrets = OwnSecrets{"broker": {Path: "/x"}}
change(&m)
if got := strings.Join(RunsAsProblems(m), "; "); !strings.Contains(got, want) {
t.Errorf("want %q, got %q", want, got)
}
}
}
-14
View File
@@ -118,20 +118,6 @@ func TestNoLocalNetworkIsSaidAndNotWidened(t *testing.T) {
}
}
// `local-network` is a source a manifest may name (§1, changing ADR 0045 §3), and an unknown source is
// still refused.
func TestAManifestMayListenFromTheLocalNetwork(t *testing.T) {
if _, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1",
"listens":[{"port":1883,"from":"local-network"}]}`)); err != nil {
t.Fatalf("a listen from the local network is refused: %v", err)
}
_, err := ParseManifest([]byte(`{"module":"mosquitto","version":"1",
"listens":[{"port":1883,"from":"lan"}]}`))
if err == nil || !strings.Contains(err.Error(), `from "lan"`) {
t.Fatalf("a source the mesh cannot render is not refused: %v", err)
}
}
// **A fourth reach** (§4, changing ADR 0138 §4): `internal` with the local networks added to what the
// filter opens — the internal name, not the public one.
func TestTheLocalNetworkReachIsInternalWithTheLocalNetworkAdded(t *testing.T) {
-31
View File
@@ -87,34 +87,3 @@ func (l LogDeclaration) MarshalJSON() ([]byte, error) {
type plain LogDeclaration
return json.Marshal(plain(l))
}
// LogProblems is what is wrong with a manifest's logs.
//
// Refused at registration, for a bucket's reason: a stream name the bus cannot hold, or a cap the
// mesh would not grant, is a module that installs, starts, and is refused on its first append.
func LogProblems(m Manifest) []string {
var problems []string
if len(m.Logs) > 0 && !stateName.MatchString(m.Module) {
problems = append(problems, fmt.Sprintf(
"%s keeps a log, and a module's name is part of its logs' names, which take one plain "+
"name — no dot (novox/hq ADR 0297)", m.Module))
}
seen := map[string]bool{}
for _, l := range m.Logs {
switch {
case !stateName.MatchString(l.Name):
problems = append(problems, fmt.Sprintf(
"%s keeps log %q: a log is named locally — lower-case letters, digits and hyphens, "+
"no dot and no underscore; the mesh derives the stream (novox/hq ADR 0297)", m.Module, l.Name))
case seen[l.Name]:
problems = append(problems, fmt.Sprintf("%s keeps log %q twice", m.Module, l.Name))
}
seen[l.Name] = true
if l.MaxMiB != 0 && (l.MaxMiB < LogLeastMiB || l.MaxMiB > LogMostMiB) {
problems = append(problems, fmt.Sprintf(
"%s caps log %q at %d MiB; a log holds between %d and %d MiB (novox/hq ADR 0297)",
m.Module, l.Name, l.MaxMiB, LogLeastMiB, LogMostMiB))
}
}
return problems
}
+3 -41
View File
@@ -2,14 +2,13 @@ package catalogue
import (
"encoding/json"
"strings"
"testing"
)
// A module declares the logs it keeps (novox/hq ADR 0297 §1): a log by its bare name, or with its cap
// in MiB; the default cap where none is said.
func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) {
m, err := ParseManifest([]byte(`{"module":"mesh-issues","version":"1",` +
m, err := decodeForTest([]byte(`{"module":"mesh-issues","version":"1",` +
`"logs":["changes",{"name":"moves","max-mib":2048}]}`))
if err != nil {
t.Fatal(err)
@@ -25,7 +24,7 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) {
t.Fatalf("written back as %s", out)
}
// 1953 S-age (ADR 0565 §10): a log may keep its entries for an age, written back with it.
aged, err := ParseManifest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`))
aged, err := decodeForTest([]byte(`{"module":"mesh-hand-acts","version":"1","logs":[{"name":"acts","max-age-days":400}]}`))
if err != nil || aged.Logs[0].MaxAgeDays != 400 {
t.Fatalf("an aged log read as %+v, %v", aged.Logs, err)
}
@@ -33,45 +32,8 @@ func TestAManifestMaySayWhatLogsItKeeps(t *testing.T) {
t.Fatalf("an aged log written back as %s", out)
}
for _, edge := range []string{`{"name":"a","max-mib":1}`, `{"name":"a","max-mib":8192}`, `{"name":"a","max-age-days":3650}`} {
if _, err := ParseManifest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil {
if _, err := decodeForTest([]byte(`{"module":"a","version":"1","logs":[` + edge + `]}`)); err != nil {
t.Errorf("%s is inside the caps and was refused: %v", edge, err)
}
}
}
// The catalogue check refuses a log outside the caps, with a name that is not one plain token, with a
// field the mesh does not know, or kept by a module whose own name is not one plain token.
func TestALogIsNamedLocallyAndCapped(t *testing.T) {
for _, c := range []struct{ manifest, says string }{
{`{"module":"a","version":"1","logs":["mesh.changes"]}`, `keeps log "mesh.changes": a log is named locally`},
{`{"module":"a","version":"1","logs":["my_changes"]}`, `keeps log "my_changes"`},
{`{"module":"a","version":"1","logs":["Changes"]}`, `keeps log "Changes"`},
{`{"module":"a","version":"1","logs":["c","c"]}`, `keeps log "c" twice`},
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":8193}]}`, `between 1 and 8192 MiB`},
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":-1}]}`, `between 1 and 8192 MiB`},
{`{"module":"a","version":"1","logs":[{"name":"c","max-mib":0}]}`, `max-mib is between 1 and 8192, not 0`},
{`{"module":"a","version":"1","logs":[{"name":"c","stream":"LOG_x"}]}`, `{name, max-mib, max-age-days}`},
{`{"module":"a","version":"1","logs":[{"name":"c","max-age-days":0}]}`, `max-age-days is between 1 and 3650, not 0`},
{`{"module":"a.b","version":"1","logs":["c"]}`, `no dot`},
} {
_, err := ParseManifest([]byte(c.manifest))
if err == nil {
t.Errorf("%s was accepted", c.manifest)
continue
}
if !strings.Contains(err.Error(), c.says) {
t.Errorf("%s refused for the wrong reason: %v", c.manifest, err)
}
}
}
// **Across the whole catalogue**: every log is named locally and capped within the mesh's caps.
func TestEveryManifestsLogsAreLocalAndCapped(t *testing.T) {
var problems []string
for _, m := range theCatalogue(t) {
problems = append(problems, LogProblems(m)...)
}
if len(problems) > 0 {
t.Fatalf("the catalogue's logs are not what ADR 0297 says:\n %s", strings.Join(problems, "\n "))
}
}
+1 -1295
View File
File diff suppressed because it is too large Load Diff
+4 -29
View File
@@ -22,20 +22,9 @@ func manifestMounting(from string, beside string) []byte {
return []byte(`{"module":"store","resources":[` + strings.Replace(res, "%s", from, 1) + `]}`)
}
func TestAMountNothingDeclaresIsRefused(t *testing.T) {
_, err := ParseManifest(manifestMounting("/services/store/data", ""))
if err == nil {
t.Fatal("a container mounting a path no resource declares was accepted; the runtime " +
"would create it as root and the module's owner and mode would never apply")
}
if !strings.Contains(err.Error(), "/services/store/data") {
t.Fatalf("refused without naming the path, which leaves the author guessing: %v", err)
}
}
// Declaring it is enough — including declaring the directory above it.
func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) {
_, err := ParseManifest(manifestMounting("/services/store/data",
_, err := decodeForTest(manifestMounting("/services/store/data",
`{"id":"state","type":"directory","path":"/services/store","mode":"0700"}`))
if err != nil {
t.Fatalf("a module that said where its data lives was refused anyway: %v", err)
@@ -45,27 +34,13 @@ func TestAMountUnderADeclaredDirectoryIsAccepted(t *testing.T) {
// The operator's path, granted for use (ADR 0051), is declared by `accesses`, not by a directory the
// module would then own.
func TestAMountOfAnAccessedPathIsAccepted(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"store","accesses":[{"path":"/services/media/movies","mode":"read"}],` +
_, err := decodeForTest([]byte(`{"module":"store","accesses":[{"path":"/services/media/movies","mode":"read"}],` +
`"resources":[` + strings.Replace(aContainerMounting, "%s", "/services/media/movies", 1) + `]}`))
if err != nil {
t.Fatalf("a mount of a path the module declares it accesses was refused: %v", err)
}
}
// The machine's facility — the container runtime's socket — is granted by the capability that names
// it, and declaring it as the module's own directory would be a lie the host would act on. This is
// the case the first version of this check refused, and was withdrawn for: the builder.
func TestTheRuntimeSocketIsGrantedByTheCapabilityAndNotOtherwise(t *testing.T) {
granted := `{"module":"builder","capabilities":["container-runtime"],"resources":[` +
strings.Replace(aContainerMounting, "%s", "/var/run/docker.sock", 1) + `]}`
if _, err := ParseManifest([]byte(granted)); err != nil {
t.Fatalf("a module with the container-runtime capability may mount its socket: %v", err)
}
if _, err := ParseManifest(manifestMounting("/var/run/docker.sock", "")); err == nil {
t.Fatal("a module mounted the container runtime's socket without declaring the capability, and was accepted")
}
}
// **Every manifest in the catalogue (internal/beside) passes**, so the rule is not one the catalogue
// is already breaking.
func TestEveryCatalogueManifestDeclaresWhatItMounts(t *testing.T) {
@@ -79,7 +54,7 @@ func TestEveryCatalogueManifestDeclaresWhatItMounts(t *testing.T) {
// Where a bound fact lands is the mesh's file too, and a container may mount it directly.
func TestAMountOfABoundFactIsAccepted(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"store","requires":["model-access"],` +
_, err := decodeForTest([]byte(`{"module":"store","requires":["model-access"],` +
`"binds":{"model-access":"/var/lib/store/model.json"},"resources":[` +
strings.Replace(aContainerMounting, "%s", "/var/lib/store/model.json", 1) + `]}`))
if err != nil {
@@ -90,7 +65,7 @@ func TestAMountOfABoundFactIsAccepted(t *testing.T) {
// The file a filter module's rule set is written to is declared by `filtering.into` (novox/hq ADR
// 0169): the module's runtime mounts it to reload the mesh's own table, and nothing else declares it.
func TestAMountOfTheFilterFileIsDeclaredByFilteringInto(t *testing.T) {
_, err := ParseManifest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` +
_, err := decodeForTest([]byte(`{"module":"nftables","filtering":{"into":"/etc/nftables.conf"},` +
`"resources":[` + strings.Replace(aContainerMounting, "%s", "/etc/nftables.conf", 1) + `]}`))
if err != nil {
t.Fatalf("a filter module mounting its own filter file was refused: %v", err)

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