Compare commits

..
Author SHA1 Message Date
jschoubben 79777d0f78 gitea: keep the operator's own login on main's merge whitelist beside mesh-tools (ADR 0534 §2)
The operator merges by hand at the forge when the mesh's own path is stuck,
without changing a rule. The logins come from the trusted config value
main-mergers, merged into the runtime config both programs read.
2026-10-23 12:10:10 +02:00
jschoubben d2036f2a63 gitea: require no status on main, judge mesh/check in the merge tool instead (ADR 0534)
The forge's required status was a second copy of the gate mesh-delivery and
the merge tool already keep; a stuck status writer held it too (issues 1886,
1891) and only a hand change at the forge let a fix merge. Main keeps no push,
the tools' merge whitelist and no administrator override. The merge tool
judges mesh/check whether or not the branch requires it, and on a branch that
requires none merges only a head the operator's Approve names. The protection
tool loses its status argument.
2026-10-23 12:01:57 +02:00
jschoubben 6c0024e43b Merge pull request 'Make the catalogue's tests pass in the build sandbox, and give the integration level the installed Node toolchain read-only (issue 1891, ADR 0533)' (#459) from fix/logrotate-base-parses into main
Reviewed-on: #459
2026-10-23 09:48:22 +00:00
jschoubben a2e5a20f52 mesh-build-node: write the install's registry credential in the account's home, never beneath the opened cache, and refuse to open a toolchain holding one (issue 1891 review, ADR 0533 §2)
mesh/delivery failed: its walk failed, carrying its own commit: a gate on a first machine (what it carried put back), a build, a machine
mesh/check failed: integration: meshbuildnode/cmd/mesh-build-node.TestAFixtureBundleBuiltTwiceHasOneDigest: build_integration_test.go:71: no toolchain…
2026-10-23 11:45:20 +02:00
jschoubben 845479d7cd mesh-build-node: prove the npm download cache beside the toolchains stays its owner's (ADR 0533 §2) 2026-10-23 11:30:26 +02:00
jschoubben 2ebcb18097 realMachine: a change carrying a lock no release has installed runs with the toolchain installed last, and says so, so a lock change is not refused by its own check (issue 1891, ADR 0533 §3) 2026-10-23 11:23:11 +02:00
jschoubben 00e54d46d7 Give the integration level the node's installed Node toolchains read-only, so the Node build module's tests build offline (issue 1891, ADR 0533)
mesh-build-go binds /var/lib/mesh-build-node-cache/toolchain read-only into the
integration level's one shut-off process when it is there; the unit level never.
mesh-build-node opens its installed toolchains to other accounts (folders 0755,
files 0644, writable by its owner alone; its cache 0711, so the npm cache beside
them stays its own), and realMachine copies the toolchain of its own lock from
there instead of npm ci from the registry, which the level cannot reach.
2026-10-23 11:22:05 +02:00
jschoubben 933370df7c gitea, minio: compile the sources beside themselves in the test script, as the old check did (issue 1891)
The resolve hook needed the typescript package, which mesh-build-node keeps
out of the workspace (notCopied): tsc is reached only as the bin/tsc
command. So the test scripts now do what merge-check-typescript.sh did
before the tests: tsc over the module's sources with --outDir ., so the
sources' ./x.js imports resolve. The emitted .js is ignored by git.
2026-10-23 10:22:41 +02:00
jschoubben 437c524b81 gitea, minio, mosquitto: tests run as the Node build module runs them, tsc -p . then npm test (issue 1891)
The old TypeScript check compiled every source beside itself, and into
dist/, before it ran the tests; mesh-build-node runs `tsc -p .` (these
tsconfigs say noEmit) and then the package's test script, so:

- gitea and minio: the tests import the .ts sources, which import each
  other as ./x.js. A resolve hook in test/ resolves such an import, when no
  .js is there, to the .ts beside it and compiles it with the toolchain's
  TypeScript (gitea's sources use parameter properties, which type
  stripping refuses). 57 of gitea's tests and minio's links test failed.
- mosquitto: its ctrl test imports ../dist/client.js; its test script now
  builds first, as gitea's does.

Test scripts and a test helper only; what is built and delivered is the same.
2026-10-23 10:18:57 +02:00
jschoubben dc96d7c2dd gitea: take a rule's null contexts as the forge's type says, so the strict build the checker runs compiles (issue 1891)
The checker builds a package with a build script by its tsconfig (strict),
where requiredOf and TrunkForge's appliedProtection said
status_check_contexts is string[] and BranchProtection says string[] | null:
four TS2345 errors, hidden on main behind earlier failures. Types only; the
emitted code is the same, and a test holds that null contexts require nothing.
2026-10-23 10:00:09 +02:00
jschoubben f9247342d1 pacman, ssh-client: tests read nothing of the machine, so the unit level passes shut off and on a desk (issue 1891)
pacman-conf proved the shipped pacman.conf through its Include of
/etc/pacman.d/mirrorlist, which the shut-off unit level does not have: the
proven file now reads a mirror list of its own, and a guard test holds that
every repository of the shipped file includes the list reflector writes.

