// Package redisconn provides shared helpers for opening, instrumenting and // pinging Redis connections used by Galaxy services. // // The package codifies the steady-state rules captured in `ARCHITECTURE.md` // `§Persistence Backends`: each service connects to one master plus // zero-or-more replicas with a mandatory password, no TLS, and no // `USERNAME`/ACL. The deprecated env vars `*_REDIS_TLS_ENABLED` and // `*_REDIS_USERNAME` are rejected by LoadFromEnv with a clear startup error. package redisconn import ( "errors" "fmt" "os" "strconv" "strings" "time" ) // Default configuration values applied by DefaultConfig and LoadFromEnv when // the corresponding environment variable is absent. const ( DefaultDB = 0 DefaultOperationTimeout = 250 * time.Millisecond ) // Config stores the connection settings for one master plus zero-or-more // replica Redis instances. Stage 1 wires only the master; the replica list is // preserved so future read-routing is a non-breaking change. type Config struct { // MasterAddr stores the Redis network address in host:port form. Required. MasterAddr string // ReplicaAddrs stores zero-or-more read-only replica addresses. ReplicaAddrs []string // Password is the mandatory connection password. Empty values are rejected // by Validate to enforce the architectural rule that Redis traffic is // password-protected even on the trusted segment. Password string // DB selects the logical Redis database index. DB int // OperationTimeout bounds individual Redis round trips. OperationTimeout time.Duration } // DefaultConfig returns the default tuning. MasterAddr and Password remain // zero-valued and must be supplied by callers (or by LoadFromEnv). func DefaultConfig() Config { return Config{ DB: DefaultDB, OperationTimeout: DefaultOperationTimeout, } } // Validate reports whether cfg is usable. func (cfg Config) Validate() error { if strings.TrimSpace(cfg.MasterAddr) == "" { return errors.New("redis master addr must not be empty") } if strings.TrimSpace(cfg.Password) == "" { return errors.New("redis password must not be empty") } for index, addr := range cfg.ReplicaAddrs { if strings.TrimSpace(addr) == "" { return fmt.Errorf("redis replica addr at index %d must not be empty", index) } } if cfg.DB < 0 { return errors.New("redis db must not be negative") } if cfg.OperationTimeout <= 0 { return errors.New("redis operation timeout must be positive") } return nil } // LoadFromEnv populates Config from environment variables prefixed with // `_REDIS_`. The required variables are // `_REDIS_MASTER_ADDR` and `_REDIS_PASSWORD`; every other // variable falls back to DefaultConfig values. // // LoadFromEnv hard-fails when either of the deprecated variables // `_REDIS_TLS_ENABLED` or `_REDIS_USERNAME` is set in the // environment, with an error pointing to ARCHITECTURE.md. func LoadFromEnv(prefix string) (Config, error) { if strings.TrimSpace(prefix) == "" { return Config{}, errors.New("redis env prefix must not be empty") } if err := rejectDeprecatedEnv(prefix); err != nil { return Config{}, err } cfg := DefaultConfig() masterName := envName(prefix, "MASTER_ADDR") master, ok := os.LookupEnv(masterName) if !ok || strings.TrimSpace(master) == "" { return Config{}, fmt.Errorf("%s must be set", masterName) } cfg.MasterAddr = strings.TrimSpace(master) passwordName := envName(prefix, "PASSWORD") password, ok := os.LookupEnv(passwordName) if !ok || strings.TrimSpace(password) == "" { return Config{}, fmt.Errorf("%s must be set", passwordName) } cfg.Password = strings.TrimSpace(password) if raw, ok := os.LookupEnv(envName(prefix, "REPLICA_ADDRS")); ok { cfg.ReplicaAddrs = splitCSV(raw) } db, err := loadInt(envName(prefix, "DB"), cfg.DB) if err != nil { return Config{}, err } cfg.DB = db timeout, err := loadDuration(envName(prefix, "OPERATION_TIMEOUT"), cfg.OperationTimeout) if err != nil { return Config{}, err } cfg.OperationTimeout = timeout if err := cfg.Validate(); err != nil { return Config{}, err } return cfg, nil } func rejectDeprecatedEnv(prefix string) error { for _, suffix := range []string{"TLS_ENABLED", "USERNAME"} { name := envName(prefix, suffix) if _, ok := os.LookupEnv(name); ok { return fmt.Errorf("%s is no longer supported (see ARCHITECTURE.md §Persistence Backends); unset it before starting the service", name) } } return nil } func envName(prefix, suffix string) string { return strings.ToUpper(strings.TrimSpace(prefix)) + "_REDIS_" + suffix } func splitCSV(raw string) []string { parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed == "" { continue } out = append(out, trimmed) } if len(out) == 0 { return nil } return out } func loadDuration(name string, fallback time.Duration) (time.Duration, error) { raw, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := time.ParseDuration(strings.TrimSpace(raw)) if err != nil { return 0, fmt.Errorf("%s: %w", name, err) } return parsed, nil } func loadInt(name string, fallback int) (int, error) { raw, ok := os.LookupEnv(name) if !ok { return fallback, nil } parsed, err := strconv.Atoi(strings.TrimSpace(raw)) if err != nil { return 0, fmt.Errorf("%s: %w", name, err) } return parsed, nil }