Compare 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
45 changed files with 424 additions and 719 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)
}
}
// 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": {
"DB_TYPE": "postgres",
"USER_UID": "1000",
"USER_GID": "1000",
"SSH_MAX_STARTUPS": "100:30:200"
"USER_GID": "1000"
},
"env-file": [
"${dir:state}/server.env"
@@ -47,9 +47,6 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps {
Has: m.Has,
Release: func(ws string) error { return m.spaces().Release(ws) },
Logf: m.Say,
// A failed test's whole output is said on the module's own log stream (buildstep.LogTool reads it back).
Narrate: m.Emit,
Module: "mesh-build-go",
}
}
@@ -3,7 +3,6 @@ package main
import (
"context"
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"os"
"path/filepath"
"slices"
@@ -48,14 +47,9 @@ func TestTheManifestDeclaresTheToolchainsStream(t *testing.T) {
Streams []struct {
Name string `json:"name"`
Events []string `json:"events"`
Age int `json:"max-age-days"`
MiB int `json:"max-mib"`
} `json:"streams"`
}
// And its log stream: a failed test's whole output, kept 14 days and at most 2 GiB, dropping its oldest (the operator,
// 2026-10-30).
if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 2 || m.Streams[0].Name != "toolchains-go" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) ||
m.Streams[1].Name != buildstep.LogStreamOf("go") || !slices.Equal(m.Streams[1].Events, []string{"log.**"}) || m.Streams[1].Age != 14 || m.Streams[1].MiB != 2048 {
if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 1 || m.Streams[0].Name != "toolchains-go" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) {
t.Errorf("streams = %+v (%v)", m.Streams, err)
}
}
@@ -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, 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.
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")
}
}
@@ -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()
@@ -14,7 +14,6 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
@@ -42,7 +41,6 @@ func main() {
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
Steps: stdio.State(delivery.StepsState).Put,
ReadSteps: readOf(stdio.State(delivery.StepsState)),
ReadLog: stdio.Stream(buildstep.LogStreamOf("go")).Read,
Pauses: stdio.State(delivery.PausesState).Put,
ReadPauses: readOf(stdio.State(delivery.PausesState)),
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
@@ -31,9 +31,6 @@ type Module struct {
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
Held *buildstep.Puller
ReadSteps, ReadToolchainsState StateRead
// ReadLog reads the module's own log stream (stdio.Stream(buildstep.LogStreamOf(language)).Read), where each step
// says a failed test's whole output (Emit says it); nil in a test.
ReadLog buildstep.StreamRead
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
Pauses buildstep.Put
ReadPauses StateRead
@@ -115,7 +115,7 @@ func TestAGoThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T) {
{Name: "short_probe", Run: run},
}, m.StepTools()...)
for _, tool := range m.Tools(steps) {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == LogReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
continue
}
var reason string
@@ -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 := ""
@@ -15,16 +15,13 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The read tools, named as the module is: build_go_ and the verb.
const (
StepReadTool = "build_go_step"
StepsReadTool = "build_go_steps"
// LogReadTool reads one step's log, a failed test's whole output, from the module's own log stream.
LogReadTool = "build_go_log"
StepReadTool = "build_go_step"
StepsReadTool = "build_go_steps"
ToolchainsReadTool = "build_go_toolchains"
)
@@ -228,14 +225,7 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
// ReadTools are the module's read tools. All are read-only.
func (m *Module) ReadTools() []stdio.Tool {
readLog := m.ReadLog
if readLog == nil {
readLog = func(string, uint64, int) (*stdio.StreamPage, error) {
return nil, fmt.Errorf("this process reads no log stream: it was not launched by the runtime")
}
}
return []stdio.Tool{
buildstep.LogTool(LogReadTool, readLog),
{Name: StepReadTool,
Description: "What one step of this machine ended with, as stored in the module's steps state: its tool, state " +
"(running, passed, failed, refused), when it started and ended, why it failed or was refused, and its result " +
@@ -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())
}
})
}
}
@@ -251,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 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde
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
+4 -8
View File
@@ -1,11 +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.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095131-16bc0dcf3e9d h1:jFOz6wUfZvIayd4DctwlaFKKZHQNME6NLs2OS1rNv30=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095131-16bc0dcf3e9d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc h1:Wdjl2gomooHswJ5qpkwZPq/ureZgXVw6FMCsgzPJhPo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde h1:eKXSllHCS31GgMazfvZ7BeiLoXIUibnUmzxWVkj+Vcw=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
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=
-7
View File
@@ -51,7 +51,6 @@
"build_go_release",
"build_go_step",
"build_go_steps",
"build_go_log",
"build_go_step_end",
"build_go_toolchains",
"build_go_status",
@@ -61,12 +60,6 @@
{
"name": "toolchains-go",
"events": ["toolchain.**"]
},
{
"name": "go-build-logs",
"events": ["log.**"],
"max-age-days": 14,
"max-mib": 2048
}
],
"requires": [
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildimage
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.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.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.20261029233535-78c9ec881771/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=
-1
View File
@@ -1 +0,0 @@
/mesh-build-node
@@ -37,9 +37,6 @@ func (m *Module) askSteps(steps []StepTool) buildstep.AskSteps {
},
Release: func(ws string) error { return m.spaces().Release(ws) },
Logf: m.Say,
// A failed test's whole output is said on the module's own log stream (buildstep.LogTool reads it back).
Narrate: m.Emit,
Module: "mesh-build-node",
}
}
@@ -3,7 +3,6 @@ package main
import (
"context"
"encoding/json"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"os"
"path/filepath"
"slices"
@@ -48,14 +47,9 @@ func TestTheManifestDeclaresTheToolchainsStream(t *testing.T) {
Streams []struct {
Name string `json:"name"`
Events []string `json:"events"`
Age int `json:"max-age-days"`
MiB int `json:"max-mib"`
} `json:"streams"`
}
// And its log stream: a failed test's whole output, kept 14 days and at most 2 GiB, dropping its oldest (the operator,
// 2026-10-30).
if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 2 || m.Streams[0].Name != "toolchains-node" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) ||
m.Streams[1].Name != buildstep.LogStreamOf("node") || !slices.Equal(m.Streams[1].Events, []string{"log.**"}) || m.Streams[1].Age != 14 || m.Streams[1].MiB != 2048 {
if err := json.Unmarshal(raw, &m); err != nil || len(m.Streams) != 1 || m.Streams[0].Name != "toolchains-node" || !slices.Equal(m.Streams[0].Events, []string{"toolchain.**"}) {
t.Errorf("streams = %+v (%v)", m.Streams, err)
}
}
@@ -16,7 +16,6 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/forgekey"
)
@@ -48,7 +47,6 @@ func main() {
Emit: func(t string, b any) error { return stdio.Emit(t, b) },
Steps: stdio.State(delivery.StepsState).Put,
ReadSteps: readOf(stdio.State(delivery.StepsState)),
ReadLog: stdio.Stream(buildstep.LogStreamOf("node")).Read,
Pauses: stdio.State(delivery.PausesState).Put,
ReadPauses: readOf(stdio.State(delivery.PausesState)),
ReadToolchainsState: readOf(stdio.State(delivery.ToolchainsState)),
@@ -45,9 +45,6 @@ type Module struct {
// Held is the puller whose running asks a step-end tool will not end (issue 1890); nil in a test.
Held *buildstep.Puller
ReadSteps, ReadToolchainsState StateRead
// ReadLog reads the module's own log stream (stdio.Stream(buildstep.LogStreamOf(language)).Read), where each step
// says a failed test's whole output (Emit says it); nil in a test.
ReadLog buildstep.StreamRead
// Pauses is the Put of the pauses state, which keeps the node's pause key without an age; ReadPauses reads it back.
Pauses buildstep.Put
ReadPauses StateRead
@@ -117,7 +117,7 @@ func TestANodeThatCannotBeRunRefusesEveryStepAndHealthIsUnhealthy(t *testing.T)
{Name: "short_probe", Run: run},
}, (&Module{}).StepTools()...)
for _, tool := range m.Tools(steps) {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == LogReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
if tool.Name == StatusTool || tool.Name == HealthTool || tool.Name == StepReadTool || tool.Name == StepsReadTool || tool.Name == ToolchainsReadTool || tool.Name == ReleaseTool || tool.Name == StepEndTool {
continue
}
var reason string
@@ -15,16 +15,13 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/buildstep"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The read tools, named as the module is: build_node_ and the verb.
const (
StepReadTool = "build_node_step"
StepsReadTool = "build_node_steps"
// LogReadTool reads one step's log, a failed test's whole output, from the module's own log stream.
LogReadTool = "build_node_log"
StepReadTool = "build_node_step"
StepsReadTool = "build_node_steps"
ToolchainsReadTool = "build_node_toolchains"
)
@@ -228,14 +225,7 @@ func (m *Module) ReadToolchains() (map[string]delivery.Toolchain, error) {
// ReadTools are the module's read tools. All are read-only.
func (m *Module) ReadTools() []stdio.Tool {
readLog := m.ReadLog
if readLog == nil {
readLog = func(string, uint64, int) (*stdio.StreamPage, error) {
return nil, fmt.Errorf("this process reads no log stream: it was not launched by the runtime")
}
}
return []stdio.Tool{
buildstep.LogTool(LogReadTool, readLog),
{Name: StepReadTool,
Description: "What one step of this machine ended with, as stored in the module's steps state: its tool, state " +
"(running, passed, failed, refused), when it started and ended, why it failed or was refused, and its result " +
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildnode
go 1.26.0
require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde
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
)
+2 -8
View File
@@ -1,11 +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.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095131-16bc0dcf3e9d h1:jFOz6wUfZvIayd4DctwlaFKKZHQNME6NLs2OS1rNv30=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095131-16bc0dcf3e9d/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc h1:Wdjl2gomooHswJ5qpkwZPq/ureZgXVw6FMCsgzPJhPo=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030095816-530d8d3007dc/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde h1:eKXSllHCS31GgMazfvZ7BeiLoXIUibnUmzxWVkj+Vcw=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030100935-c2ec8b6b2fde/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
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=
-7
View File
@@ -46,7 +46,6 @@
"build_node_release",
"build_node_step",
"build_node_steps",
"build_node_log",
"build_node_step_end",
"build_node_toolchains",
"build_node_status",
@@ -58,12 +57,6 @@
"events": [
"toolchain.**"
]
},
{
"name": "node-build-logs",
"events": ["log.**"],
"max-age-days": 14,
"max-mib": 2048
}
],
"requires": [
+3 -8
View File
@@ -21,16 +21,11 @@ account alone writes its buckets.
## How a value resolves
For every module the seat mesh-placement places on a node (`mesh-delivery-assigner.placement`), from the module's
own manifests as the delivery built them, never from the controller (issue 1953): the trunk's, which the indexer's
`modules.<module>` names in its manifests by digest (`mesh-delivery-indexer.modules`, `.manifests`), with the build the
node's want names (`mesh-delivery-assigner.wants`, the builder's `mesh-delivery-builder.builds` naming its manifest)
laid over it key by key on that node. So a module from its own repository resolves as a catalogue module does. A placed
module none of whose manifests can be read is listed under `unresolved` with why and said in the log, and keeps the
values last resolved for it. Then: the manifest's default; mesh config for the predefined key the value falls back
From the controller's mirrors of the assignments and of each module's declarations (`mesh-controller.assignments`,
`mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back
to; node config for that key on the node; the module's own value on that node, the most specific winning. A value
naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at
start, whenever a 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
@@ -97,10 +97,6 @@ type Config struct {
RouterWithin, RouterEvery time.Duration
endedMu sync.Mutex
ended map[string]endedWord
// manifests is each manifest's declarations read, by digest: a manifest never changes (manifestDeclarations).
manifestsMu sync.Mutex
manifests map[string]ModuleDeclarations
}
// Call is one call of the verb, its arguments read.
@@ -340,8 +336,8 @@ func (c *Config) declared(s Sources, level Level, key string) (rule, bool, error
joinKeys(decl.Config))
}
if !assigned(s, level.Node, level.Module) {
return rule{}, false, fmt.Errorf("%s is not placed on %s, and a module's own value is set for a node it "+
"is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node,
return rule{}, false, fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node it "+
"is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
strings.Join(nodesOf(s, level.Module), ", "))
}
trusted := d.Trusted
@@ -9,8 +9,6 @@ import (
"sync"
"testing"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log,
@@ -122,81 +120,9 @@ func (m *memStore) mirror(t *testing.T, read, key, raw string) {
m.states[read][key] = []byte(raw)
}
// assign places a module on a node, in the seat mesh-placement's key for the node.
func (m *memStore) assign(t *testing.T, node, module string) {
t.Helper()
m.place(t, node, module, true)
}
// unassign takes a module off a node's placement.
func (m *memStore) unassign(t *testing.T, node, module string) {
t.Helper()
m.place(t, node, module, false)
}
func (m *memStore) place(t *testing.T, node, module string, on bool) {
t.Helper()
type placed struct {
Module string `json:"module"`
}
p := struct {
V int `json:"v"`
Node string `json:"node"`
Modules []placed `json:"modules"`
}{V: 1, Node: node}
if e, _ := m.Get(PlacementRead, "placement."+node); e != nil {
if err := json.Unmarshal(e.Value, &p); err != nil {
t.Fatal(err)
}
}
var kept []placed
for _, x := range p.Modules {
if x.Module != module {
kept = append(kept, x)
}
}
if on {
kept = append(kept, placed{Module: module})
}
p.Modules = kept
if err := m.Put(PlacementRead, "placement."+node, p); err != nil {
t.Fatal(err)
}
}
// manifest keeps a manifest in the indexer's manifests by its digest, as the indexer writes it, and answers the digest.
func (m *memStore) manifest(t *testing.T, module, raw string) string {
t.Helper()
if !json.Valid([]byte(raw)) {
t.Fatalf("not JSON: %s", raw)
}
digest := delivery.ManifestDigest([]byte(raw))
value, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw})
if err != nil {
t.Fatal(err)
}
m.mirror(t, ManifestsRead, digest, string(value))
return digest
}
// declare makes raw the module's manifest at the trunk: kept by digest, and named by the indexer's modules.<module>.
func (m *memStore) declare(t *testing.T, module, raw string) {
t.Helper()
digest := m.manifest(t, module, raw)
m.mirror(t, ModulesRead, module, fmt.Sprintf(`{"v":1,"manifest":%q,"repository":"novox/%s","commit":"c1"}`,
digest, module))
}
// want makes a node's want name a build of the module whose manifest is raw, as the assigner and the builder keep them.
func (m *memStore) want(t *testing.T, node, module, fp, raw string) {
t.Helper()
digest := m.manifest(t, module, raw)
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
t.Fatal(err)
}
m.mirror(t, BuildsRead, key, fmt.Sprintf(`{"v":1,"state":"done","manifest":%q}`, digest))
m.mirror(t, WantsRead, "want."+node+"."+module, fmt.Sprintf(`{"v":1,"fp":%q,"self":true}`, fp))
m.mirror(t, AssignmentsRead, node+"."+module,
fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module))
}
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) {
t.Helper()
m := newStore()
m.declare(t, "dunst", dunstDeclared)
m.declare(t, "claude-code", `{"module":"claude-code","settings":{
m.mirror(t, DeclaredConfigRead, "dunst", dunstDeclared)
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"}}}`)
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)
for _, node := range []string{"g14", "novox"} {
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 module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"},
"no module value for the whole mesh"},
{"a module on a node it is not placed on", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
"it is placed on: g14, novox"},
{"a module on a node it is not assigned to", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
"it is assigned to: g14, novox"},
{"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"},
{"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`},
{"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"},
@@ -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.
func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) {
c, m := aMesh(t)
@@ -563,7 +489,7 @@ func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing
if len(m.events) != 1 || m.events[0].Action != "resolve" {
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 {
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
// alone): required trusted values, no default.
const despiegel = `{"module":"de-spiegel","version":"1","requires":["route","smtp"],"config":{
"praktijk-name":{"type":"string","description":"the practice's public name","trusted":true,"required":true},
"praktijk-www-name":{"type":"string","description":"its www form","trusted":true,"required":true}}}`
// Issue 1953: **each placed module's declarations come from its own manifests as built**, the trunk's at the indexer
// and the wanted build's at the builder, never from the controller's mirror; a module placed with no manifest to read
// is said unresolved with why, in the listing and the log, and keeps what was resolved for it.
func TestDeclarationsComeFromTheManifestsAsBuilt(t *testing.T) {
fp := "src2:" + strings.Repeat("ab", 32)
cases := []struct {
name string
given func(t *testing.T, m *memStore)
// resolved is each key's resolved value on novox, `<module>.<key>` to source and value ("" for none).
resolved map[string]string
absent []string
unresolved string
}{
{name: "a module of its own repository resolves from the trunk's manifest, its value set",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
must(t, m.Put(ValuesState, Level{Kind: LevelModule, Node: "novox", Module: "de-spiegel"}.ValueKey(
"praktijk-name"), Value{Value: json.RawMessage(`"praktijkdespiegel.be"`), By: "the operator"}))
// The controller's mirror says otherwise, and is not read.
m.mirror(t, "mesh-controller.declared-config", "de-spiegel", `{"module":"de-spiegel","settings":{}}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": `module "praktijkdespiegel.be"`,
"de-spiegel.praktijk-www-name": "unset "}},
{name: "the wanted build's manifest lays its keys over the trunk's on its node",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
"praktijk-name":{"type":"string","description":"d","default":"old.example"},
"old-key":{"type":"number","description":"d","default":3}}}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": `default "old.example"`,
"de-spiegel.old-key": "default 3", "de-spiegel.praktijk-www-name": "unset "}},
{name: "a removed want resolves from the trunk alone",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
"old-key":{"type":"number","description":"d","default":3}}}`)
m.mirror(t, WantsRead, "want.novox.de-spiegel", `{"v":1,"removed":true}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "}, absent: []string{"de-spiegel.old-key"}},
{name: "a placed module the indexer holds no manifest of is unresolved, said, its values kept",
given: func(t *testing.T, m *memStore) {
must(t, m.Put(ResolvedState, ResolvedKey("novox", "de-spiegel", "praktijk-name"),
Resolved{Node: "novox", Module: "de-spiegel", Key: "praktijk-name", Source: SourceUnset}))
m.assign(t, "novox", "de-spiegel")
},
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "},
unresolved: "the indexer holds no modules.de-spiegel"},
// Issue 1098: a value of the assignments bucket with `ended` set, or with a `state`, is not an assignment: it
// resolves nothing and takes the module's resolved values away, as a removed key does.
func TestAnEndedAssignmentIsNotAnAssignment(t *testing.T) {
c, m := aMesh(t)
m.assign(t, "ace", "dunst")
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
t.Fatalf("the assignment moved %+v (%v)", r.Moved, err)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := newStore()
tc.given(t, m)
var said []string
c := &Config{Store: m, Now: time.Now, Logf: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }}
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
}
for k, want := range tc.resolved {
module, key, _ := strings.Cut(k, ".")
r := m.resolved(t, "novox", module, key)
if got := r.Source + " " + string(r.Value); got != want {
t.Errorf("%s resolved as %s, want %s", k, got, want)
}
}
for _, k := range tc.absent {
module, key, _ := strings.Cut(k, ".")
if e, _ := m.Get(ResolvedState, ResolvedKey("novox", module, key)); e != nil {
t.Errorf("%s is resolved: %s", k, e.Value)
}
}
l, err := c.List(Call{Module: "de-spiegel"})
if err != nil {
t.Fatal(err)
}
if tc.unresolved == "" {
if len(l.Unresolved) != 0 {
t.Errorf("unresolved: %+v", l.Unresolved)
}
return
}
if len(l.Unresolved) != 1 || l.Unresolved[0].Node != "novox" ||
!strings.Contains(l.Unresolved[0].Why, tc.unresolved) {
t.Errorf("the listing says %+v, want %q", l.Unresolved, tc.unresolved)
}
if !strings.Contains(strings.Join(said, "\n"), tc.unresolved) {
t.Errorf("the log does not say it: %q", said)
}
})
for _, ended := range []string{`"ended":"2026-10-18T01:00:00Z"`, `"state":"pending"`} {
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+ended+`}`)
r, err := c.Reconcile()
if err != nil || len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("%s moved %+v (%v)", ended, r.Moved, err)
}
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil {
t.Errorf("%s: the module keeps its resolved values", ended)
}
m.assign(t, "ace", "dunst")
if r, _ = c.Reconcile(); len(r.Moved) != 4 {
t.Fatalf("the assignment standing again moved %+v", r.Moved)
}
}
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatal(err)
// Issue 1098: the fields the controller adds to an assignment change nothing, under the same key.
func TestAnAssignmentCarriesBuildCommitAndRolloutWithoutChangingItsKey(t *testing.T) {
c, m := aMesh(t)
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+
`"build":"b-1","artifact":{"name":"dunst","version":"1.2"},"commit":"abc123","rollout":"r-9",`+
`"since":"2026-10-18T00:00:00Z","ended":"","state":"","future":{"x":1}}`)
r, err := c.Reconcile()
if err != nil || len(r.Moved) != 4 {
t.Fatalf("an assignment with the new fields moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
if r, _ = c.Reconcile(); len(r.Moved) != 0 {
t.Errorf("a quiet pass moved %+v", r.Moved)
}
}
+20 -33
View File
@@ -11,8 +11,8 @@ import (
"strings"
)
// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations as its manifests declare
// them, the list of predefined keys, and where each module is placed (issue 1953).
// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations, the list of predefined
// keys, and the assignments, as the controller mirrors them on the bus (design 57 F3, K3).
// The types a config value may have (ADR 0348 §4).
const (
@@ -69,34 +69,7 @@ type ModuleDeclarations struct {
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
// 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.
// ParseModuleDeclarations reads one key of the declared-config mirror.
func ParseModuleDeclarations(raw []byte) (ModuleDeclarations, error) {
var m ModuleDeclarations
if err := json.Unmarshal(raw, &m); err != nil {
@@ -148,10 +121,24 @@ type Published struct {
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 {
Node string `json:"node"`
Module string `json:"module"`
Node string `json:"node"`
Module string `json:"module"`
Ended json.RawMessage `json:"ended,omitempty"`
State json.RawMessage `json:"state,omitempty"`
}
// Standing is whether the value is a standing assignment: neither ended nor in a state.
func (a Assignment) Standing() bool { return !fieldSet(a.Ended) && !fieldSet(a.State) }
// fieldSet is whether a field of an assignment's value carries something: not absent, null or empty.
func fieldSet(f json.RawMessage) bool {
s := strings.TrimSpace(string(f))
return s != "" && s != "null" && s != `""`
}
// 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"`
// Withheld is why the newest list of predefined keys is not published, when it is not.
Withheld string `json:"withheld,omitempty"`
// Unresolved is each placed module the call narrows to whose declarations cannot be read, with why (issue 1953).
Unresolved []Unresolved `json:"unresolved,omitempty"`
// Held is the approved values held for open pull requests, not yet set (novox/hq ADR 0502).
Held []HeldValue `json:"held,omitempty"`
}
@@ -44,18 +42,12 @@ func (c *Config) List(call Call) (*Listed, error) {
if err != nil {
return nil, err
}
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
for _, u := range s.Unresolved {
if (call.Module == "" || u.Module == call.Module) && (call.Node == "" || u.Node == call.Node) {
out.Unresolved = append(out.Unresolved, u)
}
}
if call.Module != "" {
if _, ok := s.Declared[call.Module]; !ok && len(out.Unresolved) == 0 {
return nil, fmt.Errorf("%s is placed on no node with a manifest the indexer holds, so it declares no "+
"config", call.Module)
if _, ok := s.Declared[call.Module]; !ok {
return nil, fmt.Errorf("%s is not a registered module, so it declares no config", call.Module)
}
}
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
resolved := Resolve(s)
for _, k := range sortedKeys(resolved) {
r := resolved[k]
+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
// published list of predefined keys) and mesh-config_proposals (each value proposed for the operator's Approve,
// asked under this module's own name, design 57 K4).
// It resolves from where the seat mesh-placement places each module, and each module's declarations as its own
// manifests declare them, the trunk's and its wanted build's (the indexer's and the builder's state, issue 1953); the
// list of predefined keys still comes from the controller's mirror until the indexer keeps it (ADR 0565 §2). stdout is the MCP channel; what this module says, it says on stderr.
// It resolves from the controller's mirrors of the assignments, the modules' declarations and the list it kept
// (design 57 F3, K3). stdout is the MCP channel; what this module says, it says on stderr.
package main
import (
@@ -116,9 +115,7 @@ func resolving(c *Config) {
}
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
// 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} {
for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} {
go func(read string) {
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
time.Sleep(wait)
@@ -573,7 +573,7 @@ func TestTheProposalArgumentsAreReadStrictly(t *testing.T) {
// not refused as carrying a secret: modules/asus-zephyrus-g14, i3status-rust and powerlevel10k are names.
func TestAnOwnersListOfNamesWithDigitsIsProposed(t *testing.T) {
c, m, r := aProposingMesh(t)
m.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}}}`)
m.assign(t, "novox", "mesh-issues")
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)
}
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 "+
"it is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.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 assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node,
strings.Join(nodesOf(s, level.Module), ", "))
}
return d.rule(), pd, declDigest(d), nil
@@ -23,7 +23,7 @@ func n8nPullDeclares() map[string]any {
func aHoldingMesh(t *testing.T) (*Config, *memStore, *standIn, *standForge) {
t.Helper()
c, m, r := aProposingMesh(t)
m.declare(t, "n8n", `{"module":"n8n","settings":{}}`)
m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{}}`)
m.assign(t, "ace", "n8n")
f := &standForge{state: map[string][2]bool{}}
c.Forge = f
@@ -112,7 +112,7 @@ func TestAPullRequestsDeclarationsAreTakenFromTheControllerAlone(t *testing.T) {
}
// 1826/K2: a proposal for a key only the pull request declares is refused without its declaration, for a mesh or
// node level, for a clear, for an undeclared key, and for a module not 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.
func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T) {
c, m, _, _ := aHoldingMesh(t)
@@ -142,7 +142,7 @@ func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T)
"a key not added": {func(c *Call) { c.Key = "other" }, "does not add or change a value"},
"a bad value": {func(c *Call) { c.Value = 0.0 }, "below the minimum"},
"a wrong type": {func(c *Call) { c.Value = "2000" }, "is not a number"},
"another node": {func(c *Call) { c.Node = "g14" }, "is not 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"},
"a malformed pull": {func(c *Call) { c.Pull = "434" }, "owner/repository#number"},
} {
@@ -342,7 +342,7 @@ func TestTheMergeAppliesAHeldValueAndItResolvesOnceTheTrunkDeclaresTheKey(t *tes
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "n8n", "media-gid")); e != nil {
t.Errorf("resolved before the trunk declares the key: %s", e.Value)
}
m.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}}}`)
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
+13 -59
View File
@@ -6,8 +6,6 @@ import (
"sort"
"strings"
"time"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// The four sources (novox/hq ADR 0348 §2, §3, §9): a module's value on a node resolves, the most specific winning,
@@ -22,21 +20,12 @@ const (
PredefinedState = "predefined"
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
// node's key `placement.<node>` with the modules placed there. It is the one source of which module runs where
// (issue 1953): the controller's assignments are no longer read.
PlacementRead = delivery.PlacementRead
// WantsRead is the assigner's wants, `want.<node>.<module>`: the build each node is to run (ADR 0539 §1).
WantsRead = delivery.AssignerModule + ".wants"
// BuildsRead is the builder's builds, `<module>.<fingerprint hex>`: each build's manifest digest (ADR 0525).
BuildsRead = delivery.BuilderModule + ".builds"
// ModulesRead is the indexer's modules, `<module>`: the trunk's manifest digest of each module, from whichever
// repository holds it (ADR 0437 §5, ADR 0478 §9); ManifestsRead its manifests by digest (ADR 0429 §16).
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
// PredefinedRead is the controller's list of predefined keys, which ADR 0565 §2 moves to the indexer's
// `predefined`: read here until that state exists.
PredefinedRead = "mesh-controller.predefined"
// node's key `placement.<node>` with the modules placed there.
PlacementRead = "mesh-delivery-assigner.placement"
DeclaredConfigRead = "mesh-controller.declared-config"
PredefinedRead = "mesh-controller.predefined"
// HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9).
HistoryLog = "history"
@@ -134,59 +123,24 @@ type Resolved struct {
// ResolvedKey is a resolved value's key in mesh-config_resolved.
func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key }
// Sources is everything a resolution reads: where each module is placed, every module's declarations, the published
// list of predefined keys, and every value set.
// Sources is everything a resolution reads: the assignments, every module's declarations, the published list of
// predefined keys, and every value set.
type Sources struct {
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
// OnNode is, by `<node>.<module>`, the declarations of the build the node's want names laid over the trunk's: what
// that node resolves. A placed module absent here resolves from Declared.
OnNode map[string]ModuleDeclarations
// Unresolved is each placed module whose declarations could not be read, with why: never skipped silently
// (issue 1953). Its resolved values are kept as they were until its declarations read again.
Unresolved []Unresolved
Predefined map[string]Published
Values map[string]Value
Declared map[string]ModuleDeclarations
Predefined map[string]Published
Values map[string]Value
// Pulls is what the controller told of each open pull request's declarations, and Kept the approved values held
// for them (pulls.go); neither is resolved.
Pulls map[string]PullDecl
Kept map[string]KeptValue
}
// Unresolved is a module placed on a node whose values are not resolved, and why.
type Unresolved struct {
Node string `json:"node"`
Module string `json:"module"`
Why string `json:"why"`
}
// declarationsOn is the declarations a module on a node resolves from, and whether any are held.
func (s Sources) declarationsOn(node, module string) (ModuleDeclarations, bool) {
if d, ok := s.OnNode[node+"."+module]; ok {
return d, true
}
d, ok := s.Declared[module]
return d, ok
}
// unresolved is whether a module on a node is unresolved.
func (s Sources) unresolved(node, module string) bool {
for _, u := range s.Unresolved {
if u.Node == node && u.Module == module {
return true
}
}
return false
}
// Resolve is every placed module's every declared value on its node, keyed as mesh-config_resolved is. A placed
// module with no declarations is in s.Unresolved, said there (sources).
// Resolve is every assigned module's every declared value on its node, keyed as mesh-config_resolved is.
func Resolve(s Sources) map[string]Resolved {
out := map[string]Resolved{}
for _, a := range s.Assignments {
decl, ok := s.declarationsOn(a.Node, a.Module)
decl, ok := s.Declared[a.Module]
if !ok {
continue
}
@@ -242,7 +196,7 @@ type Moved struct {
From json.RawMessage `json:"from,omitempty"`
To json.RawMessage `json:"to,omitempty"`
Source string `json:"source"`
// Gone says the module no longer takes the key there: 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"`
}
+58 -233
View File
@@ -4,30 +4,73 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"git.novox.be/novox/mesh-sdk/go/delivery"
)
// Reading the sources, writing the resolved values, and publishing the list of predefined keys.
// sources reads everything a resolution reads, from the bus as it is now: which modules are placed where from the
// seat mesh-placement, and each placed module's declarations from its own manifests as built, never from the
// controller (issue 1953, ADR 0565 §3).
// sources reads everything a resolution reads, from the bus as it is now.
func (c *Config) sources() (Sources, error) {
s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{},
Predefined: map[string]Published{}, Values: map[string]Value{}}
s := Sources{Declared: map[string]ModuleDeclarations{}, Predefined: map[string]Published{},
Values: map[string]Value{}}
var problems []string
if err := c.readPlacement(&s); err != nil {
return s, fmt.Errorf("the seat mesh-placement's placement cannot be read: %w", err)
if err := c.each(AssignmentsRead, func(key string, raw []byte) error {
var a Assignment
if err := json.Unmarshal(raw, &a); err != nil || a.Node == "" || a.Module == "" {
return fmt.Errorf("the assignment %s is not one: %s", key, raw)
}
if !a.Standing() {
return nil // ended or pending: read as a removed key (issue 1098)
}
s.Assignments = append(s.Assignments, a)
return nil
}); err != nil {
return s, fmt.Errorf("the assignments cannot be read: %w", err)
}
if err := c.readDeclarations(&s); err != nil {
// 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)
}
for _, u := range s.Unresolved {
problems = append(problems, fmt.Sprintf("%s on %s: %s", u.Module, u.Node, u.Why))
}
if err := c.each(PredefinedState, func(key string, raw []byte) error {
var p Published
if err := json.Unmarshal(raw, &p); err != nil {
@@ -52,222 +95,11 @@ func (c *Config) sources() (Sources, error) {
return s, err
}
for _, p := range problems {
c.logf("[mesh-config] a placed module's declarations are not read, so its values are not resolved (they "+
"stay as last resolved): %s", p)
c.logf("[mesh-config] a module's declarations are not read, so its values are not resolved: %s", p)
}
return s, nil
}
// readPlacement is every module the seat mesh-placement places on a node (ADR 0567 §1, §9): each node's key
// `placement.<node>`.
func (c *Config) readPlacement(s *Sources) error {
placed := map[string]bool{}
return c.each(PlacementRead, func(key string, raw []byte) error {
node, err := delivery.ParsePlacementKey(key)
if err != nil {
return nil // a key of another kind
}
var p struct {
Node string `json:"node"`
Modules []struct {
Module string `json:"module"`
} `json:"modules"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.Node != node {
return fmt.Errorf("the placement %s is not one", key)
}
for _, m := range p.Modules {
if m.Module != "" && !placed[node+"."+m.Module] {
placed[node+"."+m.Module] = true
s.Assignments = append(s.Assignments, Assignment{Node: node, Module: m.Module})
}
}
return nil
})
}
// readDeclarations reads each placed module's declarations from its manifests as the delivery holds them: the trunk's
// (the indexer's `modules.<module>` and its manifest by digest), and, on each node whose want names a build, that
// build's (the builder's `<module>.<fp>` names its manifest digest). On a node the want's build is laid over the
// trunk's, key by key, so a key only the build it runs declares still resolves there and a key the trunk adds resolves
// before its build is wanted; Declared is the trunk's with every key only a wanted build declares. A placed module
// none of whose manifests can be read is Unresolved, with why.
func (c *Config) readDeclarations(s *Sources) error {
wanted := map[string]string{} // <node>.<module> → the fingerprint its want names
if err := c.each(WantsRead, func(key string, raw []byte) error {
node, module, err := delivery.ParseWantKey(key)
if err != nil {
return nil // a held key or a node's own want
}
var w struct {
FP string `json:"fp"`
Removed bool `json:"removed"`
}
if json.Unmarshal(raw, &w) == nil && !w.Removed && w.FP != "" {
wanted[node+"."+module] = w.FP
}
return nil
}); err != nil {
return fmt.Errorf("the assigner's wants: %w", err)
}
trunk := map[string]ModuleDeclarations{}
trunkWhy := map[string]string{}
for _, module := range modulesOf(s.Assignments) {
d, err := c.trunkDeclarations(module)
if err != nil {
trunkWhy[module] = err.Error()
continue
}
trunk[module] = d
s.Declared[module] = d
}
for _, a := range s.Assignments {
t, hasTrunk := trunk[a.Module]
fp, isWanted := wanted[a.Node+"."+a.Module]
var built ModuleDeclarations
var builtErr error
if isWanted {
built, builtErr = c.builtDeclarations(a.Module, fp)
}
switch {
case isWanted && builtErr == nil:
on := overlay(t, built)
s.OnNode[a.Node+"."+a.Module] = on
d := s.Declared[a.Module]
s.Declared[a.Module] = overlay(built, d) // the trunk's wins in what a set is judged against
case hasTrunk:
if isWanted {
c.logf("[mesh-config] %s on %s resolves from the trunk's manifest alone: the build its want names "+
"cannot be read: %v", a.Module, a.Node, builtErr)
}
default:
why := trunkWhy[a.Module]
if isWanted {
why += "; and the build its want names: " + builtErr.Error()
} else {
why += "; and no want names a build of it on " + a.Node
}
s.Unresolved = append(s.Unresolved, Unresolved{Node: a.Node, Module: a.Module, Why: why})
}
}
return nil
}
// overlay is base with every key of over laid on it, the module's name kept.
func overlay(base, over ModuleDeclarations) ModuleDeclarations {
out := ModuleDeclarations{Module: over.Module, Config: map[string]Declaration{}}
if out.Module == "" {
out.Module = base.Module
}
for k, d := range base.Config {
out.Config[k] = d
}
for k, d := range over.Config {
out.Config[k] = d
}
return out
}
func modulesOf(as []Assignment) []string {
seen := map[string]bool{}
var out []string
for _, a := range as {
if !seen[a.Module] {
seen[a.Module] = true
out = append(out, a.Module)
}
}
sort.Strings(out)
return out
}
// trunkDeclarations is a module's declarations at the trunk: the manifest the indexer's `modules.<module>` names.
func (c *Config) trunkDeclarations(module string) (ModuleDeclarations, error) {
key, err := delivery.ModulesKey(module)
if err != nil {
return ModuleDeclarations{}, err
}
e, err := c.Store.Get(ModulesRead, key)
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer's modules.%s cannot be read: %w", module, err)
}
if e == nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer holds no modules.%s, so no manifest of it is known", module)
}
var m struct {
Manifest string `json:"manifest"`
}
if err := json.Unmarshal(e.Value, &m); err != nil || m.Manifest == "" {
return ModuleDeclarations{}, fmt.Errorf("the indexer's modules.%s names no manifest", module)
}
return c.manifestDeclarations(module, m.Manifest)
}
// builtDeclarations is a module's declarations at a build: the manifest the builder's done key names.
func (c *Config) builtDeclarations(module, fp string) (ModuleDeclarations, error) {
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
return ModuleDeclarations{}, err
}
e, err := c.Store.Get(BuildsRead, key)
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the builder's %s cannot be read: %w", key, err)
}
if e == nil {
return ModuleDeclarations{}, fmt.Errorf("the builder holds no build %s", key)
}
var b struct {
Manifest string `json:"manifest"`
}
if err := json.Unmarshal(e.Value, &b); err != nil || b.Manifest == "" {
return ModuleDeclarations{}, fmt.Errorf("the builder's %s names no manifest", key)
}
return c.manifestDeclarations(module, b.Manifest)
}
// manifestDeclarations is the declarations of the manifest of a digest in the indexer's manifests, read once: a
// manifests key is written once and never changes (ADR 0429 §16), so what it declares is kept by digest.
func (c *Config) manifestDeclarations(module, digest string) (ModuleDeclarations, error) {
c.manifestsMu.Lock()
d, ok := c.manifests[digest]
c.manifestsMu.Unlock()
if ok {
return d, nil
}
key, err := delivery.ManifestsKey(digest)
if err != nil {
return ModuleDeclarations{}, err
}
e, err := c.Store.Get(ManifestsRead, key)
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s cannot be read: %w", short(digest), err)
}
if e == nil {
return ModuleDeclarations{}, fmt.Errorf("the indexer holds no manifest %s", short(digest))
}
var m struct {
Module string `json:"module"`
JSON string `json:"manifest"`
}
if err := json.Unmarshal(e.Value, &m); err != nil || m.JSON == "" {
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s is not one", short(digest))
}
if m.Module != module {
return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s is %s's, not %s's", short(digest), m.Module, module)
}
d, err = ManifestDeclarations([]byte(m.JSON))
if err != nil {
return ModuleDeclarations{}, fmt.Errorf("the manifest %s: %w", short(digest), err)
}
c.manifestsMu.Lock()
if c.manifests == nil {
c.manifests = map[string]ModuleDeclarations{}
}
c.manifests[digest] = d
c.manifestsMu.Unlock()
return d, nil
}
// each hands every key of a state to visit.
func (c *Config) each(state string, visit func(key string, raw []byte) error) error {
keys, err := c.Store.Keys(state)
@@ -313,13 +145,6 @@ func (c *Config) writeResolved(s Sources) ([]Moved, error) {
return nil, fmt.Errorf("the resolved values cannot be read: %w", err)
}
want := Resolve(s)
// A placed module whose declarations cannot be read keeps what was resolved for it: not knowing is no reason to take
// a node's values away (issue 1953).
for key, h := range held {
if _, ok := want[key]; !ok && h.Source != "unreadable" && s.unresolved(h.Node, h.Module) {
want[key] = h
}
}
moved := diff(held, want)
var problems []error
for _, m := range moved {
+2 -4
View File
@@ -28,11 +28,9 @@
"gitea.gitea_get_pull_request"
],
"reads": [
"mesh-controller.assignments",
"mesh-delivery-assigner.placement",
"mesh-delivery-assigner.wants",
"mesh-delivery-builder.builds",
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.manifests",
"mesh-controller.declared-config",
"mesh-controller.predefined"
],
"state": [