Skip to content

Commit 08394e6

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 08394e6

File tree

4 files changed

+489
-17
lines changed

4 files changed

+489
-17
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: 118 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,30 @@
11
package daemon
22

33
import (
4+
"bytes"
45
"errors"
6+
"fmt"
57
"github.com/creasty/defaults"
68
"github.com/goccy/go-yaml"
79
icingadbConfig "github.com/icinga/icingadb/pkg/config"
10+
"io"
811
"os"
12+
"reflect"
13+
"regexp"
14+
"strconv"
15+
"strings"
916
)
1017

18+
// ConfigFile used from the icinga-notifications-daemon.
19+
//
20+
// The ConfigFile will be populated from different sources in the following order, when calling the LoadConfig method:
21+
// 1. Default values (default struct tags) are getting assigned from all nested types.
22+
// 2. Values are getting overridden from the YAML configuration file.
23+
// 3. Values are getting overridden by environment variables of the form ICINGA_NOTIFICATIONS_${KEY}.
24+
//
25+
// The environment variable key is an underscore separated string of uppercase struct fields. For example
26+
// - ICINGA_NOTIFICATIONS_LISTEN sets ConfigFile.Listen and
27+
// - ICINGA_NOTIFICATIONS_DATABASE_HOST sets ConfigFile.Database.Host.
1128
type ConfigFile struct {
1229
Listen string `yaml:"listen" default:"localhost:5680"`
1330
DebugPassword string `yaml:"debug-password"`
@@ -20,13 +37,29 @@ type ConfigFile struct {
2037
// config holds the configuration state as a singleton. It is used from LoadConfig and Config
2138
var config *ConfigFile
2239

23-
// LoadConfig loads the daemon config from given path. Call it only once when starting the daemon.
24-
func LoadConfig(path string) error {
40+
// LoadConfig loads the daemon configuration from environment variables, YAML configuration, and defaults.
41+
//
42+
// After loading, some validations will be performed. This function MUST be called only once when starting the daemon.
43+
func LoadConfig(cfgPath string) error {
2544
if config != nil {
2645
return errors.New("config already set")
2746
}
2847

29-
cfg, err := fromFile(path)
48+
var cfgReader io.ReadCloser
49+
if cfgPath != "" {
50+
var err error
51+
if cfgReader, err = os.Open(cfgPath); err != nil {
52+
return err
53+
}
54+
defer func() { _ = cfgReader.Close() }()
55+
}
56+
57+
cfg, err := loadConfig(cfgReader, os.Environ())
58+
if err != nil {
59+
return err
60+
}
61+
62+
err = cfg.Validate()
3063
if err != nil {
3164
return err
3265
}
@@ -41,32 +74,105 @@ func Config() *ConfigFile {
4174
return config
4275
}
4376

44-
func fromFile(path string) (*ConfigFile, error) {
45-
f, err := os.Open(path)
46-
if err != nil {
47-
return nil, err
77+
// yamlConfigFileFromEnvironmentEnvMatch is a regular expression to match ICINGA_NOTIFICATIONS_KEY_SUBKEY_SUBSUBKEY...=VAL pairs.
78+
var yamlConfigFileFromEnvironmentEnvMatch = regexp.MustCompile(`(?s)\AICINGA_NOTIFICATIONS_([A-Z0-9_]+)=(.*)\z`)
79+
80+
// yamlConfigFileFromEnvironment creates a ConfigFile compatible YAML representation from environment variables.
81+
//
82+
// Environment variables of the form ICINGA_NOTIFICATIONS_${KEY}=${VALUE} will be translated into an ephemeral
83+
// multidimensional map (key_1 -> ... -> key_n -> value) with keys based on the "yaml" struct tag. Eventually, the map
84+
// will be encoded as YAML, matching ConfigFile's YAML format.
85+
func yamlConfigFileFromEnvironment(environ []string) (io.Reader, error) {
86+
cfg := make(map[string]any)
87+
for _, env := range environ {
88+
match := yamlConfigFileFromEnvironmentEnvMatch.FindStringSubmatch(env)
89+
if match == nil {
90+
continue
91+
}
92+
93+
path := strings.Split(match[1], "_")
94+
trace := make([]string, 0, len(path)-1)
95+
root := reflect.Indirect(reflect.ValueOf(&ConfigFile{}))
96+
97+
pathLoop:
98+
for pathNo := 0; pathNo < len(path); pathNo++ {
99+
subKey := "ICINGA_NOTIFICATIONS_" + strings.Join(path[:pathNo+1], "_")
100+
t := root.Type()
101+
for fieldNo := 0; fieldNo < t.NumField(); fieldNo++ {
102+
if strings.ToUpper(t.Field(fieldNo).Name) != path[pathNo] {
103+
continue
104+
}
105+
106+
fieldName := t.Field(fieldNo).Tag.Get("yaml")
107+
if fieldName == "" {
108+
return nil, fmt.Errorf("field %q misses yaml struct tag", subKey)
109+
}
110+
111+
if pathNo == len(path)-1 {
112+
// Find and/or create leaf node in nested multidimensional config map.
113+
partCfg := cfg
114+
for _, part := range trace {
115+
tmpPartCfg, ok := partCfg[part]
116+
if !ok {
117+
tmpPartCfg = make(map[string]any)
118+
partCfg[part] = tmpPartCfg
119+
}
120+
partCfg = tmpPartCfg.(map[string]any)
121+
}
122+
123+
// Encode numeric values as integers as, otherwise, YAML cannot decode those as integers back.
124+
if i, err := strconv.ParseInt(match[2], 10, 64); err == nil {
125+
partCfg[fieldName] = i
126+
} else {
127+
partCfg[fieldName] = match[2]
128+
}
129+
} else {
130+
trace = append(trace, fieldName)
131+
root = reflect.Indirect(root).Field(fieldNo)
132+
}
133+
continue pathLoop
134+
}
135+
return nil, fmt.Errorf("cannot resolve field %q", subKey)
136+
}
48137
}
49-
defer func() { _ = f.Close() }()
50138

139+
var buff bytes.Buffer
140+
err := yaml.NewEncoder(&buff).Encode(cfg)
141+
return &buff, err
142+
}
143+
144+
// loadConfig loads the daemon configuration from environment variables, YAML configuration, and defaults.
145+
func loadConfig(yamlFile io.Reader, environ []string) (*ConfigFile, error) {
51146
var c ConfigFile
52147

53148
if err := defaults.Set(&c); err != nil {
54149
return nil, err
55150
}
56151

57-
d := yaml.NewDecoder(f)
58-
if err := d.Decode(&c); err != nil {
152+
yamlEnvConf, err := yamlConfigFileFromEnvironment(environ)
153+
if err != nil {
59154
return nil, err
60155
}
61156

62-
if err := c.Validate(); err != nil {
63-
return nil, err
157+
for _, yamlConf := range []io.Reader{yamlFile, yamlEnvConf} {
158+
if yamlConf == nil {
159+
continue
160+
}
161+
162+
d := yaml.NewDecoder(yamlConf)
163+
if err := d.Decode(&c); err != nil && !errors.Is(err, io.EOF) {
164+
return nil, err
165+
}
64166
}
65167

66168
return &c, nil
67169
}
68170

171+
// Validate the ConfigFile and return an error if a check failed.
69172
func (c *ConfigFile) Validate() error {
173+
if c.Icingaweb2URL == "" {
174+
return fmt.Errorf("Icingaweb2URL field MUST be populated")
175+
}
70176
if err := c.Database.Validate(); err != nil {
71177
return err
72178
}

0 commit comments

Comments
 (0)