@@ -2,12 +2,127 @@ package daemon
22
33import (
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.
11126type 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
21136var 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.
69196func (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