a466b3e07f
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/push/pre-commit Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Apps fronted by valkey-operator instances need the same wait-for-ready initContainer postgres workloads already get, and wiring per-field secretKeyRefs for operator-generated secrets is boilerplate. waitfordb now speaks enough RESP to AUTH and PING, and natively understands the secret shapes CNPG and valkey-operator generate so an initContainer is just envFrom plus a mode variable. - valkey driver (alias: redis): fresh TCP connection per attempt, optional AUTH (ACL user or default), PING, reusing the existing retry/backoff wait loop; redis:///valkey:// DSNs; default port 6379 - WAITFORDB_SECRET_FORMAT=cnpg|valkey for envFrom-injected operator secrets: CNPG <cluster>-app host/port/dbname/user/password keys, and valkey-operator key-per-username secrets (_operator preferred, or WAITFORDB_USER's same-named key) - autodetection from injected keys (CNPG keys -> postgres, valkey keys -> valkey); explicit WAITFORDB_DRIVER/WAITFORDB_* always win, PG* fallback and all existing flags unchanged - valkey needs no user/database to be valid (unauthenticated PING) - tests: secret shape parsing/autodetect/mismatch, fake RESP server covering NOAUTH/WRONGPASS/ACL auth/DSN, retry-until-up wait - README: envFrom initContainer snippets for CNPG and valkey-operator
330 lines
9.5 KiB
Go
330 lines
9.5 KiB
Go
package config
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// envFrom returns a Getenv backed by a map.
|
|
func envFrom(m map[string]string) Getenv {
|
|
return func(k string) string { return m[k] }
|
|
}
|
|
|
|
func TestLoadDefaults(t *testing.T) {
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_USER": "appuser",
|
|
"WAITFORDB_DATABASE": "appdb",
|
|
"WAITFORDB_HOST": "db",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Driver != "postgres" {
|
|
t.Errorf("driver = %q, want postgres", c.Driver)
|
|
}
|
|
if c.Port != "5432" {
|
|
t.Errorf("port = %q, want default 5432", c.Port)
|
|
}
|
|
if c.Interval != 2*time.Second {
|
|
t.Errorf("interval = %v, want 2s", c.Interval)
|
|
}
|
|
if c.ConnectTimeout != 5*time.Second {
|
|
t.Errorf("connect_timeout = %v, want 5s", c.ConnectTimeout)
|
|
}
|
|
if c.Timeout != 0 {
|
|
t.Errorf("timeout = %v, want 0 (forever)", c.Timeout)
|
|
}
|
|
}
|
|
|
|
func TestPrecedenceDSNWins(t *testing.T) {
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_DSN": "postgres://u:p@h:5432/d",
|
|
"WAITFORDB_HOST": "ignored",
|
|
"WAITFORDB_USER": "ignored",
|
|
"WAITFORDB_DATABASE": "ignored",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.DSN == "" {
|
|
t.Fatal("DSN should be set")
|
|
}
|
|
}
|
|
|
|
func TestPrecedenceWaitfordbOverPG(t *testing.T) {
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_HOST": "native-host",
|
|
"PGHOST": "pg-host",
|
|
"WAITFORDB_USER": "native-user",
|
|
"PGUSER": "pg-user",
|
|
"PGDATABASE": "pg-db", // only PG* set for database -> fallback used
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Host != "native-host" {
|
|
t.Errorf("host = %q, want native-host (WAITFORDB_* wins)", c.Host)
|
|
}
|
|
if c.User != "native-user" {
|
|
t.Errorf("user = %q, want native-user", c.User)
|
|
}
|
|
if c.Database != "pg-db" {
|
|
t.Errorf("database = %q, want pg-db (PG* fallback)", c.Database)
|
|
}
|
|
}
|
|
|
|
func TestPGFallbackPostgresOnly(t *testing.T) {
|
|
// For a non-postgres driver the PG* fallback must not apply.
|
|
_, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_DRIVER": "mysql",
|
|
"PGUSER": "pg-user",
|
|
"PGDATABASE": "pg-db",
|
|
// No WAITFORDB_USER/DATABASE -> must be a config error, PG* ignored.
|
|
}))
|
|
if err == nil {
|
|
t.Fatal("expected config error: PG* must not satisfy mysql config")
|
|
}
|
|
}
|
|
|
|
func TestMySQLDefaultPort(t *testing.T) {
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_DRIVER": "mysql",
|
|
"WAITFORDB_USER": "u",
|
|
"WAITFORDB_DATABASE": "d",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Port != "3306" {
|
|
t.Errorf("port = %q, want 3306", c.Port)
|
|
}
|
|
}
|
|
|
|
func TestValidationErrors(t *testing.T) {
|
|
cases := map[string]map[string]string{
|
|
"missing database": {"WAITFORDB_USER": "u"},
|
|
"missing user": {"WAITFORDB_DATABASE": "d"},
|
|
"bad driver": {"WAITFORDB_DRIVER": "oracle", "WAITFORDB_USER": "u", "WAITFORDB_DATABASE": "d"},
|
|
"bad timeout": {"WAITFORDB_USER": "u", "WAITFORDB_DATABASE": "d", "WAITFORDB_TIMEOUT": "nope"},
|
|
"zero interval": {"WAITFORDB_USER": "u", "WAITFORDB_DATABASE": "d", "WAITFORDB_INTERVAL": "0"},
|
|
}
|
|
for name, env := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := Load(envFrom(env)); err == nil {
|
|
t.Fatalf("expected error for %s", name)
|
|
} else if _, ok := err.(*Error); !ok {
|
|
t.Fatalf("expected *config.Error, got %T", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRedactedHidesPassword(t *testing.T) {
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_HOST": "h",
|
|
"WAITFORDB_USER": "u",
|
|
"WAITFORDB_PASSWORD": "sup3r-s3cret",
|
|
"WAITFORDB_DATABASE": "d",
|
|
"WAITFORDB_TIMEOUT": "5m",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
got := c.Redacted()
|
|
if strings.Contains(got, "sup3r-s3cret") {
|
|
t.Fatalf("redacted output leaked password: %q", got)
|
|
}
|
|
for _, want := range []string{"driver=postgres", "database=d", "user=u", "password=***", "timeout=5m", "interval=2s"} {
|
|
if !strings.Contains(got, want) {
|
|
t.Errorf("redacted output missing %q: %q", want, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRedactedForeverTimeout(t *testing.T) {
|
|
c, _ := Load(envFrom(map[string]string{"WAITFORDB_USER": "u", "WAITFORDB_DATABASE": "d"}))
|
|
if !strings.Contains(c.Redacted(), "timeout=forever") {
|
|
t.Errorf("want timeout=forever, got %q", c.Redacted())
|
|
}
|
|
}
|
|
|
|
func TestRedactedDSNMasksPassword(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
dsn string
|
|
}{
|
|
{"url", "postgres://user:topsecret@host:5432/db?sslmode=disable"},
|
|
{"keyword", "host=h user=u password=topsecret dbname=d"},
|
|
{"mysql", "user:topsecret@tcp(h:3306)/d?timeout=5s"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
c := Config{DSN: tc.dsn, Timeout: 0, Interval: time.Second, ConnectTimeout: time.Second, Driver: "postgres"}
|
|
got := c.Redacted()
|
|
if strings.Contains(got, "topsecret") {
|
|
t.Fatalf("leaked password: %q", got)
|
|
}
|
|
if !strings.Contains(got, "***") {
|
|
t.Errorf("expected redaction marker in %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCNPGAutodetect(t *testing.T) {
|
|
// Shape of a CNPG <cluster>-app secret injected wholesale via envFrom.
|
|
c, err := Load(envFrom(map[string]string{
|
|
"host": "mydb-rw.ns.svc",
|
|
"port": "5432",
|
|
"dbname": "appdb",
|
|
"user": "appuser",
|
|
"username": "appuser",
|
|
"password": "pw",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Driver != "postgres" || c.SecretFormat != FormatCNPG {
|
|
t.Errorf("driver=%q format=%q, want postgres/cnpg", c.Driver, c.SecretFormat)
|
|
}
|
|
if c.Host != "mydb-rw.ns.svc" || c.Database != "appdb" || c.User != "appuser" || c.Password != "pw" {
|
|
t.Errorf("fields not filled from CNPG keys: %+v", c)
|
|
}
|
|
}
|
|
|
|
func TestCNPGExplicitFormat(t *testing.T) {
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_SECRET_FORMAT": "cnpg",
|
|
"host": "h",
|
|
"dbname": "d",
|
|
"username": "u",
|
|
"password": "pw",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.SecretFormat != FormatCNPG || c.User != "u" {
|
|
t.Errorf("format=%q user=%q, want cnpg/u", c.SecretFormat, c.User)
|
|
}
|
|
}
|
|
|
|
func TestWaitfordbOverridesCNPGKeys(t *testing.T) {
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_HOST": "explicit-host",
|
|
"host": "secret-host",
|
|
"dbname": "d",
|
|
"user": "u",
|
|
"password": "pw",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Host != "explicit-host" {
|
|
t.Errorf("host = %q, want explicit-host (WAITFORDB_* wins over secret keys)", c.Host)
|
|
}
|
|
if c.Database != "d" || c.Password != "pw" {
|
|
t.Errorf("unset fields should still fill from secret: %+v", c)
|
|
}
|
|
}
|
|
|
|
func TestValkeySystemSecretAutodetect(t *testing.T) {
|
|
// Shape of the valkey-operator system-passwords secret: key = username.
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_HOST": "myapp-valkey.ns.svc",
|
|
"_operator": "op-pass",
|
|
"_replication": "repl-pass",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Driver != "valkey" || c.SecretFormat != FormatValkey {
|
|
t.Errorf("driver=%q format=%q, want valkey/valkey", c.Driver, c.SecretFormat)
|
|
}
|
|
if c.User != "_operator" || c.Password != "op-pass" {
|
|
t.Errorf("user=%q password=%q, want _operator/op-pass", c.User, c.Password)
|
|
}
|
|
if c.Port != "6379" {
|
|
t.Errorf("port = %q, want default 6379", c.Port)
|
|
}
|
|
}
|
|
|
|
func TestValkeyACLUserSecret(t *testing.T) {
|
|
// An explicit ACL username whose password arrives as an env key of the
|
|
// same name (valkey-operator user secret via envFrom).
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_DRIVER": "valkey",
|
|
"WAITFORDB_USER": "appuser",
|
|
"appuser": "app-pass",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.SecretFormat != FormatValkey || c.Password != "app-pass" {
|
|
t.Errorf("format=%q password=%q, want valkey/app-pass", c.SecretFormat, c.Password)
|
|
}
|
|
}
|
|
|
|
func TestValkeyExplicitFlagsNoSecret(t *testing.T) {
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_DRIVER": "valkey",
|
|
"WAITFORDB_HOST": "h",
|
|
"WAITFORDB_PASSWORD": "pw",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("valkey needs no user/database: %v", err)
|
|
}
|
|
if c.SecretFormat != "" {
|
|
t.Errorf("format = %q, want none without operator secret keys", c.SecretFormat)
|
|
}
|
|
}
|
|
|
|
func TestRedisDriverAlias(t *testing.T) {
|
|
c, err := Load(envFrom(map[string]string{"WAITFORDB_DRIVER": "redis"}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.Driver != "valkey" || c.Port != "6379" {
|
|
t.Errorf("driver=%q port=%q, want valkey/6379", c.Driver, c.Port)
|
|
}
|
|
}
|
|
|
|
func TestExplicitDriverIgnoresMismatchedDetect(t *testing.T) {
|
|
// CNPG-shaped keys with an explicit valkey driver: format must not apply.
|
|
c, err := Load(envFrom(map[string]string{
|
|
"WAITFORDB_DRIVER": "valkey",
|
|
"host": "secret-host",
|
|
"dbname": "d",
|
|
"user": "u",
|
|
"password": "pw",
|
|
}))
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if c.SecretFormat != "" {
|
|
t.Errorf("format = %q, want none (cnpg does not apply to valkey)", c.SecretFormat)
|
|
}
|
|
if c.Host != DefaultHost {
|
|
t.Errorf("host = %q, want default (secret keys must not fill)", c.Host)
|
|
}
|
|
}
|
|
|
|
func TestSecretFormatErrors(t *testing.T) {
|
|
cases := map[string]map[string]string{
|
|
"unknown format": {"WAITFORDB_SECRET_FORMAT": "vault"},
|
|
"format/driver mismatch": {
|
|
"WAITFORDB_SECRET_FORMAT": "cnpg",
|
|
"WAITFORDB_DRIVER": "valkey",
|
|
},
|
|
}
|
|
for name, env := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := Load(envFrom(env)); err == nil {
|
|
t.Fatalf("expected error for %s", name)
|
|
} else if _, ok := err.(*Error); !ok {
|
|
t.Fatalf("expected *config.Error, got %T", err)
|
|
}
|
|
})
|
|
}
|
|
}
|