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

Co-authored-by: James Read <contact@jread.com>
This commit is contained in:
David Q 2025-04-05 15:36:10 -07:00 committed by GitHub
parent c3097e40db
commit 182548e0dc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 135 additions and 6 deletions

View File

@ -16,6 +16,7 @@ require (
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.4.1 github.com/gorilla/websocket v1.4.1
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 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/prometheus/client_golang v1.20.2
github.com/robfig/cron/v3 v3.0.1 github.com/robfig/cron/v3 v3.0.1
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
@ -91,7 +92,6 @@ require (
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/go-homedir v1.1.0 // 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/docker-image-spec v1.3.1 // indirect
github.com/moby/locker v1.0.1 // indirect github.com/moby/locker v1.0.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect

View File

@ -1,14 +1,15 @@
package config package config
import ( import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"os" "os"
"path/filepath" "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" log "github.com/sirupsen/logrus"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@ -31,7 +32,7 @@ func AddListener(l func()) {
} }
func Reload(cfg *Config) { 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) log.Errorf("Config unmarshal error %+v", err)
os.Exit(1) os.Exit(1)
} }
@ -46,3 +47,27 @@ func Reload(cfg *Config) {
l() 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
}

View File

@ -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")
}
}