From 182548e0dcb51eba96803c07df024d06dccb00d2 Mon Sep 17 00:00:00 2001 From: David Q <105565781+davidqnz@users.noreply.github.com> Date: Sat, 5 Apr 2025 15:36:10 -0700 Subject: [PATCH] feat: add syntax for interpolating environment variables into config file (#548) Co-authored-by: James Read --- service/go.mod | 2 +- service/internal/config/config_reloader.go | 35 +++++- .../internal/config/config_reloader_test.go | 104 ++++++++++++++++++ 3 files changed, 135 insertions(+), 6 deletions(-) create mode 100644 service/internal/config/config_reloader_test.go diff --git a/service/go.mod b/service/go.mod index bc4b8bb..fde74f7 100644 --- a/service/go.mod +++ b/service/go.mod @@ -16,6 +16,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.4.1 github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 + github.com/mitchellh/mapstructure v1.5.0 github.com/prometheus/client_golang v1.20.2 github.com/robfig/cron/v3 v3.0.1 github.com/sirupsen/logrus v1.9.3 @@ -91,7 +92,6 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect - github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/locker v1.0.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect diff --git a/service/internal/config/config_reloader.go b/service/internal/config/config_reloader.go index 0a34add..cf6da47 100644 --- a/service/internal/config/config_reloader.go +++ b/service/internal/config/config_reloader.go @@ -1,14 +1,15 @@ package config import ( - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promauto" - "os" "path/filepath" + "reflect" + "regexp" + "github.com/mitchellh/mapstructure" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" log "github.com/sirupsen/logrus" - "github.com/spf13/viper" ) @@ -31,7 +32,7 @@ func AddListener(l func()) { } func Reload(cfg *Config) { - if err := viper.UnmarshalExact(&cfg); err != nil { + if err := viper.UnmarshalExact(&cfg, configureDecoder); err != nil { log.Errorf("Config unmarshal error %+v", err) os.Exit(1) } @@ -46,3 +47,27 @@ func Reload(cfg *Config) { l() } } + +func configureDecoder(config *mapstructure.DecoderConfig) { + config.DecodeHook = mapstructure.ComposeDecodeHookFunc(envDecodeHookFunc, config.DecodeHook) + +} + +var envRegex = regexp.MustCompile(`\${{ *?(\S+) *?}}`) + +func envDecodeHookFunc(from reflect.Value, to reflect.Value) (any, error) { + if from.Kind() != reflect.String { + return from.Interface(), nil + } + input := from.Interface().(string) + output := envRegex.ReplaceAllStringFunc(input, func(match string) string { + submatches := envRegex.FindStringSubmatch(match) + key := submatches[1] + val, set := os.LookupEnv(key) + if !set { + log.Warnf("Config file references unset environment variable: \"%s\"", key) + } + return val + }) + return output, nil +} diff --git a/service/internal/config/config_reloader_test.go b/service/internal/config/config_reloader_test.go new file mode 100644 index 0000000..7e7a669 --- /dev/null +++ b/service/internal/config/config_reloader_test.go @@ -0,0 +1,104 @@ +package config + +import ( + "os" + "strings" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" +) + +var stringEnvConfigYaml = ` +pageTitle: ${{ INPUT }} +` + +var stringEnvInterpolationConfigYaml = ` +pageTitle: Olivetin - ${{ INPUT }} +` + +var boolEnvConfigYaml = ` +checkForUpdates: ${{ INPUT }} +` + +var numericEnvConfigYaml = ` +logHistoryPageSize: ${{ INPUT }} +` + +var argsSyntaxConfigYaml = ` +actions: + - title: Ping host + id: ping_host + shell: ping {{ host }} -c ${{ INPUT }} + icon: ping + timeout: 100 + popupOnStart: execution-dialog-stdout-only + arguments: + - name: host + title: Host + type: ascii_identifier + default: example.com + description: The host that you want to ping +` + +func pageTitleSelector(cfg *Config) any { + return cfg.PageTitle +} + +func checkForUpdatesSelector(cfg *Config) any { + return cfg.CheckForUpdates +} + +func logHistoryPageSizeSelector(cfg *Config) any { + return cfg.LogHistoryPageSize +} + +var envConfigTests = []struct { + yaml string + input string + output any + selector func(*Config) any +}{ + // Test that it works for string type config fields, both standalone and as part of a larger string value. + {stringEnvConfigYaml, "A Nice Title", "A Nice Title", pageTitleSelector}, + {stringEnvInterpolationConfigYaml, "A Nice Title", "Olivetin - A Nice Title", pageTitleSelector}, + // Test that unset variables turn into empty strings. + {stringEnvConfigYaml, "", "", pageTitleSelector}, + // Test that it works for bool type config fields for intuitive bool->string conversions. + {boolEnvConfigYaml, "FALSE", false, checkForUpdatesSelector}, + {boolEnvConfigYaml, "false", false, checkForUpdatesSelector}, + {boolEnvConfigYaml, "False", false, checkForUpdatesSelector}, + {boolEnvConfigYaml, "TRUE", true, checkForUpdatesSelector}, + {boolEnvConfigYaml, "true", true, checkForUpdatesSelector}, + {boolEnvConfigYaml, "True", true, checkForUpdatesSelector}, + {boolEnvConfigYaml, "0", false, checkForUpdatesSelector}, + {boolEnvConfigYaml, "1", true, checkForUpdatesSelector}, + // Test that unset variables turn into false bools. + {boolEnvConfigYaml, "", false, checkForUpdatesSelector}, + // Test that it works for numeric type config fields. + {numericEnvConfigYaml, "2048", int64(2048), logHistoryPageSizeSelector}, + // Test that unset variables turn into zero numbers. + {numericEnvConfigYaml, "", int64(0), logHistoryPageSizeSelector}, + // Test that it doesn't interfere with similar arguments + {argsSyntaxConfigYaml, "5", "ping {{ host }} -c 5", func(cfg *Config) any { return cfg.Actions[0].Shell }}, +} + +func TestEnvInConfig(t *testing.T) { + viper.SetConfigType("yaml") + + for _, tt := range envConfigTests { + err := viper.ReadConfig(strings.NewReader(tt.yaml)) + assert.Nil(t, err, "Viper read config file with environment variable syntax") + + if tt.input != "" { + os.Setenv("INPUT", tt.input) + } + + cfg := DefaultConfig() + Reload(cfg) + field := tt.selector(cfg) + assert.Equal(t, tt.output, field, "Unmarshaled config field doesn't match expected value: env=\"%s\"", tt.input) + + os.Unsetenv("INPUT") + } +}