diff --git a/modules/mesh-grants/grants_test.go b/modules/mesh-grants/grants_test.go index d8dd498..257c4b1 100644 --- a/modules/mesh-grants/grants_test.go +++ b/modules/mesh-grants/grants_test.go @@ -1435,7 +1435,12 @@ func TestGrants(t *testing.T) { var fresh []asks.Condition fp := partOn(b) fp.Objects = p.Objects - fp.Asker, fp.Say = fa, func(c asks.Condition) error { fresh = append(fresh, c); return nil } + fp.Asker, fp.Say = fa, func(c asks.Condition) error { + if c.Key != grants.GrantMissingCondition { // its own row below + fresh = append(fresh, c) + } + return nil + } fp.Compose() if len(fresh) == 0 || fresh[0].Key != grants.HeldCondition || fresh[0].State != asks.StateCleared { t.Errorf("a fresh part's first pass does not say the held warning is cleared: %+v", fresh) @@ -1951,6 +1956,52 @@ func TestGrants(t *testing.T) { } }) + // Issue 1126 (C5): a module whose source commit has not moved since a build of the old form, with a done key of the + // new form at that commit (link2pay, snake, f1-circuits, txt-game), is granted at the indexer's fingerprint, never + // judged by its commit; and a placed module left without a grant is said, never passed over: in the pass, and as the + // condition mesh-grants.grant-missing once the part's grants are the ones read, cleared when it has one. + t.Run("grants: at the indexer's fingerprint whatever the commit, and a placed module without one is said", func(t *testing.T) { + b := fixture() + b.loaded = shadowOf(t, b) + p := partOn(b) + var said []asks.Condition + p.Say = func(c asks.Condition) error { + if c.Key == grants.GrantMissingCondition { + said = append(said, c) + } + return nil + } + b.put(grants.ReadLease, grants.LeaseKey, map[string]any{"handed-to": "the operator's hand-over"}) + fp := "src2:d4afe7a1abab5ccdbef6d8629b2245006a2aec60290f430f7b4756e2930ed60c" + b.put(grants.ReadModules, "mod-x", delivery.Module{V: delivery.Version, FP2: fp, Manifest: delivery.ManifestDigest([]byte(xManifest)), + Repository: "novox/Link2Pay", Commit: "5d20e8bfbc953c51e8ebe3c671fe883df9c36e15"}) + b.put(grants.ReadModules, "mod-y", delivery.Module{V: delivery.Version, None2: "its builder's version is not read yet", + Manifest: delivery.ManifestDigest([]byte(yManifest)), Repository: "novox/y", Commit: "c"}) + for _, u := range []string{"a.mod-x", "a.nats", "b.mod-y"} { + b.put(grants.ReadAccounts, u, map[string]any{"user": u, "hash": "$2a$10$x", "generation": 1}) + } + res := p.Compose() + key, _ := delivery.PartGrantKey("mod-x", fp) + if _, held := b.own[grants.StateGrants][key]; !held { + t.Errorf("mod-x has no grant at the indexer's fingerprint %s: %+v", fp, res.Grants) + } + if res.Grants == nil || len(res.Grants.Missing) != 1 || !strings.HasPrefix(res.Grants.Missing[0], + "mod-y: no fingerprint of the new form: its builder's version is not read yet") { + t.Fatalf("missing = %+v", res.Grants) + } + if len(said) != 1 || said[0].State != asks.StateRaised || !strings.Contains(said[0].Explanation, "mod-y") { + t.Fatalf("said %+v", said) + } + if p.Compose(); len(said) != 1 { + t.Errorf("the same missing grant was said again: %+v", said) + } + b.put(grants.ReadModules, "mod-y", delivery.Module{V: delivery.Version, FP2: fp, Manifest: delivery.ManifestDigest([]byte(yManifest)), + Repository: "novox/y", Commit: "c"}) + if res := p.Compose(); len(res.Grants.Missing) != 0 || len(said) != 2 || said[1].State != asks.StateCleared { + t.Errorf("once mod-y has a grant: missing %v, said %+v", res.Grants.Missing, said) + } + }) + // 774/AC4 (novox/hq ADR 0561 ยง2): every account the vault mints that the records compose is written to the state // accounts once, with the bus's address and a module's claims, read by the vault alone; a key is deleted when placement // no longer names its user, and never in a pass whose reads failed. @@ -2092,7 +2143,12 @@ func TestGrants(t *testing.T) { // A held consumer that differs is never changed by a pass: it is raised as a condition naming it, and brought to // the composed configuration only by hand (grants_consumer_match), never while the controller holds the lease. var said []asks.Condition - p.Say = func(c asks.Condition) error { said = append(said, c); return nil } + p.Say = func(c asks.Condition) error { + if c.Key != grants.GrantMissingCondition { // its own row + said = append(said, c) + } + return nil + } made["max_deliver"] = 9 res = p.Compose() if len(res.Consumers.Differ) != 1 || !strings.Contains(res.Consumers.Differ[0], "EVENTS.b_mod-c: max deliver 9") || diff --git a/modules/mesh-grants/internal/grants/grants.go b/modules/mesh-grants/internal/grants/grants.go index d95668c..17c9e93 100644 --- a/modules/mesh-grants/internal/grants/grants.go +++ b/modules/mesh-grants/internal/grants/grants.go @@ -2,9 +2,11 @@ package grants import ( "fmt" + "slices" "sort" "strings" + "git.novox.be/novox/mesh-sdk/go/asks" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" ) @@ -28,6 +30,91 @@ type GrantsWritten struct { Deleted []string `json:"deleted,omitempty"` // Kept says why a pass wrote and deleted nothing, or left a module out. Kept []string `json:"kept,omitempty"` + // Missing is each module the placement or an assignment puts on a machine that has no grant after the pass, + // `: `: the assigner's sixth rule wants none of it (MissingGrants). Never passed over in silence: once + // the part's grants are the ones read, it is the condition mesh-grants.grant-missing. + Missing []string `json:"missing,omitempty"` +} + +// GrantMissingCondition is the key of the warning raised while a placed module has no grant (issue 1126). +const GrantMissingCondition = "mesh-grants.grant-missing" + +// MissingGrants is each module assigned or placed on a machine that want leaves without a grant, `: `, +// sorted: its manifest refused (skip), no fingerprint of the new form at the indexer's trunk, or no account with a hash +// in the vault (drop). **The fingerprint is the indexer's, never a commit's** (issue 1126: link2pay, snake, f1-circuits +// and txt-game, whose source commit has not moved since a build of the old form, have a done key of the new form at it, +// and that is the fingerprint granted). Pure. +func MissingGrants(in Inputs, want map[string]delivery.ControllerGrant, drop []string, skip map[string]bool) []string { + placed := map[string]bool{} + for _, ms := range in.Assigned { + for _, m := range ms { + placed[m] = true + } + } + var out []string + for _, module := range sortedKeys(placed) { + fp, has := in.Fingerprints[module] + key, err := delivery.PartGrantKey(module, fp) + if has && err == nil { + if _, granted := want[key]; granted { + continue + } + } + why := "" + switch { + case skip[module]: + why = "its manifest at the indexer's trunk is refused" + case !has: + why = "no fingerprint of the new form: " + orSaid(in.NoFingerprint[module], "the indexer holds no value of it") + case err != nil: + why = fmt.Sprintf("no grant key for %s: %v", fp, err) + case slices.Contains(drop, key): + why = "none of its accounts at " + fp + " has a hash in the vault" + default: + why = "no grant composed at " + fp + } + out = append(out, module+": "+why) + } + return out +} + +func orSaid(s, none string) string { + if s == "" { + return none + } + return s +} + +// sayMissing raises mesh-grants.grant-missing while a placed module has no grant, once the controller's lease is handed +// away (the part's grants are then the ones the assigner reads), and clears it when every one has one; in shadow the +// pass and the tool say them. Said once per set, again when it changes, and always at the first pass after a start. +func (p *Part) sayMissing(missing []string) { + if p.Say == nil { + return + } + why := "" + if on, _ := leaseHandedAway(p.Read); on && len(missing) > 0 { + why = strings.Join(missing, "; ") + } + p.holdMu.Lock() + defer p.holdMu.Unlock() + if p.missingKnown && why == p.missingSaid { + return + } + c := asks.Condition{Key: GrantMissingCondition, Subject: asks.Scope{Scope: "module", ID: "mesh-grants"}} + if why == "" { + c.State = asks.StateCleared + } else { + c.Severity, c.State = asks.SeverityWarning, asks.StateRaised + c.Summary = fmt.Sprintf("%d placed module(s) have no grant, so no want can name them", len(missing)) + c.Headline, c.Needs = "A placed module has no bus grant", "a look at why each has none" + c.Explanation = asks.NeedsYou + "a look. The grants part wrote no grant for these modules, so the assigner's " + + "credential rule gives them no want: " + why + c.Resolved = "every placed module has a grant at the fingerprint the indexer names" + } + if p.Say(c) == nil { + p.missingSaid, p.missingKnown = why, true + } } // GrantsOf is the grant of every module at the fingerprint the indexer's trunk names for it, by key, and the keys that @@ -102,8 +189,9 @@ func (p *Part) writeGrants(res *Result, in Inputs, recs buscompose.Records) { res.Errors = append(res.Errors, "the grants could not be composed: "+err.Error()) return } - done := &GrantsWritten{Wanted: len(want)} + done := &GrantsWritten{Wanted: len(want), Missing: MissingGrants(in, want, drop, skip)} res.Grants = done + p.sayMissing(done.Missing) for _, key := range sortedKeys(want) { g := want[key] data, err := delivery.EncodeControllerGrant(g) diff --git a/modules/mesh-grants/internal/grants/inputs.go b/modules/mesh-grants/internal/grants/inputs.go index ffeec51..fa34b6d 100644 --- a/modules/mesh-grants/internal/grants/inputs.go +++ b/modules/mesh-grants/internal/grants/inputs.go @@ -118,6 +118,9 @@ type Inputs struct { // Fingerprints is the fingerprint of the new form the indexer's trunk value names for each module, by module: what a // grant is written for (grants.go). Fingerprints map[string]string + // NoFingerprint is why the indexer's value of a module names no fingerprint of the new form, by module (its none2, or + // that it names none): a module here gets no grant, and that is said (grants.go MissingGrants). + NoFingerprint map[string]string // Running is the manifest a machine is wanted to run, by `.`: the one the builder built at the // fingerprint the assigner's want names. An assignment with none is composed from the trunk's manifest. Running map[string]catalogue.Manifest @@ -241,8 +244,8 @@ func each(r Reader, state string, gaps *[]Gap) map[string][]byte { // ReadInputs reads what the shadow composes from. It never fails: what it cannot read is a gap. func ReadInputs(r Reader, cache *Manifests) Inputs { in := Inputs{Assigned: map[string][]string{}, Manifests: map[string]catalogue.Manifest{}, Digests: map[string]string{}, Fingerprints: map[string]string{}, - Running: map[string]catalogue.Manifest{}, - Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)} + NoFingerprint: map[string]string{}, Running: map[string]catalogue.Manifest{}, + Values: map[string]any{}, Gaps: append([]Gap(nil), standingGaps...)} nodes := map[string]bool{} for _, raw := range each(r, ReadAssignments, &in.Gaps) { var a assignment @@ -341,8 +344,14 @@ func ReadInputs(r Reader, cache *Manifests) Inputs { if err == nil && v.Manifest == "" { err = fmt.Errorf("the indexer's value names no manifest digest") } - if err == nil && v.FP2 != "" { + if err == nil && strings.HasPrefix(v.FP2, delivery.SourcePrefix2) { in.Fingerprints[module] = v.FP2 + } else if err == nil { + why := v.None2 + if why == "" { + why = "the indexer's value names no fingerprint of the new form" + } + in.NoFingerprint[module] = why } var m catalogue.Manifest if err == nil { diff --git a/modules/mesh-grants/internal/grants/part.go b/modules/mesh-grants/internal/grants/part.go index 616f031..29c5291 100644 --- a/modules/mesh-grants/internal/grants/part.go +++ b/modules/mesh-grants/internal/grants/part.go @@ -179,6 +179,9 @@ type Part struct { // differSaid is the account consumers' differences last said as a condition, once differKnown (consumers.go). differSaid string differKnown bool + // missingSaid is the modules without a grant last said as a condition, once missingKnown (grants.go). + missingSaid string + missingKnown bool // hashHold are the users whose hash changes held the pass's list, or nil (sealed.go). hashHold []string }