diff --git a/checks/placement/assignments-readers b/checks/placement/assignments-readers new file mode 100644 index 00000000..2346b029 --- /dev/null +++ b/checks/placement/assignments-readers @@ -0,0 +1,9 @@ +# The modules of this catalogue that still read the controller's assignments (mesh-controller.assignments), each with +# why. The check of this directory refuses any other module that reads them, and an entry here whose module no longer +# does: the list only shrinks, and it is empty before the controller's lease is written to a holder that is no +# controller (novox/hq ADR 0549 §3, §6; issue 774). +gitea the provisioner's plan of a module (go/provider) rides only in an assignment's value; its home after the lease is issue 774's provisioner-plan subtask +keycloak the provisioner's plan of a module (go/provider), as gitea +postgres the provisioner's plan of a module (go/provider), as gitea +route-adapter the provisioner's plan of a module (go/provider), as gitea +mesh-vault the provisioner's plan, which only an assignment's value carries; its home is issue 774's provisioner-plan subtask diff --git a/modules/mesh-config/README.md b/modules/mesh-config/README.md index b8ac192f..2adf6385 100644 --- a/modules/mesh-config/README.md +++ b/modules/mesh-config/README.md @@ -21,11 +21,12 @@ 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 catalogue's placement puts on a node (`mesh-delivery-indexer.placement`, ADR 0549 §3), from the +controller's mirror of each module's declarations (`mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back to; node config for that key on the node; the module's own value on that node, the most specific winning. A value naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at -start, whenever a mirror moves, and every five minutes, writing only the values that moved. +start, whenever the placement or a mirror moves, and every five minutes, writing only the values that moved. Before +any node's placement is read nothing is resolved anew, and what is resolved stays. ## The verb diff --git a/modules/mesh-config/cmd/mesh-config/config_test.go b/modules/mesh-config/cmd/mesh-config/config_test.go index 91a14c71..6e8dd4d5 100644 --- a/modules/mesh-config/cmd/mesh-config/config_test.go +++ b/modules/mesh-config/cmd/mesh-config/config_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "slices" "sort" "strings" "sync" @@ -120,9 +121,48 @@ func (m *memStore) mirror(t *testing.T, read, key, raw string) { m.states[read][key] = []byte(raw) } +// assign puts module on node in the catalogue's placement (ADR 0549 §3), beside what is placed there already. 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, func(modules []string) []string { return append(modules, module) }) +} + +// unassign takes module off node in the catalogue's placement. +func (m *memStore) unassign(t *testing.T, node, module string) { + t.Helper() + m.place(t, node, func(modules []string) []string { + return slices.DeleteFunc(modules, func(x string) bool { return x == module }) + }) +} + +func (m *memStore) place(t *testing.T, node string, change func([]string) []string) { + t.Helper() + type placed struct { + Module string `json:"module"` + } + var p struct { + V int `json:"v"` + Node string `json:"node"` + Modules []placed `json:"modules"` + } + m.mu.Lock() + raw := m.states[PlacementRead]["placement."+node] + m.mu.Unlock() + if raw != nil { + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatal(err) + } + } + var modules []string + for _, x := range p.Modules { + modules = append(modules, x.Module) + } + p.V, p.Node, p.Modules = 1, node, nil + for _, x := range change(modules) { + p.Modules = append(p.Modules, placed{Module: x}) + } + out, _ := json.Marshal(p) + m.mirror(t, PlacementRead, "placement."+node, string(out)) } func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved { @@ -489,7 +529,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 +592,28 @@ 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) { +// Issue 774 (ADR 0549 §3, §5): the catalogue's placement alone says where modules stand. Before any node's placement +// is read nothing is resolved anew and what is resolved stays; a placement that does not read resolves nothing on its +// node, said. +func TestThePlacementAloneSaysWhereValuesResolve(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) + t.Fatalf("a placed module moved %+v (%v)", r.Moved, err) } - 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) - } - } -} - -// 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) + m.mu.Lock() + kept := m.states[PlacementRead] + delete(m.states, PlacementRead) + m.mu.Unlock() + if r, err := c.Reconcile(); err == nil || len(r.Moved) != 0 || !strings.Contains(err.Error(), "no placement") { + t.Errorf("no placement at all moved %+v (%v), want nothing moved and why", 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) + m.mu.Lock() + m.states[PlacementRead] = kept + m.mu.Unlock() + m.mirror(t, PlacementRead, "placement.ace", `{"v":1,"node":"g14","modules":[{"module":"dunst"}]}`) + if r, _ := c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone { + t.Errorf("a placement naming another node moved %+v, want ace's values gone", r.Moved) } } diff --git a/modules/mesh-config/cmd/mesh-config/declared.go b/modules/mesh-config/cmd/mesh-config/declared.go index 372179a9..c065598b 100644 --- a/modules/mesh-config/cmd/mesh-config/declared.go +++ b/modules/mesh-config/cmd/mesh-config/declared.go @@ -121,24 +121,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 the catalogue's placement puts on a node (ADR 0549 §3): what a resolution resolves values for. 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 + Module string } // 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/main.go b/modules/mesh-config/cmd/mesh-config/main.go index 63428e01..99406b88 100644 --- a/modules/mesh-config/cmd/mesh-config/main.go +++ b/modules/mesh-config/cmd/mesh-config/main.go @@ -115,7 +115,7 @@ func resolving(c *Config) { } return nil } - for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} { + for _, read := range []string{PlacementRead, DeclaredConfigRead, 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/pulled_in_test.go b/modules/mesh-config/cmd/mesh-config/pulled_in_test.go index dbf9ebfb..ba772aa0 100644 --- a/modules/mesh-config/cmd/mesh-config/pulled_in_test.go +++ b/modules/mesh-config/cmd/mesh-config/pulled_in_test.go @@ -2,34 +2,19 @@ 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) { +// 1126 (ADR 0549 §3), issue 774: a module the catalogue's placement names on a node resolves there; it goes when the +// placement no longer names it. A module another pulls in is placed in its own right (the seed placed what runs). +func TestAModulePlacedHasItsValuesResolvedOnItsNode(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") + m.mirror(t, PlacementRead, "placement.ace", `{"v":1,"node":"ace","modules":[]}`) 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/resolve.go b/modules/mesh-config/cmd/mesh-config/resolve.go index b57bf3dc..46272013 100644 --- a/modules/mesh-config/cmd/mesh-config/resolve.go +++ b/modules/mesh-config/cmd/mesh-config/resolve.go @@ -20,7 +20,6 @@ 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" @@ -123,10 +122,13 @@ 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 modules stand (the catalogue's placement), every module's +// declarations, the published list of predefined keys, and every value set. type Sources struct { + // Assignments is every module the catalogue's placement puts on a node (ADR 0549 §3), and Placed whether any node's + // placement was read: without one nothing is resolved anew. Assignments []Assignment + Placed bool Declared map[string]ModuleDeclarations Predefined map[string]Published Values map[string]Value diff --git a/modules/mesh-config/cmd/mesh-config/sources.go b/modules/mesh-config/cmd/mesh-config/sources.go index 6cd7e88d..6ad241c4 100644 --- a/modules/mesh-config/cmd/mesh-config/sources.go +++ b/modules/mesh-config/cmd/mesh-config/sources.go @@ -14,31 +14,16 @@ func (c *Config) sources() (Sources, error) { s := Sources{Declared: 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) - } - // 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. + // Every module the seat's placement names on a node (ADR 0549 §3): the one source of where modules run, the + // controller's assignments read no more (issue 774, ADR 0549 §6). A placement that cannot be read leaves nothing + // resolved anew, and what is resolved stays (writeResolved). 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 } + s.Placed = true var p struct { Node string `json:"node"` Modules []struct { @@ -46,7 +31,7 @@ func (c *Config) sources() (Sources, error) { } `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)) + problems = append(problems, fmt.Sprintf("the placement %s is not one, so nothing is resolved on %s", key, node)) return nil } for _, m := range p.Modules { @@ -57,7 +42,8 @@ func (c *Config) sources() (Sources, error) { } return nil }); err != nil { - problems = append(problems, fmt.Sprintf("the seat's placement cannot be read, so the assignments alone are resolved: %v", err)) + s.Placed = false + problems = append(problems, fmt.Sprintf("the seat's placement cannot be read, so nothing is resolved anew: %v", err)) } if err := c.each(DeclaredConfigRead, func(key string, raw []byte) error { m, err := ParseModuleDeclarations(raw) @@ -140,6 +126,11 @@ func (c *Config) held() (map[string]Resolved, error) { // writeResolved makes mesh-config_resolved hold what the sources resolve to, writing only what moved, and // answers what moved. func (c *Config) writeResolved(s Sources) ([]Moved, error) { + if !s.Placed { + // Before the assigner's adopt writes the first placement, or while the seat's state cannot be read, nothing places + // any module: resolving then would take every module's values away (ADR 0549 §5, ADR 0567). + return nil, errors.New("no placement of the seat mesh-placement is read, so the resolved values stay as they are") + } held, err := c.held() if err != nil { return nil, fmt.Errorf("the resolved values cannot be read: %w", err) diff --git a/modules/mesh-config/module.json b/modules/mesh-config/module.json index ca7e0a2f..c886c031 100644 --- a/modules/mesh-config/module.json +++ b/modules/mesh-config/module.json @@ -28,7 +28,6 @@ "gitea.gitea_get_pull_request" ], "reads": [ - "mesh-controller.assignments", "mesh-delivery-assigner.placement", "mesh-controller.declared-config", "mesh-controller.predefined"