mirror of
https://github.com/Icinga/icingadb.git
synced 2026-02-20 00:10:44 -05:00
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:
commit
1ca18cfdce
4 changed files with 276 additions and 55 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue