Compare commits

..
Author SHA1 Message Date
jschoubben 7f555d197b Probe: the placement files, opened only to read each node's shadow answer with both bodies (issue 1126); not to be merged
mesh/delivery closed: the pull request closed unmerged
mesh/check checking 7f555d197ba8 on 2a03c106f4b9
2026-10-28 01:26:30 +01:00
175 changed files with 3147 additions and 5255 deletions
+1 -1
View File
@@ -36,8 +36,8 @@ modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
/modules/mesh-build-go/mesh-build-go /modules/mesh-build-go/mesh-build-go
/modules/mesh-delivery-gate/mesh-delivery-gate /modules/mesh-delivery-gate/mesh-delivery-gate
modules/mesh-config/cmd/mesh-config/mesh-config modules/mesh-config/cmd/mesh-config/mesh-config
checks/placement/placement
# Built by their modules' build step (issue 1577), never committed. # Built by their modules' build step (issue 1577), never committed.
modules/forticlient/cmd/forticlient-tools/forticlient-tools modules/forticlient/cmd/forticlient-tools/forticlient-tools
modules/networkmanager/uplink-tools modules/networkmanager/uplink-tools
checks/words/words
+6 -12
View File
@@ -1,4 +1,4 @@
// Package nobinaries refuses a compiled program anywhere in the repository (issue 1577; first a checks/ binary slipped past a modules/-only rule): it is build output, made by the module's build step, // Package nobinaries refuses a compiled program under modules/ (issue 1577): it is build output, made by the module's build step,
// and `go build` in a module's folder writes one beside go.mod where `git add -A` takes it. // and `go build` in a module's folder writes one beside go.mod where `git add -A` takes it.
package nobinaries package nobinaries
@@ -27,10 +27,10 @@ func Kind(head []byte) string {
return "" return ""
} }
// Files are the files of the catalogue's root that the change holds, anywhere: the tracked ones when the root is a git // Files are the files of the catalogue's root that the change holds under modules/: the tracked ones when the root is a git
// checkout (an ignored binary a local build left is not the change's), else every file there but .git's. // checkout (an ignored binary a local build left is not the change's), else every file there.
func Files(root string) ([]string, error) { func Files(root string) ([]string, error) {
cmd := exec.Command("git", "-C", root, "ls-files", "-z") cmd := exec.Command("git", "-C", root, "ls-files", "-z", "--", "modules")
if out, err := cmd.Output(); err == nil && len(out) > 0 { if out, err := cmd.Output(); err == nil && len(out) > 0 {
var files []string var files []string
for _, f := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") { for _, f := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") {
@@ -41,16 +41,10 @@ func Files(root string) ([]string, error) {
return files, nil return files, nil
} }
var files []string var files []string
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error { err := filepath.WalkDir(filepath.Join(root, "modules"), func(p string, d os.DirEntry, err error) error {
if err != nil { if err != nil || d.IsDir() || !d.Type().IsRegular() {
return err return err
} }
if d.IsDir() && d.Name() == ".git" {
return filepath.SkipDir
}
if d.IsDir() || !d.Type().IsRegular() {
return nil
}
rel, _ := filepath.Rel(root, p) rel, _ := filepath.Rel(root, p)
files = append(files, rel) files = append(files, rel)
return nil return nil
+2 -37
View File
@@ -2,7 +2,6 @@ package nobinaries
import ( import (
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"testing" "testing"
) )
@@ -17,7 +16,6 @@ func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
"modules/e/short": []byte("\x7f"), "modules/e/short": []byte("\x7f"),
"modules/f/empty": {}, "modules/f/empty": {},
"modules/g/Foo.class": {0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52}, // Java, not Mach-O "modules/g/Foo.class": {0xca, 0xfe, 0xba, 0xbe, 0, 0, 0, 52}, // Java, not Mach-O
"checks/words/words": []byte("\x7fELF\x02\x01\x01\x00rest"), // outside modules/ too
} }
var names []string var names []string
for f, b := range files { for f, b := range files {
@@ -34,8 +32,8 @@ func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if len(got) != 4 { if len(got) != 3 {
t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c and checks/words/words only", got) t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c only", got)
} }
} }
@@ -54,36 +52,3 @@ func TestTheCatalogueHoldsNoCompiledProgram(t *testing.T) {
t.Errorf("%s: a compiled program is build output: remove it and add it to .gitignore (issue 1577)", f) t.Errorf("%s: a compiled program is build output: remove it and add it to .gitignore (issue 1577)", f)
} }
} }
// A compiled program outside modules/ is refused too (a binary under checks/ slipped past the modules/-only rule): Files holds
// every tracked file of a git checkout and every file of a plain folder, and Find names the binary in both.
func TestAProgramUnderChecksIsRefused(t *testing.T) {
for _, git := range []bool{true, false} {
root := t.TempDir()
p := filepath.Join(root, "checks", "words", "words")
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte("\x7fELF\x02\x01\x01\x00rest"), 0o644); err != nil {
t.Fatal(err)
}
if git {
for _, args := range [][]string{{"init", "-q"}, {"add", "-A"}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
}
files, err := Files(root)
if err != nil {
t.Fatal(err)
}
found, err := Find(root, files)
if err != nil {
t.Fatal(err)
}
if len(found) != 1 {
t.Errorf("git=%v: found %v; wanted checks/words/words (ELF)", git, found)
}
}
}
+5 -1
View File
@@ -1 +1,5 @@
# every tracked file of the repository (git ls-files, no path limit): the whole tree, so this package's verdict is never kept. # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# every file under modules/ (git ls-files and a walk).
modules
-3
View File
@@ -1,3 +0,0 @@
module git.novox.be/novox/mesh-catalog/checks/noplacement
go 1.22
-99
View File
@@ -1,99 +0,0 @@
// Command noplacement refuses what the repository path of placement left behind (novox/hq ADR 0567 §10, §12).
//
// Placement, which module runs on which node, is bus state held by the seat mesh-placement; no file of the
// catalogue says it, and no manifest reads it from the delivery indexer, which no longer keeps it. It refuses:
//
// 1. a `placement` directory (or file) at the root of the catalogue;
//
// 2. a `particulars` file at the root (the mesh's node names come from the checker's run, §16);
//
// 3. a manifest naming a state, stream, event or bucket of the indexer's placement (`mesh-delivery-indexer.placement…`
// or `mesh-delivery-indexer_placement…`) in any string value, such as `reads`.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Prefixes are the names of the indexer's placement parts; a manifest string starting with one names them.
var Prefixes = []string{"mesh-delivery-indexer.placement", "mesh-delivery-indexer_placement"}
// Findings is every refusal in the catalogue at root, sorted.
func Findings(root string) ([]string, error) {
var out []string
if _, err := os.Lstat(filepath.Join(root, "placement")); err == nil {
out = append(out, "placement: the repository holds no placement; it is bus state of the seat mesh-placement (novox/hq ADR 0567 §10)")
}
if _, err := os.Lstat(filepath.Join(root, "particulars")); err == nil {
out = append(out, "particulars: the repository lists no node names; the checker hands them to the check (novox/hq ADR 0567 §16)")
}
manifests, err := filepath.Glob(filepath.Join(root, "modules", "*", "module.json"))
if err != nil {
return nil, err
}
sort.Strings(manifests)
for _, file := range manifests {
raw, err := os.ReadFile(file)
if err != nil {
return nil, err
}
var doc any
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("%s: %w", file, err)
}
module := filepath.Base(filepath.Dir(file))
for _, s := range named(doc, nil) {
out = append(out, fmt.Sprintf("%s: names %q, which the indexer no longer keeps; placement is the seat mesh-placement's (novox/hq ADR 0567 §12)", module, s))
}
}
return out, nil
}
// named collects the strings of a decoded manifest that start with one of the Prefixes.
func named(v any, into []string) []string {
switch v := v.(type) {
case string:
for _, p := range Prefixes {
if strings.HasPrefix(v, p) {
return append(into, v)
}
}
case []any:
for _, e := range v {
into = named(e, into)
}
case map[string]any:
for _, e := range v {
into = named(e, into)
}
}
return into
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: noplacement <catalogue root>")
os.Exit(2)
}
found, err := Findings(os.Args[1])
if err != nil {
fmt.Println(err)
os.Exit(1)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
fmt.Println("no placement directory, and no manifest reads the indexer's placement")
}
-55
View File
@@ -1,55 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestFindingsRefuseWhatThePlacementPathLeft(t *testing.T) {
for _, row := range []struct {
name string
files map[string]string
want string // empty: clean
}{
{"clean", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-controller.assignments"]}`}, ""},
{"a placement directory", map[string]string{"placement/ace.json": `{}`}, "placement: the repository holds no placement"},
{"a particulars file", map[string]string{"particulars": "ace\n"}, "particulars: the repository lists no node names"},
{"a read of the indexer's placement", map[string]string{"modules/a/module.json": `{"module":"a","reads":["mesh-delivery-indexer.placement"]}`}, `a: names "mesh-delivery-indexer.placement"`},
{"a read of its candidates", map[string]string{"modules/b/module.json": `{"module":"b","reads":["mesh-delivery-indexer.placement-candidates"]}`}, "placement-candidates"},
{"its event", map[string]string{"modules/c/module.json": `{"module":"c","on":[{"event":"mesh-delivery-indexer.placement-changed"}]}`}, "placement-changed"},
{"the seat's own state is allowed", map[string]string{"modules/d/module.json": `{"module":"d","reads":["mesh-placement.placement"]}`}, ""},
} {
t.Run(row.name, func(t *testing.T) {
root := t.TempDir()
for path, text := range row.files {
p := filepath.Join(root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, []byte(text), 0o644); err != nil {
t.Fatal(err)
}
}
got, err := Findings(root)
if err != nil {
t.Fatal(err)
}
if all := strings.Join(got, "\n"); (row.want == "") != (all == "") || !strings.Contains(all, row.want) {
t.Fatalf("findings = %q, want one containing %q", all, row.want)
}
})
}
}
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2).
func TestTheCatalogueHoldsNoPlacementOfTheRepositoryPath(t *testing.T) {
got, err := Findings(filepath.Join("..", ".."))
if err != nil {
t.Fatal(err)
}
for _, f := range got {
t.Error(f)
}
}
-6
View File
@@ -1,6 +0,0 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# the root, for a placement directory, and every manifest, for a read of the indexer's placement states.
?placement
modules/*/module.json
+7 -22
View File
@@ -2,9 +2,8 @@
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or // issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
// fixture of a covered module names a node of this one. // fixture of a covered module names a node of this one.
// //
// The names are the mesh's own, not the repository's: the checker hands them in the environment value // The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
// MESH_NODE_NAMES, comma-separated, from the node records it reads (novox/hq ADR 0567 §16). A run with none fails // they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
// loudly, since it would pass on anything. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
// whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word. // whole and in any case; a hyphen, an underscore or a CamelCase boundary parts one, so a longer word holding a name is another word.
// The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked // The forge is not a node name: its host (git.novox.be) and the organisation path (novox/<repo>) are blanked
// before a line is read. // before a line is read.
@@ -35,28 +34,14 @@ func main() {
if len(os.Args) > 1 { if len(os.Args) > 1 {
root = os.Args[1] root = os.Args[1]
} }
os.Exit(check(root, namesFromEnv())) os.Exit(check(root))
}
// NamesEnv is the environment value that carries the mesh's node names (mesh-sdk delivery.NodeNamesEnv).
const NamesEnv = "MESH_NODE_NAMES"
// namesFromEnv is the names NamesEnv holds, comma-separated, trimmed, empty entries left out.
func namesFromEnv() []string {
var out []string
for _, n := range strings.Split(os.Getenv(NamesEnv), ",") {
if n = strings.TrimSpace(n); n != "" {
out = append(out, n)
}
}
return out
} }
// check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable. // check runs the check over a catalogue and returns the exit code: 0 clean, 1 a finding, 2 a list unreadable.
func check(root string, names []string) int { func check(root string) int {
var err error names, err := readList(filepath.Join(root, "particulars"))
if len(names) == 0 { if err == nil && len(names) == 0 {
err = fmt.Errorf("no node names (%s is empty) — the check would pass on anything", NamesEnv) err = fmt.Errorf("particulars lists no name — the check would pass on anything")
} }
var covered []string var covered []string
if err == nil { if err == nil {
+8 -9
View File
@@ -74,24 +74,26 @@ func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
} }
} }
func TestCheckReturnsOneOnAFindingAndTwoOnNoNames(t *testing.T) { func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
root := t.TempDir() root := t.TempDir()
write := func(p, s string) { write := func(p, s string) {
p = filepath.Join(root, p) p = filepath.Join(root, p)
os.MkdirAll(filepath.Dir(p), 0o755) os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(s), 0o644) os.WriteFile(p, []byte(s), 0o644)
} }
write("particulars", "ace\n")
write("checks/particulars/covered", "kernel\n") write("checks/particulars/covered", "kernel\n")
write("modules/kernel/a", "clean\n") write("modules/kernel/a", "clean\n")
if got := check(root, []string{"ace"}); got != 0 { if got := check(root); got != 0 {
t.Errorf("clean = %d, want 0", got) t.Errorf("clean = %d, want 0", got)
} }
write("modules/kernel/a", "on ace\n") write("modules/kernel/a", "on ace\n")
if got := check(root, []string{"ace"}); got != 1 { if got := check(root); got != 1 {
t.Errorf("finding = %d, want 1", got) t.Errorf("finding = %d, want 1", got)
} }
if got := check(root, nil); got != 2 { os.Remove(filepath.Join(root, "particulars"))
t.Errorf("no names = %d, want 2", got) if got := check(root); got != 2 {
t.Errorf("no list = %d, want 2", got)
} }
} }
@@ -115,10 +117,7 @@ func TestOnlyCoveredModulesAreRead(t *testing.T) {
// The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no node name of this mesh in a covered module. // The script's run of this check over the real tree (novox/hq ADR 0529 clause 2): no node name of this mesh in a covered module.
func TestTheCoveredModulesNameNoNode(t *testing.T) { func TestTheCoveredModulesNameNoNode(t *testing.T) {
if _, set := os.LookupEnv(NamesEnv); !set { if code := check("../.."); code != 0 {
t.Skip("ADR 0567 §16: this checker hands no node names (before D14 is delivered); the skip goes once it is")
}
if code := check("../..", namesFromEnv()); code != 0 {
t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code) t.Fatalf("the check of the real catalogue exits %d (see what it printed above)", code)
} }
} }
+2 -1
View File
@@ -1,5 +1,6 @@
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole; # What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept # ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing. # until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
# check("../.."): every file of the modules the covered list names. The node names come from the run (MESH_NODE_NAMES). # check("../.."): the root's list and every file of the modules it names.
particulars
modules modules
+5
View File
@@ -0,0 +1,5 @@
module checks/placement
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f
+18
View File
@@ -0,0 +1,18 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117 h1:88CNZOoQgu7YPYKW16qkWT8Gw9Ucg07aTrIImA2nYUk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026210326-979644420117/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f h1:AKzV973UTWe0OYFoIjgGu0f+B26xh8QsAHzCpsTIjM8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027012704-062c29c3a22f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f h1:mimAGERguQmCGYVNdRTrFcZ1dhpU6GWo+Q3EDhHOrVk=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+80
View File
@@ -0,0 +1,80 @@
// Command placement is the catalogue's check of its placement files (novox/hq ADR 0549 §1, ADR 0555; issue 1126): every
// `placement/<node>.json` reads as a placement of the node its name says, and the files together are refused, every
// finding named at once, for two modules of one node on one host port, a pin, binding or holding naming a provider not
// placed where it says, a seat held once for the mesh held twice, two nodes with one address on the private network, or
// more than one hub (mesh-sdk delivery.CheckPlacements).
//
// A module the catalogue does not hold and a node not enrolled are the composition check's to refuse: placement names
// modules of every repository the indexer reads and the nodes the mesh enrolled, neither of which this tree holds.
//
// go run . <catalogue root>
//
// Exits 1 on a finding.
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Check reads every placement file under root and answers each finding; none is a catalogue whose placement holds. A
// catalogue with no placement directory has none to check.
func Check(root string) ([]string, error) {
dir := filepath.Join(root, delivery.PlacementDir)
entries, err := os.ReadDir(dir)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
var found []string
placements := map[string]delivery.Placement{}
for _, e := range entries {
rel := delivery.PlacementDir + "/" + e.Name()
node, ok := delivery.PlacementFileNode(rel)
if e.IsDir() || !ok {
found = append(found, fmt.Sprintf("%s is not a placement file: the directory holds only <node>.json, one per node", rel))
continue
}
raw, err := os.ReadFile(filepath.Join(dir, e.Name()))
if err != nil {
return nil, err
}
p, err := delivery.DecodePlacement(raw)
if err != nil {
found = append(found, fmt.Sprintf("%s does not read as a placement: %v", rel, err))
continue
}
if p.Revision != "" {
found = append(found, fmt.Sprintf("%s names a revision, which only the indexer writes into the key", rel))
}
placements[node] = p
}
all := func(string) bool { return true }
found = append(found, delivery.CheckPlacements(placements, all, all)...)
sort.Strings(found)
return found, nil
}
func main() {
if len(os.Args) != 2 {
fmt.Fprintln(os.Stderr, "usage: placement <catalogue root>")
os.Exit(2)
}
found, err := Check(os.Args[1])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
for _, f := range found {
fmt.Println(f)
}
if len(found) > 0 {
os.Exit(1)
}
}
+55
View File
@@ -0,0 +1,55 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// The catalogue's placement files as the merge check reads them (ADR 0549 §1, ADR 0555): the real tree holds every
// rule, and a tree that breaks them is refused with every finding named at once.
func TestPlacementFiles(t *testing.T) {
found, err := Check("../..")
if err != nil {
t.Fatal(err)
}
for _, f := range found {
t.Errorf("%s (novox/hq ADR 0549 §1)", f)
}
root := t.TempDir()
write := func(name, body string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, "placement"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "placement", name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
if found, err := Check(root); err != nil || len(found) != 0 {
t.Fatalf("a catalogue with no placement directory: %v, %v", found, err)
}
write("ace.json", `{"v":1,"node":"ace","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"a","ports":{"80":8080}},{"module":"b","ports":{"81":8080}}]}`)
write("g14.json", `{"v":1,"node":"g14","decided":{"hub":true,"address":"10.77.0.1"},"modules":[{"module":"c","bindings":{"pg":{"node":"ace","module":"pg"}}}]}`)
write("novox.json", `{"v":1,"node":"shanks","modules":[]}`)
write("bad.json", `{"v":1,"node":"bad","modules":{}}`)
write("dated.json", `{"v":1,"node":"dated","revision":"abc","modules":[]}`)
write("notes.md", "x")
found, err = Check(root)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{"a and b are both published on host port 8080", "bound to ace/pg", "both have the address 10.77.0.1",
"ace, g14 are each placed as it", "placement file of novox names the node", "bad.json does not read", "dated.json names a revision",
"notes.md is not a placement file"} {
hit := false
for _, f := range found {
hit = hit || strings.Contains(f, want)
}
if !hit {
t.Errorf("no finding says %q; found %v", want, found)
}
}
}
+1
View File
@@ -0,0 +1 @@
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
-8
View File
@@ -206,9 +206,6 @@ func excerpt(s string, from, to int) string {
return strings.Join(strings.Fields(s[a:b]), " ") return strings.Join(strings.Fields(s[a:b]), " ")
} }
// modulePath matches a string that is only a module or import path ("git.example.org/org/repo/go/pkg").
var modulePath = regexp.MustCompile(`^[a-z0-9.-]+\.[a-z]+/[A-Za-z0-9_./-]+$`)
// goStrings returns a Go file's string literals, adjacent ones joined across `+` as the program joins them. // goStrings returns a Go file's string literals, adjacent ones joined across `+` as the program joins them.
func goStrings(path, rel string) ([]text, error) { func goStrings(path, rel string) ([]text, error) {
src, err := os.ReadFile(path) src, err := os.ReadFile(path)
@@ -234,11 +231,6 @@ func goStrings(path, rel string) ([]text, error) {
if err != nil { if err != nil {
v = lit v = lit
} }
if modulePath.MatchString(v) {
// An import path or another module path names code, not words an agent reads.
cur, joining = nil, false
continue
}
if cur != nil && joining { if cur != nil && joining {
cur.s += v cur.s += v
} else { } else {
@@ -23,7 +23,7 @@ import (
// manifest's. // manifest's.
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE" const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
// CatalogueModules and CatalogueModule are the catalog's tools these definitions are read through. // CatalogueModules and CatalogueModule are the catalogue's tools these definitions are read through.
const ( const (
CatalogueModules = "mesh-catalog.catalog_modules" CatalogueModules = "mesh-catalog.catalog_modules"
CatalogueModule = "mesh-catalog.catalog_module" CatalogueModule = "mesh-catalog.catalog_module"
@@ -143,21 +143,21 @@ func PinsIn(manifest json.RawMessage, names StoreNames) []pinned {
return out return out
} }
// Definitions is what the catalog's definitions pin in the store, and what of them could not be read. // Definitions is what the catalogue's definitions pin in the store, and what of them could not be read.
type Definitions struct { type Definitions struct {
// Pins are by Key(repository, digest). // Pins are by Key(repository, digest).
Pins map[string][]Pin Pins map[string][]Pin
// Read is how many definitions were read; Unread says, per module, why its definition was not. // Read is how many definitions were read; Unread says, per module, why its definition was not.
Read int Read int
Unread map[string]string Unread map[string]string
// NotAsked says why the catalog could not be asked at all, when it could not. // NotAsked says why the catalogue could not be asked at all, when it could not.
NotAsked string NotAsked string
// NamesNotRead says why the store's older names could not be read, when they could not. // NamesNotRead says why the store's older names could not be read, when they could not.
NamesNotRead string NamesNotRead string
Names StoreNames Names StoreNames
} }
// Catalogue reads the definitions through the catalog's tools, and keeps each definition it read by its // Catalogue reads the definitions through the catalogue's tools, and keeps each definition it read by its
// module and commit, which never change together. // module and commit, which never change together.
type Catalogue struct { type Catalogue struct {
Ask Asking Ask Asking
@@ -171,21 +171,21 @@ type catalogueEntry struct {
Commit string `json:"commit"` Commit string `json:"commit"`
} }
// Read asks the catalog for every module and reads each definition, bounded by DefinitionsTimeout. // Read asks the catalogue for every module and reads each definition, bounded by DefinitionsTimeout.
func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions { func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names} defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names}
if c == nil || c.Ask == nil { if c == nil || c.Ask == nil {
defs.NotAsked = "this bundle has no way to ask the catalog" defs.NotAsked = "this bundle has no way to ask the catalogue"
return defs return defs
} }
raw, err := c.Ask(CatalogueModules, map[string]any{}) raw, err := c.Ask(CatalogueModules, map[string]any{})
if err != nil { if err != nil {
defs.NotAsked = "the catalog's modules could not be asked: " + err.Error() defs.NotAsked = "the catalogue's modules could not be asked: " + err.Error()
return defs return defs
} }
answer, output, ok := answerOf(raw) answer, output, ok := answerOf(raw)
if !ok { if !ok {
defs.NotAsked = "the catalog refused its modules: " + lastLine(output) defs.NotAsked = "the catalogue refused its modules: " + lastLine(output)
return defs return defs
} }
var list struct { var list struct {
@@ -193,7 +193,7 @@ func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
Error string `json:"error"` Error string `json:"error"`
} }
if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil { if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil {
defs.NotAsked = "the catalog's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output)) defs.NotAsked = "the catalogue's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
return defs return defs
} }
c.mu.Lock() c.mu.Lock()
@@ -54,7 +54,7 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
Name: "artifact_store_references", Name: "artifact_store_references",
Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " + Description: "What the controller's records say of each manifest the artifact store holds: kept (a definition " +
"names it, or one of the five most recent builds of a module the mesh holds), named by a definition (no " + "names it, or one of the five most recent builds of a module the mesh holds), named by a definition (no " +
"record names it, and a definition in the catalog pins it by digest, under the store's current name or " + "record names it, and a definition in the catalogue pins it by digest, under the store's current name or " +
"an older one: the module is said), the holder of a kept archive, " + "an older one: the module is said), the holder of a kept archive, " +
"eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " + "eligible (the mesh made it and keeps it for no reason), the holder of an eligible archive, let go yet present, " +
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " + "a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
+2 -2
View File
@@ -45,8 +45,8 @@ whenever the node's tool runtime collects the module's tools:
| file | holds | | file | holds |
|---|---| |---|---|
| `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh` (with a `timeout` of 660000 ms, mesh_wait's longest wait and a minute, so the agent's one-minute default does not cut a wait, issue 1871), and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's | | `managed-mcp.json` | the tool servers every session loads: the mesh's console as `mesh`, and the servers set in this module's `mcp_servers` setting. **Exclusive**: a server not listed here does not load — not one added with `claude mcp add`, not a project's `.mcp.json`, not a plugin's |
| `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin, and `CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT` (660000) in `env` beside the operator's own, the mesh's key winning | | `managed-settings.json` | the keys set in this module's `managed_settings` setting — at the controller's terminal alone (hq issue 340) — under the mesh's own keys: the repositories' attribution convention, the claude.ai connectors kept beside the managed servers, the key-helper while the node holds an API-key licence, git's credential file while the node holds the forge's read credential (below), and the two that name the `nox-mesh` marketplace and enable its plugin |
| `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one | | `CLAUDE.md` | how a session on this mesh works, this node's name and role, the conventions, the **"instead of" table** — then the instruction sections registered for every node and for this one |
| `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in | | `marketplace/` | the `nox-mesh` plugin (hq ADR 0216): the skills, subagents, commands and output styles registered for every node and for this one, offered in a session as `nox-mesh:<name>` — and the **guard on the agent's shell** (`guard/`), the mesh's own hook and the plugin's only one (hq issue 340). Replaced whole, staged beside and swapped in |
@@ -83,20 +83,6 @@ func TestTheRendererWritesWhatTheTypeScriptOneWrote(t *testing.T) {
for k := range MarketplaceKeys() { for k := range MarketplaceKeys() {
delete(m, k) delete(m, k)
} }
// The wait bound is new since the TypeScript (issue 1871), and is held below.
if env, ok := m["env"].(map[string]any); ok {
delete(env, "CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT")
if len(env) == 0 {
delete(m, "env")
}
}
}
if m, ok := a.(map[string]any); ok && file == "managed-mcp.json" {
if servers, ok := m["mcpServers"].(map[string]any); ok {
if entry, ok := servers["mesh"].(map[string]any); ok {
delete(entry, "timeout")
}
}
} }
_ = json.Unmarshal([]byte(want[file]), &b) _ = json.Unmarshal([]byte(want[file]), &b)
if !reflect.DeepEqual(a, b) { if !reflect.DeepEqual(a, b) {
@@ -118,21 +104,6 @@ func TestASettingCannotReplaceTheMeshsOwnEntryAndABadNameIsLeftOut(t *testing.T)
if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil { if mcp.MCPServers["mesh"]["url"] != "http://127.0.0.1:4270/mcp" || mcp.MCPServers["bad name"] != nil {
t.Fatalf("%v", mcp.MCPServers) t.Fatalf("%v", mcp.MCPServers)
} }
// The entry bounds a call by mesh_wait's longest wait and the idle bound is laid over the operator's env
// (issue 1871): a session's wait is not cut at the agent's one-minute default.
if mcp.MCPServers["mesh"]["timeout"] != float64(660000) {
t.Errorf("the mesh entry's timeout is %v", mcp.MCPServers["mesh"]["timeout"])
}
for _, held := range []map[string]any{nil, {"CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT": "1", "FOO": "bar"}} {
var m struct {
Env map[string]any `json:"env"`
}
got := Render(Facts{Console: "x"}, Settings{ManagedSettings: map[string]any{"env": held}}, nil, "/h", nil, Config{})
_ = json.Unmarshal([]byte(got["managed-settings.json"]), &m)
if m.Env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] != "660000" || (held != nil && m.Env["FOO"] != "bar") {
t.Errorf("env %v rendered as %v", held, m.Env)
}
}
if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) { if !reflect.DeepEqual(Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{}), Render(Facts{Console: "x"}, Settings{}, nil, "/h", nil, Config{})) {
t.Fatal("rendering is not deterministic") t.Fatal("rendering is not deterministic")
} }
@@ -4,9 +4,9 @@ package main
// the file under the agent's managed directory did not change by a byte when the module moved to Go; // the file under the agent's managed directory did not change by a byte when the module moved to Go;
// a test holds it to that renderer's own output (testdata/rendered-by-typescript.json). // a test holds it to that renderer's own output (testdata/rendered-by-typescript.json).
func instructionsText(node, role string) string { func instructionsText(node, role string) string {
return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" + return "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
node + node +
"`\n- **Role:** " + "`\n- **Role:** " +
role + role +
"\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" "\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
} }
+1 -24
View File
@@ -22,7 +22,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"regexp" "regexp"
"strconv"
"strings" "strings"
) )
@@ -99,27 +98,6 @@ func jsonFile(v any) string {
return b.String() return b.String()
} }
// MeshWaitBoundMillis is how long the agent may wait on the mesh's console for one call: mesh_wait's longest
// wait (600 s, mesh-tools node-tools console.WaitLongest) plus a minute's margin. The agent bounds an HTTP
// server's request by the largest of 60 s, the server entry's "timeout" and MCP_TIMEOUT, and a call that
// receives nothing for CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT (5 minutes by default) is aborted
// (code.claude.com/docs/en/mcp.md). Without these every mesh_wait ended "The operation timed out." at a
// minute, whatever its timeout (issue 1871).
const MeshWaitBoundMillis = 660000
// meshWaitEnv lays the idle bound into the environment the settings give every session, the mesh's key winning
// over the operator's, like the forge's keys.
func meshWaitEnv(managed map[string]any) {
env := map[string]any{}
if held, ok := managed["env"].(map[string]any); ok {
for k, v := range held {
env[k] = v
}
}
env["CLAUDE_CODE_MCP_TOOL_IDLE_TIMEOUT"] = strconv.Itoa(MeshWaitBoundMillis)
managed["env"] = env
}
// Render composes the three files. registered — what was registered through this module and applies // Render composes the three files. registered — what was registered through this module and applies
// here — is laid over the servers the operator set in its settings; config is the rest of what was // here — is laid over the servers the operator set in its settings; config is the rest of what was
// registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections // registered (ADR 0216): its settings laid over the operator's `managed_settings`, its instruction sections
@@ -141,7 +119,7 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
servers[name] = entry servers[name] = entry
} }
} }
servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console, "timeout": MeshWaitBoundMillis} servers[meshEntry] = map[string]any{"type": "http", "url": facts.Console}
// The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered // The operator's `managed_settings` (ADR 0213), set at the controller's terminal alone: nothing registered
// through this module's tools is laid over it (novox/hq issue 340). // through this module's tools is laid over it (novox/hq issue 340).
@@ -165,7 +143,6 @@ func RenderWithMesh(facts Facts, settings Settings, binding *Binding, helperPath
managed[key] = entries managed[key] = entries
} }
forgeEnv(managed, facts.ForgeCredentials) forgeEnv(managed, facts.ForgeCredentials)
meshWaitEnv(managed)
if binding != nil && binding.Kind == "api-key" { if binding != nil && binding.Kind == "api-key" {
managed["apiKeyHelper"] = helperPath managed["apiKeyHelper"] = helperPath
} }
@@ -32,11 +32,11 @@
"withKey": { "withKey": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n", "managed-mcp.json": "{\n \"mcpServers\": {\n \"anton\": {\n \"type\": \"stdio\",\n \"command\": \"node\",\n \"args\": [\n \"/a/b.js\"\n ],\n \"env\": {}\n },\n \"docs\": {\n \"type\": \"stdio\",\n \"command\": \"docs-mcp\",\n \"args\": [\n \"--x\"\n ]\n },\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n },\n \"search\": {\n \"type\": \"http\",\n \"url\": \"https://s.example/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n", "managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true,\n \"apiKeyHelper\": \"/state/api-key-helper\"\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** the laptop\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
}, },
"plain": { "plain": {
"managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n", "managed-mcp.json": "{\n \"mcpServers\": {\n \"mesh\": {\n \"type\": \"http\",\n \"url\": \"http://127.0.0.1:4270/mcp\"\n }\n }\n}\n",
"managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n", "managed-settings.json": "{\n \"attribution\": {\n \"commit\": \"\",\n \"pr\": \"\"\n },\n \"allowAllClaudeAiMcps\": true\n}\n",
"CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalog.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalog that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n" "CLAUDE.md": "# This machine is a node of a Novox mesh\n\nWritten by the mesh's `claude-code` module. Edit the module's settings or the catalogue, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `workstation`\n- **Role:** not stated — set it in this module's settings for the node\n- The other nodes, their roles and what runs where: ask the controller (`mesh-controller.nodes`,\n `mesh-controller.node`). Nothing here lists them, because a copy drifts.\n\n## How a session on this mesh works\n\nThe console is the only way to the mesh: the MCP server named `mesh`. It offers five tools, and\neverything else is an address you find and call through them:\n\n- `mesh_search` — words in, matching addresses out. `mesh_describe` — one address's arguments.\n- `mesh_call` — call an address. A seat the mesh holds once is `<seat>.<verb>` (the mesh's own verbs\n are `mesh-controller.<verb>`: `status`, `plan`, `node`, `assign`, `push`, `settings`);\n a module on a machine is `<node>/<module>.<tool>`.\n- `mesh_overview` and `mesh_machine` — the mesh's seats and machines, and what one machine runs.\n\n- **Symptom first.** For an error, a failing service or anything unexpected, search the record with the\n literal text before forming a hypothesis: the records module's `records_search`, then\n `records_read`.\n- **Ask the mesh before changing it**, and change it through the controller's verbs or the catalogue.\n- **A licence** through the `anthropic-licence-manager` seat's verbs. Never edit the agent's credentials\n file by hand, never print or ask for a token.\n\n## Hard rules\n\n- A file the mesh manages is changed through the verb or the catalogue that owns it, never on disk. If\n unsure, `mesh-controller.plan` for the node says what the mesh writes there.\n- Never write to a store's database by hand; schema changes are numbered migrations.\n- Never push to a main branch: a branch, a pull request, and a human approval for every merge.\n- The mesh creates no symlinks, and nobody else does either.\n- A package is declared in a module, never installed by hand.\n\n## Conventions\n\n- Commit messages are concise, in the imperative, about why.\n- Test before pushing: nodes update unattended.\n- The playbooks in the record say how research, decisions, designs, issues and hand-offs are done.\n"
} }
} }
@@ -4,9 +4,6 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv"
"strings"
"testing" "testing"
) )
@@ -65,60 +62,3 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
t.Errorf("the build packs files/ into %d archives, not one", packs) t.Errorf("the build packs files/ into %d archives, not one", packs)
} }
} }
// The packaged dnsmasq.service orders itself Before= network-online.target and nss-lookup.target. A drop-in
// that orders it After= either of them makes an ordering cycle, and systemd drops the start job: the mesh
// resolver stayed down for 90 minutes at a boot (novox/hq issue 1126). Wants=, Requires= and BindsTo= are
// refused too: they pull the target in, and with the packaged ordering they are the same mistake. Because the
// tunnel is ordered after those targets, the drop-in cannot wait for the address either: a start that finds it
// missing fails at once and Restart=on-failure brings it up (see the next test).
func TestDropInDoesNotOrderAfterATargetThePackagedUnitOrdersBefore(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "mesh.conf"))
if err != nil {
t.Fatal(err)
}
forbidden := []string{"network-online.target", "nss-lookup.target"}
for n, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
if !ok || (key != "After" && key != "Requires" && key != "Wants" && key != "BindsTo") {
continue
}
for _, target := range forbidden {
if strings.Contains(val, target) {
t.Errorf("mesh.conf line %d: %s=%s orders dnsmasq after a target its packaged unit orders itself before: a cycle", n+1, key, val)
}
}
}
}
// The wait for the address in ExecStartPre is at most two seconds: the tunnel that brings the address is ordered
// after network-online.target, which the resolver is ordered before, so a longer wait would hold the tunnel back.
func TestAddressWaitIsAtMostTwoSeconds(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "files", "mesh.conf"))
if err != nil {
t.Fatal(err)
}
found := false
for _, line := range strings.Split(string(raw), "\n") {
line = strings.TrimSpace(line)
if !strings.HasPrefix(line, "ExecStartPre=") {
continue
}
m := regexp.MustCompile(`seq 1 (\d+)`).FindStringSubmatch(line)
if m == nil {
t.Fatalf("ExecStartPre has no bounded wait (seq 1 N): %s", line)
}
found = true
if n, _ := strconv.Atoi(m[1]); n > 2 {
t.Errorf("ExecStartPre waits up to %d s for the address, more than 2 s", n)
}
}
if !found {
t.Fatal("mesh.conf has no ExecStartPre")
}
}
+5 -6
View File
@@ -1,15 +1,14 @@
# Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push. # Written by the mesh (module dnsmasq, novox/hq issue 348). Replaced on every push.
# #
# The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this # The resolver binds its addresses once, at start (bind-interfaces), so it starts only once this
# machine's private address is there: a start that finds it missing fails after at most two seconds and # machine's private address is there: it waits up to 80 seconds for it, and a start that found it
# is tried again every ten seconds (Restart=on-failure, no start limit) until the tunnel has brought it. # missing is tried again every ten seconds rather than left failed.
# It does not order itself After= or Wants= network-online.target, and its wait is short: the packaged
# unit is Before= network-online.target and nss-lookup.target, and the tunnel is ordered after them, so
# a long wait would hold the tunnel back and a drop-in ordering would be a cycle (issue 1126).
[Unit] [Unit]
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=0 StartLimitIntervalSec=0
[Service] [Service]
ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 2); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1' ExecStartPre=/usr/bin/sh -c 'for i in $(seq 1 80); do ip -4 -o addr show to ${machine:address} | grep -q . && exit 0; sleep 1; done; echo "${machine:address} is not on this machine" >&2; exit 1'
Restart=on-failure Restart=on-failure
RestartSec=10s RestartSec=10s
-9
View File
@@ -312,15 +312,6 @@ read-only. With a `user` it adds the teams that user is in and its effective rig
listing those above read: the check that a build key's user, `nox-builder` in `builders`, reads and pushes nothing. The listing those above read: the check that a build key's user, `nox-builder` in `builders`, reads and pushes nothing. The
tools' token lacks `read:organization` (the forge answers 403), so the read goes with the administrator's login, GET only. tools' token lacks `read:organization` (the forge answers 403), so the read goes with the administrator's login, GET only.
### Two settings have named tools, and a retargeted pull request is judged again
`gitea_api` refuses a repository's settings and a pull request's base (guard.ts). Issue 1422 note 7 gave each its tool:
`gitea_default_branch_set` (owner, repo, branch, why) and `gitea_pull_base_set` (owner, repo, number, base, why). Both
act as the tools' account, need a reason, refuse a branch the repository does not have, and answer the value before and
after; a default must also be a branch a protection rule guards, and only an open pull request has its base changed.
A pull request's announced head is kept under `owner/repo#number@base`, so a retarget (same head, new base) is
announced again into the new base by the next poll instead of being skipped as already announced.
### The merging account writes the repositories the mesh delivers ### The merging account writes the repositories the mesh delivers
`mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of `mesh-tools` sets every commit status and merges; outside the organisation it only reads (above), so a pull request of
+19 -61
View File
@@ -560,7 +560,7 @@ export class GiteaClient {
* **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages * **A listing the forge cut is no listing** (it says `truncated` on a page with nothing more to read, or the pages
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did * run past a bound): the call fails naming the repository and the commit, never answering the directories it did
* see, since a directory left out would be taken for one without a module. Reads only. */ * see, since a directory left out would be taken for one without a module. Reads only. */
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> { async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>, listed?: string[]): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>>; listed?: Record<string, Record<string, string>> }> {
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`; const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`); const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
const entries: any[] = []; const entries: any[] = [];
@@ -587,7 +587,23 @@ export class GiteaClient {
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`); if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
trees[dir] = tree; trees[dir] = tree;
} }
if (paths === undefined) return { root, trees }; // **Listed directories** (novox/hq ADR 0549 §2, issue 1126): each asked directory's files directly under it, by name
// to blob id, from the same listing: how the forge bridge finds the catalogue's placement files, one per node, at a
// merge. A directory the commit lacks lists nothing (the catalogue before its first placement file).
let files: Record<string, Record<string, string>> | undefined;
if (listed !== undefined) {
files = {};
for (const dir of listed) {
files[dir] = {};
const prefix = dir === "" ? "" : `${dir}/`;
for (const e of entries) {
if (e?.type !== "blob" || typeof e.path !== "string" || !e.path.startsWith(prefix)) continue;
const name = e.path.slice(prefix.length);
if (name !== "" && !name.includes("/")) files[dir][name] = String(e.sha);
}
}
}
if (paths === undefined) return files === undefined ? { root, trees } : { root, trees, listed: files };
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a // **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails // directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
// naming both, and nothing is answered in part. // naming both, and nothing is answered in part.
@@ -602,7 +618,7 @@ export class GiteaClient {
named[dir][p] = id; named[dir][p] = id;
} }
} }
return { root, trees, paths: named }; return files === undefined ? { root, trees, paths: named } : { root, trees, paths: named, listed: files };
} }
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */ /** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
@@ -742,15 +758,6 @@ export class GiteaClient {
return { created: true, rule }; return { created: true, rule };
} }
/** Write a branch's rule with exactly the fields given (gitea_branch_protection_set_full): an edit of the rule named
* for the branch, or a new one. As this client's account, which the forge refuses where it is no administrator. */
async writeBranchProtection(owner: string, repo: string, branch: string, body: Record<string, unknown>, creating: boolean): Promise<BranchProtection> {
return creating
? this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections`, { method: "POST", body: JSON.stringify({ rule_name: branch, ...body }) })
: this.request<BranchProtection>(`${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`,
{ method: "PATCH", body: JSON.stringify(body) });
}
async createPullRequest( async createPullRequest(
owner: string, owner: string,
repo: string, repo: string,
@@ -827,30 +834,6 @@ export class GiteaClient {
return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) })); return (raw ?? []).map((b) => ({ name: String(b?.name ?? ""), commit: String(b?.commit?.id ?? ""), protected: Boolean(b?.protected) }));
} }
/** One branch as the forge keeps it: whether a protection rule guards it; null when the repository has no such branch. */
async branchState(owner: string, repo: string, branch: string): Promise<{ protected: boolean } | null> {
try {
const b = await this.request<any>(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`);
return { protected: Boolean(b?.protected) };
} catch (err) {
if (/: 404 /.test(err instanceof Error ? err.message : "")) return null;
throw err;
}
}
/** Make a branch the repository's default (issue 1422): what the forge shows, clones and opens pull requests against. */
async setDefaultBranch(owner: string, repo: string, branch: string): Promise<GiteaRepo> {
return GiteaClient.mapRepo(await this.request<any>(repoPath(owner, repo), { method: "PATCH", body: JSON.stringify({ default_branch: branch }) }));
}
/** Change the branch an open pull request merges into (issue 1422). The head stays; the forge recomputes the diff. */
async setPullBase(owner: string, repo: string, index: number, base: string): Promise<GiteaPull> {
indexOf(index);
return GiteaClient.mapPull(
await this.request<any>(`${repoPath(owner, repo)}/pulls/${index}`, { method: "PATCH", body: JSON.stringify({ base }) }),
);
}
async deleteBranch(owner: string, repo: string, branch: string): Promise<void> { async deleteBranch(owner: string, repo: string, branch: string): Promise<void> {
await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`, { method: "DELETE" }); await this.request(`${repoPath(owner, repo)}/branches/${pathSegments("branch", branch)}`, { method: "DELETE" });
} }
@@ -1045,31 +1028,6 @@ export class GiteaAdmin {
return Array.isArray(res.body) ? res.body : []; return Array.isArray(res.body) ? res.body : [];
} }
/** Write a branch's rule with exactly the fields given, as the forge's administrator (gitea_branch_protection_set_full;
* the operator, 2026-10-28): an edit of the rule named for the branch, or a new one. */
async writeBranchProtection(owner: string, repo: string, branch: string, body: Record<string, unknown>, creating: boolean): Promise<BranchProtection> {
const path = creating ? `${repoPath(owner, repo)}/branch_protections` : `${repoPath(owner, repo)}/branch_protections/${pathSegments("branch", branch)}`;
const res = await this.request(path, { method: creating ? "POST" : "PATCH", body: JSON.stringify(creating ? { rule_name: branch, ...body } : body) });
if (res.status !== 200 && res.status !== 201) GiteaAdmin.fail(path, res);
return res.body as BranchProtection;
}
/** A repository as the forge answers it to its administrator: every setting (gitea_repo_settings_set_full). */
async repoSettings(owner: string, repo: string): Promise<Record<string, unknown>> {
const path = repoPath(owner, repo);
const res = await this.request(path);
if (res.status !== 200) GiteaAdmin.fail(path, res);
return res.body as Record<string, unknown>;
}
/** Edit a repository's settings with exactly the fields given, as the forge's administrator. */
async editRepoSettings(owner: string, repo: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
const path = repoPath(owner, repo);
const res = await this.request(path, { method: "PATCH", body: JSON.stringify(body) });
if (res.status !== 200) GiteaAdmin.fail(path, res);
return res.body as Record<string, unknown>;
}
/** Put main's rule right in every repository of an organisation (novox/hq ADR 0398 §2, issue 948): no push, only /** 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 * 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: * rule for main gets one. Answers the repositories it touched. With the administrator's login:
-2
View File
@@ -12,8 +12,6 @@
// forge as themselves; // forge as themselves;
// - `gitea_branch_protection_set` lets no person push and no administrator merge past a rule, and on main keeps // - `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); // the tools' account the only one that merges; it requires no status, as no rule of the mesh does (ADR 0534 §5);
// - `gitea_default_branch_set` and `gitea_pull_base_set` are the two settings gitea_api refuses, as named tools: a branch that
// exists (and, for a default, that a rule guards), a stated reason, the tools' account, the value before and after (issue 1422);
// - `gitea_merge_pull_request` merges only into a branch that refuses its push, only a head whose mesh/check passed // - `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 // against the trunk's head, approved by the operator where the branch requires no status (ADR 0534 §4), and only
// that head; // that head;
+66 -67
View File
@@ -53,9 +53,8 @@ import { announce, keepWatermark, readWatermark } from "./announce.js";
import { mergeTool } from "./merge.js"; import { mergeTool } from "./merge.js";
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js"; import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
import { keepMainRules, mainRulesStep } from "./mainrules.js"; import { keepMainRules, mainRulesStep } from "./mainrules.js";
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headKey, maintained, import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headsToAnnounce, maintained,
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts } from "./pulls.js"; requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts, reposToLook } from "./pulls.js";
import { actOn, announceNewHeads, startPass } from "./watch.js";
import { GateWriter } from "./gate.js"; import { GateWriter } from "./gate.js";
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js"; import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js"; import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
@@ -139,7 +138,12 @@ async function pollMerged(client: GiteaClient): Promise<void> {
// Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops. // Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops.
const closedKey = `closed:${repo.full_name}#${pull.number}`; const closedKey = `closed:${repo.full_name}#${pull.number}`;
if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) { if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) {
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) await announceClosed(client, repo, pull); if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) {
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
await siblingMoves(client, repo.full_name, pull.number);
}
announced.add(closedKey); announced.add(closedKey);
continue; continue;
} }
@@ -164,23 +168,6 @@ async function pollMerged(client: GiteaClient): Promise<void> {
type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string }; type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string };
type PullOf = Awaited<ReturnType<GiteaClient["listPullRequests"]>>[number]; type PullOf = Awaited<ReturnType<GiteaClient["listPullRequests"]>>[number];
/** A pull request closed without a merge, announced once (ADR 0239): by its event, or by the start's pass. */
async function announceClosed(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
const closedKey = `closed:${repo.full_name}#${pull.number}`;
if (pull.merged || pull.state !== "closed" || announced.has(closedKey)) return false;
announced.add(closedKey);
try {
await announce("pull.closed", { owner: repo.owner, repo: repo.name, number: pull.number, title: pull.title,
head: pull.head, head_sha: pull.head_sha, base: pull.base, html_url: pull.html_url });
} catch (err) {
announced.delete(closedKey);
throw err;
}
console.log(`[gitea] announced ${repo.full_name}#${pull.number} closed unmerged`);
await siblingMoves(client, repo.full_name, pull.number);
return true;
}
/** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */ /** **A merge, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> { async function announceMerge(client: GiteaClient, repo: RepoOf, pull: PullOf): Promise<boolean> {
if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false; if (!pull.merged || !pull.merge_commit_sha || announced.has(pull.merge_commit_sha)) return false;
@@ -256,7 +243,7 @@ let heads: Announced = {};
let primedPulls = false; let primedPulls = false;
/** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */ /** **A head, announced once, whichever path saw it first** (novox/hq issue 729): the poll or the forge's event. */
async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> { async function announceHead(client: GiteaClient, repo: RepoOf, pull: OpenOf): Promise<boolean> {
const key = headKey(repo.full_name, pull.number, pull.base); const key = `${repo.full_name}#${pull.number}`;
const sha = String(pull.head_sha ?? ""); const sha = String(pull.head_sha ?? "");
const before = heads[key]; const before = heads[key];
// Claimed before the first wait, so the other path finds it taken while this one is still announcing. // Claimed before the first wait, so the other path finds it taken while this one is still announcing.
@@ -294,18 +281,43 @@ async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf):
} }
let lastPullLook = ""; let lastPullLook = "";
/** The pass over every open pull request: run at the start and when the receiver listens, never on a timer (issue 1422). */ // **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
async function passPulls(client: GiteaClient): Promise<void> { // heuristic, and a heuristic that misses is caught by the next sweep rather than never.
const SWEEP_MS = 10 * 60_000;
let lastSweep = 0;
async function pollPulls(client: GiteaClient): Promise<void> {
const began = new Date().toISOString(); const began = new Date().toISOString();
const floor = lastPullLook ? new Date(Date.parse(lastPullLook) - MARGIN_MS).toISOString() : "";
const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString(); const dayAgo = new Date(Date.now() - 24 * 3600_000).toISOString();
for (const repo of await client.listAllRepos()) { // A pull request opened, reopened or retargeted moves its own update time and not its repository's: the
// Every open pull request, paged (novox/hq issue 579). // pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep.
const open = await client.listOpenPullsSince(repo.owner, repo.name, ""); let sweep = Date.now() - lastSweep >= SWEEP_MS;
await announceNewHeads(repo.full_name, open, heads, primedPulls, dayAgo, { let updated: { owner: string; name: string; updated_at?: string }[] = [];
alreadyChecked: (pull) => alreadyChecked(client, repo, pull), if (floor && !sweep) {
announceHead: (pull) => announceHead(client, repo, pull), try {
missed: (what) => pace.miss(what), updated = await client.searchOpenPulls(floor);
}); } catch (err) {
console.error(`[gitea] the pull requests updated lately could not be searched, so every repository is looked at — ${err instanceof Error ? err.message : err}`);
sweep = true;
}
}
if (sweep) lastSweep = Date.now();
for (const repo of reposToLook(await client.listAllRepos(), floor, updated, sweep)) {
// Every open pull request updated since the floor, paged (novox/hq issue 579): the newest 20 alone left a head
// beyond them unannounced. A sweep and the first pass read every page.
const open = await client.listOpenPullsSince(repo.owner, repo.name, sweep ? "" : floor);
for (const pull of headsToAnnounce(repo.full_name, open, heads)) {
const key = `${repo.full_name}#${pull.number}`;
const fresh = primedPulls || (!!pull.updated_at && pull.updated_at > dayAgo);
// The first look after a start remembers nothing: a head the forge already shows a merge check of was announced.
if (fresh && !primedPulls && await alreadyChecked(client, repo, pull)) {
heads[key] = String(pull.head_sha);
continue;
}
if (fresh) {
if (await announceHead(client, repo, pull)) pace.miss(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
} else heads[key] = String(pull.head_sha);
}
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true; if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
} }
if (changedTrunks) keepTrunks(); if (changedTrunks) keepTrunks();
@@ -485,9 +497,6 @@ const checks = new CheckStatuses({
/** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what /** How fast the polls look: 30 s until the forge's events are heard, five minutes while they are; and what
* gitea_events says of them (webhook.ts). */ * gitea_events says of them (webhook.ts). */
const pace = new Pace(); const pace = new Pace();
/** Asks the pass over every repository again, shortly and once (set where the pass is made): an event that could not be
* acted on is not lost until the next start. */
let catchUp: () => void = () => {};
if (!gitea) pace.unheard("the forge is not watched: no way to a token", false); if (!gitea) pace.unheard("the forge is not watched: no way to a token", false);
// The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2). // The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2).
@@ -513,15 +522,22 @@ registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll),
gates, gates,
}), mergeTool(gitea, { gates })] : [])]); }), mergeTool(gitea, { gates })] : [])]);
/** What the forge's event asks of the announcer at once (issue 729, 1422): see watch.ts. */ /** What the forge's event asks of the announcer at once (issue 729): a new head is announced, and a merge. */
const actOnEvent = (client: GiteaClient, event: ForgeEvent): Promise<void> => actOn<RepoOf, PullOf>(event, { async function actOn(client: GiteaClient, event: ForgeEvent): Promise<void> {
getRepo: (o, r) => client.getRepo(o, r), const b = event.body as { owner?: string; repo?: string; number?: number };
getPull: (o, r, n) => client.getPullRequest(o, r, n) as Promise<PullOf & { state?: string; merged?: boolean }>, if (!b.owner || !b.repo || !b.number) return;
announceHead: (repo, pull) => announceHead(client, repo, pull as OpenOf), if (event.name === "forge.pull-request.closed") {
announceMerge: (repo, pull) => announceMerge(client, repo, pull), // Closed unmerged is a sibling's move; a merge is weighed with its announcement below (ADR 0382 §5).
announceClosed: (repo, pull) => announceClosed(client, repo, pull), if (!(event.body as { merged?: boolean }).merged) await siblingMoves(client, `${b.owner}/${b.repo}`, b.number);
siblingMoves: (full, n) => siblingMoves(client, full, n), return;
}); }
const head = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"].includes(event.name);
const merged = event.name === "forge.pull-request.merged";
if (!head && !merged) return;
const [repo, pull] = await Promise.all([client.getRepo(b.owner, b.repo), client.getPullRequest(b.owner, b.repo, b.number)]);
if (head && pull.state === "open") await announceHead(client, repo, pull);
if (merged) await announceMerge(client, repo, pull);
}
/** /**
* **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside * **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside
@@ -547,12 +563,7 @@ async function listenForForge(client: GiteaClient): Promise<boolean> {
// A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts). // A repository made, or one not yet seen readable, is granted read before anything acts on it (readall.ts).
await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) => await Promise.resolve().then(() => readAll().onEvent(event)).catch((err) =>
console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`)); console.error(`[gitea] whether the mesh's accounts read the repository of ${event.name} could not be settled — ${err instanceof Error ? err.message : err}`));
try { await actOn(client, event);
await actOnEvent(client, event);
} catch (err) {
catchUp();
throw err;
}
} }); } });
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
server.once("error", reject); server.once("error", reject);
@@ -601,9 +612,9 @@ if (gitea) {
// polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s. // polls slow to every five minutes and announce only what no event said (Pace); otherwise they keep 30 s.
// **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle // **Not awaited** (novox/hq issue 1726): the receiver's set-up asks the forge's admin API, and the runtime kills a bundle
// whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle // whose import has not ended in its 10 s handshake and never starts it again; a forge slow to answer while every bundle
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. The pass below // of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. Until it is
// runs again once it is listening, so nothing said before is missed; listenForForge says its own failure. // listening, Pace keeps the polls at 30 s, so nothing is missed meanwhile; listenForForge says its own failure.
const listenOk = listenForForge(client); void listenForForge(client);
// The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet // The start's one pass over every repository (readall.ts), not awaited for the same reason. A forge not answering yet
// is asked again, a minute apart, until one pass has run; after that only the forge's events grant. // is asked again, a minute apart, until one pass has run; after that only the forge's events grant.
// An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when // An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when
@@ -635,20 +646,8 @@ if (gitea) {
// Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set. // Only while the setting keep-main-rules is true (mainrules.ts); false, the default, leaves every repository's rule as set.
tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000); tick(mainRulesStep(keepMainRules(), () => GiteaAdmin.fromEnv().ensureMainMerges("novox"), (line) => console.log(line)), 600_000);
tick(() => pollRepos(client), 60_000); tick(() => pollRepos(client), 60_000);
// **No timer asks the forge for pull requests or merges** (issue 1422): the forge's events announce them. One pass over tick(() => pollMerged(client), () => pace.every());
// every repository runs at the start and again once the receiver listens, so what happened while the module was away or tick(() => pollPulls(client), () => pace.every());
// not yet listening is announced; passes run one at a time, a failed one is asked again a minute later.
let passes: Promise<void> = Promise.resolve();
const pass = (): Promise<void> => (passes = passes.catch(() => {}).then(() =>
startPass(async () => { await pollMerged(client); await passPulls(client); }, { retryMs: 60_000 })));
void pass();
let catchUpAsked = false;
catchUp = () => {
if (catchUpAsked) return;
catchUpAsked = true;
setTimeout(() => { catchUpAsked = false; void pass(); }, 30_000);
};
void listenOk.then((ok) => (ok ? pass() : undefined));
// Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its // Bound again until they hold, and not awaited (bind.ts, issue 1793): the runtime kills a bundle that has not answered its
// handshake in 10 s, so the import must end while a refused bind is still asked again. // handshake in 10 s, so the import must end while a refused bind is still asked again.
bindInBackground([ bindInBackground([
+1 -2
View File
@@ -569,7 +569,6 @@
"mesh-build-node.keys", "mesh-build-node.keys",
"mesh-build-image.keys", "mesh-build-image.keys",
"mesh-delivery-checker.owed", "mesh-delivery-checker.owed",
"mesh-delivery-indexer.modules", "mesh-delivery-indexer.modules"
"mesh-delivery-gate.source"
] ]
} }
-257
View File
@@ -1,257 +0,0 @@
// Every field of a branch protection rule, set through one tool (gitea_branch_protection_set_full; the operator,
// 2026-10-28: "just make a generic tool so you can setup any configuration"). Pure, so it is tested without a forge:
// which fields the forge's rule takes and of what kind, the body a call sends, and whether the rule the forge then
// applies to the branch is one the controller registers a module from (judgedRule, mesh-control
// cmd/mesh-controller/build_source.go).
import { TOOLS_ACCOUNT } from "./tools-account.js";
import { MAIN_BRANCH, type BranchProtection } from "./protection.js";
import { ruleCovers } from "./guard.js";
type Kind = "boolean" | "logins" | "strings" | "number" | "string";
/** unread: the forge never answers it, so it is not read back; duration: a Go duration, compared as one ("8h" is the
* forge's "8h0m0s"). */
type Field = { kind: Kind; says: string; parent?: string; unread?: boolean; duration?: boolean };
/** Every field gitea's branch protection API takes on an edit at 1.27.3 (EditBranchProtectionOption, 32 fields), by its
* own name, and its kind; rule_name is the branch argument, never a field. A field with a parent is read by the forge
* only when the same call sets that parent true, and setting the parent false clears it (gitea's
* routers/api/v1/repo/branch.go): such a field is refused without its parent true in the call. */
export const PROTECTION_FIELDS: Record<string, Field> = {
enable_push: { kind: "boolean", says: "whether anyone who writes may push to the branch directly (false: no direct push; also clears enable_push_whitelist and push_whitelist_deploy_keys)" },
enable_push_whitelist: { kind: "boolean", says: "only the push whitelist may push; needs enable_push: true in the same call", parent: "enable_push" },
push_whitelist_usernames: { kind: "logins", says: "the logins that may push directly" },
push_whitelist_teams: { kind: "strings", says: "the teams that may push directly" },
push_whitelist_deploy_keys: { kind: "boolean", says: "whether deploy keys may push; needs enable_push: true in the same call", parent: "enable_push" },
enable_force_push: { kind: "boolean", says: "whether a force push is taken (false also clears the force-push allowlist flags)" },
enable_force_push_allowlist: { kind: "boolean", says: "only the force-push allowlist may force-push; needs enable_force_push: true in the same call", parent: "enable_force_push" },
force_push_allowlist_usernames: { kind: "logins", says: "the logins that may force-push" },
force_push_allowlist_teams: { kind: "strings", says: "the teams that may force-push" },
force_push_allowlist_deploy_keys: { kind: "boolean", says: "whether deploy keys may force-push; needs enable_force_push: true in the same call", parent: "enable_force_push" },
enable_merge_whitelist: { kind: "boolean", says: "whether only the merge whitelist may merge" },
merge_whitelist_usernames: { kind: "logins", says: "the logins that may merge (the controller's trunk rule wants mesh-tools, no agent-<node>)" },
merge_whitelist_teams: { kind: "strings", says: "the teams that may merge" },
enable_status_check: { kind: "boolean", says: "whether the statuses in status_check_contexts must pass to merge" },
status_check_contexts: { kind: "strings", says: "the required commit statuses, e.g. mesh/check" },
required_approvals: { kind: "number", says: "how many approving reviews a merge needs" },
enable_approvals_whitelist: { kind: "boolean", says: "whether only the approvals whitelist's reviews count" },
approvals_whitelist_username: { kind: "logins", says: "the logins whose approvals count" },
approvals_whitelist_teams: { kind: "strings", says: "the teams whose approvals count" },
block_on_rejected_reviews: { kind: "boolean", says: "a review asking changes blocks the merge" },
block_on_official_review_requests: { kind: "boolean", says: "an open official review request blocks the merge" },
block_on_outdated_branch: { kind: "boolean", says: "a head behind the branch blocks the merge" },
dismiss_stale_approvals: { kind: "boolean", says: "a new commit dismisses earlier approvals" },
ignore_stale_approvals: { kind: "boolean", says: "approvals of an earlier commit do not count" },
require_signed_commits: { kind: "boolean", says: "every commit must be signed" },
protected_file_patterns: { kind: "string", says: "files no push may change, ';'-separated globs" },
unprotected_file_patterns: { kind: "string", says: "files a push may change even without push rights, ';'-separated globs" },
block_admin_merge_override: { kind: "boolean", says: "true: an administrator cannot merge past the rule (the controller's trunk rule wants true)" },
enable_bypass_allowlist: { kind: "boolean", says: "whether the bypass allowlist may merge past the rule's checks" },
bypass_allowlist_usernames: { kind: "logins", says: "the logins that may merge past the rule's checks" },
bypass_allowlist_teams: { kind: "strings", says: "the teams that may merge past the rule's checks" },
priority: { kind: "number", says: "the rule's order among glob rules" },
};
/** The repository settings gitea_repo_settings_set_full sets (gitea's EditRepoOption at 1.27.3), by their own names. Left
* out: name (a rename moves every module's source address), the internal tracker, external tracker and external wiki
* objects, and mirror_username, mirror_password and mirror_token, which are credentials. The forge ignores a
* default_branch that does not exist; the read-back below says so. */
export const REPO_FIELDS: Record<string, Field> = Object.fromEntries(([
["description", "string"], ["website", "string"], ["private", "boolean"], ["template", "boolean"],
["default_branch", "string"], ["has_code", "boolean"], ["has_issues", "boolean"], ["has_wiki", "boolean"],
["has_pull_requests", "boolean"], ["has_projects", "boolean"], ["projects_mode", "string"], ["has_releases", "boolean"],
["has_packages", "boolean"], ["has_actions", "boolean"], ["ignore_whitespace_conflicts", "boolean"],
["allow_merge_commits", "boolean"], ["allow_rebase", "boolean"], ["allow_rebase_explicit", "boolean"],
["allow_squash_merge", "boolean"], ["allow_fast_forward_only_merge", "boolean"], ["allow_manual_merge", "boolean"],
["autodetect_manual_merge", "boolean"], ["allow_rebase_update", "boolean"], ["allow_merge_update", "boolean"],
["default_update_style", "string"], ["default_delete_branch_after_merge", "boolean"], ["default_merge_style", "string"],
["default_allow_maintainer_edit", "boolean"], ["archived", "boolean"], ["mirror_interval", "string"], ["enable_prune", "boolean"],
] as [string, Kind][]).map(([name, kind]) => [name, {
kind, says: `the repository's ${name.replace(/_/g, " ")}`,
...(name === "projects_mode" ? { parent: "has_projects", says: "the repository's projects mode; needs has_projects: true in the same call" } : {}),
...(name === "enable_prune" ? { unread: true, says: "a mirror's prune setting; the forge never answers it, so it is not read back" } : {}),
...(name === "mirror_interval" ? { duration: true, says: "a mirror's interval, a Go duration such as 8h (the forge answers 8h0m0s)" } : {}),
}]));
/** A Go duration ("8h", "1h30m", "8h0m0s", "90s") in milliseconds; NaN when it is none. */
export function durationMs(v: unknown): number {
if (typeof v !== "string" || !/^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/.test(v)) return v === "0" ? 0 : NaN;
const unit: Record<string, number> = { ns: 1e-6, us: 1e-3, "µs": 1e-3, ms: 1, s: 1e3, m: 6e4, h: 3.6e6 };
let ms = 0;
for (const m of v.matchAll(/(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/g)) ms += Number(m[1]) * unit[m[2]];
return ms;
}
/** Fields the forge was sent but does not hold after the write, each with what was sent and what it holds: a write it
* answered 200 and did not apply (gitea drops a field whose parent is not set, or a branch that does not exist). */
export function unapplied(sent: Record<string, unknown>, held: Record<string, unknown>, fields: Record<string, Field> = PROTECTION_FIELDS): string[] {
const norm = (k: string, v: unknown) => fields[k]?.duration ? String(durationMs(v))
: Array.isArray(v) ? JSON.stringify([...v].map(String).sort()) : JSON.stringify(v ?? null);
return Object.keys(sent).filter((k) => !fields[k]?.unread && norm(k, sent[k]) !== norm(k, held[k]))
.map((k) => `${k} (sent ${JSON.stringify(sent[k])}, the forge holds ${JSON.stringify(held[k] ?? null)})`);
}
/** Fields that changed between two reads but were not sent: what the forge changed beside the call (enable_push: false
* clears the push whitelist flags). */
export function alsoChanged(before: Record<string, unknown> | null, after: Record<string, unknown>, sent: Record<string, unknown>): string[] {
if (!before) return [];
const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
return [...keys].filter((k) => !(k in sent) && !["updated_at", "created_at"].includes(k) &&
JSON.stringify(before[k] ?? null) !== JSON.stringify(after[k] ?? null));
}
/** A forge login. */
const LOGIN = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,39}$/;
/** The forge body for the fields given: each checked against its kind, a field not given left out (the forge keeps it on
* an edit), an unknown field refused before anything is sent. Throws on the first wrong field. */
export function fullProtectionBody(args: Record<string, unknown>, given: string[],
fields: Record<string, Field> = PROTECTION_FIELDS, what = "a branch protection rule"): Record<string, unknown> {
const body: Record<string, unknown> = {};
for (const name of given) {
const field = fields[name];
if (!field) throw new Error(`${name} is no field of ${what}; the fields are ${Object.keys(fields).join(", ")}`);
// The parent false clears the field, so only a field false goes beside a parent false.
if (field.parent && args[field.parent] !== true && !(args[field.parent] === false && args[name] === false)) {
throw new Error(`${name} is read by the forge only when the same call sets ${field.parent}: true (gitea ignores it otherwise). Nothing was sent`);
}
const v = args[name];
switch (field.kind) {
case "boolean":
if (typeof v !== "boolean") throw new Error(`${name} is true or false`);
body[name] = v;
break;
case "number":
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) throw new Error(`${name} is a whole number, 0 or more`);
body[name] = v;
break;
case "string":
if (typeof v !== "string") throw new Error(`${name} is a string`);
if (field.duration && Number.isNaN(durationMs(v))) throw new Error(`${name} is a duration, such as 8h or 1h30m`);
body[name] = v;
break;
case "strings":
case "logins": {
if (!Array.isArray(v) || v.some((s) => typeof s !== "string" || s.trim() === "")) throw new Error(`${name} is a list of names`);
const names = [...new Set((v as string[]).map((s) => s.trim()))];
if (field.kind === "logins") {
const bad = names.find((s) => !LOGIN.test(s) || s.includes(".."));
if (bad !== undefined) throw new Error(`${name}: ${JSON.stringify(bad)} is no forge login`);
}
body[name] = names;
break;
}
}
}
return body;
}
/** The fields of a call that are protection fields: every argument but owner, repo, branch and why. */
export function givenFields(args: Record<string, unknown>): string[] {
return Object.keys(args).filter((k) => !["owner", "repo", "branch", "why"].includes(k) && args[k] !== undefined);
}
/** A rule as the controller reads it from gitea_branch_protection_get (protectionRule in build_source.go). */
interface ControllerRule { rule: string; push: boolean; admin_may_override: boolean; merge_whitelist: string[] | null }
function asControllerRule(p: BranchProtection): ControllerRule {
return {
rule: String(p.rule_name ?? p.branch_name ?? ""),
push: p.enable_push ?? false,
admin_may_override: !(p.block_admin_merge_override ?? false),
merge_whitelist: p.enable_merge_whitelist ? (p.merge_whitelist_usernames ?? []) : null,
};
}
/** ruleCovers of build_source.go, as the controller reads a rule's glob: `**` any, `*` within a segment, `?` one. */
function controllerCovers(rule: string, branch: string): boolean {
if (rule === branch) return true;
if (!/[*?[]/.test(rule)) return false;
let re = "^";
for (let i = 0; i < rule.length; i++) {
const c = rule[i];
if (c === "*" && rule[i + 1] === "*") { re += ".*"; i++; }
else if (c === "*") re += "[^/]*";
else if (c === "?") re += "[^/]";
else re += c.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
return new RegExp(re + "$").test(branch);
}
/** Whether the controller registers a module from this branch, judged as judgedRule in mesh-control
* cmd/mesh-controller/build_source.go does (ADR 0534 §6): the rule named for the branch, else the first glob rule that
* covers it; it takes no push, its merge whitelist names mesh-tools and no agent-<node>, and no administrator merges
* past it. A mirror, not the judge: the controller's own verdict is the one that counts. */
export function trunkRule(rules: BranchProtection[], branch: string, trunks: string[] = [MAIN_BRANCH]): { holds: boolean; why: string; is_trunk: boolean } {
const judged = judgedTrunkRule(rules, branch);
return { ...judged, is_trunk: trunks.includes(branch) };
}
function judgedTrunkRule(rules: BranchProtection[], branch: string): { holds: boolean; why: string } {
const all = rules.map(asControllerRule);
const r = all.find((x) => x.rule === branch) ?? all.find((x) => controllerCovers(x.rule, branch));
const no = (why: string) => ({ holds: false, why });
if (!r) return no(`no protection rule covers ${branch}`);
if (r.push) return no(`the rule ${r.rule} lets a person push to ${branch} directly`);
if (!r.merge_whitelist || r.merge_whitelist.length === 0) {
return no(`the rule ${r.rule} keeps no merge whitelist naming ${TOOLS_ACCOUNT} alone for ${branch}`);
}
if (!r.merge_whitelist.includes(TOOLS_ACCOUNT)) {
return no(`the rule ${r.rule} lets ${r.merge_whitelist.join(", ")} merge into ${branch}, and not ${TOOLS_ACCOUNT}`);
}
if (r.merge_whitelist.some((l) => l.startsWith("agent-"))) {
return no(`the rule ${r.rule} lets an agent's account merge into ${branch}: ${r.merge_whitelist.join(", ")}`);
}
if (r.admin_may_override) return no(`the rule ${r.rule} lets an administrator merge into ${branch} past it`);
return { holds: true, why: `the rule ${r.rule} takes no push, only ${r.merge_whitelist.join(", ")} merge, and no administrator merges past it` };
}
/** The gate's source as it is read: one key per trunk, each with the heads of the repositories installed from it. */
export type SourceRead = () => Promise<Record<string, unknown>>;
/** The state the trunks are read from: mesh-delivery-gate's source, whose key `main` is today's one trunk of each
* repository it names (each repository's trunk head the checker judges on); a later trunk is a key of its own. */
export const TRUNK_SOURCE = "mesh-delivery-gate.source";
/** A repository's trunks, the branches the mesh installs from (the operator, 2026-10-28 16:5x): each key of the gate's
* source whose heads name the repository. "main" only when the source names none of its trunks or cannot be read, and
* `from` says which. The one place trunks are read: several trunks later change only this. */
export async function trunksOf(owner: string, repo: string, read: SourceRead): Promise<{ names: string[]; from: string }> {
const full = `${owner}/${repo}`;
let src: Record<string, unknown>;
try {
src = await read();
} catch (err) {
return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} could not be read (${err instanceof Error ? err.message : String(err)}): ${MAIN_BRANCH} assumed` };
}
const names = Object.keys(src).filter((k) => {
const heads = (src[k] as { heads?: Record<string, unknown> } | null)?.heads;
return !!heads && typeof heads === "object" && full in heads;
}).sort();
if (names.length) return { names, from: TRUNK_SOURCE };
return { names: [MAIN_BRANCH], from: `${TRUNK_SOURCE} names no trunk of ${full}: ${MAIN_BRANCH} assumed` };
}
/** The gate's source read from the bus, every key and its value (the tools bundle's state channel). */
export function sourceFromBus(get: (name: string) => { keys(): Promise<string[]>; get(k: string): Promise<{ value: unknown } | null> }): SourceRead {
return async () => {
const st = get(TRUNK_SOURCE);
const out: Record<string, unknown> = {};
for (const k of await st.keys()) out[k] = (await st.get(k))?.value ?? null;
return out;
};
}
/** Why a call is the operator's, or null: it lets a force-push onto a rule covering a trunk (the operator, 2026-10-28
* 16:4x: a call waits for their Approve only if it removes branch protection or allows a force-push to a trunk). No
* tool of this module can ask for that Approve yet, so such a call is refused with nothing sent. */
export function forcePushToTrunk(rule: string, body: Record<string, unknown>, trunks: string[]): string | null {
const covered = trunks.filter((t) => ruleCovers(rule, t));
if (!covered.length) return null;
const opens = body.enable_force_push === true || body.force_push_allowlist_deploy_keys === true ||
(["force_push_allowlist_usernames", "force_push_allowlist_teams"] as const).some((k) => Array.isArray(body[k]) && (body[k] as unknown[]).length > 0);
if (!opens) return null;
return `the rule ${rule} covers ${covered.join(", ")}, and this call lets a force-push onto it: a force-push to a trunk is ` +
`the operator's Approve (the operator, 2026-10-28), and no tool of this module can ask it yet, so it is the operator's ` +
`at the forge. Nothing was sent`;
}
+2 -8
View File
@@ -141,19 +141,13 @@ export function planText(plan: ChangePlan): string {
return lines.join("\n"); return lines.join("\n");
} }
/** The heads already announced, keyed by `headKey`, so a restart announces nothing twice. */ /** The heads already announced, keyed `owner/repo#number`, so a restart announces nothing twice. */
export type Announced = Record<string, string>; export type Announced = Record<string, string>;
/** The key a pull request's announced head is kept under: `owner/repo#number@base`. The base is in it because a pull
* request retargeted keeps its head, and a head judged into one base is not judged into another (issue 1422, de-spiegel #6). */
export function headKey(full: string, number: number, base?: string): string {
return `${full}#${number}@${base ?? ""}`;
}
/** Which open pull requests have a head not yet announced. A pull request merged or closed is not /** Which open pull requests have a head not yet announced. A pull request merged or closed is not
* open and is never asked about. */ * open and is never asked about. */
export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] { export function headsToAnnounce(full: string, pulls: GiteaPull[], announced: Announced): GiteaPull[] {
return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[headKey(full, p.number, p.base)] !== p.head_sha); return pulls.filter((p) => p.state === "open" && !!p.head_sha && announced[`${full}#${p.number}`] !== p.head_sha);
} }
/** A pull request the forge found updated, by its repository: what `searchOpenPulls` answers. */ /** A pull request the forge found updated, by its repository: what `searchOpenPulls` answers. */
+15 -1
View File
@@ -183,6 +183,15 @@ test("named paths: each asked path's git object id at the commit, a file's or a
}); });
assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") }); assert.deepEqual(got.trees, { "": ROOT, "modules/a": h("4"), "modules/b/c": h("8") });
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered"); assert.equal((await client.commitTrees("novox", "hq", COMMIT)).paths, undefined, "no paths asked, none answered");
// Listed directories (ADR 0549 §2, issue 1126): the files directly under each, not deeper; a missing one lists none.
const listed = await client.commitTrees("novox", "hq", COMMIT, undefined, ["modules/a", "modules/b", "", "placement"]);
assert.deepEqual(listed.listed, {
"modules/a": { "main.go": h("6"), "module.json": h("5") },
"modules/b": {},
"": { "README.md": h("1"), "module.json": h("2") },
placement: {},
});
assert.equal((await client.commitTrees("novox", "hq", COMMIT)).listed, undefined, "no directory asked, none listed");
} finally { } finally {
server.close(); server.close();
} }
@@ -209,7 +218,12 @@ test("gitea_commit_trees takes paths as {directory: [paths]} and refuses another
const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]); const { client, server, asked } = await serve([{ sha: COMMIT, url: "u", tree: LISTING, truncated: false, page: 1, total_count: LISTING.length }]);
try { try {
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!; const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
assert.ok("paths" in tool.input, "the tool names the arguments"); assert.ok("paths" in tool.input && "listed" in tool.input, "the tool names the arguments");
const listedAnswer: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: ["modules/d"] } as any);
assert.deepEqual(listedAnswer.listed, { "modules/d": { "notes.md": h("b") } });
for (const bad of ["modules/d", ["../x"], ["/abs"], [1]]) {
await assert.rejects(tool.run({ owner: "novox", repo: "hq", sha: COMMIT, listed: bad } as any), /listed/, JSON.stringify(bad));
}
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any); const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any);
assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } }); assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } });
const before = asked.length; const before = asked.length;
+4 -67
View File
@@ -34,12 +34,6 @@ const state = {
// refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch. // refuses it the rules' list, as gitea's reqAdmin does, and it reads a branch's protection from the branch.
admin: false, admin: false,
lastCommit: "2026-01-01T00:00:00Z", lastCommit: "2026-01-01T00:00:00Z",
// The repository's default branch and pull request 7's base, as the forge keeps them (issue 1422 note 7).
defaultBranch: "main",
base: "main",
prState: "open",
// Branches the forge answers 404 for; "scratch" exists but no protection rule guards it.
absent: ["nope"],
// The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's // The pull request's head; the head the merge commit joined (the same, unless a test says otherwise) — the forge's
// record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the // record of the pull request keeps saying `head` either way, as it would in the race issue 381 is about; and the
// merge commit's own sha. // merge commit's own sha.
@@ -72,10 +66,7 @@ beforeEach(() => {
state.statuses = judgedAt(TRUNK); state.statuses = judgedAt(TRUNK);
state.trunk = TRUNK; state.trunk = TRUNK;
state.emitted = []; state.emitted = [];
state.rules = [protectedMain(), { rule_name: "release/*", enable_push: false }]; state.rules = [protectedMain()];
state.defaultBranch = "main";
state.base = "main";
state.prState = "open";
state.admin = false; state.admin = false;
state.head = REVIEWED; state.head = REVIEWED;
state.landed = ""; state.landed = "";
@@ -107,22 +98,12 @@ const server = createServer(async (req, res) => {
state.asked.push(`${method} ${path}${url.search}`); state.asked.push(`${method} ${path}${url.search}`);
if (method !== "GET") state.sent.push({ method, path, body }); if (method !== "GET") state.sent.push({ method, path, body });
const repo = "/repos/novox/mesh-catalog"; const repo = "/repos/novox/mesh-catalog";
if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch }); if (method === "GET" && path === repo) return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: "main" });
if (method === "PATCH" && path === repo) {
if (body?.default_branch) state.defaultBranch = body.default_branch;
return json(res, 200, { full_name: "novox/mesh-catalog", default_branch: state.defaultBranch });
}
if (method === "PATCH" && path === `${repo}/pulls/7`) {
if (body?.base) state.base = body.base;
return json(res, 201, { number: 7, title: "x", state: state.prState, merged: false, head: { ref: "feat/x", sha: state.head },
base: { ref: state.base }, html_url: "http://fake/7" });
}
if (path.startsWith(`${repo}/branch_protections`) && !state.admin) { if (path.startsWith(`${repo}/branch_protections`) && !state.admin) {
return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" }); return json(res, 403, { message: "user should be an owner or a collaborator with admin write of a repository" });
} }
if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules); if (method === "GET" && path === `${repo}/branch_protections`) return json(res, 200, state.rules);
const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null; const branch = method === "GET" && path.startsWith(`${repo}/branches/`) ? decodeURIComponent(path.slice(`${repo}/branches/`.length)) : null;
if (branch !== null && state.absent.includes(branch)) return json(res, 404, { message: "branch does not exist" });
if (branch !== null) { if (branch !== null) {
// As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it. // As the forge says it to any reader of the code: the rule that covers the branch, as the forge matches it.
const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch)); const rule = (state.rules as any[]).find((r) => ruleCovers(String(r.rule_name ?? r.branch_name), branch));
@@ -144,8 +125,8 @@ const server = createServer(async (req, res) => {
return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]); return json(res, 200, [{ commit: { committer: { date: state.lastCommit } } }]);
} }
if (method === "GET" && path === `${repo}/pulls/7`) { if (method === "GET" && path === `${repo}/pulls/7`) {
return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : state.prState, merged: state.merged, return json(res, 200, { number: 7, title: "x", state: state.merged ? "closed" : "open", merged: state.merged,
head: { ref: "feat/x", sha: state.head }, base: { ref: state.base }, html_url: "http://fake/7", head: { ref: "feat/x", sha: state.head }, base: { ref: "main" }, html_url: "http://fake/7",
merge_commit_sha: state.merged ? state.mergeCommit : null }); merge_commit_sha: state.merged ? state.mergeCommit : null });
} }
if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) { if (method === "GET" && path === `${repo}/git/commits/${state.mergeCommit}`) {
@@ -828,47 +809,3 @@ test("gitea_merge_pull_request judges a main requiring mesh/repo-check alone on
state.statuses = [judgedAt(TRUNK)[1]]; state.statuses = [judgedAt(TRUNK)[1]];
assert.equal(((await merge()) as Record<string, unknown>).merged, true); assert.equal(((await merge()) as Record<string, unknown>).merged, true);
}); });
// Issue 1422 note 7: the two settings gitea_api refuses (ADR 0266) have named tools, held to the same guard as
// gitea_branch_protection_set: the tools' account, a stated reason, a branch that exists and that a rule guards.
test("gitea_default_branch_set answers the default before and after, and refuses a branch that is missing, unguarded or unexplained", async () => {
const set = (args: Record<string, unknown>) =>
tool("gitea_default_branch_set").run({ owner: "novox", repo: "mesh-catalog", why: "the trunk was recorded as unknown", ...args });
await assert.rejects(set({ branch: "nope" }), /no branch nope/);
await assert.rejects(set({ branch: "scratch" }), /no protection rule/);
await assert.rejects(set({ branch: "release/1", why: " " }), /why/);
await assert.rejects(set({ branch: ".." }), /dot segment|not a branch/);
assert.deepEqual(writes(), []);
assert.deepEqual(await set({ branch: "release/1" }), { branch: "release/1", before: "main", after: "release/1", changed: true,
why: "the trunk was recorded as unknown" });
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog"]);
assert.deepEqual(state.sent[0].body, { default_branch: "release/1" });
assert.equal(state.defaultBranch, "release/1");
state.sent = [];
assert.equal(((await set({ branch: "release/1" })) as any).changed, false, "already the default: nothing is sent");
assert.deepEqual(writes(), []);
});
test("gitea_pull_base_set answers the base before and after, and refuses a base that is missing, a closed pull request or no reason", async () => {
const set = (args: Record<string, unknown>) =>
tool("gitea_pull_base_set").run({ owner: "novox", repo: "mesh-catalog", number: 7, why: "it was opened against the wrong trunk", ...args });
await assert.rejects(set({ base: "nope" }), /no branch nope/);
await assert.rejects(set({ base: "release/1", why: "" }), /why/);
await assert.rejects(set({ base: "release/1", number: 0 }), /pull request's number/);
state.prState = "closed";
await assert.rejects(set({ base: "release/1" }), /not open/);
state.prState = "open";
assert.deepEqual(writes(), []);
assert.deepEqual(await set({ base: "release/1" }), { number: 7, before: "main", after: "release/1", changed: true,
why: "it was opened against the wrong trunk" });
assert.deepEqual(writes(), ["PATCH /repos/novox/mesh-catalog/pulls/7"]);
assert.deepEqual(state.sent[0].body, { base: "release/1" });
assert.equal(state.base, "release/1");
});
test("the two new tools are named for what mesh_search is asked, and gitea_api still refuses both settings", () => {
assert.match(tool("gitea_default_branch_set").description, /default branch/i);
assert.match(tool("gitea_pull_base_set").description, /pull request.s base/i);
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b", { default_branch: "x" }));
assert.ok("refused" in judgeApiCall("PATCH", "/repos/a/b/pulls/7", { base: "x" }));
});
+2 -2
View File
@@ -45,7 +45,7 @@ test("a new head beyond the newest 20 open pull requests is announced, exactly o
const { headsToAnnounce } = await import("../pulls.ts"); const { headsToAnnounce } = await import("../pulls.ts");
// 25 open, every head announced before; #3 — the 23rd most recently updated — got a new head. // 25 open, every head announced before; #3 — the 23rd most recently updated — got a new head.
const announced: Record<string, string> = {}; const announced: Record<string, string> = {};
for (let n = 1; n <= 25; n++) announced[`novox/hq#${n}@main`] = sha(n, "a"); for (let n = 1; n <= 25; n++) announced[`novox/hq#${n}`] = sha(n, "a");
const { client, server, asked } = await serve(25, { 3: sha(3, "b") }); const { client, server, asked } = await serve(25, { 3: sha(3, "b") });
try { try {
// The fault: the newest 20 alone do not hold it. // The fault: the newest 20 alone do not hold it.
@@ -58,7 +58,7 @@ test("a new head beyond the newest 20 open pull requests is announced, exactly o
const open = await client.listOpenPullsSince("novox", "hq", ""); const open = await client.listOpenPullsSince("novox", "hq", "");
for (const p of headsToAnnounce("novox/hq", open, announced)) { for (const p of headsToAnnounce("novox/hq", open, announced)) {
said.push(`#${p.number}@${p.head_sha}`); said.push(`#${p.number}@${p.head_sha}`);
announced[`novox/hq#${p.number}@${p.base}`] = String(p.head_sha); announced[`novox/hq#${p.number}`] = String(p.head_sha);
} }
} }
assert.deepEqual(said, [`#3@${sha(3, "b")}`], "the new head past the newest 20, announced once over two passes"); assert.deepEqual(said, [`#3@${sha(3, "b")}`], "the new head past the newest 20, announced once over two passes");
-250
View File
@@ -1,250 +0,0 @@
// gitea_branch_protection_set_full (the operator, 2026-10-28: "just make a generic tool so you can setup any
// configuration"): any field of a branch protection rule, only the fields given, as the forge's administrator; it
// answers the rule before and after, and whether the controller's trunk rule (judgedRule, mesh-control
// build_source.go) now holds. Against a fake forge that keeps the rules and answers them to the administrator alone.
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { GiteaAdmin, GiteaClient } from "../dist/client.js";
import { ConfiguredToken } from "../dist/token.js";
import { getGiteaTools } from "../dist/tools/index.js";
import { trunkRule } from "../dist/protectfull.js";
const adminBasic = "Basic " + Buffer.from("mesh-admin:secret").toString("base64");
let rules: any[] = [];
let repoState: any = {};
const branches = ["master", "main"];
let sent: { method: string; path: string; body: any }[] = [];
beforeEach(() => {
// de-spiegel's main as found: no push, but the operator alone merges and an administrator merges past it.
rules = [{ rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["jschoubben"],
enable_status_check: false, status_check_contexts: [], block_admin_merge_override: false, required_approvals: 0,
enable_push_whitelist: false, push_whitelist_deploy_keys: false, enable_force_push: false, enable_force_push_allowlist: false,
force_push_allowlist_deploy_keys: false, merge_whitelist_teams: [] }];
source = {};
repoState = { full_name: "novox/de-spiegel", default_branch: "master", description: "", has_wiki: true };
sent = [];
});
// What gitea 1.27.3's EditBranchProtection does with a body (routers/api/v1/repo/branch.go): the push whitelist flags are
// read only beside enable_push true and cleared by enable_push false, the force-push allowlist flags likewise with
// enable_force_push; teams are passed over on a repository no organisation owns (de-spiegel here, for the test).
function giteaEdit(r: any, body: any) {
const nested = ["enable_push", "enable_push_whitelist", "push_whitelist_deploy_keys", "enable_force_push",
"enable_force_push_allowlist", "force_push_allowlist_deploy_keys", "merge_whitelist_teams", "push_whitelist_teams"];
for (const [k, v] of Object.entries(body)) if (!nested.includes(k) && k !== "rule_name") r[k] = v;
if (body.enable_push === false) Object.assign(r, { enable_push: false, enable_push_whitelist: false, push_whitelist_deploy_keys: false });
if (body.enable_push === true) {
r.enable_push = true;
if (body.enable_push_whitelist !== undefined) r.enable_push_whitelist = body.enable_push_whitelist;
if (body.push_whitelist_deploy_keys !== undefined) r.push_whitelist_deploy_keys = body.push_whitelist_deploy_keys;
}
if (body.enable_force_push === false) Object.assign(r, { enable_force_push: false, enable_force_push_allowlist: false, force_push_allowlist_deploy_keys: false });
if (body.enable_force_push === true) {
r.enable_force_push = true;
if (body.enable_force_push_allowlist !== undefined) r.enable_force_push_allowlist = body.enable_force_push_allowlist;
if (body.force_push_allowlist_deploy_keys !== undefined) r.force_push_allowlist_deploy_keys = body.force_push_allowlist_deploy_keys;
}
}
const server = createServer((req, res) => {
const path = (req.url ?? "").replace(/^\/api\/v1/, "");
const send = (status: number, body: unknown) => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(JSON.stringify(body)); };
if (req.headers.authorization !== adminBasic) return send(403, { message: "not an administrator of the repository" });
let raw = "";
req.on("data", (c) => (raw += c));
req.on("end", () => {
const body = raw ? JSON.parse(raw) : undefined;
if (req.method !== "GET") sent.push({ method: req.method!, path, body });
if (path === "/repos/novox/de-spiegel") {
if (req.method === "GET") return send(200, repoState);
if (req.method === "PATCH") {
// gitea's updateBasicProperties: a default branch that does not exist is passed over, the call still 200.
// enable_prune is a mirror's, kept beside the repository and never in its answer; mirror_interval is answered in Go's
// duration form.
const { default_branch, enable_prune, mirror_interval, ...rest } = body;
Object.assign(repoState, rest);
if (mirror_interval !== undefined) repoState.mirror_interval = mirror_interval === "8h" ? "8h0m0s" : mirror_interval;
if (default_branch !== undefined && branches.includes(default_branch)) repoState.default_branch = default_branch;
return send(200, repoState);
}
}
const m = path.match(/^\/repos\/novox\/de-spiegel\/branch_protections(?:\/(.+))?$/);
if (!m) return send(404, { message: "not found" });
if (req.method === "GET" && !m[1]) return send(200, rules);
if (req.method === "PATCH" && m[1]) {
const r = rules.find((x) => x.rule_name === decodeURIComponent(m[1]));
if (!r) return send(404, { message: "no rule" });
giteaEdit(r, body);
return send(200, r);
}
if (req.method === "POST" && !m[1]) { const r = { rule_name: body.rule_name }; giteaEdit(r, body); rules.push(r); return send(201, r); }
send(405, { message: "no" });
});
});
await new Promise<void>((r) => server.listen(0, "127.0.0.1", () => r()));
after(() => new Promise<void>((r) => server.close(() => r())));
const { port } = server.address() as { port: number };
const url = `http://127.0.0.1:${port}`;
// The gate's source as the tools read it (mesh-delivery-gate.source): one key per trunk, each naming the repositories
// installed from it. A test sets it; empty is a source that names no trunk of de-spiegel.
let source: Record<string, { heads: Record<string, string> }> = {};
const readSource = async () => source;
const tools = getGiteaTools(new GiteaClient(url, new ConfiguredToken("tools-token")), () => new GiteaAdmin(url, "mesh-admin", "secret"), readSource);
const setRepo = (args: Record<string, unknown>) =>
tools.find((t) => t.name === "gitea_repo_settings_set_full")!.run({ owner: "novox", repo: "de-spiegel", why: "L2", ...args });
const setFull = (args: Record<string, unknown>) =>
tools.find((t) => t.name === "gitea_branch_protection_set_full")!.run({ owner: "novox", repo: "de-spiegel", branch: "main", why: "L2", ...args });
test("it sets the merge whitelist and the administrator's override, only the fields given, and says the trunk rule now holds", async () => {
const out: any = await setFull({ merge_whitelist_usernames: ["mesh-tools", "jschoubben"], block_admin_merge_override: true });
assert.deepEqual(sent, [{ method: "PATCH", path: "/repos/novox/de-spiegel/branch_protections/main",
body: { merge_whitelist_usernames: ["mesh-tools", "jschoubben"], block_admin_merge_override: true } }]);
assert.deepEqual(out.before.merge_whitelist, ["jschoubben"]);
assert.equal(out.before.admin_may_override, true);
assert.deepEqual(out.after.merge_whitelist, ["mesh-tools", "jschoubben"]);
assert.equal(out.after.admin_may_override, false);
assert.equal(out.why, "L2");
assert.equal(out.trunk_rule.holds, true, out.trunk_rule.why);
});
test("it loosens as told too, and says the trunk rule no longer holds and why", async () => {
const out: any = await setFull({ enable_push: true, status_check_contexts: ["mesh/check"], enable_status_check: true, required_approvals: 1 });
assert.equal(out.after.push, true);
assert.deepEqual(out.after.required_statuses, ["mesh/check"]);
assert.equal(out.after.required_approvals, 1);
assert.equal(out.trunk_rule.holds, false);
assert.match(out.trunk_rule.why, /push to main directly/);
});
test("a branch with no rule gets one with the fields given", async () => {
const out: any = await setFull({ branch: "release/*", enable_push: false, protected_file_patterns: "*.lock" });
assert.equal(out.created, true);
assert.deepEqual(sent[0], { method: "POST", path: "/repos/novox/de-spiegel/branch_protections",
body: { rule_name: "release/*", enable_push: false, protected_file_patterns: "*.lock" } });
});
test("an unknown field, a field of the wrong kind, no field, no reason or a login that is none: refused, nothing sent", async () => {
await assert.rejects(setFull({ enable_pushes: true }), /no field of a branch protection rule/);
await assert.rejects(setFull({ enable_push: "yes" }), /true or false/);
await assert.rejects(setFull({ required_approvals: -1 }), /whole number/);
await assert.rejects(setFull({ merge_whitelist_usernames: ["../x"] }), /no forge login/);
await assert.rejects(setFull({}), /at least one field/);
await assert.rejects(setFull({ why: "", enable_push: false }), /say why/);
assert.deepEqual(sent, []);
});
// judgedRule's cases (mesh-control cmd/mesh-controller/build_source.go), each in its own words.
test("the trunk rule is judged as the controller judges it", () => {
const ok = { rule_name: "main", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true };
const cases: [any[], string, boolean, RegExp][] = [
[[], "main", false, /no protection rule covers main/],
[[{ ...ok, enable_push: true }], "main", false, /push to main directly/],
[[{ ...ok, enable_merge_whitelist: false }], "main", false, /no merge whitelist/],
[[{ ...ok, merge_whitelist_usernames: ["jschoubben"] }], "main", false, /and not mesh-tools/],
[[{ ...ok, merge_whitelist_usernames: ["mesh-tools", "agent-g14"] }], "main", false, /agent's account/],
[[{ ...ok, block_admin_merge_override: false }], "main", false, /administrator/],
[[ok], "main", true, /takes no push/],
[[{ ...ok, rule_name: "ma*" }], "main", true, /ma\*/],
[[{ ...ok, rule_name: "ma*", enable_push: true }, { ...ok }], "main", true, /rule main/],
];
for (const [rs, branch, holds, why] of cases) {
const got = trunkRule(rs, branch);
assert.equal(got.holds, holds, `${JSON.stringify(rs)}: ${got.why}`);
assert.match(got.why, why);
}
});
// Review round 1 of feat/protect-as-trunk: gitea passes over a dependent field without its parent set true, and answers 200.
test("a field the forge reads only beside its parent is refused without it; with it, it is applied", async () => {
await assert.rejects(setFull({ enable_push_whitelist: true }), /only when the same call sets enable_push: true/);
await assert.rejects(setFull({ force_push_allowlist_deploy_keys: true, enable_force_push: false }), /enable_force_push: true/);
assert.deepEqual(sent, []);
const out: any = await setFull({ enable_push: true, enable_push_whitelist: true, push_whitelist_usernames: ["jschoubben"] });
assert.equal(out.after.fields.enable_push_whitelist, true);
await setFull({ enable_push: false, enable_push_whitelist: false }); // a false beside a false parent is what the forge does
});
test("a field the forge answered and did not apply fails the call, naming it; what it changed beside the call is said", async () => {
await assert.rejects(setFull({ merge_whitelist_teams: ["owners"] }), /did not apply merge_whitelist_teams \(sent \["owners"\], the forge holds \[\]\)/);
rules[0].enable_push = true; rules[0].enable_push_whitelist = true;
const out: any = await setFull({ enable_push: false });
assert.deepEqual(out.also_changed, ["enable_push_whitelist"]);
});
test("gitea_repo_settings_set_full sets the default branch, and fails loudly on a branch the forge passed over", async () => {
const out: any = await setRepo({ default_branch: "main" });
assert.deepEqual(sent.at(-1), { method: "PATCH", path: "/repos/novox/de-spiegel", body: { default_branch: "main" } });
assert.equal(out.before.default_branch, "master");
assert.equal(out.after.default_branch, "main");
await assert.rejects(setRepo({ default_branch: "trunk" }), /did not apply default_branch/);
await assert.rejects(setRepo({ name: "x" }), /no field of a repository's settings/);
await assert.rejects(setRepo({ why: "", has_wiki: false }), /say why/);
});
test("every field gitea 1.27.3 edits on a rule is taken: 32, the bypass allowlist among them", async () => {
const { PROTECTION_FIELDS } = await import("../dist/protectfull.js");
assert.equal(Object.keys(PROTECTION_FIELDS).length, 32);
for (const f of ["enable_bypass_allowlist", "bypass_allowlist_usernames", "bypass_allowlist_teams"]) assert.ok(f in PROTECTION_FIELDS, f);
});
// Review round 2: the settings tool's read-back meets the forge's own answer.
test("A: enable_prune, which the forge never answers, is said as not read back, never failed", async () => {
const pruned: any = await setRepo({ enable_prune: true });
assert.deepEqual(pruned.not_read_back, ["enable_prune"]);
});
test("B: mirror_interval is compared as a duration: 8h and the forge's 8h0m0s are one", async () => {
const mirror: any = await setRepo({ mirror_interval: "8h" });
assert.equal(mirror.after.mirror_interval, "8h0m0s");
await assert.rejects(setRepo({ mirror_interval: "often" }), /duration/);
});
test("C: allow_merge_update, default_update_style and has_code are taken; projects_mode needs has_projects; what is left out is named", async () => {
const more: any = await setRepo({ allow_merge_update: true, default_update_style: "rebase", has_code: true });
assert.deepEqual(more.set, { allow_merge_update: true, default_update_style: "rebase", has_code: true });
await assert.rejects(setRepo({ projects_mode: "repo" }), /only when the same call sets has_projects: true/);
await setRepo({ has_projects: true, projects_mode: "repo" });
const desc = tools.find((t) => t.name === "gitea_repo_settings_set_full")!.description;
for (const w of ["name", "mirror_username", "mirror_password", "mirror_token", "tracker", "wiki"]) assert.match(desc, new RegExp(w));
});
// The operator, 2026-10-28 16:4x: a call waits for their Approve only if it removes branch protection or allows a
// force-push to main. No tool of this module can ask yet (no TypeScript asker; ADR 0286's mark is unbuilt in the
// runner), so until one can, such a call is refused, with nothing sent, and says it is the operator's; everything else
// runs at once.
test("a force-push onto a rule covering main is refused, nothing sent; on another branch, and tightening, it runs", async () => {
for (const args of [{ enable_force_push: true }, { enable_force_push: true, enable_force_push_allowlist: true },
{ force_push_allowlist_usernames: ["jschoubben"] }, { force_push_allowlist_teams: ["owners"] },
{ branch: "ma*", enable_force_push: true }, { branch: "**", force_push_allowlist_usernames: ["x"] }]) {
await assert.rejects(setFull(args), /force-push to a trunk is the operator's/, JSON.stringify(args));
}
assert.deepEqual(sent, []);
await setFull({ branch: "release/*", enable_force_push: true });
const tight: any = await setFull({ enable_force_push: false, force_push_allowlist_usernames: [] });
assert.equal(tight.trunk_rule.holds, false, "trunk_rule is in the answer");
});
// The operator, 2026-10-28 16:5x: the gates and trunk_rule apply to every trunk, the branches the mesh installs from, as
// the gate's source names them; "main" only when it names none, and said.
test("a trunk the gate's source names, beta, is gated and judged as main is; main is then no trunk of the repository", async () => {
source = { main: { heads: { "novox/okioki": "a1" } }, beta: { heads: { "novox/de-spiegel": "b2" } } };
await assert.rejects(setFull({ branch: "beta", enable_force_push: true }), /force-push to a trunk is the operator's/);
await assert.rejects(setFull({ branch: "b*", force_push_allowlist_usernames: ["x"] }), /covers beta/);
assert.deepEqual(sent, []);
const main: any = await setFull({ enable_force_push: true });
assert.deepEqual(main.trunks, { names: ["beta"], from: "mesh-delivery-gate.source" });
assert.equal(main.trunk_rule.is_trunk, false);
const beta: any = await setFull({ branch: "beta", enable_push: false, enable_merge_whitelist: true, merge_whitelist_usernames: ["mesh-tools"], block_admin_merge_override: true });
assert.equal(beta.trunk_rule.is_trunk, true);
assert.equal(beta.trunk_rule.holds, true, beta.trunk_rule.why);
});
test("a source that names no trunk of the repository falls back to main, and says so", async () => {
const out: any = await setFull({ block_admin_merge_override: true });
assert.equal(out.trunks.names[0], "main");
assert.match(out.trunks.from, /names no trunk of novox\/de-spiegel: main assumed/);
assert.equal(out.trunk_rule.is_trunk, true);
});
+2 -13
View File
@@ -8,27 +8,16 @@ import { createServer } from "node:http";
test("each open pull request's new head is announced once, and nothing closed or merged", async () => { test("each open pull request's new head is announced once, and nothing closed or merged", async () => {
const { headsToAnnounce } = await import("../pulls.ts"); const { headsToAnnounce } = await import("../pulls.ts");
const pulls = [ const pulls = [
{ number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", base: "main", html_url: "" }, { number: 1, title: "a", state: "open", merged: false, head_sha: "aaa", html_url: "" },
{ number: 2, title: "b", state: "open", merged: false, head_sha: "bbb", html_url: "" }, { number: 2, title: "b", state: "open", merged: false, head_sha: "bbb", html_url: "" },
{ number: 3, title: "c", state: "closed", merged: true, head_sha: "ccc", html_url: "" }, { number: 3, title: "c", state: "closed", merged: true, head_sha: "ccc", html_url: "" },
{ number: 4, title: "d", state: "open", merged: false, html_url: "" }, { number: 4, title: "d", state: "open", merged: false, html_url: "" },
]; ];
const announced = { "novox/mesh-catalog#1@main": "aaa", "novox/mesh-catalog#2@": "old" }; const announced = { "novox/mesh-catalog#1": "aaa", "novox/mesh-catalog#2": "old" };
assert.deepEqual(headsToAnnounce("novox/mesh-catalog", pulls, announced).map((p) => p.number), [2], assert.deepEqual(headsToAnnounce("novox/mesh-catalog", pulls, announced).map((p) => p.number), [2],
"only a head not announced, of a pull request that is open"); "only a head not announced, of a pull request that is open");
}); });
test("a pull request retargeted keeps its head and is announced again, into its new base (de-spiegel #6)", async () => {
const { headsToAnnounce, headKey } = await import("../pulls.ts");
const was = { number: 6, title: "a", state: "open", merged: false, head_sha: "aaa", base: "old-trunk", html_url: "" };
const announced: Record<string, string> = { [headKey("novox/de-spiegel", 6, "old-trunk")]: "aaa" };
assert.deepEqual(headsToAnnounce("novox/de-spiegel", [was], announced), [], "the same head into the same base is announced once");
const retargeted = { ...was, base: "main" };
assert.deepEqual(headsToAnnounce("novox/de-spiegel", [retargeted], announced).map((p) => p.number), [6],
"the same head into a new base is judged afresh");
assert.notEqual(headKey("novox/de-spiegel", 6, "main"), headKey("novox/de-spiegel", 6, "old-trunk"));
});
test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => { test("a verdict is the head commit's status; an error is the forge's error, never a success", async () => {
const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts"); const { statusFor, commentFor, CHECK_CONTEXT } = await import("../pulls.ts");
const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" }; const base = { owner: "novox", repo: "mesh-catalog", number: 7, commit: "0123456789abcdef", id: "build-1", on: "laptop" };
+1 -2
View File
@@ -538,8 +538,7 @@ test("the tools register once there is a way to a token, and the first call mint
"gitea_get_file", "gitea_get_file",
"gitea_list_branches", "gitea_list_branches",
"gitea_delete_branch", "gitea_delete_branch",
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_repo_settings_set_full", "gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set",
"gitea_default_branch_set", "gitea_pull_base_set",
"gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains", "gitea_note_append", "gitea_delivery_view", "gitea_commit_statuses", "gitea_open_pulls", "gitea_commit_trees", "gitea_contains",
"gitea_list_labels", "gitea_create_label", "gitea_list_labels", "gitea_create_label",
"gitea_api", "gitea_api",
-73
View File
@@ -1,73 +0,0 @@
import assert from "node:assert/strict";
import { mock, test } from "node:test";
import type { GiteaPull } from "../client.ts";
// **No poll of the forge** (issue 1422, notes 10 and 11): the forge's events announce heads and merges; a pass runs once at
// the start and when the receiver listens.
const edited = (base: string) => ({ name: "forge.pull-request.edited", body: { owner: "novox", repo: "hq", number: 7, base, merged: false } });
function deps() {
const calls: string[] = [];
return { calls, d: {
getRepo: async () => ({ r: 1 }),
getPull: async () => ({ state: "open", base: "trunk" }) as any,
announceHead: async () => { calls.push("head"); },
announceMerge: async () => { calls.push("merge"); },
announceClosed: async () => { calls.push("closed"); },
siblingMoves: async () => { calls.push("siblings"); },
} };
}
test("an edited event announces the head into the base it now has, without a poll", async () => {
const { actOn } = await import("../watch.ts");
const { calls, d } = deps();
await actOn(edited("trunk"), d);
assert.deepEqual(calls, ["head"]);
});
test("a close without a merge is announced by its event, a merged close is left to the merge event", async () => {
const { actOn } = await import("../watch.ts");
const { calls, d } = deps();
await actOn({ name: "forge.pull-request.closed", body: { owner: "novox", repo: "hq", number: 7, merged: false } }, d);
assert.deepEqual(calls, ["closed", "siblings"]);
calls.length = 0;
await actOn({ name: "forge.pull-request.closed", body: { owner: "novox", repo: "hq", number: 7, merged: true } }, d);
assert.deepEqual(calls, []);
});
test("no timer polls after the start: one pass ends and nothing is scheduled; a failed one is asked again", async () => {
const { startPass } = await import("../watch.ts");
mock.timers.enable({ apis: ["setTimeout", "setInterval"] });
try {
let runs = 0;
await startPass(async () => { runs++; }, { retryMs: 60_000, log: () => {} });
mock.timers.tick(24 * 3600_000);
assert.equal(runs, 1, "a day later the forge was not asked again");
let tries = 0;
const retried = startPass(async () => { if (++tries < 2) throw new Error("forge down"); }, { retryMs: 60_000, log: () => {} });
await Promise.resolve(); await Promise.resolve();
mock.timers.tick(60_000);
await retried;
assert.equal(tries, 2);
} finally {
mock.timers.reset();
}
});
test("a start pass announces a head nobody announced, and only remembers one the forge shows a check of", async () => {
const { announceNewHeads } = await import("../watch.ts");
const pull = (n: number, sha: string): GiteaPull => ({ number: n, state: "open", head_sha: sha, base: "main",
updated_at: new Date().toISOString() } as unknown as GiteaPull);
const announced: number[] = [], said: string[] = [];
const heads: Record<string, string> = {};
await announceNewHeads("novox/hq", [pull(1, "a".repeat(40)), pull(2, "b".repeat(40))], heads, false,
new Date(Date.now() - 24 * 3600_000).toISOString(), {
alreadyChecked: async (p) => p.number === 2,
announceHead: async (p) => { announced.push(p.number); return true; },
missed: (w) => said.push(w),
});
assert.deepEqual(announced, [1]);
assert.equal(heads["novox/hq#2@main"], "b".repeat(40), "the checked head is remembered, not announced");
assert.equal(said.length, 1);
});
+11 -7
View File
@@ -251,8 +251,7 @@ test("a forge that never delivers is said loudly once, and unhealthy, when the p
assert.equal(r.healthy, true); assert.equal(r.healthy, true);
assert.equal(r.verified.count, 1); assert.equal(r.verified.count, 1);
assert.equal(r.verified.last.event, "push"); assert.equal(r.verified.last.event, "push");
assert.equal(r.polls.every_seconds, null); assert.equal(r.polls.every_seconds, 300);
assert.equal(r.polls.hearing, true);
assert.match(said.join("\n"), /heard again/); assert.match(said.join("\n"), /heard again/);
}); });
@@ -315,19 +314,24 @@ test("without a receiver by configuration the polls alone watch, healthy; a rece
assert.match(r.why, /403/); assert.match(r.why, /403/);
}); });
test("nothing is paced: the report says no timer asks the forge, and a miss is still counted and said", async () => { test("the polls look every 30 s until events are heard, every five minutes while they are, and 30 s again on a miss", async () => {
const { Pace } = await import("../webhook.ts"); const { Pace } = await import("../webhook.ts");
let now = 1_000_000; let now = 1_000_000;
const said: string[] = []; const said: string[] = [];
const pace = new Pace(() => now, (l) => said.push(l)); const pace = new Pace(() => now, (l) => said.push(l));
assert.equal((pace as any).every, undefined, "no pace to ask"); assert.equal(pace.every(), 30_000, "no delivery yet");
assert.equal((pace.report() as any).polls.every_seconds, null); pace.heard();
assert.equal(pace.every(), 300_000, "a delivery heard");
now += 11 * 60_000;
assert.equal(pace.every(), 30_000, "ten minutes without a delivery");
pace.heard(); pace.heard();
assert.equal((pace.report() as any).polls.hearing, true);
now += 1000; now += 1000;
pace.miss("novox/hq#7 at aaaaaaaa"); pace.miss("novox/hq#7 at aaaaaaaa");
assert.equal((pace.report() as any).polls.hearing, false, "the pass found what no event said"); assert.equal(pace.every(), 30_000, "the poll found what no event said");
assert.match(said.join("\n"), /MISSED novox\/hq#7/); assert.match(said.join("\n"), /MISSED novox\/hq#7/);
now += 1000;
pace.heard();
assert.equal(pace.every(), 300_000, "the next delivery");
}); });
// Issue 1079: a pull request's edit event carries its description, so mesh-delivery reads a Reviewed-by line written after // Issue 1079: a pull request's edit event carries its description, so mesh-delivery reads a Reviewed-by line written after
+19 -158
View File
@@ -12,8 +12,6 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"
import { emit } from "@novox/mesh-sdk/events"; import { emit } from "@novox/mesh-sdk/events";
import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js"; import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.js";
import { readTeamRights } from "../teamrights.js"; import { readTeamRights } from "../teamrights.js";
import { state } from "@novox/mesh-sdk/state";
import { PROTECTION_FIELDS, REPO_FIELDS, alsoChanged, forcePushToTrunk, fullProtectionBody, sourceFromBus, trunksOf, type SourceRead, givenFields, trunkRule, unapplied } from "../protectfull.js";
import { appendNote, run, viewBody, viewComment } from "../delivery.js"; import { appendNote, run, viewBody, viewComment } from "../delivery.js";
import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js"; import { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
import { import {
@@ -79,13 +77,24 @@ function namedPaths(raw: unknown): Record<string, string[]> | undefined {
return out; return out;
} }
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] { /** The `listed` argument of gitea_commit_trees: [directory], each plain (no `.` or `..` segment, not absolute, the root
return namesChecked(allGiteaTools(gitea, admin, source)); * the empty name); anything else is refused before the forge is asked. Absent is absent. */
function listedDirs(raw: unknown): string[] | undefined {
if (raw === undefined || raw === null) return undefined;
if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("listed is [directory, ...]");
for (const d of raw as string[]) {
if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`listed: ${JSON.stringify(d)} is not a plain relative directory`);
}
return raw as string[];
}
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return namesChecked(allGiteaTools(gitea, admin));
} }
// gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one // gitea_merge_pull_request is served from the watcher's bundle (merge.ts, index.ts), beside the merge check's one
// writer (novox/hq issue 456, ADR 0323). // writer (novox/hq issue 456, ADR 0323).
function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined, source: SourceRead): ToolDefinition[] { function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
return [ return [
// ---- Repositories ---- // ---- Repositories ----
{ {
@@ -422,9 +431,8 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
}, },
run: async (args) => { run: async (args) => {
const owner = String(args.owner), repo = String(args.repo); const owner = String(args.owner), repo = String(args.repo);
// Read as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the repository // The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the
// administrator's, which the tools' account is not. gitea_team_rights reads, and gitea_branch_protection_set_full // repository administrator's, which the tools' account is not. No other tool uses this account.
// and gitea_repo_settings_set_full write, with the same account.
const rules = await (admin ? admin() : gitea).branchProtections(owner, repo); const rules = await (admin ? admin() : gitea).branchProtections(owner, repo);
if (!args.branch) return { rules: rules.map(summarised) }; if (!args.branch) return { rules: rules.map(summarised) };
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null; const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null;
@@ -480,154 +488,6 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
}, },
}, },
{
name: "gitea_branch_protection_set_full",
description: "Set any field of a branch protection rule — protect a branch as a trunk, set its merge whitelist (users and teams), " +
"push and push whitelist, force push and its allowlist, the bypass allowlist, required status checks, required approvals and " +
"the approvals whitelist, block on rejected reviews, block on an outdated branch, dismiss stale approvals, signed commits, " +
"protected and unprotected file patterns, and the administrator's merge override (block_admin_merge_override): the 32 " +
"fields gitea 1.27.3's API edits, by their own names. Only the fields given change; a branch with no rule gets one. " +
"enable_push_whitelist and push_whitelist_deploy_keys need enable_push: true in the same call, the force-push allowlist " +
"flags enable_force_push: true (the forge ignores them otherwise). The rule is read back after the write: a field the " +
"forge did not apply fails the call, naming it; also_changed names what the forge changed beside the call. Generic: it " +
"tightens or loosens as told (the operator, 2026-10-28), so a reason (why) is required and logged; but a call that lets a force-push onto a rule covering a trunk is refused, nothing sent, until the module can ask the operator's Approve for it. A repository's trunks, the branches the mesh installs from, are read from mesh-delivery-gate's source at the call (main when it names none, said in trunks.from). As the forge's " +
"administrator. Answers the rule before and after, the repository's trunks, and trunk_rule: whether the branch is a trunk (is_trunk) and whether the controller now registers a module from " +
"this branch (judgedRule, mesh-control build_source.go: no push, a merge whitelist naming mesh-tools and no " +
"agent-<node>, no administrator override) and why not.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
branch: { type: "string", description: "the branch (the rule's name, or a glob) whose rule is set" },
why: { type: "string", description: "why, in a sentence: logged and answered" },
...Object.fromEntries(Object.entries(PROTECTION_FIELDS).map(([name, f]) => [name, f.kind === "logins" || f.kind === "strings"
? { type: "array", items: { type: "string" }, description: f.says }
: { type: f.kind === "number" ? "number" : f.kind, description: f.says }])),
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
const why = String(args.why ?? "").trim();
if (!branch) throw new Error("name the branch whose rule is set");
if (!why) throw new Error("say why (why): a branch's protection is changed for a reason, which is logged");
pathSegments("branch", branch);
const given = givenFields(args);
if (!given.length) throw new Error(`name at least one field to set: ${Object.keys(PROTECTION_FIELDS).join(", ")}`);
const body = fullProtectionBody(args, given);
const trunks = await trunksOf(owner, repo, source);
const operators = forcePushToTrunk(branch, body, trunks.names);
if (operators) throw new Refused(operators);
const forge = admin ? admin() : gitea;
const rules = await forge.branchProtections(owner, repo);
const before = rules.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? null;
console.log(`[gitea] gitea_branch_protection_set_full ${owner}/${repo} ${branch}: ${JSON.stringify(body)} — why: ${why}`);
const rule = await forge.writeBranchProtection(owner, repo, branch, body, !before);
const after = await forge.branchProtections(owner, repo);
const read = after.find((p) => (p.rule_name ?? p.branch_name) === branch) ?? rule;
const missed = unapplied(body, read);
if (missed.length) {
throw new Error(`the forge answered the write of ${owner}/${repo} ${branch}'s rule but did not apply ${missed.join("; ")}. ` +
`The rule it holds now: ${JSON.stringify(summarised(read))}`);
}
return {
branch, created: !before, why, set: body, also_changed: alsoChanged(before, read, body),
before: before ? { ...summarised(before), fields: before } : null,
after: { ...summarised(read), fields: read },
trunks, trunk_rule: trunkRule(after, branch, trunks.names),
};
},
},
{
name: "gitea_repo_settings_set_full",
description: "Set a repository's settings as the forge's administrator — its default branch (e.g. master to main), " +
"description, website, private, template, archived, the issues, wiki, pull requests, projects, releases, packages and " +
"actions units, the merge styles it allows and its default merge style, delete the branch after merge, and the mirror " +
"settings: gitea 1.27.3's EditRepoOption by its own names. Left out: name (a rename moves every module's source " +
"address), the internal tracker, external tracker and external wiki objects, and mirror_username, mirror_password " +
"and mirror_token, which are credentials. projects_mode needs has_projects: true in the same call. Only the fields " +
"given change. The repository is read back: a field the forge did not apply (a default branch that does not exist) " +
"fails the call, naming it; mirror_interval is compared as a duration, and enable_prune, which the forge never " +
"answers, is listed in not_read_back. A reason (why) is required and logged. Answers the settings before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
why: { type: "string", description: "why, in a sentence: logged and answered" },
...Object.fromEntries(Object.entries(REPO_FIELDS).map(([name, f]) => [name, { type: f.kind, description: f.says }])),
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), why = String(args.why ?? "").trim();
if (!why) throw new Error("say why (why): a repository's settings are changed for a reason, which is logged");
if (!admin) throw new Error("the forge's administrator login is not set: a repository's settings are written with it");
const given = givenFields(args);
if (!given.length) throw new Error(`name at least one setting: ${Object.keys(REPO_FIELDS).join(", ")}`);
const body = fullProtectionBody(args, given, REPO_FIELDS, "a repository's settings");
const forge = admin();
const before = await forge.repoSettings(owner, repo);
console.log(`[gitea] gitea_repo_settings_set_full ${owner}/${repo}: ${JSON.stringify(body)} — why: ${why}`);
await forge.editRepoSettings(owner, repo, body);
const after = await forge.repoSettings(owner, repo);
const missed = unapplied(body, after, REPO_FIELDS);
if (missed.length) throw new Error(`the forge answered the write of ${owner}/${repo}'s settings but did not apply ${missed.join("; ")}`);
const pick = (r: Record<string, unknown>) => Object.fromEntries(Object.keys(REPO_FIELDS).filter((k) => k in r).map((k) => [k, r[k]]));
return { repository: `${owner}/${repo}`, why, set: body, before: pick(before), after: pick(after),
not_read_back: Object.keys(body).filter((k) => REPO_FIELDS[k]?.unread) };
},
},
// ---- Two settings gitea_api refuses (issue 1422 note 7; guard.ts), each its own named tool ----
{
name: "gitea_default_branch_set",
description: "Set a repository's default branch — the branch the forge shows, clones and opens pull requests against, and the one gitea_api never writes a file to. Refused when the branch does not exist, when no protection rule guards it (a default nothing protects would open it to direct writes), or when no reason (why) is given; acts as the tools' account, so the forge refuses it where that account is no administrator of the repository. Answers the default branch 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 make the default; it must exist and be guarded by a protection rule" },
why: { type: "string", description: "why, in a sentence: kept in the call's record" },
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), branch = String(args.branch ?? "").trim();
const why = String(args.why ?? "").trim();
if (!branch) throw new Error("name the branch to make the default");
if (!why) throw new Error("say why (why): a default branch is changed for a reason, which the call's record keeps");
pathSegments("branch", branch);
const state = await gitea.branchState(owner, repo, branch);
if (!state) throw new Refused(`${owner}/${repo} has no branch ${branch}, so it cannot be the default branch. Nothing was sent`);
if (!state.protected) {
throw new Refused(`no protection rule guards ${branch} of ${owner}/${repo}: a default branch nothing protects takes direct writes ` +
`(gitea_branch_protection_set guards it first). Nothing was sent`);
}
const before = String((await gitea.getRepo(owner, repo)).default_branch ?? "");
if (before === branch) return { branch, before, after: before, changed: false, why };
const after = String((await gitea.setDefaultBranch(owner, repo, branch)).default_branch ?? "");
return { branch, before, after, changed: before !== after, why };
},
},
{
name: "gitea_pull_base_set",
description: "Change an open pull request's base — the branch it merges into — keeping its head. The checks were judged against the base it had, so the pull request is announced again into the new one. Refused when the base branch does not exist, when the pull request is not open, or when no reason (why) is given; acts as the tools' account. Answers the pull request's base before and after.",
input: {
owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" },
number: { type: "number", description: "the pull request's number" },
base: { type: "string", description: "the branch it should merge into; it must exist" },
why: { type: "string", description: "why, in a sentence: kept in the call's record" },
},
run: async (args) => {
const owner = String(args.owner), repo = String(args.repo), number = Number(args.number);
const base = String(args.base ?? "").trim(), why = String(args.why ?? "").trim();
if (!Number.isInteger(number) || number < 1) throw new Error(`${args.number} is not a pull request's number`);
if (!base) throw new Error("name the base branch");
if (!why) throw new Error("say why (why): a pull request's base is changed for a reason, which the call's record keeps");
pathSegments("branch", base);
if (!(await gitea.branchState(owner, repo, base))) throw new Refused(`${owner}/${repo} has no branch ${base}, so a pull request cannot merge into it. Nothing was sent`);
const had = await gitea.getPullRequest(owner, repo, number);
if (had.state !== "open" || had.merged) throw new Refused(`${owner}/${repo}#${number} is not open, so its base is not changed. Nothing was sent`);
const before = String(had.base ?? "");
if (before === base) return { number, before, after: before, changed: false, why };
const after = String((await gitea.setPullBase(owner, repo, number, base)).base ?? "");
return { number, before, after, changed: before !== after, why };
},
},
// ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ---- // ---- A delivery's note, view and statuses (novox/hq ADR 0239), asked by mesh-delivery ----
{ {
name: "gitea_note_append", name: "gitea_note_append",
@@ -722,19 +582,20 @@ function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined
}, },
{ {
name: "gitea_commit_trees", name: "gitea_commit_trees",
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.", description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. With listed, [directory], the answer also carries listed, {directory: {name: git blob id}} of the files directly under each (the catalogue's placement files, novox/hq ADR 0549 §2). A listing the forge cut is refused, never answered in part. Reads only.",
input: { input: {
owner: { type: "string", description: "the repository owner" }, owner: { type: "string", description: "the repository owner" },
repo: { type: "string", description: "the repository name" }, repo: { type: "string", description: "the repository name" },
sha: { type: "string", description: "the commit, its full 40-hex sha" }, sha: { type: "string", description: "the commit, its full 40-hex sha" },
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" }, paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
listed: { type: "array", items: { type: "string" }, description: "optional, [directory]: the files directly under each, {directory: {name: git blob id}}, are answered under listed; a directory the commit lacks lists none" },
}, },
run: async (args) => { run: async (args) => {
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim(); const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
const sha = String(args.sha ?? "").trim(); const sha = String(args.sha ?? "").trim();
if (!owner || !repo) throw new Error("name the repository: owner and repo"); if (!owner || !repo) throw new Error("name the repository: owner and repo");
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha"); if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths)); return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths), listedDirs(args.listed));
}, },
}, },
{ {
-87
View File
@@ -1,87 +0,0 @@
// What the forge's events ask of the announcer, and the one pass at a start (issue 1422, notes 10 and 11). Nothing polls the
// forge: a head or merge is announced when its event is heard, and a pass over every repository runs once when the module
// starts and again when the receiver is listening, so what happened while it was down is announced too.
//
// Apart from index.ts, which has side effects at import, so these are tested with the announcers injected.
import type { GiteaPull, GiteaRepo } from "./client.js";
import type { Announced } from "./pulls.js";
import { headKey, headsToAnnounce } from "./pulls.js";
import type { ForgeEvent } from "./webhook.js";
export interface ActDeps<R, P> {
getRepo(owner: string, repo: string): Promise<R>;
getPull(owner: string, repo: string, number: number): Promise<P & { state?: string; merged?: boolean }>;
announceHead(repo: R, pull: P): Promise<unknown>;
announceMerge(repo: R, pull: P): Promise<unknown>;
announceClosed(repo: R, pull: P): Promise<unknown>;
siblingMoves(full: string, number: number): Promise<unknown>;
}
const HEAD_EVENTS = ["forge.pull-request.opened", "forge.pull-request.synchronized", "forge.pull-request.reopened"];
/**
* What the forge's event asks of the announcer at once: a new head is announced, a merge, a close without a merge, and a
* pull request retargeted (an edit: the head is announced into the base it now has — the base is in the head's key, so an
* edit that changed nothing about the base announces nothing).
*/
export async function actOn<R, P>(event: ForgeEvent, d: ActDeps<R, P>): Promise<void> {
const b = event.body as { owner?: string; repo?: string; number?: number; merged?: boolean };
if (!b.owner || !b.repo || !b.number) return;
const edited = event.name === "forge.pull-request.edited";
const closed = event.name === "forge.pull-request.closed";
const head = HEAD_EVENTS.includes(event.name) || edited;
const merged = event.name === "forge.pull-request.merged";
if (!head && !merged && !closed) return;
if (closed && b.merged) return; // a merge is weighed with its own event (ADR 0382 §5)
const [repo, pull] = await Promise.all([d.getRepo(b.owner, b.repo), d.getPull(b.owner, b.repo, b.number)]);
if (closed) {
await d.announceClosed(repo, pull);
await d.siblingMoves(`${b.owner}/${b.repo}`, b.number);
return;
}
if (head && pull.state === "open") await d.announceHead(repo, pull);
if (merged) await d.announceMerge(repo, pull);
}
/**
* The one pass at a start, and its retry: run now; if it fails, say so and run again after `retryMs`, until one pass has
* ended. A retry of a failed pass, never a poll: after a pass has ended nothing is scheduled.
*/
export function startPass(run: () => Promise<void>, opts: { retryMs: number; log?: (l: string) => void }): Promise<void> {
const log = opts.log ?? ((l) => console.error(`[gitea] ${l}`));
return run().catch((err) => {
log(`the pass over every repository failed, asked again in ${opts.retryMs / 1000} s — ${err instanceof Error ? err.message : err}`);
return new Promise<void>((resolve) => setTimeout(() => resolve(startPass(run, opts)), opts.retryMs));
});
}
export interface HeadDeps {
/** Whether the forge shows a merge check of the head: only an announced head has one. */
alreadyChecked(pull: GiteaPull): Promise<boolean>;
/** Announce it; true when it was this call that announced it (not an event before it). */
announceHead(pull: GiteaPull): Promise<boolean>;
/** Told of a head this pass announced that no event had. */
missed(what: string): void;
}
/**
* The heads of one repository's open pull requests a pass announces. A head is announced when it is new and fresh (moved in
* the last day); at the first pass after a start (`primed` false) one the forge shows a merge check of was announced
* already, and is only remembered.
*/
export async function announceNewHeads(full: string, open: GiteaPull[], heads: Announced, primed: boolean, dayAgo: string, d: HeadDeps): Promise<void> {
for (const pull of headsToAnnounce(full, open, heads)) {
const key = headKey(full, pull.number, pull.base);
const fresh = primed || (!!pull.updated_at && pull.updated_at > dayAgo);
if (fresh && !primed && await d.alreadyChecked(pull)) {
heads[key] = String(pull.head_sha);
continue;
}
if (fresh) {
if (await d.announceHead(pull)) d.missed(`${key} at ${String(pull.head_sha).slice(0, 8)}`);
} else heads[key] = String(pull.head_sha);
}
}
export type { GiteaRepo };
+22 -19
View File
@@ -3,7 +3,7 @@
// with a secret it mints and keeps beside its token; the receiver refuses whatever is unsigned or does not verify, and // with a secret it mints and keeps beside its token; the receiver refuses whatever is unsigned or does not verify, and
// publishes each event, trimmed, on the module's events as forge.<kind>.<action> (forge.pull-request.opened, forge.push, …), // publishes each event, trimmed, on the module's events as forge.<kind>.<action> (forge.pull-request.opened, forge.push, …),
// on the shared EVENTS path its other events take; its own stream (ADR 0363) is a follow-up once issue 725 ships. // on the shared EVENTS path its other events take; its own stream (ADR 0363) is a follow-up once issue 725 ships.
// No poll is the safety net (issue 1422); the announced-heads and announced-merges records keep anything from // The polls stay as the safety net, slowed; the announced-heads and announced-merges records keep anything from
// being announced twice, whichever path saw it first. // being announced twice, whichever path saw it first.
// //
// The secret is never logged: lines say it was minted or reused and where it is kept, never what it is. // The secret is never logged: lines say it was minted or reused and where it is kept, never what it is.
@@ -144,7 +144,7 @@ export interface ReceiverOptions {
publish: (event: ForgeEvent) => Promise<void>; publish: (event: ForgeEvent) => Promise<void>;
/** Acts on it at once, beside publishing it (the announcer). */ /** Acts on it at once, beside publishing it (the announcer). */
act?: (event: ForgeEvent) => Promise<void>; act?: (event: ForgeEvent) => Promise<void>;
/** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the module knows events are /** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the polls know events are
* heard, and the module can say when it last heard one. */ * heard, and the module can say when it last heard one. */
heard?: (kind: string, delivery: string) => void; heard?: (kind: string, delivery: string) => void;
/** Told of every refused delivery, with the reason and where it came from. */ /** Told of every refused delivery, with the reason and where it came from. */
@@ -242,7 +242,7 @@ export function receiver(opts: ReceiverOptions): Server & { idle(): Promise<void
await opts.publish(event); await opts.publish(event);
if (opts.act) await opts.act(event); if (opts.act) await opts.act(event);
} catch (err) { } catch (err) {
log(`a forge event ${event.name} could not be published or acted on; the pass over every repository is asked again shortly — ${err instanceof Error ? err.message : err}`); log(`a forge event ${event.name} could not be published or acted on; the polls catch it — ${err instanceof Error ? err.message : err}`);
} }
}); });
}); });
@@ -276,13 +276,14 @@ export interface HookState {
export const SILENT_AFTER = 10 * 60_000; export const SILENT_AFTER = 10 * 60_000;
/** /**
* **Whether the forge's events are heard** (issues 729, 1422). Nothing polls the forge, so there is no pace: a delivery * **How fast the polls look, and whether the forge's events are heard** (issue 729): every five minutes only while the
* heard within the last ten minutes, and none missed since, is `hearing`. * forge's events are heard — a verified delivery within the last ten minutes, and none missed since. Until the first
* delivery, after ten minutes without one, or once the poll found something no event said, every thirty seconds.
* *
* It also keeps what `gitea_events` says: where the receiver listens, the hook as ensured, the verified and refused * It also keeps what `gitea_events` says: where the receiver listens, the hook as ensured, the verified and refused
* deliveries, and the finds of the pass over every repository (at the start and when the receiver listens) that no event said. **A forge whose events stop is said** (issue 729): once the receiver * deliveries, and the polls' finds no event said. **A forge whose events stop is said** (issue 729): once the receiver
* listens and the hook is ensured, when nothing verified for ten minutes — since the start, or since the last delivery — * listens and the hook is ensured, when nothing verified for ten minutes — since the start, or since the last delivery —
* and a pass has found at least two heads or merges no event said in that quiet (one could be a pass beating an * and the poll has found at least two heads or merges no event said in that quiet (one could be the poll beating an
* event by a second), it is logged loudly once per silent spell, and the module's events are said unhealthy until a * event by a second), it is logged loudly once per silent spell, and the module's events are said unhealthy until a
* delivery verifies. Deliveries refused since the last verified one are said unhealthy too. * delivery verifies. Deliveries refused since the last verified one are said unhealthy too.
*/ */
@@ -317,7 +318,7 @@ export class Pace {
this.notHeard = null; this.notHeard = null;
} }
/** No events are listened for, and why: by configuration (`fails` false), or because the receiver could not listen /** No events are listened for, and why: by configuration (`fails` false), or because the receiver could not listen
* or the hook could not be ensured (`fails` true, said unhealthy). Only the pass at the start watches. */ * or the hook could not be ensured (`fails` true, said unhealthy). The polls alone watch. */
unheard(why: string, fails = true): void { unheard(why: string, fails = true): void {
this.notHeard = why; this.notHeard = why;
this.notHeardFails = fails; this.notHeardFails = fails;
@@ -336,10 +337,10 @@ export class Pace {
this.refusedSpell++; this.refusedSpell++;
this.lastRefused = { at: this.now(), reason, from }; this.lastRefused = { at: this.now(), reason, from };
} }
/** The pass over every repository found a head or merge no forge event announced. */ /** The poll found a head or merge no forge event announced. */
miss(what: string): void { miss(what: string): void {
const t = this.now(); const t = this.now();
if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the pass over every repository found it`); if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the poll found it, so the polls look every 30 s again until the next event`);
this.missed = t; this.missed = t;
this.missCount++; this.missCount++;
this.lastMiss = { at: t, what }; this.lastMiss = { at: t, what };
@@ -354,8 +355,8 @@ export class Pace {
this.log(`THE FORGE'S EVENTS ARE NOT HEARD: ${this.last ? `no delivery verified since the last at ` + this.log(`THE FORGE'S EVENTS ARE NOT HEARD: ${this.last ? `no delivery verified since the last at ` +
`${new Date(this.last).toISOString()}` : "no delivery ever verified since the start"}, ${minutes} min, from the ` + `${new Date(this.last).toISOString()}` : "no delivery ever verified since the start"}, ${minutes} min, from the ` +
`system webhook at ${this.hook?.url}${refusedSince ? `; ${refusedSince} refused since, the last because ` + `system webhook at ${this.hook?.url}${refusedSince ? `; ${refusedSince} refused since, the last because ` +
`${this.lastRefused?.reason}` : ""}; the pass found ${what}; nothing else watches the forge ` + `${this.lastRefused?.reason}` : ""}; the poll found ${what} — the polls alone watch the forge, every 30 s ` +
`(novox/hq issue 729, 1422)`); `(novox/hq issue 729)`);
} }
} }
/** Whether a delivery was refused after the last verified one (or with none verified). */ /** Whether a delivery was refused after the last verified one (or with none verified). */
@@ -366,6 +367,9 @@ export class Pace {
const t = this.now(); const t = this.now();
return this.last > 0 && t - this.last < 10 * 60_000 && this.last > this.missed; return this.last > 0 && t - this.last < 10 * 60_000 && this.last > this.missed;
} }
every(): number {
return this.hearing() ? 5 * 60_000 : 30_000;
}
private expecting(): boolean { private expecting(): boolean {
return this.where !== null && this.hook !== null; return this.where !== null && this.hook !== null;
} }
@@ -379,14 +383,14 @@ export class Pace {
if (this.silentSince) { if (this.silentSince) {
state = "unhealthy"; state = "unhealthy";
why = `${this.last ? `no delivery verified since the last at ${iso(this.last)}` : `the forge has never delivered an ` + why = `${this.last ? `no delivery verified since the last at ${iso(this.last)}` : `the forge has never delivered an ` +
`event since the start at ${iso(this.started)}`}, while the pass found ${this.missCount} head(s) or merge(s) no ` + `event since the start at ${iso(this.started)}`}, while the poll found ${this.missCount} head(s) or merge(s) no ` +
`event said, the last ${this.lastMiss?.what} at ${iso(this.lastMiss?.at ?? t)}`; `event said, the last ${this.lastMiss?.what} at ${iso(this.lastMiss?.at ?? t)}`;
} else if (refusedSince) { } else if (refusedSince) {
state = "unhealthy"; state = "unhealthy";
why = `${refusedSince} deliveries refused ${this.last ? `since the last verified one at ${iso(this.last)}` : why = `${refusedSince} deliveries refused ${this.last ? `since the last verified one at ${iso(this.last)}` :
"and none verified"}, the last at ${iso(this.lastRefused?.at ?? t)} because ${this.lastRefused?.reason}`; "and none verified"}, the last at ${iso(this.lastRefused?.at ?? t)} because ${this.lastRefused?.reason}`;
} else if (!this.expecting()) { } else if (!this.expecting()) {
why = this.notHeard ? `no events are listened for, so only the pass at the start watches the forge: ${this.notHeard}` : why = this.notHeard ? `no events are listened for, so the polls alone watch the forge: ${this.notHeard}` :
"the receiver is not listening yet"; "the receiver is not listening yet";
if (this.notHeard && this.notHeardFails) state = "unhealthy"; if (this.notHeard && this.notHeardFails) state = "unhealthy";
} else if (this.hearing()) { } else if (this.hearing()) {
@@ -394,7 +398,7 @@ export class Pace {
} else if (this.last === 0) { } else if (this.last === 0) {
why = `no delivery heard yet; the forge is said silent if the poll finds what no event said ${SILENT_AFTER / 60_000} min after the start`; why = `no delivery heard yet; the forge is said silent if the poll finds what no event said ${SILENT_AFTER / 60_000} min after the start`;
} else { } else {
why = `the last delivery was at ${iso(this.last)}; nothing polls the forge, so what happens meanwhile is announced at the next start or when the receiver listens again`; why = `the last delivery was at ${iso(this.last)}; the polls look every 30 s until the next`;
} }
return { return {
healthy: state !== "unhealthy", state, why, healthy: state !== "unhealthy", state, why,
@@ -403,8 +407,7 @@ export class Pace {
hook: this.hook, hook: this.hook,
verified: { count: this.verifiedCount, last: this.lastVerified && { ...this.lastVerified, at: iso(this.lastVerified.at) } }, verified: { count: this.verifiedCount, last: this.lastVerified && { ...this.lastVerified, at: iso(this.lastVerified.at) } },
refused: { count: this.refusedCount, since_verified: refusedSince, last: this.lastRefused && { ...this.lastRefused, at: iso(this.lastRefused.at) } }, refused: { count: this.refusedCount, since_verified: refusedSince, last: this.lastRefused && { ...this.lastRefused, at: iso(this.lastRefused.at) } },
polls: { every_seconds: null, note: "no timer asks the forge; one pass at the start and when the receiver listens (issue 1422)", polls: { every_seconds: this.every() / 1000, hearing: this.hearing(), found_unsaid: this.missCount,
hearing: this.hearing(), found_unsaid: this.missCount,
last_found: this.lastMiss && { ...this.lastMiss, at: iso(this.lastMiss.at) } }, last_found: this.lastMiss && { ...this.lastMiss, at: iso(this.lastMiss.at) } },
silent: this.silentSince ? { since: iso(this.silentSince) } : null, silent: this.silentSince ? { since: iso(this.silentSince) } : null,
}; };
@@ -499,8 +502,8 @@ export function eventsTool(pace: Pace): { name: string; description: string; inp
name: "gitea_events", name: "gitea_events",
description: "Whether the forge's events reach the mesh (novox/hq issue 729): where the receiver listens, the " + description: "Whether the forge's events reach the mesh (novox/hq issue 729): where the receiver listens, the " +
"system webhook as ensured at the start (its id, url, events, kind, and what it replaced), how many deliveries " + "system webhook as ensured at the start (its id, url, events, kind, and what it replaced), how many deliveries " +
"verified and the last one's time and event, how many were refused and the last reason, whether " + "verified and the last one's time and event, how many were refused and the last reason, how often the polls look " +
"events are heard (nothing polls; one pass runs at the start), and how many heads or merges that pass found that no event said. Unhealthy when the " + "and whether events are heard, and how many heads or merges the poll found that no event said. Unhealthy when the " +
"forge has delivered nothing that verified for ten minutes (since the start or since the last delivery) while the " + "forge has delivered nothing that verified for ten minutes (since the start or since the last delivery) while the " +
"poll found what no event said, when deliveries were refused since the last verified one, or when the receiver " + "poll found what no event said, when deliveries were refused since the last verified one, or when the receiver " +
"could not listen or the webhook could not be made.", "could not listen or the webhook could not be made.",
+1 -17
View File
@@ -53,22 +53,6 @@ built for the fallback kernel too) after its boot-entries service, so the entry
| file | `/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook` | runs the script after a kernel image is installed, upgraded or removed | | file | `/etc/pacman.d/hooks/90-mesh-kernel-boot-entries.hook` | runs the script after a kernel image is installed, upgraded or removed |
| packages | `linux-lts`, `linux-lts-headers` | declared after the service and the hook | | packages | `linux-lts`, `linux-lts-headers` | declared after the service and the hook |
## Kernel modules at boot, and the running kernel's modules kept
A machine that runs for weeks meets a kernel upgrade that removes `/usr/lib/modules/<the running kernel>`: from then on
no module that was not loaded yet can be loaded until the next boot. On 2026-10-28 a build node, up 24 days on
`linux-lts` 6.18.54 with only 6.18.58's modules on disk, had `/dev/net/tun` and no tun driver, and every image build
there failed in buildah's network setup ("Failed to open() /dev/net/tun: No such device").
| | what | notes |
|---|---|---|
| file | `/etc/modules-load.d/mesh-kernel.conf` | the kernel modules loaded at every boot by systemd-modules-load: `tun` (pasta, which buildah uses for a RUN line's network, and every user-space tunnel open `/dev/net/tun`). Read at boot only: nothing is loaded by an apply |
| package | `kernel-modules-hook` | keeps the running kernel's modules across an upgrade (saved before the transaction, put back after), so a module first needed later still loads |
| service | `linux-modules-cleanup.service` | the package's oneshot, enabled at boot and stopped otherwise: it moves the modules of a kernel that is neither running nor owned by a package to `/usr/lib/modules/.old`, which the package's tmpfiles rule empties after four weeks. An apply never starts it |
A machine whose running kernel's modules are already gone loads nothing new until it is started again; the image build
module says so in its health and takes no image build there meanwhile.
## Tools ## Tools
| tool | | answers | | tool | | answers |
@@ -79,7 +63,7 @@ module says so in its health and takes no image build there meanwhile.
## Tests ## Tests
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the `go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the kernel modules loaded at boot (`tun`) and the package and boot service that keep the running kernel's modules; the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); the boot-entries script run with `sh` over stand-in roots (a systemd-boot entries directory, a GRUB menu, a hook that copies kernels to one fixed name): the entry from the booted command line, a menu generated again only when the kernels changed, no loader, no room, the taken-over hook; `kernel_boot` over the same roots; and the huge-pages script as the mesh
writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once, writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once,
rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a
reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel
@@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
for _, r := range m.Resources { for _, r := range m.Resources {
ids = append(ids, r["id"].(string)) ids = append(ids, r["id"].(string))
} }
if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers modules-load modules-keep modules-cleanup" { if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers" {
t.Fatalf("order: %v", ids) t.Fatalf("order: %v", ids)
} }
script := m.resource(t, "huge-pages") script := m.resource(t, "huge-pages")
@@ -57,31 +57,6 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
if h := apply["health"].(map[string]any); h["kind"] != "unit" { if h := apply["health"].(map[string]any); h["kind"] != "unit" {
t.Fatalf("health: %v", h) t.Fatalf("health: %v", h)
} }
// A build node, 2026-10-28: up 24 days on linux-lts 6.18.54 after an upgrade to 6.18.58 removed the running kernel's modules,
// so tun could not be loaded when a build first asked for it. tun is loaded at every boot, before an upgrade can take
// its file away, and the running kernel's modules are kept across an upgrade (kernel-modules-hook) and cleaned up at
// the next boot.
load := m.resource(t, "modules-load")
if load["path"] != "/etc/modules-load.d/mesh-kernel.conf" || load["mode"] != "0644" {
t.Fatalf("modules-load: %v", load)
}
var names []string
for _, l := range strings.Split(load.text(t), "\n") {
if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
names = append(names, l)
}
}
if strings.Join(names, " ") != "tun" {
t.Fatalf("the modules loaded at boot: %v", names)
}
if keep := m.resource(t, "modules-keep"); keep["type"] != "package" || keep["package"] != "kernel-modules-hook" {
t.Fatalf("modules-keep: %v", keep)
}
// A oneshot at boot: enabled, and stopped once it ran, never started by an apply (it moves the modules of kernels no
// package owns away).
if c := m.resource(t, "modules-cleanup"); c["unit"] != "linux-modules-cleanup.service" || c["state"] != "stopped" || c["boot"] != "enabled" {
t.Fatalf("modules-cleanup: %v", c)
}
} }
// bed is the script as the mesh writes it for one value of the setting, a stand-in transparent_hugepage // bed is the script as the mesh writes it for one value of the setting, a stand-in transparent_hugepage
-4
View File
@@ -1,4 +0,0 @@
# Written by the mesh's kernel module (systemd-modules-load reads it at boot).
# tun: buildah's network for a RUN line (pasta) and every other user-space tunnel open /dev/net/tun.
# Loaded at boot, so an upgrade that removes the running kernel's modules cannot keep it out later.
tun
-19
View File
@@ -100,25 +100,6 @@
"id": "linux-lts-headers", "id": "linux-lts-headers",
"type": "package", "type": "package",
"package": "linux-lts-headers" "package": "linux-lts-headers"
},
{
"id": "modules-load",
"type": "file",
"path": "/etc/modules-load.d/mesh-kernel.conf",
"mode": "0644",
"template": "files/modules-load.conf"
},
{
"id": "modules-keep",
"type": "package",
"package": "kernel-modules-hook"
},
{
"id": "modules-cleanup",
"type": "service",
"unit": "linux-modules-cleanup.service",
"state": "stopped",
"boot": "enabled"
} }
], ],
"build": { "build": {
@@ -87,15 +87,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
var watch func(string, func(stdio.ProvisionChange) error) error var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice // And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity. // gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
// And the node's build hours (a node may build only in a window): outside them it takes // And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
// nothing new. // nothing new.
var cores func() float64 var cores func() float64
var hours func() (*buildstep.BuildWindow, string) var hours func() (*buildstep.BuildWindow, string)
settings := ""
if capacity == nil { if capacity == nil {
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say) hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
settings = m.Settings
watch = buildstep.WatchConfigServed watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say) cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say)
} }
@@ -108,7 +106,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
} }
} }
return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load, return &buildstep.Puller{Source: source, Language: delivery.AskGo, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, SettingsFile: settings, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say, Has: m.Has, Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say, Has: m.Has,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil { if err := m.Check(ctx); err != nil {
return nil, err return nil, err
@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"regexp"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -127,8 +126,7 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) {
raw, _ := os.ReadFile(moduleDir + "/module.json") raw, _ := os.ReadFile(moduleDir + "/module.json")
var doc struct { var doc struct {
Config map[string]struct { Config map[string]struct {
Default json.RawMessage `json:"default"` Default json.RawMessage `json:"default"`
Pattern, Description string
} `json:"config"` } `json:"config"`
} }
_ = json.Unmarshal(raw, &doc) _ = json.Unmarshal(raw, &doc)
@@ -146,20 +144,10 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) {
if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" { if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" {
t.Errorf("the settings template holds build hours %v (%s)", w, fault) t.Errorf("the settings template holds build hours %v (%s)", w, fault)
} }
// ADR 0567 §15: build-hours names no node (one window, or empty for any hour) and capacity defaults to 0. var capacity string
window := regexp.MustCompile("^(?:" + doc.Config[buildstep.BuildHoursSetting].Pattern + ")$")
for value, ok := range map[string]bool{"": true, "18:00-06:00": true, "g14=18:00-06:00": false, "18:00": false} {
if window.MatchString(value) != ok {
t.Errorf("build-hours pattern on %q: accepted %v, want %v", value, !ok, ok)
}
}
if strings.Contains(doc.Config[buildstep.BuildHoursSetting].Description, "=") {
t.Errorf("the build-hours description names a node pair")
}
var capacity float64
_ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity) _ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity)
if capacity != 0 { if capacity != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %g, want 0", capacity) t.Errorf("the capacity's default = %q, want %q", capacity, buildstep.DefaultCapacities)
} }
settings := resource(t, m, "settings") settings := resource(t, m, "settings")
if settings["path"] != "${dir:state}/settings.json" { if settings["path"] != "${dir:state}/settings.json" {
@@ -73,7 +73,6 @@ func TestAComposeAskIsBuiltOnceAndAnsweredOnTheBus(t *testing.T) {
states := newStates() states := newStates()
m := moduleWith(states, "go1.26.0") m := moduleWith(states, "go1.26.0")
m.Work, m.Cache, m.Inside = t.TempDir(), t.TempDir(), true m.Work, m.Cache, m.Inside = t.TempDir(), t.TempDir(), true
m.Capacity = func() int { return 1 } // its own slot: no settings file, and a node with no capacity set pulls nothing (ADR 0567 §15)
m.Run = StepRunner(t.TempDir()) m.Run = StepRunner(t.TempDir())
m.GoModCache = goEnvOf(t, "GOMODCACHE") // the module cache this module's own build filled, read and not fetched m.GoModCache = goEnvOf(t, "GOMODCACHE") // the module cache this module's own build filled, read and not fetched
run, gocache := m.Run, goEnvOf(t, "GOCACHE") // this machine's build cache, so the composer's packages compile once run, gocache := m.Run, goEnvOf(t, "GOCACHE") // this machine's build cache, so the composer's packages compile once
@@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits {
case m.Capacity != nil: case m.Capacity != nil:
slots = m.Capacity() slots = m.Capacity()
case m.Settings != "": case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)() slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
} }
return buildstep.NodeLimits(slots) return buildstep.NodeLimits(slots)
} }
@@ -37,10 +37,6 @@ const DigestVersion = "6"
// depends on the siblings' commits. // depends on the siblings' commits.
const besideVariable = "MESH_CHECK_BESIDE" const besideVariable = "MESH_CHECK_BESIDE"
// namesVariable is the same for the mesh's node names: a package whose tests reach a file naming it depends on the node
// list the ask carries, so its digest holds the sorted names and a new node misses a kept verdict (ADR 0567 §15).
const namesVariable = "MESH_NODE_NAMES"
// ReadsOutsideMarker is the name of a file in a package's directory that says its tests read files outside it (a // ReadsOutsideMarker is the name of a file in a package's directory that says its tests read files outside it (a
// repository's documents, a sibling directory), which the Go toolchain does not list (issue 1935). It lists, one per // repository's documents, a sibling directory), which the Go toolchain does not list (issue 1935). It lists, one per
// line, what those tests read, as paths from the repository's root; a path may be a glob (filepath.Match), and a // line, what those tests read, as paths from the repository's root; a path may be a glob (filepath.Match), and a
@@ -168,8 +164,6 @@ func (m *Module) digestTool() StepTool {
"workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"}, "workspace": map[string]any{"type": "string", "description": "the workspace id build_go_fetch answered"},
"level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"}, "level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"},
"runner": runnerProperty, "runner": runnerProperty,
delivery.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "optional, the names of the mesh's nodes: a package whose tests read " + delivery.NodeNamesEnv + " has them in its digest"},
}, },
"required": []string{"workspace", "level"}, "required": []string{"workspace", "level"},
}, },
@@ -184,10 +178,6 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
names, err := nodeNamesArg(args[delivery.NodeNamesArg])
if err != nil {
return nil, err
}
if runner == RunnerWorkspace { if runner == RunnerWorkspace {
if err := m.workspaceThere(workspace); err != nil { if err := m.workspaceThere(workspace); err != nil {
return nil, err return nil, err
@@ -201,7 +191,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
default: default:
return nil, fmt.Errorf("the level %q is neither unit nor integration", level) return nil, fmt.Errorf("the level %q is neither unit nor integration", level)
} }
a, judged, err := m.asCandidate(ctx, levelSpec{Step: stepDigest, Level: level, Workspace: workspace, NodeNames: names}) a, judged, err := m.asCandidate(ctx, levelSpec{Step: stepDigest, Level: level, Workspace: workspace})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -211,7 +201,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
a.Digest.JudgedBy = judged a.Digest.JudgedBy = judged
return *a.Digest, nil return *a.Digest, nil
} }
res, err := m.digestHere(ctx, workspace, level, names) res, err := m.digestHere(ctx, workspace, level)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -220,7 +210,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
} }
// digestHere is the digest step as this program runs it. // digestHere is the digest step as this program runs it.
func (m *Module) digestHere(ctx context.Context, workspace, level string, names []string) (DigestResult, error) { func (m *Module) digestHere(ctx context.Context, workspace, level string) (DigestResult, error) {
if !workspaceOK.MatchString(workspace) { if !workspaceOK.MatchString(workspace) {
return DigestResult{}, fmt.Errorf("%q is not a workspace id", workspace) return DigestResult{}, fmt.Errorf("%q is not a workspace id", workspace)
} }
@@ -251,14 +241,6 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string, names
besideHeader = append(besideHeader, "beside "+b) besideHeader = append(besideHeader, "beside "+b)
} }
// The node names, sorted, as a header line for a package that reads them.
sortedNames := append([]string{}, names...)
sort.Strings(sortedNames)
var namesHeader []string
if len(sortedNames) > 0 {
namesHeader = []string{"node-names " + strings.Join(sortedNames, ",")}
}
dirs, err := m.goModDirs(ctx, tree) dirs, err := m.goModDirs(ctx, tree)
if err != nil { if err != nil {
return DigestResult{}, err return DigestResult{}, err
@@ -269,7 +251,7 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string, names
if err != nil { if err != nil {
return DigestResult{}, err return DigestResult{}, err
} }
if err := digestModule(&res, pkgs, tree, header, besideHeader, namesHeader, level == LevelIntegration); err != nil { if err := digestModule(&res, pkgs, tree, header, besideHeader, level == LevelIntegration); err != nil {
return DigestResult{}, err return DigestResult{}, err
} }
} }
@@ -320,7 +302,7 @@ func parsePackages(out string) (map[string]*goPackage, error) {
// digestModule adds to res the digest of each root package of one go.mod directory. A root is a package of the main // digestModule adds to res the digest of each root package of one go.mod directory. A root is a package of the main
// module that is listed, not only a dependency, and not a variant of another. // module that is listed, not only a dependency, and not a variant of another.
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside, names []string, needs bool) error { func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside []string, needs bool) error {
roots := []string{} roots := []string{}
for path, p := range pkgs { for path, p := range pkgs {
if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main { if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main {
@@ -367,7 +349,7 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
res.neverKept[path] = why res.neverKept[path] = why
continue continue
} }
d, err := packageDigest(root, testMain, pkgs, tree, header, beside, names, declared) d, err := packageDigest(root, testMain, pkgs, tree, header, beside, declared)
if err != nil { if err != nil {
return fmt.Errorf("%s: %w", path, err) return fmt.Errorf("%s: %w", path, err)
} }
@@ -379,9 +361,9 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
// packageDigest is the digest of one package: a hash over a sorted text of the header, the files of every package its // packageDigest is the digest of one package: a hash over a sorted text of the header, the files of every package its
// tests reach in the module's own tree (by their content, under their path in the tree), the version and checksum of // tests reach in the module's own tree (by their content, under their path in the tree), the version and checksum of
// every module outside it, and the package's own test data. The beside lines (the siblings' commits) join the header // every module outside it, and the package's own test data. The beside lines (the siblings' commits) join the header
// only when a file the tests reach names MESH_CHECK_BESIDE, and the node names likewise for MESH_NODE_NAMES. A package of the standard library is the Go version's. // only when a file the tests reach names MESH_CHECK_BESIDE. A package of the standard library is the Go version's.
// The lines a package's ReadsOutsideMarker declares join them (declaredReads). // The lines a package's ReadsOutsideMarker declares join them (declaredReads).
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside, names, declared []string) (string, error) { func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside, declared []string) (string, error) {
reached := map[string]*goPackage{root.ImportPath: root} reached := map[string]*goPackage{root.ImportPath: root}
for _, dep := range testMain.Deps { for _, dep := range testMain.Deps {
if p := pkgs[dep]; p != nil { if p := pkgs[dep]; p != nil {
@@ -389,7 +371,7 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
} }
} }
lines := append([]string{}, declared...) lines := append([]string{}, declared...)
readsBeside, readsNames := false, false readsBeside := false
for _, p := range reached { for _, p := range reached {
if p.Standard || p.Module == nil { if p.Standard || p.Module == nil {
continue continue
@@ -408,10 +390,9 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
return "", err return "", err
} }
lines = append(lines, l) lines = append(lines, l)
if !readsBeside || !readsNames { if !readsBeside {
if raw, err := os.ReadFile(filepath.Join(p.Dir, f)); err == nil { if raw, err := os.ReadFile(filepath.Join(p.Dir, f)); err == nil && bytes.Contains(raw, []byte(besideVariable)) {
readsBeside = readsBeside || bytes.Contains(raw, []byte(besideVariable)) readsBeside = true
readsNames = readsNames || bytes.Contains(raw, []byte(namesVariable))
} }
} }
} }
@@ -443,9 +424,6 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
if readsBeside { if readsBeside {
header = append(append([]string{}, header...), beside...) header = append(append([]string{}, header...), beside...)
} }
if readsNames {
header = append(append([]string{}, header...), names...)
}
h := sha256.New() h := sha256.New()
for _, l := range append(append([]string{}, header...), lines...) { for _, l := range append(append([]string{}, header...), lines...) {
h.Write([]byte(l + "\n")) h.Write([]byte(l + "\n"))
@@ -172,9 +172,8 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
root := &goPackage{ImportPath: "x/r", Dir: dir, Module: &goModule{Path: "x", Main: true}, GoFiles: []string{"r.go"}, TestGoFiles: []string{"r.go"}} root := &goPackage{ImportPath: "x/r", Dir: dir, Module: &goModule{Path: "x", Main: true}, GoFiles: []string{"r.go"}, TestGoFiles: []string{"r.go"}}
testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}} testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}}
pkgs[root.ImportPath] = root pkgs[root.ImportPath] = root
var names []string
digest := func() string { digest := func() string {
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil, names, nil) d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil, nil)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -189,20 +188,6 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
if d3 := digest(); d3 == d1 { if d3 := digest(); d3 == d1 {
t.Errorf("a new version left the digest as it was") t.Errorf("a new version left the digest as it was")
} }
// A different node list misses the cache only for a package whose tests read MESH_NODE_NAMES (ADR 0567 §15).
names = []string{"node-names a"}
d4 := digest()
names = []string{"node-names a", "node-names b"}
if d5 := digest(); d5 != d4 {
t.Errorf("a package that does not read the node names changed with them")
}
os.WriteFile(filepath.Join(dir, "r.go"), []byte("package r\n// "+namesVariable+"\n"), 0o644)
names = []string{"node-names a"}
d6 := digest()
names = []string{"node-names a", "node-names b"}
if d7 := digest(); d7 == d6 {
t.Errorf("a new node left the digest of a package that reads the node names as it was")
}
} }
// Issue 1935, 1936: a package whose reads-outside marker lists nothing it can trust is never kept: its digest is one no // Issue 1935, 1936: a package whose reads-outside marker lists nothing it can trust is never kept: its digest is one no
@@ -72,7 +72,7 @@ func (m *Module) integrationHere(ctx context.Context, workspace, tree string, pa
return nil, fmt.Errorf("the integration level's bus: %w", err) return nil, fmt.Errorf("the integration level's bus: %w", err)
} }
defer stopBus() defer stopBus()
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace, nil)...) env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace)...)
// Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run // Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run
// below finds them built. Whether they compile is for the run to say; its failure is not read here. // below finds them built. Whether they compile is for the run to say; its failure is not read here.
// Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory. // Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory.
@@ -43,7 +43,6 @@ type levelSpec struct {
Run string `json:"run,omitempty"` Run string `json:"run,omitempty"`
Setup time.Duration `json:"setup"` Setup time.Duration `json:"setup"`
Bound time.Duration `json:"bound"` Bound time.Duration `json:"bound"`
NodeNames []string `json:"node-names,omitempty"`
} }
// levelAnswer is the one line the process prints: the level's result or the digest, or why it could not run. // levelAnswer is the one line the process prints: the level's result or the digest, or why it could not run.
@@ -222,14 +221,14 @@ func (m *Module) answer(ctx context.Context, spec levelSpec) levelAnswer {
tree := filepath.Join(m.Work, spec.Workspace, "tree") tree := filepath.Join(m.Work, spec.Workspace, "tree")
switch spec.Step { switch spec.Step {
case stepDigest: case stepDigest:
d, err := m.digestHere(ctx, spec.Workspace, spec.Level, spec.NodeNames) d, err := m.digestHere(ctx, spec.Workspace, spec.Level)
if err != nil { if err != nil {
return levelAnswer{Error: err.Error()} return levelAnswer{Error: err.Error()}
} }
return levelAnswer{Digest: &d} return levelAnswer{Digest: &d}
case stepUnit: case stepUnit:
res, err := m.runLevel(ctx, tree, levelOpts{workspace: spec.Workspace, level: LevelUnit, packages: spec.Packages, run: spec.Run, res, err := m.runLevel(ctx, tree, levelOpts{workspace: spec.Workspace, level: LevelUnit, packages: spec.Packages, run: spec.Run,
bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(spec.Workspace, spec.NodeNames)}) bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(spec.Workspace)})
if err != nil { if err != nil {
return levelAnswer{Error: err.Error()} return levelAnswer{Error: err.Error()}
} }
@@ -148,17 +148,7 @@ func (m *Module) Health(ctx context.Context) health.Answer {
if err := m.room(); err != nil { if err := m.room(); err != nil {
return health.Unhealthy(err.Error()) return health.Unhealthy(err.Error())
} }
note := "" return health.Healthy(fmt.Sprintf("%s installed, the version the module builds with (recipe %s)", installed, m.Recipe.Version()))
if m.Held != nil {
why, fault := m.Held.CapacityNote()
if fault {
return health.Unhealthy(why)
}
if why != "" {
note = "; " + why // a capacity set to 0 is a choice, said so a starved builder is seen
}
}
return health.Healthy(fmt.Sprintf("%s installed, the version the module builds with (recipe %s)", installed, m.Recipe.Version()) + note)
} }
// Long is a tool that runs a long step (ADR 0469 §2): it answers at once with the step's id, and the step ends on // Long is a tool that runs a long step (ADR 0469 §2): it answers at once with the step's id, and the step ends on
@@ -2,10 +2,7 @@ package main
import ( import (
"slices" "slices"
"strings"
"testing" "testing"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// Issue 1309: a test that runs a step in bubblewrap is proof only where it runs. Where a build module judges (it hands // Issue 1309: a test that runs a step in bubblewrap is proof only where it runs. Where a build module judges (it hands
@@ -32,12 +29,8 @@ func TestAMissingToolSkipsExceptBubblewrapOnABuildNode(t *testing.T) {
// The levels a build module judges hand the tests the build node mark, with the siblings they read. // The levels a build module judges hand the tests the build node mark, with the siblings they read.
func TestEveryLevelHandsTheTestsTheBuildNodeMark(t *testing.T) { func TestEveryLevelHandsTheTestsTheBuildNodeMark(t *testing.T) {
m := &Module{Work: t.TempDir()} m := &Module{Work: t.TempDir()}
if env := m.checkEnv("ws1", nil); !slices.Contains(env, BuildNodeEnv+"=1") || slices.ContainsFunc(env, func(e string) bool { return strings.HasPrefix(e, delivery.NodeNamesEnv) }) { if env := m.checkEnv("ws1"); !slices.Contains(env, BuildNodeEnv+"=1") {
t.Errorf("env %v lacks %s=1, or names nodes when none were asked", env, BuildNodeEnv) t.Errorf("env %v lacks %s=1", env, BuildNodeEnv)
}
// And the mesh's node names, when the ask carried them (ADR 0567 §12).
if env := m.checkEnv("ws1", []string{"a", "b"}); !slices.Contains(env, delivery.NodeNamesEnv+"=a,b") {
t.Errorf("env %v lacks the node names", env)
} }
} }
@@ -16,7 +16,6 @@ import (
"time" "time"
"git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey" "git.novox.be/novox/mesh-sdk/go/forgekey"
) )
@@ -39,9 +38,7 @@ func (m *Module) testTool() StepTool {
"level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"}, "level": map[string]any{"type": "string", "enum": []string{LevelUnit, LevelIntegration}, "description": "the level"},
"packages": map[string]any{"type": "array", "items": map[string]any{"type": "string"}, "packages": map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "package patterns of each module, ./... when none"}, "description": "package patterns of each module, ./... when none"},
"run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"}, "run": map[string]any{"type": "string", "description": "optional go test -run pattern: only the tests it matches run (a try's tests)"},
delivery.NodeNamesArg: map[string]any{"type": "array", "items": map[string]any{"type": "string"},
"description": "optional, the names of the mesh's nodes: the tests get them comma-separated in " + delivery.NodeNamesEnv + " (a check that keeps node names out of the repository reads them there)"},
"runner": runnerProperty, "runner": runnerProperty,
}, },
"required": []string{"workspace", "level"}, "required": []string{"workspace", "level"},
@@ -81,16 +78,12 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
names, err := nodeNamesArg(args[delivery.NodeNamesArg])
if err != nil {
return nil, err
}
var res *TestResult var res *TestResult
switch { switch {
case runner == RunnerWorkspace && (level == LevelUnit || level == LevelIntegration): case runner == RunnerWorkspace && (level == LevelUnit || level == LevelIntegration):
return m.testAsCandidate(ctx, workspace, level, packages, pattern, names) return m.testAsCandidate(ctx, workspace, level, packages, pattern)
case level == LevelUnit: case level == LevelUnit:
res, err = m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(workspace, names)}) res, err = m.runLevel(ctx, tree, levelOpts{workspace: workspace, level: LevelUnit, packages: packages, run: pattern, bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(workspace)})
case level == LevelIntegration: case level == LevelIntegration:
res, err = m.integration(ctx, workspace, tree, packages, pattern) res, err = m.integration(ctx, workspace, tree, packages, pattern)
default: default:
@@ -105,14 +98,14 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
// testAsCandidate runs a level as the candidate's program (ADR 0535 §1): the integration level is refused first as the // testAsCandidate runs a level as the candidate's program (ADR 0535 §1): the integration level is refused first as the
// installed program would refuse it (no postgres), then the whole level runs in the candidate's one shut-off process. // installed program would refuse it (no postgres), then the whole level runs in the candidate's one shut-off process.
func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string, names []string) (*TestResult, error) { func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string) (*TestResult, error) {
spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound(), NodeNames: names} spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound()}
if level == LevelIntegration { if level == LevelIntegration {
if _, err := m.checkPostgres(ctx); err != nil { if _, err := m.checkPostgres(ctx); err != nil {
return nil, err // a *buildstep.Refusal: this level only return nil, err // a *buildstep.Refusal: this level only
} }
b := m.integrationBounds() b := m.integrationBounds()
spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run, NodeNames: names} spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run}
} }
a, judged, err := m.asCandidate(ctx, spec) a, judged, err := m.asCandidate(ctx, spec)
if err != nil { if err != nil {
@@ -139,35 +132,9 @@ func (m *Module) besideEnv(workspace string) []string {
// and present, so a test that needs it fails where it is missing instead of skipping (issue 1309). // and present, so a test that needs it fails where it is missing instead of skipping (issue 1309).
const BuildNodeEnv = "MESH_BUILD_NODE" const BuildNodeEnv = "MESH_BUILD_NODE"
// checkEnv is what every level hands the tests it runs: the build node mark, the siblings (besideEnv) and, when the // checkEnv is what every level hands the tests it runs: the build node mark and the siblings (besideEnv).
// ask carried them, the mesh's node names (delivery.NodeNamesEnv, novox/hq ADR 0567 §12). func (m *Module) checkEnv(workspace string) []string {
func (m *Module) checkEnv(workspace string, names []string) []string { return append([]string{BuildNodeEnv + "=1"}, m.besideEnv(workspace)...)
env := append([]string{BuildNodeEnv + "=1"}, m.besideEnv(workspace)...)
if len(names) > 0 {
env = append(env, delivery.NodeNamesEnv+"="+strings.Join(names, ","))
}
return env
}
// nodeNamesArg reads the node-names argument: a list of names, absent when none.
func nodeNamesArg(raw any) ([]string, error) {
switch v := raw.(type) {
case nil:
return nil, nil
case []string:
return v, nil
case []any:
var out []string
for _, e := range v {
n, ok := e.(string)
if !ok || n == "" || strings.ContainsAny(n, ", \n") {
return nil, fmt.Errorf("%s is a list of node names, not %v", delivery.NodeNamesArg, e)
}
out = append(out, n)
}
return out, nil
}
return nil, fmt.Errorf("%s is a list of node names", delivery.NodeNamesArg)
} }
// packagesArg reads the packages argument: a list of package patterns, as []any when it came over the bus as JSON and // packagesArg reads the packages argument: a list of package patterns, as []any when it came over the bus as JSON and
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"capacity": 0, "capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 6, "cores-per-step": 6,
"build-hours": "" "build-hours": ""
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1
github.com/distribution/distribution/v3 v3.1.2 github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1 h1:9qyUcrvlklxDRBUCBqGH1L3oximOk554IfaPrTMgaTA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+6 -6
View File
@@ -25,15 +25,15 @@
], ],
"config": { "config": {
"capacity": { "capacity": {
"type": "number", "type": "string",
"description": "How many asks of the Go build seat this module runs at once on this node (novox/hq ADR 0525 §5); 0 pulls nothing. It is set per node in the mesh's configuration; a node with no value builds nothing.", "description": "How many asks of the Go build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"minimum": 0, "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": 0 "default": "shanks=3,ace=2,g14=0,novox=0"
}, },
"build-hours": { "build-hours": {
"type": "string", "type": "string",
"description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.", "description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
"pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]", "pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
"default": "" "default": ""
}, },
"cores-per-step": { "cores-per-step": {
+1 -8
View File
@@ -16,14 +16,7 @@ credential and an environment built from scratch.
The account's subordinate user and group ranges are not declared anywhere: the node-engine makes every `user` account The account's subordinate user and group ranges are not declared anywhere: the node-engine makes every `user` account
with `useradd`, which allocates them. The module's health reads `/etc/subuid` and `/etc/subgid` and is unhealthy with `useradd`, which allocates them. The module's health reads `/etc/subuid` and `/etc/subgid` and is unhealthy
when the module cannot run: without a range for the account, without buildah, when a user namespace cannot be made, without a range for the account, without buildah, or when a user namespace cannot be made.
or when the disk is under its floor. A node where `/dev/net/tun` cannot be opened stays **healthy** and is **not
eligible** (buildah makes a RUN line's network with pasta, which needs the tun driver; on 2026-10-28 a build node had
the device node and no driver, so every image build there failed with "Failed to open() /dev/net/tun: No such
device"): the seat's puller judges by a capacity of 0 with that reason (`capacity.<node>`, which `build_usage` shows),
so the asks go to a node that can build them, and its health says "not eligible" with the cause, so a node that
cannot build here now never holds the module's delivery for every node. The kernel module loads `tun` at boot; a look
that passes again wakes the puller.
The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can read holds a key. The Dockerfile's steps may use the network (ADR 0487 §6); nothing a step can read holds a key.
@@ -43,15 +43,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
var watch func(string, func(stdio.ProvisionChange) error) error var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice // And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity. // gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
// And the node's build hours (a node may build only in a window): outside them it takes // And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
// nothing new. // nothing new.
var cores func() float64 var cores func() float64
var hours func() (*buildstep.BuildWindow, string) var hours func() (*buildstep.BuildWindow, string)
settings := ""
if capacity == nil { if capacity == nil {
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say) hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
settings = m.Settings
watch = buildstep.WatchConfigServed watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskImage], m.Say) cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskImage], m.Say)
} }
@@ -61,7 +59,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) } load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) }
} }
return &buildstep.Puller{Source: source, Language: delivery.AskImage, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load, return &buildstep.Puller{Source: source, Language: delivery.AskImage, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, SettingsFile: settings, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Eligible: m.Eligible, Logf: m.Say, Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Check(ctx); err != nil { if err := m.Check(ctx); err != nil {
return nil, err return nil, err
@@ -71,17 +71,4 @@ func TestAPulledImageAskCopiesThenBuilds(t *testing.T) {
t.Errorf("%s.%s is not served", Seat, verb) t.Errorf("%s.%s is not served", Seat, verb)
} }
} }
// A node without /dev/net/tun (a build node, 2026-10-28) takes no ask: its capacity is 0 and says why, so the ask goes to a node
// that can build it instead of failing in buildah's network setup; once it is there again, the capacity is back.
tun := m.Tun
m.Tun = t.TempDir() + "/none"
_ = m.Check(context.Background())
if c := p.CapacityDetail(); c.Effective != 0 || !strings.Contains(c.Why, "not eligible: "+m.Tun+" cannot be opened") {
t.Errorf("without /dev/net/tun the capacity = %+v", c)
}
m.Tun = tun
_ = m.Check(context.Background())
if c := p.CapacityDetail(); c.Effective != 1 || c.Ineligible != "" {
t.Errorf("with /dev/net/tun again the capacity = %+v", c)
}
} }
@@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits {
case m.Capacity != nil: case m.Capacity != nil:
slots = m.Capacity() slots = m.Capacity()
case m.Settings != "": case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)() slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
} }
return buildstep.NodeLimits(slots) return buildstep.NodeLimits(slots)
} }
@@ -190,7 +190,6 @@ func moduleWith(t *testing.T, f *fake, states *keptStates) *Module {
SubUIDFile: write("subuid", "someone:100000:65536\nmesh-build-image:624288:65536\n"), SubUIDFile: write("subuid", "someone:100000:65536\nmesh-build-image:624288:65536\n"),
SubGIDFile: write("subgid", "mesh-build-image:624288:65536\n"), SubGIDFile: write("subgid", "mesh-build-image:624288:65536\n"),
RegistryFile: write("registry", f.address()+"\n"), RegistryFile: write("registry", f.address()+"\n"),
Tun: write("tun", ""),
RegistryWait: 2 * time.Second, RegistryWait: 2 * time.Second,
RegistryPause: 5 * time.Millisecond, RegistryPause: 5 * time.Millisecond,
} }
@@ -1,10 +1,8 @@
package main package main
import ( import (
"encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -123,30 +121,6 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes
if strings.TrimSpace(string(tpl)) != "${seat:mesh-artifact-store:reach}" { if strings.TrimSpace(string(tpl)) != "${seat:mesh-artifact-store:reach}" {
t.Errorf("registry template = %q", tpl) t.Errorf("registry template = %q", tpl)
} }
raw, _ = os.ReadFile(filepath.Join(moduleDir, "module.json"))
var doc struct {
Config map[string]struct {
Default json.RawMessage `json:"default"`
Pattern, Description string
} `json:"config"`
}
_ = json.Unmarshal(raw, &doc)
var capacity float64 = -1
_ = json.Unmarshal(doc.Config["capacity"].Default, &capacity)
if capacity != 0 {
t.Errorf("the capacity's default = %g, want 0", capacity)
}
// ADR 0567 §15: build-hours names no node (one window, or empty for any hour) and capacity defaults to 0.
window := regexp.MustCompile("^(?:" + doc.Config["build-hours"].Pattern + ")$")
for value, ok := range map[string]bool{"": true, "18:00-06:00": true, "g14=18:00-06:00": false, "18:00": false} {
if window.MatchString(value) != ok {
t.Errorf("build-hours pattern on %q: accepted %v, want %v", value, !ok, ok)
}
}
if strings.Contains(doc.Config["build-hours"].Description, "=") {
t.Errorf("the build-hours description names a node pair")
}
} }
// Every tool the manifest names is served, and every tool served is named. // Every tool the manifest names is served, and every tool served is named.
@@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"sync" "sync"
@@ -39,8 +38,6 @@ type Module struct {
Work, Store string Work, Store string
// SubUIDFile and SubGIDFile are the subordinate id files; /etc/subuid and /etc/subgid when empty. // SubUIDFile and SubGIDFile are the subordinate id files; /etc/subuid and /etc/subgid when empty.
SubUIDFile, SubGIDFile string SubUIDFile, SubGIDFile string
// Tun is the tun device buildah's network for a RUN line (pasta) opens; TunDevice when empty.
Tun string
// RegistryFile holds the registry's address, host:port, which the manifest binds from the artifact-store provision. // RegistryFile holds the registry's address, host:port, which the manifest binds from the artifact-store provision.
RegistryFile string RegistryFile string
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of // RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
@@ -65,10 +62,6 @@ type Module struct {
spacesOnce sync.Once spacesOnce sync.Once
workspaces *buildstep.Workspaces workspaces *buildstep.Workspaces
// lastMu guards last: the refusal of the last look ("" when it could build), which Eligible answers.
lastMu sync.Mutex
last string
} }
// Look is what the module found out about its machine. // Look is what the module found out about its machine.
@@ -97,65 +90,15 @@ func (m *Module) look(ctx context.Context) (Look, error) {
if err := m.userNamespace(ctx); err != nil { if err := m.userNamespace(ctx); err != nil {
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah} return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
} }
if err := m.tunDevice(); err != nil {
return l, &notEligible{&buildstep.Refusal{Text: err.Error(), Have: l.Buildah}}
}
return l, nil return l, nil
} }
// notEligible is a refusal of a machine that runs the module fine but cannot build here now (no tun device: its asks go // Check is the guard: nil when the machine can build, else the refusal.
// to another node). It is a *buildstep.Refusal for a step, and the health stays healthy for it (issue 1126): the
// assigner reads unhealthy as a failed delivery and holds the build for every node. What cannot run (buildah missing,
// no ranges, no namespace, no runtime directory, no room) stays unhealthy.
type notEligible struct{ *buildstep.Refusal }
func (e *notEligible) Unwrap() error { return e.Refusal }
// isNotEligible says whether err is a refusal that leaves the module healthy.
func isNotEligible(err error) bool {
var n *notEligible
return errors.As(err, &n)
}
// Check is the guard: nil when the machine can build, else the refusal. Every look is kept for Eligible, and a change
// wakes the puller, so a node that can build again takes asks at once.
func (m *Module) Check(ctx context.Context) error { func (m *Module) Check(ctx context.Context) error {
_, err := m.look(ctx) _, err := m.look(ctx)
m.keep(err)
return err return err
} }
// keep records a look's outcome for Eligible and wakes the puller when it changed.
func (m *Module) keep(err error) {
why := ""
if err != nil {
why = err.Error()
}
m.lastMu.Lock()
changed := m.last != why
m.last = why
m.lastMu.Unlock()
if changed && m.Say != nil {
if why == "" {
m.Say("this machine is eligible to build images again")
} else {
m.Say("this machine takes no image build now: %s", why)
}
}
if changed && m.Held != nil {
m.Held.Wake()
}
}
// Eligible is the puller's eligibility (buildstep.Puller.Eligible): the refusal of the module's last look, "" when it
// could build. While the machine cannot build, the node's capacity is 0 and says why, so its asks go to a node that can
// build them instead of failing here (a build node without /dev/net/tun, 2026-10-28).
func (m *Module) Eligible() string {
m.lastMu.Lock()
defer m.lastMu.Unlock()
return m.last
}
// Start looks once, as the module starts, and then every CheckRound until ctx ends; it also removes old workspaces. // Start looks once, as the module starts, and then every CheckRound until ctx ends; it also removes old workspaces.
func (m *Module) Start(ctx context.Context) { func (m *Module) Start(ctx context.Context) {
for { for {
@@ -176,35 +119,20 @@ func (m *Module) Start(ctx context.Context) {
// healthy it names the buildah installed and the account's ranges (ADR 0487 §2: the ranges are proven here, not declared). // healthy it names the buildah installed and the account's ranges (ADR 0487 §2: the ranges are proven here, not declared).
func (m *Module) Health(ctx context.Context) health.Answer { func (m *Module) Health(ctx context.Context) health.Answer {
l, err := m.look(ctx) l, err := m.look(ctx)
m.keep(err) if err != nil {
if err != nil && !isNotEligible(err) {
return health.Unhealthy(err.Error()) return health.Unhealthy(err.Error())
} }
if err := m.room(); err != nil { if err := m.room(); err != nil {
return health.Unhealthy(err.Error()) return health.Unhealthy(err.Error())
} }
if err != nil { return health.Healthy(fmt.Sprintf("buildah %s installed; %s holds %d subordinate uids and %d gids; a user namespace can be made", l.Buildah, l.Account, l.UIDs, l.GIDs))
// Not eligible: the module runs, it takes no image ask here now, and says why; the capacity record says it too.
return health.Healthy("not eligible to build images, so it takes no image ask: " + err.Error())
}
note := ""
if m.Held != nil {
why, fault := m.Held.CapacityNote()
if fault {
return health.Unhealthy(why)
}
if why != "" {
note = "; " + why // a capacity set to 0 is a choice, said so a starved builder is seen
}
}
return health.Healthy(fmt.Sprintf("buildah %s installed; %s holds %d subordinate uids and %d gids; a user namespace can be made; %s opens", l.Buildah, l.Account, l.UIDs, l.GIDs, m.tunPath()) + note)
} }
// Status is what the module reports of itself. // Status is what the module reports of itself.
func (m *Module) Status(ctx context.Context) map[string]any { func (m *Module) Status(ctx context.Context) map[string]any {
l, err := m.look(ctx) l, err := m.look(ctx)
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil, out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil,
"eligible": err == nil, "cpu": m.cpuStatus()} "cpu": m.cpuStatus()}
if err != nil { if err != nil {
out["why"] = err.Error() out["why"] = err.Error()
} }
@@ -2,9 +2,7 @@ package main
import ( import (
"context" "context"
"fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"testing" "testing"
@@ -45,88 +43,21 @@ func TestHealthIsUnhealthyWithoutBuildah(t *testing.T) {
} }
// The ranges are read from the two files, one each: without one in /etc/subuid or in /etc/subgid the account has no ids to // The ranges are read from the two files, one each: without one in /etc/subuid or in /etc/subgid the account has no ids to
// map, and the health says which file. And without /dev/net/tun (a build node on 2026-10-28: its running kernel's modules were // map, and the health says which file.
// gone, so tun could not be loaded) buildah's network for a RUN line cannot be made: the health says the node is not func TestHealthIsUnhealthyWithoutARangeInEitherFile(t *testing.T) {
// eligible, and the puller judges by 0 so its asks go to a node that can build them. for file, set := range map[string]func(*Module, string){
func TestHealthIsUnhealthyWithoutWhatABuildNeeds(t *testing.T) { "subuid": func(m *Module, p string) { m.SubUIDFile = p },
for _, c := range []struct { "subgid": func(m *Module, p string) { m.SubGIDFile = p },
name string
set func(*Module, string)
says []string
}{
{"subuid", func(m *Module, p string) { m.SubUIDFile = p }, []string{"/subuid", "mesh-build-image"}},
{"subgid", func(m *Module, p string) { m.SubGIDFile = p }, []string{"/subgid", "mesh-build-image"}},
} { } {
f := newFake(t) f := newFake(t)
m := moduleWith(t, f, newStates()) m := moduleWith(t, f, newStates())
p := t.TempDir() + "/" + c.name p := t.TempDir() + "/" + file
os.WriteFile(p, []byte("someone-else:100000:65536\n"), 0o644) os.WriteFile(p, []byte("someone-else:100000:65536\n"), 0o644)
c.set(m, p) set(m, p)
a := m.Health(context.Background()) a := m.Health(context.Background())
for _, want := range c.says { if a.Healthy || !strings.Contains(a.Why, p) || !strings.Contains(a.Why, "mesh-build-image") {
if a.Healthy || !strings.Contains(a.Why, want) { t.Errorf("%s: health = %+v, want unhealthy naming %s and the account", file, a, p)
t.Errorf("%s: health = %+v, want unhealthy saying %q", c.name, a, want)
}
} }
// Whatever the look refuses, the node is not eligible, with the same words.
if got := m.Eligible(); got != a.Why {
t.Errorf("%s: eligible says %q, the health %q", c.name, got, a.Why)
}
}
}
// 1126: a node that cannot build here now (no tun device) stays healthy, saying "not eligible" and why; the assigner reads
// unhealthy as a failed delivery and held the new build for every node. The eligibility, the status and the log say it too.
func TestANodeWithoutTunIsHealthyAndSaysItIsNotEligible(t *testing.T) {
f := newFake(t)
m := moduleWith(t, f, newStates())
var said []string
m.Say = func(format string, a ...any) { said = append(said, fmt.Sprintf(format, a...)) }
m.Tun = t.TempDir() + "/dev/net/tun"
a := m.Health(context.Background())
if a.State != health.StateHealthy || !a.Healthy {
t.Fatalf("health = %+v, want healthy", a)
}
for _, want := range []string{"not eligible", "/dev/net/tun cannot be opened", "tun"} {
if !strings.Contains(a.Why, want) {
t.Errorf("health says %q, lacks %q", a.Why, want)
}
}
if got := m.Eligible(); !strings.Contains(got, "not eligible: ") {
t.Errorf("eligible says %q", got)
}
if s := m.Status(context.Background()); s["eligible"] != false || s["refusing"] != true {
t.Errorf("status = %v, want not eligible", s)
}
// One log line for the change, none for the same look again; one when it can build again.
m.Health(context.Background())
if len(said) != 1 || !strings.Contains(said[0], "takes no image build now") {
t.Errorf("said = %q, want one line for the change", said)
}
m.Tun = filepath.Join(t.TempDir(), "tun")
os.WriteFile(m.Tun, nil, 0o644)
if a := m.Health(context.Background()); !a.Healthy || strings.Contains(a.Why, "not eligible") {
t.Errorf("health = %+v, want plain healthy", a)
}
if len(said) != 2 || !strings.Contains(said[1], "eligible to build images again") {
t.Errorf("said = %q, want the return said", said)
}
// A step still ends refused with the cause named.
m.Tun = t.TempDir() + "/none"
if err := m.Check(context.Background()); err == nil || !strings.Contains(err.Error(), "not eligible") {
t.Errorf("check = %v", err)
}
}
// 1126: what cannot run stays unhealthy even beside a missing tun: buildah missing is not "not eligible".
func TestWithoutBuildahTheNodeIsUnhealthyNotIneligible(t *testing.T) {
m := moduleWith(t, newFake(t), newStates())
m.Tun = t.TempDir() + "/none"
m.Run = func(ctx context.Context, env []string, name string, args ...string) forgekey.Ran {
return forgekey.Ran{Status: 127, Err: "exec: \"buildah\": executable file not found in $PATH"}
}
if a := m.Health(context.Background()); a.Healthy {
t.Errorf("health = %+v, want unhealthy", a)
} }
} }
@@ -2,13 +2,11 @@ package main
// What the module proves about its own machine before it builds (novox/hq ADR 0487 §2, issue 1703a): buildah runs, // What the module proves about its own machine before it builds (novox/hq ADR 0487 §2, issue 1703a): buildah runs,
// the account holds subordinate user and group ranges (the node-engine's useradd allocates them for every `user` // the account holds subordinate user and group ranges (the node-engine's useradd allocates them for every `user`
// account, so nothing declares them; this reads them), a user namespace can be made, and the tun device opens (buildah // account, so nothing declares them; this reads them), and a user namespace can be made.
// makes a RUN line's network with pasta, which needs it; a build node on 2026-10-28 had the device node and no tun driver).
import ( import (
"bufio" "bufio"
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -20,9 +18,6 @@ import (
// ProbeTimeout bounds each probe: they read the machine and fetch nothing. // ProbeTimeout bounds each probe: they read the machine and fetch nothing.
const ProbeTimeout = 20 * time.Second const ProbeTimeout = 20 * time.Second
// TunDevice is the tun device pasta opens for a RUN line's network.
const TunDevice = "/dev/net/tun"
// Default subordinate id files. // Default subordinate id files.
const ( const (
SubUIDFile = "/etc/subuid" SubUIDFile = "/etc/subuid"
@@ -138,23 +133,3 @@ func (m *Module) ensureRuntimeDir() error {
} }
return nil return nil
} }
func (m *Module) tunPath() string {
if m.Tun != "" {
return m.Tun
}
return TunDevice
}
// tunDevice opens the tun device as the account does for a build, and says the node is not eligible when it cannot: a
// device node without its driver answers "no such device" (the tun kernel module is not loaded and cannot be, as when the
// running kernel's modules were removed by an upgrade), and every RUN line would fail in buildah's network setup.
func (m *Module) tunDevice() error {
f, err := os.OpenFile(m.tunPath(), os.O_RDWR, 0)
if err != nil {
return fmt.Errorf("not eligible: %s cannot be opened (%v), so buildah cannot make a RUN line's network (pasta): "+
"the tun kernel module is not loaded here (the kernel module loads it at boot; a machine whose running kernel's "+
"modules are gone needs a reboot)", m.tunPath(), errors.Unwrap(err))
}
return f.Close()
}
@@ -54,7 +54,7 @@ func (m *Module) Tools(steps []StepTool) []stdio.Tool {
Run: func(map[string]any) (any, error) { return m.Status(m.Ctx), nil }}, Run: func(map[string]any) (any, error) { return m.Status(m.Ctx), nil }},
stdio.Tool{Name: HealthTool, stdio.Tool{Name: HealthTool,
Description: "How the image build module is: unhealthy while buildah cannot be run, while its account holds no " + Description: "How the image build module is: unhealthy while buildah cannot be run, while its account holds no " +
"subordinate user or group range, or while a user namespace cannot be made, saying which; healthy otherwise. A machine that cannot build here now (no tun device) stays healthy and says in its words \"not eligible\", with the cause; it takes no image ask meanwhile (issue 1126). Read-only.", "subordinate user or group range, or while a user namespace cannot be made, saying which; healthy otherwise. Read-only.",
Run: func(map[string]any) (any, error) { return m.Health(m.Ctx), nil }}, Run: func(map[string]any) (any, error) { return m.Health(m.Ctx), nil }},
) )
} }
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"capacity": 0, "capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 2, "cores-per-step": 2,
"build-hours": "" "build-hours": ""
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1 h1:9qyUcrvlklxDRBUCBqGH1L3oximOk554IfaPrTMgaTA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+6 -6
View File
@@ -20,15 +20,15 @@
], ],
"config": { "config": {
"capacity": { "capacity": {
"type": "number", "type": "string",
"description": "How many asks of the image build seat this module runs at once on this node (novox/hq ADR 0525 §5); 0 pulls nothing. It is set per node in the mesh's configuration; a node with no value builds nothing.", "description": "How many asks of the image build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"minimum": 0, "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": 0 "default": "shanks=3,ace=2,g14=0,novox=0"
}, },
"build-hours": { "build-hours": {
"type": "string", "type": "string",
"description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.", "description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
"pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]", "pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
"default": "" "default": ""
}, },
"cores-per-step": { "cores-per-step": {
@@ -51,15 +51,13 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
var watch func(string, func(stdio.ProvisionChange) error) error var watch func(string, func(stdio.ProvisionChange) error) error
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice // And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity. // gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
// And the node's build hours (a node may build only in a window): outside them it takes // And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
// nothing new. // nothing new.
var cores func() float64 var cores func() float64
var hours func() (*buildstep.BuildWindow, string) var hours func() (*buildstep.BuildWindow, string)
settings := ""
if capacity == nil { if capacity == nil {
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say) hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say) capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
settings = m.Settings
watch = buildstep.WatchConfigServed watch = buildstep.WatchConfigServed
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskNode], m.Say) cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskNode], m.Say)
} }
@@ -69,7 +67,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) } load = func(key string) ([]byte, error) { return m.ReadPauses.Get(key) }
} }
return &buildstep.Puller{Source: source, Language: delivery.AskNode, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load, return &buildstep.Puller{Source: source, Language: delivery.AskNode, Node: m.Node, Put: m.Steps, PausePut: m.Pauses, Load: load,
Capacity: capacity, SettingsFile: settings, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say, Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say,
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) { Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
if err := m.Prepare(ctx); err != nil { if err := m.Prepare(ctx); err != nil {
return nil, err return nil, err
@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"regexp"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -85,8 +84,7 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) {
raw, _ := os.ReadFile(moduleDir + "/module.json") raw, _ := os.ReadFile(moduleDir + "/module.json")
var doc struct { var doc struct {
Config map[string]struct { Config map[string]struct {
Default json.RawMessage `json:"default"` Default json.RawMessage `json:"default"`
Pattern, Description string
} `json:"config"` } `json:"config"`
} }
_ = json.Unmarshal(raw, &doc) _ = json.Unmarshal(raw, &doc)
@@ -104,20 +102,10 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) {
if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" { if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" {
t.Errorf("the settings template holds build hours %v (%s)", w, fault) t.Errorf("the settings template holds build hours %v (%s)", w, fault)
} }
// ADR 0567 §15: build-hours names no node (one window, or empty for any hour) and capacity defaults to 0. var capacity string
window := regexp.MustCompile("^(?:" + doc.Config[buildstep.BuildHoursSetting].Pattern + ")$")
for value, ok := range map[string]bool{"": true, "18:00-06:00": true, "g14=18:00-06:00": false, "18:00": false} {
if window.MatchString(value) != ok {
t.Errorf("build-hours pattern on %q: accepted %v, want %v", value, !ok, ok)
}
}
if strings.Contains(doc.Config[buildstep.BuildHoursSetting].Description, "=") {
t.Errorf("the build-hours description names a node pair")
}
var capacity float64
_ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity) _ = json.Unmarshal(doc.Config[buildstep.CapacitySetting].Default, &capacity)
if capacity != 0 { if capacity != buildstep.DefaultCapacities {
t.Errorf("the capacity's default = %g, want 0", capacity) t.Errorf("the capacity's default = %q", capacity)
} }
if resource(t, m, "settings")["path"] != "${dir:state}/settings.json" { if resource(t, m, "settings")["path"] != "${dir:state}/settings.json" {
t.Error("no settings file") t.Error("no settings file")
@@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits {
case m.Capacity != nil: case m.Capacity != nil:
slots = m.Capacity() slots = m.Capacity()
case m.Settings != "": case m.Settings != "":
slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)() slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
} }
return buildstep.NodeLimits(slots) return buildstep.NodeLimits(slots)
} }
@@ -159,17 +159,7 @@ func (m *Module) Health(ctx context.Context) health.Answer {
if err := m.room(); err != nil { if err := m.room(); err != nil {
return health.Unhealthy(err.Error()) return health.Unhealthy(err.Error())
} }
note := "" return health.Healthy(fmt.Sprintf("node %s installed, the version the module builds with (recipe %s)", installed, m.recipe().Version()))
if m.Held != nil {
why, fault := m.Held.CapacityNote()
if fault {
return health.Unhealthy(why)
}
if why != "" {
note = "; " + why // a capacity set to 0 is a choice, said so a starved builder is seen
}
}
return health.Healthy(fmt.Sprintf("node %s installed, the version the module builds with (recipe %s)", installed, m.recipe().Version()) + note)
} }
// Long is a tool that runs a long step (ADR 0469 §2): it answers at once with the step's id, and the step ends on // Long is a tool that runs a long step (ADR 0469 §2): it answers at once with the step's id, and the step ends on
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"capacity": 0, "capacity": "shanks=3,ace=2,g14=0,novox=0",
"cores-per-step": 2, "cores-per-step": 2,
"build-hours": "" "build-hours": ""
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1
github.com/distribution/distribution/v3 v3.1.2 github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1 h1:9qyUcrvlklxDRBUCBqGH1L3oximOk554IfaPrTMgaTA=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+6 -6
View File
@@ -21,15 +21,15 @@
], ],
"config": { "config": {
"capacity": { "capacity": {
"type": "number", "type": "string",
"description": "How many asks of the Node build seat this module runs at once on this node (novox/hq ADR 0525 §5); 0 pulls nothing. It is set per node in the mesh's configuration; a node with no value builds nothing.", "description": "How many asks of the Node build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
"minimum": 0, "pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
"default": 0 "default": "shanks=3,ace=2,g14=0,novox=0"
}, },
"build-hours": { "build-hours": {
"type": "string", "type": "string",
"description": "When this node may take this module's build asks: one window HH:MM-HH:MM in the node's local time (an end before its start crosses midnight, 18:00-06:00). Outside it the node takes no new step and finishes the ones it runs; an empty value builds at any hour. It is set per node in the mesh's configuration.", "description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
"pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]", "pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
"default": "" "default": ""
}, },
"cores-per-step": { "cores-per-step": {
+3 -3
View File
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS module_version (
PRIMARY KEY (module, commit_sha) PRIMARY KEY (module, commit_sha)
); );
-- What the catalog considers this module to be now. Separate from module_version because a -- What the catalogue considers this module to be now. Separate from module_version because a
-- module has many versions and exactly one current one, and "current" is a decision rather than -- module has many versions and exactly one current one, and "current" is a decision rather than
-- a consequence of being newest. -- a consequence of being newest.
CREATE TABLE IF NOT EXISTS module_current ( CREATE TABLE IF NOT EXISTS module_current (
@@ -144,7 +144,7 @@ export class Graph {
const url = env["DATABASE_URL"] ?? readMaybe(env["DATABASE_URL_FILE"]); const url = env["DATABASE_URL"] ?? readMaybe(env["DATABASE_URL_FILE"]);
if (!url) { if (!url) {
throw new Error( throw new Error(
"no DATABASE_URL: the catalog holds the module graph and cannot hold it in memory, " + "no DATABASE_URL: the catalogue holds the module graph and cannot hold it in memory, " +
"because a graph that disappears on restart is not a record of anything", "because a graph that disappears on restart is not a record of anything",
); );
} }
@@ -246,7 +246,7 @@ export class Graph {
} }
} }
/** Every module the catalog holds, with the commit it considers current. */ /** Every module the catalogue holds, with the commit it considers current. */
async modules(): Promise<{ module: string; commit: string; repository: string; path: string }[]> { async modules(): Promise<{ module: string; commit: string; repository: string; path: string }[]> {
const { rows } = await this.pool.query( const { rows } = await this.pool.query(
`SELECT c.module, c.commit_sha, v.repository, v.path `SELECT c.module, c.commit_sha, v.repository, v.path
+2 -2
View File
@@ -12,7 +12,7 @@ export function getCatalogueTools(graph: Graph): ToolDefinition[] {
{ {
name: "catalog_modules", name: "catalog_modules",
description: description:
"Every module this mesh holds, with the commit the catalog considers current and where it came from.", "Every module this mesh holds, with the commit the catalogue considers current and where it came from.",
input: {}, input: {},
run: async () => ({ modules: await graph.modules() }), run: async () => ({ modules: await graph.modules() }),
}, },
@@ -28,7 +28,7 @@ export function getCatalogueTools(graph: Graph): ToolDefinition[] {
const module = String(args.module ?? ""); const module = String(args.module ?? "");
if (!module) return { error: "name a module" }; if (!module) return { error: "name a module" };
const found = await graph.show(module, args.commit ? String(args.commit) : undefined); const found = await graph.show(module, args.commit ? String(args.commit) : undefined);
return found ? { module: found } : { error: `the catalog holds no ${module}` }; return found ? { module: found } : { error: `the catalogue holds no ${module}` };
}, },
}, },
{ {
+3 -8
View File
@@ -21,16 +21,11 @@ account alone writes its buckets.
## How a value resolves ## How a value resolves
For every module the seat mesh-placement places on a node (`mesh-delivery-assigner.placement`), from the module's From the controller's mirrors of the assignments and of each module's declarations (`mesh-controller.assignments`,
own manifests as the delivery built them, never from the controller (issue 1953): the trunk's, which the indexer's `mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back
`modules.<module>` names in its manifests by digest (`mesh-delivery-indexer.modules`, `.manifests`), with the build the
node's want names (`mesh-delivery-assigner.wants`, the builder's `mesh-delivery-builder.builds` naming its manifest)
laid over it key by key on that node. So a module from its own repository resolves as a catalogue module does. A placed
module none of whose manifests can be read is listed under `unresolved` with why and said in the log, and keeps the
values last resolved for it. Then: the manifest's default; mesh config for the predefined key the value falls back
to; node config for that key on the node; the module's own value on that node, the most specific winning. A value to; node config for that key on the node; the module's own value on that node, the most specific winning. A value
naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at
start, whenever a placement, a want or a trunk module moves, and every five minutes, writing only the values that moved. start, whenever a mirror moves, and every five minutes, writing only the values that moved.
## The verb ## The verb
@@ -97,10 +97,6 @@ type Config struct {
RouterWithin, RouterEvery time.Duration RouterWithin, RouterEvery time.Duration
endedMu sync.Mutex endedMu sync.Mutex
ended map[string]endedWord ended map[string]endedWord
// manifests is each manifest's declarations read, by digest: a manifest never changes (manifestDeclarations).
manifestsMu sync.Mutex
manifests map[string]ModuleDeclarations
} }
// Call is one call of the verb, its arguments read. // Call is one call of the verb, its arguments read.
@@ -188,7 +184,7 @@ func (call Call) level() (Level, error) {
case call.Module != "": case call.Module != "":
return Level{}, fmt.Errorf("a module's own value is set for one node, and no node is named: there is no "+ return Level{}, fmt.Errorf("a module's own value is set for one node, and no node is named: there is no "+
"module value for the whole mesh (novox/hq ADR 0348 §3); name the node, or set mesh config for the "+ "module value for the whole mesh (novox/hq ADR 0348 §3); name the node, or set mesh config for the "+
"predefined key %s's value falls back to, or change its default in the catalog", call.Module) "predefined key %s's value falls back to, or change its default in the catalogue", call.Module)
case call.Node != "": case call.Node != "":
return Level{Kind: LevelNode, Node: call.Node}, nil return Level{Kind: LevelNode, Node: call.Node}, nil
} }
@@ -340,8 +336,8 @@ func (c *Config) declared(s Sources, level Level, key string) (rule, bool, error
joinKeys(decl.Config)) joinKeys(decl.Config))
} }
if !assigned(s, level.Node, level.Module) { if !assigned(s, level.Node, level.Module) {
return rule{}, false, fmt.Errorf("%s is not placed on %s, and a module's own value is set for a node it "+ return rule{}, false, fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node it "+
"is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node, "is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
strings.Join(nodesOf(s, level.Module), ", ")) strings.Join(nodesOf(s, level.Module), ", "))
} }
trusted := d.Trusted trusted := d.Trusted
@@ -9,8 +9,6 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log, // memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log,
@@ -122,81 +120,9 @@ func (m *memStore) mirror(t *testing.T, read, key, raw string) {
m.states[read][key] = []byte(raw) m.states[read][key] = []byte(raw)
} }
// assign places a module on a node, in the seat mesh-placement's key for the node.
func (m *memStore) assign(t *testing.T, node, module string) { func (m *memStore) assign(t *testing.T, node, module string) {
t.Helper() m.mirror(t, AssignmentsRead, node+"."+module,
m.place(t, node, module, true) fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module))
}
// unassign takes a module off a node's placement.
func (m *memStore) unassign(t *testing.T, node, module string) {
t.Helper()
m.place(t, node, module, false)
}
func (m *memStore) place(t *testing.T, node, module string, on bool) {
t.Helper()
type placed struct {
Module string `json:"module"`
}
p := struct {
V int `json:"v"`
Node string `json:"node"`
Modules []placed `json:"modules"`
}{V: 1, Node: node}
if e, _ := m.Get(PlacementRead, "placement."+node); e != nil {
if err := json.Unmarshal(e.Value, &p); err != nil {
t.Fatal(err)
}
}
var kept []placed
for _, x := range p.Modules {
if x.Module != module {
kept = append(kept, x)
}
}
if on {
kept = append(kept, placed{Module: module})
}
p.Modules = kept
if err := m.Put(PlacementRead, "placement."+node, p); err != nil {
t.Fatal(err)
}
}
// manifest keeps a manifest in the indexer's manifests by its digest, as the indexer writes it, and answers the digest.
func (m *memStore) manifest(t *testing.T, module, raw string) string {
t.Helper()
if !json.Valid([]byte(raw)) {
t.Fatalf("not JSON: %s", raw)
}
digest := delivery.ManifestDigest([]byte(raw))
value, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw})
if err != nil {
t.Fatal(err)
}
m.mirror(t, ManifestsRead, digest, string(value))
return digest
}
// declare makes raw the module's manifest at the trunk: kept by digest, and named by the indexer's modules.<module>.
func (m *memStore) declare(t *testing.T, module, raw string) {
t.Helper()
digest := m.manifest(t, module, raw)
m.mirror(t, ModulesRead, module, fmt.Sprintf(`{"v":1,"manifest":%q,"repository":"novox/%s","commit":"c1"}`,
digest, module))
}
// want makes a node's want name a build of the module whose manifest is raw, as the assigner and the builder keep them.
func (m *memStore) want(t *testing.T, node, module, fp, raw string) {
t.Helper()
digest := m.manifest(t, module, raw)
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
t.Fatal(err)
}
m.mirror(t, BuildsRead, key, fmt.Sprintf(`{"v":1,"state":"done","manifest":%q}`, digest))
m.mirror(t, WantsRead, "want."+node+"."+module, fmt.Sprintf(`{"v":1,"fp":%q,"self":true}`, fp))
} }
func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved { func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved {
@@ -228,10 +154,10 @@ const theList = `{"keys":{"agent-managed-settings":{"type":"object","description
func aMesh(t *testing.T) (*Config, *memStore) { func aMesh(t *testing.T) (*Config, *memStore) {
t.Helper() t.Helper()
m := newStore() m := newStore()
m.declare(t, "dunst", dunstDeclared) m.mirror(t, DeclaredConfigRead, "dunst", dunstDeclared)
m.declare(t, "claude-code", `{"module":"claude-code","settings":{ m.mirror(t, DeclaredConfigRead, "claude-code", `{"module":"claude-code","settings":{
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`) "managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`)
m.declare(t, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`) m.mirror(t, DeclaredConfigRead, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList)
for _, node := range []string{"g14", "novox"} { for _, node := range []string{"g14", "novox"} {
m.assign(t, node, "dunst") m.assign(t, node, "dunst")
@@ -372,8 +298,8 @@ func TestTheVerbRefusesWhatADeclarationDoesNotAllow(t *testing.T) {
{"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"}, {"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"},
{"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"}, {"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"},
"no module value for the whole mesh"}, "no module value for the whole mesh"},
{"a module on a node it is not placed on", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"}, {"a module on a node it is not assigned to", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
"it is placed on: g14, novox"}, "it is assigned to: g14, novox"},
{"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"}, {"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"},
{"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`}, {"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`},
{"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"}, {"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"},
@@ -546,7 +472,7 @@ func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T
} }
} }
// **A placement, a declaration or its removal moves the resolved values**, written only where they moved, and // **An assignment, a declaration or its removal moves the resolved values**, written only where they moved, and
// said as config-changed; a pass over unchanged sources writes nothing. // said as config-changed; a pass over unchanged sources writes nothing.
func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) { func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) {
c, m := aMesh(t) c, m := aMesh(t)
@@ -563,7 +489,7 @@ func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing
if len(m.events) != 1 || m.events[0].Action != "resolve" { if len(m.events) != 1 || m.events[0].Action != "resolve" {
t.Errorf("an assignment raised %+v", m.events) t.Errorf("an assignment raised %+v", m.events)
} }
m.unassign(t, "ace", "dunst") _ = m.Delete(AssignmentsRead, "ace.dunst")
if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone { if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("an unassignment moved %+v", r.Moved) t.Errorf("an unassignment moved %+v", r.Moved)
} }
@@ -626,110 +552,42 @@ func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) {
} }
} }
// despiegel is de-spiegel's manifest as its own repository declares it (novox/de-spiegel at e25e1beb, its config // Issue 1098: a value of the assignments bucket with `ended` set, or with a `state`, is not an assignment: it
// alone): required trusted values, no default. // resolves nothing and takes the module's resolved values away, as a removed key does.
const despiegel = `{"module":"de-spiegel","version":"1","requires":["route","smtp"],"config":{ func TestAnEndedAssignmentIsNotAnAssignment(t *testing.T) {
"praktijk-name":{"type":"string","description":"the practice's public name","trusted":true,"required":true}, c, m := aMesh(t)
"praktijk-www-name":{"type":"string","description":"its www form","trusted":true,"required":true}}}` m.assign(t, "ace", "dunst")
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
// Issue 1953: **each placed module's declarations come from its own manifests as built**, the trunk's at the indexer t.Fatalf("the assignment moved %+v (%v)", r.Moved, err)
// and the wanted build's at the builder, never from the controller's mirror; a module placed with no manifest to read
// is said unresolved with why, in the listing and the log, and keeps what was resolved for it.
func TestDeclarationsComeFromTheManifestsAsBuilt(t *testing.T) {
fp := "src2:" + strings.Repeat("ab", 32)
cases := []struct {
name string
given func(t *testing.T, m *memStore)
// resolved is each key's resolved value on novox, `<module>.<key>` to source and value ("" for none).
resolved map[string]string
absent []string
unresolved string
}{
{name: "a module of its own repository resolves from the trunk's manifest, its value set",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
must(t, m.Put(ValuesState, Level{Kind: LevelModule, Node: "novox", Module: "de-spiegel"}.ValueKey(
"praktijk-name"), Value{Value: json.RawMessage(`"praktijkdespiegel.be"`), By: "the operator"}))
// The controller's mirror says otherwise, and is not read.
m.mirror(t, "mesh-controller.declared-config", "de-spiegel", `{"module":"de-spiegel","settings":{}}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": `module "praktijkdespiegel.be"`,
"de-spiegel.praktijk-www-name": "unset "}},
{name: "the wanted build's manifest lays its keys over the trunk's on its node",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
"praktijk-name":{"type":"string","description":"d","default":"old.example"},
"old-key":{"type":"number","description":"d","default":3}}}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": `default "old.example"`,
"de-spiegel.old-key": "default 3", "de-spiegel.praktijk-www-name": "unset "}},
{name: "a removed want resolves from the trunk alone",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
"old-key":{"type":"number","description":"d","default":3}}}`)
m.mirror(t, WantsRead, "want.novox.de-spiegel", `{"v":1,"removed":true}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "}, absent: []string{"de-spiegel.old-key"}},
{name: "a placed module the indexer holds no manifest of is unresolved, said, its values kept",
given: func(t *testing.T, m *memStore) {
must(t, m.Put(ResolvedState, ResolvedKey("novox", "de-spiegel", "praktijk-name"),
Resolved{Node: "novox", Module: "de-spiegel", Key: "praktijk-name", Source: SourceUnset}))
m.assign(t, "novox", "de-spiegel")
},
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "},
unresolved: "the indexer holds no modules.de-spiegel"},
} }
for _, tc := range cases { for _, ended := range []string{`"ended":"2026-10-18T01:00:00Z"`, `"state":"pending"`} {
t.Run(tc.name, func(t *testing.T) { m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+ended+`}`)
m := newStore() r, err := c.Reconcile()
tc.given(t, m) if err != nil || len(r.Moved) != 4 || !r.Moved[0].Gone {
var said []string t.Errorf("%s moved %+v (%v)", ended, r.Moved, err)
c := &Config{Store: m, Now: time.Now, Logf: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }} }
if _, err := c.Reconcile(); err != nil { if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil {
t.Fatal(err) t.Errorf("%s: the module keeps its resolved values", ended)
} }
for k, want := range tc.resolved { m.assign(t, "ace", "dunst")
module, key, _ := strings.Cut(k, ".") if r, _ = c.Reconcile(); len(r.Moved) != 4 {
r := m.resolved(t, "novox", module, key) t.Fatalf("the assignment standing again moved %+v", r.Moved)
if got := r.Source + " " + string(r.Value); got != want { }
t.Errorf("%s resolved as %s, want %s", k, got, want)
}
}
for _, k := range tc.absent {
module, key, _ := strings.Cut(k, ".")
if e, _ := m.Get(ResolvedState, ResolvedKey("novox", module, key)); e != nil {
t.Errorf("%s is resolved: %s", k, e.Value)
}
}
l, err := c.List(Call{Module: "de-spiegel"})
if err != nil {
t.Fatal(err)
}
if tc.unresolved == "" {
if len(l.Unresolved) != 0 {
t.Errorf("unresolved: %+v", l.Unresolved)
}
return
}
if len(l.Unresolved) != 1 || l.Unresolved[0].Node != "novox" ||
!strings.Contains(l.Unresolved[0].Why, tc.unresolved) {
t.Errorf("the listing says %+v, want %q", l.Unresolved, tc.unresolved)
}
if !strings.Contains(strings.Join(said, "\n"), tc.unresolved) {
t.Errorf("the log does not say it: %q", said)
}
})
} }
} }
func must(t *testing.T, err error) { // Issue 1098: the fields the controller adds to an assignment change nothing, under the same key.
t.Helper() func TestAnAssignmentCarriesBuildCommitAndRolloutWithoutChangingItsKey(t *testing.T) {
if err != nil { c, m := aMesh(t)
t.Fatal(err) m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+
`"build":"b-1","artifact":{"name":"dunst","version":"1.2"},"commit":"abc123","rollout":"r-9",`+
`"since":"2026-10-18T00:00:00Z","ended":"","state":"","future":{"x":1}}`)
r, err := c.Reconcile()
if err != nil || len(r.Moved) != 4 {
t.Fatalf("an assignment with the new fields moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
if r, _ = c.Reconcile(); len(r.Moved) != 0 {
t.Errorf("a quiet pass moved %+v", r.Moved)
} }
} }
+20 -33
View File
@@ -11,8 +11,8 @@ import (
"strings" "strings"
) )
// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations as its manifests declare // What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations, the list of predefined
// them, the list of predefined keys, and where each module is placed (issue 1953). // keys, and the assignments, as the controller mirrors them on the bus (design 57 F3, K3).
// The types a config value may have (ADR 0348 §4). // The types a config value may have (ADR 0348 §4).
const ( const (
@@ -69,34 +69,7 @@ type ModuleDeclarations struct {
Config map[string]Declaration `json:"settings"` Config map[string]Declaration `json:"settings"`
} }
// ManifestDeclarations is the config a manifest declares (ADR 0348 §1, §4): its `config`, or for a module not yet // ParseModuleDeclarations reads one key of the declared-config mirror.
// moved its `settings` (kind, default and why), the two never both. Only these two fields are read, so a manifest
// field this module's SDK does not know yet never leaves a module's config unresolved.
func ManifestDeclarations(raw []byte) (ModuleDeclarations, error) {
var m struct {
Module string `json:"module"`
Config map[string]Declaration `json:"config"`
Settings map[string]Declaration `json:"settings"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return ModuleDeclarations{}, fmt.Errorf("not a manifest: %w", err)
}
if m.Config != nil && m.Settings != nil {
return ModuleDeclarations{}, fmt.Errorf("%s declares both config and settings, and a module uses one form "+
"(novox/hq ADR 0348 §1)", m.Module)
}
decl := m.Config
if decl == nil {
decl = m.Settings
}
raw, err := json.Marshal(ModuleDeclarations{Module: m.Module, Config: decl})
if err != nil {
return ModuleDeclarations{}, err
}
return ParseModuleDeclarations(raw)
}
// ParseModuleDeclarations reads one module's declarations in the layout ModuleDeclarations writes.
func ParseModuleDeclarations(raw []byte) (ModuleDeclarations, error) { func ParseModuleDeclarations(raw []byte) (ModuleDeclarations, error) {
var m ModuleDeclarations var m ModuleDeclarations
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &m); err != nil {
@@ -148,10 +121,24 @@ type Published struct {
Commit string `json:"commit"` Commit string `json:"commit"`
} }
// Assignment is a module placed on a node, as the seat mesh-placement's key `placement.<node>` names it. // Assignment is one key of the assignments bucket: a module on a node. The value also carries the build, commit and
// rollout that put it there and more; those are read where they are reported, and an unknown field never fails a
// parse. **Ended** and **State** are set on a value that is not a standing assignment (novox/hq ADR 0408 clause 4):
// it reads exactly as a removed key (issue 1098).
type Assignment struct { type Assignment struct {
Node string `json:"node"` Node string `json:"node"`
Module string `json:"module"` Module string `json:"module"`
Ended json.RawMessage `json:"ended,omitempty"`
State json.RawMessage `json:"state,omitempty"`
}
// Standing is whether the value is a standing assignment: neither ended nor in a state.
func (a Assignment) Standing() bool { return !fieldSet(a.Ended) && !fieldSet(a.State) }
// fieldSet is whether a field of an assignment's value carries something: not absent, null or empty.
func fieldSet(f json.RawMessage) bool {
s := strings.TrimSpace(string(f))
return s != "" && s != "null" && s != `""`
} }
// rule is the validation a value is held to, from a module's declaration or a predefined key. // rule is the validation a value is held to, from a module's declaration or a predefined key.
+3 -11
View File
@@ -17,8 +17,6 @@ type Listed struct {
Predefined map[string]Published `json:"predefined,omitempty"` Predefined map[string]Published `json:"predefined,omitempty"`
// Withheld is why the newest list of predefined keys is not published, when it is not. // Withheld is why the newest list of predefined keys is not published, when it is not.
Withheld string `json:"withheld,omitempty"` Withheld string `json:"withheld,omitempty"`
// Unresolved is each placed module the call narrows to whose declarations cannot be read, with why (issue 1953).
Unresolved []Unresolved `json:"unresolved,omitempty"`
// Held is the approved values held for open pull requests, not yet set (novox/hq ADR 0502). // Held is the approved values held for open pull requests, not yet set (novox/hq ADR 0502).
Held []HeldValue `json:"held,omitempty"` Held []HeldValue `json:"held,omitempty"`
} }
@@ -44,18 +42,12 @@ func (c *Config) List(call Call) (*Listed, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
for _, u := range s.Unresolved {
if (call.Module == "" || u.Module == call.Module) && (call.Node == "" || u.Node == call.Node) {
out.Unresolved = append(out.Unresolved, u)
}
}
if call.Module != "" { if call.Module != "" {
if _, ok := s.Declared[call.Module]; !ok && len(out.Unresolved) == 0 { if _, ok := s.Declared[call.Module]; !ok {
return nil, fmt.Errorf("%s is placed on no node with a manifest the indexer holds, so it declares no "+ return nil, fmt.Errorf("%s is not a registered module, so it declares no config", call.Module)
"config", call.Module)
} }
} }
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
resolved := Resolve(s) resolved := Resolve(s)
for _, k := range sortedKeys(resolved) { for _, k := range sortedKeys(resolved) {
r := resolved[k] r := resolved[k]
+3 -6
View File
@@ -4,9 +4,8 @@
// node), mesh-config_resolved (each module's value on each node, with its source), mesh-config_predefined (the // node), mesh-config_resolved (each module's value on each node, with its source), mesh-config_predefined (the
// published list of predefined keys) and mesh-config_proposals (each value proposed for the operator's Approve, // published list of predefined keys) and mesh-config_proposals (each value proposed for the operator's Approve,
// asked under this module's own name, design 57 K4). // asked under this module's own name, design 57 K4).
// It resolves from where the seat mesh-placement places each module, and each module's declarations as its own // It resolves from the controller's mirrors of the assignments, the modules' declarations and the list it kept
// manifests declare them, the trunk's and its wanted build's (the indexer's and the builder's state, issue 1953); the // (design 57 F3, K3). stdout is the MCP channel; what this module says, it says on stderr.
// list of predefined keys still comes from the controller's mirror until the indexer keeps it (ADR 0565 §2). stdout is the MCP channel; what this module says, it says on stderr.
package main package main
import ( import (
@@ -116,9 +115,7 @@ func resolving(c *Config) {
} }
return nil return nil
} }
// A build's manifest is written before a want names the build, so a want, a trunk module or a placement moving is for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} {
// every change of what a module declares where (issue 1953); the builds themselves are read, not watched.
for _, read := range []string{PlacementRead, WantsRead, ModulesRead, PredefinedRead} {
go func(read string) { go func(read string) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
time.Sleep(wait) time.Sleep(wait)
@@ -573,7 +573,7 @@ func TestTheProposalArgumentsAreReadStrictly(t *testing.T) {
// not refused as carrying a secret: modules/asus-zephyrus-g14, i3status-rust and powerlevel10k are names. // not refused as carrying a secret: modules/asus-zephyrus-g14, i3status-rust and powerlevel10k are names.
func TestAnOwnersListOfNamesWithDigitsIsProposed(t *testing.T) { func TestAnOwnersListOfNamesWithDigitsIsProposed(t *testing.T) {
c, m, r := aProposingMesh(t) c, m, r := aProposingMesh(t)
m.declare(t, "mesh-issues", `{"module":"mesh-issues","settings":{ m.mirror(t, DeclaredConfigRead, "mesh-issues", `{"module":"mesh-issues","settings":{
"owners":{"kind":"","default":[],"why":"","type":"list","description":"owners","trusted":true}}}`) "owners":{"kind":"","default":[],"why":"","type":"list","description":"owners","trusted":true}}}`)
m.assign(t, "novox", "mesh-issues") m.assign(t, "novox", "mesh-issues")
raw, err := os.ReadFile(filepath.Join("testdata", "owners-1439.json")) raw, err := os.ReadFile(filepath.Join("testdata", "owners-1439.json"))
@@ -0,0 +1,36 @@
package main
import "testing"
// A module only pulled in by another on a node, and handed to the provisioner there, has a value of its own in the
// assignments mirror (mesh-controller internal/inventory AssignmentValues, issue 765): what requires it, its plan,
// and no time it was assigned. Its values resolve there like an assigned module's, so the node's client can fill its
// templates; they go when the value goes.
func TestAModulePulledInHasItsValuesResolvedOnItsNode(t *testing.T) {
c, m := aMesh(t)
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
}
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","required-by":"desk",`+
`"provisioning":{"templates":{"source":"x","digest":"y"},"files":[]}}`)
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
t.Fatalf("a pulled-in module's value moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
_ = m.Delete(AssignmentsRead, "ace.dunst")
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("a module no longer pulled in moved %+v", r.Moved)
}
// 1126 (ADR 0549 §3, §5): a module the catalogue's placement names on a node resolves there as an assigned one does,
// while the two sources stand; it goes when neither names it.
m.mirror(t, PlacementRead, "placement.ace", `{"v":1,"node":"ace","modules":[{"module":"dunst"}]}`)
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
t.Fatalf("a placed module's values moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
_ = m.Delete(PlacementRead, "placement.ace")
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("a module no longer placed moved %+v", r.Moved)
}
}
+2 -2
View File
@@ -378,8 +378,8 @@ func heldRule(s Sources, call Call, level Level) (rule, PullDecl, string, error)
return rule{}, PullDecl{}, "", fmt.Errorf("%s is not a registered module, so a value cannot be held for it", level.Module) return rule{}, PullDecl{}, "", fmt.Errorf("%s is not a registered module, so a value cannot be held for it", level.Module)
} }
if !assigned(s, level.Node, level.Module) { if !assigned(s, level.Node, level.Module) {
return rule{}, PullDecl{}, "", fmt.Errorf("%s is not placed on %s, and a module's own value is set for a node "+ return rule{}, PullDecl{}, "", fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node "+
"it is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node, "it is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
strings.Join(nodesOf(s, level.Module), ", ")) strings.Join(nodesOf(s, level.Module), ", "))
} }
return d.rule(), pd, declDigest(d), nil return d.rule(), pd, declDigest(d), nil
@@ -23,7 +23,7 @@ func n8nPullDeclares() map[string]any {
func aHoldingMesh(t *testing.T) (*Config, *memStore, *standIn, *standForge) { func aHoldingMesh(t *testing.T) (*Config, *memStore, *standIn, *standForge) {
t.Helper() t.Helper()
c, m, r := aProposingMesh(t) c, m, r := aProposingMesh(t)
m.declare(t, "n8n", `{"module":"n8n","settings":{}}`) m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{}}`)
m.assign(t, "ace", "n8n") m.assign(t, "ace", "n8n")
f := &standForge{state: map[string][2]bool{}} f := &standForge{state: map[string][2]bool{}}
c.Forge = f c.Forge = f
@@ -112,7 +112,7 @@ func TestAPullRequestsDeclarationsAreTakenFromTheControllerAlone(t *testing.T) {
} }
// 1826/K2: a proposal for a key only the pull request declares is refused without its declaration, for a mesh or // 1826/K2: a proposal for a key only the pull request declares is refused without its declaration, for a mesh or
// node level, for a clear, for an undeclared key, and for a module not placed on the node; and trunk-only // node level, for a clear, for an undeclared key, and for a module not assigned to the node; and trunk-only
// proposals are judged as before. // proposals are judged as before.
func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T) { func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T) {
c, m, _, _ := aHoldingMesh(t) c, m, _, _ := aHoldingMesh(t)
@@ -142,7 +142,7 @@ func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T)
"a key not added": {func(c *Call) { c.Key = "other" }, "does not add or change a value"}, "a key not added": {func(c *Call) { c.Key = "other" }, "does not add or change a value"},
"a bad value": {func(c *Call) { c.Value = 0.0 }, "below the minimum"}, "a bad value": {func(c *Call) { c.Value = 0.0 }, "below the minimum"},
"a wrong type": {func(c *Call) { c.Value = "2000" }, "is not a number"}, "a wrong type": {func(c *Call) { c.Value = "2000" }, "is not a number"},
"another node": {func(c *Call) { c.Node = "g14" }, "is not placed on g14"}, "another node": {func(c *Call) { c.Node = "g14" }, "is not assigned to g14"},
"another pull": {func(c *Call) { c.Pull = "novox/mesh-catalog#435" }, "holds no declaration for"}, "another pull": {func(c *Call) { c.Pull = "novox/mesh-catalog#435" }, "holds no declaration for"},
"a malformed pull": {func(c *Call) { c.Pull = "434" }, "owner/repository#number"}, "a malformed pull": {func(c *Call) { c.Pull = "434" }, "owner/repository#number"},
} { } {
@@ -342,7 +342,7 @@ func TestTheMergeAppliesAHeldValueAndItResolvesOnceTheTrunkDeclaresTheKey(t *tes
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "n8n", "media-gid")); e != nil { if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "n8n", "media-gid")); e != nil {
t.Errorf("resolved before the trunk declares the key: %s", e.Value) t.Errorf("resolved before the trunk declares the key: %s", e.Value)
} }
m.declare(t, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+ m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+
`"type":"number","required":true,"minimum":1,"maximum":65535}}}`) `"type":"number","required":true,"minimum":1,"maximum":65535}}}`)
if _, err := c.Reconcile(); err != nil { if _, err := c.Reconcile(); err != nil {
t.Fatal(err) t.Fatal(err)
+14 -60
View File
@@ -6,8 +6,6 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// The four sources (novox/hq ADR 0348 §2, §3, §9): a module's value on a node resolves, the most specific winning, // The four sources (novox/hq ADR 0348 §2, §3, §9): a module's value on a node resolves, the most specific winning,
@@ -22,21 +20,12 @@ const (
PredefinedState = "predefined" PredefinedState = "predefined"
ProposalsState = "proposals" ProposalsState = "proposals"
// PlacementRead is the seat mesh-placement's state, held by the assigner (novox/hq ADR 0567 §1, §8): each AssignmentsRead = "mesh-controller.assignments"
// node's key `placement.<node>` with the modules placed there. It is the one source of which module runs where // PlacementRead is the indexer's placement, read from the catalogue at each merge (novox/hq ADR 0549 §2, §3): each
// (issue 1953): the controller's assignments are no longer read. // node's key `placement.<node>` with the modules placed there.
PlacementRead = delivery.PlacementRead PlacementRead = "mesh-delivery-indexer.placement"
// WantsRead is the assigner's wants, `want.<node>.<module>`: the build each node is to run (ADR 0539 §1). DeclaredConfigRead = "mesh-controller.declared-config"
WantsRead = delivery.AssignerModule + ".wants" PredefinedRead = "mesh-controller.predefined"
// BuildsRead is the builder's builds, `<module>.<fingerprint hex>`: each build's manifest digest (ADR 0525).
BuildsRead = delivery.BuilderModule + ".builds"
// ModulesRead is the indexer's modules, `<module>`: the trunk's manifest digest of each module, from whichever
// repository holds it (ADR 0437 §5, ADR 0478 §9); ManifestsRead its manifests by digest (ADR 0429 §16).
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
// PredefinedRead is the controller's list of predefined keys, which ADR 0565 §2 moves to the indexer's
// `predefined`: read here until that state exists.
PredefinedRead = "mesh-controller.predefined"
// HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9). // HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9).
HistoryLog = "history" HistoryLog = "history"
@@ -134,59 +123,24 @@ type Resolved struct {
// ResolvedKey is a resolved value's key in mesh-config_resolved. // ResolvedKey is a resolved value's key in mesh-config_resolved.
func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key } func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key }
// Sources is everything a resolution reads: where each module is placed, every module's declarations, the published // Sources is everything a resolution reads: the assignments, every module's declarations, the published list of
// list of predefined keys, and every value set. // predefined keys, and every value set.
type Sources struct { type Sources struct {
Assignments []Assignment Assignments []Assignment
// Declared is each module's declarations as its manifests declare them: the trunk's, with every key only a wanted Declared map[string]ModuleDeclarations
// build still declares (declarationsOf). It is what a set is judged against. Predefined map[string]Published
Declared map[string]ModuleDeclarations Values map[string]Value
// OnNode is, by `<node>.<module>`, the declarations of the build the node's want names laid over the trunk's: what
// that node resolves. A placed module absent here resolves from Declared.
OnNode map[string]ModuleDeclarations
// Unresolved is each placed module whose declarations could not be read, with why: never skipped silently
// (issue 1953). Its resolved values are kept as they were until its declarations read again.
Unresolved []Unresolved
Predefined map[string]Published
Values map[string]Value
// Pulls is what the controller told of each open pull request's declarations, and Kept the approved values held // Pulls is what the controller told of each open pull request's declarations, and Kept the approved values held
// for them (pulls.go); neither is resolved. // for them (pulls.go); neither is resolved.
Pulls map[string]PullDecl Pulls map[string]PullDecl
Kept map[string]KeptValue Kept map[string]KeptValue
} }
// Unresolved is a module placed on a node whose values are not resolved, and why. // Resolve is every assigned module's every declared value on its node, keyed as mesh-config_resolved is.
type Unresolved struct {
Node string `json:"node"`
Module string `json:"module"`
Why string `json:"why"`
}
// declarationsOn is the declarations a module on a node resolves from, and whether any are held.
func (s Sources) declarationsOn(node, module string) (ModuleDeclarations, bool) {
if d, ok := s.OnNode[node+"."+module]; ok {
return d, true
}
d, ok := s.Declared[module]
return d, ok
}
// unresolved is whether a module on a node is unresolved.
func (s Sources) unresolved(node, module string) bool {
for _, u := range s.Unresolved {
if u.Node == node && u.Module == module {
return true
}
}
return false
}
// Resolve is every placed module's every declared value on its node, keyed as mesh-config_resolved is. A placed
// module with no declarations is in s.Unresolved, said there (sources).
func Resolve(s Sources) map[string]Resolved { func Resolve(s Sources) map[string]Resolved {
out := map[string]Resolved{} out := map[string]Resolved{}
for _, a := range s.Assignments { for _, a := range s.Assignments {
decl, ok := s.declarationsOn(a.Node, a.Module) decl, ok := s.Declared[a.Module]
if !ok { if !ok {
continue continue
} }
@@ -242,7 +196,7 @@ type Moved struct {
From json.RawMessage `json:"from,omitempty"` From json.RawMessage `json:"from,omitempty"`
To json.RawMessage `json:"to,omitempty"` To json.RawMessage `json:"to,omitempty"`
Source string `json:"source"` Source string `json:"source"`
// Gone says the module no longer takes the key there: no longer placed, or no longer declared. // Gone says the module no longer takes the key there: unassigned, or no longer declared.
Gone bool `json:"gone,omitempty"` Gone bool `json:"gone,omitempty"`
} }
+58 -233
View File
@@ -4,30 +4,73 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// Reading the sources, writing the resolved values, and publishing the list of predefined keys. // Reading the sources, writing the resolved values, and publishing the list of predefined keys.
// sources reads everything a resolution reads, from the bus as it is now: which modules are placed where from the // sources reads everything a resolution reads, from the bus as it is now.
// seat mesh-placement, and each placed module's declarations from its own manifests as built, never from the
// controller (issue 1953, ADR 0565 §3).
func (c *Config) sources() (Sources, error) { func (c *Config) sources() (Sources, error) {
s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{}, s := Sources{Declared: map[string]ModuleDeclarations{}, Predefined: map[string]Published{},
Predefined: map[string]Published{}, Values: map[string]Value{}} Values: map[string]Value{}}
var problems []string var problems []string
if err := c.readPlacement(&s); err != nil { if err := c.each(AssignmentsRead, func(key string, raw []byte) error {
return s, fmt.Errorf("the seat mesh-placement's placement cannot be read: %w", err) var a Assignment
if err := json.Unmarshal(raw, &a); err != nil || a.Node == "" || a.Module == "" {
return fmt.Errorf("the assignment %s is not one: %s", key, raw)
}
if !a.Standing() {
return nil // ended or pending: read as a removed key (issue 1098)
}
s.Assignments = append(s.Assignments, a)
return nil
}); err != nil {
return s, fmt.Errorf("the assignments cannot be read: %w", err)
} }
if err := c.readDeclarations(&s); err != nil { // And every module the catalogue's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
// sources stand (ADR 0549 §5), so a pair either names is resolved, and none loses its values while the two differ.
// Placement that cannot be read yet (the indexer's state not on the bus) leaves the assignments alone, said.
standing := map[string]bool{}
for _, a := range s.Assignments {
standing[a.Node+"."+a.Module] = true
}
if err := c.each(PlacementRead, func(key string, raw []byte) error {
node, ok := strings.CutPrefix(key, "placement.")
if !ok {
return nil
}
var p struct {
Node string `json:"node"`
Modules []struct {
Module string `json:"module"`
} `json:"modules"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
problems = append(problems, fmt.Sprintf("the placement %s is not one", key))
return nil
}
for _, m := range p.Modules {
if m.Module != "" && !standing[node+"."+m.Module] {
standing[node+"."+m.Module] = true
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
}
}
return nil
}); err != nil {
problems = append(problems, fmt.Sprintf("the catalogue's placement cannot be read, so the assignments alone are resolved: %v", err))
}
if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error {
m, err := ParseModuleDeclarations(raw)
if err != nil {
// One module's declarations unread leaves that module unresolved, said; the rest resolve.
problems = append(problems, fmt.Sprintf("%s: %v", key, err))
return nil
}
s.Declared[m.Module] = m
return nil
}); err != nil {
return s, fmt.Errorf("the modules' declarations cannot be read: %w", err) return s, fmt.Errorf("the modules' declarations cannot be read: %w", err)
} }
for _, u := range s.Unresolved {
problems = append(problems, fmt.Sprintf("%s on %s: %s", u.Module, u.Node, u.Why))
}
if err := c.each(PredefinedState, func(key string, raw []byte) error { if err := c.each(PredefinedState, func(key string, raw []byte) error {
var p Published var p Published
if err := json.Unmarshal(raw, &p); err != nil { if err := json.Unmarshal(raw, &p); err != nil {
@@ -52,222 +95,11 @@ func (c *Config) sources() (Sources, error) {
return s, err return s, err
} }
for _, p := range problems { for _, p := range problems {
c.logf("[mesh-config] a placed module's declarations are not read, so its values are not resolved (they "+ c.logf("[mesh-config] a module's declarations are not read, so its values are not resolved: %s", p)
"stay as last resolved): %s", p)
} }
return s, nil return s, nil
} }
// readPlacement is every module the seat mesh-placement places on a node (ADR 0567 §1, §9): each node's key
// `placement.<node>`.
func (c *Config) readPlacement(s *Sources) error {
placed := map[string]bool{}
return c.each(PlacementRead, func(key string, raw []byte) error {
node, err := delivery.ParsePlacementKey(key)
if err != nil {
return nil // a key of another kind
}
var p struct {
Node string `json:"node"`
Modules []struct {
Module string `json:"module"`
} `json:"modules"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
return fmt.Errorf("the placement %s is not one", key)
}
for _, m := range p.Modules {
if m.Module != "" && !placed[node+"."+m.Module] {
placed[node+"."+m.Module] = true
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
}
}
return nil
})
}
// readDeclarations reads each placed module's declarations from its manifests as the delivery holds them: the trunk's
// (the indexer's `modules.<module>` and its manifest by digest), and, on each node whose want names a build, that
// build's (the builder's `<module>.<fp>` names its manifest digest). On a node the want's build is laid over the
// trunk's, key by key, so a key only the build it runs declares still resolves there and a key the trunk adds resolves
// before its build is wanted; Declared is the trunk's with every key only a wanted build declares. A placed module
// none of whose manifests can be read is Unresolved, with why.
func (c *Config) readDeclarations(s *Sources) error {
wanted := map[string]string{} // <node>.<module> → the fingerprint its want names
if err := c.each(WantsRead, func(key string, raw []byte) error {
node, module, err := delivery.ParseWantKey(key)
if err != nil {
return nil // a held key or a node's own want
}
var w struct {
FP string `json:"fp"`
Removed bool `json:"removed"`
}
if json.Unmarshal(raw, &w) == nil && !w.Removed && w.FP != "" {
wanted[node+"."+module] = w.FP
}
return nil
}); err != nil {
return fmt.Errorf("the assigner's wants: %w", err)
}
trunk := map[string]ModuleDeclarations{}
trunkWhy := map[string]string{}
for _, module := range modulesOf(s.Assignments) {
d, err := c.trunkDeclarations(module)
if err != nil {
trunkWhy[module] = err.Error()
continue
}
trunk[module] = d
s.Declared[module] = d
}
for _, a := range s.Assignments {
t, hasTrunk := trunk[a.Module]
fp, isWanted := wanted[a.Node+"."+a.Module]
var built ModuleDeclarations
var builtErr error
if isWanted {
built, builtErr = c.builtDeclarations(a.Module, fp)
}
switch {
case isWanted && builtErr == nil:
on := overlay(t, built)
s.OnNode[a.Node+"."+a.Module] = on
d := s.Declared[a.Module]
s.Declared[a.Module] = overlay(built, d) // the trunk's wins in what a set is judged against
case hasTrunk:
if isWanted {
c.logf("[mesh-config] %s on %s resolves from the trunk's manifest alone: the build its want names "+
"cannot be read: %v", a.Module, a.Node, builtErr)
}
default:
why := trunkWhy[a.Module]
if isWanted {
why += "; and the build its want names: " + builtErr.Error()
} else {
why += "; and no want names a build of it on " + a.Node
}
s.Unresolved = append(s.Unresolved, Unresolved{Node: a.Node, Module: a.Module, Why: why})
}
}
return nil
}
// overlay is base with every key of over laid on it, the module's name kept.
func overlay(base, over ModuleDeclarations) ModuleDeclarations {
out := ModuleDeclarations{Module: over.Module, Config: map[string]Declaration{}}
if out.Module == "" {
out.Module = base.Module
}
for k, d := range base.Config {
out.Config[k] = d
}
for k, d := range over.Config {
out.Config[k] = d
}
return out
}
func modulesOf(as []Assignment) []string {
seen := map[string]bool{}
var out []string
for _, a := range as {
if !seen[a.Module] {
seen[a.Module] = true
out = append(out, a.Module)
}
}
sort.Strings(out)
return out
}
// trunkDeclarations is a module's declarations at the trunk: the manifest the indexer's `modules.<module>` names.
func (c *Config) trunkDeclarations(module string) (ModuleDeclarations, error) {
key, err := delivery.ModulesKey(module)
if err != nil {
return ModuleDeclarations{}, err
}
e, err := c.Store.Get(ModulesRead, key)
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer's modules.%s cannot be read: %w", module, err)
}
if e == nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer holds no modules.%s, so no manifest of it is known", module)
}
var m struct {
Manifest string `json:"manifest"`
}
if err := json.Unmarshal(e.Value, &m); err != nil || m.Manifest == "" {
return ModuleDeclarations{}, fmt.Errorf("the indexer's modules.%s names no manifest", module)
}
return c.manifestDeclarations(module, m.Manifest)
}
// builtDeclarations is a module's declarations at a build: the manifest the builder's done key names.
func (c *Config) builtDeclarations(module, fp string) (ModuleDeclarations, error) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return ModuleDeclarations{}, err
}
e, err := c.Store.Get(BuildsRead, key)
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the builder's %s cannot be read: %w", key, err)
}
if e == nil {
return ModuleDeclarations{}, fmt.Errorf("the builder holds no build %s", key)
}
var b struct {
Manifest string `json:"manifest"`
}
if err := json.Unmarshal(e.Value, &b); err != nil || b.Manifest == "" {
return ModuleDeclarations{}, fmt.Errorf("the builder's %s names no manifest", key)
}
return c.manifestDeclarations(module, b.Manifest)
}
// manifestDeclarations is the declarations of the manifest of a digest in the indexer's manifests, read once: a
// manifests key is written once and never changes (ADR 0429 §16), so what it declares is kept by digest.
func (c *Config) manifestDeclarations(module, digest string) (ModuleDeclarations, error) {
c.manifestsMu.Lock()
d, ok := c.manifests[digest]
c.manifestsMu.Unlock()
if ok {
return d, nil
}
key, err := delivery.ManifestsKey(digest)
if err != nil {
return ModuleDeclarations{}, err
}
e, err := c.Store.Get(ManifestsRead, key)
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s cannot be read: %w", short(digest), err)
}
if e == nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer holds no manifest %s", short(digest))
}
var m struct {
Module string `json:"module"`
JSON string `json:"manifest"`
}
if err := json.Unmarshal(e.Value, &m); err != nil || m.JSON == "" {
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s is not one", short(digest))
}
if m.Module != module {
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s is %s's, not %s's", short(digest), m.Module, module)
}
d, err = ManifestDeclarations([]byte(m.JSON))
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the manifest %s: %w", short(digest), err)
}
c.manifestsMu.Lock()
if c.manifests == nil {
c.manifests = map[string]ModuleDeclarations{}
}
c.manifests[digest] = d
c.manifestsMu.Unlock()
return d, nil
}
// each hands every key of a state to visit. // each hands every key of a state to visit.
func (c *Config) each(state string, visit func(key string, raw []byte) error) error { func (c *Config) each(state string, visit func(key string, raw []byte) error) error {
keys, err := c.Store.Keys(state) keys, err := c.Store.Keys(state)
@@ -313,13 +145,6 @@ func (c *Config) writeResolved(s Sources) ([]Moved, error) {
return nil, fmt.Errorf("the resolved values cannot be read: %w", err) return nil, fmt.Errorf("the resolved values cannot be read: %w", err)
} }
want := Resolve(s) want := Resolve(s)
// A placed module whose declarations cannot be read keeps what was resolved for it: not knowing is no reason to take
// a node's values away (issue 1953).
for key, h := range held {
if _, ok := want[key]; !ok && h.Source != "unreadable" && s.unresolved(h.Node, h.Module) {
want[key] = h
}
}
moved := diff(held, want) moved := diff(held, want)
var problems []error var problems []error
for _, m := range moved { for _, m := range moved {
+1 -1
View File
@@ -2,4 +2,4 @@ module mesh-config
go 1.26.0 go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021075504-7bf4a8053a49

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