diff --git a/modules/mesh-vault/cmd/mesh-vault/askers.go b/modules/mesh-vault/cmd/mesh-vault/askers.go index 2e385c3a..037bce99 100644 --- a/modules/mesh-vault/cmd/mesh-vault/askers.go +++ b/modules/mesh-vault/cmd/mesh-vault/askers.go @@ -143,8 +143,8 @@ func (d Declared) May(caller, name string) (bool, string) { } raw, err := d.Read(node + "." + module) if err != nil { - return false, fmt.Sprintf("the assignment %s.%s could not be read, so what it names is not known: %v", node, - module, err) + return false, fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read, so what it names is not known: %v", + node, module, err) } if raw == nil { return false, fmt.Sprintf("%s is not on %s: neither assigned there nor required by a module there", module, @@ -173,3 +173,8 @@ func (d Declared) May(caller, name string) (bool, string) { } return false, fmt.Sprintf("%s's plan on %s names no secret %s", module, node, secret) } + +// notYetPrefix begins a reason that is not a refusal (issue 928): what the rule needs could not be read just now, a +// bus error rather than an answer. The vault says "holds no secret named ... yet", which the client waits on and asks +// again, and records no refusal. +const notYetPrefix = "not yet: " diff --git a/modules/mesh-vault/cmd/mesh-vault/notyet_test.go b/modules/mesh-vault/cmd/mesh-vault/notyet_test.go new file mode 100644 index 00000000..951ae1b6 --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/notyet_test.go @@ -0,0 +1,48 @@ +package main + +import ( + "errors" + "strings" + "testing" + + "git.novox.be/novox/mesh-sdk/go/seal" +) + +// Issue 928: an assignment the vault could not read (the controller was being pushed, "no responders") is not a +// refusal the client keeps for ten minutes: it is "not yet", the answer the client waits on and asks again. +func TestAnAssignmentThatCannotBeReadIsNotYetAndNotARefusal(t *testing.T) { + down := errors.New("nats: no responders available for request") + read := func(string) ([]byte, error) { return nil, down } + v := newVault(t, Declared{Read: read}) + asker, _ := seal.GenerateKeyPair() + for _, name := range []string{"own/g14/claude-code/token", "provision/novox/gitea-read/g14/claude-code"} { + _, err := v.Get("provisioner.g14", name, asker.PublicKey) + if err == nil { + t.Fatalf("%s: answered while the assignment cannot be read and the vault holds nothing", name) + } + if !strings.Contains(err.Error(), "holds no secret named") || !strings.Contains(err.Error(), "no responders") { + t.Errorf("%s: the answer is %q, not the not-yet the client waits on, with the reason", name, err) + } + } + hs, _ := v.Store.Handed(10) + for _, h := range hs { + if h.Outcome == "refused" { + t.Errorf("a read error was recorded as a refusal: %+v", h) + } + } +} + +// A pair row asked for while the consumer's assignment cannot be read is not yet either, and a row whose +// assignment reads as absent stays "not on". +func TestAPairRowWhoseAssignmentCannotBeReadIsNotYet(t *testing.T) { + down := errors.New("nats: no responders available for request") + parts := []string{"pair", "novox", "db", "g14", "claude-code"} + why := pairRowRefusal(func(string) ([]byte, error) { return nil, down }, "g14", parts) + if !strings.HasPrefix(why, notYetPrefix) || !strings.Contains(why, "no responders") { + t.Errorf("a read error answered %q, want a not-yet carrying the reason", why) + } + why = pairRowRefusal(func(string) ([]byte, error) { return nil, nil }, "g14", parts) + if strings.HasPrefix(why, notYetPrefix) || !strings.Contains(why, "is not on g14") { + t.Errorf("an absent assignment answered %q, want the refusal 'not on'", why) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/pair.go b/modules/mesh-vault/cmd/mesh-vault/pair.go index ac3df924..2417df12 100644 --- a/modules/mesh-vault/cmd/mesh-vault/pair.go +++ b/modules/mesh-vault/cmd/mesh-vault/pair.go @@ -780,7 +780,10 @@ func pairRowRefusal(read AssignmentReader, node string, parts []string) string { return "the vault cannot read the assignments, so it cannot tell what " + node + "'s plans name" } raw, err := read(consumer + "." + module) - if err != nil || raw == nil { + if err != nil { + return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", consumer, module, err) + } + if raw == nil { return fmt.Sprintf("%s is not on %s", module, consumer) } var a plannedProvisions diff --git a/modules/mesh-vault/cmd/mesh-vault/vault.go b/modules/mesh-vault/cmd/mesh-vault/vault.go index 817bb3b3..0fffa275 100644 --- a/modules/mesh-vault/cmd/mesh-vault/vault.go +++ b/modules/mesh-vault/cmd/mesh-vault/vault.go @@ -11,6 +11,7 @@ import ( "errors" "fmt" "regexp" + "strings" "sync" "time" @@ -237,6 +238,11 @@ func (v *Vault) Get(caller, name, publicKey string) (Answer, error) { return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why) } } else if ok, why := askers.May(caller, name); !ok { + if reason, notYet := strings.CutPrefix(why, notYetPrefix); notYet { + // Not a refusal (issue 928): the client waits and asks again, and nothing is recorded against it. + v.log("not yet %s to %s: %s", name, caller, reason) + return Answer{}, fmt.Errorf("the vault holds no secret named %s yet: %s", name, reason) + } v.log("refused %s to %s: %s", name, caller, why) _ = v.recordHanding(Handing{To: caller, Name: name, Outcome: "refused", Why: why}) return Answer{}, fmt.Errorf("%s may not ask for %s: %s", caller, name, why)