From 1f86ed4135eac097ab4bc68f893a696511c5ce46 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 18:16:14 +0200 Subject: [PATCH 1/4] One runtime principal per node carries every assigned module's tools (hq ADR 0175, to-be 38 WP2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where the node-tools module is assigned, the machine's bus user list gains one principal of kind node-tools in place of that module's own: it may subscribe every carried module's tool namespace and every held seat's verbs on its node, read and follow every membership on its node, call any tool anywhere, answer what it is asked — and consume nothing, because tools are what it runs. Every other module keeps its own principal, so a module still serving tools from its container holds its own credential until it moves. Named exactly as the module it stands for, so `module issue` and `rollout mint` deliver its credential through the path a module's already takes, into node-tools' own `broker` secret. The runtime module's name is one constant in each of the broker and catalogue packages, held to one string by the agreement test, because a rule turns on it. --- cmd/mesh-controller/modules.go | 12 ++- cmd/mesh-controller/rollout.go | 6 +- internal/broker/agreement_catalogue_test.go | 10 +++ internal/broker/nats.go | 87 ++++++++++++++++++++- internal/broker/nats_test.go | 70 +++++++++++++++++ internal/broker/users.go | 20 +++++ internal/broker/users_test.go | 51 ++++++++++++ internal/catalogue/runtime.go | 13 +++ internal/inventory/bususers.go | 3 + 9 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 internal/catalogue/runtime.go 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 From b1df688c6231a5eaf4a878f8e907e9bc9847bee2 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 18:19:10 +0200 Subject: [PATCH 2/4] A module's tools bundle reaches the machine as an archive where the runtime runs (hq ADR 0175, to-be 38 WP2.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The resolved manifest now carries what the build compiled — each bundle's source, digest, language and entrypoints — because a tools bundle is named by no resource of the module's own: the node's runtime loads it, and until this the mesh held no trace of the one artifact that runtime needs. A repository manifest that writes `bundles` beside its build is refused: the mesh derives it. Where the runtime module is in a node's set, every assigned module's bundle with entrypoints is composed as an archive under the mesh's own directory, routed through the artifact store like any image or archive the mesh built. A bundle without entrypoints is run rather than loaded and is delivered by the process that runs it. A node without the runtime is sent exactly what it was. --- internal/catalogue/build.go | 18 +++++ internal/catalogue/declaration.go | 7 ++ internal/catalogue/manifest.go | 36 +++++++++ internal/catalogue/runtime.go | 50 +++++++++++++ internal/catalogue/runtime_test.go | 116 +++++++++++++++++++++++++++++ 5 files changed, 227 insertions(+) create mode 100644 internal/catalogue/runtime_test.go diff --git a/internal/catalogue/build.go b/internal/catalogue/build.go index cf73864..e52965e 100644 --- a/internal/catalogue/build.go +++ b/internal/catalogue/build.go @@ -67,6 +67,24 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) { out := m out.Build = nil out.Resources = nil + // What the build compiled, kept on the resolved manifest (novox/hq ADR 0175): a tools bundle is + // named by no resource of the module's own — the node's runtime loads it — so this is the only + // place the mesh would otherwise not have it. In artifact order, so two resolutions of one + // build compare equal. + out.Bundles = nil + if m.Build != nil { + for _, a := range m.Build.Artifacts { + if a.Kind != ArtifactBundle { + continue + } + made := by[a.Name] + out.Bundles = append(out.Bundles, Bundle{ + Name: a.Name, Source: made.Reference, Digest: made.Digest, + Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...), + }) + } + sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name }) + } for _, r := range m.Resources { named, _ := r["artifact"].(string) if named == "" { diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index f302e0e..c9fb22b 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -512,6 +512,13 @@ func (r Resolution) compose(with Rendering, owner map[string]string, "id": NeedID(name), "type": "file", "path": m.OwnSecrets[name].Path, "sealed": sealed, })) } + // This module's tools bundles, where the machine runs the node's tool runtime (novox/hq + // ADR 0175, to-be 38 WP2). Mesh-computed like everything above it, and before the module's + // own resources for the same reason: the runtime's process names the files inside these + // and is restarted when one changes, so they are on the machine before it is. + if r.runtimeHere() { + first = append(first, bundleArchives(m)...) + } // Operator-owned paths this module is granted use of (novox/hq ADR 0051). Written before // the module's own resources, and so before the container that mounts them: the host must // find each present — refusing clearly if the operator has not provided it — before it diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 1a532ec..1854cd7 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -572,6 +572,33 @@ type Manifest struct { // a module that could ask for it could read every credential on the bus — and the claim on // `mesh-broker` is what authorises it, checked from this manifest alone. BusUsers string `json:"bus-users,omitempty"` + + // Bundles are this module's compiled bundles as the build produced them: what each is called, + // where it is, what it hashes to, what language it is in and which files a tool runtime loads + // from it (novox/hq ADR 0175, to-be 38). + // + // **Derived, never written.** The manifest in a repository says `build.artifacts`; the manifest + // the mesh holds says what came out, the way a resource naming an artifact comes to name a + // digest. Kept here because a tools bundle is referenced by no resource of the module's own — + // the node's runtime loads it, and the runtime is composed by the mesh — so without this the + // resolved manifest would carry no trace of the one artifact the runtime needs. A repository + // manifest that writes this beside a build is refused: it would be stating the build's output + // by hand. + Bundles []Bundle `json:"bundles,omitempty"` +} + +// Bundle is one compiled bundle after it exists, as the resolved manifest carries it. +type Bundle struct { + Name string `json:"name"` + // Source is where a machine fetches it, kept without the store's address like every reference + // the mesh records (artifacts.go); Digest is what it must hash to. + Source string `json:"source"` + Digest string `json:"digest"` + // Language is what it was compiled from, which is what says how it is run. + Language string `json:"language,omitempty"` + // Entrypoints are the compiled files a tool runtime loads from it, relative to its root; empty + // for a bundle that is run rather than loaded. + Entrypoints []string `json:"entrypoints,omitempty"` } // Build says how to produce this module's artifacts from its source. @@ -1333,6 +1360,15 @@ func ParseManifest(raw []byte) (Manifest, error) { // // Refused here because the alternative is a build that never returns, on a mesh new enough // that nobody is watching it yet. + if m.Build != nil && len(m.Bundles) > 0 { + // The output of a build, written beside the build that produces it (ADR 0175). A resource + // naming a digest beside an `artifact` would be the same mistake, and is caught the same way: + // what the mesh derives, a repository does not state. + problems = append(problems, fmt.Sprintf( + "%s writes `bundles` beside its build. The mesh derives that from what the build "+ + "produced; a manifest states `build.artifacts` and nothing about what came out", + m.Module)) + } if m.Build != nil && len(m.Build.Artifacts) > 0 { for _, o := range m.Offers() { if o != ArtifactStoreProvision { diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index 1d498dd..de8a6bb 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -11,3 +11,53 @@ package catalogue // 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" + +// BundleRoot is where a machine keeps the tools bundles the mesh delivers to it: under the mesh's +// own directory, beside the daemons the host unpacks there, and never where a package manager also +// writes. One directory per module, one per bundle beneath it, at a path that does not move with +// the version — so the runtime's process names each entrypoint once and is restarted, not +// recomposed, when a bundle changes. +const BundleRoot = "/var/lib/mesh/bundles" + +// BundleID names the archive resource that delivers one of a module's bundles; prefixed with the +// module like every resource of its own. +func BundleID(bundle string) string { return "bundle-" + bundle } + +// BundlePath is where one module's bundle is unpacked on a machine. +func BundlePath(module, bundle string) string { return BundleRoot + "/" + module + "/" + bundle } + +// runtimeHere says whether this node's set includes the runtime module, which is what decides +// whether anything about tools changes on the machine (to-be 38 WP2): until the runtime is assigned, +// a node is sent exactly what it was sent before, bundles included, because a bundle nothing loads +// is bytes nobody reads. +func (r Resolution) runtimeHere() bool { + for _, m := range r.Modules { + if m.Module == RuntimeModule { + return true + } + } + return false +} + +// bundleArchives is one archive per tools bundle of a module — a bundle with entrypoints, which a +// runtime LOADS — as the host fetches and unpacks any artifact (novox/hq ADR 0175 §3: a module brings +// its tools as a bundle, delivered by the host like any artifact, never an image). A bundle without +// entrypoints is run rather than loaded: a daemon, a step, the runtime itself — delivered by the +// process that runs it, and not again here. +// +// The source is the kept reference; the per-resource pass that follows routes it through the +// artifact store as this network reaches it now, as it does every image and archive the mesh built. +func bundleArchives(m Manifest) []map[string]any { + var out []map[string]any + for _, b := range m.Bundles { + if len(b.Entrypoints) == 0 { + continue + } + out = append(out, map[string]any{ + "id": BundleID(b.Name), "type": "archive", + "source": b.Source, "digest": b.Digest, + "path": BundlePath(m.Module, b.Name), + }) + } + return out +} diff --git a/internal/catalogue/runtime_test.go b/internal/catalogue/runtime_test.go new file mode 100644 index 0000000..229499c --- /dev/null +++ b/internal/catalogue/runtime_test.go @@ -0,0 +1,116 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The node's tool runtime (novox/hq ADR 0175, to-be 38): where the runtime module is assigned, a +// machine is sent every assigned module's tools bundle as an archive, and the runtime's own process +// loading them. Where it is not, the machine is sent exactly what it was sent before. + +var bundleDigest = "sha256:" + strings.Repeat("b", 64) + +// aToolsModule is a module whose tools come as a compiled bundle and nothing else — the shape every +// module takes once its tool container goes (to-be 38 WP4). +func aToolsModule(t *testing.T, name string, entrypoints ...string) Manifest { + t.Helper() + m := Manifest{Module: name, Version: "1", Tools: []string{"status"}, + Build: &Build{Artifacts: []Artifact{ + {Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: entrypoints}, + }}} + resolved, err := m.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + name + "/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + return resolved +} + +// theRuntime is the runtime module as the catalogue holds it: its own bundle, run rather than +// loaded, and its broker secret to receive the node's credential in. +func theRuntime(t *testing.T) Manifest { + t.Helper() + m := Manifest{Module: RuntimeModule, Version: "1", + OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "typescript"}}}} + resolved, err := m.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + return resolved +} + +func TestABuildsBundlesAreCarriedOnTheResolvedManifest(t *testing.T) { + m := aToolsModule(t, "nftables", "tools/index.js") + if len(m.Bundles) != 1 { + t.Fatalf("the resolved manifest carries %d bundle(s), not the one the build made", len(m.Bundles)) + } + b := m.Bundles[0] + if b.Name != "tools" || b.Digest != bundleDigest || b.Language != "typescript" || + b.Source != ArtifactStoreScheme+"nftables/tools/blobs/"+bundleDigest || + len(b.Entrypoints) != 1 || b.Entrypoints[0] != "tools/index.js" { + t.Errorf("the bundle is carried as %+v", b) + } + // A repository manifest may not write what the build derives. + raw := `{"module":"x","version":"1","build":{"artifacts":[{"name":"t","kind":"bundle","language":"typescript"}]},` + + `"bundles":[{"name":"t","source":"s","digest":"` + bundleDigest + `"}]}` + if _, err := ParseManifest([]byte(raw)); err == nil || !strings.Contains(err.Error(), "bundles") { + t.Errorf("a manifest stating its build's output by hand was accepted: %v", err) + } +} + +func TestEveryToolsBundleIsDeliveredWhereTheRuntimeRuns(t *testing.T) { + store := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} + nftables := aToolsModule(t, "nftables", "tools/index.js") + zsh := aToolsModule(t, "zsh", "tools/index.js", "tools/more.js") + + t.Run("with the runtime, one archive per tools bundle", func(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{nftables, zsh, theRuntime(t)}} + out, err := r.Declaration(store) + if err != nil { + t.Fatal(err) + } + archive := fileNamed(out, "nftables."+BundleID("tools")) + if archive == nil { + t.Fatalf("nftables' tools bundle was not delivered: %v", ids(out)) + } + if archive["type"] != "archive" || archive["digest"] != bundleDigest || + archive["path"] != BundleRoot+"/nftables/tools" { + t.Errorf("delivered as %v", archive) + } + if archive["source"] != "http://anchor.internal:5101/v2/nftables/tools/blobs/"+bundleDigest { + t.Errorf("fetched from %v, not through the store as this network reaches it", archive["source"]) + } + if fileNamed(out, "zsh."+BundleID("tools")) == nil { + t.Errorf("zsh's tools bundle was not delivered: %v", ids(out)) + } + // The runtime's own bundle is run, not loaded: its process delivers it, not an archive. + if fileNamed(out, RuntimeModule+"."+BundleID("runtime")) != nil { + t.Error("the runtime's own bundle was delivered as an archive beside its process") + } + }) + + t.Run("without the runtime, nothing changes", func(t *testing.T) { + r := Resolution{Node: "anchor", Modules: []Manifest{nftables, zsh}} + out, err := r.Declaration(store) + if err != nil { + t.Fatal(err) + } + for _, id := range ids(out) { + if strings.Contains(id, BundleID("")) { + t.Errorf("%s was delivered to a machine running no runtime to load it", id) + } + } + }) +} + +func ids(out []map[string]any) []string { + var names []string + for _, r := range out { + names = append(names, r["id"].(string)) + } + return names +} From 9d4d847dc43d5a20a44f1673a7cd694076d27c51 Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 18:26:54 +0200 Subject: [PATCH 3/4] The machine runs one tool runtime, loading every delivered bundle (hq ADR 0175, to-be 38 WP2.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Where node-tools is in a node's set, the declaration ends with one process: the runtime module's own bundle, run from its one entrypoint by its language's interpreter, told in MESH_TOOL_MODULES every = the machine's bundles load, where its credential is (the module's own broker secret as this node places it), and — on a machine with an operator account — who the operator is, running as that account so a tool that needs root can escalate as the operator would. Restarted when any bundle it loads or the credential changes. A machine with no account runs it as root without the two operator words; a machine without the runtime is sent nothing new. A bundle says which of its entrypoints the runtime LOADS (`loads`), because one bundle may carry a daemon beside its tools and importing the daemon into the runtime would start it there; absent, a module declaring tools has every entrypoint loaded. And the TypeScript toolchain is rooted at the module, so an entrypoint lands at the path it is named by — the runtime loading bundles by their declared paths is what made the compiler's common-directory default visible. --- internal/broker/membership_test.go | 34 +++++++ internal/builder/toolchain.go | 8 +- internal/catalogue/build.go | 21 +++++ internal/catalogue/declaration.go | 11 +++ internal/catalogue/manifest.go | 17 +++- internal/catalogue/runtime.go | 144 ++++++++++++++++++++++++++++- internal/catalogue/runtime_test.go | 90 +++++++++++++++++- 7 files changed, 316 insertions(+), 9 deletions(-) diff --git a/internal/broker/membership_test.go b/internal/broker/membership_test.go index 055438c..5bb335c 100644 --- a/internal/broker/membership_test.go +++ b/internal/broker/membership_test.go @@ -73,3 +73,37 @@ func TestAnAccountMayReadItsOwnMembershipAndNoOthers(t *testing.T) { has(t, perms.Publish, "$JS.API.DIRECT.GET.ASSIGNMENTS.mesh.assignment.anchor.postgres") hasNot(t, perms.Subscribe, "mesh.assignment.>") } + +// The runtime arriving on a machine changes nothing about what each module is issued (to-be 38 WP2): +// the memberships are composed as before and the runtime reads several of them. What the machine's +// user list gains is one runtime principal, and loses nothing but the runtime module's own. +func TestTheRuntimeArrivingLeavesEveryMembershipAsItWas(t *testing.T) { + filter := Seat{Name: "node-packet-filter", Scope: "node", Serves: []string{"rules", "reload"}} + three := []Declared{ + {Module: "nftables", Holds: []Seat{filter}, Serves: []string{"firewall_rules"}}, + {Module: "zsh", Serves: []string{"execute"}}, + {Module: "systemd", Serves: []string{"units"}}, + } + before := Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{"anchor": three}} + after := Records{Nodes: []string{"anchor"}, Assigned: map[string][]Declared{ + "anchor": append(append([]Declared{}, three...), Declared{Module: RuntimeModule}), + }} + for _, d := range three { + was := MembershipFor("anchor", d, PlacementsOf(before, nil)) + is := MembershipFor("anchor", d, PlacementsOf(after, nil)) + if !reflect.DeepEqual(was, is) { + t.Errorf("%s's membership changed when the runtime arrived:\n%+v\n%+v", d.Module, was, is) + } + } + users, err := Users(after) + if err != nil { + t.Fatal(err) + } + kinds := map[Kind]int{} + for _, p := range users { + kinds[p.Kind]++ + } + if kinds[KindNodeTools] != 1 || kinds[KindModule] != 3 || kinds[KindNode] != 1 || kinds[KindController] != 1 { + t.Errorf("the machine's users are %v; one runtime, the three modules, the host and the controller", kinds) + } +} diff --git a/internal/builder/toolchain.go b/internal/builder/toolchain.go index c34a3b3..0e2d50a 100644 --- a/internal/builder/toolchain.go +++ b/internal/builder/toolchain.go @@ -107,10 +107,16 @@ var toolchains = []Toolchain{ // symlinks to a launcher that requires its library relatively — and the base image's own // assembly resolves them away, leaving a launcher whose relative require points nowhere. // Every module's hand-written Dockerfile had to know this. Now none of them does. + // **Rooted at the module, so an entrypoint lands where it is named.** Without a root the + // compiler takes the common directory of the files it is given: a module compiling only + // `tools/index.ts` had its output at `index.js`, and the entrypoint it declared — + // `tools/index.js`, "named as it will be found" — named a file the bundle did not + // contain. The runtime that loads bundles by their declared entrypoints (novox/hq ADR + // 0175) is what made this visible. Compile: []string{ "node", "/app/node_modules/typescript/bin/tsc", "--module", "NodeNext", "--moduleResolution", "NodeNext", - "--target", "ES2022", + "--target", "ES2022", "--rootDir", ".", }, OutputFlag: "--outDir", Unit: UnitSources, diff --git a/internal/catalogue/build.go b/internal/catalogue/build.go index e52965e..7caa350 100644 --- a/internal/catalogue/build.go +++ b/internal/catalogue/build.go @@ -78,9 +78,16 @@ func (m Manifest) Resolve(built []Built) (Manifest, error) { continue } made := by[a.Name] + // What the runtime loads: what the artifact said, else every entrypoint of a module + // that declares tools, else nothing (the field's own rule; see Artifact.Loads). + loads := append([]string(nil), a.Loads...) + if a.Loads == nil && len(m.Tools) > 0 { + loads = append([]string(nil), a.Entrypoints...) + } out.Bundles = append(out.Bundles, Bundle{ Name: a.Name, Source: made.Reference, Digest: made.Digest, Language: a.Language, Entrypoints: append([]string(nil), a.Entrypoints...), + Loads: loads, }) } sort.Slice(out.Bundles, func(i, j int) bool { return out.Bundles[i].Name < out.Bundles[j].Name }) @@ -209,6 +216,20 @@ func (b *Build) problems(module string) []string { "%s: %q is a bundle and says no language, so nothing can choose a compiler "+ "for it", module, a.Name)) } + // What the runtime loads is among what was compiled (ADR 0175): a name here that is + // not an entrypoint is a file the bundle does not contain, and the runtime would + // fail to import it on every machine rather than here. + for _, load := range a.Loads { + found := false + for _, e := range a.Entrypoints { + found = found || e == load + } + if !found { + problems = append(problems, fmt.Sprintf( + "%s: %q says the runtime loads %q, which is not among its entrypoints — "+ + "what is loaded is compiled, so it is named there too", module, a.Name, load)) + } + } // **A system, for a language that compiles to a binary** (novox/hq ADR 0142). A binary // is pinned to one operating system at link time so a host refuses to touch a machine // it was not built for (novox/hq ADR 0005); an artifact that says nothing would be diff --git a/internal/catalogue/declaration.go b/internal/catalogue/declaration.go index c9fb22b..14333ba 100644 --- a/internal/catalogue/declaration.go +++ b/internal/catalogue/declaration.go @@ -892,6 +892,17 @@ func (r Resolution) compose(with Rendering, owner map[string]string, out = append(out, fact) } } + // The node's tool runtime, last (novox/hq ADR 0175, to-be 38 WP2.3): one process loading every + // bundle delivered above and holding the credential sealed above, so both exist before it starts + // — the order written here is the order the machine applies. + if r.runtimeHere() { + process, err := r.runtimeProcess(with) + if err != nil { + return nil, err + } + owner[fmt.Sprint(process["id"])] = RuntimeModule + out = append(out, process) + } if with.Adopted { // First, before anything a module declares: what the mesh needs reachable, then its guard. // The order a machine applies is the order written here. diff --git a/internal/catalogue/manifest.go b/internal/catalogue/manifest.go index 1854cd7..7a17c1f 100644 --- a/internal/catalogue/manifest.go +++ b/internal/catalogue/manifest.go @@ -596,9 +596,12 @@ type Bundle struct { Digest string `json:"digest"` // Language is what it was compiled from, which is what says how it is run. Language string `json:"language,omitempty"` - // Entrypoints are the compiled files a tool runtime loads from it, relative to its root; empty - // for a bundle that is run rather than loaded. + // Entrypoints are the compiled files it was built around, relative to its root. Entrypoints []string `json:"entrypoints,omitempty"` + // Loads are the entrypoints a node's tool runtime imports from it: what the artifact said, or + // every entrypoint for a module declaring tools that said nothing. Empty for a bundle that is + // run rather than loaded. + Loads []string `json:"loads,omitempty"` } // Build says how to produce this module's artifacts from its source. @@ -735,6 +738,16 @@ type Artifact struct { // somebody adds a helper. An empty list is a bundle that is run rather than loaded — a // provisioner or a step, named by whatever runs it. Entrypoints []string `json:"entrypoints,omitempty"` + + // Loads are the entrypoints of this bundle the node's tool runtime loads (novox/hq ADR 0175, + // to-be 38): the module's tool code, each file registering its tools as it is imported. A + // subset of Entrypoints, for a bundle that also carries things that are RUN — a daemon, a + // step, a report — and must not have them imported into the runtime. + // + // Absent means every entrypoint, for a module that declares `tools`: a bundle holding the + // module's tools and nothing else is the ordinary case and should not have to say the same + // list twice. A module declaring no tools has nothing the runtime loads, whatever it compiles. + Loads []string `json:"loads,omitempty"` } // Kinds an artifact may be. diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index de8a6bb..ed6d251 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -1,5 +1,11 @@ package catalogue +import ( + "fmt" + "sort" + "strings" +) + // 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 @@ -39,10 +45,10 @@ func (r Resolution) runtimeHere() bool { return false } -// bundleArchives is one archive per tools bundle of a module — a bundle with entrypoints, which a -// runtime LOADS — as the host fetches and unpacks any artifact (novox/hq ADR 0175 §3: a module brings -// its tools as a bundle, delivered by the host like any artifact, never an image). A bundle without -// entrypoints is run rather than loaded: a daemon, a step, the runtime itself — delivered by the +// bundleArchives is one archive per tools bundle of a module — a bundle the runtime LOADS something +// from — as the host fetches and unpacks any artifact (novox/hq ADR 0175 §3: a module brings its +// tools as a bundle, delivered by the host like any artifact, never an image). A bundle it loads +// nothing from is run rather than loaded: a daemon, a step, the runtime itself — delivered by the // process that runs it, and not again here. // // The source is the kept reference; the per-resource pass that follows routes it through the @@ -50,7 +56,7 @@ func (r Resolution) runtimeHere() bool { func bundleArchives(m Manifest) []map[string]any { var out []map[string]any for _, b := range m.Bundles { - if len(b.Entrypoints) == 0 { + if len(b.Loads) == 0 { continue } out = append(out, map[string]any{ @@ -61,3 +67,131 @@ func bundleArchives(m Manifest) []map[string]any { } return out } + +// RuntimeProcessID names the one process the mesh composes for a machine's runtime; prefixed with +// the runtime module like a resource of its own, because that module is what the host sees it as. +func RuntimeProcessID() string { return "runtime" } + +// RuntimeToolModules is the variable the runtime reads the modules it serves from: one +// `=` per file it loads, comma-separated — several entries may name one module. +// RuntimeBrokerFile is where it reads the node's credential; RuntimeOperatorAccount and +// RuntimeOperatorHome are the machine's operator account and home, handed to every tool's +// environment (to-be 38 WP1), and absent on a machine with no account. +const ( + RuntimeToolModules = "MESH_TOOL_MODULES" + RuntimeBrokerFile = "MESH_BROKER_FILE" + RuntimeOperatorAccount = "MESH_OPERATOR_ACCOUNT" + RuntimeOperatorHome = "MESH_OPERATOR_HOME" +) + +// interpreterFor is how a bundle in a language is run: the program the host's unit starts, with the +// bundle's entrypoint after it. The one thing the composer takes from a language, and said here +// rather than in a manifest because the runtime's process is the mesh's to compose (to-be 38 WP3). +func interpreterFor(language string) (string, error) { + switch language { + case "typescript": + return "node", nil + } + return "", fmt.Errorf( + "%s is written in %q, and the mesh knows no interpreter to run a %q bundle with", + RuntimeModule, language, language) +} + +// runtimeProcess is the one process a machine runs the node's tool runtime as (novox/hq ADR 0175, +// to-be 38 WP2.3): the runtime module's own bundle, run by its language's interpreter, told which +// modules it serves and from which files, where its credential is, and who the machine's operator +// is — and restarted when any bundle it loads or the credential it holds changes. +// +// Composed from the placed manifests, so the credential's path is where this node puts it. The +// runtime runs as the operator's account when the machine has one, which is what lets a tool that +// needs root escalate as the operator would (ADR 0175 §4); on a machine with no account it runs as +// root, and the two operator words are not set. +func (r Resolution) runtimeProcess(with Rendering) (map[string]any, error) { + var runtime *Manifest + for i := range r.Modules { + if r.Modules[i].Module == RuntimeModule { + runtime = &r.Modules[i] + } + } + if runtime == nil { + return nil, nil + } + if len(runtime.Bundles) != 1 { + return nil, fmt.Errorf( + "%s is assigned to %s and its build produced %d bundle(s); the runtime is one bundle "+ + "the mesh runs, so the module declares exactly one (novox/hq to-be 38)", + RuntimeModule, r.Node, len(runtime.Bundles)) + } + bundle := runtime.Bundles[0] + if len(bundle.Entrypoints) != 1 { + return nil, fmt.Errorf( + "%s's bundle %q names %d entrypoint(s); the runtime is run from one, so the module "+ + "declares exactly one (novox/hq to-be 38)", RuntimeModule, bundle.Name, len(bundle.Entrypoints)) + } + interpreter, err := interpreterFor(bundle.Language) + if err != nil { + return nil, err + } + credential, declared := runtime.OwnSecrets["broker"] + if !declared { + return nil, fmt.Errorf( + "%s declares no own secret named broker, and the node's credential is delivered there: "+ + "a module that speaks on the bus declares \"own-secrets\": {\"broker\": }", + RuntimeModule) + } + + // What it serves, and from which files: every module on this machine that composes here, in + // name order, each bundle it loads from in the order the manifest gave. A module left out of + // the declaration — a filter on an adopted machine — is left out of this too, or the runtime + // would be told to load files that were never delivered. + var served []string + var restartOn []string + for _, m := range r.Modules { + if with.Adopted && m.Filtering != nil { + continue + } + for _, b := range m.Bundles { + if len(b.Loads) == 0 { + continue + } + for _, load := range b.Loads { + served = append(served, m.Module+"="+BundlePath(m.Module, b.Name)+"/"+load) + } + restartOn = append(restartOn, m.Module+"."+BundleID(b.Name)) + } + } + sort.Strings(served) + restartOn = append(restartOn, RuntimeModule+"."+NeedID("broker")) + sort.Strings(restartOn) + + env := map[string]string{ + RuntimeToolModules: strings.Join(served, ","), + RuntimeBrokerFile: credential.Path, + } + process := map[string]any{ + "id": RuntimeModule + "." + RuntimeProcessID(), "type": "process", "name": RuntimeModule, + "source": bundle.Source, "digest": bundle.Digest, + "run": []any{interpreter, bundle.Entrypoints[0]}, + "env": env, + "restart-on": toAny(restartOn), + } + if r.Account != "" { + env[RuntimeOperatorAccount] = r.Account + env[RuntimeOperatorHome] = accountHomeOf(r.Account, r.AccountHome) + process["user"] = r.Account + } + // Routed through the artifact store as this network reaches it now, like everything the mesh + // built; refused with the same words when there is no store to route through. + if err := artifactsInto(process, RuntimeModule, with); err != nil { + return nil, err + } + return process, nil +} + +func toAny(in []string) []any { + out := make([]any, 0, len(in)) + for _, s := range in { + out = append(out, s) + } + return out +} diff --git a/internal/catalogue/runtime_test.go b/internal/catalogue/runtime_test.go index 229499c..8dc73b6 100644 --- a/internal/catalogue/runtime_test.go +++ b/internal/catalogue/runtime_test.go @@ -1,6 +1,7 @@ package catalogue import ( + "fmt" "strings" "testing" ) @@ -33,7 +34,8 @@ func theRuntime(t *testing.T) Manifest { t.Helper() m := Manifest{Module: RuntimeModule, Version: "1", OwnSecrets: OwnSecrets{"broker": {Path: "/var/lib/mesh/" + RuntimeModule + "/broker"}}, - Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "typescript"}}}} + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "typescript", + Entrypoints: []string{"src/main.js"}}}}} resolved, err := m.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, Reference: ArtifactStoreScheme + RuntimeModule + "/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) if err != nil { @@ -114,3 +116,89 @@ func ids(out []map[string]any) []string { } return names } + +// One process per machine runs the runtime from its own bundle, told what it serves and from where, +// where its credential is, and who the operator is — restarted when any of that changes. +func TestTheMachineRunsOneRuntimeLoadingEveryDeliveredBundle(t *testing.T) { + with := Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}} + nftables := aToolsModule(t, "nftables", "tools/index.js") + // A bundle carrying a daemon beside its tools says which files the runtime loads. + showcase := Manifest{Module: "showcase", Version: "1", Tools: []string{"greet"}, + Build: &Build{Artifacts: []Artifact{{Name: "code", Kind: ArtifactBundle, Language: "typescript", + Entrypoints: []string{"daemon/index.js", "tools/index.js"}, Loads: []string{"tools/index.js"}}}}} + showcase, err := showcase.Resolve([]Built{{Name: "code", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "showcase/code/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + + r := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{nftables, showcase, theRuntime(t)}} + out, err := r.Declaration(with) + if err != nil { + t.Fatal(err) + } + process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) + if process == nil { + t.Fatalf("no runtime process was composed: %v", ids(out)) + } + if process["type"] != "process" || process["name"] != RuntimeModule || process["digest"] != bundleDigest || + process["source"] != "http://anchor.internal:5101/v2/"+RuntimeModule+"/runtime/blobs/"+bundleDigest { + t.Errorf("the runtime's process is %v", process) + } + if fmt.Sprint(process["run"]) != "[node src/main.js]" { + t.Errorf("the runtime is run as %v; its bundle's one entrypoint, by its language's interpreter", process["run"]) + } + env := process["env"].(map[string]string) + if env[RuntimeToolModules] != "nftables="+BundleRoot+"/nftables/tools/tools/index.js,"+ + "showcase="+BundleRoot+"/showcase/code/tools/index.js" { + t.Errorf("the runtime is told to serve %q: every loaded file, by module, and nothing a bundle runs", env[RuntimeToolModules]) + } + if env[RuntimeBrokerFile] != "/var/lib/mesh/"+RuntimeModule+"/broker" { + t.Errorf("the runtime reads its credential at %q, not where the module's own secret is placed", env[RuntimeBrokerFile]) + } + if env[RuntimeOperatorAccount] != "ops" || env[RuntimeOperatorHome] != "/home/ops" || process["user"] != "ops" { + t.Errorf("the operator is not handed to the runtime: %v as %v", env, process["user"]) + } + restarts := fmt.Sprint(process["restart-on"]) + for _, want := range []string{"nftables." + BundleID("tools"), "showcase." + BundleID("code"), RuntimeModule + "." + NeedID("broker")} { + if !strings.Contains(restarts, want) { + t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts) + } + } + // After every bundle and the credential, so both exist before it starts. + names := ids(out) + if names[len(names)-1] != RuntimeModule+"."+RuntimeProcessID() { + t.Errorf("the runtime's process is not last: %v", names) + } + + t.Run("a machine with no account runs it as root without the operator words", func(t *testing.T) { + out, err := Resolution{Node: "anchor", Modules: []Manifest{nftables, theRuntime(t)}}.Declaration(with) + if err != nil { + t.Fatal(err) + } + process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID()) + env := process["env"].(map[string]string) + if _, set := env[RuntimeOperatorAccount]; set { + t.Error("an operator account was named on a machine that has none") + } + if _, set := process["user"]; set { + t.Error("a user was set on a machine with no account") + } + }) + + t.Run("a runtime module built wrong is refused by name", func(t *testing.T) { + two := Manifest{Module: RuntimeModule, Version: "1", OwnSecrets: OwnSecrets{"broker": {Path: "/b"}}, + Build: &Build{Artifacts: []Artifact{{Name: "runtime", Kind: ArtifactBundle, Language: "typescript", + Entrypoints: []string{"a.js", "b.js"}}}}} + resolved, err := two.Resolve([]Built{{Name: "runtime", Kind: ArtifactBundle, + Reference: ArtifactStoreScheme + "x/runtime/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + _, err = Resolution{Node: "anchor", Modules: []Manifest{resolved}}.Declaration(with) + if err == nil || !strings.Contains(err.Error(), "entrypoint") { + t.Errorf("a runtime bundle with two entrypoints was composed: %v", err) + } + }) +} From 8eb6c3e94aa8838e52a42b32eee4636631bef2ce Mon Sep 17 00:00:00 2001 From: jochen Date: Fri, 2 Oct 2026 18:30:14 +0200 Subject: [PATCH 4/4] A tool container on the runtime's image is refused once the runtime is registered (hq ADR 0175, to-be 38 WP2.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A module declaring tools, a container, and a build on mesh-tools' runtime image is a container whose purpose is serving tools — the pattern the node's tool runtime retires. Once node-tools is in the catalogue, registering one is refused by name, with the record that says why; before, it is accepted as it always was, so a mesh converts in the design's order and nothing is refused before there is anything to move to. This is the mechanism that keeps the old pattern from returning by habit. Judged from a repository manifest's own build.on, and for a built manifest — which carries no build — from what its build stood on, now recorded beside the commit as part of a module's provenance. --- cmd/mesh-controller/build.go | 2 + cmd/mesh-controller/order_test.go | 3 +- internal/catalogue/runtime.go | 54 +++++++++++++++ internal/catalogue/runtime_gate_test.go | 88 +++++++++++++++++++++++++ internal/inventory/catalogue.go | 31 +++++++++ internal/inventory/catalogue_test.go | 29 ++++++++ 6 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 internal/catalogue/runtime_gate_test.go diff --git a/cmd/mesh-controller/build.go b/cmd/mesh-controller/build.go index 5b969b2..60695c3 100644 --- a/cmd/mesh-controller/build.go +++ b/cmd/mesh-controller/build.go @@ -522,6 +522,8 @@ func takeIn(ctx context.Context, inv *inventory.Inventory, result link.BuildResu recorded := inventory.Source{ Repository: result.Repository, Path: result.Path, Ref: result.Ref, BuiltFrom: result.Commit, Head: result.Commit, + // What it stood on, so registration can judge a built manifest's base (to-be 38 WP2.4). + Against: kept.Against, } if result.Source != nil && result.Source.Seat != "" { recorded.Repository, recorded.Seat = result.Source.Repository, result.Source.Seat diff --git a/cmd/mesh-controller/order_test.go b/cmd/mesh-controller/order_test.go index f3b0cd6..be2c4d0 100644 --- a/cmd/mesh-controller/order_test.go +++ b/cmd/mesh-controller/order_test.go @@ -1,6 +1,7 @@ package main import ( + "reflect" "strings" "testing" "time" @@ -191,7 +192,7 @@ func TestWhatAHandedOverModuleRecordsAboutItsSource(t *testing.T) { t.Fatalf("the source records as %+v", from) } // A manifest with no provenance at all is legitimate: fixing something in a hurry. - if from, err := whereItComesFrom("", "", "", "", false); err != nil || from != (inventory.Source{}) { + if from, err := whereItComesFrom("", "", "", "", false); err != nil || !reflect.DeepEqual(from, inventory.Source{}) { t.Fatalf("a manifest handed over with no provenance was refused: %+v, %v", from, err) } for _, c := range []struct { diff --git a/internal/catalogue/runtime.go b/internal/catalogue/runtime.go index ed6d251..b5be9e3 100644 --- a/internal/catalogue/runtime.go +++ b/internal/catalogue/runtime.go @@ -195,3 +195,57 @@ func toAny(in []string) []any { } return out } + +// RuntimeImageModule and RuntimeImageArtifact name the image every per-module tool container was +// built on: the tool runtime's own runtime image. With the runtime a module of its own, that image +// stays the way a module's SERVICE may be built and stops being the way tools reach a node (ADR 0175). +const ( + RuntimeImageModule = "mesh-tools" + RuntimeImageArtifact = "runtime" +) + +// ToolContainerOnTheRuntime says why a manifest is the pattern ADR 0175 retires — a module whose tools +// are served from a container built on the tool runtime's image — or nothing when it is not. Judged +// from the manifest's own `build.on` when it is a repository manifest, and from what its build stood +// on when it is a built one, because a resolved manifest carries no build. The gate itself is +// registration's (to-be 38 WP2.4): once the runtime module is in the catalogue, this is refused. +// +// Three things must hold, and each alone is fine: declaring tools (a bundle does that); a container +// (a module's service may well be one); building on the runtime's image (a service written against +// the SDK may). All three is a container whose purpose is tools, which the runtime now serves. +func ToolContainerOnTheRuntime(m Manifest, against []string) string { + if len(m.Tools) == 0 { + return "" + } + container := false + for _, r := range m.Resources { + if fmt.Sprint(r["type"]) == "container" { + container = true + } + } + if !container { + return "" + } + onTheRuntime := false + if m.Build != nil { + for _, on := range m.Build.On { + if on.Module == RuntimeImageModule && on.Artifact == RuntimeImageArtifact { + onTheRuntime = true + } + } + } + for _, ref := range against { + path, kept := InArtifactStore(Recorded(ref)) + if kept && strings.HasPrefix(path, RuntimeImageModule+"/"+RuntimeImageArtifact+"@") { + onTheRuntime = true + } + } + if !onTheRuntime { + return "" + } + return fmt.Sprintf( + "%s declares tools and a container built on %s's %s image — a container whose purpose is "+ + "serving tools. The node's tool runtime (%s) serves every module's tools from its bundle "+ + "now (novox/hq ADR 0175, to-be 38); declare the tools as a bundle and drop the container", + m.Module, RuntimeImageModule, RuntimeImageArtifact, RuntimeModule) +} diff --git a/internal/catalogue/runtime_gate_test.go b/internal/catalogue/runtime_gate_test.go new file mode 100644 index 0000000..d023a35 --- /dev/null +++ b/internal/catalogue/runtime_gate_test.go @@ -0,0 +1,88 @@ +package catalogue + +import ( + "strings" + "testing" +) + +// The packet-filter manifest as it was the day the runtime was decided (novox/hq ADR 0175): tools, +// served from a container built on the tool runtime's image, with NET_ADMIN so the container could +// reach the filter. The exact pattern to-be 38 WP4 moves it off, and the one the gate refuses. +const thePacketFilterAsItWas = `{ + "module": "nftables", + "version": "1", + "capabilities": ["firewall", "container-runtime"], + "claims": [{"name": "node-packet-filter", "scope": "node", "serves": ["rules", "reload", "remove"]}], + "filtering": {"into": "/etc/nftables.conf"}, + "resources": [ + {"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"}, + {"id": "package", "type": "package", "package": "nftables"}, + {"id": "unit", "type": "file", "path": "/etc/systemd/system/mesh-filter.service", + "content": "[Unit]\nDescription=The mesh's packet filter\n[Service]\nType=oneshot\nExecStart=nft -f /etc/nftables.conf\n", "mode": "0644"}, + {"id": "load", "type": "service", "unit": "mesh-filter.service", "state": "running", "boot": "enabled", + "restart-on": ["unit"], "reload-on": ["filtering"]}, + {"id": "runtime", "type": "container", "name": "mesh-nftables", "network": "host", + "capabilities": ["NET_ADMIN"], + "volumes": ["${dir:mesh-state}/broker:/run/secrets/broker:ro", "/etc/nftables.conf:/etc/nftables.conf:ro"], + "env": {"MESH_BROKER_FILE": "/run/secrets/broker", "MESH_FILTER_FILE": "/etc/nftables.conf"}, + "artifact": "runtime"} + ], + "tools": ["firewall_rules"], + "own-secrets": {"broker": "${dir:mesh-state}/broker"}, + "build": { + "on": [ + {"arg": "BUILD_BASE", "module": "mesh-tools", "artifact": "build"}, + {"arg": "RUNTIME_BASE", "module": "mesh-tools", "artifact": "runtime"} + ], + "artifacts": [{"name": "runtime", "kind": "image", "from": "Dockerfile"}] + } +}` + +func TestAToolContainerOnTheRuntimeImageIsNamedForWhatItIs(t *testing.T) { + m, err := ParseManifest([]byte(thePacketFilterAsItWas)) + if err != nil { + t.Fatal(err) + } + // From the repository: the manifest says what it builds on. + why := ToolContainerOnTheRuntime(m, nil) + if why == "" { + t.Fatal("the packet filter's tool container was not recognised from its build") + } + for _, word := range []string{"nftables", "mesh-tools", "runtime", "ADR 0175", "bundle"} { + if !strings.Contains(why, word) { + t.Errorf("the refusal does not say %q: %s", word, why) + } + } + + // Built: the manifest carries no build, and what it stood on says the same. + built, err := m.Resolve([]Built{{Name: "runtime", Kind: ArtifactImage, + Reference: ArtifactStoreScheme + "nftables/runtime@" + digest}}) + if err != nil { + t.Fatal(err) + } + stoodOn := []string{"anchor.internal:5100/mesh-tools/build@" + digest, "anchor.internal:5100/mesh-tools/runtime@" + digest} + if ToolContainerOnTheRuntime(built, stoodOn) == "" { + t.Error("the packet filter's tool container was not recognised from what its build stood on") + } + if ToolContainerOnTheRuntime(built, nil) != "" { + t.Error("a built manifest with no record of its base was judged to be on the runtime") + } + + // Each of the three alone is an ordinary module. + bundle := m + bundle.Resources = m.Resources[:len(m.Resources)-1] + if ToolContainerOnTheRuntime(bundle, nil) != "" { + t.Error("a module with tools and no container is the pattern the runtime serves, and was refused") + } + service := m + service.Tools = nil + if ToolContainerOnTheRuntime(service, nil) != "" { + t.Error("a service built against the SDK, declaring no tools, was refused") + } + elsewhere := m + elsewhere.Build = &Build{On: []BuildsOn{{Arg: "NODE_BASE", Image: "node@" + digest}}, + Artifacts: m.Build.Artifacts} + if ToolContainerOnTheRuntime(elsewhere, nil) != "" { + t.Error("a tool container on a public base was refused as though it were on the runtime's") + } +} diff --git a/internal/inventory/catalogue.go b/internal/inventory/catalogue.go index b339509..0c1def5 100644 --- a/internal/inventory/catalogue.go +++ b/internal/inventory/catalogue.go @@ -42,6 +42,11 @@ type Source struct { // Seen is when the source was last looked at — by a build, by hand, or by the forge saying it // moved. What a late report of an older move is judged against. Seen time.Time + // Against is every artifact the build this manifest came from stood on, as recorded. Part of a + // module's provenance like the commit is, and what tells a built manifest's base when the manifest + // itself no longer carries its build (novox/hq to-be 38 WP2.4). Empty for a manifest handed over + // by hand, which carries its `build.on` itself. + Against []string } // Current reports whether what the mesh holds is what the source last had. @@ -61,6 +66,22 @@ func (s Source) Current() bool { // gains a requirement, a claim, a resource. What matters is that the change is visible the next // time a node is resolved, which it is. func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, from Source) error { + // **Once the node's tool runtime is in the catalogue, the pattern it retires is refused** + // (novox/hq ADR 0175, to-be 38 WP2.4): a module serving its tools from a container built on the + // runtime's image. Refused at registration, by name, because this is the mechanism that keeps + // the old pattern from returning by habit — a rebuild of an unmoved module stops here with the + // record that says why. Before the runtime exists the pattern is accepted as it always was. + if m.Module != catalogue.RuntimeModule { + if why := catalogue.ToolContainerOnTheRuntime(m, from.Against); why != "" { + runtime, err := i.hasModule(ctx, catalogue.RuntimeModule) + if err != nil { + return err + } + if runtime { + return fmt.Errorf("%s is not registered: %s", m.Module, why) + } + } + } raw, err := json.Marshal(m) if err != nil { return err @@ -89,6 +110,16 @@ func (i *Inventory) RegisterModule(ctx context.Context, m catalogue.Manifest, fr return err } +// hasModule is whether the catalogue holds a module of that name. +func (i *Inventory) hasModule(ctx context.Context, name string) (bool, error) { + var one int + err := i.store.Pool().QueryRow(ctx, `select 1 from module where name = $1`, name).Scan(&one) + if errors.Is(err, pgx.ErrNoRows) { + return false, nil + } + return err == nil, err +} + // SourceMoved records that a module's source has a newer commit than the mesh has built. // // This is the whole of noticing. Nothing here builds anything — it writes down that the two diff --git a/internal/inventory/catalogue_test.go b/internal/inventory/catalogue_test.go index 9c0f974..7940ccb 100644 --- a/internal/inventory/catalogue_test.go +++ b/internal/inventory/catalogue_test.go @@ -685,3 +685,32 @@ func TestRegisteringWithoutProvenanceKeepsTheSeat(t *testing.T) { t.Fatalf("a hand-registered manifest erased where the module comes from: %+v", got) } } + +// Once the node's tool runtime is in the catalogue, a module serving its tools from a container +// built on the runtime's image is refused at registration, naming the record (novox/hq ADR 0175, +// to-be 38 WP2.4). Before, it is accepted as it always was — so a mesh converts in the order the +// design says and nothing is refused before there is anything to move to. +func TestAToolContainerIsRefusedOnceTheRuntimeIsRegistered(t *testing.T) { + inv := fresh(t) + filter := catalogue.Manifest{Module: "nftables", Version: "1", Tools: []string{"firewall_rules"}, + Resources: []map[string]any{{"id": "runtime", "type": "container", "name": "mesh-nftables"}}} + stoodOn := []string{catalogue.ArtifactStoreScheme + "mesh-tools/runtime@sha256:" + strings.Repeat("d", 64)} + + if err := inv.RegisterModule(t.Context(), filter, Source{Repository: "/r", Against: stoodOn}); err != nil { + t.Fatalf("before the runtime exists the old pattern is accepted: %v", err) + } + runtime := catalogue.Manifest{Module: catalogue.RuntimeModule, Version: "1"} + if err := inv.RegisterModule(t.Context(), runtime, Source{Repository: "/r"}); err != nil { + t.Fatal(err) + } + err := inv.RegisterModule(t.Context(), filter, Source{Repository: "/r", Against: stoodOn}) + if err == nil || !strings.Contains(err.Error(), "ADR 0175") { + t.Fatalf("the old pattern was registered beside the runtime: %v", err) + } + // A module that moved its tools to a bundle registers. + moved := filter + moved.Resources = nil + if err := inv.RegisterModule(t.Context(), moved, Source{Repository: "/r", Against: stoodOn}); err != nil { + t.Fatalf("a module whose tools are a bundle was refused: %v", err) + } +}