diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go index 9c841f7..3ba3270 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/pass.go @@ -36,6 +36,8 @@ type passer struct { failedProvisions map[string]bool // missing are the required provisions with no key this process pulsed, and missingSaid those whose finding it wrote. missing, missingSaid map[string]bool + // unplaced are the nodes whose unplaced finding this process wrote. + unplaced map[string]bool // answeredAt is, by the id of a backup ask, how many values the have had when its answer was written into the want. answeredAt map[string]uint64 asks *composeAsks @@ -142,6 +144,7 @@ func (p *passer) wants(ctx context.Context) { provisionsOf := p.provisionsOf() missing := map[string]bool{} defer func() { p.provisionsMissing(ctx, missing) }() + defer p.unplacedFindings() defer p.policySplits(manifests, chosen) frozen := p.sayPlacement(p.withCalls(p.w.placementDiffers())) placed := p.w.placedNodes() @@ -171,6 +174,9 @@ func (p *passer) wants(ctx context.Context) { m := assign.Module{Name: name, FP: fp, Manifest: manifests[name], Chosen: chosenOf(chosen, name), Provisions: provisionsOf(name), Nodes: nodesOf[name], Now: time.Now(), Puts: puts[name], Self: func(node string) bool { return placed[node] }, Composed: func(node, fp string) (delivery.ComposedDigest, bool) { + if placed[node] && p.w.handedAway() { + return p.w.ownComposed(node) // from the lease write: the digest its own have names (ADR 0571 §6) + } if placed[node] { // The node composes its own (ADR 0547 §2, §4): no composition of the controller's is waited for, the want // names the build alone, and the node judges the move itself. @@ -972,7 +978,11 @@ func (p *passer) policySplits(manifests map[string]catalogue.Manifest, chosen ma now = p.now } split := map[string]bool{} - for _, s := range assign.Splits(manifests, chosen, assign.ControllerPolicies(p.w.read(delivery.ControllerComposedBucket))) { + theirs := map[string][]string{} + if !p.w.handedAway() { // the controller writes no policy from the lease write (ADR 0571 §6) + theirs = assign.ControllerPolicies(p.w.read(delivery.ControllerComposedBucket)) + } + for _, s := range assign.Splits(manifests, chosen, theirs) { split[s.Module] = true if p.splits[s.Module] { continue @@ -1140,3 +1150,39 @@ func (p *passer) provisionsMissing(ctx context.Context, missing map[string]bool) } } } + +// unplacedFindings writes, from the lease write, the finding `machine..unplaced` for each node that runs but no +// placement names, which gets no want (ADR 0571 §6), and deletes it once the node is placed. +func (p *passer) unplacedFindings() { + if p.findings.Put == nil { + return + } + now := []string(p.w.unplacedNodes()) + is := map[string]bool{} + for _, node := range now { + is[node] = true + if p.unplaced[node] { + continue + } + value, err := assign.UnplacedFinding(node, p.clock()) + if err == nil { + err = p.findings.Put(assign.UnplacedKey(node), value) + } + if err != nil { + p.logf("the unplaced node %s is not said: %v", node, err) + continue + } + if p.unplaced == nil { + p.unplaced = map[string]bool{} + } + p.unplaced[node] = true + } + for node := range p.unplaced { + if is[node] { + continue + } + if err := p.findings.Delete(assign.UnplacedKey(node)); err == nil { + delete(p.unplaced, node) + } + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go index af24d5f..ea1b3ef 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/seat_tools_test.go @@ -160,6 +160,47 @@ func TestTheSeatsVerbs(t *testing.T) { t.Errorf("the seat's verbs as served: %+v", tools) } t.Run("1953/D4-D6: the seat mesh-wants' upgrade and bus", wantsVerbs) + t.Run("1955/L7 (ADR 0571 §6): rule 7 reads the controller's composition until the lease write, the node's own after; an unplaced node is said", func(t *testing.T) { + w := newWorld() + fp := delivery.SourcePrefix2 + strings.Repeat("2", 64) + ckey, _ := delivery.ComposedKey("ace", "web", fp) + cv, _ := delivery.EncodeComposedDigest(delivery.ComposedDigest{Digest: "sha256:" + strings.Repeat("c", 64), Judgement: delivery.JudgedNeither, + Policy: catalogue.PolicyRoll, PolicyFrom: catalogue.FromDefault}) + w.putEntry(delivery.ControllerComposedBucket, ckey, cv, 1, time.Now()) + pl, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "ace", Modules: []delivery.PlacedModule{{Module: "web"}}}) + pkey, _ := delivery.PlacementKey("ace") + w.putEntry(delivery.PlacementBucket, pkey, pl, 1, time.Now()) + for node, digest := range map[string]string{"ace": "sha256:" + strings.Repeat("a", 64), "g14": ""} { + hn, _ := delivery.EncodeHaveNode(delivery.HaveNode{Composed: digest}) + hk, _ := delivery.HaveNodeKey(node) + w.putEntry(delivery.HaveBucket, hk, hn, 1, time.Now()) + } + if d, ok := w.composed("ace", "web", fp); !ok || d.Digest != "sha256:"+strings.Repeat("c", 64) || len(w.unplacedNodes()) != 0 { + t.Fatalf("before the lease write: %+v %v, unplaced %v", d, ok, w.unplacedNodes()) + } + w.putEntry(buscompose.LeaseBucket, buscompose.ControllerLeaseKey, []byte(`{"handed-to":"mesh-vault","handed-by":"operator"}`), 2, time.Now()) + if d, ok := w.composed("ace", "web", fp); !ok || d.Digest != "sha256:"+strings.Repeat("a", 64) { + t.Errorf("after the lease write, ace: %+v %v", d, ok) + } + if _, ok := w.composed("g14", "web", fp); ok || !slices.Equal(w.unplacedNodes(), []string{"g14"}) { + t.Errorf("after the lease write, g14 unplaced: composed %v, unplaced %v", ok, w.unplacedNodes()) + } + findings := map[string][]byte{} + p := &passer{w: w, logf: func(string, ...any) {}, findings: assign.Findings{ + Put: func(k string, v []byte) error { findings[k] = v; return nil }, + Delete: func(k string) error { delete(findings, k); return nil }}} + p.unplacedFindings() + if _, ok := findings[assign.UnplacedKey("g14")]; !ok || len(findings) != 1 { + t.Fatalf("findings = %v", findings) + } + pl2, _ := delivery.EncodePlacement(delivery.Placement{V: delivery.Version, Node: "g14", Modules: []delivery.PlacedModule{{Module: "web"}}}) + pkey2, _ := delivery.PlacementKey("g14") + w.putEntry(delivery.PlacementBucket, pkey2, pl2, 1, time.Now()) + p.unplacedFindings() + if len(findings) != 0 { + t.Errorf("placed again: findings %v", findings) + } + }) t.Run("1954/D1: a failed provision is the assigner's finding until it is provided", func(t *testing.T) { findings := map[string][]byte{} p := &passer{w: newWorld(), logf: func(string, ...any) {}, kvs: buckets{provisions.ProvisionsBucket: nil}, findings: assign.Findings{ diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go index 8495a3d..175bf7a 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/world.go @@ -604,7 +604,17 @@ func (w *world) built(module, fp string) bool { // composed is the controller's composition of module for node at fp: its digest and the judgement of the move to it // (mesh-controller_composed `..`), and whether it exists and can be read. +// +// From the lease write the controller's copy is no longer read (ADR 0571 §6, a named exception of clause 10 until then): a +// placed node composes its own, and its composition is the digest its own have names; an unplaced node has none, so it gets +// no want (and the pass says so, unplacedFindings). func (w *world) composed(node, module, fp string) (delivery.ComposedDigest, bool) { + if w.handedAway() { + if !w.placedNodes()[node] { + return delivery.ComposedDigest{}, false + } + return w.ownComposed(node) + } key, err := delivery.ComposedKey(node, module, fp) if err != nil { return delivery.ComposedDigest{}, false @@ -622,6 +632,9 @@ func (w *world) composed(node, module, fp string) (delivery.ComposedDigest, bool // nodeComposed is, by node, the digest of the node's own composition (mesh-controller_composed `.node`). func (w *world) nodeComposed() map[string]delivery.ComposedDigest { out := map[string]delivery.ComposedDigest{} + if w.handedAway() { + return out // the controller composes no node from the lease write (ADR 0571 §6) + } for key, value := range w.read(delivery.ControllerComposedBucket) { node, _, _, isNode, err := delivery.ParseComposedKey(key) if err != nil || !isNode { @@ -683,3 +696,40 @@ func (w *world) runningCommits(ctx context.Context, get getter, module string) [ // keptBucket is mesh-config's `kept`: the approved held values of open pull requests (ADR 0565 §5). var keptBucket = "mesh-config_" + delivery.MeshConfigKeptState + +// ownComposed is a placed node's own composition, as the digest its own have names (`.node` of the haves, Composed): +// what rule 7 reads from the lease write. A node whose have names none is not composed yet. +func (w *world) ownComposed(node string) (delivery.ComposedDigest, bool) { + key, err := delivery.HaveNodeKey(node) + if err != nil { + return delivery.ComposedDigest{}, false + } + raw, ok := w.read(delivery.HaveBucket)[key] + if !ok { + return delivery.ComposedDigest{}, false + } + h, err := delivery.DecodeHaveNode(raw) + if err != nil || h.Composed == "" { + return delivery.ComposedDigest{}, false + } + return delivery.ComposedDigest{V: delivery.Version, Digest: h.Composed, Judgement: delivery.JudgedNeither, + Words: "the node composes its own"}, true +} + +// unplacedNodes are, from the lease write, the nodes whose engine has a have and that no placement names: they get no +// want, since nothing composes them. +func (w *world) unplacedNodes() []string { + if !w.handedAway() { + return nil + } + placed := w.placedNodes() + var out []string + for key := range w.read(delivery.HaveBucket) { + node, _, isNode, err := delivery.ParseHaveKey(key) + if err == nil && isNode && !placed[node] { + out = append(out, node) + } + } + sort.Strings(out) + return out +} diff --git a/modules/mesh-delivery-assigner/internal/assign/policy.go b/modules/mesh-delivery-assigner/internal/assign/policy.go index c2c9aa8..e877ba7 100644 --- a/modules/mesh-delivery-assigner/internal/assign/policy.go +++ b/modules/mesh-delivery-assigner/internal/assign/policy.go @@ -186,3 +186,23 @@ func ProvisionMissingFinding(key string, bound time.Duration, at time.Time) ([]b Resolved: provision + " for " + module + " on " + node + " has its key", Evidence: []health.Evidence{{At: at.UTC(), Said: summary}}, Source: "assigner." + KindProvisionMissing, Observer: delivery.AssignerModule, At: at.UTC()}) } + +// KindUnplaced is, from the lease write, a node that runs but no placement names (ADR 0571 §6). +const KindUnplaced = "unplaced" + +// UnplacedKey is its finding's key, `machine..unplaced`. +func UnplacedKey(node string) string { + return health.FindingKey(health.ScopeMachine, node, KindUnplaced) +} + +// UnplacedFinding is the finding of a node no placement names since the lease write. +func UnplacedFinding(node string, at time.Time) ([]byte, error) { + summary := fmt.Sprintf("%s runs, but no placement names it: since the controller handed its lease away nothing composes it, "+ + "so the assigner writes it no want", node) + return health.EncodeFinding(health.Finding{Key: UnplacedKey(node), Kind: KindUnplaced, + Subject: health.Subject{Scope: health.ScopeMachine, ID: node, Machine: node}, Severity: health.Warning, Summary: summary, + Headline: clipHeadline(node + " has no placement"), Needs: "place it with mesh-placement.propose.", + Explanation: asks.NeedsYou + "place it with mesh-placement.propose. " + summary + ".", + Resolved: node + " is placed", Evidence: []health.Evidence{{At: at.UTC(), Said: summary}}, + Source: "assigner." + KindUnplaced, Observer: delivery.AssignerModule, At: at.UTC()}) +}