Skip to content

Commit 19ac124

Browse files
committed
Load ConfigFile from Environment Variables
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.
1 parent 4e5716e commit 19ac124

File tree

4 files changed

+737
-19
lines changed

4 files changed

+737
-19
lines changed

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@ git clone https://github.com/Icinga/icinga-notifications.git
1919
```
2020

2121
Next, you need to provide a `config.yml` file, similar to the [example config](config.example.yml), for the daemon.
22+
It is also possible to set environment variables by name instead of or in addition to the configuration file.
23+
The environment variable key is an underscore separated string of uppercase struct fields. For example
24+
* `ICINGA_NOTIFICATIONS_LISTEN` sets `ConfigFile.Listen` and
25+
* `ICINGA_NOTIFICATIONS_DATABASE_HOST` sets `ConfigFile.Database.Host`.
26+
2227
It is required that you have created a new database and imported the [schema](schema/pgsql/schema.sql) file beforehand.
2328
> **Note**
2429
> At the moment **PostgreSQL** is the only database backend we support.

cmd/icinga-notifications-daemon/main.go

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,6 @@ func main() {
4141
return
4242
}
4343

44-
if configPath == "" {
45-
_, _ = fmt.Fprintln(os.Stderr, "missing -config flag")
46-
os.Exit(1)
47-
}
48-
4944
err := daemon.LoadConfig(configPath)
5045
if err != nil {
5146
_, _ = fmt.Fprintln(os.Stderr, "cannot load config:", err)

internal/daemon/config.go

Lines changed: 144 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,127 @@ package daemon
22

33
import (
44
"errors"
5+
"fmt"
56
"github.com/creasty/defaults"
67
"github.com/goccy/go-yaml"
78
icingadbConfig "github.com/icinga/icingadb/pkg/config"
9+
"io"
810
"os"
11+
"reflect"
12+
"regexp"
13+
"strings"
914
)
1015

16+
// populateFromYamlEnvironmentPathStep is the recursive worker function for PopulateFromYamlEnvironment.
17+
//
18+
// It performs a linear search along the path with pathNo as the current element except when a wild `,inline` appears,
19+
// resulting in branching off to allow peeking into the inlined struct.
20+
func populateFromYamlEnvironmentPathStep(keyPrefix string, cur reflect.Value, path []string, pathNo int, value string) error {
21+
notFoundErr := errors.New("cannot resolve path")
22+
23+
subKey := keyPrefix + "_" + strings.Join(path[:pathNo+1], "_")
24+
25+
t := cur.Type()
26+
for fieldNo := 0; fieldNo < t.NumField(); fieldNo++ {
27+
fieldName := t.Field(fieldNo).Tag.Get("yaml")
28+
if fieldName == "" {
29+
return fmt.Errorf("field %q misses yaml struct tag", subKey)
30+
}
31+
if strings.Contains(fieldName, "_") {
32+
return fmt.Errorf("field %q contains an underscore, the environment key separator, in its yaml struct tag", subKey)
33+
}
34+
35+
if regexp.MustCompile(`^.*(,[a-z]+)*,inline(,[a-z]+)*$`).MatchString(fieldName) {
36+
// Peek into the `,inline`d struct but ignore potential failure.
37+
err := populateFromYamlEnvironmentPathStep(keyPrefix, reflect.Indirect(cur).Field(fieldNo), path, pathNo, value)
38+
if err == nil {
39+
return nil
40+
} else if !errors.Is(err, notFoundErr) {
41+
return err
42+
}
43+
}
44+
45+
if strings.ToUpper(fieldName) != path[pathNo] {
46+
continue
47+
}
48+
49+
if pathNo < len(path)-1 {
50+
return populateFromYamlEnvironmentPathStep(keyPrefix, reflect.Indirect(cur).Field(fieldNo), path, pathNo+1, value)
51+
}
52+
53+
field := cur.Field(fieldNo)
54+
tmp := reflect.New(field.Type()).Interface()
55+
err := yaml.NewDecoder(strings.NewReader(value)).Decode(tmp)
56+
if err != nil {
57+
return fmt.Errorf("cannot unmarshal into %q: %w", subKey, err)
58+
}
59+
field.Set(reflect.ValueOf(tmp).Elem())
60+
return nil
61+
}
62+
63+
return fmt.Errorf("%w %q", notFoundErr, subKey)
64+
}
65+
66+
// PopulateFromYamlEnvironment populates a struct with "yaml" struct tags based on environment variables.
67+
//
68+
// To write into targetElem, it must be passed as a pointer reference.
69+
//
70+
// Environment variables of the form ${KEY_PREFIX}_${KEY_0}_${KEY_i}_${KEY_n}=${VALUE} will be translated to a YAML path
71+
// from the struct field with the "yaml" struct tag ${KEY_0} across all further nested fields ${KEY_i} up to the last
72+
// ${KEY_n}. The ${VALUE} will be YAML decoded into the referenced field of the targetElem.
73+
//
74+
// Next to addressing fields through keys, elementary `,inline` flags are also being supported. This allows referring an
75+
// inline struct's field as it would be a field of the parent.
76+
//
77+
// Consider the following struct:
78+
//
79+
// type Example struct {
80+
// Outer struct {
81+
// Inner int `yaml:"inner"`
82+
// } `yaml:"outer"`
83+
// }
84+
//
85+
// The Inner field can get populated through:
86+
//
87+
// PopulateFromYamlEnvironment("EXAMPLE", &example, []string{"EXAMPLE_OUTER_INNER=23"})
88+
func PopulateFromYamlEnvironment(keyPrefix string, targetElem any, environ []string) error {
89+
matcher, err := regexp.Compile(`(?s)\A` + keyPrefix + `_([A-Z0-9_-]+)=(.*)\z`)
90+
if err != nil {
91+
return err
92+
}
93+
94+
if reflect.ValueOf(targetElem).Type().Kind() != reflect.Ptr {
95+
return errors.New("targetElem is required to be a pointer")
96+
}
97+
98+
for _, env := range environ {
99+
match := matcher.FindStringSubmatch(env)
100+
if match == nil {
101+
continue
102+
}
103+
104+
path := strings.Split(match[1], "_")
105+
parent := reflect.Indirect(reflect.ValueOf(targetElem))
106+
107+
err := populateFromYamlEnvironmentPathStep(keyPrefix, parent, path, 0, match[2])
108+
if err != nil {
109+
return err
110+
}
111+
}
112+
113+
return nil
114+
}
115+
116+
// ConfigFile used from the icinga-notifications-daemon.
117+
//
118+
// The ConfigFile will be populated from different sources in the following order, when calling the LoadConfig method:
119+
// 1. Default values (default struct tags) are getting assigned from all nested types.
120+
// 2. Values are getting overridden from the YAML configuration file.
121+
// 3. Values are getting overridden by environment variables of the form ICINGA_NOTIFICATIONS_${KEY}.
122+
//
123+
// The environment variable key is an underscore separated string of uppercase struct fields. For example
124+
// - ICINGA_NOTIFICATIONS_DEBUG-PASSWORD sets ConfigFile.DebugPassword and
125+
// - ICINGA_NOTIFICATIONS_DATABASE_HOST sets ConfigFile.Database.Host.
11126
type ConfigFile struct {
12127
Listen string `yaml:"listen" default:"localhost:5680"`
13128
DebugPassword string `yaml:"debug-password"`
@@ -20,13 +135,29 @@ type ConfigFile struct {
20135
// config holds the configuration state as a singleton. It is used from LoadConfig and Config
21136
var config *ConfigFile
22137

23-
// LoadConfig loads the daemon config from given path. Call it only once when starting the daemon.
24-
func LoadConfig(path string) error {
138+
// LoadConfig loads the daemon configuration from environment variables, YAML configuration, and defaults.
139+
//
140+
// After loading, some validations will be performed. This function MUST be called only once when starting the daemon.
141+
func LoadConfig(cfgPath string) error {
25142
if config != nil {
26143
return errors.New("config already set")
27144
}
28145

29-
cfg, err := fromFile(path)
146+
var cfgReader io.ReadCloser
147+
if cfgPath != "" {
148+
var err error
149+
if cfgReader, err = os.Open(cfgPath); err != nil {
150+
return err
151+
}
152+
defer func() { _ = cfgReader.Close() }()
153+
}
154+
155+
cfg, err := loadConfig(cfgReader, os.Environ())
156+
if err != nil {
157+
return err
158+
}
159+
160+
err = cfg.Validate()
30161
if err != nil {
31162
return err
32163
}
@@ -41,32 +172,31 @@ func Config() *ConfigFile {
41172
return config
42173
}
43174

44-
func fromFile(path string) (*ConfigFile, error) {
45-
f, err := os.Open(path)
46-
if err != nil {
47-
return nil, err
48-
}
49-
defer func() { _ = f.Close() }()
50-
175+
// loadConfig loads the daemon configuration from environment variables, YAML configuration, and defaults.
176+
func loadConfig(yamlCfg io.Reader, environ []string) (*ConfigFile, error) {
51177
var c ConfigFile
52178

53179
if err := defaults.Set(&c); err != nil {
54180
return nil, err
55181
}
56182

57-
d := yaml.NewDecoder(f)
58-
if err := d.Decode(&c); err != nil {
183+
err := yaml.NewDecoder(yamlCfg).Decode(&c)
184+
if err != nil && err != io.EOF {
59185
return nil, err
60186
}
61187

62-
if err := c.Validate(); err != nil {
188+
err = PopulateFromYamlEnvironment("ICINGA_NOTIFICATIONS", &c, environ)
189+
if err != nil {
63190
return nil, err
64191
}
65-
66192
return &c, nil
67193
}
68194

195+
// Validate the ConfigFile and return an error if a check failed.
69196
func (c *ConfigFile) Validate() error {
197+
if c.Icingaweb2URL == "" {
198+
return fmt.Errorf("Icingaweb2URL field MUST be populated")
199+
}
70200
if err := c.Database.Validate(); err != nil {
71201
return err
72202
}

0 commit comments

Comments
 (0)