diff --git a/checks/noplacement/assignments-readers b/checks/noplacement/assignments-readers index a2136427..6c9e81b1 100644 --- a/checks/noplacement/assignments-readers +++ b/checks/noplacement/assignments-readers @@ -4,6 +4,7 @@ # 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 +mesh-config unioned with the placement while both stand, as the assigner and the vault do (ADR 0549 §5); its reading placement alone is a step before the lease write +mesh-vault the provisioner's plan, which only an assignment's value carries; its home is issue 774's provisioner-plan subtask 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 21a3a827..b8ac192f 100644 --- a/modules/mesh-config/README.md +++ b/modules/mesh-config/README.md @@ -21,12 +21,11 @@ account alone writes its buckets. ## How a value resolves -For every module the seat mesh-placement's placement puts on a node (`mesh-delivery-assigner.placement`, ADR 0567), 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 +From the controller's mirrors of the assignments and of each module's declarations (`mesh-controller.assignments`, +`mesh-controller.declared-config`): the manifest's default; mesh config for the predefined key the value falls back to; node config for that key on the node; the module's own value on that node, the most specific winning. A value naming no predefined key resolves from its default and the module's own value only (ADR 0348 §2). It resolves at -start, whenever 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. +start, whenever a mirror moves, and every five minutes, writing only the values that moved. ## 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 6c2d7e15..91a14c71 100644 --- a/modules/mesh-config/cmd/mesh-config/config_test.go +++ b/modules/mesh-config/cmd/mesh-config/config_test.go @@ -4,7 +4,6 @@ import ( "encoding/json" "errors" "fmt" - "slices" "sort" "strings" "sync" @@ -121,48 +120,9 @@ func (m *memStore) mirror(t *testing.T, read, key, raw string) { m.states[read][key] = []byte(raw) } -// assign puts module on node in the seat's placement (ADR 0549 §3), beside what is placed there already. func (m *memStore) assign(t *testing.T, node, module string) { - t.Helper() - m.place(t, node, func(modules []string) []string { return append(modules, module) }) -} - -// unassign takes module off node in the seat'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)) + m.mirror(t, AssignmentsRead, node+"."+module, + fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T00:00:00Z"}`, node, module)) } func (m *memStore) resolved(t *testing.T, node, module, key string) Resolved { @@ -529,7 +489,7 @@ func TestAnAssignmentMovesTheResolvedValuesAndAQuietPassWritesNothing(t *testing if len(m.events) != 1 || m.events[0].Action != "resolve" { t.Errorf("an assignment raised %+v", m.events) } - m.unassign(t, "ace", "dunst") + _ = m.Delete(AssignmentsRead, "ace.dunst") if r, _ = c.Reconcile(); len(r.Moved) != 4 || !r.Moved[0].Gone { t.Errorf("an unassignment moved %+v", r.Moved) } @@ -592,28 +552,42 @@ func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) { } } -// Issue 774 (ADR 0549 §3, §5): the seat'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) { +// 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("a placed module moved %+v (%v)", r.Moved, err) + t.Fatalf("the assignment 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) + 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) + } expect(t, m, "ace", "10", SourceDefault) - 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) + if r, _ = c.Reconcile(); len(r.Moved) != 0 { + t.Errorf("a quiet pass moved %+v", r.Moved) } } diff --git a/modules/mesh-config/cmd/mesh-config/declared.go b/modules/mesh-config/cmd/mesh-config/declared.go index a84b6ae8..372179a9 100644 --- a/modules/mesh-config/cmd/mesh-config/declared.go +++ b/modules/mesh-config/cmd/mesh-config/declared.go @@ -121,10 +121,24 @@ type Published struct { Commit string `json:"commit"` } -// Assignment is a module the seat's placement puts on a node (ADR 0549 §3): what a resolution resolves values for. +// Assignment is one key of the assignments bucket: a module on a node. The value also carries the build, commit and +// rollout that put it there and more; those are read where they are reported, and an unknown field never fails a +// parse. **Ended** and **State** are set on a value that is not a standing assignment (novox/hq ADR 0408 clause 4): +// it reads exactly as a removed key (issue 1098). type Assignment struct { - Node string - Module string + Node string `json:"node"` + Module string `json:"module"` + Ended json.RawMessage `json:"ended,omitempty"` + State json.RawMessage `json:"state,omitempty"` +} + +// Standing is whether the value is a standing assignment: neither ended nor in a state. +func (a Assignment) Standing() bool { return !fieldSet(a.Ended) && !fieldSet(a.State) } + +// fieldSet is whether a field of an assignment's value carries something: not absent, null or empty. +func fieldSet(f json.RawMessage) bool { + s := strings.TrimSpace(string(f)) + return s != "" && s != "null" && s != `""` } // rule is the validation a value is held to, from a module's declaration or a predefined key. diff --git a/modules/mesh-config/cmd/mesh-config/main.go b/modules/mesh-config/cmd/mesh-config/main.go index 99406b88..63428e01 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{PlacementRead, DeclaredConfigRead, PredefinedRead} { + for _, read := range []string{AssignmentsRead, PlacementRead, DeclaredConfigRead, PredefinedRead} { go func(read string) { for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) { time.Sleep(wait) 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 ad0b5c43..dbf9ebfb 100644 --- a/modules/mesh-config/cmd/mesh-config/pulled_in_test.go +++ b/modules/mesh-config/cmd/mesh-config/pulled_in_test.go @@ -2,19 +2,34 @@ package main import "testing" -// 1126 (ADR 0549 §3), issue 774: a module the seat'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) { +// 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.mirror(t, PlacementRead, "placement.ace", `{"v":1,"node":"ace","modules":[]}`) + _ = 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/resolve.go b/modules/mesh-config/cmd/mesh-config/resolve.go index 64184f72..b57bf3dc 100644 --- a/modules/mesh-config/cmd/mesh-config/resolve.go +++ b/modules/mesh-config/cmd/mesh-config/resolve.go @@ -20,6 +20,7 @@ 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" @@ -122,13 +123,10 @@ type Resolved struct { // ResolvedKey is a resolved value's key in mesh-config_resolved. func ResolvedKey(node, module, key string) string { return node + "." + module + "." + key } -// Sources is everything a resolution reads: where modules stand (the seat's placement), every module's -// declarations, the published list of predefined keys, and every value set. +// Sources is everything a resolution reads: the assignments, every module's declarations, the published list of +// predefined keys, and every value set. type Sources struct { - // Assignments is every module the seat'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 6ad241c4..6cd7e88d 100644 --- a/modules/mesh-config/cmd/mesh-config/sources.go +++ b/modules/mesh-config/cmd/mesh-config/sources.go @@ -14,16 +14,31 @@ func (c *Config) sources() (Sources, error) { s := Sources{Declared: map[string]ModuleDeclarations{}, Predefined: map[string]Published{}, Values: map[string]Value{}} var problems []string - // 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). + 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. 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 { @@ -31,7 +46,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, so nothing is resolved on %s", key, node)) + problems = append(problems, fmt.Sprintf("the placement %s is not one", key)) return nil } for _, m := range p.Modules { @@ -42,8 +57,7 @@ func (c *Config) sources() (Sources, error) { } return nil }); err != nil { - s.Placed = false - problems = append(problems, fmt.Sprintf("the seat's placement cannot be read, so nothing is resolved anew: %v", err)) + 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) @@ -126,11 +140,6 @@ 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 c886c031..ca7e0a2f 100644 --- a/modules/mesh-config/module.json +++ b/modules/mesh-config/module.json @@ -28,6 +28,7 @@ "gitea.gitea_get_pull_request" ], "reads": [ + "mesh-controller.assignments", "mesh-delivery-assigner.placement", "mesh-controller.declared-config", "mesh-controller.predefined"