Merge pull request #831 from Icinga/config-from-environment-variables

Support loading configuration from both YAML files and env vars
This commit is contained in:
Eric Lippmann 2025-03-26 13:21:58 +01:00 committed by GitHub
commit 1ca18cfdce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 276 additions and 55 deletions

View file

@ -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)
}

View file

@ -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

View file

@ -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
}))
}
}

View file

@ -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 <https://github.com/caarlos0/env/pull/323> 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)
}