Compare commits

...
9 Commits
Author SHA1 Message Date
mesh-tools 12c2574c9d Merge pull request 'Let the forge's sshd take a burst of connections from one build machine, so a check's parallel fetch is not dropped (issue 774)' (#561) from fix/gitea-ssh-maxstartups into main 2026-10-30 00:54:02 +00:00
jschoubben 8bf85863ce gitea: raise the forge's sshd MaxStartups to 100:30:200, so a burst of the mesh's machines fetching at once is not dropped
mesh/check being carried to 8bf85863cedb on 12c2574c9d54 | from f61265f7a0f3
mesh/delivery merged: source.main moved to novox/mesh-catalog@12c2574c9d54
2026-10-30 01:38:58 +01:00
mesh-tools f61265f7a0 Merge pull request 'Pin the parse-only mesh-sdk in the build modules, so they build design 47's manifests (issue 774)' (#559) from fix/774-new-words-first into main 2026-10-30 00:10:07 +00:00
jschoubben 22ff0d1000 Pin the parse-only mesh-sdk 78c9ec8 in the build modules, which read each manifest they build, so the new words are read first (issue 774)
mesh/delivery merged: source.main moved to novox/mesh-catalog@f61265f7a0f3
mesh/delivery-group group fix/774-new-words-first merged: every member is merged
mesh/check pass 22ff0d1000f3 on 96b129e54441 | judged by the candidate's mesh-build-go at d351f9c41ff8
2026-10-30 00:39:37 +01:00
jschoubben 7d43574783 Pin mesh-sdk 6e4a301 in the build modules, which read each manifest they build, so the new words are read first (issue 774) 2026-10-30 00:23:30 +01:00
mesh-tools 96b129e544 Merge pull request 'mesh-config: read config declarations from each placed module's own manifests on the bus, never from the controller (issue 1953)' (#558) from fix/1953-config-from-builds into main 2026-10-29 19:47:55 +00:00
jschoubben 0d71d8b56c Move the head so the checker takes #558 again: a reopened pull request is not taken back as a candidate (recovery of de-spiegel, operator's yes 2026-10-29 17:56 CET)
mesh/delivery merged: source.main moved to novox/mesh-catalog@96b129e54441
mesh/check being carried to 0d71d8b56c55 on 96b129e54441 | from 030f9ee4e780
2026-10-29 18:10:27 +01:00
jschoubben 96a37f58ca mesh-config: read declarations from each placed module's own manifests as built, and placement alone, so a module from its own repository resolves (issue 1953)
mesh/delivery closed: the pull request closed unmerged
mesh/check pass 96a37f58ca76 on 030f9ee4e780 | unit 1173 run, 3272 kept, 13 skipped | integration 1212 run, 3274 kept, 22 skipped | over bound: unit, …
The controller's declared-config mirror never held de-spiegel's keys, and Resolve skipped
an assigned module it held no declarations of, silently: de-spiegel stayed held on novox
with its values set. Declarations now come from the indexer's trunk manifest with the
wanted build's laid over it per node; which module runs where from the seat
mesh-placement alone. A placed module with no readable manifest is listed as unresolved
with why, logged, and keeps its last resolved values.
2026-10-29 15:19:24 +01:00
mesh-tools 030f9ee4e7 Merge pull request 'Revert L3: its node-engine removed every held module on novox, the bus server among them (issue 774)' (#557) from revert/774-l3 into main 2026-10-29 08:59:53 +00:00
21 changed files with 588 additions and 199 deletions
+26
View File
@@ -62,3 +62,29 @@ 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")
}
+2 -1
View File
@@ -266,7 +266,8 @@
"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"
+1 -1
View File
@@ -3,7 +3,7 @@ module meshbuildgo
go 1.26.0 go 1.26.0
require ( require (
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771
github.com/distribution/distribution/v3 v3.1.2 github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig=
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.20261029233535-78c9ec881771/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.20261028190821-9e5666b4d6f5 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771
github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig=
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.20261029233535-78c9ec881771/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op 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.20261028190821-9e5666b4d6f5 git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771
github.com/distribution/distribution/v3 v3.1.2 github.com/distribution/distribution/v3 v3.1.2
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
) )
+2 -2
View File
@@ -1,5 +1,5 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261028190821-9e5666b4d6f5 h1:L7EJbCPZfJRCYHyco5Ck3ui+pWc//sp+h5nnPqSarng= git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029233535-78c9ec881771 h1:Tt16HJk8JZRce1i3JzzDSNdCWOrv7dw8FQ/bckNQ5Ig=
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.20261029233535-78c9ec881771/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=
+8 -3
View File
@@ -21,11 +21,16 @@ account alone writes its buckets.
## How a value resolves ## How a value resolves
From the controller's mirrors of the assignments and of each module's declarations (`mesh-controller.assignments`, For every module the seat mesh-placement places on a node (`mesh-delivery-assigner.placement`), from the module's
`mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back own manifests as the delivery built them, never from the controller (issue 1953): the trunk's, which the indexer's
`modules.<module>` names in its manifests by digest (`mesh-delivery-indexer.modules`, `.manifests`), with the build the
node's want names (`mesh-delivery-assigner.wants`, the builder's `mesh-delivery-builder.builds` naming its manifest)
laid over it key by key on that node. So a module from its own repository resolves as a catalogue module does. A placed
module none of whose manifests can be read is listed under `unresolved` with why and said in the log, and keeps the
values last resolved for it. Then: the manifest's default; mesh config for the predefined key the value falls back
to; node config for that key on the node; the module's own value on that node, the most specific winning. A value 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 mirror moves, and every five minutes, writing only the values that moved. start, whenever a placement, a want or a trunk module moves, and every five minutes, writing only the values that moved.
## The verb ## The verb
@@ -97,6 +97,10 @@ 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.
@@ -336,8 +340,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 assigned to %s, and a module's own value is set for a node it "+ return rule{}, false, fmt.Errorf("%s is not placed on %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, "is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node,
strings.Join(nodesOf(s, level.Module), ", ")) strings.Join(nodesOf(s, level.Module), ", "))
} }
trusted := d.Trusted trusted := d.Trusted
@@ -9,6 +9,8 @@ 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,
@@ -120,9 +122,81 @@ 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) {
m.mirror(t, AssignmentsRead, node+"."+module, t.Helper()
fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module)) m.place(t, node, module, true)
}
// unassign takes a module off a node's placement.
func (m *memStore) unassign(t *testing.T, node, module string) {
t.Helper()
m.place(t, node, module, false)
}
func (m *memStore) place(t *testing.T, node, module string, on bool) {
t.Helper()
type placed struct {
Module string `json:"module"`
}
p := struct {
V int `json:"v"`
Node string `json:"node"`
Modules []placed `json:"modules"`
}{V: 1, Node: node}
if e, _ := m.Get(PlacementRead, "placement."+node); e != nil {
if err := json.Unmarshal(e.Value, &p); err != nil {
t.Fatal(err)
}
}
var kept []placed
for _, x := range p.Modules {
if x.Module != module {
kept = append(kept, x)
}
}
if on {
kept = append(kept, placed{Module: module})
}
p.Modules = kept
if err := m.Put(PlacementRead, "placement."+node, p); err != nil {
t.Fatal(err)
}
}
// manifest keeps a manifest in the indexer's manifests by its digest, as the indexer writes it, and answers the digest.
func (m *memStore) manifest(t *testing.T, module, raw string) string {
t.Helper()
if !json.Valid([]byte(raw)) {
t.Fatalf("not JSON: %s", raw)
}
digest := delivery.ManifestDigest([]byte(raw))
value, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw})
if err != nil {
t.Fatal(err)
}
m.mirror(t, ManifestsRead, digest, string(value))
return digest
}
// declare makes raw the module's manifest at the trunk: kept by digest, and named by the indexer's modules.<module>.
func (m *memStore) declare(t *testing.T, module, raw string) {
t.Helper()
digest := m.manifest(t, module, raw)
m.mirror(t, ModulesRead, module, fmt.Sprintf(`{"v":1,"manifest":%q,"repository":"novox/%s","commit":"c1"}`,
digest, module))
}
// want makes a node's want name a build of the module whose manifest is raw, as the assigner and the builder keep them.
func (m *memStore) want(t *testing.T, node, module, fp, raw string) {
t.Helper()
digest := m.manifest(t, module, raw)
key, err := delivery.BuilderBuildKey(module, fp)
if err != nil {
t.Fatal(err)
}
m.mirror(t, BuildsRead, key, fmt.Sprintf(`{"v":1,"state":"done","manifest":%q}`, digest))
m.mirror(t, WantsRead, "want."+node+"."+module, fmt.Sprintf(`{"v":1,"fp":%q,"self":true}`, fp))
} }
func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved { func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved {
@@ -154,10 +228,10 @@ const theList = `{"keys":{"agent-managed-settings":{"type":"object","description
func aMesh(t *testing.T) (*Config, *memStore) { func aMesh(t *testing.T) (*Config, *memStore) {
t.Helper() t.Helper()
m := newStore() m := newStore()
m.mirror(t, DeclaredConfigRead, "dunst", dunstDeclared) m.declare(t, "dunst", dunstDeclared)
m.mirror(t, DeclaredConfigRead, "claude-code", `{"module":"claude-code","settings":{ m.declare(t, "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.mirror(t, DeclaredConfigRead, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`) m.declare(t, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`)
m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) 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")
@@ -298,8 +372,8 @@ func TestTheVerbRefusesWhatADeclarationDoesNotAllow(t *testing.T) {
{"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"}, {"a 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 assigned to", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"}, {"a module on a node it is not placed on", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"},
"it is assigned to: g14, novox"}, "it is placed on: g14, novox"},
{"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"}, {"a 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"},
@@ -472,7 +546,7 @@ func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T
} }
} }
// **An assignment, a declaration or its removal moves the resolved values**, written only where they moved, and // **A placement, a declaration or its removal moves the resolved values**, written only where they moved, and
// said as config-changed; a pass over unchanged sources writes nothing. // 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)
@@ -489,7 +563,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.Delete(AssignmentsRead, "ace.dunst") m.unassign(t, "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)
} }
@@ -552,42 +626,110 @@ func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) {
} }
} }
// Issue 1098: a value of the assignments bucket with `ended` set, or with a `state`, is not an assignment: it // despiegel is de-spiegel's manifest as its own repository declares it (novox/de-spiegel at e25e1beb, its config
// resolves nothing and takes the module's resolved values away, as a removed key does. // alone): required trusted values, no default.
func TestAnEndedAssignmentIsNotAnAssignment(t *testing.T) { const despiegel = `{"module":"de-spiegel","version":"1","requires":["route","smtp"],"config":{
c, m := aMesh(t) "praktijk-name":{"type":"string","description":"the practice's public name","trusted":true,"required":true},
m.assign(t, "ace", "dunst") "praktijk-www-name":{"type":"string","description":"its www form","trusted":true,"required":true}}}`
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
t.Fatalf("the assignment moved %+v (%v)", r.Moved, err) // Issue 1953: **each placed module's declarations come from its own manifests as built**, the trunk's at the indexer
// and the wanted build's at the builder, never from the controller's mirror; a module placed with no manifest to read
// is said unresolved with why, in the listing and the log, and keeps what was resolved for it.
func TestDeclarationsComeFromTheManifestsAsBuilt(t *testing.T) {
fp := "src2:" + strings.Repeat("ab", 32)
cases := []struct {
name string
given func(t *testing.T, m *memStore)
// resolved is each key's resolved value on novox, `<module>.<key>` to source and value ("" for none).
resolved map[string]string
absent []string
unresolved string
}{
{name: "a module of its own repository resolves from the trunk's manifest, its value set",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
must(t, m.Put(ValuesState, Level{Kind: LevelModule, Node: "novox", Module: "de-spiegel"}.ValueKey(
"praktijk-name"), Value{Value: json.RawMessage(`"praktijkdespiegel.be"`), By: "the operator"}))
// The controller's mirror says otherwise, and is not read.
m.mirror(t, "mesh-controller.declared-config", "de-spiegel", `{"module":"de-spiegel","settings":{}}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": `module "praktijkdespiegel.be"`,
"de-spiegel.praktijk-www-name": "unset "}},
{name: "the wanted build's manifest lays its keys over the trunk's on its node",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
"praktijk-name":{"type":"string","description":"d","default":"old.example"},
"old-key":{"type":"number","description":"d","default":3}}}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": `default "old.example"`,
"de-spiegel.old-key": "default 3", "de-spiegel.praktijk-www-name": "unset "}},
{name: "a removed want resolves from the trunk alone",
given: func(t *testing.T, m *memStore) {
m.declare(t, "de-spiegel", despiegel)
m.assign(t, "novox", "de-spiegel")
m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{
"old-key":{"type":"number","description":"d","default":3}}}`)
m.mirror(t, WantsRead, "want.novox.de-spiegel", `{"v":1,"removed":true}`)
},
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "}, absent: []string{"de-spiegel.old-key"}},
{name: "a placed module the indexer holds no manifest of is unresolved, said, its values kept",
given: func(t *testing.T, m *memStore) {
must(t, m.Put(ResolvedState, ResolvedKey("novox", "de-spiegel", "praktijk-name"),
Resolved{Node: "novox", Module: "de-spiegel", Key: "praktijk-name", Source: SourceUnset}))
m.assign(t, "novox", "de-spiegel")
},
resolved: map[string]string{"de-spiegel.praktijk-name": "unset "},
unresolved: "the indexer holds no modules.de-spiegel"},
} }
for _, ended := range []string{`"ended":"2026-10-18T01:00:00Z"`, `"state":"pending"`} { for _, tc := range cases {
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+ended+`}`) t.Run(tc.name, func(t *testing.T) {
r, err := c.Reconcile() m := newStore()
if err != nil || len(r.Moved) != 4 || !r.Moved[0].Gone { tc.given(t, m)
t.Errorf("%s moved %+v (%v)", ended, r.Moved, err) var said []string
} c := &Config{Store: m, Now: time.Now, Logf: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }}
if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil { if _, err := c.Reconcile(); err != nil {
t.Errorf("%s: the module keeps its resolved values", ended) t.Fatal(err)
} }
m.assign(t, "ace", "dunst") for k, want := range tc.resolved {
if r, _ = c.Reconcile(); len(r.Moved) != 4 { module, key, _ := strings.Cut(k, ".")
t.Fatalf("the assignment standing again moved %+v", r.Moved) r := m.resolved(t, "novox", module, key)
} if got := r.Source + " " + string(r.Value); got != want {
t.Errorf("%s resolved as %s, want %s", k, got, want)
}
}
for _, k := range tc.absent {
module, key, _ := strings.Cut(k, ".")
if e, _ := m.Get(ResolvedState, ResolvedKey("novox", module, key)); e != nil {
t.Errorf("%s is resolved: %s", k, e.Value)
}
}
l, err := c.List(Call{Module: "de-spiegel"})
if err != nil {
t.Fatal(err)
}
if tc.unresolved == "" {
if len(l.Unresolved) != 0 {
t.Errorf("unresolved: %+v", l.Unresolved)
}
return
}
if len(l.Unresolved) != 1 || l.Unresolved[0].Node != "novox" ||
!strings.Contains(l.Unresolved[0].Why, tc.unresolved) {
t.Errorf("the listing says %+v, want %q", l.Unresolved, tc.unresolved)
}
if !strings.Contains(strings.Join(said, "\n"), tc.unresolved) {
t.Errorf("the log does not say it: %q", said)
}
})
} }
} }
// Issue 1098: the fields the controller adds to an assignment change nothing, under the same key. func must(t *testing.T, err error) {
func TestAnAssignmentCarriesBuildCommitAndRolloutWithoutChangingItsKey(t *testing.T) { t.Helper()
c, m := aMesh(t) if err != nil {
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+ t.Fatal(err)
`"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)
} }
} }
+33 -20
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, the list of predefined // What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations as its manifests declare
// keys, and the assignments, as the controller mirrors them on the bus (design 57 F3, K3). // them, the list of predefined keys, and where each module is placed (issue 1953).
// The types a config value may have (ADR 0348 §4). // The types a config value may have (ADR 0348 §4).
const ( const (
@@ -69,7 +69,34 @@ type ModuleDeclarations struct {
Config map[string]Declaration `json:"settings"` Config map[string]Declaration `json:"settings"`
} }
// ParseModuleDeclarations reads one key of the declared-config mirror. // ManifestDeclarations is the config a manifest declares (ADR 0348 §1, §4): its `config`, or for a module not yet
// moved its `settings` (kind, default and why), the two never both. Only these two fields are read, so a manifest
// field this module's SDK does not know yet never leaves a module's config unresolved.
func ManifestDeclarations(raw []byte) (ModuleDeclarations, error) {
var m struct {
Module string `json:"module"`
Config map[string]Declaration `json:"config"`
Settings map[string]Declaration `json:"settings"`
}
if err := json.Unmarshal(raw, &m); err != nil {
return ModuleDeclarations{}, fmt.Errorf("not a manifest: %w", err)
}
if m.Config != nil && m.Settings != nil {
return ModuleDeclarations{}, fmt.Errorf("%s declares both config and settings, and a module uses one form "+
"(novox/hq ADR 0348 §1)", m.Module)
}
decl := m.Config
if decl == nil {
decl = m.Settings
}
raw, err := json.Marshal(ModuleDeclarations{Module: m.Module, Config: decl})
if err != nil {
return ModuleDeclarations{}, err
}
return ParseModuleDeclarations(raw)
}
// ParseModuleDeclarations reads one module's declarations in the layout ModuleDeclarations writes.
func ParseModuleDeclarations(raw []byte) (ModuleDeclarations, error) { 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 {
@@ -121,24 +148,10 @@ type Published struct {
Commit string `json:"commit"` Commit string `json:"commit"`
} }
// Assignment is one key of the assignments bucket: a module on a node. The value also carries the build, commit and // Assignment is a module placed on a node, as the seat mesh-placement's key `placement.<node>` names it.
// 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.
+11 -3
View File
@@ -17,6 +17,8 @@ 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"`
} }
@@ -42,12 +44,18 @@ 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 { if _, ok := s.Declared[call.Module]; !ok && len(out.Unresolved) == 0 {
return nil, fmt.Errorf("%s is not a registered module, so it declares no config", call.Module) return nil, fmt.Errorf("%s is placed on no node with a manifest the indexer holds, so it declares no "+
"config", call.Module)
} }
} }
out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld}
resolved := Resolve(s) resolved := Resolve(s)
for _, k := range sortedKeys(resolved) { for _, k := range sortedKeys(resolved) {
r := resolved[k] r := resolved[k]
+6 -3
View File
@@ -4,8 +4,9 @@
// 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 the controller's mirrors of the assignments, the modules' declarations and the list it kept // It resolves from where the seat mesh-placement places each module, and each module's declarations as its own
// (design 57 F3, K3). stdout is the MCP channel; what this module says, it says on stderr. // manifests declare them, the trunk's and its wanted build's (the indexer's and the builder's state, issue 1953); the
// list of predefined keys still comes from the controller's mirror until the indexer keeps it (ADR 0565 §2). stdout is the MCP channel; what this module says, it says on stderr.
package main package main
import ( import (
@@ -115,7 +116,9 @@ func resolving(c *Config) {
} }
return nil return nil
} }
for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} { // A build's manifest is written before a want names the build, so a want, a trunk module or a placement moving is
// every change of what a module declares where (issue 1953); the builds themselves are read, not watched.
for _, read := range []string{PlacementRead, WantsRead, ModulesRead, PredefinedRead} {
go func(read string) { 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.mirror(t, DeclaredConfigRead, "mesh-issues", `{"module":"mesh-issues","settings":{ m.declare(t, "mesh-issues", `{"module":"mesh-issues","settings":{
"owners":{"kind":"","default":[],"why":"","type":"list","description":"owners","trusted":true}}}`) "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"))
@@ -1,36 +0,0 @@
package main
import "testing"
// A module only pulled in by another on a node, and handed to the provisioner there, has a value of its own in the
// assignments mirror (mesh-controller internal/inventory AssignmentValues, issue 765): what requires it, its plan,
// and no time it was assigned. Its values resolve there like an assigned module's, so the node's client can fill its
// templates; they go when the value goes.
func TestAModulePulledInHasItsValuesResolvedOnItsNode(t *testing.T) {
c, m := aMesh(t)
if _, err := c.Reconcile(); err != nil {
t.Fatal(err)
}
m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","required-by":"desk",`+
`"provisioning":{"templates":{"source":"x","digest":"y"},"files":[]}}`)
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
t.Fatalf("a pulled-in module's value moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
_ = m.Delete(AssignmentsRead, "ace.dunst")
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("a module no longer pulled in moved %+v", r.Moved)
}
// 1126 (ADR 0549 §3, §5): a module the catalogue's placement names on a node resolves there as an assigned one does,
// while the two sources stand; it goes when neither names it.
m.mirror(t, PlacementRead, "placement.ace", `{"v":1,"node":"ace","modules":[{"module":"dunst"}]}`)
if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 {
t.Fatalf("a placed module's values moved %+v (%v)", r.Moved, err)
}
expect(t, m, "ace", "10", SourceDefault)
_ = m.Delete(PlacementRead, "placement.ace")
if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone {
t.Errorf("a module no longer placed moved %+v", r.Moved)
}
}
+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 assigned to %s, and a module's own value is set for a node "+ return rule{}, PullDecl{}, "", fmt.Errorf("%s is not placed on %s, and a module's own value is set for a node "+
"it is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node, "it is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node,
strings.Join(nodesOf(s, level.Module), ", ")) 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.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{}}`) m.declare(t, "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 assigned to the node; and trunk-only // node level, for a clear, for an undeclared key, and for a module not placed on the node; and trunk-only
// proposals are judged as before. // 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 assigned to g14"}, "another node": {func(c *Call) { c.Node = "g14" }, "is not placed on g14"},
"another pull": {func(c *Call) { c.Pull = "novox/mesh-catalog#435" }, "holds no declaration for"}, "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.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+ m.declare(t, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+
`"type":"number","required":true,"minimum":1,"maximum":65535}}}`) `"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)
+59 -13
View File
@@ -6,6 +6,8 @@ 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,
@@ -20,12 +22,21 @@ 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. // node's key `placement.<node>` with the modules placed there. It is the one source of which module runs where
PlacementRead = "mesh-delivery-assigner.placement" // (issue 1953): the controller's assignments are no longer read.
DeclaredConfigRead = "mesh-controller.declared-config" PlacementRead = delivery.PlacementRead
PredefinedRead = "mesh-controller.predefined" // WantsRead is the assigner's wants, `want.<node>.<module>`: the build each node is to run (ADR 0539 §1).
WantsRead = delivery.AssignerModule + ".wants"
// BuildsRead is the builder's builds, `<module>.<fingerprint hex>`: each build's manifest digest (ADR 0525).
BuildsRead = delivery.BuilderModule + ".builds"
// ModulesRead is the indexer's modules, `<module>`: the trunk's manifest digest of each module, from whichever
// repository holds it (ADR 0437 §5, ADR 0478 §9); ManifestsRead its manifests by digest (ADR 0429 §16).
ModulesRead = delivery.IndexerModule + ".modules"
ManifestsRead = delivery.IndexerModule + ".manifests"
// PredefinedRead is the controller's list of predefined keys, which ADR 0565 §2 moves to the indexer's
// `predefined`: read here until that state exists.
PredefinedRead = "mesh-controller.predefined"
// HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9). // HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9).
HistoryLog = "history" HistoryLog = "history"
@@ -123,24 +134,59 @@ 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: the assignments, every module's declarations, the published list of // Sources is everything a resolution reads: where each module is placed, every module's declarations, the published
// predefined keys, and every value set. // list of predefined keys, and every value set.
type Sources struct { type Sources struct {
Assignments []Assignment Assignments []Assignment
Declared map[string]ModuleDeclarations // Declared is each module's declarations as its manifests declare them: the trunk's, with every key only a wanted
Predefined map[string]Published // build still declares (declarationsOf). It is what a set is judged against.
Values map[string]Value Declared map[string]ModuleDeclarations
// OnNode is, by `<node>.<module>`, the declarations of the build the node's want names laid over the trunk's: what
// that node resolves. A placed module absent here resolves from Declared.
OnNode map[string]ModuleDeclarations
// Unresolved is each placed module whose declarations could not be read, with why: never skipped silently
// (issue 1953). Its resolved values are kept as they were until its declarations read again.
Unresolved []Unresolved
Predefined map[string]Published
Values map[string]Value
// Pulls is what the controller told of each open pull request's declarations, and Kept the approved values held // Pulls is what the controller told of each open pull request's declarations, and Kept the approved values held
// for them (pulls.go); neither is resolved. // for them (pulls.go); neither is resolved.
Pulls map[string]PullDecl Pulls map[string]PullDecl
Kept map[string]KeptValue Kept map[string]KeptValue
} }
// Resolve is every assigned module's every declared value on its node, keyed as mesh-config_resolved is. // Unresolved is a module placed on a node whose values are not resolved, and why.
type Unresolved struct {
Node string `json:"node"`
Module string `json:"module"`
Why string `json:"why"`
}
// declarationsOn is the declarations a module on a node resolves from, and whether any are held.
func (s Sources) declarationsOn(node, module string) (ModuleDeclarations, bool) {
if d, ok := s.OnNode[node+"."+module]; ok {
return d, true
}
d, ok := s.Declared[module]
return d, ok
}
// unresolved is whether a module on a node is unresolved.
func (s Sources) unresolved(node, module string) bool {
for _, u := range s.Unresolved {
if u.Node == node && u.Module == module {
return true
}
}
return false
}
// Resolve is every placed module's every declared value on its node, keyed as mesh-config_resolved is. A placed
// module with no declarations is in s.Unresolved, said there (sources).
func Resolve(s Sources) map[string]Resolved { 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.Declared[a.Module] decl, ok := s.declarationsOn(a.Node, a.Module)
if !ok { if !ok {
continue continue
} }
@@ -196,7 +242,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: unassigned, or no longer declared. // Gone says the module no longer takes the key there: no longer placed, or no longer declared.
Gone bool `json:"gone,omitempty"` Gone bool `json:"gone,omitempty"`
} }
+233 -58
View File
@@ -4,73 +4,30 @@ 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. // sources reads everything a resolution reads, from the bus as it is now: which modules are placed where from the
// seat mesh-placement, and each placed module's declarations from its own manifests as built, never from the
// controller (issue 1953, ADR 0565 §3).
func (c *Config) sources() (Sources, error) { func (c *Config) sources() (Sources, error) {
s := Sources{Declared: map[string]ModuleDeclarations{}, Predefined: map[string]Published{}, s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{},
Values: map[string]Value{}} Predefined: map[string]Published{}, Values: map[string]Value{}}
var problems []string var problems []string
if err := c.each(AssignmentsRead, func(key string, raw []byte) error { if err := c.readPlacement(&s); err != nil {
var a Assignment return s, fmt.Errorf("the seat mesh-placement's placement cannot be read: %w", err)
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)
} }
// And every module the seat's placement names on a node (ADR 0549 §3): until ADR 0547's third milestone both if err := c.readDeclarations(&s); err != nil {
// 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 {
@@ -95,11 +52,222 @@ 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 module's declarations are not read, so its values are not resolved: %s", p) c.logf("[mesh-config] a placed module's declarations are not read, so its values are not resolved (they "+
"stay as last resolved): %s", p)
} }
return s, nil 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)
@@ -145,6 +313,13 @@ 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 {
+4 -2
View File
@@ -28,9 +28,11 @@
"gitea.gitea_get_pull_request" "gitea.gitea_get_pull_request"
], ],
"reads": [ "reads": [
"mesh-controller.assignments",
"mesh-delivery-assigner.placement", "mesh-delivery-assigner.placement",
"mesh-controller.declared-config", "mesh-delivery-assigner.wants",
"mesh-delivery-builder.builds",
"mesh-delivery-indexer.modules",
"mesh-delivery-indexer.manifests",
"mesh-controller.predefined" "mesh-controller.predefined"
], ],
"state": [ "state": [