mesh-config reads the placement beside the controller's assignments while both stand, as L2 has it and the vault does, and is listed as a reader of them (issue 774, ADR 0549 §5; re-review)

This commit is contained in:
2026-10-28 21:54:36 +01:00
parent 5be6576117
commit 10447d8c1e
9 changed files with 101 additions and 90 deletions
+2 -1
View File
@@ -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
+3 -4
View File
@@ -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
@@ -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)
}
}
@@ -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.
+1 -1
View File
@@ -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)
@@ -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)
}
@@ -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.<node>` 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
+21 -12
View File
@@ -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)
+1
View File
@@ -28,6 +28,7 @@
"gitea.gitea_get_pull_request"
],
"reads": [
"mesh-controller.assignments",
"mesh-delivery-assigner.placement",
"mesh-controller.declared-config",
"mesh-controller.predefined"