Add valkey/redis driver with operator-secret auto-configuration
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
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
This commit is contained in:
+123
-28
@@ -20,12 +20,24 @@ const (
|
||||
var defaultPort = map[string]string{
|
||||
"postgres": "5432",
|
||||
"mysql": "3306",
|
||||
"valkey": "6379",
|
||||
}
|
||||
|
||||
// Secret formats natively understood via WAITFORDB_SECRET_FORMAT (or
|
||||
// autodetection): operator-generated secrets injected wholesale with envFrom.
|
||||
const (
|
||||
FormatCNPG = "cnpg" // CNPG <cluster>-app secret: host/port/dbname/user/password keys
|
||||
FormatValkey = "valkey" // valkey-operator user secrets: key = username, value = password
|
||||
)
|
||||
|
||||
// Config is the resolved, validated configuration for a single run.
|
||||
type Config struct {
|
||||
Driver string
|
||||
|
||||
// SecretFormat records which operator secret convention supplied the
|
||||
// connection parameters ("" when none).
|
||||
SecretFormat string
|
||||
|
||||
// DSN, when set, is a full driver-native connection string that overrides
|
||||
// the discrete Host/Port/User/Password/Database fields.
|
||||
DSN string
|
||||
@@ -57,26 +69,79 @@ func errf(format string, a ...any) *Error { return &Error{msg: fmt.Sprintf(forma
|
||||
type Getenv func(string) string
|
||||
|
||||
// Load resolves configuration from env. Connection-parameter precedence is
|
||||
// DSN > WAITFORDB_* > PG* (the libpq fallback applies to the postgres driver
|
||||
// only).
|
||||
// DSN > WAITFORDB_* > PG* > operator-secret keys (the libpq fallback applies
|
||||
// to the postgres driver only).
|
||||
func Load(get Getenv) (Config, error) {
|
||||
c := Config{
|
||||
Driver: firstNonEmpty(get("WAITFORDB_DRIVER"), DefaultDriver),
|
||||
Driver: strings.ToLower(strings.TrimSpace(get("WAITFORDB_DRIVER"))),
|
||||
DSN: get("WAITFORDB_DSN"),
|
||||
ConnectTimeout: DefaultConnectTimeout,
|
||||
Interval: DefaultInterval,
|
||||
}
|
||||
c.Driver = strings.ToLower(strings.TrimSpace(c.Driver))
|
||||
if c.Driver == "redis" {
|
||||
c.Driver = "valkey"
|
||||
}
|
||||
|
||||
pg := c.Driver == "postgres"
|
||||
c.Host = get("WAITFORDB_HOST")
|
||||
c.Port = get("WAITFORDB_PORT")
|
||||
c.User = get("WAITFORDB_USER")
|
||||
c.Password = get("WAITFORDB_PASSWORD")
|
||||
c.Database = get("WAITFORDB_DATABASE")
|
||||
c.SSLMode = get("WAITFORDB_SSLMODE")
|
||||
|
||||
// WAITFORDB_* first, then the libpq PG* fallback for postgres.
|
||||
c.Host = pick(get, pg, "WAITFORDB_HOST", "PGHOST")
|
||||
c.Port = pick(get, pg, "WAITFORDB_PORT", "PGPORT")
|
||||
c.User = pick(get, pg, "WAITFORDB_USER", "PGUSER")
|
||||
c.Password = pick(get, pg, "WAITFORDB_PASSWORD", "PGPASSWORD")
|
||||
c.Database = pick(get, pg, "WAITFORDB_DATABASE", "PGDATABASE")
|
||||
c.SSLMode = pick(get, pg, "WAITFORDB_SSLMODE", "PGSSLMODE")
|
||||
format := strings.ToLower(strings.TrimSpace(get("WAITFORDB_SECRET_FORMAT")))
|
||||
formatExplicit := format != ""
|
||||
if formatExplicit {
|
||||
if format != FormatCNPG && format != FormatValkey {
|
||||
return Config{}, errf("unsupported WAITFORDB_SECRET_FORMAT %q (supported: %s, %s)", format, FormatCNPG, FormatValkey)
|
||||
}
|
||||
} else {
|
||||
format = detectFormat(get, c)
|
||||
}
|
||||
|
||||
// An explicit driver wins; otherwise the secret format implies it, falling
|
||||
// back to the historical postgres default.
|
||||
if c.Driver == "" {
|
||||
switch format {
|
||||
case FormatValkey:
|
||||
c.Driver = "valkey"
|
||||
default:
|
||||
c.Driver = DefaultDriver
|
||||
}
|
||||
}
|
||||
if formatExplicit && !formatMatchesDriver(format, c.Driver) {
|
||||
return Config{}, errf("WAITFORDB_SECRET_FORMAT %q does not apply to driver %q", format, c.Driver)
|
||||
}
|
||||
if formatMatchesDriver(format, c.Driver) {
|
||||
c.SecretFormat = format
|
||||
}
|
||||
|
||||
// The libpq PG* fallback for still-unset postgres fields.
|
||||
if c.Driver == "postgres" {
|
||||
fill(&c.Host, get("PGHOST"))
|
||||
fill(&c.Port, get("PGPORT"))
|
||||
fill(&c.User, get("PGUSER"))
|
||||
fill(&c.Password, get("PGPASSWORD"))
|
||||
fill(&c.Database, get("PGDATABASE"))
|
||||
fill(&c.SSLMode, get("PGSSLMODE"))
|
||||
}
|
||||
|
||||
// Operator-secret keys fill whatever is still unset.
|
||||
switch c.SecretFormat {
|
||||
case FormatCNPG:
|
||||
fill(&c.Host, get("host"))
|
||||
fill(&c.Port, get("port"))
|
||||
fill(&c.Database, get("dbname"))
|
||||
fill(&c.User, firstNonEmpty(get("user"), get("username")))
|
||||
fill(&c.Password, get("password"))
|
||||
case FormatValkey:
|
||||
if c.User == "" && get("_operator") != "" {
|
||||
c.User = "_operator"
|
||||
}
|
||||
if c.Password == "" && c.User != "" {
|
||||
c.Password = get(c.User)
|
||||
}
|
||||
}
|
||||
|
||||
if c.Host == "" {
|
||||
c.Host = DefaultHost
|
||||
@@ -102,9 +167,44 @@ func Load(get Getenv) (Config, error) {
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// detectFormat recognises operator-generated secrets injected via envFrom.
|
||||
// CNPG <cluster>-app secrets carry lowercase host/dbname/user keys; valkey
|
||||
// user secrets carry one key per username (system users start with "_").
|
||||
func detectFormat(get Getenv, c Config) string {
|
||||
if get("host") != "" && get("dbname") != "" && (get("user") != "" || get("username") != "") {
|
||||
return FormatCNPG
|
||||
}
|
||||
if get("_operator") != "" || get("_replication") != "" {
|
||||
return FormatValkey
|
||||
}
|
||||
// An explicit ACL user whose password key is present, with no SQL database
|
||||
// configured anywhere, is the valkey users-secret shape.
|
||||
if (c.Driver == "" || c.Driver == "valkey") && c.User != "" && c.Password == "" &&
|
||||
c.Database == "" && get("PGDATABASE") == "" && get(c.User) != "" {
|
||||
return FormatValkey
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func formatMatchesDriver(format, driver string) bool {
|
||||
switch format {
|
||||
case FormatCNPG:
|
||||
return driver == "postgres"
|
||||
case FormatValkey:
|
||||
return driver == "valkey"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fill(dst *string, v string) {
|
||||
if *dst == "" {
|
||||
*dst = v
|
||||
}
|
||||
}
|
||||
|
||||
func (c Config) validate() error {
|
||||
if _, ok := defaultPort[c.Driver]; !ok {
|
||||
return errf("unsupported WAITFORDB_DRIVER %q (supported: postgres, mysql)", c.Driver)
|
||||
return errf("unsupported WAITFORDB_DRIVER %q (supported: postgres, mysql, valkey/redis)", c.Driver)
|
||||
}
|
||||
if c.Interval <= 0 {
|
||||
return errf("WAITFORDB_INTERVAL must be > 0")
|
||||
@@ -116,7 +216,8 @@ func (c Config) validate() error {
|
||||
return errf("WAITFORDB_TIMEOUT must be >= 0")
|
||||
}
|
||||
// With a DSN the discrete fields are optional (the DSN carries them).
|
||||
if c.DSN == "" {
|
||||
// Valkey needs neither a database nor a user (unauthenticated PING is valid).
|
||||
if c.DSN == "" && c.Driver != "valkey" {
|
||||
if c.Database == "" {
|
||||
return errf("no database configured: set WAITFORDB_DATABASE (or PGDATABASE for postgres) or WAITFORDB_DSN")
|
||||
}
|
||||
@@ -132,11 +233,17 @@ func (c Config) validate() error {
|
||||
func (c Config) Redacted() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "driver=%s", c.Driver)
|
||||
if c.SecretFormat != "" {
|
||||
fmt.Fprintf(&b, " secret_format=%s", c.SecretFormat)
|
||||
}
|
||||
if c.DSN != "" {
|
||||
fmt.Fprintf(&b, " dsn=%s", redactDSN(c.DSN))
|
||||
} else {
|
||||
fmt.Fprintf(&b, " addr=%s:%s database=%s user=%s password=%s",
|
||||
c.Host, c.Port, c.Database, c.User, redactSecret(c.Password))
|
||||
fmt.Fprintf(&b, " addr=%s:%s", c.Host, c.Port)
|
||||
if c.Database != "" {
|
||||
fmt.Fprintf(&b, " database=%s", c.Database)
|
||||
}
|
||||
fmt.Fprintf(&b, " user=%s password=%s", c.User, redactSecret(c.Password))
|
||||
if c.SSLMode != "" {
|
||||
fmt.Fprintf(&b, " sslmode=%s", c.SSLMode)
|
||||
}
|
||||
@@ -202,18 +309,6 @@ func redactKeyword(dsn, key string) string {
|
||||
return dsn[:valStart] + "***" + dsn[valEnd:]
|
||||
}
|
||||
|
||||
// pick returns the WAITFORDB_* value, falling back to the PG* value only when
|
||||
// fallback is true (postgres).
|
||||
func pick(get Getenv, fallback bool, primary, secondary string) string {
|
||||
if v := get(primary); v != "" {
|
||||
return v
|
||||
}
|
||||
if fallback {
|
||||
return get(secondary)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
|
||||
@@ -171,3 +171,159 @@ func TestRedactedDSNMasksPassword(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user