ssh-client's Test case for a run without an agent read the account's
SSH_AUTH_SOCK, so it failed on any desk with an agent: it now sets its own,
and also proves the environment's agent is the one reported.
2026-10-23 09:38:40 +02:00
jschoubben b1d875410a logrotate: dry-run the base against logs of its own, so the shut-off unit level does not stat the machine's /var/log (issue 1891)
The unit level runs in bubblewrap, where /var/log is not there; the base's
/var/log/wtmp block has no missingok, so logrotate -d failed its stat and
every mesh-catalog check that reached this test failed.
2026-10-23 09:10:09 +02:00
mesh-tools 89e15c7bcd Merge pull request 'R4e (mesh-catalog): build modules push and narrow, the gate stops judging, gitea writes mesh/check from the checker's owed state, and the census fixes' (#457) from feat/r4e into main 2026-10-22 22:12:51 +00:00
mesh-tools a21da33d48 Merge pull request 'Make buildah's runtime directory before every buildah run, so mesh-build-image's health does not fail for want of it (issue 1884)' (#456) from fix/1884-runtime-dir into main 2026-10-22 21:48:47 +00:00
jschoubben b53b0e7e2c Make buildah's runtime directory before every buildah run, so a machine without it is not unhealthy forever (issue 1884)
mesh/merge-gate head b53b0e7e2cc4 merged into trunk 48dd41db91a6 — pass: builds mesh-build-image → ace, shanks; no bus step; every machine composes with th…
mesh/repo-check head b53b0e7e2cc4 merged into trunk 48dd41db91a6 — 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: ✔ the same merge, head or close announced twice has the same id, and another has another (7.92618ms) | b5…
The look made <store>/xdg only after `buildah --version`, which refuses first
when its XDG_RUNTIME_DIR is missing: the directory was never made on ace.
2026-10-22 23:39:51 +02:00
mesh-tools 48dd41db91 Merge pull request 'Fill a module again when a node-engine returns after any failed fill, not only a missing socket (issue 1873)' (#455) from fix/1873-fill-path into main 2026-10-22 21:19:09 +00:00
jschoubben 16331cdfdc Fill a module again when the node-engine's socket returns after any failed fill, not only one for want of the socket (issue 1873)
mesh/merge-gate head 16331cdfdce6 merged into trunk 66c404fd39a5 — pass: builds mesh-provisioner-client → ace, g14, novox, shanks; no bus step; every machi…
mesh/repo-check head 16331cdfdce6 merged into trunk 66c404fd39a5 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/check failed: mesh-build-go: no go build node qualifies: builders disagree: go1.27.1-X:nodwarf5 recipe 74744709d277ded9 on g14; go1.27.3-X:nodwar…
On ace postgres's fill of superuser.secret failed at 22:12 with the engine's own
"is a directory", what a container runtime had left at the path. The engine
delivered at 22:42 clears such a directory, but the client tried again only
modules whose fill had failed for want of the socket (issue 1165), so postgres
was never filled again and its old failure was said every 30 s. A newer engine
may write what the older could not: any failed fill is now tried again when a
node-engine makes its socket again.
2026-10-22 22:56:16 +02:00
jschoubben ab3722d3b3 Test that a fill the node-engine failed is tried again when its socket returns (issue 1873) 2026-10-22 22:53:39 +02:00
mesh-tools 66c404fd39 Merge pull request 'Let a run read from a node's pacman log never hold back a new upgrade, so the mesh can upgrade nodes at all (issue 1841)' (#454) from fix/1841-stale-run into main 2026-10-22 20:14:07 +00:00
jschoubben 3ad9d42be9 Name the captured log's machine by its role, not its name (issue 1841)
mesh/merge-gate head 3ad9d42be923 merged into trunk 083515da1be7 — pass: builds pacman → ace, g14, novox, shanks; no bus step; every machine composes with …
mesh/repo-check head 3ad9d42be923 merged into trunk 083515da1be7 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
mesh/check failed: unit: i3/cmd/i3-tools.TestTheMainFileAndEveryModulesContributionLoadTogether: manifest_test.go:247: expected the launcher, clipboar…
2026-10-22 22:05:27 +02:00
mesh-tools 083515da1b Merge pull request 'Give /var/lib/systemd and /var/lib/postgres back to their packages and keep the two modules' own files under /var/lib/mesh (issue 1873, delivery A)' (#453) from fix/1873-system-state-dirs into main 2026-10-22 20:04:28 +00:00
jschoubben ac5d257a1c Let no run read from pacman's log, nor a run whose transaction ended, hold an asked upgrade back; read one run of the addressed machine whole (issue 1841)
Every run imported from the log ends at finished or failed and is never checked after, so the
one-run-at-a-time rule counted each as still going: novox's ask of 2026-10-22 22:00 was refused on
its install-time run 20220123T194900Z, and every node holds such runs. The rule now holds back a new
run only for a run the mesh ran whose transaction may still go on.

pacman_upgrades refused a run without its node, and the console takes node out of the arguments of
an address that names the machine, so no run could be read whole: a run without a node is now that
machine's.
2026-10-22 22:04:23 +02:00
jschoubben 896bd88380 Write mesh/check from the checker's owed state, read by a watch (R2-1)
mesh/repo-check head 896bd88380b3 merged into trunk a21da33d4857 beside mesh-controller#441@d51324df — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.s…
mesh/merge-gate head 896bd88380b3 merged into trunk a21da33d4857 — pass: group feat/r4e composed: every machine composes with the change as it did without …
mesh/delivery covered after its stop: a later walk that contains it was delivered: plan-1792707174627079192 (novox/mesh-catalog at 89e15c7b into main, do…
mesh/delivery-group group feat/r4e delivered: every member is delivered
mesh/check failed: ace: novox/mesh-catalog#457: modules/gitea/go.mod names mesh-sdk 104533e8c0c9, which is neither on mesh-sdk's trunk nor the group's…
R4e round 2 finding R2-1, ADR 0521: the delivery plan draws no edge that
orders the forge module before the forge bridge, and the forge module's
durable consumer keeps its deliver policy when its filters change, so a
check.status the bridge emitted before the forge module consumed it was lost.
An in-memory revision guard also forgot itself on a restart.

The forge module now reads mesh-delivery-checker.owed, which the checker
announces readable, by a watch: the watch hands over the latest value of every
key whenever the module starts, so a status written while it was away is
written then, and only the latest. Each mesh/check is written once per
revision, an older one dropped; a forge refusal is asked again on a bound
until written or overtaken; a refused read is said once and asked again until
granted. No tool writes mesh/check, and no check.status is consumed.
2026-10-22 21:45:53 +02:00
jschoubben 24f50c7c86 mesh-build-go: name go.mod among the narrowing test's paths, since mesh-sdk 104533e8 keeps only the manifest and the lock files beside them (review finding S1) 2026-10-22 21:39:55 +02:00
jschoubben e0bf87c2e3 Give /var/lib/systemd and /var/lib/postgres back to their packages, and keep the modules' files in their own directories (issue 1873)
mesh/repo-check head e0bf87c2e3da merged into trunk ef1e98f8678c — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/merge-gate head e0bf87c2e3da merged into trunk ef1e98f8678c — pass: group fix/1873-system-state-dirs composed: every machine composes with the change …
mesh/delivery delivered
mesh/delivery-group group fix/1873-system-state-dirs delivered: every member is delivered
mesh/check failed: unit: i3/cmd/i3-tools.TestTheMainFileAndEveryModulesContributionLoadTogether: manifest_test.go:247: expected the launcher, clipboar…
The systemd and postgres modules' state directories said place ".", which is
/var/lib/systemd and /var/lib/postgres: directories the systemd and postgresql
packages own (root 0755; postgres 0700). The node-engine took both before issue
339's rule and gave them to the operator's account, 0700, on every machine; postgres's
grants directory, placed by default, lay inside /var/lib/postgres too.

What each module keeps moves to a directory of its own, "data", placed "mesh"
(/var/lib/mesh/systemd, /var/lib/mesh/postgres): the unlink record, and postgres's
own secrets (the same values: the vault keys them by name), consumers.json and
grants. The store's data stays /var/lib/mesh-store, untouched.

The node-engine keeps no found owner or mode for a directory, and forgets one no
longer declared as it stands; it sets the declared owner and mode on a directory it
recorded at the same path under the same id. So for this delivery "state" names
the package's directory with the package's owner and mode, and the engine gives it
back; the next change drops "state". The postgres container is recreated, its
secret's volume moving.

checks/owned/system-known keeps systemd state and postgres state, declared for
this delivery only; postgres's consumers and grants leave the list.
2026-10-22 21:31:58 +02:00
mesh-tools ef1e98f867 Merge pull request 'Keep the pacman keeper's state in a directory of its own, not in the package database's /var/lib/pacman (issue 1841)' (#452) from fix/1841-keeper-state-dir into main 2026-10-22 19:31:24 +00:00
jschoubben e3db481491 Pin every R4e member module to mesh-sdk's final R4e head 104533e8, which holds review fixes S1 and S2, so the group pins one SDK 2026-10-22 21:29:19 +02:00
jschoubben 21b4925892 Merge remote-tracking branch 'origin/fix/r4e-k1' into r4e-int2 2026-10-22 21:28:24 +02:00
jschoubben 098c6d3ec7 Place pacman's state outside the package manager's own directory, and refuse a module directory in a system package's (issue 1841)
mesh/merge-gate head 098c6d3ec70f merged into trunk 2f05e70734cd — pass: builds pacman → ace, g14, novox, shanks; no bus step; every machine composes with …
mesh/repo-check head 098c6d3ec70f merged into trunk 2f05e70734cd — pass: THE CHANGE ALTERS ITS OWN CHECK (merge-check.sh): main's version judged it; the ch…
mesh/delivery delivered
mesh/check failed: unit: clipmenu/cmd/clipmenu-tools.TestTheLauncherWaitsQuietlyWhileAnotherClipmenudHoldsTheLock: manifest_test.go:199: the launcher …
pacman's state directory said place ".", which resolves to /var/lib/pacman: the
package manager's database directory. The node-engine used it as found and
reported it unhealthy on every machine, and wrote keeper.json into it. It is now
the mesh's directory for the module (place "mesh", /var/lib/mesh/pacman), as
docker's is; keeper.json, the keeper's bus secret and the root helper's socket
follow through ${dir:state}.

checks/owned rule 5 refuses a directory that is, lies in or holds a /var/lib
directory a system package owns (checks/owned/system, read from shanks through
pacman_owns and tmpfiles.d), and any resource inside one. systemd and postgres
collide today and are listed in checks/owned/system-known under issue 1873; that
list only shrinks.
2026-10-22 21:19:15 +02:00
jschoubben 8d3f81c2c0 Write mesh/check from the forge bridge's own event, never from a tool (K1)
Review finding K1 of R4e, issue 1301 S4 (ADR 0503 §3, ADR 0513 §4: the forge
requires mesh/check alone). gitea_check_status assumed only the bridge could
call it, but a tool is told no caller and node-tools invokes every tool, so any
session could set mesh/check to success and merge past the checker: the fault
issue 293 closed for the two statuses before it.

gitea now consumes mesh-delivery-forge-bridge.check.status, a subject the bus
grants to the bridge's own account alone, and check.ts writes mesh/check from
it: the source is checked, the head's whole sha and the state, and a revision
older than one already written on the head is dropped so a redelivered stale
pending never hides a verdict; a forge refusal throws, so the event is
delivered again. gitea_check_status is removed, and gitea_commit_status keeps
refusing mesh/check.
2026-10-22 21:14:01 +02:00
75 changed files with 2391 additions and 498 deletions
+181 -13
View File
@@ -11,13 +11,23 @@
// another module, after ${dir:<id>} is resolved within each module and both homes are read as one root.
// Archives are not containers for this rule. This holds for ~/.ssh what checks/sshdir held (ADR 0415 §6).
//
// go run . <catalogue root>
// 5. a directory that is, lies inside or holds a directory a system package owns under /var/lib, or any other
// resource inside one (issue 1841: pacman's state directory, placed with "place": ".", was /var/lib/pacman, the
// package manager's own database directory). A placed directory is resolved as the mesh resolves it with the
// default data root: "." is /var/lib/<module>, "mesh" /var/lib/mesh/<module>, neither /var/lib/<module>/<id>.
// The system directories are checks/owned/system, captured from a machine (pacman -Qo, /usr/lib/tmpfiles.d);
// checks/owned/system-known names the exceptions still to fix, by module, resource and issue. That list only
// shrinks: an entry that no longer collides is refused, and with -base one the trunk's copy does not hold.
//
// go run . [-base <trunk's system-known>] <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"path"
@@ -27,6 +37,12 @@ import (
"strings"
)
// The lists of rule 5, relative to the catalogue's root.
const (
SystemList = "checks/owned/system"
SystemKnown = "checks/owned/system-known"
)
// Home is the one root both machine homes are read as.
const Home = "${home}"
@@ -43,9 +59,9 @@ type Finding struct {
}
type resource struct {
ID, Type, Path, Unknown string
Knows []string
HasKnows bool
ID, Type, Path, Unknown, Place string
Knows []string
HasKnows bool
}
type parsed struct {
@@ -65,6 +81,7 @@ func load(file string) (*parsed, error) {
ID string `json:"id"`
Type string `json:"type"`
Path string `json:"path"`
Place string `json:"place"`
Unknown *string `json:"unknown"`
Knows []string `json:"knows"`
} `json:"resources"`
@@ -81,7 +98,7 @@ func load(file string) (*parsed, error) {
out.Module = filepath.Base(filepath.Dir(file))
}
for _, r := range m.Resources {
x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Knows: r.Knows, HasKnows: r.Knows != nil}
x := resource{ID: r.ID, Type: r.Type, Path: r.Path, Place: r.Place, Knows: r.Knows, HasKnows: r.Knows != nil}
if r.Unknown != nil {
x.Unknown = *r.Unknown
if x.Unknown == "" {
@@ -143,14 +160,23 @@ type declared struct {
}
// Check reads every manifest under root/modules and answers what it refuses.
func Check(root string) ([]Finding, error) {
func Check(root string, base []string) ([]Finding, error) {
files, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(files)
system, err := readSystem(filepath.Join(root, SystemList))
if err != nil {
return nil, err
}
known, err := readLines(filepath.Join(root, SystemKnown))
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
var out []Finding
var paths []declared
collided := map[string]bool{}
type container struct{ module, path string }
var containers []container
for _, file := range files {
@@ -170,6 +196,7 @@ func Check(root string) ([]Finding, error) {
dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID
}
}
out = append(out, inSystem(m, system, known, collided)...)
for _, r := range m.Resources {
p := norm(r.Path, dirs)
if p != "" {
@@ -212,6 +239,28 @@ func Check(root string) ([]Finding, error) {
}
}
}
for _, k := range known {
if !collided[k] {
module, id, _ := strings.Cut(k, " ")
id, _, _ = strings.Cut(id, " ")
out = append(out, Finding{5, module, id, fmt.Sprintf("is listed in %s, and no longer lies in a system "+
"package's directory: take the line out, so the list only shrinks", SystemKnown)})
}
}
if base != nil {
held := map[string]bool{}
for _, b := range base {
held[b] = true
}
for _, k := range known {
if !held[k] {
module, id, _ := strings.Cut(k, " ")
id, _, _ = strings.Cut(id, " ")
out = append(out, Finding{5, module, id, fmt.Sprintf("is a new line in %s, which the trunk's copy "+
"does not hold: a module's directory is placed outside a system package's, never listed", SystemKnown)})
}
}
}
for _, d := range paths {
for _, c := range containers {
if c.module != d.module && (d.path == c.path || strings.HasPrefix(d.path, c.path+"/")) {
@@ -222,21 +271,140 @@ func Check(root string) ([]Finding, error) {
return out, nil
}
func main() {
root := "."
if len(os.Args) > 1 {
root = os.Args[1]
// systemDir is one directory a system package owns, and what says so.
type systemDir struct{ Path, Source string }
// readLines is a list's lines, without comments and blank lines.
func readLines(file string) ([]string, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
found, err := Check(root)
var lines []string
for _, line := range strings.Split(string(raw), "\n") {
if line = strings.TrimSpace(line); line != "" && !strings.HasPrefix(line, "#") {
lines = append(lines, line)
}
}
return lines, nil
}
// readSystem is the list of system directories: "<absolute path> <package>: <how it was read>" per line. Absent or
// empty, the check fails: a list that says nothing would let every module through.
func readSystem(file string) ([]systemDir, error) {
lines, err := readLines(file)
if err != nil {
return nil, fmt.Errorf("rule 5 reads the system packages' directories from %s: %w", file, err)
}
if len(lines) == 0 {
return nil, fmt.Errorf("%s names no system package's directory, so rule 5 would pass everything", file)
}
var out []systemDir
for _, line := range lines {
p, source, _ := strings.Cut(line, " ")
if !strings.HasPrefix(p, "/var/lib/") || strings.TrimSpace(source) == "" {
return nil, fmt.Errorf("%s: %q is not \"/var/lib/<directory> <package>: <how it was read>\"", file, line)
}
out = append(out, systemDir{path.Clean(p), strings.TrimSpace(source)})
}
return out, nil
}
// placedAt is where the mesh places a pathless directory with the default data root (mesh-sdk catalogue dirsFor).
func placedAt(module string, r resource) string {
switch r.Place {
case ".":
return "/var/lib/" + module
case "mesh":
return "/var/lib/mesh/" + module
}
return "/var/lib/" + module + "/" + r.ID
}
// inSystem is rule 5 for one module: each resource whose resolved path collides with a system package's directory,
// unless system-known lists it (then it is marked collided, so a stale line is found).
func inSystem(m *parsed, system []systemDir, known []string, collided map[string]bool) []Finding {
real := map[string]string{}
for _, r := range m.Resources {
if r.Type == "directory" && r.Path == "" && r.ID != "" {
real[r.ID] = placedAt(m.Module, r)
}
}
for _, r := range m.Resources {
if r.ID != "" && r.Path != "" {
real[r.ID] = r.Path
}
}
var out []Finding
for _, r := range m.Resources {
p := ""
if r.Path != "" {
p = norm(r.Path, real)
} else if r.Type == "directory" {
p = real[r.ID]
}
if !strings.HasPrefix(p, "/") {
continue
}
for _, s := range system {
inside := p == s.Path || strings.HasPrefix(p, s.Path+"/")
holds := r.Type == "directory" && strings.HasPrefix(s.Path, p+"/")
if !inside && !holds {
continue
}
listed := ""
for _, k := range known {
if strings.HasPrefix(k, m.Module+" "+r.ID+" ") || k == m.Module+" "+r.ID {
listed = k
}
}
if listed != "" {
collided[listed] = true
break
}
how := "is or lies inside"
if holds {
how = "holds"
}
out = append(out, Finding{5, m.Module, r.ID, fmt.Sprintf("resolves to %q, which %s %s, the directory of the "+
"system package %s; place it under /var/lib/mesh/%s (\"place\": \"mesh\") or a path of its own", p, how,
s.Path, s.Source, m.Module)})
break
}
}
return out
}
func main() {
baseFile := flag.String("base", "", "the trunk's copy of "+SystemKnown+": a line it does not hold is refused")
flag.Parse()
root := "."
if flag.NArg() > 0 {
root = flag.Arg(0)
}
var base []string
if *baseFile != "" {
lines, err := readLines(*baseFile)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
base = append([]string{}, lines...)
}
found, err := Check(root, base)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
for _, f := range found {
fmt.Printf("%s: %s: %s (rule %d, novox/hq ADR 0422 §8, issue 1024)\n", f.Module, f.ID, f.Why, f.Rule)
cite := "novox/hq ADR 0422 §8, issue 1024"
if f.Rule == 5 {
cite = "issue 1841"
}
fmt.Printf("%s: %s: %s (rule %d, %s)\n", f.Module, f.ID, f.Why, f.Rule, cite)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory")
fmt.Println("owned paths: every directory and archive says how it treats unknown files; no module declares inside another's directory, or in a system package's")
}
+102 -1
View File
@@ -20,7 +20,10 @@ func manifest(t *testing.T, root, module, body string) {
func check(t *testing.T, root string) []Finding {
t.Helper()
found, err := Check(root)
if _, err := os.Stat(filepath.Join(root, SystemList)); os.IsNotExist(err) {
lists(t, root, "/var/lib/pacman pacman: pacman -Qo\n", "")
}
found, err := Check(root, nil)
if err != nil {
t.Fatal(err)
}
@@ -35,6 +38,20 @@ func only(t *testing.T, found []Finding, rule int, module, id string) {
}
}
// lists writes the catalogue's list of system directories and its known exceptions.
func lists(t *testing.T, root, system, known string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, "checks", "owned"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, SystemList), []byte(system), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, SystemKnown), []byte(known), 0o644); err != nil {
t.Fatal(err)
}
}
func dir(id, path, rest string) string {
return `{"id":"` + id + `","type":"directory","path":"` + path + `"` + rest + `}`
}
@@ -145,3 +162,87 @@ func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) {
t.Fatalf("refused %+v", found)
}
}
// 1841/AC18: a module's directory that resolves to a /var/lib directory a system package owns, lies inside one or
// holds one, is refused, whether placed (place ".", or by default beneath the module's own) or stated; a placed
// directory saying place "mesh" lies under /var/lib/mesh and passes (issue 1841: pacman's state was /var/lib/pacman).
func TestADirectoryOfASystemPackageIsRefused(t *testing.T) {
system := "/var/lib/pacman pacman: pacman -Qo\n/var/lib/systemd/coredump systemd: tmpfiles.d/systemd.conf\n"
for name, resources := range map[string][]string{
"placed-own": {`{"id":"state","type":"directory","place":".","unknown":"program"}`},
"placed-beneath": {`{"id":"coredump","type":"directory","unknown":"program"}`},
"stated-inside": {dir("d", "/var/lib/pacman/keeper", `,"unknown":"program"`)},
"stated-holding": {dir("d", "/var/lib/systemd", `,"unknown":"program"`)},
"file-inside": {`{"id":"state","type":"directory","place":"mesh","unknown":"program"}`,
`{"id":"f","type":"file","path":"/var/lib/pacman/keeper.json"}`},
} {
module := map[string]string{"placed-own": "pacman", "placed-beneath": "systemd"}[name]
if module == "" {
module = "keeper"
}
root := t.TempDir()
lists(t, root, system, "")
manifest(t, root, module, mod(module, resources...))
found := check(t, root)
if len(found) != 1 || found[0].Rule != 5 || found[0].Module != module ||
!strings.Contains(found[0].Why, "/var/lib/") || !strings.Contains(found[0].Why, "package") {
t.Errorf("%s: found %+v; want one finding of rule 5 for %s naming the path and the package", name, found, module)
}
}
root := t.TempDir()
lists(t, root, system, "")
manifest(t, root, "pacman", mod("pacman",
`{"id":"state","type":"directory","place":"mesh","unknown":"program"}`,
`{"id":"f","type":"file","path":"${dir:state}/keeper.json"}`,
dir("other", "/var/lib/pacman-other", `,"unknown":"program"`)))
if found := check(t, root); len(found) != 0 {
t.Fatalf("a directory under /var/lib/mesh/pacman was refused: %+v", found)
}
}
// 1841/AC18: a known exception passes; one that no longer collides is refused, so the list only shrinks; one the trunk's
// copy does not hold is refused; and an absent list of system directories fails the check rather than passing it.
func TestKnownSystemExceptionsOnlyShrink(t *testing.T) {
system := "/var/lib/systemd systemd: tmpfiles.d/systemd.conf\n"
known := "# comment\nsystemd state issue 1873\n"
root := t.TempDir()
lists(t, root, system, known)
manifest(t, root, "systemd", mod("systemd", `{"id":"state","type":"directory","place":".","unknown":"program"}`))
if found := check(t, root); len(found) != 0 {
t.Fatalf("a known exception was refused: %+v", found)
}
if found, err := Check(root, []string{"systemd state issue 1873"}); err != nil || len(found) != 0 {
t.Fatalf("a known exception held by the trunk was refused: %+v %v", found, err)
}
if found, err := Check(root, []string{}); err != nil || len(found) != 1 || found[0].Rule != 5 {
t.Fatalf("an exception the trunk does not hold passed: %+v %v", found, err)
}
manifest(t, root, "systemd", mod("systemd", `{"id":"state","type":"directory","place":"mesh","unknown":"program"}`))
only(t, check(t, root), 5, "systemd", "state")
root = t.TempDir()
manifest(t, root, "a", mod("a"))
if err := os.MkdirAll(filepath.Join(root, "checks", "owned"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, SystemList), []byte("# nothing\n"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Check(root, nil); err == nil {
t.Fatal("an empty list of system directories passed")
}
}
// 1841/AC18: the catalogue as it stands passes rule 5 with its own lists.
func TestTheCatalogueKeepsOutOfSystemDirectories(t *testing.T) {
found, err := Check("../..", nil)
if err != nil {
t.Fatal(err)
}
for _, f := range found {
if f.Rule == 5 {
t.Errorf("%s: %s: %s", f.Module, f.ID, f.Why)
}
}
}
+23
View File
@@ -0,0 +1,23 @@
# The directories under /var/lib that a system package owns (rule 5 of checks/owned, issue 1841). A module's
# directory, placed or stated, may not be one of these, lie inside one or hold one: the package's program and the
# package manager keep their own files there, and the node-engine would judge, chmod or chown the directory.
#
# One line per directory: "<path> <package>: <how it was read>". Captured on the workstation on 2026-10-22, through
# the mesh: pacman's own database by `pacman -Qo` (pacman_owns), the rest from the `d`, `D` and `Q` lines of
# /usr/lib/tmpfiles.d (node-reads.search for "/var/lib/"). A directory found to belong to a package is added here.
/var/lib/pacman pacman: pacman -Qo /var/lib/pacman/ (pacman 7.1.0)
/var/lib/alsa alsa-utils: tmpfiles.d/alsa-utils.conf
/var/lib/colord colord: tmpfiles.d/colord.conf
/var/lib/containers podman: tmpfiles.d/podman.conf
/var/lib/dbus dbus: tmpfiles.d/dbus.conf
/var/lib/geoclue geoclue: tmpfiles.d/geoclue.conf
/var/lib/lastlog util-linux: tmpfiles.d/lastlog2.conf
/var/lib/libuuid util-linux: tmpfiles.d/uuidd.conf
/var/lib/machines systemd: tmpfiles.d/systemd-nspawn.conf
/var/lib/openvpn/chroot networkmanager-openvpn: tmpfiles.d/nm-openvpn-tmpfiles.conf
/var/lib/portables systemd: tmpfiles.d/portables.conf
/var/lib/postgres postgresql: tmpfiles.d/postgresql.conf
/var/lib/private systemd: tmpfiles.d/systemd.conf
/var/lib/rpcbind rpcbind: tmpfiles.d/rpcbind.conf
/var/lib/systemd systemd: tmpfiles.d/systemd.conf
/var/lib/tpm2-tss/system/keystore tpm2-tss: tmpfiles.d/tpm2-tss-fapi.conf
+5
View File
@@ -0,0 +1,5 @@
# Modules whose directory lies in a system package's directory today (rule 5 of checks/owned, issue 1841), each with
# the issue that moves it out: "<module> <resource id> issue <number>". This list only shrinks: a line whose module
# no longer collides is refused, and so is a line the trunk's copy does not hold.
postgres state issue 1873
systemd state issue 1873
+15 -3
View File
@@ -48,7 +48,8 @@
# 10. the mail-store seat (novox/hq ADR 0378 §3, issue 800): no manifest but modules/mesh-mail claims it.
# 11. owned paths (novox/hq ADR 0422 §8, issue 1024; it holds ~/.ssh for ssh-client as ADR 0415 §6 asked):
# 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.
# only under a home; no module declares a path inside a directory of another module; and none is, lies in or
# holds a directory a system package owns under /var/lib (checks/owned/system, issue 1841).
# 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.
@@ -73,8 +74,19 @@ set -eu
# ~/.ssh (novox/hq ADR 0415 §6): only ssh-client may name a path under it. Main's merge check runs this line, so
# checks/sshdir stays until main runs checks/owned; it goes under issue 1035.
(cd checks/sshdir && go test -count=1 . && go run . ../..)
# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory.
(cd checks/owned && go test -count=1 . && go run . ../..)
# Owned paths (novox/hq ADR 0422 §8): unknown files named per directory; no path inside another module's directory;
# none in a system package's directory under /var/lib (issue 1841), the known exceptions only shrinking: judged
# against the trunk's copy of checks/owned/system-known at the merge base; a trunk without one is the list's first copy.
owned_base=""
if merge_base=$(git merge-base HEAD "origin/${MESH_CHECK_BASE:-main}" 2>/dev/null); then
if git cat-file -e "$merge_base:checks/owned/system-known" 2>/dev/null; then
owned_base=$(mktemp)
git show "$merge_base:checks/owned/system-known" >"$owned_base"
fi
else
echo "NOT COMPARED WITH THE TRUNK: no merge base with origin/${MESH_CHECK_BASE:-main}; checks/owned/system-known is held to the list it was first written with"
fi
(cd checks/owned && go test -count=1 . && if [ -n "$owned_base" ]; then go run . -base "$owned_base" ../..; else go run . ../..; fi)
# The secrets (novox/hq issue 342): a password is not a setting, so no setting can replace the vault's. The
# known list only shrinks: judged against the trunk's copy at the merge base, a line it does not hold is refused.
secrets_base=""
+2
View File
@@ -0,0 +1,2 @@
# The test script compiles the sources beside themselves before the tests import them (package.json, issue 1891).
*.js
+188
View File
@@ -0,0 +1,188 @@
// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1
// before it; novox/hq issue 1301; ADR 0447 §1, ADR 0503 §3: main requires mesh/check alone).
//
// No tool writes mesh/check: a tool is told no caller, and the console every agent session speaks through (node-tools)
// invokes every tool, so a tool that wrote it could be called by any session to merge past the checker (the fault issue
// 293 closed for the two statuses before it). Nor does an event: nothing in the delivery plan orders this module before the
// forge bridge, and a durable consumer whose filters change keeps its deliver policy, so an event said before this module
// consumed it was lost. The checker keeps what it owes the forge in its state `owed` (ADR 0484 §2), which it announces
// readable (ADR 0495 §2-§3), and only the checker writes it. This module watches it: the watch hands over the latest value of
// every key when it starts, whenever that is, and then every change. Each key whose act is the status mesh/check is
// written on its head, once per revision; a revision older than one already written on the key is dropped.
//
// A read refused (the grant not yet composed) is said once and asked again on a bound until it holds. A forge that refuses
// a status is asked again on a bound, until it writes or a newer revision of the key comes.
import type { CommitStatus } from "./client.js";
import { bindUntilHeld, bindWait } from "./bind.js";
import { checkName } from "./guard.js";
import { CHECK_STATUS } from "./pulls.js";
/** The checker's state this module reads, and the act of its keys this module writes. */
export const CHECKER_OWED = "mesh-delivery-checker.owed";
export const CHECK_ACT = `status=${CHECK_STATUS}`;
const states = new Set(["pending", "success", "error", "failure", "warning"]);
/** What writing the check needs of the forge. */
export interface CheckForge {
setCommitStatus(owner: string, repo: string, sha: string, status: CommitStatus): Promise<void>;
}
/** One change of the checker's owed, as the SDK's state watch hands it. */
export interface OwedChange {
key: string;
op: "put" | "delete";
value?: unknown;
revision: number;
current?: boolean;
}
/** A key part as mesh-sdk go/delivery EscapeName writes it, read back: `=` and two lowercase hex digits per byte. */
function unescapeName(part: string): string {
if (!/^([-/_a-zA-Z0-9]|=[0-9a-f]{2})+$/.test(part)) throw new Error(`${JSON.stringify(part)} is not an escaped name`);
return Buffer.from(part.replace(/=([0-9a-f]{2})/g, (_, h) => String.fromCharCode(parseInt(h, 16))), "latin1").toString("utf8");
}
/** The pull request and head of the checker's owed key (mesh-sdk go/delivery CheckerOwedKey):
* `<owner>/<name>.<number>.<head>.status=mesh/check`, owner and name escaped. */
export function owedKeyOf(key: string): { owner: string; repo: string; number: number; sha: string } {
const parts = String(key ?? "").split(".");
if (parts.length !== 4) throw new Error(`${JSON.stringify(key)} is not an owed key, <repository>.<number>.<head>.<act>`);
const [repository, number, sha, act] = parts;
if (act !== CHECK_ACT) throw new Error(`${JSON.stringify(key)} owes ${JSON.stringify(act)}, not ${CHECK_ACT}`);
const m = /^([^/]+)\/([^/]+)$/.exec(repository);
if (!m) throw new Error(`${JSON.stringify(key)} names no <owner>/<name>`);
if (!/^(0|[1-9][0-9]*)$/.test(number)) throw new Error(`${JSON.stringify(key)} names no pull request number`);
if (!/^([0-9a-f]{40}|[0-9a-f]{64})$/i.test(sha)) throw new Error(`${JSON.stringify(key)} names no head's whole sha`);
return { owner: checkName("owner", unescapeName(m[1])), repo: checkName("name", unescapeName(m[2])), number: Number(number), sha };
}
/** The status an owed change asks for, checked as mesh-sdk go/delivery DecodeCheckerOwed checks it; or why it is not one. */
export function checkStatusOf(change: OwedChange): { owner: string; repo: string; sha: string; revision: number; status: CommitStatus } {
if (change.op !== "put") throw new Error(`a ${change.op} owes nothing`);
const { owner, repo, sha } = owedKeyOf(change.key);
const revision = change.revision;
if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 0) {
throw new Error(`${JSON.stringify(revision)} is not a revision`);
}
const v = change.value;
if (!v || typeof v !== "object") throw new Error("the value is no owed act");
const o = v as Record<string, unknown>;
if (o.v !== 1) throw new Error(`the owed act's version ${JSON.stringify(o.v)} is not one this module reads`);
if (o.act !== "status" || o.context !== CHECK_STATUS) {
throw new Error(`the owed act is ${JSON.stringify(o.act)} ${JSON.stringify(o.context)}, not the status ${CHECK_STATUS}`);
}
const state = String(o.state ?? "");
if (!states.has(state)) throw new Error(`${JSON.stringify(o.state)} is not a status the forge keeps`);
let description = String(o.text ?? "").replace(/\s+/g, " ").trim();
if (description.length > 140) description = description.slice(0, 139) + "…";
const target = typeof o.target === "string" && /^https?:\/\//.test(o.target) ? o.target : undefined;
const status: CommitStatus = { state: state as CommitStatus["state"], context: CHECK_STATUS, description,
...(target ? { target_url: target } : {}) };
return { owner, repo, sha, revision, status };
}
/** The one writer of mesh/check: each owed revision of the checker's, in revision order per key. */
export class CheckStatuses {
/** The newest revision written of each key, and the newest seen; the oldest keys go past what any forge holds open. */
private written = new Map<string, number>();
private seen = new Map<string, number>();
/** The turn each key's writes wait on, and the asks again still running. */
private turns = new Map<string, Promise<unknown>>();
private again = new Set<Promise<void>>();
private forge: CheckForge;
private say: (line: string) => void;
private sleep: (ms: number) => Promise<void>;
constructor(forge: CheckForge, say: (line: string) => void = (l) => console.error(l),
sleep: (ms: number) => Promise<void> = (ms) => new Promise((r) => setTimeout(r, ms))) {
this.forge = forge;
this.say = say;
this.sleep = sleep;
}
/** Write the change's status: "written", or "dropped" for one that owes no mesh/check or is older than one written. A
* forge that refuses throws. */
async apply(change: OwedChange): Promise<"written" | "dropped"> {
let asked;
try {
asked = checkStatusOf(change);
} catch (err) {
this.say(`[gitea] ${CHECKER_OWED} ${change?.key} at revision ${change?.revision} writes nothing: ${err instanceof Error ? err.message : err}`);
return "dropped";
}
const key = change.key;
const seen = this.seen.get(key);
if (seen === undefined || asked.revision > seen) this.remember(this.seen, key, asked.revision);
// One key's writes take turns, so two at once are weighed one after the other.
const before = this.turns.get(key) ?? Promise.resolve();
const turn = before.then(async (): Promise<"written" | "dropped"> => {
const newest = this.written.get(key);
if (newest !== undefined && asked.revision < newest) {
this.say(`[gitea] ${asked.owner}/${asked.repo}@${asked.sha.slice(0, 12)}: mesh/check at revision ${asked.revision} is older than ${newest}, already written, and is dropped`);
return "dropped";
}
await this.forge.setCommitStatus(asked.owner, asked.repo, asked.sha, asked.status);
this.remember(this.written, key, asked.revision);
return "written";
});
const settled = turn.catch(() => {});
this.turns.set(key, settled);
void settled.then(() => { if (this.turns.get(key) === settled) this.turns.delete(key); });
return turn;
}
/** What the watch hands over: written, or when the forge refuses, asked again on a bound in the background until it is
* written or a newer revision of the key was seen. Never throws: a watch's handler that throws loses the change. */
async take(change: OwedChange): Promise<void> {
try {
await this.apply(change);
} catch (err) {
const run = this.askAgain(change, err);
this.again.add(run);
void run.finally(() => this.again.delete(run));
}
}
/** Resolves when no ask again is running. */
async settled(): Promise<void> {
while (this.again.size) await Promise.all([...this.again]);
}
private async askAgain(change: OwedChange, first: unknown): Promise<void> {
let err = first;
for (let attempt = 1; ; attempt++) {
const wait = bindWait(attempt);
this.say(`[gitea] ${change.key}: mesh/check at revision ${change.revision} could not be written (${err instanceof Error ? err.message : err}); asked again in ${wait / 1000}s`);
await this.sleep(wait);
const newest = this.seen.get(change.key);
if (newest !== undefined && newest > change.revision) return;
try {
await this.apply(change);
return;
} catch (e) {
err = e;
}
}
}
private remember(map: Map<string, number>, key: string, revision: number): void {
map.delete(key);
map.set(key, revision);
if (map.size > 4000) map.delete(map.keys().next().value as string);
}
}
/**
* Watch the checker's owed with `watch` (the SDK's `state(CHECKER_OWED).watch`), handing each change to `checks`. A watch
* refused — the read not granted yet — is said once and asked again on a bound until it holds (bind.ts). Never rejects.
*/
export async function watchOwed(watch: (handler: (change: OwedChange) => Promise<void>) => Promise<void>, checks: CheckStatuses,
sleep?: (ms: number) => Promise<void>, say: (line: string) => void = (l) => console.error(l)): Promise<void> {
let refusals = 0;
await bindUntilHeld(CHECKER_OWED, () => watch((change) => checks.take(change)), sleep, (line) => {
if (/could not be bound/.test(line) && refusals++ > 0) return;
say(line);
});
}
+10 -10
View File
@@ -699,9 +699,9 @@ export class GiteaClient {
};
}
/** Make a branch require these commit statuses to merge (novox/hq ADR 0237): the rule named for the
* branch is edited — its required statuses replaced by these, everything else it says kept unless
* asked — or created, refusing direct pushes unless asked otherwise. Answers the rule as it is now. */
/** Protect a branch (novox/hq ADR 0237): the rule named for the branch is edited — its required statuses taken
* off (ADR 0534 §5), everything else it says kept unless asked — or created, refusing direct pushes unless asked
* otherwise. Answers the rule as it is now. */
async setBranchProtection(owner: string, repo: string, branch: string, want: ProtectionWanted): Promise<{ created: boolean; rule: BranchProtection }> {
const had = await this.branchProtection(owner, repo, branch);
const body = protectionBody(want, !had, branch);
@@ -932,9 +932,9 @@ export class GiteaAdmin {
return Array.isArray(res.body) ? res.body : [];
}
/** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, and
* only the tools' account merges. The rule there is edited, its statuses left as they are; a repository with no
* rule for main gets one that requires none. Answers the repositories it touched. With the administrator's login:
/** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, only
* the tools' account merges, and no status required (ADR 0534 §1). The rule there is edited; a repository with no
* rule for main gets one. Answers the repositories it touched. With the administrator's login:
* the tools' account is no repository's administrator, so no tool can do this (ADR 0365 §2). */
async ensureMainMerges(org: string): Promise<string[]> {
const repos: string[] = [];
@@ -954,11 +954,11 @@ export class GiteaAdmin {
if (patch.status !== 200) GiteaAdmin.fail(rule, patch);
continue;
}
// A rule named exactly main is matched before any glob rule, so it would hide the statuses, approvals and
// administrator limit a glob rule covering main asks: they are copied into it (issue 948, review round 1).
// A rule named exactly main is matched before any glob rule, so it would hide the approvals and administrator
// limit a glob rule covering main asks: they are copied into it (issue 948, review round 1). Its statuses are
// not: main requires none (ADR 0534 §1).
const covering = rules.filter((r) => ruleCovers(String(r.rule_name ?? r.branch_name ?? ""), MAIN_BRANCH));
const checks = [...new Set(covering.filter((r) => r.enable_status_check).flatMap((r) => (r.status_check_contexts ?? []).filter(Boolean)))];
const copied: Record<string, unknown> = { enable_status_check: checks.length > 0, status_check_contexts: checks };
const copied: Record<string, unknown> = {};
const approvals = Math.max(0, ...covering.map((r) => Number(r.required_approvals ?? 0)));
if (approvals > 0) copied.required_approvals = approvals;
if (covering.some((r) => r.block_admin_merge_override === true)) copied.block_admin_merge_override = true;
+125 -77
View File
@@ -1,134 +1,135 @@
package main
import (
"os"
"path/filepath"
"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.
// The tests of ADR 0534 §1, §2 (the operator, 2026-10-23): the forge requires no status to merge. Main's rule keeps no
// push, no push whitelist, the tools' account alone in its merge whitelist and no administrator merging past it, and
// requires no status: the program writes it so on every repository it lists, and takes every required status off a
// rule covering main in every repository of the organisation it does not list. Nothing else of an unlisted
// repository's rule is sent.
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,
"enable_status_check": len(contexts) > 0, "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) {
// requiresNone is whether a rule as the fake forge keeps it requires no status.
func requiresNone(rule map[string]any) bool {
return rule["enable_status_check"] != true && len(strs(rule["status_check_contexts"])) == 0
}
// 0534-1: a listed repository whose main requires statuses has them all taken off, the rest of the rule kept; a
// second pass changes nothing.
func TestListedRepositoryRequiresNoStatus(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")
checked := f.add("novox/checked", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "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)
o := run(t, s, Value{"mesh-delivery": {}, "checked": {}})
for name, r := range map[string]*fRepo{"mesh-delivery": repo, "checked": checked} {
rule := r.Rules[mainRule]
if !requiresNone(rule) {
t.Errorf("%s's main still requires %v", name, rule["status_check_contexts"])
}
if rule["enable_push"] != false || rule["enable_merge_whitelist"] != true ||
strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools || rule["block_admin_merge_override"] != true {
t.Errorf("%s's main lost the rest of its rule: %v", name, rule)
}
if r := result(o, name); r.Did != DidPutRight {
t.Errorf("%s outcome %+v, want put right", name, r)
}
}
before := len(f.writes())
run(t, s, Value{"mesh-delivery": {}})
o = run(t, s, Value{"mesh-delivery": {}, "checked": {}})
if w := f.writes(); len(w) != before {
t.Errorf("a second pass wrote %v", w[before:])
}
if result(o, "checked").Did != DidUnchanged {
t.Errorf("second outcome %+v, want unchanged", result(o, "checked"))
}
}
// 1301-S1: a repository the program makes requires `mesh/check` alone from its first rule in the organisation on.
func TestNewRepositoryRequiresCheckAlone(t *testing.T) {
// 0534-1: a repository the program makes requires no status from its first rule in the organisation on.
func TestNewRepositoryRequiresNoStatus(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 !requiresNone(rule) {
t.Errorf("a new repository's main requires %v, want none", rule["status_check_contexts"])
}
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") {
if readme := f.repos["novox/fresh"].Files["README.md"]; 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) {
// 0534-1: a listed repository whose main requires nothing stays so, and is not written when the rest is right.
func TestListedRuleRequiringNothingIsRight(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")
none := f.add("novox/none", map[string]map[string]any{mainRule: narrowedRule()}, "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)
o := run(t, s, Value{"none": {}})
if !requiresNone(none.Rules[mainRule]) {
t.Errorf("none requires %v", none.Rules[mainRule]["status_check_contexts"])
}
if got := strs(right.Rules[mainRule]["status_check_contexts"]); strings.Join(got, ",") != CheckStatus {
t.Errorf("right requires %v", got)
if result(o, "none").Did != DidUnchanged {
t.Errorf("outcome %+v, want unchanged", result(o, "none"))
}
if result(o, "right").Did != DidUnchanged || result(o, "both").Did != DidPutRight {
t.Errorf("outcome %+v", o)
for _, w := range f.writes() {
t.Errorf("a right rule was written: %s", w)
}
}
// 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) {
// 0534-1: hq, whose main required mesh/repo-check alone, requires no status either, listed or not.
func TestOwnCheckAloneGoesToo(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 !requiresNone(repo.Rules[mainRule]) {
t.Errorf("%s requires %v, want none", name, repo.Rules[mainRule]["status_check_contexts"])
}
if r := result(o, name); r.Did != DidPutRight {
t.Errorf("%s outcome %+v, want put right", name, r)
}
}
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) {
// 0534-1: a repository the value does not list whose rule covering main requires any status has them taken off, and
// only its statuses: nothing else of its rule is sent. One that requires none is not written to.
func TestUnlistedRepositoryRequiringAStatusHasItTakenOff(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"},
"enable_push": true, "enable_status_check": true, "status_check_contexts": []any{"mesh/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/unticked", map[string]map[string]any{mainRule: {"enable_push": false, "enable_status_check": false,
"status_check_contexts": []any{"mesh/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 !requiresNone(catalog.Rules[mainRule]) {
t.Errorf("mesh-catalog requires %v, want none", catalog.Rules[mainRule]["status_check_contexts"])
}
if catalog.Rules[mainRule]["enable_push"] != true {
t.Errorf("an unlisted repository's rule was narrowed beyond its statuses: %v", catalog.Rules[mainRule])
t.Errorf("an unlisted repository's rule was changed 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)
if !requiresNone(toolsGo.Rules[mainRule]) {
t.Errorf("mesh-tools-go requires %v, want none", toolsGo.Rules[mainRule]["status_check_contexts"])
}
want := []string{
"PATCH /api/v1/repos/novox/mesh-catalog/branch_protections/main",
@@ -142,7 +143,7 @@ func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
t.Errorf("%s outcome %+v, want put right", n, r)
}
}
for _, n := range []string{"plain", "hq", "norule", "elsewhere"} {
for _, n := range []string{"plain", "unticked", "norule", "elsewhere"} {
if r := result(o, n); r.Name != "" {
t.Errorf("%s is in the outcome though nothing was done: %+v", n, r)
}
@@ -154,27 +155,27 @@ func TestUnlistedRepositoryRequiringTheGateIsSwitched(t *testing.T) {
}
}
// 1301-S3: the organisation's repositories are read page by page, so one past the first page is switched too.
// 0534-1: the organisation's repositories are read page by page, so one past the first page is put right 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")
f.add("novox/"+n, map[string]map[string]any{mainRule: narrowedRule("mesh/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)
if rule := f.repos["novox/"+n].Rules[mainRule]; !requiresNone(rule) {
t.Errorf("%s requires %v", n, rule["status_check_contexts"])
}
}
}
// 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) {
// 0534-1: a refused removal is said with the repository and its step, and the pass goes on to the next.
func TestRefusedRemovalIsSaid(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.add("novox/a", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
f.add("novox/b", map[string]map[string]any{mainRule: narrowedRule("mesh/check")}, "main")
f.refuse = func(method, path string) (int, string) {
if method == "PATCH" && strings.Contains(path, "/novox/a/") {
return 403, "no"
@@ -183,8 +184,8 @@ func TestRefusedSwapIsSaid(t *testing.T) {
}
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, "a"); r.Did != DidRefused || r.Step != StepNoStatus {
t.Errorf("a: %+v, want refused at %s", r, StepNoStatus)
}
if r := result(o, "b"); r.Did != DidPutRight {
t.Errorf("b: %+v, want put right", r)
@@ -213,3 +214,50 @@ func TestOrganisationListRefusedIsSaid(t *testing.T) {
t.Errorf("the refused list is not in the outcome: %+v", o)
}
}
// 0534-2 (the operator, 2026-10-23 ~12:05): the operator's own forge login stays on main's merge whitelist beside the
// tools' account, so the operator can always merge by hand at the forge; a rule naming the tools' account alone is put
// right, and a second pass changes nothing.
func TestTheOperatorsLoginStaysBesideTheTools(t *testing.T) {
f := newFake()
repo := f.add("novox/mesh-delivery", map[string]map[string]any{mainRule: narrowedRule()}, "main")
s, _ := syncer(t, f)
s.Mergers = []string{"jschoubben", tools, "jschoubben"}
o := run(t, s, Value{"mesh-delivery": {}, "fresh": {}})
for name, rule := range map[string]map[string]any{"mesh-delivery": repo.Rules[mainRule], "fresh": f.repos["novox/fresh"].Rules[mainRule]} {
if got := strings.Join(strs(rule["merge_whitelist_usernames"]), ","); got != tools+",jschoubben" {
t.Errorf("%s's merge whitelist is %s, want %s and jschoubben", name, got, tools)
}
if !requiresNone(rule) || rule["enable_push"] != false {
t.Errorf("%s's rule: %v", name, rule)
}
}
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": {}, "fresh": {}})
if w := f.writes(); len(w) != before {
t.Errorf("a second pass wrote %v", w[before:])
}
}
// 0534-2: the mergers are read from the module's runtime config file; a missing key is none; a name that is no forge login
// is refused.
func TestReadMergers(t *testing.T) {
p := filepath.Join(t.TempDir(), "r.json")
_ = os.WriteFile(p, []byte(`{"url":"","main-mergers":["jschoubben"]}`), 0o600)
if m, err := ReadMergers(p); err != nil || strings.Join(m, ",") != "jschoubben" {
t.Fatalf("%v %v", m, err)
}
_ = os.WriteFile(p, []byte(`{"url":""}`), 0o600)
if m, err := ReadMergers(p); err != nil || len(m) != 0 {
t.Fatalf("%v %v", m, err)
}
for _, bad := range []string{`["a b"]`, `["../x"]`, `[""]`, `"jschoubben"`} {
_ = os.WriteFile(p, []byte(`{"main-mergers":`+bad+`}`), 0o600)
if _, err := ReadMergers(p); err == nil {
t.Errorf("%s was accepted", bad)
}
}
}
+2 -1
View File
@@ -126,7 +126,8 @@ func lets(rule map[string]any, login string) bool {
// and in the organisation the tools' team does (it is on every repository there); nobody else here.
func reaches(full, login string) bool {
owner := strings.SplitN(full, "/", 2)[0]
return owner == login || (owner == org && login == tools)
// The operator's own login is an owner of the organisation, so it reaches every repository there (ADR 0534 §2).
return owner == login || (owner == org && (login == tools || login == "jschoubben"))
}
// keepReaching drops from a rule's merge whitelist every user without access to the repository, as gitea's
+9 -3
View File
@@ -3,8 +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, 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
// its main rule put right, requiring no status (novox/hq ADR 0534 §1). A repository the value does not list is
// touched only to take the required statuses off a rule covering its main. 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.
@@ -30,7 +30,8 @@ func main() {
func runSync() int {
forge, missing := forgehttp.FromEnv()
file, outcome := os.Getenv("MESH_REPOSITORIES_FILE"), os.Getenv("MESH_REPOSITORIES_OUTCOME")
for k, v := range map[string]string{"MESH_REPOSITORIES_FILE": file, "MESH_REPOSITORIES_OUTCOME": outcome} {
for k, v := range map[string]string{"MESH_REPOSITORIES_FILE": file, "MESH_REPOSITORIES_OUTCOME": outcome,
"MESH_GITEA_CONFIG_FILE": os.Getenv("MESH_GITEA_CONFIG_FILE")} {
if v == "" {
missing = append(missing, k)
}
@@ -56,6 +57,11 @@ func runSync() int {
_ = WriteOutcome(outcome, o)
return Report(o, os.Stderr)
}
if s.Mergers, err = ReadMergers(os.Getenv("MESH_GITEA_CONFIG_FILE")); err != nil {
o := Outcome{At: time.Now().UTC(), Org: s.Org, Failed: err.Error()}
_ = WriteOutcome(outcome, o)
return Report(o, os.Stderr)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
o, _ := s.Sync(ctx, value) // a forge not reachable is in o.Failed
@@ -64,6 +64,7 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
if v, err := ReadValue(filled); err != nil || len(v) != 1 || v["mesh-delivery"].Description != "d" {
t.Errorf("the filled template reads as %v, %v; want mesh-delivery", v, err)
}
for _, r := range m.Resources {
if r["type"] == "process" && has(strs(r["restart-on"]), file["id"].(string)) {
process = r
@@ -105,3 +106,61 @@ func TestManifestDeclaresTheValueAndItsTrigger(t *testing.T) {
t.Errorf("repositories is declared unlike agent-read-teams: %v vs %v", v, teams)
}
}
// 0534-2: the manifest declares main-mergers as a trusted list naming the operator's login by default, merges it into
// the module's runtime config file (with url, which the TypeScript part reads), and the repositories program is given
// that file and runs again when it changes.
func TestManifestDeclaresTheMainMergers(t *testing.T) {
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m struct {
Config map[string]map[string]any `json:"config"`
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
v := m.Config[MergersKey]
if v == nil || v["type"] != "list" || v["trusted"] != true || strings.Join(strs(v["default"]), ",") != "jschoubben" {
t.Fatalf("config %s = %v, want a trusted list defaulting to the operator's login", MergersKey, v)
}
var cfg, process map[string]any
for _, r := range m.Resources {
if r["id"] == "runtime-config" {
cfg = r
}
if r["name"] == "gitea-repositories" {
process = r
}
}
if cfg == nil || !has(strs(cfg["takes-config"]), MergersKey) || cfg["merge"] != "json" {
t.Fatalf("runtime-config does not take %s: %v", MergersKey, cfg)
}
text, err := os.ReadFile("../../" + cfg["template"].(string))
if err != nil {
t.Fatal(err)
}
file := filepath.Join(t.TempDir(), "config.json")
_ = os.WriteFile(file, text, 0o600)
if got, err := ReadMergers(file); err != nil || strings.Join(got, ",") != "jschoubben" {
t.Errorf("the template's mergers read as %v, %v; want the default", got, err)
}
env, _ := process["env"].(map[string]any)
if env["MESH_GITEA_CONFIG_FILE"] != cfg["path"] {
t.Errorf("the repositories program reads %v, want %v", env["MESH_GITEA_CONFIG_FILE"], cfg["path"])
}
if !has(strs(process["restart-on"]), "runtime-config") {
t.Errorf("the repositories program does not run again when the mergers change: %v", process["restart-on"])
}
}
func has(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
@@ -5,7 +5,7 @@ import (
"testing"
)
// 1141/AC1: a repository the program makes gets main's rule with an administrator's merge past a status refused,
// 1141/AC1: a repository the program makes gets main's rule with an administrator's merge past it refused,
// as every repository's main has had since 2026-10-07.
func TestNewRepositoryRefusesTheAdministratorsOverride(t *testing.T) {
f := newFake()
@@ -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 (the two old ones as mesh/check, issue 1301), and a second pass changes nothing.
// statuses taken off (ADR 0534 §1), 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,8 +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)
}
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 !requiresNone(rule) {
t.Errorf("statuses %v, want none (ADR 0534 §1)", rule["status_check_contexts"])
}
if r := result(o, "mesh-delivery"); r.Did != DidPutRight {
t.Errorf("outcome %+v, want put right", r)
+85 -94
View File
@@ -11,6 +11,7 @@ import (
"os"
"path"
"regexp"
"slices"
"sort"
"strings"
"time"
@@ -26,46 +27,11 @@ const (
MainBranch = "main"
)
// 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")
// noStatus is what main's rule says of statuses: none is required to merge (novox/hq ADR 0534 §1, the operator,
// 2026-10-23). The checks still run and report; mesh-delivery asks the operator's Approve on a passing verdict and the
// merge tool judges it at the merge (ADR 0534 §3, §4). The forge enforces none of them.
func noStatus() map[string]any {
return map[string]any{"enable_status_check": false, "status_check_contexts": []string{}}
}
// The steps of one repository, as an outcome names a refused one (ADR 0428 §6).
@@ -82,8 +48,8 @@ const (
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"
// StepNoStatus takes the required statuses off main's rule of a repository the value does not list (ADR 0534 §1).
StepNoStatus = "no-status"
)
// What a pass did with one repository.
@@ -106,6 +72,37 @@ type Repo struct {
// Value is the config value: each repository of the organisation by name.
type Value map[string]Repo
// MergersKey is the config value naming the forge logins that merge into main beside the tools' account (ADR 0534 §2).
const MergersKey = "main-mergers"
// loginName is a forge login: what a merge whitelist may name.
var loginName = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$`)
// ReadMergers reads the main-mergers value from the module's runtime config file, which the mesh merges the value into
// (module.json runtime-config); a file without the key names none.
func ReadMergers(file string) ([]string, error) {
raw, err := os.ReadFile(file)
if err != nil {
return nil, fmt.Errorf("the module's config file cannot be read (%s): %w", file, err)
}
var whole map[string]json.RawMessage
if err := json.Unmarshal(raw, &whole); err != nil {
return nil, fmt.Errorf("the module's config file %s is not JSON: %w", file, err)
}
var out []string
if r, ok := whole[MergersKey]; ok && string(r) != "null" {
if err := json.Unmarshal(r, &out); err != nil {
return nil, fmt.Errorf("%s in %s is not a list of forge logins: %w", MergersKey, file, err)
}
}
for _, n := range out {
if !loginName.MatchString(n) || strings.Contains(n, "..") {
return nil, fmt.Errorf("%s in %s lists %q, which is not a forge login", MergersKey, file, n)
}
}
return out, nil
}
// ReadValue reads the file the mesh renders from the config value. A file without the key is the empty value,
// which makes nothing and touches nothing.
func ReadValue(file string) (Value, error) {
@@ -166,8 +163,11 @@ type Syncer struct {
Forge forgehttp.Client // the setup login's
Org string // the organisation, novox
Admin string // the setup login, under whose own account a repository is made
Tools string // the tools' account, the only one that merges into main
Log func(format string, a ...any)
Tools string // the tools' account, which merges into main for mesh-delivery
// Mergers are the forge logins that merge into main beside the tools' account: the operator's own, so the operator
// can always merge by hand at the forge (ADR 0534 §2; the gitea module's config value main-mergers).
Mergers []string
Log func(format string, a ...any)
// WaitFor is how long to wait for the forge to answer; none when zero.
WaitFor, Poll time.Duration
}
@@ -203,7 +203,7 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) {
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)
s.say("%s: %s/%s (main requires no status)", r.Did, s.Org, r.Name)
}
out.Repositories = append(out.Repositories, r)
}
@@ -213,12 +213,10 @@ func (s *Syncer) Sync(ctx context.Context, v Value) (Outcome, error) {
// 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.
// switchUnlisted takes every required status off a rule covering main in every repository of the organisation the
// value does not list (ADR 0534 §1): the forge requires no status to merge anywhere, with no list kept by hand. Only
// the statuses are sent; nothing else of a rule the value does not hold is changed (ADR 0428 §7). A main whose rules
// require no status gets no call. It answers only what it did: a repository put right, or refused.
func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
var names []string
for page := 1; ; page++ {
@@ -248,13 +246,12 @@ func (s *Syncer) switchUnlisted(ctx context.Context, v Value) []Result {
}
var did *Result
for _, r := range rules {
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || !has(required(r), GateStatus) {
if ok, _ := path.Match(ruleName(r), MainBranch); !ok || len(required(r)) == 0 {
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()}
repoAPI(s.Org, n)+"/branch_protections/"+url.PathEscape(ruleName(r)), noStatus(), nil); err != nil {
did = &Result{Name: n, Did: DidRefused, Step: StepNoStatus, Said: err.Error()}
break
}
did = &Result{Name: n, Did: DidPutRight}
@@ -349,9 +346,9 @@ func (s *Syncer) one(ctx context.Context, name string, r Repo) Result {
return refused(name, StepFirstCommit, err)
}
}
if rule == nil || !isRight(rule, s.Tools) {
if rule == nil || !isRight(rule, s.Tools, s.Mergers) {
if err := s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(s.Admin, name)+"/branch_protections/"+MainBranch,
narrowed(rule, s.Tools), nil); err != nil {
narrowed(s.Tools, s.Mergers), nil); err != nil {
return refused(name, StepNarrow, err)
}
}
@@ -415,24 +412,6 @@ func strList(v any) []string {
return out
}
// statuses is what the rules require together, as wanted makes it.
func statuses(rules ...rule) []string {
seen := map[string]bool{}
var out []string
add := func(s string) {
if !seen[s] {
seen[s] = true
out = append(out, s)
}
}
for _, r := range rules {
for _, s := range required(r) {
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 {
@@ -441,46 +420,58 @@ func required(r rule) []string {
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,
// requiring the statuses (ADR 0428 §5), and no administrator merging past them, as every repository's main has been
// since 2026-10-07 (issue 1141). A field the forge keeps beside these is left as it is.
func narrowed(r rule, toolsAccount string, also ...rule) map[string]any {
return map[string]any{
// narrowed is main's rule of ADR 0398 §2 (protection.ts mainRuleBody): no push, only the tools' account merges, no
// administrator merging past the rule, as every repository's main has been since 2026-10-07 (issue 1141), and no
// status required (ADR 0534 §1). A field the forge keeps beside these is left as it is.
func narrowed(toolsAccount string, mergers []string) map[string]any {
body := map[string]any{
"enable_push": false, "enable_push_whitelist": false, "push_whitelist_usernames": []string{},
"enable_merge_whitelist": true, "merge_whitelist_usernames": []string{toolsAccount},
"enable_status_check": true, "status_check_contexts": statuses(append([]rule{r}, also...)...),
"enable_merge_whitelist": true, "merge_whitelist_usernames": whitelist(toolsAccount, mergers),
"block_admin_merge_override": true,
}
for k, v := range noStatus() {
body[k] = v
}
return body
}
// isRight says whether a rule already is narrowed and requires what wanted makes of it.
func isRight(r rule, toolsAccount string) bool {
// whitelist is main's merge whitelist: the tools' account first, then each other merger once, in the value's order.
func whitelist(toolsAccount string, mergers []string) []string {
out := []string{toolsAccount}
for _, m := range mergers {
if !slices.Contains(out, m) {
out = append(out, m)
}
}
return out
}
// isRight says whether a rule already is narrowed and requires no status.
func isRight(r rule, toolsAccount string, mergers []string) bool {
if r["enable_push"] != false || r["enable_push_whitelist"] == true || r["enable_merge_whitelist"] != true {
return false
}
if m := strList(r["merge_whitelist_usernames"]); len(m) != 1 || m[0] != toolsAccount {
if m := strList(r["merge_whitelist_usernames"]); !slices.Equal(m, whitelist(toolsAccount, mergers)) {
return false
}
have := strList(r["status_check_contexts"])
return r["enable_status_check"] == true && len(have) > 0 && sameList(have, wanted(have)) &&
return r["enable_status_check"] != true && len(strList(r["status_check_contexts"])) == 0 &&
r["block_admin_merge_override"] == true
}
// 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 takes away no other status and never unprotects (ADR 0428 §5, §7).
// putRight makes main's rule in an organisation's repository the narrowed one, requiring no status (ADR 0534 §1).
// A repository with no rule named main gets one, carrying the approvals a glob rule covering main asks (client.ts
// ensureMainMerges, issue 948). It never unprotects (ADR 0428 §7).
func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error) {
rules, err := s.rules(ctx, owner, name)
if err != nil {
return false, err
}
if own := ruleNamed(rules, MainBranch); own != nil {
if isRight(own, s.Tools) {
if isRight(own, s.Tools, s.Mergers) {
return false, nil
}
return true, s.Forge.SendJSON(ctx, http.MethodPatch, repoAPI(owner, name)+"/branch_protections/"+MainBranch,
narrowed(own, s.Tools), nil)
narrowed(s.Tools, s.Mergers), nil)
}
var covering []rule
for _, r := range rules {
@@ -488,7 +479,7 @@ func (s *Syncer) putRight(ctx context.Context, owner, name string) (bool, error)
covering = append(covering, r)
}
}
body := narrowed(nil, s.Tools, covering...)
body := narrowed(s.Tools, s.Mergers)
body["rule_name"] = MainBranch
approvals := 0.0
for _, r := range covering {
@@ -19,7 +19,7 @@ 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/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.
// narrowed rule, and nothing else is pushed to main. Main requires no status (ADR 0534 §1).
func TestNewRepositoryIsMadeInOrder(t *testing.T) {
f := newFake()
s, _ := syncer(t, f)
@@ -66,9 +66,8 @@ func TestNewRepositoryIsMadeInOrder(t *testing.T) {
if rule["enable_merge_whitelist"] != true || strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools {
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 || strings.Join(checks, ",") != CheckStatus {
t.Errorf("main does not require mesh/check alone (1124/AC5, issue 1301): %v", rule)
if !requiresNone(rule) {
t.Errorf("main requires a status (ADR 0534 §1): %v", rule)
}
if r := result(o, "mesh-delivery"); r.Did != DidMade || r.Step != "" {
t.Errorf("outcome %+v, want made", r)
@@ -87,7 +86,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, mesh/check required.
// put right: the tools' account the only one that merges, no status required (ADR 0534 §1).
func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
f := newFake()
s, _ := syncer(t, f)
@@ -103,9 +102,8 @@ func TestOrganisationRuleListsTheToolsAfterTheMove(t *testing.T) {
if rule["enable_push"] != false {
t.Errorf("after the move main takes a push: %v", rule)
}
checks := strs(rule["status_check_contexts"])
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 !requiresNone(rule) {
t.Errorf("after the move main requires a status (ADR 0534 §1): %v", rule)
}
if r := result(o, "mesh-delivery"); r.Did != DidMade {
t.Errorf("outcome %+v, want made", r)
@@ -136,8 +134,7 @@ 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 but the two mesh/check replaces (1124/AC5,
// issue 1301).
// a rule still letting the setup login push is narrowed, and requires no status (ADR 0534 §1).
func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
f := newFake()
hq := f.add("novox/hq", map[string]map[string]any{mainRule: {
@@ -157,12 +154,11 @@ func TestExistingOrganisationRepositoryIsOnlyPutRight(t *testing.T) {
if rule["enable_push"] != false || strings.Join(strs(rule["merge_whitelist_usernames"]), ",") != tools {
t.Errorf("hq's rule not narrowed: %v", rule)
}
checks := strs(rule["status_check_contexts"])
if strings.Join(checks, ",") != "other/x,mesh/check" {
t.Errorf("hq's rule requires %v, want other/x and mesh/check", checks)
if !requiresNone(rule) {
t.Errorf("hq's rule requires %v, want none (ADR 0534 §1)", rule["status_check_contexts"])
}
made := bare.Rules[mainRule]
if made == nil || made["enable_push"] != false || strings.Join(strs(made["status_check_contexts"]), ",") != CheckStatus {
if made == nil || made["enable_push"] != false || !requiresNone(made) {
t.Errorf("bare's rule: %v", made)
}
if hq.Commits != 0 || bare.Commits != 0 {
@@ -212,33 +208,33 @@ func TestRepositoryUnderTheSetupAccountIsFinished(t *testing.T) {
}
}
// 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) {
// ADR 0534 §1: putting a rule right takes every required status off it, whichever they are, and a rule already right
// is not written.
func TestStatusesAreTakenOff(t *testing.T) {
f := newFake()
only := f.add("novox/only", 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/repo-check"},
}}, "main")
more := f.add("novox/more", map[string]map[string]any{mainRule: {
right := f.add("novox/right", 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/delivery", "mesh/check"},
"enable_status_check": false, "status_check_contexts": []any{},
"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"]); strings.Join(c, ",") != "mesh/repo-check" {
t.Errorf("only: %v", c)
o := run(t, s, Value{"only": {}, "right": {}})
if !requiresNone(only.Rules[mainRule]) {
t.Errorf("only: %v", only.Rules[mainRule]["status_check_contexts"])
}
if c := strs(more.Rules[mainRule]["status_check_contexts"]); len(c) != 2 || !has(c, "mesh/delivery") {
t.Errorf("more lost a status: %v", c)
if !requiresNone(right.Rules[mainRule]) {
t.Errorf("right: %v", right.Rules[mainRule]["status_check_contexts"])
}
for _, w := range f.writes() {
if strings.Contains(w, "/more/") {
if strings.Contains(w, "/right/") {
t.Errorf("a right rule was written: %s", w)
}
}
if result(o, "more").Did != DidUnchanged || result(o, "only").Did != DidPutRight {
if result(o, "right").Did != DidUnchanged || result(o, "only").Did != DidPutRight {
t.Errorf("outcome %+v", o)
}
}
+4 -49
View File
@@ -101,10 +101,10 @@ export function deliveryStatus(context: string, state: string, description: stri
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).
// state, as issue 293 closed for the two before it. No tool writes it, since a tool is told no caller (R4e finding K1):
// check.ts writes it from the checker's owed state (ADR 0521).
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`);
throw new Error(`${CHECK_STATUS} is the checker's status: it is written from mesh-delivery-checker.owed alone, never by a 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
@@ -136,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. 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.",
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: no tool writes it: it is written from the checker's own state. 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" },
@@ -163,48 +163,3 @@ 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 };
},
};
}
+1 -1
View File
@@ -1 +1 @@
{}
{"main-mergers": ["jschoubben"]}
+16 -18
View File
@@ -10,15 +10,16 @@
// Everything else — administration, protection, settings, collaborators, teams, hooks, keys, tokens,
// statuses, a merge, a repository deleted or moved — has its own tool or is the operator's, at the
// forge as themselves;
// - `gitea_branch_protection_set` only makes protection stronger;
// - `gitea_merge_pull_request` merges only a head whose every required status succeeded, and only that
// head;
// - `gitea_branch_protection_set` lets no person push and no administrator merge past a rule, and on main keeps
// the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5);
// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed
// against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only
// that head;
// - `gitea_delete_repo` refuses a repository any protection rule guards.
//
// Pure, so it is tested without a forge; the tools do the asking (tools/index.ts).
import { MAIN_BRANCH, type BranchProtection, type ProtectionWanted } from "./protection.js";
import { TOOLS_ACCOUNT } from "./tools-account.js";
import { MAIN_BRANCH, mainMergers, type BranchProtection, type ProtectionWanted } from "./protection.js";
/** A refusal of policy, in the words the caller sees. Never an error of the forge's. */
export class Refused extends Error {
@@ -163,7 +164,7 @@ export function judgeApiCall(method: string, rawPath: string, body: unknown): Ap
`that is not the default branch and that no protection rule names. The forge's administration, a ` +
`branch's protection, a repository's settings, collaborators, teams, hooks, keys, tokens and statuses ` +
`are the operator's at the forge, or have their own tool (gitea_merge_pull_request, gitea_commit_status, ` +
`gitea_branch_protection_set, which only strengthens). Nothing was sent`,
`gitea_branch_protection_set, which never lets a person push). Nothing was sent`,
};
}
@@ -287,7 +288,7 @@ export function judgeContentsWrite(target: string, defaultBranch: string, rules:
* Refuses a protection change that weakens what guards a branch (novox/hq ADR 0266): the branch's own rule, or —
* when it has none — every glob rule that covers it, since a rule named for the branch takes the glob's place.
* A rule made new takes the forge's defaults for what it does not say: pushes refused, an administrator's
* override allowed.
* override allowed. A required status taken away is no weakening: no rule of the mesh requires one (ADR 0534 §5).
*/
export function judgeProtectionChange(before: BranchProtection | null, want: ProtectionWanted,
covering: BranchProtection[] = [], branch = ""): string | null {
@@ -305,37 +306,34 @@ export function judgeProtectionChange(before: BranchProtection | null, want: Pro
}
return null; // A new rule protects what nothing protected.
}
const had = before.enable_status_check ? [...new Set((before.status_check_contexts ?? []).filter(Boolean))] : [];
const wanted = new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean));
const dropped = had.filter((c) => !wanted.has(c));
const why: string[] = [];
if (dropped.length) why.push(`it drops the required status ${dropped.join(", ")}`);
if (want.push === true && before.enable_push === false) why.push("it lets a person push to the branch directly");
if (want.blockAdminOverride === false && before.block_admin_merge_override === true) {
why.push("it lets an administrator merge past a status that has not succeeded");
why.push("it lets an administrator merge past what the rule asks");
}
if (!why.length) return null;
return `the protection of ${before.rule_name ?? before.branch_name} is only made stronger through a tool: ` +
`${why.join("; ")}. Whoever may call a tool includes agents, and a branch's protection is what keeps a ` +
`change from merging without its checks and the operator (novox/hq ADR 0266) — weakening it is the ` +
`change from landing but through a pull request the operator approved (novox/hq ADR 0266, ADR 0534) — weakening it is the ` +
`operator's, at the forge as themselves. Nothing was changed`;
}
/** Main's rule takes no push and lets only the tools' account merge (novox/hq ADR 0398 §2, issue 948): a change
* that enables push, drops the merge whitelist or widens it past that account is refused, whatever the rule was. */
/** Main's rule takes no push and lets only the tools' account and main-mergers merge (novox/hq ADR 0398 §2, issue 948,
* ADR 0534 §2): a change that enables push, drops the merge whitelist or widens it past them is refused, whatever the
* rule was. */
function judgeMain(before: BranchProtection | null, want: ProtectionWanted): string | null {
const why: string[] = [];
if (want.push === true) why.push("it lets a person push to the branch directly");
if (want.mergeWhitelist !== undefined) {
const names = want.mergeWhitelist ?? [];
if (names.length === 0) why.push("it drops the merge whitelist of main");
else if (names.some((n) => n !== TOOLS_ACCOUNT)) {
why.push(`it widens who may merge into main beyond ${TOOLS_ACCOUNT}`);
else if (names.some((n) => !mainMergers().includes(n))) {
why.push(`it widens who may merge into main beyond ${mainMergers().join(", ")}`);
}
}
if (!why.length) return null;
return `the protection of ${before?.rule_name ?? before?.branch_name ?? "main"} is only made stronger through a tool: ` +
`${why.join("; ")}. Main takes no push, and only ${TOOLS_ACCOUNT} merges into it (novox/hq ADR 0398 §2) — ` +
`${why.join("; ")}. Main takes no push, and only ${mainMergers().join(" and ")} merge into it (novox/hq ADR 0398 §2, ADR 0534 §2) — ` +
`weakening that is the operator's, at the forge as themselves. Nothing was changed`;
}
+17 -5
View File
@@ -13,8 +13,12 @@
// 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. 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).
// still requires are written (issue 1301): main requires `mesh/check` — the checker's — everywhere but hq, whose main
// requires `mesh/repo-check` alone (ADR 0503 §5).
//
// Reads mesh-delivery-checker.owed — what the checker owes the forge, mesh/check on each head it judged — by a watch, and
// writes it (check.ts; ADR 0521, R4e finding R2-1). No tool writes mesh/check (finding K1), and no event: the watch hands
// over the latest value of every key whenever this module starts.
//
// 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.
@@ -41,7 +45,9 @@
import { emit, on } from "@novox/mesh-sdk/events";
import { registerModuleTools } from "@novox/mesh-sdk/tools";
import { statesAnnouncement } from "./kept.js";
import { checkStatusTool, commitStatusTool } from "./delivery.js";
import { commitStatusTool } from "./delivery.js";
import { CHECKER_OWED, CheckStatuses, watchOwed, type OwedChange } from "./check.js";
import { state } from "@novox/mesh-sdk/state";
import { announce, keepWatermark, readWatermark } from "./announce.js";
import { mergeTool } from "./merge.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
@@ -475,6 +481,11 @@ const gates = new GateWriter({
headChecks: (owner, repo, sha) => (gitea as GiteaClient).headChecks(owner, repo, sha),
});
// The one writer of mesh/check, from the checker's owed (check.ts; ADR 0521).
const checks = new CheckStatuses({
setCommitStatus: (owner, repo, sha, status) => (gitea as GiteaClient).setCommitStatus(owner, repo, sha, status),
});
// **The status tool is served here, beside the writer** (novox/hq issue 456): the runtime starts each bundle as its own
// process, so a recheck's pending written from the tools' bundle bypassed this writer's chain. Without a forge to
// reach, nothing is served, as the tools' bundle serves nothing then.
@@ -491,8 +502,6 @@ await emit("states", statesAnnouncement(process.env.MESH_MODULE || "gitea", proc
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
@@ -621,4 +630,7 @@ if (gitea) {
})],
["node-build-agent.started", () => on("node-build-agent.started", buildStarted)],
], () => console.log("[gitea] watching for new repositories and merged pull requests"));
// The checker's mesh/check, from its owed state (check.ts; ADR 0521): a read not granted yet is said once and asked again,
// apart from the binds above, so neither waits for the other.
void watchOwed((handler) => state<OwedChange["value"]>(CHECKER_OWED).watch((change) => handler(change)), checks);
}
+31 -16
View File
@@ -5,10 +5,12 @@
// 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.
// **It judges mesh/check whether or not the base requires it** (issue 1301; ADR 0447 §1, §4; ADR 0534 §4): the checker's
// verdict, whose line names the trunk it judged ("pass <head12> on <trunk12>"), must be a pass against the trunk's head.
// The forge requires no status to merge (ADR 0534 §1); where a base still requires one of the controller's old statuses,
// until the forge's holder takes it off, that status judges, 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. Where the base requires no
// status, only a head the operator's Approve names (approved_heads) merges (ADR 0534 §4, ADR 0421 §9).
import { announce } from "./announce.js";
import type { ToolDefinition } from "@novox/mesh-sdk/tools";
@@ -36,9 +38,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
now: given.now ?? (() => new Date()) };
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/check; hq's mesh/repo-check), " +
"judged against the trunk's head as it is now, optionally deleting the source branch afterwards. Refused " +
description: "Merge a pull request at the head you name — the commit you reviewed — when that is still its head, " +
"its base refuses this account's push, its mesh/check passed judged against the trunk's head as it is now, and, " +
"where the base requires no status (novox/hq ADR 0534), the operator's Approve names it in approved_heads; " +
"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 " +
"the trunk's head is asked once, with how long the last check of the repository took: ask again at the same head " +
@@ -100,11 +103,11 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
`approved (novox/hq ADR 0421). Nothing was merged`);
}
const rule = await gitea.appliedProtection(owner, repo, pull.base);
// Only into a branch whose protection requires the checks: a pull request whose base was moved to a
// branch nothing guards would merge past every check (novox/hq ADR 0266).
if (!rule || !rule.enable_status_check || !(rule.status_check_contexts ?? []).filter(Boolean).length) {
throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule requiring ` +
`the checks guards: a tool merges only into such a branch (novox/hq ADR 0266). Nothing was merged`);
// Only into a branch whose protection refuses this account's push: a pull request whose base was moved to a
// branch nothing guards would land where anyone pushes (novox/hq ADR 0266, ADR 0534 §4).
if (!rule || rule.enable_push !== false) {
throw new Refused(`${owner}/${repo}#${number} merges into ${pull.base}, which no protection rule refusing ` +
`a push guards: a tool merges only into such a branch (novox/hq ADR 0266, ADR 0534). Nothing was merged`);
}
// **The verdict must be judged against the trunk's head** (novox/hq ADR 0323 §5, issue 341): a check judges the
// head merged into a named trunk commit, and a merge of another trunk is not the one that check judged. Compared
@@ -114,7 +117,16 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
const trunkHead = await gitea.branchHead(owner, repo, pull.base);
const checks = await gitea.headChecks(owner, repo, pull.head_sha);
const required = requiredOf(rule) ?? [];
if (required.includes(CHECK_STATUS)) {
// **The forge requires no status; the operator's Approve and mesh/check do** (ADR 0534 §4): where the base requires
// no status, only a head the Approve names merges, and mesh/check judges it as where it was required.
if (required.length === 0 && !approved) {
throw new Refused(`${owner}/${repo}#${number} is not merged: ${pull.base} requires no status, so a merge into it ` +
`lands only a head the operator's Approve names: give approved_heads with ${owner}/${repo}@<full sha> ` +
`(novox/hq ADR 0534, ADR 0421 §9). Nothing was merged`);
}
const kept = maintained(required);
const byCheck = required.includes(CHECK_STATUS) || kept.length === 0;
if (byCheck) {
// **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];
@@ -124,6 +136,10 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
`${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 (check.state !== "success") {
throw new Refused(`${owner}/${repo}#${number} is not merged: its ${CHECK_STATUS} is ${check.state} ` +
`(${check.description ?? ""}). A pull request merges once its check passes (novox/hq ADR 0534). 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 ` +
@@ -133,7 +149,6 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
}
// 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) {
@@ -197,9 +212,9 @@ export function mergeTool(gitea: GiteaClient, given: MergeDeps): ToolDefinition
// of them is no verdict to merge on, never read as none.
// 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 heard = byCheck ? undefined : readSiblings(siblingsFile(deps.stateDir))[memberKey(owner, repo, number)];
const siblings = heard && sameCommit(heard.head, pull.head_sha) ? heard.siblings : [];
if (!siblings.length && !required.includes(CHECK_STATUS) &&
if (!siblings.length && !byCheck &&
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`);
+15 -5
View File
@@ -172,9 +172,15 @@
},
"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/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.",
"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, the tools' account and main-mergers alone merging, and no status required (ADR 0534), and only then moved into the organisation; one that exists only has main's rule put right so. Every other repository has the statuses its main requires taken off; nothing else of a repository out of the value is touched.",
"trusted": true,
"default": {}
},
"main-mergers": {
"type": "list",
"description": "The forge logins that may merge into main beside the tools' account, on every repository of the organisation (ADR 0534 §2): the operator's own, so the operator can always merge by hand at the forge. The tools' account is always on main's merge whitelist; no agent's account belongs here.",
"trusted": true,
"default": ["jschoubben"]
}
},
"facts": {
@@ -303,7 +309,8 @@
"template": "files/config.json",
"merge": "json",
"takes-config": [
"url"
"url",
"main-mergers"
]
},
{
@@ -366,10 +373,12 @@
"MESH_GITEA_ADMIN_PASSWORD_FILE": "${dir:state}/admin.secret",
"MESH_GITEA_TOOLS_USER": "mesh-tools",
"MESH_REPOSITORIES_FILE": "${dir:mesh-state}/repositories.json",
"MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json"
"MESH_REPOSITORIES_OUTCOME": "${dir:runtime-state}/repositories-outcome.json",
"MESH_GITEA_CONFIG_FILE": "${dir:mesh-state}/config.json"
},
"restart-on": [
"repositories-file"
"repositories-file",
"runtime-config"
]
}
],
@@ -543,6 +552,7 @@
"ssh-client.keys",
"mesh-build-go.keys",
"mesh-build-node.keys",
"mesh-build-image.keys"
"mesh-build-image.keys",
"mesh-delivery-checker.owed"
]
}
+1 -1
View File
@@ -6,7 +6,7 @@
"private": true,
"scripts": {
"build": "tsc client.ts token.ts index.ts provisioner/index.ts provisioner/teams.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
"test": "npm run build && tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
+37 -11
View File
@@ -1,7 +1,9 @@
// 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).
// A branch's protection (novox/hq ADR 0237): no direct push, and on main only the tools' account merges (ADR 0398 §2).
// The forge requires no status to merge (ADR 0534 §1): the checks still run and report, and mesh-delivery and the
// merge tool judge them (ADR 0534 §3, §4), never the forge. Pure, so it is tested without a forge; the client does the
// asking (client.ts).
import { readFileSync } from "node:fs";
import { TOOLS_ACCOUNT } from "./tools-account.js";
/** The branch whose rule takes no push and lets only the tools' account merge (novox/hq ADR 0398 §2, issue 948). */
@@ -24,26 +26,50 @@ 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/check. Empty: none. */
statusChecks: string[];
/** Whether a person may push to the branch directly. */
push?: boolean;
/** Whether an administrator is stopped from merging past a status that has not succeeded. */
/** Whether an administrator is stopped from merging past what the rule asks. */
blockAdminOverride?: boolean;
/** Who may merge into the branch; for main only ever the tools' account (ADR 0398 §2). Not given: left as it is.
* Empty or null asks for no whitelist, which the guard refuses for main; the body for main ignores it. */
mergeWhitelist?: string[] | null;
}
/** What main's rule always says: no push, and only the tools' account merges (novox/hq ADR 0398 §2, issue 948). */
export function mainRuleBody(): Record<string, unknown> {
return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: [TOOLS_ACCOUNT] };
/** What every rule the mesh writes says of statuses: none is required to merge (novox/hq ADR 0534 §1). */
export function noStatusBody(): Record<string, unknown> {
return { enable_status_check: false, status_check_contexts: [] };
}
/** A forge login a merge whitelist may name. */
const LOGIN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
/** Who merges into main: the tools' account, for mesh-delivery, then each login of the module's config value
* main-mergers once, the operator's own, so the operator can always merge by hand at the forge (novox/hq ADR 0534 §2).
* Read from the module's runtime config file (MESH_GITEA_CONFIG_FILE); a file not there, or a name that is no login,
* leaves the tools' account alone, the narrowest whitelist. */
export function mainMergers(env: NodeJS.ProcessEnv = process.env): string[] {
const out = [TOOLS_ACCOUNT];
let given: unknown = [];
try {
if (env.MESH_GITEA_CONFIG_FILE) given = JSON.parse(readFileSync(env.MESH_GITEA_CONFIG_FILE, "utf8"))["main-mergers"] ?? [];
} catch {
given = [];
}
for (const name of Array.isArray(given) ? given : []) {
if (typeof name === "string" && LOGIN.test(name) && !name.includes("..") && !out.includes(name)) out.push(name);
}
return out;
}
/** What main's rule always says: no push, only the tools' account and main-mergers merge (novox/hq ADR 0398 §2,
* issue 948, ADR 0534 §2), and no status required (ADR 0534 §1). */
export function mainRuleBody(mergers: string[] = mainMergers()): Record<string, unknown> {
return { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: mergers, ...noStatusBody() };
}
/** The forge's body for a wanted protection. */
export function protectionBody(want: ProtectionWanted, creating: boolean, branch = ""): Record<string, unknown> {
const checks = [...new Set(want.statusChecks.map((c) => c.trim()).filter(Boolean))];
const body: Record<string, unknown> = { enable_status_check: checks.length > 0, status_check_contexts: checks };
const body: Record<string, unknown> = noStatusBody();
if (want.push !== undefined) body.enable_push = want.push;
else if (creating) body.enable_push = false;
if (want.blockAdminOverride !== undefined) body.block_admin_merge_override = want.blockAdminOverride;
+3 -3
View File
@@ -19,13 +19,13 @@ 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> …". */
/** The one status main requires (novox/hq ADR 0447 §1, issue 1301): the checker's verdict, written by this module from the
* checker's owed state (check.ts, ADR 0521). 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 {
export function requiredOf(rule: { enable_status_check?: boolean; status_check_contexts?: string[] | null } | null | undefined): string[] | undefined {
if (rule === undefined) return undefined;
if (!rule || !rule.enable_status_check) return [];
return (rule.status_check_contexts ?? []).filter(Boolean);
+73
View File
@@ -36,3 +36,76 @@ test("importing the bundle resolves while the broker keeps refusing the binds",
clearTimeout(timer);
assert.match(out, /imported/, `the import did not resolve within 3 s while a bind was refused; stderr: ${err.slice(0, 600)}`);
});
// **The bundle writes mesh/check from the checker's owed state, and serves no tool that writes it** (ADR 0521; R4e round 2
// finding R2-1; finding K1). The bundle is imported whole, as the runtime imports it, while its read of the checker's owed
// is refused (the grant not yet composed): the watch is asked again, and once granted it is handed the bucket as the bus
// keeps it, the latest revision of each key, then an older revision delivered late. The forge stand-in sees the latest
// written, and nothing after it.
test("a bundle whose read of the checker's owed is granted late writes the latest mesh/check, and serves no gitea_check_status", async () => {
const index = pathToFileURL(join(here, "..", "dist", "index.js")).href;
const sha = "0123456789abcdef0123456789abcdef01234567";
const script = `
const http = await import("node:http");
const statuses = [];
const forge = http.createServer((req, res) => {
let b = ""; req.on("data", (d) => (b += d)); req.on("end", () => {
if (req.method === "POST" && /\\/statuses\\//.test(req.url)) statuses.push(req.url + " " + b);
res.setHeader("content-type", "application/json"); res.end(req.method === "GET" ? "[]" : "{}");
});
});
await new Promise((r) => forge.listen(0, "127.0.0.1", r));
process.env.MESH_GITEA_URL = "http://127.0.0.1:" + forge.address().port;
const bound = [];
const { useBroker } = await import("@novox/mesh-sdk/messaging");
useBroker(() => ({
subscribe: async (pattern) => { bound.push(pattern); return () => {}; },
publish: async () => {}, request: async () => { throw new Error("no responders"); },
handle: async () => () => {}, close: async () => {},
}));
const key = "novox/mesh-sdk.12.${sha}.status=mesh/check";
const owed = (state, text) => ({ v: 1, act: "status", context: "mesh/check", state, text, target: "https://view.example/c" });
const watched = [];
let granted = 0;
const { useStateChannel } = await import("@novox/mesh-sdk/state");
useStateChannel({
ask: async () => null,
watch: async (params, deliver) => {
watched.push(params.state);
if (watched.length === 1) throw new Error('gitea keeps and reads no state called "mesh-delivery-checker.owed"');
granted++;
// Two revisions were written while the read was refused; the bucket hands the latest, then a late older one.
await deliver({ state: params.state, key, op: "put", value: owed("success", "pass at 4"), revision: 4, current: true });
await deliver({ state: params.state, key, op: "put", value: owed("pending", "pending at 3"), revision: 3, current: false });
},
});
await import(${JSON.stringify(index)});
const { collectTools } = await import("@novox/mesh-sdk/tools");
const tools = collectTools().flatMap((r) => r.tools.map((t) => t.name));
for (let i = 0; i < 300 && !(granted && statuses.length); i++) await new Promise((r) => setTimeout(r, 20));
await new Promise((r) => setTimeout(r, 200));
console.log(JSON.stringify({ tools, bound, watched, statuses }));
process.exit(0);`;
const env = { ...process.env, MESH_GITEA_TOKEN: "x" };
for (const k of ["MESH_GITEA_STATE_DIR", "MESH_GITEA_ADMIN_USER", "MESH_GITEA_ADMIN_PASSWORD", "MESH_SERVED_MODULE", "MESH_GITEA_URL"]) 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"), 15000);
await ended;
clearTimeout(timer);
const line = out.split("\n").find((l) => l.startsWith("{"));
assert.ok(line, `the bundle said nothing; stdout: ${out.slice(0, 400)} stderr: ${err.slice(0, 600)}`);
const got = JSON.parse(line!) as { tools: string[]; bound: string[]; watched: string[]; statuses: string[] };
assert.ok(got.tools.includes("gitea_commit_status"), `the bundle's tools: ${got.tools}`);
assert.ok(!got.tools.includes("gitea_check_status"), "no tool writes mesh/check");
assert.ok(!got.bound.some((p) => p.includes("check.status")), `bound: ${got.bound}`);
assert.deepEqual(got.watched, ["mesh-delivery-checker.owed", "mesh-delivery-checker.owed"], "refused, then asked again");
assert.equal(got.statuses.length, 1, `statuses written: ${got.statuses}`);
assert.match(got.statuses[0], new RegExp(`/repos/novox/mesh-sdk/statuses/${sha} .*"context":"mesh/check"`));
assert.match(got.statuses[0], /"state":"success".*pass at 4/);
assert.match(err, /mesh-delivery-checker\.owed could not be bound/, "the refusal is said");
});
+135
View File
@@ -0,0 +1,135 @@
// **mesh/check is written from the checker's owed state, read by a watch** (ADR 0521; R4e round 2 finding R2-1; finding K1
// before it). No tool writes mesh/check, since a tool is told no caller and node-tools invokes every tool. Nor does an event:
// nothing in the delivery plan orders this module before the forge bridge, and a durable consumer whose filters change keeps
// its deliver policy, so an event said before this module consumed it was lost. A watch of the checker's state hands over
// the latest value of every key when it starts, whenever that is.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
const sha = "0123456789abcdef0123456789abcdef01234567";
const key = (s = sha, repo = "novox/mesh-sdk", n = 12) => `${repo}.${n}.${s}.status=mesh/check`;
const owed = (over: Record<string, unknown> = {}) => ({ v: 1, act: "status", context: "mesh/check", state: "success",
text: "pass 0123456789ab on aaaaaaaaaaaa", target: "https://view.example/checks/g.1", ...over });
const put = (k: string, value: unknown, revision: number, current = false) =>
({ state: "mesh-delivery-checker.owed", key: k, op: "put" as const, value, revision, current });
function forge() {
const sent: string[] = [];
let failing = 0;
return {
sent,
fail: (n: number) => { failing = n; },
setCommitStatus: async (o: string, r: string, s: string, st: any) => {
if (failing > 0) { failing--; throw new Error("Gitea API /repos/x/statuses: 502 bad gateway"); }
sent.push(`${o}/${r}@${s.slice(0, 8)} ${st.context} ${st.state} ${st.description}${st.target_url ? " " + st.target_url : ""}`);
},
};
}
const noSleep = async () => {};
test("the checker's owed is what this module reads, and it consumes no check.status", async () => {
const { CHECKER_OWED } = await import("../check.ts");
assert.equal(CHECKER_OWED, "mesh-delivery-checker.owed");
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
assert.ok(manifest.reads.includes(CHECKER_OWED), "gitea's manifest reads the checker's owed");
assert.ok(!manifest.consumes.some((c: string) => c.includes("check.status")), "no check.status is consumed");
});
test("an owed mesh/check writes the status on its head: its state, its line cut and its link", async () => {
const { CheckStatuses } = await import("../check.ts");
const f = forge();
const checks = new CheckStatuses(f, () => {}, noSleep);
assert.equal(await checks.apply(put(key(), owed(), 7, true)), "written");
const other = sha.replace(/0/g, "f");
assert.equal(await checks.apply(put(key(other, "novox/my=2erepo"), owed({ state: "pending", text: "y".repeat(300), target: "javascript:x" }), 8)), "written");
assert.equal(f.sent[0], "novox/mesh-sdk@01234567 mesh/check success pass 0123456789ab on aaaaaaaaaaaa https://view.example/checks/g.1");
assert.ok(f.sent[1].startsWith("novox/my.repo@f1234567 mesh/check pending yyyy"), f.sent[1]);
assert.ok(f.sent[1].length < 200, "the line is cut as every status's is");
assert.ok(!f.sent[1].includes("javascript"), "a link that is no web page is not written");
});
test("a key or value that is no owed mesh/check, and a delete, write nothing", async () => {
const { CheckStatuses } = await import("../check.ts");
const f = forge();
const checks = new CheckStatuses(f, () => {}, noSleep);
const cases = [
put(key(sha.slice(0, 12)), owed(), 1), put(`novox/mesh-sdk.12.${sha}.status=mesh/merge-gate`, owed(), 1),
put(`novox/mesh-sdk.12.${sha}.view`, owed(), 1), put(`nokey`, owed(), 1), put(`../x/y.12.${sha}.status=mesh/check`, owed(), 1),
put(key(), owed({ context: "mesh/delivery" }), 1), put(key(), owed({ act: "view" }), 1), put(key(), owed({ state: "green" }), 1),
put(key(), owed({ v: 2 }), 1), put(key(), null, 1), put(key(), "text", 1), put(key(), owed(), -1),
{ state: "mesh-delivery-checker.owed", key: key(), op: "delete" as const, revision: 2, current: false },
];
for (const c of cases) assert.equal(await checks.apply(c as any), "dropped", JSON.stringify(c));
assert.deepEqual(f.sent, []);
});
test("a revision older than one written on the key is dropped; the same one is written again", async () => {
const { CheckStatuses } = await import("../check.ts");
const f = forge();
const checks = new CheckStatuses(f, () => {}, noSleep);
await checks.apply(put(key(), owed({ state: "success" }), 10));
assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 9)), "dropped", "a stale pending never hides the verdict");
assert.equal(await checks.apply(put(key(), owed({ state: "success" }), 10)), "written");
assert.equal(await checks.apply(put(key(sha.replace(/1/g, "2")), owed({ state: "pending" }), 3)), "written", "another key is its own");
assert.equal(await checks.apply(put(key(), owed({ state: "pending" }), 11)), "written");
assert.deepEqual(f.sent.map((s) => s.split(" ").slice(0, 3).join(" ")), ["novox/mesh-sdk@01234567 mesh/check success",
"novox/mesh-sdk@01234567 mesh/check success", "novox/mesh-sdk@02234567 mesh/check pending", "novox/mesh-sdk@01234567 mesh/check pending"]);
});
test("a forge that refuses is asked again until it writes, unless a newer revision of the key came meanwhile", async () => {
const { CheckStatuses } = await import("../check.ts");
const f = forge();
const said: string[] = [];
const waits: number[] = [];
const checks = new CheckStatuses(f, (l) => said.push(l), async (ms) => { waits.push(ms); });
f.fail(2);
await checks.take(put(key(), owed({ state: "success" }), 5));
await checks.settled();
assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "written on the third ask");
assert.deepEqual(waits, [2_000, 5_000]);
assert.match(said[0], /502.*asked again/);
// A refusal whose key moved on meanwhile is not asked again: the newer revision is written, the older never after it.
const g = forge();
let release: () => void = () => {};
const gate = new Promise<void>((r) => { release = r; });
const later = new CheckStatuses(g, () => {}, () => gate);
g.fail(1);
await later.take(put(key(), owed({ state: "pending" }), 6));
await later.take(put(key(), owed({ state: "success" }), 7));
release();
await later.settled();
assert.deepEqual(g.sent.map((s) => s.split(" ")[2]), ["success"]);
});
test("a watch refused at first is asked again, said once, and once granted writes the latest revision of each key", async () => {
const { CheckStatuses, watchOwed } = await import("../check.ts");
const f = forge();
const said: string[] = [];
const checks = new CheckStatuses(f, () => {}, noSleep);
// The checker's bucket as the bus keeps it: two revisions of one key written while the read was refused.
const kept = new Map<string, { value: unknown; revision: number }>();
kept.set(key(), { value: owed({ state: "pending" }), revision: 3 });
kept.set(key(), { value: owed({ state: "success" }), revision: 4 });
let asked = 0;
const watch = async (handler: (c: any) => Promise<void>) => {
asked++;
if (asked < 3) throw new Error("gitea keeps and reads no state called \"mesh-delivery-checker.owed\"");
for (const [k, e] of kept) await handler(put(k, e.value, e.revision, true));
};
await watchOwed(watch, checks, noSleep, (l) => said.push(l));
await checks.settled();
assert.equal(asked, 3);
assert.deepEqual(f.sent.map((s) => s.split(" ")[2]), ["success"], "only the latest revision is written");
assert.equal(said.filter((l) => /could not be bound/.test(l)).length, 1, `said once: ${said.join(" | ")}`);
assert.ok(said.some((l) => /bound after 3 attempts/.test(l)), said.join(" | "));
});
test("no tool writes mesh/check: gitea_check_status is gone and gitea_commit_status refuses it", async () => {
const delivery = await import("../delivery.ts");
assert.equal((delivery as Record<string, unknown>).checkStatusTool, undefined, "delivery.ts serves no gitea_check_status");
assert.throws(() => delivery.deliveryStatus("mesh/check", "success", "x"), /mesh-delivery-checker\.owed/);
const index = readFileSync(new URL("../index.ts", import.meta.url), "utf8");
assert.ok(!/gitea_check_status|checkStatusTool/.test(index), "index.ts serves no gitea_check_status");
});
+7 -24
View File
@@ -62,37 +62,20 @@ test("one view per pull request, found by its marker; only the mesh's statuses",
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");
// Issue 1301-S4 (ADR 0447 §1; R4e findings K1 and R2-1): main requires mesh/check, so no hand may set it. The status tool
// every caller may use refuses it in every state; no tool writes it at all: it is written from the checker's owed state
// (check.ts, test/check-owed.test.ts; ADR 0521).
test("mesh/check is refused by the status tool in every state", async () => {
const { commitStatusTool, 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 setCommitStatus = async (o: string, r: string, s: string, st: any) => void sent.push(`${o}/${r}@${s} ${st.context}`);
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/);
context: "mesh/check", state, description: "by hand" }), /mesh-delivery-checker\.owed/);
}
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");
});
+74 -36
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 { checkStatusTool, commitStatusTool } from "../dist/delivery.js";
import { commitStatusTool } from "../dist/delivery.js";
import { GateWriter } from "../dist/gate.js";
import { judgeApiCall, judgeProtectionChange, ruleCovers, routedPath } from "../dist/guard.js";
@@ -109,7 +109,7 @@ const server = createServer(async (req, res) => {
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
return json(res, 200, { name: branch, commit: { id: branch === "main" ? state.trunk : "c0ffee" }, protected: !!rule,
...(rule ? { effective_branch_protection_name: rule.rule_name ?? rule.branch_name, enable_status_check: !!rule.enable_status_check,
status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: false } : {}) });
status_check_contexts: rule.status_check_contexts ?? [], required_approvals: rule.required_approvals ?? 0, user_can_push: !!rule.enable_push } : {}) });
}
if (method === "GET" && path === `${repo}/pulls/7/files`) {
return json(res, 200, url.searchParams.get("page") === "1" ? [{ filename: "modules/gitea/x.ts", status: "modified" }] : []);
@@ -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,
}), 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 }); },
}), 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}`);
@@ -237,18 +237,24 @@ test("gitea_api still writes what an author writes", async () => {
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/issues/3/comments", "POST /repos/novox/mesh-catalog/pulls/7/reviews"]);
});
test("gitea_branch_protection_set only strengthens", async () => {
// **The forge requires no status to merge** (novox/hq ADR 0534 §5): the tool writes no required status and takes no
// status argument; taking a required status away is no weakening it refuses, and a push or an administrator's merge
// past the rule still is.
test("gitea_branch_protection_set writes no required status, and refuses a push or an administrator's override", async () => {
state.admin = true; // a repository administrator's token: the tools' account is refused the rules by the forge
const set = (args: Record<string, unknown>) =>
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "main", ...args });
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate, mesh\/repo-check/);
await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /drops the required status mesh\/repo-check/);
await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", push: true }), /push to the branch directly/);
await assert.rejects(set({ status_checks: "mesh/merge-gate,mesh/repo-check", block_admin_override: false }),
/merge past a status/);
assert.equal((tool("gitea_branch_protection_set").input as Record<string, unknown>).status_checks, undefined,
"the tool takes no status argument");
await assert.rejects(set({ push: true }), /push to the branch directly/);
await assert.rejects(set({ block_admin_override: false }), /merge past/);
assert.deepEqual(writes(), []);
await set({ status_checks: "mesh/merge-gate,mesh/repo-check,mesh/delivery" });
await set({ status_checks: "mesh/check" }); // an argument it no longer takes is not read
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/branch_protections/main"]);
const sent = state.sent[0].body;
assert.equal(sent.enable_status_check, false);
assert.deepEqual(sent.status_check_contexts, []);
assert.equal(sent.enable_push, false);
});
test("gitea_merge_pull_request merges only a head whose required statuses all succeeded, and only that head", async () => {
@@ -474,26 +480,62 @@ test("gitea_api sends the path it judged, and refuses moving a pull request's ba
assert.equal(writes().length, 2);
});
test("gitea_merge_pull_request merges only into a branch whose protection requires the checks", async () => {
test("gitea_merge_pull_request merges only into a branch whose protection refuses its push", async () => {
const approved_heads = [`novox/mesh-catalog@${REVIEWED}`];
state.statuses = [{ context: "mesh/check", state: "success", description: `pass ${REVIEWED.slice(0, 12)} on ${TRUNK.slice(0, 12)}` }];
state.rules = [];
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }),
/no protection rule requiring the checks guards/);
state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [] }];
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED }),
/no protection rule requiring the checks guards/);
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED,
approved_heads }), /no protection rule refusing/);
state.rules = [{ rule_name: "main", enable_push: true, enable_status_check: false, status_check_contexts: [] }];
await assert.rejects(tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED,
approved_heads }), /no protection rule refusing/);
assert.deepEqual(writes(), []);
});
// **The forge requires no status to merge; the merge tool still judges mesh/check, and asks the operator's Approve**
// (novox/hq ADR 0534 §4): on a main whose rule takes no push and requires no status, a head merges only with
// approved_heads naming it and a mesh/check that passed against the trunk's head at the merge.
test("gitea_merge_pull_request judges mesh/check on a main requiring no status, and merges only an approved head", async () => {
const { keepSiblings, siblingsFile } = await import("../dist/siblings.js");
keepSiblings(siblingsFile(stateDir), {});
const approved_heads = [`novox/mesh-catalog@${REVIEWED}`];
const merge = (args: Record<string, unknown> = { approved_heads }) =>
tool("gitea_merge_pull_request").run({ owner: "novox", repo: "mesh-catalog", number: 7, head: REVIEWED, ...args });
state.rules = [{ rule_name: "main", enable_push: false, enable_status_check: false, status_check_contexts: [],
block_admin_merge_override: true, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] }];
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(TRUNK);
await assert.rejects(merge({}), /Approve/);
state.statuses = [];
await assert.rejects(merge(), /mesh\/check is not set yet/);
state.statuses = line(TRUNK, "pending", "checking");
await assert.rejects(merge(), /mesh\/check is pending/);
state.statuses = line(TRUNK, "failure", "fail");
await assert.rejects(merge(), /mesh\/check is failure/);
state.statuses = line(MOVED);
await assert.rejects(merge(), new RegExp(`${MOVED.slice(0, 12)}.*${TRUNK.slice(0, 12)}`));
state.statuses = judgedAt(TRUNK); // the old two, current: not mesh/check
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);
});
test("a rule named for a branch is not made weaker than the glob rule that guards it now", async () => {
state.admin = true; // reads the rules' list, a repository administrator's (novox/hq ADR 0365)
state.rules.push({ rule_name: "release/*", enable_push: false, enable_status_check: true,
status_check_contexts: ["mesh/merge-gate"], block_admin_merge_override: true });
const set = (args: Record<string, unknown>) =>
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-catalog", branch: "release/1", ...args });
await assert.rejects(set({ status_checks: "" }), /drops the required status mesh\/merge-gate/);
await assert.rejects(set({ status_checks: "mesh/merge-gate" }), /merge past a status/);
await assert.rejects(set({}), /merge past/);
assert.deepEqual(writes(), []);
await set({ status_checks: "mesh/merge-gate", block_admin_override: true });
await set({ block_admin_override: true }); // the glob's required status is no longer asked (ADR 0534 §5)
assert.deepEqual(writes(), ["POST /repos/novox/mesh-catalog/branch_protections"]);
});
@@ -511,26 +553,25 @@ test("the judges, without a forge", () => {
assert.ok("refused" in judgeApiCall("POST", "/repos/a/b/branch_protections", {}));
assert.ok("contents" in judgeApiCall("POST", "/repos/a/b/contents", { new_branch: "x" }));
assert.ok(ruleCovers("release/**", "release/a/b") && ruleCovers("feat/*", "feat/a") && !ruleCovers("feat/*", "feat/a/b"));
assert.equal(judgeProtectionChange(null, { statusChecks: [] }), null);
assert.equal(judgeProtectionChange(protectedMain(), { statusChecks: ["mesh/repo-check", "mesh/merge-gate"] }), null);
assert.equal(judgeProtectionChange(null, {}), null);
assert.equal(judgeProtectionChange(protectedMain(), {}), null, "taking its required statuses off is no weakening (ADR 0534 §5)");
});
test("main's protection is never weakened in its push or its merge whitelist (issue 948, ADR 0398 §2)", () => {
const strong = () => ({ ...protectedMain(), enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] });
const checks = ["mesh/merge-gate", "mesh/repo-check"];
assert.equal(judgeProtectionChange(strong(), { statusChecks: checks }, [], "main"), null);
assert.equal(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["mesh-tools"] }, [], "main"), null);
assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: [] }, [], "main") ?? "", /merge whitelist/);
assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: null }, [], "main") ?? "", /merge whitelist/);
assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["mesh-tools", "agent-ace"] }, [], "main") ?? "", /widens/);
assert.match(judgeProtectionChange(strong(), { statusChecks: checks, mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/);
assert.match(judgeProtectionChange(strong(), { statusChecks: checks, push: true }, [], "main") ?? "", /push/);
assert.equal(judgeProtectionChange(strong(), {}, [], "main"), null);
assert.equal(judgeProtectionChange(strong(), { mergeWhitelist: ["mesh-tools"] }, [], "main"), null);
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: [] }, [], "main") ?? "", /merge whitelist/);
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: null }, [], "main") ?? "", /merge whitelist/);
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: ["mesh-tools", "agent-ace"] }, [], "main") ?? "", /widens/);
assert.match(judgeProtectionChange(strong(), { mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/);
assert.match(judgeProtectionChange(strong(), { push: true }, [], "main") ?? "", /push/);
// Even a rule that lets a person push already, or has no rule yet, is not given a push through a tool.
assert.match(judgeProtectionChange({ ...strong(), enable_push: true }, { statusChecks: checks, push: true }, [], "main") ?? "", /push/);
assert.match(judgeProtectionChange(null, { statusChecks: checks, push: true }, [], "main") ?? "", /push/);
assert.match(judgeProtectionChange(null, { statusChecks: checks, mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/);
assert.match(judgeProtectionChange({ ...strong(), enable_push: true }, { push: true }, [], "main") ?? "", /push/);
assert.match(judgeProtectionChange(null, { push: true }, [], "main") ?? "", /push/);
assert.match(judgeProtectionChange(null, { mergeWhitelist: ["agent-ace"] }, [], "main") ?? "", /widens/);
// Another branch is judged as before.
assert.equal(judgeProtectionChange(null, { statusChecks: checks, push: true }, [], "feat/x"), null);
assert.equal(judgeProtectionChange(null, { push: true }, [], "feat/x"), null);
});
test("gitea_api never acts as another user: sudo, in any case, and any query key it does not send are refused", async () => {
@@ -568,9 +609,6 @@ 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" }],
+1 -1
View File
@@ -52,7 +52,7 @@ test("gitea_branch_protection_get answers the rule for a branch, null when it ha
test("gitea_branch_protection_set still gets the forge's refusal, as the tools' account", async () => {
await assert.rejects(
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main", status_checks: "mesh/merge-gate" }),
tool("gitea_branch_protection_set").run({ owner: "novox", repo: "mesh-controller", branch: "main" }),
/403/,
);
assert.ok(!seen.some((s) => /^(PATCH|POST|PUT|DELETE) .*branch_protections.* as admin$/.test(s)), seen.join("\n"));
+31 -15
View File
@@ -4,32 +4,33 @@ import { test } from "node:test";
// A branch's protection (novox/hq ADR 0237): what the operator's agent sets so a pull request waits for the
// mesh's merge check before it merges.
test("the statuses asked for replace the rule's, and an empty list requires none", async () => {
test("a rule's body requires no status, whatever the rule required (ADR 0534 §5)", async () => {
const { protectionBody } = await import("../dist/protection.js");
assert.deepEqual(protectionBody({ statusChecks: ["mesh/merge-gate", " mesh/repo-check", "", "mesh/merge-gate"] }, false),
{ enable_status_check: true, status_check_contexts: ["mesh/merge-gate", "mesh/repo-check"] },
"an edited rule keeps its pushes as they are, and each status once");
assert.deepEqual(protectionBody({ statusChecks: [] }, false), { enable_status_check: false, status_check_contexts: [] });
assert.deepEqual(protectionBody({}, false), { enable_status_check: false, status_check_contexts: [] },
"an edited rule keeps its pushes as they are, and requires no status");
});
test("a new rule refuses direct pushes unless asked; an administrator's override only when said", async () => {
const { protectionBody } = await import("../dist/protection.js");
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).enable_push, false);
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], push: true }, true).enable_push, true);
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"] }, true).block_admin_merge_override, undefined);
assert.equal(protectionBody({ statusChecks: ["mesh/merge-gate"], blockAdminOverride: true }, false).block_admin_merge_override, true);
assert.equal(protectionBody({}, true).enable_push, false);
assert.equal(protectionBody({ push: true }, true).enable_push, true);
assert.equal(protectionBody({}, true).block_admin_merge_override, undefined);
assert.equal(protectionBody({ blockAdminOverride: true }, false).block_admin_merge_override, true);
});
// Issue 948 (novox/hq ADR 0398 §2): main takes no push, and only the tools' account merges.
// Issue 948 (novox/hq ADR 0398 §2): main takes no push, and only the tools' account merges; it requires no status
// (ADR 0534 §1).
test("main's body always refuses pushes and lets only the tools' account merge, whatever is asked", async () => {
const { protectionBody } = await import("../dist/protection.js");
const { TOOLS_ACCOUNT } = await import("../dist/tools-account.js");
for (const creating of [true, false]) {
for (const want of [{ statusChecks: ["mesh/merge-gate"] }, { statusChecks: [], push: true }, { statusChecks: [], push: false }]) {
for (const want of [{}, { push: true }, { push: false }]) {
const body = protectionBody(want, creating, "main");
assert.equal(body.enable_push, false, "main takes no push, an edit and a push=true included");
assert.equal(body.enable_merge_whitelist, true);
assert.deepEqual(body.merge_whitelist_usernames, [TOOLS_ACCOUNT]);
assert.equal(body.enable_status_check, false);
assert.deepEqual(body.status_check_contexts, []);
}
}
assert.deepEqual(TOOLS_ACCOUNT, "mesh-tools");
@@ -37,13 +38,28 @@ test("main's body always refuses pushes and lets only the tools' account merge,
test("another branch's body carries no merge whitelist", async () => {
const { protectionBody } = await import("../dist/protection.js");
const body = protectionBody({ statusChecks: ["mesh/merge-gate"] }, false, "release/1");
const body = protectionBody({}, false, "release/1");
assert.equal(body.enable_merge_whitelist, undefined);
assert.equal(body.merge_whitelist_usernames, undefined);
assert.equal(protectionBody({ statusChecks: [] }, false).enable_merge_whitelist, undefined);
});
test("the rule main is put right in the forge's own words: the body a setup sends carries no status checks of its own", async () => {
test("the rule main is put right in the forge's own words: no push, the tools' account merges, no status required", async () => {
const { mainRuleBody } = await import("../dist/protection.js");
assert.deepEqual(mainRuleBody(), { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"] });
assert.deepEqual(mainRuleBody(), { enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"],
enable_status_check: false, status_check_contexts: [] });
});
// The operator's own login stays on main's merge whitelist beside the tools' account (novox/hq ADR 0534 §2): read from
// the module's config value main-mergers, in the runtime config file; nothing there leaves the tools' account alone.
test("main's merge whitelist is the tools' account, then the configured mergers once each", async () => {
const { mainMergers, mainRuleBody } = await import("../dist/protection.js");
const { mkdtempSync, writeFileSync } = await import("node:fs");
const { join } = await import("node:path");
const { tmpdir } = await import("node:os");
const file = join(mkdtempSync(join(tmpdir(), "gitea-mergers-")), "config.json");
writeFileSync(file, JSON.stringify({ url: "", "main-mergers": ["jschoubben", "mesh-tools", "jschoubben", "../x", "a b"] }));
assert.deepEqual(mainMergers({ MESH_GITEA_CONFIG_FILE: file }), ["mesh-tools", "jschoubben"]);
assert.deepEqual(mainMergers({}), ["mesh-tools"]);
assert.deepEqual(mainMergers({ MESH_GITEA_CONFIG_FILE: join(tmpdir(), "no-such-gitea-config.json") }), ["mesh-tools"]);
assert.deepEqual(mainRuleBody(["mesh-tools", "jschoubben"]).merge_whitelist_usernames, ["mesh-tools", "jschoubben"]);
});
+9
View File
@@ -216,3 +216,12 @@ test("a base's required statuses decide which of the controller's statuses are w
const comment = commentFor(c, {}, ["mesh/repo-check"]);
assert.ok(comment === null || !comment.includes("mesh/merge-gate"), comment ?? "");
});
test("a rule the forge gives with no contexts (null) requires nothing, and an unread rule is unknown", async () => {
const { requiredOf } = await import("../pulls.ts");
// The forge's BranchProtection says status_check_contexts may be null (protection.ts); requiredOf takes it as it comes.
assert.deepEqual(requiredOf({ enable_status_check: true, status_check_contexts: null }), []);
assert.deepEqual(requiredOf({ enable_status_check: true, status_check_contexts: ["mesh/check", ""] }), ["mesh/check"]);
assert.deepEqual(requiredOf(null), []);
assert.equal(requiredOf(undefined), undefined);
});
+7 -6
View File
@@ -235,7 +235,7 @@ test("with the tools' token, the forge writes a status and refuses a protection
const client = fresh(env);
await client.setCommitStatus("novox", "hq", "abc123def4567890abc123def4567890abc12345", { state: "success", context: "mesh/merge-gate", description: "ok" } as any);
await assert.rejects(
client.setBranchProtection("novox", "hq", "main", { statusChecks: ["mesh/merge-gate"] }),
client.setBranchProtection("novox", "hq", "main", {}),
/403/,
);
});
@@ -350,7 +350,7 @@ test("a team another process made between the look and the create is found, not
assert.ok(team?.members.has(TOOLS_ACCOUNT));
});
test("setting the tools' account up puts main's rule right in every repository: no push, only the tools' account merges (issue 948)", async () => {
test("setting the tools' account up puts main's rule right in every repository: no push, only the tools' account merges, no status (issue 948, ADR 0534 §1)", async () => {
const env = await environment();
const from = forge.calls.length;
await fresh(env).listRepos();
@@ -358,7 +358,8 @@ test("setting the tools' account up puts main's rule right in every repository:
assert.equal(hq.enable_push, false);
assert.equal(hq.enable_merge_whitelist, true);
assert.deepEqual(hq.merge_whitelist_usernames, [TOOLS_ACCOUNT]);
assert.deepEqual(hq.status_check_contexts, ["mesh/merge-gate"], "a rule found keeps the statuses it requires");
assert.deepEqual(hq.status_check_contexts, [], "a rule found has its required statuses taken off (ADR 0534 §1)");
assert.equal(hq.enable_status_check, false);
const catalog = forge.mainRules.get("mesh-catalog");
assert.ok(catalog, "a repository with no rule for main is given one");
assert.equal(catalog.rule_name, "main");
@@ -368,13 +369,13 @@ test("setting the tools' account up puts main's rule right in every repository:
"only the administrator's login touches a rule: the tools' account is no repository's administrator");
});
test("a glob rule covering main keeps its statuses when main is given a rule of its own name (issue 948, review round 1)", async () => {
test("a glob rule covering main gives its approvals and override, not its statuses, when main is given a rule of its own name (issue 948, ADR 0534 §1)", async () => {
const env = await environment();
await fresh(env).listRepos();
const rule = forge.mainRules.get("globbed");
assert.ok(rule, "main is given its own rule");
assert.deepEqual(rule.status_check_contexts, ["mesh/merge-gate", "mesh/repo-check"]);
assert.equal(rule.enable_status_check, true);
assert.deepEqual(rule.status_check_contexts, []);
assert.equal(rule.enable_status_check, false);
assert.equal(rule.block_admin_merge_override, true);
assert.equal(rule.required_approvals, 1);
assert.equal(rule.enable_push, false);
+2 -4
View File
@@ -429,14 +429,13 @@ 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 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.",
description: "Protect a branch. Never weaker (novox/hq ADR 0266): a change that lets a person push directly or lets an administrator merge past the rule 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. The forge requires no status to merge (ADR 0534): the rule written requires none, and one the rule required is taken off; the checks are judged by mesh-delivery and the merge tool. Edits the branch's rule (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/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)" },
block_admin_override: { type: "boolean", description: "stop an administrator merging past what the rule asks (left as it is when not given)" },
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
@@ -445,7 +444,6 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
const before = await gitea.branchProtection(owner, repo, branch);
const want = {
statusChecks: String(args.status_checks ?? "").split(","),
push: args.push === undefined ? undefined : Boolean(args.push),
blockAdminOverride: args.block_admin_override === undefined ? undefined : Boolean(args.block_admin_override),
};
+1 -1
View File
@@ -128,7 +128,7 @@ export interface TrunkForge {
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>;
appliedProtection?(owner: string, repo: string, branch: string): Promise<{ enable_status_check?: boolean; status_check_contexts?: string[] | null } | null>;
}
/** What it writes with: the merge check's one writer (gate.ts), which decides in the head's own turn. */
@@ -39,12 +39,8 @@ func TestTheBaseParses(t *testing.T) {
t.Skip("logrotate is not installed here; the base configuration is not dry-run")
}
dir := t.TempDir()
content := strings.ReplaceAll(templateText(t, manifest(t).resource(t, "config")), "include "+RulesDir, "include "+filepath.Join(dir, "d"))
if err := os.Mkdir(filepath.Join(dir, "d"), 0o755); err != nil {
t.Fatal(err)
}
conf := filepath.Join(dir, "logrotate.conf")
if err := os.WriteFile(conf, []byte(content), 0o644); err != nil {
if err := os.WriteFile(conf, []byte(dryRunBase(t, dir)), 0o644); err != nil {
t.Fatal(err)
}
out, err := exec.Command(logrotate, "-d", "-s", filepath.Join(dir, "state"), conf).CombinedOutput()
@@ -52,3 +48,40 @@ func TestTheBaseParses(t *testing.T) {
t.Fatalf("logrotate -d: %v\n%s", err, out)
}
}
// The dry run proves the base parses, not what the machine's logs are: the unit level runs shut off in
// bubblewrap (mesh-sdk buildstep.Isolated), where /var/log is not there, and a base block without
// missingok then fails its stat (issue 1891).
func TestTheDryRunReadsNothingOfTheMachine(t *testing.T) {
dir := t.TempDir()
content := dryRunBase(t, dir)
for _, machine := range []string{"include " + RulesDir, "/var/log/"} {
if strings.Contains(content, machine) {
t.Fatalf("the dry run's base still names %s of the machine:\n%s", machine, content)
}
}
for _, log := range []string{"wtmp", "btmp"} {
if _, err := os.Stat(filepath.Join(dir, "log", log)); err != nil {
t.Fatalf("the base's own log %s is not in the dry run's directory: %v", log, err)
}
}
}
// dryRunBase is the base configuration pointed into dir: its include at dir/d, and its own logs at dir/log,
// both made there.
func dryRunBase(t *testing.T, dir string) string {
t.Helper()
content := strings.ReplaceAll(templateText(t, manifest(t).resource(t, "config")), "include "+RulesDir, "include "+filepath.Join(dir, "d"))
content = strings.ReplaceAll(content, "/var/log/", filepath.Join(dir, "log")+"/")
for _, d := range []string{"d", "log"} {
if err := os.Mkdir(filepath.Join(dir, d), 0o755); err != nil {
t.Fatal(err)
}
}
for _, log := range []string{"wtmp", "btmp"} {
if err := os.WriteFile(filepath.Join(dir, "log", log), nil, 0o644); err != nil {
t.Fatal(err)
}
}
return content
}
@@ -68,6 +68,7 @@ func (m *Module) integrationShutOff(ctx context.Context, workspace string, packa
}
iso := m.isolation(workspace, false)
iso.ReadOnly = append(iso.ReadOnly, exe) // the program alone, not the directory it lies in
iso.ReadOnly = append(iso.ReadOnly, m.nodeToolchains()...)
r := buildstep.Isolated(m.Run, iso)(ctx, nil, exe, levelArg, string(spec))
var answer levelAnswer
line := lastLine(r.Stdout)
@@ -80,6 +81,25 @@ func (m *Module) integrationShutOff(ctx context.Context, workspace string, packa
return answer.Result, nil
}
// NodeToolchains is the Node build module's toolchains directory on this node: its cache directory (module.json
// resource `cache`, mode 0711) joined with toolchain, where it installs typescript, esbuild, @novox/mesh-sdk and nats
// from its own lock (ADR 0533 §1, §2; issue 1891).
const NodeToolchains = "/var/lib/mesh-build-node-cache/toolchain"
// nodeToolchains is what the integration level is given of the Node build module's toolchains: their directory,
// read-only, when it is there and this account may enter it, and nothing on a node where the Node build module has
// installed none (ADR 0533 §1). The unit level is never given it.
func (m *Module) nodeToolchains() []string {
dir := m.NodeToolchains
if dir == "" {
dir = NodeToolchains
}
if _, err := os.ReadDir(dir); err != nil {
return nil
}
return []string{dir}
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
return strings.TrimSpace(lines[len(lines)-1])
@@ -62,6 +62,9 @@ type Module struct {
Push func(ctx context.Context, registry, repository, digest string, body []byte) error
// Executable is this program's path, which the integration level starts again shut off; os.Executable when empty.
Executable string
// NodeToolchains is where the Node build module keeps its installed toolchains, bound read-only into the
// integration level (ADR 0533 §1); NodeToolchains when empty. A test points it elsewhere.
NodeToolchains string
// StartBus starts the throwaway bus of a run in dir and answers its address and what stops it (the module's own
// program, StartBus in bus.go, when nil).
StartBus func(ctx context.Context, dir string) (url string, stop func(), err error)
@@ -13,7 +13,8 @@ import (
// ADR 0499 §4 (issue 1758): a fetch asked with the module's directory and its named paths leaves the workspace holding
// only those, the manifest and the lock files; the rest of the commit is gone before any run step; a request naming
// paths without a directory is refused before anything is fetched.
// paths without a directory is refused before anything is fetched. go.mod is kept only when named among the paths
// (mesh-sdk's narrowKept is the manifest and the lock files, review finding S1), as a Go build that names its paths does.
func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) {
f := newForge(t)
_, head := f.repo("novox/narrow", map[string]string{
@@ -26,7 +27,7 @@ func TestAFetchWithNamedPathsNarrowsTheWorkspace(t *testing.T) {
"other/o.go": "package other\n",
})
m, states := f.module(t)
res, st := fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "dir": "lib", "paths": []any{"cmd"}})
res, st := fetched(t, m, states, map[string]any{"repository": "novox/narrow", "commit": head, "dir": "lib", "paths": []any{"cmd", "go.mod"}})
if st.State != delivery.StepPassed {
t.Fatalf("the fetch ended %s: %s", st.State, st.Reason)
}
@@ -0,0 +1,128 @@
package main
import (
"context"
"encoding/json"
"os"
"path/filepath"
"slices"
"strconv"
"testing"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// integrationShutOffWith runs the integration level through the fake bubblewrap and answers what its one shut-off
// process was given.
func integrationShutOffWith(t *testing.T, toolchains string) shutOff {
t.Helper()
m, states, ws, _ := workspaceFor(t, "failing")
m.Executable = "/opt/bundle/mesh-build-go"
m.NodeToolchains = toolchains
want := TestResult{Level: LevelIntegration, Passed: true, Tests: 1, Packages: []PackageResult{}, Store: "postgres 18"}
var seen []shutOff
m.Run = throughBubblewrap(t, func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
switch {
case name == "postgres" && len(args) == 1 && args[0] == "--version":
return forgekey.Ran{Stdout: "postgres (PostgreSQL) 18.6\n"}
case name == m.Executable && len(args) == 2 && args[0] == levelArg:
raw, _ := json.Marshal(levelAnswer{Result: &want})
return forgekey.Ran{Stdout: string(raw) + "\n"}
}
return forgekey.Ran{Status: 1, Err: "not expected here"}
}, &seen)
run := stepTool(t, m, "build_go_test")
if _, err := run(map[string]any{"workspace": ws, "level": LevelIntegration}); err != nil {
t.Fatal(err)
}
if st := states.waitEnd(t); st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
if len(seen) != 1 {
t.Fatalf("shut off %d processes, want one: %v", len(seen), seen)
}
checkShutOff(t, m, seen[0], false)
return seen[0]
}
// 1891 (ADR 0533 §1): the integration level is given the Node build module's installed toolchains, read-only, so the
// Node build module's own integration tests build with the real tsc and esbuild and reach no registry.
func TestTheIntegrationLevelIsGivenTheNodeToolchainsReadOnly(t *testing.T) {
dir := t.TempDir()
s := integrationShutOffWith(t, dir)
if !slices.Contains(s.roBinds, dir) {
t.Errorf("the node toolchains %s are not bound read-only: %v", dir, s.roBinds)
}
if slices.Contains(s.binds, dir) || slices.Contains(s.overlays, dir) {
t.Errorf("the node toolchains %s may be written: binds %v, overlays %v", dir, s.binds, s.overlays)
}
if slices.Contains(s.roBinds, filepath.Dir(dir)) {
t.Errorf("the whole cache %s is bound, not its toolchains alone: %v", filepath.Dir(dir), s.roBinds)
}
}
// 1891 (ADR 0533 §1): a node where the Node build module has installed no toolchain runs the level as before, with
// nothing more bound (bubblewrap binds only what is there).
func TestANodeWithoutNodeToolchainsRunsTheLevelWithNothingMoreBound(t *testing.T) {
missing := filepath.Join(t.TempDir(), "none")
s := integrationShutOffWith(t, missing)
if slices.Contains(s.roBinds, missing) {
t.Errorf("a missing %s is bound: %v", missing, s.roBinds)
}
}
// 1891 (ADR 0533 §1): the unit level is not given them.
func TestTheUnitLevelIsNotGivenTheNodeToolchains(t *testing.T) {
m, states, ws, _ := workspaceFor(t, "failing")
dir := t.TempDir()
m.NodeToolchains = dir
var seen []shutOff
m.Run = throughBubblewrap(t, m.Run, &seen)
if _, st := runUnit(t, m, states, ws); st.State != delivery.StepPassed {
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
}
for _, s := range seen {
if slices.Contains(s.roBinds, dir) || slices.Contains(s.binds, dir) {
t.Errorf("%s was given the node toolchains", s.command)
}
}
}
// 1891 (ADR 0533 §1, §2): the place bound is the Node build module's own: its manifest's cache directory joined with
// toolchain, and that cache directory lets other accounts enter it without listing it (0711).
func TestTheNodeToolchainsAreTheNodeBuildModulesCache(t *testing.T) {
if _, err := catalogue.ReadTreeManifest(catalogueRoot, filepath.Join(catalogueRoot, "modules", "mesh-build-node")); err != nil {
t.Fatalf("mesh-sdk's validator refuses mesh-build-node's manifest: %v", err)
}
raw, err := os.ReadFile(filepath.Join(catalogueRoot, "modules", "mesh-build-node", "module.json"))
if err != nil {
t.Fatal(err)
}
var man struct {
Resources []struct {
ID, Path, Mode string
} `json:"resources"`
}
if err := json.Unmarshal(raw, &man); err != nil {
t.Fatal(err)
}
found := false
for _, r := range man.Resources {
if r.ID != "cache" {
continue
}
found = true
if want := filepath.Join(r.Path, "toolchain"); NodeToolchains != want {
t.Errorf("NodeToolchains = %s, want %s (mesh-build-node's cache)", NodeToolchains, want)
}
mode, err := strconv.ParseUint(r.Mode, 8, 32)
if err != nil || mode&0o001 == 0 || mode&0o004 != 0 {
t.Errorf("mesh-build-node's cache has mode %q; want others to enter it and not list it (0711)", r.Mode)
}
}
if !found {
t.Fatal("mesh-build-node's manifest declares no cache directory")
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876/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/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
@@ -4,8 +4,6 @@ import (
"context"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep"
@@ -60,6 +58,11 @@ type Look struct {
// missing is a *buildstep.Refusal naming it, so a step ends refused and the health says the same.
func (m *Module) look(ctx context.Context) (Look, error) {
l := Look{Account: m.Account}
// The runtime directory comes first: buildah refuses before anything else when its XDG_RUNTIME_DIR is not there
// (issue 1884), so a look that made it only after `buildah --version` never made it at all.
if err := m.ensureRuntimeDir(); err != nil {
return l, &buildstep.Refusal{Text: err.Error()}
}
var err error
if l.Buildah, err = m.buildahVersion(ctx); err != nil {
return l, &buildstep.Refusal{Text: err.Error()}
@@ -67,11 +70,6 @@ func (m *Module) look(ctx context.Context) (Look, error) {
if l.UIDs, l.GIDs, err = m.ranges(); err != nil {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
}
for _, d := range []string{m.Store, filepath.Join(m.Store, "xdg")} {
if err := os.MkdirAll(d, 0o700); err != nil {
return l, err
}
}
if err := m.userNamespace(ctx); err != nil {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
}
@@ -111,6 +111,25 @@ func (m *Module) storeFlags() []string {
// env is what buildah is given beyond the base environment: a runtime directory of its own inside the store, so
// nothing is read from the account's runtime directory, and no registry credential file anywhere (the module has none
// and REGISTRY_AUTH_FILE is never set).
//
// Every call makes the runtime directory first if it is not there (issue 1884): a directory removed after the last look
// is made again before buildah runs. A failure to make it is not lost: buildah then names the directory in its own
// error, and the next look refuses with it.
func (m *Module) env() []string {
return []string{"XDG_RUNTIME_DIR=" + filepath.Join(m.Store, "xdg")}
_ = m.ensureRuntimeDir()
return []string{"XDG_RUNTIME_DIR=" + m.runtimeDir()}
}
// runtimeDir is buildah's runtime directory, inside the store.
func (m *Module) runtimeDir() string { return filepath.Join(m.Store, "xdg") }
// ensureRuntimeDir makes the store and the runtime directory inside it, 0700 and under the module's own account (the
// process runs as it), when they are not there; it changes nothing that is.
func (m *Module) ensureRuntimeDir() error {
for _, d := range []string{m.Store, m.runtimeDir()} {
if err := os.MkdirAll(d, 0o700); err != nil {
return fmt.Errorf("buildah's runtime directory %s cannot be made: %w", m.runtimeDir(), err)
}
}
return nil
}
@@ -0,0 +1,83 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
"git.novox.be/novox/mesh-sdk/go/health"
)
// buildahLike refuses as buildah does when the XDG_RUNTIME_DIR it is given is not there (seen on ace, issue 1884):
// "lstat <dir>: no such file or directory", before it does anything else.
func buildahLike(t *testing.T, run forgekey.Runner, seen *[]string) forgekey.Runner {
t.Helper()
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
if name == "buildah" {
for _, e := range env {
dir, ok := strings.CutPrefix(e, "XDG_RUNTIME_DIR=")
if !ok {
continue
}
if _, err := os.Lstat(dir); err != nil {
*seen = append(*seen, "missing")
return forgekey.Ran{Status: 125, Stderr: "Error: lstat " + dir + ": no such file or directory\n"}
}
*seen = append(*seen, "present")
}
}
return run(ctx, env, name, args...)
}
}
// 1884/AC1: given a store directory that exists without its runtime directory, when the health is looked at, then the
// module makes the runtime directory (0700) before it runs buildah, and the health is healthy.
func TestHealthMakesTheRuntimeDirectoryBeforeBuildah(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
var seen []string
m.Run = buildahLike(t, m.Run, &seen)
xdg := filepath.Join(m.Store, "xdg")
if _, err := os.Stat(xdg); !os.IsNotExist(err) {
t.Fatalf("the test needs a store without its runtime directory: %v", err)
}
a := m.Health(context.Background())
if a.State != health.StateHealthy || !a.Healthy {
t.Fatalf("health = %+v, want healthy", a)
}
st, err := os.Stat(xdg)
if err != nil {
t.Fatalf("the runtime directory was not made: %v", err)
}
if st.Mode().Perm() != 0o700 || !st.IsDir() {
t.Errorf("the runtime directory is %v, want a directory 0700", st.Mode())
}
for _, s := range seen {
if s != "present" {
t.Fatalf("buildah ran without its runtime directory: %v", seen)
}
}
}
// 1884/AC2: given a module whose runtime directory was removed after its last look, when a build step runs buildah, then
// the runtime directory is made again before buildah runs.
func TestEveryBuildahRunMakesTheRuntimeDirectoryAgain(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
var seen []string
m.Run = buildahLike(t, m.Run, &seen)
if err := m.Check(context.Background()); err != nil {
t.Fatal(err)
}
if err := os.RemoveAll(filepath.Join(m.Store, "xdg")); err != nil {
t.Fatal(err)
}
seen = nil
m.remove("localhost/x:y")
if len(seen) != 1 || seen[0] != "present" {
t.Fatalf("buildah's runs saw their runtime directory %v, want [present]", seen)
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
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
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876/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=
@@ -3,18 +3,25 @@
package main
import (
"bytes"
"context"
"fmt"
"io/fs"
"os"
"os/exec"
"path/filepath"
"slices"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// realMachine installs the real toolchain from the module's own lock (npm ci --ignore-scripts, from the mesh's
// registry for @novox, which reads without a credential) and makes two workspaces holding one fixture bundle.
// realMachine gives the tests the real toolchain of the module's own lock and makes two workspaces holding one fixture
// bundle. The toolchain is the one this node's installed Node build module keeps (ADR 0533 §1): the integration level
// is given its toolchains read-only and no network, so it is copied into the test's own cache from there. Only where
// no toolchain is installed (a desk) is it installed with `npm ci --ignore-scripts` from the mesh's registry, as the
// module does, which needs the network.
func realMachine(t *testing.T) (*Module, string) {
t.Helper()
for _, p := range []string{"node", "npm", "bwrap"} { // bwrap: tsc, esbuild and the scripts run shut off (1309)
@@ -40,8 +47,11 @@ func realMachine(t *testing.T) (*Module, string) {
return Registry{URL: "https://git.novox.be/api/packages/novox/npm/"}, nil
}}}
withStore(t, m)
if from := installedToolchain(t, filepath.Join(CacheDir, "toolchain"), filepath.Join(home, "toolchain", "package-lock.json")); from != "" {
copyToolchain(t, from, m.Toolchain.Paths.Dir(m.Recipe.Version()))
}
if err := m.Toolchain.Ensure(context.Background(), m.Recipe.Version()); err != nil {
t.Fatal(err)
t.Fatalf("no toolchain of this lock is installed under %s, and installing it from the registry failed: %v", filepath.Join(CacheDir, "toolchain"), err)
}
for _, id := range []string{"w1", "w2"} {
for p, body := range map[string]string{
@@ -115,3 +125,74 @@ func TestAPackageWhoseTestsImportItsBuildPassesTheUnitLevel(t *testing.T) {
t.Errorf("result = %+v", r)
}
}
// installedToolchain is the toolchain directory under toolchains that this node's Node build module installed: the
// one installed from the lock at lockPath, byte for byte, or, when the change carries a lock no delivered release has
// installed yet, the one installed last, said in the test's log (ADR 0533 §3: a new lock is proven by the module that
// installs it on its first node). "" when none is there or the directory cannot be read: no Node build module here.
func installedToolchain(t *testing.T, toolchains, lockPath string) string {
t.Helper()
want, err := os.ReadFile(lockPath)
if err != nil {
t.Fatal(err)
}
entries, err := os.ReadDir(toolchains)
if err != nil {
return ""
}
last, lastAt := "", time.Time{}
for _, e := range entries {
dir := filepath.Join(toolchains, e.Name())
mark, err := os.Stat(filepath.Join(dir, installedMark))
if err != nil {
continue
}
got, err := os.ReadFile(filepath.Join(dir, "package-lock.json"))
if err != nil {
continue
}
if bytes.Equal(got, want) {
t.Logf("the toolchain is this node's installed %s, of this change's lock", dir)
return dir
}
if mark.ModTime().After(lastAt) {
last, lastAt = dir, mark.ModTime()
}
}
if last != "" {
t.Logf("NOT THIS LOCK: no toolchain of this change's lock is installed here; the tests run with the one installed last, %s", last)
}
return last
}
// copyToolchain copies the installed toolchain from into to whole, files and folders with their modes, so the test's
// steps run behind a toolchain of their own and the installed one is only read.
func copyToolchain(t *testing.T, from, to string) {
t.Helper()
err := filepath.WalkDir(from, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(from, p)
dst := filepath.Join(to, rel)
info, err := d.Info()
if err != nil {
return err
}
switch {
case d.IsDir():
return os.MkdirAll(dst, 0o755)
case info.Mode().IsRegular():
raw, err := os.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(dst, raw, info.Mode().Perm()|0o600)
default:
return fmt.Errorf("%s is neither a file nor a folder", p)
}
})
if err != nil {
t.Fatalf("copying the installed toolchain %s: %v", from, err)
}
}
@@ -10,6 +10,7 @@ package main
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
@@ -53,14 +54,17 @@ func (t Toolchain) Present(version string) bool {
// are copied in, and `npm ci --ignore-scripts` runs once, the mesh's registry named for the @novox scope. A directory
// an earlier install left unfinished is removed first.
func (t Toolchain) Ensure(ctx context.Context, version string) error {
if t.Present(version) {
return nil
}
dir := t.Paths.Dir(version)
if t.Present(version) {
if opened(dir) {
return nil
}
return t.open(dir)
}
if err := os.RemoveAll(dir); err != nil {
return err
}
if err := os.MkdirAll(dir, 0o700); err != nil {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
for _, name := range []string{"package.json", "package-lock.json"} {
@@ -76,18 +80,95 @@ func (t Toolchain) Ensure(ctx context.Context, version string) error {
if err != nil {
return err
}
npmrc := filepath.Join(dir, ".npmrc-install")
if err := os.WriteFile(npmrc, []byte(reg.Npmrc()), 0o600); err != nil {
// The credential is written in the account's own home (0700), never beneath the cache, whose toolchains are
// opened to every account (ADR 0533 §2), and removed as soon as npm has read it.
f, err := os.CreateTemp(t.Paths.Home, ".npmrc-install-")
if err != nil {
return err
}
npmrc := f.Name()
defer os.Remove(npmrc)
_, err = f.WriteString(reg.Npmrc())
if cerr := f.Close(); err == nil {
err = cerr
}
if err != nil {
return err
}
ran := t.Run(ctx, []string{"PATH=" + pathOrDefault(), "HOME=" + t.Paths.Home, "LC_ALL=C", "npm_config_cache=" + filepath.Join(t.Paths.Cache, "npm")},
"npm", "ci", "--ignore-scripts", "--no-bin-links", "--no-audit", "--no-fund", "--prefix", dir, "--userconfig", npmrc)
os.Remove(npmrc)
if ran.Err != "" || ran.Status != 0 {
return fmt.Errorf("npm ci --ignore-scripts for the toolchain %s failed (status %d %s): %s",
version, ran.Status, ran.Err, tail(ran.Stderr))
}
return os.WriteFile(filepath.Join(dir, installedMark), []byte(version+"\n"), 0o600)
if err := os.WriteFile(filepath.Join(dir, installedMark), []byte(version+"\n"), 0o600); err != nil {
return err
}
return t.open(dir)
}
// refuseNpmConfig refuses a toolchain folder that holds an install's npm config file (one a dead install of an older release left with
// the registry credential in it), naming it, before anything of the folder is opened.
func refuseNpmConfig(dir string) error {
return filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && strings.HasPrefix(d.Name(), ".npmrc-install") {
return fmt.Errorf("the toolchain %s holds the npm config file %s, which may hold the registry credential, "+
"so it is not opened to other accounts; remove it", dir, p)
}
return nil
})
}
// opened says whether the toolchain directory dir was opened to other accounts: its installed mark, opened last, is
// readable by others.
func opened(dir string) bool {
st, err := os.Stat(filepath.Join(dir, installedMark))
return err == nil && st.Mode().Perm()&0o004 != 0
}
// open lets every account read the toolchain directory dir and nobody but its owner write it (ADR 0533 §2): the
// toolchains folder and every folder beneath dir 0755, every file 0644 and every program 0755, whatever umask npm ran
// under; the installed mark last, so a toolchain opened only in part is opened again. The other build module's
// integration level reads it through the cache directory, which others may enter and not list (0711); the npm cache
// beside it stays the owner's.
func (t Toolchain) open(dir string) error {
if err := refuseNpmConfig(dir); err != nil {
return err
}
if err := os.Chmod(filepath.Join(t.Paths.Cache, "toolchain"), 0o755); err != nil {
return err
}
mark := filepath.Join(dir, installedMark)
err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if p == mark {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
switch mode := info.Mode(); {
case mode.IsDir():
return os.Chmod(p, 0o755)
case mode.IsRegular() && mode.Perm()&0o100 != 0:
return os.Chmod(p, 0o755)
case mode.IsRegular():
return os.Chmod(p, 0o644)
default:
return fmt.Errorf("%s is neither a file nor a folder, and a toolchain holds only those", p)
}
})
if err != nil {
return fmt.Errorf("the toolchain %s cannot be opened to the integration level: %w", dir, err)
}
return os.Chmod(mark, 0o644)
}
func tail(s string) string {
@@ -0,0 +1,166 @@
package main
import (
"context"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
// npmAsUmask077 installs as npm does under a umask of 077: node_modules with a package whose folders are 0700, its
// file 0600 and its program 0700.
func npmAsUmask077(f *fakeNpm) forgekey.Runner {
return func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
ran := f.run(ctx, env, name, args...)
for i, a := range args {
if a == "--prefix" {
pkg := filepath.Join(args[i+1], "node_modules", "typescript", "bin")
os.MkdirAll(pkg, 0o700)
os.WriteFile(filepath.Join(args[i+1], "node_modules", "typescript", "package.json"), []byte("{}"), 0o600)
os.WriteFile(filepath.Join(pkg, "tsc"), []byte("#!/bin/sh\n"), 0o700)
}
}
return ran
}
}
// checkOpen fails unless every folder from the cache's toolchain down lets others enter and read it and every file lets
// others read it, a program others run, and nothing lets anyone but the owner write.
func checkOpen(t *testing.T, tc Toolchain) {
t.Helper()
root := filepath.Join(tc.Paths.Cache, "toolchain")
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
mode := info.Mode().Perm()
switch {
case mode&0o022 != 0:
t.Errorf("%s is %o: writable by others than its owner", p, mode)
case d.IsDir() && mode&0o005 != 0o005:
t.Errorf("%s is %o: others may not enter and read it", p, mode)
case !d.IsDir() && mode&0o004 == 0:
t.Errorf("%s is %o: others may not read it", p, mode)
case !d.IsDir() && mode&0o100 != 0 && mode&0o001 == 0:
t.Errorf("%s is %o: a program others may not run", p, mode)
}
return nil
})
if err != nil {
t.Fatal(err)
}
}
// 1891 (ADR 0533 §2): a toolchain the module installs can be read by the other build module's integration level:
// its folders 0755 and its files 0644 (programs 0755), whatever umask npm ran under, and writable by its owner alone.
func TestAnInstalledToolchainIsReadableByOthersAndWritableByItsOwnerAlone(t *testing.T) {
fake := &fakeNpm{}
tc := toolchainIn(t, fake)
tc.Run = npmAsUmask077(fake)
npm := filepath.Join(tc.Paths.Cache, "npm")
if err := os.MkdirAll(npm, 0o700); err != nil {
t.Fatal(err)
}
if err := tc.Ensure(context.Background(), "v1"); err != nil {
t.Fatal(err)
}
checkOpen(t, tc)
if st, err := os.Stat(npm); err != nil || st.Mode().Perm() != 0o700 {
t.Errorf("the npm download cache beside the toolchains is %v (%v), want 0700: only the toolchains are opened", st.Mode().Perm(), err)
}
}
// 1891 (ADR 0533 §2): a toolchain installed before this release, shut to its owner, is opened by the next Ensure
// without installing it again.
func TestAToolchainInstalledShutIsOpenedWithoutAnInstall(t *testing.T) {
fake := &fakeNpm{}
tc := toolchainIn(t, fake)
dir := tc.Paths.Dir("v1")
os.MkdirAll(filepath.Join(dir, "node_modules", "esbuild", "bin"), 0o700)
os.WriteFile(filepath.Join(dir, "node_modules", "esbuild", "bin", "esbuild"), []byte("x"), 0o700)
os.WriteFile(filepath.Join(dir, "package-lock.json"), []byte("{}"), 0o600)
os.WriteFile(filepath.Join(dir, installedMark), []byte("v1\n"), 0o600)
os.Chmod(filepath.Join(tc.Paths.Cache, "toolchain"), 0o700)
if err := tc.Ensure(context.Background(), "v1"); err != nil {
t.Fatal(err)
}
if len(fake.calls) != 0 {
t.Errorf("npm ran %d times for a toolchain already there", len(fake.calls))
}
checkOpen(t, tc)
}
// 1891 (ADR 0533 §2): the cache directory lets other accounts enter it and not list it, so the toolchains beneath it
// can be reached by their path and the npm cache beside them (0700) cannot.
func TestTheCacheDirectoryIsEnteredByOthersNotListed(t *testing.T) {
r := resource(t, readManifest(t), "cache")
if r["mode"] != "0711" {
t.Errorf("the cache directory's mode is %v, want 0711", r["mode"])
}
}
// 1891 review (ADR 0533 §2): the registry credential npm is told is never written beneath the cache, so opening a
// toolchain can never open it: npm reads it from a file in the account's own home, readable by its owner alone, and
// the file is gone when Ensure returns.
func TestTheInstallCredentialIsNeverInTheFolderThatIsOpened(t *testing.T) {
fake := &fakeNpm{}
tc := toolchainIn(t, fake)
var userconfig string
inner := npmAsUmask077(fake)
tc.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
for i, a := range args {
if a == "--userconfig" {
userconfig = args[i+1]
}
}
if st, err := os.Stat(userconfig); err != nil || st.Mode().Perm() != 0o600 {
t.Errorf("the credential file %s is %v (%v), want 0600", userconfig, st.Mode().Perm(), err)
}
return inner(ctx, env, name, args...)
}
if err := tc.Ensure(context.Background(), "v1"); err != nil {
t.Fatal(err)
}
if rel, err := filepath.Rel(tc.Paths.Home, userconfig); err != nil || rel != filepath.Base(userconfig) {
t.Errorf("the credential file %s is not in the account's home %s", userconfig, tc.Paths.Home)
}
if _, err := os.Stat(userconfig); err == nil {
t.Errorf("the credential file %s is left after the install", userconfig)
}
filepath.WalkDir(tc.Paths.Cache, func(p string, d fs.DirEntry, err error) error {
if err == nil && strings.Contains(d.Name(), "npmrc") {
t.Errorf("a credential file %s lies beneath the cache", p)
}
return nil
})
}
// 1891 review (ADR 0533 §2): open refuses a toolchain folder that holds an npm config file, naming it, and opens
// nothing of it, so a credential left by a dead install is never made readable.
func TestOpenRefusesAFolderHoldingAnNpmConfig(t *testing.T) {
fake := &fakeNpm{}
tc := toolchainIn(t, fake)
dir := tc.Paths.Dir("v1")
os.MkdirAll(filepath.Join(dir, "node_modules"), 0o700)
left := filepath.Join(dir, ".npmrc-install")
os.WriteFile(left, []byte("//x/:_auth=secret\n"), 0o600)
os.WriteFile(filepath.Join(dir, installedMark), []byte("v1\n"), 0o600)
err := tc.Ensure(context.Background(), "v1")
if err == nil || !strings.Contains(err.Error(), left) {
t.Errorf("Ensure = %v, want a refusal naming %s", err, left)
}
if st, _ := os.Stat(left); st.Mode().Perm() != 0o600 {
t.Errorf("the credential file was opened to %o", st.Mode().Perm())
}
if st, _ := os.Stat(dir); st.Mode().Perm()&0o005 != 0 {
t.Errorf("the folder holding it was opened to %o", st.Mode().Perm())
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876/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/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+1 -1
View File
@@ -87,7 +87,7 @@
"id": "cache",
"type": "directory",
"path": "/var/lib/mesh-build-node-cache",
"mode": "0700",
"mode": "0711",
"owner": "mesh-build-node",
"unknown": "program"
},
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-delivery-gate
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
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
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876/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=
@@ -120,7 +120,7 @@ type Client struct {
// Now dates the reports; nil is time.Now.
Now func() time.Time
// SocketUp wakes the client when the node-engine's write socket is made again (watchSocket); nil never does.
// A module whose write failed for want of the socket is filled again then (issue 1165), and nothing else is.
// A module whose write failed is filled again then (issue 1165, issue 1873), and nothing else is.
SocketUp <-chan struct{}
// watchBound watches a provider's bucket of entries for this machine's keys, once per run (consumers.go); set by
@@ -150,7 +150,8 @@ type state struct {
// of the consumers left out of one of its files.
providing map[string]bool
leftOut map[string]string
// unplaced is a module whose last fill failed for want of the node-engine's write socket (issue 1165).
// unplaced is a module whose last fill failed, for want of the node-engine's write socket (issue 1165) or by the
// engine's own refusal (issue 1873): filled again when the socket is made again.
unplaced map[string]bool
// bound is each provider's bucket of entries this client watches, by key: this machine's consumers' entries alone;
// boundReady is a bucket whose current entries are all read, and boundUsers the modules filled from each.
@@ -705,9 +706,10 @@ func (c *Client) reconcile(ctx context.Context, kvs map[string]jetstream.KeyValu
err = errFill(a)
}
if err != nil {
if socketAbsent(err) {
s.unplaced[module] = true
}
// Filled again when a node-engine makes its socket again: one gone (issue 1165), or one that refused for a
// reason of its own, which a newer engine may write (issue 1873: "is a directory", what a container
// runtime left at the path, cleared by the engine that followed).
s.unplaced[module] = true
failed = append(failed, fmt.Sprintf("%s: %v", f.Path, err))
continue
}
@@ -106,3 +106,38 @@ func TestWatchSocketWakesWhenTheSocketIsMadeAndWhenItsDirectoryReturns(t *testin
defer l.Close()
heard("the directory and socket back")
}
// Issue 1873: a fill the node-engine refused for a reason of its own (on ace, "read …/superuser.secret: is a
// directory", what a container runtime had left there) is tried again when a node-engine makes its socket again, as a
// newer engine may write what the older could not. Before, only a fill that failed for want of the socket was, so
// ace's postgres stayed unplaced after the engine that cures it was delivered, and its old failure was said every 30 s.
func TestNatsAFillTheEngineFailedIsTriedWhenTheSocketReturns(t *testing.T) {
b := startBus(t)
heard := b.listen(t)
e := newEngine(t, map[string]string{dunstrc: "owned"})
if err := os.MkdirAll(e.at(dunstrc), 0o755); err != nil {
t.Fatal(err)
}
stop := e.listen(t)
b.config(t, "dunst", "font-size", "13")
b.assign(t, "dunst", dunst(t))
aClient(t, b, e, t.TempDir())
eventually(t, "the engine's failure is said", func() bool {
r, _ := heard.last("dunst")
return r.Status == StatusFailed && strings.Contains(r.Reason, "is a directory")
})
// The node-engine replaces itself with one that clears what the runtime left.
stop()
if err := os.Remove(e.at(dunstrc)); err != nil {
t.Fatal(err)
}
e.listen(t)
eventually(t, "dunstrc is written by the new engine", func() bool {
return strings.Contains(e.read(t, dunstrc), "Monospace 13")
})
eventually(t, "and the report says applied", func() bool {
r, _ := heard.last("dunst")
return r.Status == StatusApplied
})
}
+2
View File
@@ -0,0 +1,2 @@
# The test script compiles the sources beside themselves before the tests import them (package.json, issue 1891).
*.js
+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
"test": "tsc --module NodeNext --moduleResolution NodeNext --target ES2022 --types node --rootDir . --outDir . $(find . -name '*.ts' ! -name '*.d.ts' ! -path './test/*' ! -path './node_modules/*' ! -path './dist/*') && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.7"
+1 -1
View File
@@ -7,7 +7,7 @@
"scripts": {
"build": "tsc topics.ts client.ts index.ts tools/index.ts provisioner/index.ts bootstrap/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"typecheck": "tsc -p tsconfig.json",
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.1"
@@ -83,7 +83,7 @@ func TestTheKeeperIsAProcessUnderItsOwnAccountWithABusAccountOfItsOwn(t *testing
t.Errorf("the bus account is the keeper's own secret, owned by its account: %v", secrets)
}
state := m.resource(t, "state")
if state["type"] != "directory" || state["place"] != "." || state["unknown"] == nil {
if state["type"] != "directory" || state["place"] != "mesh" || state["unknown"] == nil {
t.Errorf("the state directory: %v", state)
}
order := map[string]int{}
@@ -211,3 +211,37 @@ func TestUpgradeOrderIsATrustedListOfTheMesh(t *testing.T) {
t.Error("a value falls back only to a key of its own type (ADR 0348 §4)")
}
}
// 1841/AC17: the keeper's state directory is the mesh's directory for the module (place "mesh", <data root>/mesh/pacman),
// never place "." or a placement by default: for this module both resolve under /var/lib/pacman, the package manager's
// own database directory (issue 1841: the node-engine used it as found and reported it unhealthy, and wrote keeper.json
// into it). What the keeper and its root helper keep there is still named through ${dir:state}.
func TestTheStateDirectoryIsOutsideThePackageManagersOwn(t *testing.T) {
m := rawManifest(t)
resources, _ := m["resources"].([]any)
var state map[string]any
for _, x := range resources {
if r, _ := x.(map[string]any); r["id"] == "state" {
state = r
}
}
if state == nil || state["type"] != "directory" {
t.Fatalf("no state directory: %v", state)
}
if state["place"] != "mesh" || state["path"] != nil {
t.Fatalf("the state directory says place %v and path %v; it is placed \"mesh\" with no path, so it resolves to "+
"/var/lib/mesh/pacman and not into /var/lib/pacman", state["place"], state["path"])
}
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
if bytes.Contains(raw, []byte("/var/lib/pacman")) {
t.Fatal("the manifest names /var/lib/pacman")
}
for _, want := range []string{"${dir:state}/keeper.json", "${dir:state}/broker-keeper.secret", RootSocketPath} {
if !bytes.Contains(raw, []byte(want)) {
t.Errorf("the manifest no longer names %s", want)
}
}
}
+7 -2
View File
@@ -248,12 +248,12 @@ func tools(m *Machine) []stdio.Tool {
Name: "pacman_upgrades",
Description: "Every full upgrade and rollback of every node, from the bus (ADR 0508 §1): each run's node, kind, what started it, " +
"its round, when it began and ended, its outcome (refused, finished, failed or running) and whether its after-upgrade check passed; " +
"narrowed to a node and to days, newest first. With a node and a run, that run whole, every step with its time.",
"narrowed to a node and to days, newest first. With a run, that run whole, every step with its time: the node's named, else this one's.",
Input: schema(map[string]any{
"node": map[string]any{"type": "string", "description": "only this node's runs"},
"from": sinceArg,
"until": map[string]any{"type": "string", "description": "a day, YYYY-MM-DD, the last one listed"},
"run": map[string]any{"type": "string", "description": "one run, by its id, with node"},
"run": map[string]any{"type": "string", "description": "one run, by its id: of node, else of this node"},
"limit": limitArg(50, 1000),
}),
Run: func(args map[string]any) (any, error) {
@@ -269,6 +269,11 @@ func tools(m *Machine) []stdio.Tool {
if err != nil {
return nil, err
}
if v[3] != "" && v[0] == "" {
// An address naming the machine has its node taken out of the arguments (the console):
// the run is this machine's (issue 1841).
v[0] = thisNode()
}
return ListUpgrades(upgradeState(StateRuns), v[0], v[1], v[2], v[3], n)
},
},
@@ -57,7 +57,7 @@ func TestPacmanConfIsWholeTheUnionOfRepositoriesAndTheImprovedOptions(t *testing
t.Skip("pacman-conf is not installed here; the rendered file is not proven")
}
file := filepath.Join(t.TempDir(), "pacman.conf")
if err := os.WriteFile(file, []byte(content), 0o644); err != nil {
if err := os.WriteFile(file, []byte(provenConf(t, content, filepath.Dir(file))), 0o644); err != nil {
t.Fatal(err)
}
repos, err := exec.Command(conf, "--config", file, "--repo-list").CombinedOutput()
@@ -74,6 +74,35 @@ func TestPacmanConfIsWholeTheUnionOfRepositoriesAndTheImprovedOptions(t *testing
}
}
// Every repository of the shipped file reads the mirror list reflector writes, and the file pacman-conf proves reads
// nothing of the machine: the unit level runs shut off (novox/hq ADR 0448), where /etc/pacman.d is not there
// (issue 1891).
func TestTheProvenConfReadsNothingOfTheMachine(t *testing.T) {
content := templateText(t, manifest(t).resource(t, "config"))
if n := strings.Count(content, "Include = "+Mirrorlist+"\n"); n != 3 || strings.Count(content, "Include") != n {
t.Fatalf("every repository reads %s, and nothing else is included: %d of 3\n%s", Mirrorlist, n, content)
}
dir := t.TempDir()
proven := provenConf(t, content, dir)
if strings.Contains(proven, "/etc/") {
t.Fatalf("the proven file still names the machine's /etc:\n%s", proven)
}
if _, err := os.Stat(filepath.Join(dir, "mirrorlist")); err != nil {
t.Fatalf("the proven file's mirror list is not in its directory: %v", err)
}
}
// provenConf is the shipped pacman.conf with its repositories reading a mirror list of its own in dir, made there,
// so pacman-conf proves the file the module ships and reads nothing of the machine.
func provenConf(t *testing.T, content, dir string) string {
t.Helper()
list := filepath.Join(dir, "mirrorlist")
if err := os.WriteFile(list, []byte("Server = https://mirror.example/$repo/os/$arch\n"), 0o644); err != nil {
t.Fatal(err)
}
return strings.ReplaceAll(content, "Include = "+Mirrorlist, "Include = "+list)
}
func TestReflectorWritesTheListPacmanReads(t *testing.T) {
content := templateText(t, manifest(t).resource(t, "mirrors"))
opts := map[string]string{}
+1 -1
View File
@@ -228,7 +228,7 @@ func (u *Upgrades) StartRollback(of string, removeAdded bool, ask string) (Run,
if o.Run.Run == r.Run.Run {
return r.Run, fmt.Errorf("run %s of %s is there already: one run a second", o.Run.Run, u.Node)
}
if !ended(o.Run) && o.From != FromLog {
if holdsTheDatabase(o.Run) {
return r.Run, u.refuse(r, fmt.Sprintf("run %s is still going (its last step is %s): one run at a time", o.Run.Run, lastStep(o.Run)))
}
}
@@ -0,0 +1,115 @@
package main
// A run read from pacman's log, or a run whose transaction ended, never holds a new run back (issue 1841, the
// refusal of 2026-10-22 22:00 on the control-node: "run 20220123T194900Z is still going (its last step is finished): one
// run at a time"); and pacman_upgrades reads one run whole at an address that names the machine.
import (
"strings"
"testing"
"time"
)
// installLogHead is the first twelve lines of the control-node's /var/log/pacman.log, captured 2026-10-22 22:10
// with node-reads.read: the install's `pacman -Syu` with nothing to do, which became run 20220123T194900Z, its
// last step finished at the moment it began (pacman_upgrades there, 2026-10-22: from log, outcome finished,
// began 2022-01-23T19:49:00Z, ended 2022-01-23T20:49:00+01:00).
const installLogHead = "[2022-01-23T20:49:00+0100] [PACMAN] Running 'pacman -Syu'\n" +
"[2022-01-23T20:49:00+0100] [PACMAN] synchronizing package lists\n" +
"[2022-01-23T20:49:00+0100] [PACMAN] starting full system upgrade\n" +
"[2022-01-23T20:50:45+0100] [PACMAN] Running 'pacman -S nano'\n" +
"[2022-01-23T20:50:49+0100] [ALPM] transaction started\n" +
"[2022-01-23T20:50:50+0100] [ALPM] installed nano (6.0-1)\n" +
"[2022-01-23T20:50:50+0100] [ALPM] transaction completed\n" +
"[2022-01-23T20:50:50+0100] [ALPM] running '30-systemd-update.hook'...\n" +
"[2022-01-23T20:55:31+0100] [PACMAN] Running 'pacman -S docker'\n" +
"[2022-01-23T20:55:36+0100] [ALPM] transaction started\n" +
"[2022-01-23T20:55:36+0100] [ALPM] installed bridge-utils (1.7.1-1)\n" +
"[2022-01-23T20:55:36+0100] [ALPM] installed libtool (2.4.6+42+gb88cebd5-16)\n"
// 1841/AC20: given the run read from the control-node's log, when an upgrade is asked, then it starts.
func TestARunReadFromTheLogIsHistoryAndNeverHoldsAnAsk(t *testing.T) {
k := newKeeperBed(t)
past := PastUpgrades("node-a", installLogHead)
if len(past) != 1 || past[0].Run != "20220123T194900Z" || steps(past[0]) != "started finished" || past[0].From != FromLog {
t.Fatalf("the captured log reads as the record the control-node holds: %+v", past)
}
if _, err := k.runs.Create(NodeKey("node-a", past[0].Run), past[0]); err != nil {
t.Fatal(err)
}
if _, err := k.u.StartUpgrade(FromAsk, "", "a1"); err != nil {
t.Fatal(err)
}
var r Run
k.runs.value("node-a.20261004T120000Z", &r)
if lastStep(r) == StepRefused || len(k.root.started) != 1 {
t.Errorf("a run read from the log held the ask back: %s %+v", steps(r), r.Steps)
}
}
// 1841/AC21: given a run the mesh ran whose last step is finished, failed or refused (no after-upgrade check
// yet, no end time of the run itself), when another run would start, then the earlier one does not hold it;
// a run whose transaction may still go on (no step, recorded, checked before, started) does.
func TestARunWhoseTransactionEndedNeverHoldsTheNext(t *testing.T) {
at := time.Date(2026, 10, 22, 20, 0, 0, 0, time.UTC)
for last, holds := range map[string]bool{
StepFinished: false, StepFailed: false, StepRefused: false, StepCheckedAfter: false,
"": true, StepRecorded: true, StepCheckedBefore: true, StepStarted: true,
} {
r := Run{Node: "node-a", Run: "20261022T200000Z", Kind: RunUpgrade, From: FromAsk}
if last != "" {
r.Steps = []Step{{Step: last, At: at}}
}
if got := holdsTheDatabase(r); got != holds {
t.Errorf("last step %q: holds %v, want %v", last, got, holds)
}
r.From = FromLog
if holdsTheDatabase(r) {
t.Errorf("a run read from the log, last step %q, holds the next", last)
}
}
k := newKeeperBed(t)
done := Run{Node: "node-a", Run: "20261004T110000Z", Kind: RunUpgrade, From: FromAsk,
Steps: []Step{{Step: StepStarted, At: k.now}, {Step: StepFinished, At: k.now}}}
if _, err := k.runs.Create(NodeKey("node-a", done.Run), done); err != nil {
t.Fatal(err)
}
if _, err := k.u.StartUpgrade(FromAsk, "", "a1"); err != nil {
t.Fatal(err)
}
var r Run
k.runs.value("node-a.20261004T120000Z", &r)
if lastStep(r) == StepRefused {
t.Errorf("a finished run held the next: %+v", r.Steps)
}
}
// 1841/AC22: given an address that names the machine (the console takes `node` out of the arguments), when
// pacman_upgrades is asked for one run, then that machine's run is answered whole.
func TestPacmanUpgradesReadsOneRunOfThisNodeWithoutANode(t *testing.T) {
runs := newBucket()
past := PastUpgrades("node-a", installLogHead)[0]
if _, err := runs.Create(NodeKey("node-a", past.Run), past); err != nil {
t.Fatal(err)
}
savedState, savedNode := upgradeState, thisNode
upgradeState = func(string) Bucket { return runs }
thisNode = func() string { return "node-a" }
defer func() { upgradeState, thisNode = savedState, savedNode }()
for _, tool := range tools(machine(nil, 1000)) {
if tool.Name != "pacman_upgrades" {
continue
}
out, err := tool.Run(map[string]any{"run": past.Run})
if err != nil {
t.Fatalf("one run without a node: %v", err)
}
whole := out.(map[string]any)["run"].(Run)
if whole.Node != "node-a" || steps(whole) != "started finished" || !strings.Contains(whole.Steps[1].Why, "no transaction") {
t.Errorf("one run whole: %+v", whole)
}
return
}
t.Fatal("no pacman_upgrades")
}
+17 -1
View File
@@ -63,6 +63,22 @@ func ended(r Run) bool {
return false
}
// holdsTheDatabase says whether a run holds a new run back (one run at a time, ADR 0508 §8): only a run the
// mesh ran whose transaction may still go on. A run read from pacman's log is history (ADR 0508 §1), and a run
// whose last step is finished, failed, refused or checked after has no transaction left: its after-upgrade
// check, still to be written by Follow, reads and changes nothing (issue 1841: every run read from the log
// ended at finished and held every ask back).
func holdsTheDatabase(r Run) bool {
if r.From == FromLog {
return false
}
switch lastStep(r) {
case StepFinished, StepFailed, StepRefused, StepCheckedAfter:
return false
}
return true
}
// ownRuns are the node's runs, oldest first, each with the revision of its key.
func (u *Upgrades) ownRuns() ([]openRun, error) {
keys, err := u.Runs.Keys()
@@ -117,7 +133,7 @@ func (u *Upgrades) startUpgrade(r *openRun) error {
if o.Run.Run == r.Run.Run {
return fmt.Errorf("run %s of %s is there already: one run a second", o.Run.Run, u.Node)
}
if !ended(o.Run) {
if holdsTheDatabase(o.Run) {
return u.refuse(r, fmt.Sprintf("run %s is still going (its last step is %s): one run at a time", o.Run.Run, lastStep(o.Run)))
}
}
+1 -1
View File
@@ -3,7 +3,7 @@ module pacman
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022190740-104533e8c0c9
github.com/nats-io/nats-server/v2 v2.11.17
)
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876 h1:dtiIJzrX7ey1wmsdzapiFgCKDllCph7/vxfUp1ib/t8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261022183749-875c680d9876/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=
+1 -1
View File
@@ -147,7 +147,7 @@
"id": "state",
"type": "directory",
"mode": "0711",
"place": ".",
"place": "mesh",
"unknown": "program"
},
{
+2 -1
View File
@@ -119,7 +119,8 @@ what is declared, refusing secrets, taking back what is no longer declared, and
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data}` (`/var/lib/mesh/<module>`): everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:state}`: the package's own directory, declared for one delivery with the package's owner and mode so the node-engine gives it back (issue 1873); the module keeps nothing there.
- `${dir:grants}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `/var/lib/mesh-store`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:store-data}/dumps`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
@@ -0,0 +1,94 @@
package main
// The module's directories (novox/hq issue 1873): its state directory said place ".", which is /var/lib/postgres,
// the postgresql package's own (tmpfiles.d/postgresql.conf: postgres, 0700), and its grants directory, placed by
// default, lay inside it; the node-engine gave /var/lib/postgres to the operator's account. What the module keeps
// moves to the mesh's directory for it, and for one delivery the record "state" states the package's owner and mode,
// so the node-engine, which takes a directory it recorded at the same path under the same id as its own, gives it
// back. The store's data is not touched: it is /var/lib/mesh-store, as before.
import (
"encoding/json"
"os"
"reflect"
"strings"
"testing"
)
func postgresManifest(t *testing.T) (map[string]map[string]any, map[string]any) {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), "${dir:state}") {
t.Errorf("the manifest still keeps something in ${dir:state}, which is /var/lib/postgres")
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
out := map[string]map[string]any{}
list, _ := m["resources"].([]any)
for _, x := range list {
r, _ := x.(map[string]any)
out[r["id"].(string)] = r
}
return out, m
}
// 1873/AC2: secrets, consumers and grants are kept in <data root>/mesh/postgres; "state" gives /var/lib/postgres back
// to postgres, 0700; the store's data and dumps are as they were.
func TestTheModuleKeepsItsFilesOutsideThePackagesDirectory(t *testing.T) {
rs, m := postgresManifest(t)
data := rs["data"]
if data == nil || data["type"] != "directory" || data["place"] != "mesh" || data["path"] != nil || data["mode"] != "0700" {
t.Fatalf("the module's directory is placed \"mesh\" with no path, 0700 (/var/lib/mesh/postgres): %v", data)
}
if g := rs["grants"]; g == nil || g["path"] != "${dir:data}/grants" || g["place"] != nil {
t.Errorf("grants lies in the module's directory: %v", g)
}
if c := rs["consumers"]; c == nil || c["path"] != "${dir:data}/consumers.json" {
t.Errorf("consumers.json lies in the module's directory: %v", c)
}
own, _ := m["own-secrets"].(map[string]any)
if own["superuser"] != "${dir:data}/superuser.secret" || own["reader"] != "${dir:data}/reader.secret" {
t.Errorf("the own secrets lie in the module's directory: %v", own)
}
state := rs["state"]
if state == nil || state["type"] != "directory" || state["path"] != "/var/lib/postgres" || state["owner"] != "postgres" ||
state["mode"] != "0700" || state["place"] != nil {
t.Errorf("state gives /var/lib/postgres back as postgresql declares it, postgres 0700: %v", state)
}
// The store's data, its dumps and how the container reads them: exactly as before.
if s := rs["store-data"]; !reflect.DeepEqual(s, map[string]any{"id": "store-data", "type": "directory",
"path": "/var/lib/mesh-store", "mode": "0700", "owner": "999:70", "unknown": "program"}) {
t.Errorf("store-data changed: %v", s)
}
if d := rs["dumps"]; !reflect.DeepEqual(d, map[string]any{"id": "dumps", "type": "directory",
"path": "${dir:store-data}/dumps", "mode": "0700", "owner": "999:70", "unknown": "program"}) {
t.Errorf("dumps changed: %v", d)
}
server := rs["server"]
if !reflect.DeepEqual(server["volumes"], []any{"/var/lib/mesh-store:/var/lib/postgresql/data",
"${dir:data}/superuser.secret:/run/secrets/superuser:ro"}) {
t.Errorf("the container's volumes: %v", server["volumes"])
}
if env, _ := server["env"].(map[string]any); env["PGDATA"] != "/var/lib/postgresql/data/pgdata" {
t.Errorf("PGDATA changed: %v", env)
}
build, _ := m["build"].(map[string]any)
artifacts, _ := build["artifacts"].([]any)
env := artifacts[0].(map[string]any)["env"].(map[string]any)
for key, want := range map[string]string{
"MESH_PROVISION_PASSWORD_FILE": "${dir:data}/superuser.secret",
"MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:data}/reader.secret",
"MESH_CONSUMERS_FILE": "${dir:data}/consumers.json",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
} {
if env[key] != want {
t.Errorf("%s is %v, not %s", key, env[key], want)
}
}
}
+18 -9
View File
@@ -61,8 +61,8 @@
"postgres-database": "${dir:grants}"
},
"own-secrets": {
"superuser": "${dir:state}/superuser.secret",
"reader": "${dir:state}/reader.secret"
"superuser": "${dir:data}/superuser.secret",
"reader": "${dir:data}/reader.secret"
},
"data": {
"own": [
@@ -93,22 +93,31 @@
},
"resources": [
{
"id": "state",
"id": "data",
"type": "directory",
"mode": "0700",
"place": ".",
"place": "mesh",
"unknown": "program"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/postgres",
"owner": "postgres",
"mode": "0700",
"unknown": "program"
},
{
"id": "grants",
"type": "directory",
"path": "${dir:data}/grants",
"mode": "0700",
"unknown": "program"
},
{
"id": "consumers",
"type": "file",
"path": "${dir:state}/consumers.json",
"path": "${dir:data}/consumers.json",
"mode": "0600",
"template": "files/consumers.json"
},
@@ -146,7 +155,7 @@
],
"volumes": [
"/var/lib/mesh-store:/var/lib/postgresql/data",
"${dir:state}/superuser.secret:/run/secrets/superuser:ro"
"${dir:data}/superuser.secret:/run/secrets/superuser:ro"
]
},
{
@@ -169,10 +178,10 @@
],
"env": {
"MESH_PROVISION_POSTGRES": "postgres://postgres@127.0.0.1:${port:5432}/postgres?sslmode=disable",
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/superuser.secret",
"MESH_PROVISION_PASSWORD_FILE": "${dir:data}/superuser.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:state}/reader.secret",
"MESH_CONSUMERS_FILE": "${dir:state}/consumers.json"
"MESH_POSTGRES_READER_PASSWORD_FILE": "${dir:data}/reader.secret",
"MESH_CONSUMERS_FILE": "${dir:data}/consumers.json"
}
},
{
@@ -311,6 +311,8 @@ func TestAnUnreachableHostIsNotRefreshed(t *testing.T) {
}
func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) {
// The test's own agent, none: the account running it may have one, and the unit level runs with none (issue 1891).
t.Setenv("SSH_AUTH_SOCK", "")
ok := "debug1: Connecting to ace.internal [10.0.0.2] port 22.\ndebug1: Server accepts key: /h/.ssh/id_ed25519 ED25519 " + edFP + "\nAuthenticated to ace.internal ([10.0.0.2]:22) using \"publickey\".\n"
f := &fake{answer: func(c call) forgekey.Ran { return forgekey.Ran{Stderr: ok} }}
got, err := client(t.TempDir(), f).Test(context.Background(), "ace")
@@ -327,9 +329,15 @@ func TestTestSaysHowItAuthenticatedOrWhyNot(t *testing.T) {
if got["ok"] != false || got["why"] != "no key it offered was accepted" || !reflect.DeepEqual(got["offered"], []string{"/h/.ssh/id_box"}) {
t.Fatalf("%v", got)
}
if !strings.Contains(got["note"].(string), "agent") {
if note, _ := got["note"].(string); !strings.Contains(note, "agent") {
t.Fatalf("no word on the agent: %v", got)
}
agent := filepath.Join(t.TempDir(), "agent.sock")
t.Setenv("SSH_AUTH_SOCK", agent)
got, _ = client(t.TempDir(), f).Test(context.Background(), "ace")
if got["agent"] != agent || got["note"] != nil {
t.Fatalf("the environment's agent is not the one it ran with, or a passphrase note without a passphrase: %v", got)
}
}
func TestCheckFindsWhatIsWrongAndSaysWhatItDidNotCheck(t *testing.T) {
+2 -1
View File
@@ -6,4 +6,5 @@ The `systemd` module of the mesh catalogue: installs the package `systemd`; clai
Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416).
- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:data}` (`/var/lib/mesh/<module>`): everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
- `${dir:state}`: the package's own directory, declared for one delivery with the package's owner and mode so the node-engine gives it back (issue 1873); the module keeps nothing there.
@@ -0,0 +1,52 @@
package main
// The module's directories (novox/hq issue 1873): its state directory said place ".", which is /var/lib/systemd,
// systemd's own directory (tmpfiles.d/systemd.conf: d /var/lib/systemd 0755 root root); the node-engine gave it to
// the operator's account, mode 0700, on every machine. What the tools keep moves to the mesh's directory for the
// module, and for one delivery the record "state" states the package's owner and mode, so the node-engine, which
// takes a directory it recorded at the same path under the same id as its own, gives it back.
import (
"encoding/json"
"os"
"testing"
)
func manifestResources(t *testing.T) (map[string]map[string]any, map[string]any) {
t.Helper()
raw, err := os.ReadFile("../../module.json")
if err != nil {
t.Fatal(err)
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
out := map[string]map[string]any{}
list, _ := m["resources"].([]any)
for _, x := range list {
r, _ := x.(map[string]any)
out[r["id"].(string)] = r
}
return out, m
}
// 1873/AC1: the tools' record is kept in <data root>/mesh/systemd; "state" gives /var/lib/systemd back to root, 0755.
func TestTheToolsKeepTheirRecordOutsideSystemdsOwnDirectory(t *testing.T) {
rs, m := manifestResources(t)
data := rs["data"]
if data == nil || data["type"] != "directory" || data["place"] != "mesh" || data["path"] != nil {
t.Fatalf("the tools' directory is placed \"mesh\" with no path (/var/lib/mesh/systemd): %v", data)
}
build, _ := m["build"].(map[string]any)
artifacts, _ := build["artifacts"].([]any)
env := artifacts[0].(map[string]any)["env"].(map[string]any)
if env["MESH_SYSTEMD_STATE"] != "${dir:data}" {
t.Errorf("MESH_SYSTEMD_STATE is %v, not ${dir:data}", env["MESH_SYSTEMD_STATE"])
}
state := rs["state"]
if state == nil || state["type"] != "directory" || state["path"] != "/var/lib/systemd" || state["owner"] != "root" ||
state["mode"] != "0755" || state["place"] != nil {
t.Errorf("state gives /var/lib/systemd back as systemd declares it, root 0755: %v", state)
}
}
+11 -3
View File
@@ -41,7 +41,7 @@
"systemd-tools"
],
"env": {
"MESH_SYSTEMD_STATE": "${dir:state}"
"MESH_SYSTEMD_STATE": "${dir:data}"
}
}
]
@@ -53,10 +53,18 @@
"package": "systemd"
},
{
"id": "state",
"id": "data",
"type": "directory",
"mode": "0700",
"place": ".",
"place": "mesh",
"unknown": "program"
},
{
"id": "state",
"type": "directory",
"path": "/var/lib/systemd",
"owner": "root",
"mode": "0755",
"unknown": "program"
}
]