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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"gitea.gitea_get_pull_request"
|
||||
],
|
||||
"reads": [
|
||||
"mesh-controller.assignments",
|
||||
"mesh-delivery-assigner.placement",
|
||||
"mesh-controller.declared-config",
|
||||
"mesh-controller.predefined"
|
||||
|
||||
Reference in New Issue
Block a user