diff --git a/internal/compose/resolution/proofs_test.go b/internal/compose/resolution/proofs_test.go new file mode 100644 index 0000000..9774120 --- /dev/null +++ b/internal/compose/resolution/proofs_test.go @@ -0,0 +1,469 @@ +package resolution + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + mcat "git.novox.be/novox/mesh-sdk/go/catalogue" + "git.novox.be/novox/mesh-sdk/go/provisions" + + "github.com/novox/mesh-host/internal/beside" +) + +// The proofs that a manifest composes, which the indexer's judge tests and route-proxy's tests held while the +// composition was mesh-sdk's (novox/hq ADR 0593 ยง7): the composition is the node-engine's, so they are held here, each +// case as it was (sdk1, .work/sdk1/proofs-for-node-engine.md; mesh-delivery 6fb743f, mesh-networking a74f9db). + +// declared is the declaration of r with, failing the test on a refusal. +func declared(t *testing.T, r mcat.Resolution, with mcat.Rendering) []map[string]any { + t.Helper() + out, err := Declaration(r, with) + if err != nil { + t.Fatal(err) + } + return out +} + +func twoMergeableFiles(app, sidecar []any) mcat.Manifest { + appFile := map[string]any{"id": "app", "type": "file", "path": "/etc/app.json", "merge": mcat.MergeJSON, + "content": `{"server":{"port":8080},"search":{"safe":1}}`} + sideFile := map[string]any{"id": "sidecar", "type": "file", "path": "/etc/sidecar.json", "merge": mcat.MergeJSON, + "content": `{"url":"http://127.0.0.1:8080"}`} + if app != nil { + appFile[mcat.TakesSettingsField] = app + } + if sidecar != nil { + sideFile[mcat.TakesSettingsField] = sidecar + } + return mcat.Manifest{Module: "search", Resources: []map[string]any{appFile, sideFile, + {"id": "env", "type": "file", "path": "/etc/search.env", "content": "SITE=${setting:site-name}\n"}}} +} + +// The indexer's judge's proofs, items 1 to 13 of its list. +func TestTheJudgesProofsOfAComposition(t *testing.T) { + runtimeWith := mcat.Rendering{ArtifactStore: "anchor.internal:5101", + Needed: map[string]map[string]string{mcat.RuntimeModule: {"broker": "sealed-credential"}, "dash": {"token": "sealed-token"}}} + processOf := func(out []map[string]any) map[string]any { + return fileNamed(out, mcat.RuntimeModule+"."+RuntimeProcessID()) + } + + t.Run("1 a bundle's words resolve per machine into the runtime's tool env", func(t *testing.T) { + dash := mcat.Manifest{Module: "dash", Version: "1", Tools: []string{"status"}, + Listens: []mcat.Listening{{Name: "web", Port: 3000, Protocol: "tcp"}}, + OwnSecrets: mcat.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: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "tools", Kind: mcat.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"}}}}} + dash, err := dash.Resolve([]mcat.Built{{Name: "tools", Kind: mcat.ArtifactBundle, + Reference: mcat.ArtifactStoreScheme + "dash/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + other := aToolsModule(t, "nftables", "tools/index.js") + out := declared(t, mcat.Resolution{Node: "anchor", Account: "ops", Modules: []mcat.Manifest{dash, other, theRuntime(t)}}, runtimeWith) + dir := fileNamed(out, "dash.mesh-state") + if dir == nil { + t.Fatalf("no directory: %v", ids(out)) + } + at := fmt.Sprint(dir["path"]) + process := processOf(out) + env := process["env"].(map[string]string) + var given map[string]map[string]string + if err := json.Unmarshal([]byte(env[mcat.RuntimeToolEnv]), &given); err != nil { + t.Fatalf("the runtime's %s is not JSON: %q", mcat.RuntimeToolEnv, env[mcat.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) + } + for _, id := range []string{"dash.mesh-state", "dash.config", "dash." + mcat.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) + } + restarts := fmt.Sprint(process["restart-on"]) + for _, want := range []string{"dash.config", "dash." + mcat.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) + } + // No account: the runtime is root and nothing is re-owned. + if r := fileNamed(declared(t, mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{dash, theRuntime(t)}}, runtimeWith), + "dash.config"); r["owner"] != nil { + t.Errorf("re-owned with no account: %v", r) + } + // A change to a module's words changes the runtime's process. + changed := dash + changed.Bundles = append([]mcat.Bundle(nil), dash.Bundles...) + changed.Bundles[0].Env = map[string]string{"DASH_ADMIN": "somebody-else"} + out2 := declared(t, mcat.Resolution{Node: "anchor", Account: "ops", Modules: []mcat.Manifest{changed, theRuntime(t)}}, runtimeWith) + if fmt.Sprint(processOf(out2)["env"]) == fmt.Sprint(env) { + t.Error("the runtime's process is the same after a module's words changed, so it would not restart") + } + }) + + t.Run("2 a Go bundle is delivered and named to the runtime by its binary", func(t *testing.T) { + lamp := mcat.Manifest{Module: "lamp", Version: "1", Tools: []string{"on"}, + Build: &mcat.Build{Artifacts: []mcat.Artifact{{Name: "tools", Kind: mcat.ArtifactBundle, Language: "go", + System: "arch", From: "cmd/lamp-tools"}}}} + lamp, err := lamp.Resolve([]mcat.Built{{Name: "tools", Kind: mcat.ArtifactBundle, + Reference: mcat.ArtifactStoreScheme + "lamp/tools/blobs/" + bundleDigest, Digest: bundleDigest}}) + if err != nil { + t.Fatal(err) + } + out := declared(t, mcat.Resolution{Node: "anchor", Account: "ops", Modules: []mcat.Manifest{lamp, theRuntime(t)}}, runtimeWith) + if fileNamed(out, "lamp."+BundleID("tools")) == nil { + t.Errorf("the Go bundle is not delivered: %v", ids(out)) + } + if env := processOf(out)["env"].(map[string]string); env[mcat.RuntimeToolModules] != "lamp="+BundlePath("lamp", "tools")+"/lamp-tools" { + t.Errorf("the runtime is told %q, want the binary", env[mcat.RuntimeToolModules]) + } + }) + + t.Run("3 a handed module's plan names its provider's credential form", func(t *testing.T) { + storeOffering := func(credential string) mcat.Manifest { + raw := `{"module":"store","version":"1","provides":[{"name":"postgres-database","scope":"mesh"` + + credential + `}],"serves":{"postgres-database":{"port":5432}}` + if strings.Contains(credential, `"own"`) { + raw += `,"own-secrets":{"admin":{"path":"/var/lib/store/admin","taken":"at-start"},` + + `"token":{"path":"/var/lib/store/token","taken":"at-start","issued-by":"outside"}}` + } + return mustParse(t, raw+`}`) + } + for credential, want := range map[string]string{ + ``: "", + `,"credential":{"form":"pair"}`: mcat.CredentialFormPair, + `,"credential":{"form":"none"}`: mcat.CredentialFormNone, + `,"credential":{"own":"admin"}`: mcat.CredentialFormShared, + `,"credential":{"own":"token"}`: mcat.CredentialFormGiven, + `,"credential":{"issued-by":"outside"}`: mcat.CredentialFormGiven, + `,"credential":{"issued-by":"software","tool":"store_issued_token"}`: mcat.CredentialFormIssued, + } { + node := workstation() + node.At = "workstation.internal" + consumer := notifierConsumer(t, true, false) + r, err := Resolve(shelf(consumer, storeOffering(credential)), []string{consumer.Module}, node, twoStores("anchor")) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + c, _ := composeWith(t, r, handingTo("store:5000")) + if got := c.Plans["notify"].Provisions; len(got) != 1 || got[0].CredentialForm != want { + t.Errorf("an offer with %q: the plan names %+v, want the form %q", credential, got, want) + } + } + }) + + t.Run("4 a stored definition from before the settings rule still composes", func(t *testing.T) { + m := twoMergeableFiles(nil, nil) + m.Resources[2]["content"] = "WORKERS=${setting:workers}\n" + raw, err := json.Marshal(map[string]any{"module": m.Module, "version": "1", "resources": m.Resources, + "settings": map[string]any{"workers": map[string]any{"kind": mcat.KindPreference, "default": 4, "why": "a number"}}}) + if err != nil { + t.Fatal(err) + } + read, err := mcat.Decode(raw) + if err != nil { + t.Fatalf("a stored build from before the rule is not read again: %v", err) + } + if err := JudgeSettings(read, nil, false); err != nil { + t.Errorf("a stored definition is composed as before, and was refused: %v", err) + } + }) + + t.Run("5 a placeholder outside its holder is refused at composition", func(t *testing.T) { + for _, c := range []struct{ content, want string }{ + {"${environment:posix}", "toolchain's resource rc names ${environment:posix} and toolchain does not claim node-environment"}, + {"${shell:zsh:normal}", "toolchain's resource rc names ${shell:zsh:normal} and toolchain does not claim node-login-shell"}, + } { + m := mcat.Manifest{Module: "toolchain", Resources: []map[string]any{ + {"id": "rc", "type": "file", "path": "/etc/rc", "content": c.content}}} + r := mcat.Resolution{Node: "workstation", Account: "op", Modules: []mcat.Manifest{m}} + if _, err := Declaration(r, mcat.Rendering{}); err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("composition accepted %s outside its holder: %v", c.content, err) + } + } + }) + + t.Run("6 a provider read from its tree composes where it would be handed", func(t *testing.T) { + dir := t.TempDir() + if err := os.MkdirAll(filepath.Join(dir, "files"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "files", "consumers.json"), []byte(consumersText), 0o644); err != nil { + t.Fatal(err) + } + var m mcat.Manifest + if err := json.Unmarshal([]byte(handedProvider), &m); err != nil { + t.Fatal(err) + } + filled, err := mcat.WithTemplatesFrom(m, dir) + if err != nil { + t.Fatalf("a provider read from its tree is refused: %v", err) + } + if _, err := Compose(notifierOn(t, filled), handingTo("store:5000")); err != nil { + t.Fatalf("a provider read from its tree does not compose where it would be handed: %v", err) + } + }) + + t.Run("7 a module whose field is set aside is composed whole", func(t *testing.T) { + for where, raw := range map[string]string{ + "the top": `{"module": "later", "version": "2", "a-field-from-later": {"x": 1}}`, + "a state": `{"module": "later", "version": "2", "state": [{"name": "s", "a-field-from-later": 1}]}`, + } { + var m mcat.Manifest + if err := json.Unmarshal([]byte(raw), &m); err != nil { + t.Fatalf("%s: %v", where, err) + } + if left := LeftOut(mcat.Resolution{Node: "laptop", Modules: []mcat.Manifest{m, notifier()}}, nil, false); len(left) != 0 { + t.Errorf("%s: a module was left out for a field set aside: %v", where, left) + } + } + var later mcat.Manifest + if err := json.Unmarshal([]byte(`{"module": "later", "version": "2", "a-field-from-later": 1, + "resources": [{"id": "d", "type": "directory", "mode": "0700"}, {"id": "rc", "type": "file", "path": "/etc/later.conf", "content": "x\n"}], + "data": {"own": [{"id": "d", "path": "${dir:d}", "class": "valuable"}]}}`), &later); err != nil { + t.Fatal(err) + } + holder := mcat.Manifest{Module: "backups", Version: "1", + Claims: []mcat.Claim{{Name: mcat.BackupSeat, Scope: mcat.ScopeNode}}, + Resources: []map[string]any{{"id": "list", "type": "file", "path": "/etc/backups.list", "mode": "0644", + "content": "${contribution:" + mcat.BackupSeat + ":backup}"}}} + r := anAdoptedAnchor() + r.Modules = append(r.Modules, holder, later) + composed, err := Compose(r, anchorRendering(false)) + if err != nil { + t.Fatal(err) + } + if _, left := composed.LeftOut["later"]; left { + t.Fatalf("left out: %v", composed.LeftOut) + } + got := byID(composed.Resources) + if _, declared := got["later.rc"]; !declared { + t.Fatal("the module's file is not declared") + } + if list, _ := got["backups.list"]["content"].(string); !strings.Contains(list, "# later") || !strings.Contains(list, "path /var/lib/later/d") { + t.Fatalf("the module's data is not backed up:\n%s", list) + } + }) + + t.Run("8 composing refuses a silent sidecar", func(t *testing.T) { + if err := JudgeSettings(twoMergeableFiles([]any{"server"}, nil), nil, false); err == nil || !strings.Contains(err.Error(), "sidecar") { + t.Errorf("composing it should refuse it, and said: %v", err) + } + }) + + t.Run("9 ApplySettings refuses a file that does not merge and names keys", func(t *testing.T) { + plain := map[string]any{"id": "plain", "type": "file", "path": "/x", "content": "x", mcat.TakesSettingsField: []any{"a"}} + if _, err := ApplySettings(plain, nil); err == nil { + t.Error("a file that does not merge and names keys was settled") + } + }) + + t.Run("10 the runtime is told whom agents run as", func(t *testing.T) { + envOf := func(r mcat.Resolution) map[string]string { + r.Node, r.Modules = "anchor", []mcat.Manifest{aToolsModule(t, "nftables", "tools/index.js"), theRuntime(t)} + process := processOf(declared(t, r, runtimeWith)) + if process == nil { + t.Fatal("no runtime process was composed") + } + return process["env"].(map[string]string) + } + env := envOf(mcat.Resolution{Account: "ops", AgentAccount: "agent"}) + if env[mcat.RuntimeAgentAccount] != "agent" || env[mcat.RuntimeAgentHome] != "/home/agent" || env[mcat.RuntimeOperatorAccount] != "ops" { + t.Errorf("the runtime is not told whom agents run as: %v", env) + } + env = envOf(mcat.Resolution{Account: "ops"}) + if env[mcat.RuntimeAgentAccount] != "ops" || env[mcat.RuntimeAgentHome] != "/home/ops" { + t.Errorf("with no agent account, agents run as the operator: %v", env) + } + if _, set := envOf(mcat.Resolution{})[mcat.RuntimeAgentAccount]; set { + t.Error("a machine with no account names an agent account") + } + }) + + t.Run("11 the session's files are placed by the display server's holder alone", func(t *testing.T) { + xorg := mcat.Manifest{Module: "xorg", Claims: []mcat.Claim{{Name: mcat.DisplayServerSeat}}, + Shell: []mcat.ShellCode{{For: "xinitrc", Slot: "first", Code: "xset s off"}}, + Resources: []map[string]any{ + {"id": "xinitrc", "type": "file", "path": "/home/op/.xinitrc", + "content": "${shell:xinitrc:first}${shell:xinitrc:normal}${shell:xinitrc:last}"}, + {"id": "xresources", "type": "file", "path": "/home/op/.Xresources", "content": "${shell:xresources:normal}"}, + }} + i3 := mcat.Manifest{Module: "i3", Shell: []mcat.ShellCode{{For: "xinitrc", Slot: "last", Code: "exec i3"}}} + theme := mcat.Manifest{Module: "theme", Shell: []mcat.ShellCode{ + {For: "xresources", Slot: "normal", Code: "Xft.dpi: 96"}, {For: "zsh", Slot: "normal", Code: "not for the session"}}} + out := declared(t, mcat.Resolution{Node: "workstation", Account: "op", Modules: []mcat.Manifest{xorg, i3, theme}}, mcat.Rendering{}) + by := map[string]any{} + for _, res := range out { + by[res["id"].(string)] = res["content"] + } + if got := by["xorg.xinitrc"]; got != "# xorg\nxset s off\n# i3\nexec i3\n" { + t.Errorf("the .xinitrc is %q", got) + } + if got := by["xorg.xresources"]; got != "# theme\nXft.dpi: 96\n" { + t.Errorf("the .Xresources is %q", got) + } + }) + + t.Run("12 what a handed module cannot have from the vault is refused", func(t *testing.T) { + requires := strings.Replace(botMoved(), `"own-secrets":`, `"requires":["database"],"secrets":{"database":"/etc/bot/db"},"own-secrets":`, 1) + r, err := Resolve(shelf(held(t, requires, builtBot("db = ${secret:database}\n")), + mcat.Manifest{Module: "db", Version: "1", Provides: []mcat.Offer{{Name: "database", Scope: mcat.ScopeNode}}}), + []string{"bot"}, workstation(), mcat.World{}) + if err != nil { + t.Fatal(err) + } + r.Account = "op" + if _, err := Compose(r, handingTo("store:5000")); err == nil || !strings.Contains(err.Error(), "moves with its provider") { + t.Errorf("a template naming a required credential: %v", err) + } + inline := strings.Replace(botMoved(), `{"id":"daemon"`, + `{"id":"legacy","type":"file","path":"/etc/bot/legacy","content":"t = ${secret:token}\n"},{"id":"daemon"`, 1) + if _, err := Compose(notifierOn(t, held(t, inline, builtBot(botText))), handingTo("store:5000")); err == nil || + !strings.Contains(err.Error(), "keep it in a template") { + t.Errorf("an inline file naming a secret in a handed module: %v", err) + } + }) + // 13, a held manifest's template checked by HeldTemplateProblem: TestAManifestStillNamingATemplateIsNotHeld in template_test.go, moved + // with the catalogue's tests, and the held fixture each case above reads its manifests through. +} + +// route-proxy's proofs, items 1 to 3 of its list. +func TestRouteProxysProofsOfAComposition(t *testing.T) { + proxy := mcat.Manifest{Module: "route-proxy", Version: "1", + Provides: []mcat.Offer{{Name: "route", Scope: mcat.ScopeMesh, AnsweredBy: mcat.AnsweredByHelper}}} + world := mcat.World{Offered: map[string][]mcat.Provider{"route": {{Node: "anchor", At: "anchor.internal", Module: "route-proxy"}}}} + + t.Run("1 route-proxy's manifest composes for a machine", func(t *testing.T) { + root := beside.Dir(t, "mesh-networking") + m, err := mcat.ReadTreeManifest(root, filepath.Join(root, "modules", "route-proxy")) + if err != nil { + t.Fatalf("route-proxy's manifest does not parse:\n%v", err) + } + // As a build would leave it: each artifact a container names resolved to an image; an archive unpacks a bundle. + for _, res := range m.Resources { + if artifact, ok := res["artifact"].(string); ok && res["type"] != "archive" { + delete(res, "artifact") + res["image"] = "registry.invalid/route-proxy/" + artifact + + "@sha256:1111111111111111111111111111111111111111111111111111111111111111" + } + } + r := mcat.Resolution{Node: "anchor", Modules: []mcat.Manifest{m}, Needs: []mcat.Needed{{ + Name: "internal-acme-ca", From: "home", At: "home.internal", For: "route-proxy", + Serves: map[string]any{"port": float64(9000), "path": "/acme/acme/directory", "roots": "/roots.pem"}}}} + // Its own broker credential, sealed as the mesh would. + out := declared(t, r, mcat.Rendering{Needed: map[string]map[string]string{"route-proxy": {"broker": "sealed"}}}) + if fileNamed(out, "route-proxy.acme-env") == nil { + t.Fatalf("nothing writes the public issuer's environment: %v", ids(out)) + } + if fileNamed(out, "route-proxy.bound-acme-ca") != nil { + t.Error("a binding to acme-ca is still written") + } + sealed := false + for _, res := range out { + sealed = sealed || strings.Contains(fmt.Sprint(res), "sealed") + } + internal := false + for _, res := range out { + internal = internal || strings.Contains(fmt.Sprint(res["content"]), "home.internal:9000") + } + if !sealed || !internal { + t.Errorf("the broker credential sealed: %v; the internal-acme-ca need resolved: %v (%v)", sealed, internal, ids(out)) + } + }) + + t.Run("2 and 3 a route endpoint's machine port, and the plan's provision entry", func(t *testing.T) { + for _, c := range []struct { + name, node, module string + port int + published []any + placed map[int]int + at int + }{ + {"beside the proxy, moved by the placement (gitea)", "anchor", "gitea", 3000, []any{"3000"}, map[int]int{3000: 20000}, 20000}, + {"on another machine, moved by the placement (grafana)", "home", "grafana", 3000, []any{"3000"}, map[int]int{3000: 20012}, 20012}, + {"a bare port the placement holds none for (influxdb)", "home", "influxdb", 8086, []any{"8086"}, nil, 8086}, + {"a fixed mapping (mailu 7080:80)", "anchor", "mailu", 7080, []any{"25", "7080:80"}, nil, 7080}, + {"a fixed mapping the placement names another port of (photos 9102:9000)", "anchor", "photos", 9102, + []any{"9102:9000"}, map[int]int{9000: 20011}, 9102}, + {"the host's network (home-assistant)", "anchor", "home-assistant", 8123, nil, nil, 8123}, + } { + container := map[string]any{"type": "container", "name": c.module, "image": "x"} + if c.published != nil { + container["ports"] = c.published + } else { + container["network"] = "host" + } + module := mcat.Manifest{Module: c.module, Version: "1", Requires: []string{"route"}, + Listens: []mcat.Listening{{Name: "web", Port: c.port, Protocol: "tcp", From: "mesh"}}, + Contributes: map[string]map[string]any{"route": {"label": c.module, "endpoint": "web"}}, + Resources: []map[string]any{container}} + node := mcat.Node{Name: c.node, At: c.node + ".internal", PublicDomain: "example.org", + Capabilities: map[string]bool{"container-runtime": true}} + r, err := Resolve(map[string]mcat.Manifest{module.Module: module, proxy.Module: proxy}, []string{module.Module}, node, world) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + r.Account = "op" + for i, n := range r.Needs { + r.Needs[i].AnsweredByHelper = n.Name == "route" // as the plan sets it (compose's answeredByHelper) + } + ports := map[int]int{} // as compose's portsFor gives them + for _, l := range module.Listens { + where, mayAssign := module.MachineSide(l.Port) + if at, known := c.placed[l.Port]; mayAssign && known { + where = at + } + ports[l.Port] = where + } + if ports[c.port] != c.at { + t.Errorf("%s: the endpoint's machine port is %d, want %d", c.name, ports[c.port], c.at) + } + composed, err := Compose(r, mcat.Rendering{Hands: true, Provisioner: "mesh-provisioner-client", + ArtifactStore: "store:5000", Planned: true, Ports: map[string]map[int]int{c.module: ports}}) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + // The plan's provision entry names the proxy and carries a Contributed the contract accepts, end to end. + value, err := provisions.EncodePlan(provisions.Plan{Node: c.node, Module: c.module, Plan: composed.Plans[c.module]}) + if err != nil { + t.Fatalf("%s: the plan does not fit the contract: %v", c.name, err) + } + back, err := provisions.DecodePlan(value) + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + var entry *mcat.PlanProvision + for i := range back.Plan.Provisions { + if back.Plan.Provisions[i].Provision == "route" { + entry = &back.Plan.Provisions[i] + } + } + if entry == nil || entry.ProviderModule != "route-proxy" || len(entry.Contributed) == 0 || + !strings.Contains(string(entry.Contributed), fmt.Sprint(c.at)) { + t.Errorf("%s: the plan's route entry %+v names no proxy, or not the port %d", c.name, entry, c.at) + } + } + }) +} diff --git a/testdata/beside/CAPTURED b/testdata/beside/CAPTURED index 9f1bb1c..ae5b79a 100644 --- a/testdata/beside/CAPTURED +++ b/testdata/beside/CAPTURED @@ -25,3 +25,6 @@ To move them, from this repository's go/ directory, with the two repositories ch and write the commits here. The placement guard is kept as .captured so no Go tool reads it as this repository's code. + +mesh-networking a74f9db508687f6fbf3c8074a2a8e195824a500a modules/route-proxy/module.json, modules/route-proxy/files/*.env +(route-proxy's manifest, for the node-engine's proof that it composes; sdk1-host, 2026-11-02) diff --git a/testdata/beside/mesh-networking/modules/route-proxy/files/acme.env b/testdata/beside/mesh-networking/modules/route-proxy/files/acme.env new file mode 100644 index 0000000..428aae2 --- /dev/null +++ b/testdata/beside/mesh-networking/modules/route-proxy/files/acme.env @@ -0,0 +1,3 @@ +ACME_DIRECTORY=https://acme-v02.api.letsencrypt.org:443/directory +ACME_ROOTS=https://acme-v02.api.letsencrypt.org:443 +ACME_ROOTS_PATH= diff --git a/testdata/beside/mesh-networking/modules/route-proxy/files/internal-acme.env b/testdata/beside/mesh-networking/modules/route-proxy/files/internal-acme.env new file mode 100644 index 0000000..72b0f7b --- /dev/null +++ b/testdata/beside/mesh-networking/modules/route-proxy/files/internal-acme.env @@ -0,0 +1,3 @@ +INTERNAL_ACME_DIRECTORY=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:path} +INTERNAL_ACME_ROOTS=https://${bound:internal-acme-ca:at}:${bound:internal-acme-ca:port}${bound:internal-acme-ca:roots} +INTERNAL_ACME_ROOTS_PATH=${bound:internal-acme-ca:roots} diff --git a/testdata/beside/mesh-networking/modules/route-proxy/module.json.captured b/testdata/beside/mesh-networking/modules/route-proxy/module.json.captured new file mode 100644 index 0000000..668ab88 --- /dev/null +++ b/testdata/beside/mesh-networking/modules/route-proxy/module.json.captured @@ -0,0 +1,272 @@ +{ + "module": "route-proxy", + "version": "1", + "slug": "rproxy", + "capabilities": [ + "container-runtime" + ], + "provides": [ + { + "name": "route", + "scope": "mesh", + "credential": { + "form": "none" + }, + "identity": false, + "answered-by": "helper" + } + ], + "serves": { + "route": {} + }, + "requires": [ + "internal-acme-ca" + ], + "binds": { + "internal-acme-ca": "${dir:state}/internal-acme-ca.json" + }, + "own-secrets": { + "broker": "${dir:mesh-state}/broker" + }, + "listens": [ + { + "name": "http", + "port": 80, + "protocol": "tcp", + "from": "anywhere", + "why": "public HTTP, and the ACME HTTP-01 challenge answered at the name being certified" + }, + { + "name": "https", + "port": 443, + "protocol": "tcp", + "from": "anywhere", + "why": "public HTTPS for every name the mesh routes here" + } + ], + "data": { + "own": [ + { + "id": "acme", + "path": "${dir:acme-cache}", + "class": "rebuildable", + "why": "issued certificates, issued again" + }, + { + "id": "ca", + "path": "${dir:ca-dir}", + "class": "cache", + "why": "the authority's roots, fetched again at every start" + } + ] + }, + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "place": "." + }, + { + "id": "mesh-state", + "type": "directory", + "mode": "0700", + "place": "mesh" + }, + { + "id": "acme-cache", + "type": "directory", + "path": "/var/lib/route-proxy/acme", + "mode": "0700" + }, + { + "id": "ca-dir", + "type": "directory", + "path": "/var/lib/route-proxy/ca", + "mode": "0755" + }, + { + "id": "acme-env", + "type": "file", + "path": "${dir:state}/acme.env", + "mode": "0600", + "template": "files/acme.env" + }, + { + "id": "internal-acme-env", + "type": "file", + "path": "${dir:state}/internal-acme.env", + "mode": "0600", + "template": "files/internal-acme.env" + }, + { + "id": "trust", + "type": "container", + "name": "route-proxy-trust", + "artifact": "trust", + "run-once": true, + "network": "host", + "env-file": [ + "${dir:state}/acme.env" + ], + "volumes": [ + "${dir:ca-dir}:/ca" + ], + "args": [ + "sh", + "-c", + "if [ -z \"$ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/root.crt \"$ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/root.crt && exit 0; sleep 2; done; echo \"the authority at $ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" + ], + "restart-on": [ + "acme-env" + ] + }, + { + "id": "internal-trust", + "type": "container", + "name": "route-proxy-internal-trust", + "artifact": "trust", + "run-once": true, + "network": "host", + "env-file": [ + "${dir:state}/internal-acme.env" + ], + "volumes": [ + "${dir:ca-dir}:/ca" + ], + "args": [ + "sh", + "-c", + "if [ -z \"$INTERNAL_ACME_ROOTS_PATH\" ]; then cp /etc/ssl/certs/ca-certificates.crt /ca/internal-root.crt; exit 0; fi; for i in $(seq 1 60); do wget -q -T 10 --no-check-certificate -O /ca/internal-root.crt \"$INTERNAL_ACME_ROOTS\" && grep -q 'BEGIN CERTIFICATE' /ca/internal-root.crt && exit 0; sleep 2; done; echo \"the authority at $INTERNAL_ACME_ROOTS did not serve its roots within two minutes\" >&2; exit 1" + ], + "restart-on": [ + "internal-acme-env" + ] + }, + { + "id": "program-route-proxy", + "type": "archive", + "path": "${dir:state}/route-proxy", + "artifact": "server", + "unknown": "program" + }, + { + "id": "server", + "type": "container", + "name": "route-proxy", + "artifact": "trust", + "network": "host", + "env-file": [ + "${dir:state}/acme.env", + "${dir:state}/internal-acme.env" + ], + "volumes": [ + "${dir:acme-cache}:/acme", + "${dir:ca-dir}:/ca:ro", + "${dir:mesh-state}/broker:/run/secrets/broker:ro", + "${dir:state}/route-proxy:/opt/mesh:ro" + ], + "env": { + "LISTEN": ":80", + "TLS_LISTEN": ":443", + "ACME_CACHE": "/acme", + "ACME_CA_BUNDLE": "/ca/root.crt", + "INTERNAL_ACME_CA_BUNDLE": "/ca/internal-root.crt", + "MESH_BROKER_FILE": "/run/secrets/broker" + }, + "args": [ + "/opt/mesh/route-proxy" + ], + "restart-on": [ + "trust", + "acme-env", + "internal-trust", + "internal-acme-env", + "program-route-proxy" + ], + "logging": "journald", + "health": { + "kind": "tcp", + "endpoint": "http", + "interval": "10s", + "timeout": "5s", + "looks": 3, + "grace": "60s" + } + } + ], + "build": { + "artifacts": [ + { + "name": "server", + "kind": "bundle", + "language": "go", + "system": "alpine", + "from": "cmd/route-proxy", + "binary": "route-proxy" + }, + { + "name": "trust", + "kind": "upstream", + "from": "alpine@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b" + }, + { + "name": "provider", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/route-provider", + "binary": "route-provider", + "loads": [ + "route-provider" + ] + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + }, + "jails": [ + { + "name": "route-proxy", + "failregex": "^.*TLS handshake error from :\\d+: (?:no public route for|acme/autocert: missing server name)\n ^.*refused: no route for .*, asked from :\\d+$", + "jail": "backend = systemd\njournalmatch = CONTAINER_NAME=route-proxy\nport = http,https\nmaxretry = 10\nfindtime = 1d\nbantime = 1d" + } + ], + "state": [ + "bound", + "routes", + { + "name": "unrouted", + "ttl-seconds": 604800 + } + ], + "tools": [ + "route_proxy_routes", + "route_proxy_route", + "route_proxy_unrouted" + ], + "emits": [ + "route.served", + "route.withdrawn", + "route.refused" + ], + "replaces": { + "route_proxy_routes": [ + "curl -I" + ], + "route_proxy_route": [ + "openssl s_client -connect" + ], + "route_proxy_unrouted": [ + "docker logs route-proxy" + ] + }, + "reads": [ + "mesh-delivery-assigner.placement", + "mesh-provisioner.plans", + "mesh-provisioner.provisions" + ] +}