A node's have says whose compositions it applies, and its answer to a head deleting its placement says it returns to the controller's (issue 1126, the seed's revert)

The check of the seed's revert (mesh-catalog #526) failed on 'would stop
running node': the shadow answered a deleted placement as everything removed,
while the node keeps what runs. HaveNode.Composes and ComposesWhy say whether a
node applies its own compositions or the controller's; CandidateAnswer.Returns
answers a deleted placement, verdict same, naming nothing composed.
This commit is contained in:
2026-10-27 23:22:54 +01:00
parent 2fd5ffdd62
commit 472d5f3144
3 changed files with 47 additions and 4 deletions
+19 -1
View File
@@ -410,8 +410,20 @@ type HaveNode struct {
// its shadow composer whenever an answer changes, removed when the candidate key goes. The node applies nothing of a
// candidate.
Candidates map[string]CandidateAnswer `json:"candidates,omitempty"`
// Composes is whose compositions the node applies: its own (ComposesOwn, the catalogue places it) or the
// controller's (ComposesController); ComposesWhy says why in words. Written by the node agent at each start, so a
// node returning to the controller's compositions when its placement key is deleted says so (issue 1126). Empty
// from an engine older than the field.
Composes string `json:"composes,omitempty"`
ComposesWhy string `json:"composes-why,omitempty"`
}
// Whose compositions a node applies (HaveNode.Composes).
const (
ComposesOwn = "own"
ComposesController = "controller"
)
// Presence is whether a node or a part's instance is online; the entry's own time says since when. The watchman
// writes it.
type Presence struct {
@@ -506,6 +518,11 @@ func DecodeHave(data []byte) (Have, error) {
}
func (n HaveNode) check() error {
switch n.Composes {
case "", ComposesOwn, ComposesController:
default:
return fmt.Errorf("delivery: a node composes %q, neither %s nor %s", n.Composes, ComposesOwn, ComposesController)
}
for group, a := range n.Candidates {
if group == "" {
return fmt.Errorf("delivery: a node's candidate answer names no group")
@@ -590,7 +607,8 @@ func SameHaveNode(a, b HaveNode) bool {
return a.V == b.V && a.Composed == b.Composed && sameJSON(a.Windows, b.Windows) && sameJSON(a.Busy, b.Busy) &&
sameJSON(a.Strays, b.Strays) && sameJSON(a.Filters, b.Filters) && sameJSON(a.Profile, b.Profile) &&
(a.Record == nil) == (b.Record == nil) && (a.Record == nil || SameNodeRecord(*a.Record, *b.Record)) &&
jsonEqual(a.Sealed, b.Sealed) && jsonEqual(a.Candidates, b.Candidates)
jsonEqual(a.Sealed, b.Sealed) && jsonEqual(a.Candidates, b.Candidates) && a.Composes == b.Composes &&
a.ComposesWhy == b.ComposesWhy
}
// SamePresence says whether two presences are the same value.
+11
View File
@@ -132,8 +132,16 @@ type CandidateAnswer struct {
Refusals []ModuleRefused `json:"refusals,omitempty"`
// Refused is why the node could not compose with the candidate at all; Refusals is then empty.
Refused string `json:"refused,omitempty"`
// Returns is, for a head that deletes the node's placement file, what the node does then: it returns to the
// controller's compositions (ReturnsToController), as a node the catalogue does not place composes none of its own
// (issue 1126: the seed's revert). The verdict is then same, and nothing else is named: the node composes nothing
// with the candidate, and judges nothing of what the controller composes.
Returns string `json:"returns,omitempty"`
}
// ReturnsToController is what a node answers a head that deletes its placement file.
const ReturnsToController = "the head deletes this node's placement: it returns to the controller's compositions"
// ModuleDiffers is one module the node runs and the candidate still places whose composition is not the one it runs:
// both digests (Composed with the candidate, Running what runs), the first resource that differs by its id, or empty
// when every resource is equal, and why in words.
@@ -187,6 +195,9 @@ func (a CandidateAnswer) check() error {
if a.Refused != "" && len(a.Same)+len(a.Differs)+len(a.Added)+len(a.Removed)+len(a.Refusals) > 0 {
return fmt.Errorf("delivery: a candidate the node could not compose names modules")
}
if a.Returns != "" && (a.Refused != "" || len(a.Same)+len(a.Differs)+len(a.Added)+len(a.Removed)+len(a.Refusals) > 0) {
return fmt.Errorf("delivery: a candidate the node returns to the controller's compositions on names what it composed")
}
for _, d := range a.Differs {
if d.Module == "" || d.Why == "" {
return fmt.Errorf("delivery: a candidate difference names no module or no why")
+17 -3
View File
@@ -229,19 +229,31 @@ func TestPlacement(t *testing.T) {
"refused": {Head: "h1", Revision: 3, Verdict: CandidateRefused, Refused: "unbound postgres"},
"added": {Head: "h1", Verdict: CandidateDiffers, Same: []string{"node"}, Added: []ModuleAdded{{Module: "app", Unissued: []string{"app/token"}}}, Removed: []string{"old"}},
"a value": {Head: "h1", Verdict: CandidateRefused, Same: []string{"node"}, Refusals: []ModuleRefused{{Module: "db", Why: "stands in for db/password"}}},
// A head that deletes the node's placement file: the node returns to the controller's compositions (issue 1126,
// the seed's revert, mesh-catalog #526).
"returns": {Head: "h1", Verdict: CandidateSame, Returns: ReturnsToController},
}
n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers}
n := HaveNode{Composed: "sha256:" + strings.Repeat("a", 64), Sealed: []string{"box"}, Candidates: answers,
Composes: ComposesOwn, ComposesWhy: "the catalogue places it (placement.n1)"}
raw, err := EncodeHaveNode(n)
if err != nil {
t.Fatal(err)
}
back, err := DecodeHaveNode(raw)
if err != nil || !SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers}) {
if err != nil || !SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers,
Composes: ComposesOwn, ComposesWhy: n.ComposesWhy}) {
t.Fatalf("a node's answers changed on the way: %+v, %v", back, err)
}
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed}) {
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Composes: ComposesOwn, ComposesWhy: n.ComposesWhy}) {
t.Error("a node's have without its answers counts as the same")
}
if SameHaveNode(back, HaveNode{V: Version, Composed: n.Composed, Sealed: n.Sealed, Candidates: answers,
Composes: ComposesController, ComposesWhy: n.ComposesWhy}) {
t.Error("a node's have composing otherwise counts as the same")
}
if _, err := EncodeHaveNode(HaveNode{Composes: "both"}); err == nil {
t.Error("a node composing neither its own nor the controller's was taken")
}
// CompareComposed: the bus's user list is left out on both sides; anything else that differs is named.
file := func(id, content string) json.RawMessage {
return json.RawMessage(`{"id":"` + id + `","type":"file","module":"nats","path":"/etc/` + id + `","mode":"0600","content":"` + content + `"}`)
@@ -285,6 +297,8 @@ func TestPlacement(t *testing.T) {
"same with an added": {Head: "h", Verdict: CandidateSame, Added: []ModuleAdded{{Module: "a"}}},
"differs refusing": {Head: "h", Verdict: CandidateDiffers, Removed: []string{"a"}, Refusals: []ModuleRefused{{Module: "b", Why: "x"}}},
"refused and modules": {Head: "h", Verdict: CandidateRefused, Refused: "x", Same: []string{"a"}},
"returns and removes": {Head: "h", Verdict: CandidateDiffers, Returns: ReturnsToController, Removed: []string{"a"}},
"returns refused": {Head: "h", Verdict: CandidateRefused, Returns: ReturnsToController, Refused: "x"},
} {
if _, err := EncodeHaveNode(HaveNode{Candidates: map[string]CandidateAnswer{"g": a}}); err == nil {
t.Errorf("%s: taken", name)