diff --git a/node-tools/go.mod b/node-tools/go.mod index 5423126..69cda1a 100644 --- a/node-tools/go.mod +++ b/node-tools/go.mod @@ -8,7 +8,7 @@ require ( ) require ( - git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 + git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030142317-46d4b4da5ebb github.com/antithesishq/antithesis-sdk-go v0.7.0-default-no-op // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/klauspost/compress v1.20.0 // indirect diff --git a/node-tools/go.sum b/node-tools/go.sum index a2b8e01..80f4477 100644 --- a/node-tools/go.sum +++ b/node-tools/go.sum @@ -1,5 +1,5 @@ -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0 h1:lKU3HjNXGiVbLW2hUgFjU/TMTUYMlNnnmZxG5T7biQA= -git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030001116-2da0de75c3f0/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030142317-46d4b4da5ebb h1:QvNKoCjW1cwKsaY/9iJO0srTFIBQQwEZMkjb664BAPg= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261030142317-46d4b4da5ebb/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/go.mod h1:FQyySiasQQM8735Ddel3MRojmy4dA1IqCeyJ5jmPMbI= github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= diff --git a/node-tools/internal/bus/bus.go b/node-tools/internal/bus/bus.go index 4051a80..fc4b508 100644 --- a/node-tools/internal/bus/bus.go +++ b/node-tools/internal/bus/bus.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "git.novox.be/novox/mesh-sdk/go/buscompose" + "git.novox.be/novox/mesh-sdk/go/membership" "log" "regexp" "strings" @@ -468,8 +469,15 @@ func (c *Conn) followGrants(module string) { if e.Operation() != jetstream.KeyValuePut { return // a membership withdrawn is the stream's, or the module's unassignment's, to say } + // Read through mesh-sdk's one codec of this state, the grants part's writer's (issue 774): it takes the bare + // membership and the wrapper the part wrote before, and refuses a value naming no node or module, which then + // leaves the assignments stream's copy in force instead of an empty membership. var m Membership - if err := json.Unmarshal(e.Value(), &m); err != nil { + body, err := membership.BareGrantsMembership(e.Value()) + if err == nil { + err = json.Unmarshal(body, &m) + } + if err != nil { c.Logf("[mesh-tools] the membership at %s is not one: %v", key, err) return } diff --git a/node-tools/internal/bus/state_watch_test.go b/node-tools/internal/bus/state_watch_test.go index 7e0e842..61166b4 100644 --- a/node-tools/internal/bus/state_watch_test.go +++ b/node-tools/internal/bus/state_watch_test.go @@ -116,6 +116,40 @@ func TestAWatchIsRefusedWhenNoCurrentValueIsTakenWithinTheBound(t *testing.T) { } } +// **A membership the grants part wrote in its old wrapper is served whole** (issue 774). Until 2026-10-30 the part wrote +// its pass record, the membership inside, into its state memberships; the tool runner read the value as a bare +// membership, took it over the assignments stream's copy, and served no tool and no state. This is what the live mesh +// held: read through mesh-sdk's codec, its tools are answered where the membership says and its state is reached. +func TestAMembershipTheGrantsPartWroteWrappedIsServed(t *testing.T) { + mesh := mt.New(t) + mesh.Bucket(t, "gitea_repos") + gitea := mt.MembershipOf("gitea", "anchor", false, nil) + gitea.State = []bus.StateIssued{{Name: "repos", Bucket: "gitea_repos", Writes: true}} + mesh.IssueInGrantsWrapped(t, gitea) + conn, err := bus.Connect(bus.Credential{URL: mt.URL(t), Module: "node-tools", Node: "anchor"}) + if err != nil { + t.Fatal(err) + } + conn.Logf = func(string, ...any) {} + t.Cleanup(conn.Close) + conn.Follow("gitea") + mt.Until(t, func() error { + if m := conn.Membership("gitea"); m == nil || len(m.Serves) == 0 { + return fmt.Errorf("gitea's membership is %+v, want the one inside the wrapper", m) + } + return nil + }) + if got, want := fmt.Sprint(conn.ServedOn("gitea", "gitea_list")), "[{mesh.mod.gitea.tool.gitea_list.anchor }]"; got != want { + t.Errorf("gitea_list is served on %s, want %s", got, want) + } + if _, err := conn.StatePut("gitea", "repos", "novox.hq", []byte(`{"v":1}`)); err != nil { + t.Fatalf("the state the membership issues is refused: %v", err) + } + if e, err := conn.StateGet("gitea", "repos", "novox.hq"); err != nil || e == nil || string(e.Value) != `{"v":1}` { + t.Errorf("the state reads back %+v, %v", e, err) + } +} + // owedWatcher is the gitea module's view of the checker's owed: the bucket, and a runtime connection // that serves gitea with the owed issued for reading. func owedWatcher(t *testing.T) (*bus.Conn, interface { diff --git a/node-tools/internal/meshtest/meshtest.go b/node-tools/internal/meshtest/meshtest.go index 1673a0d..a22fd93 100644 --- a/node-tools/internal/meshtest/meshtest.go +++ b/node-tools/internal/meshtest/meshtest.go @@ -15,6 +15,7 @@ import ( "encoding/json" "git.novox.be/novox/mesh-sdk/go/buscompose" "git.novox.be/novox/mesh-sdk/go/delivery" + "git.novox.be/novox/mesh-sdk/go/membership" "os" "path/filepath" "runtime" @@ -110,7 +111,23 @@ func (m *Mesh) Issue(t *testing.T, mem bus.Membership) { // IssueInGrants writes a membership where the grants part keeps it, its state memberships, key `.` // (novox/hq ADR 0571 clause 7; mesh-sdk buscompose.GrantsMembershipsBucket), making the bucket when it is not there. +// It writes with mesh-sdk's encoder, the grants part's own, never json of this package's type: a value built here by +// hand agreed with the reader's mistake and hid that the part wrote another shape (issue 774). func (m *Mesh) IssueInGrants(t *testing.T, mem bus.Membership) { + t.Helper() + m.putGrants(t, mem, membership.EncodeGrantsMembership(sdkMembership(t, mem))) +} + +// IssueInGrantsWrapped writes a membership as the grants part wrote it before mesh-sdk's codec (until 2026-10-30, what +// the live mesh holds until the part writes again): its pass record, the membership inside. +func (m *Mesh) IssueInGrantsWrapped(t *testing.T, mem bus.Membership) { + t.Helper() + body, _ := json.Marshal(map[string]any{"node": mem.Node, "module": mem.Module, "digest": "sha256:0", + "membership": sdkMembership(t, mem), "published": "published"}) + m.putGrants(t, mem, body) +} + +func (m *Mesh) putGrants(t *testing.T, mem bus.Membership, body []byte) { t.Helper() kv, err := m.js.KeyValue(buscompose.GrantsMembershipsBucket) if err != nil { @@ -118,12 +135,22 @@ func (m *Mesh) IssueInGrants(t *testing.T, mem bus.Membership) { t.Fatal(err) } } - body, _ := json.Marshal(mem) if _, err := kv.Put(buscompose.MembershipKey(mem.Node, mem.Module), body); err != nil { t.Fatal(err) } } +// sdkMembership is a membership of this package's type as mesh-sdk's, which the grants part composes and writes. +func sdkMembership(t *testing.T, mem bus.Membership) membership.Membership { + t.Helper() + raw, _ := json.Marshal(mem) + var out membership.Membership + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out +} + // ConsoleReads are the states a console's runtime reads to say what the mesh holds (novox/hq ADR 0571 clauses 4, 5): // placement, the indexer's modules and manifests, and the seat holders. func ConsoleReads() []bus.StateIssued {