From 60a3823f0eee65920f9f117ec2db39dfba7fb0ba Mon Sep 17 00:00:00 2001 From: jochen Date: Sun, 18 Oct 2026 02:37:28 +0200 Subject: [PATCH] mesh-vault reads an ended assignment as a removed key (issue 1098) --- modules/mesh-vault/cmd/mesh-vault/askers.go | 31 ++++++++++++- .../mesh-vault/cmd/mesh-vault/ended_test.go | 43 +++++++++++++++++++ modules/mesh-vault/cmd/mesh-vault/pair.go | 3 ++ 3 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 modules/mesh-vault/cmd/mesh-vault/ended_test.go diff --git a/modules/mesh-vault/cmd/mesh-vault/askers.go b/modules/mesh-vault/cmd/mesh-vault/askers.go index 037bce99..e8eab5ab 100644 --- a/modules/mesh-vault/cmd/mesh-vault/askers.go +++ b/modules/mesh-vault/cmd/mesh-vault/askers.go @@ -75,6 +75,35 @@ const AssignmentsRead = "mesh-controller.assignments" // AssignmentReader reads one value of the assignments mirror, `.`: nil when there is none. type AssignmentReader func(key string) ([]byte, error) +// standing wraps an assignment reader so a value that is not a standing assignment reads as a removed key: one with +// `ended` set (the module was taken off its node, novox/hq ADR 0408 clause 4), or with a `state` (a pending one). The +// other fields of a value, new or unknown, change nothing (issue 1098). +func standing(read AssignmentReader) AssignmentReader { + if read == nil { + return nil + } + return func(key string) ([]byte, error) { + raw, err := read(key) + if err != nil || raw == nil { + return raw, err + } + var v struct { + Ended json.RawMessage `json:"ended"` + State json.RawMessage `json:"state"` + } + if json.Unmarshal(raw, &v) == nil && (fieldSet(v.Ended) || fieldSet(v.State)) { + return nil, nil + } + return raw, nil + } +} + +// fieldSet is whether a field of an assignment's value carries something: not absent, null or empty. +func fieldSet(f json.RawMessage) bool { + s := strings.TrimSpace(string(f)) + return s != "" && s != "null" && s != `""` +} + // Declared is the rule the running vault keeps: a node's provisioner client, for a secret of a module on its node // that the module's plan there names. type Declared struct { @@ -141,7 +170,7 @@ func (d Declared) May(caller, name string) (bool, string) { if d.Read == nil { return false, "the vault cannot read the assignments, so it cannot tell what " + node + "'s declaration names" } - raw, err := d.Read(node + "." + module) + raw, err := standing(d.Read)(node + "." + module) if err != nil { return false, fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read, so what it names is not known: %v", node, module, err) diff --git a/modules/mesh-vault/cmd/mesh-vault/ended_test.go b/modules/mesh-vault/cmd/mesh-vault/ended_test.go new file mode 100644 index 00000000..e82d5e5a --- /dev/null +++ b/modules/mesh-vault/cmd/mesh-vault/ended_test.go @@ -0,0 +1,43 @@ +package main + +import "testing" + +// Issue 1098: a value of the assignments bucket that is not a standing assignment (one with `ended` set, or with a +// `state`) reads as a removed key, whichever way the vault reads it; the fields the controller adds change nothing. +func TestAnEndedAssignmentIsNotAnAssignmentToTheVault(t *testing.T) { + plan := `"provisioning":{"templates":{"source":"x","digest":"y"},"files":[],"secrets":[{"name":"token"}],` + + `"provisions":[{"provision":"gitea-read","provider":"novox","provider-module":"gitea"},` + + `{"name":"gitea-read","provision":"gitea-read","provider":"novox","provider-module":"gitea"}]}` + head := `{"node":"g14","module":"dunst","assigned":"2026-10-14T20:00:00Z",` + mirror := assignments{ + "g14.dunst": head + plan + `}`, + "g14.ended": `{"node":"g14","module":"ended","ended":"2026-10-18T01:00:00Z",` + plan + `}`, + "g14.wait": `{"node":"g14","module":"wait","state":"pending",` + plan + `}`, + "g14.new": `{"node":"g14","module":"new","build":"b1","artifact":{"name":"x","version":"1"},"commit":"c1",` + + `"rollout":"r1","since":"2026-10-18T00:00:00Z","unknown-field":[1],"ended":"","state":"",` + plan + `}`, + } + rule := Declared{Read: mirror.read} + for module, want := range map[string]bool{"dunst": true, "new": true, "ended": false, "wait": false} { + if ok, why := rule.May("provisioner.g14", "own/g14/"+module+"/token"); ok != want { + t.Errorf("own secret of %s: allowed %v (%s), want %v", module, ok, why, want) + } + if ok, why := rule.May("provisioner.g14", "provision/novox/gitea-read/g14/"+module); ok != want { + t.Errorf("credential of %s: allowed %v (%s), want %v", module, ok, why, want) + } + if why := pairRowRefusal(mirror.read, "g14", []string{"pair", "novox", "gitea-read", "g14", module}); (why == "") != want { + t.Errorf("pair row of %s: refusal %q, want allowed %v", module, why, want) + } + } + seen, err := readBindings(Mirror{Keys: mirror.keys, Read: mirror.read}) + if err != nil { + t.Fatal(err) + } + for module, want := range map[string]bool{"dunst": true, "new": true, "ended": false, "wait": false} { + if seen.assigned["g14."+module] != want || seen.handed["g14."+module] != want { + t.Errorf("binding of %s: assigned %v, want %v", module, seen.assigned["g14."+module], want) + } + } + if got := seen.bound[pairKey{"novox", "gitea-read"}]; got == nil || len(got.holders) != 2 { + t.Errorf("the bound holders of the provision: %+v", got) + } +} diff --git a/modules/mesh-vault/cmd/mesh-vault/pair.go b/modules/mesh-vault/cmd/mesh-vault/pair.go index 40221732..933fd895 100644 --- a/modules/mesh-vault/cmd/mesh-vault/pair.go +++ b/modules/mesh-vault/cmd/mesh-vault/pair.go @@ -223,6 +223,7 @@ func readBindings(m Mirror) (bindings, error) { if m.Keys == nil || m.Read == nil { return b, errors.New("the vault cannot read the assignments, so it cannot see who binds a provider") } + m.Read = standing(m.Read) keys, err := m.Keys() if err != nil { return b, fmt.Errorf("the assignments' keys could not be read: %w", err) @@ -704,6 +705,7 @@ func sharedRefusal(store *Store, read AssignmentReader, node, provider, provisio if read == nil { return "the vault cannot read the assignments, so it cannot tell what " + node + "'s plans name" } + read = standing(read) if node == provider && sh.ProviderModule != "" { for _, m := range strings.Split(sh.ProviderModule, ",") { if raw, err := read(node + "." + m); err == nil && raw != nil { @@ -787,6 +789,7 @@ func pairRowRefusal(read AssignmentReader, node string, parts []string) string { if read == nil { return "the vault cannot read the assignments, so it cannot tell what " + node + "'s plans name" } + read = standing(read) raw, err := read(consumer + "." + module) if err != nil { return fmt.Sprintf(notYetPrefix+"the assignment %s.%s could not be read: %v", consumer, module, err)