Compare commits
82
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf51cc7dbf | ||
|
|
12c2574c9d | ||
|
|
8bf85863ce | ||
|
|
f61265f7a0 | ||
|
|
22ff0d1000 | ||
|
|
7d43574783 | ||
|
|
96b129e544 | ||
|
|
0d71d8b56c | ||
|
|
96a37f58ca | ||
|
|
030f9ee4e7 | ||
|
|
c8240c38bb | ||
|
|
1555241393 | ||
|
|
add6f143ca | ||
|
|
b46089b698 | ||
|
|
158348ede7 | ||
|
|
ff4c6c0435 | ||
|
|
c193d2b389 | ||
|
|
10447d8c1e | ||
|
|
5be6576117 | ||
|
|
24fad2b92a | ||
|
|
d3eb5cae80 | ||
|
|
2043200201 | ||
|
|
ef618e8199 | ||
|
|
c00005c141 | ||
|
|
bd5f170af0 | ||
|
|
6bbb8329e4 | ||
|
|
e6b913cac7 | ||
|
|
411c1b8de8 | ||
|
|
64e6688ec8 | ||
|
|
2d57c9663a | ||
|
|
5c1729523b | ||
|
|
1628db6c80 | ||
|
|
0b9f19b612 | ||
|
|
39ce149f74 | ||
|
|
f37f93e812 | ||
|
|
cf4879eaa1 | ||
|
|
1e377fe862 | ||
|
|
5c6899e641 | ||
|
|
848c0cbd03 | ||
|
|
af89893d42 | ||
|
|
105c4d628c | ||
|
|
59cad7154a | ||
|
|
3b35e78733 | ||
|
|
e072abb35e | ||
|
|
e389e770d9 | ||
|
|
00613b438c | ||
|
|
d1f42564e3 | ||
|
|
5548e1030c | ||
|
|
7bb07ce349 | ||
|
|
4e3b140d43 | ||
|
|
b5aa226649 | ||
|
|
77b511a79a | ||
|
|
d836a31aa7 | ||
|
|
683ce7e37f | ||
|
|
3494548c88 | ||
|
|
3feeefc70f | ||
|
|
3f552a17f6 | ||
|
|
16ea3824b5 | ||
|
|
fabb62da00 | ||
|
|
8068cf74a7 | ||
|
|
6e97ba3867 | ||
|
|
448310908c | ||
|
|
58309a9729 | ||
|
|
6724dab0cf | ||
|
|
387ee59140 | ||
|
|
85bbeb37e3 | ||
|
|
363039f337 | ||
|
|
a9d96b30be | ||
|
|
644a34cf49 | ||
|
|
277a4d0060 | ||
|
|
69056948bf | ||
|
|
9cdac09ad9 | ||
|
|
86d37a14e7 | ||
|
|
d13f74df40 | ||
|
|
dfe490d9e2 | ||
|
|
4f317201c9 | ||
|
|
25f6154564 | ||
|
|
b8ba9dd966 | ||
|
|
002a0e1a12 | ||
|
|
9bb85a37f2 | ||
|
|
774fcdfa93 | ||
|
|
470687ea4c |
+1
-1
@@ -36,8 +36,8 @@ modules/mesh-build-image/cmd/mesh-build-image/mesh-build-image
|
||||
/modules/mesh-build-go/mesh-build-go
|
||||
/modules/mesh-delivery-gate/mesh-delivery-gate
|
||||
modules/mesh-config/cmd/mesh-config/mesh-config
|
||||
checks/placement/placement
|
||||
|
||||
# Built by their modules' build step (issue 1577), never committed.
|
||||
modules/forticlient/cmd/forticlient-tools/forticlient-tools
|
||||
modules/networkmanager/uplink-tools
|
||||
checks/words/words
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package nobinaries refuses a compiled program under modules/ (issue 1577): it is build output, made by the module's build step,
|
||||
// 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,
|
||||
// and `go build` in a module's folder writes one beside go.mod where `git add -A` takes it.
|
||||
package nobinaries
|
||||
|
||||
@@ -27,10 +27,10 @@ func Kind(head []byte) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Files are the files of the catalogue's root that the change holds, anywhere: 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.
|
||||
func Files(root string) ([]string, error) {
|
||||
cmd := exec.Command("git", "-C", root, "ls-files", "-z", "--", "modules")
|
||||
cmd := exec.Command("git", "-C", root, "ls-files", "-z")
|
||||
if out, err := cmd.Output(); err == nil && len(out) > 0 {
|
||||
var files []string
|
||||
for _, f := range strings.Split(strings.TrimRight(string(out), "\x00"), "\x00") {
|
||||
@@ -41,10 +41,16 @@ func Files(root string) ([]string, error) {
|
||||
return files, nil
|
||||
}
|
||||
var files []string
|
||||
err := filepath.WalkDir(filepath.Join(root, "modules"), func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() || !d.Type().IsRegular() {
|
||||
err := filepath.WalkDir(root, func(p string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() && d.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if d.IsDir() || !d.Type().IsRegular() {
|
||||
return nil
|
||||
}
|
||||
rel, _ := filepath.Rel(root, p)
|
||||
files = append(files, rel)
|
||||
return nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package nobinaries
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
@@ -16,6 +17,7 @@ func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
|
||||
"modules/e/short": []byte("\x7f"),
|
||||
"modules/f/empty": {},
|
||||
"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
|
||||
for f, b := range files {
|
||||
@@ -32,8 +34,8 @@ func TestFindNamesEachCompiledProgramAndNothingElse(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c only", got)
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("found %v; wanted modules/a/a, modules/b/cmd/b/b.exe and modules/c/c and checks/words/words only", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,3 +54,36 @@ func TestTheCatalogueHoldsNoCompiledProgram(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
# 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
|
||||
# every tracked file of the repository (git ls-files, no path limit): the whole tree, so this package's verdict is never kept.
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
module git.novox.be/novox/mesh-catalog/checks/noplacement
|
||||
|
||||
go 1.22
|
||||
@@ -0,0 +1,99 @@
|
||||
// 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")
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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
|
||||
@@ -2,8 +2,9 @@
|
||||
// issue 1838): a module is for any mesh, so no manifest, file, code string, identifier, comment, test name or
|
||||
// fixture of a covered module names a node of this one.
|
||||
//
|
||||
// The names are in the file `particulars` at the catalogue's root, one a line, `#` comments — the one place
|
||||
// they may be written. The modules covered are in `covered` at checks/particulars/covered, one a line. A name is matched
|
||||
// The names are the mesh's own, not the repository's: the checker hands them in the environment value
|
||||
// MESH_NODE_NAMES, comma-separated, from the node records it reads (novox/hq ADR 0567 §16). A run with none fails
|
||||
// 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.
|
||||
// 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.
|
||||
@@ -34,14 +35,28 @@ func main() {
|
||||
if len(os.Args) > 1 {
|
||||
root = os.Args[1]
|
||||
}
|
||||
os.Exit(check(root))
|
||||
os.Exit(check(root, namesFromEnv()))
|
||||
}
|
||||
|
||||
// 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.
|
||||
func check(root string) int {
|
||||
names, err := readList(filepath.Join(root, "particulars"))
|
||||
if err == nil && len(names) == 0 {
|
||||
err = fmt.Errorf("particulars lists no name — the check would pass on anything")
|
||||
func check(root string, names []string) int {
|
||||
var err error
|
||||
if len(names) == 0 {
|
||||
err = fmt.Errorf("no node names (%s is empty) — the check would pass on anything", NamesEnv)
|
||||
}
|
||||
var covered []string
|
||||
if err == nil {
|
||||
|
||||
@@ -74,26 +74,24 @@ func TestANodeNamedPathIsRefusedThroughRun(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckReturnsOneOnAFindingAndTwoOnAnUnreadableList(t *testing.T) {
|
||||
func TestCheckReturnsOneOnAFindingAndTwoOnNoNames(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p, s string) {
|
||||
p = filepath.Join(root, p)
|
||||
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
os.WriteFile(p, []byte(s), 0o644)
|
||||
}
|
||||
write("particulars", "ace\n")
|
||||
write("checks/particulars/covered", "kernel\n")
|
||||
write("modules/kernel/a", "clean\n")
|
||||
if got := check(root); got != 0 {
|
||||
if got := check(root, []string{"ace"}); got != 0 {
|
||||
t.Errorf("clean = %d, want 0", got)
|
||||
}
|
||||
write("modules/kernel/a", "on ace\n")
|
||||
if got := check(root); got != 1 {
|
||||
if got := check(root, []string{"ace"}); got != 1 {
|
||||
t.Errorf("finding = %d, want 1", got)
|
||||
}
|
||||
os.Remove(filepath.Join(root, "particulars"))
|
||||
if got := check(root); got != 2 {
|
||||
t.Errorf("no list = %d, want 2", got)
|
||||
if got := check(root, nil); got != 2 {
|
||||
t.Errorf("no names = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +115,10 @@ 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.
|
||||
func TestTheCoveredModulesNameNoNode(t *testing.T) {
|
||||
if code := check("../.."); code != 0 {
|
||||
if _, set := os.LookupEnv(NamesEnv); !set {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# What these tests read outside their package, as paths from the repository's root (a glob, or a directory read whole;
|
||||
# ? before a path whose absence is read). mesh-build-go hashes them into the package's digest, so its verdict is kept
|
||||
# until one of them changes (issue 1936). An empty list, or a line matching nothing, keeps nothing.
|
||||
# check("../.."): the root's list and every file of the modules it names.
|
||||
particulars
|
||||
# check("../.."): every file of the modules the covered list names. The node names come from the run (MESH_NODE_NAMES).
|
||||
modules
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
module checks/placement
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f
|
||||
@@ -1,18 +0,0 @@
|
||||
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=
|
||||
@@ -1,80 +0,0 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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 +0,0 @@
|
||||
These tests read files outside this directory (the whole catalogue, the repository, or its root files): mesh-build-go never keeps this package's verdict (issue 1935).
|
||||
@@ -206,6 +206,9 @@ func excerpt(s string, from, to int) string {
|
||||
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.
|
||||
func goStrings(path, rel string) ([]text, error) {
|
||||
src, err := os.ReadFile(path)
|
||||
@@ -231,6 +234,11 @@ func goStrings(path, rel string) ([]text, error) {
|
||||
if err != nil {
|
||||
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 {
|
||||
cur.s += v
|
||||
} else {
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
// manifest's.
|
||||
const NamesFileEnv = "MESH_ARTIFACT_STORE_NAMES_FILE"
|
||||
|
||||
// CatalogueModules and CatalogueModule are the catalogue's tools these definitions are read through.
|
||||
// CatalogueModules and CatalogueModule are the catalog's tools these definitions are read through.
|
||||
const (
|
||||
CatalogueModules = "mesh-catalog.catalog_modules"
|
||||
CatalogueModule = "mesh-catalog.catalog_module"
|
||||
@@ -143,21 +143,21 @@ func PinsIn(manifest json.RawMessage, names StoreNames) []pinned {
|
||||
return out
|
||||
}
|
||||
|
||||
// Definitions is what the catalogue's definitions pin in the store, and what of them could not be read.
|
||||
// Definitions is what the catalog's definitions pin in the store, and what of them could not be read.
|
||||
type Definitions struct {
|
||||
// Pins are by Key(repository, digest).
|
||||
Pins map[string][]Pin
|
||||
// Read is how many definitions were read; Unread says, per module, why its definition was not.
|
||||
Read int
|
||||
Unread map[string]string
|
||||
// NotAsked says why the catalogue could not be asked at all, when it could not.
|
||||
// NotAsked says why the catalog could not be asked at all, when it could not.
|
||||
NotAsked string
|
||||
// NamesNotRead says why the store's older names could not be read, when they could not.
|
||||
NamesNotRead string
|
||||
Names StoreNames
|
||||
}
|
||||
|
||||
// Catalogue reads the definitions through the catalogue's tools, and keeps each definition it read by its
|
||||
// Catalogue reads the definitions through the catalog's tools, and keeps each definition it read by its
|
||||
// module and commit, which never change together.
|
||||
type Catalogue struct {
|
||||
Ask Asking
|
||||
@@ -171,21 +171,21 @@ type catalogueEntry struct {
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// Read asks the catalogue for every module and reads each definition, bounded by DefinitionsTimeout.
|
||||
// Read asks the catalog for every module and reads each definition, bounded by DefinitionsTimeout.
|
||||
func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
|
||||
defs := &Definitions{Pins: map[string][]Pin{}, Unread: map[string]string{}, Names: names}
|
||||
if c == nil || c.Ask == nil {
|
||||
defs.NotAsked = "this bundle has no way to ask the catalogue"
|
||||
defs.NotAsked = "this bundle has no way to ask the catalog"
|
||||
return defs
|
||||
}
|
||||
raw, err := c.Ask(CatalogueModules, map[string]any{})
|
||||
if err != nil {
|
||||
defs.NotAsked = "the catalogue's modules could not be asked: " + err.Error()
|
||||
defs.NotAsked = "the catalog's modules could not be asked: " + err.Error()
|
||||
return defs
|
||||
}
|
||||
answer, output, ok := answerOf(raw)
|
||||
if !ok {
|
||||
defs.NotAsked = "the catalogue refused its modules: " + lastLine(output)
|
||||
defs.NotAsked = "the catalog refused its modules: " + lastLine(output)
|
||||
return defs
|
||||
}
|
||||
var list struct {
|
||||
@@ -193,7 +193,7 @@ func (c *Catalogue) Read(ctx context.Context, names StoreNames) *Definitions {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal(answer, &list); err != nil || list.Error != "" || list.Modules == nil {
|
||||
defs.NotAsked = "the catalogue's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
|
||||
defs.NotAsked = "the catalog's modules answer is not readable: " + firstOf(list.Error, errText(err), lastLine(output))
|
||||
return defs
|
||||
}
|
||||
c.mu.Lock()
|
||||
|
||||
@@ -54,7 +54,7 @@ func Tools(s *Store, c Controller, cat *Catalogue) []stdio.Tool {
|
||||
Name: "artifact_store_references",
|
||||
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 " +
|
||||
"record names it, and a definition in the catalogue pins it by digest, under the store's current name or " +
|
||||
"record names it, and a definition in the catalog pins it by digest, under the store's current name or " +
|
||||
"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, " +
|
||||
"a named document the controller keeps under a tag, or unrecorded — no record names it. A platform manifest " +
|
||||
|
||||
@@ -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;
|
||||
// a test holds it to that renderer's own output (testdata/rendered-by-typescript.json).
|
||||
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 catalogue, 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 catalog, never this file:\nit is rewritten whenever the module renders.\n\n## Who this node is\n\n- **Node:** `" +
|
||||
node +
|
||||
"`\n- **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 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"
|
||||
"\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"
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@
|
||||
"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-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 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"
|
||||
"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"
|
||||
},
|
||||
"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-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 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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -62,3 +65,60 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
# 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
|
||||
# machine's private address is there: it waits up to 80 seconds for it, and a start that found it
|
||||
# missing is tried again every ten seconds rather than left failed.
|
||||
# machine's private address is there: a start that finds it missing fails after at most two seconds and
|
||||
# is tried again every ten seconds (Restart=on-failure, no start limit) until the tunnel has brought it.
|
||||
# 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]
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
StartLimitIntervalSec=0
|
||||
|
||||
[Service]
|
||||
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'
|
||||
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'
|
||||
Restart=on-failure
|
||||
RestartSec=10s
|
||||
|
||||
+37
-19
@@ -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
|
||||
* run past a bound): the call fails naming the repository and the commit, never answering the directories it did
|
||||
* see, since a directory left out would be taken for one without a module. Reads only. */
|
||||
async commitTrees(owner: string, repo: string, sha: string, 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>> }> {
|
||||
async commitTrees(owner: string, repo: string, sha: string, paths?: Record<string, string[]>): Promise<{ root: string; trees: Record<string, string>; paths?: Record<string, Record<string, string>> }> {
|
||||
const base = `${repoPath(owner, repo)}/git/trees/${commitSegment(sha)}?recursive=true`;
|
||||
const cut = (why: string) => new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} was cut (${why}); no trees are answered from a part`);
|
||||
const entries: any[] = [];
|
||||
@@ -587,23 +587,7 @@ export class GiteaClient {
|
||||
if (!tree) throw new Error(`the forge's tree listing of ${owner}/${repo} at ${sha} holds ${e.path} but not the tree of ${dir}`);
|
||||
trees[dir] = tree;
|
||||
}
|
||||
// **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 };
|
||||
if (paths === undefined) return { root, trees };
|
||||
// **Named paths** (novox/hq ADR 0499 §3, issue 1758): each asked path's git object id at the commit, a file's or a
|
||||
// directory's, from the same listing, by directory and by path relative to it. A path the listing lacks fails
|
||||
// naming both, and nothing is answered in part.
|
||||
@@ -618,7 +602,7 @@ export class GiteaClient {
|
||||
named[dir][p] = id;
|
||||
}
|
||||
}
|
||||
return files === undefined ? { root, trees, paths: named } : { root, trees, paths: named, listed: files };
|
||||
return { root, trees, paths: named };
|
||||
}
|
||||
|
||||
/** Whether a repository holds a file at a commit: true, false for a 404, thrown when the forge cannot say. */
|
||||
@@ -758,6 +742,15 @@ export class GiteaClient {
|
||||
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(
|
||||
owner: string,
|
||||
repo: string,
|
||||
@@ -1052,6 +1045,31 @@ export class GiteaAdmin {
|
||||
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
|
||||
* 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:
|
||||
|
||||
@@ -62,3 +62,29 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
|
||||
t.Errorf("the build packs files/ into %d archives, not one", packs)
|
||||
}
|
||||
}
|
||||
|
||||
// The forge's sshd takes a burst of the mesh's machines fetching at once: the image's MaxStartups raised from OpenSSH's
|
||||
// default 10:30:100 to 100:30:200 through SSH_MAX_STARTUPS, which dropped ace's thirteen connections at once.
|
||||
func TestTheForgesSshdTakesABurstOfTheMeshsMachines(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Resources []map[string]any `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, r := range m.Resources {
|
||||
if r["id"] != "server" {
|
||||
continue
|
||||
}
|
||||
env, _ := r["env"].(map[string]any)
|
||||
if env["SSH_MAX_STARTUPS"] != "100:30:200" {
|
||||
t.Errorf("SSH_MAX_STARTUPS is %v, not 100:30:200", env["SSH_MAX_STARTUPS"])
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Error("the manifest has no server container")
|
||||
}
|
||||
|
||||
+66
-65
@@ -53,8 +53,9 @@ import { announce, keepWatermark, readWatermark } from "./announce.js";
|
||||
import { mergeTool } from "./merge.js";
|
||||
import { GiteaAdmin, GiteaClient, movedSince } from "./client.js";
|
||||
import { keepMainRules, mainRulesStep } from "./mainrules.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headKey, headsToAnnounce, maintained,
|
||||
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts, reposToLook } from "./pulls.js";
|
||||
import { CHECK_CONTEXT, CHECK_STATUS, REPO_CHECK_CONTEXT, commentFor, composedGate, groupGateStatus, headKey, maintained,
|
||||
requiredOf, statusesFor, writesGate, type Announced, type Checked, repoCheckFacts } from "./pulls.js";
|
||||
import { actOn, announceNewHeads, startPass } from "./watch.js";
|
||||
import { GateWriter } from "./gate.js";
|
||||
import { HOOK_PATH, HOOK_PORT, Pace, claimHead, ensureSystemHook, eventsTool, keptSecret, receiver, type ForgeEvent } from "./webhook.js";
|
||||
import { keepSiblings, noteComposed, noteVerdict, readSiblings, siblingsFile, siblingsOf, weighSiblings } from "./siblings.js";
|
||||
@@ -138,12 +139,7 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
// Closed unmerged (novox/hq ADR 0239): announced once, since the watching began, so its delivery stops.
|
||||
const closedKey = `closed:${repo.full_name}#${pull.number}`;
|
||||
if (!pull.merged && pull.state === "closed" && !announced.has(closedKey)) {
|
||||
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);
|
||||
}
|
||||
if (primedMerges && !!pull.updated_at && !!since && pull.updated_at > since) await announceClosed(client, repo, pull);
|
||||
announced.add(closedKey);
|
||||
continue;
|
||||
}
|
||||
@@ -168,6 +164,23 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
type RepoOf = { owner: string; name: string; full_name: string; clone_url?: string };
|
||||
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. */
|
||||
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;
|
||||
@@ -281,43 +294,18 @@ async function alreadyChecked(client: GiteaClient, repo: RepoOf, pull: OpenOf):
|
||||
}
|
||||
|
||||
let lastPullLook = "";
|
||||
// **A sweep now and then looks at every repository** (novox/hq issue 347), whatever moved: the floor is a
|
||||
// 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> {
|
||||
/** The pass over every open pull request: run at the start and when the receiver listens, never on a timer (issue 1422). */
|
||||
async function passPulls(client: GiteaClient): Promise<void> {
|
||||
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();
|
||||
// A pull request opened, reopened or retargeted moves its own update time and not its repository's: the
|
||||
// pull requests updated since the floor are found by their own (issue 347). A search that fails is a sweep.
|
||||
let sweep = Date.now() - lastSweep >= SWEEP_MS;
|
||||
let updated: { owner: string; name: string; updated_at?: string }[] = [];
|
||||
if (floor && !sweep) {
|
||||
try {
|
||||
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 = headKey(repo.full_name, pull.number, pull.base);
|
||||
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);
|
||||
}
|
||||
for (const repo of await client.listAllRepos()) {
|
||||
// Every open pull request, paged (novox/hq issue 579).
|
||||
const open = await client.listOpenPullsSince(repo.owner, repo.name, "");
|
||||
await announceNewHeads(repo.full_name, open, heads, primedPulls, dayAgo, {
|
||||
alreadyChecked: (pull) => alreadyChecked(client, repo, pull),
|
||||
announceHead: (pull) => announceHead(client, repo, pull),
|
||||
missed: (what) => pace.miss(what),
|
||||
});
|
||||
if (await trunksMoved(client, repo.owner, repo.name)) changedTrunks = true;
|
||||
}
|
||||
if (changedTrunks) keepTrunks();
|
||||
@@ -497,6 +485,9 @@ 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
|
||||
* gitea_events says of them (webhook.ts). */
|
||||
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);
|
||||
|
||||
// The states this module keeps and which others may read, said on the bus before it serves (novox/hq ADR 0495 clause 2).
|
||||
@@ -522,22 +513,15 @@ registerModuleTools("gitea", () => [eventsTool(pace), ...readAllTools(readAll),
|
||||
gates,
|
||||
}), mergeTool(gitea, { gates })] : [])]);
|
||||
|
||||
/** What the forge's event asks of the announcer at once (issue 729): a new head is announced, and a merge. */
|
||||
async function actOn(client: GiteaClient, event: ForgeEvent): Promise<void> {
|
||||
const b = event.body as { owner?: string; repo?: string; number?: number };
|
||||
if (!b.owner || !b.repo || !b.number) return;
|
||||
if (event.name === "forge.pull-request.closed") {
|
||||
// Closed unmerged is a sibling's move; a merge is weighed with its announcement below (ADR 0382 §5).
|
||||
if (!(event.body as { merged?: boolean }).merged) await siblingMoves(client, `${b.owner}/${b.repo}`, b.number);
|
||||
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);
|
||||
}
|
||||
/** What the forge's event asks of the announcer at once (issue 729, 1422): see watch.ts. */
|
||||
const actOnEvent = (client: GiteaClient, event: ForgeEvent): Promise<void> => actOn<RepoOf, PullOf>(event, {
|
||||
getRepo: (o, r) => client.getRepo(o, r),
|
||||
getPull: (o, r, n) => client.getPullRequest(o, r, n) as Promise<PullOf & { state?: string; merged?: boolean }>,
|
||||
announceHead: (repo, pull) => announceHead(client, repo, pull as OpenOf),
|
||||
announceMerge: (repo, pull) => announceMerge(client, repo, pull),
|
||||
announceClosed: (repo, pull) => announceClosed(client, repo, pull),
|
||||
siblingMoves: (full, n) => siblingMoves(client, full, n),
|
||||
});
|
||||
|
||||
/**
|
||||
* **The receiver, and the forge's one system webhook pointing at it** (issue 729). The secret is minted and kept beside
|
||||
@@ -563,7 +547,12 @@ 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).
|
||||
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}`));
|
||||
await actOn(client, event);
|
||||
try {
|
||||
await actOnEvent(client, event);
|
||||
} catch (err) {
|
||||
catchUp();
|
||||
throw err;
|
||||
}
|
||||
} });
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
@@ -612,9 +601,9 @@ if (gitea) {
|
||||
// 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
|
||||
// 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. Until it is
|
||||
// listening, Pace keeps the polls at 30 s, so nothing is missed meanwhile; listenForForge says its own failure.
|
||||
void listenForForge(client);
|
||||
// of the node starts left gitea_commit_status, gitea_merge_pull_request and gitea_events served nowhere. The pass below
|
||||
// runs again once it is listening, so nothing said before is missed; listenForForge says its own failure.
|
||||
const listenOk = listenForForge(client);
|
||||
// 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.
|
||||
// An account of the list the forge does not hold yet (the forge-account provider makes nox-builder and nox-agent when
|
||||
@@ -646,8 +635,20 @@ if (gitea) {
|
||||
// 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(() => pollRepos(client), 60_000);
|
||||
tick(() => pollMerged(client), () => pace.every());
|
||||
tick(() => pollPulls(client), () => pace.every());
|
||||
// **No timer asks the forge for pull requests or merges** (issue 1422): the forge's events announce them. One pass over
|
||||
// every repository runs at the start and again once the receiver listens, so what happened while the module was away or
|
||||
// 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
|
||||
// handshake in 10 s, so the import must end while a refused bind is still asked again.
|
||||
bindInBackground([
|
||||
|
||||
@@ -266,7 +266,8 @@
|
||||
"env": {
|
||||
"DB_TYPE": "postgres",
|
||||
"USER_UID": "1000",
|
||||
"USER_GID": "1000"
|
||||
"USER_GID": "1000",
|
||||
"SSH_MAX_STARTUPS": "100:30:200"
|
||||
},
|
||||
"env-file": [
|
||||
"${dir:state}/server.env"
|
||||
@@ -569,6 +570,7 @@
|
||||
"mesh-build-node.keys",
|
||||
"mesh-build-image.keys",
|
||||
"mesh-delivery-checker.owed",
|
||||
"mesh-delivery-indexer.modules"
|
||||
"mesh-delivery-indexer.modules",
|
||||
"mesh-delivery-gate.source"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
// 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`;
|
||||
}
|
||||
@@ -183,15 +183,6 @@ 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.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 {
|
||||
server.close();
|
||||
}
|
||||
@@ -218,12 +209,7 @@ 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 }]);
|
||||
try {
|
||||
const tool = getGiteaTools(client).find((t) => t.name === "gitea_commit_trees")!;
|
||||
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));
|
||||
}
|
||||
assert.ok("paths" in tool.input, "the tool names the arguments");
|
||||
const got: any = await tool.run({ owner: "novox", repo: "hq", sha: COMMIT, paths: { "modules/a": ["main.go"] } } as any);
|
||||
assert.deepEqual(got.paths, { "modules/a": { "main.go": h("6") } });
|
||||
const before = asked.length;
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
// 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);
|
||||
});
|
||||
@@ -538,7 +538,7 @@ test("the tools register once there is a way to a token, and the first call mint
|
||||
"gitea_get_file",
|
||||
"gitea_list_branches",
|
||||
"gitea_delete_branch",
|
||||
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set",
|
||||
"gitea_branch_protection_get", "gitea_team_rights", "gitea_branch_protection_set", "gitea_branch_protection_set_full", "gitea_repo_settings_set_full",
|
||||
"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_list_labels", "gitea_create_label",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
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);
|
||||
});
|
||||
@@ -251,7 +251,8 @@ test("a forge that never delivers is said loudly once, and unhealthy, when the p
|
||||
assert.equal(r.healthy, true);
|
||||
assert.equal(r.verified.count, 1);
|
||||
assert.equal(r.verified.last.event, "push");
|
||||
assert.equal(r.polls.every_seconds, 300);
|
||||
assert.equal(r.polls.every_seconds, null);
|
||||
assert.equal(r.polls.hearing, true);
|
||||
assert.match(said.join("\n"), /heard again/);
|
||||
});
|
||||
|
||||
@@ -314,24 +315,19 @@ test("without a receiver by configuration the polls alone watch, healthy; a rece
|
||||
assert.match(r.why, /403/);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
test("nothing is paced: the report says no timer asks the forge, and a miss is still counted and said", async () => {
|
||||
const { Pace } = await import("../webhook.ts");
|
||||
let now = 1_000_000;
|
||||
const said: string[] = [];
|
||||
const pace = new Pace(() => now, (l) => said.push(l));
|
||||
assert.equal(pace.every(), 30_000, "no delivery yet");
|
||||
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");
|
||||
assert.equal((pace as any).every, undefined, "no pace to ask");
|
||||
assert.equal((pace.report() as any).polls.every_seconds, null);
|
||||
pace.heard();
|
||||
assert.equal((pace.report() as any).polls.hearing, true);
|
||||
now += 1000;
|
||||
pace.miss("novox/hq#7 at aaaaaaaa");
|
||||
assert.equal(pace.every(), 30_000, "the poll found what no event said");
|
||||
assert.equal((pace.report() as any).polls.hearing, false, "the pass found what no event said");
|
||||
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
|
||||
|
||||
+103
-19
@@ -12,6 +12,8 @@ import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools"
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GiteaAdmin, GiteaClient, type BranchProtection } from "../client.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 { containedFrom, isCommit, mergeableUnder, newestByContext } from "../checks.js";
|
||||
import {
|
||||
@@ -77,24 +79,13 @@ function namedPaths(raw: unknown): Record<string, string[]> | undefined {
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The `listed` argument of gitea_commit_trees: [directory], each plain (no `.` or `..` segment, not absolute, the root
|
||||
* the empty name); anything else is refused before the forge is asked. Absent is absent. */
|
||||
function listedDirs(raw: unknown): string[] | undefined {
|
||||
if (raw === undefined || raw === null) return undefined;
|
||||
if (!Array.isArray(raw) || raw.some((d) => typeof d !== "string")) throw new Error("listed is [directory, ...]");
|
||||
for (const d of raw as string[]) {
|
||||
if (d !== "" && d.split("/").some((seg) => seg === "" || seg === "." || seg === "..")) throw new Error(`listed: ${JSON.stringify(d)} is not a plain relative directory`);
|
||||
}
|
||||
return raw as string[];
|
||||
}
|
||||
|
||||
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
|
||||
return namesChecked(allGiteaTools(gitea, admin));
|
||||
export function getGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin, source: SourceRead = sourceFromBus(state)): ToolDefinition[] {
|
||||
return namesChecked(allGiteaTools(gitea, admin, source));
|
||||
}
|
||||
|
||||
// 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).
|
||||
function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefinition[] {
|
||||
function allGiteaTools(gitea: GiteaClient, admin: (() => GiteaAdmin) | undefined, source: SourceRead): ToolDefinition[] {
|
||||
return [
|
||||
// ---- Repositories ----
|
||||
{
|
||||
@@ -431,8 +422,9 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
run: async (args) => {
|
||||
const owner = String(args.owner), repo = String(args.repo);
|
||||
// The one read made as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the
|
||||
// repository administrator's, which the tools' account is not. No other tool uses this account.
|
||||
// Read as the forge's administrator (novox/hq ADR 0375, issue 772): the rules' list is the repository
|
||||
// administrator's, which the tools' account is not. gitea_team_rights reads, and gitea_branch_protection_set_full
|
||||
// and gitea_repo_settings_set_full write, with the same account.
|
||||
const rules = await (admin ? admin() : gitea).branchProtections(owner, repo);
|
||||
if (!args.branch) return { rules: rules.map(summarised) };
|
||||
const rule = rules.find((p) => (p.rule_name ?? p.branch_name) === String(args.branch)) ?? null;
|
||||
@@ -488,6 +480,99 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
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",
|
||||
@@ -637,20 +722,19 @@ function allGiteaTools(gitea: GiteaClient, admin?: () => GiteaAdmin): ToolDefini
|
||||
},
|
||||
{
|
||||
name: "gitea_commit_trees",
|
||||
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). 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.",
|
||||
description: "A commit's trees from the forge's one recursive listing: the root tree, and the tree of every directory that holds a module.json (the root as an empty name), each by its git hash. What the forge bridge puts in a merge's message (novox/hq ADR 0437 §5). With paths, {directory: [path relative to it]}, the answer also carries paths, {directory: {path: git object id}} of each named file or directory at the commit (novox/hq ADR 0499 §3); a path the commit lacks fails naming the directory and the path. The commit is a full 40-hex sha. A listing the forge cut is refused, never answered in part. Reads only.",
|
||||
input: {
|
||||
owner: { type: "string", description: "the repository owner" },
|
||||
repo: { type: "string", description: "the repository name" },
|
||||
sha: { type: "string", description: "the commit, its full 40-hex sha" },
|
||||
paths: { type: "object", description: "optional, {directory: [path relative to it]}: the git object id of each named file or directory is answered under paths; the root directory is the empty name" },
|
||||
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) => {
|
||||
const owner = String(args.owner ?? "").trim(), repo = String(args.repo ?? "").trim();
|
||||
const sha = String(args.sha ?? "").trim();
|
||||
if (!owner || !repo) throw new Error("name the repository: owner and repo");
|
||||
if (!/^[0-9a-f]{40}$/i.test(sha)) throw new Error("sha is a commit's full 40-hex sha");
|
||||
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths), listedDirs(args.listed));
|
||||
return gitea.commitTrees(owner, repo, sha, namedPaths(args.paths));
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
// 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 };
|
||||
+19
-22
@@ -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
|
||||
// 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.
|
||||
// The polls stay as the safety net, slowed; the announced-heads and announced-merges records keep anything from
|
||||
// No poll is the safety net (issue 1422); the announced-heads and announced-merges records keep anything from
|
||||
// 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.
|
||||
@@ -144,7 +144,7 @@ export interface ReceiverOptions {
|
||||
publish: (event: ForgeEvent) => Promise<void>;
|
||||
/** Acts on it at once, beside publishing it (the announcer). */
|
||||
act?: (event: ForgeEvent) => Promise<void>;
|
||||
/** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the polls know events are
|
||||
/** Told of every verified delivery — its kind (X-Gitea-Event) and its delivery id — so the module knows events are
|
||||
* heard, and the module can say when it last heard one. */
|
||||
heard?: (kind: string, delivery: string) => void;
|
||||
/** 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);
|
||||
if (opts.act) await opts.act(event);
|
||||
} catch (err) {
|
||||
log(`a forge event ${event.name} could not be published or acted on; the polls catch it — ${err instanceof Error ? err.message : 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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -276,14 +276,13 @@ export interface HookState {
|
||||
export const SILENT_AFTER = 10 * 60_000;
|
||||
|
||||
/**
|
||||
* **How fast the polls look, and whether the forge's events are heard** (issue 729): every five minutes only while the
|
||||
* 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.
|
||||
* **Whether the forge's events are heard** (issues 729, 1422). Nothing polls the forge, so there is no pace: a delivery
|
||||
* heard within the last ten minutes, and none missed since, is `hearing`.
|
||||
*
|
||||
* It also keeps what `gitea_events` says: where the receiver listens, the hook as ensured, the verified and refused
|
||||
* deliveries, and the polls' finds no event said. **A forge whose events stop is said** (issue 729): once the receiver
|
||||
* 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
|
||||
* listens and the hook is ensured, when nothing verified for ten minutes — since the start, or since the last delivery —
|
||||
* and the poll has found at least two heads or merges no event said in that quiet (one could be the poll beating an
|
||||
* and a pass has found at least two heads or merges no event said in that quiet (one could be a pass beating an
|
||||
* 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.
|
||||
*/
|
||||
@@ -318,7 +317,7 @@ export class Pace {
|
||||
this.notHeard = null;
|
||||
}
|
||||
/** 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). The polls alone watch. */
|
||||
* or the hook could not be ensured (`fails` true, said unhealthy). Only the pass at the start watches. */
|
||||
unheard(why: string, fails = true): void {
|
||||
this.notHeard = why;
|
||||
this.notHeardFails = fails;
|
||||
@@ -337,10 +336,10 @@ export class Pace {
|
||||
this.refusedSpell++;
|
||||
this.lastRefused = { at: this.now(), reason, from };
|
||||
}
|
||||
/** The poll found a head or merge no forge event announced. */
|
||||
/** The pass over every repository found a head or merge no forge event announced. */
|
||||
miss(what: string): void {
|
||||
const t = this.now();
|
||||
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`);
|
||||
if (this.hearing()) this.log(`THE FORGE'S EVENTS MISSED ${what}: the pass over every repository found it`);
|
||||
this.missed = t;
|
||||
this.missCount++;
|
||||
this.lastMiss = { at: t, what };
|
||||
@@ -355,8 +354,8 @@ export class Pace {
|
||||
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 ` +
|
||||
`system webhook at ${this.hook?.url}${refusedSince ? `; ${refusedSince} refused since, the last because ` +
|
||||
`${this.lastRefused?.reason}` : ""}; the poll found ${what} — the polls alone watch the forge, every 30 s ` +
|
||||
`(novox/hq issue 729)`);
|
||||
`${this.lastRefused?.reason}` : ""}; the pass found ${what}; nothing else watches the forge ` +
|
||||
`(novox/hq issue 729, 1422)`);
|
||||
}
|
||||
}
|
||||
/** Whether a delivery was refused after the last verified one (or with none verified). */
|
||||
@@ -367,9 +366,6 @@ export class Pace {
|
||||
const t = this.now();
|
||||
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 {
|
||||
return this.where !== null && this.hook !== null;
|
||||
}
|
||||
@@ -383,14 +379,14 @@ export class Pace {
|
||||
if (this.silentSince) {
|
||||
state = "unhealthy";
|
||||
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 poll found ${this.missCount} head(s) or merge(s) no ` +
|
||||
`event since the start at ${iso(this.started)}`}, while the pass found ${this.missCount} head(s) or merge(s) no ` +
|
||||
`event said, the last ${this.lastMiss?.what} at ${iso(this.lastMiss?.at ?? t)}`;
|
||||
} else if (refusedSince) {
|
||||
state = "unhealthy";
|
||||
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}`;
|
||||
} else if (!this.expecting()) {
|
||||
why = this.notHeard ? `no events are listened for, so the polls alone watch the forge: ${this.notHeard}` :
|
||||
why = this.notHeard ? `no events are listened for, so only the pass at the start watches the forge: ${this.notHeard}` :
|
||||
"the receiver is not listening yet";
|
||||
if (this.notHeard && this.notHeardFails) state = "unhealthy";
|
||||
} else if (this.hearing()) {
|
||||
@@ -398,7 +394,7 @@ export class Pace {
|
||||
} 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`;
|
||||
} else {
|
||||
why = `the last delivery was at ${iso(this.last)}; the polls look every 30 s until the next`;
|
||||
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`;
|
||||
}
|
||||
return {
|
||||
healthy: state !== "unhealthy", state, why,
|
||||
@@ -407,7 +403,8 @@ export class Pace {
|
||||
hook: this.hook,
|
||||
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) } },
|
||||
polls: { every_seconds: this.every() / 1000, hearing: this.hearing(), found_unsaid: this.missCount,
|
||||
polls: { every_seconds: null, note: "no timer asks the forge; one pass at the start and when the receiver listens (issue 1422)",
|
||||
hearing: this.hearing(), found_unsaid: this.missCount,
|
||||
last_found: this.lastMiss && { ...this.lastMiss, at: iso(this.lastMiss.at) } },
|
||||
silent: this.silentSince ? { since: iso(this.silentSince) } : null,
|
||||
};
|
||||
@@ -502,8 +499,8 @@ export function eventsTool(pace: Pace): { name: string; description: string; inp
|
||||
name: "gitea_events",
|
||||
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 " +
|
||||
"verified and the last one's time and event, how many were refused and the last reason, how often the polls look " +
|
||||
"and whether events are heard, and how many heads or merges the poll found that no event said. Unhealthy when the " +
|
||||
"verified and the last one's time and event, how many were refused and the last reason, whether " +
|
||||
"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 " +
|
||||
"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 " +
|
||||
"could not listen or the webhook could not be made.",
|
||||
|
||||
@@ -53,6 +53,22 @@ 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 |
|
||||
| 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
|
||||
|
||||
| tool | | answers |
|
||||
@@ -63,7 +79,7 @@ built for the fallback kernel too) after its boot-entries service, so the entry
|
||||
## Tests
|
||||
|
||||
`go test ./...`: the tool over a stand-in tree holding the control node's own files of 2026-10-14; the
|
||||
manifest's shape (the default, the order, the unit and its sandbox, reload, restart and health); 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 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
|
||||
writes it, run with `sh` against a stand-in directory: it sets and reads back, keeps what it found once,
|
||||
rotates the journal once and not for `always` nor while the machine starts, goes straight to a new value on a
|
||||
reload without rewriting what it found, gives back what it found and then keeps nothing, refuses a value the kernel
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
|
||||
for _, r := range m.Resources {
|
||||
ids = append(ids, r["id"].(string))
|
||||
}
|
||||
if strings.Join(ids, " ") != "state huge-pages unit apply boot-entries boot-unit boot-hook boot-apply linux-lts linux-lts-headers" {
|
||||
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" {
|
||||
t.Fatalf("order: %v", ids)
|
||||
}
|
||||
script := m.resource(t, "huge-pages")
|
||||
@@ -57,6 +57,31 @@ func TestTheDefaultIsMadviseAndTheUnitFollowsTheSetting(t *testing.T) {
|
||||
if h := apply["health"].(map[string]any); h["kind"] != "unit" {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# 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
|
||||
@@ -100,6 +100,25 @@
|
||||
"id": "linux-lts-headers",
|
||||
"type": "package",
|
||||
"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": {
|
||||
|
||||
@@ -47,6 +47,9 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps {
|
||||
Has: m.Has,
|
||||
Release: func(ws string) error { return m.spaces().Release(ws) },
|
||||
Logf: m.Say,
|
||||
// A failed test's whole output is said on the module's own log stream (buildstep.LogTool reads it back).
|
||||
Narrate: m.Emit,
|
||||
Module: "mesh-build-go",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,13 +90,15 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
var watch func(string, func(stdio.ProvisionChange) error) error
|
||||
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
|
||||
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
|
||||
// And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
|
||||
// And the node's build hours (a node may build only in a window): outside them it takes
|
||||
// nothing new.
|
||||
var cores func() float64
|
||||
var hours func() (*buildstep.BuildWindow, string)
|
||||
settings := ""
|
||||
if capacity == nil {
|
||||
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
|
||||
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
|
||||
capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say)
|
||||
settings = m.Settings
|
||||
watch = buildstep.WatchConfigServed
|
||||
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskGo], m.Say)
|
||||
}
|
||||
@@ -106,7 +111,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,
|
||||
Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say, Has: m.Has,
|
||||
Capacity: capacity, SettingsFile: settings, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say, Has: m.Has,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -126,7 +127,8 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) {
|
||||
raw, _ := os.ReadFile(moduleDir + "/module.json")
|
||||
var doc struct {
|
||||
Config map[string]struct {
|
||||
Default json.RawMessage `json:"default"`
|
||||
Default json.RawMessage `json:"default"`
|
||||
Pattern, Description string
|
||||
} `json:"config"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &doc)
|
||||
@@ -144,10 +146,20 @@ func TestManifestClaimsTheGoBuildSeatAndReadsItsCapacity(t *testing.T) {
|
||||
if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" {
|
||||
t.Errorf("the settings template holds build hours %v (%s)", w, fault)
|
||||
}
|
||||
var capacity string
|
||||
// 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[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)
|
||||
if capacity != buildstep.DefaultCapacities {
|
||||
t.Errorf("the capacity's default = %q, want %q", capacity, buildstep.DefaultCapacities)
|
||||
if capacity != 0 {
|
||||
t.Errorf("the capacity's default = %g, want 0", capacity)
|
||||
}
|
||||
settings := resource(t, m, "settings")
|
||||
if settings["path"] != "${dir:state}/settings.json" {
|
||||
|
||||
@@ -73,6 +73,7 @@ func TestAComposeAskIsBuiltOnceAndAnsweredOnTheBus(t *testing.T) {
|
||||
states := newStates()
|
||||
m := moduleWith(states, "go1.26.0")
|
||||
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.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
|
||||
|
||||
@@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits {
|
||||
case m.Capacity != nil:
|
||||
slots = m.Capacity()
|
||||
case m.Settings != "":
|
||||
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
|
||||
slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)()
|
||||
}
|
||||
return buildstep.NodeLimits(slots)
|
||||
}
|
||||
|
||||
@@ -37,6 +37,10 @@ const DigestVersion = "6"
|
||||
// depends on the siblings' commits.
|
||||
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
|
||||
// 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
|
||||
@@ -164,6 +168,8 @@ func (m *Module) digestTool() StepTool {
|
||||
"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"},
|
||||
"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"},
|
||||
},
|
||||
@@ -178,6 +184,10 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names, err := nodeNamesArg(args[delivery.NodeNamesArg])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if runner == RunnerWorkspace {
|
||||
if err := m.workspaceThere(workspace); err != nil {
|
||||
return nil, err
|
||||
@@ -191,7 +201,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
|
||||
default:
|
||||
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})
|
||||
a, judged, err := m.asCandidate(ctx, levelSpec{Step: stepDigest, Level: level, Workspace: workspace, NodeNames: names})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -201,7 +211,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
|
||||
a.Digest.JudgedBy = judged
|
||||
return *a.Digest, nil
|
||||
}
|
||||
res, err := m.digestHere(ctx, workspace, level)
|
||||
res, err := m.digestHere(ctx, workspace, level, names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -210,7 +220,7 @@ func (m *Module) digest(ctx context.Context, args map[string]any) (any, error) {
|
||||
}
|
||||
|
||||
// digestHere is the digest step as this program runs it.
|
||||
func (m *Module) digestHere(ctx context.Context, workspace, level string) (DigestResult, error) {
|
||||
func (m *Module) digestHere(ctx context.Context, workspace, level string, names []string) (DigestResult, error) {
|
||||
if !workspaceOK.MatchString(workspace) {
|
||||
return DigestResult{}, fmt.Errorf("%q is not a workspace id", workspace)
|
||||
}
|
||||
@@ -241,6 +251,14 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string) (Diges
|
||||
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)
|
||||
if err != nil {
|
||||
return DigestResult{}, err
|
||||
@@ -251,7 +269,7 @@ func (m *Module) digestHere(ctx context.Context, workspace, level string) (Diges
|
||||
if err != nil {
|
||||
return DigestResult{}, err
|
||||
}
|
||||
if err := digestModule(&res, pkgs, tree, header, besideHeader, level == LevelIntegration); err != nil {
|
||||
if err := digestModule(&res, pkgs, tree, header, besideHeader, namesHeader, level == LevelIntegration); err != nil {
|
||||
return DigestResult{}, err
|
||||
}
|
||||
}
|
||||
@@ -302,7 +320,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
|
||||
// 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 []string, needs bool) error {
|
||||
func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, header, beside, names []string, needs bool) error {
|
||||
roots := []string{}
|
||||
for path, p := range pkgs {
|
||||
if p.ForTest == "" && !p.DepOnly && !p.Standard && !strings.HasSuffix(path, ".test") && p.Module != nil && p.Module.Main {
|
||||
@@ -349,7 +367,7 @@ func digestModule(res *DigestResult, pkgs map[string]*goPackage, tree string, he
|
||||
res.neverKept[path] = why
|
||||
continue
|
||||
}
|
||||
d, err := packageDigest(root, testMain, pkgs, tree, header, beside, declared)
|
||||
d, err := packageDigest(root, testMain, pkgs, tree, header, beside, names, declared)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", path, err)
|
||||
}
|
||||
@@ -361,9 +379,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
|
||||
// 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
|
||||
// only when a file the tests reach names MESH_CHECK_BESIDE. A package of the standard library is the Go version's.
|
||||
// only when a file the tests reach names MESH_CHECK_BESIDE, and the node names likewise for MESH_NODE_NAMES. A package of the standard library is the Go version's.
|
||||
// The lines a package's ReadsOutsideMarker declares join them (declaredReads).
|
||||
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside, declared []string) (string, error) {
|
||||
func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree string, header, beside, names, declared []string) (string, error) {
|
||||
reached := map[string]*goPackage{root.ImportPath: root}
|
||||
for _, dep := range testMain.Deps {
|
||||
if p := pkgs[dep]; p != nil {
|
||||
@@ -371,7 +389,7 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
|
||||
}
|
||||
}
|
||||
lines := append([]string{}, declared...)
|
||||
readsBeside := false
|
||||
readsBeside, readsNames := false, false
|
||||
for _, p := range reached {
|
||||
if p.Standard || p.Module == nil {
|
||||
continue
|
||||
@@ -390,9 +408,10 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
|
||||
return "", err
|
||||
}
|
||||
lines = append(lines, l)
|
||||
if !readsBeside {
|
||||
if raw, err := os.ReadFile(filepath.Join(p.Dir, f)); err == nil && bytes.Contains(raw, []byte(besideVariable)) {
|
||||
readsBeside = true
|
||||
if !readsBeside || !readsNames {
|
||||
if raw, err := os.ReadFile(filepath.Join(p.Dir, f)); err == nil {
|
||||
readsBeside = readsBeside || bytes.Contains(raw, []byte(besideVariable))
|
||||
readsNames = readsNames || bytes.Contains(raw, []byte(namesVariable))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,6 +443,9 @@ func packageDigest(root, testMain *goPackage, pkgs map[string]*goPackage, tree s
|
||||
if readsBeside {
|
||||
header = append(append([]string{}, header...), beside...)
|
||||
}
|
||||
if readsNames {
|
||||
header = append(append([]string{}, header...), names...)
|
||||
}
|
||||
h := sha256.New()
|
||||
for _, l := range append(append([]string{}, header...), lines...) {
|
||||
h.Write([]byte(l + "\n"))
|
||||
|
||||
@@ -172,8 +172,9 @@ 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"}}
|
||||
testMain := &goPackage{ImportPath: "x/r.test", Deps: []string{ext.ImportPath}}
|
||||
pkgs[root.ImportPath] = root
|
||||
var names []string
|
||||
digest := func() string {
|
||||
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil, nil)
|
||||
d, err := packageDigest(root, testMain, pkgs, dir, []string{"digest 1"}, nil, names, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -188,6 +189,20 @@ func TestAModuleOutsideTheTreeCountsByVersionAndChecksum(t *testing.T) {
|
||||
if d3 := digest(); d3 == d1 {
|
||||
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
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -47,9 +48,14 @@ func TestTheManifestDeclaresTheToolchainsStream(t *testing.T) {
|
||||
Streams []struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
Age int `json:"max-age-days"`
|
||||
MiB int `json:"max-mib"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 1 || m.Streams[0].Name != "toolchains-go" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) {
|
||||
// And its log stream: a failed test's whole output, kept 14 days and at most 2 GiB, dropping its oldest (the operator,
|
||||
// 2026-10-30).
|
||||
if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 2 || m.Streams[0].Name != "toolchains-go" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) ||
|
||||
m.Streams[1].Name != buildstep.LogStreamOf("go") || !slices.Equal(m.Streams[1].Events, []string{"log.**"}) || m.Streams[1].Age != 14 || m.Streams[1].MiB != 2048 {
|
||||
t.Errorf("streams = %+v (%v)", m.Streams, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
defer stopBus()
|
||||
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace)...)
|
||||
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace, nil)...)
|
||||
// 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.
|
||||
// Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory.
|
||||
|
||||
@@ -43,6 +43,7 @@ type levelSpec struct {
|
||||
Run string `json:"run,omitempty"`
|
||||
Setup time.Duration `json:"setup"`
|
||||
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.
|
||||
@@ -221,14 +222,14 @@ func (m *Module) answer(ctx context.Context, spec levelSpec) levelAnswer {
|
||||
tree := filepath.Join(m.Work, spec.Workspace, "tree")
|
||||
switch spec.Step {
|
||||
case stepDigest:
|
||||
d, err := m.digestHere(ctx, spec.Workspace, spec.Level)
|
||||
d, err := m.digestHere(ctx, spec.Workspace, spec.Level, spec.NodeNames)
|
||||
if err != nil {
|
||||
return levelAnswer{Error: err.Error()}
|
||||
}
|
||||
return levelAnswer{Digest: &d}
|
||||
case stepUnit:
|
||||
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)})
|
||||
bounds: Bounds{Run: m.unitBound()}, env: m.checkEnv(spec.Workspace, spec.NodeNames)})
|
||||
if err != nil {
|
||||
return levelAnswer{Error: err.Error()}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
@@ -41,6 +42,7 @@ func main() {
|
||||
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
ReadSteps: readOf(stdio.State(delivery.StepsState)),
|
||||
ReadLog: stdio.Stream(buildstep.LogStreamOf("go")).Read,
|
||||
Pauses: stdio.State(delivery.PausesState).Put,
|
||||
ReadPauses: readOf(stdio.State(delivery.PausesState)),
|
||||
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
|
||||
|
||||
@@ -31,6 +31,9 @@ type Module struct {
|
||||
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
|
||||
Held *buildstep.Puller
|
||||
ReadSteps, ReadToolchainsState StateRead
|
||||
// ReadLog reads the module's own log stream (stdio.Stream(buildstep.LogStreamOf(language)).Read), where each step
|
||||
// says a failed test's whole output (Emit says it); nil in a test.
|
||||
ReadLog buildstep.StreamRead
|
||||
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
|
||||
Pauses buildstep.Put
|
||||
ReadPauses StateRead
|
||||
@@ -148,7 +151,17 @@ func (m *Module) Health(ctx context.Context) health.Answer {
|
||||
if err := m.room(); err != nil {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
return health.Healthy(fmt.Sprintf("%s installed, the version the module builds with (recipe %s)", installed, m.Recipe.Version()))
|
||||
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("%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
|
||||
|
||||
@@ -115,7 +115,7 @@ func TestAGoThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
|
||||
{Name: "short_probe", Run: run},
|
||||
}, m.StepTools()...)
|
||||
for _, tool := range m.Tools(steps) {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == LogReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
|
||||
@@ -2,7 +2,10 @@ package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
"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
|
||||
@@ -29,8 +32,12 @@ func TestAMissingToolSkipsExceptBubblewrapOnABuildNode(t *testing.T) {
|
||||
// The levels a build module judges hand the tests the build node mark, with the siblings they read.
|
||||
func TestEveryLevelHandsTheTestsTheBuildNodeMark(t *testing.T) {
|
||||
m := &Module{Work: t.TempDir()}
|
||||
if env := m.checkEnv("ws1"); !slices.Contains(env, BuildNodeEnv+"=1") {
|
||||
t.Errorf("env %v lacks %s=1", env, BuildNodeEnv)
|
||||
if env := m.checkEnv("ws1", nil); !slices.Contains(env, BuildNodeEnv+"=1") || slices.ContainsFunc(env, func(e string) bool { return strings.HasPrefix(e, delivery.NodeNamesEnv) }) {
|
||||
t.Errorf("env %v lacks %s=1, or names nodes when none were asked", 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,16 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The read tools, named as the module is: build_go_ and the verb.
|
||||
const (
|
||||
StepReadTool = "build_go_step"
|
||||
StepsReadTool = "build_go_steps"
|
||||
StepReadTool = "build_go_step"
|
||||
StepsReadTool = "build_go_steps"
|
||||
// LogReadTool reads one step's log, a failed test's whole output, from the module's own log stream.
|
||||
LogReadTool = "build_go_log"
|
||||
ToolchainsReadTool = "build_go_toolchains"
|
||||
)
|
||||
|
||||
@@ -225,7 +228,14 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
|
||||
|
||||
// ReadTools are the module's read tools. All are read-only.
|
||||
func (m *Module) ReadTools() []stdio.Tool {
|
||||
readLog := m.ReadLog
|
||||
if readLog == nil {
|
||||
readLog = func(string, uint64, int) (*stdio.StreamPage, error) {
|
||||
return nil, fmt.Errorf("this process reads no log stream: it was not launched by the runtime")
|
||||
}
|
||||
}
|
||||
return []stdio.Tool{
|
||||
buildstep.LogTool(LogReadTool, readLog),
|
||||
{Name: StepReadTool,
|
||||
Description: "What one step of this machine ended with, as stored in the module's steps state: its tool, state " +
|
||||
"(running, passed, failed, refused), when it started and ended, why it failed or was refused, and its result " +
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
|
||||
@@ -38,7 +39,9 @@ func (m *Module) testTool() StepTool {
|
||||
"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"},
|
||||
"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,
|
||||
},
|
||||
"required": []string{"workspace", "level"},
|
||||
@@ -78,12 +81,16 @@ func (m *Module) test(ctx context.Context, args map[string]any) (any, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
names, err := nodeNamesArg(args[delivery.NodeNamesArg])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res *TestResult
|
||||
switch {
|
||||
case runner == RunnerWorkspace && (level == LevelUnit || level == LevelIntegration):
|
||||
return m.testAsCandidate(ctx, workspace, level, packages, pattern)
|
||||
return m.testAsCandidate(ctx, workspace, level, packages, pattern, names)
|
||||
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)})
|
||||
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)})
|
||||
case level == LevelIntegration:
|
||||
res, err = m.integration(ctx, workspace, tree, packages, pattern)
|
||||
default:
|
||||
@@ -98,14 +105,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
|
||||
// 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) (*TestResult, error) {
|
||||
spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound()}
|
||||
func (m *Module) testAsCandidate(ctx context.Context, workspace, level string, packages []string, pattern string, names []string) (*TestResult, error) {
|
||||
spec := levelSpec{Step: stepUnit, Workspace: workspace, Packages: packages, Run: pattern, Bound: m.unitBound(), NodeNames: names}
|
||||
if level == LevelIntegration {
|
||||
if _, err := m.checkPostgres(ctx); err != nil {
|
||||
return nil, err // a *buildstep.Refusal: this level only
|
||||
}
|
||||
b := m.integrationBounds()
|
||||
spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run}
|
||||
spec = levelSpec{Step: stepIntegration, Workspace: workspace, Packages: packages, Run: pattern, Setup: b.Setup, Bound: b.Run, NodeNames: names}
|
||||
}
|
||||
a, judged, err := m.asCandidate(ctx, spec)
|
||||
if err != nil {
|
||||
@@ -132,9 +139,35 @@ 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).
|
||||
const BuildNodeEnv = "MESH_BUILD_NODE"
|
||||
|
||||
// checkEnv is what every level hands the tests it runs: the build node mark and the siblings (besideEnv).
|
||||
func (m *Module) checkEnv(workspace string) []string {
|
||||
return append([]string{BuildNodeEnv + "=1"}, m.besideEnv(workspace)...)
|
||||
// checkEnv is what every level hands the tests it runs: the build node mark, the siblings (besideEnv) and, when the
|
||||
// ask carried them, the mesh's node names (delivery.NodeNamesEnv, novox/hq ADR 0567 §12).
|
||||
func (m *Module) checkEnv(workspace string, names []string) []string {
|
||||
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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"capacity": "shanks=3,ace=2,g14=0,novox=0",
|
||||
"capacity": 0,
|
||||
"cores-per-step": 6,
|
||||
"build-hours": ""
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildgo
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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.20261027002948-db1007ffdda1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095131-16bc0dcf3e9d h1:jFOz6wUfZvIayd4DctwlaFKKZHQNME6NLs2OS1rNv30=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095131-16bc0dcf3e9d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc h1:Wdjl2gomooHswJ5qpkwZPq/ureZgXVw6FMCsgzPJhPo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde h1:eKXSllHCS31GgMazfvZ7BeiLoXIUibnUmzxWVkj+Vcw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -25,15 +25,15 @@
|
||||
],
|
||||
"config": {
|
||||
"capacity": {
|
||||
"type": "string",
|
||||
"description": "How many asks of the Go build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
|
||||
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
|
||||
"default": "shanks=3,ace=2,g14=0,novox=0"
|
||||
"type": "number",
|
||||
"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.",
|
||||
"minimum": 0,
|
||||
"default": 0
|
||||
},
|
||||
"build-hours": {
|
||||
"type": "string",
|
||||
"description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
|
||||
"pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
|
||||
"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.",
|
||||
"pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]",
|
||||
"default": ""
|
||||
},
|
||||
"cores-per-step": {
|
||||
@@ -51,6 +51,7 @@
|
||||
"build_go_release",
|
||||
"build_go_step",
|
||||
"build_go_steps",
|
||||
"build_go_log",
|
||||
"build_go_step_end",
|
||||
"build_go_toolchains",
|
||||
"build_go_status",
|
||||
@@ -60,6 +61,12 @@
|
||||
{
|
||||
"name": "toolchains-go",
|
||||
"events": ["toolchain.**"]
|
||||
},
|
||||
{
|
||||
"name": "go-build-logs",
|
||||
"events": ["log.**"],
|
||||
"max-age-days": 14,
|
||||
"max-mib": 2048
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
|
||||
@@ -16,7 +16,14 @@ credential and an environment built from scratch.
|
||||
|
||||
The account's subordinate user and group ranges are not declared anywhere: the node-engine makes every `user` account
|
||||
with `useradd`, which allocates them. The module's health reads `/etc/subuid` and `/etc/subgid` and is unhealthy
|
||||
without a range for the account, without buildah, or when a user namespace cannot be made.
|
||||
when the module cannot run: without a range for the account, without buildah, 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.
|
||||
|
||||
|
||||
@@ -43,13 +43,15 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
var watch func(string, func(stdio.ProvisionChange) error) error
|
||||
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
|
||||
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
|
||||
// And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
|
||||
// And the node's build hours (a node may build only in a window): outside them it takes
|
||||
// nothing new.
|
||||
var cores func() float64
|
||||
var hours func() (*buildstep.BuildWindow, string)
|
||||
settings := ""
|
||||
if capacity == nil {
|
||||
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
|
||||
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
|
||||
capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say)
|
||||
settings = m.Settings
|
||||
watch = buildstep.WatchConfigServed
|
||||
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskImage], m.Say)
|
||||
}
|
||||
@@ -59,7 +61,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
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,
|
||||
Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say,
|
||||
Capacity: capacity, SettingsFile: settings, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Eligible: m.Eligible, Logf: m.Say,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
if err := m.Check(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -71,4 +71,17 @@ func TestAPulledImageAskCopiesThenBuilds(t *testing.T) {
|
||||
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:
|
||||
slots = m.Capacity()
|
||||
case m.Settings != "":
|
||||
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
|
||||
slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)()
|
||||
}
|
||||
return buildstep.NodeLimits(slots)
|
||||
}
|
||||
|
||||
@@ -190,6 +190,7 @@ func moduleWith(t *testing.T, f *fake, states *keptStates) *Module {
|
||||
SubUIDFile: write("subuid", "someone:100000:65536\nmesh-build-image:624288:65536\n"),
|
||||
SubGIDFile: write("subgid", "mesh-build-image:624288:65536\n"),
|
||||
RegistryFile: write("registry", f.address()+"\n"),
|
||||
Tun: write("tun", ""),
|
||||
RegistryWait: 2 * time.Second,
|
||||
RegistryPause: 5 * time.Millisecond,
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -121,6 +123,30 @@ func TestManifestKeepsItsStatesClaimsItsBuildSeatAndDeclaresItsHealthTool(t *tes
|
||||
if strings.TrimSpace(string(tpl)) != "${seat:mesh-artifact-store:reach}" {
|
||||
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.
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
@@ -38,6 +39,8 @@ type Module struct {
|
||||
Work, Store string
|
||||
// SubUIDFile and SubGIDFile are the subordinate id files; /etc/subuid and /etc/subgid when empty.
|
||||
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 string
|
||||
// RemoteBase is where repositories are fetched from, "ssh://mesh-forge/" when empty; FetchOptions are the options of
|
||||
@@ -62,6 +65,10 @@ type Module struct {
|
||||
|
||||
spacesOnce sync.Once
|
||||
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.
|
||||
@@ -90,15 +97,65 @@ func (m *Module) look(ctx context.Context) (Look, error) {
|
||||
if err := m.userNamespace(ctx); err != nil {
|
||||
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
|
||||
}
|
||||
if err := m.tunDevice(); err != nil {
|
||||
return l, ¬Eligible{&buildstep.Refusal{Text: err.Error(), Have: l.Buildah}}
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// Check is the guard: nil when the machine can build, else the refusal.
|
||||
// notEligible is a refusal of a machine that runs the module fine but cannot build here now (no tun device: its asks go
|
||||
// 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 {
|
||||
_, err := m.look(ctx)
|
||||
m.keep(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.
|
||||
func (m *Module) Start(ctx context.Context) {
|
||||
for {
|
||||
@@ -119,20 +176,35 @@ 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).
|
||||
func (m *Module) Health(ctx context.Context) health.Answer {
|
||||
l, err := m.look(ctx)
|
||||
if err != nil {
|
||||
m.keep(err)
|
||||
if err != nil && !isNotEligible(err) {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
if err := m.room(); err != nil {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
return health.Healthy(fmt.Sprintf("buildah %s installed; %s holds %d subordinate uids and %d gids; a user namespace can be made", l.Buildah, l.Account, l.UIDs, l.GIDs))
|
||||
if err != nil {
|
||||
// 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.
|
||||
func (m *Module) Status(ctx context.Context) map[string]any {
|
||||
l, err := m.look(ctx)
|
||||
out := map[string]any{"node": m.Node, "account": m.Account, "buildah": l.Buildah, "subuids": l.UIDs, "subgids": l.GIDs, "refusing": err != nil,
|
||||
"cpu": m.cpuStatus()}
|
||||
"eligible": err == nil, "cpu": m.cpuStatus()}
|
||||
if err != nil {
|
||||
out["why"] = err.Error()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -43,21 +45,88 @@ 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
|
||||
// map, and the health says which file.
|
||||
func TestHealthIsUnhealthyWithoutARangeInEitherFile(t *testing.T) {
|
||||
for file, set := range map[string]func(*Module, string){
|
||||
"subuid": func(m *Module, p string) { m.SubUIDFile = p },
|
||||
"subgid": func(m *Module, p string) { m.SubGIDFile = p },
|
||||
// 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
|
||||
// gone, so tun could not be loaded) buildah's network for a RUN line cannot be made: the health says the node is not
|
||||
// eligible, and the puller judges by 0 so its asks go to a node that can build them.
|
||||
func TestHealthIsUnhealthyWithoutWhatABuildNeeds(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
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)
|
||||
m := moduleWith(t, f, newStates())
|
||||
p := t.TempDir() + "/" + file
|
||||
p := t.TempDir() + "/" + c.name
|
||||
os.WriteFile(p, []byte("someone-else:100000:65536\n"), 0o644)
|
||||
set(m, p)
|
||||
c.set(m, p)
|
||||
a := m.Health(context.Background())
|
||||
if a.Healthy || !strings.Contains(a.Why, p) || !strings.Contains(a.Why, "mesh-build-image") {
|
||||
t.Errorf("%s: health = %+v, want unhealthy naming %s and the account", file, a, p)
|
||||
for _, want := range c.says {
|
||||
if a.Healthy || !strings.Contains(a.Why, want) {
|
||||
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,11 +2,13 @@ package main
|
||||
|
||||
// What the module proves about its own machine before it builds (novox/hq ADR 0487 §2, issue 1703a): buildah runs,
|
||||
// the account holds subordinate user and group ranges (the node-engine's useradd allocates them for every `user`
|
||||
// account, so nothing declares them; this reads them), and a user namespace can be made.
|
||||
// account, so nothing declares them; this reads them), a user namespace can be made, and the tun device opens (buildah
|
||||
// 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 (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -18,6 +20,9 @@ import (
|
||||
// ProbeTimeout bounds each probe: they read the machine and fetch nothing.
|
||||
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.
|
||||
const (
|
||||
SubUIDFile = "/etc/subuid"
|
||||
@@ -133,3 +138,23 @@ func (m *Module) ensureRuntimeDir() error {
|
||||
}
|
||||
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 }},
|
||||
stdio.Tool{Name: HealthTool,
|
||||
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. Read-only.",
|
||||
"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.",
|
||||
Run: func(map[string]any) (any, error) { return m.Health(m.Ctx), nil }},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"capacity": "shanks=3,ace=2,g14=0,novox=0",
|
||||
"capacity": 0,
|
||||
"cores-per-step": 2,
|
||||
"build-hours": ""
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildimage
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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.20261027002948-db1007ffdda1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
],
|
||||
"config": {
|
||||
"capacity": {
|
||||
"type": "string",
|
||||
"description": "How many asks of the image build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
|
||||
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
|
||||
"default": "shanks=3,ace=2,g14=0,novox=0"
|
||||
"type": "number",
|
||||
"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.",
|
||||
"minimum": 0,
|
||||
"default": 0
|
||||
},
|
||||
"build-hours": {
|
||||
"type": "string",
|
||||
"description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
|
||||
"pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
|
||||
"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.",
|
||||
"pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]",
|
||||
"default": ""
|
||||
},
|
||||
"cores-per-step": {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/mesh-build-node
|
||||
@@ -37,6 +37,9 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps {
|
||||
},
|
||||
Release: func(ws string) error { return m.spaces().Release(ws) },
|
||||
Logf: m.Say,
|
||||
// A failed test's whole output is said on the module's own log stream (buildstep.LogTool reads it back).
|
||||
Narrate: m.Emit,
|
||||
Module: "mesh-build-node",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,13 +54,15 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
var watch func(string, func(stdio.ProvisionChange) error) error
|
||||
// And the CPU bounds it (issue 1806's follow-up): the configured capacity is an upper bound, the node's build slice
|
||||
// gives floor(its quota's cores / the module's cores-per-step) at once, read live; a test judges by its own Capacity.
|
||||
// And the node's build hours (the operator, 2026-10-26: g14 builds only from 18:00 to 06:00): outside them it takes
|
||||
// And the node's build hours (a node may build only in a window): outside them it takes
|
||||
// nothing new.
|
||||
var cores func() float64
|
||||
var hours func() (*buildstep.BuildWindow, string)
|
||||
settings := ""
|
||||
if capacity == nil {
|
||||
hours = buildstep.NodeBuildHours(m.Settings, m.Node, m.Say)
|
||||
capacity = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, m.Say)
|
||||
capacity = buildstep.NodeCapacity(m.Settings, m.Node, m.Say)
|
||||
settings = m.Settings
|
||||
watch = buildstep.WatchConfigServed
|
||||
cores = buildstep.NodeCoresPerStep(m.Settings, buildstep.DefaultCoresPerStep[delivery.AskNode], m.Say)
|
||||
}
|
||||
@@ -67,7 +72,7 @@ func (m *Module) Puller(steps []StepTool, source buildstep.AskSource) *buildstep
|
||||
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,
|
||||
Capacity: capacity, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say,
|
||||
Capacity: capacity, SettingsFile: settings, WatchConfig: watch, CoresPerStep: cores, BuildHours: hours, Logf: m.Say,
|
||||
Run: func(ctx context.Context, a delivery.StepAsk) (any, error) {
|
||||
if err := m.Prepare(ctx); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -84,7 +85,8 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) {
|
||||
raw, _ := os.ReadFile(moduleDir + "/module.json")
|
||||
var doc struct {
|
||||
Config map[string]struct {
|
||||
Default json.RawMessage `json:"default"`
|
||||
Default json.RawMessage `json:"default"`
|
||||
Pattern, Description string
|
||||
} `json:"config"`
|
||||
}
|
||||
_ = json.Unmarshal(raw, &doc)
|
||||
@@ -102,10 +104,20 @@ func TestManifestTakesTheBuildCapacity(t *testing.T) {
|
||||
if w, fault := buildstep.NodeBuildHours(moduleDir+"/files/settings.json", "g14", t.Errorf)(); w != nil || fault != "" {
|
||||
t.Errorf("the settings template holds build hours %v (%s)", w, fault)
|
||||
}
|
||||
var capacity string
|
||||
// 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[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)
|
||||
if capacity != buildstep.DefaultCapacities {
|
||||
t.Errorf("the capacity's default = %q", capacity)
|
||||
if capacity != 0 {
|
||||
t.Errorf("the capacity's default = %g, want 0", capacity)
|
||||
}
|
||||
if resource(t, m, "settings")["path"] != "${dir:state}/settings.json" {
|
||||
t.Error("no settings file")
|
||||
|
||||
@@ -20,7 +20,7 @@ func (m *Module) cpuLimits() buildcpu.Limits {
|
||||
case m.Capacity != nil:
|
||||
slots = m.Capacity()
|
||||
case m.Settings != "":
|
||||
slots = buildstep.NodeCapacity(m.Settings, buildstep.DefaultCapacities, m.Node, nil)()
|
||||
slots = buildstep.NodeCapacity(m.Settings, m.Node, nil)()
|
||||
}
|
||||
return buildstep.NodeLimits(slots)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -47,9 +48,14 @@ func TestTheManifestDeclaresTheToolchainsStream(t *testing.T) {
|
||||
Streams []struct {
|
||||
Name string `json:"name"`
|
||||
Events []string `json:"events"`
|
||||
Age int `json:"max-age-days"`
|
||||
MiB int `json:"max-mib"`
|
||||
} `json:"streams"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 1 || m.Streams[0].Name != "toolchains-node" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) {
|
||||
// And its log stream: a failed test's whole output, kept 14 days and at most 2 GiB, dropping its oldest (the operator,
|
||||
// 2026-10-30).
|
||||
if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 2 || m.Streams[0].Name != "toolchains-node" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) ||
|
||||
m.Streams[1].Name != buildstep.LogStreamOf("node") || !slices.Equal(m.Streams[1].Events, []string{"log.**"}) || m.Streams[1].Age != 14 || m.Streams[1].MiB != 2048 {
|
||||
t.Errorf("streams = %+v (%v)", m.Streams, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
)
|
||||
@@ -47,6 +48,7 @@ func main() {
|
||||
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
|
||||
Steps: stdio.State(delivery.StepsState).Put,
|
||||
ReadSteps: readOf(stdio.State(delivery.StepsState)),
|
||||
ReadLog: stdio.Stream(buildstep.LogStreamOf("node")).Read,
|
||||
Pauses: stdio.State(delivery.PausesState).Put,
|
||||
ReadPauses: readOf(stdio.State(delivery.PausesState)),
|
||||
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
|
||||
|
||||
@@ -45,6 +45,9 @@ type Module struct {
|
||||
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
|
||||
Held *buildstep.Puller
|
||||
ReadSteps, ReadToolchainsState StateRead
|
||||
// ReadLog reads the module's own log stream (stdio.Stream(buildstep.LogStreamOf(language)).Read), where each step
|
||||
// says a failed test's whole output (Emit says it); nil in a test.
|
||||
ReadLog buildstep.StreamRead
|
||||
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
|
||||
Pauses buildstep.Put
|
||||
ReadPauses StateRead
|
||||
@@ -159,7 +162,17 @@ func (m *Module) Health(ctx context.Context) health.Answer {
|
||||
if err := m.room(); err != nil {
|
||||
return health.Unhealthy(err.Error())
|
||||
}
|
||||
return health.Healthy(fmt.Sprintf("node %s installed, the version the module builds with (recipe %s)", installed, m.recipe().Version()))
|
||||
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("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
|
||||
|
||||
@@ -117,7 +117,7 @@ func TestANodeThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T)
|
||||
{Name: "short_probe", Run: run},
|
||||
}, (&Module{}).StepTools()...)
|
||||
for _, tool := range m.Tools(steps) {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
|
||||
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == LogReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
|
||||
continue
|
||||
}
|
||||
var reason string
|
||||
|
||||
@@ -15,13 +15,16 @@ import (
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// The read tools, named as the module is: build_node_ and the verb.
|
||||
const (
|
||||
StepReadTool = "build_node_step"
|
||||
StepsReadTool = "build_node_steps"
|
||||
StepReadTool = "build_node_step"
|
||||
StepsReadTool = "build_node_steps"
|
||||
// LogReadTool reads one step's log, a failed test's whole output, from the module's own log stream.
|
||||
LogReadTool = "build_node_log"
|
||||
ToolchainsReadTool = "build_node_toolchains"
|
||||
)
|
||||
|
||||
@@ -225,7 +228,14 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
|
||||
|
||||
// ReadTools are the module's read tools. All are read-only.
|
||||
func (m *Module) ReadTools() []stdio.Tool {
|
||||
readLog := m.ReadLog
|
||||
if readLog == nil {
|
||||
readLog = func(string, uint64, int) (*stdio.StreamPage, error) {
|
||||
return nil, fmt.Errorf("this process reads no log stream: it was not launched by the runtime")
|
||||
}
|
||||
}
|
||||
return []stdio.Tool{
|
||||
buildstep.LogTool(LogReadTool, readLog),
|
||||
{Name: StepReadTool,
|
||||
Description: "What one step of this machine ended with, as stored in the module's steps state: its tool, state " +
|
||||
"(running, passed, failed, refused), when it started and ended, why it failed or was refused, and its result " +
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"capacity": "shanks=3,ace=2,g14=0,novox=0",
|
||||
"capacity": 0,
|
||||
"cores-per-step": 2,
|
||||
"build-hours": ""
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ module meshbuildnode
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027002948-db1007ffdda1
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
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.20261027002948-db1007ffdda1/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095131-16bc0dcf3e9d h1:jFOz6wUfZvIayd4DctwlaFKKZHQNME6NLs2OS1rNv30=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095131-16bc0dcf3e9d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc h1:Wdjl2gomooHswJ5qpkwZPq/ureZgXVw6FMCsgzPJhPo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde h1:eKXSllHCS31GgMazfvZ7BeiLoXIUibnUmzxWVkj+Vcw=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
|
||||
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
|
||||
|
||||
@@ -21,15 +21,15 @@
|
||||
],
|
||||
"config": {
|
||||
"capacity": {
|
||||
"type": "string",
|
||||
"description": "How many asks of the Node build seat this module runs at once on each node, as node=asks pairs separated by commas, such as shanks=3,ace=2 (novox/hq ADR 0525 §5); a node set to 0 pulls nothing, a node not named runs one at a time. The control-node and the laptop build nothing.",
|
||||
"pattern": "|[a-z0-9_-]+=[0-9]+(,[a-z0-9_-]+=[0-9]+)*",
|
||||
"default": "shanks=3,ace=2,g14=0,novox=0"
|
||||
"type": "number",
|
||||
"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.",
|
||||
"minimum": 0,
|
||||
"default": 0
|
||||
},
|
||||
"build-hours": {
|
||||
"type": "string",
|
||||
"description": "When each node may take this module's build asks, as node=HH:MM-HH:MM pairs in the node's local time, separated by commas, such as g14=18:00-06:00 (an end before its start crosses midnight). Outside its window a node takes no new step and finishes the ones it runs; a node not named, or an empty value, builds at any hour. The operator, 2026-10-26: g14 builds only outside office hours.",
|
||||
"pattern": "|[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9](,[a-z0-9_-]+=([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9])*",
|
||||
"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.",
|
||||
"pattern": "|([01][0-9]|2[0-3]):[0-5][0-9]-([01][0-9]|2[0-3]):[0-5][0-9]",
|
||||
"default": ""
|
||||
},
|
||||
"cores-per-step": {
|
||||
@@ -46,6 +46,7 @@
|
||||
"build_node_release",
|
||||
"build_node_step",
|
||||
"build_node_steps",
|
||||
"build_node_log",
|
||||
"build_node_step_end",
|
||||
"build_node_toolchains",
|
||||
"build_node_status",
|
||||
@@ -57,6 +58,12 @@
|
||||
"events": [
|
||||
"toolchain.**"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "node-build-logs",
|
||||
"events": ["log.**"],
|
||||
"max-age-days": 14,
|
||||
"max-mib": 2048
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
|
||||
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS module_version (
|
||||
PRIMARY KEY (module, commit_sha)
|
||||
);
|
||||
|
||||
-- What the catalogue considers this module to be now. Separate from module_version because a
|
||||
-- What the catalog 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
|
||||
-- a consequence of being newest.
|
||||
CREATE TABLE IF NOT EXISTS module_current (
|
||||
@@ -144,7 +144,7 @@ export class Graph {
|
||||
const url = env["DATABASE_URL"] ?? readMaybe(env["DATABASE_URL_FILE"]);
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
"no DATABASE_URL: the catalogue holds the module graph and cannot hold it in memory, " +
|
||||
"no DATABASE_URL: the catalog holds the module graph and cannot hold it in memory, " +
|
||||
"because a graph that disappears on restart is not a record of anything",
|
||||
);
|
||||
}
|
||||
@@ -246,7 +246,7 @@ export class Graph {
|
||||
}
|
||||
}
|
||||
|
||||
/** Every module the catalogue holds, with the commit it considers current. */
|
||||
/** Every module the catalog holds, with the commit it considers current. */
|
||||
async modules(): Promise<{ module: string; commit: string; repository: string; path: string }[]> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT c.module, c.commit_sha, v.repository, v.path
|
||||
|
||||
@@ -12,7 +12,7 @@ export function getCatalogueTools(graph: Graph): ToolDefinition[] {
|
||||
{
|
||||
name: "catalog_modules",
|
||||
description:
|
||||
"Every module this mesh holds, with the commit the catalogue considers current and where it came from.",
|
||||
"Every module this mesh holds, with the commit the catalog considers current and where it came from.",
|
||||
input: {},
|
||||
run: async () => ({ modules: await graph.modules() }),
|
||||
},
|
||||
@@ -28,7 +28,7 @@ export function getCatalogueTools(graph: Graph): ToolDefinition[] {
|
||||
const module = String(args.module ?? "");
|
||||
if (!module) return { error: "name a module" };
|
||||
const found = await graph.show(module, args.commit ? String(args.commit) : undefined);
|
||||
return found ? { module: found } : { error: `the catalogue holds no ${module}` };
|
||||
return found ? { module: found } : { error: `the catalog holds no ${module}` };
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -21,11 +21,16 @@ account alone writes its buckets.
|
||||
|
||||
## How a value resolves
|
||||
|
||||
From the controller's mirrors of the assignments and of each module's declarations (`mesh-controller.assignments`,
|
||||
`mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back
|
||||
For every module the seat mesh-placement places on a node (`mesh-delivery-assigner.placement`), from the module's
|
||||
own manifests as the delivery built them, never from the controller (issue 1953): the trunk's, which the indexer's
|
||||
`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
|
||||
naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at
|
||||
start, whenever a mirror moves, and every five minutes, writing only the values that moved.
|
||||
start, whenever a placement, a want or a trunk module moves, and every five minutes, writing only the values that moved.
|
||||
|
||||
## The verb
|
||||
|
||||
|
||||
@@ -97,6 +97,10 @@ type Config struct {
|
||||
RouterWithin, RouterEvery time.Duration
|
||||
endedMu sync.Mutex
|
||||
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.
|
||||
@@ -184,7 +188,7 @@ func (call Call) level() (Level, error) {
|
||||
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 "+
|
||||
"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 catalogue", call.Module)
|
||||
"predefined key %s's value falls back to, or change its default in the catalog", call.Module)
|
||||
case call.Node != "":
|
||||
return Level{Kind: LevelNode, Node: call.Node}, nil
|
||||
}
|
||||
@@ -336,8 +340,8 @@ func (c *Config) declared(s Sources, level Level, key string) (rule, bool, error
|
||||
joinKeys(decl.Config))
|
||||
}
|
||||
if !assigned(s, level.Node, level.Module) {
|
||||
return rule{}, false, fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node it "+
|
||||
"is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
|
||||
return rule{}, false, fmt.Errorf("%s is not placed on %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,
|
||||
strings.Join(nodesOf(s, level.Module), ", "))
|
||||
}
|
||||
trusted := d.Trusted
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
"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,
|
||||
@@ -120,9 +122,81 @@ func (m *memStore) mirror(t *testing.T, read, key, raw string) {
|
||||
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) {
|
||||
m.mirror(t, AssignmentsRead, node+"."+module,
|
||||
fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module))
|
||||
t.Helper()
|
||||
m.place(t, node, module, true)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -154,10 +228,10 @@ const theList = `{"keys":{"agent-managed-settings":{"type":"object","description
|
||||
func aMesh(t *testing.T) (*Config, *memStore) {
|
||||
t.Helper()
|
||||
m := newStore()
|
||||
m.mirror(t, DeclaredConfigRead, "dunst", dunstDeclared)
|
||||
m.mirror(t, DeclaredConfigRead, "claude-code", `{"module":"claude-code","settings":{
|
||||
m.declare(t, "dunst", dunstDeclared)
|
||||
m.declare(t, "claude-code", `{"module":"claude-code","settings":{
|
||||
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`)
|
||||
m.mirror(t, DeclaredConfigRead, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
|
||||
m.declare(t, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
|
||||
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList)
|
||||
for _, node := range []string{"g14", "novox"} {
|
||||
m.assign(t, node, "dunst")
|
||||
@@ -298,8 +372,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 module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"},
|
||||
"no module value for the whole mesh"},
|
||||
{"a module on a node it is not assigned to", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
|
||||
"it is assigned to: g14, novox"},
|
||||
{"a module on a node it is not placed on", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
|
||||
"it is placed on: g14, novox"},
|
||||
{"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 below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"},
|
||||
@@ -472,7 +546,7 @@ func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
// **An assignment, a declaration or its removal moves the resolved values**, written only where they moved, and
|
||||
// **A placement, 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.
|
||||
func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
@@ -489,7 +563,7 @@ func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing
|
||||
if len(m.events) != 1 || m.events[0].Action != "resolve" {
|
||||
t.Errorf("an assignment raised %+v", m.events)
|
||||
}
|
||||
_ = m.Delete(AssignmentsRead, "ace.dunst")
|
||||
m.unassign(t, "ace", "dunst")
|
||||
if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
|
||||
t.Errorf("an unassignment moved %+v", r.Moved)
|
||||
}
|
||||
@@ -552,42 +626,110 @@ func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1098: a value of the assignments bucket with `ended` set, or with a `state`, is not an assignment: it
|
||||
// resolves nothing and takes the module's resolved values away, as a removed key does.
|
||||
func TestAnEndedAssignmentIsNotAnAssignment(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
m.assign(t, "ace", "dunst")
|
||||
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
|
||||
t.Fatalf("the assignment moved %+v (%v)", r.Moved, err)
|
||||
// despiegel is de-spiegel's manifest as its own repository declares it (novox/de-spiegel at e25e1beb, its config
|
||||
// alone): required trusted values, no default.
|
||||
const despiegel = `{"module":"de-spiegel","version":"1","requires":["route","smtp"],"config":{
|
||||
"praktijk-name":{"type":"string","description":"the practice's public name","trusted":true,"required":true},
|
||||
"praktijk-www-name":{"type":"string","description":"its www form","trusted":true,"required":true}}}`
|
||||
|
||||
// Issue 1953: **each placed module's declarations come from its own manifests as built**, the trunk's at the indexer
|
||||
// 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 _, ended := range []string{`"ended":"2026-10-18T01:00:00Z"`, `"state":"pending"`} {
|
||||
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+ended+`}`)
|
||||
r, err := c.Reconcile()
|
||||
if err != nil || len(r.Moved) != 4 || !r.Moved[0].Gone {
|
||||
t.Errorf("%s moved %+v (%v)", ended, r.Moved, err)
|
||||
}
|
||||
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil {
|
||||
t.Errorf("%s: the module keeps its resolved values", ended)
|
||||
}
|
||||
m.assign(t, "ace", "dunst")
|
||||
if r, _ = c.Reconcile(); len(r.Moved) != 4 {
|
||||
t.Fatalf("the assignment standing again moved %+v", r.Moved)
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m := newStore()
|
||||
tc.given(t, m)
|
||||
var said []string
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for k, want := range tc.resolved {
|
||||
module, key, _ := strings.Cut(k, ".")
|
||||
r := m.resolved(t, "novox", module, key)
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1098: the fields the controller adds to an assignment change nothing, under the same key.
|
||||
func TestAnAssignmentCarriesBuildCommitAndRolloutWithoutChangingItsKey(t *testing.T) {
|
||||
c, m := aMesh(t)
|
||||
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)
|
||||
func must(t *testing.T, err error) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations, the list of predefined
|
||||
// keys, and the assignments, as the controller mirrors them on the bus (design 57 F3, K3).
|
||||
// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations as its manifests declare
|
||||
// them, the list of predefined keys, and where each module is placed (issue 1953).
|
||||
|
||||
// The types a config value may have (ADR 0348 §4).
|
||||
const (
|
||||
@@ -69,7 +69,34 @@ type ModuleDeclarations struct {
|
||||
Config map[string]Declaration `json:"settings"`
|
||||
}
|
||||
|
||||
// ParseModuleDeclarations reads one key of the declared-config mirror.
|
||||
// ManifestDeclarations is the config a manifest declares (ADR 0348 §1, §4): its `config`, or for a module not yet
|
||||
// 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) {
|
||||
var m ModuleDeclarations
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
@@ -121,24 +148,10 @@ type Published struct {
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// 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).
|
||||
// Assignment is a module placed on a node, as the seat mesh-placement's key `placement.<node>` names it.
|
||||
type Assignment struct {
|
||||
Node string `json:"node"`
|
||||
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 != `""`
|
||||
Node string `json:"node"`
|
||||
Module string `json:"module"`
|
||||
}
|
||||
|
||||
// rule is the validation a value is held to, from a module's declaration or a predefined key.
|
||||
|
||||
@@ -17,6 +17,8 @@ type Listed struct {
|
||||
Predefined map[string]Published `json:"predefined,omitempty"`
|
||||
// Withheld is why the newest list of predefined keys is not published, when it is not.
|
||||
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 []HeldValue `json:"held,omitempty"`
|
||||
}
|
||||
@@ -42,12 +44,18 @@ func (c *Config) List(call Call) (*Listed, error) {
|
||||
if err != nil {
|
||||
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 _, ok := s.Declared[call.Module]; !ok {
|
||||
return nil, fmt.Errorf("%s is not a registered module, so it declares no config", call.Module)
|
||||
if _, ok := s.Declared[call.Module]; !ok && len(out.Unresolved) == 0 {
|
||||
return nil, fmt.Errorf("%s is placed on no node with a manifest the indexer holds, so it declares no "+
|
||||
"config", call.Module)
|
||||
}
|
||||
}
|
||||
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
|
||||
resolved := Resolve(s)
|
||||
for _, k := range sortedKeys(resolved) {
|
||||
r := resolved[k]
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
// 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,
|
||||
// asked under this module's own name, design 57 K4).
|
||||
// It resolves from the controller's mirrors of the assignments, the modules' declarations and the list it kept
|
||||
// (design 57 F3, K3). stdout is the MCP channel; what this module says, it says on stderr.
|
||||
// It resolves from where the seat mesh-placement places each module, and each module's declarations as its own
|
||||
// manifests declare them, the trunk's and its wanted build's (the indexer's and the builder's state, issue 1953); the
|
||||
// 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
|
||||
|
||||
import (
|
||||
@@ -115,7 +116,9 @@ func resolving(c *Config) {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} {
|
||||
// A build's manifest is written before a want names the build, so a want, a trunk module or a placement moving is
|
||||
// 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) {
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
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.
|
||||
func TestAnOwnersListOfNamesWithDigitsIsProposed(t *testing.T) {
|
||||
c, m, r := aProposingMesh(t)
|
||||
m.mirror(t, DeclaredConfigRead, "mesh-issues", `{"module":"mesh-issues","settings":{
|
||||
m.declare(t, "mesh-issues", `{"module":"mesh-issues","settings":{
|
||||
"owners":{"kind":"","default":[],"why":"","type":"list","description":"owners","trusted":true}}}`)
|
||||
m.assign(t, "novox", "mesh-issues")
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "owners-1439.json"))
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
if !assigned(s, level.Node, level.Module) {
|
||||
return rule{}, PullDecl{}, "", fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node "+
|
||||
"it is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
|
||||
return rule{}, PullDecl{}, "", fmt.Errorf("%s is not placed on %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,
|
||||
strings.Join(nodesOf(s, level.Module), ", "))
|
||||
}
|
||||
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) {
|
||||
t.Helper()
|
||||
c, m, r := aProposingMesh(t)
|
||||
m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{}}`)
|
||||
m.declare(t, "n8n", `{"module":"n8n","settings":{}}`)
|
||||
m.assign(t, "ace", "n8n")
|
||||
f := &standForge{state: map[string][2]bool{}}
|
||||
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
|
||||
// node level, for a clear, for an undeclared key, and for a module not assigned to the node; and trunk-only
|
||||
// node level, for a clear, for an undeclared key, and for a module not placed on the node; and trunk-only
|
||||
// proposals are judged as before.
|
||||
func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.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 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"},
|
||||
"another node": {func(c *Call) { c.Node = "g14" }, "is not assigned to g14"},
|
||||
"another node": {func(c *Call) { c.Node = "g14" }, "is not placed on g14"},
|
||||
"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"},
|
||||
} {
|
||||
@@ -342,7 +342,7 @@ func TestTheMergeAppliesAHeldValueAndItResolvesOnceTheTrunkDeclaresTheKey(t *tes
|
||||
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "n8n", "media-gid")); e != nil {
|
||||
t.Errorf("resolved before the trunk declares the key: %s", e.Value)
|
||||
}
|
||||
m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+
|
||||
m.declare(t, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+
|
||||
`"type":"number","required":true,"minimum":1,"maximum":65535}}}`)
|
||||
if _, err := c.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"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,
|
||||
@@ -20,12 +22,21 @@ const (
|
||||
PredefinedState = "predefined"
|
||||
ProposalsState = "proposals"
|
||||
|
||||
AssignmentsRead = "mesh-controller.assignments"
|
||||
// PlacementRead is the indexer's placement, read from the catalogue at each merge (novox/hq ADR 0549 §2, §3): each
|
||||
// node's key `placement.<node>` with the modules placed there.
|
||||
PlacementRead = "mesh-delivery-indexer.placement"
|
||||
DeclaredConfigRead = "mesh-controller.declared-config"
|
||||
PredefinedRead = "mesh-controller.predefined"
|
||||
// PlacementRead is the seat mesh-placement's state, held by the assigner (novox/hq ADR 0567 §1, §8): each
|
||||
// node's key `placement.<node>` with the modules placed there. It is the one source of which module runs where
|
||||
// (issue 1953): the controller's assignments are no longer read.
|
||||
PlacementRead = delivery.PlacementRead
|
||||
// WantsRead is the assigner's wants, `want.<node>.<module>`: the build each node is to run (ADR 0539 §1).
|
||||
WantsRead = delivery.AssignerModule + ".wants"
|
||||
// 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 = "history"
|
||||
@@ -123,24 +134,59 @@ type Resolved struct {
|
||||
// ResolvedKey is a resolved value's key in mesh-config_resolved.
|
||||
func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key }
|
||||
|
||||
// Sources is everything a resolution reads: the assignments, every module's declarations, the published list of
|
||||
// predefined keys, and every value set.
|
||||
// Sources is everything a resolution reads: where each module is placed, every module's declarations, the published
|
||||
// list of predefined keys, and every value set.
|
||||
type Sources struct {
|
||||
Assignments []Assignment
|
||||
Declared map[string]ModuleDeclarations
|
||||
Predefined map[string]Published
|
||||
Values map[string]Value
|
||||
// Declared is each module's declarations as its manifests declare them: the trunk's, with every key only a wanted
|
||||
// build still declares (declarationsOf). It is what a set is judged against.
|
||||
Declared map[string]ModuleDeclarations
|
||||
// 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
|
||||
// for them (pulls.go); neither is resolved.
|
||||
Pulls map[string]PullDecl
|
||||
Kept map[string]KeptValue
|
||||
}
|
||||
|
||||
// Resolve is every assigned module's every declared value on its node, keyed as mesh-config_resolved is.
|
||||
// Unresolved is a module placed on a node whose values are not resolved, and why.
|
||||
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 {
|
||||
out := map[string]Resolved{}
|
||||
for _, a := range s.Assignments {
|
||||
decl, ok := s.Declared[a.Module]
|
||||
decl, ok := s.declarationsOn(a.Node, a.Module)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
@@ -196,7 +242,7 @@ type Moved struct {
|
||||
From json.RawMessage `json:"from,omitempty"`
|
||||
To json.RawMessage `json:"to,omitempty"`
|
||||
Source string `json:"source"`
|
||||
// Gone says the module no longer takes the key there: unassigned, or no longer declared.
|
||||
// Gone says the module no longer takes the key there: no longer placed, or no longer declared.
|
||||
Gone bool `json:"gone,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
@@ -4,73 +4,30 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/delivery"
|
||||
)
|
||||
|
||||
// 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.
|
||||
// sources reads everything a resolution reads, from the bus as it is now: which modules are placed where from the
|
||||
// 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) {
|
||||
s := Sources{Declared: map[string]ModuleDeclarations{}, Predefined: map[string]Published{},
|
||||
Values: map[string]Value{}}
|
||||
s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{},
|
||||
Predefined: map[string]Published{}, Values: map[string]Value{}}
|
||||
var problems []string
|
||||
if err := c.each(AssignmentsRead, func(key string, raw []byte) error {
|
||||
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.readPlacement(&s); err != nil {
|
||||
return s, fmt.Errorf("the seat mesh-placement's placement cannot be read: %w", err)
|
||||
}
|
||||
// And every module the catalogue's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
|
||||
// sources stand (ADR 0549 §5), so a pair either names is resolved, and none loses its values while the two differ.
|
||||
// Placement that cannot be read yet (the indexer's state not on the bus) leaves the assignments alone, said.
|
||||
standing := map[string]bool{}
|
||||
for _, a := range s.Assignments {
|
||||
standing[a.Node+"."+a.Module] = true
|
||||
}
|
||||
if err := c.each(PlacementRead, func(key string, raw []byte) error {
|
||||
node, ok := strings.CutPrefix(key, "placement.")
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
var p struct {
|
||||
Node string `json:"node"`
|
||||
Modules []struct {
|
||||
Module string `json:"module"`
|
||||
} `json:"modules"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
|
||||
problems = append(problems, fmt.Sprintf("the placement %s is not one", key))
|
||||
return nil
|
||||
}
|
||||
for _, m := range p.Modules {
|
||||
if m.Module != "" && !standing[node+"."+m.Module] {
|
||||
standing[node+"."+m.Module] = true
|
||||
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("the catalogue's placement cannot be read, so the assignments alone are resolved: %v", err))
|
||||
}
|
||||
if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error {
|
||||
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 {
|
||||
if err := c.readDeclarations(&s); err != nil {
|
||||
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 {
|
||||
var p Published
|
||||
if err := json.Unmarshal(raw, &p); err != nil {
|
||||
@@ -95,11 +52,222 @@ func (c *Config) sources() (Sources, error) {
|
||||
return s, err
|
||||
}
|
||||
for _, p := range problems {
|
||||
c.logf("[mesh-config] a module's declarations are not read, so its values are not resolved: %s", p)
|
||||
c.logf("[mesh-config] a placed module's declarations are not read, so its values are not resolved (they "+
|
||||
"stay as last resolved): %s", p)
|
||||
}
|
||||
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.
|
||||
func (c *Config) each(state string, visit func(key string, raw []byte) error) error {
|
||||
keys, err := c.Store.Keys(state)
|
||||
@@ -145,6 +313,13 @@ func (c *Config) writeResolved(s Sources) ([]Moved, error) {
|
||||
return nil, fmt.Errorf("the resolved values cannot be read: %w", err)
|
||||
}
|
||||
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)
|
||||
var problems []error
|
||||
for _, m := range moved {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user