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 12, 2024
1 parent 4e5716e commit 08394e6
Show file tree
Hide file tree
Showing 4 changed files with 489 additions and 17 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
130 changes: 118 additions & 12 deletions internal/daemon/config.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,30 @@
package daemon

import (
"bytes"
"errors"
"fmt"
"github.com/creasty/defaults"
"github.com/goccy/go-yaml"
icingadbConfig "github.com/icinga/icingadb/pkg/config"
"io"
"os"
"reflect"
"regexp"
"strconv"
"strings"
)

// 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_LISTEN sets ConfigFile.Listen 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 +37,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 +74,105 @@ func Config() *ConfigFile {
return config
}

func fromFile(path string) (*ConfigFile, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
// yamlConfigFileFromEnvironmentEnvMatch is a regular expression to match ICINGA_NOTIFICATIONS_KEY_SUBKEY_SUBSUBKEY...=VAL pairs.
var yamlConfigFileFromEnvironmentEnvMatch = regexp.MustCompile(`(?s)\AICINGA_NOTIFICATIONS_([A-Z0-9_]+)=(.*)\z`)

// yamlConfigFileFromEnvironment creates a ConfigFile compatible YAML representation from environment variables.
//
// Environment variables of the form ICINGA_NOTIFICATIONS_${KEY}=${VALUE} will be translated into an ephemeral
// multidimensional map (key_1 -> ... -> key_n -> value) with keys based on the "yaml" struct tag. Eventually, the map
// will be encoded as YAML, matching ConfigFile's YAML format.
func yamlConfigFileFromEnvironment(environ []string) (io.Reader, error) {
cfg := make(map[string]any)
for _, env := range environ {
match := yamlConfigFileFromEnvironmentEnvMatch.FindStringSubmatch(env)
if match == nil {
continue
}

path := strings.Split(match[1], "_")
trace := make([]string, 0, len(path)-1)
root := reflect.Indirect(reflect.ValueOf(&ConfigFile{}))

pathLoop:
for pathNo := 0; pathNo < len(path); pathNo++ {
subKey := "ICINGA_NOTIFICATIONS_" + strings.Join(path[:pathNo+1], "_")
t := root.Type()
for fieldNo := 0; fieldNo < t.NumField(); fieldNo++ {
if strings.ToUpper(t.Field(fieldNo).Name) != path[pathNo] {
continue
}

fieldName := t.Field(fieldNo).Tag.Get("yaml")
if fieldName == "" {
return nil, fmt.Errorf("field %q misses yaml struct tag", subKey)
}

if pathNo == len(path)-1 {
// Find and/or create leaf node in nested multidimensional config map.
partCfg := cfg
for _, part := range trace {
tmpPartCfg, ok := partCfg[part]
if !ok {
tmpPartCfg = make(map[string]any)
partCfg[part] = tmpPartCfg
}
partCfg = tmpPartCfg.(map[string]any)
}

// Encode numeric values as integers as, otherwise, YAML cannot decode those as integers back.
if i, err := strconv.ParseInt(match[2], 10, 64); err == nil {
partCfg[fieldName] = i
} else {
partCfg[fieldName] = match[2]
}
} else {
trace = append(trace, fieldName)
root = reflect.Indirect(root).Field(fieldNo)
}
continue pathLoop
}
return nil, fmt.Errorf("cannot resolve field %q", subKey)
}
}
defer func() { _ = f.Close() }()

var buff bytes.Buffer
err := yaml.NewEncoder(&buff).Encode(cfg)
return &buff, err
}

// loadConfig loads the daemon configuration from environment variables, YAML configuration, and defaults.
func loadConfig(yamlFile 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 {
yamlEnvConf, err := yamlConfigFileFromEnvironment(environ)
if err != nil {
return nil, err
}

if err := c.Validate(); err != nil {
return nil, err
for _, yamlConf := range []io.Reader{yamlFile, yamlEnvConf} {
if yamlConf == nil {
continue
}

d := yaml.NewDecoder(yamlConf)
if err := d.Decode(&c); err != nil && !errors.Is(err, io.EOF) {
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 08394e6

Please sign in to comment.