minio: read who holds a seat from the placement seat's state, not the controller's seats (issue 1955, ADR 0571 clause 4)

This commit is contained in:
2026-10-29 23:46:55 +01:00
parent 34a6932288
commit 45902cd728
6 changed files with 63 additions and 67 deletions
@@ -20,7 +20,7 @@ func manifest(t *testing.T) map[string]any {
return m
}
// 1516/AC6: minio claims object-store (scope mesh) serving link, invokes seat:mesh-controller.seats, builds the Go
// 1516/AC6: minio claims object-store (scope mesh) serving link, reads the placement seat's seat-holders, builds the Go
// bundle, and no tool named minio_presigned_url remains.
func TestManifestClaimsTheSeat(t *testing.T) {
m := manifest(t)
@@ -38,9 +38,9 @@ func TestManifestClaimsTheSeat(t *testing.T) {
if c["name"] != "object-store" || c["scope"] != "mesh" || len(serves) != 1 || serves[0] != "link" {
t.Fatalf("claim: %v", c)
}
inv := m["invokes"].([]any)
if len(inv) != 1 || inv[0] != "seat:mesh-controller.seats" {
t.Fatalf("invokes: %v", inv)
reads, _ := m["reads"].([]any)
if _, invokes := m["invokes"]; invokes || len(reads) != 1 || reads[0] != "mesh-delivery-assigner.seat-holders" {
t.Fatalf("reads %v and invokes %v: it reads the placement seat's holders and calls nothing", reads, m["invokes"])
}
var goBundle map[string]any
for _, a := range m["build"].(map[string]any)["artifacts"].([]any) {
+33 -49
View File
@@ -13,6 +13,7 @@ import (
"time"
stdio "git.novox.be/novox/mesh-sdk/go"
"git.novox.be/novox/mesh-sdk/go/delivery"
"git.novox.be/novox/mesh-sdk/go/objectlink"
)
@@ -59,11 +60,11 @@ func linkKeyID(bucket string) string {
type server struct {
configPath, linksDir, receives string
now func() time.Time
// seats asks the controller for its seats; a test replaces it.
seats func() (json.RawMessage, error)
// seats reads the placement seat's state of who holds which seat, every key; a test replaces it.
seats func() (map[string][]byte, error)
mu sync.Mutex
holders map[string]bool // "<seat>/<node>.<module>"
keys map[string][]byte // seat-holders, as read
readAt time.Time
haveSet bool
}
@@ -77,8 +78,23 @@ func newServer(config, links, receives string) (*server, error) {
return &server{configPath: config, linksDir: links, receives: receives, now: time.Now, seats: askSeats}, nil
}
func askSeats() (json.RawMessage, error) {
return stdio.Ask("seat:mesh-controller.seats", map[string]any{})
func askSeats() (map[string][]byte, error) {
state := stdio.State(delivery.SeatHoldersRead)
names, err := state.Keys()
if err != nil {
return nil, err
}
keys := map[string][]byte{}
for _, k := range names {
e, err := state.Get(k)
if err != nil {
return nil, err
}
if e != nil {
keys[k] = e.Value
}
}
return keys, nil
}
// link answers one ask.
@@ -206,7 +222,7 @@ func (s *server) allowed(g grant, node, module, method string) error {
if seat, isSeat := strings.CutPrefix(sh.To, "seat:"); isSeat {
held, err := s.holds(seat, node, module)
if err != nil {
return false, fmt.Errorf("a share names seat %s and the controller's seats cannot be read to tell whether this caller holds it: %w", seat, err)
return false, fmt.Errorf("a share names seat %s and the placement seat's holders cannot be read to tell whether this caller holds it: %w", seat, err)
}
return held, nil
}
@@ -249,58 +265,26 @@ func has(list []string, v string) bool {
return false
}
// holds says whether node.module holds the seat, from the controller's seats, read at most once a minute. A read that
// failed is not cached: the next ask reads again.
// holds says whether node.module holds the seat, from the placement seat's state of who holds which seat (novox/hq ADR
// 0571 clause 4), read at most once a minute. A read that failed is not cached: the next ask reads again.
func (s *server) holds(seat, node, module string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if !s.haveSet || s.now().Sub(s.readAt) >= holdersFor {
raw, err := s.seats()
keys, err := s.seats()
if err != nil {
return false, err
}
set, err := holdersIn(raw)
if err != nil {
return false, err
}
s.holders, s.readAt, s.haveSet = set, s.now(), true
s.keys, s.readAt, s.haveSet = keys, s.now(), true
}
return s.holders[seat+"/"+node+"."+module], nil
}
// holdersIn reads the controller's `seats` answer (answer.seats[].holders[] of {node, module}, and answer.outside[] of
// {Claim, Node, Module}) into "<seat>/<node>.<module>". The answer may arrive wrapped in {"answer": ...}.
func holdersIn(raw json.RawMessage) (map[string]bool, error) {
var doc struct {
Answer json.RawMessage `json:"answer"`
Seats []struct {
Seat string `json:"seat"`
Holders []struct {
Node string `json:"node"`
Module string `json:"module"`
} `json:"holders"`
} `json:"seats"`
Outside []struct {
Claim, Node, Module string
} `json:"outside"`
holders, err := delivery.HoldersOf(s.keys, seat, node)
if err != nil {
return false, err
}
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("the controller's seats answer is not JSON: %w", err)
}
if len(doc.Answer) > 0 && string(doc.Answer) != "null" {
return holdersIn(doc.Answer)
}
if doc.Seats == nil && doc.Outside == nil {
return nil, errors.New("the controller's seats answer holds neither seats nor outside claims")
}
out := map[string]bool{}
for _, s := range doc.Seats {
for _, h := range s.Holders {
out[s.Seat+"/"+h.Node+"."+h.Module] = true
for _, h := range holders {
if h.Node == node && h.Module == module {
return true, nil
}
}
for _, o := range doc.Outside {
out[o.Claim+"/"+o.Node+"."+o.Module] = true
}
return out, nil
return false, nil
}
@@ -1,7 +1,6 @@
package main
import (
"encoding/json"
"errors"
"net/url"
"os"
@@ -25,12 +24,13 @@ const grantFile = `{
]
}`
// The seats answer is the controller's (mesh-controller.seats read on 2026-10-21): seats[].holders[] of {node, module},
// and outside[] of {Claim, Node, Module}.
const seatsAnswer = `{"answer": {"seats": [
{"seat": "node-files", "scope": "node", "holders": [{"node": "g14", "module": "mesh-files"}, {"node": "shanks", "module": "mesh-files"}]},
{"seat": "node-backup", "scope": "node", "holders": [{"node": "g14", "module": "restic"}]}
], "outside": [{"Claim": "printer", "Scope": "mesh", "Node": "shanks", "Module": "brother", "Site": "home", "Kind": ""}]}}`
// The placement seat's state of who holds which seat (seat-holders): a key per node seat and node, a key per mesh seat.
var seatHolderKeys = map[string][]byte{
"node-files.g14": []byte(`{"v":1,"seat":"node-files","scope":"node","node":"g14","holders":[{"module":"mesh-files","node":"g14"}]}`),
"node-files.shanks": []byte(`{"v":1,"seat":"node-files","scope":"node","node":"shanks","holders":[{"module":"mesh-files","node":"shanks"}]}`),
"node-backup.g14": []byte(`{"v":1,"seat":"node-backup","scope":"node","node":"g14","holders":[{"module":"restic","node":"g14"}]}`),
"printer": []byte(`{"v":1,"seat":"printer","scope":"mesh","holders":[{"module":"brother","node":"shanks"}]}`),
}
type rig struct {
s *server
@@ -64,12 +64,12 @@ func newRig(t *testing.T) *rig {
t.Fatal(err)
}
s.now = func() time.Time { return r.now }
s.seats = func() (json.RawMessage, error) {
s.seats = func() (map[string][]byte, error) {
r.seatsCalls++
if r.seatsErr != nil {
return nil, r.seatsErr
}
return json.RawMessage(seatsAnswer), nil
return seatHolderKeys, nil
}
r.s = s
return r
+1 -1
View File
@@ -2,4 +2,4 @@ module minio
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220947-f9a496d3e121
+14 -2
View File
@@ -1,2 +1,14 @@
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f h1:7J/9jOfSfjfYpf8MOj96HwQRViQUkBNMb71RHP2mjn8=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261021122348-6c857e1c025f/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220947-f9a496d3e121 h1:OsA1v6n736a7RqEV8Nkip8pf5ILMbzj0PWuKqcBOhK0=
git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261029220947-f9a496d3e121/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
+2 -2
View File
@@ -39,8 +39,8 @@
]
}
],
"invokes": [
"seat:mesh-controller.seats"
"reads": [
"mesh-delivery-assigner.seat-holders"
],
"contributes": {
"route": {