Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de2de44c98 | ||
|
|
1989dd7cf2 | ||
|
|
31c860bc92 | ||
|
|
158348ede7 | ||
|
|
ff4c6c0435 | ||
|
|
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 |
@@ -36,7 +36,6 @@ 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
|
||||
|
||||
@@ -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).
|
||||
@@ -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:
|
||||
|
||||
@@ -569,6 +569,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",
|
||||
|
||||
+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 catalog'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));
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -87,13 +87,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 +108,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
|
||||
|
||||
@@ -296,6 +296,7 @@ func (m *Module) goEnv() []string {
|
||||
modcache = m.GoModCache
|
||||
}
|
||||
return append([]string{
|
||||
"CGO_ENABLED=0", // as a production build compiles (build.go), so a check tests what ships and the warm cache matches (ADR 0542 §1)
|
||||
"GOTOOLCHAIN=local",
|
||||
"GOFLAGS=-mod=mod",
|
||||
"GOMODCACHE=" + modcache,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
@@ -62,30 +63,73 @@ func (m *Module) integrationHere(ctx context.Context, workspace, tree string, pa
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
postgres, stopStore, err := m.startStore(ctx, run)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the integration level's store: %w", err)
|
||||
}
|
||||
defer stopStore()
|
||||
nats, stopBus, err := m.startBus(ctx, filepath.Join(run, "nats"))
|
||||
if err != nil {
|
||||
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)...)
|
||||
// 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.
|
||||
byDir, err := packagesByDir(tree, dirs, packages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The store, the bus and the compile start together, and the directories compile side by side up to the bound
|
||||
// (issue 1921, try speed S5): the setup is the longest of them, not their sum. The compile runs no test binary
|
||||
// (-exec true), so it needs neither server and is given nothing a test reads; whether the tests compile is for the
|
||||
// run to say, its failure is not read here, and the run below finds them built.
|
||||
var (
|
||||
wg sync.WaitGroup
|
||||
postgres, nats string
|
||||
stopStore, stopBus func()
|
||||
storeErr, busErr error
|
||||
crash any
|
||||
crashMu sync.Mutex
|
||||
)
|
||||
guard := func() {
|
||||
if p := recover(); p != nil {
|
||||
crashMu.Lock()
|
||||
crash = p
|
||||
crashMu.Unlock()
|
||||
}
|
||||
}
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer guard()
|
||||
postgres, stopStore, storeErr = m.startStore(ctx, run)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer guard()
|
||||
nats, stopBus, busErr = m.startBus(ctx, filepath.Join(run, "nats"))
|
||||
}()
|
||||
slots := make(chan struct{}, m.parallel())
|
||||
for _, d := range dirs {
|
||||
if len(byDir[d]) == 0 {
|
||||
continue
|
||||
}
|
||||
m.Run(ctx, append(m.goEnvFor(filepath.Join(tree, d)), env...), "go", append([]string{"-C", filepath.Join(tree, d), "test", "-tags", strings.Join(integrationTags, ","), "-run", "^$", "-count=1"}, byDir[d]...)...)
|
||||
wg.Add(1)
|
||||
slots <- struct{}{}
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
defer func() { <-slots }()
|
||||
defer guard()
|
||||
m.Run(ctx, m.goEnvFor(filepath.Join(tree, d)), "go", append([]string{"-C", filepath.Join(tree, d), "test", "-tags", strings.Join(integrationTags, ","), "-exec", "true", "-count=1"}, byDir[d]...)...)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
// Whatever started is stopped however the level ends, a half-started store included.
|
||||
if stopStore != nil {
|
||||
defer stopStore()
|
||||
}
|
||||
if stopBus != nil {
|
||||
defer stopBus()
|
||||
}
|
||||
if crash != nil {
|
||||
panic(crash)
|
||||
}
|
||||
if storeErr != nil {
|
||||
return nil, fmt.Errorf("the integration level's store: %w", storeErr)
|
||||
}
|
||||
if busErr != nil {
|
||||
return nil, fmt.Errorf("the integration level's bus: %w", busErr)
|
||||
}
|
||||
env := append([]string{"MESH_TEST_POSTGRES=" + postgres, "MESH_TEST_NATS=" + nats}, m.checkEnv(workspace, nil)...)
|
||||
setup := m.clock().Sub(began)
|
||||
|
||||
return m.runLevel(ctx, tree, levelOpts{
|
||||
|
||||
@@ -35,6 +35,9 @@ type integFake struct {
|
||||
// command runs, with its line.
|
||||
gomods string
|
||||
onTest func(line string)
|
||||
// onCompile is called, unlocked, as each compile of the setup runs, and onStart as the store starts (issue 1921, S5).
|
||||
onCompile func(line string, env []string)
|
||||
onStart func()
|
||||
}
|
||||
|
||||
func newIntegFake(t *testing.T, captured string) *integFake {
|
||||
@@ -71,7 +74,10 @@ func (f *integFake) run(ctx context.Context, env []string, name string, args ...
|
||||
return forgekey.Ran{Stdout: f.gomods}
|
||||
}
|
||||
return forgekey.Ran{Stdout: "go.mod\n"}
|
||||
case name == "go" && strings.Contains(line, "-run ^$"):
|
||||
case name == "go" && strings.Contains(line, "-exec true"):
|
||||
if f.onCompile != nil {
|
||||
f.onCompile(line, env)
|
||||
}
|
||||
f.tick("compile")
|
||||
case name == "go":
|
||||
if f.onTest != nil {
|
||||
@@ -84,6 +90,9 @@ func (f *integFake) run(ctx context.Context, env []string, name string, args ...
|
||||
}
|
||||
return forgekey.Ran{Stdout: f.captured, Status: status}
|
||||
case name == "pg_ctl" && strings.HasSuffix(line, " start"):
|
||||
if f.onStart != nil {
|
||||
f.onStart()
|
||||
}
|
||||
f.tick("start")
|
||||
for i, a := range args {
|
||||
if a == "-D" {
|
||||
@@ -314,8 +323,8 @@ func TestAStoreThatDoesNotStartEndsTheStepFailedAndLeavesNothing(t *testing.T) {
|
||||
if !strings.Contains(f.lines(), " stop") {
|
||||
t.Errorf("the half-started store was not stopped:\n%s", f.lines())
|
||||
}
|
||||
if f.busDir != "" {
|
||||
t.Errorf("the bus was started after the store failed")
|
||||
if f.busDir != "" && !f.busDown {
|
||||
t.Errorf("the bus, started beside the store, was not stopped after the store failed")
|
||||
}
|
||||
if entries, _ := os.ReadDir(filepath.Join(m.Work, "ws1")); len(entries) != 1 || entries[0].Name() != "tree" {
|
||||
t.Errorf("the workspace holds %v, want only its tree", entries)
|
||||
|
||||
@@ -29,23 +29,32 @@ func (m *Module) caches() []string {
|
||||
}
|
||||
|
||||
// isolation is how a run step of the workspace is shut off: the workspace (its tree, its siblings, its run directories,
|
||||
// its packed artifacts) read-write, the caches beneath a throwaway layer unless write, and an environment of a PATH and
|
||||
// its packed artifacts) read-write, the caches beneath the ask's own layer unless write, and an environment of a PATH and
|
||||
// a locale alone (the step's HOME is its own, inside its /tmp).
|
||||
func (m *Module) isolation(workspace string, write bool) buildstep.Isolation {
|
||||
path := os.Getenv("PATH")
|
||||
if path == "" {
|
||||
path = defaultPath
|
||||
}
|
||||
return buildstep.Isolation{
|
||||
iso := buildstep.Isolation{
|
||||
Workspace: filepath.Join(m.Work, workspace),
|
||||
Caches: m.caches(),
|
||||
WriteCaches: write,
|
||||
Env: append([]string{"PATH=" + path, "LC_ALL=C"}, m.threadEnv()...),
|
||||
}
|
||||
if !write {
|
||||
// What one command of the ask compiles is kept for its later commands (the vet, the unit tests, the integration
|
||||
// level's compile), in the workspace and gone with it (issue 1921, try speed S2).
|
||||
iso.CacheLayer = filepath.Join(iso.Workspace, cacheLayerDir)
|
||||
}
|
||||
return iso
|
||||
}
|
||||
|
||||
// cacheLayerDir is the directory in a workspace's root where an ask keeps what its commands wrote into the caches.
|
||||
const cacheLayerDir = "cachelayer"
|
||||
|
||||
// stepRunner is the runner of a run step of the workspace: buildstep.Isolated over m.Run, so every command it runs is
|
||||
// shut off; write is true for a trunk build alone (the shared caches are written by trunk builds only). Inside is the
|
||||
// shut off; write is true for a trunk build and for a check's go vet, compile steps that run nothing of the change (ADR 0541 §2). Inside is the
|
||||
// integration level's own process, already running shut off (level.go): it runs its commands as they are.
|
||||
func (m *Module) stepRunner(workspace string, write bool) (forgekey.Runner, error) {
|
||||
if m.Inside {
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
type shutOff struct {
|
||||
flags []string
|
||||
binds, roBinds, overlays []string
|
||||
uppers map[string]string // the writable layer each --overlay gives a cache: cache -> upper
|
||||
env []string
|
||||
command string
|
||||
chdir string
|
||||
@@ -27,7 +28,7 @@ type shutOff struct {
|
||||
|
||||
// bwrapArity is how many words each bubblewrap option the SDK writes takes.
|
||||
var bwrapArity = map[string]int{"--unshare-all": 0, "--die-with-parent": 0, "--new-session": 0, "--clearenv": 0,
|
||||
"--proc": 1, "--dev": 1, "--tmpfs": 1, "--dir": 1, "--chdir": 1, "--overlay-src": 1, "--tmp-overlay": 1,
|
||||
"--proc": 1, "--dev": 1, "--tmpfs": 1, "--dir": 1, "--chdir": 1, "--overlay-src": 1, "--tmp-overlay": 1, "--overlay": 3,
|
||||
"--ro-bind": 2, "--ro-bind-try": 2, "--bind": 2, "--symlink": 2, "--setenv": 2}
|
||||
|
||||
// throughBubblewrap is the fake runner of a step's tests: a bubblewrap command is read back (recorded in seen) and its
|
||||
@@ -56,6 +57,11 @@ func throughBubblewrap(t *testing.T, run forgekey.Runner, seen *[]shutOff) forge
|
||||
s.roBinds = append(s.roBinds, args[i+1])
|
||||
case "--overlay-src":
|
||||
s.overlays = append(s.overlays, args[i+1])
|
||||
case "--overlay":
|
||||
if s.uppers == nil {
|
||||
s.uppers = map[string]string{}
|
||||
}
|
||||
s.uppers[args[i+3]] = args[i+1]
|
||||
case "--setenv":
|
||||
s.env = append(s.env, args[i+1]+"="+args[i+2])
|
||||
case "--chdir":
|
||||
@@ -105,6 +111,9 @@ func checkShutOff(t *testing.T, m *Module, s shutOff, write bool) {
|
||||
t.Errorf("a trunk build's %s does not write the shared cache %s (bound %v, layered %v)", s.command, c, bound, layered)
|
||||
case !write && (bound || !layered):
|
||||
t.Errorf("%s may write the shared cache %s (bound %v, layered %v): only a trunk build does", s.command, c, bound, layered)
|
||||
case !write && !strings.HasPrefix(s.uppers[c], filepath.Join(m.Work, "ws1", "cachelayer")+"/"):
|
||||
// 1921/S2: what one command of the ask compiles is kept for its later commands, in the workspace
|
||||
t.Errorf("%s writes the cache %s into %q, not the ask's cache layer in its workspace", s.command, c, s.uppers[c])
|
||||
}
|
||||
}
|
||||
if want := filepath.Join(m.Work, "ws1"); !slices.Contains(s.binds, want) {
|
||||
@@ -130,8 +139,23 @@ func TestTheUnitLevelRunsEveryGoTestShutOff(t *testing.T) {
|
||||
if ran == 0 || len(seen) != ran {
|
||||
t.Fatalf("%d go commands ran, %d of them shut off: %v", ran, len(seen), *calls)
|
||||
}
|
||||
// A check compiles with cgo off as a build does (ADR 0542 §1), and the lint's go vet, a compile that runs nothing
|
||||
// of the change, writes the shared caches; every other command reads them beneath its own layer (ADR 0541 §2).
|
||||
for _, c := range *calls {
|
||||
if strings.HasPrefix(c.line, "go ") && !slices.Contains(c.env, "CGO_ENABLED=0") {
|
||||
t.Errorf("%s ran without CGO_ENABLED=0: %v", c.line, c.env)
|
||||
}
|
||||
}
|
||||
vets := 0
|
||||
for _, s := range seen {
|
||||
checkShutOff(t, m, s, false)
|
||||
vet := strings.Contains(s.command, " vet ")
|
||||
if vet {
|
||||
vets++
|
||||
}
|
||||
checkShutOff(t, m, s, vet)
|
||||
}
|
||||
if vets == 0 {
|
||||
t.Error("no go vet ran shut off")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/buildstep"
|
||||
"git.novox.be/novox/mesh-sdk/go/forgekey"
|
||||
@@ -144,6 +145,18 @@ func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string,
|
||||
var wg sync.WaitGroup
|
||||
var crash any // a panic of a lint goes on in the caller, as a panic of a test run does
|
||||
var crashMu sync.Mutex
|
||||
// Each directory's end is said as a phase, so a long lint is readable while it runs (issue 1921): which directories
|
||||
// are done and how long the last one took.
|
||||
var doneMu sync.Mutex
|
||||
done := 0
|
||||
said := func(d string, began time.Time) {
|
||||
doneMu.Lock()
|
||||
defer doneMu.Unlock()
|
||||
done++
|
||||
if done < len(dirs) {
|
||||
buildstep.Phase(ctx, fmt.Sprintf("unit: lint of %d directories, %d done (%s in %s)", len(dirs), done, d, m.clock().Sub(began).Round(time.Second)))
|
||||
}
|
||||
}
|
||||
for i, d := range dirs {
|
||||
wg.Add(1)
|
||||
slots <- struct{}{}
|
||||
@@ -157,7 +170,9 @@ func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string,
|
||||
crashMu.Unlock()
|
||||
}
|
||||
}()
|
||||
began := m.clock()
|
||||
answers[i].failed, answers[i].err = m.lint(ctx, run, tree, d, env(d))
|
||||
said(d, began)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
@@ -148,7 +148,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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -290,8 +290,8 @@ func TestTheIntegrationLevelIsGivenTheBesideDirectoryOnlyWhenSiblingsWerePlaced(
|
||||
}
|
||||
tested := 0
|
||||
for _, c := range f.calls {
|
||||
if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") {
|
||||
continue
|
||||
if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") || strings.Contains(c.line, "-exec true") {
|
||||
continue // the setup's compile runs no test binary, so it is given nothing a test reads (issue 1921, S5)
|
||||
}
|
||||
tested++
|
||||
got := ""
|
||||
|
||||
@@ -72,7 +72,7 @@ func TestOnlyTheSelectedDirectoryCompilesAndRuns(t *testing.T) {
|
||||
if !strings.HasPrefix(l, "go -C "+filepath.Join(tree, "b")+" test ") {
|
||||
t.Errorf("go test ran outside the selected directory: %s", l)
|
||||
}
|
||||
if strings.Contains(l, "-run ^$") {
|
||||
if strings.Contains(l, "-exec true") {
|
||||
compiled++
|
||||
} else {
|
||||
ran++
|
||||
@@ -91,7 +91,7 @@ func TestWithoutASelectionEveryDirectoryCompilesAndRuns(t *testing.T) {
|
||||
compiled, ran := 0, 0
|
||||
for _, l := range strings.Split(f.lines(), "\n") {
|
||||
if strings.HasPrefix(l, "go ") && strings.Contains(l, " test ") {
|
||||
if strings.Contains(l, "-run ^$") {
|
||||
if strings.Contains(l, "-exec true") {
|
||||
compiled++
|
||||
} else {
|
||||
ran++
|
||||
@@ -146,27 +146,85 @@ func TestSelectedDirectoriesRunAtOnceAndAnswerInDirectoryOrder(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The bound holds: with one directory at a time, no two go test commands run at once.
|
||||
// The bound holds for the setup's compiles and for the tests: no more of them run at once than the bound, and with a
|
||||
// bound of 3 the three directories compile at once, beside the store's start, given nothing a test reads, since the
|
||||
// compile runs no test binary (issue 1921, try speed S5).
|
||||
func TestTheDirectoriesAtOnceNeverPassTheBound(t *testing.T) {
|
||||
f := newIntegFake(t, "slow")
|
||||
var mu sync.Mutex
|
||||
running, most := 0, 0
|
||||
f.onTest = func(string) {
|
||||
mu.Lock()
|
||||
running++
|
||||
if running > most {
|
||||
most = running
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
mu.Lock()
|
||||
running--
|
||||
mu.Unlock()
|
||||
}
|
||||
m, states := threeModules(t, f)
|
||||
m.Parallel = 1
|
||||
runIntegrationOf(t, m, states)
|
||||
if most != 1 {
|
||||
t.Errorf("%d go test commands ran at once with a bound of 1", most)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
compile bool // the setup's compiles are counted, not the tests
|
||||
bound int
|
||||
want int // how many must run at once (at most the bound always)
|
||||
}{
|
||||
{"tests one at a time", false, 1, 1},
|
||||
{"compiles one at a time", true, 1, 1},
|
||||
{"compiles at once beside the store", true, 3, 3},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
f := newIntegFake(t, "slow")
|
||||
var mu sync.Mutex
|
||||
running, most := 0, 0
|
||||
reached := make(chan struct{})
|
||||
storeStarting, beside := false, false
|
||||
count := func() {
|
||||
mu.Lock()
|
||||
running++
|
||||
most = max(most, running)
|
||||
if running == tc.want && tc.want > 1 {
|
||||
close(reached)
|
||||
}
|
||||
mu.Unlock()
|
||||
if tc.want > 1 {
|
||||
select { // held until all of them run, so "at once" does not hang on timing
|
||||
case <-reached:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
} else {
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
mu.Lock()
|
||||
running--
|
||||
mu.Unlock()
|
||||
}
|
||||
if tc.compile {
|
||||
f.onCompile = func(_ string, env []string) {
|
||||
for _, e := range env {
|
||||
if strings.HasPrefix(e, "MESH_TEST_") {
|
||||
t.Errorf("the compile was given %s: it runs no test binary", e)
|
||||
}
|
||||
}
|
||||
mu.Lock()
|
||||
beside = beside || storeStarting
|
||||
mu.Unlock()
|
||||
count()
|
||||
}
|
||||
if tc.want > 1 {
|
||||
f.onStart = func() { // the store starts until the compiles have all run at once, or 2 s pass
|
||||
mu.Lock()
|
||||
storeStarting = true
|
||||
mu.Unlock()
|
||||
select {
|
||||
case <-reached:
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
f.onTest = func(string) { count() }
|
||||
}
|
||||
m, states := threeModules(t, f)
|
||||
m.Parallel = tc.bound
|
||||
if _, st := runIntegrationOf(t, m, states); st.State != delivery.StepPassed {
|
||||
t.Fatalf("the step ended %s: %s", st.State, st.Reason)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if most > tc.bound || most < tc.want {
|
||||
t.Errorf("%d ran at once with a bound of %d, want %d", most, tc.bound, tc.want)
|
||||
}
|
||||
if tc.compile && tc.want > 1 && !beside {
|
||||
t.Errorf("no compile ran while the store was starting:\n%s", f.lines())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -218,8 +251,15 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
|
||||
if o.level == LevelUnit {
|
||||
// gofmt and go vet of each directory the level runs come before its tests (ADR 0529 §1; issue 1887 D7-go): a
|
||||
// directory that fails them fails the level at once, and their time is the result's Lint.
|
||||
// go vet compiles and runs nothing of the change, with cgo off (goEnv), so it writes the shared cache and warms it
|
||||
// for every later check, a pull request's head included (ADR 0541 §2, ADR 0542 §1); the tests run beneath their
|
||||
// own throwaway layer.
|
||||
compile, err := m.stepRunner(o.workspace, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
linted := m.clock()
|
||||
failed, err := m.lintAll(ctx, run, tree, todo, func(d string) []string {
|
||||
failed, err := m.lintAll(ctx, compile, tree, todo, func(d string) []string {
|
||||
return append(m.goEnvFor(filepath.Join(tree, d)), o.env...)
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -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.20261028235126-2a8670189188
|
||||
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,7 @@
|
||||
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.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028235126-2a8670189188 h1:0ZJZmp4wqiJlBlVHAdGonVIRaLf0Z79Ql4DFR2k8E8c=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028235126-2a8670189188/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": {
|
||||
|
||||
@@ -16,12 +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, when a user namespace cannot be made, or when `/dev/net/tun`
|
||||
cannot be opened (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
|
||||
kernel module loads `tun` at boot. Whatever the health refuses, the node is **not eligible**: 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; a look that passes again wakes the puller.
|
||||
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, Eligible: m.Eligible, 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -97,11 +98,25 @@ func (m *Module) look(ctx context.Context) (Look, error) {
|
||||
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
|
||||
}
|
||||
if err := m.tunDevice(); err != nil {
|
||||
return l, &buildstep.Refusal{Text: err.Error(), Have: l.Buildah}
|
||||
return l, ¬Eligible{&buildstep.Refusal{Text: err.Error(), Have: l.Buildah}}
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// notEligible is a refusal of a machine that runs the module fine but cannot build here now (no tun device: its asks go
|
||||
// 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 {
|
||||
@@ -120,6 +135,13 @@ func (m *Module) keep(err error) {
|
||||
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()
|
||||
}
|
||||
@@ -155,20 +177,34 @@ func (m *Module) Start(ctx context.Context) {
|
||||
func (m *Module) Health(ctx context.Context) health.Answer {
|
||||
l, err := m.look(ctx)
|
||||
m.keep(err)
|
||||
if err != nil {
|
||||
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; %s opens", l.Buildah, l.Account, l.UIDs, l.GIDs, m.tunPath()))
|
||||
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"
|
||||
|
||||
@@ -54,7 +56,6 @@ func TestHealthIsUnhealthyWithoutWhatABuildNeeds(t *testing.T) {
|
||||
}{
|
||||
{"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"}},
|
||||
{"tun", func(m *Module, p string) { m.Tun = p + "/dev/net/tun" }, []string{"not eligible: ", "/dev/net/tun cannot be opened", "tun"}},
|
||||
} {
|
||||
f := newFake(t)
|
||||
m := moduleWith(t, f, newStates())
|
||||
@@ -74,6 +75,61 @@ func TestHealthIsUnhealthyWithoutWhatABuildNeeds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthIsUnhealthyWhenANamespaceCannotBeMade(t *testing.T) {
|
||||
f := newFake(t)
|
||||
m := moduleWith(t, f, newStates())
|
||||
|
||||
@@ -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.20261028115016-1618837ea9fe
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5
|
||||
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.20261028115016-1618837ea9fe h1:QDWi8k7zxaRPnJ+V/izoy6A19hZt1LTLl3uksZSlqOg=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028115016-1618837ea9fe/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/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": {
|
||||
|
||||
@@ -51,13 +51,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 +69,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)
|
||||
}
|
||||
|
||||
@@ -159,7 +159,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
|
||||
|
||||
@@ -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.20261028190821-9e5666b4d6f5
|
||||
github.com/distribution/distribution/v3 v3.1.2
|
||||
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.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5/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": {
|
||||
|
||||
@@ -21,9 +21,9 @@ const (
|
||||
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
|
||||
// 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.
|
||||
PlacementRead = "mesh-delivery-indexer.placement"
|
||||
PlacementRead = "mesh-delivery-assigner.placement"
|
||||
DeclaredConfigRead = "mesh-controller.declared-config"
|
||||
PredefinedRead = "mesh-controller.predefined"
|
||||
|
||||
|
||||
@@ -27,9 +27,9 @@ func (c *Config) sources() (Sources, error) {
|
||||
}); err != nil {
|
||||
return s, fmt.Errorf("the assignments cannot be read: %w", err)
|
||||
}
|
||||
// And every module the catalog's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both
|
||||
// And every module the seat'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.
|
||||
// Placement that cannot be read yet (the seat'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
|
||||
@@ -57,7 +57,7 @@ func (c *Config) sources() (Sources, error) {
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
problems = append(problems, fmt.Sprintf("the catalog's placement cannot be read, so the assignments alone are resolved: %v", err))
|
||||
problems = append(problems, fmt.Sprintf("the seat'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)
|
||||
|
||||
@@ -2,4 +2,4 @@ module mesh-config
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021075504-7bf4a8053a49
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009 h1:I9rm8HhYRJ0+mzQQz+VAH0kgv2YJWkvctEc67Yd0PX8=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261013235212-c8b07242a009/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021075504-7bf4a8053a49 h1:h1hE+H1TzmeWNFP3EnfhUXTZq2gyiyeND32Tsg83ygY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021075504-7bf4a8053a49/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"mesh-delivery-indexer.placement",
|
||||
"mesh-delivery-assigner.placement",
|
||||
"mesh-controller.declared-config",
|
||||
"mesh-controller.predefined"
|
||||
],
|
||||
|
||||
@@ -157,8 +157,8 @@ func checkClause(c checkSaid, sep string) string {
|
||||
return ": " + c.Words
|
||||
}
|
||||
|
||||
// approveCaveat is what an Approve does beyond the merge while the check has not passed: it merges before the check ends,
|
||||
// or although it failed (the check gates no merge, check-gates-merge false). Empty once it passed.
|
||||
// approveCaveat is what an Approve does beyond the merge while the check has not passed: it merges although it failed (the check gates
|
||||
// no merge, check-gates-merge false). Empty once it passed; an ask is made only with a verdict.
|
||||
func approveCaveat(c checkSaid) string {
|
||||
switch c.Key {
|
||||
case checkSaidPass:
|
||||
@@ -166,7 +166,7 @@ func approveCaveat(c checkSaid) string {
|
||||
case checkSaidFail:
|
||||
return " although its check failed"
|
||||
}
|
||||
return " now, before its check ends"
|
||||
return ""
|
||||
}
|
||||
|
||||
// composeMergeAsk is the ask for a delivery, or for a group's members together.
|
||||
@@ -411,9 +411,6 @@ func (h *Holder) approval(d *Delivery, now time.Time) (string, func()) {
|
||||
}
|
||||
}
|
||||
check := h.checkAt(members)
|
||||
if !h.checkGates() {
|
||||
check = unansweredWords(check) // asked with no verdict only past its bound (verdictWaits)
|
||||
}
|
||||
ask := composeMergeAsk(sdk.AskingID(id, asking), d, group, members, heads, check, now)
|
||||
taken := []*Delivery{}
|
||||
var reads []memberRead
|
||||
|
||||
@@ -79,10 +79,7 @@ type Holder struct {
|
||||
unchecked map[string]unchecked
|
||||
// checker are the checker's verdicts by the hash of the heads they judged, as its verdicts bucket holds them
|
||||
// (onestatus.go, issue 1302); runningSince when this owner first saw each running (ADR 0548 §2, turns.go).
|
||||
checker map[string]checkerVerdict
|
||||
// verdictGone is when the checker's verdict at a heads hash was taken away (its key deleted): the Approve's wait for a
|
||||
// verdict counts its bound from then, as from a check that starts again (verdictWaits).
|
||||
verdictGone map[string]time.Time
|
||||
checker map[string]checkerVerdict
|
||||
runningSince map[string]time.Time
|
||||
// unmergeableSince is when the forge first said a group's member does not merge now, by group and member (issue 1275,
|
||||
// group_merge.go): within forgeMergeGrace that is the forge still working out mergeability, asked again shortly.
|
||||
@@ -146,7 +143,7 @@ func (h *Holder) init() {
|
||||
h.lostRead = map[string]time.Time{}
|
||||
h.unchecked = map[string]unchecked{}
|
||||
h.checker = map[string]checkerVerdict{}
|
||||
h.runningSince, h.turnsHeld, h.verdictGone = map[string]time.Time{}, map[string]sdk.Turn{}, map[string]time.Time{}
|
||||
h.runningSince, h.turnsHeld = map[string]time.Time{}, map[string]sdk.Turn{}
|
||||
h.reach = map[string]sdk.Reach{}
|
||||
h.started = h.Now()
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ type checkerVerdict struct {
|
||||
Words string `json:"words,omitempty"`
|
||||
} `json:"failed,omitzero"`
|
||||
// Composition is what the check of the machines' composition made of it (mesh-sdk delivery.Composition): its state
|
||||
// not-run says the composition, and with it a placement change's answers from its nodes, was not judged.
|
||||
// not-run says the composition was not judged.
|
||||
Composition *struct {
|
||||
State string `json:"state"`
|
||||
Words string `json:"words,omitempty"`
|
||||
@@ -82,13 +82,9 @@ func (h *Holder) CheckerVerdictChanged(key string, value []byte) {
|
||||
if h.runningSince == nil {
|
||||
h.runningSince = map[string]time.Time{}
|
||||
}
|
||||
if h.verdictGone == nil {
|
||||
h.verdictGone = map[string]time.Time{}
|
||||
}
|
||||
if len(value) == 0 || string(value) == "null" {
|
||||
delete(h.checker, hash)
|
||||
delete(h.runningSince, hash)
|
||||
h.verdictGone[hash] = h.Now()
|
||||
h.Poke()
|
||||
return
|
||||
}
|
||||
@@ -104,7 +100,6 @@ func (h *Holder) CheckerVerdictChanged(key string, value []byte) {
|
||||
h.runningSince[v.Heads] = h.Now() // a check starts: a merge it holds says since when (ADR 0548 §2)
|
||||
} else if v.State != checkerRunning {
|
||||
delete(h.runningSince, v.Heads)
|
||||
delete(h.verdictGone, v.Heads)
|
||||
}
|
||||
h.checker[v.Heads] = v
|
||||
h.Poke()
|
||||
@@ -341,7 +336,7 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid {
|
||||
if ok {
|
||||
switch v.State {
|
||||
case checkerPass:
|
||||
return checkSaid{Key: checkSaidPass, Words: "passed its check" + notComposed(v, members)}
|
||||
return checkSaid{Key: checkSaidPass, Words: "passed its check" + notComposed(v)}
|
||||
case checkerRunning:
|
||||
words := "its check has no verdict yet: it is running"
|
||||
if since, held := h.runningSince[hash]; held && !since.IsZero() {
|
||||
@@ -384,20 +379,13 @@ func (h *Holder) checkAt(members []*Delivery) checkSaid {
|
||||
return checkSaid{Key: checkSaidNone, Words: "its check has no verdict yet"}
|
||||
}
|
||||
|
||||
// notComposed is what a passing verdict adds when its composition was not judged (mesh-sdk delivery.CompositionNotRun): a
|
||||
// placement change's pass then holds no node's answer to its placement (ADR 0559 §5), which the ask says. Empty otherwise.
|
||||
func notComposed(v checkerVerdict, members []*Delivery) string {
|
||||
// notComposed is what a passing verdict adds when its composition was not judged (mesh-sdk delivery.CompositionNotRun).
|
||||
// Empty otherwise.
|
||||
func notComposed(v checkerVerdict) string {
|
||||
if v.Composition == nil || v.Composition.State != sdk.CompositionNotRun {
|
||||
return ""
|
||||
}
|
||||
why := firstOf(oneLine(v.Composition.Words), "it was not run")
|
||||
for _, m := range members {
|
||||
for _, p := range m.Paths {
|
||||
if strings.HasPrefix(p, "placement/") && strings.HasSuffix(p, ".json") {
|
||||
return ", but its composition was not judged (" + why + "), so no node answered its placement"
|
||||
}
|
||||
}
|
||||
}
|
||||
return ", but its composition was not judged (" + why + ")"
|
||||
}
|
||||
|
||||
|
||||
@@ -453,67 +453,20 @@ func (h *Holder) ungatedWaits(d *Delivery) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// askCheckBound is how long an Approve waits for the check's verdict at its heads while the check gates no merge: past it,
|
||||
// the operator is asked anyway, and the ask says the check has not answered. The checker's own step bound is 30 minutes
|
||||
// and a check runs several steps; an hour covers a whole check.
|
||||
const askCheckBound = time.Hour
|
||||
|
||||
// waitsCheckBeforeAsking is how the wait for the check's verdict begins, as `show` and the delivery's wait say it.
|
||||
const waitsCheckBeforeAsking = "waits its check before asking the operator's Approve: "
|
||||
|
||||
// verdictWaits is why an Approve is not asked yet while the check gates no merge: the check at the heads the ask would cover
|
||||
// has no verdict (the operator, 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request").
|
||||
// A pass or a failure is asked (a failure in its words: the operator may still merge it), and so is a check that has not
|
||||
// answered within askCheckBound of the heads becoming ready, which the ask says. For a group the check is the one of all its
|
||||
// heads together; for a placement change it ends only once every node it places answered (ADR 0559 §5). Under the lock.
|
||||
// A pass or a failure is asked (a failure in its words: the operator may still merge it), and nothing else: no timer stands
|
||||
// in for the verdict, because the checker turns a check it lost into a failure it says aloud (issue 1733, the operator,
|
||||
// 2026-10-28: "one hour is absurd"). For a group the check is the one of all its heads together. Under the lock.
|
||||
func (h *Holder) verdictWaits(d *Delivery) string {
|
||||
members := h.askMembers(d)
|
||||
c := h.checkAt(members)
|
||||
c := h.checkAt(h.askMembers(d))
|
||||
if c.Key == checkSaidPass || c.Key == checkSaidFail {
|
||||
return ""
|
||||
}
|
||||
until := h.waitSince(d, members).Add(askCheckBound)
|
||||
if !h.Now().Before(until) {
|
||||
return ""
|
||||
}
|
||||
h.lookAt(until, "an Approve that waits its check, asked anyway at its bound")
|
||||
return waitsCheckBeforeAsking + c.Words + "; asked anyway at " + clockSaid(until) + " if it has not answered"
|
||||
}
|
||||
|
||||
// waitSince is when the wait for a verdict began, the moment the bound of verdictWaits counts from: the latest of the
|
||||
// heads becoming ready (a member with no ready run yet, such as one re-pushed or settled ready by an event this look has
|
||||
// not reached, became ready now), the check at those heads starting again, and its verdict being taken away. Under the lock.
|
||||
func (h *Holder) waitSince(d *Delivery, members []*Delivery) time.Time {
|
||||
now := h.Now()
|
||||
since := time.Time{}
|
||||
later := func(t time.Time) {
|
||||
if t.After(since) {
|
||||
since = t
|
||||
}
|
||||
}
|
||||
for _, m := range append([]*Delivery{d}, members...) {
|
||||
if m.Ready == nil {
|
||||
return now
|
||||
}
|
||||
later(m.Ready.Seen)
|
||||
}
|
||||
if hash, err := sdk.HeadsHash(headsOf(members)); err == nil {
|
||||
later(h.runningSince[hash])
|
||||
later(h.verdictGone[hash])
|
||||
}
|
||||
if since.IsZero() || since.After(now) {
|
||||
return now
|
||||
}
|
||||
return since
|
||||
}
|
||||
|
||||
// unansweredWords is the check's words in an ask made past askCheckBound with no verdict: it says the check has not answered.
|
||||
func unansweredWords(c checkSaid) checkSaid {
|
||||
if c.Key == checkSaidPass || c.Key == checkSaidFail {
|
||||
return c
|
||||
}
|
||||
c.Words += ", and has given none in " + strconv.Itoa(int(askCheckBound.Minutes())) + " minutes, so the operator is asked anyway"
|
||||
return c
|
||||
return waitsCheckBeforeAsking + c.Words
|
||||
}
|
||||
|
||||
// waitsForReview is how a hold for a review begins, as the delivery's state and its mesh/delivery status say it.
|
||||
|
||||
@@ -71,38 +71,32 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
// 1733: the Approve's ask says the check's state at the heads it names, read from the checker's verdict there: "passed
|
||||
// its check" only when it passed. While the check gates no merge, the ask waits for the check's verdict (the operator,
|
||||
// 2026-10-27 23:54: "if you don't want me to approve it, don't send the approval request"): a pass or a failure is asked,
|
||||
// the failure in its words; with no verdict nothing is asked and the delivery says it waits its check, until the bound,
|
||||
// past which it is asked anyway, saying so, and asked again in new words once the check passes. The verdict is the
|
||||
// the failure in its words; with no verdict nothing is asked and the delivery says it waits its check, until the verdict
|
||||
// comes, however long (issue 1733: no timer stands in for it). The verdict is the
|
||||
// checker's verdicts bucket value as captured on the bus (mesh-delivery-checker, `<group>.<heads hash>`).
|
||||
for _, row := range []struct {
|
||||
name, state, failed, then, path string
|
||||
gated bool
|
||||
notComposed bool
|
||||
after time.Duration
|
||||
asks, open int
|
||||
want, not []string
|
||||
name, state, failed, then string
|
||||
gated bool
|
||||
after time.Duration
|
||||
asks, open int
|
||||
want, not []string
|
||||
}{
|
||||
{name: "gated, passed", gated: true, state: "pass", asks: 1,
|
||||
want: []string{"app#7 Fix it passed its check. Approve merges it into main at " + readyHead[:12] + ";"},
|
||||
not: []string{"before its check ends", "no verdict yet"}},
|
||||
{name: "ungated, no verdict", asks: 0, want: []string{waitsCheckBeforeAsking + "its check has no verdict yet; asked anyway at 15:00 CEST if"}},
|
||||
{name: "ungated, no verdict", asks: 0, want: []string{waitsCheckBeforeAsking + "its check has no verdict yet"}, not: []string{"anyway"}},
|
||||
{name: "ungated, running", state: "running", asks: 0, want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running"}},
|
||||
{name: "ungated, running past its bound", state: "running", after: askCheckBound, asks: 1,
|
||||
want: []string{"its check has no verdict yet: it is running", "has given none in 60 minutes, so the operator is asked anyway",
|
||||
"Approve merges it into main at " + readyHead[:12] + " now, before its check ends"}, not: []string{"passed"}},
|
||||
{name: "ungated, past its bound, then passed", state: "running", after: askCheckBound, then: "pass", asks: 2,
|
||||
{name: "ungated, running for hours, still not asked", state: "running", after: 5 * time.Hour, asks: 0,
|
||||
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running"}, not: []string{"anyway"}},
|
||||
{name: "ungated, running, then passed", state: "running", after: 2 * time.Hour, then: "pass", asks: 1,
|
||||
want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends", "anyway"}},
|
||||
{name: "ungated, passed", state: "pass", asks: 1,
|
||||
want: []string{"passed its check. Approve merges it into main at " + readyHead[:12] + ";"}, not: []string{"before its check ends"}},
|
||||
// A pass that turns back into a check with no verdict (a check started again, its verdict taken away) past the bound
|
||||
// withdraws the ask, and the bound counts again from that moment: nothing is asked at once as unanswered.
|
||||
{name: "ungated, passed, running again past its bound", state: "pass", after: askCheckBound, then: "running", asks: 1, open: -1,
|
||||
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running (since 15:00 CEST); asked anyway at 16:00 CEST"}},
|
||||
{name: "ungated, passed, its verdict gone past its bound", state: "pass", after: askCheckBound, then: "gone", asks: 1, open: -1,
|
||||
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet; asked anyway at 16:00 CEST"}},
|
||||
// A placement change's pass with its composition not judged holds no node's answer, and the ask says so (ADR 0559 §5).
|
||||
{name: "ungated, placement passed uncomposed", state: "pass", path: "placement/node-a.json", notComposed: true, asks: 1,
|
||||
want: []string{"passed its check, but its composition was not judged (the assigner is not running), so no node answered its placement"}},
|
||||
// A pass that turns back into a check with no verdict (a check started again, its verdict taken away) withdraws the ask.
|
||||
{name: "ungated, passed, running again", state: "pass", then: "running", asks: 1, open: -1,
|
||||
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet: it is running"}, not: []string{"anyway"}},
|
||||
{name: "ungated, passed, its verdict gone", state: "pass", then: "gone", asks: 1, open: -1,
|
||||
want: []string{waitsCheckBeforeAsking + "its check has no verdict yet"}, not: []string{"anyway"}},
|
||||
{name: "ungated, failed", state: "fail", failed: "TestThing: wanted 1, got 2", asks: 1,
|
||||
want: []string{"its check failed: TestThing: wanted 1, got 2", "although its check failed"}, not: []string{"passed", "anyway"}},
|
||||
} {
|
||||
@@ -112,7 +106,7 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
ungated(v)
|
||||
}
|
||||
v.op.auto = false
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", firstOf(row.path, "src/a.go"))
|
||||
p := pr("novox/app", 7, readyHead, "feat/x", "src/a.go")
|
||||
p.Title = "Fix it"
|
||||
v.h.PullUpdated(p)
|
||||
head := headOf("novox/app", 7, readyHead)
|
||||
@@ -121,11 +115,7 @@ func TestAnApprovedPullRequestIsMergedThroughTheMergeTool(t *testing.T) {
|
||||
}
|
||||
v.settleAll()
|
||||
if !row.gated && row.state != "" {
|
||||
var more map[string]any
|
||||
if row.notComposed {
|
||||
more = map[string]any{"composition": map[string]string{"state": "not-run", "words": "the assigner is not running"}}
|
||||
}
|
||||
checkerSaysWith(v, "g", row.state, row.failed, more, head)
|
||||
checkerSaysWith(v, "g", row.state, row.failed, nil, head)
|
||||
v.settleAll()
|
||||
}
|
||||
if row.after > 0 {
|
||||
@@ -377,8 +367,8 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
|
||||
if e := u.op.ask(u.op.open()[0]).Explanation; !strings.Contains(e, "2 pull requests, passed its check. Approve merges all of them into main in one pass, at the heads named;") {
|
||||
t.Fatalf("ungated, the group's ask does not say its check passed:\n%s", e)
|
||||
}
|
||||
// A member re-pushed past the bound after its sibling became ready: the bound counts from the new head, so nothing is
|
||||
// asked as unanswered at once (review round 1).
|
||||
// A member re-pushed hours after its sibling became ready: with no verdict at the new heads nothing is asked, however
|
||||
// long the sibling has waited.
|
||||
r := newWorld(t)
|
||||
ungated(r)
|
||||
r.op.auto = false
|
||||
@@ -386,17 +376,16 @@ func TestAGroupMergesEveryMemberInOnePassOnlyOnceComposedAndApproved(t *testing.
|
||||
r.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead, "feat/x", "internal/a.go"))
|
||||
r.h.PullUpdated(pr("novox/mesh-catalog", 2, catHead, "feat/x", "modules/app/module.json"))
|
||||
r.settleAll()
|
||||
r.later(askCheckBound + time.Minute)
|
||||
r.later(5 * time.Hour)
|
||||
r.h.PullUpdated(pr("novox/mesh-controller", 1, ctlHead2, "feat/x", "internal/a.go"))
|
||||
// Made ready by a handler, before the look gives it a run: no verdict and no status at the new heads, so only the rule
|
||||
// that a member with no run counts as ready now keeps the older sibling's time from asking it as unanswered.
|
||||
// Made ready by a handler, before the look gives it a run: no verdict and no status at the new heads.
|
||||
r.h.mu.Lock()
|
||||
r.h.ungate(r.h.deliveries[IDOf("novox/mesh-controller", ctlHead2)], r.h.Now())
|
||||
r.h.mu.Unlock()
|
||||
r.settleAll()
|
||||
if d := r.delivery(IDOf("novox/mesh-catalog", catHead)); r.op.count() != 0 || d.Ready == nil ||
|
||||
!strings.Contains(d.Ready.Waits, waitsCheckBeforeAsking) {
|
||||
t.Fatalf("a member re-pushed past the bound: asked %d times, wait %+v; wanted none asked and the wait said", r.op.count(), d.Ready)
|
||||
t.Fatalf("a member re-pushed hours later: asked %d times, wait %+v; wanted none asked and the wait said", r.op.count(), d.Ready)
|
||||
}
|
||||
|
||||
u.op.answer(u.op.open()[0], OptionApprove)
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-delivery-gate
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261027014310-cadd7b1b2f8f
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
)
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61 h1:/c6ncICWjq55nBf8TlDM0ROe/PuH+qZIdnuVbtg8UuM=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261026211913-68d65e6bbf61/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=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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=
|
||||
|
||||
@@ -4,6 +4,9 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Who may ask `get` for which secret (novox/hq ADR 0349 §11, design 57 V4, issue 647).
|
||||
@@ -108,6 +111,9 @@ func fieldSet(f json.RawMessage) bool {
|
||||
// that the module's plan there names.
|
||||
type Declared struct {
|
||||
Read AssignmentReader
|
||||
// Confirm, when set, reads an assignment directly (over the bus): a module Read does not hold is confirmed through it
|
||||
// before a credential is refused, since a watched mirror can be behind (issue 1126, review round 1).
|
||||
Confirm AssignmentReader
|
||||
// Store is the vault's store, whose record of who holds a provision's one credential (pair.go) the rule reads.
|
||||
Store *Store
|
||||
}
|
||||
@@ -207,3 +213,77 @@ func (d Declared) May(caller, name string) (bool, string) {
|
||||
// bus error rather than an answer. The vault says "holds no secret named ... yet", which the client waits on and asks
|
||||
// again, and records no refusal.
|
||||
const notYetPrefix = "not yet: "
|
||||
|
||||
// WatchedAssignments is the assignments mirror as the vault's watch of it hands it over (issue 1126, 2026-10-28: novox's
|
||||
// `own` read one assignment over the bus per own secret and per credential, 203 reads, and answered after 26 s, past
|
||||
// the node-engine's 15 s). The state is the bus's; the vault watches it, never polls it, and reads it from memory once the
|
||||
// watch has handed every current value. Until then, and when the watch could not be begun, a read goes to the bus
|
||||
// (Fallback), as before.
|
||||
type WatchedAssignments struct {
|
||||
mu sync.Mutex
|
||||
held map[string][]byte
|
||||
caught bool
|
||||
attempt uint64
|
||||
Fallback AssignmentReader
|
||||
}
|
||||
|
||||
// WatchAttempt is one attempt at the watch: what it hands over counts only while it is the latest attempt, so a watch
|
||||
// that failed partway and is begun again keeps nothing it took (a key deleted meanwhile is never handed again), and a
|
||||
// late delivery to an older attempt changes nothing (issue 1126, review round 1).
|
||||
type WatchAttempt struct {
|
||||
w *WatchedAssignments
|
||||
n uint64
|
||||
}
|
||||
|
||||
// Begin starts a new attempt from nothing held: reads go to the bus until it has caught up.
|
||||
func (w *WatchedAssignments) Begin() WatchAttempt {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
w.attempt++
|
||||
w.held, w.caught = map[string][]byte{}, false
|
||||
return WatchAttempt{w: w, n: w.attempt}
|
||||
}
|
||||
|
||||
// Take keeps one change the attempt handed over, a value put or a key deleted, while it is the latest attempt.
|
||||
func (a WatchAttempt) Take(c stdio.StateChange) error {
|
||||
w := a.w
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if a.n != w.attempt {
|
||||
return nil
|
||||
}
|
||||
if c.Op == "delete" || c.Op == "purge" || len(c.Value) == 0 {
|
||||
delete(w.held, c.Key)
|
||||
return nil
|
||||
}
|
||||
w.held[c.Key] = append([]byte(nil), c.Value...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Caught says the attempt handed every current value: reads are answered from memory from now on, if it is the latest.
|
||||
func (a WatchAttempt) Caught() {
|
||||
w := a.w
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
if a.n == w.attempt {
|
||||
w.caught = true
|
||||
}
|
||||
}
|
||||
|
||||
// Read is one value of the mirror, `<node>.<module>`: nil when there is none.
|
||||
func (w *WatchedAssignments) Read(key string) ([]byte, error) {
|
||||
w.mu.Lock()
|
||||
if w.caught {
|
||||
raw, ok := w.held[key]
|
||||
w.mu.Unlock()
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
return append([]byte(nil), raw...), nil
|
||||
}
|
||||
w.mu.Unlock()
|
||||
if w.Fallback == nil {
|
||||
return nil, fmt.Errorf("the assignments mirror is not watched yet, and nothing reads it from the bus")
|
||||
}
|
||||
return w.Fallback(key)
|
||||
}
|
||||
|
||||
@@ -49,13 +49,37 @@ func build() *Env {
|
||||
}
|
||||
// Who may ask (askers.go): a node's provisioner client, for what its module's plan there names.
|
||||
// Where it sees a consumer bind a provider (pair.go): the same mirror, every key.
|
||||
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: readAssignment, Store: store},
|
||||
// The assignments as the vault's watch keeps them (watchAssignments), read over the bus until it has caught up.
|
||||
assigned = &WatchedAssignments{Fallback: readAssignment}
|
||||
env.Vault = &Vault{Store: store, Own: own, Askers: Declared{Read: assigned.Read, Confirm: readAssignment, Store: store},
|
||||
Assignments: Mirror{Keys: assignmentKeys, Read: readAssignment}, Emit: emit, Ask: stdio.Ask,
|
||||
Now: time.Now, Log: say, Accounts: busAccounts{}, Haves: readHave,
|
||||
HaveKeys: func() ([]string, error) { return stdio.State(HaveRead).Keys() }}
|
||||
return env
|
||||
}
|
||||
|
||||
// assigned is the assignments mirror as the vault's watch keeps it.
|
||||
var assigned *WatchedAssignments
|
||||
|
||||
// watchAssignments begins the watch of the assignments mirror the vault's rules read (issue 1126, 2026-10-28): a
|
||||
// watch, never a poll, so `own` and `get` read no assignment over the bus once it has caught up. It answers whether the
|
||||
// watch began; one that did not is begun again at the next minute pass, and reads go to the bus meanwhile.
|
||||
func watchAssignments() bool {
|
||||
if assigned == nil {
|
||||
return true
|
||||
}
|
||||
// Each attempt begins from nothing held: one that failed partway leaves nothing a later one would not hand again.
|
||||
attempt := assigned.Begin()
|
||||
if err := stdio.State(AssignmentsRead).Watch("", attempt.Take); err != nil {
|
||||
assigned.Begin() // what the failed attempt took counts for nothing, and reads go to the bus
|
||||
say("the assignments are not watched, so each is read over the bus; begun again at the next pass: %v", err)
|
||||
return false
|
||||
}
|
||||
attempt.Caught()
|
||||
say("the assignments are watched: the vault's rules read them from memory")
|
||||
return true
|
||||
}
|
||||
|
||||
// readHave is one key of the node-engines' have bucket, read through the runtime under the vault's own account (its
|
||||
// manifest `reads` it), or nil when there is none.
|
||||
func readHave(key string) ([]byte, error) {
|
||||
@@ -149,11 +173,14 @@ func main() {
|
||||
written, failed := env.Vault.HashHeld()
|
||||
say("hashed %d bus accounts at start; %d could not be, tried again every minute: %v", len(written), len(failed), failed)
|
||||
}
|
||||
watched := watchHandings(env)
|
||||
watched, watching := watchHandings(env), watchAssignments()
|
||||
for {
|
||||
if !watched {
|
||||
watched = watchHandings(env)
|
||||
}
|
||||
if !watching {
|
||||
watching = watchAssignments()
|
||||
}
|
||||
env.pass()
|
||||
if env.Vault != nil {
|
||||
env.Vault.HashOwed()
|
||||
|
||||
@@ -437,10 +437,10 @@ func TestANodeEngineHandsTheVaultWhatRunsOnItsOwnMachine(t *testing.T) {
|
||||
later -= time.Hour
|
||||
// own_drift reads it from the node's latest shadow answer, mesh-wide; the id as a node runs it, its module's name
|
||||
// first (captured from novox's shadow answer, 2026-10-28: only-office.needs-jwt).
|
||||
answer := delivery.CandidateAnswer{Head: "h", Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{
|
||||
answer := delivery.CandidateAnswer{Proposal: "pro", Revision: 1, Verdict: delivery.CandidateDiffers, Differs: []delivery.ModuleDiffers{{
|
||||
Module: "postgres", Why: "the resource differs", Bodies: []delivery.ResourceBodies{{ID: "postgres.needs-superuser-password",
|
||||
Composed: `{"sealed":"` + delivery.MaskedAnotherValue + `"}`, Running: `{"sealed":"` + delivery.MaskedAnotherValue + `"}`}}}}}
|
||||
have, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"g": answer},
|
||||
have, err := delivery.EncodeHaveNode(delivery.HaveNode{Candidates: map[string]delivery.CandidateAnswer{"pro": answer},
|
||||
Composes: delivery.ComposesController})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -453,7 +453,7 @@ func TestANodeEngineHandsTheVaultWhatRunsOnItsOwnMachine(t *testing.T) {
|
||||
}
|
||||
if len(drift.Secrets) != 1 || drift.Secrets[0].Secret != "own/anchor/postgres/superuser-password" ||
|
||||
drift.Secrets[0].Generation != 2 || drift.Secrets[0].Previous != 1 || drift.Secrets[0].Takeable ||
|
||||
len(drift.Nodes) != 1 || drift.Nodes[0].ValuesDiffer["g"] != 1 || drift.Nodes[0].Composes != delivery.ComposesController {
|
||||
len(drift.Nodes) != 1 || drift.Nodes[0].ValuesDiffer["pro"] != 1 || drift.Nodes[0].Composes != delivery.ComposesController {
|
||||
t.Errorf("own_drift: %+v", drift)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ func credentialNames(c CredentialAsked) []string {
|
||||
|
||||
// credentialRefusal says why node may not have c, or "": a consumer's credential to the machine its consuming module
|
||||
// stands on, a provider's to the providing machine, and nothing to a machine that is neither.
|
||||
func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
|
||||
func (v *Vault) credentialRefusal(node string, c CredentialAsked, plan *planReader) string {
|
||||
for _, part := range []string{c.Provision, c.Consumer, c.ConsumerModule, c.Provider} {
|
||||
if !namePart.MatchString(part) {
|
||||
return fmt.Sprintf("%q is not a name the mesh writes", part)
|
||||
@@ -85,14 +85,19 @@ func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
|
||||
return fmt.Sprintf("%s asks as a consumer on %s, and is not that machine", node, c.Consumer)
|
||||
}
|
||||
// The consuming module stands on the machine: read, never assumed (an assignment that cannot be read refuses).
|
||||
read, why := v.assignmentReader(node)
|
||||
raw, why, err := plan.read(c.ConsumerModule)
|
||||
if why != "" {
|
||||
return why
|
||||
}
|
||||
raw, err := read(node + "." + c.ConsumerModule)
|
||||
if err != nil {
|
||||
return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", node, c.ConsumerModule, err)
|
||||
}
|
||||
if raw == nil {
|
||||
// Confirmed directly before refusing: the mirror may not hold a module assigned a moment ago.
|
||||
if raw, err = plan.confirmed(c.ConsumerModule); err != nil {
|
||||
return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", node, c.ConsumerModule, err)
|
||||
}
|
||||
}
|
||||
if raw == nil {
|
||||
return fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", c.ConsumerModule, node)
|
||||
}
|
||||
@@ -106,6 +111,56 @@ func (v *Vault) credentialRefusal(node string, c CredentialAsked) string {
|
||||
return fmt.Sprintf("the role %q is not consumer or provider", c.Role)
|
||||
}
|
||||
|
||||
// planReader reads the assignments of one machine's modules for one answer of `own`, each module's once (issue 1126,
|
||||
// 2026-10-28: one read per own secret and per credential, 203 on novox, held the answer past the node-engine's bound).
|
||||
type planReader struct {
|
||||
node string
|
||||
reader AssignmentReader
|
||||
confirm AssignmentReader
|
||||
why string
|
||||
got map[string]planRead
|
||||
}
|
||||
|
||||
type planRead struct {
|
||||
raw []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (v *Vault) plansOf(node string) *planReader {
|
||||
read, why := v.assignmentReader(node)
|
||||
var confirm AssignmentReader
|
||||
if d, ok := v.Askers.(Declared); ok && d.Confirm != nil {
|
||||
confirm = standing(d.Confirm)
|
||||
}
|
||||
return &planReader{node: node, confirm: confirm, reader: read, why: why, got: map[string]planRead{}}
|
||||
}
|
||||
|
||||
// read is module's assignment on the machine, nil when there is none, or why the vault reads none.
|
||||
func (p *planReader) read(module string) ([]byte, string, error) {
|
||||
if p.why != "" {
|
||||
return nil, p.why, nil
|
||||
}
|
||||
r, ok := p.got[module]
|
||||
if !ok {
|
||||
r.raw, r.err = p.reader(p.node + "." + module)
|
||||
p.got[module] = r
|
||||
}
|
||||
return r.raw, "", r.err
|
||||
}
|
||||
|
||||
// confirmed is module's assignment read directly, for one the reader does not hold (a watched mirror can be behind),
|
||||
// nil when there is none or nothing reads directly.
|
||||
func (p *planReader) confirmed(module string) ([]byte, error) {
|
||||
if p.confirm == nil {
|
||||
return nil, nil
|
||||
}
|
||||
raw, err := p.confirm(p.node + "." + module)
|
||||
if err == nil && raw != nil {
|
||||
p.got[module] = planRead{raw: raw}
|
||||
}
|
||||
return raw, err
|
||||
}
|
||||
|
||||
// assignmentReader is the vault's reader of the assignments, or why it has none: the rule `own` keeps reads them,
|
||||
// and without them it refuses rather than hands (a rule that cannot be read is no rule).
|
||||
func (v *Vault) assignmentReader(node string) (AssignmentReader, string) {
|
||||
@@ -177,17 +232,21 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
|
||||
if err != nil {
|
||||
return OwnAnswer{}, fmt.Errorf("%s's sealing key as recorded is not usable: %w", node, err)
|
||||
}
|
||||
all, err := v.Store.List()
|
||||
// Only this machine's own records are read, each module's assignment once, and every hand-out recorded in one synced
|
||||
// write after all are sealed: none is handed when that record cannot be written (issue 1126, 2026-10-28).
|
||||
all, err := v.Store.ListPrefix("own/" + node + "/")
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
plan := v.plansOf(node)
|
||||
var handings []Handing
|
||||
out := OwnAnswer{Node: node, Key: key}
|
||||
for _, r := range all {
|
||||
parts := strings.Split(r.Name, "/")
|
||||
if len(parts) != 4 || parts[0] != "own" || parts[1] != node {
|
||||
continue
|
||||
}
|
||||
handed, err := v.handed(node, parts[2])
|
||||
handed, err := plan.handed(parts[2])
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
@@ -202,9 +261,7 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
if err := v.recordHanding(Handing{To: caller, Name: r.Name, Generation: r.Generation, Outcome: "handed"}); err != nil {
|
||||
return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", r.Name, err)
|
||||
}
|
||||
handings = append(handings, Handing{To: caller, Name: r.Name, Generation: r.Generation, Outcome: "handed"})
|
||||
out.Own = append(out.Own, NodeOwnSecret{Module: parts[2], Name: parts[3], Origin: composeOrigin(r.Origin),
|
||||
Generation: r.Generation, Sealed: sealed})
|
||||
}
|
||||
@@ -215,7 +272,7 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
|
||||
return out.Own[i].Name < out.Own[j].Name
|
||||
})
|
||||
for _, c := range asked {
|
||||
if why := v.credentialRefusal(node, c); why != "" {
|
||||
if why := v.credentialRefusal(node, c, plan); why != "" {
|
||||
return OwnAnswer{}, fmt.Errorf("%s may not ask for the credential of %s from %s to %s on %s: %s", caller,
|
||||
c.Provision, c.Provider, c.ConsumerModule, c.Consumer, why)
|
||||
}
|
||||
@@ -248,9 +305,7 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
|
||||
if err != nil {
|
||||
return OwnAnswer{}, err
|
||||
}
|
||||
if err := v.recordHanding(Handing{To: caller, Name: name, Generation: r.Generation, Outcome: "handed"}); err != nil {
|
||||
return OwnAnswer{}, fmt.Errorf("%s is not handed out: who was handed it could not be recorded: %w", name, err)
|
||||
}
|
||||
handings = append(handings, Handing{To: caller, Name: name, Generation: r.Generation, Outcome: "handed"})
|
||||
out.Credentials = append(out.Credentials, NodeCredential{CredentialAsked: c, Name: name, Generation: r.Generation, Sealed: sealed})
|
||||
found = true
|
||||
break
|
||||
@@ -259,6 +314,10 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
|
||||
out.Missing = append(out.Missing, c)
|
||||
}
|
||||
}
|
||||
if err := v.recordHandings(handings); err != nil {
|
||||
return OwnAnswer{}, fmt.Errorf("nothing is handed out to %s: who was handed its %d values could not be recorded: %w",
|
||||
caller, len(handings), err)
|
||||
}
|
||||
v.log("handed %s's node-engine the %d own secrets of its modules and %d credentials (%d not held), sealed to its key",
|
||||
node, len(out.Own), len(out.Credentials), len(out.Missing))
|
||||
return out, nil
|
||||
@@ -267,12 +326,12 @@ func (v *Vault) OwnOfNode(caller string, asked ...CredentialAsked) (OwnAnswer, e
|
||||
// handed says the plan of module on node hands it to the provisioner, so its secrets are filled from `get`, not composed.
|
||||
// A plan that cannot be read is an error, never "not handed": `own` refuses rather than hands a secret the provisioner
|
||||
// holds.
|
||||
func (v *Vault) handed(node, module string) (bool, error) {
|
||||
read, why := v.assignmentReader(node)
|
||||
func (p *planReader) handed(module string) (bool, error) {
|
||||
node := p.node
|
||||
raw, why, err := p.read(module)
|
||||
if why != "" {
|
||||
return false, errors.New(why)
|
||||
}
|
||||
raw, err := read(node + "." + module)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf(notYetPrefix+"the assignment %s.%s could not be read, so whether it is handed to the "+
|
||||
"provisioner is not known: %w", node, module, err)
|
||||
|
||||
@@ -2,9 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
"git.novox.be/novox/mesh-sdk/go/seal"
|
||||
)
|
||||
|
||||
@@ -119,3 +122,117 @@ func TestOwnAnswersAMachineItsModulesSecretsSealedToItsKey(t *testing.T) {
|
||||
t.Errorf("own with no reader of the assignments: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Issue 1126, 2026-10-28 (seed-9's probe: novox's 57 own secrets and 146 credentials took 26 s, past the node-engine's
|
||||
// 15 s): one answer of `own` reads each module's assignment once, and records every hand-out in one synced write.
|
||||
func TestOwnReadsEachAssignmentOnceAndSyncsItsRecordOnce(t *testing.T) {
|
||||
plans := assignments{"anchor.web": `{"node":"anchor","module":"web"}`, "anchor.db": `{"node":"anchor","module":"db"}`}
|
||||
reads := map[string]int{}
|
||||
v := newVault(t, nil)
|
||||
v.Askers = Declared{Read: func(key string) ([]byte, error) { reads[key]++; return plans.read(key) }, Store: v.Store}
|
||||
pub, _ := aSealingKey(t)
|
||||
for _, m := range []MoveOf{
|
||||
{Node: "anchor", Module: "web", Name: "password", Origin: "made", Generation: "g-1", SealingKey: pub},
|
||||
{Node: "anchor", Module: "web", Name: "api-key", Origin: "made", Generation: "g-1", SealingKey: pub},
|
||||
{Node: "anchor", Module: "web", Name: "cookie", Origin: "made", Generation: "g-1", SealingKey: pub},
|
||||
{Node: "anchor", Module: "db", Name: "root", Origin: "made", Generation: "g-1", SealingKey: pub},
|
||||
{Provider: "anchor", Provision: "postgres", Consumer: "anchor", ConsumerModule: "web", Origin: "made", Generation: "g-1", SealingKey: pub},
|
||||
{Provider: "anchor", Provision: "redis", Consumer: "anchor", ConsumerModule: "web", Origin: "made", Generation: "g-1", SealingKey: pub},
|
||||
} {
|
||||
if _, err := moveIn(t, v, "node.anchor", m, "v-"+m.Name+m.Provision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for k := range reads {
|
||||
delete(reads, k)
|
||||
}
|
||||
syncs, handed := v.Store.syncs, len(mustHanded(t, v))
|
||||
got, err := v.OwnOfNode("node.anchor",
|
||||
CredentialAsked{Role: "consumer", Provision: "postgres", Consumer: "anchor", ConsumerModule: "web", Provider: "anchor"},
|
||||
CredentialAsked{Role: "consumer", Provision: "redis", Consumer: "anchor", ConsumerModule: "web", Provider: "anchor"})
|
||||
if err != nil || len(got.Own) != 4 || len(got.Credentials) != 2 {
|
||||
t.Fatalf("anchor was answered %+v, %v", got, err)
|
||||
}
|
||||
for key, n := range reads {
|
||||
if n != 1 {
|
||||
t.Errorf("the assignment %s was read %d times in one answer", key, n)
|
||||
}
|
||||
}
|
||||
if len(reads) != 2 {
|
||||
t.Errorf("the answer read %v, want each of anchor's two modules once", reads)
|
||||
}
|
||||
if n := v.Store.syncs - syncs; n != 1 {
|
||||
t.Errorf("one answer synced the record %d times", n)
|
||||
}
|
||||
if n := len(mustHanded(t, v)) - handed; n != 6 {
|
||||
t.Errorf("one answer recorded %d hand-outs, want 6", n)
|
||||
}
|
||||
// Review round 1, note (b): a consumer module the mirror does not hold yet (its watch behind) is read directly before
|
||||
// the credential is refused.
|
||||
v.Askers = Declared{Read: func(string) ([]byte, error) { return nil, nil }, Confirm: plans.read, Store: v.Store}
|
||||
if got, err := v.OwnOfNode("node.anchor", CredentialAsked{Role: "consumer", Provision: "postgres", Consumer: "anchor",
|
||||
ConsumerModule: "web", Provider: "anchor"}); err != nil || len(got.Credentials) != 1 {
|
||||
t.Errorf("a module the mirror does not hold yet, read directly: %+v, %v", got, err)
|
||||
}
|
||||
v.Askers = Declared{Read: plans.read, Store: v.Store}
|
||||
// A record that cannot be written hands nothing, the batch as one.
|
||||
if err := os.Remove(filepath.Join(v.dir, "store", "handed.jsonl")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Mkdir(filepath.Join(v.dir, "store", "handed.jsonl"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a, err := v.OwnOfNode("node.anchor"); err == nil {
|
||||
t.Errorf("handed with no record of it: %+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func mustHanded(t *testing.T, v testVault) []Handing {
|
||||
t.Helper()
|
||||
h, err := v.Store.Handed(1000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Issue 1126, 2026-10-28: the vault keeps the assignments mirror as its watch hands it over, so `own` and `get` read
|
||||
// no assignment over the bus once the watch has caught up; before that a read goes to the bus.
|
||||
func TestTheWatchedAssignmentsAnswerWithoutTheBusOnceCaughtUp(t *testing.T) {
|
||||
asked := 0
|
||||
w := &WatchedAssignments{Fallback: func(key string) ([]byte, error) { asked++; return []byte(`{"from":"bus"}`), nil }}
|
||||
if raw, err := w.Read("anchor.web"); err != nil || string(raw) != `{"from":"bus"}` || asked != 1 {
|
||||
t.Errorf("before the watch caught up: %s, %v, %d asks", raw, err, asked)
|
||||
}
|
||||
// A first watch takes two values and fails before it caught up; a late value still arrives on it.
|
||||
first := w.Begin()
|
||||
for _, c := range []stdio.StateChange{
|
||||
{Key: "anchor.web", Op: "put", Value: []byte(`{"node":"anchor","module":"web"}`), Current: true},
|
||||
{Key: "anchor.db", Op: "put", Value: []byte(`{"node":"anchor","module":"db"}`), Current: true},
|
||||
} {
|
||||
if err := first.Take(c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Review round 1: the watch begun again holds anchor.db no more (removed meanwhile); what the first took is gone.
|
||||
second := w.Begin()
|
||||
_ = second.Take(stdio.StateChange{Key: "anchor.web", Op: "put", Value: []byte(`{"node":"anchor","module":"web"}`), Current: true})
|
||||
_ = first.Take(stdio.StateChange{Key: "anchor.db", Op: "put", Value: []byte(`{"node":"anchor","module":"db"}`)})
|
||||
first.Caught()
|
||||
if raw, _ := w.Read("anchor.db"); string(raw) != `{"from":"bus"}` {
|
||||
t.Errorf("the failed watch's caught up counted: %s", raw)
|
||||
}
|
||||
second.Caught()
|
||||
_ = first.Take(stdio.StateChange{Key: "anchor.db", Op: "put", Value: []byte(`{"node":"anchor","module":"db"}`)})
|
||||
_ = second.Take(stdio.StateChange{Key: "anchor.web", Op: "put", Value: []byte(`{"node":"anchor","module":"web","v":2}`)})
|
||||
asked = 0
|
||||
for key, want := range map[string]string{"anchor.web": `{"node":"anchor","module":"web","v":2}`, "anchor.db": "", "laptop.x": ""} {
|
||||
raw, err := w.Read(key)
|
||||
if err != nil || string(raw) != want || (want == "") != (raw == nil) {
|
||||
t.Errorf("%s read %q, %v; want %q", key, raw, err, want)
|
||||
}
|
||||
}
|
||||
if asked != 0 {
|
||||
t.Errorf("caught up, the mirror asked the bus %d times", asked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@ type Store struct {
|
||||
|
||||
handedMu sync.Mutex
|
||||
rolled int
|
||||
// syncs counts the record's synced appends since the vault started: how a test proves one sync per answer.
|
||||
syncs int
|
||||
// asksMu serialises the record of asks, so two asks for one secret at once do not both pass its bounds.
|
||||
asksMu sync.Mutex
|
||||
}
|
||||
@@ -173,22 +175,37 @@ type Handing struct {
|
||||
// RecordHanding appends one handing to the store's record, synced before it returns. It says whether the
|
||||
// record was rolled to make room.
|
||||
func (s *Store) RecordHanding(h Handing) (rolled bool, err error) {
|
||||
raw, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return false, err
|
||||
return s.RecordHandings([]Handing{h})
|
||||
}
|
||||
|
||||
// RecordHandings appends every handing of one answer to the store's record in one write, synced once before it returns
|
||||
// (issue 1126, 2026-10-28: one sync per secret, under the one lock every node's answers share, held `own` past the
|
||||
// node-engine's bound). It says whether the record was rolled to make room; nothing is written when one cannot be.
|
||||
func (s *Store) RecordHandings(hs []Handing) (rolled bool, err error) {
|
||||
if len(hs) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
var raw []byte
|
||||
for _, h := range hs {
|
||||
line, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
raw = append(append(raw, line...), '\n')
|
||||
}
|
||||
s.handedMu.Lock()
|
||||
defer s.handedMu.Unlock()
|
||||
path := filepath.Join(s.dir, "handed.jsonl")
|
||||
max := s.HandedBound()
|
||||
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw))+1 > max {
|
||||
if info, err := os.Stat(path); err == nil && info.Mode().IsRegular() && info.Size()+int64(len(raw)) > max {
|
||||
if err := s.roll(path); err != nil {
|
||||
return false, err
|
||||
}
|
||||
s.rolled++
|
||||
rolled = true
|
||||
}
|
||||
return rolled, appendSynced(path, append(raw, '\n'))
|
||||
s.syncs++
|
||||
return rolled, appendSynced(path, raw)
|
||||
}
|
||||
|
||||
// roll moves handed.jsonl to .1, .1 to .2 and so on, dropping what falls past HandedKept.
|
||||
@@ -266,7 +283,10 @@ func (s *Store) HandedBound() int64 {
|
||||
}
|
||||
|
||||
// List is every record, by name.
|
||||
func (s *Store) List() ([]Record, error) {
|
||||
func (s *Store) List() ([]Record, error) { return s.ListPrefix("") }
|
||||
|
||||
// ListPrefix is every record whose name begins with prefix, by name: only those files are read.
|
||||
func (s *Store) ListPrefix(prefix string) ([]Record, error) {
|
||||
entries, err := os.ReadDir(filepath.Join(s.dir, "secrets"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -277,7 +297,7 @@ func (s *Store) List() ([]Record, error) {
|
||||
continue
|
||||
}
|
||||
name, err := url.PathUnescape(strings.TrimSuffix(e.Name(), ".json"))
|
||||
if err != nil {
|
||||
if err != nil || !strings.HasPrefix(name, prefix) {
|
||||
continue
|
||||
}
|
||||
r, ok, err := s.Read(name)
|
||||
|
||||
@@ -235,11 +235,24 @@ func (v *Vault) announce(c Changed) {
|
||||
|
||||
// recordHanding keeps who asked for what, and what they got, in the vault's own store, and says loudly
|
||||
// when the record had to be rolled to make room.
|
||||
func (v *Vault) recordHanding(h Handing) error {
|
||||
h.At = v.Now().UTC()
|
||||
rolled, err := v.Store.RecordHanding(h)
|
||||
func (v *Vault) recordHanding(h Handing) error { return v.recordHandings([]Handing{h}) }
|
||||
|
||||
// recordHandings keeps every handing of one answer in one synced write, as recordHanding keeps one.
|
||||
func (v *Vault) recordHandings(hs []Handing) error {
|
||||
if len(hs) == 0 {
|
||||
return nil
|
||||
}
|
||||
at := v.Now().UTC()
|
||||
for i := range hs {
|
||||
hs[i].At = at
|
||||
}
|
||||
rolled, err := v.Store.RecordHandings(hs)
|
||||
if err != nil {
|
||||
v.log("the record of %s asking for %s could not be written: %v", h.To, h.Name, err)
|
||||
names := make([]string, len(hs))
|
||||
for i, h := range hs {
|
||||
names[i] = h.Name
|
||||
}
|
||||
v.log("the record of %s asking for %s could not be written: %v", hs[0].To, strings.Join(names, ", "), err)
|
||||
return err
|
||||
}
|
||||
if rolled {
|
||||
|
||||
@@ -167,8 +167,10 @@ func TestNoBucketHoldsASecret(t *testing.T) {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Put(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(BusAccountsState).Keys(", "", 1))
|
||||
}
|
||||
// The haves, in main.go alone: one read, one watch and their keys (own_drift), never a write.
|
||||
// The haves, in main.go alone: one read, one watch and their keys (own_drift), never a write; and one watch of
|
||||
// the assignments, which the vault's rules read from memory once it caught up (issue 1126).
|
||||
if f == "main.go" {
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(AssignmentsRead).Watch(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Get(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Watch(", "", 1))
|
||||
code = []byte(strings.Replace(string(code), "stdio.State(HaveRead).Keys(", "", 1))
|
||||
|
||||
@@ -3,7 +3,7 @@ module mesh-vault
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388
|
||||
github.com/nats-io/nats-server/v2 v2.11.17
|
||||
github.com/nats-io/nats.go v1.54.0
|
||||
golang.org/x/crypto v0.57.0
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2 h1:eSwObLdxs3jxPrlcwdaMo9hzIDTUz2ogWw8cjX8To7I=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028090029-3028c7ec4ed2/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388 h1:ycu/sNieTdLpD3bzhZOCXdzcoqXUOxPRmqWV019REpo=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028174713-38dbe1d3a388/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=
|
||||
|
||||
@@ -14,6 +14,7 @@ states on the bus.
|
||||
| `/usr/local/lib/mesh-power/bin/power-moment` | the runner: each module's piece on its own, as root, with `sh`, bounded (`MESH_POWER_BOUND`, default 30 s); every outcome in the journal under `mesh-power` |
|
||||
| five units | `mesh-power-after-boot`, `-before-shutdown` (its stop is the shutdown, while the network is still up), `-before-sleep`, `-after-wake`, `-supply` |
|
||||
| `mesh-power-draw` (process) | samples the power drawn for the module's bundle, which puts it on the bus as `power.draw` (below) |
|
||||
| `reboot.target.d`, `poweroff.target.d`, `halt.target.d` (`mesh-power.conf`) | a shutdown is bounded at ten minutes, not systemd's thirty; the target keeps its own forcing action (issue 1126: on ace's reboot of 2026-10-28 the bus was started again during the shutdown, nothing stopped it a second time, and the reboot waited 29 minutes) |
|
||||
| `/etc/udev/rules.d/90-mesh-power.rules` | a power supply's change starts `mesh-power-supply`, which runs `on-mains` or `on-battery` once per change of source |
|
||||
|
||||
## The moments, and how a module adds code
|
||||
@@ -146,4 +147,7 @@ Each directory or archive this module declares says how it treats a file it did
|
||||
- `/etc/systemd/system/hibernate.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/hybrid-sleep.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/suspend-then-hibernate.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/reboot.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/poweroff.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/etc/systemd/system/halt.target.d`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
- `/run/mesh-power`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2).
|
||||
|
||||
@@ -271,13 +271,12 @@ func (m *Machine) Publish(r Reading) error {
|
||||
|
||||
// RunDraw samples and publishes until the context ends.
|
||||
func RunDraw(ctx context.Context, m *Machine) error {
|
||||
return runDraw(ctx, m, SampleEvery, m.Publish)
|
||||
return runDraw(ctx, m, SampleEvery, sleepFor(ctx), m.Publish)
|
||||
}
|
||||
|
||||
// runDraw publishes one reading every `every`. A battery reading is instant, so it waits the interval
|
||||
// itself; a counter reading waits inside its sample. It returns as soon as the context ends.
|
||||
func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(Reading) error) error {
|
||||
wait := func(d time.Duration) bool {
|
||||
// sleepFor waits d on the real clock, or less when ctx ends first; it reports whether the full wait passed.
|
||||
func sleepFor(ctx context.Context) func(time.Duration) bool {
|
||||
return func(d time.Duration) bool {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
@@ -285,6 +284,12 @@ func runDraw(ctx context.Context, m *Machine, every time.Duration, publish func(
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runDraw publishes one reading every `every`. A battery reading is instant, so it waits the interval
|
||||
// itself; a counter reading waits inside its sample. The wait is injected so a test drives the clock;
|
||||
// it returns false when the loop must stop. It returns as soon as the context ends.
|
||||
func runDraw(ctx context.Context, m *Machine, every time.Duration, wait func(time.Duration) bool, publish func(Reading) error) error {
|
||||
for ctx.Err() == nil {
|
||||
d := m.Sample(ctx, every, wait)
|
||||
if ctx.Err() != nil {
|
||||
|
||||
@@ -187,24 +187,25 @@ func TestTheLoopPublishesEveryIntervalAndStopsWhenCancelled(t *testing.T) {
|
||||
"/sys/class/power_supply/BAT0/status": "Discharging",
|
||||
"/sys/class/power_supply/BAT0/power_now": "12000000",
|
||||
})
|
||||
// A fake clock: each wait returns at once; the tenth cancels the context, as the end of the run.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var lines []string
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
done <- runDraw(ctx, m, 20*time.Millisecond, func(r Reading) error { lines = append(lines, r.Text); return nil })
|
||||
}()
|
||||
time.Sleep(210 * time.Millisecond)
|
||||
cancel()
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
waits := 0
|
||||
wait := func(d time.Duration) bool {
|
||||
if d != 20*time.Millisecond {
|
||||
t.Errorf("waited %v, want the 20 ms interval", d)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("the loop did not stop when cancelled")
|
||||
if waits++; waits == 10 {
|
||||
cancel()
|
||||
}
|
||||
return ctx.Err() == nil
|
||||
}
|
||||
// One at once, then one every 20 ms: about eleven in 210 ms, never a burst, never a stall.
|
||||
if len(lines) < 6 || len(lines) > 12 || lines[0] != "12.0 W machine" {
|
||||
if err := runDraw(ctx, m, 20*time.Millisecond, wait, func(r Reading) error { lines = append(lines, r.Text); return nil }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// One at once, then one after each of the nine waits that passed; the tenth wait ended the loop.
|
||||
if len(lines) != 10 || lines[0] != "12.0 W machine" {
|
||||
t.Fatalf("%d lines: %v", len(lines), lines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,3 +127,53 @@ func TestTheBarBlocksAreTwoItemsOfTheBarsCollection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A shutdown is bounded at ten minutes, not systemd's thirty (issue 1126, ace's reboot of 2026-10-28): every unit had
|
||||
// stopped within 17 s, yet the bus and the authorization manager were started again during the shutdown, nothing
|
||||
// stopped them a second time, and the reboot waited on its job's own timeout of 30 min with nothing on the console.
|
||||
// The bound keeps each target's own forcing action (reboot-force and the like): systemd's last phase still ends
|
||||
// every process, unmounts and syncs. Ten minutes stays above the longest stop the catalogue declares (120 s) chained.
|
||||
func TestEveryShutdownIsBoundedAtTenMinutes(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../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)
|
||||
}
|
||||
byPath := map[string]map[string]any{}
|
||||
for _, r := range m.Resources {
|
||||
if p, ok := r["path"].(string); ok {
|
||||
byPath[p] = r
|
||||
}
|
||||
}
|
||||
for _, target := range []string{"reboot", "poweroff", "halt"} {
|
||||
dir := "/etc/systemd/system/" + target + ".target.d"
|
||||
if d := byPath[dir]; d == nil || d["type"] != "directory" || d["unknown"] != "program" {
|
||||
t.Errorf("%s: no directory resource %s whose unknown files are the program's: %v", target, dir, d)
|
||||
}
|
||||
f := byPath[dir+"/mesh-power.conf"]
|
||||
if f == nil || f["type"] != "file" {
|
||||
t.Errorf("%s: no file resource %s/mesh-power.conf", target, dir)
|
||||
continue
|
||||
}
|
||||
name, _ := f["template"].(string)
|
||||
text, err := os.ReadFile(filepath.Join("../..", name))
|
||||
if err != nil {
|
||||
t.Errorf("%s: %v", target, err)
|
||||
continue
|
||||
}
|
||||
var lines []string
|
||||
for _, l := range strings.Split(string(text), "\n") {
|
||||
if l != "" && !strings.HasPrefix(l, "#") {
|
||||
lines = append(lines, l)
|
||||
}
|
||||
}
|
||||
if strings.Join(lines, "\n") != "[Unit]\nJobTimeoutSec=10min" {
|
||||
t.Errorf("%s: %s sets %q, want only [Unit] JobTimeoutSec=10min (the target keeps its own action)", target, name, lines)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,11 +213,24 @@ func TestAMachineWithoutMainsSaysNothingAboutSupplies(t *testing.T) {
|
||||
func TestRunWithoutLogindStillSaysTheBoot(t *testing.T) {
|
||||
m := machine(t, map[string]string{"/proc/sys/kernel/random/boot_id": "xyz\n"})
|
||||
b := &bus{}
|
||||
w := NewWatcher(m, b.emit, func() (Login1, error) { return nil, errors.New("no system bus") })
|
||||
w.state = filepath.Join(t.TempDir(), "b")
|
||||
w.poll = 10 * time.Millisecond
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
// No timeout racing the loop: the run ends once logind was tried and the boot was published,
|
||||
// both of which happen in Run's own goroutine.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
dialed, emitted := false, false
|
||||
stopWhenBoth := func() {
|
||||
if dialed && emitted {
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
w := NewWatcher(m, func(typ string, body any) error {
|
||||
err := b.emit(typ, body)
|
||||
emitted = true
|
||||
stopWhenBoth()
|
||||
return err
|
||||
}, func() (Login1, error) { dialed = true; defer stopWhenBoth(); return nil, errors.New("no system bus") })
|
||||
w.state = filepath.Join(t.TempDir(), "b")
|
||||
w.poll = time.Millisecond
|
||||
w.Run(ctx)
|
||||
if got := b.seen(); len(got) == 0 || got[0] != Booted || w.Snapshot().Problem == "" {
|
||||
t.Fatalf("%v %+v", got, w.Snapshot())
|
||||
|
||||
@@ -314,6 +314,48 @@
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/suspend-then-hibernate.target.d/mesh-power.conf"
|
||||
},
|
||||
{
|
||||
"id": "reboot-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/reboot.target.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "reboot-bound",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/reboot.target.d/mesh-power.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/reboot.target.d/mesh-power.conf"
|
||||
},
|
||||
{
|
||||
"id": "poweroff-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/poweroff.target.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "poweroff-bound",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/poweroff.target.d/mesh-power.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/poweroff.target.d/mesh-power.conf"
|
||||
},
|
||||
{
|
||||
"id": "halt-drop-ins",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/system/halt.target.d",
|
||||
"mode": "0755",
|
||||
"unknown": "program"
|
||||
},
|
||||
{
|
||||
"id": "halt-bound",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/system/halt.target.d/mesh-power.conf",
|
||||
"mode": "0644",
|
||||
"template": "templates/systemd/system/halt.target.d/mesh-power.conf"
|
||||
},
|
||||
{
|
||||
"id": "udev-rule",
|
||||
"type": "file",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Written by the mesh (module power, novox/hq ADR 0211; issue 1126). Replaced on every push.
|
||||
#
|
||||
# A shutdown is bounded at ten minutes, not systemd's thirty. A unit started again during the shutdown
|
||||
# (on ace, 2026-10-28, the bus and the authorization manager) is never stopped a second time, and the
|
||||
# shutdown then waits on this job's timeout with nothing on the console. The target keeps its own
|
||||
# forcing action: the last phase still ends every process, unmounts and syncs.
|
||||
[Unit]
|
||||
JobTimeoutSec=10min
|
||||
@@ -0,0 +1,8 @@
|
||||
# Written by the mesh (module power, novox/hq ADR 0211; issue 1126). Replaced on every push.
|
||||
#
|
||||
# A shutdown is bounded at ten minutes, not systemd's thirty. A unit started again during the shutdown
|
||||
# (on ace, 2026-10-28, the bus and the authorization manager) is never stopped a second time, and the
|
||||
# shutdown then waits on this job's timeout with nothing on the console. The target keeps its own
|
||||
# forcing action: the last phase still ends every process, unmounts and syncs.
|
||||
[Unit]
|
||||
JobTimeoutSec=10min
|
||||
@@ -0,0 +1,8 @@
|
||||
# Written by the mesh (module power, novox/hq ADR 0211; issue 1126). Replaced on every push.
|
||||
#
|
||||
# A shutdown is bounded at ten minutes, not systemd's thirty. A unit started again during the shutdown
|
||||
# (on ace, 2026-10-28, the bus and the authorization manager) is never stopped a second time, and the
|
||||
# shutdown then waits on this job's timeout with nothing on the console. The target keeps its own
|
||||
# forcing action: the last phase still ends every process, unmounts and syncs.
|
||||
[Unit]
|
||||
JobTimeoutSec=10min
|
||||
@@ -1,5 +0,0 @@
|
||||
# This mesh's node names (novox/hq ADR 0507 §9, issue 1838): the one place they may be written.
|
||||
novox
|
||||
ace
|
||||
shanks
|
||||
g14
|
||||
-1125
File diff suppressed because it is too large
Load Diff
@@ -1,276 +0,0 @@
|
||||
{
|
||||
"v": 1,
|
||||
"node": "g14",
|
||||
"decided": {
|
||||
"address": "10.10.0.4",
|
||||
"account": "jochen",
|
||||
"tunnel": {
|
||||
"interface": "wg0",
|
||||
"unit": "wg-quick@wg0",
|
||||
"config": "/etc/wireguard/wg0.conf",
|
||||
"port": 51820,
|
||||
"mtu": 1380,
|
||||
"address": "10.10.0.4/24",
|
||||
"range": "10.10.0.0/24",
|
||||
"public-key": "mq76YPW0FrExhAvt/ZG1SB2BK6/cPISKgXO53SEMazU="
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"module": "adwaita"
|
||||
},
|
||||
{
|
||||
"module": "asus-zephyrus-g14"
|
||||
},
|
||||
{
|
||||
"module": "avahi"
|
||||
},
|
||||
{
|
||||
"module": "blueman"
|
||||
},
|
||||
{
|
||||
"module": "bluetooth"
|
||||
},
|
||||
{
|
||||
"module": "ca-trust"
|
||||
},
|
||||
{
|
||||
"module": "claude-code",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "clipmenu"
|
||||
},
|
||||
{
|
||||
"module": "cups"
|
||||
},
|
||||
{
|
||||
"module": "dbus"
|
||||
},
|
||||
{
|
||||
"module": "dev-tools"
|
||||
},
|
||||
{
|
||||
"module": "disk-load"
|
||||
},
|
||||
{
|
||||
"module": "dmenu"
|
||||
},
|
||||
{
|
||||
"module": "docker"
|
||||
},
|
||||
{
|
||||
"module": "docker-compose"
|
||||
},
|
||||
{
|
||||
"module": "dunst",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "fail2ban"
|
||||
},
|
||||
{
|
||||
"module": "feh"
|
||||
},
|
||||
{
|
||||
"module": "flatpak"
|
||||
},
|
||||
{
|
||||
"module": "fonts"
|
||||
},
|
||||
{
|
||||
"module": "forticlient"
|
||||
},
|
||||
{
|
||||
"module": "gnome-keyring"
|
||||
},
|
||||
{
|
||||
"module": "go"
|
||||
},
|
||||
{
|
||||
"module": "hostname",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "i3"
|
||||
},
|
||||
{
|
||||
"module": "i3status-rust"
|
||||
},
|
||||
{
|
||||
"module": "kernel"
|
||||
},
|
||||
{
|
||||
"module": "lemurs"
|
||||
},
|
||||
{
|
||||
"module": "localization"
|
||||
},
|
||||
{
|
||||
"module": "logrotate"
|
||||
},
|
||||
{
|
||||
"module": "memory-pressure"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-go",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-node",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-cli"
|
||||
},
|
||||
{
|
||||
"module": "mesh-files"
|
||||
},
|
||||
{
|
||||
"module": "mesh-host",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-notifier"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"module": "mesh-reads"
|
||||
},
|
||||
{
|
||||
"module": "mesh-wireguard"
|
||||
},
|
||||
{
|
||||
"module": "netcheck"
|
||||
},
|
||||
{
|
||||
"module": "networkmanager"
|
||||
},
|
||||
{
|
||||
"module": "nextcloud-client"
|
||||
},
|
||||
{
|
||||
"module": "nftables"
|
||||
},
|
||||
{
|
||||
"module": "nm-applet"
|
||||
},
|
||||
{
|
||||
"module": "node-env"
|
||||
},
|
||||
{
|
||||
"module": "node-tools"
|
||||
},
|
||||
{
|
||||
"module": "nodejs"
|
||||
},
|
||||
{
|
||||
"module": "okioki",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "openrazer"
|
||||
},
|
||||
{
|
||||
"module": "pacman",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "picom"
|
||||
},
|
||||
{
|
||||
"module": "polychromatic"
|
||||
},
|
||||
{
|
||||
"module": "power",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "powerlevel10k"
|
||||
},
|
||||
{
|
||||
"module": "restic"
|
||||
},
|
||||
{
|
||||
"module": "rofi"
|
||||
},
|
||||
{
|
||||
"module": "screen-lock"
|
||||
},
|
||||
{
|
||||
"module": "sensors"
|
||||
},
|
||||
{
|
||||
"module": "slack"
|
||||
},
|
||||
{
|
||||
"module": "snapd"
|
||||
},
|
||||
{
|
||||
"module": "ssh-client"
|
||||
},
|
||||
{
|
||||
"module": "sshd"
|
||||
},
|
||||
{
|
||||
"module": "sudo"
|
||||
},
|
||||
{
|
||||
"module": "systemd"
|
||||
},
|
||||
{
|
||||
"module": "systemd-resolved"
|
||||
},
|
||||
{
|
||||
"module": "time-sync"
|
||||
},
|
||||
{
|
||||
"module": "triggerhappy"
|
||||
},
|
||||
{
|
||||
"module": "xclip"
|
||||
},
|
||||
{
|
||||
"module": "xdg",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "xorg"
|
||||
},
|
||||
{
|
||||
"module": "xterm"
|
||||
},
|
||||
{
|
||||
"module": "zsh"
|
||||
},
|
||||
{
|
||||
"module": "zsh-autosuggestions"
|
||||
},
|
||||
{
|
||||
"module": "zsh-syntax-highlighting"
|
||||
}
|
||||
],
|
||||
"pins": {
|
||||
"wildcard-resolution": {
|
||||
"node": "novox",
|
||||
"module": "dnsmasq"
|
||||
}
|
||||
},
|
||||
"holds": [
|
||||
{
|
||||
"claim": "bookkeeping",
|
||||
"scope": "mesh",
|
||||
"module": "okioki"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,666 +0,0 @@
|
||||
{
|
||||
"v": 1,
|
||||
"node": "novox",
|
||||
"decided": {
|
||||
"endpoint": "142.132.152.141:51820",
|
||||
"site": "hosting",
|
||||
"hub": true,
|
||||
"address": "10.10.0.1",
|
||||
"public-domain": "novox.be",
|
||||
"account": "jochens",
|
||||
"agent-account": "nox",
|
||||
"tunnel": {
|
||||
"interface": "wg0",
|
||||
"unit": "wg-quick@wg0",
|
||||
"config": "/etc/wireguard/wg0.conf",
|
||||
"port": 51820,
|
||||
"address": "10.10.0.1/24",
|
||||
"range": "10.10.0.0/24",
|
||||
"public-key": "xW0ETQwyR9rHi2XSfOJrKkairE+Fp70FPNNcirqMixY="
|
||||
},
|
||||
"peer-names": {
|
||||
"XT1l25Z0/YhRNQ8FQM+9XsAp7chx5MI0eLOOg1SUEXA=": "shanks",
|
||||
"mq76YPW0FrExhAvt/ZG1SB2BK6/cPISKgXO53SEMazU=": "g14",
|
||||
"zTKYP6Sz1hDwnwxIHgiGXYAeGcWbqti3tcSvKEA0Ew4=": "ace"
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"module": "artifact-store-tools",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "avahi"
|
||||
},
|
||||
{
|
||||
"module": "ca-trust"
|
||||
},
|
||||
{
|
||||
"module": "claude-code",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "claude-licence-manager",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "dbus"
|
||||
},
|
||||
{
|
||||
"module": "de-spiegel",
|
||||
"ports": {
|
||||
"35621": 20005
|
||||
},
|
||||
"bindings": {
|
||||
"smtp": {
|
||||
"node": "novox",
|
||||
"module": "mailu"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "desk-channel",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "dev-tools"
|
||||
},
|
||||
{
|
||||
"module": "disk-load"
|
||||
},
|
||||
{
|
||||
"module": "distribution",
|
||||
"layer": {
|
||||
"ports": {
|
||||
"5000": 5100
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "dnsmasq"
|
||||
},
|
||||
{
|
||||
"module": "docker"
|
||||
},
|
||||
{
|
||||
"module": "fail2ban"
|
||||
},
|
||||
{
|
||||
"module": "gitea",
|
||||
"ports": {
|
||||
"3000": 20000
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
},
|
||||
"layer": {
|
||||
"expose": {
|
||||
"22": "anywhere"
|
||||
},
|
||||
"ports": {
|
||||
"22": 222
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "go"
|
||||
},
|
||||
{
|
||||
"module": "hostname",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "invoicing",
|
||||
"ports": {
|
||||
"80": 20007,
|
||||
"9000": 20008
|
||||
},
|
||||
"bindings": {
|
||||
"mongodb-database": {
|
||||
"node": "novox",
|
||||
"module": "mongodb"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "kernel",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "keycloak",
|
||||
"ports": {
|
||||
"8080": 20009
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "link2pay"
|
||||
},
|
||||
{
|
||||
"module": "localization"
|
||||
},
|
||||
{
|
||||
"module": "logrotate"
|
||||
},
|
||||
{
|
||||
"module": "mailu",
|
||||
"ports": {
|
||||
"110": 110,
|
||||
"143": 143,
|
||||
"25": 25,
|
||||
"4243": 20004,
|
||||
"465": 465,
|
||||
"587": 587,
|
||||
"8080": 20010,
|
||||
"993": 993,
|
||||
"995": 995
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
},
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-go",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-node",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-catalog",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-cli"
|
||||
},
|
||||
{
|
||||
"module": "mesh-config"
|
||||
},
|
||||
{
|
||||
"module": "mesh-controller"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-checker"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-forge-bridge"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-gate",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-indexer"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-teller"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-watchman"
|
||||
},
|
||||
{
|
||||
"module": "mesh-files"
|
||||
},
|
||||
{
|
||||
"module": "mesh-grants"
|
||||
},
|
||||
{
|
||||
"module": "mesh-host",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-issues"
|
||||
},
|
||||
{
|
||||
"module": "mesh-knowledge",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
},
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-reader",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-web",
|
||||
"bindings": {
|
||||
"oidc-client": {
|
||||
"node": "novox",
|
||||
"module": "keycloak"
|
||||
}
|
||||
},
|
||||
"layer": {
|
||||
"endpoints": {
|
||||
"web": {
|
||||
"reach": "internal"
|
||||
}
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"module": "mesh-reads"
|
||||
},
|
||||
{
|
||||
"module": "mesh-vault"
|
||||
},
|
||||
{
|
||||
"module": "mesh-wireguard"
|
||||
},
|
||||
{
|
||||
"module": "messenger",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "minio",
|
||||
"ports": {
|
||||
"9000": 20001,
|
||||
"9001": 20002
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mongodb",
|
||||
"ports": {
|
||||
"27017": 20006
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mssql",
|
||||
"layer": {
|
||||
"ports": {
|
||||
"1433": 4848
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "nats"
|
||||
},
|
||||
{
|
||||
"module": "netcheck"
|
||||
},
|
||||
{
|
||||
"module": "nextcloud",
|
||||
"ports": {
|
||||
"80": 20003
|
||||
},
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "nftables"
|
||||
},
|
||||
{
|
||||
"module": "node-env"
|
||||
},
|
||||
{
|
||||
"module": "node-tools"
|
||||
},
|
||||
{
|
||||
"module": "nodejs"
|
||||
},
|
||||
{
|
||||
"module": "only-office"
|
||||
},
|
||||
{
|
||||
"module": "pacman",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "photos",
|
||||
"ports": {
|
||||
"9000": 20011
|
||||
},
|
||||
"bindings": {
|
||||
"mongodb-database": {
|
||||
"node": "novox",
|
||||
"module": "mongodb"
|
||||
},
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "postgres",
|
||||
"layer": {
|
||||
"ports": {
|
||||
"5432": 6852
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "power",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "powerlevel10k"
|
||||
},
|
||||
{
|
||||
"module": "records",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "restic",
|
||||
"layer": {
|
||||
"places": {
|
||||
"repository": "/home/restic"
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "route-proxy"
|
||||
},
|
||||
{
|
||||
"module": "ssh-client"
|
||||
},
|
||||
{
|
||||
"module": "sshd"
|
||||
},
|
||||
{
|
||||
"module": "step-ca"
|
||||
},
|
||||
{
|
||||
"module": "sudo"
|
||||
},
|
||||
{
|
||||
"module": "systemd"
|
||||
},
|
||||
{
|
||||
"module": "systemd-networkd"
|
||||
},
|
||||
{
|
||||
"module": "telegram"
|
||||
},
|
||||
{
|
||||
"module": "time-sync"
|
||||
},
|
||||
{
|
||||
"module": "umami",
|
||||
"bindings": {
|
||||
"postgres-database": {
|
||||
"node": "novox",
|
||||
"module": "postgres"
|
||||
},
|
||||
"secret": {
|
||||
"node": "novox",
|
||||
"module": "mesh-vault"
|
||||
}
|
||||
},
|
||||
"layer": {
|
||||
"expose": {
|
||||
"3000": "mesh"
|
||||
},
|
||||
"ports": {
|
||||
"3000": 1212
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "website",
|
||||
"ports": {
|
||||
"8080": 20012
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "zsh",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "zsh-autosuggestions"
|
||||
},
|
||||
{
|
||||
"module": "zsh-syntax-highlighting"
|
||||
}
|
||||
],
|
||||
"holds": [
|
||||
{
|
||||
"claim": "anthropic-licence-manager",
|
||||
"scope": "mesh",
|
||||
"module": "claude-licence-manager",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "channel",
|
||||
"scope": "mesh",
|
||||
"module": "desk-channel",
|
||||
"site": "hosting",
|
||||
"kind": "desktop"
|
||||
},
|
||||
{
|
||||
"claim": "channel",
|
||||
"scope": "mesh",
|
||||
"module": "telegram",
|
||||
"site": "hosting",
|
||||
"kind": "telegram"
|
||||
},
|
||||
{
|
||||
"claim": "document-converter",
|
||||
"scope": "mesh",
|
||||
"module": "only-office",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "git",
|
||||
"scope": "mesh",
|
||||
"module": "gitea",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "intake",
|
||||
"scope": "mesh",
|
||||
"module": "desk-channel",
|
||||
"site": "hosting",
|
||||
"kind": "desktop"
|
||||
},
|
||||
{
|
||||
"claim": "intake",
|
||||
"scope": "mesh",
|
||||
"module": "telegram",
|
||||
"site": "hosting",
|
||||
"kind": "telegram"
|
||||
},
|
||||
{
|
||||
"claim": "issue-tracker",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-issues",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "knowledge-base",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-knowledge",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mail-reading",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-mail-reader",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mail-store",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-mail",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mail-webclient",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-mail-web",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-artifact-store",
|
||||
"scope": "mesh",
|
||||
"module": "distribution",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-broker",
|
||||
"scope": "mesh",
|
||||
"module": "nats",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-catalog",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-catalog",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-config",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-config",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-controller",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-controller",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-delivery",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-delivery-gate",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-dns-resolver",
|
||||
"scope": "mesh",
|
||||
"module": "dnsmasq",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-provisioner",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-provisioner",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-store",
|
||||
"scope": "mesh",
|
||||
"module": "postgres",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "mesh-vault",
|
||||
"scope": "mesh",
|
||||
"module": "mesh-vault",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "npm-package-registry",
|
||||
"scope": "mesh",
|
||||
"module": "gitea",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "object-store",
|
||||
"scope": "mesh",
|
||||
"module": "minio",
|
||||
"site": "hosting"
|
||||
},
|
||||
{
|
||||
"claim": "operator-channel",
|
||||
"scope": "mesh",
|
||||
"module": "messenger",
|
||||
"site": "hosting"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
{
|
||||
"v": 1,
|
||||
"node": "shanks",
|
||||
"decided": {
|
||||
"site": "home",
|
||||
"address": "10.10.0.3",
|
||||
"account": "jochen",
|
||||
"tunnel": {
|
||||
"interface": "wg0",
|
||||
"unit": "wg-quick@wg0",
|
||||
"config": "/etc/wireguard/wg0.conf",
|
||||
"port": 51820,
|
||||
"address": "10.10.0.3/24",
|
||||
"range": "10.10.0.0/24",
|
||||
"public-key": "XT1l25Z0/YhRNQ8FQM+9XsAp7chx5MI0eLOOg1SUEXA="
|
||||
}
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"module": "adwaita"
|
||||
},
|
||||
{
|
||||
"module": "avahi"
|
||||
},
|
||||
{
|
||||
"module": "blueman"
|
||||
},
|
||||
{
|
||||
"module": "bluetooth"
|
||||
},
|
||||
{
|
||||
"module": "brother",
|
||||
"bindings": {
|
||||
"s3-bucket": {
|
||||
"node": "novox",
|
||||
"module": "minio"
|
||||
}
|
||||
},
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "buildah"
|
||||
},
|
||||
{
|
||||
"module": "ca-trust"
|
||||
},
|
||||
{
|
||||
"module": "claude-code",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "clipmenu"
|
||||
},
|
||||
{
|
||||
"module": "cups"
|
||||
},
|
||||
{
|
||||
"module": "dbus"
|
||||
},
|
||||
{
|
||||
"module": "dev-tools"
|
||||
},
|
||||
{
|
||||
"module": "disk-load"
|
||||
},
|
||||
{
|
||||
"module": "dmenu"
|
||||
},
|
||||
{
|
||||
"module": "docker"
|
||||
},
|
||||
{
|
||||
"module": "docker-compose"
|
||||
},
|
||||
{
|
||||
"module": "dunst"
|
||||
},
|
||||
{
|
||||
"module": "fail2ban"
|
||||
},
|
||||
{
|
||||
"module": "feh"
|
||||
},
|
||||
{
|
||||
"module": "flatpak"
|
||||
},
|
||||
{
|
||||
"module": "fonts"
|
||||
},
|
||||
{
|
||||
"module": "forticlient"
|
||||
},
|
||||
{
|
||||
"module": "gnome-keyring"
|
||||
},
|
||||
{
|
||||
"module": "go"
|
||||
},
|
||||
{
|
||||
"module": "hostname",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "i3"
|
||||
},
|
||||
{
|
||||
"module": "i3status-rust"
|
||||
},
|
||||
{
|
||||
"module": "kernel",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "lab",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
},
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "lemurs"
|
||||
},
|
||||
{
|
||||
"module": "localization"
|
||||
},
|
||||
{
|
||||
"module": "logrotate"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-go",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-image"
|
||||
},
|
||||
{
|
||||
"module": "mesh-build-node",
|
||||
"bindings": {
|
||||
"npm-package-registry": {
|
||||
"node": "novox",
|
||||
"module": "gitea"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"module": "mesh-cli"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-builder"
|
||||
},
|
||||
{
|
||||
"module": "mesh-delivery-view"
|
||||
},
|
||||
{
|
||||
"module": "mesh-files"
|
||||
},
|
||||
{
|
||||
"module": "mesh-host",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "mesh-mail-notifier"
|
||||
},
|
||||
{
|
||||
"module": "mesh-provisioner-client"
|
||||
},
|
||||
{
|
||||
"module": "mesh-reads"
|
||||
},
|
||||
{
|
||||
"module": "mesh-wireguard"
|
||||
},
|
||||
{
|
||||
"module": "mounts",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "netcheck"
|
||||
},
|
||||
{
|
||||
"module": "networkmanager"
|
||||
},
|
||||
{
|
||||
"module": "nextcloud-client"
|
||||
},
|
||||
{
|
||||
"module": "nftables"
|
||||
},
|
||||
{
|
||||
"module": "nm-applet"
|
||||
},
|
||||
{
|
||||
"module": "node-env"
|
||||
},
|
||||
{
|
||||
"module": "node-tools"
|
||||
},
|
||||
{
|
||||
"module": "nodejs"
|
||||
},
|
||||
{
|
||||
"module": "openrazer"
|
||||
},
|
||||
{
|
||||
"module": "pacman",
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "picom"
|
||||
},
|
||||
{
|
||||
"module": "polychromatic"
|
||||
},
|
||||
{
|
||||
"module": "power",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "powerlevel10k"
|
||||
},
|
||||
{
|
||||
"module": "restic"
|
||||
},
|
||||
{
|
||||
"module": "rofi"
|
||||
},
|
||||
{
|
||||
"module": "screen-lock"
|
||||
},
|
||||
{
|
||||
"module": "slack"
|
||||
},
|
||||
{
|
||||
"module": "snapd"
|
||||
},
|
||||
{
|
||||
"module": "ssh-client"
|
||||
},
|
||||
{
|
||||
"module": "sshd"
|
||||
},
|
||||
{
|
||||
"module": "sudo"
|
||||
},
|
||||
{
|
||||
"module": "systemd"
|
||||
},
|
||||
{
|
||||
"module": "systemd-resolved"
|
||||
},
|
||||
{
|
||||
"module": "time-sync"
|
||||
},
|
||||
{
|
||||
"module": "xclip"
|
||||
},
|
||||
{
|
||||
"module": "xdg",
|
||||
"has-mesh-wide": true,
|
||||
"has-layer": true
|
||||
},
|
||||
{
|
||||
"module": "xorg"
|
||||
},
|
||||
{
|
||||
"module": "xterm"
|
||||
},
|
||||
{
|
||||
"module": "zsh"
|
||||
},
|
||||
{
|
||||
"module": "zsh-autosuggestions"
|
||||
},
|
||||
{
|
||||
"module": "zsh-syntax-highlighting"
|
||||
}
|
||||
],
|
||||
"pins": {
|
||||
"wildcard-resolution": {
|
||||
"node": "novox",
|
||||
"module": "dnsmasq"
|
||||
}
|
||||
},
|
||||
"holds": [
|
||||
{
|
||||
"claim": "printer",
|
||||
"scope": "mesh",
|
||||
"module": "brother",
|
||||
"site": "home"
|
||||
},
|
||||
{
|
||||
"claim": "scanner",
|
||||
"scope": "mesh",
|
||||
"module": "brother",
|
||||
"site": "home"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user