diff --git a/README.md b/README.md index 0a4b46e9..6a472343 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,11 @@ git clone https://github.com/Icinga/icinga-notifications.git ``` Next, you need to provide a `config.yml` file, similar to the [example config](config.example.yml), for the daemon. +It is also possible to set environment variables by name instead of or in addition to the configuration file. +The environment variable key is an underscore separated string of uppercase struct fields. For example +* `ICINGA_NOTIFICATIONS_LISTEN` sets `ConfigFile.Listen` and +* `ICINGA_NOTIFICATIONS_DATABASE_HOST` sets `ConfigFile.Database.Host`. + It is required that you have created a new database and imported the [schema](schema/pgsql/schema.sql) file beforehand. > **Note** > At the moment **PostgreSQL** is the only database backend we support. diff --git a/cmd/icinga-notifications-daemon/main.go b/cmd/icinga-notifications-daemon/main.go index b0f8f438..b4694bb1 100644 --- a/cmd/icinga-notifications-daemon/main.go +++ b/cmd/icinga-notifications-daemon/main.go @@ -41,11 +41,6 @@ func main() { return } - if configPath == "" { - _, _ = fmt.Fprintln(os.Stderr, "missing -config flag") - os.Exit(1) - } - err := daemon.LoadConfig(configPath) if err != nil { _, _ = fmt.Fprintln(os.Stderr, "cannot load config:", err) diff --git a/internal/daemon/config.go b/internal/daemon/config.go index bd4c4983..7f95b838 100644 --- a/internal/daemon/config.go +++ b/internal/daemon/config.go @@ -2,12 +2,127 @@ package daemon import ( "errors" + "fmt" "github.com/creasty/defaults" "github.com/goccy/go-yaml" icingadbConfig "github.com/icinga/icingadb/pkg/config" + "io" "os" + "reflect" + "regexp" + "strings" ) +// populateFromYamlEnvironmentPathStep is the recursive worker function for PopulateFromYamlEnvironment. +// +// It performs a linear search along the path with pathNo as the current element except when a wild `,inline` appears, +// resulting in branching off to allow peeking into the inlined struct. +func populateFromYamlEnvironmentPathStep(keyPrefix string, cur reflect.Value, path []string, pathNo int, value string) error { + notFoundErr := errors.New("cannot resolve path") + + subKey := keyPrefix + "_" + strings.Join(path[:pathNo+1], "_") + + t := cur.Type() + for fieldNo := 0; fieldNo < t.NumField(); fieldNo++ { + fieldName := t.Field(fieldNo).Tag.Get("yaml") + if fieldName == "" { + return fmt.Errorf("field %q misses yaml struct tag", subKey) + } + if strings.Contains(fieldName, "_") { + return fmt.Errorf("field %q contains an underscore, the environment key separator, in its yaml struct tag", subKey) + } + + if regexp.MustCompile(`^.*(,[a-z]+)*,inline(,[a-z]+)*$`).MatchString(fieldName) { + // Peek into the `,inline`d struct but ignore potential failure. + err := populateFromYamlEnvironmentPathStep(keyPrefix, reflect.Indirect(cur).Field(fieldNo), path, pathNo, value) + if err == nil { + return nil + } else if !errors.Is(err, notFoundErr) { + return err + } + } + + if strings.ToUpper(fieldName) != path[pathNo] { + continue + } + + if pathNo < len(path)-1 { + return populateFromYamlEnvironmentPathStep(keyPrefix, reflect.Indirect(cur).Field(fieldNo), path, pathNo+1, value) + } + + field := cur.Field(fieldNo) + tmp := reflect.New(field.Type()).Interface() + err := yaml.NewDecoder(strings.NewReader(value)).Decode(tmp) + if err != nil { + return fmt.Errorf("cannot unmarshal into %q: %w", subKey, err) + } + field.Set(reflect.ValueOf(tmp).Elem()) + return nil + } + + return fmt.Errorf("%w %q", notFoundErr, subKey) +} + +// PopulateFromYamlEnvironment populates a struct with "yaml" struct tags based on environment variables. +// +// To write into targetElem, it must be passed as a pointer reference. +// +// Environment variables of the form ${KEY_PREFIX}_${KEY_0}_${KEY_i}_${KEY_n}=${VALUE} will be translated to a YAML path +// from the struct field with the "yaml" struct tag ${KEY_0} across all further nested fields ${KEY_i} up to the last +// ${KEY_n}. The ${VALUE} will be YAML decoded into the referenced field of the targetElem. +// +// Next to addressing fields through keys, elementary `,inline` flags are also being supported. This allows referring an +// inline struct's field as it would be a field of the parent. +// +// Consider the following struct: +// +// type Example struct { +// Outer struct { +// Inner int `yaml:"inner"` +// } `yaml:"outer"` +// } +// +// The Inner field can get populated through: +// +// PopulateFromYamlEnvironment("EXAMPLE", &example, []string{"EXAMPLE_OUTER_INNER=23"}) +func PopulateFromYamlEnvironment(keyPrefix string, targetElem any, environ []string) error { + matcher, err := regexp.Compile(`(?s)\A` + keyPrefix + `_([A-Z0-9_-]+)=(.*)\z`) + if err != nil { + return err + } + + if reflect.ValueOf(targetElem).Type().Kind() != reflect.Ptr { + return errors.New("targetElem is required to be a pointer") + } + + for _, env := range environ { + match := matcher.FindStringSubmatch(env) + if match == nil { + continue + } + + path := strings.Split(match[1], "_") + parent := reflect.Indirect(reflect.ValueOf(targetElem)) + + err := populateFromYamlEnvironmentPathStep(keyPrefix, parent, path, 0, match[2]) + if err != nil { + return err + } + } + + return nil +} + +// ConfigFile used from the icinga-notifications-daemon. +// +// The ConfigFile will be populated from different sources in the following order, when calling the LoadConfig method: +// 1. Default values (default struct tags) are getting assigned from all nested types. +// 2. Values are getting overridden from the YAML configuration file. +// 3. Values are getting overridden by environment variables of the form ICINGA_NOTIFICATIONS_${KEY}. +// +// The environment variable key is an underscore separated string of uppercase struct fields. For example +// - ICINGA_NOTIFICATIONS_DEBUG-PASSWORD sets ConfigFile.DebugPassword and +// - ICINGA_NOTIFICATIONS_DATABASE_HOST sets ConfigFile.Database.Host. type ConfigFile struct { Listen string `yaml:"listen" default:"localhost:5680"` DebugPassword string `yaml:"debug-password"` @@ -20,13 +135,29 @@ type ConfigFile struct { // config holds the configuration state as a singleton. It is used from LoadConfig and Config var config *ConfigFile -// LoadConfig loads the daemon config from given path. Call it only once when starting the daemon. -func LoadConfig(path string) error { +// LoadConfig loads the daemon configuration from environment variables, YAML configuration, and defaults. +// +// After loading, some validations will be performed. This function MUST be called only once when starting the daemon. +func LoadConfig(cfgPath string) error { if config != nil { return errors.New("config already set") } - cfg, err := fromFile(path) + var cfgReader io.ReadCloser + if cfgPath != "" { + var err error + if cfgReader, err = os.Open(cfgPath); err != nil { + return err + } + defer func() { _ = cfgReader.Close() }() + } + + cfg, err := loadConfig(cfgReader, os.Environ()) + if err != nil { + return err + } + + err = cfg.Validate() if err != nil { return err } @@ -41,32 +172,31 @@ func Config() *ConfigFile { return config } -func fromFile(path string) (*ConfigFile, error) { - f, err := os.Open(path) - if err != nil { - return nil, err - } - defer func() { _ = f.Close() }() - +// loadConfig loads the daemon configuration from environment variables, YAML configuration, and defaults. +func loadConfig(yamlCfg io.Reader, environ []string) (*ConfigFile, error) { var c ConfigFile if err := defaults.Set(&c); err != nil { return nil, err } - d := yaml.NewDecoder(f) - if err := d.Decode(&c); err != nil { + err := yaml.NewDecoder(yamlCfg).Decode(&c) + if err != nil && err != io.EOF { return nil, err } - if err := c.Validate(); err != nil { + err = PopulateFromYamlEnvironment("ICINGA_NOTIFICATIONS", &c, environ) + if err != nil { return nil, err } - return &c, nil } +// Validate the ConfigFile and return an error if a check failed. func (c *ConfigFile) Validate() error { + if c.Icingaweb2URL == "" { + return fmt.Errorf("Icingaweb2URL field MUST be populated") + } if err := c.Database.Validate(); err != nil { return err } diff --git a/internal/daemon/config_test.go b/internal/daemon/config_test.go new file mode 100644 index 00000000..dbbfc2eb --- /dev/null +++ b/internal/daemon/config_test.go @@ -0,0 +1,588 @@ +package daemon + +import ( + "bytes" + "github.com/goccy/go-yaml" + icingadbConfig "github.com/icinga/icingadb/pkg/config" + "github.com/icinga/icingadb/pkg/icingadb" + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + "strings" + "testing" + "time" +) + +func TestPopulateFromYamlEnvironment(t *testing.T) { + tests := []struct { + name string + target any + environ []string // Prefix is an additional "_" for this test, resulting in "__${KEY..}". + want string + wantErr bool + }{ + { + name: "empty", + target: &struct{}{}, + want: "{}", + }, + { + name: "missing-yaml-tag", + target: &struct{ A int }{}, + environ: []string{"__A=23"}, + wantErr: true, + }, + { + name: "primitive-types", + target: &struct { + A bool `yaml:"a"` + B uint64 `yaml:"b"` + C int64 `yaml:"c"` + D float64 `yaml:"d"` + E string `yaml:"e"` + }{}, + environ: []string{ + "__A=true", + "__B=9001", + "__C=-9001", + "__D=23.42", + "__E=Hello World!", + }, + want: ` +a: true +b: 9001 +c: -9001 +d: 23.42 +e: Hello World! + `, + }, + { + name: "nested-struct", + target: &struct { + A struct { + A int `yaml:"a"` + } `yaml:"a"` + }{}, + environ: []string{"__A_A=23"}, + want: ` +a: + a: 23 +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := PopulateFromYamlEnvironment("_", tt.target, tt.environ) + assert.Equal(t, tt.wantErr, err != nil, "unexpected error: %v", err) + if err != nil { + return + } + + var yamlBuff bytes.Buffer + assert.NoError(t, yaml.NewEncoder(&yamlBuff).Encode(tt.target), "encoding YAML") + assert.Equal(t, strings.TrimSpace(tt.want), strings.TrimSpace(yamlBuff.String()), "unexpected ConfigFile") + }) + } +} + +func TestPopulateFromYamlEnvironmentInline(t *testing.T) { + type Inner struct { + IA int `yaml:"ia"` + IB string `yaml:"ib"` + } + type Outer struct { + A float64 `yaml:"a"` + I Inner `yaml:",inline"` + } + + environ := []string{ + "__A=3.14", + "__IA=12345", + "__IB=_inline string", + } + + var outer Outer + assert.NoError(t, PopulateFromYamlEnvironment("_", &outer, environ), "populating _inline struct") +} + +func TestPopulateFromYamlEnvironmentConfigFile(t *testing.T) { + tests := []struct { + name string + environ []string + want string + wantErr bool + }{ + { + name: "empty", + want: ` +listen: "" +debug-password: "" +channel-plugin-dir: "" +icingaweb2-url: "" +database: + type: "" + host: "" + port: 0 + database: "" + user: "" + password: "" + tls: false + cert: "" + key: "" + ca: "" + insecure: false + options: + max_connections: 0 + max_connections_per_table: 0 + max_placeholders_per_statement: 0 + max_rows_per_transaction: 0 +logging: + level: info + output: "" + interval: 0s + options: {} + `, + }, + { + name: "irrelevant-keys", + environ: []string{ + "ICINGA_NOPE=FOO", + "FOO=NOPE", + }, + want: ` +listen: "" +debug-password: "" +channel-plugin-dir: "" +icingaweb2-url: "" +database: + type: "" + host: "" + port: 0 + database: "" + user: "" + password: "" + tls: false + cert: "" + key: "" + ca: "" + insecure: false + options: + max_connections: 0 + max_connections_per_table: 0 + max_placeholders_per_statement: 0 + max_rows_per_transaction: 0 +logging: + level: info + output: "" + interval: 0s + options: {} + `, + }, + { + name: "base-unknown-field", + environ: []string{ + "ICINGA_NOTIFICATIONS_INVALID=no", + }, + wantErr: true, + }, + { + name: "base-config", + environ: []string{ + "ICINGA_NOTIFICATIONS_LISTEN='[2001:db8::1]:5680'", + "ICINGA_NOTIFICATIONS_DEBUG-PASSWORD=insecure", + "ICINGA_NOTIFICATIONS_CHANNEL-PLUGIN-DIR=/channel", + "ICINGA_NOTIFICATIONS_ICINGAWEB2-URL=http://[2001:db8::1]/icingaweb2/", + }, + want: ` +listen: "[2001:db8::1]:5680" +debug-password: insecure +channel-plugin-dir: /channel +icingaweb2-url: http://[2001:db8::1]/icingaweb2/ +database: + type: "" + host: "" + port: 0 + database: "" + user: "" + password: "" + tls: false + cert: "" + key: "" + ca: "" + insecure: false + options: + max_connections: 0 + max_connections_per_table: 0 + max_placeholders_per_statement: 0 + max_rows_per_transaction: 0 +logging: + level: info + output: "" + interval: 0s + options: {} + `, + }, + { + name: "nested-config", + environ: []string{ + "ICINGA_NOTIFICATIONS_LISTEN='[2001:db8::1]:5680'", + "ICINGA_NOTIFICATIONS_DEBUG-PASSWORD=insecure", + "ICINGA_NOTIFICATIONS_CHANNEL-PLUGIN-DIR=/channel", + "ICINGA_NOTIFICATIONS_ICINGAWEB2-URL=http://[2001:db8::1]/icingaweb2/", + "ICINGA_NOTIFICATIONS_DATABASE_TYPE=pgsql", + "ICINGA_NOTIFICATIONS_DATABASE_HOST='[2001:db8::23]'", + "ICINGA_NOTIFICATIONS_DATABASE_PORT=5432", + "ICINGA_NOTIFICATIONS_DATABASE_DATABASE=icinga_notifications", + "ICINGA_NOTIFICATIONS_DATABASE_USER=icinga_notifications", + "ICINGA_NOTIFICATIONS_DATABASE_PASSWORD=insecure", + "ICINGA_NOTIFICATIONS_LOGGING_LEVEL=debug", + "ICINGA_NOTIFICATIONS_LOGGING_OUTPUT=console", + "ICINGA_NOTIFICATIONS_LOGGING_INTERVAL=9001h", + }, + want: ` +listen: "[2001:db8::1]:5680" +debug-password: insecure +channel-plugin-dir: /channel +icingaweb2-url: http://[2001:db8::1]/icingaweb2/ +database: + type: pgsql + host: "[2001:db8::23]" + port: 5432 + database: icinga_notifications + user: icinga_notifications + password: insecure + tls: false + cert: "" + key: "" + ca: "" + insecure: false + options: + max_connections: 0 + max_connections_per_table: 0 + max_placeholders_per_statement: 0 + max_rows_per_transaction: 0 +logging: + level: debug + output: console + interval: 9001h0m0s + options: {} + `, + }, + { + name: "inlined-database-tls-config", + environ: []string{ + "ICINGA_NOTIFICATIONS_DATABASE_TLS=true", + "ICINGA_NOTIFICATIONS_DATABASE_CERT=./client.crt", + "ICINGA_NOTIFICATIONS_DATABASE_KEY=./client.key", + "ICINGA_NOTIFICATIONS_DATABASE_CA=./ca.crt", + "ICINGA_NOTIFICATIONS_DATABASE_INSECURE=false", + }, + want: ` +listen: "" +debug-password: "" +channel-plugin-dir: "" +icingaweb2-url: "" +database: + type: "" + host: "" + port: 0 + database: "" + user: "" + password: "" + tls: true + cert: ./client.crt + key: ./client.key + ca: ./ca.crt + insecure: false + options: + max_connections: 0 + max_connections_per_table: 0 + max_placeholders_per_statement: 0 + max_rows_per_transaction: 0 +logging: + level: info + output: "" + interval: 0s + options: {} + `, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var cfg ConfigFile + err := PopulateFromYamlEnvironment("ICINGA_NOTIFICATIONS", &cfg, tt.environ) + assert.Equal(t, tt.wantErr, err != nil, "unexpected error: %v", err) + if err != nil { + return + } + + var yamlBuff bytes.Buffer + assert.NoError(t, yaml.NewEncoder(&yamlBuff).Encode(&cfg), "encoding YAML") + assert.Equal(t, strings.TrimSpace(tt.want), strings.TrimSpace(yamlBuff.String()), "unexpected ConfigFile") + }) + } +} + +func TestLoadConfig(t *testing.T) { + tests := []struct { + name string + envs []string + yaml string + want *ConfigFile + }{ + { + // Some defaults are inherited from nested fields, e.g., all Options within ConfigFile.Database.Options. + name: "defaults", + want: &ConfigFile{ + Listen: "localhost:5680", + DebugPassword: "", + ChannelPluginDir: "/usr/libexec/icinga-notifications/channel", + Icingaweb2URL: "", + Database: icingadbConfig.Database{ + Type: "mysql", + Options: icingadb.Options{ + MaxConnections: 16, + MaxConnectionsPerTable: 8, + MaxPlaceholdersPerStatement: 8192, + MaxRowsPerTransaction: 8192, + }, + }, + Logging: icingadbConfig.Logging{ + Level: zap.InfoLevel, + Interval: 20 * time.Second, + }, + }, + }, + { + name: "envs-base", + envs: []string{ + "ICINGA_NOTIFICATIONS_LISTEN='[2001:db8::1]:5680'", + "ICINGA_NOTIFICATIONS_DEBUG-PASSWORD=insecure", + "ICINGA_NOTIFICATIONS_CHANNEL-PLUGIN-DIR=/channel", + "ICINGA_NOTIFICATIONS_ICINGAWEB2-URL=http://[2001:db8::1]/icingaweb2/", + }, + want: &ConfigFile{ + Listen: "[2001:db8::1]:5680", + DebugPassword: "insecure", + ChannelPluginDir: "/channel", + Icingaweb2URL: "http://[2001:db8::1]/icingaweb2/", + Database: icingadbConfig.Database{ + Type: "mysql", + Options: icingadb.Options{ + MaxConnections: 16, + MaxConnectionsPerTable: 8, + MaxPlaceholdersPerStatement: 8192, + MaxRowsPerTransaction: 8192, + }, + }, + Logging: icingadbConfig.Logging{ + Level: zap.InfoLevel, + Interval: 20 * time.Second, + }, + }, + }, + { + name: "env-nested", + envs: []string{ + "ICINGA_NOTIFICATIONS_LISTEN='[2001:db8::1]:5680'", + "ICINGA_NOTIFICATIONS_DEBUG-PASSWORD=insecure", + "ICINGA_NOTIFICATIONS_CHANNEL-PLUGIN-DIR=/channel", + "ICINGA_NOTIFICATIONS_ICINGAWEB2-URL=http://[2001:db8::1]/icingaweb2/", + "ICINGA_NOTIFICATIONS_DATABASE_TYPE=pgsql", + "ICINGA_NOTIFICATIONS_DATABASE_HOST='[2001:db8::23]'", + "ICINGA_NOTIFICATIONS_DATABASE_PORT=5432", + "ICINGA_NOTIFICATIONS_DATABASE_DATABASE=icinga_notifications", + "ICINGA_NOTIFICATIONS_DATABASE_USER=icinga_notifications", + "ICINGA_NOTIFICATIONS_DATABASE_PASSWORD=insecure", + "ICINGA_NOTIFICATIONS_LOGGING_LEVEL=debug", + "ICINGA_NOTIFICATIONS_LOGGING_OUTPUT=console", + "ICINGA_NOTIFICATIONS_LOGGING_INTERVAL=9001h", + }, + want: &ConfigFile{ + Listen: "[2001:db8::1]:5680", + DebugPassword: "insecure", + ChannelPluginDir: "/channel", + Icingaweb2URL: "http://[2001:db8::1]/icingaweb2/", + Database: icingadbConfig.Database{ + Type: "pgsql", + Host: "[2001:db8::23]", + Port: 5432, + Database: "icinga_notifications", + User: "icinga_notifications", + Password: "insecure", + Options: icingadb.Options{ + MaxConnections: 16, + MaxConnectionsPerTable: 8, + MaxPlaceholdersPerStatement: 8192, + MaxRowsPerTransaction: 8192, + }, + }, + Logging: icingadbConfig.Logging{ + Level: zap.DebugLevel, + Output: "console", + Interval: 9001 * time.Hour, + }, + }, + }, + { + name: "yaml-base", + yaml: ` +listen: "[2001:db8::1]:5680" +debug-password: "insecure" +channel-plugin-dir: "/channel" +icingaweb2-url: "http://[2001:db8::1]/icingaweb2/" + `, + want: &ConfigFile{ + Listen: "[2001:db8::1]:5680", + DebugPassword: "insecure", + ChannelPluginDir: "/channel", + Icingaweb2URL: "http://[2001:db8::1]/icingaweb2/", + Database: icingadbConfig.Database{ + Type: "mysql", + Options: icingadb.Options{ + MaxConnections: 16, + MaxConnectionsPerTable: 8, + MaxPlaceholdersPerStatement: 8192, + MaxRowsPerTransaction: 8192, + }, + }, + Logging: icingadbConfig.Logging{ + Level: zap.InfoLevel, + Interval: 20 * time.Second, + }, + }, + }, + { + name: "yaml-nested", + yaml: ` +listen: "[2001:db8::1]:5680" +debug-password: "insecure" +channel-plugin-dir: "/channel" +icingaweb2-url: "http://[2001:db8::1]/icingaweb2/" + +database: + type: "pgsql" + host: "[2001:db8::23]" + port: 5432 + database: "icinga_notifications" + user: "icinga_notifications" + password: "insecure" + +logging: + level: "debug" + output: "console" + interval: "9001h" + `, + want: &ConfigFile{ + Listen: "[2001:db8::1]:5680", + DebugPassword: "insecure", + ChannelPluginDir: "/channel", + Icingaweb2URL: "http://[2001:db8::1]/icingaweb2/", + Database: icingadbConfig.Database{ + Type: "pgsql", + Host: "[2001:db8::23]", + Port: 5432, + Database: "icinga_notifications", + User: "icinga_notifications", + Password: "insecure", + Options: icingadb.Options{ + MaxConnections: 16, + MaxConnectionsPerTable: 8, + MaxPlaceholdersPerStatement: 8192, + MaxRowsPerTransaction: 8192, + }, + }, + Logging: icingadbConfig.Logging{ + Level: zap.DebugLevel, + Output: "console", + Interval: 9001 * time.Hour, + }, + }, + }, + { + name: "yaml-env-mixed", + yaml: ` +listen: "[2001:db8::1]:5680" +debug-password: "INCORRECT" +channel-plugin-dir: "/channel" +icingaweb2-url: "http://[2001:db8::1]/icingaweb2/" + `, + envs: []string{ + "ICINGA_NOTIFICATIONS_DEBUG-PASSWORD=insecure", + }, + want: &ConfigFile{ + Listen: "[2001:db8::1]:5680", + DebugPassword: "insecure", + ChannelPluginDir: "/channel", + Icingaweb2URL: "http://[2001:db8::1]/icingaweb2/", + Database: icingadbConfig.Database{ + Type: "mysql", + Options: icingadb.Options{ + MaxConnections: 16, + MaxConnectionsPerTable: 8, + MaxPlaceholdersPerStatement: 8192, + MaxRowsPerTransaction: 8192, + }, + }, + Logging: icingadbConfig.Logging{ + Level: zap.InfoLevel, + Interval: 20 * time.Second, + }, + }, + }, + { + name: "yaml-env-mixed-nested", + yaml: ` +listen: "[2001:db8::1]:5680" +debug-password: "INCORRECT" +channel-plugin-dir: "/channel" +icingaweb2-url: "http://[2001:db8::1]/icingaweb2/" + +database: + type: "pgsql" + host: "[2001:db8::23]" + port: 5432 + database: "icinga_notifications" + user: "icinga_notifications" + password: "insecure" + `, + envs: []string{ + "ICINGA_NOTIFICATIONS_DEBUG-PASSWORD=insecure", + "ICINGA_NOTIFICATIONS_LOGGING_LEVEL=debug", + "ICINGA_NOTIFICATIONS_LOGGING_OUTPUT=console", + "ICINGA_NOTIFICATIONS_LOGGING_INTERVAL=9001h", + }, + want: &ConfigFile{ + Listen: "[2001:db8::1]:5680", + DebugPassword: "insecure", + ChannelPluginDir: "/channel", + Icingaweb2URL: "http://[2001:db8::1]/icingaweb2/", + Database: icingadbConfig.Database{ + Type: "pgsql", + Host: "[2001:db8::23]", + Port: 5432, + Database: "icinga_notifications", + User: "icinga_notifications", + Password: "insecure", + Options: icingadb.Options{ + MaxConnections: 16, + MaxConnectionsPerTable: 8, + MaxPlaceholdersPerStatement: 8192, + MaxRowsPerTransaction: 8192, + }, + }, + Logging: icingadbConfig.Logging{ + Level: zap.DebugLevel, + Output: "console", + Interval: 9001 * time.Hour, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := loadConfig(strings.NewReader(tt.yaml), tt.envs) + assert.NoError(t, err, "unexpected error") + assert.Equal(t, tt.want, got, "unexpected ConfigFile") + }) + } +}