Compare commits

...
Author SHA1 Message Date
mesh-tools f0f7b6737e Merge pull request 'Delivery records are true at the source: the gate keeps no state after the merge, and the issue tracker reads delivered from the view (issue 1280)' (#515) from fix/delivery-records-true into main 2026-10-26 21:48:46 +00:00
jschoubben c1cd8461bc Merge main (#514, issue 1733) into the delivery-records group
mesh/check checking c1cd8461bc6e on 53bf4bb84cb1 | novox/mesh-catalog#515 in group fix/delivery-records-true
mesh/delivery merged: source.main moved to novox/mesh-catalog@f0f7b6737e11
mesh/delivery-group group fix/delivery-records-true merged: every member is merged
2026-10-26 22:29:36 +01:00
mesh-tools 53bf4bb84c Merge pull request 'The merge Approve says the check's real state, and is asked again when it passes or fails (issue 1733)' (#514) from fix/1733-ask-says-the-check into main 2026-10-26 21:25:37 +00:00
jschoubben 4677b8a2f5 mesh-delivery-gate: pin mesh-sdk 68d65e6, the group's sdk head merged with sdk main (issue 1280) 2026-10-26 22:21:21 +01:00
jschoubben cc6e478c4f Merge remote-tracking branch 'origin/main' into fix/delivery-records-true 2026-10-26 22:21:12 +01:00
mesh-tools c17ab34a28 Merge pull request 'A reads-outside marker lists what its tests read, so its package's check is reused until one of those files changes (issue 1936)' (#513) from fix/1936-declared-reads into main 2026-10-26 21:18:14 +00:00
jschoubben 099605ec70 mesh-delivery-gate: the Approve's ask says the check's actual state at its heads, never a pass it has not had (issue 1733)
mesh/delivery merged: source.main moved to novox/mesh-catalog@53bf4bb84cb1
mesh/check checking 099605ec70a2 on 53bf4bb84cb1
With check-gates-merge false a head is ready and asked before any verdict exists, yet the ask said "<pr> passed its
checks" (a group: "passed its checks"): on 2026-10-26 the operator approved mesh-delivery #79 and a six-repository group
on that claim while their checks had not answered.

The ask now reads the checker's verdict at exactly the heads it covers (checkAt, beside checkerAt): "passed its check"
only for a pass there; otherwise "its check has no verdict yet[: it is running (since HH:MM UTC)]" or "its check failed:
<first failure>", and the Approve clause says it merges "now, before its check ends" or "although its check failed".
While the check gates no merge, a pending ask whose check then passes or fails (or loses a pass it named) is withdrawn
and asked again in new words (the ask's AskCheck against the state now, endStaleAsks, the review-record path's
withdrawAsk); a check that starts running is no new word, so the wait stays one message. Whether a merge waits is
unchanged. The gate's log lines and stalled lines that claimed a pass for a ready head say the same state.

Quick fix: replace "passed its checks" by neutral words in the two summaries. Thorough fix: one reader of the check's
state at the ask's heads (checkAt) used by every text that names it, and the ask re-said when that state changes.
Chosen: thorough, as it costs one helper and one case in endStaleAsks, and the quick one would still leave a pending
ask silent about a failure that came after it.

Tests: rows in TestAnApprovedPullRequestIsMergedThroughTheMergeTool (gated pass; ungated no verdict, running, then
passed, then failed) and the ungated group's wording; no new test function.
2026-10-26 22:18:08 +01:00
jschoubben 488b7bfc58 checks/health: its marker names health-undeclared for what it counts (issue 1936)
mesh/delivery merged: source.main moved to novox/mesh-catalog@c17ab34a28cc
mesh/check checking 488b7bfc5816 on c17ab34a28cc
2026-10-26 22:13:14 +01:00
jschoubben d58249d0d8 checks/health: its marker lists the predefined keys, the modules' templates and health-undeclared it reads (issue 1936)
ReadTreeManifest reads predefined.json and each templated module's templates (WithTemplatesFrom), and the test reads
the root's health-undeclared through Kept: none was listed, so a change to them reused a stale verdict. No other
marker's package reads manifests through ReadTreeManifest beyond what it lists. Review finding F1 on ba49a7f0.
2026-10-26 22:13:01 +01:00
jschoubben d224b74ff1 mesh-build-go: a marked package's digest hashes every file of its own directory, Go files its build tags exclude too (issue 1936)
go list leaves a file its build tags exclude (an integration test) in IgnoredGoFiles, which the digest did not hash;
mesh-provisioner-client's unit test globs *_test.go and reads two of them, so a change there reused a stale unit pass.
Review finding F2 on ba49a7f0.
2026-10-26 22:13:01 +01:00
jschoubben 234d2d5534 mesh-delivery-gate: pin mesh-sdk b4310df, the delivery-records work merged with sdk main (issue 1280) 2026-10-26 22:11:55 +01:00
mesh-tools 78d7ef71cc Merge pull request 'The catalogue's placement files: listed at a merge and checked (issue 1126)' (#505) from feat/1126-nodes-compose into main 2026-10-26 21:11:16 +00:00
jschoubben 1a738ed4fc Merge remote-tracking branch 'origin/main' into fix/delivery-records-true 2026-10-26 22:08:50 +01:00
jschoubben c085975146 Pin mesh-sdk 9796444 (l2-next: feat/1126-nodes-compose merged with sdk main #101, the standing-test race fixes) (issue 1126)
mesh/delivery merged: source.main moved to novox/mesh-catalog@78d7ef71cc27
mesh/delivery-group group feat/1126-nodes-compose merged: every member is merged
mesh/check checking c0859751461a on 78d7ef71cc27 | novox/mesh-catalog#505 in group feat/1126-nodes-compose
2026-10-26 22:05:42 +01:00
jschoubben ba49a7f0f9 mesh-build-go: a reads-outside marker lists what its tests read, so its package is kept until one of those files changes (issue 1936)
47 packages carried the marker and were never kept: about 2000 tests per level ran on every check, whatever it
changed. The marker now lists, as paths or globs from the repository's root (? before a path whose absence is read),
what the package's tests read outside it; the digest hashes those files with the package's own non-Go files. A marker
that lists nothing, names an unclean path, or a line that matches nothing keeps today's never-kept digest.

The 46 markers list their reads; checks/deps and checks/trunk lose theirs (their tests read only temporary
directories). Digest form 6.

Quick fix of issue 1936, chosen by the operator 2026-10-26 21:56; the thorough fix (the Go test log's files kept with
each verdict) stays on the issue.
2026-10-26 22:02:58 +01:00
jschoubben be7e98d25c Pin mesh-sdk 5f45175 (l2-next: feat/1126-nodes-compose merged with sdk main) after merging main (issue 1126) 2026-10-26 21:59:21 +01:00
jschoubben b1413949f0 Merge remote-tracking branch 'origin/main' into feat/1126-nodes-compose 2026-10-26 21:56:51 +01:00
mesh-tools eba4bab00a Merge pull request 'The image build context is the module's own directory, so a Dockerfile in a subdirectory builds (issue 1703)' (#512) from fix/1703-image-context into main 2026-10-26 20:36:08 +00:00
mesh-tools 216701fcba Merge pull request 'The build modules size their capacity by CPU share and keep build hours (issues 1890, 1806)' (#511) from fix/capacity-follows-cpu-share into main 2026-10-26 20:33:30 +00:00
jschoubben 8e116cb0e6 Merge main into fix/capacity-follows-cpu-share; pin the build modules at mesh-sdk 805b4d9
mesh/delivery merged: source.main moved to novox/mesh-catalog@216701fcbace
mesh/delivery-group group fix/capacity-follows-cpu-share merged: every member is merged
mesh/check checking 8e116cb0e6e4 on 7c5c29421f02 | novox/mesh-catalog#511 in group fix/capacity-follows-cpu-share
Main's #508 (the binary refusal) pinned mesh-sdk fe11db9; 805b4d9 is the capacity work merged with
sdk main, so it holds both.
2026-10-26 21:29:33 +01:00
jschoubben dcf32576a8 mesh-build-image: build with the module's directory as context, so a Dockerfile in a subdirectory finds its COPY paths (issue 1703)
mesh/delivery merged: source.main moved to novox/mesh-catalog@eba4bab00aa7
mesh/check checking dcf32576a880 on eba4bab00aa7
photos' admin-client, client and server Dockerfiles live in subdirectories and copy
paths from the module's root (admin-client/..., client-server/serve.mjs), as build-agent
built them and as the fingerprint reads the module's whole directory. Taking the
Dockerfile's own directory as context failed every such COPY with copier: stat
"/admin-client/package.json": no such file or directory, on shanks and on ace.

Quick fix (rejected): move photos' Dockerfiles to its repository root; it changes the
operator's photo application and leaves the trap for the next module.
Thorough fix (chosen): one context for every image, the module's directory; it never
leaves that directory, so the isolation of ADR 0487 section 2 holds.
2026-10-26 21:25:24 +01:00
jschoubben 3f8b0b4248 Build modules take asks only within their node's build hours
mesh/delivery-group group fix/capacity-follows-cpu-share rejected: composed together: the checker's verdict at the members' heads (63694fe9222d): unit: ..test
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 3f8b0b42480b on 7c5c29421f02 | novox/mesh-catalog#511 in group fix/capacity-follows-cpu-share
mesh-build-go, -node and -image declare build-hours (node=HH:MM-HH:MM in the node's local time,
empty = any hour) and hand it to the SDK puller (the operator, 2026-10-26: g14 builds only from
18:00 to 06:00); their SDK pin moves to mesh-sdk d00dac1, which has it.
2026-10-26 21:22:09 +01:00
mesh-tools 7c5c29421f Merge pull request 'mesh-issues: a milestone or commit link moves no issue and keeps no worker current (issue 1781)' (#510) from fix/1781-moved-ignores-milestone-links into main 2026-10-26 20:17:09 +00:00
jschoubben 9e41438f1b A merge of a module running nowhere countable reads not yet on the machines in the gate and the tracker; a settle ask is taken away once answered or lapsed; pin mesh-sdk e44cbaf (review of issue 1280) 2026-10-26 21:15:31 +01:00
mesh-tools 3690c149c4 Merge pull request 'Forge rights read as the user sees them; write-only grant for mesh-tools; progress answers in minimal detail (issues 1945, 1947)' (#509) from fix/progress-and-unwritable into main 2026-10-26 20:08:05 +00:00
jschoubben 671fdb7dc2 Pin mesh-sdk 33144781 (reach and settle layouts) in the gate (issue 1280) 2026-10-26 21:05:29 +01:00
jschoubben eab6918459 The tracker takes the view's per-repository reached head as it takes the whole-mesh one: a merge at or before it is delivered (issue 1280, ADR 0556 clause 6) 2026-10-26 21:02:41 +01:00
jschoubben ff2632356b The gate says whether a merged delivery is on the machines, derived from the view's reach, and a person settles a merge held without a passing check on the operator's answer (issue 1280, ADR 0556 clauses 4 and 5) 2026-10-26 21:02:04 +01:00
jschoubben 1e6b157865 Pin mesh-sdk feat/1126-nodes-compose 03dce12, the group's head: the checker refuses a pin that is neither trunk nor the group's head (issue 1126)
mesh/delivery-group group feat/1126-nodes-compose checking: 4 of 6 member(s) ready
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 1e6b15786562 on eba4bab00aa7 | novox/mesh-catalog#505 in group feat/1126-nodes-compose
2026-10-26 20:35:47 +01:00
mesh-tools 5b75233406 Merge pull request 'The catalogue's check uses the shared binary refusal, commit by commit (issue 1577)' (#508) from fix/1577-checker-refuses-binaries into main 2026-10-26 19:32:31 +00:00
jschoubben 1faf317069 Review of L2: the vault's own fails closed: a pair credential's consumer is read standing, the shared value goes only to a holder its record names, and unread assignments refuse, never hand (C1, C2); the placement check pins mesh-sdk 3968849 (issue 1126, ADR 0550 §5)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 1faf31706919 on 5b75233406fe | novox/mesh-catalog#505 in group feat/1126-nodes-compose
2026-10-26 20:12:21 +01:00
jschoubben 245143d948 Build modules run at most as many steps as their node's build CPU gives, and say a check's phases
Each of mesh-build-go, -node and -image declares cores-per-step (6, 2, 2) and hands it to the
SDK puller, which bounds the configured capacity by floor(slice quota cores / cores-per-step),
read live; thread limits and the status tools follow the effective capacity (issue 1806's
follow-up: three Go checks on shanks' 12-core slice starved past the 30-minute step bound).
mesh-build-go says its lint and test phases with the ask id (issue 1890).
2026-10-26 19:55:31 +01:00
jschoubben 851e8782d5 Nodes compose: the catalogue's placement files and their check, mesh-config reading placement, the vault's own and pair credentials, the forge's commit trees (issue 1126)
mesh/delivery closed: a newer head of the same pull request
mesh/check checking 851e8782d569 on da29f77b1081 | novox/mesh-catalog#505 in group feat/1126-nodes-compose
One commit for the branch feat/1126-nodes-compose of mesh-catalog: its 15 commits squashed so its history does not carry two compiled programs committed by mistake and removed later, mesh-config (5.5 MB) and checks/placement (6.2 MB) (issue 1577). The tree equals the former head 2db56067. The operator allowed this rewrite of the feature branch on 2026-10-26.
2026-10-26 19:49:39 +01:00
jschoubben 8530a9730f The build modules take mesh-sdk's report of programs left in a branch's history (issue 1577)
mesh/delivery merged: source.main moved to novox/mesh-catalog@5b75233406fe
mesh/delivery-group group fix/1577-checker-refuses-binaries merged: every member is merged
mesh/check checking 8530a9730fbc on 5b75233406fe
2026-10-26 19:47:38 +01:00
jschoubben ee48e5a01e gitea_get_file refuses a bad path before its size listing reaches the forge; the guard test expects the listing (issue 1577) 2026-10-26 19:32:00 +01:00
jschoubben 854dbfb3f3 mesh-issues: a milestone or commit link moves no issue and keeps no worker current (issue 1781)
mesh/delivery merged: source.main moved to novox/mesh-catalog@7c5c29421f02
mesh/check being carried to 854dbfb3f3f0 on 7c5c29421f02 | from 3690c149c4ad
The seed linked 188 issues at once and every one read as moved at the seed's minute, hiding when each
really moved; on a worked issue the link also counted as its worker's activity. These links only place
an issue, so last-moved and the worker's activity leave them out.
2026-10-26 19:12:12 +01:00
jschoubben 69a3b3f7e0 The forge tools cut a request the forge never answers and refuse a file too big for an answer without fetching it: a 5 MB read stalled every read (issue 1577) 2026-10-26 19:03:18 +01:00
jschoubben 3cd826830b The build modules' fetch step says which changed files are compiled programs, so the checker can refuse them (issue 1577) 2026-10-26 19:00:54 +01:00
jschoubben c584dc57b9 gitea: mesh/check is the mesh's own status and never blocks; signed commits, an outdated branch and a rejected review are said as blocking the mesh's merge (issue 1945, review round 2)
mesh/delivery merged: source.main moved to novox/mesh-catalog@3690c149c4ad
mesh/check being carried to c584dc57b95e on 3690c149c4ad | a compiled program is not refused: mesh-build-node reported no programs
2026-10-26 18:37:02 +01:00
jschoubben e1e5a8ff0d gitea: mesh-tools writes a user's delivered repository and main's rule stays the operator's: only its merge whitelist gets mesh-tools, read back; what else blocks the mesh's merge is said (issue 1945, the operator's answer 2026-10-26 18:30) 2026-10-26 18:34:03 +01:00
jschoubben c85472285d gitea: main's rule read back must say what was written, since the forge silently drops a whitelist login that cannot write (issue 1945, review round 1) 2026-10-26 18:23:42 +01:00
jschoubben f0fbe193fa gitea: mesh-tools administers a user's delivered repository and puts main's rule right, permissions read as the account sees them; knowledge-base.progress: minimal answers leave items to the tree, failed calls warn before parse faults (issues 1945, 1947) 2026-10-26 17:59:34 +01:00
128 changed files with 2863 additions and 242 deletions
+2
View File
@@ -35,6 +35,8 @@ modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
/checks/paths/paths /checks/paths/paths
/modules/mesh-build-go/mesh-build-go /modules/mesh-build-go/mesh-build-go
/modules/mesh-delivery-gate/mesh-delivery-gate /modules/mesh-delivery-gate/mesh-delivery-gate
modules/mesh-config/cmd/mesh-config/mesh-config
checks/placement/placement
# Built by their modules' build step (issue 1577), never committed. # Built by their modules' build step (issue 1577), never committed.
modules/forticlient/cmd/forticlient-tools/forticlient-tools modules/forticlient/cmd/forticlient-tools/forticlient-tools
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest.
modules/*/module.json
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest and the files it names.
modules
+10 -1
View File
@@ -1 +1,10 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# every manifest through mesh-sdk's ReadTreeManifest: the root's predefined keys and each templated module's
# templates (files/ by default, or the directory its manifest names); and the root's kept count of resources that do not say how they are ready.
modules/*/module.json
predefined.json
modules/*/files
modules/*/templates
health-undeclared
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest.
modules/*/module.json
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest, and whether the path each image artifact builds from is there.
modules
+7 -1
View File
@@ -1 +1,7 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest, a TypeScript module's package.json and whether its lock is there.
modules/*/module.json
modules/*/package.json
modules/*/package-lock.json
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest.
modules/*/module.json
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# every file under modules/ (git ls-files and a walk).
modules
+7 -1
View File
@@ -1 +1,7 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# that none of the old check scripts stands at the root.
?merge-check.sh
?merge-check-typescript.sh
?check-nested-modules.sh
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest; its lists are this package's own files.
modules/*/module.json
+6 -1
View File
@@ -1 +1,6 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# check("../.."): the root's list and every file of the modules it names.
particulars
modules
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest and every file of a module.
modules
+5
View File
@@ -0,0 +1,5 @@
module checks/placement
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117
+14
View File
@@ -0,0 +1,14 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117 h1:88CNZOoQgu7YPYKW16qkWT8Gw9Ucg07aTrIImA2nYUk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+80
View File
@@ -0,0 +1,80 @@
// Command placement is the catalogue's check of its placement files (novox/hq ADR 0549 §1, ADR 0555; issue 1126): every
// `placement/<node>.json` reads as a placement of the node its name says, and the files together are refused, every
// finding named at once, for two modules of one node on one host port, a pin, binding or holding naming a provider not
// placed where it says, a seat held once for the mesh held twice, two nodes with one address on the private network, or
// more than one hub (mesh-sdk delivery.CheckPlacements).
//
// A module the catalogue does not hold and a node not enrolled are the composition check's to refuse: placement names
// modules of every repository the indexer reads and the nodes the mesh enrolled, neither of which this tree holds.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Check reads every placement file under root and answers each finding; none is a catalogue whose placement holds. A
// catalogue with no placement directory has none to check.
func Check(root string) ([]string, error) {
dir := filepath.Join(root, delivery.PlacementDir)
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var found []string
placements := map[string]delivery.Placement{}
for _, e := range entries {
rel := delivery.PlacementDir + "/" + e.Name()
node, ok := delivery.PlacementFileNode(rel)
if e.IsDir() || !ok {
found = append(found, fmt.Sprintf("%s is not a placement file: the directory holds only <node>.json, one per node", rel))
continue
}
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, err
}
p, err := delivery.DecodePlacement(raw)
if err != nil {
found = append(found, fmt.Sprintf("%s does not read as a placement: %v", rel, err))
continue
}
if p.Revision != "" {
found = append(found, fmt.Sprintf("%s names a revision, which only the indexer writes into the key", rel))
}
placements[node] = p
}
all := func(string) bool { return true }
found = append(found, delivery.CheckPlacements(placements, all, all)...)
sort.Strings(found)
return found, nil
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: placement <catalogue root>")
os.Exit(2)
}
found, err := Check(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The catalogue's placement files as the merge check reads them (ADR 0549 §1, ADR 0555): the real tree holds every
// rule, and a tree that breaks them is refused with every finding named at once.
func TestPlacementFiles(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
for _, f := range found {
t.Errorf("%s (novox/hq ADR 0549 §1)", f)
}
root := t.TempDir()
write := func(name, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, "placement"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "placement", name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
if found, err := Check(root); err != nil || len(found) != 0 {
t.Fatalf("a catalogue with no placement directory: %v, %v", found, err)
}
write("ace.json", `{"v":1,"node":"ace","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"a","ports":{"80":8080}},{"module":"b","ports":{"81":8080}}]}`)
write("g14.json", `{"v":1,"node":"g14","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"c","bindings":{"pg":{"node":"ace","module":"pg"}}}]}`)
write("novox.json", `{"v":1,"node":"shanks","modules":[]}`)
write("bad.json", `{"v":1,"node":"bad","modules":{}}`)
write("dated.json", `{"v":1,"node":"dated","revision":"abc","modules":[]}`)
write("notes.md", "x")
found, err = Check(root)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"a and b are both published on host port 8080", "bound to ace/pg", "both have the address 10.77.0.1",
"ace, g14 are each placed as it", "placement file of novox names the node", "bad.json does not read", "dated.json names a revision",
"notes.md is not a placement file"} {
hit := false
for _, f := range found {
hit = hit || strings.Contains(f, want)
}
if !hit {
t.Errorf("no finding says %q; found %v", want, found)
}
}
}
+6 -1
View File
@@ -1 +1,6 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the root's list of predefined keys and every manifest that names one.
predefined.json
modules/*/module.json
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest and the files it names.
modules
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest.
modules/*/module.json
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest and its templates.
modules
+5 -1
View File
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# Check("../.."): every manifest.
modules/*/module.json
-1
View File
@@ -1 +0,0 @@
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).
+7 -1
View File
@@ -1 +1,7 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# run("../.."): the root's lists of retired words and lines, and every module's texts.
retired-words
retired-lines
modules
@@ -1 +1,9 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the copies of this package in every module that carries one.
modules/xorg/internal/desktop/*.go
modules/lemurs/internal/desktop/*.go
modules/i3/internal/desktop/*.go
modules/xterm/internal/desktop/*.go
modules/adwaita/internal/desktop/*.go
@@ -1 +1,25 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the copies of desktop.go and its tests in every module that carries one.
modules/blueman/cmd/blueman-tools/desktop.go
modules/blueman/cmd/blueman-tools/desktop_test.go
modules/blueman/cmd/blueman-tools/copies_test.go
modules/forticlient/cmd/forticlient-tools/desktop.go
modules/forticlient/cmd/forticlient-tools/desktop_test.go
modules/forticlient/cmd/forticlient-tools/copies_test.go
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go
modules/nextcloud-client/cmd/nextcloud-client-tools/copies_test.go
modules/nm-applet/cmd/nm-applet-tools/desktop.go
modules/nm-applet/cmd/nm-applet-tools/desktop_test.go
modules/nm-applet/cmd/nm-applet-tools/copies_test.go
modules/openrazer/cmd/openrazer-tools/desktop.go
modules/openrazer/cmd/openrazer-tools/desktop_test.go
modules/openrazer/cmd/openrazer-tools/copies_test.go
modules/polychromatic/cmd/polychromatic-tools/desktop.go
modules/polychromatic/cmd/polychromatic-tools/desktop_test.go
modules/polychromatic/cmd/polychromatic-tools/copies_test.go
modules/slack/cmd/slack-tools/desktop.go
modules/slack/cmd/slack-tools/desktop_test.go
modules/slack/cmd/slack-tools/copies_test.go
+6 -1
View File
@@ -1 +1,6 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# mesh-mail's store.json is the same as brother's; no module scan is left.
modules/mesh-mail/files/store.json
?modules/scan
@@ -1 +1,25 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the copies of desktop.go and its tests in every module that carries one.
modules/blueman/cmd/blueman-tools/desktop.go
modules/blueman/cmd/blueman-tools/desktop_test.go
modules/blueman/cmd/blueman-tools/copies_test.go
modules/forticlient/cmd/forticlient-tools/desktop.go
modules/forticlient/cmd/forticlient-tools/desktop_test.go
modules/forticlient/cmd/forticlient-tools/copies_test.go
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go
modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go
modules/nextcloud-client/cmd/nextcloud-client-tools/copies_test.go
modules/nm-applet/cmd/nm-applet-tools/desktop.go
modules/nm-applet/cmd/nm-applet-tools/desktop_test.go
modules/nm-applet/cmd/nm-applet-tools/copies_test.go
modules/openrazer/cmd/openrazer-tools/desktop.go
modules/openrazer/cmd/openrazer-tools/desktop_test.go
modules/openrazer/cmd/openrazer-tools/copies_test.go
modules/polychromatic/cmd/polychromatic-tools/desktop.go
modules/polychromatic/cmd/polychromatic-tools/desktop_test.go
modules/polychromatic/cmd/polychromatic-tools/copies_test.go
modules/slack/cmd/slack-tools/desktop.go
modules/slack/cmd/slack-tools/desktop_test.go
modules/slack/cmd/slack-tools/copies_test.go
+102 -12
View File
@@ -174,8 +174,27 @@ function ignoreSecretSetting(cfg: Record<string, unknown>, key: string): void {
console.error(`the setting ${key} is ignored: it comes from the vault`); console.error(`the setting ${key} is ignored: it comes from the vault`);
} }
/** How long the forge may take to answer one request before it is cut and said (novox/hq issue 1577: a request for a 5 MB
* file never ended, and every read through the module timed out behind it). */
export const FORGE_REQUEST_TIMEOUT_MS = 20_000;
/** The most a tool's answer carries of one file: the bus's message bound is 1 MiB and a file travels as text (issue 1577). */
export const MAX_FILE_BYTES = 512 * 1024;
/** One request to the forge, cut at the limit and failing loud naming the request. */
async function timedFetch(url: string, init: RequestInit, label: string, timeoutMs: number): Promise<Response> {
try {
return await fetch(url, { ...init, signal: init.signal ? AbortSignal.any([init.signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs) });
} catch (err) {
if (err instanceof Error && err.name === "TimeoutError") throw new Error(`Gitea API ${label}: no answer in ${timeoutMs / 1000} s, the request was cut`);
throw err;
}
}
export class GiteaClient { export class GiteaClient {
readonly baseUrl: string; readonly baseUrl: string;
/** The limit of one request; a test sets it short. */
requestTimeoutMs = FORGE_REQUEST_TIMEOUT_MS;
private readonly tokens: TokenSource; private readonly tokens: TokenSource;
/** A token given as a string is one somebody configured; a source decides for itself (token.ts). */ /** A token given as a string is one somebody configured; a source decides for itself (token.ts). */
@@ -230,14 +249,14 @@ export class GiteaClient {
private send(path: string, options: RequestInit, token: string): Promise<Response> { private send(path: string, options: RequestInit, token: string): Promise<Response> {
checkSentPath(path); checkSentPath(path);
return fetch(`${this.baseUrl}/api/v1${path}`, { return timedFetch(`${this.baseUrl}/api/v1${path}`, {
...options, ...options,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: `token ${token}`, Authorization: `token ${token}`,
...(options.headers as Record<string, string> | undefined), ...(options.headers as Record<string, string> | undefined),
}, },
}); }, `${options.method ?? "GET"} ${path}`, this.requestTimeoutMs);
} }
/** Generic authenticated API call — the escape hatch for endpoints without a dedicated method. /** Generic authenticated API call — the escape hatch for endpoints without a dedicated method.
@@ -540,7 +559,7 @@ export class GiteaClient {
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages * **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did * run past a bound): the call fails naming the repository and the commit, never answering the directories it did
* see, since a directory left out would be taken for one without a module. Reads only. */ * see, since a directory left out would be taken for one without a module. Reads only. */
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> { async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>, listed?: string[]): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>>; listed?: Record<string, Record<string, string>> }> {
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`; const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`); const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
const entries: any[] = []; const entries: any[] = [];
@@ -567,7 +586,23 @@ export class GiteaClient {
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`); if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
trees[dir] = tree; trees[dir] = tree;
} }
if (paths === undefined) return { root, trees }; // **Listed directories** (novox/hq ADR 0549 §2, issue 1126): each asked directory's files directly under it, by name
// to blob id, from the same listing: how the forge bridge finds the catalogue's placement files, one per node, at a
// merge. A directory the commit lacks lists nothing (the catalogue before its first placement file).
let files: Record<string, Record<string, string>> | undefined;
if (listed !== undefined) {
files = {};
for (const dir of listed) {
files[dir] = {};
const prefix = dir === "" ? "" : `${dir}/`;
for (const e of entries) {
if (e?.type !== "blob" || typeof e.path !== "string" || !e.path.startsWith(prefix)) continue;
const name = e.path.slice(prefix.length);
if (name !== "" && !name.includes("/")) files[dir][name] = String(e.sha);
}
}
}
if (paths === undefined) return files === undefined ? { root, trees } : { root, trees, listed: files };
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a // **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails // directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
// naming both, and nothing is answered in part. // naming both, and nothing is answered in part.
@@ -582,7 +617,7 @@ export class GiteaClient {
named[dir][p] = id; named[dir][p] = id;
} }
} }
return { root, trees, paths: named }; return files === undefined ? { root, trees, paths: named } : { root, trees, paths: named, listed: files };
} }
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */ /** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
@@ -771,6 +806,22 @@ export class GiteaClient {
/** One file's contents at a ref (default the repository's default branch), decoded. */ /** One file's contents at a ref (default the repository's default branch), decoded. */
async getFile(owner: string, repo: string, path: string, ref?: string): Promise<{ path: string; ref?: string; sha: string; size: number; content: string }> { async getFile(owner: string, repo: string, path: string, ref?: string): Promise<{ path: string; ref?: string; sha: string; size: number; content: string }> {
const qs = ref ? `?ref=${encodeURIComponent(ref)}` : ""; const qs = ref ? `?ref=${encodeURIComponent(ref)}` : "";
// The size first, from the file's directory listing, which carries no content: a file over what an answer can carry is
// refused without its body being fetched (issue 1577). A file the listing does not show is asked for as before.
const clean = path.replace(/^\/+|\/+$/g, "");
pathSegments("file path", clean); // a path that is another path or user is refused before anything reaches the forge
const slash = clean.lastIndexOf("/");
const dir = slash < 0 ? "" : clean.slice(0, slash);
let listing: any[] | null = null;
try {
listing = await this.request<any[]>(`${repoPath(owner, repo)}/contents${dir ? "/" + pathSegments("file path", dir) : ""}${qs}`);
} catch (err) {
if (!(err instanceof Error) || !/: 404 /.test(err.message)) throw err;
}
const entry = Array.isArray(listing) ? listing.find((e) => e?.path === clean) : undefined;
if (entry && Number(entry.size) > MAX_FILE_BYTES) {
throw new Error(`the file is ${Number(entry.size)} bytes, over the ${MAX_FILE_BYTES / 1024} KiB a tool answer can carry; read it from a clone`);
}
const f = await this.request<any>(`${repoPath(owner, repo)}/contents/${pathSegments("file path", path.replace(/^\/+|\/+$/g, ""))}${qs}`); const f = await this.request<any>(`${repoPath(owner, repo)}/contents/${pathSegments("file path", path.replace(/^\/+|\/+$/g, ""))}${qs}`);
if (!f || f.type !== "file") throw new Error(`Gitea API: ${path} is not a file`); if (!f || f.type !== "file") throw new Error(`Gitea API: ${path} is not a file`);
const content = f.encoding === "base64" ? Buffer.from(String(f.content ?? ""), "base64").toString("utf8") : String(f.content ?? ""); const content = f.encoding === "base64" ? Buffer.from(String(f.content ?? ""), "base64").toString("utf8") : String(f.content ?? "");
@@ -940,14 +991,14 @@ export class GiteaAdmin {
* throwing on it — the caller decides which non-2xx codes are idempotent successes. Only an * throwing on it — the caller decides which non-2xx codes are idempotent successes. Only an
* unexpected status becomes an error, and only where the caller says so. */ * unexpected status becomes an error, and only where the caller says so. */
private async request(path: string, options: RequestInit = {}): Promise<AdminResponse> { private async request(path: string, options: RequestInit = {}): Promise<AdminResponse> {
const res = await fetch(`${this.baseUrl}/api/v1${path}`, { const res = await timedFetch(`${this.baseUrl}/api/v1${path}`, {
...options, ...options,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
Authorization: this.authorization, Authorization: this.authorization,
...(options.headers as Record<string, string> | undefined), ...(options.headers as Record<string, string> | undefined),
}, },
}); }, `${options.method ?? "GET"} ${path}`, FORGE_REQUEST_TIMEOUT_MS);
const text = await res.text(); const text = await res.text();
let body: any = null; let body: any = null;
if (text) { if (text) {
@@ -1047,12 +1098,19 @@ export class GiteaAdmin {
return true; return true;
} }
/** What the forge itself says an account may do on a repository: none, read, write, admin or owner. */ /** What the forge gives an account on a repository, effective: none, read, write, admin or owner. It is the repository as
* that account sees it (the site administrator acts as the account with `sudo`), so access through an organisation's team
* counts. The collaborator endpoint is not used: it names only a collaborator's own permission, and says "read" of an
* account that writes through a team (novox/hq issue 1945). */
async repositoryPermission(owner: string, repo: string, user: string): Promise<string> { async repositoryPermission(owner: string, repo: string, user: string): Promise<string> {
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}/permission`; const path = `${repoPath(owner, repo)}?sudo=${encodeURIComponent(user)}`;
const res = await this.request(path); const res = await this.request(path);
if (res.status === 404) return "none";
if (res.status !== 200) GiteaAdmin.fail(path, res); if (res.status !== 200) GiteaAdmin.fail(path, res);
return String(res.body?.permission ?? "none"); const p = res.body?.permissions;
if (p?.admin === true) return res.body?.owner?.login === user ? "owner" : "admin";
if (p?.push === true) return "write";
return p?.pull === true ? "read" : "none";
} }
/** Make an account a collaborator of a repository that reads only. The forge asks a repository's administrator for /** Make an account a collaborator of a repository that reads only. The forge asks a repository's administrator for
@@ -1067,6 +1125,38 @@ export class GiteaAdmin {
await this.addCollaborator(owner, repo, user, "write"); await this.addCollaborator(owner, repo, user, "write");
} }
/** The rule that applies to main in a repository, as its administrator reads it: a rule named main, else the first glob
* rule covering main; null when none does. With the administrator's login. */
async mainRule(owner: string, repo: string): Promise<BranchProtection | null> {
const rules = await this.branchProtections(owner, repo);
return rules.find((r) => (r.rule_name ?? r.branch_name) === MAIN_BRANCH)
?? rules.find((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH)) ?? null;
}
/** Put an account on main's merge whitelist in a repository of a user, and nothing else (the operator, 2026-10-26:
* write, the rule untouched). Only an existing rule whose merge whitelist is on is changed, and only its whitelist: the
* account is added to the logins already there. Push, status checks, approvals and the administrator override stay as
* the operator set them; with no rule, or the whitelist off, nothing is written (whoever writes may merge). Read back:
* the forge drops a whitelist login that cannot write the repository and still answers success, so a whitelist read back
* without the account fails loud. Answers the rule as the forge holds it after. With the administrator's login. */
async ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }> {
const rule = await this.mainRule(owner, repo);
const listed = rule?.merge_whitelist_usernames ?? [];
if (!rule || rule.enable_merge_whitelist !== true || listed.includes(account)) return { changed: false, rule };
const name = String(rule.rule_name ?? rule.branch_name);
const path = `${repoPath(owner, repo)}/branch_protections/${encodeURIComponent(name)}`;
const res = await this.request(path, { method: "PATCH", body: JSON.stringify({ merge_whitelist_usernames: [...listed, account] }) });
if (res.status !== 200) GiteaAdmin.fail(path, res);
const after = (await this.branchProtections(owner, repo)).find((r) => (r.rule_name ?? r.branch_name) === name);
if (!after) throw new Error(`main's rule ${name} of ${owner}/${repo} was written, but the forge does not list it`);
const held = after.merge_whitelist_usernames ?? [];
if (after.enable_merge_whitelist === true && !held.includes(account)) {
throw new Error(`${account} was added to the merge whitelist of main's rule ${name} of ${owner}/${repo}, but the forge ` +
`holds [${held.join(", ")}]: it drops a login that cannot write the repository`);
}
return { changed: true, rule: after };
}
private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write"): Promise<void> { private async addCollaborator(owner: string, repo: string, user: string, permission: "read" | "write"): Promise<void> {
const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}`; const path = `${repoPath(owner, repo)}/collaborators/${encodeURIComponent(user)}`;
const res = await this.request(path, { method: "PUT", body: JSON.stringify({ permission }) }); const res = await this.request(path, { method: "PUT", body: JSON.stringify({ permission }) });
@@ -1192,9 +1282,9 @@ export class GiteaAdmin {
* other answer rejects (novox/hq issue 120). * other answer rejects (novox/hq issue 120).
*/ */
async holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean> { async holdsTeamMember(org: string, team: string, username: string, password: string): Promise<boolean> {
const me = await fetch(`${this.baseUrl}/api/v1/user`, { const me = await timedFetch(`${this.baseUrl}/api/v1/user`, {
headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") }, headers: { Authorization: "Basic " + Buffer.from(`${username}:${password}`).toString("base64") },
}); }, "GET /user", FORGE_REQUEST_TIMEOUT_MS);
if (me.status === 401 || me.status === 403) return false; if (me.status === 401 || me.status === 403) return false;
if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`); if (me.status !== 200) throw new Error(`Gitea GET /user as ${username}: ${me.status}`);
const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`); const teams = await this.request(`/orgs/${encodeURIComponent(org)}/teams?limit=50`);
+15 -1
View File
@@ -183,6 +183,15 @@ test("named paths: each asked path's git object id at the commit, a file's or a
}); });
assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") }); assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") });
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered"); assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered");
// Listed directories (ADR 0549 §2, issue 1126): the files directly under each, not deeper; a missing one lists none.
const listed = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", "", "placement"]);
assert.deepEqual(listed.listed, {
"modules/a": { "main.go": h("6"), "module.json": h("5") },
"modules/b": {},
"": { "README.md": h("1"), "module.json": h("2") },
placement: {},
});
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).listed, undefined, "no directory asked, none listed");
} finally { } finally {
server.close(); server.close();
} }
@@ -209,7 +218,12 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another
const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try { try {
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!; const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
assert.ok("paths" in tool.input, "the tool names the argument"); assert.ok("paths" in tool.input && "listed" in tool.input, "the tool names the arguments");
const listedAnswer: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: ["modules/d"] } as any);
assert.deepEqual(listedAnswer.listed, { "modules/d": { "notes.md": h("b") } });
for (const bad of ["modules/d", ["../x"], ["/abs"], [1]]) {
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: bad } as any), /listed/, JSON.stringify(bad));
}
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any); const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any);
assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } }); assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } });
const before = asked.length; const before = asked.length;
+1
View File
@@ -640,6 +640,7 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
await tool("gitea_delete_branch").run({ ...repo, branch: "feat/x" }); await tool("gitea_delete_branch").run({ ...repo, branch: "feat/x" });
await tool("gitea_get_file").run({ ...repo, path: "modules/gitea/module.json" }).catch(() => undefined); await tool("gitea_get_file").run({ ...repo, path: "modules/gitea/module.json" }).catch(() => undefined);
assert.deepEqual(state.asked, ["DELETE /repos/novox/mesh-catalog/branches/feat/x", assert.deepEqual(state.asked, ["DELETE /repos/novox/mesh-catalog/branches/feat/x",
"GET /repos/novox/mesh-catalog/contents/modules/gitea", // the file's size first (issue 1577)
"GET /repos/novox/mesh-catalog/contents/modules/gitea/module.json"]); "GET /repos/novox/mesh-catalog/contents/modules/gitea/module.json"]);
}); });
+45
View File
@@ -90,3 +90,48 @@ test("a pass pages until it reaches back past its floor, and no further", async
server.close(); server.close();
} }
}); });
// **One forge request never holds the others, and a file too big for an answer is not downloaded** (novox/hq issue 1577): a
// request for a 4.9 MB file never ended and every read through the module timed out behind it.
test("a request the forge never answers is cut at the limit and says which request", async () => {
const { GiteaClient } = await import("../client.ts");
const server = createServer((req) => { if (!req.url?.includes("fast")) return; });
await new Promise<void>((r) => server.listen(0, r));
const client = new GiteaClient(`http://127.0.0.1:${(server.address() as any).port}`, "t");
client.requestTimeoutMs = 150;
try {
const started = Date.now();
await assert.rejects(client.api("/repos/novox/hq/pulls"), /Gitea API GET \/repos\/novox\/hq\/pulls: no answer in 0\.15 s/);
assert.ok(Date.now() - started < 3000);
} finally {
server.closeAllConnections();
await new Promise<void>((r) => server.close(() => r()));
}
});
test("a file over 512 KiB is refused from its directory listing, and its body is never asked for", async () => {
const { GiteaClient } = await import("../client.ts");
const asked: string[] = [];
const server = createServer((req, res) => {
asked.push(req.url ?? "");
res.setHeader("Content-Type", "application/json");
if (req.url?.startsWith("/api/v1/repos/novox/hq/contents/bin?")) {
return res.end(JSON.stringify([{ path: "bin/big", type: "file", size: 5_000_000 }, { path: "bin/small", type: "file", size: 5 }]));
}
if (req.url?.startsWith("/api/v1/repos/novox/hq/contents/bin/small")) {
return res.end(JSON.stringify({ type: "file", encoding: "base64", sha: "s", size: 5, content: Buffer.from("hello").toString("base64") }));
}
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");
try {
await assert.rejects(client.getFile("novox", "hq", "bin/big", "main"),
/the file is 5000000 bytes, over the 512 KiB a tool answer can carry; read it from a clone/);
assert.ok(!asked.some((u) => u.includes("/contents/bin/big")), asked.join("\n"));
assert.equal((await client.getFile("novox", "hq", "bin/small", "main")).content, "hello");
} finally {
await new Promise<void>((r) => server.close(() => r()));
}
});
+53 -4
View File
@@ -28,6 +28,7 @@ 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> } interface ForgeRepo { owner: string; name: string; full: string; private: boolean; archived?: boolean; ownerVisibility: string; collaborators: Map<string, string> }
const forgeRepos: ForgeRepo[] = []; const forgeRepos: ForgeRepo[] = [];
const userRules = new Map<string, any>();
const repoFaults = { refuse: new Set<string>(), permission: new Set<string>() }; const repoFaults = { refuse: new Set<string>(), permission: new Set<string>() };
function addRepo(full: string, isPrivate: boolean, ownerVisibility = "public"): ForgeRepo { function addRepo(full: string, isPrivate: boolean, ownerVisibility = "public"): ForgeRepo {
const [owner, name] = full.split("/"); const [owner, name] = full.split("/");
@@ -84,7 +85,7 @@ function fakeForge() {
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://fake"); const url = new URL(req.url ?? "/", "http://fake");
const p = url.pathname.replace(/^\/api\/v1/, ""); const p = url.pathname.replace(/^\/api\/v1/, "");
const me = who(req); const me = url.searchParams.has("sudo") && isAdmin(who(req)) ? url.searchParams.get("sudo") : who(req);
calls.push(`${req.method} ${p} as ${me}`); calls.push(`${req.method} ${p} as ${me}`);
if (me === null) return json(res, 401, { message: "unauthorized" }); if (me === null) return json(res, 401, { message: "unauthorized" });
@@ -168,6 +169,23 @@ function fakeForge() {
if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false }); if (m[2] === "feat/unguarded") return json(res, 200, { name: m[2], protected: false });
return json(res, 404, { message: "branch does not exist" }); return json(res, 404, { message: "branch does not exist" });
} }
// A repository of a user: its rules are its administrator's (a site administrator, or a collaborator with admin).
m = p.match(/^\/repos\/(?!novox\/)([^/]+)\/([^/]+)\/branch_protections(?:\/(.*))?$/);
if (m) {
const key = `${m[1]}/${m[2]}`;
const repo = forgeRepos.find((r) => r.full === key);
if (!repo || !["admin", "owner"].includes(permissionOf(me, repo))) return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
if (req.method === "GET" && m[3] === undefined) return json(res, 200, userRules.has(key) ? [userRules.get(key)] : []);
// As gitea does: a whitelist login that cannot write the repository is dropped, and the write still answered.
const keep = (b: any) => (Array.isArray(b.merge_whitelist_usernames)
? { ...b, merge_whitelist_usernames: b.merge_whitelist_usernames.filter((u: string) => ["write", "admin", "owner"].includes(permissionOf(u, repo))) } : b);
if (req.method === "PATCH" && m[3] === "main") {
if (!userRules.has(key)) return json(res, 404, { message: "branch protection does not exist" });
Object.assign(userRules.get(key), keep(await read(req)));
return json(res, 200, userRules.get(key));
}
if (req.method === "POST") { const b = keep(await read(req)); userRules.set(key, b); return json(res, 201, b); }
}
// gitea's reqAdmin on the whole group: reading the rules is administration too. // gitea's reqAdmin on the whole group: reading the rules is administration too.
m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/(.*))?$/); m = p.match(/^\/repos\/novox\/([^/]+)\/branch_protections(?:\/(.*))?$/);
if (m) { if (m) {
@@ -205,6 +223,7 @@ function fakeForge() {
m = p.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/collaborators\/([^/]+)(\/permission)?)?$/); m = p.match(/^\/repos\/([^/]+)\/([^/]+)(?:\/collaborators\/([^/]+)(\/permission)?)?$/);
if (m) { if (m) {
const repo = forgeRepos.find((r) => r.owner === decodeURIComponent(m![1]) && r.name === decodeURIComponent(m![2])); const repo = forgeRepos.find((r) => r.owner === decodeURIComponent(m![1]) && r.name === decodeURIComponent(m![2]));
if (repo && req.method === "GET" && !m[3] && url.searchParams.has("sudo") && repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" });
if (!repo || permissionOf(me, repo) === "none") return json(res, 404, { message: "repository does not exist" }); if (!repo || permissionOf(me, repo) === "none") return json(res, 404, { message: "repository does not exist" });
if (req.method === "GET" && !m[3]) { if (req.method === "GET" && !m[3]) {
const mine = permissionOf(me, repo); const mine = permissionOf(me, repo);
@@ -214,7 +233,8 @@ function fakeForge() {
if (req.method === "GET" && m[4]) { if (req.method === "GET" && m[4]) {
if (repoFaults.permission.has(repo.full)) return json(res, 500, { message: "the forge is not well" }); 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" }); if (!isAdmin(me) && me !== user) return json(res, 403, { message: "only the user or an administrator" });
return json(res, 200, { permission: permissionOf(user, repo) }); // As gitea answers: the collaborator's own permission, not what a team gives the account.
return json(res, 200, { permission: repo.collaborators.get(user) ?? "read" });
} }
if (req.method === "PUT" && !m[4]) { 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. // gitea's reqAdmin: the repository's administration, which on a repository of another user the site administrator alone holds.
@@ -608,7 +628,8 @@ test("the merging account is given write on each repository the indexer's module
await writes.take({ key: "car-hunter-2", op: "put", value: { v: 1, repository: "jschoubben/car-hunter" } }); 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"); assert.equal(forge.calls.length, from, "a repository already written asks nothing more");
const repo = (full: string) => forgeRepos.find((x) => x.full === full)!; const repo = (full: string) => forgeRepos.find((x) => x.full === full)!;
assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "write"); assert.equal(repo("jschoubben/car-hunter").collaborators.get(TOOLS_ACCOUNT), "write", "a user's repository is written, not administered");
assert.equal(userRules.has("jschoubben/car-hunter"), false, "a repository with no rule for main is given none");
assert.equal(repo("novox/hq").collaborators.size, 0, "the organisation's repository is written through its team, no collaborator"); 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.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.deepEqual(said, [{ type: WRITABLE_EVENT, body: { owner: "jschoubben", repo: "car-hunter", account: TOOLS_ACCOUNT } }]);
@@ -636,9 +657,37 @@ test("the merging account is given write on each repository the indexer's module
assert.ok(!forge.calls.slice(puts).some((c) => c.startsWith("PUT ")), "no write is asked on a repository no module builds from"); 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"); assert.equal(repo("jschoubben/dotfiles").collaborators.get(TOOLS_ACCOUNT), "read");
repoFaults.refuse.clear(); repoFaults.refuse.clear();
// The operator's rule is left as set (the operator, 2026-10-26: "Write, rule untouched"): only mesh-tools is added to its
// merge whitelist; a status the mesh does not set and required approvals are said as blocking the mesh's merge, unchanged.
const operators = { rule_name: "main", enable_push: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"],
enable_status_check: true, status_check_contexts: ["mesh/check", "mesh/merge-gate", "ci/external"], required_approvals: 2,
block_admin_merge_override: false, require_signed_commits: true, block_on_outdated_branch: true, block_on_rejected_reviews: true };
userRules.set("jschoubben/snake", structuredClone(operators));
const after = await grantTool.run({ repositories: ["jschoubben/snake"] }) as any; const after = await grantTool.run({ repositories: ["jschoubben/snake"] }) as any;
assert.deepEqual(after.granted, ["jschoubben/snake"]); assert.deepEqual(after.granted, ["jschoubben/snake"]);
assert.deepEqual(((await unwritableTool.run({})) as any).unwritable, []); assert.equal(repo("jschoubben/snake").collaborators.get(TOOLS_ACCOUNT), "write");
assert.deepEqual(userRules.get("jschoubben/snake"), { ...operators, merge_whitelist_usernames: ["jschoubben", TOOLS_ACCOUNT] }, "only the whitelist changed");
assert.deepEqual(after.merge_whitelist, [{ repository: "jschoubben/snake", changed: true, listed: ["jschoubben", TOOLS_ACCOUNT] }]);
const blockedWhy = "writable, but main's rule blocks the mesh's merge: it requires the status ci/external, which the mesh does not set; " +
"it requires 2 approving reviews on the forge, which the gate does not give; it requires signed commits, which the mesh does not make; " +
"it refuses a branch behind main, which the mesh does not bring up to date; " +
"it refuses a merge after a review asked for changes, which the gate does not clear";
assert.deepEqual(after.merge_blocked, [{ repository: "jschoubben/snake", why: blockedWhy }]);
const again = await unwritableTool.run({}) as any;
assert.deepEqual([again.unwritable, again.merge_blocked], [[], [{ repository: "jschoubben/snake", why: blockedWhy }]]);
const writesBefore = forge.calls.filter((c) => /^PATCH .*branch_protections/.test(c)).length;
assert.deepEqual(((await grantTool.run({ repositories: ["jschoubben/snake"] })) as any).merge_whitelist[0].changed, false);
assert.equal(forge.calls.filter((c) => /^PATCH .*branch_protections/.test(c)).length, writesBefore, "a listed account is not written again");
// The mesh's own statuses never block: a rule requiring only them, with mesh-tools listed, is not reported.
userRules.set("jschoubben/snake", { rule_name: "main", enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT],
enable_status_check: true, status_check_contexts: ["mesh/check", "mesh/merge-gate", "mesh/repo-check"] });
assert.deepEqual(((await unwritableTool.run({})) as any).merge_blocked, []);
assert.deepEqual(((await grantTool.run({ repositories: ["jschoubben/snake"] })) as any).merge_blocked, []);
// The forge drops a whitelist login that cannot write and still answers 200: the whitelist is read back and that fails loud.
userRules.set("jschoubben/dotfiles", { rule_name: "main", enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"] });
await assert.rejects(GiteaAdmin.fromEnv(env).ensureMergerListed("jschoubben", "dotfiles", TOOLS_ACCOUNT),
/mesh-tools was added to the merge whitelist of main's rule main of jschoubben\/dotfiles, but the forge holds \[jschoubben\]: it drops a login that cannot write/);
userRules.delete("jschoubben/dotfiles");
// A module gone from the indexer leaves its repository out of the delivered list. // A module gone from the indexer leaves its repository out of the delivered list.
await writes.take({ key: "snake", op: "delete" }); await writes.take({ key: "snake", op: "delete" });
assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]); assert.deepEqual(writes.delivered(), ["jschoubben/car-hunter", "novox/hq"]);
+15 -3
View File
@@ -76,6 +76,17 @@ function namedPaths(raw: unknown): Record<string, string[]> | undefined {
return out; return out;
} }
/** The `listed` argument of gitea_commit_trees: [directory], each plain (no `.` or `..` segment, not absolute, the root
* the empty name); anything else is refused before the forge is asked. Absent is absent. */
function listedDirs(raw: unknown): string[] | undefined {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("listed is [directory, ...]");
for (const d of raw as string[]) {
if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`listed: ${JSON.stringify(d)} is not a plain relative directory`);
}
return raw as string[];
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] { export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin)); return namesChecked(allGiteaTools(gitea, admin));
} }
@@ -374,7 +385,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
// ---- Contents and branches ---- // ---- Contents and branches ----
{ {
name: "gitea_get_file", name: "gitea_get_file",
description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch).", description: "One file's contents from a repository, decoded, at a branch, tag or commit (default the repository's default branch). Refuses a file over 512 KiB, which a tool answer cannot carry: read it from a clone.",
input: { input: {
owner: { type: "string", description: "the repository owner" }, owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" }, repo: { type: "string", description: "the repository name" },
@@ -550,19 +561,20 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
}, },
{ {
name: "gitea_commit_trees", name: "gitea_commit_trees",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.", description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. With listed, [directory], the answer also carries listed, {directory: {name: git blob id}} of the files directly under each (the catalogue's placement files, novox/hq ADR 0549 §2). A listing the forge cut is refused, never answered in part. Reads only.",
input: { input: {
owner: { type: "string", description: "the repository owner" }, owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" }, repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the commit, its full 40-hex sha" }, sha: { type: "string", description: "the commit, its full 40-hex sha" },
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" }, paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
listed: { type: "array", items: { type: "string" }, description: "optional, [directory]: the files directly under each, {directory: {name: git blob id}}, are answered under listed; a directory the commit lacks lists none" },
}, },
run: async (args) => { run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const sha = String(args.sha ?? "").trim(); const sha = String(args.sha ?? "").trim();
if (!owner || !repo) throw new Error("name the repository: owner and repo"); if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha"); if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths)); return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths), listedDirs(args.listed));
}, },
}, },
{ {
+100 -25
View File
@@ -18,10 +18,18 @@
// `repo.writable` {owner, repo, account}: mesh-delivery-gate, which does not ask the operator's Approve of a pull request // `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. // whose merge the forge would refuse, reads that repository again on it.
// //
// **A repository of a user is written, and main's rule is left as the operator set it** (the operator, 2026-10-26, asked
// "Write, rule untouched"): the account is a write collaborator there, and only added to main's merge whitelist when that
// rule keeps one (client.ts ensureMergerListed). What else in main's rule stops the mesh's merge (a status the mesh does not
// set, approvals the gate does not give) is changed by nobody but said: both tools list such a repository as writable, but
// blocked, with why (merge_blocked).
//
// Not taken away: a repository whose last module goes from the indexer keeps its collaborator until a hand removes 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. // gitea_unwritable_repos and gitea_grant_write say the delivered list as it is now.
import type { ForgeRepository } from "./client.js"; import type { ForgeRepository } from "./client.js";
import type { BranchProtection } from "./protection.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT } from "./pulls.js";
/** The indexer's state this module reads: one key per registered module, its value naming the repository. */ /** 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"; export const INDEXER_MODULES = "mesh-delivery-indexer.modules";
@@ -34,8 +42,35 @@ export interface WriteForge {
repository(owner: string, repo: string): Promise<ForgeRepository | null>; repository(owner: string, repo: string): Promise<ForgeRepository | null>;
repositoryPermission(owner: string, repo: string, user: string): Promise<string>; repositoryPermission(owner: string, repo: string, user: string): Promise<string>;
addWriteCollaborator(owner: string, repo: string, user: string): Promise<void>; addWriteCollaborator(owner: string, repo: string, user: string): Promise<void>;
mainRule(owner: string, repo: string): Promise<BranchProtection | null>;
ensureMergerListed(owner: string, repo: string, account: string): Promise<{ changed: boolean; rule: BranchProtection | null }>;
} }
/** The statuses every pull request the mesh delivers carries (mesh-delivery's checks); a rule requiring another blocks. */
const MESH_CONTEXTS = new Set([CHECK_STATUS, CHECK_CONTEXT, REPO_CHECK_CONTEXT]);
/** What in main's rule stops the mesh's merge, in words; empty when nothing does. Changed by nobody here: the operator's. */
export function mergeBlocks(rule: BranchProtection | null, account: string): string[] {
if (!rule) return [];
const blocks: string[] = [];
if (rule.enable_merge_whitelist === true && !(rule.merge_whitelist_usernames ?? []).includes(account)) {
blocks.push(`its merge whitelist [${(rule.merge_whitelist_usernames ?? []).join(", ")}] leaves out ${account} (gitea_grant_write adds it)`);
}
if (rule.enable_status_check === true) {
for (const c of rule.status_check_contexts ?? []) {
if (!MESH_CONTEXTS.has(c)) blocks.push(`it requires the status ${c}, which the mesh does not set`);
}
}
const approvals = Number(rule.required_approvals ?? 0);
if (approvals > 0) blocks.push(`it requires ${approvals} approving review${approvals === 1 ? "" : "s"} on the forge, which the gate does not give`);
if (rule.require_signed_commits === true) blocks.push("it requires signed commits, which the mesh does not make");
if (rule.block_on_outdated_branch === true) blocks.push("it refuses a branch behind main, which the mesh does not bring up to date");
if (rule.block_on_rejected_reviews === true) blocks.push("it refuses a merge after a review asked for changes, which the gate does not clear");
return blocks;
}
const blockedWhy = (blocks: string[]): string => `writable, but main's rule blocks the mesh's merge: ${blocks.join("; ")}`;
/** One change of the indexer's modules state, as the SDK's state watch hands it. */ /** One change of the indexer's modules state, as the SDK's state watch hands it. */
export interface ModulesChange { export interface ModulesChange {
key: string; key: string;
@@ -51,8 +86,13 @@ export interface Written {
delivered: string[]; delivered: string[];
/** Made writable now, each read back from the forge. */ /** Made writable now, each read back from the forge. */
granted: string[]; granted: string[];
/** Written already (the organisation's team, or a collaborator that writes). */ /** Written already (a collaborator of a user's repository), or written through the organisation's team. */
already: string[]; already: string[];
/** Each repository of a user: whether the account was added to main's merge whitelist now, and the whitelist read back
* (null: main's rule keeps no whitelist, or there is no rule, and whoever writes may merge). */
merge_whitelist: { repository: string; changed: boolean; listed: string[] | null }[];
/** Writable, but main's rule (the operator's, left as set) stops the mesh's merge, and why. */
merge_blocked: { repository: string; why: string }[];
/** The forge refused, in its own words; or the repository is the organisation's, whose team should write it. */ /** The forge refused, in its own words; or the repository is the organisation's, whose team should write it. */
refused: { repository: string; why: string }[]; refused: { repository: string; why: string }[];
/** Repositories the account cannot write that are left so, and why: they build no registered module. */ /** Repositories the account cannot write that are left so, and why: they build no registered module. */
@@ -118,7 +158,7 @@ export class DeliveredWrites {
"(the watch is not granted or not answered); nothing is granted"); "(the watch is not granted or not answered); nothing is granted");
} }
const delivered = this.delivered(); const delivered = this.delivered();
const result: Written = { account: this.account, delivered, granted: [], already: [], refused: [], left_read_only: [] }; const result: Written = { account: this.account, delivered, granted: [], already: [], merge_whitelist: [], merge_blocked: [], refused: [], left_read_only: [] };
const named = only && only.length ? only.map((x) => String(x).trim()) : null; const named = only && only.length ? only.map((x) => String(x).trim()) : null;
const targets: string[] = []; const targets: string[] = [];
for (const full of named ?? delivered) { for (const full of named ?? delivered) {
@@ -140,36 +180,55 @@ export class DeliveredWrites {
result.refused.push({ repository: full, why: `its permission could not be read — ${said(err)}` }); result.refused.push({ repository: full, why: `its permission could not be read — ${said(err)}` });
continue; continue;
} }
if (WRITES.has(permission)) {
this.writable.add(full);
result.already.push(full);
continue;
}
if (owner === this.org) { if (owner === this.org) {
if (WRITES.has(permission)) {
this.writable.add(full);
result.already.push(full);
continue;
}
// The organisation's repositories are written through its team (tools-account.ts), never a collaborator. // 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 ` + 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 ` + `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)" }); "set again at the next mint of the tools' token)" });
continue; continue;
} }
try { // A repository of a user: the account writes it, and is put on main's merge whitelist when the rule keeps one.
await this.forge.addWriteCollaborator(owner, name, this.account); let changed = false;
} catch (err) { if (!WRITES.has(permission)) {
result.refused.push({ repository: full, why: said(err) }); try {
continue; 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;
}
changed = true;
} }
let after: string;
try { try {
after = await this.forge.repositoryPermission(owner, name, this.account); const { changed: listedNow, rule } = await this.forge.ensureMergerListed(owner, name, this.account);
result.merge_whitelist.push({ repository: full, changed: listedNow,
listed: rule?.enable_merge_whitelist === true ? (rule.merge_whitelist_usernames ?? []) : null });
const blocks = mergeBlocks(rule, this.account);
if (blocks.length > 0) result.merge_blocked.push({ repository: full, why: blockedWhy(blocks) });
} catch (err) { } catch (err) {
result.refused.push({ repository: full, why: `granted, but its permission could not be read back — ${said(err)}` }); result.refused.push({ repository: full, why: `${this.account} writes it, but could not be put on main's merge whitelist — ${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; continue;
} }
this.writable.add(full); this.writable.add(full);
if (!changed) {
result.already.push(full);
continue;
}
result.granted.push(full); result.granted.push(full);
await this.emit(WRITABLE_EVENT, { owner, repo: name, account: this.account }).catch((err) => 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)}`)); this.log(`${this.account} writes ${full} now, and that could not be said on the bus — ${said(err)}`));
@@ -192,10 +251,12 @@ export class DeliveredWrites {
} }
/** Every delivered repository the account cannot write, as the forge says now, and why. */ /** 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 }[] }> { async unwritable(): Promise<{ account: string; delivered: number; unwritable: { repository: string; permission: string; why: string }[];
merge_blocked: { repository: 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`); 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 delivered = this.delivered();
const out: { repository: string; permission: string; why: string }[] = []; const out: { repository: string; permission: string; why: string }[] = [];
const blocked: { repository: string; why: string }[] = [];
for (const full of delivered) { for (const full of delivered) {
const [owner, name] = split(full); const [owner, name] = split(full);
let permission: string; let permission: string;
@@ -205,12 +266,21 @@ export class DeliveredWrites {
out.push({ repository: full, permission: "unknown", why: `the forge did not answer ${this.account}'s permission on it — ${said(err)}` }); out.push({ repository: full, permission: "unknown", why: `the forge did not answer ${this.account}'s permission on it — ${said(err)}` });
continue; continue;
} }
if (WRITES.has(permission)) continue; if (WRITES.has(permission)) {
if (owner === this.org) continue; // the organisation's main rules are the mesh's own (mainrules.ts)
try {
const blocks = mergeBlocks(await this.forge.mainRule(owner, name), this.account);
if (blocks.length > 0) blocked.push({ repository: full, why: blockedWhy(blocks) });
} catch (err) {
blocked.push({ repository: full, why: `writable, but main's rule could not be read — ${said(err)}` });
}
continue;
}
const modules = [...this.modules].filter(([, r]) => r === full).map(([m]) => m).sort(); 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} ` + 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` }); `"${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 }; return { account: this.account, delivered: delivered.length, unwritable: out, merge_blocked: blocked };
} }
} }
@@ -224,14 +294,19 @@ export function writeTools(writes: () => DeliveredWrites): Tool[] {
description: "Every repository that builds a registered module (the indexer's modules, read over the bus) on which " + 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 " + "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 " + "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.", "delivery gate does not ask its Approve. Empty when it writes every one. Its merge_blocked lists each repository of " +
"a user it writes whose main's rule (the operator's, left as set) still stops the mesh's merge: a merge whitelist " +
"without it, a required status the mesh does not set, required approving reviews. Read with the site administrator's login.",
input: {}, input: {},
run: async () => writes().unwritable(), run: async () => writes().unwritable(),
}, },
{ {
name: "gitea_grant_write", name: "gitea_grant_write",
description: "Give the mesh's merging forge account (mesh-tools) write, as a collaborator, on every repository that " + description: "Give the mesh's merging forge account (mesh-tools) write, as a collaborator, on every repository of a " +
"builds a registered module and that it cannot write now, or on the repositories named (each must build one). " + "user that builds a registered module, or on the repositories named (each must build one). Main's rule there is left " +
"as the operator set it; only when it keeps a merge whitelist is mesh-tools added to it, read back (merge_whitelist). " +
"What else in that rule stops the mesh's merge (a required status the mesh does not set, required approving reviews) " +
"is changed by nobody and said in merge_blocked. " +
"The module does this itself at its start and when a module of a new repository is registered; this is the same " + "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 " + "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 " + "is written through its team and needs nothing. Answers what was granted (each read back from the forge), what " +
+9 -1
View File
@@ -1 +1,9 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the copies of this package in every module that carries one.
modules/xorg/internal/desktop/*.go
modules/lemurs/internal/desktop/*.go
modules/i3/internal/desktop/*.go
modules/xterm/internal/desktop/*.go
modules/adwaita/internal/desktop/*.go
@@ -1 +1,9 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the copies of this package in every module that carries one.
modules/xorg/internal/desktop/*.go
modules/lemurs/internal/desktop/*.go
modules/i3/internal/desktop/*.go
modules/xterm/internal/desktop/*.go
modules/adwaita/internal/desktop/*.go
@@ -84,9 +84,17 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config // 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. // 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 var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
// And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
// nothing new.
var cores func() float64
var hours func() (*buildstep.BuildWindow, string)
if capacity == nil { if capacity == nil {
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say)
} }
ask := m.askSteps(steps) ask := m.askSteps(steps)
var load func(string) ([]byte, error) var load func(string) ([]byte, error)
@@ -97,7 +105,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
} }
} }
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load, 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, Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say, Has: m.Has,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil { if err := m.Check(ctx); err != nil {
return nil, err return nil, err
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"slices" "slices"
"strings" "strings"
@@ -125,12 +126,28 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) {
raw, _ := os.ReadFile(moduleDir + "/module.json") raw, _ := os.ReadFile(moduleDir + "/module.json")
var doc struct { var doc struct {
Config map[string]struct { Config map[string]struct {
Default string `json:"default"` Default json.RawMessage `json:"default"`
} `json:"config"` } `json:"config"`
} }
_ = json.Unmarshal(raw, &doc) _ = json.Unmarshal(raw, &doc)
if doc.Config[buildstep.CapacitySetting].Default != buildstep.DefaultCapacities { // Issue 1806's follow-up: the cores a step needs is a setting of its own, in the settings file, with the SDK's default.
t.Errorf("the capacity's default = %q, want %q", doc.Config[buildstep.CapacitySetting].Default, buildstep.DefaultCapacities) if cores := string(doc.Config[buildstep.CoresPerStepSetting].Default); cores != fmt.Sprint(buildstep.DefaultCoresPerStep[delivery.AskGo]) {
t.Errorf("the cores-per-step default = %s, want %g", cores, buildstep.DefaultCoresPerStep[delivery.AskGo])
}
if got := buildstep.NodeCoresPerStep(moduleDir+"/files/settings.json", 0, nil)(); got != buildstep.DefaultCoresPerStep[delivery.AskGo] {
t.Errorf("the settings template holds cores-per-step %g", got)
}
// And the build hours (the operator, 2026-10-26): a setting of their own, empty by default (any hour), in the template.
if hours := string(doc.Config[buildstep.BuildHoursSetting].Default); hours != `""` {
t.Errorf("the build-hours default = %s, want empty", hours)
}
if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" {
t.Errorf("the settings template holds build hours %v (%s)", w, fault)
}
var capacity string
_ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity)
if capacity != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q, want %q", capacity, buildstep.DefaultCapacities)
} }
settings := resource(t, m, "settings") settings := resource(t, m, "settings")
if settings["path"] != "${dir:state}/settings.json" { if settings["path"] != "${dir:state}/settings.json" {
@@ -116,7 +116,7 @@ func TestTheIntegrationLevelDigestsUnderBothTags(t *testing.T) {
t.Errorf("the integration digest listed %q", s.command) t.Errorf("the integration digest listed %q", s.command)
} }
} }
if DigestVersion != "5" { if DigestVersion != "6" {
t.Errorf("the digest's form changed in kind and is still version %s", DigestVersion) t.Errorf("the digest's form changed in kind and is still version %s", DigestVersion)
} }
} }
@@ -15,6 +15,8 @@ import (
func (m *Module) cpuLimits() buildcpu.Limits { func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1 slots := 1
switch { switch {
case m.Held != nil:
slots = m.Held.CapacityNow() // the capacity the puller judges by, bounded by the node's build slice
case m.Capacity != nil: case m.Capacity != nil:
slots = m.Capacity() slots = m.Capacity()
case m.Settings != "": case m.Settings != "":
@@ -32,5 +34,9 @@ func (m *Module) threadEnv() []string {
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime // 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. // to (its unit and its slice), read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any { func (m *Module) cpuStatus() map[string]any {
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()} out := map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
if m.Held != nil {
out["capacity"] = m.Held.CapacityDetail() // the capacity judged by and why (issue 1806's follow-up)
}
return out
} }
+138 -14
View File
@@ -16,6 +16,7 @@ import (
"io" "io"
"io/fs" "io/fs"
"os" "os"
"path"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv" "strconv"
@@ -29,17 +30,25 @@ import (
// commits (the lead's decision L5) and the integration level's tags integration,isolation (the checker's agreement). // commits (the lead's decision L5) and the integration level's tags integration,isolation (the checker's agreement).
// 4: the siblings' commits enter only the digest of a package whose tests reach a file naming MESH_CHECK_BESIDE (ADR 0536 §1). // 4: the siblings' commits enter only the digest of a package whose tests reach a file naming MESH_CHECK_BESIDE (ADR 0536 §1).
// 5: a catalogue module's non-Go files enter the digest of its packages (issue 1935). // 5: a catalogue module's non-Go files enter the digest of its packages (issue 1935).
const DigestVersion = "5" // 6: a reads-outside marker that lists what its tests read enters the digest with those files' contents (issue 1936).
const DigestVersion = "6"
// besideVariable is the name a file bears when it reads where the siblings lie; a package whose tests reach such a file // besideVariable is the name a file bears when it reads where the siblings lie; a package whose tests reach such a file
// depends on the siblings' commits. // depends on the siblings' commits.
const besideVariable = "MESH_CHECK_BESIDE" const besideVariable = "MESH_CHECK_BESIDE"
// ReadsOutsideMarker is the name of a file in a package's directory that says its tests read files outside it (a // ReadsOutsideMarker is the name of a file in a package's directory that says its tests read files outside it (a
// repository's documents, a sibling directory): such a package is never kept (issue 1935). The digest hashes only what // repository's documents, a sibling directory), which the Go toolchain does not list (issue 1935). It lists, one per
// the Go toolchain lists, so it cannot name what a test opens elsewhere; a marker file, beside the Go files where a // line, what those tests read, as paths from the repository's root; a path may be a glob (filepath.Match), and a
// reader of the package sees it, is the plain way to say so until the checker keeps the files a test opened (the Go // directory it names is read whole (issue 1936). Blank lines and lines starting with # are not read. A line starting
// test log) with each verdict. // with ? names a path whose absence is what the tests read (a file that must not be there): it may match nothing.
//
// The digest then hashes, besides what the toolchain lists, every line of the marker, every file it names (under its
// path in the tree, by content) and every file of the package's own directory, Go files included (a file its build tags
// exclude is one go list does not hash). A marker that lists nothing, a line that is not a clean relative path, or a
// line without ? that matches nothing makes the package one that is never kept (its digest a fresh random hex): a list
// nobody can trust keeps nothing. The exact fix, the files a test opened kept
// from the Go test log with each verdict, replaces the lists (issue 1936).
const ReadsOutsideMarker = "reads-outside" 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 // NeedsMarker is the name of a file in a package's directory that names, one per line, what its integration tests need
@@ -128,9 +137,13 @@ type DigestResult struct {
// only when they are the siblings it gave the fetch. // only when they are the siblings it gave the fetch.
Beside []string `json:"beside,omitempty"` Beside []string `json:"beside,omitempty"`
WithoutTests []string `json:"without_tests,omitempty"` WithoutTests []string `json:"without_tests,omitempty"`
// ReadsOutside names the packages that carry the marker: each has a digest that no kept verdict can have (a fresh // ReadsOutside names the packages whose marker lists nothing it can trust (ReadsOutsideMarker): each has a digest
// random one), so the check runs it every time. // that no kept verdict can have (a fresh random one), so the check runs it every time.
ReadsOutside []string `json:"reads_outside,omitempty"` ReadsOutside []string `json:"reads_outside,omitempty"`
// neverKept is, per package of ReadsOutside, why its marker cannot be trusted; hashedFiles caches the content lines
// of the files markers name, which several packages share (the catalogue's manifests).
neverKept map[string]string
hashedFiles map[string]string
// Needs are the packages whose tests at this level need something of the node, as their NeedsMarker names it. // 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"` Needs []delivery.PackageNeeds `json:"needs,omitempty"`
// JudgedBy is the program that ran the digest (ADR 0535 §5): "installed", or "candidate <merge commit>" under the // JudgedBy is the program that ran the digest (ADR 0535 §5): "installed", or "candidate <merge commit>" under the
@@ -247,7 +260,7 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string) (Diges
sort.Slice(res.Needs, func(i, j int) bool { return res.Needs[i].Package < res.Needs[j].Package }) sort.Slice(res.Needs, func(i, j int) bool { return res.Needs[i].Package < res.Needs[j].Package })
for _, p := range res.ReadsOutside { for _, p := range res.ReadsOutside {
if m.Say != nil { 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) m.Say("the %s level of %s always runs: its %s %s, so its tests read files outside it that no digest names", level, p, ReadsOutsideMarker, res.neverKept[p])
} }
} }
return res, nil return res, nil
@@ -316,18 +329,27 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
res.Needs = append(res.Needs, delivery.PackageNeeds{Package: path, Needs: n}) res.Needs = append(res.Needs, delivery.PackageNeeds{Package: path, Needs: n})
} }
} }
if _, err := os.Stat(filepath.Join(root.Dir, ReadsOutsideMarker)); err == nil { declared, why, err := declaredReads(tree, root, res.hashed())
// The digest of a package that reads outside itself is one nothing can have kept: the key is a fresh random if err != nil {
// hex, so the ask finds no kept verdict and runs it, and what the checker writes under it is never read again. return fmt.Errorf("%s: its %s: %w", path, ReadsOutsideMarker, err)
}
if why != "" {
// The digest of a package whose reads outside itself are not listed 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.
var nonce [32]byte var nonce [32]byte
if _, err := rand.Read(nonce[:]); err != nil { if _, err := rand.Read(nonce[:]); err != nil {
return err return err
} }
res.Packages[path] = hex.EncodeToString(nonce[:]) res.Packages[path] = hex.EncodeToString(nonce[:])
res.ReadsOutside = append(res.ReadsOutside, path) res.ReadsOutside = append(res.ReadsOutside, path)
if res.neverKept == nil {
res.neverKept = map[string]string{}
}
res.neverKept[path] = why
continue continue
} }
d, err := packageDigest(root, testMain, pkgs, tree, header, beside) d, err := packageDigest(root, testMain, pkgs, tree, header, beside, declared)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
@@ -340,14 +362,15 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
// tests reach in the module's own tree (by their content, under their path in the tree), the version and checksum of // tests reach in the module's own tree (by their content, under their path in the tree), the version and checksum of
// every module outside it, and the package's own test data. The beside lines (the siblings' commits) join the header // every module outside it, and the package's own test data. The beside lines (the siblings' commits) join the header
// only when a file the tests reach names MESH_CHECK_BESIDE. A package of the standard library is the Go version's. // only when a file the tests reach names MESH_CHECK_BESIDE. A package of the standard library is the Go version's.
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside []string) (string, error) { // The lines a package's ReadsOutsideMarker declares join them (declaredReads).
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside, declared []string) (string, error) {
reached := map[string]*goPackage{root.ImportPath: root} reached := map[string]*goPackage{root.ImportPath: root}
for _, dep := range testMain.Deps { for _, dep := range testMain.Deps {
if p := pkgs[dep]; p != nil { if p := pkgs[dep]; p != nil {
reached[dep] = p reached[dep] = p
} }
} }
lines := []string{} lines := append([]string{}, declared...)
readsBeside := false readsBeside := false
for _, p := range reached { for _, p := range reached {
if p.Standard || p.Module == nil { if p.Standard || p.Module == nil {
@@ -469,3 +492,104 @@ func contentLine(tree string, p *goPackage, path string) (string, error) {
sum := sha256.Sum256(raw) sum := sha256.Sum256(raw)
return "file " + label + " " + hex.EncodeToString(sum[:]), nil return "file " + label + " " + hex.EncodeToString(sum[:]), nil
} }
// hashed is the cache of content lines a digest step shares across its packages.
func (r *DigestResult) hashed() map[string]string {
if r.hashedFiles == nil {
r.hashedFiles = map[string]string{}
}
return r.hashedFiles
}
// declaredReads are the digest lines a package's ReadsOutsideMarker adds: each of its lines, the content line of every
// file it names, and those of every file of the package's own directory. Without a marker it answers nothing. why is
// set, and the lines nil, when the marker cannot be trusted: it lists nothing, a line is not a clean relative path, or
// a line without ? matches nothing.
func declaredReads(tree string, root *goPackage, cache map[string]string) (lines []string, why string, err error) {
raw, err := os.ReadFile(filepath.Join(root.Dir, ReadsOutsideMarker))
if errors.Is(err, fs.ErrNotExist) {
return nil, "", nil
}
if err != nil {
return nil, "", err
}
var patterns []string
for _, l := range strings.Split(string(raw), "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
patterns = append(patterns, l)
}
}
if len(patterns) == 0 {
return nil, "lists nothing", nil
}
seen := map[string]bool{}
add := func(path string) error {
if seen[path] {
return nil
}
seen[path] = true
l, ok := cache[path]
if !ok {
var err error
if l, err = contentLine(tree, root, path); err != nil {
return err
}
cache[path] = l
}
lines = append(lines, "read "+l)
return nil
}
for _, l := range patterns {
pattern, absent := strings.CutPrefix(l, "?")
if !cleanTreePattern(pattern) {
return nil, fmt.Sprintf("names %q, which is not a clean path from the repository's root", l), nil
}
matches, err := filepath.Glob(filepath.Join(tree, filepath.FromSlash(pattern)))
if err != nil {
return nil, fmt.Sprintf("names %q, which is not a glob", l), nil
}
if len(matches) == 0 && !absent {
return nil, fmt.Sprintf("names %q, which matches nothing (a path whose absence is read starts with ?)", l), nil
}
lines = append(lines, "reads "+l)
for _, match := range matches {
if err := walkFiles(match, add); err != nil {
return nil, "", err
}
}
}
// Every file of the package's own directory: its lists, its marker, and its Go files too, since go list leaves a
// file its build tags exclude (an integration test a unit test globs) out of what the digest hashes. A file the
// toolchain lists is hashed twice, which changes nothing.
err = walkFiles(root.Dir, add)
return lines, "", err
}
// cleanTreePattern says a marker's line is a path from the repository's root that stays inside it.
func cleanTreePattern(p string) bool {
if p == "" || strings.ContainsAny(p, " \t\\") || strings.HasPrefix(p, "/") || path.Clean(p) != p {
return false
}
for _, e := range strings.Split(p, "/") {
if e == ".." || e == "." {
return false
}
}
return true
}
// walkFiles calls each with every file at or under path, skipping .git and node_modules, in lexical order.
func walkFiles(path string, each func(string) error) error {
return filepath.WalkDir(path, func(p string, e fs.DirEntry, err error) error {
if err != nil {
return err
}
if e.IsDir() {
if n := e.Name(); p != path && (n == ".git" || n == "node_modules") {
return filepath.SkipDir
}
return nil
}
return each(p)
})
}
@@ -173,7 +173,7 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}} testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}}
pkgs[root.ImportPath] = root pkgs[root.ImportPath] = root
digest := func() string { digest := func() string {
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil) d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -190,28 +190,74 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
} }
} }
// Issue 1935: a package whose directory carries the reads-outside marker is never kept: its digest is one no kept verdict // Issue 1935, 1936: a package whose reads-outside marker lists nothing it can trust is never kept: its digest is one no
// can have (it differs on every call, though nothing changed), and the answer names it. A package without the marker // kept verdict can have (it differs on every call, though nothing changed), and the answer names it. A marker that lists
// keeps its digest. // what the tests read is kept: a change to a file it names changes the package's digest, a change elsewhere does not.
func TestAPackageWithTheReadsOutsideMarkerIsNeverKept(t *testing.T) { func TestAPackageWithTheReadsOutsideMarkerIsKeptOnlyByWhatItLists(t *testing.T) {
t.Parallel() t.Parallel()
m, tree := digestFixture(t) m, tree := digestFixture(t)
plain := digests(t, m, "unit") plain := digests(t, m, "unit")
if len(plain.ReadsOutside) != 0 { if len(plain.ReadsOutside) != 0 {
t.Fatalf("a package carries the marker in the fixture: %v", plain.ReadsOutside) t.Fatalf("a package carries the marker in the fixture: %v", plain.ReadsOutside)
} }
edit(t, tree, "c/"+ReadsOutsideMarker, "the tests read ../docs\n") c, a := "fixture/digest/c", "fixture/digest/a"
first, second := digests(t, m, "unit"), digests(t, m, "unit") if err := os.MkdirAll(filepath.Join(tree, "docs"), 0o755); err != nil {
c := "fixture/digest/c" t.Fatal(err)
if len(first.Packages[c]) != 64 || first.Packages[c] == second.Packages[c] || first.Packages[c] == plain.Packages[c] {
t.Errorf("c carries the marker and its digest is stable: %s, %s, before %s", first.Packages[c], second.Packages[c], plain.Packages[c])
} }
if strings.Join(first.ReadsOutside, ",") != c { edit(t, tree, "docs/one.md", "one\n")
t.Errorf("the answer does not name the package that reads outside: %v", first.ReadsOutside) edit(t, tree, "other.md", "other\n")
for name, marker := range map[string]string{
"an empty marker": "# nothing listed\n\n",
"a sentence": "the tests read ../docs\n",
"a path climbing out": "../docs\n",
"a path matching nothing": "docs/none.md\n",
"an absolute path": "/etc/passwd\n",
"a path that is not clean": "docs/./one.md\n",
"a good line and a bad line": "docs\nnope/*.md\n",
} {
edit(t, tree, "c/"+ReadsOutsideMarker, marker)
first, second := digests(t, m, "unit"), digests(t, m, "unit")
if len(first.Packages[c]) != 64 || first.Packages[c] == second.Packages[c] || first.Packages[c] == plain.Packages[c] {
t.Errorf("%s: c's digest is stable: %s, %s, before %s", name, first.Packages[c], second.Packages[c], plain.Packages[c])
}
if strings.Join(first.ReadsOutside, ",") != c {
t.Errorf("%s: the answer does not name the package that reads outside: %v", name, first.ReadsOutside)
}
if first.Packages[a] != plain.Packages[a] || first.Packages[a] != second.Packages[a] {
t.Errorf("%s: a has no marker and its digest moved: %s, %s, before %s", name, first.Packages[a], second.Packages[a], plain.Packages[a])
}
} }
a := "fixture/digest/a"
if first.Packages[a] != plain.Packages[a] || first.Packages[a] != second.Packages[a] { // A list: a glob, a directory read whole, and a path whose absence is read.
t.Errorf("a has no marker and its digest moved: %s, %s, before %s", first.Packages[a], second.Packages[a], plain.Packages[a]) edit(t, tree, "c/"+ReadsOutsideMarker, "# what c's tests read\ndocs/*.md\ndocs\n?gone\n")
before := digests(t, m, "unit")
if len(before.ReadsOutside) != 0 || len(before.Packages[c]) != 64 || before.Packages[c] == plain.Packages[c] {
t.Fatalf("a listed marker is not kept: %v, %s (no marker %s)", before.ReadsOutside, before.Packages[c], plain.Packages[c])
}
if again := digests(t, m, "unit"); again.Packages[c] != before.Packages[c] {
t.Errorf("nothing changed and c's digest moved")
}
for _, step := range []struct {
file, text string
moves bool
}{
{"other.md", "changed\n", false}, // outside the list
{"d/d.go", "package d\n\n// D.\n", false}, // a package c's tests do not reach
{"docs/one.md", "changed\n", true}, // a listed file
{"docs/two.md", "new\n", true}, // a new file the glob matches
{"gone", "here\n", true}, // a path whose absence was read
{"c/note.txt", "changed\n", true}, // the package's own file that is not Go
{"c/x_integration_test.go", "//go:build integration\n\npackage c\n", true}, // a Go file its build tags leave out (go list's IgnoredGoFiles)
} {
edit(t, tree, step.file, step.text)
after := digests(t, m, "unit")
if moved := after.Packages[c] != before.Packages[c]; moved != step.moves {
t.Errorf("a change to %s: c's digest moved %v, want %v", step.file, moved, step.moves)
}
if after.Packages[a] != plain.Packages[a] {
t.Errorf("a change to %s moved a's digest", step.file)
}
before = after
} }
// Issue 890/AC3: a package whose directory carries the needs marker is named with its needs at the integration level, // Issue 890/AC3: a package whose directory carries the needs marker is named with its needs at the integration level,
@@ -42,11 +42,13 @@ type FetchResult struct {
// head onto it, the paths the merge changes, and the tree of each module directory (a directory holding a // head onto it, the paths the merge changes, and the tree of each module directory (a directory holding a
// module.json) in the merge. A head that does not merge cleanly is an answer: Merge is empty, Conflicts names the // module.json) in the merge. A head that does not merge cleanly is an answer: Merge is empty, Conflicts names the
// files, and the workspace is left at the trunk commit. // files, and the workspace is left at the trunk commit.
Base string `json:"base,omitempty"` Base string `json:"base,omitempty"`
Merge string `json:"merge,omitempty"` Merge string `json:"merge,omitempty"`
Conflicts []string `json:"conflicts,omitempty"` Conflicts []string `json:"conflicts,omitempty"`
Changed []string `json:"changed,omitempty"` Changed []string `json:"changed,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"` // Programs are the changed files that are compiled programs (issue 1577); a merge answers it, a pointer to an empty list when there are none, absent for a fetch that made no merge.
Programs *[]buildstep.Program `json:"programs,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
// Beside are the sibling repositories placed as plain directories under the workspace's beside directory, which // Beside are the sibling repositories placed as plain directories under the workspace's beside directory, which
// build_go_test hands to the tests as MESH_CHECK_BESIDE (ADR 0484 clause 4). // build_go_test hands to the tests as MESH_CHECK_BESIDE (ADR 0484 clause 4).
Beside []Sibling `json:"beside,omitempty"` Beside []Sibling `json:"beside,omitempty"`
@@ -14,6 +14,7 @@ import (
"strings" "strings"
"sync" "sync"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey"
) )
@@ -132,6 +133,9 @@ func (m *Module) lint(ctx context.Context, run forgekey.Runner, tree, d string,
// lintAll lints each directory, up to m.parallel() at once like the level's tests; the failures join in directory order // lintAll lints each directory, up to m.parallel() at once like the level's tests; the failures join in directory order
// and the error is the first directory's, so the answer does not depend on which ended first (issue 1930). // and the error is the first directory's, so the answer does not depend on which ended first (issue 1930).
func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string, dirs []string, env func(d string) []string) ([]Failure, error) { func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string, dirs []string, env func(d string) []string) ([]Failure, error) {
// The ask's phases say it (issue 1890): lint of the directories, then their tests.
buildstep.Phase(ctx, fmt.Sprintf("unit: lint of %d directories", len(dirs)))
defer buildstep.Phase(ctx, fmt.Sprintf("unit: tests of %d directories", len(dirs)))
type answer struct { type answer struct {
failed []Failure failed []Failure
err error err error
@@ -41,6 +41,9 @@ func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base
} }
res.Base = mr.Base res.Base = mr.Base
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
if mr.Merge != "" {
res.Programs = &mr.Programs
}
if res.Merge == "" { if res.Merge == "" {
return nil return nil
} }
@@ -77,7 +77,7 @@ func TestFetchWithABaseMergesAndAnswersThePreview(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
if res.Base != baseSHA || res.Merge != mergeSHA || res.Commit != commitSHA || len(res.Conflicts) != 0 || if res.Base != baseSHA || res.Merge != mergeSHA || res.Commit != commitSHA || len(res.Conflicts) != 0 ||
strings.Join(res.Changed, ",") != "a.go,modules/m/main.go" { strings.Join(res.Changed, ",") != "a.go,modules/m/main.go" || res.Programs == nil || len(*res.Programs) != 0 { // 1577: a merge says it found no program
t.Errorf("result = %s", st.Result) t.Errorf("result = %s", st.Result)
} }
mt, ok := res.Trees["modules/m"] mt, ok := res.Trees["modules/m"]
@@ -1 +1,7 @@
These tests read other catalogue modules' files, outside this module (issue 1935): never kept. # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the manifests through mesh-sdk's validator (the predefined keys), gitea's and mesh-build-node's.
predefined.json
modules/gitea
modules/mesh-build-node
+3 -1
View File
@@ -1,3 +1,5 @@
{ {
"capacity": "shanks=3,ace=2,g14=0,novox=0" "capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 6,
"build-hours": ""
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026202658-805b4d9a3536
github.com/distribution/distribution/v3 v3.1.2 github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,5 +1,5 @@
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.20261026202658-805b4d9a3536 h1:+SoDdDXF7Tgj38WP4kRpUSps6DJVl6P+w5u/KfdtwnQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026202658-805b4d9a3536/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= 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/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= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+13 -1
View File
@@ -29,6 +29,18 @@
"description": "How many asks of the Go build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", "description": "How many asks of the Go build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": "shanks=3,ace=2,g14=0,novox=0" "default": "shanks=3,ace=2,g14=0,novox=0"
},
"build-hours": {
"type": "string",
"description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
"pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
"default": ""
},
"cores-per-step": {
"type": "number",
"description": "How many processors one Go step of this module needs (a full check of a large repository takes what it is given: three at about 4 cores each ran past the 30-minute step bound on 2026-10-26). The capacity above is an upper bound: on each node the module runs at most floor(the cores of its build slice's CPU quota / this) steps at once, the quota read live from its cgroup (issue 1806), so a lowered build CPU share never starves its steps. Above 0.",
"minimum": 0.1,
"default": 6
} }
}, },
"tools": [ "tools": [
@@ -107,7 +119,7 @@
"path": "${dir:state}/settings.json", "path": "${dir:state}/settings.json",
"mode": "0600", "mode": "0600",
"owner": "mesh-build-go", "owner": "mesh-build-go",
"takes-config": ["capacity"], "takes-config": ["capacity", "cores-per-step", "build-hours"],
"merge": "json", "merge": "json",
"template": "files/settings.json" "template": "files/settings.json"
}, },
+3 -2
View File
@@ -24,8 +24,9 @@ The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can re
- `build_image_fetch` fetches a commit over SSH into a workspace (a base commit makes the merge preview, as the other - `build_image_fetch` fetches a commit over SSH into a workspace (a base commit makes the merge preview, as the other
build modules' fetch). Long: answers a step id and ends on its key in `steps`. build modules' fetch). Long: answers a step id and ends on its key in `steps`.
- `build_image_build` builds one image artifact: `buildah bud` with the Dockerfile of the module's directory as file - `build_image_build` builds one image artifact: `buildah bud` with the Dockerfile `from` names (in the module's
and context, `--target`, and the images `build.on` pins as `--build-arg`. Answers the image's digest. With directory or a subdirectory of it) as file and the module's own directory as context, so a COPY names paths from the
module's root (issue 1703), `--target`, and the images `build.on` pins as `--build-arg`. Answers the image's digest. With
`publish: true` (a merged trunk commit only) the module's own process then pushes it to the registry under `publish: true` (a merged trunk commit only) the module's own process then pushes it to the registry under
`<module>/<artifact>:<tag of its own>` (the first 12 hex digits of the digest unless the call gives one), waiting out `<module>/<artifact>:<tag of its own>` (the first 12 hex digits of the digest unless the call gives one), waiting out
a registry held still, and the key carries the digest the registry serves and the reference pinned by it. Without a registry held still, and the key carries the digest the registry serves and the reference pinned by it. Without
@@ -40,9 +40,17 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config // 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. // 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 var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
// And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
// nothing new.
var cores func() float64
var hours func() (*buildstep.BuildWindow, string)
if capacity == nil { if capacity == nil {
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskImage], m.Say)
} }
ask := m.askSteps(steps) ask := m.askSteps(steps)
var load func(string) ([]byte, error) var load func(string) ([]byte, error)
@@ -50,7 +58,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) } 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, PausePut: m.Pauses, Load: load, 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, Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil { if err := m.Check(ctx); err != nil {
return nil, err return nil, err
@@ -55,18 +55,18 @@ func (m *Module) buildTool() StepTool {
} }
// unsupportedImage says why this module does not build the artifact, or "" when it does. It is the one place that // unsupportedImage says why this module does not build the artifact, or "" when it does. It is the one place that
// knows which image forms are out: ADR 0487 §1 takes the Dockerfile's directory as the build context and the base // knows which image forms are out: ADR 0487 §1 builds from the module's own directory (the build context) and the base
// from the Dockerfile or an image pinned in `build.on`, so a context that is another repository and a base that is // from the Dockerfile or an image pinned in `build.on`, so a context that is another repository and a base that is
// another module's artifact are not built (the lead's decision for R4c-9, which retires them with build-agent at R4g). // another module's artifact are not built (the lead's decision for R4c-9, which retires them with build-agent at R4g).
func unsupportedImage(manifest catalogue.Manifest, a catalogue.Artifact) string { func unsupportedImage(manifest catalogue.Manifest, a catalogue.Artifact) string {
if a.Context != nil { if a.Context != nil {
return fmt.Sprintf("the image %s of %s builds with the repository %s as its context; mesh-build-image takes the Dockerfile's directory as the build context (ADR 0487 §1) and does not build a whole repository", return fmt.Sprintf("the image %s of %s builds with the repository %s as its context; mesh-build-image takes the module's own directory as the build context (ADR 0487 §1) and does not build another repository",
a.Name, manifest.Module, a.Context.Repository) a.Name, manifest.Module, a.Context.Repository)
} }
if manifest.Build != nil { if manifest.Build != nil {
for _, on := range manifest.Build.On { for _, on := range manifest.Build.On {
if on.Module != "" { if on.Module != "" {
return fmt.Sprintf("the image %s of %s stands on the artifact %s of the module %s through the build argument %s; mesh-build-image builds only from the Dockerfile's directory and an image pinned in build.on (ADR 0487 §1)", return fmt.Sprintf("the image %s of %s stands on the artifact %s of the module %s through the build argument %s; mesh-build-image builds only from the module's own directory and an image pinned in build.on (ADR 0487 §1)",
a.Name, manifest.Module, on.Artifact, on.Module, on.Arg) a.Name, manifest.Module, on.Artifact, on.Module, on.Arg)
} }
} }
@@ -183,8 +183,14 @@ func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
return m.push(ctx, workspace, manifest.Module, local, res, tag) return m.push(ctx, workspace, manifest.Module, local, res, tag)
} }
// dockerfileOf is the Dockerfile an artifact's `from` names and the build context: the directory it lies in. It must // dockerfileOf is the Dockerfile an artifact's `from` names and the build context: the module's own directory, the
// be a regular file inside the module's own directory (checks/imageplace holds the catalogue to the same). // one its manifest lies in, whichever subdirectory the Dockerfile sits in. `from` is a path relative to that
// directory, and so is every COPY of the Dockerfile: a recipe in a subdirectory (photos' admin-client/Dockerfile
// copies admin-client/... and client-server/serve.mjs) builds as build-agent built it, and as the fingerprint reads
// the module's whole directory as its build source. Taking the Dockerfile's own directory instead failed every such
// COPY with `copier: stat ... no such file or directory` (issue 1703, 2026-10-26). The context never leaves the module's
// directory, so a module's build reads no other module's files. The Dockerfile must be a regular file inside that
// directory (checks/imageplace holds the catalogue to the same).
func dockerfileOf(moduleDir, from string) (dockerfile, contextDir string, err error) { func dockerfileOf(moduleDir, from string) (dockerfile, contextDir string, err error) {
clean := filepath.Clean(filepath.FromSlash(strings.TrimSpace(from))) clean := filepath.Clean(filepath.FromSlash(strings.TrimSpace(from)))
if from == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) { if from == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
@@ -195,7 +201,7 @@ func dockerfileOf(moduleDir, from string) (dockerfile, contextDir string, err er
if err != nil || !info.Mode().IsRegular() { if err != nil || !info.Mode().IsRegular() {
return "", "", fmt.Errorf("%q is not a file in the module's directory", from) return "", "", fmt.Errorf("%q is not a file in the module's directory", from)
} }
return dockerfile, filepath.Dir(dockerfile), nil return dockerfile, moduleDir, nil
} }
// localName names the image in buildah's store: one name for each artifact of a workspace. // localName names the image in buildah's store: one name for each artifact of a workspace.
@@ -94,7 +94,7 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
} }
} }
if !strings.HasSuffix(bud[0].line, " "+dir) { if !strings.HasSuffix(bud[0].line, " "+dir) {
t.Errorf("the context is not the Dockerfile's directory:\n%s", bud[0].line) t.Errorf("the context is not the module's directory:\n%s", bud[0].line)
} }
for _, c := range f.calls { for _, c := range f.calls {
if c.name != "buildah" { if c.name != "buildah" {
@@ -122,6 +122,43 @@ func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
} }
} }
// Issue 1703, 2026-10-26: a Dockerfile in a subdirectory of the module builds with the module's directory as its
// context, so its COPY of a path from the module's root (photos' admin-client/Dockerfile copies admin-client/... and
// client-server/serve.mjs) finds it; the Dockerfile's own directory as the context failed with `copier: stat
// "/admin-client/package.json": no such file or directory`.
func TestBuildContextIsTheModulesDirectoryForADockerfileInASubdirectory(t *testing.T) {
m, f, states := setup(t, func(s string) string {
return strings.Replace(s, `"from": "Dockerfile"`, `"from": "admin-client/Dockerfile"`, 1)
})
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
for name, body := range map[string]string{
"admin-client/Dockerfile": "ARG DEMO_BASE\nFROM ${DEMO_BASE} AS final\nCOPY admin-client/package.json client-server/serve.mjs /\n",
"admin-client/package.json": "{}\n",
"client-server/serve.mjs": "\n",
} {
if err := os.MkdirAll(filepath.Dir(filepath.Join(dir, name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
_, st := runBuild(t, m, states, nil)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
bud := f.called("bud")
if len(bud) != 1 {
t.Fatalf("buildah bud ran %d times", len(bud))
}
if !strings.Contains(bud[0].line, "--file "+dir+"/admin-client/Dockerfile ") {
t.Errorf("the Dockerfile is not the subdirectory's:\n%s", bud[0].line)
}
if !strings.HasSuffix(bud[0].line, " "+dir) {
t.Errorf("the context is not the module's directory, so admin-client/ and client-server/ are out of reach:\n%s", bud[0].line)
}
}
// 1703c, issue 1703: a failing Dockerfile step fails the step, its reason saying buildah's error from stderr, never the // 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. // last STEP line buildah writes on stdout; nothing is pushed.
func TestBuildFailsWithBuildahsError(t *testing.T) { func TestBuildFailsWithBuildahsError(t *testing.T) {
@@ -178,7 +215,7 @@ func TestBuildRefusesAnImageWithARepositoryContextOrAModuleBase(t *testing.T) {
}{ }{
"context": {func(s string) string { "context": {func(s string) string {
return strings.Replace(s, `"target": "final"`, `"target": "final", "context": {"seat": "git", "repository": "novox/mesh-controller"}`, 1) return strings.Replace(s, `"target": "final"`, `"target": "final", "context": {"seat": "git", "repository": "novox/mesh-controller"}`, 1)
}, []string{"novox/mesh-controller", "Dockerfile's directory", "ADR 0487"}}, }, []string{"novox/mesh-controller", "module's own directory", "ADR 0487"}},
"module base": {func(s string) string { "module base": {func(s string) string {
return strings.Replace(s, `"image": "docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001"`, `"module": "mesh-sdk", "artifact": "lib"`, 1) return strings.Replace(s, `"image": "docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001"`, `"module": "mesh-sdk", "artifact": "lib"`, 1)
}, []string{"artifact lib of the module mesh-sdk", "DEMO_BASE"}}, }, []string{"artifact lib of the module mesh-sdk", "DEMO_BASE"}},
@@ -15,6 +15,8 @@ import (
func (m *Module) cpuLimits() buildcpu.Limits { func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1 slots := 1
switch { switch {
case m.Held != nil:
slots = m.Held.CapacityNow() // the capacity the puller judges by, bounded by the node's build slice
case m.Capacity != nil: case m.Capacity != nil:
slots = m.Capacity() slots = m.Capacity()
case m.Settings != "": case m.Settings != "":
@@ -32,5 +34,9 @@ func (m *Module) threadEnv() []string {
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime // 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. // to (its unit and its slice), read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any { func (m *Module) cpuStatus() map[string]any {
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()} out := map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
if m.Held != nil {
out["capacity"] = m.Held.CapacityDetail() // the capacity judged by and why (issue 1806's follow-up)
}
return out
} }
@@ -31,13 +31,15 @@ const ForgeAlias = "mesh-forge"
// files in conflict, the paths the merge changes and the tree of each module directory). A head that does not // files in conflict, the paths the merge changes and the tree of each module directory). A head that does not
// merge cleanly is an answer: Merge is empty, Conflicts names the files, and the workspace is left at the trunk. // merge cleanly is an answer: Merge is empty, Conflicts names the files, and the workspace is left at the trunk.
type FetchResult struct { type FetchResult struct {
Workspace string `json:"workspace"` Workspace string `json:"workspace"`
Commit string `json:"commit"` Commit string `json:"commit"`
Base string `json:"base,omitempty"` Base string `json:"base,omitempty"`
Merge string `json:"merge,omitempty"` Merge string `json:"merge,omitempty"`
Conflicts []string `json:"conflicts,omitempty"` Conflicts []string `json:"conflicts,omitempty"`
Changed []string `json:"changed,omitempty"` Changed []string `json:"changed,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"` // Programs are the changed files that are compiled programs (issue 1577); a merge answers it, a pointer to an empty list when there are none, absent for a fetch that made no merge.
Programs *[]buildstep.Program `json:"programs,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
} }
func (m *Module) fetchTool() StepTool { func (m *Module) fetchTool() StepTool {
@@ -127,6 +129,9 @@ func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base
} }
res.Base = mr.Base res.Base = mr.Base
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
if mr.Merge != "" {
res.Programs = &mr.Programs
}
if res.Merge == "" { if res.Merge == "" {
return nil return nil
} }
@@ -1 +1,10 @@
These tests read other catalogue modules' files, outside this module (issue 1935): never kept. # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the manifests through mesh-sdk's validator (the predefined keys), with the modules they compose with.
predefined.json
modules/gitea
modules/mesh-build-go
modules/mesh-build-node
modules/buildah
modules/docker
+3 -1
View File
@@ -1,3 +1,5 @@
{ {
"capacity": "shanks=3,ace=2,g14=0,novox=0" "capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 2,
"build-hours": ""
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026202658-805b4d9a3536
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
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.20261026202658-805b4d9a3536 h1:+SoDdDXF7Tgj38WP4kRpUSps6DJVl6P+w5u/KfdtwnQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026202658-805b4d9a3536/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+13 -1
View File
@@ -24,6 +24,18 @@
"description": "How many asks of the image build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", "description": "How many asks of the image build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": "shanks=3,ace=2,g14=0,novox=0" "default": "shanks=3,ace=2,g14=0,novox=0"
},
"build-hours": {
"type": "string",
"description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
"pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
"default": ""
},
"cores-per-step": {
"type": "number",
"description": "How many processors one image step of this module needs (buildah, whose RUN lines are mostly fetches). The capacity above is an upper bound: on each node the module runs at most floor(the cores of its build slice's CPU quota / this) steps at once, the quota read live from its cgroup (issue 1806), so a lowered build CPU share never starves its steps. Above 0.",
"minimum": 0.1,
"default": 2
} }
}, },
"tools": [ "tools": [
@@ -78,7 +90,7 @@
"path": "${dir:state}/settings.json", "path": "${dir:state}/settings.json",
"mode": "0600", "mode": "0600",
"owner": "mesh-build-image", "owner": "mesh-build-image",
"takes-config": ["capacity"], "takes-config": ["capacity", "cores-per-step", "build-hours"],
"merge": "json", "merge": "json",
"template": "files/settings.json" "template": "files/settings.json"
}, },
@@ -48,9 +48,17 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
// The capacity's own change wakes the puller (issue 1887): the runtime's watch of the config value as mesh-config // 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. // 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 var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
// And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
// nothing new.
var cores func() float64
var hours func() (*buildstep.BuildWindow, string)
if capacity == nil { if capacity == nil {
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say) capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
watch = buildstep.WatchConfigServed watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskNode], m.Say)
} }
ask := m.askSteps(steps) ask := m.askSteps(steps)
var load func(string) ([]byte, error) var load func(string) ([]byte, error)
@@ -58,7 +66,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) } 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, PausePut: m.Pauses, Load: load, 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, Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Prepare(ctx); err != nil { if err := m.Prepare(ctx); err != nil {
return nil, err return nil, err
@@ -3,6 +3,7 @@ package main
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"os" "os"
"strings" "strings"
"testing" "testing"
@@ -83,12 +84,28 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) {
raw, _ := os.ReadFile(moduleDir + "/module.json") raw, _ := os.ReadFile(moduleDir + "/module.json")
var doc struct { var doc struct {
Config map[string]struct { Config map[string]struct {
Default string `json:"default"` Default json.RawMessage `json:"default"`
} `json:"config"` } `json:"config"`
} }
_ = json.Unmarshal(raw, &doc) _ = json.Unmarshal(raw, &doc)
if doc.Config[buildstep.CapacitySetting].Default != buildstep.DefaultCapacities { // Issue 1806's follow-up: the cores a step needs is a setting of its own, in the settings file, with the SDK's default.
t.Errorf("the capacity's default = %q", doc.Config[buildstep.CapacitySetting].Default) if cores := string(doc.Config[buildstep.CoresPerStepSetting].Default); cores != fmt.Sprint(buildstep.DefaultCoresPerStep[delivery.AskNode]) {
t.Errorf("the cores-per-step default = %s, want %g", cores, buildstep.DefaultCoresPerStep[delivery.AskNode])
}
if got := buildstep.NodeCoresPerStep(moduleDir+"/files/settings.json", 0, nil)(); got != buildstep.DefaultCoresPerStep[delivery.AskNode] {
t.Errorf("the settings template holds cores-per-step %g", got)
}
// And the build hours (the operator, 2026-10-26): a setting of their own, empty by default (any hour), in the template.
if hours := string(doc.Config[buildstep.BuildHoursSetting].Default); hours != `""` {
t.Errorf("the build-hours default = %s, want empty", hours)
}
if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" {
t.Errorf("the settings template holds build hours %v (%s)", w, fault)
}
var capacity string
_ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity)
if capacity != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %q", capacity)
} }
if resource(t, m, "settings")["path"] != "${dir:state}/settings.json" { if resource(t, m, "settings")["path"] != "${dir:state}/settings.json" {
t.Error("no settings file") t.Error("no settings file")
@@ -15,6 +15,8 @@ import (
func (m *Module) cpuLimits() buildcpu.Limits { func (m *Module) cpuLimits() buildcpu.Limits {
slots := 1 slots := 1
switch { switch {
case m.Held != nil:
slots = m.Held.CapacityNow() // the capacity the puller judges by, bounded by the node's build slice
case m.Capacity != nil: case m.Capacity != nil:
slots = m.Capacity() slots = m.Capacity()
case m.Settings != "": case m.Settings != "":
@@ -32,5 +34,9 @@ func (m *Module) threadEnv() []string {
// cpuStatus is what the status tool says of it: the limits a step is given, and what the kernel holds this runtime // 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. // to (its unit and its slice), read from its cgroup rather than assumed.
func (m *Module) cpuStatus() map[string]any { func (m *Module) cpuStatus() map[string]any {
return map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()} out := map[string]any{"limits": m.cpuLimits(), "in-force": buildcpu.ReadInForce()}
if m.Held != nil {
out["capacity"] = m.Held.CapacityDetail() // the capacity judged by and why (issue 1806's follow-up)
}
return out
} }
@@ -51,11 +51,13 @@ type FetchResult struct {
// commit, the merge commit of the head onto it, the paths the merge changes and the tree of each module directory. // commit, the merge commit of the head onto it, the paths the merge changes and the tree of each module directory.
// A head that does not merge cleanly is an answer: Merge is empty, Conflicts names the files, and the workspace is // A head that does not merge cleanly is an answer: Merge is empty, Conflicts names the files, and the workspace is
// left at the trunk commit with nothing installed. // left at the trunk commit with nothing installed.
Base string `json:"base,omitempty"` Base string `json:"base,omitempty"`
Merge string `json:"merge,omitempty"` Merge string `json:"merge,omitempty"`
Conflicts []string `json:"conflicts,omitempty"` Conflicts []string `json:"conflicts,omitempty"`
Changed []string `json:"changed,omitempty"` Changed []string `json:"changed,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"` // Programs are the changed files that are compiled programs (issue 1577); a merge answers it, a pointer to an empty list when there are none, absent for a fetch that made no merge.
Programs *[]buildstep.Program `json:"programs,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
// Group is the group's package the fetch packed and served to the TypeScript members (the call's `package`): // Group is the group's package the fetch packed and served to the TypeScript members (the call's `package`):
// "<name>@<version> <integrity>". // "<name>@<version> <integrity>".
Group string `json:"group,omitempty"` Group string `json:"group,omitempty"`
@@ -27,6 +27,9 @@ func (m *Module) merge(ctx context.Context, run forgekey.Runner, res *FetchResul
} }
res.Base = mr.Base res.Base = mr.Base
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
if mr.Merge != "" {
res.Programs = &mr.Programs
}
if res.Merge == "" { if res.Merge == "" {
return nil return nil
} }
@@ -1 +1,5 @@
These tests read other catalogue modules' files, outside this module (issue 1935): never kept. # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# its manifest through mesh-sdk's validator, which reads the root's predefined keys.
predefined.json
+3 -1
View File
@@ -1,3 +1,5 @@
{ {
"capacity": "shanks=3,ace=2,g14=0,novox=0" "capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 2,
"build-hours": ""
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026202658-805b4d9a3536
github.com/distribution/distribution/v3 v3.1.2 github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0 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.20261026142629-ff81bdf8cc8f h1:GYLCvz2x+JNUOTbXzyh2QwzX0sTzvRlXR7lGocWjk3A= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026202658-805b4d9a3536 h1:+SoDdDXF7Tgj38WP4kRpUSps6DJVl6P+w5u/KfdtwnQ=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026142629-ff81bdf8cc8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026202658-805b4d9a3536/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= 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/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= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+13 -1
View File
@@ -25,6 +25,18 @@
"description": "How many asks of the Node build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.", "description": "How many asks of the Node build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*", "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": "shanks=3,ace=2,g14=0,novox=0" "default": "shanks=3,ace=2,g14=0,novox=0"
},
"build-hours": {
"type": "string",
"description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
"pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
"default": ""
},
"cores-per-step": {
"type": "number",
"description": "How many processors one Node step of this module needs (tsc, esbuild and a test runner; a median step takes 15 s). The capacity above is an upper bound: on each node the module runs at most floor(the cores of its build slice's CPU quota / this) steps at once, the quota read live from its cgroup (issue 1806), so a lowered build CPU share never starves its steps. Above 0.",
"minimum": 0.1,
"default": 2
} }
}, },
"tools": [ "tools": [
@@ -100,7 +112,7 @@
"path": "${dir:state}/settings.json", "path": "${dir:state}/settings.json",
"mode": "0600", "mode": "0600",
"owner": "mesh-build-node", "owner": "mesh-build-node",
"takes-config": ["capacity"], "takes-config": ["capacity", "cores-per-step", "build-hours"],
"merge": "json", "merge": "json",
"template": "files/settings.json" "template": "files/settings.json"
}, },
+1 -1
View File
@@ -115,7 +115,7 @@ func resolving(c *Config) {
} }
return nil return nil
} }
for _, read := range []string{AssignmentsRead, DeclaredConfigRead, PredefinedRead} { for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} {
go func(read string) { go func(read string) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
time.Sleep(wait) time.Sleep(wait)
@@ -21,4 +21,16 @@ func TestAModulePulledInHasItsValuesResolvedOnItsNode(t *testing.T) {
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone { if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("a module no longer pulled in moved %+v", r.Moved) t.Errorf("a module no longer pulled in moved %+v", r.Moved)
} }
// 1126 (ADR 0549 §3, §5): a module the catalogue's placement names on a node resolves there as an assigned one does,
// while the two sources stand; it goes when neither names it.
m.mirror(t, PlacementRead, "placement.ace", `{"v":1,"node":"ace","modules":[{"module":"dunst"}]}`)
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
t.Fatalf("a placed module's values moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
_ = m.Delete(PlacementRead, "placement.ace")
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("a module no longer placed moved %+v", r.Moved)
}
} }
@@ -20,7 +20,10 @@ const (
PredefinedState = "predefined" PredefinedState = "predefined"
ProposalsState = "proposals" ProposalsState = "proposals"
AssignmentsRead = "mesh-controller.assignments" AssignmentsRead = "mesh-controller.assignments"
// PlacementRead is the indexer's placement, read from the catalogue at each merge (novox/hq ADR 0549 §2, §3): each
// node's key `placement.<node>` with the modules placed there.
PlacementRead = "mesh-delivery-indexer.placement"
DeclaredConfigRead = "mesh-controller.declared-config" DeclaredConfigRead = "mesh-controller.declared-config"
PredefinedRead = "mesh-controller.predefined" PredefinedRead = "mesh-controller.predefined"
@@ -27,6 +27,38 @@ func (c *Config) sources() (Sources, error) {
}); err != nil { }); err != nil {
return s, fmt.Errorf("the assignments cannot be read: %w", err) return s, fmt.Errorf("the assignments cannot be read: %w", err)
} }
// And every module the catalogue's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
// sources stand (ADR 0549 §5), so a pair either names is resolved, and none loses its values while the two differ.
// Placement that cannot be read yet (the indexer's state not on the bus) leaves the assignments alone, said.
standing := map[string]bool{}
for _, a := range s.Assignments {
standing[a.Node+"."+a.Module] = true
}
if err := c.each(PlacementRead, func(key string, raw []byte) error {
node, ok := strings.CutPrefix(key, "placement.")
if !ok {
return nil
}
var p struct {
Node string `json:"node"`
Modules []struct {
Module string `json:"module"`
} `json:"modules"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
problems = append(problems, fmt.Sprintf("the placement %s is not one", key))
return nil
}
for _, m := range p.Modules {
if m.Module != "" && !standing[node+"."+m.Module] {
standing[node+"."+m.Module] = true
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
}
}
return nil
}); err != nil {
problems = append(problems, fmt.Sprintf("the catalogue's placement cannot be read, so the assignments alone are resolved: %v", err))
}
if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error { if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error {
m, err := ParseModuleDeclarations(raw) m, err := ParseModuleDeclarations(raw)
if err != nil { if err != nil {
+1
View File
@@ -29,6 +29,7 @@
], ],
"reads": [ "reads": [
"mesh-controller.assignments", "mesh-controller.assignments",
"mesh-delivery-indexer.placement",
"mesh-controller.declared-config", "mesh-controller.declared-config",
"mesh-controller.predefined" "mesh-controller.predefined"
], ],
@@ -148,8 +148,32 @@ func touchesSaid(d *Delivery) string {
return strings.Join(d.Check.Modules, ", ") return strings.Join(d.Check.Modules, ", ")
} }
// checkClause is the check's state as the ask's summary says it after the pull request's or the group's name: sep and
// "passed its check", or ": its check has no verdict yet: it is running (since 21:05 UTC)" and the like.
func checkClause(c checkSaid, sep string) string {
if c.passed() {
return sep + c.Words
}
return ": " + c.Words
}
// approveCaveat is what an Approve does beyond the merge while the check has not passed: it merges before the check ends,
// or although it failed (the check gates no merge, check-gates-merge false). Empty once it passed.
func approveCaveat(c checkSaid) string {
switch c.Key {
case checkSaidPass:
return ""
case checkSaidFail:
return " although its check failed"
}
return " now, before its check ends"
}
// composeMergeAsk is the ask for a delivery, or for a group's members together. // composeMergeAsk is the ask for a delivery, or for a group's members together.
func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, heads map[string]string, now time.Time) asks.Ask { // check is the state of the check at the heads it covers (checkAt): the ask says "passed its check" only when it did, and
// otherwise that Approve merges before the check ends, or although it failed (issue 1733).
func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery, heads map[string]string, check checkSaid,
now time.Time) asks.Ask {
var a asks.Ask var a asks.Ask
a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts) a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts)
a.OnExpiry = "nothing is merged; the ask is made again" a.OnExpiry = "nothing is merged; the ask is made again"
@@ -170,8 +194,8 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
} }
if group == "" { if group == "" {
a.Headline = phone("Merge into main: "+pullSaid(d)+" "+d.Title, asks.HeadlineLength) a.Headline = phone("Merge into main: "+pullSaid(d)+" "+d.Title, asks.HeadlineLength)
summary := fmt.Sprintf("%s %s passed its checks. Approve merges it into main at %s; Decline leaves it unmerged.", summary := fmt.Sprintf("%s %s%s. Approve merges it into main at %s%s; Decline leaves it unmerged.",
pullSaid(d), d.Title, shortOf(d.Commit, 12)) pullSaid(d), d.Title, checkClause(check, " "), shortOf(d.Commit, 12), approveCaveat(check))
if unreviewed != "" { if unreviewed != "" {
summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary
} }
@@ -187,8 +211,12 @@ func composeMergeAsk(id string, d *Delivery, group string, members []*Delivery,
names = append(names, pullSaid(m)) names = append(names, pullSaid(m))
} }
a.Headline = phone("Merge into main: group "+group+" ("+strings.Join(names, ", ")+")", asks.HeadlineLength) a.Headline = phone("Merge into main: group "+group+" ("+strings.Join(names, ", ")+")", asks.HeadlineLength)
summary := fmt.Sprintf("The group %s, %d pull requests, passed its checks. Approve merges all of them into main in one pass, at the heads named; Decline leaves them unmerged.", caveat := approveCaveat(check)
group, len(members)) if caveat != "" {
caveat = "," + caveat
}
summary := fmt.Sprintf("The group %s, %d pull requests%s. Approve merges all of them into main in one pass, at the heads named%s; Decline leaves them unmerged.",
group, len(members), checkClause(check, ", "), caveat)
if unreviewed != "" { if unreviewed != "" {
summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary summary = strings.ToUpper(unreviewed[:1]) + unreviewed[1:] + ". " + summary
} }
@@ -299,6 +327,7 @@ func (h *Holder) headsHold(d *Delivery, c *ReadyRun) bool {
// §3), and says so once on each pull request it covered (ADR 0421 §6). An ask for a head a newer one replaced ends too. // §3), and says so once on each pull request it covered (ADR 0421 §6). An ask for a head a newer one replaced ends too.
// A Decline stands, and is not ended. Under the lock; the cancels run with the look's asks. // A Decline stands, and is not ended. Under the lock; the cancels run with the look's asks.
func (h *Holder) endStaleAsks() { func (h *Holder) endStaleAsks() {
h.endStaleSettles()
for _, d := range h.deliveries { for _, d := range h.deliveries {
r := d.Ready r := d.Ready
if r == nil || r.Merged != nil || d.MergedAs != "" || r.Declined != nil || (r.AskID == "" && r.Approved == nil) || len(r.Heads) == 0 { if r == nil || r.Merged != nil || d.MergedAs != "" || r.Declined != nil || (r.AskID == "" && r.Approved == nil) || len(r.Heads) == 0 {
@@ -309,6 +338,10 @@ func (h *Holder) endStaleAsks() {
h.cancels = append(h.cancels, h.dropAsk(r.AskID, "its head "+shortOf(d.Commit, 12)+" is no longer the pull request's head, or the pull request closed")) h.cancels = append(h.cancels, h.dropAsk(r.AskID, "its head "+shortOf(d.Commit, 12)+" is no longer the pull request's head, or the pull request closed"))
case !h.headsHold(d, r): case !h.headsHold(d, r):
h.cancels = append(h.cancels, h.dropAsk(r.AskID, "a head it covers moved on: the heads it covers are no longer the group's heads")) h.cancels = append(h.cancels, h.dropAsk(r.AskID, "a head it covers moved on: the heads it covers are no longer the group's heads"))
case r.Approved == nil && r.AskID != "" && r.AskCheck != "" && !h.checkGates() && h.checkMoved(d, r.AskCheck):
// The check gates no merge, so the ask was made before its verdict: once the check passes or fails (or a pass it
// named is gone), the ask is asked again in words that say so (issue 1733). Running and not started say the same.
h.cancels = append(h.cancels, h.withdrawAsk(r.AskID, "the check at its heads is now: "+h.checkAt(h.askMembers(d)).Words))
case r.Approved == nil && r.AskID != "" && !r.AskedAt.IsZero() && !h.Now().Before(r.AskedAt.Add(AskLasts)): case r.Approved == nil && r.AskID != "" && !r.AskedAt.IsZero() && !h.Now().Before(r.AskedAt.Add(AskLasts)):
// The ask lapsed at the router with nobody's answer, and nothing in the gate's inputs says so: it is taken away and // The ask lapsed at the router with nobody's answer, and nothing in the gate's inputs says so: it is taken away and
// written again, which the teller asks afresh (ADR 0441 §1). // written again, which the teller asks afresh (ADR 0441 §1).
@@ -317,6 +350,16 @@ func (h *Holder) endStaleAsks() {
} }
} }
// checkMoved is whether the check at the heads of d's ask reached a verdict its pending ask does not say: it passed or failed
// since, or the pass it said is gone. Under the lock.
func (h *Holder) checkMoved(d *Delivery, said string) bool {
now := h.checkAt(h.askMembers(d)).Key
if now == said {
return false
}
return now == checkSaidPass || now == checkSaidFail || said == checkSaidPass || said == checkSaidFail
}
// approval is what a delivery that may merge waits on for the operator: empty once its head is approved, and the ask // approval is what a delivery that may merge waits on for the operator: empty once its head is approved, and the ask
// to make when none is made yet. Under the lock. // to make when none is made yet. Under the lock.
func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) { func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
@@ -367,19 +410,20 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
asking = max(asking, m.Ready.Asking+1) asking = max(asking, m.Ready.Asking+1)
} }
} }
ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, now) check := h.checkAt(members)
ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, check, now)
taken := []*Delivery{} taken := []*Delivery{}
var reads []memberRead var reads []memberRead
for _, m := range members { for _, m := range members {
h.runOf(m, now) h.runOf(m, now)
m.Ready.AskID, m.Ready.Ask, m.Ready.Heads, m.Ready.AskedAt, m.Ready.Asks = id, &ask, heads, now, max(m.Ready.Asks, r.Asks) m.Ready.AskID, m.Ready.Ask, m.Ready.Heads, m.Ready.AskedAt, m.Ready.Asks = id, &ask, heads, now, max(m.Ready.Asks, r.Asks)
m.Ready.AskedKey, m.Ready.Asking = id, asking m.Ready.AskedKey, m.Ready.Asking, m.Ready.AskCheck = id, asking, check.Key
h.keep(m) h.keep(m)
taken = append(taken, m) taken = append(taken, m)
reads = append(reads, readOf(m, m.Commit)) reads = append(reads, readOf(m, m.Commit))
} }
h.Logf("[mesh-delivery] %s passed its checks: the operator is asked to Approve its merge into main (%s, ask %d)", h.Logf("[mesh-delivery] %s (%s): the operator is asked to Approve its merge into main (%s, ask %d)",
d.ID, id, r.Asks) d.ID, check.Words, id, r.Asks)
gid := "" gid := ""
if group != "" { if group != "" {
gid = d.Group gid = d.Group
@@ -432,7 +476,7 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
h.mu.Lock() h.mu.Lock()
defer h.mu.Unlock() defer h.mu.Unlock()
undo() undo()
h.Logf("[mesh-delivery] %s passed its checks and the operator is not asked: %s", d.ID, says) h.Logf("[mesh-delivery] %s (%s): the operator is not asked: %s", d.ID, check.Words, says)
h.lookAt(h.Now(), "an ask not made: the forge account cannot write a repository it covers") h.lookAt(h.Now(), "an ask not made: the forge account cannot write a repository it covers")
return return
} }
@@ -579,7 +623,7 @@ func (h *Holder) hearAsks() {
return return
} }
h.mu.Lock() h.mu.Lock()
keys := h.pendingAsks() keys := append(h.pendingAsks(), h.settleKeys()...)
h.mu.Unlock() h.mu.Unlock()
for _, key := range keys { for _, key := range keys {
answer, err := h.Operator.Where(key) answer, err := h.Operator.Where(key)
@@ -591,7 +635,12 @@ func (h *Holder) hearAsks() {
continue continue
} }
h.mu.Lock() h.mu.Lock()
said := h.answered(key, *answer) var said []func()
if h.isSettleKey(key) {
h.settled(key, *answer) // a person's settle ask (settle.go, novox/hq ADR 0556 clause 5)
} else {
said = h.answered(key, *answer)
}
h.mu.Unlock() h.mu.Unlock()
for _, s := range said { for _, s := range said {
s() s()
@@ -89,6 +89,9 @@ type Holder struct {
Turns TurnStore Turns TurnStore
turnsHeld map[string]sdk.Turn turnsHeld map[string]sdk.Turn
turnsLoaded bool turnsLoaded bool
// reach is the delivery view's reach of each repository, by its key (reached.go, novox/hq ADR 0556): what `deliveries`
// and `show` derive whether a merge is on the machines from. Nothing here records it.
reach map[string]sdk.Reach
// tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407). // tried counts the owed effects Flush tried since this owner started, by how each ended (novox/hq issue 407).
tried owedTries tried owedTries
// forgeWanted is whether the forge's open pull requests are to be read at the next React: a check may have // forgeWanted is whether the forge's open pull requests are to be read at the next React: a check may have
@@ -139,6 +142,7 @@ func (h *Holder) init() {
h.unchecked = map[string]unchecked{} h.unchecked = map[string]unchecked{}
h.checker = map[string]checkerVerdict{} h.checker = map[string]checkerVerdict{}
h.runningSince, h.turnsHeld = map[string]time.Time{}, map[string]sdk.Turn{} h.runningSince, h.turnsHeld = map[string]time.Time{}, map[string]sdk.Turn{}
h.reach = map[string]sdk.Reach{}
h.started = h.Now() h.started = h.Now()
} }
@@ -757,7 +761,7 @@ func (h *Holder) pullMerged(p PullEvent, late bool) {
} }
ts = append(ts, settle(d, h.factsAt(now))...) ts = append(ts, settle(d, h.factsAt(now))...)
if d.State == Held && d.HeldWhy == "" { if d.State == Held && d.HeldWhy == "" {
d.HeldWhy = "merged without a passing check: only a person decides what follows" d.HeldWhy = HeldUnchecked
} }
h.moved(d, ts) h.moved(d, ts)
h.closeGroupOf(d) h.closeGroupOf(d)
@@ -2,8 +2,9 @@
// runtime launches. It owns the delivery — one commit in one repository, from its pull request's head to // runtime launches. It owns the delivery — one commit in one repository, from its pull request's head to
// every machine — and the delivery group above it: their states in one compiled table, kept in its own state // every machine — and the delivery group above it: their states in one compiled table, kept in its own state
// on the bus, every transition said as an event, noted on the commit and shown on the pull request through // on the bus, every transition said as an event, noted on the commit and shown on the pull request through
// the forge's holder. It asks the Approve of a merge and merges once; it asks the controller nothing and derives nothing // the forge's holder. It asks the Approve of a merge and merges once; it asks the controller nothing and records nothing
// after the merge (ADR 0539 §17). stdout // after the merge (ADR 0539 §17): whether a merge is on the machines it derives when asked from the delivery view's reach
// (ADR 0556 clause 4), and a merge held without a passing check is settled on the operator's answer (clause 5). stdout
// is the MCP channel; what this module says, it says on stderr. // is the MCP channel; what this module says, it says on stderr.
package main package main
@@ -116,6 +117,21 @@ func run(h *Holder, l *listening) {
time.Sleep(wait) time.Sleep(wait)
} }
}() }()
// The delivery view's reach (novox/hq ADR 0556 clause 4): how far each repository reached, which `deliveries` and `show`
// derive whether a merge is on the machines from. Each change is kept; nothing is moved by it.
go func() {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
err := stdio.State(reachState).Watch("", func(c stdio.StateChange) error {
h.ReachChanged(c.Key, c.Value, c.Op == "delete")
return nil
})
if err == nil {
return
}
logf("[mesh-delivery] not hearing the delivery view's reach yet (%v); asking again in %s", err, wait)
time.Sleep(wait)
}
}()
// The checker's verdicts (issue 1302, ADR 0447 §2): the Approve on mesh/check is asked only on its verdict at exactly the // The checker's verdicts (issue 1302, ADR 0447 §2): the Approve on mesh/check is asked only on its verdict at exactly the
// heads an ask covers, and a group's verdict is that one; each change asks for a look. // heads an ask covers, and a group's verdict is that one; each change asks for a look.
go func() { go func() {
@@ -290,7 +306,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
list := []stdio.Tool{ list := []stdio.Tool{
{Name: seat + "deliveries", {Name: seat + "deliveries",
Description: "Every delivery not final, those that ended in the last day, and any stop `stalled` still lists, one line each: its id " + Description: "Every delivery not final, those that ended in the last day, and any stop `stalled` still lists, one line each: its id " +
"(owner/repository@commit), its state, its group, what it waits for and since when. Narrowed by state, " + "(owner/repository@commit), its state, its group, what it waits for and since when, and for a merged one whether it is on " +
"the machines (machines, derived from the delivery view's reach, novox/hq ADR 0556). Narrowed by state, " +
"repository or group.", "repository or group.",
Input: map[string]any{"type": "object", "properties": map[string]any{"state": str("one state"), Input: map[string]any{"type": "object", "properties": map[string]any{"state": str("one state"),
"repository": str("owner/repository"), "group": str("a group's id (its branch name)"), "repository": str("owner/repository"), "group": str("a group's id (its branch name)"),
@@ -301,7 +318,8 @@ func tools(h *Holder, l *listening) []stdio.Tool {
}}, }},
{Name: seat + "show", {Name: seat + "show",
Description: "One delivery or group whole: every transition with when and why, and its group. " + Description: "One delivery or group whole: every transition with when and why, and its group. " +
"What the merge moved is source.main; whether it reached the machines is the view's.", "For a merged delivery, whether its merge is on the machines (machines), derived from the delivery view's reach of its " +
"repository (novox/hq ADR 0556), and else what holds it.",
Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("a delivery's id, or a group's")}, Input: map[string]any{"type": "object", "properties": map[string]any{"id": str("a delivery's id, or a group's")},
"required": []string{"id"}}, "required": []string{"id"}},
Run: func(a map[string]any) (any, error) { return h.Show(strArg(a, "id")) }}, Run: func(a map[string]any) (any, error) { return h.Show(strArg(a, "id")) }},
@@ -362,6 +380,30 @@ func tools(h *Holder, l *listening) []stdio.Tool {
} }
return h.Close(strArg(a, "id"), strArg(a, "why")) return h.Close(strArg(a, "id"), strArg(a, "why"))
}}, }},
{Name: seat + "settle",
Description: "Ask the operator to settle deliveries held because their merge reached the trunk without a passing check: " +
"one ask on the operator's channel for all of them, as an Approve is asked. The verb moves nothing; on the operator's " +
"answer each goes to merged, and whether it is on the machines is derived as for any merge (novox/hq ADR 0556). " +
"Refused, nothing asked, when any delivery named is not held or is held for another reason, naming why.",
Input: map[string]any{"type": "object", "additionalProperties": false, "required": []string{"ids", "why"},
"properties": map[string]any{
"ids": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "minItems": 1,
"description": "the held deliveries' ids (owner/repository@commit)"},
"why": str("why they may be settled, shown to the operator")}},
Run: func(a map[string]any) (any, error) {
var ids []string
switch v := a["ids"].(type) {
case []any:
for _, x := range v {
if s, ok := x.(string); ok {
ids = append(ids, s)
}
}
case []string:
ids = v
}
return h.Settle(ids, strArg(a, "why"), aPerson)
}},
{Name: "delivery_turns", {Name: "delivery_turns",
Description: "Every merge turn (novox/hq ADR 0548 §3): per repository, the approved merges that wait to merge into it in the order of " + Description: "Every merge turn (novox/hq ADR 0548 §3): per repository, the approved merges that wait to merge into it in the order of " +
"their Approve, the check of another delivery each waits for and since when, whether the gate wrote the turn to its bucket " + "their Approve, the check of another delivery each waits for and since when, whether the gate wrote the turn to its bucket " +
@@ -70,8 +70,9 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
if len(m.Seats) != 0 { if len(m.Seats) != 0 {
t.Fatal("the manifest declares a seat; mesh-delivery is the mesh's own") t.Fatal("the manifest declares a seat; mesh-delivery is the mesh's own")
} }
// The eight verbs the seat keeps (ADR 0539 §17): times, what-if, release, stop and retire-history went with the plans. // The eight verbs the seat keeps (ADR 0539 §17): times, what-if, release, stop and retire-history went with the plans; and
if want := []string{"deliveries", "show", "groups", "checks", "table", "stalled", "recheck", "close"}; !reflect.DeepEqual(m.Claims[0].Serves, want) { // settle, a person's ask to settle merges held without a passing check (ADR 0556 clause 5).
if want := []string{"deliveries", "show", "groups", "checks", "table", "stalled", "recheck", "close", "settle"}; !reflect.DeepEqual(m.Claims[0].Serves, want) {
t.Fatalf("serves %v, wanted %v", m.Claims[0].Serves, want) t.Fatalf("serves %v, wanted %v", m.Claims[0].Serves, want)
} }
// R6-C1: no controller verb is invoked and no plan event is consumed. // R6-C1: no controller verb is invoked and no plan event is consumed.
@@ -98,8 +99,9 @@ func TestItClaimsTheDeliverySeatAndFollowsWhatItHandles(t *testing.T) {
t.Fatalf("state %v", buckets) t.Fatalf("state %v", buckets)
} }
// What it reads: the teller's answers and the checker's verdicts (issue 1302). The carry from today's module's buckets, the // What it reads: the teller's answers and the checker's verdicts (issue 1302). The carry from today's module's buckets, the
// seat's holder and the machines' haves went with the seat's hand-over (ADR 0539 §17). // seat's holder and the machines' haves went with the seat's hand-over (ADR 0539 §17). The delivery view's reach, which
if !reflect.DeepEqual(m.Reads, []string{"mesh-delivery-teller.answers", "mesh-delivery-checker.verdicts"}) { // `deliveries` and `show` derive whether a merge is on the machines from (ADR 0556 clause 4); no forge tool is asked for it.
if !reflect.DeepEqual(m.Reads, []string{"mesh-delivery-teller.answers", "mesh-delivery-checker.verdicts", "mesh-delivery-view.reach"}) {
t.Fatalf("reads %v", m.Reads) t.Fatalf("reads %v", m.Reads)
} }
// It calls no controller verb and reads the forge with three tools; the forge's writes are the bridge's (ADR 0437 §8). It // It calls no controller verb and reads the forge with three tools; the forge's writes are the bridge's (ADR 0437 §8). It
@@ -287,7 +287,7 @@ func (h *Holder) ForgeMerged(f sdk.Forge) {
r, now := d.Ready, h.Now() r, now := d.Ready, h.Now()
d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, f.Head))), Since: now}) d.Owed = append(d.Owed, Effect{Kind: EffectEmit, Event: "merged", Line: string(mustJSON(mergedEvent(d, f.Head))), Since: now})
r.Merged, r.Answer, r.RefusedSince, r.merging, r.MergeCommit = &now, "merged", nil, false, f.Merge r.Merged, r.Answer, r.RefusedSince, r.merging, r.MergeCommit = &now, "merged", nil, false, f.Merge
h.Logf("[mesh-delivery] %s merged on the operator's Approve at %s, its check passed against its trunk's head", d.ID, shortOf(f.Head, 12)) h.Logf("[mesh-delivery] %s merged on the operator's Approve at %s (%s)", d.ID, shortOf(f.Head, 12), h.checkAt(h.askMembers(d)).Words)
h.keep(d) h.keep(d)
} }
@@ -105,6 +105,10 @@ type Delivery struct {
// trunk's head, before the operator is asked (novox/hq ADR 0421 §3, issue 1022). Cleared when it is asked. // trunk's head, before the operator is asked (novox/hq ADR 0421 §3, issue 1022). Cleared when it is asked.
FreshWanted bool `json:"fresh_wanted,omitempty"` FreshWanted bool `json:"fresh_wanted,omitempty"`
// Settle is the settle ask a person had written for it while it is held for a merge without a passing check (settle.go,
// novox/hq ADR 0556 clause 5): its key in the gate's asks, why, who asked and when, and the operator's answer once read.
Settle *SettleAsk `json:"settle,omitempty"`
Transitions []Transition `json:"transitions,omitempty"` Transitions []Transition `json:"transitions,omitempty"`
// Owed are the forge's and the bus's effects of its transitions not yet done: said only after the // Owed are the forge's and the bus's effects of its transitions not yet done: said only after the
// delivery is kept, retried until done. // delivery is kept, retried until done.
@@ -302,3 +302,72 @@ func checkerRefusal(id string) error {
"its trunk or its group, and nothing is asked of it here (novox/hq ADR 0447). Read where its check stands with the "+ "its trunk or its group, and nothing is asked of it here (novox/hq ADR 0447). Read where its check stands with the "+
"checker's checker_pull tool", id) "checker's checker_pull tool", id)
} }
// The states of the check at an ask's heads, as the ask says them (issue 1733).
const (
checkSaidPass = "pass"
checkSaidRunning = "running"
checkSaidNone = "none"
checkSaidFail = "fail"
)
// checkSaid is the state of the check at the heads an ask covers, and its words for the ask: what the checker's verdict at
// exactly those heads says, never what the gate would like it to say (issue 1733). Key is one of the checkSaid states.
type checkSaid struct {
Key string
Words string
}
// passed is whether the check at the heads passed.
func (c checkSaid) passed() bool { return c.Key == checkSaidPass }
// checkAt reads the check at these members' heads: the checker's verdict for exactly those heads (its verdicts bucket,
// keyed by their hash), and, while none is held, the forge's mesh/check at a member's head (a failure or a pending one
// there). "passed its check" is said only for a passing verdict at these heads. Under the lock.
func (h *Holder) checkAt(members []*Delivery) checkSaid {
v, hash, ok := h.checkerAt(members)
if ok {
switch v.State {
case checkerPass:
return checkSaid{Key: checkSaidPass, Words: "passed its check"}
case checkerRunning:
words := "its check has no verdict yet: it is running"
if since, held := h.runningSince[hash]; held && !since.IsZero() {
words += " (since " + since.UTC().Format("15:04") + " UTC)"
}
return checkSaid{Key: checkSaidRunning, Words: words}
default:
why := firstOf(oneLine(v.Failed.Test), oneLine(v.Failed.Words), v.State)
if v.Failed.Test != "" && v.Failed.Words != "" {
why = oneLine(v.Failed.Test) + ": " + oneLine(v.Failed.Words)
}
return checkSaid{Key: checkSaidFail, Words: "its check failed: " + clip(why, 120)}
}
}
for _, m := range members {
switch m.CheckState {
case "failure", "error":
why := "mesh/check is " + m.CheckState + " at " + shortOf(m.Commit, 12)
if m.Check != nil && m.Check.Summary != "" {
why = oneLine(m.Check.Summary)
}
return checkSaid{Key: checkSaidFail, Words: "its check failed: " + clip(why, 120)}
}
}
succeeded := 0
for _, m := range members {
switch m.CheckState {
case "pending":
return checkSaid{Key: checkSaidRunning, Words: "its check has no verdict yet: it is running"}
case "success":
succeeded++
}
}
switch {
case succeeded > 0 && len(members) == 1:
return checkSaid{Key: checkSaidPass, Words: "passed its check"} // its head's mesh/check is the checker's verdict at it
case succeeded == len(members) && succeeded > 0:
return checkSaid{Key: checkSaidNone, Words: "its members' own checks passed; the check of their heads together has not answered"}
}
return checkSaid{Key: checkSaidNone, Words: "its check has no verdict yet"}
}
@@ -0,0 +1,102 @@
package main
import (
"fmt"
"strings"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
// Whether a merge is on the machines (novox/hq ADR 0556 clause 4). The gate records only the merge (ADR 0539 §17, ADR 0429
// §1): it reads the delivery view's reach of each repository, which names the repository's reached head (the newest merge every
// node runs a build containing), and derives when asked, for a merged delivery, whether its merge is at or before that head.
// The order of two merges into one trunk is the gate's own: the moment each was merged, the trunk being a line of merges. A
// reached head the gate holds no merge of is said, never asked of the forge.
// reachState is the delivery view's state the gate reads.
const reachState = sdk.ViewModule + ".reach"
// ReachChanged takes one change of the view's reach.
func (h *Holder) ReachChanged(key string, value []byte, deleted bool) {
h.mu.Lock()
defer h.mu.Unlock()
if deleted {
delete(h.reach, key)
return
}
r, err := sdk.DecodeReach(value)
if err != nil {
h.Logf("[mesh-delivery] the view's reach %s is not readable, so its merges read as not known: %v", key, err)
delete(h.reach, key)
return
}
h.reach[key] = r
}
// The words a merged delivery's machines are said in.
const (
onMachines = "on the machines"
notYetOn = "not yet on the machines: "
notKnownWhere = "not known: "
)
// machines says whether a merged delivery's merge is on the machines, derived from the view's reach; empty for a delivery
// that is not merged. Under the lock.
func (h *Holder) machines(d *Delivery) string {
if d.State != Merged || d.MergedAs == "" {
return ""
}
key, err := sdk.RepositoryKey(d.Repository)
if err != nil {
return notKnownWhere + err.Error()
}
r, ok := h.reach[key]
switch {
case !ok:
return notKnownWhere + "the delivery view says no reach of " + d.Repository
}
// A merge that changed a module a node wants and runs nothing countable of is not yet there, whatever the reached head
// (the module holds no head back, ADR 0556 clause 2).
for _, mr := range r.Modules {
if mr.Nodes == 0 || mr.Floor != "" {
continue
}
for _, c := range mr.Changes {
if sameCommit(d.MergedAs, c.Commit) {
return notYetOn + mr.Waiting
}
}
}
switch {
case r.Reached == "":
return notYetOn + holdsSaid(r)
case sameCommit(d.MergedAs, r.Reached):
return onMachines
}
head := h.mergeOf(d.Repository, r.Reached)
switch {
case head == nil:
return notKnownWhere + fmt.Sprintf("the reached head %s of %s is no merge this gate holds", shortOf(r.Reached, 12), d.Repository)
case !d.MergedAt.After(head.MergedAt):
return onMachines
}
return notYetOn + holdsSaid(r)
}
// holdsSaid is what holds a repository's next head, in words.
func holdsSaid(r sdk.Reach) string {
if len(r.Holds) == 0 {
return "the delivery view names nothing that holds it yet"
}
return strings.Join(r.Holds, "; ")
}
// mergeOf is the delivery of a repository that merged as the commit; nil when none does. Under the lock.
func (h *Holder) mergeOf(repository, commit string) *Delivery {
for _, d := range h.deliveries {
if d.MergedAs != "" && strings.EqualFold(d.Repository, repository) && sameCommit(d.MergedAs, commit) {
return d
}
}
return nil
}
@@ -0,0 +1,73 @@
package main
import (
"strings"
"testing"
"time"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1280 (novox/hq ADR 0556 clause 4): `deliveries` and `show` say for a merged delivery whether it is on the machines, derived
// when asked from the view's reach and the gate's own order of merges: at or before the reached head it is, after it not yet
// and why; a repository the view has no reach of, or a reached head the gate holds no merge of, is not known. Nothing records it.
func TestAMergedDeliverySaysWhetherItIsOnTheMachines(t *testing.T) {
w := newWorld(t)
at := w.now.Add(-3 * time.Hour)
merge := func(commit string, n int) *Delivery {
d := &Delivery{ID: "novox/web@" + commit[:12], Repository: "novox/web", Commit: commit[:12], Number: n, State: Merged,
Since: at, MergedAs: strings.Repeat(commit[:1], 40), MergedAt: at.Add(time.Duration(n) * time.Minute)}
w.h.mu.Lock()
w.h.keep(d)
w.h.mu.Unlock()
return d
}
first, second, third := merge("111111111111", 1), merge("222222222222", 2), merge("333333333333", 3)
reach := func(r sdk.Reach) {
key, _ := sdk.RepositoryKey(r.Repository)
raw, err := sdk.EncodeReach(r)
if err != nil {
t.Fatal(err)
}
w.h.ReachChanged(key, raw, false)
}
machines := func(d *Delivery) string { w.h.mu.Lock(); defer w.h.mu.Unlock(); return w.h.machines(d) }
if got := machines(first); !strings.HasPrefix(got, notKnownWhere) {
t.Errorf("with no reach: %q", got)
}
reach(sdk.Reach{Repository: "novox/web", Reached: second.MergedAs, ReachedSrc: 7, Holds: []string{"web on g14 runs the build of 2222, not yet that of 3333"}})
for d, want := range map[*Delivery]string{first: onMachines, second: onMachines, third: notYetOn + "web on g14"} {
if got := machines(d); !strings.HasPrefix(got, want) {
t.Errorf("%s: %q, want %q", d.ID, got, want)
}
}
reach(sdk.Reach{Repository: "novox/web", Reached: strings.Repeat("9", 40), ReachedSrc: 8})
if got := machines(first); !strings.Contains(got, "no merge this gate holds") {
t.Errorf("a reached head the gate holds no merge of: %q", got)
}
reach(sdk.Reach{Repository: "novox/web", Holds: []string{"web on ace runs nothing yet"}})
if got := machines(first); got != notYetOn+"web on ace runs nothing yet" {
t.Errorf("no reached head: %q", got)
}
// A module a node wants and runs nothing countable of holds no head back, and a merge that changed it is not yet there.
reach(sdk.Reach{Repository: "novox/web", Reached: third.MergedAs, ReachedSrc: 9, Modules: map[string]sdk.ModuleReach{
"web": {FP: "src2:x", Nodes: 1, Waiting: "web on g14 runs nothing yet", Changes: []sdk.Change{{Commit: second.MergedAs, FP: "src2:x"}}}}})
for d, want := range map[*Delivery]string{first: onMachines, second: notYetOn + "web on g14 runs nothing yet", third: onMachines} {
if got := machines(d); got != want {
t.Errorf("with a module running nowhere countable, %s: %q, want %q", d.ID, got, want)
}
}
// The verbs say it; a delivery not merged says nothing of the machines.
reach(sdk.Reach{Repository: "novox/web", Reached: third.MergedAs, ReachedSrc: 9})
lines := w.h.Deliveries("", "novox/web", "", true)
if len(lines) != 3 || lines[0].Machines != onMachines {
t.Errorf("deliveries = %+v", lines)
}
shown, err := w.h.Show(third.ID)
if err != nil || shown.(map[string]any)["machines"] != onMachines {
t.Errorf("show = %v, %v", shown, err)
}
if got := machines(&Delivery{State: Held, MergedAs: "x", Repository: "novox/web"}); got != "" {
t.Errorf("a held delivery says %q of the machines", got)
}
}
@@ -1 +1,5 @@
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). # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# every manifest: the gate is the seat's only claimant.
modules/*/module.json
@@ -115,6 +115,10 @@ type ReadyRun struct {
Heads map[string]string `json:"heads,omitempty"` Heads map[string]string `json:"heads,omitempty"`
AskedAt time.Time `json:"asked_at"` AskedAt time.Time `json:"asked_at"`
Asks int `json:"asks,omitempty"` Asks int `json:"asks,omitempty"`
// AskCheck is the state of the check at the heads the pending ask names, as its words said it (checkAt: pass, running,
// none or fail; issue 1733): while the check gates no merge, an ask whose check reached a verdict since is asked again
// in new words, so it never keeps a claim the check no longer holds.
AskCheck string `json:"ask_check,omitempty"`
// Approved and Declined are when the operator chose, Warrant what the router's warrant says of who and how; Refused // Approved and Declined are when the operator chose, Warrant what the router's warrant says of who and how; Refused
// why an answer was not taken (it did not hold to the ask). // why an answer was not taken (it did not hold to the ask).
Approved *time.Time `json:"approved,omitempty"` Approved *time.Time `json:"approved,omitempty"`
@@ -186,7 +190,8 @@ func (h *Holder) readyAsks(now time.Time) []func() {
if d.Ready == nil && d.State == Ready { if d.Ready == nil && d.State == Ready {
h.runOf(d, now) h.runOf(d, now)
h.Poke() // its check, if none is asked, is worth asking now (askCheck) h.Poke() // its check, if none is asked, is worth asking now (askCheck)
h.Logf("[mesh-delivery] %s: its checks passed; the operator's Approve of its merge into main is asked once it may merge", d.ID) h.Logf("[mesh-delivery] %s is ready (%s); the operator's Approve of its merge into main is asked once it may merge",
d.ID, h.checkAt([]*Delivery{d}).Words)
} }
if a := h.sayAfterLine(d); a != nil { if a := h.sayAfterLine(d); a != nil {
asks = append(asks, a) asks = append(asks, a)
@@ -227,11 +232,12 @@ func (h *Holder) readyAsks(now time.Time) []func() {
if d.Base != MergeBranch { if d.Base != MergeBranch {
if r.OtherBase != d.Base { if r.OtherBase != d.Base {
r.OtherBase = d.Base r.OtherBase = d.Base
h.Logf("[mesh-delivery] %s passed its checks and is not asked: it merges into %s, not %s", d.ID, d.Base, MergeBranch) check := h.checkAt([]*Delivery{d}).Words // ready need not mean passed: the check may gate no merge (issue 1733)
h.Logf("[mesh-delivery] %s (%s) is not asked: it merges into %s, not %s", d.ID, check, d.Base, MergeBranch)
h.keep(d) h.keep(d)
asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request's checks passed at "+ asks = append(asks, h.sayOn(d, fmt.Sprintf("mesh-delivery: **not merged.** This pull request is at %s (%s), "+
"%s, and it merges into %s. Only a merge into %s is this owner's; a merge into another branch is its "+ "and it merges into %s. Only a merge into %s is this owner's; a merge into another branch is its "+
"author's (novox/hq ADR 0398 §4).", d.Commit, d.Base, MergeBranch))) "author's (novox/hq ADR 0398 §4).", d.Commit, check, d.Base, MergeBranch)))
} }
continue continue
} }
@@ -524,13 +530,14 @@ func (h *Holder) readyLines(now time.Time) []StalledLine {
case d.Base == "" && d.Number != 0 && now.Sub(r.Seen) >= headHeardBound: case d.Base == "" && d.Number != 0 && now.Sub(r.Seen) >= headHeardBound:
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(), out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, For: now.Sub(r.Seen).Round(time.Second).String(),
Bound: headHeardBound.String(), H2: "none: a later announcement of the pull request's head (the merge tool announces it again, the forge's announcer restarts, or a new push) or an edit of its description; a recheck does not fill it — the operator's", Bound: headHeardBound.String(), H2: "none: a later announcement of the pull request's head (the merge tool announces it again, the forge's announcer restarts, or a new push) or an edit of its description; a recheck does not fill it — the operator's",
Says: fmt.Sprintf("%s#%d passed its checks at %s and waits for %s", d.Repository, d.Number, shortOf(r.Head, 12), waitsForHead)}) Says: fmt.Sprintf("%s#%d at %s (%s) waits for %s", d.Repository, d.Number, shortOf(r.Head, 12),
h.checkAt([]*Delivery{d}).Words, waitsForHead)})
case r.CannotWrite != "" && r.Approved == nil && r.CannotWriteSince != nil: case r.CannotWrite != "" && r.Approved == nil && r.CannotWriteSince != nil:
out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State, out = append(out, StalledLine{ID: d.ID, Number: d.Number, State: d.State,
For: now.Sub(*r.CannotWriteSince).Round(time.Second).String(), Bound: "0s", For: now.Sub(*r.CannotWriteSince).Round(time.Second).String(), Bound: "0s",
H2: "none: the forge account given write on the repository (gitea_grant_write; the gitea module grants it itself once a module of it is registered), which asks the operator then", H2: "none: the forge account given write on the repository (gitea_grant_write; the gitea module grants it itself once a module of it is registered), which asks the operator then",
Says: fmt.Sprintf("%s#%d passed its checks at %s and the operator is not asked for its Approve: %s", Says: fmt.Sprintf("%s#%d at %s (%s): the operator is not asked for its Approve: %s",
d.Repository, d.Number, shortOf(r.Head, 12), r.CannotWrite)}) d.Repository, d.Number, shortOf(r.Head, 12), h.checkAt([]*Delivery{d}).Words, r.CannotWrite)})
case r.Failed != "" && d.State == Rejected: case r.Failed != "" && d.State == Rejected:
// Its cure is its author's new head: listed here at once (`show`), told on the pull request at once, and the // Its cure is its author's new head: listed here at once (`show`), told on the pull request at once, and the
// operator's only once no new head came within the author's bound (novox/hq ADR 0505 §5). // operator's only once no new head came within the author's bound (novox/hq ADR 0505 §5).
@@ -1,11 +1,15 @@
package main package main
import ( import (
"encoding/json"
"errors" "errors"
"sort" "sort"
"strings" "strings"
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/asks"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
) )
// **A pull request whose checks passed and whose merge the operator approved is merged by this owner** (novox/hq issues // **A pull request whose checks passed and whose merge the operator approved is merged by this owner** (novox/hq issues
@@ -65,6 +69,73 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
t.Fatalf("merged again: %v", w.forge.merges) t.Fatalf("merged again: %v", w.forge.merges)
} }
// 1733: the Approve's ask says the check's state at the heads it names, read from the checker's verdict there: "passed
// its check" only when it passed. While the check gates no merge, the ask is made before any verdict, says that Approve
// merges before the check ends, and is asked again in new words once the check passes or fails; a running check is no
// new word.
for _, row := range []struct {
name, state, failed string
gated bool
asks int
want, not []string
}{
{name: "gated, passed", gated: true, state: "pass", asks: 1,
want: []string{"app#7 Fix it passed its check. Approve merges it into main at " + readyHead[:12] + ";"},
not: []string{"before its check ends", "no verdict yet"}},
{name: "ungated, no verdict", asks: 1,
want: []string{"its check has no verdict yet", "Approve merges it into main at " + readyHead[:12] + " now, before its check ends"},
not: []string{"passed"}},
{name: "ungated, running", state: "running", asks: 1,
want: []string{"its check has no verdict yet", "before its check ends"}, not: []string{"passed"}},
{name: "ungated, then passed", state: "pass", asks: 2,
want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends"}},
{name: "ungated, then failed", state: "fail", failed: "TestThing: wanted 1, got 2", asks: 2,
want: []string{"its check failed: TestThing: wanted 1, got 2", "although its check failed"}, not: []string{"passed"}},
} {
t.Run(row.name, func(t *testing.T) {
v := newWorld(t)
if !row.gated {
ungated(v)
}
v.op.auto = false
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
p.Title = "Fix it"
v.h.PullUpdated(p)
put := func() {
hash, _ := sdk.HeadsHash([]sdk.Head{headOf("novox/app", 7, readyHead)})
value, _ := json.Marshal(map[string]any{"v": 1, "group": "g", "heads": hash, "state": row.state,
"failed": map[string]string{"words": row.failed}})
v.h.CheckerVerdictChanged("g."+hash, value)
}
if row.gated {
v.h.Checked(judgedAt("novox/app", 7, readyHead, "pass", "t1t1t1t1t1t1"))
}
v.settleAll()
if !row.gated && row.state != "" {
put()
v.settleAll()
}
open := v.op.open()
if len(open) != 1 || v.op.count() != row.asks {
t.Fatalf("open asks %v, asked %d times; wanted one open, asked %d times", open, v.op.count(), row.asks)
}
e := v.op.ask(open[0]).Explanation
for _, w := range row.want {
if !strings.Contains(e, w) {
t.Fatalf("the ask does not say %q:\n%s", w, e)
}
}
for _, w := range row.not {
if strings.Contains(e, w) {
t.Fatalf("the ask says %q:\n%s", w, e)
}
}
if err := asks.Glance(e); err != nil {
t.Fatal(err)
}
})
}
// check-gates-merge false (the operator, 2026-10-25 22:56): a reviewed pull request is asked with no verdict, another // check-gates-merge false (the operator, 2026-10-25 22:56): a reviewed pull request is asked with no verdict, another
// merge moving its trunk puts it out of nothing, a failed verdict is shown on it and holds nothing, and the gate's // merge moving its trunk puts it out of nothing, a failed verdict is shown on it and holds nothing, and the gate's
// status and show say the mode. // status and show say the mode.
@@ -255,6 +326,11 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
if u.op.count() != 1 { if u.op.count() != 1 {
t.Fatalf("ungated, the group is asked %d times; wanted once", u.op.count()) t.Fatalf("ungated, the group is asked %d times; wanted once", u.op.count())
} }
// 1733: the group's ask claims no pass it has not had.
if e := u.op.ask(u.op.open()[0]).Explanation; strings.Contains(e, "passed") ||
!strings.Contains(e, "2 pull requests: its check has no verdict yet. Approve merges all of them into main in one pass, at the heads named, now, before its check ends;") {
t.Fatalf("ungated, the group's ask does not say its check has no verdict yet:\n%s", e)
}
u.op.answer(u.op.open()[0], OptionApprove) u.op.answer(u.op.open()[0], OptionApprove)
u.settleAll() u.settleAll()
if len(u.forge.merges) != 2 { if len(u.forge.merges) != 2 {
@@ -0,0 +1,252 @@
package main
import (
"fmt"
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
// A person settles a delivery held because its merge reached the trunk without a passing check (novox/hq ADR 0556 clause 5).
// The verb `settle` moves nothing: it writes one ask, for every delivery named, into the gate's asks, which the teller puts to
// the operator as it puts an Approve, its options binding mesh-delivery.settle (sdk.AskVerb). On the answer settle each
// delivery takes the table's asked row held → merged with the operator's words; on leave they stay held. Whether a settled
// merge is on the machines is then derived as for any merge (reached.go). A hold of another kind, or a delivery not held, is
// refused, naming why.
// The settle ask's two answers, both at the level approve.
const (
OptionSettle = "settle"
OptionLeave = "leave"
)
// SettleAsk is the settle ask a delivery is covered by.
type SettleAsk struct {
Key string `json:"key"`
Asking int `json:"asking,omitempty"`
Why string `json:"why"`
By string `json:"by,omitempty"`
At time.Time `json:"at"`
Answered *time.Time `json:"answered,omitempty"`
Answer string `json:"answer,omitempty"`
Words string `json:"words,omitempty"`
}
// Settle is the `settle` verb: one ask of the operator for every delivery named, each held for a merge without a passing
// check. Nothing moves until the operator answers.
func (h *Holder) Settle(ids []string, why, by string) (map[string]any, error) {
why = strings.TrimSpace(why)
if why == "" {
return nil, fmt.Errorf("settle says why: it is shown to the operator")
}
if len(ids) == 0 {
return nil, fmt.Errorf("settle names at least one held delivery (ids)")
}
if h.Operator == nil {
return nil, fmt.Errorf("this owner has no way to ask the operator")
}
h.mu.Lock()
var covered []*Delivery
var refused []string
seen := map[string]bool{}
for _, id := range ids {
id = strings.TrimSpace(id)
if seen[id] {
continue
}
seen[id] = true
d := h.deliveries[id]
switch {
case d == nil:
refused = append(refused, id+": no such delivery")
case d.State != Held:
refused = append(refused, fmt.Sprintf("%s: it is %s, not held", id, d.State))
case !heldUnchecked(d):
refused = append(refused, fmt.Sprintf("%s: it is held for another reason, which settle does not take (%s)", id, d.HeldWhy))
default:
covered = append(covered, d)
}
}
if len(refused) > 0 {
h.mu.Unlock()
sort.Strings(refused)
return nil, fmt.Errorf("settle refused, nothing asked: %s", strings.Join(refused, "; "))
}
sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID })
var names []string
var heads []sdk.Head
for _, d := range covered {
names = append(names, d.ID)
heads = append(heads, sdk.Head{Repository: d.Repository, Number: d.Number, Head: d.Commit})
}
group := sdk.SettleGroup(names)
key, err := sdk.MergeAskKey(group, heads)
if err != nil {
h.mu.Unlock()
return nil, fmt.Errorf("the settle ask has no key: %w", err)
}
now := h.Now()
// The same deliveries asked again (the ask lapsed, or was left unanswered) are a new asking of the same key, under its own
// router id: the router drops a second ask under an id it kept.
asking := 1 // the first asking (sdk.Ask.Asking: 0 or 1 is the first, n the n-th)
for _, d := range covered {
if d.Settle != nil && d.Settle.Key == key && max(d.Settle.Asking, 1) >= asking {
asking = max(d.Settle.Asking, 1) + 1
}
}
ask := sdk.Ask{Group: group, Heads: heads, Asking: asking, Ask: composeSettleAsk(sdk.AskingID(key, asking), covered, why, now)}
for _, d := range covered {
d.Settle = &SettleAsk{Key: key, Asking: asking, Why: why, By: by, At: now}
h.keep(d)
}
h.mu.Unlock()
if err := h.Operator.Ask(key, ask); err != nil {
return nil, fmt.Errorf("the settle ask %s could not be written (call settle again): %w", key, err)
}
h.Logf("[mesh-delivery] asked the operator to settle %s: %s", strings.Join(names, ", "), why)
return map[string]any{"asked": key, "deliveries": names,
"said": fmt.Sprintf("The operator is asked once to settle %d deliveries; nothing moves until the answer.", len(covered))}, nil
}
// settleAct is what a settle ask's answer binds: its verb (mesh-delivery.settle), this ask, the answer and the heads.
func settleAct(group, ask, option string, heads map[string]string) asks.Act {
return asks.Act{"verb": sdk.AskVerb(group), "ask": ask, "option": option, "heads": headsString(heads)}
}
// composeSettleAsk is the operator's ask to settle these held deliveries.
func composeSettleAsk(id string, ds []*Delivery, why string, now time.Time) asks.Ask {
var a asks.Ask
a.ID, a.Who, a.Expires = id, asks.Operator, now.Add(AskLasts)
a.OnExpiry = "they stay held; settle asks again"
a.About = "delivery.settle"
var pulls, lines []string
heads := map[string]string{}
for _, d := range ds {
pulls = append(pulls, pullSaid(d))
heads[d.ID] = d.Commit
lines = append(lines, phone(fmt.Sprintf("%s %q merged at %s without a passing check", pullSaid(d), d.Title,
shortOf(d.MergedAs, 12)), 400))
}
a.Headline = phone(fmt.Sprintf("Settle %d merges made without a passing check", len(ds)), asks.HeadlineLength)
a.Explanation = strings.Join([]string{
phone(fmt.Sprintf("%s reached main without a passing check and are held. Settle ends the hold: each reads merged, and on the machines once every machine runs it. Leave keeps them held.",
strings.Join(pulls, ", ")), asks.SummaryLength),
phone("Why: "+why, asks.PointLength),
}, "\n")
a.Details = strings.Join(lines, "\n")
group := sdk.SettleGroup(func() []string {
var ids []string
for _, d := range ds {
ids = append(ids, d.ID)
}
return ids
}())
for _, o := range []struct{ id, label, does string }{
{OptionSettle, "Settle", "each delivery named ends its hold and reads merged"},
{OptionLeave, "Leave", "they stay held"},
} {
binds, _ := asks.ActDigest(settleAct(group, id, o.id, heads))
a.Options = append(a.Options, asks.Option{ID: o.id, Label: o.label, Does: o.does, Level: asks.Approve, Binds: binds})
}
return a
}
// isSettleKey is whether an ask key is a settle ask's; under the lock.
func (h *Holder) isSettleKey(key string) bool {
_, group, _, err := sdk.ParseAskKey(key)
return err == nil && strings.HasPrefix(group, sdk.SettleGroupPrefix)
}
// settleKeys are the settle asks waiting for an answer; under the lock.
func (h *Holder) settleKeys() []string {
seen := map[string]bool{}
var out []string
for _, d := range h.deliveries {
if s := d.Settle; s != nil && s.Answered == nil && d.State == Held && !seen[s.Key] {
seen[s.Key] = true
out = append(out, s.Key)
}
}
sort.Strings(out)
return out
}
// settled applies the teller's answer to a settle ask; under the lock. The teller held the warrant (the act it bound is
// mesh-delivery.settle); an answer to an earlier asking, or one choosing what the ask did not offer, moves nothing.
func (h *Holder) settled(key string, ans sdk.Answer) {
var covered []*Delivery
for _, d := range h.deliveries {
if d.Settle != nil && d.Settle.Key == key && d.Settle.Answered == nil {
covered = append(covered, d)
}
}
if len(covered) == 0 {
h.Logf("[mesh-delivery] the answer to the settle ask %s covers no delivery that waits for it: nothing settled", key)
return
}
sort.Slice(covered, func(i, j int) bool { return covered[i].ID < covered[j].ID })
if asked, at, ok := strings.Cut(ans.Warrant, "@"); ok && at != "" && asked != sdk.AskingID(key, covered[0].Settle.Asking) {
h.Logf("[mesh-delivery] the answer to the settle ask %s was given to another asking (%s): not taken", key, asked)
return
}
if ans.Option != OptionSettle && ans.Option != OptionLeave {
h.refuse(fmt.Errorf("the answer to the settle ask %s chose %s, which it did not offer: nothing settled", key, ans.Option))
return
}
now := h.Now()
for _, d := range covered {
d.Settle.Answered, d.Settle.Answer, d.Settle.Words = &now, ans.Option, ans.Words
if ans.Option == OptionLeave {
h.Logf("[mesh-delivery] %s: the operator left it held (%s)", d.ID, ans.Words)
h.keep(d)
continue
}
t, err := Apply(d, EvSettled, true, Facts{Now: now, By: "the operator", Why: "settled: " + ans.Words})
if err != nil {
h.refuse(err)
h.keep(d)
continue
}
d.HeldWhy = ""
h.moved(d, []Transition{t})
}
// Answered: the ask is taken away, as a merge ask is once acted on.
h.cancels = append(h.cancels, h.cancelSettle(key))
}
// cancelSettle takes a settle ask away from the gate's asks.
func (h *Holder) cancelSettle(key string) func() {
return func() {
if h.Operator == nil {
return
}
if err := h.Operator.Cancel(key); err != nil {
h.Logf("[mesh-delivery] the settle ask %s could not be taken away (taken away again at the next look): %v", key, err)
}
}
}
// endStaleSettles takes away every settle ask nobody answered within its bound; its deliveries stay held and settle may ask
// again. Under the lock; the cancels run with the look's asks.
func (h *Holder) endStaleSettles() {
done := map[string]bool{}
for _, d := range h.deliveries {
s := d.Settle
if s == nil || s.Answered != nil || done[s.Key] || h.Now().Before(s.At.Add(AskLasts)) {
continue
}
done[s.Key] = true
h.Logf("[mesh-delivery] the settle ask %s lapsed with no answer: taken away, %s stays held", s.Key, d.ID)
h.cancels = append(h.cancels, h.cancelSettle(s.Key))
}
for _, d := range h.deliveries {
if s := d.Settle; s != nil && done[s.Key] {
now := h.Now()
s.Answered, s.Answer = &now, "lapsed"
h.keep(d)
}
}
}
@@ -0,0 +1,113 @@
package main
import (
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/asks"
sdk "git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1280 (novox/hq ADR 0556 clause 5): settle writes one ask for several deliveries held for a merge without a passing check and
// moves none of them; it refuses, asking nothing, a delivery held for another reason and one not held, naming why; the
// operator's Leave keeps them held, and Settle, asked again under a new asking, moves each to merged with the operator's words.
func TestSettleAsksTheOperatorOnceAndMovesOnlyOnTheAnswer(t *testing.T) {
w := newWorld(t)
w.op.auto = false
merged := w.now.Add(-time.Hour)
put := func(id string, state State, why string) *Delivery {
repo, commit, _ := strings.Cut(id, "@")
d := &Delivery{ID: id, Repository: repo, Commit: commit, Number: len(id), Title: "a fix", State: state, Since: merged,
MergedAs: "m" + commit, MergedAt: merged, HeldWhy: why}
w.h.mu.Lock()
w.h.keep(d)
w.h.mu.Unlock()
return d
}
a := put("novox/web@aaaaaaaaaaaa", Held, HeldUnchecked)
b := put("novox/sdk@bbbbbbbbbbbb", Held, HeldUnchecked)
put("novox/web@cccccccccccc", Held, "a conflict on the trunk")
put("novox/web@dddddddddddd", Merged, "")
for ids, words := range map[string]string{
"novox/web@aaaaaaaaaaaa novox/web@cccccccccccc": "held for another reason",
"novox/web@aaaaaaaaaaaa novox/web@dddddddddddd": "not held",
"novox/web@aaaaaaaaaaaa novox/web@eeeeeeeeeeee": "no such delivery",
} {
if _, err := w.h.Settle(strings.Fields(ids), "on every machine since 10-25", aPerson); err == nil || !strings.Contains(err.Error(), words) {
t.Errorf("settle %s: %v, want refused naming %q", ids, err, words)
}
}
if _, err := w.h.Settle([]string{a.ID}, " ", aPerson); err == nil {
t.Error("a settle with no why was asked")
}
if w.op.count() != 0 {
t.Fatalf("a refused settle asked the operator: %d asks", w.op.count())
}
out, err := w.h.Settle([]string{b.ID, a.ID}, "on every machine since 10-25", aPerson)
if err != nil {
t.Fatal(err)
}
key, _ := out["asked"].(string)
if open := w.op.open(); len(open) != 1 || open[0] != key || w.op.count() != 1 {
t.Fatalf("asks open %v (asked %d), want the one key %s", open, w.op.count(), key)
}
ask := w.op.ask(key)
heads := map[string]string{a.ID: a.Commit, b.ID: b.Commit}
group := sdk.SettleGroup([]string{a.ID, b.ID})
for _, o := range ask.Options {
if want, _ := asks.ActDigest(settleAct(group, ask.ID, o.ID, heads)); o.Binds != want {
t.Errorf("the option %s binds %s, not mesh-delivery.settle of these heads", o.ID, o.Binds)
}
}
w.h.hearAsks()
if a.State != Held || b.State != Held {
t.Fatalf("moved before an answer: %s, %s", a.State, b.State)
}
w.op.answer(key, OptionLeave)
w.h.hearAsks()
if a.State != Held || a.Settle == nil || a.Settle.Answer != OptionLeave {
t.Fatalf("after Leave: %s %+v", a.State, a.Settle)
}
// Asked again: a new asking of the same key, under its own id.
if _, err := w.h.Settle([]string{a.ID, b.ID}, "asked again", aPerson); err != nil {
t.Fatal(err)
}
if again := w.op.ask(key); again.ID == ask.ID || a.Settle.Asking != 2 {
t.Fatalf("asked again under %s (asking %d), want a new id", again.ID, a.Settle.Asking)
}
// An answer under a settle key no delivery waits for moves nothing.
other, _ := sdk.MergeAskKey(sdk.SettleGroup([]string{"novox/web@ffffffffffff"}), []sdk.Head{{Repository: "novox/web", Number: 1, Head: "ffffffffffff"}})
w.h.mu.Lock()
w.h.settled(other, sdk.Answer{Option: OptionSettle, Warrant: "x@now", Words: "chose Settle"})
w.h.mu.Unlock()
if a.State != Held || b.State != Held || !strings.Contains(w.logbuf.String(), "covers no delivery") {
t.Fatalf("an answer for other ids moved %s, %s", a.State, b.State)
}
w.op.answer(key, OptionSettle)
w.h.hearAsks()
w.h.mu.Lock()
cancels := len(w.h.cancels)
w.h.mu.Unlock()
if cancels == 0 {
t.Error("the answered settle ask is not taken away")
}
for _, d := range []*Delivery{a, b} {
last := d.Transitions[len(d.Transitions)-1]
if d.State != Merged || last.Event != EvSettled || last.By != "the operator" || !strings.Contains(last.Why, "chose Settle") {
t.Errorf("%s after Settle: %s, %+v", d.ID, d.State, last)
}
}
// A settle ask nobody answers within its bound is taken away; its delivery stays held.
c := put("novox/web@999999999999", Held, HeldUnchecked)
if _, err := w.h.Settle([]string{c.ID}, "once more", aPerson); err != nil {
t.Fatal(err)
}
w.later(AskLasts + time.Minute)
w.h.mu.Lock()
w.h.endStaleSettles()
w.h.mu.Unlock()
if c.State != Held || c.Settle.Answer != "lapsed" {
t.Errorf("a lapsed settle ask: %s %+v", c.State, c.Settle)
}
}
@@ -77,8 +77,18 @@ const (
EvMerged Event = "merged" // it reached the trunk EvMerged Event = "merged" // it reached the trunk
EvMergedUnchecked Event = "merged-unchecked" // it reached the trunk without a passing check EvMergedUnchecked Event = "merged-unchecked" // it reached the trunk without a passing check
EvTrunkMoved Event = "trunk moved" // another merge moved its trunk, and its verdict names the trunk commit before (novox/hq ADR 0323) EvTrunkMoved Event = "trunk moved" // another merge moved its trunk, and its verdict names the trunk commit before (novox/hq ADR 0323)
EvSettled Event = "settled" // the operator settled a merge held without a passing check (novox/hq ADR 0556 clause 5)
) )
// HeldUnchecked is the why of a delivery held because its merge reached the trunk without a passing check: the one hold a
// person may settle (novox/hq ADR 0556 clause 5).
const HeldUnchecked = "merged without a passing check: only a person decides what follows"
// heldUnchecked is whether a held delivery is held for a merge without a passing check, and reached the trunk.
func heldUnchecked(d *Delivery) bool {
return d.State == Held && d.MergedAs != "" && strings.HasPrefix(d.HeldWhy, "merged without a passing check")
}
// Facts are what a guard reads beyond the delivery itself: the moment, and who asks, for an act. // Facts are what a guard reads beyond the delivery itself: the moment, and who asks, for an act.
type Facts struct { type Facts struct {
Now time.Time Now time.Time
@@ -177,6 +187,20 @@ var Table = []Row{
{From: []State{Proposed, Rejected}, Event: EvMergedUnchecked, To: Held, {From: []State{Proposed, Rejected}, Event: EvMergedUnchecked, To: Held,
Guard: "merged without a passing check — a verdict known with it is taken first: only a person decides what follows", Guard: "merged without a passing check — a verdict known with it is taken first: only a person decides what follows",
Holds: func(d *Delivery, f Facts) error { return unless(d.MergedAs != "", "not merged") }}, Holds: func(d *Delivery, f Facts) error { return unless(d.MergedAs != "", "not merged") }},
// **A person settles a merge held without a passing check** (novox/hq ADR 0556 clause 5): only on the operator's answer to
// the settle ask, with its words; a hold of another kind is not taken here. Whether it is on the machines is then derived.
{From: []State{Held}, Event: EvSettled, To: Merged, Act: true,
Guard: "held because its merge reached the trunk without a passing check, and the operator answered settle to the ask " +
"`settle` wrote, with the words of that answer",
Holds: func(d *Delivery, f Facts) error {
switch {
case !heldUnchecked(d):
return errors.New("it is not held for a merge without a passing check: " + d.HeldWhy)
case f.By == "" || f.Why == "":
return errors.New("a settle names the operator's answer and its words")
}
return nil
}},
{From: []State{Rejected, Ready}, Event: EvRecheck, To: Proposed, Act: true, {From: []State{Rejected, Ready}, Event: EvRecheck, To: Proposed, Act: true,
Guard: "a person asked, with why, or its group changed", Guard: "a person asked, with why, or its group changed",
Holds: func(d *Delivery, f Facts) error { return unless(f.Why != "", "a recheck says why") }}, Holds: func(d *Delivery, f Facts) error { return unless(f.Why != "", "a recheck says why") }},
@@ -34,6 +34,9 @@ func aDeliveryFor(r Row) (*Delivery, Facts) {
case EvTrunkMoved: case EvTrunkMoved:
d.Check = &Verdict{Gate: "pass", Head: "c0ffee000000", Trunk: "d28ca95bbbbb"} d.Check = &Verdict{Gate: "pass", Head: "c0ffee000000", Trunk: "d28ca95bbbbb"}
d.TrunkNow = "690966dccccccccc" d.TrunkNow = "690966dccccccccc"
case EvSettled:
d.MergedAs, d.HeldWhy = "aaaa1111", HeldUnchecked
f.Why, f.By = "settled: the operator chose Settle", "the operator"
} }
return d, f return d, f
} }
@@ -103,7 +106,7 @@ func TestEveryRowOfTheTableIsTakenWhenItsGuardHoldsAndRefusedWhenNot(t *testing.
func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) { func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) {
events := []Event{EvAnnounced, EvAccepted, EvRefused, EvRecheck, EvReask, EvLost, EvNewHead, EvClosed, EvMerged, events := []Event{EvAnnounced, EvAccepted, EvRefused, EvRecheck, EvReask, EvLost, EvNewHead, EvClosed, EvMerged,
EvMergedUnchecked, EvTrunkMoved} EvMergedUnchecked, EvTrunkMoved, EvSettled}
for _, from := range append([]State{None}, AllStates...) { for _, from := range append([]State{None}, AllStates...) {
for _, ev := range events { for _, ev := range events {
for _, act := range []bool{false, true} { for _, act := range []bool{false, true} {
@@ -128,14 +131,17 @@ func TestEveryPairTheTableDoesNotHoldIsRefusedByName(t *testing.T) {
// The rules the table is for, read off the table. // The rules the table is for, read off the table.
func TestTheTableKeepsItsRules(t *testing.T) { func TestTheTableKeepsItsRules(t *testing.T) {
for _, r := range Table { for _, r := range Table {
// The one row out of a final state is a person's settle of a merge held without a passing check, taken on the
// operator's answer (novox/hq ADR 0556 clause 5): it is already on the trunk, and moves from held to merged.
settle := r.Event == EvSettled && r.Act && len(r.From) == 1 && r.From[0] == Held && r.To == Merged
for _, from := range r.From { for _, from := range r.From {
// Nothing leaves a final state. // Nothing else leaves a final state.
if from.Final() { if from.Final() && !settle {
t.Errorf("%s is final and the table leaves it by %s", from, r.Event) t.Errorf("%s is final and the table leaves it by %s", from, r.Event)
} }
} }
// Only a ready delivery is merged, and nothing but its merge, or a merge without a passing check, puts one on the trunk. // Only a ready delivery is merged, and nothing but its merge, or a merge without a passing check, puts one on the trunk.
if r.To == Merged && !(r.Event == EvMerged && len(r.From) == 1 && r.From[0] == Ready) { if r.To == Merged && !settle && !(r.Event == EvMerged && len(r.From) == 1 && r.From[0] == Ready) {
t.Errorf("%v —%s→ merged: only a ready delivery's merge merges", r.From, r.Event) t.Errorf("%v —%s→ merged: only a ready delivery's merge merges", r.From, r.Event)
} }
if r.To == Held && r.Event != EvMergedUnchecked { if r.To == Held && r.Event != EvMergedUnchecked {
@@ -22,6 +22,9 @@ type Line struct {
Waits string `json:"waits,omitempty"` Waits string `json:"waits,omitempty"`
Owed int `json:"owed,omitempty"` Owed int `json:"owed,omitempty"`
Stalled bool `json:"stalled,omitempty"` Stalled bool `json:"stalled,omitempty"`
// Machines says, for a merged delivery, whether its merge is on the machines, derived from the delivery view's reach
// (reached.go, novox/hq ADR 0556 clause 4); recorded nowhere.
Machines string `json:"machines,omitempty"`
} }
// waitsFor is what a delivery waits for, in words. // waitsFor is what a delivery waits for, in words.
@@ -61,7 +64,7 @@ func (h *Holder) Deliveries(state, repository, group string, all bool) []Line {
continue continue
} }
l := Line{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), For: now.Sub(d.Since).Round(time.Second).String(), l := Line{ID: d.ID, State: d.State, Since: d.Since.UTC().Format(time.RFC3339), For: now.Sub(d.Since).Round(time.Second).String(),
Group: d.Group, Number: d.Number, Waits: h.waitsFor(d), Owed: len(d.Owed)} Group: d.Group, Number: d.Number, Waits: h.waitsFor(d), Owed: len(d.Owed), Machines: h.machines(d)}
l.Stalled = pastBound(d, now) l.Stalled = pastBound(d, now)
out = append(out, l) out = append(out, l)
} }
@@ -80,6 +83,9 @@ func (h *Holder) Show(id string) (any, error) {
defer h.mu.Unlock() defer h.mu.Unlock()
if d := h.deliveries[id]; d != nil { if d := h.deliveries[id]; d != nil {
out := map[string]any{"delivery": d, "waits": h.waitsFor(d), "check-gates-merge": h.checkGates()} out := map[string]any{"delivery": d, "waits": h.waitsFor(d), "check-gates-merge": h.checkGates()}
if m := h.machines(d); m != "" {
out["machines"] = m
}
if g := h.groups[d.Group]; g != nil { if g := h.groups[d.Group]; g != nil {
state, why, _ := h.groupState(g, h.membersOf(g)) state, why, _ := h.groupState(g, h.membersOf(g))
out["group"] = map[string]any{"group": g, "state": state, "why": why} out["group"] = map[string]any{"group": g, "state": state, "why": why}
@@ -326,5 +332,9 @@ func (h *Holder) Status(listening string) map[string]any {
// HeldLine says a held delivery in one line: which, since when and why. A merge without a passing check is a person's to // HeldLine says a held delivery in one line: which, since when and why. A merge without a passing check is a person's to
// read; the gate moves nothing after the merge. // read; the gate moves nothing after the merge.
func HeldLine(d *Delivery) string { func HeldLine(d *Delivery) string {
return fmt.Sprintf("%s held since %s: %s", d.ID, d.Since.UTC().Format(time.RFC3339), d.HeldWhy) line := fmt.Sprintf("%s held since %s: %s", d.ID, d.Since.UTC().Format(time.RFC3339), d.HeldWhy)
if heldUnchecked(d) {
line += " (mesh-delivery.settle asks the operator to settle it, ADR 0556)"
}
return line
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-gate
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026111253-9c61918ac36f git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026111253-9c61918ac36f h1:9+TZpNq8Ifj1+qmbidE6l/7qkbua4yLdzRMrezNvf1A= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61 h1:/c6ncICWjq55nBf8TlDM0ROe/PuH+qZIdnuVbtg8UuM=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026111253-9c61918ac36f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+4 -2
View File
@@ -14,7 +14,8 @@
"table", "table",
"stalled", "stalled",
"recheck", "recheck",
"close" "close",
"settle"
] ]
} }
], ],
@@ -70,7 +71,8 @@
], ],
"reads": [ "reads": [
"mesh-delivery-teller.answers", "mesh-delivery-teller.answers",
"mesh-delivery-checker.verdicts" "mesh-delivery-checker.verdicts",
"mesh-delivery-view.reach"
], ],
"tools": [ "tools": [
"delivery_hand_now", "delivery_hand_now",
@@ -130,6 +130,33 @@ func TestANewerRevisionCoversTheOlderMergesOfItsRepositoryAsTheForgeSaysAncestry
if v, _ := h2.View(n); v.Delivery != "fix merged, not delivered: mesh-catalog#33" { if v, _ := h2.View(n); v.Delivery != "fix merged, not delivered: mesh-catalog#33" {
t.Fatalf("another repository's head covered it: %q", v.Delivery) t.Fatalf("another repository's head covered it: %q", v.Delivery)
} }
// 1280 (ADR 0556 clauses 3 and 6): the view's per-repository form, a repository's reached head with no group, delivers the
// merges at or before that head and not a later one.
h3 := newHarness(t)
reached, later := fixIssue(t, h3, "mesh-catalog#34"), fixIssue(t, h3, "mesh-catalog#35")
mergedFix(t, h3, 34, "feat/e", "6666666666660000")
mergedFix(t, h3, 35, "feat/f", "7777777777770000")
h3.Contains = func(_, _, commit, in string) (bool, error) {
return commit == "6666666666660000" && in == "8888888888880000", nil
}
say := func(rev int, notYet ...string) {
raw, _ := json.Marshal(map[string]any{"revision": rev, "group": "", "repository": "novox/mesh-catalog",
"heads": map[string]string{"novox/mesh-catalog": "8888888888880000"}, "not_yet": notYet})
if err := h3.OnDelivered(stdio.Envelope{Key: DeliveredEvent, Body: raw}); err != nil {
t.Fatal(err)
}
}
// A merge the event says is not yet on the machines (its module runs on no node yet) waits for the next event.
say(15, "6666666666660000")
if v, _ := h3.View(reached); v.Delivery != "fix merged, not delivered: mesh-catalog#34" {
t.Errorf("a merge said not yet: %q", v.Delivery)
}
say(15)
for n, want := range map[uint64]string{reached: "delivered to every machine it should reach", later: "fix merged, not delivered: mesh-catalog#35"} {
if v, _ := h3.View(n); v.Delivery != want {
t.Errorf("per repository: issue %d reads %q, wanted %q", n, v.Delivery, want)
}
}
} }
// A fix kept with only its merged flag (heard through `checks`, or kept before release 6) is matched to a delivered revision // A fix kept with only its merged flag (heard through `checks`, or kept before release 6) is matched to a delivered revision

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