Compare commits

..
Author SHA1 Message Date
jschoubben 4548b97910 Build modules pin the SDK whose parsers keep a crashed file's and a subtest's panic's cause (issue 1901, review round 1) 2026-10-26 20:18:32 +01:00
jschoubben 358ef45536 Pin mesh-sdk's fix/1901-failure-contract head 81ec7bd7 (issue 1901) 2026-10-26 19:50:45 +01:00
jschoubben 175b2cd791 Build modules report failed tests through mesh-sdk's failure contract and run its conformance kit (issue 1901)
mesh-build-node kept the first fifteen lines of node --test's output, so a passing line of the first file stood for the
failure. Both build modules now parse through the SDK (go test -json, node --test's TAP report written to a file, the
type-check and go vet as console failures), name themselves in the result, and run the SDK's captured cases; the
catalogue check failurekit refuses a node-build-* module with a test step whose tests do not run the kit.
2026-10-26 19:48:39 +01:00
mesh-tools da29f77b10 Merge pull request 'The catalogue's check refuses a compiled program under modules/, and main tracks none (issue 1577)' (#507) from fix/1577-no-committed-binaries into main 2026-10-26 18:13:59 +00:00
mesh-tools 5a4ac49913 Merge pull request 'Milestones are records in the issue tracker, and progress reads them (issue 1781)' (#506) from feat/1781-milestone-records into main 2026-10-26 18:06:48 +00:00
jschoubben 94dab68635 Refuse a compiled program under modules/, and untrack the two main held: build output reaches the catalogue only by a slip nothing caught (issue 1577)
mesh/delivery merged: source.main moved to novox/mesh-catalog@da29f77b1081
mesh/check being carried to 94dab6863523 on da29f77b1081 | from 5a4ac4991395
2026-10-26 18:43:46 +01:00
jschoubben 76e4ec4dc6 mesh-issues and mesh-knowledge: milestones are records of the issue tracker, and progress reads them (ADR 0493 §1 to §4, issue 1781)
mesh/delivery merged: source.main moved to novox/mesh-catalog@5a4ac4991395
mesh/delivery-group group feat/1781-milestone-records merged: every member is merged
mesh/check checking 76e4ec4dc680 on 26908a89506f | novox/mesh-catalog#506 in group feat/1781-milestone-records
mesh-issues keeps each design's milestone in the state milestones (history 5), says milestone-set and
milestone-moved, and serves milestones, milestone-set and milestone-move. An issue's milestone is its own link
of kind milestone, so at most one milestone per issue is checked inside the issue's own write; kind commit
links a fixing commit. The triage links no milestone, pull request or commit. import with milestones=N seeds
a design's table once. mesh-knowledge's progress reads issue-tracker.milestones and its table parse is gone.

Squashed from four commits so the branch history does not carry a compiled bundle that was committed by
mistake (6 MB); the path is now in .gitignore.
2026-10-26 18:42:51 +01:00
mesh-tools 26908a8950 Merge pull request 'knowledge-base.progress: how far a design, milestone, issue or record has come, in one call (issue 1947)' (#504) from feat/1947-progress into main 2026-10-26 16:42:30 +00:00
mesh-tools 27a7757b97 Merge pull request 'The mesh writes the repositories it delivers, and asks no Approve for a merge it cannot carry out (issue 1945)' (#503) from fix/forge-writes-delivered-repos into main 2026-10-26 16:25:07 +00:00
jschoubben 87fe2c45cf Knowledge base: progress counts an issue done only once its fix is delivered, and says every gap (issue 1947, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@26908a89506f
mesh/delivery-group group feat/1947-progress merged: every member is merged
mesh/check pass 87fe2c45cfed on 26908a89506f | carried from 27a7757b970e, hq 3a5c4c7c4d9c | unit 2091 run, 2327 kept, 6 skipped | integration 2112 run…
A resolved issue whose delivery is held or failed is stuck, one merged and not delivered
is moving; a row the tracker did not answer, an answer that does not read and linked
issues cut by the list's limit are said as warnings instead of passing silently.
2026-10-26 17:02:47 +01:00
jschoubben 24b244bc88 mesh-delivery-gate: ask no Approve of a merge the forge account cannot carry out, and say why (issue 1945)
mesh/delivery merged: source.main moved to novox/mesh-catalog@27a7757b970e
mesh/check pass 24b244bc88dc on 27a7757b970e | carried from 6b6d5cba7195 | unit 2046 run, 2327 kept, 6 skipped | integration 2066 run, 2346 kept, 10 s…
car-hunter #2 was approved and its commit status then refused 403. Before the ask the
gate reads merge.writes for each repository it covers; where the merging account cannot
write, nothing is asked, the delivery waits on 'the forge account cannot write <repo>:
gitea_grant_write' and a condition is raised. gitea.repo.writable reads it again and
asks an approved, refused merge of it again.
2026-10-26 16:39:56 +01:00
jschoubben 5baf8ad1d7 Knowledge base: progress is proven over the bus with the module started whole (issue 1947)
The verb asks three seats and the records module; only a run over a real bus shows its
grants and subjects line up with what the manifest invokes.
2026-10-26 16:39:19 +01:00
jschoubben 8da33e43d4 Knowledge base: progress says how far a design, milestone, issue or record has come (issue 1947)
A lead's five-minute report and 'is design 47 nearing its end' were joined by hand from
the design file, the tracker and the deliveries. One verb now works the state out
(ADR 0424 §1), minimal for every five minutes, detailed for the report's tree.
Milestones are read from the design's table until the tracker's milestone records
(issue 1781) are delivered.
2026-10-26 16:37:54 +01:00
jschoubben 52de4c320e gitea: the merging account writes every repository that builds a registered module, read from the indexer's modules (issue 1945)
car-hunter #2 was approved and its commit status refused 403: mesh-tools only read the
repositories of a user. It is now a write collaborator on each repository the indexer's
modules state names (watched over the bus: at the start and on each registration), read
back and said as gitea.repo.writable; the operator's other repositories stay read-only.
gitea_grant_write and gitea_unwritable_repos do and show it by hand; gitea_commit_statuses
answers merge.writes.
2026-10-26 16:36:50 +01:00
jschoubben 5e516317e9 Issue tracker: list rows say when each issue last moved, and list takes linked (issue 1947)
The knowledge base's progress verb reads a milestone's issues in one call and must say
when each last moved, and the issues linked to a record or a design; neither was
readable without one get per issue.
2026-10-26 16:29:56 +01:00
mesh-tools 6b6d5cba71 Merge pull request 'Image builds run their RUN steps on pasta, and every build failure keeps its real error (issue 1703)' (#502) from fix/1703-image-error-shown into main 2026-10-26 14:48:17 +00:00
mesh-tools 8393e29621 Merge pull request 'An approved group merges whole or not at all, and two groups never interleave in one repository (issue 1275)' (#501) from fix/1275-group-merge-whole into main 2026-10-26 14:47:34 +00:00
mesh-tools 61563abf2f Merge pull request 'Every forge account the mesh's parts use reads every repository (the operator, 2026-10-26)' (#500) from fix/fetch-reads-all into main 2026-10-26 14:47:29 +00:00
jschoubben 071c6885d9 The mesh's forge accounts all read every repository, and a build's fetch says the forge's own words
mesh/delivery merged: source.main moved to novox/mesh-catalog@61563abf2f93
mesh/delivery-group group fix/fetch-reads-all merged: every member is merged
mesh/check checking 071c6885d923 on 8393e2962193
mesh-catalog #495 gave read on every repository to the tools' account alone. The build modules
fetch over ssh as nox-builder (the forge-account provider's builders account, ADR 0466 §3), so
ace's and g14's fetch of jschoubben/car-hunter and jschoubben/snake still failed; the machines'
agents (nox-agent, ADR 0410 §1) could not read them either.

- gitea: readall.ts grants every account of one list, meshAccounts in tools-account.ts (mesh-tools,
  nox-builder, nox-agent; a Go test holds the two Go names to internal/forgeaccount's constants),
  on the same start pass, forge events and tools. gitea_unreadable_repos answers per account and
  names the accounts the forge does not hold yet; gitea_grant_read takes accounts as well as
  repositories. An absent account is asked nothing; the start pass runs again ten minutes apart
  until every account was there once.
- mesh-build-go, mesh-build-node, mesh-build-image: pin mesh-sdk ff81bdf8 (fix/fetch-reads-all),
  whose forgekey.Said keeps git's words past the bare "error:" line: the mirror's line said only
  "git -C: error:". Their own git reasons (ls-files, ls-remote, rev-parse, repack) go through it too.
2026-10-26 15:27:38 +01:00
jschoubben 8c5f27627b mesh-build-image: give a RUN step pasta's network, since the default netavark bridge cannot be set up rootless (issue 1703)
mesh/delivery merged: source.main moved to novox/mesh-catalog@6b6d5cba7195
mesh/check checking 8c5f27627b67 on 6b6d5cba7195
Every RUN failed before it started: 'setup network: netavark: nftables error ... Could not process rule'. Reproduced on
shanks with buildah 1.45.2, a clean environment and a private XDG_RUNTIME_DIR; --network pasta builds, and reaches the
network from a RUN. The sysrq integration row now fails if the RUN never started.
2026-10-26 15:23:19 +01:00
jschoubben 82bd6f49ff A half-merged group keeps its repositories until whole or declined, and the operator's word does not ask it again while another pass it would wait for is under way (issue 1275, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@8393e2962193
mesh/check checking 82bd6f49ff9c on 8393e2962193
A held half-merged group did not count as under way, so another group sharing a repository could start; asked
again with delivery_merge_again, each then waited on the other. Passes are now read from every approved,
undeclined Approve not merged whole (approvedMerges leaves out merged and refused members), and
delivery_merge_again is refused while passWaits names a pass, naming it. The forge's mergeable is also false
for a work in progress: said so.
2026-10-26 15:23:01 +01:00
mesh-tools ceb3aac9b3 Merge pull request 'Build modules size their steps from the node's build share once it is given (issue 1806, change A)' (#499) from fix/build-cpu-yields into main 2026-10-26 14:22:50 +00:00
jschoubben 98c66fb1e4 Build modules: a failed command's reason says its error, not its last progress line (issue 1703)
buildah writes STEP lines on stdout and its error on stderr, so the last line of both hid every image build's cause.
mesh-build-image's reason is now stderr's last lines (600 bytes) with the exit status, and the journal gets both tails;
mesh-build-node keeps stderr's end and stdout's start apart; mesh-build-go's cause skips go build's '# package' line.
2026-10-26 15:21:01 +01:00
jschoubben 07ec186031 Merge an approved group only when every member merges now, and let no other merge land between its members (issue 1275)
On 2026-10-26 13:40 group fix/734-lost-ask merged two of its three members: the forge answered mesh-delivery#73's
merge 405 "Please try again later", and in the seconds between, group feat/890-placement merged mesh-delivery#72,
so #73 then conflicted.

- mesh-delivery-gate: before any member is handed, every member is read for its required statuses, its head still
  the approved one, and the forge's own mergeable (in conflict, or still being worked out); the forge's "no" is asked
  again every 5 s for 2 minutes, then the group is held and said urgent with nothing merged.
- mesh-delivery-gate: an approved merge waits while another approved merge's pass is under way into a repository it
  goes into (handed until merged whole, or stopped), whether or not mesh/check gates merges (it waits for a merge,
  never for a check: ADR 0551).
- mesh-delivery-gate: tool delivery_merge_again asks a refused or held merge again on the operator's word.
- gitea: the merge asks again after the forge's 405 "Please try again later" (1 s, 2 s, 4 s), and a pull request
  carries the forge's mergeable, which gitea_commit_statuses answers.
2026-10-26 14:53:22 +01:00
jschoubben 2aecbe8055 Merge main into fix/build-cpu-yields; pin mesh-sdk at its head (ADR 0553, ADR 0554)
mesh/delivery merged: source.main moved to novox/mesh-catalog@ceb3aac9b39a
mesh/delivery-group group fix/build-cpu-yields merged: every member is merged
mesh/check being carried to 2aecbe80553c on ceb3aac9b39a | from 9a8e54d988fe, mesh-host bba9081ca127
2026-10-26 14:47:27 +01:00
jschoubben 3def293d94 Build modules: no thread limit without the composer's share; with it, the share over their own capacity (ADR 0553 round 2)
Change A rebuilds the three build modules, so their runtimes restart once; it
changes nothing else about their builds until change B gives them the share.
2026-10-26 14:41:12 +01:00
mesh-tools 9a8e54d988 Merge pull request 'mesh-build-go says which packages it cannot test here, and mesh-build-node's tests need the Node toolchain (issue 890, ADR 0552)' (#498) from feat/890-placement into main 2026-10-26 13:40:41 +00:00
mesh-tools 2da9329df4 Merge pull request 'pacman: the round and the history tools say in plain words what they answer' (#497) from fix/pacman-findable into main 2026-10-26 13:39:16 +00:00
jschoubben ecbee53cfd Merge main into fix/build-cpu-yields; pin mesh-sdk at its head (ADR 0553) 2026-10-26 14:23:33 +01:00
jschoubben dcdf4238c7 Build modules: no cap of their own; a step's threads are its part of the node's one share (ADR 0553, issue 1806)
Review round 1: a cap per module let three build modules take three shares
of the node. The share is now one node value held by the node-engine's
yielding slice; each step divides it by every build slot of the node, from the
composer's words, or assumes every build seat at its own capacity without them.
2026-10-26 14:22:05 +01:00
jschoubben d3423f54ed mesh-build-go: resolve the go.mod and go.sum the main merge left in conflict; pin mesh-sdk 33395f68 (issue 890)
mesh/delivery merged: source.main moved to novox/mesh-catalog@9a8e54d988fe
mesh/delivery-group group feat/890-placement merged: every member is merged
mesh/check checking d3423f54ed58 on 9a8e54d988fe | novox/mesh-catalog#498 in group feat/890-placement
2026-10-26 14:06:37 +01:00
jschoubben 0e322d087f mesh-build-go: pin mesh-sdk 33395f68, feat/890-placement merged with main (issue 890) 2026-10-26 14:05:38 +01:00
jschoubben 0300a6fd99 pacman: drop the plain-word phrases from replaces (it holds shell commands only, ADR 0245); the descriptions' first lines carry them
mesh/delivery merged: source.main moved to novox/mesh-catalog@2da9329df448
mesh/check being carried to 0300a6fd99b5 on 2da9329df448 | from b1e610b2135a
2026-10-26 14:04:09 +01:00
jschoubben d21b81b00a mesh-build-go: node-build-node also needs bubblewrap and a toolchain installed whole (its mark and lock), as mesh-build-node's integration tests do; pins mesh-sdk 45564e69 (issue 890, review round 1) 2026-10-26 14:02:27 +01:00
mesh-tools b1e610b213 Merge pull request 'Build modules end the steps a previous process left running, and serve build_<lang>_step_end (issue 1890)' (#496) from fix/1890-left-running-steps into main 2026-10-26 12:57:34 +00:00
jschoubben 0134697f97 Build modules: the round ends asks past the step bound; pin the sdk head (issue 1890)
mesh/delivery merged: source.main moved to novox/mesh-catalog@b1e610b2135a
mesh/delivery-group group fix/1890-left-running-steps merged: every member is merged
mesh/check pass 0134697f9739 on b1e610b2135a | carried from a4f5df797b68, mesh-sdk b4cd95e7c4a4 | unit 3132 run, 3022 kept, 7 skipped | integration 31…
2026-10-26 13:52:53 +01:00
jschoubben 21583d14ad Pin mesh-sdk at fix/build-cpu-yields' head (issue 1806) 2026-10-26 13:49:54 +01:00
mesh-tools a4f5df797b Merge pull request 'gitea: the tools' forge account reads every repository (the operator, 2026-10-26)' (#495) from fix/forge-account-reads-all into main 2026-10-26 12:46:37 +00:00
jschoubben ac2c56f2a9 Build modules: steps end by compare-and-set; pin the sdk head with the review's fixes (issue 1890) 2026-10-26 13:44:00 +01:00
jschoubben b4a7bb9dea gitea: say that a repository made private later is granted only at the next start or by hand, and let one unreadable permission refuse its repository, not the whole pass
mesh/delivery merged: source.main moved to novox/mesh-catalog@a4f5df797b68
mesh/check being carried to b4a7bb9dea50 on a4f5df797b68
2026-10-26 13:43:46 +01:00
jschoubben 64b9ded2db Build modules: a per-node processor cap, and each step's threads within it (issue 1806)
The operator's desktop slows while it builds (shanks at 92%, the Go build
runtime at 1650% of 24 processors). The build modules declare cpu-cap
(default shanks=50,g14=50,ace=75), which the composer turns into the runtime's
quota at idle priority; each step's go, node and buildah commands get
GOMAXPROCS and UV_THREADPOOL_SIZE for their part of it, and the status tools
show the limits and what the kernel holds the runtime to.
2026-10-26 13:41:05 +01:00
jschoubben 67d6a5a7c1 mesh-build-go: report the needs a package's check-needs marker names, test it only where the node has them, decline an ask needing what the node lacks (issue 890)
mesh-build-node's integration tests read the Node toolchain installed only where the node holds node-build-node, and
skipped on ace: its package now declares node-build-node beside its tests; the integration digest names it, a node
lacking it leaves the package unmet for the checker to place, and declines a placed ask. Re-pins mesh-sdk feat/890-placement.
2026-10-26 13:37:52 +01:00
jschoubben 5fe5f26e7e Build modules: a restart ends the steps it left running, a step shows what it is, build_*_step_end ends one (issue 1890) 2026-10-26 13:27:46 +01:00
jschoubben b3221b6ab8 pacman: a plain-words search finds pacman_round and pacman_upgrades first (answer on the first line, phrases they replace) 2026-10-26 13:27:09 +01:00
jschoubben 0e7e220d49 gitea: give the tools' account read on every repository, at the start and on the forge's events, so a user's private repository no longer answers its tools 404 2026-10-26 13:22:08 +01:00
mesh-tools 879600202a Merge pull request 'Vault: adopt the hashes of bus accounts it holds no value for, handed sealed by the bus node (issue 1945, ADR 0550 clause 4)' (#494) from feat/1945-user-list-switch into main 2026-10-26 12:21:47 +00:00
mesh-tools 565d0a848a Merge pull request 'Gate: merge turns, a merge waits for a running check of its repository, merges on a carried verdict with no second ask (ADR 0548, issue 568)' (#493) from feat/548-one-check into main 2026-10-26 12:01:28 +00:00
mesh-tools 71e094b2de Merge pull request 'mesh-build-go: count skipped tests per package, so the checker keeps no pass that skipped (issue 890)' (#492) from fix/checker-skips-not-kept into main 2026-10-26 12:01:13 +00:00
mesh-tools f96704fa0f Merge pull request 'mesh-build-node tests: never install a toolchain from the registry in the integration level; skip as NOT RUN when none is installed (issue 890)' (#491) from fix/build-node-tests-offline into main 2026-10-26 11:34:17 +00:00
mesh-tools 86fdf74a0a Merge pull request 'Desk channel: keep what was shown in memory while the store does not answer, so a clearing replaces the urgent notification (issue 384)' (#490) from fix/desk-clear-lost into main 2026-10-26 11:33:47 +00:00
mesh-tools 843281c0e5 Merge pull request 'Knowledge base: withdraw a clause's 'changed by' mark once no accepted record changes it (issue 466)' (#489) from fix/kb-stale-marks into main 2026-10-26 11:33:34 +00:00
jschoubben e9279ce3c2 gate: find an approved merge by its ask, not by which member's run was seen first, so a group's turns are written before its first member is handed at every look (ADR 0548 §3; issue 568, review round 1)
mesh/delivery merged: source.main moved to novox/mesh-catalog@565d0a848a85
mesh/delivery-group group feat/548-one-check merged: every member is merged
mesh/check checking e9279ce3c209 on 565d0a848a85 | novox/mesh-catalog#493 in group feat/548-one-check
2026-10-26 12:32:46 +01:00
jschoubben c9414b1c98 Vault: pin mesh-sdk at the switch group's head c2a410bb, one sdk for the adoption and the switch (issue 1945)
mesh/delivery merged: source.main moved to novox/mesh-catalog@879600202a09
mesh/delivery-group group feat/1945-user-list-switch merged: every member is merged
mesh/check checking c9414b1c9856 on 879600202a09 | novox/mesh-catalog#494 in group feat/1945-user-list-switch
2026-10-26 12:31:56 +01:00
jschoubben ea53b2f920 mesh-build-go: each package counts its skipped tests (issue 890)
mesh/delivery merged: source.main moved to novox/mesh-catalog@71e094b2de59
mesh/delivery-group group fix/checker-skips-not-kept merged: every member is merged
mesh/check failed: ace: the check of novox/mesh-catalog#492 refused: the installed go version could not be read: `go version` with GOTOOLCHAIN=local f…
2026-10-26 12:29:19 +01:00
jschoubben 99cc073e2a gate: pin mesh-sdk at the group's head 9c61918ac36f, where a turn never holds the check of heads waiting in it (ADR 0548 §3; issue 568, review round 1) 2026-10-26 12:15:20 +01:00
jschoubben b94150fcdb gitea: the merge tool reads a carried verdict's line as judged on the new trunk, so it merges a carried verdict (ADR 0548 §8; issue 568, review round 1) 2026-10-26 12:15:20 +01:00
jschoubben a01715ddf5 Skip the Node build module's toolchain tests by name where no Node toolchain is installed, instead of installing one from the registry in a level that has no network; merge three tests that share one fixture
mesh/delivery merged: source.main moved to novox/mesh-catalog@f96704fa0ff4
mesh/check checking a01715ddf508 on 565d0a848a85
On ace, which holds no Node build module, five integration tests each spent 70 s in npm's network retries and failed every mesh-catalog check run there (five verdicts since 00:07). The tests now copy the node's installed toolchain or are skipped as NOT RUN; the registry is never reached. Three tests sharing the fixture are one, so the toolchain is copied three times instead of five (10 integration tests in the module, now 8).
2026-10-26 12:05:09 +01:00
jschoubben e2136ec0c9 The vault takes a handing whole once, refusals and all, and names the refused users in its last adoption (review of the adoption, ADR 0547 clause 9) 2026-10-26 12:03:14 +01:00
jschoubben a30c168512 desk-channel: take the work when the store does not answer, and keep what it holds at the next work and the minute's standing
mesh/delivery merged: source.main moved to novox/mesh-catalog@86fdf74a0a83
mesh/check checking a30c16851246 on 565d0a848a85
Review round 1: offering the work again let a redelivered raise follow its own clearing and
replace it, and a redelivery is bounded where an outage is not. The work is now taken; what
was shown stays in memory, recalled first, and is kept at the start of the next work and at
the minute's standing, stopping at the first write the store does not answer. A desk that
restarts before then loses where those are shown, and its log said how many it held.
2026-10-26 11:46:21 +01:00
jschoubben f885250913 desk-channel: keep where a message is shown when the store does not answer, so its clearing still replaces it
On 2026-10-26 09:18 the bus reloading its accounts made the desk's state store answer
'no responders' just as the urgent 'A new build of mesh-controller cannot be delivered'
was shown. The desk logged that it could not keep the notification ids and acked the work;
the router's clearing at 09:34 then found no record, was shown as a new notification, and
the urgent one stayed on the operator's screen for hours.

Now what was shown and not kept is held in memory, recalled before the store by edits,
reminders, clicks and a notifier restart, kept at the next piece of work, and its work is
offered again until the store keeps it, without being shown twice. An edit of a message
nothing records is said in the journal.
2026-10-26 11:46:09 +01:00
jschoubben 7627503d9d gate: pin mesh-sdk at the group's head f5c233a9288c, which holds the merge turns and the carried verdict (ADR 0548 §10; issue 568) 2026-10-26 11:45:30 +01:00
jschoubben 351c7c4bf4 gate: deliveries and show say an approved merge waits for the check of the named delivery on the named repository, and the turns bucket is read by the checker alone, written by the gate alone (ADR 0548 §3, §9; issue 568 G6) 2026-10-26 11:44:29 +01:00
jschoubben 55a26b22cf gate: delivery_turns reads every merge turn with the check it waits for, and delivery_hand_now hands a held merge at once on the operator's word (ADR 0548 §4; issue 568 G5) 2026-10-26 11:43:40 +01:00
jschoubben 100e74f283 gate: an approved merge whose trunk moved waits for its carried verdict and merges on it with no second ask, never on the old one (ADR 0548 §8; issue 568 G4) 2026-10-26 11:43:18 +01:00
jschoubben c8e1e2b2fa gate: a merge held past 30 minutes by another delivery's check is a stalled line naming that check and since when, said as delivery.<id>.stalled (ADR 0548 §4; issue 568 G3) 2026-10-26 11:41:42 +01:00
jschoubben d0d00f922a gate: an approved merge writes its turn on every repository it goes into before it is handed, kept for a group until its last member merged, read by the checker (ADR 0548 §3; issue 568 G2) 2026-10-26 11:41:26 +01:00
jschoubben c2bd7760b2 gate: hand no approved merge into a repository a running check of other heads judges, and hand it on that check's end (ADR 0548 §2; issue 568 G1) 2026-10-26 11:40:35 +01:00
jschoubben f0fd53281b The vault says once why a look of the bus module's have adopted nothing, and again when it changes (ADR 0547 clause 9) 2026-10-26 11:37:14 +01:00
jschoubben fb039250f6 The vault adopts the hashes of the bus accounts it holds no value for from the bus node's handing (ADR 0547 clause 9, issue 1945)
take-over waits on a hash for every user of the loaded list, and the vault holds no value for the controller's own
account, the node accounts and the enrolled devices. The bus node's engine now hands every loaded user's hash in the
bus module's have, sealed to the vault's key (mesh-host and mesh-sdk on the same branch). The vault answers its key in
node-key, reads the haves (manifest reads node-engine.have, granted from the manifest, no controller change), watches
the bus module's have, opens the handing with its own key, and writes each user it holds no value for into
bus-accounts marked adopted, with the list and node it came from, saying account-hashed. It never touches a user whose
covered account it holds or whose key is its own hash, writes an adopted key again only for another hash, and refuses
a handing from any node the assignments do not name as holding the bus module.

Tools: account_adopt adopts now from the have as the watch does; secret_own, unnarrowed by module, lists the adopted
accounts and the last adoption.
2026-10-26 11:36:33 +01:00
jschoubben 447e6a13cd Knowledge base: withdraw a clause's changed mark when its changing record's accepted links drop it, on every judgement and by a tool (stale marks)
mesh/delivery merged: source.main moved to novox/mesh-catalog@843281c0e5f0
mesh/check checking 447e6a13cd68 on 565d0a848a85
2026-10-26 11:22:12 +01:00
mesh-tools c51a46b802 Merge pull request 'Build modules: run every package's tests of a level, with a fixture of two failing packages (issue 1921)' (#488) from fix/1921-every-failure-r into main 2026-10-26 10:18:08 +00:00
jschoubben 15a5fe09b6 mesh-build-go, mesh-build-node: run every package of a check's level after a failure (issue 1921)
mesh/delivery merged: source.main moved to novox/mesh-catalog@c51a46b802d3
mesh/delivery-group group fix/1921-every-failure-r merged: every member is merged
mesh/check checking 15a5fe09b62e on c51a46b802d3 | novox/mesh-catalog#488 in group fix/1921-every-failure-r
Pins mesh-sdk fix/1921-every-failure-r, whose check ask no longer stops a level at its first failing test step. A captured fixture with a failing test in each of two packages proves the build module's test step names both.
2026-10-26 10:58:07 +01:00
mesh-tools 3ded9e7800 Merge pull request 'Vault: name a hash record's secret in a field called of, so the runtime keeps it (issue 1945)' (#487) from fix/1945-vault-hash-field into main 2026-10-26 09:00:51 +00:00
jschoubben e7aaa8420e Vault: name a hash record's secret in a field called of, so the runtime accepts it
mesh/delivery merged: source.main moved to novox/mesh-catalog@3ded9e780043
mesh/check pass e7aaa8420eac on 3ded9e780043 | unit 2018 run, 2325 kept, 5 skipped | integration 2040 run, 2344 kept, 9 skipped | over bound: unit, in…
The runtime refuses a state field whose name ends in secret, so no bus-account hash could be written on novox. The test state now applies the same check.

Issue: 1945
2026-10-26 09:25:35 +01:00
mesh-tools 22381d7522 Merge pull request 'Vault: read own secrets' generations, and hash each bus account it is handed for the grants part (issue 1945, ADR 0547 clause 9)' (#486) from fix/1945-vault-own-read into main 2026-10-26 07:48:03 +00:00
jschoubben 1c55e5d44d Vault: take and hash a process's own bus account (broker-<process id>) as a covered account, so take-over is not held by every process user
mesh/delivery merged: source.main moved to novox/mesh-catalog@22381d75224f
mesh/check failed: integration: meshbuildnode/cmd/mesh-build-node.TestAFixtureBundleBuiltTwiceHasOneDigest: no toolchain of this lock is installed und…
The controller hands each process account of a core or moved module with origin issued (ADR 0436 §2); the vault took
only broker and provisioner-broker, refused these and never hashed them. Owed hashes are now said once per pass, and
account_hash says who asked when it refuses too (review round 1).

Issue: 1945
2026-10-26 02:35:57 +01:00
jschoubben 32b912cfe5 Vault: hash each bus account it is handed into bus-accounts.<user>, once per generation, so the grants part can compose the user list without the controller
The controller still mints each covered account and hands it to the vault (ADR 0374); ADR 0547 clause 9 has the vault
write the bcrypt hash of what it holds into its own state, readable by the grants part, and say account-hashed. It
hashes on keep, at start every held account with no key of its generation, and again by hand (account_hash); a refused
write is owed and written at the next pass. secret_own says per bus account whether this generation's hash is written.

Issue: 1945
2026-10-26 02:28:28 +01:00
jschoubben e572937d97 Vault: secret_own reads the module-own secrets it keeps, as fingerprints, so a generation can be told without the value
mesh/delivery closed: a newer head of the same pull request
mesh/check pass e572937d97fa on 567736f7795a | unit 2143 run, 2198 kept, 5 skipped | integration 2165 run, 2217 kept, 9 skipped | over bound: unit, in…
Issue: 1945
2026-10-26 02:09:03 +01:00
mesh-tools 567736f779 Merge pull request 'Build modules' step and toolchain lists read only the newest keys they answer, in parallel (issue 1827)' (#485) from fix/1827-bounded-reads into main 2026-10-25 23:56:12 +00:00
jschoubben e901769cb2 Read the newest steps first: sort the node's keys by id (start order) before the cut, and say older steps were not read
mesh/delivery merged: source.main moved to novox/mesh-catalog@567736f7795a
mesh/check failed: integration: meshbuildnode/cmd/mesh-build-node.TestAFixtureBundleBuiltTwiceHasOneDigest: no toolchain of this lock is installed und…
The review found a cut read took an arbitrary 1000 keys, so the newest steps could be missing. A step id is
base-36 milliseconds plus random digits, so its string order is its start order; the newest max(limit*4, 100)
keys are read now, 1000 at most. The toolchains read logs when it drops keys past its cap.
2026-10-26 00:43:53 +01:00
jschoubben 8f9c78bcba Read only the node's own steps, in parallel and bounded, so build_*_steps answers within the call's 30 s (issue 1827)
On a steps state of 1164 keys / 20 MB (mesh-build-node: 10034 keys) the read tools listed every key and
then read each value one round trip at a time, filtering by node only after the Get, so they timed out
at 30 s. Now the keys are filtered by the node's prefix before any Get, the values are read 16 at a
time, at most 1000 a call (the answer says it was cut), and one log line gives rows read and time.
build_*_toolchains reads its values the same parallel way. The kind: a read tool that reads a whole
state key by key; every read tool of the three modules is covered.
2026-10-26 00:39:33 +01:00
mesh-tools 82e7e3e326 Merge pull request 'mesh-provisioner-client: a module that leaves or is placed again asks the vault afresh (issue 1945)' (#484) from fix/1945-held-secret-on-leave into main 2026-10-25 23:21:44 +00:00
jschoubben 363a3d3f7e Provisioner client: drop a module's held vault secrets when it leaves or is placed again, so it asks afresh (issue 1945)
mesh/delivery merged: source.main moved to novox/mesh-catalog@82e7e3e32675
mesh/check pass 363a3d3f7e8f on 82e7e3e32675 | unit 2015 run, 2322 kept, 5 skipped | integration 2037 run, 2341 kept, 9 skipped | over bound: unit, in…
A re-assigned module was filled with the generation it held before the vault minted a new one; the bus refused the account.
2026-10-26 00:03:43 +01:00
jschoubben 609013a263 Merge pull request 'Gate: a merge learnt seconds late moves the trunk; and a setting check-gates-merge so the merge check can stop gating (operator 2026-10-25 22:56)' (#483) from fix/merge-without-check-gate into main
Reviewed-on: #483
2026-10-25 22:18:41 +00:00
jschoubben 32ad59fc64 mesh-delivery-gate and gitea: a setting check-gates-merge; false asks a reviewed pull request's Approve and merges it without waiting for mesh/check
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/check pass 32ad59fc6402 on 609013a2638f | unit 2010 run, 2322 kept, 5 skipped | integration 2032 run, 2341 kept, 9 skipped | over bound: unit, in…
The operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses us too much time". Tonight each
merge ask waited 3-7 minutes for mesh/check, and every merge into main refused the next approved merge for a check judged
on the trunk before (mesh-delivery#52). Both settings default to true, so nothing changes until the operator sets them.
False: the gate makes a reviewed pull request ready and asks its Approve with no verdict, a trunk's move puts nothing out
of date, a verdict is kept and shown, and a group still merges in one pass; the merge tool merges a head the Approve
names whatever its mesh/check says. delivery_status and show say the mode.
2026-10-25 23:07:50 +01:00
jschoubben 93f655dac9 mesh-delivery-gate: a merge learnt from the open list seconds after it landed moves its trunk and source.main; delivery_status shows source.main
mesh-delivery#51 merged 21:43:11 and the gate read the open list at 21:43:14, before the forge's merged event: taken as
learnt late, it moved no trunk and its group's finish left it out of source.main, so the checker kept judging every
mesh-delivery head on 73be3df. A late merge within replayLate of its landing is now taken as heard as it happened, and
one a gate before this left is written at the catch-up of the start (never backwards).
2026-10-25 23:01:37 +01:00
mesh-tools 573a59e3cc Merge pull request 'mesh-provisioner-client: retry a refused write on the node-engine's wake and ask the vault again at once (issue 924)' (#482) from fix/provisioner-retries-on-declaration into main 2026-10-25 21:08:48 +00:00
jschoubben d0566f9ade Fill a refused file again once the node-engine's declaration may let it through (ace, 2026-10-25 21:17)
mesh/delivery merged: source.main moved to novox/mesh-catalog@573a59e3cc07
mesh/delivery-group group fix/provisioner-retries-on-declaration merged: every member is merged
mesh/check pass d0566f9ade9a on 573a59e3cc07 | unit 2301 run, 3410 kept, 10 skipped | integration 2326 run, 3431 kept, 13 skipped | over bound: unit, …
The node-engine refused mesh-build-go's files as not named as filled 32 s before
its want arrived, and nothing filled them after the apply that named them: the
runtime crash-looped on its missing broker credential. The client now fills
every module again when the node-engine touches its write socket after such an
apply (mesh-host fix/provisioner-retries-on-declaration), says each retry and
each refusal it waits on, and asks the vault again at once for a refused secret
when that wake or the module's own assignment may have ended the refusal.
2026-10-25 21:42:34 +01:00
jschoubben 9d9dfda19b Merge pull request 'Gate: hand every member of an approved group in pin order, settle pull requests closed on the forge, and say a stopped pass as a condition (issue 1275)' (#480) from fix/1275-group-hands-every-member into main
Reviewed-on: #480
2026-10-25 19:28:33 +00:00
jschoubben cdbe2945cf Merge pull request 'Build modules' steps live two days and their pause has a state of its own, so the steps bucket no longer fills (2026-10-25)' (#481) from fix/steps-bucket-ages into main
Reviewed-on: #481
2026-10-25 18:06:39 +00:00
jschoubben 4e82a824be Announce the pauses state in the three build modules, and pin mesh-sdk 518dae5 (an unreadable pause pulls as not paused)
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/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/check pass 4e82a824be7a on cdbe2945cf6e | unit 1994 run, 2322 kept, 5 skipped | integration 2016 run, 2341 kept, 9 skipped | over bound: unit, in…
Without it in Kept, stdio.KeptState refuses the state before the bus (mesh-sdk go/keeps.go
refusedUnannounced), so the pause could be neither read nor written.
2026-10-25 18:57:08 +01:00
jschoubben 78d26867d2 Give the build modules' steps state an age of two days, and keep a node's pause in a pauses state of its own
mesh-build-go's steps bucket filled to the bus's 64 MiB cap (27,370 keys, none ever removed) and
refused the end of a passed check, so the check read failed; mesh-build-node's (17.8 MB) and
mesh-build-image's grow the same way. A step's key is per step: its reader reads its end within
the step's bound, and a check's verdict is kept by the checker, so two days is ample. The node's
pause key lived in the same bucket and would have ended by itself; it moves to a pauses state
without an age (mesh-sdk fix/steps-bucket-ages, Puller.PausePut). No node is paused today, so
nothing is carried over.
2026-10-25 18:53:04 +01:00
jschoubben 0c216d99d0 Gate takes a merge learnt after the fact as past: no trunk move, the forge's time, never source.main (issue 1275, review round 2)
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/check pass 0c216d99d0c0 on 9d9dfda19b7e | unit 2181 run, 2147 kept, 5 skipped | integration 2203 run, 2166 kept, 9 skipped | over bound: unit, in…
A pull request found merged only by its absence from the open list went through PullMerged, which moved every
other open delivery of its repository back to an old trunk (ready ones to proposed, composed checks out of
date), dated the merge now, and so let a later catch-up write the old commit into source.main. pullMerged now
takes such a merge as late: its own delivery is merged and its group closed, no other trunk moves, its time is
the forge's or none, and LearntLate keeps it out of source.main in finish, settleForgeMerges and the catch-up.

The gitea tool gitea_commit_statuses now answers the pull request's merged_at, and the gate reads a gone pull
request by its number alone, so a deleted branch does not fail the read.
2026-10-25 18:37:59 +01:00
jschoubben 145c54d19c Gate settles pull requests closed with no event, looks again at a line's bound, and keeps a waiting head's condition over a restart (issue 1275, review round 1)
The conditions the gate now says would otherwise be wrong three ways:

- A pull request closed or merged on the forge with no event heard stayed ready here for days (de-spiegel #4,
  closed 2026-10-17) and would raise a condition. After a complete read of the open list (shorter than the
  forge tool's 1000), each delivery not final whose pull request is missing is read from the forge and taken as
  its close or merge; a merge learnt so does not move source.main, as it is undated and could move it back.
- A line that comes due on the clock (a state's bound, the author's bound, an answered refusal, an
  out-of-date refusal turning urgent after 10 minutes) waited for an unrelated event. The gate now asks a look
  at the earliest such time.
- After a restart the heads waiting on the forge start their bound again, so their conditions were cleared and
  raised again later. A condition is cleared only once the open list was read since start, and not while the
  forge still shows its head open and waiting.

Also: an empty path list ranks with a truncated one in the merge order, and a seat that keeps refusing
conditions is retried on the clock 10 looks in a row, then at events only, said once in the log.
2026-10-25 18:32:19 +01:00
jschoubben 67092161bd Gate hands every member of an approved group, in pin order, and says a stopped pass (issue 1275)
Met again 2026-10-25: group fix/puller-wakes-on-capacity was merged half. Three causes in the gate:

- The next member's turn compared the group's state with "delivering", a word GroupState no longer says
  since release 6 renamed it "merging" (ready.go groupTurnToMerge). Once the first member's merge was heard
  from the forge the group read merging, and the second member waited for ever. Group states are now named
  constants, the turn goes on in ready and merging (groupMayMerge), and a test holds every word to it.
- Members merged in id order, so mesh-catalog went before the mesh-sdk commit it pins. membersOf now orders
  by mergeRank: mesh-sdk first, then members that change no go.mod or go.sum, then those that do.
- No condition was raised: the controller's self-check that read `stalled` went with release 6
  (mesh-controller dc456d56). The gate now says its stalled lines as conditions itself on the
  operator-channel seat (ADR 0437 §9) after every look, clears those listed no more, and keeps which it
  raised in its new bucket conditions. A held group's line says "half merged" when a member merged.

A pass held for a required status no longer retries on a timer: the status turning success is the event
that asks the merge again.
2026-10-25 18:20:58 +01:00
mesh-tools 2df0503034 Merge pull request 'Let the build modules follow their capacity on the bus, so a node raised from capacity 0 starts pulling (issue 1887)' (#479) from fix/puller-wakes-on-capacity into main 2026-10-25 17:02:40 +00:00
jschoubben 23c827e791 Take the mesh-sdk head that wakes a puller on its capacity, within the test budget, in the three build modules (issue 1887)
mesh/delivery merged: source.main moved to novox/mesh-catalog@2df0503034dc
mesh/delivery-group group fix/puller-wakes-on-capacity merged: every member is merged
mesh/check pass 23c827e7910c on a85d90374195 | judged by the candidate's mesh-build-go at eab02c80fe86
2026-10-25 17:44:50 +01:00
jschoubben 3138570c6b Let the build modules follow their capacity on the bus, so a node raised from capacity 0 starts pulling (issue 1887)
g14's Go builder sat idle at capacity 2 because nothing woke its parked
puller. mesh-build-go, mesh-build-node and mesh-build-image now pin mesh-sdk
b9911dc1, whose puller watches the config value capacity as mesh-config
resolved it and wakes on each change, and hand it that watch.
2026-10-25 17:28:00 +01:00
jschoubben a85d903741 Merge pull request 'nextcloud: the module makes its own mesh-admin account right on a 401 and backs off on a refusal, so its shares poll stops being refused every minute (issue 1253)' (#478) from fix/nextcloud-admin-401 into main
Reviewed-on: #478
2026-10-25 16:01:37 +00:00
jschoubben 1cc54959af nextcloud: repair the account only on a 401, never reset the throttle (issue 1253, review 2)
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/check pass 1cc54959afcf on a85d90374195 | unit 1994 run, 2322 kept, 5 skipped | integration 2016 run, 2341 kept, 9 skipped | over bound: unit, in…
Resetting Nextcloud's brute-force record for the docker gateway cleared it for
every client behind that address: someone guessing passwords could drive the
throttle to 429 and have the module's poll wipe it. A throttle is no evidence
the password is wrong, so a 429 is now backed off only, and the back-off
outlasts the throttle. The account's existence is read from user:info's exit
status, since user:list pages.
2026-10-25 16:57:46 +01:00
jschoubben 962bc359a7 nextcloud: repair only the module's own account, read its existence from the user list, no admin group (issue 1253, review 1)
occ's user:info says 'user not found' on stdout with an empty stderr, so the
error read as 'occ produced no output' and mesh-admin was never made on novox:
occ now says stderr and stdout both, and existence is read from user:list.
The repair could reset whatever account the user setting names, the
operator's included, and the 'admin' fallback is a hand-made install's human
administrator: only mesh-admin is repaired now, and it is the default. The
shares poll needs no admin rights, so the account gets no group. After a
repair the throttle of the caller's address is reset, and a failing repair
reaches the back-off's one loud line with occ's message.
2026-10-25 16:54:51 +01:00
jschoubben 67b13c42d3 nextcloud: repair the account on a throttle too, since a throttle follows refusals (issue 1253)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 67b13c42d32a on f27f4c8cd58c
Right after the delivery the first answer may be Nextcloud's 429, not a 401;
without a repair then the account would never be made and the back-off would
wait out a throttle that cannot end.
2026-10-25 16:40:54 +01:00
jschoubben 4021724ca4 nextcloud: make the mesh's own account match its secret when Nextcloud refuses it, and back off a refusal that stays (issue 1253)
The image creates NEXTCLOUD_ADMIN_USER only on a fresh install. novox's
instance was installed before the mesh adopted it, so mesh-admin was never
created there, and the shares poll asked Nextcloud every minute with a login
that cannot pass: 401, then Nextcloud's brute-force throttle (429), which
also held the backup before a delivery.

On a refused login the client now adds the account through occ (admin
group), or sets its password to the module's own secret, passing it by
OC_PASS so it is on no argv, and asks once more. A refusal that stays is
said once and the poll backs off from 5 minutes to an hour instead of nine
lines of XML every minute. An empty setting no longer becomes an empty name.
2026-10-25 16:40:01 +01:00
jschoubben f27f4c8cd5 Merge pull request 'mesh-build-go: a package that fails without an assertion carries its real cause, never "PASS" (issue 1901)' (#477) from fix/1901-package-cause into main
Reviewed-on: #477
2026-10-25 15:07:40 +00:00
jschoubben 155124b59a mesh-build-go: a cause is the runtime's line at column 0, and only go's exact summary lines are dropped (issue 1901, review round 1)
mesh/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/delivery-group group fix/1901-package-cause held: a member is held: novox/mesh-catalog@155124b59af4 (merged without a passing check: only a person decides…
mesh/check pass 155124b59af4 on f27f4c8cd58c | unit 2015 run, 2883 kept, 5 skipped | integration 2037 run, 2978 kept, 9 skipped | over bound: unit, in…
An indented log line of another running test that held 'panic:' won over the
real timeout panic, since the lines were trimmed before matching. Go prints
the runtime's lines at column 0; a test's log is indented.
2026-10-25 15:58:07 +01:00
jschoubben 6ec6fdaad2 mesh-build-go: a race the race detector reports is a failure's cause, in a test or after PASS (issue 1901)
mesh-controller's try was worded 'PASS' again because the race detector
reported after the tests passed and the program exited 66. The cause is now
the race: its access, the frame that made it and where its goroutine was
created, instead of PASS or testing's generic 'race detected during execution
of test'. Tests from captured go test -race -json (1901/AC4).
2026-10-25 15:52:20 +01:00
jschoubben 663f3de975 mesh-build-go: a package that fails without an assertion carries its real cause, never "PASS" (issue 1901)
A try of mesh-controller kept 'PASS' as its only failure: the package's test
program stopped after printing PASS and the package-level failure took its
first line. Now a failure that is not one test's assertion names the tests that
were running and carries the panic line (a timeout's with its running tests),
a signal or fatal error line, a build's first error, or words saying the
program exited during which tests or after they passed; go test's summary lines
are never kept. Tests from captured go test -json of eight real failures
(1901/AC4).
2026-10-25 15:38:28 +01:00
jschoubben 68f7431d98 Merge pull request 'The provisioner client fills every module again when the socket returns and when its node's own composition moves (issue 1944)' (#476) from fix/r6-node-composition into main
Reviewed-on: #476
2026-10-25 00:18:25 +00:00
jschoubben 53749833bf Fill every module again when the node-engine's socket returns and when the node's own composition moves, so a file an apply left empty is filled (issue 1945)
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/delivery held for a person: merged without a passing check: only a person decides what follows
mesh/check checking 53749833bfbd on 2167b95a3699 | novox/mesh-catalog#476 in group fix/r6-node-composition
2026-10-25 02:02:02 +02:00
mesh-tools 2167b95a36 Merge pull request 'R6: the gate keeps only the merge's states; walk, plan and step code and their verbs go (issue 1946)' (#475) from feat/r6 into main 2026-10-24 23:00:25 +00:00
jschoubben d09cbf1523 Ignore the binaries a local go build leaves in three module roots (issue 1943)
mesh/delivery-group group feat/r6 merged: every member is merged
mesh/delivery delivering: 0 machine step(s) passed
mesh/check pass d09cbf152317 on 6fb0ceeef817 | unit 4378 run, 4719 kept, 15 skipped | integration 5060 run, 4917 kept, 23 skipped | over bound: unit, …
2026-10-25 00:02:45 +02:00
jschoubben ac6712cbb5 Pin mesh-sdk to feat/r6 748c564, which carries main's composer (issue 1943) 2026-10-25 00:02:45 +02:00
jschoubben 5b32342ac2 Merge main into feat/r6: keep R6's gate, which already carries #474's catch-up at start, and drop the seat and walk adoption R6 removed
The reads-outside markers, mesh-build-go's compose job and gitea's forge-account fix come in from main.
2026-10-24 23:03:42 +02:00
jschoubben b67b5af5e6 Pin the gate to mesh-sdk feat/r6 at d7a9b968 (issue 1946) 2026-10-24 21:18:26 +02:00
jschoubben d700ceca20 Pin the gate to mesh-sdk feat/r6 at 7f9cc0e5 (issue 1946) 2026-10-24 20:43:41 +02:00
jschoubben 3a1017f559 Pin the gate to mesh-sdk feat/r6 at e45bdc08 (issue 1946) 2026-10-24 20:22:07 +02:00
jschoubben a17d0c8e93 The messenger holds and tells what the delivery view says is in motion, not the controller's plans (issue 1946) 2026-10-24 18:25:38 +02:00
jschoubben 9fa6710296 Pin the gate to mesh-sdk feat/r6 at a523a8cb (issue 1946) 2026-10-24 18:18:06 +02:00
jschoubben 05f849979c Match a fix to a delivered revision by what the gate says of its merge; keep #474's source catch-up at start (issues 1946, 1726) 2026-10-24 18:13:40 +02:00
jschoubben 4dee77df23 Pin the gate to the head of mesh-sdk feat/r6 (issue 1946) 2026-10-24 17:54:30 +02:00
jschoubben 8a79877a15 A newer delivered revision covers the older merges of its repository, as the forge says ancestry (issue 1946) 2026-10-24 17:03:20 +02:00
jschoubben 2139e83a8a Remove a binary built by mistake 2026-10-24 16:58:33 +02:00
jschoubben e7ca8f7af1 The backup module says on the bus when a backup asked for now has ended, so no one polls backed-up (issue 1944, ADR 0539 §16) 2026-10-24 16:58:19 +02:00
jschoubben eb79996336 The tracker derives a fix's delivered from the delivery view's event, not the gate's gone states (issue 1946) 2026-10-24 16:50:50 +02:00
jschoubben 1a271a1fb1 The group's integration test hears the forge's merges once both members are handed (issue 1946, R6-C3) 2026-10-24 16:43:48 +02:00
jschoubben 30d04f6bef Pin the gate to mesh-sdk's release 6 verb tables and prove it holds the seat as they have it (issue 1946, R6-C3)
The integration tests hear the forge's own word that a pull request merged, as the real flow does, and fail on any call of the controller.
2026-10-24 16:35:15 +02:00
jschoubben 5ff337e0dc Gate keeps the merge's six states and asks the controller nothing (issue 1946, R6-C1, C2, C4)
Release 6 deletes the plans: the walk half of the holder, the controller port, the carry-over of the former module and the verbs times, what-if, release, stop and retire-history go; the table holds proposed, ready, rejected, merged, held and closed; the delivery's status and view name the source revision the merge moved.
2026-10-24 16:26:36 +02:00
271 changed files with 15905 additions and 11590 deletions
+10
View File
@@ -14,6 +14,7 @@ modules/claude-code/claude-code
modules/mesh-watcher/mesh-watcher
modules/dnsmasq/dnsmasq
modules/mesh-issues/cmd/mesh-issues/mesh-issues
modules/mesh-issues/mesh-issues
modules/mounts/cmd/mounts/mounts
modules/mesh-knowledge/cmd/mesh-knowledge/mesh-knowledge
modules/mesh-knowledge/mesh-knowledge
@@ -29,3 +30,12 @@ modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
modules/mesh-config/mesh-config
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
*.test
# binaries a local go build leaves in a module root
/checks/paths/paths
/modules/mesh-build-go/mesh-build-go
/modules/mesh-delivery-gate/mesh-delivery-gate
# Built by their modules' build step (issue 1577), never committed.
modules/forticlient/cmd/forticlient-tools/forticlient-tools
modules/networkmanager/uplink-tools
+115
View File
@@ -0,0 +1,115 @@
// Package failurekit refuses a build module whose tests do not run mesh-sdk's conformance kit (issue 1901, its thorough
// fix): a module that claims a node-build-* seat and serves a test step must hold a test that calls conformance.Run, so
// its parse of the test runner's output is held to the failure contract (a failing test named with its cause, never a
// passing line in its place). A build module with no test step (mesh-build-image builds images and runs no tests) is
// exempt, and said so.
package failurekit
import (
"encoding/json"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
// Judged is what the check says of one build module: Refused says why it fails the check, "" when it passes; Exempt
// says why the kit does not apply.
type Judged struct {
Module, Refused, Exempt string
}
type manifest struct {
Claims []struct {
Name string `json:"name"`
} `json:"claims"`
Tools []json.RawMessage `json:"tools"`
}
// Judge reads every modules/<m>/module.json of the catalogue at root and judges each module that claims a node-build-*
// seat.
func Judge(root string) ([]Judged, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Judged
for _, p := range manifests {
raw, err := os.ReadFile(p)
if err != nil {
return nil, err
}
var m manifest
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", p, err)
}
seat := ""
for _, c := range m.Claims {
if strings.HasPrefix(c.Name, "node-build-") {
seat = c.Name
}
}
if seat == "" {
continue
}
dir := filepath.Dir(p)
j := Judged{Module: filepath.Base(dir)}
test := testTool(m.Tools)
switch {
case test == "":
j.Exempt = fmt.Sprintf("it claims %s and serves no test step", seat)
default:
runs, err := runsTheKit(dir)
if err != nil {
return nil, err
}
if !runs {
j.Refused = fmt.Sprintf("it claims %s and serves the test step %s, and no test of it calls conformance.Run "+
"(mesh-sdk go/buildstep/conformance): run the kit with the module's own parse path (issue 1901)", seat, test)
}
}
out = append(out, j)
}
return out, nil
}
// testTool is the name of the module's test step tool (<prefix>_test), or "".
func testTool(tools []json.RawMessage) string {
for _, raw := range tools {
var name string
if json.Unmarshal(raw, &name) != nil {
var t struct {
Name string `json:"name"`
}
if json.Unmarshal(raw, &t) != nil {
continue
}
name = t.Name
}
if strings.HasSuffix(name, "_test") {
return name
}
}
return ""
}
// runsTheKit says a _test.go file under dir calls conformance.Run.
func runsTheKit(dir string) (bool, error) {
found := false
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil || found || d.IsDir() || !strings.HasSuffix(p, "_test.go") {
return err
}
raw, err := os.ReadFile(p)
if err != nil {
return err
}
found = strings.Contains(string(raw), "conformance.Run(") &&
strings.Contains(string(raw), `"git.novox.be/novox/mesh-sdk/go/buildstep/conformance"`)
return nil
})
return found, err
}
+70
View File
@@ -0,0 +1,70 @@
package failurekit
import (
"os"
"path/filepath"
"testing"
)
// 1901/AC7: a build module with a test step and no kit is refused, one that runs the kit passes, one with no test step is
// exempt, and a module that claims no node-build-* seat is not judged.
func TestABuildModuleWithATestStepMustRunTheKit(t *testing.T) {
root := t.TempDir()
kit := "package main\n\nimport \"git.novox.be/novox/mesh-sdk/go/buildstep/conformance\"\n\nfunc TestKit(t *testing.T) { conformance.Run(t, conformance.GoTest, nil) }\n"
for path, body := range map[string]string{
"modules/a/module.json": `{"claims":[{"name":"node-build-a"}],"tools":["build_a_fetch","build_a_test"]}`,
"modules/a/cmd/a/check_test.go": "package main\n",
"modules/b/module.json": `{"claims":[{"name":"node-build-b"}],"tools":[{"name":"build_b_test"}]}`,
"modules/b/cmd/b/kit_test.go": kit,
"modules/c/module.json": `{"claims":[{"name":"node-build-c"}],"tools":["build_c_build"]}`,
"modules/d/module.json": `{"claims":[{"name":"other"}],"tools":["d_test"]}`,
"modules/e/module.json": `{"claims":[{"name":"node-build-e"}],"tools":["build_e_test"]}`,
"modules/e/cmd/e/kit.go": kit, // not a test file
} {
p := filepath.Join(root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := Judge(root)
if err != nil {
t.Fatal(err)
}
want := map[string]string{"a": "refused", "b": "passes", "c": "exempt", "e": "refused"}
if len(got) != len(want) {
t.Fatalf("judged %+v, want a, b, c and e", got)
}
for _, j := range got {
is := "passes"
if j.Refused != "" {
is = "refused"
} else if j.Exempt != "" {
is = "exempt"
}
if is != want[j.Module] {
t.Errorf("%s %s (%+v), want it %s", j.Module, is, j, want[j.Module])
}
}
}
// Every build module of this catalogue with a test step runs the kit.
func TestTheCataloguesBuildModulesRunTheKit(t *testing.T) {
got, err := Judge(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
if len(got) == 0 {
t.Fatal("no module claims a node-build-* seat: the check reads the wrong tree")
}
for _, j := range got {
switch {
case j.Refused != "":
t.Errorf("%s: %s", j.Module, j.Refused)
case j.Exempt != "":
t.Logf("%s is exempt: %s", j.Module, j.Exempt)
}
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/failurekit
go 1.22
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/nobinaries
go 1.22
+78
View File
@@ -0,0 +1,78 @@
// Package nobinaries refuses a compiled program under modules/ (issue 1577): it is build output, made by the module's build step,
// and `go build` in a module's folder writes one beside go.mod where `git add -A` takes it.
package nobinaries
import (
"bytes"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
// Kind says what executable format the first bytes of a file are, or "" when they are none.
func Kind(head []byte) string {
switch {
case bytes.HasPrefix(head, []byte("\x7fELF")):
return "ELF"
case bytes.HasPrefix(head, []byte("MZ")):
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[7] < 0x20 && head[6] == 0 && head[5] == 0 && head[4] == 0:
return "Mach-O"
}
return ""
}
// Files are the files of the catalogue's root that the change holds under modules/: the tracked ones when the root is a git
// checkout (an ignored binary a local build left is not the change's), else every file there.
func Files(root string) ([]string, error) {
cmd := exec.Command("git", "-C", root, "ls-files", "-z", "--", "modules")
if out, err := cmd.Output(); err == nil && len(out) > 0 {
var files []string
for _, f := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") {
if _, err := os.Lstat(filepath.Join(root, f)); err == nil { // a file the change deletes is not there
files = append(files, f)
}
}
return files, nil
}
var files []string
err := filepath.WalkDir(filepath.Join(root, "modules"), func(p string, d os.DirEntry, err error) error {
if err != nil || d.IsDir() || !d.Type().IsRegular() {
return err
}
rel, _ := filepath.Rel(root, p)
files = append(files, rel)
return nil
})
return files, err
}
// Find names each file of files (paths from root) that is a compiled program, with its format.
func Find(root string, files []string) ([]string, error) {
var found []string
for _, f := range files {
p := filepath.Join(root, f)
if st, err := os.Lstat(p); err != nil || !st.Mode().IsRegular() {
continue
}
fh, err := os.Open(p)
if err != nil {
return nil, err
}
head := make([]byte, 8)
n, err := io.ReadFull(fh, head)
fh.Close()
if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
return nil, err
}
if k := Kind(head[:n]); k != "" {
found = append(found, fmt.Sprintf("%s (%s)", f, k))
}
}
return found, nil
}
+54
View File
@@ -0,0 +1,54 @@
package nobinaries
import (
"os"
"path/filepath"
"testing"
)
func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
root := t.TempDir()
files := map[string][]byte{
"modules/a/a": []byte("\x7fELF\x02\x01\x01\x00rest"),
"modules/b/cmd/b/b.exe": []byte("MZ\x90\x00"),
"modules/c/c": {0xcf, 0xfa, 0xed, 0xfe, 7, 0, 0, 1},
"modules/d/main.go": []byte("package main\n"),
"modules/e/short": []byte("\x7f"),
"modules/f/empty": {},
"modules/g/Foo.class": {0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52}, // Java, not Mach-O
}
var names []string
for f, b := range files {
p := filepath.Join(root, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, b, 0o644); err != nil {
t.Fatal(err)
}
names = append(names, f)
}
got, err := Find(root, names)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c only", got)
}
}
// The catalogue's own tree holds no compiled program: a compiled program is build output, so remove it and add it to .gitignore.
func TestTheCatalogueHoldsNoCompiledProgram(t *testing.T) {
root := filepath.Join("..", "..")
files, err := Files(root)
if err != nil {
t.Fatal(err)
}
found, err := Find(root, files)
if err != nil {
t.Fatal(err)
}
for _, f := range found {
t.Errorf("%s: a compiled program is build output: remove it and add it to .gitignore (issue 1577)", f)
}
}
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
+107 -9
View File
@@ -15,6 +15,13 @@ package main
// the desk says in its standing when that happened (`answers-lost-at`), and the router, which alone knows
// what is still open and unanswered, says that again under its handle — replacing the notification in place,
// so its buttons work again. Nothing resolved, silenced or cancelled comes back.
// - **A store that does not answer does not lose where a message is shown, while the desk runs.** A message
// is shown first and its notification ids kept after; when the state store does not answer then (the bus
// reloading its accounts, 2026-10-26 09:18: the very outage that raised the condition), the work is taken
// all the same and the ids are held in memory, recalled before the store, and kept at the start of the next
// work and at the minute's standing. Otherwise the clearing of that message finds no notification to
// replace and the urgent one stays on screen for good. A desk that restarts while it still holds some loses
// where those are shown; the log says so when they are held.
// - **Standing**: it can send when machines are named in its settings; which of them answered last is in its
// status. A message no machine took is said to the router as failed, and tried again by it.
@@ -93,6 +100,9 @@ type Desk struct {
pause map[string]pauseSeen
// lost is when each machine's notifier last started again: the answers its notifications waited for are lost.
lost map[string]time.Time
// unkept is what was shown and not yet kept in the store, because it did not answer: recalled from here
// before the store, and kept at the next piece of work.
unkept map[string]Shown
}
func (d *Desk) init() {
@@ -107,6 +117,9 @@ func (d *Desk) init() {
if d.lost == nil {
d.lost = map[string]time.Time{}
}
if d.unkept == nil {
d.unkept = map[string]Shown{}
}
if d.Now == nil {
d.Now = time.Now
}
@@ -138,6 +151,7 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
d.Logf("[desk-channel] work on %s could not be read; taken and dropped", subject)
return nil
}
d.KeepUnkept()
machines := d.Machines()
if m.Secret {
// Shown only where the notifier said lately it is not paused (the review of 2026-10-09): one held back
@@ -167,12 +181,16 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
switch {
case edit:
var err error
if before, err = d.Store.Get(m.Handle); err != nil {
if before, err = d.recall(m.Handle); err != nil {
return err
}
if before == nil {
d.Logf("[desk-channel] an edit of %s, which nothing here records as shown: shown as a new message; "+
"a notification it was to replace, if any, stays", m.Handle)
}
case !m.Secret:
var err error
if before, err = d.Store.Get(m.Handle); err != nil {
if before, err = d.recall(m.Handle); err != nil {
d.Logf("[desk-channel] what is shown under this handle could not be read (%v): shown as a new message", err)
before = nil
}
@@ -218,12 +236,91 @@ func (d *Desk) Take(subject string, body json.RawMessage) error {
// Shown once and kept nowhere of this module's (a link's code): no edit or re-showing reaches it.
return nil
}
if err := d.Store.Put(m.Handle, s); err != nil {
d.Logf("[desk-channel] shown, and what was shown could not be kept: an edit of it will not reach it: %v", err)
}
d.keep(m.Handle, s)
return nil
}
// keep keeps what a handle shows in the store. When the store does not answer it is held in memory, where an
// edit, a reminder and a click still find it, and kept later (KeepUnkept). The work is taken either way: offered
// again it would race the message's own later edits, and a redelivery is bounded where an outage is not.
func (d *Desk) keep(handle string, s Shown) {
err := d.Store.Put(handle, s)
d.mu.Lock()
defer d.mu.Unlock()
if err == nil {
delete(d.unkept, handle)
return
}
d.unkept[handle] = s
d.Logf("[desk-channel] shown, and where it is shown could not be kept yet (%v): held in memory, so its edit "+
"and clearing still reach it, and kept when the store answers; %d held, lost if the desk restarts first",
err, len(d.unkept))
}
// KeepUnkept keeps in the store what it did not answer for before: at the start of each piece of work and at
// the minute's standing. It stops at the first write the store does not answer, so one call waits on it once.
func (d *Desk) KeepUnkept() {
d.init()
d.mu.Lock()
held := make(map[string]Shown, len(d.unkept))
for h, s := range d.unkept {
held[h] = s
}
d.mu.Unlock()
for h, s := range held {
if err := d.Store.Put(h, s); err != nil {
return
}
d.mu.Lock()
if cur, ok := d.unkept[h]; ok && sameShown(cur, s) {
delete(d.unkept, h)
}
d.mu.Unlock()
d.Logf("[desk-channel] where %s is shown is kept now", h)
}
}
func (d *Desk) unkeptOf(handle string) (Shown, bool) {
d.mu.Lock()
defer d.mu.Unlock()
s, ok := d.unkept[handle]
return s, ok
}
// recall is what a handle shows: from memory when the store did not keep it yet, else from the store.
func (d *Desk) recall(handle string) (*Shown, error) {
if s, ok := d.unkeptOf(handle); ok {
return &s, nil
}
return d.Store.Get(handle)
}
// handles is every handle shown: the store's and those held in memory.
func (d *Desk) handles() ([]string, error) {
hs, err := d.Store.Handles()
d.mu.Lock()
defer d.mu.Unlock()
seen := map[string]bool{}
for _, h := range hs {
seen[h] = true
}
for h := range d.unkept {
if !seen[h] {
hs = append(hs, h)
}
}
if err != nil && len(d.unkept) == 0 {
return nil, err
}
return hs, nil
}
func sameShown(a, b Shown) bool {
x, err1 := json.Marshal(a)
y, err2 := json.Marshal(b)
return err1 == nil && err2 == nil && string(x) == string(y)
}
// show puts one message on one machine's notifier, in place of `replace` when it names one.
func (d *Desk) show(machine string, m asks.Message, replace int) (int, error) {
urgency := "normal"
@@ -308,12 +405,12 @@ func (d *Desk) Chosen(event, machine, ticket string) {
}
func (d *Desk) find(ticket string) (string, *Shown) {
handles, err := d.Store.Handles()
handles, err := d.handles()
if err != nil {
return "", nil
}
for _, h := range handles {
s, err := d.Store.Get(h)
s, err := d.recall(h)
if err != nil || s == nil {
continue
}
@@ -356,9 +453,9 @@ func (d *Desk) Started(event, machine string) int {
d.lost[machine] = d.Now()
d.mu.Unlock()
n := 0
if handles, err := d.Store.Handles(); err == nil {
if handles, err := d.handles(); err == nil {
for _, h := range handles {
if s, err := d.Store.Get(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
if s, err := d.recall(h); err == nil && s != nil && len(s.Message.Buttons) > 0 {
if _, here := s.On[machine]; here {
n++
}
@@ -472,6 +569,7 @@ func (d *Desk) Status() map[string]any {
"it proves nothing about who clicked: any process of the operator's account can choose an action, so it answers acknowledgements only",
"an answer is taken only on a machine its message was shown on; a message with buttons stays until dismissed",
"what it remembers is never shown again: when a notifier started again, the answers its notifications waited for are lost; the desk says when in its standing (answers-lost-at), and the router says again what is still open and unanswered, replacing those notifications in place — nothing resolved, silenced or cancelled comes back (issue 384)",
"a store that does not answer does not lose where a message is shown while the desk runs: it is held in memory, its edit and clearing still replace it in place, and it is kept when the store answers again; a desk restarted before then loses where those are shown, and its log said how many it held",
"it can send while a named machine's notifier said lately that it is not paused; a link's code is shown only on such a machine, kept nowhere, and sent secret so the notifier keeps it out of its history",
}}
if s.Why != "" {
@@ -3,6 +3,7 @@ package main
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
@@ -363,9 +364,64 @@ func TestAMessageSaidAgainUnderItsHandleReplacesItsNotification(t *testing.T) {
}
}
// downStore is a store that does not answer while down: the bus reloading its accounts (2026-10-26 09:18).
type downStore struct {
memShown
down *bool
}
func (s downStore) Get(h string) (*Shown, error) {
if *s.down {
return nil, errors.New("nats: no responders available for request")
}
return s.memShown.Get(h)
}
func (s downStore) Put(h string, v Shown) error {
if *s.down {
return errors.New("nats: no responders available for request")
}
return s.memShown.Put(h, v)
}
// The review of PR 176: a replace that fails on a machine keeps that machine's notification on record, so a
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed.
// later edit or clearing still reaches it; failing everywhere, it is said to the router as failed. And
// (2026-10-26 09:18, an urgent "A new build of mesh-controller cannot be delivered" left on screen for hours
// after its clearing): a message shown while the store does not answer keeps where it is shown in memory, its
// work is taken, its clearing replaces it in place, and it is kept once the store answers.
func TestAFailedReplaceKeepsTheNotificationItWasToReplace(t *testing.T) {
// The raise, then its clearing, taken while the store does not answer; the store then stays down or comes
// back. Each is taken (never offered again, where a redelivered raise would follow its clearing), the
// clearing replaces the urgent notification in place, and the screen and the store end on the clearing.
for _, back := range []bool{false, true} {
t.Run(fmt.Sprintf("the store does not answer, back %v", back), func(t *testing.T) {
d, shown, _, store := newDesk(t, "laptop")
down := true
d.Store = downStore{store, &down}
raised, _ := json.Marshal(asks.Message{Handle: "m9", Title: "A new build cannot be delivered", Urgent: true})
if err := d.Take(asks.ChannelSubject(asks.Send, Kind), raised); err != nil {
t.Fatalf("the raise was not taken: %v", err)
}
cleared, _ := json.Marshal(asks.Message{Handle: "m9", Title: "Cleared: a new build can be delivered"})
if err := d.Take(asks.ChannelSubject(asks.Edit, Kind), cleared); err != nil {
t.Fatalf("the clearing was not taken: %v", err)
}
d.KeepUnkept() // the minute's standing, store still down
down = !back
d.KeepUnkept()
take(t, d, asks.Send, asks.Message{Handle: "m10", Title: "another"})
if len(*shown) != 3 || (*shown)[1].args["replace_id"] != 1 || (*shown)[1].args["summary"] != "Cleared: a new build can be delivered" {
t.Fatalf("the clearing did not replace the urgent notification: %+v", *shown)
}
s, kept := store["m9"]
if back != kept || (kept && s.Message.Title != "Cleared: a new build can be delivered") {
t.Fatalf("store back %v, kept %v: %+v", back, kept, store)
}
if got, _ := d.recall("m9"); got == nil || got.Message.Title != "Cleared: a new build can be delivered" {
t.Fatalf("recalled %+v", got)
}
})
}
d, shown, said, store := newDesk(t, "laptop", "desktop")
take(t, d, asks.Send, asks.Message{Handle: "m1", Title: "2 failed services on shanks"})
first := store["m1"].On
@@ -100,6 +100,7 @@ func run(d *Desk) {
}()
go func() {
for {
d.KeepUnkept()
d.SayStanding()
time.Sleep(time.Minute)
}
+47
View File
@@ -275,6 +275,53 @@ reported no key gets no entry, and its client says it waits. Keys titled `mesh:`
a key added by hand never is. Every consumer is made again every ten minutes and at once when a key changes.
`go test ./cmd/forge-account-provider ./internal/forgeaccount` runs it against a fake forge.
## The mesh's accounts read every repository (the operator, 2026-10-26)
The tools act as the plain account `mesh-tools` (ADR 0365 §2, issue 469), which writes to the organisation `novox`
through its team; the build modules fetch over ssh as `nox-builder` (ADR 0466 §3), in a team that reads the
organisation's code; the machines' agents use `nox-agent` (ADR 0410 §1). Any other repository each saw only when public,
so a private repository of a user answered every tool 404 and every build's fetch "not authorized to read". `readall.ts`
makes each of these accounts (one list, `meshAccounts` in `tools-account.ts`, whose two Go names
`internal/forgeaccount`'s test holds to its own constants) a collaborator that **reads only** on each repository it
cannot read: one repository at a time, no organisation membership, the least the forge offers. An account the forge
does not hold yet (the forge-account provider makes `nox-builder` and `nox-agent` when their first consumer asks) is
named and asked nothing; the start's pass is run again ten minutes apart until every account was there once.
- **When:** one pass over every repository of every user and organisation at the start (a forge not answering yet is
asked again a minute apart until one pass ran), and on the forge's system webhook: `forge.repository.created` and
`forge.fork` at once, and the first event of any repository this process has not seen readable (the forge may not
send a creation for every owner). Nothing polls for it.
- **Not caught:** a repository made private after it was seen readable. The forge sends no event for a change of
visibility, and the start's pass already saw it readable, so its events ask nothing. It is granted at the module's
next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile. A repository whose permission
the forge will not answer is named as refused (or, in the listing, with permission `unknown`) and the pass goes on.
- **With what:** the site administrator's login (`MESH_GITEA_ADMIN_PASSWORD_FILE`), because adding a collaborator asks a
repository's administration, which on another user's repository only the site administrator holds. It is used for
five calls alone: list every repository, read one, whether an account exists, read an account's permission on one,
add an account as a read collaborator. The tools' own calls keep their token.
- **Tools:** `gitea_unreadable_repos` (per account, every repository it cannot read, the permission the forge gives
it there and why, a count per account and the accounts the forge does not hold) and `gitea_grant_read` (the same
grant by hand, now, on every repository or on those named, for every account or those named; each grant read back
from the forge, a refusal answered in the forge's own words).
### The merging account writes the repositories the mesh delivers
`mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of
a user's repository could be checked and asked but never merged (issue 1945: car-hunter #2's status refused 403 after
the operator's Approve). `writes.ts` makes it a collaborator that **writes** on each repository that builds a registered
module, and on no other (the operator, 2026-10-26): the build modules' and the agents' accounts keep read.
- **Which:** the indexer's state `mesh-delivery-indexer.modules` (in this module's `reads`), one key per registered
module, each naming its repository. A repository of `novox` needs nothing: the team writes it. Every other repository
of a user stays read-only.
- **When:** the state's watch hands every module at the start and each one registered after; a repository not yet
written is granted then, read back from the forge, and said on the bus as `gitea.repo.writable` {owner, repo,
account}, on which mesh-delivery-gate reads that repository again. Nothing polls.
- **Tools:** `gitea_unwritable_repos` (every delivered repository the account cannot write, its permission and why)
and `gitea_grant_write` (the same grant by hand, on every delivered repository or those named; what it granted, what
was written already, what the forge refused in its own words, and the repositories left read-only and why).
`gitea_commit_statuses` answers `merge.writes`, whether the account may write the repository at all.
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
+116 -5
View File
@@ -50,6 +50,9 @@ export interface GiteaRepo {
default_branch?: string;
/** When anything last moved in it — a push, and so a merge. */
updated_at?: string;
/** Whether the account asking may write it (the forge's permissions.push or admin): a commit status and a merge need
* it. Absent when the forge did not say. */
writes?: boolean;
}
/** An issue, with its labels flattened to names. */
@@ -98,6 +101,10 @@ export interface GiteaPull {
html_url: string;
/** The description: where a pull request says it goes after another (`after: <repository>`, novox/hq ADR 0239). */
body?: string;
/** Whether the forge would merge it into its base now (gitea's `mergeable`): false while it is in conflict, while the
* forge is still working that out after its base moved, or while it is a work in progress; absent when the forge did not
* say. What a group's merge reads of every member before it merges any (novox/hq issue 1275). */
mergeable?: boolean;
}
/** An open pull request with what its base requires and what its head has: what gitea_open_pulls answers. */
@@ -789,14 +796,29 @@ export class GiteaClient {
/** Merge a pull request — never forced past its checks, and, given the head that was checked, only that head:
* the forge refuses the merge when the branch moved since (novox/hq ADR 0266). */
/** How long a merge waits before it asks again after the forge's 405 "Please try again later" (novox/hq issue 1275):
* the forge says so while it works out whether the pull request merges, for a few seconds after its base moved. */
static mergeRetryAfter: number[] = [1000, 2000, 4000];
async mergePullRequest(owner: string, repo: string, index: number, method = "merge", deleteBranch = false,
headCommit?: string): Promise<void> {
indexOf(index);
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
method: "POST",
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
...(headCommit ? { head_commit_id: headCommit } : {}) }),
});
for (let attempt = 0; ; attempt++) {
try {
await this.request(`${repoPath(owner, repo)}/pulls/${index}/merge`, {
method: "POST",
body: JSON.stringify({ Do: method, delete_branch_after_merge: deleteBranch, force_merge: false,
...(headCommit ? { head_commit_id: headCommit } : {}) }),
});
return;
} catch (err) {
// Only the forge's transient answer is asked again, briefly; every other refusal, and this one past the last
// wait, is the caller's to say. head_commit_id keeps a head that moved in between from landing.
const wait = GiteaClient.mergeRetryAfter[attempt];
if (wait === undefined || !tryAgainLater(err)) throw err;
await new Promise((r) => setTimeout(r, wait));
}
}
}
// ---- Mappers: the wire shape is broad and unstable; the mesh sees only these fields. ----
@@ -812,6 +834,8 @@ export class GiteaClient {
html_url: r.html_url,
default_branch: r.default_branch,
updated_at: r.updated_at ?? undefined,
...(r.permissions && typeof r.permissions === "object"
? { writes: r.permissions.push === true || r.permissions.admin === true } : {}),
};
}
@@ -842,6 +866,7 @@ export class GiteaClient {
updated_at: p.updated_at ?? undefined,
html_url: p.html_url,
body: typeof p.body === "string" ? p.body : undefined,
mergeable: typeof p.mergeable === "boolean" && !p.merged ? p.mergeable : undefined,
};
}
}
@@ -853,6 +878,25 @@ interface AdminResponse {
readonly body: any;
}
/** A repository as the site administrator sees it: what saying why an account cannot read it needs (readall.ts). */
export interface ForgeRepository {
full_name: string;
owner: string;
name: string;
private: boolean;
archived: boolean;
/** The owner's own visibility: public, limited or private. A public repository of an owner not public is hidden
* from whoever is no member. */
owner_visibility: string;
}
function forgeRepository(r: any): ForgeRepository {
const owner = String(r?.owner?.login ?? r?.owner?.username ?? "");
const name = String(r?.name ?? "");
return { full_name: String(r?.full_name ?? `${owner}/${name}`), owner, name, private: r?.private === true,
archived: r?.archived === true, owner_visibility: String(r?.owner?.visibility ?? "public") };
}
/**
* The forge's admin client, over **basic auth** — gitea's own code, living in the module, used only
* by the provisioner (novox/hq ADR 0048/0076).
@@ -970,6 +1014,66 @@ export class GiteaAdmin {
return repos;
}
// ---- The tools' account reads every repository (readall.ts) ----
/** Every repository on the forge, of every user and organisation: the search as the site administrator, whom the
* forge answers every repository, private ones and those of private owners included. Only a read. */
async everyRepository(): Promise<ForgeRepository[]> {
const all: ForgeRepository[] = [];
for (let page = 1; page <= 200; page++) {
const res = await this.request(`/repos/search?limit=50&page=${page}`);
if (res.status !== 200) GiteaAdmin.fail("/repos/search", res);
const list: any[] = Array.isArray(res.body?.data) ? res.body.data : [];
all.push(...list.map(forgeRepository));
if (list.length < 50) break;
}
return all;
}
/** One repository as the site administrator sees it, or null when the forge holds none by that name. */
async repository(owner: string, repo: string): Promise<ForgeRepository | null> {
const res = await this.request(repoPath(owner, repo));
if (res.status === 404) return null;
if (res.status !== 200) GiteaAdmin.fail(repoPath(owner, repo), res);
return forgeRepository(res.body);
}
/** Whether the forge holds an account of this name. */
async accountExists(user: string): Promise<boolean> {
const path = `/users/${encodeURIComponent(user)}`;
const res = await this.request(path);
if (res.status === 404) return false;
if (res.status !== 200) GiteaAdmin.fail(path, res);
return true;
}
/** What the forge itself says an account may do on a repository: none, read, write, admin or owner. */
async repositoryPermission(owner: string, repo: string, user: string): Promise<string> {
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}/permission`;
const res = await this.request(path);
if (res.status !== 200) GiteaAdmin.fail(path, res);
return String(res.body?.permission ?? "none");
}
/** Make an account a collaborator of a repository that reads only. The forge asks a repository's administrator for
* it, which on a repository of another user only the site administrator is. Idempotent: the forge answers 204 again. */
async addReadCollaborator(owner: string, repo: string, user: string): Promise<void> {
await this.addCollaborator(owner, repo, user, "read");
}
/** Make an account a collaborator of a repository that writes: pushes a branch, sets a commit status, merges (writes.ts:
* only on a repository that builds a registered module). An existing read collaborator is changed to write. */
async addWriteCollaborator(owner: string, repo: string, user: string): Promise<void> {
await this.addCollaborator(owner, repo, user, "write");
}
private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write"): Promise<void> {
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}`;
const res = await this.request(path, { method: "PUT", body: JSON.stringify({ permission }) });
if (res.status === 204 || res.status === 200) return;
GiteaAdmin.fail(path, res);
}
/** Ensure the npm-owner org exists. 201 created, 2xx/404-then-created, and 422/409 (a concurrent
* create won the race) are all success. */
async ensureOrg(name: string): Promise<void> {
@@ -1160,3 +1264,10 @@ export function directoriesAbove(paths: string[]): string[] {
function encodePath(path: string): string {
return pathSegments("file path", path);
}
/** Whether an error is the forge's 405 "Please try again later" on a merge: its answer while the pull request's
* mergeability is being worked out (or it is in conflict, which a later ask says the same way). */
export function tryAgainLater(err: unknown): boolean {
const words = err instanceof Error ? err.message : String(err);
return / 405 /.test(words) && /try again later/i.test(words);
}
+50 -3
View File
@@ -58,8 +58,11 @@ import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGa
import { GateWriter } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
import { bindInBackground } from "./bind.js";
import { bindInBackground, bindUntilHeld } from "./bind.js";
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
import { ReadAll, readAllTools } from "./readall.js";
import { DeliveredWrites, INDEXER_MODULES, writeTools, type ModulesChange } from "./writes.js";
import { TOOLS_ACCOUNT, TOOLS_ORG, meshAccounts } from "./tools-account.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
@@ -500,8 +503,21 @@ if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
// Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852).
await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE));
// **The mesh's accounts read every repository** (readall.ts; the operator, 2026-10-26): the tools', the build modules'
// and the machines' agents' (meshAccounts, tools-account.ts), granted with the site
// administrator's login, which the module holds for setting the forge up and uses here for that grant alone. Made on
// first use, so a module whose admin login is missing still serves the rest and the tools say why.
let readAllOnce: ReadAll | null = null;
const readAll = (): ReadAll => (readAllOnce ??= new ReadAll(GiteaAdmin.fromEnv(), meshAccounts(process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT)));
// **The merging account writes every repository that builds a registered module** (writes.ts; the operator,
// 2026-10-26; issue 1945): which those are is the indexer's state, watched below. Made on first use, as readAll.
let writesOnce: DeliveredWrites | null = null;
const writes = (): DeliveredWrites => (writesOnce ??= new DeliveredWrites(GiteaAdmin.fromEnv(),
process.env.MESH_GITEA_TOOLS_USER || TOOLS_ACCOUNT, TOOLS_ORG, (l) => console.log(`[gitea] ${l}`), (type, body) => emit(type, body)));
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll), ...writeTools(writes), ...(gitea ? [commitStatusTool({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
gates,
}), mergeTool(gitea, { gates })] : [])]);
@@ -543,7 +559,12 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
const server = receiver({ secret, port, heard: (kind, delivery) => pace.heard(kind, delivery),
refused: (reason, from) => pace.refused(reason, from),
publish: (event) => emit(event.name, event.body),
act: (event) => actOn(client, event) });
act: async (event) => {
// A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts).
await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) =>
console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`));
await actOn(client, event);
} });
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => resolve());
@@ -594,6 +615,32 @@ if (gitea) {
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. Until it is
// listening, Pace keeps the polls at 30 s, so nothing is missed meanwhile; listenForForge says its own failure.
void listenForForge(client);
// The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet
// is asked again, a minute apart, until one pass has run; after that only the forge's events grant.
// An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when
// their first consumer asks, and the forge sends no event for a user made) is looked for again ten minutes apart
// until every account was granted once; the line of each pass names it.
const readAllAtStart = (): void => void Promise.resolve().then(() => readAll().atStart()).then((r) => {
if (r.absent.length) setTimeout(readAllAtStart, 600_000);
}, (err) => {
console.error(`[gitea] whether the mesh's accounts read every repository could not be settled; asked again in a minute — ${err instanceof Error ? err.message : err}`);
setTimeout(readAllAtStart, 60_000);
});
try {
readAll();
readAllAtStart();
} catch (err) {
console.error(`[gitea] the mesh's accounts are not given read on every repository: no site administrator's login — ${err instanceof Error ? err.message : err}`);
}
// The indexer's modules, watched: the watch hands every key's latest value at its start and then each change, so a
// repository that builds a registered module is made writable at the start and when its first module is registered
// (writes.ts). A read not granted yet is said and asked again until it holds.
try {
const w = writes();
void bindUntilHeld(INDEXER_MODULES, () => state<unknown>(INDEXER_MODULES).watch((change) => w.take(change as ModulesChange)));
} catch (err) {
console.error(`[gitea] the merging account is not given write on the delivered repositories: no site administrator's login — ${err instanceof Error ? err.message : err}`);
}
// Main takes no push and only the tools' account merges, in every repository, kept and not only when a token is
// minted: at the start and every ten minutes after, so a repository made later gets it too (issue 948, ADR 0398 §2).
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
@@ -1,6 +1,10 @@
package forgeaccount
import "testing"
import (
"os"
"regexp"
"testing"
)
func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) {
if got := ForgeUser("g14", "machine"); got != "nox-agent" {
@@ -15,6 +19,18 @@ func TestForgeUserOfAMachineAccountIsTheOneAgentUser(t *testing.T) {
if got := NodeAccount("g14"); got != "agent-g14" {
t.Fatalf("the token tool's per-node account = %q", got)
}
// The accounts that read every repository (readall.ts, meshAccounts in tools-account.ts) name these two users: the
// list is held to the Go constants here, so the two cannot drift apart (the operator, 2026-10-26).
ts, err := os.ReadFile("../../tools-account.ts")
if err != nil {
t.Fatal(err)
}
for name, want := range map[string]string{"BUILDER_ACCOUNT": BuilderUser, "AGENT_ACCOUNT": AgentUser} {
m := regexp.MustCompile(`export const ` + name + ` = "([^"]+)";`).FindSubmatch(ts)
if m == nil || string(m[1]) != want {
t.Errorf("tools-account.ts %s = %q, want %q (internal/forgeaccount)", name, m, want)
}
}
}
func TestRemoveKeysTitledTakesOnlyTheMeshKeysOfThatTitle(t *testing.T) {
+13
View File
@@ -15,6 +15,19 @@ export function keepMainRules(env: NodeJS.ProcessEnv = process.env): boolean {
} catch { return false; }
}
/** The setting check-gates-merge (the operator, 2026-10-25 22:56: "run the check locally only, but not as a gate, it loses
* us too much time"): whether the merge tool judges mesh/check before it merges. Absent, unreadable or anything but false
* is true; false lets a head the operator's Approve names merge with mesh/check pending, failed, missing or judged on an
* older trunk (mesh-delivery-gate's setting of the same name stops waiting for it before the Approve). */
export function checkGatesMerge(env: NodeJS.ProcessEnv = process.env): boolean {
const file = env.MESH_GITEA_CONFIG_FILE;
if (!file) return true;
try {
const v = (JSON.parse(readFileSync(file, "utf8")) as Record<string, unknown>)["check-gates-merge"];
return !(v === false || v === "false");
} catch { return true; }
}
/** The 10-minute step: puts the rules right only when kept; else says once that they are left and writes nothing. */
export function mainRulesStep(keep: boolean, put: () => Promise<string[]>, say: (line: string) => void): () => Promise<void> {
let said = false;
+8 -2
View File
@@ -19,6 +19,7 @@ import { mergeableUnder, newestByContext } from "./checks.js";
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
import { checkGatesMerge } from "./mainrules.js";
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
/** A head named for a merge: at least 12 hex characters of its sha (novox/hq issue 381), up to a sha-256 commit's 64. */
@@ -31,11 +32,13 @@ export interface MergeDeps {
emit?: (type: string, body: unknown) => Promise<void>;
stateDir?: string;
now?: () => Date;
/** Whether mesh/check gates the merge: the setting check-gates-merge, read at each merge (mainrules.ts). */
checkGates?: () => boolean;
}
export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition {
const deps = { ...given, emit: given.emit ?? announce, stateDir: given.stateDir ?? process.env.MESH_GITEA_STATE_DIR,
now: given.now ?? (() => new Date()) };
now: given.now ?? (() => new Date()), checkGates: given.checkGates ?? (() => checkGatesMerge()) };
return {
name: "gitea_merge_pull_request",
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " +
@@ -126,7 +129,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
}
const kept = maintained(required);
const byCheck = required.includes(CHECK_STATUS) || kept.length === 0;
if (byCheck) {
// **check-gates-merge false** (the operator, 2026-10-25): mesh/check is information, not a gate. A head the operator's
// Approve names merges whatever its mesh/check says, pending, failed, missing or judged on an older trunk; without
// approved_heads the check still judges.
if (byCheck && (deps.checkGates() || !approved)) {
// **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
const check = checks[CHECK_STATUS];
+11 -2
View File
@@ -40,6 +40,7 @@
],
"emits": [
"repo.created",
"repo.writable",
"issue.opened",
"pull.merged",
"pull.updated",
@@ -181,6 +182,12 @@
"trusted": true,
"default": {}
},
"check-gates-merge": {
"type": "boolean",
"description": "Whether gitea_merge_pull_request judges mesh/check before it merges. True (the default): a head merges only on a passing mesh/check judged against the trunk's head (ADR 0447, ADR 0534 §4). False (the operator, 2026-10-25 22:56: \"run the check locally only, but not as a gate, it loses us too much time\"): a head the operator's Approve names in approved_heads merges whatever its mesh/check says (pending, failed, missing or judged on an older trunk); a head that moved, a branch no rule guards and a merge git refuses are still refused. Set together with mesh-delivery-gate's check-gates-merge.",
"trusted": true,
"default": true
},
"main-mergers": {
"type": "list",
"description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.",
@@ -316,7 +323,8 @@
"takes-config": [
"url",
"keep-main-rules",
"main-mergers"
"main-mergers",
"check-gates-merge"
]
},
{
@@ -560,6 +568,7 @@
"mesh-build-go.keys",
"mesh-build-node.keys",
"mesh-build-image.keys",
"mesh-delivery-checker.owed"
"mesh-delivery-checker.owed",
"mesh-delivery-indexer.modules"
]
}
+263
View File
@@ -0,0 +1,263 @@
// The mesh's own forge accounts read every repository on the forge (the operator, 2026-10-26: "the mesh's forge account
// should get read access to all repositories"; and "why wouldn't the forge account get access to all repo's?").
//
// **Every account the mesh's parts use, from one list** (MESH_ACCOUNTS, tools-account.ts): the tools' `mesh-tools`, the
// build modules' `nox-builder` (their fetch over ssh, ADR 0466 §3) and the machines' `nox-agent` (ssh-client's key,
// ADR 0410 §1). The first pass granted the tools' account alone, so the build modules' fetch of jschoubben/car-hunter
// still failed on ace and g14 ("nox-builder ... is not authorized to read"). The two Go names are the ones of
// internal/forgeaccount, whose test holds the list to them. An account the forge does not hold yet (its consumer never
// asked) is said and left out, never a refusal per repository.
//
// Since issue 469 the tools act as the plain account `mesh-tools`, which writes to the organisation novox's
// repositories through its team (tools-account.ts). Every other repository it saw only when public: a private
// repository of a user (jschoubben/car-hunter, jschoubben/snake) answered its tools 404 and was missing from every
// list, while that user's public ones were there. So the module makes its account a collaborator that reads only on
// each repository it cannot read, the least the forge offers: one repository, read, no organisation membership.
//
// - **at the start**, one pass over every repository of every user and organisation;
// - **on the forge's own event** (the system webhook, webhook.ts): `forge.repository.created` and `forge.fork` at
// once, and the first event of any repository this process has not yet seen readable, since the forge may not
// send a repository's creation for every owner. Nothing polls for it.
//
// **Not caught: a repository made private later.** The forge sends no event for a change of visibility, and a public
// repository is already seen readable by the start's pass, so its events ask nothing. Such a repository is granted at
// the module's next start, or now by `gitea_grant_read`; `gitea_unreadable_repos` shows it meanwhile.
//
// **The grant needs the site administrator's login.** Adding a collaborator asks the repository's administration,
// which on another user's repository only the site administrator holds; the module holds that login for setting the
// forge up (MESH_GITEA_ADMIN_PASSWORD_FILE). Here it is used through `ReadAllForge` alone: list the repositories, read
// the account's permission on one, add the account as a collaborator that reads. Nothing else, and never for the tools'
// own calls, which keep their token.
//
// What the forge refuses (a repository that went meanwhile, an archived one if the forge will not take a collaborator
// on it) is said per repository and not retried; the tool `gitea_unreadable_repos` shows what is left, and
// `gitea_grant_read` grants again now.
import type { ForgeRepository } from "./client.js";
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
export interface ReadAllForge {
everyRepository(): Promise<ForgeRepository[]>;
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
/** Whether the forge holds the account at all. */
accountExists(user: string): Promise<boolean>;
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
addReadCollaborator(owner: string, repo: string, user: string): Promise<void>;
}
/** A repository an account cannot read, and why. */
export interface Unreadable {
account: string;
repository: string;
permission: string;
private: boolean;
archived: boolean;
why: string;
}
export interface Granted {
/** Repositories looked at (for each account). */
checked: number;
/** The accounts looked at: those the forge holds. */
accounts: string[];
/** Accounts of the list the forge does not hold yet: nothing is granted to them until it does. */
absent: string[];
/** Made readable now, by a read collaborator: each repository and the account. */
granted: { account: string; repository: string }[];
/** The forge refused the grant: each repository, the account and the forge's own words. */
refused: { account: string; repository: string; why: string }[];
}
const READS = new Set(["read", "write", "admin", "owner"]);
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
/** Why the account cannot read a repository, from what the forge says of it. */
export function whyUnreadable(repo: ForgeRepository, account: string): string {
const parts: string[] = [];
if (repo.private) parts.push(`a private repository of ${repo.owner}, on which ${account} is no collaborator and in no team that covers it`);
else if (repo.owner_visibility !== "public") {
parts.push(`a public repository of ${repo.owner}, whose own visibility is ${repo.owner_visibility}: the forge shows it to members only`);
} else parts.push(`the forge answers ${account} no read on it`);
if (repo.archived) parts.push("archived");
return parts.join("; ");
}
export class ReadAll {
/** Per account, the repositories this process has seen it read, by full name: an event of a repository every account
* was seen reading asks nothing more. */
readonly readable = new Map<string, Set<string>>();
readonly accounts: readonly string[];
constructor(private readonly forge: ReadAllForge, accounts: readonly string[] | string,
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`)) {
this.accounts = [...new Set(typeof accounts === "string" ? [accounts] : accounts)];
if (!this.accounts.length) throw new Error("no forge account to give read: the list is empty");
for (const a of this.accounts) this.readable.set(a, new Set());
}
/** The accounts of the list (or of those named) the forge holds, and those it does not hold yet. */
private async present(only?: string[]): Promise<{ accounts: string[]; absent: string[] }> {
const wanted = only && only.length ? only : [...this.accounts];
for (const a of wanted) if (!this.accounts.includes(a)) throw new Error(`"${a}" is not one of the mesh's accounts: ${this.accounts.join(", ")}`);
const accounts: string[] = [];
const absent: string[] = [];
for (const a of wanted) ((await this.forge.accountExists(a)) ? accounts : absent).push(a);
return { accounts, absent };
}
/** Every repository each account cannot read, and why, as the forge says it now. */
async unreadable(only?: string[]): Promise<{ checked: number; accounts: { account: string; unreadable: number }[]; absent: string[]; unreadable: Unreadable[] }> {
const { accounts, absent } = await this.present(only);
const repos = await this.forge.everyRepository();
const out: Unreadable[] = [];
for (const account of accounts) {
for (const repo of repos) {
try {
const u = await this.judge(repo, account);
if (u) out.push(u);
} catch (err) {
// A permission the forge would not answer is said for that repository; the rest are still looked at.
out.push({ account, repository: repo.full_name, permission: "unknown", private: repo.private, archived: repo.archived,
why: `the forge did not answer ${account}'s permission on it — ${said(err)}` });
}
}
}
return { checked: repos.length, accounts: accounts.map((account) => ({ account, unreadable: out.filter((u) => u.account === account).length })),
absent, unreadable: out };
}
/** Grant read on every repository an account cannot read (or on those named, `owner/name`), for every account of the
* list the forge holds (or for those named). */
async grant(only?: string[], accountsOnly?: string[]): Promise<Granted> {
const { accounts, absent } = await this.present(accountsOnly);
let repos: ForgeRepository[];
if (only && only.length) {
repos = [];
for (const full of only) {
const [owner, name, ...rest] = full.split("/");
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
const repo = await this.forge.repository(owner, name);
if (!repo) throw new Error(`the forge holds no repository ${full}`);
repos.push(repo);
}
} else repos = await this.forge.everyRepository();
const result: Granted = { checked: repos.length, accounts, absent, granted: [], refused: [] };
for (const account of accounts) {
for (const repo of repos) {
const repository = repo.full_name;
let u: Unreadable | null;
try {
u = await this.judge(repo, account);
} catch (err) {
// One repository whose permission cannot be read is a refusal of its own, never the end of the pass.
result.refused.push({ account, repository, why: `its permission could not be read — ${said(err)}` });
continue;
}
if (!u) continue;
try {
await this.forge.addReadCollaborator(repo.owner, repo.name, account);
} catch (err) {
result.refused.push({ account, repository, why: said(err) });
continue;
}
// Read back: the forge's own word that the account reads it now, never the grant's answer alone.
let after: string;
try {
after = await this.forge.repositoryPermission(repo.owner, repo.name, account);
} catch (err) {
result.refused.push({ account, repository, why: `granted, but its permission could not be read back — ${said(err)}` });
continue;
}
if (READS.has(after)) {
this.readable.get(account)!.add(repository);
result.granted.push({ account, repository });
} else {
result.refused.push({ account, repository, why: `the grant was answered, but the forge still gives ${account} "${after}"` });
}
}
}
return result;
}
/** The start's one pass: grant what is missing and say it. Answers the result; a failure is the caller's to say. */
async atStart(): Promise<Granted> {
const r = await this.grant();
const per = r.accounts.map((a) => {
const g = r.granted.filter((x) => x.account === a).map((x) => x.repository);
return `${a} ${g.length} granted read now${g.length ? ` (${g.join(", ")})` : ""}`;
});
this.log(`the mesh's accounts read every repository: ${r.checked} looked at; ${per.join("; ")}` +
`${r.absent.length ? `; not on the forge yet, so not granted: ${r.absent.join(", ")}` : ""}` +
`${r.refused.length ? `; ${r.refused.length} refused: ` + r.refused.map((x) => `${x.account} on ${x.repository} — ${x.why}`).join("; ") : ""}`);
return r;
}
/** A forge event: a repository made (or forked), or the first event of one some account was not seen reading, is
* granted read now to every account that cannot read it. */
async onEvent(event: { name: string; body: unknown }): Promise<void> {
const b = (event.body ?? {}) as { owner?: string; repo?: string };
if (!b.owner || !b.repo) return;
const full = `${b.owner}/${b.repo}`;
if (event.name === "forge.repository.deleted") {
for (const seen of this.readable.values()) seen.delete(full);
return;
}
const made = event.name === "forge.repository.created" || event.name === "forge.fork";
// Seen readable once by every account, its events ask nothing: so a repository made private after that is not
// caught here (the forge says no change of visibility), only at the next start or by gitea_grant_read.
if (!made && [...this.readable.values()].every((seen) => seen.has(full))) return;
const r = await this.grant([full]);
for (const x of r.granted) this.log(`${x.account} was given read on ${full}, on the forge's ${event.name}`);
for (const x of r.refused) this.log(`${x.account} could not be given read on ${x.repository} — ${x.why}`);
}
private async judge(repo: ForgeRepository, account: string): Promise<Unreadable | null> {
const permission = await this.forge.repositoryPermission(repo.owner, repo.name, account);
const seen = this.readable.get(account)!;
if (READS.has(permission)) {
seen.add(repo.full_name);
return null;
}
seen.delete(repo.full_name);
return { account, repository: repo.full_name, permission, private: repo.private, archived: repo.archived, why: whyUnreadable(repo, account) };
}
}
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
const strings = (v: unknown): string[] | undefined => (Array.isArray(v) ? v.map(String) : undefined);
/** The two tools: what each account cannot read and why, and the grant now. */
export function readAllTools(readAll: () => ReadAll): Tool[] {
return [
{
name: "gitea_unreadable_repos",
description: "Every repository on the forge one of the mesh's own forge accounts cannot read, and why, per account: " +
"the tools' (mesh-tools), the build modules' fetch over ssh (nox-builder) and the machines' agents' (nox-agent). " +
"For each, the permission the forge gives it there, whether the repository is private or archived, and in words " +
"the reason (a private repository of a user with the account no collaborator, an owner not public); a count per " +
"account, and the accounts the forge does not hold yet. Empty when every account reads every repository. Read " +
"with the site administrator's login, which alone sees every repository.",
input: {
accounts: { type: "array", items: { type: "string" }, description: "the accounts to look at; empty or absent, every one of the mesh's" },
},
run: async (args) => readAll().unreadable(strings(args.accounts)),
},
{
name: "gitea_grant_read",
description: "Give each of the mesh's own forge accounts (mesh-tools, nox-builder, nox-agent) read on every " +
"repository it cannot read now, or on the repositories named, or for the accounts named: each becomes a " +
"collaborator that reads only, the least the forge offers. The module does this itself at its start and on the " +
"forge's event of a repository made; this is the same act by hand, such as when a build's fetch says its account " +
"is not authorized to read a repository. Answers how many were looked at, which were granted to which account " +
"(each read back from the forge), which the forge refused, in its own words, and the accounts it does not hold " +
"yet. Needs the site administrator's login, used for this grant alone.",
input: {
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every repository" },
accounts: { type: "array", items: { type: "string" }, description: "the accounts to grant; empty or absent, every one of the mesh's" },
},
run: async (args) => readAll().grant(strings(args.repositories), strings(args.accounts)),
},
];
}
+12
View File
@@ -525,6 +525,18 @@ test("gitea_merge_pull_request judges mesh/check on a main requiring no status,
state.statuses = line(TRUNK);
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
// check-gates-merge false (the operator, 2026-10-25): a head the Approve names merges whatever its mesh/check says;
// without approved_heads it is refused as before.
const ungated = mergeTool(client, { gates, stateDir, emit: async () => {}, checkGates: () => false });
const mergeUngated = (args: Record<string, unknown> = { approved_heads }) =>
ungated.run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args });
for (const statuses of [[], line(TRUNK, "pending", "checking"), line(TRUNK, "failure", "fail"), line(MOVED)]) {
state.merged = false;
state.statuses = statuses;
await assert.rejects(mergeUngated({}), /Approve/);
assert.equal(((await mergeUngated()) as Record<string, unknown>).merged, true, JSON.stringify(statuses));
}
});
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
+68
View File
@@ -0,0 +1,68 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { createServer } from "node:http";
// **The forge's 405 "Please try again later" on a merge is asked again briefly** (novox/hq issue 1275, 2026-10-26 13:40):
// mesh-delivery#73's merge was answered so a few seconds after its base moved, and counted as refused at once. The forge
// says it while it works out whether the pull request merges; the merge is asked again after a short wait, and every other
// refusal is answered at once. A pull request's own mergeable is carried, so a group can be read whole before it merges.
/** A forge whose merge of novox/hq#7 answers each of `answers` in turn (the last for ever), and whose pull request says
* `mergeable`. */
async function serve(answers: { status: number; body: string }[], mergeable?: boolean) {
const { GiteaClient } = await import("../client.ts");
let merges = 0;
const server = createServer((req, res) => {
const url = new URL(req.url ?? "", "http://x");
if (url.pathname === "/api/v1/repos/novox/hq/pulls/7/merge" && req.method === "POST") {
const a = answers[Math.min(merges++, answers.length - 1)];
res.statusCode = a.status;
return res.end(a.body);
}
if (url.pathname === "/api/v1/repos/novox/hq/pulls/7") {
return res.end(JSON.stringify({ number: 7, state: "open", merged: false, base: { ref: "main" },
head: { ref: "b", sha: "a".repeat(40) }, html_url: "h/7", ...(mergeable === undefined ? {} : { mergeable }) }));
}
res.statusCode = 404;
res.end("{}");
});
await new Promise<void>((r) => server.listen(0, r));
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
GiteaClient.mergeRetryAfter = [1, 1, 1];
return { client, server, merges: () => merges };
}
const later = { status: 405, body: '{"message":"Please try again later"}' };
const ok = { status: 200, body: "" };
test("a merge answered 'try again later' while the forge works out its mergeability, then merged", async (t) => {
const cases: { name: string; answers: { status: number; body: string }[]; merged: boolean; asked: number }[] = [
{ name: "merged on the third ask", answers: [later, later, ok], merged: true, asked: 3 },
{ name: "still 'later' after the last wait: refused, with the forge's words", answers: [later], merged: false, asked: 4 },
{ name: "a refusal of another kind is answered at once", answers: [{ status: 409, body: '{"message":"head moved"}' }], merged: false, asked: 1 },
];
for (const c of cases) {
await t.test(c.name, async () => {
const { client, server, merges } = await serve(c.answers);
try {
const done = client.mergePullRequest("novox", "hq", 7, "merge", false, "a".repeat(40));
if (c.merged) await done;
else await assert.rejects(done, (err: Error) => /40[59]/.test(err.message));
assert.equal(merges(), c.asked);
} finally {
server.close();
}
});
}
});
test("a pull request carries the forge's mergeable, and none when the forge did not say", async () => {
for (const mergeable of [true, false, undefined]) {
const { client, server } = await serve([ok], mergeable);
try {
assert.equal((await client.getPullRequest("novox", "hq", 7)).mergeable, mergeable);
} finally {
server.close();
}
}
});
+243 -2
View File
@@ -16,7 +16,7 @@ import { join } from "node:path";
import { MintedToken } from "../dist/token.js";
import { GiteaClient } from "../dist/client.js";
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS } from "../dist/tools-account.js";
import { TOOLS_ACCOUNT, TOOLS_TEAM_UNITS, BUILDER_ACCOUNT, AGENT_ACCOUNT, meshAccounts } from "../dist/tools-account.js";
import { repoCheckFacts } from "../dist/pulls.js";
const ADMIN = "mesh-admin";
@@ -26,6 +26,18 @@ interface Account { admin: boolean; password: string; active: boolean }
interface Token { id: number; name: string; sha1: string; user: string; scopes: string[] }
interface Team { id: number; name: string; units: any; members: Set<string> }
interface ForgeRepo { owner: string; name: string; full: string; private: boolean; archived?: boolean; ownerVisibility: string; collaborators: Map<string, string> }
const forgeRepos: ForgeRepo[] = [];
const repoFaults = { refuse: new Set<string>(), permission: new Set<string>() };
function addRepo(full: string, isPrivate: boolean, ownerVisibility = "public"): ForgeRepo {
const [owner, name] = full.split("/");
const r = { owner, name, full, private: isPrivate, ownerVisibility, collaborators: new Map<string, string>() };
forgeRepos.push(r);
return r;
}
const repoJSON = (r: ForgeRepo) => ({ full_name: r.full, name: r.name, owner: { login: r.owner, visibility: r.ownerVisibility },
private: r.private, archived: r.archived === true });
function fakeForge() {
const users = new Map<string, Account>([[ADMIN, { admin: true, password: PASSWORD, active: true }]]);
const tokens: Token[] = [];
@@ -61,6 +73,13 @@ function fakeForge() {
return null;
};
const isAdmin = (u: string | null) => u !== null && users.get(u)?.admin === true;
const permissionOf = (u: string | null, r: ForgeRepo): string => {
if (isAdmin(u)) return "admin";
if (u === r.owner) return "owner";
if (r.owner === "novox" && teams.some((t) => t.members.has(u ?? "") && t.units.units_map?.["repo.code"] === "write")) return "write";
if (u && r.collaborators.has(u)) return r.collaborators.get(u)!;
return !r.private && r.ownerVisibility === "public" ? "read" : "none";
};
const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake");
@@ -176,7 +195,35 @@ function fakeForge() {
if (!writes) return json(res, 403, { message: "write on code required" });
return json(res, 201, { context: (await read(req)).context, creator: { login: me } });
}
if (req.method === "GET" && p === "/repos/search") return json(res, 200, { data: [] });
// Every repository of every owner, and who reads it (readall.ts): the site administrator reads all; the tools' account
// a repository of novox through its team, a public one of a public owner, and one it is a collaborator of.
if (req.method === "GET" && p === "/repos/search") {
const page = Number(url.searchParams.get("page") ?? "1");
const seen = forgeRepos.filter((r) => permissionOf(me, r) !== "none");
return json(res, 200, { data: page === 1 ? seen.map(repoJSON) : [] });
}
m = p.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/collaborators\/([^/]+)(\/permission)?)?$/);
if (m) {
const repo = forgeRepos.find((r) => r.owner === decodeURIComponent(m![1]) && r.name === decodeURIComponent(m![2]));
if (!repo || permissionOf(me, repo) === "none") return json(res, 404, { message: "repository does not exist" });
if (req.method === "GET" && !m[3]) {
const mine = permissionOf(me, repo);
return json(res, 200, { ...repoJSON(repo), permissions: { pull: true, push: ["write", "admin", "owner"].includes(mine), admin: ["admin", "owner"].includes(mine) } });
}
const user = decodeURIComponent(m[3] ?? "");
if (req.method === "GET" && m[4]) {
if (repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
if (!isAdmin(me) && me !== user) return json(res, 403, { message: "only the user or an administrator" });
return json(res, 200, { permission: permissionOf(user, repo) });
}
if (req.method === "PUT" && !m[4]) {
// gitea's reqAdmin: the repository's administration, which on a repository of another user the site administrator alone holds.
if (!isAdmin(me) && me !== repo.owner) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
if (repoFaults.refuse.has(repo.full)) return json(res, 422, { message: "the forge would not have it" });
repo.collaborators.set(user, (await read(req))?.permission ?? "write");
return json(res, 204, null);
}
}
return json(res, 404, { message: "no such route in the fake" });
});
return new Promise<{ url: string; users: typeof users; tokens: typeof tokens; teams: typeof teams; calls: string[]; mainRules: typeof mainRules;
@@ -402,3 +449,197 @@ test("a mint with keep-main-rules false (the default) writes no branch protectio
const writes = forge.calls.slice(from).filter((c: string) => /branch_protections/.test(c) && !/^GET /.test(c) && !/^(GET|HEAD) /.test(c));
assert.deepEqual(writes, []);
});
// The mesh's accounts read every repository (readall.ts; the operator, 2026-10-26): jschoubben/car-hunter and
// jschoubben/snake, private repositories of a user, answered the tools 404 while that user's public ones were seen; and
// once the tools' account read them, the build modules' fetch as nox-builder still could not.
test("the start's pass gives each of the mesh's accounts read, as a collaborator that reads only, on each repository it could not read, and on no other; an account the forge does not hold is said, not refused", async () => {
const env = await environment();
await fresh(env).listRepos(); // the account and its team exist, as after any mint
forge.users.set(BUILDER_ACCOUNT, { admin: false, password: "unused", active: true }); // made by the forge-account provider
forge.users.delete(AGENT_ACCOUNT); // no machine's key asked for it yet
forgeRepos.splice(0);
addRepo("novox/hq", true);
addRepo("jschoubben/txt-game", false);
addRepo("jschoubben/car-hunter", true);
addRepo("jschoubben/snake", true);
addRepo("shy/open", false, "limited");
addRepo("elsewhere/secret", true);
const { GiteaAdmin } = await import("../dist/client.js");
const { ReadAll, readAllTools } = await import("../dist/readall.js");
const lines: string[] = [];
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), meshAccounts(), (l: string) => lines.push(l));
assert.deepEqual(readAll.accounts, [TOOLS_ACCOUNT, BUILDER_ACCOUNT, AGENT_ACCOUNT]);
const [unreadableTool, grantTool] = readAllTools(() => readAll);
// What the tool says before, per account: the four the tools' account cannot read, each with why; the builders'
// account, in no write team here, also not novox/hq; nox-agent not on the forge.
const before = await unreadableTool.run({}) as any;
assert.equal(before.checked, 6);
const of = (account: string) => before.unreadable.filter((u: any) => u.account === account).map((u: any) => u.repository).sort();
assert.deepEqual(of(TOOLS_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]);
assert.deepEqual(of(BUILDER_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "novox/hq", "shy/open"]);
assert.deepEqual(before.accounts, [{ account: TOOLS_ACCOUNT, unreadable: 4 }, { account: BUILDER_ACCOUNT, unreadable: 5 }]);
assert.deepEqual(before.absent, [AGENT_ACCOUNT]);
assert.match(before.unreadable.find((u: any) => u.account === BUILDER_ACCOUNT && u.repository === "jschoubben/car-hunter").why,
/a private repository of jschoubben, on which nox-builder is no collaborator/);
const builderOnly = await unreadableTool.run({ accounts: [BUILDER_ACCOUNT] }) as any;
assert.deepEqual(builderOnly.accounts, [{ account: BUILDER_ACCOUNT, unreadable: 5 }]);
await assert.rejects(unreadableTool.run({ accounts: ["someone"] }), /not one of the mesh's accounts/);
assert.match(before.unreadable.find((u: any) => u.repository === "jschoubben/car-hunter").why,
/a private repository of jschoubben, on which mesh-tools is no collaborator/);
assert.match(before.unreadable.find((u: any) => u.repository === "shy/open").why, /visibility is limited/);
assert.deepEqual((await fresh(env).listAllRepos()).map((r) => r.full_name).sort(), ["jschoubben/txt-game", "novox/hq"]);
// The account's own token cannot grant itself: the forge asks the repository's administration (the reason for the admin login).
const own = (await readFile(join(env.MESH_GITEA_STATE_DIR, "token"), "utf8")).trim();
const self = await fetch(`${forge.url}/api/v1/repos/jschoubben/car-hunter/collaborators/${TOOLS_ACCOUNT}`,
{ method: "PUT", headers: { Authorization: `token ${own}`, "Content-Type": "application/json" }, body: JSON.stringify({ permission: "read" }) });
assert.equal(self.status, 404, "a repository the account cannot read is not even there for it");
const from = forge.calls.length;
const r = await readAll.atStart();
const grantedTo = (account: string) => r.granted.filter((x: any) => x.account === account).map((x: any) => x.repository).sort();
assert.deepEqual(grantedTo(TOOLS_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "shy/open"]);
assert.deepEqual(grantedTo(BUILDER_ACCOUNT), ["elsewhere/secret", "jschoubben/car-hunter", "jschoubben/snake", "novox/hq", "shy/open"]);
assert.deepEqual(r.refused, []);
assert.deepEqual(r.absent, [AGENT_ACCOUNT]);
const puts = forge.calls.slice(from).filter((c) => c.startsWith("PUT "));
assert.equal(puts.length, 9, "the repositories each read already were left alone, and the absent account was asked nothing");
assert.ok(!puts.some((c) => c.includes(AGENT_ACCOUNT)));
assert.ok(puts.every((c) => c.endsWith(` as ${ADMIN}`)), "only the administrator's login grants");
for (const repo of forgeRepos.filter((x) => x.collaborators.size)) {
for (const [account, permission] of repo.collaborators) assert.equal(permission, "read", `${account} on ${repo.full} reads only`);
}
assert.equal(forgeRepos.find((x) => x.full === "jschoubben/car-hunter")!.collaborators.get(BUILDER_ACCOUNT), "read");
assert.match(lines.join("\n"), /6 looked at; mesh-tools 4 granted read now .*; nox-builder 5 granted read now .*; not on the forge yet, so not granted: nox-agent/);
// The account made later is granted by hand (or at the next pass), its rows like any other.
forge.users.set(AGENT_ACCOUNT, { admin: false, password: "unused", active: true });
const agent = await grantTool.run({ accounts: [AGENT_ACCOUNT], repositories: ["jschoubben/car-hunter"] }) as any;
assert.deepEqual(agent.granted, [{ account: AGENT_ACCOUNT, repository: "jschoubben/car-hunter" }]);
assert.deepEqual(agent.absent, []);
forge.users.delete(AGENT_ACCOUNT);
// After: the account's own token sees every repository, and the tool names none.
assert.equal((await fresh(env).listAllRepos()).length, 6);
assert.deepEqual(((await unreadableTool.run({})) as any).unreadable, []);
// Granting again asks nothing of the forge but reads.
const again = forge.calls.length;
assert.deepEqual(((await grantTool.run({})) as any).granted, []);
assert.ok(forge.calls.slice(again).every((c) => c.startsWith("GET ")));
});
test("on the forge's events: a repository made is granted read at once, a known readable one asks nothing, a refusal is said, not thrown", async () => {
const env = await environment();
await fresh(env).listRepos();
forgeRepos.splice(0);
addRepo("jschoubben/txt-game", false);
const { GiteaAdmin } = await import("../dist/client.js");
const { ReadAll, readAllTools } = await import("../dist/readall.js");
const lines: string[] = [];
forge.users.set(BUILDER_ACCOUNT, { admin: false, password: "unused", active: true });
const readAll = new ReadAll(GiteaAdmin.fromEnv(env), [TOOLS_ACCOUNT, BUILDER_ACCOUNT], (l: string) => lines.push(l));
await readAll.atStart();
const cases: { name: string; event: string; repo: string; make?: boolean; refuse?: boolean; unanswered?: boolean; asks: boolean; reads: string }[] = [
{ name: "a repository made", event: "forge.repository.created", repo: "jschoubben/new-private", make: true, asks: true, reads: "read" },
{ name: "a fork made", event: "forge.fork", repo: "someone/fork", make: true, asks: true, reads: "read" },
{ name: "a push to a repository seen readable", event: "forge.push", repo: "jschoubben/txt-game", asks: false, reads: "read" },
{ name: "a push to a private repository never seen", event: "forge.push", repo: "jschoubben/quiet", make: true, asks: true, reads: "read" },
{ name: "a repository the forge refuses the grant on", event: "forge.repository.created", repo: "jschoubben/stuck", make: true, refuse: true, asks: true, reads: "none" },
{ name: "a repository whose permission the forge will not answer", event: "forge.repository.created", repo: "jschoubben/mute", make: true, unanswered: true, asks: true, reads: "none" },
];
for (const c of cases) {
if (c.make) addRepo(c.repo, true);
if (c.refuse) repoFaults.refuse.add(c.repo);
if (c.unanswered) repoFaults.permission.add(c.repo);
const from = forge.calls.length;
const [owner, repo] = c.repo.split("/");
await readAll.onEvent({ name: c.event, body: { owner, repo } });
assert.equal(forge.calls.length > from, c.asks, `${c.name}: asks the forge`);
for (const account of [TOOLS_ACCOUNT, BUILDER_ACCOUNT]) {
assert.equal(forgeRepos.find((x) => x.full === c.repo)!.collaborators.get(account) ?? (c.make ? "none" : "read"), c.reads, `${c.name}: ${account}`);
}
}
assert.match(lines.join("\n"), /mesh-tools could not be given read on jschoubben\/stuck — .*422/);
assert.match(lines.join("\n"), /nox-builder was given read on someone\/fork, on the forge's forge.fork/);
const named = await (readAllTools(() => readAll)[1].run({ repositories: ["jschoubben/stuck"] })) as any;
assert.deepEqual(named.refused.map((x: any) => `${x.account} ${x.repository}`), [`${TOOLS_ACCOUNT} jschoubben/stuck`, `${BUILDER_ACCOUNT} jschoubben/stuck`]);
await assert.rejects(readAllTools(() => readAll)[1].run({ repositories: ["no-slash"] }), /not a repository/);
assert.match(lines.join("\n"), /could not be given read on jschoubben\/mute — its permission could not be read — .*500/);
// A whole pass goes past the repository it cannot judge: every other one is still granted, and the listing names it.
addRepo("jschoubben/after-mute", true);
const pass = await readAll.grant();
assert.ok(pass.granted.some((x: any) => x.repository === "jschoubben/after-mute" && x.account === BUILDER_ACCOUNT), "the pass did not stop at the repository it could not judge");
assert.ok(pass.refused.some((x: any) => x.repository === "jschoubben/mute" && /permission could not be read/.test(x.why)));
const listed = await readAllTools(() => readAll)[0].run({}) as any;
assert.equal(listed.unreadable.find((u: any) => u.repository === "jschoubben/mute").permission, "unknown");
repoFaults.refuse.clear();
repoFaults.permission.clear();
});
// The merging account writes every repository that builds a registered module, and no other (writes.ts; the operator,
// 2026-10-26; issue 1945): car-hunter #2 was approved and its commit status refused 403, mesh-tools reading only there.
test("the merging account is given write on each repository the indexer's modules name, read back and said; the operator's other repositories stay read-only; the tools say both", async () => {
const env = await environment();
const client = fresh(env);
await client.listRepos(); // the account and its team exist, as after any mint
forgeRepos.splice(0);
addRepo("novox/hq", true);
for (const full of ["jschoubben/car-hunter", "jschoubben/snake", "jschoubben/dotfiles"]) addRepo(full, true).collaborators.set(TOOLS_ACCOUNT, "read");
const { GiteaAdmin } = await import("../dist/client.js");
const { DeliveredWrites, writeTools, WRITABLE_EVENT } = await import("../dist/writes.js");
const lines: string[] = [];
const said: { type: string; body: any }[] = [];
const writes = new DeliveredWrites(GiteaAdmin.fromEnv(env), TOOLS_ACCOUNT, "novox", (l: string) => lines.push(l),
async (type: string, body: unknown) => { said.push({ type, body }); });
const [unwritableTool, grantTool] = writeTools(() => writes);
await assert.rejects(grantTool.run({}), /not known yet/, "nothing is granted before the indexer's modules were read");
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, false, "the statuses' answer says the account cannot write");
// 1945/AC1: the watch hands the indexer's modules; a user's repository a module builds from is made writable, once.
await writes.take({ key: "car-hunter", op: "put", value: { v: 1, repository: "jschoubben/car-hunter", commit: "c" }, current: true });
await writes.take({ key: "gitea", op: "put", value: { v: 1, repository: "novox/hq", commit: "c" }, current: true });
await writes.take({ key: "broken", op: "put", value: { v: 1, repository: "no-slash" }, current: true });
const from = forge.calls.length;
await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } });
assert.equal(forge.calls.length, from, "a repository already written asks nothing more");
const repo = (full: string) => forgeRepos.find((x) => x.full === full)!;
assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "write");
assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator");
assert.equal((await client.getRepo("jschoubben", "car-hunter")).writes, true);
assert.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]);
assert.match(lines.join("\n"), /mesh-tools was given write on jschoubben\/car-hunter, which builds the registered module car-hunter/);
assert.match(lines.join("\n"), /broken names no repository/);
// 1945/AC2: a delivered repository the forge refuses is said, in its words, by the listing and the grant; the operator's
// repository no module builds from is left read-only, named with why, and a grant naming it asks the forge nothing.
repoFaults.refuse.add("jschoubben/snake");
await writes.take({ key: "snake", op: "put", value: { v: 1, repository: "jschoubben/snake" } });
assert.match(lines.join("\n"), /could not be given write on jschoubben\/snake, which builds snake — .*422/);
const listed = await unwritableTool.run({}) as any;
assert.deepEqual(listed.unwritable.map((u: any) => [u.repository, u.permission]), [["jschoubben/snake", "read"]]);
assert.match(listed.unwritable[0].why, /builds snake.*gitea_grant_write/);
const all = await grantTool.run({}) as any;
assert.deepEqual(all.delivered, ["jschoubben/car-hunter", "jschoubben/snake", "novox/hq"]);
assert.deepEqual(all.already, ["jschoubben/car-hunter", "novox/hq"]);
assert.deepEqual(all.granted, []);
assert.match(all.refused.find((x: any) => x.repository === "jschoubben/snake").why, /422.*would not have it/);
assert.deepEqual(all.left_read_only.map((x: any) => x.repository), ["jschoubben/dotfiles"]);
assert.match(all.left_read_only[0].why, /builds no registered module/);
const puts = forge.calls.length;
const named = await grantTool.run({ repositories: ["jschoubben/dotfiles"] }) as any;
assert.deepEqual([named.granted, named.left_read_only.map((x: any) => x.repository)], [[], ["jschoubben/dotfiles"]]);
assert.ok(!forge.calls.slice(puts).some((c) => c.startsWith("PUT ")), "no write is asked on a repository no module builds from");
assert.equal(repo("jschoubben/dotfiles").collaborators.get(TOOLS_ACCOUNT), "read");
repoFaults.refuse.clear();
const after = await grantTool.run({ repositories: ["jschoubben/snake"] }) as any;
assert.deepEqual(after.granted, ["jschoubben/snake"]);
assert.deepEqual(((await unwritableTool.run({})) as any).unwritable, []);
// A module gone from the indexer leaves its repository out of the delivered list.
await writes.take({ key: "snake", op: "delete" });
assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]);
});
+3
View File
@@ -34,6 +34,9 @@ test("both statuses say the head and the trunk commit the verdict judged, first,
const [old] = statusesFor({ ...c, trunk: undefined, gate: { verdict: "pass", summary: "every machine composes" } });
assert.equal(old.description, "pass: every machine composes");
assert.equal(trunkNamed(old.description), undefined);
// ADR 0548 §8: a carried verdict's line names the head on the new trunk first, so the merge tool merges it on that trunk.
assert.equal(trunkNamed(`pass ${HEAD.slice(0, 12)} on ${MOVED.slice(0, 12)} | carried from ${TRUNK.slice(0, 12)}, mesh-catalog 0123456789ab | unit 0 run, 4 kept`),
MOVED.slice(0, 12));
});
test("a trunk's move puts an open head's merge check out of date, naming both commits, unless a check is on its way", () => {
+9
View File
@@ -22,6 +22,15 @@
import { keepMainRules } from "./mainrules.js";
export const TOOLS_ACCOUNT = "mesh-tools";
/** The build modules' account, whose keys fetch over ssh (internal/forgeaccount BuilderUser, ADR 0466 §3). */
export const BUILDER_ACCOUNT = "nox-builder";
/** The machines' agents' account, whose keys ssh-client reports (internal/forgeaccount AgentUser, ADR 0410 §1). */
export const AGENT_ACCOUNT = "nox-agent";
/** Every forge account the mesh's own parts use: each reads every repository on the forge (readall.ts; the operator,
* 2026-10-26). internal/forgeaccount's test holds the two Go names to its own constants. */
export function meshAccounts(tools: string = TOOLS_ACCOUNT): string[] {
return [tools, BUILDER_ACCOUNT, AGENT_ACCOUNT];
}
/** The organisation whose repositories are the mesh's, and the team through which the tools' account writes there. */
export const TOOLS_ORG = "novox";
export const TOOLS_TEAM = "mesh-tools";
+15 -4
View File
@@ -492,7 +492,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_commit_statuses",
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success; for a pull request, also the forge's own mergeable (false while it is in conflict with its base or the forge is still working that out); and merge.writes, whether the mesh's merging account may write the repository at all (false: its statuses and its merge are refused until gitea_grant_write). Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -508,11 +508,12 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!Number.isInteger(number) || number < 0) throw new Error(`${args.number} is not a pull request's number`);
let pull: { number: number; title: string; state: string; merged: boolean; base?: string; head?: string;
head_sha?: string; merge_commit_sha?: string; html_url: string } | undefined;
head_sha?: string; merge_commit_sha?: string; merged_at?: string; html_url: string; mergeable?: boolean } | undefined;
if (number > 0) {
const p = await gitea.getPullRequest(owner, repo, number);
pull = { number: p.number, title: p.title, state: p.state, merged: p.merged, base: p.base, head: p.head,
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, html_url: p.html_url };
head_sha: p.head_sha, merge_commit_sha: p.merge_commit_sha, merged_at: p.merged_at, html_url: p.html_url,
...(p.mergeable === undefined ? {} : { mergeable: p.mergeable }) };
if (!ref) ref = p.head_sha ?? "";
if (!base) base = p.base ?? "";
if (!ref) throw new Error(`${owner}/${repo}#${number} names no head commit`);
@@ -527,8 +528,18 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
} catch (err) {
unreadable = err instanceof Error ? err.message : String(err);
}
// Whether this account may write the repository at all (issue 1945): a commit status and a merge need it, and a
// repository of a user it only reads refuses both 403. Not said when the forge would not answer.
let writes: boolean | undefined;
try {
writes = (await gitea.getRepo(owner, repo)).writes;
} catch {
writes = undefined;
}
const merge = mergeableUnder(base, rule, statuses, unreadable);
return { commit: combined.sha || ref, pull, combined: combined.state, statuses,
merge: mergeableUnder(base, rule, statuses, unreadable) };
merge: writes === undefined ? merge : { ...merge, writes,
...(writes ? {} : { cannot_write: `the forge account cannot write ${owner}/${repo}: gitea_grant_write` }) } };
},
},
{
+246
View File
@@ -0,0 +1,246 @@
// The account that sets commit statuses and merges writes every repository that builds a registered module, and no other
// (the operator, 2026-10-26: "yes the mesh should get write access", on the repositories the mesh delivers; novox/hq issue
// 1945). car-hunter #2 (a repository of a user, not of the organisation novox) was approved, and mesh-delivery's first
// act, its commit status, was refused 403: the tools' account was only a collaborator that reads there (readall.ts).
//
// **Which account.** The tools' account, `mesh-tools`: the gitea module's own tools set every commit status and merge
// (gitea_commit_status, gitea_merge_pull_request) with its token. The build modules' `nox-builder` and the machines'
// `nox-agent` only fetch, and keep read (readall.ts).
//
// **Which repositories: the indexer's word, read over the bus.** mesh-delivery-indexer keeps every registered module in its
// state `modules` (mesh-sdk go/delivery ModulesBucket), each value naming the repository it builds from. This module reads
// that state (its manifest's `reads`) with a watch: the watch hands the latest value of every key when it starts, and then
// every change, so a repository is granted at the module's start and when a module of it is first registered. Nothing
// polls. A repository of the organisation needs nothing: the organisation's team writes it (tools-account.ts). Every other
// repository of a user (the operator's dotfiles, say) is left as it is: read only.
//
// **Every grant is read back** from the forge (its permission for the account), and said on the bus as
// `repo.writable` {owner, repo, account}: mesh-delivery-gate, which does not ask the operator's Approve of a pull request
// whose merge the forge would refuse, reads that repository again on it.
//
// Not taken away: a repository whose last module goes from the indexer keeps its collaborator until a hand removes it;
// gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now.
import type { ForgeRepository } from "./client.js";
/** The indexer's state this module reads: one key per registered module, its value naming the repository. */
export const INDEXER_MODULES = "mesh-delivery-indexer.modules";
/** The event said once an account writes a repository it could not write before. */
export const WRITABLE_EVENT = "repo.writable";
/** What the module asks of the forge for this, with the site administrator's login (GiteaAdmin, client.ts). */
export interface WriteForge {
everyRepository(): Promise<ForgeRepository[]>;
repository(owner: string, repo: string): Promise<ForgeRepository | null>;
repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
addWriteCollaborator(owner: string, repo: string, user: string): Promise<void>;
}
/** One change of the indexer's modules state, as the SDK's state watch hands it. */
export interface ModulesChange {
key: string;
op: "put" | "delete";
value?: unknown;
revision?: number;
current?: boolean;
}
export interface Written {
account: string;
/** The repositories that build a registered module, as the indexer says now. */
delivered: string[];
/** Made writable now, each read back from the forge. */
granted: string[];
/** Written already (the organisation's team, or a collaborator that writes). */
already: string[];
/** The forge refused, in its own words; or the repository is the organisation's, whose team should write it. */
refused: { repository: string; why: string }[];
/** Repositories the account cannot write that are left so, and why: they build no registered module. */
left_read_only: { repository: string; permission: string; why: string }[];
}
const WRITES = new Set(["write", "admin", "owner"]);
const said = (err: unknown): string => (err instanceof Error ? err.message : String(err));
function split(full: string): [string, string] {
const [owner, name, ...rest] = String(full ?? "").split("/");
if (!owner || !name || rest.length) throw new Error(`"${full}" is not a repository: say owner/name`);
return [owner, name];
}
export class DeliveredWrites {
/** Module to the repository it builds from, as the indexer's state says. */
readonly modules = new Map<string, string>();
/** Whether the watch has handed the indexer's state at all: until then, which repositories are delivered is not known. */
heard = false;
/** Repositories seen written by the account in this process: a module registered again asks nothing more. */
readonly writable = new Set<string>();
constructor(private readonly forge: WriteForge, readonly account: string, readonly org: string,
private readonly log: (line: string) => void = (l) => console.log(`[gitea] ${l}`),
private readonly emit: (type: string, body: unknown) => Promise<void> = async () => {}) {}
/** The repositories that build a registered module, sorted. */
delivered(): string[] {
return [...new Set(this.modules.values())].sort();
}
/** One change of the indexer's modules: a repository newly delivered is made writable now. Never throws. */
async take(change: ModulesChange): Promise<void> {
this.heard = true;
if (change.op === "delete") {
this.modules.delete(change.key);
return;
}
const repository = String((change.value as { repository?: unknown } | undefined)?.repository ?? "").trim();
try {
split(repository);
} catch {
this.log(`${INDEXER_MODULES} ${change.key} names no repository (${JSON.stringify(repository)}): no write is granted for it`);
return;
}
this.modules.set(change.key, repository);
if (this.writable.has(repository)) return;
try {
const r = await this.grant([repository]);
for (const g of r.granted) this.log(`${this.account} was given write on ${g}, which builds the registered module ${change.key}`);
for (const x of r.refused) this.log(`${this.account} could not be given write on ${x.repository}, which builds ${change.key} — ${x.why}`);
} catch (err) {
this.log(`whether ${this.account} writes ${repository} (module ${change.key}) could not be settled — ${said(err)}`);
}
}
/** Grant write on every delivered repository the account cannot write (or on those named, each of which must be
* delivered); with none named, also say which repositories it cannot write are left so, and why. */
async grant(only?: string[]): Promise<Written> {
if (!this.heard) {
throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read ` +
"(the watch is not granted or not answered); nothing is granted");
}
const delivered = this.delivered();
const result: Written = { account: this.account, delivered, granted: [], already: [], refused: [], left_read_only: [] };
const named = only && only.length ? only.map((x) => String(x).trim()) : null;
const targets: string[] = [];
for (const full of named ?? delivered) {
split(full);
if (delivered.includes(full)) targets.push(full);
else result.left_read_only.push({ repository: full, permission: "unchanged",
why: "builds no registered module (the indexer names none from it), so the account is given no write there" });
}
for (const full of targets) {
const [owner, name] = split(full);
let permission: string;
try {
if (!(await this.forge.repository(owner, name))) {
result.refused.push({ repository: full, why: "the forge holds no repository by that name" });
continue;
}
permission = await this.forge.repositoryPermission(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: `its permission could not be read — ${said(err)}` });
continue;
}
if (WRITES.has(permission)) {
this.writable.add(full);
result.already.push(full);
continue;
}
if (owner === this.org) {
// The organisation's repositories are written through its team (tools-account.ts), never a collaborator.
result.refused.push({ repository: full, why: `a repository of ${this.org}, which ${this.account} writes through the ` +
`team ${this.org}/${this.account}; the forge gives it "${permission}", so the team is not as it should be (it is ` +
"set again at the next mint of the tools' token)" });
continue;
}
try {
await this.forge.addWriteCollaborator(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: said(err) });
continue;
}
let after: string;
try {
after = await this.forge.repositoryPermission(owner, name, this.account);
} catch (err) {
result.refused.push({ repository: full, why: `granted, but its permission could not be read back — ${said(err)}` });
continue;
}
if (!WRITES.has(after)) {
result.refused.push({ repository: full, why: `the grant was answered, but the forge still gives ${this.account} "${after}"` });
continue;
}
this.writable.add(full);
result.granted.push(full);
await this.emit(WRITABLE_EVENT, { owner, repo: name, account: this.account }).catch((err) =>
this.log(`${this.account} writes ${full} now, and that could not be said on the bus — ${said(err)}`));
}
if (!named) {
for (const repo of await this.forge.everyRepository()) {
if (repo.owner === this.org || delivered.includes(repo.full_name)) continue;
let permission: string;
try {
permission = await this.forge.repositoryPermission(repo.owner, repo.name, this.account);
} catch (err) {
permission = `unknown — ${said(err)}`;
}
if (WRITES.has(permission)) continue;
result.left_read_only.push({ repository: repo.full_name, permission,
why: "builds no registered module (the indexer names none from it), so the account is given no write there" });
}
}
return result;
}
/** Every delivered repository the account cannot write, as the forge says now, and why. */
async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[] }> {
if (!this.heard) throw new Error(`which repositories build a registered module is not known yet: ${INDEXER_MODULES} has not been read`);
const delivered = this.delivered();
const out: { repository: string; permission: string; why: string }[] = [];
for (const full of delivered) {
const [owner, name] = split(full);
let permission: string;
try {
permission = await this.forge.repositoryPermission(owner, name, this.account);
} catch (err) {
out.push({ repository: full, permission: "unknown", why: `the forge did not answer ${this.account}'s permission on it — ${said(err)}` });
continue;
}
if (WRITES.has(permission)) continue;
const modules = [...this.modules].filter(([, r]) => r === full).map(([m]) => m).sort();
out.push({ repository: full, permission, why: `builds ${modules.join(", ")}, and the forge gives ${this.account} ` +
`"${permission}" on it: its commit status and its merge are refused until gitea_grant_write grants it` });
}
return { account: this.account, delivered: delivered.length, unwritable: out };
}
}
type Tool = { name: string; description: string; input: Record<string, unknown>; run: (args: Readonly<Record<string, unknown>>) => Promise<unknown> };
/** The two tools: which delivered repositories the merging account cannot write, and the grant now. */
export function writeTools(writes: () => DeliveredWrites): Tool[] {
return [
{
name: "gitea_unwritable_repos",
description: "Every repository that builds a registered module (the indexer's modules, read over the bus) on which " +
"the mesh's merging forge account (mesh-tools, which sets commit statuses and merges) cannot write, with the " +
"permission the forge gives it there and why: such a pull request can be checked but never merged, and the " +
"delivery gate does not ask its Approve. Empty when it writes every one. Read with the site administrator's login.",
input: {},
run: async () => writes().unwritable(),
},
{
name: "gitea_grant_write",
description: "Give the mesh's merging forge account (mesh-tools) write, as a collaborator, on every repository that " +
"builds a registered module and that it cannot write now, or on the repositories named (each must build one). " +
"The module does this itself at its start and when a module of a new repository is registered; this is the same " +
"act by hand, such as when a merge or a commit status was refused 403. A repository of the organisation novox " +
"is written through its team and needs nothing. Answers what was granted (each read back from the forge), what " +
"was written already, what the forge refused in its own words, and which other repositories it left read-only " +
"and why (they build no registered module). Needs the site administrator's login, used for this grant alone.",
input: {
repositories: { type: "array", items: { type: "string" }, description: "owner/name of each repository; empty or absent, every delivered one" },
},
run: async (args) => writes().grant(Array.isArray(args.repositories) ? args.repositories.map(String) : undefined),
},
];
}
@@ -14,6 +14,7 @@ var Kept = []announce.State{
{Name: "toolchains", Readable: true},
{Name: "keys", Readable: true},
{Name: "steps", Readable: true},
{Name: "pauses", Readable: true},
}
// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves.
@@ -43,6 +43,7 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps {
return delivery.ToolchainUsed{Version: installed, Recipe: m.Recipe.Version()}, nil
},
Kept: m.KeptLookup,
Has: m.Has,
Release: func(ws string) error { return m.spaces().Release(ws) },
Logf: m.Say,
}
@@ -80,19 +81,23 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
source = buildstep.SeatAsks{Language: delivery.AskGo}
}
capacity := m.Capacity
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
var watch func(string, func(stdio.ProvisionChange) error) error
if capacity == nil {
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed
}
ask := m.askSteps(steps)
var load func(string) ([]byte, error)
if m.ReadSteps.Get != nil {
if m.ReadPauses.Get != nil {
load = func(key string) ([]byte, error) {
raw, err := m.ReadSteps.Get(key)
raw, err := m.ReadPauses.Get(key)
return raw, err
}
}
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, Load: load,
Capacity: capacity, Logf: m.Say,
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, WatchConfig: watch, Logf: m.Say, Has: m.Has,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil {
return nil, err
@@ -111,3 +116,11 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool {
return buildstep.SeatTools(Seat, "Go", p)
}
// StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes.
const StepEndTool = "build_go_step_end"
// stepScan is the module's steps state as buildstep reads it.
func (m *Module) stepScan() buildstep.Scan {
return buildstep.ScanOf(stdio.State(delivery.StepsState))
}
@@ -183,12 +183,12 @@ func (m *Module) buildEnv(dir string) []string {
} else if modfile, ok := m.pinModfile(dir); ok {
mod += " -modfile=" + modfile // a pin on a pull request's head the fetch placed beside the workspace (pins.go)
}
return []string{
return append([]string{
"CGO_ENABLED=0",
"GOFLAGS=" + recipeFlags + " " + mod,
"GOTOOLCHAIN=local",
"GOMODCACHE=" + filepath.Join(m.Cache, "gomod"),
"GOCACHE=" + filepath.Join(m.Cache, "gobuild"),
"GOPROXY=off",
}
}, m.threadEnv()...)
}
@@ -13,6 +13,8 @@ import (
"path/filepath"
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// The runners of a digest or test step: this installed program, or the candidate's compiled from the workspace.
@@ -108,7 +110,7 @@ func (m *Module) mergeCommit(ctx context.Context, workspace string) (string, err
r := m.Run(ctx, nil, "git", "-C", filepath.Join(m.Work, workspace, "tree"), "rev-parse", "HEAD")
commit := strings.TrimSpace(r.Stdout)
if r.Status != 0 || r.Err != "" || commit == "" {
return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", firstLine(r.Err+"\n"+r.Stderr))
return "", fmt.Errorf("the workspace's commit: git rev-parse: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
}
return commit, nil
}
@@ -0,0 +1,36 @@
package main
import (
"git.novox.be/novox/mesh-sdk/go/buildcpu"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The processors a step may take (novox/hq ADR 0553, issue 1806). The node-engine holds this module's runtime, with
// every other build module's of the node, in one slice at idle priority under the node's one share of its processors:
// that quota bounds the total. Threads only size each step: the share the composer gives (buildstep.NodeLimits) over
// this module's own capacity, so a step does not wait in the kernel's throttle. Without the composer's share there is
// no slice to size a step to, and no thread limit is set.
// cpuLimits is what one step is held to now.
func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1
switch {
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
return buildstep.NodeLimits(slots)
}
// threadEnv is the environment (nothing without a share) every go command of a step is given for its threads: GOMAXPROCS sets its -p, the compiler's
// concurrency, a test binary's processors and -parallel.
func (m *Module) threadEnv() []string {
return buildcpu.Env(m.cpuLimits().Threads)
}
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// to (its unit and its slice), read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any {
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
}
@@ -11,6 +11,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
@@ -19,6 +20,8 @@ import (
"sort"
"strconv"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// DigestVersion is the form of the digest's text: changed whenever what the digest hashes changes in kind.
@@ -39,6 +42,33 @@ const besideVariable = "MESH_CHECK_BESIDE"
// test log) with each verdict.
const ReadsOutsideMarker = "reads-outside"
// NeedsMarker is the name of a file in a package's directory that names, one per line, what its integration tests need
// of the node they run on (issue 890; mesh-sdk delivery.AskNeeds): `node-build-node` for tests that read the Node build
// module's installed toolchain. The digest reports it (DigestResult.Needs); a check ask leaves such a package untested on
// a node that lacks it, and the checker asks it again of a node that has it. A marker beside the tests, as
// ReadsOutsideMarker is, and not a manifest field: it is a property of the package's tests, read only where they are
// run, and the manifest describes the module on the machines. The unit level is never given anything of the node (ADR
// 0533 §1), so a marker binds the integration level alone. Blank lines and lines starting with # are not read.
const NeedsMarker = "check-needs"
// readNeeds is what the marker in dir names; nil when there is none.
func readNeeds(dir string) ([]string, error) {
raw, err := os.ReadFile(filepath.Join(dir, NeedsMarker))
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var needs []string
for _, l := range strings.Split(string(raw), "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
needs = append(needs, l)
}
}
return needs, nil
}
// integrationTags are the build tags of the integration level, its run and its digest alike: integration, and isolation
// for the bubblewrap proofs (mesh-sdk's and the build modules'), which a build node can run.
var integrationTags = []string{"integration", "isolation"}
@@ -101,6 +131,8 @@ type DigestResult struct {
// ReadsOutside names the packages that carry the marker: each has a digest that no kept verdict can have (a fresh
// random one), so the check runs it every time.
ReadsOutside []string `json:"reads_outside,omitempty"`
// Needs are the packages whose tests at this level need something of the node, as their NeedsMarker names it.
Needs []delivery.PackageNeeds `json:"needs,omitempty"`
// JudgedBy is the program that ran the digest (ADR 0535 §5): "installed", or "candidate <merge commit>" under the
// runner workspace.
JudgedBy string `json:"judged_by,omitempty"`
@@ -206,12 +238,13 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string) (Diges
if err != nil {
return DigestResult{}, err
}
if err := digestModule(&res, pkgs, tree, header, besideHeader); err != nil {
if err := digestModule(&res, pkgs, tree, header, besideHeader, level == LevelIntegration); err != nil {
return DigestResult{}, err
}
}
sort.Strings(res.WithoutTests)
sort.Strings(res.ReadsOutside)
sort.Slice(res.Needs, func(i, j int) bool { return res.Needs[i].Package < res.Needs[j].Package })
for _, p := range res.ReadsOutside {
if m.Say != nil {
m.Say("the %s level of %s always runs: its directory carries %s, so its tests read files outside it that no digest names", level, p, ReadsOutsideMarker)
@@ -256,7 +289,7 @@ func parsePackages(out string) (map[string]*goPackage, error) {
// digestModule adds to res the digest of each root package of one go.mod directory. A root is a package of the main
// module that is listed, not only a dependency, and not a variant of another.
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside []string) error {
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside []string, needs bool) error {
roots := []string{}
for path, p := range pkgs {
if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main {
@@ -274,6 +307,15 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
if testMain == nil {
return fmt.Errorf("go list gave no test binary for %s", path)
}
if needs {
n, err := readNeeds(root.Dir)
if err != nil {
return fmt.Errorf("%s: its %s: %w", path, NeedsMarker, err)
}
if len(n) > 0 {
res.Needs = append(res.Needs, delivery.PackageNeeds{Package: path, Needs: n})
}
}
if _, err := os.Stat(filepath.Join(root.Dir, ReadsOutsideMarker)); err == nil {
// The digest of a package that reads outside itself is one nothing can have kept: the key is a fresh random
// hex, so the ask finds no kept verdict and runs it, and what the checker writes under it is never read again.
@@ -213,6 +213,16 @@ func TestAPackageWithTheReadsOutsideMarkerIsNeverKept(t *testing.T) {
if first.Packages[a] != plain.Packages[a] || first.Packages[a] != second.Packages[a] {
t.Errorf("a has no marker and its digest moved: %s, %s, before %s", first.Packages[a], second.Packages[a], plain.Packages[a])
}
// Issue 890/AC3: a package whose directory carries the needs marker is named with its needs at the integration level,
// comments and blank lines not read, and at the unit level not at all.
edit(t, tree, "a/"+NeedsMarker, "# its integration tests copy the installed Node toolchain\n\nnode-build-node\n")
if n := digests(t, m, LevelIntegration).Needs; len(n) != 1 || n[0].Package != a || strings.Join(n[0].Needs, ",") != "node-build-node" {
t.Errorf("the integration level's needs = %+v, want %s needing node-build-node", n, a)
}
if n := digests(t, m, LevelUnit).Needs; len(n) != 0 {
t.Errorf("the unit level names needs: %+v", n)
}
}
// Issue 1935: a catalogue module's packages read ../../module.json and the module's files: a change to them changes the
@@ -1,112 +0,0 @@
package main
import (
"encoding/json"
"os"
"strings"
"testing"
)
// A failed test keeps the lines that say why it failed, not the lines it logged first (issue 1901). The fixture is
// the real `go test -json` of a test that logs a line and then fails with a multi-line t.Fatalf.
func TestAFailedTestKeepsItsFatalMessageAfterItsLogLines(t *testing.T) {
raw, err := os.ReadFile("testdata/captured/go-test-json-log-then-fatal.jsonl")
if err != nil {
t.Fatal(err)
}
res := &TestResult{}
rd := newReader(res, ".")
rd.read(string(raw))
rd.finish()
if len(res.Failed) != 1 {
t.Fatalf("failures = %+v, want one", res.Failed)
}
f := res.Failed[0]
for _, want := range []string{"3 subscription(s) derive to a subject no emitter owns:", "a.b.c", "d.e.f", "g.h.i"} {
if !strings.Contains(f.Output, want) {
t.Errorf("1901/AC1: output lacks %q: %q", want, f.Output)
}
}
if want := "3 subscription(s) derive to a subject no emitter owns:"; f.Cause != want && !strings.HasSuffix(f.Cause, want) {
t.Errorf("1901/AC1: cause = %q, want the t.Fatalf line, not the t.Logf line", f.Cause)
}
}
// A failure longer than the bound keeps its last lines, where the cause is, and says how many earlier lines it dropped.
func TestAFailureLongerThanTheBoundKeepsItsLastLines(t *testing.T) {
var lines []string
for i := 0; i < 400; i++ {
lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40))
}
lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)")
out := clip(lines, "")
if len(out) > outputBytes+100 {
t.Errorf("1901/AC1: kept %d bytes, bound is %d", len(out), outputBytes)
}
if !strings.HasSuffix(out, "the real cause\n--- FAIL: TestX (0.00s)") || !strings.Contains(out, "\n... (") {
t.Errorf("1901/AC1: out = %q...%q", out[:40], out[len(out)-60:])
}
}
// An older checker, which knows no "cause" field, still reads the new build module's answer, and the output it gets
// holds at least what the older build module kept (its first lines, for an output shorter than the bound).
func TestAnOlderCheckerReadsTheNewFailureAndGetsAtLeastWhatItKept(t *testing.T) { // 1901/AC3
raw, err := os.ReadFile("testdata/captured/go-test-json-log-then-fatal.jsonl")
if err != nil {
t.Fatal(err)
}
res := &TestResult{}
rd := newReader(res, ".")
rd.read(string(raw))
rd.finish()
b, err := json.Marshal(res.Failed)
if err != nil {
t.Fatal(err)
}
var old []struct {
Package string `json:"package"`
Test string `json:"test,omitempty"`
Output string `json:"output,omitempty"`
}
if err := json.Unmarshal(b, &old); err != nil || len(old) != 1 {
t.Fatalf("1901/AC3: an older checker refused the answer: %v, %d failures", err, len(old))
}
first := strings.SplitN(old[0].Output, "\n", 2)[0]
if first == "" || !strings.Contains(old[0].Output, "judged against") || !strings.Contains(old[0].Output, "3 subscription(s)") {
t.Errorf("1901/AC3: older checker's output = %q", old[0].Output)
}
}
// A long failure, whose earlier lines are left out, still starts with a real line for an older checker, which keeps only the
// first non-empty line of the output cut to 120 characters, and not with the marker.
func TestALongFailureStillStartsWithARealLineForAnOlderChecker(t *testing.T) { // 1901/AC3
var lines []string
for i := 0; i < 400; i++ {
lines = append(lines, " x_test.go:1: log line "+strings.Repeat("y", 40))
}
lines = append(lines, " x_test.go:9: the real cause", "--- FAIL: TestX (0.00s)")
oldFirst := func(out string) string { // the older checker's rule (judge.go firstLine)
for _, l := range strings.Split(out, "\n") {
if l = strings.TrimSpace(l); l != "" {
if len(l) > 120 {
l = l[:120]
}
return l
}
}
return ""
}
for _, cause := range []string{"the real cause", ""} {
out := clip(lines, cause)
got := oldFirst(out)
if strings.HasPrefix(got, "... (") || got == "" {
t.Errorf("1901/AC3: cause %q: an older checker would read %q", cause, got)
}
if cause != "" && got != cause {
t.Errorf("1901/AC3: older checker reads %q, want the cause", got)
}
if !strings.HasSuffix(out, "--- FAIL: TestX (0.00s)") {
t.Errorf("1901/AC3: the last lines are lost: %q", out[len(out)-40:])
}
}
}
@@ -18,6 +18,7 @@ import (
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
@@ -255,7 +256,7 @@ func (m *Module) downloadAndVerify(ctx context.Context, dir, d string) error {
func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) {
r := m.Run(ctx, nil, "git", "-C", tree, "ls-files", "--", "go.mod", "*/go.mod")
if r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
return nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
}
dirs := []string{}
for _, l := range strings.Split(r.Stdout, "\n") {
@@ -292,7 +293,7 @@ func (m *Module) goEnv() []string {
if m.GoModCache != "" {
modcache = m.GoModCache
}
return []string{
return append([]string{
"GOTOOLCHAIN=local",
"GOFLAGS=-mod=mod",
"GOMODCACHE=" + modcache,
@@ -300,7 +301,7 @@ func (m *Module) goEnv() []string {
"GOPRIVATE=git.novox.be",
"GIT_TERMINAL_PROMPT=0",
"GIT_SSH_COMMAND=ssh -o BatchMode=yes",
}
}, m.threadEnv()...)
}
// goEnvFor is goEnv for a command run in dir: a directory that vendors (vendor/modules.txt) is read from its vendor
@@ -331,10 +332,11 @@ func (m *Module) clock() time.Time {
func (m *Module) now() time.Time { return m.clock() }
// causeLine is the first line of a go command's output that says something other than Go's own progress: `go:
// downloading ...` comes first when Go fetches a module, and the cause of a failure on a later line.
// downloading ...` comes first when Go fetches a module, and `# <package>` heads a compile error of go build or go vet
// (issue 1703: the reason said only the package), and the cause of a failure on a later line.
func causeLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "go: downloading ") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "go: downloading ") && !strings.HasPrefix(l, "# ") {
return l
}
}
@@ -302,3 +302,16 @@ func TestWorkAndCacheAreTheDirectoriesTheManifestPlacesOutsideTheHome(t *testing
}
}
}
// issue 1703: a go command's reason is its cause, not Go's progress nor go build's `# <package>` heading.
func TestCauseLineSkipsProgressAndThePackageHeading(t *testing.T) {
for in, want := range map[string]string{
"go: downloading x v1\n# git.example/m/cmd\n./main.go:3:2: undefined: y\n": "./main.go:3:2: undefined: y",
"\ngo: example.com/x@v1: reading: 404\n": "go: example.com/x@v1: reading: 404",
"go: downloading x v1\n": "go: downloading x v1",
} {
if got := causeLine(in); got != want {
t.Errorf("causeLine(%q) = %q, want %q", in, got, want)
}
}
}
@@ -5,10 +5,13 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildcpu"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"git.novox.be/novox/mesh-sdk/go/health"
@@ -28,6 +31,7 @@ func toolchainValues(env []string) []string {
// 1572/AC1 (ADR 0477 §1): every environment a step gives its go commands says GOTOOLCHAIN=local, once, and names no
// release.
func TestEveryStepEnvironmentSaysGotoolchainLocal(t *testing.T) {
t.Setenv("MESH_BUILD_CPU_SHARE", "50")
m := &Module{Cache: t.TempDir()}
vend := t.TempDir()
os.MkdirAll(filepath.Join(vend, "vendor"), 0o755)
@@ -41,6 +45,24 @@ func TestEveryStepEnvironmentSaysGotoolchainLocal(t *testing.T) {
if got := toolchainValues(env); len(got) != 1 || got[0] != "GOTOOLCHAIN=local" {
t.Errorf("%s: GOTOOLCHAIN settings %v, want exactly GOTOOLCHAIN=local", name, got)
}
// Issue 1806: and its threads, its part of the node's processor cap (probeEnv runs no build).
if procs := strings.Count(strings.Join(env, "\n"), "GOMAXPROCS="); name != "probeEnv" && procs != 1 {
t.Errorf("%s: GOMAXPROCS said %d times, want once: %v", name, procs, env)
}
}
// Without the composer's share no thread limit; with it, the share over its own three slots (ADR 0553 §4).
t.Setenv("MESH_BUILD_CPU_SHARE", "")
m = &Module{Cache: t.TempDir(), Node: "shanks", Capacity: func() int { return 3 }}
if l := m.cpuLimits(); l.Threads != 0 || strings.Contains(strings.Join(m.goEnv(), " "), "GOMAXPROCS=") {
t.Errorf("without the share: %+v, goEnv %v", l, m.goEnv())
}
t.Setenv("MESH_BUILD_CPU_SHARE", "50")
want := buildcpu.Threads(50, runtime.NumCPU(), 3)
if l := m.cpuLimits(); l.Slots != 3 || !strings.Contains(strings.Join(m.goEnv(), " "), "GOMAXPROCS="+strconv.Itoa(want)) {
t.Errorf("limits %+v, goEnv %v; want %d threads", l, m.goEnv(), want)
}
if st := m.cpuStatus(); st["limits"].(buildcpu.Limits) != m.cpuLimits() {
t.Errorf("the status says %v", st)
}
}
@@ -40,7 +40,7 @@ func (m *Module) isolation(workspace string, write bool) buildstep.Isolation {
Workspace: filepath.Join(m.Work, workspace),
Caches: m.caches(),
WriteCaches: write,
Env: []string{"PATH=" + path, "LC_ALL=C"},
Env: append([]string{"PATH=" + path, "LC_ALL=C"}, m.threadEnv()...),
}
}
@@ -0,0 +1,22 @@
package main
import (
"testing"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/buildstep/conformance"
)
// 1901/AC7: the module's own parse path (readGoTest, then the level's judging) keeps the failure contract on every captured
// go test -json case of the SDK's conformance kit, and its result names the module that sent it.
func TestTheTestStepKeepsTheFailureContract(t *testing.T) {
conformance.Run(t, conformance.GoTest, func(o conformance.Output) []buildstep.Failure {
res := &TestResult{Level: LevelUnit, By: buildstep.By("mesh-build-go")}
readGoTest(res, ".", o.Stdout)
res.judge(Bounds{Run: UnitBound})
if res.Passed {
t.Errorf("a failed run judged passed: %+v", res)
}
return res.Failed
})
}
@@ -11,11 +11,13 @@ import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// levelArg is the first argument that makes this binary a step's shut-off process: the integration level's, and, when
@@ -140,6 +142,49 @@ func (m *Module) nodeToolchains() []string {
return []string{dir}
}
// nodeToolchainMark is the file the Node build module writes in a toolchain directory once it is installed whole
// (mesh-build-node toolchain.go installedMark).
const nodeToolchainMark = ".installed"
// Has says whether this node has a need a check ask or a package's tests name (issue 890, ADR 0552; mesh-sdk
// delivery.AskNeeds), read from the node itself at every ask: node-build-node when the Node build module's toolchains
// directory, given to the integration level as nodeToolchains gives it, holds a toolchain installed whole (its mark and
// its package-lock.json readable by this account), and node, npm and bwrap are installed: what mesh-build-node's
// integration tests (realMachine) look for before they run. Any other need it lacks.
func (m *Module) Has(need string) bool {
if m.HasNeed != nil {
return m.HasNeed(need)
}
switch need {
case delivery.NeedNodeBuildNode:
dirs := m.nodeToolchains()
if len(dirs) == 0 {
return false
}
for _, p := range []string{"node", "npm", "bwrap"} {
if _, err := exec.LookPath(p); err != nil {
return false
}
}
entries, err := os.ReadDir(dirs[0])
if err != nil {
return false
}
for _, e := range entries {
dir := filepath.Join(dirs[0], e.Name())
if _, err := os.Stat(filepath.Join(dir, nodeToolchainMark)); err != nil {
continue
}
if f, err := os.Open(filepath.Join(dir, "package-lock.json")); err == nil {
f.Close()
return true
}
}
return false
}
return false
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
return strings.TrimSpace(lines[len(lines)-1])
+25 -210
View File
@@ -6,13 +6,12 @@ package main
// step that could not run.
import (
"bufio"
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The two levels (ADR 0445 §2).
@@ -31,33 +30,14 @@ type Bounds struct {
Setup, Run time.Duration
}
// PackageResult is one package's part of a level.
type PackageResult struct {
Dir string `json:"dir"`
Package string `json:"package"`
Passed bool `json:"passed"`
Elapsed float64 `json:"elapsed"`
Tests int `json:"tests"`
// NoTests is a package with no test files (at this level: a file with the other level's build tag is none).
NoTests bool `json:"no_tests,omitempty"`
}
// TestTime is one test and the time it took.
type TestTime struct {
Package string `json:"package"`
Test string `json:"test"`
Elapsed float64 `json:"elapsed"`
}
// Failure is a test that failed, or a package that failed with no test to name (a build that failed, a TestMain that
// panicked); Output is the last lines it wrote, up to outputBytes (issue 1901), and Cause the first line of what the
// test reported as its error (t.Error, t.Fatal), without its file and line, when go test said which lines those were.
type Failure struct {
Package string `json:"package"`
Test string `json:"test,omitempty"`
Output string `json:"output,omitempty"`
Cause string `json:"cause,omitempty"`
}
// PackageResult, TestTime and Failure are the SDK's failure contract (buildstep, issue 1901), with the JSON names this
// module always sent: a Failure names its test, its file, its cause (never empty, never a passing line) and its own
// output up to 4096 bytes, cause first.
type (
PackageResult = buildstep.GoPackage
TestTime = buildstep.TestTime
Failure = buildstep.Failure
)
// TestResult is what a test step passes with.
type TestResult struct {
@@ -91,188 +71,23 @@ type TestResult struct {
// JudgedBy is the program that ran the level (ADR 0535 §5): "installed", or "candidate <merge commit>" under the
// runner workspace.
JudgedBy string `json:"judged_by,omitempty"`
// By is the build module and the SDK it was built with (buildstep.By): the checker names it when a failure hides
// its cause.
By string `json:"by,omitempty"`
}
// event is one line of `go test -json` (test2json, and the build events go test adds).
type event struct {
Action string
Package string
Test string
Output string
OutputType string // "error" for a t.Error or t.Fatal message, "error-continue" for its further lines (go test -json)
ImportPath string
FailedBuild string
Elapsed float64
}
// outputBytes is the most a failure keeps of its output, and outputLineBytes the most of one line. **A failure keeps its
// last lines** (issue 1901): a test logs before it fails, so the lines that say why are at the end, and the first
// fifteen lines kept before lost them (the verdict of group r4g kept a t.Logf line, not the t.Fatalf after it). Four
// kilobytes holds a long assertion message and stays far under a bus message's or a verdict's bound.
const (
outputBytes = 4096
outputLineBytes = 1024
)
// reader collects the events of one go.mod directory's `go test -json` into a result.
type reader struct {
res *TestResult
dir string
pkgs map[string]*PackageResult
order []string
builds map[string][]string // build output by import path
pkgOut map[string][]string
testOut map[string][]string // by package + "\x00" + test
causes map[string]string // the first error line of a test, by the same key
times []TestTime
failedIn map[string]bool // packages with a failed test
}
func newReader(res *TestResult, dir string) *reader {
return &reader{res: res, dir: dir, pkgs: map[string]*PackageResult{}, builds: map[string][]string{},
pkgOut: map[string][]string{}, testOut: map[string][]string{}, causes: map[string]string{}, failedIn: map[string]bool{}}
}
// read takes go test's output. Lines that are not events are kept for a failure that has nothing else to say.
func (r *reader) read(out string) (noise []string) {
sc := bufio.NewScanner(strings.NewReader(out))
sc.Buffer(make([]byte, 1<<20), 1<<26)
for sc.Scan() {
var ev event
if err := json.Unmarshal(sc.Bytes(), &ev); err != nil {
if l := strings.TrimSpace(sc.Text()); l != "" {
noise = append(noise, l)
}
continue
}
r.event(ev)
}
return noise
}
func (r *reader) pkg(name string) *PackageResult {
p := r.pkgs[name]
if p == nil {
p = &PackageResult{Dir: r.dir, Package: name}
r.pkgs[name] = p
r.order = append(r.order, name)
}
return p
}
func keep(lines []string, out string) []string {
if t := strings.TrimRight(out, "\r\n"); t != "" && !strings.HasPrefix(t, "=== ") {
lines = append(lines, t)
}
return lines
}
func (r *reader) event(ev event) {
switch {
case ev.Package == "" && ev.ImportPath != "":
if ev.Action == "build-output" {
r.builds[ev.ImportPath] = keep(r.builds[ev.ImportPath], ev.Output)
}
case ev.Package == "":
case ev.Test == "":
p := r.pkg(ev.Package)
switch ev.Action {
case "output":
r.pkgOut[ev.Package] = keep(r.pkgOut[ev.Package], ev.Output)
case "pass":
p.Passed, p.Elapsed = true, ev.Elapsed
case "fail":
p.Passed, p.Elapsed = false, ev.Elapsed
if ev.FailedBuild != "" {
r.pkgOut[ev.Package] = append(r.builds[ev.FailedBuild], r.pkgOut[ev.Package]...)
}
case "skip":
p.Passed, p.NoTests = true, true
}
default:
key := ev.Package + "\x00" + ev.Test
switch ev.Action {
case "output":
r.testOut[key] = keep(r.testOut[key], ev.Output)
if ev.OutputType == "error" && r.causes[key] == "" {
r.causes[key] = errorWords(ev.Output)
}
case "pass", "fail", "skip":
p := r.pkg(ev.Package)
p.Tests++
r.res.Tests++
if ev.Action == "skip" {
r.res.Skipped++
}
r.times = append(r.times, TestTime{Package: ev.Package, Test: ev.Test, Elapsed: ev.Elapsed})
if ev.Action == "fail" {
r.failedIn[ev.Package] = true
r.res.Failed = append(r.res.Failed, Failure{Package: ev.Package, Test: ev.Test, Output: clip(r.testOut[key], r.causes[key]), Cause: r.causes[key]})
}
}
}
}
// clip joins the last lines of a failure's output that fit outputBytes, each cut to outputLineBytes, and says how many
// earlier lines it left out. **When it leaves lines out, its first line is still a real one** (issue 1901/AC3): the failure's
// cause, or failing that the first non-empty line of the output, then the marker. An older checker takes only the first
// non-empty line of the output, and a marker there would be less than the older pair kept.
func clip(lines []string, cause string) string {
size, from := 0, len(lines)
for from > 0 {
n := len(cutLine(lines[from-1])) + 1
if size+n > outputBytes && from < len(lines) {
break
}
size += n
from--
}
kept := make([]string, 0, len(lines)-from+1)
if from > 0 {
head := strings.TrimSpace(cause)
for i := 0; head == "" && i < from; i++ {
head = strings.TrimSpace(lines[i])
}
if head != "" {
kept = append(kept, cutLine(head))
}
kept = append(kept, fmt.Sprintf("... (%d earlier lines)", from))
}
for _, l := range lines[from:] {
kept = append(kept, cutLine(l))
}
return strings.Join(kept, "\n")
}
// cutLine cuts a line to outputLineBytes, on a character boundary.
func cutLine(l string) string {
if len(l) <= outputLineBytes {
return l
}
return strings.ToValidUTF8(l[:outputLineBytes], "") + "..."
}
var fileAndLine = regexp.MustCompile(`^\S+\.go:\d+: `)
// errorWords is the words of an error line of a test: trimmed, without the file and line go test put before them.
func errorWords(out string) string {
return strings.TrimSpace(fileAndLine.ReplaceAllString(strings.TrimSpace(out), ""))
}
// finish moves what was read into the result: the packages in order, a failure for each failed package that has no
// failed test to name it, and the tests' times.
func (r *reader) finish() {
for _, name := range r.order {
p := r.pkgs[name]
if !p.Passed && !r.failedIn[name] {
r.res.Failed = append(r.res.Failed, Failure{Package: name, Output: clip(r.pkgOut[name], "")})
}
if !p.NoTests && p.Elapsed > r.res.Elapsed {
r.res.Elapsed = p.Elapsed
}
r.res.Packages = append(r.res.Packages, *p)
}
r.res.Slowest = append(r.res.Slowest, r.times...)
// readGoTest reads one go.mod directory's `go test -json` into res through the SDK's reader (issue 1901: one failure
// contract for every build module, buildstep.Failure), and answers the lines that were no event and how many packages
// it named.
func readGoTest(res *TestResult, dir, out string) (noise []string, packages int) {
read := buildstep.ReadGoTest(out, dir)
res.Tests += read.Tests
res.Skipped += read.Skipped
res.Failed = append(res.Failed, read.Failed...)
res.Packages = append(res.Packages, read.Packages...)
res.Slowest = append(res.Slowest, read.Times...)
res.Elapsed = max(res.Elapsed, read.Elapsed)
return read.Noise, len(read.Packages)
}
// judge sets Passed and Why from what was read and the bounds. A level with no failure, over no bound, passes; with no
@@ -14,6 +14,7 @@ import (
"strings"
"sync"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -103,8 +104,7 @@ func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string,
}
var failed []Failure
if len(files) > 0 {
failed = append(failed, Failure{Package: d, Test: "gofmt",
Output: "not as gofmt writes them (run gofmt -w): " + strings.Join(files, ", ")})
failed = append(failed, buildstep.ConsoleFailure(d, "gofmt", "not as gofmt writes them (run gofmt -w): "+strings.Join(files, ", ")))
}
sets := [][]string{nil}
if len(tags) > 0 {
@@ -123,7 +123,7 @@ func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string,
return nil, fmt.Errorf("%s in %s could not be run: %s", name, d, firstLine(r.Err+"\n"+r.Stderr))
}
if r.Status != 0 {
failed = append(failed, Failure{Package: d, Test: name, Output: strings.TrimSpace(r.Stderr + "\n" + r.Stdout)})
failed = append(failed, buildstep.ConsoleFailure(d, name, r.Stderr+"\n"+r.Stdout))
}
}
return failed, nil
@@ -8,9 +8,11 @@ package main
import (
"context"
"fmt"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"os"
"os/user"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -40,6 +42,8 @@ func main() {
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
Steps: stdio.State(delivery.StepsState).Put,
ReadSteps: readOf(stdio.State(delivery.StepsState)),
Pauses: stdio.State(delivery.PausesState).Put,
ReadPauses: readOf(stdio.State(delivery.PausesState)),
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
Ctx: ctx,
Say: logf,
@@ -68,7 +72,13 @@ func main() {
os.Exit(1)
}
steps := m.StepTools()
booted := time.Now()
puller := m.Puller(steps, nil)
m.Held = puller
if m.Node != "" {
// A step a previous process left running is ended (issue 1890); this process's own steps start after booted.
go buildstep.EndStaleAtStart(ctx, m.stepScan(), m.Node, booted, logf)
}
if m.Node != "" {
// The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again.
go func() { _ = puller.Pull(ctx) }()
@@ -92,9 +92,13 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes
states := []string{}
for _, s := range m.State {
states = append(states, s.Name)
// A step's key is per step and dropped after its age, so the bucket cannot fill; a pause has no age.
if (s.Name == "steps") != (s.TTLSeconds > 0) {
t.Errorf("state %s has ttl-seconds %d: only steps has an age", s.Name, s.TTLSeconds)
}
}
slices.Sort(states)
if want := []string{"keys", "steps", "toolchains"}; !slices.Equal(states, want) {
if want := []string{"keys", "pauses", "steps", "toolchains"}; !slices.Equal(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if len(m.Claims) != 1 || m.Claims[0].Name != Seat {
@@ -9,6 +9,7 @@ import (
"syscall"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// mirrorLocks hold one mutex per mirror of this process; the lock file beside the mirror holds off another process.
@@ -67,7 +68,7 @@ func (m *Module) dissociate(ctx context.Context, dir string) error {
return nil // no mirror was used
}
if r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-C", dir, "repack", "-a", "-d", "-q"); r.Status != 0 || r.Err != "" {
return fmt.Errorf("git repack in %s: %s", dir, firstLine(r.Err+" "+r.Stderr))
return fmt.Errorf("git repack in %s: %s", dir, forgekey.Said(r.Err+"\n"+r.Stderr))
}
return os.Remove(alternates)
}
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"fmt"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"os"
"os/exec"
@@ -121,20 +122,35 @@ func TestTwoUpdatesOfOneMirrorNeverRunAtOnce(t *testing.T) {
}
}
// A mirror that cannot be updated does not fail the fetch: the fetch goes on without it.
// A mirror that cannot be updated does not fail the fetch: the fetch goes on without it, and the line saying so carries
// git's own words. The forge's refusal of a repository its account cannot read came out as "git -C: error:" (ace,
// 2026-10-26: git writes a bare "error:" line before the forge's message).
func TestAMirrorThatCannotBeUpdatedLetsTheFetchGoOnWithoutIt(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n"}
f.rule = func(line string) (r runAnswer, ok bool) {
if strings.Contains(line, "mirror") && strings.Contains(line, " fetch ") {
return runAnswer{Status: 1, Stderr: "fatal: no space left\n"}, true
return runAnswer{Status: 128, Stderr: "error: \nerror: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.\nerror: \nfatal: Could not read from remote repository.\n"}, true
}
return r, false
}
m, states := fetchModule(t, f)
var mu sync.Mutex
var said []string
m.Say = func(format string, a ...any) {
mu.Lock()
defer mu.Unlock()
said = append(said, fmt.Sprintf(format, a...))
}
ws := fetchOnce(t, m, states, fetchArgs).Workspace
if _, err := os.Stat(filepath.Join(m.Work, ws, "tree", ".git", "objects", "info", "alternates")); err == nil {
t.Errorf("the workspace borrows from a mirror that is not there")
}
mu.Lock()
defer mu.Unlock()
all := strings.Join(said, "\n")
if !strings.Contains(all, "is not used: git fetch: error: User: 22:nox-builder with Key: 30:mesh:ace.mesh-build-go is not authorized to read jschoubben/car-hunter.; fatal: Could not read from remote repository.") {
t.Errorf("the mirror's line lost git's words:\n%s", all)
}
}
func sleepABit() { time.Sleep(30 * time.Millisecond) }
@@ -28,7 +28,12 @@ type Module struct {
// Toolchains and Steps are the Put of the states of those names.
Toolchains, Steps buildstep.Put
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
Held *buildstep.Puller
ReadSteps, ReadToolchainsState StateRead
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
Pauses buildstep.Put
ReadPauses StateRead
// Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of
// the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444).
Emit func(eventType string, body any) error
@@ -70,6 +75,8 @@ type Module struct {
// NodeToolchains is where the Node build module keeps its installed toolchains, bound read-only into the
// integration level (ADR 0533 §1); NodeToolchains when empty. A test points it elsewhere.
NodeToolchains string
// HasNeed answers Has in place of the node's own reading; a test's.
HasNeed func(need string) bool
// StartBus starts the throwaway bus of a run in dir and answers its address and what stops it (the module's own
// program, StartBus in bus.go, when nil).
StartBus func(ctx context.Context, dir string) (url string, stop func(), err error)
@@ -118,6 +125,7 @@ func (m *Module) Start(ctx context.Context) {
m.Say("the toolchain: %v", err)
}
m.CleanWorkspaces(time.Now())
buildstep.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890)
select {
case <-ctx.Done():
return
@@ -181,13 +189,14 @@ func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any,
}
// Status is what the module reports of itself: the node, the recipe, the toolchain found and why it refuses, if it
// does.
// does, and the processors its steps may take (issue 1806).
func (m *Module) Status(ctx context.Context) map[string]any {
err := m.Check(ctx)
installed, _ := m.Probe(ctx)
out := map[string]any{
"node": m.Node, "recipe": m.Recipe.Version(), "installed": installed,
"postgres-major": m.Recipe.PostgresMajor, "nats-server": m.Recipe.NatsServer, "refusing": err != nil,
"cpu": m.cpuStatus(),
}
if err != nil {
out["why"] = err.Error()
@@ -115,7 +115,7 @@ func TestAGoThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
{Name: "short_probe", Run: run},
}, m.StepTools()...)
for _, tool := range m.Tools(steps) {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
continue
}
var reason string
@@ -71,6 +71,47 @@ func TestANodeWithoutNodeToolchainsRunsTheLevelWithNothingMoreBound(t *testing.T
if slices.Contains(s.roBinds, missing) {
t.Errorf("a missing %s is bound: %v", missing, s.roBinds)
}
// Issue 890/AC4: such a node lacks node-build-node; one with an installed toolchain and node and npm has it; nothing
// else is had.
bin := t.TempDir()
for _, p := range []string{"node", "npm", "bwrap"} {
if err := os.WriteFile(filepath.Join(bin, p), []byte("#!/bin/sh\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", bin)
m := &Module{NodeToolchains: missing}
if m.Has(delivery.NeedNodeBuildNode) {
t.Error("a node with no Node toolchain has node-build-node")
}
installed := t.TempDir()
m.NodeToolchains = installed
if m.Has(delivery.NeedNodeBuildNode) {
t.Error("a node with an empty toolchains directory has node-build-node")
}
half := filepath.Join(installed, "0.1.0")
if err := os.Mkdir(half, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(half, "package-lock.json"), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
if m.Has(delivery.NeedNodeBuildNode) {
t.Error("a toolchain without its installed mark counts")
}
if err := os.WriteFile(filepath.Join(half, nodeToolchainMark), nil, 0o644); err != nil {
t.Fatal(err)
}
if !m.Has(delivery.NeedNodeBuildNode) || m.Has("gpu") {
t.Errorf("with a toolchain installed: node-build-node %v, gpu %v; want true, false", m.Has(delivery.NeedNodeBuildNode), m.Has("gpu"))
}
if err := os.Remove(filepath.Join(bin, "bwrap")); err != nil {
t.Fatal(err)
}
if m.Has(delivery.NeedNodeBuildNode) {
t.Error("a node with no bubblewrap installed has node-build-node")
}
}
// 1891 (ADR 0533 §1): the unit level is not given them.
@@ -15,6 +15,8 @@ import (
"path/filepath"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
const forgePrefix = "git.novox.be/novox/"
@@ -125,7 +127,7 @@ func (m *Module) placePin(ctx context.Context, root string, p pin, failure strin
repository := "novox/" + p.repo
r := m.Run(ctx, []string{"GIT_TERMINAL_PROMPT=0"}, "git", "-c", "credential.helper=", "ls-remote", m.remote(repository), "refs/pull/*/head")
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s: %s", p.module, p.short, repository, firstLine(r.Err+" "+r.Stderr))
return "", fmt.Errorf("the pin of %s at commit %s: listing the pull requests of %s: %s", p.module, p.short, repository, forgekey.Said(r.Err+"\n"+r.Stderr))
}
commit, ref := "", ""
for _, l := range strings.Split(r.Stdout, "\n") {
+99 -23
View File
@@ -7,9 +7,12 @@ package main
import (
"encoding/json"
"fmt"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -39,6 +42,53 @@ func readOf(s stdio.KeptState) StateRead {
}}
}
// MaxStateReads is the most values one read tool reads from a state in a call, and stateReadParallel how many of them
// are in flight at once. Every value is a round trip from the tool through the runtime to the bus, so a call that read
// every key of a large state one after the other outlasted the call's 30 s (issue 1827: 1164 keys, 20 MB).
const (
MaxStateReads = 1000
stateReadParallel = 16
)
// readValues reads the values of keys, stateReadParallel at a time, in the order of keys (nil for a key with none). It
// stops starting reads after the first error and returns it.
func readValues(keys []string, get func(string) (json.RawMessage, error)) ([]json.RawMessage, error) {
out := make([]json.RawMessage, len(keys))
var (
wg sync.WaitGroup
mu sync.Mutex
fail error
next = make(chan int)
)
for w := 0; w < stateReadParallel && w < len(keys); w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range next {
raw, err := get(keys[i])
mu.Lock()
if err != nil && fail == nil {
fail = err
}
mu.Unlock()
out[i] = raw
}
}()
}
for i := range keys {
mu.Lock()
stop := fail != nil
mu.Unlock()
if stop {
break
}
next <- i
}
close(next)
wg.Wait()
return out, fail
}
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
func (m *Module) stepKey(id string) (string, error) {
id = strings.TrimSpace(id)
@@ -78,29 +128,44 @@ type StepLine struct {
Started string `json:"started"`
Ended string `json:"ended,omitempty"`
Reason string `json:"reason,omitempty"`
What string `json:"what,omitempty"`
}
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero). Only
// the node's own keys (<node>.) are listed and the newest max(limit*4, 100) of them read (at most MaxStateReads): a step's
// id is base-36 milliseconds and random digits, so its string order is its start order. cut says older steps were not
// read. The values still order the lines, since an ask step's id carries the ask time.
func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) {
if limit <= 0 {
limit = 10
}
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
return nil, fmt.Errorf("the steps state cannot be read here")
return nil, false, fmt.Errorf("the steps state cannot be read here")
}
start := time.Now()
keys, err := m.ReadSteps.Keys()
if err != nil {
return nil, err
return nil, false, err
}
own := keys[:0:0]
for _, k := range keys {
if strings.HasPrefix(k, m.Node+".") {
if node, _, err := delivery.ParseStepKey(k); err == nil && node == m.Node {
own = append(own, k)
}
}
}
sort.Sort(sort.Reverse(sort.StringSlice(own)))
if n := min(max(limit*4, 100), MaxStateReads); len(own) > n {
own, cut = own[:n], true
}
raws, err := readValues(own, m.ReadSteps.Get)
log.Printf("steps read: %d of %d keys listed read in %v (cut %v)", len(own), len(keys), time.Since(start).Round(time.Millisecond), cut)
if err != nil {
return nil, cut, err
}
var steps []delivery.Step
for _, k := range keys {
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
continue
}
raw, err := m.ReadSteps.Get(k)
if err != nil {
return nil, err
}
for _, raw := range raws {
if len(raw) == 0 {
continue
}
@@ -116,13 +181,13 @@ func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
}
out := make([]StepLine, 0, len(steps))
for _, s := range steps {
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason, What: s.What}
if !s.Ended.IsZero() {
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
}
out = append(out, l)
}
return out, nil
return out, cut, nil
}
// ReadToolchains is every key of the toolchains state (<node>.<language>) with its stored value.
@@ -130,24 +195,30 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil {
return nil, fmt.Errorf("the toolchains state cannot be read here")
}
start := time.Now()
keys, err := m.ReadToolchainsState.Keys()
if err != nil {
return nil, err
}
cut := len(keys) > MaxStateReads
if cut {
keys = keys[:MaxStateReads] // a node has a key per language: far fewer in practice
}
raws, err := readValues(keys, m.ReadToolchainsState.Get)
log.Printf("toolchains read: %d keys read in %v (cut %v: keys past %d were not read)", len(keys), time.Since(start).Round(time.Millisecond), cut, MaxStateReads)
if err != nil {
return nil, err
}
out := map[string]delivery.Toolchain{}
for _, k := range keys {
raw, err := m.ReadToolchainsState.Get(k)
if err != nil {
return nil, err
}
for i, raw := range raws {
if len(raw) == 0 {
continue
}
t, err := delivery.DecodeToolchain(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
return nil, fmt.Errorf("%s: %w", keys[i], err)
}
out[k] = t
out[keys[i]] = t
}
return out, nil
}
@@ -171,8 +242,13 @@ func (m *Module) ReadTools() []stdio.Tool {
Input: map[string]any{"type": "object", "properties": map[string]any{
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
Run: func(args map[string]any) (any, error) {
steps, err := m.RecentSteps(intArg(args["limit"]))
return map[string]any{"node": m.Node, "steps": steps}, err
steps, cut, err := m.RecentSteps(intArg(args["limit"]))
ans := map[string]any{"node": m.Node, "steps": steps}
if cut {
ans["cut"] = true
ans["note"] = fmt.Sprintf("this node has more steps stored than one call reads; older steps were not read (ask for a larger limit, at most %d reads)", MaxStateReads)
}
return ans, err
}},
{Name: ToolchainsReadTool,
Description: "What the module's toolchains state holds: each key <node>.<language> with the version installed, the " +
@@ -3,8 +3,10 @@ package main
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
@@ -94,7 +96,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
putStep(t, steps, delivery.Step{ID: "a", Tool: "build_go_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)})
putStep(t, steps, delivery.Step{ID: "b", Tool: "build_go_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)})
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_go_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom"})
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_go_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom", What: "check ask, novox/x@abc"})
putStep(t, steps, delivery.Step{ID: "d", Tool: "build_go_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)})
m := readModule(steps, fakeState{})
out, err := tool(t, m, StepsReadTool)(map[string]any{})
@@ -109,7 +111,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
if !slices.Equal(ids, []string{"c", "b", "a"}) {
t.Errorf("ids = %v, want c b a", ids)
}
if lines[0].Reason != "boom" || lines[0].State != delivery.StepFailed {
if lines[0].Reason != "boom" || lines[0].What != "check ask, novox/x@abc" || lines[0].State != delivery.StepFailed {
t.Errorf("first = %+v", lines[0])
}
out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)})
@@ -145,3 +147,62 @@ func TestReadToolsAreInTheManifestAndNeedNoToolchain(t *testing.T) {
t.Errorf("the read refused: %v", err)
}
}
// 1827: on the live shape (4 nodes, 600 steps each; a steps state of 1164 keys / 20 MB timed out at 30 s) the steps tool
// lists, then reads only this node's keys, in parallel, at most MaxStateReads of them, and says when it cut.
func TestRecentStepsReadsOnlyTheNodesKeysInParallelAndBounded(t *testing.T) {
t0 := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
for _, row := range []struct {
perNode, limit int
reads int64
steps int
cut bool
}{
{20, 0, 20, 10, false},
{600, 0, 100, 10, true},
{1500, 300, MaxStateReads, 300, true},
} {
var reads, inFlight, maxFlight atomic.Int64
stored := map[string][]byte{}
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
for i := 0; i < row.perNode; i++ {
b, _ := delivery.EncodeStep(delivery.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: delivery.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)})
stored[n+".s"+fmt.Sprintf("%04d", i)] = b
}
}
m := &Module{Node: "shanks"}
m.ReadSteps = StateRead{
Keys: func() ([]string, error) {
var k []string
for key := range stored {
k = append(k, key)
}
return k, nil
},
Get: func(key string) (json.RawMessage, error) {
reads.Add(1)
now := inFlight.Add(1)
for o := maxFlight.Load(); now > o && !maxFlight.CompareAndSwap(o, now); o = maxFlight.Load() {
}
time.Sleep(time.Millisecond)
inFlight.Add(-1)
if !strings.HasPrefix(key, "shanks.") {
t.Errorf("read %s, a key of another node", key)
}
return stored[key], nil
},
}
start := time.Now()
lines, cut, err := m.RecentSteps(row.limit)
if err != nil || len(lines) != row.steps || cut != row.cut || reads.Load() != row.reads {
t.Errorf("%d a node: %d steps, cut %v, %d reads, err %v; want %d, %v, %d", row.perNode, len(lines), cut, reads.Load(), err, row.steps, row.cut, row.reads)
}
if row.cut && len(lines) > 0 && lines[0].ID != fmt.Sprintf("s%04d", row.perNode-1) {
t.Errorf("%d a node: newest answered %s", row.perNode, lines[0].ID)
}
if maxFlight.Load() < 2 {
t.Errorf("%d a node: values read one at a time", row.perNode)
}
t.Logf("%d a node: %d reads in %v (one after the other: %v)", row.perNode, reads.Load(), time.Since(start), time.Duration(reads.Load())*time.Millisecond)
}
}
@@ -15,6 +15,7 @@ import (
"sync"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -181,7 +182,7 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
if err != nil {
return nil, err
}
res := &TestResult{Level: o.level, Packages: []PackageResult{}, Setup: o.setup.Seconds(), Store: o.store, Bus: o.bus}
res := &TestResult{Level: o.level, Packages: []PackageResult{}, Setup: o.setup.Seconds(), Store: o.store, Bus: o.bus, By: buildstep.By("mesh-build-go")}
byDir, err := packagesByDir(tree, dirs, o.packages)
if err != nil {
return nil, err
@@ -194,10 +195,10 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
// The directories that hold a selected package run side by side, up to the bound, each into a result of its own;
// the results join in directory order, so the answer does not depend on which ended first (ADR 0536 §3).
type dirRun struct {
res *TestResult
rd *reader
r runAnswer
noise []string
res *TestResult
r runAnswer
noise []string
packages int
}
var todo []string
for _, d := range dirs {
@@ -252,9 +253,7 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
dr := &runs[i]
dr.res = &TestResult{}
dr.r = run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), o.env...), "go", args...)
dr.rd = newReader(dr.res, d)
dr.noise = dr.rd.read(dr.r.Stdout)
dr.rd.finish()
dr.noise, dr.packages = readGoTest(dr.res, d, dr.r.Stdout)
}()
}
wg.Wait()
@@ -263,7 +262,7 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
}
for i, d := range todo {
dr := runs[i]
if (dr.r.Status != 0 || dr.r.Err != "") && len(dr.rd.pkgs) == 0 {
if (dr.r.Status != 0 || dr.r.Err != "") && dr.packages == 0 {
// go test said nothing a test said: it could not run (a go.mod it cannot read, a toolchain it cannot find).
return nil, fmt.Errorf("go test in %s: %s", d, firstLine(dr.r.Err+"\n"+strings.Join(dr.noise, "\n")+"\n"+dr.r.Stderr))
}
@@ -25,3 +25,7 @@ The tar streams (the archives gunzipped) of the three archives above, captured t
gunzip and sha256) at origin/main 0205b8f7 on 2026-10-21: directory f8af1d94...d1b6, within 294e2bec...07fb, file
055cffc2...428e. The digests above hold the gzip bytes of go1.27.1. The first try of issue 1589 on the merge check's build node answered
other digests; the tests therefore compare the stream always and the digest only under go1.27.1.
The go-test-json-*.jsonl captures of issue 1901 (2026-10-23 and 2026-10-25) moved to mesh-sdk's conformance kit
(go/buildstep/conformance/captured/go, with their description) on 2026-10-26, with the reader they test: the build module
reads go test -json through buildstep.ReadGoTest and runs the kit (kit_test.go).
@@ -1,13 +0,0 @@
{"Action":"start","Package":"x"}
{"Action":"run","Package":"x","Test":"TestThree"}
{"Action":"output","Package":"x","Test":"TestThree","Output":"=== RUN TestThree\n","OutputType":"frame"}
{"Action":"output","Package":"x","Test":"TestThree","Output":" a_test.go:4: judged against /var/lib/mesh-build-go-work/id/beside/mesh-catalog as cloned\n"}
{"Action":"output","Package":"x","Test":"TestThree","Output":" a_test.go:5: 3 subscription(s) derive to a subject no emitter owns:\n","OutputType":"error"}
{"Action":"output","Package":"x","Test":"TestThree","Output":" a.b.c\n","OutputType":"error-continue"}
{"Action":"output","Package":"x","Test":"TestThree","Output":" d.e.f\n","OutputType":"error-continue"}
{"Action":"output","Package":"x","Test":"TestThree","Output":" g.h.i\n","OutputType":"error-continue"}
{"Action":"output","Package":"x","Test":"TestThree","Output":"--- FAIL: TestThree (0.00s)\n","OutputType":"frame"}
{"Action":"fail","Package":"x","Test":"TestThree","Elapsed":0}
{"Action":"output","Package":"x","Output":"FAIL\n","OutputType":"frame"}
{"Action":"output","Package":"x","Output":"FAIL\tx\t0.002s\n","OutputType":"frame"}
{"Action":"fail","Package":"x","Elapsed":0.002}
@@ -2,6 +2,7 @@ package main
import (
"context"
"git.novox.be/novox/mesh-sdk/go/buildstep"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -40,6 +41,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
tools = append(tools, t)
}
tools = append(tools, m.ReadTools()...)
tools = append(tools, buildstep.StepEndTool(StepEndTool, m.Node, m.stepScan(), m.Held))
tools = append(tools, m.releaseTool())
return append(tools,
stdio.Tool{Name: StatusTool,
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026191742-47a66e3c0dc8
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d h1:oJREw0ggKqJWJ5DEISSL0i2ZC5XqdXAidxo9NJ74TCc=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261024214154-bc16740feb0d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026191742-47a66e3c0dc8 h1:6OaUWIjcpXOtnu70zeqH0VEaF4oGykrxGpFkaFOzCmY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026191742-47a66e3c0dc8/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+2 -1
View File
@@ -10,7 +10,7 @@
"state": [
"toolchains",
"keys",
"steps"
{"name": "steps", "ttl-seconds": 172800}, "pauses"
],
"claims": [
{
@@ -39,6 +39,7 @@
"build_go_release",
"build_go_step",
"build_go_steps",
"build_go_step_end",
"build_go_toolchains",
"build_go_status",
"build_go_health"
@@ -13,6 +13,7 @@ import (
var Kept = []announce.State{
{Name: "keys", Readable: true},
{Name: "steps", Readable: true},
{Name: "pauses", Readable: true},
}
// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves.
@@ -37,16 +37,20 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
source = buildstep.SeatAsks{Language: delivery.AskImage}
}
capacity := m.Capacity
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
var watch func(string, func(stdio.ProvisionChange) error) error
if capacity == nil {
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed
}
ask := m.askSteps(steps)
var load func(string) ([]byte, error)
if m.ReadSteps.Get != nil {
load = func(key string) ([]byte, error) { return m.ReadSteps.Get(key) }
if m.ReadPauses.Get != nil {
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) }
}
return &buildstep.Puller{Source: source, Language: delivery.AskImage, Node: m.Node, Put: m.Steps, Load: load,
Capacity: capacity, Logf: m.Say,
return &buildstep.Puller{Source: source, Language: delivery.AskImage, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, WatchConfig: watch, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil {
return nil, err
@@ -62,3 +66,11 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool {
return buildstep.SeatTools(Seat, "image", p)
}
// StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes.
const StepEndTool = "build_image_step_end"
// stepScan is the module's steps state as buildstep reads it.
func (m *Module) stepScan() buildstep.Scan {
return buildstep.ScanOf(stdio.State(delivery.StepsState))
}
@@ -155,14 +155,18 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
}
local := localName(workspace, manifest.Module, art.Name)
argv := append(m.storeFlags(), "bud", "--isolation", "oci", "--tag", local, "--file", dockerfile)
// --network pasta: a RUN step's network is pasta's, in user space. buildah's default for a rootless RUN is a
// netavark bridge whose nftables rules cannot be applied for an account without a user session, so every RUN
// failed before it started ("setup network: netavark: nftables error"; issue 1703, reproduced with buildah 1.45.2).
argv := append(m.storeFlags(), "bud", "--isolation", "oci", "--network", "pasta", "--tag", local, "--file", dockerfile)
if art.Target != "" {
argv = append(argv, "--target", art.Target)
}
argv = append(argv, imageArgs(manifest)...)
argv = append(argv, contextDir)
if r := m.Run(ctx, m.env(), "buildah", argv...); r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("buildah bud of %s: %s", art.Name, lastLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
m.sayFailure("buildah bud of "+art.Name+" of "+manifest.Module, r)
return nil, fmt.Errorf("buildah bud of %s: %s", art.Name, cause(r))
}
defer m.remove(local)
digest, err := m.localDigest(ctx, local)
@@ -204,8 +208,11 @@ func localName(workspace, module, artifact string) string {
func (m *Module) localDigest(ctx context.Context, name string) (string, error) {
r := m.Run(ctx, m.env(), "buildah", append(m.storeFlags(), "images", "--no-trunc", "--format", "{{.Digest}}", name)...)
digest := strings.TrimSpace(r.Stdout)
if r.Status != 0 || r.Err != "" || !digestOK.MatchString(digest) {
return "", fmt.Errorf("the digest of the image %s could not be read: %s", name, lastLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("the digest of the image %s could not be read: %s", name, cause(r))
}
if !digestOK.MatchString(digest) {
return "", fmt.Errorf("the digest of the image %s could not be read: buildah images answered %q, not a digest", name, cutFront(digest, CauseBytes))
}
return digest, nil
}
@@ -216,17 +223,6 @@ var digestOK = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
func (m *Module) remove(name string) {
r := m.Run(m.Ctx, m.env(), "buildah", append(m.storeFlags(), "rmi", "--force", name)...)
if (r.Status != 0 || r.Err != "") && m.Say != nil {
m.Say("the image %s could not be removed from the store: %s", name, lastLine(r.Err+"\n"+r.Stderr))
m.Say("the image %s could not be removed from the store: %s", name, cause(r))
}
}
// lastLine is the last line of a command's output that says something: buildah's error is its last line.
func lastLine(s string) string {
lines := strings.Split(s, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if l := strings.TrimSpace(lines[i]); l != "" {
return l
}
}
return ""
}
@@ -2,6 +2,7 @@ package main
import (
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"os"
"path/filepath"
"strings"
@@ -69,6 +70,7 @@ func setup(t *testing.T, edit func(string) string) (*Module, *fake, *keptStates)
// Dockerfile of the module's own directory and that directory as the context, the target, the pinned base as a build
// argument; no mount, no credential file, a runtime directory of its own, and it answers the image's digest.
func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
t.Setenv("MESH_BUILD_CPU_SHARE", "50") // the composer's share (ADR 0553): buildah is given its threads
m, f, states := setup(t, same)
res, st := runBuild(t, m, states, nil)
if st.State != delivery.StepPassed {
@@ -83,7 +85,7 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
}
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
for _, want := range []string{
"--root " + m.Store + "/root", "--runroot " + m.Store + "/run", "--isolation oci",
"--root " + m.Store + "/root", "--runroot " + m.Store + "/run", "--isolation oci", "--network pasta",
"--file " + dir + "/Dockerfile", "--target final",
"--build-arg DEMO_BASE=docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001",
} {
@@ -110,6 +112,9 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
if !strings.Contains(env, "XDG_RUNTIME_DIR="+m.Store+"/xdg") {
t.Errorf("the environment of %q lacks the module's own runtime directory: %s", c.line, env)
}
if !strings.Contains(env, "GOMAXPROCS=") {
t.Errorf("the environment of %q gives buildah no thread limit under a share (ADR 0553): %s", c.line, env)
}
}
// The image is removed from the store once the digest is read, so the store does not grow with every build.
if len(f.called("rmi")) != 1 {
@@ -117,16 +122,50 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
}
}
// 1703c: a failing Dockerfile step fails the step, saying buildah's last line.
func TestBuildFailsWithBuildahsLastLine(t *testing.T) {
m, f, states := setup(t, same)
f.failBud = "Error: building at STEP \"RUN false\": exit status 1"
_, st := runBuild(t, m, states, nil)
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, `STEP "RUN false"`) {
t.Errorf("step = %+v", st)
// 1703c, issue 1703: a failing Dockerfile step fails the step, its reason saying buildah's error from stderr, never the
// last STEP line buildah writes on stdout; nothing is pushed.
func TestBuildFailsWithBuildahsError(t *testing.T) {
steps := "STEP 1/3: FROM docker.io/library/alpine\nSTEP 2/3: RUN apk add --no-cache ca-certificates\nfetch https://dl-cdn.alpinelinux.org/x\n"
long := strings.Repeat("x", 2*CauseBytes)
cases := map[string]struct {
ran forgekey.Ran
want []string
wantNot []string
}{
"buildah's RUN failure": {forgekey.Ran{Status: 1, Stdout: steps + "STEP 3/3: COPY . /app\n",
Stderr: "error running container: from /usr/bin/crun creating container for [/bin/sh -c apk add]: sd-bus call: Interactive authentication required.\nError: building at STEP \"RUN apk add --no-cache ca-certificates\": while running runtime: exit status 1\n"},
[]string{"exit status 1", "Interactive authentication required", `building at STEP "RUN apk add`}, []string{"STEP 3/3", "COPY"}},
"a failed RUN": {forgekey.Ran{Status: 1, Stdout: steps, Stderr: "Error: building at STEP \"RUN false\": exit status 1\n"},
[]string{`STEP "RUN false"`}, []string{"dl-cdn"}},
"a long stderr is bounded, its end kept": {forgekey.Ran{Status: 125, Stdout: steps, Stderr: "early noise\n" + long + "\nError: the end\n"},
[]string{"Error: the end"}, []string{"early noise", long}},
"nothing on stderr": {forgekey.Ran{Status: 2, Stdout: steps}, []string{"exit status 2", "nothing on stderr", "dl-cdn"}, nil},
"never ran": {forgekey.Ran{Err: "ENOENT"}, []string{"ENOENT", "wrote nothing"}, nil},
}
if len(f.called("push")) != 0 {
t.Error("a failed build pushed")
for name, c := range cases {
m, f, states := setup(t, same)
f.failBud = c.ran
_, st := runBuild(t, m, states, nil)
if st.State != delivery.StepFailed {
t.Errorf("%s: step = %+v, want failed", name, st)
continue
}
for _, w := range c.want {
if !strings.Contains(st.Reason, w) {
t.Errorf("%s: the reason %q lacks %q", name, st.Reason, w)
}
}
for _, w := range c.wantNot {
if strings.Contains(st.Reason, w) {
t.Errorf("%s: the reason %q carries %q", name, st.Reason, w)
}
}
if len(st.Reason) > CauseBytes+200 {
t.Errorf("%s: the reason is %d bytes, more than its bound", name, len(st.Reason))
}
if len(f.called("push")) != 0 {
t.Errorf("%s: a failed build pushed", name)
}
}
}
@@ -0,0 +1,90 @@
package main
// Why a command failed, as the command said it (issue 1703). buildah writes its progress (STEP n/m, the RUN's own
// output) on stdout and its error on stderr, so the last line of both together is a STEP line and hides the error:
// a failure's reason is stderr's last lines, never stdout's, and the whole tail of stderr is said in the journal.
import (
"fmt"
"strings"
"unicode/utf8"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// CauseBytes bounds what a failure's reason carries of the command's output; TailBytes bounds what the journal gets.
const (
CauseBytes = 600
TailBytes = 4096
)
// cause says why a command failed: its exit status (or why it never ran to one) and the last lines it wrote on stderr,
// at most CauseBytes of them, joined with " | ". Only when stderr is empty does stdout's tail stand in, said as such.
func cause(r forgekey.Ran) string {
head := exitOf(r)
if said := tailLines(r.Stderr, CauseBytes, " | "); said != "" {
return head + ": " + said
}
if said := tailLines(r.Stdout, CauseBytes, " | "); said != "" {
return head + ", nothing on stderr; stdout ends: " + said
}
return head + ", and it wrote nothing"
}
// exitOf is how a command ended: why it never ran to an exit status, or the status.
func exitOf(r forgekey.Ran) string {
if r.Err != "" {
return r.Err
}
return fmt.Sprintf("exit status %d", r.Status)
}
// tailLines is the last non-empty lines of s, trimmed, joined with sep, at most max bytes: the lines are taken whole
// from the end while they fit, and when even the last one does not, its end is kept behind "…".
func tailLines(s string, max int, sep string) string {
var lines []string
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
lines = append(lines, l)
}
}
var keep []string
n := 0
for i := len(lines) - 1; i >= 0; i-- {
add := len(lines[i])
if len(keep) > 0 {
add += len(sep)
}
if n+add > max {
if len(keep) == 0 {
keep = append(keep, "…"+cutFront(lines[i], max-len("…")))
}
break
}
keep = append([]string{lines[i]}, keep...)
n += add
}
return strings.Join(keep, sep)
}
// cutFront is the last at most max bytes of s, starting on a whole character.
func cutFront(s string, max int) string {
if max <= 0 {
return ""
}
if len(s) <= max {
return s
}
s = s[len(s)-max:]
for len(s) > 0 && !utf8.RuneStart(s[0]) {
s = s[1:]
}
return s
}
// sayFailure writes the tail of a failed command's stderr (and of its stdout, where buildah's RUN output goes) to the
// journal, so what the reason had to leave out can still be read there.
func (m *Module) sayFailure(what string, r forgekey.Ran) {
m.say("%s failed (%s); stderr ends:\n%s\nstdout ends:\n%s", what, exitOf(r),
tailLines(r.Stderr, TailBytes, "\n"), tailLines(r.Stdout, TailBytes, "\n"))
}
@@ -0,0 +1,36 @@
package main
import (
"git.novox.be/novox/mesh-sdk/go/buildcpu"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The processors a step may take (novox/hq ADR 0553, issue 1806). The node-engine holds this module's runtime, with
// every other build module's of the node, in one slice at idle priority under the node's one share of its processors:
// that quota bounds the total. Threads only size each step: the share the composer gives (buildstep.NodeLimits) over
// this module's own capacity, so a step does not wait in the kernel's throttle. Without the composer's share there is
// no slice to size a step to, and no thread limit is set.
// cpuLimits is what one step is held to now.
func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1
switch {
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
return buildstep.NodeLimits(slots)
}
// threadEnv is the environment (nothing without a share) buildah is given for its threads (it is a Go program). What a build's RUN lines start
// runs in the container's own environment, inside the runtime's cgroup: the slice's quota and the nice level bind it.
func (m *Module) threadEnv() []string {
return buildcpu.Env(m.cpuLimits().Threads)
}
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// to (its unit and its slice), read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any {
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
}
@@ -70,8 +70,8 @@ type fake struct {
calls []cmdRecord
digest string // what `buildah images` prints
pushes int
refuse int // the first n pushes answer "connection refused"
failBud string
refuse int // the first n pushes answer "connection refused"
failBud forgekey.Ran // what a failing buildah bud answers; a zero Status is a passing bud
// digestFile: "" writes the digest, "none" writes no file, "empty" writes an empty one.
digestFile string
// registry: repository:tag -> digest
@@ -116,8 +116,8 @@ func (f *fake) run(ctx context.Context, env []string, name string, args ...strin
case name == "buildah" && strings.Contains(line, "--version"):
return forgekey.Ran{Stdout: "buildah version 1.45.1 (image-spec 1.1.0, runtime-spec 1.1.0)\n"}
case name == "buildah" && strings.Contains(line, " bud "):
if f.failBud != "" {
return forgekey.Ran{Status: 1, Stderr: "STEP 1/2: FROM x\n" + f.failBud + "\n"}
if f.failBud.Status != 0 || f.failBud.Err != "" {
return f.failBud
}
case name == "buildah" && strings.Contains(line, " images "):
return forgekey.Ran{Stdout: f.digest + "\n"}
@@ -203,6 +203,8 @@ func TestARealBuildIsRootlessWithNoSocketAndNoWayOut(t *testing.T) {
imageModule(t, m, "FROM "+base+"\nRUN echo b > /proc/sysrq-trigger\n", nil)
if _, st := buildReal(t, m, states); st.State != delivery.StepFailed {
t.Errorf("a write to the host's /proc/sysrq-trigger passed: %+v", st)
} else if strings.Contains(st.Reason, "setup network") {
t.Errorf("the RUN never started, so the write was not what failed (issue 1703): %s", st.Reason)
}
}
@@ -9,9 +9,11 @@ package main
import (
"context"
"fmt"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"os"
"os/user"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -29,13 +31,15 @@ func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-build-image]
func main() {
ctx := context.Background()
m := &Module{
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
Steps: stdio.State(delivery.StepsState).Put,
ReadSteps: readOf(stdio.State(delivery.StepsState)),
Ctx: ctx,
Say: logf,
Work: WorkDir,
Store: StoreDir,
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
Steps: stdio.State(delivery.StepsState).Put,
ReadSteps: readOf(stdio.State(delivery.StepsState)),
Pauses: stdio.State(delivery.PausesState).Put,
ReadPauses: readOf(stdio.State(delivery.PausesState)),
Ctx: ctx,
Say: logf,
Work: WorkDir,
Store: StoreDir,
}
m.Floor = floorOf(m.Node)
if u, err := user.Current(); err == nil {
@@ -55,7 +59,13 @@ func main() {
os.Exit(1)
}
steps := m.StepTools()
booted := time.Now()
puller := m.Puller(steps, nil)
m.Held = puller
if m.Node != "" {
// A step a previous process left running is ended (issue 1890); this process's own steps start after booted.
go buildstep.EndStaleAtStart(ctx, m.stepScan(), m.Node, booted, logf)
}
if m.Node != "" {
// The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again.
go func() { _ = puller.Pull(ctx) }()
@@ -97,9 +97,13 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes
states := []string{}
for _, s := range m.State {
states = append(states, s.Name)
// A step's key is per step and dropped after its age, so the bucket cannot fill; a pause has no age.
if (s.Name == "steps") != (s.TTLSeconds > 0) {
t.Errorf("state %s has ttl-seconds %d: only steps has an age", s.Name, s.TTLSeconds)
}
}
slices.Sort(states)
if want := []string{"keys", "steps"}; !slices.Equal(states, want) {
if want := []string{"keys", "pauses", "steps"}; !slices.Equal(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "pause", "resume"}) {
@@ -22,8 +22,13 @@ type Module struct {
// Account and UID are the account the module runs as; Home is its home.
Account, UID, Home string
// Steps is the Put of the steps state; ReadSteps reads it back for the read tools.
Steps buildstep.Put
Steps buildstep.Put
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
Held *buildstep.Puller
ReadSteps StateRead
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
Pauses buildstep.Put
ReadPauses StateRead
// Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not).
Ctx context.Context
Say func(format string, a ...any)
@@ -101,6 +106,7 @@ func (m *Module) Start(ctx context.Context) {
m.Say("this machine cannot build images: %v", err)
}
m.CleanWorkspaces(m.clock())
buildstep.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890)
select {
case <-ctx.Done():
return
@@ -125,7 +131,8 @@ func (m *Module) Health(ctx context.Context) health.Answer {
// Status is what the module reports of itself.
func (m *Module) Status(ctx context.Context) map[string]any {
l, err := m.look(ctx)
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil}
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil,
"cpu": m.cpuStatus()}
if err != nil {
out["why"] = err.Error()
}
@@ -117,7 +117,7 @@ func (m *Module) storeFlags() []string {
// error, and the next look refuses with it.
func (m *Module) env() []string {
_ = m.ensureRuntimeDir()
return []string{"XDG_RUNTIME_DIR=" + m.runtimeDir()}
return append([]string{"XDG_RUNTIME_DIR=" + m.runtimeDir()}, m.threadEnv()...)
}
// runtimeDir is buildah's runtime directory, inside the store.
@@ -15,6 +15,8 @@ import (
"strings"
"syscall"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// RegistryWait is how long a call waits for a registry that refuses connections, longer than the nightly collection
@@ -47,13 +49,15 @@ func (m *Module) push(ctx context.Context, workspace, module, local string, res
dest := addr + "/" + repository + ":" + tag
digestFile := filepath.Join(m.Work, workspace, "pushed."+tag)
argv := append(m.storeFlags(), "push", "--tls-verify=false", "--digestfile", digestFile, local, "docker://"+dest)
var last forgekey.Ran
if err := m.waitForRegistry(ctx, addr, "buildah push "+dest, func() error {
r := m.Run(ctx, m.env(), "buildah", argv...)
if r.Status != 0 || r.Err != "" {
return errors.New(lastLine(r.Err + "\n" + r.Stderr + "\n" + r.Stdout))
last = m.Run(ctx, m.env(), "buildah", argv...)
if last.Status != 0 || last.Err != "" {
return errors.New(cause(last))
}
return nil
}); err != nil {
m.sayFailure("buildah push "+dest, last)
return nil, fmt.Errorf("pushing %s: %w", dest, err)
}
var served string
@@ -7,9 +7,12 @@ package main
import (
"encoding/json"
"fmt"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -38,6 +41,53 @@ func readOf(s stdio.KeptState) StateRead {
}}
}
// MaxStateReads is the most values one read tool reads from a state in a call, and stateReadParallel how many of them
// are in flight at once. Every value is a round trip from the tool through the runtime to the bus, so a call that read
// every key of a large state one after the other outlasted the call's 30 s (issue 1827: 1164 keys, 20 MB).
const (
MaxStateReads = 1000
stateReadParallel = 16
)
// readValues reads the values of keys, stateReadParallel at a time, in the order of keys (nil for a key with none). It
// stops starting reads after the first error and returns it.
func readValues(keys []string, get func(string) (json.RawMessage, error)) ([]json.RawMessage, error) {
out := make([]json.RawMessage, len(keys))
var (
wg sync.WaitGroup
mu sync.Mutex
fail error
next = make(chan int)
)
for w := 0; w < stateReadParallel && w < len(keys); w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range next {
raw, err := get(keys[i])
mu.Lock()
if err != nil && fail == nil {
fail = err
}
mu.Unlock()
out[i] = raw
}
}()
}
for i := range keys {
mu.Lock()
stop := fail != nil
mu.Unlock()
if stop {
break
}
next <- i
}
close(next)
wg.Wait()
return out, fail
}
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
func (m *Module) stepKey(id string) (string, error) {
id = strings.TrimSpace(id)
@@ -77,29 +127,44 @@ type StepLine struct {
Started string `json:"started"`
Ended string `json:"ended,omitempty"`
Reason string `json:"reason,omitempty"`
What string `json:"what,omitempty"`
}
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero). Only
// the node's own keys (<node>.) are listed and the newest max(limit*4, 100) of them read (at most MaxStateReads): a step's
// id is base-36 milliseconds and random digits, so its string order is its start order. cut says older steps were not
// read. The values still order the lines, since an ask step's id carries the ask time.
func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) {
if limit <= 0 {
limit = 10
}
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
return nil, fmt.Errorf("the steps state cannot be read here")
return nil, false, fmt.Errorf("the steps state cannot be read here")
}
start := time.Now()
keys, err := m.ReadSteps.Keys()
if err != nil {
return nil, err
return nil, false, err
}
own := keys[:0:0]
for _, k := range keys {
if strings.HasPrefix(k, m.Node+".") {
if node, _, err := delivery.ParseStepKey(k); err == nil && node == m.Node {
own = append(own, k)
}
}
}
sort.Sort(sort.Reverse(sort.StringSlice(own)))
if n := min(max(limit*4, 100), MaxStateReads); len(own) > n {
own, cut = own[:n], true
}
raws, err := readValues(own, m.ReadSteps.Get)
log.Printf("steps read: %d of %d keys listed read in %v (cut %v)", len(own), len(keys), time.Since(start).Round(time.Millisecond), cut)
if err != nil {
return nil, cut, err
}
var steps []delivery.Step
for _, k := range keys {
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
continue
}
raw, err := m.ReadSteps.Get(k)
if err != nil {
return nil, err
}
for _, raw := range raws {
if len(raw) == 0 {
continue
}
@@ -115,13 +180,13 @@ func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
}
out := make([]StepLine, 0, len(steps))
for _, s := range steps {
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason, What: s.What}
if !s.Ended.IsZero() {
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
}
out = append(out, l)
}
return out, nil
return out, cut, nil
}
// ReadTools are the module's read tools. All are read-only.
@@ -143,8 +208,13 @@ func (m *Module) ReadTools() []stdio.Tool {
Input: map[string]any{"type": "object", "properties": map[string]any{
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
Run: func(args map[string]any) (any, error) {
steps, err := m.RecentSteps(intArg(args["limit"]))
return map[string]any{"node": m.Node, "steps": steps}, err
steps, cut, err := m.RecentSteps(intArg(args["limit"]))
ans := map[string]any{"node": m.Node, "steps": steps}
if cut {
ans["cut"] = true
ans["note"] = fmt.Sprintf("this node has more steps stored than one call reads; older steps were not read (ask for a larger limit, at most %d reads)", MaxStateReads)
}
return ans, err
}},
}
}
@@ -2,7 +2,11 @@ package main
import (
"encoding/json"
"fmt"
"strings"
"sync/atomic"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -39,8 +43,67 @@ func TestTheReadToolsAnswerAStepsResult(t *testing.T) {
if err := json.Unmarshal(got.Result, &back); err != nil || back.Digest != res.Digest {
t.Errorf("result = %s, %v", got.Result, err)
}
lines, err := m.RecentSteps(0)
lines, _, err := m.RecentSteps(0)
if err != nil || len(lines) != 1 || lines[0].Tool != BuildTool {
t.Errorf("steps = %+v, %v", lines, err)
}
}
// 1827: on the live shape (4 nodes, 600 steps each; a steps state of 1164 keys / 20 MB timed out at 30 s) the steps tool
// lists, then reads only this node's keys, in parallel, at most MaxStateReads of them, and says when it cut.
func TestRecentStepsReadsOnlyTheNodesKeysInParallelAndBounded(t *testing.T) {
t0 := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
for _, row := range []struct {
perNode, limit int
reads int64
steps int
cut bool
}{
{20, 0, 20, 10, false},
{600, 0, 100, 10, true},
{1500, 300, MaxStateReads, 300, true},
} {
var reads, inFlight, maxFlight atomic.Int64
stored := map[string][]byte{}
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
for i := 0; i < row.perNode; i++ {
b, _ := delivery.EncodeStep(delivery.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: delivery.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)})
stored[n+".s"+fmt.Sprintf("%04d", i)] = b
}
}
m := &Module{Node: "shanks"}
m.ReadSteps = StateRead{
Keys: func() ([]string, error) {
var k []string
for key := range stored {
k = append(k, key)
}
return k, nil
},
Get: func(key string) (json.RawMessage, error) {
reads.Add(1)
now := inFlight.Add(1)
for o := maxFlight.Load(); now > o && !maxFlight.CompareAndSwap(o, now); o = maxFlight.Load() {
}
time.Sleep(time.Millisecond)
inFlight.Add(-1)
if !strings.HasPrefix(key, "shanks.") {
t.Errorf("read %s, a key of another node", key)
}
return stored[key], nil
},
}
start := time.Now()
lines, cut, err := m.RecentSteps(row.limit)
if err != nil || len(lines) != row.steps || cut != row.cut || reads.Load() != row.reads {
t.Errorf("%d a node: %d steps, cut %v, %d reads, err %v; want %d, %v, %d", row.perNode, len(lines), cut, reads.Load(), err, row.steps, row.cut, row.reads)
}
if row.cut && len(lines) > 0 && lines[0].ID != fmt.Sprintf("s%04d", row.perNode-1) {
t.Errorf("%d a node: newest answered %s", row.perNode, lines[0].ID)
}
if maxFlight.Load() < 2 {
t.Errorf("%d a node: values read one at a time", row.perNode)
}
t.Logf("%d a node: %d reads in %v (one after the other: %v)", row.perNode, reads.Load(), time.Since(start), time.Duration(reads.Load())*time.Millisecond)
}
}
@@ -2,6 +2,7 @@ package main
import (
"context"
"git.novox.be/novox/mesh-sdk/go/buildstep"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -44,6 +45,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
tools = append(tools, t)
}
tools = append(tools, m.ReadTools()...)
tools = append(tools, buildstep.StepEndTool(StepEndTool, m.Node, m.stepScan(), m.Held))
tools = append(tools, m.releaseTool())
return append(tools,
stdio.Tool{Name: StatusTool,
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93 h1:FAiFY2KBKEoZSH49OZTt7/zRBIQxyl5g/KhYrpggbN4=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261023213200-403a5d72ab93/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+2 -1
View File
@@ -9,7 +9,7 @@
],
"state": [
"keys",
"steps"
{"name": "steps", "ttl-seconds": 172800}, "pauses"
],
"claims": [
{
@@ -33,6 +33,7 @@
"build_image_release",
"build_image_step",
"build_image_steps",
"build_image_step_end",
"build_image_status",
"build_image_health"
],
@@ -14,6 +14,7 @@ var Kept = []announce.State{
{Name: "toolchains", Readable: true},
{Name: "keys", Readable: true},
{Name: "steps", Readable: true},
{Name: "pauses", Readable: true},
}
// keepStates announces Kept as this module's (stdio.Keeps): main calls it before it serves.
@@ -45,16 +45,20 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
source = buildstep.SeatAsks{Language: delivery.AskNode}
}
capacity := m.Capacity
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config
// resolved it; the settings file is read only when that watch cannot start. A test's Capacity is its own.
var watch func(string, func(stdio.ProvisionChange) error) error
if capacity == nil {
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed
}
ask := m.askSteps(steps)
var load func(string) ([]byte, error)
if m.ReadSteps.Get != nil {
load = func(key string) ([]byte, error) { return m.ReadSteps.Get(key) }
if m.ReadPauses.Get != nil {
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) }
}
return &buildstep.Puller{Source: source, Language: delivery.AskNode, Node: m.Node, Put: m.Steps, Load: load,
Capacity: capacity, Logf: m.Say,
return &buildstep.Puller{Source: source, Language: delivery.AskNode, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, WatchConfig: watch, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Prepare(ctx); err != nil {
return nil, err
@@ -70,3 +74,11 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
func (m *Module) SeatTools(p *buildstep.Puller) []stdio.Tool {
return buildstep.SeatTools(Seat, "Node", p)
}
// StepEndTool ends one step left running (issue 1890); the module's steps state is the one it reads and writes.
const StepEndTool = "build_node_step_end"
// stepScan is the module's steps state as buildstep reads it.
func (m *Module) stepScan() buildstep.Scan {
return buildstep.ScanOf(stdio.State(delivery.StepsState))
}
@@ -17,11 +17,11 @@ import (
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// realMachine gives the tests the real toolchain of the module's own lock and makes two workspaces holding one fixture
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1): the integration level
// is given its toolchains read-only and no network, so it is copied into the test's own cache from there. Only where
// no toolchain is installed (a desk) is it installed with `npm ci --ignore-scripts` from the mesh's registry, as the
// module does, which needs the network.
// realMachine gives the tests the real toolchain of the module's own lock and makes three workspaces holding one fixture
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1), copied into the test's
// own cache: the integration level is given its toolchains read-only and no network, so it is never installed from the
// registry here. Where no Node build module has installed one (a node without it, a desk), the test is skipped by
// name: an install from the public registry inside a shut-off level only fails, after 70 s of npm's retries.
func realMachine(t *testing.T) (*Module, string) {
t.Helper()
for _, p := range []string{"node", "npm", "bwrap"} { // bwrap: tsc, esbuild and the scripts run shut off (1309)
@@ -44,16 +44,19 @@ func realMachine(t *testing.T) (*Module, string) {
run := forgekey.ExecRunnerFor(0)
m := &Module{Node: "ace", Recipe: lock, Work: work, Run: run, Ctx: context.Background(),
Toolchain: &Toolchain{Paths: Paths{Home: home, Cache: cache}, Run: run, Registry: func() (Registry, error) {
return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil
return Registry{}, fmt.Errorf("the integration test installs no toolchain from a registry: it has no network")
}}}
withStore(t, m)
if from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json")); from != "" {
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json"))
if from == "" {
t.Skipf("NOT RUN: no Node toolchain is installed under %s for this account to read (no Node build module on this node); "+
"the integration level has no network to install one, so this runs on a node that holds the Node build module", filepath.Join(CacheDir, "toolchain"))
}
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil {
t.Fatalf("no toolchain of this lock is installed under %s, and installing it from the registry failed: %v", filepath.Join(CacheDir, "toolchain"), err)
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil { // present: opens it, installs nothing
t.Fatalf("the toolchain copied from %s cannot be used: %v", from, err)
}
for _, id := range []string{"w1", "w2"} {
for _, id := range []string{"w1", "w2", "w3"} {
for p, body := range map[string]string{
"modules/m/module.json": `{"module":"m","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"typescript","entrypoints":["tools/index.js"]}]}}`,
"modules/m/package.json": `{"name":"m","type":"module","scripts":{"test":"node -e \"process.exit(0)\""}}`,
@@ -65,10 +68,36 @@ func realMachine(t *testing.T) (*Module, string) {
return m, work
}
// 1490/AC2: a fixture TypeScript bundle importing @novox/mesh-sdk, built twice by the real tsc and esbuild, has equal
// digests, and its archive holds the bundled .js, the .serve.mjs launcher and package.json.
func TestAFixtureBundleBuiltTwiceHasOneDigest(t *testing.T) {
// 1490/AC2, 1491 and issue 1869, in one test so the toolchain is copied once: the real type-check and test script of the
// fixture pass the unit level; a package whose test imports its own build output (as mesh-sdk's do) passes the unit level,
// the test step emitting it with the real tsc, shut off, before the test script runs; and the fixture TypeScript bundle
// importing @novox/mesh-sdk, built twice by the real tsc and esbuild, has equal digests, its archive holding the bundled
// .js, the .serve.mjs launcher and package.json. The unit levels run in w3, so the two builds' workspaces stay alike.
func TestTheFixturePassesTheUnitLevelAndBuildsTwiceToOneDigest(t *testing.T) {
m, work := realMachine(t)
out, err := m.test(context.Background(), map[string]any{"workspace": "w3", "module": "modules/m", "level": "unit"})
if err != nil {
t.Fatal(err)
}
if r := out.(*TestResult); !r.Passed || r.NoTests || len(r.Ran) != 2 {
t.Errorf("the fixture's unit level: result = %+v", r)
}
for p, body := range map[string]string{
"modules/p/package.json": `{"name":"p","type":"module","scripts":{"build":"tsc","test":"node --test test/a.test.mjs","prepare":"tsc"}}`,
"modules/p/tsconfig.json": `{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","target":"ES2022","outDir":"dist","rootDir":"src"},"include":["src"]}`,
"modules/p/src/index.ts": "export const answer: number = 42;\n",
"modules/p/test/a.test.mjs": "import { test } from 'node:test';\nimport assert from 'node:assert';\nimport { answer } from '../dist/index.js';\n" +
"test('the build is there', () => assert.equal(answer, 42));\n",
} {
write(t, filepath.Join(work, "w3", "tree", filepath.FromSlash(p)), body, 0o644)
}
out, err = m.test(context.Background(), map[string]any{"workspace": "w3", "module": "modules/p", "level": "unit"})
if err != nil {
t.Fatal(err)
}
if r := out.(*TestResult); !r.Passed || !slices.Equal(r.Ran, []string{"build", "test"}) {
t.Errorf("the package importing its build: result = %+v", r)
}
var digests []string
for _, id := range []string{"w1", "w2"} {
out, err := m.build(context.Background(), map[string]any{"workspace": id, "module": "modules/m", "artifact": "tools"})
@@ -89,43 +118,6 @@ func TestAFixtureBundleBuiltTwiceHasOneDigest(t *testing.T) {
}
}
// 1491: the real type-check and test script of the same fixture pass the unit level.
func TestTheFixturePassesTheUnitLevel(t *testing.T) {
m, _ := realMachine(t)
out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/m", "level": "unit"})
if err != nil {
t.Fatal(err)
}
if r := out.(*TestResult); !r.Passed || r.NoTests || len(r.Ran) != 2 {
t.Errorf("result = %+v", r)
}
}
// Issue 1869: a package whose test imports its own build output (as mesh-sdk's do) passes the unit level: the test step
// emits it with the real tsc, shut off, before the test script runs.
func TestAPackageWhoseTestsImportItsBuildPassesTheUnitLevel(t *testing.T) {
if _, err := exec.LookPath("bwrap"); err != nil {
t.Skip("NOT RUN: no bubblewrap installed where this check runs; every build node has it (the module declares it)")
}
m, work := realMachine(t)
for p, body := range map[string]string{
"modules/p/package.json": `{"name":"p","type":"module","scripts":{"build":"tsc","test":"node --test test/a.test.mjs","prepare":"tsc"}}`,
"modules/p/tsconfig.json": `{"compilerOptions":{"module":"NodeNext","moduleResolution":"NodeNext","target":"ES2022","outDir":"dist","rootDir":"src"},"include":["src"]}`,
"modules/p/src/index.ts": "export const answer: number = 42;\n",
"modules/p/test/a.test.mjs": "import { test } from 'node:test';\nimport assert from 'node:assert';\nimport { answer } from '../dist/index.js';\n" +
"test('the build is there', () => assert.equal(answer, 42));\n",
} {
write(t, filepath.Join(work, "w1", "tree", filepath.FromSlash(p)), body, 0o644)
}
out, err := m.test(context.Background(), map[string]any{"workspace": "w1", "module": "modules/p", "level": "unit"})
if err != nil {
t.Fatal(err)
}
if r := out.(*TestResult); !r.Passed || !slices.Equal(r.Ran, []string{"build", "test"}) {
t.Errorf("result = %+v", r)
}
}
// installedToolchain is the toolchain directory under toolchains that this node's Node build module installed: the
// one installed from the lock at lockPath, byte for byte, or, when the change carries a lock no delivered release has
// installed yet, the one installed last, said in the test's log (ADR 0533 §3: a new lock is proven by the module that
@@ -267,3 +267,38 @@ func TestTheLaunchersAreTheSDKsRule(t *testing.T) {
t.Errorf("launchers %v, mesh-sdk's rule %v", got, want)
}
}
// issue 1703: a failed command's reason keeps stderr's end and stdout's start, each bounded, so a long stdout (npm's
// progress) never pushes npm's error out, and tsc's first error on stdout stays in.
func TestRanFailedKeepsTheError(t *testing.T) {
noise := strings.Repeat("npm progress line\n", 200)
cases := map[string]struct {
ran forgekey.Ran
want []string
wantNot []string
}{
"passed": {forgekey.Ran{Stdout: "ok"}, nil, nil},
"npm, long stdout": {forgekey.Ran{Status: 1, Stdout: noise, Stderr: "npm ERR! early\n" + noise + "npm ERR! code E404\n"}, []string{"status 1", "npm ERR! code E404"}, []string{"npm ERR! early"}},
"tsc on stdout": {forgekey.Ran{Status: 2, Stdout: "src/a.ts(1,7): error TS2322: wrong\n" + noise}, []string{"error TS2322"}, nil},
"never ran": {forgekey.Ran{Err: "ENOENT"}, []string{"ENOENT"}, nil},
}
for name, c := range cases {
got := ranFailed(c.ran)
if c.want == nil && got != "" {
t.Errorf("%s: %q, want none", name, got)
}
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Errorf("%s: %q lacks %q", name, got, w)
}
}
for _, w := range c.wantNot {
if strings.Contains(got, w) {
t.Errorf("%s: %q carries %q", name, got, w)
}
}
if len(got) > failedBytes+100 {
t.Errorf("%s: %d bytes, past its bound", name, len(got))
}
}
}
@@ -0,0 +1,3 @@
# Its integration tests copy the Node toolchain the Node build module installed on this node and run node and npm
# (realMachine, build_integration_test.go): they run only where the node holds node-build-node (issue 890).
node-build-node
@@ -20,6 +20,7 @@ import (
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -54,14 +55,15 @@ type TestResult struct {
// the catalogue's TypeScript against the bounds is a later release's (R4f), not this one's gate.
OverBound bool `json:"over_bound"`
Failed []Failure `json:"failed,omitempty"`
// By is the build module and the SDK it was built with (buildstep.By): the checker names it when a failure hides
// its cause.
By string `json:"by,omitempty"`
}
// Failure is a check or script that failed; Output is the first lines it wrote.
type Failure struct {
Package string `json:"package"`
Test string `json:"test,omitempty"`
Output string `json:"output,omitempty"`
}
// Failure is the SDK's failure contract (buildstep.Failure, issue 1901): a failing test of the test script, read from
// node's TAP reporter, or a type-check or build that failed, each with its cause (never empty, never a passing line) and
// its own output up to 4096 bytes, cause first.
type Failure = buildstep.Failure
func (m *Module) testTool() StepTool {
return StepTool{
@@ -104,7 +106,7 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
if err != nil {
return nil, err
}
res := &TestResult{Workspace: workspace, Module: module, Level: level}
res := &TestResult{Workspace: workspace, Module: module, Level: level, By: buildstep.By("mesh-build-node")}
script := "test"
if level == "integration" {
script = "test:integration"
@@ -128,12 +130,12 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
var check, run time.Duration
var failed []Failure
// a command that could not start is not a verdict: the step fails. One that ran and said no is.
ran := func(name string, r forgekey.Ran) error {
ran := func(name string, r forgekey.Ran, report string) error {
if r.Err != "" {
return fmt.Errorf("%s could not be run: status %d %s: %s", name, r.Status, r.Err, firstLine(r.Stderr))
}
if r.Status != 0 {
failed = append(failed, Failure{Package: where, Test: name, Output: lines(r.Stdout + "\n" + r.Stderr)})
failed = append(failed, testFailures(where, name, dir, r, report)...)
}
return nil
}
@@ -148,17 +150,23 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
start := m.now()
r := m.runIn(ctx, root, false, dir, nil, "node", line...)
check = m.now().Sub(start)
if err := ran(name, r); err != nil {
if err := ran(name, r, ""); err != nil {
return nil, err
}
res.Ran = append(res.Ran, name)
}
if hasScript && len(failed) == 0 {
start := m.now()
env := []string{"PATH=" + binDir(root) + ":" + pathOrDefault(), "npm_config_cache=" + filepath.Join(m.Toolchain.Paths.Cache, "npm")}
// node --test writes its TAP report to a file of the workspace (issue 1901), whatever the script prints itself
report := filepath.Join(root, ".mesh-node-test-"+level+".tap")
_ = os.Remove(report)
env := []string{"PATH=" + binDir(root) + ":" + pathOrDefault(), "npm_config_cache=" + filepath.Join(m.Toolchain.Paths.Cache, "npm"),
"NODE_OPTIONS=" + buildstep.NodeTestOptions(report)}
r := m.runIn(ctx, root, false, dir, env, "npm", scriptArgs(script)...)
run = m.now().Sub(start)
if err := ran(script, r); err != nil {
tap, _ := os.ReadFile(report)
_ = os.Remove(report)
if err := ran(script, r, string(tap)); err != nil {
return nil, err
}
res.Ran = append(res.Ran, script)
@@ -166,7 +174,7 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
res.Failed = failed
var why []string
for _, f := range failed {
why = append(why, fmt.Sprintf("%s failed in %s: %s", f.Test, f.Package, firstLine(f.Output)))
why = append(why, failureWhy(f))
}
if level == "unit" {
res.Elapsed, res.Bound = (check + run).Seconds(), UnitBound.Seconds()
@@ -188,18 +196,23 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
return res, nil
}
// lines is the first lines of a command's output.
func lines(out string) string {
var keep []string
for _, l := range strings.Split(out, "\n") {
if l = strings.TrimSpace(l); l != "" {
keep = append(keep, l)
if len(keep) == 15 {
break
}
}
// testFailures is the module's parse path of a command that failed (the conformance kit runs it, issue 1901): the test
// script's failing tests from node's TAP report, or, for the type-check or the build, or a script whose report names no
// failing test, the command's own output with the line that names its error as the cause.
func testFailures(where, name, dir string, r forgekey.Ran, report string) []Failure {
if name == "test" || name == "test:integration" {
return buildstep.ReadTAP(where, name, dir, report, r.Stdout+"\n"+r.Stderr)
}
return strings.Join(keep, "\n")
return []Failure{buildstep.ConsoleFailure(where, name, r.Stdout+"\n"+r.Stderr)}
}
// failureWhy is the words of one failure: the test or check that failed, where, and its cause.
func failureWhy(f Failure) string {
file := ""
if f.File != "" {
file = " (" + f.File + ")"
}
return fmt.Sprintf("%s failed in %s%s: %s", f.Test, f.Package, file, firstLine(f.Cause))
}
func firstLine(s string) string {
@@ -9,6 +9,7 @@ import (
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -198,4 +199,8 @@ func TestTheTestScriptFindsTscAndEsbuildOnItsPath(t *testing.T) {
if npm.name != "npm" || !strings.HasPrefix(path, bin+":") {
t.Errorf("npm ran with PATH %q, want %s first", path, bin)
}
// 1901/AC7: the test script runs under node's TAP reporter, written to a file of the workspace the step reads
if !slices.Contains(npm.env, "NODE_OPTIONS="+buildstep.NodeTestOptions(filepath.Join(work, "w1", ".mesh-node-test-unit.tap"))) {
t.Errorf("npm ran with %v, want NODE_OPTIONS naming the TAP reporter and its file", npm.env)
}
}
@@ -0,0 +1,36 @@
package main
import (
"git.novox.be/novox/mesh-sdk/go/buildcpu"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// The processors a step may take (novox/hq ADR 0553, issue 1806). The node-engine holds this module's runtime, with
// every other build module's of the node, in one slice at idle priority under the node's one share of its processors:
// that quota bounds the total. Threads only size each step: the share the composer gives (buildstep.NodeLimits) over
// this module's own capacity, so a step does not wait in the kernel's throttle. Without the composer's share there is
// no slice to size a step to, and no thread limit is set.
// cpuLimits is what one step is held to now.
func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1
switch {
case m.Capacity != nil:
slots = m.Capacity()
case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
}
return buildstep.NodeLimits(slots)
}
// threadEnv is the environment (nothing without a share) every command of a step is given for its threads: GOMAXPROCS for esbuild and every Go
// program, UV_THREADPOOL_SIZE for Node's worker pool.
func (m *Module) threadEnv() []string {
return buildcpu.Env(m.cpuLimits().Threads)
}
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime
// to (its unit and its slice), read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any {
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
}
@@ -212,7 +212,7 @@ func (m *Module) fetchInto(ctx context.Context, req buildstep.FetchRequest, grou
func (m *Module) typeScriptModules(ctx context.Context, tree string) (dirs []string, packages map[string]bool, err error) {
r := m.Run(ctx, m.baseEnv(), "git", "-C", tree, "ls-files", "--", "module.json", "*/module.json")
if r.Status != 0 || r.Err != "" {
return nil, nil, fmt.Errorf("git ls-files: %s", firstLine(r.Err+" "+r.Stderr))
return nil, nil, fmt.Errorf("git ls-files: %s", forgekey.Said(r.Err+"\n"+r.Stderr))
}
packages = map[string]bool{}
for _, l := range strings.Split(r.Stdout, "\n") {
@@ -4,6 +4,7 @@ import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
@@ -225,6 +226,7 @@ func mustReadDir(t *testing.T, dir string) []string {
// are given PATH, HOME and the locale and their own words; and the real clean runner, run on `env` here, prints only
// those.
func TestNoCommandOfTheStepSeesATokenOfTheProcess(t *testing.T) {
t.Setenv("MESH_BUILD_CPU_SHARE", "50")
t.Setenv("MESH_BUS_CREDENTIAL", "hunter2")
t.Setenv("NPM_TOKEN", "hunter3")
files := typeScriptModule(`{"dependencies":{"lodash":"^4"}}`)
@@ -245,6 +247,11 @@ func TestNoCommandOfTheStepSeesATokenOfTheProcess(t *testing.T) {
if len(c.env) < 3 || !strings.HasPrefix(c.env[0], "PATH=") || !strings.HasPrefix(c.env[1], "HOME=") || c.env[2] != "LC_ALL=C" {
t.Errorf("%s was given %v, want PATH, HOME and the locale first", c.name, c.env)
}
// And its threads, its part of the node's share the composer gave (ADR 0553).
if !slices.ContainsFunc(c.env, func(e string) bool { return strings.HasPrefix(e, "GOMAXPROCS=") }) ||
!slices.ContainsFunc(c.env, func(e string) bool { return strings.HasPrefix(e, "UV_THREADPOOL_SIZE=") }) {
t.Errorf("%s was given %v, without its threads", c.name, c.env)
}
}
// The runner main wires: what it hands a command is exactly what the step gave.
ran := forgekey.ExecRunnerClean(10*time.Second)(context.Background(), append(m.baseEnv(), "ONLY=this"), "env")
@@ -35,7 +35,7 @@ func (m *Module) isolation(root string, write bool) buildstep.Isolation {
ReadOnly: []string{m.toolchainDir()},
Caches: []string{m.npmCache()},
WriteCaches: write,
Env: []string{"PATH=" + pathOrDefault(), "LC_ALL=C"},
Env: append([]string{"PATH=" + pathOrDefault(), "LC_ALL=C"}, m.threadEnv()...),
}
}
@@ -0,0 +1,20 @@
package main
import (
"testing"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/buildstep/conformance"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 1901/AC7: the module's own parse path (testFailures, as the test step calls it on a command that failed) keeps the
// failure contract on every captured node --test and tsc case of the SDK's conformance kit.
func TestTheTestStepKeepsTheFailureContract(t *testing.T) {
conformance.Run(t, conformance.NodeTest, func(o conformance.Output) []buildstep.Failure {
return testFailures(".", "test", o.Dir, forgekey.Ran{Status: 1, Stdout: o.Stdout, Stderr: o.Stderr}, o.Report)
})
conformance.Run(t, conformance.TypeCheck, func(o conformance.Output) []buildstep.Failure {
return testFailures(".", "type-check", o.Dir, forgekey.Ran{Status: 2, Stdout: o.Stdout, Stderr: o.Stderr}, "")
})
}
@@ -9,10 +9,12 @@ package main
import (
"context"
"fmt"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"os"
"os/user"
"path/filepath"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -46,6 +48,8 @@ func main() {
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
Steps: stdio.State(delivery.StepsState).Put,
ReadSteps: readOf(stdio.State(delivery.StepsState)),
Pauses: stdio.State(delivery.PausesState).Put,
ReadPauses: readOf(stdio.State(delivery.PausesState)),
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
Work: WorkDir,
Run: forgekey.ExecRunnerClean(0),
@@ -67,7 +71,13 @@ func main() {
os.Exit(1)
}
steps := m.StepTools()
booted := time.Now()
puller := m.Puller(steps, nil)
m.Held = puller
if m.Node != "" {
// A step a previous process left running is ended (issue 1890); this process's own steps start after booted.
go buildstep.EndStaleAtStart(ctx, m.stepScan(), m.Node, booted, logf)
}
if m.Node != "" {
// The seat's asks, pulled once Serve has the runtime's channel; a pull before it is said and tried again.
go func() { _ = puller.Pull(ctx) }()
@@ -94,9 +94,13 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes
states := []string{}
for _, s := range m.State {
states = append(states, s.Name)
// A step's key is per step and dropped after its age, so the bucket cannot fill; a pause has no age.
if (s.Name == "steps") != (s.TTLSeconds > 0) {
t.Errorf("state %s has ttl-seconds %d: only steps has an age", s.Name, s.TTLSeconds)
}
}
slices.Sort(states)
if want := []string{"keys", "steps", "toolchains"}; !slices.Equal(states, want) {
if want := []string{"keys", "pauses", "steps", "toolchains"}; !slices.Equal(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !slices.Equal(m.Claims[0].Serves, []string{"current", "pause", "resume"}) {
@@ -42,7 +42,12 @@ type Module struct {
// Toolchains and Steps are the Put of the states of those names.
Toolchains, Steps buildstep.Put
// ReadSteps and ReadToolchainsState read those two states back for the read tools (issue 1562).
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
Held *buildstep.Puller
ReadSteps, ReadToolchainsState StateRead
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
Pauses buildstep.Put
ReadPauses StateRead
// Emit tells the bus an event (stdio.Emit); nil emits nothing. The guard emits toolchain.reported after each write of
// the toolchains key, so the indexer, which holds no timer, learns that the bucket exists (issue 1444).
Emit func(eventType string, body any) error
@@ -134,6 +139,7 @@ func (m *Module) Start(ctx context.Context) {
m.Say("the toolchain: %v", err)
}
m.CleanWorkspaces(m.now())
buildstep.EndStaleRound(m.stepScan(), m.Node, m.Say) // asks past the step bound, on the module's own round (issue 1890)
select {
case <-ctx.Done():
return
@@ -199,7 +205,8 @@ func (m *Module) Status(ctx context.Context) map[string]any {
err := m.Check(ctx)
installed, _ := m.Probe(ctx)
r := m.recipe()
out := map[string]any{"node": m.Node, "recipe": r.Version(), "installed": installed, "refusing": err != nil}
out := map[string]any{"node": m.Node, "recipe": r.Version(), "installed": installed, "refusing": err != nil,
"cpu": m.cpuStatus()}
if m.Toolchain != nil {
out["toolchain-dir"] = m.Toolchain.Paths.Dir(r.Version())
out["toolchain-installed"] = m.Toolchain.Present(r.Version())
@@ -224,7 +231,7 @@ const defaultPath = "/usr/local/bin:/usr/bin:/bin"
// files and git configuration) and a fixed locale. A command is given these and its own words, never this process's
// environment, which holds the module's bus credentials (ADR 0466 §4).
func (m *Module) baseEnv() []string {
return []string{"PATH=" + pathOrDefault(), "HOME=" + m.Toolchain.homeOrEmpty(), "LC_ALL=C"}
return append([]string{"PATH=" + pathOrDefault(), "HOME=" + m.Toolchain.homeOrEmpty(), "LC_ALL=C"}, m.threadEnv()...)
}
func pathOrDefault() string {
@@ -117,7 +117,7 @@ func TestANodeThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T)
{Name: "short_probe", Run: run},
}, (&Module{}).StepTools()...)
for _, tool := range m.Tools(steps) {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
continue
}
var reason string
@@ -7,9 +7,12 @@ package main
import (
"encoding/json"
"fmt"
"log"
"sort"
"strconv"
"strings"
"sync"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
@@ -39,6 +42,53 @@ func readOf(s stdio.KeptState) StateRead {
}}
}
// MaxStateReads is the most values one read tool reads from a state in a call, and stateReadParallel how many of them
// are in flight at once. Every value is a round trip from the tool through the runtime to the bus, so a call that read
// every key of a large state one after the other outlasted the call's 30 s (issue 1827: 1164 keys, 20 MB).
const (
MaxStateReads = 1000
stateReadParallel = 16
)
// readValues reads the values of keys, stateReadParallel at a time, in the order of keys (nil for a key with none). It
// stops starting reads after the first error and returns it.
func readValues(keys []string, get func(string) (json.RawMessage, error)) ([]json.RawMessage, error) {
out := make([]json.RawMessage, len(keys))
var (
wg sync.WaitGroup
mu sync.Mutex
fail error
next = make(chan int)
)
for w := 0; w < stateReadParallel && w < len(keys); w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range next {
raw, err := get(keys[i])
mu.Lock()
if err != nil && fail == nil {
fail = err
}
mu.Unlock()
out[i] = raw
}
}()
}
for i := range keys {
mu.Lock()
stop := fail != nil
mu.Unlock()
if stop {
break
}
next <- i
}
close(next)
wg.Wait()
return out, fail
}
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
func (m *Module) stepKey(id string) (string, error) {
id = strings.TrimSpace(id)
@@ -78,29 +128,44 @@ type StepLine struct {
Started string `json:"started"`
Ended string `json:"ended,omitempty"`
Reason string `json:"reason,omitempty"`
What string `json:"what,omitempty"`
}
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero). Only
// the node's own keys (<node>.) are listed and the newest max(limit*4, 100) of them read (at most MaxStateReads): a step's
// id is base-36 milliseconds and random digits, so its string order is its start order. cut says older steps were not
// read. The values still order the lines, since an ask step's id carries the ask time.
func (m *Module) RecentSteps(limit int) (lines []StepLine, cut bool, err error) {
if limit <= 0 {
limit = 10
}
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
return nil, fmt.Errorf("the steps state cannot be read here")
return nil, false, fmt.Errorf("the steps state cannot be read here")
}
start := time.Now()
keys, err := m.ReadSteps.Keys()
if err != nil {
return nil, err
return nil, false, err
}
own := keys[:0:0]
for _, k := range keys {
if strings.HasPrefix(k, m.Node+".") {
if node, _, err := delivery.ParseStepKey(k); err == nil && node == m.Node {
own = append(own, k)
}
}
}
sort.Sort(sort.Reverse(sort.StringSlice(own)))
if n := min(max(limit*4, 100), MaxStateReads); len(own) > n {
own, cut = own[:n], true
}
raws, err := readValues(own, m.ReadSteps.Get)
log.Printf("steps read: %d of %d keys listed read in %v (cut %v)", len(own), len(keys), time.Since(start).Round(time.Millisecond), cut)
if err != nil {
return nil, cut, err
}
var steps []delivery.Step
for _, k := range keys {
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
continue
}
raw, err := m.ReadSteps.Get(k)
if err != nil {
return nil, err
}
for _, raw := range raws {
if len(raw) == 0 {
continue
}
@@ -116,13 +181,13 @@ func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
}
out := make([]StepLine, 0, len(steps))
for _, s := range steps {
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason, What: s.What}
if !s.Ended.IsZero() {
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
}
out = append(out, l)
}
return out, nil
return out, cut, nil
}
// ReadToolchains is every key of the toolchains state (<node>.<language>) with its stored value.
@@ -130,24 +195,30 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
if m.ReadToolchainsState.Keys == nil || m.ReadToolchainsState.Get == nil {
return nil, fmt.Errorf("the toolchains state cannot be read here")
}
start := time.Now()
keys, err := m.ReadToolchainsState.Keys()
if err != nil {
return nil, err
}
cut := len(keys) > MaxStateReads
if cut {
keys = keys[:MaxStateReads] // a node has a key per language: far fewer in practice
}
raws, err := readValues(keys, m.ReadToolchainsState.Get)
log.Printf("toolchains read: %d keys read in %v (cut %v: keys past %d were not read)", len(keys), time.Since(start).Round(time.Millisecond), cut, MaxStateReads)
if err != nil {
return nil, err
}
out := map[string]delivery.Toolchain{}
for _, k := range keys {
raw, err := m.ReadToolchainsState.Get(k)
if err != nil {
return nil, err
}
for i, raw := range raws {
if len(raw) == 0 {
continue
}
t, err := delivery.DecodeToolchain(raw)
if err != nil {
return nil, fmt.Errorf("%s: %w", k, err)
return nil, fmt.Errorf("%s: %w", keys[i], err)
}
out[k] = t
out[keys[i]] = t
}
return out, nil
}
@@ -171,8 +242,13 @@ func (m *Module) ReadTools() []stdio.Tool {
Input: map[string]any{"type": "object", "properties": map[string]any{
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
Run: func(args map[string]any) (any, error) {
steps, err := m.RecentSteps(intArg(args["limit"]))
return map[string]any{"node": m.Node, "steps": steps}, err
steps, cut, err := m.RecentSteps(intArg(args["limit"]))
ans := map[string]any{"node": m.Node, "steps": steps}
if cut {
ans["cut"] = true
ans["note"] = fmt.Sprintf("this node has more steps stored than one call reads; older steps were not read (ask for a larger limit, at most %d reads)", MaxStateReads)
}
return ans, err
}},
{Name: ToolchainsReadTool,
Description: "What the module's toolchains state holds: each key <node>.<language> with the version installed, the " +
@@ -3,8 +3,10 @@ package main
import (
"context"
"encoding/json"
"fmt"
"slices"
"strings"
"sync/atomic"
"testing"
"time"
@@ -94,7 +96,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
t0 := time.Date(2026, 10, 21, 9, 0, 0, 0, time.UTC)
putStep(t, steps, delivery.Step{ID: "a", Tool: "build_node_fetch", Node: "ace", State: delivery.StepPassed, Started: t0, Ended: t0, Result: json.RawMessage(`1`)})
putStep(t, steps, delivery.Step{ID: "b", Tool: "build_node_test", Node: "ace", State: delivery.StepRunning, Started: t0.Add(time.Minute)})
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_node_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom"})
putStep(t, steps, delivery.Step{ID: "c", Tool: "build_node_build", Node: "ace", State: delivery.StepFailed, Started: t0.Add(2 * time.Minute), Reason: "boom", What: "check ask, novox/x@abc"})
putStep(t, steps, delivery.Step{ID: "d", Tool: "build_node_build", Node: "g14", State: delivery.StepPassed, Started: t0.Add(time.Hour)})
m := readModule(steps, fakeState{})
out, err := tool(t, m, StepsReadTool)(map[string]any{})
@@ -109,7 +111,7 @@ func TestStepsToolListsRecentStepsOfThisNode(t *testing.T) {
if !slices.Equal(ids, []string{"c", "b", "a"}) {
t.Errorf("ids = %v, want c b a", ids)
}
if lines[0].Reason != "boom" || lines[0].State != delivery.StepFailed {
if lines[0].Reason != "boom" || lines[0].What != "check ask, novox/x@abc" || lines[0].State != delivery.StepFailed {
t.Errorf("first = %+v", lines[0])
}
out, _ = tool(t, m, StepsReadTool)(map[string]any{"limit": float64(1)})
@@ -145,3 +147,62 @@ func TestReadToolsAreInTheManifestAndNeedNoToolchain(t *testing.T) {
t.Errorf("the read refused: %v", err)
}
}
// 1827: on the live shape (4 nodes, 600 steps each; a steps state of 1164 keys / 20 MB timed out at 30 s) the steps tool
// lists, then reads only this node's keys, in parallel, at most MaxStateReads of them, and says when it cut.
func TestRecentStepsReadsOnlyTheNodesKeysInParallelAndBounded(t *testing.T) {
t0 := time.Date(2026, 10, 25, 9, 0, 0, 0, time.UTC)
for _, row := range []struct {
perNode, limit int
reads int64
steps int
cut bool
}{
{20, 0, 20, 10, false},
{600, 0, 100, 10, true},
{1500, 300, MaxStateReads, 300, true},
} {
var reads, inFlight, maxFlight atomic.Int64
stored := map[string][]byte{}
for _, n := range []string{"ace", "g14", "novox", "shanks"} {
for i := 0; i < row.perNode; i++ {
b, _ := delivery.EncodeStep(delivery.Step{ID: fmt.Sprintf("s%04d", i), Tool: "t", Node: n, State: delivery.StepPassed, Started: t0.Add(time.Duration(i) * time.Second)})
stored[n+".s"+fmt.Sprintf("%04d", i)] = b
}
}
m := &Module{Node: "shanks"}
m.ReadSteps = StateRead{
Keys: func() ([]string, error) {
var k []string
for key := range stored {
k = append(k, key)
}
return k, nil
},
Get: func(key string) (json.RawMessage, error) {
reads.Add(1)
now := inFlight.Add(1)
for o := maxFlight.Load(); now > o && !maxFlight.CompareAndSwap(o, now); o = maxFlight.Load() {
}
time.Sleep(time.Millisecond)
inFlight.Add(-1)
if !strings.HasPrefix(key, "shanks.") {
t.Errorf("read %s, a key of another node", key)
}
return stored[key], nil
},
}
start := time.Now()
lines, cut, err := m.RecentSteps(row.limit)
if err != nil || len(lines) != row.steps || cut != row.cut || reads.Load() != row.reads {
t.Errorf("%d a node: %d steps, cut %v, %d reads, err %v; want %d, %v, %d", row.perNode, len(lines), cut, reads.Load(), err, row.steps, row.cut, row.reads)
}
if row.cut && len(lines) > 0 && lines[0].ID != fmt.Sprintf("s%04d", row.perNode-1) {
t.Errorf("%d a node: newest answered %s", row.perNode, lines[0].ID)
}
if maxFlight.Load() < 2 {
t.Errorf("%d a node: values read one at a time", row.perNode)
}
t.Logf("%d a node: %d reads in %v (one after the other: %v)", row.perNode, reads.Load(), time.Since(start), time.Duration(reads.Load())*time.Millisecond)
}
}
@@ -2,6 +2,7 @@ package main
import (
"context"
"git.novox.be/novox/mesh-sdk/go/buildstep"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -40,6 +41,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
tools = append(tools, t)
}
tools = append(tools, m.ReadTools()...)
tools = append(tools, buildstep.StepEndTool(StepEndTool, m.Node, m.stepScan(), m.Held))
tools = append(tools, m.releaseTool())
return append(tools,
stdio.Tool{Name: StatusTool,
@@ -180,14 +180,28 @@ func (m *Module) runIn(ctx context.Context, root string, write bool, dir string,
return run(ctx, extra, "env", append([]string{"-C", dir, name}, args...)...)
}
// ranFailed says why a command did not pass, or "" when it did.
// ranFailed says why a command did not pass, or "" when it did (issue 1703): its exit status, the end of what it wrote
// on stderr (npm's error is its last lines) and the start of what it wrote on stdout (tsc writes its errors there,
// first first), each bounded, so a long stdout never pushes stderr's error out of the reason.
func ranFailed(r forgekey.Ran) string {
if r.Status == 0 && r.Err == "" {
return ""
}
out := strings.TrimSpace(r.Stdout + "\n" + r.Stderr)
if len(out) > 1500 {
out = out[:1500] + " ..."
why := fmt.Sprintf("status %d %s", r.Status, r.Err)
if e := strings.TrimSpace(r.Stderr); e != "" {
if len(e) > failedBytes/2 {
e = "... " + e[len(e)-failedBytes/2:]
}
why += ": stderr: " + e
}
return fmt.Sprintf("status %d %s: %s", r.Status, r.Err, out)
if o := strings.TrimSpace(r.Stdout); o != "" {
if len(o) > failedBytes/2 {
o = o[:failedBytes/2] + " ..."
}
why += ": stdout: " + o
}
return why
}
// failedBytes bounds what ranFailed carries of a command's output, stderr and stdout half each.
const failedBytes = 1500

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