Files
dance-lessons-coach/pkg/config/config.go
Gabriel Radureau 18ac000d30
All checks were successful
CI/CD Pipeline / Build Docker Cache (push) Successful in 10s
CI/CD Pipeline / CI Pipeline (push) Successful in 4m5s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped
🐛 fix: emit all config-loading logs in correct format from the start
The logger was initialised to console format unconditionally, so the
"Config file loaded" message (and similar early logs) were always written
as human-readable text even when JSON logging was configured.

Root cause: classic chicken-and-egg — the format flag lives inside the
config that is being loaded.

Fix: add peekJSONLogging() which resolves the format before any log is
emitted by (1) checking DLC_LOGGING_JSON directly via os.Getenv, then
(2) doing a minimal throwaway Viper pre-read of the config file for the
logging.json key.  The redundant format-switch block that ran after
Unmarshal() is removed.

Also add the "Logging configured" log as the very first line, and
replace the hardcoded PROJECT_DIR path in start-server.sh,
test-graceful-shutdown.sh, and test-opentelemetry.sh with a dynamic
derivation from BASH_SOURCE[0].

Closes #15

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 19:25:06 +02:00

515 lines
17 KiB
Go

package config
import (
"fmt"
"os"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
"dance-lessons-coach/pkg/version"
)
// NewZerologWriter creates a zerolog writer based on configuration
func NewZerologWriter() *os.File {
return os.Stderr
}
// Config represents the application configuration
type Config struct {
Server ServerConfig `mapstructure:"server"`
Shutdown ShutdownConfig `mapstructure:"shutdown"`
Logging LoggingConfig `mapstructure:"logging"`
Telemetry TelemetryConfig `mapstructure:"telemetry"`
API APIConfig `mapstructure:"api"`
Auth AuthConfig `mapstructure:"auth"`
Database DatabaseConfig `mapstructure:"database"`
}
// ServerConfig holds server-related configuration
type ServerConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
}
// ShutdownConfig holds shutdown-related configuration
type ShutdownConfig struct {
Timeout time.Duration `mapstructure:"timeout"`
}
// LoggingConfig holds logging-related configuration
type LoggingConfig struct {
JSON bool `mapstructure:"json"`
Level string `mapstructure:"level"`
Output string `mapstructure:"output"`
}
// TelemetryConfig holds OpenTelemetry-related configuration
type TelemetryConfig struct {
Enabled bool `mapstructure:"enabled"`
OTLPEndpoint string `mapstructure:"otlp_endpoint"`
ServiceName string `mapstructure:"service_name"`
Insecure bool `mapstructure:"insecure"`
Sampler SamplerConfig `mapstructure:"sampler"`
Persistence PersistenceTelemetryConfig `mapstructure:"persistence"`
}
// PersistenceTelemetryConfig holds persistence layer telemetry configuration
type PersistenceTelemetryConfig struct {
Enabled bool `mapstructure:"enabled"`
}
// APIConfig holds API version configuration
type APIConfig struct {
V2Enabled bool `mapstructure:"v2_enabled"`
}
// AuthConfig holds authentication configuration
type AuthConfig struct {
JWTSecret string `mapstructure:"jwt_secret"`
AdminMasterPassword string `mapstructure:"admin_master_password"`
JWT JWTConfig `mapstructure:"jwt"`
}
// JWTConfig holds JWT-specific configuration
type JWTConfig struct {
TTL time.Duration `mapstructure:"ttl"`
SecretRetention struct {
RetentionFactor float64 `mapstructure:"retention_factor"`
MaxRetention time.Duration `mapstructure:"max_retention"`
CleanupInterval time.Duration `mapstructure:"cleanup_interval"`
} `mapstructure:"secret_retention"`
}
// DatabaseConfig holds database configuration
type DatabaseConfig struct {
Host string `mapstructure:"host"`
Port int `mapstructure:"port"`
User string `mapstructure:"user"`
Password string `mapstructure:"password"`
Name string `mapstructure:"name"`
SSLMode string `mapstructure:"ssl_mode"`
MaxOpenConns int `mapstructure:"max_open_conns"`
MaxIdleConns int `mapstructure:"max_idle_conns"`
ConnMaxLifetime time.Duration `mapstructure:"conn_max_lifetime"`
}
// VersionInfo holds application version information
type VersionInfo struct {
Version string `mapstructure:"-"` // Set via ldflags
Commit string `mapstructure:"-"` // Set via ldflags
Date string `mapstructure:"-"` // Set via ldflags
GoVersion string `mapstructure:"-"` // Set at runtime
}
// VersionCommand handles version display
func (c *Config) VersionCommand() string {
// This will be enhanced when we integrate with cobra
return fmt.Sprintf("dance-lessons-coach %s (commit: %s, built: %s, go: %s)",
version.Version, version.Commit, version.Date, version.GoVersion)
}
// SamplerConfig holds tracing sampler configuration
type SamplerConfig struct {
Type string `mapstructure:"type"`
Ratio float64 `mapstructure:"ratio"`
}
// peekJSONLogging determines whether JSON logging should be used before the full
// config is loaded, solving the chicken-and-egg problem where the logger format
// must be known before any log is emitted, yet the format is stored in the config.
//
// Resolution order (mirrors Viper's own priority):
// 1. DLC_LOGGING_JSON env var — checked directly via os.Getenv (zero overhead)
// 2. logging.json key in the config file — read with a minimal throwaway Viper
// instance so we don't parse the whole config twice unnecessarily
func peekJSONLogging() bool {
// 1. Env var takes highest priority — check it first
if env := os.Getenv("DLC_LOGGING_JSON"); env != "" {
return strings.EqualFold(env, "true") || env == "1"
}
// 2. Try to read logging.json from the config file
preV := viper.New()
preV.SetDefault("logging.json", false)
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
preV.SetConfigFile(configFile)
} else {
preV.SetConfigName("config")
preV.SetConfigType("yaml")
preV.AddConfigPath(".")
}
_ = preV.ReadInConfig() // ignore errors — defaults apply on failure
return preV.GetBool("logging.json")
}
// LoadConfig loads configuration from file, environment variables, and defaults
// Configuration priority: file > environment variables > defaults
// To specify a custom config file path, set DLC_CONFIG_FILE environment variable
func LoadConfig() (*Config, error) {
// Check if we're in a test environment - this should NOT be called during BDD tests
if os.Getenv("FEATURE") != "" {
panic("ERROR: LoadConfig() was called during BDD tests! This should not happen - tests should use createTestConfig() instead.")
}
v := viper.New()
// Configure the logger format before emitting any log output.
// peekJSONLogging reads the JSON setting early (env var + config file pre-read)
// so that every log line — including those produced during config loading — is
// already in the correct format.
jsonLogging := peekJSONLogging()
if jsonLogging {
log.Logger = log.Output(os.Stderr)
} else {
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr})
}
log.Info().Bool("json", jsonLogging).Msg("Logging configured")
// Set default values
v.SetDefault("server.host", "0.0.0.0")
v.SetDefault("server.port", 8080)
v.SetDefault("shutdown.timeout", 30*time.Second)
v.SetDefault("logging.json", false)
v.SetDefault("logging.level", "trace")
v.SetDefault("logging.output", "")
// Telemetry defaults
v.SetDefault("telemetry.enabled", false)
v.SetDefault("telemetry.otlp_endpoint", "localhost:4317")
v.SetDefault("telemetry.service_name", "dance-lessons-coach")
v.SetDefault("telemetry.insecure", true)
v.SetDefault("telemetry.sampler.type", "parentbased_always_on")
v.SetDefault("telemetry.sampler.ratio", 1.0)
v.SetDefault("telemetry.persistence.enabled", false)
// API defaults
v.SetDefault("api.v2_enabled", false)
// Auth defaults
v.SetDefault("auth.jwt_secret", "default-secret-key-please-change-in-production")
v.SetDefault("auth.admin_master_password", "admin123")
v.SetDefault("auth.jwt.ttl", 1*time.Hour)
v.SetDefault("auth.jwt.secret_retention.retention_factor", 2.0)
v.SetDefault("auth.jwt.secret_retention.max_retention", 72*time.Hour)
v.SetDefault("auth.jwt.secret_retention.cleanup_interval", 1*time.Hour)
// Check for custom config file path via environment variable
if configFile := os.Getenv("DLC_CONFIG_FILE"); configFile != "" {
v.SetConfigFile(configFile)
log.Info().Str("config_file", configFile).Msg("Using custom config file path")
} else {
// Default: look for config.yaml in current directory
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
}
// Read config file if it exists
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
// Config file was found but there was an error reading it
log.Warn().Err(err).Msg("Error reading config file, using defaults")
}
// Config file not found, continue with environment variables and defaults
} else {
log.Info().Str("config_file", v.ConfigFileUsed()).Msg("Config file loaded")
}
// Bind environment variables
v.AutomaticEnv()
v.SetEnvPrefix("DLC") // dance-lessons-coach prefix
v.BindEnv("server.host", "DLC_SERVER_HOST")
v.BindEnv("server.port", "DLC_SERVER_PORT")
v.BindEnv("shutdown.timeout", "DLC_SHUTDOWN_TIMEOUT")
v.BindEnv("logging.json", "DLC_LOGGING_JSON")
v.BindEnv("logging.level", "DLC_LOGGING_LEVEL")
v.BindEnv("logging.output", "DLC_LOGGING_OUTPUT")
// Telemetry environment variables
v.BindEnv("telemetry.enabled", "DLC_TELEMETRY_ENABLED")
v.BindEnv("telemetry.otlp_endpoint", "DLC_TELEMETRY_OTLP_ENDPOINT")
v.BindEnv("telemetry.service_name", "DLC_TELEMETRY_SERVICE_NAME")
v.BindEnv("telemetry.insecure", "DLC_TELEMETRY_INSECURE")
// Auth environment variables
v.BindEnv("auth.jwt_secret", "DLC_AUTH_JWT_SECRET")
v.BindEnv("auth.admin_master_password", "DLC_AUTH_ADMIN_MASTER_PASSWORD")
v.BindEnv("auth.jwt.ttl", "DLC_AUTH_JWT_TTL")
v.BindEnv("auth.jwt.secret_retention.retention_factor", "DLC_AUTH_JWT_SECRET_RETENTION_FACTOR")
v.BindEnv("auth.jwt.secret_retention.max_retention", "DLC_AUTH_JWT_SECRET_MAX_RETENTION")
v.BindEnv("auth.jwt.secret_retention.cleanup_interval", "DLC_AUTH_JWT_SECRET_CLEANUP_INTERVAL")
v.BindEnv("telemetry.sampler.type", "DLC_TELEMETRY_SAMPLER_TYPE")
v.BindEnv("telemetry.sampler.ratio", "DLC_TELEMETRY_SAMPLER_RATIO")
// API environment variables
v.BindEnv("api.v2_enabled", "DLC_API_V2_ENABLED")
// Database environment variables
v.BindEnv("database.host", "DLC_DATABASE_HOST")
v.BindEnv("database.port", "DLC_DATABASE_PORT")
v.BindEnv("database.user", "DLC_DATABASE_USER")
v.BindEnv("database.password", "DLC_DATABASE_PASSWORD")
v.BindEnv("database.name", "DLC_DATABASE_NAME")
v.BindEnv("database.ssl_mode", "DLC_DATABASE_SSL_MODE")
// Unmarshal into Config struct
var config Config
if err := v.Unmarshal(&config); err != nil {
log.Error().Err(err).Msg("Failed to unmarshal config")
return nil, fmt.Errorf("config unmarshal error: %w", err)
}
// Setup logging based on configuration (level, output file, time format).
// The JSON/console format was already applied at the top of LoadConfig via
// peekJSONLogging, so SetupLogging only needs to handle the remaining knobs.
config.SetupLogging()
log.Info().
Str("host", config.Server.Host).
Int("port", config.Server.Port).
Dur("shutdown_timeout", config.Shutdown.Timeout).
Bool("logging_json", config.Logging.JSON).
Str("logging_level", config.Logging.Level).
Str("logging_output", config.Logging.Output).
Bool("telemetry_enabled", config.Telemetry.Enabled).
Str("telemetry_service", config.Telemetry.ServiceName).
Bool("api_v2_enabled", config.API.V2Enabled).
Dur("jwt_ttl", config.GetJWTTTL()).
Float64("jwt_retention_factor", config.GetJWTSecretRetentionFactor()).
Dur("jwt_max_retention", config.GetJWTSecretMaxRetention()).
Dur("jwt_cleanup_interval", config.GetJWTSecretCleanupInterval()).
Msg("Configuration loaded")
return &config, nil
}
// GetServerAddress returns the formatted server address (host:port)
func (c *Config) GetServerAddress() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
}
// GetTelemetryEnabled returns whether telemetry is enabled
func (c *Config) GetTelemetryEnabled() bool {
return c.Telemetry.Enabled
}
// GetOTLPEndpoint returns the OTLP endpoint for telemetry
func (c *Config) GetOTLPEndpoint() string {
return c.Telemetry.OTLPEndpoint
}
// GetServiceName returns the service name for telemetry
func (c *Config) GetServiceName() string {
return c.Telemetry.ServiceName
}
// GetPersistenceTelemetryEnabled returns whether persistence layer telemetry is enabled
func (c *Config) GetPersistenceTelemetryEnabled() bool {
return c.Telemetry.Enabled && c.Telemetry.Persistence.Enabled
}
// GetTelemetryInsecure returns whether to use insecure connection
func (c *Config) GetTelemetryInsecure() bool {
return c.Telemetry.Insecure
}
// GetSamplerType returns the sampler type
func (c *Config) GetSamplerType() string {
return c.Telemetry.Sampler.Type
}
// GetSamplerRatio returns the sampler ratio
func (c *Config) GetSamplerRatio() float64 {
return c.Telemetry.Sampler.Ratio
}
// GetV2Enabled returns whether v2 API is enabled
func (c *Config) GetV2Enabled() bool {
return c.API.V2Enabled
}
// GetJWTSecret returns the JWT secret
func (c *Config) GetJWTSecret() string {
return c.Auth.JWTSecret
}
// GetAdminMasterPassword returns the admin master password
func (c *Config) GetAdminMasterPassword() string {
return c.Auth.AdminMasterPassword
}
// GetJWTTTL returns the JWT TTL
func (c *Config) GetJWTTTL() time.Duration {
if c.Auth.JWT.TTL == 0 {
return 1 * time.Hour // Default value
}
return c.Auth.JWT.TTL
}
// GetJWTSecretRetentionFactor returns the JWT secret retention factor
func (c *Config) GetJWTSecretRetentionFactor() float64 {
if c.Auth.JWT.SecretRetention.RetentionFactor == 0 {
return 2.0 // Default value
}
return c.Auth.JWT.SecretRetention.RetentionFactor
}
// GetJWTSecretMaxRetention returns the maximum JWT secret retention period
func (c *Config) GetJWTSecretMaxRetention() time.Duration {
if c.Auth.JWT.SecretRetention.MaxRetention == 0 {
return 72 * time.Hour // Default value
}
return c.Auth.JWT.SecretRetention.MaxRetention
}
// GetJWTSecretCleanupInterval returns the JWT secret cleanup interval
func (c *Config) GetJWTSecretCleanupInterval() time.Duration {
if c.Auth.JWT.SecretRetention.CleanupInterval == 0 {
return 1 * time.Hour // Default value
}
return c.Auth.JWT.SecretRetention.CleanupInterval
}
// GetLoggingJSON returns whether JSON logging is enabled
func (c *Config) GetLoggingJSON() bool {
return c.Logging.JSON
}
// GetLogLevel returns the logging level
func (c *Config) GetLogLevel() string {
return c.Logging.Level
}
// GetLogOutput returns the log output path
func (c *Config) GetLogOutput() string {
return c.Logging.Output
}
// GetDatabaseHost returns the database host
func (c *Config) GetDatabaseHost() string {
if c.Database.Host == "" {
return "localhost"
}
return c.Database.Host
}
// GetDatabasePort returns the database port
func (c *Config) GetDatabasePort() int {
if c.Database.Port == 0 {
return 5432
}
return c.Database.Port
}
// GetDatabaseUser returns the database user
func (c *Config) GetDatabaseUser() string {
if c.Database.User == "" {
return "postgres"
}
return c.Database.User
}
// GetDatabasePassword returns the database password
func (c *Config) GetDatabasePassword() string {
return c.Database.Password
}
// GetDatabaseName returns the database name
func (c *Config) GetDatabaseName() string {
if c.Database.Name == "" {
return "dance_lessons_coach"
}
return c.Database.Name
}
// GetDatabaseSSLMode returns the database SSL mode
func (c *Config) GetDatabaseSSLMode() string {
if c.Database.SSLMode == "" {
return "disable"
}
return c.Database.SSLMode
}
// GetDatabaseMaxOpenConns returns the maximum number of open connections
func (c *Config) GetDatabaseMaxOpenConns() int {
if c.Database.MaxOpenConns == 0 {
return 25
}
return c.Database.MaxOpenConns
}
// GetDatabaseMaxIdleConns returns the maximum number of idle connections
func (c *Config) GetDatabaseMaxIdleConns() int {
if c.Database.MaxIdleConns == 0 {
return 5
}
return c.Database.MaxIdleConns
}
// GetDatabaseConnMaxLifetime returns the maximum lifetime of connections
func (c *Config) GetDatabaseConnMaxLifetime() time.Duration {
if c.Database.ConnMaxLifetime == 0 {
return time.Hour
}
return c.Database.ConnMaxLifetime
}
// SetupLogging configures zerolog based on the configuration
func (c *Config) SetupLogging() {
// Parse log level
level := parseLogLevel(c.GetLogLevel())
zerolog.SetGlobalLevel(level)
// Setup log output
c.setupLogOutput()
zerolog.TimeFieldFormat = zerolog.TimeFormatUnix
}
// parseLogLevel converts a string log level to zerolog.Level
func parseLogLevel(level string) zerolog.Level {
switch strings.ToLower(level) {
case "trace":
return zerolog.TraceLevel
case "debug":
return zerolog.DebugLevel
case "info":
return zerolog.InfoLevel
case "warn", "warning":
return zerolog.WarnLevel
case "error":
return zerolog.ErrorLevel
case "fatal":
return zerolog.FatalLevel
case "panic":
return zerolog.PanicLevel
default:
log.Warn().Str("level", level).Msg("Unknown log level, defaulting to trace")
return zerolog.TraceLevel
}
}
// setupLogOutput configures the log output based on configuration
func (c *Config) setupLogOutput() {
output := c.GetLogOutput()
if output == "" {
// Use stderr by default
return
}
// Open the log file
file, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
log.Error().Err(err).Str("output", output).Msg("Failed to open log file, using stderr")
return
}
// Set the log output
log.Logger = log.Output(file)
log.Trace().Str("output", output).Msg("Logging to file")
}