Explorar el Código

feat: add syntax for interpolating environment variables into config file (#548)

Co-authored-by: James Read <contact@jread.com>
David Q hace 1 año
padre
commit
182548e0dc

+ 1 - 1
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

+ 30 - 5
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
+}

+ 104 - 0
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")
+	}
+}