Files
waitfordb/internal/config/config.go
T
unkin-agent 2b37986218 waitfordb: initial tool — env-configured wait-for-DB init container
Small Go tool + distroless container used as a K8s initContainer to block an
app until its database (Postgres/MySQL) is reachable. Env-var configured
(WAITFORDB_* + libpq PG* fallback), configurable timeout/interval, redacted
logs, exit codes. Woodpecker CI publishes docker-internal/waitfordb on tag.
2026-08-22 13:31:44 +10:00

237 lines
6.3 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",
}
// Config is the resolved, validated configuration for a single run.
type Config struct {
Driver 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* (the libpq fallback applies to the postgres driver
// only).
func Load(get Getenv) (Config, error) {
c := Config{
Driver: firstNonEmpty(get("WAITFORDB_DRIVER"), DefaultDriver),
DSN: get("WAITFORDB_DSN"),
ConnectTimeout: DefaultConnectTimeout,
Interval: DefaultInterval,
}
c.Driver = strings.ToLower(strings.TrimSpace(c.Driver))
pg := c.Driver == "postgres"
// 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")
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
}
func (c Config) validate() error {
if _, ok := defaultPort[c.Driver]; !ok {
return errf("unsupported WAITFORDB_DRIVER %q (supported: postgres, mysql)", 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).
if c.DSN == "" {
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.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))
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:]
}
// 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 != "" {
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
}