Judge object buckets and history streams as ADR 0592 decides
The indexer's judge takes a module's own object bucket (a plain local name, once, no name its states take, none of the mesh's derived prefixes, 1 to 8192 MiB with no default, a grant only by a trusted value) and a stream that keeps its history (no max age, deliveries or duplicates), counts such a stream among the bus's predefined forms, gives a bucket's name one owner on the bus, and refuses a read of a bucket its owner grants nobody. The seat table expects the node-engine's compose-candidate verb (ADR 0593 §7; D5). Claude-Session: https://claude.ai/code/session_01NcbgfJ9Gum47zkFKWRaovL
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
package judge
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.novox.be/novox/mesh-sdk/go/catalogue"
|
||||
)
|
||||
|
||||
// knowledge is mesh-knowledge's manifest as ADR 0592 has it: an object bucket granted by its trusted value readers (a
|
||||
// trusted file takes it), and
|
||||
// two streams that keep their history.
|
||||
const knowledge = `{"module":"mesh-knowledge","version":"1",
|
||||
"resources":[{"id":"state","type":"directory","mode":"0700","place":"."},
|
||||
{"id":"readers","trusted":true,"type":"file","path":"${dir:state}/readers.json","mode":"0600",
|
||||
"merge":"json","takes-settings":["readers"],"content":"{\"readers\": {}}"}],
|
||||
"objects":[{"name":"sections","max-mib":2048,"grant":{"config":"readers"}}],
|
||||
"streams":[{"name":"versions","retention":"history","max-mib":4096,"events":["version.stored"]},
|
||||
{"name":"reviews","retention":"history","events":["review.stored"]}]}`
|
||||
|
||||
// ADR 0592 §5, §6, §25: a module's own object bucket and a stream that keeps its history are taken as declared; each
|
||||
// bound and form the record names is refused, saying which.
|
||||
func TestObjectBucketsAndHistoryStreamsAreJudgedAsADR0592Says(t *testing.T) {
|
||||
if _, err := ParseManifest([]byte(knowledge)); err != nil {
|
||||
t.Fatalf("mesh-knowledge's manifest was refused: %v", err)
|
||||
}
|
||||
for _, c := range []struct{ change, want string }{
|
||||
{`"max-mib":2048,"grant"`, `"max-mib":0,"grant"`}, // no size: there is no default
|
||||
{`"max-mib":2048,"grant"`, `"max-mib":8193,"grant"`}, // over the bound
|
||||
{`"name":"sections"`, `"name":"Sections"`}, // not a plain local name
|
||||
{`"name":"sections"`, `"name":"sections"},{"name":"sections","max-mib":1`}, // one bucket per name
|
||||
{`"grant":{"config":"readers"}`, `"grant":{"config":"nobody"}`}, // a grant not by a trusted value
|
||||
{`"retention":"history","max-mib":4096`, `"retention":"history","max-age-days":30`},
|
||||
{`"retention":"history","max-mib":4096`, `"retention":"history","max-deliveries":3`},
|
||||
{`"retention":"history","max-mib":4096`, `"retention":"history","duplicates":2`},
|
||||
{`"retention":"history","max-mib":4096`, `"retention":"history","max-mib":8193`},
|
||||
{`"retention":"history","max-mib":4096`, `"retention":"forever"`},
|
||||
} {
|
||||
raw := strings.Replace(knowledge, c.change, c.want, 1)
|
||||
if raw == knowledge {
|
||||
t.Fatalf("the change %q found nothing to change", c.change)
|
||||
}
|
||||
if _, err := ParseManifest([]byte(raw)); err == nil {
|
||||
t.Errorf("%s was taken", c.want)
|
||||
}
|
||||
}
|
||||
// A bucket whose name on the bus starts with what the mesh derives names with is refused.
|
||||
if _, err := ParseManifest([]byte(`{"module":"kv","version":"1","objects":[{"name":"x","max-mib":1}]}`)); err == nil ||
|
||||
!strings.Contains(err.Error(), "KV_") {
|
||||
t.Errorf("a bucket kv_x was taken: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ADR 0592 §5, §9: a bucket name has one owner on the bus, and a module reads another's bucket only where the owner
|
||||
// grants it; a history stream is one of the bus's predefined forms.
|
||||
func TestAnObjectBucketIsReadOnlyThroughItsOwnersGrant(t *testing.T) {
|
||||
parse := func(raw string) catalogue.Manifest {
|
||||
t.Helper()
|
||||
m, err := ParseManifest([]byte(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
owner := parse(knowledge)
|
||||
reader := parse(`{"module":"records-page","version":"1","reads":["mesh-knowledge.sections"]}`)
|
||||
shelf := catalogue.Shelf{"mesh-knowledge": owner, "records-page": reader}
|
||||
if p := objectProblemsOf(CatalogueProblems(shelf)); len(p) > 0 {
|
||||
t.Fatalf("a granted read was refused: %v", p)
|
||||
}
|
||||
ungranted := parse(strings.Replace(knowledge, `,"grant":{"config":"readers"}`, "", 1))
|
||||
shelf["mesh-knowledge"] = ungranted
|
||||
if p := objectProblemsOf(CatalogueProblems(shelf)); len(p) != 1 || !strings.Contains(p[0], "records-page") {
|
||||
t.Errorf("a read of a bucket its owner grants nobody: %v", p)
|
||||
}
|
||||
}
|
||||
|
||||
// objectProblemsOf are the problems that name an object bucket or a history stream.
|
||||
func objectProblemsOf(all []string) []string {
|
||||
var out []string
|
||||
for _, p := range all {
|
||||
if strings.Contains(p, "object bucket") || strings.Contains(p, "history") {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -2623,6 +2623,8 @@ func parseManifest(raw []byte, predefined catalogue.PredefinedList) (catalogue.M
|
||||
problems = append(problems, LogProblems(m)...)
|
||||
// And the streams it owns, and who may hear them (streams.go, novox/hq ADR 0363).
|
||||
problems = append(problems, StreamProblems(m)...)
|
||||
// And the object buckets it owns (novox/hq ADR 0592 §5, §9).
|
||||
problems = append(problems, ObjectsProblems(m)...)
|
||||
problems = append(problems, WorkQueueStreamProblems(m)...)
|
||||
// And the defaults it gives its settings (setting_defaults.go, novox/hq ADR 0262), or, written in the
|
||||
// config form, the whole declaration of each value (config.go, novox/hq ADR 0348).
|
||||
@@ -4550,6 +4552,8 @@ func CatalogueProblems(shelf catalogue.Shelf) []string {
|
||||
var problems []string
|
||||
// A stream name has one owner (novox/hq ADR 0363).
|
||||
problems = append(problems, StreamNameProblems(shelf)...)
|
||||
// An object bucket's name has one owner, and another module reads it only through its owner's grant (ADR 0592 §5, §9).
|
||||
problems = append(problems, ObjectsCatalogueProblems(shelf)...)
|
||||
// A module that hears a restricted stream runs as an account of its own (novox/hq ADR 0376 §2).
|
||||
problems = append(problems, RestrictedHearingProblems(shelf)...)
|
||||
// A work-queue stream is never asked for by another module's consumes (novox/hq ADR 0436 §6).
|
||||
@@ -5378,7 +5382,8 @@ func StateProblems(m catalogue.Manifest) []string {
|
||||
func PredefinedFormProblems(m catalogue.Manifest) []string {
|
||||
var out []string
|
||||
for _, s := range m.Streams {
|
||||
if !slices.Contains(catalogue.StreamsKeptUntilMoved[m.Module], s.Name) {
|
||||
// A stream that keeps its history is one of the bus's predefined forms (novox/hq ADR 0592 §6).
|
||||
if !s.KeepsHistory() && !slices.Contains(catalogue.StreamsKeptUntilMoved[m.Module], s.Name) {
|
||||
out = append(out, fmt.Sprintf("%s keeps the stream %q, outside the bus's predefined forms: keep it as a state, "+
|
||||
"a log or an object store, which the runtime reads for the module's tools and others read by `reads`", m.Module, s.Name))
|
||||
}
|
||||
@@ -5489,10 +5494,18 @@ func StreamProblems(m catalogue.Manifest) []string {
|
||||
// meaningless, and refused, without it. A work-queue stream is read by its one consumer, never granted.
|
||||
switch s.Retention {
|
||||
case "", catalogue.StreamRetentionWorkQueue:
|
||||
case catalogue.StreamRetentionHistory:
|
||||
// A stream that keeps its history (novox/hq ADR 0592 §6) keeps every message with no maximum age and
|
||||
// refuses a new one when full: an age is meaningless on it, and refused.
|
||||
if s.MaxAgeDays != 0 {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's stream %q keeps its history and declares max-age-days %d; a stream that keeps its history keeps "+
|
||||
"every message with no maximum age (novox/hq ADR 0592 §6)", m.Module, s.Name, s.MaxAgeDays))
|
||||
}
|
||||
default:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's stream %q declares retention %q; a stream's retention is %q or nothing (novox/hq ADR 0436 §6)",
|
||||
m.Module, s.Name, s.Retention, catalogue.StreamRetentionWorkQueue))
|
||||
"%s's stream %q declares retention %q; a stream's retention is %q, %q or nothing (novox/hq ADR 0436 §6, "+
|
||||
"ADR 0592 §6)", m.Module, s.Name, s.Retention, catalogue.StreamRetentionWorkQueue, catalogue.StreamRetentionHistory))
|
||||
}
|
||||
switch {
|
||||
case s.Retention == catalogue.StreamRetentionWorkQueue &&
|
||||
@@ -8774,3 +8787,105 @@ func second(_, b []string) []string { return b }
|
||||
// reservedStreamPrefixes are the starts of the stream names the mesh derives, which no module's stream may take (moved
|
||||
// from mesh-sdk's catalogue, ReservedStreamPrefixes: a rule's table is the judge's, novox/hq ADR 0578 §4).
|
||||
var reservedStreamPrefixes = []string{"SEAT_", "LOG_", "KV_", "OBJ_", "DEAD_"}
|
||||
|
||||
// ObjectsProblems is what is wrong with a manifest's own object buckets, judged on the one manifest (novox/hq ADR 0592
|
||||
// §5, §9, §25): each named locally as a state is, once, never a name its own state takes, its name on the bus taking
|
||||
// none of the prefixes the mesh derives names with, its size between catalogue.ObjectsLeastMiB and ObjectsMostMiB (no
|
||||
// default), and a grant only by a trusted config value of the module.
|
||||
func ObjectsProblems(m catalogue.Manifest) []string {
|
||||
if len(m.Objects) == 0 {
|
||||
return nil
|
||||
}
|
||||
var problems []string
|
||||
if !stateName.MatchString(m.Module) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps an object bucket, and a module's name is part of its buckets' names, which take one plain name — "+
|
||||
"no dot (novox/hq ADR 0592 §5)", m.Module))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
states := map[string]bool{}
|
||||
for _, st := range m.State {
|
||||
states[st.Name] = true
|
||||
}
|
||||
terminal := catalogue.TerminalKeys(m)
|
||||
for _, o := range m.Objects {
|
||||
switch {
|
||||
case !stateName.MatchString(o.Name):
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps object bucket %q: an object bucket is named locally — lower-case letters, digits and hyphens, "+
|
||||
"no dot and no underscore; the mesh derives the bucket (novox/hq ADR 0592 §5)", m.Module, o.Name))
|
||||
case seen[o.Name]:
|
||||
problems = append(problems, fmt.Sprintf("%s keeps object bucket %q twice: one bucket per name (novox/hq ADR 0592 §5)", m.Module, o.Name))
|
||||
case states[o.Name]:
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps both a state and an object bucket %q: a read names one of them, so each name is one (novox/hq "+
|
||||
"ADR 0592 §9)", m.Module, o.Name))
|
||||
default:
|
||||
for _, prefix := range reservedStreamPrefixes {
|
||||
if strings.HasPrefix(strings.ToUpper(o.Bucket(m.Module)), prefix) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps object bucket %q, whose name on the bus %s starts with %s, which the mesh derives names "+
|
||||
"with (novox/hq ADR 0592 §5)", m.Module, o.Name, o.Bucket(m.Module), prefix))
|
||||
}
|
||||
}
|
||||
}
|
||||
seen[o.Name] = true
|
||||
if o.MaxMiB < catalogue.ObjectsLeastMiB || o.MaxMiB > catalogue.ObjectsMostMiB {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s caps object bucket %q at %d MiB; an object bucket says its size, between %d and %d MiB (novox/hq ADR "+
|
||||
"0592 §5, §25)", m.Module, o.Name, o.MaxMiB, catalogue.ObjectsLeastMiB, catalogue.ObjectsMostMiB))
|
||||
}
|
||||
if g := o.Grant; g != nil && !slices.Contains(terminal, g.Config) {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s's object bucket %q is granted by %q, which is not a trusted config value of the module, set only at "+
|
||||
"the controller's terminal or on the operator's Approve: a grant of what a module reads is the "+
|
||||
"operator's (novox/hq ADR 0348 §8, ADR 0592 §9)", m.Module, o.Name, g.Config))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
// ObjectsCatalogueProblems are the rules of object buckets no single manifest can be judged against (novox/hq ADR 0592
|
||||
// §5, §9): a bucket's name on the bus has one owner, never another module's stream or bucket; and a module that reads
|
||||
// another's bucket (`reads` naming it) reads it only through the owner's grant, a bucket with none being read by its
|
||||
// owner alone. Which modules the grant's value names is the operator's, set at the controller's terminal, and checked
|
||||
// where the read is granted, not here.
|
||||
func ObjectsCatalogueProblems(shelf catalogue.Shelf) []string {
|
||||
var problems []string
|
||||
owner := map[string]string{} // a name on the bus, upper-cased, to the module declaring it
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
for _, s := range shelf[module].Streams {
|
||||
if _, taken := owner[strings.ToUpper(s.BusName())]; !taken {
|
||||
owner[strings.ToUpper(s.BusName())] = module
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
for _, o := range shelf[module].Objects {
|
||||
name := strings.ToUpper(o.Bucket(module))
|
||||
if first, taken := owner[name]; taken && first != module {
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s keeps object bucket %s, a name %s already takes on the bus; an object bucket's name has one owner "+
|
||||
"(novox/hq ADR 0592 §5)", module, o.Bucket(module), first))
|
||||
continue
|
||||
}
|
||||
owner[name] = module
|
||||
}
|
||||
}
|
||||
for _, module := range shelfOrder(shelf) {
|
||||
for _, r := range shelf[module].Reads {
|
||||
from, local, err := catalogue.ReadState(r)
|
||||
if err != nil {
|
||||
continue // StateProblems says why
|
||||
}
|
||||
o, ok := shelf[from].ObjectsNamed(local)
|
||||
if !ok || from == module || o.Grant != nil {
|
||||
continue
|
||||
}
|
||||
problems = append(problems, fmt.Sprintf(
|
||||
"%s reads %s's object bucket %q, which grants no reader: an object bucket is read by another module only "+
|
||||
"through its owner's grant, a trusted config value naming the reader (novox/hq ADR 0592 §9)", module, from, local))
|
||||
}
|
||||
}
|
||||
return problems
|
||||
}
|
||||
|
||||
@@ -5012,8 +5012,8 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
t.Errorf("the health seat %s is %v, %+v", c.seat, ok, s)
|
||||
}
|
||||
}
|
||||
// The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its six
|
||||
// verbs and three events, held by no assignment, and a module's claim of it refused.
|
||||
// The node seat node-engine is the node's engine's (novox/hq ADR 0563 §1): at node scope, delivering nothing, its verbs
|
||||
// (compose-candidate since ADR 0593 §7) and three events, held by no assignment, and a module's claim of it refused.
|
||||
if engine, ok := catalogue.SeatNamed(catalogue.EngineSeat); !ok || engine.Scope != catalogue.ScopeNode || engine.Delivers != "" || !engine.HeldByTheEngine {
|
||||
t.Errorf("node-engine is %+v, not a node seat delivering nothing that the engine holds", engine)
|
||||
} else {
|
||||
@@ -5025,7 +5025,7 @@ func TestTheSeatsAreAClosedSetAndEachNamesItsDecision(t *testing.T) {
|
||||
if props, _ := engine.Serves[5].Input["properties"].(map[string]any); props["id"] == nil {
|
||||
t.Errorf("acts takes no id: %v", engine.Serves[5].Input)
|
||||
}
|
||||
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose" ||
|
||||
if strings.Join(verbs, ",") != "hand-over,access-act,judge-directories,setuid-search,root-free,acts,packages,composition,compose,compose-candidate" ||
|
||||
strings.Join(engine.Emits, ",") != "asked,done,refused" {
|
||||
t.Errorf("node-engine serves %v and emits %v", verbs, engine.Emits)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user