11package daemon
22
33import (
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.
1128type 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
2138var 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.
69172func (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