Compare commits

..
Author SHA1 Message Date
jschoubben 68183ee441 Keep n8n on its own Dockerfile: since ADR 0487 a module may ship one, and the move to the upstream image only added a config value and a uuid mount (the operator, 2026-10-22)
mesh/delivery-group group feat/r4d checking: composed check out of date: novox/mesh-controller@707bfba4ea36 was rechecked (both earlier checks ran on shanks, w…
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 68183ee4413d merged into trunk b7b9f2a3da78 beside mesh-controller#433@707bfba4 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.s…
mesh/delivery ready: it delivers once merged
mesh/check failed: unit: i3/cmd/i3-tools.TestTheMainFileAndEveryModulesContributionLoadTogether: manifest_test.go:247: expected the launcher, clipboar…
2026-10-22 11:47:44 +02:00
jschoubben cff5ed46b7 Check an image's Dockerfile place instead of refusing the image kind (ADR 0487 clause 13)
mesh/check failed: unit: modules/gitea.test: [gitea] where the poll of merges last looked from could not be read, so none is kept — no runtime | cff5e…
mesh/merge-gate waits for its group's composed check
mesh/repo-check head cff5ed46b794 merged into trunk 529c156f6407 beside mesh-controller#433@707bfba4 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.s…
mesh/delivery superseded: a newer head of the same pull request
mesh/delivery-group group feat/r4d checking: composed check out of date: novox/mesh-catalog@c948f3a61c74 was judged against trunk 848249cf54b6, the trunk is no…
2026-10-22 11:44:45 +02:00
jschoubben 7451e7edf4 Merge main into feat/r4d: keep the image check beside the image builder's two checks (ADR 0487 clause 13)
mesh/merge-gate waits for its group's composed check
mesh/repo-check head 7451e7edf4e5 merged into trunk 529c156f6407 beside mesh-controller#433@499ff5c2 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.s…
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: unit: modules/gitea.test: ✔ the same merge, head or close announced twice has the same id, and another has another (6.449977ms) | 7…
2026-10-22 11:41:14 +02:00
mesh-tools 529c156f64 Merge pull request 'Hold a config value for the pull request that introduces its key (issue 1826, ADR 0502)' (#437) from fix/1826-held-config into main 2026-10-22 09:38:31 +00:00
mesh-tools 74baa53787 Merge pull request 'Give mesh-build-image the registry's address as the seat's reach (issue 1703)' (#436) from fix/1703-registry-binding into main 2026-10-22 09:14:12 +00:00
jschoubben 332aa75afd mesh-config: read the pull request's commit from head_sha (issue 1826)
mesh/delivery delivered
mesh/merge-gate head 332aa75afd19 merged into trunk 74baa5378716 — pass: group fix/1826-held-config composed: every machine composes with the change as it …
mesh/delivery-group group fix/1826-held-config delivered: every member is delivered
mesh/check failed: g14: novox/mesh-catalog#437: checks/imagebuild/go.mod cannot be read to check its mesh-sdk pin: Gitea API /repos/novox/mesh-catalog…
mesh/repo-check head 332aa75afd19 merged into trunk 74baa5378716 beside mesh-controller#434@4357b368 — pass: its merge-check.sh passed, each part in its to…
2026-10-22 11:09:48 +02:00
jschoubben 9e2d1164f0 Give mesh-build-image the registry's address as the seat's reach, which the provisioner's client fills (issue 1703)
mesh/merge-gate head 9e2d1164f06e merged into trunk 5a6d082e8c55 — pass: builds mesh-build-image, sent nowhere; no bus step; every machine composes with th…
mesh/repo-check head 9e2d1164f06e merged into trunk 5a6d082e8c55 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/check failed: unit: modules/gitea.test: [gitea] where the poll of merges last looked from could not be read, so none is kept — no runtime | 9e2d1…
2026-10-22 11:07:50 +02:00
jschoubben 48b08c74ab mesh-config: apply a held value only at the merged head; a close the forge cannot confirm drops nothing (issue 1826) 2026-10-22 11:07:19 +02:00
jschoubben 260be1c3e6 mesh-delivery-gate: ask a pull request's check again when a config value is held or dropped for it (issue 1826)
mesh-config raises config-changed (hold, drop) naming the pull request; the delivery, or its group's
composed check, is asked again on the gate's own path, with why in the transition. Not the recheck verb.
2026-10-22 11:01:07 +02:00
jschoubben 224a7a66a5 mesh-config: hold a value for the open pull request that introduces its key (issue 1826)
A key a pull request adds is declared by no registered manifest, so its value was refused and the
group's composed check could not pass. A proposal may now name the pull request: judged against what
the controller told of its head, asked of the operator, kept apart on Approve, applied at the merge,
dropped at a close.
2026-10-22 10:58:28 +02:00
mesh-tools 5a6d082e8c Merge pull request 'Add the image build module: buildah and mesh-build-image, rootless, publishing only from the trunk (issues 1702, 1703, 1704, 1707)' (#435) from feat/r4c9-image-build into main 2026-10-22 08:57:09 +00:00
jschoubben 7b5f7f84ac List mesh-build-image among the issue tracker's owners
mesh/repo-check head 7b5f7f84acb7 merged into trunk 848249cf54b6 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/check failed: unit: modules/gitea.test: ✔ the same merge, head or close announced twice has the same id, and another has another (6.882981ms) | 7…
mesh/merge-gate head 7b5f7f84acb7 merged into trunk 848249cf54b6 — pass: group feat/r4c9-image-build composed: every machine composes with the change as it…
mesh/delivery delivered
mesh/delivery-group group feat/r4c9-image-build delivered: every member is delivered
2026-10-22 10:51:40 +02:00
jschoubben 3508be1476 Merge origin/main into feat/r4c9-image-build 2026-10-22 10:51:24 +02:00
jschoubben 7c5d11e55d Skip the real fetch test where buildah is not installed: every step is guarded by it
mesh/delivery-group group feat/r4c9-image-build checking: 0 of 2 member(s) ready
mesh/merge-gate group feat/r4c9-image-build: its group's composed check has not given a verdict at the members' heads yet
mesh/check failed: unit: i3/cmd/i3-tools.TestTheMainFileAndEveryModulesContributionLoadTogether: manifest_test.go:247: expected the launcher, clipboar…
mesh/delivery superseded: a newer head of the same pull request
2026-10-22 10:43:07 +02:00
jschoubben 3dcaa7a996 Fail a push whose digest file cannot be read; test the image steps through a real bus against captured shapes (issue 1703) 2026-10-22 10:34:37 +02:00
mesh-tools 848249cf54 Merge pull request 'Register the build and delivery modules as issue owners (issue 1815)' (#433) from fix/1815-tracker-owners into main 2026-10-22 08:30:41 +00:00
jschoubben 692fc3e1ab Add mesh-build-image: rootless image builds with buildah, pushed by tag from trunk only (issue 1703) 2026-10-22 10:27:01 +02:00
jschoubben ce8d52ed0b Let gitea read the image build module's keys, so its key is the builders' (issue 1703f) 2026-10-22 10:27:01 +02:00
jschoubben f5153a490c Check that every image builds from a Dockerfile in its own module's directory (issue 1707) 2026-10-22 10:27:01 +02:00
jschoubben 8815f950b0 Add the buildah module and the check that only it provides rootless-image-build (issues 1702, 1704) 2026-10-22 10:27:01 +02:00
jschoubben c948f3a61c Skip the bus's docker tests loudly where there is no docker, and run them by name in a docker step of the merge check where there is (ADR 0480 clause 6)
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: g14: novox/mesh-catalog#434 does not merge into the trunk: files in conflict: merge-check.sh | c948f3a61c74 on 529c156f6407 | unit …
mesh/delivery-group group feat/r4d rejected: a member's own check failed
mesh/merge-gate out of date: judged against trunk 5a6d082e8c55, the trunk is now 74baa5378716; judged again on a recheck or when it is merged
mesh/repo-check out of date: judged against trunk 5a6d082e8c55, the trunk is now 74baa5378716; judged again on a recheck or when it is merged
2026-10-22 10:18:15 +02:00
jschoubben 91877e3421 Put the bus's two mounted programs under a directory the module declares, as the controller's module check requires (review round 1, C1) 2026-10-22 10:04:31 +02:00
jschoubben 77b89ff182 Take the rehearsal tool out of the nats module: a new nats build is held until the bus upgrade itself, so it could only run after the step it rehearses (ADR 0236 section 36) 2026-10-22 10:01:23 +02:00
jschoubben e7ca330e1f Rehearse the bus upgrade on a copy of the last snapshot with a tool of the nats module, and run the docker tests only as integration tests (issue 1642, ADR 0480 clause 6) 2026-10-22 10:00:15 +02:00
jschoubben b4e90e5fce Run the bus from its pinned upstream image with the snapshot program and the entrypoint mounted, and write and rehearse the way there and back (issues 1640, 1641, 1642, ADR 0480 clauses 1-3, 5, 6, 11) 2026-10-22 09:55:03 +02:00
jschoubben e2220df5e8 Register the build and delivery modules as issue owners, and test that every code module is one (issue 1815)
mesh/check failed: unit: modules/gitea.test: ✔ the same merge, head or close announced twice has the same id, and another has another (9.586412ms) | e…
mesh/merge-gate head e2220df5e851 merged into trunk 97a111e70abf — pass: builds mesh-issues → novox; no bus step; every machine composes with the change as…
mesh/repo-check head e2220df5e851 merged into trunk 97a111e70abf — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-22 09:54:51 +02:00
jschoubben 0ec446ab75 n8n's media-gid is a required config value: a machine that does not say it refuses n8n (issue 1639) 2026-10-22 09:49:28 +02:00
jschoubben 3c0496d1d3 n8n runs its upstream image with uuid mounted and the media group joined, in place of a Dockerfile (issue 1639, ADR 0480 clauses 1, 3, 7, 8) 2026-10-22 09:49:28 +02:00
mesh-tools 97a111e70a Merge pull request 'Hold the Approve ask until a review names the head (issue 1803, ADR 0496)' (#432) from fix/1803-hold-ask-without-review into main 2026-10-21 23:48:59 +00:00
mesh-tools eb4f16431c Merge pull request 'Leave the go.mod fixtures under testdata out of the build module's tests and digests (issue 1810)' (#431) from fix/1810-beside-sibling into main 2026-10-21 23:44:20 +00:00
jschoubben c4fd31aa7e Say a held group waits for a review and keep each member's status current (issue 1803, round 1)
mesh/merge-gate head c4fd31aa7e97 merged into trunk eb4f16431ce3 — pass: builds mesh-delivery-gate → novox; no bus step; every machine composes with the ch…
mesh/repo-check head c4fd31aa7e97 merged into trunk eb4f16431ce3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/check failed: unit: clipmenu/cmd/clipmenu-tools.TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock: manifest_test.go:199: the launcher …
2026-10-22 01:37:49 +02:00
jschoubben 09d5360bce Follow the hold in the bus test: a head with no review is asked only once an edit names it (issue 1803)
mesh/merge-gate head 09d5360bce5a merged into trunk 4915f6985785 — pass: builds mesh-delivery-gate → novox; no bus step; every machine composes with the ch…
mesh/repo-check head 09d5360bce5a merged into trunk 4915f6985785 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/check failed: unit: clipmenu/cmd/clipmenu-tools.TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock: manifest_test.go:199: the launcher …
mesh/delivery superseded: a newer head of the same pull request
2026-10-22 01:27:18 +02:00
jschoubben b4d7a32abf Hold the Approve ask until a review names the head (issue 1803, ADR 0496)
Twice an unreviewed pull request was asked and merged on a warning line alone.
2026-10-22 01:25:15 +02:00
jschoubben 6aa4f135c9 Leave the go.mod fixtures under testdata out of the build module's tests and digests, so the checker stops running a fixture as a module (issue 1810)
mesh/check failed: unit: i3/cmd/i3-tools.TestTheMainFileAndEveryModulesContributionLoadTogether: manifest_test.go:247: expected the launcher, clipboar…
mesh/merge-gate head 6aa4f135c9f6 merged into trunk 4915f6985785 — pass: builds mesh-build-go → ace, g14, novox, shanks; no bus step; every machine compose…
mesh/repo-check head 6aa4f135c9f6 merged into trunk 4915f6985785 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-22 01:22:31 +02:00
mesh-tools 4915f69857 Merge pull request 'Add disk_folders to disk-load: which folders fill a disk (issue 1804)' (#430) from fix/1804-folder-sizes into main 2026-10-21 23:20:59 +00:00
jschoubben 843e408556 disk_folders: answer said as a list (issue 1804)
mesh/merge-gate head 843e40855684 merged into trunk 3bbae41037e3 — pass: builds disk-load → ace, g14, novox, shanks; no bus step; every machine composes wi…
mesh/repo-check head 843e40855684 merged into trunk 3bbae41037e3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/check failed: unit: clipmenu/cmd/clipmenu-tools.TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock: manifest_test.go:199: the launcher …
mesh/delivery delivered
2026-10-22 01:13:35 +02:00
jschoubben 4ef2fa2dff disk_folders: bound the walk at 25 s, below the tool runner's 30 s, and say findings as lines (issue 1804)
mesh/merge-gate waiting in the build queue since 01:13
mesh/delivery superseded: a newer head of the same pull request
mesh/check failed: unit: diskload/cmd/disk-load: # diskload/cmd/disk-load [diskload/cmd/disk-load.test] | 4ef2fa2dff1c on 3bbae41037e3 | unit 4096 run…
2026-10-22 01:13:29 +02:00
jschoubben 8288042eb7 Add disk_folders to disk-load: no tool said which folders fill a disk (issue 1804)
mesh/merge-gate building on shanks since 01:09
mesh/check failed: unit: clipmenu/cmd/clipmenu-tools.TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock: manifest_test.go:199: the launcher …
mesh/delivery superseded: a newer head of the same pull request
2026-10-22 01:02:31 +02:00
mesh-tools 3bbae41037 Merge pull request 'Start the gitea bundle's binds without awaiting them, so its handshake is answered (issue 1793)' (#429) from fix/1793-bind-after-handshake into main 2026-10-21 22:59:00 +00:00
jschoubben 77f3963d83 Test that the gitea bundle's import resolves while its binds are refused (issue 1793)
mesh/merge-gate head 77f3963d8338 merged into trunk c770f7947d95 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 77f3963d8338 merged into trunk c770f7947d95 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/check failed: unit: fixture/beside.TestReadsTheSiblingBesideIt: beside_test.go:17: open /var/lib/mesh-build-go-work/mvipvmt100c902/beside/sibling…
2026-10-22 00:49:27 +02:00
jschoubben 49a28f8537 Start the gitea bundle's binds without awaiting them, so its handshake is answered while a refused bind is asked again (issue 1793)
mesh/check failed: unit: modules/gitea.test: [gitea] where the poll of merges last looked from could not be read, so none is kept — no runtime | 49a28…
mesh/delivery superseded: a newer head of the same pull request
mesh/merge-gate head 49a28f85378b merged into trunk c770f7947d95 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 49a28f85378b merged into trunk c770f7947d95 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
2026-10-22 00:45:54 +02:00
mesh-tools c770f7947d Merge pull request 'Bind the gitea bundle's event consumers again until they hold, so verdicts reach the forge (issue 1793)' (#428) from fix/1793-verdict-reaches-the-forge into main 2026-10-21 22:44:16 +00:00
mesh-tools 4b8e7f11cf Merge pull request 'Make docker_volumes_remove_anonymous answer on a machine with over a thousand volumes (issue 1796)' (#427) from fix/1796-sizes-on-a-large-set into main 2026-10-21 22:38:36 +00:00
jschoubben 0d2686b5eb Bind the gitea bundle's event consumers again until they hold, so a refusal at its start no longer ends it and loses verdicts (issue 1793)
mesh/merge-gate head 0d2686b5ebee merged into trunk 4b8e7f11cf53 — pass: builds gitea → novox; no bus step; every machine composes with the change as it di…
mesh/repo-check head 0d2686b5ebee merged into trunk 4b8e7f11cf53 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/check failed: unit: fixture/beside.TestReadsTheSiblingBesideIt: beside_test.go:17: open /var/lib/mesh-build-go-work/mvip9ircc11d3c/beside/sibling…
2026-10-22 00:37:30 +02:00
mesh-tools 839be0eb64 Merge pull request 'A delivery whose walk was folded into a batch follows that batch's walk and ends delivered (issue 1786)' (#426) from fix/1786-follow-taken-over-walk into main 2026-10-21 22:26:33 +00:00
jschoubben dc144a4e7d Count volumes a half-done batch already removed as removed, and test the batching (issue 1796)
mesh/merge-gate head dc144a4e7dd7 merged into trunk 839be0eb6444 — pass: builds docker → ace, g14, novox, shanks; no bus step; every machine composes with …
mesh/repo-check head dc144a4e7dd7 merged into trunk 839be0eb6444 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/check failed: unit: fixture/beside.TestReadsTheSiblingBesideIt: beside_test.go:17: open /var/lib/mesh-build-go-work/mvip2dsm29adef/beside/sibling…
2026-10-22 00:23:21 +02:00
jschoubben bfc42e4f4f Read volume sizes only when asked and remove in bounded batches, so docker_volumes_remove_anonymous answers on a machine with over a thousand volumes (issue 1796)
mesh/merge-gate building on shanks since 00:21
mesh/delivery superseded: a newer head of the same pull request
2026-10-22 00:20:32 +02:00
jschoubben c4176107f2 Keep a delivery superseded by its walk final, and heal it as delivered by the walk that took its walk over (issue 1786)
mesh/merge-gate head c4176107f287 merged into trunk a3794ea156dd — pass: builds mesh-delivery-gate → novox; no bus step; every machine composes with the ch…
mesh/repo-check head c4176107f287 merged into trunk a3794ea156dd — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivering: 0 machine step(s) passed
2026-10-22 00:01:22 +02:00
mesh-tools a3794ea156 Merge pull request 'Add a tool that removes leftover anonymous docker volumes (issue 1796)' (#425) from fix/1796-remove-anonymous-volumes into main 2026-10-21 21:54:26 +00:00
jschoubben e8cb2ee470 Wait for the batch that took a folded walk over, and let a delivery superseded by its walk follow that walk (issue 1786)
mesh/merge-gate building on ace since 23:57
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 23:53:52 +02:00
jschoubben 07b3c5e77a Report each anonymous volume's size and the space freed (issue 1796)
mesh/merge-gate head 07b3c5e77a19 merged into trunk f3100d111633 — pass: builds docker → ace, g14, novox, shanks; no bus step; every machine composes with …
mesh/repo-check head 07b3c5e77a19 merged into trunk f3100d111633 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 23:46:46 +02:00
mesh-tools f3100d1116 Merge pull request 'The checker runs beside today's check: build modules merge and place siblings; with automx, provisioner and gate fixes' (#423) from feat/the-checker into main 2026-10-21 21:46:17 +00:00
jschoubben 64a96a13f7 Add docker_volumes_remove_anonymous: remove leftover unmounted anonymous volumes (issue 1796)
mesh/merge-gate building on ace since 23:45
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 23:44:18 +02:00
mesh-tools f1fb73e12c Merge pull request 'Ask a group member's own repo-check when the group is held for it (issue 1792)' (#424) from fix/1792-recheck-stale-member into main 2026-10-21 21:38:09 +00:00
jschoubben d2faada61b Clear a member's own-ask mark when its own verdict arrives, so a later pending status is asked again (issue 1792)
mesh/merge-gate head d2faada61bf3 merged into trunk 379317589488 — pass: builds mesh-delivery-gate → novox; no bus step; every machine composes with the ch…
mesh/repo-check head d2faada61bf3 merged into trunk 379317589488 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-21 23:16:35 +02:00
jschoubben f436d1dace Ask a group member's own repo-check when the group is held before its Approve for it (issue 1792)
mesh/merge-gate waiting in the build queue since 23:13
mesh/delivery superseded: a newer head of the same pull request
2026-10-21 23:11:55 +02:00
jschoubben 12ff1cfbc4 Merge remote-tracking branch 'origin/fix/1106-sdk-member-merges' into feat/the-checker
mesh/repo-check head 12ff1cfbc434 merged into trunk f1fb73e12cee beside mesh-controller#430@b9ec1919 — pass: its merge-check.sh passed, each part in its to…
mesh/merge-gate head 12ff1cfbc434 merged into trunk f1fb73e12cee — pass: group feat/the-checker composed: every machine composes with the change as it did …
mesh/delivery delivered
mesh/delivery-group group feat/the-checker delivered: every member is delivered
2026-10-21 21:58:44 +02:00
jschoubben 7bf0de0ff5 Move the socket-return fill tests (issue 1165) to the integration level: they make a real socket and watch its directory 2026-10-21 21:57:53 +02:00
jschoubben ce72a366bf Merge remote-tracking branch 'origin/feat/r4d-automx-go' into feat/the-checker 2026-10-21 21:50:26 +02:00
jschoubben d9876e5296 Merge remote-tracking branch 'origin/fix/1165-fill-retry' into feat/the-checker
# Conflicts:
#	modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go
2026-10-21 21:50:26 +02:00
jschoubben 62e67578eb Merge remote-tracking branch 'origin/fix/xterm-config-defaults' into feat/the-checker 2026-10-21 21:50:00 +02:00
jschoubben 1319432e55 Test that the integration level gets MESH_CHECK_BESIDE only when siblings were placed (issue 1677) 2026-10-21 21:49:37 +02:00
jschoubben 0cf7b2344f Write a moved contributor's declared defaults into the provisioner client tests' stand-in mesh-config (issue 1780) 2026-10-21 21:48:59 +02:00
jschoubben aa5d263c08 Test the returning socket by a real restart of the stand-in node-engine 2026-10-21 21:45:34 +02:00
jschoubben 769a8d6f6a Fill again what failed for want of the node-engine's write socket, when inotify says the socket is back (issue 1165) 2026-10-21 21:45:13 +02:00
jschoubben 34b310b9f8 Drop the unused imports from the Node fetch preview 2026-10-21 21:24:44 +02:00
jschoubben 33d8b71cf7 Drop the unused imports from the Node fetch preview 2026-10-21 21:24:33 +02:00
jschoubben aa0eaa6c52 Build the merge preview's module trees with mesh-sdk's ModuleTrees and re-pin mesh-sdk to 78364e31, so the two fetch steps share it (issues 1675, 1676) 2026-10-21 21:24:24 +02:00
jschoubben d5804927b3 Make the build modules' fetch merge the head onto a trunk commit and place siblings beside the tree, so the checker judges the merge and tests read their siblings (issues 1675, 1676, 1677) 2026-10-21 21:04:59 +02:00
jschoubben 70500c21a8 Run mailu's automx as a Go program mounted into the mirrored alpine image, answering as captured but naming the IMAP server (issues 1631, 1632, 1700, 1701, ADR 0480)
Python automx2, its database and its automx_rows tool go; the container keeps its name, network and port, gains an http health.
2026-10-21 19:29:05 +02:00
177 changed files with 9888 additions and 926 deletions
+1
View File
@@ -27,3 +27,4 @@ modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-cli
modules/brother/cmd/brother/brother
modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go
modules/mesh-config/mesh-config
modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/imagebuild
go 1.22
+92
View File
@@ -0,0 +1,92 @@
// Command imagebuild refuses any manifest but modules/buildah providing the rootless-image-build provision
// (novox/hq ADR 0487 §3, issue 1704): the image builder's tool is buildah alone, and docker, whose daemon holds
// the machine's root, never stands in for it. A second provider would let a node compose mesh-build-image over
// something that is not the rootless builder it was designed against.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
)
// Provision is the provision only Provider may provide.
const Provision = "rootless-image-build"
// Provider is the one module allowed to provide it.
const Provider = "buildah"
// Finding is one manifest refused: a module other than buildah providing the provision.
type Finding struct {
Module string
}
// Check reads every manifest under root/modules and answers which ones it refuses, and whether buildah provides.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
provided := false
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Provides []struct {
Name string `json:"name"`
} `json:"provides"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
for _, p := range m.Provides {
if p.Name != Provision {
continue
}
if m.Module == Provider {
provided = true
} else {
out = append(out, Finding{Module: m.Module})
}
break
}
}
if !provided {
out = append(out, Finding{Module: Provider})
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
if f.Module == Provider {
fmt.Printf("%s does not provide %s (novox/hq ADR 0487 §3)\n", Provider, Provision)
continue
}
fmt.Printf("%s provides %s: only %s may, and docker never (novox/hq ADR 0487 §3)\n", f.Module, Provision, Provider)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("imagebuild: rootless-image-build is provided only by buildah")
}
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)
func catalogue(t *testing.T, manifests map[string]string) string {
t.Helper()
root := t.TempDir()
for name, body := range manifests {
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
return root
}
const buildah = `{"module":"buildah","provides":[{"name":"rootless-image-build","reach":"machine"}]}`
func TestOnlyBuildahProvidesTheRootlessImageBuild(t *testing.T) {
root := catalogue(t, map[string]string{"buildah": buildah, "go": `{"module":"go","provides":[{"name":"go-toolchain"}]}`})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("found %v, want none", got)
}
}
func TestDockerAndAnySecondProviderAreRefused(t *testing.T) {
root := catalogue(t, map[string]string{
"buildah": buildah,
"docker": `{"module":"docker","provides":[{"name":"rootless-image-build"}]}`,
"other": `{"module":"other","provides":[{"name":"x"},{"name":"rootless-image-build"}]}`,
})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := []Finding{{Module: "docker"}, {Module: "other"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("found %v, want %v", got, want)
}
}
func TestBuildahMustProvideIt(t *testing.T) {
root := catalogue(t, map[string]string{"buildah": `{"module":"buildah"}`})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if want := []Finding{{Module: "buildah"}}; !reflect.DeepEqual(got, want) {
t.Errorf("found %v, want %v", got, want)
}
}
// The catalogue as it stands: this is the check itself, run here too.
func TestTheCatalogueGivesTheProvisionToBuildahAlone(t *testing.T) {
got, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("the catalogue's providers of rootless-image-build are wrong: %v", got)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/imageplace
go 1.22
+91
View File
@@ -0,0 +1,91 @@
// Command imageplace refuses an image artifact whose Dockerfile is not a file inside its own module's directory
// (novox/hq ADR 0487 §1, issue 1707): the image builder builds a module from that module's directory alone, so
// a `from` that leaves it (../other/Dockerfile) or names a file that is not there could never build.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Finding is one image refused: Module's artifact Artifact names From, and Why says what is wrong with it.
type Finding struct {
Module, Artifact, From, Why string
}
// Check reads every manifest under root/modules and answers which images it refuses.
func Check(root string) ([]Finding, error) {
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
var out []Finding
for _, path := range manifests {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var m struct {
Module string `json:"module"`
Build struct {
Artifacts []struct {
Name string `json:"name"`
Kind string `json:"kind"`
From string `json:"from"`
} `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
dir := filepath.Dir(path)
for _, a := range m.Build.Artifacts {
if a.Kind != "image" {
continue
}
f := Finding{Module: m.Module, Artifact: a.Name, From: a.From}
clean := filepath.Clean(a.From)
switch {
case a.From == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)):
f.Why = "is not a path inside the module's own directory"
default:
info, err := os.Stat(filepath.Join(dir, clean))
if err != nil || info.IsDir() {
f.Why = "is not a file in the module's directory"
}
}
if f.Why != "" {
out = append(out, f)
}
}
}
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
found, err := Check(root)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s: image %s builds from %q, which %s (novox/hq ADR 0487 §1)\n", f.Module, f.Artifact, f.From, f.Why)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("imageplace: every image builds from a Dockerfile in its own module's directory")
}
+84
View File
@@ -0,0 +1,84 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// module writes modules/<name>/module.json with one image artifact from `from`, and the files named.
func module(t *testing.T, root, name, from string, files ...string) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
body := `{"module":"` + name + `","build":{"artifacts":[{"name":"server","kind":"image","from":"` + from + `"},{"name":"files","kind":"archive","from":"../elsewhere"}]}}`
if err := os.WriteFile(filepath.Join(dir, "module.json"), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
for _, f := range files {
p := filepath.Join(dir, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("FROM scratch\n"), 0o644); err != nil {
t.Fatal(err)
}
}
}
func TestADockerfileInTheModulesOwnDirectoryPasses(t *testing.T) {
root := t.TempDir()
module(t, root, "own", "Dockerfile", "Dockerfile")
module(t, root, "nested", "image/Dockerfile", "image/Dockerfile")
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("found %v, want none", got)
}
}
func TestADockerfileOutsideTheModulesDirectoryFails(t *testing.T) {
root := t.TempDir()
module(t, root, "other", "Dockerfile", "Dockerfile")
module(t, root, "leaves", "../other/Dockerfile")
module(t, root, "absolute", "/etc/Dockerfile")
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
var mods []string
for _, f := range got {
mods = append(mods, f.Module)
}
if want := []string{"absolute", "leaves"}; !reflect.DeepEqual(mods, want) {
t.Errorf("refused %v (%v), want %v", mods, got, want)
}
}
func TestAMissingDockerfileFails(t *testing.T) {
root := t.TempDir()
module(t, root, "gone", "Dockerfile")
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Module != "gone" {
t.Errorf("found %v, want gone refused", got)
}
}
// The catalogue as it stands: this is the check itself, run here too.
func TestTheCataloguesImagesBuildFromTheirOwnDirectories(t *testing.T) {
got, err := Check("../..")
if err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Errorf("an image builds from outside its module: %v", got)
}
}
+6 -5
View File
@@ -1,5 +1,5 @@
// Command toolchains refuses any manifest but the owner's declaring a language's toolchain package (novox/hq
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, and nodejs and npm only in modules/nodejs.
// ADR 0477 clause 4, issue 1573): the package go only in modules/go, nodejs and npm only in modules/nodejs, and buildah (issue 1702) only in modules/buildah.
// Everything that compiles or runs that language requires the owner's provision instead (go-toolchain,
// node-toolchain), so the package is declared once, and a node without the owner refuses the module that
// needs it at resolution rather than failing at its first build. The owners must declare their packages.
@@ -19,9 +19,10 @@ import (
// Owners names, for each toolchain package, the one module that declares it.
var Owners = map[string]string{
"go": "go",
"nodejs": "nodejs",
"npm": "nodejs",
"buildah": "buildah",
"go": "go",
"nodejs": "nodejs",
"npm": "nodejs",
}
// Finding is one refusal: Module declares Package, which Owner alone may, or (Missing) Owner does not declare
@@ -100,5 +101,5 @@ func main() {
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("toolchains: go, nodejs and npm are declared only by their owners")
fmt.Println("toolchains: go, nodejs, npm and buildah are declared only by their owners")
}
+20 -2
View File
@@ -26,6 +26,7 @@ func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
root := catalogue(t, map[string]string{
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
"buildah": `{"module":"buildah","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
"lab": `{"module":"lab","resources":[{"id":"git","type":"package","package":"git"},{"id":"go","type":"package","package":"go"}]}`,
"builder": `{"module":"builder","resources":[{"id":"n","type":"package","package":"nodejs"},{"id":"dir","type":"directory","path":"/go"}]}`,
})
@@ -39,6 +40,23 @@ func TestOnlyTheOwnerDeclaresAToolchainPackage(t *testing.T) {
}
}
func TestASecondDeclarerOfBuildahIsRefused(t *testing.T) {
root := catalogue(t, map[string]string{
"go": `{"module":"go","resources":[{"id":"go","type":"package","package":"go"}]}`,
"nodejs": `{"module":"nodejs","resources":[{"id":"nodejs","type":"package","package":"nodejs"},{"id":"npm","type":"package","package":"npm"}]}`,
"buildah": `{"module":"buildah","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
"imagery": `{"module":"imagery","resources":[{"id":"b","type":"package","package":"buildah"}]}`,
})
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
want := []Finding{{Module: "imagery", Package: "buildah", Owner: "buildah"}}
if !reflect.DeepEqual(got, want) {
t.Errorf("found %v, want %v", got, want)
}
}
func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
root := catalogue(t, map[string]string{
"go": `{"module":"go","resources":[]}`,
@@ -47,8 +65,8 @@ func TestTheOwnersMustExistAndDeclareTheirPackages(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Errorf("an owner missing its packages gave %v, want three findings (go, nodejs, npm)", got)
if len(got) != 4 {
t.Errorf("an owner missing its packages gave %v, want four findings (buildah, go, nodejs, npm)", got)
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
59
58
The long-running resources of this catalogue that do not say how they are ready (`health`, novox/hq ADR 0240
rule 8), as `module check` counts them. It may only go down: merge-check.sh fails a change that raises it, and
one that lowers it writes the new number on the first line. From 2026-11-18, or once it is 0, a long-running
+19
View File
@@ -50,11 +50,17 @@
# every directory and archive says `unknown` (move-out, module or program) and a valid `knows`; `move-out`
# only under a home; and no module declares a path inside a directory of another module.
# checks/sshdir (ADR 0415 §6) stays while main runs it; it goes under issue 1035 once main runs checks/owned.
# 12. the image builder (novox/hq ADR 0487 §3, issue 1704): only modules/buildah provides rootless-image-build.
# 13. the image's place (novox/hq ADR 0487 §1, issue 1707): every image builds from a Dockerfile in its own module's directory.
set -eu
(cd checks/words && go test -count=1 . && go run . ../..)
# The toolchains (novox/hq ADR 0477 clause 4): the package go only in modules/go, nodejs and npm only in modules/nodejs.
(cd checks/toolchains && go test -count=1 . && go run . ../..)
# The image builder (novox/hq ADR 0487 §3): only modules/buildah provides rootless-image-build, docker never.
(cd checks/imagebuild && go test -count=1 . && go run . ../..)
# The image's place (novox/hq ADR 0487 §1): every image builds from a Dockerfile in its own module's directory.
(cd checks/imageplace && go test -count=1 . && go run . ../..)
# The agent's home (novox/hq ADR 0266): no archive, and no file written into, under it.
(cd checks/agenthome && go test -count=1 . && go run . ../..)
# The mail-store seat (novox/hq ADR 0378 §3): only mesh-mail may claim it.
@@ -190,6 +196,19 @@ for m in $touched; do
# another process) run here and nowhere else: the unit suite stays under a second (novox/hq playbook 07).
(cd "modules/$m" && CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go vet -tags integration ${modfile:+"-modfile=$modfile"} ./... &&
CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} $race -count=1 ./...)
# The docker tests of the bus's snapshot program and entrypoint (novox/hq ADR 0480 clause 6) start throwaway nats
# containers, and the Go toolchain container holds no docker, so above they skip with a "NOT RUN" line. Where the
# build seat says docker exists (MESH_CHECK_DOCKER=1) they run here by name, and a missing docker fails the check
# (MESH_REQUIRE_DOCKER=1 makes the tests fail instead of skip). Without it the step says it did not run.
if [ "$m" = nats ]; then
if [ "${MESH_CHECK_DOCKER:-}" = 1 ]; then
(cd modules/nats && MESH_REQUIRE_DOCKER=1 CGO_ENABLED=$cgo GOPRIVATE=git.novox.be go test -tags integration ${modfile:+"-modfile=$modfile"} -count=1 \
-run 'TestTheDeclaredDumpRunsInTheBussImage|TestTheEntrypointReloadsTheServerWithoutDroppingAConnection|TestTheStoreSurvivesTheNewDeclarationTheOldAndTheNewAgain|TestASnapshotOfALiveBusRestoresToTheSameContent|TestTheSnapshotUserCannotChangeTheBus' \
./cmd/mesh-nats-snapshot) || { echo "the bus's docker tests failed"; exit 1; }
else
echo "NOT RUN: the bus's five docker tests (modules/nats/cmd/mesh-nats-snapshot) skipped: this check has no docker (MESH_CHECK_DOCKER is not 1)"
fi
fi
done
if [ -n "$untestable" ]; then
echo "the check fails: the tests of$untestable could not run (novox/hq issue 385)"
+13
View File
@@ -0,0 +1,13 @@
# buildah
The rootless image builder as a module (novox/hq ADR 0487, issue 1702).
- **The only module that declares the package `buildah`** (`checks/toolchains`), and the only one that provides
`rootless-image-build` (`checks/imagebuild`; docker never does).
- **Provides `rootless-image-build` with the machine's reach**: mesh-build-image requires it, so it composes only on a
machine where this module is assigned, and a node without it is refused at resolution naming `buildah`.
- **No account and no subordinate-id word.** The node-engine makes every `user` account with `useradd`, which
allocates the account 65536 subordinate uids and gids; mesh-build-image's own account gets its ranges that way and
its health proves them (it reads /etc/subuid and /etc/subgid).
- The helpers (an OCI runtime, passt, containers-common) come as buildah's package dependencies.
- Holds no long-running resource, so it adds nothing to `health-undeclared`.
+21
View File
@@ -0,0 +1,21 @@
{
"module": "buildah",
"version": "1",
"capabilities": [
"package-manager",
"user-namespaces"
],
"provides": [
{
"name": "rootless-image-build",
"reach": "machine"
}
],
"resources": [
{
"id": "buildah",
"type": "package",
"package": "buildah"
}
]
}
+3 -1
View File
@@ -24,7 +24,7 @@ machine's resources a seat, these tools become its verbs.
## Tools
All five only read.
All six only read.
| tool | what | replaces |
|---|---|---|
@@ -32,6 +32,7 @@ All five only read.
| `disk_top` | Over a window. `by=unit` (the default) ranks systemd units and containers by bytes moved, from their cgroups. `by=container` ranks containers by name, and `by=process` ranks processes | `iotop` |
| `disk_filesystems` | Each filesystem that stores data, said once however many places it is mounted: size, used and free GiB, used % as `df` counts it, inodes used % | `df -h`, `df -i` |
| `disk_devices` | Each disk without sampling: model, size, whether it spins, scheduler, queue limit, what it is built on, and since boot the GiB read and written, the busy share and the wait per read and per write | `lsblk`, `cat /proc/diskstats` |
| `disk_folders` | Which folders fill a disk, like `du -x`: the largest folders under `path` (default `/`) to `depth` levels (default 2, at most 3), `limit` of them (default 20, at most 100), with bytes and GiB. Sizes are allocated bytes, a hard link once. It stays on the path's one filesystem: a mount below it is named, never entered. It reads every folder the tool runner can read (docker's included) and no content; folders it cannot read are counted and named. After 25 s (the tool runner gives a call 30 s) it answers what it has measured, marked `partial`, with `stopped_at` (novox/hq issue 1804) | `du -x`, `ncdu` |
| `cpu_top` | Samples a window (`seconds`, default 3, at most 10). It answers the machine's CPU in use (% of all CPUs and as CPUs busy, from `/proc/stat`), then `by=unit` (the default) ranks systemd units and containers by CPU, from their cgroups' cpu.stat, busiest first (`limit`, default 15), with the busy time no unit accounts for; `by=container` ranks containers by name | `top`, `systemd-cgtop` |
### How each number is read
@@ -87,4 +88,5 @@ cover:
- processes ranked, one that started inside the window left out, unreadable ones counted;
- filesystems said once, mount escapes undone, a statfs that fails said;
- the window's bounds;
- folders ranked biggest first to the depth asked, a mount never entered, unreadable folders named, a walk past its bound answered as partial with where it stopped, a hard link counted once;
- the manifest: tools listed equal tools served, all read-only, no resources, no machine named.
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"fmt"
"io/fs"
"math"
"os"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
)
// FolderBudget is how long a folder walk may take before it answers what it has measured.
const FolderBudget = 25 * time.Second
// namedUnreadable is how many unreadable folders are named; the rest are only counted.
const namedUnreadable = 50
// Folder is one folder's size: everything under it on the same filesystem.
type Folder struct {
Path string `json:"path"`
Depth int `json:"depth"`
Bytes int64 `json:"bytes"`
GiB float64 `json:"gib"`
}
// FolderReport is the answer of disk_folders.
type FolderReport struct {
Path string `json:"path"`
Bytes int64 `json:"bytes"`
GiB float64 `json:"gib"`
Largest []Folder `json:"largest"`
Partial bool `json:"partial"`
StoppedAt string `json:"stopped_at,omitempty"`
Unreadable int `json:"unreadable"`
UnreadableFolders []string `json:"unreadable_folders,omitempty"`
Skipped []string `json:"other_filesystems_skipped,omitempty"`
Said []string `json:"said"`
}
func (m *Machine) readDir(real string) ([]os.DirEntry, error) {
if m.ReadDir != nil {
return m.ReadDir(real)
}
return os.ReadDir(real)
}
func (m *Machine) now() time.Time {
if m.Clock != nil {
return m.Clock()
}
return time.Now()
}
func devOf(path string, info fs.FileInfo) uint64 {
if st, ok := info.Sys().(*syscall.Stat_t); ok {
return uint64(st.Dev)
}
return 0
}
type inodeKey struct{ dev, ino uint64 }
// walk is one folder measurement.
type walk struct {
m *Machine
maxDepth int
dev uint64
deadline time.Time
seen map[inodeKey]bool
folders []Folder
bad []string
skipped []string
stopped string
}
// Folders measures path like du -x: allocated bytes, a hard link once, never entering another filesystem.
func (m *Machine) Folders(path string, depth, limit int) (FolderReport, error) {
if !filepath.IsAbs(path) {
return FolderReport{}, fmt.Errorf("path must be absolute, not %q", path)
}
path = filepath.Clean(path)
info, err := os.Stat(m.path(path))
if err != nil {
return FolderReport{}, fmt.Errorf("path %s: %w", path, err)
}
if !info.IsDir() {
return FolderReport{}, fmt.Errorf("path %s is not a folder", path)
}
budget := m.Budget
if budget == 0 {
budget = FolderBudget
}
w := &walk{m: m, maxDepth: depth, deadline: m.now().Add(budget), seen: map[inodeKey]bool{}}
w.dev = w.device(path, info)
total := w.dir(path, 0)
sort.SliceStable(w.folders, func(i, j int) bool {
if w.folders[i].Bytes != w.folders[j].Bytes {
return w.folders[i].Bytes > w.folders[j].Bytes
}
return w.folders[i].Path < w.folders[j].Path
})
if len(w.folders) > limit {
w.folders = w.folders[:limit]
}
sort.Strings(w.bad)
named := w.bad
if len(named) > namedUnreadable {
named = named[:namedUnreadable]
}
r := FolderReport{
Path: path, Bytes: total, GiB: gibOf(total), Largest: w.folders,
Partial: w.stopped != "", StoppedAt: w.stopped,
Unreadable: len(w.bad), UnreadableFolders: named, Skipped: w.skipped,
}
if r.Largest == nil {
r.Largest = []Folder{}
}
var said []string
if r.Partial {
said = append(said, fmt.Sprintf("PARTIAL: the walk stopped after %s at %s; sizes are what was measured so far", budget, r.StoppedAt))
}
said = append(said, fmt.Sprintf("%s holds %.1f GiB on its own filesystem", path, r.GiB))
if r.Unreadable > 0 {
said = append(said, fmt.Sprintf("%d folders could not be read (%s), so their size is missing", r.Unreadable, strings.Join(named, ", ")))
}
if len(r.Skipped) > 0 {
said = append(said, "other filesystems not entered: "+strings.Join(r.Skipped, ", "))
}
r.Said = said
return r, nil
}
func (w *walk) device(path string, info fs.FileInfo) uint64 {
if w.m.DevOf != nil {
return w.m.DevOf(path, info)
}
return devOf(path, info)
}
// dir is the allocated bytes under a folder; it records the folder when it is within the depth.
func (w *walk) dir(path string, depth int) int64 {
if w.stopped != "" {
return 0
}
if w.m.now().After(w.deadline) {
w.stopped = path
return 0
}
entries, err := w.m.readDir(w.m.path(path))
if err != nil {
w.bad = append(w.bad, path)
return 0
}
var total int64
for _, e := range entries {
child := filepath.Join(path, e.Name())
info, err := os.Lstat(w.m.path(child))
if err != nil {
continue // gone since it was listed
}
if info.IsDir() {
if w.device(child, info) != w.dev {
w.skipped = append(w.skipped, child)
continue
}
total += w.allocated(info, false) + w.dir(child, depth+1)
continue
}
total += w.allocated(info, true)
}
if depth >= 1 && depth <= w.maxDepth {
w.folders = append(w.folders, Folder{Path: path, Depth: depth, Bytes: total, GiB: gibOf(total)})
}
return total
}
// allocated is the bytes an entry occupies on disk, as du counts them; a file with several links once.
func (w *walk) allocated(info fs.FileInfo, file bool) int64 {
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return info.Size()
}
if file && st.Nlink > 1 {
k := inodeKey{uint64(st.Dev), uint64(st.Ino)}
if w.seen[k] {
return 0
}
w.seen[k] = true
}
return st.Blocks * 512
}
func gibOf(b int64) float64 { return math.Round(float64(b)/gib*100) / 100 }
@@ -0,0 +1,170 @@
package main
import (
"fmt"
"io/fs"
"os"
"strings"
"testing"
"time"
)
const mb = 1 << 20
// megabytes writes a file of n MiB under the fake root.
func (f *fake) megabytes(path string, n int) { f.file(path, strings.Repeat("x", n*mb)) }
func aFullDisk(t *testing.T) *fake {
f := newFake(t)
f.megabytes("var/lib/docker/overlay2/a/layer", 8)
f.megabytes("var/lib/docker/overlay2/b/layer", 4)
f.megabytes("var/log/big.log", 3)
f.megabytes("home/jo/video.mkv", 6)
f.megabytes("home/jo/deep/er/est/file", 1)
f.dir("empty")
return f
}
func folders(t *testing.T, m *Machine, args map[string]any) FolderReport {
t.Helper()
out, err := tool(t, m, "disk_folders").Run(args)
if err != nil {
t.Fatal(err)
}
return out.(FolderReport)
}
func paths(r FolderReport) string {
var p []string
for _, f := range r.Largest {
p = append(p, f.Path)
}
return strings.Join(p, ",")
}
// 1804/AC1: given a disk with folders of known sizes, when disk_folders runs with the defaults, then it answers
// the largest folders to depth 2, biggest first, with bytes and GiB, and the total of the path.
func TestFoldersAreRankedBiggestFirstToDepthTwoByDefault(t *testing.T) {
r := folders(t, aFullDisk(t).machine(), nil)
if got := paths(r); !strings.HasPrefix(got, "/var,/var/lib,") || !strings.Contains(got, "/home,/home/jo") {
t.Fatalf("ranked %s", got)
}
if r.Path != "/" || r.Partial || r.Unreadable != 0 {
t.Errorf("%+v", r)
}
top := r.Largest[0]
if top.Path != "/var" || top.Depth != 1 || top.Bytes < 15*mb || top.Bytes > 16*mb || top.GiB <= 0 {
t.Errorf("top %+v", top)
}
for _, f := range r.Largest {
if f.Depth > 2 || f.Depth < 1 {
t.Errorf("%+v is outside depth 2", f)
}
if strings.Contains(f.Path, "overlay2") {
t.Errorf("%s is deeper than 2", f.Path)
}
}
if r.Bytes < 22*mb || r.Bytes > 24*mb {
t.Errorf("total %d", r.Bytes)
}
}
// 1804/AC2: given a path, a depth and a limit, then only that folder is measured, depth is held at 3 and limit at 100.
func TestFoldersTakeAPathADepthAndALimitHeldAtThreeAndAHundred(t *testing.T) {
f := aFullDisk(t)
r := folders(t, f.machine(), map[string]any{"path": "/var/lib/docker", "depth": 1.0})
if r.Path != "/var/lib/docker" || paths(r) != "/var/lib/docker/overlay2" {
t.Errorf("%s: %s", r.Path, paths(r))
}
r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0, "limit": 2.0})
if len(r.Largest) != 2 {
t.Errorf("limit 2 gave %d", len(r.Largest))
}
r = folders(t, f.machine(), map[string]any{"path": "/home", "depth": 9.0})
if got := paths(r); !strings.Contains(got, "/home/jo/deep/er") || strings.Contains(got, "/home/jo/deep/er/est") {
t.Errorf("depth 3 under /home is /home/jo/deep/er: %s", got)
}
tl := tool(t, f.machine(), "disk_folders")
for _, bad := range []map[string]any{{"depth": 0.0}, {"limit": 0.0}, {"depth": "deep"}, {"path": "relative/dir"}, {"path": "/nope"}, {"path": "/var/log/big.log"}} {
if _, err := tl.Run(bad); err == nil {
t.Errorf("%v was accepted", bad)
}
}
for i := 0; i < 120; i++ {
f.file(fmt.Sprintf("many/d%03d/f", i), "x")
}
if r := folders(t, f.machine(), map[string]any{"path": "/many", "limit": 500.0}); len(r.Largest) != 100 {
t.Errorf("limit held at %d, not 100", len(r.Largest))
}
}
// 1804/AC3: given a mount below the path, then it is never entered or counted, and is named.
func TestFoldersNeverCrossIntoAnotherFilesystem(t *testing.T) {
f := aFullDisk(t)
m := f.machine()
m.DevOf = func(path string, info fs.FileInfo) uint64 {
if path == "/var/lib/docker" {
return 7
}
return 1
}
r := folders(t, m, nil)
if strings.Contains(paths(r), "docker") || len(r.Skipped) != 1 || r.Skipped[0] != "/var/lib/docker" {
t.Errorf("%s, skipped %v", paths(r), r.Skipped)
}
if r.Bytes > 14*mb {
t.Errorf("total %d includes the mount", r.Bytes)
}
}
// 1804/AC4: given folders it cannot read, then they are counted and named and the rest is still answered.
func TestUnreadableFoldersAreCountedAndNamed(t *testing.T) {
f := aFullDisk(t)
m := f.machine()
real := os.ReadDir
m.ReadDir = func(p string) ([]os.DirEntry, error) {
if strings.HasSuffix(p, "/var/log") || strings.HasSuffix(p, "/overlay2/b") {
return nil, fmt.Errorf("open %s: permission denied", p)
}
return real(p)
}
r := folders(t, m, map[string]any{"depth": 3.0})
if r.Unreadable != 2 || strings.Join(r.UnreadableFolders, ",") != "/var/lib/docker/overlay2/b,/var/log" {
t.Errorf("%d: %v", r.Unreadable, r.UnreadableFolders)
}
if r.Partial || !strings.Contains(strings.Join(r.Said, "\n"), "2 folders could not be read") {
t.Errorf("%+v", r)
}
}
// 1804/AC5: given a walk that outlasts its bound, then it answers what it measured, partial, with where it stopped.
func TestALongWalkAnswersPartialAndSaysWhereItStopped(t *testing.T) {
f := aFullDisk(t)
m := f.machine()
now := time.Unix(0, 0)
m.Clock = func() time.Time { now = now.Add(10 * time.Second); return now }
r := folders(t, m, nil)
if !r.Partial || r.StoppedAt == "" || len(r.Said) < 2 || !strings.HasPrefix(r.Said[0], "PARTIAL") || !strings.Contains(r.Said[0], r.StoppedAt) {
t.Errorf("%+v", r)
}
}
// 1804/AC6: a hard link is counted once, and the tool changes nothing.
func TestAHardLinkIsCountedOnce(t *testing.T) {
f := newFake(t)
f.megabytes("a/big", 5)
if err := os.Link(f.root+"/a/big", f.root+"/a/again"); err != nil {
t.Skip(err)
}
r := folders(t, f.machine(), nil)
if r.Bytes > 6*mb {
t.Errorf("counted twice: %d", r.Bytes)
}
}
// 1804/AC7: the bound sits below the 30 s the tool runner gives a call, so a partial answer reaches its caller.
func TestTheWalkBoundIsBelowTheToolRunnersCallLimit(t *testing.T) {
if FolderBudget >= 30*time.Second {
t.Errorf("the budget %s is not below the 30 s a call gets", FolderBudget)
}
}
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
@@ -53,6 +54,13 @@ type Machine struct {
Run Runner
Wait func(time.Duration)
Statfs func(path string) (FSStat, error)
// The folder walk's seams, all optional: the folder reader, the filesystem a path is on, the clock and
// the time the walk may take (60 s when zero).
ReadDir func(path string) ([]os.DirEntry, error)
DevOf func(path string, info fs.FileInfo) uint64
Clock func() time.Time
Budget time.Duration
}
// Here is the machine this process runs on.
+28
View File
@@ -136,6 +136,34 @@ func Tools(m *Machine) []stdio.Tool {
}, nil
},
},
{
Name: "disk_folders",
Description: "Which folders fill a disk, like du -x: the largest folders under a path, measured on disk (allocated bytes, a hard link " +
"once), staying on the path's one filesystem (a mount below it is named, never entered). path defaults to /, depth to 2 (at most 3), " +
"limit to 20 (at most 100). Sizes in bytes and GiB. It reads every folder the tool runner can read, none of the content; folders " +
"it cannot read are counted and named. It stops after 25 s and answers what it measured so far, marked partial, with where it stopped. " +
"Replaces du. (r)",
Input: map[string]any{
"path": map[string]any{"type": "string", "description": "the absolute folder to measure (default /)"},
"depth": map[string]any{"type": "integer", "description": "how many levels below path to rank (default 2, at most 3)"},
"limit": map[string]any{"type": "integer", "description": "how many folders to answer (default 20, at most 100)"},
},
Run: func(args map[string]any) (any, error) {
depth, err := count(args, "depth", 2, 1, 3)
if err != nil {
return nil, err
}
limit, err := count(args, "limit", 20, 1, 100)
if err != nil {
return nil, err
}
path := text(args, "path")
if path == "" {
path = "/"
}
return m.Folders(path, depth, limit)
},
},
{
Name: "disk_filesystems",
Description: "The space of every filesystem that stores data (not proc, tmpfs or overlay layers): size, used and free in GiB, " +
+1
View File
@@ -5,6 +5,7 @@
"disk_load",
"disk_top",
"disk_filesystems",
"disk_folders",
"disk_devices",
"cpu_top"
],
+1
View File
@@ -160,6 +160,7 @@ A failure is an error naming how it failed, never an empty answer.
| `docker_disk_usage` | r | `docker system df -v`: total, active and reclaimable per kind, with the largest of each |
| `docker_networks` | r | networks, subnets, and the containers on each |
| `docker_volumes` | r | volumes, who mounts each, whether the mesh holds one of them, anonymous or not, and sizes if asked |
| `docker_volumes_remove_anonymous` | a | leftover anonymous volumes no container mounts in any state (so none the mesh holds), optionally created before a time; each other volume answered with why it stays; none can be named. **A dry run unless `dry_run` is false, which needs `why`. Never forced** (issue 1796). Sizes only when `sizes` is true (docker is slow on very many volumes); at most `limit` (default 200) per call |
| `docker_events` | r | the runtime's events over a window ending now (default 60 min, at most 24 h), without exec noise unless asked; **an exec's command line is shown with any secret it carried as `[redacted: <what it was>]`** |
| `docker_daemon_config` | r | `daemon.json` as on disk, `docker info`'s essentials, and keys the daemon has not taken yet |
| `docker_unlabelled` | r | the containers the mesh does not hold: the cleanup list |
+132 -8
View File
@@ -944,16 +944,9 @@ func (c *Client) Volumes(ctx context.Context, unmountedOnly, sizes bool) (map[st
}
size := map[string]string{}
if sizes {
out, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
if err != nil {
if size, err = c.volumeSizes(ctx); err != nil {
return nil, err
}
var v struct{ Volumes []map[string]any }
if err := json.Unmarshal([]byte(out), &v); err == nil {
for _, vol := range v.Volumes {
size[fmt.Sprint(vol["Name"])] = fmt.Sprint(vol["Size"])
}
}
}
vols := []map[string]any{}
if len(lines(names)) > 0 {
@@ -1215,3 +1208,134 @@ func (c *Client) DaemonConfig(ctx context.Context) (map[string]any, error) {
answer["note"] = "keys read only at start take effect at the daemon's next restart; with live-restore on, a restart keeps every container running"
return answer, nil
}
// volumeSizes is every volume's size by name, from docker system df -v, which walks every volume:
// on a machine with a thousand of them it does not answer within a call's bound.
func (c *Client) volumeSizes(ctx context.Context) (map[string]string, error) {
size := map[string]string{}
out, err := c.docker(ctx, "system", "df", "--verbose", "--format", "{{json .}}")
if err != nil {
return nil, err
}
var v struct{ Volumes []map[string]any }
if err := json.Unmarshal([]byte(out), &v); err == nil {
for _, vol := range v.Volumes {
size[fmt.Sprint(vol["Name"])] = fmt.Sprint(vol["Size"])
}
}
return size, nil
}
// RemoveVolumesAsk is what docker_volumes_remove_anonymous is asked to take.
type RemoveVolumesAsk struct {
DryRun bool
Why string
CreatedBefore *time.Time
Sizes bool
Limit int // how many to remove in one call; 0 is 200
}
// RemoveAnonymousVolumes removes the leftover anonymous volumes (novox/hq issue 1796): those docker
// labels anonymous, which no container mounts in any state, so none a mesh-held container holds. The
// classification is Volumes' own, not a copy. Every other volume is answered with why it stays. Each
// is removed by its own `docker volume rm`, never forced and never `volume prune`, so one the daemon
// refuses is reported and the rest still go.
func (c *Client) RemoveAnonymousVolumes(ctx context.Context, a RemoveVolumesAsk) (map[string]any, error) {
if !a.DryRun && strings.TrimSpace(a.Why) == "" {
return nil, fmt.Errorf("why is required when dry_run is false")
}
all, err := c.Volumes(ctx, false, false)
if err != nil {
return nil, err
}
candidates := []string{}
skipped := []map[string]any{}
skip := func(name, why string) { skipped = append(skipped, map[string]any{"name": name, "why": why}) }
for _, v := range all["volumes"].([]map[string]any) {
name := fmt.Sprint(v["name"])
switch by := v["mounted_by"].([]map[string]any); {
case v["anonymous"] != true:
skip(name, "not anonymous: a named volume is data")
case v["mesh_held"] == true:
skip(name, "mounted by a container the mesh holds")
case len(by) > 0:
skip(name, fmt.Sprintf("mounted by %v (%v)", by[0]["container"], by[0]["state"]))
case a.CreatedBefore != nil && !volumeOlder(fmt.Sprint(v["created"]), *a.CreatedBefore):
skip(name, "not created before "+a.CreatedBefore.Format(time.RFC3339)+" (created "+fmt.Sprint(v["created"])+")")
default:
candidates = append(candidates, name)
}
}
answer := map[string]any{"dry_run": a.DryRun, "candidates": len(candidates)}
// Sizes are asked for, never needed: the listing walks every volume and may not answer, and
// then the call answers without them. Nothing below depends on it.
sizes := map[string]string{}
if a.Sizes {
if got, err := c.volumeSizes(ctx); err != nil {
answer["sizes_unknown"] = err.Error()
} else {
sizes = got
answer["sizes"] = sizes
}
}
sum := func(names []string) int64 {
var t int64
for _, n := range names {
t += max(Bytes(sizes[n]), 0)
}
return t
}
if a.DryRun {
answer["skipped"] = skipped
answer["would_remove"] = candidates
if len(sizes) > 0 {
answer["would_free_bytes"] = sum(candidates)
}
answer["note"] = "nothing was removed: call again with dry_run false and why to remove them (limit volumes at most per call)"
return answer, nil
}
limit := a.Limit
if limit <= 0 {
limit = 200
}
take := candidates
if len(take) > limit {
take = candidates[:limit]
}
removed := []string{}
// Chunks of a hundred in one `docker volume rm`, so a thousand volumes are ten calls; a chunk
// docker refuses any of is done again one by one, so what it refuses is named.
for i := 0; i < len(take); i += 100 {
chunk := take[i:min(i+100, len(take))]
if _, err := c.docker(ctx, append([]string{"volume", "rm"}, chunk...)...); err == nil {
removed = append(removed, chunk...)
continue
}
for _, name := range chunk {
if _, err := c.docker(ctx, "volume", "rm", name); err != nil {
// The batch took it before it refused another (or before its bound ran out):
// a volume that is no longer there is a removed one.
if strings.Contains(strings.ToLower(err.Error()), "no such volume") {
removed = append(removed, name)
continue
}
skip(name, "docker refused: "+err.Error())
continue
}
removed = append(removed, name)
}
}
answer["skipped"] = skipped
answer["removed"], answer["count"], answer["why"] = removed, len(removed), a.Why
answer["remaining"] = len(candidates) - len(take)
if len(sizes) > 0 {
answer["freed_bytes"] = sum(removed)
}
return answer, nil
}
// volumeOlder is whether a volume's CreatedAt is before the cutoff; one that cannot be read is not.
func volumeOlder(created string, cutoff time.Time) bool {
t, err := time.Parse(time.RFC3339Nano, created)
return err == nil && t.Before(cutoff)
}
@@ -431,3 +431,145 @@ func TestEventsCoveringTheWholeWindowSayItIsCompleteAndFilterTheTypeHere(t *test
t.Fatalf("a full buffer reaching past the window: %v", got)
}
}
// 1796/AC1: only anonymous volumes nothing mounts go, each answered; a named, a mounted and a too young one stay with why.
func TestRemoveAnonymousVolumesTakesOnlyTheUnmountedAnonymousOnes(t *testing.T) {
f := machine().
on("docker volume ls --quiet", Ran{Stdout: "webdata\nanonmounted\nold\nyoung\nnamed\n"}).
on("docker volume inspect", Ran{Stdout: `[
{"Name":"webdata","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
{"Name":"old","Driver":"local","CreatedAt":"2026-09-01T00:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
{"Name":"young","Driver":"local","CreatedAt":"2026-10-04T11:00:00Z","Labels":{"com.docker.volume.anonymous":""}},
{"Name":"named","Driver":"local","CreatedAt":"2026-01-01T00:00:00Z"}]`}).
on("docker system df --verbose", Ran{Stdout: `{"Volumes":[{"Name":"old","Size":"1.5MB"},{"Name":"young","Size":"1kB"}]}`}).
on("docker volume rm old", Ran{Stdout: "old\n"})
c := client(f, 1000)
before := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
got, err := c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true, Sizes: true, CreatedBefore: &before})
if err != nil {
t.Fatal(err)
}
if f.ran("docker volume rm") || !reflect.DeepEqual(got["would_remove"], []string{"old"}) {
t.Fatalf("dry run: %v %+v", got, f.calls)
}
if got["would_free_bytes"] != int64(1500000) || got["sizes"].(map[string]string)["old"] != "1.5MB" {
t.Fatalf("dry run size: %v", got)
}
skipped := got["skipped"].([]map[string]any)
why := map[string]string{}
for _, s := range skipped {
why[s["name"].(string)] = s["why"].(string)
}
if len(why) != 3 || !strings.Contains(why["webdata"], "mesh holds") || why["young"] == "" || why["named"] == "" {
t.Fatalf("skipped: %v", skipped)
}
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "leftovers", Sizes: true, CreatedBefore: &before})
if err != nil {
t.Fatal(err)
}
if !f.ran("docker volume rm old") || got["count"] != 1 || !reflect.DeepEqual(got["removed"], []string{"old"}) {
t.Fatalf("removed: %v %+v", got, f.calls)
}
if got["freed_bytes"] != int64(1500000) {
t.Fatalf("freed: %v", got)
}
for _, c := range f.calls {
if len(c.args) > 1 && c.args[0] == "volume" && c.args[1] == "rm" && (len(c.args) != 3 || c.args[2] != "old") {
t.Errorf("reached too far: %v", c.args)
}
}
}
// 1796/AC2: a volume that is mounted by a mesh-held container is never removed; the tool takes no name.
func TestRemoveAnonymousVolumesRefusesANamedVolumeAndKeepsWhatTheMeshHolds(t *testing.T) {
for _, tool := range tools(client(&fake{}, 1000)) {
if tool.Name != "docker_volumes_remove_anonymous" {
continue
}
for _, k := range []string{"name", "names", "volume", "volumes"} {
if _, err := tool.Run(map[string]any{k: "x", "dry_run": false, "why": "w"}); err == nil {
t.Errorf("%s accepted", k)
}
}
if _, err := tool.Run(map[string]any{"dry_run": false}); err == nil {
t.Error("a removal without why was accepted")
}
if _, err := tool.Run(map[string]any{"created_before": "yesterday"}); err == nil {
t.Error("a bad time was accepted")
}
return
}
t.Fatal("tool not served")
}
// 1796/AC3: on a machine with a very large set of volumes docker's size listing does not answer in
// time; the tool still answers, and removal never depends on it.
func TestRemoveAnonymousVolumesAnswersWhenTheSizeListingDoesNot(t *testing.T) {
f := (&fake{}).
on("docker ps --all --quiet --no-trunc", Ran{}).
on("docker volume ls --quiet", Ran{Stdout: "a1\na2\na3\n"}).
on("docker volume inspect", Ran{Stdout: `[{"Name":"a1","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a2","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a3","Labels":{"com.docker.volume.anonymous":""}}]`}).
on("docker system df", Ran{Status: 124, Err: "no answer within 20 s"}).
on("docker volume rm", Ran{Stdout: "ok\n"})
c := client(f, 1000)
got, err := c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true})
if err != nil || f.ran("docker system df") || !reflect.DeepEqual(got["would_remove"], []string{"a1", "a2", "a3"}) {
t.Fatalf("default dry run: %v %v %+v", got, err, f.calls)
}
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{DryRun: true, Sizes: true})
if err != nil || !reflect.DeepEqual(got["would_remove"], []string{"a1", "a2", "a3"}) || got["sizes_unknown"] == nil {
t.Fatalf("sizes asked, df silent: %v %v", got, err)
}
got, err = c.RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w", Sizes: true, Limit: 2})
if err != nil || got["count"] != 2 || got["remaining"] != 1 || !f.ran("docker volume rm a1 a2") {
t.Fatalf("removal: %v %v %+v", got, err, f.calls)
}
}
// 1796/AC4: a batch docker half-did is settled by name: what is already gone counts as removed, what is in use stays with why.
func TestRemoveAnonymousVolumesCountsWhatAFailedBatchAlreadyRemoved(t *testing.T) {
f := (&fake{}).
on("docker ps --all --quiet --no-trunc", Ran{}).
on("docker volume ls --quiet", Ran{Stdout: "a1\na2\na3\n"}).
on("docker volume inspect", Ran{Stdout: `[{"Name":"a1","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a2","Labels":{"com.docker.volume.anonymous":""}},{"Name":"a3","Labels":{"com.docker.volume.anonymous":""}}]`}).
on("docker volume rm a1 a2 a3", Ran{Status: 1, Stdout: "a1\n", Stderr: "Error response from daemon: remove a2: volume is in use - [c1]\n"}).
on("docker volume rm a1", Ran{Status: 1, Stderr: "Error response from daemon: get a1: no such volume\n"}).
on("docker volume rm a2", Ran{Status: 1, Stderr: "Error response from daemon: remove a2: volume is in use - [c1]\n"}).
on("docker volume rm a3", Ran{Stdout: "a3\n"})
got, err := client(f, 1000).RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w"})
if err != nil {
t.Fatal(err)
}
sk := got["skipped"].([]map[string]any)
if !reflect.DeepEqual(got["removed"], []string{"a1", "a3"}) || got["count"] != 2 || len(sk) != 1 || sk[0]["name"] != "a2" || !strings.Contains(sk[0]["why"].(string), "in use") {
t.Fatalf("%v", got)
}
}
// 1796/AC5: removal goes in batches of a hundred names.
func TestRemoveAnonymousVolumesRemovesInBatchesOfAHundred(t *testing.T) {
names, entries := []string{}, []string{}
for i := 0; i < 250; i++ {
n := fmt.Sprintf("v%03d", i)
names = append(names, n)
entries = append(entries, `{"Name":"`+n+`","Labels":{"com.docker.volume.anonymous":""}}`)
}
f := (&fake{}).
on("docker ps --all --quiet --no-trunc", Ran{}).
on("docker volume ls --quiet", Ran{Stdout: strings.Join(names, "\n")}).
on("docker volume inspect", Ran{Stdout: "[" + strings.Join(entries, ",") + "]"}).
on("docker volume rm", Ran{Stdout: "ok\n"})
got, err := client(f, 1000).RemoveAnonymousVolumes(context.Background(), RemoveVolumesAsk{Why: "w", Limit: 250})
if err != nil || got["count"] != 250 {
t.Fatalf("%v %v", got, err)
}
sizes := []int{}
for _, c := range f.calls {
if len(c.args) > 1 && c.args[0] == "volume" && c.args[1] == "rm" {
sizes = append(sizes, len(c.args)-2)
}
}
if !reflect.DeepEqual(sizes, []int{100, 100, 50}) {
t.Fatalf("batches %v", sizes)
}
}
+35
View File
@@ -11,6 +11,7 @@ import (
"math"
"os"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
)
@@ -286,6 +287,40 @@ func tools(c *Client) []stdio.Tool {
return c.Volumes(ctx, flag(args, "unmounted", false), flag(args, "sizes", false))
},
},
{
Name: "docker_volumes_remove_anonymous",
Description: "Remove the leftover anonymous volumes (novox/hq issue 1796): only a volume docker labels anonymous (as docker_volumes says), which no container mounts in any state " +
"and so none the mesh holds, and with created_before only one created before it. Every other volume is answered with why it stays; a named volume is data and is never taken, " +
"and no volume can be named. Each is removed on its own, never forced; the answer has the count and names removed. With sizes true the answer has each volume's size and the bytes freed; docker's size listing is slow on many volumes, so it is asked for, and when it does not answer the call answers without it. At most limit are removed per call, in batches. " +
"A dry run by default; dry_run false needs why. Replaces docker volume rm and docker volume prune. (a)",
Input: map[string]any{
"dry_run": map[string]any{"type": "boolean", "description": "list only (default true)"},
"why": map[string]any{"type": "string", "description": "why: required when dry_run is false"},
"created_before": map[string]any{"type": "string", "description": "RFC 3339 time: only volumes created before it (default: any age)"},
"sizes": map[string]any{"type": "boolean", "description": "also read each volume's size and the bytes freed (default false: docker walks every volume for it and on a machine with very many may not answer; the call then answers without sizes)"},
"limit": map[string]any{"type": "integer", "description": "how many to remove in one call (default 200, at most 2000); the answer says how many remain, call again for them"},
},
Run: func(args map[string]any) (any, error) {
for _, k := range []string{"name", "names", "volume", "volumes"} {
if _, ok := args[k]; ok {
return nil, fmt.Errorf("%s: this tool takes no volume by name; it removes only unmounted anonymous volumes", k)
}
}
limit, err := bounded(args, "limit", 200, 2000)
if err != nil {
return nil, err
}
ask := RemoveVolumesAsk{DryRun: flag(args, "dry_run", true), Why: optional(args, "why"), Sizes: flag(args, "sizes", false), Limit: limit}
if s := optional(args, "created_before"); s != "" {
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return nil, fmt.Errorf("created_before must be an RFC 3339 time such as 2026-10-01T00:00:00Z")
}
ask.CreatedBefore = &t
}
return c.RemoveAnonymousVolumes(ctx, ask)
},
},
{
Name: "docker_events",
Description: "What the runtime did in a window ending now (default the last 60 minutes, at most 24 hours): containers created, started, died, health changes, images pulled — with mesh_held. Exec events are left out unless asked; " +
+1
View File
@@ -33,6 +33,7 @@
"docker_disk_usage",
"docker_networks",
"docker_volumes",
"docker_volumes_remove_anonymous",
"docker_events",
"docker_daemon_config",
"docker_unlabelled",
+44
View File
@@ -0,0 +1,44 @@
// **A consumer that cannot be bound yet is bound again, never fatal** (novox/hq issue 1793). When the tool runtime
// starts this bundle, the runtime's answer to "bind this module's consumer" can be "no responders" for a few seconds (the
// runtime restarted by a delivery, the bus not yet serving the stream). That rejection, left unhandled at the top of
// index.ts, ended the bundle: nothing heard `mesh-controller.checked` again, and a verdict that landed later never
// reached the forge's statuses. A bind that fails is said and asked again with a growing wait until it holds.
/** How long to wait before the nth new attempt (1-based): 2 s, 5 s, 10 s, then every 30 s. */
export function bindWait(attempt: number): number {
return [2_000, 5_000, 10_000][attempt - 1] ?? 30_000;
}
/**
* Binds with `bind`, asking again after each failure, until it holds. `sleep` is the wait; `say` how a failure is said.
* Never rejects; resolves once the bind holds.
*/
export async function bindUntilHeld(what: string, bind: () => Promise<unknown>,
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms)),
say: (line: string) => void = (line) => console.error(line)): Promise<void> {
for (let attempt = 1; ; attempt++) {
try {
await bind();
if (attempt > 1) say(`[gitea] ${what} is bound after ${attempt} attempts`);
return;
} catch (err) {
const wait = bindWait(attempt);
say(`[gitea] ${what} could not be bound (${err instanceof Error ? err.message : String(err)}); asking again in ${wait / 1000}s`);
await sleep(wait);
}
}
}
/**
* **Starts the binds without making the bundle wait for them** (novox/hq issue 1793). The launcher imports the bundle
* and then answers the runtime's handshake, which the runtime gives 10 s and then kills the bundle without starting it
* again; a bind awaited at the top of the module that stays refused longer ends the bundle all the same. So this
* returns at once: the binds run in the order given, each until it holds, and `done` is called after the last.
*/
export function bindInBackground(binds: [what: string, bind: () => Promise<unknown>][], done: () => void,
sleep?: (ms: number) => Promise<void>, say?: (line: string) => void): void {
void (async () => {
for (const [what, bind] of binds) await bindUntilHeld(what, bind, sleep, say);
done();
})();
}
@@ -179,7 +179,7 @@ func newTeamRig(t *testing.T) *teamRig {
svc := NewService(Config{Node: "novox", HostFile: host, OriginsFile: origins, Port: 222,
Forge: forgehttp.Client{URL: srv.URL, User: "mesh-admin", PasswordFile: pw}, Runner: runner, Log: func(string, ...any) {}})
svc.loaded = true
svc.loadedFrom["mesh-build-go"], svc.loadedFrom["mesh-build-node"] = true, true
svc.loadedFrom["mesh-build-go"], svc.loadedFrom["mesh-build-node"], svc.loadedFrom["mesh-build-image"] = true, true, true
return &teamRig{t: t, bus: newBus(), forge: forge, svc: svc, runner: runner}
}
@@ -347,7 +347,7 @@ func TestABuildModulesEntryWaitsForItsOwnBuilderKey(t *testing.T) {
}
func TestTheKindOfAModulesKeyAndItsForgeUser(t *testing.T) {
for module, want := range map[string]string{"mesh-build-go": "nox-builder", "mesh-build-node": "nox-builder", "ssh-client": "nox-agent", "anything-else": "nox-agent"} {
for module, want := range map[string]string{"mesh-build-go": "nox-builder", "mesh-build-node": "nox-builder", "mesh-build-image": "nox-builder", "ssh-client": "nox-agent", "anything-else": "nox-agent"} {
got, err := (&Service{}).valuesForUser("ace", module)
if err != nil || got != want {
t.Errorf("%s: %q, %v; want %q", module, got, err, want)
@@ -38,7 +38,7 @@ func newWatched(t *testing.T) *watched {
r.svc.OnKeys = func(string) { n++ }
r.svc.cfg.Log = func(f string, a ...any) { mu.Lock(); logs = append(logs, fmt.Sprintf(f, a...)); mu.Unlock() }
w := &watched{teamRig: r, sources: map[string]*fakeKeys{}, passes: &n, logs: &logs, mu: mu}
for _, module := range []string{"ssh-client", "mesh-build-go", "mesh-build-node"} {
for _, module := range []string{"ssh-client", "mesh-build-go", "mesh-build-node", "mesh-build-image"} {
w.sources[module] = &fakeKeys{}
if err := r.svc.WatchSource(context.Background(), module, w.sources[module]); err != nil {
t.Fatal(err)
@@ -154,7 +154,7 @@ func TestTheManifestReadsTheBuildModulesKeys(t *testing.T) {
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys"} {
for _, state := range []string{"mesh-controller.assignments", "ssh-client.keys", "mesh-build-go.keys", "mesh-build-node.keys", "mesh-build-image.keys"} {
if !slices.Contains(m.Reads, state) {
t.Errorf("gitea's reads %v lack %q", m.Reads, state)
}
+15 -11
View File
@@ -47,6 +47,7 @@ import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateS
import { GateWriter, clonedAs, type BuildStarted } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
import { bindInBackground } from "./bind.js";
import { announcementOf, keepTook, lateVerdictComment, outOfDateWords, sameCommit, tookFile, weighTrunks } from "./trunk.js";
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
@@ -590,15 +591,18 @@ if (gitea) {
tick(() => pollRepos(client), 60_000);
tick(() => pollMerged(client), () => pace.every());
tick(() => pollPulls(client), () => pace.every());
await on("mesh-controller.checked", (event) => {
const c = (event.body ?? {}) as Checked;
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
if (!c.group && c.owner && c.repo && c.commit) {
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
}
return setVerdict(client, event);
});
await on("node-build-agent.started", buildStarted);
console.log("[gitea] watching for new repositories and merged pull requests");
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its
// handshake in 10 s, so the import must end while a refused bind is still asked again.
bindInBackground([
["mesh-controller.checked", () => on("mesh-controller.checked", (event) => {
const c = (event.body ?? {}) as Checked;
// A head's own verdict takes its turn after every write of that head's gate already asked for (gate.ts).
// A verdict of a check asked before the one now pending is an older run's, and dropped (novox/hq issue 456).
if (!c.group && c.owner && c.repo && c.commit) {
return gates.verdict(c.owner, c.repo, c.commit, () => setVerdict(client, event), c.id);
}
return setVerdict(client, event);
})],
["node-build-agent.started", () => on("node-build-agent.started", buildStarted)],
], () => console.log("[gitea] watching for new repositories and merged pull requests"));
}
@@ -195,7 +195,7 @@ const BuilderUser = "nox-builder"
// BuildModules are the modules whose keys are the builders' (kind builder), by their name, which is also the name of
// the account each runs as (ADR 0466 §1).
var BuildModules = []string{"mesh-build-go", "mesh-build-node"}
var BuildModules = []string{"mesh-build-go", "mesh-build-node", "mesh-build-image"}
// KindOfModule is the kind of key a consumer module's entry is made for: builder for a build module, machine for any
// other (ssh-client's).
@@ -12,7 +12,7 @@ func TestTheBuildModulesHaveOneForgeUserAndTheMachinesKeepTheirs(t *testing.T) {
if ForgeUser("Not A Node", "builder") != "" {
t.Errorf("a name no account could be named for got a user")
}
for module, want := range map[string]string{"mesh-build-go": "builder", "mesh-build-node": "builder", "ssh-client": "machine", "": "machine"} {
for module, want := range map[string]string{"mesh-build-go": "builder", "mesh-build-node": "builder", "mesh-build-image": "builder", "ssh-client": "machine", "": "machine"} {
if got := KindOfModule(module); got != want {
t.Errorf("KindOfModule(%q) = %s, want %s", module, got, want)
}
+2 -1
View File
@@ -542,6 +542,7 @@
"mesh-controller.assignments",
"ssh-client.keys",
"mesh-build-go.keys",
"mesh-build-node.keys"
"mesh-build-node.keys",
"mesh-build-image.keys"
]
}
+58
View File
@@ -0,0 +1,58 @@
// **A consumer that cannot be bound yet is bound again** (novox/hq issue 1793): the runtime's "no responders" at the
// bundle's start, seen 2026-10-22 00:28:37 on mesh-catalog#427's verdict, ended the bundle and the verdict was never
// written on the forge.
import { test } from "node:test";
import assert from "node:assert/strict";
import { bindInBackground, bindUntilHeld, bindWait } from "../dist/bind.js";
test("a bind refused with no responders is asked again until it holds, and says so", async () => {
let calls = 0;
const waits: number[] = [];
const said: string[] = [];
await bindUntilHeld("mesh-controller.checked", async () => {
calls++;
if (calls < 4) throw new Error("binding gitea's consumer novox_gitea: nats: no responders available for request");
}, async (ms) => { waits.push(ms); }, (l) => said.push(l));
assert.equal(calls, 4);
assert.deepEqual(waits, [2_000, 5_000, 10_000]);
assert.equal(said.length, 4);
assert.match(said[0], /no responders available for request.*asking again in 2s/);
assert.match(said[3], /is bound after 4 attempts/);
});
test("a bind that holds the first time says nothing and waits for nothing", async () => {
const said: string[] = [];
await bindUntilHeld("x", async () => {}, async () => assert.fail("waited"), (l) => said.push(l));
assert.deepEqual(said, []);
});
test("the wait grows to 30 s and stays", () => {
assert.deepEqual([1, 2, 3, 4, 50].map(bindWait), [2_000, 5_000, 10_000, 30_000, 30_000]);
});
test("the binds start without being waited for: the caller returns while a bind keeps refusing, and they go in order", async () => {
const order: string[] = [];
let finished = false;
let refusals = 0;
const gate = new Promise<void>((r) => setTimeout(r, 0));
bindInBackground([
["first", async () => { refusals++; if (refusals < 3) throw new Error("nats: no responders available for request"); order.push("first"); }],
["second", async () => { order.push("second"); }],
], () => { finished = true; }, async () => { await gate; }, () => {});
// Returned at once, with the first bind refused and nothing done.
assert.equal(finished, false);
assert.deepEqual(order, []);
await new Promise((r) => setTimeout(r, 20));
assert.deepEqual(order, ["first", "second"]);
assert.equal(finished, true);
});
test("a bind that never holds does not stop the caller from returning", () => {
let returned = false;
bindInBackground([["x", async () => { throw new Error("no responders"); }]], () => assert.fail("done"),
() => new Promise(() => {}), () => {});
returned = true;
assert.equal(returned, true);
});
+38
View File
@@ -0,0 +1,38 @@
// **The bundle's import ends while a bind is refused** (novox/hq issue 1793). The launcher imports index.js and then answers
// node-tools' handshake, which has 10 s before the bundle is killed and never started again. A bind awaited at the top
// of index.ts, refused for longer, ended the bundle at 00:28:37 on 2026-10-22. Run in a child: the polls' timers keep a
// process alive, and the child says "imported" and exits as soon as the import resolves.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
test("importing the bundle resolves while the broker keeps refusing the binds", async () => {
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
const script = `
const { useBroker } = await import("@novox/mesh-sdk/messaging");
useBroker(() => ({
subscribe: async () => { throw new Error("binding gitea's consumer novox_gitea: nats: no responders available for request"); },
publish: async () => {}, request: async () => { throw new Error("no responders"); },
handle: async () => () => {}, close: async () => {},
}));
await import(${JSON.stringify(index)});
console.log("imported");
process.exit(0);`;
const env = { ...process.env, MESH_GITEA_TOKEN: "x", MESH_GITEA_URL: "http://127.0.0.1:1" };
for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE"]) delete env[k];
const child = spawn(process.execPath, ["--input-type=module", "-e", script], { cwd: join(here, ".."), env, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
let err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
const ended = new Promise<void>((r) => child.on("exit", () => r()));
const timer = setTimeout(() => child.kill("SIGKILL"), 3000);
await ended;
clearTimeout(timer);
assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`);
});
+13 -1
View File
@@ -16,6 +16,18 @@ installation's are settings on the assignment, `settings set mailu <file>`:
header from — the address the proxy forwards with. The front's own hostname is the name of its
`web` route, told to it by the mesh.
## Mail client autoconfiguration
`mailu-automx` answers what mail clients ask for their settings (Thunderbird, Outlook, Apple) for the domains in
`AMX_MAIL_DOMAINS`, naming the IMAP and SMTP servers and ports of mailu's environment (`AMX_IMAP_ADDRESS`,
`AMX_IMAP_PORT`, `AMX_SMTP_ADDRESS`, `AMX_SMTP_PORT`). It is `cmd/automx`, a Go program of the standard library
only in this module's `go.mod` (ADR 0480 clauses 1, 2, 9). It runs in the mirrored alpine image the route proxy's
`trust` step runs, from the `program-automx` archive mounted read-only at `/opt/mesh`; the container is restarted
with the archive and with `mailu.env`, which the program reads at each start. It keeps no database. The file the
Python automx kept in `data-automx` is left where it is on the machine, not deleted by the mesh (ADR 0480
clause 10). The answers it gave before are the fixtures in `cmd/automx/testdata/captured`, with how they were
taken (`CAPTURED`) and what the program answers differently (`DIFFERENCES`).
## Files it does not know
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
@@ -23,7 +35,7 @@ Each directory or archive this module declares says how it treats a file it did
- `${dir:mesh-state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:grants}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data-automx}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- the archive `program-automx` (`${dir:state}/automx`): everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data-certs}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data-data}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data-dkim}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
-87
View File
@@ -1,87 +0,0 @@
// What the autoconfiguration module (automx2) holds, as stored: its provider, server, domain and server_domain
// rows, read from its SQLite file inside its container with the sqlite3 program the image carries, opened
// read-only. Nothing is written and nothing is interpreted: the rows are the evidence for what automx answers
// to mail clients (novox/hq issue 1630, ADR 0480), and the reason the answer may be empty.
import type { Exec } from "./health.js";
export const AUTOMX_DATABASE = "/data/db.sqlite";
/** The tables read, each with its columns and its ordering. Fixed text: the tool takes no query from its caller. */
export const AUTOMX_TABLES: Record<string, { columns: string[]; order: string }> = {
provider: { columns: ["id", "name", "short_name"], order: "id" },
server: { columns: ["id", "prio", "name", "port", "type", "socket_type", "user_name", "authentication"], order: "id" },
domain: { columns: ["id", "name", "provider_id", "ldapserver_id"], order: "id" },
server_domain: { columns: ["server_id", "domain_id"], order: "domain_id, server_id" },
};
/**
* The one statement that reads a table. The sqlite3 in the mailu-automx image does not know `-json` (it answers
* "sqlite3: Error: unknown option: -json", issue 1630), so each column is printed as `<type>:<hex of its bytes>`, which
* the oldest sqlite3 prints in its default list mode: no quoting, no separator or newline inside a cell, and a NULL
* (`null:`) apart from an empty text (`text:`).
*/
export function selectOf(table: string): string {
const t = AUTOMX_TABLES[table];
return `SELECT ${t.columns.map((c) => `typeof(${c}) || ':' || hex(${c})`).join(", ")} FROM ${table} ORDER BY ${t.order}`;
}
export type Row = Record<string, string | number | null>;
export type AutomxRows = {
database: string;
tables: Record<string, Row[]>;
/** servers whose name is empty: the client is told an empty host name for them */
empty_names: { id: number; type: string }[];
/** tables that could not be read, each with what sqlite3 or docker said on one line */
unreadable: Record<string, string>;
};
/** One cell as selectOf prints it: `<type>:<hex>`; text is the bytes read as UTF-8, a number is read from its text. */
function cellOf(cell: string): string | number | null {
const at = cell.indexOf(":");
if (at < 0 || cell.length - at - 1 > 0 && !/^([0-9A-Fa-f]{2})+$/.test(cell.slice(at + 1))) throw new Error(`sqlite3 printed ${JSON.stringify(cell.slice(0, 40))}, not a cell`);
const type = cell.slice(0, at);
const bytes = Buffer.from(cell.slice(at + 1), "hex").toString("utf8");
switch (type) {
case "null": return null;
case "integer": case "real": return Number(bytes);
default: return bytes;
}
}
/** sqlite3 prints one line per row, cells apart by `|`, and nothing at all for a table without rows. */
export function parseRows(table: string, output: string): Row[] {
const columns = AUTOMX_TABLES[table].columns;
const rows: Row[] = [];
for (const line of output.split("\n")) {
if (line.trim() === "") continue;
const cells = line.replace(/\r$/, "").split("|");
if (cells.length !== columns.length) throw new Error(`sqlite3 printed ${cells.length} cells for ${columns.length} columns`);
rows.push(Object.fromEntries(columns.map((c, i) => [c, cellOf(cells[i])])));
}
return rows;
}
/** The command that reads one table: read-only, one fixed statement. */
export function readCommand(table: string): string[] {
return ["sqlite3", "-readonly", AUTOMX_DATABASE, selectOf(table)];
}
/** Read every table; one that fails is said in `unreadable` and does not hide the others. */
export async function readAutomx(exec: Exec, container: string): Promise<AutomxRows> {
const answer: AutomxRows = { database: AUTOMX_DATABASE, tables: {}, empty_names: [], unreadable: {} };
for (const table of Object.keys(AUTOMX_TABLES)) {
try {
answer.tables[table] = parseRows(table, await exec(container, readCommand(table)));
} catch (e) {
const err = e as { stderr?: string; message?: string };
const text = (err?.stderr ?? "").trim() || (err?.message ?? String(e)).trim();
answer.unreadable[table] = text.split("\n").map((l) => l.trim()).filter(Boolean)[0] ?? "it failed and said nothing";
}
}
for (const s of answer.tables.server ?? []) {
if (String(s.name ?? "").trim() === "") answer.empty_names.push({ id: Number(s.id), type: String(s.type) });
}
return answer;
}
-44
View File
@@ -1,44 +0,0 @@
# automx2 — the autoconfig/autodiscover responder, carried by the mailu module as its own
# artifact: it is a config-baked sidecar of this mail server, not a standalone application
# (novox/hq ADR 0015 draws that line at applications).
#
# The base is named rather than pinned (novox/hq issue 044): declared in module.json's
# `build.on`. The build context is the module's own directory; every ADD says so.
ARG PYTHON_BASE
FROM ${PYTHON_BASE}
RUN apk add --no-cache bash sqlite
WORKDIR /automx2
ADD automx/files/setupvenv.sh /automx2/setupvenv.sh
ADD automx/files/start /automx2/start
ADD automx/files/setup /automx2/setup
ADD automx/files/setup-db /automx2/setup-db
ADD automx/files/add-domains /automx2/add-domains
RUN chmod u+x setupvenv.sh start add-domains setup setup-db
RUN ./setupvenv.sh \
&& . .venv/bin/activate \
&& pip install automx2==2021.6
# The launcher `start` expects. In the predecessor's image this wrapper appeared during a build
# step that never made it into the files this module carries — the image worked and the recipe
# could not reproduce it. Written here explicitly, verbatim from the proven image, so the build
# is the whole truth about the image again.
RUN mkdir -p .venv/scripts && printf '%s\n' \
'#!/usr/bin/env bash' \
'set -euo pipefail' \
'. .venv/bin/activate' \
"export FLASK_ENV='production'" \
"export FLASK_APP='automx2.server:app'" \
'flask "$@"' > .venv/scripts/flask.sh && chmod +x .venv/scripts/flask.sh
ENV AUTOMX2_CONF=/etc/automx2.conf
ADD automx/files/automx2.conf /etc/automx2.conf
# VOLUME deliberately absent: the anonymous /data volume is exactly what lost db.sqlite on
# every recreate (measured on novox 2026-08-10). The manifest binds a real directory instead.
ENTRYPOINT ["/bin/sh"]
CMD ["./start"]
EXPOSE 4243
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env bash
set -e
echo "${MAIL_DOMAINS}"
# Split domains into array
IFS=', ' read -r -a array <<< "${AMX_MAIL_DOMAINS}"
# User configurable section -- START
PROVIDER_ID=001
SQL_CMD="";
# Iterate domains resulting from split on second arg
for element in "${array[@]}"
do
# Set vars
DOMAIN=$element
PROVIDER_NAME=$DOMAIN
PROVIDER_SHORTNAME=$DOMAIN
# Optional LDAP server
#LDAP_SERVER="ldap.${DOMAIN}"
# User configurable section -- END
s1_id=$((PROVIDER_ID + 1))
s2_id=$((PROVIDER_ID + 2))
s3_id=$((PROVIDER_ID + 3))
dom_id=$((PROVIDER_ID + 4))
s3_id='NULL'
SQL_CMD=$(cat <<EOT
$SQL_CMD
INSERT INTO provider(id, name, short_name) VALUES(${PROVIDER_ID}, '${PROVIDER_NAME}', '${PROVIDER_SHORTNAME}');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES(${s1_id}, ${AMX_IMAP_PORT}, 'imap', '${AMX_IMAP_SERVER}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES(${s2_id}, ${AMX_SMTP_PORT}, 'smtp', '${AMX_SMTP_ADDRESS}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO domain(id, name, provider_id, ldapserver_id) VALUES(${dom_id}, '${DOMAIN}', ${PROVIDER_ID}, ${s3_id});
INSERT INTO server_domain(server_id, domain_id) VALUES(${s1_id}, ${dom_id});
INSERT INTO server_domain(server_id, domain_id) VALUES(${s2_id}, ${dom_id});
EOT
)
PROVIDER_ID=$((PROVIDER_ID+10))
done
echo -e ${SQL_CMD}
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
-21
View File
@@ -1,21 +0,0 @@
[automx2]
# A typical production setup would use loglevel = WARNING
loglevel = WARNING
# Echo SQL commands into log? Used for debugging.
db_echo = false
# In-memory SQLite database
# db_uri = sqlite:///:memory:
# SQLite database in a UNIX-like file system
db_uri = sqlite:////data/db.sqlite
# MySQL database on a remote server. This example does not use an encrypted
# connection and is therefore *not* recommended for production use.
#db_uri = mysql://username:password@server.example.com/db
# Number of proxy servers between automx2 and the client (default: 0).
# If your logs only show 127.0.0.1 or ::1 as the source IP for incoming
# connections, proxy_count probably needs to be changed.
proxy_count = 1
-12
View File
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -e
if [ ! -e /data/db.sqlite ]; then
# DB SETUP
echo "SETTING UP DB"
./setup-db
echo "ADDING DOMAINS"
# Add the domains
./add-domains
fi
-84
View File
@@ -1,84 +0,0 @@
#!/usr/bin/env bash
set -e
# LDAP-Server
LDAP=$(cat <<EOT
CREATE TABLE ldapserver(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
port INT NOT NULL,
use_ssl INT NOT NULL,
search_base TEXT NOT NULL,
search_filter TEXT NOT NULL,
attr_uid TEXT NOT NULL,
attr_cn TEXT NOT NULL,
bind_password TEXT NOT NULL,
bind_user TEXT NOT NULL
);
EOT
)
# Provider
PROVIDER=$(cat <<EOT
CREATE TABLE provider(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
short_name TEXT NOT NULL
);
EOT
)
# Server
SERVER=$(cat <<EOT
CREATE TABLE server(
id INT PRIMARY KEY NOT NULL,
prio INT NOT NULL DEFAULT 10,
name TEXT NOT NULL,
port INT NOT NULL,
type TEXT NOT NULL,
socket_type TEXT NOT NULL,
user_name TEXT NOT NULL,
authentication TEXT NOT NULL
);
EOT
)
# Domain
DOMAIN=$(cat <<EOT
CREATE TABLE domain(
id INT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
provider_id INT NOT NULL,
ldapserver_id INT NULL,
FOREIGN KEY(ldapserver_id) REFERENCES ldapserver(id),
FOREIGN KEY(provider_id) REFERENCES provider(id)
);
CREATE UNIQUE INDEX domain_name ON domain(name);
EOT
)
# Server-Domain
SERVER_DOMAIN=$(cat <<EOT
CREATE TABLE server_domain(
server_id INT NOT NULL,
domain_id INT NOT NULL,
FOREIGN KEY(server_id) REFERENCES server(id),
FOREIGN KEY(domain_id) REFERENCES domain(id)
);
EOT
)
## TODO Foreign keys
SQL_CMD=$(cat <<EOT
$LDAP
$PROVIDER
$SERVER
$DOMAIN
$SERVER_DOMAIN
EOT
)
echo -e ${SQL_CMD}
echo -e ${SQL_CMD} | sqlite3 /data/db.sqlite
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env bash
# vim:ts=4:sw=4:noet
#
# Creates a Python 3 virtual environment. The target directory can be passed
# as a parameter. The default path is '.venv' in the current directory.
dir="${1:-.venv}"
echo "Setup dir $dir"
set -e
if [ -d "${dir}" ]; then
echo >&2 "Directory '${dir}' already exists, exiting."
exit 1
fi
python3 -m venv "${dir}"
source "${dir}/bin/activate"
set +e
pip install -U pip setuptools wheel || true
#set -e
## vim:tabstop=4:noexpandtab
##
## Creates a Python 3 virtual environment. The target directory can be passed
## as a parameter. The default path is 'venv' in the current directory.
#
#dir="${1:-venv}"
#
#set -e
#if [ -d "${dir}" ]; then
# echo "Directory '${dir}' already exists, exiting." >&2
# exit 1
#fi
#python3 -m venv "${dir}"
#. "${dir}/bin/activate"
#
#set +e
#pip install -U pip setuptools || true
-8
View File
@@ -1,8 +0,0 @@
#!/usr/bin/env bash
set -e
# Setup
./setup
# Start
./.venv/scripts/flask.sh run --host=0.0.0.0 --port=4243
+260
View File
@@ -0,0 +1,260 @@
package main
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"testing"
)
// The environment mailu gives automx on novox when the fixtures were captured (files/mailu.env): the mail domain,
// the route name for both servers, the ports.
var capturedEnvironment = map[string]string{
"AMX_MAIL_DOMAINS": "novox.be",
"AMX_IMAP_ADDRESS": "mail.novox.be",
"AMX_IMAP_PORT": "143",
"AMX_SMTP_ADDRESS": "mail.novox.be",
"AMX_SMTP_PORT": "587",
"AUTOMX_PORT": "4243",
}
func getenvOf(m map[string]string) func(string) string {
return func(k string) string { return m[k] }
}
func testServer(t *testing.T) *httptest.Server {
t.Helper()
cfg, err := configFrom(getenvOf(capturedEnvironment))
if err != nil {
t.Fatal(err)
}
s := httptest.NewServer(newHandler(cfg))
t.Cleanup(s.Close)
return s
}
const captured = "testdata/captured"
// A captured request: its first line is the method and the URL the client used, then the headers sent, then after
// a blank line the body sent (CAPTURED, how the files were written).
type request struct {
method, url string
header [][2]string
body string
}
func readRequest(t *testing.T, stem string) request {
t.Helper()
raw, err := os.ReadFile(stem + ".request")
if err != nil {
t.Fatal(err)
}
head, body, hasBody := strings.Cut(string(raw), "\n\n")
if !hasBody {
head = strings.TrimSuffix(head, "\n")
}
lines := strings.Split(head, "\n")
method, target, ok := strings.Cut(lines[0], " ")
if !ok {
t.Fatalf("%s: first line %q is not METHOD URL", stem, lines[0])
}
r := request{method: method, url: target, body: body}
for _, l := range lines[1:] {
k, v, ok := strings.Cut(l, ": ")
if !ok {
t.Fatalf("%s: header line %q", stem, l)
}
r.header = append(r.header, [2]string{k, v})
}
return r
}
func readFile(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(name)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// A fixture asks for its body to differ only where DIFFERENCES says so: a stem with a .corrected.body is answered
// with that, and its Content-Length is the corrected body's.
func expectedBody(t *testing.T, stem string) (body string, corrected bool) {
if _, err := os.Stat(stem + ".corrected.body"); err == nil {
return readFile(t, stem+".corrected.body"), true
}
return readFile(t, stem+".body"), false
}
func TestEveryCapturedRequestIsAnsweredAsAutomxAnswered(t *testing.T) {
stems, _ := filepath.Glob(filepath.Join(captured, "*.request"))
if len(stems) < 17 {
t.Fatalf("%d captured requests found, want the 17 of issue 1630", len(stems))
}
srv := testServer(t)
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
for _, f := range stems {
stem := strings.TrimSuffix(f, ".request")
t.Run(filepath.Base(stem), func(t *testing.T) {
rq := readRequest(t, stem)
u, err := url.Parse(rq.url)
if err != nil {
t.Fatal(err)
}
// The mesh's route reaches the program with the client's name and scheme; the program is told them in
// the forwarding headers (the Location of fixture 14 holds them).
req, err := http.NewRequest(rq.method, srv.URL+u.RequestURI(), strings.NewReader(rq.body))
if err != nil {
t.Fatal(err)
}
for _, h := range rq.header {
req.Header.Set(h[0], h[1])
}
req.Host = u.Host
req.Header.Set("X-Forwarded-Proto", u.Scheme)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
got, _ := io.ReadAll(resp.Body)
if want := strings.TrimRight(readFile(t, stem+".status"), "\n"); resp.Status != want {
t.Errorf("status %q, captured %q", resp.Status, want)
}
wantBody, corrected := expectedBody(t, stem)
if !bytes.Equal(got, []byte(wantBody)) {
t.Errorf("body\n%q\nwant\n%q", got, wantBody)
}
want := map[string]string{}
for _, l := range strings.Split(strings.TrimRight(readFile(t, stem+".headers"), "\n"), "\n") {
if k, v, ok := strings.Cut(l, ": "); ok {
want[k] = v
}
}
if corrected {
want["Content-Length"] = strconv.Itoa(len(wantBody))
}
// Server names the Python program that answered (DIFFERENCES); Date changes on every request.
delete(want, "Server")
var extra []string
for k := range resp.Header {
if k == "Date" || k == "Server" {
continue
}
if _, ok := want[k]; !ok {
extra = append(extra, k)
}
}
sort.Strings(extra)
if len(extra) > 0 {
t.Errorf("headers the capture does not have: %v", extra)
}
for k, v := range want {
if g := resp.Header.Get(k); g != v {
t.Errorf("header %s: %q, captured %q", k, g, v)
}
}
})
}
}
// DIFFERENCES: the one change clause 9 of ADR 0480 allows to an answer is the IMAP server's name, written from
// AMX_IMAP_ADDRESS where automx wrote an empty one.
func TestTheOnlyCorrectedAnswerIsTheImapServersName(t *testing.T) {
corrected, _ := filepath.Glob(filepath.Join(captured, "*.corrected.body"))
if len(corrected) != 3 {
t.Fatalf("%d corrected answers, want the three Thunderbird answers (01, 02, 03): %v", len(corrected), corrected)
}
for _, c := range corrected {
orig := readFile(t, strings.TrimSuffix(c, ".corrected.body")+".body")
want := strings.Replace(orig, "<hostname />", "<hostname>mail.novox.be</hostname>", 1)
if strings.Contains(want, "<hostname />") || want == orig {
t.Fatalf("%s: the capture has no single empty hostname to correct", c)
}
if got := readFile(t, c); got != want {
t.Errorf("%s differs from the capture by more than the IMAP server's name", c)
}
}
}
func TestTheImapServerIsNamedFromTheImapAddress(t *testing.T) {
env := map[string]string{}
for k, v := range capturedEnvironment {
env[k] = v
}
env["AMX_IMAP_ADDRESS"] = "imap.example.test"
env["AMX_IMAP_PORT"] = "993"
cfg, err := configFrom(getenvOf(env))
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
newHandler(cfg).ServeHTTP(rec, httptest.NewRequest("GET", "/mail/config-v1.1.xml?emailaddress=a@novox.be", nil))
body := rec.Body.String()
if !strings.Contains(body, `<incomingServer type="imap"><hostname>imap.example.test</hostname><port>993</port>`) {
t.Errorf("incoming server not named from AMX_IMAP_ADDRESS and AMX_IMAP_PORT: %s", body)
}
if !strings.Contains(body, `<outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port>`) {
t.Errorf("outgoing server changed: %s", body)
}
}
func TestEveryDomainOfTheEnvironmentIsServed(t *testing.T) {
env := map[string]string{}
for k, v := range capturedEnvironment {
env[k] = v
}
env["AMX_MAIL_DOMAINS"] = "novox.be, example.test"
cfg, err := configFrom(getenvOf(env))
if err != nil {
t.Fatal(err)
}
h := newHandler(cfg)
for _, d := range []string{"novox.be", "example.test"} {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest("GET", "/mail/config-v1.1.xml?emailaddress=a@"+d, nil))
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "<domain>"+d+"</domain>") {
t.Errorf("%s: %d %s", d, rec.Code, rec.Body.String())
}
}
}
// Failure is loud (issue 1631 AC2): a missing variable stops the program and its name is in the message.
func TestAMissingVariableStopsTheProgramNamingIt(t *testing.T) {
for name := range capturedEnvironment {
t.Run(name, func(t *testing.T) {
env := map[string]string{}
for k, v := range capturedEnvironment {
env[k] = v
}
delete(env, name)
if _, err := configFrom(getenvOf(env)); err == nil || !strings.Contains(err.Error(), name) {
t.Fatalf("configFrom without %s: %v", name, err)
}
var out bytes.Buffer
if code := run(getenvOf(env), &out, func(*http.Server) error { t.Fatal("served without its configuration"); return nil }); code == 0 || !strings.Contains(out.String(), name) {
t.Errorf("run without %s: exit %d, said %q", name, code, out.String())
}
})
}
}
func TestAPortThatIsNotANumberIsRefusedNamingIt(t *testing.T) {
env := map[string]string{}
for k, v := range capturedEnvironment {
env[k] = v
}
env["AMX_SMTP_PORT"] = "submission"
if _, err := configFrom(getenvOf(env)); err == nil || !strings.Contains(err.Error(), "AMX_SMTP_PORT") {
t.Fatalf("got %v", err)
}
}
+290
View File
@@ -0,0 +1,290 @@
// automx answers mail clients' autoconfiguration requests for the domains mailu serves: Thunderbird's
// /mail/config-v1.1.xml, Outlook's /autodiscover/autodiscover.xml and Apple's /mobileconfig. It replaces the
// Python automx2 image (novox/hq ADR 0480 clauses 1, 9, 10): standard library only, no database, every answer
// made from mailu's environment at the start, so a change of the domains, servers or ports reaches clients when
// the program is started again.
//
// The answers are those automx2 2021.6 gave, captured in testdata/captured (CAPTURED, issue 1630), byte for byte,
// but for the one thing testdata/captured/DIFFERENCES lists: the IMAP server is named, from AMX_IMAP_ADDRESS.
package main
import (
"encoding/xml"
"errors"
"fmt"
"html"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
// The environment, all required: a program that cannot say which servers clients use must not run (failure is
// loud). The names are those of mailu's files/mailu.env.
const (
envDomains = "AMX_MAIL_DOMAINS"
envImapHost = "AMX_IMAP_ADDRESS"
envImapPort = "AMX_IMAP_PORT"
envSmtpHost = "AMX_SMTP_ADDRESS"
envSmtpPort = "AMX_SMTP_PORT"
envListen = "AUTOMX_PORT"
)
type config struct {
domains []string
imapHost, smtpHost string
imapPort, smtpPort int
listen int
}
// configFrom reads the environment; it names every variable that is missing or wrong.
func configFrom(getenv func(string) string) (config, error) {
var c config
var problems []string
text := func(name string) string {
v := strings.TrimSpace(getenv(name))
if v == "" {
problems = append(problems, name+" is not set")
}
return v
}
port := func(name string) int {
v := text(name)
if v == "" {
return 0
}
n, err := strconv.Atoi(v)
if err != nil || n < 1 || n > 65535 {
problems = append(problems, fmt.Sprintf("%s is %q, not a port number", name, v))
return 0
}
return n
}
// automx2's add-domains split the list at commas and spaces.
for _, d := range strings.FieldsFunc(text(envDomains), func(r rune) bool { return r == ',' || r == ' ' }) {
c.domains = append(c.domains, strings.ToLower(d))
}
c.imapHost, c.imapPort = text(envImapHost), port(envImapPort)
c.smtpHost, c.smtpPort = text(envSmtpHost), port(envSmtpPort)
c.listen = port(envListen)
if len(problems) > 0 {
return config{}, errors.New(strings.Join(problems, "; "))
}
return c, nil
}
// What automx2 sent (Werkzeug), copied from the capture.
const (
htmlType = "text/html; charset=utf-8"
xmlType = "application/xml"
notFound = `<!doctype html>
<html lang=en>
<title>404 Not Found</title>
<h1>Not Found</h1>
<p>The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.</p>
`
notAllowed = `<!doctype html>
<html lang=en>
<title>405 Method Not Allowed</title>
<h1>Method Not Allowed</h1>
<p>The method is not allowed for the requested URL.</p>
`
internalError = `<!doctype html>
<html lang=en>
<title>500 Internal Server Error</title>
<h1>Internal Server Error</h1>
<p>The server encountered an internal error and was unable to complete your request. Either the server is overloaded or there is an error in the application.</p>
`
badRequest = `<!doctype html>
<html lang=en>
<title>400 Bad Request</title>
<h1>Bad Request</h1>
<p>The browser (or proxy) sent a request that this server could not understand.</p>
`
index = `<html><body>Show Thunderbird-style <a href="/mail/config-v1.1.xml?emailaddress=abc@example.com">XML configuration</a> for abc@example.com.</body></html>`
)
func reply(w http.ResponseWriter, status int, contentType, body string) {
w.Header().Set("Content-Type", contentType)
if status != http.StatusNoContent {
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
}
w.WriteHeader(status)
if status != http.StatusNoContent {
io.WriteString(w, body)
}
}
func missing(w http.ResponseWriter, argument string) {
reply(w, http.StatusBadRequest, htmlType, fmt.Sprintf("Missing request argument %q", argument))
}
type handler struct{ config }
func newHandler(c config) http.Handler { return handler{c} }
func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var allow string
switch r.URL.Path {
case "/", "/mail/config-v1.1.xml", "/mobileconfig/":
allow = "GET, HEAD, OPTIONS"
case "/autodiscover/autodiscover.xml":
allow = "POST, OPTIONS"
case "/mobileconfig":
h.redirectToSlash(w, r)
return
default:
reply(w, http.StatusNotFound, htmlType, notFound)
return
}
if r.Method == http.MethodOptions {
w.Header().Set("Allow", allow)
reply(w, http.StatusOK, htmlType, "")
return
}
if !strings.Contains(", "+allow+",", ", "+r.Method+",") {
w.Header().Set("Allow", allow)
reply(w, http.StatusMethodNotAllowed, htmlType, notAllowed)
return
}
switch r.URL.Path {
case "/":
reply(w, http.StatusOK, htmlType, index)
case "/mail/config-v1.1.xml":
h.byAddress(w, r.URL.Query().Get("emailaddress"), r.URL.Query().Has("emailaddress"), "emailaddress", h.thunderbird)
case "/mobileconfig/":
h.byAddress(w, r.URL.Query().Get("emailaddress"), r.URL.Query().Has("emailaddress"), "emailaddress", h.apple)
case "/autodiscover/autodiscover.xml":
h.outlook(w, r)
}
}
// byAddress is the part the three protocols share: the address is required, and a domain that is not served is
// answered with no content.
func (h handler) byAddress(w http.ResponseWriter, address string, present bool, argument string, answer func(http.ResponseWriter, int, string)) {
if !present {
missing(w, argument)
return
}
domain := strings.ToLower(address[strings.LastIndex(address, "@")+1:])
for i, d := range h.domains {
if strings.Contains(address, "@") && d == domain {
answer(w, i, d)
return
}
}
reply(w, http.StatusNoContent, htmlType, "")
}
// thunderbird is Mozilla's autoconfiguration, config-v1.1. The provider id is automx2's: the provider rows were
// numbered 1, 11, 21 ... by add-domains.
func (h handler) thunderbird(w http.ResponseWriter, i int, domain string) {
d := html.EscapeString(domain)
server := func(kind, host string, port int) string {
tag := map[string]string{"imap": "incomingServer", "smtp": "outgoingServer"}[kind]
return fmt.Sprintf(`<%s type="%s"><hostname>%s</hostname><port>%d</port><socketType>STARTTLS</socketType>`+
`<username>%%EMAILADDRESS%%</username><authentication>password-cleartext</authentication></%s>`,
tag, kind, html.EscapeString(host), port, tag)
}
body := fmt.Sprintf(`<clientConfig version="1.1"><emailProvider id="automx2-%d"><identity /><domain>%s</domain>`+
`<displayName>%s</displayName><displayShortName>%s</displayShortName>%s%s</emailProvider></clientConfig>`,
1+10*i, d, d, d, server("imap", h.imapHost, h.imapPort), server("smtp", h.smtpHost, h.smtpPort))
reply(w, http.StatusOK, xmlType, body)
}
// apple and outlook answer a served domain as automx2 did: with an error (DIFFERENCES, issue 1691). They are
// kept as captured here, so the move to a Go program changes the answer only where clause 9 says.
func (h handler) apple(w http.ResponseWriter, _ int, _ string) {
reply(w, http.StatusBadRequest, htmlType, badRequest)
}
// redirectToSlash is Werkzeug's strict-slash redirect, to the name the client used.
func (h handler) redirectToSlash(w http.ResponseWriter, r *http.Request) {
scheme := "http"
if p := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Proto"), ",")[0]); p != "" {
scheme = p
}
host := r.Host
if f := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); f != "" {
host = f
}
target := scheme + "://" + host + "/mobileconfig/"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Location", target)
e := html.EscapeString(target)
reply(w, http.StatusPermanentRedirect, htmlType, fmt.Sprintf(`<!doctype html>
<html lang=en>
<title>Redirecting...</title>
<h1>Redirecting...</h1>
<p>You should be redirected automatically to the target URL: <a href="%s">%s</a>. If not, click the link.
`, e, e))
}
// outlook reads the request's EMailAddress anywhere in the document, whatever the namespace. A body that is not
// one XML document is automx2's 500.
func (h handler) outlook(w http.ResponseWriter, r *http.Request) {
dec := xml.NewDecoder(io.LimitReader(r.Body, 1<<20))
var address string
var found, elements, inAddress = false, 0, false
for {
tok, err := dec.Token()
if err == io.EOF {
break
}
if err != nil {
reply(w, http.StatusInternalServerError, htmlType, internalError)
return
}
switch t := tok.(type) {
case xml.StartElement:
elements++
if t.Name.Local == "EMailAddress" && !found {
inAddress = true
}
case xml.CharData:
if inAddress {
address += string(t)
}
case xml.EndElement:
if inAddress && t.Name.Local == "EMailAddress" {
inAddress, found = false, true
}
}
}
if elements == 0 {
reply(w, http.StatusInternalServerError, htmlType, internalError)
return
}
h.byAddress(w, strings.TrimSpace(address), found, "EMailAddress", func(w http.ResponseWriter, _ int, _ string) {
reply(w, http.StatusInternalServerError, htmlType, internalError)
})
}
// run is the program: configuration from the environment, then serving. A configuration that cannot be read is
// said, with the variables' names, and the exit code is not zero.
func run(getenv func(string) string, out io.Writer, serve func(*http.Server) error) int {
c, err := configFrom(getenv)
if err != nil {
fmt.Fprintf(out, "automx: %v\n", err)
return 1
}
srv := &http.Server{
Addr: ":" + strconv.Itoa(c.listen),
Handler: newHandler(c),
ReadHeaderTimeout: 10 * time.Second,
}
if err := serve(srv); err != nil {
fmt.Fprintf(out, "automx: %v\n", err)
return 1
}
return 0
}
func main() {
os.Exit(run(os.Getenv, os.Stderr, func(s *http.Server) error { return s.ListenAndServe() }))
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The digest route-proxy's `trust` artifact runs: one alpine image is held for both (ADR 0480 clause 3). It was
// read from mesh-controller's modules/route-proxy/module.json and is the one hello-web runs.
const alpineDigest = "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"
type manifest struct {
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Listens []struct {
Name string `json:"name"`
Port int `json:"port"`
} `json:"listens"`
Data struct {
Own []struct {
ID string `json:"id"`
Path string `json:"path"`
} `json:"own"`
} `json:"data"`
Build map[string]any `json:"build"`
}
func readManifest(t *testing.T) manifest {
t.Helper()
var m manifest
if err := json.Unmarshal([]byte(readFile(t, "../../module.json")), &m); err != nil {
t.Fatal(err)
}
return m
}
func (m manifest) resource(id string) map[string]any {
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
return nil
}
func (m manifest) artifact(name string) map[string]any {
arts, _ := m.Build["artifacts"].([]any)
for _, a := range arts {
if a, _ := a.(map[string]any); a["name"] == name {
return a
}
}
return nil
}
func strs(v any) []string {
var out []string
l, _ := v.([]any)
for _, e := range l {
out = append(out, e.(string))
}
return out
}
// Clause 2: a Go bundle from a package of the module's own go.mod, stamped for alpine; an archive resource unpacks
// it; the container mounts that read-only at /opt/mesh, calls the program there and names the archive in restart-on.
func TestAutomxIsAGoBundleForAlpineMountedAtOptMeshAndRestartedWithIt(t *testing.T) {
m := readManifest(t)
b := m.artifact("automx")
if b == nil || b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "alpine" || b["from"] != "cmd/automx" || b["binary"] != "automx" {
t.Fatalf("artifact automx is %v, want a Go bundle, system alpine, from cmd/automx, binary automx", b)
}
if _, loads := b["loads"]; loads {
t.Errorf("a program run in a container is not loaded by the tool runner: %v", b["loads"])
}
goMod := readFile(t, "../../go.mod")
if !strings.HasPrefix(goMod, "module mailu\n") {
t.Errorf("the module's go.mod is %q; the bundle is a package of it", goMod)
}
if _, err := os.Stat(filepath.Join("..", "..", filepath.FromSlash(b["from"].(string)), "main.go")); err != nil {
t.Errorf("the bundle's package is not in the module's directory: %v", err)
}
unpack := m.resource("program-automx")
if unpack == nil || unpack["type"] != "archive" || unpack["artifact"] != "automx" || unpack["unknown"] != "program" {
t.Fatalf("resource program-automx is %v, want an archive of the bundle", unpack)
}
where, _ := unpack["path"].(string)
c := m.resource("automx")
if c == nil || c["type"] != "container" {
t.Fatalf("no container automx: %v", c)
}
if got := strs(c["volumes"]); len(got) != 1 || got[0] != where+":/opt/mesh:ro" {
t.Errorf("volumes %v, want the archive's place %q mounted read-only at /opt/mesh", got, where)
}
if got := strs(c["args"]); len(got) != 1 || got[0] != "/opt/mesh/automx" {
t.Errorf("args %v, want the program at /opt/mesh/automx", got)
}
if !contains(strs(c["restart-on"]), "program-automx") {
t.Errorf("restart-on %v does not name the archive: a new program would leave the old one running", c["restart-on"])
}
if !contains(strs(c["restart-on"]), "config-env") {
t.Errorf("restart-on %v does not name the environment file the program reads at its start", c["restart-on"])
}
}
// Clause 3: an upstream artifact pinned by digest, the one route-proxy's trust artifact runs. automx's own design
// builds no image from a Dockerfile and has no Dockerfile or base to build one on; the catalogue as such does not
// refuse the image kind (novox/hq ADR 0487 clause 13).
func TestAutomxRunsTheMirroredAlpineImageAndBuildsNoImage(t *testing.T) {
m := readManifest(t)
c := m.resource("automx")
base := m.artifact(c["artifact"].(string))
if base == nil || base["kind"] != "upstream" || base["from"] != alpineDigest {
t.Errorf("the container runs artifact %v, want an upstream artifact %s", base, alpineDigest)
}
if _, has := c["image"]; has {
t.Errorf("the container names an image %v, fetched from a registry on the machine", c["image"])
}
for _, a := range m.Build["artifacts"].([]any) {
if a.(map[string]any)["kind"] == "image" {
t.Errorf("artifact %v is an image built from a Dockerfile, which automx's design (ADR 0480) does not use", a)
}
}
if _, has := m.Build["on"]; has {
t.Errorf("build.on %v: nothing is built on a base image any more", m.Build["on"])
}
for _, gone := range []string{"../../automx/Dockerfile", "../../automx"} {
if _, err := os.Stat(gone); err == nil {
t.Errorf("%s is still there", gone)
}
}
}
// Clause 1: the container stays as it was for what watches it.
func TestAutomxKeepsItsContainerNameNetworkPortAndEnvironment(t *testing.T) {
m := readManifest(t)
c := m.resource("automx")
if c["name"] != "mailu-automx" || c["network"] != "mailu" {
t.Errorf("container %v on network %v, want mailu-automx on mailu", c["name"], c["network"])
}
if got := strs(c["ports"]); len(got) != 1 || got[0] != "4243" {
t.Errorf("ports %v, want 4243", got)
}
if got := strs(c["env-file"]); len(got) != 1 || got[0] != "${dir:state}/mailu.env" {
t.Errorf("env-file %v, want mailu's environment only", got)
}
if !strings.Contains("\n"+readFile(t, "../../files/mailu.env"), "\nAUTOMX_PORT=4243\n") {
t.Errorf("mailu.env does not say AUTOMX_PORT=4243")
}
for _, l := range m.Listens {
if l.Name == "autoconfig" && l.Port == 4243 {
return
}
}
t.Errorf("the autoconfig endpoint is not on 4243: %v", m.Listens)
}
// Clause 10: a database nobody reads is gone from the declaration (the file stays on disk), and the container says
// whether it answers with one captured request and its captured answer.
func TestAutomxKeepsNoDatabaseAndSaysWhetherItAnswers(t *testing.T) {
m := readManifest(t)
if m.resource("data-automx") != nil {
t.Errorf("the data-automx directory is still declared")
}
for _, it := range m.Data.Own {
if strings.Contains(it.ID, "automx") || strings.Contains(it.Path, "data-automx") {
t.Errorf("data item %v is still declared", it)
}
}
for _, tool := range m.Tools {
if tool == "automx_rows" {
t.Errorf("the tool automx_rows reads a database that no longer exists (issue 1700)")
}
}
h, _ := m.resource("automx")["health"].(map[string]any)
if h["kind"] != "http" || h["endpoint"] != "autoconfig" {
t.Fatalf("health %v, want an http check of the autoconfig endpoint", h)
}
// The request is captured fixture 06 and the answer its status and body.
rq := readRequest(t, filepath.Join(captured, "06-autoconfig-root"))
if u := strings.SplitN(rq.url, "/", 4); "/"+u[len(u)-1] != h["path"] || rq.method != "GET" {
t.Errorf("health path %v is not captured request %s %s", h["path"], rq.method, rq.url)
}
if st := readFile(t, filepath.Join(captured, "06-autoconfig-root.status")); !strings.HasPrefix(st, "200 ") || h["status"] != float64(200) {
t.Errorf("health status %v, captured %q", h["status"], st)
}
if body, _ := h["body"].(string); body == "" || !strings.Contains(readFile(t, filepath.Join(captured, "06-autoconfig-root.body")), body) {
t.Errorf("health body %q is not in the captured answer", h["body"])
}
}
func contains(l []string, s string) bool {
for _, e := range l {
if e == s {
return true
}
}
return false
}
@@ -0,0 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname>mail.novox.be</hostname><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -0,0 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname>mail.novox.be</hostname><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
@@ -0,0 +1 @@
<clientConfig version="1.1"><emailProvider id="automx2-1"><identity /><domain>novox.be</domain><displayName>novox.be</displayName><displayShortName>novox.be</displayShortName><incomingServer type="imap"><hostname>mail.novox.be</hostname><port>143</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></incomingServer><outgoingServer type="smtp"><hostname>mail.novox.be</hostname><port>587</port><socketType>STARTTLS</socketType><username>%EMAILADDRESS%</username><authentication>password-cleartext</authentication></outgoingServer></emailProvider></clientConfig>
+12 -1
View File
@@ -14,6 +14,13 @@ Each capture is four files with the same stem:
The files were written from the tool's answers by a script (not by retyping each by hand); the tool returns JSON, so
a later capture that wants exact bytes needs a tool that writes the answer to a file.
A fixture is not the bytes on the wire in four ways (issue 1701):
- Date is left out of .headers (it changes on every request).
- .headers are sorted by name.
- .status holds the tool's status line without the protocol (the tool reported "HTTP/1.1" apart).
- Header names are as the tool's Go HTTP client gives them (canonical case, after the mesh's route), not as
automx wrote them.
What the answers show (read, not guessed):
- GET /mail/config-v1.1.xml?emailaddress=... answers 200 application/xml on all three names; same body on each.
- The incoming (IMAP) server has an EMPTY hostname (<hostname />), port 143, STARTTLS. The outgoing server is
@@ -27,4 +34,8 @@ What the answers show (read, not guessed):
- GET /mobileconfig?emailaddress=... redirects (308) to /mobileconfig/?emailaddress=...; for the served domain that
answers 400 with no profile; unknown domain 204; no emailaddress 400.
- GET / on autoconfig answers 200 with a small HTML page linking to an example address.
- Not captured: the database rows (no mesh tool reads /data/db.sqlite; issue 1630 AC4).
- The database rows behind the answers were read once with the tool automx_rows (mailu, issue 1630 AC4), which
ran sqlite3 read-only inside mailu-automx. The tool is retired with the database (ADR 0480 clause 10, issue
1700), so nothing reads the rows any more; the answers above are what they gave.
- The Go program that replaced automx2 answers these requests as they are captured here, but for what
DIFFERENCES lists.
+25
View File
@@ -0,0 +1,25 @@
How the Go program (modules/mailu/cmd/automx) answers differently from the automx2 2021.6 answers captured here
(issue 1631, ADR 0480 clause 9). automx_test.go answers every .request and compares with the capture; this is the
whole list of where the two differ.
1. The IMAP server's name (the one correction clause 9 allows). automx2 answered <hostname /> for the incoming
server because its setup script read AMX_IMAP_SERVER, which mailu's environment does not set (it sets
AMX_IMAP_ADDRESS). The program names it from AMX_IMAP_ADDRESS (and the port from AMX_IMAP_PORT).
Fixtures 01, 02 and 03 each have a <stem>.corrected.body: the capture with <hostname /> replaced by
<hostname>mail.novox.be</hostname> and nothing else (a test checks that). Their Content-Length is that file's
size, 634, not the captured 612.
2. The Server header. The capture has "Server: Werkzeug/3.1.9 Python/3.9.5". The program sends no Server header, and
Date, which the captured .headers leave out, is sent as net/http sends it.
Not changed, though issue 1691 calls them faults; they are kept as captured because clause 9 allows only the first
correction, and their cause is not known from the capture:
- Outlook's POST for a served domain (fixture 08) answers 500.
- Apple's /mobileconfig/ for a served domain (fixture 15) answers 400 with no profile.
Whether automx2 failed there because of the empty IMAP name is a guess; it can be tested only against the
running program after the delivery. Issue 1691 stays open for them.
Assumptions the fixtures do not settle (answers to requests nobody captured): a domain is compared in lower case;
an address without "@" is an unknown domain (204); HEAD is answered as GET and OPTIONS with the Allow header and
no body; the redirect of /mobileconfig takes its scheme and host from X-Forwarded-Proto and X-Forwarded-Host when
present (the route sends the client's, as fixture 14's Location shows) and from the request otherwise.
+3
View File
@@ -0,0 +1,3 @@
module mailu
go 1.22
+34 -24
View File
@@ -71,8 +71,7 @@
"mailu_list_domains",
"mailu_read_mail",
"mailu_search_mail",
"mailu_health",
"automx_rows"
"mailu_health"
],
"emits": [
"user.created",
@@ -216,12 +215,6 @@
"class": "rebuildable",
"why": "certificates, issued again"
},
{
"id": "automx",
"path": "${dir:data-automx}",
"class": "rebuildable",
"why": "client autoconfiguration, written again"
},
{
"id": "fetchmail",
"path": "${dir:data-fetchmail}",
@@ -308,12 +301,6 @@
"mode": "0700",
"unknown": "program"
},
{
"id": "data-automx",
"type": "directory",
"mode": "0700",
"unknown": "program"
},
{
"id": "config-env",
"type": "file",
@@ -681,11 +668,25 @@
"takes-config": ["domain", "proxy-address", "sitename", "website", "url", "container"],
"merge": "json"
},
{
"id": "program-automx",
"type": "archive",
"path": "${dir:state}/automx",
"artifact": "automx",
"unknown": "program"
},
{
"id": "automx",
"type": "container",
"name": "mailu-automx",
"artifact": "automx",
"artifact": "alpine",
"health": {
"kind": "http",
"endpoint": "autoconfig",
"path": "/",
"status": 200,
"body": "Show Thunderbird-style"
},
"network": "mailu",
"env-file": [
"${dir:state}/mailu.env"
@@ -694,17 +695,18 @@
"4243"
],
"volumes": [
"${dir:data-automx}:/data"
"${dir:state}/automx:/opt/mesh:ro"
],
"args": [
"/opt/mesh/automx"
],
"restart-on": [
"program-automx",
"config-env"
]
}
],
"build": {
"on": [
{
"arg": "PYTHON_BASE",
"image": "python@sha256:25f3cfeaceca14921366af4d1240b56457ef46273bdb508c7b0e8f469f6fd228"
}
],
"artifacts": [
{
"name": "code",
@@ -731,8 +733,16 @@
},
{
"name": "automx",
"kind": "image",
"from": "automx/Dockerfile"
"kind": "bundle",
"language": "go",
"system": "alpine",
"from": "cmd/automx",
"binary": "automx"
},
{
"name": "alpine",
"kind": "upstream",
"from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b"
},
{
"name": "files",
-103
View File
@@ -1,103 +0,0 @@
// What automx_rows reads (novox/hq issue 1630): the rows of automx2's database as stored, through a read-only
// sqlite3 inside its container. The database here is a real SQLite file made by the test with the tables
// automx/files/setup-db creates and the rows add-domains writes — with the IMAP server's name empty, the case
// the live instance is in. The fake container runs each command's statement against that database and prints
// it the way the image's sqlite3 does in list mode (one line per row, cells apart by "|", nothing for no rows); that sqlite3
// refuses `-json` ("sqlite3: Error: unknown option: -json", captured from novox/mailu.automx_rows, issue 1630). Run against the compiled module.
import { test } from "node:test";
import assert from "node:assert/strict";
import { DatabaseSync } from "node:sqlite";
import { readAutomx, readCommand, selectOf, parseRows, AUTOMX_DATABASE } from "../dist/automx.js";
function database(imapName: string): DatabaseSync {
const db = new DatabaseSync(":memory:");
db.exec(`
CREATE TABLE provider(id INT PRIMARY KEY NOT NULL, name TEXT NOT NULL, short_name TEXT NOT NULL);
CREATE TABLE server(id INT PRIMARY KEY NOT NULL, prio INT NOT NULL DEFAULT 10, name TEXT NOT NULL, port INT NOT NULL,
type TEXT NOT NULL, socket_type TEXT NOT NULL, user_name TEXT NOT NULL, authentication TEXT NOT NULL);
CREATE TABLE domain(id INT PRIMARY KEY NOT NULL, name TEXT NOT NULL, provider_id INT NOT NULL, ldapserver_id INT NULL);
CREATE TABLE server_domain(server_id INT NOT NULL, domain_id INT NOT NULL);
INSERT INTO provider VALUES (1, 'example.org', 'example.org');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES (2, 143, 'imap', '${imapName}', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO server(id, port, type, name, socket_type, user_name, authentication)
VALUES (3, 587, 'smtp', 'mail.example.org', 'STARTTLS', '%EMAILADDRESS%', 'password-cleartext');
INSERT INTO domain VALUES (5, 'example.org', 1, NULL);
INSERT INTO server_domain VALUES (2, 5), (3, 5);
`);
return db;
}
function container(db: DatabaseSync, asked: string[][] = []) {
return async (_name: string, command: string[]) => {
asked.push(command);
if (command.includes("-json")) throw Object.assign(new Error("Command failed: docker exec"), { stderr: "sqlite3: Error: unknown option: -json\n" });
const rows = db.prepare(command[command.length - 1]).all() as unknown as Record<string, string>[];
return rows.length === 0 ? "" : rows.map((r) => Object.values(r).join("|")).join("\n") + "\n";
};
}
test("the rows come back as stored, and an empty server name is said", async () => {
const answer = await readAutomx(container(database("")), "mailu-automx");
assert.equal(answer.database, AUTOMX_DATABASE);
assert.deepEqual(answer.unreadable, {});
assert.deepEqual(answer.tables.provider, [{ id: 1, name: "example.org", short_name: "example.org" }]);
assert.deepEqual(answer.tables.domain, [{ id: 5, name: "example.org", provider_id: 1, ldapserver_id: null }]);
assert.deepEqual(answer.tables.server_domain, [{ server_id: 2, domain_id: 5 }, { server_id: 3, domain_id: 5 }]);
assert.deepEqual(
answer.tables.server.map((s) => [s.id, s.type, s.name, s.port]),
[[2, "imap", "", 143], [3, "smtp", "mail.example.org", 587]],
);
assert.deepEqual(answer.empty_names, [{ id: 2, type: "imap" }]);
});
test("a named IMAP server is not reported empty", async () => {
const answer = await readAutomx(container(database("mail.example.org")), "mailu-automx");
assert.deepEqual(answer.empty_names, []);
});
test("every command opens the file read-only and runs one fixed SELECT", async () => {
const asked: string[][] = [];
await readAutomx(container(database("x"), asked), "mailu-automx");
assert.equal(asked.length, 4);
for (const c of asked) {
assert.deepEqual(c.slice(0, 3), ["sqlite3", "-readonly", AUTOMX_DATABASE]);
assert.match(c[3], /^SELECT /);
assert.equal(c.length, 4);
assert.ok(!c.includes("-json"));
}
assert.deepEqual(readCommand("provider"), ["sqlite3", "-readonly", AUTOMX_DATABASE, selectOf("provider")]);
});
test("a table that cannot be read is said and does not hide the others", async () => {
const db = database("x");
const exec = async (name: string, command: string[]) => {
if (command[3].includes("FROM domain ")) throw Object.assign(new Error("Command failed: docker exec"), { stderr: "Error: unable to open database file\nmore" });
return container(db)(name, command);
};
const answer = await readAutomx(exec, "mailu-automx");
assert.deepEqual(answer.unreadable, { domain: "Error: unable to open database file" });
assert.equal(answer.tables.provider.length, 1);
assert.equal(answer.tables.domain, undefined);
});
test("a NULL, an empty text, a number and a text holding a bar or a newline are read apart", () => {
const hex = (t: string) => Buffer.from(t).toString("hex").toUpperCase();
const line = [`integer:${hex("5")}`, `text:${hex("a|b\nc")}`, "text:", "null:"].join("|");
const rows = parseRows("domain", line + "\n");
assert.deepEqual(rows, [{ id: 5, name: "a|b\nc", provider_id: "", ldapserver_id: null }]);
});
test("output that is not cells is an unreadable table, not a guess", async () => {
const exec = async () => "Error: something else\n";
const answer = await readAutomx(exec, "mailu-automx");
assert.equal(Object.keys(answer.unreadable).length, 4);
});
test("the image's refusal of -json is said as the table's reason", async () => {
const exec = async (_n: string, command: string[]) => container(database("x"))("", ["sqlite3", "-json", ...command.slice(1)]);
const answer = await readAutomx(exec, "mailu-automx");
assert.deepEqual(answer.unreadable.domain, "sqlite3: Error: unknown option: -json");
});
+1 -2
View File
@@ -135,7 +135,6 @@ test("the health tool is served without the admin API, on the smtp container it
await import("../dist/tools/index.js");
const mailu = collectTools({ MESH_MAILU_SMTP_CONTAINER: "mailu-smtp" }).find((m) => m.module === "mailu");
const names = (mailu?.tools ?? []).map((t) => t.name);
assert.deepEqual(names, ["mailu_health", "automx_rows"]);
assert.deepEqual(names, ["mailu_health"]);
assert.match(mailu!.tools[0].description, /inside mailu-smtp/);
assert.match(mailu!.tools[1].description, /read-only, inside mailu-automx/);
});
+1 -19
View File
@@ -4,7 +4,6 @@
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { MailuClient } from "../client.js";
import { readAutomx } from "../automx.js";
import { dockerExec, mailFlows, DEFERRED_BOUND_SECONDS, PUBLIC_NAME, SPREAD_DOMAINS } from "../health.js";
/**
@@ -27,23 +26,6 @@ export function getHealthTool(env: NodeJS.ProcessEnv): ToolDefinition {
};
}
/**
* What automx holds, as stored (novox/hq issue 1630): the rows behind the answers it gives mail clients. It needs
* no admin API, only the automx container, so like the health tool it is served without the API.
*/
export function getAutomxTool(env: NodeJS.ProcessEnv): ToolDefinition {
const container = env.MESH_MAILU_AUTOMX_CONTAINER || "mailu-automx";
return {
name: "automx_rows",
description:
`Read the autoconfiguration module's database, as stored, read-only, inside ${container}: its provider, ` +
"server, domain and server_domain rows, and the servers whose name is empty (a mail client is then told " +
"an empty host name). Writes nothing.",
input: {},
run: async () => readAutomx(dockerExec(), container),
};
}
export function getMailuTools(mailu: MailuClient): ToolDefinition[] {
return [
{
@@ -191,5 +173,5 @@ registerModuleTools("mailu", (env) => {
} catch {
tools = [];
}
return [...tools, getHealthTool(env), getAutomxTool(env)];
return [...tools, getHealthTool(env)];
});
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true,
"noEmit": true
},
"include": ["client.ts", "health.ts", "automx.ts", "index.ts", "tools/index.ts"]
"include": ["client.ts", "health.ts", "index.ts", "tools/index.ts"]
}
@@ -17,6 +17,8 @@ import (
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
var commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
// ForgeAlias is the ssh name the forge-account provision sets up in the account's ssh files (ADR 0409 §1); the
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
const ForgeAlias = "mesh-forge"
@@ -37,6 +39,18 @@ type FetchResult struct {
Workspace string `json:"workspace"`
Commit string `json:"commit"`
Modules []string `json:"modules"`
// The merge preview (ADR 0484 clause 3), only when the call gave a base: the trunk commit, the merge commit of the
// 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
// files, and the workspace is left at the trunk commit.
Base string `json:"base,omitempty"`
Merge string `json:"merge,omitempty"`
Conflicts []string `json:"conflicts,omitempty"`
Changed []string `json:"changed,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
// build_go_test hands to the tests as MESH_CHECK_BESIDE (ADR 0484 clause 4).
Beside []Sibling `json:"beside,omitempty"`
}
// fetchTool is the fetch step tool.
@@ -52,6 +66,12 @@ func (m *Module) fetchTool() StepTool {
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
"ref": map[string]any{"type": "string", "description": "refs/pull/<n>/head when only a pull request's head carries the commit"},
"base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"},
"beside": map[string]any{"type": "array", "description": "optional sibling repositories the tests read, each placed as a plain directory (no symlink) under the workspace's beside/<name>, which build_go_test gives the tests as MESH_CHECK_BESIDE",
"items": map[string]any{"type": "object", "properties": map[string]any{
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
}, "required": []string{"repository", "commit"}}},
},
"required": []string{"repository", "commit"},
},
@@ -64,6 +84,14 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
repository, _ := args["repository"].(string)
commit, _ := args["commit"].(string)
ref, _ := args["ref"].(string)
base, _ := args["base"].(string)
if base != "" && !commitOK.MatchString(base) {
return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base)
}
siblings, err := siblingsArg(args["beside"])
if err != nil {
return nil, err
}
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
return nil, fmt.Errorf("%q is not a repository, <owner>/<name>", repository)
}
@@ -74,14 +102,27 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
if err := os.MkdirAll(tree, 0o700); err != nil {
return nil, err
}
remote := "ssh://" + ForgeAlias + "/" + repository + ".git"
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref); err != nil {
remote := m.remote(repository)
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, m.FetchOptions...); err != nil {
return nil, err
}
res := FetchResult{Workspace: id, Commit: commit}
if base != "" {
if err := m.merge(ctx, &res, tree, remote, base, commit); err != nil {
return nil, err
}
}
if res.Beside, err = m.placeSiblings(ctx, root, siblings); err != nil {
return nil, err
}
dirs, err := m.goModDirs(ctx, tree)
if err != nil {
return nil, err
}
res.Modules = dirs
if base != "" && res.Merge == "" {
return res, nil // a conflict: the tree is at the trunk, nothing of the head is built or tested
}
for _, d := range dirs {
if _, err := os.Stat(filepath.Join(tree, d, "vendor", "modules.txt")); err == nil {
continue // a module that vendors resolves nothing
@@ -93,7 +134,7 @@ func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
}
}
}
return FetchResult{Workspace: id, Commit: commit, Modules: dirs}, nil
return res, nil
}
// goModDirs are the directories of the tree that hold a go.mod, relative to it, in order.
@@ -104,7 +145,7 @@ func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) {
}
dirs := []string{}
for _, l := range strings.Split(r.Stdout, "\n") {
if l = strings.TrimSpace(l); l != "" {
if l = strings.TrimSpace(l); l != "" && !inTestdata(l) {
dirs = append(dirs, filepath.ToSlash(filepath.Dir(l)))
}
}
@@ -112,6 +153,17 @@ func (m *Module) goModDirs(ctx context.Context, tree string) ([]string, error) {
return dirs, nil
}
// inTestdata says a path lies under a directory named testdata, whose go.mod files are fixtures Go ignores, not modules
// of the tree (issue 1810).
func inTestdata(path string) bool {
for _, seg := range strings.Split(filepath.ToSlash(path), "/") {
if seg == "testdata" {
return true
}
}
return false
}
// goEnv is what a go command of a step is given beyond the base environment: the installed go alone (GOTOOLCHAIN=local, ADR 0477 §1: no toolchain is ever downloaded),
// modules resolved by their go.mod and go.sum, the module's own caches, the mesh's own modules fetched directly (by
// git, through the account's rewrite of the forge's address to ssh), and nothing that asks a question. For a directory
@@ -30,7 +30,8 @@ type fakeStep struct {
gomods string
gate chan struct{} // when set, the first git command waits for it
goRule func(line string) forgekey.Ran
vendors []string // directories (relative to the tree) that get a vendor/modules.txt at checkout
vendors []string // directories (relative to the tree) that get a vendor/modules.txt at checkout
rule func(line string) (forgekey.Ran, bool) // answers a command before the defaults do
}
func (f *fakeStep) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
@@ -41,6 +42,11 @@ func (f *fakeStep) run(ctx context.Context, env []string, name string, args ...s
if f.gate != nil && strings.Contains(line, " init ") {
<-f.gate
}
if f.rule != nil {
if r, ok := f.rule(line); ok {
return r
}
}
switch {
case name == "git" && strings.Contains(line, " rev-parse HEAD"):
return forgekey.Ran{Stdout: commitSHA + "\n"}
@@ -0,0 +1,27 @@
package main
import (
"context"
"reflect"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 1810/AC1: the go.mod files under a testdata directory are fixtures Go itself ignores, not modules of the tree, so
// the build modules neither test nor digest them (a repository holding this module held one whose test reads
// MESH_CHECK_BESIDE, which failed every check of it).
func TestGoModDirsLeaveOutTheFixturesUnderTestdata(t *testing.T) {
m := &Module{}
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
return forgekey.Ran{Stdout: "checks/deps/go.mod\ngo.mod\nmodules/x/cmd/x/testdata/repos/beside/go.mod\nmodules/x/go.mod\ntestdata/go.mod\n"}
}
got, err := m.goModDirs(context.Background(), t.TempDir())
if err != nil {
t.Fatal(err)
}
want := []string{".", "checks/deps", "modules/x"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("go.mod directories = %v, want %v", got, want)
}
}
@@ -60,7 +60,7 @@ func (m *Module) integration(ctx context.Context, workspace, tree string, packag
return nil, fmt.Errorf("the integration level's bus: %w", err)
}
defer stopBus()
env := []string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.besideEnv(workspace)...)
// Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run
// below finds them built. Whether they compile is for the run to say; its failure is not read here.
for _, d := range dirs {
@@ -37,6 +37,10 @@ type Module struct {
// both outside the account's home (ADR 0466 §1); Home is the account's home.
Run forgekey.Runner
Home, Work, Cache string
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
// the SDK's fetch and merge. A test points them at a local repository (buildstep.AllowFileRemote).
RemoteBase string
FetchOptions []buildstep.Option
// HTTP fetches the files an archive artifact is pinned to (url and sha256); a client of its own with a fifteen-minute
// timeout when nil. A test serves its own.
HTTP *http.Client
@@ -0,0 +1,119 @@
package main
// The merge preview and the siblings of a fetch (novox/hq ADR 0484 clauses 3 and 4; issues 1675 and 1677). The merge
// itself is the SDK's (buildstep.MergeInto), the one every build module's fetch step makes; what is here is the answer
// built from it and the placing of the sibling repositories the tests read.
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
// Sibling is a repository placed beside the fetched tree: its name (the directory under beside/) and its commit.
type Sibling struct {
Repository string `json:"repository"`
Commit string `json:"commit"`
Dir string `json:"dir"`
}
// remote is the address a repository is fetched from: the forge over ssh as the account, or RemoteBase when a test set it.
func (m *Module) remote(repository string) string {
base := m.RemoteBase
if base == "" {
base = "ssh://" + ForgeAlias + "/"
}
return base + repository + ".git"
}
// merge makes the merge preview of the head onto base and writes the answer into res.
func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base, commit string) error {
mr, err := buildstep.MergeInto(ctx, m.Run, tree, remote, base, commit, m.FetchOptions...)
if err != nil {
return err
}
res.Base = mr.Base
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
if res.Merge == "" {
return nil
}
res.Trees, err = buildstep.ModuleTrees(ctx, m.Run, nil, tree, res.Merge)
return err
}
type siblingArg struct{ repository, commit string }
func siblingsArg(v any) ([]siblingArg, error) {
if v == nil {
return nil, nil
}
list, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("beside is a list of {repository, commit}")
}
var out []siblingArg
seen := map[string]bool{}
for _, e := range list {
o, _ := e.(map[string]any)
repository, _ := o["repository"].(string)
commit, _ := o["commit"].(string)
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
return nil, fmt.Errorf("beside: %q is not a repository, <owner>/<name>", repository)
}
if !commitOK.MatchString(commit) {
return nil, fmt.Errorf("beside: %q is not a full commit (40 hex digits)", commit)
}
name := repository[strings.Index(repository, "/")+1:]
if seen[name] {
return nil, fmt.Errorf("beside: two repositories are named %s", name)
}
seen[name] = true
out = append(out, siblingArg{repository, commit})
}
return out, nil
}
// BesideDir is where a workspace's siblings are placed, and what MESH_CHECK_BESIDE names.
func (m *Module) besideDir(workspace string) string {
return filepath.Join(m.Work, workspace, "beside")
}
// placeSiblings fetches each sibling into beside/<name> of the workspace root, as a plain directory. A symlink anywhere
// in a placed tree fails the step naming it: the mesh creates none, and a test must not be led out of its sibling.
func (m *Module) placeSiblings(ctx context.Context, root string, siblings []siblingArg) ([]Sibling, error) {
var placed []Sibling
for _, s := range siblings {
name := s.repository[strings.Index(s.repository, "/")+1:]
dir := filepath.Join(root, "beside", name)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
if err := buildstep.FetchCommit(ctx, m.Run, dir, m.remote(s.repository), s.commit, "", m.FetchOptions...); err != nil {
return nil, fmt.Errorf("beside %s: %w", s.repository, err)
}
if link := firstSymlink(dir); link != "" {
return nil, fmt.Errorf("beside %s holds a symlink, %s", s.repository, link)
}
placed = append(placed, Sibling{Repository: s.repository, Commit: s.commit, Dir: name})
}
sort.Slice(placed, func(i, j int) bool { return placed[i].Dir < placed[j].Dir })
return placed, nil
}
func firstSymlink(dir string) string {
var found string
filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err == nil && d.Type()&fs.ModeSymlink != 0 && found == "" {
found, _ = filepath.Rel(dir, p)
return fs.SkipAll
}
return nil
})
return found
}
@@ -0,0 +1,228 @@
//go:build integration
package main
// The fetch step's merge and siblings on real git repositories and the real go (issues 1675 and 1677): they start
// processes, so they carry the tag; preview_test.go proves the commands with a fake runner.
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
func put(t *testing.T, dir, name, body string) {
t.Helper()
p := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// forge is a directory of bare repositories <owner>/<name>.git standing in for the forge, and a module fetching from it.
type forge struct {
t *testing.T
root string
}
func newForge(t *testing.T) *forge { return &forge{t: t, root: t.TempDir()} }
// repo makes the repository owner/name with the files on main and answers its work directory and the commit.
func (f *forge) repo(name string, files map[string]string) (work, commit string) {
bare := filepath.Join(f.root, "forge", name+".git")
work = filepath.Join(f.root, "work", name)
os.MkdirAll(bare, 0o755)
os.MkdirAll(work, 0o755)
git(f.t, bare, "init", "-q", "--bare")
git(f.t, work, "init", "-q", "-b", "main")
for n, c := range files {
put(f.t, work, n, c)
}
git(f.t, work, "add", ".")
git(f.t, work, "commit", "-q", "-m", "one")
git(f.t, work, "push", "-q", bare, "HEAD:refs/heads/main")
return work, git(f.t, work, "rev-parse", "HEAD")
}
// commit makes a commit off `from` in the repository's work directory and publishes it as the pull request's head.
func (f *forge) commit(name, work, from string, files map[string]string, ref string) string {
git(f.t, work, "checkout", "-q", "-B", "b"+ref[len(ref)-3:], from)
for n, c := range files {
put(f.t, work, n, c)
}
git(f.t, work, "add", ".")
git(f.t, work, "commit", "-q", "-m", "change")
sha := git(f.t, work, "rev-parse", "HEAD")
git(f.t, work, "push", "-q", "-f", filepath.Join(f.root, "forge", name+".git"), "HEAD:"+ref)
return sha
}
func (f *forge) module(t *testing.T) (*Module, *keptStates) {
m, states := fetchModule(t, &fakeStep{})
m.Run = StepRunner(m.Home)
m.RemoteBase = "file://" + filepath.Join(f.root, "forge") + "/"
m.FetchOptions = []buildstep.Option{buildstep.AllowFileRemote()}
return m, states
}
// waitLong is waitEnd for a step that runs the real go, whose first build in a cold cache takes longer than 3 s.
func waitLong(t *testing.T, s *keptStates) delivery.Step {
t.Helper()
select {
case key := <-s.ended:
s.mu.Lock()
defer s.mu.Unlock()
v := s.steps[key]
return v[len(v)-1]
case <-time.After(2 * time.Minute):
t.Fatal("the step never ended on its key")
return delivery.Step{}
}
}
func fetched(t *testing.T, m *Module, states *keptStates, args map[string]any) (FetchResult, delivery.Step) {
t.Helper()
if _, err := fetchTool(t, m)(args); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
var res FetchResult
if st.State == delivery.StepPassed {
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
}
return res, st
}
var baseFiles = map[string]string{
"go.mod": "module fixture/m\n\ngo 1.26\n",
"a.txt": "one\ntwo\nthree\n",
"modules/m/module.json": "{\"name\":\"m\"}\n",
"modules/m/go.sum": "",
"modules/m/m.txt": "m\n",
"modules/other/other.txt": "o\n",
}
// 1675/AC1 and AC2 on real git: a clean merge answers the merge commit, the changed paths (the trunk's change is not
// among them) and the tree of each module directory, which equals git's own; a conflict is a passed step naming the files,
// leaving the workspace at the trunk commit; and no base is today's fetch.
func TestRealFetchMergesConflictsAndPlainlyFetches(t *testing.T) {
f := newForge(t)
work, fork := f.repo("o/r", baseFiles)
trunk := f.commit("o/r", work, fork, map[string]string{"modules/other/other.txt": "trunk\n", "a.txt": "one\ntwo\nTRUNK\n"}, "refs/heads/main")
clean := f.commit("o/r", work, fork, map[string]string{"modules/m/m.txt": "head\n"}, "refs/pull/1/head")
clash := f.commit("o/r", work, fork, map[string]string{"a.txt": "one\ntwo\nHEAD\n"}, "refs/pull/2/head")
m, states := f.module(t)
res, st := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head", "base": trunk})
if st.State != delivery.StepPassed || res.Merge == "" || res.Base != trunk || res.Commit != clean || len(res.Conflicts) != 0 {
t.Fatalf("clean: %s %s", st.State, st.Result)
}
if strings.Join(res.Changed, ",") != "modules/m/m.txt" {
t.Errorf("changed = %v, want the head's own change alone", res.Changed)
}
tree := res.Trees["modules/m"]
if want := git(t, filepath.Join(m.Work, res.Workspace, "tree"), "rev-parse", res.Merge+":modules/m"); tree.Tree != want {
t.Errorf("tree = %s, want %s", tree.Tree, want)
}
if tree.Manifest != sha256Hex([]byte("{\"name\":\"m\"}\n")) || tree.Locks["go.sum"] != sha256Hex(nil) {
t.Errorf("manifest and locks = %+v", tree)
}
if got := git(t, filepath.Join(m.Work, res.Workspace, "tree"), "rev-parse", "HEAD"); got != res.Merge {
t.Errorf("the workspace is at %s, want the merge %s", got, res.Merge)
}
res, st = fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clash, "ref": "refs/pull/2/head", "base": trunk})
if st.State != delivery.StepPassed || res.Merge != "" || strings.Join(res.Conflicts, ",") != "a.txt" {
t.Fatalf("conflict: %s %s", st.State, st.Result)
}
if got := git(t, filepath.Join(m.Work, res.Workspace, "tree"), "rev-parse", "HEAD"); got != trunk {
t.Errorf("after a conflict the workspace is at %s, want the trunk %s", got, trunk)
}
res, st = fetched(t, m, states, map[string]any{"repository": "o/r", "commit": clean, "ref": "refs/pull/1/head"})
if st.State != delivery.StepPassed || res.Merge != "" || res.Base != "" || res.Trees != nil || len(res.Modules) != 1 {
t.Errorf("no base: %s %s", st.State, st.Result)
}
}
// 1677/AC1 on the real go: the unit level of a repository whose test reads MESH_CHECK_BESIDE fails loudly without the
// sibling and passes with it; the sibling is a plain directory with no symlink in it; and a sibling that holds a
// symlink fails the fetch naming it.
func TestRealTestsReadTheSiblingBesideTheTree(t *testing.T) {
f := newForge(t)
files := map[string]string{}
for _, n := range []string{"go.mod", "beside.go", "beside_test.go"} {
raw, err := os.ReadFile(filepath.Join("testdata", "repos", "beside", n))
if err != nil {
t.Fatal(err)
}
files[n] = string(raw)
}
_, head := f.repo("o/r", files)
_, sibling := f.repo("o/sibling", map[string]string{"data.txt": "from the sibling\n"})
m, states := f.module(t)
without, _ := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head})
if _, err := m.testToolRun(t, without.Workspace); err != nil {
t.Fatal(err)
}
var res TestResult
st := waitLong(t, states)
json.Unmarshal(st.Result, &res)
if st.State != delivery.StepPassed || res.Passed || len(res.Failed) != 1 || !strings.Contains(res.Failed[0].Output, "MESH_CHECK_BESIDE is not set") {
t.Fatalf("without the sibling: %s %s", st.State, st.Result)
}
with, st2 := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head,
"beside": []any{map[string]any{"repository": "o/sibling", "commit": sibling}}})
if st2.State != delivery.StepPassed || len(with.Beside) != 1 {
t.Fatalf("with the sibling: %s %s", st2.State, st2.Result)
}
m.testToolRun(t, with.Workspace)
res = TestResult{}
st = waitLong(t, states)
json.Unmarshal(st.Result, &res)
if !res.Passed {
t.Errorf("with the sibling the level failed: %s", st.Result)
}
if link := firstSymlink(filepath.Join(m.Work, with.Workspace, "beside")); link != "" {
t.Errorf("the placed tree holds a symlink, %s", link)
}
_, bad := f.repo("o/linked", map[string]string{"data.txt": "x\n"})
work := filepath.Join(f.root, "work", "o/linked")
if err := os.Symlink("/etc", filepath.Join(work, "etc-link")); err != nil {
t.Fatal(err)
}
git(t, work, "add", ".")
git(t, work, "commit", "-q", "-m", "link")
bad = git(t, work, "rev-parse", "HEAD")
git(t, work, "push", "-q", "-f", filepath.Join(f.root, "forge", "o/linked.git"), "HEAD:refs/heads/main")
_, st3 := fetched(t, m, states, map[string]any{"repository": "o/r", "commit": head,
"beside": []any{map[string]any{"repository": "o/linked", "commit": bad}}})
if st3.State != delivery.StepFailed || !strings.Contains(st3.Reason, "etc-link") {
t.Errorf("a sibling with a symlink: %s %s", st3.State, st3.Reason)
}
}
@@ -0,0 +1,317 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
const (
baseSHA = "abcdef0123456789abcdef0123456789abcdef01"
mergeSHA = "fedcba9876543210fedcba9876543210fedcba98"
treeSHA = "1111111111111111111111111111111111111111"
)
// previewRules answers the merge's commands as git does: the merge commit, the changed paths, the manifests, the trees.
func previewRules(conflict bool) func(string) (forgekey.Ran, bool) {
merged := false
return func(line string) (forgekey.Ran, bool) {
switch {
case strings.Contains(line, "--is-shallow-repository"):
return forgekey.Ran{Stdout: "true\n"}, true
case strings.Contains(line, " merge --no-ff"):
if conflict {
return forgekey.Ran{Status: 1, Stderr: "CONFLICT (content): Merge conflict in a.go\n"}, true
}
merged = true
return forgekey.Ran{}, true
case strings.Contains(line, "diff --name-only --diff-filter=U"):
return forgekey.Ran{Stdout: "a.go\nsub/b.go\n"}, true
case strings.Contains(line, "diff --name-only --no-renames"):
return forgekey.Ran{Stdout: "a.go\nmodules/m/main.go\n"}, true
case strings.Contains(line, " rev-parse HEAD") && merged:
return forgekey.Ran{Stdout: mergeSHA + "\n"}, true
case strings.Contains(line, " ls-files ") && strings.Contains(line, "module.json"):
return forgekey.Ran{Stdout: "modules/m/module.json\n"}, true
case strings.Contains(line, " rev-parse "+mergeSHA+":modules/m"):
return forgekey.Ran{Stdout: treeSHA + "\n"}, true
}
return forgekey.Ran{}, false
}
}
// 1675/AC1: with a base the fetch merges the head onto it with the SDK's merge (the identity and flags of the check
// merge, hooks off), and answers base, merge commit, changed paths and, per module directory, tree, manifest hash and lock hashes.
func TestFetchWithABaseMergesAndAnswersThePreview(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n", rule: previewRules(false)}
m, states := fetchModule(t, f)
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
// the workspace's module.json and go.sum are on disk once the fake checks the head out
for i, a := range args {
if a == "-C" && len(args) > i+1 && strings.Contains(strings.Join(args, " "), " checkout ") {
os.MkdirAll(filepath.Join(args[i+1], "modules", "m"), 0o755)
os.WriteFile(filepath.Join(args[i+1], "modules", "m", "module.json"), []byte("{}\n"), 0o644)
os.WriteFile(filepath.Join(args[i+1], "modules", "m", "go.sum"), []byte("sum\n"), 0o644)
}
}
return f.run(ctx, env, name, args...)
}
args := map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": baseSHA}
if _, err := fetchTool(t, m)(args); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
var res FetchResult
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
if res.Base != baseSHA || res.Merge != mergeSHA || res.Commit != commitSHA || len(res.Conflicts) != 0 ||
strings.Join(res.Changed, ",") != "a.go,modules/m/main.go" {
t.Errorf("result = %s", st.Result)
}
mt, ok := res.Trees["modules/m"]
if !ok || mt.Tree != treeSHA || mt.Manifest != sha256Hex([]byte("{}\n")) || mt.Locks["go.sum"] != sha256Hex([]byte("sum\n")) {
t.Errorf("trees = %+v", res.Trees)
}
lines := strings.Join(f.lines(), "\n")
for _, want := range []string{"fetch --no-tags --unshallow origin " + baseSHA, "checkout -q --detach " + baseSHA,
"core.hooksPath=/dev/null", "merge --no-ff --no-edit --no-verify"} {
if !strings.Contains(lines, want) {
t.Errorf("git was not asked %q:\n%s", want, lines)
}
}
}
// 1675/AC2: a conflict is an answer: the step ends passed, the files are named, no merge commit, nothing downloaded.
func TestFetchWithAConflictEndsPassedAndNamesTheFiles(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n", rule: previewRules(true)}
m, states := fetchModule(t, f)
args := map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": baseSHA}
if _, err := fetchTool(t, m)(args); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
if st.State != delivery.StepPassed {
t.Fatalf("a conflict ended the step %s: %s", st.State, st.Reason)
}
var res FetchResult
json.Unmarshal(st.Result, &res)
if res.Merge != "" || res.Base != baseSHA || strings.Join(res.Conflicts, ",") != "a.go,sub/b.go" || len(res.Changed) != 0 {
t.Errorf("result = %s", st.Result)
}
lines := strings.Join(f.lines(), "\n")
if !strings.Contains(lines, "merge --abort") || strings.Contains(lines, "mod download") {
t.Errorf("the merge was not undone, or modules were downloaded:\n%s", lines)
}
}
// 1675/AC3: no base is today's fetch: no merge, no extra command, and the answer carries none of the preview.
func TestFetchWithoutABaseIsTodaysFetch(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n"}
m, states := fetchModule(t, f)
fetchTool(t, m)(fetchArgs)
st := states.waitEnd(t)
lines := strings.Join(f.lines(), "\n")
if strings.Contains(lines, "merge") || strings.Contains(lines, "unshallow") {
t.Errorf("a fetch without a base merged:\n%s", lines)
}
for _, key := range []string{"base", "merge", "conflicts", "changed", "trees", "beside"} {
if strings.Contains(string(st.Result), `"`+key+`"`) {
t.Errorf("the answer carries %q without a base: %s", key, st.Result)
}
}
}
func TestABaseThatIsNotACommitIsRefusedBeforeAnythingRuns(t *testing.T) {
f := &fakeStep{}
m, states := fetchModule(t, f)
fetchTool(t, m)(map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "base": "main"})
if st := states.waitEnd(t); st.State != delivery.StepFailed || len(f.lines()) != 0 {
t.Errorf("the step ended %s after %d commands", st.State, len(f.lines()))
}
}
// 1677/AC1: each sibling is fetched into beside/<name> of the workspace, and the tests of the workspace are given
// MESH_CHECK_BESIDE naming that directory, at the unit level; a workspace without siblings is given none.
func TestSiblingsArePlacedBesideAndTheUnitLevelIsGivenTheirDirectory(t *testing.T) {
f := &fakeStep{gomods: "go.mod\n"}
m, states := fetchModule(t, f)
args := map[string]any{"repository": "novox/mesh-controller", "commit": commitSHA, "beside": []any{
map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA},
map[string]any{"repository": "novox/mesh-host", "commit": commitSHA}}}
if _, err := fetchTool(t, m)(args); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
var res FetchResult
json.Unmarshal(st.Result, &res)
if st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
if len(res.Beside) != 2 || res.Beside[0].Dir != "mesh-catalog" || res.Beside[1].Dir != "mesh-host" {
t.Fatalf("result = %s", st.Result)
}
lines := strings.Join(f.lines(), "\n")
for _, d := range []string{"beside/mesh-catalog", "beside/mesh-host"} {
if !strings.Contains(lines, filepath.Join(m.Work, res.Workspace, d)+" init") && !strings.Contains(lines, "-C "+filepath.Join(m.Work, res.Workspace, d)) {
t.Errorf("nothing was fetched into %s:\n%s", d, lines)
}
}
if !strings.Contains(lines, "remote add origin ssh://mesh-forge/novox/mesh-catalog.git") {
t.Errorf("the sibling was not fetched over ssh:\n%s", lines)
}
calls := &[]cmdRecord{}
m.Run = capturedRunner(t, "failing", calls)
if err := os.MkdirAll(filepath.Join(m.Work, res.Workspace, "tree"), 0o755); err != nil {
t.Fatal(err)
}
if _, err := m.testToolRun(t, res.Workspace); err != nil {
t.Fatal(err)
}
states.waitEnd(t)
want := "MESH_CHECK_BESIDE=" + filepath.Join(m.Work, res.Workspace, "beside")
if !envHas(*calls, "go", want) {
t.Errorf("no go command was given %s", want)
}
}
func (m *Module) testToolRun(t *testing.T, ws string) (any, error) {
t.Helper()
for _, tool := range m.Tools(m.StepTools()) {
if tool.Name == "build_go_test" {
return tool.Run(map[string]any{"workspace": ws, "level": "unit"})
}
}
t.Fatal("no tool build_go_test")
return nil, nil
}
func envHas(calls []cmdRecord, name, want string) bool {
for _, c := range calls {
if strings.HasPrefix(c.line, name+" ") {
for _, e := range c.env {
if e == want {
return true
}
}
}
}
return false
}
func TestAWorkspaceWithoutSiblingsIsGivenNoBesideVariable(t *testing.T) {
m, states, ws, calls := workspaceFor(t, "failing")
runUnit(t, m, states, ws)
for _, c := range *calls {
for _, e := range c.env {
if strings.HasPrefix(e, "MESH_CHECK_BESIDE=") {
t.Errorf("%s was given %s", c.line, e)
}
}
}
}
func TestSiblingsThatAreNotRepositoriesOrCommitsAreRefused(t *testing.T) {
for name, beside := range map[string]any{
"not a list": "x",
"bad repository": []any{map[string]any{"repository": "../x/y", "commit": baseSHA}},
"bad commit": []any{map[string]any{"repository": "novox/a", "commit": "main"}},
"twice": []any{map[string]any{"repository": "novox/a", "commit": baseSHA}, map[string]any{"repository": "other/a", "commit": baseSHA}},
} {
f := &fakeStep{}
m, states := fetchModule(t, f)
fetchTool(t, m)(map[string]any{"repository": "novox/mesh-sdk", "commit": commitSHA, "beside": beside})
if st := states.waitEnd(t); st.State != delivery.StepFailed || len(f.lines()) != 0 {
t.Errorf("%s: the step ended %s after %d commands", name, st.State, len(f.lines()))
}
}
}
// 1288/AC5 (the checker's contract): an import path runs only in the go.mod directory whose module holds it; a
// relative pattern runs in every directory; no pattern is ./... everywhere.
func TestPackagesRunOnlyInTheModuleThatHoldsThem(t *testing.T) {
tree := t.TempDir()
for dir, mod := range map[string]string{".": "example.org/root", "tools": "example.org/root/tools", "other": "example.org/other"} {
os.MkdirAll(filepath.Join(tree, dir), 0o755)
os.WriteFile(filepath.Join(tree, dir, "go.mod"), []byte("module "+mod+"\n\ngo 1.26\n"), 0o644)
}
dirs := []string{".", "other", "tools"}
got, err := packagesByDir(tree, dirs, []string{"example.org/root/a", "example.org/root/tools/b", "example.org/other"})
if err != nil {
t.Fatal(err)
}
want := map[string]string{".": "example.org/root/a", "tools": "example.org/root/tools/b", "other": "example.org/other"}
for d, w := range want {
if strings.Join(got[d], ",") != w {
t.Errorf("%s runs %v, want %s", d, got[d], w)
}
}
got, _ = packagesByDir(tree, dirs, []string{"./x/..."})
if len(got["."]) != 1 || len(got["tools"]) != 1 || len(got["other"]) != 1 {
t.Errorf("a relative pattern = %v", got)
}
got, _ = packagesByDir(tree, dirs, nil)
if got["tools"][0] != "./..." {
t.Errorf("no pattern = %v", got)
}
if _, err := packagesByDir(tree, dirs, []string{"example.org/nowhere/x"}); err == nil || !strings.Contains(err.Error(), "example.org/nowhere/x") {
t.Errorf("a package no module holds: %v", err)
}
}
func sha256Hex(b []byte) string { h := sha256.Sum256(b); return hex.EncodeToString(h[:]) }
// 1677/AC (integration level): the integration level's go test is given MESH_CHECK_BESIDE when the workspace has siblings
// beside it, with the store's and the bus's addresses, and is given none when it has not.
func TestTheIntegrationLevelIsGivenTheBesideDirectoryOnlyWhenSiblingsWerePlaced(t *testing.T) {
for _, siblings := range []bool{true, false} {
f := newIntegFake(t, "slow")
m, states := integModule(t, f)
beside := filepath.Join(m.Work, "ws1", "beside")
if siblings {
if err := os.MkdirAll(filepath.Join(beside, "mesh-catalog"), 0o755); err != nil {
t.Fatal(err)
}
}
if _, st := runLevelTool(t, m, states, "integration"); st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
tested := 0
for _, c := range f.calls {
if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") {
continue
}
tested++
got := ""
hasNATS := false
for _, e := range c.env {
if strings.HasPrefix(e, "MESH_CHECK_BESIDE=") {
got = strings.TrimPrefix(e, "MESH_CHECK_BESIDE=")
}
hasNATS = hasNATS || strings.HasPrefix(e, "MESH_TEST_NATS=")
}
want := ""
if siblings {
want = beside
}
if got != want || !hasNATS {
t.Errorf("siblings %v: %s was given MESH_CHECK_BESIDE=%q (want %q), MESH_TEST_NATS present %v", siblings, c.line, got, want, hasNATS)
}
}
if tested == 0 {
t.Errorf("siblings %v: no integration go test ran", siblings)
}
}
}
@@ -20,7 +20,7 @@ const PostgresMajor = 18
// RecipeSteps is the recipe's own version: changed whenever the steps of fetch, test or build change in a way
// that can change what they produce (ADR 0405 §7). It is one of the three values the recipe version hashes.
const RecipeSteps = "1"
const RecipeSteps = "2"
// natsModule is the library the integration level's bus is started from (ADR 0470 §2).
const natsModule = "github.com/nats-io/nats-server/v2"
@@ -65,13 +65,23 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
}
switch level {
case LevelUnit:
return m.runLevel(ctx, tree, levelOpts{level: LevelUnit, packages: packages, bounds: Bounds{Run: m.unitBound()}})
return m.runLevel(ctx, tree, levelOpts{level: LevelUnit, packages: packages, bounds: Bounds{Run: m.unitBound()}, env: m.besideEnv(workspace)})
case LevelIntegration:
return m.integration(ctx, workspace, tree, packages)
}
return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
}
// besideEnv is MESH_CHECK_BESIDE, naming the directory build_go_fetch placed the workspace's siblings in (ADR 0484
// clause 4), when it placed any; the tests that read other repositories look there (mesh-sdk go/internal/beside).
func (m *Module) besideEnv(workspace string) []string {
dir := m.besideDir(workspace)
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
return nil
}
return []string{"MESH_CHECK_BESIDE=" + dir}
}
func packagesArg(v any) ([]string, error) {
if v == nil {
return nil, nil
@@ -115,12 +125,16 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
return nil, err
}
res := &TestResult{Level: o.level, Packages: []PackageResult{}, Setup: o.setup.Seconds(), Store: o.store, Bus: o.bus}
packages := o.packages
if len(packages) == 0 {
packages = []string{"./..."}
byDir, err := packagesByDir(tree, dirs, o.packages)
if err != nil {
return nil, err
}
began := m.clock()
for _, d := range dirs {
packages := byDir[d]
if len(packages) == 0 {
continue // no package asked for lies in this module
}
args := []string{"-C", filepath.Join(tree, d), "test", "-json", "-count=1", "-timeout", testTimeout}
if len(o.tags) > 0 {
args = append(args, "-tags", strings.Join(o.tags, ","))
@@ -141,3 +155,50 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
res.judge(o.bounds)
return res, nil
}
// packagesByDir says which patterns run in which go.mod directory. A relative pattern (./...) runs in every directory, as
// it always did; an import path, as build_go_digest keys its packages, runs only in the directory whose module holds it
// (the longest module path that is the path or its prefix), as another module's path would fail the step. No pattern
// means ./... everywhere. An import path no module of the tree holds is an error that names it.
func packagesByDir(tree string, dirs, patterns []string) (map[string][]string, error) {
out := map[string][]string{}
if len(patterns) == 0 {
patterns = []string{"./..."}
}
modules := map[string]string{}
for _, d := range dirs {
if raw, err := os.ReadFile(filepath.Join(tree, d, "go.mod")); err == nil {
modules[d] = modulePath(string(raw))
}
}
for _, p := range patterns {
if strings.HasPrefix(p, "./") || p == "." {
for _, d := range dirs {
out[d] = append(out[d], p)
}
continue
}
owner, best := "", -1
for _, d := range dirs {
mp := modules[d]
if mp != "" && (p == mp || strings.HasPrefix(p, mp+"/")) && len(mp) > best {
owner, best = d, len(mp)
}
}
if best < 0 {
return nil, fmt.Errorf("no module of the workspace holds the package %s", p)
}
out[owner] = append(out[owner], p)
}
return out, nil
}
// modulePath is the module path a go.mod declares, "" when it declares none.
func modulePath(gomod string) string {
for _, l := range strings.Split(gomod, "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "module" {
return strings.Trim(f[1], `"`)
}
}
return ""
}
@@ -0,0 +1 @@
package beside
@@ -0,0 +1,22 @@
package beside
import (
"os"
"path/filepath"
"testing"
)
// A test of a repository that reads another one, as the controller's, the node-engine's, the SDK's and the catalogue's do.
func TestReadsTheSiblingBesideIt(t *testing.T) {
root := os.Getenv("MESH_CHECK_BESIDE")
if root == "" {
t.Fatal("the sibling is not beside this check: MESH_CHECK_BESIDE is not set")
}
raw, err := os.ReadFile(filepath.Join(root, "sibling", "data.txt"))
if err != nil {
t.Fatal(err)
}
if string(raw) != "from the sibling\n" {
t.Fatalf("data.txt = %q", raw)
}
}
@@ -0,0 +1,3 @@
module fixture/beside
go 1.26
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc
github.com/nats-io/nats-server/v2 v2.11.17
)
+4
View File
@@ -1,5 +1,9 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3 h1:OKxU64kHN1yQ/GOa6LwoUwVmiQ74d83WMSZJRFPVm9c=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021093645-465beb4ea5e3/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5 h1:1BiPKRPTYpgex8rSHWT9az0BteOafGWRDzd/Ba6GAcA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021185947-afbe371003a5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc h1:ZJScJSnF6LgFl55/VJ9RT/lNroVWun469sXtx0nlVYw=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021192148-78364e318bdc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+38
View File
@@ -0,0 +1,38 @@
# mesh-build-image — the image build module
Design 47, release R4c-9 (novox/hq ADR 0487, ADR 0466 §1, ADR 0469; issue 1703). The module that builds a module's
`image` artifact from the Dockerfile in the module's own directory, on a build machine, as an account of its own
(`runs-as`, no login shell, home at mode 0700). It serves every module's image, whatever the base, so it is not a
build module for one module. It claims no seat: it is assigned to the build machines and runs dark until the builder
uses it.
## Rootless
The tool is `buildah`, which the `buildah` module installs and provides as `rootless-image-build` with the machine's
reach: a machine without it refuses this module at assignment, naming `buildah`. The build runs as the module's own
account in a user namespace (`--isolation oci`), with buildah's image store and runtime state in
`/var/lib/mesh-build-image-store` (outside the home), no mount of the host, no container runtime socket, no registry
credential and an environment built from scratch.
The account's subordinate user and group ranges are not declared anywhere: the node-engine makes every `user` account
with `useradd`, which allocates them. The module's health reads `/etc/subuid` and `/etc/subgid` and is unhealthy
without a range for the account, without buildah, or when a user namespace cannot be made.
The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can read holds a key.
## Steps
- `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_image_build` builds one image artifact: `buildah bud` with the Dockerfile of the module's directory as file
and context, `--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
`<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
`publish` (a pull request) nothing is pushed.
- It refuses, naming why, an image whose build context is a whole repository (`context`) or whose `build.on` names
another module's artifact (`unsupportedImage` in `build.go`): those forms belong to the images build-agent builds
until it is removed at R4g.
- `build_image_step`, `build_image_steps`, `build_image_status` and `build_image_health` read.
The registry's address is placed into `<home>/registry` as `${seat:mesh-artifact-store:reach}` (host:port): the provisioner's client fills a moved module's placement, and the artifact store has not moved, so a `${bound:artifact-store:...}` is refused at composition (issue 1703).
@@ -0,0 +1,215 @@
package main
// The build step (novox/hq ADR 0487 §1 §2 §5 §6; issue 1703c): one `image` artifact of a module of a fetched workspace,
// built from the Dockerfile in the module's own directory with buildah, rootless: as the module's own account, in a
// user namespace, with its image store outside the home, no mount of the host, no registry credential and an
// environment built from scratch. The step runs the Dockerfile and nothing else; the push of a trunk build is made
// after the build has ended (push.go).
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// BuildResult is what a passed build step says: the image's manifest digest as buildah holds it, and, for a trunk
// build that published, the digest the registry serves and the reference pinned by it.
type BuildResult struct {
Artifact string `json:"artifact"`
Digest string `json:"digest"`
Reference string `json:"reference,omitempty"`
Pushed bool `json:"pushed"`
}
var tagOK = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
func (m *Module) buildTool() StepTool {
return StepTool{
Name: BuildTool,
Description: "Build one image artifact of a module of a fetched workspace from the Dockerfile in the module's own directory, " +
"rootless with buildah. Answers a step id at once; the step ends on its key with the image's digest. With publish " +
"true (a merged trunk commit) the image is then pushed to the registry from this module's own process and the key " +
"carries the digest the registry serves and the reference pinned by it; without it (a pull request) nothing is pushed.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"workspace": map[string]any{"type": "string", "description": "the workspace id build_image_fetch answered"},
"module": map[string]any{"type": "string", "description": "the module's directory in the workspace, such as modules/n8n"},
"artifact": map[string]any{"type": "string", "description": "the image artifact's name in the module's manifest"},
"publish": map[string]any{"type": "boolean", "description": "push the image to the registry after the build: only for a merged trunk commit"},
"tag": map[string]any{"type": "string", "description": "the registry tag of a published image; the first 12 hex digits of the digest when left out"},
},
"required": []string{"workspace", "module", "artifact"},
},
Long: true,
Run: m.build,
}
}
// 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
// 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).
func unsupportedImage(manifest catalogue.Manifest, a catalogue.Artifact) string {
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",
a.Name, manifest.Module, a.Context.Repository)
}
if manifest.Build != nil {
for _, on := range manifest.Build.On {
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)",
a.Name, manifest.Module, on.Artifact, on.Module, on.Arg)
}
}
}
return ""
}
// imageArgs are the --build-arg words of the images the manifest pins in build.on, in its order.
func imageArgs(manifest catalogue.Manifest) []string {
var out []string
if manifest.Build == nil {
return out
}
for _, on := range manifest.Build.On {
if on.Image != "" && on.Arg != "" {
out = append(out, "--build-arg", on.Arg+"="+on.Image)
}
}
return out
}
func (m *Module) build(ctx context.Context, args map[string]any) (any, error) {
workspace, _ := args["workspace"].(string)
module, _ := args["module"].(string)
name, _ := args["artifact"].(string)
publish, _ := args["publish"].(bool)
tag, _ := args["tag"].(string)
if !workspaceOK.MatchString(workspace) {
return nil, fmt.Errorf("%q is not a workspace id", workspace)
}
if tag != "" && !tagOK.MatchString(tag) {
return nil, fmt.Errorf("%q is not a registry tag", tag)
}
tree := filepath.Join(m.Work, workspace, "tree")
dir := filepath.Join(tree, filepath.FromSlash(module))
if rel, err := filepath.Rel(tree, dir); err != nil || strings.HasPrefix(rel, "..") {
return nil, fmt.Errorf("the module directory %q lies outside the workspace", module)
}
raw, err := os.ReadFile(filepath.Join(dir, "module.json"))
if err != nil {
return nil, fmt.Errorf("no module in %q of workspace %s: %w", module, workspace, err)
}
manifest, err := catalogue.ParseManifest(raw)
if err != nil {
return nil, fmt.Errorf("the manifest in %q: %w", module, err)
}
var art *catalogue.Artifact
names := []string{}
if manifest.Build != nil {
for i := range manifest.Build.Artifacts {
names = append(names, manifest.Build.Artifacts[i].Name)
if manifest.Build.Artifacts[i].Name == name {
art = &manifest.Build.Artifacts[i]
}
}
}
if art == nil {
return nil, fmt.Errorf("the module %s has no artifact %q (it has %s)", manifest.Module, name, strings.Join(names, ", "))
}
if art.Kind != "image" {
return nil, fmt.Errorf("the artifact %s of %s is a %s: this module builds images", name, manifest.Module, art.Kind)
}
if why := unsupportedImage(manifest, *art); why != "" {
return nil, &buildstep.Refusal{Text: why}
}
dockerfile, contextDir, err := dockerfileOf(dir, art.From)
if err != nil {
return nil, fmt.Errorf("the image %s of %s: %w", name, manifest.Module, err)
}
local := localName(workspace, manifest.Module, art.Name)
argv := append(m.storeFlags(), "bud", "--isolation", "oci", "--tag", local, "--file", dockerfile)
if art.Target != "" {
argv = append(argv, "--target", art.Target)
}
argv = append(argv, imageArgs(manifest)...)
argv = append(argv, contextDir)
if r := m.Run(ctx, m.env(), "buildah", argv...); r.Status != 0 || r.Err != "" {
return nil, fmt.Errorf("buildah bud of %s: %s", art.Name, lastLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
}
defer m.remove(local)
digest, err := m.localDigest(ctx, local)
if err != nil {
return nil, err
}
res := BuildResult{Artifact: art.Name, Digest: digest}
if !publish {
return res, nil
}
if tag == "" {
tag = strings.TrimPrefix(digest, "sha256:")[:12]
}
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
// be a regular file inside the module's own directory (checks/imageplace holds the catalogue to the same).
func dockerfileOf(moduleDir, from string) (dockerfile, contextDir string, err error) {
clean := filepath.Clean(filepath.FromSlash(strings.TrimSpace(from)))
if from == "" || filepath.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
return "", "", fmt.Errorf("%q is not a Dockerfile in the module's own directory", from)
}
dockerfile = filepath.Join(moduleDir, clean)
info, err := os.Lstat(dockerfile)
if err != nil || !info.Mode().IsRegular() {
return "", "", fmt.Errorf("%q is not a file in the module's directory", from)
}
return dockerfile, filepath.Dir(dockerfile), nil
}
// localName names the image in buildah's store: one name for each artifact of a workspace.
func localName(workspace, module, artifact string) string {
sum := sha256.Sum256([]byte(module + "/" + artifact))
return "mesh-build:" + workspace + "-" + hex.EncodeToString(sum[:])[:8]
}
// localDigest is the manifest digest of the image buildah holds under name.
func (m *Module) localDigest(ctx context.Context, name string) (string, error) {
r := m.Run(ctx, m.env(), "buildah", append(m.storeFlags(), "images", "--no-trunc", "--format", "{{.Digest}}", name)...)
digest := strings.TrimSpace(r.Stdout)
if r.Status != 0 || r.Err != "" || !digestOK.MatchString(digest) {
return "", fmt.Errorf("the digest of the image %s could not be read: %s", name, lastLine(r.Err+"\n"+r.Stderr+"\n"+r.Stdout))
}
return digest, nil
}
var digestOK = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
// remove drops the image from buildah's store, so the store does not grow with every build; a failure is said.
func (m *Module) remove(name string) {
r := m.Run(m.Ctx, m.env(), "buildah", append(m.storeFlags(), "rmi", "--force", name)...)
if (r.Status != 0 || r.Err != "") && m.Say != nil {
m.Say("the image %s could not be removed from the store: %s", name, lastLine(r.Err+"\n"+r.Stderr))
}
}
// lastLine is the last line of a command's output that says something: buildah's error is its last line.
func lastLine(s string) string {
lines := strings.Split(s, "\n")
for i := len(lines) - 1; i >= 0; i-- {
if l := strings.TrimSpace(lines[i]); l != "" {
return l
}
}
return ""
}
@@ -0,0 +1,200 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The fixture module (testdata/repos/image): a Dockerfile that adds a file to an upstream base the manifest pins in
// build.on, as a workspace's tree holds it.
func place(t *testing.T, m *Module, edit func(string) string) {
t.Helper()
src := filepath.Join("testdata", "repos", "image", "modules", "demo")
dst := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
if err := os.MkdirAll(dst, 0o755); err != nil {
t.Fatal(err)
}
for _, n := range []string{"Dockerfile", "hello.txt", "module.json.fixture"} {
raw, err := os.ReadFile(filepath.Join(src, n))
if err != nil {
t.Fatal(err)
}
if n == "module.json.fixture" {
n = "module.json"
raw = []byte(edit(string(raw)))
}
if err := os.WriteFile(filepath.Join(dst, n), raw, 0o644); err != nil {
t.Fatal(err)
}
}
}
func same(s string) string { return s }
func runBuild(t *testing.T, m *Module, states *keptStates, extra map[string]any) (BuildResult, delivery.Step) {
t.Helper()
args := map[string]any{"workspace": "ws1", "module": "modules/demo", "artifact": "server"}
for k, v := range extra {
args[k] = v
}
if _, err := tool(t, m, BuildTool)(args); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
var res BuildResult
if st.State == delivery.StepPassed {
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
}
return res, st
}
func setup(t *testing.T, edit func(string) string) (*Module, *fake, *keptStates) {
t.Helper()
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
place(t, m, edit)
return m, f, states
}
// 1703c/AC: the build runs buildah bud rootless: the store and the runtime state in the store directory, the
// Dockerfile of the module's own directory and that directory as the context, the target, the pinned base as a build
// argument; no mount, no credential file, a runtime directory of its own, and it answers the image's digest.
func TestBuildRunsBuildahRootlessAndAnswersTheDigest(t *testing.T) {
m, f, states := setup(t, same)
res, st := runBuild(t, m, states, nil)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
if res.Digest != imageDigest || res.Artifact != "server" || res.Pushed || res.Reference != "" {
t.Errorf("result = %+v, want the local digest and no push", res)
}
bud := f.called("bud")
if len(bud) != 1 {
t.Fatalf("buildah bud ran %d times", len(bud))
}
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "demo")
for _, want := range []string{
"--root " + m.Store + "/root", "--runroot " + m.Store + "/run", "--isolation oci",
"--file " + dir + "/Dockerfile", "--target final",
"--build-arg DEMO_BASE=docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001",
} {
if !strings.Contains(bud[0].line, want) {
t.Errorf("bud line lacks %q:\n%s", want, bud[0].line)
}
}
if !strings.HasSuffix(bud[0].line, " "+dir) {
t.Errorf("the context is not the Dockerfile's directory:\n%s", bud[0].line)
}
for _, c := range f.calls {
if c.name != "buildah" {
continue
}
for _, bad := range []string{" -v ", "--volume", "--mount", "--authfile", "--creds", "--cap-add", "--privileged", "--device"} {
if strings.Contains(" "+c.line+" ", bad) {
t.Errorf("%q carries %q", c.line, bad)
}
}
env := strings.Join(c.env, " ")
if strings.Contains(env, "REGISTRY_AUTH") || strings.Contains(env, "DOCKER_CONFIG") || strings.Contains(env, "MESH_") {
t.Errorf("the environment of %q carries a credential or the module's own: %s", c.line, env)
}
if !strings.Contains(env, "XDG_RUNTIME_DIR="+m.Store+"/xdg") {
t.Errorf("the environment of %q lacks the module's own runtime directory: %s", c.line, env)
}
}
// The image is removed from the store once the digest is read, so the store does not grow with every build.
if len(f.called("rmi")) != 1 {
t.Errorf("rmi ran %d times, want the image removed once", len(f.called("rmi")))
}
}
// 1703c: a failing Dockerfile step fails the step, saying buildah's last line.
func TestBuildFailsWithBuildahsLastLine(t *testing.T) {
m, f, states := setup(t, same)
f.failBud = "Error: building at STEP \"RUN false\": exit status 1"
_, st := runBuild(t, m, states, nil)
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, `STEP "RUN false"`) {
t.Errorf("step = %+v", st)
}
if len(f.called("push")) != 0 {
t.Error("a failed build pushed")
}
}
// 1703c: an image whose context is a repository, or whose build.on names another module's artifact, is refused
// (not failed), the reason saying why; nothing is built.
func TestBuildRefusesAnImageWithARepositoryContextOrAModuleBase(t *testing.T) {
cases := map[string]struct {
edit func(string) string
want []string
}{
"context": {func(s string) string {
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"}},
"module base": {func(s string) string {
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"}},
}
for name, c := range cases {
m, f, states := setup(t, c.edit)
_, st := runBuild(t, m, states, nil)
if st.State != delivery.StepRefused {
t.Errorf("%s: step = %+v, want refused", name, st)
continue
}
for _, w := range c.want {
if !strings.Contains(st.Reason, w) {
t.Errorf("%s: the reason %q lacks %q", name, st.Reason, w)
}
}
if len(f.called("bud")) != 0 {
t.Errorf("%s: a refused image was built", name)
}
}
}
// The refusal is one small function: it knows the two forms and passes the rest.
func TestUnsupportedImageNamesTheTwoFormsAndPassesTheRest(t *testing.T) {
plain := catalogue.Manifest{Module: "x", Build: &catalogue.Build{On: []catalogue.BuildsOn{{Arg: "A", Image: "i@sha256:aa"}}}}
if why := unsupportedImage(plain, catalogue.Artifact{Name: "a", Kind: "image"}); why != "" {
t.Errorf("a plain image was refused: %s", why)
}
if why := unsupportedImage(plain, catalogue.Artifact{Name: "a", Kind: "image", Context: &catalogue.ArtifactContext{Repository: "o/r"}}); why == "" {
t.Error("a repository context passed")
}
}
func TestBuildFailsForWhatIsNotAnImageOrNotThere(t *testing.T) {
for name, tc := range map[string]struct {
edit func(string) string
args map[string]any
}{
"not an image": {func(s string) string { return strings.Replace(s, `"kind": "image"`, `"kind": "archive"`, 1) }, nil},
"no artifact": {same, map[string]any{"artifact": "other"}},
"no Dockerfile": {func(s string) string {
return strings.Replace(s, `"from": "Dockerfile"`, `"from": "Missing"`, 1)
}, nil},
"leaves the module": {func(s string) string {
return strings.Replace(s, `"from": "Dockerfile"`, `"from": "../x/Dockerfile"`, 1)
}, nil},
"outside the workspace": {same, map[string]any{"module": "../.."}},
} {
m, f, states := setup(t, tc.edit)
_, st := runBuild(t, m, states, tc.args)
if st.State != delivery.StepFailed {
t.Errorf("%s: step = %+v, want failed", name, st)
}
if len(f.called("bud")) != 0 {
t.Errorf("%s: buildah bud ran", name)
}
}
}
@@ -0,0 +1,157 @@
//go:build integration
package main
// ADR 0487 §1: the build step answers the image's digest, through a real bus (issue 1703e). A nats-server from the Go
// library, at the version this module's go.mod names, runs with JetStream on in the test's directory (the pattern of
// mesh-build-go's bus.go). The module's steps state is a key-value bucket on that bus, and its tools are served on bus
// subjects by a small bridge in the test, so a call, the step that runs behind it and the key it ends on all travel over
// the bus; the mesh's own tool runner is not started here. buildah and git are the fake runner's, so this runs wherever
// the check runs; the real buildah is TestARealBuild... in integration_test.go. What the keys hold is compared with the
// captured shapes (testdata/captured, capture_test.go) that the builder's tests copy.
import (
"context"
"encoding/json"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
)
// bus starts the throwaway server and answers a connection to it.
func bus(t *testing.T) *nats.Conn {
t.Helper()
s, err := server.NewServer(&server.Options{Host: "127.0.0.1", Port: server.RANDOM_PORT, JetStream: true, StoreDir: t.TempDir(), NoLog: true, NoSigs: true})
if err != nil {
t.Fatal(err)
}
go s.Start()
if !s.ReadyForConnections(30 * time.Second) {
t.Fatal("the bus was not ready")
}
t.Cleanup(func() { s.Shutdown(); s.WaitForShutdown() })
nc, err := nats.Connect(s.ClientURL())
if err != nil {
t.Fatal(err)
}
t.Cleanup(nc.Close)
return nc
}
// onBus wires a module's steps state to a bucket and serves its tools on "test.tool.<name>": the request body is the
// arguments, the answer is {"result": ...} or {"error": "..."}.
func onBus(t *testing.T, nc *nats.Conn, m *Module) jetstream.KeyValue {
t.Helper()
js, err := jetstream.New(nc)
if err != nil {
t.Fatal(err)
}
ctx := context.Background()
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "mesh-build-image-steps"})
if err != nil {
t.Fatal(err)
}
m.Steps = func(key string, value any) (uint64, error) {
raw, err := json.Marshal(value)
if err != nil {
return 0, err
}
return kv.Put(ctx, key, raw)
}
for _, tl := range m.Tools(m.StepTools()) {
tl := tl
if _, err := nc.Subscribe("test.tool."+tl.Name, func(msg *nats.Msg) {
var args map[string]any
_ = json.Unmarshal(msg.Data, &args)
res, err := tl.Run(args)
out := map[string]any{"result": res}
if err != nil {
out = map[string]any{"error": err.Error()}
}
b, _ := json.Marshal(out)
_ = msg.Respond(b)
}); err != nil {
t.Fatal(err)
}
}
return kv
}
// call calls a tool over the bus and answers its result, which for a long step is the running step's key.
func call(t *testing.T, nc *nats.Conn, tool string, args map[string]any) map[string]any {
t.Helper()
body, _ := json.Marshal(args)
msg, err := nc.Request("test.tool."+tool, body, 10*time.Second)
if err != nil {
t.Fatal(err)
}
var out struct {
Result map[string]any `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(msg.Data, &out); err != nil || out.Error != "" {
t.Fatalf("%s answered %s (%v)", tool, msg.Data, err)
}
return out.Result
}
// ended waits for the step's key in the bucket to leave running and answers the step as the bucket holds it.
func ended(t *testing.T, kv jetstream.KeyValue, key string) delivery.Step {
t.Helper()
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if e, err := kv.Get(context.Background(), key); err == nil {
st, err := delivery.DecodeStep(e.Value())
if err != nil {
t.Fatal(err)
}
if st.State != delivery.StepRunning {
return st
}
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("the key %s never left running", key)
return delivery.Step{}
}
func TestTheStepsAnswerThroughARealBusWithTheCapturedShapes(t *testing.T) {
nc := bus(t)
f := newFake(t)
m := moduleWith(t, f, newStates())
kv := onBus(t, nc, m)
place(t, m, same)
// The fetch: a running key at once, then the workspace on the key.
started := call(t, nc, FetchTool, map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA})
if started["state"] != delivery.StepRunning || started["key"] == "" {
t.Fatalf("the fetch answered %v, want a running step and its key", started)
}
st := ended(t, kv, started["key"].(string))
compareCaptured(t, "fetch.json", normalized(t, st, workspaceOf(t, st), commitSHA, ""))
// The build of a pull request: the image's digest, nothing pushed. The workspace is the fixture's, "ws1".
for name, extra := range map[string]map[string]any{"build-candidate.json": nil, "build-trunk.json": {"publish": true}} {
args := map[string]any{"workspace": "ws1", "module": "modules/demo", "artifact": "server"}
for k, v := range extra {
args[k] = v
}
started := call(t, nc, BuildTool, args)
st := ended(t, kv, started["key"].(string))
if st.State != delivery.StepPassed {
t.Fatalf("%s: step = %+v", name, st)
}
var res BuildResult
if err := json.Unmarshal(st.Result, &res); err != nil || !digestOK.MatchString(res.Digest) || res.Pushed != (extra != nil) {
t.Errorf("%s: result = %s (%v), want the image's digest and pushed %v", name, st.Result, err, extra != nil)
}
compareCaptured(t, name, normalized(t, st, "", "", f.address()))
}
if len(f.registry) != 1 || f.pushes != 1 {
t.Errorf("the registry holds %v after %d pushes, want only the trunk build's one", f.registry, f.pushes)
}
}
@@ -0,0 +1,102 @@
package main
// The shapes of what the module's steps end on in the steps state (novox/hq ADR 0487 §1, issue 1703e), kept as fixtures in
// testdata/captured for the builder's tests (mesh-delivery-builder copies them): the stored step of a fetch, of a
// candidate's build (no publish) and of a trunk build (publish). They are the step exactly as buildstep writes it to the
// state, with what differs between runs (ids, times, the workspace, the commit, digests, the registry's address) put
// to fixed words. This unit test regenerates them from the fake runner and compares (MESH_UPDATE_CAPTURED=1 rewrites
// them); the integration test through the real bus (bus_integration_test.go) compares the steps the real bus holds with
// the same files, so a change of shape fails in both places.
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
const capturedDir = "testdata/captured"
var hexDigest = regexp.MustCompile(`sha256:[0-9a-f]{64}`)
// normalized is a stored step with the words that differ between runs put to fixed ones.
func normalized(t *testing.T, st delivery.Step, workspace, commit, registry string) []byte {
t.Helper()
raw, err := json.Marshal(st)
if err != nil {
t.Fatal(err)
}
var v map[string]any
if err := json.Unmarshal(raw, &v); err != nil {
t.Fatal(err)
}
v["id"] = "STEPID"
v["started"], v["ended"] = "2026-10-22T08:00:00Z", "2026-10-22T08:00:01Z"
out, _ := json.MarshalIndent(v, "", " ")
s := string(out)
for from, to := range map[string]string{workspace: "WORKSPACE", commit: "COMMIT", registry: "REGISTRY"} {
if from != "" {
s = strings.ReplaceAll(s, from, to)
}
}
s = hexDigest.ReplaceAllString(s, imageDigest)
// The result is stored as a JSON string of an object in some encodings: keep the file readable by decoding it once.
var back map[string]any
if err := json.Unmarshal([]byte(s), &back); err != nil {
t.Fatal(err)
}
pretty, _ := json.MarshalIndent(back, "", " ")
return append(pretty, '\n')
}
func compareCaptured(t *testing.T, name string, got []byte) {
t.Helper()
path := filepath.Join(capturedDir, name)
if os.Getenv("MESH_UPDATE_CAPTURED") == "1" {
if err := os.WriteFile(path, got, 0o644); err != nil {
t.Fatal(err)
}
return
}
want, err := os.ReadFile(path)
if err != nil {
t.Fatalf("%s: %v (MESH_UPDATE_CAPTURED=1 writes it)", path, err)
}
if string(want) != string(got) {
t.Errorf("%s differs from what the step ended on:\n--- want\n%s--- got\n%s", name, want, got)
}
}
func workspaceOf(t *testing.T, st delivery.Step) string {
var r struct {
Workspace string `json:"workspace"`
}
json.Unmarshal(st.Result, &r)
return r.Workspace
}
func TestTheStoredStepsHaveTheCapturedShapes(t *testing.T) {
_ = time.Now
// fetch
{
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
if _, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
compareCaptured(t, "fetch.json", normalized(t, st, workspaceOf(t, st), commitSHA, ""))
}
// a candidate's build and a trunk build
for name, extra := range map[string]map[string]any{"build-candidate.json": nil, "build-trunk.json": {"publish": true}} {
m, f, states := setup(t, same)
_, st := runBuild(t, m, states, extra)
compareCaptured(t, name, normalized(t, st, "", "", f.address()))
}
}
@@ -0,0 +1,76 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Issue 1703 (the live failure of the first assignment): the module is moved, so the provisioner's client on the node
// fills its templates, and it fills only config, secrets, collections, placement and what a moved provider told it.
// The registry's address therefore cannot be a ${bound:artifact-store:...} (the artifact store has not moved): it is the
// placement ${seat:mesh-artifact-store:reach}, the form the container runtime's daemon.json uses. This composes the
// module on a machine with buildah and the artifact store reachable, handing its files to the client as a node does.
func TestTheModuleComposesWhereItsFilesAreHandedToTheProvisionersClient(t *testing.T) {
built := func(m catalogue.Manifest) []catalogue.Built {
texts := map[string]string{}
entries, err := os.ReadDir(filepath.Join(moduleDir, "files"))
if err != nil {
t.Fatal(err)
}
for _, e := range entries {
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", e.Name()))
if err != nil {
t.Fatal(err)
}
texts[e.Name()] = string(raw)
}
return []catalogue.Built{
{Name: "tools", Kind: "bundle", Reference: catalogue.ArtifactStoreScheme + "mesh-build-image/tools/blobs/sha256:" + strings.Repeat("a", 64), Digest: "sha256:" + strings.Repeat("a", 64)},
{Name: "files", Kind: "archive", Reference: catalogue.ArtifactStoreScheme + "mesh-build-image/files/blobs/sha256:" + strings.Repeat("b", 64), Digest: "sha256:" + strings.Repeat("b", 64), Texts: texts},
}
}
read := func(name string) catalogue.Manifest {
m, err := catalogue.ReadTreeManifestWritten(catalogueRoot, filepath.Join(catalogueRoot, "modules", name))
if err != nil {
t.Fatalf("modules/%s: %v", name, err)
}
return m
}
image := read("mesh-build-image")
resolved, err := image.Resolve(built(image))
if err != nil {
t.Fatal(err)
}
shelf := map[string]catalogue.Manifest{
"mesh-build-image": resolved,
"buildah": read("buildah"),
"forge": {Module: "forge", Version: "1", Provides: []catalogue.Offer{{Name: "forge-account"}}},
"store": {Module: "store", Version: "1", Provides: []catalogue.Offer{{Name: "artifact-store"}}},
}
node := catalogue.Node{Name: "builder", Site: "house",
Capabilities: map[string]bool{"package-manager": true, "user-namespaces": true}}
r, err := catalogue.Resolve(shelf, []string{"forge", "store", "buildah", "mesh-build-image"}, node, catalogue.World{})
if err != nil {
t.Fatal(err)
}
r.Account = "op"
// What the resolution states for the node: the forge's provider has moved, the artifact store's has not.
r.Needs = []catalogue.Needed{
{Name: "forge-account", For: "mesh-build-image", Module: "forge", From: "novox", At: "10.7.0.1", ProviderMoved: true,
Serves: map[string]any{"known_host": "[novox.internal]:222 ssh-ed25519 AAAA", "host_key": "ssh-ed25519 AAAA", "ssh_host": "novox.internal", "ssh_alias": "mesh-forge", "ssh_port": "222", "ssh_base": "ssh://git@novox.internal:222/", "https_base": "https://git.novox.be/"}},
{Name: "artifact-store", For: "mesh-build-image", Module: "store", From: "novox", At: "10.7.0.1", Serves: map[string]any{"port": "5000"}},
}
with := catalogue.Rendering{Hands: true, Provisioner: "mesh-provisioner-client", ArtifactStore: "store:5000",
Needed: map[string]map[string]string{"mesh-build-image": {"broker": "sealed-to-the-node"}}}
c, err := r.Compose(with)
if err != nil {
t.Fatalf("the module does not compose on a machine with buildah and the artifact store: %v", err)
}
if _, asked := c.Plans["mesh-build-image"].Placement["seat:mesh-artifact-store:reach"]; !asked {
t.Errorf("the plan's placement %v does not ask where the registry is reached", c.Plans["mesh-build-image"].Placement)
}
}
@@ -0,0 +1,159 @@
package main
// The fetch step (novox/hq ADR 0487 §1, ADR 0466 §4 §5, ADR 0469 §2; issue 1703b): a commit of a repository of the
// mesh's forge, fetched over SSH as the module's own account into a workspace of its own. Nothing is downloaded
// beyond it: an image's Dockerfile fetches what it needs itself, inside the build.
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
)
var (
commitOK = regexp.MustCompile(`^[0-9a-f]{40}$`)
repositoryOK = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
workspaceOK = regexp.MustCompile(`^[a-z0-9]+$`)
)
// ForgeAlias is the ssh name the forge-account provision sets up in the account's ssh files (ADR 0409 §1); the
// remote of a fetch is ssh://<it>/<owner>/<repository>.git.
const ForgeAlias = "mesh-forge"
// WorkspaceAge is how long a workspace is kept: a module removes the workspaces older than a day, each time it
// fetches and every round it checks itself. A workspace lies in the module's work directory, never in its home
// (ADR 0466 §1).
const WorkspaceAge = 24 * time.Hour
// FetchResult is what a passed fetch step says: the workspace it made and the commit in it, and, only when the call
// gave a base, the merge preview of ADR 0484 clause 3 (the trunk commit, the merge commit of the head onto it, the
// 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.
type FetchResult struct {
Workspace string `json:"workspace"`
Commit string `json:"commit"`
Base string `json:"base,omitempty"`
Merge string `json:"merge,omitempty"`
Conflicts []string `json:"conflicts,omitempty"`
Changed []string `json:"changed,omitempty"`
Trees map[string]buildstep.ModuleTree `json:"trees,omitempty"`
}
func (m *Module) fetchTool() StepTool {
return StepTool{
Name: FetchTool,
Description: "Fetch a commit of a repository of the mesh's forge over SSH, as this module's own account, into a " +
"workspace of its own. Answers a step id at once; the step ends on its key in the steps state with the " +
"workspace's id.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"repository": map[string]any{"type": "string", "description": "<owner>/<repository> on the forge"},
"commit": map[string]any{"type": "string", "description": "the full commit, 40 hex digits"},
"ref": map[string]any{"type": "string", "description": "refs/pull/<n>/head when only a pull request's head carries the commit"},
"base": map[string]any{"type": "string", "description": "optional trunk commit, 40 hex digits: the head is merged onto it as the forge's merge would, and the answer carries the merge commit, the files in conflict (an answer, the workspace is then left at the trunk commit), the changed paths and the tree of each module directory"},
},
"required": []string{"repository", "commit"},
},
Long: true,
Run: m.fetch,
}
}
func (m *Module) fetch(ctx context.Context, args map[string]any) (any, error) {
repository, _ := args["repository"].(string)
commit, _ := args["commit"].(string)
ref, _ := args["ref"].(string)
base, _ := args["base"].(string)
if !repositoryOK.MatchString(repository) || strings.Contains(repository, "..") {
return nil, fmt.Errorf("%q is not a repository, <owner>/<name>", repository)
}
if !commitOK.MatchString(commit) {
return nil, fmt.Errorf("%q is not a full commit (40 hex digits)", commit)
}
if base != "" && !commitOK.MatchString(base) {
return nil, fmt.Errorf("%q is not a trunk commit (40 hex digits)", base)
}
m.CleanWorkspaces(m.clock())
id := buildstep.NewID()
tree := filepath.Join(m.Work, id, "tree")
if err := os.MkdirAll(tree, 0o700); err != nil {
return nil, err
}
remote := m.remote(repository)
if err := buildstep.FetchCommit(ctx, m.Run, tree, remote, commit, ref, m.FetchOptions...); err != nil {
return nil, err
}
res := FetchResult{Workspace: id, Commit: commit}
if base != "" {
if err := m.merge(ctx, &res, tree, remote, base, commit); err != nil {
return nil, err
}
}
return res, nil
}
// remote is the address a repository is fetched from: the forge over ssh as the account, or RemoteBase when a test set it.
func (m *Module) remote(repository string) string {
base := m.RemoteBase
if base == "" {
base = "ssh://" + ForgeAlias + "/"
}
return base + repository + ".git"
}
// merge makes the merge preview of the head onto base (the SDK's buildstep.MergeInto, shared by every build module)
// and writes the answer into res.
func (m *Module) merge(ctx context.Context, res *FetchResult, tree, remote, base, commit string) error {
mr, err := buildstep.MergeInto(ctx, m.Run, tree, remote, base, commit, m.FetchOptions...)
if err != nil {
return err
}
res.Base = mr.Base
res.Conflicts, res.Merge, res.Changed = mr.Conflicts, mr.Merge, mr.Changed
if res.Merge == "" {
return nil
}
res.Trees, err = buildstep.ModuleTrees(ctx, m.Run, nil, tree, res.Merge)
return err
}
// CleanWorkspaces removes the workspaces older than WorkspaceAge from the work directory.
func (m *Module) CleanWorkspaces(now time.Time) {
entries, err := os.ReadDir(m.Work)
if err != nil {
return
}
for _, e := range entries {
info, err := e.Info()
if err != nil || !e.IsDir() || !workspaceOK.MatchString(e.Name()) || now.Sub(info.ModTime()) < WorkspaceAge {
continue
}
if err := os.RemoveAll(filepath.Join(m.Work, e.Name())); err != nil && m.Say != nil {
m.Say("the workspace %s could not be removed: %v", e.Name(), err)
}
}
}
// clock is the time now: Now when a test set it, else the wall clock.
func (m *Module) clock() time.Time {
if m.Now != nil {
return m.Now()
}
return time.Now()
}
func firstLine(s string) string {
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
return l
}
}
return ""
}
@@ -0,0 +1,67 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// 1703b: the fetch step asks git, through the SDK's FetchCommit, for the ssh remote of the forge as the account, makes
// the workspace's tree in the work directory and answers its id and the commit; it downloads nothing else.
func TestFetchAsksGitForTheSshRemoteIntoAWorkspace(t *testing.T) {
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
ans, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA, "ref": "refs/pull/7/head"})
if err != nil {
t.Fatal(err)
}
if a := ans.(map[string]any); a["state"] != delivery.StepRunning || a["step"] == "" {
t.Errorf("the call answered %v, want a running step", a)
}
st := states.waitEnd(t)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
var res FetchResult
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
if res.Commit != commitSHA || res.Workspace == "" || res.Merge != "" {
t.Errorf("result = %+v", res)
}
if info, err := os.Stat(filepath.Join(m.Work, res.Workspace, "tree")); err != nil || !info.IsDir() {
t.Errorf("no tree in the workspace: %v", err)
}
joined := strings.Join(f.lines(), "\n")
if !strings.Contains(joined, "ssh://mesh-forge/novox/mesh-catalog.git") || !strings.Contains(joined, commitSHA) {
t.Errorf("git was not asked for the forge's ssh remote and the commit:\n%s", joined)
}
for _, l := range f.lines() {
if strings.Contains(l, "buildah bud") || strings.HasPrefix(l, "go ") {
t.Errorf("a fetch ran %q", l)
}
}
}
func TestFetchRefusesWhatIsNotARepositoryOrACommit(t *testing.T) {
for name, args := range map[string]map[string]any{
"repository": {"repository": "../etc", "commit": commitSHA},
"commit": {"repository": "novox/mesh-catalog", "commit": "main"},
"base": {"repository": "novox/mesh-catalog", "commit": commitSHA, "base": "trunk"},
} {
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
if _, err := tool(t, m, FetchTool)(args); err != nil {
t.Fatal(err)
}
if st := states.waitEnd(t); st.State != delivery.StepFailed {
t.Errorf("%s: step = %+v, want failed", name, st)
}
}
}
@@ -0,0 +1,258 @@
package main
// The forge side of a build module (novox/hq ADR 0466 §2 and §4, issue 1484). This file is the same for every build
// module: mesh-build-node takes a copy and changes the three constants below.
import (
"context"
"encoding/base64"
"encoding/binary"
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
const (
forgeModule = "mesh-build-image"
forgeHome = "/var/lib/mesh-build-image"
forgeModuleD = "../.." // the module's directory, where module.json and files/ are
)
type forgeManifest struct {
Moved bool `json:"moved"`
Requires []any `json:"requires"`
State []any `json:"state"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readForgeManifest(t *testing.T) forgeManifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join(forgeModuleD, "module.json"))
if err != nil {
t.Fatal(err)
}
var m forgeManifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m
}
// The forge files are filled on the node by the provisioner's client, never by the controller (ADR 0466 §4, ADR 0400
// §5, issue 1537): the forge serves a module's entry only after the module has run and reported its key (ADR 0400
// §2), so a controller filling them would refuse the machine until then, and the module could never run.
func TestTheForgeFilesAreFilledOnTheNodeSoTheModuleCanRunBeforeItsEntry(t *testing.T) {
if !readForgeManifest(t).Moved {
t.Errorf("%s's manifest does not say \"moved\": true, so the controller fills its forge files and refuses "+
"every machine it is assigned to until the forge serves an entry it makes only after the module ran", forgeModule)
}
}
func forgeTemplate(t *testing.T, r map[string]any) string {
t.Helper()
name, _ := r["template"].(string)
if name == "" {
t.Fatalf("resource %v names no template", r["id"])
}
raw, err := os.ReadFile(filepath.Join(forgeModuleD, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("resource %v names %s: %v", r["id"], name, err)
}
return string(raw)
}
func forgeResource(t *testing.T, m forgeManifest, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
if r["owner"] != forgeModule {
t.Errorf("%s is owned by %v, not the module's own account", id, r["owner"])
}
if p, _ := r["path"].(string); !strings.HasPrefix(p, "${dir:") || strings.Contains(p, "account-home") {
t.Errorf("%s is at %q: a path of the account's own home (${dir:...}), never the operator's", id, p)
}
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
var forgeSample = map[string]string{
"known_host": "[novox.internal]:222 ssh-ed25519 AAAAC3Nza",
"host_key": "ssh-ed25519 AAAAC3Nza",
"ssh_host": "novox.internal",
"ssh_alias": "mesh-forge",
"ssh_port": "222",
"ssh_base": "ssh://git@novox.internal:222/",
"https_base": "https://git.novox.be/",
}
// forgeRender fills ${bound:forge-account:<key>} with the sample values, as the provisioner client does.
func forgeRender(t *testing.T, s string) string {
t.Helper()
for k, v := range forgeSample {
s = strings.ReplaceAll(s, "${bound:forge-account:"+k+"}", v)
}
if strings.Contains(s, "${") {
t.Errorf("unfilled placeholder in %q", s)
}
return s
}
// 1484/AC1: given sample bound values, the ssh config has Host mesh-forge, IdentitiesOnly yes and the key under the
// module's own home; known_hosts carries the host key; git's config rewrites https_base to ssh://mesh-forge/; no
// ${ is left.
func TestForgeFilesRenderFromTheBoundValuesInTheModulesOwnHome(t *testing.T) {
m := readForgeManifest(t)
cfg := forgeResource(t, m, "ssh-config")
want := "Host mesh-forge\n HostName novox.internal\n Port 222\n HostKeyAlias mesh-forge\n User git\n IdentityFile ~/.ssh/mesh_ed25519\n IdentitiesOnly yes\n"
if got := forgeRender(t, forgeTemplate(t, cfg)); got != want {
t.Errorf("ssh config = %q, want %q", got, want)
}
if cfg["path"] != "${dir:ssh-dir}/config" || cfg["mode"] != "0600" || cfg["into"] != nil {
t.Errorf("ssh config resource = %v, want the whole file ${dir:ssh-dir}/config at mode 0600", cfg)
}
kh := forgeResource(t, m, "known-hosts")
if got := strings.TrimSpace(forgeRender(t, forgeTemplate(t, kh))); got != "mesh-forge ssh-ed25519 AAAAC3Nza" {
t.Errorf("known_hosts = %q", got)
}
g := forgeResource(t, m, "git-config")
wantGit := "[url \"ssh://mesh-forge/\"]\n\tinsteadOf = https://git.novox.be/\n"
if got := forgeRender(t, forgeTemplate(t, g)); got != wantGit {
t.Errorf("git config = %q, want %q", got, wantGit)
}
dir := forgeResource(t, m, "ssh-dir")
if dir["mode"] != "0700" || dir["path"] != "${dir:state}/.ssh" {
t.Errorf("ssh-dir = %v, want ${dir:state}/.ssh at mode 0700", dir)
}
}
// 1484/AC2: the module requires forge-account, keeps the state keys, and every file resource takes its text from a
// template in the files/ archive (novox/hq ADR 0349 §2).
func TestManifestRequiresForgeAccountKeepsKeysAndTakesFilesFromTemplates(t *testing.T) {
m := readForgeManifest(t)
if want := []any{"forge-account", "rootless-image-build", "artifact-store"}; !reflect.DeepEqual(m.Requires, want) {
t.Errorf("requires = %v, want %v", m.Requires, want)
}
keeps := false
for _, s := range m.State {
if s == "keys" {
keeps = true
}
}
if !keeps {
t.Errorf("state = %v lacks keys", m.State)
}
files := 0
for _, r := range m.Resources {
if _, inline := r["content"]; inline {
t.Errorf("resource %v keeps its text inline", r["id"])
}
if r["type"] == "file" {
files++
forgeTemplate(t, r)
}
}
if files == 0 {
t.Errorf("no file resource: the forge's ssh files are missing")
}
packs := 0
for _, a := range m.Build.Artifacts {
if a["kind"] == "archive" && a["from"] == "files" && a["name"] == "files" {
packs++
}
}
if packs != 1 {
t.Errorf("the build packs files/ into %d archives named files, not one", packs)
}
}
// forgeEd25519Line is a public key line with a well-formed ed25519 blob.
func forgeEd25519Line(comment string) string {
blob := []byte{}
for _, part := range [][]byte{[]byte("ssh-ed25519"), make([]byte, 32)} {
l := make([]byte, 4)
binary.BigEndian.PutUint32(l, uint32(len(part)))
blob = append(append(blob, l...), part...)
}
return "ssh-ed25519 " + base64.StdEncoding.EncodeToString(blob) + " " + comment
}
type forgeKeys struct{ puts map[string][]byte }
func (f *forgeKeys) Put(key string, value any) (uint64, error) {
raw, _ := json.Marshal(value)
if f.puts == nil {
f.puts = map[string][]byte{}
}
f.puts[key] = raw
return uint64(len(f.puts)), nil
}
// forgeKeygen stands in for ssh-keygen: it writes the pair the command names.
func forgeKeygen(_ context.Context, _ []string, name string, args ...string) forgekey.Ran {
var file, comment string
for i, a := range args {
switch a {
case "-f":
file = args[i+1]
case "-C":
comment = args[i+1]
}
}
if name != "ssh-keygen" || file == "" {
return forgekey.Ran{Status: 1, Stderr: "unexpected"}
}
os.WriteFile(file, []byte("PRIVATE\n"), 0o600)
os.WriteFile(file+".pub", []byte(forgeEd25519Line(comment)+"\n"), 0o644)
return forgekey.Ran{}
}
// 1484/AC3: given the bundle started with an empty home, the keeper writes <node>.<module> with kind builder, and
// the key is in the home's .ssh.
func TestTheKeeperWritesTheModulesEntryWithKindBuilder(t *testing.T) {
home, st := t.TempDir(), &forgeKeys{}
if err := keeperFor(home, "ace", forgeModule, forgeKeygen, st).Once(context.Background()); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(home, ".ssh", "mesh_ed25519")); err != nil {
t.Errorf("no key in an empty home: %v", err)
}
raw, ok := st.puts["ace."+forgeModule]
if !ok || len(st.puts) != 1 {
t.Fatalf("entries = %v, want one at ace.%s", st.puts, forgeModule)
}
var e forgekey.KeyEntry
if err := json.Unmarshal(raw, &e); err != nil {
t.Fatal(err)
}
if e.Kind != "builder" || e.Node != "ace" || e.Account != forgeModule || !strings.HasPrefix(e.Public, "ssh-ed25519 ") {
t.Errorf("entry = %s", raw)
}
if strings.Contains(strings.ToUpper(string(raw)), "PRIVATE") {
t.Errorf("the entry carries private material: %s", raw)
}
}
// The home the manifest places is the one the account is given, so the keeper's home and the templates' agree.
func TestTheAccountsHomeIsTheOneTheManifestNames(t *testing.T) {
m := readForgeManifest(t)
for _, r := range m.Resources {
if r["type"] == "user" && r["name"] == forgeModule && r["home"] != forgeHome {
t.Errorf("the account's home is %v, want %s", r["home"], forgeHome)
}
}
}
@@ -0,0 +1,208 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
const commitSHA = "0123456789abcdef0123456789abcdef01234567"
// keptStates is the steps state, kept in memory: every write of a key, and the keys that ended.
type keptStates struct {
mu sync.Mutex
steps map[string][]delivery.Step
ended chan string
}
func newStates() *keptStates {
return &keptStates{steps: map[string][]delivery.Step{}, ended: make(chan string, 16)}
}
func (s *keptStates) putStep(key string, value any) (uint64, error) {
st, err := delivery.DecodeStep(value.(json.RawMessage))
if err != nil {
return 0, err
}
s.mu.Lock()
s.steps[key] = append(s.steps[key], st)
s.mu.Unlock()
if st.State != delivery.StepRunning {
s.ended <- key
}
return 1, nil
}
func (s *keptStates) waitEnd(t *testing.T) delivery.Step {
t.Helper()
select {
case key := <-s.ended:
s.mu.Lock()
defer s.mu.Unlock()
v := s.steps[key]
return v[len(v)-1]
case <-time.After(5 * time.Second):
t.Fatal("the step never ended on its key")
return delivery.Step{}
}
}
// cmdRecord is one command a step ran.
type cmdRecord struct {
env []string
name string
line string
}
// fake stands in for git and buildah at the process boundary and for the registry over HTTP.
type fake struct {
mu sync.Mutex
calls []cmdRecord
digest string // what `buildah images` prints
pushes int
refuse int // the first n pushes answer "connection refused"
failBud string
// digestFile: "" writes the digest, "none" writes no file, "empty" writes an empty one.
digestFile string
// registry: repository:tag -> digest
registry map[string]string
server *httptest.Server
}
const imageDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111"
func newFake(t *testing.T) *fake {
f := &fake{digest: imageDigest, registry: map[string]string{}}
f.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
key := strings.TrimPrefix(r.URL.Path, "/v2/")
i := strings.Index(key, "/manifests/")
if r.Method != http.MethodHead || i < 0 {
http.NotFound(w, r)
return
}
d, ok := f.registry[key[:i]+":"+key[i+len("/manifests/"):]]
if !ok {
http.NotFound(w, r)
return
}
w.Header().Set("Docker-Content-Digest", d)
}))
t.Cleanup(f.server.Close)
return f
}
func (f *fake) address() string { return f.server.Listener.Addr().String() }
func (f *fake) run(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
line := name + " " + strings.Join(args, " ")
f.mu.Lock()
f.calls = append(f.calls, cmdRecord{env: env, name: name, line: line})
f.mu.Unlock()
switch {
case name == "git" && strings.Contains(line, " rev-parse HEAD"):
return forgekey.Ran{Stdout: commitSHA + "\n"}
case name == "buildah" && strings.Contains(line, "--version"):
return forgekey.Ran{Stdout: "buildah version 1.45.1 (image-spec 1.1.0, runtime-spec 1.1.0)\n"}
case name == "buildah" && strings.Contains(line, " bud "):
if f.failBud != "" {
return forgekey.Ran{Status: 1, Stderr: "STEP 1/2: FROM x\n" + f.failBud + "\n"}
}
case name == "buildah" && strings.Contains(line, " images "):
return forgekey.Ran{Stdout: f.digest + "\n"}
case name == "buildah" && strings.Contains(line, " push "):
f.mu.Lock()
defer f.mu.Unlock()
if f.refuse > 0 {
f.refuse--
return forgekey.Ran{Status: 125, Stderr: "Error: dial tcp 127.0.0.1:5000: connect: connection refused\n"}
}
f.pushes++
var digestFile, dest string
for i, a := range args {
if a == "--digestfile" {
digestFile = args[i+1]
}
if strings.HasPrefix(a, "docker://") {
dest = strings.TrimPrefix(a, "docker://")
}
}
dest = strings.TrimPrefix(dest, f.address()+"/")
f.registry[dest] = f.digest
switch f.digestFile {
case "":
os.WriteFile(digestFile, []byte(f.digest), 0o644)
case "empty":
os.WriteFile(digestFile, nil, 0o644)
}
}
return forgekey.Ran{}
}
func (f *fake) lines() []string {
f.mu.Lock()
defer f.mu.Unlock()
out := []string{}
for _, c := range f.calls {
out = append(out, c.line)
}
return out
}
func (f *fake) called(verb string) []cmdRecord {
f.mu.Lock()
defer f.mu.Unlock()
var out []cmdRecord
for _, c := range f.calls {
if c.name == "buildah" && strings.Contains(c.line, " "+verb+" ") {
out = append(out, c)
}
}
return out
}
// moduleWith is a module on node ace over the fake, with subordinate ranges for its account, and the registry's
// address written where the manifest would bind it.
func moduleWith(t *testing.T, f *fake, states *keptStates) *Module {
t.Helper()
dir := t.TempDir()
write := func(name, body string) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return p
}
m := &Module{
Node: "ace", Account: "mesh-build-image", UID: "1234", Home: t.TempDir(), Work: t.TempDir(), Store: t.TempDir(),
Steps: states.putStep, Ctx: context.Background(), Run: f.run, HTTP: f.server.Client(),
SubUIDFile: write("subuid", "someone:100000:65536\nmesh-build-image:624288:65536\n"),
SubGIDFile: write("subgid", "mesh-build-image:624288:65536\n"),
RegistryFile: write("registry", f.address()+"\n"),
RegistryWait: 2 * time.Second,
RegistryPause: 5 * time.Millisecond,
}
return m
}
func tool(t *testing.T, m *Module, name string) func(map[string]any) (any, error) {
t.Helper()
for _, tl := range m.Tools(m.StepTools()) {
if tl.Name == name {
return tl.Run
}
}
t.Fatalf("no tool %s", name)
return nil
}
@@ -0,0 +1,219 @@
//go:build integration
package main
// The image build module against real git and real buildah (novox/hq ADR 0487 §1, §2, §6; issue 1703e). They start
// processes, so they carry the tag; the unit tests prove the commands with a fake runner. A test whose tools are not
// installed where the check runs skips, loudly: the mesh's checker has git but not buildah until buildah is delivered
// to its node, so the rootless isolation of §2 and §6 is proven live after the delivery (the issue says so).
import (
"context"
"encoding/json"
"os"
"os/exec"
"os/user"
"path/filepath"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
func needTools(t *testing.T, what string, tools ...string) {
t.Helper()
for _, tool := range tools {
if _, err := exec.LookPath(tool); err != nil {
t.Skipf("NOT RUN: no %s installed where this check runs (%s); proven live on a build node", tool, what)
}
}
}
func git(t *testing.T, dir string, args ...string) string {
t.Helper()
cmd := exec.Command("git", append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@example.invalid", "-c", "commit.gpgsign=false"}, args...)...)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
// realModule is a module over the real runner, a local directory standing in for the forge and the account running the test.
func realModule(t *testing.T) (*Module, *keptStates) {
t.Helper()
states := newStates()
u, err := user.Current()
if err != nil {
t.Fatal(err)
}
home := t.TempDir()
m := &Module{Node: "ace", Account: u.Username, UID: u.Uid, Home: home, Work: t.TempDir(), Store: t.TempDir(),
Steps: states.putStep, Ctx: context.Background(), Say: t.Logf, Run: StepRunner(home),
FetchOptions: []buildstep.Option{buildstep.AllowFileRemote()}}
return m, states
}
// The fetch step on a real repository: the workspace holds the commit's tree, with the Dockerfile in it, and a base
// makes the merge preview.
func TestFetchOnARealRepository(t *testing.T) {
needTools(t, "the fetch step on a real repository (every step is guarded by buildah and a user namespace)", "git", "buildah", "newuidmap")
m, states := realModule(t)
root := t.TempDir()
bare, work := filepath.Join(root, "forge", "novox", "demo.git"), filepath.Join(root, "work")
for _, d := range []string{bare, work} {
os.MkdirAll(d, 0o755)
}
git(t, bare, "init", "-q", "--bare")
git(t, work, "init", "-q", "-b", "main")
os.MkdirAll(filepath.Join(work, "modules", "demo"), 0o755)
os.WriteFile(filepath.Join(work, "modules", "demo", "Dockerfile"), []byte("FROM scratch\n"), 0o644)
os.WriteFile(filepath.Join(work, "modules", "demo", "module.json"), []byte(`{"module":"demo","version":"1"}`), 0o644)
git(t, work, "add", ".")
git(t, work, "commit", "-q", "-m", "one")
git(t, work, "push", "-q", bare, "HEAD:refs/heads/main")
base := git(t, work, "rev-parse", "HEAD")
os.WriteFile(filepath.Join(work, "modules", "demo", "hello.txt"), []byte("hi\n"), 0o644)
git(t, work, "add", ".")
git(t, work, "commit", "-q", "-m", "two")
head := git(t, work, "rev-parse", "HEAD")
git(t, work, "push", "-q", bare, "HEAD:refs/pull/1/head")
m.RemoteBase = "file://" + filepath.Join(root, "forge") + "/"
if _, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/demo", "commit": head, "ref": "refs/pull/1/head", "base": base}); err != nil {
t.Fatal(err)
}
st := waitLong(t, states)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
var res FetchResult
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
if res.Commit != head || res.Base != base || res.Merge == "" || len(res.Conflicts) != 0 {
t.Errorf("result = %+v, want the head merged cleanly onto the base", res)
}
if _, err := os.Stat(filepath.Join(m.Work, res.Workspace, "tree", "modules", "demo", "Dockerfile")); err != nil {
t.Errorf("the Dockerfile is not in the workspace: %v", err)
}
}
func waitLong(t *testing.T, s *keptStates) delivery.Step {
t.Helper()
select {
case key := <-s.ended:
s.mu.Lock()
defer s.mu.Unlock()
v := s.steps[key]
return v[len(v)-1]
case <-time.After(5 * time.Minute):
t.Fatal("the step never ended on its key")
return delivery.Step{}
}
}
// imageModule writes a workspace "ws1" holding the module "modules/img" with one image artifact and this Dockerfile.
func imageModule(t *testing.T, m *Module, dockerfile string, files map[string]string) {
t.Helper()
dir := filepath.Join(m.Work, "ws1", "tree", "modules", "img")
os.MkdirAll(dir, 0o755)
os.WriteFile(filepath.Join(dir, "module.json"), []byte(`{"module":"img","version":"1","build":{"artifacts":[{"name":"server","kind":"image","from":"Dockerfile"}]}}`), 0o644)
os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte(dockerfile), 0o644)
for n, c := range files {
os.MkdirAll(filepath.Dir(filepath.Join(dir, n)), 0o755)
os.WriteFile(filepath.Join(dir, n), []byte(c), 0o644)
}
}
func buildReal(t *testing.T, m *Module, states *keptStates) (BuildResult, delivery.Step) {
t.Helper()
if _, err := tool(t, m, BuildTool)(map[string]any{"workspace": "ws1", "module": "modules/img", "artifact": "server"}); err != nil {
t.Fatal(err)
}
st := waitLong(t, states)
var res BuildResult
if st.State == delivery.StepPassed {
if err := json.Unmarshal(st.Result, &res); err != nil {
t.Fatal(err)
}
}
return res, st
}
// A Dockerfile that adds a file to scratch builds without any network, and the step answers the manifest digest.
func TestARealBuildAnswersTheImagesDigest(t *testing.T) {
needTools(t, "a rootless build", "buildah", "newuidmap")
m, states := realModule(t)
imageModule(t, m, "FROM scratch\nCOPY hello.txt /hello.txt\n", map[string]string{"hello.txt": "hello\n"})
res, st := buildReal(t, m, states)
if st.State != delivery.StepPassed || !digestOK.MatchString(res.Digest) {
t.Fatalf("step = %+v result = %+v", st, res)
}
}
// needBase skips unless the build node can pull the small base the isolation Dockerfiles run in.
func needBase(t *testing.T, m *Module) string {
t.Helper()
const base = "docker.io/library/busybox:1.36"
if err := os.MkdirAll(filepath.Join(m.Store, "xdg"), 0o700); err != nil {
t.Fatal(err)
}
r := m.Run(context.Background(), m.env(), "buildah", append(m.storeFlags(), "pull", base)...)
if r.Status != 0 {
t.Skipf("NOT RUN: the base %s cannot be pulled where this check runs (%s); proven live on a build node", base, firstLine(r.Stderr+r.Err))
}
return base
}
// ADR 0487 §2: the build's process has a non-zero user outside its namespace, no container runtime socket is among its
// mounts, and a step that tries to write outside its workspace fails.
func TestARealBuildIsRootlessWithNoSocketAndNoWayOut(t *testing.T) {
needTools(t, "the rootless isolation", "buildah", "newuidmap")
m, states := realModule(t)
base := needBase(t, m)
// The host side: the namespace the module makes maps the namespace's root to an id other than 0.
r := m.Run(context.Background(), m.env(), "buildah", append(m.storeFlags(), "unshare", "cat", "/proc/self/uid_map")...)
if f := strings.Fields(r.Stdout); r.Status != 0 || len(f) < 3 || f[0] != "0" || f[1] == "0" {
t.Errorf("uid_map in the module's namespace = %q (%s), want 0 mapped to a non-zero id", r.Stdout, r.Stderr)
}
// The inside: a RUN step sees root mapped to a non-zero outer id, and no runtime socket anywhere in its mounts.
imageModule(t, m, "FROM "+base+"\n"+
"RUN awk 'NR==1 { exit ($2 == 0) }' /proc/self/uid_map\n"+
"RUN ! ls /var/run/docker.sock /run/docker.sock /run/podman/podman.sock /run/containerd/containerd.sock 2>/dev/null\n"+
"RUN ! grep -E 'docker.sock|podman.sock|containerd.sock' /proc/self/mountinfo\n", nil)
if _, st := buildReal(t, m, states); st.State != delivery.StepPassed {
t.Errorf("the isolation checks inside the build failed: %+v", st)
}
// A step that writes outside its workspace fails: the context cannot be left, and the host's kernel files are not writable.
imageModule(t, m, "FROM "+base+"\nCOPY ../outside /outside\n", nil)
if _, st := buildReal(t, m, states); st.State != delivery.StepFailed {
t.Errorf("a COPY out of the context passed: %+v", st)
}
imageModule(t, m, "FROM "+base+"\nRUN echo b > /proc/sysrq-trigger\n", nil)
if _, st := buildReal(t, m, states); st.State != delivery.StepFailed {
t.Errorf("a write to the host's /proc/sysrq-trigger passed: %+v", st)
}
}
// ADR 0487 §5, §6: a step that looks for the account's credentials finds nothing: the key in the home's .ssh is in no
// mount of the build.
func TestARealBuildFindsNoCredentialsOfTheAccount(t *testing.T) {
needTools(t, "the credential paths", "buildah", "newuidmap")
m, states := realModule(t)
base := needBase(t, m)
os.MkdirAll(filepath.Join(m.Home, ".ssh"), 0o700)
os.WriteFile(filepath.Join(m.Home, ".ssh", "mesh_ed25519"), []byte("PRIVATE\n"), 0o600)
imageModule(t, m, "FROM "+base+"\n"+
"RUN ! find / -xdev -name mesh_ed25519 2>/dev/null | grep .\n"+
"RUN ! test -e /root/.ssh\n"+
"RUN ! env | grep -E 'REGISTRY_AUTH|MESH_|SSH_AUTH'\n", nil)
if _, st := buildReal(t, m, states); st.State != delivery.StepPassed {
t.Errorf("a step found a credential path of the account: %+v", st)
}
}
@@ -0,0 +1,42 @@
package main
// The module's own forge key (novox/hq ADR 0466 §2, issue 1484): mesh-sdk's key keeper, kind builder, for the
// module's account in its home. It makes ~/.ssh/mesh_ed25519 when it is missing and writes one entry to this
// module's `keys` state at <node>.<account>, which gitea's provider reads to register the key on the forge user
// that reads and pushes nothing (ADR 0466 §3).
import (
"context"
"fmt"
"os"
"os/user"
"strings"
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// keeperFor is this module's keeper: go/forgekey's, of kind builder, for account in home on node, whose entry lies
// at <node>.<account> of the keys state.
func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper {
return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindBuilder, Run: run, State: state, Now: time.Now}
}
// startKeeper runs the keeper when the runtime launched this process for a node: outside a launch — a build, a
// check — there is no state to put the entry in.
func startKeeper(ctx context.Context) {
node := strings.TrimSpace(os.Getenv("MESH_NODE"))
u, err := user.Current()
if node == "" || err != nil || u.HomeDir == "" {
return
}
k := keeperFor(u.HomeDir, node, u.Username, forgekey.ExecRunner, keysState{})
k.Keep(ctx, func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-build-image] "+f+"\n", a...) })
}
type keysState struct{}
func (keysState) Put(key string, value any) (uint64, error) {
return stdio.State("keys").Put(key, value)
}
@@ -0,0 +1,54 @@
// mesh-build-image: the image build module (novox/hq ADR 0487, ADR 0466 §1, ADR 0469; design 47 R4c-9, issue 1703). A
// tools bundle the node's runtime launches as the module's own account (runs-as). It builds a module's `image`
// artifact from the Dockerfile in the module's own directory with buildah, rootless, in a user namespace of its
// account and with no container runtime socket, and pushes a merged trunk commit's image to the registry from its
// own process once the build has ended. It refuses a step only when buildah cannot be run or the account has no
// subordinate ranges, and its health is then unhealthy. What it says, it says on stderr; stdout is the MCP channel.
package main
import (
"context"
"fmt"
"os"
"os/user"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The directories the module's manifest places outside its home (ADR 0466 §1): the workspaces, and buildah's image
// store, so nothing of the home is in a build.
const (
WorkDir = "/var/lib/mesh-build-image-work"
StoreDir = "/var/lib/mesh-build-image-store"
)
func logf(format string, a ...any) { fmt.Fprintf(os.Stderr, "[mesh-build-image] "+format+"\n", a...) }
func main() {
ctx := context.Background()
m := &Module{
Node: strings.TrimSpace(os.Getenv("MESH_NODE")),
Steps: stdio.State(delivery.StepsState).Put,
ReadSteps: readOf(stdio.State(delivery.StepsState)),
Ctx: ctx,
Say: logf,
Work: WorkDir,
Store: StoreDir,
}
if u, err := user.Current(); err == nil {
m.Home, m.Account, m.UID = u.HomeDir, u.Username, u.Uid
}
m.RegistryFile = m.Home + "/registry"
m.Run = StepRunner(m.Home)
// Outside a launch by the runtime — a build, a check — there is no node and no state to write.
if m.Node != "" {
go m.Start(ctx)
}
go startKeeper(ctx)
if err := stdio.Serve("", m.Tools(m.StepTools())); err != nil {
logf("%v", err)
os.Exit(1)
}
}
@@ -0,0 +1,191 @@
package main
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// The catalogue's root and this module's directory.
const (
catalogueRoot = "../../../.."
moduleDir = "../.."
)
func readManifest(t *testing.T) catalogue.Manifest {
t.Helper()
m, err := catalogue.ReadTreeManifest(catalogueRoot, moduleDir)
if err != nil {
t.Fatalf("mesh-sdk's validator refuses module.json: %v", err)
}
return m
}
func resource(t *testing.T, m catalogue.Manifest, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("module.json declares no resource %q", id)
return nil
}
// 1703a: the validator accepts the module (alone on a shelf too), it runs as mesh-build-image with its home at mode
// 0700, lists user-namespaces, and declares git and openssh; buildah is not its package (the buildah module's alone).
func TestManifestIsValidAndDeclaresTheAccountTheCapabilityAndThePackages(t *testing.T) {
m := readManifest(t)
if problems := catalogue.CatalogueProblems(catalogue.Shelf{m.Module: m}); len(problems) > 0 {
t.Errorf("the catalogue's rules refuse it: %v", problems)
}
if m.Module != "mesh-build-image" || m.RunsAs != "mesh-build-image" {
t.Errorf("module %q runs as %q, want mesh-build-image as itself", m.Module, m.RunsAs)
}
account := resource(t, m, "account")
if account["type"] != "user" || account["name"] != "mesh-build-image" || account["shell"] != "/usr/bin/nologin" || account["home"] != "/var/lib/mesh-build-image" {
t.Errorf("account = %v", account)
}
home := resource(t, m, "state")
if home["type"] != "directory" || home["mode"] != "0700" || home["owner"] != "mesh-build-image" || home["place"] != "." {
t.Errorf("home = %v, want its directory at mode 0700 owned by the account", home)
}
for _, c := range []string{"user-namespaces", "package-manager"} {
if !slices.Contains(m.Capabilities, c) {
t.Errorf("capabilities = %v, want %s", m.Capabilities, c)
}
}
packages := []string{}
for _, r := range m.Resources {
if r["type"] == "package" {
packages = append(packages, r["package"].(string))
}
}
slices.Sort(packages)
if want := []string{"git", "openssh"}; !slices.Equal(packages, want) {
t.Errorf("packages = %v, want %v (buildah is the buildah module's, ADR 0487 §3)", packages, want)
}
}
// The work and store directories are outside the home (ADR 0466 §1), so nothing of the home is in a build; owned by the
// account at mode 0700; and the constants of main.go are the manifest's paths.
func TestWorkAndStoreDirectoriesLieOutsideTheHome(t *testing.T) {
m := readManifest(t)
for id, want := range map[string]string{"work": WorkDir, "store": StoreDir} {
r := resource(t, m, id)
path, _ := r["path"].(string)
if path != want {
t.Errorf("%s is at %q, the program uses %q", id, path, want)
}
if path == "" || path == "/var/lib/mesh-build-image" || strings.HasPrefix(path, "/var/lib/mesh-build-image/") {
t.Errorf("%s is at %q, inside or at the home", id, path)
}
if r["owner"] != "mesh-build-image" || r["mode"] != "0700" {
t.Errorf("%s = %v, want owned by the account at mode 0700", id, r)
}
}
}
// The states are the two the module keeps, no seat is claimed, the health is its own tool, which the manifest also lists,
// and the registry's address is bound into a file of the home that the program reads.
func TestManifestKeepsItsStatesClaimsNoSeatAndDeclaresItsHealthTool(t *testing.T) {
m := readManifest(t)
states := []string{}
for _, s := range m.State {
states = append(states, s.Name)
}
slices.Sort(states)
if want := []string{"keys", "steps"}; !slices.Equal(states, want) {
t.Errorf("state = %v, want %v", states, want)
}
if len(m.Claims) != 0 {
t.Errorf("claims = %v, want no seat", m.Claims)
}
raw, _ := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if !strings.Contains(string(raw), `"tool": "`+HealthTool+`"`) {
t.Errorf("runtime-health does not name %s", HealthTool)
}
reg := resource(t, m, "registry")
if reg["path"] != "${dir:state}/registry" {
t.Errorf("registry file = %v, want ${dir:state}/registry (the program reads <home>/registry)", reg)
}
tpl, _ := os.ReadFile(filepath.Join(moduleDir, "files", "registry"))
if strings.TrimSpace(string(tpl)) != "${seat:mesh-artifact-store:reach}" {
t.Errorf("registry template = %q", tpl)
}
}
// Every tool the manifest names is served, and every tool served is named.
func TestTheToolsServedAreTheToolsTheManifestNames(t *testing.T) {
m := readManifest(t)
served := []string{}
for _, tl := range (&Module{}).Tools((&Module{}).StepTools()) {
served = append(served, tl.Name)
}
slices.Sort(served)
named := slices.Clone(m.Tools)
slices.Sort(named)
if !slices.Equal(served, named) {
t.Errorf("served %v, manifest names %v", served, named)
}
}
// 1703f: gitea's manifest reads the keys state of mesh-build-image beside the other build modules', and the catalogue's
// validator accepts it with the owners present.
func TestGiteaReadsTheImageBuildModulesKeys(t *testing.T) {
var all []catalogue.Manifest
var gitea catalogue.Manifest
for _, name := range []string{"gitea", "mesh-build-go", "mesh-build-node", "mesh-build-image"} {
m, err := catalogue.ReadTreeManifest(catalogueRoot, filepath.Join(catalogueRoot, "modules", name))
if err != nil {
t.Fatalf("mesh-sdk's validator refuses modules/%s/module.json: %v", name, err)
}
all = append(all, m)
if name == "gitea" {
gitea = m
}
}
for _, want := range []string{"mesh-build-go.keys", "mesh-build-node.keys", "mesh-build-image.keys"} {
if !slices.Contains(gitea.Reads, want) {
t.Errorf("gitea reads %v, lacks %s", gitea.Reads, want)
}
}
if problems := catalogue.StateReadsNothingDeclares(all); len(problems) > 0 {
t.Errorf("a read names a state its owner does not keep: %v", problems)
}
}
// 1704b: over the real manifests, mesh-build-image where no module provides rootless-image-build is refused, the error
// naming the provision, the module that wants it and the module to assign (buildah); with buildah it composes. The
// forge and the registry, which other modules provide for the whole mesh, are stood in for.
func TestACompositionWithoutBuildahIsRefusedNamingItAndWithBuildahComposes(t *testing.T) {
shelf := map[string]catalogue.Manifest{
"forge": {Module: "forge", Version: "1", Provides: []catalogue.Offer{{Name: "forge-account"}}},
"store": {Module: "store", Version: "1", Provides: []catalogue.Offer{{Name: "artifact-store"}}},
}
for _, name := range []string{"mesh-build-image", "buildah", "docker"} {
m, err := catalogue.ReadTreeManifest(catalogueRoot, filepath.Join(catalogueRoot, "modules", name))
if err != nil {
t.Fatalf("modules/%s: %v", name, err)
}
shelf[m.Module] = m
}
node := catalogue.Node{Name: "builder", Site: "house",
Capabilities: map[string]bool{"package-manager": true, "user-namespaces": true}}
_, err := catalogue.Resolve(shelf, []string{"forge", "store", "docker", "mesh-build-image"}, node, catalogue.World{})
if err == nil {
t.Fatal("mesh-build-image composed on a machine without buildah (docker does not stand in for it)")
}
for _, want := range []string{`"rootless-image-build" is wanted by mesh-build-image`, "buildah"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("the refusal does not say %q:\n%v", want, err)
}
}
if _, err := catalogue.Resolve(shelf, []string{"forge", "store", "buildah", "mesh-build-image"}, node, catalogue.World{}); err != nil {
t.Errorf("with buildah assigned the composition is refused: %v", err)
}
}
@@ -0,0 +1,149 @@
package main
import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"git.novox.be/novox/mesh-sdk/go/health"
)
// CheckRound is how often the module looks at itself again, beside the look before every step and at every health look.
const CheckRound = 10 * time.Minute
// Module is the image build module's one running state.
type Module struct {
Node string
// Account and UID are the account the module runs as; Home is its home.
Account, UID, Home string
// Steps is the Put of the steps state; ReadSteps reads it back for the read tools.
Steps buildstep.Put
ReadSteps StateRead
// Ctx is the process's context, which a step runs under (the tool call's is cut at 30 s, the step's is not).
Ctx context.Context
Say func(format string, a ...any)
// Run runs the commands of a step (StepRunner); Work holds the workspaces and Store buildah's image store, both
// outside the account's home (ADR 0466 §1).
Run forgekey.Runner
Work, Store string
// SubUIDFile and SubGIDFile are the subordinate id files; /etc/subuid and /etc/subgid when empty.
SubUIDFile, SubGIDFile string
// RegistryFile holds the registry's address, host:port, which the manifest binds from the artifact-store provision.
RegistryFile string
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
// the SDK's fetch and merge. A test points them at a local repository (buildstep.AllowFileRemote).
RemoteBase string
FetchOptions []buildstep.Option
// HTTP talks to the registry; a client of its own when nil. RegistryWait and RegistryPause bound the wait for a
// registry held still (RegistryWait and RegistryFirstPause when zero).
HTTP *http.Client
RegistryWait time.Duration
RegistryPause time.Duration
// Now is the clock; time.Now when nil.
Now func() time.Time
}
// Look is what the module found out about its machine.
type Look struct {
Buildah string
UIDs, GIDs int
Account string
}
// look proves the machine: buildah runs, the account holds ranges, a user namespace can be made. The first thing
// missing is a *buildstep.Refusal naming it, so a step ends refused and the health says the same.
func (m *Module) look(ctx context.Context) (Look, error) {
l := Look{Account: m.Account}
var err error
if l.Buildah, err = m.buildahVersion(ctx); err != nil {
return l, &buildstep.Refusal{Text: err.Error()}
}
if l.UIDs, l.GIDs, err = m.ranges(); err != nil {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
}
for _, d := range []string{m.Store, filepath.Join(m.Store, "xdg")} {
if err := os.MkdirAll(d, 0o700); err != nil {
return l, err
}
}
if err := m.userNamespace(ctx); err != nil {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
}
return l, nil
}
// Check is the guard: nil when the machine can build, else the refusal.
func (m *Module) Check(ctx context.Context) error {
_, err := m.look(ctx)
return err
}
// Start looks once, as the module starts, and then every CheckRound until ctx ends; it also removes old workspaces.
func (m *Module) Start(ctx context.Context) {
for {
if err := m.Check(ctx); err != nil && m.Say != nil {
m.Say("this machine cannot build images: %v", err)
}
m.CleanWorkspaces(m.clock())
select {
case <-ctx.Done():
return
case <-time.After(CheckRound):
}
}
}
// Health is the module's health (the runtime-health tool, ADR 0366): unhealthy while it cannot build, saying why,
// healthy it names the buildah installed and the account's ranges (ADR 0487 §2: the ranges are proven here, not declared).
func (m *Module) Health(ctx context.Context) health.Answer {
l, err := m.look(ctx)
if err != nil {
return health.Unhealthy(err.Error())
}
return health.Healthy(fmt.Sprintf("buildah %s installed; %s holds %d subordinate uids and %d gids; a user namespace can be made", l.Buildah, l.Account, l.UIDs, l.GIDs))
}
// Status is what the module reports of itself.
func (m *Module) Status(ctx context.Context) map[string]any {
l, err := m.look(ctx)
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil}
if err != nil {
out["why"] = err.Error()
}
return out
}
// Long is a tool that runs a long step (ADR 0469 §2): it answers at once with the step's id, and the step ends on its
// key in the steps state. The guard runs inside the step, before run, so a machine that cannot build ends every step
// refused with the cause named.
func (m *Module) Long(tool string, run func(ctx context.Context, args map[string]any) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
id, err := buildstep.Start(m.Ctx, m.Steps, m.Node, tool, func(ctx context.Context) (any, error) {
if err := m.Check(ctx); err != nil {
return nil, err
}
return run(ctx, args)
})
if err != nil {
return nil, err
}
key, _ := delivery.StepKey(m.Node, id)
return map[string]any{"step": id, "key": key, "state": delivery.StepRunning}, nil
}
}
// Short is a tool that answers in its own call; the guard runs before it, and a refusal is the call's error.
func (m *Module) Short(run func(ctx context.Context, args map[string]any) (any, error)) func(map[string]any) (any, error) {
return func(args map[string]any) (any, error) {
if err := m.Check(m.Ctx); err != nil {
return nil, err
}
return run(m.Ctx, args)
}
}
@@ -0,0 +1,118 @@
package main
import (
"context"
"os"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"git.novox.be/novox/mesh-sdk/go/health"
)
// 1703a: a machine where buildah runs, the account holds ranges in both files and a namespace can be made is healthy,
// and the health names buildah and the ranges (ADR 0487 §2: proven, not declared).
func TestHealthIsHealthyWithBuildahRangesAndANamespace(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
a := m.Health(context.Background())
if a.State != health.StateHealthy || !a.Healthy {
t.Fatalf("health = %+v, want healthy", a)
}
for _, want := range []string{"buildah 1.45.1", "mesh-build-image holds 65536 subordinate uids and 65536 gids"} {
if !strings.Contains(a.Why, want) {
t.Errorf("health says %q, lacks %q", a.Why, want)
}
}
if _, err := os.Stat(m.Store + "/xdg"); err != nil {
t.Errorf("the runtime directory inside the store was not made: %v", err)
}
}
func TestHealthIsUnhealthyWithoutBuildah(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
return forgekey.Ran{Status: 127, Err: "exec: \"buildah\": executable file not found in $PATH"}
}
a := m.Health(context.Background())
if a.Healthy || !strings.Contains(a.Why, "buildah") {
t.Errorf("health = %+v, want unhealthy naming buildah", a)
}
}
// The ranges are read from the two files, one each: without one in /etc/subuid or in /etc/subgid the account has no ids to
// map, and the health says which file.
func TestHealthIsUnhealthyWithoutARangeInEitherFile(t *testing.T) {
for file, set := range map[string]func(*Module, string){
"subuid": func(m *Module, p string) { m.SubUIDFile = p },
"subgid": func(m *Module, p string) { m.SubGIDFile = p },
} {
f := newFake(t)
m := moduleWith(t, f, newStates())
p := t.TempDir() + "/" + file
os.WriteFile(p, []byte("someone-else:100000:65536\n"), 0o644)
set(m, p)
a := m.Health(context.Background())
if a.Healthy || !strings.Contains(a.Why, p) || !strings.Contains(a.Why, "mesh-build-image") {
t.Errorf("%s: health = %+v, want unhealthy naming %s and the account", file, a, p)
}
}
}
func TestHealthIsUnhealthyWhenANamespaceCannotBeMade(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
run := m.Run
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
if len(args) >= 2 && args[len(args)-2] == "unshare" {
return forgekey.Ran{Status: 125, Stderr: "Error: cannot clone: Operation not permitted\n"}
}
return run(ctx, env, name, args...)
}
a := m.Health(context.Background())
if a.Healthy || !strings.Contains(a.Why, "user namespace") {
t.Errorf("health = %+v, want unhealthy naming the user namespace", a)
}
}
func TestSubRangeSumsTheAccountsLinesByNameOrUid(t *testing.T) {
p := t.TempDir() + "/subuid"
os.WriteFile(p, []byte("a:1:10\nmesh-build-image:100:65536\n1234:200000:100\nb:5:5\nbad line\n"), 0o644)
if n, err := SubRange(p, "mesh-build-image", "1234"); err != nil || n != 65636 {
t.Errorf("range = %d, %v, want 65636", n, err)
}
if n, _ := SubRange(p, "nobody", ""); n != 0 {
t.Errorf("nobody has %d", n)
}
}
// A step on a machine that cannot build ends refused with the cause named, and nothing else runs.
func TestAStepOnAMachineThatCannotBuildEndsRefused(t *testing.T) {
f := newFake(t)
states := newStates()
m := moduleWith(t, f, states)
m.SubGIDFile = "/nonexistent/subgid"
if _, err := tool(t, m, FetchTool)(map[string]any{"repository": "novox/mesh-catalog", "commit": commitSHA}); err != nil {
t.Fatal(err)
}
st := states.waitEnd(t)
if st.State != delivery.StepRefused || !strings.Contains(st.Reason, "/nonexistent/subgid") {
t.Errorf("step = %+v, want refused naming the file", st)
}
for _, l := range f.lines() {
if strings.HasPrefix(l, "git ") {
t.Errorf("a refused step ran %q", l)
}
}
}
func TestStatusSaysWhatTheMachineHas(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
s := m.Status(context.Background())
if s["refusing"] != false || s["buildah"] != "1.45.1" || s["subuids"] != 65536 || s["account"] != "mesh-build-image" {
t.Errorf("status = %v", s)
}
}
@@ -0,0 +1,116 @@
package main
// What the module proves about its own machine before it builds (novox/hq ADR 0487 §2, issue 1703a): buildah runs,
// the account holds subordinate user and group ranges (the node-engine's useradd allocates them for every `user`
// account, so nothing declares them; this reads them), and a user namespace can be made.
import (
"bufio"
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// ProbeTimeout bounds each probe: they read the machine and fetch nothing.
const ProbeTimeout = 20 * time.Second
// Default subordinate id files.
const (
SubUIDFile = "/etc/subuid"
SubGIDFile = "/etc/subgid"
)
// buildahVersion runs `buildah --version` and answers the version, the third word of "buildah version 1.45.1 (...)".
func (m *Module) buildahVersion(ctx context.Context) (string, error) {
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
defer cancel()
r := m.Run(ctx, m.env(), "buildah", "--version")
if r.Status != 0 || r.Err != "" {
return "", fmt.Errorf("`buildah --version` failed (is the module buildah assigned to this machine?): %s", firstLine(r.Err+"\n"+r.Stderr))
}
f := strings.Fields(r.Stdout)
if len(f) < 3 || f[0] != "buildah" || f[1] != "version" {
return "", fmt.Errorf("`buildah --version` answered %q", strings.TrimSpace(r.Stdout))
}
return f[2], nil
}
// userNamespace makes a user namespace as the account does for a build and runs `true` in it.
func (m *Module) userNamespace(ctx context.Context) error {
ctx, cancel := context.WithTimeout(ctx, ProbeTimeout)
defer cancel()
r := m.Run(ctx, m.env(), "buildah", append(m.storeFlags(), "unshare", "true")...)
if r.Status != 0 || r.Err != "" {
return fmt.Errorf("a user namespace cannot be made: %s", firstLine(r.Err+"\n"+r.Stderr))
}
return nil
}
// SubRange is how many subordinate ids file lists for the account (by name or by numeric uid): the sum of its lines'
// counts, zero when it has none.
func SubRange(file, account, uid string) (int, error) {
f, err := os.Open(file)
if err != nil {
return 0, err
}
defer f.Close()
total := 0
sc := bufio.NewScanner(f)
for sc.Scan() {
parts := strings.Split(strings.TrimSpace(sc.Text()), ":")
if len(parts) != 3 || (parts[0] != account && (uid == "" || parts[0] != uid)) {
continue
}
if n, err := strconv.Atoi(parts[2]); err == nil && n > 0 {
total += n
}
}
return total, sc.Err()
}
func (m *Module) subFiles() (uid, gid string) {
uid, gid = m.SubUIDFile, m.SubGIDFile
if uid == "" {
uid = SubUIDFile
}
if gid == "" {
gid = SubGIDFile
}
return
}
// ranges reads the account's subordinate ranges, or says which file lacks one.
func (m *Module) ranges() (uids, gids int, err error) {
uf, gf := m.subFiles()
for _, c := range []struct {
file string
n *int
}{{uf, &uids}, {gf, &gids}} {
n, rerr := SubRange(c.file, m.Account, m.UID)
if rerr != nil {
return 0, 0, fmt.Errorf("%s cannot be read: %v", c.file, rerr)
}
if n == 0 {
return 0, 0, fmt.Errorf("%s holds no subordinate range for the account %s: a build in a user namespace has no ids to map (the node-engine's useradd allocates them when it makes a user account)", c.file, m.Account)
}
*c.n = n
}
return uids, gids, nil
}
// storeFlags are buildah's global flags that put its image store and its runtime state in the module's store
// directory, outside the account's home.
func (m *Module) storeFlags() []string {
return []string{"--root", filepath.Join(m.Store, "root"), "--runroot", filepath.Join(m.Store, "run")}
}
// env is what buildah is given beyond the base environment: a runtime directory of its own inside the store, so
// nothing is read from the account's runtime directory, and no registry credential file anywhere (the module has none
// and REGISTRY_AUTH_FILE is never set).
func (m *Module) env() []string {
return []string{"XDG_RUNTIME_DIR=" + filepath.Join(m.Store, "xdg")}
}
@@ -0,0 +1,157 @@
package main
// The trunk push (novox/hq ADR 0487 §5; issue 1703d): after the build has ended, from the module's own process and not
// from a Dockerfile step, the image is pushed to the registry under a tag of its own and the digest is read back from
// the registry, which is the only one that can say what it serves for the reference (issue 1742). The push and the
// read wait out a registry held still by the store's nightly collection (issue 457).
import (
"context"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"syscall"
"time"
)
// RegistryWait is how long a call waits for a registry that refuses connections, longer than the nightly collection
// holds it still (about a minute and a half); RegistryFirstPause is the first pause, doubling to RegistryMostPause.
const (
RegistryWait = 5 * time.Minute
RegistryFirstPause = time.Second
RegistryMostPause = 10 * time.Second
)
// registry is the registry's address, host:port, as the manifest bound it into RegistryFile.
func (m *Module) registry() (string, error) {
raw, err := os.ReadFile(m.RegistryFile)
if err != nil {
return "", fmt.Errorf("the registry's address cannot be read: %w", err)
}
addr := strings.TrimSpace(string(raw))
if addr == "" || strings.Contains(addr, "${") || strings.HasPrefix(addr, ":") || strings.HasSuffix(addr, ":") {
return "", fmt.Errorf("the registry's address in %s is %q, not host:port", m.RegistryFile, addr)
}
return addr, nil
}
func (m *Module) push(ctx context.Context, workspace, module, local string, res BuildResult, tag string) (any, error) {
addr, err := m.registry()
if err != nil {
return nil, err
}
repository := module + "/" + res.Artifact
dest := addr + "/" + repository + ":" + tag
digestFile := filepath.Join(m.Work, workspace, "pushed."+tag)
argv := append(m.storeFlags(), "push", "--tls-verify=false", "--digestfile", digestFile, local, "docker://"+dest)
if err := m.waitForRegistry(ctx, addr, "buildah push "+dest, func() error {
r := m.Run(ctx, m.env(), "buildah", argv...)
if r.Status != 0 || r.Err != "" {
return errors.New(lastLine(r.Err + "\n" + r.Stderr + "\n" + r.Stdout))
}
return nil
}); err != nil {
return nil, fmt.Errorf("pushing %s: %w", dest, err)
}
var served string
if err := m.waitForRegistry(ctx, addr, "reading "+dest, func() error {
var err error
served, err = m.registryDigest(ctx, addr, repository, tag)
return err
}); err != nil {
return nil, fmt.Errorf("pushed %s and cannot read back what the registry serves for it: %w", dest, err)
}
// A push that succeeded wrote the digest it made: a file missing, unreadable or empty is a fault, and the registry's
// digest is never answered unchecked.
raw, err := os.ReadFile(digestFile)
if err != nil {
return nil, fmt.Errorf("pushed %s, but the digest buildah pushed cannot be read from %s, so the registry's %s cannot be checked: %w", dest, digestFile, served, err)
}
pushed := strings.TrimSpace(string(raw))
if !digestOK.MatchString(pushed) {
return nil, fmt.Errorf("pushed %s, but the digest file %s holds %q, not a digest, so the registry's %s cannot be checked", dest, digestFile, pushed, served)
}
if pushed != served {
return nil, fmt.Errorf("pushed %s as %s, but the registry serves %s for it", dest, pushed, served)
}
res.Digest, res.Pushed = served, true
res.Reference = addr + "/" + repository + "@" + served
return res, nil
}
// registryDigest is the digest the registry answers for repository:tag (Docker-Content-Digest of a HEAD).
func (m *Module) registryDigest(ctx context.Context, addr, repository, tag string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodHead, "http://"+addr+"/v2/"+repository+"/manifests/"+tag, nil)
if err != nil {
return "", err
}
req.Header.Set("Accept", strings.Join([]string{
"application/vnd.oci.image.manifest.v1+json", "application/vnd.oci.image.index.v1+json",
"application/vnd.docker.distribution.manifest.v2+json", "application/vnd.docker.distribution.manifest.list.v2+json"}, ", "))
client := m.HTTP
if client == nil {
client = &http.Client{Timeout: time.Minute}
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("the registry answered %s for %s:%s", resp.Status, repository, tag)
}
d := resp.Header.Get("Docker-Content-Digest")
if !digestOK.MatchString(d) {
return "", fmt.Errorf("the registry named no digest for %s:%s (Docker-Content-Digest %q)", repository, tag, d)
}
return d, nil
}
// refused is whether an error is a connection refused: from a dial here, or as buildah said it in its output.
func refused(err error) bool {
return err != nil && (errors.Is(err, syscall.ECONNREFUSED) || strings.Contains(err.Error(), "connection refused"))
}
// waitForRegistry runs try, and while it fails because the registry at addr refuses connections, tries again with a
// growing pause until RegistryWait has passed (novox/hq issue 457). A refusal is the one error waited for: nothing
// was sent, so trying again does nothing twice. what names the call, for what the module says.
func (m *Module) waitForRegistry(ctx context.Context, addr, what string, try func() error) error {
err := try()
if !refused(err) {
return err
}
limit, pause := m.RegistryWait, m.RegistryPause
if limit == 0 {
limit = RegistryWait
}
if pause == 0 {
pause = RegistryFirstPause
}
started := time.Now()
m.say("%s: refused; waiting for the registry at %s for up to %s — the store's nightly collection holds it still for about a minute and a half (issue 457)", what, addr, limit)
for {
left := limit - time.Since(started)
if left <= 0 {
return fmt.Errorf("the registry at %s refused every connection for %s, longer than its nightly collection holds it still, so it is down, not paused: %w", addr, time.Since(started).Round(time.Millisecond), err)
}
select {
case <-ctx.Done():
return fmt.Errorf("stopped while waiting for the registry at %s: %w (last: %v)", addr, ctx.Err(), err)
case <-time.After(min(pause, left)):
}
pause = min(pause*2, RegistryMostPause)
if err = try(); !refused(err) {
m.say("%s: the registry at %s answers again after %s", what, addr, time.Since(started).Round(time.Millisecond))
return err
}
}
}
func (m *Module) say(format string, a ...any) {
if m.Say != nil {
m.Say(format, a...)
}
}
@@ -0,0 +1,156 @@
package main
import (
"context"
"os"
"strings"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// 1703d/AC (ADR 0487 §5): a candidate's build leaves the registry unchanged: nothing is pushed, nothing is asked of it.
func TestACandidatesBuildLeavesTheRegistryUnchanged(t *testing.T) {
m, f, states := setup(t, same)
res, st := runBuild(t, m, states, nil) // no publish
if st.State != delivery.StepPassed || res.Pushed {
t.Fatalf("step = %+v result = %+v", st, res)
}
if len(f.called("push")) != 0 || len(f.registry) != 0 || f.pushes != 0 {
t.Errorf("a candidate's build reached the registry: pushes %d, registry %v, calls %v", f.pushes, f.registry, f.lines())
}
}
// 1703d/AC: a trunk build adds exactly one digest, under <module>/<artifact> and a tag of its own (the digest's first 12
// hex digits), after the build has ended, with TLS verification off (plain HTTP) and no credential; the key carries the
// digest the registry serves and the reference pinned by it.
func TestATrunkBuildPushesOnceAndAnswersTheRegistrysDigest(t *testing.T) {
m, f, states := setup(t, same)
res, st := runBuild(t, m, states, map[string]any{"publish": true})
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
tag := "111111111111"
if len(f.registry) != 1 || f.registry["fixture-image/server:"+tag] != imageDigest || f.pushes != 1 {
t.Fatalf("registry = %v after %d pushes, want exactly fixture-image/server:%s", f.registry, f.pushes, tag)
}
if !res.Pushed || res.Digest != imageDigest || res.Reference != f.address()+"/fixture-image/server@"+imageDigest {
t.Errorf("result = %+v", res)
}
push := f.called("push")
if len(push) != 1 || !strings.Contains(push[0].line, "--tls-verify=false") || !strings.Contains(push[0].line, "docker://"+f.address()+"/fixture-image/server:"+tag) {
t.Fatalf("push = %v", push)
}
for _, bad := range []string{"--creds", "--authfile", "--sign"} {
if strings.Contains(push[0].line, bad) {
t.Errorf("the push carries %s", bad)
}
}
// The push comes after the build has ended: bud, then images, then push.
order := []string{}
for _, l := range f.lines() {
for _, v := range []string{" bud ", " images ", " push ", " rmi "} {
if strings.Contains(l, "buildah") && strings.Contains(l, v) {
order = append(order, strings.TrimSpace(v))
}
}
}
if strings.Join(order, ",") != "bud,images,push,rmi" {
t.Errorf("order = %v", order)
}
}
// A tag the call gives is the one used; one that is not a tag is refused.
func TestATagOfTheCallsOwn(t *testing.T) {
m, f, states := setup(t, same)
if _, st := runBuild(t, m, states, map[string]any{"publish": true, "tag": "fp-abc123"}); st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
if _, ok := f.registry["fixture-image/server:fp-abc123"]; !ok || len(f.registry) != 1 {
t.Errorf("registry = %v", f.registry)
}
m2, f2, states2 := setup(t, same)
if _, st := runBuild(t, m2, states2, map[string]any{"publish": true, "tag": "a/b:c"}); st.State != delivery.StepFailed || len(f2.called("bud")) != 0 {
t.Errorf("step = %+v", st)
}
}
// issue 457: a registry held still (connection refused) is waited for, and the push then goes through once.
func TestAPushWaitsForARegistryHeldStill(t *testing.T) {
m, f, states := setup(t, same)
f.refuse = 2
res, st := runBuild(t, m, states, map[string]any{"publish": true})
if st.State != delivery.StepPassed || !res.Pushed {
t.Fatalf("step = %+v result = %+v", st, res)
}
if f.pushes != 1 || len(f.registry) != 1 || len(f.called("push")) != 3 {
t.Errorf("pushes %d, registry %v, push calls %d; want two refused and one made", f.pushes, f.registry, len(f.called("push")))
}
}
// A registry that stays down past the wait fails the step saying how long it was refused; nothing is claimed pushed.
func TestARegistryDownPastTheWaitFailsTheStep(t *testing.T) {
m, f, states := setup(t, same)
f.refuse = 1 << 20
m.RegistryWait = 50 * time.Millisecond
_, st := runBuild(t, m, states, map[string]any{"publish": true})
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "refused every connection") {
t.Errorf("step = %+v", st)
}
if len(f.registry) != 0 {
t.Errorf("registry = %v", f.registry)
}
}
// The digest the registry serves is the answer: when it differs from the one the push reported, the step fails.
func TestAnAnswerThatDisagreesWithThePushFails(t *testing.T) {
m, f, states := setup(t, same)
run := m.Run
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
r := run(ctx, env, name, args...)
if name == "buildah" && strings.Contains(strings.Join(args, " "), " push ") {
f.mu.Lock()
for k := range f.registry {
f.registry[k] = "sha256:2222222222222222222222222222222222222222222222222222222222222222"
}
f.mu.Unlock()
}
return r
}
_, st := runBuild(t, m, states, map[string]any{"publish": true})
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "the registry serves") {
t.Errorf("step = %+v", st)
}
}
func TestTheRegistrysAddressMustBeBound(t *testing.T) {
for _, bad := range []string{"", "${bound:artifact-store:at}:5000", ":5000"} {
m, _, _ := setup(t, same)
writeFile(t, m.RegistryFile, bad)
if _, err := m.registry(); err == nil {
t.Errorf("%q was taken as the registry's address", bad)
}
}
}
func writeFile(t *testing.T, path, body string) {
t.Helper()
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
// A push that succeeded wrote its digest file: one missing, unreadable or empty is a fault, and the registry's digest is
// never answered unchecked (review round 1).
func TestAPushWithoutAReadableDigestFileFailsTheStep(t *testing.T) {
for _, kind := range []string{"none", "empty"} {
m, f, states := setup(t, same)
f.digestFile = kind
_, st := runBuild(t, m, states, map[string]any{"publish": true})
if st.State != delivery.StepFailed || !strings.Contains(st.Reason, "digest") {
t.Errorf("%s: step = %+v, want failed naming the digest file", kind, st)
}
}
}
@@ -0,0 +1,164 @@
package main
// The module's read tools (novox/hq issue 1562): what the module wrote to its own `steps` and `toolchains` states,
// answered as stored. A long step ends on its key in the steps state (ADR 0469 §2) and its result, such as the
// workspace a fetch made, is only there; a module reading its own bucket needs no grant.
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The read tools, named as the module is: build_image_ and the verb.
const (
StepReadTool = "build_image_step"
StepsReadTool = "build_image_steps"
)
// StateRead reads one of the module's own states: its keys and a key's stored value (nil for a key with none).
type StateRead struct {
Keys func() ([]string, error)
Get func(key string) (json.RawMessage, error)
}
// readOf is the read of a state the process keeps.
func readOf(s stdio.KeptState) StateRead {
return StateRead{Keys: s.Keys, Get: func(key string) (json.RawMessage, error) {
e, err := s.Get(key)
if err != nil || e == nil {
return nil, err
}
return e.Value, nil
}}
}
// stepKey is the key of a step of this node: the id alone, or the whole key <node>.<id> as a step tool answered it.
func (m *Module) stepKey(id string) (string, error) {
id = strings.TrimSpace(id)
if node, rest, ok := strings.Cut(id, "."); ok {
if node != m.Node {
return "", fmt.Errorf("the step %q is of node %q, this module runs on %q", id, node, m.Node)
}
id = rest
}
return delivery.StepKey(m.Node, id)
}
// ReadStep is the stored value of this node's step id (state, reason, result).
func (m *Module) ReadStep(id string) (delivery.Step, error) {
key, err := m.stepKey(id)
if err != nil {
return delivery.Step{}, err
}
if m.ReadSteps.Get == nil {
return delivery.Step{}, fmt.Errorf("the steps state cannot be read here")
}
raw, err := m.ReadSteps.Get(key)
if err != nil {
return delivery.Step{}, err
}
if len(raw) == 0 {
return delivery.Step{}, fmt.Errorf("no step %s in the steps state", key)
}
return delivery.DecodeStep(raw)
}
// StepLine is a step without its result: the list answers these.
type StepLine struct {
ID string `json:"id"`
Tool string `json:"tool"`
State string `json:"state"`
Started string `json:"started"`
Ended string `json:"ended,omitempty"`
Reason string `json:"reason,omitempty"`
}
// RecentSteps are this node's steps, the latest started first, at most limit (ten when limit is not above zero).
func (m *Module) RecentSteps(limit int) ([]StepLine, error) {
if limit <= 0 {
limit = 10
}
if m.ReadSteps.Keys == nil || m.ReadSteps.Get == nil {
return nil, fmt.Errorf("the steps state cannot be read here")
}
keys, err := m.ReadSteps.Keys()
if err != nil {
return nil, err
}
var steps []delivery.Step
for _, k := range keys {
if node, _, err := delivery.ParseStepKey(k); err != nil || node != m.Node {
continue
}
raw, err := m.ReadSteps.Get(k)
if err != nil {
return nil, err
}
if len(raw) == 0 {
continue
}
s, err := delivery.DecodeStep(raw)
if err != nil {
continue // a value this module cannot read is not one of its own steps
}
steps = append(steps, s)
}
sort.SliceStable(steps, func(i, j int) bool { return steps[i].Started.After(steps[j].Started) })
if len(steps) > limit {
steps = steps[:limit]
}
out := make([]StepLine, 0, len(steps))
for _, s := range steps {
l := StepLine{ID: s.ID, Tool: s.Tool, State: s.State, Started: s.Started.UTC().Format("2006-01-02T15:04:05Z"), Reason: s.Reason}
if !s.Ended.IsZero() {
l.Ended = s.Ended.UTC().Format("2006-01-02T15:04:05Z")
}
out = append(out, l)
}
return out, nil
}
// ReadTools are the module's read tools. All are read-only.
func (m *Module) ReadTools() []stdio.Tool {
return []stdio.Tool{
{Name: StepReadTool,
Description: "What one step of this machine ended with, as stored in the module's steps state: its tool, state " +
"(running, passed, failed, refused), when it started and ended, why it failed or was refused, and its result " +
"(a fetch's workspace id is there). id is the step id a step tool answered, or the whole key <node>.<id>. Read-only.",
Input: map[string]any{"type": "object", "required": []string{"id"}, "properties": map[string]any{
"id": map[string]any{"type": "string", "description": "the step id, or the key <node>.<id>"}}},
Run: func(args map[string]any) (any, error) {
id, _ := args["id"].(string)
return m.ReadStep(id)
}},
{Name: StepsReadTool,
Description: "This machine's latest steps from the module's steps state, newest first, each with its id, tool, state, " +
"times and reason but not its result (read one with " + StepReadTool + "). Read-only.",
Input: map[string]any{"type": "object", "properties": map[string]any{
"limit": map[string]any{"type": "integer", "description": "how many, at most; 10 when left out"}}},
Run: func(args map[string]any) (any, error) {
steps, err := m.RecentSteps(intArg(args["limit"]))
return map[string]any{"node": m.Node, "steps": steps}, err
}},
}
}
// intArg is a tool argument that is a number, or a string of one; zero otherwise.
func intArg(v any) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case string:
i, _ := strconv.Atoi(n)
return i
}
return 0
}
@@ -0,0 +1,46 @@
package main
import (
"encoding/json"
"testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The read tools answer what the steps state holds: a finished build's result is there, read by the step's id.
func TestTheReadToolsAnswerAStepsResult(t *testing.T) {
m, _, states := setup(t, same)
res, st := runBuild(t, m, states, nil)
if st.State != delivery.StepPassed {
t.Fatalf("step = %+v", st)
}
m.ReadSteps = StateRead{
Keys: func() ([]string, error) {
states.mu.Lock()
defer states.mu.Unlock()
var k []string
for key := range states.steps {
k = append(k, key)
}
return k, nil
},
Get: func(key string) (json.RawMessage, error) {
states.mu.Lock()
defer states.mu.Unlock()
v := states.steps[key]
return json.Marshal(v[len(v)-1])
},
}
got, err := m.ReadStep(st.ID)
if err != nil || got.State != delivery.StepPassed {
t.Fatalf("ReadStep = %+v, %v", got, err)
}
var back BuildResult
if err := json.Unmarshal(got.Result, &back); err != nil || back.Digest != res.Digest {
t.Errorf("result = %s, %v", got.Result, err)
}
lines, err := m.RecentSteps(0)
if err != nil || len(lines) != 1 || lines[0].Tool != BuildTool {
t.Errorf("steps = %+v, %v", lines, err)
}
}
@@ -0,0 +1,32 @@
package main
import (
"context"
"os"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// StepRunner runs the commands of a build step with an environment built from scratch (novox/hq ADR 0466 §4, issue
// 1485): PATH, HOME (the account's own home, where its ssh files and git configuration are) and a fixed locale, plus
// the words the caller passes, and nothing else of this process's environment — which holds the module's bus
// credentials and might hold a password or a token. It is mesh-sdk's clean runner (ExecRunnerClean, issue 1497) with
// that base given; a command is bound by its context alone (the bound 0), so a fetch may outlast a tool call's thirty
// seconds (the step has its own context).
func StepRunner(home string) forgekey.Runner {
run := forgekey.ExecRunnerClean(0)
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
return run(ctx, append(baseEnv(home), env...), name, args...)
}
}
// defaultPath is where a step looks for git, ssh and buildah when this process was given no PATH.
const defaultPath = "/usr/local/bin:/usr/bin:/bin"
func baseEnv(home string) []string {
path := os.Getenv("PATH")
if path == "" {
path = defaultPath
}
return []string{"PATH=" + path, "HOME=" + home, "LC_ALL=C"}
}
@@ -0,0 +1,5 @@
The stored steps (what a step ends on in the module's `steps` state) of a fetch (fetch.json), a candidate's build
(build-candidate.json, no publish) and a trunk build (build-trunk.json, publish), as buildstep writes them, with ids, times,
the workspace, the commit, digests and the registry's address put to fixed words (STEPID, WORKSPACE, COMMIT, REGISTRY, one
sha256 digest). Written by capture_test.go (MESH_UPDATE_CAPTURED=1) from the unit tests' fake runner on 2026-10-22, and
compared by bus_integration_test.go with the steps a real bus holds. mesh-delivery-builder's tests copy these files.
@@ -0,0 +1,14 @@
{
"ended": "2026-10-22T08:00:01Z",
"id": "STEPID",
"node": "ace",
"result": {
"artifact": "server",
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"pushed": false
},
"started": "2026-10-22T08:00:00Z",
"state": "passed",
"tool": "build_image_build",
"v": 1
}
@@ -0,0 +1,15 @@
{
"ended": "2026-10-22T08:00:01Z",
"id": "STEPID",
"node": "ace",
"result": {
"artifact": "server",
"digest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
"pushed": true,
"reference": "REGISTRY/fixture-image/server@sha256:1111111111111111111111111111111111111111111111111111111111111111"
},
"started": "2026-10-22T08:00:00Z",
"state": "passed",
"tool": "build_image_build",
"v": 1
}
@@ -0,0 +1,13 @@
{
"ended": "2026-10-22T08:00:01Z",
"id": "STEPID",
"node": "ace",
"result": {
"commit": "COMMIT",
"workspace": "WORKSPACE"
},
"started": "2026-10-22T08:00:00Z",
"state": "passed",
"tool": "build_image_fetch",
"v": 1
}
@@ -0,0 +1,4 @@
# A fixture module: a file added to an upstream base the manifest pins in build.on.
ARG DEMO_BASE
FROM ${DEMO_BASE}
COPY hello.txt /hello.txt
@@ -0,0 +1 @@
hello from the fixture module
@@ -0,0 +1,20 @@
{
"module": "fixture-image",
"version": "1",
"build": {
"on": [
{
"arg": "DEMO_BASE",
"image": "docker.io/library/busybox@sha256:0000000000000000000000000000000000000000000000000000000000000001"
}
],
"artifacts": [
{
"name": "server",
"kind": "image",
"from": "Dockerfile",
"target": "final"
}
]
}
}

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