diff --git a/internal/command/command.go b/internal/command/command.go index 708c054c..0bc5b595 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -38,7 +38,10 @@ func New() *Command { } var cfg icingadbconfig.Config - if err := config.FromYAMLFile(flags.Config, &cfg); err != nil { + if err := config.Load(&cfg, config.LoadOptions{ + Flags: flags, + EnvOptions: config.EnvOptions{Prefix: "ICINGADB_"}, + }); err != nil { if errors.Is(err, config.ErrInvalidArgument) { panic(err) } diff --git a/internal/config/config.go b/internal/config/config.go index a9358927..5fb91d6d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -10,12 +10,15 @@ import ( "time" ) +// DefaultConfigPath specifies the default location of Icinga DB's config.yml for package installations. +const DefaultConfigPath = "/etc/icingadb/config.yml" + // Config defines Icinga DB config. type Config struct { - Database database.Config `yaml:"database"` - Redis redis.Config `yaml:"redis"` - Logging logging.Config `yaml:"logging"` - Retention RetentionConfig `yaml:"retention"` + Database database.Config `yaml:"database" envPrefix:"DATABASE_"` + Redis redis.Config `yaml:"redis" envPrefix:"REDIS_"` + Logging logging.Config `yaml:"logging" envPrefix:"LOGGING_"` + Retention RetentionConfig `yaml:"retention" envPrefix:"RETENTION_"` } func (c *Config) SetDefaults() { @@ -46,20 +49,43 @@ func (c *Config) Validate() error { } // Flags defines CLI flags. +// +// Flags implements the [github.com/icinga/icinga-go-library/config.Flags] interface. type Flags struct { // Version decides whether to just print the version and exit. Version bool `long:"version" description:"print version and exit"` - // Config is the path to the config file - Config string `short:"c" long:"config" description:"path to config file" required:"true" default:"/etc/icingadb/config.yml"` + + // Config is the path to the config file. If not provided, it defaults to DefaultConfigPath. + Config string `short:"c" long:"config" description:"path to config file (default: /etc/icingadb/config.yml)"` + // default must be kept in sync with DefaultConfigPath. +} + +// GetConfigPath retrieves the path to the configuration file. +// It returns the path specified via the command line, or DefaultConfigPath if none is provided. +// +// GetConfigPath implements parts of the [github.com/icinga/icinga-go-library/config.Flags] interface. +func (f Flags) GetConfigPath() string { + if f.Config == "" { + return DefaultConfigPath + } + + return f.Config +} + +// IsExplicitConfigPath indicates whether the configuration file path was explicitly set. +// +// IsExplicitConfigPath implements parts of the [github.com/icinga/icinga-go-library/config.Flags] interface. +func (f Flags) IsExplicitConfigPath() bool { + return f.Config != "" } // RetentionConfig defines configuration for history retention. type RetentionConfig struct { - HistoryDays uint16 `yaml:"history-days"` - SlaDays uint16 `yaml:"sla-days"` - Interval time.Duration `yaml:"interval" default:"1h"` - Count uint64 `yaml:"count" default:"5000"` - Options history.RetentionOptions `yaml:"options"` + HistoryDays uint16 `yaml:"history-days" env:"HISTORY_DAYS"` + SlaDays uint16 `yaml:"sla-days" env:"SLA_DAYS"` + Interval time.Duration `yaml:"interval" env:"INTERVAL" default:"1h"` + Count uint64 `yaml:"count" env:"COUNT" default:"5000"` + Options history.RetentionOptions `yaml:"options" env:"OPTIONS"` } // Validate checks constraints in the supplied retention configuration and diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 692ea920..72590159 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,14 +3,36 @@ package config import ( "github.com/creasty/defaults" "github.com/icinga/icinga-go-library/config" + "github.com/icinga/icinga-go-library/database" "github.com/icinga/icinga-go-library/logging" + "github.com/icinga/icinga-go-library/redis" + "github.com/icinga/icinga-go-library/testutils" + "github.com/icinga/icingadb/pkg/icingadb/history" "github.com/stretchr/testify/require" + "go.uber.org/zap/zapcore" "os" "testing" ) -func TestFromYAMLFile(t *testing.T) { - const miniConf = ` +// testFlags is a struct that implements the Flags interface. +// It holds information about the configuration file path and whether it was explicitly set. +type testFlags struct { + configPath string // The path to the configuration file. + explicitConfigPath bool // Indicates if the config path was explicitly set. +} + +// GetConfigPath returns the path to the configuration file. +func (f testFlags) GetConfigPath() string { + return f.configPath +} + +// IsExplicitConfigPath indicates whether the configuration file path was explicitly set. +func (f testFlags) IsExplicitConfigPath() bool { + return f.explicitConfigPath +} + +func TestConfig(t *testing.T) { + const yamlConfig = ` database: host: 192.0.2.1 database: icingadb @@ -20,52 +42,180 @@ database: redis: host: 2001:db8::1 ` - - subtests := []struct { - name string - input string - output *Config - }{ + loadTests := []testutils.TestCase[config.Validator, testutils.ConfigTestData]{ { - name: "mini", - input: miniConf, - output: func() *Config { - c := &Config{} - _ = defaults.Set(c) + Name: "Load from YAML only", + Data: testutils.ConfigTestData{ + Yaml: yamlConfig + ` +logging: + options: + database: debug + redis: debug - c.Database.Host = "192.0.2.1" - c.Database.Database = "icingadb" - c.Database.User = "icingadb" - c.Database.Password = "icingadb" - - c.Redis.Host = "2001:db8::1" - c.Logging.Output = logging.CONSOLE - - return c - }(), +retention: + options: + comment: 31 + downtime: 365 +`, + }, + Expected: &Config{ + Database: database.Config{ + Host: "192.0.2.1", + Database: "icingadb", + User: "icingadb", + Password: "icingadb", + }, + Redis: redis.Config{ + Host: "2001:db8::1", + }, + Logging: logging.Config{ + Options: logging.Options{ + "database": zapcore.DebugLevel, + "redis": zapcore.DebugLevel, + }, + }, + Retention: RetentionConfig{ + Options: history.RetentionOptions{ + "comment": 31, + "downtime": 365, + }, + }, + }, }, { - name: "mini-with-unknown", - input: miniConf + "\nunknown: 42", - output: nil, + Name: "Load from Env only", + Data: testutils.ConfigTestData{ + Env: map[string]string{ + "ICINGADB_DATABASE_HOST": "192.0.2.1", + "ICINGADB_DATABASE_DATABASE": "icingadb", + "ICINGADB_DATABASE_USER": "icingadb", + "ICINGADB_DATABASE_PASSWORD": "icingadb", + "ICINGADB_REDIS_HOST": "2001:db8::1", + "ICINGADB_LOGGING_OPTIONS": "database:debug,redis:debug", + "ICINGADB_RETENTION_OPTIONS": "comment:31,downtime:365", + }, + }, + Expected: &Config{ + Database: database.Config{ + Host: "192.0.2.1", + Database: "icingadb", + User: "icingadb", + Password: "icingadb", + }, + Redis: redis.Config{ + Host: "2001:db8::1", + }, + Logging: logging.Config{ + Options: logging.Options{ + "database": zapcore.DebugLevel, + "redis": zapcore.DebugLevel, + }, + }, + Retention: RetentionConfig{ + Options: history.RetentionOptions{ + "comment": 31, + "downtime": 365, + }, + }, + }, + }, + { + Name: "YAML and Env; Env overrides", + Data: testutils.ConfigTestData{ + Yaml: yamlConfig, + Env: map[string]string{ + "ICINGADB_DATABASE_HOST": "192.168.0.1", + "ICINGADB_REDIS_HOST": "localhost", + }, + }, + Expected: &Config{ + Database: database.Config{ + Host: "192.168.0.1", + Database: "icingadb", + User: "icingadb", + Password: "icingadb", + }, + Redis: redis.Config{ + Host: "localhost", + }, + }, + }, + { + Name: "YAML and Env; Env supplements", + Data: testutils.ConfigTestData{ + Yaml: yamlConfig, + Env: map[string]string{ + "ICINGADB_REDIS_USERNAME": "icingadb", + "ICINGADB_REDIS_PASSWORD": "icingadb", + }}, + Expected: &Config{ + Database: database.Config{ + Host: "192.0.2.1", + Database: "icingadb", + User: "icingadb", + Password: "icingadb", + }, + Redis: redis.Config{ + Host: "2001:db8::1", + Username: "icingadb", + Password: "icingadb", + }, + }, + }, + { + Name: "YAML and Env; Env overrides defaults", + Data: testutils.ConfigTestData{ + Yaml: yamlConfig, + Env: map[string]string{ + "ICINGADB_DATABASE_PORT": "3307", + }}, + Expected: &Config{ + Database: database.Config{ + Host: "192.0.2.1", + Port: 3307, + Database: "icingadb", + User: "icingadb", + Password: "icingadb", + }, + Redis: redis.Config{ + Host: "2001:db8::1", + }, + }, + }, + { + Name: "Unknown YAML field", + Data: testutils.ConfigTestData{ + Yaml: `unknown: unknown`, + }, + Error: testutils.ErrorContains(`unknown field "unknown"`), }, } - for _, st := range subtests { - t.Run(st.name, func(t *testing.T) { - tempFile, err := os.CreateTemp("", "") - require.NoError(t, err) - defer func() { _ = os.Remove(tempFile.Name()) }() - - require.NoError(t, os.WriteFile(tempFile.Name(), []byte(st.input), 0o600)) - - var actual Config - if err := config.FromYAMLFile(tempFile.Name(), &actual); st.output == nil { - require.Error(t, err) - } else { - require.NoError(t, err) - require.Equal(t, *st.output, actual) + for _, tc := range loadTests { + t.Run(tc.Name, tc.F(func(data testutils.ConfigTestData) (config.Validator, error) { + if tc.Error == nil { + // Set defaults for the expected configuration if no error is expected. + require.NoError(t, defaults.Set(tc.Expected), "setting defaults") } - }) + + actual := new(Config) + + var err error + if data.Yaml != "" { + testutils.WithYAMLFile(t, data.Yaml, func(file *os.File) { + err = config.Load(actual, config.LoadOptions{ + Flags: testFlags{configPath: file.Name(), explicitConfigPath: true}, + EnvOptions: config.EnvOptions{Prefix: "ICINGADB_", Environment: data.Env}, + }) + }) + } else { + err = config.Load(actual, config.LoadOptions{ + Flags: testFlags{}, + EnvOptions: config.EnvOptions{Prefix: "ICINGADB_", Environment: data.Env}, + }) + } + + return actual, err + })) } } diff --git a/pkg/icingadb/history/retention.go b/pkg/icingadb/history/retention.go index a97697d3..05ad48ee 100644 --- a/pkg/icingadb/history/retention.go +++ b/pkg/icingadb/history/retention.go @@ -11,6 +11,8 @@ import ( "github.com/icinga/icingadb/pkg/icingaredis/telemetry" "github.com/pkg/errors" "go.uber.org/zap" + "strconv" + "strings" "time" ) @@ -97,9 +99,49 @@ var RetentionStatements = []retentionStatement{{ // RetentionOptions defines the non-default mapping of history categories with their retention period in days. type RetentionOptions map[string]uint16 +// UnmarshalText implements [encoding.TextUnmarshaler] to allow RetentionOptions to be parsed by env. +// +// This custom TextUnmarshaler is necessary as - for the moment - env does not support map[T]encoding.TextUnmarshaler. +// After got merged and a new env release was drafted, this method can be +// removed. +func (o *RetentionOptions) UnmarshalText(text []byte) error { + optionsMap := make(map[string]uint16) + + for _, pair := range strings.Split(string(text), ",") { + key, value, found := strings.Cut(pair, ":") + if !found { + return fmt.Errorf("entry %q cannot be unmarshalled as a history-category:retention-period pair", pair) + } + + days, err := strconv.ParseUint(value, 10, 16) + if err != nil { + return fmt.Errorf("failed to parse %q as a uint16 retention period in days: %v", value, err) + } + + optionsMap[key] = uint16(days) + } + + *o = optionsMap + + return nil +} + +// UnmarshalYAML implements yaml.InterfaceUnmarshaler to allow RetentionOptions to be parsed go-yaml. +func (o *RetentionOptions) UnmarshalYAML(unmarshal func(any) error) error { + optionsMap := make(map[string]uint16) + + if err := unmarshal(&optionsMap); err != nil { + return err + } + + *o = optionsMap + + return nil +} + // Validate checks constraints in the supplied retention options and // returns an error if they are violated. -func (o RetentionOptions) Validate() error { +func (o *RetentionOptions) Validate() error { allowedCategories := make(map[string]struct{}) for _, stmt := range RetentionStatements { if stmt.RetentionType == RetentionHistory { @@ -107,7 +149,7 @@ func (o RetentionOptions) Validate() error { } } - for category := range o { + for category := range *o { if _, ok := allowedCategories[category]; !ok { return errors.Errorf("invalid key %s for history retention", category) }