diff --git a/modules/mesh-delivery-indexer/judge/objects_test.go b/modules/mesh-delivery-indexer/judge/objects_test.go new file mode 100644 index 0000000..f651619 --- /dev/null +++ b/modules/mesh-delivery-indexer/judge/objects_test.go @@ -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 +} diff --git a/modules/mesh-delivery-indexer/judge/rules.go b/modules/mesh-delivery-indexer/judge/rules.go index 415ea92..c26cca3 100644 --- a/modules/mesh-delivery-indexer/judge/rules.go +++ b/modules/mesh-delivery-indexer/judge/rules.go @@ -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 +} diff --git a/modules/mesh-delivery-indexer/judge/rules_test.go b/modules/mesh-delivery-indexer/judge/rules_test.go index a85dc00..76cbab5 100644 --- a/modules/mesh-delivery-indexer/judge/rules_test.go +++ b/modules/mesh-delivery-indexer/judge/rules_test.go @@ -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) }