Skip to content

Commit

Permalink
Load ConfigFile from Environment Variables
Browse files Browse the repository at this point in the history
Next to populating ConfigFile through default values and by a YAML
configuration, environment variable support was added.

The code works by reflecting on the existing nested ConfigFile struct.
The environment variable key is an underscore separated string of
uppercase struct fields.
  • Loading branch information
oxzi committed Jan 15, 2024
1 parent 4e5716e commit 19ac124
Show file tree
Hide file tree
Showing 4 changed files with 737 additions and 19 deletions.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 0 additions & 5 deletions cmd/icinga-notifications-daemon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
158 changes: 144 additions & 14 deletions internal/daemon/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand All @@ -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
}
Expand All @@ -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
}
Expand Down
Loading

0 comments on commit 19ac124

Please sign in to comment.