Compare commits

..
3 Commits
Author SHA1 Message Date
jschoubben de2de44c98 mesh-build-go: give each ask a cache layer in its workspace, so what its digest and vet compile its tests find compiled
Pins mesh-sdk fix/check-speed-s2 (2a867018), whose Isolation.CacheLayer keeps what one
command of an ask writes into the caches for the ask's later commands, the shared cache
unchanged. A trunk build and the lint's vet still write the shared cache directly.
Ported from feat/try-speed S2 (issue 1921; ADR 0541 §2).
2026-10-29 00:52:25 +01:00
jschoubben 1989dd7cf2 mesh-build-go: start the integration level's store and bus beside its compile, and compile the go.mod directories side by side
The setup started the store, then the bus, then compiled each directory one after
another: 132 s of setup in the L3 group check. Now the three start together and the
directories compile up to the bound at once; the compile runs no test binary
(-exec true), so it needs neither server and is given nothing a test reads. Ported
from feat/try-speed S5 (issue 1921; ADR 0541 §1).
2026-10-29 00:48:50 +01:00
jschoubben 31c860bc92 mesh-build-go: let a check's go vet warm the shared Go build cache with cgo off, and say each linted directory as it ends
A check's lint recompiled every package from a cold layer on each ask (g14 linted
mesh-catalog's 8 directories in 141 s where ace, warm, took 17 s), and said nothing
until it ended. ADR 0541 §2 and ADR 0542 §1 decided this; the built speed-up on
feat/try-speed (S11) was parked. Ported here alone: CGO_ENABLED=0 in every check
command's go environment, vet runs with the shared caches writable, and the lint
says a phase per finished directory (issue 1921).
2026-10-29 00:46:19 +01:00
30 changed files with 416 additions and 636 deletions
-26
View File
@@ -62,29 +62,3 @@ func TestEveryFileTakesItsTextFromATemplate(t *testing.T) {
t.Errorf("the build packs files/ into %d archives, not one", packs) t.Errorf("the build packs files/ into %d archives, not one", packs)
} }
} }
// The forge's sshd takes a burst of the mesh's machines fetching at once: the image's MaxStartups raised from OpenSSH's
// default 10:30:100 to 100:30:200 through SSH_MAX_STARTUPS, which dropped ace's thirteen connections at once.
func TestTheForgesSshdTakesABurstOfTheMeshsMachines(t *testing.T) {
raw, err := os.ReadFile(filepath.Join(moduleDir, "module.json"))
if err != nil {
t.Fatal(err)
}
var m struct {
Resources []map[string]any `json:"resources"`
}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
for _, r := range m.Resources {
if r["id"] != "server" {
continue
}
env, _ := r["env"].(map[string]any)
if env["SSH_MAX_STARTUPS"] != "100:30:200" {
t.Errorf("SSH_MAX_STARTUPS is %v, not 100:30:200", env["SSH_MAX_STARTUPS"])
}
return
}
t.Error("the manifest has no server container")
}
+1 -2
View File
@@ -266,8 +266,7 @@
"env": { "env": {
"DB_TYPE": "postgres", "DB_TYPE": "postgres",
"USER_UID": "1000", "USER_UID": "1000",
"USER_GID": "1000", "USER_GID": "1000"
"SSH_MAX_STARTUPS": "100:30:200"
}, },
"env-file": [ "env-file": [
"${dir:state}/server.env" "${dir:state}/server.env"
@@ -296,6 +296,7 @@ func (m *Module) goEnv() []string {
modcache = m.GoModCache modcache = m.GoModCache
} }
return append([]string{ 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", "GOTOOLCHAIN=local",
"GOFLAGS=-mod=mod", "GOFLAGS=-mod=mod",
"GOMODCACHE=" + modcache, "GOMODCACHE=" + modcache,
@@ -12,6 +12,7 @@ import (
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"sync"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/buildstep" "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 { if err != nil {
return nil, err 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, nil)...)
// Compiling counts as setup: the tests are built once, here, with the addresses a TestMain may read, and the run
// below finds them built. Whether they compile is for the run to say; its failure is not read here.
// Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory. // Only the directories the selected packages lie in compile (ADR 0536 §3); no selection is every directory.
byDir, err := packagesByDir(tree, dirs, packages) byDir, err := packagesByDir(tree, dirs, packages)
if err != nil { if err != nil {
return nil, err 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 { for _, d := range dirs {
if len(byDir[d]) == 0 { if len(byDir[d]) == 0 {
continue 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) setup := m.clock().Sub(began)
return m.runLevel(ctx, tree, levelOpts{ return m.runLevel(ctx, tree, levelOpts{
@@ -35,6 +35,9 @@ type integFake struct {
// command runs, with its line. // command runs, with its line.
gomods string gomods string
onTest func(line 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 { 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: f.gomods}
} }
return forgekey.Ran{Stdout: "go.mod\n"} 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") f.tick("compile")
case name == "go": case name == "go":
if f.onTest != nil { 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} return forgekey.Ran{Stdout: f.captured, Status: status}
case name == "pg_ctl" && strings.HasSuffix(line, " start"): case name == "pg_ctl" && strings.HasSuffix(line, " start"):
if f.onStart != nil {
f.onStart()
}
f.tick("start") f.tick("start")
for i, a := range args { for i, a := range args {
if a == "-D" { if a == "-D" {
@@ -314,8 +323,8 @@ func TestAStoreThatDoesNotStartEndsTheStepFailedAndLeavesNothing(t *testing.T) {
if !strings.Contains(f.lines(), " stop") { if !strings.Contains(f.lines(), " stop") {
t.Errorf("the half-started store was not stopped:\n%s", f.lines()) t.Errorf("the half-started store was not stopped:\n%s", f.lines())
} }
if f.busDir != "" { if f.busDir != "" && !f.busDown {
t.Errorf("the bus was started after the store failed") 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" { 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) 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, // 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). // a locale alone (the step's HOME is its own, inside its /tmp).
func (m *Module) isolation(workspace string, write bool) buildstep.Isolation { func (m *Module) isolation(workspace string, write bool) buildstep.Isolation {
path := os.Getenv("PATH") path := os.Getenv("PATH")
if path == "" { if path == "" {
path = defaultPath path = defaultPath
} }
return buildstep.Isolation{ iso := buildstep.Isolation{
Workspace: filepath.Join(m.Work, workspace), Workspace: filepath.Join(m.Work, workspace),
Caches: m.caches(), Caches: m.caches(),
WriteCaches: write, WriteCaches: write,
Env: append([]string{"PATH=" + path, "LC_ALL=C"}, m.threadEnv()...), 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 // 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. // 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) { func (m *Module) stepRunner(workspace string, write bool) (forgekey.Runner, error) {
if m.Inside { if m.Inside {
@@ -20,6 +20,7 @@ import (
type shutOff struct { type shutOff struct {
flags []string flags []string
binds, roBinds, overlays []string binds, roBinds, overlays []string
uppers map[string]string // the writable layer each --overlay gives a cache: cache -> upper
env []string env []string
command string command string
chdir string chdir string
@@ -27,7 +28,7 @@ type shutOff struct {
// bwrapArity is how many words each bubblewrap option the SDK writes takes. // 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, 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} "--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 // 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]) s.roBinds = append(s.roBinds, args[i+1])
case "--overlay-src": case "--overlay-src":
s.overlays = append(s.overlays, args[i+1]) 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": case "--setenv":
s.env = append(s.env, args[i+1]+"="+args[i+2]) s.env = append(s.env, args[i+1]+"="+args[i+2])
case "--chdir": 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) 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): 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) 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) { 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 { if ran == 0 || len(seen) != ran {
t.Fatalf("%d go commands ran, %d of them shut off: %v", ran, len(seen), *calls) 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 { 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")
} }
} }
@@ -13,6 +13,7 @@ import (
"sort" "sort"
"strings" "strings"
"sync" "sync"
"time"
"git.novox.be/novox/mesh-sdk/go/buildstep" "git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/forgekey" "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 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 crash any // a panic of a lint goes on in the caller, as a panic of a test run does
var crashMu sync.Mutex 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 { for i, d := range dirs {
wg.Add(1) wg.Add(1)
slots <- struct{}{} slots <- struct{}{}
@@ -157,7 +170,9 @@ func (m *Module) lintAll(ctx context.Context, run forgekey.Runner, tree string,
crashMu.Unlock() crashMu.Unlock()
} }
}() }()
began := m.clock()
answers[i].failed, answers[i].err = m.lint(ctx, run, tree, d, env(d)) answers[i].failed, answers[i].err = m.lint(ctx, run, tree, d, env(d))
said(d, began)
}() }()
} }
wg.Wait() wg.Wait()
@@ -290,8 +290,8 @@ func TestTheIntegrationLevelIsGivenTheBesideDirectoryOnlyWhenSiblingsWerePlaced(
} }
tested := 0 tested := 0
for _, c := range f.calls { for _, c := range f.calls {
if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") { if !strings.HasPrefix(c.line, "go ") || !strings.Contains(c.line, "-tags integration") || strings.Contains(c.line, "-exec true") {
continue continue // the setup's compile runs no test binary, so it is given nothing a test reads (issue 1921, S5)
} }
tested++ tested++
got := "" got := ""
@@ -72,7 +72,7 @@ func TestOnlyTheSelectedDirectoryCompilesAndRuns(t *testing.T) {
if !strings.HasPrefix(l, "go -C "+filepath.Join(tree, "b")+" test ") { if !strings.HasPrefix(l, "go -C "+filepath.Join(tree, "b")+" test ") {
t.Errorf("go test ran outside the selected directory: %s", l) t.Errorf("go test ran outside the selected directory: %s", l)
} }
if strings.Contains(l, "-run ^$") { if strings.Contains(l, "-exec true") {
compiled++ compiled++
} else { } else {
ran++ ran++
@@ -91,7 +91,7 @@ func TestWithoutASelectionEveryDirectoryCompilesAndRuns(t *testing.T) {
compiled, ran := 0, 0 compiled, ran := 0, 0
for _, l := range strings.Split(f.lines(), "\n") { for _, l := range strings.Split(f.lines(), "\n") {
if strings.HasPrefix(l, "go ") && strings.Contains(l, " test ") { if strings.HasPrefix(l, "go ") && strings.Contains(l, " test ") {
if strings.Contains(l, "-run ^$") { if strings.Contains(l, "-exec true") {
compiled++ compiled++
} else { } else {
ran++ 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) { func TestTheDirectoriesAtOnceNeverPassTheBound(t *testing.T) {
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") f := newIntegFake(t, "slow")
var mu sync.Mutex var mu sync.Mutex
running, most := 0, 0 running, most := 0, 0
f.onTest = func(string) { reached := make(chan struct{})
storeStarting, beside := false, false
count := func() {
mu.Lock() mu.Lock()
running++ running++
if running > most { most = max(most, running)
most = running if running == tc.want && tc.want > 1 {
close(reached)
} }
mu.Unlock() 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) time.Sleep(20 * time.Millisecond)
}
mu.Lock() mu.Lock()
running-- running--
mu.Unlock() 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, states := threeModules(t, f)
m.Parallel = 1 m.Parallel = tc.bound
runIntegrationOf(t, m, states) if _, st := runIntegrationOf(t, m, states); st.State != delivery.StepPassed {
if most != 1 { t.Fatalf("the step ended %s: %s", st.State, st.Reason)
t.Errorf("%d go test commands ran at once with a bound of 1", most) }
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())
}
})
} }
} }
@@ -251,8 +251,15 @@ func (m *Module) runLevel(ctx context.Context, tree string, o levelOpts) (*TestR
if o.level == LevelUnit { 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 // 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. // 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() 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...) return append(m.goEnvFor(filepath.Join(tree, d)), o.env...)
}) })
if err != nil { if err != nil {
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028235126-2a8670189188
github.com/distribution/distribution/v3 v3.1.2 github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
+4 -2
View File
@@ -1,5 +1,7 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= git.novox.be/novox/mesh-sdk/go v0.1.14-0.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 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 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-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig= 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.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= 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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5
github.com/distribution/distribution/v3 v3.1.2 github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig= 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.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= 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 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI=
github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
+3 -8
View File
@@ -21,16 +21,11 @@ account alone writes its buckets.
## How a value resolves ## How a value resolves
For every module the seat mesh-placement places on a node (`mesh-delivery-assigner.placement`), from the module's From the controller's mirrors of the assignments and of each module's declarations (`mesh-controller.assignments`,
own manifests as the delivery built them, never from the controller (issue 1953): the trunk's, which the indexer's `mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back
`modules.<module>` names in its manifests by digest (`mesh-delivery-indexer.modules`, `.manifests`), with the build the
node's want names (`mesh-delivery-assigner.wants`, the builder's `mesh-delivery-builder.builds` naming its manifest)
laid over it key by key on that node. So a module from its own repository resolves as a catalogue module does. A placed
module none of whose manifests can be read is listed under `unresolved` with why and said in the log, and keeps the
values last resolved for it. Then: the manifest's default; mesh config for the predefined key the value falls back
to; node config for that key on the node; the module's own value on that node, the most specific winning. A value to; node config for that key on the node; the module's own value on that node, the most specific winning. A value
naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at
start, whenever a placement, a want or a trunk module moves, and every five minutes, writing only the values that moved. start, whenever a mirror moves, and every five minutes, writing only the values that moved.
## The verb ## The verb
@@ -97,10 +97,6 @@ type Config struct {
RouterWithin, RouterEvery time.Duration RouterWithin, RouterEvery time.Duration
endedMu sync.Mutex endedMu sync.Mutex
ended map[string]endedWord ended map[string]endedWord
// manifests is each manifest's declarations read, by digest: a manifest never changes (manifestDeclarations).
manifestsMu sync.Mutex
manifests map[string]ModuleDeclarations
} }
// Call is one call of the verb, its arguments read. // Call is one call of the verb, its arguments read.
@@ -340,8 +336,8 @@ func (c *Config) declared(s Sources, level Level, key string) (rule, bool, error
joinKeys(decl.Config)) joinKeys(decl.Config))
} }
if !assigned(s, level.Node, level.Module) { if !assigned(s, level.Node, level.Module) {
return rule{}, false, fmt.Errorf("%s is not placed on %s, and a module's own value is set for a node it "+ return rule{}, false, fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node it "+
"is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node, "is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
strings.Join(nodesOf(s, level.Module), ", ")) strings.Join(nodesOf(s, level.Module), ", "))
} }
trusted := d.Trusted trusted := d.Trusted
@@ -9,8 +9,6 @@ import (
"sync" "sync"
"testing" "testing"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log, // memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log,
@@ -122,81 +120,9 @@ func (m *memStore) mirror(t *testing.T, read, key, raw string) {
m.states[read][key] = []byte(raw) m.states[read][key] = []byte(raw)
} }
// assign places a module on a node, in the seat mesh-placement's key for the node.
func (m *memStore) assign(t *testing.T, node, module string) { func (m *memStore) assign(t *testing.T, node, module string) {
t.Helper() m.mirror(t, AssignmentsRead, node+"."+module,
m.place(t, node, module, true) fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module))
}
// unassign takes a module off a node's placement.
func (m *memStore) unassign(t *testing.T, node, module string) {
t.Helper()
m.place(t, node, module, false)
}
func (m *memStore) place(t *testing.T, node, module string, on bool) {
t.Helper()
type placed struct {
Module string `json:"module"`
}
p := struct {
V int `json:"v"`
Node string `json:"node"`
Modules []placed `json:"modules"`
}{V: 1, Node: node}
if e, _ := m.Get(PlacementRead, "placement."+node); e != nil {
if err := json.Unmarshal(e.Value, &p); err != nil {
t.Fatal(err)
}
}
var kept []placed
for _, x := range p.Modules {
if x.Module != module {
kept = append(kept, x)
}
}
if on {
kept = append(kept, placed{Module: module})
}
p.Modules = kept
if err := m.Put(PlacementRead, "placement."+node, p); err != nil {
t.Fatal(err)
}
}
// manifest keeps a manifest in the indexer's manifests by its digest, as the indexer writes it, and answers the digest.
func (m *memStore) manifest(t *testing.T, module, raw string) string {
t.Helper()
if !json.Valid([]byte(raw)) {
t.Fatalf("not JSON: %s", raw)
}
digest := delivery.ManifestDigest([]byte(raw))
value, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw})
if err != nil {
t.Fatal(err)
}
m.mirror(t, ManifestsRead, digest, string(value))
return digest
}
// declare makes raw the module's manifest at the trunk: kept by digest, and named by the indexer's modules.<module>.
func (m *memStore) declare(t *testing.T, module, raw string) {
t.Helper()
digest := m.manifest(t, module, raw)
m.mirror(t, ModulesRead, module, fmt.Sprintf(`{"v":1,"manifest":%q,"repository":"novox/%s","commit":"c1"}`,
digest, module))
}
// want makes a node's want name a build of the module whose manifest is raw, as the assigner and the builder keep them.
func (m *memStore) want(t *testing.T, node, module, fp, raw string) {
t.Helper()
digest := m.manifest(t, module, raw)
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
t.Fatal(err)
}
m.mirror(t, BuildsRead, key, fmt.Sprintf(`{"v":1,"state":"done","manifest":%q}`, digest))
m.mirror(t, WantsRead, "want."+node+"."+module, fmt.Sprintf(`{"v":1,"fp":%q,"self":true}`, fp))
} }
func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved { func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved {
@@ -228,10 +154,10 @@ const theList = `{"keys":{"agent-managed-settings":{"type":"object","description
func aMesh(t *testing.T) (*Config, *memStore) { func aMesh(t *testing.T) (*Config, *memStore) {
t.Helper() t.Helper()
m := newStore() m := newStore()
m.declare(t, "dunst", dunstDeclared) m.mirror(t, DeclaredConfigRead, "dunst", dunstDeclared)
m.declare(t, "claude-code", `{"module":"claude-code","settings":{ m.mirror(t, DeclaredConfigRead, "claude-code", `{"module":"claude-code","settings":{
"managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`) "managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`)
m.declare(t, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`) m.mirror(t, DeclaredConfigRead, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList)
for _, node := range []string{"g14", "novox"} { for _, node := range []string{"g14", "novox"} {
m.assign(t, node, "dunst") m.assign(t, node, "dunst")
@@ -372,8 +298,8 @@ func TestTheVerbRefusesWhatADeclarationDoesNotAllow(t *testing.T) {
{"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"}, {"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"},
{"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"}, {"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"},
"no module value for the whole mesh"}, "no module value for the whole mesh"},
{"a module on a node it is not placed on", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"}, {"a module on a node it is not assigned to", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
"it is placed on: g14, novox"}, "it is assigned to: g14, novox"},
{"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"}, {"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"},
{"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`}, {"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`},
{"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"}, {"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"},
@@ -546,7 +472,7 @@ func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T
} }
} }
// **A placement, a declaration or its removal moves the resolved values**, written only where they moved, and // **An assignment, a declaration or its removal moves the resolved values**, written only where they moved, and
// said as config-changed; a pass over unchanged sources writes nothing. // said as config-changed; a pass over unchanged sources writes nothing.
func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) { func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) {
c, m := aMesh(t) c, m := aMesh(t)
@@ -563,7 +489,7 @@ func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing
if len(m.events) != 1 || m.events[0].Action != "resolve" { if len(m.events) != 1 || m.events[0].Action != "resolve" {
t.Errorf("an assignment raised %+v", m.events) t.Errorf("an assignment raised %+v", m.events)
} }
m.unassign(t, "ace", "dunst") _ = m.Delete(AssignmentsRead, "ace.dunst")
if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone { if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("an unassignment moved %+v", r.Moved) t.Errorf("an unassignment moved %+v", r.Moved)
} }
@@ -626,110 +552,42 @@ func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) {
} }
} }
// despiegel is de-spiegel's manifest as its own repository declares it (novox/de-spiegel at e25e1beb, its config // Issue 1098: a value of the assignments bucket with `ended` set, or with a `state`, is not an assignment: it
// alone): required trusted values, no default. // resolves nothing and takes the module's resolved values away, as a removed key does.
const despiegel = `{"module":"de-spiegel","version":"1","requires":["route","smtp"],"config":{ func TestAnEndedAssignmentIsNotAnAssignment(t *testing.T) {
"praktijk-name":{"type":"string","description":"the practice's public name","trusted":true,"required":true}, c, m := aMesh(t)
"praktijk-www-name":{"type":"string","description":"its www form","trusted":true,"required":true}}}` m.assign(t, "ace", "dunst")
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
// Issue 1953: **each placed module's declarations come from its own manifests as built**, the trunk's at the indexer t.Fatalf("the assignment moved %+v (%v)", r.Moved, err)
// and the wanted build's at the builder, never from the controller's mirror; a module placed with no manifest to read
// is said unresolved with why, in the listing and the log, and keeps what was resolved for it.
func TestDeclarationsComeFromTheManifestsAsBuilt(t *testing.T) {
fp := "src2:" + strings.Repeat("ab", 32)
cases := []struct {
name string
given func(t *testing.T, m *memStore)
// resolved is each key's resolved value on novox, `<module>.<key>` to source and value ("" for none).
resolved map[string]string
absent []string
unresolved string
}{
{name: "a module of its own repository resolves from the trunk's manifest, its value set",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
must(t, m.Put(ValuesState, Level{Kind: LevelModule, Node: "novox", Module: "de-spiegel"}.ValueKey(
"praktijk-name"), Value{Value: json.RawMessage(`"praktijkdespiegel.be"`), By: "the operator"}))
// The controller's mirror says otherwise, and is not read.
m.mirror(t, "mesh-controller.declared-config", "de-spiegel", `{"module":"de-spiegel","settings":{}}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": `module "praktijkdespiegel.be"`,
"de-spiegel.praktijk-www-name": "unset "}},
{name: "the wanted build's manifest lays its keys over the trunk's on its node",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
"praktijk-name":{"type":"string","description":"d","default":"old.example"},
"old-key":{"type":"number","description":"d","default":3}}}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": `default "old.example"`,
"de-spiegel.old-key": "default 3", "de-spiegel.praktijk-www-name": "unset "}},
{name: "a removed want resolves from the trunk alone",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
"old-key":{"type":"number","description":"d","default":3}}}`)
m.mirror(t, WantsRead, "want.novox.de-spiegel", `{"v":1,"removed":true}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "}, absent: []string{"de-spiegel.old-key"}},
{name: "a placed module the indexer holds no manifest of is unresolved, said, its values kept",
given: func(t *testing.T, m *memStore) {
must(t, m.Put(ResolvedState, ResolvedKey("novox", "de-spiegel", "praktijk-name"),
Resolved{Node: "novox", Module: "de-spiegel", Key: "praktijk-name", Source: SourceUnset}))
m.assign(t, "novox", "de-spiegel")
},
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "},
unresolved: "the indexer holds no modules.de-spiegel"},
} }
for _, tc := range cases { for _, ended := range []string{`"ended":"2026-10-18T01:00:00Z"`, `"state":"pending"`} {
t.Run(tc.name, func(t *testing.T) { m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+ended+`}`)
m := newStore() r, err := c.Reconcile()
tc.given(t, m) if err != nil || len(r.Moved) != 4 || !r.Moved[0].Gone {
var said []string t.Errorf("%s moved %+v (%v)", ended, r.Moved, err)
c := &Config{Store: m, Now: time.Now, Logf: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }}
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
} }
for k, want := range tc.resolved { if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil {
module, key, _ := strings.Cut(k, ".") t.Errorf("%s: the module keeps its resolved values", ended)
r := m.resolved(t, "novox", module, key)
if got := r.Source + " " + string(r.Value); got != want {
t.Errorf("%s resolved as %s, want %s", k, got, want)
} }
m.assign(t, "ace", "dunst")
if r, _ = c.Reconcile(); len(r.Moved) != 4 {
t.Fatalf("the assignment standing again moved %+v", r.Moved)
} }
for _, k := range tc.absent {
module, key, _ := strings.Cut(k, ".")
if e, _ := m.Get(ResolvedState, ResolvedKey("novox", module, key)); e != nil {
t.Errorf("%s is resolved: %s", k, e.Value)
}
}
l, err := c.List(Call{Module: "de-spiegel"})
if err != nil {
t.Fatal(err)
}
if tc.unresolved == "" {
if len(l.Unresolved) != 0 {
t.Errorf("unresolved: %+v", l.Unresolved)
}
return
}
if len(l.Unresolved) != 1 || l.Unresolved[0].Node != "novox" ||
!strings.Contains(l.Unresolved[0].Why, tc.unresolved) {
t.Errorf("the listing says %+v, want %q", l.Unresolved, tc.unresolved)
}
if !strings.Contains(strings.Join(said, "\n"), tc.unresolved) {
t.Errorf("the log does not say it: %q", said)
}
})
} }
} }
func must(t *testing.T, err error) { // Issue 1098: the fields the controller adds to an assignment change nothing, under the same key.
t.Helper() func TestAnAssignmentCarriesBuildCommitAndRolloutWithoutChangingItsKey(t *testing.T) {
if err != nil { c, m := aMesh(t)
t.Fatal(err) m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+
`"build":"b-1","artifact":{"name":"dunst","version":"1.2"},"commit":"abc123","rollout":"r-9",`+
`"since":"2026-10-18T00:00:00Z","ended":"","state":"","future":{"x":1}}`)
r, err := c.Reconcile()
if err != nil || len(r.Moved) != 4 {
t.Fatalf("an assignment with the new fields moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
if r, _ = c.Reconcile(); len(r.Moved) != 0 {
t.Errorf("a quiet pass moved %+v", r.Moved)
} }
} }
+18 -31
View File
@@ -11,8 +11,8 @@ import (
"strings" "strings"
) )
// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations as its manifests declare // What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations, the list of predefined
// them, the list of predefined keys, and where each module is placed (issue 1953). // keys, and the assignments, as the controller mirrors them on the bus (design 57 F3, K3).
// The types a config value may have (ADR 0348 §4). // The types a config value may have (ADR 0348 §4).
const ( const (
@@ -69,34 +69,7 @@ type ModuleDeclarations struct {
Config map[string]Declaration `json:"settings"` Config map[string]Declaration `json:"settings"`
} }
// ManifestDeclarations is the config a manifest declares (ADR 0348 §1, §4): its `config`, or for a module not yet // ParseModuleDeclarations reads one key of the declared-config mirror.
// moved its `settings` (kind, default and why), the two never both. Only these two fields are read, so a manifest
// field this module's SDK does not know yet never leaves a module's config unresolved.
func ManifestDeclarations(raw []byte) (ModuleDeclarations, error) {
var m struct {
Module string `json:"module"`
Config map[string]Declaration `json:"config"`
Settings map[string]Declaration `json:"settings"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return ModuleDeclarations{}, fmt.Errorf("not a manifest: %w", err)
}
if m.Config != nil && m.Settings != nil {
return ModuleDeclarations{}, fmt.Errorf("%s declares both config and settings, and a module uses one form "+
"(novox/hq ADR 0348 §1)", m.Module)
}
decl := m.Config
if decl == nil {
decl = m.Settings
}
raw, err := json.Marshal(ModuleDeclarations{Module: m.Module, Config: decl})
if err != nil {
return ModuleDeclarations{}, err
}
return ParseModuleDeclarations(raw)
}
// ParseModuleDeclarations reads one module's declarations in the layout ModuleDeclarations writes.
func ParseModuleDeclarations(raw []byte) (ModuleDeclarations, error) { func ParseModuleDeclarations(raw []byte) (ModuleDeclarations, error) {
var m ModuleDeclarations var m ModuleDeclarations
if err := json.Unmarshal(raw, &m); err != nil { if err := json.Unmarshal(raw, &m); err != nil {
@@ -148,10 +121,24 @@ type Published struct {
Commit string `json:"commit"` Commit string `json:"commit"`
} }
// Assignment is a module placed on a node, as the seat mesh-placement's key `placement.<node>` names it. // Assignment is one key of the assignments bucket: a module on a node. The value also carries the build, commit and
// rollout that put it there and more; those are read where they are reported, and an unknown field never fails a
// parse. **Ended** and **State** are set on a value that is not a standing assignment (novox/hq ADR 0408 clause 4):
// it reads exactly as a removed key (issue 1098).
type Assignment struct { type Assignment struct {
Node string `json:"node"` Node string `json:"node"`
Module string `json:"module"` Module string `json:"module"`
Ended json.RawMessage `json:"ended,omitempty"`
State json.RawMessage `json:"state,omitempty"`
}
// Standing is whether the value is a standing assignment: neither ended nor in a state.
func (a Assignment) Standing() bool { return !fieldSet(a.Ended) && !fieldSet(a.State) }
// fieldSet is whether a field of an assignment's value carries something: not absent, null or empty.
func fieldSet(f json.RawMessage) bool {
s := strings.TrimSpace(string(f))
return s != "" && s != "null" && s != `""`
} }
// rule is the validation a value is held to, from a module's declaration or a predefined key. // rule is the validation a value is held to, from a module's declaration or a predefined key.
+3 -11
View File
@@ -17,8 +17,6 @@ type Listed struct {
Predefined map[string]Published `json:"predefined,omitempty"` Predefined map[string]Published `json:"predefined,omitempty"`
// Withheld is why the newest list of predefined keys is not published, when it is not. // Withheld is why the newest list of predefined keys is not published, when it is not.
Withheld string `json:"withheld,omitempty"` Withheld string `json:"withheld,omitempty"`
// Unresolved is each placed module the call narrows to whose declarations cannot be read, with why (issue 1953).
Unresolved []Unresolved `json:"unresolved,omitempty"`
// Held is the approved values held for open pull requests, not yet set (novox/hq ADR 0502). // Held is the approved values held for open pull requests, not yet set (novox/hq ADR 0502).
Held []HeldValue `json:"held,omitempty"` Held []HeldValue `json:"held,omitempty"`
} }
@@ -44,18 +42,12 @@ func (c *Config) List(call Call) (*Listed, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
for _, u := range s.Unresolved {
if (call.Module == "" || u.Module == call.Module) && (call.Node == "" || u.Node == call.Node) {
out.Unresolved = append(out.Unresolved, u)
}
}
if call.Module != "" { if call.Module != "" {
if _, ok := s.Declared[call.Module]; !ok && len(out.Unresolved) == 0 { if _, ok := s.Declared[call.Module]; !ok {
return nil, fmt.Errorf("%s is placed on no node with a manifest the indexer holds, so it declares no "+ return nil, fmt.Errorf("%s is not a registered module, so it declares no config", call.Module)
"config", call.Module)
} }
} }
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
resolved := Resolve(s) resolved := Resolve(s)
for _, k := range sortedKeys(resolved) { for _, k := range sortedKeys(resolved) {
r := resolved[k] r := resolved[k]
+3 -6
View File
@@ -4,9 +4,8 @@
// node), mesh-config_resolved (each module's value on each node, with its source), mesh-config_predefined (the // node), mesh-config_resolved (each module's value on each node, with its source), mesh-config_predefined (the
// published list of predefined keys) and mesh-config_proposals (each value proposed for the operator's Approve, // published list of predefined keys) and mesh-config_proposals (each value proposed for the operator's Approve,
// asked under this module's own name, design 57 K4). // asked under this module's own name, design 57 K4).
// It resolves from where the seat mesh-placement places each module, and each module's declarations as its own // It resolves from the controller's mirrors of the assignments, the modules' declarations and the list it kept
// manifests declare them, the trunk's and its wanted build's (the indexer's and the builder's state, issue 1953); the // (design 57 F3, K3). stdout is the MCP channel; what this module says, it says on stderr.
// list of predefined keys still comes from the controller's mirror until the indexer keeps it (ADR 0565 §2). stdout is the MCP channel; what this module says, it says on stderr.
package main package main
import ( import (
@@ -116,9 +115,7 @@ func resolving(c *Config) {
} }
return nil return nil
} }
// A build's manifest is written before a want names the build, so a want, a trunk module or a placement moving is for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} {
// every change of what a module declares where (issue 1953); the builds themselves are read, not watched.
for _, read := range []string{PlacementRead, WantsRead, ModulesRead, PredefinedRead} {
go func(read string) { go func(read string) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
time.Sleep(wait) time.Sleep(wait)
@@ -573,7 +573,7 @@ func TestTheProposalArgumentsAreReadStrictly(t *testing.T) {
// not refused as carrying a secret: modules/asus-zephyrus-g14, i3status-rust and powerlevel10k are names. // not refused as carrying a secret: modules/asus-zephyrus-g14, i3status-rust and powerlevel10k are names.
func TestAnOwnersListOfNamesWithDigitsIsProposed(t *testing.T) { func TestAnOwnersListOfNamesWithDigitsIsProposed(t *testing.T) {
c, m, r := aProposingMesh(t) c, m, r := aProposingMesh(t)
m.declare(t, "mesh-issues", `{"module":"mesh-issues","settings":{ m.mirror(t, DeclaredConfigRead, "mesh-issues", `{"module":"mesh-issues","settings":{
"owners":{"kind":"","default":[],"why":"","type":"list","description":"owners","trusted":true}}}`) "owners":{"kind":"","default":[],"why":"","type":"list","description":"owners","trusted":true}}}`)
m.assign(t, "novox", "mesh-issues") m.assign(t, "novox", "mesh-issues")
raw, err := os.ReadFile(filepath.Join("testdata", "owners-1439.json")) raw, err := os.ReadFile(filepath.Join("testdata", "owners-1439.json"))
@@ -0,0 +1,36 @@
package main
import "testing"
// A module only pulled in by another on a node, and handed to the provisioner there, has a value of its own in the
// assignments mirror (mesh-controller internal/inventory AssignmentValues, issue 765): what requires it, its plan,
// and no time it was assigned. Its values resolve there like an assigned module's, so the node's client can fill its
// templates; they go when the value goes.
func TestAModulePulledInHasItsValuesResolvedOnItsNode(t *testing.T) {
c, m := aMesh(t)
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
}
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","required-by":"desk",`+
`"provisioning":{"templates":{"source":"x","digest":"y"},"files":[]}}`)
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
t.Fatalf("a pulled-in module's value moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
_ = m.Delete(AssignmentsRead, "ace.dunst")
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("a module no longer pulled in moved %+v", r.Moved)
}
// 1126 (ADR 0549 §3, §5): a module the catalogue's placement names on a node resolves there as an assigned one does,
// while the two sources stand; it goes when neither names it.
m.mirror(t, PlacementRead, "placement.ace", `{"v":1,"node":"ace","modules":[{"module":"dunst"}]}`)
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
t.Fatalf("a placed module's values moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
_ = m.Delete(PlacementRead, "placement.ace")
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("a module no longer placed moved %+v", r.Moved)
}
}
+2 -2
View File
@@ -378,8 +378,8 @@ func heldRule(s Sources, call Call, level Level) (rule, PullDecl, string, error)
return rule{}, PullDecl{}, "", fmt.Errorf("%s is not a registered module, so a value cannot be held for it", level.Module) return rule{}, PullDecl{}, "", fmt.Errorf("%s is not a registered module, so a value cannot be held for it", level.Module)
} }
if !assigned(s, level.Node, level.Module) { if !assigned(s, level.Node, level.Module) {
return rule{}, PullDecl{}, "", fmt.Errorf("%s is not placed on %s, and a module's own value is set for a node "+ return rule{}, PullDecl{}, "", fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node "+
"it is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node, "it is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
strings.Join(nodesOf(s, level.Module), ", ")) strings.Join(nodesOf(s, level.Module), ", "))
} }
return d.rule(), pd, declDigest(d), nil return d.rule(), pd, declDigest(d), nil
@@ -23,7 +23,7 @@ func n8nPullDeclares() map[string]any {
func aHoldingMesh(t *testing.T) (*Config, *memStore, *standIn, *standForge) { func aHoldingMesh(t *testing.T) (*Config, *memStore, *standIn, *standForge) {
t.Helper() t.Helper()
c, m, r := aProposingMesh(t) c, m, r := aProposingMesh(t)
m.declare(t, "n8n", `{"module":"n8n","settings":{}}`) m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{}}`)
m.assign(t, "ace", "n8n") m.assign(t, "ace", "n8n")
f := &standForge{state: map[string][2]bool{}} f := &standForge{state: map[string][2]bool{}}
c.Forge = f c.Forge = f
@@ -112,7 +112,7 @@ func TestAPullRequestsDeclarationsAreTakenFromTheControllerAlone(t *testing.T) {
} }
// 1826/K2: a proposal for a key only the pull request declares is refused without its declaration, for a mesh or // 1826/K2: a proposal for a key only the pull request declares is refused without its declaration, for a mesh or
// node level, for a clear, for an undeclared key, and for a module not placed on the node; and trunk-only // node level, for a clear, for an undeclared key, and for a module not assigned to the node; and trunk-only
// proposals are judged as before. // proposals are judged as before.
func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T) { func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T) {
c, m, _, _ := aHoldingMesh(t) c, m, _, _ := aHoldingMesh(t)
@@ -142,7 +142,7 @@ func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T)
"a key not added": {func(c *Call) { c.Key = "other" }, "does not add or change a value"}, "a key not added": {func(c *Call) { c.Key = "other" }, "does not add or change a value"},
"a bad value": {func(c *Call) { c.Value = 0.0 }, "below the minimum"}, "a bad value": {func(c *Call) { c.Value = 0.0 }, "below the minimum"},
"a wrong type": {func(c *Call) { c.Value = "2000" }, "is not a number"}, "a wrong type": {func(c *Call) { c.Value = "2000" }, "is not a number"},
"another node": {func(c *Call) { c.Node = "g14" }, "is not placed on g14"}, "another node": {func(c *Call) { c.Node = "g14" }, "is not assigned to g14"},
"another pull": {func(c *Call) { c.Pull = "novox/mesh-catalog#435" }, "holds no declaration for"}, "another pull": {func(c *Call) { c.Pull = "novox/mesh-catalog#435" }, "holds no declaration for"},
"a malformed pull": {func(c *Call) { c.Pull = "434" }, "owner/repository#number"}, "a malformed pull": {func(c *Call) { c.Pull = "434" }, "owner/repository#number"},
} { } {
@@ -342,7 +342,7 @@ func TestTheMergeAppliesAHeldValueAndItResolvesOnceTheTrunkDeclaresTheKey(t *tes
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "n8n", "media-gid")); e != nil { if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "n8n", "media-gid")); e != nil {
t.Errorf("resolved before the trunk declares the key: %s", e.Value) t.Errorf("resolved before the trunk declares the key: %s", e.Value)
} }
m.declare(t, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+ m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+
`"type":"number","required":true,"minimum":1,"maximum":65535}}}`) `"type":"number","required":true,"minimum":1,"maximum":65535}}}`)
if _, err := c.Reconcile(); err != nil { if _, err := c.Reconcile(); err != nil {
t.Fatal(err) t.Fatal(err)
+9 -55
View File
@@ -6,8 +6,6 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// The four sources (novox/hq ADR 0348 §2, §3, §9): a module's value on a node resolves, the most specific winning, // The four sources (novox/hq ADR 0348 §2, §3, §9): a module's value on a node resolves, the most specific winning,
@@ -22,20 +20,11 @@ const (
PredefinedState = "predefined" PredefinedState = "predefined"
ProposalsState = "proposals" ProposalsState = "proposals"
AssignmentsRead = "mesh-controller.assignments"
// PlacementRead is the seat mesh-placement's state, held by the assigner (novox/hq ADR 0567 §1, §8): 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. It is the one source of which module runs where // node's key `placement.<node>` with the modules placed there.
// (issue 1953): the controller's assignments are no longer read. PlacementRead = "mesh-delivery-assigner.placement"
PlacementRead = delivery.PlacementRead DeclaredConfigRead = "mesh-controller.declared-config"
// WantsRead is the assigner's wants, `want.<node>.<module>`: the build each node is to run (ADR 0539 §1).
WantsRead = delivery.AssignerModule + ".wants"
// BuildsRead is the builder's builds, `<module>.<fingerprint hex>`: each build's manifest digest (ADR 0525).
BuildsRead = delivery.BuilderModule + ".builds"
// ModulesRead is the indexer's modules, `<module>`: the trunk's manifest digest of each module, from whichever
// repository holds it (ADR 0437 §5, ADR 0478 §9); ManifestsRead its manifests by digest (ADR 0429 §16).
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
// PredefinedRead is the controller's list of predefined keys, which ADR 0565 §2 moves to the indexer's
// `predefined`: read here until that state exists.
PredefinedRead = "mesh-controller.predefined" PredefinedRead = "mesh-controller.predefined"
// HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9). // HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9).
@@ -134,19 +123,11 @@ type Resolved struct {
// ResolvedKey is a resolved value's key in mesh-config_resolved. // ResolvedKey is a resolved value's key in mesh-config_resolved.
func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key } func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key }
// Sources is everything a resolution reads: where each module is placed, every module's declarations, the published // Sources is everything a resolution reads: the assignments, every module's declarations, the published list of
// list of predefined keys, and every value set. // predefined keys, and every value set.
type Sources struct { type Sources struct {
Assignments []Assignment Assignments []Assignment
// Declared is each module's declarations as its manifests declare them: the trunk's, with every key only a wanted
// build still declares (declarationsOf). It is what a set is judged against.
Declared map[string]ModuleDeclarations Declared map[string]ModuleDeclarations
// OnNode is, by `<node>.<module>`, the declarations of the build the node's want names laid over the trunk's: what
// that node resolves. A placed module absent here resolves from Declared.
OnNode map[string]ModuleDeclarations
// Unresolved is each placed module whose declarations could not be read, with why: never skipped silently
// (issue 1953). Its resolved values are kept as they were until its declarations read again.
Unresolved []Unresolved
Predefined map[string]Published Predefined map[string]Published
Values map[string]Value Values map[string]Value
// Pulls is what the controller told of each open pull request's declarations, and Kept the approved values held // Pulls is what the controller told of each open pull request's declarations, and Kept the approved values held
@@ -155,38 +136,11 @@ type Sources struct {
Kept map[string]KeptValue Kept map[string]KeptValue
} }
// Unresolved is a module placed on a node whose values are not resolved, and why. // Resolve is every assigned module's every declared value on its node, keyed as mesh-config_resolved is.
type Unresolved struct {
Node string `json:"node"`
Module string `json:"module"`
Why string `json:"why"`
}
// declarationsOn is the declarations a module on a node resolves from, and whether any are held.
func (s Sources) declarationsOn(node, module string) (ModuleDeclarations, bool) {
if d, ok := s.OnNode[node+"."+module]; ok {
return d, true
}
d, ok := s.Declared[module]
return d, ok
}
// unresolved is whether a module on a node is unresolved.
func (s Sources) unresolved(node, module string) bool {
for _, u := range s.Unresolved {
if u.Node == node && u.Module == module {
return true
}
}
return false
}
// Resolve is every placed module's every declared value on its node, keyed as mesh-config_resolved is. A placed
// module with no declarations is in s.Unresolved, said there (sources).
func Resolve(s Sources) map[string]Resolved { func Resolve(s Sources) map[string]Resolved {
out := map[string]Resolved{} out := map[string]Resolved{}
for _, a := range s.Assignments { for _, a := range s.Assignments {
decl, ok := s.declarationsOn(a.Node, a.Module) decl, ok := s.Declared[a.Module]
if !ok { if !ok {
continue continue
} }
@@ -242,7 +196,7 @@ type Moved struct {
From json.RawMessage `json:"from,omitempty"` From json.RawMessage `json:"from,omitempty"`
To json.RawMessage `json:"to,omitempty"` To json.RawMessage `json:"to,omitempty"`
Source string `json:"source"` Source string `json:"source"`
// Gone says the module no longer takes the key there: no longer placed, or no longer declared. // Gone says the module no longer takes the key there: unassigned, or no longer declared.
Gone bool `json:"gone,omitempty"` Gone bool `json:"gone,omitempty"`
} }
+58 -233
View File
@@ -4,30 +4,73 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"sort"
"strings" "strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
) )
// Reading the sources, writing the resolved values, and publishing the list of predefined keys. // Reading the sources, writing the resolved values, and publishing the list of predefined keys.
// sources reads everything a resolution reads, from the bus as it is now: which modules are placed where from the // sources reads everything a resolution reads, from the bus as it is now.
// seat mesh-placement, and each placed module's declarations from its own manifests as built, never from the
// controller (issue 1953, ADR 0565 §3).
func (c *Config) sources() (Sources, error) { func (c *Config) sources() (Sources, error) {
s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{}, s := Sources{Declared: map[string]ModuleDeclarations{}, Predefined: map[string]Published{},
Predefined: map[string]Published{}, Values: map[string]Value{}} Values: map[string]Value{}}
var problems []string var problems []string
if err := c.readPlacement(&s); err != nil { if err := c.each(AssignmentsRead, func(key string, raw []byte) error {
return s, fmt.Errorf("the seat mesh-placement's placement cannot be read: %w", err) var a Assignment
if err := json.Unmarshal(raw, &a); err != nil || a.Node == "" || a.Module == "" {
return fmt.Errorf("the assignment %s is not one: %s", key, raw)
} }
if err := c.readDeclarations(&s); err != nil { if !a.Standing() {
return nil // ended or pending: read as a removed key (issue 1098)
}
s.Assignments = append(s.Assignments, a)
return nil
}); err != nil {
return s, fmt.Errorf("the assignments cannot be read: %w", err)
}
// 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 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
}
if err := c.each(PlacementRead, func(key string, raw []byte) error {
node, ok := strings.CutPrefix(key, "placement.")
if !ok {
return nil
}
var p struct {
Node string `json:"node"`
Modules []struct {
Module string `json:"module"`
} `json:"modules"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
problems = append(problems, fmt.Sprintf("the placement %s is not one", key))
return nil
}
for _, m := range p.Modules {
if m.Module != "" && !standing[node+"."+m.Module] {
standing[node+"."+m.Module] = true
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
}
}
return nil
}); err != nil {
problems = append(problems, fmt.Sprintf("the 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)
if err != nil {
// One module's declarations unread leaves that module unresolved, said; the rest resolve.
problems = append(problems, fmt.Sprintf("%s: %v", key, err))
return nil
}
s.Declared[m.Module] = m
return nil
}); err != nil {
return s, fmt.Errorf("the modules' declarations cannot be read: %w", err) return s, fmt.Errorf("the modules' declarations cannot be read: %w", err)
} }
for _, u := range s.Unresolved {
problems = append(problems, fmt.Sprintf("%s on %s: %s", u.Module, u.Node, u.Why))
}
if err := c.each(PredefinedState, func(key string, raw []byte) error { if err := c.each(PredefinedState, func(key string, raw []byte) error {
var p Published var p Published
if err := json.Unmarshal(raw, &p); err != nil { if err := json.Unmarshal(raw, &p); err != nil {
@@ -52,222 +95,11 @@ func (c *Config) sources() (Sources, error) {
return s, err return s, err
} }
for _, p := range problems { for _, p := range problems {
c.logf("[mesh-config] a placed module's declarations are not read, so its values are not resolved (they "+ c.logf("[mesh-config] a module's declarations are not read, so its values are not resolved: %s", p)
"stay as last resolved): %s", p)
} }
return s, nil return s, nil
} }
// readPlacement is every module the seat mesh-placement places on a node (ADR 0567 §1, §9): each node's key
// `placement.<node>`.
func (c *Config) readPlacement(s *Sources) error {
placed := map[string]bool{}
return c.each(PlacementRead, func(key string, raw []byte) error {
node, err := delivery.ParsePlacementKey(key)
if err != nil {
return nil // a key of another kind
}
var p struct {
Node string `json:"node"`
Modules []struct {
Module string `json:"module"`
} `json:"modules"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
return fmt.Errorf("the placement %s is not one", key)
}
for _, m := range p.Modules {
if m.Module != "" && !placed[node+"."+m.Module] {
placed[node+"."+m.Module] = true
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
}
}
return nil
})
}
// readDeclarations reads each placed module's declarations from its manifests as the delivery holds them: the trunk's
// (the indexer's `modules.<module>` and its manifest by digest), and, on each node whose want names a build, that
// build's (the builder's `<module>.<fp>` names its manifest digest). On a node the want's build is laid over the
// trunk's, key by key, so a key only the build it runs declares still resolves there and a key the trunk adds resolves
// before its build is wanted; Declared is the trunk's with every key only a wanted build declares. A placed module
// none of whose manifests can be read is Unresolved, with why.
func (c *Config) readDeclarations(s *Sources) error {
wanted := map[string]string{} // <node>.<module> → the fingerprint its want names
if err := c.each(WantsRead, func(key string, raw []byte) error {
node, module, err := delivery.ParseWantKey(key)
if err != nil {
return nil // a held key or a node's own want
}
var w struct {
FP string `json:"fp"`
Removed bool `json:"removed"`
}
if json.Unmarshal(raw, &w) == nil && !w.Removed && w.FP != "" {
wanted[node+"."+module] = w.FP
}
return nil
}); err != nil {
return fmt.Errorf("the assigner's wants: %w", err)
}
trunk := map[string]ModuleDeclarations{}
trunkWhy := map[string]string{}
for _, module := range modulesOf(s.Assignments) {
d, err := c.trunkDeclarations(module)
if err != nil {
trunkWhy[module] = err.Error()
continue
}
trunk[module] = d
s.Declared[module] = d
}
for _, a := range s.Assignments {
t, hasTrunk := trunk[a.Module]
fp, isWanted := wanted[a.Node+"."+a.Module]
var built ModuleDeclarations
var builtErr error
if isWanted {
built, builtErr = c.builtDeclarations(a.Module, fp)
}
switch {
case isWanted && builtErr == nil:
on := overlay(t, built)
s.OnNode[a.Node+"."+a.Module] = on
d := s.Declared[a.Module]
s.Declared[a.Module] = overlay(built, d) // the trunk's wins in what a set is judged against
case hasTrunk:
if isWanted {
c.logf("[mesh-config] %s on %s resolves from the trunk's manifest alone: the build its want names "+
"cannot be read: %v", a.Module, a.Node, builtErr)
}
default:
why := trunkWhy[a.Module]
if isWanted {
why += "; and the build its want names: " + builtErr.Error()
} else {
why += "; and no want names a build of it on " + a.Node
}
s.Unresolved = append(s.Unresolved, Unresolved{Node: a.Node, Module: a.Module, Why: why})
}
}
return nil
}
// overlay is base with every key of over laid on it, the module's name kept.
func overlay(base, over ModuleDeclarations) ModuleDeclarations {
out := ModuleDeclarations{Module: over.Module, Config: map[string]Declaration{}}
if out.Module == "" {
out.Module = base.Module
}
for k, d := range base.Config {
out.Config[k] = d
}
for k, d := range over.Config {
out.Config[k] = d
}
return out
}
func modulesOf(as []Assignment) []string {
seen := map[string]bool{}
var out []string
for _, a := range as {
if !seen[a.Module] {
seen[a.Module] = true
out = append(out, a.Module)
}
}
sort.Strings(out)
return out
}
// trunkDeclarations is a module's declarations at the trunk: the manifest the indexer's `modules.<module>` names.
func (c *Config) trunkDeclarations(module string) (ModuleDeclarations, error) {
key, err := delivery.ModulesKey(module)
if err != nil {
return ModuleDeclarations{}, err
}
e, err := c.Store.Get(ModulesRead, key)
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer's modules.%s cannot be read: %w", module, err)
}
if e == nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer holds no modules.%s, so no manifest of it is known", module)
}
var m struct {
Manifest string `json:"manifest"`
}
if err := json.Unmarshal(e.Value, &m); err != nil || m.Manifest == "" {
return ModuleDeclarations{}, fmt.Errorf("the indexer's modules.%s names no manifest", module)
}
return c.manifestDeclarations(module, m.Manifest)
}
// builtDeclarations is a module's declarations at a build: the manifest the builder's done key names.
func (c *Config) builtDeclarations(module, fp string) (ModuleDeclarations, error) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return ModuleDeclarations{}, err
}
e, err := c.Store.Get(BuildsRead, key)
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the builder's %s cannot be read: %w", key, err)
}
if e == nil {
return ModuleDeclarations{}, fmt.Errorf("the builder holds no build %s", key)
}
var b struct {
Manifest string `json:"manifest"`
}
if err := json.Unmarshal(e.Value, &b); err != nil || b.Manifest == "" {
return ModuleDeclarations{}, fmt.Errorf("the builder's %s names no manifest", key)
}
return c.manifestDeclarations(module, b.Manifest)
}
// manifestDeclarations is the declarations of the manifest of a digest in the indexer's manifests, read once: a
// manifests key is written once and never changes (ADR 0429 §16), so what it declares is kept by digest.
func (c *Config) manifestDeclarations(module, digest string) (ModuleDeclarations, error) {
c.manifestsMu.Lock()
d, ok := c.manifests[digest]
c.manifestsMu.Unlock()
if ok {
return d, nil
}
key, err := delivery.ManifestsKey(digest)
if err != nil {
return ModuleDeclarations{}, err
}
e, err := c.Store.Get(ManifestsRead, key)
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s cannot be read: %w", short(digest), err)
}
if e == nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer holds no manifest %s", short(digest))
}
var m struct {
Module string `json:"module"`
JSON string `json:"manifest"`
}
if err := json.Unmarshal(e.Value, &m); err != nil || m.JSON == "" {
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s is not one", short(digest))
}
if m.Module != module {
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s is %s's, not %s's", short(digest), m.Module, module)
}
d, err = ManifestDeclarations([]byte(m.JSON))
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the manifest %s: %w", short(digest), err)
}
c.manifestsMu.Lock()
if c.manifests == nil {
c.manifests = map[string]ModuleDeclarations{}
}
c.manifests[digest] = d
c.manifestsMu.Unlock()
return d, nil
}
// each hands every key of a state to visit. // each hands every key of a state to visit.
func (c *Config) each(state string, visit func(key string, raw []byte) error) error { func (c *Config) each(state string, visit func(key string, raw []byte) error) error {
keys, err := c.Store.Keys(state) keys, err := c.Store.Keys(state)
@@ -313,13 +145,6 @@ func (c *Config) writeResolved(s Sources) ([]Moved, error) {
return nil, fmt.Errorf("the resolved values cannot be read: %w", err) return nil, fmt.Errorf("the resolved values cannot be read: %w", err)
} }
want := Resolve(s) want := Resolve(s)
// A placed module whose declarations cannot be read keeps what was resolved for it: not knowing is no reason to take
// a node's values away (issue 1953).
for key, h := range held {
if _, ok := want[key]; !ok && h.Source != "unreadable" && s.unresolved(h.Node, h.Module) {
want[key] = h
}
}
moved := diff(held, want) moved := diff(held, want)
var problems []error var problems []error
for _, m := range moved { for _, m := range moved {
+2 -4
View File
@@ -28,11 +28,9 @@
"gitea.gitea_get_pull_request" "gitea.gitea_get_pull_request"
], ],
"reads": [ "reads": [
"mesh-controller.assignments",
"mesh-delivery-assigner.placement", "mesh-delivery-assigner.placement",
"mesh-delivery-assigner.wants", "mesh-controller.declared-config",
"mesh-delivery-builder.builds",
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.manifests",
"mesh-controller.predefined" "mesh-controller.predefined"
], ],
"state": [ "state": [