diff --git a/pkg/alertmanager/alertmanager.go b/pkg/alertmanager/alertmanager.go index 39b3cc7d67..9ed7e97f4d 100644 --- a/pkg/alertmanager/alertmanager.go +++ b/pkg/alertmanager/alertmanager.go @@ -209,10 +209,13 @@ func clusterWait(p *cluster.Peer, timeout time.Duration) func() time.Duration { // ApplyConfig applies a new configuration to an Alertmanager. func (am *Alertmanager) ApplyConfig(userID string, conf *config.Config, rawCfg string) error { templateFiles := make([]string, len(conf.Templates)) - if len(conf.Templates) > 0 { - for i, t := range conf.Templates { - templateFiles[i] = filepath.Join(am.cfg.DataDir, "templates", userID, t) + for i, t := range conf.Templates { + templateFilepath, err := safeTemplateFilepath(filepath.Join(am.cfg.DataDir, "templates", userID), t) + if err != nil { + return err } + + templateFiles[i] = templateFilepath } tmpl, err := template.FromGlobs(templateFiles...) diff --git a/pkg/alertmanager/api.go b/pkg/alertmanager/api.go index d2d3f09fa6..2fc82f1576 100644 --- a/pkg/alertmanager/api.go +++ b/pkg/alertmanager/api.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "reflect" "github.com/cortexproject/cortex/pkg/alertmanager/alerts" "github.com/cortexproject/cortex/pkg/tenant" @@ -13,8 +14,10 @@ import ( "github.com/go-kit/kit/log" "github.com/go-kit/kit/log/level" + "github.com/pkg/errors" "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/template" + commoncfg "github.com/prometheus/common/config" "gopkg.in/yaml.v2" ) @@ -27,6 +30,11 @@ const ( errNoOrgID = "unable to determine the OrgID" ) +var ( + errPasswordFileNotAllowed = errors.New("setting password_file, bearer_token_file and credentials_file is not allowed") + errTLSFileNotAllowed = errors.New("setting TLS ca_file, cert_file and key_file is not allowed") +) + // UserConfig is used to communicate a users alertmanager configs type UserConfig struct { TemplateFiles map[string]string `yaml:"template_files"` @@ -146,28 +154,52 @@ func validateUserConfig(logger log.Logger, cfg alerts.AlertConfigDesc) error { return err } + // Validate the config recursively scanning it. + if err := validateAlertmanagerConfig(amCfg); err != nil { + return err + } + + // Validate templates referenced in the alertmanager config. + for _, name := range amCfg.Templates { + if err := validateTemplateFilename(name); err != nil { + return err + } + } + + // Validate template files. + for _, tmpl := range cfg.Templates { + if err := validateTemplateFilename(tmpl.Filename); err != nil { + return err + } + } + // Create templates on disk in a temporary directory. // Note: This means the validation will succeed if we can write to tmp but // not to configured data dir, and on the flipside, it'll fail if we can't write // to tmpDir. Ignoring both cases for now as they're ultra rare but will revisit if // we see this in the wild. - tmpDir, err := ioutil.TempDir("", "validate-config") + userTmpDir, err := ioutil.TempDir("", "validate-config-"+cfg.User) if err != nil { return err } - defer os.RemoveAll(tmpDir) + defer os.RemoveAll(userTmpDir) for _, tmpl := range cfg.Templates { - _, err := createTemplateFile(tmpDir, cfg.User, tmpl.Filename, tmpl.Body) + templateFilepath, err := safeTemplateFilepath(userTmpDir, tmpl.Filename) if err != nil { - level.Error(logger).Log("msg", "unable to create template file", "err", err, "user", cfg.User) - return fmt.Errorf("unable to create template file '%s'", tmpl.Filename) + level.Error(logger).Log("msg", "unable to create template file path", "err", err, "user", cfg.User) + return err + } + + if _, err = storeTemplateFile(templateFilepath, tmpl.Body); err != nil { + level.Error(logger).Log("msg", "unable to store template file", "err", err, "user", cfg.User) + return fmt.Errorf("unable to store template file '%s'", tmpl.Filename) } } templateFiles := make([]string, len(amCfg.Templates)) for i, t := range amCfg.Templates { - templateFiles[i] = filepath.Join(tmpDir, "templates", cfg.User, t) + templateFiles[i] = filepath.Join(userTmpDir, t) } _, err = template.FromGlobs(templateFiles...) @@ -182,3 +214,97 @@ func validateUserConfig(logger log.Logger, cfg alerts.AlertConfigDesc) error { return nil } + +// validateAlertmanagerConfig recursively scans the input config looking for data types for which +// we have a specific validation and, whenever encountered, it runs their validation. Returns the +// first error or nil if validation succeeds. +func validateAlertmanagerConfig(cfg interface{}) error { + v := reflect.ValueOf(cfg) + t := v.Type() + + // Skip invalid, the zero value or a nil pointer (checked by zero value). + if !v.IsValid() || v.IsZero() { + return nil + } + + // If the input config is a pointer then we need to get its value. + // At this point the pointer value can't be nil. + if v.Kind() == reflect.Ptr { + v = v.Elem() + t = v.Type() + } + + // Check if the input config is a data type for which we have a specific validation. + // At this point the value can't be a pointer anymore. + switch t { + case reflect.TypeOf(commoncfg.HTTPClientConfig{}): + return validateReceiverHTTPConfig(v.Interface().(commoncfg.HTTPClientConfig)) + + case reflect.TypeOf(commoncfg.TLSConfig{}): + return validateReceiverTLSConfig(v.Interface().(commoncfg.TLSConfig)) + } + + // If the input config is a struct, recursively iterate on all fields. + if t.Kind() == reflect.Struct { + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + fieldValue := v.FieldByIndex(field.Index) + + // Skip any field value which can't be converted to interface (eg. primitive types). + if fieldValue.CanInterface() { + if err := validateAlertmanagerConfig(fieldValue.Interface()); err != nil { + return err + } + } + } + } + + if t.Kind() == reflect.Slice || t.Kind() == reflect.Array { + for i := 0; i < v.Len(); i++ { + fieldValue := v.Index(i) + + // Skip any field value which can't be converted to interface (eg. primitive types). + if fieldValue.CanInterface() { + if err := validateAlertmanagerConfig(fieldValue.Interface()); err != nil { + return err + } + } + } + } + + if t.Kind() == reflect.Map { + for _, key := range v.MapKeys() { + fieldValue := v.MapIndex(key) + + // Skip any field value which can't be converted to interface (eg. primitive types). + if fieldValue.CanInterface() { + if err := validateAlertmanagerConfig(fieldValue.Interface()); err != nil { + return err + } + } + } + } + + return nil +} + +// validateReceiverHTTPConfig validates the HTTP config and returns an error if it contains +// settings not allowed by Cortex. +func validateReceiverHTTPConfig(cfg commoncfg.HTTPClientConfig) error { + if cfg.BasicAuth != nil && cfg.BasicAuth.PasswordFile != "" { + return errPasswordFileNotAllowed + } + if cfg.BearerTokenFile != "" { + return errPasswordFileNotAllowed + } + return validateReceiverTLSConfig(cfg.TLSConfig) +} + +// validateReceiverTLSConfig validates the TLS config and returns an error if it contains +// settings not allowed by Cortex. +func validateReceiverTLSConfig(cfg commoncfg.TLSConfig) error { + if cfg.CAFile != "" || cfg.CertFile != "" || cfg.KeyFile != "" { + return errTLSFileNotAllowed + } + return nil +} diff --git a/pkg/alertmanager/api_test.go b/pkg/alertmanager/api_test.go index 4fe8df8811..c41366f518 100644 --- a/pkg/alertmanager/api_test.go +++ b/pkg/alertmanager/api_test.go @@ -9,11 +9,15 @@ import ( "net/http/httptest" "testing" - "github.com/cortexproject/cortex/pkg/alertmanager/alerts" - "github.com/cortexproject/cortex/pkg/util" - + "github.com/pkg/errors" + "github.com/prometheus/alertmanager/config" + commoncfg "github.com/prometheus/common/config" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/weaveworks/common/user" + + "github.com/cortexproject/cortex/pkg/alertmanager/alerts" + "github.com/cortexproject/cortex/pkg/util" ) func TestAMConfigValidationAPI(t *testing.T) { @@ -24,7 +28,7 @@ func TestAMConfigValidationAPI(t *testing.T) { err error }{ { - name: "It is not a valid payload without receivers", + name: "Should return error if the alertmanager config contains no receivers", cfg: ` alertmanager_config: | route: @@ -37,7 +41,7 @@ alertmanager_config: | err: fmt.Errorf("error validating Alertmanager config: undefined receiver \"default-receiver\" used in route"), }, { - name: "It is valid", + name: "Should pass if the alertmanager config is valid", cfg: ` alertmanager_config: | route: @@ -51,9 +55,27 @@ alertmanager_config: | `, }, { - name: "It is not valid with paths in the template", + name: "Should return error if the config is empty due to wrong indentation", cfg: ` alertmanager_config: | +route: + receiver: 'default-receiver' + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + group_by: [cluster, alertname] +receivers: + - name: default-receiver +template_files: + "good.tpl": "good-templ" + "not/very/good.tpl": "bad-template" +`, + err: fmt.Errorf("error validating Alertmanager config: configuration provided is empty, if you'd like to remove your configuration please use the delete configuration endpoint"), + }, + { + name: "Should return error if the alertmanager config is empty due to wrong key", + cfg: ` +XWRONGalertmanager_config: | route: receiver: 'default-receiver' group_wait: 30s @@ -64,12 +86,11 @@ alertmanager_config: | - name: default-receiver template_files: "good.tpl": "good-templ" - "not/very/good.tpl": "bad-template" `, - err: fmt.Errorf("error validating Alertmanager config: unable to create template file 'not/very/good.tpl'"), + err: fmt.Errorf("error validating Alertmanager config: configuration provided is empty, if you'd like to remove your configuration please use the delete configuration endpoint"), }, { - name: "It is not valid with .", + name: "Should return error if the external template file name contains an absolute path", cfg: ` alertmanager_config: | route: @@ -81,33 +102,31 @@ alertmanager_config: | receivers: - name: default-receiver template_files: - "good.tpl": "good-templ" - ".": "bad-template" + "/absolute/filepath": "a simple template" `, - err: fmt.Errorf("error validating Alertmanager config: unable to create template file '.'"), + err: fmt.Errorf(`error validating Alertmanager config: invalid template name "/absolute/filepath": the template name cannot contain any path`), }, { - name: "It is not valid if the config is empty due to wrong indendatation", + name: "Should return error if the external template file name contains a relative path", cfg: ` alertmanager_config: | -route: - receiver: 'default-receiver' - group_wait: 30s - group_interval: 5m - repeat_interval: 4h - group_by: [cluster, alertname] -receivers: - - name: default-receiver + route: + receiver: 'default-receiver' + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + group_by: [cluster, alertname] + receivers: + - name: default-receiver template_files: - "good.tpl": "good-templ" - "not/very/good.tpl": "bad-template" + "../filepath": "a simple template" `, - err: fmt.Errorf("error validating Alertmanager config: configuration provided is empty, if you'd like to remove your configuration please use the delete configuration endpoint"), + err: fmt.Errorf(`error validating Alertmanager config: invalid template name "../filepath": the template name cannot contain any path`), }, { - name: "It is not valid if the config is empty due to wrong key", + name: "Should return error if the external template file name is not a valid filename", cfg: ` -XWRONGalertmanager_config: | +alertmanager_config: | route: receiver: 'default-receiver' group_wait: 30s @@ -118,9 +137,176 @@ XWRONGalertmanager_config: | - name: default-receiver template_files: "good.tpl": "good-templ" - "not/very/good.tpl": "bad-template" + ".": "bad-template" `, - err: fmt.Errorf("error validating Alertmanager config: configuration provided is empty, if you'd like to remove your configuration please use the delete configuration endpoint"), + err: fmt.Errorf("error validating Alertmanager config: unable to store template file '.'"), + }, + { + name: "Should return error if the referenced template contains the root /", + cfg: ` +alertmanager_config: | + route: + receiver: 'default-receiver' + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + group_by: [cluster, alertname] + receivers: + - name: default-receiver + templates: + - "/" +`, + err: fmt.Errorf(`error validating Alertmanager config: invalid template name "/": the template name cannot contain any path`), + }, + { + name: "Should return error if the referenced template contains the root with repeated separators ///", + cfg: ` +alertmanager_config: | + route: + receiver: 'default-receiver' + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + group_by: [cluster, alertname] + receivers: + - name: default-receiver + templates: + - "///" +`, + err: fmt.Errorf(`error validating Alertmanager config: invalid template name "///": the template name cannot contain any path`), + }, + { + name: "Should return error if the referenced template contains an absolute path", + cfg: ` +alertmanager_config: | + route: + receiver: 'default-receiver' + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + group_by: [cluster, alertname] + receivers: + - name: default-receiver + templates: + - "/absolute/filepath" +`, + err: fmt.Errorf(`error validating Alertmanager config: invalid template name "/absolute/filepath": the template name cannot contain any path`), + }, + { + name: "Should return error if the referenced template contains a relative path", + cfg: ` +alertmanager_config: | + route: + receiver: 'default-receiver' + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + group_by: [cluster, alertname] + receivers: + - name: default-receiver + templates: + - "../filepath" +`, + err: fmt.Errorf(`error validating Alertmanager config: invalid template name "../filepath": the template name cannot contain any path`), + }, + { + name: "Should pass if the referenced template is valid filename", + cfg: ` +alertmanager_config: | + route: + receiver: 'default-receiver' + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + group_by: [cluster, alertname] + receivers: + - name: default-receiver + templates: + - "something.tmpl" +`, + }, + { + name: "Should return error if global HTTP password_file is set", + cfg: ` +alertmanager_config: | + global: + http_config: + basic_auth: + password_file: /secrets + + route: + receiver: 'default-receiver' + receivers: + - name: default-receiver +`, + err: errors.Wrap(errPasswordFileNotAllowed, "error validating Alertmanager config"), + }, + { + name: "Should return error if global HTTP bearer_token_file is set", + cfg: ` +alertmanager_config: | + global: + http_config: + bearer_token_file: /secrets + + route: + receiver: 'default-receiver' + receivers: + - name: default-receiver +`, + err: errors.Wrap(errPasswordFileNotAllowed, "error validating Alertmanager config"), + }, + { + name: "Should return error if receiver's HTTP password_file is set", + cfg: ` +alertmanager_config: | + receivers: + - name: default-receiver + webhook_configs: + - url: http://localhost + http_config: + basic_auth: + password_file: /secrets + + route: + receiver: 'default-receiver' +`, + err: errors.Wrap(errPasswordFileNotAllowed, "error validating Alertmanager config"), + }, + { + name: "Should return error if receiver's HTTP bearer_token_file is set", + cfg: ` +alertmanager_config: | + receivers: + - name: default-receiver + webhook_configs: + - url: http://localhost + http_config: + bearer_token_file: /secrets + + route: + receiver: 'default-receiver' +`, + err: errors.Wrap(errPasswordFileNotAllowed, "error validating Alertmanager config"), + }, + { + name: "should return error if template is wrong", + cfg: ` +alertmanager_config: | + route: + receiver: 'default-receiver' + group_wait: 30s + group_interval: 5m + repeat_interval: 4h + group_by: [cluster, alertname] + receivers: + - name: default-receiver + templates: + - "*.tmpl" +template_files: + "test.tmpl": "{{ invalid Go template }}" +`, + err: fmt.Errorf(`error validating Alertmanager config: template: test.tmpl:1: function "invalid" not defined`), }, } @@ -146,7 +332,6 @@ template_files: require.Equal(t, http.StatusBadRequest, resp.StatusCode) require.Equal(t, tc.err.Error()+"\n", string(body)) } - }) } } @@ -165,3 +350,103 @@ func (noopAlertStore) SetAlertConfig(ctx context.Context, cfg alerts.AlertConfig func (noopAlertStore) DeleteAlertConfig(ctx context.Context, user string) error { return nil } + +func TestValidateAlertmanagerConfig(t *testing.T) { + tests := map[string]struct { + input interface{} + expected error + }{ + "*HTTPClientConfig": { + input: &commoncfg.HTTPClientConfig{ + BasicAuth: &commoncfg.BasicAuth{ + PasswordFile: "/secrets", + }, + }, + expected: errPasswordFileNotAllowed, + }, + "HTTPClientConfig": { + input: commoncfg.HTTPClientConfig{ + BasicAuth: &commoncfg.BasicAuth{ + PasswordFile: "/secrets", + }, + }, + expected: errPasswordFileNotAllowed, + }, + "*TLSConfig": { + input: &commoncfg.TLSConfig{ + CertFile: "/cert", + }, + expected: errTLSFileNotAllowed, + }, + "TLSConfig": { + input: commoncfg.TLSConfig{ + CertFile: "/cert", + }, + expected: errTLSFileNotAllowed, + }, + "struct containing *HTTPClientConfig as direct child": { + input: config.GlobalConfig{ + HTTPConfig: &commoncfg.HTTPClientConfig{ + BasicAuth: &commoncfg.BasicAuth{ + PasswordFile: "/secrets", + }, + }, + }, + expected: errPasswordFileNotAllowed, + }, + "struct containing *HTTPClientConfig as nested child": { + input: config.Config{ + Global: &config.GlobalConfig{ + HTTPConfig: &commoncfg.HTTPClientConfig{ + BasicAuth: &commoncfg.BasicAuth{ + PasswordFile: "/secrets", + }, + }, + }, + }, + expected: errPasswordFileNotAllowed, + }, + "struct containing *HTTPClientConfig as nested child within a slice": { + input: config.Config{ + Receivers: []*config.Receiver{{ + Name: "test", + WebhookConfigs: []*config.WebhookConfig{{ + HTTPConfig: &commoncfg.HTTPClientConfig{ + BasicAuth: &commoncfg.BasicAuth{ + PasswordFile: "/secrets", + }, + }, + }}}, + }, + }, + expected: errPasswordFileNotAllowed, + }, + "map containing *HTTPClientConfig": { + input: map[string]*commoncfg.HTTPClientConfig{ + "test": { + BasicAuth: &commoncfg.BasicAuth{ + PasswordFile: "/secrets", + }, + }, + }, + expected: errPasswordFileNotAllowed, + }, + "map containing TLSConfig as nested child": { + input: map[string][]config.EmailConfig{ + "test": {{ + TLSConfig: commoncfg.TLSConfig{ + CAFile: "/file", + }, + }}, + }, + expected: errTLSFileNotAllowed, + }, + } + + for testName, testData := range tests { + t.Run(testName, func(t *testing.T) { + err := validateAlertmanagerConfig(testData.input) + assert.True(t, errors.Is(err, testData.expected)) + }) + } +} diff --git a/pkg/alertmanager/multitenant.go b/pkg/alertmanager/multitenant.go index a636122ba1..0c8d1c27ee 100644 --- a/pkg/alertmanager/multitenant.go +++ b/pkg/alertmanager/multitenant.go @@ -11,6 +11,7 @@ import ( "net/url" "os" "path/filepath" + "strings" "sync" "time" @@ -630,7 +631,12 @@ func (am *MultitenantAlertmanager) setConfig(cfg alerts.AlertConfigDesc) error { var hasTemplateChanges bool for _, tmpl := range cfg.Templates { - hasChanged, err := createTemplateFile(am.cfg.DataDir, cfg.User, tmpl.Filename, tmpl.Body) + templateFilepath, err := safeTemplateFilepath(filepath.Join(am.cfg.DataDir, "templates", cfg.User), tmpl.Filename) + if err != nil { + return err + } + + hasChanged, err := storeTemplateFile(templateFilepath, tmpl.Body) if err != nil { return err } @@ -815,25 +821,68 @@ func (s StatusHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { } } -func createTemplateFile(dataDir, userID, fn, content string) (bool, error) { - if fn != filepath.Base(fn) { - return false, fmt.Errorf("template file name '%s' is not not valid", fn) +// validateTemplateFilename validated the template filename and returns error if it's not valid. +// The validation done in this function is a first fence to avoid having a tenant submitting +// a config which may escape the per-tenant data directory on disk. +func validateTemplateFilename(filename string) error { + if filepath.Base(filename) != filename { + return fmt.Errorf("invalid template name %q: the template name cannot contain any path", filename) + } + + // Further enforce no path in the template name. + if filepath.Dir(filepath.Clean(filename)) != "." { + return fmt.Errorf("invalid template name %q: the template name cannot contain any path", filename) } - dir := filepath.Join(dataDir, "templates", userID, filepath.Dir(fn)) + return nil +} + +// safeTemplateFilepath builds and return the template filepath within the provided dir. +// This function also performs a security check to make sure the provided templateName +// doesn't contain a relative path escaping the provided dir. +func safeTemplateFilepath(dir, templateName string) (string, error) { + // We expect all template files to be stored and referenced within the provided directory. + containerDir, err := filepath.Abs(dir) + if err != nil { + return "", err + } + + // Build the actual path of the template. + actualPath, err := filepath.Abs(filepath.Join(containerDir, templateName)) + if err != nil { + return "", err + } + + // Ensure the actual path of the template is within the expected directory. + // This check is a counter-measure to make sure the tenant is not trying to + // escape its own directory on disk. + if !strings.HasPrefix(actualPath, containerDir) { + return "", fmt.Errorf("invalid template name %q: the template filepath is escaping the per-tenant local directory", templateName) + } + + return actualPath, nil +} + +// storeTemplateFile stores template file at the given templateFilepath. +// Returns true, if file content has changed (new or updated file), false if file with the same name +// and content was already stored locally. +func storeTemplateFile(templateFilepath, content string) (bool, error) { + // Make sure the directory exists. + dir := filepath.Dir(templateFilepath) err := os.MkdirAll(dir, 0755) if err != nil { return false, fmt.Errorf("unable to create Alertmanager templates directory %q: %s", dir, err) } - file := filepath.Join(dir, fn) // Check if the template file already exists and if it has changed - if tmpl, err := ioutil.ReadFile(file); err == nil && string(tmpl) == content { + if tmpl, err := ioutil.ReadFile(templateFilepath); err == nil && string(tmpl) == content { return false, nil + } else if err != nil && !os.IsNotExist(err) { + return false, err } - if err := ioutil.WriteFile(file, []byte(content), 0644); err != nil { - return false, fmt.Errorf("unable to create Alertmanager template file %q: %s", file, err) + if err := ioutil.WriteFile(templateFilepath, []byte(content), 0644); err != nil { + return false, fmt.Errorf("unable to create Alertmanager template file %q: %s", templateFilepath, err) } return true, nil diff --git a/pkg/alertmanager/multitenant_test.go b/pkg/alertmanager/multitenant_test.go index c55b5105b3..9719177fad 100644 --- a/pkg/alertmanager/multitenant_test.go +++ b/pkg/alertmanager/multitenant_test.go @@ -3,12 +3,14 @@ package alertmanager import ( "bytes" "context" + "errors" "fmt" "io/ioutil" "net/http" "net/http/httptest" "net/http/pprof" "os" + "path/filepath" "strings" "testing" "time" @@ -834,3 +836,54 @@ func TestAlertmanager_InitialSyncFailureWithSharding(t *testing.T) { require.False(t, am.ringLifecycler.IsRegistered()) require.NotNil(t, am.ring) } + +func TestSafeTemplateFilepath(t *testing.T) { + tests := map[string]struct { + dir string + template string + expectedPath string + expectedErr error + }{ + "should succeed if the provided template is a filename": { + dir: "/data/tenant", + template: "test.tmpl", + expectedPath: "/data/tenant/test.tmpl", + }, + "should fail if the provided template is escaping the dir": { + dir: "/data/tenant", + template: "../test.tmpl", + expectedErr: errors.New(`invalid template name "../test.tmpl": the template filepath is escaping the per-tenant local directory`), + }, + } + + for testName, testData := range tests { + t.Run(testName, func(t *testing.T) { + actualPath, actualErr := safeTemplateFilepath(testData.dir, testData.template) + assert.Equal(t, testData.expectedErr, actualErr) + assert.Equal(t, testData.expectedPath, actualPath) + }) + } +} + +func TestStoreTemplateFile(t *testing.T) { + tempDir, err := ioutil.TempDir(os.TempDir(), "alertmanager") + require.NoError(t, err) + + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(tempDir)) + }) + + testTemplateDir := filepath.Join(tempDir, "templates") + + changed, err := storeTemplateFile(filepath.Join(testTemplateDir, "some-template"), "content") + require.NoError(t, err) + require.True(t, changed) + + changed, err = storeTemplateFile(filepath.Join(testTemplateDir, "some-template"), "new content") + require.NoError(t, err) + require.True(t, changed) + + changed, err = storeTemplateFile(filepath.Join(testTemplateDir, "some-template"), "new content") // reusing previous content + require.NoError(t, err) + require.False(t, changed) +}