Compare commits

...
431 Commits
Author SHA1 Message Date
jschoubben 9796444201 Merge remote-tracking branch 'origin/main' into feat/1126-nodes-compose
mesh/delivery merged: source.main moved to novox/mesh-sdk@823d52051f03
mesh/delivery-group group feat/1126-nodes-compose merged: every member is merged
mesh/check checking 979644420117 on 7187beaf042b | novox/mesh-sdk#98 in group feat/1126-nodes-compose
# Conflicts:
#	test/standing.test.ts
2026-10-26 22:03:26 +01:00
mesh-tools 7187beaf04 Merge pull request 'The provider loop's tests wait on what they mean, not on the clock (issue 1901 note 27)' (#101) from fix/standing-test-races into main 2026-10-26 20:58:44 +00:00
jschoubben 5f45175ed0 Merge remote-tracking branch 'origin/main' into feat/1126-nodes-compose 2026-10-26 21:56:47 +01:00
jschoubben 18e541c1ba The standing test allows 10 ms between the loop's clock and the test's stamp of an announcement
mesh/delivery merged: source.main moved to novox/mesh-sdk@7187beaf042b
mesh/check pass 18e541c1badc on 7187beaf042b | carried from 2cbe21937153 | a compiled program is not refused: mesh-build-node reported no programs | u…
2026-10-26 21:55:46 +01:00
jschoubben cf930388f9 The standing test's runs last 0.6 of failingAfterMs of clock as well as two attempts, so a success that does not reset a run is caught; the addition test holds a thousand stable passes, so 'first pass' is proved (review of the standing test races) 2026-10-26 21:49:03 +01:00
jschoubben 36ae0c691b The standing and retirement tests wait on what they mean, not on a slept fraction of a bound: announcements are timed from the clock at the moment they are said, short runs are counted in attempts, an addition is proved by a hold it must not wait for (the second timing race of one kind in a day; issue 1126) 2026-10-26 21:44:06 +01:00
mesh-tools 2cbe219371 Merge pull request 'A build seat's capacity follows its CPU share and its build hours; asks say their phases (issues 1890, 1806)' (#100) from fix/capacity-follows-cpu-share into main 2026-10-26 20:33:28 +00:00
jschoubben 3a095a66be The standing test keeps c failing alone after its ask is dropped, so its only recovery is its retirement: clearing every failure at once raced the loop's read of the new list under load (issue 1126; the hidden failure of try-1793042418286545971, issue 1901) 2026-10-26 21:33:20 +01:00
jschoubben 805b4d9a35 Merge main into fix/capacity-follows-cpu-share: the build modules' binary refusal (sdk #99) beside the capacity work
mesh/delivery merged: source.main moved to novox/mesh-sdk@2cbe21937153
mesh/delivery-group group fix/capacity-follows-cpu-share merged: every member is merged
mesh/check checking 805b4d9a3536 on dae8e288d2e1 | novox/mesh-sdk#100 in group fix/capacity-follows-cpu-share
2026-10-26 21:26:58 +01:00
jschoubben d00dac140b A build module takes asks only within its node's build hours
mesh/delivery-group group fix/capacity-follows-cpu-share rejected: composed together: the checker's verdict at the members' heads (63694fe9222d): unit: ..test
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: ..test: ✔ a transient empty list for four passes retires nothing; five stable passes retire, data kept (418.253369ms) | d00da…
The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00. A build-hours setting names a
window per node in its local time (crossing midnight allowed, absent = any hour); outside it the
capacity judged by is 0, running steps finish, and the puller wakes on one timer at the next edge.
The change is said in the log and in capacity.<node> with the window and when it opens.
2026-10-26 21:21:37 +01:00
jschoubben 03dce12378 The standing test keeps c failing alone after its ask is dropped, so its only recovery is its retirement: clearing every failure at once raced the loop's read of the new list under load (issue 1126; the hidden failure of try-1793042418286545971, issue 1901)
mesh/delivery-group group feat/1126-nodes-compose rejected: composed together: the checker's verdict at the members' heads (0d06ff386dce): ace
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: ace: novox/mesh-sdk#98 does not merge into the trunk: files in conflict: test/standing.test.ts | 03dce1237803 on 7187beaf042b | uni…
2026-10-26 20:33:23 +01:00
mesh-tools dae8e288d2 Merge pull request 'A change that adds a compiled binary is refused, in any repository (issue 1577)' (#99) from fix/1577-checker-refuses-binaries into main 2026-10-26 19:32:25 +00:00
jschoubben dd9d9d5b5f Compare haves with SameHave in the round-trip tests, now that a have carries a list (issue 1126)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking dd9d9d5b5f01 on dae8e288d2e1 | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 20:20:12 +01:00
jschoubben 2c0ab3a592 A have carries the values a self-composing node's running composition holds sealed to its key, so its composer reuses them after a restart (review of L2, round 2; issue 1126, ADR 0547 §3)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (4 tests): unit: go.go vet: # git.novox.be/novox/mesh-sdk/go/delivery [git.novox.be/novox/mesh-sdk/go/delivery.test] | 2c0ab3a59288 …
2026-10-26 20:19:54 +01:00
jschoubben 3968849b1b Review of L2: the export is a placement file at its version with its modules (S1); a node asks the vault only when a module placed on it declares an own secret, so one with none composes whether or not the vault answers (H8's failure) (issue 1126)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 3968849b1b36 on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 20:08:32 +01:00
jschoubben 02f50bd9d6 A build seat's capacity follows its node's build CPU, and a running ask says its phases
Since the build CPU cap (issue 1806) shanks' mesh-build-go kept capacity 3 inside a 12-core slice:
three full checks got about 4 cores each and ran past the 30-minute step bound (issue 1890).
The puller now judges by min(configured, floor(slice quota cores / cores-per-step)), the quota
read live from its cgroup, says it on change and writes capacity.<node> to the pauses state.
A run's phases (fetch and merge, digest, tests) are logged with the ask id and written to its
running step key; Asker.Wait hands every step value to a WithStepSeen hook.
2026-10-26 19:52:54 +01:00
jschoubben fe11db9293 MergeInto also names programs a commit of the branch added and a later one removed: the merge commit would keep the blob in history (issue 1577)
mesh/delivery merged: source.main moved to novox/mesh-sdk@dae8e288d2e1
mesh/delivery-group group fix/1577-checker-refuses-binaries merged: every member is merged
mesh/check checking fe11db929397 on a7a1fbd34e1d | novox/mesh-sdk#99 in group fix/1577-checker-refuses-binaries
2026-10-26 19:47:17 +01:00
jschoubben 9713c3023a Placement carries a settings-form module's config values, which mesh-config does not hold, and the export writes them (issue 1126, ADR 0555 §1): the media modules on one node would compose without them
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: ace: novox/mesh-catalog#505 does not merge into the trunk: files in conflict: .gitignore | 9713c3023a18 on a7a1fbd34e1d | unit 1245…
2026-10-26 19:38:37 +01:00
jschoubben 53c2def1a5 VaultSecretChanged is a variable (its seat is), and gofmt (issue 1126)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: unit: git.novox.be/novox/mesh-catalog/checks/nobinaries.TestTheCatalogueHoldsNoCompiledProgram: modules/mesh-config/cmd/mesh-config…
2026-10-26 19:25:19 +01:00
jschoubben f55b34febc A node composing its own hears the vault's secret-changed, names and generations only (issue 1126, ADR 0547 §2)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed (6 tests): unit: go.gofmt: not as gofmt writes them (run gofmt -w): go/buscompose/buscompose_test.go | f55b34febc2d on a7a1fbd34e1d …
2026-10-26 19:25:09 +01:00
jschoubben 944a35d200 A node's record carries the mesh's own certificate as the machine holds it, and frombus composes with the composing node's (issue 1126, ADR 0555 §3)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: shanks: novox/mesh-catalog#505: checks/placement/go.mod names mesh-sdk 91413de33516, which is neither on mesh-sdk's trunk nor the g…
2026-10-26 19:23:06 +01:00
jschoubben 7d81e8326f frombus gives the composing node's core credentials as the vault's boxes sealed to it, from the same own secrets (issue 1126, ADR 0349 §16, ADR 0550 §5) 2026-10-26 19:22:58 +01:00
jschoubben 91413de335 compose names the pair credentials a node's composition reads as consumer and provider, and frombus asks them sealed before it composes (issue 1126, ADR 0550 §5)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: g14: novox/mesh-catalog#505: checks/placement/go.mod names mesh-sdk f1d7c7377dc4, which is neither on mesh-sdk's trunk nor the grou…
2026-10-26 19:18:46 +01:00
jschoubben 6447a1c6d4 placement-seed writes each node's first placement file from the controller's export, checked before anything is written, once (issue 1126, ADR 0549 §4, ADR 0555 §6)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: shanks: novox/mesh-catalog#505: checks/placement/go.mod names mesh-sdk f1d7c7377dc4, which is neither on mesh-sdk's trunk nor the g…
2026-10-26 19:17:45 +01:00
jschoubben 4d5f430224 A want the node composes itself says so (Self) and names the build alone; every other want names its composition as before, so no caller of the old form changes (issue 1126, ADR 0547 §2)
mesh/delivery closed: a newer head of the same pull request
mesh/check failed: ace: novox/mesh-catalog#505: checks/placement/go.mod names mesh-sdk f1d7c7377dc4, which is neither on mesh-sdk's trunk nor the grou…
2026-10-26 19:13:56 +01:00
jschoubben dfe3ba9475 frombus exports a node's placement from a composition input, Input's inverse for what a person decides, and the controller's seat declares placement-export (issue 1126, ADR 0555 §6)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking dfe3ba94750c on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 19:11:34 +01:00
jschoubben f1d7c7377d frombus reads the composing node's own secrets sealed to it, and node principals may ask the vault's own in their own name (issue 1126, ADR 0550 §5)
mesh/delivery-group group feat/1126-nodes-compose checking: 4 of 5 member(s) ready
mesh/delivery closed: a newer head of the same pull request
mesh/check checking f1d7c7377dc4 on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 19:03:00 +01:00
jschoubben 22bc4dc8ef MergeInto names the changed files that are compiled programs, read in the workspace, so a check can refuse build output (issue 1577) 2026-10-26 18:59:35 +01:00
jschoubben b72de2b75a A node applying what it composed refuses a composition that stands in for anything its input lacks, the bus's user list alone held out (issue 1126, ADR 0547 §2) 2026-10-26 18:59:05 +01:00
jschoubben 6df0dfe208 Node principals read what they compose their own modules from; the composer copy is held per user to the controller's grants plus exactly these (issue 1126, ADR 0547 §2, ADR 0549 §3)
mesh/delivery-group group feat/1126-nodes-compose rejected: composed together: the checker's verdict at the members' heads (922da6d6ae76): forge
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 6df0dfe208c6 on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 18:44:06 +01:00
jschoubben 9532b78724 A node's own want names no composition from the second milestone: the node composes its own (issue 1126, ADR 0547 §2) 2026-10-26 18:41:33 +01:00
jschoubben e49309d61c A want names the build alone from the second milestone, and the yes a person gave; a have waits for that yes with its judgement (issue 1126, ADR 0547 §2, §3) 2026-10-26 18:39:46 +01:00
jschoubben 9789c8379a Node principals read every node's own facts, where its record rides; the composer copy is checked to grant all the controller's grants and only that and the issue 1790 rows more (issue 1126, ADR 0555 §3)
mesh/delivery-group group feat/1126-nodes-compose checking: 1 of 3 member(s) ready
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 9789c8379a74 on a7a1fbd34e1d | novox/mesh-sdk#98 in group feat/1126-nodes-compose
2026-10-26 18:33:36 +01:00
jschoubben 2b0689f654 A node's record rides in its own have key, <node>.node: no bucket, writer or grant of its own, and its key's change is what readers hear (issue 1126, ADR 0555 §3) 2026-10-26 18:29:45 +01:00
jschoubben 5b1e990eb4 The tunnel found at adoption and the names of the hub's peers are kept in the placement; whether the hub took it over and who enrolled under a peer's key are read from placement and records, as the controller reads them (issue 1126, ADR 0555) 2026-10-26 18:26:11 +01:00
jschoubben a431746c0e What a person decides of a node lives in its placement file and what the machine observes in its record; two nodes on one address or two hubs are refused (issue 1126, K2) 2026-10-26 18:17:59 +01:00
jschoubben 5db53e738f Name the catalogue repository whose placement files are read (issue 1126, ADR 0549 §1) 2026-10-26 18:10:25 +01:00
jschoubben 9f7f50de8d A merged message of the catalogue carries its placement files, so the indexer reads placement at the merge without asking the forge again (issue 1126, ADR 0549 §2) 2026-10-26 18:08:59 +01:00
jschoubben bdd4c7b875 The judgement of a move is a pure function in mesh-sdk, so a node agent judges what it composed against what it runs (issue 1126, ADR 0547 §3, ADR 0377 §3) 2026-10-26 18:07:20 +01:00
jschoubben e982072443 frombus fills the composition's input from placement, node records, the indexer's manifests and mesh-config's slice, so one composer serves the node agent, the check and the grants part (issue 1126, ADR 0547 §2, ADR 0549 §3) 2026-10-26 18:06:04 +01:00
jschoubben 146cd858a3 Placement and the node record have one layout in mesh-sdk, so the indexer, the node agents and the composition check read them alike (issue 1126, ADR 0549 §1-§2, ADR 0547 §2) 2026-10-26 18:02:13 +01:00
mesh-tools a7a1fbd34e Merge pull request 'A module waits for a state created after it starts (issue 1790)' (#97) from fix/1790-state-appears into main 2026-10-26 14:47:31 +00:00
mesh-tools 1888d1d230 Merge pull request 'A git or ssh-keygen failure keeps its own words (forgekey.Said)' (#96) from fix/fetch-reads-all into main 2026-10-26 14:47:25 +00:00
mesh-tools 960d6c34d2 Merge pull request 'The composer gives build modules the node's share: idle, capped builds (issue 1806, ADR 0553/0554, change B)' (#95) from fix/build-cpu-yields-compose into main 2026-10-26 14:31:13 +00:00
jschoubben ff81bdf8cc Keep git's own words in a fetch's reason: a bare "error:" line no longer stands for the forge's refusal
mesh/delivery merged: source.main moved to novox/mesh-sdk@1888d1d230b3
mesh/delivery-group group fix/fetch-reads-all merged: every member is merged
mesh/check pass ff81bdf8cc8f on 960d6c34d25e | unit 5815 run, 345 kept, 18 skipped | integration 5603 run, 640 kept, 29 skipped | over bound: unit, in…
The mirror of jschoubben/car-hunter on ace said only "git -C: error:": git writes a bare
"error:" before the forge's message, and the reason kept the first non-empty line. forgekey.Said
keeps every line that says something, leaves bare prefixes out, joins them and bounds them at
600 bytes; every git and ssh-keygen reason of buildstep and forgekey goes through it, and the
mirror's commands are named by their verb, not by -C.
2026-10-26 15:26:29 +01:00
jschoubben 65ee43f075 Merge remote-tracking branch 'origin/main' into fix/1790-state-appears
mesh/delivery merged: source.main moved to novox/mesh-sdk@a7a1fbd34e1d
mesh/delivery-group group fix/1790-state-appears merged: every member is merged
mesh/check being carried to 65ee43f07583 on a7a1fbd34e1d | from 1888d1d230b3, mesh-delivery feef96ed160e
2026-10-26 15:24:34 +01:00
jschoubben c3e8fc4ba6 process: an awaited bucket says loudly once when the bus refuses hearing its creation, and hears it again and binds at each reconnect (issue 1790, review) 2026-10-26 15:24:33 +01:00
mesh-tools 75be8acbad Merge pull request 'Build seats yield: the decoder, buildcpu and NodeLimits (issue 1806, ADR 0553/0554, change A)' (#94) from fix/build-cpu-yields into main 2026-10-26 14:22:47 +00:00
mesh-tools d103ed88ab Merge pull request 'forgekey: a command starts in a directory that exists (issue 734)' (#93) from fix/daemon-dir-kept into main 2026-10-26 14:15:57 +00:00
jschoubben 3ac644baef process: gofmt the integration fixture (issue 1790) 2026-10-26 15:13:25 +01:00
jschoubben fd7d93333e Keep a failed toolchain probe a loud refusal; only the command directory is fixed (issue 734, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-sdk@d103ed88ab17
mesh/delivery-group group fix/daemon-dir-kept merged: every member is merged
mesh/check checking fd7d93333e6b on 21252194d530 | novox/mesh-sdk#93 in group fix/daemon-dir-kept
Declining an ask whose probe failed would turn a truly broken toolchain into a
wait of up to the decline bound that then fails with an empty "needs a node
with" reason. With the node-engine no longer deleting a running build and
commands started in a directory that exists, the getwd cause is gone, so the
guard's refusal stays as it was. The decline refactor goes with it.
2026-10-26 15:11:04 +01:00
jschoubben d515c71e1c process: a bucket missing at a part's start is bound when the bus says it was created, keeping what was written meanwhile; buscompose grants each principal the creation of the buckets it may bind (issue 1790)
Since the grants part creates a manifest's buckets after a delivery (ADR 0547), a module restarted by that
delivery can open a new state before it exists; OptionalBucket answered nil and the part never looked again
(mesh-delivery's checker and builder, state hearing, 2026-10-26). AwaitBucket binds on the server's
STREAM.CREATED advisory, or at the next use, and writes the latest value kept per key. The copied composer's
agreement test leaves that one row out: the controller's list is no longer loaded since the take-over.
2026-10-26 15:11:00 +01:00
jschoubben 36061b5c56 Start commands in a directory that exists, and decline an ask whose toolchain could not be read (issue 734)
A build module runs in the directory its build was unpacked in. When the
node-engine replaced that build, every command the module started inherited a
deleted working directory, and the toolchain probe's failure became a final
refusal of the check that asked it (three checks on ace on 2026-10-26).

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

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

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

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

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

One table test holds the copy to four meshes' lists and the server golden, written by the controller's own
internal/broker from the same fixtures (testdata/controller-user-lists.json).
2026-10-25 21:54:58 +01:00
jschoubben 0be0ffd615 Compare the have's user list by what it says, so a have is not written again for the same list (ADR 0547 §8)
mesh/delivery merged: source.main moved to novox/mesh-sdk@3db051be57ca
mesh/delivery-group group fix/1945-user-list-follows merged: every member is merged
mesh/check pass 0be0ffd6159e on db26f7e8b6f5 | unit 1467 run, 1696 kept, 7 skipped | integration 1471 run, 1740 kept, 6 skipped | over bound: unit, in…
2026-10-25 21:47:30 +01:00
jschoubben f63fa86c74 Say in the bus module's have the user list its node wrote alone, so the bridge of ADR 0547 §8 reports what the bus loads (issue 1945) 2026-10-25 21:42:13 +01:00
mesh-tools db26f7e8b6 Merge pull request 'The put's words: delivery.Put, a want's put and hold marks, a grant that says which node's accounts it names (ADR 0546)' (#82) from feat/put-version into main 2026-10-25 20:35:28 +00:00
jschoubben d4ae4b6790 Say the put's tools, not a seat's verbs, in the put's words (ADR 0546)
mesh/delivery merged: source.main moved to novox/mesh-sdk@db26f7e8b6f5
mesh/check pass d4ae4b6790b8 on db26f7e8b6f5 | unit 1102 run, 680 kept, 2 skipped | integration 1103 run, 722 kept, 2 skipped | over bound: unit (slow…
2026-10-25 21:08:10 +01:00
jschoubben 2f6741b7f8 No seat and no compose verb for the put: the controller takes no change, so the assigner serves the put as its own tools from compositions the controller already writes (ADR 0546) 2026-10-25 21:07:58 +01:00
jschoubben 20a6dc4920 A grant says whether it names a module's accounts on a node, for the assigner and the compose verb (ADR 0546) 2026-10-25 20:50:51 +01:00
jschoubben c4f37c279a The assigner's seat with put, release and puts, the controller's compose verb, a put's key and value, and the view answers no option that authorises (ADR 0546) 2026-10-25 20:49:29 +01:00
jschoubben 7f89cb51de Merge pull request 'Keep a build node's pause in a state of its own, and pull as not paused when the pause cannot be read (steps bucket full, 2026-10-25)' (#81) from fix/steps-bucket-ages into main
Reviewed-on: #81
2026-10-25 18:06:32 +00:00
jschoubben 518dae5ec9 Pull as not paused when the pause cannot be read, and say so once, instead of ending the puller silently
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/delivery-group group fix/steps-bucket-ages held: a member is held: novox/mesh-sdk@518dae5ec9c8 (merged without a passing check: only a person decides what…
mesh/check checking 518dae5ec9c8 on 7f89cb51de15 | novox/mesh-sdk#81 in group fix/steps-bucket-ages
A Load error ended Pull, and the modules run it in a goroutine whose error nobody reads: every
build node would stop pulling when the new pauses state could not be read (or when Pull reached
restore before Serve). A node kept from building is held by capacity 0, not by a pause.
2026-10-25 18:56:17 +01:00
jschoubben e8f8ef63fa Keep a build node's pause in a pauses state of its own, so the steps state can drop its old keys without ending a pause
The steps state of the build modules filled to the bus's 64 MiB cap (27,370 keys of mesh-build-go,
none ever removed) and refused a passed check's end. Its keys get an age in the catalogue; the
pause key lived among them and would then have ended by itself. Puller.PausePut writes it to
delivery.PausesState instead; Put stays the fallback for a module not yet moved.
2026-10-25 18:51:04 +01:00
jschoubben 4f4d5ca9c9 Merge pull request 'Wake a parked build puller when its capacity changes, so a node raised from capacity 0 takes asks (issue 1887)' (#80) from fix/puller-wakes-on-capacity into main
Reviewed-on: #80
2026-10-25 17:10:33 +00:00
jschoubben a8fbabbfc3 Fold the puller-wake and Served tests into existing tests to stay within the test budget (issue 1887)
mesh/delivery merged: source.main moved to novox/mesh-sdk@4f4d5ca9c956
mesh/delivery-group group fix/puller-wakes-on-capacity merged: every member is merged
mesh/check pass a8fbabbfc373 on 4f4d5ca9c956 | unit 1102 run, 679 kept, 2 skipped | integration 1103 run, 721 kept, 2 skipped | over bound: unit, inte…
2026-10-25 17:44:03 +01:00
jschoubben b9911dc1c1 Start the puller's capacity watch once Serve has the runtime's channel, since a build module pulls before it serves (issue 1887)
A watch asked before Serve is refused, which would have left every build
module on its settings file again. stdio.Served closes when Serve has the
channel; WatchConfigServed waits on it, and Pull starts the watch beside its
loop.
2026-10-25 17:26:10 +01:00
jschoubben e6b2df6506 Wake a parked build puller on its capacity's own change, so a node started at capacity 0 takes asks once raised (issue 1887)
g14's Go builder sat idle at capacity 2 while delivery builds waited: a puller
with no room waited on its wake alone, which only a pause, a resume or a step's
end gives, and it read its capacity from the settings file only when woken.
The puller now follows the module's config value capacity through the
runtime's watch of what mesh-config resolved (stdio.WatchConfig), uses the
value it hands, and wakes on each change: no timer, no file watch, and no wait
for the node-engine to fill the file again. It says when it parks and when it
wakes, with its capacity.
2026-10-25 17:23:28 +01:00
jschoubben f9d11acf23 Merge pull request 'A node's own composition may carry filled paths; a composition that would leave a provisioner file empty is refused (issue 1944)' (#79) from fix/r6-node-composition into main
Reviewed-on: #79
2026-10-25 00:17:36 +00:00
jschoubben 10b6e20232 Let the node's own composition carry its files' filled paths, and refuse a composition that would have a provisioner's file written empty (issue 1945)
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/delivery-group group fix/r6-node-composition held: a member is held: novox/mesh-sdk@10b6e202320f (merged without a passing check: only a person decides wh…
mesh/check checking 10b6e202320f on da7f0455759a
2026-10-25 01:46:36 +02:00
jschoubben da7f045575 Merge pull request 'R6: the composition's layout, its signature and digest, the want's digest, the judgement values; the hand tool is mesh-hand-compose (issue 1943)' (#78) from feat/r6 into main
Reviewed-on: #78
2026-10-24 23:17:32 +00:00
jschoubben 748c564cdf Merge main (the R4g finish: the composer command) into feat/r6 (issue 1943)
mesh/delivery delivering: 0 machine step(s) passed
mesh/delivery-group group feat/r6 merged: every member is merged
mesh/check failed: the composition: the composer of the mesh-sdk mesh-controller at bdb8193456e17c9df59215c7f3e69a83a2e5a262 pins did not run: failed …
main's mesh-compose is the composer command (ADR 0482 clause 4) and keeps its name, which composer.Package and the
build steps name; feat/r6's hand path tool, which composes one module for one node and signs it with the operator's key
(ADR 0539 clause 10), moves to cmd/mesh-hand-compose.
2026-10-24 23:01:18 +02:00
jschoubben 312878befb Merge pull request 'R4g finish: the composer's step contract and the reads-outside marker (issues 1621-1623, 1935)' (#77) from feat/r4g-finish into main
Reviewed-on: #77
2026-10-24 20:56:35 +00:00
jschoubben d7a9b9688b Let the judgement beside a composition say unchanged: equal to the declaration the node last applied (issue 1943; ADR 0540 clause 9)
The first want at the switch skips the record hold only where the composition changes nothing on the node.
2026-10-24 21:17:14 +02:00
jschoubben 7f9cc0e55c Keep plan-moved and sent among the controller seat's events until the catalogue's main consumes them no more (issue 1943)
The controller's broker tests read the catalogue's main, whose gate and messenger still consume plan-moved; nothing
emits them (ADR 0539).
2026-10-24 20:38:24 +02:00
jschoubben e45bdc0891 Drop plan-moved and sent from the controller seat's events (issue 1943; the review's notes on 1945)
No plan is kept or sent in release 6 (ADR 0539), and the messenger now hears the delivery view's events instead.
2026-10-24 20:20:48 +02:00
jschoubben a523a8cbb0 Say beside a composition that it changes only the bus's user list (issue 1943; the review's A2, for issue 1906)
A revision that changes only the user list is written into the bus node's want at once, outside the record hold
(ADR 0539 clause 7), so the assigner must tell it apart from a move that recreates or moves data.
2026-10-24 18:15:32 +02:00
jschoubben 5da87a0dda Let the controller say a bucket it deleted at its first start (issue 1943; for issue 1945, B13)
ADR 0539 clause 13 has the controller delete the plan-state buckets once and say each as its event.
2026-10-24 17:03:54 +02:00
jschoubben e64486b0e6 Sign a composition through a signer that keeps its private half (issue 1943, S5)
The controller's identity store signs a message and never hands its key out, as it signed declarations.
2026-10-24 16:42:41 +02:00
jschoubben 64a502d16a Carry the module's upgrade policy and its source beside a composition's judgement (issue 1943; for issue 1906)
The assigner holds a move of a module of policy record, and only the controller knows a person's own choice of
policy (ADR 0539 clause 14).
2026-10-24 16:40:45 +02:00
jschoubben de5b33843f Run the captured declarations' test per node in parallel, inside the unit level's bound (issue 1943) 2026-10-24 16:36:42 +02:00
jschoubben d8da8f1d36 Sign a composition with a trusted key and compose one by hand with mesh-compose (issue 1943, S5)
The node-engine applies only a composition signed by the controller or the operator, and when the controller cannot
compose its own fix the operator composes, signs and writes one at the terminal (ADR 0539 clauses 3 and 10).
2026-10-24 16:36:14 +02:00
jschoubben d506f2196a Refuse the plan verbs by name on the controller and delivery seats (issue 1943, S4)
With no delivery plan nothing starts, stops, retries or reads one; each removed verb stays a row with no input so a
caller is told its replacement, optional on the delivery seat so the gate of before and of release 6 both hold it
(ADR 0539 clauses 15, 17 and 18).
2026-10-24 16:32:11 +02:00
jschoubben d6cef5829b Have a want name its composition and a backup answer; give the node its own want, the seventh rule, the held phase and the backup and record asks (issue 1943, S3)
The node agent applies only the composition its want names, and the assigner writes a want only once that
composition exists (ADR 0539 clauses 1, 6, 14 and 16).
2026-10-24 16:29:24 +02:00
jschoubben 826fd73cf2 Give a composition its value, its canonical digest and its files by digest; key a dotted module escaped (issue 1943, S2; issue 1150)
A node applies one module's composition at a time, so each carries only its own module's resources, checked
resource by resource as the node-engine reads them, and its digest follows its content alone (ADR 0539 clauses 1
and 4). The fixtures are the controller's plan answers for ace and g14, their sealed resources taken out.
2026-10-24 16:26:32 +02:00
jschoubben aee971c198 Name a node's composition bucket, its keys, and the digest with the move's judgement beside it (issue 1943, S1)
The controller writes one module's composition per node and fingerprint, and the assigner reads its digest and
judgement before it writes a want (ADR 0539 clauses 1, 2 and 14).
2026-10-24 16:21:02 +02:00
jschoubben bfc3883af9 Read a manifest's fallback to a predefined key in the composer as the indexer reads it, or against the list a request or -catalogue gives: a run never holds the catalogue's predefined.json, and the composition refused pacman and xterm for it (ADR 0348 §7)
mesh/delivery delivered
mesh/delivery-group group feat/r4g-finish merged: every member is merged
mesh/check checking bfc3883af980 on 8c6e18e82286 | novox/mesh-sdk#77 in group feat/r4g-finish
2026-10-24 14:57:10 +02:00
jschoubben 19c1d35939 Name the pinned mesh-sdk version when an SDK command is built from a reader's tree, not a bare (devel): the compose job builds mesh-compose there and its verdict must name the reader's SDK (ADR 0482 clause 5)
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: the composition: the manifest of pacman cannot be read: this manifest cannot be used: | 19c1d35939bc on 8c6e18e82286 | unit 4799 ru…
2026-10-24 14:11:20 +02:00
jschoubben bc0c25e510 Merge main into feat/r4g-finish: the controller seat's verb held and event artifacts-gone (#76)
mesh/delivery-group group feat/r4g-finish rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: integration: meshbuildgo/cmd/mesh-build-go.TestAComposeAskIsBuiltOnceAndAnsweredOnTheBus: ask 1: answer built true key composer-066…
2026-10-24 13:25:44 +02:00
jschoubben 8c6e18e822 Merge pull request 'Give the controller seat the verb held and the event artifacts-gone (issue 1937)' (#76) from fix/1937-done-key-blobs into main
Reviewed-on: #76
2026-10-24 10:35:07 +00:00
jschoubben d9fa5536a8 Mark the packages whose tests read files outside their directory, found by the review (issue 1935) 2026-10-24 04:38:58 +02:00
jschoubben dd0d156b9e Wait for the third run to start, not only for its take, so the capacity test does not race under a loaded suite 2026-10-24 04:25:12 +02:00
jschoubben 51bc6a65b6 Merge remote-tracking branch 'origin/feat/r4g-finish-1935' into join/r4g-finish-sdk 2026-10-24 04:23:53 +02:00
jschoubben 66bf26c0d7 Let the composer read its request from a file, since a run step hands it no standard input (issue 1622) 2026-10-24 04:10:31 +02:00
jschoubben 34f210e5eb Give the controller seat the verb held and the event artifacts-gone, so the builder rebuilds a done key whose blob the artifact store no longer holds (issue 1937)
mesh/delivery covered after its stop: a later walk that contains it was delivered: plan-1792875397682979170 (novox/mesh-sdk at 312878be into main, done, …
mesh/delivery-group group fix/1937-done-key-blobs closed: a member was closed before the group merged
mesh/check pass 34f210e5eba1 on e1f393bbb12e | unit 202 run, 3523 kept | integration 2223 run, 2682 kept, 7 skipped | over bound: unit, integration (s…
The store's nightly collector deletes every bare blob no manifest names, and a done key the controller never took in holds
nothing: node-tools, mesh-host and the forge bridge kept done keys naming blobs that answer 404. held lets the builder ask
before it reuses a key; artifacts-gone is said when a take-in finds a gone blob. delivery/held.go is the shared contract.
2026-10-24 04:07:22 +02:00
jschoubben 4e0036cc95 Mark the packages whose tests read files outside their directory, so mesh-build-go never keeps their verdict (issue 1935) 2026-10-24 04:00:48 +02:00
jschoubben 4784762a70 Name every repository's siblings in beside's comment, mesh-controller beside mesh-host's tests, as the checker places them (issue 1295) 2026-10-24 03:51:35 +02:00
jschoubben 668fc2a722 Let the Go build seat carry a compose ask and its answer, so the assigner composes a group with its mesh-sdk head (issue 1622) 2026-10-24 03:51:35 +02:00
jschoubben 20d18eb4f5 Give mesh-sdk a composer command, so a program linking one mesh-sdk can have another commit's composition judged (issue 1621) 2026-10-24 03:51:35 +02:00
jschoubben e1f393bbb1 Merge pull request 'Raise @novox/mesh-sdk to 0.1.17, and a unit test refuses a packed change that keeps its version (issue 1899, met twice)' (#75) from fix/1899-version-guard into main
Reviewed-on: #75
2026-10-23 22:35:42 +00:00
jschoubben 35e86b00d3 Raise @novox/mesh-sdk to 0.1.17 and guard that a changed package carries a new version (issue 1899)
mesh/delivery delivered
mesh/check checking 35e86b00d3f0 on e1f393bbb12e
2026-10-24 00:15:27 +02:00
jschoubben 53a1de2aaa Merge pull request 'A level's digest is stored as a list, and an end the state refuses is said, so a check of a repository with a package named token ends (issue 1930, with mesh-catalog and mesh-delivery)' (#74) from fix/1930-digest-shape into main
Reviewed-on: #74
2026-10-23 22:08:06 +00:00
jschoubben 403a5d72ab Keep a level's digest as a list and say an end the state refuses, so a check of a repository with a package named token ends (issue 1930)
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792794944815912745 (novox/mesh-sdk at e1f393bb into main, don…
mesh/delivery-group group fix/1930-digest-shape merged: every member is merged
mesh/check pass 403a5d72ab93 on 1ff6d339780c | unit 4545 run, 2097 kept, 13 skipped | integration 4595 run, 2188 kept, 21 skipped | over bound: unit, …
A check ask's answer kept the digest's packages as a map keyed by Go import path;
the node runtime refuses a state value with a field ending in a credential word,
so the end of any check of mesh-controller (internal/token) was never written and
the ask was acked anyway. The level's digest is now delivery.LevelDigest, packages
a sorted list of {package, digest}, reading the old map form too. finish logs an
end refused every time, writes a small failed end naming the write's error, and
the puller gives the ask back instead of acking it when no end could be written.
2026-10-23 23:32:00 +02:00
jschoubben 1ff6d33978 Merge pull request 'R4g+R5 (mesh-sdk): asks on the build seats' streams, the assigner's layouts, mesh-pack (design 47, ADR 0524-0530, 0537)' (#73) from feat/r4g into main
Reviewed-on: #73
2026-10-23 17:48:34 +00:00
jschoubben a2c813a6b4 Stand in node-tools beside the toolchain modules in the composition test, as the catalogue's build modules require it since issue 1911, so the test judges the toolchain alone (issue 1887, group check of #73)
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792794944815912745 (novox/mesh-sdk at e1f393bb into main, don…
mesh/delivery-group group feat/r4g merged: every member is merged
mesh/check checking a2c813a6b468 on 07462c4caa12 | novox/mesh-sdk#73 in group feat/r4g
2026-10-23 17:03:21 +02:00
jschoubben a9560890ee Name the unit level's lint time in a verdict's times, so the check's line can say what gofmt and go vet cost (issue 1887 D7-go, review round 2 item 5)
mesh/delivery-group group feat/r4g rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: g14: fetch of novox/mesh-catalog#464: build_go_fetch on g14: the step g14.mvl3o921790ccf failed: go mod verify in checks/config: ch…
2026-10-23 16:50:22 +02:00
jschoubben bec9585d50 Run an ask no more once its stream delivered it past AskRuns, failing its step with why, so a run that kills its node is not offered to each build node in turn (issue 1907) 2026-10-23 16:41:12 +02:00
jschoubben fc4f57c4b3 Restart an ask's bound at each run's start, also on the same node, so a step redelivered after its build module was replaced is not cut by the killed run's time (issue 1887, review: issue 734 path; ADR 0525 §6) 2026-10-23 16:36:46 +02:00
jschoubben 88fa74ce5e Bound an ask's step from its start on the node that took it, not from its put, so an ask waiting in its stream during a large rebuild is never failed and asked twice (issue 1887, review round 2 item 1; ADR 0525 §3, §6) 2026-10-23 16:32:21 +02:00
jschoubben 9205222a98 Merge branch 'feat/r4g-fallback' into feat/r4g 2026-10-23 16:19:25 +02:00
jschoubben 285566eed1 Merge branch 'feat/r4g-scripts' into feat/r4g 2026-10-23 16:19:25 +02:00
jschoubben 51756ee9a4 Merge branch 'feat/r4g-asks' into feat/r4g 2026-10-23 16:19:24 +02:00
jschoubben d7375c9811 Add mesh-pack: a hand build with the build module's recipe and mesh-sdk's own pack (F1) 2026-10-23 16:12:23 +02:00
jschoubben 232ded26ea Delete both merge-check scripts; the type check runs before npm test (issue 1295, S8; ADR 0529)
gofmt and vet are the Go build module's, the unit bound the checker's.
2026-10-23 16:10:46 +02:00
jschoubben 691c97ec31 Ask a check's digest as the candidate under the runner and plan as any other when the installed module ignores it, as ADR 0535 §2 and §4 judge (issue 1887 A5a) 2026-10-23 16:04:01 +02:00
jschoubben ba092d543f Run the levels of one check in one ask after one fetch, keeping nothing under the candidate's runner or a digest without the siblings, as ADR 0525 §3 says a check ask is (issue 1887 A5a) 2026-10-23 15:59:15 +02:00
jschoubben c3915ffb11 Merge branch 'feat/r4g-assigner' into feat/r4g 2026-10-23 15:58:11 +02:00
jschoubben 34338df950 Key a building key's asks by build seat language, so a builder taking it over knows whose steps to watch (issue 1887 A4b) 2026-10-23 15:44:57 +02:00
jschoubben 07462c4caa Merge pull request 'Checks under 5 minutes (mesh-sdk): a kept mirror per repository for build steps' fetches, and @novox/mesh-sdk 0.1.16 (issues 890, 1899; ADR 0536)' (#72) from feat/fastcheck into main
Reviewed-on: #72
2026-10-23 13:44:13 +00:00
jschoubben 6003da0a58 Put an ask on its build seat's stream and read its step, and name a building key's asks, so the builder and the checker ask no node and a restarted builder never asks twice (issue 1887 A4a, A4b) 2026-10-23 15:43:59 +02:00
jschoubben 3e5d4c30ff Let a composition not run say why, the assigner not running among it, and name the indexer's words for a removed module, so a check never waits for a composition nobody makes (issue 1297, B3d) 2026-10-23 15:41:55 +02:00
jschoubben 1672f40233 Serve a build seat's current, pause and resume from the SDK, naming the asks running, so the three build modules hold their seats one way (issue 1887 A3node, A9a, ADR 0525 §10) 2026-10-23 15:38:03 +02:00
jschoubben e1dca0a39f Release an ask's workspace when its job ends, so a pulled build or check leaves nothing for the sweep (issue 1887 A3go, ADR 0509) 2026-10-23 15:30:32 +02:00
jschoubben 4b503f8d0d Pull a build seat's ask through the bundle's runtime one at a time and settle it by id, so a tools bundle pulls by its capacity and order (issue 1887 A11) 2026-10-23 15:29:05 +02:00
jschoubben 8cd22892a3 Read a node's build capacity from the build module's own setting at every pull, defaulting to the builder's values, so the control-node pulls nothing (issue 1887 A7, ADR 0525 §5) 2026-10-23 15:23:03 +02:00
jschoubben 1f6fc027c0 Run a pulled ask's whole job with a build module's own step tools, kept verdicts taken after its digest, so both build modules run asks one way and their tools stay the hand path (issue 1887 A3, ADR 0525 §3, §8) 2026-10-23 15:22:25 +02:00
jschoubben f625d98713 Lay out the assigner's ask value and name the asking a view answer is for, so a stale answer never fills a later asking of the same key (issue 1297 subtask B1c, for the view lane's B9 and B10) 2026-10-23 15:20:58 +02:00
jschoubben c2967432e6 Refuse the build queue's verbs by name and drop the rows R4e removed, so a caller is told the backlog is the delivery view's and a pause the build seat's (issue 1887 A9b, ADR 0525 §9) 2026-10-23 15:20:27 +02:00
jschoubben 69041c5302 Retire the seat node-build-agent for one build seat per language, so a node's builds are pulled and paused at its build modules and a claim on the old seat names its replacement (issue 1887 A9a, ADR 0525 §2, §10) 2026-10-23 15:19:41 +02:00
jschoubben 792fb2ccfd Keep a long ask alive with in-progress signals and leave a stopped holder's ask to the stream, so a node that dies hands its ask on (issue 1887 A2c, ADR 0525 §6) 2026-10-23 15:17:18 +02:00
jschoubben 89aa6fc17c Pull nothing at capacity 0, take a new capacity at the next pull, and keep a node's pause on its pause key, so a paused node stays paused across a restart (issue 1887 A2b) 2026-10-23 15:16:46 +02:00
jschoubben a1d73bdcf0 Pull a build seat's asks delivery first, at most the node's capacity, each ended on its own step key, so no part chooses a node or holds a check (issue 1887 A2a, ADR 0525 §4-§6) 2026-10-23 15:15:57 +02:00
jschoubben d9384bcb23 Lay out the asks the view may answer and the view's answer key, refusing the merge's ask, so the view and the teller answer only what needs no proof (issue 1297 subtask B1c; ADR 0528 clause 6, ADR 0437 clause 10) 2026-10-23 15:12:40 +02:00
jschoubben b9b9c5120c Name an ask's step key <node>.<ask id> and its watch, so the asker learns which node took it and refuses other keys (issue 1887 A1b, ADR 0525 §6) 2026-10-23 15:11:44 +02:00
jschoubben 37eff6fe5c Lay out the ask a build node pulls: its subjects, stream, workers and job, so builder, checker and build modules share one form (issue 1887 A1a, ADR 0525, ADR 0536 §4) 2026-10-23 15:11:39 +02:00
jschoubben 687d715e99 Lay out the controller's grants and the assigner's composition verdict under the checker's key, so the assigner's rule 6 and the checker's composition read one layout (issue 1297 subtask B1b, issue 1394; ADR 0528 clauses 2 and 5) 2026-10-23 15:11:34 +02:00
jschoubben ec2bd3b990 Lay out the assigner's wants, holds and differences in mesh-sdk, so the assigner, the watchman and the view read one layout (issue 1297, subtask B1a; ADR 0528 clauses 1 and 4) 2026-10-23 15:10:09 +02:00
jschoubben 2248b985bf Raise @novox/mesh-sdk to 0.1.16, so its package build is not refused as a second, different 0.1.15 (issue 1899)
mesh/delivery delivered
mesh/delivery-group group feat/fastcheck merged: every member is merged
mesh/check pass 2248b985bfac on 7aacbc162014 | unit 6632 run, 0 kept, 16 skipped | integration 6757 run, 0 kept, 24 skipped | over bound: unit, integr…
2026-10-23 14:15:26 +02:00
jschoubben 780be2fae5 Fetch through a bare mirror with git alternates, so a check transfers only new objects (issue 890, ADR 0536 §2) 2026-10-23 14:03:06 +02:00
jschoubben 5ab7ccf47e Merge pull request 'A build step is refused under a 20 GiB free-space floor, and a workspace is held, released and swept after 2 h (issue 1839, ADR 0532)' (#71) from fix/1839-build-floor into main
Reviewed-on: #71
2026-10-23 11:28:31 +00:00
jschoubben 6147a20454 Refuse a build step under a free-space floor and release workspaces, so a build node's disk is never filled (issue 1839)
mesh/merge-gate head 6147a2045467 merged into trunk 7aacbc162014 — pass: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus step; 9 wait(…
mesh/repo-check head 6147a2045467 merged into trunk 7aacbc162014 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
mesh/delivery-group group fix/1839-build-floor merged: every member is merged
mesh/check failed: integration: meshbuildnode/cmd/mesh-build-node.TestAFixtureBundleBuiltTwiceHasOneDigest: build_integration_test.go:71: no toolchain…
buildstep.Floor refuses a step, naming the node, what is free and the floor
(20 GiB, build-agent's), while the work or cache filesystem is under it.
buildstep.Workspaces holds a workspace while a step runs on it, removes it
when its caller releases it, and sweeps one left unheld past ADR 0509's
two-hour bound.
2026-10-23 08:28:28 +02:00
mesh-tools 7aacbc1620 Merge pull request 'R4e (mesh-sdk): the narrowed tree, the build modules' shared steps and layouts, ParseManifestForInputs, and the vendor directory's leaving' (#70) from feat/r4e into main 2026-10-22 22:13:10 +00:00
jschoubben 22b70c82b0 Stand in the artifact store in the toolchain test's shelf: R4e's build modules require it (ADR 0513), and the group's composed check judges them at the catalogue's group head
mesh/repo-check head 22b70c82b0ab merged into trunk cdfc63cf102b beside mesh-catalog#457@896bd883 +1 more — pass: THE CHANGE ALTERS ITS OWN CHECK (go/merge…
mesh/merge-gate head 22b70c82b0ab merged into trunk cdfc63cf102b — pass: group feat/r4e composed: every machine composes with the change as it did without …
mesh/delivery covered after its stop: a later walk that contains it was delivered: plan-1792707174627079192 (novox/mesh-sdk at 7aacbc16 into main, done, …
mesh/delivery-group group feat/r4e closed: a member was closed before the group merged
mesh/check failed: unit: ..test: node:internal/modules/esm/resolve:989 | 22b70c82b0ab on 7aacbc162014 | unit 1675 run, 0 kept, 3 skipped | integration…
2026-10-23 00:03:06 +02:00
jschoubben 6aa8e869a3 Name no paths in mesh-sdk's own manifest until R4e is delivered: today's controller judges R4e's group and refuses the word (issue 1758, ADR 0499 §7, ADR 0503 §4)
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 6aa8e869a3b6 merged into trunk cdfc63cf102b beside mesh-catalog#457@896bd883 +1 more — fail: THE CHANGE ALTERS ITS OWN CHECK (go/merge…
mesh/delivery-group group feat/r4e rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: ace: novox/mesh-catalog#457: modules/gitea/go.mod names mesh-sdk 104533e8c0c9, which is neither on mesh-sdk's trunk nor the group's…
2026-10-22 23:57:26 +02:00
jschoubben 104533e8c0 S2: read a manifest for a merge's named tree as the indexer does
mesh/merge-gate head 104533e8c0c9 merged into trunk cdfc63cf102b — fail: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus step; 9 wait(…
mesh/repo-check head 104533e8c0c9 merged into trunk cdfc63cf102b — pass: THE CHANGE ALTERS ITS OWN CHECK (go/merge-check.sh): main's version judged it; the…
mesh/delivery-group group feat/r4e rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: unit: modules/gitea.type-check: index.ts(263,29): error TS2345: Argument of type 'BranchProtection | null | undefined' is not assig…
catalogue.ParseManifestForInputs (issue 1751): a manifest falling back to a predefined key was narrowed by the indexer and not by a build module, two computations of one tree (novox/hq ADR 0499 clause 2).
2026-10-22 21:07:40 +02:00
jschoubben 259bfd0c2b S1: keep no package.json or go.mod outside the named tree in a narrowed build
A file outside the key changed what is built (novox/hq ADR 0499 clause 4); a build that needs one names it among its paths.
2026-10-22 21:07:22 +02:00
jschoubben 875c680d98 Merge remote-tracking branch 'origin/main' into feat/r4e-sdk
# Conflicts:
#	go/catalogue/state.go
#	go/catalogue/state_test.go
#	go/process/state.go
2026-10-22 20:37:49 +02:00
mesh-tools cdfc63cf10 Merge pull request 'Let a process watch, put, list and delete its own state's keys, as a tools bundle can (upgrade safety, issue 1840, ADR 0507 §8)' (#69) from feat/upgrade-safety into main 2026-10-22 18:34:24 +00:00
jschoubben d1af87a83a Leave a narrowed build only its named paths, manifest and locks, in one function for both build modules (issue 1758, ADR 0499 §4) 2026-10-22 18:31:07 +02:00
jschoubben e76a5a31a0 Read a manifest for its build's inputs without the published list of predefined keys (issue 1751, dev-r4e-builder's request)
The indexer and the builder hold no list, so claude-code, dunst and xterm were refused;
ParseManifestForInputs takes each fallback as its value declares it and judges no
config value, refusing everything else ParseManifest refuses.
2026-10-22 18:18:55 +02:00
jschoubben 8892a11768 Merge remote-tracking branch 'origin/main' into feat/upgrade-safety
mesh/merge-gate head 8892a1176800 merged into trunk 4fac7810a1e4 — pass: group feat/upgrade-safety composed: every machine composes with the change as it d…
mesh/repo-check head 8892a1176800 merged into trunk 4fac7810a1e4 beside mesh-catalog#450@86ba54b0 +1 more — pass: its merge-check.sh passed, each part in i…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792694058124013462 (novox/mesh-sdk at cdfc63cf into main, don…
mesh/delivery-group group feat/upgrade-safety merged: every member is merged
mesh/check failed: unit: ..test: node:internal/modules/esm/resolve:241 | 8892a1176800 on 4fac7810a1e4 | unit 3372 run, 0 kept, 7 skipped | integration…
2026-10-22 18:13:24 +02:00
mesh-tools 4fac7810a1 Merge pull request 'Keep a machine that does not resolve on the private network and in the names, so it is refused alone (issue 1833)' (#68) from fix/1833-one-machine-alone into main 2026-10-22 16:10:23 +00:00
jschoubben b07e741c41 Say in the controller seat's verbs what R4e removes and what the checker now does (R4e, dev-r4e-controller's request via the lead; ADR 0501 §5, ADR 0503 §3, ADR 0429 §18)
build, rebuild, forget-checks and delivery-plan stay listed, refused by name; replay
is a dry run only; delivery-check asks nothing; try is the checker's.
2026-10-22 18:07:37 +02:00
jschoubben 72a477eb1c Carry a candidate module's named paths, so the builder's fetch for a candidate names them (issue 1758, ADR 0499 §2, the builder lane's request) 2026-10-22 18:06:49 +02:00
jschoubben 8c3bfad86b Run the run step's isolation proof only where bubblewrap is, under its own tag (issue 1309, S19, the lead's decision)
The try's toolchain container has no bubblewrap; the checker runs the isolation tag on
the build nodes, and merge-check.sh still vets it.
2026-10-22 18:06:10 +02:00
jschoubben 9cb4d59093 Keep each artifact's named paths in the modules value, so the checker needs no manifest to see a change touch them (issue 1758, ADR 0499, lead decision L8) 2026-10-22 18:00:24 +02:00
jschoubben fd2ae83431 Name the error of a fetch whose lock waits for an unpublished package (issue 1587, ADR 0500 §6)
waits for: is the prefix the builder turns into a queued key with Waits; it is not
one of the errors held until the fingerprint changes.
2026-10-22 18:00:02 +02:00
jschoubben 4dd967e62f Let a module that builds no artifact have a done key that can be delivered (issue 1613, the lead's request for the builder lane)
A done key needs its manifest digest, no longer an artifact; Referenced answers ok
for it, since no artifact lacks a reference.
2026-10-22 17:59:55 +02:00
jschoubben ab2ca6e3a7 Push a done build's bundle or archive to the artifact store from the build module, in one shared client (issue 1587, ADR 0498 §1-§2)
buildstep.PushBlob uploads by digest unless the store holds it, waits out a store
refusing connections as mesh-build-image does, and names the store's own words;
BlobReference is the form the key and the controller check.
2026-10-22 17:59:14 +02:00
jschoubben a90f523edc Name the TypeScript package's own files as lib's paths, so a Go-only change moves no TypeScript module (issue 1758, S6, ADR 0499 §6) 2026-10-22 17:47:26 +02:00
jschoubben fc7c4e97e6 Stop committing the Go dependencies, which go.sum already pins (issue 1304, S8)
go/vendor's 810 files go and .gitignore names vendor/, as merge-check.sh already
said; every dependency resolves by go.sum and go mod verify fails by name.
2026-10-22 17:47:06 +02:00
jschoubben f7cd91e49d Accept a modules value with the new fingerprint alone, and say mesh/check is the required status (issue 1851, S7 and S16, ADR 0453 §6, ADR 0503 §3 §5)
FP and None stay readable for build-agent's path until R4g; the gate's status
comment and the checks verb now name mesh/check as what a main requires.
2026-10-22 17:46:56 +02:00
jschoubben 3b3cabd2b0 Prove a run step reaches no key, login, name or socket and leaves the caches unchanged (issue 1309, S19, ADR 0448 check) 2026-10-22 17:46:10 +02:00
jschoubben f8b8f3a666 issue 1840/S2: take a narrowed watch's current values as a set, since the bus hands them in the order of their last revision 2026-10-22 17:45:58 +02:00
jschoubben 1f4ff20488 Keep the shared caches unchanged by a candidate's step, under a throwaway writable layer (issue 1309, S18, ADR 0448)
A trunk build alone writes them; any other step writes into an overlay that dies with it.
2026-10-22 17:45:35 +02:00
jschoubben 39a2144cd6 Run a step's command shut off in bubblewrap, so a change's code reaches no key, login or network (issue 1309, S17, ADR 0448)
buildstep.Isolation and Isolated: its own namespaces with only a loopback, an
environment of exactly what the step is given, the system and toolchain read-only,
the workspace writable, and no home, key, bus login, runtime directory or name service.
2026-10-22 17:45:21 +02:00
jschoubben 2aab86c5cb Delete the copied list of the controller's readable states and the reads check built on it (issue 1790, S12, ADR 0495 §4-§5)
catalogue.ControllerMirrors and StateReadsNothingDeclares go, and the catalogue pass
no longer judges a read against the owner's manifest: an owner's announcement and its
own announcement test hold what it keeps.
2026-10-22 17:43:49 +02:00
jschoubben 33387ac3e5 Give every module the announcement test, on an exported test bus (issue 1790, S11, ADR 0495 §5)
announcetest.Check starts the module against a bus of the test's own and holds its
announcement equal to what its code declares; testbus leaves internal/ so a module's
own integration tests can start it.
2026-10-22 17:43:07 +02:00
jschoubben 57d2f31f05 Let a module's code announce the states it keeps, in Go and TypeScript (issue 1790, S10, ADR 0495 §2 §5)
process.Conn.Keeps, stdio.Keeps and the TypeScript keeps publish the announcement on
the module's own subject; afterwards a state the code did not announce is refused at
the call, so announcement and code cannot drift. The package goes to 0.1.15 for keeps.
2026-10-22 17:42:24 +02:00
jschoubben bb8b3bb43b Define how a module announces the states it keeps, so readers are granted from the owner's word (issue 1790, S9, ADR 0495 §2-§3)
announce.States on the module's own event subject mesh.mod.<m>.event.states, kept by
the controller in mesh-controller_states-announced by module.
2026-10-22 17:34:35 +02:00
jschoubben d13bc6ecc6 Let a key wait queued for a package version, so it is not failed before the package is published (issue 1587, S15, ADR 0500 §6)
waits names <name>@<version> on a queued key only; an older reader reads it as queued.
2026-10-22 17:31:42 +02:00
jschoubben ad1f6e6ac2 Name the build errors in one place, and which are not built again until the source changes (issue 1587, S14, ADR 0498 §2 §4 §5)
not stored, not pinned, version taken and refused kind (moved here from the builder);
FailedUntilChanged keeps an unpinned upstream or a taken version from rebuilding in a
loop, while a failed push is built again.
2026-10-22 17:31:40 +02:00
jschoubben 97895851f8 Check each done artifact's reference against its kind, and say when a key can be delivered (issue 1587, S13, ADR 0498 §1 §5 §6 §8)
A bundle or archive names its artifact-store blob, an image or upstream copy the
registry's reference to its digest (L1), a package name@version; Referenced names
the artifacts without one, since the controller delivers only from a key with all.
2026-10-22 17:31:40 +02:00
jschoubben d14a6d301c Prove on real git that a Go-only commit leaves mesh-sdk's package tree equal (issue 1758, S5, ADR 0499 §2 check) 2026-10-22 17:30:25 +02:00
jschoubben 7cf73ed76b Hand a build's fetch the named paths in a shared request, so no build module reads them from a manifest (issue 1758, ADR 0499 §4, ADR 0495 §1)
buildstep.FetchRequest is what the builder writes and the build modules read: the
repository, commit, ref, base and, for a module naming its paths, its directory and
those paths from the indexer's kept inputs.
2026-10-22 17:29:58 +02:00
jschoubben d32743499c Narrow the merge preview's tree of a module that names its paths (issue 1758, S4, ADR 0499 §2)
ModuleTrees reads each named path's object with one git ls-tree and answers their
named tree, so the preview gives the value the indexer gives; a named path absent at
the commit fails by name rather than pinning a source that does not exist.
2026-10-22 17:29:17 +02:00
jschoubben 8d65cb3016 Carry each named path's git object beside a module's other inputs (issue 1758, S3, ADR 0499 §2-§3)
Forge.Paths, PreviewModule.Paths, KeptInputs2.Paths and buildstep.ModuleTree.Paths,
each optional and read as absent by older readers; a kept or previewed tree that its
named paths do not compute to is refused, so a recompute gives the merge's value.
2026-10-22 17:27:58 +02:00
jschoubben 0c8d0f73fa Compute a module's narrowed tree in one function, so every caller gives the merge's value (issue 1758, S2, ADR 0499 §2)
delivery.NamedTree hashes the manifest digest with each named path's git object;
catalogue.Manifest.NamedPaths gives the union of the paths, only when every artifact
names some. The named: prefix keeps the value apart from a git tree id.
2026-10-22 17:27:02 +02:00
jschoubben dfc2c858d9 Share the launcher rule of a TypeScript bundle, so builder and controller name it alike (R4e, the lead's request for dev-r4e-controller)
The rule mesh-build-node and build-agent each keep their own copy of: x.js is
started by x.serve.mjs beside it; other languages get none (ADR 0193).
2026-10-22 17:25:21 +02:00
jschoubben 06fe1ee8b3 Read the manifest word paths, so a package names the files it is built from (issue 1758, S1, ADR 0499 §1)
A package or a non-Go bundle may name its build source; a Go program, an archive, an
image and an upstream artifact refuse the word by name, as do a path leaving the
module's directory, the whole directory and a pattern.
2026-10-22 17:25:02 +02:00
jschoubben bd7731f358 Count a machine that does not resolve and holds a place as on the network for its name and the mesh's sources too (issue 1833)
mesh/repo-check head bd7731f3588c merged into trunk 5652312f09d2 beside mesh-controller#437@28a278c1 — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head bd7731f3588c merged into trunk 5652312f09d2 — pass: group fix/1833-one-machine-alone composed: every machine composes with the change …
mesh/delivery delivered
mesh/delivery-group group fix/1833-one-machine-alone merged: every member is merged
mesh/check failed: unit: ..test: node:internal/modules/esm/resolve:241 | bd7731f3588c on 4fac7810a1e4 | unit 1634 run, 0 kept, 3 skipped | integration…
The tunnel kept such a machine while the names and the packet filter's sources from the mesh, read from their own
unchecked resolve, dropped it when that pass refused it too (a seat claimed twice): every other machine was sent the
hub as a peer without its name or its address. onTheNetwork keeps it by the same rule as network().
2026-10-22 15:26:48 +02:00
jschoubben c154bdedd3 Keep a machine that does not resolve on the private network at its place, so one machine's modules refuse only its own declaration (issue 1833)
A machine whose own set could not be resolved was left off the private network; when it was the hub, the network
could not be composed and every machine's declaration failed. Its placement (key and address) is its last good place
and does not change when its modules fail, so it stays on the network and only its own declaration is refused. A
refused machine with no place is left off, and a network that cannot be composed from what remains still fails.
2026-10-22 15:03:17 +02:00
jschoubben 5ea6c215a4 issue 1840/S1: let a process delete a key, as a tools bundle can 2026-10-22 14:36:24 +02:00
jschoubben 2b8ada5eb9 Merge remote-tracking branch 'origin/main' into feat/upgrade-safety 2026-10-22 14:24:43 +02:00
mesh-tools 5652312f09 Merge pull request 'Make the route proxy's bus helpers public in mesh-sdk (issue 1633, ADR 0480 §4)' (#67) from feat/r4d into main 2026-10-22 12:19:16 +00:00
jschoubben 076e1f7947 issue 1840/S2: let a process watch a state, current values first then each change, as a tools bundle can 2026-10-22 14:16:59 +02:00
jschoubben 26517ac00b issue 1840/S1: let a process put a key whole and list a state's keys, as a tools bundle can 2026-10-22 14:16:13 +02:00
jschoubben 637c33b7f8 issue 1840/S1c: name declared-packages among the controller's readable states, so a manifest may read it 2026-10-22 14:15:33 +02:00
jschoubben c61212eba7 Make the membership, its subject, stream, bare address and the pin public, so the route proxy leaves the controller's module (issue 1633)
mesh/delivery delivered
mesh/merge-gate head c61212eba77c merged into trunk c6e669f71e8e — pass: group feat/r4d composed: every machine composes with the change as it did without …
mesh/repo-check head c61212eba77c merged into trunk c6e669f71e8e — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery-group group feat/r4d merged: every member is merged
mesh/check failed: unit: ..test: node:internal/modules/esm/resolve:241 | c61212eba77c on 5652312f09d2 | unit 1629 run, 0 kept, 3 skipped | integration…
2026-10-22 09:51:01 +02:00
mesh-tools c6e669f71e Merge pull request 'The checker runs beside today's check: the SDK part (layouts, shared build-run parts, merge and module trees)' (#66) from feat/the-checker into main 2026-10-21 21:46:39 +00:00
jschoubben 580da61a91 Name plans-building among the controller's readable states, for the checker's read (issue 1687, ADR 0483 clause 2)
mesh/repo-check head 580da61a9107 merged into trunk 07a65927c362 beside mesh-catalog#423@12ff1cfb +1 more — pass: its merge-check.sh passed, each part in i…
mesh/merge-gate head 580da61a9107 merged into trunk 07a65927c362 — pass: group feat/the-checker composed: every machine composes with the change as it did …
mesh/delivery delivered
mesh/delivery-group group feat/the-checker merged: every member is merged
2026-10-21 22:44:52 +02:00
jschoubben 78364e318b Put the module trees of a merge into go/buildstep for both fetch steps, and let a preview name the conflicts of a head that does not merge (issues 1675, 1676, 1445)
mesh/repo-check head 78364e318bdc merged into trunk 07a65927c362 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery-group group feat/the-checker rejected: composed together: 1 problem(s) the change brings; the first: mesh-delivery-checker reads "mesh-controller…
mesh/merge-gate head 78364e318bdc merged into trunk 07a65927c362 — fail: group feat/the-checker composed: 1 problem(s) the change brings; the first: mesh-d…
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 21:21:48 +02:00
jschoubben afbe371003 Merge main (mesh-sdk #62, issue 1756) into feat/r4c5-sdk 2026-10-21 20:59:47 +02:00
jschoubben 9aaa5c80ac Accept the gate's recheck status mesh/repo-check among the owed contexts, so a pinned recheck is not refused (issue 1673) 2026-10-21 19:35:14 +02:00
mesh-tools 07a65927c3 Merge pull request 'Count an item's text as a placement of a config value (issue 1658)' (#62) from fix/1658-item-placement into main 2026-10-21 17:34:36 +00:00
jschoubben 92fdc8c86a Parse the beside catalogue once per test binary, so mesh-sdk's unit level fits its bound (issue 1756)
mesh/merge-gate head 92fdc8c86a17 merged into trunk 725590e06e75 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 92fdc8c86a17 merged into trunk 725590e06e75 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-21 19:26:30 +02:00
jschoubben 0730ef6092 Keep the unit level cheap: the merge's real-git tests start processes, so they are integration tests; the places tests wait 10 ms, not 50 (issue 895) 2026-10-21 19:26:02 +02:00
jschoubben 4495a16608 Put the merge of a head onto a trunk commit into go/buildstep, so both build modules' fetch steps call one function and none copies the controller's (issue 1674) 2026-10-21 19:22:33 +02:00
jschoubben b944d07158 Add the checker's owed bucket, the mesh/check status context and the previews layout, and correct the checker header: the checker decides the status, the bridge sets it (issue 1673) 2026-10-21 19:21:22 +02:00
jschoubben 6d0c2076a3 Give the checker's verdict a skipped count, a composition and the over-bound levels, so the line never hides skips or claims a composition not run (issue 1672) 2026-10-21 19:19:14 +02:00
jschoubben 4375ed8b2c Move the builder's shared build-run parts into go/delivery/buildrun, so the checker shares them and no module keeps a copy (issue 1671) 2026-10-21 19:18:11 +02:00
jschoubben c0bbff3b4b Merge remote-tracking branch 'origin/main' into fix/1658-item-placement
mesh/merge-gate head c0bbff3b4bb2 merged into trunk 725590e06e75 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head c0bbff3b4bb2 merged into trunk 725590e06e75 — fail: merge-check.sh (typescript) passed; its go/merge-check.sh failed: make them cheap …
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 19:17:26 +02:00
mesh-tools 725590e06e Merge pull request 'Give each node a declaration-files bucket of its own, so a grant names it and never needs a wildcard (issue 1694, ADR 0488)' (#64) from fix/1694-bucket-per-node into main 2026-10-21 15:39:08 +00:00
jschoubben 5792c3b959 Keep the unit level within its bound: the counter test counts 400 not 2000, the deadline test waits 50 ms not 1 s (issue 895)
mesh/merge-gate head 5792c3b959d5 merged into trunk d4deecdb380b — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 5792c3b959d5 merged into trunk d4deecdb380b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
Both still prove what they did: the counter test still collides every run and fails when an increment
writes without its revision; the deadline test fails when the read after a failed write takes a later
deadline than the ask's.
2026-10-21 16:57:31 +02:00
mesh-tools d4deecdb38 Merge pull request 'Add upload and download to node-files' verbs, so mesh-files may serve them (issue 1718)' (#63) from feat/files-through-the-store-files-verbs into main 2026-10-21 14:40:48 +00:00
jschoubben 216d2b4411 Check every mesh node's bucket and stream name, not only one (issue 1694, ADR 0488 §4)
mesh/merge-gate head 216d2b441132 merged into trunk d4deecdb380b — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 216d2b441132 merged into trunk d4deecdb380b — fail: merge-check.sh (typescript) passed; its go/merge-check.sh failed: make them cheap …
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 16:03:55 +02:00
jschoubben 86f9a1a5c5 Cite ADR 0488 in the per-node bucket's doc comments (issue 1694) 2026-10-21 15:48:22 +02:00
jschoubben 36d9eba51d Each node has an object bucket of its own for files by digest (issue 1694, ADR 0486) 2026-10-21 15:39:38 +02:00
jschoubben 90b20f5a3b Add upload and download to node-files' verbs, as ADR 0468 §9 decides, so mesh-files may serve them (issue 1718)
mesh/merge-gate head 90b20f5a3bda merged into trunk 85b5a2687e6a — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 90b20f5a3bda merged into trunk 85b5a2687e6a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-21 15:21:16 +02:00
mesh-tools 85b5a2687e Merge pull request 'Hold a module whose container names groups off a node that does not report container-groups (issues 1637, 1638, R4d)' (#60) from feat/r4d-groups-capability into main 2026-10-21 13:09:58 +00:00
jschoubben b5d8eb03ce A module whose container names groups needs the container-groups capability, so it is held, naming it, on an engine that does not read them (issue 1638, ADR 0480 clause 7)
mesh/merge-gate head b5d8eb03cea3 merged into trunk 863a981e014e — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head b5d8eb03cea3 merged into trunk 863a981e014e — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery covered: a later merge that contains it was delivered: novox/mesh-sdk@90b20f5a3bda (merged as d4deecdb into main, walk plan-179259365188251…
2026-10-21 15:03:32 +02:00
mesh-tools 863a981e01 Merge pull request 'Let a declared file travel by its sha256 digest instead of its content, so a node's declaration is no longer bounded by one bus message (issue 1694, ADR 0486 step 1)' (#59) from fix/1694-file-references into main 2026-10-21 12:43:57 +00:00
mesh-tools b68fc75589 Merge pull request 'Let a container name supplementary groups, from a number or a trusted config key, so n8n can join the media group without an image of its own (issue 1636, R4d)' (#58) from feat/r4d-groups into main 2026-10-21 12:30:02 +00:00
jschoubben 6c857e1c02 A bytes file of exactly 64 KiB stays inline: take its exact decoded length (issue 1694)
mesh/merge-gate head 6c857e1c025f merged into trunk b68fc755894c — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 6c857e1c025f merged into trunk b68fc755894c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivering: 0 machine step(s) passed
2026-10-21 14:23:48 +02:00
jschoubben f8a9420952 A declaration's file can be a reference by digest: layout, bucket and capability names, helpers (issues 1694, ADR 0486) 2026-10-21 14:12:19 +02:00
jschoubben f0f66d5b8d A container may add supplementary groups, and a group may be a config value filled at composition (issue 1636, ADR 0480 clause 7)
mesh/merge-gate head f0f66d5b8d42 merged into trunk 66e8fdaa62e2 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head f0f66d5b8d42 merged into trunk 66e8fdaa62e2 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-21 14:01:19 +02:00
mesh-tools 66e8fdaa62 Merge pull request 'Read an ended or pending assignment as a removed key in the provider helper, on main, so the modules pinning it can pin main (issues 1098, 1663)' (#57) from fix/1663-provider-standing into main 2026-10-21 11:40:14 +00:00
mesh-tools 88375c5786 Merge pull request 'Test that a node without the go or nodejs module refuses the modules needing a toolchain, against the catalogue as it now stands (issue 1573)' (#56) from fix/1573-toolchain-resolve into main 2026-10-21 11:27:27 +00:00
jschoubben 757509366e The provider helper reads an ended or pending assignment as a removed key (issue 1098)
mesh/merge-gate head 757509366e3c merged into trunk 88375c5786d9 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 757509366e3c merged into trunk 88375c5786d9 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
(carried to main for issue 1663)
2026-10-21 13:23:14 +02:00
jschoubben c8e35097e8 Count an item's text as a placement of a config value, so a key only an item says is not refused (issue 1658)
mesh/merge-gate waits for its group's composed check
mesh/repo-check head c8e35097e861 merged into trunk d4deecdb380b beside mesh-controller#420@858b06a9 +1 more — pass: its merge-check.sh passed, each part i…
mesh/delivery-group group fix/1656-xterm-config rejected: a member's own check failed
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 12:54:08 +02:00
jschoubben ddfa119720 Cite ADR 0478's accepted clause numbers
mesh/merge-gate head ddfa1197209b merged into trunk cb54756c3865 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head ddfa1197209b merged into trunk cb54756c3865 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-21 12:45:04 +02:00
jschoubben a86d607e48 Refresh the captured catalogue from mesh-catalog main, with the toolchain modules (issue 1573) 2026-10-21 12:45:01 +02:00
jschoubben 86ac7596c9 Test that a node without the go or nodejs module refuses the modules needing a toolchain, and with it composes (issue 1573) 2026-10-21 12:44:34 +02:00
mesh-tools cb54756c38 Merge pull request 'Give delivery.Module the toolchains its fingerprint was computed with, and '.' as a root module's path, so the builder builds a key only with its own toolchain (issue 1615)' (#55) from fix/1615-module-toolchains-root into main 2026-10-21 10:02:09 +00:00
mesh-tools 89dd57bed0 Give delivery.Module the toolchains its fingerprint was computed with, and '.' as a root module's path, so the builder builds a key only with its own toolchain (issue 1615)
mesh/merge-gate head 89dd57bed05d merged into trunk 465beb4ea5e3 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 89dd57bed05d merged into trunk 465beb4ea5e3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-21 09:37:14 +00:00
mesh-tools 465beb4ea5 Merge pull request 'Name a bucket's other users in the manifest, and sign and stream store links in one helper (issues 1591, 1592)' (#54) from feat/files-through-the-store into main 2026-10-21 09:36:45 +00:00
mesh-tools c9f978d330 Merge pull request 'Give delivery.Module a path and BuildRecord the artifacts a build made, so the builder can find a module and the view compare digests (issue 1588, ADR 0478)' (#53) from fix/1588-module-path-made into main 2026-10-21 09:27:12 +00:00
jschoubben efd3d224e1 Put takes the body's exact size and always sends it as Content-Length; refuse a link under one second (issue 1592)
mesh/merge-gate head efd3d224e131 merged into trunk c9f978d3308b — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head efd3d224e131 merged into trunk c9f978d3308b — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-21 11:22:18 +02:00
jschoubben 081dc616ff Add objectlink: one helper signs a short store link and streams a document over it with a size limit and a digest (issue 1592) 2026-10-21 11:18:15 +02:00
jschoubben b7b81e040f Carry shares on a requirement of s3-bucket to the provider's grant, so a bucket's other users are named in the manifest (issue 1591) 2026-10-21 11:15:49 +02:00
mesh-tools 66b052aeee Cite ADR 0478's clause on the module's directory by its number in the record's version 4 (issue 1588)
mesh/merge-gate head 66b052aeee69 merged into trunk 3558903ff9e7 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 66b052aeee69 merged into trunk 3558903ff9e7 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-21 09:08:15 +00:00
mesh-tools ea1b2ab395 Give delivery.Module a path and BuildRecord the artifacts a build made, so the builder can find a module and compare digests (issue 1588) 2026-10-21 09:04:43 +00:00
mesh-tools 3558903ff9 Merge pull request 'Read a lowercase name with digits as a name, not a secret (issue 1439)' (#52) from feat/1439-name-runs into main 2026-10-21 08:07:43 +00:00
jochen 7bf4a8053a Read a lowercase name with digits as a name, not a secret (issue 1439)
mesh/repo-check head 7bf4a8053a49 merged into trunk 36d6f7745c8d — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/merge-gate head 7bf4a8053a49 merged into trunk 36d6f7745c8d — pass: group feat/1439-name-runs composed: every machine composes with the change as it d…
mesh/delivery delivered
mesh/delivery-group group feat/1439-name-runs merged: every member is merged
mesh-config refused to propose mesh-issues' owners because CheckSecret took
modules/asus-zephyrus-g14, i3status-rust and powerlevel10k for random strings: a run with a
digit in it was judged whole. A run of lowercase letters and digits in short pieces between
-, _ and / is now judged piece by piece; a capital letter or a piece of 20 or more is judged
whole as before.
2026-10-21 09:55:04 +02:00
mesh-tools 36d6f7745c Merge pull request 'The build modules' shared steps: the forge key keeper, a call to a named machine, long steps on a key, the toolchain guard, fetching a commit, packing an artifact (design 47 R4c-3)' (#51) from feat/r4c3-sdk into main 2026-10-20 20:46:26 +00:00
mesh-tools 1572e50e0e Give a build step's commands only the environment its caller names (issue 1497)
mesh/merge-gate head 1572e50e0ef0 merged into trunk 4024909a2960 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 1572e50e0ef0 merged into trunk 4024909a2960 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-20 20:39:45 +00:00
mesh-tools 51e517f055 Restore the full tar stream of Pack's golden (issue 1479) 2026-10-20 20:39:23 +00:00
mesh-tools df9a2bba8c Add the build agent's uncompressed tar stream as Pack's golden (issue 1479) 2026-10-20 20:38:59 +00:00
mesh-tools 8406bb3a77 Compare Pack's tar stream with the build agent's, since gzip's bytes differ between Go versions (issue 1479) 2026-10-20 20:38:47 +00:00
mesh-tools 0b990a9c31 Name the step's deadline, not a bound never set, when an unbounded command is cut (issue 1497) 2026-10-20 20:09:32 +00:00
mesh-tools 9e296295c0 Let a long step's exec runner be bound by its context alone (issue 1497), part 8 of 8 2026-10-20 20:07:15 +00:00
mesh-tools 512472c02a Pack a build's output like the build agent and keep the steps state's shape (issues 1471, 1479), part 7 of 7 2026-10-20 20:06:48 +00:00
mesh-tools a776ca525a Test the fetch of one commit over SSH (issue 1474), part 6 of 7 2026-10-20 20:06:12 +00:00
mesh-tools 43b6361d09 Test the step runner and the toolchain guard (issues 1471, 1473), part 5 of 7 2026-10-20 20:05:49 +00:00
mesh-tools 927852fda8 Lay out a build module's long steps, guard its toolchain and fetch one commit over SSH (issues 1471, 1473, 1474), part 4 of 7 2026-10-20 20:05:21 +00:00
mesh-tools 4d4112a008 Call a module's tool or a seat verb on a named machine (issue 1470), part 3 of 6 2026-10-20 20:04:47 +00:00
mesh-tools a51dc144a1 Test the key keeper (issue 1469), part 2 of 6 2026-10-20 20:04:27 +00:00
mesh-tools c5705ed551 Move the key keeper into mesh-sdk and add the build modules' shared steps (issues 1469, 1470, 1471, 1473, 1474, 1479), part 1 of 4 2026-10-20 20:03:17 +00:00
mesh-tools 4024909a29 Merge pull request 'The forge messages carry manifests and lock hashes, a module keeps its new-form inputs, and the build modules' toolchain keys get a layout (design 47 R4c-1, issues 1442, 1443)' (#50) from feat/r4c1-sdk-layouts into main 2026-10-20 18:28:14 +00:00
mesh-tools 6f9a83646a Pin the json names of manifests, locks and inputs2 by the encoded bytes (issue 1442, review round 1)
mesh/merge-gate head 6f9a83646ab7 merged into trunk ab0c16ddad26 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 6f9a83646ab7 merged into trunk ab0c16ddad26 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
Go decodes keys case-insensitively, so a round trip could not see a dropped tag; the encoded bytes now name each field and an older message re-encodes to its own bytes.
2026-10-20 18:22:58 +00:00
mesh-tools 31c3b6cedc Carry manifests and lock hashes in the forge messages, keep a module's new-form inputs, and lay out the build modules' toolchain keys (design 47 R4c-1, issues 1442, 1443)
The indexer needs a module's manifest, lock hashes and toolchain to compute its new-form fingerprint, and has none of them (ADR 0465 clauses 1, 2, 5): the forge messages now carry the first two, a module keeps its inputs so it can be computed again when a toolchain is reported, and the build modules' toolchain keys have a layout and an agreement rule.
2026-10-20 18:20:15 +00:00
mesh-tools ab0c16ddad Merge pull request 'mesh-sdk takes the catalogue's remaining tests and the facts snapshot's layout, with each machine's grants and bindings (design 47 R4b, issues 1379, 1380)' (#49) from feat/r4b-sdk-users into main 2026-10-20 08:56:42 +00:00
mesh-tools c020c303ff Name the source of go/facts in its package doc (issue 1380, review round 1)
mesh/merge-gate head c020c303fff8 merged into trunk 14fd5cb68fd1 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head c020c303fff8 merged into trunk 14fd5cb68fd1 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-20 08:31:47 +00:00
mesh-tools 527840af9b Give go/facts each machine's grants and bindings, from facts format 2 (issue 1380)
Machine gains Grants (provision, consumer node, consumer module, local name; no sealed copy) and Bindings (consumer module, provision, provider node, provider module), both omitted when empty. Format moves 1 to 2, because a reader of 1 must refuse a snapshot that says what it cannot read (GrantsFrom names the number); MediaType stays, a reader of 2 reads a snapshot of 1 as having none. Scrubber.Grant and Scrubber.Binding put the machine names under pseudonyms as Pins' are. go/compose's Input.Issued and Node.Bound docs name them as where an assigner fills those from; no logic changes there. Tests: all new ones failed first (the types did not exist); the copied ones are exempt. Unit level 1.19 s (bound 2 s).
2026-10-20 08:25:46 +00:00
mesh-tools bd5feec19b Copy mesh-controller's facts snapshot layout into go/facts unchanged (issue 1380)
A pure copy of internal/facts (facts.go, scrub.go, facts_test.go) from novox/mesh-controller 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1; standard library only. The next commit adds the grants and bindings.
2026-10-20 08:23:51 +00:00
mesh-tools d1886bbd7b Move the catalogue tests that read mesh-catalog into go/catalogue, and lay the captured copy out once per process (issue 1379)
Imports rewritten to mesh-sdk's (beside, catalogue, overlay). Left in mesh-controller, which holds their modules: TestTheBuilderRequiresTheRegistryTheNpmSeatDelivers (foundation_manifests_test.go), TestGenesisAssignsTheBuildAgentWithNothingProvidingTheRegistry (genesis_registry_test.go, whole file), TestRouteProxyKeepsItsPublicAccountDirectory (public_issuer_test.go), TestEveryManifestInUseClaimsASeatTheMeshDefines (seats_test.go); with controller_is_a_process_test.go and genesis_image_test.go. moduleTree reads mesh-catalog only: mesh-sdk has no modules directory of its own. beside.Dir lays a repository's captured copy out once per process (a mutex and a map, os.MkdirTemp), new test TestTheCapturedCopyIsLaidOutOnce failed first (two directories). The captured copy is refreshed from the mains: mesh-catalog bb0fe93b, mesh-host cdcba9ca, only what these tests read; CAPTURED says how to move it. Unit level 1.16 s (bound 2 s).
2026-10-20 08:22:45 +00:00
mesh-tools 8a4381d0c6 Copy mesh-controller's catalogue tests that read mesh-catalog, and internal/beside, from 6c2a8d62 unchanged (issue 1379)
A pure copy of 30 test files and internal/beside/beside.go; they do not compile until the next commit rewrites their imports. Source: novox/mesh-controller 6c2a8d62100cb7d491f8eb9a623e1eeea5ec0de1, internal/catalogue and internal/beside.
2026-10-20 08:21:38 +00:00
mesh-tools 781ba6aeb4 Capture the catalogue modules' remaining files directories and templates at mesh-catalog bb0fe93b for the moved catalogue tests (issue 1379)
Part 4 of 4 of go/testdata/beside.
2026-10-20 08:20:05 +00:00
mesh-tools 013d6231ed Capture the catalogue modules' files directories (adwaita to mailu) at mesh-catalog bb0fe93b for the moved catalogue tests (issue 1379)
Part 3 of 4 of go/testdata/beside.
2026-10-20 08:19:14 +00:00
mesh-tools a706310d34 Capture the catalogue's module.json files at mesh-catalog bb0fe93b for the moved catalogue tests (issue 1379)
Part 2 of 4 of go/testdata/beside; each kept as module.json.captured.
2026-10-20 08:18:33 +00:00
mesh-tools e58761afce Capture the catalogue's module.json files and the placement guard at today's mains for the moved catalogue tests (issue 1379)
mesh-catalog bb0fe93b, mesh-host cdcba9ca; part 1 of 3 of go/testdata/beside (module.json as module.json.captured).
2026-10-20 08:17:05 +00:00
mesh-tools 14fd5cb68f Merge pull request 'mesh-sdk takes the catalogue, the validator and a store-free composition, the fingerprint's new form and the delivery parts' key layouts (design 47 R4a, issues 1353-1364, 1298)' (#48) from feat/r4a-sdk-first into main 2026-10-20 07:15:54 +00:00
mesh-tools 075bf1e1fe Restore the line break the previous patch lost in rowsFor (formatting only)
mesh/repo-check head 075bf1e1fe8f merged into trunk 88581d0acaef — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/merge-gate head 075bf1e1fe8f merged into trunk 88581d0acaef — pass: group feat/r4a-sdk-first composed: every machine composes with the change as it di…
mesh/delivery delivered
mesh/delivery-group group feat/r4a-sdk-first merged: every member is merged
2026-10-20 07:06:40 +00:00
mesh-tools cf965b93c2 Test the grant of a consumer on the provider's own node (review round 2, item 2); it failed before the previous commit 2026-10-20 07:06:26 +00:00
mesh-tools 8f0150bba2 Grant a consumer on the provider's own node too: a need answered on the same machine has its secret made before the grants are read (review round 2, item 2). The test failed first; the suite is 1259 tests in 1.05 s 2026-10-20 07:06:17 +00:00
mesh-tools 0729556ba3 Read the rest of the mesh and who is on the network with the current manifests: a node's kept build applies only to its own resolution, as the controller keeps a recorded module after reading both (review round 2, item 1). The test asserting the opposite failed first 2026-10-20 07:06:09 +00:00
mesh-tools 28d4b9eb0d Return the lines the composition found on the way (a node not counted as on the network) in Rendered.Said and Declared.Said (review round 1, finding 6). The test failed first; the suite is 1258 tests in 1.05 s 2026-10-20 07:03:36 +00:00
mesh-tools 3eadff23eb Take the suffix of internal names from the Input and never the environment: Input.InternalSuffix and a local internal-name helper, so the package doc is true (review round 1, finding 5). The test failed first; the suite is 1257 tests in 1.04 s 2026-10-20 07:02:58 +00:00
mesh-tools 892a3955b7 Place a given secret-family member whose value the Input lacks with a stand-in and name it foreseen, not drop it (review round 1, finding 4). The test failed first; the suite is 1256 tests in 1.05 s 2026-10-20 07:01:56 +00:00
mesh-tools 0864ae5e47 Compose a licence key the Input does not carry with a stand-in named foreseen; keep empty only where Keys is given and lacks the holder (review round 1, finding 3). The test failed first 2026-10-20 07:01:50 +00:00
mesh-tools 66da19827c Grant a provider every consumer the same send would make a credential for, with a stand-in named foreseen, so the identity bound is judged for a consumer a change adds; rows in the controller's order (review round 1, finding 2). Three tests failed first; the suite is 1254 tests in 1.05 s 2026-10-20 07:00:44 +00:00
mesh-tools 12319ce9dd Keep a recorded module at the build a node runs, per node: Node.Kept and Node.KeptBuilds overlay that node's own resolution and the commit it carries, never another node's (review round 1, finding 1; mirrors plan.go keepRecorded and carriedOn). Both tests failed first; the suite is 1251 tests in 1.05 s 2026-10-20 06:59:38 +00:00
mesh-tools 57d852e7da Test the whole-node composition: Compose accepted by the validator, refused manifests as problems, an unresolvable set as an error, determinism, foreseen outputs, the envelope's order and builds, adoption, the overflow refusal (issue 1298) 2026-10-20 06:49:28 +00:00
mesh-tools 6be65acb57 Compose a node's declaration over an Input and validate it with the node-engine's own validator: Compose and ComposeIn (issue 1298). Ported from mesh-control 6c2a8d62 plan.go declarationWith, carriedOn, wireBuilds, sendable.go Body and adoptionFor, facts.go composedAndValidated, in the Foreseeing mode: it allocates, makes, keeps and writes nothing. Named Compose, not Node, because the Input already has a type Node. A pull request's check refuses what Declared.Refusals() returns beside the validator's problems. 2026-10-20 06:47:27 +00:00
mesh-tools c1ba7bc1ae Give Input what a declaration's envelope reads: the order it is sent under, whether the engine reads modules, the builds last sent and the builds the mesh holds (issue 1298) 2026-10-20 06:46:54 +00:00
mesh-tools f7cf488ee7 Say in the Go README which packages release 4a adds (issues 1353 to 1364) 2026-10-20 06:46:22 +00:00
mesh-tools bd9ff3797d Test the credential half of the rendering over an Input (issue 1364). Fixtures from mesh-control 6c2a8d62 outside_not_given_test.go (the assistant's two secrets) and the facts_test.go provider and consumer; the refusals checked word for word against plan.go and secrets.go. Written after the code, each rule broken in turn and a test seen to fail. The bus user list is an Input value, so its test is that it is carried (see the commit of the code). Unit level 1.04 s of 2 s. 2026-10-20 06:42:55 +00:00
mesh-tools b7e1c22512 Compose the credential half of the rendering over an Input: grants, own secrets, core boxes, the certificate, the bus's user list (issue 1364). Ported from mesh-control 6c2a8d62 plan.go renderingFor, grantsFor, certificateFor and core_box.go in their Reading and Foreseeing modes: nothing is made, kept or written; a stand-in is named foreseen, a core box to keep is returned, a consumer left out of a grant and a credential bound elsewhere are returned naming them. The bus's user list is an Input value the controller composes: the broker's account composer (internal/broker, about 900 lines that import the lease and the bus client) is not ported, see the report of this branch. 2026-10-20 06:41:58 +00:00
mesh-tools aecb52c1af Give Input what the credential half of the rendering reads: the credentials made for consumers, own secrets held, kept core boxes, the bus's user list and memberships, serving keys and certificates (issue 1364) 2026-10-20 06:41:00 +00:00
mesh-tools 7823403869 Test the place half of the rendering over an Input (issue 1363). Fixtures from mesh-control 6c2a8d62 sendable_integration_test.go (the store, the given port) and local_network_test.go (the networks); written after the code, each rule broken in turn and a test seen to fail. Unit level 1.05 s of 2 s. 2026-10-20 06:35:09 +00:00
mesh-tools d3436abd82 Compose the place half of a rendering over an Input: ports never allocated, seats, networks, names, zones, accounts, the operator's export (issue 1363). Ported from mesh-control 6c2a8d62 plan.go renderingFor, network.go, holdings.go, local_network.go in their Reading and Foreseeing modes. A port the Input holds none for is composed as the manifest says and named foreseen. 2026-10-20 06:34:19 +00:00
mesh-tools 084aa4ae75 Give Input what the place half of the rendering reads: taken modules, outward links, attached networks, the engine's contract, built repositories and the operator's export (issue 1363) 2026-10-20 06:33:14 +00:00
mesh-tools fa08c5c308 Test one node's resolution over an Input (issue 1362). Ported from mesh-control 6c2a8d62 bindings_test.go and bindings_integration_test.go (store-free part); the credential cases are new, from the rules in plan.go. Written after the code, checked by breaking each rule in turn and seeing a test fail. Unit level 1.05 s of 2 s. 2026-10-20 06:28:49 +00:00
mesh-tools 7ba9bcd873 Resolve one node over an Input, its credentials as stand-ins and its settings (issue 1362). Ported from mesh-control 6c2a8d62 planFor. Nothing is made: a pair credential is how it stands (held, to make, only a person's to give, refused), never its value; keepRecorded is left to the caller, who puts the kept manifest in the Catalogue. The bus credential nobody issued and a given own secret under an old key are refused where the controller refuses them, in the rendering (issue 1364). 2026-10-20 06:28:05 +00:00
mesh-tools 80fa1a0551 Test the private network and the rest of the mesh over an Input (issues 1360, 1361). Tests ported from mesh-control 6c2a8d62 network_test.go and seat_pass_test.go with their values; written after the code, checked by breaking each rule in turn and seeing a test fail. Store-backed ones are rebuilt as Inputs; overlayPlace's own refusal of an endpoint off the tunnel's port, namesInTheMesh and the resolver's roster test are the controller's or the rendering's (1363). 2026-10-20 06:24:18 +00:00
mesh-tools cb1b7a6f5d Compose the private network and the rest of the mesh over a plain Input: Generators and TheRestOfTheMesh (issues 1360, 1361). Ported from mesh-control 6c2a8d62 network.go, plan.go, seat_pass.go. Store-backed tests (aMesh) are rebuilt as Inputs from the same values; the writes they exercised (a placement through overlay place, a seat passed through the store) are Input values or a returned Pass, and the controller-only tests of a failed read or write have no counterpart here. Unit level 1.04 s of 2 s. 2026-10-20 06:22:58 +00:00
mesh-tools 3cd2bd8104 Point the copied overlay generator at the SDK's catalogue, and name its source (issue 1360) 2026-10-20 06:13:08 +00:00
mesh-tools 9e2dc045b6 Copy mesh-controller's overlay generator into mesh-sdk, from mesh-control 6c2a8d62 (issue 1360) 2026-10-20 06:12:52 +00:00
mesh-tools a79f737fc7 Make the copied catalogue stand in mesh-sdk: the SDK's validator, user-namespaces, two tests that read the controller's module.json left out (issue 1354). Unit level 1.05 s of 2 s with the package in (1162 tests, 3 skipped). Diff against the source: git diff --no-index <mesh-control 6c2a8d62 internal/catalogue> go/catalogue shows only doc.go, user_namespaces_test.go, three import lines, KnownCapabilities, and the two deleted test functions 2026-10-20 06:12:25 +00:00
mesh-tools 16ce926406 Copy mesh-controller's catalogue into mesh-sdk, part 2: its 149 self-contained test files, from mesh-control 6c2a8d62 (issue 1354) 2026-10-20 06:10:23 +00:00
mesh-tools 5e4fcc6db7 Copy mesh-controller's catalogue into mesh-sdk, part 1: the package and its testdata, from mesh-control 6c2a8d62 (issue 1354) 2026-10-20 06:05:52 +00:00
mesh-tools aa70feb9b5 Test a 21 KB manifest round trip byte for byte, on mailu's manifest copied from mesh-catalog (issue 1357) 2026-10-20 06:02:43 +00:00
mesh-tools be506bb502 Copy the node-engine's validator into mesh-sdk, on the SDK's parser (issue 1353) 2026-10-20 06:02:28 +00:00
mesh-tools 1d6a0e892e Name the source commit in the declaration package's doc (issue 1353) 2026-10-20 06:02:17 +00:00
mesh-tools 88581d0aca Merge pull request 'Give each asking of a merge ask its own id, and add the have phase removed (issues 1370, 1369)' (#47) from fix/r3e-before-the-gate into main 2026-10-20 06:02:01 +00:00
mesh-tools e5e3e62f28 Copy the node-engine's declaration parser into mesh-sdk, from mesh-host main (issue 1353) 2026-10-20 06:01:48 +00:00
mesh-tools 3387abf50e Name the builder's and the checker's layouts in the delivery package doc (issues 1358, 1359) 2026-10-20 01:48:41 +00:00
mesh-tools 33b87b794c Lay out the checker's verdicts, keyed by group and heads, and the kept verdicts (issue 1359) 2026-10-20 01:48:29 +00:00
mesh-tools 21cccc6a8d Lay out the builder's build keys, builds.<module>.<fingerprint>, beside the controller's (issue 1358) 2026-10-20 01:46:16 +00:00
mesh-tools d4967f186e Test the indexer's manifests, the new fields of modules and candidates, and the fpset (issue 1357) 2026-10-20 01:44:22 +00:00
mesh-tools 9b4c2ba591 Give a module and a candidate the new-form fingerprint, the manifest digest and the fpset, in version 1 (issue 1357) 2026-10-20 01:43:39 +00:00
mesh-tools 1bbf75ae19 Add the layout of the indexer's manifests bucket: the key by digest and its value (issue 1357) 2026-10-20 01:43:17 +00:00
mesh-tools 605eca2c0a Compute the source fingerprint's new form beside src1, and its key part (issue 1356) 2026-10-20 01:40:35 +00:00
mesh-tools 0cdbcc6019 Say which mesh-sdk version a program was built with, from the build info (issue 1355) 2026-10-20 01:38:41 +00:00
mesh-tools ba684e5497 Give each asking of a merge ask its own id, and add the have phase removed (issues 1370, 1369)
mesh/merge-gate head ba684e54976b merged into trunk 5786ba190c69 — pass: group fix/r3e-before-the-gate composed: every machine composes with the change as …
mesh/repo-check head ba684e54976b merged into trunk 5786ba190c69 beside mesh-host#121@150fadfe — pass: its merge-check.sh passed, each part in its toolchai…
mesh/delivery delivered
mesh/delivery-group group fix/r3e-before-the-gate merged: every member is merged
The router drops an ask under an id it keeps as a redelivery, so a gate that writes the same group's ask again at unchanged heads was never heard. Ask gains Asking; AskingID gives the first asking the old id and later ones their own. PhaseRemoved lets the node-engine say a module left its node.
2026-10-20 01:18:49 +00:00
mesh-tools 5786ba190c Merge pull request 'Count a build outcome as the controller writes it: no state, no failure (issue 1281)' (#46) from fix/1281-outcome-is-a-build into main 2026-10-19 23:02:01 +00:00
mesh-tools 839238fec9 Say in the fixture's comment how it was made; test an answered state without inputs (issue 1281)
mesh/repo-check head 839238fec9b9 merged into trunk 6079ddfe84ee — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/merge-gate head 839238fec9b9 merged into trunk 6079ddfe84ee — pass: group fix/1281-outcome-is-a-build composed: every machine composes with the change…
mesh/delivery delivered
mesh/delivery-group group fix/1281-outcome-is-a-build merged: every member is merged
2026-10-19 22:47:27 +00:00
mesh-tools 58373d65b4 Count a build record without a state as the outcome it is (issue 1281): the controller never stores state built 2026-10-19 22:39:29 +00:00
mesh-tools 6079ddfe84 Merge pull request 'Give the gate's keys what the forge needs: approved heads, owed targets, escaped names (design 47 R3d, ADR 0450)' (#45) from feat/r3d-sdk-and-controller into main 2026-10-19 22:33:12 +00:00
mesh-tools 2f1454699a Let an owed act carry a status target, a commit without a pull request and a numbered comment (issue 1322)
mesh/repo-check head 2f1454699a49 merged into trunk 1536dfa74592 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/merge-gate head 2f1454699a49 merged into trunk 1536dfa74592 — pass: group feat/r3d-sdk-and-controller composed: every machine composes with the change…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792449194411267197 (novox/mesh-sdk at 6079ddfe into main, don…
mesh/delivery-group group feat/r3d-sdk-and-controller merged: every member is merged
2026-10-19 21:32:55 +00:00
mesh-tools 44d15fa0be Carry the Approve's heads of every group member in a merge so the bridge can hand them to the forge (issue 1321) 2026-10-19 21:31:50 +00:00
mesh-tools a6dd698114 Escape group and repository names in every delivery key so a dotted branch keys (issue 1323) 2026-10-19 21:31:09 +00:00
mesh-tools 1536dfa745 Merge pull request 'Add SameRepository and BuildRecord.IsTrunkBuild to go/delivery (issue 1281)' (#40) from fix/1281-trunk-builds-by-repository into main 2026-10-19 20:58:08 +00:00
jschoubben 84a7643723 Add the repository-identity and trunk-build tests both delivery readers need (issue 1281)
mesh/repo-check head 84a764372337 merged into trunk 8360a3bba2b4 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/merge-gate head 84a764372337 merged into trunk 8360a3bba2b4 — pass: group fix/1281-trunk-builds-by-repository composed: every machine composes with th…
mesh/delivery delivered
mesh/delivery-group group fix/1281-trunk-builds-by-repository merged: every member is merged
A build record names its repository by clone URL and a plan-asked build carries no ref; the indexer and view compared both naively.
2026-10-19 22:39:25 +02:00
mesh-tools 8360a3bba2 Merge pull request 'Add the forge kind open: every open pull request into trunk (issue 1279, ADR 0443)' (#42) from fix/1279-consumers-from-now into main 2026-10-19 20:22:45 +00:00
mesh-tools 2db8ef5079 Merge pull request 'Check the Go binding without a vendor directory (issue 1304, step 1 of 2)' (#44) from fix/1304-check-without-vendor into main 2026-10-19 20:15:37 +00:00
jschoubben ba3aa8f907 Check mesh-sdk's Go binding without a vendor directory: resolve by go.sum (issue 1304, step 1 of 2)
mesh/merge-gate out of date: judged against trunk 91763dd70ee4, the trunk is now 2db8ef5079f5; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 91763dd70ee4, the trunk is now 2db8ef5079f5; judged again on a recheck or when it is merged
mesh/delivery delivered
2026-10-19 22:04:53 +02:00
mesh-tools 91763dd70e Merge pull request 'Stop the stdio channel holding its lock across a pipe write (issue 1286)' (#41) from fix/1286-refused-subscribe-deadlock into main 2026-10-19 19:55:53 +00:00
jschoubben eaec209a37 Stop the stdio channel holding its lock across a pipe write, so a refused subscribe cannot deadlock it (issue 1286)
mesh/merge-gate head eaec209a371b merged into trunk 9ebfb87b5d0c — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head eaec209a371b merged into trunk 9ebfb87b5d0c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-19 21:51:13 +02:00
jschoubben 4580d80a32 Add the forge kind open: every open pull request into trunk, so the indexer can drop the rest (issue 1279, ADR 0443)
mesh/repo-check head 4580d80a32d9 merged into trunk 2db8ef5079f5 beside mesh-controller#400@a0c971ce — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head 4580d80a32d9 merged into trunk 2db8ef5079f5 — pass: group fix/1279-consumers-from-now composed: every machine composes with the change…
mesh/delivery delivered
mesh/delivery-group group fix/1279-consumers-from-now merged: every member is merged
2026-10-19 21:50:03 +02:00
mesh-tools 9ebfb87b5d Merge pull request 'Say a build's directory and a pull request's module directories in the SDK (issue 1233)' (#39) from fix/1233-path-and-dirs into main 2026-10-19 13:17:09 +00:00
jschoubben 9ade199740 Give the build fixture the path the controller writes for build-agent (issue 1233)
mesh/merge-gate head 9ade199740e3 merged into trunk d79264611071 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 9ade199740e3 merged into trunk d79264611071 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-19 14:42:04 +02:00
jschoubben 0596f44414 Say a build's directory and a pull request's module directories in the SDK (issue 1233) 2026-10-19 14:40:19 +02:00
mesh-tools d792646110 Merge pull request 'Give the build record's layout to readers outside the controller (issue 1223)' (#38) from fix/1223-build-record-layout into main 2026-10-19 12:07:24 +00:00
jschoubben 732238cb6a Cite the controller's lines rightly, and say the newest key holds an id (issue 1223, review round 1)
mesh/merge-gate head 732238cb6a72 merged into trunk aa68b67389c9 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 732238cb6a72 merged into trunk aa68b67389c9 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-19 13:46:36 +02:00
jschoubben 845c8b0e56 Give the build record's layout to readers outside the controller (issue 1223) 2026-10-19 13:44:09 +02:00
mesh-tools aa68b67389 Merge pull request 'Delivery's release 3 keys and the source fingerprint, in one place the parts read (design 47 R3a: issues 1200-1203)' (#37) from feat/delivery-source-keys into main 2026-10-19 09:10:11 +00:00
jschoubben a39661aa36 Prove the source fingerprint against three builds the live builder recorded (issue 1203)
mesh/merge-gate head a39661aa36f3 merged into trunk 34392e74916c — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head a39661aa36f3 merged into trunk 34392e74916c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-19 11:03:46 +02:00
jschoubben 662fb0a57c Map every byte the router's id refuses in an ask id, so a group with = gives a usable ask (issue 1201) 2026-10-19 10:37:03 +02:00
jschoubben 0bc5659076 Make every ask id end in a digest of its key so two keys never share an id (issue 1201) 2026-10-19 10:34:59 +02:00
jschoubben c23833d3e5 Move the source fingerprint into one function the builder and the indexer both call (issue 1203) 2026-10-19 10:34:19 +02:00
jschoubben 0ae600dc97 Give a module one subject and one layout to say a condition to the operator (issue 1202) 2026-10-19 10:33:53 +02:00
jschoubben 15af148520 Give the gate's merges, asks and owed acts and the teller's answers one layout the parts read (issue 1201) 2026-10-19 10:33:17 +02:00
jschoubben 6d620f24b7 Give the source revision, candidates, modules and forge messages one layout the parts read (issue 1200) 2026-10-19 10:31:59 +02:00
mesh-tools 34392e7491 Merge pull request 'A module's process connects to the bus on an account of its own (ADR 0436 clause 4, issues 1171, 1187-1191)' (#36) from feat/mail-sender-sdk into main 2026-10-19 06:20:50 +00:00
jochen cfa20f05ad Reconnect without limit, as the tool runner does, so a process outlives a bus restart (issue 1192, review round 1 finding 5)
mesh/merge-gate head cfa20f05ad6d merged into trunk c8bd1370828a — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head cfa20f05ad6d merged into trunk c8bd1370828a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-19 04:52:10 +02:00
jochen f1438dff6a Prove Conn offers no Serve by reflection, and build its AC3 test account through ReadAccount (issue 1171, review round 1 findings 3 and 4) 2026-10-19 04:51:14 +02:00
jochen 1ffbf9ea5b Make the Term test prove no redelivery instead of passing on a 30s AckWait it never waits out (issue 1190, review round 1 finding 2) 2026-10-19 04:50:07 +02:00
jochen e05e7130ae Catch a denied publish at once instead of waiting it out, so an ungranted bucket's refusal names it (issue 1188, review round 1 finding 1) 2026-10-19 04:48:58 +02:00
jochen 80d3975762 Let a process call a tool or a seat verb on its own account, with the tool runner's caller fallback (issue 1191) 2026-10-19 04:41:08 +02:00
jochen f73ec7457a Let a process consume its module's work queue, acknowledging, delaying or terminating each message (issue 1190) 2026-10-19 04:38:45 +02:00
jochen 94702bcd79 Let a process read the router's record of its module's own ask (issue 1189) 2026-10-19 04:35:33 +02:00
jochen de2d944c2a Let a process read and change its module's state by compare-and-set, and emit with a chosen message id (issue 1188) 2026-10-19 04:34:04 +02:00
jochen 0796341c8d Let an SDK Emit choose its event's message id, so a repeated publish is kept to one (issue 1187) 2026-10-19 04:26:54 +02:00
jochen 96155dea23 Give mesh-sdk a process's own pinned bus connection, reading its delivered account (issue 1171) 2026-10-19 04:26:01 +02:00
jochen 1361c1b456 Vendor the bus library and give integration tests a bus of their own (issue 1171) 2026-10-19 03:43:19 +02:00
mesh-tools c8bd137082 Merge pull request 'Give the SDK's Go check an integration level and vendored dependencies, before a test needs them (issue 1175)' (#35) from fix/1175-sdk-integration-level into main 2026-10-19 00:06:01 +00:00
jochen 9908faaa02 Give the SDK's Go check an integration level and vendored dependencies, before a test needs them (issue 1175)
mesh/merge-gate head 9908faaa0295 merged into trunk 238440d6f8c4 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 9908faaa0295 merged into trunk 238440d6f8c4 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh, go/merge-check.sh): main's versio…
mesh/delivery delivered
2026-10-19 02:00:12 +02:00
mesh-tools 238440d6f8 Merge pull request 'Give the watchman's stuck keys a layout the controller reads (issue 1153)' (#34) from feat/delivery-stuck into main 2026-10-18 18:45:57 +00:00
jschoubben 953446cc8f Give the watchman's stuck keys a layout the controller reads (issue 1153)
mesh/merge-gate out of date: judged against trunk f692fe7da89d, the trunk is now 238440d6f8c4; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk f692fe7da89d, the trunk is now 238440d6f8c4; judged again on a recheck or when it is merged
mesh/delivery delivered
ADR 0433 §2, §3: the watchman writes what stopped moving and the controller's
self-check raises it, so the key and value live in the SDK (ADR 0429 §2).
2026-10-18 20:11:04 +02:00
mesh-tools f692fe7da8 Merge pull request 'Give delivery's keys and values one home the node-engine and the parts read (issue 1131)' (#33) from feat/delivery-keys into main 2026-10-18 17:06:04 +00:00
jschoubben 2b420603fe Give delivery's keys and values one home the node-engine and the parts read (issue 1131)
mesh/merge-gate head 2b420603fe83 merged into trunk f70a99dbf485 — pass, with a note: builds mesh-sdk (+41 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 2b420603fe83 merged into trunk f70a99dbf485 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
2026-10-18 18:49:24 +02:00
mesh-tools f70a99dbf4 Merge pull request 'Feature providers-after-switch-on, step 1: the provider helper's fixes and the moved-provider harness (issues 945, 825, 849)' (#31) from feat/providers-after-switch-on into main 2026-10-16 19:13:11 +00:00
fix-sdk-pas a4e7f480b3 Apply the plan's credential form only to an offer that declares a credential (issue 825)
mesh/merge-gate head a4e7f480b3b0 merged into trunk 219bedbdd2b3 beside mesh-catalog#337@d2992f81 +1 more — pass, with a note: builds mesh-sdk (+41 depende…
mesh/repo-check head a4e7f480b3b0 merged into trunk 219bedbdd2b3 beside mesh-catalog#337@d2992f81 +1 more — pass: its merge-check.sh passed, each part in i…
mesh/delivery delivered
mesh/delivery-group group feat/providers-after-switch-on merged: every member is merged
2026-10-16 19:44:26 +02:00
fix-sdk-pas d7cac79585 Carry the grant-file and keycloak-shape moved-path tests into harness (issue 849) 2026-10-16 19:37:02 +02:00
fix-sdk-pas 377fd6d67d Move the moved path's shared driving into harness.Moved, so postgres and keycloak keep only what differs (issue 849) 2026-10-16 19:34:02 +02:00
fix-sdk-pas bb7c82cac6 Move the moved-provider harness and its retirement into go/provider/harness (issue 849) 2026-10-16 19:29:27 +02:00
fix-sdk-pas 0db956dd83 Skip a bad assignment, honour the plan's credential form, delete only what was read, redact every credential form (issue 825) 2026-10-16 19:28:25 +02:00
fix-sdk-pas 40f3658557 Name the vault's credential under vault-name so the state guard accepts the bound entry (issue 945) 2026-10-16 19:26:58 +02:00
mesh-tools 219bedbdd2 Merge pull request 'Feature 57 (config and provisioning): release mesh-sdk' (#30) from release/57-config-and-provisioning into main 2026-10-16 09:32:31 +00:00
mesh-tools 1bc5719b8b Feature 57 (config and provisioning): release mesh-sdk, the reviewed release branch on top of main
mesh/merge-gate head 1bc5719b8b85 merged into trunk a2d2af00dc5d beside mesh-host#116@bff1ac14 — pass, with a note: builds mesh-sdk (+40 dependent(s)) → ac…
mesh/repo-check head 1bc5719b8b85 merged into trunk a2d2af00dc5d beside mesh-host#116@bff1ac14 — pass: its merge-check.sh passed, each part in its toolchai…
mesh/delivery delivered
mesh/delivery-group group release/57-config-and-provisioning merged: every member is merged
2026-10-16 08:24:11 +00:00
mesh-admin a2d2af00dc Merge pull request 'go/asks: a message carries its note apart, and an explanation is held to a glance (issues 383 and 639)' (#25) from fix/639-an-ask-reads-at-a-glance into main 2026-10-13 19:02:17 +00:00
jochen 5933eb757d go/asks: whole details never digest as another ask's Whole and Details (the review of #25)
mesh/merge-gate head 5933eb757d44 merged into trunk a0fc82fdcd7c — pass, with a note: builds mesh-sdk (+40 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 5933eb757d44 merged into trunk a0fc82fdcd7c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
WholeDetails was written after a tag, so an ask with only whole details "+ x: 1" encoded as one whose Whole
was "whole-details" and Details "+ x: 1". Whole and Details are now written whenever whole details are
given, then the whole details, untagged: the number of trailing values tells the three shapes apart, and an
ask without whole details digests as before. The note's comment says what ADR 0347 §5 holds of cuts.
2026-10-13 20:29:24 +02:00
jochen f4745447ba go/asks: an ask may carry its Details whole, for a channel that proves who answers (hq issue 383)
mesh/merge-gate head f4745447ba6d merged into trunk a0fc82fdcd7c — pass, with a note: builds mesh-sdk (+40 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head f4745447ba6d merged into trunk a0fc82fdcd7c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery superseded: a newer head of the same pull request
The review of mesh-controller #304: a settings proposal with more than three keys showed only the names
of the rest, and its Details could carry no value under the content rule, so Approve bound values the
operator never saw. WholeDetails carries every value whole for the note on a proving channel, held to
the secret rule alone, as Whole is; it is digested only when given.
2026-10-13 16:56:22 +02:00
jochen 9588871d66 go/asks: a message carries its note apart, and an explanation is held to a glance (hq issues 639 and 383)
mesh/merge-gate head 9588871d6629 merged into trunk a0fc82fdcd7c — pass, with a note: builds mesh-sdk (+40 dependent(s)) → ace, g14, novox, shanks; no bus …
mesh/repo-check head 9588871d6629 merged into trunk a0fc82fdcd7c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery superseded: a newer head of the same pull request
The operator will not read a Telegram ask of thirty plain lines. The secondary words (who asked, what each
answer does, the expiry, the fingerprint, how to read it whole) travel as Message.Note, so a channel can
show them small or collapsed; an ask's Details go there. Glance names the bound of an explanation: a
summary of at most 300 characters, then at most three points of at most 160.
2026-10-13 16:15:20 +02:00
mesh-admin a0fc82fdcd Merge pull request '0.1.14: the provider loop says a consumer it keeps failing on the bus, and its recovery (hq ADR 0224, issue 272)' (#24) from fix/272-the-provider-loop-says-its-standing into main 2026-10-13 06:37:30 +00:00
jochen d49d3a14d8 0.1.14: the provider loop says a consumer it keeps failing on the bus, and its recovery (hq ADR 0224, issue 272)
mesh/merge-gate head d49d3a14d82a merged into trunk c1ae30b0a2c8 — pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change…
mesh/repo-check head d49d3a14d82a merged into trunk c1ae30b0a2c8 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript)…
mesh/delivery delivered
No provider on runProvisioner passed announce, and the loop had no standing per
consumer, so the object store, the SQL and document stores, the mail server and
the others failed their consumers in their journals alone. The loop now keeps the
Go copy's standing (failing after five minutes, again every fifteen, recovered on
success, when unasked or retired, and on the first success since start) and says
it through the SDK's emit unless given another announce.
2026-10-13 08:26:33 +02:00
mesh-admin c1ae30b0a2 Merge pull request 'go/outward: the content rule in one place, for the operator channel and node-reads (hq ADR 0310 §4, issue 418)' (#23) from fix/418-node-reads into main 2026-10-12 13:20:22 +00:00
jochen 67bfa22132 go/outward: refuse a password in an address, a value given to an env-style secret name, and base64 wherever its slashes fall (review 53, issue 418)
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery delivered
mesh/delivery-group group fix/418-node-reads merged: every member is merged
node-reads applies CheckSecret to env, unit and configuration files, where
user:pass@ in a URL, DB_PASS=, MESH_TOKEN= and name = value with spaces passed,
and a random base64 value passed when its slashes or dashes cut it into short
pieces. A run is now judged in pieces only where it is a path, base64 padding
marks a value, and letters alone are random when the case changes as a random
string's does, so a CamelCase name still passes.
2026-10-12 14:27:49 +02:00
jochen 4dc5f29d3e go/outward: the content rule in one place, so the operator channel and node-reads apply the same one (hq ADR 0310 §4, issue 418)
mesh/delivery-group group fix/418-node-reads rejected: a member's own check failed
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery superseded: a newer head of the same pull request
The operator channel's rule (messenger's content.go, with the secret class
split out as in issue 383's branch) moves here as package outward: Check for
what may leave on a channel, CheckSecret for a secret's shape alone, which is
what a node-reads answer is held to. The messenger switches to it once
mesh-catalog #178, which changes that file, has merged.
2026-10-12 13:52:57 +02:00
mesh-admin 9747f3e1b1 Merge pull request 'go: a module reads the values its bucket still keeps for a key (ADR 0307, issue 502)' (#22) from adr-0307-state-history into main 2026-10-12 08:21:13 +00:00
jochen d41d255f28 go: a module reads the values its bucket still keeps for a key, so the tracker can carry them into its log (hq ADR 0307)
mesh/delivery delivered
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery-group group adr-0307-state-history merged: every member is merged
2026-10-12 10:17:56 +02:00
mesh-admin 5794ccd569 Merge pull request 'go: a module appends to and reads its log through the runtime (ADR 0297, issues 501, 502)' (#21) from adr-0297-log into main 2026-10-11 18:08:52 +00:00
jochen eaed934629 go: a module appends to and reads its log through the runtime, so its record outlives a bucket's history (hq ADR 0297)
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery delivered
2026-10-11 19:16:09 +02:00
mesh-admin 4cf45b5ca0 Merge pull request 'go: a tool reads its caller from the runtime (hq issue 365)' (#20) from fix/365-a-tool-call-names-its-caller into main 2026-10-11 09:19:42 +00:00
jschoubben d9e24e95a2 go: a tool reads its caller from the runtime, so a record can say who acted (hq issue 365)
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery delivered
mesh/delivery-group group fix/365-a-tool-call-names-its-caller merged: every member is merged
The runtime names the caller it took from the subject a call arrived on in the
call's _meta; stdio.CallerOf(args) hands it to the tool. Never read from the
arguments, and Unproven when the runtime named none, so a bundle on an older
runtime still says, explicitly, that its caller was not proven.
2026-10-11 05:11:08 +02:00
mesh-admin ee1e1a8303 Merge pull request 'cli: a client reads the answer its failed write left waiting, so a refusal is heard (issue 444)' (#19) from fix/444-a-client-reads-the-answer-its-write-could-not-finish into main 2026-10-11 00:56:01 +00:00
jschoubben 24f5ee81bd cli: after a failed write take only a refusal, since nothing can have run, and pin that the read keeps the ask's deadline (hq issue 444)
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery delivered
2026-10-11 02:49:17 +02:00
jschoubben b4900f52b8 cli: read the engine's answer after a failed request write, so a refusal is not lost as a broken pipe (hq issue 444)
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery superseded: a newer head of the same pull request
2026-10-11 02:43:54 +02:00
mesh-admin 19cfe0ef03 Merge pull request 'go/health: a health tool answers healthy, unhealthy or waits (issue 386, ADR 0283)' (#18) from feat/386-a-wait-for-the-operator into main 2026-10-10 19:13:46 +00:00
jochen 33917f91ac Refuse every form of path or address in a wait's words, a contradicting healthy answer, and a malformed wait at Waiting
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery delivered
mesh/delivery-group group feat/386-a-wait-for-the-operator merged: every member is merged
From the review of mesh-sdk#18: quoted and bracketed paths, home and relative
paths, IPv6, host and port, UNC and drive paths passed the check.
2026-10-10 21:10:01 +02:00
jochen 622b6b4f55 Add go/health: a health tool answers healthy, unhealthy or waits (hq ADR 0283, issue 386)
mesh/delivery-group group feat/386-a-wait-for-the-operator checking: 1 of 2 member(s) ready
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery superseded: a newer head of the same pull request
A module waiting for the operator's secret had only 'not healthy' to say,
so its first-node gate failed and the operator was told nothing to do.
The answer gains a state and named waits, and Read refuses any wait it
cannot check, so a malformed wait never excuses a fault.
2026-10-10 21:06:44 +02:00
mesh-admin 67dfeafd51 Merge pull request 'go/asks: an ask says what a channel that proves who answers shows whole, and what Details shows (issue 383)' (#17) from fix/383-an-ask-says-what-a-proving-channel-shows-whole into main 2026-10-10 13:50:56 +00:00
jochen 4f3efc98e0 go/asks: an ask says what a channel that proves who answers shows whole, and what Details shows (hq issue 383)
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery delivered
A settings proposal reached the phone with its values masked, so the operator could not
tell what they were approving. An ask now carries Whole, shown in place of Explanation on
a kind that proves its sender and carries the ask's answers, and Details, what the
Details answer shows. Both enter the digest only when given, so an ask without them
digests as before and a router and an asker of different builds still agree on it.
2026-10-10 15:23:41 +02:00
mesh-admin 7605a3fa56 Merge pull request 'go: a counter by compare-and-set, so a number is handed out once (hq research 042)' (#16) from feat/a-counter-by-compare-and-set into main 2026-10-10 10:37:11 +00:00
jochen 9c548753b8 go: say an Increment error other than a lost write may have spent its number, refuse null, and prove the writers collided
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
mesh/delivery delivered
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/delivery-group group feat/a-counter-by-compare-and-set merged: every member is merged
The review of 2026-10-10: a timeout can come after the write landed, so a caller tolerates gaps; a null value
is no counter; and a contention test where no write was lost would prove nothing.
2026-10-10 12:33:41 +02:00
jochen c58ce2c7fe go: a counter by compare-and-set, so a number is handed out once (hq research 042)
mesh/delivery-group group feat/a-counter-by-compare-and-set checking: 0 of 2 member(s) ready
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate pass: builds mesh-sdk, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (typescript), go/merge-check.sh (go)
The coming issues module hands out issue numbers from its own state; two instances must never hand out one
number twice. Create and Update already write by compare-and-set (ADR 0259), so State(...).Increment reads,
writes one more at the revision read, and on a lost write reads and tries again, bounded by the caller's
attempts. A lost write past the bound is ErrChanged, any other refusal and a value that is no counter are
returned at once: nothing is reported counted that was not written.

Part of the issue tracker decision, hq research 042.
2026-10-10 12:31:05 +02:00
1128 changed files with 146755 additions and 125 deletions
+1
View File
@@ -1,2 +1,3 @@
node_modules/
dist/
vendor/
+26 -6
View File
@@ -20,7 +20,13 @@ Not the manifest schema (the control plane owns and parses that, in Go). The sha
contains — the contract both a provider and a consumer conform to), and the tool and event types.
These change rarely and deliberately; when one does, a rebuild of everything is *correct*.
### `provisioner` — the reconcile harness every provider shares
### `provider-loop` — the reconcile harness every provider on the grant files shares
Called `provisioner` until design 57 (novox/hq issue 678); that word is mesh-provisioner's now. The old path and
names (`/provisioner`, `runProvisioner`, `provisionerTools`) stay aliases until the providers importing them move to
the Go helper that answers consumers over the bus (`go/provider`, design 57 B2 and B3) and this loop retires with the
grant files. Its events and tools keep their names (`provisioner.failing`, `provisioner_retirement`, …): the
controller and the operator read them.
The loop is identical for postgres, redis, minio and umami: watch the grants directory, for each
requested grant call the provider's adapter, write the sealed credential, handle withdrawal, and
keep converging (`watch`). That loop lives here. A **module provides only the adapter** — "create
@@ -39,11 +45,21 @@ never called to retire**; it is the delete of an adapter without `delete`, reach
person's `provisioner_delete`. A stable set of more than 3, or more than half of those held where more
than one is held, is not retired without a person: the provider waits for
`provisioner_retire_approve` or `provisioner_retire_reject`, and says `provisioner.retirement`
(`waiting`) through the `announce` option. Asked for again, a retired consumer is created — re-enabled
(`waiting`). Asked for again, a retired consumer is created — re-enabled
— at once. Given `module`, the four tools (`provisioner_retirement`, `provisioner_retire_approve`,
`provisioner_retire_reject`, `provisioner_delete`) are registered for it; `provisionerTools(handle)`
builds them otherwise. Additions and changes are never delayed.
**A provider that keeps failing a consumer says so on the bus** (novox/hq ADR 0224). A consumer whose
create, check (`holds`) or secret has failed for five minutes without one success between
(`failingAfterMs`) is announced `provisioner.failing` — the consumer, its machine, the class of error
(`credentials-rejected`, `unreachable`, `secret-unreadable`, `refused`; an adapter's `classify` is
believed over the words), the error with the password removed, since when and how many attempts — and
again every fifteen minutes while it lasts. The first success after it is `provisioner.recovered`, as is
a consumer no longer asked for or retired, and the first success for each consumer since the process
started. Every event goes through the `announce` option, which defaults to the SDK's `emit`: a provider
says them without asking, and a failed publish is said in its journal, never failing the act.
### `tools` — the tool-serving harness
`registerModuleTools`, the `ToolDefinition` type, and the registry/worker that loads a module's
tools and exposes them through the mesh's command surface. *How* a tool is declared and served is
@@ -96,7 +112,8 @@ an event under the module's own name or kind, `stdio.SeatTake` takes a held seat
and `stdio.SeatAnswer` carry a proof (a code typed by a person, by request and reply, never kept),
`stdio.SeatRecord` reads a record kept for the module, and `State(...).Create` and `.Update` write by
compare-and-set. The runtime does each only as far as the module's membership lists. `go/asks` is the
contract on top: the `Ask` an asker publishes and the checks it is held to, the `Warrant` it acts on
contract on top: the `Ask` an asker publishes and the checks it is held to (its `Whole` is what a channel that proves
who answers shows in place of its `Explanation`, and its `Details` what the Details answer shows, hq issue 383), the `Warrant` it acts on
(`For`, `Says`), and the `Message`, `Choice`, `LinkAsked` and `Code` a channel takes and says, with the
subjects each travels on.
`go/asker` is an asking module's client (ADR 0259 §10): `Ask` keeps an ask in the module's own state and then
@@ -104,6 +121,9 @@ publishes it under the module's name; `Cancel`; `Where` reads its state from the
`Take` hand the module each warrant once — marked acted on by compare-and-set before the module acts, and only
for an ask it made, an option it offered, at that option's level, before it expired. A lost compare-and-set is
`stdio.ErrChanged`, told by the runtime's error code.
`State(...).Increment(key, attempts)` is a counter on top of the two (for numbers handed out once, such as an
issue's): it reads the key, writes one more at the revision read, and on a lost write reads and tries again, at
most `attempts` times; past that it is `stdio.ErrChanged` and nothing was counted.
### `primitives` — sealing, semver, resolved-env
Seal/unseal for secrets, semantic version comparison, and the accessor a module uses to read its
@@ -132,12 +152,12 @@ import { UmamiClient } from "../client.js"; // the client lives in the module, n
registerModuleTools("umami", (env) => getUmamiTools(new UmamiClient(env.UMAMI_URL)));
```
A provider's provisioner:
A provider's loop:
```ts
import { runProvisioner, Grant, Credential } from "@novox/mesh-sdk/provisioner";
import { runProviderLoop, Grant, Credential } from "@novox/mesh-sdk/provider-loop";
runProvisioner("analytics", {
runProviderLoop("analytics", {
async create(grant: Grant): Promise<Credential> { /* create a umami site, return the grant */ },
async remove(grant: Grant): Promise<void> { /* delete the site */ },
});
+6
View File
@@ -0,0 +1,6 @@
# Release 57: config and provisioning
This branch carries the work of hq design 57 (ADR 0348, ADR 0349) in this repository. Each package
merges here through its own reviewed pull request; the branch reaches main as one pull request, in one
delivery group with the branch of the same name in mesh-controller, mesh-host, mesh-sdk, mesh-catalog
and hq, after a rehearsal in the lab. This file is deleted by the last commit before that pull request.
+5 -3
View File
@@ -2,8 +2,8 @@
`stdio.Serve("", tools)` speaks MCP over stdio to the node's tool runtime, as the module the runtime
names (novox/hq ADR 0188, ADR 0193). A `Tool` is a name, a description, an argument schema and a
function; calls run concurrently. A tool named `<seat>.<verb>` is the seat's implementation.
`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists and watches the module's declared state — all through the runtime (novox/hq ADR 0193, 0198, 0201). Standard library only.
function; calls run concurrently. `stdio.CallerOf(args)` inside a tool is who called it: the bus user the runtime took from the subject the call arrived on, named in the call's `_meta`, never read from the arguments — or `stdio.Unproven` when the runtime named none (novox/hq issue 365). A tool named `<seat>.<verb>` is the seat's implementation.
`stdio.Emit(type, body)` publishes an event as the module, `stdio.Subscribe(pattern, handler)` receives the module's events (taken when the handler returns nil, offered again when it returns an error — the one rule is in the repository README, "Taking an event") and `stdio.Ask(key, body)` calls a tool, and `stdio.State(name)` gets, puts, deletes, lists, watches and reads the kept history of the module's declared state, and `stdio.Log(name)` appends to, reads, and measures a log of operations the module declared — all through the runtime (novox/hq ADR 0193, 0198, 0201, 0297, 0307). `stdio.Config(key)` and `stdio.Bound(provision, key)` read, and `stdio.WatchConfig` and `stdio.WatchBound` watch, the module's own config values and the non-secret values of what it is bound to, for what its manifest declares: the runtime asks its machine's provisioner client as the module it launched, never a module the code names. `stdio.Secret(name)` is refused while the module's code runs in the tool runner; its secrets reach it through its files (novox/hq ADR 0349 §14, ADR 0370). `provider` is a providing module's half of a binding (novox/hq ADR 0349 §12, issue 678): it sees each consumer that binds the provider in the plans of the assignments mirror, writes one entry per consumer to `<module>_bound` (`<node>.<module>.<provision>[.<local>]`: served values composed with the consumer's identity, the login, and the name of the vault's credential, never a secret), hands the provider's adapter the vault's value (`provision/<provider>/<provision>` shared, `pair/<provider>/<provision>/<node>/<module>[/<local>]` per consumer), refuses a derived value to a consumer bound under a local name, naming both ends, and removes the entry of one that unbinds; the bus and the vault are interfaces the provider wires, the vault as `provider.ConsumersFile`, the file its machine's provisioner client fills from `${consumers:<provision>}` with each bound consumer's credential (novox/hq ADR 0373), since a provider in the tool runner never asks the vault itself. Standard library only.
## Installing
@@ -16,4 +16,6 @@ at the same version as the TypeScript package.
What it holds, under ADR 0039's test: the stdio loop and the tool type, and `cli`, the local protocol
`mesh-cli` speaks to the node-engine on its machine (novox/hq ADR 0272): the socket's path and one request and one
answer. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it.
answer; and `seal`, the mesh's sealed box (novox/hq ADR 0206: X25519, HKDF-SHA256, AES-256-GCM, `{v, eph, iv, tag, ct}`), byte for byte the box the licence manager made before it lived here and the TypeScript `@novox/mesh-sdk/seal` makes. Nothing else — no bus, no client, nothing of any module's. `go build ./example` and pipe a `tools/list` request into it.
From release 4a of design 47 (novox/hq ADR 0453 clause 3, ADR 0446 clause 4) it also holds the composition of a machine's declaration, over a plain input and with nothing behind it: `declaration` and `validate`, the node-engine's parser and its validator, copied from mesh-host; `catalogue`, the manifests, their resolution and rendering, and `overlay`, the private network's generator, copied from mesh-controller; `compose`, the controller's planFor, renderingFor and declarationWith as functions of an `Input` (the composition never allocates, makes, keeps or writes: what a send would do it names as foreseen or returns); `sdkversion`, which mesh-sdk version a program was built with; and, in `delivery`, the layouts of the keys the builder, the checker and the indexer write (the source fingerprint's new form, the builds, the verdicts and kept verdicts, the manifests by digest). None of them holds a bus, a store or a vault client, which `go list -deps` of `compose` shows.
+155
View File
@@ -0,0 +1,155 @@
// Package announce is how a module says on the bus which states it keeps and which of them others may read (novox/hq
// ADR 0495 clauses 2 and 3; issue 1790): the subject it says it on, the value it says, and where the controller keeps
// the last one said by each module. What a module announced is the truth of what it keeps; a reader is granted a read
// from it, never from a list or from the owner's manifest.
//
// A module announces from its code (process.Conn.Keeps, stdio.Keeps, the TypeScript SDK's keeps), never from its
// manifest, which only the core reads (clause 1). A module that keeps no state on the bus announces nothing.
package announce
import (
"encoding/json"
"fmt"
"regexp"
"sort"
)
// Version is the version of the value this package writes.
const Version = 1
// Event is the event type of an announcement, under the module's own namespace.
const Event = "states"
// Subject is where module announces what it keeps: its own event subject, mesh.mod.<module>.event.states, which its
// account may already publish to and the events stream keeps.
func Subject(module string) string { return "mesh.mod." + module + ".event." + Event }
// SubjectAll is every module's announcement, what the controller consumes.
const SubjectAll = "mesh.mod.*.event." + Event
// KeptBucket is the controller's bucket of the last announcement of each module, by Key.
const KeptBucket = "mesh-controller_states-announced"
var name = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
// 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).
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)
}
return module, nil
}
// State is one state a module keeps, by its local name, and whether others may read it. Others never write it: a
// state another module writes is the owner's own code's business.
type State struct {
Name string `json:"name"`
Readable bool `json:"readable,omitempty"`
// Readers, when named, are the only modules that may read the state (novox/hq ADR 0547 clause 9, issue 1945): a
// state readable by some and not all, such as the vault's bus account hashes. Only ReadableBy grants them; Readable,
// which a composer that knows no readers asks, stays false for it, so such a composer grants nobody.
Readers []string `json:"readers,omitempty"`
}
// States is an announcement: the module, the machine whose instance said it, and every state the module keeps, sorted
// by name. An empty list says the module keeps nothing.
type States struct {
V int `json:"v"`
Module string `json:"module"`
Node string `json:"node,omitempty"`
States []State `json:"states"`
}
// Keeps says whether the module announced keeping the state.
func (s States) Keeps(state string) bool {
for _, k := range s.States {
if k.Name == state {
return true
}
}
return false
}
// ReadableBy says whether the module announced the state readable by reader: readable, and reader among the readers
// when it names any.
func (s States) ReadableBy(state, reader string) bool {
for _, k := range s.States {
if k.Name != state {
continue
}
if len(k.Readers) > 0 {
for _, r := range k.Readers {
if r == reader {
return true
}
}
return false
}
return k.Readable
}
return false
}
// Readable says whether the module announced the state readable by others.
func (s States) Readable(state string) bool {
for _, k := range s.States {
if k.Name == state {
return k.Readable
}
}
return false
}
func (s *States) check() error {
if s.Module == "" {
return fmt.Errorf("announce: an announcement names no module")
}
if _, err := Key(s.Module); err != nil {
return err
}
seen := map[string]bool{}
for _, k := range s.States {
if !name.MatchString(k.Name) {
return fmt.Errorf("announce: %s announces the state %q, and a state is named locally: lower-case letters, digits and hyphens", s.Module, k.Name)
}
if seen[k.Name] {
return fmt.Errorf("announce: %s announces the state %q twice", s.Module, k.Name)
}
seen[k.Name] = true
for _, r := range k.Readers {
if !name.MatchString(r) {
return fmt.Errorf("announce: %s names %q as a reader of %s, and a reader is a module: lower-case letters, digits and hyphens", s.Module, r, k.Name)
}
}
}
return nil
}
// Encode is s as JSON at Version, its states sorted; it refuses a module or a state no bucket could be named by and a
// state announced twice, naming it.
func Encode(s States) ([]byte, error) {
if err := s.check(); err != nil {
return nil, err
}
s.V = Version
s.States = append([]State{}, s.States...)
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
return json.Marshal(s)
}
// Decode reads an announcement; it refuses another version and what Encode refuses.
func Decode(data []byte) (States, error) {
var s States
if err := json.Unmarshal(data, &s); err != nil {
return States{}, fmt.Errorf("announce: the announcement is not JSON: %w", err)
}
if s.V != Version {
return States{}, fmt.Errorf("announce: the announcement is version %d, and this reads version %d", s.V, Version)
}
if err := s.check(); err != nil {
return States{}, err
}
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
return s, nil
}
+78
View File
@@ -0,0 +1,78 @@
package announce
import (
"strings"
"testing"
)
// A module says on the bus which states it keeps and which of them others may read (novox/hq ADR 0495 clause 2), on its
// own event subject, in a versioned value the controller keeps by module.
func TestAnAnnouncementRoundTripsSortedAndSaysWhatIsReadable(t *testing.T) {
data, err := Encode(States{Module: "mesh-controller", Node: "novox", States: []State{
{Name: "plans-building", Readable: true}, {Name: "assignments", Readable: true}, {Name: "own"},
{Name: "hashes", Readers: []string{"mesh-grants"}}}})
if err != nil {
t.Fatal(err)
}
back, err := Decode(data)
if err != nil {
t.Fatal(err)
}
if back.V != Version || back.Module != "mesh-controller" || back.Node != "novox" {
t.Fatalf("decoded %+v", back)
}
var names []string
for _, s := range back.States {
names = append(names, s.Name)
}
if strings.Join(names, ",") != "assignments,hashes,own,plans-building" {
t.Fatalf("the states are not sorted: %v", names)
}
if !back.Readable("plans-building") || back.Readable("own") || back.Readable("absent") {
t.Fatal("Readable does not say what was announced readable")
}
// Named readers (ADR 0547 clause 9): readable by them alone, and not readable to a composer that knows no readers.
if back.Readable("hashes") || !back.ReadableBy("hashes", "mesh-grants") || back.ReadableBy("hashes", "spy") ||
!back.ReadableBy("plans-building", "spy") || back.ReadableBy("own", "spy") {
t.Fatal("ReadableBy does not say who was named")
}
if !back.Keeps("own") || back.Keeps("absent") {
t.Fatal("Keeps does not say what was announced")
}
if Subject("mesh-controller") != "mesh.mod.mesh-controller.event.states" || SubjectAll != "mesh.mod.*.event.states" {
t.Fatalf("the subjects are %q and %q", Subject("mesh-controller"), SubjectAll)
}
if KeptBucket != "mesh-controller_states-announced" {
t.Fatalf("the kept bucket is %q", KeptBucket)
}
if k, err := Key("mesh-delivery-builder"); err != nil || k != "mesh-delivery-builder" {
t.Fatalf("Key = %q, %v", k, err)
}
if nothing, err := Encode(States{Module: "quiet"}); err != nil || !strings.Contains(string(nothing), `"states":[]`) {
t.Fatalf("a module keeping nothing: %s, %v", nothing, err)
}
}
func TestAnAnnouncementRefusesWhatNoBucketCouldBe(t *testing.T) {
for _, c := range []struct {
s States
says string
}{
{States{Module: ""}, "no module"},
{States{Module: "a.b"}, `"a.b"`},
{States{Module: "a", States: []State{{Name: "x_y"}}}, `"x_y"`},
{States{Module: "a", States: []State{{Name: ""}}}, `""`},
{States{Module: "a", States: []State{{Name: "x"}, {Name: "x", Readable: true}}}, "twice"},
{States{Module: "a", States: []State{{Name: "x", Readers: []string{"b.c"}}}}, `"b.c"`},
} {
if _, err := Encode(c.s); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%+v: %v, wanted a refusal saying %s", c.s, err, c.says)
}
}
if _, err := Decode([]byte(`{"v":2,"module":"a","states":[]}`)); err == nil {
t.Error("an unknown version was read")
}
if _, err := Key("a.b"); err == nil {
t.Error("a module with a dot became a key")
}
}
+91
View File
@@ -0,0 +1,91 @@
// Package announcetest is the test each module runs with its own checks to hold that it announces on the bus every
// state it keeps (novox/hq ADR 0495 clause 5; issue 1790): it starts the module against a bus of the test's own and
// compares what the module announced with what its code declares. With the bind guard of process.Conn.Keeps,
// stdio.Keeps and the TypeScript keeps, which refuses a state the code did not announce, announcement and code agree.
//
// It replaces the merge gate's check of one module's reads against another's manifest (clause 5); a test using it
// carries `//go:build integration`, since it starts a bus.
package announcetest
import (
"context"
"fmt"
"reflect"
"sort"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
// Wait is how long Check waits for the module's announcement once it started it.
var Wait = 10 * time.Second
// Check starts a bus of the test's own, with a stream taking module's events, calls start with its address (start
// runs the module, or its part that connects, against it, and answers how to stop it), waits for the module's
// announcement and fails the test unless it is module's and names exactly want (in any order), readable as want says.
// It answers the announcement.
func Check(t testing.TB, module string, want []announce.State, start func(url string) (stop func())) announce.States {
t.Helper()
url := testbus.URL(t)
nc, err := nats.Connect(url)
if err != nil {
t.Fatalf("announcetest: connecting to the test bus: %v", err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), Wait)
defer cancel()
stream, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ANNOUNCETEST", Subjects: []string{"mesh.mod." + module + ".event.>"}})
if err != nil {
t.Fatalf("announcetest: making the events stream: %v", err)
}
stop := start(url)
if stop != nil {
defer stop()
}
got, err := waitFor(ctx, stream, module)
if err != nil {
t.Fatalf("announcetest: %v", err)
}
if problem := differs(got, module, want); problem != "" {
t.Fatalf("announcetest: %s", problem)
}
return got
}
// waitFor reads the stream until module's announcement is in it.
func waitFor(ctx context.Context, stream jetstream.Stream, module string) (announce.States, error) {
for {
msg, err := stream.GetLastMsgForSubject(ctx, announce.Subject(module))
if err == nil {
return announce.Decode(msg.Data)
}
select {
case <-ctx.Done():
return announce.States{}, fmt.Errorf("%s announced nothing on %s within %s", module, announce.Subject(module), Wait)
case <-time.After(50 * time.Millisecond):
}
}
}
// differs says how got is not module's announcement of want, or "".
func differs(got announce.States, module string, want []announce.State) string {
if got.Module != module {
return fmt.Sprintf("the announcement names the module %q, not %q", got.Module, module)
}
w := append([]announce.State{}, want...)
sort.Slice(w, func(i, j int) bool { return w[i].Name < w[j].Name })
g := append([]announce.State{}, got.States...)
if !reflect.DeepEqual(g, w) && !(len(g) == 0 && len(w) == 0) {
return fmt.Sprintf("%s announced %v, and its code declares %v", module, g, w)
}
return ""
}
@@ -0,0 +1,29 @@
//go:build integration
package announcetest
import (
"testing"
"git.novox.be/novox/mesh-sdk/go/announce"
"git.novox.be/novox/mesh-sdk/go/process"
)
// 1790/S11: a module started against the test bus announces through process.Conn.Keeps, and Check reads that
// announcement back against what the code declares.
func TestCheckReadsTheModulesAnnouncementOffARealBus(t *testing.T) {
declared := []announce.State{{Name: "accounts", Readable: true}, {Name: "drafts"}}
got := Check(t, "mesh-mail", declared, func(url string) func() {
conn, err := process.Connect(process.Account{URL: url, Module: "mesh-mail", Node: "novox", User: "u", Password: "p"})
if err != nil {
t.Fatal(err)
}
if err := conn.Keeps(declared...); err != nil {
t.Fatal(err)
}
return conn.Close
})
if got.Node != "novox" {
t.Fatalf("announced from %q", got.Node)
}
}
+32
View File
@@ -0,0 +1,32 @@
package announcetest
import (
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// The comparison is exact up to order: a state missing, one extra, a readability that differs or another module's
// announcement each fail, naming what differs.
func TestAnAnnouncementDiffersFromTheCodeByNameAndReadability(t *testing.T) {
got := announce.States{Module: "m", States: []announce.State{{Name: "a", Readable: true}, {Name: "b"}}}
if p := differs(got, "m", []announce.State{{Name: "b"}, {Name: "a", Readable: true}}); p != "" {
t.Fatalf("an equal announcement differs: %s", p)
}
for _, want := range [][]announce.State{
{{Name: "a", Readable: true}},
{{Name: "a", Readable: true}, {Name: "b"}, {Name: "c"}},
{{Name: "a"}, {Name: "b"}},
} {
if p := differs(got, "m", want); p == "" || !strings.Contains(p, "declares") {
t.Errorf("want %v: %q", want, p)
}
}
if p := differs(got, "other", nil); !strings.Contains(p, `"other"`) {
t.Errorf("another module's announcement: %q", p)
}
if p := differs(announce.States{Module: "m"}, "m", nil); p != "" {
t.Errorf("a module keeping nothing: %q", p)
}
}
+82 -3
View File
@@ -154,7 +154,9 @@ func canonical(b *strings.Builder, v string) {
//
// It is over the ask's named fields in a fixed order, each written canonically, and the expiry as UTC
// RFC 3339 to the nanosecond — never over a language's encoding of the struct, so a field added to Ask
// later changes no digest until it is added here, on purpose.
// later changes no digest until it is added here, on purpose. Whole and Details (novox/hq issue 383) follow
// the options only when the ask gives either: an ask without them digests as it did before they existed, so
// a router and an asker of different builds still agree on every such ask.
func (a Ask) Digest() string {
var b strings.Builder
b.WriteString("novox.ask.v1\n")
@@ -168,6 +170,18 @@ func (a Ask) Digest() string {
canonical(&b, v)
}
}
// Whole and Details are written whenever either or WholeDetails is given, and WholeDetails after them whenever
// it is given (novox/hq ADR 0347): the fields after the options are then always one of three fixed shapes —
// none, the two, or the three — and the count of values the sequence holds tells them apart, so no two asks
// encode the same (the review of mesh-sdk #25). An ask with none of them digests as before they existed, and
// one without WholeDetails as before it existed.
if a.Whole != "" || a.Details != "" || a.WholeDetails != "" {
canonical(&b, a.Whole)
canonical(&b, a.Details)
}
if a.WholeDetails != "" {
canonical(&b, a.WholeDetails)
}
sum := sha256.Sum256([]byte(b.String()))
return "sha256:" + hex.EncodeToString(sum[:])
}
@@ -190,6 +204,65 @@ type Ask struct {
// About is what the ask is about (a condition's key): a newer ask about it replaces the older.
About string `json:"about,omitempty"`
Urgent bool `json:"urgent,omitempty"`
// Whole is the explanation with its exact values whole, shown in place of Explanation on a channel kind
// that proves who answers (verified-sender) and carries the ask's answers (novox/hq issue 383): what the
// person approves — a mount point, a share, a private address — must be readable where they approve it.
// The asker withholds a value shaped like a secret in it, and the router refuses the ask when one is left;
// Explanation stays under the whole content rule everywhere else. Empty, Explanation is shown everywhere.
Whole string `json:"whole,omitempty"`
// Details are the ask's secondary words, line by line under the content rule: who proposed it, a
// fingerprint, how to read it whole. The router shows them in the note of the ask's own message
// (Message.Note), after what the ask's options do and when it expires — never in its body (novox/hq
// issue 639).
Details string `json:"details,omitempty"`
// WholeDetails are the Details with their exact values whole, shown in place of Details where Whole is shown
// in place of the explanation (a kind that proves who answers, given the ask's answers): every value an
// approval sets, whole, so the person reads exactly what they approve (novox/hq issue 383). Held, like
// Whole, to the secret rule alone; the router refuses the ask when a secret's shape is left in it. Empty,
// Details are shown everywhere.
WholeDetails string `json:"whole-details,omitempty"`
}
// What an ask's explanation is held to, so it reads at a glance on a phone (novox/hq issue 639): a summary
// of two or three short sentences on its first line — what changes, why, what it costs or risks — then at
// most three points, a line each, only where they add something. Whole is held to the same. Who asked, when,
// the fingerprint and how to read it whole are Details, never the explanation.
const (
SummaryLength = 300
MostPoints = 3
PointLength = 160
)
// Glance says why an explanation does not read at a glance, or nil: its first line, the summary, longer than
// SummaryLength; more than MostPoints lines after it; a point longer than PointLength. Blank lines are not
// counted. An asker holds its asks to it in its own tests; the router says, without refusing, an ask that
// does not hold to it.
func Glance(explanation string) error {
var lines []string
for _, l := range strings.Split(explanation, "\n") {
if strings.TrimSpace(l) != "" {
lines = append(lines, strings.TrimSpace(l))
}
}
var problems []string
switch {
case len(lines) == 0:
problems = append(problems, "it has no summary")
case len([]rune(lines[0])) > SummaryLength:
problems = append(problems, fmt.Sprintf("its summary is %d characters, more than %d", len([]rune(lines[0])), SummaryLength))
}
if len(lines) > 1+MostPoints {
problems = append(problems, fmt.Sprintf("it has %d points after its summary, more than %d", len(lines)-1, MostPoints))
}
for i, l := range lines {
if i > 0 && len([]rune(l)) > PointLength {
problems = append(problems, fmt.Sprintf("its point %d is %d characters, more than %d", i, len([]rune(l)), PointLength))
}
}
if len(problems) > 0 {
return errors.New("the explanation does not read at a glance: " + strings.Join(problems, "; "))
}
return nil
}
// The bounds of an ask (novox/hq ADR 0234 §8, ADR 0259 §4).
@@ -420,8 +493,14 @@ type Message struct {
Title string `json:"title"`
Body string `json:"body"`
Buttons []Button `json:"buttons,omitempty"`
Urgent bool `json:"urgent,omitempty"`
Silent bool `json:"silent,omitempty"`
// Note is the message's secondary words, after its body and its buttons: who asked and when, what each
// answer does, when it expires, and the ask's Details. A channel shows it smaller, collapsed or set apart
// where it can, and after the body where it cannot (novox/hq issue 639). A channel that bounds a message's
// length cuts the body first and the note only when the title and the note alone do not fit, saying each
// cut (novox/hq ADR 0347 §5).
Note string `json:"note,omitempty"`
Urgent bool `json:"urgent,omitempty"`
Silent bool `json:"silent,omitempty"`
// Reply is the Choice or LinkAsked this answers, by its ID: the channel shows it where that was made.
Reply string `json:"reply,omitempty"`
// To is the account linked as the operator on this kind, for a channel that verifies its sender: where
+110 -2
View File
@@ -230,9 +230,31 @@ func TestDigestsAreCanonicalOverNamedFields(t *testing.T) {
t.Errorf("changing %s left the digest the same", name)
}
}
// Whole and Details (issue 383) are in the digest once given, and change it; an ask without either digests
// exactly as before they existed — the encoding by hand, so no build can drift from it unseen.
plain := Ask{ID: "p", Headline: "h", Explanation: "e", Who: Operator, Expires: now, OnExpiry: "o", About: "a",
Options: []Option{{ID: "ok", Label: "OK", Does: "d", Level: Acknowledge}}}
byHand := sha256.Sum256([]byte("novox.ask.v1\n1:p\n1:h\n1:e\n8:operator\n20:2026-10-08T14:00:00Z\n1:o\n1:a\n5:false\n1:1\n" +
"2:ok\n2:OK\n1:d\n11:acknowledge\n0:\n"))
if plain.Digest() != "sha256:"+hex.EncodeToString(byHand[:]) {
t.Errorf("an ask without Whole and Details does not digest as it did before them: %s", plain.Digest())
}
withWhole, withDetails := plain, plain
withWhole.Whole = "e, whole"
withDetails.Details = "fingerprint 1234"
if withWhole.Digest() == plain.Digest() || withDetails.Digest() == plain.Digest() || withWhole.Digest() == withDetails.Digest() {
t.Error("Whole or Details left the digest the same")
}
both := withWhole
both.Details = "fingerprint 1234"
changed := both
changed.Whole += "!"
if changed.Digest() == both.Digest() {
t.Error("changing Whole beside Details left the digest the same")
}
// A field added to Ask or Option without being added to Digest fails here.
if n := reflect.TypeOf(Ask{}).NumField(); n != 9 {
t.Errorf("Ask has %d fields, and Digest covers 9: add the new one to Digest and to this test", n)
if n := reflect.TypeOf(Ask{}).NumField(); n != 12 {
t.Errorf("Ask has %d fields, and Digest covers 12: add the new one to Digest and to this test", n)
}
if n := reflect.TypeOf(Option{}).NumField(); n != 5 {
t.Errorf("Option has %d fields, and Digest covers 5: add the new one to Digest and to this test", n)
@@ -257,3 +279,89 @@ func TestAWarrantForAnAskWithNoExpiryIsRefused(t *testing.T) {
t.Error("a warrant for an ask with no expiry was taken")
}
}
// An explanation that reads at a glance passes; a wall of text says each way it is one (novox/hq issue 639).
func TestAnExplanationReadsAtAGlance(t *testing.T) {
if err := Glance("Your agents' memory follows you to every machine. A file changed on two keeps both.\n\n• Only the memory folders are carried\n• A conflict reaches you as a warning"); err != nil {
t.Fatalf("a summary and two points: %v", err)
}
var wall strings.Builder
wall.WriteString("ADR 0343 was proposed by fix-367 and approved by the knowledge base's own review.")
for i := 1; i <= 13; i++ {
wall.WriteString("\n" + strings.Repeat("A clause lead. ", 3))
}
err := Glance(wall.String())
if err == nil || !strings.Contains(err.Error(), "13 points") {
t.Fatalf("thirteen clause lines pass as a glance: %v", err)
}
if err := Glance(strings.Repeat("word ", 70)); err == nil || !strings.Contains(err.Error(), "summary is 349") {
t.Fatalf("a long summary passes: %v", err)
}
if err := Glance("short.\n" + strings.Repeat("p", PointLength+1)); err == nil || !strings.Contains(err.Error(), "point 1") {
t.Fatalf("a long point passes: %v", err)
}
if err := Glance(" \n"); err == nil {
t.Fatal("an empty explanation passes")
}
}
// A message's note travels beside its body, and is left out of the encoding when empty, so a channel older
// than it reads every message it read before.
func TestAMessageCarriesItsNoteApart(t *testing.T) {
raw, _ := json.Marshal(Message{Handle: "h", Title: "t", Body: "b", Note: "Asked by x."})
if !strings.Contains(string(raw), `"note":"Asked by x."`) {
t.Fatalf("the note is not encoded: %s", raw)
}
raw, _ = json.Marshal(Message{Handle: "h", Title: "t"})
if strings.Contains(string(raw), "note") {
t.Fatalf("an empty note is encoded: %s", raw)
}
}
// WholeDetails is digested when given, and an ask without it digests as before it existed: a router and an asker
// of different builds still agree on every such ask.
func TestWholeDetailsAreDigestedOnlyWhenGiven(t *testing.T) {
a := Ask{ID: "a", Headline: "h", Explanation: "e", Details: "d"}
before := a.Digest()
a.WholeDetails = "+ shares: media=/storage/media"
if a.Digest() == before {
t.Fatal("the whole details are not in the digest")
}
b := a
b.WholeDetails = "+ shares: media=/storage/other"
if a.Digest() == b.Digest() {
t.Fatal("two whole details digest the same")
}
a.WholeDetails = ""
if a.Digest() != before {
t.Fatal("an ask without whole details digests otherwise than before")
}
}
// No two asks encode the same (the review of mesh-sdk #25): whole details given beside nothing else are not read as
// a Whole and Details that happen to spell the same values, nor as any other split of the three fields.
func TestWholeDetailsCollideWithNoOtherAsk(t *testing.T) {
base := Ask{ID: "a", Headline: "h", Explanation: "e"}
shapes := []Ask{}
for _, f := range []struct{ whole, details, wholeDetails string }{
{"", "", "+ x: 1"},
{"whole-details", "+ x: 1", ""},
{"", "+ x: 1", ""},
{"+ x: 1", "", ""},
{"", "", "whole-details"},
{"", "+ x: 1", "+ x: 1"},
{"+ x: 1", "+ x: 1", ""},
{"", "", ""},
} {
a := base
a.Whole, a.Details, a.WholeDetails = f.whole, f.details, f.wholeDetails
shapes = append(shapes, a)
}
seen := map[string]int{}
for i, a := range shapes {
if j, ok := seen[a.Digest()]; ok {
t.Errorf("asks %d and %d digest the same: %+v / %+v", j, i, shapes[j], a)
}
seen[a.Digest()] = i
}
}
+149
View File
@@ -0,0 +1,149 @@
package asks
import (
"encoding/json"
"errors"
"fmt"
"reflect"
"strings"
)
// ConditionSubject is where a module says a condition to the operator: a by-caller accept of the operator-channel
// seat, `mesh.seat.operator-channel.accept.condition.<publisher>` (novox/hq ADR 0437 §9). The router reads the
// verb at the fifth part and the caller at the sixth, so the publisher is one token, the module's own name; it is
// refused when empty or when it holds a dot, a wildcard or whitespace.
func ConditionSubject(publisher string) (string, error) {
if publisher == "" {
return "", errors.New("asks: the condition's publisher is empty")
}
if strings.ContainsAny(publisher, ".*> \t\r\n") || strings.ContainsFunc(publisher, func(r rune) bool { return r <= ' ' }) {
return "", fmt.Errorf("asks: the condition's publisher %q is not one token: it holds a dot, a wildcard or whitespace", publisher)
}
return "mesh.seat." + Seat + ".accept.condition." + publisher, nil
}
// ConditionVersion is the version of the condition value this package writes and the only one it reads.
const ConditionVersion = 1
// ErrUnknownConditionVersion is what DecodeCondition returns for a value whose version is missing or not
// ConditionVersion. Test it with errors.Is.
var ErrUnknownConditionVersion = errors.New("asks: unknown condition version")
// The words a condition's explanation opens with (novox/hq ADR 0253 §1).
const (
NothingToDo = "Nothing for you to do."
NeedsYou = "Needs you: "
)
// The severities and states of a condition.
const (
SeverityUrgent = "urgent"
SeverityWarning = "warning"
StateRaised = "raised"
StateCleared = "cleared"
)
// Scope is what a condition is about, under the controller's names.
type Scope struct {
Scope string `json:"scope"`
ID string `json:"id"`
Machine string `json:"machine,omitempty"`
}
// Action is one answer a notification offers, under the controller's names.
type Action struct {
Label string `json:"label"`
Verb string `json:"verb"`
Machine string `json:"machine,omitempty"`
Arguments map[string]string `json:"arguments,omitempty"`
Level Level `json:"level,omitempty"`
}
// Condition is a condition a module says to the operator, under the names of the controller's conditions: its key,
// severity, subject, state (raised or cleared), summary and the plain words of ADR 0253. A cleared condition needs
// only its key, subject and state. The router alone chooses the channels.
type Condition struct {
V int `json:"v"`
Key string `json:"key"`
Severity string `json:"severity,omitempty"`
Subject Scope `json:"subject"`
State string `json:"state"`
Summary string `json:"summary,omitempty"`
Headline string `json:"headline,omitempty"`
Needs string `json:"needs,omitempty"`
Explanation string `json:"explanation,omitempty"`
Resolved string `json:"resolved,omitempty"`
Actions []Action `json:"actions,omitempty"`
}
func (c Condition) check() error {
if c.Key == "" {
return errors.New("asks: a condition has no key")
}
if c.Subject.Scope == "" || c.Subject.ID == "" {
return errors.New("asks: a condition's subject needs its scope and id")
}
switch c.State {
case StateCleared:
return nil
case StateRaised:
default:
return fmt.Errorf("asks: the condition's state %q is not raised or cleared", c.State)
}
switch c.Severity {
case SeverityUrgent, SeverityWarning:
default:
return fmt.Errorf("asks: the condition's severity %q is not urgent or warning", c.Severity)
}
if c.Headline == "" {
return errors.New("asks: a raised condition has no headline")
}
if n := len([]rune(c.Headline)); n > HeadlineLength {
return fmt.Errorf("asks: the condition's headline is %d characters, more than %d", n, HeadlineLength)
}
verdict := NothingToDo
if c.Needs != "" {
verdict = NeedsYou + c.Needs
}
if !strings.HasPrefix(c.Explanation, verdict) {
return fmt.Errorf("asks: the condition's explanation must open with %q", verdict)
}
return nil
}
// EncodeCondition is c as JSON at ConditionVersion; it refuses what ADR 0253 and ADR 0437 §9 refuse.
func EncodeCondition(c Condition) ([]byte, error) {
if err := c.check(); err != nil {
return nil, err
}
c.V = ConditionVersion
return json.Marshal(c)
}
// DecodeCondition reads a condition; it refuses an unknown version (ErrUnknownConditionVersion) and what
// EncodeCondition refuses.
func DecodeCondition(data []byte) (Condition, error) {
var probe struct {
V *int `json:"v"`
}
if err := json.Unmarshal(data, &probe); err != nil {
return Condition{}, fmt.Errorf("asks: the condition is not JSON: %w", err)
}
if probe.V == nil {
return Condition{}, fmt.Errorf("%w: no version in the value, this reader reads %d", ErrUnknownConditionVersion, ConditionVersion)
}
if *probe.V != ConditionVersion {
return Condition{}, fmt.Errorf("%w: the value is version %d, this reader reads %d", ErrUnknownConditionVersion, *probe.V, ConditionVersion)
}
var c Condition
if err := json.Unmarshal(data, &c); err != nil {
return Condition{}, fmt.Errorf("asks: the condition does not fit its shape: %w", err)
}
if err := c.check(); err != nil {
return Condition{}, err
}
return c, nil
}
// SameCondition says whether two conditions are the same value.
func SameCondition(a, b Condition) bool { return reflect.DeepEqual(a, b) }
+143
View File
@@ -0,0 +1,143 @@
package asks
import (
"encoding/json"
"errors"
"strings"
"testing"
)
func TestConditionSubject(t *testing.T) { // 1202/AC1
s, err := ConditionSubject("mesh-delivery-teller")
if err != nil || s != "mesh.seat.operator-channel.accept.condition.mesh-delivery-teller" {
t.Fatalf("subject = %q, %v", s, err)
}
// The router splits an accept subject in six parts and reads the verb at 4 and the caller at 5.
parts := strings.Split(s, ".")
if len(parts) != 6 || parts[4] != "condition" || parts[5] != "mesh-delivery-teller" {
t.Errorf("subject %q does not keep the router's shape: %v", s, parts)
}
for _, bad := range []string{"", "a.b", "a*", "a>", "a b", "a\tb", "a\nb"} {
if _, err := ConditionSubject(bad); err == nil {
t.Errorf("publisher %q accepted", bad)
}
}
}
func raisedCondition() Condition {
return Condition{
Key: "delivery.stuck.mesh-mail", Severity: "warning",
Subject: Scope{Scope: "module", ID: "mesh-mail", Machine: "ace"}, State: "raised",
Summary: "plan 12 stopped", Headline: "Delivery of mesh-mail is stuck",
Needs: "approve the merge.", Explanation: "Needs you: approve the merge. The plan waits.",
Resolved: "The delivery of mesh-mail moved on.",
Actions: []Action{{Label: "Approve", Verb: "mesh-controller.assign", Machine: "ace",
Arguments: map[string]string{"why": ""}, Level: Approve}},
}
}
func clearedCondition() Condition {
return Condition{Key: "delivery.stuck.mesh-mail", Subject: Scope{Scope: "module", ID: "mesh-mail"}, State: "cleared"}
}
func TestConditionRoundTrip(t *testing.T) { // 1202/AC2
for _, c := range []Condition{raisedCondition(), clearedCondition()} {
data, err := EncodeCondition(c)
if err != nil {
t.Fatalf("%s: %v", c.State, err)
}
var probe map[string]any
if err := json.Unmarshal(data, &probe); err != nil || probe["v"] != float64(1) {
t.Fatalf("%s: %s does not carry v:1", c.State, data)
}
c.V = ConditionVersion
got, err := DecodeCondition(data)
if err != nil || !SameCondition(got, c) {
t.Errorf("%s round trip: %+v %v", c.State, got, err)
}
}
data, _ := EncodeCondition(raisedCondition())
var probe map[string]any
_ = json.Unmarshal(data, &probe)
for _, name := range []string{"key", "severity", "subject", "summary", "headline", "needs", "explanation", "resolved", "actions", "state"} {
if _, ok := probe[name]; !ok {
t.Errorf("the JSON has no %q: %s", name, data)
}
}
subject := probe["subject"].(map[string]any)
for _, name := range []string{"scope", "id", "machine"} {
if _, ok := subject[name]; !ok {
t.Errorf("the subject has no %q: %s", name, data)
}
}
action := probe["actions"].([]any)[0].(map[string]any)
for _, name := range []string{"label", "verb", "machine", "arguments", "level"} {
if _, ok := action[name]; !ok {
t.Errorf("the action has no %q: %s", name, data)
}
}
for _, in := range []string{`{"key":"k"}`, `{"v":2,"key":"k"}`} {
if _, err := DecodeCondition([]byte(in)); !errors.Is(err, ErrUnknownConditionVersion) {
t.Errorf("decode of %s = %v, want ErrUnknownConditionVersion", in, err)
}
}
}
func TestConditionVerdict(t *testing.T) { // 1202/AC3
c := raisedCondition()
c.Needs, c.Explanation = "", "The plan waits."
_, err := EncodeCondition(c)
if err == nil || !strings.Contains(err.Error(), "Nothing for you to do.") {
t.Errorf("empty needs without its verdict: %v", err)
}
c.Explanation = "Nothing for you to do. The plan waits."
if _, err := EncodeCondition(c); err != nil {
t.Errorf("empty needs with its verdict refused: %v", err)
}
c = raisedCondition()
c.Explanation = "Nothing for you to do. The plan waits."
_, err = EncodeCondition(c)
if err == nil || !strings.Contains(err.Error(), "Needs you: approve the merge.") {
t.Errorf("needs set with the wrong verdict: %v", err)
}
c.Explanation = "Needs you: something else. The plan waits."
if _, err = EncodeCondition(c); err == nil {
t.Error("needs set with other needs in the verdict accepted")
}
}
func TestConditionRefused(t *testing.T) { // 1202/AC4
mutate := func(f func(*Condition)) error {
c := raisedCondition()
f(&c)
_, err := EncodeCondition(c)
return err
}
for name, f := range map[string]func(*Condition){
"severity": func(c *Condition) { c.Severity = "critical" },
"state": func(c *Condition) { c.State = "open" },
"key": func(c *Condition) { c.Key = "" },
"headline": func(c *Condition) { c.Headline = "" },
"long headline": func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength+1) },
} {
if err := mutate(f); err == nil {
t.Errorf("a condition with a bad %s accepted", name)
}
}
if err := mutate(func(c *Condition) { c.Headline = strings.Repeat("h", HeadlineLength) }); err != nil {
t.Errorf("a headline of the greatest length refused: %v", err)
}
if _, err := EncodeCondition(clearedCondition()); err != nil {
t.Errorf("a cleared condition with only key, subject and state refused: %v", err)
}
c := clearedCondition()
c.Key = ""
if _, err := EncodeCondition(c); err == nil {
t.Error("a cleared condition without its key accepted")
}
c = clearedCondition()
c.State = "gone"
if _, err := EncodeCondition(c); err == nil {
t.Error("a condition with an unknown state accepted")
}
}
+215
View File
@@ -0,0 +1,215 @@
// Package buildcpu is how much of a build node's processors its builds may take, and how they yield to everything else
// there (novox/hq ADR 0553, issue 1806; the operator, 2026-10-26: "limit CPU usage on our build nodes so the total cpu
// usage never impacts the normal workings of our nodes ... We have 3 build agents so they should share the cpu power").
//
// Three parts, each with one owner:
//
// - **Priority.** A build seat's holder (catalogue.Seat.Yields) runs at the lowest priority there is: the composer
// gives its runtime process declaration.Yields, and the node-engine places every yielding process of the node in
// one slice (declaration.YieldSlice) whose CPU weight is idle, with Nice=19 for every process it starts.
// Interactive and service work always comes first; builds take the cycles nothing else wants.
// - **One cap per node, shared.** The node's share of its processors is one value, the node-engine's own config
// `build-cpu-share` (ShareSetting), set per node through the node's settings layer. The node-engine writes it as the
// slice's CPUQuota: one bound the kernel holds for all the node's build modules together, never one per module.
// - **Threads.** The quota bounds the total; threads only size each step. A step gets the share divided by its own
// module's capacity (Threads), from the share the composer hands as a word (ShareWord): GOMAXPROCS sets every go
// command's -p, the compiler's concurrency, a test binary's processors and -parallel. A module given no word sets
// no thread limit at all: without the slice there is nothing to size a step to, and a limit then would only slow
// builds.
package buildcpu
import (
"os"
"runtime"
"strconv"
"strings"
)
// ShareSetting is the node-engine's config value of the node's processor share for its builds, in percent of the
// machine's processors, 1 to 100: one value per node, set through the node's settings layer (novox/hq ADR 0553 §3).
const ShareSetting = "build-cpu-share"
// DefaultPercent is the share where nothing sets one: half the machine, so a desktop keeps half its processors for
// itself whatever builds.
const DefaultPercent = 50
// ShareWord is the word the composer gives every yielding module (novox/hq ADR 0553 §4): the node's share, in percent.
const ShareWord = "MESH_BUILD_CPU_SHARE"
// Quota is a CPUQuota for a share of a machine with cores processors: systemd counts a quota in percent of one
// processor, so half of 24 is 1200%. At least 100%, one processor, so a share on a small machine never stops a step.
func Quota(percent, cores int) int {
q := percent * cores
if q < 100 {
q = 100
}
return q
}
// Threads is how many threads one step may run at once: its part of the share when its own module runs slots steps at
// once, at least one. The slice's quota bounds the steps of all the node's build modules together; this only sizes one.
func Threads(percent, cores, slots int) int {
if slots < 1 {
slots = 1
}
if percent < 1 || percent > 100 {
percent = DefaultPercent
}
t := cores * percent / 100 / slots
if t < 1 {
t = 1
}
return t
}
// Limits is what one step is held to on this node, as a status tool says it.
type Limits struct {
// Percent is the node's share in percent of its processors, and From where it was read: ShareWord, or "none" when
// the composer gave no share and a step runs without a thread limit.
Percent int `json:"share-percent,omitempty"`
From string `json:"share-from"`
// Cores is the machine's processor count.
Cores int `json:"cores"`
// Slots is this module's own capacity on the node: steps at once.
Slots int `json:"slots"`
// Threads is what each step is given (Threads); 0 is no limit.
Threads int `json:"threads-per-step"`
}
// FromWords is the limits of a step from the share the composer gave this process (ShareWord) and the module's own
// capacity. Without a usable share there is no limit: Threads is 0.
func FromWords(getenv func(string) string, own int) Limits {
if own < 1 {
own = 1
}
l := Limits{From: "none", Cores: runtime.NumCPU(), Slots: own}
if n, err := strconv.Atoi(strings.TrimSpace(getenv(ShareWord))); err == nil && n >= 1 && n <= 100 {
l.Percent, l.From = n, ShareWord
l.Threads = Threads(n, l.Cores, own)
}
return l
}
// Env is the environment a step's commands are given so they start no more threads than they may: GOMAXPROCS for every
// Go program (the compiler, a test binary, esbuild, buildah), UV_THREADPOOL_SIZE for Node's worker pool. Returned as
// KEY=value words to append to a command's environment; nothing for 0 threads, no limit.
func Env(threads int) []string {
if threads < 1 {
return nil
}
t := strconv.Itoa(threads)
return []string{"GOMAXPROCS=" + t, "UV_THREADPOOL_SIZE=" + t}
}
// InForce is what the kernel holds this process to, read rather than assumed (evidence over assertion): its nice
// level, its cgroup (the unit) and the cgroup's parent (the slice the node-engine placed it in), each one's CPU weight,
// idle mark and quota. A field the machine does not answer is left empty and Unread says which.
//
// The IO weight is read but binds only where the disk's scheduler honours it (bfq, or the io.cost controller); under
// the scheduler `none`, the usual one for NVMe, it does nothing, and the CPU bound is what holds.
type InForce struct {
Nice int `json:"nice"`
Cgroup string `json:"cgroup,omitempty"`
Unit Group `json:"unit"`
Slice Group `json:"slice"`
// Quota is the tighter of the two groups' cpu.max as a percent of one processor, 0 with none.
Quota int `json:"quota-percent,omitempty"`
Unread []string `json:"unread,omitempty"`
}
// Group is one cgroup's controls.
type Group struct {
Path string `json:"path,omitempty"`
CPUIdle string `json:"cpu-idle,omitempty"`
CPUWeight string `json:"cpu-weight,omitempty"`
// CPUMax is the cgroup's cpu.max: "<quota µs> <period µs>", or "max" with no quota.
CPUMax string `json:"cpu-max,omitempty"`
IOWeight string `json:"io-weight,omitempty"`
}
// cgroupRoot is where the unified hierarchy is mounted. A variable so a test can point it elsewhere.
var cgroupRoot = "/sys/fs/cgroup"
// procSelfCgroup is this process's cgroup file. A variable so a test can point it elsewhere.
var procSelfCgroup = "/proc/self/cgroup"
// procSelfStat is this process's stat file. A variable so a test can point it elsewhere.
var procSelfStat = "/proc/self/stat"
// ReadInForce reads what the kernel holds this process to.
func ReadInForce() InForce {
var f InForce
f.Nice = nice()
raw, err := os.ReadFile(procSelfCgroup)
if err != nil {
f.Unread = append(f.Unread, "cgroup: "+err.Error())
return f
}
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
if strings.HasPrefix(line, "0::") {
f.Cgroup = strings.TrimPrefix(line, "0::")
}
}
if f.Cgroup == "" {
f.Unread = append(f.Unread, "cgroup: not on the unified hierarchy")
return f
}
read := func(path string) Group {
g := Group{Path: path}
value := func(name string) string {
b, err := os.ReadFile(cgroupRoot + path + "/" + name)
if err != nil {
f.Unread = append(f.Unread, path+"/"+name+": "+err.Error())
return ""
}
return strings.TrimSpace(string(b))
}
g.CPUIdle, g.CPUWeight, g.CPUMax, g.IOWeight = value("cpu.idle"), value("cpu.weight"), value("cpu.max"), value("io.weight")
return g
}
f.Unit = read(f.Cgroup)
if i := strings.LastIndex(f.Cgroup, "/"); i > 0 {
f.Slice = read(f.Cgroup[:i])
}
for _, q := range []int{quotaOf(f.Unit.CPUMax), quotaOf(f.Slice.CPUMax)} {
if q > 0 && (f.Quota == 0 || q < f.Quota) {
f.Quota = q
}
}
return f
}
// quotaOf is a cpu.max value as a percent of one processor: "1200000 100000" is 1200; "max ..." or nothing is 0.
func quotaOf(cpuMax string) int {
fields := strings.Fields(cpuMax)
if len(fields) != 2 || fields[0] == "max" {
return 0
}
q, err1 := strconv.Atoi(fields[0])
p, err2 := strconv.Atoi(fields[1])
if err1 != nil || err2 != nil || p <= 0 {
return 0
}
return q * 100 / p
}
// nice is this process's nice level from its stat file (the 19th field, counted after the command's closing
// parenthesis, which may itself hold spaces); 0 when it cannot be read.
func nice() int {
raw, err := os.ReadFile(procSelfStat)
if err != nil {
return 0
}
s := string(raw)
i := strings.LastIndex(s, ")")
if i < 0 {
return 0
}
fields := strings.Fields(s[i+1:])
// After the command: state is field 3, so nice (field 19) is index 16.
if len(fields) < 17 {
return 0
}
n, _ := strconv.Atoi(fields[16])
return n
}
+90
View File
@@ -0,0 +1,90 @@
package buildcpu
import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
// The node's share as a quota, the threads one step is given for its own module's capacity, and the word the composer
// hands, in one table (ADR 0553, issue 1806).
func TestShareQuotaAndThreads(t *testing.T) {
for _, c := range []struct {
name string
percent, cores, slots int
quota, threads int
}{
{"shanks: half of 24, three slots", 50, 24, 3, 1200, 4},
{"ace: three quarters of 24, two slots", 75, 24, 2, 1800, 9},
{"g14: half of 16, one slot", 50, 16, 1, 800, 8},
{"one processor at least", 10, 2, 4, 100, 1},
{"no slots counts as one", 100, 4, 0, 400, 4},
{"a share out of range is the default", 0, 8, 1, 0, 4},
} {
if c.quota != 0 {
if q := Quota(c.percent, c.cores); q != c.quota {
t.Errorf("%s: Quota = %d, want %d", c.name, q, c.quota)
}
}
if th := Threads(c.percent, c.cores, c.slots); th != c.threads {
t.Errorf("%s: Threads = %d, want %d", c.name, th, c.threads)
}
}
l := FromWords(func(k string) string { return map[string]string{ShareWord: "75"}[k] }, 2)
if l.Percent != 75 || l.From != ShareWord || l.Slots != 2 || l.Threads != Threads(75, runtime.NumCPU(), 2) {
t.Errorf("from the word: %+v", l)
}
// Told nothing usable, a step has no thread limit: nothing in its environment.
for _, word := range []string{"", "250", "lots"} {
l := FromWords(func(string) string { return word }, 3)
if l.Threads != 0 || l.From != "none" || Env(l.Threads) != nil {
t.Errorf("share %q: %+v, env %v", word, l, Env(l.Threads))
}
}
if got := Env(3); !reflect.DeepEqual(got, []string{"GOMAXPROCS=3", "UV_THREADPOOL_SIZE=3"}) {
t.Errorf("Env = %q", got)
}
}
// What the kernel holds a process to is read from its unit's and its slice's cgroup and its stat file, not assumed.
func TestReadInForce(t *testing.T) {
dir := t.TempDir()
cg := filepath.Join(dir, "cg")
slice := filepath.Join(cg, "meshyield.slice")
unit := filepath.Join(slice, "mesh-build-go-runtime.service")
if err := os.MkdirAll(unit, 0o755); err != nil {
t.Fatal(err)
}
for at, files := range map[string]map[string]string{
slice: {"cpu.idle": "1\n", "cpu.weight": "0\n", "cpu.max": "1200000 100000\n", "io.weight": "default 1\n"},
unit: {"cpu.idle": "0\n", "cpu.weight": "100\n", "cpu.max": "max 100000\n", "io.weight": "default 100\n"},
} {
for name, v := range files {
if err := os.WriteFile(filepath.Join(at, name), []byte(v), 0o644); err != nil {
t.Fatal(err)
}
}
}
self := filepath.Join(dir, "cgroup")
stat := filepath.Join(dir, "stat")
_ = os.WriteFile(self, []byte("0::/meshyield.slice/mesh-build-go-runtime.service\n"), 0o644)
// A command with a space and a parenthesis in it, then nice 19 in field 19.
_ = os.WriteFile(stat, []byte("42 (mesh (build) go) S 1 42 42 0 -1 4194560 1 0 0 0 0 0 0 0 39 19 8 0 1 2 3\n"), 0o644)
cgroupRoot, procSelfCgroup, procSelfStat = cg, self, stat
t.Cleanup(func() {
cgroupRoot, procSelfCgroup, procSelfStat = "/sys/fs/cgroup", "/proc/self/cgroup", "/proc/self/stat"
})
got := ReadInForce()
want := InForce{Nice: 19, Cgroup: "/meshyield.slice/mesh-build-go-runtime.service",
Unit: Group{Path: "/meshyield.slice/mesh-build-go-runtime.service", CPUIdle: "0", CPUWeight: "100",
CPUMax: "max 100000", IOWeight: "default 100"},
Slice: Group{Path: "/meshyield.slice", CPUIdle: "1", CPUWeight: "0", CPUMax: "1200000 100000",
IOWeight: "default 1"},
Quota: 1200}
if !reflect.DeepEqual(got, want) {
t.Fatalf("ReadInForce = %+v\nwant %+v", got, want)
}
}
+437
View File
@@ -0,0 +1,437 @@
package buildstep
import (
"context"
"encoding/json"
"fmt"
"path"
"slices"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **One ask, one whole job** (novox/hq ADR 0525 §3). A build module runs a pulled ask with its own step tools' bodies,
// the same ones its request and reply tools run (ADR 0525 §8), in the order the job names: a build ask fetches, then
// builds or copies each artifact; a check ask fetches, digests the level, takes the kept verdicts and tests the rest,
// the packages whose tests the change touches first. The answers are the tools' own, whole, so the asker judges them as
// it judged the tools' answers.
// StepFunc is one step tool's body: its arguments in, its answer out.
type StepFunc func(ctx context.Context, args map[string]any) (any, error)
// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's.
type AskSteps struct {
Fetch, Build, Copy, Digest, Test StepFunc
// Toolchain is the toolchain the module builds with now; a build ask naming another is refused. Nil checks nothing
// (an image or an archive names none).
Toolchain func(ctx context.Context) (delivery.ToolchainUsed, error)
// Kept looks up a kept verdict by level and digest (the checker's kept bucket, delivery.KeptKey): whether one is
// kept and how many tests it counted. Nil takes none.
Kept func(ctx context.Context, level, digest string) (tests int, found bool, err error)
// Release removes a workspace once the ask's steps on it ended (the module's release tool's body); nil leaves it to
// the module's sweep.
Release func(workspace string) error
Logf func(format string, args ...any)
// Has says whether this node has a need a package's tests declare (delivery.AskNeeds, issue 890); a package needing
// what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing.
Has func(need string) bool
}
// RunAsk runs ask's job with steps and answers a delivery.BuildAnswer or a delivery.CheckAnswer.
func RunAsk(ctx context.Context, steps AskSteps, ask delivery.StepAsk) (any, error) {
if ask.Build != nil {
return steps.build(ctx, *ask.Build)
}
if ask.Check != nil {
return steps.check(ctx, *ask.Check)
}
return nil, Refuse("the ask %s holds no job", ask.ID)
}
func raw(v any) (json.RawMessage, error) {
if r, ok := v.(json.RawMessage); ok {
return r, nil
}
return json.Marshal(v)
}
// fetched is what an ask reads of a fetch's answer.
type fetched struct {
Workspace string `json:"workspace"`
Merge string `json:"merge"`
Conflicts []string `json:"conflicts"`
Changed []string `json:"changed"`
Modules []string `json:"modules"`
}
func (s AskSteps) fetch(ctx context.Context, args map[string]any) (json.RawMessage, fetched, error) {
if s.Fetch == nil {
return nil, fetched{}, Refuse("this build module fetches nothing")
}
if _, merges := args["base"]; merges {
Phase(ctx, "fetch and merge")
} else {
Phase(ctx, "fetch")
}
out, err := s.Fetch(ctx, args)
if err != nil {
return nil, fetched{}, err
}
r, err := raw(out)
if err != nil {
return nil, fetched{}, err
}
var f fetched
if err := json.Unmarshal(r, &f); err != nil || f.Workspace == "" {
return r, f, fmt.Errorf("the fetch answered no workspace: %s", r)
}
return r, f, nil
}
func (s AskSteps) build(ctx context.Context, job delivery.BuildJob) (any, error) {
answer := delivery.BuildAnswer{}
if job.Toolchain != nil && s.Toolchain != nil {
tc, err := s.Toolchain(ctx)
if err != nil {
return nil, err
}
if tc.Version != job.Toolchain.Version || tc.Recipe != job.Toolchain.Recipe {
return nil, Refuse("the ask is for %s (recipe %s) and this node builds with %s (recipe %s)",
job.Toolchain.Version, job.Toolchain.Recipe, tc.Version, tc.Recipe)
}
answer.Toolchain = &tc
}
var f fetched
defer func() { s.release(f.Workspace) }()
for _, a := range job.Artifacts {
if a.From == "" && f.Workspace == "" {
r, got, err := s.fetch(ctx, job.Fetch)
if err != nil {
return nil, err
}
if _, candidate := job.Fetch["base"]; candidate {
// A candidate is built from its head merged onto the trunk; a head that does not merge leaves the tree at the
// trunk, and building that would store the trunk's build under the candidate's fingerprint.
if len(got.Conflicts) > 0 {
return nil, fmt.Errorf("the head does not merge into the trunk: files in conflict: %s", strings.Join(got.Conflicts, ", "))
}
if got.Merge == "" {
return nil, fmt.Errorf("the fetch with a base answered no merge: %s", r)
}
}
f = got
}
var (
out any
err error
)
if a.From != "" {
if s.Copy == nil {
return nil, Refuse("this build module copies no artifact (%s)", a.Artifact)
}
Phase(ctx, "copy "+a.Artifact)
out, err = s.Copy(ctx, map[string]any{"module": job.Module, "artifact": a.Artifact, "from": a.From})
} else {
if s.Build == nil {
return nil, Refuse("this build module builds no artifact (%s)", a.Artifact)
}
args := map[string]any{"workspace": f.Workspace, "module": job.Path, "artifact": a.Artifact}
if job.Publish {
args["publish"] = true
}
Phase(ctx, "build "+a.Artifact)
out, err = s.Build(ctx, args)
}
if err != nil {
return nil, fmt.Errorf("%s: %w", a.Artifact, err)
}
r, err := raw(out)
if err != nil {
return nil, err
}
var built struct {
Digest string `json:"digest"`
Reference string `json:"reference"`
}
if err := json.Unmarshal(r, &built); err != nil || built.Digest == "" {
return nil, fmt.Errorf("the build of %s answered no digest: %s", a.Artifact, r)
}
answer.Artifacts = append(answer.Artifacts, delivery.AskBuilt{Artifact: a.Artifact, Digest: built.Digest, Reference: built.Reference})
}
return answer, nil
}
func (s AskSteps) check(ctx context.Context, job delivery.CheckJob) (any, error) {
r, f, err := s.fetch(ctx, job.Fetch)
if err != nil {
return nil, err
}
defer s.release(f.Workspace)
answer := delivery.CheckAnswer{Fetch: r}
if len(f.Conflicts) > 0 {
return answer, nil // the asker reads the conflict from the fetch's answer: a verdict, not an error
}
if s.Test == nil {
return nil, Refuse("this build module tests nothing")
}
runner := ""
if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) {
runner = job.Runner
}
for _, level := range job.Levels {
la := delivery.LevelAnswer{Level: level}
passed, err := s.level(ctx, job, level, f, runner, &la)
answer.Levels = append(answer.Levels, la)
if err != nil {
return nil, err
}
if !passed {
return answer, nil // the first level that did not pass ends the check (ADR 0484 clause 13)
}
}
return answer, nil
}
// level runs one level of a check ask and says whether it passed.
func (s AskSteps) level(ctx context.Context, job delivery.CheckJob, level string, f fetched, runner string, la *delivery.LevelAnswer) (bool, error) {
withRunner := func(args map[string]any) map[string]any {
if runner != "" {
args["runner"] = runner
}
return args
}
test := func(args map[string]any) (bool, error) {
what := "the whole level"
if dir, _ := args["module"].(string); dir != "" {
what = dir
} else if pkgs, _ := args["packages"].([]string); len(pkgs) > 0 {
what = fmt.Sprintf("%d packages", len(pkgs))
}
Phase(ctx, fmt.Sprintf("%s: tests of %s", level, what))
out, err := s.Test(ctx, args)
if err != nil {
return false, err
}
r, err := raw(out)
if err != nil {
return false, err
}
la.Tests = append(la.Tests, r)
var t struct {
Passed bool `json:"passed"`
}
_ = json.Unmarshal(r, &t)
return t.Passed, nil
}
// Every test step of the level runs, also after one that did not pass, so one check names every failing test of the
// level and not only the first (issue 1921); the level as a whole passes only when each of its steps did.
all := true
if s.Digest == nil {
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
for _, priority := range []bool{true, false} {
for _, dir := range f.Modules {
if changesTestIn(f.Changed, dir) != priority {
continue
}
passed, err := test(withRunner(map[string]any{"workspace": f.Workspace, "module": dir, "level": level}))
if err != nil {
return false, fmt.Errorf("the %s level of %s: %w", level, dir, err)
}
all = all && passed
}
}
return all, nil
}
todo, whole, keepRunner := s.plan(ctx, job, level, f, runner, withRunner, la)
if !keepRunner {
runner = "" // an installed module that ignores the runner judges the level as it judges any other (ADR 0535 §2)
}
if whole && len(job.Packages) > 0 {
todo, whole = job.Packages, false // a placed ask tests its packages alone, whatever the digest said (ADR 0552 §4)
}
for _, priority := range []bool{true, false} {
args := withRunner(map[string]any{"workspace": f.Workspace, "level": level})
if job.Run != "" {
args["run"] = job.Run
}
if whole {
if priority {
continue // nothing to put first: the level runs whole after
}
} else {
var run []string
for _, p := range todo {
if changesTestOf(f.Changed, p) == priority {
run = append(run, p)
}
}
if len(run) == 0 {
continue
}
args["packages"] = run
}
passed, err := test(args)
if err != nil {
return false, fmt.Errorf("the %s level: %w", level, err)
}
all = all && passed // the packages left still run (issue 1921)
}
return all, nil
}
// release removes an ask's workspace, saying a failure: the module's sweep removes it later.
func (s AskSteps) release(workspace string) {
if workspace == "" || s.Release == nil {
return
}
if err := s.Release(workspace); err != nil && s.Logf != nil {
s.Logf("the workspace %s is left to the sweep: %v", workspace, err)
}
}
// plan digests the level and takes the kept verdicts: the packages left to test, or all when the level runs whole, and
// whether the runner stays. A try of some tests runs whole, digesting nothing (a partial run proves no package). Under the
// candidate's runner the digest is asked as the candidate: when the candidate answers it, every package runs and nothing
// is digested for keeping (ADR 0535 §4); when the installed module answers it (it does not take the runner), the level is
// planned as any other and the runner is dropped. The level runs whole and takes nothing when the digest names no package
// or does not cover the siblings the fetch placed (ADR 0444, L5).
func (s AskSteps) plan(ctx context.Context, job delivery.CheckJob, level string, f fetched, runner string, withRunner func(map[string]any) map[string]any, la *delivery.LevelAnswer) ([]string, bool, bool) {
if job.Run != "" {
return nil, true, true
}
Phase(ctx, level+": digest")
out, err := s.Digest(ctx, withRunner(map[string]any{"workspace": f.Workspace, "level": level}))
if err != nil {
if s.Logf != nil {
s.Logf("no digest of the %s level, so it runs whole: %v", level, err)
}
return nil, true, true
}
r, err := raw(out)
if err != nil {
return nil, true, true
}
var d delivery.LevelDigest // packages kept as a sorted list, never keyed by a package path (issue 1930)
if err := json.Unmarshal(r, &d); err != nil {
return nil, true, true
}
if runner != "" {
if strings.HasPrefix(d.JudgedBy, "candidate ") {
la.Judged = d.JudgedBy
return nil, true, true // the candidate decides which verdicts would be taken: all run, nothing kept
}
runner = ""
}
if len(d.Packages) == 0 || !slices.Equal(d.Beside, besideOf(job.Fetch)) {
return nil, true, runner != "" // a digest that names nothing or not the siblings keys no verdict: the level runs whole
}
la.Digest = &d
only := map[string]bool{}
for _, p := range job.Packages {
only[p] = true
}
var todo []string
for _, pd := range d.Packages {
p := pd.Package
if len(only) > 0 && !only[p] {
continue // a placed ask's other packages were judged by the first ask (ADR 0552 §4)
}
if job.Kept && s.Kept != nil {
tests, found, err := s.Kept(ctx, level, pd.Digest)
if err != nil && s.Logf != nil {
s.Logf("the kept verdict of %s at %s cannot be read, so it runs: %v", p, level, err)
}
if err == nil && found {
la.Kept = append(la.Kept, delivery.KeptTaken{Package: p, Digest: pd.Digest, Tests: tests})
continue
}
}
if needs := d.NeedsOf(p); len(delivery.Lacks(needs, s.Has)) > 0 {
// Its tests would only skip here (issue 890): it is left for a node that has what they need.
la.Unmet = append(la.Unmet, delivery.PackageNeeds{Package: p, Needs: needs})
if s.Logf != nil {
s.Logf("the %s level of %s is left to a node with %s: this node lacks %s", level, p,
strings.Join(needs, ", "), strings.Join(delivery.Lacks(needs, s.Has), ", "))
}
continue
}
todo = append(todo, p)
}
return todo, false, false
}
// besideOf are the siblings a fetch's arguments place, as a digest echoes them: `<owner/repo> <commit>`, sorted.
func besideOf(fetch map[string]any) []string {
raw, err := json.Marshal(fetch["beside"])
if err != nil {
return nil
}
var refs []struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
}
_ = json.Unmarshal(raw, &refs)
var out []string
for _, r := range refs {
out = append(out, r.Repository+" "+r.Commit)
}
sort.Strings(out)
return out
}
// changesUnder says whether a path the merge changes is under prefix.
func changesUnder(changed []string, prefix string) bool {
for _, p := range changed {
if strings.HasPrefix(p, prefix) {
return true
}
}
return false
}
// isTestFile says whether a repository-relative path is a test file: Go's _test.go, and a TypeScript module's .test.
// and .spec. files and test directories.
func isTestFile(p string) bool {
switch {
case strings.HasSuffix(p, "_test.go"):
return true
case strings.Contains(p, ".test.") || strings.Contains(p, ".spec."):
return true
}
for _, seg := range strings.Split(path.Dir(p), "/") {
if seg == "__tests__" || seg == "test" || seg == "tests" {
return true
}
}
return false
}
// changesTestOf says whether the merge changes a test file of the Go package with the import path pkg: the file's
// directory and the import path agree on their last segments.
func changesTestOf(changed []string, pkg string) bool {
for _, f := range changed {
if !strings.HasSuffix(f, "_test.go") {
continue
}
dir := path.Dir(f)
if dir == pkg || strings.HasSuffix(pkg, "/"+dir) || strings.HasSuffix(dir, "/"+pkg) {
return true
}
}
return false
}
// changesTestIn says whether the merge changes a test file inside the directory of a Node module.
func changesTestIn(changed []string, dir string) bool {
for _, f := range changed {
if !isTestFile(f) || strings.HasSuffix(f, "_test.go") {
continue
}
if dir == "." || dir == "" || strings.HasPrefix(f, strings.TrimSuffix(dir, "/")+"/") {
return true
}
}
return false
}
+285
View File
@@ -0,0 +1,285 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
const runCommit = "0123456789abcdef0123456789abcdef01234567"
// recorder is a build module's step tools that record each call.
type recorder struct {
calls []string
fetch map[string]any
tests map[string]bool // package or module directory -> passed
digests map[string]string
needs []delivery.PackageNeeds
}
func (r *recorder) steps() AskSteps {
call := func(name string, answer func(args map[string]any) any) StepFunc {
return func(ctx context.Context, args map[string]any) (any, error) {
b, _ := json.Marshal(args)
r.calls = append(r.calls, name+" "+string(b))
return answer(args), nil
}
}
return AskSteps{
Fetch: call("fetch", func(map[string]any) any { return r.fetch }),
Build: call("build", func(args map[string]any) any {
return map[string]any{"digest": "sha256:" + args["artifact"].(string), "reference": "artifact-store://" + args["artifact"].(string)}
}),
Copy: call("copy", func(args map[string]any) any { return map[string]any{"digest": "sha256:copied"} }),
Digest: call("digest", func(map[string]any) any {
return map[string]any{"level": "unit", "packages": r.digests, "needs": r.needs}
}),
Test: call("test", func(args map[string]any) any {
passed := true
if pkgs, ok := args["packages"].([]string); ok {
for _, p := range pkgs {
passed = passed && r.tests[p]
}
}
if dir, ok := args["module"].(string); ok {
passed = r.tests[dir]
}
return map[string]any{"passed": passed, "tests": 1}
}),
Kept: func(ctx context.Context, level, digest string) (int, bool, error) { return 7, digest == "d-kept", nil },
}
}
func ask(job any) delivery.StepAsk {
a := delivery.StepAsk{ID: NewID(), Kind: delivery.AskCheck, Language: delivery.AskGo, Asker: "t", At: time.Now()}
switch j := job.(type) {
case delivery.BuildJob:
a.Build = &j
case delivery.CheckJob:
a.Check = &j
}
return a
}
// 1887/A3: a build ask fetches once, builds each artifact from the workspace with publish, copies a pinned one without a
// fetch, and answers each artifact's digest and reference.
func TestRunAskBuilds(t *testing.T) {
r := &recorder{fetch: map[string]any{"workspace": "/w/1"}}
steps := r.steps()
var released []string
steps.Release = func(ws string) error { released = append(released, ws); return nil }
out, err := RunAsk(context.Background(), steps, ask(delivery.BuildJob{Module: "m", Path: "modules/m", Publish: true,
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "base", From: "registry/b@sha256:00"}, {Artifact: "bundle"}, {Artifact: "lib"}}}))
if err != nil {
t.Fatal(err)
}
got := strings.Join(r.calls, "\n")
want := `copy {"artifact":"base","from":"registry/b@sha256:00","module":"m"}
fetch {"commit":"` + runCommit + `","repository":"novox/x"}
build {"artifact":"bundle","module":"modules/m","publish":true,"workspace":"/w/1"}
build {"artifact":"lib","module":"modules/m","publish":true,"workspace":"/w/1"}`
if got != want {
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
}
if len(released) != 1 || released[0] != "/w/1" {
t.Errorf("released %v, want the one workspace", released)
}
a := out.(delivery.BuildAnswer)
if len(a.Artifacts) != 3 || a.Artifacts[1].Reference != "artifact-store://bundle" {
t.Fatalf("answer %+v", a)
}
}
// A build ask for another toolchain is refused, so its step ends refused and the asker reads why.
func TestRunAskRefusesAnotherToolchain(t *testing.T) {
r := &recorder{fetch: map[string]any{"workspace": "/w"}}
s := r.steps()
s.Toolchain = func(context.Context) (delivery.ToolchainUsed, error) {
return delivery.ToolchainUsed{Version: "go1.27", Recipe: "3"}, nil
}
_, err := RunAsk(context.Background(), s, ask(delivery.BuildJob{Module: "m", Path: ".", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit},
Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}, Toolchain: &delivery.ToolchainUsed{Version: "go1.26", Recipe: "3"}}))
var ref *Refusal
if !errors.As(err, &ref) || len(r.calls) != 0 {
t.Fatalf("err %v, calls %v", err, r.calls)
}
}
// 1887/A3, A5a: a check ask runs the levels of one check after one fetch: it digests the unit level, takes the kept
// verdicts, tests the packages whose tests changed first, then the rest also after a failure (issue 1921), and runs no
// level after one that did not pass (ADR 0484 clause 13).
func TestRunAskChecksTheLevelsOfOneCheck(t *testing.T) {
r := &recorder{
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b", "x/pkg/k": "d-kept"},
tests: map[string]bool{"x/pkg/a": true, "x/pkg/b": false},
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration}, Kept: true,
Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "base": runCommit}}))
if err != nil {
t.Fatal(err)
}
got := strings.Join(r.calls, "\n")
want := `fetch {"base":"` + runCommit + `","commit":"` + runCommit + `","repository":"novox/x"}
digest {"level":"unit","workspace":"/w"}
test {"level":"unit","packages":["x/pkg/b"],"workspace":"/w"}
test {"level":"unit","packages":["x/pkg/a"],"workspace":"/w"}`
if got != want {
t.Fatalf("calls:\n%s\nwant:\n%s", got, want)
}
a := out.(delivery.CheckAnswer)
if len(a.Levels) != 1 {
t.Fatalf("levels %+v, want the unit level alone", a.Levels)
}
u := a.Levels[0]
if u.Level != delivery.LevelUnit || len(u.Kept) != 1 || u.Kept[0].Package != "x/pkg/k" || u.Kept[0].Tests != 7 || len(u.Tests) != 2 || u.Digest == nil {
t.Fatalf("the unit level %+v", u)
}
// Issue 890/AC2: a package declaring node-build-node is not tested on a node that lacks it but named unmet, and is
// tested on a node that has it.
for _, has := range []bool{false, true} {
r.calls, r.tests["x/pkg/b"] = nil, true
r.needs = []delivery.PackageNeeds{{Package: "x/pkg/a", Needs: []string{delivery.NeedNodeBuildNode}}}
steps := r.steps()
if has {
steps.Has = func(n string) bool { return n == delivery.NeedNodeBuildNode }
}
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
u := out.(delivery.CheckAnswer).Levels[0]
tested := strings.Contains(strings.Join(r.calls, "\n"), `"x/pkg/a"`)
if tested != has || (len(u.Unmet) == 0) == !has || (!has && (u.Unmet[0].Package != "x/pkg/a" || delivery.UnmetNeeds([]delivery.LevelAnswer{u})[0] != delivery.NeedNodeBuildNode)) {
t.Fatalf("has %v: tested x/pkg/a %v, unmet %+v, calls %v", has, tested, u.Unmet, r.calls)
}
}
// A placed ask naming its packages tests those alone and takes no other's kept verdict, also when the level would
// run whole (ADR 0552 §4).
for _, digests := range []map[string]string{r.digests, nil} {
r.calls, r.digests = nil, digests
steps := r.steps()
steps.Has = func(string) bool { return true }
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true, Packages: []string{"x/pkg/a"},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
u := out.(delivery.CheckAnswer).Levels[0]
if last := r.calls[len(r.calls)-1]; !strings.HasSuffix(last, `"packages":["x/pkg/a"],"workspace":"/w"}`) || len(u.Kept) != 0 || len(u.Tests) != 1 {
t.Fatalf("digests %v: calls %v, kept %+v: want x/pkg/a alone tested, nothing taken kept", digests != nil, r.calls, u.Kept)
}
}
}
// ADR 0535 §2, §4: a merge that changes the build module's own code is judged by the candidate's runner: its digest says
// the candidate ran, and every package runs with nothing kept; an installed module that ignores the runner is planned as
// any other, the runner dropped. A digest that does not cover the fetch's siblings runs the level whole too.
func TestRunAskRunsWholeUnderTheRunnerOrWithoutTheSiblings(t *testing.T) {
for _, tc := range []struct{ judged, want string }{
{"candidate 999", `digest {"level":"unit","runner":"workspace","workspace":"/w"}
test {"level":"unit","runner":"workspace","workspace":"/w"}`},
{"installed", `digest {"level":"unit","runner":"workspace","workspace":"/w"}`},
} {
r := &recorder{fetch: map[string]any{"workspace": "/w", "changed": []string{"modules/mesh-build-go/x.go"}},
digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}}
steps := r.steps()
digest := steps.Digest
steps.Digest = func(ctx context.Context, args map[string]any) (any, error) {
_, _ = digest(ctx, args)
return map[string]any{"level": "unit", "packages": r.digests, "judged_by": tc.judged}, nil
}
out, err := RunAsk(context.Background(), steps, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
Runner: "workspace", RunnerWhen: "modules/mesh-build-go/", Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
if got := strings.Join(r.calls[1:], "\n"); got != tc.want {
t.Errorf("judged by %s: calls\n%s\nwant\n%s", tc.judged, got, tc.want)
}
l := out.(delivery.CheckAnswer).Levels[0]
if tc.judged == "installed" && (len(l.Kept) != 1 || l.Judged != "") {
t.Errorf("an installed module's level %+v, want the kept verdict taken", l)
}
if tc.judged != "installed" && (len(l.Kept) != 0 || l.Digest != nil || l.Judged != tc.judged) {
t.Errorf("the candidate's level %+v, want nothing kept and the program named", l)
}
}
r := &recorder{fetch: map[string]any{"workspace": "/w"}, digests: map[string]string{"x/pkg/a": "d-kept"}, tests: map[string]bool{}}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Kept: true,
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit, "beside": []any{map[string]any{"repository": "novox/mesh-sdk", "commit": runCommit}}}}))
if err != nil {
t.Fatal(err)
}
if l := out.(delivery.CheckAnswer).Levels[0]; len(l.Kept) != 0 || l.Digest != nil {
t.Fatalf("a digest without the siblings kept %+v", l)
}
}
// A Node check tests each module directory, the one whose tests changed first; a conflict ends the ask with the fetch
// alone, a verdict for the asker.
func TestRunAskChecksNodeModulesAndConflicts(t *testing.T) {
r := &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
tests: map[string]bool{"a": true, "b": true}}
s := r.steps()
s.Digest = nil
if _, err := RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}})); err != nil {
t.Fatal(err)
}
if fmt.Sprint(r.calls[1:]) != `[test {"level":"unit","module":"b","workspace":"/w"} test {"level":"unit","module":"a","workspace":"/w"}]` {
t.Fatalf("calls %v", r.calls)
}
r = &recorder{fetch: map[string]any{"workspace": "/w", "conflicts": []string{"go.mod"}}}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit}, Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil || len(r.calls) != 1 || len(out.(delivery.CheckAnswer).Levels) != 0 {
t.Fatalf("a conflict: %v %v", err, r.calls)
}
}
// Issue 1921: a failure in the packages whose tests changed does not stop the level: the other packages still run, so two
// failing packages, one changed and one not, are two test results of the one level, and the level does not pass. A Node
// check runs every module directory after one fails too.
func TestRunAskRunsEveryPackageAfterAFailure(t *testing.T) {
r := &recorder{
fetch: map[string]any{"workspace": "/w", "changed": []string{"pkg/b/b_test.go"}},
digests: map[string]string{"x/pkg/a": "d-a", "x/pkg/b": "d-b"},
tests: map[string]bool{"x/pkg/a": false, "x/pkg/b": false},
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelIntegration, delivery.LevelUnit},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
a := out.(delivery.CheckAnswer)
if len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
t.Fatalf("levels %+v, want the one level with both packages' results", a.Levels)
}
for i, raw := range a.Levels[0].Tests {
var res struct{ Passed bool }
if err := json.Unmarshal(raw, &res); err != nil || res.Passed {
t.Errorf("result %d %s, want a failure", i, raw)
}
}
r = &recorder{fetch: map[string]any{"workspace": "/w", "modules": []string{"a", "b"}, "changed": []string{"b/test/x.test.ts"}},
tests: map[string]bool{"a": true, "b": false}}
s := r.steps()
s.Digest = nil
out, err = RunAsk(context.Background(), s, ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit, delivery.LevelIntegration},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
if a := out.(delivery.CheckAnswer); len(r.calls) != 3 || len(a.Levels) != 1 || len(a.Levels[0].Tests) != 2 {
t.Fatalf("calls %v, levels %+v: want both directories of the unit level and no integration level", r.calls, a.Levels)
}
}
+170
View File
@@ -0,0 +1,170 @@
package buildstep
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"syscall"
"time"
)
// RegistryWait is how long a push waits for an artifact store that refuses connections, longer than its nightly
// collection holds it still (about a minute and a half, issue 457); RegistryFirstPause is the first pause, doubling
// to RegistryMostPause. Variables so a test shortens them.
var (
RegistryWait = 5 * time.Minute
RegistryFirstPause = time.Second
RegistryMostPause = 10 * time.Second
)
// BlobReference is where a pushed bundle or archive is, as a done key names it and the controller delivers from
// (novox/hq ADR 0498 clause 1): artifact-store://<module>/<artifact>/blobs/<digest>, the store's address left out
// (issue 102).
func BlobReference(module, artifact, digest string) string {
return "artifact-store://" + module + "/" + artifact + "/blobs/" + digest
}
// PushBlob puts body into the artifact store at registry (host:port) as a blob of repository (<module>/<artifact>) by
// its digest (`sha256:` and hex), from the build module's own process, after the build step ended (novox/hq ADR 0498
// clauses 1 and 2): a blob the store already holds is done without an upload; otherwise it is uploaded (POST
// blobs/uploads/, then PUT with the digest). A store refusing connections is waited out up to RegistryWait; any other
// refusal fails, naming the store's status and its own words, for the caller's `not stored:` error. Bytes that are not
// their digest are refused before anything is sent.
func PushBlob(ctx context.Context, client *http.Client, registry, repository, digest string, body []byte) error {
sum := sha256.Sum256(body)
if got := "sha256:" + hex.EncodeToString(sum[:]); got != digest {
return fmt.Errorf("the blob is %s, not the digest %s it was to be pushed as", got, digest)
}
if client == nil {
client = http.DefaultClient
}
base := "http://" + registry + "/v2/" + repository + "/blobs/"
var present bool
if err := waitForStore(ctx, registry, func() error {
var err error
present, err = blobPresent(ctx, client, base+digest)
return err
}); err != nil {
return err
}
if present {
return nil
}
var location string
if err := waitForStore(ctx, registry, func() error {
var err error
location, err = startUpload(ctx, client, base+"uploads/")
return err
}); err != nil {
return err
}
target, err := url.Parse(base)
if err != nil {
return err
}
loc, err := target.Parse(location)
if err != nil {
return fmt.Errorf("the store answered the upload's location %q, which is not a URL: %w", location, err)
}
q := loc.Query()
q.Set("digest", digest)
loc.RawQuery = q.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPut, loc.String(), bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("putting %s of %s: %w", digest, repository, err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
return storeSaid(resp, "putting "+digest+" of "+repository)
}
return nil
}
func blobPresent(ctx context.Context, client *http.Client, at string) (bool, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, at, nil)
if err != nil {
return false, err
}
resp, err := client.Do(req)
if err != nil {
return false, err
}
resp.Body.Close()
switch resp.StatusCode {
case http.StatusOK:
return true, nil
case http.StatusNotFound:
return false, nil
}
return false, fmt.Errorf("asking the store for %s: it answered %s", at, resp.Status)
}
func startUpload(ctx context.Context, client *http.Client, at string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, at, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
return "", storeSaid(resp, "starting an upload at "+at)
}
location := resp.Header.Get("Location")
if location == "" {
return "", fmt.Errorf("starting an upload at %s: the store named no location", at)
}
return location, nil
}
// storeSaid is the store's refusal: its status and the first of its own words.
func storeSaid(resp *http.Response, what string) error {
words, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("%s: the store answered %s: %s", what, resp.Status, strings.TrimSpace(string(words)))
}
// refused is whether an error is a connection refused.
func refused(err error) bool {
return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused"))
}
// waitForStore runs try, and while it fails because the store refuses connections, tries again with a growing pause
// until RegistryWait has passed. A refusal is the one error waited for: nothing was sent, so trying again does nothing
// twice.
func waitForStore(ctx context.Context, registry string, try func() error) error {
err := try()
if !refused(err) {
return err
}
started, pause := time.Now(), RegistryFirstPause
for {
left := RegistryWait - time.Since(started)
if left <= 0 {
return fmt.Errorf("the artifact store at %s refused every connection for %s, longer than its nightly collection holds it still, so it is down, not paused: %w",
registry, time.Since(started).Round(time.Millisecond), err)
}
select {
case <-ctx.Done():
return fmt.Errorf("stopped while waiting for the artifact store at %s: %w (last: %v)", registry, ctx.Err(), err)
case <-time.After(min(pause, left)):
}
pause = min(pause*2, RegistryMostPause)
if err = try(); !refused(err) {
return err
}
}
}
+151
View File
@@ -0,0 +1,151 @@
package buildstep
import (
"context"
"crypto/sha256"
"encoding/hex"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
func digestOfBytes(b []byte) string {
h := sha256.Sum256(b)
return "sha256:" + hex.EncodeToString(h[:])
}
// registry is an artifact store as far as a blob push needs one: HEAD a blob, start an upload, PUT it with its digest.
type registry struct {
mu sync.Mutex
blobs map[string][]byte
calls []string
put int // the status a PUT answers, 201 when 0
}
func (r *registry) ServeHTTP(w http.ResponseWriter, req *http.Request) {
r.mu.Lock()
defer r.mu.Unlock()
r.calls = append(r.calls, req.Method+" "+req.URL.Path)
switch {
case req.Method == http.MethodHead && strings.Contains(req.URL.Path, "/blobs/sha256:"):
if _, ok := r.blobs[req.URL.Path[strings.LastIndex(req.URL.Path, "/")+1:]]; ok {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
case req.Method == http.MethodPost && strings.HasSuffix(req.URL.Path, "/blobs/uploads/"):
w.Header().Set("Location", strings.TrimSuffix(req.URL.Path, "/")+"/u1?state=x")
w.WriteHeader(http.StatusAccepted)
case req.Method == http.MethodPut && strings.Contains(req.URL.Path, "/blobs/uploads/u1"):
if r.put != 0 {
w.WriteHeader(r.put)
_, _ = io.WriteString(w, `{"errors":[{"code":"DIGEST_INVALID","message":"provided digest did not match uploaded content"}]}`)
return
}
body, _ := io.ReadAll(req.Body)
d := req.URL.Query().Get("digest")
if req.URL.Query().Get("state") != "x" || d != digestOfBytes(body) {
w.WriteHeader(http.StatusBadRequest)
return
}
r.blobs[d] = body
w.WriteHeader(http.StatusCreated)
default:
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
// A done build's bundle or archive is pushed to the artifact store by its build module (novox/hq ADR 0498 clauses 1
// and 2): a blob the store already holds is done with no upload; one it lacks is uploaded by digest; its reference is
// the artifact-store form the builder's key and the controller check.
func TestABlobIsPushedOnceByItsDigest(t *testing.T) {
reg := &registry{blobs: map[string][]byte{}}
srv := httptest.NewServer(reg)
defer srv.Close()
addr := strings.TrimPrefix(srv.URL, "http://")
body := []byte("the packed bundle")
d := digestOfBytes(body)
if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil {
t.Fatal(err)
}
if string(reg.blobs[d]) != string(body) {
t.Fatalf("the store holds %q", reg.blobs[d])
}
reg.calls = nil
if err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", d, body); err != nil {
t.Fatal(err)
}
if len(reg.calls) != 1 || !strings.HasPrefix(reg.calls[0], "HEAD ") {
t.Fatalf("a blob the store holds was uploaded again: %v", reg.calls)
}
ref := BlobReference("mail", "code", d)
if ref != "artifact-store://mail/code/blobs/"+d {
t.Fatalf("BlobReference = %q", ref)
}
b := delivery.BuilderBuild{State: delivery.BuildDone, Manifest: strings.Repeat("4", 64),
Artifacts: []delivery.BuiltArtifact{{Name: "code", Kind: "bundle", Digest: d, Reference: ref}}}
if _, err := delivery.EncodeBuilderBuild(b); err != nil {
t.Fatalf("the key refuses the reference BlobReference gives: %v", err)
}
}
// A store that refuses the upload fails the push naming the store's own words; bytes that are not the digest are
// refused before anything is sent.
func TestAPushTheStoreRefusesSaysTheStoresWords(t *testing.T) {
reg := &registry{blobs: map[string][]byte{}, put: http.StatusBadRequest}
srv := httptest.NewServer(reg)
defer srv.Close()
addr := strings.TrimPrefix(srv.URL, "http://")
body := []byte("x")
err := PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes(body), body)
if err == nil || !strings.Contains(err.Error(), "provided digest did not match") || !strings.Contains(err.Error(), "400") {
t.Fatalf("a refused upload said %v", err)
}
reg.calls = nil
err = PushBlob(context.Background(), srv.Client(), addr, "mail/code", digestOfBytes([]byte("other")), body)
if err == nil || len(reg.calls) != 0 {
t.Fatalf("bytes that are not their digest: %v, calls %v", err, reg.calls)
}
}
// A store refusing connections (its nightly collection holds it still) is waited out, up to RegistryWait (issue 457).
func TestAPushWaitsOutAStoreRefusingConnections(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := l.Addr().String()
l.Close() // refusing now
reg := &registry{blobs: map[string][]byte{}}
go func() {
time.Sleep(300 * time.Millisecond)
l2, err := net.Listen("tcp", addr)
if err != nil {
return
}
_ = http.Serve(l2, reg)
}()
beforeWait, beforePause := RegistryWait, RegistryFirstPause
RegistryWait, RegistryFirstPause = 3*time.Second, 100*time.Millisecond
defer func() { RegistryWait, RegistryFirstPause = beforeWait, beforePause }()
body := []byte("late")
if err := PushBlob(context.Background(), http.DefaultClient, addr, "mail/code", digestOfBytes(body), body); err != nil {
t.Fatalf("a store that came back was not waited for: %v", err)
}
l3, _ := net.Listen("tcp", "127.0.0.1:0")
gone := l3.Addr().String()
l3.Close()
RegistryWait = 300 * time.Millisecond
err = PushBlob(context.Background(), http.DefaultClient, gone, "mail/code", digestOfBytes(body), body)
if err == nil || !strings.Contains(err.Error(), "refused every connection") {
t.Fatalf("a store that stayed down said %v", err)
}
}
+135
View File
@@ -0,0 +1,135 @@
package buildstep
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
)
// **A node may build only within its build hours** (the operator, 2026-10-26 21:18: "we should only use g14 as a builder
// outside office hours", "from 18 o'clock until 6 in the morning"). A build module's `build-hours` setting names, per
// node, the window in the node's local time in which it takes asks; outside it the capacity judged by is 0: steps running
// finish, no new one is taken. A node it does not name, or an empty setting, builds at any hour. The puller judges it at
// every judgement and wakes on one timer to the window's next edge (a clock edge, not a poll of another part).
// BuildHoursSetting is the build modules' setting of the nodes' build windows: `<node>=HH:MM-HH:MM` pairs separated by
// commas, in the node's local time; an end before its start crosses midnight (g14=18:00-06:00).
const BuildHoursSetting = "build-hours"
// BuildWindow is one node's build hours: minutes after local midnight, Start inclusive, End exclusive.
type BuildWindow struct {
Start, End int
}
// String is the window as the setting writes it.
func (w BuildWindow) String() string {
return fmt.Sprintf("%02d:%02d-%02d:%02d", w.Start/60, w.Start%60, w.End/60, w.End%60)
}
// Open says whether t, in its own location, falls in the window.
func (w BuildWindow) Open(t time.Time) bool {
m := t.Hour()*60 + t.Minute()
if w.Start < w.End {
return m >= w.Start && m < w.End
}
return m >= w.Start || m < w.End
}
// NextEdge is the first time after t, in t's location, at which the window opens or closes.
func (w BuildWindow) NextEdge(t time.Time) time.Time {
var next time.Time
for day := 0; day <= 1; day++ {
for _, m := range []int{w.Start, w.End} {
e := time.Date(t.Year(), t.Month(), t.Day()+day, m/60, m%60, 0, 0, t.Location())
if e.After(t) && (next.IsZero() || e.Before(next)) {
next = e
}
}
}
return next
}
// ParseBuildHours is node's window in a build-hours setting: nil when the setting is empty or does not name the node.
func ParseBuildHours(setting, node string) (*BuildWindow, error) {
var found *BuildWindow
for _, pair := range strings.Split(setting, ",") {
pair = strings.TrimSpace(pair)
if pair == "" {
continue
}
name, span, ok := strings.Cut(pair, "=")
from, to, ok2 := strings.Cut(span, "-")
if !ok || !ok2 || strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("the build hours %q: %q is not <node>=HH:MM-HH:MM", setting, pair)
}
start, err := minuteOf(from)
if err != nil {
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
}
end, err := minuteOf(to)
if err != nil {
return nil, fmt.Errorf("the build hours %q: %v", setting, err)
}
if start == end {
return nil, fmt.Errorf("the build hours %q: %s opens and closes at the same minute", setting, pair)
}
if strings.TrimSpace(name) == node {
found = &BuildWindow{Start: start, End: end}
}
}
return found, nil
}
// minuteOf reads HH:MM as minutes after midnight.
func minuteOf(s string) (int, error) {
var h, m int
s = strings.TrimSpace(s)
if n, err := fmt.Sscanf(s, "%d:%d", &h, &m); err != nil || n != 2 || h < 0 || h > 23 || m < 0 || m > 59 || len(s) != 5 {
return 0, fmt.Errorf("%q is not a time HH:MM", s)
}
return h*60 + m, nil
}
// NodeBuildHours is node's build window, read from the module's settings file at path at every call, and why it is
// none when the value cannot be read. No path, no file value or an empty one is no window and no fault. A value that
// cannot be read is no window too, so the node builds at any hour; the setting is a preference of hours, not a safety
// bound (a node that must never build has capacity 0). It is loud: the fault is said once per distinct fault through
// logf, and the puller writes it in the capacity record that build_usage shows.
func NodeBuildHours(path, node string, logf func(string, ...any)) func() (*BuildWindow, string) {
said := ""
say := func(why string) {
if logf != nil && why != said {
said = why
logf("%s, so %s builds at any hour", why, node)
}
}
fault := func(why string) (*BuildWindow, string) {
say(why)
return nil, why
}
return func() (*BuildWindow, string) {
if path == "" {
return nil, ""
}
raw, err := os.ReadFile(path)
if err != nil {
return fault(fmt.Sprintf("the setting %s cannot be read: %v", BuildHoursSetting, err))
}
var s map[string]json.RawMessage
if err := json.Unmarshal(raw, &s); err != nil {
return fault(fmt.Sprintf("the settings file %s is not JSON: %v", path, err))
}
setting := ""
if v, ok := s[BuildHoursSetting]; ok && json.Unmarshal(v, &setting) != nil {
return fault(fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, v))
}
w, err := ParseBuildHours(setting, node)
if err != nil {
return fault(err.Error())
}
said = ""
return w, ""
}
}
+61
View File
@@ -0,0 +1,61 @@
package buildstep
import (
"encoding/json"
"fmt"
"os"
"git.novox.be/novox/mesh-sdk/go/delivery/buildrun"
)
// CapacitySetting is the build modules' setting of each node's build capacity (novox/hq ADR 0525 §5): `<node>=<asks at
// once>` pairs separated by commas, as the builder's was (ADR 0485 §1). Each build module declares it with
// DefaultCapacities as its default, and the controller lays it into the module's settings file.
const CapacitySetting = "capacity"
// DefaultCapacities is the setting's default, the builder's before R4g: the control-node (novox) and the laptop (g14)
// build nothing.
const DefaultCapacities = "shanks=3,ace=2,g14=0,novox=0"
// NodeCapacity is node's build capacity, read from the module's settings file at path at every call, so a change takes
// effect at the next pull; with no path it reads fallback. A node the setting does not name runs one ask at a time (as
// the builder's did). A file or a setting that cannot be read gives 0 and says why: pulling on a setting nobody can read
// could build on a node that must not.
func NodeCapacity(path, fallback, node string, logf func(string, ...any)) func() int {
say := func(format string, args ...any) {
if logf != nil {
logf(format, args...)
}
}
return func() int {
setting := fallback
if path != "" {
raw, err := os.ReadFile(path)
if err != nil {
say("the build capacity cannot be read, so %s pulls nothing: %v", node, err)
return 0
}
var s map[string]any
if err := json.Unmarshal(raw, &s); err != nil {
say("the settings file %s is not JSON, so %s pulls nothing: %v", path, node, err)
return 0
}
setting, _ = s[CapacitySetting].(string)
}
n, err := capacityOf(setting, node)
if err != nil {
say("%v, so %s pulls nothing", err, node)
return 0
}
return n
}
}
// capacityOf is node's capacity in a setting's `<node>=<asks>` pairs: DefaultCapacity for a node it does not name.
func capacityOf(setting, node string) (int, error) {
caps, err := buildrun.ParseCapacity(setting)
if err != nil {
return 0, fmt.Errorf("the build capacity: %w", err)
}
return (&buildrun.Places{Capacity: caps}).CapacityOf(node), nil
}
+141
View File
@@ -0,0 +1,141 @@
package buildstep
import (
"encoding/json"
"fmt"
"math"
"os"
"runtime"
"strconv"
"strings"
"git.novox.be/novox/mesh-sdk/go/buildcpu"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **A build seat's capacity follows the CPU its node gives builds** (issue 1806's follow-up; issue 1890, 2026-10-26
// 19:41: under the 50 % cap shanks' mesh-build-go kept capacity 3, three full mesh-catalog checks shared 12 cores, and
// each step ran past the 30-minute step bound with no test run). The configured capacity is an upper bound; the node's
// build slice bounds it again by the cores a step needs: floor(the slice's quota in cores / cores-per-step), one at
// least. The quota is read live from the cgroup at every judgement, so a changed cap is followed at the next take
// without a restart, and the result is said (the seat's current verb, the status tools, `capacity.<node>` in the pauses
// state that the delivery view's build_usage reads).
// CoresPerStepSetting is a build module's setting of the processors one of its steps needs: a number above zero, the
// same on every node (a step's need is the module's, not the node's). Each build module declares it with its own default
// (DefaultCoresPerStep).
const CoresPerStepSetting = "cores-per-step"
// DefaultCoresPerStep are the build modules' defaults by language, from measured steps (2026-10-26): a full mesh-catalog
// check on mesh-build-go takes what it is given (three at once took 965 % of shanks' 1200 % slice, about 4 cores each,
// and ran past the 30-minute step bound), so a Go step is given 6, half again what starved; a Node step (tsc, esbuild, a
// test runner: median 15 s) and an image build (buildah, its RUN lines mostly fetches) need 2.
var DefaultCoresPerStep = map[string]float64{delivery.AskGo: 6, delivery.AskNode: 2, delivery.AskImage: 2}
// Quota is the build slice's CPU quota as this process's cgroup holds it, in percent of one processor (1200 is 12
// cores), and where it was read: the cgroup's cpu.max (buildcpu.ReadInForce, the tighter of its unit's and its slice's),
// else the share the composer gave (buildcpu.ShareWord) over the machine's processors, else 0 and why none is known.
func Quota() (percent int, from string) {
f := buildcpu.ReadInForce()
if f.Quota > 0 {
path := f.Slice.Path
if path == "" {
path = f.Cgroup
}
return f.Quota, "the cpu.max of " + path
}
if n, err := strconv.Atoi(strings.TrimSpace(os.Getenv(buildcpu.ShareWord))); err == nil && n >= 1 && n <= 100 {
return buildcpu.Quota(n, runtime.NumCPU()), fmt.Sprintf("the share %d%% the composer gave (%s), of %d processors; no cpu.max is read", n, buildcpu.ShareWord, runtime.NumCPU())
}
return 0, "no CPU quota: neither a cpu.max on its cgroup nor a share from the composer"
}
// EffectiveCapacity is the capacity a seat's holder judges by, from its configured capacity, its node's quota (percent of
// one processor, 0 when none is known) and the cores one of its steps needs. Without a quota it is the configured one;
// with one, min(configured, floor(quota cores / coresPerStep)), and one at least while the configured one is above
// zero. A coresPerStep of zero or less is taken as 1.
func EffectiveCapacity(configured, quotaPercent int, quotaFrom string, coresPerStep float64) delivery.Capacity {
if coresPerStep <= 0 || math.IsNaN(coresPerStep) || math.IsInf(coresPerStep, 0) {
coresPerStep = 1
}
c := delivery.Capacity{Configured: configured, Effective: configured, QuotaFrom: quotaFrom, CoresPerStep: coresPerStep}
switch {
case configured <= 0:
c.Effective = 0
c.Why = fmt.Sprintf("its configured capacity is %d, so it pulls nothing", configured)
return c
case quotaPercent <= 0:
c.Why = fmt.Sprintf("its configured capacity %d; %s, so the CPU bounds nothing", configured, quotaFrom)
return c
}
c.QuotaCores = float64(quotaPercent) / 100
c.ByCPU = int(math.Floor(c.QuotaCores/coresPerStep + 1e-9))
if c.ByCPU < 1 {
c.ByCPU = 1
}
byCPU := fmt.Sprintf("%g cores of the build slice (%s) / %g cores a step = %d", c.QuotaCores, quotaFrom, coresPerStep, c.ByCPU)
if c.ByCPU < configured {
c.Effective = c.ByCPU
c.Why = fmt.Sprintf("%d, bounded by the CPU: %s, under its configured %d", c.Effective, byCPU, configured)
} else {
c.Why = fmt.Sprintf("%d, its configured capacity; the CPU allows %s", configured, byCPU)
}
return c
}
// NodeCoresPerStep is the module's cores-per-step, read from its settings file at path at every call so a change is
// followed at the next judgement; fallback when there is no path, no value or one that is not a number above zero, said
// once per distinct fault through logf.
func NodeCoresPerStep(path string, fallback float64, logf func(string, ...any)) func() float64 {
said := ""
say := func(why string) {
if logf != nil && why != said {
said = why
logf("%s, so a step is taken to need %g cores", why, fallback)
}
}
return func() float64 {
if path == "" {
return fallback
}
raw, err := os.ReadFile(path)
if err != nil {
say(fmt.Sprintf("the setting %s cannot be read: %v", CoresPerStepSetting, err))
return fallback
}
var s map[string]json.RawMessage
if err := json.Unmarshal(raw, &s); err != nil {
say(fmt.Sprintf("the settings file %s is not JSON: %v", path, err))
return fallback
}
v, ok := s[CoresPerStepSetting]
if !ok {
return fallback // not declared yet (a module before its manifest names it): the default, nothing to say
}
n, err := coresOf(v)
if err != nil {
say(fmt.Sprintf("the setting %s %s: %v", CoresPerStepSetting, v, err))
return fallback
}
said = ""
return n
}
}
// coresOf reads a cores-per-step value: a JSON number or a string holding one, above zero.
func coresOf(v json.RawMessage) (float64, error) {
var n float64
if err := json.Unmarshal(v, &n); err != nil {
var s string
if err := json.Unmarshal(v, &s); err != nil {
return 0, fmt.Errorf("is neither a number nor a string")
}
if n, err = strconv.ParseFloat(strings.TrimSpace(s), 64); err != nil {
return 0, fmt.Errorf("is not a number")
}
}
if n <= 0 || math.IsNaN(n) || math.IsInf(n, 0) {
return 0, fmt.Errorf("is not above zero")
}
return n, nil
}
+137
View File
@@ -0,0 +1,137 @@
package buildstep
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// 1887/A7: the node's build capacity is the build module's setting, read at every pull: the control-node at 0 pulls
// nothing, a change takes effect at the next read, and a setting that cannot be read pulls nothing.
func TestNodeCapacity(t *testing.T) {
if got := NodeCapacity("", DefaultCapacities, "novox", nil)(); got != 0 {
t.Errorf("novox = %d by default, want 0", got)
}
if got := NodeCapacity("", DefaultCapacities, "shanks", nil)(); got != 3 {
t.Errorf("shanks = %d by default, want 3", got)
}
file := filepath.Join(t.TempDir(), "settings.json")
os.WriteFile(file, []byte(`{"capacity":"shanks=1,ace=2"}`), 0o600)
capacity := NodeCapacity(file, DefaultCapacities, "shanks", nil)
if got := capacity(); got != 1 {
t.Errorf("shanks = %d from the file, want 1", got)
}
os.WriteFile(file, []byte(`{"capacity":"shanks=4"}`), 0o600)
if got := capacity(); got != 4 {
t.Errorf("shanks = %d after the change, want 4", got)
}
if got := NodeCapacity(file, "", "pi", nil)(); got != 1 {
t.Errorf("a node not named = %d, want 1", got)
}
os.WriteFile(file, []byte(`{"capacity":"shanks=lots"}`), 0o600)
if got := capacity(); got != 0 {
t.Errorf("an unreadable setting = %d, want 0", got)
}
if got := NodeCapacity(filepath.Join(t.TempDir(), "none"), DefaultCapacities, "shanks", nil)(); got != 0 {
t.Errorf("a missing file = %d, want 0", got)
}
}
// ADR 0553 §4: a step's threads are the composer's share over the module's own capacity; without the word, none. And
// issue 1806's follow-up: the capacity judged by is the configured one bounded by the build slice's cores over the cores
// a step needs (the three build nodes as they stand on 2026-10-26), and cores-per-step is read from the settings file.
func TestNodeLimitsAndTheCPUBound(t *testing.T) {
t.Setenv("MESH_BUILD_CPU_SHARE", "")
if l := NodeLimits(3); l.Threads != 0 || l.Slots != 3 {
t.Errorf("without the word: %+v", l)
}
t.Setenv("MESH_BUILD_CPU_SHARE", "75")
if l := NodeLimits(3); l.Percent != 75 || l.Slots != 3 || l.Threads < 1 {
t.Errorf("from the word: %+v", l)
}
for _, c := range []struct {
name string
configured, quota int
cores float64
effective, byCPU int
}{
{"shanks: 12 cores, configured 3", 3, 1200, 6, 2, 2},
{"ace: 18 cores, configured 2", 2, 1800, 6, 2, 3},
{"g14: 8 cores, configured 2", 2, 800, 6, 1, 1},
{"node on shanks: 12 cores at 2", 3, 1200, 2, 3, 6},
{"no quota: the configured one", 3, 0, 6, 3, 0},
{"configured 0 pulls nothing", 0, 1200, 6, 0, 0},
{"a quota under one step still builds one", 2, 100, 6, 1, 1},
{"no cores a step is one", 2, 150, 0, 1, 1},
} {
got := EffectiveCapacity(c.configured, c.quota, "test", c.cores)
if got.Effective != c.effective || got.ByCPU != c.byCPU || got.Why == "" {
t.Errorf("%s: %+v, want effective %d, by CPU %d", c.name, got, c.effective, c.byCPU)
}
}
file := filepath.Join(t.TempDir(), "settings.json")
var said []string
cores := NodeCoresPerStep(file, 6, func(f string, a ...any) { said = append(said, f) })
for _, c := range []struct {
file string
want float64
}{{`{"capacity":"shanks=2"}`, 6}, {`{"cores-per-step":4}`, 4}, {`{"cores-per-step":"3.5"}`, 3.5}, {`{"cores-per-step":0}`, 6}, {`{"cores-per-step":0}`, 6}} {
os.WriteFile(file, []byte(c.file), 0o600)
if got := cores(); got != c.want {
t.Errorf("cores-per-step from %s = %g, want %g", c.file, got, c.want)
}
}
if len(said) != 1 {
t.Errorf("a value not above zero is said once, said %d times", len(said))
}
// The operator, 2026-10-26 21:18: g14 builds only from 18:00 to 06:00, its local time. Outside its build hours the
// capacity judged by is 0, said with the window and when it opens; a node not named, or a bad value, builds at any hour.
cet := time.FixedZone("CET", 3600)
day := func(hh, mm int) time.Time { return time.Date(2026, 10, 26, hh, mm, 0, 0, cet) }
was := now
defer func() { now = was }()
for _, c := range []struct {
name, setting, node string
at time.Time
effective int
edge string // the window's next edge, HH:MM; "" for no window
bad bool
}{
{"g14 in the office", "g14=18:00-06:00", "g14", day(10, 0), 0, "18:00", false},
{"g14 in the evening", "g14=18:00-06:00", "g14", day(18, 0), 2, "06:00", false},
{"g14 past midnight", "g14=18:00-06:00", "g14", day(5, 59), 2, "06:00", false},
{"g14 at the close", "g14=18:00-06:00", "g14", day(6, 0), 0, "18:00", false},
{"a day window", "shanks=09:00-17:30,g14=18:00-06:00", "shanks", day(17, 29), 2, "17:30", false},
{"a node not named", "g14=18:00-06:00", "ace", day(10, 0), 2, "", false},
{"a bad time", "g14=18:00-6", "g14", day(10, 0), 2, "", true},
{"no span", "g14=18:00-18:00", "g14", day(10, 0), 2, "", true},
} {
w, err := ParseBuildHours(c.setting, c.node)
if (err != nil) != c.bad {
t.Errorf("%s: parse %v", c.name, err)
}
if edge := ""; w != nil {
edge = w.NextEdge(c.at).Format("15:04")
if edge != c.edge {
t.Errorf("%s: next edge %s, want %s", c.name, edge, c.edge)
}
} else if c.edge != "" {
t.Errorf("%s: no window, want one", c.name)
}
file := filepath.Join(t.TempDir(), "settings.json")
os.WriteFile(file, []byte(`{"build-hours":"`+c.setting+`"}`), 0o600)
now = func() time.Time { return c.at }
p := &Puller{Language: "go", Node: c.node, Capacity: func() int { return 2 }, BuildHours: NodeBuildHours(file, c.node, nil)}
got := p.CapacityDetail()
if got.Effective != c.effective || got.OutsideHours != (c.effective == 0) || (c.effective == 0 && !strings.Contains(got.Why, "outside its build hours 18:00-06:00 CET until 18:00")) {
t.Errorf("%s: %+v, want capacity %d", c.name, got, c.effective)
}
// A value that cannot be read builds at any hour, loudly: the record names the value and says why.
if c.bad && (!strings.Contains(got.BuildHoursFault, c.setting) || !strings.Contains(got.Why, "at any hour, because its build hours cannot be read")) {
t.Errorf("%s: a bad value is not said in the record: %+v", c.name, got)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
package buildstep
import (
"os"
"git.novox.be/novox/mesh-sdk/go/buildcpu"
)
// NodeLimits is what one step of a build module is held to (novox/hq ADR 0553 §4): the node's processor share from the
// word the composer gives the module (buildcpu.ShareWord), divided by the module's own capacity, own, over the machine's
// processors. Without the word, no thread limit: Threads is 0.
func NodeLimits(own int) buildcpu.Limits {
return buildcpu.FromWords(os.Getenv, own)
}
+234
View File
@@ -0,0 +1,234 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// credentialField is the node runtime's rule for a state value (mesh-tools node-tools/internal/bus/state.go, novox/hq
// ADR 0201), copied whole: the first field anywhere in the value whose name, lower-cased and without - _ and spaces, ends
// in a credential word, or "". A step's end is refused when it answers a field.
func credentialField(value json.RawMessage) string {
endings := []string{"password", "passwd", "secret", "token", "credential", "credentials",
"authorization", "apikey", "privatekey", "accesskey", "cookie"}
var v any
if json.Unmarshal(value, &v) != nil {
return ""
}
var walk func(any) string
walk = func(v any) string {
switch t := v.(type) {
case map[string]any:
keys := make([]string, 0, len(t))
for k := range t {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
norm := strings.NewReplacer("-", "", "_", "", " ", "").Replace(strings.ToLower(k))
for _, end := range endings {
if strings.HasSuffix(norm, end) {
return k
}
}
if found := walk(t[k]); found != "" {
return found
}
}
case []any:
for _, x := range t {
if found := walk(x); found != "" {
return found
}
}
}
return ""
}
return walk(v)
}
// runtimeState is a steps state that refuses a value as the node runtime does, and keeps every value it took.
type runtimeState struct {
mu sync.Mutex
took map[string][]delivery.Step
refused int
}
func newRuntimeState() *runtimeState { return &runtimeState{took: map[string][]delivery.Step{}} }
func (s *runtimeState) put(key string, value any) (uint64, error) {
raw, _ := value.(json.RawMessage)
if f := credentialField(raw); f != "" {
s.mu.Lock()
s.refused++
s.mu.Unlock()
return 0, fmt.Errorf("steps.%s carries a field %q, which names a credential", key, f)
}
st, err := delivery.DecodeStep(raw)
if err != nil {
return 0, err
}
s.mu.Lock()
defer s.mu.Unlock()
s.took[key] = append(s.took[key], st)
return uint64(len(s.took[key])), nil
}
func (s *runtimeState) last(key string) (delivery.Step, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t := s.took[key]
if len(t) == 0 {
return delivery.Step{}, false
}
return t[len(t)-1], true
}
func fastEnds(t *testing.T) {
attempts, retry := EndAttempts, EndRetry
EndAttempts, EndRetry = 2, time.Millisecond
t.Cleanup(func() { EndAttempts, EndRetry = attempts, retry })
}
// 1930/AC1: given a check of a repository with the package .../internal/token, when its ask's answer is stored as the
// step's end, then no field is keyed by a package path: the runtime's credential rule takes the value, and the package
// and its digest read back from the stored end.
func TestADigestOfAPackageNamedTokenIsStoredAsAList(t *testing.T) {
fastEnds(t)
r := &recorder{
fetch: map[string]any{"workspace": "/w"},
digests: map[string]string{"x/internal/token": "d-token", "x/pkg/a": "d-a"},
tests: map[string]bool{"x/internal/token": true, "x/pkg/a": true},
}
out, err := RunAsk(context.Background(), r.steps(), ask(delivery.CheckJob{Levels: []string{delivery.LevelUnit},
Fetch: map[string]any{"repository": "novox/x", "commit": runCommit}}))
if err != nil {
t.Fatal(err)
}
old, _ := json.Marshal(map[string]any{"levels": []any{map[string]any{"level": "unit", "digest": map[string]any{"packages": r.digests}}}})
if credentialField(old) != "x/internal/token" {
t.Fatalf("the rule does not refuse the form before issue 1930 (%s): the test proves nothing", old)
}
st := newRuntimeState()
if err := finish(st.put, "shanks.a1", delivery.Step{ID: "a1", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, out, nil, t.Logf); err != nil {
t.Fatal(err)
}
s, ok := st.last("shanks.a1")
if !ok || s.State != delivery.StepPassed || st.refused != 0 {
t.Fatalf("the end %+v (written %v, refused %d), want it passed and taken at once", s, ok, st.refused)
}
var a delivery.CheckAnswer
if err := json.Unmarshal(s.Result, &a); err != nil {
t.Fatal(err)
}
if len(a.Levels) != 1 || a.Levels[0].Digest == nil {
t.Fatalf("levels %+v, want the unit level with its digest", a.Levels)
}
want := []delivery.PackageDigest{{Package: "x/internal/token", Digest: "d-token"}, {Package: "x/pkg/a", Digest: "d-a"}}
if got := a.Levels[0].Digest.Packages; fmt.Sprint(got) != fmt.Sprint(want) {
t.Errorf("packages %v, want %v", got, want)
}
if !strings.Contains(string(s.Result), `"package":"x/internal/token"`) {
t.Errorf("the stored end does not name the package as a value: %s", s.Result)
}
}
// 1930/AC2: given an end the state refuses every time, when finish writes it, then it logs the refusal and writes a small
// failed end without the result that names the write's error; and when no end can be written at all it answers an error.
func TestARefusedEndIsSaidAndEndsTheStepFailed(t *testing.T) {
fastEnds(t)
big := errors.New("the value carries a field \"x/internal/token\", which names a credential")
var refused int
var stored []delivery.Step
put := func(key string, value any) (uint64, error) {
st, _ := delivery.DecodeStep(value.(json.RawMessage))
if len(st.Result) > 0 {
refused++
return 0, big
}
stored = append(stored, st)
return 1, nil
}
var logged []string
logf := func(format string, args ...any) { logged = append(logged, fmt.Sprintf(format, args...)) }
err := finish(put, "shanks.a2", delivery.Step{ID: "a2", Tool: "check ask", Node: "shanks", State: delivery.StepRunning, Started: time.Now()}, map[string]any{"levels": 1}, nil, logf)
if err != nil {
t.Fatalf("finish answered %v, want the small end written", err)
}
if refused != EndAttempts || len(stored) != 1 {
t.Fatalf("refused %d, stored %d, want %d refusals and one small end", refused, len(stored), EndAttempts)
}
s := stored[0]
if s.State != delivery.StepFailed || len(s.Result) != 0 || !strings.Contains(s.Reason, "could not be written") ||
!strings.Contains(s.Reason, big.Error()) || !strings.Contains(s.Reason, "passed") {
t.Errorf("the small end %+v, want failed, no result, naming the write's error and that it passed", s)
}
if len(logged) == 0 || !strings.Contains(logged[0], big.Error()) {
t.Errorf("logged %q, want the refusal said", logged)
}
none := func(string, any) (uint64, error) { return 0, errors.New("the bus is gone") }
if err := finish(none, "shanks.a3", delivery.Step{ID: "a3", Tool: "check ask", Node: "shanks", Started: time.Now()}, nil, nil, logf); err == nil || !strings.Contains(err.Error(), "the bus is gone") {
t.Errorf("finish with no write possible answered %v, want the error", err)
}
}
// 1930/AC2: given an ask whose end the state refuses, when it runs, then the ask is acknowledged only once its small
// failed end is written; when no end can be written at all, it is given back, never acknowledged as done.
func TestAnAskWhoseEndCannotBeWrittenIsNotAcked(t *testing.T) {
fastEnds(t)
for _, tc := range []struct {
name string
smallOK bool
wantAcked bool
}{{"the small end written", true, true}, {"no end written", false, false}} {
t.Run(tc.name, func(t *testing.T) {
src := NewFakeAsks()
a := testAsk(delivery.AskCheck)
_ = src.PublishAsk(a)
var mu sync.Mutex
var ends []delivery.Step
put := func(key string, value any) (uint64, error) {
st, err := delivery.DecodeStep(value.(json.RawMessage))
if err != nil {
return 0, err
}
if st.State != delivery.StepRunning && (len(st.Result) > 0 || !tc.smallOK) {
return 0, errors.New("refused")
}
mu.Lock()
ends = append(ends, st)
mu.Unlock()
return 1, nil
}
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Logf: t.Logf,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return map[string]any{"ran": true}, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the ask's fate", func() bool { ac, tm, nk := src.Fates(); return len(ac)+len(tm)+len(nk) > 0 })
cancel()
acked, termed, naked := src.Fates()
if tc.wantAcked && (len(acked) != 1 || len(naked) != 0) || !tc.wantAcked && (len(acked) != 0 || len(naked) == 0) || len(termed) != 0 {
t.Fatalf("acked %v naked %v termed %v", acked, naked, termed)
}
mu.Lock()
defer mu.Unlock()
if tc.smallOK {
last := ends[len(ends)-1]
if last.State != delivery.StepFailed || !strings.Contains(last.Reason, "refused") {
t.Errorf("the end %+v, want the small failed end", last)
}
}
})
}
}
+196
View File
@@ -0,0 +1,196 @@
package buildstep
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Option changes FetchCommit.
type Option func(*fetchOptions)
type fetchOptions struct {
allowFile bool
mirror string
}
// AllowFileRemote lets FetchCommit take a file:// remote. It is for a test's local repository; production
// code does not pass it, and FetchCommit then refuses anything but ssh://.
func AllowFileRemote() Option { return func(o *fetchOptions) { o.allowFile = true } }
// WithMirror makes FetchCommit (and MergeInto) borrow objects from mirror, a bare repository that UpdateMirror keeps
// in the build module's own cache (novox/hq ADR 0536 §2; issue 890): the workspace's .git/objects/info/alternates
// names the mirror's objects directory, so git transfers only the objects the mirror lacks. It is a plain file, never
// a symlink. mirror must be an absolute path of an existing directory; the workspace never writes to it.
func WithMirror(mirror string) Option { return func(o *fetchOptions) { o.mirror = mirror } }
// useMirror writes the alternates file of the repository in dir, when a mirror is set.
func useMirror(o fetchOptions, dir string) error {
if o.mirror == "" {
return nil
}
info := filepath.Join(dir, ".git", "objects", "info")
if err := os.MkdirAll(info, 0o755); err != nil {
return fmt.Errorf("buildstep: the mirror cannot be used: %w", err)
}
line := filepath.Join(o.mirror, "objects") + "\n"
if err := os.WriteFile(filepath.Join(info, "alternates"), []byte(line), 0o644); err != nil {
return fmt.Errorf("buildstep: the mirror cannot be used: %w", err)
}
return nil
}
var (
commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
refOK = regexp.MustCompile(`^refs/[A-Za-z0-9._/-]+$`)
)
// FetchCommit fetches one commit of a forge repository into dir, an empty directory, and checks it out detached
// (novox/hq ADR 0466 §4, §5; issue 1474). remote is ssh://<ssh name>/<owner>/<repo>.git, the ssh name being the
// one the build module's templates set up (ADR 0409 §1); commit is a full 40-hex sha; ref is optional, and names
// the pull request's head (refs/pull/<n>/head) when the commit is only there. Every git command runs through run
// as `git -C dir ...` with GIT_TERMINAL_PROMPT=0 and no credential helper, so nothing is asked for and no
// credential is used. A remote that is not ssh://, or one with user information, and a commit that is not 40
// hex digits are refused before anything runs.
func FetchCommit(ctx context.Context, run forgekey.Runner, dir, remote, commit, ref string, opts ...Option) error {
var o fetchOptions
for _, opt := range opts {
opt(&o)
}
if err := checkFetch(o, dir, remote, commit, ref); err != nil {
return err
}
env := []string{"GIT_TERMINAL_PROMPT=0"}
git := func(args ...string) (string, error) {
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
r := run(ctx, env, "git", full...)
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr))
}
return r.Stdout, nil
}
if _, err := git("init", "-q"); err != nil {
return err
}
if err := useMirror(o, dir); err != nil {
return err
}
if _, err := git("remote", "add", "origin", remote); err != nil {
return err
}
if _, err := git("fetch", "--no-tags", "--depth", "1", "origin", commit); err != nil {
if ref == "" {
return err
}
if _, rerr := git("fetch", "--no-tags", "origin", ref); rerr != nil {
return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr)
}
if _, verr := git("cat-file", "-e", commit+"^{commit}"); verr != nil {
return fmt.Errorf("the commit %s is not in %s: %w", commit, ref, verr)
}
}
if _, err := git("checkout", "-q", "--detach", commit); err != nil {
return err
}
out, err := git("rev-parse", "HEAD")
if err != nil {
return err
}
if head := strings.TrimSpace(out); head != commit {
return fmt.Errorf("the checkout is at %s, not at the commit %s", head, commit)
}
return nil
}
// UpdateMirror brings the bare repository mirror to commit: it makes the mirror when it is absent (git init --bare,
// origin set to remote), then fetches the commit, or the ref when only the ref holds it, with the same rules as
// FetchCommit (no prompt, no credential helper, ssh:// or an allowed file:// remote, a 40-hex commit). The mirror
// keeps every commit it was brought to, so later fetches through WithMirror transfer only what is new. Callers
// serialise updates of one mirror.
func UpdateMirror(ctx context.Context, run forgekey.Runner, mirror, remote, commit, ref string, opts ...Option) error {
var o fetchOptions
for _, opt := range opts {
opt(&o)
}
if !filepath.IsAbs(mirror) {
return fmt.Errorf("buildstep: the mirror %q is not an absolute path", mirror)
}
if err := checkFetch(o, mirror, remote, commit, ref); err != nil {
return err
}
env := []string{"GIT_TERMINAL_PROMPT=0"}
git := func(args ...string) error {
r := run(ctx, env, "git", append([]string{"-c", "credential.helper="}, args...)...)
if r.Status != 0 || r.Err != "" {
verb := args[0]
if verb == "-C" && len(args) > 2 { // the mirror's commands are named by what they do, not by -C
verb = args[2]
}
return fmt.Errorf("git %s: %s", verb, forgekey.Said(r.Err+"\n"+r.Stderr))
}
return nil
}
in := func(args ...string) error { return git(append([]string{"-C", mirror}, args...)...) }
if _, err := os.Stat(filepath.Join(mirror, "HEAD")); err != nil {
if err := git("init", "-q", "--bare", mirror); err != nil {
return err
}
if err := in("remote", "add", "origin", remote); err != nil {
return err
}
} else if err := in("remote", "set-url", "origin", remote); err != nil {
return err
}
if err := in("fetch", "--no-tags", "origin", commit); err != nil {
if ref == "" {
return err
}
if rerr := in("fetch", "--no-tags", "origin", ref); rerr != nil {
return fmt.Errorf("%v; then the ref %s: %w", err, ref, rerr)
}
}
if err := in("cat-file", "-e", commit+"^{commit}"); err != nil {
return fmt.Errorf("the commit %s is not in the mirror: %w", commit, err)
}
return nil
}
func checkFetch(o fetchOptions, dir, remote, commit, ref string) error {
if dir == "" {
return fmt.Errorf("buildstep: no workspace directory")
}
if o.mirror != "" {
if !filepath.IsAbs(o.mirror) {
return fmt.Errorf("buildstep: the mirror %q is not an absolute path", o.mirror)
}
if fi, err := os.Stat(o.mirror); err != nil || !fi.IsDir() {
return fmt.Errorf("buildstep: the mirror %q is not an existing directory", o.mirror)
}
}
if !commitOK.MatchString(commit) {
return fmt.Errorf("buildstep: %q is not a full commit (40 hex digits)", commit)
}
if ref != "" && !refOK.MatchString(ref) {
return fmt.Errorf("buildstep: %q is not a ref (refs/...)", ref)
}
u, err := url.Parse(remote)
if err != nil {
return fmt.Errorf("buildstep: the remote is not a URL: %w", err)
}
if u.User != nil {
return fmt.Errorf("buildstep: the remote carries user information; the key is the credential")
}
switch {
case u.Scheme == "ssh" && u.Host != "":
case u.Scheme == "file" && o.allowFile:
default:
return fmt.Errorf("buildstep: the remote %q is not ssh://", remote)
}
return nil
}
+59
View File
@@ -0,0 +1,59 @@
//go:build integration
package buildstep
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
func gitIn(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid"}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
// 1474/AC5: a local bare repository with a commit only under refs/pull/1/head (a file:// remote, allowed by the
// test-only option): the workspace's HEAD is the commit. The forge itself is proven live after the build
// modules' delivery (ADR 0466 §5).
func TestFetchCommitFromAPullRefOnly(t *testing.T) {
root := t.TempDir()
bare := filepath.Join(root, "r.git")
work := filepath.Join(root, "work")
for _, d := range []string{bare, work} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
gitIn(t, bare, "init", "-q", "--bare")
gitIn(t, work, "init", "-q")
if err := os.WriteFile(filepath.Join(work, "f"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
gitIn(t, work, "add", "f")
gitIn(t, work, "commit", "-q", "-m", "pull request head")
commit := gitIn(t, work, "rev-parse", "HEAD")
gitIn(t, work, "push", "-q", bare, "HEAD:refs/pull/1/head")
ws := filepath.Join(root, "ws")
if err := os.MkdirAll(ws, 0o755); err != nil {
t.Fatal(err)
}
err := FetchCommit(context.Background(), forgekey.ExecRunner, ws, "file://"+bare, commit, "refs/pull/1/head", AllowFileRemote())
if err != nil {
t.Fatal(err)
}
if head := gitIn(t, ws, "rev-parse", "HEAD"); head != commit {
t.Errorf("HEAD = %s, want %s", head, commit)
}
}
+137
View File
@@ -0,0 +1,137 @@
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
const sha = "0123456789abcdef0123456789abcdef01234567"
// fakeGit records every command and answers by a rule.
type fakeGit struct {
calls []string
envs [][]string
rule func(args string) forgekey.Ran
}
func (f *fakeGit) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
line := name + " " + strings.Join(args, " ")
f.calls = append(f.calls, line)
f.envs = append(f.envs, env)
if f.rule != nil {
if r := f.rule(line); r.Status != 0 || r.Stdout != "" || r.Err != "" {
return r
}
}
if len(args) > 4 && args[4] == "rev-parse" {
return forgekey.Ran{Stdout: sha + "\n"}
}
return forgekey.Ran{}
}
const pre = "git -c credential.helper= -C /w "
// 1474/AC1: the git commands are exactly the sequence, none with a credential or an https remote.
func TestFetchCommitRunsTheSequence(t *testing.T) {
f := &fakeGit{}
if err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/novox/mesh-sdk.git", sha, ""); err != nil {
t.Fatal(err)
}
want := []string{
pre + "init -q",
pre + "remote add origin ssh://mesh-forge/novox/mesh-sdk.git",
pre + "fetch --no-tags --depth 1 origin " + sha,
pre + "checkout -q --detach " + sha,
pre + "rev-parse HEAD",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
t.Errorf("commands:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
}
for i, c := range f.calls {
if strings.Contains(c, "https://") || strings.Contains(c, "@") || !strings.Contains(c, "credential.helper= ") {
t.Errorf("command %q carries a credential or https", c)
}
if len(f.envs[i]) != 1 || f.envs[i][0] != "GIT_TERMINAL_PROMPT=0" {
t.Errorf("env %v", f.envs[i])
}
}
}
// 1474/AC2: the depth-1 fetch fails and a pull ref is given: the ref is fetched and the commit verified; with no
// ref, the fetch's failure is the answer.
func TestFetchCommitFallsBackToThePullRef(t *testing.T) {
f := &fakeGit{rule: func(l string) forgekey.Ran {
if strings.Contains(l, "--depth 1") {
return forgekey.Ran{Status: 128, Stderr: "fatal: couldn't find remote ref " + sha + "\n"}
}
return forgekey.Ran{}
}}
if err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err != nil {
t.Fatal(err)
}
want := []string{pre + "fetch --no-tags origin refs/pull/1/head", pre + "cat-file -e " + sha + "^{commit}", pre + "checkout -q --detach " + sha}
got := strings.Join(f.calls[3:6], "\n")
if got != strings.Join(want, "\n") {
t.Errorf("after the failed fetch:\n%s\nwant:\n%s", got, strings.Join(want, "\n"))
}
g := &fakeGit{rule: f.rule}
err := FetchCommit(context.Background(), g.run, "/w", "ssh://mesh-forge/o/r.git", sha, "")
if err == nil || !strings.Contains(err.Error(), "couldn't find remote ref") || len(g.calls) != 3 {
t.Errorf("without a ref: %v after %d commands", err, len(g.calls))
}
h := &fakeGit{rule: func(l string) forgekey.Ran {
if strings.Contains(l, "--depth 1") || strings.Contains(l, "cat-file") {
return forgekey.Ran{Status: 1, Stderr: "no"}
}
return forgekey.Ran{}
}}
if err := FetchCommit(context.Background(), h.run, "/w", "ssh://mesh-forge/o/r.git", sha, "refs/pull/1/head"); err == nil {
t.Error("a commit that is not in the ref was accepted")
}
}
// 1474/AC3: HEAD differs from the commit after the checkout: an error naming both.
func TestFetchCommitAnswersAHeadThatIsNotTheCommit(t *testing.T) {
other := strings.Repeat("a", 40)
f := &fakeGit{rule: func(l string) forgekey.Ran {
if strings.HasSuffix(l, "rev-parse HEAD") {
return forgekey.Ran{Stdout: other + "\n"}
}
return forgekey.Ran{}
}}
err := FetchCommit(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", sha, "")
if err == nil || !strings.Contains(err.Error(), other) || !strings.Contains(err.Error(), sha) {
t.Errorf("error = %v, want both shas", err)
}
}
// 1474/AC4: an https remote, user information, a file remote without the option, or a commit that is not 40
// hex digits is refused before anything runs.
func TestFetchCommitRefusesBeforeRunningAnything(t *testing.T) {
for _, c := range []struct{ remote, commit, ref string }{
{"https://forge.example/o/r.git", sha, ""},
{"http://forge.example/o/r.git", sha, ""},
{"ssh://git@mesh-forge/o/r.git", sha, ""},
{"ssh://user:pw@mesh-forge/o/r.git", sha, ""},
{"file:///tmp/r.git", sha, ""},
{"/tmp/r.git", sha, ""},
{"ssh://mesh-forge/o/r.git", "abc123", ""},
{"ssh://mesh-forge/o/r.git", strings.ToUpper(sha), ""},
{"ssh://mesh-forge/o/r.git", "main", ""},
{"ssh://mesh-forge/o/r.git", sha, "--upload-pack=x"},
} {
f := &fakeGit{}
if err := FetchCommit(context.Background(), f.run, "/w", c.remote, c.commit, c.ref); err == nil || len(f.calls) != 0 {
t.Errorf("%+v: err %v, %d commands", c, err, len(f.calls))
}
}
f := &fakeGit{}
if err := FetchCommit(context.Background(), f.run, "/w", "file:///tmp/r.git", sha, "", AllowFileRemote()); err != nil {
t.Errorf("a file remote with the option: %v", err)
}
}
+52
View File
@@ -0,0 +1,52 @@
package buildstep
import (
"context"
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Guard is a build module's check that the toolchain installed on its node is the one its recipe builds with
// (novox/hq ADR 0469 §1; issue 1473). The same guard serves ADR 0470 §4's postgres major for the integration
// level, with its own Probe and Want.
type Guard struct {
// Language is the toolchain's key part: go or typescript (delivery.ToolchainKey).
Language string
// Want is the toolchain version the recipe builds with, and Recipe the recipe's own version. The indexer
// compares both texts as they are written here.
Want, Recipe string
// Probe reads the installed version (go version, node --version).
Probe func(ctx context.Context) (installed string, err error)
Node string
// Put is the toolchains state's Put (stdio.State(delivery.ToolchainsState).Put).
Put Put
}
// Check probes the installed version, writes it to <node>.<language> of the toolchains state every time it runs,
// and answers nil when it is the recipe's. Otherwise it answers a *Refusal naming both versions; the installed
// version is written even then, so the indexer sees that builders disagree. A probe that fails is a *Refusal
// naming its failure, and nothing is written. A write that fails is an error that is not a Refusal.
func (g Guard) Check(ctx context.Context) error {
installed, err := g.Probe(ctx)
if err != nil {
return &Refusal{Text: fmt.Sprintf("the installed %s version could not be read: %v", g.Language, err), Want: g.Want}
}
key, err := delivery.ToolchainKey(g.Node, g.Language)
if err != nil {
return err
}
raw, err := delivery.EncodeToolchain(delivery.Toolchain{Language: g.Language, Version: installed, Recipe: g.Recipe, At: now().UTC()})
if err != nil {
return err
}
if _, err := g.Put(key, json.RawMessage(raw)); err != nil {
return fmt.Errorf("buildstep: writing the toolchain %s: %w", key, err)
}
if installed != g.Want {
return &Refusal{Text: fmt.Sprintf("%s %s installed, the recipe builds with %s %s", g.Language, installed, g.Language, g.Want),
Want: g.Want, Have: installed}
}
return nil
}
+79
View File
@@ -0,0 +1,79 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
type toolchainKeys map[string]delivery.Toolchain
func (k toolchainKeys) put(key string, value any) (uint64, error) {
tc, err := delivery.DecodeToolchain(value.(json.RawMessage))
if err != nil {
return 0, err
}
k[key] = tc
return uint64(len(k)), nil
}
func guardFor(k toolchainKeys, installed string, probeErr error) Guard {
return Guard{Language: "go", Want: "1.27.0", Recipe: "r7", Node: "ace", Put: k.put,
Probe: func(context.Context) (string, error) { return installed, probeErr }}
}
// 1473/AC1: the probe answers the recipe's version: nil, and <node>.go holds that version and the recipe's.
func TestCheckPassesAndWritesTheToolchain(t *testing.T) {
k := toolchainKeys{}
if err := guardFor(k, "1.27.0", nil).Check(context.Background()); err != nil {
t.Fatal(err)
}
tc, ok := k["ace.go"]
if !ok || tc.Version != "1.27.0" || tc.Recipe != "r7" || tc.Language != "go" || tc.At.IsZero() {
t.Errorf("keys = %+v", k)
}
}
// 1473/AC2: another version: a Refusal naming both, and the key holds the installed version.
func TestCheckRefusesAnotherVersionAndWritesTheInstalledOne(t *testing.T) {
k := toolchainKeys{}
err := guardFor(k, "1.27.1", nil).Check(context.Background())
var ref *Refusal
if !errors.As(err, &ref) {
t.Fatalf("Check = %v, want a Refusal", err)
}
if ref.Text != "go 1.27.1 installed, the recipe builds with go 1.27.0" || ref.Want != "1.27.0" || ref.Have != "1.27.1" {
t.Errorf("refusal = %+v", ref)
}
if k["ace.go"].Version != "1.27.1" {
t.Errorf("the key holds %q, want the installed 1.27.1", k["ace.go"].Version)
}
}
// 1473/AC3: a probe that fails: a Refusal naming the failure, and nothing put.
func TestCheckRefusesAFailedProbeAndPutsNothing(t *testing.T) {
k := toolchainKeys{}
err := guardFor(k, "", errors.New("go: not found")).Check(context.Background())
var ref *Refusal
if !errors.As(err, &ref) || !strings.Contains(ref.Text, "go: not found") {
t.Fatalf("Check = %v, want a Refusal naming the probe's failure", err)
}
if len(k) != 0 {
t.Errorf("put %v", k)
}
}
// A failed write is an error, not a refusal: the step fails instead of refusing.
func TestCheckAnswersAFailedWriteAsAnError(t *testing.T) {
g := guardFor(toolchainKeys{}, "1.27.0", nil)
g.Put = func(string, any) (uint64, error) { return 0, errors.New("no grant") }
err := g.Check(context.Background())
var ref *Refusal
if err == nil || errors.As(err, &ref) {
t.Errorf("Check = %v", err)
}
}
+175
View File
@@ -0,0 +1,175 @@
package buildstep
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Bubblewrap is the program a run step runs in (novox/hq ADR 0448): a build module declares its package, which needs
// unprivileged user namespaces (issue 1308).
const Bubblewrap = "bwrap"
// Isolation is how a run step's command is shut off (novox/hq ADR 0448; issue 1309, the lead's decision L2 of R4e: one
// runner in the SDK for every build module). The fetch step holds the forge key and the network and runs none of the
// change's code; the run step runs the change's build and tests in bubblewrap with:
//
// - no network: its own network namespace, with only a loopback, so a test may start a bus or a store of its own but
// resolves no name and reaches no socket of the machine;
// - none of the account: not its home, not the forge key, not a bus login, not the runtime directory (/run), since
// none of them is bound; HOME is a directory in the step's own /tmp unless Env names one;
// - an environment of exactly Env and the runner's env: nothing of the build module's own passes;
// - the system's programs and libraries read-only, and of /etc only what a program needs to run (users, libraries,
// certificates, time zone), with a hosts file naming only the loopback;
// - the Workspace and Writable directories read-write, the ReadOnly ones (the toolchain, the modules the fetch step
// downloaded) read-only;
// - the shared warm Caches read-only under a throwaway writable layer (S18), or read-write for a trunk build alone
// (WriteCaches), so a candidate's code never changes what a later build reads.
type Isolation struct {
Workspace string
Writable []string
ReadOnly []string
Caches []string
WriteCaches bool
Env []string
// Hosts is the file bound as /etc/hosts; Isolated writes one naming only the loopback.
Hosts string
}
// etcNeeded is what of /etc a program in the step may read: who the users are, where the libraries are, the
// certificates, the time zone. Not resolv.conf, not hosts: the step resolves no name.
var etcNeeded = []string{"/etc/passwd", "/etc/group", "/etc/nsswitch.conf", "/etc/ld.so.cache", "/etc/ld.so.conf",
"/etc/ld.so.conf.d", "/etc/ssl", "/etc/ca-certificates", "/etc/pki", "/etc/localtime", "/etc/os-release", "/etc/alternatives"}
// systemArgs binds the system's programs and libraries read-only: /usr, and /bin, /sbin, /lib, /lib64 as the symlinks
// they are on a merged-usr system, or bound when they are directories. A variable so a unit test fixes it.
var systemArgs = func() []string {
args := []string{"--ro-bind", "/usr", "/usr"}
for _, d := range []string{"/bin", "/sbin", "/lib", "/lib64"} {
info, err := os.Lstat(d)
switch {
case err != nil:
case info.Mode()&os.ModeSymlink != 0:
if target, err := os.Readlink(d); err == nil {
args = append(args, "--symlink", target, d)
}
case info.IsDir():
args = append(args, "--ro-bind", d, d)
}
}
for _, e := range etcNeeded {
args = append(args, "--ro-bind-try", e, e)
}
return args
}
// stepHome is the HOME of a step that is given none: inside its own /tmp, gone with it.
const stepHome = "/tmp/home"
func checkDir(what, dir string) error {
if dir == "" || !strings.HasPrefix(dir, "/") || path.Clean(dir) != dir || dir == "/" {
return fmt.Errorf("buildstep: the %s %q is not an absolute, clean directory other than the root", what, dir)
}
return nil
}
// Command is the bubblewrap command line that runs name with args shut off as iso says, env added to iso.Env. It
// refuses a directory that is not absolute and clean, or is the root, and an environment word without "=".
func (iso Isolation) Command(name string, args ...string) ([]string, error) {
return iso.command(nil, name, args...)
}
func (iso Isolation) command(env []string, name string, args ...string) ([]string, error) {
if err := checkDir("workspace", iso.Workspace); err != nil {
return nil, err
}
for _, group := range []struct {
what string
dirs []string
}{{"writable directory", iso.Writable}, {"read-only directory", iso.ReadOnly}, {"cache", iso.Caches}} {
for _, d := range group.dirs {
if err := checkDir(group.what, d); err != nil {
return nil, err
}
}
}
words := append(append([]string{}, iso.Env...), env...)
home := false
for _, w := range words {
k, _, ok := strings.Cut(w, "=")
if !ok || k == "" {
return nil, fmt.Errorf("buildstep: the environment word %q is not NAME=value", w)
}
home = home || k == "HOME"
}
argv := []string{"--unshare-all", "--die-with-parent", "--new-session", "--clearenv",
"--proc", "/proc", "--dev", "/dev", "--tmpfs", "/tmp", "--dir", stepHome}
argv = append(argv, systemArgs()...)
for _, d := range iso.ReadOnly {
argv = append(argv, "--ro-bind", d, d)
}
argv = append(argv, cacheArgs(iso.Caches, iso.WriteCaches)...)
argv = append(argv, "--bind", iso.Workspace, iso.Workspace)
for _, d := range iso.Writable {
argv = append(argv, "--bind", d, d)
}
if iso.Hosts != "" {
argv = append(argv, "--ro-bind", iso.Hosts, "/etc/hosts")
}
for _, w := range words {
k, v, _ := strings.Cut(w, "=")
argv = append(argv, "--setenv", k, v)
}
if !home {
argv = append(argv, "--setenv", "HOME", stepHome)
}
argv = append(argv, "--chdir", iso.Workspace, "--", name)
return append(argv, args...), nil
}
// cacheArgs binds the shared warm caches (S18): for a trunk build read-write, so the next build is warm; for any other
// read-only beneath an overlay whose writes go to a tmpfs of the step's own, so the step writes as a build does and
// nothing it wrote outlives it (bubblewrap's --tmp-overlay, an overlay mount in the step's user namespace).
func cacheArgs(caches []string, write bool) []string {
var argv []string
for _, c := range caches {
if write {
argv = append(argv, "--bind", c, c)
continue
}
argv = append(argv, "--overlay-src", c, "--tmp-overlay", c)
}
return argv
}
// loopbackHosts is the hosts file of a step: the loopback and nothing else.
const loopbackHosts = "127.0.0.1 localhost\n::1 localhost\n"
// Isolated is run with every command shut off as iso says (Command), run's env going inside the step: the build modules'
// run steps call their test, build and digest commands through it, never run itself. Each command gets a hosts file of
// its own, naming only the loopback, removed when it ends.
func Isolated(run forgekey.Runner, iso Isolation) forgekey.Runner {
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
dir, err := os.MkdirTemp("", "step-hosts-")
if err != nil {
return forgekey.Ran{Status: 1, Err: "the step's hosts file: " + err.Error()}
}
defer os.RemoveAll(dir)
hosts := filepath.Join(dir, "hosts")
if err := os.WriteFile(hosts, []byte(loopbackHosts), 0o644); err != nil {
return forgekey.Ran{Status: 1, Err: "the step's hosts file: " + err.Error()}
}
iso := iso
iso.Hosts = hosts
argv, err := iso.command(env, name, args...)
if err != nil {
return forgekey.Ran{Status: 1, Err: err.Error()}
}
return run(ctx, nil, Bubblewrap, argv...)
}
}
+30
View File
@@ -0,0 +1,30 @@
package buildstep
import "testing"
// The shared warm caches are read under a throwaway writable layer (novox/hq ADR 0448; issue 1309): a candidate's step
// writes into them as a build does, and nothing it writes outlives it or reaches a later build; only a trunk build
// writes them.
func TestACandidatesStepWritesTheCachesOnlyIntoAThrowawayLayer(t *testing.T) {
fixedSystem(t)
iso := Isolation{Workspace: "/w", Caches: []string{"/var/cache/go-build", "/var/cache/npm"}}
argv, err := iso.Command("go", "test")
if err != nil {
t.Fatal(err)
}
for _, c := range iso.Caches {
if !holds(argv, "--overlay-src", c, "--tmp-overlay", c) {
t.Errorf("the cache %s is not under a throwaway layer: %v", c, argv)
}
if holds(argv, "--bind", c, c) {
t.Errorf("a candidate's step writes the cache %s: %v", c, argv)
}
}
iso.WriteCaches = true
argv, _ = iso.Command("go", "build")
for _, c := range iso.Caches {
if !holds(argv, "--bind", c, c) || holds(argv, "--tmp-overlay", c) {
t.Errorf("a trunk build does not write the cache %s: %v", c, argv)
}
}
}
+66
View File
@@ -0,0 +1,66 @@
//go:build integration && isolation
package buildstep
import (
"context"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 1309/S19, the issue's proof: a run step, in bubblewrap as Isolated runs it, reads no key and no login, resolves no
// name, reaches no TCP or unix socket of the machine, finds no home of the account, and leaves the shared cache it
// wrote unchanged; the workspace it writes keeps what it wrote. It needs bubblewrap and unprivileged user namespaces
// (issue 1308) on the machine that runs it, and fails naming them when they are not there.
func TestARunStepReachesNothingAndLeavesTheCachesUnchanged(t *testing.T) {
if _, err := exec.LookPath(Bubblewrap); err != nil {
t.Fatalf("bubblewrap is not installed here, so the isolation of a run step cannot be proved (issue 1309): %v", err)
}
root := t.TempDir()
account := filepath.Join(root, "account")
key, login := filepath.Join(account, ".ssh", "id_ed25519"), filepath.Join(account, "bus-login.json")
writeFile(t, filepath.Dir(key), filepath.Base(key), "PRIVATE KEY")
writeFile(t, account, "bus-login.json", `{"password":"secret"}`)
cacheDir := filepath.Join(root, "cache")
writeFile(t, cacheDir, "warm", "warm\n")
workspace := filepath.Join(root, "work", "tree")
if err := os.MkdirAll(workspace, 0o755); err != nil {
t.Fatal(err)
}
probe := filepath.Join(workspace, "probe")
if out, err := exec.Command("go", "build", "-o", probe, "./testdata/isolated-probe").CombinedOutput(); err != nil {
t.Fatalf("building the probe: %v\n%s", err, out)
}
tcp, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer tcp.Close()
socket := filepath.Join(account, "bus.sock")
unix, err := net.Listen("unix", socket)
if err != nil {
t.Fatal(err)
}
defer unix.Close()
run := Isolated(forgekey.ExecRunnerFor(0), Isolation{Workspace: workspace, Caches: []string{cacheDir}})
r := run(context.Background(), []string{"PATH=/usr/bin:/bin"}, probe, key, login, tcp.Addr().String(), socket, "localhost.localdomain.invalid", filepath.Join(cacheDir, "warm"))
if r.Status != 0 || r.Err != "" || strings.TrimSpace(r.Stdout) != "shut off" {
t.Fatalf("the run step was not shut off (status %d, %s):\n%s%s", r.Status, r.Err, r.Stdout, r.Stderr)
}
if b, _ := os.ReadFile(filepath.Join(cacheDir, "warm")); string(b) != "warm\n" {
t.Fatalf("the step changed the shared cache: %q", b)
}
// The workspace is the step's own: what it writes there stays.
r = run(context.Background(), nil, "/bin/sh", "-c", "echo built > out")
if b, err := os.ReadFile(filepath.Join(workspace, "out")); r.Status != 0 || err != nil || string(b) != "built\n" {
t.Fatalf("the step's write to its workspace did not stay (status %d, %s%s): %q %v", r.Status, r.Stderr, r.Err, b, err)
}
}
+115
View File
@@ -0,0 +1,115 @@
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// pairs says whether argv holds the words of want one after another.
func holds(argv []string, want ...string) bool {
for i := 0; i+len(want) <= len(argv); i++ {
match := true
for j, w := range want {
if argv[i+j] != w {
match = false
break
}
}
if match {
return true
}
}
return false
}
func fixedSystem(t *testing.T) {
t.Helper()
before := systemArgs
systemArgs = func() []string { return []string{"--ro-bind", "/usr", "/usr", "--symlink", "usr/bin", "/bin"} }
t.Cleanup(func() { systemArgs = before })
}
// A run step's command runs in bubblewrap (novox/hq ADR 0448; issue 1309): no network, no namespace shared, an
// environment of exactly what the step is given, the workspace the one tree it writes, the toolchain read-only, and
// nothing else of the machine's: not the account's home, not the forge key or a bus login, not the runtime directory,
// and no name service.
func TestARunStepRunsShutOffInBubblewrap(t *testing.T) {
fixedSystem(t)
iso := Isolation{Workspace: "/work/ab12/tree", ReadOnly: []string{"/usr/lib/go"}, Writable: []string{"/work/ab12/out"},
Env: []string{"GOFLAGS=-mod=mod", "PATH=/usr/bin"}, Hosts: "/work/ab12/hosts"}
argv, err := iso.Command("go", "test", "./...")
if err != nil {
t.Fatal(err)
}
for _, want := range [][]string{
{"--unshare-all"}, {"--die-with-parent"}, {"--new-session"}, {"--clearenv"},
{"--proc", "/proc"}, {"--dev", "/dev"}, {"--tmpfs", "/tmp"},
{"--ro-bind", "/usr", "/usr"}, {"--symlink", "usr/bin", "/bin"},
{"--ro-bind", "/usr/lib/go", "/usr/lib/go"},
{"--bind", "/work/ab12/tree", "/work/ab12/tree"}, {"--bind", "/work/ab12/out", "/work/ab12/out"},
{"--ro-bind", "/work/ab12/hosts", "/etc/hosts"},
{"--setenv", "GOFLAGS", "-mod=mod"}, {"--setenv", "PATH", "/usr/bin"}, {"--setenv", "HOME", "/tmp/home"},
{"--chdir", "/work/ab12/tree"},
{"--", "go", "test", "./..."},
} {
if !holds(argv, want...) {
t.Errorf("the command has no %v: %v", want, argv)
}
}
if holds(argv, "--share-net") || strings.Contains(strings.Join(argv, " "), "resolv.conf") || strings.Contains(strings.Join(argv, " "), "/run") {
t.Errorf("the command reaches the network, the name service or the runtime directory: %v", argv)
}
// The workspace is mounted after /tmp's tmpfs, so a workspace under /tmp is not hidden by it.
if strings.Index(strings.Join(argv, "\x00"), "--tmpfs\x00/tmp") > strings.Index(strings.Join(argv, "\x00"), "--bind\x00/work/ab12/tree") {
t.Errorf("the tmpfs of /tmp comes after the workspace: %v", argv)
}
// A HOME the step is given is its own.
iso.Env = append(iso.Env, "HOME=/work/ab12/home")
argv, _ = iso.Command("true")
if holds(argv, "--setenv", "HOME", "/tmp/home") || !holds(argv, "--setenv", "HOME", "/work/ab12/home") {
t.Errorf("the step's own HOME was replaced: %v", argv)
}
}
func TestAnIsolationNamesNoPathItCannotPin(t *testing.T) {
for _, iso := range []Isolation{
{Workspace: ""},
{Workspace: "tree"},
{Workspace: "/"},
{Workspace: "/w", ReadOnly: []string{"relative"}},
{Workspace: "/w", Writable: []string{"/"}},
{Workspace: "/w", Caches: []string{"/a/../b"}},
{Workspace: "/w", Env: []string{"NOEQUALS"}},
} {
if _, err := iso.Command("true"); err == nil {
t.Errorf("%+v was accepted", iso)
}
}
}
// Isolated runs every command through bwrap, with the step's environment inside and a hosts file that names only the
// loopback, removed after.
func TestIsolatedRunsEachCommandThroughBubblewrap(t *testing.T) {
fixedSystem(t)
var ran []string
var hosts string
inner := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
ran = append([]string{name}, args...)
for i, a := range args {
if a == "/etc/hosts" && i > 0 {
hosts = args[i-1]
}
}
return forgekey.Ran{Stdout: "ok"}
}
r := Isolated(inner, Isolation{Workspace: "/w"})(context.Background(), []string{"A=1"}, "go", "build")
if r.Stdout != "ok" || ran[0] != Bubblewrap || !holds(ran, "--setenv", "A", "1") || !holds(ran, "--", "go", "build") {
t.Fatalf("ran %v: %+v", ran, r)
}
if hosts == "" {
t.Fatalf("no hosts file was bound: %v", ran)
}
}
+24
View File
@@ -0,0 +1,24 @@
package buildstep
import "strings"
// LauncherSuffix is what a TypeScript entrypoint's launcher is called beside it: index.js is started as
// index.serve.mjs (novox/hq ADR 0193), an ES module by its own extension whatever the bundle's package.json says.
const LauncherSuffix = ".serve.mjs"
// Launchers are the launchers a bundle's build writes, by entrypoint, relative to the bundle's root: for TypeScript
// every entrypoint `x.js` is started by `x.serve.mjs` beside it; an entrypoint that is not JavaScript and every other
// language, whose build is already executable, get none. The one rule mesh-build-node, build-agent and the controller
// share (R4e), so the name a build writes is the name a reader expects.
func Launchers(language string, entrypoints []string) map[string]string {
out := map[string]string{}
if language != "typescript" {
return out
}
for _, entry := range entrypoints {
if strings.HasSuffix(entry, ".js") {
out[entry] = strings.TrimSuffix(entry, ".js") + LauncherSuffix
}
}
return out
}
+22
View File
@@ -0,0 +1,22 @@
package buildstep
import (
"reflect"
"testing"
)
// A TypeScript bundle's entrypoint x.js is started as x.serve.mjs beside it (novox/hq ADR 0193), the rule mesh-build-node
// and build-agent both apply; an entrypoint that is not .js gets none, and another language gets none.
func TestATypeScriptEntrypointIsStartedByTheLauncherBesideIt(t *testing.T) {
got := Launchers("typescript", []string{"index.js", "tools/x.js", "data.json"})
want := map[string]string{"index.js": "index.serve.mjs", "tools/x.js": "tools/x.serve.mjs"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("Launchers(typescript) = %v, want %v", got, want)
}
if got := Launchers("go", []string{"index.js"}); len(got) != 0 {
t.Fatalf("a Go bundle got launchers: %v", got)
}
if got := Launchers("typescript", nil); len(got) != 0 {
t.Fatalf("a bundle with no entrypoint got launchers: %v", got)
}
}
+128
View File
@@ -0,0 +1,128 @@
package buildstep
import (
"context"
"fmt"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// MergeResult is what MergeInto made of a head and a trunk commit: the trunk commit (Base) and the head, whole, and the
// merge commit and the paths the merge changes against the trunk commit; or, when the head does not merge cleanly, the
// files in conflict, with Merge and Changed empty.
type MergeResult struct {
Base, Head, Merge string
Conflicts []string
Changed []string
// Programs are the changed files that are compiled programs (ExecutableKind of their first bytes, read in the workspace
// at the merge); never nil for a clean merge, so a caller tells none from a module that reports none (issue 1577).
Programs []Program
}
// MergeInto makes, in the workspace dir that FetchCommit left at commit, the merge of commit into the trunk commit
// base, as the forge's default merge would (a merge commit, never a fast-forward), and leaves the workspace at the merge
// (novox/hq ADR 0484 clause 3, ADR 0323). It is the one merge the build modules' fetch steps make, with the identity
// and flags of the controller's check merge (mesh-controller internal/builder/check_merge.go): a name nobody owns, no
// signature, no hook, nothing of the machine's git configuration that changes a merge. Nothing is pushed.
//
// A merge needs both histories, so it makes the fetch whole (the shallow clone FetchCommit made is deepened) and
// fetches base by its sha. A head that does not merge cleanly is an answer, not an error: the files in conflict, the
// merge undone and the workspace left at base. A head already in base answers a Merge equal to Base and no changed
// paths. An error is that the merge could not be tried: a base or commit that is not a full sha, no base, a workspace
// not at commit, or a fetch or git command that failed. remote and the options are those of FetchCommit; the runner's
// bound must allow a whole fetch.
func MergeInto(ctx context.Context, run forgekey.Runner, dir, remote, base, commit string, opts ...Option) (MergeResult, error) {
var o fetchOptions
for _, opt := range opts {
opt(&o)
}
res := MergeResult{Base: base, Head: commit}
if base == "" {
return res, fmt.Errorf("buildstep: no base: a merge needs the trunk commit it is made onto")
}
if err := checkFetch(o, dir, remote, base, ""); err != nil {
return res, err
}
if err := checkFetch(o, dir, remote, commit, ""); err != nil {
return res, err
}
if err := useMirror(o, dir); err != nil {
return res, err
}
env := []string{"GIT_TERMINAL_PROMPT=0"}
git := func(args ...string) (string, error) {
full := append([]string{"-c", "credential.helper=", "-C", dir}, args...)
r := run(ctx, env, "git", full...)
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("git %s: %s", args[0], forgekey.Said(r.Err+"\n"+r.Stderr))
}
return r.Stdout, nil
}
at, err := git("rev-parse", "HEAD")
if err != nil {
return res, err
}
if head := strings.TrimSpace(at); head != commit {
return res, fmt.Errorf("buildstep: the workspace is at %s, not at the commit %s", head, commit)
}
shallow, err := git("rev-parse", "--is-shallow-repository")
if err != nil {
return res, err
}
fetch := []string{"fetch", "--no-tags", "origin", base}
if strings.TrimSpace(shallow) == "true" {
fetch = []string{"fetch", "--no-tags", "--unshallow", "origin", base}
}
if _, err := git(fetch...); err != nil {
return res, fmt.Errorf("the trunk commit %s: %w", base, err)
}
if _, err := git("checkout", "-q", "--detach", base); err != nil {
return res, fmt.Errorf("the trunk commit %s cannot be checked out: %w", base, err)
}
message := fmt.Sprintf("Check the merge of %s into %s (made for a check, never pushed)", short(commit), short(base))
args := append(append([]string{}, mergeIdentity...), "merge", "--no-ff", "--no-edit", "--no-verify", "--quiet", "-m", message, commit)
if _, err := git(args...); err != nil {
out, _ := git("diff", "--name-only", "--diff-filter=U")
res.Conflicts = strings.Fields(out)
_, _ = git("merge", "--abort")
if len(res.Conflicts) == 0 {
return res, fmt.Errorf("the head %s could not be merged into the trunk commit %s: %w", short(commit), short(base), err)
}
return res, nil
}
merge, err := git("rev-parse", "HEAD")
if err != nil {
return res, fmt.Errorf("the merge cannot be read: %w", err)
}
res.Merge = strings.TrimSpace(merge)
changed, err := git("diff", "--name-only", "--no-renames", base, res.Merge)
if err != nil {
return res, fmt.Errorf("the paths the merge changes cannot be read: %w", err)
}
res.Changed = strings.Fields(changed)
raw, err := git("diff", "--raw", "-z", "--no-renames", "--diff-filter=AM", base, res.Merge)
if err != nil {
return res, fmt.Errorf("the files the merge adds or modifies cannot be read: %w", err)
}
res.Programs = programsIn(dir, raw)
history, err := historyPrograms(git, base, commit, res.Programs)
if err != nil {
return res, fmt.Errorf("the commits of the branch cannot be read for compiled programs: %w", err)
}
res.Programs = append(res.Programs, history...)
return res, nil
}
// mergeIdentity is who a check's merge is made as: it is never pushed, so it names nobody's account. No hook of the
// clone's or the build machine's git configuration runs, and no setting there changes the merge (the controller's
// check merge, internal/builder/check_merge.go).
var mergeIdentity = []string{"-c", "user.name=mesh merge check", "-c", "user.email=merge-check@mesh.invalid",
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", "-c", "merge.verifySignatures=false"}
func short(commit string) string {
if len(commit) > 12 {
return commit[:12]
}
return commit
}
+199
View File
@@ -0,0 +1,199 @@
//go:build integration
package buildstep
import (
"context"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// The merge on real git repositories starts git processes, so it is an integration test (the unit level keeps its bound,
// issue 895); the unit tests of merge_test.go prove the commands and the refusals with a fake runner.
func realGit(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
func writeFile(t *testing.T, dir, name, content string) {
t.Helper()
p := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// mergeRig is a local bare repository standing in for the forge, a work repository that pushes to it, and an empty
// workspace: the fetch step's shape (FetchCommit of the head into the workspace, then MergeInto).
type mergeRig struct {
t *testing.T
bare, work, ws string
fork, trunk string // the commit both histories share, and the trunk's head
remote string
}
func newMergeRig(t *testing.T) *mergeRig {
root := t.TempDir()
r := &mergeRig{t: t, bare: filepath.Join(root, "r.git"), work: filepath.Join(root, "work"), ws: filepath.Join(root, "ws")}
for _, d := range []string{r.bare, r.work, r.ws} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
r.remote = "file://" + r.bare
realGit(t, r.bare, "init", "-q", "--bare")
realGit(t, r.work, "init", "-q", "-b", "main")
writeFile(t, r.work, "a.txt", "one\ntwo\nthree\n")
writeFile(t, r.work, "modules/m/module.json", "{}\n")
realGit(t, r.work, "add", ".")
realGit(t, r.work, "commit", "-q", "-m", "fork point")
r.fork = realGit(t, r.work, "rev-parse", "HEAD")
return r
}
// head makes a commit on a branch off the fork point, pushes it as the pull request's head ref and answers its sha.
func (r *mergeRig) head(files map[string]string) string {
realGit(r.t, r.work, "checkout", "-q", "-B", "pr", r.fork)
for n, c := range files {
writeFile(r.t, r.work, n, c)
}
realGit(r.t, r.work, "add", ".")
realGit(r.t, r.work, "commit", "-q", "-m", "head")
h := realGit(r.t, r.work, "rev-parse", "HEAD")
realGit(r.t, r.work, "push", "-q", r.bare, "HEAD:refs/pull/1/head")
return h
}
// headThatAddsAndRemoves is head with an earlier commit on the branch that adds a program and a later one that removes it: the
// branch's tree is clean, its history is not. It answers the head and the earlier commit.
func (r *mergeRig) headThatAddsAndRemoves(files map[string]string) (head, added string) {
realGit(r.t, r.work, "checkout", "-q", "-B", "pr", r.fork)
writeFile(r.t, r.work, "gone/prog", "\x7fELF\x02\x01")
realGit(r.t, r.work, "add", ".")
realGit(r.t, r.work, "commit", "-q", "-m", "oops")
added = realGit(r.t, r.work, "rev-parse", "HEAD")
realGit(r.t, r.work, "rm", "-q", "gone/prog")
return r.head2(files), added
}
// head2 commits files on the current branch and pushes it as the pull request's head.
func (r *mergeRig) head2(files map[string]string) string {
for n, c := range files {
writeFile(r.t, r.work, n, c)
}
realGit(r.t, r.work, "add", ".")
realGit(r.t, r.work, "commit", "-q", "-m", "head")
h := realGit(r.t, r.work, "rev-parse", "HEAD")
realGit(r.t, r.work, "push", "-q", r.bare, "HEAD:refs/pull/1/head")
return h
}
// advance moves the trunk past the fork point with a commit changing files, pushes it and answers its sha.
func (r *mergeRig) advance(files map[string]string) string {
realGit(r.t, r.work, "checkout", "-q", "-B", "main", r.fork)
for n, c := range files {
writeFile(r.t, r.work, n, c)
}
realGit(r.t, r.work, "add", ".")
realGit(r.t, r.work, "commit", "-q", "-m", "trunk moves")
r.trunk = realGit(r.t, r.work, "rev-parse", "HEAD")
realGit(r.t, r.work, "push", "-q", "-f", r.bare, "HEAD:refs/heads/main")
return r.trunk
}
func (r *mergeRig) fetch(head string) {
if err := FetchCommit(context.Background(), forgekey.ExecRunner, r.ws, r.remote, head, "refs/pull/1/head", AllowFileRemote()); err != nil {
r.t.Fatal(err)
}
}
// 1674/AC1: a clean merge answers the merge commit and the changed files, and leaves the workspace at the merge, which
// holds both sides' changes.
func TestMergeIntoACleanMergeAnswersTheMergeCommitAndTheChangedFiles(t *testing.T) {
r := newMergeRig(t)
head, added := r.headThatAddsAndRemoves(map[string]string{"b.txt": "head\n", "prog": "\x7fELF\x02\x01"})
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
r.fetch(head)
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
if err != nil {
t.Fatal(err)
}
if res.Base != base || res.Head != head || len(res.Merge) != 40 || res.Merge == base || res.Merge == head || len(res.Conflicts) != 0 {
t.Fatalf("result = %+v", res)
}
if !reflect.DeepEqual(res.Changed, []string{"b.txt", "prog"}) {
t.Errorf("changed = %v, want only what the head adds to the trunk: b.txt, prog", res.Changed)
}
if !reflect.DeepEqual(res.Programs, []Program{{"prog", "ELF", ""}, {"gone/prog", "ELF", added}}) { // issue 1577
t.Errorf("programs = %v, want the ELF the head adds and the one an earlier commit added and a later one removed", res.Programs)
}
if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != res.Merge {
t.Errorf("the workspace is at %s, not at the merge %s", at, res.Merge)
}
for f, want := range map[string]string{"b.txt": "head\n", "a.txt": "one\ntwo\nthree\nfour\n"} {
if got, _ := os.ReadFile(filepath.Join(r.ws, f)); string(got) != want {
t.Errorf("%s = %q, want %q", f, got, want)
}
}
if parents := strings.Fields(realGit(t, r.ws, "rev-list", "--parents", "-n", "1", res.Merge)); len(parents) != 3 || parents[1] != base || parents[2] != head {
t.Errorf("the merge commit's parents = %v, want the trunk commit and the head", parents)
}
if author := realGit(t, r.ws, "log", "-1", "--format=%an <%ae>", res.Merge); author != "mesh merge check <merge-check@mesh.invalid>" {
t.Errorf("the merge is made as %q", author)
}
}
// 1674/AC2: a conflict is an answer: the files in conflict, no merge commit, and the workspace left at the trunk commit.
func TestMergeIntoAConflictAnswersTheFilesAndLeavesTheTreeAtTheTrunk(t *testing.T) {
r := newMergeRig(t)
head := r.head(map[string]string{"a.txt": "one\nHEAD\nthree\n", "c.txt": "c\n"})
base := r.advance(map[string]string{"a.txt": "one\nTRUNK\nthree\n"})
r.fetch(head)
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
if err != nil {
t.Fatalf("a conflict is an answer, not an error: %v", err)
}
if !reflect.DeepEqual(res.Conflicts, []string{"a.txt"}) || res.Merge != "" || len(res.Changed) != 0 || res.Base != base || res.Head != head {
t.Fatalf("result = %+v", res)
}
if at := realGit(t, r.ws, "rev-parse", "HEAD"); at != base {
t.Errorf("the workspace is at %s, not at the trunk commit %s", at, base)
}
if got, _ := os.ReadFile(filepath.Join(r.ws, "a.txt")); string(got) != "one\nTRUNK\nthree\n" {
t.Errorf("a.txt = %q, want the trunk's", got)
}
if st := realGit(t, r.ws, "status", "--porcelain"); st != "" {
t.Errorf("the tree is not clean after the aborted merge: %q", st)
}
if _, err := os.Stat(filepath.Join(r.ws, "c.txt")); err == nil {
t.Error("a file of the head is in a tree left at the trunk")
}
}
// A head already in the trunk merges into nothing: the merge is the trunk commit and no path changes.
func TestMergeIntoAHeadAlreadyInTheTrunkChangesNothing(t *testing.T) {
r := newMergeRig(t)
head := r.head(map[string]string{"b.txt": "head\n"})
r.fork = head // the trunk continues from the head
base := r.advance(map[string]string{"a.txt": "one\ntwo\nthree\nfour\n"})
r.fetch(head)
res, err := MergeInto(context.Background(), forgekey.ExecRunner, r.ws, r.remote, base, head, AllowFileRemote())
if err != nil || res.Merge != base || len(res.Changed) != 0 || len(res.Conflicts) != 0 {
t.Errorf("result = %+v, %v", res, err)
}
}
+74
View File
@@ -0,0 +1,74 @@
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 1674/AC3: no base is refused before anything runs; so is a base or a commit that is not a full sha, a workspace that
// is not at the commit, and a remote that is not ssh://.
func TestMergeIntoRefusesWhatIsNotAMerge(t *testing.T) {
f := &fakeGit{}
for name, c := range map[string]struct{ remote, base, commit string }{
"no base": {"ssh://mesh-forge/o/r.git", "", sha},
"a short base": {"ssh://mesh-forge/o/r.git", "abc123", sha},
"a branch as base": {"ssh://mesh-forge/o/r.git", "main", sha},
"a short commit": {"ssh://mesh-forge/o/r.git", sha, "abc"},
"an https remote": {"https://forge.example/o/r.git", sha, sha},
} {
_, err := MergeInto(context.Background(), f.run, "/w", c.remote, c.base, c.commit)
if err == nil {
t.Errorf("%s accepted", name)
}
}
if len(f.calls) != 0 {
t.Errorf("git ran for a refused merge: %v", f.calls)
}
g := &fakeGit{rule: func(l string) forgekey.Ran {
if strings.Contains(l, "rev-parse HEAD") {
return forgekey.Ran{Stdout: strings.Repeat("f", 40) + "\n"}
}
return forgekey.Ran{}
}}
_, err := MergeInto(context.Background(), g.run, "/w", "ssh://mesh-forge/o/r.git", strings.Repeat("a", 40), sha)
if err == nil || !strings.Contains(err.Error(), "not at the commit") || len(g.calls) != 1 {
t.Errorf("a workspace at another commit: %v after %d commands", err, len(g.calls))
}
}
// 1674/AC4: the merge is made with the check merge's identity and flags, hooks off, and fetches with no credential.
func TestMergeIntoRunsTheCheckMergesCommands(t *testing.T) {
base := strings.Repeat("b", 40)
f := &fakeGit{rule: func(l string) forgekey.Ran {
switch {
case strings.HasSuffix(l, "rev-parse HEAD"):
return forgekey.Ran{Stdout: sha + "\n"}
case strings.Contains(l, "--is-shallow-repository"):
return forgekey.Ran{Stdout: "true\n"}
}
return forgekey.Ran{}
}}
if _, err := MergeInto(context.Background(), f.run, "/w", "ssh://mesh-forge/o/r.git", base, sha); err != nil {
t.Fatal(err)
}
all := strings.Join(f.calls, "\n")
for _, want := range []string{
pre + "fetch --no-tags --unshallow origin " + base,
pre + "checkout -q --detach " + base,
"user.name=mesh merge check", "user.email=merge-check@mesh.invalid", "commit.gpgsign=false", "core.hooksPath=/dev/null",
"merge --no-ff --no-edit --no-verify --quiet -m",
pre + "diff --name-only --no-renames " + base,
} {
if !strings.Contains(all, want) {
t.Errorf("the commands lack %q:\n%s", want, all)
}
}
for _, c := range f.calls {
if strings.Contains(c, "https://") || !strings.Contains(c, "credential.helper= ") {
t.Errorf("command %q carries a credential or https", c)
}
}
}
+65
View File
@@ -0,0 +1,65 @@
//go:build integration
package buildstep
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Issue 890 C1 (AC3): a mirror brought to two commits in turn; a fetch through it checks out each commit, the
// alternates name the mirror, and no symlink is in the workspace.
func TestMirrorServesTwoCommitsWithoutSymlinks(t *testing.T) {
root := t.TempDir()
src := filepath.Join(root, "src.git")
work := filepath.Join(root, "work")
for _, d := range []string{src, work} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
gitIn(t, src, "init", "-q", "--bare")
gitIn(t, work, "init", "-q")
var commits []string
for _, c := range []string{"one", "two"} {
if err := os.WriteFile(filepath.Join(work, "f"), []byte(c), 0o644); err != nil {
t.Fatal(err)
}
gitIn(t, work, "add", "f")
gitIn(t, work, "commit", "-q", "-m", c)
commits = append(commits, gitIn(t, work, "rev-parse", "HEAD"))
gitIn(t, work, "push", "-q", src, "HEAD:refs/heads/main")
}
remote := "file://" + src
mirror := filepath.Join(root, "mirror.git")
for i, commit := range commits {
if err := UpdateMirror(context.Background(), forgekey.ExecRunner, mirror, remote, commit, "", AllowFileRemote()); err != nil {
t.Fatal(err)
}
ws := filepath.Join(root, "ws"+string(rune('0'+i)))
if err := os.MkdirAll(ws, 0o755); err != nil {
t.Fatal(err)
}
if err := FetchCommit(context.Background(), forgekey.ExecRunner, ws, remote, commit, "", AllowFileRemote(), WithMirror(mirror)); err != nil {
t.Fatal(err)
}
if head := gitIn(t, ws, "rev-parse", "HEAD"); head != commit {
t.Errorf("HEAD = %s, want %s", head, commit)
}
b, _ := os.ReadFile(filepath.Join(ws, ".git", "objects", "info", "alternates"))
if !strings.Contains(string(b), filepath.Join(mirror, "objects")) {
t.Errorf("alternates %q", b)
}
_ = filepath.Walk(ws, func(p string, fi os.FileInfo, err error) error {
if err == nil && fi.Mode()&os.ModeSymlink != 0 {
t.Errorf("symlink %s", p)
}
return nil
})
}
}
+124
View File
@@ -0,0 +1,124 @@
package buildstep
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// Issue 890 C1 (ADR 0536 §2): a fetch through a bare mirror names the mirror in .git/objects/info/alternates, a
// plain file, and never links it.
func TestFetchCommitWithMirrorWritesAlternates(t *testing.T) {
mirror := t.TempDir()
ws := t.TempDir()
f := &fakeGit{}
// the fake's init does not create .git; make the objects directory as git init would.
if err := os.MkdirAll(filepath.Join(ws, ".git", "objects", "info"), 0o755); err != nil {
t.Fatal(err)
}
err := FetchCommit(context.Background(), f.run, ws, "ssh://mesh-forge/novox/mesh-sdk.git", sha, "", WithMirror(mirror))
if err != nil {
t.Fatal(err)
}
p := filepath.Join(ws, ".git", "objects", "info", "alternates")
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
if got, want := strings.TrimSpace(string(b)), filepath.Join(mirror, "objects"); got != want {
t.Errorf("alternates %q, want %q", got, want)
}
if fi, err := os.Lstat(p); err != nil || !fi.Mode().IsRegular() {
t.Errorf("alternates is not a plain file: %v %v", fi, err)
}
// the alternates exist before the fetch runs
if len(f.calls) < 3 || !strings.Contains(f.calls[2], "fetch") {
t.Errorf("calls %v", f.calls)
}
}
func TestWithMirrorRefusesARelativeOrMissingMirror(t *testing.T) {
for _, m := range []string{"rel/mirror", filepath.Join(t.TempDir(), "absent")} {
f := &fakeGit{}
err := FetchCommit(context.Background(), f.run, t.TempDir(), "ssh://mesh-forge/novox/x.git", sha, "", WithMirror(m))
if err == nil || len(f.calls) != 0 {
t.Errorf("mirror %q: err %v, calls %v", m, err, f.calls)
}
}
}
func TestUpdateMirrorInitsBareThenFetches(t *testing.T) {
mirror := filepath.Join(t.TempDir(), "mirror.git")
f := &fakeGit{}
if err := UpdateMirror(context.Background(), f.run, mirror, "ssh://mesh-forge/novox/x.git", sha, ""); err != nil {
t.Fatal(err)
}
pm := "git -c credential.helper= -C " + mirror + " "
want := []string{
"git -c credential.helper= init -q --bare " + mirror,
pm + "remote add origin ssh://mesh-forge/novox/x.git",
pm + "fetch --no-tags origin " + sha,
pm + "cat-file -e " + sha + "^{commit}",
}
if strings.Join(f.calls, "\n") != strings.Join(want, "\n") {
t.Errorf("commands:\n%s\nwant:\n%s", strings.Join(f.calls, "\n"), strings.Join(want, "\n"))
}
}
func TestUpdateMirrorKeepsAnExistingMirrorAndFallsBackToTheRef(t *testing.T) {
mirror := t.TempDir() // exists: no init
if err := os.WriteFile(filepath.Join(mirror, "HEAD"), []byte("ref: refs/heads/main\n"), 0o644); err != nil {
t.Fatal(err)
}
f := &fakeGit{}
f.rule = func(args string) (r forgekey.Ran) {
if strings.HasSuffix(args, "origin "+sha) {
r.Status = 1
r.Stderr = "not our ref"
}
return
}
if err := UpdateMirror(context.Background(), f.run, mirror, "ssh://mesh-forge/novox/x.git", sha, "refs/pull/3/head"); err != nil {
t.Fatal(err)
}
all := strings.Join(f.calls, "\n")
if strings.Contains(all, "init") || strings.Contains(all, "remote add") || !strings.Contains(all, "fetch --no-tags origin refs/pull/3/head") {
t.Errorf("commands:\n%s", all)
}
}
func TestUpdateMirrorRefusesBeforeRunning(t *testing.T) {
f := &fakeGit{}
for _, c := range []struct{ remote, commit string }{
{"https://x/y.git", sha}, {"ssh://u@h/o/r.git", sha}, {"ssh://h/o/r.git", "abc"},
} {
if err := UpdateMirror(context.Background(), f.run, t.TempDir(), c.remote, c.commit, ""); err == nil {
t.Errorf("%v accepted", c)
}
}
if len(f.calls) != 0 {
t.Errorf("ran %v", f.calls)
}
}
// The forge's refusal reaches the reason in git's own words: the mirror of a repository its account cannot read said
// only "git -C: error:" (2026-10-26), since git writes a bare "error:" line before the forge's message.
func TestUpdateMirrorSaysTheForgesRefusal(t *testing.T) {
f := &fakeGit{}
f.rule = func(args string) (r forgekey.Ran) {
if strings.Contains(args, "fetch") {
r.Status = 128
r.Stderr = "error: \nerror: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read o/r.\nerror: \nfatal: Could not read from remote repository.\n"
}
return
}
err := UpdateMirror(context.Background(), f.run, filepath.Join(t.TempDir(), "m.git"), "ssh://mesh-forge/o/r.git", sha, "")
if err == nil || !strings.Contains(err.Error(), "nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read o/r.") ||
!strings.Contains(err.Error(), "Could not read from remote repository") || !strings.HasPrefix(err.Error(), "git fetch: ") {
t.Errorf("err = %v", err)
}
}
+71
View File
@@ -0,0 +1,71 @@
package buildstep
import (
"fmt"
"os"
"path"
"path/filepath"
"strings"
)
// narrowKept are the files of a module's directory a narrowed build keeps beside its named paths: its manifest and
// its lock files, each an input of the module's key (ModuleTree's Manifest and Locks). package.json and go.mod are
// not kept: neither is in the named tree, so a build that needs one names it among its artifact's paths, which puts
// it in the key (novox/hq ADR 0499 clause 4).
var narrowKept = append([]string{"module.json"}, lockFiles...)
// Narrow leaves in the fetched tree only what a build of an artifact that names its paths may see (novox/hq ADR 0499
// clause 4): r.Dir's named paths, its manifest and lock files, and .git; everything else is removed, so a file outside
// them cannot change what is built without changing its key. A named path absent at the tree is an error naming it,
// and nothing is removed then. With no paths in r it changes nothing. Both build modules' fetch steps call it.
func Narrow(tree string, r FetchRequest) error {
if len(r.Paths) == 0 {
return nil
}
keep := map[string]bool{".git": true}
for _, p := range r.Paths {
rel := path.Join(r.Dir, p)
if strings.HasPrefix(rel, "../") || rel == ".." || path.IsAbs(p) {
return fmt.Errorf("the named path %q leaves the module's directory", p)
}
if _, err := os.Lstat(filepath.Join(tree, filepath.FromSlash(rel))); err != nil {
return fmt.Errorf("the named path %q is not in the fetched tree: %w", p, err)
}
keep[rel] = true
}
for _, f := range narrowKept {
keep[path.Join(r.Dir, f)] = true
}
return prune(tree, "", keep)
}
// prune removes from dir (rel within the tree) every entry that is neither kept nor on the way to one.
func prune(root, rel string, keep map[string]bool) error {
entries, err := os.ReadDir(filepath.Join(root, filepath.FromSlash(rel)))
if err != nil {
return err
}
for _, e := range entries {
p := path.Join(rel, e.Name())
if keep[p] {
continue
}
ancestor := false
for k := range keep {
if strings.HasPrefix(k, p+"/") {
ancestor = true
break
}
}
if ancestor && e.IsDir() {
if err := prune(root, p, keep); err != nil {
return err
}
continue
}
if err := os.RemoveAll(filepath.Join(root, filepath.FromSlash(p))); err != nil {
return err
}
}
return nil
}
+82
View File
@@ -0,0 +1,82 @@
package buildstep
import (
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
func filesUnder(t *testing.T, root string) []string {
t.Helper()
var out []string
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
rel, _ := filepath.Rel(root, p)
out = append(out, filepath.ToSlash(rel))
}
return nil
})
sort.Strings(out)
return out
}
// A build of an artifact that names its paths sees only those paths, its manifest and its lock files (novox/hq ADR 0499
// clause 4): Narrow removes everything else of the fetched tree but .git, package.json and go.mod among it, since
// neither is in the named tree nor among the lock files; with no paths it changes nothing.
func TestNarrowLeavesOnlyTheNamedPathsTheManifestAndTheLocks(t *testing.T) {
tree := t.TempDir()
for _, f := range []string{".git/HEAD", "module.json", "package.json", "package-lock.json", "go.sum", "src/index.ts",
"src/deep/a.ts", "test/a.test.ts", "tsconfig.json", "go/delivery/x.go", "README.md", "other/module.json"} {
writeTreeFile(t, tree, f, "x")
}
if err := Narrow(tree, FetchRequest{}); err != nil {
t.Fatal(err)
}
if got := len(filesUnder(t, tree)); got != 12 {
t.Fatalf("a request without paths changed the tree: %d files", got)
}
if err := Narrow(tree, FetchRequest{Dir: ".", Paths: []string{"src", "test", "tsconfig.json"}}); err != nil {
t.Fatal(err)
}
want := ".git/HEAD,go.sum,module.json,package-lock.json,src/deep/a.ts,src/index.ts,test/a.test.ts,tsconfig.json"
if got := strings.Join(filesUnder(t, tree), ","); got != want {
t.Fatalf("the narrowed tree holds %s\nwant %s", got, want)
}
}
func TestNarrowKeepsAModuleDirectorysPathsAndFailsOnAnAbsentOne(t *testing.T) {
tree := t.TempDir()
for _, f := range []string{".git/config", "modules/a/module.json", "modules/a/go.mod", "modules/a/src/x.ts",
"modules/a/notes.md", "modules/b/module.json", "root.txt"} {
writeTreeFile(t, tree, f, "x")
}
err := Narrow(tree, FetchRequest{Dir: "modules/a", Paths: []string{"src", "gone"}})
if err == nil || !strings.Contains(err.Error(), `"gone"`) {
t.Fatalf("an absent named path gave %v", err)
}
if len(filesUnder(t, tree)) != 7 {
t.Fatal("a refused narrowing removed files")
}
if err := Narrow(tree, FetchRequest{Dir: "modules/a", Paths: []string{"src"}}); err != nil {
t.Fatal(err)
}
if got := strings.Join(filesUnder(t, tree), ","); got != ".git/config,modules/a/module.json,modules/a/src/x.ts" {
t.Fatalf("the narrowed tree holds %s", got)
}
}
// A build that needs package.json or go.mod names it among its paths, and so keys on it (ADR 0499 clause 4).
func TestNarrowKeepsAManifestOfTheLanguageOnlyWhenNamed(t *testing.T) {
tree := t.TempDir()
for _, f := range []string{"module.json", "package.json", "go.mod", "src/a.ts"} {
writeTreeFile(t, tree, f, "x")
}
if err := Narrow(tree, FetchRequest{Dir: ".", Paths: []string{"src", "package.json"}}); err != nil {
t.Fatal(err)
}
if got := strings.Join(filesUnder(t, tree), ","); got != "module.json,package.json,src/a.ts" {
t.Fatalf("the narrowed tree holds %s", got)
}
}
+100
View File
@@ -0,0 +1,100 @@
package buildstep
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"sort"
)
// Pack tars and gzips a directory the way the build agent does, byte for byte, and answers the archive and its
// digest, "sha256:" and the hex of the archive's bytes (issue 1479; novox/hq ADR 0405 §7, ADR 0453 §7). It is a
// copy of mesh-controller's internal/builder/builder.go pack (origin/main 073ccc29, :1351-1425), which stays
// where it is until the build agent goes: a build module's digest is compared with the build agent's for one
// fingerprint, so the bytes must be equal.
//
// Deterministically: regular files only (a symlink or a device is refused), sorted relative paths with
// slashes, each header carrying its name, permission bits and size and nothing else, the gzip default writer.
func Pack(root string) (archive []byte, digest string, err error) {
raw, err := pack(root)
if err != nil {
return nil, "", err
}
sum := sha256.Sum256(raw)
return raw, "sha256:" + hex.EncodeToString(sum[:]), nil
}
func pack(root string) ([]byte, error) {
info, err := os.Stat(root)
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, fmt.Errorf("%s is not a directory", root)
}
var paths []string
err = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() || !info.Mode().IsRegular() {
// Only files. A symlink or a device in an archive is refused by the host that unpacks
// it, so putting one in would build something that cannot be applied.
if !info.IsDir() && !info.Mode().IsRegular() {
return fmt.Errorf("%s is neither a file nor a directory, and an archive carries "+
"only those", path)
}
return nil
}
paths = append(paths, path)
return nil
})
if err != nil {
return nil, err
}
// filepath.Walk walks in lexical order; the sort is kept as the build agent keeps it, since an archive
// whose digest changes because the traversal did is a silent failure.
sort.Strings(paths)
var out bytes.Buffer
zipped := gzip.NewWriter(&out)
writer := tar.NewWriter(zipped)
for _, path := range paths {
body, err := os.ReadFile(path)
if err != nil {
return nil, err
}
relative, err := filepath.Rel(root, path)
if err != nil {
return nil, err
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
mode := int64(info.Mode().Perm())
if err := writer.WriteHeader(&tar.Header{
Name: filepath.ToSlash(relative), Mode: mode, Size: int64(len(body)),
Typeflag: tar.TypeReg,
// Everything else left at its zero value on purpose: no times, owners or names.
}); err != nil {
return nil, err
}
if _, err := writer.Write(body); err != nil {
return nil, err
}
}
if err := writer.Close(); err != nil {
return nil, err
}
if err := zipped.Close(); err != nil {
return nil, err
}
return out.Bytes(), nil
}
+84
View File
@@ -0,0 +1,84 @@
package buildstep
import (
"bytes"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"testing"
"time"
)
// fixture writes three files in two directories (modes 0644 and 0755).
func fixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
for _, f := range []struct {
p string
mode os.FileMode
body string
}{{"a/x.txt", 0o644, "x\n"}, {"a/run.sh", 0o755, "#!/bin/sh\necho hi\n"}, {"b/c/y.txt", 0o644, "why\n"}} {
path := filepath.Join(root, f.p)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(f.body), f.mode); err != nil {
t.Fatal(err)
}
if err := os.Chmod(path, f.mode); err != nil {
t.Fatal(err)
}
}
return root
}
// 1479/AC1: the tar stream inside the archive equals the bytes mesh-controller's pack makes for this tree.
// testdata/pack-golden.tar is the uncompressed stream, made once by running mesh-controller
// internal/builder/builder.go pack (origin/main 073ccc29, :1351-1425) on this fixture (above, modes set by chmod)
// and gunzipping its archive. The gzip layer is not compared byte for byte, since compress/gzip's output differs
// between Go versions; its header is checked to carry no name, no comment and no time.
func TestPackEqualsTheBuildAgentsArchive(t *testing.T) {
golden, err := os.ReadFile("testdata/pack-golden.tar")
if err != nil {
t.Fatal(err)
}
archive, digest, err := Pack(fixture(t))
if err != nil {
t.Fatal(err)
}
zr, err := gzip.NewReader(bytes.NewReader(archive))
if err != nil {
t.Fatal(err)
}
if zr.Name != "" || zr.Comment != "" || !zr.ModTime.IsZero() {
t.Errorf("gzip header = name %q, comment %q, time %v; want none", zr.Name, zr.Comment, zr.ModTime)
}
tarBytes, err := io.ReadAll(zr)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(tarBytes, golden) {
t.Errorf("the tar stream is %d bytes, the build agent's %d, and they differ", len(tarBytes), len(golden))
}
sum := sha256.Sum256(archive)
if digest != "sha256:"+hex.EncodeToString(sum[:]) {
t.Errorf("digest = %s, not the sha256 of the archive", digest)
}
}
// 1479/AC2: touching every file's time changes nothing.
func TestPackDigestIgnoresFileTimes(t *testing.T) {
root := fixture(t)
_, first, err := Pack(root)
if err != nil {
t.Fatal(err)
}
later := time.Now().Add(48 * time.Hour)
filepath.Walk(root, func(p string, _ os.FileInfo, _ error) error { return os.Chtimes(p, later, later) })
if _, second, err := Pack(root); err != nil || second != first {
t.Errorf("digests %s and %s (%v)", first, second, err)
}
}
+25
View File
@@ -0,0 +1,25 @@
//go:build unix
package buildstep
import (
"path/filepath"
"strings"
"syscall"
"testing"
)
// 1479/AC3: anything but a file or a directory is refused, naming it. The rule of this repository is that no
// test makes a symlink, so the non-regular entry is a named pipe: Pack refuses a symlink on the same branch
// (filepath.Walk reports it by lstat as not regular).
func TestPackRefusesAnEntryThatIsNotAFile(t *testing.T) {
root := fixture(t)
pipe := filepath.Join(root, "a", "pipe")
if err := syscall.Mkfifo(pipe, 0o644); err != nil {
t.Fatal(err)
}
_, _, err := Pack(root)
if err == nil || !strings.Contains(err.Error(), pipe) {
t.Errorf("Pack = %v, want a refusal naming %s", err, pipe)
}
}
+67
View File
@@ -0,0 +1,67 @@
package buildstep
import (
"context"
"strings"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **A running ask says its phases** (issue 1890, 2026-10-26 19:09: a mesh-catalog try's step on shanks ran 30 minutes
// and its module's journal held no line naming its ask, so nobody could tell whether it fetched, linted or tested). The
// puller hands every ask's run a phase reporter in its context; RunAsk and a module's own step bodies call Phase as they
// move (fetch and merge, a level's digest, lint, a level's tests), and each phase is logged with the ask's id and its
// time since the start, and written to the ask's running step key (delivery.Step.Phase), so its asker and the delivery
// view read where it stands from the bus.
type phaseKey struct{}
// Phase says that the ask whose run ctx belongs to entered phase; nothing outside an ask's run (a tool called by hand).
func Phase(ctx context.Context, phase string) {
if r, ok := ctx.Value(phaseKey{}).(*phases); ok && r != nil {
r.enter(strings.TrimSpace(phase))
}
}
// phases is one running ask's reporter: it writes the step key again with each new phase until the step ends.
type phases struct {
mu sync.Mutex
put Put
key string
step delivery.Step
ended bool
logf func(format string, args ...any)
prefix string
}
func (r *phases) enter(phase string) {
if phase == "" {
return
}
r.mu.Lock()
defer r.mu.Unlock()
if r.ended || phase == r.step.Phase {
return
}
at := now().UTC()
r.step.Phase, r.step.PhaseAt = phase, at
if r.logf != nil {
r.logf("%s: %s, %s after its start", r.prefix, phase, at.Sub(r.step.Started).Round(time.Second))
}
if r.put == nil {
return
}
if err := write(r.put, r.key, r.step); err != nil && r.logf != nil {
r.logf("%s: the phase %s could not be written to %s: %v", r.prefix, phase, r.key, err)
}
}
// end stops the writes (the step's end is written after it) and answers the step with its last phase, for the end.
func (r *phases) end() delivery.Step {
r.mu.Lock()
defer r.mu.Unlock()
r.ended = true
return r.step
}
+115
View File
@@ -0,0 +1,115 @@
package buildstep
import (
"bytes"
"io"
"os"
"path/filepath"
"strings"
)
// Program is a changed file whose first bytes are an executable binary: a compiled program, which is build output and not
// source (novox/hq issue 1577: `go build` in a module's folder writes one beside go.mod, and `git add -A` takes it).
type Program struct {
Path string `json:"path"`
Kind string `json:"kind"` // ELF, Mach-O or PE
// Commit is set for a program a commit of the branch added and a later one removed (or replaced): the merge's tree is clean,
// but the merge commit carries the blob into the trunk's history for good. Empty for a program the merge's tree holds.
Commit string `json:"commit,omitempty"`
}
// ExecutableKind says what executable format the first bytes of a file are, or "" when they are none. A PE needs its header
// ("PE\0\0" at the offset in bytes 0x3c) so a text starting with "MZ" is not one; a fat Mach-O (0xCAFEBABE) is told from a
// Java class by its architecture count (bytes 4 to 7, fewer than 0x20; a class has its version there).
func ExecutableKind(head []byte) string {
switch {
case bytes.HasPrefix(head, []byte("\x7fELF")):
return "ELF"
case bytes.HasPrefix(head, []byte("MZ")):
if len(head) >= 0x40 {
off := int(head[0x3c]) | int(head[0x3d])<<8 | int(head[0x3e])<<16 | int(head[0x3f])<<24
if off >= 0 && off+4 <= len(head) && bytes.Equal(head[off:off+4], []byte("PE\x00\x00")) {
return "PE"
}
}
case bytes.HasPrefix(head, []byte{0xcf, 0xfa, 0xed, 0xfe}), bytes.HasPrefix(head, []byte{0xce, 0xfa, 0xed, 0xfe}),
bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xcf}), bytes.HasPrefix(head, []byte{0xfe, 0xed, 0xfa, 0xce}),
bytes.HasPrefix(head, []byte{0xca, 0xfe, 0xba, 0xbe}) && len(head) >= 8 && head[4] == 0 && head[5] == 0 && head[6] == 0 && head[7] < 0x20:
return "Mach-O"
}
return ""
}
// programHeadSize is how many bytes of a file are read to tell its format: enough for a PE header at its usual offsets.
const programHeadSize = 0x100
// historyPrograms names the compiled programs that a commit of base..head added or modified and the merge's tree no longer
// holds at that path (those it holds are in final already): each with the commit that added it. git is the merge's git runner.
func historyPrograms(git func(args ...string) (string, error), base, head string, final []Program) ([]Program, error) {
revs, err := git("rev-list", "--no-merges", "--reverse", base+".."+head)
if err != nil {
return nil, err
}
held := map[string]bool{}
for _, p := range final {
held[p.Path] = true
}
out, seen := []Program{}, map[string]bool{}
for _, c := range strings.Fields(revs) {
raw, err := git("diff-tree", "-r", "-z", "--no-commit-id", "--no-renames", "--root", "--diff-filter=AM", c)
if err != nil {
return nil, err
}
f := strings.Split(raw, "\x00")
for i := 0; i+1 < len(f); i += 2 {
meta := strings.Fields(strings.TrimPrefix(f[i], ":"))
if len(meta) < 4 || (meta[1] != "100644" && meta[1] != "100755") || held[f[i+1]] || seen[meta[3]] {
continue
}
seen[meta[3]] = true
blob, err := git("cat-file", "blob", meta[3])
if err != nil {
return nil, err
}
head := []byte(blob)
if len(head) > programHeadSize {
head = head[:programHeadSize]
}
if k := ExecutableKind(head); k != "" {
out = append(out, Program{Path: f[i+1], Kind: k, Commit: c})
}
}
}
return out, nil
}
// programsIn reads the output of `git diff --raw -z --no-renames --diff-filter=AM` (":<old mode> <new mode> <old sha> <new
// sha> <status>\0<path>\0" per file) and names each added or modified regular file of the tree dir that is a compiled
// program, from its first bytes read locally. A symlink (mode 120000) and a submodule (160000) are not read. It never
// answers nil, so a caller can tell no programs from none reported.
func programsIn(dir, raw string) []Program {
out := []Program{}
f := strings.Split(raw, "\x00")
for i := 0; i+1 < len(f); i += 2 {
meta := strings.Fields(strings.TrimPrefix(f[i], ":"))
if len(meta) < 2 || (meta[1] != "100644" && meta[1] != "100755") {
continue
}
p := f[i+1]
full := filepath.Join(dir, filepath.FromSlash(p))
if st, err := os.Lstat(full); err != nil || !st.Mode().IsRegular() {
continue
}
fh, err := os.Open(full)
if err != nil {
continue
}
head := make([]byte, programHeadSize)
n, _ := io.ReadFull(fh, head)
fh.Close()
if k := ExecutableKind(head[:n]); k != "" {
out = append(out, Program{Path: p, Kind: k})
}
}
return out
}
+72
View File
@@ -0,0 +1,72 @@
package buildstep
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func pe(offset int, magic string) []byte {
b := make([]byte, 0x100)
copy(b, "MZ")
b[0x3c] = byte(offset)
copy(b[offset:], magic)
return b
}
func TestExecutableKindTellsProgramsFromText(t *testing.T) { // issue 1577
for _, c := range []struct {
name string
head []byte
want string
}{
{"elf", []byte("\x7fELF\x02\x01\x01"), "ELF"},
{"mach-o 64", []byte{0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1}, "Mach-O"},
{"mach-o 32 big", []byte{0xfe, 0xed, 0xfa, 0xce, 0, 0, 0, 7}, "Mach-O"},
{"fat mach-o", []byte{0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 2}, "Mach-O"},
{"java class", []byte{0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 0x34}, ""},
{"pe", pe(0x80, "PE\x00\x00"), "PE"},
{"mz text", []byte("MZ is a text file starting with two letters"), ""},
{"mz without a pe header", pe(0x80, "XX\x00\x00"), ""},
{"text", []byte("package main"), ""},
{"empty", nil, ""},
} {
if got := ExecutableKind(c.head); got != c.want {
t.Errorf("%s: kind = %q, want %q", c.name, got, c.want)
}
}
}
func TestProgramsInNamesTheAddedAndModifiedRegularFilesThatArePrograms(t *testing.T) { // issue 1577
dir := t.TempDir()
write := func(name string, b []byte) {
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), b, 0o755); err != nil {
t.Fatal(err)
}
}
write("m/prog", []byte("\x7fELF\x02\x01"))
write("m/notes", []byte("plain text"))
write("m/mac", []byte{0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1})
if err := os.Symlink("prog", filepath.Join(dir, "m/link")); err != nil {
t.Fatal(err)
}
line := func(mode, path string) string { return ":000000 " + mode + " 0000 1111 A\x00" + path + "\x00" }
for _, c := range []struct {
name, raw string
want []Program
}{
{"none", "", []Program{}},
{"a program and a text", line("100755", "m/prog") + line("100644", "m/notes"), []Program{{"m/prog", "ELF", ""}}},
{"two programs", line("100644", "m/prog") + line("100755", "m/mac"), []Program{{"m/prog", "ELF", ""}, {"m/mac", "Mach-O", ""}}},
{"a symlink and a submodule are not read", line("120000", "m/link") + line("160000", "m/sub"), []Program{}},
{"a path the tree lacks", line("100644", "m/gone"), []Program{}},
} {
if got := programsIn(dir, c.raw); !reflect.DeepEqual(got, c.want) {
t.Errorf("%s: programs = %v, want %v", c.name, got, c.want)
}
}
}
+688
View File
@@ -0,0 +1,688 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **The pull loop the build modules share** (novox/hq ADR 0525 §4-§6, ADR 0536 §4; issue 1887). A build module on a node
// whose build capacity is above zero pulls the asks of its language's seat: from the delivery worker first, then check,
// then recheck, holding at most its capacity at once. Each ask runs as a step under the ask's own id, so its end is the
// key <node>.<ask id> in the module's steps state, which the asker watches; the ask is acknowledged when that end is
// written, kept alive while it runs by an in-progress signal, and redelivered by the stream to another node if this one
// stops. No part keeps a timer for an ask: the pull's own wait and the stream's acknowledgement wait are the bus's.
// Pulled is one ask a source handed over, with what decides its fate on the bus.
type Pulled struct {
// Kind is the worker it came from, filled by the puller.
Kind string
Data []byte
// Delivered is how many times the bus has handed it over, this time included.
Delivered uint64
// Ack ends it: it is never delivered again. InProgress tells the bus it is still being worked, restarting its
// acknowledgement wait. Nak hands it back to be delivered again at once (to this node or another). Term drops it.
Ack, InProgress, Nak, Term func() error
}
// AskSource is where a puller takes asks from: the seat's workers on the bus (JetStreamAsks) or a test double
// (FakeAsks).
type AskSource interface {
// Next takes one ask of kind: with wait 0 only one already waiting, else waiting for one at most wait. It answers
// nil and no error when none came.
Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error)
}
// Defaults of a Puller.
var (
// DefaultPullWait is how long one pull of the delivery worker waits for an ask while every worker is empty: the
// pull's own wait (as process.Consume's), after which the check and recheck workers are looked at again.
DefaultPullWait = 5 * time.Second
// DefaultHeartbeat is how often a running ask tells the bus it is still being worked; under the seat worker's
// acknowledgement wait of 60 s (mesh-controller internal/broker HolderConsumerFor).
DefaultHeartbeat = 20 * time.Second
)
// Puller pulls a build seat's asks and runs them.
type Puller struct {
Source AskSource
Language string // the seat's language: delivery.AskGo, AskNode or AskImage
Node string // the node this holder runs on, the first part of its step keys
Put Put // writes the module's steps state
// Capacity is how many asks the node runs at once; read before every pull, so a change takes effect at the next
// one. 0 pulls nothing.
Capacity func() int
// Run runs one ask's job; a *Refusal ends its step refused. Its result is the step's result.
Run func(ctx context.Context, ask delivery.StepAsk) (any, error)
// PullWait and Heartbeat default to DefaultPullWait and DefaultHeartbeat.
PullWait time.Duration
Heartbeat time.Duration
Logf func(format string, args ...any)
// Load reads one key of the state the node's pause is kept in (nil and no error when it is absent). When set, Pull
// reads the node's pause key (delivery.PauseKey) before it pulls, and Pause and Resume write it with PausePut, so a
// pause outlives a restart.
Load func(key string) ([]byte, error)
// PausePut writes the node's pause key: the module's pauses state (delivery.PausesState), which has no age, so a
// pause never ends by itself while the steps state drops its old keys. Put when nil.
PausePut Put
// WatchConfig, when set, follows the module's config value `capacity` (CapacitySetting) as mesh-config resolves it
// for this node, and wakes the puller on each change (watchCapacity): WatchConfigServed in a module, a test's own
// double. Until it hands a value the puller judges by Capacity.
WatchConfig func(key string, handle func(stdio.ProvisionChange) error) error
// CoresPerStep is what one ask of this module needs, read at every judgement (NodeCoresPerStep); with it the capacity
// judged by is bounded by the node's build slice: min(the configured capacity, floor(the quota's cores / CoresPerStep))
// (EffectiveCapacity). Nil bounds nothing by the CPU. Quota reads the quota (Quota when nil; a test's own).
CoresPerStep func() float64
Quota func() (percent int, from string)
// BuildHours is the node's build window, read at every judgement (NodeBuildHours); with WatchConfig set the value
// mesh-config resolves for `build-hours` takes its place once handed. Outside the window the capacity judged by is 0:
// steps running finish, no new one is taken; the puller wakes at the window's next edge. Nil builds at any hour.
BuildHours func() (*BuildWindow, string)
// Has says whether this node has a need an ask names (delivery.AskNeeds, issue 890). An ask needing what it lacks is
// declined: its step ends refused as declined (delivery.StepDeclined), the message is dropped for its asker to put
// again, and the worker it came from is not looked at again until the next pull's wait, so a node lacking the need
// does not take the asker's next copy at once. Nil has nothing.
Has func(need string) bool
mu sync.Mutex
inHand int
running map[string]RunningAsk
paused bool
wake chan struct{}
once sync.Once
// watched is the capacity the config watch handed last, used in place of Capacity once hasWatched.
watched int
hasWatched bool
// hours is the build window the config watch handed last, and hoursFault why its value cannot be read; used in place
// of BuildHours once hasHours.
hours *BuildWindow
hoursFault string
hasHours bool
// judged is the capacity said last (log and capacity.<node>), so a change is said once.
judged delivery.Capacity
hasJudged bool
capWritten bool
capFault string
// declined are the kinds whose worker handed an ask this node declined, left out of the pulls without a wait until
// the next pull's wait ends.
declined map[string]bool
}
func (p *Puller) init() {
p.once.Do(func() { p.wake = make(chan struct{}, 1) })
}
func (p *Puller) logf(format string, args ...any) {
if p.Logf != nil {
p.Logf(format, args...)
}
}
// Wake makes a waiting puller look again: after a capacity change, a resume, or a step's end.
func (p *Puller) Wake() {
p.init()
select {
case p.wake <- struct{}{}:
default:
}
}
// Pause stops the puller taking asks; the asks it holds run to their end. by names who paused it. With Load set the
// pause is written on the node's pause key first, and an error there leaves the puller as it was.
func (p *Puller) Pause(by string) error { return p.setPaused(true, by) }
// Resume lets the puller take asks again, written as Pause's is.
func (p *Puller) Resume(by string) error { return p.setPaused(false, by) }
func (p *Puller) setPaused(paused bool, by string) error {
if p.Load != nil {
key, err := delivery.PauseKey(p.Node)
if err != nil {
return err
}
raw, err := delivery.EncodePause(delivery.Pause{Paused: paused, By: by, At: now()})
if err != nil {
return err
}
put := p.PausePut
if put == nil {
put = p.Put
}
if _, err := put(key, json.RawMessage(raw)); err != nil {
return fmt.Errorf("buildstep: writing the pause of %s: %w", p.Node, err)
}
}
p.mu.Lock()
p.paused = paused
p.mu.Unlock()
p.Wake()
return nil
}
// restore reads the node's pause key, when Load is set.
func (p *Puller) restore() error {
if p.Load == nil {
return nil
}
key, err := delivery.PauseKey(p.Node)
if err != nil {
return err
}
raw, err := p.Load(key)
if err != nil {
return fmt.Errorf("buildstep: reading the pause of %s: %w", p.Node, err)
}
if raw == nil {
return nil
}
v, err := delivery.DecodePause(raw)
if err != nil {
return fmt.Errorf("buildstep: the pause of %s: %w", p.Node, err)
}
p.mu.Lock()
p.paused = v.Paused
p.mu.Unlock()
return nil
}
// Paused says whether the puller takes no asks.
func (p *Puller) Paused() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.paused
}
// InHand is how many asks run now.
func (p *Puller) InHand() int {
p.mu.Lock()
defer p.mu.Unlock()
return p.inHand
}
// RunningAsk is an ask a puller runs now, as its seat's current verb says it.
type RunningAsk struct {
ID string `json:"id"`
Kind string `json:"kind"`
Module string `json:"module,omitempty"`
Started time.Time `json:"started"`
}
// Running are the asks the puller runs now, oldest first.
func (p *Puller) Running() []RunningAsk {
p.mu.Lock()
defer p.mu.Unlock()
out := make([]RunningAsk, 0, len(p.running))
for _, r := range p.running {
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].Started.Before(out[j].Started) })
return out
}
// room says whether the puller may take one more ask now.
func (p *Puller) room() bool {
ok, _, _ := p.roomNow()
return ok
}
// roomNow is room, with the capacity and the asks in hand it was judged on.
func (p *Puller) roomNow() (ok bool, capacity, inHand int) {
capacity = p.CapacityDetail().Effective
p.mu.Lock()
defer p.mu.Unlock()
return !p.paused && p.inHand < capacity, capacity, p.inHand
}
// CapacityNow is the capacity the puller judges by (CapacityDetail's Effective).
func (p *Puller) CapacityNow() int {
return p.CapacityDetail().Effective
}
// CapacityDetail is the capacity the puller judges by now and why: the configured one (the config watch's last value,
// else Capacity), bounded by the node's build slice when CoresPerStep is set (EffectiveCapacity), its quota read now. A
// change from what it said last is logged and written to `capacity.<node>` of the pauses state (PausePut), so the
// delivery view says it too.
func (p *Puller) CapacityDetail() delivery.Capacity {
p.mu.Lock()
watched, has := p.watched, p.hasWatched
p.mu.Unlock()
configured, from := 0, "the settings file"
switch {
case has:
configured, from = watched, "mesh-config, as the config watch handed it"
case p.Capacity != nil:
configured = p.Capacity()
}
var c delivery.Capacity
if p.CoresPerStep == nil {
c = delivery.Capacity{Configured: configured, Effective: configured, QuotaFrom: "not judged: the module names no cores a step needs",
Why: fmt.Sprintf("its configured capacity %d", configured)}
} else {
quota := p.Quota
if quota == nil {
quota = Quota
}
percent, qfrom := quota()
c = EffectiveCapacity(configured, percent, qfrom, p.CoresPerStep())
}
c.ConfiguredFrom = from
w, fault := p.window()
if fault != "" {
c.BuildHoursFault = fault
c.Why = fmt.Sprintf("%s; at any hour, because its build hours cannot be read: %s", c.Why, fault)
}
if w != nil {
at := now()
zone, _ := at.Zone()
c.BuildHours = w.String() + " " + zone
if !w.Open(at) && c.Effective > 0 {
c.OutsideHours = true
c.Why = fmt.Sprintf("0, outside its build hours %s until %s (otherwise %s)", c.BuildHours, w.NextEdge(at).Format("15:04"), c.Why)
c.Effective = 0
} else if c.Effective > 0 {
c.Why = fmt.Sprintf("%s; within its build hours %s until %s", c.Why, c.BuildHours, w.NextEdge(at).Format("15:04"))
}
}
p.sayCapacity(c)
return c
}
// window is the node's build window now (the config watch's last value, else BuildHours, else none), and why its value
// cannot be read when it cannot.
func (p *Puller) window() (*BuildWindow, string) {
p.mu.Lock()
w, fault, has := p.hours, p.hoursFault, p.hasHours
p.mu.Unlock()
if has {
return w, fault
}
if p.BuildHours != nil {
return p.BuildHours()
}
return nil, ""
}
// untilEdge is how long until the build window's next edge, and false when the node has no window.
func (p *Puller) untilEdge() (time.Duration, bool) {
w, _ := p.window()
if w == nil {
return 0, false
}
at := now()
return w.NextEdge(at).Sub(at), true
}
// sayCapacity logs c when it differs from what was said last, and writes it to the node's capacity key until a write
// lands (a failed write is tried again at the next judgement, its error said once).
func (p *Puller) sayCapacity(c delivery.Capacity) {
p.mu.Lock()
same := p.hasJudged && p.judged.Effective == c.Effective && p.judged.Configured == c.Configured && p.judged.ByCPU == c.ByCPU &&
p.judged.CoresPerStep == c.CoresPerStep && p.judged.QuotaCores == c.QuotaCores && p.judged.OutsideHours == c.OutsideHours &&
p.judged.BuildHours == c.BuildHours && p.judged.BuildHoursFault == c.BuildHoursFault
if !same {
p.judged, p.hasJudged, p.capWritten = c, true, false
}
write := !p.capWritten && p.PausePut != nil
p.mu.Unlock()
if !same {
p.logf("the %s build capacity of %s is %s (configured from %s)", p.Language, p.Node, c.Why, c.ConfiguredFrom)
}
if !write {
return
}
key, err := delivery.CapacityKey(p.Node)
if err != nil {
return
}
c.At = now()
raw, err := delivery.EncodeCapacity(c)
if err == nil {
_, err = p.PausePut(key, json.RawMessage(raw))
}
p.mu.Lock()
defer p.mu.Unlock()
if err == nil {
p.capWritten, p.capFault = true, ""
return
}
if err.Error() != p.capFault {
p.capFault = err.Error()
p.logf("the %s build capacity of %s could not be written to %s (tried again at the next judgement): %v", p.Language, p.Node, key, err)
}
}
// Pull takes and runs asks until ctx ends. An ask still running then is neither ended nor acknowledged: the stream
// delivers it again, to another node or to this one restarted.
func (p *Puller) Pull(ctx context.Context) error {
p.init()
if p.Source == nil || p.Put == nil || p.Run == nil {
return errors.New("buildstep: a puller needs its source, its steps' put and its run")
}
if err := p.restore(); err != nil {
// An unreadable pause degrades to not paused, said once: a puller that stops silently is the worse failure, and a
// node kept from building is held by its capacity (0), not by a pause.
p.logf("the pause of %s cannot be read: %v; pulling as not paused", p.Node, err)
}
go p.watchCapacity() // WatchConfigServed waits for Serve, which a module starts after its pull
go p.watchHours()
wait := p.PullWait
if wait <= 0 {
wait = DefaultPullWait
}
for {
if err := ctx.Err(); err != nil {
return err
}
if ok, capacity, inHand := p.roomNow(); !ok {
// Parked until something wakes it: a capacity change (the config watch), a resume, or a step's end. Every
// park logs once, so a puller that sits idle says why (issue 1887).
p.logf("the %s puller on %s parks: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
var edge <-chan time.Time // the build window's next edge, when the node has one: one timer, not a poll
stop := func() bool { return false }
if d, ok := p.untilEdge(); ok {
t := time.NewTimer(d + time.Second)
edge, stop = t.C, t.Stop
}
select {
case <-ctx.Done():
stop()
return ctx.Err()
case <-p.wake:
case <-edge:
}
stop()
_, capacity, inHand = p.roomNow()
p.logf("the %s puller on %s wakes: %d of capacity %d in hand, paused %v", p.Language, p.Node, inHand, capacity, p.Paused())
continue
}
pullWait := wait
if d, ok := p.untilEdge(); ok && d < pullWait {
pullWait = d // a pull does not wait past the window's close
}
msg, err := p.next(ctx, pullWait)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
p.logf("the %s asks cannot be pulled: %v", p.Language, err)
select { // the pull's own wait before the next try, as an empty pull's
case <-ctx.Done():
return ctx.Err()
case <-p.wake:
case <-time.After(wait):
}
continue
}
if msg == nil {
continue
}
p.take(ctx, msg)
}
}
// next is one ask from the workers in their order: one already waiting on delivery, check or recheck, and when none
// waits, a pull of the delivery worker that waits for one.
//
// A kind whose worker handed an ask this node declined is left out until that wait ended (issue 890): the pull's own
// wait, no timer of its own.
func (p *Puller) next(ctx context.Context, wait time.Duration) (*Pulled, error) {
p.mu.Lock()
declined := p.declined
p.mu.Unlock()
for _, kind := range delivery.AskKinds {
if declined[kind] {
continue
}
msg, err := p.Source.Next(ctx, kind, 0)
if err != nil || msg != nil {
return withKind(msg, kind), err
}
}
msg, err := p.Source.Next(ctx, delivery.AskDelivery, wait)
p.mu.Lock()
p.declined = nil
p.mu.Unlock()
return withKind(msg, delivery.AskDelivery), err
}
// decline ends the step of an ask this node lacks a need of, refused as declined with what it lacks, and drops the
// message: the asker puts the ask again under a new id (issue 890). A message whose decline cannot be written is handed
// back, as one whose start cannot be.
func (p *Puller) decline(msg *Pulled, ask delivery.StepAsk, lacks []string) {
step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID, AskWhat(ask))
if err == nil {
why := Refuse("the ask %s needs %s, and %s lacks %s: it is left to a node that has it", ask.ID,
strings.Join(ask.Needs, ", "), p.Node, strings.Join(lacks, ", "))
step.Ended = now().UTC()
step.State, step.Reason = delivery.StepRefused, why.Error()
step.Result, err = json.Marshal(delivery.StepDeclined{Declined: true, Needs: ask.Needs, Lacks: lacks})
if err == nil {
err = writeEnd(p.Put, key, step)
}
}
if 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: it needs %s, and this node lacks %s", ask.ID, p.Node, strings.Join(ask.Needs, ", "), strings.Join(lacks, ", "))
_ = 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
}
return msg
}
// take starts one ask: it decodes it, refuses one that is not of this seat, and runs it as a step under its own id.
func (p *Puller) take(ctx context.Context, msg *Pulled) {
kind := msg.Kind
ask, err := delivery.DecodeStepAsk(msg.Data)
if err == nil && (ask.Language != p.Language || (kind != "" && ask.Kind != kind)) {
err = fmt.Errorf("the ask %s is a %s ask of %s, pulled from the %s %s worker", ask.ID, ask.Kind, ask.Language, p.Language, kind)
}
if err != nil {
// An ask that is not one runs nowhere: delivering it again would only fail again.
p.logf("an ask dropped: %v", err)
_ = msg.Term()
return
}
if lacks := delivery.Lacks(ask.Needs, p.Has); len(lacks) > 0 {
p.decline(msg, ask, lacks)
return
}
step, key, err := begin(p.Put, p.Node, ask.Kind+" ask", ask.ID, AskWhat(ask))
if err != nil {
p.logf("the ask %s cannot start on %s: %v", ask.ID, p.Node, err)
_ = msg.Nak()
return
}
if msg.Delivered > delivery.AskRuns {
// Run AskRuns times and never ended: each run took its node down or died with it. It is run no more, and said
// (issue 1907): the asker's key fails with why.
why := fmt.Errorf("the ask was taken %d times and no run of it ended: each took its build node down or died with it, so it is run no more (poison)", msg.Delivered-1)
p.logf("the ask %s is ended on %s: %v", ask.ID, p.Node, why)
_ = finish(p.Put, key, step, nil, why, p.Logf) // run no more either way: the asker's bound says it then
_ = msg.Term()
return
}
module := ""
if ask.Build != nil {
module = ask.Build.Module
}
p.mu.Lock()
p.inHand++
if p.running == nil {
p.running = map[string]RunningAsk{}
}
p.running[ask.ID] = RunningAsk{ID: ask.ID, Kind: ask.Kind, Module: module, Started: step.Started}
p.mu.Unlock()
p.logf("the %s ask %s starts on %s: %s", ask.Kind, ask.ID, p.Node, step.What)
go p.run(ctx, msg, ask, key, step)
}
func (p *Puller) run(ctx context.Context, msg *Pulled, ask delivery.StepAsk, key string, step delivery.Step) {
defer func() {
p.mu.Lock()
p.inHand--
delete(p.running, ask.ID)
p.mu.Unlock()
p.Wake()
}()
beat := p.Heartbeat
if beat <= 0 {
beat = DefaultHeartbeat
}
done := make(chan struct{})
go func() {
t := time.NewTicker(beat)
defer t.Stop()
for {
select {
case <-done:
return
case <-t.C:
if msg.InProgress != nil {
_ = msg.InProgress()
}
}
}
}()
ph := &phases{put: p.Put, key: key, step: step, logf: p.Logf, prefix: fmt.Sprintf("the %s ask %s on %s", ask.Kind, ask.ID, p.Node)}
result, err := protect(ctx, func(ctx context.Context) (any, error) {
return p.Run(context.WithValue(ctx, phaseKey{}, ph), ask)
})
step = ph.end() // its end keeps the phase it ended in
close(done)
if ctx.Err() != nil {
// The process stops: the ask is neither ended nor acknowledged here, so the stream gives it to a node that runs.
return
}
if ferr := finish(p.Put, key, step, result, err, p.Logf); ferr != nil {
// No end was written: the ask is not acknowledged as done, so the stream gives it again and a run that can
// write its end (or the poison rule, issue 1907) ends it where its asker sees it (issue 1930).
p.logf("the ask %s ran on %s and no end of it could be written, so it is given back: %v", ask.ID, p.Node, ferr)
_ = msg.Nak()
return
}
outcome := "passed"
if err != nil {
outcome = "did not pass: " + err.Error()
if len(outcome) > 300 {
outcome = outcome[:300] + "..."
}
}
p.logf("the %s ask %s ended on %s after %s, in its phase %q; %s", ask.Kind, ask.ID, p.Node,
now().Sub(step.Started).Round(time.Second), step.Phase, outcome)
if aerr := msg.Ack(); aerr != nil {
p.logf("the ask %s ended on %s and its acknowledgement failed: %v", ask.ID, p.Node, aerr)
}
}
// watchCapacity follows the module's config value `capacity` when WatchConfig is set (issue 1887, 2026-10-25: g14's
// puller started at capacity 0 sat parked with capacity 2 set, because nothing woke it to read its settings file
// again). The value comes as mesh-config resolved it for this node, the event of the change itself: the runtime hands
// the value now and then each change its machine's provisioner client says moved, so nothing polls, and the puller does
// not wait for the node-engine to fill its settings file again. Each value handed is kept, used in place of Capacity,
// and wakes the puller. A watch that cannot start leaves the puller on Capacity, said once.
func (p *Puller) watchCapacity() {
if p.WatchConfig == nil {
return
}
err := p.WatchConfig(CapacitySetting, func(c stdio.ProvisionChange) error {
n, why := p.capacityFrom(c)
p.mu.Lock()
before, had := p.watched, p.hasWatched
p.watched, p.hasWatched = n, true
p.mu.Unlock()
switch {
case why != "":
p.logf("the %s puller on %s pulls nothing: %s", p.Language, p.Node, why)
case !had || before != n:
p.logf("the %s build capacity of %s is %d, as mesh-config resolved it", p.Language, p.Node, n)
}
p.Wake()
return nil
})
if err != nil {
p.logf("the %s build capacity of %s cannot be followed on the bus, so it is read from the settings file when the "+
"puller wakes: %v", p.Language, p.Node, err)
}
}
// watchHours follows the module's config value `build-hours` when WatchConfig and BuildHours are set, as watchCapacity
// follows `capacity`: each value handed is kept, used in place of BuildHours, and wakes the puller. A value that cannot
// be read is no window, said; a watch that cannot start leaves the puller on BuildHours, said once.
func (p *Puller) watchHours() {
if p.WatchConfig == nil || p.BuildHours == nil {
return
}
err := p.WatchConfig(BuildHoursSetting, func(c stdio.ProvisionChange) error {
setting := ""
var w *BuildWindow
var why string
switch {
case c.Refused != "":
why = "its build hours are no longer answered: " + c.Refused
case len(c.Value) > 0 && string(c.Value) != "null" && json.Unmarshal(c.Value, &setting) != nil:
why = fmt.Sprintf("the setting %s %s is not a string", BuildHoursSetting, c.Value)
default:
var err error
if w, err = ParseBuildHours(setting, p.Node); err != nil {
why = err.Error()
}
}
p.mu.Lock()
p.hours, p.hoursFault, p.hasHours = w, why, true
p.mu.Unlock()
if why != "" {
p.logf("the %s puller on %s builds at any hour: %s", p.Language, p.Node, why)
}
p.Wake()
return nil
})
if err != nil {
p.logf("the %s build hours of %s cannot be followed on the bus, so they are read from the settings file: %v", p.Language, p.Node, err)
}
}
// WatchConfigServed is stdio.WatchConfig once Serve has the runtime's channel (stdio.Served): a build module starts its
// pull before it serves, and a watch asked before that would be refused.
func WatchConfigServed(key string, handle func(stdio.ProvisionChange) error) error {
<-stdio.Served()
return stdio.WatchConfig(key, handle)
}
// capacityFrom is the node's capacity in one value the watch handed, and why it is 0 when it cannot be read.
func (p *Puller) capacityFrom(c stdio.ProvisionChange) (int, string) {
if c.Refused != "" {
return 0, "its capacity is no longer answered: " + c.Refused
}
setting := ""
if len(c.Value) > 0 && string(c.Value) != "null" {
if err := json.Unmarshal(c.Value, &setting); err != nil {
return 0, fmt.Sprintf("its capacity %s is not a string", c.Value)
}
}
n, err := capacityOf(setting, p.Node)
if err != nil {
return 0, err.Error()
}
return n, ""
}
+102
View File
@@ -0,0 +1,102 @@
package buildstep
import (
"context"
"fmt"
"sync"
"time"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// JetStreamAsks is a build seat's workers on the bus: the shared pull consumer of each kind on the seat's stream
// (delivery.AskStream, AskWorker), which the broker makes when the seat is declared; a holder only binds them.
type JetStreamAsks struct {
JS jetstream.JetStream
Language string
mu sync.Mutex
cons map[string]jetstream.Consumer
}
func (s *JetStreamAsks) consumer(ctx context.Context, kind string) (jetstream.Consumer, error) {
s.mu.Lock()
defer s.mu.Unlock()
if c, ok := s.cons[kind]; ok {
return c, nil
}
stream, err := delivery.AskStream(s.Language)
if err != nil {
return nil, err
}
worker, err := delivery.AskWorker(s.Language, kind)
if err != nil {
return nil, err
}
c, err := s.JS.Consumer(ctx, stream, worker)
if err != nil {
return nil, fmt.Errorf("binding the %s worker %s on %s: %w", kind, worker, stream, err)
}
if s.cons == nil {
s.cons = map[string]jetstream.Consumer{}
}
s.cons[kind] = c
return c, nil
}
// Next is AskSource's: a fetch of one with no wait when wait is 0, else one that waits at most wait.
func (s *JetStreamAsks) Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error) {
c, err := s.consumer(ctx, kind)
if err != nil {
return nil, err
}
var batch jetstream.MessageBatch
if wait <= 0 {
batch, err = c.FetchNoWait(1)
} else {
batch, err = c.Fetch(1, jetstream.FetchMaxWait(wait))
}
if err != nil {
return nil, err
}
select {
case msg, ok := <-batch.Messages():
if !ok {
return nil, batch.Error()
}
var delivered uint64 = 1
if meta, err := msg.Metadata(); err == nil && meta != nil {
delivered = meta.NumDelivered
}
return &Pulled{Data: append([]byte(nil), msg.Data()...), Delivered: delivered,
Ack: msg.Ack, InProgress: msg.InProgress, Nak: msg.Nak, Term: msg.Term}, nil
case <-ctx.Done():
return nil, ctx.Err()
}
}
// DeclareAskWorkers makes language's seat stream and its three workers on a bus with no broker: a test's (the build
// modules' integration tests start one). On the mesh the broker declares them from the seat, as it declares every
// seat's stream (mesh-controller internal/broker SeatStreams); a holder never does.
func DeclareAskWorkers(ctx context.Context, js jetstream.JetStream, language string, ackWait time.Duration) error {
stream, err := delivery.AskStream(language)
if err != nil {
return err
}
seat, _ := delivery.BuildSeat(language)
if _, err := js.CreateOrUpdateStream(ctx, jetstream.StreamConfig{Name: stream, Subjects: []string{"mesh.seat." + seat + ".accept.>"},
Retention: jetstream.WorkQueuePolicy}); err != nil {
return fmt.Errorf("the stream %s: %w", stream, err)
}
for _, kind := range delivery.AskKinds {
worker, _ := delivery.AskWorker(language, kind)
subject, _ := delivery.AskSubject(language, kind)
if _, err := js.CreateOrUpdateConsumer(ctx, stream, jetstream.ConsumerConfig{Durable: worker, FilterSubject: subject,
AckPolicy: jetstream.AckExplicitPolicy, AckWait: ackWait, MaxDeliver: 5}); err != nil {
return fmt.Errorf("the worker %s: %w", worker, err)
}
}
return nil
}
+39
View File
@@ -0,0 +1,39 @@
package buildstep
import (
"encoding/json"
"sync"
"testing"
stdio "git.novox.be/novox/mesh-sdk/go"
)
// configWatch is a double of stdio.WatchConfig: it hands the value now at the start, as the runtime does, and a test
// hands each change after with set.
type configWatch struct {
mu sync.Mutex
value string
handle func(stdio.ProvisionChange) error
asked string
}
func (w *configWatch) watch(key string, handle func(stdio.ProvisionChange) error) error {
w.mu.Lock()
w.asked, w.handle = key, handle
value := w.value
w.mu.Unlock()
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)})
}
func (w *configWatch) set(t *testing.T, value string) {
t.Helper()
w.mu.Lock()
handle, key := w.handle, w.asked
w.mu.Unlock()
if handle == nil {
t.Fatal("the puller watches no config value")
}
if err := handle(stdio.ProvisionChange{Kind: "config", Key: key, Value: json.RawMessage(`"` + value + `"`)}); err != nil {
t.Fatal(err)
}
}
+113
View File
@@ -0,0 +1,113 @@
package buildstep
import (
"context"
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// FakeAsks is a test double of a seat's workers for the build modules' unit tests: Publish puts an ask on a kind's
// worker, Next hands them out in their order, and the fates the puller gives them are counted. Safe for concurrent use.
type FakeAsks struct {
mu sync.Mutex
waiting map[string][][]byte
added chan struct{}
// Acked, Termed and Naked are the asks' ids or data by fate, InProgress how many heartbeats were given.
Acked, Termed, Naked []string
InProgress int
// Delivered, when set, is the delivery count every ask handed over says (1 when 0): a test of a poisoned ask sets it.
Delivered uint64
}
// NewFakeAsks is an empty double.
func NewFakeAsks() *FakeAsks {
return &FakeAsks{waiting: map[string][][]byte{}, added: make(chan struct{}, 1)}
}
// Publish puts an encoded ask on kind's worker.
func (f *FakeAsks) Publish(kind string, data []byte) {
f.mu.Lock()
f.waiting[kind] = append(f.waiting[kind], data)
f.mu.Unlock()
select {
case f.added <- struct{}{}:
default:
}
}
// PublishAsk encodes a and puts it on its kind's worker.
func (f *FakeAsks) PublishAsk(a delivery.StepAsk) error {
raw, err := delivery.EncodeStepAsk(a)
if err != nil {
return err
}
f.Publish(a.Kind, raw)
return nil
}
// Waiting is how many asks wait on kind's worker.
func (f *FakeAsks) Waiting(kind string) int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.waiting[kind])
}
// Fates is a copy of the acked, termed and naked lists.
func (f *FakeAsks) Fates() (acked, termed, naked []string) {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.Acked...), append([]string(nil), f.Termed...), append([]string(nil), f.Naked...)
}
// Next is AskSource's.
func (f *FakeAsks) Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error) {
var deadline <-chan time.Time
if wait > 0 {
t := time.NewTimer(wait)
defer t.Stop()
deadline = t.C
}
for {
f.mu.Lock()
if q := f.waiting[kind]; len(q) > 0 {
data := q[0]
f.waiting[kind] = q[1:]
f.mu.Unlock()
return f.pulled(data), nil
}
f.mu.Unlock()
if wait <= 0 {
return nil, nil
}
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-deadline:
return nil, nil
case <-f.added:
}
}
}
func (f *FakeAsks) pulled(data []byte) *Pulled {
id := string(data)
if a, err := delivery.DecodeStepAsk(data); err == nil {
id = a.ID
}
fate := func(list *[]string) func() error {
return func() error {
f.mu.Lock()
*list = append(*list, id)
f.mu.Unlock()
return nil
}
}
delivered := f.Delivered
if delivered == 0 {
delivered = 1
}
return &Pulled{Data: data, Delivered: delivered, Ack: fate(&f.Acked), Nak: fate(&f.Naked), Term: fate(&f.Termed),
InProgress: func() error { f.mu.Lock(); f.InProgress++; f.mu.Unlock(); return nil }}
}
+144
View File
@@ -0,0 +1,144 @@
//go:build integration
package buildstep
import (
"context"
"strings"
"sync"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/testbus"
)
func askBus(t *testing.T, ackWait time.Duration) jetstream.JetStream {
t.Helper()
nc, err := nats.Connect(testbus.URL(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
if err := DeclareAskWorkers(context.Background(), js, delivery.AskGo, ackWait); err != nil {
t.Fatal(err)
}
return js
}
func publishAsk(t *testing.T, js jetstream.JetStream, a delivery.StepAsk) {
t.Helper()
raw, err := delivery.EncodeStepAsk(a)
if err != nil {
t.Fatal(err)
}
subject, _ := delivery.AskSubject(a.Language, a.Kind)
if _, err := js.Publish(context.Background(), subject, raw); err != nil {
t.Fatal(err)
}
}
// 1887/A2a, 1887/AC-order, 1887/AC-pull on the real server: a check ask published before a delivery ask runs after it;
// with capacity 2 and three asks two run, the third waits in the stream and starts when one ends.
func TestPullOrderAndCapacityOnTheBus(t *testing.T) {
js := askBus(t, 30*time.Second)
check, first, second := testAsk(delivery.AskCheck), testAsk(delivery.AskDelivery), testAsk(delivery.AskDelivery)
publishAsk(t, js, check)
publishAsk(t, js, first)
publishAsk(t, js, second)
release := make(chan struct{})
var mu sync.Mutex
var order []string
st := newSteps()
p := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "shanks", Put: st.put,
Capacity: func() int { return 2 }, PullWait: 200 * time.Millisecond,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
mu.Lock()
order = append(order, a.ID)
mu.Unlock()
<-release
return nil, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "two in hand", func() bool { return p.InHand() == 2 })
time.Sleep(500 * time.Millisecond)
mu.Lock()
if len(order) != 2 || order[0] != first.ID || order[1] != second.ID {
t.Fatalf("ran %v, want the two delivery asks %s %s first", order, first.ID, second.ID)
}
mu.Unlock()
release <- struct{}{}
waitFor(t, "the check taken", func() bool { mu.Lock(); defer mu.Unlock(); return len(order) == 3 })
close(release)
waitFor(t, "every step ended", func() bool {
for _, a := range []delivery.StepAsk{check, first, second} {
key, _ := delivery.AskStepKey("shanks", a.ID)
if s, ok := st.get(key); !ok || s.State != delivery.StepPassed {
return false
}
}
return true
})
info, err := js.Stream(ctx, "SEAT_NODE_BUILD_GO")
if err != nil {
t.Fatal(err)
}
waitFor(t, "the stream empty (every ask acknowledged)", func() bool {
si, err := info.Info(ctx)
return err == nil && si.State.Msgs == 0
})
}
// 1887/A2c, ADR 0525 §6: a killed puller's ask is redelivered by the stream's acknowledgement wait to the other node,
// whose step key names the reference.
func TestPullRedeliversAKilledPullersAsk(t *testing.T) {
js := askBus(t, 2*time.Second)
a := testAsk(delivery.AskDelivery)
publishAsk(t, js, a)
st := newSteps()
taken := make(chan struct{})
dying := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "ace", Put: st.put,
Capacity: func() int { return 1 }, PullWait: 200 * time.Millisecond, Heartbeat: time.Hour,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
close(taken)
<-ctx.Done()
return nil, ctx.Err()
}}
kill, killed := context.WithCancel(context.Background())
go dying.Pull(kill)
<-taken
killed()
other := &Puller{Source: &JetStreamAsks{JS: js, Language: delivery.AskGo}, Language: delivery.AskGo, Node: "shanks", Put: st.put,
Capacity: func() int { return 1 }, PullWait: 200 * time.Millisecond,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
return delivery.BuildAnswer{Artifacts: []delivery.AskBuilt{{Artifact: "bundle", Digest: "sha256:00", Reference: "artifact-store://x"}}}, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go other.Pull(ctx)
key, _ := delivery.AskStepKey("shanks", a.ID)
deadline := time.Now().Add(10 * time.Second)
for {
if s, ok := st.get(key); ok && s.State == delivery.StepPassed {
if want := `artifact-store://x`; !contains(string(s.Result), want) {
t.Fatalf("the step's result %s names no %s", s.Result, want)
}
return
}
if time.Now().After(deadline) {
t.Fatalf("the killed puller's ask was not taken by shanks within 10 s")
}
time.Sleep(20 * time.Millisecond)
}
}
func contains(s, sub string) bool { return strings.Contains(s, sub) }
+44
View File
@@ -0,0 +1,44 @@
package buildstep
import (
"context"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// SeatAsks is a build seat's workers as a tools bundle reaches them: through its runtime's seat pull and settle
// (stdio.SeatPull, SeatSettle; issue 1887 A11), one message per pull, only from the workers the module's membership
// lists.
type SeatAsks struct {
Language string
// Pull and Settle are stdio.SeatPull and stdio.SeatSettle when nil; a test gives its own.
Pull func(worker string, wait time.Duration) (*stdio.Pulled, error)
Settle func(id, fate string) error
}
// Next is AskSource's.
func (s SeatAsks) Next(ctx context.Context, kind string, wait time.Duration) (*Pulled, error) {
pull, settle := s.Pull, s.Settle
if pull == nil {
pull = stdio.SeatPull
}
if settle == nil {
settle = stdio.SeatSettle
}
worker, err := delivery.AskWorker(s.Language, kind)
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
return nil, err
}
p, err := pull(worker, wait)
if err != nil || p == nil {
return nil, err
}
fate := func(f string) func() error { return func() error { return settle(p.ID, f) } }
return &Pulled{Data: p.Body, Delivered: p.Delivered, Ack: fate(stdio.SettleAck), InProgress: fate(stdio.SettleProgress),
Nak: fate(stdio.SettleNak), Term: fate(stdio.SettleTerm)}, nil
}
+40
View File
@@ -0,0 +1,40 @@
package buildstep
import (
"context"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1887/A11: a bundle's source pulls the kind's worker by its seat's name and settles each ask by the runtime's id.
func TestSeatAsksPullsTheKindsWorkerAndSettlesByID(t *testing.T) {
var pulled []string
var settled []string
src := SeatAsks{Language: delivery.AskGo,
Pull: func(worker string, wait time.Duration) (*stdio.Pulled, error) {
pulled = append(pulled, worker)
if worker != "SEAT_NODE_BUILD_GO_CHECK_worker" {
return nil, nil
}
return &stdio.Pulled{ID: "p9", Worker: worker, Body: []byte(`{"v":1}`), Delivered: 2}, nil
},
Settle: func(id, fate string) error { settled = append(settled, id+" "+fate); return nil }}
if p, err := src.Next(context.Background(), delivery.AskDelivery, 0); err != nil || p != nil {
t.Fatalf("delivery = %+v, %v", p, err)
}
p, err := src.Next(context.Background(), delivery.AskCheck, time.Second)
if err != nil || p == nil || string(p.Data) != `{"v":1}` || p.Delivered != 2 {
t.Fatalf("check = %+v, %v", p, err)
}
_ = p.InProgress()
_ = p.Ack()
if len(pulled) != 2 || pulled[0] != "SEAT_NODE_BUILD_GO_DELIVERY_worker" {
t.Errorf("pulled %v", pulled)
}
if len(settled) != 2 || settled[0] != "p9 progress" || settled[1] != "p9 ack" {
t.Errorf("settled %v", settled)
}
}
+438
View File
@@ -0,0 +1,438 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// steps records a steps state's puts: the last value of each key.
type steps struct {
mu sync.Mutex
keys map[string]delivery.Step
}
func newSteps() *steps { return &steps{keys: map[string]delivery.Step{}} }
func (s *steps) put(key string, value any) (uint64, error) {
raw, _ := value.(json.RawMessage)
st, err := delivery.DecodeStep(raw)
if err != nil {
return 0, err
}
s.mu.Lock()
s.keys[key] = st
s.mu.Unlock()
return 1, nil
}
func (s *steps) get(key string) (delivery.Step, bool) {
s.mu.Lock()
defer s.mu.Unlock()
st, ok := s.keys[key]
return st, ok
}
func testAsk(kind string) delivery.StepAsk {
id := NewID()
a := delivery.StepAsk{ID: id, Kind: kind, Language: delivery.AskGo, Asker: "mesh-delivery-builder", At: time.Now()}
fetch := map[string]any{"repository": "novox/x", "commit": "0123456789abcdef0123456789abcdef01234567"}
if kind == delivery.AskDelivery {
b := buildJobFixture
b.Fetch = fetch
a.Build = &b
} else {
a.Check = &delivery.CheckJob{Fetch: fetch, Levels: []string{delivery.LevelUnit}}
}
return a
}
var buildJobFixture = delivery.BuildJob{Module: "m", Path: ".", Artifacts: []delivery.AskArtifact{{Artifact: "bundle"}}}
func waitFor(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("waited 2 s for %s", what)
}
time.Sleep(time.Millisecond)
}
}
// 1887/A2a: given a check ask published before a delivery ask and one slot, the delivery ask runs first.
func TestPullTakesDeliveryBeforeCheck(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
check, recheck, deliver := testAsk(delivery.AskCheck), testAsk(delivery.AskRecheck), testAsk(delivery.AskDelivery)
for _, a := range []delivery.StepAsk{recheck, check, deliver} {
if err := src.PublishAsk(a); err != nil {
t.Fatal(err)
}
}
var mu sync.Mutex
var order []string
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
mu.Lock()
order = append(order, a.Kind)
mu.Unlock()
return map[string]string{"ran": a.ID}, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "three asks acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 })
mu.Lock()
defer mu.Unlock()
if want := []string{delivery.AskDelivery, delivery.AskCheck, delivery.AskRecheck}; len(order) != 3 || order[0] != want[0] || order[1] != want[1] || order[2] != want[2] {
t.Fatalf("order = %v, want %v", order, want)
}
key, _ := delivery.AskStepKey("shanks", deliver.ID)
s, ok := st.get(key)
if !ok || s.State != delivery.StepPassed || string(s.Result) != `{"ran":"`+deliver.ID+`"}` {
t.Fatalf("the step %s = %+v (%v)", key, s, ok)
}
}
// 1887/A2a, 1887/AC-pull: given capacity 2 and three asks waiting, two run and the third waits, and it starts when
// one ends, with no timer. Issue 1806's follow-up: the 2 is the CPU's, a configured 3 bounded by a 12-core slice at 6
// cores a step, said once in capacity.<node>; and issue 1890: a run's phase is written to its step key and logged with
// its ask id, and its end keeps the last phase.
func TestPullHoldsAtMostItsCapacity(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
asks := []delivery.StepAsk{}
for i := 0; i < 3; i++ {
a := testAsk(delivery.AskDelivery)
asks = append(asks, a)
_ = src.PublishAsk(a)
}
release := make(chan struct{})
var mu sync.Mutex
started := 0
var said []string
capacities := map[string][]byte{}
quota := 1200
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 3 },
CoresPerStep: func() float64 { return 6 },
Quota: func() (int, string) { mu.Lock(); defer mu.Unlock(); return quota, "a test's slice" },
PausePut: func(key string, v any) (uint64, error) {
mu.Lock()
capacities[key] = v.(json.RawMessage)
mu.Unlock()
return 1, nil
},
Logf: func(f string, a ...any) { mu.Lock(); said = append(said, fmt.Sprintf(f, a...)); mu.Unlock() },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
mu.Lock()
started++
mu.Unlock()
Phase(ctx, "unit: tests of 3 packages")
<-release
return nil, nil
}}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "two in hand", func() bool { return p.InHand() == 2 })
time.Sleep(30 * time.Millisecond) // three pull waits: the third must still wait
if p.InHand() != 2 || src.Waiting(delivery.AskDelivery) != 1 {
t.Fatalf("in hand %d, waiting %d; want 2 and 1", p.InHand(), src.Waiting(delivery.AskDelivery))
}
c := p.CapacityDetail()
if c.Effective != 2 || c.Configured != 3 || c.ByCPU != 2 || c.QuotaCores != 12 || !strings.Contains(c.Why, "bounded by the CPU") {
t.Fatalf("the capacity judged by = %+v, want 2 of a configured 3 bounded by 12 cores / 6", c)
}
mu.Lock()
raw := capacities["capacity.shanks"]
capLines := 0
for _, l := range said {
if strings.Contains(l, "build capacity of shanks is 2, bounded by the CPU") {
capLines++
}
}
mu.Unlock()
if got, err := delivery.DecodeCapacity(raw); err != nil || got.Effective != 2 || capLines != 1 {
t.Fatalf("capacity.shanks = %s (%v), said %d times; want effective 2, said once", raw, err, capLines)
}
waitFor(t, "a phase on the step key", func() bool {
key, _ := delivery.AskStepKey("shanks", p.Running()[0].ID)
s, _ := st.get(key)
return s.State == delivery.StepRunning && s.Phase == "unit: tests of 3 packages" && !s.PhaseAt.IsZero()
})
release <- struct{}{}
waitFor(t, "the third taken", func() bool { return src.Waiting(delivery.AskDelivery) == 0 })
// Taken from the source comes a moment before its run starts: wait for the start, not only the take.
waitFor(t, "the third started", func() bool { mu.Lock(); defer mu.Unlock(); return started == 3 })
close(release)
waitFor(t, "all acked", func() bool { a, _, _ := src.Fates(); return len(a) == 3 })
for _, a := range asks {
key, _ := delivery.AskStepKey("shanks", a.ID)
if s, _ := st.get(key); s.State != delivery.StepPassed || s.Phase != "unit: tests of 3 packages" {
t.Errorf("the end of %s = %+v, want passed in its last phase", a.ID, s)
}
}
mu.Lock()
logged := strings.Join(said, "\n")
quota = 600 // the cap lowered: one step at 6 cores, followed at the next judgement
mu.Unlock()
if !strings.Contains(logged, "the delivery ask "+asks[0].ID+" on shanks: unit: tests of 3 packages") {
t.Errorf("no journal line names the ask's phase: %s", logged)
}
if c := p.CapacityDetail(); c.Effective != 1 {
t.Errorf("after the quota fell to 6 cores, capacity %+v, want 1", c)
}
}
// An ask that is not one, or not this seat's, is dropped and runs nowhere. And issue 890/AC1: a check ask needing
// node-build-node is declined on a node that lacks it (its step refused as declined, naming what it lacks, the message
// dropped for the asker to put again), and runs on a node that has it.
func TestPullDropsWhatIsNoAsk(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
src.Publish(delivery.AskDelivery, []byte(`{"v":1}`))
other := testAsk(delivery.AskDelivery)
other.Language = delivery.AskNode
_ = src.PublishAsk(other)
placed := testAsk(delivery.AskCheck)
placed.Needs = []string{delivery.NeedNodeBuildNode}
if err := src.PublishAsk(placed); err != nil {
t.Fatal(err)
}
ran := false
p := &Puller{Source: src, Language: delivery.AskGo, Node: "ace", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "three termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 3 })
if ran {
t.Fatal("a wrong or a declined ask ran")
}
key, _ := delivery.AskStepKey("ace", placed.ID)
s, _ := st.get(key)
if d, ok := delivery.DeclinedOf(s); !ok || len(d.Lacks) != 1 || d.Lacks[0] != delivery.NeedNodeBuildNode || !strings.Contains(s.Reason, "lacks node-build-node") {
t.Fatalf("the declined step = %+v", s)
}
has, hasSteps := NewFakeAsks(), newSteps()
_ = has.PublishAsk(placed)
q := &Puller{Source: has, Language: delivery.AskGo, Node: "g14", Put: hasSteps.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Has: func(n string) bool { return n == delivery.NeedNodeBuildNode },
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 })
}
// load reads the recorded steps state's raw values: the pause key is not a step, so it is kept apart.
type kv struct {
mu sync.Mutex
vals map[string][]byte
}
func (k *kv) put(key string, value any) (uint64, error) {
raw, _ := value.(json.RawMessage)
k.mu.Lock()
defer k.mu.Unlock()
if k.vals == nil {
k.vals = map[string][]byte{}
}
k.vals[key] = append([]byte(nil), raw...)
return 1, nil
}
func (k *kv) load(key string) ([]byte, error) {
k.mu.Lock()
defer k.mu.Unlock()
return k.vals[key], nil
}
// 1887/A2b, 1887/AC-zero, 1887/AC-wake: at capacity 0 the puller takes nothing; a capacity raised takes effect at the
// next pull, or, with the config watch (g14 idle at capacity 2, 2026-10-25), at once when the value moves, with no
// pause, resume or step end to wake it; a refused value pulls nothing, and a watch that cannot start leaves the
// settings file's capacity.
func TestPullAtCapacityZeroTakesNothing(t *testing.T) {
t.Run("settings file", func(t *testing.T) {
src, st := NewFakeAsks(), &kv{}
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
var mu sync.Mutex
capacity := 0
p := &Puller{Source: src, Language: delivery.AskGo, Node: "novox", Put: st.put, PullWait: 10 * time.Millisecond,
Capacity: func() int { mu.Lock(); defer mu.Unlock(); return capacity },
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
time.Sleep(50 * time.Millisecond)
if src.Waiting(delivery.AskDelivery) != 1 {
t.Fatal("a node at capacity 0 took an ask")
}
mu.Lock()
capacity = 1
mu.Unlock()
p.Wake()
waitFor(t, "the ask taken at capacity 1", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
})
t.Run("config watch wakes", func(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
w := &configWatch{value: "shanks=3,g14=0"}
p := &Puller{Source: src, Language: delivery.AskGo, Node: "g14", Put: st.put, PullWait: 10 * time.Millisecond,
Capacity: func() int { return 0 }, // the settings file, not refilled yet
WatchConfig: w.watch,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the watch of the capacity", func() bool { w.mu.Lock(); defer w.mu.Unlock(); return w.handle != nil })
time.Sleep(30 * time.Millisecond)
if src.Waiting(delivery.AskDelivery) != 1 {
t.Fatal("a node at capacity 0 took an ask")
}
if w.asked != CapacitySetting {
t.Fatalf("the puller watches %q, want %q", w.asked, CapacitySetting)
}
w.set(t, "shanks=3,g14=2")
waitFor(t, "the ask taken once the capacity moved to 2", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
})
for _, c := range []struct {
name string
watch func(string, func(stdio.ProvisionChange) error) error
room bool
}{
{"refused value leaves no room", func(key string, handle func(stdio.ProvisionChange) error) error {
return handle(stdio.ProvisionChange{Kind: "config", Key: key, Refused: "the module is no longer on g14"})
}, false},
{"watch that cannot start keeps the settings file", func(string, func(stdio.ProvisionChange) error) error {
return errors.New("no runtime")
}, true},
} {
t.Run(c.name, func(t *testing.T) {
p := &Puller{Source: NewFakeAsks(), Language: delivery.AskGo, Node: "g14", Put: newSteps().put,
Capacity: func() int { return 1 }, WatchConfig: c.watch,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }}
p.watchCapacity()
if p.room() != c.room {
t.Fatalf("room = %v, want %v", p.room(), c.room)
}
})
}
}
// 1887/A2b: a paused puller takes nothing, its pause is kept on the pause key, and a puller started again on that state
// stays paused until resumed.
func TestPullPauseOutlivesARestart(t *testing.T) {
src, st, steps := NewFakeAsks(), &kv{}, &kv{}
run := func(ctx context.Context, a delivery.StepAsk) (any, error) { return nil, nil }
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load,
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run}
if err := p.Pause("the operator"); err != nil {
t.Fatal(err)
}
key, _ := delivery.PauseKey("shanks")
raw, _ := st.load(key)
if v, err := delivery.DecodePause(raw); err != nil || !v.Paused || v.By != "the operator" {
t.Fatalf("the pause key holds %s (%v)", raw, err)
}
if raw, _ := steps.load(key); raw != nil {
t.Fatalf("the pause was written in the steps state, whose keys age out: %s", raw)
}
// The restart: a new puller on the same state.
again := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put, Load: st.load,
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run}
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go again.Pull(ctx)
time.Sleep(50 * time.Millisecond)
if !again.Paused() || src.Waiting(delivery.AskDelivery) != 1 {
t.Fatalf("restarted: paused %v, waiting %d; want paused and 1", again.Paused(), src.Waiting(delivery.AskDelivery))
}
if err := again.Resume("the operator"); err != nil {
t.Fatal(err)
}
waitFor(t, "the ask taken after the resume", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
t.Run("a pause that cannot be read is said and pulled as not paused", func(t *testing.T) {
src := NewFakeAsks()
var said atomic.Value
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: steps.put, PausePut: st.put,
Load: func(string) ([]byte, error) { return nil, errors.New("no runtime yet") },
PullWait: 10 * time.Millisecond, Capacity: func() int { return 2 }, Run: run,
Logf: func(f string, a ...any) {
if m := fmt.Sprintf(f, a...); strings.Contains(m, "cannot be read") {
said.Store(m)
}
}}
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the ask taken", func() bool { a, _, _ := src.Fates(); return len(a) == 1 })
if m, _ := said.Load().(string); !strings.Contains(m, "pulling as not paused") {
t.Fatalf("the unreadable pause was not said: %q", m)
}
})
}
// 1887/A2c: a long step tells the bus it is still being worked; a holder that stops mid-step neither ends nor
// acknowledges its ask, so the stream gives it to another node.
func TestPullHeartbeatAndStop(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
a := testAsk(delivery.AskDelivery)
_ = src.PublishAsk(a)
running := make(chan struct{})
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Heartbeat: 5 * time.Millisecond,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
close(running)
<-ctx.Done()
return nil, ctx.Err()
}}
ctx, cancel := context.WithCancel(context.Background())
stopped := make(chan struct{})
go func() { p.Pull(ctx); close(stopped) }()
<-running
waitFor(t, "two heartbeats", func() bool { src.mu.Lock(); defer src.mu.Unlock(); return src.InProgress >= 2 })
cancel()
<-stopped
waitFor(t, "the step's goroutine gone", func() bool { return p.InHand() == 0 })
acked, termed, naked := src.Fates()
if len(acked)+len(termed)+len(naked) != 0 {
t.Fatalf("a stopped holder gave its ask a fate: acked %v termed %v naked %v", acked, termed, naked)
}
key, _ := delivery.AskStepKey("shanks", a.ID)
if s, ok := st.get(key); !ok || s.State != delivery.StepRunning {
t.Fatalf("the step %s = %+v, want left running", key, s)
}
}
// 1907: an ask taken past AskRuns (each run took its node down) is run no more: its step ends failed saying why, so the
// asker's key fails, and it is ended on the stream, not offered to another node.
func TestAPoisonedAskIsSaidAndRunNoMore(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
src.Delivered = delivery.AskRuns + 1
a := testAsk(delivery.AskDelivery)
_ = src.PublishAsk(a)
ran := false
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { ran = true; return nil, nil }}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the ask termed", func() bool { _, tm, _ := src.Fates(); return len(tm) == 1 })
key, _ := delivery.AskStepKey("shanks", a.ID)
s, ok := st.get(key)
if ran || !ok || s.State != delivery.StepFailed || !strings.Contains(s.Reason, "taken 3 times") || !strings.Contains(s.Reason, "poison") {
t.Fatalf("ran %v, step %+v", ran, s)
}
if acked, _, naked := src.Fates(); len(acked)+len(naked) != 0 {
t.Errorf("acked %v naked %v, want it termed alone", acked, naked)
}
}
+18
View File
@@ -0,0 +1,18 @@
package buildstep
import "fmt"
// Refusal is a step that is not run on purpose, as against one that failed: the toolchain installed is not the
// recipe's, the integration level's postgres major is not the one it is built for (novox/hq ADR 0469 §1, ADR 0470
// §4; issue 1473). A step's run returns one and the runner ends the step refused with its text.
type Refusal struct {
// Text is the reason, in words the operator reads.
Text string
// Want is the version the recipe asks for and Have the one found; either may be empty.
Want, Have string
}
func (r *Refusal) Error() string { return r.Text }
// Refuse is a Refusal with its text formatted.
func Refuse(format string, args ...any) *Refusal { return &Refusal{Text: fmt.Sprintf(format, args...)} }
+115
View File
@@ -0,0 +1,115 @@
package buildstep
import (
"fmt"
"path"
"regexp"
"strings"
)
var (
requestCommitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
requestRepositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
)
// FetchRequest is what a build module's fetch tool is asked: the repository and the full commit, the pull request's
// ref when only its head carries the commit, the trunk commit to merge onto for a merge preview, and, for a build of a
// module whose every artifact names its paths, that module's directory and its named paths (novox/hq ADR 0499 clause 4).
//
// **The paths come from the builder, not from a manifest** (ADR 0495 clause 1; the lead's decision L8 of R4e): the
// builder fills Dir and Paths from the indexer's Module value (Path, and KeptInputs2.Paths' keys), and the build module
// gives the build and test steps a workspace holding those paths, the manifest and its lock files. A request without
// them builds from the module's directory, as before.
type FetchRequest struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
Ref string `json:"ref,omitempty"`
Base string `json:"base,omitempty"`
Dir string `json:"dir,omitempty"`
Paths []string `json:"paths,omitempty"`
}
// FetchRequestProperties are the JSON schema properties of FetchRequest's dir and paths, for a fetch tool's Input.
func FetchRequestProperties() map[string]any {
return 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"},
}
}
// Args is r as a tool call's arguments.
func (r FetchRequest) Args() map[string]any {
args := map[string]any{"repository": r.Repository, "commit": r.Commit}
for k, v := range map[string]string{"ref": r.Ref, "base": r.Base, "dir": r.Dir} {
if v != "" {
args[k] = v
}
}
if len(r.Paths) > 0 {
args["paths"] = append([]string(nil), r.Paths...)
}
return args
}
// ParseFetchRequest reads a fetch tool's arguments; arguments it does not know (a build module's own) are left to the
// caller. It refuses a repository that is not <owner>/<name>, a commit or a base that is not 40 hex digits, paths with no
// module directory or a directory with no path, a directory leaving the repository and a path leaving the directory,
// naming each.
func ParseFetchRequest(args map[string]any) (FetchRequest, error) {
var r FetchRequest
r.Repository, _ = args["repository"].(string)
r.Commit, _ = args["commit"].(string)
r.Ref, _ = args["ref"].(string)
r.Base, _ = args["base"].(string)
r.Dir, _ = args["dir"].(string)
switch v := args["paths"].(type) {
case nil:
case []string:
r.Paths = append([]string(nil), v...)
case []any:
for _, p := range v {
s, ok := p.(string)
if !ok {
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
}
r.Paths = append(r.Paths, s)
}
default:
return FetchRequest{}, fmt.Errorf("paths is a list of strings")
}
return r, r.Check()
}
// Check is what ParseFetchRequest refuses.
func (r FetchRequest) Check() error {
if !requestRepositoryOK.MatchString(r.Repository) || strings.Contains(r.Repository, "..") {
return fmt.Errorf("%q is not a repository, <owner>/<name>", r.Repository)
}
if !requestCommitOK.MatchString(r.Commit) {
return fmt.Errorf("%q is not a full commit (40 hex digits)", r.Commit)
}
if r.Base != "" && !requestCommitOK.MatchString(r.Base) {
return fmt.Errorf("%q is not a trunk commit (40 hex digits)", r.Base)
}
switch {
case len(r.Paths) > 0 && r.Dir == "":
return fmt.Errorf("the request names paths and names no module directory (dir) they are relative to")
case len(r.Paths) == 0 && r.Dir != "":
return fmt.Errorf("the request names a module directory and no path")
}
if r.Dir != "" && (outside(r.Dir) || strings.HasPrefix(r.Dir, "/")) {
return fmt.Errorf("the module directory %q leaves the repository", r.Dir)
}
for _, p := range r.Paths {
if p == "" || strings.HasPrefix(p, "/") || outside(p) || path.Clean(p) == "." {
return fmt.Errorf("the path %q leaves the module's directory or is not a path in it", p)
}
}
return nil
}
func outside(p string) bool {
c := path.Clean(p)
return c == ".." || strings.HasPrefix(c, "../")
}
+57
View File
@@ -0,0 +1,57 @@
package buildstep
import (
"reflect"
"strings"
"testing"
)
// A build's fetch is handed the paths of a module that names them (novox/hq ADR 0499 clause 4): the builder fills them
// 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) {
req := FetchRequest{Repository: "novox/mesh-sdk", Commit: strings.Repeat("a", 40), Ref: "refs/pull/7/head",
Dir: ".", Paths: []string{"package.json", "src"}}
args := req.Args()
// A tool call's arguments arrive as JSON: lists of any.
args["paths"] = []any{"package.json", "src"}
got, err := ParseFetchRequest(args)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, req) {
t.Fatalf("got %+v, want %+v", got, req)
}
plain, err := ParseFetchRequest(map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40),
"base": strings.Repeat("c", 40), "beside": []any{}})
if err != nil || plain.Paths != nil || plain.Base != strings.Repeat("c", 40) {
t.Fatalf("a request without paths: %+v, %v", plain, err)
}
ok := map[string]any{"repository": "novox/x", "commit": strings.Repeat("b", 40)}
for _, c := range []struct {
set map[string]any
says string
}{
{map[string]any{"repository": "novox"}, "is not a repository"},
{map[string]any{"repository": "novox/../x"}, "is not a repository"},
{map[string]any{"commit": "abc"}, "is not a full commit"},
{map[string]any{"base": "abc"}, "is not a trunk commit"},
{map[string]any{"paths": []any{"src"}}, "names no module directory"},
{map[string]any{"dir": "modules/a", "paths": []any{"../b"}}, "leaves the module's directory"},
{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"},
} {
args := map[string]any{}
for k, v := range ok {
args[k] = v
}
for k, v := range c.set {
args[k] = v
}
if _, err := ParseFetchRequest(args); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%v: %v, wanted a refusal saying %q", c.set, err, c.says)
}
}
}
+39
View File
@@ -0,0 +1,39 @@
package buildstep
import (
stdio "git.novox.be/novox/mesh-sdk/go"
)
// SeatTools are a build seat's verbs on one node, as its holder serves them (novox/hq ADR 0525 §10; mesh-sdk catalogue
// buildSeatVerbs): `<seat>.current` (what the node runs, its capacity, whether it is paused), `<seat>.pause` and
// `<seat>.resume`, the pause kept on the node's pause key by the puller. what names the asks in the descriptions ("Go").
func SeatTools(seat, what string, p *Puller) []stdio.Tool {
verb := func(name, description string, run func() (any, error)) stdio.Tool {
return stdio.Tool{Name: seat + "." + name, Description: description,
Input: map[string]any{"type": "object", "properties": map[string]any{}},
Run: func(map[string]any) (any, error) { return run() }}
}
current := func() (any, error) {
c := p.CapacityDetail()
return map[string]any{"node": p.Node, "capacity": c.Effective, "capacity_why": c.Why, "capacity_detail": c,
"running": p.Running(), "paused": p.Paused()}, nil
}
return []stdio.Tool{
verb("current", "The "+what+" asks this node runs now (each one's id, kind and when it started), its build capacity (the configured one, "+
"bounded by the cores its node's build slice gives over the cores a step needs) and why, "+
"and whether it is paused.", current),
verb("pause", "Take no new "+what+" ask on this node until resumed; an ask running here ends in its place. Kept "+
"across a restart (the node's pause key).", func() (any, error) {
if err := p.Pause("the seat's pause verb"); err != nil {
return nil, err
}
return current()
}),
verb("resume", "Take "+what+" asks again on this node.", func() (any, error) {
if err := p.Resume("the seat's resume verb"); err != nil {
return nil, err
}
return current()
}),
}
}
+52
View File
@@ -0,0 +1,52 @@
package buildstep
import (
"context"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1887/A9a, ADR 0525 §10: a build seat's verbs are <seat>.current, .pause and .resume; current names the ask running,
// pause stops the puller taking asks, resume lets it take them again.
func TestSeatToolsCurrentPauseResume(t *testing.T) {
src, st := NewFakeAsks(), newSteps()
release := make(chan struct{})
p := &Puller{Source: src, Language: delivery.AskGo, Node: "shanks", Put: st.put, Capacity: func() int { return 1 },
PullWait: 10 * time.Millisecond, Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { <-release; return nil, nil }}
tools := map[string]func(map[string]any) (any, error){}
for _, tool := range SeatTools("node-build-go", "Go", p) {
tools[tool.Name] = tool.Run
}
for _, name := range []string{"node-build-go.current", "node-build-go.pause", "node-build-go.resume"} {
if tools[name] == nil {
t.Fatalf("no %s", name)
}
}
a := testAsk(delivery.AskDelivery)
_ = src.PublishAsk(a)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go p.Pull(ctx)
waitFor(t, "the ask running", func() bool { return p.InHand() == 1 })
out, _ := tools["node-build-go.current"](nil)
cur := out.(map[string]any)
running := cur["running"].([]RunningAsk)
if len(running) != 1 || running[0].ID != a.ID || running[0].Module != "m" || cur["capacity"] != 1 || cur["paused"] != false {
t.Fatalf("current = %+v", cur)
}
if out, err := tools["node-build-go.pause"](nil); err != nil || out.(map[string]any)["paused"] != true {
t.Fatalf("pause = %v, %v", out, err)
}
close(release)
_ = src.PublishAsk(testAsk(delivery.AskDelivery))
time.Sleep(50 * time.Millisecond)
if src.Waiting(delivery.AskDelivery) != 1 {
t.Fatal("a paused node took an ask")
}
if _, err := tools["node-build-go.resume"](nil); err != nil {
t.Fatal(err)
}
waitFor(t, "the ask taken after the resume", func() bool { return src.Waiting(delivery.AskDelivery) == 0 })
}
+272
View File
@@ -0,0 +1,272 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// **A step left running** (novox/hq issue 1890, note 5). A build module that stops while a step runs leaves its key in
// state running for good: nothing else writes that key's end, and the stream's redelivery only rewrites it when the same
// ask comes back. At its start a module ends the steps its node left running before the process began (EndStale), and
// its step-end tool ends one by hand (EndOne).
// RestartReason is why a step a previous process left running is ended.
const RestartReason = "the build module restarted while it ran"
// Boot is when this process began. A step started at or after it belongs to this process, which may still run it, so
// EndOne refuses it.
var Boot = time.Now()
// Scan reads and writes a steps state with its revisions: its keys, a key's stored value and revision (nil and 0 for
// none), and a write that lands only while the key is still at the revision read (KeptState.Update), failing with
// stdio.ErrChanged when another writer was first.
type Scan struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, uint64, error)
Update func(key string, value any, revision uint64) (uint64, error)
}
// ScanOf is the Scan of a state the process keeps.
func ScanOf(s stdio.KeptState) Scan {
return Scan{Keys: s.Keys, Update: s.Update, Get: func(key string) (json.RawMessage, uint64, error) {
e, err := s.Get(key)
if err != nil || e == nil {
return nil, 0, err
}
return e.Value, e.Revision, nil
}}
}
// isAsk says a step is a pulled ask's: its key is <node>.<ask id> and its tool is "<kind> ask" (a hand-started step
// has the tool's name). An ask's key is the stream's to settle, never a restart's: the puller leaves an ask it was
// running unacknowledged so the stream redelivers it, and the asker's wait takes the first end it reads on the key.
// Only once the asker's bound has passed (delivery.StepBound) is the key nobody's, and then it is ended.
func isAsk(key string, s delivery.Step) bool {
_, _, err := delivery.ParseAskStepKey(key)
return err == nil && strings.HasSuffix(s.Tool, " ask")
}
// AskWhat says what an ask is, in one line: its kind, and its repository, head and levels, or its module and
// fingerprint.
func AskWhat(a delivery.StepAsk) string {
short := func(s string) string {
if len(s) > 12 {
return s[:12]
}
return s
}
str := func(m map[string]any, k string) string { v, _ := m[k].(string); return v }
parts := []string{a.Kind + " ask"}
switch {
case a.Check != nil:
parts = append(parts, str(a.Check.Fetch, "repository")+"@"+short(str(a.Check.Fetch, "commit")), "levels "+strings.Join(a.Check.Levels, ","))
if a.Check.Run != "" {
parts = append(parts, "run "+a.Check.Run)
}
case a.Build != nil:
parts = append(parts, "module "+a.Build.Module)
if r := str(a.Build.Fetch, "repository"); r != "" {
parts = append(parts, r+"@"+short(str(a.Build.Fetch, "commit")))
}
if a.Build.FP != "" {
parts = append(parts, "fp "+short(a.Build.FP))
}
case a.Compose != nil:
parts = append(parts, "compose", str(a.Compose.Fetch, "repository")+"@"+short(str(a.Compose.Fetch, "commit")))
}
return strings.Join(parts, ", ")
}
// endRunning writes step ended failed with why, only while its key is still at revision.
func endRunning(scan Scan, key string, step delivery.Step, revision uint64, why string) error {
step.State, step.Reason, step.Ended, step.Result = delivery.StepFailed, why, now().UTC(), nil
raw, err := delivery.EncodeStep(step)
if err != nil {
return err
}
_, err = scan.Update(key, json.RawMessage(raw), revision)
return err
}
// EndStale ends this node's steps that are running and started before `before` (the start of this process) as failed
// with RestartReason, and answers their ids. Only keys of node are read; a value that is no step is left alone. A step
// of a pulled ask is left to the stream's redelivery (isAsk) until its start is older than delivery.StepBound: its asker
// stopped waiting then, whether the stream gave it to another node or gave it up, and it is ended as failed, "past the
// step bound: its asker stopped waiting at <time>". A key written by another writer since it was read
// (a redelivered ask's start, a real end) is skipped.
func EndStale(scan Scan, node string, before time.Time, logf func(string, ...any)) ([]string, error) {
keys, err := scan.Keys()
if err != nil {
return nil, fmt.Errorf("buildstep: listing the steps of %s: %w", node, err)
}
var ended []string
for _, k := range keys {
n, _, perr := delivery.ParseStepKey(k)
if perr != nil || n != node {
continue
}
raw, rev, err := scan.Get(k)
if err != nil {
return ended, fmt.Errorf("buildstep: reading the step %s: %w", k, err)
}
if len(raw) == 0 {
continue
}
step, err := delivery.DecodeStep(raw)
if err != nil || step.State != delivery.StepRunning {
continue
}
why := RestartReason
if isAsk(k, step) {
if !step.Started.Add(delivery.StepBound).Before(now()) {
continue
}
why = "past the step bound: its asker stopped waiting at " + step.Started.Add(delivery.StepBound).UTC().Format("2006-01-02T15:04:05Z")
} else if !step.Started.Before(before) {
continue
}
if err := endRunning(scan, k, step, rev, why); err != nil {
if errors.Is(err, stdio.ErrChanged) {
continue
}
return ended, fmt.Errorf("buildstep: ending the step %s: %w", k, err)
}
if logf != nil {
logf("the step %s (%s) was left running and is ended: %s", k, step.Tool, why)
}
ended = append(ended, step.ID)
}
return ended, nil
}
// EndOne ends this node's running step id (or the key <node>.<id>) as failed with why. A step that is not running is
// refused, and so is one started since this process began (Boot: it may still run, whether a pulled ask or a step a
// tool started), one live reports as held, and one whose key changed since it was read.
func EndOne(scan Scan, node, id, why string, live func(id string) bool) (delivery.Step, error) {
id = strings.TrimSpace(id)
if n, rest, ok := strings.Cut(id, "."); ok {
if n != node {
return delivery.Step{}, fmt.Errorf("the step %q is of node %q, this module runs on %q", id, n, node)
}
id = rest
}
if strings.TrimSpace(why) == "" {
return delivery.Step{}, fmt.Errorf("say why the step is ended (why)")
}
key, err := delivery.StepKey(node, id)
if err != nil {
return delivery.Step{}, err
}
raw, rev, err := scan.Get(key)
if err != nil {
return delivery.Step{}, err
}
if len(raw) == 0 {
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
}
step, err := delivery.DecodeStep(raw)
if err != nil {
return delivery.Step{}, err
}
if step.State != delivery.StepRunning {
return delivery.Step{}, fmt.Errorf("the step %s is %s, not running", key, step.State)
}
if !step.Started.Before(Boot) || (live != nil && live(id)) {
return delivery.Step{}, fmt.Errorf("the step %s was started by this process and may run now; only a step a previous process left is ended", key)
}
if err := endRunning(scan, key, step, rev, why); err != nil {
if errors.Is(err, stdio.ErrChanged) {
return delivery.Step{}, fmt.Errorf("the step %s changed while it was being ended; read it again", key)
}
return delivery.Step{}, err
}
step.State, step.Reason = delivery.StepFailed, why
return step, nil
}
// StepEndTool is the module's step-end tool, named name (build_go_step_end): it ends one step left running, with a
// reason, refusing one this process started. p may be nil.
func StepEndTool(name string, node string, scan Scan, p *Puller) stdio.Tool {
return stdio.Tool{Name: name,
Description: "End one step of this machine that is stuck in state running (for instance left by a restart of the module) " +
"as failed, with the reason given; its asker then sees it over, so ending an ask still in its stream fails its asker. " +
"A step not running, or one this process started, is refused. " +
"id is the step id or the key <node>.<id>.",
Input: map[string]any{"type": "object", "required": []string{"id", "why"}, "properties": map[string]any{
"id": map[string]any{"type": "string", "description": "the step id, or the key <node>.<id>"},
"why": map[string]any{"type": "string", "description": "why it is ended; it becomes the step's reason"}}},
Run: func(args map[string]any) (any, error) {
id, _ := args["id"].(string)
why, _ := args["why"].(string)
var live func(string) bool
if p != nil {
live = func(id string) bool {
for _, r := range p.Running() {
if r.ID == id {
return true
}
}
return false
}
}
return EndOne(scan, node, id, why, live)
}}
}
// StaleAttempts and StaleRetry bound EndStaleAtStart's tries: the steps state is readable once the runtime serves.
var (
StaleAttempts = 6
StaleRetry = 5 * time.Second
)
// EndStaleAtStart is what a build module runs once at its start, in a goroutine: it waits for the runtime to serve,
// then ends this node's steps left running before `before` (EndStale), trying again when the state cannot be read yet.
// A step this process starts is never one of them: it starts after `before`.
func EndStaleAtStart(ctx context.Context, scan Scan, node string, before time.Time, logf func(string, ...any)) {
select {
case <-ctx.Done():
return
case <-stdio.Served():
}
for attempt := 1; ; attempt++ {
ended, err := EndStale(scan, node, before, logf)
if err == nil {
if logf != nil {
logf("%d steps of %s left running by a previous process ended", len(ended), node)
}
return
}
if logf != nil {
logf("ending the steps left running (try %d of %d): %v", attempt, StaleAttempts, err)
}
if attempt >= StaleAttempts {
return
}
select {
case <-ctx.Done():
return
case <-time.After(StaleRetry):
}
}
}
// EndStaleRound is EndStale as the module's periodic round runs it: at once, and only when the runtime serves (a round
// before that is skipped, the start's EndStaleAtStart and the next round cover it). It ends the steps a previous
// process left and the asks past delivery.StepBound.
func EndStaleRound(scan Scan, node string, logf func(string, ...any)) {
select {
case <-stdio.Served():
default:
return
}
if _, err := EndStale(scan, node, Boot, logf); err != nil && logf != nil {
logf("ending the steps left running: %v", err)
}
}
+131
View File
@@ -0,0 +1,131 @@
package buildstep
import (
"encoding/json"
stdio "git.novox.be/novox/mesh-sdk/go"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// scanOf is the steps a keys double holds, last value per key, its revision the count of its puts; a write through
// Update lands only at the revision read, as the state's does. race, when set, runs once before the next Update's check.
func scanOf(k *keys, race func(key string)) Scan {
return Scan{
Keys: func() ([]string, error) {
k.mu.Lock()
defer k.mu.Unlock()
var out []string
for key := range k.puts {
out = append(out, key)
}
return out, nil
},
Get: func(key string) (json.RawMessage, uint64, error) {
k.mu.Lock()
defer k.mu.Unlock()
s, ok := k.puts[key]
if !ok {
return nil, 0, nil
}
raw, err := delivery.EncodeStep(s[len(s)-1])
return raw, uint64(len(s)), err
},
Update: func(key string, value any, revision uint64) (uint64, error) {
if race != nil {
race(key)
}
k.mu.Lock()
n := uint64(len(k.puts[key]))
k.mu.Unlock()
if n != revision {
return 0, stdio.ErrChanged
}
return k.put(key, value)
},
}
}
// 1890: EndStale ends only this node's hand-started steps left running before the process began and the asks past the
// step bound (a younger ask is the stream's to redeliver), and never a key written meanwhile; EndOne ends one by hand, never one this process started.
func TestEndStaleAndEndOne(t *testing.T) {
k := newKeys()
boot := time.Now()
oldBoot := Boot
Boot = boot
defer func() { Boot = oldBoot }()
put := func(node, id, tool, state string, started time.Time) {
key, _ := delivery.StepKey(node, id)
_, _ = k.put(key, json.RawMessage(mustStep(t, delivery.Step{ID: id, Tool: tool, Node: node, State: state, Started: started})))
}
put("ace", "old1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour))
put("ace", "mjk3l9x0abcdef", "check ask", delivery.StepRunning, boot.Add(-time.Hour))
put("ace", "mjk3l9x0abcdee", "check ask", delivery.StepRunning, time.Now().Add(-5*time.Minute))
put("ace", "raced1", "build_go_test", delivery.StepRunning, boot.Add(-time.Hour))
put("ace", "new1", "build_go_fetch", delivery.StepRunning, boot.Add(time.Second))
put("ace", "done1", "build_go_fetch", delivery.StepPassed, boot.Add(-time.Hour))
put("g14", "other1", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour))
// raced1 is ended by its own run between the read and the write.
raced := false
scan := scanOf(k, func(key string) {
if key == "ace.raced1" && !raced {
raced = true
put("ace", "raced1", "build_go_test", delivery.StepPassed, boot.Add(-time.Hour))
}
})
ended, err := EndStale(scan, "ace", boot, nil)
if err != nil || len(ended) != 2 || ended[0] == ended[1] {
t.Fatalf("ended %v, %v", ended, err)
}
if s := k.last("ace.old1"); s.State != delivery.StepFailed || s.Reason != RestartReason || s.Ended.IsZero() {
t.Errorf("old1 = %+v", s)
}
if s := k.last("ace.mjk3l9x0abcdef"); s.State != delivery.StepFailed || !strings.HasPrefix(s.Reason, "past the step bound: its asker stopped waiting at ") {
t.Errorf("the old ask = %+v", s)
}
for key, state := range map[string]string{"ace.mjk3l9x0abcdee": delivery.StepRunning, "ace.raced1": delivery.StepPassed, "ace.new1": delivery.StepRunning, "ace.done1": delivery.StepPassed, "g14.other1": delivery.StepRunning} {
if s := k.last(key); s.State != state {
t.Errorf("%s = %s, want %s", key, s.State, state)
}
}
// EndOne: refuses without why, a held step, one started by this process (a pulled ask or a step tool's), a step not
// running and another node's; ends a step a previous process left.
for _, c := range []struct{ id, why string }{{"old1", ""}, {"new1", "stuck"}, {"done1", "x"}, {"g14.other1", "x"}} {
if _, err := EndOne(scan, "ace", c.id, c.why, nil); err == nil {
t.Errorf("ended %s with why %q", c.id, c.why)
}
}
put("ace", "old2", "build_go_fetch", delivery.StepRunning, boot.Add(-time.Hour))
if _, err := EndOne(scan, "ace", "old2", "x", func(string) bool { return true }); err == nil {
t.Error("ended a held step")
}
if _, err := EndOne(scan, "ace", "ace.old2", "stuck for good", nil); err != nil {
t.Fatal(err)
}
if s := k.last("ace.old2"); s.State != delivery.StepFailed || s.Reason != "stuck for good" {
t.Errorf("old2 = %+v", s)
}
}
func mustStep(t *testing.T, s delivery.Step) []byte {
t.Helper()
raw, err := delivery.EncodeStep(s)
if err != nil {
t.Fatal(err)
}
return raw
}
// 1890: AskWhat names the repository, head and levels of a check ask and the module of a build ask.
func TestAskWhat(t *testing.T) {
check := delivery.StepAsk{Kind: "check", Check: &delivery.CheckJob{Fetch: map[string]any{"repository": "novox/mesh-sdk", "commit": "0123456789abcdef0123"}, Levels: []string{"unit", "integration"}}}
if got, want := AskWhat(check), "check ask, novox/mesh-sdk@0123456789ab, levels unit,integration"; got != want {
t.Errorf("check = %q", got)
}
build := delivery.StepAsk{Kind: "delivery", Build: &delivery.BuildJob{Module: "mesh-x", FP: "abcdef0123456789"}}
if got, want := AskWhat(build), "delivery ask, module mesh-x, fp abcdef012345"; got != want {
t.Errorf("build = %q", got)
}
}
+146
View File
@@ -0,0 +1,146 @@
// Package buildstep is what both build modules share: the runner of a long step that answers a tool call at once
// and ends on its key in the module's `steps` state (novox/hq ADR 0469 §2; issue 1471), the toolchain guard
// (issue 1473) and the fetch of one commit over SSH (issue 1474).
package buildstep
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log"
"strconv"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Put writes one key of a state: stdio.State(delivery.StepsState).Put's shape (go/state.go).
type Put func(key string, value any) (uint64, error)
// EndAttempts and EndRetry bound how a step's end is written: a write that fails is tried again, since the end
// is the only way the step's reader learns it is over.
var (
EndAttempts = 5
EndRetry = 2 * time.Second
)
var now = time.Now
// NewID is a step id: the time in milliseconds in base 36 and six random hex digits, lower-case letters and
// digits only, so it is one key token and differs between two steps of one node even in one millisecond.
func NewID() string {
var b [3]byte
_, _ = rand.Read(b[:])
return strconv.FormatInt(now().UnixMilli(), 36) + hex.EncodeToString(b[:])
}
// Start begins a step of tool on node and answers its id at once: the key <node>.<id> holds state running when
// Start returns. run is then called in a goroutine of its own, under ctx (the process's, not the tool call's: the
// call is cut at 30 s, the step is not). When it returns, the key is written passed with run's result, failed with
// the error's text, or refused when the error is a *Refusal; a panic in run ends the step failed, naming it, and
// the process goes on. If the running write fails, nothing was started and Start answers the error.
func Start(ctx context.Context, put Put, node, tool string, run func(ctx context.Context) (result any, err error)) (string, error) {
step, key, err := begin(put, node, tool, NewID(), "")
if err != nil {
return "", err
}
go end(ctx, put, key, step, run)
return step.ID, nil
}
// begin writes the step running under id.
func begin(put Put, node, tool, id, what string) (delivery.Step, string, error) {
step := delivery.Step{ID: id, Tool: tool, Node: node, State: delivery.StepRunning, Started: now().UTC(), What: what}
key, err := delivery.StepKey(node, step.ID)
if err != nil {
return delivery.Step{}, "", err
}
if err := write(put, key, step); err != nil {
return delivery.Step{}, "", fmt.Errorf("buildstep: writing the running step %s: %w", key, err)
}
return step, key, nil
}
// end runs run and writes the step's end.
func end(ctx context.Context, put Put, key string, step delivery.Step, run func(ctx context.Context) (any, error)) {
result, err := protect(ctx, run)
_ = finish(put, key, step, result, err, log.Printf) // finish says a refused end itself
}
// finish writes the step's end from run's result or error: passed, failed or refused (a *Refusal). A write that fails is
// tried EndAttempts times. When the whole end is refused every time (a result the state will not take: issue 1930), the
// refusal is logged and a small failed end without the result is written instead, naming the write's error, so the
// step's reader learns it is over and why. finish answers an error only when no end could be written at all, and then
// the step's ask is not acknowledged as done.
func finish(put Put, key string, step delivery.Step, result any, err error, logf func(format string, args ...any)) error {
step.Ended = now().UTC()
switch ref := (*Refusal)(nil); {
case err == nil:
step.State = delivery.StepPassed
if result != nil {
raw, merr := json.Marshal(result)
if merr != nil {
step.State, step.Reason = delivery.StepFailed, "the result is not JSON: "+merr.Error()
} else {
step.Result = raw
}
}
case errors.As(err, &ref):
step.State, step.Reason = delivery.StepRefused, err.Error()
default:
step.State, step.Reason = delivery.StepFailed, err.Error()
}
werr := writeEnd(put, key, step)
if werr == nil {
return nil
}
if logf != nil {
logf("the end of the step %s (%s) could not be written in %d tries: %v", key, step.State, EndAttempts, werr)
}
ended := step.State
why := fmt.Sprintf("the step ended %s, and its end could not be written: %v", ended, werr)
if step.Reason != "" {
why = fmt.Sprintf("the step ended %s (%s), and its end could not be written: %v", ended, step.Reason, werr)
}
step.State, step.Reason, step.Result = delivery.StepFailed, why, nil
if ferr := writeEnd(put, key, step); ferr != nil {
if logf != nil {
logf("the failed end of the step %s could not be written either: %v", key, ferr)
}
return fmt.Errorf("buildstep: no end of the step %s could be written: %w", key, ferr)
}
return nil
}
// writeEnd writes the step's end, trying EndAttempts times.
func writeEnd(put Put, key string, step delivery.Step) error {
for attempt := 1; ; attempt++ {
werr := write(put, key, step)
if werr == nil || attempt >= EndAttempts {
return werr
}
time.Sleep(EndRetry)
}
}
// protect is run with a panic turned into the error that ends the step.
func protect(ctx context.Context, run func(ctx context.Context) (any, error)) (result any, err error) {
defer func() {
if p := recover(); p != nil {
result, err = nil, fmt.Errorf("the step panicked: %v", p)
}
}()
return run(ctx)
}
func write(put Put, key string, step delivery.Step) error {
raw, err := delivery.EncodeStep(step)
if err != nil {
return err
}
_, err = put(key, json.RawMessage(raw))
return err
}
+154
View File
@@ -0,0 +1,154 @@
package buildstep
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// keys is a steps state in memory: every value put, in order, per key, and a signal for each end.
type keys struct {
mu sync.Mutex
puts map[string][]delivery.Step
ended chan string
}
func newKeys() *keys { return &keys{puts: map[string][]delivery.Step{}, ended: make(chan string, 16)} }
func (k *keys) put(key string, value any) (uint64, error) {
step, err := delivery.DecodeStep(value.(json.RawMessage))
if err != nil {
return 0, err
}
k.mu.Lock()
k.puts[key] = append(k.puts[key], step)
n := uint64(len(k.puts[key]))
k.mu.Unlock()
if step.State != delivery.StepRunning {
k.ended <- key
}
return n, nil
}
func (k *keys) last(key string) delivery.Step {
k.mu.Lock()
defer k.mu.Unlock()
s := k.puts[key]
return s[len(s)-1]
}
func (k *keys) waitEnd(t *testing.T) string {
t.Helper()
select {
case key := <-k.ended:
return key
case <-time.After(3 * time.Second):
t.Fatal("the step never ended on its key")
return ""
}
}
// 1471/AC1: a run that blocks: Start answers an id within 100 ms and the key holds running.
func TestStartAnswersAtOnceWithTheStepRunning(t *testing.T) {
k := newKeys()
release := make(chan struct{})
defer close(release)
began := time.Now()
id, err := Start(context.Background(), k.put, "ace", "fetch", func(ctx context.Context) (any, error) {
<-release
return nil, nil
})
if err != nil {
t.Fatal(err)
}
if d := time.Since(began); d > 100*time.Millisecond {
t.Errorf("Start took %v", d)
}
s := k.last("ace." + id)
if s.State != delivery.StepRunning || s.Tool != "fetch" || s.Node != "ace" || s.ID != id || s.Started.IsZero() {
t.Errorf("step = %+v", s)
}
}
// 1471/AC2 and 1473/AC4: a result, an error and a Refusal end the step passed, failed and refused.
func TestStepEndsPassedFailedOrRefusedOnItsKey(t *testing.T) {
for _, c := range []struct {
name string
run func(context.Context) (any, error)
state string
reason string
result string
}{
{"result", func(context.Context) (any, error) { return map[string]string{"sha": "abc"}, nil }, delivery.StepPassed, "", `{"sha":"abc"}`},
{"error", func(context.Context) (any, error) { return nil, errors.New("exit 2") }, delivery.StepFailed, "exit 2", ""},
{"refusal", func(context.Context) (any, error) {
return nil, &Refusal{Text: "go 1.27.1 installed, the recipe builds with go 1.27.0", Want: "1.27.0", Have: "1.27.1"}
}, delivery.StepRefused, "go 1.27.1 installed, the recipe builds with go 1.27.0", ""},
} {
t.Run(c.name, func(t *testing.T) {
k := newKeys()
id, err := Start(context.Background(), k.put, "ace", "test", c.run)
if err != nil {
t.Fatal(err)
}
if got := k.waitEnd(t); got != "ace."+id {
t.Fatalf("ended key %q", got)
}
s := k.last("ace." + id)
if s.State != c.state || s.Reason != c.reason || string(s.Result) != c.result || s.Ended.IsZero() {
t.Errorf("step = %+v", s)
}
})
}
}
// 1471/AC3: a panic in run ends the step failed, naming it, and the test process goes on.
func TestAPanicEndsTheStepFailed(t *testing.T) {
k := newKeys()
id, _ := Start(context.Background(), k.put, "ace", "build", func(context.Context) (any, error) { panic("boom") })
k.waitEnd(t)
s := k.last("ace." + id)
if s.State != delivery.StepFailed || s.Reason != "the step panicked: boom" {
t.Errorf("step = %+v", s)
}
}
// 1471/AC5: ids made in one and the same millisecond differ, and each is one key token. The clock is fixed
// while no step runs, so no goroutine reads it meanwhile.
func TestTwoIDsInOneMillisecondDiffer(t *testing.T) {
was := now
defer func() { now = was }()
at := time.Date(2026, 10, 20, 12, 0, 0, 0, time.UTC)
now = func() time.Time { return at }
seen := map[string]bool{}
for i := 0; i < 50; i++ {
id := NewID()
if seen[id] {
t.Fatalf("id %q handed out twice", id)
}
seen[id] = true
if _, err := delivery.StepKey("ace", id); err != nil {
t.Errorf("id %q: %v", id, err)
}
}
}
// A first write that fails starts nothing.
func TestStartAnswersTheErrorWhenTheRunningWriteFails(t *testing.T) {
ran := make(chan struct{}, 1)
_, err := Start(context.Background(), func(string, any) (uint64, error) { return 0, errors.New("no grant") }, "ace", "fetch",
func(context.Context) (any, error) { ran <- struct{}{}; return nil, nil })
if err == nil {
t.Fatal("Start answered no error")
}
select {
case <-ran:
t.Error("run was called")
case <-time.After(50 * time.Millisecond):
}
}
+49
View File
@@ -0,0 +1,49 @@
// The probe a run step's isolation is proved with (novox/hq ADR 0448; issue 1309, S19): run inside the step, it says
// on stdout every way it reached what a step must not, and "shut off" when it reached none. Its arguments: the files
// it must not read (the forge key, a bus login), a TCP address and a unix socket of the machine it must not reach, a
// name it must not resolve, and a cache file it writes, which must be unchanged after the step.
package main
import (
"fmt"
"net"
"os"
"time"
)
func main() {
if len(os.Args) != 7 {
fmt.Println("usage: probe <key> <login> <tcp address> <unix socket> <name> <cache file>")
os.Exit(2)
}
key, login, tcp, socket, name, cache := os.Args[1], os.Args[2], os.Args[3], os.Args[4], os.Args[5], os.Args[6]
reached := 0
say := func(f string, a ...any) { reached++; fmt.Printf(f+"\n", a...) }
for _, f := range []string{key, login} {
if _, err := os.ReadFile(f); err == nil {
say("read %s", f)
}
}
if c, err := net.DialTimeout("tcp", tcp, 2*time.Second); err == nil {
c.Close()
say("reached %s", tcp)
}
if c, err := net.DialTimeout("unix", socket, 2*time.Second); err == nil {
c.Close()
say("reached %s", socket)
}
if addrs, err := net.LookupHost(name); err == nil {
say("resolved %s to %v", name, addrs)
}
if home, err := os.UserHomeDir(); err == nil {
if entries, err := os.ReadDir(home); err == nil && len(entries) > 0 {
say("the home %s holds %d entries", home, len(entries))
}
}
if err := os.WriteFile(cache, []byte("written by the step\n"), 0o644); err != nil {
say("could not write the cache under its throwaway layer: %v", err)
}
if reached == 0 {
fmt.Println("shut off")
}
}
Binary file not shown.
+123
View File
@@ -0,0 +1,123 @@
package buildstep
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path"
"path/filepath"
"strings"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// ModuleTree is what a merge holds of one module directory: its tree id, the sha256 of its module.json and the sha256
// of each lock file it has (go.sum, package-lock.json), from which the checker and the indexer take the module's inputs
// (novox/hq ADR 0484 clause 3).
type ModuleTree struct {
Tree string `json:"tree"`
Manifest string `json:"manifest"`
Locks map[string]string `json:"locks,omitempty"`
// Paths are, of a module whose every artifact names its paths, each named path's git object id at the commit; Tree
// is then their delivery.NamedTree with Manifest (novox/hq ADR 0499 clause 2). Absent otherwise.
Paths map[string]string `json:"paths,omitempty"`
}
var lockFiles = []string{"go.sum", "package-lock.json"}
// ModuleTrees are the trees, in commit, of every directory of the workspace dir that holds a module.json, by directory
// (the repository's root is "."); for a module whose every artifact names its paths, the named tree of those paths and
// their objects (novox/hq ADR 0499 clause 2). The workspace is at commit, so the manifests and the lock files are read from it. env is
// the environment git runs with (nil for none). Both build modules' fetch steps answer it for a merge.
func ModuleTrees(ctx context.Context, run forgekey.Runner, env []string, dir, commit string) (map[string]ModuleTree, error) {
r := run(ctx, env, "git", "-C", dir, "ls-files", "--", "module.json", "*/module.json")
if r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
}
trees := map[string]ModuleTree{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l == "" {
continue
}
mdir := filepath.ToSlash(filepath.Dir(l))
spec := commit + ":" + mdir
if mdir == "." {
spec = commit + "^{tree}"
}
t := run(ctx, env, "git", "-C", dir, "rev-parse", spec)
if t.Status != 0 || t.Err != "" {
return nil, fmt.Errorf("git rev-parse %s: %s", spec, forgekey.Said(t.Err+"\n"+t.Stderr))
}
mt := ModuleTree{Tree: strings.TrimSpace(t.Stdout)}
raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(l)))
if err != nil {
return nil, err
}
mt.Manifest = sha256Hex(raw)
if err := narrow(ctx, run, env, dir, commit, mdir, raw, &mt); err != nil {
return nil, err
}
for _, lock := range lockFiles {
if raw, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(mdir), lock)); err == nil {
if mt.Locks == nil {
mt.Locks = map[string]string{}
}
mt.Locks[lock] = sha256Hex(raw)
}
}
trees[mdir] = mt
}
return trees, nil
}
// narrow sets mt's Paths and its named Tree when the module's manifest names paths on every artifact (novox/hq ADR 0499
// clause 2): one `git ls-tree -z` gives each named path's object at commit. The merge preview reads the manifest here, as
// the indexer does, with catalogue.ParseManifestForInputs (issue 1751), and no other build step reads paths from one (ADR 0495 clause 1). A manifest that does not parse
// is not narrowed: the indexer gives it none. A named path absent at commit fails, naming the module and the path.
func narrow(ctx context.Context, run forgekey.Runner, env []string, dir, commit, mdir string, raw []byte, mt *ModuleTree) error {
m, err := catalogue.ParseManifestForInputs(raw)
if err != nil {
return nil
}
paths, every := m.NamedPaths()
if !every {
return nil
}
full := make([]string, len(paths))
for i, p := range paths {
full[i] = path.Join(mdir, p)
}
r := run(ctx, env, "git", append([]string{"-C", dir, "ls-tree", "-z", commit, "--"}, full...)...)
if r.Status != 0 || r.Err != "" {
return fmt.Errorf("git ls-tree %s of the module %s: %s", commit, mdir, forgekey.Said(r.Err+"\n"+r.Stderr))
}
objects := map[string]string{}
for _, l := range strings.Split(r.Stdout, "\x00") {
meta, name, ok := strings.Cut(l, "\t")
fields := strings.Fields(meta)
if !ok || len(fields) != 3 {
continue
}
objects[name] = fields[2]
}
mt.Paths = map[string]string{}
for i, p := range paths {
object := objects[full[i]]
if object == "" {
return fmt.Errorf("the module %s names the path %q, which is not in %s", mdir, p, commit)
}
mt.Paths[p] = object
}
named, err := delivery.NamedTree(mt.Manifest, mt.Paths)
if err != nil {
return fmt.Errorf("the module %s: %w", mdir, err)
}
mt.Tree = named
return nil
}
func sha256Hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
+63
View File
@@ -0,0 +1,63 @@
//go:build integration
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// On a real repository shaped like mesh-sdk (novox/hq ADR 0499 clauses 2 and 6), a commit touching only the Go package
// leaves the TypeScript package's tree value equal, and a commit touching its source changes it; a module that names no
// path keeps its directory's tree id.
func TestAGoOnlyCommitLeavesTheNamedTreeEqualOnRealGit(t *testing.T) {
repo := t.TempDir()
realGit(t, repo, "init", "-q", "-b", "main")
writeFile(t, repo, "module.json", `{"module":"mesh-sdk","version":"1","build":{"artifacts":[`+
`{"name":"lib","kind":"package","language":"typescript","paths":["src","test","package.json","package-lock.json","tsconfig.json"]}]}}`)
writeFile(t, repo, "src/index.ts", "export const a = 1\n")
writeFile(t, repo, "test/a.test.ts", "// a\n")
writeFile(t, repo, "package.json", "{}\n")
writeFile(t, repo, "package-lock.json", "{}\n")
writeFile(t, repo, "tsconfig.json", "{}\n")
writeFile(t, repo, "go/delivery/x.go", "package delivery\n")
writeFile(t, repo, "other/module.json", `{"module":"other","version":"1"}`)
writeFile(t, repo, "other/a.txt", "a\n")
commit := func(msg string) string {
realGit(t, repo, "add", "-A")
realGit(t, repo, "commit", "-q", "-m", msg)
return realGit(t, repo, "rev-parse", "HEAD")
}
trees := func(c string) map[string]ModuleTree {
got, err := ModuleTrees(context.Background(), forgekey.ExecRunner, nil, repo, c)
if err != nil {
t.Fatal(err)
}
return got
}
first := trees(commit("first"))
if !strings.HasPrefix(first["."].Tree, "named:") || len(first["."].Paths) != 5 {
t.Fatalf("the root module was not narrowed: %+v", first["."])
}
if want := realGit(t, repo, "rev-parse", "HEAD:other"); first["other"].Tree != want || first["other"].Paths != nil {
t.Fatalf("a module naming no path: %+v, want its directory's tree %s", first["other"], want)
}
writeFile(t, repo, "go/delivery/x.go", "package delivery\n\nconst B = 2\n")
goOnly := trees(commit("go only"))
if goOnly["."].Tree != first["."].Tree {
t.Fatalf("a Go-only commit changed the TypeScript package's tree: %s, was %s", goOnly["."].Tree, first["."].Tree)
}
writeFile(t, repo, "src/index.ts", "export const a = 2\n")
src := trees(commit("src"))
if src["."].Tree == first["."].Tree {
t.Fatal("a commit to src/ left the TypeScript package's tree equal")
}
if src["."].Paths["src"] != realGit(t, repo, "rev-parse", "HEAD:src") {
t.Fatalf("the object of src is %s, git says %s", src["."].Paths["src"], realGit(t, repo, "rev-parse", "HEAD:src"))
}
}
+113
View File
@@ -0,0 +1,113 @@
package buildstep
import (
"context"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// A module whose every artifact names its paths takes its tree from them (novox/hq ADR 0499 clause 2): ModuleTrees asks
// git for each named path's object at the commit, keeps them in Paths and answers their delivery.NamedTree as Tree; a
// module naming none keeps its directory's tree, as before.
func TestModuleTreesNarrowsAModuleWhoseArtifactsNameTheirPaths(t *testing.T) {
root := t.TempDir()
sdk := `{"module":"mesh-sdk","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","package.json"]}]}}`
writeTreeFile(t, root, "module.json", sdk)
writeTreeFile(t, root, "modules/b/module.json", `{"module":"b","version":"1"}`)
merge := strings.Repeat("c", 40)
var lsTree []string
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "module.json\nmodules/b/module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "tree-of-" + args[3] + "\n"}
case "ls-tree":
lsTree = append(lsTree, strings.Join(args[4:], " "))
return forgekey.Ran{Stdout: "100644 blob " + strings.Repeat("b", 40) + "\tpackage.json\x00" +
"040000 tree " + strings.Repeat("a", 40) + "\tsrc\x00"}
}
return forgekey.Ran{Status: 1, Stderr: "unexpected " + args[2]}
}
got, err := ModuleTrees(context.Background(), run, nil, root, merge)
if err != nil {
t.Fatal(err)
}
paths := map[string]string{"src": strings.Repeat("a", 40), "package.json": strings.Repeat("b", 40)}
want, _ := delivery.NamedTree(hexOf(sdk), paths)
if g := got["."]; g.Tree != want || len(g.Paths) != 2 || g.Paths["src"] != paths["src"] || g.Paths["package.json"] != paths["package.json"] {
t.Fatalf("the root module = %+v, want tree %s and paths %v", g, want, paths)
}
if len(lsTree) != 1 || lsTree[0] != merge+" -- package.json src" {
t.Fatalf("git ls-tree ran as %v", lsTree)
}
if g := got["modules/b"]; g.Tree != "tree-of-"+merge+":modules/b" || g.Paths != nil {
t.Fatalf("a module naming no path was narrowed: %+v", g)
}
}
// A named path absent at the commit fails the answer, naming the module and the path: a tree computed without it would
// pin a build source that does not exist.
func TestModuleTreesFailsNamingANamedPathAbsentAtTheCommit(t *testing.T) {
root := t.TempDir()
writeTreeFile(t, root, "modules/a/module.json", `{"module":"a","version":"1","build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src","gone"]}]}}`)
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "modules/a/module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "t\n"}
case "ls-tree":
if strings.Join(args[4:], " ") != strings.Repeat("c", 40)+" -- modules/a/gone modules/a/src" {
return forgekey.Ran{Status: 1, Stderr: "wrong arguments " + strings.Join(args[3:], " ")}
}
return forgekey.Ran{Stdout: "040000 tree " + strings.Repeat("a", 40) + "\tmodules/a/src\x00"}
}
return forgekey.Ran{Status: 1}
}
_, err := ModuleTrees(context.Background(), run, nil, root, strings.Repeat("c", 40))
if err == nil || !strings.Contains(err.Error(), `"gone"`) || !strings.Contains(err.Error(), "modules/a") {
t.Fatalf("an absent named path gave %v", err)
}
}
// A manifest whose config value falls back to a predefined key is narrowed here as the indexer narrows it: both read it
// with catalogue.ParseManifestForInputs (issue 1751), so a build module holding no published list computes the same
// tree (ADR 0499 clause 2).
func TestModuleTreesNarrowsAManifestFallingBackToAPredefinedKey(t *testing.T) {
was := catalogue.ReplacePublished(nil) // the build modules' case: no published list
defer catalogue.ReplacePublished(was)
root := t.TempDir()
raw := `{"module":"xterm","version":"1","config":{"size":{"type":"number","description":"The text size.","predefined":"font-size","default":12,"runtime":true}},` +
`"build":{"artifacts":[{"name":"lib","kind":"package","language":"typescript","paths":["src"]}]}}`
if _, err := catalogue.ParseManifest([]byte(raw)); err == nil {
t.Fatal("ParseManifest reads the manifest with no list; the test proves nothing")
}
if _, err := catalogue.ParseManifestForInputs([]byte(raw)); err != nil {
t.Fatalf("the indexer's read refuses the manifest: %v", err)
}
writeTreeFile(t, root, "module.json", raw)
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "whole\n"}
case "ls-tree":
return forgekey.Ran{Stdout: "040000 tree " + strings.Repeat("a", 40) + "\tsrc\x00"}
}
return forgekey.Ran{Status: 1}
}
got, err := ModuleTrees(context.Background(), run, nil, root, strings.Repeat("c", 40))
if err != nil {
t.Fatal(err)
}
want, _ := delivery.NamedTree(hexOf(raw), map[string]string{"src": strings.Repeat("a", 40)})
if g := got["."]; g.Tree != want || g.Paths["src"] == "" {
t.Fatalf("the module = %+v, want the named tree %s", g, want)
}
}
+93
View File
@@ -0,0 +1,93 @@
package buildstep
import (
"context"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
func hexOf(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func writeTreeFile(t *testing.T, root, name, content string) {
t.Helper()
p := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// ModuleTrees answers, for each directory holding a module.json (the root as "."), the tree id git gives, the sha256 of
// module.json and of each lock file present, and reads them from the workspace.
func TestModuleTreesAnswersTreeManifestAndLocksPerModuleDirectory(t *testing.T) {
root := t.TempDir()
writeTreeFile(t, root, "module.json", `{"root":1}`)
writeTreeFile(t, root, "go.sum", "rootsum")
writeTreeFile(t, root, "modules/a/module.json", `{"a":1}`)
writeTreeFile(t, root, "modules/a/go.sum", "asum")
writeTreeFile(t, root, "modules/a/package-lock.json", "alock")
writeTreeFile(t, root, "modules/b/module.json", `{"b":1}`)
merge := strings.Repeat("c", 40)
var calls []string
var envs [][]string
run := func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
calls = append(calls, name+" "+strings.Join(args[2:], " "))
envs = append(envs, env)
switch args[2] {
case "ls-files":
return forgekey.Ran{Stdout: "module.json\nmodules/a/module.json\nmodules/b/module.json\n"}
case "rev-parse":
return forgekey.Ran{Stdout: "tree-of-" + args[3] + "\n"}
}
return forgekey.Ran{Status: 1, Stderr: "unexpected"}
}
got, err := ModuleTrees(context.Background(), run, []string{"X=1"}, root, merge)
if err != nil {
t.Fatal(err)
}
want := map[string]ModuleTree{
".": {Tree: "tree-of-" + merge + "^{tree}", Manifest: hexOf(`{"root":1}`), Locks: map[string]string{"go.sum": hexOf("rootsum")}},
"modules/a": {Tree: "tree-of-" + merge + ":modules/a", Manifest: hexOf(`{"a":1}`), Locks: map[string]string{"go.sum": hexOf("asum"), "package-lock.json": hexOf("alock")}},
"modules/b": {Tree: "tree-of-" + merge + ":modules/b", Manifest: hexOf(`{"b":1}`)},
}
if !reflect.DeepEqual(got, want) {
t.Errorf("trees = %+v\nwant %+v", got, want)
}
if got["modules/b"].Locks != nil {
t.Error("a module with no lock file carries a lock map")
}
for _, e := range envs {
if len(e) != 1 || e[0] != "X=1" {
t.Errorf("git ran with env %v, want the one given", e)
}
}
}
func TestModuleTreesFailsNamingTheGitCommandOrTheUnreadableManifest(t *testing.T) {
root := t.TempDir()
fail := func(on string) forgekey.Runner {
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
if args[2] == on {
return forgekey.Ran{Status: 128, Stderr: "fatal: no\n"}
}
return forgekey.Ran{Stdout: "modules/a/module.json\n"}
}
}
for _, on := range []string{"ls-files", "rev-parse"} {
if _, err := ModuleTrees(context.Background(), fail(on), nil, root, sha); err == nil || !strings.Contains(err.Error(), on) || !strings.Contains(err.Error(), "fatal: no") {
t.Errorf("%s failing: %v", on, err)
}
}
if _, err := ModuleTrees(context.Background(), fail("none"), nil, root, sha); err == nil {
t.Error("a module.json that is not in the workspace was read")
}
}
+160
View File
@@ -0,0 +1,160 @@
package buildstep
// A build module's room to work and its workspaces (novox/hq issue 1839, ADR 0509): a step is refused while the
// filesystem it writes to has less free than the floor, and a workspace is removed when its caller releases it, or,
// unheld, once its last use is past a bound (a lost caller's), never while a running step holds it.
import (
"fmt"
"os"
"path/filepath"
"regexp"
"sort"
"sync"
"syscall"
"time"
)
// FreeSpaceFloor is the free space a build module needs on its work and cache filesystems to take a step: the floor
// build-agent keeps (mesh-controller internal/builder/floor.go, issue 1834).
const FreeSpaceFloor uint64 = 20 << 30
// UnheldBound is how long a workspace no step holds is kept after its last use, when its caller never released it
// (ADR 0509: a lost caller's workspace goes after two hours).
const UnheldBound = 2 * time.Hour
// FreeBytes reads the bytes an unprivileged writer can still use on the filesystem holding path.
func FreeBytes(path string) (uint64, error) {
var s syscall.Statfs_t
if err := syscall.Statfs(path, &s); err != nil {
return 0, err
}
return s.Bavail * uint64(s.Bsize), nil
}
// Floor refuses a step while a filesystem it writes to has less free than Bytes. A zero Bytes checks nothing.
type Floor struct {
// Node is named in the refusal.
Node string
Bytes uint64
// Free reads a path's free bytes; FreeBytes when nil. A test answers for a disk it does not have.
Free func(path string) (uint64, error)
}
// Check answers a *Refusal, "refused: <node> has N GiB free under the floor of F GiB (on <path>)", when any path's
// filesystem has less free than the floor, or its free space cannot be read; nil otherwise. A path not made yet is
// read at its nearest folder that is; an empty path is skipped.
func (f Floor) Check(paths ...string) error {
if f.Bytes == 0 {
return nil
}
free := f.Free
if free == nil {
free = FreeBytes
}
for _, p := range paths {
if p == "" {
continue
}
at := p
for {
if _, err := os.Stat(at); err == nil || filepath.Dir(at) == at {
break
}
at = filepath.Dir(at)
}
n, err := free(at)
if err != nil {
return &Refusal{Text: fmt.Sprintf("refused: the free space of %s on %s cannot be read: %v", p, f.Node, err)}
}
if n < f.Bytes {
return &Refusal{Text: fmt.Sprintf("refused: %s has %d GiB free under the floor of %d GiB (on %s)", f.Node, n>>30, f.Bytes>>30, p),
Want: fmt.Sprintf("%d GiB", f.Bytes>>30), Have: fmt.Sprintf("%d GiB", n>>30)}
}
}
return nil
}
var workspaceID = regexp.MustCompile(`^[a-z0-9]+$`)
// Workspaces are a build module's workspaces, each <Dir>/<id>, and which of them running steps hold.
type Workspaces struct {
Dir string
mu sync.Mutex
held map[string]int
}
// Hold marks the workspace held by a running step until the answered func is called; both mark it used now, so the
// sweep counts its bound from the step's end. An id that is not a workspace's holds nothing.
func (w *Workspaces) Hold(id string) func() {
if !workspaceID.MatchString(id) {
return func() {}
}
w.mu.Lock()
if w.held == nil {
w.held = map[string]int{}
}
w.held[id]++
w.mu.Unlock()
w.touch(id)
var once sync.Once
return func() {
once.Do(func() {
w.touch(id)
w.mu.Lock()
if w.held[id]--; w.held[id] <= 0 {
delete(w.held, id)
}
w.mu.Unlock()
})
}
}
func (w *Workspaces) touch(id string) {
now := time.Now()
_ = os.Chtimes(filepath.Join(w.Dir, id), now, now) // a workspace not made yet has nothing to mark
}
// Release removes the workspace at once: its caller's chain of steps has ended. One already gone is no error; one a
// running step holds is refused and kept; an id that is not a workspace's is refused.
func (w *Workspaces) Release(id string) error {
if !workspaceID.MatchString(id) {
return fmt.Errorf("%q is not a workspace id", id)
}
w.mu.Lock()
defer w.mu.Unlock()
if w.held[id] > 0 {
return fmt.Errorf("the workspace %s is held by a running step", id)
}
if err := os.RemoveAll(filepath.Join(w.Dir, id)); err != nil {
return fmt.Errorf("the workspace %s could not be removed: %w", id, err)
}
return nil
}
// Sweep removes every workspace no step holds whose last use is older than bound, and answers the ids it removed,
// sorted. It is the safety net for a caller that never released its workspace.
func (w *Workspaces) Sweep(now time.Time, bound time.Duration) []string {
entries, err := os.ReadDir(w.Dir)
if err != nil {
return nil
}
removed := []string{}
w.mu.Lock()
defer w.mu.Unlock()
for _, e := range entries {
if !e.IsDir() || !workspaceID.MatchString(e.Name()) || w.held[e.Name()] > 0 {
continue
}
info, err := e.Info()
if err != nil || now.Sub(info.ModTime()) < bound {
continue
}
if os.RemoveAll(filepath.Join(w.Dir, e.Name())) == nil {
removed = append(removed, e.Name())
}
}
sort.Strings(removed)
return removed
}
+113
View File
@@ -0,0 +1,113 @@
package buildstep
import (
"errors"
"os"
"path/filepath"
"testing"
"time"
)
// 1839/AC1: a filesystem under the floor refuses the step, naming the node, what is free and the floor.
func TestAFilesystemUnderTheFloorRefusesNamingNodeFreeAndFloor(t *testing.T) {
dir := t.TempDir()
f := Floor{Node: "ace", Bytes: 20 << 30, Free: func(string) (uint64, error) { return 4<<30 + 5, nil }}
err := f.Check(dir)
var ref *Refusal
if !errors.As(err, &ref) {
t.Fatalf("Check = %v, want a *Refusal", err)
}
if want := "refused: ace has 4 GiB free under the floor of 20 GiB (on " + dir + ")"; err.Error() != want {
t.Errorf("Check = %q, want %q", err, want)
}
}
// 1839/AC1: at or above the floor, and with no floor set, nothing is refused.
func TestAtTheFloorOrWithNoFloorNothingIsRefused(t *testing.T) {
dir := t.TempDir()
if err := (Floor{Node: "ace", Bytes: 20 << 30, Free: func(string) (uint64, error) { return 20 << 30, nil }}).Check(dir); err != nil {
t.Errorf("at the floor: %v", err)
}
if err := (Floor{Node: "ace", Free: func(string) (uint64, error) { return 0, nil }}).Check(dir); err != nil {
t.Errorf("no floor: %v", err)
}
}
// 1839/AC1: a path not made yet is read at its nearest folder that is; a free space that cannot be read is a refusal.
func TestAPathNotMadeIsReadAtItsParentAndAnUnreadableOneRefuses(t *testing.T) {
dir := t.TempDir()
var read string
f := Floor{Node: "ace", Bytes: 1, Free: func(p string) (uint64, error) { read = p; return 2, nil }}
if err := f.Check(filepath.Join(dir, "not", "made")); err != nil || read != dir {
t.Errorf("Check = %v, read %q, want nil and %q", err, read, dir)
}
f.Free = func(string) (uint64, error) { return 0, errors.New("statfs: boom") }
var ref *Refusal
if err := f.Check(dir); !errors.As(err, &ref) {
t.Errorf("Check = %v, want a refusal", err)
}
}
func makeWorkspace(t *testing.T, w *Workspaces, id string, age time.Duration) string {
t.Helper()
p := filepath.Join(w.Dir, id)
if err := os.MkdirAll(filepath.Join(p, "tree"), 0o700); err != nil {
t.Fatal(err)
}
at := time.Now().Add(-age)
if err := os.Chtimes(p, at, at); err != nil {
t.Fatal(err)
}
return p
}
func gone(p string) bool { _, err := os.Stat(p); return os.IsNotExist(err) }
// 1839/AC2: a released workspace is gone at once; one already gone is no error; one a running step holds is refused
// and kept; an id that is not a workspace's is refused.
func TestReleaseRemovesAWorkspaceAtOnce(t *testing.T) {
w := &Workspaces{Dir: t.TempDir()}
p := makeWorkspace(t, w, "abc1", 0)
if err := w.Release("abc1"); err != nil || !gone(p) {
t.Fatalf("Release = %v, gone %v", err, gone(p))
}
if err := w.Release("abc1"); err != nil {
t.Errorf("a second release = %v, want nil", err)
}
held := makeWorkspace(t, w, "abc2", 0)
done := w.Hold("abc2")
if err := w.Release("abc2"); err == nil || gone(held) {
t.Errorf("Release of a held workspace = %v, gone %v; want refused and kept", err, gone(held))
}
done()
if err := w.Release("abc2"); err != nil || !gone(held) {
t.Errorf("Release after the hold ended = %v, gone %v", err, gone(held))
}
if err := w.Release("../etc"); err == nil {
t.Errorf("Release of ../etc answered nil")
}
}
// 1839/AC3: the sweep removes a workspace no step holds whose last use is past the bound, and keeps a held one and a
// recent one; a hold ending counts as a use.
func TestSweepRemovesUnheldWorkspacesPastTheBound(t *testing.T) {
w := &Workspaces{Dir: t.TempDir()}
old := makeWorkspace(t, w, "old1", 3*time.Hour)
heldOld := makeWorkspace(t, w, "held1", 3*time.Hour)
recent := makeWorkspace(t, w, "new1", time.Minute)
if err := os.WriteFile(filepath.Join(w.Dir, "not-a-workspace.txt"), nil, 0o600); err != nil {
t.Fatal(err)
}
done := w.Hold("held1")
removed := w.Sweep(time.Now(), 2*time.Hour)
if !gone(old) || gone(heldOld) || gone(recent) {
t.Errorf("after the sweep: old gone %v, held kept %v, recent kept %v", gone(old), !gone(heldOld), !gone(recent))
}
if len(removed) != 1 || removed[0] != "old1" {
t.Errorf("Sweep answered %v, want [old1]", removed)
}
done()
if w.Sweep(time.Now(), 2*time.Hour); gone(heldOld) {
t.Errorf("a workspace whose hold just ended was swept: its end is a use")
}
}
+125
View File
@@ -0,0 +1,125 @@
package buscompose
import (
"fmt"
"sort"
"strings"
)
// Do the emitters and the consumers of a catalogue agree?
//
// **The check that was missing** (novox/hq 04-ISSUES/127). Every manifest was individually
// well-formed and every derivation individually correct, and no cross-module subscription in the
// mesh matched anything: a consumer's declaration derived into a namespace nobody publishes to.
// Nothing failed, because a subscription that matches nothing is not an error — it is silence.
//
// The comparison has to be over the whole catalogue, because the two halves live in different
// manifests, and it cannot simply demand that every consumed event have a live emitter: a module
// may be installed long before the one whose events it wants. So the rule is narrower and still
// catches this: **where the emitter is present, it must emit what the consumer asked for.**
// AConsumer is one module's interest in another's events, as this check needs it.
type AConsumer struct {
Module string
Consumes []string
}
// AnEmitter is one module's events.
type AnEmitter struct {
Module string
Emits []string
}
// Disagreements are the consumed events whose emitter is in the catalogue and does not emit them.
//
// Returned as sentences rather than as structs: every one of them is read by a person deciding
// whether a manifest or a catalogue is wrong, and a pair of names without the reason is a puzzle.
func Disagreements(emitters []AnEmitter, consumers []AConsumer, seats []DeclaredSeat) []string {
emits := map[string]map[string]bool{}
for _, e := range emitters {
if emits[e.Module] == nil {
emits[e.Module] = map[string]bool{}
}
for _, name := range e.Emits {
emits[e.Module][name] = true
}
}
// A seat's events are published by its holder under the seat's name, so a consumer naming the
// seat is naming something real even though no module declares it as its own.
for _, s := range seats {
if len(s.Emits) == 0 {
continue
}
if emits[s.Name] == nil {
emits[s.Name] = map[string]bool{}
}
for _, name := range s.Emits {
emits[s.Name][name] = true
}
}
var out []string
for _, c := range consumers {
for _, pattern := range c.Consumes {
emitter, event, named := strings.Cut(pattern, ".")
// Every event from everyone, or every event from one module: both are deliberate and
// neither names a particular event to check.
if !named || emitter == "*" || emitter == catalogueTheRest || event == catalogueTheRest {
continue
}
known, present := emits[emitter]
if !present {
// Not installed here, which is ordinary: a module lives in its own repository and
// may be registered later. Nothing to compare, so nothing to say.
continue
}
if matchesAny(event, known) {
continue
}
out = append(out, fmt.Sprintf(
"%s consumes %q and %s emits %s — so that subscription would match nothing, and "+
"nothing would report it",
c.Module, pattern, emitter, listOf(known)))
}
}
sort.Strings(out)
return out
}
// matchesAny says whether one of an emitter's event names satisfies a consumer's pattern.
func matchesAny(pattern string, emitted map[string]bool) bool {
want := strings.Split(pattern, ".")
for name := range emitted {
if matches(want, strings.Split(name, ".")) {
return true
}
}
return false
}
func matches(pattern, name []string) bool {
for i, part := range pattern {
if part == catalogueTheRest {
return i < len(name)
}
if i >= len(name) {
return false
}
if part != "*" && part != name[i] {
return false
}
}
return len(pattern) == len(name)
}
func listOf(names map[string]bool) string {
if len(names) == 0 {
return "nothing"
}
out := make([]string, 0, len(names))
for n := range names {
out = append(out, n)
}
sort.Strings(out)
return strings.Join(out, ", ")
}
+97
View File
@@ -0,0 +1,97 @@
package buscompose
import (
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/announce"
)
// Who may read a module's state is what the module announced (novox/hq ADR 0495 clauses 2 and 3, ADR 0513 §3; issue
// 1790). A module says on the bus which states it keeps and which others may read (announce.Subject); the controller
// keeps the last word of each (announce.KeptBucket) and grants a reader's `reads: <owner>.<name>` only when the owner
// announced that state readable. An owner that has announced nothing yet is read as before, from the reader's `reads`
// (the transition, until every module announces), and is named as not yet announced.
// StatesAnnouncedBucket is the controller's bucket of the last announcement of each module (announce.KeptBucket).
const StatesAnnouncedBucket = announce.KeptBucket
// controllerReadable are the controller's states others may read: the record mirrors (the packages each node declares
// among them, which the pacman module reads, ADR 0507 §5), the chain's buckets, and the grants issued and the
// compositions' digests, which the assigner reads (ADR 0528 clause 2, ADR 0539 clause 6).
var controllerReadable = []string{MirrorAssignments, MirrorDeclaredConfig, MirrorPredefined, ChainBuilds, ChainRollouts,
MirrorDeclaredPackages, ControllerGrants, ControllerComposed}
// ControllerAnnouncement is what the controller announces it keeps (ADR 0495 clause 2): every bucket of its own by its local
// name, the ones others may read marked readable. It announces itself, as any module does from its code.
func ControllerAnnouncement(node string) announce.States {
s := announce.States{Module: ControllerSeat, Node: node}
readable := map[string]bool{}
for _, n := range controllerReadable {
readable[n] = true
}
for _, bucket := range ControllerBuckets() {
local, mine := strings.CutPrefix(bucket, ControllerSeat+"_")
if !mine {
continue
}
s.States = append(s.States, announce.State{Name: local, Readable: readable[local]})
}
sort.Slice(s.States, func(i, j int) bool { return s.States[i].Name < s.States[j].Name })
return s
}
// ReadsAnnounced splits a reader's `reads` (`<owner>.<name>`) by what each owner announced: granted when the owner
// announced the state readable, or announced nothing yet (the transition); refused when the owner announced and did
// not announce that state readable.
func ReadsAnnounced(reads []string, announced map[string]announce.States) (granted, refused []string) {
return ReadsAnnouncedBy("", reads, announced)
}
// ReadsAnnouncedBy is ReadsAnnounced for one reader module, which also honours the readers an owner named for a state
// (announce.State.Readers) and the composer's named readers (NamedReaders, ADR 0547 clause 9): a read named for other
// modules is refused. An empty reader is judged by the readable mark alone.
func ReadsAnnouncedBy(reader string, reads []string, announced map[string]announce.States) (granted, refused []string) {
for _, r := range reads {
owner, name, ok := strings.Cut(r, ".")
s, said := announced[owner]
switch {
case reader != "" && !ReaderMay(reader, r):
refused = append(refused, r)
case reader != "" && ok && said && s.Keeps(name):
if s.ReadableBy(name, reader) {
granted = append(granted, r)
} else {
refused = append(refused, r)
}
case !ok || !said || s.Readable(name):
granted = append(granted, r)
default:
refused = append(refused, r)
}
}
return granted, refused
}
// NotAnnounced are the owners some module reads the state of and that have announced nothing yet, sorted: read from the
// readers' `reads` as before, until they announce.
func NotAnnounced(r Records) []string {
seen := map[string]bool{}
var out []string
for _, ds := range r.Assigned {
for _, d := range ds {
for _, read := range append(append([]string(nil), d.Reads...), d.ReadsRefused...) {
owner, _, ok := strings.Cut(read, ".")
if !ok || seen[owner] {
continue
}
if _, said := r.Announced[owner]; !said {
seen[owner] = true
out = append(out, owner)
}
}
}
}
sort.Strings(out)
return out
}
+47
View File
@@ -0,0 +1,47 @@
package buscompose
import "git.novox.be/novox/mesh-sdk/go/delivery"
// The view alone reads the build seats' queues (novox/hq ADR 0525 §2, issue 1887 A8a): the shared workers' counts and the
// oldest waiting ask, to show the backlog. It may not publish an ask: only the builder and the checker ask, by their seat use.
// The watchman is granted nothing here: a waiting ask has no pulse, a taken one is read from the build module's steps (issue
// 1908). Nothing is a wildcard over a stream or a worker.
const (
// ViewModule is the module of the delivery view (mesh-delivery-view).
ViewModule = "mesh-delivery-view"
// BuilderModule is the module of the builder (mesh-delivery-builder).
BuilderModule = "mesh-delivery-builder"
)
// BuildAskReads are the API subjects the builder requests when it takes over, at its start, a key naming an ask (issue
// 1910): each build seat's stream's information and its messages by sequence, to tell whether the ask is on its stream.
// Reads only: no consumer, no pull, no acknowledgement; the builder puts its asks by its seat use, as before.
func BuildAskReads() []string {
var out []string
for _, lang := range delivery.AskLanguages {
if stream, err := delivery.AskStream(lang); err == nil {
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream)
}
}
return out
}
// BuildBacklogReads are the API subjects the view requests: the information of each build seat's stream and of each of its
// shared workers (delivery.AskStream, delivery.AskWorker), and the message of the stream by sequence. Reads only: no
// message is pulled, none acknowledged, none published.
func BuildBacklogReads() []string {
var out []string
for _, lang := range delivery.AskLanguages {
stream, err := delivery.AskStream(lang)
if err != nil {
continue
}
out = append(out, "$JS.API.STREAM.INFO."+stream, "$JS.API.STREAM.MSG.GET."+stream)
for _, kind := range delivery.AskKinds {
if w, err := delivery.AskWorker(lang, kind); err == nil {
out = append(out, "$JS.API.CONSUMER.INFO."+stream+"."+w)
}
}
}
return out
}
+255
View File
@@ -0,0 +1,255 @@
package buscompose
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"regexp"
"slices"
"strings"
"testing"
)
// Defends issue 1377 and novox/hq ADR 0547 §5: this is the controller's bus composer copied, and it composes byte for
// byte what the controller's composes. testdata/controller-user-lists.json was written by the controller's own
// internal/broker (mesh-controller branch fix/1945-mesh-grants-publishes-memberships, and fix/1790-grants-create-buckets for the grants part creating declared streams), lists and memberships, from fixtures_test.go compiled there unchanged; composed.conf is the
// controller's golden of the whole server configuration. A row that differs is the copy drifting from the controller,
// which is a fault while both compose (until the controller loses its lease, ADR 0547 §13), but for the grants the copy
// gives beyond it on purpose (beyondTheControllers): the copy grants every user everything the controller's grants, and
// only those more.
// createdAdvisory is one subscription to a bucket's creation in a composed list, as stateCreatedHeard composes it.
var createdAdvisory = regexp.MustCompile(`"\$JS\.EVENT\.ADVISORY\.STREAM\.CREATED\.KV_[^"]*", `)
// beyondTheControllers is what the copy grants beyond the controller's on purpose (novox/hq ADR 0547 §2, ADR 0555 §3,
// issue 1126), by user: a node's account reads every node's own facts, `<node>.node` of the have bucket where the node's
// record rides, and what it composes its own modules from (NodeComposeReads). After the take-over (ADR 0547 §8) the
// grants part's list is the one the bus loads; a hand-back loads the controller's, which lacks these reads, and the nodes
// keep applying without composing their own until the next take-over. Each principal's hearing of its buckets' creation
// (issue 1790) is the other row beyond, matched by createdAdvisory.
func beyondTheControllers(user string) map[string]bool {
out := map[string]bool{}
node, ok := strings.CutPrefix(user, "node.")
if !ok {
return out
}
for _, s := range append(readGrants("node-engine_have", "*.node"), NodeComposeReads(node)...) {
out[s] = true
}
return out
}
// mayBeyondTheControllers is what the copy may grant beyond the controller's where the records allow it, and need not
// where they do not: a node's ask of the vault's `own` while the vault serves it (issue 1126, ADR 0550 §5).
func mayBeyondTheControllers(user string) map[string]bool {
if _, ok := strings.CutPrefix(user, "node."); !ok {
return nil
}
return map[string]bool{VaultOwnCall(Principal{Kind: KindNode, Node: strings.TrimPrefix(user, "node.")}): true,
VaultSecretChanged: true}
}
// quotedToken is a quoted token of a composed list.
var quotedToken = regexp.MustCompile(`"[^"]*"`)
// grantsByUser splits a composed list into the text outside its users and each user's quoted tokens (its name, its
// password's hash, every subject it may publish or subscribe to), by user.
func grantsByUser(list string) (string, map[string]map[string]bool) {
var outside strings.Builder
users := map[string]map[string]bool{}
user := ""
for _, line := range strings.SplitAfter(list, "\n") {
trimmed := strings.TrimSpace(line)
if name, ok := strings.CutPrefix(trimmed, `{ user: "`); ok {
user, _, _ = strings.Cut(name, `"`)
users[user] = map[string]bool{}
} else if user != "" && trimmed == "]" {
user = ""
}
if user == "" {
outside.WriteString(line)
continue
}
for _, q := range quotedToken.FindAllString(line, -1) {
users[user][strings.Trim(q, `"`)] = true
}
}
return outside.String(), users
}
// grantsAsTheControllers fails t unless the copy's list is the controller's, user for user, but for the grants it
// names on purpose (beyondTheControllers, the creation advisories): every grant of the controller's is the copy's, and
// the copy's beyond them are exactly those, every one of them there.
func grantsAsTheControllers(t *testing.T, got, want string) {
t.Helper()
gotOutside, gotUsers := grantsByUser(got)
wantOutside, wantUsers := grantsByUser(want)
if gotOutside != wantOutside {
t.Errorf("the list differs from the controller's outside its users:\n%s", gotOutside)
}
for user, was := range wantUsers {
now, ok := gotUsers[user]
if !ok {
t.Errorf("the copy has no user %s", user)
continue
}
intended := beyondTheControllers(user)
for g := range was {
if !now[g] {
t.Errorf("%s: the copy does not grant %s, which the controller's does", user, g)
}
}
for g := range now {
if !was[g] && !intended[g] && !mayBeyondTheControllers(user)[g] && !createdAdvisory.MatchString(`"`+g+`", `) {
t.Errorf("%s: the copy grants %s beyond the controller's, and no decision names it", user, g)
}
}
for g := range intended {
if !now[g] {
t.Errorf("%s: the copy does not grant %s, which a node composing its own modules reads", user, g)
}
}
}
for user := range gotUsers {
if _, ok := wantUsers[user]; !ok {
t.Errorf("the copy has a user %s the controller's has not", user)
}
}
}
func TestTheCopiedComposerComposesAsTheControllers(t *testing.T) {
raw, err := os.ReadFile(filepath.Join("testdata", "controller-user-lists.json"))
if err != nil {
t.Fatal(err)
}
var want []struct {
Name, List string
Missing []string
Err string
Memberships json.RawMessage
}
if err := json.Unmarshal(raw, &want); err != nil {
t.Fatal(err)
}
fixtures := UserListFixtures()
if len(want) != len(fixtures) {
t.Fatalf("%d fixtures, %d lists the controller composed", len(fixtures), len(want))
}
for i, f := range fixtures {
t.Run(f.Name, func(t *testing.T) {
if want[i].Name != f.Name {
t.Fatalf("fixture %q against the controller's %q", f.Name, want[i].Name)
}
list, missing, err := ComposedFor(f)
got := ""
if err != nil {
got = err.Error()
}
if got != want[i].Err {
t.Fatalf("error %q, the controller's %q", got, want[i].Err)
}
if strings.Join(missing, ",") != strings.Join(want[i].Missing, ",") {
t.Errorf("left out %v, the controller %v", missing, want[i].Missing)
}
// The one row the copy composes beyond the controller's, on purpose (novox/hq issue 1790, 2026-10-26, after
// the take-over of ADR 0547 milestone 2): each principal hears its own and read buckets created, so a state
// missing at its start is bound when it appears. The controller's composer is frozen and its list no longer
// loaded; while it is not, this row is left out of the comparison and nothing else.
grantsAsTheControllers(t, list, want[i].List)
// And every membership the records issue, as the controller's MembershipFor makes it (ADR 0547 §5): the
// grants part publishes them.
raw, err := json.Marshal(MembershipsFor(f))
if err != nil {
t.Fatal(err)
}
var gotM, wasM any
if err := json.Unmarshal(raw, &gotM); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(want[i].Memberships, &wasM); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(gotM, wasM) {
t.Errorf("the memberships differ from the controller's:\n%s", raw)
}
})
}
// An asker on a seat pulled by kind hears what that seat's workers gave up on, and no other stream's dead letters
// (novox/hq issue 734: a check ask given up during a bus reload left its try running for ever).
t.Run("an asker reads its seat's dead letters", func(t *testing.T) {
build := Seat{Name: "node-build-go", Scope: "node", Accepts: []string{"delivery", "check", "recheck"}, PulledByKind: true}
perms, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "mesh-delivery-checker", Uses: []Seat{build}})
if err != nil {
t.Fatal(err)
}
for subject, want := range map[string]bool{
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.>": true,
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.SEAT_NODE_BUILD_GO.w.next": true,
"$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1": true,
"$JS.API.CONSUMER.MSG.NEXT.DEAD_LETTERS.x1": true,
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1.mesh.events.dead.EVENTS.>": false,
"$JS.API.CONSUMER.CREATE.DEAD_LETTERS.x1": false,
"$JS.API.STREAM.PURGE.DEAD_LETTERS": false,
"$JS.FC.DEAD_LETTERS.x1": false,
} {
if got := MayPublish(perms, subject); got != want {
t.Errorf("may publish %s: %v, want %v", subject, got, want)
}
}
plain, err := PermissionsFor(Principal{Kind: KindModule, Node: "one", Module: "shop",
Uses: []Seat{{Name: "telegram-sender", Accepts: []string{"send"}}}})
if err != nil {
t.Fatal(err)
}
if MayPublish(plain, "$JS.API.CONSUMER.DELETE.DEAD_LETTERS.x1") {
t.Error("a user of a seat not pulled by kind reads DEAD_LETTERS")
}
})
// Issue 1126 (ADR 0550 §5): a node's engine asks the vault's `own` in its own name while the vault serves it, and no
// other node's.
t.Run("a node asks the vault its own secrets", func(t *testing.T) {
one := Principal{Kind: KindNode, Node: "one", AsksItsOwnSecrets: true}
perms, err := PermissionsFor(one)
if err != nil {
t.Fatal(err)
}
if !MayPublish(perms, VaultOwnCall(one)) || MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})) {
t.Errorf("node one asks own as %v, and as two %v", MayPublish(perms, VaultOwnCall(one)),
MayPublish(perms, VaultOwnCall(Principal{Kind: KindNode, Node: "two"})))
}
if plain, _ := PermissionsFor(Principal{Kind: KindNode, Node: "one"}); MayPublish(plain, VaultOwnCall(one)) {
t.Error("a node asks own while the vault does not serve it")
}
if !slices.Contains(perms.Subscribe, VaultSecretChanged) {
t.Errorf("node one does not hear the vault's secret-changed: %v", perms.Subscribe)
}
})
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(
Server{ClientPort: 4222, MonitoringPort: 8222, StoreDir: "/data",
TLSCert: "/tls/tls.crt", TLSKey: "/tls/tls.key", TLSCA: "/tls/ca.crt"},
[]Principal{
{Kind: KindController, PasswordHash: "$2a$11$cccccccccccccccccccccc"},
{Kind: KindEnrolment, Node: "one", PasswordHash: "$2a$11$eeeeeeeeeeeeeeeeeeeeee"},
{Kind: KindNode, Node: "one", PasswordHash: "$2a$11$nnnnnnnnnnnnnnnnnnnnnn"},
{Kind: KindModule, Node: "one", Module: "telegram", Holds: []Seat{seat},
Serves: []string{"status"}, PasswordHash: "$2a$11$tttttttttttttttttttttt"},
{Kind: KindModule, Node: "two", Module: "shop", Uses: []Seat{seat},
Emits: []string{"order.placed"}, PasswordHash: "$2a$11$ssssssssssssssssssssss"},
{Kind: KindModule, Node: "two", Module: "audit",
Consumes: []string{"shop.order.placed"}, PasswordHash: "$2a$11$aaaaaaaaaaaaaaaaaaaaaa"},
{Kind: KindModule, Node: "one", Module: WatchmanModule, PasswordHash: "$2a$11$wwwwwwwwwwwwwwwwwwwwww"},
{Kind: KindModule, Node: "one", Module: "nats", SnapshotsTheBus: true,
Serves: []string{"nats_streams"}, PasswordHash: "$2a$11$bbbbbbbbbbbbbbbbbbbbbb"},
})
if err != nil {
t.Fatal(err)
}
want, err := os.ReadFile(filepath.Join("testdata", "composed.conf"))
if err != nil {
t.Fatal(err)
}
grantsAsTheControllers(t, got, string(want))
})
}
+117
View File
@@ -0,0 +1,117 @@
package buscompose
import (
"regexp"
"strings"
)
// A tool call names its caller (novox/hq issue 365, by ADR 0259 §3's precedent for asks).
//
// mesh.mod.<module>.call.<tool>[.<node>].<caller>
// mesh.seat.<seat>.call.<verb>[.<node>].<caller>
//
// The last token is the bus user that published the call, and each user is granted these subjects with its own
// name there and no other — the caller is a fact the server enforces, and the tool runtime hands it to the
// module from the subject a call arrived on (mesh-tools node-tools internal/bus caller.go holds the same shape).
//
// **A kind of its own, `call`, not the `tool` subject with a token appended.** Every grant to call a tool is a
// wildcard over the `tool` kind — `.tool.<t>.*` for the instance on any machine, `mesh.mod.*.tool.>` for every
// tool — and either would match a `tool` subject with any caller appended, so a caller could name another.
// Under `call` nothing is granted but the caller-named subjects. No stream's filter covers it: a tool call is
// never persisted (design 25 §3).
//
// **Derived from the `tool` grants, in one place** (callerNamed): wherever a principal may call or answer a
// tool, it may call it in its own name, or answer it naming any caller — so no kind of principal is left
// unable to call in its own name, and a new grant to call is one in both shapes by construction.
//
// The `tool` grants stay beside these for one release, so every caller and every runtime moves without a gap:
// their retirement is hq issue 464.
const CallKind = "call"
var userName = regexp.MustCompile(`^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)*$`)
// CallerToken is a bus user's name as the last token of a call it publishes: each dot written `~`, which no part
// of a user's name may hold (safeSubject), so the token names that user and no other. "" for a name that is not
// a user's.
func CallerToken(user string) string {
if !userName.MatchString(user) {
return ""
}
return strings.ReplaceAll(user, ".", "~")
}
// toolGrant splits a grant on the `tool` kind — `mesh.mod.<m>.tool.<rest>` or `mesh.seat.<s>.tool.<rest>`, any
// part possibly a wildcard — at the kind; ok is false for any other subject.
func toolGrant(subject string) (head, rest string, ok bool) {
parts := strings.SplitN(subject, ".", 5)
if len(parts) != 5 || parts[0] != "mesh" || (parts[1] != "mod" && parts[1] != "seat") || parts[3] != "tool" ||
parts[2] == "" || parts[4] == "" {
return "", "", false
}
return parts[0] + "." + parts[1] + "." + parts[2], parts[4], true
}
// CalledSubject is where a call to a tool subject goes naming its caller; "" when the subject is not a tool's
// or the user is not a bus user.
func CalledSubject(subject, user string) string {
head, rest, ok := toolGrant(subject)
token := CallerToken(user)
if !ok || token == "" || strings.ContainsAny(rest, "*>") {
return ""
}
return head + "." + CallKind + "." + rest + "." + token
}
// CalledPattern is what a holder answering a tool subject also subscribes, to hear the calls that name their
// caller: the same address under the `call` kind, any caller last. "" for a subject that is not a tool's.
func CalledPattern(subject string) string {
head, rest, ok := toolGrant(subject)
if !ok || strings.ContainsAny(rest, "*>") {
return ""
}
return head + "." + CallKind + "." + rest + ".*"
}
// calledPublish is a grant to call on the `tool` kind, as the same grant in the caller's own name: its last
// token the caller's, and nothing that reaches past it. A `>` is every tail a call carries — the tool alone or
// the tool and the machine — so it becomes both, each ending in the caller.
func calledPublish(grant, token string) []string {
head, rest, ok := toolGrant(grant)
if !ok || token == "" {
return nil
}
base := head + "." + CallKind + "."
switch {
case rest == ">":
return []string{base + "*." + token, base + "*.*." + token}
case strings.HasSuffix(rest, ".>"):
return []string{base + strings.TrimSuffix(rest, ">") + "*." + token}
}
return []string{base + rest + "." + token}
}
// calledSubscribe is a grant to answer on the `tool` kind, as the same grant for the calls naming any caller.
func calledSubscribe(grant string) []string {
head, rest, ok := toolGrant(grant)
if !ok {
return nil
}
base := head + "." + CallKind + "."
if strings.HasSuffix(rest, ">") {
return []string{base + rest}
}
return []string{base + rest + ".*"}
}
// callerNamed adds, beside a principal's grants on the `tool` kind, the same grants under `call`: to publish in
// its own name, to subscribe naming anybody.
func callerNamed(user string, pub, sub []string) ([]string, []string) {
token := CallerToken(user)
for _, g := range pub {
pub = append(pub, calledPublish(g, token)...)
}
for _, g := range sub {
sub = append(sub, calledSubscribe(g)...)
}
return unique(pub), unique(sub)
}
+11
View File
@@ -0,0 +1,11 @@
package buscompose
import "strings"
// A seat's cancelled set (novox/hq ADR 0219) is a bucket the mesh no longer raises or grants (R4g, ADR 0525 clause 6): the
// controller asks no work queue, so nothing is cancelled. A bus that still holds one is not reported as state nothing declares.
// IsCancelledSet says a bucket is a seat's cancelled set rather than a module's state.
func IsCancelledSet(bucket string) bool {
return strings.HasPrefix(bucket, "SEAT_") && strings.HasSuffix(bucket, "_cancelled")
}
+196
View File
@@ -0,0 +1,196 @@
package buscompose
import (
"context"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go/jetstream"
)
// The compositions' buckets (novox/hq ADR 0539 clauses 2 and 4): one per node, `composed-<node>`, holding that node's
// compositions, which only that node's node-engine reads, since a composition can carry a sealed value (ADR 0488 §2's
// reason); and `mesh-controller_composed`, holding each composition's digest and the judgement of its move, which the
// assigner reads. The controller writes both, by compare-and-set while it holds the lease; each keeps five revisions
// of a key. The names and layouts are mesh-sdk's (delivery.ComposedBucketFor, ControllerComposedBucket, ComposedKey).
// ControllerComposed is the local name of the compositions' digests, as a reader names it in `reads` after
// `mesh-controller.`: delivery.ControllerComposedBucket.
const ControllerComposed = "composed"
// ComposedValueBytes is the most a composition may hold: a file over 64 KiB travels by digest, so a module's
// composition is far smaller; just under the server's largest message, which also carries the subject and headers.
const ComposedValueBytes = 1<<20 - 8<<10
// ComposedBucketBytes is a node's composition bucket's size: five revisions of every module's compositions of the node.
const ComposedBucketBytes = 256 << 20
// ComposedDigestsBytes is the size of the bucket of digests: a value of a few hundred bytes per node, module and
// fingerprint kept.
const ComposedDigestsBytes = 64 << 20
// ComposedConfig is a node's composition bucket as the controller asserts it.
func ComposedConfig(node string) (jetstream.KeyValueConfig, error) {
bucket, err := delivery.ComposedBucketFor(node)
if err != nil {
return jetstream.KeyValueConfig{}, err
}
return jetstream.KeyValueConfig{
Bucket: bucket,
Description: "the compositions of " + node + " (novox/hq ADR 0539): each module's resources for this node at one " +
"fingerprint, and the node's own, signed; written by the controller alone, read by this node's node-engine " +
"alone; deleted when outside the module's five newest fingerprints and named by no want",
History: delivery.ComposedHistory,
MaxValueSize: ComposedValueBytes,
MaxBytes: ComposedBucketBytes,
Storage: jetstream.FileStorage,
}, nil
}
// ComposedDigestsConfig is the bucket of the compositions' digests as the controller asserts it.
func ComposedDigestsConfig() jetstream.KeyValueConfig {
return jetstream.KeyValueConfig{
Bucket: delivery.ControllerComposedBucket,
Description: "each composition's digest and the judgement of its move from what the node runs (novox/hq ADR 0539 " +
"clauses 2 and 14): written by the controller alone, read by the assigner",
History: delivery.ControllerComposedHistory,
MaxValueSize: 4 << 10,
MaxBytes: ComposedDigestsBytes,
Storage: jetstream.FileStorage,
}
}
// EnsureComposed creates the bucket of digests and each named node's composition bucket if absent and brings their
// options to match. An update, never a delete.
func EnsureComposed(ctx context.Context, js jetstream.JetStream, nodes ...string) error {
if _, err := js.CreateOrUpdateKeyValue(ctx, ComposedDigestsConfig()); err != nil {
return fmt.Errorf("asserting bucket %s: %w", delivery.ControllerComposedBucket, err)
}
for _, node := range nodes {
cfg, err := ComposedConfig(node)
if err != nil {
return err
}
if _, err := js.CreateOrUpdateKeyValue(ctx, cfg); err != nil {
return fmt.Errorf("asserting bucket %s: %w", cfg.Bucket, err)
}
}
return nil
}
// ComposedWrites is what the controller publishes to write a node's compositions: the bucket's own subjects.
func ComposedWrites(node string) (string, error) {
bucket, err := delivery.ComposedBucketFor(node)
if err != nil {
return "", err
}
return "$KV." + bucket + ".>", nil
}
// ComposedReads is what a node's node-engine publishes to follow its wants and compositions (ADR 0539 clauses 1, 2 and
// 9): every key of its own composition bucket, and its own wants (`want.<node>.<module>` and `want.<node>.node`) in the
// assigner's bucket; nothing of another node's, nor the bucket of digests.
func ComposedReads(node string) ([]string, error) {
bucket, err := delivery.ComposedBucketFor(node)
if err != nil {
return nil, err
}
if !safeSubject.MatchString(node) {
return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node)
}
return append(readGrants(bucket, ""), readGrants(delivery.AssignerWantsBucket, "want."+node+".>")...), nil
}
// VerbsTheNodeAgentAsks are the seat verbs a node's node-engine calls of its own machine while it applies a want (ADR
// 0539 clause 16, ADR 0429 §14): its backup holder backs a data module up before it moves, and says when it was. Asked
// of its own machine only, `mesh.seat.<seat>.tool.<verb>.<node>`.
var VerbsTheNodeAgentAsks = []SeatVerb{{Seat: "node-backup", Verb: "now"}, {Seat: "node-backup", Verb: "backed-up"}}
// NodeAgentAsks are the subjects of VerbsTheNodeAgentAsks for one node.
func NodeAgentAsks(node string) ([]string, error) {
if !safeSubject.MatchString(node) {
return nil, fmt.Errorf("%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", node)
}
var out []string
for _, v := range VerbsTheNodeAgentAsks {
out = append(out, "mesh.seat."+v.Seat+".tool."+v.Verb+"."+node)
}
return out, nil
}
// NodeAgentHears are the events a node's node-engine subscribes while it applies a want: the backup holder's word that a
// backup is taken (`backed-up`), from whichever module holds node-backup on its machine (restic today).
var NodeAgentHears = []string{"mesh.mod." + BackupHolderModule + ".event.backed-up"}
// BackupHolderModule is the module that holds node-backup on every node, restic: the one whose word a node-engine hears.
// A second holder of the seat is a change of this grant.
const BackupHolderModule = "restic"
// administers says a person may invoke every tool (`*`): the operator.
func administers(invokes []string) bool {
for _, i := range invokes {
if i == "*" {
return true
}
}
return false
}
// HandPathWrites is what the operator's account publishes for release 6's hand path (ADR 0539 clauses 2 and 10): a want
// of any node, written and read back, in the assigner's bucket; each named node's compositions; the digests; and each
// node's object bucket for a file over 64 KiB, whose put reads an object's metadata and purges a replaced one's chunks.
func HandPathWrites(nodes []string) ([]string, error) {
out := append([]string{"$KV." + delivery.AssignerWantsBucket + ".want.>"}, readGrants(delivery.AssignerWantsBucket, "want.>")...)
out = append(out, "$KV."+delivery.ControllerComposedBucket+".>")
out = append(out, readGrants(delivery.ControllerComposedBucket, "")...)
for _, node := range nodes {
w, err := ComposedWrites(node)
if err != nil {
return nil, err
}
bucket, _ := delivery.ComposedBucketFor(node)
out = append(out, w)
out = append(out, readGrants(bucket, "")...)
files, err := DeclarationFilesWrites(node)
if err != nil {
return nil, err
}
stream := "OBJ_" + DeclarationFilesBucket + node
out = append(out, files, "$JS.API.STREAM.INFO."+stream, "$JS.API.DIRECT.GET."+stream+".>",
"$JS.API.STREAM.MSG.GET."+stream, "$JS.API.STREAM.PURGE."+stream)
}
return out, nil
}
// DeclarationsStream is the stream of each node's last declaration, `mesh.node.<node>.declare` (issue 107). Release 6
// sends none (ADR 0539 clauses 5 and 11): the stream is kept, with each node's consumer, only so a node away during the
// switch finds its last declaration and with it the new node-engine; the controller deletes it once every node's have
// names the new node-engine, and from then on neither asserts it nor any node's consumer on it; nor makes it again once
// it is gone from the bus for any reason, since a stream made anew would hold no node's last declaration (issue 1945).
const DeclarationsStream = "NODES"
// RaiseAfterDeclarations is Raise once the declarations' stream is retired: the mesh's streams and consumers but the
// declarations' stream, and no node's consumer of a declaration.
func RaiseAfterDeclarations(r Raiser) error {
for _, s := range MeshStreams() {
if s.Name == DeclarationsStream {
continue
}
if err := r.EnsureStream(s); err != nil {
return fmt.Errorf("asserting stream %s: %w", s.Name, err)
}
}
return AssertMeshConsumers(r)
}
// TakeInConsumer is the durable consumer the controller takes done keys in from, on the stream of each of TakeInBuckets
// (novox/hq ADR 0544 §1).
const TakeInConsumer = "mesh-controller-take-in"
// TakeInBuckets are the buckets the take-in consumes: the indexer's modules (each module's trunk value) and the builder's
// builds (each build's key).
var TakeInBuckets = []string{delivery.ModulesBucket, delivery.BuilderBuildsBucket}
// ConfigFollowConsumer is the durable consumer the controller follows mesh-config's resolved values with, on that
// bucket's stream (novox/hq ADR 0545).
const ConfigFollowConsumer = "mesh-controller-config-follow"
+247
View File
@@ -0,0 +1,247 @@
package buscompose
import (
"context"
"fmt"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats.go/jetstream"
)
// The controller's own key-value buckets (novox/hq to-be 45 §1, §6, §7).
//
// **What the controller must remember across its own restart, it keeps on the bus.** A call's
// outcome lived in the memory of the process that served it (novox/hq issue 265), so a controller
// replaced while a push ran answered "no such call" for the one thing its caller had been told to
// ask about. The bus already outlives the controller and is the shape ADR 0201 gives a module's
// current state: one value per key, written by one owner, read by anybody granted it. These are the
// controller's, written by it alone — the writers table of to-be 45 §1 — and asserted on every start
// like the streams, so a bus raised from nothing has them before the first call is served.
// CallsBucket keeps every call of the mesh's own verbs and what came of it; HandActsBucket every act
// a person did by hand, with why; ConditionsBucket every condition open now (to-be 45 §2), one key
// each, and ConditionHistoryBucket every transition of one — raised, changed, silenced, cleared —
// for ninety days.
//
// **The history is a bucket of its own** because its keys expire and an open condition's must not:
// a bucket has one age for every key, and a condition open longer than the history is kept would
// otherwise vanish from the store while still true.
var (
CallsBucket = BucketName(ControllerSeat, "calls")
HandActsBucket = BucketName(ControllerSeat, "hand-acts")
ConditionsBucket = BucketName(ControllerSeat, "conditions")
ConditionHistoryBucket = BucketName(ControllerSeat, "condition-history")
// LeaseBucket holds the controller's lease (to-be 45 §6): one key, `holder`, which the instance
// allowed to act writes by compare-and-set and renews; its revision when taken is the epoch.
LeaseBucket = BucketName(ControllerSeat, "lease")
// AskedBucket keeps what the controller asked the operator about its conditions (novox/hq ADR 0259):
// each ask by its id, its options and the actions they stand for, how it ended and whether the
// controller acted on its warrant — so a restart neither asks twice nor acts twice.
AskedBucket = BucketName(ControllerSeat, "asked")
// DeliveryMergesBucket holds the merges mesh-delivery announced it made, each with the ask whose Approve it merged
// on (novox/hq ADR 0420 §7, issue 1025): per merge a key `<owner>/<repo>/<number>/<head sha>` holding the
// announcement, kept DeliveryMergesKeptFor. What lets the build of a moved module register it from its new
// repository: the controller reads the ask's warrant from the router and accepts only the operator's Approve of
// those heads. Written by the controller alone, from the announcement, when it holds the lease.
DeliveryMergesBucket = BucketName(ControllerSeat, "delivery-merges")
)
// DeliveryMergesKeptFor is how long a merge mesh-delivery announced is kept: a week, as the events are.
const DeliveryMergesKeptFor = 7 * 24 * time.Hour
// The controller's record mirrors (novox/hq ADR 0289, design 54, design 57 package F3): a bucket per kind of
// record, one key per record, written from the store by the lease holder only when the record changed, with
// one event on the controller's seat per write. Read by whatever names them in `reads` as
// `mesh-controller.<mirror>` (ADR 0201 §1), written by nobody else. **No secret in either** (ADR 0201): an
// assignment's value names its module and node; a module's declared config is what its manifest, which is
// public, declares.
//
// AssignmentsBucket holds which module is assigned to which node, keyed `<node>.<module>`; and
// DeclaredConfigBucket each registered module's config declarations as its manifest holds them, keyed by
// the module: what mesh-config resolves a config value from (ADR 0348 clauses 2 and 4); and PredefinedBucket the
// list of predefined keys the mesh published (the newest trunk build's of the catalogue, ADR 0348 §7), one key
// `list`: what mesh-config publishes as mesh-config_predefined (design 57 K3, issue 662).
var (
AssignmentsBucket = BucketName(ControllerSeat, MirrorAssignments)
DeclaredConfigBucket = BucketName(ControllerSeat, MirrorDeclaredConfig)
PredefinedBucket = BucketName(ControllerSeat, MirrorPredefined)
// DeclaredPackagesBucket holds, keyed by the node, each package the modules assigned to it declare, present or absent,
// with the declaring modules: what the pacman module's verbs read to say a package is declared (ADR 0507 §5).
DeclaredPackagesBucket = BucketName(ControllerSeat, MirrorDeclaredPackages)
)
// The mirrors' local names, as a reader names them in `reads` after `mesh-controller.`.
const (
MirrorAssignments = "assignments"
MirrorDeclaredConfig = "declared-config"
MirrorPredefined = "predefined"
// MirrorDeclaredPackages is the local name of the packages each node's modules declare (ADR 0507 §5, issue 1840/S3).
MirrorDeclaredPackages = "declared-packages"
)
// The chain's buckets (novox/hq ADR 0408, issue 1089): builds, rollouts and assignments are one chain kept on the bus
// alone, one bucket each, one key per record, written by the lease holder by compare-and-set. The assignments bucket is
// AssignmentsBucket above, which keeps its name and its readers (ADR 0408 §3). Nothing is removed from them (§4), they keep
// ChainHistory earlier revisions of every key (§5), and a value is never cut (§6).
var (
BuildsBucket = BucketName(ControllerSeat, ChainBuilds)
RolloutsBucket = BucketName(ControllerSeat, ChainRollouts)
)
// **The assignments bucket keeps the marker age (MirrorMarkerTTL) it was made with as a mirror.** A stream that allows
// messages an age cannot be made to disallow them (the server refuses the update: "message TTL status can not be
// disabled", nats-server stream.go), so asserting the bucket without the age would fail the controller's start on a
// mesh that has the bucket. The chain never purges or removes a key (ADR 0408 §4), so no marker is ever written and the
// age ends nothing; it is kept only so the stream's configuration stays one the server accepts.
//
// The chain buckets' local names, as a reader names them in `reads` after `mesh-controller.`.
const (
ChainBuilds = "builds"
ChainRollouts = "rollouts"
)
// The chain buckets' bounds. ChainHistory is the bus's largest history (ADR 0408 §5). A value is at most ChainValueBytes,
// just under the server's largest message of a mebibyte, which also carries the subject and the headers (ADR 0408 §6):
// the controller writes none larger and the move refuses to. The buckets' size is the arithmetic of the survey of
// issue 981: 8585 builds of 1 to 40 KB, 1049 rollouts of about 2 KB a revision and 64 revisions, so 512 MiB for the two
// that grow, and the assignments, 285 of under a kilobyte, 64 MiB.
const (
ChainHistory = 64
ChainValueBytes = 1<<20 - 8<<10
ChainBuildsBytes = 512 << 20
ChainRolloutsBytes = 512 << 20
ChainAssignmentBytes = 64 << 20
)
// GrantsBucket holds, keyed `<module>.<fingerprint hex>`, each fingerprint of a module whose bus credential the controller
// has issued (novox/hq ADR 0528 clause 2, ADR 0429 §6 rule 6, issue 1394): written by the controller alone once the bus's
// user list composed with the module's manifest at that fingerprint was sent to the bus's machine; read by the assigner,
// whose sixth rule moves a node's want to a fingerprint only once its grant exists. The name and layout are mesh-sdk's
// (delivery.ControllerGrantsBucket, ControllerGrantKey).
var GrantsBucket = BucketName(ControllerSeat, ControllerGrants)
// ControllerGrants is the grants bucket's local name, as a reader names it in `reads` after `mesh-controller.`.
const ControllerGrants = "grants"
// ChainBuckets are the three buckets of the chain: builds, rollouts and assignments.
func ChainBuckets() []string { return []string{BuildsBucket, RolloutsBucket, AssignmentsBucket} }
// MirrorsWrittenByAnother are the controller's mirrors whose writer is not the controller, each with its reason: the rule
// that a mirror is written by the controller alone (TestTheMirrorsListAndTheWritersTableAgree) has these exceptions and no
// other.
var MirrorsWrittenByAnother = map[string]string{}
// The mirrors' caps (design 54 §5): one value per key, no age, 64 KiB a value, 64 MiB a bucket.
const (
MirrorValueBytes = 64 << 10
MirrorBytes = 64 << 20
// MirrorMarkerTTL is the longest a purge's marker is kept (design 54 §2): the bucket allows a
// per-message age for it, and the writer gives each marker a day.
MirrorMarkerTTL = 24 * time.Hour
)
// PlanStateBuckets are the buckets of the plans' state that release 6 deletes (novox/hq ADR 0539 clause 13): the merge
// window's clock, the silent sends' waits and the building plans' hold. The controller deletes each at its first start
// and says it once (composed_start.go); none is asserted, granted or written any more.
var PlanStateBuckets = []string{BucketName(ControllerSeat, "merge-window"), BucketName(ControllerSeat, "rollout-silent"),
BucketName(ControllerSeat, "plans-building")}
// AskedKeptFor is how long an ask is kept after it was made: a month, as the router keeps its own.
const AskedKeptFor = 30 * 24 * time.Hour
// LeaseTTL is how long the lease's key lives unrenewed (to-be 45 §6): fifteen seconds, renewed
// every five. The bucket's age, so the bus forgets a holder that stopped renewing.
const LeaseTTL = 15 * time.Second
// The bounds to-be 45 §6 sets for calls: the last thousand, or fourteen days, whichever is fewer.
// A call is two keys — its record, and its answer apart so a listing does not read every answer —
// so the stream holds twice as many messages as it keeps calls.
const (
KeptCallsDurably = 1000
CallsKeptFor = 14 * 24 * time.Hour
// CallAnswerBytes is the most of one answer kept: a whole declaration is far smaller, and an
// answer larger is cut and says so.
CallAnswerBytes = 64 << 10
// HandActsKeptFor is as long as a condition's history (to-be 45 §2): an act by hand is read
// back beside what it addressed.
HandActsKeptFor = 90 * 24 * time.Hour
// ConditionHistoryKeptFor is how long a condition's transitions are kept (to-be 45 §2).
ConditionHistoryKeptFor = 90 * 24 * time.Hour
)
// IsControllerBucket says a bucket is the controller's own, not a module's state nothing declares.
func IsControllerBucket(bucket string) bool {
return bucket == CallsBucket || bucket == HandActsBucket || bucket == ConditionsBucket ||
bucket == ConditionHistoryBucket || bucket == LeaseBucket || bucket == AskedBucket ||
bucket == AssignmentsBucket || bucket == DeclaredConfigBucket || bucket == PredefinedBucket ||
bucket == DeclaredPackagesBucket ||
bucket == DeliveryMergesBucket || bucket == BuildsBucket || bucket == RolloutsBucket ||
bucket == delivery.HaveBucket || bucket == StatesAnnouncedBucket ||
bucket == GrantsBucket || bucket == delivery.ControllerComposedBucket
}
// ControllerBuckets are the controller's own buckets, in the order they are asserted.
func ControllerBuckets() []string {
return []string{LeaseBucket, CallsBucket, HandActsBucket, ConditionsBucket, ConditionHistoryBucket, AskedBucket,
AssignmentsBucket, DeclaredConfigBucket, PredefinedBucket, DeclaredPackagesBucket,
DeliveryMergesBucket, BuildsBucket, RolloutsBucket, delivery.HaveBucket, StatesAnnouncedBucket, GrantsBucket,
delivery.ControllerComposedBucket}
}
// ControllerBucketsAsserter is what raising the controller's buckets needs of a connection.
type ControllerBucketsAsserter interface {
EnsureControllerBuckets() error
}
// chainBucket is the options of a bucket of the chain: sixty-four revisions of each key, no age, a value up to
// ChainValueBytes, on disk.
func chainBucket(bucket, why string, bytes int64, markers time.Duration) jetstream.KeyValueConfig {
return jetstream.KeyValueConfig{
Bucket: bucket,
Description: why,
History: ChainHistory,
MaxValueSize: ChainValueBytes,
MaxBytes: bytes,
Storage: jetstream.FileStorage,
LimitMarkerTTL: markers,
}
}
// EnsureLeaseBucket creates the lease's bucket if absent and brings its options to match (to-be 45
// §6). **Before the lease is taken, by any candidate**: it is the lease's own precondition, and asserting
// a bucket that exists changes nothing. File storage, so its revisions — the epochs — outlive a restart of
// the bus; one raised again from nothing is moved past the highest epoch issued when the lease is taken.
func EnsureLeaseBucket(ctx context.Context, js jetstream.JetStream) error {
if _, err := js.CreateOrUpdateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: LeaseBucket,
Description: "the controller's lease (novox/hq to-be 45 §6): the key `holder`, written by compare-and-set " +
"by the one controller instance that may act and renewed every five seconds; its revision when taken " +
"is the epoch every declaration and plan write carries",
History: 1,
TTL: LeaseTTL,
MaxValueSize: 4 << 10,
MaxBytes: 1 << 20,
Storage: jetstream.FileStorage,
}); err != nil {
return fmt.Errorf("asserting bucket %s: %w", LeaseBucket, err)
}
return nil
}
// HaveBucketConfig is the options of the have bucket (novox/hq ADR 0429 §1): delivery.HaveHistory revisions of each
// key, no age, 64 MiB on disk like the assignments. A module's have is JSON of about 500 bytes, but a node's own key
// carries its whole facts (its profile among them), several kilobytes on a desktop: a value is as large as the chain's
// buckets take, since a value is never cut (novox/hq ADR 0408 §6, issue 1162).
func HaveBucketConfig() jetstream.KeyValueConfig {
return jetstream.KeyValueConfig{
Bucket: delivery.HaveBucket,
Description: "what each machine has of each module, one key `<node>.<module>` (and `<node>.node` for the machine's " +
"own facts) (novox/hq ADR 0429, design 47): written by each machine's node-engine under its own node's keys " +
"alone, never by the controller; read by the delivery view and the watchman",
History: delivery.HaveHistory,
MaxValueSize: ChainValueBytes,
MaxBytes: 64 << 20,
Storage: jetstream.FileStorage,
}
}
+88
View File
@@ -0,0 +1,88 @@
package buscompose
import (
"time"
"github.com/nats-io/nats.go/jetstream"
)
// The streams a manifest declares, as the server is asked to create them (novox/hq ADR 0547 §5, issue 1790): the
// grants part creates each one the bus does not hold yet, with what the controller asserts at its start, so the two
// make the same stream. Pure. Only a create is ever asked with these: a stream the bus holds is never updated by the
// grants part (CreateStreamGrants grants no update).
// BucketStreamConfig is a module's bucket as the stream the controller's CreateOrUpdateKeyValue makes of its options
// (mesh-controller internal/broker BucketConfig, with no marker age held): the client library's key-value shape
// (rollup, no delete, direct reads, discard new, a duplicate window of two minutes or the age when shorter) with the
// mesh's caps, file storage and one replica.
func BucketStreamConfig(b Bucket) jetstream.StreamConfig {
history := b.History
if history < 1 {
history = 1
}
age := time.Duration(b.TTLSeconds) * time.Second
dupes := 2 * time.Minute
if age > 0 && age < dupes {
dupes = age
}
cfg := jetstream.StreamConfig{
Name: "KV_" + b.Bucket(),
Description: b.Why(),
Subjects: []string{"$KV." + b.Bucket() + ".>"},
MaxMsgsPerSubject: int64(history),
MaxBytes: StateMaxBytes,
MaxAge: age,
MaxMsgSize: StateMaxValueBytes,
Storage: jetstream.FileStorage,
Replicas: 1,
AllowRollup: true,
DenyDelete: true,
Duplicates: dupes,
MaxMsgs: -1,
MaxConsumers: -1,
AllowDirect: true,
Discard: jetstream.DiscardNew,
}
if b.KeyTTL {
cfg.AllowMsgTTL, cfg.SubjectDeleteMarkerTTL = true, KeyTTLMarkerTTL
}
return cfg
}
// ModuleStreamConfig is a module's own stream as the controller asserts it (mesh-controller internal/broker
// StreamConfigFor of ModuleStream.Stream).
func ModuleStreamConfig(m ModuleStream) jetstream.StreamConfig {
s := m.Stream()
cfg := jetstream.StreamConfig{
Name: s.Name,
Subjects: s.Subjects,
Retention: jetstream.LimitsPolicy,
MaxAge: time.Duration(s.MaxAge) * time.Second,
MaxMsgsPerSubject: int64(s.MaxMsgsPerSubject),
Description: s.Why,
AllowDirect: s.Direct,
AllowMsgTTL: s.AllowMsgTTL,
}
if s.Retention == RetentionWorkQueue {
cfg.Retention = jetstream.WorkQueuePolicy
}
if s.Restricted || s.WorkQueue {
cfg.Metadata = map[string]string{}
if s.Restricted {
cfg.Metadata[RestrictedMetadata] = "true"
}
if s.WorkQueue {
cfg.Metadata[WorkQueueMetadata] = "true"
}
}
if s.MaxBytes > 0 {
cfg.MaxBytes = s.MaxBytes
}
if s.DiscardNew {
cfg.Discard = jetstream.DiscardNew
}
if s.DuplicatesSeconds > 0 {
cfg.Duplicates = time.Duration(s.DuplicatesSeconds) * time.Second
}
return cfg
}
+46
View File
@@ -0,0 +1,46 @@
package buscompose
import (
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
)
// Defends issue 1790 (ADR 0547 §5): the grants part creates a declared bucket and stream as the controller asserts them.
func TestDeclaredStreamsAreCreatedAsTheControllerAssertsThem(t *testing.T) {
plain := BucketStreamConfig(Bucket{Module: "mesh-vault", Name: "bus-accounts"})
keyed := BucketStreamConfig(Bucket{Module: "m", Name: "presence", History: 5, TTLSeconds: 60, KeyTTL: true})
queue := ModuleStreamConfig(ModuleStream{Module: "b", Name: "ASKS", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32,
WorkQueue: true, Duplicates: 1})
for _, row := range []struct {
what string
got, want any
}{
{"bucket name", plain.Name, "KV_mesh-vault_bus-accounts"},
{"bucket subjects", plain.Subjects[0], "$KV.mesh-vault_bus-accounts.>"},
{"bucket history defaults to one", plain.MaxMsgsPerSubject, int64(1)},
{"bucket cap", plain.MaxBytes, int64(StateMaxBytes)},
{"bucket value cap", plain.MaxMsgSize, int32(StateMaxValueBytes)},
{"bucket duplicate window", plain.Duplicates, 2 * time.Minute},
{"bucket discards new", plain.Discard, jetstream.DiscardNew},
{"bucket direct and rollup, no delete", plain.AllowDirect && plain.AllowRollup && plain.DenyDelete, true},
{"bucket without key ttl", plain.AllowMsgTTL, false},
{"history", keyed.MaxMsgsPerSubject, int64(5)},
{"age", keyed.MaxAge, time.Minute},
{"duplicate window under the age", keyed.Duplicates, time.Minute},
{"key ttl markers", keyed.SubjectDeleteMarkerTTL, KeyTTLMarkerTTL},
{"key ttl", keyed.AllowMsgTTL, true},
{"queue retention", queue.Retention, jetstream.WorkQueuePolicy},
{"queue subjects", queue.Subjects[0], "mesh.stream.b.event.ask"},
{"queue age", queue.MaxAge, 7 * 24 * time.Hour},
{"queue cap", queue.MaxBytes, int64(32 << 20)},
{"queue discards new", queue.Discard, jetstream.DiscardNew},
{"queue duplicates", queue.Duplicates, time.Hour},
{"queue metadata", queue.Metadata[WorkQueueMetadata] + queue.Metadata[RestrictedMetadata], "truetrue"},
} {
if row.got != row.want {
t.Errorf("%s: %v, want %v", row.what, row.got, row.want)
}
}
}
+130
View File
@@ -0,0 +1,130 @@
package buscompose
import "testing"
// Defends issue 1790 and novox/hq ADR 0547 §5 (the operator, 2026-10-26 08:54): the grants part may create the stream
// of each bucket, log and stream a placed module's manifest declares, by its own name, and read it back; nothing
// wider, nothing updated or deleted, and no other principal so. Held byte-equal in the controller's copy. And ADR 0547
// clause 7 (issue 1945, the operator's second exception): the grants part writes its own object bucket of sealed user
// lists, and the node-engine of the node holding mesh-broker alone reads its one key and an object of it by name.
func TestTheGrantsPartCreatesOnlyDeclaredStreams(t *testing.T) {
records := Records{
Nodes: []string{"novox", "ace"},
Assigned: map[string][]Declared{"ace": {{Module: "plex"}}, "novox": {
{Module: "nats", SnapshotsTheBus: true},
// mesh-sdk's copy alone (ADR 0547 clause 9, named readers): the vault's hashes are read by the grants part
// alone, whatever another module's reads say; the controller's copy grants any module that declares it.
{Module: "mesh-vault", State: []Bucket{{Module: "mesh-vault", Name: "bus-accounts"}}},
{Module: "spy", Serves: []string{"spy_x"}, Reads: []string{VaultBusAccounts}},
{Module: "mesh-delivery-builder",
State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5}},
Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds"}},
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "BUILDASKS", Events: []string{"ask"}}}},
{Module: GrantsModule, State: []Bucket{{Module: GrantsModule, Name: "assertions"}}, Reads: []string{VaultBusAccounts}},
{Module: RuntimeModule},
}},
}
users, err := Users(records)
if err != nil {
t.Fatal(err)
}
perms := map[string]Permissions{}
for _, u := range users {
if u.Kind == KindNode || u.Kind == KindNodeTools {
p, err := PermissionsFor(u)
if err != nil {
t.Fatalf("%s: %v", u.Username(), err)
}
perms[u.Username()] = p
continue
}
if u.Kind != KindModule {
continue
}
p, err := PermissionsFor(u)
if err != nil {
t.Fatalf("%s: %v", u.Username(), err)
}
perms[u.Module] = p
}
for _, row := range []struct {
who, subject string
may bool
}{
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", true},
{GrantsModule, "$JS.API.STREAM.INFO.KV_mesh-delivery-builder_steps", true},
{GrantsModule, "$JS.API.STREAM.CREATE.LOG_mesh-delivery-builder_builds", true},
{GrantsModule, "$JS.API.STREAM.CREATE.BUILDASKS", true},
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-grants_assertions", true},
{GrantsModule, "$JS.API.STREAM.UPDATE.KV_mesh-delivery-builder_steps", false},
{GrantsModule, "$JS.API.STREAM.DELETE.KV_mesh-delivery-builder_steps", false},
{GrantsModule, "$JS.API.STREAM.PURGE.KV_mesh-delivery-builder_steps", false},
{GrantsModule, "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_undeclared", false},
{GrantsModule, "$JS.API.STREAM.CREATE.EVENTS", false},
{GrantsModule, "$JS.API.STREAM.CREATE.ASSIGNMENTS", false},
{GrantsModule, "$JS.API.STREAM.INFO.EVENTS", false},
{"mesh-delivery-builder", "$JS.API.STREAM.CREATE.KV_mesh-delivery-builder_steps", false},
// The sealed user list (ADR 0547 clause 7): the grants part writes its own bucket and no other.
{GrantsModule, "$JS.API.STREAM.CREATE.OBJ_mesh-grants-user-list", true},
{GrantsModule, "$O.mesh-grants-user-list.C.abc", true},
{GrantsModule, "$JS.API.STREAM.PURGE.OBJ_mesh-grants-user-list", true},
{GrantsModule, "$O.declaration-files-novox.C.abc", false},
{GrantsModule, "$JS.API.STREAM.DELETE.OBJ_mesh-grants-user-list", false},
{"mesh-delivery-builder", "$O.mesh-grants-user-list.C.abc", false},
// The bus node's engine reads the one key and an object by name; nothing of the bucket's list, no write.
{"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", true},
{"node.novox", "$JS.API.CONSUMER.CREATE.KV_mesh-grants_user-list.abc.$KV.mesh-grants_user-list.sealed", true},
{"node.novox", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", true},
{"node.novox", "$JS.API.CONSUMER.CREATE.OBJ_mesh-grants-user-list.abc.$O.mesh-grants-user-list.C.abc", true},
{"node.novox", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.list", false},
{"node.novox", "$JS.API.STREAM.INFO.OBJ_mesh-grants-user-list", false},
{"node.novox", "$JS.API.STREAM.MSG.GET.OBJ_mesh-grants-user-list", false},
{"node.novox", "$O.mesh-grants-user-list.C.abc", false},
{"node.ace", "$JS.API.DIRECT.GET.KV_mesh-grants_user-list.$KV.mesh-grants_user-list.sealed", false},
{"node.ace", "$JS.API.DIRECT.GET.OBJ_mesh-grants-user-list.$O.mesh-grants-user-list.M.abc", false},
{GrantsModule, "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", true},
{"spy", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false},
{"novox.node-tools", "$JS.API.DIRECT.GET.KV_mesh-vault_bus-accounts.$KV.mesh-vault_bus-accounts.x", false},
} {
if got := MayPublish(perms[row.who], row.subject); got != row.may {
t.Errorf("%s may publish %s: %v, want %v", row.who, row.subject, got, row.may)
}
}
// Issue 1790 (2026-10-26): a module hears the bus create each bucket it may bind, its own and those it reads, so a
// state missing at its start is bound when it appears; no other bucket's, and no other advisory.
for _, row := range []struct {
who, subject string
may bool
}{
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-delivery-builder_steps", true},
{GrantsModule, "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-vault_bus-accounts", true},
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.KV_mesh-grants_assertions", false},
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.CREATED.LOG_mesh-delivery-builder_builds", false},
{"mesh-delivery-builder", "$JS.EVENT.ADVISORY.STREAM.DELETED.KV_mesh-delivery-builder_steps", false},
} {
if got := MaySubscribe(perms[row.who], row.subject); got != row.may {
t.Errorf("%s may hear %s: %v, want %v", row.who, row.subject, got, row.may)
}
}
// A name that is no plain token is never granted, and a module other than the grants part is never granted one.
if got := CreateStreamGrants([]string{"*", ">", "KV_a.b", "KV_a_b"}); len(got) != 2 {
t.Errorf("grants for wildcard names: %v", got)
}
other, err := PermissionsFor(Principal{Kind: KindModule, Node: "novox", Module: "shop", CreatesStreams: []string{"KV_shop_x"}})
if err != nil {
t.Fatal(err)
}
if MayPublish(other, "$JS.API.STREAM.CREATE.KV_shop_x") {
t.Error("a module other than the grants part was granted a create")
}
// The writers table refuses the grants part an update, and a create of a stream it was not given.
g := Principal{Kind: KindModule, Node: "novox", Module: GrantsModule, CreatesStreams: []string{"KV_a_b"}}
if err := CheckWriters(g, []string{"$JS.API.STREAM.CREATE.KV_a_b"}); err != nil {
t.Errorf("the declared create refused: %v", err)
}
for _, refused := range []string{"$JS.API.STREAM.UPDATE.KV_a_b", "$JS.API.STREAM.CREATE.KV_a_c", "$JS.API.STREAM.CREATE.*"} {
if CheckWriters(g, []string{refused}) == nil {
t.Errorf("%s was not refused", refused)
}
}
}
+111
View File
@@ -0,0 +1,111 @@
package buscompose
import (
"context"
"fmt"
"time"
"git.novox.be/novox/mesh-sdk/go/declaration"
"github.com/nats-io/nats.go/jetstream"
)
// The object buckets a declaration's large files travel in (novox/hq ADR 0486, issues 1696 and 1697): **one bucket for each
// node**, `declaration-files-<node>`.
//
// **The controller alone writes them, and a node-engine reads an object by its name from its own bucket** (ADR 0486 §2): its
// account may neither list the bucket nor write it, no account may touch another node's, and no other account has any access.
// Why a bucket each: a subject pattern cannot tell a name from a wildcard. A grant of `...M.*` matches a publish to the literal
// subject `...M.*` too (probed on nats-server 2.11.17 by the review of this issue: a node created a consumer filtered on
// `$O.<bucket>.C.*` and read every object of the bucket), so in one shared bucket a node could read every other node's files.
// With a bucket each, the same trick reads only the node's own files, which are the files of its own declaration.
//
// A bucket of the bus's object store is a stream, OBJ_<bucket>, whose messages are the objects' metadata
// (`$O.<bucket>.M.<base64 of the name>`) and chunks (`$O.<bucket>.C.<nuid>`).
// DeclarationFilesBucket is the prefix of the buckets' names, which mesh-sdk fixes for the node-engine and the controller alike.
const DeclarationFilesBucket = declaration.FileBucketPrefix
// DeclarationFilesBucketOf is the bucket a node's files are in: mesh-sdk's FileBucketFor, the one place the name is composed. A
// node name it refuses (one that could carry a subject wildcard or separator into a grant) is an error, never a bucket.
func DeclarationFilesBucketOf(node string) (string, error) { return declaration.FileBucketFor(node) }
// DeclarationFilesStreamOf is the stream under a node's bucket: mesh-sdk's FileStreamFor.
func DeclarationFilesStreamOf(node string) (string, error) { return declaration.FileStreamFor(node) }
// DeclarationFilesMaxBytes is a bucket's size: the objects are immutable and regenerable, and one node's hold well under a hundred
// megabytes.
const DeclarationFilesMaxBytes = 1 << 30
// DeclarationFilesKeptFor is how long an object stays after no node's latest declaration names it (ADR 0486 §3).
const DeclarationFilesKeptFor = 24 * time.Hour
// DeclarationFilesWrites is the one pattern the controller may publish to write a node's bucket: every object's metadata and
// chunks. The server's API for the stream is the controller's `$JS.API.>`.
func DeclarationFilesWrites(node string) (string, error) {
bucket, err := DeclarationFilesBucketOf(node)
if err != nil {
return "", err
}
return "$O." + bucket + ".>", nil
}
// DeclarationFilesReads is what a node-engine's account may publish to read an object of its own bucket by its name, and nothing
// more. The set is the one node-engine's own call (mesh-host internal/link FileObject) makes, for B the node's bucket and S its
// stream (issue 1697, notes 4 and 5):
//
// - the object's metadata, by a direct get of the last message of the one subject that names it. **Only the suffixed
// form**: the bare `$JS.API.DIRECT.GET.S` takes a sequence number and would read any message of the stream.
// - the ordered consumer on that object's chunks: `$JS.API.CONSUMER.CREATE.S.<name>.$O.B.C.<nuid>`. **Not `S.>`**:
// a consumer filtered on `$O.B.M.>` would read every object's name.
// - the flow-control replies of that consumer, without which a large object stalls at the flow-control window.
// - the consumer's deletion, best effort: the consumer's inactivity threshold removes it if this is refused.
//
// **Never granted:** STREAM.INFO (a `subjects_filter` of `>` lists every name), STREAM.MSG.GET (any message by its sequence), a
// publish to `$O.B.>` (a write), and any subject of another node's stream. A literal `*` in a subject matches the grant's `*`: it
// reaches the objects of this node's own bucket and no other (above).
func DeclarationFilesReads(node string) ([]string, error) {
bucket, err := DeclarationFilesBucketOf(node)
if err != nil {
return nil, err
}
stream, err := DeclarationFilesStreamOf(node)
if err != nil {
return nil, err
}
return []string{
"$JS.API.DIRECT.GET." + stream + ".$O." + bucket + ".M.*",
"$JS.API.CONSUMER.CREATE." + stream + ".*.$O." + bucket + ".C.*",
"$JS.API.CONSUMER.DELETE." + stream + ".*",
"$JS.FC." + stream + ".>",
}, nil
}
// EnsureDeclarationFiles is the same on a JetStream the caller holds.
func EnsureDeclarationFiles(ctx context.Context, js jetstream.JetStream, nodes ...string) error {
for _, node := range nodes {
cfg, err := DeclarationFilesConfig(node)
if err != nil {
return err
}
if _, err := js.CreateOrUpdateObjectStore(ctx, cfg); err != nil {
return fmt.Errorf("asserting the object bucket %s: %w", cfg.Bucket, err)
}
}
return nil
}
// DeclarationFilesConfig is a node's bucket as the controller asserts it.
func DeclarationFilesConfig(node string) (jetstream.ObjectStoreConfig, error) {
bucket, err := DeclarationFilesBucketOf(node)
if err != nil {
return jetstream.ObjectStoreConfig{}, err
}
return jetstream.ObjectStoreConfig{
Bucket: bucket,
Description: "the contents of the files over 64 KiB of the declaration of " + node + ", each under the hex of its sha256 " +
"digest (novox/hq ADR 0486): written by the controller alone, read by the node-engine of that node by name, removed " +
"a day after the node's latest declaration no longer names it",
MaxBytes: DeclarationFilesMaxBytes,
Storage: jetstream.FileStorage,
}, nil
}
+357
View File
@@ -0,0 +1,357 @@
package buscompose
import (
"fmt"
"sort"
"strings"
)
// Streams and consumers derived from what modules declare.
//
// The mesh's own four exist before any module does (streams.go). Everything here is the other
// half: a seat's stream comes into being when the module declaring it is **registered**, and a
// consumer when a module is **assigned** — which is why ADR 0116's task 1.4 had to be narrowed to
// the foundation set. Neither has happened at genesis.
//
// All of it is a pure function of declarations. The controller is still the only writer; this is
// only what it writes.
// A Consumer is a durable subscription the controller creates on a module's behalf. A module
// declares what it reacts to, never how delivery works, so it does not name these and cannot
// misconfigure them.
type Consumer struct {
Name string
Stream string
// Filters are the subjects this consumer receives. One consumer per module with several
// filters, rather than one per consumed event: its ack subject is derived from its name, and
// a module with five consumers would need five ack permissions to ack its own deliveries.
Filters []string
// Queue is the queue group, set for a seat's worker so that "exactly one holder" survives a
// seat later being relaxed to several. Authority and delivery are kept separate on purpose.
Queue string
// Push asks the server to deliver to a subject rather than wait to be pulled.
//
// For the mesh's own consumer, where the controller wants every message to arrive in the one
// loop it already runs: pulling would mean a second goroutine fetching batches and handing
// them over, and a loop that acts on one message at a time is the property the store window
// depends on. A queue group implies this, because a group has nothing to pull from.
Push bool
// TakesOverFrom names a consumer of the same stream whose filter this one takes over (issue 973): made while
// that consumer still filters this one's subject, it starts after what that consumer has acknowledged, so a
// message the old one held unacknowledged is not lost to the change. Otherwise it starts as FromNow says.
TakesOverFrom string
// DeliverSubject, when set, is where a push consumer delivers instead of DeliverSubjectFor's.
DeliverSubject string
// AckWaitSeconds before an unacknowledged delivery is redelivered.
AckWaitSeconds int
// MaxDeliver is how often a message is handed over before the consumer gives it up; zero for no
// bound. What a consumer gives up is kept in DEAD_LETTERS by the controller (novox/hq issue 330).
MaxDeliver int
// MaxAckPending is how many deliveries the server lets stand unacknowledged at once; zero for
// the server's default, which is many. **One, for a consumer handled one at a time**
// (novox/hq issue 175): a handler that builds for minutes keeps its own message alive with a
// heartbeat, but everything handed over behind it times out unacknowledged and comes back —
// and a merge that came back rebuilt what it had just built, five times over on 2026-09-30.
// With one outstanding, the server holds the rest, and the heartbeat is keeping the message.
MaxAckPending int
// FromNow makes a consumer that does not exist yet start at the stream's end rather than its
// beginning (novox/hq issue 248). For a consumer that reacts to announcements — a merge, a build's
// outcome — on a stream that keeps a week of them: the server's default, everything the stream
// holds, replays every merge and every build of that week as if it had just happened. A consumer
// that exists keeps where it is, whatever this says; only its making is decided here.
FromNow bool
Why string
// Resettable says why this consumer may be re-made to deliver from now by the mesh itself, with
// nobody asked (novox/hq to-be 45 §7, healer H4): what a reset drops, something else catches up.
// Empty for every consumer where nothing would — a module's, whose events would be lost to it.
// Not a property of the consumer on the bus: nothing here is sent to the server.
Resettable string
}
// seatStreamName is the stream holding a seat's inbound work. Named after the seat rather than
// the module holding it, because the holder can change and the queued work must not care — which
// is the whole reason a caller addresses a seat instead of a module.
func seatStreamName(seat string) string { return "SEAT_" + upperSnake(seat) }
// SeatStreams is one work queue per declared seat, created when the declaring module is
// registered rather than when it is assigned.
//
// **The stream exists before anyone holds the seat, and that is the point.** Work queues until a
// holder appears, so installing the telegram module a week after something started sending to it
// flushes the backlog instead of having lost it. A stream created at assignment would make "the
// holder is not here yet" mean "your messages are gone".
func SeatStreams(seats []DeclaredSeat) []Stream {
sorted := append([]DeclaredSeat(nil), seats...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
var out []Stream
for _, s := range sorted {
if len(s.Accepts) == 0 {
// A seat that only emits and serves needs no stream: its events ride EVENTS and its
// tools are core request/reply, which is never persisted.
continue
}
retain := s.RetainSeconds
if retain == 0 {
retain = 7 * 24 * 60 * 60
}
out = append(out, Stream{
Name: seatStreamName(s.Name),
Subjects: []string{"mesh.seat." + s.Name + ".accept.>"},
Retention: RetentionWorkQueue,
MaxAge: retain,
Why: fmt.Sprintf("work submitted to the %s seat; one holder consumes it, and it "+
"queues while nobody does", s.Name),
})
}
return out
}
// A DeclaredSeat is a seat as the catalogue knows it. Mirrored here rather than imported so this
// package stays free of the catalogue's own types — the same reason the host mirrors the
// contracts instead of importing the sdk.
type DeclaredSeat struct {
Name string
Accepts []string
// Emits are the verbs the seat's holder publishes under the seat's own name. An event about a
// role belongs here rather than in the holder's namespace, because the name then outlives
// whoever fills it (novox/hq ADR 0121, 04-ISSUES/127).
Emits []string
// Serves are the verbs the holder answers, request and reply.
Serves []string
RetainSeconds int
// PulledByKind is Seat's: a worker per accepted kind, derived from the holders' seat traffic, not here.
PulledByKind bool
}
// ConsumerFor is the durable consumer a module's declarations imply, or false when it subscribes
// to nothing and needs none.
//
// One per module, with every consumed subject as a filter, because its ack permission is derived
// from its name: a module with a consumer per event would need an ack permission per consumer,
// and the permission list would stop being derivable from the declaration.
func ConsumerFor(p Principal) (Consumer, bool) {
// A module that reacts to anything — a module's events or a role's (novox/hq ADR 0121). Watching
// a role was missing here, so the one module that does it got no consumer at all: it started,
// connected, and its graph stayed empty with nothing anywhere reporting why.
// And one that hears its own answers on a seat it uses (novox/hq ADR 0259 §3): an asker's warrants.
hearsItsOwn := len(SeatTrafficOf(p.Module, nil, p.Uses, nil).Subscribe) > 0
hearsAStream := false
for _, h := range p.Hears {
if len(h.Filters) > 0 {
hearsAStream = true
}
}
if p.Kind != KindModule || (len(p.Consumes) == 0 && len(p.Watches) == 0 && !hearsItsOwn && !hearsAStream) {
return Consumer{}, false
}
perms, err := PermissionsFor(p)
if err != nil {
return Consumer{}, false
}
// Events, wherever they live: a module's own namespace, and the namespace of any role it watches
// (novox/hq ADR 0121). Tool subjects and inboxes are subscribed directly and are not a consumer's
// business, which is why this is a filter and not the whole list.
var filters []string
for _, s := range perms.Subscribe {
// Not a module stream's, which its own consumer on that stream hears (novox/hq ADR 0363).
if strings.Contains(s, ".event.") && !strings.HasPrefix(s, moduleStreamPrefix) {
filters = append(filters, s)
}
}
// **A module that hears only a module's stream still has one here** (novox/hq ADR 0363 §7): what it
// gives up on there is delivered again under `mesh.again.<consumer>.` on EVENTS, and only this filters it.
if len(filters) == 0 && !hearsAStream {
return Consumer{}, false
}
sort.Strings(filters)
// And the events given up on and delivered again to this consumer alone (novox/hq issue 330).
filters = append(filters, AgainFilter(consumerDurable(p)))
return Consumer{
Name: consumerDurable(p),
Stream: consumerStream(p),
Filters: filters,
AckWaitSeconds: 30,
MaxDeliver: 5,
// From now, as the controller's own consumers have been since issue 248: a module made on a stream that
// keeps a week replays none of it. What a part needs of the present it reads from its owner
// (novox/hq ADR 0443 §1, §3). A consumer that exists keeps its place whatever this says.
FromNow: true,
Why: "what " + p.Module + " declared it consumes; after max-deliver it gives an event up, and the " +
"controller keeps it in DEAD_LETTERS until a person delivers it again or drops it",
}, true
}
// ModuleConsumer is one module's durable consumer, with the module and node it is for.
type ModuleConsumer struct {
Node, Module string
Consumer Consumer
}
// ConsumersOf is every module's durable consumer the composed users imply: each module user's, and
// each module a runtime carries — a carried module with no account of its own is no user (novox/hq
// issue 195), and its consumer is still the controller's to make, since the runtime reads it on the
// module's behalf (ADR 0198). One per module and node, whichever of the two named it first.
func ConsumersOf(users []Principal) []ModuleConsumer {
var out []ModuleConsumer
seen := map[string]bool{}
add := func(p Principal) {
key := p.Node + "/" + p.Module
if seen[key] {
return
}
c, needed := ConsumerFor(p)
// **A module with a work-queue consumer of its own needs no other reason to be visited here**
// (novox/hq ADR 0436 §1, §2, §6): mesh-mail's sender consumes and watches nothing of its own, so
// ConsumerFor alone would have found nothing needed and skipped it before its one queue consumer
// was ever composed.
if !needed && len(p.Queues) == 0 {
return
}
seen[key] = true
if needed {
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: c})
// And one on each module stream it hears (novox/hq ADR 0363 §5).
for _, h := range p.Hears {
if len(h.Filters) == 0 {
continue
}
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: heardConsumer(p, h)})
}
}
// And the one durable consumer of each of its own work-queue streams, for the one process its
// bus.queues names (novox/hq ADR 0436 §1, §2, §6).
for _, q := range p.Queues {
out = append(out, ModuleConsumer{Node: p.Node, Module: p.Module, Consumer: QueueConsumer(p, q)})
}
}
for _, p := range users {
if p.Kind == KindModule {
add(p)
}
}
for _, p := range users {
if p.Kind != KindNodeTools {
continue
}
for _, d := range p.Carries {
// **A carried module's work-queue consumer is still the controller's to make** (novox/hq ADR
// 0436 §1, §2, §6), the same reason its ordinary consumer is (ADR 0198): mesh-mail has no
// account of its own (NoAccount) and is carried here, but its process does, and the consumer
// it reads through must exist and survive PruneHeard whether or not the module itself speaks.
add(Principal{Kind: KindModule, Node: p.Node, Module: d.Module, Emits: d.Emits,
Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches,
Streams: d.Streams, Hears: ComposedHears(d), Queues: d.Queues})
}
}
return out
}
// HolderConsumerFor is the worker a seat's holders share on that seat's work queue.
//
// **One worker for every holder, and each holder pulls one ask when it is idle** (novox/hq ADR
// 0190). The seat is *authority* — who may be the telegram sender — and the worker is *delivery*,
// kept separate so that relaxing one changes nothing about the other: a node-scoped seat has a
// holder per machine, and all of them take from this one consumer, so the work is shared without
// any holder knowing about the others. Pulled rather than pushed because a push consumer hands the
// next ask to whichever subscriber the server picks, busy or not, and a pulled one is asked for by
// a holder that has just become free. Which is also what ends the race issue 186 describes — asks
// delivered behind the one being worked, expiring unacknowledged and dropped after the fifth
// redelivery: nothing is delivered that nobody asked for. A long build keeps its own ask alive
// (stillWorking); the ack wait is for a holder that died.
func HolderConsumerFor(node, module string, seat DeclaredSeat) (Consumer, bool) {
if len(seat.Accepts) == 0 || seat.PulledByKind {
// A seat pulled by kind has a worker per kind, which its holders' seat traffic derives (SeatTrafficObjects): one
// worker filtered on every accept would overlap them on the work queue.
return Consumer{}, false
}
return Consumer{
Name: "SEAT_" + upperSnake(seat.Name) + "_worker",
Stream: seatStreamName(seat.Name),
Filters: []string{"mesh.seat." + seat.Name + ".accept.>"},
AckWaitSeconds: 60,
MaxDeliver: 5,
// As many in flight as there are holders working, which pulling bounds by itself: a holder
// fetches one and fetches again only after it acknowledged. The server's default stands.
Why: fmt.Sprintf("%s on %s holds %s; every holder pulls one ask at a time from this worker "+
"and acknowledges after the work is done, so a crash mid-work redelivers rather than "+
"loses and an idle holder is the one that takes the next ask", module, node, seat.Name),
}, true
}
// NodeConsumer is the durable consumer a node reads its own declaration through.
//
// **Derived from a node existing, and created by the controller, because a host cannot create it.**
// A host's account may subscribe its own declaration subject and publish its own ack subject, and
// reaches no part of the JetStream API — which is correct (the controller is the only writer of
// consumer definitions, design 25 §3) and means the consumer must be waiting before the host binds
// to it. Named after the node, because the node's ack grant is `$JS.ACK.NODES.<node>.>` and a
// consumer named anything else is one the host cannot acknowledge a delivery from.
//
// **No max-deliver, and a long ack wait.** A declaration is settled only after the node has applied
// it and reported, which is minutes on a machine pulling images; and a declaration the mesh cannot
// get a node to accept is not one to give up on, because the stream keeps only the newest per node
// anyway — so there is exactly one message per node to redeliver, for as long as that node is away.
func NodeConsumer(node string) Consumer {
return Consumer{
Name: node,
Stream: "NODES",
Filters: []string{"mesh.node." + node + ".declare"},
Push: true,
AckWaitSeconds: 300,
Why: "how " + node + " hears what it should be; last-per-subject, so a node that was away " +
"gets exactly the current declaration and nothing older",
}
}
// AssertNodeConsumers brings every known node's declaration consumer into being.
//
// Asserted on start as well as created at enrolment, for the reason the streams are: a mesh raised
// from a restored backup, or one whose bus was recreated, has node records and no consumers, and a
// node whose consumer is missing hears nothing while everything else about it looks correct.
func AssertNodeConsumers(e Ensurer, nodes []string) error {
for _, n := range nodes {
if err := e.EnsureConsumer(NodeConsumer(n)); err != nil {
return fmt.Errorf("asserting how %s hears its declaration: %w", n, err)
}
}
return nil
}
// AllOverlaps reports subject filters claimed by more than one stream, across the mesh's own and
// every derived one.
//
// NATS refuses an overlapping stream rather than merging it (verified against nats-server 2.10:
// "subjects overlap with an existing stream"), so this is not a subtle divergence — it is a
// registration that fails. Catching it here names both streams, before a half-applied mesh does.
func AllOverlaps(seats []DeclaredSeat) []string {
all := append(MeshStreams(), SeatStreams(seats)...)
seen := map[string]string{}
var clashes []string
for _, s := range all {
for _, subject := range s.Subjects {
if first, ok := seen[subject]; ok {
clashes = append(clashes, fmt.Sprintf("%s and %s both claim %s", first, s.Name, subject))
continue
}
seen[subject] = s.Name
}
}
sort.Strings(clashes)
return clashes
}
// upperSnake makes a stream name from a seat name. NATS stream names may not contain a dot,
// a space or a wildcard, and a hyphen is legal but reads badly beside the mesh's own.
func upperSnake(s string) string {
out := []rune(s)
for i, r := range out {
switch {
case r >= 'a' && r <= 'z':
out[i] = r - 32
case r == '-' || r == '.':
out[i] = '_'
}
}
return string(out)
}
+12
View File
@@ -0,0 +1,12 @@
// Package buscompose is the bus's composer: who may say what on the bus, as the user list and the server configuration
// the bus loads, derived from the mesh's records (Users, WithPasswords, ComposeAccounts, Compose), with the grants,
// streams, state and logs it composes them from.
//
// **A copy of the controller's internal/broker, composer half** (issue 1377, novox/hq ADR 0547 §5), taken at
// mesh-controller 7cd4d18b with the grants part's membership grant of f67d1553 (ADR 0547 §5) and its grant to create the streams manifests declare (issue 1790): the bus client (jetstream.go), the broker's address from the environment (broker.go, onnats.go
// but its one constant) and the two methods on that client are left out; the lease's two subjects are copied in
// (nats.go). The controller keeps its own copy, unchanged, until it loses its lease at ADR 0547's third milestone, so
// for that time two composers state who may say what and must say the same: TestTheCopiedComposerComposesAsTheControllers
// holds this copy to lists the controller's own code wrote from the same fixtures. A change to what is granted is made
// here and, while the controller composes, in the controller's copy too, never in one alone.
package buscompose
+119
View File
@@ -0,0 +1,119 @@
package buscompose
// The fixtures the copied bus composer is held to (issue 1377, ADR 0547 §5): the same source is compiled into the
// controller's internal/broker to write the expected lists, and into mesh-sdk's buscompose to compare against them.
// UserListFixture is one mesh's records, and the hashes its accounts carry.
type UserListFixture struct {
Name string
Records Records
// Hashless are users given no hash, so they are left out and named.
Hashless []string
}
func fixtureTelegramSeat() Seat {
return Seat{Name: "telegram-sender", Accepts: []string{"send"}, Emits: []string{"delivered", "failed"}}
}
// UserListFixtures are the meshes, from the controller's own tests' records outward.
func UserListFixtures() []UserListFixture {
return []UserListFixture{
{Name: "empty"},
{Name: "the controller's someRecords", Records: Records{
Nodes: []string{"two", "one"},
Assigned: map[string][]Declared{
"one": {{Module: "telegram", Serves: []string{"status"}}},
"two": {{Module: "shop", Emits: []string{"order.placed"}}},
},
Enrolling: []string{"three"},
People: map[string][]string{"ada": {"mesh-catalog.catalog_tools"}},
}},
{Name: "seats, consumers, people and a hashless user", Hashless: []string{"two.audit"}, Records: Records{
Nodes: []string{"one", "two"},
Assigned: map[string][]Declared{
"one": {
{Module: "telegram", Holds: []Seat{fixtureTelegramSeat()}, Serves: []string{"status"}},
{Module: "nats", SnapshotsTheBus: true, Serves: []string{"nats_streams"}},
{Module: "mesh-watchman"},
{Module: "mesh-grants", Emits: []string{"membership-changed"}},
},
"two": {
{Module: "shop", Uses: []Seat{fixtureTelegramSeat()}, Emits: []string{"order.placed"}},
{Module: "audit", Consumes: []string{"shop.order.placed"}},
},
},
People: map[string][]string{"ada": {"*"}, "bob": {"shop.status"}},
Interchangeable: map[string]bool{"shop": true},
}},
{Name: "state, logs, streams, queues and processes", Records: Records{
Nodes: []string{"ace", "novox"},
Assigned: map[string][]Declared{
"novox": {
{Module: "mesh-delivery-builder", Emits: []string{"built"},
State: []Bucket{{Module: "mesh-delivery-builder", Name: "steps", History: 5},
{Module: "mesh-delivery-builder", Name: "presence", PerMachine: true, KeyTTL: true, TTLSeconds: 60}},
Logs: []Log{{Module: "mesh-delivery-builder", Name: "builds", MaxMiB: 64}},
Streams: []ModuleStream{{Module: "mesh-delivery-builder", Name: "asks", Events: []string{"ask"}, MaxAgeDays: 7, MaxMiB: 32, WorkQueue: true}},
Queues: []QueueOwner{{Process: "worker", Stream: "asks", MaxDeliver: 5}},
Processes: []ProcessPrincipal{{Process: "worker", Emits: []string{"built"},
Queues: []QueueOwner{{Process: "worker", Stream: "asks", MaxDeliver: 5}},
State: []string{"steps"}, Invokes: []string{"gitea.gitea_commit"}}},
Invokes: []string{"gitea.gitea_commit"},
},
{Module: "mesh-delivery-view", Reads: []string{"mesh-delivery-builder.steps"},
KeyedReads: []KeyedRead{{Read: "mesh-delivery-builder.presence", Key: "novox"}}},
// The grants part, which may create every stream declared here (ADR 0547 §5, issue 1790).
{Module: "mesh-grants", Emits: []string{"membership-changed"},
State: []Bucket{{Module: "mesh-grants", Name: "assertions", History: 5}}},
},
"ace": {
{Module: "plex", Serves: []string{"plex_status"}, Checks: []string{"plex.plex_status"}},
{Module: "silent", NoAccount: true},
},
},
RootFree: map[string]bool{"ace": true},
}},
}
}
// HashOf is the stand-in hash a fixture's user carries.
func HashOf(user string) string {
h := "$2a$11$"
for len(h) < 29 {
h += user
}
return h[:29] + "fixturefixturefixturefixture00"
}
// ComposedFor is a fixture's user list as the composer writes it, and the users left out.
func ComposedFor(f UserListFixture) (string, []string, error) {
users, err := Users(f.Records)
if err != nil {
return "", nil, err
}
hashes := map[string]string{}
skip := map[string]bool{}
for _, u := range f.Hashless {
skip[u] = true
}
for _, u := range users {
if !skip[u.Username()] {
hashes[u.Username()] = HashOf(u.Username())
}
}
filled, missing := WithPasswords(users, hashes)
list, err := ComposeAccounts(filled)
return list, missing, err
}
// MembershipsFor is every membership a fixture's records issue, by `<node>.<module>`, as the composer makes them.
func MembershipsFor(f UserListFixture) map[string]Membership {
where := PlacementsOf(f.Records, f.Records.Interchangeable)
out := map[string]Membership{}
for node, ds := range f.Records.Assigned {
for _, d := range ds {
out[node+"."+d.Module] = MembershipFor(node, d, where)
}
}
return out
}
+184
View File
@@ -0,0 +1,184 @@
package buscompose
import (
"fmt"
"sort"
"strings"
"github.com/nats-io/nats.go/jetstream"
)
// A module's log on the bus (novox/hq ADR 0297): its record of operations, each entry appended under
// a key and kept as long as the log is.
//
// A log follows a bucket in every respect (ADR 0201): the module names it locally, the mesh derives
// its stream and subjects, the controller creates it from the catalogue on every raise and from
// registration, never a module, and **no path of the mesh removes one** — a log whose declaration
// is gone is reported, as a bucket is. Unlike a bucket, a log is its owner's alone: no other module
// reads it, so there is no read of a log to grant or issue.
//
// Pure, but for the stream's configuration, which is the server's own type so that what is tested
// is exactly what is sent; jetstream.go is the part that asks a server.
// The mesh's caps on a log: what an entry's value may weigh, what a message on the log's stream may
// weigh — the value and its headers, which the server counts in a message's size, so a value of the
// full size still fits with the expected-last-sequence header an append carries — and what a log holds
// when its module says nothing and at most.
const (
LogMaxEntryBytes = 256 * 1024
LogMaxMessageBytes = LogMaxEntryBytes + 4*1024
LogDefaultMiB = 1024
LogMostMiB = 8192
)
// A Log is one module's declared log as the bus holds it.
type Log struct {
Module string
Name string
// MaxMiB is its cap in MiB; zero is LogDefaultMiB.
MaxMiB int
}
// LogStreamPrefix starts every log's stream name, which no other stream of the mesh's starts with.
const LogStreamPrefix = "LOG_"
// LogStreamName is the stream a module's log lives in: `LOG_<module>_<name>`. The module and the
// local name are each one token with no underscore, so two modules can never derive one stream.
func LogStreamName(module, name string) string { return LogStreamPrefix + module + "_" + name }
// LogSubject is the subject a log's entries are published under, without the key: an entry for key K
// is on `<LogSubject>.<K>`.
func LogSubject(module, name string) string { return "mesh.log." + module + "." + name }
// Stream is this log's stream name.
func (l Log) Stream() string { return LogStreamName(l.Module, l.Name) }
// Subject is this log's subject, without the key.
func (l Log) Subject() string { return LogSubject(l.Module, l.Name) }
// MaxBytes is this log's cap in bytes.
func (l Log) MaxBytes() int64 {
mib := l.MaxMiB
if mib <= 0 {
mib = LogDefaultMiB
}
return int64(mib) * 1024 * 1024
}
// Why is carried into the server's description of the stream, so somebody reading the server's own
// state finds whose it is and why it is kept.
func (l Log) Why() string {
return fmt.Sprintf("%s's log %q (novox/hq ADR 0297): its record of operations, one entry per operation "+
"under its key, appended by %s alone and kept as long as the log; never removed by the mesh, because "+
"it is data", l.Module, l.Name, l.Module)
}
// Config is the stream a log is, field by field as novox/hq ADR 0297 fixes it: a file stream kept by
// limits, with no maximum age and no cap per key, whose message is an entry's value and 4 KiB of
// headers at most, that refuses a new entry when full rather than
// drop an old one, and that refuses deleting an entry or purging it. Direct gets are allowed, which
// is how the runtime reads it.
func (l Log) Config() jetstream.StreamConfig {
return jetstream.StreamConfig{
Name: l.Stream(),
Description: l.Why(),
Subjects: []string{l.Subject() + ".>"},
Storage: jetstream.FileStorage,
Retention: jetstream.LimitsPolicy,
MaxAge: 0,
MaxMsgs: -1,
MaxMsgsPerSubject: -1,
MaxBytes: l.MaxBytes(),
MaxMsgSize: LogMaxMessageBytes,
Discard: jetstream.DiscardNew,
AllowDirect: true,
DenyDelete: true,
DenyPurge: true,
Replicas: 1,
}
}
// logsIssuedFor is every log a module's code may reach, as its membership lists them: its own.
func logsIssuedFor(d Declared) []LogIssued {
var out []LogIssued
for _, l := range d.Logs {
if !safeSubject.MatchString(d.Module) || !safeSubject.MatchString(l.Name) {
continue
}
out = append(out, LogIssued{Name: l.Name, Stream: LogStreamName(d.Module, l.Name),
Subject: LogSubject(d.Module, l.Name), Writes: true})
}
return out
}
// logGrants is what a principal publishes to reach the logs its module keeps: for each, appending
// under the log's subjects, binding to its stream, and reading it directly. Nothing more — the
// runtime reads a log by direct gets alone and makes no consumer on it — and nothing of any other
// module's log, because a log is its owner's alone. Replies come on the principal's inbox, as for a
// bucket.
func logGrants(module string, names []string) []string {
if !safeSubject.MatchString(module) {
return nil
}
var out []string
for _, name := range names {
if !safeSubject.MatchString(name) {
continue
}
stream := LogStreamName(module, name)
out = append(out,
LogSubject(module, name)+".>",
"$JS.API.STREAM.INFO."+stream,
"$JS.API.DIRECT.GET."+stream,
"$JS.API.DIRECT.GET."+stream+".>")
}
return out
}
// logNames is the local names of a module's logs.
func logNames(logs []Log) []string {
out := make([]string, 0, len(logs))
for _, l := range logs {
out = append(out, l.Name)
}
return out
}
// A LogAsserter is the part of a JetStream connection log assertion needs. It has no way to remove a
// log, by design: nothing the mesh runs asks for one.
type LogAsserter interface {
// EnsureLog creates the log's stream if absent and brings its configuration to match if present,
// never deleting or recreating it.
EnsureLog(l Log) error
// LogStreams is every log stream on the server: every stream named with LogStreamPrefix.
LogStreams() ([]string, error)
}
// RaiseLogs asserts every declared log and answers the log streams on the server that nothing
// declares any more.
//
// **Those are reported, never removed** (novox/hq ADR 0297 §2, ADR 0201 §15–16): a log is a module's
// record, and a manifest edited, a module renamed or unassigned is an ordinary day's work that must
// not take a record with it. Removing one is a person's act, outside the mesh.
func RaiseLogs(a LogAsserter, logs []Log) (undeclared []string, err error) {
sorted := append([]Log(nil), logs...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Stream() < sorted[j].Stream() })
declared := map[string]bool{}
for _, l := range sorted {
if err := a.EnsureLog(l); err != nil {
return nil, fmt.Errorf("asserting %s's log %q: %w", l.Module, l.Name, err)
}
declared[l.Stream()] = true
}
names, err := a.LogStreams()
if err != nil {
return nil, fmt.Errorf("listing the bus's logs: %w", err)
}
for _, n := range names {
if strings.HasPrefix(n, LogStreamPrefix) && !declared[n] {
undeclared = append(undeclared, n)
}
}
sort.Strings(undeclared)
return undeclared, nil
}
+178
View File
@@ -0,0 +1,178 @@
package buscompose
import (
"slices"
"sort"
"strings"
)
// What the mesh issues an assignment to serve and to reach (novox/hq ADR 0160).
//
// A module's code names its tools and its events; **where they land is the mesh's to decide**, and
// it decided it twice — once in the runtime, once here, by one rule compiled into both. Now the
// controller composes a membership for every module on every machine and publishes it to a subject
// only that assignment reads; the runtime serves exactly what the membership says, and the account's
// grant is the same composition read the other way. The shape issued today is the shape the mesh
// already had, so nothing moves when a membership first arrives; only who decides it moves.
// Placements is where every module runs, for deciding which instance answers for the module.
type Placements struct {
// Nodes is each module's machines.
Nodes map[string][]string
// Interchangeable is each module whose definition says its instances are the same anywhere,
// so the module's plain subject is issued to all of them in one queue.
Interchangeable map[string]bool
// Kinds is every kind held of a kinded bench, by whom and with what capabilities (ADR 0259 §5).
Kinds []KindHeld
}
// AnswersForTheModule says whether an instance of a module on one machine is issued the module's
// plain subject: when it is the only instance, or when the definition says instances are
// interchangeable. A stateful module on two machines gets only its machines' subjects, so a call
// that names none reaches nothing rather than the wrong store.
func (p Placements) AnswersForTheModule(module string) bool {
return len(p.Nodes[module]) <= 1 || p.Interchangeable[module]
}
// MembershipFor composes one assignment's membership from what it declared and where everything
// runs. The subjects are the ones PermissionsFor grants, derived here once more only until the
// grant itself is read from the membership — which is the next step, not this one.
func MembershipFor(node string, d Declared, where Placements) Membership {
own := "mesh.mod." + d.Module
m := Membership{
Node: node, Module: d.Module,
Emits: own + ".event.{event}",
Tools: own + ".tool.tools",
}
// This machine's address always; the module's when this instance answers for the module.
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}." + node})
if where.AnswersForTheModule(d.Module) {
m.Serves = append(m.Serves, Served{Subject: own + ".tool.{tool}", Queue: "serve." + d.Module})
}
for _, s := range d.Holds {
if servedOnlyByTheController(s) {
continue // answered by the serving controller alone, never through a membership
}
for _, verb := range s.Serves {
m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)})
}
}
m.State = stateIssuedFor(d, node)
m.Logs = logsIssuedFor(d)
m.Streams = streamsIssuedFor(d)
m.Hears = HeardIssuedFor(node, d)
m.Waits = waitsIssuedFor(d)
t := SeatTrafficOf(d.Module, d.Holds, d.Uses, d.Watches)
for _, s := range append(append([]Seat{}, d.Uses...), d.Watches...) {
if !s.Kinded {
continue
}
for _, k := range where.Kinds {
if k.Seat == s.Name && !kindListed(t.Kinds, k) {
t.Kinds = append(t.Kinds, k)
}
}
}
if len(t.Publish)+len(t.Subscribe)+len(t.Answers)+len(t.Workers)+len(t.Records)+len(t.Kinds) > 0 {
m.SeatTraffic = &t
}
if len(d.Invokes) > 0 {
m.Reaches = map[string][]string{}
for _, t := range d.Invokes {
if t == "*" || strings.HasPrefix(t, "seat:") {
continue // every tool, or a role's: addressed by name, not resolved per instance
}
module, tool, ok := strings.Cut(t, ".")
if !ok {
continue
}
var reach []string
if where.AnswersForTheModule(module) {
reach = append(reach, "mesh.mod."+module+".tool."+tool)
}
nodes := append([]string{}, where.Nodes[module]...)
sort.Strings(nodes)
for _, n := range nodes {
reach = append(reach, "mesh.mod."+module+".tool."+tool+"."+n)
}
m.Reaches[t] = reach
}
}
return m
}
// PlacementsOf reads where everything runs from the records the bus's accounts are composed from.
func PlacementsOf(r Records, interchangeable map[string]bool) Placements {
p := Placements{Nodes: map[string][]string{}, Interchangeable: interchangeable}
for node, declared := range r.Assigned {
for _, d := range declared {
p.Nodes[d.Module] = append(p.Nodes[d.Module], node)
}
}
for _, nodes := range p.Nodes {
sort.Strings(nodes)
}
// Where the router runs: a verified sender is believed only while its machine is root-free too. A router
// placed nowhere, or on more than one machine, frees nothing.
var routerNodes []string
for node, declared := range r.Assigned {
for _, d := range declared {
for _, s := range d.Holds {
if s.Name == routerSeat && !slices.Contains(routerNodes, node) {
routerNodes = append(routerNodes, node)
}
}
}
}
routerFree := len(routerNodes) == 1 && r.RootFree[routerNodes[0]]
for node, declared := range r.Assigned {
for _, d := range declared {
for _, s := range d.Holds {
if s.Kinded && s.Kind != "" {
p.Kinds = append(p.Kinds, KindHeld{Seat: s.Name, Kind: s.Kind, Module: d.Module, Node: node,
Capabilities: placedCapabilities(s.Capabilities, d.RunsAs, routerFree && r.RootFree[node])})
}
}
}
}
sort.Slice(p.Kinds, func(i, j int) bool {
a, b := p.Kinds[i], p.Kinds[j]
if a.Seat != b.Seat {
return a.Seat < b.Seat
}
if a.Kind != b.Kind {
return a.Kind < b.Kind
}
return a.Node < b.Node
})
return p
}
// routerSeat is the seat the router of asks holds (novox/hq ADR 0259 §3).
const routerSeat = "operator-channel"
// placedCapabilities is what a kind's claim promises, as far as its placement lets the router believe it
// (novox/hq ADR 0259 §8): `verified-sender` only from a holder that runs as an account of its own, on a bus
// account of its own — never one the machine's runtime carries as the operator's account — and only while
// its machine and the router's were root-free when composed (rootFree; the review of 2026-10-09, H3). The
// membership carrying it is composed at a push, so it can outlive a pass that later fails: the router asks
// the controller's root-free verb again before it honours an approval, and that is the check that holds.
func placedCapabilities(declared []string, runsAs string, rootFree bool) []string {
var out []string
for _, c := range declared {
if c == "verified-sender" && (runsAs == "" || !rootFree) {
continue
}
out = append(out, c)
}
return out
}
func kindListed(list []KindHeld, k KindHeld) bool {
for _, x := range list {
if x.Seat == k.Seat && x.Kind == k.Kind && x.Module == k.Module && x.Node == k.Node {
return true
}
}
return false
}
+343
View File
@@ -0,0 +1,343 @@
package buscompose
import (
"fmt"
"sort"
"strings"
)
// A module's own stream on the bus (novox/hq ADR 0363).
//
// A module declares a stream as it declares a log: named locally, created by the controller from the
// catalogue on every raise and every send, never removed by the mesh. Its events are published on
// `mesh.stream.<module>.event.<event>`, a subject space the shared EVENTS stream (`mesh.mod.*.event.>`)
// does not take — the bus refuses two streams claiming one subject, so a module's stream cannot share
// EVENTS' — and a consumer hears it through a durable consumer of its own on that stream, named as on
// EVENTS. Pure; the controller's raise asserts what is composed here.
// moduleStreamPrefix starts every subject a module's own stream takes.
const moduleStreamPrefix = "mesh.stream."
// ModuleStreamSubject is where a module's event on its own stream lands: `mesh.stream.<module>.event.<event>`.
// The token before `.event.` is the emitter, as on EVENTS, so a runtime reads the same key from it.
func ModuleStreamSubject(module, event string) string {
return moduleStreamPrefix + module + ".event." + event
}
// OnAModuleStream says a subject is one a module's own stream takes.
func OnAModuleStream(subject string) bool { return strings.HasPrefix(subject, moduleStreamPrefix) }
// WirePattern is a local event pattern as the bus spells it: `**` is `>`.
func WirePattern(local string) string {
parts := strings.Split(local, ".")
for i, p := range parts {
if p == "**" {
parts[i] = ">"
}
}
return strings.Join(parts, ".")
}
// A ModuleStream is one module's declared stream as the bus holds it.
type ModuleStream struct {
Module string
// Name is its name on the bus, `MAIL`.
Name string
// Events are the events it carries, as the bus spells them (WirePattern).
Events []string
MaxAgeDays int
MaxMiB int
// Restricted says a trusted config value of the module grants who hears it (ADR 0363 §4).
Restricted bool
// WorkQueue says this stream holds ADR 0436 §6's work-queue retention: a message is removed once a
// consumer acknowledges it, and the bus refuses a new one when full rather than dropping its oldest.
WorkQueue bool
// Duplicates is the work-queue's duplicate window in hours (novox/hq ADR 0436 §6); meaningless unless
// WorkQueue.
Duplicates int
}
// Subjects are the subjects the stream takes.
func (s ModuleStream) Subjects() []string {
var out []string
for _, e := range s.Events {
out = append(out, ModuleStreamSubject(s.Module, e))
}
sort.Strings(out)
return out
}
// Stream is the stream the controller asserts: kept by limits, its age and size the module's, dropping
// its oldest when full — a consumer that was away catches up within the age, and the module's own record
// answers for anything older.
func (s ModuleStream) Stream() Stream {
why := fmt.Sprintf("%s's own stream (novox/hq ADR 0363): its events, kept %d days and at most %d MiB so a "+
"consumer that was away catches up", s.Module, s.MaxAgeDays, s.MaxMiB)
if s.Restricted {
why += "; heard only by the consumers its trusted config value grants"
}
if s.WorkQueue {
why += "; a work-queue (novox/hq ADR 0436 §6): a message is removed once its one consumer " +
"acknowledges it, and a full stream refuses a new one rather than dropping what it holds"
}
out := Stream{
Name: s.Name,
Subjects: s.Subjects(),
Retention: RetentionLimits,
MaxAge: s.MaxAgeDays * 24 * 60 * 60,
MaxBytes: int64(s.MaxMiB) * 1024 * 1024,
Why: why,
// Said on the bus, so a dead letter kept from it ages out with it (novox/hq ADR 0376 §3).
Restricted: s.Restricted,
}
if s.WorkQueue {
out.Retention = RetentionWorkQueue
out.DiscardNew = true
out.DuplicatesSeconds = s.Duplicates * 60 * 60
// Said on the bus too, reusing the one flag a restricted stream's dead letters already age and hide
// by (novox/hq ADR 0436 §6, ADR 0376 §3-4): a work-queue stream's grant is always empty (ADR 0436
// §6 refuses one that carries a grant), so this never collides with a trusted-config restriction.
out.Restricted = true
// And its own metadata, apart from the bus's work-queue retention SEAT_* and other mesh-internal
// streams share without being one of this clause's: only this flag says a dead letter kept from the
// stream is drop-only (novox/hq ADR 0436 §6), never the stream's Retention alone.
out.WorkQueue = true
}
return out
}
// QueueOwner is the one process of a module that is the durable consumer of one of its own work-queue
// streams (novox/hq ADR 0436 §1, §2, §6): named in that process's bus.queues, and checked at registration
// to be the only one (catalogue.WorkQueueStreamProblems).
type QueueOwner struct {
// Process is the process resource's id — its bus account's name, with the node and module (novox/hq ADR
// 0436 §2).
Process string
// Stream is the work-queue stream's name on the bus.
Stream string
// MaxDeliver bounds how many times a message is handed over before it is given up as a dead letter
// (novox/hq ADR 0436 §6).
MaxDeliver int
}
// QueueConsumer is the durable consumer the one process a module's bus.queues names is granted on its own
// work-queue stream (novox/hq ADR 0436 §1, §2, §6): no filter, since it is the stream's only consumer —
// unlike heardConsumer, which is one of possibly several hearing a module's ordinary stream.
func QueueConsumer(p Principal, q QueueOwner) Consumer {
return Consumer{
Name: p.Node + "_" + p.Module + "_" + q.Process,
Stream: q.Stream,
AckWaitSeconds: 30,
MaxDeliver: q.MaxDeliver,
Why: fmt.Sprintf("%s's process %s is the one consumer %s's bus.queues names for its work-queue "+
"stream %s (novox/hq ADR 0436 §6); after max-deliver %d it gives a message up, and the "+
"controller keeps it in %s, offered only drop", p.Module, q.Process, p.Module, q.Stream,
q.MaxDeliver, DeadLettersStream),
}
}
// ModuleStreams is every declared module stream as the controller asserts it, in name order.
func ModuleStreams(declared []ModuleStream) []Stream {
sorted := append([]ModuleStream(nil), declared...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i].Name < sorted[j].Name })
out := make([]Stream, 0, len(sorted))
for _, s := range sorted {
out = append(out, s.Stream())
}
return out
}
// Heard is a module's stream another module hears, and the subjects it hears of it: its `consumes`
// within the stream and, for a restricted stream, within what the provider's trusted config value grants it
// (ADR 0363 §5). Composed only with at least one subject.
type Heard struct {
Stream string
Emitter string
Filters []string
// Restricted says the stream is restricted by its provider's trusted config value (ADR 0363 §4): then it
// is heard only by a module on an account of its own (ADR 0376 §1, ComposedHears).
Restricted bool
}
// ComposedHears is what of a module's hearing the controller composes (novox/hq ADR 0376 §1): every stream it
// hears, but a restricted one only for a module that runs in a runtime of its own on a bus account of its own
// (`runs-as`, ADR 0259). **The tool runner never hears a restricted stream**: a module it carries reaches the bus
// through the tool runner's account, which every agent session on that machine reaches the bus as too, so a
// consumer, its grants or its bind word composed there would hand the stream to any session. A module that
// declares no `runs-as` and keeps its own container credential gets none either: only `runs-as` is an account
// the mesh keeps from agents.
func ComposedHears(d Declared) []Heard {
if d.RunsAs != "" {
return d.Hears
}
var out []Heard
for _, h := range d.Hears {
if !h.Restricted {
out = append(out, h)
}
}
return out
}
// Withheld is a restricted stream a grant gives a module that the controller composes nothing for, because the
// module has no account of its own (novox/hq ADR 0376 §2): said in `doctor` and in the controller's log, never
// silent.
type Withheld struct {
Node, Module, Stream, Emitter string
}
// Said is a withheld hearing in the operator's words: the module, the stream and the remedy.
func (w Withheld) Said() string {
return fmt.Sprintf("%s on %s is granted part of %s's restricted stream %s, and hears none of it: it is carried "+
"by the machine's tool runner, whose bus account every agent session there shares, so nothing is composed "+
"for it. Declare runs-as in %s's manifest, so it runs as an account of its own (novox/hq ADR 0376)",
w.Module, w.Node, w.Emitter, w.Stream, w.Module)
}
// WithheldOf is every granted hearing of a restricted stream composed for nothing, by machine and module.
func WithheldOf(r Records) []Withheld {
var out []Withheld
for _, node := range sortedCopy(r.Nodes) {
for _, d := range r.Assigned[node] {
if d.RunsAs != "" {
continue
}
for _, h := range d.Hears {
if h.Restricted && len(h.Filters) > 0 {
out = append(out, Withheld{Node: node, Module: d.Module, Stream: h.Stream, Emitter: h.Emitter})
}
}
}
}
return out
}
// heardConsumer is the durable consumer a module hears a module's stream through: named as its consumer
// on EVENTS, on that stream, with the subjects it was granted as its filters.
func heardConsumer(p Principal, h Heard) Consumer {
filters := append([]string(nil), h.Filters...)
sort.Strings(filters)
return Consumer{
Name: consumerDurable(p),
Stream: h.Stream,
Filters: filters,
AckWaitSeconds: 30,
MaxDeliver: 5,
// From now, like a module's consumer on EVENTS (novox/hq ADR 0443 §1).
FromNow: true,
Why: "what " + p.Module + " consumes of " + h.Emitter + "'s stream " + h.Stream + " and is granted " +
"(novox/hq ADR 0363); after max-deliver it gives an event up, and the controller keeps it in " +
"DEAD_LETTERS until a person delivers it again or drops it",
}
}
// heardGrants are what a principal publishes to read and acknowledge the consumers it hears module streams
// through: asking about each, pulling from it, acknowledging it — its own name on each stream, no other's.
func heardGrants(p Principal, hears []Heard) []string {
var out []string
durable := consumerDurable(p)
for _, h := range hears {
if !safeSubject.MatchString(h.Stream) || len(h.Filters) == 0 {
continue
}
out = append(out,
"$JS.API.CONSUMER.INFO."+h.Stream+"."+durable,
"$JS.API.CONSUMER.MSG.NEXT."+h.Stream+"."+durable,
"$JS.ACK."+h.Stream+"."+durable+".>")
}
return out
}
// ownStreamGrants are the subjects a module publishes its own streams' events on, and no other's.
func ownStreamGrants(module string, streams []ModuleStream) []string {
var out []string
for _, s := range streams {
if s.Module != module {
continue
}
out = append(out, s.Subjects()...)
}
return out
}
// streamsIssuedFor is every stream of its own a module's membership names.
func streamsIssuedFor(d Declared) []StreamIssued {
var out []StreamIssued
for _, s := range d.Streams {
if s.Module != d.Module || !safeSubject.MatchString(d.Module) {
continue
}
out = append(out, StreamIssued{Stream: s.Name, Events: append([]string(nil), s.Events...),
Subject: ModuleStreamSubject(d.Module, "{event}")})
}
return out
}
// HeardIssuedFor is every module stream an assignment hears, as its membership names it.
func HeardIssuedFor(node string, d Declared) []HeardIssued {
var out []HeardIssued
// A restricted stream's bind word only for a module of its own account (novox/hq ADR 0376 §1).
for _, h := range ComposedHears(d) {
if len(h.Filters) == 0 {
continue
}
out = append(out, HeardIssued{Stream: h.Stream, Consumer: node + "_" + d.Module})
}
sort.Slice(out, func(i, j int) bool { return out[i].Stream < out[j].Stream })
return out
}
// Pruner is the part of a JetStream connection that removes a module's consumer on a module's stream it
// may no longer hear (ADR 0363 §6). Optional: what only records what it is asked to assert has none.
type Pruner interface {
ConsumerNames(stream string) ([]string, error)
RemoveConsumer(stream, name string) error
}
// PruneHeard removes, from each module stream, every durable consumer the composition no longer asks for,
// and answers what it removed as `<stream>/<consumer>`. A consumer that loses its grant loses what it reads
// through, whatever its runtime still holds; a stream that does not exist yet has nothing to remove.
func PruneHeard(p Pruner, streams []ModuleStream, wanted []Consumer) ([]string, error) {
keep := map[string]bool{}
for _, c := range wanted {
keep[c.Stream+"/"+c.Name] = true
}
var removed []string
for _, s := range streams {
names, err := p.ConsumerNames(s.Name)
if err != nil {
return removed, fmt.Errorf("listing the consumers on %s: %w", s.Name, err)
}
for _, n := range names {
if keep[s.Name+"/"+n] {
continue
}
if err := p.RemoveConsumer(s.Name, n); err != nil {
return removed, fmt.Errorf("removing %s's consumer on %s, which is no longer granted: %w", n, s.Name, err)
}
removed = append(removed, s.Name+"/"+n)
}
}
return removed, nil
}
// EnsureHeard asserts a consumer on a module's stream (ADR 0363 §5, §6). **On a restricted stream, a consumer
// the bus will not bring to match is removed**: a pull consumer delivers to whoever pulls it, so one left with
// its wider filters after a narrowing was refused would keep handing over what the grant no longer names,
// whatever the user list now says. The next assertion makes it again with the granted filters. The refusal is
// returned either way, saying what was done, so the send says it and raises it.
func EnsureHeard(r Raiser, c Consumer, restricted bool) error {
err := r.EnsureConsumer(c)
if err == nil || !restricted {
return err
}
p, prunes := r.(Pruner)
if !prunes {
return fmt.Errorf("%w; it could not be removed here, and may still deliver more than it is granted", err)
}
if rerr := p.RemoveConsumer(c.Stream, c.Name); rerr != nil {
return fmt.Errorf("%w; REMOVING IT FAILED TOO, so it may still deliver more than it is granted: %v", err, rerr)
}
return fmt.Errorf("%w; REMOVED, so it delivers nothing it is no longer granted: the next assertion makes it "+
"again with its granted filters (novox/hq ADR 0363 §6)", err)
}
File diff suppressed because it is too large Load Diff
+5
View File
@@ -0,0 +1,5 @@
package buscompose
// NATSVar names the environment variable that says the controller is on the bus (mesh-controller internal/broker
// onnats.go); kept with the composer for the readiness it composes.
const NATSVar = "MESH_BUS_NATS"
+56
View File
@@ -0,0 +1,56 @@
package buscompose
// The bus account of one process of a module that declares bus (novox/hq ADR 0436 §1, §2, §3, §5).
//
// **Never the module's own account's grants, never the tool runner's.** A process's authority is read
// straight from its bus declaration — its emits, its queue's consumer, its state, whether it reads the
// records of its module's own asks, and the tools and seat verbs it calls — and nothing else: it holds no
// seat, serves no tool, hears no module stream, and answers nothing a caller asked (§3). Modelled on
// provisionerGrants, the other kind of process account the mesh mints (ADR 0349 clauses 6 and 11).
// processGrants is one process's whole authority: to publish, and to subscribe.
func processGrants(p Principal) (pub, sub []string, err error) {
// 1. Its own events, under its module's own namespace — the same subjects the module's own principal
// would publish them on, had it declared them there instead (novox/hq ADR 0436 §1).
own := "mesh.mod." + p.Module
for _, e := range p.Emits {
pub = append(pub, own+".event."+e)
}
// 2. The one durable consumer of each work-queue stream its bus names (novox/hq ADR 0436 §1, §2, §6):
// asked about, pulled from and acknowledged, by the same name ConsumersOf composes the consumer under
// (QueueConsumer) — and no other stream's.
for _, q := range p.Queues {
consumer := p.Node + "_" + p.Module + "_" + q.Process
pub = append(pub,
"$JS.API.CONSUMER.INFO."+q.Stream+"."+consumer,
"$JS.API.CONSUMER.MSG.NEXT."+q.Stream+"."+consumer,
"$JS.ACK."+q.Stream+"."+consumer+".>")
}
// 3. Its module's own state, by the local names its bus names alone — never the whole of what the
// module keeps (novox/hq ADR 0436 §1), read and written as stateGrants gives any module's own bucket.
kept := stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State}
pub = append(pub, stateGrants(kept)...)
// And it hears each of them created, so one not on the bus at its start is bound when it appears (novox/hq issue
// 1790; process.AwaitBucket).
sub = append(sub, stateCreatedHeard(kept)...)
// 4. The router's records of its module's own asks (novox/hq ADR 0436 §1, ADR 0259 §3): the same
// direct-get subjects a module's own principal reads for the seats its Uses names that keep them —
// read here from the one list, so the two cannot disagree.
if p.Asks {
pub = append(pub, SeatTrafficOf(p.Module, nil, p.Uses, nil).Records...)
}
// 5. The tools and seat verbs it calls (novox/hq ADR 0152, ADR 0436 §1): the same derivation a
// module's own principal's invokes takes, so `callerNamed` (applied to every non-controller principal)
// adds the caller-named `call` variants for it exactly as it would for a module.
invoked, err := invokedSubjects(p.Invokes)
if err != nil {
return nil, nil, err
}
pub = append(pub, invoked...)
return pub, sub, nil
}
+217
View File
@@ -0,0 +1,217 @@
package buscompose
import (
"errors"
"fmt"
"git.novox.be/novox/mesh-sdk/go/delivery"
"strings"
"github.com/nats-io/nats.go"
)
// The provisioner client's own bus account (novox/hq ADR 0349 clauses 6 and 11, design 57 package P3).
//
// **Not the tool runner's account.** The client fills a machine's files and asks the vault for the secrets that
// machine's declaration names; the vault hands a secret only to an account of its own, because a call under the
// tool runner's account cannot be told from an agent's on that machine (ADR 0305 §3, §12). So each machine that
// runs the client has a principal of its own, KindProvisioner, named for the machine, and granted what the client
// does and nothing else: read its own machine's keys in each domain's bucket, write its own machine's keys of the
// provisioner server's needs, applied and collections (design 57 P4), read the server's registry, say what it
// applied on its own machine, and ask the vault in its own name.
// NodeProvisionerSeat is the seat the provisioner client holds on every machine (ADR 0349 clause 3).
const NodeProvisionerSeat = "node-provisioner"
// ProvisionerSecret is the own secret of the module running the client that its bus account is delivered in, as
// `broker` is a module's own (novox/hq issue 195): a module that claims NodeProvisionerSeat and declares no own
// secret of this name has nowhere to read an account, and is given none.
const ProvisionerSecret = "provisioner-broker"
// ConfigResolvedBucket is the config domain's resolved values, keyed `<node>.<module>.<key>` (ADR 0348, research
// 051): mesh-config's state `resolved`, written by mesh-config alone.
var ConfigResolvedBucket = BucketName("mesh-config", "resolved")
// ProvisionerReads are the domains' buckets a provisioner client reads its own machine's keys of, each keyed
// `<node>.…` (research 051): placement, the controller's assignments mirror (`<node>.<module>`), whose value carries
// the provisioner's plan of a module handed to it (inventory.Provisioning, design 57 P5); and config,
// mesh-config's resolved values. **Named one by one**, as the controller's asks are: a domain that moves to the
// provisioner adds its bucket here, and a bucket not named is one the client cannot read.
var ProvisionerReads = []string{ConfigResolvedBucket}
// ProvisionerReadsWhole are the buckets a provisioner client reads every key of: the controller's assignments mirror,
// where a provider's consumers are bound to it in their own machines' plans, which the provider machine's client
// reads to fill ${consumers:<provision>} with each one's credential (novox/hq ADR 0373, issue 679). It holds placement
// and plans, never a secret's value; the client writes none of it.
var ProvisionerReadsWhole = []string{AssignmentsBucket}
// BoundState is a providing module's state of entries, one per consumer (mesh-sdk go/provider, issue 678): a
// consumer's machine's client reads its own keys of it, `<node>.<module>.<provision>[.<local>]`, to fill
// ${bound:<provision>:<key>} (issue 679).
const BoundState = "bound"
// ProvisionerServerModule is the module holding the seat mesh-provisioner once for the mesh (ADR 0349 clause 3,
// design 57 P4), whose buckets the clients write their own machine's keys of.
const ProvisionerServerModule = "mesh-provisioner"
// The provisioner server's buckets (ADR 0349 clause 3): its state, created by the controller from its manifest
// like any module's, and written by its own account whole.
var (
// ProvisionerRegistryBucket is which domain answers which kind of placeholder, keyed by the kind; the
// server's alone, read whole by every client.
ProvisionerRegistryBucket = BucketName(ProvisionerServerModule, "registry")
// ProvisionerNeedsBucket is what each module on each machine needs, `<node>.<module>.<kind>.<name>`.
ProvisionerNeedsBucket = BucketName(ProvisionerServerModule, "needs")
// ProvisionerAppliedBucket is what is applied where, `<node>.<module>`.
ProvisionerAppliedBucket = BucketName(ProvisionerServerModule, "applied")
// ProvisionerCollectBucket is the items of every collection, `<node>.<collection>.<contributor>`.
ProvisionerCollectBucket = BucketName(ProvisionerServerModule, "collect")
)
// ProvisionerWrites are the server's buckets a client writes, and reads, its own machine's keys of (`<node>.>`)
// and no other's: the needs it records, what it applied, and the items of the collections on its machine. A
// client cannot write another machine's needs, or say another machine applied a file.
var ProvisionerWrites = []string{ProvisionerNeedsBucket, ProvisionerAppliedBucket, ProvisionerCollectBucket}
// VerbsTheProvisionerAsksTheVault are the vault's verbs a provisioner client asks in its own name (ADR 0349
// clause 11): `get`, a secret its machine's declaration names, answered sealed to the asker's key. On the subject
// that names its caller, so the vault knows which machine's client asks from the bus's word.
var VerbsTheProvisionerAsksTheVault = []SeatVerb{{Seat: "mesh-vault", Verb: "get"}}
// VaultSecretChangedSubject is where the vault says a secret it holds changed (`secret-changed`: which, by whom,
// when, its generation; never a value, ADR 0349 clause 11), as link.EventSubject addresses mesh-vault's event. A
// provisioner client listens to it so a rotated secret reaches its file without a new declaration (design 57 V4):
// it asks the vault again, in its own name, for a secret its machine's declaration names.
const VaultSecretChangedSubject = "mesh.mod.mesh-vault.event.secret-changed"
// ProvisionerAppliedSubject is where a machine's client says what it applied for one module, applied or failed
// with the reason (ADR 0349 clauses 4 and 15): `prov.applied.<node>.<module>`.
func ProvisionerAppliedSubject(node, module string) string {
return "prov.applied." + node + "." + module
}
// ProvisionerAskSubject is where a machine's tool runner asks that machine's provisioner client for a config value
// or a non-secret bound value of a module it carries, naming the module from the bundle that asks (novox/hq ADR
// 0370 §1, §3, §4; issue 685): `prov.ask.<node>`, which the client subscribes and the tool runner's account of the
// same machine alone may publish. The bus proves only that the tool runner asks; the module is the runtime's word,
// so the client answers no secret here (ADR 0370 §2).
func ProvisionerAskSubject(node string) string { return "prov.ask." + node }
// ProvisionerChangedSubject is where a machine's client says that a value of one module it answers moved, so the
// tool runner asks again for the module's code's watches (ADR 0349 §14, ADR 0370 §1): `prov.changed.<node>.<module>`,
// carrying which value and never the value itself, heard by that machine's tool runner alone.
func ProvisionerChangedSubject(node, module string) string {
return "prov.changed." + node + "." + module
}
// provisionerGrants is a provisioner client's whole authority: to publish, and to subscribe.
func provisionerGrants(p Principal) (pub, sub []string, err error) {
if p.Node == "" {
// Refused rather than composed into `$KV.<bucket>..>`, a pattern no machine's keys are under, and a
// name every nameless client would share.
return nil, nil, errors.New("a provisioner client names no machine, so it would have no keys of its own " +
"to read and an account it shared with every other")
}
for _, bucket := range ProvisionerReads {
pub = append(pub, machineKeysRead(bucket, p.Node)...)
}
for _, bucket := range ProvisionerReadsWhole {
pub = append(pub, wholeBucketRead(bucket)...)
}
for _, bucket := range p.BoundBuckets {
module, name, ok := strings.Cut(bucket, "_")
if !ok || name != BoundState || !safeSubject.MatchString(module) || strings.Contains(module, "_") {
return nil, nil, fmt.Errorf("%q is not a provider's bucket of entries, <module>_%s", bucket, BoundState)
}
pub = append(pub, machineKeysRead(bucket, p.Node)...)
}
// The server's buckets (design 57 P4): its own machine's keys of the needs, the applied and the
// collections, read and written, `$KV.<bucket>.<node>.>` and nothing wider; and the registry, read whole,
// which says which domain answers which kind and is the same for every machine.
for _, bucket := range ProvisionerWrites {
pub = append(pub, machineKeysRead(bucket, p.Node)...)
pub = append(pub, "$KV."+bucket+"."+p.Node+".>")
}
pub = append(pub, wholeBucketRead(ProvisionerRegistryBucket)...)
// Its own node's own have, `<node>.node`, read and never written, and no other key of the bucket: it fills every
// module again when the node-engine moves to a new composition of the node's own (issue 1945), since an apply can
// leave a filled file as the composition had it.
pub = append(pub, readGrants(delivery.HaveBucket, p.Node+".node")...)
pub = append(pub, "prov.applied."+p.Node+".>")
token := CallerToken(p.Username())
if token == "" {
return nil, nil, fmt.Errorf("%q cannot name a caller", p.Username())
}
for _, v := range VerbsTheProvisionerAsksTheVault {
pub = append(pub, "mesh.seat."+v.Seat+"."+CallKind+"."+v.Verb+"."+token)
}
// What moved of a value it answers its own machine's tool runner (ADR 0370 §1), under its own machine's name.
pub = append(pub, "prov.changed."+p.Node+".>")
// Its own reply space, for the vault's answers and its reads' and watches' deliveries; what the vault says
// changed; and its own machine's tool runner asking it for a module's config and non-secret bound values (ADR
// 0370 §3), which it answers on the ask's reply and nowhere else.
return pub, []string{p.inbox(), VaultSecretChangedSubject, ProvisionerAskSubject(p.Node)}, nil
}
// machineKeysRead is reading every key of a bucket under one machine's name, `<node>.>`, and no other key: a
// direct get of a key's own subject, and a consumer whose filter is under it — the server's create-with-filter
// form, which it holds to the filter the subject names (as stateGrants does for one key, novox/hq ADR 0260).
// Binding, deleting a consumer and flow control cannot be narrowed by key and read no value.
func machineKeysRead(bucket, node string) []string {
stream := "KV_" + bucket
keys := "$KV." + bucket + "." + node + ".>"
return []string{
"$JS.API.STREAM.INFO." + stream,
"$JS.API.DIRECT.GET." + stream + "." + keys,
"$JS.API.CONSUMER.CREATE." + stream + ".*." + keys,
"$JS.API.CONSUMER.DELETE." + stream + ".>",
"$JS.FC." + stream + ".>",
}
}
// wholeBucketRead is reading every key of a bucket and writing none: what stateGrants grants a module for a
// bucket it `reads`.
func wholeBucketRead(bucket string) []string {
stream := "KV_" + bucket
return []string{
"$JS.API.STREAM.INFO." + stream,
"$JS.API.DIRECT.GET." + stream + ".>",
"$JS.API.CONSUMER.CREATE." + stream + ".>",
"$JS.API.CONSUMER.DELETE." + stream + ".>",
"$JS.FC." + stream + ".>",
}
}
// LastProvApplied is what a machine's provisioner client last said of one module's files, as it published it on
// ProvisionerAppliedSubject and ProvAppliedStream keeps it (design 57 P6): false when it has said nothing of it in
// the stream's week.
func LastProvApplied(js nats.JetStreamContext, node, module string) ([]byte, bool, error) {
msg, err := js.GetLastMsg(ProvAppliedStream, ProvisionerAppliedSubject(node, module))
if errors.Is(err, nats.ErrMsgNotFound) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("what %s's provisioner client said of %s cannot be read: %w", node, module, err)
}
return msg.Data, true, nil
}
// ProvAppliedReports is the newest word of every machine's provisioner client on every module it said one of, kept by
// ProvAppliedStream, by subject (`prov.applied.<node>.<module>`).
func ProvAppliedReports(js nats.JetStreamContext) (map[string][]byte, error) {
info, err := js.StreamInfo(ProvAppliedStream, &nats.StreamInfoRequest{SubjectsFilter: ProvAppliedSubjects})
if err != nil {
return nil, fmt.Errorf("the provisioner clients' reports cannot be listed: %w", err)
}
out := map[string][]byte{}
for subject := range info.State.Subjects {
msg, err := js.GetLastMsg(ProvAppliedStream, subject)
if errors.Is(err, nats.ErrMsgNotFound) {
continue // aged out between the two reads
}
if err != nil {
return nil, fmt.Errorf("the provisioner client's report on %s cannot be read: %w", subject, err)
}
out[subject] = msg.Data
}
return out, nil
}
+73
View File
@@ -0,0 +1,73 @@
package buscompose
import "fmt"
// Bringing the bus's own objects into being, in the one order that works.
//
// **Asserted on every start rather than created once at genesis.** A stream somebody deleted, a mesh
// raised from a restored backup, or a bus whose data directory was replaced all have records and no
// objects — and a node whose consumer is missing hears nothing while everything else about it looks
// correct. Idempotence is the whole requirement, and the parts are already idempotent; this is the
// order they have to be asked in.
// Raiser is everything asserting the bus's objects needs of a connection to it.
type Raiser interface {
Asserter
Ensurer
}
// Raise asserts the mesh's streams, the controller's own consumers, and one consumer per node.
//
// **The order is not a preference.** A consumer on a stream that does not exist is refused, and the
// refusal names the stream rather than the order — so somebody reading it goes looking for a deleted
// stream instead of a reversed pair of lines. Nodes last, because the one a node reads lives on a
// stream the mesh's own set defines.
func Raise(r Raiser, nodes []string) error {
if err := AssertMeshStreams(r); err != nil {
return err
}
if err := AssertMeshConsumers(r); err != nil {
return err
}
if err := AssertNodeConsumers(r, nodes); err != nil {
return err
}
return nil
}
// RaiseSeats asserts one work queue per declared seat, and the worker of whoever holds it.
//
// Separate from Raise because it is answered by a different question: the mesh's own objects exist
// because the mesh does, and a seat's exist because a module declaring one was registered. Kept
// beside it so the order is visible — a holder's worker needs the seat's stream, and a seat's stream
// needs nothing.
func RaiseSeats(r Raiser, seats []DeclaredSeat, holders map[string]Holder) error {
for _, s := range SeatStreams(seats) {
if err := r.EnsureStream(s); err != nil {
return fmt.Errorf("asserting the work queue for %s: %w", s.Name, err)
}
}
for _, s := range seats {
h, held := holders[s.Name]
if !held {
// **The stream exists and the consumer does not, on purpose.** Work queues until a
// holder appears, so installing the module a week after something started sending to it
// flushes the backlog instead of having lost it.
continue
}
c, needed := HolderConsumerFor(h.Node, h.Module, s)
if !needed {
continue
}
if err := r.EnsureConsumer(c); err != nil {
return fmt.Errorf("asserting how %s on %s works %s: %w", h.Module, h.Node, s.Name, err)
}
}
return nil
}
// Holder is which module on which machine holds a seat.
type Holder struct {
Node string
Module string
}
+139
View File
@@ -0,0 +1,139 @@
package buscompose
import (
"fmt"
"sort"
"strings"
)
// Whether a mesh could move its bus, and what is missing if not.
//
// **Asked before anything moves, and answerable from records alone.** The rollout moves every node at
// once (novox/hq ADR 0116 step 5), so there is no partial state to inspect afterwards and no half to
// roll back: either the mesh was ready or it was not. That makes a readiness question the most
// valuable thing here — it costs nothing, it can be asked of a running mesh any number of times, and
// every answer is a thing somebody can go and fix.
//
// Deliberately pure. It is handed what the mesh knows and returns sentences; nothing here connects to
// anything, so it can be asked on a workstation about a mesh it has never reached.
// Readiness is what the mesh knows about its own ability to move.
type Readiness struct {
// TheBus is the address the mesh's own traffic would move to, empty when nothing names one.
TheBus string
// ServerStanding is whether a bus is reachable at that address, as somebody checked.
ServerStanding bool
// Holder is the node running the module that holds the bus seat, empty when nothing does.
Holder string
// AccountsComposed is whether that node has been sent the composed user list.
AccountsComposed bool
// Nodes is every machine the mesh knows.
Nodes []string
// Credentialled is which of them has a credential for the new bus.
Credentialled map[string]bool
// Modules is every assigned module, as `<node>/<module>`.
Modules []string
// ModuleCredentialled is which of those has one.
ModuleCredentialled map[string]bool
}
// NotReady is every reason this mesh cannot move its bus yet, in the order somebody would fix them.
//
// Empty means ready. **Each entry names one thing and what to do about it**, because a readiness check
// that says "not ready" is a check nobody can act on — and this is read at the point where the next
// step is irreversible.
func NotReady(r Readiness) []string {
var why []string
if strings.TrimSpace(r.TheBus) == "" {
why = append(why, "nothing names the bus to move to: set "+NATSVar+" on the control node "+
"to the address the new server answers on")
}
if !r.ServerStanding {
why = append(why, "no bus is answering at that address. Step 2 of the change raises it beside "+
"the one the mesh is on, carrying nothing — assign the module that holds "+
"mesh-broker and push the machine that runs it")
}
if r.Holder == "" {
why = append(why, "no machine holds mesh-broker, so nothing would compose the bus's user "+
"list. Assign the module that claims it")
} else if !r.AccountsComposed {
why = append(why, fmt.Sprintf(
"%s holds mesh-broker and has not been sent the composed user list, so the bus would "+
"refuse every connection. `push %s`", r.Holder, r.Holder))
}
// A node with no credential cannot come back after the move, and a node that cannot come back is
// a machine the mesh has lost until somebody goes to it.
var missing []string
for _, n := range r.Nodes {
if !r.Credentialled[n] {
missing = append(missing, n)
}
}
sort.Strings(missing)
if len(missing) > 0 {
why = append(why, fmt.Sprintf(
"%d machine(s) have no credential for the new bus and would not come back: %s. Each needs "+
"one minted before the move, not after — after, there is no bus to ask over",
len(missing), strings.Join(missing, ", ")))
}
// A module without one keeps running and stops being reachable, which is a smaller fault and still
// one somebody should choose rather than discover.
var quiet []string
for _, m := range r.Modules {
if !r.ModuleCredentialled[m] {
quiet = append(quiet, m)
}
}
sort.Strings(quiet)
if len(quiet) > 0 {
why = append(why, fmt.Sprintf(
"%d module(s) have no credential for the new bus: %s. Each keeps serving and stops "+
"answering tools and hearing events until it is issued one",
len(quiet), strings.Join(quiet, ", ")))
}
return why
}
// WhatMoves is what the rollout would do, in order, for somebody reading before they commit.
//
// **Written out rather than summarised.** This is the one step with nothing to inspect afterwards, so
// the last useful moment to disagree with it is while reading this.
func WhatMoves(r Readiness) []string {
out := []string{
fmt.Sprintf("compose the bus's user list and send it to %s", holderOr(r.Holder)),
fmt.Sprintf("move this control plane to %s, and confirm it is heard", busOr(r.TheBus)),
}
nodes := append([]string(nil), r.Nodes...)
sort.Strings(nodes)
for _, n := range nodes {
out = append(out, fmt.Sprintf("move %s, and confirm it reports", n))
}
if len(r.Modules) > 0 {
out = append(out, fmt.Sprintf("move %d module runtime(s), and confirm each answers",
len(r.Modules)))
}
// **The old broker goes, and it goes last** (novox/hq ADR 0131). AMQP is not a provision, so once
// every machine reports on the new bus nothing of the mesh is left speaking to it, and its module
// is unassigned. Said as a step so nobody reads the move as leaving a second bus behind.
out = append(out, "then unassign the old broker's module: AMQP is not a provision (ADR 0131), and "+
"once every machine reports on the new bus nothing of the mesh speaks to it")
return out
}
func holderOr(node string) string {
if node == "" {
return "whichever machine holds mesh-broker"
}
return node
}
func busOr(address string) string {
if address == "" {
return "the new bus"
}
return address
}
+38
View File
@@ -0,0 +1,38 @@
package buscompose
import (
"git.novox.be/novox/mesh-sdk/go/membership"
"git.novox.be/novox/mesh-sdk/go/process"
)
// The membership the mesh issues an assignment (novox/hq ADR 0160), its subject, its stream, the bare address and the
// pin are mesh-sdk's, public so that a module's program outside this Go module, the route proxy, reads them too
// (novox/hq ADR 0480 clause 4, issue 1633). This package keeps no second copy: what follows only names them where the
// controller already did.
type (
Membership = membership.Membership
Served = membership.Served
SeatServed = membership.SeatServed
StateIssued = membership.StateIssued
LogIssued = membership.LogIssued
Worker = membership.Worker
SeatTraffic = membership.SeatTraffic
KindHeld = membership.KindHeld
StreamIssued = membership.StreamIssued
HeardIssued = membership.HeardIssued
WaitIssued = membership.WaitIssued
)
// AssignmentsStream holds every assignment's membership, the newest per subject.
const AssignmentsStream = membership.AssignmentsStream
var (
// MembershipSubject is where an assignment's own membership is published.
MembershipSubject = membership.Subject
// CredentialIn reads the user and password out of a bus address, and the address without them.
CredentialIn = membership.CredentialIn
// BareAddress is a bus address with any credential stripped.
BareAddress = membership.BareAddress
// PinnedToFingerprint accepts exactly the certificate with this SHA-256 and no other.
PinnedToFingerprint = process.PinnedToFingerprint
)
+308
View File
@@ -0,0 +1,308 @@
package buscompose
import (
"fmt"
"sort"
)
// What a module may say and take on the seats it holds, uses and watches, beyond tools (novox/hq ADR
// 0259 §3, to-be 46 §10).
//
// Three rules are added to the ones a seat always had, each a subject whose last token names who may
// publish it, granted to that publisher alone — the way a node seat's event about a machine carries the
// machine (ADR 0219):
//
// - **A verb named by its caller** (`by-caller`). A user of the seat submits that accept, and hears that
// event, under its own module's name and no other: `accept.ask.<module>`, `event.decided.<module>`. The
// holder takes every caller's accept and says the event to any caller. So an ask's asker is a fact the
// server enforces, and a warrant reaches only the asker it is for.
// - **A kinded bench** (ADR 0234 §2). A holder claims one kind and takes its own kind's accepts, says its
// own kind's events and asks its own kind's proofs, and nothing of another kind; a user submits to any
// kind. Each kind has its own worker on the seat's queue, so a holder that is away keeps its work and
// holds up no other kind.
// - **A proof** (`proofs`): core request and reply on `mesh.seat.<seat>.proof.<verb>.<kind>`. No stream's
// subjects cover it, so what travels there — a code typed by the operator — is never persisted. A kinded
// holder asks with its own kind; the modules that watch the seat answer.
//
// And two reads: **a holder's records**, a bucket the seat names, read by each user under its own name only
// (`$KV.<bucket>.<module>.>`), so an asker reads the state of its own asks and no other asker's; and on a seat pulled by
// kind, **what its workers gave up on**, the copies DEAD_LETTERS keeps of the seat's stream (DeadLetterReads, issue 734).
// BuildAskMaxDeliver bounds the deliveries of a build seat's ask (issue 1907): mesh-sdk delivery.AskMaxDeliver, written
// here as the broker mirrors the catalogue rather than importing it.
const BuildAskMaxDeliver = 5
// KindedBenches are the seats that may be kinded (ADR 0234 §2): making another is a decision, recorded.
var KindedBenches = map[string]bool{"channel": true, "intake": true}
// WorkerName is the worker a seat's holders pull from: one for the seat, or one per kind on a kinded bench.
func WorkerName(seat, kind string) string {
if kind == "" {
return "SEAT_" + upperSnake(seat) + "_worker"
}
return "SEAT_" + upperSnake(seat) + "_" + upperSnake(kind) + "_worker"
}
func namesVerb(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// SeatTrafficOf derives one module's seat traffic from the seats it holds, uses and watches. Only seats
// carrying one of the rules above are read: every other seat is composed as it always was.
func SeatTrafficOf(module string, holds, uses, watches []Seat) SeatTraffic {
var t SeatTraffic
for _, s := range holds {
if !s.isNewTraffic() {
continue
}
kind := ""
if s.Kinded {
kind = s.Kind
if kind == "" || !safeSubject.MatchString(kind) {
// A kinded claim without a usable kind is refused at registration; here it is granted
// nothing, which is the same answer at the last place it could be asked.
continue
}
}
if len(s.Accepts) > 0 && s.PulledByKind {
// One worker per accepted kind, which every holder pulls in the order of Accepts (ADR 0525 §4, ADR 0536 §4):
// `SEAT_<SEAT>_<KIND>_worker`, filtered to `accept.<kind>` (mesh-sdk delivery.AskWorker).
for _, a := range s.Accepts {
t.Workers = append(t.Workers, Worker{Stream: seatStreamName(s.Name), Consumer: WorkerName(s.Name, a),
Filter: "mesh.seat." + s.Name + ".accept." + a})
}
} else if len(s.Accepts) > 0 {
stream := seatStreamName(s.Name)
filter := "mesh.seat." + s.Name + ".accept.>"
if kind != "" {
filter = "mesh.seat." + s.Name + ".accept.*." + kind
}
t.Workers = append(t.Workers, Worker{Stream: stream, Consumer: WorkerName(s.Name, kind), Filter: filter})
}
for _, a := range s.Accepts {
switch {
case kind != "":
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+"."+kind))
case namesVerb(s.ByCaller, a):
t.Subscribe = append(t.Subscribe, seatSubject(s, "accept", a+".*"))
}
}
for _, e := range s.Emits {
switch {
case kind != "":
t.Publish = append(t.Publish, seatSubject(s, "event", e+"."+kind))
case namesVerb(s.ByCaller, e):
t.Publish = append(t.Publish, seatSubject(s, "event", e+".*"))
}
}
if kind != "" {
for _, v := range s.Proofs {
t.Publish = append(t.Publish, seatSubject(s, "proof", v+"."+kind))
}
}
}
for _, s := range uses {
if !s.isNewTraffic() {
continue
}
for _, a := range s.Accepts {
switch {
case namesVerb(s.ByCaller, a):
t.Publish = append(t.Publish, seatSubject(s, "accept", a+"."+module))
case s.Kinded && module == s.DeclaredBy:
// Work for a kind is put on its queue by the bench's own router, and by no other user.
t.Publish = append(t.Publish, seatSubject(s, "accept", a+".*"))
}
}
for _, e := range s.Emits {
if namesVerb(s.ByCaller, e) {
t.Subscribe = append(t.Subscribe, seatSubject(s, "event", e+"."+module))
}
}
for _, b := range s.Records {
if !safeSubject.MatchString(b) {
continue
}
t.Records = append(t.Records, "$JS.API.DIRECT.GET.KV_"+b+".$KV."+b+"."+module+".>")
}
if s.PulledByKind && len(s.Accepts) > 0 && safeSubject.MatchString(s.Name) {
t.Records = append(t.Records, DeadLetterReads(seatStreamName(s.Name))...)
}
}
for _, w := range watches {
if w.Kinded {
for _, e := range w.Emits {
t.Subscribe = append(t.Subscribe, seatSubject(w, "event", e+".*"))
}
if module == w.DeclaredBy {
// A code is answered by the bench's own router, and by no other watcher.
for _, v := range w.Proofs {
t.Answers = append(t.Answers, seatSubject(w, "proof", v+".*"))
}
}
}
}
t.Publish = unique(t.Publish)
t.Subscribe = unique(t.Subscribe)
t.Answers = unique(t.Answers)
t.Records = unique(t.Records)
sort.Slice(t.Workers, func(i, j int) bool { return t.Workers[i].Consumer < t.Workers[j].Consumer })
return t
}
// DeadLetterReads is what a user of a seat pulled by kind is granted to hear which of the asks it put the seat's workers
// gave up on (novox/hq issue 734): an ordered consumer on DEAD_LETTERS filtered to the copies the controller keeps of that
// seat's stream (DeadLetterSubject(stream, *)), pulled, and its deletion. A copy carries the ask's own message id
// (Mesh-Dead-Msg-Id), so the asker ends the step it waits for instead of waiting for ever; it reads the seat's dead
// letters and no other stream's, and changes none.
func DeadLetterReads(stream string) []string {
return []string{
"$JS.API.STREAM.INFO." + DeadLettersStream,
"$JS.API.CONSUMER.CREATE." + DeadLettersStream + ".*." + DeadLetterSubject(stream, ">"),
"$JS.API.CONSUMER.MSG.NEXT." + DeadLettersStream + ".*",
"$JS.API.CONSUMER.DELETE." + DeadLettersStream + ".*",
}
}
// seatTrafficGrants is the bus permissions seat traffic needs: the subjects themselves, and the JetStream API a
// worker is pulled and acknowledged through and a record is read through.
func seatTrafficGrants(t SeatTraffic) (pub, sub []string) {
pub = append(pub, t.Publish...)
sub = append(sub, t.Subscribe...)
sub = append(sub, t.Answers...)
for _, w := range t.Workers {
pub = append(pub,
"$JS.API.CONSUMER.INFO."+w.Stream+"."+w.Consumer,
"$JS.API.CONSUMER.MSG.NEXT."+w.Stream+"."+w.Consumer,
"$JS.ACK."+w.Stream+"."+w.Consumer+".>")
}
pub = append(pub, t.Records...)
return pub, sub
}
// isNewTraffic says whether a seat carries any of the rules above, so a seat that carries none is
// composed exactly as before them.
func (s Seat) isNewTraffic() bool {
return s.Kinded || len(s.ByCaller) > 0 || len(s.Proofs) > 0 || len(s.Records) > 0 || s.PulledByKind
}
// plainVerbs is a seat's accepts or emits with those the rules above compose taken out: a verb named by
// its caller and every verb of a kinded bench are composed by SeatTrafficOf and nowhere else.
func plainVerbs(s Seat, verbs []string) []string {
if s.Kinded {
return nil
}
var out []string
for _, v := range verbs {
if !namesVerb(s.ByCaller, v) {
out = append(out, v)
}
}
return out
}
// SeatTrafficObjects is the work queues and workers the seat traffic of every composed user implies
// (novox/hq ADR 0259 §3): a queue for each seat a holder takes work from, and each holder's worker on it —
// one per kind on a kinded bench, filtered to that kind, so the kinds never take each other's work. Only
// seats carrying the rules above; the mesh's own seats' queues are RaiseSeats'.
func SeatTrafficObjects(users []Principal) ([]Stream, []Consumer) {
streams := map[string]Stream{}
consumers := map[string]Consumer{}
add := func(module string, holds []Seat) {
for _, w := range SeatTrafficOf(module, holds, nil, nil).Workers {
seat, byKind := "", false
for _, s := range holds {
if seatStreamName(s.Name) == w.Stream {
seat, byKind = s.Name, s.PulledByKind
}
}
streams[w.Stream] = Stream{
Name: w.Stream,
Subjects: []string{"mesh.seat." + seat + ".accept.>"},
Retention: RetentionWorkQueue,
MaxAge: 7 * 24 * 60 * 60,
Why: "work submitted to the " + seat + " seat; its holders take it, each kind its own, and it queues while nobody does",
}
c := Consumer{
Name: w.Consumer,
Stream: w.Stream,
Filters: []string{w.Filter},
AckWaitSeconds: 60,
// No bound on redelivery: a channel away for a day keeps its work, offered again later and
// later by its holder's runtime (novox/hq ADR 0259; the correctness review of 2026-10-08).
MaxDeliver: 0,
Why: module + " holds " + seat + "; it pulls one ask at a time and acknowledges once it has " +
"recorded it, so a crash redelivers rather than loses",
}
if byKind {
// A build seat's ask is offered a bounded number of times (issue 1907): the holder that takes it past
// mesh-sdk delivery.AskRuns ends it failed saying it is poison, and the bound stops a run that kills
// every node it reaches even when that holder dies too.
c.MaxDeliver = BuildAskMaxDeliver
c.Why = module + " holds " + seat + "; each holder pulls an ask when it has a free slot, and an ask that " +
"no run ends is offered at most " + fmt.Sprint(BuildAskMaxDeliver) + " times, its step failed as poison"
}
consumers[w.Consumer] = c
}
}
for _, p := range users {
switch p.Kind {
case KindModule:
add(p.Module, p.Holds)
case KindNodeTools:
for _, d := range p.Carries {
add(d.Module, d.Holds)
}
}
}
var ss []Stream
for _, s := range streams {
ss = append(ss, s)
}
sort.Slice(ss, func(i, j int) bool { return ss[i].Name < ss[j].Name })
var cs []Consumer
for _, c := range consumers {
cs = append(cs, c)
}
sort.Slice(cs, func(i, j int) bool { return cs[i].Name < cs[j].Name })
return ss, cs
}
// trustedTraffic is why a module's seat traffic is the trusted holder's (novox/hq ADR 0259 §8), or "": it
// says a seat's event to one caller each (a warrant), or holds a kind of a kinded bench that proves its sender.
func trustedTraffic(d Declared) string {
for _, s := range d.Holds {
for _, e := range s.Emits {
if namesVerb(s.ByCaller, e) {
return "it says " + s.Name + "'s " + e + " to one caller each"
}
}
if s.Kinded && namesVerb(s.Capabilities, "verified-sender") {
return "it holds " + s.Name + " of kind " + s.Kind + ", which proves its sender"
}
}
return ""
}
// TrafficQueues is the work queue of every seat naming its caller or its kind that accepts work, held or not
// (novox/hq ADR 0259 §3): what is submitted before a holder is assigned waits for it.
func TrafficQueues(seats []Seat) []Stream {
var out []Stream
seen := map[string]bool{}
for _, s := range seats {
if len(s.Accepts) == 0 || !s.isNewTraffic() || seen[s.Name] {
continue
}
seen[s.Name] = true
out = append(out, Stream{Name: seatStreamName(s.Name), Subjects: []string{"mesh.seat." + s.Name + ".accept.>"},
Retention: RetentionWorkQueue, MaxAge: 7 * 24 * 60 * 60,
Why: "work submitted to the " + s.Name + " seat; its holders take it, each kind its own, and it queues while nobody does"})
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}

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