2b37986218
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.
237 lines
6.3 KiB
Go
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
|
|
}
|