Event names are checked now, per manifest and across the catalogue
Issue 127 stood because nothing compared the two halves. Every manifest was well-formed on its own and every derivation correct on its own, and no cross-module subscription in the mesh matched anything — a subscription that matches nothing is not an error, it is silence. Two checks, because the mistake is possible at two scales. Per manifest: an event is a local name, and `module.` is refused with the name to write instead. A module emitting under what reads as another module's name is refused too, pointing at the seat, where a name outlives whoever holds it. Across the catalogue: where a consumed event's emitter is present, it must emit that event. It cannot demand a live emitter for everything — a module lives in its own repository and may be installed long before the one whose events it wants — so the rule is narrower and still catches this. It found two real dangling subscriptions the moment it ran. Wildcards were undecided and two manifests needed them: `*` is one name and `**` is the rest, spelled the mesh's way and derived to `>` here and `#` on the old bus. A manifest naming either would stop being true when the wire changed, which is the whole reason names are local. And the field documentation taught the old form, examples included — which is why the drift was uniform across 37 manifests rather than scattered. Nobody was guessing; everybody followed the comment.
This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Do the emitters and the consumers of a catalogue agree?
|
||||
//
|
||||
// **The check that was missing** (novox/hq 04-ISSUES/127). Every manifest was individually
|
||||
// well-formed and every derivation individually correct, and no cross-module subscription in the
|
||||
// mesh matched anything: a consumer's declaration derived into a namespace nobody publishes to.
|
||||
// Nothing failed, because a subscription that matches nothing is not an error — it is silence.
|
||||
//
|
||||
// The comparison has to be over the whole catalogue, because the two halves live in different
|
||||
// manifests, and it cannot simply demand that every consumed event have a live emitter: a module
|
||||
// may be installed long before the one whose events it wants. So the rule is narrower and still
|
||||
// catches this: **where the emitter is present, it must emit what the consumer asked for.**
|
||||
|
||||
// AConsumer is one module's interest in another's events, as this check needs it.
|
||||
type AConsumer struct {
|
||||
Module string
|
||||
Consumes []string
|
||||
}
|
||||
|
||||
// AnEmitter is one module's events.
|
||||
type AnEmitter struct {
|
||||
Module string
|
||||
Emits []string
|
||||
}
|
||||
|
||||
// Disagreements are the consumed events whose emitter is in the catalogue and does not emit them.
|
||||
//
|
||||
// Returned as sentences rather than as structs: every one of them is read by a person deciding
|
||||
// whether a manifest or a catalogue is wrong, and a pair of names without the reason is a puzzle.
|
||||
func Disagreements(emitters []AnEmitter, consumers []AConsumer, seats []DeclaredSeat) []string {
|
||||
emits := map[string]map[string]bool{}
|
||||
for _, e := range emitters {
|
||||
if emits[e.Module] == nil {
|
||||
emits[e.Module] = map[string]bool{}
|
||||
}
|
||||
for _, name := range e.Emits {
|
||||
emits[e.Module][name] = true
|
||||
}
|
||||
}
|
||||
// A seat's events are published by its holder under the seat's name, so a consumer naming the
|
||||
// seat is naming something real even though no module declares it as its own.
|
||||
for _, s := range seats {
|
||||
if len(s.Emits) == 0 {
|
||||
continue
|
||||
}
|
||||
if emits[s.Name] == nil {
|
||||
emits[s.Name] = map[string]bool{}
|
||||
}
|
||||
for _, name := range s.Emits {
|
||||
emits[s.Name][name] = true
|
||||
}
|
||||
}
|
||||
|
||||
var out []string
|
||||
for _, c := range consumers {
|
||||
for _, pattern := range c.Consumes {
|
||||
emitter, event, named := strings.Cut(pattern, ".")
|
||||
// Every event from everyone, or every event from one module: both are deliberate and
|
||||
// neither names a particular event to check.
|
||||
if !named || emitter == "*" || emitter == catalogueTheRest || event == catalogueTheRest {
|
||||
continue
|
||||
}
|
||||
known, present := emits[emitter]
|
||||
if !present {
|
||||
// Not installed here, which is ordinary: a module lives in its own repository and
|
||||
// may be registered later. Nothing to compare, so nothing to say.
|
||||
continue
|
||||
}
|
||||
if matchesAny(event, known) {
|
||||
continue
|
||||
}
|
||||
out = append(out, fmt.Sprintf(
|
||||
"%s consumes %q and %s emits %s — so that subscription would match nothing, and "+
|
||||
"nothing would report it",
|
||||
c.Module, pattern, emitter, listOf(known)))
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// matchesAny says whether one of an emitter's event names satisfies a consumer's pattern.
|
||||
func matchesAny(pattern string, emitted map[string]bool) bool {
|
||||
want := strings.Split(pattern, ".")
|
||||
for name := range emitted {
|
||||
if matches(want, strings.Split(name, ".")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func matches(pattern, name []string) bool {
|
||||
for i, part := range pattern {
|
||||
if part == catalogueTheRest {
|
||||
return i < len(name)
|
||||
}
|
||||
if i >= len(name) {
|
||||
return false
|
||||
}
|
||||
if part != "*" && part != name[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return len(pattern) == len(name)
|
||||
}
|
||||
|
||||
func listOf(names map[string]bool) string {
|
||||
if len(names) == 0 {
|
||||
return "nothing"
|
||||
}
|
||||
out := make([]string, 0, len(names))
|
||||
for n := range names {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return strings.Join(out, ", ")
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package broker
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// **Do the catalogue's emitters and consumers agree?**
|
||||
//
|
||||
// This is the check whose absence let issue 127 stand: every manifest was individually well-formed,
|
||||
// every derivation individually correct, and no cross-module subscription in the mesh matched
|
||||
// anything. A subscription that matches nothing is not an error — it is silence — so nothing
|
||||
// anywhere reported it.
|
||||
//
|
||||
// It compares what one manifest asks to hear against what another says it emits. It cannot demand
|
||||
// that every consumed event have a live emitter, because a module lives in its own repository and
|
||||
// may be registered long before the one whose events it wants. Where the emitter *is* here, it must
|
||||
// emit what the consumer asked for.
|
||||
func TestTheCataloguesEmittersAndConsumersAgree(t *testing.T) {
|
||||
emitters, consumers, seats := theCataloguesEvents(t)
|
||||
|
||||
if bad := Disagreements(emitters, consumers, seats); len(bad) > 0 {
|
||||
t.Fatalf("%d subscription(s) in the catalogue would match nothing:\n %s",
|
||||
len(bad), strings.Join(bad, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// And the check itself catches the thing it exists for, so it cannot pass by doing nothing.
|
||||
func TestTheAgreementCheckCatchesASubscriptionThatMatchesNothing(t *testing.T) {
|
||||
bad := Disagreements(
|
||||
[]AnEmitter{{Module: "builder", Emits: []string{"built"}}},
|
||||
[]AConsumer{{Module: "mesh-catalog", Consumes: []string{"builder.finished"}}},
|
||||
nil)
|
||||
if len(bad) != 1 {
|
||||
t.Fatalf("a consumer asking for an event its emitter does not emit was not caught: %v", bad)
|
||||
}
|
||||
if !strings.Contains(bad[0], "builder.finished") || !strings.Contains(bad[0], "built") {
|
||||
t.Fatalf("the report names neither what was asked for nor what is emitted: %s", bad[0])
|
||||
}
|
||||
|
||||
// A module that is not here is not a disagreement: it may be registered later.
|
||||
if bad := Disagreements(nil,
|
||||
[]AConsumer{{Module: "plex", Consumes: []string{"sonarr.download.completed"}}}, nil); len(bad) != 0 {
|
||||
t.Fatalf("a consumer whose emitter is not installed was reported: %v", bad)
|
||||
}
|
||||
|
||||
// A wildcard over emitters is deliberate and names no particular event to check.
|
||||
if bad := Disagreements([]AnEmitter{{Module: "sonarr", Emits: []string{"download.completed"}}},
|
||||
[]AConsumer{{Module: "plex", Consumes: []string{"*.download.completed"}}}, nil); len(bad) != 0 {
|
||||
t.Fatalf("a wildcard over emitters was reported: %v", bad)
|
||||
}
|
||||
|
||||
// An event published under a seat's name is real even though no module declares it as its own.
|
||||
if bad := Disagreements(nil,
|
||||
[]AConsumer{{Module: "watcher", Consumes: []string{"mesh-artifact-store.image.pushed"}}},
|
||||
[]DeclaredSeat{{Name: "mesh-artifact-store", Emits: []string{"image.pushed"}}}); len(bad) != 0 {
|
||||
t.Fatalf("an event a seat emits was reported as matching nothing: %v", bad)
|
||||
}
|
||||
}
|
||||
|
||||
func theCataloguesEvents(t *testing.T) ([]AnEmitter, []AConsumer, []DeclaredSeat) {
|
||||
t.Helper()
|
||||
root := filepath.Join("..", "..", "..", "mesh-catalog", "modules")
|
||||
entries, err := os.ReadDir(root)
|
||||
if err != nil {
|
||||
t.Skipf("catalogue sibling not present: %v", err)
|
||||
}
|
||||
var emitters []AnEmitter
|
||||
var consumers []AConsumer
|
||||
var seats []DeclaredSeat
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(root, e.Name(), "module.json"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
var m struct {
|
||||
Module string `json:"module"`
|
||||
Emits []string `json:"emits"`
|
||||
Consumes []string `json:"consumes"`
|
||||
Seats []struct {
|
||||
Name string `json:"name"`
|
||||
Emits []string `json:"emits"`
|
||||
} `json:"seats"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatalf("%s: %v", e.Name(), err)
|
||||
}
|
||||
if len(m.Emits) > 0 {
|
||||
emitters = append(emitters, AnEmitter{Module: m.Module, Emits: m.Emits})
|
||||
}
|
||||
if len(m.Consumes) > 0 {
|
||||
consumers = append(consumers, AConsumer{Module: m.Module, Consumes: m.Consumes})
|
||||
}
|
||||
for _, s := range m.Seats {
|
||||
seats = append(seats, DeclaredSeat{Name: s.Name, Emits: s.Emits})
|
||||
}
|
||||
}
|
||||
if len(emitters) == 0 {
|
||||
t.Skip("no manifests found beside this checkout")
|
||||
}
|
||||
return emitters, consumers, seats
|
||||
}
|
||||
@@ -85,8 +85,12 @@ func SeatStreams(seats []DeclaredSeat) []Stream {
|
||||
// package stays free of the catalogue's own types — the same reason the host mirrors the
|
||||
// contracts instead of importing the sdk.
|
||||
type DeclaredSeat struct {
|
||||
Name string
|
||||
Accepts []string
|
||||
Name string
|
||||
Accepts []string
|
||||
// Emits are the verbs the seat's holder publishes under the seat's own name. An event about a
|
||||
// role belongs here rather than in the holder's namespace, because the name then outlives
|
||||
// whoever fills it (novox/hq 04-ISSUES/127).
|
||||
Emits []string
|
||||
RetainSeconds int
|
||||
}
|
||||
|
||||
|
||||
+51
-5
@@ -241,12 +241,11 @@ func PermissionsFor(p Principal) (Permissions, error) {
|
||||
// 2. What it consumes, by the emitter's own subject — an event is addressed to its
|
||||
// emitter, because the emitter's identity is the meaning (ADR 0118).
|
||||
for _, c := range p.Consumes {
|
||||
emitter, event, ok := strings.Cut(c, ".")
|
||||
if !ok {
|
||||
return Permissions{}, fmt.Errorf(
|
||||
"%q does not name an emitter and an event: a consumed event is <module>.<event>", c)
|
||||
subject, err := consumedSubject(c)
|
||||
if err != nil {
|
||||
return Permissions{}, err
|
||||
}
|
||||
sub = append(sub, "mesh.mod."+emitter+".event."+event)
|
||||
sub = append(sub, subject)
|
||||
}
|
||||
|
||||
// 3. Seats it holds: full participation.
|
||||
@@ -331,6 +330,53 @@ func seatSubject(s Seat, kind, verb string) string {
|
||||
//
|
||||
// They are derived here, beside the permission that must match them, because two places deriving
|
||||
// the same name is how a module ends up unable to ack its own deliveries.
|
||||
// consumedSubject is where a consumed event lands, from the local pattern a module declared.
|
||||
//
|
||||
// **The mesh's wildcards become this transport's** (design 29 §1): `*` is one name on both, and `**`
|
||||
// — the rest — is `>` here. A module writes neither transport's spelling, so a manifest stays correct
|
||||
// when the wire changes, which is the whole reason names are local.
|
||||
//
|
||||
// `**` on its own is every event from every module: the emitter is any, the event is anything. An
|
||||
// audit logger wants exactly that and says so in one token.
|
||||
func consumedSubject(pattern string) (string, error) {
|
||||
if pattern == catalogueTheRest {
|
||||
return "mesh.mod.*.event.>", nil
|
||||
}
|
||||
emitter, event, named := strings.Cut(pattern, ".")
|
||||
if !named || emitter == "" || event == "" {
|
||||
return "", fmt.Errorf(
|
||||
"%q does not name an emitter and an event: a consumed event is <emitter>.<event>, or "+
|
||||
"%q for every event", pattern, catalogueTheRest)
|
||||
}
|
||||
if emitter == catalogueTheRest {
|
||||
return "", fmt.Errorf("%q stands for the rest of a name, so it cannot name the emitter", catalogueTheRest)
|
||||
}
|
||||
// Each name is checked before it becomes a subject: a name carrying a dot would add a token and
|
||||
// silently widen the permission, which is the whole reason safeSubject exists.
|
||||
var out []string
|
||||
for _, part := range strings.Split(event, ".") {
|
||||
switch part {
|
||||
case catalogueTheRest:
|
||||
out = append(out, ">")
|
||||
case "*":
|
||||
out = append(out, "*")
|
||||
default:
|
||||
if !safeSubject.MatchString(part) {
|
||||
return "", fmt.Errorf("%q cannot be part of a subject: it would widen the permission", part)
|
||||
}
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
if emitter != "*" && !safeSubject.MatchString(emitter) {
|
||||
return "", fmt.Errorf("%q cannot name an emitter: it would widen the permission", emitter)
|
||||
}
|
||||
return "mesh.mod." + emitter + ".event." + strings.Join(out, "."), nil
|
||||
}
|
||||
|
||||
// catalogueTheRest is the mesh's wildcard for "the rest of a name", duplicated from the catalogue
|
||||
// package for the one direction of dependency the build queue's name is duplicated for.
|
||||
const catalogueTheRest = "**"
|
||||
|
||||
func consumerStream(p Principal) string {
|
||||
switch p.Kind {
|
||||
case KindModule:
|
||||
|
||||
Reference in New Issue
Block a user