feat: add syntax for interpolating environment variables into config file (#548)
Co-authored-by: James Read <contact@jread.com>
This commit is contained in:
parent
c3097e40db
commit
182548e0dc
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue