Skip to content

Commit cf9d730

Browse files
authored
Merge pull request #87 from Icinga/load-config-from-yaml-and-env
Support loading configuration from both YAML files and env vars
2 parents 3271652 + 37ce192 commit cf9d730

File tree

3 files changed

+331
-56
lines changed

3 files changed

+331
-56
lines changed

config/config.go

Lines changed: 175 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,8 @@
11
// Package config provides utilities for configuration parsing and loading.
2-
// It includes functionality for handling command-line flags and loading configuration from YAML files,
2+
// It includes functionality for handling command-line flags and
3+
// loading configuration from YAML files and environment variables,
34
// with additional support for setting default values and validation.
45
// Additionally, it provides a struct that defines common settings for a TLS client.
5-
//
6-
// Example usage:
7-
//
8-
// type Config struct {
9-
// ServerAddress string `yaml:"server_address" default:"localhost:8080"`
10-
// TLS config.TLS `yaml:",inline"`
11-
// }
12-
//
13-
// // Validate implements the Validator interface.
14-
// func (c *Config) Validate() error {
15-
// if _, _, err := net.SplitHostPort(c.ServerAddress); err != nil {
16-
// return errors.Wrapf(err, "invalid server address: %s", c.ServerAddress)
17-
// }
18-
//
19-
// return nil
20-
// }
21-
//
22-
// type Flags struct {
23-
// Config string `short:"c" long:"config" description:"Path to config file" required:"true"`
24-
// }
25-
//
26-
// func main() {
27-
// var flags Flags
28-
// if err := config.ParseFlags(&flags); err != nil {
29-
// log.Fatalf("error parsing flags: %v", err)
30-
// }
31-
//
32-
// var cfg Config
33-
// if err := config.FromYAMLFile(flags.Config, &cfg); err != nil {
34-
// log.Fatalf("error loading config: %v", err)
35-
// }
36-
//
37-
// tlsCfg, err := cfg.TLS.MakeConfig("icinga.com")
38-
// if err != nil {
39-
// log.Fatalf("error creating TLS config: %v", err)
40-
// }
41-
//
42-
// // ...
43-
// }
446
package config
457

468
import (
@@ -51,16 +13,17 @@ import (
5113
"github.com/goccy/go-yaml"
5214
"github.com/jessevdk/go-flags"
5315
"github.com/pkg/errors"
16+
"io/fs"
5417
"os"
5518
"reflect"
5619
)
5720

58-
// ErrInvalidArgument is the error returned by [ParseFlags] or [FromYAMLFile] if
59-
// its parsing result cannot be stored in the value pointed to by the designated passed argument which
60-
// must be a non-nil struct pointer.
21+
// ErrInvalidArgument is the error returned by any function that loads configuration if
22+
// the parsing result cannot be stored in the value pointed to by the specified argument,
23+
// which must be a non-nil struct pointer.
6124
var ErrInvalidArgument = stderrors.New("invalid argument")
6225

63-
// ErrInvalidConfiguration is attached to errors returned by [FromYAMLFile] or [FromEnv] when
26+
// ErrInvalidConfiguration is attached to errors returned by any function that loads configuration when
6427
// the configuration is invalid,
6528
// i.e. if the Validate method of the provided [Validator] interface returns an error,
6629
// which is then propagated by these functions.
@@ -71,7 +34,9 @@ var ErrInvalidConfiguration = stderrors.New("invalid configuration")
7134
// FromYAMLFile parses the given YAML file and stores the result
7235
// in the value pointed to by v. If v is nil or not a struct pointer,
7336
// FromYAMLFile returns an [ErrInvalidArgument] error.
37+
//
7438
// It is possible to define default values via the struct tag `default`.
39+
//
7540
// The function also validates the configuration using the Validate method
7641
// of the provided [Validator] interface.
7742
// Any error returned from Validate is propagated with [ErrInvalidConfiguration] attached,
@@ -82,14 +47,18 @@ var ErrInvalidConfiguration = stderrors.New("invalid configuration")
8247
//
8348
// type Config struct {
8449
// ServerAddress string `yaml:"server_address" default:"localhost:8080"`
50+
// TLS config.TLS `yaml:",inline"`
8551
// }
8652
//
87-
// // Validate implements the Validator interface.
8853
// func (c *Config) Validate() error {
8954
// if _, _, err := net.SplitHostPort(c.ServerAddress); err != nil {
9055
// return errors.Wrapf(err, "invalid server address: %s", c.ServerAddress)
9156
// }
9257
//
58+
// if err := c.TLS.Validate(); err != nil {
59+
// return errors.WithStack(err)
60+
// }
61+
//
9362
// return nil
9463
// }
9564
//
@@ -99,6 +68,11 @@ var ErrInvalidConfiguration = stderrors.New("invalid configuration")
9968
// log.Fatalf("error loading config: %v", err)
10069
// }
10170
//
71+
// tlsCfg, err := cfg.TLS.MakeConfig("icinga.com")
72+
// if err != nil {
73+
// log.Fatalf("error creating TLS config: %v", err)
74+
// }
75+
//
10276
// // ...
10377
// }
10478
func FromYAMLFile(name string, v Validator) error {
@@ -136,9 +110,47 @@ type EnvOptions = env.Options
136110

137111
// FromEnv parses environment variables and stores the result in the value pointed to by v.
138112
// If v is nil or not a struct pointer, FromEnv returns an [ErrInvalidArgument] error.
113+
//
114+
// It is possible to define default values via the struct tag `default`.
115+
//
116+
// The function also validates the configuration using the Validate method
117+
// of the provided [Validator] interface.
139118
// Any error returned from Validate is propagated with [ErrInvalidConfiguration] attached,
140119
// allowing errors.Is() checks on the returned errors to recognize both ErrInvalidConfiguration and
141120
// the original errors returned from Validate.
121+
//
122+
// Example usage:
123+
//
124+
// type Config struct {
125+
// ServerAddress string `env:"SERVER_ADDRESS" default:"localhost:8080"`
126+
// TLS config.TLS
127+
// }
128+
//
129+
// func (c *Config) Validate() error {
130+
// if _, _, err := net.SplitHostPort(c.ServerAddress); err != nil {
131+
// return errors.Wrapf(err, "invalid server address: %s", c.ServerAddress)
132+
// }
133+
//
134+
// if err := c.TLS.Validate(); err != nil {
135+
// return errors.WithStack(err)
136+
// }
137+
//
138+
// return nil
139+
// }
140+
//
141+
// func main() {
142+
// var cfg Config
143+
// if err := config.FromEnv(cfg, config.EnvOptions{}); err != nil {
144+
// log.Fatalf("error loading config: %v", err)
145+
// }
146+
//
147+
// tlsCfg, err := cfg.TLS.MakeConfig("icinga.com")
148+
// if err != nil {
149+
// log.Fatalf("error creating TLS config: %v", err)
150+
// }
151+
//
152+
// // ...
153+
// }
142154
func FromEnv(v Validator, options EnvOptions) error {
143155
if err := validateNonNilStructPointer(v); err != nil {
144156
return errors.WithStack(err)
@@ -159,13 +171,131 @@ func FromEnv(v Validator, options EnvOptions) error {
159171
return nil
160172
}
161173

174+
// LoadOptions contains options for loading configuration from both files and environment variables.
175+
type LoadOptions struct {
176+
// Flags provides access to specific command line flag values.
177+
Flags Flags
178+
179+
// EnvOptions contains options for loading configuration from environment variables.
180+
EnvOptions EnvOptions
181+
}
182+
183+
// Load loads configuration from both YAML files and environment variables and
184+
// stores the result in the value pointed to by v.
185+
// If v is nil or not a struct pointer,
186+
// Load returns an [ErrInvalidArgument] error.
187+
//
188+
// It is possible to define default values via the struct tag `default`.
189+
//
190+
// The function also validates the configuration using the Validate method
191+
// of the provided [Validator] interface.
192+
// Any error returned from Validate is propagated with [ErrInvalidConfiguration] attached,
193+
// allowing errors.Is() checks on the returned errors to recognize both ErrInvalidConfiguration and
194+
// the original errors returned from Validate.
195+
//
196+
// This function handles configuration loading in three scenarios:
197+
//
198+
// 1. Load configuration exclusively from YAML files when no applicable environment variables are set.
199+
// 2. Combine YAML file and environment variable configurations, allowing environment variables to
200+
// supplement or override possible incomplete YAML data.
201+
// 3. Load entirely from environment variables if the default YAML config file is missing and
202+
// no specific config path is provided.
203+
//
204+
// Example usage:
205+
//
206+
// const DefaultConfigPath = "/path/to/config.yml"
207+
//
208+
// type Flags struct {
209+
// Config string `short:"c" long:"config" description:"Path to config file"`
210+
// }
211+
//
212+
// func (f Flags) GetConfigPath() string {
213+
// if f.Config == "" {
214+
// return DefaultConfigPath
215+
// }
216+
//
217+
// return f.Config
218+
// }
219+
//
220+
// func (f Flags) IsExplicitConfigPath() bool {
221+
// return f.Config != ""
222+
// }
223+
//
224+
// type Config struct {
225+
// ServerAddress string `yaml:"server_address" env:"SERVER_ADDRESS" default:"localhost:8080"`
226+
// TLS config.TLS `yaml:",inline"`
227+
// }
228+
//
229+
// func (c *Config) Validate() error {
230+
// if _, _, err := net.SplitHostPort(c.ServerAddress); err != nil {
231+
// return errors.Wrapf(err, "invalid server address: %s", c.ServerAddress)
232+
// }
233+
//
234+
// if err := c.TLS.Validate(); err != nil {
235+
// return errors.WithStack(err)
236+
// }
237+
//
238+
// return nil
239+
// }
240+
//
241+
// func main() {
242+
// var flags Flags
243+
// if err := config.ParseFlags(&flags); err != nil {
244+
// log.Fatalf("error parsing flags: %v", err)
245+
// }
246+
//
247+
// var cfg Config
248+
// if err := config.Load(&cfg, config.LoadOptions{Flags: flags, EnvOptions: config.EnvOptions{}}); err != nil {
249+
// log.Fatalf("error loading config: %v", err)
250+
// }
251+
//
252+
// tlsCfg, err := cfg.TLS.MakeConfig("icinga.com")
253+
// if err != nil {
254+
// log.Fatalf("error creating TLS config: %v", err)
255+
// }
256+
//
257+
// // ...
258+
// }
259+
func Load(v Validator, options LoadOptions) error {
260+
if err := validateNonNilStructPointer(v); err != nil {
261+
return errors.WithStack(err)
262+
}
263+
264+
if err := FromYAMLFile(options.Flags.GetConfigPath(), v); err != nil {
265+
// Allow continuation with FromEnv by handling:
266+
//
267+
// - ErrInvalidConfiguration:
268+
// The configuration may be incomplete and will be revalidated in FromEnv.
269+
//
270+
// - Non-existent file errors:
271+
// If no explicit config path is set, fallback to environment variables is allowed.
272+
configIsInvalid := errors.Is(err, ErrInvalidConfiguration)
273+
configFileDoesNotExist := errors.Is(err, fs.ErrNotExist) && !options.Flags.IsExplicitConfigPath()
274+
if !(configIsInvalid || configFileDoesNotExist) {
275+
return errors.WithStack(err)
276+
}
277+
}
278+
279+
// Call FromEnv regardless of the outcome from FromYAMLFile.
280+
// If no environment variables are set, configuration relies entirely on YAML.
281+
// Otherwise, environment variables can supplement, override YAML settings, or serve as the sole source.
282+
// FromEnv also includes validation, ensuring completeness after considering both sources.
283+
if err := FromEnv(v, options.EnvOptions); err != nil {
284+
return errors.WithStack(err)
285+
}
286+
287+
return nil
288+
}
289+
162290
// ParseFlags parses CLI flags and stores the result
163291
// in the value pointed to by v. If v is nil or not a struct pointer,
164292
// ParseFlags returns an [ErrInvalidArgument] error.
293+
//
165294
// ParseFlags adds a default Help Options group,
166295
// which contains the options -h and --help.
167296
// If either option is specified on the command line,
168297
// ParseFlags prints the help message to [os.Stdout] and exits.
298+
//
169299
// Note that errors are not printed automatically,
170300
// so error handling is the sole responsibility of the caller.
171301
//

0 commit comments

Comments
 (0)