diff --git a/config/config.go b/config/config.go index 12bfa819d7..51b4265809 100644 --- a/config/config.go +++ b/config/config.go @@ -19,6 +19,7 @@ import ( "io/ioutil" "net" "net/url" + "os" "path/filepath" "regexp" "sort" @@ -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 } diff --git a/config/expand_env.go b/config/expand_env.go new file mode 100644 index 0000000000..576e3750bd --- /dev/null +++ b/config/expand_env.go @@ -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 + } +} diff --git a/config/expand_env_test.go b/config/expand_env_test.go new file mode 100644 index 0000000000..3b522c9e0e --- /dev/null +++ b/config/expand_env_test.go @@ -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") + } +} diff --git a/config/testdata/conf.env-variables.yml b/config/testdata/conf.env-variables.yml new file mode 100644 index 0000000000..9d8841494b --- /dev/null +++ b/config/testdata/conf.env-variables.yml @@ -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'