From 96a37f58ca76d5af3f8d1d9de36af52d6e2e7b02 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 29 Oct 2026 15:19:24 +0100 Subject: [PATCH] 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) 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. --- modules/mesh-config/README.md | 11 +- modules/mesh-config/cmd/mesh-config/config.go | 8 +- .../cmd/mesh-config/config_test.go | 226 +++++++++++--- .../mesh-config/cmd/mesh-config/declared.go | 53 ++-- modules/mesh-config/cmd/mesh-config/list.go | 14 +- modules/mesh-config/cmd/mesh-config/main.go | 9 +- .../cmd/mesh-config/proposals_test.go | 2 +- .../cmd/mesh-config/pulled_in_test.go | 36 --- modules/mesh-config/cmd/mesh-config/pulls.go | 4 +- .../mesh-config/cmd/mesh-config/pulls_test.go | 8 +- .../mesh-config/cmd/mesh-config/resolve.go | 72 ++++- .../mesh-config/cmd/mesh-config/sources.go | 291 ++++++++++++++---- modules/mesh-config/module.json | 6 +- 13 files changed, 551 insertions(+), 189 deletions(-) delete mode 100644 modules/mesh-config/cmd/mesh-config/pulled_in_test.go diff --git a/modules/mesh-config/README.md b/modules/mesh-config/README.md index b8ac192f..42bd1ed0 100644 --- a/modules/mesh-config/README.md +++ b/modules/mesh-config/README.md @@ -21,11 +21,16 @@ account alone writes its buckets. ## How a value resolves -From the controller's mirrors of the assignments and of each module's declarations (`mesh-controller.assignments`, -`mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back +For every module the seat mesh-placement places on a node (`mesh-delivery-assigner.placement`), from the module's +own manifests as the delivery built them, never from the controller (issue 1953): the trunk's, which the indexer's +`modules.` names in its manifests by digest (`mesh-delivery-indexer.modules`, `.manifests`), with the build the +node's want names (`mesh-delivery-assigner.wants`, the builder's `mesh-delivery-builder.builds` naming its manifest) +laid over it key by key on that node. So a module from its own repository resolves as a catalogue module does. A placed +module none of whose manifests can be read is listed under `unresolved` with why and said in the log, and keeps the +values last resolved for it. Then: the manifest's default; mesh config for the predefined key the value falls back to; node config for that key on the node; the module's own value on that node, the most specific winning. A value naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at -start, whenever a mirror moves, and every five minutes, writing only the values that moved. +start, whenever a placement, a want or a trunk module moves, and every five minutes, writing only the values that moved. ## The verb diff --git a/modules/mesh-config/cmd/mesh-config/config.go b/modules/mesh-config/cmd/mesh-config/config.go index a2103671..0f2435cc 100644 --- a/modules/mesh-config/cmd/mesh-config/config.go +++ b/modules/mesh-config/cmd/mesh-config/config.go @@ -97,6 +97,10 @@ type Config struct { RouterWithin, RouterEvery time.Duration endedMu sync.Mutex ended map[string]endedWord + + // manifests is each manifest's declarations read, by digest: a manifest never changes (manifestDeclarations). + manifestsMu sync.Mutex + manifests map[string]ModuleDeclarations } // Call is one call of the verb, its arguments read. @@ -336,8 +340,8 @@ func (c *Config) declared(s Sources, level Level, key string) (rule, bool, error joinKeys(decl.Config)) } if !assigned(s, level.Node, level.Module) { - return rule{}, false, fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node it "+ - "is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node, + return rule{}, false, fmt.Errorf("%s is not placed on %s, and a module's own value is set for a node it "+ + "is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node, strings.Join(nodesOf(s, level.Module), ", ")) } trusted := d.Trusted diff --git a/modules/mesh-config/cmd/mesh-config/config_test.go b/modules/mesh-config/cmd/mesh-config/config_test.go index 91a14c71..b086ff21 100644 --- a/modules/mesh-config/cmd/mesh-config/config_test.go +++ b/modules/mesh-config/cmd/mesh-config/config_test.go @@ -9,6 +9,8 @@ import ( "sync" "testing" "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) // memStore is the bus as mesh-config reaches it, in memory: its states and the mirrors it reads, its history log, @@ -120,9 +122,81 @@ func (m *memStore) mirror(t *testing.T, read, key, raw string) { m.states[read][key] = []byte(raw) } +// assign places a module on a node, in the seat mesh-placement's key for the node. func (m *memStore) assign(t *testing.T, node, module string) { - m.mirror(t, AssignmentsRead, node+"."+module, - fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module)) + t.Helper() + m.place(t, node, module, true) +} + +// unassign takes a module off a node's placement. +func (m *memStore) unassign(t *testing.T, node, module string) { + t.Helper() + m.place(t, node, module, false) +} + +func (m *memStore) place(t *testing.T, node, module string, on bool) { + t.Helper() + type placed struct { + Module string `json:"module"` + } + p := struct { + V int `json:"v"` + Node string `json:"node"` + Modules []placed `json:"modules"` + }{V: 1, Node: node} + if e, _ := m.Get(PlacementRead, "placement."+node); e != nil { + if err := json.Unmarshal(e.Value, &p); err != nil { + t.Fatal(err) + } + } + var kept []placed + for _, x := range p.Modules { + if x.Module != module { + kept = append(kept, x) + } + } + if on { + kept = append(kept, placed{Module: module}) + } + p.Modules = kept + if err := m.Put(PlacementRead, "placement."+node, p); err != nil { + t.Fatal(err) + } +} + +// manifest keeps a manifest in the indexer's manifests by its digest, as the indexer writes it, and answers the digest. +func (m *memStore) manifest(t *testing.T, module, raw string) string { + t.Helper() + if !json.Valid([]byte(raw)) { + t.Fatalf("not JSON: %s", raw) + } + digest := delivery.ManifestDigest([]byte(raw)) + value, err := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw}) + if err != nil { + t.Fatal(err) + } + m.mirror(t, ManifestsRead, digest, string(value)) + return digest +} + +// declare makes raw the module's manifest at the trunk: kept by digest, and named by the indexer's modules.. +func (m *memStore) declare(t *testing.T, module, raw string) { + t.Helper() + digest := m.manifest(t, module, raw) + m.mirror(t, ModulesRead, module, fmt.Sprintf(`{"v":1,"manifest":%q,"repository":"novox/%s","commit":"c1"}`, + digest, module)) +} + +// want makes a node's want name a build of the module whose manifest is raw, as the assigner and the builder keep them. +func (m *memStore) want(t *testing.T, node, module, fp, raw string) { + t.Helper() + digest := m.manifest(t, module, raw) + key, err := delivery.BuilderBuildKey(module, fp) + if err != nil { + t.Fatal(err) + } + m.mirror(t, BuildsRead, key, fmt.Sprintf(`{"v":1,"state":"done","manifest":%q}`, digest)) + m.mirror(t, WantsRead, "want."+node+"."+module, fmt.Sprintf(`{"v":1,"fp":%q,"self":true}`, fp)) } func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved { @@ -154,10 +228,10 @@ const theList = `{"keys":{"agent-managed-settings":{"type":"object","description func aMesh(t *testing.T) (*Config, *memStore) { t.Helper() m := newStore() - m.mirror(t, DeclaredConfigRead, "dunst", dunstDeclared) - m.mirror(t, DeclaredConfigRead, "claude-code", `{"module":"claude-code","settings":{ + m.declare(t, "dunst", dunstDeclared) + m.declare(t, "claude-code", `{"module":"claude-code","settings":{ "managed":{"kind":"","default":{},"why":"","type":"object","description":"managed settings","trusted":true,"predefined":"agent-managed-settings"}}}`) - m.mirror(t, DeclaredConfigRead, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`) + m.declare(t, "old", `{"module":"old","settings":{"workers":{"kind":"preference","default":4,"why":"two per core"}}}`) m.mirror(t, PredefinedRead, PredefinedMirrorKey, theList) for _, node := range []string{"g14", "novox"} { m.assign(t, node, "dunst") @@ -298,8 +372,8 @@ func TestTheVerbRefusesWhatADeclarationDoesNotAllow(t *testing.T) { {"a key not on the predefined list, at a node", Call{Key: "width", Value: 400.0, Node: "g14"}, "is not one"}, {"a module value without a node", Call{Key: "width", Value: 400.0, Module: "dunst"}, "no module value for the whole mesh"}, - {"a module on a node it is not assigned to", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"}, - "it is assigned to: g14, novox"}, + {"a module on a node it is not placed on", Call{Key: "width", Value: 400.0, Node: "ace", Module: "dunst"}, + "it is placed on: g14, novox"}, {"a module not registered", Call{Key: "width", Value: 400.0, Node: "g14", Module: "nobody"}, "not a registered module"}, {"a value of another type", Call{Key: "font-size", Value: "12"}, `"12" is not a number`}, {"a value below its minimum", Call{Key: "font-size", Value: 2.0, Node: "g14"}, "below the minimum 6"}, @@ -472,7 +546,7 @@ func TestAListDroppingAKeyThatHoldsAValueIsWithheldUntilItIsCleared(t *testing.T } } -// **An assignment, a declaration or its removal moves the resolved values**, written only where they moved, and +// **A placement, a declaration or its removal moves the resolved values**, written only where they moved, and // said as config-changed; a pass over unchanged sources writes nothing. func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing.T) { c, m := aMesh(t) @@ -489,7 +563,7 @@ func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing if len(m.events) != 1 || m.events[0].Action != "resolve" { t.Errorf("an assignment raised %+v", m.events) } - _ = m.Delete(AssignmentsRead, "ace.dunst") + m.unassign(t, "ace", "dunst") if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone { t.Errorf("an unassignment moved %+v", r.Moved) } @@ -552,42 +626,110 @@ func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) { } } -// Issue 1098: a value of the assignments bucket with `ended` set, or with a `state`, is not an assignment: it -// resolves nothing and takes the module's resolved values away, as a removed key does. -func TestAnEndedAssignmentIsNotAnAssignment(t *testing.T) { - c, m := aMesh(t) - m.assign(t, "ace", "dunst") - if r, err := c.Reconcile(); err != nil || len(r.Moved) != 4 { - t.Fatalf("the assignment moved %+v (%v)", r.Moved, err) +// despiegel is de-spiegel's manifest as its own repository declares it (novox/de-spiegel at e25e1beb, its config +// alone): required trusted values, no default. +const despiegel = `{"module":"de-spiegel","version":"1","requires":["route","smtp"],"config":{ + "praktijk-name":{"type":"string","description":"the practice's public name","trusted":true,"required":true}, + "praktijk-www-name":{"type":"string","description":"its www form","trusted":true,"required":true}}}` + +// Issue 1953: **each placed module's declarations come from its own manifests as built**, the trunk's at the indexer +// and the wanted build's at the builder, never from the controller's mirror; a module placed with no manifest to read +// is said unresolved with why, in the listing and the log, and keeps what was resolved for it. +func TestDeclarationsComeFromTheManifestsAsBuilt(t *testing.T) { + fp := "src2:" + strings.Repeat("ab", 32) + cases := []struct { + name string + given func(t *testing.T, m *memStore) + // resolved is each key's resolved value on novox, `.` to source and value ("" for none). + resolved map[string]string + absent []string + unresolved string + }{ + {name: "a module of its own repository resolves from the trunk's manifest, its value set", + given: func(t *testing.T, m *memStore) { + m.declare(t, "de-spiegel", despiegel) + m.assign(t, "novox", "de-spiegel") + must(t, m.Put(ValuesState, Level{Kind: LevelModule, Node: "novox", Module: "de-spiegel"}.ValueKey( + "praktijk-name"), Value{Value: json.RawMessage(`"praktijkdespiegel.be"`), By: "the operator"})) + // The controller's mirror says otherwise, and is not read. + m.mirror(t, "mesh-controller.declared-config", "de-spiegel", `{"module":"de-spiegel","settings":{}}`) + }, + resolved: map[string]string{"de-spiegel.praktijk-name": `module "praktijkdespiegel.be"`, + "de-spiegel.praktijk-www-name": "unset "}}, + {name: "the wanted build's manifest lays its keys over the trunk's on its node", + given: func(t *testing.T, m *memStore) { + m.declare(t, "de-spiegel", despiegel) + m.assign(t, "novox", "de-spiegel") + m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{ + "praktijk-name":{"type":"string","description":"d","default":"old.example"}, + "old-key":{"type":"number","description":"d","default":3}}}`) + }, + resolved: map[string]string{"de-spiegel.praktijk-name": `default "old.example"`, + "de-spiegel.old-key": "default 3", "de-spiegel.praktijk-www-name": "unset "}}, + {name: "a removed want resolves from the trunk alone", + given: func(t *testing.T, m *memStore) { + m.declare(t, "de-spiegel", despiegel) + m.assign(t, "novox", "de-spiegel") + m.want(t, "novox", "de-spiegel", fp, `{"module":"de-spiegel","config":{ + "old-key":{"type":"number","description":"d","default":3}}}`) + m.mirror(t, WantsRead, "want.novox.de-spiegel", `{"v":1,"removed":true}`) + }, + resolved: map[string]string{"de-spiegel.praktijk-name": "unset "}, absent: []string{"de-spiegel.old-key"}}, + {name: "a placed module the indexer holds no manifest of is unresolved, said, its values kept", + given: func(t *testing.T, m *memStore) { + must(t, m.Put(ResolvedState, ResolvedKey("novox", "de-spiegel", "praktijk-name"), + Resolved{Node: "novox", Module: "de-spiegel", Key: "praktijk-name", Source: SourceUnset})) + m.assign(t, "novox", "de-spiegel") + }, + resolved: map[string]string{"de-spiegel.praktijk-name": "unset "}, + unresolved: "the indexer holds no modules.de-spiegel"}, } - for _, ended := range []string{`"ended":"2026-10-18T01:00:00Z"`, `"state":"pending"`} { - m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+ended+`}`) - r, err := c.Reconcile() - if err != nil || len(r.Moved) != 4 || !r.Moved[0].Gone { - t.Errorf("%s moved %+v (%v)", ended, r.Moved, err) - } - if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "dunst", "font-size")); e != nil { - t.Errorf("%s: the module keeps its resolved values", ended) - } - m.assign(t, "ace", "dunst") - if r, _ = c.Reconcile(); len(r.Moved) != 4 { - t.Fatalf("the assignment standing again moved %+v", r.Moved) - } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + m := newStore() + tc.given(t, m) + var said []string + c := &Config{Store: m, Now: time.Now, Logf: func(f string, a ...any) { said = append(said, fmt.Sprintf(f, a...)) }} + if _, err := c.Reconcile(); err != nil { + t.Fatal(err) + } + for k, want := range tc.resolved { + module, key, _ := strings.Cut(k, ".") + r := m.resolved(t, "novox", module, key) + if got := r.Source + " " + string(r.Value); got != want { + t.Errorf("%s resolved as %s, want %s", k, got, want) + } + } + for _, k := range tc.absent { + module, key, _ := strings.Cut(k, ".") + if e, _ := m.Get(ResolvedState, ResolvedKey("novox", module, key)); e != nil { + t.Errorf("%s is resolved: %s", k, e.Value) + } + } + l, err := c.List(Call{Module: "de-spiegel"}) + if err != nil { + t.Fatal(err) + } + if tc.unresolved == "" { + if len(l.Unresolved) != 0 { + t.Errorf("unresolved: %+v", l.Unresolved) + } + return + } + if len(l.Unresolved) != 1 || l.Unresolved[0].Node != "novox" || + !strings.Contains(l.Unresolved[0].Why, tc.unresolved) { + t.Errorf("the listing says %+v, want %q", l.Unresolved, tc.unresolved) + } + if !strings.Contains(strings.Join(said, "\n"), tc.unresolved) { + t.Errorf("the log does not say it: %q", said) + } + }) } } -// Issue 1098: the fields the controller adds to an assignment change nothing, under the same key. -func TestAnAssignmentCarriesBuildCommitAndRolloutWithoutChangingItsKey(t *testing.T) { - c, m := aMesh(t) - m.mirror(t, AssignmentsRead, "ace.dunst", `{"node":"ace","module":"dunst","assigned":"2026-10-14T00:00:00Z",`+ - `"build":"b-1","artifact":{"name":"dunst","version":"1.2"},"commit":"abc123","rollout":"r-9",`+ - `"since":"2026-10-18T00:00:00Z","ended":"","state":"","future":{"x":1}}`) - r, err := c.Reconcile() - if err != nil || len(r.Moved) != 4 { - t.Fatalf("an assignment with the new fields moved %+v (%v)", r.Moved, err) - } - expect(t, m, "ace", "10", SourceDefault) - if r, _ = c.Reconcile(); len(r.Moved) != 0 { - t.Errorf("a quiet pass moved %+v", r.Moved) +func must(t *testing.T, err error) { + t.Helper() + if err != nil { + t.Fatal(err) } } diff --git a/modules/mesh-config/cmd/mesh-config/declared.go b/modules/mesh-config/cmd/mesh-config/declared.go index 372179a9..85654cdf 100644 --- a/modules/mesh-config/cmd/mesh-config/declared.go +++ b/modules/mesh-config/cmd/mesh-config/declared.go @@ -11,8 +11,8 @@ import ( "strings" ) -// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations, the list of predefined -// keys, and the assignments, as the controller mirrors them on the bus (design 57 F3, K3). +// What mesh-config resolves from (novox/hq ADR 0348 §2, §4, §7): each module's declarations as its manifests declare +// them, the list of predefined keys, and where each module is placed (issue 1953). // The types a config value may have (ADR 0348 §4). const ( @@ -69,7 +69,34 @@ type ModuleDeclarations struct { Config map[string]Declaration `json:"settings"` } -// ParseModuleDeclarations reads one key of the declared-config mirror. +// ManifestDeclarations is the config a manifest declares (ADR 0348 §1, §4): its `config`, or for a module not yet +// moved its `settings` (kind, default and why), the two never both. Only these two fields are read, so a manifest +// field this module's SDK does not know yet never leaves a module's config unresolved. +func ManifestDeclarations(raw []byte) (ModuleDeclarations, error) { + var m struct { + Module string `json:"module"` + Config map[string]Declaration `json:"config"` + Settings map[string]Declaration `json:"settings"` + } + if err := json.Unmarshal(raw, &m); err != nil { + return ModuleDeclarations{}, fmt.Errorf("not a manifest: %w", err) + } + if m.Config != nil && m.Settings != nil { + return ModuleDeclarations{}, fmt.Errorf("%s declares both config and settings, and a module uses one form "+ + "(novox/hq ADR 0348 §1)", m.Module) + } + decl := m.Config + if decl == nil { + decl = m.Settings + } + raw, err := json.Marshal(ModuleDeclarations{Module: m.Module, Config: decl}) + if err != nil { + return ModuleDeclarations{}, err + } + return ParseModuleDeclarations(raw) +} + +// ParseModuleDeclarations reads one module's declarations in the layout ModuleDeclarations writes. func ParseModuleDeclarations(raw []byte) (ModuleDeclarations, error) { var m ModuleDeclarations if err := json.Unmarshal(raw, &m); err != nil { @@ -121,24 +148,10 @@ type Published struct { Commit string `json:"commit"` } -// Assignment is one key of the assignments bucket: a module on a node. The value also carries the build, commit and -// rollout that put it there and more; those are read where they are reported, and an unknown field never fails a -// parse. **Ended** and **State** are set on a value that is not a standing assignment (novox/hq ADR 0408 clause 4): -// it reads exactly as a removed key (issue 1098). +// Assignment is a module placed on a node, as the seat mesh-placement's key `placement.` names it. type Assignment struct { - Node string `json:"node"` - Module string `json:"module"` - Ended json.RawMessage `json:"ended,omitempty"` - State json.RawMessage `json:"state,omitempty"` -} - -// Standing is whether the value is a standing assignment: neither ended nor in a state. -func (a Assignment) Standing() bool { return !fieldSet(a.Ended) && !fieldSet(a.State) } - -// fieldSet is whether a field of an assignment's value carries something: not absent, null or empty. -func fieldSet(f json.RawMessage) bool { - s := strings.TrimSpace(string(f)) - return s != "" && s != "null" && s != `""` + Node string `json:"node"` + Module string `json:"module"` } // rule is the validation a value is held to, from a module's declaration or a predefined key. diff --git a/modules/mesh-config/cmd/mesh-config/list.go b/modules/mesh-config/cmd/mesh-config/list.go index ba129102..099bafaf 100644 --- a/modules/mesh-config/cmd/mesh-config/list.go +++ b/modules/mesh-config/cmd/mesh-config/list.go @@ -17,6 +17,8 @@ type Listed struct { Predefined map[string]Published `json:"predefined,omitempty"` // Withheld is why the newest list of predefined keys is not published, when it is not. Withheld string `json:"withheld,omitempty"` + // Unresolved is each placed module the call narrows to whose declarations cannot be read, with why (issue 1953). + Unresolved []Unresolved `json:"unresolved,omitempty"` // Held is the approved values held for open pull requests, not yet set (novox/hq ADR 0502). Held []HeldValue `json:"held,omitempty"` } @@ -42,12 +44,18 @@ func (c *Config) List(call Call) (*Listed, error) { if err != nil { return nil, err } + out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld} + for _, u := range s.Unresolved { + if (call.Module == "" || u.Module == call.Module) && (call.Node == "" || u.Node == call.Node) { + out.Unresolved = append(out.Unresolved, u) + } + } if call.Module != "" { - if _, ok := s.Declared[call.Module]; !ok { - return nil, fmt.Errorf("%s is not a registered module, so it declares no config", call.Module) + if _, ok := s.Declared[call.Module]; !ok && len(out.Unresolved) == 0 { + return nil, fmt.Errorf("%s is placed on no node with a manifest the indexer holds, so it declares no "+ + "config", call.Module) } } - out := &Listed{Resolved: []Resolved{}, Set: []SetValue{}, Withheld: withheld} resolved := Resolve(s) for _, k := range sortedKeys(resolved) { r := resolved[k] diff --git a/modules/mesh-config/cmd/mesh-config/main.go b/modules/mesh-config/cmd/mesh-config/main.go index 63428e01..036acd86 100644 --- a/modules/mesh-config/cmd/mesh-config/main.go +++ b/modules/mesh-config/cmd/mesh-config/main.go @@ -4,8 +4,9 @@ // node), mesh-config_resolved (each module's value on each node, with its source), mesh-config_predefined (the // published list of predefined keys) and mesh-config_proposals (each value proposed for the operator's Approve, // asked under this module's own name, design 57 K4). -// It resolves from the controller's mirrors of the assignments, the modules' declarations and the list it kept -// (design 57 F3, K3). stdout is the MCP channel; what this module says, it says on stderr. +// It resolves from where the seat mesh-placement places each module, and each module's declarations as its own +// manifests declare them, the trunk's and its wanted build's (the indexer's and the builder's state, issue 1953); the +// list of predefined keys still comes from the controller's mirror until the indexer keeps it (ADR 0565 §2). stdout is the MCP channel; what this module says, it says on stderr. package main import ( @@ -115,7 +116,9 @@ func resolving(c *Config) { } return nil } - for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} { + // A build's manifest is written before a want names the build, so a want, a trunk module or a placement moving is + // every change of what a module declares where (issue 1953); the builds themselves are read, not watched. + for _, read := range []string{PlacementRead, WantsRead, ModulesRead, PredefinedRead} { go func(read string) { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { time.Sleep(wait) diff --git a/modules/mesh-config/cmd/mesh-config/proposals_test.go b/modules/mesh-config/cmd/mesh-config/proposals_test.go index 85da677a..64a23994 100644 --- a/modules/mesh-config/cmd/mesh-config/proposals_test.go +++ b/modules/mesh-config/cmd/mesh-config/proposals_test.go @@ -573,7 +573,7 @@ func TestTheProposalArgumentsAreReadStrictly(t *testing.T) { // not refused as carrying a secret: modules/asus-zephyrus-g14, i3status-rust and powerlevel10k are names. func TestAnOwnersListOfNamesWithDigitsIsProposed(t *testing.T) { c, m, r := aProposingMesh(t) - m.mirror(t, DeclaredConfigRead, "mesh-issues", `{"module":"mesh-issues","settings":{ + m.declare(t, "mesh-issues", `{"module":"mesh-issues","settings":{ "owners":{"kind":"","default":[],"why":"","type":"list","description":"owners","trusted":true}}}`) m.assign(t, "novox", "mesh-issues") raw, err := os.ReadFile(filepath.Join("testdata", "owners-1439.json")) diff --git a/modules/mesh-config/cmd/mesh-config/pulled_in_test.go b/modules/mesh-config/cmd/mesh-config/pulled_in_test.go deleted file mode 100644 index dbf9ebfb..00000000 --- a/modules/mesh-config/cmd/mesh-config/pulled_in_test.go +++ /dev/null @@ -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) - } -} diff --git a/modules/mesh-config/cmd/mesh-config/pulls.go b/modules/mesh-config/cmd/mesh-config/pulls.go index 82c73fd2..f803b5de 100644 --- a/modules/mesh-config/cmd/mesh-config/pulls.go +++ b/modules/mesh-config/cmd/mesh-config/pulls.go @@ -378,8 +378,8 @@ func heldRule(s Sources, call Call, level Level) (rule, PullDecl, string, error) return rule{}, PullDecl{}, "", fmt.Errorf("%s is not a registered module, so a value cannot be held for it", level.Module) } if !assigned(s, level.Node, level.Module) { - return rule{}, PullDecl{}, "", fmt.Errorf("%s is not assigned to %s, and a module's own value is set for a node "+ - "it is assigned to (novox/hq ADR 0348 §3); it is assigned to: %s", level.Module, level.Node, + return rule{}, PullDecl{}, "", fmt.Errorf("%s is not placed on %s, and a module's own value is set for a node "+ + "it is placed on (novox/hq ADR 0348 §3); it is placed on: %s", level.Module, level.Node, strings.Join(nodesOf(s, level.Module), ", ")) } return d.rule(), pd, declDigest(d), nil diff --git a/modules/mesh-config/cmd/mesh-config/pulls_test.go b/modules/mesh-config/cmd/mesh-config/pulls_test.go index 0f664f7d..820a13b4 100644 --- a/modules/mesh-config/cmd/mesh-config/pulls_test.go +++ b/modules/mesh-config/cmd/mesh-config/pulls_test.go @@ -23,7 +23,7 @@ func n8nPullDeclares() map[string]any { func aHoldingMesh(t *testing.T) (*Config, *memStore, *standIn, *standForge) { t.Helper() c, m, r := aProposingMesh(t) - m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{}}`) + m.declare(t, "n8n", `{"module":"n8n","settings":{}}`) m.assign(t, "ace", "n8n") f := &standForge{state: map[string][2]bool{}} c.Forge = f @@ -112,7 +112,7 @@ func TestAPullRequestsDeclarationsAreTakenFromTheControllerAlone(t *testing.T) { } // 1826/K2: a proposal for a key only the pull request declares is refused without its declaration, for a mesh or -// node level, for a clear, for an undeclared key, and for a module not assigned to the node; and trunk-only +// node level, for a clear, for an undeclared key, and for a module not placed on the node; and trunk-only // proposals are judged as before. func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T) { c, m, _, _ := aHoldingMesh(t) @@ -142,7 +142,7 @@ func TestAProposalNamingAPullRequestIsJudgedAgainstItsDeclaration(t *testing.T) "a key not added": {func(c *Call) { c.Key = "other" }, "does not add or change a value"}, "a bad value": {func(c *Call) { c.Value = 0.0 }, "below the minimum"}, "a wrong type": {func(c *Call) { c.Value = "2000" }, "is not a number"}, - "another node": {func(c *Call) { c.Node = "g14" }, "is not assigned to g14"}, + "another node": {func(c *Call) { c.Node = "g14" }, "is not placed on g14"}, "another pull": {func(c *Call) { c.Pull = "novox/mesh-catalog#435" }, "holds no declaration for"}, "a malformed pull": {func(c *Call) { c.Pull = "434" }, "owner/repository#number"}, } { @@ -342,7 +342,7 @@ func TestTheMergeAppliesAHeldValueAndItResolvesOnceTheTrunkDeclaresTheKey(t *tes if e, _ := m.Get(ResolvedState, ResolvedKey("ace", "n8n", "media-gid")); e != nil { t.Errorf("resolved before the trunk declares the key: %s", e.Value) } - m.mirror(t, DeclaredConfigRead, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+ + m.declare(t, "n8n", `{"module":"n8n","settings":{"media-gid":{"kind":"","default":null,"why":"",`+ `"type":"number","required":true,"minimum":1,"maximum":65535}}}`) if _, err := c.Reconcile(); err != nil { t.Fatal(err) diff --git a/modules/mesh-config/cmd/mesh-config/resolve.go b/modules/mesh-config/cmd/mesh-config/resolve.go index b57bf3dc..dac4e9f8 100644 --- a/modules/mesh-config/cmd/mesh-config/resolve.go +++ b/modules/mesh-config/cmd/mesh-config/resolve.go @@ -6,6 +6,8 @@ import ( "sort" "strings" "time" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) // The four sources (novox/hq ADR 0348 §2, §3, §9): a module's value on a node resolves, the most specific winning, @@ -20,12 +22,21 @@ const ( PredefinedState = "predefined" ProposalsState = "proposals" - AssignmentsRead = "mesh-controller.assignments" // PlacementRead is the seat mesh-placement's state, held by the assigner (novox/hq ADR 0567 §1, §8): each - // node's key `placement.` with the modules placed there. - PlacementRead = "mesh-delivery-assigner.placement" - DeclaredConfigRead = "mesh-controller.declared-config" - PredefinedRead = "mesh-controller.predefined" + // node's key `placement.` 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..`: the build each node is to run (ADR 0539 §1). + WantsRead = delivery.AssignerModule + ".wants" + // BuildsRead is the builder's builds, `.`: each build's manifest digest (ADR 0525). + BuildsRead = delivery.BuilderModule + ".builds" + // ModulesRead is the indexer's modules, ``: the trunk's manifest digest of each module, from whichever + // repository holds it (ADR 0437 §5, ADR 0478 §9); ManifestsRead its manifests by digest (ADR 0429 §16). + ModulesRead = delivery.IndexerModule + ".modules" + ManifestsRead = delivery.IndexerModule + ".manifests" + // PredefinedRead is the controller's list of predefined keys, which ADR 0565 §2 moves to the indexer's + // `predefined`: read here until that state exists. + PredefinedRead = "mesh-controller.predefined" // HistoryLog is the log of every set and clear, with the proven caller and why (ADR 0348 §9). HistoryLog = "history" @@ -123,24 +134,59 @@ type Resolved struct { // ResolvedKey is a resolved value's key in mesh-config_resolved. func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key } -// Sources is everything a resolution reads: the assignments, every module's declarations, the published list of -// predefined keys, and every value set. +// Sources is everything a resolution reads: where each module is placed, every module's declarations, the published +// list of predefined keys, and every value set. type Sources struct { Assignments []Assignment - Declared map[string]ModuleDeclarations - Predefined map[string]Published - Values map[string]Value + // Declared is each module's declarations as its manifests declare them: the trunk's, with every key only a wanted + // build still declares (declarationsOf). It is what a set is judged against. + Declared map[string]ModuleDeclarations + // OnNode is, by `.`, the declarations of the build the node's want names laid over the trunk's: what + // that node resolves. A placed module absent here resolves from Declared. + OnNode map[string]ModuleDeclarations + // Unresolved is each placed module whose declarations could not be read, with why: never skipped silently + // (issue 1953). Its resolved values are kept as they were until its declarations read again. + Unresolved []Unresolved + Predefined map[string]Published + Values map[string]Value // Pulls is what the controller told of each open pull request's declarations, and Kept the approved values held // for them (pulls.go); neither is resolved. Pulls map[string]PullDecl Kept map[string]KeptValue } -// Resolve is every assigned module's every declared value on its node, keyed as mesh-config_resolved is. +// Unresolved is a module placed on a node whose values are not resolved, and why. +type Unresolved struct { + Node string `json:"node"` + Module string `json:"module"` + Why string `json:"why"` +} + +// declarationsOn is the declarations a module on a node resolves from, and whether any are held. +func (s Sources) declarationsOn(node, module string) (ModuleDeclarations, bool) { + if d, ok := s.OnNode[node+"."+module]; ok { + return d, true + } + d, ok := s.Declared[module] + return d, ok +} + +// unresolved is whether a module on a node is unresolved. +func (s Sources) unresolved(node, module string) bool { + for _, u := range s.Unresolved { + if u.Node == node && u.Module == module { + return true + } + } + return false +} + +// Resolve is every placed module's every declared value on its node, keyed as mesh-config_resolved is. A placed +// module with no declarations is in s.Unresolved, said there (sources). func Resolve(s Sources) map[string]Resolved { out := map[string]Resolved{} for _, a := range s.Assignments { - decl, ok := s.Declared[a.Module] + decl, ok := s.declarationsOn(a.Node, a.Module) if !ok { continue } @@ -196,7 +242,7 @@ type Moved struct { From json.RawMessage `json:"from,omitempty"` To json.RawMessage `json:"to,omitempty"` Source string `json:"source"` - // Gone says the module no longer takes the key there: unassigned, or no longer declared. + // Gone says the module no longer takes the key there: no longer placed, or no longer declared. Gone bool `json:"gone,omitempty"` } diff --git a/modules/mesh-config/cmd/mesh-config/sources.go b/modules/mesh-config/cmd/mesh-config/sources.go index 6cd7e88d..761706d8 100644 --- a/modules/mesh-config/cmd/mesh-config/sources.go +++ b/modules/mesh-config/cmd/mesh-config/sources.go @@ -4,73 +4,30 @@ import ( "encoding/json" "errors" "fmt" + "sort" "strings" + + "git.novox.be/novox/mesh-sdk/go/delivery" ) // Reading the sources, writing the resolved values, and publishing the list of predefined keys. -// sources reads everything a resolution reads, from the bus as it is now. +// sources reads everything a resolution reads, from the bus as it is now: which modules are placed where from the +// seat mesh-placement, and each placed module's declarations from its own manifests as built, never from the +// controller (issue 1953, ADR 0565 §3). func (c *Config) sources() (Sources, error) { - s := Sources{Declared: map[string]ModuleDeclarations{}, Predefined: map[string]Published{}, - Values: map[string]Value{}} + s := Sources{Declared: map[string]ModuleDeclarations{}, OnNode: map[string]ModuleDeclarations{}, + Predefined: map[string]Published{}, Values: map[string]Value{}} var problems []string - if err := c.each(AssignmentsRead, func(key string, raw []byte) error { - var a Assignment - if err := json.Unmarshal(raw, &a); err != nil || a.Node == "" || a.Module == "" { - return fmt.Errorf("the assignment %s is not one: %s", key, raw) - } - if !a.Standing() { - return nil // ended or pending: read as a removed key (issue 1098) - } - s.Assignments = append(s.Assignments, a) - return nil - }); err != nil { - return s, fmt.Errorf("the assignments cannot be read: %w", err) + if err := c.readPlacement(&s); err != nil { + return s, fmt.Errorf("the seat mesh-placement's placement cannot be read: %w", err) } - // And every module the 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 { + if err := c.readDeclarations(&s); err != nil { return s, fmt.Errorf("the modules' declarations cannot be read: %w", err) } + for _, u := range s.Unresolved { + problems = append(problems, fmt.Sprintf("%s on %s: %s", u.Module, u.Node, u.Why)) + } if err := c.each(PredefinedState, func(key string, raw []byte) error { var p Published if err := json.Unmarshal(raw, &p); err != nil { @@ -95,11 +52,222 @@ func (c *Config) sources() (Sources, error) { return s, err } for _, p := range problems { - c.logf("[mesh-config] a module's declarations are not read, so its values are not resolved: %s", p) + c.logf("[mesh-config] a placed module's declarations are not read, so its values are not resolved (they "+ + "stay as last resolved): %s", p) } return s, nil } +// readPlacement is every module the seat mesh-placement places on a node (ADR 0567 §1, §9): each node's key +// `placement.`. +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.` and its manifest by digest), and, on each node whose want names a build, that +// build's (the builder's `.` 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{} // . → 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.` names. +func (c *Config) trunkDeclarations(module string) (ModuleDeclarations, error) { + key, err := delivery.ModulesKey(module) + if err != nil { + return ModuleDeclarations{}, err + } + e, err := c.Store.Get(ModulesRead, key) + if err != nil { + return ModuleDeclarations{}, fmt.Errorf("the indexer's modules.%s cannot be read: %w", module, err) + } + if e == nil { + return ModuleDeclarations{}, fmt.Errorf("the indexer holds no modules.%s, so no manifest of it is known", module) + } + var m struct { + Manifest string `json:"manifest"` + } + if err := json.Unmarshal(e.Value, &m); err != nil || m.Manifest == "" { + return ModuleDeclarations{}, fmt.Errorf("the indexer's modules.%s names no manifest", module) + } + return c.manifestDeclarations(module, m.Manifest) +} + +// builtDeclarations is a module's declarations at a build: the manifest the builder's done key names. +func (c *Config) builtDeclarations(module, fp string) (ModuleDeclarations, error) { + key, err := delivery.BuilderBuildKey(module, fp) + if err != nil { + return ModuleDeclarations{}, err + } + e, err := c.Store.Get(BuildsRead, key) + if err != nil { + return ModuleDeclarations{}, fmt.Errorf("the builder's %s cannot be read: %w", key, err) + } + if e == nil { + return ModuleDeclarations{}, fmt.Errorf("the builder holds no build %s", key) + } + var b struct { + Manifest string `json:"manifest"` + } + if err := json.Unmarshal(e.Value, &b); err != nil || b.Manifest == "" { + return ModuleDeclarations{}, fmt.Errorf("the builder's %s names no manifest", key) + } + return c.manifestDeclarations(module, b.Manifest) +} + +// manifestDeclarations is the declarations of the manifest of a digest in the indexer's manifests, read once: a +// manifests key is written once and never changes (ADR 0429 §16), so what it declares is kept by digest. +func (c *Config) manifestDeclarations(module, digest string) (ModuleDeclarations, error) { + c.manifestsMu.Lock() + d, ok := c.manifests[digest] + c.manifestsMu.Unlock() + if ok { + return d, nil + } + key, err := delivery.ManifestsKey(digest) + if err != nil { + return ModuleDeclarations{}, err + } + e, err := c.Store.Get(ManifestsRead, key) + if err != nil { + return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s cannot be read: %w", short(digest), err) + } + if e == nil { + return ModuleDeclarations{}, fmt.Errorf("the indexer holds no manifest %s", short(digest)) + } + var m struct { + Module string `json:"module"` + JSON string `json:"manifest"` + } + if err := json.Unmarshal(e.Value, &m); err != nil || m.JSON == "" { + return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s is not one", short(digest)) + } + if m.Module != module { + return ModuleDeclarations{}, fmt.Errorf("the indexer's manifest %s is %s's, not %s's", short(digest), m.Module, module) + } + d, err = ManifestDeclarations([]byte(m.JSON)) + if err != nil { + return ModuleDeclarations{}, fmt.Errorf("the manifest %s: %w", short(digest), err) + } + c.manifestsMu.Lock() + if c.manifests == nil { + c.manifests = map[string]ModuleDeclarations{} + } + c.manifests[digest] = d + c.manifestsMu.Unlock() + return d, nil +} + // each hands every key of a state to visit. func (c *Config) each(state string, visit func(key string, raw []byte) error) error { keys, err := c.Store.Keys(state) @@ -145,6 +313,13 @@ func (c *Config) writeResolved(s Sources) ([]Moved, error) { return nil, fmt.Errorf("the resolved values cannot be read: %w", err) } want := Resolve(s) + // A placed module whose declarations cannot be read keeps what was resolved for it: not knowing is no reason to take + // a node's values away (issue 1953). + for key, h := range held { + if _, ok := want[key]; !ok && h.Source != "unreadable" && s.unresolved(h.Node, h.Module) { + want[key] = h + } + } moved := diff(held, want) var problems []error for _, m := range moved { diff --git a/modules/mesh-config/module.json b/modules/mesh-config/module.json index ca7e0a2f..66db5b60 100644 --- a/modules/mesh-config/module.json +++ b/modules/mesh-config/module.json @@ -28,9 +28,11 @@ "gitea.gitea_get_pull_request" ], "reads": [ - "mesh-controller.assignments", "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" ], "state": [