Files
mesh-catalog/modules/mesh-provisioner-client/cmd/mesh-provisioner-client/consumers_test.go
T
jochen e7ff7c3045
mesh/merge-gate head e7ff7c3045fc merged into trunk afb94d925e94 — pass: builds mesh-provisioner-client → ace, g14, novox, shanks; no bus step; every machi…
mesh/repo-check head e7ff7c3045fc merged into trunk afb94d925e94 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered
Write a provisioner client's files only on a change over the bus, never on a timer (issue 1129, ADR 0349 clause 4)
2026-10-18 18:08:50 +02:00

263 lines
12 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"github.com/nats-io/nats.go/jetstream"
)
// A provider's consumers' credentials, filled by its machine's client (novox/hq ADR 0373, issue 679), against a real
// bus whose accounts are the client's as mesh-controller composes them: a provider template with
// ${consumers:<provision>} is filled with one entry per bound consumer, in the plan's provisions order, each carrying
// the credential the vault holds for that pair; a consumer that binds or unbinds, or a credential the vault remakes,
// changes the file within one watch; no value crosses the bus in the clear. And a consumer's ${bound:…} is filled from
// its provider's entry (mesh-sdk go/provider, issue 678).
const consumersFile = "/var/lib/postgres/consumers.json"
// registryOf679 writes the registry's two answers this issue adds, as mesh-provisioner writes them.
func registryOf679(t *testing.T, b *aBus) {
t.Helper()
b.put(t, RegistryBucket, "consumers", `{"kind":"consumers","domain":"mesh-vault","from":"ask","ask":"mesh-vault.get"}`)
b.put(t, RegistryBucket, "bound", `{"kind":"bound","domain":"provider","from":"bucket",`+
`"bucket":"<provider-module>_bound","key":"<node>.<module>.<provision>[.<local>]"}`)
}
// bind writes another machine's module's assignment, whose plan binds it to providers, as the controller does.
func (b *aBus) bind(t *testing.T, at, module string, provisions ...PlannedProvision) {
t.Helper()
ps, err := json.Marshal(provisions)
if err != nil {
t.Fatal(err)
}
b.put(t, AssignmentsBucket, at+"."+module, fmt.Sprintf(`{"node":%q,"module":%q,"assigned":"2026-10-14T20:00:00Z",`+
`"provisioning":{"templates":{"source":"x","digest":"sha256:%s"},"files":[{"id":"c","path":"/etc/c",`+
`"template":"c","owner-at-runtime":"owned"}],"provisions":%s}}`, at, module, strings.Repeat("0", 64), ps))
}
func postgresPlan(t *testing.T, text string) Plan {
tpl, _ := templates(t, map[string]string{"consumers.json": text})
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "consumers", Path: consumersFile, Template: "consumers.json",
OwnerAtRuntime: "owned"}}}
}
func fromHere(local string) PlannedProvision {
return PlannedProvision{Provision: "postgres-database", Local: local, Provider: node, ProviderModule: "postgres"}
}
func pairOf(at, module, local string) string {
n := "pair/" + node + "/postgres-database/" + at + "/" + module
if local != "" {
n += "/" + local
}
return n
}
type listed struct {
Consumer string `json:"consumer"`
Module string `json:"module"`
Local string `json:"local,omitempty"`
Credential string `json:"credential"`
Value string `json:"value"`
}
func consumersIn(t *testing.T, e *engine) []listed {
t.Helper()
var out []listed
if err := json.Unmarshal([]byte(e.read(t, consumersFile)), &out); err != nil {
return nil
}
return out
}
func sameList(have []listed, want ...listed) bool {
if len(have) != len(want) {
return false
}
for i := range want {
if have[i] != want[i] {
return false
}
}
return true
}
func TestNatsAProvidersConsumersAreFilledFromTheVaultAndFollowEachBindAndRemake(t *testing.T) {
b := startBus(t)
registryOf679(t, b)
heard := b.listen(t)
wire := sniff(t, b)
vault := startVault(t, b)
vault.keep(pairOf("ace", "letta", ""), "letta-pw-never-on-the-bus", false)
vault.keep(pairOf("shanks", "gitea", "cache"), "gitea-pw-never-on-the-bus", false)
// Bound to this provider: ace's letta, and shanks's gitea under a local name. Not: a provision of another
// provider, another provision, and a credential its provider's software issued per holder (issue 757).
b.bind(t, "ace", "letta", fromHere(""),
PlannedProvision{Provision: "postgres-database", Local: "old", Provider: "ace", ProviderModule: "postgres"})
b.bind(t, "shanks", "gitea", PlannedProvision{Provision: "redis", Provider: node, ProviderModule: "postgres"},
fromHere("cache"),
PlannedProvision{Name: "token", Provision: "forge-read", Provider: node, ProviderModule: "postgres"})
// Nor a bind whose offer names another credential form than a login per consumer (issue 768).
b.bind(t, "ace", "shared", PlannedProvision{Provision: "postgres-database", Provider: node,
ProviderModule: "postgres", CredentialForm: "shared"})
e := startEngine(t, map[string]string{consumersFile: "owned"})
b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}"))
aClient(t, b, e, t.TempDir())
letta := listed{Consumer: "ace", Module: "letta", Credential: pairOf("ace", "letta", ""),
Value: "letta-pw-never-on-the-bus"}
gitea := listed{Consumer: "shanks", Module: "gitea", Local: "cache", Credential: pairOf("shanks", "gitea", "cache"),
Value: "gitea-pw-never-on-the-bus"}
eventually(t, "the file lists each bound consumer with its credential, in the plans' order", func() bool {
return sameList(consumersIn(t, e), letta, gitea)
})
eventually(t, "prov.applied says applied", func() bool {
r, _ := heard.last("postgres")
return r.Status == StatusApplied
})
// A consumer binds, on this machine: listed once the vault holds its credential; until then left out, and the
// others kept.
b.bind(t, node, "photos", fromHere(""))
time.Sleep(300 * time.Millisecond)
if !sameList(consumersIn(t, e), letta, gitea) {
t.Fatalf("a consumer whose credential the vault does not hold yet changed the file: %+v", consumersIn(t, e))
}
vault.keep(pairOf(node, "photos", ""), "photos-pw-never-on-the-bus", true)
photos := listed{Consumer: node, Module: "photos", Credential: pairOf(node, "photos", ""),
Value: "photos-pw-never-on-the-bus"}
eventually(t, "a consumer that binds is listed when the vault holds its credential, in its assignment's order",
func() bool { return sameList(consumersIn(t, e), letta, photos, gitea) })
// The vault remakes a credential.
vault.keep(pairOf("ace", "letta", ""), "letta-pw-remade-never-on-the-bus", true)
letta.Value = "letta-pw-remade-never-on-the-bus"
eventually(t, "a credential the vault remakes reaches the file", func() bool {
return sameList(consumersIn(t, e), letta, photos, gitea)
})
// A consumer unbinds.
b.del(t, AssignmentsBucket, "shanks.gitea")
eventually(t, "a consumer that unbinds leaves the file", func() bool {
return sameList(consumersIn(t, e), letta, photos)
})
// Another provider's consumer's credential changing is no reason to ask.
before := vault.times(pairOf("ace", "letta", ""))
vault.keep("pair/ace/postgres-database/ace/letta/old", "elsewhere", true)
time.Sleep(300 * time.Millisecond)
if vault.times("pair/ace/postgres-database/ace/letta/old") != 0 || vault.times(pairOf("ace", "letta", "")) != before {
t.Error("a change of another provider's consumer made the client ask")
}
if wire.heard() == 0 {
t.Fatal("the sniffer heard nothing: it proves nothing")
}
for _, value := range []string{"letta-pw", "gitea-pw", "photos-pw"} {
if said := wire.carrying(value); len(said) > 0 {
t.Errorf("a consumer's credential crossed the bus in the clear: %q", said)
}
}
// The need is recorded as any other, by name: never a value.
if keys := b.keys(t, NeedsBucket, node+".postgres."); len(keys) != 1 ||
keys[0] != node+".postgres.consumers.postgres-database" {
t.Errorf("the provider's needs are %v", keys)
}
}
// A provider with no consumer bound is filled with an empty list, never left waiting.
func TestNatsAProviderWithNoConsumerIsFilledWithAnEmptyList(t *testing.T) {
b := startBus(t)
registryOf679(t, b)
startVault(t, b)
e := startEngine(t, map[string]string{consumersFile: "owned"})
b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}"))
aClient(t, b, e, t.TempDir())
eventually(t, "the file holds an empty list", func() bool { return e.read(t, consumersFile) == "[]" })
}
// An item is kept in a bucket, where no secret is: an item naming its consumers fails.
func TestAnItemNamingConsumersIsRefused(t *testing.T) {
p := Plan{Items: []PlannedItem{{Collection: "c", Template: "item"}}}
if _, why := itemTargets(p, map[string]string{"item": "${consumers:postgres-database}"}); !strings.Contains(why,
"no secret") {
t.Fatalf("an item naming consumers: %q", why)
}
}
const appBound = "/etc/app/database.env"
func appPlan(t *testing.T) Plan {
tpl, _ := templates(t, map[string]string{"database.env": "HOST=${bound:postgres-database:host}\n" +
"PORT=${bound:postgres-database:port}\nUSER=${bound:postgres-database:user}\n"})
return Plan{Templates: tpl, Files: []PlannedFile{{ID: "db", Path: appBound, Template: "database.env",
OwnerAtRuntime: "owned"}},
Provisions: []PlannedProvision{{Provision: "postgres-database", Provider: "novox", ProviderModule: "postgres"}}}
}
func TestNatsAConsumersBoundValuesAreFilledFromItsProvidersEntry(t *testing.T) {
b := startBus(t)
registryOf679(t, b)
heard := b.listen(t)
e := startEngine(t, map[string]string{appBound: "owned"})
b.assign(t, "app", appPlan(t))
aClient(t, b, e, t.TempDir())
// No bucket of the provider's yet: waited for, and said.
eventually(t, "the consumer waits for its provider's bucket", func() bool {
r, _ := heard.last("app")
return r.Status == StatusWaiting && strings.Contains(strings.Join(r.WaitsFor, " "), "postgres_bound")
})
kv, err := b.domain.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: "postgres_bound"})
if err != nil {
t.Fatal(err)
}
b.kv["postgres_bound"] = kv
// The provider has not answered it yet: waited for.
b.put(t, "postgres_bound", "ace.app.postgres-database", `{"values":{"host":"elsewhere"}}`)
b.assign(t, "app", appPlan(t)) // a change of its own is the only reason it looks again: there is no timer
eventually(t, "the consumer waits for its own entry", func() bool {
r, _ := heard.last("app")
return r.Status == StatusWaiting && strings.Contains(strings.Join(r.WaitsFor, " "), "postgres on novox")
})
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},"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"
})
b.put(t, "postgres_bound", node+".app.postgres-database", `{"user":"mesh_g14_app",`+
`"values":{"host":"novox.lan","port":5432}}`)
eventually(t, "a changed entry refills the file", func() bool {
return e.read(t, appBound) == "HOST=novox.lan\nPORT=5432\nUSER=mesh_g14_app\n"
})
b.put(t, "postgres_bound", node+".app.postgres-database", `{"refused":"app on g14 keeps several holders"}`)
eventually(t, "an entry the provider refused fails the consumer with its reason", func() bool {
r, _ := heard.last("app")
return r.Status == StatusFailed && strings.Contains(r.Reason, "keeps several holders")
})
}
// A consumer whose plan names its login's file (issue 779) is still a bind of its provider: it is listed, where an
// entry naming a value its provider's software issued per holder (issue 757) is not.
func TestNatsAProviderListsAConsumerWhoseEntryNamesItsLoginFile(t *testing.T) {
b := startBus(t)
registryOf679(t, b)
vault := startVault(t, b)
vault.keep(pairOf("ace", "nextcloud", ""), "nc-pw-never-on-the-bus", false)
vault.keep(pairOf("ace", "forge", ""), "forge-pw", false)
b.bind(t, "ace", "nextcloud", PlannedProvision{Name: "database", Provision: "postgres-database", Provider: node,
ProviderModule: "postgres", CredentialForm: "pair", Path: "/s/database.secret"})
b.bind(t, "ace", "forge", PlannedProvision{Name: "database", Provision: "postgres-database", Provider: node,
ProviderModule: "postgres", CredentialForm: "issued", Path: "/s/forge.secret"})
e := startEngine(t, map[string]string{consumersFile: "owned"})
b.assign(t, "postgres", postgresPlan(t, "${consumers:postgres-database}"))
aClient(t, b, e, t.TempDir())
eventually(t, "the consumer with a login file is listed, the per-holder one is not", func() bool {
return sameList(consumersIn(t, e), listed{Consumer: "ace", Module: "nextcloud",
Credential: pairOf("ace", "nextcloud", ""), Value: "nc-pw-never-on-the-bus"})
})
}