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