From 237a534ca4d3dbaa89a54ebc49ef1b8ff9d0fe94 Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 02:29:37 +0100 Subject: [PATCH] mesh-vault: read which modules of a machine the vault judges handed, and why, with own_handed (issue 1966) The vault leaves a handed module's own secrets out of `own`, judged by the plan in its assignment, and nothing read that judgement: on 2026-11-04 the circle on ace (no plan, so not handed, so refused, so no plan) could be told only by reading the provisioner's plans beside the node-engine's refusals. own_handed answers, per module the vault keeps an own secret of on the machine or that is placed there, handed or not and why. Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- modules/mesh-vault/cmd/mesh-vault/ownnode.go | 70 +++++++++++++++++++ .../mesh-vault/cmd/mesh-vault/ownnode_test.go | 23 ++++++ modules/mesh-vault/cmd/mesh-vault/tools.go | 17 +++++ modules/mesh-vault/module.json | 1 + 4 files changed, 111 insertions(+) diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode.go b/modules/mesh-vault/cmd/mesh-vault/ownnode.go index 84fec61e..aefc537e 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode.go @@ -10,6 +10,7 @@ package main import ( "errors" "fmt" + "maps" "slices" "sort" "strings" @@ -424,3 +425,72 @@ func hands(raw []byte) bool { h, err := handsOf(raw) return err == nil && h } + +// HandedJudged is how the vault judges one module of a machine for `own`: handed (its own secrets left out, for the +// provisioner's client to fill from `get`), and why, as the plan in its assignment says it; or why it could not judge. +type HandedJudged struct { + Module string `json:"module"` + Handed bool `json:"handed"` + Why string `json:"why"` +} + +// HandedOf is the vault's judgement of each module of node, as `own` would answer it now (issue 1966): every module it +// keeps an own secret of there and every module placed there, sorted. Read from the same plans `own` reads, so what it +// says is what the next answer leaves out. A node composes by its manifest, its build and the machine, and the plans it +// makes are what this reads next: a module named here as not handed for want of a plan is handed by its node's next +// composition, where the machine takes it. +func (v *Vault) HandedOf(node string) ([]HandedJudged, error) { + if !namePart.MatchString(node) { + return nil, fmt.Errorf("%q is not a machine's name", node) + } + all, err := v.Store.ListPrefix("own/" + node + "/") + if err != nil { + return nil, err + } + modules := map[string]bool{} + for _, r := range all { + if parts := strings.Split(r.Name, "/"); len(parts) == 4 { + modules[parts[2]] = true + } + } + plan := v.plansOf(node) + placed, placedErr := plan.placedModules() + for _, m := range placed { + modules[m] = true + } + out := make([]HandedJudged, 0, len(modules)) + for _, module := range slices.Sorted(maps.Keys(modules)) { + out = append(out, plan.judged(module)) + } + if placedErr != nil && len(out) == 0 { + return nil, fmt.Errorf("the placement of %s could not be read and the vault keeps no own secret there: %w", node, placedErr) + } + return out, nil +} + +// judged is handed with its why, as HandedOf says it. +func (p *planReader) judged(module string) HandedJudged { + j := HandedJudged{Module: module} + raw, why, err := p.read(module) + switch { + case why != "": + j.Why = "not judged: " + why + return j + case err != nil: + j.Why = fmt.Sprintf("not judged: the assignment %s.%s could not be read: %v", p.node, module, err) + return j + case raw == nil: + j.Why = "no assignment holds a plan of it yet: its own secrets are answered to its node" + return j + } + hands, err := handsOf(raw) + switch { + case err != nil: + j.Why = fmt.Sprintf("not judged: the assignment %s.%s is not the controller's: %v", p.node, module, err) + case hands: + j.Handed, j.Why = true, "its plan hands it: its own secrets are its provisioner client's, from get" + default: + j.Why = "its plan hands nothing (none, or provisions alone): its own secrets are answered to its node" + } + return j +} diff --git a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go index 461d7203..5362dd7e 100644 --- a/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go +++ b/modules/mesh-vault/cmd/mesh-vault/ownnode_test.go @@ -291,4 +291,27 @@ func TestOwnLeavesOutOnlyAModuleWhosePlanHandsIt(t *testing.T) { if want := map[string]bool{"grafana": false, "web": true, "db": true}; !reflect.DeepEqual(got.Handed, want) { t.Errorf("own judged %v handed, want %v", got.Handed, want) } + // Issue 1966: the vault's judgement is read by a tool, each module with why; one with no plan yet is not handed, for + // want of a plan its node's next composition makes. + if _, err := moveIn(t, v, "node.anchor", MoveOf{Node: "anchor", Module: "builder", Name: "token", Origin: "made", + Generation: "g-1", SealingKey: pub}, "v-builder"); err != nil { + t.Fatal(err) + } + answer, err := toolNamed(t, definitions(&Env{Vault: v.Vault}), "own_handed").Run(map[string]any{"node": "anchor"}) + if err != nil { + t.Fatal(err) + } + judged := map[string]HandedJudged{} + for _, j := range answer.(map[string]any)["modules"].([]HandedJudged) { + judged[j.Module] = j + } + for module, want := range map[string][2]string{"builder": {"false", "no assignment holds a plan"}, + "grafana": {"false", "hands nothing"}, "web": {"true", "its plan hands it"}, "db": {"true", "its plan hands it"}} { + if j := judged[module]; fmt.Sprint(j.Handed) != want[0] || !strings.Contains(j.Why, want[1]) { + t.Errorf("own_handed says %s is %+v, want handed %s, why saying %q", module, j, want[0], want[1]) + } + } + if len(judged) != 4 { + t.Errorf("own_handed names %v, want builder, db, grafana and web", judged) + } } diff --git a/modules/mesh-vault/cmd/mesh-vault/tools.go b/modules/mesh-vault/cmd/mesh-vault/tools.go index f709a59f..f15e428b 100644 --- a/modules/mesh-vault/cmd/mesh-vault/tools.go +++ b/modules/mesh-vault/cmd/mesh-vault/tools.go @@ -399,6 +399,23 @@ func definitions(env *Env) []toolDef { given, _ := a["fingerprint"].(string) return verifyAnswer(env, as, given), nil }}, + {Name: "own_handed", + Description: "Which modules of a machine the vault judges handed to the provisioner, as its verb `own` answers that " + + "machine's node-engine now (issue 1966): for every module it keeps an own secret of there and every module " + + "placed there, handed or not and why (its plan hands it, hands nothing, no plan yet, or why it could not " + + "judge). A handed module's own secrets are left out of `own`, for its client to fill from `get`. Never a value.", + Input: map[string]any{"node": str("the machine")}, + run: func(_ string, a map[string]any) (any, error) { + v, err := env.ready() + if err != nil { + return nil, err + } + judged, err := v.HandedOf(arg(a, "node")) + if err != nil { + return nil, err + } + return map[string]any{"node": arg(a, "node"), "modules": judged}, nil + }}, {Name: "secret_handed", Description: "Who was handed which secret by `get`, and when, and who was refused, newest first, as the " + "vault's own store records it. Never a value.", diff --git a/modules/mesh-vault/module.json b/modules/mesh-vault/module.json index 72758138..b872c37c 100644 --- a/modules/mesh-vault/module.json +++ b/modules/mesh-vault/module.json @@ -173,6 +173,7 @@ "secret_holders", "secret_verify", "secret_handed", + "own_handed", "secret_own", "account_hash", "account_adopt",