diff --git a/go/delivery/delivery.go b/go/delivery/delivery.go index 1b2306f..6e51c60 100644 --- a/go/delivery/delivery.go +++ b/go/delivery/delivery.go @@ -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. diff --git a/go/delivery/placement_candidate.go b/go/delivery/placement_candidate.go index edf257b..e3736bc 100644 --- a/go/delivery/placement_candidate.go +++ b/go/delivery/placement_candidate.go @@ -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") diff --git a/go/delivery/placement_test.go b/go/delivery/placement_test.go index ae77b5f..29376d0 100644 --- a/go/delivery/placement_test.go +++ b/go/delivery/placement_test.go @@ -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)