diff --git a/go/catalogue/manifest.go b/go/catalogue/manifest.go index 568be11..ef4b2ea 100644 --- a/go/catalogue/manifest.go +++ b/go/catalogue/manifest.go @@ -612,6 +612,11 @@ type Manifest struct { // hears a part of it only where that setting's value names it. Streams []StreamDeclaration `json:"streams,omitempty"` + // Objects are the object buckets this module owns on the bus (novox/hq ADR 0592 §5): each named locally and + // bounded in size; the mesh creates each once, with no age limit, and refuses a new object when it is full. Only its + // module writes it; another reads it only through a `reads` entry and the bucket's grant (ADR 0592 §9). + Objects []ObjectsDeclaration `json:"objects,omitempty"` + // Settings are the defaults this module gives its settings (novox/hq ADR 0262): each key a file, // a contribution or a served fact asks for as `${setting:}`, its default, and why that // default. Only a preference has one — a font size, a width, a number of workers — and a value diff --git a/go/catalogue/objects.go b/go/catalogue/objects.go new file mode 100644 index 0000000..888c232 --- /dev/null +++ b/go/catalogue/objects.go @@ -0,0 +1,104 @@ +package catalogue + +import ( + "bytes" + "encoding/json" + "fmt" +) + +// A module's own object bucket (novox/hq ADR 0592 §5, §9). +// +// A module declares an object bucket as it declares a state (ADR 0201) or a stream (ADR 0363): named locally, +// with its maximum size. The mesh creates it once, keeps every object with no age limit, and refuses a new object +// when it is full rather than dropping one; only its module writes it. Another module reads it only as a restricted +// stream is heard: a `reads` entry `.` naming it, and a module the owner's trusted config value of the +// bucket's grant names (ADR 0363 §4, §5), read-only. This is the shape only: its checks are the indexer's judge's. + +// The mesh's bounds on a module's object bucket: its size in MiB (ADR 0592 §5, §25; the bound ADR 0363 §1 sets for a +// stream). A bucket says its size: there is no default. +const ( + ObjectsLeastMiB = 1 + ObjectsMostMiB = 8192 +) + +// FullWarnPercent is how full, in percent of its cap, a history stream or an object bucket is when the mesh raises its +// condition, in one place for every module (novox/hq ADR 0592 §6, §25). +const FullWarnPercent = 75 + +// ObjectsDeclaration is one object bucket a module owns. +type ObjectsDeclaration struct { + // Name is its local name, one plain name as a state's (lower-case letters, digits and hyphens). + Name string `json:"name"` + // MaxMiB bounds it, ObjectsLeastMiB to ObjectsMostMiB. + MaxMiB int `json:"max-mib"` + // Grant names the trusted config value of the module whose entries name the modules that may read it; absent, + // no other module reads it. + Grant *ObjectsGrant `json:"grant,omitempty"` +} + +// ObjectsGrant says which trusted config value of the owning module grants another module the read of a bucket +// (novox/hq ADR 0592 §9): the value has a stream grant's shape (GrantedKeys), and a module any of its keys names, wherever +// it runs or as `.`, reads the whole bucket, read-only. One value may grant a module's streams and its +// object buckets alike. +type ObjectsGrant struct { + // Config is the trusted config value of the module that grants it (ADR 0348 §8): set only at the controller's + // terminal or on the operator's Approve. + Config string `json:"config"` +} + +// UnmarshalJSON reads an object bucket strictly: an unknown key is refused here, not passed over. +func (o *ObjectsDeclaration) UnmarshalJSON(raw []byte) error { + type plain ObjectsDeclaration + var p plain + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&p); err != nil { + return fmt.Errorf("an object bucket is {name, max-mib, grant}: %w", typedUnknown(err)) + } + *o = ObjectsDeclaration(p) + return nil +} + +// UnmarshalJSON reads an object bucket's grant strictly. +func (g *ObjectsGrant) UnmarshalJSON(raw []byte) error { + type plain ObjectsGrant + var p plain + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + if err := dec.Decode(&p); err != nil { + return fmt.Errorf("an object bucket's grant is {config}: %w", typedUnknown(err)) + } + *g = ObjectsGrant(p) + return nil +} + +// ObjectsBucket is the bucket's name on the bus, as a state's is: the module and the local name joined by an +// underscore, which neither may hold, so two modules never derive one bucket. +func ObjectsBucket(module, local string) string { return module + "_" + local } + +// Bucket is this bucket's name on the bus for its module. +func (o ObjectsDeclaration) Bucket(module string) string { return ObjectsBucket(module, o.Name) } + +// ObjectsNamed is the module's object bucket of the local name, and whether it declares one. +func (m Manifest) ObjectsNamed(local string) (ObjectsDeclaration, bool) { + for _, o := range m.Objects { + if o.Name == local { + return o, true + } + } + return ObjectsDeclaration{}, false +} + +// ObjectsReader says whether the restricting config values of a bucket's grant let module, on machine, read it +// (novox/hq ADR 0592 §9): a bucket with no grant is read by its owner alone, and a value of another shape grants nothing. +func (o ObjectsDeclaration) ObjectsReader(values []any, module, machine string) bool { + if o.Grant == nil { + return false + } + for _, v := range values { + if len(GrantedKeys(v, module, machine)) > 0 { + return true + } + } + return false +} diff --git a/go/catalogue/objects_test.go b/go/catalogue/objects_test.go new file mode 100644 index 0000000..f296a66 --- /dev/null +++ b/go/catalogue/objects_test.go @@ -0,0 +1,55 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// A module declares its own object bucket and a stream that keeps its history (ADR 0592 §5, §6, §9): both are read with +// their bounds, the bucket's grant names its readers through a trusted config value as a restricted stream's does, and +// an unknown key in either is refused, not passed over. +func TestAModuleDeclaresAnObjectBucketAndAHistoryStream(t *testing.T) { + m, err := decodeForTest([]byte(`{"module":"mesh-knowledge","version":"1",` + + `"streams":[{"name":"versions","events":["version.**"],"max-mib":2048,"retention":"history","grant":{"config":"readers","events":"{key}.**"}}],` + + `"objects":[{"name":"sections","max-mib":4096,"grant":{"config":"readers"}},{"name":"own","max-mib":1}]}`)) + if err != nil { + t.Fatal(err) + } + s := m.Streams[0] + if !s.KeepsHistory() || s.Days() != 0 || s.Cap() != 2048 { + t.Fatalf("history stream read as %+v (days %d)", s, s.Days()) + } + o, ok := m.ObjectsNamed("sections") + if !ok || o.MaxMiB != 4096 || o.Grant == nil || o.Grant.Config != "readers" || o.Bucket(m.Module) != "mesh-knowledge_sections" { + t.Fatalf("object bucket read as %+v", o) + } + readers := []any{map[string]any{"records": []any{"knowledge-web", "ace.mesh-view"}}} + if !o.ObjectsReader(readers, "knowledge-web", "g14") || !o.ObjectsReader(readers, "mesh-view", "ace") || + o.ObjectsReader(readers, "mesh-view", "g14") || o.ObjectsReader(readers, "other", "ace") { + t.Fatal("the bucket's grant does not name its readers as a restricted stream's does") + } + own, _ := m.ObjectsNamed("own") + if own.ObjectsReader(readers, "knowledge-web", "g14") { + t.Fatal("a bucket with no grant is read by another module") + } + for name, raw := range map[string]string{ + "bucket": `{"module":"x","version":"1","objects":[{"name":"a","max-mib":1,"max-age-days":3}]}`, + "bucket grant": `{"module":"x","version":"1","objects":[{"name":"a","max-mib":1,"grant":{"config":"r","events":"{key}"}}]}`, + } { + if _, err := decodeStrictObjects(raw); err == nil || !strings.Contains(err.Error(), "object bucket") { + t.Errorf("%s: an unknown key was not refused in the object bucket's words: %v", name, err) + } + } +} + +func decodeStrictObjects(raw string) (Manifest, error) { + m, err := decodeForTest([]byte(raw)) + if err == nil && m.SetAside() != "" { + return m, errString(m.SetAside()) + } + return m, err +} + +type errString string + +func (e errString) Error() string { return string(e) } diff --git a/go/catalogue/streams.go b/go/catalogue/streams.go index 86cb60f..884a29b 100644 --- a/go/catalogue/streams.go +++ b/go/catalogue/streams.go @@ -35,6 +35,12 @@ const GrantKey = "{key}" // is the mesh's own limits retention — kept until it ages or the stream fills, dropping its oldest. const StreamRetentionWorkQueue = "work-queue" +// StreamRetentionHistory is a stream's declared retention that keeps its history (novox/hq ADR 0592 §6): every message +// is kept with no maximum age, and when the stream reaches its maximum size the bus refuses a new message rather than +// dropping its oldest. It takes no max-age-days, max-deliveries or duplicates; the mesh raises a condition when it +// passes FullWarnPercent of its cap. +const StreamRetentionHistory = "history" + // StreamLeastDeliveries and StreamMostDeliveries bound a work-queue stream's max-deliveries (novox/hq ADR // 0436 §6): how many times a message is handed over before it is given up as a dead letter. const ( @@ -57,7 +63,8 @@ type StreamDeclaration struct { // Grant restricts it, when said. Grant *StreamGrant `json:"grant,omitempty"` // Retention is how the stream decides what to keep when it fills (novox/hq ADR 0436 §6): empty for the - // mesh's own limits retention (dropping its oldest), or StreamRetentionWorkQueue. + // mesh's own limits retention (dropping its oldest), StreamRetentionWorkQueue, or StreamRetentionHistory + // (novox/hq ADR 0592 §6). Retention string `json:"retention,omitempty"` // MaxDeliveries bounds how many times a work-queue message is handed over, 1 to 10; refused without // retention work-queue (novox/hq ADR 0436 §6). @@ -104,14 +111,21 @@ func (g *StreamGrant) UnmarshalJSON(raw []byte) error { return nil } -// Days is the stream's age in days, the default where none is said. +// Days is the stream's age in days, the default where none is said, and 0 (no maximum age) for a stream that keeps its +// history. func (s StreamDeclaration) Days() int { + if s.KeepsHistory() { + return 0 + } if s.MaxAgeDays == 0 { return StreamDefaultDays } return s.MaxAgeDays } +// KeepsHistory says the stream keeps every message and refuses a new one when full (StreamRetentionHistory). +func (s StreamDeclaration) KeepsHistory() bool { return s.Retention == StreamRetentionHistory } + // Cap is the stream's size in MiB, the default where none is said. func (s StreamDeclaration) Cap() int { if s.MaxMiB == 0 {