diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_entry_keys_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_entry_keys_test.go new file mode 100644 index 00000000..0b117768 --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_entry_keys_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// Issue 945: the SDK names the vault's credential of a bound entry `vault-name`, and the state guard refuses an entry +// written under the old key `credential`. The client never reads that key, but a fixture that carries it is a bound +// entry the mesh would refuse, so no fixture of this client writes one. +func TestNoFixtureCarriesABoundEntryKeyTheStateGuardRefuses(t *testing.T) { + files, err := filepath.Glob("*_test.go") + if err != nil { + t.Fatal(err) + } + old := `"credential"` + `:"pair/` // a bound entry's credential, under its old key + for _, f := range files { + raw, err := os.ReadFile(f) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), old) { + t.Errorf("%s has a bound entry with the key credential; the SDK names it vault-name (issue 945)", f) + } + } +} diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go index 23664ee4..c7ed03eb 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/bound_gitea_test.go @@ -63,7 +63,7 @@ func giteaRun(t *testing.T, entry string, extra string) (*engine, *aBus, *report func TestNatsAHandedGiteasServerEnvIsFilledAsTheControllerComposedIt(t *testing.T) { e, _, _ := giteaRun(t, `{"provider":"novox","provider-module":"postgres","provision":"postgres-database",`+ `"consumer":"g14","module":"gitea","as":"mesh_g14_gitea","user":"mesh_g14_gitea",`+ - `"values":{"at":"10.7.0.1","from":"novox","port":5432},"credential":"pair/novox/postgres-database/g14/gitea"}`, + `"values":{"at":"10.7.0.1","from":"novox","port":5432},"vault-name":"pair/novox/postgres-database/g14/gitea"}`, "FROM=${bound:postgres-database:from}\n") eventually(t, "server.env is filled to the controller's text", func() bool { return e.read(t, giteaEnvPath) == giteaBoundExpected("tok-never-on-the-bus", "pw-never-on-the-bus")+"FROM=novox\n" diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go index 469e4396..b6a75b20 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go @@ -224,7 +224,7 @@ func TestNatsAConsumersBoundValuesAreFilledFromItsProvidersEntry(t *testing.T) { }) b.put(t, "postgres_bound", node+".app.postgres-database", `{"provider":"novox","provider-module":"postgres",`+ `"provision":"postgres-database","consumer":"g14","module":"app","as":"mesh_g14_app","user":"mesh_g14_app",`+ - `"values":{"host":"novox.mesh","port":5432},"credential":"pair/novox/postgres-database/g14/app"}`) + `"values":{"host":"novox.mesh","port":5432},"vault-name":"pair/novox/postgres-database/g14/app"}`) eventually(t, "the file is filled from its provider's entry", func() bool { return e.read(t, appBound) == "HOST=novox.mesh\nPORT=5432\nUSER=mesh_g14_app\n" }) diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_forge_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_forge_test.go index 848227ef..882670f9 100644 --- a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_forge_test.go +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/moved_forge_test.go @@ -86,7 +86,7 @@ func TestNatsAMovedGiteaAndClaudeCodeHaveTheirFilesFilledFromTheBus(t *testing.T } b.put(t, "postgres_bound", node+".gitea.postgres-database", `{"provider":"novox","provider-module":"postgres",`+ `"provision":"postgres-database","consumer":"g14","module":"gitea","as":"mesh_g14_gitea","user":"mesh_g14_gitea",`+ - `"values":{"at":"10.7.0.1","from":"novox","port":5432},"credential":"pair/novox/postgres-database/g14/gitea"}`) + `"values":{"at":"10.7.0.1","from":"novox","port":5432},"vault-name":"pair/novox/postgres-database/g14/gitea"}`) b.put(t, "route-proxy_bound", node+".gitea.route", `{"provider":"novox","provider-module":"route-proxy",`+ `"provision":"route","consumer":"g14","module":"gitea","as":"mesh_g14_gitea",`+ `"values":{"name":"git.example.org","internal-name":"git.novox.internal","name-web":"git.example.org"}}`) diff --git a/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/route_names_test.go b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/route_names_test.go new file mode 100644 index 00000000..42b27ecf --- /dev/null +++ b/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/route_names_test.go @@ -0,0 +1,138 @@ +package main + +import ( + "context" + "os" + "strings" + "sync" + "testing" + "time" + + "git.novox.be/novox/mesh-sdk/go/provider" + "github.com/nats-io/nats.go/jetstream" +) + +// Issue 923: route-proxy serves a consumer of its route the names the controller composed, and the consumer's client +// fills gitea's origins and keycloak's hostname.env from them. The route-proxy provider is run here as its module +// runs it (the SDK helper, `route` offered with no credential and nothing served), over a plan entry shaped as the +// controller writes it (mesh-controller handed.go PlanProvision: `at`, `from`, `as` and `names`), and the entry it +// writes is the one the client reads. + +// memBus is the provider helper's bus over in-memory buckets. +type memBus struct { + mu sync.Mutex + buckets map[string]map[string][]byte +} + +func (m *memBus) Watch(_ context.Context, bucket string, handle func(string, []byte)) error { + m.mu.Lock() + defer m.mu.Unlock() + for k, v := range m.buckets[bucket] { + handle(k, v) + } + return nil +} + +func (m *memBus) Put(_ context.Context, bucket, key string, value []byte) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.buckets[bucket] == nil { + m.buckets[bucket] = map[string][]byte{} + } + m.buckets[bucket][key] = append([]byte(nil), value...) + return nil +} + +func (m *memBus) Delete(_ context.Context, bucket, key string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.buckets[bucket], key) + return nil +} + +// routeEntryFor is the entry route-proxy's helper writes for module's plan entry on the route provision. +func routeEntryFor(t *testing.T, module, planEntry string) string { + t.Helper() + bus := &memBus{buckets: map[string]map[string][]byte{provider.AssignmentsBucket: { + node + "." + module: []byte(`{"node":"` + node + `","module":"` + module + `","provisioning":{"provisions":[` + + planEntry + `]}}`)}}} + p := provider.New(provider.Options{Node: "novox", Module: "route-proxy", Bus: bus, Logf: t.Logf, + Offers: map[string]provider.Offer{"route": {Credential: provider.NoCredential, Serves: map[string]any{}}}}) + out, err := p.Pass(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0].Status != provider.Answered { + t.Fatalf("the provider did %+v", out) + } + return string(bus.buckets[provider.BucketOf("route-proxy")][node+"."+module+".route"]) +} + +func routeRun(t *testing.T, module, id, path, templatePath, entryKey, firstEntry string) (*engine, *aBus, *reports) { + t.Helper() + raw, err := os.ReadFile(templatePath) + if err != nil { + t.Fatal(err) + } + b := startBus(t) + registryOf679(t, b) + startVault(t, b) + heard := b.listen(t) + e := startEngine(t, map[string]string{path: "owned"}) + tpl, _ := templates(t, map[string]string{id: string(raw)}) + b.assign(t, module, Plan{Templates: tpl, Files: []PlannedFile{{ID: id, Path: path, Template: id, + OwnerAtRuntime: "owned"}}, + Provisions: []PlannedProvision{{Provision: "route", Provider: "novox", ProviderModule: "route-proxy"}}}) + kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "route-proxy_bound"}) + if err != nil { + t.Fatal(err) + } + b.kv["route-proxy_bound"] = kv + if firstEntry != "" { + b.put(t, "route-proxy_bound", node+"."+entryKey, firstEntry) + } + aClient(t, b, e, t.TempDir(), time.Hour) + return e, b, heard +} + +// gitea's plan entry for its `web` route, as the controller writes it for a node whose public domain is example.org. +const giteaRoutePlanEntry = `{"provision":"route","provider":"novox","provider-module":"route-proxy",` + + `"at":"novox.internal","from":"novox","as":"mesh_g14_gitea",` + + `"names":{"name-web":"git.example.org","internal-name-web":"git.novox.internal",` + + `"name-internal-api-refused":"git.example.org","internal-name-internal-api-refused":"git.novox.internal"}}` + +const keycloakRoutePlanEntry = `{"provision":"route","provider":"novox","provider-module":"route-proxy",` + + `"at":"novox.internal","from":"novox","as":"mesh_g14_keycloak",` + + `"names":{"name":"keycloak.example.org","internal-name":"keycloak.novox.internal"}}` + +func TestNatsGiteasOriginsAreFilledFromRouteProxysEntryForThePlanEntryTheControllerWrites(t *testing.T) { + e, _, _ := routeRun(t, "gitea", "origins", "/etc/gitea/origins", "../../../gitea/files/origins", "gitea.route", + routeEntryFor(t, "gitea", giteaRoutePlanEntry)) + eventually(t, "origins is filled with the public name", func() bool { + return e.read(t, "/etc/gitea/origins") == "https://git.example.org\n" + }) +} + +func TestNatsKeycloaksHostnameIsFilledFromRouteProxysEntryForThePlanEntryTheControllerWrites(t *testing.T) { + e, _, _ := routeRun(t, "keycloak", "hostname", "/etc/keycloak/hostname.env", "../../../keycloak/files/hostname.env", + "keycloak.route", routeEntryFor(t, "keycloak", keycloakRoutePlanEntry)) + eventually(t, "hostname.env is filled with the public name", func() bool { + return e.read(t, "/etc/keycloak/hostname.env") == "KC_HOSTNAME=https://keycloak.example.org\n" + }) +} + +// A module that failed on a missing name is filled once the provider's entry carries it: the entry first held no +// names (as the provider wrote it before the controller composed them), then the controller's plan entry arrives. +func TestNatsAModuleThatFailedOnAMissingNameIsFilledWhenTheNameArrives(t *testing.T) { + bare := routeEntryFor(t, "gitea", `{"provision":"route","provider":"novox","provider-module":"route-proxy",`+ + `"as":"mesh_g14_gitea"}`) + e, b, heard := routeRun(t, "gitea", "origins", "/etc/gitea/origins", "../../../gitea/files/origins", "gitea.route", bare) + eventually(t, "the module fails for the missing name", func() bool { + r, _ := heard.last("gitea") + return r.Status == StatusFailed && strings.Contains(r.Reason, `no "name-web"`) + }) + b.put(t, "route-proxy_bound", node+".gitea.route", routeEntryFor(t, "gitea", giteaRoutePlanEntry)) + eventually(t, "origins is filled once the name arrives", func() bool { + return e.read(t, "/etc/gitea/origins") == "https://git.example.org\n" + }) +} diff --git a/modules/route-proxy/cmd/route-provider/provider_test.go b/modules/route-proxy/cmd/route-provider/provider_test.go index f5c4b7c2..6c580b97 100644 --- a/modules/route-proxy/cmd/route-provider/provider_test.go +++ b/modules/route-proxy/cmd/route-provider/provider_test.go @@ -105,4 +105,21 @@ func TestRouteProxyIsNotMovedAndKeepsItsOwnFiles(t *testing.T) { if got := hex.EncodeToString(sum[:]); got != "57a39de0943d8b33b9db6cb998ce97f8793602cca6cdfa5849c1c392390454a3" { t.Fatalf("route-proxy's own manifest changed: digest %s", got) } + // The two acme templates are pinned the same way (issue 849): they are what the proxy's certificates are asked + // with, so a change to either is a change to the public ingress. + for file, want := range map[string]string{ + "acme.env": "d66eba145c893080e30e013329fc00e51395f0c31fc8621a20c7b26abc11b52f", + "internal-acme.env": "baf4da75379183758c8ae505be4dd8a79d5b089ae1e7c8ded4d232df40251df5", + } { + text, err := os.ReadFile("../../files/" + file) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(text) + if got := hex.EncodeToString(sum[:]); got != want { + t.Fatalf("route-proxy's files/%s changed: digest %s, pinned %s; if the change is meant, check that the "+ + "public and internal ingress still ask for the certificates they should, then pin the new digest here", + file, got, want) + } + } }