Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Load ConfigFile from Environment Variables #148

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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