From 8b1ab467a9d91fb95b6e1d4148c3e82254654cbe Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 4 Nov 2026 11:40:03 +0100 Subject: [PATCH] TLS-1, TLS-2, TLS-3: the runner declares its own secret ledgers, refuses a bundle tool named as a ledger repair, and states the capabilities of its calls in its PING (issue 1966) Claude-Session: https://claude.ai/code/session_01JG73KJNrH5EgFXMKKYSrig --- node-tools/internal/announce/announce.go | 5 +++ .../internal/console/manifest_waits_test.go | 19 ++++++++ node-tools/internal/runtime/announce_test.go | 31 +++++++++++-- node-tools/internal/runtime/busstate.go | 28 ++++++++++++ node-tools/internal/runtime/ledgers.go | 15 +++++++ node-tools/internal/runtime/ledgers_test.go | 43 +++++++++++++++++++ node-tools/internal/runtime/runtime.go | 15 ++++++- node-tools/module.json | 3 +- 8 files changed, 153 insertions(+), 6 deletions(-) diff --git a/node-tools/internal/announce/announce.go b/node-tools/internal/announce/announce.go index 6c941ea..8771120 100644 --- a/node-tools/internal/announce/announce.go +++ b/node-tools/internal/announce/announce.go @@ -43,6 +43,11 @@ var Patience = 5 * time.Second // to the laptop, and the hundred-byte PING answers queued behind it. const Digest = "digest" +// Capabilities is the service metadata key a runtime states what it can do under, a comma-separated list of capabilities +// (runtime.CallCapabilities): its machine's node-engine asks the runtime's PING and takes them into the node's record, so a +// module needing one is placed only where the runtime states it. +const Capabilities = "capabilities" + // Shortened is the service metadata key a runtime sets when its announcement was cut to fit the bus. const Shortened = "shortened" diff --git a/node-tools/internal/console/manifest_waits_test.go b/node-tools/internal/console/manifest_waits_test.go index 3d9386d..4d6d2e4 100644 --- a/node-tools/internal/console/manifest_waits_test.go +++ b/node-tools/internal/console/manifest_waits_test.go @@ -39,3 +39,22 @@ func TestTheManifestNamesTheFirstFamiliesOneByOne(t *testing.T) { t.Errorf("the tool runner consumes %v: a wait is no consumer (ADR 0367 §2)", m.Consumes) } } + +// issue 1966 TLS-1: the tool runner declares its own secret `ledgers`, each module's ledger credential, in the directory it +// keeps its broker credential in, which is where the runner reads it (runtime.LedgersFileOf). +func TestTheManifestDeclaresTheRunnersOwnSecretLedgers(t *testing.T) { + raw, err := os.ReadFile("../../module.json") + if err != nil { + t.Fatal(err) + } + var m struct { + OwnSecrets map[string]string `json:"own-secrets"` + } + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatal(err) + } + want := map[string]string{"broker": "${dir:mesh-state}/broker", "ledgers": "${dir:mesh-state}/ledgers"} + if !reflect.DeepEqual(m.OwnSecrets, want) { + t.Errorf("own secrets %v, want %v", m.OwnSecrets, want) + } +} diff --git a/node-tools/internal/runtime/announce_test.go b/node-tools/internal/runtime/announce_test.go index ab49ada..c263b7e 100644 --- a/node-tools/internal/runtime/announce_test.go +++ b/node-tools/internal/runtime/announce_test.go @@ -10,6 +10,7 @@ import ( "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/micro" + "github.com/novox/mesh-tools/node-tools/internal/announce" "github.com/novox/mesh-tools/node-tools/internal/bus" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) @@ -100,10 +101,6 @@ func TestTheRuntimeAnnouncesWhatItServesInTheServicesProtocol(t *testing.T) { if msg, err := nc.Request("$SRV.PING", nil, 2*time.Second); err != nil || !strings.Contains(string(msg.Data), micro.PingResponseType) { t.Errorf("ping: %v %v", msg, err) } - if _, err := nc.Request("$SRV.INFO.somebody-else", nil, 300*time.Millisecond); err == nil { - t.Error("answered a request for another service") - } - // alpha is re-issued a plain subject: the announcement says so, and says it is interchangeable. mesh.Issue(t, mt.MembershipOf("alpha", "anchor", true, nil)) var after string @@ -120,3 +117,29 @@ func TestTheRuntimeAnnouncesWhatItServesInTheServicesProtocol(t *testing.T) { } _ = bus.Served{} } + +// issue 1966 TLS-3 (ENG-2): the runner states the capabilities of the calls it answers in its PING, on its own instance, which +// is what its machine's node-engine asks and takes into the node's record; no bundle is needed for it. +func TestTheRunnerStatesTheCapabilitiesOfItsCallsInItsPing(t *testing.T) { + mesh := mt.New(t) + mesh.Issue(t, mt.MembershipOf("alpha", "anchor", false, nil)) + nodeTools := connect(t, "node-tools", "anchor") + stop, err := Run(nodeTools, nil, nil, (&mt.Logs{}).Logf) + if err != nil { + t.Fatal(err) + } + defer stop() + nc, err := nats.Connect(mt.URL(t)) + if err != nil { + t.Fatal(err) + } + defer nc.Close() + msg, err := nc.Request("$SRV.PING.node-tools.anchor", nil, 2*time.Second) + if err != nil { + t.Fatalf("ping of the instance: %v", err) + } + var ping micro.Ping + if err := json.Unmarshal(msg.Data, &ping); err != nil || ping.Metadata[announce.Capabilities] != "bus-state-calls,ledger-calls" { + t.Errorf("the runner states %q (%v), want its bus-state and ledger calls", ping.Metadata[announce.Capabilities], err) + } +} diff --git a/node-tools/internal/runtime/busstate.go b/node-tools/internal/runtime/busstate.go index bd72a15..3d293d4 100644 --- a/node-tools/internal/runtime/busstate.go +++ b/node-tools/internal/runtime/busstate.go @@ -3,10 +3,38 @@ package runtime import ( "encoding/json" "fmt" + "sort" "git.novox.be/novox/mesh-sdk/go/buscalls" ) +// The capabilities the tool runner states of the calls it answers (novox/hq ADR 0595 §9, ADR 0597; design 25 § 12.4): each is +// a family of the methods a bundle asks it, named so a manifest's `capabilities` can ask for it, and stated in its announcement +// (announce.Capabilities) for its machine's node-engine to take into the node's record. A module whose manifest asks for one is +// held off a machine whose runner does not state it. +const ( + CapBusStateCalls = "bus-state-calls" + CapLedgerCalls = "ledger-calls" +) + +// callFamilies are the methods each capability stands for, the ones BusState and BusStateWatch answer. +var callFamilies = map[string][]string{ + CapBusStateCalls: {buscalls.ObjectsPut, buscalls.ObjectsGet, buscalls.ObjectsList, buscalls.ObjectsDelete, buscalls.ObjectsInfo, + buscalls.ObjectsWatch, buscalls.StreamRead, buscalls.StreamFill, buscalls.WorkQueueSize, buscalls.WorkQueueDead}, + CapLedgerCalls: {buscalls.LedgerApply, buscalls.LedgerGet, buscalls.LedgerHistory, buscalls.LedgerWatch, buscalls.LedgerQuery, + buscalls.LedgerRebuild, buscalls.LedgerReindex, buscalls.LedgerSnapshot, buscalls.LedgerLag}, +} + +// CallCapabilities is every capability of the calls this runner answers, sorted. +func CallCapabilities() []string { + out := make([]string, 0, len(callFamilies)) + for c := range callFamilies { + out = append(out, c) + } + sort.Strings(out) + return out +} + // BusState answers a bundle's calls on its module's bus state in the new form (mesh-sdk buscalls; novox/hq ADR 0595 // §9, design 25 § 12.5; issue 1966): each as the module whose bundle asks, never a module the request names, so a // bundle reaches its own module's bindings alone. diff --git a/node-tools/internal/runtime/ledgers.go b/node-tools/internal/runtime/ledgers.go index ab2be1e..114cd54 100644 --- a/node-tools/internal/runtime/ledgers.go +++ b/node-tools/internal/runtime/ledgers.go @@ -213,6 +213,21 @@ const ( RepairLag = "ledger_lag" ) +// repairClash is why a bundle's tools of a module cannot be served beside the runner's own ledger repairs: a tool named as +// one of them, which the runner serves for every module owning a ledger, would be taken for it or hide it. It names the +// tool and the repair it clashes with; empty when none does. +func repairClash(module string, tools []launch.Tool) string { + for _, t := range tools { + for _, repair := range []string{RepairRebuild, RepairReindex, RepairSnapshot, RepairLag} { + if t.Name == repair { + return fmt.Sprintf("%s's bundle serves a tool named %s, which is also the ledger repair the runner serves for %s "+ + "(novox/hq ADR 0597 §10): rename the bundle's tool", module, t.Name, module) + } + } + } + return "" +} + // The effects a tool carries (novox/hq ADR 0387 §1). const ( EffectReads = "reads" diff --git a/node-tools/internal/runtime/ledgers_test.go b/node-tools/internal/runtime/ledgers_test.go index b688711..6d530fd 100644 --- a/node-tools/internal/runtime/ledgers_test.go +++ b/node-tools/internal/runtime/ledgers_test.go @@ -15,6 +15,7 @@ import ( "github.com/nats-io/nats.go/jetstream" "github.com/novox/mesh-tools/node-tools/internal/bus" + "github.com/novox/mesh-tools/node-tools/internal/launch" mt "github.com/novox/mesh-tools/node-tools/internal/meshtest" ) @@ -179,3 +180,45 @@ func TestAnApplyIsStampedWithTheCallerTheRuntimeProved(t *testing.T) { t.Fatal("a call that ended was still taken as in flight") } } + +// issue 1966 TLS-2: a bundle's tool named as one of the runner's ledger repairs is refused at the load, naming the tool and the +// repair; any other name is served. +func TestABundleToolNamedAsALedgerRepairIsRefused(t *testing.T) { + for _, c := range []struct{ name, tool, want string }{ + {"rebuild", RepairRebuild, "m's bundle serves a tool named ledger_rebuild, which is also the ledger repair the runner serves for m"}, + {"reindex", RepairReindex, "ledger_reindex"}, + {"snapshot", RepairSnapshot, "ledger_snapshot"}, + {"lag", RepairLag, "ledger_lag"}, + {"another name", "ledger_status", ""}, + } { + t.Run(c.name, func(t *testing.T) { + got := repairClash("m", []launch.Tool{{Name: "first"}, {Name: c.tool}}) + if (c.want == "") != (got == "") || !strings.Contains(got, c.want) { + t.Errorf("clash %q, want one containing %q", got, c.want) + } + }) + } +} + +// issue 1966 TLS-3: every method of a capability the runner states is a call it answers, and the two states its calls under +// no other name; none answers "the runtime answers no ...". +func TestEveryCallOfAStatedCapabilityIsAnswered(t *testing.T) { + m, _, _ := aLedger(t) + if got := strings.Join(CallCapabilities(), ","); got != "bus-state-calls,ledger-calls" { + t.Fatalf("the runner states %s", got) + } + for capability, methods := range callFamilies { + for _, method := range methods { + t.Run(capability+" "+method, func(t *testing.T) { + _, err := m.BusState(method, json.RawMessage(`"not a request"`)) + if err != nil && strings.Contains(err.Error(), "the runtime answers no") { + if stop, werr := m.BusStateWatch(method, json.RawMessage(`"not a request"`), func(json.RawMessage) error { return nil }); werr != nil && strings.Contains(werr.Error(), "the runtime answers no") { + t.Errorf("%s is stated under %s and answered by neither BusState nor BusStateWatch", method, capability) + } else if stop != nil { + stop() + } + } + }) + } + } +} diff --git a/node-tools/internal/runtime/runtime.go b/node-tools/internal/runtime/runtime.go index cd76059..4bff1e8 100644 --- a/node-tools/internal/runtime/runtime.go +++ b/node-tools/internal/runtime/runtime.go @@ -308,7 +308,7 @@ func Run(conn *bus.Conn, served []Served, envs map[string]map[string]string, log announced, err := announce.Serve(conn, announce.Service{ Name: conn.Module(), ID: instanceOf(conn), Description: "the mesh's tool runtime on " + node + ": every assigned module's tools and the seats they hold", - Metadata: map[string]string{"node": node}, + Metadata: map[string]string{"node": node, announce.Capabilities: strings.Join(CallCapabilities(), ",")}, }, func() []announce.Endpoint { return endpointsOf(conn, r.own(), r.registrations(), r.modules) }) if err != nil { logf("[mesh-tools] cannot announce what it serves: %v", err) @@ -396,6 +396,19 @@ func (r *runner) load(s Served) *loadedModule { fail(err.Error()) continue } + // A tool named as one of the runner's ledger repairs would be taken for it, or hide it: refused at the load, naming both + // (ADR 0597 §10). + clashed := "" + for _, reg := range child.Registrations { + if clashed == "" && r.isServed[reg.Module] { + clashed = repairClash(reg.Module, reg.Tools) + } + } + if clashed != "" { + child.Stop() + fail(clashed) + continue + } l.children = append(l.children, child.Stop) for _, reg := range child.Registrations { got := registration{module: reg.Module, owner: module, tools: reg.Tools} diff --git a/node-tools/module.json b/node-tools/module.json index e9d1a43..10f153a 100644 --- a/node-tools/module.json +++ b/node-tools/module.json @@ -29,7 +29,8 @@ } }, "own-secrets": { - "broker": "${dir:mesh-state}/broker" + "broker": "${dir:mesh-state}/broker", + "ledgers": "${dir:mesh-state}/ledgers" }, "listens": [ {