Compare commits

..
Author SHA1 Message Date
jschoubben 0fdfecfa29 K3: pin gitea to mesh-sdk 104533e8, the group's final SDK head after S1 and S2 (ADR 0500 §2) 2026-10-22 21:19:11 +02:00
jschoubben 97f0f4813c K2: keep the captured claude-code manifest in a test file (issue 1751)
The catalogue's paths check (ADR 0260) reads every file of a module but its documentation and tests, and
the verbatim manifest names claude-code's own paths; as a constant in a _test.go file it stays a real
capture without being taken for one module reaching into another's state.
2026-10-22 21:18:18 +02:00
jschoubben 8f10d3192c K3: pin gitea to mesh-sdk 875c680d, the group's one SDK head (ADR 0500 §2)
gitea's Go code (cmd/repositories) changes in the R4e group, so it pins the SDK head every other member
pins; it still held 66e8fdaa.
2026-10-22 21:08:33 +02:00
jschoubben 1e3439429f K2: read a manifest for its build inputs in mesh-build-go and mesh-build-image (issue 1751, ADR 0514 §2)
The build step parsed the manifest as a judge of config values, so claude-code, dunst and xterm, whose
values fall back to a predefined key, got a fingerprint but their tools bundle was refused at the build.
The builder holds no published list of predefined keys; ParseManifestForInputs is its read. mesh-build-node
reads module.json with its own narrow struct and needs no change.
2026-10-22 21:08:12 +02:00
jschoubben e9e8c7c901 Pin every R4e member module to mesh-sdk's final R4e head 875c680d, which holds main's #68 and #69, so the group pins one SDK 2026-10-22 20:45:17 +02:00
jschoubben db35f2b0cb Merge origin/main into feat/r4e (R4e integration, before the final SDK pin) 2026-10-22 20:43:32 +02:00
jschoubben f90d3bfcc0 Merge feat/r4e-leaf into feat/r4e again: the gate's read states announced readable, pulls too (R4e integration) 2026-10-22 20:36:42 +02:00
jschoubben 37c47f8f95 Mark the delivery gate's pulls readable, as build-agent reads it (issue 1790) 2026-10-22 20:36:21 +02:00
jschoubben a45e467ddd Merge feat/r4e-leaf into feat/r4e again: the gate's read states announced readable (R4e integration) 2026-10-22 20:35:52 +02:00
jschoubben d3f9a1855d Mark the delivery gate's asks, merges, owed and source readable, as mesh-delivery's parts read them (issue 1790) 2026-10-22 20:35:29 +02:00
jschoubben c97cea21cf Merge feat/r4e-leaf into feat/r4e (R4e integration)
gitea's index.ts conflicted on imports: kept both, the leaf's statesAnnouncement and the gate's checkStatusTool.
mesh-delivery-gate is pinned to the sdk lane's newest head d1af87a (the leaf had a90f523) until the final SDK head.
2026-10-22 20:34:48 +02:00
mesh-tools 2f05e70734 Merge pull request 'Upgrade the nodes one at a time on the bus, with a package record, a kernel hold, an after-upgrade check, a rollback and a fallback kernel (upgrade safety, issues 1840 and 1841)' (#450) from feat/upgrade-safety into main 2026-10-22 18:34:15 +00:00
jschoubben 5ab9d6c296 Merge feat/r4e-buildmods into feat/r4e (R4e integration) 2026-10-22 20:30:01 +02:00
jschoubben e61c5ee5bf Merge feat/r4e-gate into feat/r4e (R4e integration) 2026-10-22 20:30:00 +02:00
mesh-tools b5f812270e Merge pull request 'Let restic's thinning wait up to ten minutes for a held repository lock instead of failing at once (issue 620)' (#451) from fix/620-thinning-waits-for-a-lock into main 2026-10-22 18:16:37 +00:00
jschoubben 0786408109 Say loudly, not fail, where a check has no bubblewrap to run a build module's shut-off steps (issue 1309)
build-agent's check container has no bwrap; the build nodes, where the checker runs from
R4e, do. The sibling test and the real Node build skip naming it, and the pull-head pin
test still runs its fetch half and skips only the shut-off build.
2026-10-22 19:04:24 +02:00
jschoubben a18eeeef5a Say gitea's states with the SDK's emit, as the toolchain's SDK has no keeps yet (issue 1790, issue 1852) 2026-10-22 19:04:21 +02:00
jschoubben 5b6dfc70f4 Wait until the older clipmenud holds the lock before the launcher starts (issue 1814, S1) 2026-10-22 19:03:03 +02:00
jschoubben 59c28971cb Narrow both build modules' fetch with mesh-sdk's buildstep.Narrow, the one copy (issue 1758, ADR 0499 §4)
The local narrowing of mesh-build-go and mesh-build-node moves into mesh-sdk (feat/r4e-sdk
d1af87a), with its tests; each module keeps its fetch-level test on real git. The three
build modules are pinned to d1af87a.
2026-10-22 18:42:17 +02:00
jschoubben 035c950ff2 mesh-build-node: prove with the real npm that a trunk package is published once and a candidate's never (issue 1587, S20, ADR 0498 §5)
A registry of the test's own speaks npm's publish protocol and asks for the credential: a
candidate's build publishes nothing, a trunk build publishes the packed file the build
digested, and a second trunk build of the same package finds it and publishes nothing.
2026-10-22 18:40:05 +02:00
jschoubben 5a79776625 mesh-build-node: name each bundle's launchers by mesh-sdk's one rule, and move the recipe for the SDK a member's lock names (R4e, lead decision L10)
writeLaunchers takes its entrypoint-to-launcher names from buildstep.Launchers, the rule the
controller reads a bundle by. RecipeSteps goes to 2: a member is now built against the SDK
its own lock names, which can change what a build produces (ADR 0405 §7).
2026-10-22 18:39:06 +02:00
jschoubben b9cdca78b0 mesh-build-node: narrow the fetch to the named paths, build members against the SDK their lock names, and build, pack and publish a package (issues 1758, 1587 S15-S19; ADR 0499 §4, ADR 0498 §5, ADR 0500 §4-§6; lead decision L10)
build_node_fetch takes buildstep.FetchRequest (dir and paths narrow the workspace) and an
optional package {repository, commit, ref}: the group's package member, built and packed
shut off and published nowhere. Each member's lock decides its SDK: the group's packed
file when the version and integrity are the lock's, else the registry's (integrity
checked); a version the registry lacks fails waits for: @novox/mesh-sdk@<version>. A
package artifact is built by its own script and packed with npm pack, shut off; only a
trunk build publishes it, from this process with the write credential (never in a
workspace): absent is published, the same file is done, another is version taken:. The
registry configuration file now lies outside every workspace.
2026-10-22 18:38:22 +02:00
jschoubben fde561075d mesh-build-go: take the fetch's request in mesh-sdk's one form and narrow the workspace to the module's named paths (issue 1758, ADR 0499 §4, lead decision L8)
build_go_fetch parses its arguments with buildstep.ParseFetchRequest; with dir and paths
(from the builder, never from a manifest) the tree is narrowed after the fetch and the
merge to those paths, the manifest and the lock files, so a build reads nothing else. The
narrowing waits to move into mesh-sdk as buildstep.Narrow.
2026-10-22 18:32:36 +02:00
jschoubben 3cce15fa81 restic: let the thinning wait up to ten minutes for a held repository lock (issue 620)
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 | 3cce1…
mesh/merge-gate head 3cce15fa811e merged into trunk 835bf22b7d40 — pass: builds restic → ace, g14, novox, shanks; no bus step; every machine composes with …
mesh/repo-check head 3cce15fa811e merged into trunk 835bf22b7d40 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
A restic outside the night's lock, such as a listing while a delivery plan backs up
data modules before their moves, held the lock 246 ms before the thinning asked for it,
and the thinning failed at once, raising an urgent condition for a timing collision.
restic's --retry-lock now waits up to ten minutes on both of the thinning's forgets;
past that it fails with restic's words as before.
2026-10-22 18:30:16 +02:00
jschoubben 889fa09ea4 Prove a done build's artifacts resolve in a real artifact store, in both build modules (issue 1587, S9 S10, ADR 0498's check)
The integration tests start the distribution registry (the store's own program) in the
test with its in-memory storage and the real PushBlob: the built bundle and archive each
answer a reference ending in its digest, the key the builder would write is valid and
deliverable, and the store serves bytes of that digest at the reference. The registry is
imported by these tests alone (go list -deps of the programs does not name it).
2026-10-22 18:28:52 +02:00
jschoubben 3a25b6e1f8 mesh-build-node: push each done bundle to the artifact store from its own process and answer its reference (issue 1587, S5-S8, ADR 0498 §1 §2, lead decision L9)
After the build ended the module pushes the packed bundle with mesh-sdk's PushBlob under
<module>/<artifact> by its digest, a candidate's as a trunk build's, and the step answers
artifact-store://<module>/<artifact>/blobs/<digest> instead of the archive's path. A push
that fails fails the step not stored: <artifact>: <the store's words>; the packed bytes are
never written to the node. The manifest requires artifact-store and places its address.
2026-10-22 18:25:14 +02:00
jschoubben 22992da15d mesh-build-node: emit a package that builds before its tests, so tests importing its own output find it (issue 1869, part of issue 1309's run step)
A package with a build or prepare script and a tsconfig.json is built with tsc -p . in the
shut-off run step, at both levels, instead of the --noEmit type-check; the fetch's
--ignore-scripts never runs those scripts. mesh-sdk's tests import ../dist and failed
ERR_MODULE_NOT_FOUND. The result names the step build.
2026-10-22 18:23:25 +02:00
jschoubben b6247f837d mesh-build-go: push each done archive to the artifact store from its own process and answer its reference (issue 1587, S1-S4, ADR 0498 §1 §2, lead decision L9)
After the build ended the module pushes the packed bundle or archive with mesh-sdk's
PushBlob under <module>/<artifact> by its digest, a candidate's as a trunk build's, and the
step answers artifact-store://<module>/<artifact>/blobs/<digest> instead of a path. A push
that fails fails the step not stored: <artifact>: <the store's words>. The packed bytes are
never written to the node. The manifest requires artifact-store and places its address.
2026-10-22 18:22:17 +02:00
jschoubben 9c5b7e14e1 mesh-build-go: fold the siblings' commits into every digest and echo them, and test and digest the integration level under integration,isolation (lead decision L5, the checker's agreement; issue 1309)
The fetch writes beside.json naming the siblings it placed; the digest adds a line per
sibling to every package's text and answers them as beside, so a core repository's verdict
is kept only for the siblings it was judged with. DigestVersion 3, once for both changes:
the integration level's tags are integration,isolation, so the bubblewrap proofs run in
every check on a build node.
2026-10-22 18:18:03 +02:00
jschoubben c0873728e2 Replace the deleted manifest reads check by each build module's state announcement (ADR 0495 §4-§5, issue 1790) 2026-10-22 18:17:04 +02:00
jschoubben 86ba54b06e Take the SDK at the milestone's mesh-sdk head 8892a117 in pacman, which holds main's SDK and the milestone's (issue 1840)
mesh/repo-check head 86ba54b06e09 merged into trunk b5f812270eae beside mesh-controller#439@abe356a2 — pass: its merge-check.sh passed, each part in its to…
mesh/check failed: unit: i3/cmd/i3-tools.TestTheMainFileAndEveryModulesContributionLoadTogether: manifest_test.go:247: expected the launcher, clipboar…
mesh/merge-gate head 86ba54b06e09 merged into trunk b5f812270eae — pass: group feat/upgrade-safety composed: every machine composes with the change as it d…
mesh/delivery covered after its failure: a later walk that contains it was delivered: plan-1792694058124013462 (novox/mesh-catalog at 2f05e707 into main,…
mesh/delivery-group group feat/upgrade-safety delivered: every member is delivered
2026-10-22 18:14:25 +02:00
jschoubben af91a7e660 Merge remote-tracking branch 'origin/main' into feat/upgrade-safety 2026-10-22 18:14:16 +02:00
jschoubben d45767bb2f Keep another module's path out of i3's fixture (issue 1814, S2) 2026-10-22 18:13:58 +02:00
jschoubben d108487396 mesh-build-go: read a pinned release compressed with xz as the build agent does, so betterbird's archive gets a done key (issue 1590)
The xz reader is github.com/therootcompany/xz, declared in go.mod as build-agent declares it,
with build-agent's framing walk copied (xz_framing.go and its tests): too many blocks or
streams are refused before anything is decoded, a dictionary over 64 MiB is refused. The
same release tar under gzip and under xz packs to the build agent's archive.
2026-10-22 18:13:49 +02:00
jschoubben 027ea030fa Qualify a short base image name before buildah reads it (issue 1824) 2026-10-22 18:13:09 +02:00
jschoubben 6eb9eb53de Commit the lock files of mssql, mongodb, model-usage and mesh-catalog, and refuse a Node module without one (issue 1868, ADR 0469 §5) 2026-10-22 18:12:32 +02:00
jschoubben eed6be0f4a mesh-build-go: run go test, go build and go list in bubblewrap, so a change's code reaches no key, login or network (issue 1309, S1-S6, ADR 0448)
Each run step goes through mesh-sdk's buildstep.Isolated: no network, none of the account,
the workspace read-write, the Go caches beneath a throwaway layer unless the build step is
told publish (a trunk build). The integration level runs its store, its bus and its tests in
one shut-off process (this program again, level <spec>), so they reach each other and
nothing else. build_go_test takes a -run pattern (a try's tests). The module declares the
bubblewrap package and refuses a step, unhealthy, while bwrap is missing or older than 0.10.
The proof test runs a probe test through the real unit level.
2026-10-22 18:12:01 +02:00
jschoubben 51900367a0 mesh-build-go: announce the states it keeps from its code, so readers are granted from its word (issue 1790, ADR 0495 §2 §5)
Kept (toolchains, keys, steps, each read by another module today) is said with stdio.Keeps
before the module serves; announcetest.Check holds the announcement equal to the code. Pinned
to mesh-sdk feat/r4e-sdk 9cb4d59. The manifest test's call of the deleted
StateReadsNothingDeclares still has to go (the edit is waiting on the operator's permission).
2026-10-22 18:12:01 +02:00
jschoubben 77596f37a3 mesh-build-image: announce the states it keeps from its code, so readers are granted from its word (issue 1790, ADR 0495 §2 §5)
Kept (keys, steps, each read by another module today) is said with stdio.Keeps before the
module serves; announcetest.Check holds the announcement equal to the code. Pinned to
mesh-sdk feat/r4e-sdk 9cb4d59. The manifest test's call of the deleted
StateReadsNothingDeclares still has to go (the edit is waiting on the operator's permission).
2026-10-22 18:12:01 +02:00
jschoubben 56f842c3af mesh-build-node: run the type-check, the test script, tsc and esbuild in bubblewrap, so a change's code reaches no key, login or network (issue 1309, S1 S7 S8 S9, ADR 0448)
Each run step goes through mesh-sdk's buildstep.Isolated: no network, none of the account,
the workspace read-write, the toolchain read-only, the npm cache beneath a throwaway layer
unless the build step is told publish (a trunk build). The module declares the bubblewrap
package and refuses a step, unhealthy, while bwrap is missing or older than 0.10. The proof
test runs a probe script through the real test step.
2026-10-22 18:08:12 +02:00
jschoubben 075b61901c mesh-build-node: announce the states it keeps from its code, so readers are granted from its word (issue 1790, ADR 0495 §2 §5)
Kept (toolchains, keys, steps, each read by another module today) is said with stdio.Keeps
before the module serves; announcetest.Check holds the announcement equal to the code. The
module is pinned to mesh-sdk feat/r4e-sdk 9cb4d59.
2026-10-22 18:08:06 +02:00
jschoubben 0270027bf9 Take the SDK at the milestone's mesh-sdk head f8b8f3a6 in pacman, which the delivery group's members must pin exactly (issue 1840) 2026-10-22 18:07:40 +02:00
mesh-tools 835bf22b7d Merge pull request 'only-office: stop declaring the old lib directory (issue 1854)' (#449) from fix/1854-drop-old-lib into main 2026-10-22 16:06:46 +00:00
jschoubben e85b7da63b Let i3's contribution test read its fixture, not the siblings' manifests (issue 1814, S2, ADR 0495 §1) 2026-10-22 18:05:04 +02:00
jschoubben c112df0c1c Announce the states the forge module keeps (issue 1790, ADR 0495 §2 §5) 2026-10-22 18:03:06 +02:00
jschoubben 8e791896f1 Announce the states the delivery gate keeps (issue 1790, ADR 0495 §2 §5) 2026-10-22 18:02:11 +02:00
jschoubben b07a101caf Answer each named path's git object id from gitea_commit_trees (issue 1758, S1-S2, ADR 0499 §3) 2026-10-22 17:59:23 +02:00
jschoubben 72effa1fb1 Copy an upstream artifact into the artifact store by its pinned digest (issue 1587, S11-S13, ADR 0498 §4) 2026-10-22 17:57:05 +02:00
jschoubben e53ac973e9 mesh-delivery-gate: stop asking the controller's forget-checks (issue 1318, the gate's part)
The controller's forget-checks goes with the merge gate's checks (ADR 0503 §3)
and 1318 removes the verb in the same group, so the gate must stop calling it
first: a closed pull request is stopped and asks the controller nothing, and
the manifest no longer invokes it. delivery-check stays for hq's base until
R4g (the lead's decision for 1302).
2026-10-22 17:50:44 +02:00
jschoubben f1f18bf2bd mesh-delivery-gate: read mesh/check alone on a base that requires it (issue 1302, S1-S10)
Once main requires mesh/check, the controller publishes no verdict for it and
the gate, which moved deliveries on the controller's checked, would hold every
pull request proposed. A head's mesh/check is now its verdict, from the
forge's status event (gitea now sends the status's words) or its open pull
requests. The Approve is asked only on the checker's verdict at exactly the
heads the ask covers, so a member judged alone never passes its group; a
group on mesh/check asks no composed check and owes no mesh/merge-gate; a
member's mesh/check that changes withdraws the ask. Nothing is asked of the
controller for such a base, and recheck refuses, naming checker_pull. hq's
mesh/repo-check keeps today's path.
2026-10-22 17:50:25 +02:00
jschoubben c41c968086 gitea: judge and write only the statuses a base requires, so merges go on mesh/check (issue 1301, S5-S7)
Once main requires mesh/check, nothing writes mesh/merge-gate, and the merge
tool, which judged on it, would refuse every merge. It now judges on the
status the base requires: mesh/check's line names the trunk it judged, an
out-of-date one is refused and nothing is asked (the checker re-checks on a
trunk move, ADR 0447 §4); hq's mesh/repo-check is judged as before. The
announcer, the verdict, a trunk's move and a build's start write the
controller's two statuses only where a base still requires them, so hq keeps
mesh/repo-check and no stale mesh/merge-gate appears anywhere else.
2026-10-22 17:49:53 +02:00
jschoubben 939475cb63 gitea: only the forge bridge writes mesh/check, through a tool of its own (issue 1301, S4)
Once main requires mesh/check, a hand that set it could pass a merge past the
checker, the gap issue 293 closed for the two statuses before it.
gitea_commit_status now refuses mesh/check in every state, and the new
gitea_check_status writes it alone; the bus grants that tool only to a module
whose manifest invokes it, the forge bridge (the lead's choice A).
2026-10-22 17:49:53 +02:00
jschoubben 5e24deb0f1 gitea: require mesh/check in place of the two old statuses on every main the gate judges (issue 1301, S1-S3)
ADR 0447 §1 has main require mesh/check alone. The repositories program now
requires it in place of mesh/merge-gate and mesh/repo-check on every listed
repository and on every other one whose main requires mesh/merge-gate, so the
switch needs no hand-kept list (lead decisions L4, L11). A main requiring
mesh/repo-check alone (hq) keeps it until issue 1296 (ADR 0503 §5).
2026-10-22 17:49:53 +02:00
jschoubben 76a0a726f0 issue 1841/S15: put the booted kernel first when GRUB's menu is generated again, so the fallback kernel, sorted first by version, never becomes the default entry (ADR 0508 §9) 2026-10-22 17:49:21 +02:00
jochen df821c8a4a only-office: stop declaring the old lib directory, emptied on the operator's word (issue 1854)
mesh/merge-gate head df821c8a4ab6 merged into trunk 49caf17ff56e — pass: builds only-office → novox; no bus step; every machine composes with the change as…
mesh/repo-check head df821c8a4ab6 merged into trunk 49caf17ff56e — 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 …
Issue 1843 kept the old /var/lib/only-office/lib declared only to close it to 0700 while it held
data. Its contents are deleted now, so the declaration has no job left; mesh-host removes the
empty directory once it is no longer declared.
2026-10-22 17:49:08 +02:00
jschoubben f40269caec issue 1841/S2: leave the login manager's configuration path out of pacman's captured log, which the paths check refuses in another module's fixture 2026-10-22 17:45:29 +02:00
jschoubben 2149d8f3e7 Merge remote-tracking branch 'origin/main' into feat/upgrade-safety 2026-10-22 17:40:30 +02:00
jschoubben e65c8c8961 issue 1841/S13: describe the package view, the runs, the round, the root helper and the health read from the bus in pacman's README 2026-10-22 17:39:52 +02:00
jschoubben ea8e0def6e issue 1841/S12: prove the round on a real bus with two made-up nodes, so the second node is seen to start only after the first passed and never after a failed check 2026-10-22 17:39:15 +02:00
jschoubben f8ac498d64 issue 1841/S11: judge the upgrades' health from the runs, rounds and held kernels on the bus, so a node that slept through a run is judged by what happened, not by a unit's age (folds issue 1579) 2026-10-22 17:38:08 +02:00
jschoubben 276422a85b issue 1841/S8: join the round to the keeper's loop, so a node starts the moment the node before it passed and a pass is answered from the operator's channel 2026-10-22 17:35:36 +02:00
jschoubben 28ef6e9e27 issue 1841/S9: carry out the upgrade's root acts through a root helper on a socket only the keeper opens, so no sudo rule lets the keeper's account run anything as root (ADR 0510) 2026-10-22 17:34:20 +02:00
mesh-tools 49caf17ff5 Merge pull request 'only-office: keep its working directories under a root-only box (issue 1843)' (#448) from fix/1843-private-box into main 2026-10-22 15:33:32 +00:00
mesh-tools cac09a4e20 Merge pull request 'Ask a check the build queue lost again, and take a recheck from proposed when no check of its head is held (issue 1274)' (#447) from fix/1274-ask-a-lost-check-again into main 2026-10-22 15:27:42 +00:00
jochen 29b4c02709 only-office: keep its working directories under a root-only box (issue 1843)
mesh/check checking 29b4c0270987 on cac09a4e203e
mesh/merge-gate head 29b4c0270987 merged into trunk cac09a4e203e — pass: builds only-office → novox; no bus step; every machine composes with the change as…
mesh/repo-check head 29b4c0270987 merged into trunk cac09a4e203e — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
The image opens everything below each mount to every account on every
start, so with the module root and lib at 0711 any account on the
machine could read the document cache. The mounts now sit under a
root-owned 0700 box the container never sees; the root stays 0711 for
the converter. The old lib keeps its id so mesh-host closes it to 0700
(a new id there would be used as found, issue 339).
2026-10-22 17:24:05 +02:00
mesh-tools c3d3194bc8 Merge pull request 'nextcloud: keep onlyoffice's jwt off argv, and re-check a failed connection every tick (issues 1831, 1832)' (#446) from fix/onlyoffice-followups into main 2026-10-22 14:51:59 +00:00
mesh-tools d0463fb510 Merge pull request 'Fetch a Go pin only a pull request carries from its pull head, so a group member pinning its mesh-sdk head builds (issue 1148)' (#445) from fix/1148-pull-head-pins into main 2026-10-22 14:40:56 +00:00
jochen 6bf054b982 Take issue 1843's owner change back out: giving the converter the module root opens a link swap to root (review), so 1843 needs its own design
mesh/check failed: unit: modules/gitea.test: ✔ the same merge, head or close announced twice has the same id, and another has another (8.058792ms) | 6…
mesh/merge-gate head 6bf054b98261 merged into trunk d0463fb5100f — pass: builds nextcloud → novox; no bus step; every machine composes with the change as i…
mesh/repo-check head 6bf054b98261 merged into trunk d0463fb5100f — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-22 16:37:35 +02:00
jschoubben fbbf28d21c mesh-delivery-gate: read the queue's state word as the controller says it, in flight (issue 1274)
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 | fbbf2…
mesh/merge-gate head fbbf28d21c37 merged into trunk c3d3194bc8d4 — pass: builds mesh-delivery-gate → novox; no bus step; every machine composes with the ch…
mesh/repo-check head fbbf28d21c37 merged into trunk c3d3194bc8d4 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-22 16:36:23 +02:00
jschoubben fb89990e2b mesh-delivery-gate: ask a check the build queue lost again, and take a recheck from proposed when none is held (issue 1274)
A proposed delivery whose asked check no queue entry holds stayed proposed for good (met in 1311, 1336, 1726, 1792, 1795, 1833). The sweep reads the controller's queue and asks again past max(10 minutes, the repository's last check time); recheck from proposed is taken on the same fact; never while the queue holds one.
2026-10-22 16:34:33 +02:00
jochen d0a3784be2 only-office: bar every account but the converter from the state root (issue 1843)
lib (bind-mounted into the container at /var/lib/onlyoffice) was 0711 so
nginx inside the container could reach App_Data/cache, and the module
root above it stayed 0711 too, so any account on the machine could walk
through the root into lib's 0755 directories and 0644 files to read
converted documents. The container never passes through the root — only
the converter process does, to read jwt.secret and
broker-converter.secret — so the root is now 0710, owned by the
converter account: only that account and root pass through it. lib
stays 0711 (1837/AC3).
2026-10-22 16:34:06 +02:00
jschoubben 555ed4963a Read the pinned placement at its new path in the pin integration test (issue 1148)
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 | 555ed…
mesh/merge-gate head 555ed4963a8a merged into trunk 77578a882f8a — pass: builds mesh-build-go → ace, g14, novox, shanks; no bus step; every machine compose…
mesh/repo-check head 555ed4963a8a merged into trunk 77578a882f8a — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-22 16:29:37 +02:00
jochen edb6d3b7d3 nextcloud: keep checking onlyoffice's connection while the last check failed (issue 1832)
applyOnlyOffice only ran the connection check when a setting changed, so
once it failed while settings matched, nothing checked again and the
failure was logged once and forgotten. The client now remembers whether
the last check failed and re-runs it on every tick until it passes;
index.ts logs every failing tick and one line when it recovers.
2026-10-22 16:28:35 +02:00
jochen 8928e6f972 nextcloud: keep onlyoffice's jwt_secret off every argv (issue 1831)
occ's config:app:set put the secret on the docker exec command line, so
it sat in the docker CLI's argv, the container's php argv and docker's
exec record. Apply jwt_secret through config:import on occ's stdin
instead; the other three onlyoffice keys still go through
config:app:set.
2026-10-22 16:27:15 +02:00
jschoubben 2d8c8f6d75 Share one placement per repository and commit for pins on pull heads; say a pin is on no pull request head and what go said (issue 1148) 2026-10-22 16:24:18 +02:00
jschoubben 9194e15072 Fetch a Go pin on a pull request's head: place it beside the workspace and hand it to Go by a modfile replace (issue 1148) 2026-10-22 16:19:23 +02:00
mesh-tools 77578a882f Merge pull request 'Hold a delivery's problem messages until it settles, and say a refusal only when its fresh check did not cure it (ADR 0505)' (#444) from feat/quiet-deliveries into main 2026-10-22 14:01:19 +00:00
mesh-tools 3f9081d00e Merge pull request 'Let nginx through only-office's lib directory, so OnlyOffice serves converted documents instead of answering 403 (issue 1837)' (#443) from fix/1837-lib-traverse into main 2026-10-22 13:44:06 +00:00
mesh-tools 7842001c53 Merge pull request 'A tool verifies the bus's snapshot, so an upgrade or a restore is checked without a docker exec (issue 1842)' (#442) from fix/1842-bus-snapshot-verify into main 2026-10-22 13:08:47 +00:00
jschoubben 09e749bc1a Format the ADR 0505 test file, so the repository check passes
mesh/repo-check head 09e749bc1a1c merged into trunk 3f9081d00eb8 beside mesh-controller#436@b7d03b68 — pass: its merge-check.sh passed, each part in its to…
mesh/check failed: g14: novox/mesh-catalog#444: checks/particulars/go.mod cannot be read to check its mesh-sdk pin: Gitea API /repos/novox/mesh-catalo…
mesh/merge-gate head 09e749bc1a1c merged into trunk 3f9081d00eb8 — pass: group feat/quiet-deliveries composed: every machine composes with the change as it…
mesh/delivery delivered
mesh/delivery-group group feat/quiet-deliveries delivered: every member is delivered
2026-10-22 15:00:37 +02:00
jschoubben f339d0aab7 Merge branch 'feat/upgrade-safety' into p2
# Conflicts:
#	modules/pacman/cmd/pacman-tools/main.go
#	modules/pacman/cmd/pacman-tools/transaction.go
#	modules/pacman/module.json
2026-10-22 14:44:41 +02:00
jschoubben 43fccb843c issue 1841/S10: return a node to a run's record, saying first what each package goes back to and from where
pacman_rollback plans from the bus: each package now -> record from the node's cache, else the archive's
exact version, else the archive's repository on the record's date; added packages listed and removed only
when asked; foreign packages untouched. apply is an ask the node's keeper runs as a rollback run through the
root helper's new Rollback act (RootActs, upgrades.go), with rollback.started, rollback.finished and the
after-upgrade check (TakeAsk's rollback case, transaction.go).
2026-10-22 14:44:24 +02:00
jschoubben f550f1c6b2 issue 1841/S8: upgrade the nodes one at a time in a weekly round in upgrade-order, stopping at a failure and passing a node only on the operator's Approve, so one bad upgrade never reaches every machine 2026-10-22 14:44:17 +02:00
jschoubben 71f47c3291 issue 1841/S5: refuse the root helper's new maps act too until its client is built in 2026-10-22 14:40:09 +02:00
jschoubben dc703d1511 Merge branch 'feat/upgrade-safety' into p2 2026-10-22 14:40:00 +02:00
jschoubben d70135bf08 issue 1841/S5: join the upgrade parts to the keeper's loop, so a node's log, records, unfinished runs and asks are taken up by its own keeper
At its start the keeper reads the log into runs once, prunes old records and carries unfinished runs on;
after every released lock it carries them on again; it takes each ask at <node>.* and prunes records daily.
The root helper's client is a constructor (newRootActs) the helper replaces; until then every act is
refused, named.
2026-10-22 14:39:59 +02:00
jschoubben ea8692b08f Say a message two walks held once, in the message of the walk whose limit is due (ADR 0505 §7) 2026-10-22 14:39:35 +02:00
jschoubben ff0f43ea2f Merge branch 'feat/upgrade-safety' into p1 2026-10-22 14:39:29 +02:00
jschoubben 2b2517e4a5 issue 1841/S7: name the processes still mapping a replaced file and fail the check on a DKMS module not built for an installed kernel, so a machine is never left unable to load its drivers unnoticed 2026-10-22 14:39:29 +02:00
jschoubben b7a4a1a6b0 Merge branch 'feat/upgrade-safety' into p2 2026-10-22 14:38:05 +02:00
jschoubben c1a9ad2abc issue 1840/S6: take the SDK's process delete for the not-current mark, now that the process state has one 2026-10-22 14:38:02 +02:00
jschoubben c93f77ac19 Merge branch 'feat/upgrade-safety' into p2
# Conflicts:
#	modules/pacman/go.mod
2026-10-22 14:37:59 +02:00
jschoubben 75f3f52589 Merge branch 'feat/upgrade-safety' into p1 2026-10-22 14:37:58 +02:00
jschoubben 900a2da3e3 issue 1841/S6: check after every upgrade the files it left, the news since the last one, units newly failed and a restart needed, so the operator sees what an upgrade asks of them 2026-10-22 14:37:58 +02:00
jschoubben b7f3664839 issue 1840/S10: run the keeper whole against a real bus, so its key, its event and another instance's view are proved together
Written and compiled under the integration tag; the merge check runs it.
2026-10-22 14:37:45 +02:00
jschoubben 9511074f40 Take the SDK whose process can delete a key (issue 1840/S1) 2026-10-22 14:37:28 +02:00
jschoubben 9651a8b2da issue 1840/S9: prove pacman's output puts no command line or environment on the bus
A fixture of pacman and checkupdates output holding command lines, environment variables and messages
leaves only names, versions and repositories in installed, updates and package.changed, and no command
the keeper runs names a package, a foreign one least of all.
2026-10-22 14:37:00 +02:00
jschoubben 3cf75580b5 Never hold a message past its first hold's limit, so a fault on a machine walk after walk touches still reaches the operator (ADR 0505 §7) 2026-10-22 14:36:35 +02:00
jschoubben 32c66a98c1 Merge branch 'feat/upgrade-safety' into p2 2026-10-22 14:36:14 +02:00
jschoubben 62437f8964 issue 1840/S8: answer the package view from the bus, so every node is read at once and none is asked
pacman_packages answers one package on every node, or each node's counts, its explicit packages no module
declares and its foreign ones apart, the versions that differ and the packages on some nodes only, with a
node over the bus's cap marked not current. pacman_updates, pacman_installed and pacman_foreign read the
keeper's states instead of the machine; pacman_updates says when each node's list last changed.
2026-10-22 14:36:11 +02:00
jschoubben ac4d156cb7 Merge branch 'feat/upgrade-safety' into p1 2026-10-22 14:35:22 +02:00
jschoubben 95e69276c3 Carry out an upgrade as an ask its node's keeper takes, through the root helper, and follow a run to its end after a restart, so a hand upgrade is a recorded, checked run like any other (issue 1841/S5) 2026-10-22 14:35:19 +02:00
jschoubben 56b285d3e8 issue 1840/S7: prove updates is read at start, after a transaction, after the mirrors and daily, and written only on change
A failed read keeps the last list and says reachable false, so a list the node can no longer confirm is
never shown as current, and a first read that fails is never an empty list taken for an answer.
2026-10-22 14:33:20 +02:00
jschoubben 5cc401683c Merge branch 'feat/upgrade-safety' into p2 2026-10-22 14:32:13 +02:00
jschoubben 79ce72a7b7 issue 1840/S6: emit through the same EventSink as the upgrade parts, so one connection serves both 2026-10-22 14:32:10 +02:00
jochen a22fdd015b Let nginx through only-office's lib directory, so it can serve the converted files the document service writes there instead of answering 403 (issue 1837)
mesh/check failed: unit: i3/cmd/i3-tools.TestTheMainFileAndEveryModulesContributionLoadTogether: manifest_test.go:247: expected the launcher, clipboar…
mesh/merge-gate head a22fdd015b09 merged into trunk 7842001c53f7 — pass: builds only-office → novox; no bus step; every machine composes with the change as…
mesh/repo-check head a22fdd015b09 merged into trunk 7842001c53f7 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-22 14:32:06 +02:00
jschoubben f40fa6e8e0 issue 1841/S4: format the gate's patterns as gofmt does, which the merge check requires 2026-10-22 14:32:01 +02:00
jschoubben b015576a13 Hold a kernel an installed DKMS module's own bound excludes, with its headers, so an upgrade never leaves a machine whose storage driver cannot load (issue 1841/S4) 2026-10-22 14:31:53 +02:00
jschoubben e4fb4cc793 Merge branch 'feat/upgrade-safety' into p2 2026-10-22 14:31:39 +02:00
jschoubben 1de9a903c8 issue 1840/S6: keep the node's packages in installed, read at the keeper's start and at every released lock
The keeper reads pacman's database into installed.<node> and writes it only when it differs from the key,
says each change as package.changed, and keeps the last value when the list is over the bus's cap, marking
the node not current until it fits again; the health names the size. The loop runs over injected states,
events, clock, watch and machine, and leaves hooks (start, after a transaction, state watches, wakes) for the
upgrade parts of ADR 0508.
2026-10-22 14:31:33 +02:00
jschoubben 0bb6584437 Take the node's package record before a run and read it back, refusing a run whose packages are not current, so a rollback always has a true record to return to (issue 1841/S3) 2026-10-22 14:30:34 +02:00
jschoubben c0bc0d4826 Merge branch 'feat/upgrade-safety' into p1 2026-10-22 14:29:29 +02:00
jschoubben efac1b4856 Keep every upgrade as a run on the bus and read the log's past upgrades once, so pacman_upgrades answers every node's history without asking a machine (issue 1841/S2) 2026-10-22 14:29:23 +02:00
jschoubben bc77afa6bb issue 1841/S16: kernel_boot answers the loader, entries, kernels, booted entry and the fallback
ADR 0508 §9: the operator sees whether every boot menu lists the fallback kernel, and the room left on /boot.
2026-10-22 14:28:23 +02:00
jschoubben e4373768af issue 1841/S14: declare the fallback kernel and its headers after the boot-entries unit
ADR 0508 §9: every DKMS module is built for the fallback kernel too, and its entry is in place first.
2026-10-22 14:27:24 +02:00
jschoubben 2cb2e27cdf issue 1838: name the kernel module's fixtures by what they show (as in the fix's own branch) 2026-10-22 14:27:15 +02:00
jschoubben 0f4e631cdd issue 1841/S15: place the fallback kernel's boot entry from the loader the machine booted with
ADR 0508 §9 and §10: systemd-boot gets an entry from the booted command line, GRUB a regenerated menu only when its kernels change, a found kernel-copying hook is taken over, a full boot partition fails loudly.
2026-10-22 14:26:59 +02:00
mesh-tools dcf6e86d02 Merge pull request 'Refuse a node name of the mesh in the pacman and kernel modules (issue 1838)' (#441) from fix/1838-particulars into main 2026-10-22 12:24:35 +00:00
jschoubben 98ca97fc5b Verify the bus's snapshot with a tool, sharing the snapshot program's archive code (issue 1842)
mesh/delivery delivered
mesh/check failed: ace: novox/mesh-catalog#442: checks/particulars/go.mod cannot be read to check its mesh-sdk pin: Gitea API /repos/novox/mesh-catalo…
mesh/merge-gate head 98ca97fc5b3f merged into trunk dcf6e86d0236 — pass: builds nats, sent nowhere; merging this needs a person's bus upgrade; 1 wait(s) fo…
mesh/repo-check head 98ca97fc5b3f merged into trunk dcf6e86d0236 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
The upgrade and restore steps told a person to docker exec the snapshot program; no mesh tool
did it. nats_snapshot_verify reads the newest bus.tar of the snapshots directory only.
2026-10-22 14:24:25 +02:00
jschoubben 802c72e4ed Take the SDK that lets a process put, list and watch its states (issue 1840/S5) 2026-10-22 14:23:43 +02:00
jschoubben eb717cc514 Declare the pacman keeper and its bus shapes, so upgrades run as rounds on the bus instead of a weekly timer on every node at once (issue 1841/S1, with 1840/S5's manifest)
The keeper process runs under its own account with a bus account of its own,
keeps the six states and emits the nine events of ADR 0507 and ADR 0508; its
config (upgrade-order, record-days, round-start-minutes) lands in its config
file and restarts it. shapes.go fixes every key's value and event body as the
interface the keeper and the verbs share. The weekly timer and its service are
dropped, which the node-engine removes.
2026-10-22 14:23:22 +02:00
mesh-tools db67a0e55e Merge pull request 'Pin only-office's secure-link secret, so OnlyOffice's signed download links verify across restarts (issue 1837)' (#440) from fix/1837-secure-link into main 2026-10-22 12:20:22 +00:00
jschoubben 34277ef60f Prove over the real bus that a refusal the gate answers is stalled only once its fresh check did not cure it (ADR 0505 §4) 2026-10-22 14:18:33 +02:00
jschoubben a3b51a9d9a issue 1838: catch node names in paths, hyphenated and lowerCamel forms; test the exit codes
mesh/merge-gate head a3b51a9d9a02 merged into trunk db67a0e55ec3 — pass: the change touches no module of the mesh's graph
mesh/repo-check head a3b51a9d9a02 merged into trunk db67a0e55ec3 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/check failed: unit: nfsserver/cmd/nfs-server.TestARealRootOwnedFolderIsRefused: --- FAIL: TestARealRootOwnedFolderIsRefused (0.00s) | a3b51a9d9a0…
mesh/delivery delivered
The forge blank hid any novox/ path segment, and joined names passed; read covered from the root.
2026-10-22 14:18:26 +02:00
jschoubben e5608f7b00 Say in the gate's README when a refused or rejected merge reaches the operator (ADR 0505 §4, §5) 2026-10-22 14:17:40 +02:00
jschoubben 5a18eb5f4a Leave a delivery that needs a new head to its author for two hours, then name the author to the operator (ADR 0505 §5) 2026-10-22 14:17:25 +02:00
jschoubben 138a1e1c4b issue 1838: refuse a node name of this mesh in the pacman and kernel modules
A module is for any mesh; the check reads the list of names at the root and the covered modules, and the kernel module's fixtures now say what they show.
2026-10-22 14:15:53 +02:00
jschoubben f1fbd91f0d Say a refusal the gate is already answering as stalled only once its fresh check did not cure it (ADR 0505 §4) 2026-10-22 14:15:49 +02:00
jschoubben 5ddc3ff312 Measure a repository's last check time from its newest verdict (ADR 0505 §4) 2026-10-22 14:13:18 +02:00
jschoubben b5908b486e Give the gate a settings file for the bounds ADR 0505 sets on saying a refusal and a needed new head 2026-10-22 14:12:31 +02:00
jschoubben aa57bbcd44 Tell a settled walk's end at the next flush, and prove the hold and the delivery line over the real bus (ADR 0505 §1-§3) 2026-10-22 14:11:16 +02:00
jschoubben 67a585600d End every problem message with whether a delivery was running on what it is about (ADR 0505 §1) 2026-10-22 14:10:06 +02:00
jschoubben 1caf728a4b Send at once what is raised on a failed walk's machines in the two minutes after it (ADR 0505 §6) 2026-10-22 14:07:51 +02:00
jschoubben 02816c0f7a Hold what a delivered walk touched until it has settled, and tell its end then (ADR 0505 §3, §7) 2026-10-22 14:07:15 +02:00
mesh-tools 7bf63b0ac5 Merge pull request 'The bus runs its upstream image with the mesh's programs mounted (issues 1640, 1641, 1642, ADR 0480 §1-§3, §5, §6; ADR 0497 §5; ADR 0506)' (#434) from feat/r4d into main 2026-10-22 12:05:13 +00:00
jschoubben 426db57d3f Hold what a walk's merges change on a machine it sends nothing to, and name only where it sent (ADR 0505 §2) 2026-10-22 14:04:06 +02:00
jschoubben 1d995d2bef Hold the controller's own lease, bus and health conditions while its delivery runs (ADR 0505 §2) 2026-10-22 14:03:09 +02:00
jschoubben 92225cd528 Make the settle after a delivered walk a setting, two minutes by default (ADR 0505 §3) 2026-10-22 14:02:17 +02:00
jochen 35e4764d35 Pin only-office's secure-link secret, so its document service and nginx sign and check Editor.bin links with one value across restarts (issue 1837)
mesh/merge-gate head 35e4764d354a merged into trunk 7bf63b0ac597 — pass: builds only-office → novox; no bus step; every machine composes with the change as…
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 | 35e47…
mesh/repo-check head 35e4764d354a merged into trunk 7bf63b0ac597 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
2026-10-22 13:40:42 +02:00
mesh-tools b7b9f2a3da Merge pull request 'Join nextcloud to only-office through a document-server provision, so OnlyOffice opens documents again (issue 1830)' (#438) from fix/1830-onlyoffice-link into main 2026-10-22 09:49:59 +00:00
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 delivered
mesh/merge-gate head 68183ee4413d merged into trunk 7bf63b0ac597 — pass: group feat/r4d composed: every machine composes with the change as it did without …
mesh/delivery-group group feat/r4d delivered: every member is delivered
mesh/check failed: unit: clipmenu/cmd/clipmenu-tools.TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock: manifest_test.go:199: the launcher …
mesh/repo-check head 68183ee4413d merged into trunk b7b9f2a3da78 beside mesh-controller#433@707bfba4 — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.s…
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
jochen 47df3a4d60 Merge remote-tracking branch 'origin/main' into fix/1830-onlyoffice-link
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 | 47df3…
mesh/merge-gate head 47df3a4d607a merged into trunk 529c156f6407 — pass: builds nextcloud, only-office → novox; no bus step; every machine composes with th…
mesh/delivery delivered
mesh/repo-check head 47df3a4d607a merged into trunk 529c156f6407 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
2026-10-22 11:30:58 +02:00
jochen 2d2e831659 Apply only-office's bound address and secret to onlyoffice settings every tick (issue 1830) 2026-10-22 11:30:26 +02:00
jochen d3ebaf72eb Require only-office's document server and carry its address and secret into nextcloud's config (issue 1830) 2026-10-22 11:27:49 +02:00
jochen 183b9680c1 Offer only-office's document server with its JWT as the shared credential (issue 1830) 2026-10-22 11:20:08 +02: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
419 changed files with 34022 additions and 2031 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)
}
}
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/locks
go 1.22
+105
View File
@@ -0,0 +1,105 @@
// Command locks refuses a TypeScript module that names packages beyond the SDK in its package.json and commits no
// package-lock.json (novox/hq ADR 0469 §5, issue 1868): the Node build module installs a module's own packages only
// from its committed lock, so such a module is refused by the builder and could not be delivered.
//
// go run . <catalogue root>
//
// Exits 1 on a finding. The packages that count are those the build module counts (mesh-build-node fetch.go
// ownDependencies): the `dependencies` beyond the SDK, typescript and the @types packages.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Finding is one module refused: it names Packages beyond the SDK and has no lock file.
type Finding struct {
Module string
Packages []string
}
// Check reads every module under root/modules that builds a TypeScript artifact.
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 {
Language string `json:"language"`
} `json:"artifacts"`
} `json:"build"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
typescript := false
for _, a := range m.Build.Artifacts {
typescript = typescript || a.Language == "typescript"
}
if !typescript {
continue
}
dir := filepath.Dir(path)
pkgRaw, err := os.ReadFile(filepath.Join(dir, "package.json"))
if os.IsNotExist(err) {
continue
}
if err != nil {
return nil, err
}
var pkg struct {
Dependencies map[string]json.RawMessage `json:"dependencies"`
}
if err := json.Unmarshal(pkgRaw, &pkg); err != nil {
return nil, fmt.Errorf("%s: package.json: %w", m.Module, err)
}
var names []string
for n := range pkg.Dependencies {
if n != "@novox/mesh-sdk" && n != "typescript" && !strings.HasPrefix(n, "@types/") {
names = append(names, n)
}
}
if len(names) == 0 {
continue
}
if _, err := os.Stat(filepath.Join(dir, "package-lock.json")); err != nil {
sort.Strings(names)
out = append(out, Finding{Module: m.Module, Packages: names})
}
}
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 names packages beyond the SDK (%s) and has no package-lock.json: commit one made by the build module's toolchain (novox/hq ADR 0469 §5)\n", f.Module, strings.Join(f.Packages, ", "))
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("locks: every TypeScript module that names packages beyond the SDK commits its lock")
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"os"
"path/filepath"
"reflect"
"testing"
)
// module writes modules/<name> with a TypeScript artifact, the package.json given ("" for none) and, when lock, a lock.
func module(t *testing.T, root, name, language, pkg string, lock bool) {
t.Helper()
dir := filepath.Join(root, "modules", name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
write := func(file, body string) {
if err := os.WriteFile(filepath.Join(dir, file), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("module.json", `{"module":"`+name+`","build":{"artifacts":[{"name":"tools","kind":"bundle","language":"`+language+`","from":"index.ts"}]}}`)
if pkg != "" {
write("package.json", pkg)
}
if lock {
write("package-lock.json", "{}")
}
}
const sdkOnly = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1"},"devDependencies":{"typescript":"^5","@types/node":"^22"}}`
const withPg = `{"dependencies":{"@novox/mesh-sdk":"^0.1.1","pg":"^8","@types/pg":"^8"}}`
func TestAModuleNamingOnlyTheSDKNeedsNoLock(t *testing.T) {
root := t.TempDir()
module(t, root, "plain", "typescript", sdkOnly, false)
module(t, root, "none", "typescript", "", false)
got, err := Check(root)
if err != nil || len(got) != 0 {
t.Fatalf("%v %v", got, err)
}
}
func TestAModuleNamingMorePackagesWithoutALockIsRefusedNamingThem(t *testing.T) {
root := t.TempDir()
module(t, root, "db", "typescript", withPg, false)
module(t, root, "db-locked", "typescript", withPg, true)
got, err := Check(root)
if err != nil {
t.Fatal(err)
}
if want := []Finding{{Module: "db", Packages: []string{"pg"}}}; !reflect.DeepEqual(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
}
func TestAModuleThatIsNotTypeScriptIsNotJudged(t *testing.T) {
root := t.TempDir()
module(t, root, "go", "go", withPg, false)
if got, err := Check(root); err != nil || len(got) != 0 {
t.Fatalf("%v %v", got, err)
}
}
+3
View File
@@ -0,0 +1,3 @@
# The modules the check covers (novox/hq ADR 0507 §9, issue 1838), one a line; issue 1838 widens it.
pacman
kernel
+3
View File
@@ -0,0 +1,3 @@
module git.novox.be/novox/mesh-catalog/checks/particulars
go 1.22
+159
View File
@@ -0,0 +1,159 @@
// Command particulars keeps this mesh's node names out of the modules it covers (novox/hq ADR 0507 §9,
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
// fixture of a covered module names a node of this one.
//
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
// before a line is read.
//
// go run . <catalogue root>
//
// Exits 1 naming file:line and the word, 2 when a list cannot be read.
package main
import (
"bufio"
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
// forge is the forge's host and organisation path, blanked before a line is read; a path segment novox/ elsewhere
// (testdata/novox/x, /srv/novox/data) is not blanked.
var forge = regexp.MustCompile(`(?i)git\.novox\.be(?:/novox/[\w.-]+)?|(?:^|[^/\w.-])novox/[\w.-]+`)
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
}
os.Exit(check(root))
}
// check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable.
func check(root string) int {
names, err := readList(filepath.Join(root, "particulars"))
if err == nil && len(names) == 0 {
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
}
var covered []string
if err == nil {
covered, err = readList(filepath.Join(root, "checks/particulars/covered"))
if err == nil && len(covered) == 0 {
err = fmt.Errorf("covered lists no module — the check would pass on anything")
}
}
if err != nil {
fmt.Fprintln(os.Stderr, "particulars:", err)
return 2
}
findings, err := run(root, names, covered)
if err != nil {
fmt.Fprintln(os.Stderr, "particulars:", err)
return 2
}
for _, f := range findings {
fmt.Println(f)
}
if len(findings) > 0 {
fmt.Printf("particulars: %d node name(s) in a module for any mesh (novox/hq ADR 0507 §9) — say what it shows, not where it was seen\n", len(findings))
return 1
}
fmt.Printf("particulars: none of %d node names in the %d covered modules\n", len(names), len(covered))
return 0
}
// readList reads a list: one entry a line; blank lines and lines starting with # are not entries.
func readList(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []string
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
out = append(out, line)
}
return out, sc.Err()
}
// run reads every text file of each covered module and returns the findings, sorted.
func run(root string, names, covered []string) ([]string, error) {
var out []string
for _, m := range covered {
dir := filepath.Join(root, "modules", m)
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
switch d.Name() {
case "node_modules", "dist", "vendor", ".git":
return filepath.SkipDir
}
return nil
}
rel, _ := filepath.Rel(root, path)
// A path is read too: a testdata directory named after a node is a finding.
for _, n := range hits(filepath.ToSlash(rel), names) {
out = append(out, fmt.Sprintf("%s:0: node name %q in the path", filepath.ToSlash(rel), n))
}
if d.Name() == "go.sum" {
return nil
}
b, err := os.ReadFile(path)
if err != nil {
return err
}
if bytes.IndexByte(b, 0) >= 0 {
return nil
}
out = append(out, scan(filepath.ToSlash(rel), string(b), names)...)
return nil
})
if err != nil {
return nil, err
}
}
sort.Strings(out)
return out, nil
}
// scan returns a finding for each node name in src, whole-word and in any case.
func scan(file, src string, names []string) []string {
var out []string
for i, line := range strings.Split(src, "\n") {
for _, n := range hits(line, names) {
out = append(out, fmt.Sprintf("%s:%d: node name %q — %s", file, i+1, n, strings.Join(strings.Fields(line), " ")))
}
}
return out
}
// hits returns the node names one line holds: as a word (a hyphen or underscore parts it), as a part of a
// CamelCase identifier (TestAce, novoxTree), the forge's host and organisation path blanked first.
func hits(line string, names []string) []string {
line = forge.ReplaceAllStringFunc(line, func(s string) string { return strings.Repeat(" ", len(s)) })
var out []string
for _, n := range names {
q := regexp.QuoteMeta(n)
camel := regexp.QuoteMeta(strings.ToUpper(n[:1]) + n[1:])
rx := regexp.MustCompile(`(?i:(?:^|[^A-Za-z0-9])` + q + `(?:$|[^A-Za-z0-9]))|[a-z0-9]` + camel + `(?:$|[^a-z])|(?:^|[^A-Za-z0-9])` + q + `[A-Z0-9]`)
if rx.MatchString(line) {
out = append(out, strings.ToLower(n))
}
}
return out
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
var names = []string{"novox", "ace", "shanks", "g14"}
func TestANodeNameIsRefusedWhereItIsWritten(t *testing.T) {
cases := map[string]struct{ file, src string }{
"manifest string": {"modules/kernel/module.json", "{\n \"description\": \"as on Shanks\"\n}\n"},
"file line": {"modules/kernel/files/boot.sh", "#!/bin/sh\necho run on g14\n"},
"go identifier": {"modules/kernel/cmd/x/a.go", "package main\n\nfunc novox() {}\n"},
"test name": {"modules/kernel/cmd/x/a_test.go", "package main\n\nfunc TestAce(t *testing.T) {}\n\nfunc TestOnAce_x(t *testing.T) {}\n"},
}
for name, c := range cases {
if got := scan(c.file, c.src, names); len(got) == 0 {
t.Errorf("%s: %q not refused", name, c.src)
}
}
}
func TestTheFindingNamesTheLineAndTheWord(t *testing.T) {
got := scan("f.sh", "a\nb\nrun on ACE now\n", names)
if len(got) != 1 || !strings.HasPrefix(got[0], "f.sh:3:") || !strings.Contains(got[0], "ace") {
t.Errorf("got %v", got)
}
}
func TestTheForgeIsNotANodeName(t *testing.T) {
for _, s := range []string{
"see novox/hq issue 5",
"module git.novox.be/novox/mesh-sdk/go v1.2.3",
"https://git.novox.be/novox/mesh-catalog",
"git.novox.be",
"space trace surface grace g14x",
} {
if got := scan("f", s, names); len(got) != 0 {
t.Errorf("%q refused: %v", s, got)
}
}
}
func TestANodeNameInAPathOrJoinedIsRefused(t *testing.T) {
for _, s := range []string{
"modules/kernel/testdata/novox/meminfo",
"/srv/novox/data",
"testdata/shanks-meminfo.txt",
"on-shanks",
"shanks-desktop",
"mesh-novox",
"func novoxTree() {}",
"aceFiles := 1",
"g14Meminfo := 2",
"novox_tree := 3",
} {
if got := scan("f", s, names); len(got) == 0 {
t.Errorf("%q not refused", s)
}
}
}
func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
root := t.TempDir()
p := filepath.Join(root, "modules/kernel/testdata/ace/x")
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte("clean\n"), 0o644)
got, err := run(root, names, []string{"kernel"})
if err != nil || len(got) != 1 || !strings.HasPrefix(got[0], "modules/kernel/testdata/ace/x:0: ") {
t.Errorf("got %v, %v", got, err)
}
}
func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
root := t.TempDir()
write := func(p, s string) {
p = filepath.Join(root, p)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(s), 0o644)
}
write("particulars", "ace\n")
write("checks/particulars/covered", "kernel\n")
write("modules/kernel/a", "clean\n")
if got := check(root); got != 0 {
t.Errorf("clean = %d, want 0", got)
}
write("modules/kernel/a", "on ace\n")
if got := check(root); got != 1 {
t.Errorf("finding = %d, want 1", got)
}
os.Remove(filepath.Join(root, "particulars"))
if got := check(root); got != 2 {
t.Errorf("no list = %d, want 2", got)
}
}
func TestOnlyCoveredModulesAreRead(t *testing.T) {
root := t.TempDir()
write := func(p, s string) {
p = filepath.Join(root, p)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(s), 0o644)
}
write("modules/kernel/files/a", "on novox\n")
write("modules/other/files/a", "on novox\n")
got, err := run(root, names, []string{"kernel"})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || !strings.Contains(got[0], "modules/kernel/files/a:1") {
t.Errorf("got %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
+24
View File
@@ -50,11 +50,22 @@
# 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 . ../..)
# Node names (novox/hq ADR 0507 §9, issue 1838): no node name of this mesh in a covered module; the names are in
# `particulars`, the covered modules in checks/particulars/covered.
(cd checks/particulars && 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 Node lock files (novox/hq ADR 0469 §5, issue 1868): a TypeScript module that names packages beyond the SDK commits its lock.
(cd checks/locks && 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 +201,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"
}
]
}
@@ -1,6 +1,7 @@
package main
import (
"bufio"
"context"
"net"
"os"
@@ -170,20 +171,33 @@ func TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock(t *testing.T)
store := dir + "/cm/clipmenu.6.op"
os.MkdirAll(store, 0o700)
lock := store + "/session_lock"
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed
holder := exec.Command("flock", "-x", "-n", lock, "sleep", "60")
// the older clipmenud: it holds the lock the way clipmenud does, until it is killed. It says "held" once it has the
// lock, so the test goes on only when the lock is held for certain: a holder that is slow to start (the checker
// runs thousands of tests at once) must not let the launcher find the lock free (issue 1814).
holder := exec.Command("flock", "-x", "-n", lock, "sh", "-c", "echo held; exec sleep 60")
holder.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
held, err := holder.StdoutPipe()
if err != nil {
t.Fatal(err)
}
if err := holder.Start(); err != nil {
t.Fatal(err)
}
// flock's child shares the lock, so the whole group goes
release := func() { syscall.Kill(-holder.Process.Pid, syscall.SIGKILL); holder.Wait() }
defer release()
for i := 0; i < 50; i++ {
if probe := exec.Command("flock", "-n", lock, "true"); probe.Run() != nil {
break
said := make(chan string, 1)
go func() {
line, _ := bufio.NewReader(held).ReadString('\n')
said <- strings.TrimSpace(line)
}()
select {
case line := <-said:
if line != "held" {
t.Fatalf("the older clipmenud did not take the lock: %q", line)
}
time.Sleep(50 * time.Millisecond)
case <-time.After(30 * time.Second):
t.Fatal("the older clipmenud did not take the lock within 30 s")
}
var out syncBuffer
cmd := exec.Command("sh", file)
+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",
+15 -9
View File
@@ -22,16 +22,18 @@ Every new head of an open pull request is announced as `pull.updated`, once, and
minutes, or when the search fails.
- **A pull request nobody checks is said — by the delivery's owner, not here.** `gitea_open_pulls` answers every
open pull request from the forge itself, with the statuses its base requires and the ones its head has. The
`mesh-delivery` module asks it every five minutes, and a head on a base requiring `mesh/merge-gate` or
`mesh/repo-check` with neither for ten minutes since it first saw it so is a delivery held past its bound
delivery gate asks it every five minutes, and a head on a base requiring `mesh/check` (or hq's
`mesh/repo-check`) with none of them for ten minutes since it first saw it so is a delivery held past its bound
(`delivery.<id>.stalled`, state `unannounced`), which the controller raises like any other (issue 347).
Each of the head's statuses comes with its state and when it was set, so a head whose check started and never
answered — a required check pending, or never set beside one that is — is said as a stalled line in the state
`unanswered` once that has lasted the delivery table's bound for a check asked and not answered, an hour, counted
from its first sight and again only when a check is asked anew (issue 438).
- **How to see it work.** A pull request opened more than a minute after its branch was pushed — this fix's own
was one, and the announcer it fixes never saw it — has `mesh/merge-gate` pending within a pass of the next sweep
at the latest, and `mesh-controller.conditions` names none for it.
was one, and the announcer it fixes never saw it — is announced within a pass of the next sweep at the latest, its
`mesh/check` is the checker's to set, and `mesh-controller.conditions` names none for it. (Since issue 1301 this
module writes `mesh/merge-gate` only where a base still requires it, and none does once the forge requires
`mesh/check`.)
- **A pull request or issue number that is not one is refused** before the forge is asked: a tool called with a
misnamed argument sent `pulls/NaN`.
@@ -154,15 +156,19 @@ listed repository:
naming the description, and a `merge-check.sh` declaring `# mesh-check-toolchain: go` that vets and runs the unit
tests, then the integration tests (`-tags integration`), of every Go module in the tree, and says so and passes on
a tree with none. The rule is then narrowed to main's rule of ADR 0398 §2 (no push, only `mesh-tools` merges),
requiring `mesh/repo-check` and `mesh/merge-gate`, and only then is the repository moved into the organisation.
requiring `mesh/check` (ADR 0447 §1, issue 1301), and only then is the repository moved into the organisation.
There main's rule is read back and put right once more (the step `rule-after-move`): the forge drops from a merge
list a user without access to the repository, and under the setup account `mesh-tools` has none;
- one found still under the setup account is finished: the steps it lacks are done, main is never written twice;
- one in the organisation is only put right: main's rule narrowed, a missing one of the two statuses added and every
status already there kept.
- one in the organisation is only put right: main's rule narrowed, `mesh/check` required in place of
`mesh/merge-gate` and `mesh/repo-check`, and every other status already there kept. A main that requires
`mesh/repo-check` without `mesh/merge-gate` is judged by its own script alone (hq, ADR 0503 §5, until issue 1296)
and keeps it; `mesh/check` is never dropped.
A step the forge refuses stops that repository, and the others go on. A repository taken out of the value is never
asked about: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
Every other repository of the organisation whose main requires `mesh/merge-gate` has the two old statuses swapped
for `mesh/check`, and only its statuses (issue 1301): the switch reaches every repository the merge gate judged, with
no list kept by hand. A step the forge refuses stops that repository, and the others go on. Beyond that swap, a
repository taken out of the value is never changed: nothing is deleted, renamed, moved out or unprotected. The pass keeps what it did per repository in
`repositories-outcome.json`.
**How a refusal is seen.** `repositories sync` is a run-once process, declared as the module's last resource, so
+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();
})();
}
+17 -2
View File
@@ -533,7 +533,7 @@ export class GiteaClient {
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
* see, since a directory left out would be taken for one without a module. Reads only. */
async commitTrees(owner: string, repo: string, sha: string): Promise<{ root: string; trees: Record<string, string> }> {
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> {
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
const entries: any[] = [];
@@ -560,7 +560,22 @@ export class GiteaClient {
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
trees[dir] = tree;
}
return { root, trees };
if (paths === undefined) return { root, trees };
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
// naming both, and nothing is answered in part.
const objectOf = new Map<string, string>();
for (const e of entries) if (typeof e?.path === "string" && (e.type === "tree" || e.type === "blob")) objectOf.set(e.path, String(e.sha));
const named: Record<string, Record<string, string>> = {};
for (const [dir, list] of Object.entries(paths)) {
named[dir] = {};
for (const p of list) {
const id = objectOf.get(dir === "" ? p : `${dir}/${p}`);
if (!id) throw new Error(`the path ${p} of ${dir === "" ? "the repository root" : dir} is not in ${owner}/${repo} at ${sha}`);
named[dir][p] = id;
}
}
return { root, trees, paths: named };
}
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
@@ -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)
}
@@ -0,0 +1,215 @@
package main
import (
"strings"
"testing"
)
// The tests of issue 1301 (ADR 0447 §1, §5; ADR 0503 §3, §5; lead decisions L4 and L11): main requires `mesh/check`
// alone in place of `mesh/merge-gate` and `mesh/repo-check`; a main that requires `mesh/repo-check` without
// `mesh/merge-gate` (hq) keeps it; every repository of the organisation whose main requires `mesh/merge-gate` is
// switched, listed in the value or not; no other status is dropped.
func narrowedRule(contexts ...any) map[string]any {
return map[string]any{
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []any{},
"enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
"enable_status_check": true, "status_check_contexts": contexts,
"block_admin_merge_override": true,
}
}
// 1301-S1: a listed repository whose main requires the two old statuses is put right to `mesh/check` in their place,
// every other status kept; a second pass changes nothing.
func TestListedRepositorySwapsTheTwoStatusesForCheck(t *testing.T) {
f := newFake()
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check", "other/x")}, "main")
s, _ := syncer(t, f)
o := run(t, s, Value{"mesh-delivery": {}})
got := strs(repo.Rules[mainRule]["status_check_contexts"])
if strings.Join(got, ",") != "other/x,mesh/check" {
t.Errorf("main requires %v, want other/x and mesh/check", got)
}
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
t.Errorf("outcome %+v, want put right", r)
}
before := len(f.writes())
run(t, s, Value{"mesh-delivery": {}})
if w := f.writes(); len(w) != before {
t.Errorf("a second pass wrote %v", w[before:])
}
}
// 1301-S1: a repository the program makes requires `mesh/check` alone from its first rule in the organisation on.
func TestNewRepositoryRequiresCheckAlone(t *testing.T) {
f := newFake()
s, _ := syncer(t, f)
run(t, s, Value{"fresh": {Description: "d"}})
rule := f.repos["novox/fresh"].Rules[mainRule]
if got := strs(rule["status_check_contexts"]); rule["enable_status_check"] != true || strings.Join(got, ",") != CheckStatus {
t.Errorf("a new repository's main requires %v, want %s alone", got, CheckStatus)
}
if readme := f.repos["novox/fresh"].Files["README.md"]; !strings.Contains(readme, CheckStatus) ||
strings.Contains(readme, "mesh/merge-gate") || strings.Contains(readme, "mesh/repo-check") {
t.Errorf("the README names the statuses of before: %q", readme)
}
}
// 1301-S1: mesh/check is never dropped, and a rule that requires it beside a retired status loses the retired one.
func TestCheckIsKeptAndARetiredStatusBesideItGoes(t *testing.T) {
f := newFake()
both := f.add("novox/both", map[string]map[string]any{mainRule: narrowedRule("mesh/check", "mesh/repo-check")}, "main")
right := f.add("novox/right", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
s, _ := syncer(t, f)
o := run(t, s, Value{"both": {}, "right": {}})
if got := strs(both.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("both requires %v, want %s alone", got, CheckStatus)
}
if got := strs(right.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("right requires %v", got)
}
if result(o, "right").Did != DidUnchanged || result(o, "both").Did != DidPutRight {
t.Errorf("outcome %+v", o)
}
}
// 1301-S1: a listed repository whose main requires nothing is given mesh/check, not the old two.
func TestListedRuleRequiringNothingGetsCheck(t *testing.T) {
f := newFake()
none := f.add("novox/none", map[string]map[string]any{mainRule: {"enable_push": false}}, "main")
s, _ := syncer(t, f)
run(t, s, Value{"none": {}})
if got := strs(none.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("none requires %v, want %s", got, CheckStatus)
}
}
// 1301-S2 (ADR 0503 §5): a main that requires mesh/repo-check without mesh/merge-gate is judged by its own script
// alone (hq): it keeps mesh/repo-check and is not given mesh/check, listed or not.
func TestOwnCheckAloneIsKept(t *testing.T) {
f := newFake()
hq := f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
lab := f.add("novox/mesh-lab", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
s, _ := syncer(t, f)
o := run(t, s, Value{"hq": {}})
for name, repo := range map[string]*fRepo{"hq": hq, "mesh-lab": lab} {
if got := strs(repo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != "mesh/repo-check" {
t.Errorf("%s requires %v, want mesh/repo-check alone", name, got)
}
}
if result(o, "hq").Did != DidUnchanged {
t.Errorf("hq outcome %+v, want unchanged", result(o, "hq"))
}
for _, w := range f.writes() {
t.Errorf("a repository judged by its own check alone was written: %s", w)
}
}
// 1301-S3 (L4, L11): a repository the value does not list whose main requires mesh/merge-gate has the two old
// statuses swapped for mesh/check, and only its statuses: nothing else of its rule is sent. One that requires
// neither, or mesh/repo-check alone, is not written to.
func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
f := newFake()
catalog := f.add("novox/mesh-catalog", map[string]map[string]any{mainRule: {
"enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check"},
}}, "main")
toolsGo := f.add("novox/mesh-tools-go", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
f.add("novox/plain", map[string]map[string]any{mainRule: {"enable_push": false}}, "main")
f.add("novox/hq", map[string]map[string]any{mainRule: narrowedRule("mesh/repo-check")}, "main")
f.add("novox/norule", nil, "main")
f.add("mesh-admin/elsewhere", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
s, _ := syncer(t, f)
o := run(t, s, Value{})
if got := strs(catalog.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("mesh-catalog requires %v, want %s", got, CheckStatus)
}
if catalog.Rules[mainRule]["enable_push"] != true {
t.Errorf("an unlisted repository's rule was narrowed beyond its statuses: %v", catalog.Rules[mainRule])
}
if got := strs(toolsGo.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("mesh-tools-go requires %v, want %s", got, CheckStatus)
}
want := []string{
"PATCH /api/v1/repos/novox/mesh-catalog/branch_protections/main",
"PATCH /api/v1/repos/novox/mesh-tools-go/branch_protections/main",
}
if got := f.writes(); strings.Join(got, "\n") != strings.Join(want, "\n") {
t.Errorf("writes:\n%s\nwant:\n%s", strings.Join(got, "\n"), strings.Join(want, "\n"))
}
for _, n := range []string{"mesh-catalog", "mesh-tools-go"} {
if r := result(o, n); r.Did != DidPutRight {
t.Errorf("%s outcome %+v, want put right", n, r)
}
}
for _, n := range []string{"plain", "hq", "norule", "elsewhere"} {
if r := result(o, n); r.Name != "" {
t.Errorf("%s is in the outcome though nothing was done: %+v", n, r)
}
}
before := len(f.writes())
run(t, s, Value{})
if w := f.writes(); len(w) != before {
t.Errorf("a second pass wrote %v", w[before:])
}
}
// 1301-S3: the organisation's repositories are read page by page, so one past the first page is switched too.
func TestUnlistedRepositoriesAreReadPastTheFirstPage(t *testing.T) {
f := newFake()
f.pageSize = 2
for _, n := range []string{"a", "b", "c", "d", "e"} {
f.add("novox/"+n, map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate", "mesh/repo-check")}, "main")
}
s, _ := syncer(t, f)
run(t, s, Value{})
for _, n := range []string{"a", "b", "c", "d", "e"} {
if got := strs(f.repos["novox/"+n].Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("%s requires %v", n, got)
}
}
}
// 1301-S3: a refused swap is said with the repository and its step, and the pass goes on to the next.
func TestRefusedSwapIsSaid(t *testing.T) {
f := newFake()
f.add("novox/a", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
f.add("novox/b", map[string]map[string]any{mainRule: narrowedRule("mesh/merge-gate")}, "main")
f.refuse = func(method, path string) (int, string) {
if method == "PATCH" && strings.Contains(path, "/novox/a/") {
return 403, "no"
}
return 0, ""
}
s, _ := syncer(t, f)
o := run(t, s, Value{})
if r := result(o, "a"); r.Did != DidRefused || r.Step != StepSwap {
t.Errorf("a: %+v, want refused at %s", r, StepSwap)
}
if r := result(o, "b"); r.Did != DidPutRight {
t.Errorf("b: %+v, want put right", r)
}
}
// 1301-S3: the organisation's list refused is said, and the listed repositories are still done.
func TestOrganisationListRefusedIsSaid(t *testing.T) {
f := newFake()
f.refuse = func(method, path string) (int, string) {
if strings.HasPrefix(path, "/api/v1/orgs/") {
return 500, "down"
}
return 0, ""
}
s, _ := syncer(t, f)
o := run(t, s, Value{"x": {}})
if result(o, "x").Did != DidMade {
t.Errorf("x: %+v", result(o, "x"))
}
refusedList := false
for _, r := range o.Refused() {
refusedList = refusedList || r.Step == StepList
}
if !refusedList {
t.Errorf("the refused list is not in the outcome: %+v", o)
}
}
+29 -9
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"testing"
@@ -43,6 +44,8 @@ type fakeForge struct {
calls []string // "METHOD /path"
// refuse answers a call with a status and a message when it returns a status above zero.
refuse func(method, path string) (int, string)
// pageSize is how many repositories one page of the organisation's list holds; 50 when zero.
pageSize int
}
func newFake() *fakeForge { return &fakeForge{repos: map[string]*fRepo{}} }
@@ -84,6 +87,7 @@ var (
branchPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/branches/([^/]+)$`)
filesPath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/contents$`)
movePath = regexp.MustCompile(`^/api/v1/repos/([^/]+)/([^/]+)/transfer$`)
orgRepos = regexp.MustCompile(`^/api/v1/orgs/([^/]+)/repos$`)
)
func strs(v any) []string {
@@ -167,6 +171,31 @@ func (f *fakeForge) ServeHTTP(w http.ResponseWriter, r *http.Request) {
switch {
case p == "/api/v1/version":
reply(200, map[string]string{"version": "1.27"})
case orgRepos.MatchString(p) && r.Method == http.MethodGet:
owner := orgRepos.FindStringSubmatch(p)[1]
var names []string
for full := range f.repos {
if o, n, _ := strings.Cut(full, "/"); o == owner {
names = append(names, n)
}
}
sort.Strings(names)
size := f.pageSize
if size == 0 {
size = 50
}
if l, _ := strconv.Atoi(r.URL.Query().Get("limit")); l > 0 && l < size {
size = l
}
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
if page < 1 {
page = 1
}
out := []map[string]any{}
for i := (page - 1) * size; i < len(names) && i < page*size; i++ {
out = append(out, map[string]any{"name": names[i], "full_name": owner + "/" + names[i]})
}
reply(200, out)
case p == "/api/v1/user/repos" && r.Method == http.MethodPost:
name := fmt.Sprint(in["name"])
if f.repos[admin+"/"+name] != nil {
@@ -312,12 +341,3 @@ func result(o Outcome, name string) Result {
}
return Result{}
}
func has(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
+3 -3
View File
@@ -8,7 +8,7 @@ import "strings"
const MergeCheck = `#!/bin/sh
# mesh-check-toolchain: go
#
# This repository's own check, mesh/repo-check (novox/hq ADR 0238 §4), written with its first commit (ADR 0428 §4):
# This repository's own check (novox/hq ADR 0238 §4), run by the checker within mesh/check (ADR 0447), written with its first commit (ADR 0428 §4):
# every Go module in the tree is vetted, its unit tests run, and then its integration tests (-tags integration).
# A tree with no Go module has nothing to test, and the check says so and passes.
set -eu
@@ -37,7 +37,7 @@ func FirstCommit(name string, r Repo) map[string]string {
}
readme := "# " + name + "\n\n" + purpose + "\n\n" +
"Made by the mesh from the gitea module's config value `repositories` (novox/hq ADR 0428). Main takes no push: " +
"a change reaches it by a pull request whose `mesh/repo-check` (this repository's `merge-check.sh`) and " +
"`mesh/merge-gate` passed.\n"
"a change reaches it by a pull request whose `mesh/check` passed: the checker's verdict on its head merged onto " +
"main, this repository's `merge-check.sh` among its tests (novox/hq ADR 0447).\n"
return map[string]string{"README.md": readme, "merge-check.sh": MergeCheck}
}
+2 -1
View File
@@ -3,7 +3,8 @@
// again whenever the value changes: it reads the file the mesh renders from that value and, with the setup login,
// makes every listed repository that does not exist under that login's own account, protects its main, writes
// main's first commit, narrows main's rule and only then moves it into the organisation; one that exists only has
// its main rule put right. A repository the value does not list is never touched. It keeps what it did, per
// its main rule put right, requiring mesh/check in place of the two statuses it replaces (issue 1301). A repository
// the value does not list is touched only to swap those two for mesh/check when its main requires mesh/merge-gate. It keeps what it did, per
// repository, in the outcome file, says each change on stdout in one line and never a secret, and exits 1 when a
// step was refused, its last stderr lines naming each (outcome.go Report): the node-engine's gate on a run-once
// step (ADR 0136) reports it and runs it again on every apply until it passes.
@@ -18,7 +18,7 @@ func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) {
}
// 1141/AC2: a narrowed main rule that still lets an administrator override is not right: it is put right, its
// statuses kept, and a second pass changes nothing.
// statuses kept (the two old ones as mesh/check, issue 1301), and a second pass changes nothing.
func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
f := newFake()
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: {
@@ -33,10 +33,8 @@ func TestNarrowedRuleLettingTheAdministratorOverrideIsPutRight(t *testing.T) {
if rule["block_admin_merge_override"] != true {
t.Errorf("the administrator's override is still allowed: %v", rule)
}
for _, c := range []string{"mesh/merge-gate", "mesh/repo-check", "other/x"} {
if !has(strs(rule["status_check_contexts"]), c) {
t.Errorf("a status was dropped: %v", rule["status_check_contexts"])
}
if c := strs(rule["status_check_contexts"]); strings.Join(c, ",") != "other/x,mesh/check" {
t.Errorf("statuses %v, want other/x kept and mesh/check for the two it replaces (issue 1301)", c)
}
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
t.Errorf("outcome %+v, want put right", r)
+126 -28
View File
@@ -26,9 +26,47 @@ const (
MainBranch = "main"
)
// RequiredStatuses are what main requires in every listed repository from its first commit (ADR 0428 §5): the
// repository's own check and the module graph's gate. A status already on the rule is kept beside them.
var RequiredStatuses = []string{"mesh/merge-gate", "mesh/repo-check"}
// CheckStatus is the one status main requires (ADR 0447 §1, issue 1301): the checker's verdict on a pull request's
// head merged onto its trunk's head. It replaces GateStatus and OwnCheckStatus, and is never dropped.
const CheckStatus = "mesh/check"
// GateStatus and OwnCheckStatus are the two statuses CheckStatus replaces: the controller's merge gate and the
// repository's own merge-check.sh (ADR 0238 §4, ADR 0428 §5 before ADR 0447 §1).
const (
GateStatus = "mesh/merge-gate"
OwnCheckStatus = "mesh/repo-check"
)
// wanted is what a main rule requiring have should require: have with GateStatus and OwnCheckStatus replaced by
// CheckStatus, every other status kept in its order (ADR 0447 §1). A rule requiring OwnCheckStatus with neither
// GateStatus nor CheckStatus is judged by its own script alone, as hq is until issue 1296 settles how hq is
// checked (ADR 0503 §5): the checker writes no status there (ADR 0484 §8), so it is left as it is. This reads the
// rule, not a list of names (lead decision L11 of R4e).
func wanted(have []string) []string {
if !has(have, GateStatus) && !has(have, CheckStatus) && has(have, OwnCheckStatus) {
return have
}
out := []string{}
for _, s := range have {
if s != GateStatus && s != OwnCheckStatus && s != CheckStatus {
out = append(out, s)
}
}
return append(out, CheckStatus)
}
func has(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
func sameList(a, b []string) bool {
return strings.Join(a, "\x00") == strings.Join(b, "\x00")
}
// The steps of one repository, as an outcome names a refused one (ADR 0428 §6).
const (
@@ -42,6 +80,10 @@ const (
// StepRuleAfterMove reads main's rule back once the repository is in the organisation and puts it right: the
// forge drops from a merge whitelist a user without access, and under the setup account the tools' have none.
StepRuleAfterMove = "rule-after-move"
// StepList reads the organisation's repositories, for those the value does not list (issue 1301).
StepList = "list"
// StepSwap swaps the two old statuses for CheckStatus on a repository the value does not list (issue 1301).
StepSwap = "swap"
)
// What a pass did with one repository.
@@ -157,9 +199,73 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) {
}
out.Repositories = append(out.Repositories, r)
}
for _, r := range s.switchUnlisted(ctx, v) {
if r.Did == DidRefused {
s.say("refused: %s/%s at the step %s: %s", s.Org, r.Name, r.Step, r.Said)
} else {
s.say("%s: %s/%s (%s in place of %s and %s)", r.Did, s.Org, r.Name, CheckStatus, GateStatus, OwnCheckStatus)
}
out.Repositories = append(out.Repositories, r)
}
return out, nil
}
// orgPage is how many repositories one read of the organisation's list asks for.
const orgPage = 50
// switchUnlisted swaps the two old statuses for CheckStatus on main's rule of every repository of the organisation
// the value does not list whose main requires GateStatus (issue 1301, lead decisions L4 and L11 of R4e): the
// repositories the merge gate judges are switched in the delivery that has the checker write mesh/check (ADR 0447
// §5), with no list kept by hand. Only the statuses of a rule covering main are sent; nothing else of a rule the
// value does not hold is changed (ADR 0428 §7 for all of it but the statuses the mesh itself requires). A main that
// does not require GateStatus gets no call. It answers only what it did: a repository switched, or refused.
func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
var names []string
for page := 1; ; page++ {
var repos []struct {
Name string `json:"name"`
}
q := "?limit=" + fmt.Sprint(orgPage) + "&page=" + fmt.Sprint(page)
if err := s.Forge.GetJSON(ctx, "/api/v1/orgs/"+url.PathEscape(s.Org)+"/repos"+q, &repos); err != nil {
return []Result{refused("(the organisation's repositories)", StepList, err)}
}
for _, r := range repos {
if _, listed := v[r.Name]; !listed && r.Name != "" {
names = append(names, r.Name)
}
}
if len(repos) == 0 || page > 1000 {
break
}
}
sort.Strings(names)
var out []Result
for _, n := range names {
rules, err := s.rules(ctx, s.Org, n)
if err != nil {
out = append(out, refused(n, StepRead, err))
continue
}
var did *Result
for _, r := range rules {
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || !has(required(r), GateStatus) {
continue
}
body := map[string]any{"enable_status_check": true, "status_check_contexts": wanted(required(r))}
if err := s.Forge.SendJSON(ctx, http.MethodPatch,
repoAPI(s.Org, n)+"/branch_protections/"+url.PathEscape(ruleName(r)), body, nil); err != nil {
did = &Result{Name: n, Did: DidRefused, Step: StepSwap, Said: err.Error()}
break
}
did = &Result{Name: n, Did: DidPutRight}
}
if did != nil {
out = append(out, *did)
}
}
return out
}
// waitForForge is cmd/agent-teams' (teams.go waitForForge): the forge starting with the module answers late.
func (s *Syncer) waitForForge(ctx context.Context) error {
deadline := time.Now().Add(s.WaitFor)
@@ -309,7 +415,7 @@ func strList(v any) []string {
return out
}
// statuses is what a rule requires, with RequiredStatuses added and none dropped.
// statuses is what the rules require together, as wanted makes it.
func statuses(rules ...rule) []string {
seen := map[string]bool{}
var out []string
@@ -320,16 +426,19 @@ func statuses(rules ...rule) []string {
}
}
for _, r := range rules {
if r != nil && r["enable_status_check"] == true {
for _, s := range strList(r["status_check_contexts"]) {
add(s)
}
for _, s := range required(r) {
add(s)
}
}
for _, s := range RequiredStatuses {
add(s)
return wanted(out)
}
// required is what a rule requires: its statuses when it checks them, else none.
func required(r rule) []string {
if r == nil || r["enable_status_check"] != true {
return nil
}
return out
return strList(r["status_check_contexts"])
}
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges,
@@ -344,7 +453,7 @@ func narrowed(r rule, toolsAccount string, also ...rule) map[string]any {
}
}
// isRight says whether a rule already is narrowed and requires both statuses.
// isRight says whether a rule already is narrowed and requires what wanted makes of it.
func isRight(r rule, toolsAccount string) bool {
if r["enable_push"] != false || r["enable_push_whitelist"] == true || r["enable_merge_whitelist"] != true {
return false
@@ -352,26 +461,15 @@ func isRight(r rule, toolsAccount string) bool {
if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount {
return false
}
return r["enable_status_check"] == true && hasAll(strList(r["status_check_contexts"])) &&
have := strList(r["status_check_contexts"])
return r["enable_status_check"] == true && len(have) > 0 && sameList(have, wanted(have)) &&
r["block_admin_merge_override"] == true
}
func hasAll(have []string) bool {
for _, want := range RequiredStatuses {
found := false
for _, h := range have {
found = found || h == want
}
if !found {
return false
}
}
return true
}
// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires.
// putRight makes main's rule in an organisation's repository the narrowed one, keeping every status it requires but
// the two CheckStatus replaces (ADR 0447 §1).
// A repository with no rule named main gets one, carrying what a glob rule covering main asks (client.ts
// ensureMainMerges, issue 948). It never takes a status away and never unprotects (ADR 0428 §5, §7).
// ensureMainMerges, issue 948). It takes away no other status and never unprotects (ADR 0428 §5, §7).
func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) {
rules, err := s.rules(ctx, owner, name)
if err != nil {
@@ -17,8 +17,8 @@ import (
const mainRule = "main"
// 1124/AC2: a listed repository that does not exist is made under the setup account, given main's rule letting that
// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/repo-check (and
// mesh/merge-gate, 1124/AC5), and only then moved into the organisation. Nothing reaches the organisation before the
// login alone push, given its first commit, narrowed to no push and the tools' merge requiring mesh/check (1124/AC5
// as issue 1301 changes it), and only then moved into the organisation. Nothing reaches the organisation before the
// narrowed rule, and nothing else is pushed to main.
func TestNewRepositoryIsMadeInOrder(t *testing.T) {
f := newFake()
@@ -67,8 +67,8 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
t.Errorf("main's merge whitelist is not the tools' account alone: %v", rule)
}
checks := strs(rule["status_check_contexts"])
if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") {
t.Errorf("main does not require mesh/repo-check and mesh/merge-gate (1124/AC5): %v", rule)
if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus {
t.Errorf("main does not require mesh/check alone (1124/AC5, issue 1301): %v", rule)
}
if r := result(o, "mesh-delivery"); r.Did != DidMade || r.Step != "" {
t.Errorf("outcome %+v, want made", r)
@@ -87,7 +87,7 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
// 1124/AC2: the forge drops from a merge whitelist a user without access to the repository, and under the setup
// account the tools' account has none; so once the repository is in the organisation its main rule is read back and
// put right: the tools' account the only one that merges, both statuses required.
// put right: the tools' account the only one that merges, mesh/check required.
func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
f := newFake()
s, _ := syncer(t, f)
@@ -104,8 +104,8 @@ func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
t.Errorf("after the move main takes a push: %v", rule)
}
checks := strs(rule["status_check_contexts"])
if rule["enable_status_check"] != true || !has(checks, "mesh/repo-check") || !has(checks, "mesh/merge-gate") {
t.Errorf("after the move main does not require both statuses: %v", rule)
if rule["enable_status_check"] != true || strings.Join(checks, ",") != CheckStatus {
t.Errorf("after the move main does not require mesh/check alone: %v", rule)
}
if r := result(o, "mesh-delivery"); r.Did != DidMade {
t.Errorf("outcome %+v, want made", r)
@@ -136,7 +136,8 @@ func TestAdminRuleLetsTheSetupLoginAlonePush(t *testing.T) {
}
// 1124/AC3: a listed repository that exists in the organisation is not made again, only its main rule put right;
// a rule still letting the setup login push is narrowed, its statuses kept (1124/AC5).
// a rule still letting the setup login push is narrowed, its statuses kept but the two mesh/check replaces (1124/AC5,
// issue 1301).
func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
f := newFake()
hq := f.add("novox/hq", map[string]map[string]any{mainRule: {
@@ -157,14 +158,11 @@ func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
t.Errorf("hq's rule not narrowed: %v", rule)
}
checks := strs(rule["status_check_contexts"])
for _, c := range []string{"other/x", "mesh/merge-gate", "mesh/repo-check"} {
if !has(checks, c) {
t.Errorf("hq's rule lacks %s: %v", c, checks)
}
if strings.Join(checks, ",") != "other/x,mesh/check" {
t.Errorf("hq's rule requires %v, want other/x and mesh/check", checks)
}
made := bare.Rules[mainRule]
if made == nil || made["enable_push"] != false || !has(strs(made["status_check_contexts"]), "mesh/repo-check") ||
!has(strs(made["status_check_contexts"]), "mesh/merge-gate") {
if made == nil || made["enable_push"] != false || strings.Join(strs(made["status_check_contexts"]), ",") != CheckStatus {
t.Errorf("bare's rule: %v", made)
}
if hq.Commits != 0 || bare.Commits != 0 {
@@ -214,8 +212,8 @@ func TestRepositoryUnderTheSetupAccountIsFinished(t *testing.T) {
}
}
// 1124/AC5: putting a rule right adds a missing one of mesh/repo-check and mesh/merge-gate and keeps every status
// already there; no pass drops one.
// 1124/AC5 as issue 1301 changes it: putting a rule right adds mesh/check when the rule requires mesh/merge-gate,
// and keeps every other status there; a rule requiring mesh/repo-check alone keeps it (ADR 0503 §5).
func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
f := newFake()
only := f.add("novox/only", map[string]map[string]any{mainRule: {
@@ -224,15 +222,15 @@ func TestStatusesAreAddedAndNeverDropped(t *testing.T) {
}}, "main")
more := f.add("novox/more", map[string]map[string]any{mainRule: {
"enable_push": false, "enable_merge_whitelist": true, "merge_whitelist_usernames": []any{tools},
"enable_status_check": true, "status_check_contexts": []any{"mesh/merge-gate", "mesh/repo-check", "mesh/delivery"},
"enable_status_check": true, "status_check_contexts": []any{"mesh/delivery", "mesh/check"},
"block_admin_merge_override": true, // right since issue 1141
}}, "main")
s, _ := syncer(t, f)
o := run(t, s, Value{"only": {}, "more": {}})
if c := strs(only.Rules[mainRule]["status_check_contexts"]); !has(c, "mesh/merge-gate") || !has(c, "mesh/repo-check") {
if c := strs(only.Rules[mainRule]["status_check_contexts"]); strings.Join(c, ",") != "mesh/repo-check" {
t.Errorf("only: %v", c)
}
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 3 || !has(c, "mesh/delivery") {
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 2 || !has(c, "mesh/delivery") {
t.Errorf("more lost a status: %v", c)
}
for _, w := range f.writes() {
@@ -314,7 +312,8 @@ func TestRefusedStepStopsThatRepositoryOnly(t *testing.T) {
}
}
// 1124/AC7: a repository taken out of the value gets no call at all.
// 1124/AC7: a repository taken out of the value is not changed: its rules are read (issue 1301 switches one whose
// main requires mesh/merge-gate), nothing is written, and one still under the setup account is not asked about.
func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) {
f := newFake()
f.add("novox/old", map[string]map[string]any{mainRule: {"enable_push": true}}, "main")
@@ -323,7 +322,7 @@ func TestRepositoryOutOfTheValueIsNotTouched(t *testing.T) {
run(t, s, Value{})
run(t, s, Value{"new": {}})
for _, c := range f.all() {
if strings.Contains(c, "old") {
if strings.Contains(c, "older") || (strings.Contains(c, "old") && !strings.HasPrefix(c, "GET ")) {
t.Errorf("a repository out of the value was asked: %s", c)
}
}
+56 -1
View File
@@ -16,6 +16,9 @@ import { promisify } from "node:util";
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
import type { CommitStatus } from "./client.js";
import { checkName } from "./guard.js";
import { CHECK_STATUS } from "./pulls.js";
export { CHECK_STATUS };
/** How a command is run: docker on the forge's machine, or a test's. */
export type Runner = (file: string, args: string[]) => Promise<{ stdout: string; code: number }>;
@@ -91,11 +94,18 @@ const states = new Set(["pending", "success", "error", "failure", "warning"]);
/** The merge check's contexts (pulls.ts): a branch's protection may require them. */
const mergeCheckContexts = new Set(["mesh/merge-gate", "mesh/repo-check"]);
/** A status mesh-delivery asks for, checked: one of the forge's states, a context of the mesh's own, a
* bounded description. */
export function deliveryStatus(context: string, state: string, description: string, target?: string) {
if (!/^mesh\/[a-z-]+$/.test(context)) throw new Error(`${context} is not a status of the mesh's own`);
if (!states.has(state)) throw new Error(`${state} is not a status the forge keeps`);
// mesh/check is the checker's verdict and the one status main requires (ADR 0447 §1): no hand sets it in any
// state, as issue 293 closed for the two before it. The forge bridge writes it through gitea_check_status, a tool
// only its account may call (issue 1301).
if (context === CHECK_STATUS) {
throw new Error(`${CHECK_STATUS} is the checker's status: the forge bridge writes it through gitea_check_status, never this tool`);
}
// The merge check's statuses are its verdict's, set when the controller says it, and a branch may require
// them: never set to a verdict by hand, so nothing passes — or fails — a merge past the check (novox/hq
// issue 293). Pending is no verdict: it is what this holder says on every new head until the verdict comes,
@@ -126,7 +136,7 @@ export interface StatusWriters {
export function commitStatusTool(writers: StatusWriters): ToolDefinition {
return {
name: "gitea_commit_status",
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. The merge check's statuses (mesh/merge-gate, mesh/repo-check) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
description: "Set one of the mesh's statuses on a commit (mesh/delivery, mesh/delivery-group): pending, success, error, failure or warning, a short description, and the page it links to. mesh/check, the status main requires, is refused: the checker's forge bridge writes it through gitea_check_status. hq's mesh/repo-check (and mesh/merge-gate, kept for a revert) take pending only — a recheck putting them back until the fresh verdict sets them — on the head's whole sha, in their turn after the check's earlier writes.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -153,3 +163,48 @@ export function commitStatusTool(writers: StatusWriters): ToolDefinition {
},
};
}
/** What the check's status tool writes with: the forge. */
export interface CheckStatusWriter {
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
}
/**
* The `gitea_check_status` tool (novox/hq issue 1301, ADR 0447 §1): the one way `mesh/check` is written. Its arguments
* are gitea_commit_status's, so the forge bridge sends an owed status's arguments unchanged to it: owner, repo, sha
* (the head's whole sha), state, description, target_url, and context, which may be left out and is mesh/check when
* given. **Who may write it is the bus's grant**: the runtime tells a tool no caller, so only a module that lists
* `gitea.gitea_check_status` in its manifest's `invokes` may call it, and the forge bridge alone does. Served beside
* gitea_commit_status, from the watcher's bundle.
*/
export function checkStatusTool(writer: CheckStatusWriter): ToolDefinition {
return {
name: "gitea_check_status",
description: "Set mesh/check, the checker's verdict and the one status main requires, on a pull request's head: pending, success, error, failure or warning, a short description, and the page it links to. The checker's forge bridge alone may call it (its manifest's invokes); no other status is written here, and no other tool writes this one.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the head's whole sha" },
context: { type: "string", description: "mesh/check, or left out" },
state: { type: "string", description: "pending | success | error | failure | warning" },
description: { type: "string", description: "one line" },
target_url: { type: "string", description: "the page it links to (the check's report)" },
},
run: async (args) => {
const owner = checkName("owner", args.owner), repo = checkName("name", args.repo), sha = String(args.sha ?? "");
if (args.context !== undefined && args.context !== null && String(args.context) !== CHECK_STATUS) {
throw new Error(`${JSON.stringify(args.context)}: this tool writes ${CHECK_STATUS} alone. Nothing was sent`);
}
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) {
throw new Error(`${JSON.stringify(args.sha)} is not a head's whole sha: ${CHECK_STATUS} names the head whole. Nothing was sent`);
}
// deliveryStatus checks the state and cuts the description; it refuses mesh/check, so it is asked for a
// context of the mesh's own and the status is named mesh/check after.
const checked = deliveryStatus("mesh/delivery", String(args.state), String(args.description ?? ""),
args.target_url ? String(args.target_url) : undefined);
const status = { ...checked, context: CHECK_STATUS };
await writer.setCommitStatus(owner, repo, sha, status);
return { set: status };
},
};
}
+2 -1
View File
@@ -212,7 +212,8 @@ export class GateWriter {
let since = h.pendingAt;
if (since === undefined) {
const gate = await this.forge.gateOf(owner, repo, sha);
if (gate && gate.state !== "pending") return;
// No gate on the forge: none was written (its base does not require it, issue 1301), and nothing is said.
if (!gate || gate.state !== "pending") return;
since = gate?.updated_at ? Date.parse(gate.updated_at) : undefined;
}
if (since !== undefined && at.getTime() < since - CLOCK_SLACK_MS) return;
+1 -1
View File
@@ -3,7 +3,7 @@ module gitea
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2 h1:Cdl5N56QPfmFBoLHrDfvvyc5Q+PUnTjf3fgYD7imcco=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021114014-66e8fdaa62e2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9 h1:Ufmd2cYEXICQ817IGflccP9bexABE4WhIO49HPbaoSo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9/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=
+42 -22
View File
@@ -12,7 +12,9 @@
// Consumes mesh-controller.checked — a pull request's merge check, judged — and sets it as the head
// commit's statuses: `mesh/merge-gate`, the modules of the mesh's graph the change touches, and
// `mesh/repo-check`, the repository's own merge-check.sh (novox/hq ADR 0237 as amended); with a comment
// saying why when the gate is not a pass or the repository's own check failed.
// saying why when the gate is not a pass or the repository's own check failed. Of the two, only those the base
// still requires are written (issue 1301): main requires `mesh/check` — the checker's, written by its forge bridge
// through gitea_check_status — everywhere but hq, whose main requires `mesh/repo-check` alone (ADR 0503 §5).
//
// Consumes node-build-agent.started — a build machine took an ask — and says, on the head a pending check is of,
// which machine builds it since when (novox/hq issue 446). This module is the one writer of mesh/merge-gate.
@@ -38,15 +40,17 @@
import { emit, on } from "@novox/mesh-sdk/events";
import { registerModuleTools } from "@novox/mesh-sdk/tools";
import { commitStatusTool } from "./delivery.js";
import { statesAnnouncement } from "./kept.js";
import { checkStatusTool, commitStatusTool } from "./delivery.js";
import { announce, keepWatermark, readWatermark } from "./announce.js";
import { mergeTool } from "./merge.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, statusesFor, type Announced, type Checked,
repoCheckFacts, reposToLook } from "./pulls.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, maintained,
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts, reposToLook } from "./pulls.js";
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
@@ -246,8 +250,11 @@ async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Pr
// Said, so an operator knows the mesh was asked to check it.
console.log(`[gitea] announced ${key} at ${sha.slice(0, 8)} to be checked before it merges`);
// Pending until the verdict comes, so the pull request says a check is running rather than nothing — and
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446).
await gates.pending(repo.owner, repo.name, sha);
// where it is: in the build queue from now, until a build machine takes it (novox/hq issue 446). Only where the base
// still requires mesh/merge-gate (issue 1301): a main that requires mesh/check is the checker's, and hq's requires
// mesh/repo-check alone; a rule that cannot be read is said pending, as before.
const rule = await client.appliedProtection(repo.owner, repo.name, String(pull.base || "main")).catch(() => undefined);
if (writesGate(requiredOf(rule))) await gates.pending(repo.owner, repo.name, sha);
return true;
}
type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
@@ -256,7 +263,7 @@ type OpenOf = Awaited<ReturnType<GiteaClient["listOpenPullsSince"]>>[number];
async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
try {
const checks = await client.headChecks(repo.owner, repo.name, String(pull.head_sha));
return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks;
return CHECK_CONTEXT in checks || REPO_CHECK_CONTEXT in checks || CHECK_STATUS in checks;
} catch {
return false; // the forge cannot say: announced, and the bus's message id collapses it if it was
}
@@ -393,16 +400,20 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
// (novox/hq ADR 0323 §4): a check asked before the trunk moved. It is said so, pending with the reason, in place of
// its pass or failure; a trunk that cannot be read lets the verdict stand, and the merge tool compares the commits.
const trunk = base ?? c.plan?.base;
// Only the statuses the base still requires are written (issue 1301): on hq, mesh/repo-check alone.
const required = trunk ? requiredOf(await client.appliedProtection(c.owner, c.repo, trunk).catch(() => undefined)) : undefined;
const kept = maintained(required);
if (c.trunk && trunk) {
const head = await client.branchHead(c.owner, c.repo, trunk).catch(() => undefined);
if (head && !sameCommit(c.trunk, head)) {
const description = outOfDateWords(c.trunk, head);
for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]) {
for (const context of [...(composedGate(c) ? [] : [CHECK_CONTEXT]), ...(c["repo-check"] ? [REPO_CHECK_CONTEXT] : [])]
.filter((x) => kept.includes(x))) {
await client.setCommitStatus(c.owner, c.repo, c.commit, { state: "pending", context, description,
...(target ? { target_url: target } : {}) });
}
// Its account is kept and shown all the same (ADR 0323 §7): what it judged, and that it is out of date.
const said = commentFor(c, await repoCheckFacts(client, c, trunk));
const said = commentFor(c, await repoCheckFacts(client, c, trunk), required);
if (c.number && !composedGate(c)) await client.addComment(c.owner, c.repo, c.number, lateVerdictComment(c, said, description));
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}, ` +
`${description}`);
@@ -411,10 +422,10 @@ async function setVerdict(client: GiteaClient, event: { body: unknown }): Promis
}
const facts = await repoCheckFacts(client, c, base ?? c.plan?.base);
const gateNow = composedGate(c) ? (await client.headChecks(c.owner, c.repo, c.commit).catch(() => ({} as Record<string, { description?: string }>)))[CHECK_CONTEXT]?.description : undefined;
for (const status of statusesFor(c, facts, gateNow)) {
for (const status of statusesFor(c, facts, gateNow, required)) {
await client.setCommitStatus(c.owner, c.repo, c.commit, target ? { ...status, target_url: target } : status);
}
const comment = composedGate(c) ? null : commentFor(c, facts);
const comment = composedGate(c) ? null : commentFor(c, facts, required);
if (comment && c.number) await client.addComment(c.owner, c.repo, c.number, comment);
console.log(`[gitea] ${c.owner}/${c.repo}#${c.number ?? "?"} at ${c.commit.slice(0, 8)}: merge check ${c.verdict}`);
}
@@ -472,10 +483,16 @@ const gates = new GateWriter({
const pace = new Pace();
if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
// The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2).
// Said with `emit`, not the SDK's `keeps`, which the toolchain's SDK does not have yet: switch to `keeps` once that SDK is 0.1.15 or later (issue 1852).
await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", process.env.MESH_NODE));
// gitea_events is served whether or not the forge is watched: it says so (novox/hq issue 729).
registerModuleTools("gitea", () => [eventsTool(pace), ...(gitea ? [commitStatusTool({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
gates,
}), checkStatusTool({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
}), mergeTool(gitea, { gates })] : [])]);
// **A build machine took an ask** (the build seat's `started`, novox/hq ADR 0157): said on the head it checks, when
@@ -590,15 +607,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)
}
+20
View File
@@ -0,0 +1,20 @@
// The states this module keeps on the bus and whether other modules may read them (novox/hq ADR 0495 clause 2; issue
// 1790): the module says it itself, from its code, as it starts (index.ts calls keeps with this list), and a reader is
// granted what it said (clause 3). A state is readable when another module's manifest reads it; none reads gitea's.
// test/kept.test.ts holds the list to the manifest's `state`.
export const KEPT: readonly { readonly name: string; readonly readable?: boolean }[] = [
{ name: "announced" },
{ name: "bound" },
];
/** The announcement of these states, in the layout mesh-sdk's go/announce reads (novox/hq ADR 0495 clause 2): version 1,
* the module, the machine when known, and the states sorted by name, `readable` only where true. It is said as the event
* `states`, which is the subject mesh.mod.gitea.event.states. gitea says it with the SDK's `emit` because the
* toolchain's SDK, which judges R4e's group, predates the SDK's `keeps` (0.1.15); switch to `keeps` once the toolchain's
* SDK is 0.1.15 or later (issue 1852). */
export function statesAnnouncement(module: string, node: string | undefined) {
const states = [...KEPT]
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
.map((k) => (k.readable ? { name: k.name, readable: true } : { name: k.name }));
return { v: 1, module, ...(node ? { node } : {}), states };
}
+41 -10
View File
@@ -4,13 +4,18 @@
// when a verdict is judged against an earlier trunk commit than the trunk's head, the tool asks the head's check and
// puts its statuses back to pending through that writer, so where the check runs is said and no second check is asked.
// Each bundle the runtime serves is its own process; from the tools' bundle the pending bypassed the writer's chain.
//
// **It judges on the status the base requires** (issue 1301; ADR 0447 §1, §4; ADR 0503 §5): mesh/check, the checker's,
// whose line names the trunk it judged ("pass <head12> on <trunk12>"); where a base does not require it, the controller's
// status it still requires (hq's mesh/repo-check), as before. A mesh/check of another trunk is refused and nothing is
// asked: the checker sets it out of date on the trunk's move and checks it again.
import { announce } from "./announce.js";
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
import type { CommitStatus, GiteaClient } from "./client.js";
import { mergeableUnder, newestByContext } from "./checks.js";
import { MERGE_METHODS, Refused, checkName } from "./guard.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT } from "./pulls.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, maintained, requiredOf } from "./pulls.js";
import { approvedHeadsOf, groupRefusal, memberKey, namesSiblings, notApproved, readSiblings, siblingOutOfDate, siblingsFile } from "./siblings.js";
import { announcementOf, askedWords, outOfDateWords, sameCommit, tookFile, tookWords, twelve, verdictAtTrunk, waitsForComposed } from "./trunk.js";
@@ -32,7 +37,7 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
return {
name: "gitea_merge_pull_request",
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head " +
"and every status its base branch's protection requires succeeded there (mesh/merge-gate, mesh/repo-check), " +
"and every status its base branch's protection requires succeeded there (mesh/check; hq's mesh/repo-check), " +
"judged against the trunk's head as it is now, optionally deleting the source branch afterwards. Refused " +
"otherwise, with nothing merged: no tool merges past a check, and none merges a head that moved after it was " +
"reviewed. A verdict judged against an earlier trunk commit is refused, naming both, and the head's check against " +
@@ -108,11 +113,33 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
// head makes — never again while one is on its way. The caller asks again at the reviewed head once it passes.
const trunkHead = await gitea.branchHead(owner, repo, pull.base);
const checks = await gitea.headChecks(owner, repo, pull.head_sha);
const at = verdictAtTrunk(checks, trunkHead);
const required = requiredOf(rule) ?? [];
if (required.includes(CHECK_STATUS)) {
// **mesh/check is the checker's** (ADR 0447 §4): it is judged here against the trunk's head, and refused when it
// is not current; nothing is written or asked — the checker checks a head again when its trunk moves.
const check = checks[CHECK_STATUS];
const at = verdictAtTrunk(checks, trunkHead, CHECK_STATUS);
if (!check || check.state === "pending") {
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ` +
`${check ? `pending (${check.description ?? ""})` : "not set yet"}; the checker answers it. Ask again at ` +
`${twelve(pull.head_sha)} once it passes against ${pull.base}'s head ${twelve(trunkHead)}. Nothing was merged`);
}
if (!at.current) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} ` +
`${at.judged ? `was judged on trunk ${twelve(at.judged)}` : "names no trunk commit it was judged on"}, and ` +
`${pull.base} is now ${twelve(trunkHead)}. The checker checks it again against the trunk's head (novox/hq ` +
`ADR 0447); this tool asks nothing. Ask again at ${twelve(pull.head_sha)} once it passes. Nothing was merged`);
}
}
// Where the base still requires the controller's statuses (hq's mesh/repo-check; mesh/merge-gate only on a
// revert), the first it requires judges, as before (ADR 0323 §5).
const kept = maintained(required);
const judge = kept[0] ?? CHECK_CONTEXT;
const at = kept.length ? verdictAtTrunk(checks, trunkHead, judge) : { current: true, onItsWay: false };
if (!at.current) {
// A verdict put out of date by a sibling's move says so in its own words (ADR 0382 §5); it is asked again as a
// trunk's move is, and a failed ask puts those words back, not the trunk's.
const siblingWords = siblingOutOfDate(checks[CHECK_CONTEXT]?.description) ? checks[CHECK_CONTEXT]?.description : undefined;
const siblingWords = siblingOutOfDate(checks[judge]?.description) ? checks[judge]?.description : undefined;
const judged = siblingWords ? `was put out of date by a sibling's move (${siblingWords})`
: at.judged ? `was judged against trunk ${twelve(at.judged)}` : "names no trunk commit it was judged against";
const took = tookWords(tookFile(deps.stateDir), `${owner}/${repo}`);
@@ -120,21 +147,22 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
// **A member that waits for its group's composed check is not announced again** (novox/hq issue 1106): its own
// check can only say "composed" once more, so an ask here brought a verdict naming no trunk and the next merge was
// refused the same way for ever. The composed check is mesh-delivery's to ask; this says whose verdict is awaited.
if (waitsForComposed(checks[CHECK_CONTEXT])) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[CHECK_CONTEXT]?.description}; the ` +
if (waitsForComposed(checks[judge])) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its merge gate ${checks[judge]?.description}; the ` +
`verdict that names its trunk commit is its group's composed check's, and mesh-delivery asks it, this tool asks no check ` +
`(main is now ${now}). Ask again once the group's composed check has passed. Nothing was merged`);
}
if (at.onItsWay) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict ${judged}, and ${pull.base} is now ` +
`${now}. Its check is already asked and not yet answered (${checks[CHECK_CONTEXT]?.description}), so it is not ` +
`${now}. Its check is already asked and not yet answered (${checks[judge]?.description}), so it is not ` +
`asked again; ${took}. Ask again at ${head} once both statuses pass: this tool never merges by itself ` +
`(novox/hq ADR 0323). A check that never answers is asked again with mesh-delivery.recheck. Nothing was merged`);
}
// Pending first, so a second ask finds the check on its way; then the head is announced as a new head is. An
// announcement that cannot be made puts the statuses back to out of date, so the next ask asks again rather
// than wait for ever on a check nobody asked.
const contexts = [CHECK_CONTEXT, ...(checks[REPO_CHECK_CONTEXT] ? [REPO_CHECK_CONTEXT] : [])];
// The statuses the base requires of the controller's (issue 1301): hq's mesh/repo-check alone.
const contexts = kept.filter((c) => c === judge || (c === REPO_CHECK_CONTEXT && checks[REPO_CHECK_CONTEXT]));
// Through the module's one writer of the merge check (gate.ts, novox/hq issue 456): the pending opens a new
// check of the head, so where it runs is said ("building on …") and its verdict is not weighed as older.
const say = async (description: string) => {
@@ -167,9 +195,12 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
// **A member judged beside its siblings merges only with its group passed and reviewed** (novox/hq ADR 0382 §6): the
// siblings its verdict names are the holder's record (siblings.ts); a status that says it read siblings with no record
// of them is no verdict to merge on, never read as none.
const heard = readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)];
// Under mesh/check (issue 1301) the group is the checker's one verdict on every member's head (ADR 0447 §2) and the
// Approve's heads above; a record the controller's verdicts left before the switch judges nothing there.
const heard = required.includes(CHECK_STATUS) ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)];
const siblings = heard && sameCommit(heard.head, pull.head_sha) ? heard.siblings : [];
if (!siblings.length && namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) {
if (!siblings.length && !required.includes(CHECK_STATUS) &&
namesSiblings(statuses.find((s) => s.context === CHECK_CONTEXT)?.description)) {
throw new Refused(`${owner}/${repo}#${number} is not merged: its verdict read siblings, and which ones is not held ` +
`here, so the group cannot be weighed. Ask its check again at ${twelve(pull.head_sha)}. Nothing was merged`);
}
+3 -2
View File
@@ -172,7 +172,7 @@
},
"repositories": {
"type": "object",
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/repo-check and mesh/merge-gate, and only then moved into the organisation; one that exists only has main's rule put right, its statuses kept. A repository taken out of the value is never touched.",
"description": "The repositories of the organisation novox the mesh holds (ADR 0428): each by name, {\"<name>\": {\"private\": true, \"description\": \"...\"}}. One that does not exist is made by the module's setup login under its own account, its main protected and given a first commit (a README naming the description and a merge-check.sh), its rule narrowed to no push and the tools' merge requiring mesh/check, and only then moved into the organisation; one that exists only has main's rule put right, mesh/check in place of mesh/merge-gate and mesh/repo-check and its other statuses kept (a main requiring mesh/repo-check alone keeps it). Every other repository whose main requires mesh/merge-gate has the two swapped for mesh/check; nothing else of a repository out of the value is touched.",
"trusted": true,
"default": {}
}
@@ -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"
]
}
+4 -4
View File
@@ -1,6 +1,6 @@
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's merge check —
// `mesh/merge-gate`, the module graph's gate, and `mesh/repo-check`, the repository's own tests — before it
// may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
// A branch's protection (novox/hq ADR 0237): what makes a pull request wait for the mesh's check — `mesh/check`,
// the checker's verdict on the head merged onto its trunk (ADR 0447 §1; hq's `mesh/repo-check` until issue 1296) —
// before it may merge. Pure, so it is tested without a forge; the client does the asking (client.ts).
import { TOOLS_ACCOUNT } from "./tools-account.js";
@@ -24,7 +24,7 @@ export interface BranchProtection {
/** What a branch's protection is set to. A field not given is left as the rule has it (or the forge's
* default on a new rule), except pushes, which a new rule refuses unless `push` says otherwise. */
export interface ProtectionWanted {
/** The statuses a pull request must have succeeded before it merges, e.g. mesh/merge-gate. Empty: none. */
/** The statuses a pull request must have succeeded before it merges, e.g. mesh/check. Empty: none. */
statusChecks: string[];
/** Whether a person may push to the branch directly. */
push?: boolean;
+45 -7
View File
@@ -19,6 +19,34 @@ export const CHECK_CONTEXT = "mesh/merge-gate";
/** The status context of the repository's own merge-check.sh, the check's second layer (novox/hq ADR 0237). */
export const REPO_CHECK_CONTEXT = "mesh/repo-check";
/** The one status main requires (novox/hq ADR 0447 §1, issue 1301): the checker's verdict, written by its forge bridge
* through gitea_check_status. Its line names the head and the trunk commit it judged: "pass <head12> on <trunk12> …". */
export const CHECK_STATUS = "mesh/check";
/** The statuses a branch's rule requires, from the rule the forge applies to it: none when it is unprotected or checks
* no status; undefined when the rule could not be read. */
export function requiredOf(rule: { enable_status_check?: boolean; status_check_contexts?: string[] } | null | undefined): string[] | undefined {
if (rule === undefined) return undefined;
if (!rule || !rule.enable_status_check) return [];
return (rule.status_check_contexts ?? []).filter(Boolean);
}
/**
* **The controller's statuses are written only where a base still requires them** (issue 1301; ADR 0503 §3, §5): of
* mesh/merge-gate and mesh/repo-check, those the base requires — hq's mesh/repo-check alone once the forge requires
* mesh/check everywhere else, so no mesh/merge-gate stands on a head that nothing will set. A base whose rule could not
* be read keeps both, as before: a status nothing requires blocks nothing, and one that is required is never left out.
*/
export function maintained(required: string[] | undefined): string[] {
const both = [CHECK_CONTEXT, REPO_CHECK_CONTEXT];
return required === undefined ? both : both.filter((c) => required.includes(c));
}
/** Whether mesh/merge-gate is written on a head of this base (maintained). */
export function writesGate(required: string[] | undefined): boolean {
return maintained(required).includes(CHECK_CONTEXT);
}
/**
* The pending words a head's merge check is announced with (novox/hq issue 446): where the check is, and since when,
* in the time the operator reads. They stood as "the mesh is mapping this head onto its module graph" for the whole
@@ -257,8 +285,14 @@ export function composedGate(c: Checked): boolean {
return c.gate?.verdict === "composed";
}
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said. */
export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] {
/** Every status a verdict sets: the gate's, and the repository's own check's when it was said; of them, only those the
* base requires (`required`, the base's rule: maintained). */
export function statusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string, required?: string[]): CommitStatus[] {
const kept = maintained(required);
return allStatusesFor(c, facts, gateNow).filter((s) => kept.includes(s.context));
}
function allStatusesFor(c: Checked, facts: RepoCheckFacts = {}, gateNow?: string): CommitStatus[] {
if (composedGate(c)) {
// A member whose gate the group's composed check judges (ADR 0403 §1): only its repository's check is its own to
// set. The gate stays pending, saying it waits for the group, unless a composed verdict already stands on it.
@@ -292,11 +326,13 @@ function judgedFirst(c: Checked, status: CommitStatus): CommitStatus {
* change that builds something (novox/hq ADR 0238), and why, when the gate is not a pass or the
* repository's own check failed or could not run. A repository with no merge-check.sh, touching nothing,
* is said by its statuses alone, not by a comment on every push. */
export function commentFor(c: Checked, facts: RepoCheckFacts = {}): string | null {
export function commentFor(c: Checked, facts: RepoCheckFacts = {}, required?: string[]): string | null {
const gate = c.gate ?? { verdict: c.verdict, summary: c.summary };
// A base that no longer requires the gate (issue 1301) is told nothing of it: its verdict is the repository's own check.
const withGate = writesGate(required);
const repo = c["repo-check"];
const wrong = repoWrong(repo, facts);
if (gate.verdict === "pass" && !wrong && !builds(c.plan)) return null;
if ((!withGate || gate.verdict === "pass") && !wrong && !builds(c.plan)) return null;
const lines = [`**Merge check** at \`${c.commit.slice(0, 8)}\``, ""];
if (c.trunk) {
// What was judged (novox/hq ADR 0323 §3): the head merged into the trunk commit, and what was read beside it.
@@ -312,9 +348,11 @@ export function commentFor(c: Checked, facts: RepoCheckFacts = {}): string | nul
} else if (c.siblings_unknown) {
lines.splice(c.trunk ? 2 : 1, 0, `Siblings could not be learned: ${c.siblings_unknown}`);
}
lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` +
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` +
(gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : ""));
if (withGate) {
lines.push(`- \`${CHECK_CONTEXT}\`: **${saidAs(gate.verdict).toUpperCase()}** — ${gate.summary}` +
(gate.modules?.length ? ` (modules: ${gate.modules.join(", ")}` +
(gate.dependents?.length ? `; built after them: ${gate.dependents.join(", ")}` : "") + ")" : ""));
}
if (repo) {
const st = repoStatus(repo, facts);
lines.push(`- \`${REPO_CHECK_CONTEXT}\`: ` + (repo.verdict === "warning" && facts.defined === false
+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)}`);
});
+50
View File
@@ -171,3 +171,53 @@ test("the root tree is git's hash of the root's own entries, never the listing's
server.close();
}
});
test("named paths: each asked path's git object id at the commit, a file's or a directory's, by directory (1758 S1, ADR 0499 §3)", async () => {
const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
const got = await client.commitTrees("novox", "hq", COMMIT, { "modules/a": ["main.go", "module.json"], "modules/b": ["c"], "": ["README.md", "modules/d"] });
assert.deepEqual(got.paths, {
"modules/a": { "main.go": h("6"), "module.json": h("5") },
"modules/b": { c: h("8") },
"": { "README.md": h("1"), "modules/d": h("a") },
});
assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") });
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered");
} finally {
server.close();
}
});
test("a named path the listing lacks fails naming the directory and the path, and answers nothing", async () => {
const { client, server } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, { "modules/a": ["main.go", "gone.go"] }), (err: Error) => {
assert.match(err.message, /modules\/a/);
assert.match(err.message, /gone\.go/);
return true;
});
for (const bad of [[""], ["../x"], ["/abs"], ["a//b"]]) {
await assert.rejects(client.commitTrees("novox", "hq", COMMIT, { "modules/a": bad }), /./, JSON.stringify(bad));
}
} finally {
server.close();
}
});
test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another shape before the forge is asked", async () => {
const { getGiteaTools } = await import("../tools/index.ts");
const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try {
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
assert.ok("paths" in tool.input, "the tool names the argument");
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any);
assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } });
const before = asked.length;
for (const paths of ["modules/a", ["main.go"], { "modules/a": "main.go" }, { "modules/a": [1] }]) {
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths } as any), /paths/, JSON.stringify(paths));
}
assert.equal(asked.length, before, "the forge was asked for a refused call");
} finally {
server.close();
}
});
+35
View File
@@ -61,3 +61,38 @@ test("one view per pull request, found by its marker; only the mesh's statuses",
assert.equal(deliveryStatus("mesh/repo-check", "pending", "checking again: main moved on").context, "mesh/repo-check");
assert.equal(deliveryStatus("mesh/delivery", "success", "x", "javascript:alert(1)").target_url, undefined);
});
// Issue 1301-S4 (ADR 0447 §1; the lead's choice A): main requires mesh/check, so no hand may set it. The status tool
// every caller may use refuses it in every state; it is written only by gitea_check_status, which the forge bridge
// alone lists in its manifest's `invokes`, so only its account may call it.
test("mesh/check is refused by the status tool and written by the check's own tool alone", async () => {
const { commitStatusTool, checkStatusTool, deliveryStatus, CHECK_STATUS } = await import("../delivery.ts");
assert.equal(CHECK_STATUS, "mesh/check");
const sha = "0123456789abcdef0123456789abcdef01234567";
const sent: string[] = [];
const setCommitStatus = async (o: string, r: string, s: string, st: any) =>
void sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`);
const gates = { rechecked: async () => { throw new Error("not the gate writer's"); } };
for (const state of ["pending", "success", "failure", "error", "warning"]) {
assert.throws(() => deliveryStatus("mesh/check", state, "x"), /mesh\/check is the checker's status/);
await assert.rejects(commitStatusTool({ setCommitStatus, gates } as any).run({ owner: "novox", repo: "mesh-sdk", sha,
context: "mesh/check", state, description: "by hand" }), /gitea_check_status/);
}
assert.deepEqual(sent, [], "nothing was sent for a hand-set mesh/check");
const tool = checkStatusTool({ setCommitStatus });
assert.equal(tool.name, "gitea_check_status");
for (const k of ["owner", "repo", "sha", "state", "description", "target_url", "context"]) assert.ok(k in tool.input, k);
// The bridge's arguments as it sends them for gitea_commit_status: the context may be given, and is mesh/check.
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/check", state: "success", description: "pass",
target_url: "https://forge.invalid/novox/mesh-sdk/pulls/1" });
await tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "pending", description: "y".repeat(300) });
assert.equal(sent[0], "novox/mesh-sdk@01234567 mesh/check success pass https://forge.invalid/novox/mesh-sdk/pulls/1");
assert.ok(sent[1].startsWith("novox/mesh-sdk@01234567 mesh/check pending yyyy"), sent[1]);
assert.ok(sent[1].length < 200, "the description is cut as every status's is");
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, context: "mesh/delivery", state: "success" }), /mesh\/check alone/);
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha: sha.slice(0, 12), state: "success" }), /whole sha/);
await assert.rejects(tool.run({ owner: "novox", repo: "mesh-sdk", sha, state: "green" }), /not a status/);
await assert.rejects(tool.run({ owner: "../x", repo: "mesh-sdk", sha, state: "success" }));
assert.equal(sent.length, 2, "nothing more was sent");
});
+10
View File
@@ -322,3 +322,13 @@ test("a repository as the mesh clones it is told from another", async () => {
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-hq.git", "novox", "hq"), false);
assert.equal(clonedAs("http://127.0.0.1:20000/novox/mesh-controller.git", "novox", "hq"), false);
});
// Issue 1301: a build machine's start on a head that holds no mesh/merge-gate (its base requires none, so none was written)
// says nothing: no "building on" status appears on a main that does not require the gate.
test("a start on a head with no gate on the forge writes nothing", async () => {
const { GateWriter } = await import("../gate.ts");
const forge = aForge();
const gates = new GateWriter(forge, announcedAt);
assert.equal(await gates.started("novox", "hq", SHA, startedOnShanks), false);
assert.equal(forge.kept["mesh/merge-gate"], undefined);
});
+56 -2
View File
@@ -15,7 +15,7 @@ import { GiteaClient } from "../dist/client.js";
import { ConfiguredToken } from "../dist/token.js";
import { getGiteaTools } from "../dist/tools/index.js";
import { mergeTool } from "../dist/merge.js";
import { commitStatusTool } from "../dist/delivery.js";
import { checkStatusTool, commitStatusTool } from "../dist/delivery.js";
import { GateWriter } from "../dist/gate.js";
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
@@ -168,7 +168,7 @@ const gates = { rechecked: (o: string, r: string, sha: string, s: any) => {
} };
const tools = [...getGiteaTools(client), commitStatusTool({
setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s), gates: writer,
}), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
}), checkStatusTool({ setCommitStatus: (o, r, sha, s) => client.setCommitStatus(o, r, sha, s) }), mergeTool(client, { gates, stateDir, emit: async (type, body) => { state.emitted.push({ type, body }); },
now: () => new Date(2026, 9, 12, 15, 1) })];
const tool = (name: string) => tools.find((t) => t.name === name)!;
const writes = () => state.sent.map((s) => `${s.method} ${s.path}`);
@@ -568,6 +568,10 @@ test("no tool's caller text becomes another path or another user: `..` and `?sud
["gitea_get_file", { ...repo, path: "README.md?sudo=jschoubben" }],
["gitea_commit_status", { ...repo, sha: "abc1234?sudo=jschoubben", context: "mesh/delivery", state: "success" }],
["gitea_commit_status", { ...repo, sha: "../../../admin/users", context: "mesh/delivery", state: "success" }],
["gitea_check_status", { ...repo, sha: "abc1234?sudo=jschoubben", state: "success" }],
["gitea_check_status", { ...repo, sha: "../../../admin/users", state: "success" }],
["gitea_check_status", { owner: "novox", repo: "..", sha: "0123456789abcdef0123456789abcdef01234567", state: "success" }],
["gitea_commit_status", { ...repo, sha: "0123456789abcdef0123456789abcdef01234567", context: "mesh/check", state: "success" }],
["gitea_commit_statuses", { ...repo, sha: ".." }],
["gitea_commit_statuses", { ...repo, sha: "main?sudo=jschoubben" }],
["gitea_branch_protection_set", { ...repo, branch: "..", status_checks: "mesh/merge-gate" }],
@@ -704,3 +708,53 @@ test("a group of two merges in one pass: the sibling's merge of the composed hea
assert.equal(done.merged, true);
assert.equal(state.emitted.length, 0, "no check was asked");
});
// **Issue 1301 (ADR 0447 §1, §4; ADR 0503 §3, §5): the merge tool judges on the status the base requires.** A main that
// requires mesh/check is judged by the checker's line ("pass <head12> on <trunk12> …"): it merges on a pass that names the
// trunk's head, and refuses one that names an older trunk, naming both, writing nothing and asking nothing — a re-check on
// a trunk's move is the checker's. No mesh/merge-gate is read or written there.
test("gitea_merge_pull_request judges a main requiring mesh/check on mesh/check alone, and asks nothing of it", async () => {
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/check"] }];
const line = (trunk: string, s = "success", words = "pass") =>
[{ context: "mesh/check", state: s, description: `${words} ${REVIEWED.slice(0, 12)} on ${trunk.slice(0, 12)} | unit 12 run` }];
const before = state.emitted.length;
state.statuses = line(MOVED);
await assert.rejects(merge(), (err: Error) => {
assert.match(err.message, /mesh\/check/);
assert.match(err.message, new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`));
assert.match(err.message, /checker checks it again/i);
assert.match(err.message, /Nothing was merged/);
return true;
});
state.statuses = line(TRUNK, "pending", "checking");
await assert.rejects(merge(), /mesh\/check is pending/);
state.statuses = judgedAt(TRUNK); // the old two, current: not what this main requires
await assert.rejects(merge(), /mesh\/check/);
assert.deepEqual(writes(), [], "nothing is written: mesh/check is the checker's");
assert.equal(state.emitted.length, before, "no check is asked");
assert.equal(state.merged, false);
state.statuses = line(TRUNK);
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
});
// hq's main requires mesh/repo-check alone (ADR 0503 §5): judged on its words, and an out-of-date one is asked again on
// mesh/repo-check alone; no mesh/merge-gate is written on it.
test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on it, and writes no mesh/merge-gate", async () => {
const { keepSiblings, siblingsFile } = await import("../dist/siblings.js");
keepSiblings(siblingsFile(stateDir), {}); // no sibling record left by the tests before
const merge = () => tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED });
state.rules = [{ ...protectedMain(), status_check_contexts: ["mesh/repo-check"] }];
state.statuses = [judgedAt(MOVED)[1]];
const before = state.emitted.length;
await assert.rejects(merge(), /judged against trunk/);
assert.deepEqual(state.sent.filter((s) => s.path.includes("/statuses/")).map((s) => `${s.body.context} ${s.body.state}`),
["mesh/repo-check pending"]);
assert.equal(state.emitted.length, before + 1, "its check is asked once");
await assert.rejects(merge(), /already asked/);
state.sent = [];
state.statuses = [judgedAt(TRUNK)[1]];
assert.equal(((await merge()) as Record<string, unknown>).merged, true);
});
+25
View File
@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { readFileSync } from "node:fs";
// 1790: the states the module announces are the states its manifest declares, no more, no fewer; none is readable
// because no other module's manifest reads one of them.
test("the states the module announces are the states its manifest declares", async () => {
const { KEPT } = await import("../kept.ts");
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
const declared = (manifest.state as (string | { name: string })[]).map((s) => (typeof s === "string" ? s : s.name)).sort();
assert.deepEqual(KEPT.map((k) => k.name).sort(), declared);
assert.ok(KEPT.every((k) => !k.readable), "no other module's manifest reads a state of gitea");
});
test("the announcement has the layout mesh-sdk's go/announce reads: version 1, the module, the node, the states sorted by name", async () => {
const { statesAnnouncement } = await import("../kept.ts");
assert.deepEqual(statesAnnouncement("gitea", "ace"), { v: 1, module: "gitea", node: "ace", states: [{ name: "announced" }, { name: "bound" }] });
assert.deepEqual(statesAnnouncement("gitea", undefined), { v: 1, module: "gitea", states: [{ name: "announced" }, { name: "bound" }] });
});
test("index.ts says the announcement as the event `states` before the tools are served", () => {
const source = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
assert.match(source, /await emit\("states", statesAnnouncement\(/);
assert.ok(source.indexOf('await emit("states"') < source.indexOf('registerModuleTools("gitea"'), "announced before the tools are served");
});
+24
View File
@@ -192,3 +192,27 @@ test("a member's own verdict with a composed gate layer leaves the gate to the c
// A verdict with an ordinary gate is as before.
assert.equal(composedGate({ ...c, gate: { verdict: "pass", summary: "x" } }), false);
});
// Issue 1301 (ADR 0503 §3, §5): gitea writes the controller's statuses only where the base still requires them. hq's main
// requires mesh/repo-check alone: its verdict sets mesh/repo-check and no mesh/merge-gate, and its comment names no gate.
test("a base's required statuses decide which of the controller's statuses are written", async () => {
const { maintained, writesGate, statusesFor, commentFor, CHECK_STATUS } = await import("../pulls.ts");
assert.equal(CHECK_STATUS, "mesh/check");
assert.deepEqual(maintained(undefined), ["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before");
assert.deepEqual(maintained(["mesh/repo-check"]), ["mesh/repo-check"]);
assert.deepEqual(maintained(["mesh/check", "other/x"]), []);
assert.deepEqual(maintained([]), []);
assert.equal(writesGate(undefined), true);
assert.equal(writesGate(["mesh/repo-check"]), false);
assert.equal(writesGate(["mesh/check"]), false);
assert.equal(writesGate(["mesh/merge-gate", "mesh/repo-check"]), true);
const c = { owner: "novox", repo: "hq", number: 5, commit: "a".repeat(40), id: "x", verdict: "warning", summary: "no script",
trunk: "b".repeat(12), "repo-check": { verdict: "pass", summary: "ok" } } as any;
const hq = statusesFor(c, {}, undefined, ["mesh/repo-check"]);
assert.deepEqual(hq.map((s: any) => s.context), ["mesh/repo-check"]);
assert.match(hq[0].description, /merged into trunk bbbbbbbbbbbb/);
assert.deepEqual(statusesFor(c).map((s: any) => s.context), ["mesh/merge-gate", "mesh/repo-check"], "unread: as before");
assert.deepEqual(statusesFor(c, {}, undefined, ["mesh/check"]), []);
const comment = commentFor(c, {}, ["mesh/repo-check"]);
assert.ok(comment === null || !comment.includes("mesh/merge-gate"), comment ?? "");
});
+18
View File
@@ -199,3 +199,21 @@ test("a release branch's move puts every open pull request into it out of date,
assert.equal(verdictAtTrunk({ "mesh/merge-gate": { state: "success",
description: "head x merged into trunk eeeeeeeeeeee — pass" } }, MOVED).current, false);
});
// Issue 1301: a trunk's move puts out of date only the controller's statuses the base still requires; on hq that is
// mesh/repo-check alone, judged by its own words; on a main requiring mesh/check, none (the checker's, ADR 0447 §4).
test("a trunk's move writes only the statuses the base still requires", async () => {
const { outOfDateStatuses, trunkNamed } = await import("../trunk.ts");
const old = "1".repeat(40), now = "2".repeat(40);
const repo = { state: "success", description: `head ${"a".repeat(12)} merged into trunk ${old.slice(0, 12)} — pass: y` };
const gate = { state: "success", description: `head ${"a".repeat(12)} merged into trunk ${old.slice(0, 12)} — pass: x` };
assert.deepEqual(outOfDateStatuses({ "mesh/repo-check": repo }, now, ["mesh/repo-check"]).map((s: any) => s.context), ["mesh/repo-check"]);
assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now, ["mesh/repo-check"]).map((s: any) => s.context),
["mesh/repo-check"]);
assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now, ["mesh/check"]), []);
assert.deepEqual(outOfDateStatuses({ "mesh/merge-gate": gate, "mesh/repo-check": repo }, now).map((s: any) => s.context),
["mesh/merge-gate", "mesh/repo-check"], "a rule not read: as before");
// The checker's line names its trunk as "<head12> on <trunk12>".
assert.equal(trunkNamed(`pass ${"a".repeat(12)} on ${old.slice(0, 12)} | unit 3 run`), old.slice(0, 12));
assert.equal(trunkNamed("building on shanks since 10:00"), undefined);
});
+11
View File
@@ -350,3 +350,14 @@ test("a pull request's edit event carries its description, and no other pull req
assert.equal(eventOf("pull_request", { action, pull_request: pull, repository }).body.body, undefined, action);
}
});
// Issue 1302: a status event carries the status's words, so the delivery gate reads mesh/check's line (the head and the trunk
// it judged) from the event itself.
test("a status event carries its context, state and words", async () => {
const { eventOf } = await import("../webhook.ts");
const e = eventOf("status", { sha: "a".repeat(40), context: "mesh/check", state: "success",
description: "pass aaaaaaaaaaaa on bbbbbbbbbbbb | unit 3 run, 0 kept", repository: { name: "x", owner: { login: "novox" } } });
assert.equal(e.name, "forge.status");
assert.equal((e.body as any).context, "mesh/check");
assert.equal((e.body as any).description, "pass aaaaaaaaaaaa on bbbbbbbbbbbb | unit 3 run, 0 kept");
});
+23 -6
View File
@@ -60,6 +60,22 @@ function namesChecked(tools: ToolDefinition[]): ToolDefinition[] {
}));
}
/** The `paths` argument of gitea_commit_trees: {directory: [path relative to it]}, each path plain (no empty, `.` or `..`
* segment, not absolute); anything else is refused before the forge is asked. Absent is absent. */
function namedPaths(raw: unknown): Record<string, string[]> | undefined {
if (raw === undefined || raw === null) return undefined;
if (typeof raw !== "object" || Array.isArray(raw)) throw new Error("paths is {directory: [path, ...]}");
const out: Record<string, string[]> = {};
for (const [dir, list] of Object.entries(raw as Record<string, unknown>)) {
if (!Array.isArray(list) || list.some((p) => typeof p !== "string")) throw new Error(`paths of ${dir || "the root"} is a list of paths`);
for (const p of list as string[]) {
if (p === "" || p.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`paths: ${JSON.stringify(p)} of ${dir || "the root"} is not a plain relative path`);
}
out[dir] = list as string[];
}
return out;
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin));
}
@@ -395,7 +411,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
// ---- Branch protection (novox/hq ADR 0237) ----
{
name: "gitea_branch_protection_get",
description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/merge-gate), approvals, and whether an administrator may merge past them.",
description: "A repository's branch protection: the rule for one branch (null when it has none) or every rule — whether direct pushes are refused, which commit statuses a pull request must have succeeded to merge (e.g. mesh/check), approvals, and whether an administrator may merge past them.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -413,12 +429,12 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_branch_protection_set",
description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's merge check sets `mesh/merge-gate` (the module graph's gate) and `mesh/repo-check` (the repository's own tests). Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
description: "Only ever stronger (novox/hq ADR 0266): a change that drops a required status, lets a person push directly or lets an administrator merge past a status is refused, and on main so is a change that drops the merge whitelist (only mesh-tools merges there) or widens it (novox/hq ADR 0398 §2, issue 948) — that is the operator's, at the forge. Make a branch require commit statuses before a pull request merges into it — the mesh's checker sets `mesh/check` (ADR 0447), and the repositories program requires it on every main. Edits the branch's rule (its required statuses replaced by these, everything else kept unless given) or creates one, which refuses direct pushes unless push=true. Answers the rule before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
branch: { type: "string", description: "the branch to protect, e.g. main" },
status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/merge-gate,mesh/repo-check; empty requires none" },
status_checks: { type: "string", description: "comma-separated statuses required to merge, e.g. mesh/check; empty requires none" },
push: { type: "boolean", description: "whether a person may push to the branch directly (a new rule refuses it when not given)" },
block_admin_override: { type: "boolean", description: "stop an administrator merging past a status that has not succeeded (left as it is when not given)" },
},
@@ -478,7 +494,7 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_commit_statuses",
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/merge-gate, mesh/repo-check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
description: "Read a commit's statuses — or a pull request's head's — as the forge keeps them: the newest of each context (mesh/check, mesh/delivery, …) with its state, description, link, when it was set and by whom; and whether the branch it merges into would let it merge, every status that branch's protection requires being a success. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
@@ -525,18 +541,19 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
},
{
name: "gitea_commit_trees",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the commit, its full 40-hex sha" },
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
},
run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const sha = String(args.sha ?? "").trim();
if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
return gitea.commitTrees(owner, repo, sha);
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths));
},
},
{
+24 -12
View File
@@ -12,12 +12,17 @@
// the moment it is asked, merges nothing when they differ, asks the head's check once — the same announcement a
// new head makes — and says how long the last check of that repository took. It never merges by itself after.
//
// Since issue 1301 (ADR 0447 §4) a main that requires mesh/check is the checker's: its line names the trunk commit it judged
// ("pass <head12> on <trunk12>"), the checker sets it out of date when the trunk moves, and the merge tool refuses a verdict
// of another trunk and asks nothing. The two statuses above are kept only where a base still requires them (hq's
// mesh/repo-check, pulls.ts maintained).
//
// Pure functions and two small files of the module's own state, so this is tested without a forge.
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
import { dirname } from "node:path";
import type { CommitStatus, GiteaClient, GiteaPull, HeadCheck } from "./client.js";
import { CHECK_CONTEXT, REPO_CHECK_CONTEXT, WAITS_FOR_COMPOSED } from "./pulls.js";
import { CHECK_CONTEXT, WAITS_FOR_COMPOSED, maintained, requiredOf } from "./pulls.js";
/** A commit as a verdict names it: its first twelve characters. */
export function twelve(sha: string | undefined): string {
@@ -40,7 +45,9 @@ export function judgedWords(head: string, trunk: string): string {
* verdict put out of date, the one it had judged ("judged against trunk …"). Undefined when it names none, as a
* verdict of a build seat from before ADR 0323 does. */
export function trunkNamed(description: string | undefined): string | undefined {
const m = /(?:merged into|judged against) trunk ([0-9a-f]{7,64})\b/i.exec(description ?? "");
const m = /(?:merged into|judged against) trunk ([0-9a-f]{7,64})\b/i.exec(description ?? "")
// The checker's mesh/check line (ADR 0447 §3): "<head12> on <trunk12>".
?? /\b[0-9a-f]{12} on ([0-9a-f]{12})\b/i.exec(description ?? "");
return m ? m[1].toLowerCase() : undefined;
}
@@ -70,15 +77,15 @@ export function waitsForComposed(gate: HeadCheck | undefined): boolean {
* gate, and the repository's own check when the head has one — a pending repo-check a verdict will not set would
* stand for ever (novox/hq issue 305's rule).
*/
export function outOfDateStatuses(checks: Record<string, HeadCheck>, trunkHead: string): CommitStatus[] {
const gate = checks[CHECK_CONTEXT];
if (!gate || gate.state === "pending") return [];
const judged = trunkNamed(gate.description);
export function outOfDateStatuses(checks: Record<string, HeadCheck>, trunkHead: string, required?: string[]): CommitStatus[] {
// Only the statuses the base still requires (issue 1301): the first of them the head holds judges.
const kept = maintained(required).filter((c) => checks[c]);
const judge = kept.length ? checks[kept[0]] : undefined;
if (!judge || judge.state === "pending") return [];
const judged = trunkNamed(judge.description);
if (judged && sameCommit(judged, trunkHead)) return [];
const description = outOfDateWords(judged, trunkHead);
const out: CommitStatus[] = [{ state: "pending", context: CHECK_CONTEXT, description }];
if (checks[REPO_CHECK_CONTEXT]) out.push({ state: "pending", context: REPO_CHECK_CONTEXT, description });
return out;
return kept.map((context) => ({ state: "pending" as const, context, description }));
}
/** Where the merge tool stands with a head's verdict and the trunk's head (ADR 0323 §5). */
@@ -91,8 +98,8 @@ export interface VerdictAtTrunk {
judged?: string;
}
export function verdictAtTrunk(checks: Record<string, HeadCheck>, trunkHead: string): VerdictAtTrunk {
const gate = checks[CHECK_CONTEXT];
export function verdictAtTrunk(checks: Record<string, HeadCheck>, trunkHead: string, context = CHECK_CONTEXT): VerdictAtTrunk {
const gate = checks[context];
const judged = trunkNamed(gate?.description);
if (checkOnItsWay(gate)) return { current: false, onItsWay: true, judged };
return { current: !!gate && gate.state !== "pending" && !!judged && sameCommit(judged, trunkHead), onItsWay: false, judged };
@@ -119,6 +126,9 @@ export function lateVerdictComment(c: { commit: string; trunk?: string; verdict:
export interface TrunkForge {
listOpenPulls(owner: string, repo: string): Promise<GiteaPull[]>;
branchHead(owner: string, repo: string, branch: string): Promise<string>;
/** The rule the forge applies to a branch: which of the controller's statuses are kept there (issue 1301). Not given,
* or not read: both, as before. */
appliedProtection?(owner: string, repo: string, branch: string): Promise<{ enable_status_check?: boolean; status_check_contexts?: string[] } | null>;
}
/** What it writes with: the merge check's one writer (gate.ts), which decides in the head's own turn. */
@@ -147,11 +157,13 @@ export async function weighTrunks(forge: TrunkForge, gates: OutOfDateWriter, tru
if (before && sameCommit(before, head)) continue;
if (before) {
let put = 0;
const required = forge.appliedProtection
? requiredOf(await forge.appliedProtection(owner, name, base).catch(() => undefined)) : undefined;
for (const pull of open.filter((p) => p.base === base)) {
// The trunk is read again in the head's own turn: a verdict judged against a head newer than the pass's first
// read — the trunk moved again meanwhile, and a check answered — is not put out of date.
const written = await gates.outOfDate(owner, name, String(pull.head_sha),
async (checks) => outOfDateStatuses(checks, await forge.branchHead(owner, name, base)), pull.html_url || undefined);
async (checks) => outOfDateStatuses(checks, await forge.branchHead(owner, name, base), required), pull.html_url || undefined);
if (written.length) put++;
}
console.log(`[gitea] ${owner}/${name}'s ${base} moved from ${twelve(before)} to ${twelve(head)}: ${put} open pull ` +
+3 -1
View File
@@ -127,7 +127,9 @@ function forgeEventOf(said: string, payload: any): ForgeEvent {
return { name: kind, body: { ...base, ref: str(payload.ref), ref_type: str(payload.ref_type), sha: str(payload.sha) } };
}
if (kind === "status") {
return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state) } };
// Its words too (issue 1302): mesh/check's line names the head and the trunk it judged, which the delivery gate reads.
return { name: "status", body: { ...base, sha: str(payload.sha), context: str(payload.context), state: str(payload.state),
description: str(payload.description) } };
}
const name = family === "other" ? `other.${kind.replace(/[^a-z0-9_-]/gi, "_") || "unknown"}` :
["fork"].includes(family) ? family : `${family}.${action ?? "changed"}`;
+23 -28
View File
@@ -10,7 +10,6 @@ import (
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
)
@@ -204,41 +203,37 @@ func TestTheLoginManagersEntryRunsTheSessionsStart(t *testing.T) {
}
}
// Every module of the catalogue that contributes window-manager lines (novox/hq ADR 0212) is placed in
// the main file as the controller places them — in module order, each module's under a line naming it
// — and loaded as i3 would load them on a machine with all of them assigned: no two bind one key, and
// every line parses.
// The window-manager lines the catalogue's modules contribute to the main file (novox/hq ADR 0212) are kept in
// testdata/window-manager-lines.json, one entry per contributing module in module order, as the controller places
// them. The test reads that file, never the other modules' manifests: a module may not read a manifest (ADR 0495
// §1), and a test that reads its siblings' fails wherever they are not beside it (issue 1814). The main file and
// those lines are placed together — each module's under a line naming it — and loaded as i3 would load them on a
// machine with all of them assigned: no two bind one key, and every line parses.
func TestTheMainFileAndEveryModulesContributionLoadTogether(t *testing.T) {
i3, err := exec.LookPath("i3")
if err != nil {
t.Skip("no i3 here to check with")
}
dir := t.TempDir()
manifests, _ := filepath.Glob(filepath.Join("..", "..", "..", "*", "module.json"))
sort.Strings(manifests)
raw, err := os.ReadFile(filepath.Join("testdata", "window-manager-lines.json"))
if err != nil {
t.Fatal(err)
}
var contributions []struct {
Module string `json:"module"`
Content []string `json:"content"`
}
if err := json.Unmarshal(raw, &contributions); err != nil {
t.Fatal(err)
}
var placed strings.Builder
var found []string
for _, p := range manifests {
raw, _ := os.ReadFile(p)
var m struct {
Module string `json:"module"`
Contributions []struct {
Seat, Kind, Content string
} `json:"contributions"`
}
json.Unmarshal(raw, &m)
named := false
for _, c := range m.Contributions {
if c.Seat != "node-display-session" || c.Kind != "config" {
continue
}
if !named {
placed.WriteString("# " + m.Module + "\n")
named = true
found = append(found, m.Module)
}
placed.WriteString(c.Content)
if !strings.HasSuffix(c.Content, "\n") {
for _, c := range contributions {
placed.WriteString("# " + c.Module + "\n")
found = append(found, c.Module)
for _, content := range c.Content {
placed.WriteString(content)
if !strings.HasSuffix(content, "\n") {
placed.WriteString("\n")
}
}
@@ -0,0 +1,50 @@
[
{
"module": "asus-zephyrus-g14",
"content": [
"# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The keys the firmware turns into ordinary key presses. The vendor keys that reach no X client are\n# triggerhappy's (/etc/triggerhappy/triggers.d/asus-g14.conf): M4 and Fn+F4/F5 for media, Fn+F7/F8 for\n# the panel, Fn+F10 for the touchpad. The keyboard backlight (Fn+F2/F3) is the firmware's and asusd's.\n\n# Fn+F6, the screenshot key: the firmware sends Super+Shift+S. Released before it runs, because the\n# screenshot grabs the pointer to select a region, which fails while the key is still held.\nbindsym --release $mod+Shift+s exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n# Fn+F9, the display key: the firmware sends Super+P. Odd workspaces to the panel, even ones to the\n# external output.\nbindsym $mod+p exec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-display order\n\n# The keyboard backlight's level, shown when it changes.\nexec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-kbd-notify\n# The laptop's own lines in i3 (module asus-zephyrus-g14, novox/hq ADR 0208, ADR 0210). Owned by the\n# mesh: replaced at every push. Once the controller places contributions to node-display-session\n# (ADR 0210), these become the module's contribution instead of a file in i3's directory.\n#\n# The model's panel and touchpad.\n\n# The internal panel is the primary output, where the bars' tray goes. Which monitors are on and where\n# is the display server's (autorandr, run at every session start).\nexec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-display primary\n\n# The touchpad's settings again, by hand. X applies them itself whenever the device appears.\nbindsym $mod+Shift+x exec --no-startup-id /usr/local/lib/example-laptop/bin/zephyrus-touchpad reset\n"
]
},
{
"module": "clipmenu",
"content": [
"# The clipboard's history key (module clipmenu, novox/hq ADR 0208). Owned by the mesh: replaced at\n# every push. clipmenu shows the history through `dmenu`, the node's dmenu-compatible command, which\n# the holder of node-launcher answers (rofi on the workstations); the chosen entry is put back on the\n# clipboard.\nbindsym $mod+period exec --no-startup-id clipmenu -p Clipboard\n"
]
},
{
"module": "dunst",
"content": [
"# The notifications' key (module dunst, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# Super+N opens the menu (rofi) of the actions and links of every notification on screen, so one is\n# chosen from the keyboard as a tap chooses it on the notification.\nbindsym $mod+n exec --no-startup-id dunstctl context\n"
]
},
{
"module": "feh",
"content": [
"# The wallpaper's key (module feh, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# It puts the declared wallpaper back, after a monitor change or a wallpaper set for the session.\nbindsym $mod+Shift+b exec --no-startup-id ~/.fehbg\n"
]
},
{
"module": "i3status-rust",
"content": [
"# The bars (module i3status-rust, novox/hq ADR 0208). Owned by the mesh: replaced at every push.\n# i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`. The bottom\n# bar shows the machine; the top bar the focused window and the tray, on the primary output. The\n# face is the monospace one every desktop module names.\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position bottom\n status_command i3status-rs ~/.config/i3status-rust/bottom-bar.toml\n tray_output none\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n # The text on an accent-coloured button is dark: light text on the accent was barely\n # legible.\n focused_workspace #de5200 #de5200 #000000\n active_workspace #de5200 #de5200 #000000\n inactive_workspace #000000 #000000 #ffffff\n urgent_workspace #2f343a #900000 #ffffff\n }\n}\n\nbar {\n font pango:JetBrainsMono Nerd Font 11\n position top\n status_command i3status-rs ~/.config/i3status-rust/top-bar.toml\n workspace_buttons no\n tray_output primary\n colors {\n separator #ffffff\n background #000000\n statusline #ffffff\n }\n}\n"
]
},
{
"module": "polychromatic",
"content": [
"# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh:\n# replaced at every push. This line is the tray's one start, at the session's start (exec, not\n# exec_always, so a reload starts nothing). The package's own login helper starts it too while the\n# application's setting \"Start the tray applet when I log on\" is ticked; polychromatic_check names\n# that as a second start.\nexec --no-startup-id polychromatic-tray-applet\n"
]
},
{
"module": "rofi",
"content": [
"# The launcher's key bindings (module rofi, novox/hq ADR 0208). Owned by the mesh: replaced at every\n# push. i3 reads this file through its configuration's `include ~/.config/i3/config.d/*.conf`, after\n# the variables it sets, so $mod is i3's.\nbindsym $mod+d exec --no-startup-id ~/.local/bin/rofi-launch drun\nbindsym $mod+t exec --no-startup-id ~/.local/bin/rofi-launch run\nbindsym $mod+Shift+t exec --no-startup-id ~/.local/bin/rofi-launch sudo\nbindsym $mod+Shift+w exec --no-startup-id ~/.local/bin/rofi-launch window\nbindsym $mod+Escape exec --no-startup-id ~/.local/bin/rofi-powermenu\n"
]
},
{
"module": "slack",
"content": [
"# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push.\n# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry.\n# No XDG autostart entry starts it as well; slack_check names one if it appears.\nexec --no-startup-id /usr/bin/slack --gtk-version=3 -s\n# The chat window. Slack owns four X windows, and the only one i3 manages has the class \"slack\" in\n# lower case; the three of class \"Slack\" (the packaged entry's StartupWMClass) are its unmanaged\n# helpers and its tray icon, so a rule on class=\"Slack\" matches nothing. window_role keeps a call or\n# screen-share window out of the rules. $ws3 is i3's, in scope here.\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] move to workspace $ws3\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] floating disable\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] border pixel 2\n"
]
}
]
+23 -4
View File
@@ -1,8 +1,9 @@
# kernel
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). Today it holds one:
**transparent huge pages**, set per machine. The rest of the row (the kernel package, microcode, boot
entries) is not built yet.
The kernel's run-time settings on a machine (novox/hq to-be 42 Phase 1, row 11). It holds two things:
**transparent huge pages**, set per machine, and **the fallback kernel** (`linux-lts` with its headers) and
its place on the boot menu (novox/hq ADR 0508 §9 and §10). The default kernel's package, and microcode, are
not built yet.
## Why (novox/hq issue 746)
@@ -35,16 +36,34 @@ under the new mode. A failed rotation is said in the unit's journal and does not
rotation is made while the machine is still starting (`systemctl is-system-running` says `initializing`
or `starting`): nothing is in huge pages yet, and the persistent journal opens after this unit.
## The fallback kernel (novox/hq ADR 0508, issue 1841)
Every machine keeps a second, older kernel to start from, so a kernel upgrade that does not boot is not the
end of the machine. The module declares the packages `linux-lts` and `linux-lts-headers` (so every DKMS module is
built for the fallback kernel too) after its boot-entries service, so the entry is in place before the kernel is.
| | what | notes |
|---|---|---|
| file | `${dir:state}/boot-entries` | the script. It reads from the machine which loader booted it: the EFI variable `LoaderEntrySelected` present means systemd-boot, a `/boot/grub/grub.cfg` without it means GRUB, neither fails the unit loudly |
| | systemd-boot | writes `/boot/loader/entries/mesh-linux-lts.conf` from `/proc/cmdline` without `initrd=` and `BOOT_IMAGE=`, with the microcode images present in `/boot` and the fallback initramfs as initrd lines; `loader.conf` and the default entry are never touched |
| | GRUB | runs `grub-mkconfig -o /boot/grub/grub.cfg` only when the set of `/boot/vmlinuz-*` differs from the kernels the menu names, with `GRUB_TOP_LEVEL` naming the kernel the machine booted (`BOOT_IMAGE`; else the non-fallback kernel), since GRUB lists kernels in reverse version order and would otherwise make the fallback kernel the default; `/etc/default/grub` still overrides it when it sets one |
| | room | while `/boot/vmlinuz-linux-lts` is not there yet, less than 96 MiB free on `/boot` fails the unit and says so (the fallback kernel writes about 72 MB, issue 1836) |
| | a kernel-copying hook | a hook in `/etc/pacman.d/hooks` that the mesh did not write, triggered by kernel images and copying to one fixed file name in `/boot`, would put the fallback kernel in the default kernel's place: it is moved into `${dir:state}/taken-over/` and named in the unit's journal. Each kernel is then placed under its own package's name by the distribution's hooks |
| file | `/etc/systemd/system/mesh-kernel-boot-entries.service` | oneshot, sandboxed: the file system read-only but for `/boot`, `/etc/pacman.d/hooks` and the module's state; no home, no network |
| file | `/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook` | runs the script after a kernel image is installed, upgraded or removed |
| packages | `linux-lts`, `linux-lts-headers` | declared after the service and the hook |
## Tools
| tool | | answers |
|---|---|---|
| `kernel_boot` | r | the loader (`systemd-boot`, `grub` or `none`), the menu's entries, the kernels in `/boot`, the entry booted (systemd-boot) or the kernel image booted (GRUB), whether the fallback kernel is on the menu with its kernel in `/boot`, and the room free on `/boot` in KiB |
| `kernel_huge_pages` | r | the mode, defrag and shared-memory mode; each per-size control and what `inherit` resolves to; huge pages by kind from `/proc/meminfo` (MiB); the units holding the most file pages in huge folios (`memory.stat` `file_thp`) with their dirty and writeback bytes, khugepaged's collapses and the bytes their cgroup wrote (`io.stat`, the most any one device saw: a mirror and its members each count the same write) |
## Tests
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); and the script as the mesh
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh
writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once,
rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a
reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel
+89
View File
@@ -0,0 +1,89 @@
package main
// kernel_boot: which loader booted the machine, its entries, the kernels in /boot, the entry it booted and
// whether the fallback kernel is on the menu (novox/hq ADR 0508 §9, issue 1841). Reads only.
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"syscall"
"unicode/utf16"
)
const loaderSelected = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
// BootState is what kernel_boot answers.
type BootState struct {
Loader string `json:"loader"`
Entries []string `json:"entries"`
Kernels []string `json:"kernels"`
Booted string `json:"booted"`
FallbackPresent bool `json:"fallback_present"`
BootFreeKiB int64 `json:"boot_free_kib"`
}
var grubKernel = regexp.MustCompile(`(?m)^\s*linux\s+(?:/boot)?/(vmlinuz-\S+)`)
var grubTitle = regexp.MustCompile(`(?m)^\s*menuentry\s+'([^']*)'`)
var bootImage = regexp.MustCompile(`BOOT_IMAGE=(?:/boot)?/?(\S+)`)
// Boot reads the machine, the loader the way the boot-entries script does: the loader's EFI variable means
// systemd-boot, a grub.cfg without it GRUB.
func (m *Machine) Boot() (*BootState, error) {
b := &BootState{Loader: "none", Entries: []string{}, Kernels: []string{}}
files, _ := filepath.Glob(m.path("boot/vmlinuz-*"))
for _, f := range files {
b.Kernels = append(b.Kernels, filepath.Base(f))
}
sort.Strings(b.Kernels)
hasKernel := func(name string) bool {
for _, k := range b.Kernels {
if k == name {
return true
}
}
return false
}
var s syscall.Statfs_t
if err := syscall.Statfs(m.path("boot"), &s); err == nil {
b.BootFreeKiB = int64(s.Bavail) * int64(s.Bsize) / 1024
}
if raw, err := os.ReadFile(m.path(loaderSelected)); err == nil {
b.Loader = "systemd-boot"
if len(raw) > 4 {
var u []uint16
for i := 4; i+1 < len(raw); i += 2 {
u = append(u, uint16(raw[i])|uint16(raw[i+1])<<8)
}
b.Booted = strings.TrimRight(string(utf16.Decode(u)), "\x00")
}
files, _ := filepath.Glob(m.path("boot/loader/entries/*.conf"))
for _, f := range files {
b.Entries = append(b.Entries, filepath.Base(f))
}
sort.Strings(b.Entries)
if raw, err := os.ReadFile(m.path("boot/loader/entries/mesh-linux-lts.conf")); err == nil {
b.FallbackPresent = strings.Contains(string(raw), "/vmlinuz-linux-lts") && hasKernel("vmlinuz-linux-lts")
}
return b, nil
}
if raw, err := os.ReadFile(m.path("boot/grub/grub.cfg")); err == nil {
b.Loader = "grub"
for _, t := range grubTitle.FindAllStringSubmatch(string(raw), -1) {
b.Entries = append(b.Entries, t[1])
}
for _, k := range grubKernel.FindAllStringSubmatch(string(raw), -1) {
if k[1] == "vmlinuz-linux-lts" && hasKernel("vmlinuz-linux-lts") {
b.FallbackPresent = true
}
}
if cmd, err := m.read("proc/cmdline"); err == nil {
if i := bootImage.FindStringSubmatch(cmd); i != nil {
b.Booted = i[1]
}
}
}
return b, nil
}
@@ -0,0 +1,90 @@
package main
// kernel_boot over stand-in roots: a systemd-boot machine with and without the fallback entry, a GRUB machine
// with and without the fallback kernel in its menu, and a machine with neither (novox/hq ADR 0508 §9, issue 1841).
import (
"os"
"path/filepath"
"testing"
)
const selectedVar = "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f"
func put(t *testing.T, root, p, content string) {
t.Helper()
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
write(t, full, content, 0o644)
}
func bootOf(t *testing.T, root string) *BootState {
t.Helper()
got, err := (&Machine{Root: root}).Boot()
if err != nil {
t.Fatal(err)
}
return got
}
func TestKernelBootReadsASystemdBootMachineWithItsFallbackEntry(t *testing.T) {
root := t.TempDir()
put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
put(t, root, "boot/loader/entries/arch.conf", defaultEntry)
put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\n")
put(t, root, "boot/vmlinuz-linux", "k")
put(t, root, "boot/vmlinuz-linux-lts", "k")
got := bootOf(t, root)
if got.Loader != "systemd-boot" || got.Booted != "arch.conf" || !got.FallbackPresent {
t.Fatalf("%+v", got)
}
if len(got.Entries) != 2 || got.Entries[0] != "arch.conf" || got.Entries[1] != "mesh-linux-lts.conf" {
t.Fatalf("entries: %v", got.Entries)
}
if len(got.Kernels) != 2 || got.Kernels[0] != "vmlinuz-linux" || got.Kernels[1] != "vmlinuz-linux-lts" {
t.Fatalf("kernels: %v", got.Kernels)
}
if got.BootFreeKiB <= 0 {
t.Fatalf("free: %d", got.BootFreeKiB)
}
}
func TestKernelBootSaysASystemdBootMachineHasNoFallbackYet(t *testing.T) {
root := t.TempDir()
put(t, root, selectedVar, "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
put(t, root, "boot/loader/entries/arch.conf", defaultEntry)
put(t, root, "boot/vmlinuz-linux", "k")
// The entry file alone is not the fallback: the kernel it names must be in /boot.
put(t, root, "boot/loader/entries/mesh-linux-lts.conf", "linux /vmlinuz-linux-lts\n")
if got := bootOf(t, root); got.FallbackPresent {
t.Fatalf("a fallback entry without its kernel: %+v", got)
}
}
func TestKernelBootReadsAGrubMachine(t *testing.T) {
root := t.TempDir()
put(t, root, "boot/grub/grub.cfg", grubMenu)
put(t, root, "boot/vmlinuz-linux", "k")
put(t, root, "proc/cmdline", bootedCmdline)
got := bootOf(t, root)
if got.Loader != "grub" || got.FallbackPresent || got.Booted != "vmlinuz-linux" {
t.Fatalf("%+v", got)
}
if len(got.Entries) != 2 || got.Entries[0] != "Arch Linux" {
t.Fatalf("entries: %v", got.Entries)
}
put(t, root, "boot/vmlinuz-linux-lts", "k")
put(t, root, "boot/grub/grub.cfg", grubMenu+"menuentry 'Arch Linux LTS' {\n\tlinux\t/boot/vmlinuz-linux-lts root=x rw\n}\n")
if got := bootOf(t, root); !got.FallbackPresent {
t.Fatalf("%+v", got)
}
}
func TestKernelBootSaysWhenThereIsNoLoader(t *testing.T) {
got := bootOf(t, t.TempDir())
if got.Loader != "none" || got.FallbackPresent || len(got.Entries) != 0 {
t.Fatalf("%+v", got)
}
}
@@ -0,0 +1,284 @@
package main
// The boot-entries script as the mesh writes it, run with sh over a stand-in root (novox/hq ADR 0508 §9 and
// §10, issue 1841). The fixtures are the shapes read from machines: a systemd-boot entries directory, a GRUB
// menu, a hook that copies every kernel image to the default kernel's name, and the command line a machine
// booted with.
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
const bootedCmdline = "initrd=\\initramfs-linux.img BOOT_IMAGE=/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3 initrd=\\intel-ucode.img\n"
const defaultEntry = "title Arch Linux\nlinux /vmlinuz-linux\ninitrd /intel-ucode.img\ninitrd /initramfs-linux.img\noptions root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet\n"
const loaderConf = "default arch.conf\ntimeout 3\nconsole-mode max\n"
// grubMenu is the part of a generated grub.cfg that names kernels, for a machine holding one kernel.
const grubMenu = `menuentry 'Arch Linux' --class arch {
set root='hd0,gpt2'
linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet
initrd /boot/initramfs-linux.img
}
menuentry 'Arch Linux, with fallback initramfs' --class arch {
linux /boot/vmlinuz-linux root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet
initrd /boot/initramfs-linux-fallback.img
}
`
// kernelCopyingHook copies every installed kernel image to the default kernel's name on the boot partition.
const kernelCopyingHook = `[Trigger]
Operation = Install
Operation = Upgrade
Type = Path
Target = usr/lib/modules/*/vmlinuz
[Action]
Description = Copying the kernel to the default name
When = PostTransaction
Exec = /usr/bin/sh -c '/usr/bin/cp /usr/lib/modules/$(uname -r)/vmlinuz /boot/vmlinuz-linux'
`
// otherHook is a hook of the distribution's shape that has nothing to do with kernel images.
const otherHook = `[Trigger]
Operation = Upgrade
Type = Package
Target = pacman-mirrorlist
[Action]
Description = Updating the mirror list
When = PostTransaction
Exec = /usr/bin/cp /etc/pacman.d/mirrorlist.pacnew /etc/pacman.d/mirrorlist
`
type bootBed struct {
script, root, state, bin, mkconfig string
need string
}
func newBootBed(t *testing.T) bootBed {
t.Helper()
dir := t.TempDir()
b := bootBed{script: filepath.Join(dir, "boot-entries"), root: filepath.Join(dir, "root"), state: filepath.Join(dir, "state"),
bin: filepath.Join(dir, "bin"), mkconfig: filepath.Join(dir, "mkconfig")}
content := manifest(t).resource(t, "boot-entries").text(t)
content = strings.ReplaceAll(content, "${dir:state}", b.state)
if strings.Contains(content, "${") {
t.Fatalf("a substitution the test does not make is left in the script")
}
for _, d := range []string{b.root + "/boot", b.root + "/proc", b.root + "/etc/pacman.d/hooks", b.state, b.bin} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
write(t, b.script, content, 0o755)
write(t, b.root+"/proc/cmdline", bootedCmdline, 0o644)
// the stand-in grub-mkconfig records the kernel it was told to put first, then its arguments.
write(t, filepath.Join(b.bin, "grub-mkconfig"), "#!/bin/sh\necho \"top=$GRUB_TOP_LEVEL $@\" >>"+b.mkconfig+"\n", 0o755)
return b
}
func (b bootBed) put(t *testing.T, p, content string) {
t.Helper()
full := filepath.Join(b.root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
write(t, full, content, 0o644)
}
func (b bootBed) systemdBoot(t *testing.T) {
b.put(t, "sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f", "\x07\x00\x00\x00a\x00r\x00c\x00h\x00.\x00c\x00o\x00n\x00f\x00\x00\x00")
b.put(t, "boot/loader/loader.conf", loaderConf)
b.put(t, "boot/loader/entries/arch.conf", defaultEntry)
b.put(t, "boot/vmlinuz-linux", "k")
}
func (b bootBed) run(t *testing.T) (string, error) {
t.Helper()
args := []string{b.script, "run", b.root, b.state}
if b.need != "" {
args = append(args, b.need)
}
cmd := exec.Command("sh", args...)
cmd.Env = append(os.Environ(), "PATH="+b.bin+string(os.PathListSeparator)+os.Getenv("PATH"))
out, err := cmd.CombinedOutput()
return string(out), err
}
func (b bootBed) read(t *testing.T, p string) string {
t.Helper()
raw, _ := os.ReadFile(filepath.Join(b.root, p))
return string(raw)
}
func TestASystemdBootMachineGetsTheFallbackEntryFromItsBootedCommandLine(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
b.put(t, "boot/intel-ucode.img", "u")
out, err := b.run(t)
if err != nil || !strings.Contains(out, "wrote") {
t.Fatalf("%v %s", err, out)
}
want := "title Arch Linux (linux-lts, fallback)\nlinux /vmlinuz-linux-lts\ninitrd /intel-ucode.img\ninitrd /initramfs-linux-lts.img\n" +
"options root=UUID=2f6c1e0a-5c0e-4d52-9a57-1d8a2a7a3c11 rw quiet loglevel=3\n"
if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); got != want {
t.Fatalf("entry:\n%s\nwant:\n%s", got, want)
}
// The default entry and the loader's own file are as they were.
if b.read(t, "boot/loader/entries/arch.conf") != defaultEntry || b.read(t, "boot/loader/loader.conf") != loaderConf {
t.Fatalf("the default entry or loader.conf was touched")
}
if out, err := b.run(t); err != nil || !strings.Contains(out, "as it was") {
t.Fatalf("again: %v %s", err, out)
}
}
func TestWithoutMicrocodeThereIsNoMicrocodeLine(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
if out, err := b.run(t); err != nil {
t.Fatalf("%v %s", err, out)
}
if got := b.read(t, "boot/loader/entries/mesh-linux-lts.conf"); strings.Contains(got, "ucode") || !strings.Contains(got, "initrd /initramfs-linux-lts.img") {
t.Fatalf("entry: %s", got)
}
}
func TestAGrubMachineIsGeneratedAgainOnlyWhenItsKernelsChanged(t *testing.T) {
b := newBootBed(t)
b.put(t, "boot/grub/grub.cfg", grubMenu)
b.put(t, "boot/vmlinuz-linux", "k")
if out, err := b.run(t); err != nil || !strings.Contains(out, "left as it is") {
t.Fatalf("same: %v %s", err, out)
}
if _, err := os.Stat(b.mkconfig); err == nil {
t.Fatalf("the menu was generated again with the same kernels")
}
b.put(t, "boot/vmlinuz-linux-lts", "k")
if out, err := b.run(t); err != nil || !strings.Contains(out, "generated again") {
t.Fatalf("changed: %v %s", err, out)
}
raw, _ := os.ReadFile(b.mkconfig)
if string(raw) != "top=/boot/vmlinuz-linux -o "+filepath.Join(b.root, "boot/grub/grub.cfg")+"\n" {
t.Fatalf("grub-mkconfig was asked %q", raw)
}
}
// ADR 0508 §9: GRUB's menu lists kernels in reverse version order, so linux-lts would come first and become the
// default entry (GRUB_DEFAULT=0); the kernel the machine booted is put first, and the fallback kernel never.
func TestGrubKeepsTheBootedKernelFirstAndNeverTheFallback(t *testing.T) {
for name, c := range map[string]struct{ cmdline, want string }{
"booted from the default kernel": {"BOOT_IMAGE=/vmlinuz-linux root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
"booted from a grub device path": {"BOOT_IMAGE=(hd0,msdos1)/vmlinuz-linux-zen root=UUID=x rw\n", "top=/boot/vmlinuz-linux-zen"},
"booted from the fallback kernel": {"BOOT_IMAGE=/vmlinuz-linux-lts root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
"no image on the command line": {"root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
"an image no longer in /boot": {"BOOT_IMAGE=/vmlinuz-linux-gone root=UUID=x rw\n", "top=/boot/vmlinuz-linux"},
} {
b := newBootBed(t)
b.put(t, "boot/grub/grub.cfg", grubMenu)
b.put(t, "proc/cmdline", c.cmdline)
for _, k := range []string{"vmlinuz-linux", "vmlinuz-linux-lts", "vmlinuz-linux-zen"} {
b.put(t, "boot/"+k, "k")
}
if out, err := b.run(t); err != nil {
t.Fatalf("%s: %v %s", name, err, out)
}
raw, _ := os.ReadFile(b.mkconfig)
if !strings.HasPrefix(string(raw), c.want+" -o ") {
t.Errorf("%s: grub-mkconfig was asked %q, want %s first", name, raw, c.want)
}
}
}
func TestAMachineWithNoLoaderFailsLoudly(t *testing.T) {
b := newBootBed(t)
out, err := b.run(t)
if err == nil || !strings.Contains(out, "no boot loader found") {
t.Fatalf("%v %s", err, out)
}
}
func TestABootPartitionWithoutRoomFailsBeforeAnythingIsWritten(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
b.need = "999999999999"
out, err := b.run(t)
if err == nil || !strings.Contains(out, "free room there before the kernel is installed") {
t.Fatalf("%v %s", err, out)
}
if b.read(t, "boot/loader/entries/mesh-linux-lts.conf") != "" {
t.Fatalf("an entry was written")
}
// Once the fallback kernel is in place the room is not asked for again.
b.put(t, "boot/vmlinuz-linux-lts", "k")
if out, err := b.run(t); err != nil {
t.Fatalf("%v %s", err, out)
}
}
func TestAHookThatCopiesKernelsToOneFixedNameIsMovedIntoTheStateAndNamed(t *testing.T) {
b := newBootBed(t)
b.systemdBoot(t)
b.put(t, "etc/pacman.d/hooks/60-copy-kernel.hook", kernelCopyingHook)
b.put(t, "etc/pacman.d/hooks/70-mirrorlist.hook", otherHook)
b.put(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook", "[Trigger]\nTarget = usr/lib/modules/*/vmlinuz\n[Action]\nExec = /bin/cp x /boot/y\n")
out, err := b.run(t)
if err != nil || !strings.Contains(out, "taken over: the pacman hook 60-copy-kernel.hook") {
t.Fatalf("%v %s", err, out)
}
if b.read(t, "etc/pacman.d/hooks/60-copy-kernel.hook") != "" {
t.Fatalf("the copying hook is still in the hook directory")
}
if raw, _ := os.ReadFile(filepath.Join(b.state, "taken-over", "60-copy-kernel.hook")); string(raw) != kernelCopyingHook {
t.Fatalf("moved hook: %q", raw)
}
if b.read(t, "etc/pacman.d/hooks/70-mirrorlist.hook") != otherHook || b.read(t, "etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook") == "" {
t.Fatalf("a hook that copies no kernel was moved")
}
}
func TestTheBootEntriesUnitAndHookAreTheModules(t *testing.T) {
m := manifest(t)
unit := m.resource(t, "boot-unit").text(t)
for _, line := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=${dir:state}/boot-entries run", "ProtectSystem=strict",
"ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks", "NoNewPrivileges=yes", "WantedBy=multi-user.target"} {
if !strings.Contains(unit, line+"\n") {
t.Errorf("the unit lacks %q", line)
}
}
hook := m.resource(t, "boot-hook")
text := hook.text(t)
if hook["path"] != "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook" || !strings.Contains(text, "Target = usr/lib/modules/*/vmlinuz\n") ||
!strings.Contains(text, "When = PostTransaction\n") || !strings.Contains(text, "Exec = ${dir:state}/boot-entries run\n") {
t.Fatalf("the hook: %v\n%s", hook["path"], text)
}
apply := m.resource(t, "boot-apply")
if apply["unit"] != "mesh-kernel-boot-entries.service" || apply["state"] != "running" || apply["boot"] != "enabled" {
t.Fatalf("apply: %v", apply)
}
}
// The fallback kernel and its headers are declared after the boot-entries service, so the entry is placed
// before the kernel is installed and every DKMS module is built for it (ADR 0508 §9).
func TestTheFallbackKernelAndItsHeadersAreDeclaredAfterTheBootEntriesUnit(t *testing.T) {
m := manifest(t)
at := map[string]int{}
for i, r := range m.Resources {
at[r["id"].(string)] = i
}
for id, pkg := range map[string]string{"linux-lts": "linux-lts", "linux-lts-headers": "linux-lts-headers"} {
r := m.resource(t, id)
if r["type"] != "package" || r["package"] != pkg || r["absent"] == true {
t.Errorf("%s: %v", id, r)
}
if at[id] < at["boot-apply"] || at[id] < at["boot-hook"] {
t.Errorf("%s is declared before the boot-entries unit and hook", id)
}
}
}
@@ -1,6 +1,6 @@
package main
// Over a stand-in tree holding novox's own files of 2026-10-14 23:25 (read through node-reads for
// Over a stand-in tree holding the files of a real machine of 2026-10-14 23:25 (read through node-reads for
// novox/hq issue 746): the mode, the per-size controls, meminfo's huge page lines, and fail2ban's and
// journald's memory.stat and io.stat, cut to the lines the tool reads.
@@ -26,7 +26,7 @@ func tree(t *testing.T, files map[string]string) *Machine {
return &Machine{Root: root}
}
func novox(t *testing.T) *Machine {
func capturedMachine(t *testing.T) *Machine {
thp := "sys/kernel/mm/transparent_hugepage/"
cg := "sys/fs/cgroup/system.slice/"
files := map[string]string{
@@ -53,8 +53,8 @@ func novox(t *testing.T) *Machine {
return tree(t, files)
}
func TestNovoxBeforeTheChange(t *testing.T) {
a, err := novox(t).HugePages(10)
func TestTheCapturedMachineBeforeTheChange(t *testing.T) {
a, err := capturedMachine(t).HugePages(10)
if err != nil {
t.Fatal(err)
}
@@ -86,7 +86,7 @@ func TestNovoxBeforeTheChange(t *testing.T) {
}
func TestUnitsAreBoundedAndNoneIsSaid(t *testing.T) {
a, err := novox(t).HugePages(1)
a, err := capturedMachine(t).HugePages(1)
if err != nil || len(a.Units) != 1 {
t.Fatalf("%v %+v", err, a)
}
+10
View File
@@ -46,5 +46,15 @@ func tools(m *Machine) []stdio.Tool {
return m.HugePages(n)
},
},
{
Name: "kernel_boot",
Description: "How this machine boots and whether the fallback kernel is on its menu (novox/hq ADR 0508 §9): the loader that booted it " +
"(systemd-boot, read from the EFI variable it sets, grub, or none), the menu's entries, the kernels in /boot, the entry " +
"the machine booted (systemd-boot) or the kernel image it booted (grub), whether the fallback kernel linux-lts is on " +
"the menu with its kernel in /boot, and the room free on /boot in KiB (the fallback kernel needs about 72 MB). " +
"Replaces ls /boot, bootctl list, reading grub.cfg and df /boot. (r)",
Input: schema(map[string]any{}),
Run: func(args map[string]any) (any, error) { return m.Boot() },
},
}
}
@@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
for _, r := range m.Resources {
ids = append(ids, r["id"].(string))
}
if strings.Join(ids, " ") != "state huge-pages unit apply" {
if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers" {
t.Fatalf("order: %v", ids)
}
script := m.resource(t, "huge-pages")
@@ -129,7 +129,7 @@ func (b bed) file(t *testing.T, p string) string {
}
func TestApplySetsTheModeRotatesTheJournalOnceAndRestoreGivesBackWhatWasFound(t *testing.T) {
// novox's file on 2026-10-14, read through node-reads.
// a real machine's file on 2026-10-14, read through node-reads.
b := newBed(t, "madvise", "[always] madvise never\n")
out, err := b.run(t, "apply")
if err != nil || !strings.Contains(out, "always -> madvise") {
@@ -0,0 +1,13 @@
# Module kernel, novox/hq ADR 0508 §9, issue 1841. After a kernel image is installed, upgraded or removed, the
# fallback kernel's boot entry (or the GRUB menu) is placed again. Written by the mesh; change the module.
[Trigger]
Operation = Install
Operation = Upgrade
Operation = Remove
Type = Path
Target = usr/lib/modules/*/vmlinuz
[Action]
Description = Placing the fallback kernel on the boot menu
When = PostTransaction
Exec = ${dir:state}/boot-entries run
+133
View File
@@ -0,0 +1,133 @@
#!/bin/sh
# The fallback kernel's place on this machine's boot menu (module kernel, novox/hq ADR 0508 §9 and §10,
# issue 1841). Written by the mesh and run as root by mesh-kernel-boot-entries.service at boot and when the
# module is applied, and by the pacman hook 90-mesh-kernel-boot-entries.hook after a kernel is installed,
# upgraded or removed. Replaced on every push; change the module, never this file.
#
# boot-entries run [<root> [<this module's state> [<room needed in /boot, KiB>]]]
#
# The three are arguments only so the module's tests can run this against a stand-in tree.
#
# It reads from the machine which loader booted it, never from a setting:
# systemd-boot the EFI variable LoaderEntrySelected is present: the entry mesh-linux-lts.conf is written
# from the command line the machine booted with.
# GRUB /boot/grub/grub.cfg exists and that variable does not: the menu is generated again, only
# when the set of kernels in /boot differs from the menu's.
# neither it fails loudly. The default entry is never touched.
# And a pacman hook of someone else's that copies kernel images to one fixed name in /boot is moved into this
# module's state first (ADR 0508 §10): it would put the fallback kernel in the default kernel's place.
set -eu
root=/
state='${dir:state}'
[ $# -ge 2 ] && root=$(printf '%s' "$2" | sed 's|/$||')
[ $# -ge 3 ] && state=$3
boot=$root/boot
hooks=$root/etc/pacman.d/hooks
efivar=$root/sys/firmware/efi/efivars/LoaderEntrySelected-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f
ours=90-mesh-kernel-boot-entries.hook
# The fallback kernel and its initramfs are about 72 MB in /boot (issue 1836): less room fails the unit
# before a package writes half of them.
need_kib=98304
[ $# -ge 4 ] && need_kib=$4
[ $# -ge 1 ] && [ "$1" = run ] || {
echo "usage: boot-entries run [<root> [<state directory> [<KiB needed>]]]" >&2
exit 2
}
# A hook that is not ours, triggered by kernel images, whose action copies to one fixed name in /boot.
copies_kernels() {
grep -Eqi '^[[:space:]]*Target[[:space:]]*=.*(vmlinuz|usr/lib/modules)' "$1" &&
grep -Eq '^[[:space:]]*Exec[[:space:]]*=.*(cp|install|ln|rsync)[[:space:]].*/boot/[A-Za-z0-9._+-]+([^A-Za-z0-9._+/%$*-]|$)' "$1"
}
if [ -d "$hooks" ]; then
for hook in "$hooks"/*.hook; do
[ -f "$hook" ] || continue
[ "$(basename "$hook")" = "$ours" ] && continue
copies_kernels "$hook" || continue
mkdir -p "$state/taken-over"
mv -f "$hook" "$state/taken-over/$(basename "$hook")"
echo "taken over: the pacman hook $(basename "$hook") copied kernel images to one fixed name in /boot; moved to $state/taken-over/ so each kernel keeps its own package's name"
done
fi
if [ -e "$efivar" ]; then
loader=systemd-boot
elif [ -f "$boot/grub/grub.cfg" ]; then
loader=grub
else
echo "no boot loader found: the machine was not booted by systemd-boot (no LoaderEntrySelected variable) and there is no $boot/grub/grub.cfg; the fallback kernel has no menu to be placed on" >&2
exit 1
fi
# Room first, only while the fallback kernel is not in /boot yet.
if [ ! -e "$boot/vmlinuz-linux-lts" ]; then
free=$(df -Pk "$boot" | awk 'NR==2 {print $4}')
[ -n "$free" ] || free=0
if [ "$free" -lt "$need_kib" ]; then
echo "$boot has $free KiB free and the fallback kernel needs $need_kib KiB: free room there before the kernel is installed" >&2
exit 1
fi
fi
case "$loader" in
systemd-boot)
entries=$boot/loader/entries
cmdline=$(sed -e 's/BOOT_IMAGE=[^ ]*//g' -e 's/initrd=[^ ]*//g' "$root/proc/cmdline" | tr -s ' \n' ' ' | sed -e 's/^ //' -e 's/ $//')
[ -n "$cmdline" ] || {
echo "$root/proc/cmdline holds no command line to boot the fallback kernel with" >&2
exit 1
}
want=$(
echo "title Arch Linux (linux-lts, fallback)"
echo "linux /vmlinuz-linux-lts"
for ucode in intel-ucode.img amd-ucode.img; do
[ -e "$boot/$ucode" ] && echo "initrd /$ucode"
done
echo "initrd /initramfs-linux-lts.img"
echo "options $cmdline"
)
mkdir -p "$entries"
if [ "$(cat "$entries/mesh-linux-lts.conf" 2>/dev/null || true)" = "$want" ]; then
echo "systemd-boot: mesh-linux-lts.conf, as it was"
else
printf '%s\n' "$want" >"$entries/mesh-linux-lts.conf"
echo "systemd-boot: wrote $entries/mesh-linux-lts.conf from the command line the machine booted with; the default entry is as it was"
fi
;;
grub)
cfg=$boot/grub/grub.cfg
menu=$(sed -n 's|^[[:space:]]*linux[[:space:]]*\(/boot\)\{0,1\}/\(vmlinuz-[^[:space:]]*\).*|\2|p' "$cfg" | sort -u)
have=$(cd "$boot" && ls -d vmlinuz-* 2>/dev/null | sort -u || true)
if [ "$menu" = "$have" ]; then
echo "grub: the menu lists the kernels in /boot, left as it is"
else
# GRUB lists kernels in reverse version order, so the fallback kernel would come first and become the
# default entry: the kernel the machine booted is put first (GRUB_TOP_LEVEL; /etc/default/grub still
# overrides it when it sets one), the fallback kernel never.
top=$(tr ' ' '\n' <"$root/proc/cmdline" | sed -n 's|^BOOT_IMAGE=\(.*\)$|\1|p' | head -n 1)
top=$(printf '%s' "$top" | sed 's|.*/||')
case "$top" in
vmlinuz-linux-lts | "") top= ;;
esac
[ -n "$top" ] && [ -e "$boot/$top" ] || top=
if [ -z "$top" ]; then
if [ -e "$boot/vmlinuz-linux" ]; then
top=vmlinuz-linux
else
top=$(printf '%s\n' $have | grep -v '^vmlinuz-linux-lts$' | head -n 1 || true)
fi
fi
if [ -n "$top" ]; then
GRUB_TOP_LEVEL=/boot/$top grub-mkconfig -o "$cfg"
else
grub-mkconfig -o "$cfg"
fi
first=$top
[ -n "$first" ] || first="the menu's own order"
echo "grub: the kernels in /boot changed ($(echo $have | tr '\n' ' ')); the menu is generated again with $first first; the default entry is as it was"
fi
;;
esac
@@ -0,0 +1,22 @@
[Unit]
Description=The fallback kernel's place on this machine's boot menu
# Module kernel, novox/hq ADR 0508 §9, issue 1841. Starting this unit reads which loader booted the machine and
# places the fallback kernel's boot entry (systemd-boot) or generates the menu again (GRUB); it fails, and says
# why, when there is no loader or the boot partition has no room. The kernel packages are declared after this
# unit, so the entry is in place before they are installed.
After=local-fs.target
Wants=local-fs.target
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=${dir:state}/boot-entries run
# It writes the boot partition, the pacman hook directory and its own state, and nothing else.
ProtectSystem=strict
ReadWritePaths=/boot ${dir:state} /etc/pacman.d/hooks
ProtectHome=yes
PrivateNetwork=yes
NoNewPrivileges=yes
[Install]
WantedBy=multi-user.target
+48 -1
View File
@@ -13,7 +13,8 @@
}
},
"tools": [
"kernel_huge_pages"
"kernel_huge_pages",
"kernel_boot"
],
"resources": [
{
@@ -53,6 +54,52 @@
"health": {
"kind": "unit"
}
},
{
"id": "boot-entries",
"type": "file",
"path": "${dir:state}/boot-entries",
"mode": "0755",
"trusted": true,
"template": "files/boot-entries.sh"
},
{
"id": "boot-unit",
"type": "file",
"path": "/etc/systemd/system/mesh-kernel-boot-entries.service",
"mode": "0644",
"template": "files/mesh-kernel-boot-entries.service"
},
{
"id": "boot-hook",
"type": "file",
"path": "/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook",
"mode": "0644",
"template": "files/90-mesh-kernel-boot-entries.hook"
},
{
"id": "boot-apply",
"type": "service",
"unit": "mesh-kernel-boot-entries.service",
"state": "running",
"boot": "enabled",
"restart-on": [
"boot-unit",
"boot-entries"
],
"health": {
"kind": "unit"
}
},
{
"id": "linux-lts",
"type": "package",
"package": "linux-lts"
},
{
"id": "linux-lts-headers",
"type": "package",
"package": "linux-lts-headers"
}
],
"build": {
+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

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