The runtime knows no language: the build makes each served entrypoint executable. For a TypeScript bundle that is <entry>.serve.mjs, which imports the entrypoint and serves what it registered over MCP on stdio through the bundle's own SDK. The build records its launchers on the bundle, and the composer names the launcher where a build wrote one and the entrypoint where it did not, so bundles built before this keep serving until they are rebuilt.
365 lines
17 KiB
Go
365 lines
17 KiB
Go
package catalogue
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"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",
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// 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"])
|
|
}
|
|
// The credential the process reads belongs to the account it runs as, or it could not read it
|
|
// (to-be 38 WP3); other modules' secrets are left as their manifests say.
|
|
if credential := fileNamed(out, RuntimeModule+"."+NeedID("broker")); credential == nil || credential["owner"] != "ops" {
|
|
t.Errorf("the runtime's credential is not the account's to read: %v", credential)
|
|
}
|
|
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")
|
|
}
|
|
if credential := fileNamed(out, RuntimeModule+"."+NeedID("broker")); credential == nil || credential["owner"] != nil {
|
|
t.Errorf("the runtime's credential was given an owner on a machine with no account: %v", credential)
|
|
}
|
|
})
|
|
|
|
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)
|
|
}
|
|
})
|
|
}
|
|
|
|
// novox/hq ADR 0192: a tools bundle says what it is given; the composer resolves it per machine as
|
|
// a container's environment, hands it to the runtime as the module's words, and makes what the
|
|
// words name readable by the account the runtime runs as.
|
|
func TestABundleIsGivenItsWordsResolvedForThisMachine(t *testing.T) {
|
|
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
|
Needed: map[string]map[string]string{
|
|
RuntimeModule: {"broker": "sealed-credential"},
|
|
"dash": {"token": "sealed-token"},
|
|
}}
|
|
dash := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
|
Listens: []Listening{{Name: "web", Port: 3000, Protocol: "tcp"}},
|
|
OwnSecrets: OwnSecrets{"token": {Path: "${dir:mesh-state}/token"}},
|
|
Resources: []map[string]any{
|
|
{"id": "mesh-state", "type": "directory", "mode": "0700", "place": "mesh"},
|
|
{"id": "config", "type": "file", "path": "${dir:mesh-state}/config.json", "mode": "0600", "content": "{}\n"},
|
|
{"id": "unrelated", "type": "file", "path": "/etc/dash.conf", "content": "x\n"},
|
|
},
|
|
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
|
|
Entrypoints: []string{"tools/index.js"},
|
|
Env: map[string]string{
|
|
"DASH_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
|
"DASH_TOKEN_FILE": "${dir:mesh-state}/token",
|
|
"DASH_URL": "http://127.0.0.1:${port:3000}",
|
|
"DASH_ADMIN": "mesh-admin",
|
|
}}}}}
|
|
if problems := dash.Build.problems(dash.Module); len(problems) > 0 {
|
|
t.Fatalf("a bundle's words written with ${dir:…} and ${port:…} were refused: %v", problems)
|
|
}
|
|
dash, err := dash.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
|
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
other := aToolsModule(t, "nftables", "tools/index.js")
|
|
|
|
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{dash, other, theRuntime(t)}}.Declaration(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := fileNamed(out, "dash.mesh-state")
|
|
if dir == nil {
|
|
t.Fatalf("no directory: %v", ids(out))
|
|
}
|
|
at := fmt.Sprint(dir["path"])
|
|
process := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())
|
|
env := process["env"].(map[string]string)
|
|
var given map[string]map[string]string
|
|
if err := json.Unmarshal([]byte(env[RuntimeToolEnv]), &given); err != nil {
|
|
t.Fatalf("the runtime's %s is not JSON: %q", RuntimeToolEnv, env[RuntimeToolEnv])
|
|
}
|
|
want := map[string]string{
|
|
"DASH_CONFIG_FILE": at + "/config.json",
|
|
"DASH_TOKEN_FILE": at + "/token",
|
|
"DASH_URL": "http://127.0.0.1:3000",
|
|
"DASH_ADMIN": "mesh-admin",
|
|
}
|
|
if fmt.Sprint(given["dash"]) != fmt.Sprint(want) {
|
|
t.Errorf("dash is given %v, want %v", given["dash"], want)
|
|
}
|
|
if _, has := given["nftables"]; has {
|
|
t.Errorf("a module that declares no words was given some: %v", given)
|
|
}
|
|
// What the words name is the account's to read; nothing else of the module's is touched.
|
|
for _, id := range []string{"dash.mesh-state", "dash.config", "dash." + NeedID("token")} {
|
|
if r := fileNamed(out, id); r == nil || r["owner"] != "ops" {
|
|
t.Errorf("%s is not the account's to read: %v", id, r)
|
|
}
|
|
}
|
|
if r := fileNamed(out, "dash.unrelated"); r == nil || r["owner"] != nil {
|
|
t.Errorf("a file no word names was given an owner: %v", r)
|
|
}
|
|
// A file a word names restarts the runtime when it changes, as it restarted the tool container.
|
|
restarts := fmt.Sprint(process["restart-on"])
|
|
for _, want := range []string{"dash.config", "dash." + NeedID("token")} {
|
|
if !strings.Contains(restarts, want) {
|
|
t.Errorf("the runtime is not restarted when %s changes: %s", want, restarts)
|
|
}
|
|
}
|
|
if strings.Contains(restarts, "dash.unrelated") || strings.Contains(restarts, "dash.mesh-state") {
|
|
t.Errorf("the runtime restarts for something no word names as a file: %s", restarts)
|
|
}
|
|
|
|
t.Run("on a machine with no account the runtime is root and nothing is re-owned", func(t *testing.T) {
|
|
out, err := Resolution{Node: "anchor", Modules: []Manifest{dash, theRuntime(t)}}.Declaration(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if r := fileNamed(out, "dash.config"); r["owner"] != nil {
|
|
t.Errorf("re-owned with no account: %v", r)
|
|
}
|
|
})
|
|
|
|
t.Run("a change to a module's words changes the runtime's process", func(t *testing.T) {
|
|
changed := dash
|
|
changed.Bundles = append([]Bundle(nil), dash.Bundles...)
|
|
changed.Bundles[0].Env = map[string]string{"DASH_ADMIN": "somebody-else"}
|
|
out2, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{changed, theRuntime(t)}}.Declaration(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if fmt.Sprint(fileNamed(out2, RuntimeModule+"."+RuntimeProcessID())["env"]) == fmt.Sprint(env) {
|
|
t.Error("the runtime's process is the same after a module's words changed, so it would not restart")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestABundlesWordsAreRefusedWhenTheyAreNotPathsOrConstants(t *testing.T) {
|
|
m := Manifest{Module: "dash", Version: "1", Build: &Build{Artifacts: []Artifact{
|
|
{Name: "tools", Kind: ArtifactBundle, Language: "typescript", Entrypoints: []string{"tools/index.js"},
|
|
Env: map[string]string{"DASH_TOKEN": "${secret:token}", RuntimeBrokerFile: "/x", "DASH_PEER": "${bound:db:url}"}},
|
|
{Name: "runtime", Kind: ArtifactImage, From: "Dockerfile", Env: map[string]string{"X": "y"}},
|
|
}}}
|
|
said := strings.Join(m.Build.problems(m.Module), "\n")
|
|
for _, want := range []string{
|
|
`"tools" gives DASH_TOKEN the value "${secret:token}"`,
|
|
`"tools" gives DASH_PEER the value "${bound:db:url}"`,
|
|
`"tools" gives itself ` + RuntimeBrokerFile,
|
|
`"runtime" is a "image" and says what it is given`,
|
|
} {
|
|
if !strings.Contains(said, want) {
|
|
t.Errorf("not refused: %s\nsaid:\n%s", want, said)
|
|
}
|
|
}
|
|
}
|
|
|
|
// novox/hq ADR 0193: the runtime is told the launcher a build wrote, and the entrypoint itself for a
|
|
// build from before launchers — so the move needs no flag day.
|
|
func TestTheRuntimeStartsTheLauncherWhereTheBuildWroteOne(t *testing.T) {
|
|
with := Rendering{ArtifactStore: "anchor.internal:5101",
|
|
Needed: map[string]map[string]string{RuntimeModule: {"broker": "sealed-credential"}}}
|
|
launched := Manifest{Module: "dash", Version: "1", Tools: []string{"status"},
|
|
Build: &Build{Artifacts: []Artifact{{Name: "tools", Kind: ArtifactBundle, Language: "typescript",
|
|
Entrypoints: []string{"tools/index.js"}}}}}
|
|
launched, err := launched.Resolve([]Built{{Name: "tools", Kind: ArtifactBundle,
|
|
Reference: ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest,
|
|
Launchers: map[string]string{"tools/index.js": "tools/index.serve.mjs"}}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
older := aToolsModule(t, "nftables", "tools/index.js")
|
|
out, err := Resolution{Node: "anchor", Account: "ops", Modules: []Manifest{launched, older, theRuntime(t)}}.Declaration(with)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
env := fileNamed(out, RuntimeModule+"."+RuntimeProcessID())["env"].(map[string]string)
|
|
want := "dash=" + BundleRoot + "/dash/tools/tools/index.serve.mjs,nftables=" + BundleRoot + "/nftables/tools/tools/index.js"
|
|
if env[RuntimeToolModules] != want {
|
|
t.Errorf("the runtime is told %q, want %q", env[RuntimeToolModules], want)
|
|
}
|
|
}
|