diff --git a/internal/broker/membership.go b/internal/broker/membership.go index 2fca5552..ed4d3c51 100644 --- a/internal/broker/membership.go +++ b/internal/broker/membership.go @@ -108,7 +108,7 @@ func MembershipFor(node string, d Declared, where Placements) Membership { m.Seats = append(m.Seats, SeatServed{Seat: s.Name, Verb: verb, Subject: seatToolSubject(s, verb, node)}) } } - m.State = stateIssuedFor(d) + m.State = stateIssuedFor(d, node) if len(d.Invokes) > 0 { m.Reaches = map[string][]string{} for _, t := range d.Invokes { diff --git a/internal/broker/nats.go b/internal/broker/nats.go index cdca1d38..4e022964 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -111,6 +111,10 @@ type Principal struct { // instances and read by whoever declares it. State []string Reads []string + // PerMachine is the local names of its state keyed by machine, reaching the key named for Node and + // no other; KeyedReads the keys of others' state it reads one key at a time (novox/hq ADR 0260). + PerMachine []string + KeyedReads []KeyedRead // SnapshotsTheBus is the bus's own module, the one holding mesh-broker (novox/hq ADR 0235). Its // whole authority is BusSnapshotGrants: it copies the streams for the night's backup and nothing @@ -591,7 +595,8 @@ func PermissionsFor(p Principal) (Permissions, error) { // 5. Its state, and the state of others it reads (novox/hq ADR 0201): every one read and // watched, its own written too. - pub = append(pub, stateGrants(p.Module, p.State, p.Reads)...) + pub = append(pub, stateGrants(stateAccess{Module: p.Module, Node: p.Node, Keeps: p.State, + PerMachine: p.PerMachine, Reads: p.Reads, KeyedReads: p.KeyedReads})...) case KindNodeTools: // **One process serves what every module on the machine would have served for itself** @@ -667,7 +672,8 @@ func PermissionsFor(p Principal) (Permissions, error) { // does not write another's bucket through it is the runtime's to keep, from the membership // each assignment is issued, as it keeps each module's events under that module's own name. for _, d := range p.Carries { - pub = append(pub, stateGrants(d.Module, stateNames(d.State), d.Reads)...) + pub = append(pub, stateGrants(stateAccess{Module: d.Module, Node: p.Node, Keeps: stateNames(d.State), + PerMachine: perMachineNames(d.State), Reads: d.Reads, KeyedReads: d.KeyedReads})...) } sub = unique(sub) pub = unique(pub) diff --git a/internal/broker/state.go b/internal/broker/state.go index 033ee4c4..f78ddd09 100644 --- a/internal/broker/state.go +++ b/internal/broker/state.go @@ -32,6 +32,9 @@ type Bucket struct { History int // TTLSeconds is how long a value lives; zero is until replaced or deleted. TTLSeconds int + // PerMachine says each machine's instance writes only the key named for its machine (novox/hq ADR + // 0260), and is granted that key and no other. + PerMachine bool } // BucketName is the bucket a module's state lives in: the module and the local name joined by an @@ -72,52 +75,124 @@ func bucketOfRead(read string) (string, bool) { // read; without CONSUMER.CREATE no key is listed and nothing is watched; without CONSUMER.DELETE a // watch cannot be stopped and lingers on the server. A write outside these is refused by the server // — and reaches the writer as a timeout, not a refusal, which is why the runtime refuses first. -func stateGrants(module string, keeps []string, reads []string) []string { +// +// **One key, where the state is per machine** (novox/hq ADR 0260). The owner's instance on a machine +// writes and reads the key named for that machine, and a holder granted a read for its machine reads +// that key: a direct get of the key's own subject, and a consumer whose filter is that subject — the +// server's create-with-filter form, which a client uses for a watch of one key and which the server +// holds to the filter it names. What cannot be narrowed by key: binding (STREAM.INFO), deleting a +// consumer (named at random by the client) and flow control; none of them reads or writes a value. +func stateGrants(a stateAccess) []string { var out []string - read := func(bucket string) { + read := func(bucket, key string) { stream := "KV_" + bucket + out = append(out, "$JS.API.STREAM.INFO."+stream) + if key == "" { + out = append(out, + "$JS.API.DIRECT.GET."+stream+".>", + "$JS.API.CONSUMER.CREATE."+stream+".>") + } else { + out = append(out, + "$JS.API.DIRECT.GET."+stream+".$KV."+bucket+"."+key, + "$JS.API.CONSUMER.CREATE."+stream+".*.$KV."+bucket+"."+key) + } out = append(out, - "$JS.API.STREAM.INFO."+stream, - "$JS.API.DIRECT.GET."+stream+".>", - "$JS.API.CONSUMER.CREATE."+stream+".>", "$JS.API.CONSUMER.DELETE."+stream+".>", "$JS.FC."+stream+".>") } - for _, name := range keeps { + machineKey := "" + if safeSubject.MatchString(a.Node) { + machineKey = a.Node + } + perMachine := map[string]bool{} + for _, n := range a.PerMachine { + perMachine[n] = true + } + for _, name := range a.Keeps { if !safeSubject.MatchString(name) { continue } - bucket := BucketName(module, name) - read(bucket) + bucket := BucketName(a.Module, name) + if perMachine[name] { + if machineKey == "" { + continue // a machine with no usable name reaches none of it + } + read(bucket, machineKey) + out = append(out, "$KV."+bucket+"."+machineKey) + continue + } + read(bucket, "") out = append(out, "$KV."+bucket+".>") } - for _, r := range reads { + for _, r := range a.Reads { if bucket, ok := bucketOfRead(r); ok { - read(bucket) + read(bucket, "") + } + } + for _, k := range a.KeyedReads { + if bucket, ok := bucketOfRead(k.Read); ok && safeSubject.MatchString(k.Key) { + read(bucket, k.Key) + } + } + return out +} + +// KeyedRead is one key of another module's state a principal reads, and no other key of it. +type KeyedRead struct { + Read string `json:"read"` + Key string `json:"key"` +} + +// stateAccess is what one principal may do with state: the module's own buckets (some per machine), +// what it reads whole, and what it reads for its machine's key alone. +type stateAccess struct { + Module, Node string + Keeps, PerMachine []string + Reads []string + KeyedReads []KeyedRead +} + +// perMachineNames is the local names of a module's buckets keyed by machine. +func perMachineNames(buckets []Bucket) []string { + var out []string + for _, b := range buckets { + if b.PerMachine { + out = append(out, b.Name) } } return out } // StateIssued is one bucket an assignment may reach, by the name its module uses for it: its own -// state by the local name, another's as `.` (novox/hq ADR 0201). +// state by the local name, another's as `.` (novox/hq ADR 0201). Key, when set, is the +// one key it may reach (novox/hq ADR 0260): this machine's. type StateIssued struct { Name string `json:"name"` Bucket string `json:"bucket"` Writes bool `json:"writes,omitempty"` + Key string `json:"key,omitempty"` } -// stateIssuedFor is every bucket a module's code may reach, as its membership lists them. -func stateIssuedFor(d Declared) []StateIssued { +// stateIssuedFor is every bucket a module's code may reach on a machine, as its membership lists them. +func stateIssuedFor(d Declared, node string) []StateIssued { var out []StateIssued for _, b := range d.State { - out = append(out, StateIssued{Name: b.Name, Bucket: BucketName(d.Module, b.Name), Writes: true}) + issued := StateIssued{Name: b.Name, Bucket: BucketName(d.Module, b.Name), Writes: true} + if b.PerMachine { + issued.Key = node + } + out = append(out, issued) } for _, r := range d.Reads { if bucket, ok := bucketOfRead(r); ok { out = append(out, StateIssued{Name: r, Bucket: bucket}) } } + for _, k := range d.KeyedReads { + if bucket, ok := bucketOfRead(k.Read); ok { + out = append(out, StateIssued{Name: k.Read, Bucket: bucket, Key: k.Key}) + } + } return out } diff --git a/internal/broker/state_test.go b/internal/broker/state_test.go index a6f7580c..402cd317 100644 --- a/internal/broker/state_test.go +++ b/internal/broker/state_test.go @@ -78,7 +78,8 @@ func TestAModuleWithNoStateReachesNoBucket(t *testing.T) { // A read that names no bucket grants nothing rather than something that happens to parse. func TestAReadThatNamesNoBucketGrantsNothing(t *testing.T) { - if got := stateGrants("a", nil, []string{"nodot", "x.", ".y", "a.b>"}); len(got) != 0 { + if got := stateGrants(stateAccess{Module: "a", Node: "one", Reads: []string{"nodot", "x.", ".y", "a.b>"}, + KeyedReads: []KeyedRead{{Read: "nodot", Key: "one"}, {Read: "a.b", Key: "x>"}}}); len(got) != 0 { t.Fatalf("granted %v for reads that name no bucket", got) } } @@ -178,3 +179,58 @@ func TestABucketIsAssertedOverAHeldConnection(t *testing.T) { t.Fatalf("the bucket is not there: %v", err) } } + +// **State keyed by machine reaches its machine's key alone** (novox/hq ADR 0260): the owner's instance on +// a machine writes and reads that key, and a bar granted a key reads that key — a direct get of its +// subject and a consumer filtered to it — and no other key of the bucket. +func TestPerMachineStateAndAKeyedReadReachOneKey(t *testing.T) { + owner, err := PermissionsFor(Principal{Kind: KindModule, Node: "laptop", Module: "power", PasswordHash: "x", + State: []string{"draw"}, PerMachine: []string{"draw"}}) + if err != nil { + t.Fatal(err) + } + has(t, owner.Publish, "$KV.power_draw.laptop") + has(t, owner.Publish, "$JS.API.DIRECT.GET.KV_power_draw.$KV.power_draw.laptop") + has(t, owner.Publish, "$JS.API.CONSUMER.CREATE.KV_power_draw.*.$KV.power_draw.laptop") + hasNot(t, owner.Publish, "$KV.power_draw.>") + hasNot(t, owner.Publish, "$JS.API.DIRECT.GET.KV_power_draw.>") + hasNot(t, owner.Publish, "$JS.API.CONSUMER.CREATE.KV_power_draw.>") + + bar, err := PermissionsFor(Principal{Kind: KindModule, Node: "laptop", Module: "bar", PasswordHash: "x", + KeyedReads: []KeyedRead{{Read: "power.draw", Key: "laptop"}}}) + if err != nil { + t.Fatal(err) + } + has(t, bar.Publish, "$JS.API.STREAM.INFO.KV_power_draw") + has(t, bar.Publish, "$JS.API.DIRECT.GET.KV_power_draw.$KV.power_draw.laptop") + has(t, bar.Publish, "$JS.API.CONSUMER.CREATE.KV_power_draw.*.$KV.power_draw.laptop") + for _, s := range bar.Publish { + if strings.HasPrefix(s, "$KV.") || strings.HasSuffix(s, "KV_power_draw.>") && !strings.Contains(s, "DELETE") && !strings.HasPrefix(s, "$JS.FC.") { + t.Fatalf("a keyed read was granted %q", s) + } + } + + // And the machine's runtime, which carries both, is granted the union: still the one key. + runtime, err := PermissionsFor(Principal{Kind: KindNodeTools, Node: "laptop", Module: RuntimeModule, PasswordHash: "x", + Carries: []Declared{ + {Module: "power", State: []Bucket{{Module: "power", Name: "draw", PerMachine: true}}}, + {Module: "bar", KeyedReads: []KeyedRead{{Read: "power.draw", Key: "laptop"}}}, + }}) + if err != nil { + t.Fatal(err) + } + has(t, runtime.Publish, "$KV.power_draw.laptop") + hasNot(t, runtime.Publish, "$KV.power_draw.>") + hasNot(t, runtime.Publish, "$JS.API.DIRECT.GET.KV_power_draw.>") +} + +func TestAMembershipNamesTheOneKeyOfKeyedState(t *testing.T) { + m := MembershipFor("laptop", Declared{Module: "bar", KeyedReads: []KeyedRead{{Read: "power.draw", Key: "laptop"}}}, Placements{}) + if len(m.State) != 1 || m.State[0] != (StateIssued{Name: "power.draw", Bucket: "power_draw", Key: "laptop"}) { + t.Fatalf("%+v", m.State) + } + o := MembershipFor("laptop", Declared{Module: "power", State: []Bucket{{Module: "power", Name: "draw", PerMachine: true}}}, Placements{}) + if len(o.State) != 1 || o.State[0] != (StateIssued{Name: "draw", Bucket: "power_draw", Writes: true, Key: "laptop"}) { + t.Fatalf("%+v", o.State) + } +} diff --git a/internal/broker/users.go b/internal/broker/users.go index a69474f2..e06ad472 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -37,6 +37,9 @@ type Declared struct { State []Bucket // Reads are other modules' state it reads, each `.` (novox/hq ADR 0201). Reads []string + // KeyedReads are keys of other modules' state it reads, each one key alone: what a seat's holder is + // granted for the pieces the modules beside it offer (novox/hq ADR 0260). + KeyedReads []KeyedRead // NoAccount says the module declares no own secret named broker, so no account could ever be // delivered to it and nothing can connect as it (novox/hq issue 195). Said in the negative so a // record that does not say is composed as it always was. @@ -113,6 +116,7 @@ func Users(r Records) ([]Principal, error) { Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes, State: stateNames(d.State), Reads: d.Reads, SnapshotsTheBus: d.SnapshotsTheBus, + PerMachine: perMachineNames(d.State), KeyedReads: d.KeyedReads, }) } if runtimeHere { diff --git a/internal/catalogue/graphical_session.go b/internal/catalogue/graphical_session.go index bbccbfb7..a03a5082 100644 --- a/internal/catalogue/graphical_session.go +++ b/internal/catalogue/graphical_session.go @@ -136,16 +136,18 @@ var barShows = map[string]map[string]barOption{ "state": {"state": {kind: "text", required: true}, "key": {kind: "text"}}, } -// barBlockReads is the state a block shows, if any. -func barBlockReads(data map[string]any) []string { +// barBlockReads is the state and key a block shows, if any; an empty key is the machine's own. +func barBlockReads(data map[string]any) []StateRead { if data["shows"] != "state" { return nil } options, _ := data["options"].(map[string]any) - if s, ok := options["state"].(string); ok && s != "" { - return []string{s} + s, ok := options["state"].(string) + if !ok || s == "" { + return nil } - return nil + key, _ := options["key"].(string) + return []StateRead{{Read: s, Key: key}} } type barOption struct { diff --git a/internal/catalogue/seat_data.go b/internal/catalogue/seat_data.go index e09c77ce..916b8a61 100644 --- a/internal/catalogue/seat_data.go +++ b/internal/catalogue/seat_data.go @@ -38,10 +38,10 @@ type Shape struct { // Examples are pieces every holder's template must render, one for each variant the shape has, // so a holder that cannot render one is refused at registration and not found on a machine. Examples []map[string]any - // Reads is the state on the bus a piece shows, as `.`, or nothing (novox/hq ADR - // 0255): a contributor offers only state it keeps itself, and the holder on the same machine is - // granted to read it, so the holder's manifest names no contributor. - Reads func(data map[string]any) []string `json:"-"` + // Reads is the state on the bus a piece shows, each a state and the one key of it, or nothing + // (novox/hq ADR 0260): a contributor offers only state it keeps itself, and the holder on the same + // machine is granted to read that key, so the holder's manifest names no contributor. + Reads func(data map[string]any) []StateRead `json:"-"` } // PlaceField is one field a holder places by, and the values it takes. @@ -120,25 +120,38 @@ func ownStateProblems(m Manifest, i int, r Receivable, c SeatContribution) []str keeps[s.Name] = true } var problems []string - for _, read := range r.Shape.Reads(c.Data) { - module, local, err := ReadState(read) + for _, sr := range r.Shape.Reads(c.Data) { + module, local, err := ReadState(sr.Read) switch { case err != nil: problems = append(problems, fmt.Sprintf("%s's contribution %d shows state %v", m.Module, i+1, err)) case module != m.Module || !keeps[local]: problems = append(problems, fmt.Sprintf("%s's contribution %d shows the state %s, which %s does not keep; a "+ - "module offers its own state only (novox/hq ADR 0255)", m.Module, i+1, read, m.Module)) + "module offers its own state only (novox/hq ADR 0260)", m.Module, i+1, sr.Read, m.Module)) + } + if sr.Key != "" && !stateKeyName.MatchString(sr.Key) { + problems = append(problems, fmt.Sprintf("%s's contribution %d shows the key %q; a key is lower-case letters, "+ + "digits and hyphens", m.Module, i+1, sr.Key)) } } return problems } +// StateRead is one key of another module's state that a holder reads: Read is `.`, +// and Key the one key, empty for the holder's own machine until ReadsGranted names it. +type StateRead struct { + Read, Key string +} + +// stateKeyName is a key a piece may name: a machine's name is one. +var stateKeyName = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + // ReadsGranted is the state a holder reads because modules on its machine offer it in pieces of a -// kind its seat receives as data (novox/hq ADR 0255), sorted and once each. The bus grants the holder -// these beside its own `reads`: the holder names no contributor, and reads only what is offered to it -// on the machine it runs on. -func ReadsGranted(holder Manifest, onMachine []Manifest) []string { - seen := map[string]bool{} +// kind its seat receives as data (novox/hq ADR 0260), each with the one key the piece shows — the +// machine's own when it names none — sorted and once each. The bus grants the holder these keys and +// no others: the holder names no contributor, and reads only what is offered to it where it runs. +func ReadsGranted(holder Manifest, onMachine []Manifest, machine string) []StateRead { + seen := map[StateRead]bool{} for _, claim := range holder.Claims { s, known := SeatNamed(claim.Name) if !known { @@ -153,16 +166,30 @@ func ReadsGranted(holder Manifest, onMachine []Manifest) []string { if cs, ok := SeatNamed(c.Seat); !ok || cs.Name != s.Name || c.Kind != r.Kind { continue } - for _, read := range r.Shape.Reads(c.Data) { - if module, _, err := ReadState(read); err == nil && module == m.Module && module != holder.Module { - seen[read] = true + for _, sr := range r.Shape.Reads(c.Data) { + if sr.Key == "" { + sr.Key = machine + } + if module, _, err := ReadState(sr.Read); err == nil && module == m.Module && module != holder.Module && + stateKeyName.MatchString(sr.Key) { + seen[sr] = true } } } } } } - return sortedKeys(seen) + out := make([]StateRead, 0, len(seen)) + for sr := range seen { + out = append(out, sr) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Read != out[j].Read { + return out[i].Read < out[j].Read + } + return out[i].Key < out[j].Key + }) + return out } // wholeIn is v as a whole number within [lo, hi]; JSON gives every number as a float. diff --git a/internal/catalogue/seat_data_test.go b/internal/catalogue/seat_data_test.go index a154ff90..80aea856 100644 --- a/internal/catalogue/seat_data_test.go +++ b/internal/catalogue/seat_data_test.go @@ -364,14 +364,15 @@ func TestAStateBlockIsRenderedForThisMachineAndTheHolderIsGrantedToReadIt(t *tes if bar := composedFile(t, r, "a-bar.bottom"); !strings.Contains(bar, `command = "show power.draw laptop"`) { t.Fatalf("the block does not read this machine's key:\n%s", bar) } - if got := ReadsGranted(barHolder(), []Manifest{barHolder(), power}); len(got) != 1 || got[0] != "power.draw" { + if got := ReadsGranted(barHolder(), []Manifest{barHolder(), power}, "laptop"); len(got) != 1 || + got[0] != (StateRead{Read: "power.draw", Key: "laptop"}) { t.Fatalf("the holder is granted %v", got) } // Only the holder, and only for what the modules on its machine offer. - if got := ReadsGranted(power, []Manifest{barHolder(), power}); len(got) != 0 { + if got := ReadsGranted(power, []Manifest{barHolder(), power}, "laptop"); len(got) != 0 { t.Fatalf("a contributor is granted %v", got) } - if got := ReadsGranted(barHolder(), []Manifest{barHolder()}); len(got) != 0 { + if got := ReadsGranted(barHolder(), []Manifest{barHolder()}, "laptop"); len(got) != 0 { t.Fatalf("a holder alone is granted %v", got) } } @@ -393,3 +394,14 @@ func TestAModuleOffersItsOwnStateOnly(t *testing.T) { t.Errorf("its own state was refused: %v", err) } } + +func TestPerMachineStateIsDeclaredAndKeptInItsLongForm(t *testing.T) { + m, err := ParseManifest([]byte(`{"module":"power","state":[{"name":"draw","ttl-seconds":30,"per-machine":true}]}`)) + if err != nil || len(m.State) != 1 || !m.State[0].PerMachine { + t.Fatalf("%+v %v", m.State, err) + } + raw, _ := json.Marshal(StateDeclaration{Name: "draw", PerMachine: true}) + if string(raw) != `{"name":"draw","per-machine":true}` { + t.Fatalf("%s", raw) + } +} diff --git a/internal/catalogue/state.go b/internal/catalogue/state.go index fa49f718..d21297cd 100644 --- a/internal/catalogue/state.go +++ b/internal/catalogue/state.go @@ -33,6 +33,9 @@ type StateDeclaration struct { History int `json:"history,omitempty"` // TTLSeconds is how long a value lives once written; zero is until it is replaced or deleted. TTLSeconds int `json:"ttl-seconds,omitempty"` + // PerMachine says the state is keyed by machine: each machine's instance writes the key named for + // its machine and no other, and the bus grants it that key alone (novox/hq ADR 0260). + PerMachine bool `json:"per-machine,omitempty"` } // UnmarshalJSON reads a bucket as its bare name, or as {name, history, ttl-seconds}. @@ -46,7 +49,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error { dec := json.NewDecoder(bytes.NewReader(trimmed)) dec.DisallowUnknownFields() if err := dec.Decode(&full); err != nil { - return fmt.Errorf("a state is either a name or {name, history, ttl-seconds}: %w", err) + return fmt.Errorf("a state is either a name or {name, history, ttl-seconds, per-machine}: %w", err) } *s = StateDeclaration(full) return nil @@ -54,7 +57,7 @@ func (s *StateDeclaration) UnmarshalJSON(raw []byte) error { // MarshalJSON writes back the short form when there is nothing else to say. func (s StateDeclaration) MarshalJSON() ([]byte, error) { - if s.History == 0 && s.TTLSeconds == 0 { + if s.History == 0 && s.TTLSeconds == 0 && !s.PerMachine { return json.Marshal(s.Name) } type plain StateDeclaration diff --git a/internal/catalogue/state_test.go b/internal/catalogue/state_test.go index 62297874..98eba5f9 100644 --- a/internal/catalogue/state_test.go +++ b/internal/catalogue/state_test.go @@ -39,7 +39,7 @@ func TestAStateNameIsLocalAndOneToken(t *testing.T) { {`{"module":"a.b","version":"1","state":["s"]}`, `no dot`}, {`{"module":"a","version":"1","reads":["bindings"]}`, `a read is .`}, {`{"module":"a","version":"1","reads":["a.s"]}`, `which is its own state`}, - {`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds}`}, + {`{"module":"a","version":"1","state":[{"name":"s","shared":true}]}`, `{name, history, ttl-seconds, per-machine}`}, } { _, err := ParseManifest([]byte(c.manifest)) if err == nil { diff --git a/internal/inventory/busrecords.go b/internal/inventory/busrecords.go index de46f7f6..60935291 100644 --- a/internal/inventory/busrecords.go +++ b/internal/inventory/busrecords.go @@ -80,8 +80,9 @@ func (i *Inventory) BusRecords(ctx context.Context) (broker.Records, error) { } d := declaredFor(m, seats) // And the state offered to it as a seat's holder by the modules beside it (novox/hq ADR 0255). - if granted := catalogue.ReadsGranted(m, onMachine(declared, modules)); len(granted) > 0 { - d.Reads = append(append([]string(nil), d.Reads...), granted...) + // For this machine's key alone (novox/hq ADR 0260): a bar shows its own machine's draw. + for _, sr := range catalogue.ReadsGranted(m, onMachine(declared, modules), n.Name) { + d.KeyedReads = append(d.KeyedReads, broker.KeyedRead{Read: sr.Read, Key: sr.Key}) } d.Holds = heldHere(d.Holds, holdings, n.Name, module) out.Assigned[n.Name] = append(out.Assigned[n.Name], d) @@ -182,7 +183,8 @@ func declaredFor(m catalogue.Manifest, seats map[string]catalogue.SeatDeclaratio func bucketsOf(m catalogue.Manifest) []broker.Bucket { var out []broker.Bucket for _, s := range m.State { - out = append(out, broker.Bucket{Module: m.Module, Name: s.Name, History: s.History, TTLSeconds: s.TTLSeconds}) + out = append(out, broker.Bucket{Module: m.Module, Name: s.Name, History: s.History, TTLSeconds: s.TTLSeconds, + PerMachine: s.PerMachine}) } return out } diff --git a/internal/inventory/busrecords_test.go b/internal/inventory/busrecords_test.go index b0f2a5d0..8c910dbc 100644 --- a/internal/inventory/busrecords_test.go +++ b/internal/inventory/busrecords_test.go @@ -322,16 +322,20 @@ func TestABarIsGrantedTheStateItsMachinesModulesOfferIt(t *testing.T) { if err != nil { t.Fatal(err) } - reads := func(node, module string) []string { + reads := func(node, module string) []broker.KeyedRead { for _, d := range records.Assigned[node] { if d.Module == module { - return d.Reads + if len(d.Reads) != 0 { + t.Fatalf("%s on %s reads whole buckets: %v", module, node, d.Reads) + } + return d.KeyedReads } } t.Fatalf("%s is not on %s", module, node) return nil } - if got := reads("laptop", "a-bar"); len(got) != 1 || got[0] != "power.draw" { + // The laptop's own key, and no other (novox/hq ADR 0260). + if got := reads("laptop", "a-bar"); len(got) != 1 || got[0] != (broker.KeyedRead{Read: "power.draw", Key: "laptop"}) { t.Fatalf("the laptop's bar reads %v", got) } if got := reads("desk", "a-bar"); len(got) != 0 {