diff --git a/modules/mesh-config/cmd/mesh-config/config_test.go b/modules/mesh-config/cmd/mesh-config/config_test.go index e2418422..91a14c71 100644 --- a/modules/mesh-config/cmd/mesh-config/config_test.go +++ b/modules/mesh-config/cmd/mesh-config/config_test.go @@ -551,3 +551,43 @@ func TestTheVerbsArgumentsAreReadStrictly(t *testing.T) { t.Errorf("read %+v (%v)", call, err) } } + +// 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) + } + 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) + 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 6cf9f63f..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 one key of the assignments mirror: a module on a node. +// 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 `json:"node"` - Module string `json:"module"` + 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/sources.go b/modules/mesh-config/cmd/mesh-config/sources.go index 13cd30f1..221d1294 100644 --- a/modules/mesh-config/cmd/mesh-config/sources.go +++ b/modules/mesh-config/cmd/mesh-config/sources.go @@ -19,6 +19,9 @@ func (c *Config) sources() (Sources, error) { 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 {