Merge pull request 'One codec for the grants part's memberships state, so its writer and readers cannot drift (issue 774)' (#134) from fix/memberships-shape into main
Reviewed-on: #134
This commit was merged in pull request #134.
This commit is contained in:
@@ -1737,8 +1737,11 @@ func CreateConsumerGrants(consumers []ModuleConsumer) []string {
|
||||
const GrantsModule = "mesh-grants"
|
||||
|
||||
// The grants part's state memberships (novox/hq ADR 0571 clause 7): one key per assignment, `<node>.<module>`, its
|
||||
// membership (Membership) as the assignments stream carries it, written only when it differs. A module's runtime reads
|
||||
// its own key, the tool runner its node's; the grants part alone writes it.
|
||||
// membership (Membership) bare, as the assignments stream carries it, written only when it differs. A module's runtime
|
||||
// reads its own key, the tool runner its node's; the grants part alone writes it. Both sides go through
|
||||
// membership.EncodeGrantsMembership and membership.DecodeGrantsMembership (or BareGrantsMembership for a reader with a
|
||||
// type of its own), never json of a type of their own: the grants part once wrote its wrapper here and every reader
|
||||
// served nothing (issue 774). The decoder still reads that wrapper, for the transition.
|
||||
const (
|
||||
GrantsMembershipsState = "memberships"
|
||||
GrantsMembershipsBucket = GrantsModule + "_" + GrantsMembershipsState
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package membership
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// The grants part's state memberships (buscompose.GrantsMembershipsState) holds, at `<node>.<module>`, the bare
|
||||
// membership as the assignments stream carries it. The writer and every reader use this one codec, so the two sides
|
||||
// cannot drift: until 2026-10-30 the grants part wrote its own wrapper ({node, module, digest, membership, published,
|
||||
// retired}) there while the tool runner read the value as a bare membership, and served nothing (issue 774).
|
||||
|
||||
// EncodeGrantsMembership is the value of a membership's key in the grants part's memberships: the bare membership.
|
||||
func EncodeGrantsMembership(m Membership) []byte {
|
||||
raw, _ := json.Marshal(m) // a Membership holds nothing json cannot write
|
||||
return raw
|
||||
}
|
||||
|
||||
// DecodeGrantsMembership reads a value of the grants part's memberships (BareGrantsMembership) as a Membership.
|
||||
func DecodeGrantsMembership(raw []byte) (Membership, error) {
|
||||
var m Membership
|
||||
body, err := BareGrantsMembership(raw)
|
||||
if err != nil {
|
||||
return m, err
|
||||
}
|
||||
err = json.Unmarshal(body, &m)
|
||||
return m, err
|
||||
}
|
||||
|
||||
// BareGrantsMembership is the bare membership a value of the grants part's memberships holds, for a reader that
|
||||
// decodes it into a type of its own. It accepts the bare membership and, for the transition, the wrapper the grants
|
||||
// part wrote before (a top-level object whose "membership" is an object), answering the membership inside it only when
|
||||
// its "published" says "published" or "unchanged". A value that is not a JSON object, a wrapper of a membership held
|
||||
// back or saying nothing of its publishing, or a membership naming no node or no module, is an error, never an empty
|
||||
// or unpublished membership.
|
||||
func BareGrantsMembership(raw []byte) (json.RawMessage, error) {
|
||||
var probe struct {
|
||||
Membership json.RawMessage `json:"membership"`
|
||||
Published *string `json:"published"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return nil, fmt.Errorf("not a membership: %w", err)
|
||||
}
|
||||
body := raw
|
||||
if inner := bytes.TrimSpace(probe.Membership); len(inner) > 0 {
|
||||
if inner[0] != '{' {
|
||||
return nil, errors.New("not a membership: its membership is no object")
|
||||
}
|
||||
// The old wrapper was written for a membership held back too, its composed and unpublished membership inside:
|
||||
// only one published (or unchanged) is the stream's, so any other is refused and the reader keeps the stream's.
|
||||
switch {
|
||||
case probe.Published == nil:
|
||||
return nil, errors.New("not a published membership: the wrapper says nothing of its publishing")
|
||||
case *probe.Published != "published" && *probe.Published != "unchanged":
|
||||
return nil, fmt.Errorf("not a published membership: the wrapper says %q", *probe.Published)
|
||||
}
|
||||
body = inner
|
||||
}
|
||||
var m Membership
|
||||
if err := json.Unmarshal(body, &m); err != nil {
|
||||
return nil, fmt.Errorf("not a membership: %w", err)
|
||||
}
|
||||
if m.Node == "" || m.Module == "" {
|
||||
return nil, errors.New("not a membership: it names no node or no module")
|
||||
}
|
||||
return json.RawMessage(body), nil
|
||||
}
|
||||
@@ -73,4 +73,39 @@ func TestAMembershipRoundTripsAsIssued(t *testing.T) {
|
||||
t.Errorf("field %q is not on the wire under that name", k)
|
||||
}
|
||||
}
|
||||
|
||||
// The grants part's memberships hold the bare membership through this package's codec; the wrapper written before
|
||||
// 2026-10-30 still reads when it says the membership was published or unchanged, and anything else is an error,
|
||||
// never an empty membership or one the part held back (issue 774).
|
||||
m := Membership{Node: "ace", Module: "forge", Tools: "mesh.mod.forge.tool", Serves: []Served{{Subject: "mesh.mod.forge.tool.{tool}"}},
|
||||
State: []StateIssued{{Name: "repos", Bucket: "forge_repos"}}}
|
||||
inner, _ := json.Marshal(m)
|
||||
wrapped := func(published string) []byte {
|
||||
return []byte(`{"node":"ace","module":"forge","digest":"sha256:x","membership":` + string(inner) + published + `,"retired":["a b"]}`)
|
||||
}
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
raw []byte
|
||||
ok bool
|
||||
}{
|
||||
{"bare, as written", EncodeGrantsMembership(m), true},
|
||||
{"wrapped and published, as the grants part wrote it before", wrapped(`,"published":"published"`), true},
|
||||
{"wrapped and unchanged", wrapped(`,"published":"unchanged"`), true},
|
||||
{"wrapped and held back", wrapped(`,"published":"not published, a shadow difference: publishing would lose x"`), false},
|
||||
{"wrapped, saying nothing of its publishing", wrapped(""), false},
|
||||
{"not json", []byte("garbage"), false},
|
||||
{"an array", []byte(`[1,2]`), false},
|
||||
{"an object naming no node or module", []byte(`{"serves":[{"subject":"s"}]}`), false},
|
||||
{"a wrapper whose membership is no object", []byte(`{"node":"ace","module":"forge","membership":"x","published":"published"}`), false},
|
||||
{"a wrapper whose membership names nothing", []byte(`{"node":"ace","module":"forge","membership":{},"published":"published"}`), false},
|
||||
} {
|
||||
got, err := DecodeGrantsMembership(c.raw)
|
||||
if (err == nil) != c.ok {
|
||||
t.Errorf("%s: err %v", c.name, err)
|
||||
continue
|
||||
}
|
||||
if c.ok && !reflect.DeepEqual(got, m) {
|
||||
t.Errorf("%s: read %+v, want %+v", c.name, got, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user