Skip to content

Commit

Permalink
Suport resolving environment variables in configuration
Browse files Browse the repository at this point in the history
Super simple solution. I want to get some input on it
Based on Kubernetes' resolution for container args: https://kubernetes.io/docs/tasks/inject-data-application/define-command-argument-container/#use-environment-variables-to-define-arguments

Signed-off-by: Julien Duchesne <julien.duchesne@grafana.com>
  • Loading branch information
julienduchesne committed Apr 1, 2021
1 parent bc7b16d commit 0dea6a9
Show file tree
Hide file tree
Showing 4 changed files with 224 additions and 1 deletion.
14 changes: 13 additions & 1 deletion config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import (
"io/ioutil"
"net"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
Expand Down Expand Up @@ -196,7 +197,18 @@ func LoadFile(filename string) (*Config, error) {
if err != nil {
return nil, err
}
cfg, err := Load(string(content))

configString := string(content)

// If enabled, alermanager will parse environment variable in its config
// Based on the container command/args $(ENV_VARIABLE) syntax
// https://github.com/kubernetes/kubernetes/blob/ea0764452222146c47ec826977f49d7001b0ea8c/pkg/kubelet/container/helpers.go#L163
if os.Getenv("RESOLVE_ENV_IN_CONFIG") == "true" {
mapping := MappingFuncFor(envVarsToMap())
configString = Expand(configString, mapping)
}

cfg, err := Load(configString)
if err != nil {
return nil, err
}
Expand Down
115 changes: 115 additions & 0 deletions config/expand_env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package config

import (
"bytes"
"os"
"strings"
)

const (
operator = '$'
referenceOpener = '('
referenceCloser = ')'
)

// syntaxWrap returns the input string wrapped by the expansion syntax.
func syntaxWrap(input string) string {
return string(operator) + string(referenceOpener) + input + string(referenceCloser)
}

// envVarsToMap constructs a map of environment name to value
func envVarsToMap() map[string]string {
result := map[string]string{}
for _, env := range os.Environ() {
split := strings.SplitN(env, "=", 2)
result[split[0]] = split[1]
}
return result
}

// MappingFuncFor returns a mapping function for use with Expand that
// implements the expansion semantics defined in the expansion spec; it
// returns the input string wrapped in the expansion syntax if no mapping
// for the input is found.
func MappingFuncFor(context ...map[string]string) func(string) string {
return func(input string) string {
for _, vars := range context {
val, ok := vars[input]
if ok {
return val
}
}

return syntaxWrap(input)
}
}

// Expand replaces variable references in the input string according to
// the expansion spec using the given mapping function to resolve the
// values of variables.
func Expand(input string, mapping func(string) string) string {
os.Environ()
var buf bytes.Buffer
checkpoint := 0
for cursor := 0; cursor < len(input); cursor++ {
if input[cursor] == operator && cursor+1 < len(input) {
// Copy the portion of the input string since the last
// checkpoint into the buffer
buf.WriteString(input[checkpoint:cursor])

// Attempt to read the variable name as defined by the
// syntax from the input string
read, isVar, advance := tryReadVariableName(input[cursor+1:])

if isVar {
// We were able to read a variable name correctly;
// apply the mapping to the variable name and copy the
// bytes into the buffer
buf.WriteString(mapping(read))
} else {
// Not a variable name; copy the read bytes into the buffer
buf.WriteString(read)
}

// Advance the cursor in the input string to account for
// bytes consumed to read the variable name expression
cursor += advance

// Advance the checkpoint in the input string
checkpoint = cursor + 1
}
}

// Return the buffer and any remaining unwritten bytes in the
// input string.
return buf.String() + input[checkpoint:]
}

// tryReadVariableName attempts to read a variable name from the input
// string and returns the content read from the input, whether that content
// represents a variable name to perform mapping on, and the number of bytes
// consumed in the input string.
//
// The input string is assumed not to contain the initial operator.
func tryReadVariableName(input string) (string, bool, int) {
switch input[0] {
case operator:
// Escaped operator; return it.
return input[0:1], false, 1
case referenceOpener:
// Scan to expression closer
for i := 1; i < len(input); i++ {
if input[i] == referenceCloser {
return input[1:i], true, i + 1
}
}

// Incomplete reference; return it.
return string(operator) + string(referenceOpener), false, 1
default:
// Not the beginning of an expression, ie, an operator
// that doesn't begin an expression. Return the operator
// and the first rune in the string.
return (string(operator) + string(input[0])), false, 1
}
}
70 changes: 70 additions & 0 deletions config/expand_env_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package config

import (
"os"
"testing"
)

func TestOnlyResolveEnvironmentConfigIfEnabled(t *testing.T) {
// Setting one env variable that will be ignored since RESOLVE_ENV_IN_CONFIG won't be set
os.Setenv("USERNAME", "any")
defer os.Unsetenv("USERNAME")

config, err := LoadFile("testdata/conf.env-variables.yml")
if err != nil {
t.Errorf("Error parsing %s: %s", "testdata/conf.good-env-variables.yml", err)
}

if config.Global.SMTPAuthUsername != "$(USERNAME)" {
t.Error(`An environment variable (smtp_auth_username: '$(USERNAME)') was resolved without having resolution enabled`)
}
}

func TestWontFailOnMissingEnvironmentVariables(t *testing.T) {
// Setting the resolve flag: RESOLVE_ENV_IN_CONFIG but no other env variables so nothing will be subsituted
os.Setenv("RESOLVE_ENV_IN_CONFIG", "true")
defer os.Unsetenv("RESOLVE_ENV_IN_CONFIG")

config, err := LoadFile("testdata/conf.env-variables.yml")
if err != nil {
t.Errorf("Error parsing %s: %s", "testdata/conf.good-env-variables.yml", err)
}

if config.Global.SMTPAuthUsername != "$(USERNAME)" {
t.Error(`An environment variable (smtp_auth_username: '$(USERNAME)') was resolved without having resolution enabled`)
}
}

func TestResolveEnvironmentVariables(t *testing.T) {
for env, value := range map[string]string{
"RESOLVE_ENV_IN_CONFIG": "true",
"EXAMPLE": "example",
"USERNAME": "username",
"PASSWORD": "password",
"RECEIVER_NAME": "my_receiver",
} {
os.Setenv(env, value)
defer os.Unsetenv(env)
}

config, err := LoadFile("testdata/conf.env-variables.yml")
if err != nil {
t.Errorf("Error parsing %s: %s", "testdata/conf.good-env-variables.yml", err)
}

if config.Receivers[0].Name != "my_receiver" {
t.Error("$(RECEIVER_NAME) was not resolved")
}

if config.Global.SMTPFrom != "alertmanager@example.org" {
t.Error("$(EXAMPLE) was not resolved")
}

if config.Global.SMTPAuthUsername != "username" {
t.Error("$(USERNAME) was not resolved")
}

if config.Global.SMTPAuthPassword != "password" {
t.Error("$(PASSWORD) was not resolved")
}
}
26 changes: 26 additions & 0 deletions config/testdata/conf.env-variables.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
global:
smtp_smarthost: 'localhost:25'
smtp_from: 'alertmanager@$(EXAMPLE).org'
smtp_auth_username: '$(USERNAME)'
smtp_auth_password: '$(PASSWORD)'
smtp_hello: ''
slack_api_url: 'https://slack.com/webhook'



templates:
- '/etc/alertmanager/template/*.tmpl'

route:
group_by: ['alertname', 'cluster', 'service']

receiver: $(RECEIVER_NAME)
routes:
- match_re:
service: ^(foo1|foo2|baz)$
receiver: $(RECEIVER_NAME)

receivers:
- name: '$(RECEIVER_NAME)'
email_configs:
- to: 'team-X+alerts@$(EXAMPLE).org'

0 comments on commit 0dea6a9

Please sign in to comment.