Files
mesh-delivery/modules/mesh-grants/internal/grants/assertions.go
T
jschoubben 998fe3a515 Add mesh-grants in shadow: compose the bus's user list, memberships and assertions from the bus and compare, writing only its own state (ADR 0547 clauses 5-8, issue 1945)
The grants part must compose with the controller's own composer before it can take over the
list; in shadow it proves the two agree, names every input the bus does not carry yet, and
takes no effect. It claims no seat (mesh-sdk's seat list does not name mesh-grants for the
running composer), serves module tools, and keeps running when a read or write is refused.
2026-10-25 22:27:47 +01:00

128 lines
5.0 KiB
Go

package grants
import (
"encoding/json"
"fmt"
"slices"
"sort"
"git.novox.be/novox/mesh-sdk/go/buscompose"
"git.novox.be/novox/mesh-sdk/go/catalogue"
)
// Item is one bucket, log or stream a manifest declares, as composed, beside what the bus holds of it.
type Item struct {
Kind string `json:"kind"`
Stream string `json:"stream"`
// Composed is what an assertion would ask for; Held what the bus holds, absent when it holds no such stream.
Composed Limits `json:"composed"`
Held *Limits `json:"held,omitempty"`
// Differences says how they differ, in words; empty when they agree in what can be read from here.
Differences []string `json:"differences,omitempty"`
}
// Limits are the facts of a stream an assertion sets and the bus module's nats_streams reads back. Its size cap is not
// read back by that tool, so it is composed and not compared.
type Limits struct {
Subjects []string `json:"subjects"`
Retention string `json:"retention"`
MaxAgeHours int `json:"max_age_hours,omitempty"`
MaxPerSubject int `json:"max_per_subject,omitempty"`
MaxBytes int64 `json:"max_bytes,omitempty"`
}
// Assertion is one module's declared buckets, logs and streams, composed and compared. Asserted is always false in
// shadow: nothing is created or changed.
type Assertion struct {
Module string `json:"module"`
Manifest string `json:"manifest"`
Items []Item `json:"items"`
Asserted bool `json:"asserted"`
Says string `json:"says"`
}
// ShadowAsserts is what every assertion says in this milestone.
const ShadowAsserts = "in shadow: composed and compared, nothing asserted (ADR 0547 clause 5 asserts from take-over)"
// Composed is a module's buckets, logs and streams as an assertion would ask for them. Pure.
func Composed(m catalogue.Manifest) []Item {
var out []Item
for _, b := range bucketsOf(m) {
per := b.History
if per < 1 {
per = 1
}
out = append(out, Item{Kind: "bucket", Stream: "KV_" + b.Bucket(), Composed: Limits{
Subjects: []string{"$KV." + b.Bucket() + ".>"}, Retention: string(buscompose.RetentionLimits),
MaxAgeHours: b.TTLSeconds / 3600, MaxPerSubject: per}})
}
for _, l := range logsOf(m) {
c := l.Config()
out = append(out, Item{Kind: "log", Stream: l.Stream(), Composed: Limits{Subjects: set(c.Subjects),
Retention: string(buscompose.RetentionLimits), MaxAgeHours: int(c.MaxAge.Hours()), MaxBytes: c.MaxBytes}})
}
for _, s := range streamsOf(m) {
st := s.Stream()
out = append(out, Item{Kind: "stream", Stream: st.Name, Composed: Limits{Subjects: set(st.Subjects),
Retention: string(st.Retention), MaxAgeHours: st.MaxAge / 3600, MaxBytes: st.MaxBytes}})
}
return out
}
// HeldStreams reads every stream the bus holds through its module's nats_streams, by name.
func HeldStreams(c Caller) (map[string]Limits, error) {
raw, err := c.Call(CallStreams, map[string]any{})
if err != nil {
return nil, fmt.Errorf("%s: %w", CallStreams, err)
}
var got struct {
Detail []struct {
Name string `json:"name"`
Subjects []string `json:"subjects"`
Retention string `json:"retention"`
MaxAgeHours int `json:"max_age_hours"`
MaxPerSubject int `json:"max_per_subject"`
} `json:"detail"`
}
if err := json.Unmarshal(unwrap(raw), &got); err != nil {
return nil, fmt.Errorf("%s answered no streams: %w", CallStreams, err)
}
out := map[string]Limits{}
for _, s := range got.Detail {
out[s.Name] = Limits{Subjects: set(s.Subjects), Retention: s.Retention, MaxAgeHours: s.MaxAgeHours, MaxPerSubject: s.MaxPerSubject}
}
return out, nil
}
// Assert composes a module's items and compares each with what the bus holds; held nil is not read. It asserts nothing.
func Assert(module, digest string, m catalogue.Manifest, held map[string]Limits, heldErr error) Assertion {
a := Assertion{Module: module, Manifest: digest, Items: Composed(m), Says: ShadowAsserts}
for i := range a.Items {
it := &a.Items[i]
if heldErr != nil {
it.Differences = []string{"what the bus holds was not read: " + heldErr.Error()}
continue
}
h, ok := held[it.Stream]
if !ok {
it.Differences = []string{"the bus holds no stream " + it.Stream}
continue
}
it.Held = &h
if !slices.Equal(h.Subjects, it.Composed.Subjects) {
it.Differences = append(it.Differences, fmt.Sprintf("subjects %v held, %v composed", h.Subjects, it.Composed.Subjects))
}
if h.Retention != it.Composed.Retention {
it.Differences = append(it.Differences, fmt.Sprintf("retention %s held, %s composed", h.Retention, it.Composed.Retention))
}
if h.MaxAgeHours != it.Composed.MaxAgeHours {
it.Differences = append(it.Differences, fmt.Sprintf("age %d h held, %d h composed", h.MaxAgeHours, it.Composed.MaxAgeHours))
}
if it.Kind == "bucket" && max(h.MaxPerSubject, 1) != it.Composed.MaxPerSubject {
it.Differences = append(it.Differences, fmt.Sprintf("history %d held, %d composed", h.MaxPerSubject, it.Composed.MaxPerSubject))
}
}
sort.SliceStable(a.Items, func(i, j int) bool { return a.Items[i].Stream < a.Items[j].Stream })
return a
}