diff --git a/checks/planhands/go.mod b/checks/planhands/go.mod new file mode 100644 index 00000000..2949a160 --- /dev/null +++ b/checks/planhands/go.mod @@ -0,0 +1,3 @@ +module checks/planhands + +go 1.26.0 diff --git a/checks/planhands/planhands.go b/checks/planhands/planhands.go new file mode 100644 index 00000000..389f6709 --- /dev/null +++ b/checks/planhands/planhands.go @@ -0,0 +1,48 @@ +// Package planhands refuses a module's line that takes a plan existing for its module handed to the provisioner (issue +// 774). Whether a plan hands its module is mesh-sdk's one rule, catalogue.Plan.Hands: since the seat mesh-provisioner keys a +// provision its provider's helper answers for a module not handed, a plan naming provisions alone exists for modules +// whose files and own secrets stay their node's (on 2026-10-30 the vault read every such plan as handed and answered +// twenty modules' own secrets to nobody, and the provisioner client refused each as a plan it could not act on). A line +// that tests whether a plan exists either asks Hands on the same line, or says "not handing": it reads only who binds +// whom, or that there is no plan at all. +package planhands + +import ( + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "sort" + "strings" +) + +// existence is a test of whether an assignment's or a plan's `provisioning` is there: nil, or an empty raw value. +var existence = regexp.MustCompile(`Provisioning\s*[!=]=\s*nil|len\([^)]*Provisioning\)\s*[!=]=\s*0`) + +// Problems is each such line under root's modules/ that neither asks Hands nor says "not handing", as +// `path:line: text`, sorted. Test files are left out: a test may build a plan by hand. +func Problems(root string) ([]string, error) { + var out []string + err := filepath.WalkDir(filepath.Join(root, "modules"), func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return nil + } + raw, err := os.ReadFile(p) + if err != nil { + return err + } + rel, _ := filepath.Rel(root, p) + for i, line := range strings.Split(string(raw), "\n") { + if existence.MatchString(line) && !strings.Contains(line, ".Hands()") && !strings.Contains(line, "not handing") { + out = append(out, fmt.Sprintf("%s:%d: %s", rel, i+1, strings.TrimSpace(line))) + } + } + return nil + }) + sort.Strings(out) + return out, err +} diff --git a/checks/planhands/planhands_test.go b/checks/planhands/planhands_test.go new file mode 100644 index 00000000..231126a6 --- /dev/null +++ b/checks/planhands/planhands_test.go @@ -0,0 +1,45 @@ +package planhands + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// A line that takes a plan existing for its module handed is refused and named; one that asks Hands, one marked "not +// handing" and any test file pass; and the real catalogue is clean. +func TestNoModuleTakesAPlanExistingForItsModuleHanded(t *testing.T) { + root := t.TempDir() + for f, body := range map[string]string{ + "modules/vault/own.go": "if a.Provisioning != nil {\n", + "modules/client/plan.go": "if len(a.Provisioning) == 0 {\n", + "modules/vault/rule.go": "return a.Provisioning != nil && a.Provisioning.Hands(), nil\n", + "modules/client/binds.go": "if a.Provisioning != nil { // binds, not handing\n", + "modules/vault/own_test.go": "if a.Provisioning == nil {\n", + "modules/client/notgo/plan.md": "if a.Provisioning == nil {\n", + } { + p := filepath.Join(root, f) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + got, err := Problems(root) + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || !strings.HasPrefix(got[0], "modules/client/plan.go:1:") || !strings.HasPrefix(got[1], "modules/vault/own.go:1:") { + t.Fatalf("refused %v, wanted the client's and the vault's existence tests alone", got) + } + real, err := Problems(filepath.Join("..", "..")) + if err != nil { + t.Fatal(err) + } + if len(real) > 0 { + t.Errorf("a module takes a plan existing for its module handed; ask catalogue.Plan.Hands, or mark a line that reads "+ + "only who binds whom \"not handing\":\n%s", strings.Join(real, "\n")) + } +} diff --git a/checks/planhands/reads-outside b/checks/planhands/reads-outside new file mode 100644 index 00000000..ee00f8dc --- /dev/null +++ b/checks/planhands/reads-outside @@ -0,0 +1,3 @@ +# What these tests read outside their package, as paths from the repository's root (issue 1936). +# every module whole (a directory is read whole, so a module's Go source at any depth enters the digest), for a line that takes a plan existing for its module handed. +modules diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go index 37650d8d..68ecc23f 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/client_test.go @@ -870,4 +870,11 @@ func TestAPlanOfProvisionsAloneFetchesNoTemplates(t *testing.T) { if targets, wrong := fillTargets(Plan{}, texts); wrong != "" || len(targets) != 0 { t.Fatalf("filled %v: %s", targets, wrong) } + // And such a plan hands its module to nobody (catalogue.Plan.Hands): read as no plan, never refused as one that + // names no source, and the module's files stay its node's. + routeOnly := `{"node":"g14","module":"grafana","provisioning":{"templates":{"digest":"","source":""},"files":null,` + + `"provisions":[{"provision":"route","provider":"g14","provider-module":"route-proxy"}]}}` + if module, _, err := planOf("g14", "g14.grafana", []byte(routeOnly)); module != "grafana" || !errors.Is(err, errNoPlan) { + t.Fatalf("a plan of provisions alone reads as %s: %v, want no plan", module, err) + } } diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers.go index 61e29d13..47c78b3b 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers.go @@ -77,7 +77,7 @@ func takeBinds(s *state, key string, value []byte, gone bool) bool { return was != nil } b := binding{node: a.Node, module: a.Module} - if a.Provisioning != nil { + if a.Provisioning != nil { // binds, not handing: a plan of provisions alone still binds its consumer b.provisions = a.Provisioning.Provisions } s.binds[key] = b diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/plan.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/plan.go index 34caced4..f697d7d1 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/plan.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/plan.go @@ -178,7 +178,14 @@ func planOf(node, key string, value []byte) (string, Plan, error) { return "", Plan{}, fmt.Errorf("the assignment %s names node %q and module %q, not its key's", key, a.Node, a.Module) } - if len(a.Provisioning) == 0 || string(a.Provisioning) == "null" { + if len(a.Provisioning) == 0 || string(a.Provisioning) == "null" { // no plan at all, not handing + return a.Module, Plan{}, errNoPlan + } + // **A plan that hands nothing is no plan of this client's** (mesh-sdk catalogue.Plan.Hands, issue 774): the seat keys + // a provision its provider's helper answers for a module not handed (grafana's route), and that plan names + // provisions alone. Read leniently for the rule; a plan that hands is read strictly below. + var rule catalogue.Plan + if err := json.Unmarshal(a.Provisioning, &rule); err == nil && !rule.Hands() { return a.Module, Plan{}, errNoPlan } var p Plan diff --git a/modules/mesh-provisioner-client/go.mod b/modules/mesh-provisioner-client/go.mod index be4b1a22..64bbcdee 100644 --- a/modules/mesh-provisioner-client/go.mod +++ b/modules/mesh-provisioner-client/go.mod @@ -3,7 +3,7 @@ module mesh-provisioner-client go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030150314-9379e9a96ad4 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/sys v0.48.0 diff --git a/modules/mesh-provisioner-client/go.sum b/modules/mesh-provisioner-client/go.sum index ed602ece..78414b54 100644 --- a/modules/mesh-provisioner-client/go.sum +++ b/modules/mesh-provisioner-client/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030150314-9379e9a96ad4 h1:CPUqC3FS/ezvBYdhVgVdsQg2DdN8xgtROQkSaHRKeOI= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030150314-9379e9a96ad4/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115 h1:ezKg8aAKQoZ5XEAWQQVk4y/x0h2qC73GcUKBmbqY5jw= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/modules/mesh-vault/cmd/mesh-vault/askers.go b/modules/mesh-vault/cmd/mesh-vault/askers.go index cad8a1de..81783a8d 100644 --- a/modules/mesh-vault/cmd/mesh-vault/askers.go +++ b/modules/mesh-vault/cmd/mesh-vault/askers.go @@ -206,7 +206,7 @@ func (d Declared) May(caller, name string) (bool, string) { if err := json.Unmarshal(raw, &a); err != nil || a.Node != node || a.Module != module { return false, fmt.Sprintf("the assignment %s.%s is not the controller's", node, module) } - if a.Provisioning == nil { + if !hands(raw) { return false, fmt.Sprintf("%s on %s is not handed to the provisioner, so its secrets reach it in its "+ "declaration (novox/hq ADR 0349 ยง17)", module, node) } diff --git a/modules/mesh-vault/cmd/mesh-vault/outcomes.go b/modules/mesh-vault/cmd/mesh-vault/outcomes.go index 8f4db79a..7b201386 100644 --- a/modules/mesh-vault/cmd/mesh-vault/outcomes.go +++ b/modules/mesh-vault/cmd/mesh-vault/outcomes.go @@ -116,7 +116,7 @@ func (v *Vault) outcomes() (map[string]BoundOutcome, error) { continue } var a plannedProvisions - if err := json.Unmarshal(raw, &a); err != nil || a.Provisioning == nil { + if err := json.Unmarshal(raw, &a); err != nil || a.Provisioning == nil { // binds, not handing continue } consumer := provisions.End{Module: a.Module, Node: a.Node} diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode.go b/modules/mesh-vault/cmd/mesh-vault/ownnode.go index ac97f4aa..d1af6800 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode.go @@ -15,6 +15,7 @@ import ( "strings" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/catalogue" "git.novox.be/novox/mesh-sdk/go/seal" ) @@ -348,23 +349,30 @@ func (p *planReader) handed(module string) (bool, error) { if raw == nil { return false, nil } - var a struct { - Provisioning *struct { - Templates struct { - Source string `json:"source"` - } `json:"templates"` - Files []json.RawMessage `json:"files"` - Secrets []json.RawMessage `json:"secrets"` - Items []json.RawMessage `json:"items"` - } `json:"provisioning"` - } - if err := json.Unmarshal(raw, &a); err != nil { + hands, err := handsOf(raw) + if err != nil { return false, fmt.Errorf("the assignment %s.%s is not the controller's: %w", node, module, err) } - // **A plan that names provisions alone hands nothing** (catalogue.Keyed, issue 774): the seat keys a provision its - // provider's helper answers for a module not handed (grafana's route), and that module's own secrets are still - // composed by its node-engine from this answer. Handed is a plan that names something the client fills. - hands := a.Provisioning - return hands != nil && (hands.Templates.Source != "" || len(hands.Files) > 0 || len(hands.Secrets) > 0 || - len(hands.Items) > 0), nil + return hands, nil +} + +// handsOf is whether an assignment's value carries a plan that hands its module to the provisioner: mesh-sdk's one +// rule, catalogue.Plan.Hands (issue 774). A plan naming provisions alone (a route the seat keys for a module not +// handed) hands nothing, and that module's files and own secrets stay its node's composition. Whether a plan exists +// is never the rule; a reader that asks only who binds whom reads the provisions and says so ("not handing", which +// the catalogue check checks/planhands holds every module to). +func handsOf(raw []byte) (bool, error) { + var a struct { + Provisioning *catalogue.Plan `json:"provisioning"` + } + if err := json.Unmarshal(raw, &a); err != nil { + return false, err + } + return a.Provisioning != nil && a.Provisioning.Hands(), nil +} + +// hands is handsOf for a value already read as the controller's: one that cannot be read hands nothing. +func hands(raw []byte) bool { + h, err := handsOf(raw) + return err == nil && h } diff --git a/modules/mesh-vault/cmd/mesh-vault/pair.go b/modules/mesh-vault/cmd/mesh-vault/pair.go index cc343e98..b155d487 100644 --- a/modules/mesh-vault/cmd/mesh-vault/pair.go +++ b/modules/mesh-vault/cmd/mesh-vault/pair.go @@ -242,10 +242,10 @@ func readBindings(m Mirror) (bindings, error) { return b, fmt.Errorf("the assignment %s is not the controller's", key) } b.assigned[key] = true - if a.Provisioning == nil { + if a.Provisioning == nil { // binds, not handing: a plan of provisions alone still binds its consumer continue } - b.handed[key] = true + b.handed[key] = hands(raw) for _, p := range a.Provisioning.Provisions { if !namePart.MatchString(p.Provider) || !namePart.MatchString(p.Provision) { continue @@ -713,7 +713,7 @@ func sharedRefusal(store *Store, read AssignmentReader, node, provider, provisio for _, m := range strings.Split(sh.ProviderModule, ",") { if raw, err := read(node + "." + m); err == nil && raw != nil { var a plannedProvisions - if json.Unmarshal(raw, &a) == nil && a.Node == node && a.Module == m && a.Provisioning != nil { + if json.Unmarshal(raw, &a) == nil && a.Node == node && a.Module == m && hands(raw) { return "" } } @@ -729,7 +729,7 @@ func sharedRefusal(store *Store, read AssignmentReader, node, provider, provisio continue } var a plannedProvisions - if json.Unmarshal(raw, &a) != nil || a.Node != node || a.Module != parts[1] || a.Provisioning == nil { + if json.Unmarshal(raw, &a) != nil || a.Node != node || a.Module != parts[1] || !hands(raw) { continue } for _, p := range a.Provisioning.Provisions { @@ -811,7 +811,7 @@ func pairRowRefusal(read AssignmentReader, node string, parts []string) string { if json.Unmarshal(raw, &a) != nil || a.Node != consumer || a.Module != module { return fmt.Sprintf("the assignment %s.%s is not the controller's", consumer, module) } - if a.Provisioning == nil { + if !hands(raw) { return fmt.Sprintf("%s on %s is not handed to the provisioner, so its login reaches it in its declaration "+ "(ADR 0349 clause 17)", module, consumer) } @@ -827,7 +827,7 @@ func pairRowRefusal(read AssignmentReader, node string, parts []string) string { if p.ProviderModule != "" { if praw, err := read(provider + "." + p.ProviderModule); err == nil && praw != nil { var pa plannedProvisions - if json.Unmarshal(praw, &pa) == nil && pa.Node == provider && pa.Provisioning != nil { + if json.Unmarshal(praw, &pa) == nil && pa.Node == provider && hands(praw) { return "" } } diff --git a/modules/mesh-vault/go.mod b/modules/mesh-vault/go.mod index 78371b50..b934e88b 100644 --- a/modules/mesh-vault/go.mod +++ b/modules/mesh-vault/go.mod @@ -3,7 +3,7 @@ module mesh-vault go 1.26.0 require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115 github.com/nats-io/nats-server/v2 v2.11.17 github.com/nats-io/nats.go v1.54.0 golang.org/x/crypto v0.57.0 diff --git a/modules/mesh-vault/go.sum b/modules/mesh-vault/go.sum index 61230834..78414b54 100644 --- a/modules/mesh-vault/go.sum +++ b/modules/mesh-vault/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24 h1:s0XXZtR7CFPBbnaDygWnE4/Q827iEFt0YfEzujk4Q9g= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030174137-a37181261c24/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115 h1:ezKg8aAKQoZ5XEAWQQVk4y/x0h2qC73GcUKBmbqY5jw= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030225009-17fc9b9b8115/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0= github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=