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) + } + }) +}