diff --git a/cmd/mesh-controller/modules.go b/cmd/mesh-controller/modules.go index 6ca7a67..2a91408 100644 --- a/cmd/mesh-controller/modules.go +++ b/cmd/mesh-controller/modules.go @@ -557,7 +557,7 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue user := broker.Principal{Kind: broker.KindModule, Node: node, Module: m.Module}.Username() password, err := inv.MintBusPassword(ctx, inventory.BusUser{ - Username: user, Kind: inventory.BusModule, Node: node, Module: m.Module, + Username: user, Kind: busKindOf(m.Module), Node: node, Module: m.Module, }) if err != nil { return err @@ -577,6 +577,16 @@ func issueOnTheNewBus(ctx context.Context, inv *inventory.Inventory, m catalogue return issueWith(ctx, inv, m, node, busAddress, known, reachable, user, password) } +// busKindOf is what a module's bus user is recorded as: the node's tool runtime where the module is +// the runtime (novox/hq ADR 0175), a module otherwise. The username is the same either way — the +// runtime is issued through this same path — and the kind is what a reader of the records sees. +func busKindOf(module string) string { + if module == catalogue.RuntimeModule { + return inventory.BusNodeTools + } + return inventory.BusModule +} + // issueWith is the delivery half: the minted password sealed to the machine as the module's broker // secret, and the module's consumer created where the bus can be reached. Split from the minting // so the move can issue every module against a bus whose address it worked out itself diff --git a/cmd/mesh-controller/rollout.go b/cmd/mesh-controller/rollout.go index f314642..8903c8c 100644 --- a/cmd/mesh-controller/rollout.go +++ b/cmd/mesh-controller/rollout.go @@ -346,7 +346,9 @@ func rolloutMint(ctx context.Context, again bool) error { } machines++ - case broker.KindModule: + case broker.KindModule, broker.KindNodeTools: + // The runtime is minted and delivered exactly as a module is (novox/hq ADR 0175): it is + // issued as the module it stands for, to that module's `broker` secret. if p.Module == "mesh-controller" { // The control plane is a module too, and its `broker` secret is the old bus's // credential it is still using while this runs. Writing the new bus's blob there @@ -365,7 +367,7 @@ func rolloutMint(ctx context.Context, again bool) error { skipped++ continue } - password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: inventory.BusModule, Node: p.Node, Module: p.Module}) + password, err := inv.MintBusPassword(ctx, inventory.BusUser{Username: p.Username(), Kind: busKindOf(p.Module), Node: p.Node, Module: p.Module}) if err != nil { return err } diff --git a/internal/broker/agreement_catalogue_test.go b/internal/broker/agreement_catalogue_test.go index 9a61b3d..53fdbec 100644 --- a/internal/broker/agreement_catalogue_test.go +++ b/internal/broker/agreement_catalogue_test.go @@ -234,3 +234,13 @@ func admitsSubject(pattern, subject []string) bool { } return len(pattern) == len(subject) } + +// The two packages name the runtime module separately — the broker's types stay free of the +// catalogue's on purpose — so this is what holds them to one string. A rename that reached only one +// side would compose a runtime principal for a module nobody assigns, silently, and leave the one +// that is assigned with a module's own grants. +func TestTheBrokerAndTheCatalogueAgreeOnTheRuntimeModule(t *testing.T) { + if RuntimeModule != catalogue.RuntimeModule { + t.Fatalf("the broker calls the runtime %q and the catalogue %q", RuntimeModule, catalogue.RuntimeModule) + } +} diff --git a/internal/broker/nats.go b/internal/broker/nats.go index 65d5120..e81a32d 100644 --- a/internal/broker/nats.go +++ b/internal/broker/nats.go @@ -34,8 +34,20 @@ const ( // authority is a list of tools and nothing else — not control, not declarations, not builds, // and no ability to answer anything, because a person asks. KindPerson Kind = "person" + // KindNodeTools is a machine's tool runtime (novox/hq ADR 0175, to-be 38): one process per + // node, on the host side, serving every assigned module's tools and every held seat's verbs. + // Its authority is the union of what the modules it carries would each have had for their + // tools — and nothing of what they consume, because tools are what it runs, not reactions. + KindNodeTools Kind = "node-tools" ) +// RuntimeModule is the module that IS the node's tool runtime (novox/hq ADR 0175). Where it is +// assigned, the mesh composes one runtime principal for the machine in place of that module's own, +// and the per-module containers that served tools until then stop being the way tools reach a node. +// Mirrored in the catalogue package, which the agreement test holds to the same string; one +// constant, so a rename is one edit and the two packages cannot drift. +const RuntimeModule = "node-tools" + // Seat is a role on the bus as a principal relates to it: the subjects it accepts, and those it // emits (novox/hq ADR 0118, design 29 §5). type Seat struct { @@ -74,6 +86,13 @@ type Principal struct { // a namespace no such module owns. Every service started and the graph stayed empty. Watches []Seat + // Carries are the modules whose tools this principal serves, for a KindNodeTools principal + // (novox/hq ADR 0175): every module assigned to its node, as each declares itself. Its + // serving authority is the union of theirs — each module's own tool namespace and each held + // seat's verbs on this node — derived from the same declarations the modules' own principals + // are, so the runtime can serve nothing a module could not have served for itself. + Carries []Declared + // Invokes are the tools this principal may call, as `.`; a single `*` is every // tool. A person's whole authority (design 25 §7), and a module's only if its manifest says so // (novox/hq ADR 0152) — the console's does, and nothing else's. @@ -112,7 +131,10 @@ func (p Principal) Username() string { switch p.Kind { case KindPerson: return "person." + p.Module - case KindModule: + case KindModule, KindNodeTools: + // The runtime is named exactly as the module it stands for would have been: the mesh + // issues its credential through the same path a module's takes (`module issue`), and + // that path knows the node and the module, not the kind. return p.Node + "." + p.Module case KindNode: return "node." + p.Node @@ -375,6 +397,48 @@ func PermissionsFor(p Principal) (Permissions, error) { pub = append(pub, seatToolSubject(s, t, "*")) } } + + case KindNodeTools: + // **One process serves what every module on the machine would have served for itself** + // (novox/hq ADR 0175). Each carried module's whole tool namespace — the same grant that + // module's own principal has, for the same reason: the tools a module serves are what its + // code answers, and a list here would be a second copy of it. Each held seat's verbs on + // this node, as the holder's own principal would be granted them. + for _, d := range p.Carries { + if !safeSubject.MatchString(d.Module) { + return Permissions{}, fmt.Errorf( + "%q cannot be part of a subject: a permission is a subject pattern, and this would widen it", d.Module) + } + own := "mesh.mod." + d.Module + sub = append(sub, own+".tool.>") + // A tool that emits an event is the module's code and emits under the module's name + // (ADR 0042); the runtime carrying that code may publish what the module declared it + // emits, and nothing it did not. + for _, e := range d.Emits { + pub = append(pub, own+".event."+e) + } + for _, s := range d.Holds { + for _, t := range s.Serves { + sub = append(sub, seatToolSubject(s, t, p.Node)) + } + } + } + // Every assigned module's membership on this node (ADR 0160): one per module, read + // directly from the stream and followed live. This node's and no other's — the one token + // that varies is the module, so the pattern is the machine's own assignments. + sub = append(sub, "mesh.assignment."+p.Node+".*") + pub = append(pub, "$JS.API.DIRECT.GET."+AssignmentsStream+".mesh.assignment."+p.Node+".*") + // And every tool on the mesh (ADR 0175, decision 5): any node may call any tool on any + // node, as the console already could — the runtime is the console's serving mode. + invoked, err := invokedSubjects([]string{"*"}) + if err != nil { + return Permissions{}, err + } + pub = append(pub, invoked...) + // Nothing about consumers: it consumes nothing. A module's reactions to events are its + // own long-lived process, which ADR 0175 leaves where it is; what moves here is tools. + sub = unique(sub) + pub = unique(pub) } if p.Kind == KindPerson { @@ -382,6 +446,11 @@ func PermissionsFor(p Principal) (Permissions, error) { // consumer, because nothing is delivered to a person — they ask and are answered. sub = append(sub, p.inbox()) } + if p.Kind == KindNodeTools { + // Its reply space, so the answers to what its tools call come back to it. No ack subject + // for the same reason a person has none: nothing is delivered to it. + sub = append(sub, p.inbox()) + } if p.Kind == KindModule || p.Kind == KindNode || p.Kind == KindController { // Its own reply space, and nothing wider. @@ -403,7 +472,7 @@ func PermissionsFor(p Principal) (Permissions, error) { // A module answers what it was asked — a tool call reaches it on its own namespace, so the // authority is bounded by having been asked — and so does the controller. A node and a // person are never asked anything, and are granted nothing here. - AllowResponses: p.Kind == KindModule || p.Kind == KindController, + AllowResponses: p.Kind == KindModule || p.Kind == KindController || p.Kind == KindNodeTools, }, nil } @@ -625,6 +694,20 @@ func ComposeAccounts(principals []Principal) (string, error) { return b.String(), nil } +// unique is a sorted list with each subject once. Two carried modules holding seats with the same +// verb, or the runtime module itself carried beside the others, would otherwise write a grant twice +// — harmless to the server, and noise in a file that is read as the mesh's authority model. +func unique(values []string) []string { + sort.Strings(values) + out := values[:0] + for i, v := range values { + if i == 0 || v != values[i-1] { + out = append(out, v) + } + } + return out +} + func quoted(values []string) string { if len(values) == 0 { return "" diff --git a/internal/broker/nats_test.go b/internal/broker/nats_test.go index 9456370..4479a21 100644 --- a/internal/broker/nats_test.go +++ b/internal/broker/nats_test.go @@ -371,3 +371,73 @@ func TestAModulePullsItsOwnConsumerAndNoOthers(t *testing.T) { } } } + +// The runtime's authority is the union of what the modules it carries would have been granted for +// their tools (novox/hq ADR 0175): every carried module's tool namespace, every held seat's verbs +// on this node, every module's membership on this node, and a call to anything. Nothing it +// consumes, because it reacts to nothing. +func TestTheRuntimeServesTheUnionAndConsumesNothing(t *testing.T) { + filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}} + p := Principal{Kind: KindNodeTools, Node: "anchor", Module: RuntimeModule, Carries: []Declared{ + {Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}}, + {Module: "zsh", Emits: []string{"shell.opened"}, Consumes: []string{"shop.order.placed"}}, + {Module: RuntimeModule}, + }} + perms, err := PermissionsFor(p) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + "mesh.mod.nftables.tool.>", "mesh.mod.zsh.tool.>", "mesh.mod." + RuntimeModule + ".tool.>", + "mesh.seat.node-packet-filter.tool.rules.anchor", "mesh.seat.node-packet-filter.tool.reload.anchor", + "mesh.assignment.anchor.*", + "_INBOX.anchor." + RuntimeModule + ".>", + } { + if !contains(perms.Subscribe, want) { + t.Errorf("the runtime may not subscribe %s: %v", want, perms.Subscribe) + } + } + for _, want := range []string{ + "mesh.mod.*.tool.>", "mesh.seat.*.tool.>", + "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.*", + "mesh.mod.zsh.event.shell.opened", + } { + if !contains(perms.Publish, want) { + t.Errorf("the runtime may not publish %s: %v", want, perms.Publish) + } + } + // Nothing of what a carried module consumes, and no consumer of its own to ack. + for _, s := range perms.Subscribe { + if strings.Contains(s, ".event.") || strings.HasPrefix(s, "_DELIVER.") { + t.Errorf("the runtime was granted a delivery it has no consumer for: %s", s) + } + } + for _, s := range perms.Publish { + if strings.HasPrefix(s, "$JS.ACK.") || strings.Contains(s, "CONSUMER") { + t.Errorf("the runtime was granted a consumer's subject and has no consumer: %s", s) + } + } + if !perms.AllowResponses { + t.Error("the runtime answers what it is asked, and may not reply") + } + if _, needed := ConsumerFor(p); needed { + t.Error("a consumer would be made for the runtime, which consumes nothing") + } + // Each subject once: the file is read as the mesh's authority model. + seen := map[string]bool{} + for _, s := range append(append([]string{}, perms.Subscribe...), perms.Publish...) { + if seen[s] { + t.Errorf("%s is granted twice", s) + } + seen[s] = true + } +} + +func contains(list []string, want string) bool { + for _, s := range list { + if s == want { + return true + } + } + return false +} diff --git a/internal/broker/users.go b/internal/broker/users.go index 8c82dc7..1395467 100644 --- a/internal/broker/users.go +++ b/internal/broker/users.go @@ -62,13 +62,33 @@ func Users(r Records) ([]Principal, error) { for _, node := range sortedCopy(r.Nodes) { out = append(out, Principal{Kind: KindNode, Node: node}) + // **Where the runtime is assigned, the machine gets one runtime principal in place of the + // runtime module's own** (novox/hq ADR 0175, to-be 38). It carries every module on the + // node: its serving grants are the union of theirs. Every other module keeps its own + // principal — a module still serving tools from its own container holds its own + // credential until it moves, and the two serve side by side in the meantime. + runtimeHere := false for _, d := range r.Assigned[node] { + if d.Module == RuntimeModule { + runtimeHere = true + } + } + for _, d := range r.Assigned[node] { + if runtimeHere && d.Module == RuntimeModule { + continue + } out = append(out, Principal{ Kind: KindModule, Node: node, Module: d.Module, Emits: d.Emits, Consumes: d.Consumes, Serves: d.Serves, Holds: d.Holds, Uses: d.Uses, Watches: d.Watches, Invokes: d.Invokes, }) } + if runtimeHere { + out = append(out, Principal{ + Kind: KindNodeTools, Node: node, Module: RuntimeModule, + Carries: append([]Declared(nil), r.Assigned[node]...), + }) + } } for _, node := range sortedCopy(r.Enrolling) { out = append(out, Principal{Kind: KindEnrolment, Node: node}) diff --git a/internal/broker/users_test.go b/internal/broker/users_test.go index 83e8d86..dc89aeb 100644 --- a/internal/broker/users_test.go +++ b/internal/broker/users_test.go @@ -245,3 +245,54 @@ func TestAUserListIsComposedBeforeAnythingMovesOntoTheBus(t *testing.T) { t.Errorf("the composed list does not contain the machine running the bus") } } + +// Where the runtime module is assigned, the machine gets one runtime principal in place of the +// runtime module's own (novox/hq ADR 0175, to-be 38). Every other module keeps its own: a module +// still serving tools from its own container holds its own credential until it moves. +func TestTheRuntimeModuleBecomesTheMachinesRuntimePrincipal(t *testing.T) { + r := someRecords() + r.Assigned["one"] = append(r.Assigned["one"], Declared{Module: RuntimeModule}) + users, err := Users(r) + if err != nil { + t.Fatal(err) + } + var runtime *Principal + for i := range users { + p := &users[i] + if p.Node == "one" && p.Module == RuntimeModule { + if p.Kind == KindModule { + t.Fatalf("%s on one was composed as an ordinary module beside the runtime", RuntimeModule) + } + runtime = p + } + } + if runtime == nil || runtime.Kind != KindNodeTools { + t.Fatalf("one runs %s and got no runtime principal: %v", RuntimeModule, namesOf(t, r)) + } + if runtime.Username() != "one."+RuntimeModule { + t.Errorf("the runtime is named %q; `module issue` names it as the module it stands for", runtime.Username()) + } + carried := map[string]bool{} + for _, d := range runtime.Carries { + carried[d.Module] = true + } + if !carried["telegram"] || !carried[RuntimeModule] { + t.Errorf("the runtime carries %v; it carries every module on its node", carried) + } + // And the other node, where the runtime is not assigned, is exactly as before. + for _, p := range users { + if p.Node == "two" && p.Kind == KindNodeTools { + t.Fatal("two runs no runtime and was given a runtime principal") + } + } + // A module serving its own tools beside the runtime keeps its own principal. + found := false + for _, p := range users { + if p.Kind == KindModule && p.Node == "one" && p.Module == "telegram" { + found = true + } + } + if !found { + t.Error("telegram lost its own principal when the runtime arrived on its node") + } +} diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go new file mode 100644 index 0000000..1d498dd --- /dev/null +++ b/internal/catalogue/runtime.go @@ -0,0 +1,13 @@ +package catalogue + +// The node's tool runtime, as the catalogue knows it (novox/hq ADR 0175, to-be 38). +// +// **One module is the runtime.** Where it is assigned, one process per machine serves every assigned +// module's tools and every held seat's verbs, on the host side, from the bundles each module's build +// produced — and no module needs a container to reach the bus with its tools. The name is a constant +// rather than a manifest field because a rule turns on it: the composer places the runtime's process +// where this module is, and registration refuses the old pattern once this module exists. + +// RuntimeModule is the module that is the node's tool runtime. Mirrored in the broker package, +// which composes a principal of its own for it; the agreement test there holds the two to one string. +const RuntimeModule = "node-tools" diff --git a/internal/inventory/bususers.go b/internal/inventory/bususers.go index eb26704..b0f661f 100644 --- a/internal/inventory/bususers.go +++ b/internal/inventory/bususers.go @@ -42,6 +42,9 @@ const ( BusModule = "module" BusEnrolment = "enrolment" BusPerson = "person" + // BusNodeTools is a machine's tool runtime (novox/hq ADR 0175): named like the module it + // stands for, recorded as what it is. + BusNodeTools = "node-tools" ) // MintBusPassword makes a bus password and records its hash under a username, replacing whatever was