From b8ce4f0c0ac587d270de04cf4a18def22e5f96f0 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 30 Oct 2026 21:26:31 +0100 Subject: [PATCH] Read a provider's outcome per offer, and read route-proxy's and node-tools' entries, so provisions a helper does not answer stop failing and routed keys stop standing asked (issue 774) The sweep of 2026-10-30 20:10: .claude-code.gitea-read and .mesh-build-node.npm-package-registry failed on all four nodes with "gitea does not serve": gitea's provider helper answers only forge-account and refuses every other bind into gitea.bound, and the provisioner took any entry of a module declaring `bound` as the outcome. And novox.gitea.route, novox.keycloak.route and .claude-code.mcp-endpoint stood asked: route-proxy and node-tools keep entries the manifest did not read. Quick fix: add route-proxy.bound and node-tools.bound to the reads. Thorough fix: judge each offer by who answers it (answered-by: helper reads the entries, any other offer of a module that names a helper is judged by its have), and say on gitea's forge-account that the helper answers it. Chose both: the reads alone leave gitea's two provisions failed. --- modules/gitea/module.json | 3 +- .../cmd/mesh-provisioner/holder.go | 103 ++++++++++++------ .../cmd/mesh-provisioner/holder_test.go | 60 ++++++++++ modules/mesh-provisioner/module.json | 2 + 4 files changed, 135 insertions(+), 33 deletions(-) diff --git a/modules/gitea/module.json b/modules/gitea/module.json index 2b197288..7d703aff 100644 --- a/modules/gitea/module.json +++ b/modules/gitea/module.json @@ -423,7 +423,8 @@ { "name": "forge-account", "credential": {"form": "none"}, - "scope": "mesh" + "scope": "mesh", + "answered-by": "helper" } ], "build": { diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go index 96e4bc13..f1c5c014 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder.go @@ -66,8 +66,10 @@ const ( // BoundReads are the providers' entries this module's manifest reads, `.bound`, whose changes it hears. Which // path judges a provider is its manifest's word (keepsEntries), not this list: a provider that declares `bound` and is -// not read here leaves its keys asked, saying its entries are not read. -var BoundReads = []string{"gitea", "keycloak", "postgres", "route-adapter", "mesh-vault"} +// not read here leaves its keys asked, saying its entries are not read. route-proxy (mesh-control) and node-tools +// (mesh-tools) answer route and mcp-endpoint by the provider helper from outside this catalogue (issue 774, sweep of +// 2026-10-30 20:10: their keys stood asked on every node). +var BoundReads = []string{"gitea", "keycloak", "postgres", "route-adapter", "route-proxy", "node-tools", "mesh-vault"} // boundRead is a provider's entries as this module reads them. func boundRead(module string) string { return module + ".bound" } @@ -546,7 +548,7 @@ type entry struct { // asked, said so. func (h *Holder) outcome(key string, p *provisions.Provision) { p.Ask, p.Warrant = "", nil - keeps, err := h.keepsEntries(p.Provider.Module) + keeps, err := h.keepsEntries(p.Provider.Module, p.Provision) if err != nil { p.State, p.Why = provisions.Asked, "the manifest of "+p.Provider.Module+" cannot be read: "+err.Error() return @@ -644,39 +646,30 @@ func (h *Holder) haveOutcome(p *provisions.Provision) { } } -// keepsEntries says a provider's manifest at the trunk declares the state `bound`: its outcome per consumer is its -// entries there; any other provider's is its own have. -func (h *Holder) keepsEntries(module string) (bool, error) { - mk, err := delivery.ModulesKey(module) - if err != nil { - return false, err - } - e, err := h.Store.Get(ModulesRead, mk) - if err != nil || e == nil { - return false, fmt.Errorf("the indexer holds no modules.%s (%v)", module, err) - } - m, err := delivery.DecodeModule(e.Value) - if err != nil || m.Manifest == "" { - return false, fmt.Errorf("the indexer's modules.%s names no manifest", module) - } - key, err := delivery.ManifestsKey(m.Manifest) - if err != nil { - return false, err - } - me, err := h.Store.Get(ManifestsRead, key) - if err != nil || me == nil { - return false, fmt.Errorf("the indexer holds no manifest of %s (%v)", module, err) - } - man, err := delivery.DecodeManifest(me.Value) +// keepsEntries says whether a provider's outcome for one provision is its entries (its manifest at the trunk declares the +// state `bound`) rather than its own have. When any offer of the provider says `answered-by: helper`, only those offers +// are answered by its entries: a module running the helper for one provision and making another some other way (gitea: +// the helper answers forge-account, the vault issues gitea-read, the package harness makes npm-package-registry) is +// judged per offer, so the helper's "does not serve" for an offer it does not run never becomes the provision's outcome +// (issue 774, sweep of 2026-10-30 20:10). A provider whose offers say nothing keeps every outcome in its entries. +func (h *Holder) keepsEntries(module, provision string) (bool, error) { + man, err := h.providerManifest(module) if err != nil { return false, err } + return keepsEntriesFor(man, provision) +} + +// keepsEntriesFor is keepsEntries over a provider's manifest JSON. +func keepsEntriesFor(manifest, provision string) (bool, error) { var decl struct { - State []json.RawMessage `json:"state"` + State []json.RawMessage `json:"state"` + Provides []json.RawMessage `json:"provides"` } - if err := json.Unmarshal([]byte(man.JSON), &decl); err != nil { + if err := json.Unmarshal([]byte(manifest), &decl); err != nil { return false, err } + bound := false for _, raw := range decl.State { var name string if json.Unmarshal(raw, &name) != nil { @@ -687,10 +680,56 @@ func (h *Holder) keepsEntries(module string) (bool, error) { name = full.Name } if name == "bound" { - return true, nil + bound = true } } - return false, nil + if !bound { + return false, nil + } + helped, says := false, false + for _, raw := range decl.Provides { + var full struct { + Name string `json:"name"` + AnsweredBy string `json:"answered-by"` + } + if json.Unmarshal(raw, &full) != nil || full.AnsweredBy != catalogue.AnsweredByHelper { + continue + } + says = true + if full.Name == provision { + helped = true + } + } + return helped || !says, nil +} + +// providerManifest is a provider's manifest JSON at the trunk, as the indexer holds it. +func (h *Holder) providerManifest(module string) (string, error) { + mk, err := delivery.ModulesKey(module) + if err != nil { + return "", err + } + e, err := h.Store.Get(ModulesRead, mk) + if err != nil || e == nil { + return "", fmt.Errorf("the indexer holds no modules.%s (%v)", module, err) + } + m, err := delivery.DecodeModule(e.Value) + if err != nil || m.Manifest == "" { + return "", fmt.Errorf("the indexer's modules.%s names no manifest", module) + } + key, err := delivery.ManifestsKey(m.Manifest) + if err != nil { + return "", err + } + me, err := h.Store.Get(ManifestsRead, key) + if err != nil || me == nil { + return "", fmt.Errorf("the indexer holds no manifest of %s (%v)", module, err) + } + man, err := delivery.DecodeManifest(me.Value) + if err != nil { + return "", err + } + return man.JSON, nil } // retired says a retiring key the operator approved is retired: its provider's entry is gone. @@ -698,7 +737,7 @@ func (h *Holder) retired(key string, p provisions.Provision) (bool, string) { if p.Warrant == nil || p.Warrant.Option != OptionApprove { return false, "" } - if keeps, err := h.keepsEntries(p.Provider.Module); err != nil || !keeps { + if keeps, err := h.keepsEntries(p.Provider.Module, p.Provision); err != nil || !keeps { return false, "" } e, err := h.Store.Get(boundRead(p.Provider.Module), key) diff --git a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go index c26fd22e..6cba2638 100644 --- a/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go +++ b/modules/mesh-provisioner/cmd/mesh-provisioner/holder_test.go @@ -412,3 +412,63 @@ func TestTheHolderHoldsNoTimer(t *testing.T) { }) } } + +// manifestOf puts a provider's manifest into the indexer's reads as the holder finds it. +func (m *mesh) manifestOf(module, raw string) { + digest := delivery.ManifestDigest([]byte(raw)) + value, _ := delivery.EncodeManifest(delivery.Manifest{Module: module, JSON: raw}) + _ = m.s.Put(ManifestsRead, mustKey(delivery.ManifestsKey(digest)), json.RawMessage(value)) + _ = m.s.Put(ModulesRead, mustKey(delivery.ModulesKey(module)), + json.RawMessage(`{"v":1,"none2":"a test","manifest":"`+digest+`","repository":"novox/mesh-catalog","commit":"c1"}`)) +} + +// A provider's outcome is read per offer: from its entries where the provider helper answers that offer, from its have +// where it does not, and the entries of route-proxy and node-tools are read (issue 774, the sweep of 2026-10-30 20:10: +// .claude-code.gitea-read failed "gitea does not serve" on all four nodes while gitea's helper answers only +// forge-account, and novox.gitea.route and .claude-code.mcp-endpoint stood asked, their providers' entries unread). +func TestAProvidersOutcomeIsReadPerOffer(t *testing.T) { + const ( + gitea = `{"module":"gitea","version":"1","state":["bound","announced"],"provides":[{"name":"npm-package-registry","credential":{"form":"pair"}},{"name":"gitea-read","credential":{"issued-by":"software","tool":"gitea_agent_token"}},{"name":"forge-account","credential":{"form":"none"},"answered-by":"helper"}]}` + routes = `{"module":"route-proxy","version":"1","state":["bound"],"provides":[{"name":"route","scope":"mesh","identity":false,"answered-by":"helper"}]}` + nodeTools = `{"module":"node-tools","version":"1","state":["bound"],"provides":[{"name":"mcp-endpoint","scope":"node","answered-by":"helper"}]}` + running = `{"v":1,"fp":"src2:` + "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd" + `","phase":"running","health":"ok"}` + refused = `{"state":"failed","refused":"claude-code on ace is bound to gitea on novox for gitea-read, which gitea does not serve"}` + ) + for _, row := range []struct { + name string + node string + plan map[string]catalogue.Plan + key string + bucket string // the provider's entries, written as entry + entry string + have string // the provider's have key, written running + state string + }{ + {"gitea-read: the helper's refusal is not its outcome; gitea runs, so provided", "ace", + map[string]catalogue.Plan{"claude-code": planOf(catalogue.PlanProvision{Provision: "gitea-read", Provider: "novox", ProviderModule: "gitea"})}, + "ace.claude-code.gitea-read", "gitea.bound", refused, "novox.gitea", provisions.Provided}, + {"forge-account: still the helper's entry", "ace", + map[string]catalogue.Plan{"mesh-build-go": planOf(catalogue.PlanProvision{Provision: "forge-account", Provider: "novox", ProviderModule: "gitea"})}, + "ace.mesh-build-go.forge-account", "gitea.bound", `{"state":"provided"}`, "", provisions.Provided}, + {"route: route-proxy's entry is read", "novox", + map[string]catalogue.Plan{"gitea": planOf(catalogue.PlanProvision{Provision: "route", Provider: "novox", ProviderModule: "route-proxy"})}, + "novox.gitea.route", "route-proxy.bound", `{"state":"provided"}`, "", provisions.Provided}, + {"mcp-endpoint: node-tools' entry is read", "ace", + map[string]catalogue.Plan{"claude-code": planOf(catalogue.PlanProvision{Provision: "mcp-endpoint", Provider: "ace", ProviderModule: "node-tools"})}, + "ace.claude-code.mcp-endpoint", "node-tools.bound", `{"state":"provided"}`, "", provisions.Provided}, + } { + m := newMesh() + m.manifestOf("gitea", gitea) + m.manifestOf("route-proxy", routes) + m.manifestOf("node-tools", nodeTools) + m.atWant[row.node], m.atNewest[row.node] = row.plan, row.plan + m.s.write(t, row.bucket, row.key, row.entry) + if row.have != "" { + m.s.write(t, HaveRead, row.have, running) + } + m.compose(t, row.node) + if p := m.state(t, row.key); p.State != row.state { + t.Errorf("%s: %s is %s (%s)", row.name, row.key, p.State, p.Why) + } + } +} diff --git a/modules/mesh-provisioner/module.json b/modules/mesh-provisioner/module.json index b417b373..b7e6d8a5 100644 --- a/modules/mesh-provisioner/module.json +++ b/modules/mesh-provisioner/module.json @@ -26,6 +26,8 @@ "keycloak.bound", "postgres.bound", "route-adapter.bound", + "route-proxy.bound", + "node-tools.bound", "mesh-vault.bound" ], "tools": [