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
332 lines
9.2 KiB
Go
332 lines
9.2 KiB
Go
// Package config resolves the waitfordb runtime configuration from environment
|
|
// variables and formats a secret-free summary for logging.
|
|
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Defaults applied when the corresponding env var is unset.
|
|
const (
|
|
DefaultDriver = "postgres"
|
|
DefaultHost = "localhost"
|
|
DefaultInterval = 2 * time.Second
|
|
DefaultConnectTimeout = 5 * time.Second
|
|
)
|
|
|
|
// defaultPort maps a driver to the port used when none is configured.
|
|
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
|
|
|
|
Host string
|
|
Port string
|
|
User string
|
|
Password string
|
|
Database string
|
|
SSLMode string
|
|
|
|
// Timeout is the total budget to wait for readiness. Zero means wait
|
|
// forever.
|
|
Timeout time.Duration
|
|
// Interval is the gap between retries.
|
|
Interval time.Duration
|
|
// ConnectTimeout bounds a single connect+ping attempt.
|
|
ConnectTimeout time.Duration
|
|
}
|
|
|
|
// Error is a configuration error; main maps it to exit code 2.
|
|
type Error struct{ msg string }
|
|
|
|
func (e *Error) Error() string { return e.msg }
|
|
|
|
func errf(format string, a ...any) *Error { return &Error{msg: fmt.Sprintf(format, a...)} }
|
|
|
|
// Getenv matches os.Getenv; injected in tests.
|
|
type Getenv func(string) string
|
|
|
|
// Load resolves configuration from env. Connection-parameter precedence is
|
|
// DSN > WAITFORDB_* > PG* > operator-secret keys (the libpq fallback applies
|
|
// to the postgres driver only).
|
|
func Load(get Getenv) (Config, error) {
|
|
c := Config{
|
|
Driver: strings.ToLower(strings.TrimSpace(get("WAITFORDB_DRIVER"))),
|
|
DSN: get("WAITFORDB_DSN"),
|
|
ConnectTimeout: DefaultConnectTimeout,
|
|
Interval: DefaultInterval,
|
|
}
|
|
if c.Driver == "redis" {
|
|
c.Driver = "valkey"
|
|
}
|
|
|
|
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")
|
|
|
|
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
|
|
}
|
|
if c.Port == "" {
|
|
c.Port = defaultPort[c.Driver]
|
|
}
|
|
|
|
var err error
|
|
if c.Timeout, err = parseDuration(get("WAITFORDB_TIMEOUT"), 0); err != nil {
|
|
return Config{}, errf("WAITFORDB_TIMEOUT: %v", err)
|
|
}
|
|
if c.Interval, err = parseDuration(get("WAITFORDB_INTERVAL"), DefaultInterval); err != nil {
|
|
return Config{}, errf("WAITFORDB_INTERVAL: %v", err)
|
|
}
|
|
if c.ConnectTimeout, err = parseDuration(get("WAITFORDB_CONNECT_TIMEOUT"), DefaultConnectTimeout); err != nil {
|
|
return Config{}, errf("WAITFORDB_CONNECT_TIMEOUT: %v", err)
|
|
}
|
|
|
|
if err := c.validate(); err != nil {
|
|
return Config{}, err
|
|
}
|
|
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, valkey/redis)", c.Driver)
|
|
}
|
|
if c.Interval <= 0 {
|
|
return errf("WAITFORDB_INTERVAL must be > 0")
|
|
}
|
|
if c.ConnectTimeout <= 0 {
|
|
return errf("WAITFORDB_CONNECT_TIMEOUT must be > 0")
|
|
}
|
|
if c.Timeout < 0 {
|
|
return errf("WAITFORDB_TIMEOUT must be >= 0")
|
|
}
|
|
// With a DSN the discrete fields are optional (the DSN carries them).
|
|
// 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")
|
|
}
|
|
if c.User == "" {
|
|
return errf("no user configured: set WAITFORDB_USER (or PGUSER for postgres) or WAITFORDB_DSN")
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Redacted returns a single-line, password-free summary of the resolved
|
|
// configuration suitable for the startup log line.
|
|
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", 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)
|
|
}
|
|
}
|
|
fmt.Fprintf(&b, " timeout=%s interval=%s connect_timeout=%s",
|
|
timeoutStr(c.Timeout), c.Interval, c.ConnectTimeout)
|
|
return b.String()
|
|
}
|
|
|
|
func redactSecret(s string) string {
|
|
if s == "" {
|
|
return "(unset)"
|
|
}
|
|
return "***"
|
|
}
|
|
|
|
func timeoutStr(d time.Duration) string {
|
|
if d == 0 {
|
|
return "forever"
|
|
}
|
|
return d.String()
|
|
}
|
|
|
|
// redactDSN masks the password in either a URL-style or keyword-style DSN so it
|
|
// never reaches a log line.
|
|
func redactDSN(dsn string) string {
|
|
// URL form: scheme://user:password@host/...
|
|
if i := strings.Index(dsn, "://"); i >= 0 {
|
|
rest := dsn[i+3:]
|
|
if at := strings.Index(rest, "@"); at >= 0 {
|
|
creds := rest[:at]
|
|
if colon := strings.Index(creds, ":"); colon >= 0 {
|
|
return dsn[:i+3] + creds[:colon] + ":***@" + rest[at+1:]
|
|
}
|
|
}
|
|
return dsn
|
|
}
|
|
// Keyword form: key=value pairs and mysql user:pass@tcp(...) form.
|
|
out := dsn
|
|
for _, key := range []string{"password", "passwd"} {
|
|
out = redactKeyword(out, key)
|
|
}
|
|
// mysql DSN: user:pass@tcp(host)/db
|
|
if at := strings.Index(out, "@tcp("); at >= 0 {
|
|
if colon := strings.LastIndex(out[:at], ":"); colon >= 0 {
|
|
out = out[:colon+1] + "***" + out[at:]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func redactKeyword(dsn, key string) string {
|
|
lower := strings.ToLower(dsn)
|
|
idx := strings.Index(lower, key+"=")
|
|
if idx < 0 {
|
|
return dsn
|
|
}
|
|
valStart := idx + len(key) + 1
|
|
valEnd := valStart
|
|
for valEnd < len(dsn) && dsn[valEnd] != ' ' {
|
|
valEnd++
|
|
}
|
|
return dsn[:valStart] + "***" + dsn[valEnd:]
|
|
}
|
|
|
|
func firstNonEmpty(vals ...string) string {
|
|
for _, v := range vals {
|
|
if v != "" {
|
|
return v
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseDuration(s string, def time.Duration) (time.Duration, error) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return def, nil
|
|
}
|
|
d, err := time.ParseDuration(s)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid duration %q", s)
|
|
}
|
|
return d, nil
|
|
}
|