Merge pull request 'One rule for whether a plan hands its module, so no part reads a provisions-only plan as handed (issue 774)' (#574) from fix/plan-hands into main

This commit was merged in pull request #574.
This commit is contained in:
2026-10-30 23:18:59 +00:00
15 changed files with 154 additions and 33 deletions
+3
View File
@@ -0,0 +1,3 @@
module checks/planhands
go 1.26.0
+48
View File
@@ -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
}
+45
View File
@@ -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"))
}
}
+3
View File
@@ -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
@@ -870,4 +870,11 @@ func TestAPlanOfProvisionsAloneFetchesNoTemplates(t *testing.T) {
if targets, wrong := fillTargets(Plan{}, texts); wrong != "" || len(targets) != 0 { if targets, wrong := fillTargets(Plan{}, texts); wrong != "" || len(targets) != 0 {
t.Fatalf("filled %v: %s", targets, wrong) 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)
}
} }
@@ -77,7 +77,7 @@ func takeBinds(s *state, key string, value []byte, gone bool) bool {
return was != nil return was != nil
} }
b := binding{node: a.Node, module: a.Module} 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 b.provisions = a.Provisioning.Provisions
} }
s.binds[key] = b s.binds[key] = b
@@ -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, return "", Plan{}, fmt.Errorf("the assignment %s names node %q and module %q, not its key's", key, a.Node,
a.Module) 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 return a.Module, Plan{}, errNoPlan
} }
var p Plan var p Plan
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-provisioner-client
go 1.26.0 go 1.26.0
require ( 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-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
golang.org/x/sys v0.48.0 golang.org/x/sys v0.48.0
+2 -2
View File
@@ -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.20261030225009-17fc9b9b8115 h1:ezKg8aAKQoZ5XEAWQQVk4y/x0h2qC73GcUKBmbqY5jw=
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/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= 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= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
+1 -1
View File
@@ -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 { 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) 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 "+ 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) "declaration (novox/hq ADR 0349 §17)", module, node)
} }
@@ -116,7 +116,7 @@ func (v *Vault) outcomes() (map[string]BoundOutcome, error) {
continue continue
} }
var a plannedProvisions 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 continue
} }
consumer := provisions.End{Module: a.Module, Node: a.Node} consumer := provisions.End{Module: a.Module, Node: a.Node}
+25 -17
View File
@@ -15,6 +15,7 @@ import (
"strings" "strings"
stdio "git.novox.be/novox/mesh-sdk/go" stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/catalogue"
"git.novox.be/novox/mesh-sdk/go/seal" "git.novox.be/novox/mesh-sdk/go/seal"
) )
@@ -348,23 +349,30 @@ func (p *planReader) handed(module string) (bool, error) {
if raw == nil { if raw == nil {
return false, nil return false, nil
} }
var a struct { hands, err := handsOf(raw)
Provisioning *struct { if err != nil {
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 {
return false, fmt.Errorf("the assignment %s.%s is not the controller's: %w", node, module, err) 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 return hands, nil
// 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 // handsOf is whether an assignment's value carries a plan that hands its module to the provisioner: mesh-sdk's one
return hands != nil && (hands.Templates.Source != "" || len(hands.Files) > 0 || len(hands.Secrets) > 0 || // rule, catalogue.Plan.Hands (issue 774). A plan naming provisions alone (a route the seat keys for a module not
len(hands.Items) > 0), nil // 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
} }
+6 -6
View File
@@ -242,10 +242,10 @@ func readBindings(m Mirror) (bindings, error) {
return b, fmt.Errorf("the assignment %s is not the controller's", key) return b, fmt.Errorf("the assignment %s is not the controller's", key)
} }
b.assigned[key] = true 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 continue
} }
b.handed[key] = true b.handed[key] = hands(raw)
for _, p := range a.Provisioning.Provisions { for _, p := range a.Provisioning.Provisions {
if !namePart.MatchString(p.Provider) || !namePart.MatchString(p.Provision) { if !namePart.MatchString(p.Provider) || !namePart.MatchString(p.Provision) {
continue continue
@@ -713,7 +713,7 @@ func sharedRefusal(store *Store, read AssignmentReader, node, provider, provisio
for _, m := range strings.Split(sh.ProviderModule, ",") { for _, m := range strings.Split(sh.ProviderModule, ",") {
if raw, err := read(node + "." + m); err == nil && raw != nil { if raw, err := read(node + "." + m); err == nil && raw != nil {
var a plannedProvisions 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 "" return ""
} }
} }
@@ -729,7 +729,7 @@ func sharedRefusal(store *Store, read AssignmentReader, node, provider, provisio
continue continue
} }
var a plannedProvisions 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 continue
} }
for _, p := range a.Provisioning.Provisions { 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 { 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) 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 "+ 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) "(ADR 0349 clause 17)", module, consumer)
} }
@@ -827,7 +827,7 @@ func pairRowRefusal(read AssignmentReader, node string, parts []string) string {
if p.ProviderModule != "" { if p.ProviderModule != "" {
if praw, err := read(provider + "." + p.ProviderModule); err == nil && praw != nil { if praw, err := read(provider + "." + p.ProviderModule); err == nil && praw != nil {
var pa plannedProvisions 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 "" return ""
} }
} }
+1 -1
View File
@@ -3,7 +3,7 @@ module mesh-vault
go 1.26.0 go 1.26.0
require ( 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-server/v2 v2.11.17
github.com/nats-io/nats.go v1.54.0 github.com/nats-io/nats.go v1.54.0
golang.org/x/crypto v0.57.0 golang.org/x/crypto v0.57.0
+2 -2
View File
@@ -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.20261030225009-17fc9b9b8115 h1:ezKg8aAKQoZ5XEAWQQVk4y/x0h2qC73GcUKBmbqY5jw=
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/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 h1:Z/MZK75wC/NSrkgqeNIa7jexam9uWzhLmFTSCPI/kn0=
github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= 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= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=