diff --git a/README.md b/README.md index 256363a883..68ee3798e1 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,13 @@ You can also build just one of the binaries in this repo by passing a name to th $ make build BINARIES=amtool ``` +You can also load configuration from an HTTP endpoint: +``` +$ ./alertmanager --config.http-url=http://config-server/config.yaml +``` + +Note: `--config.file` defaults to `alertmanager.yml`. `--config.http-url` takes precedence when set. Do not pass both flags explicitly. + ## Example This is an example configuration that should cover most relevant aspects of the new YAML configuration format. The full documentation of the configuration can be found [here](https://prometheus.io/docs/alerting/configuration/). diff --git a/app/app.go b/app/app.go index cb1c4c6790..09a045b1c2 100644 --- a/app/app.go +++ b/app/app.go @@ -248,11 +248,23 @@ func (a *App) setup() error { stopc := make(chan struct{}) var wg sync.WaitGroup + var loader config.ConfigLoader + if opts.ConfigHTTPURL != "" { + loader = config.NewHTTPLoader(opts.ConfigHTTPURL) + logger.Info("Starting Alertmanager in HTTP configuration mode", "source", loader.Source()) + } else { + loader = config.NewFileLoader(opts.ConfigFile) + logger.Info("Starting Alertmanager in file configuration mode", "source", loader.Source()) + } // Load config once for both event recorder initialization and the // first coordinator apply. Subsequent reloads go through // configCoordinator.Reload() which reads the file again. - initialConf, err := config.LoadFile(opts.ConfigFile) + data, err := loader.Load(context.Background()) + if err != nil { + return fmt.Errorf("error loading configuration: %w", err) + } + initialConf, err := config.Load(string(data)) if err != nil { return fmt.Errorf("error loading configuration file: %w", err) } @@ -457,11 +469,7 @@ func (a *App) setup() error { }) configLogger := logger.With("component", "configuration") - configCoordinator := config.NewCoordinator( - opts.ConfigFile, - reg, - configLogger, - ) + configCoordinator := config.NewCoordinator(loader, reg, configLogger) a.coordinator = configCoordinator // The reloader owns the config-scoped subgraph (templates, routes, diff --git a/app/config_loader_test.go b/app/config_loader_test.go new file mode 100644 index 0000000000..a8cce9b44c --- /dev/null +++ b/app/config_loader_test.go @@ -0,0 +1,195 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package app + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/promslog" + "github.com/prometheus/exporter-toolkit/web" + + "github.com/prometheus/alertmanager/featurecontrol" +) + +func TestStartupWithHTTPConfig(t *testing.T) { + // Start a HTTP server that returns a minimal valid config. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("route:\n receiver: test\nreceivers:\n- name: test")) + })) + defer srv.Close() + + // Create a temporary data directory. + dir := t.TempDir() + + // Build minimal options for HTTP source. + webCfg := web.FlagConfig{} + webCfg.WebListenAddresses = &[]string{"127.0.0.1:0"} + webCfgFile := "" + webCfg.WebConfigFile = &webCfgFile + + ff, err := featurecontrol.NewFlags(promslog.NewNopLogger(), "") + if err != nil { + t.Fatal(err) + } + opts := Options{ + ConfigHTTPURL: srv.URL, + DataDir: dir, + Retention: DefaultRetention, + MaintenanceInterval: DefaultMaintenanceInterval, + AlertGCInterval: DefaultAlertGCInterval, + DispatchMaintenanceInterval: DefaultDispatchMaintenanceInterval, + WebConfig: &webCfg, + Logger: promslog.NewNopLogger(), + Registerer: prometheus.NewRegistry(), + Flagger: ff, + } + + // Try to create the app (setup). + app, err := New(opts) + if err != nil { + t.Fatalf("failed to create app with HTTP config: %v", err) + } + defer func() { _ = app.Stop(context.Background()) }() + + // Start the app. + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = app.Start() }() + + // Verify it started without error. + select { + case <-ctx.Done(): + t.Fatal("app stopped unexpectedly") + default: + } +} + +func TestStartupWithFileConfig(t *testing.T) { + // Create a temporary config file. + dir := t.TempDir() + configPath := filepath.Join(dir, "alertmanager.yml") + data := []byte("route:\n receiver: test\nreceivers:\n- name: test") + if err := os.WriteFile(configPath, data, 0o600); err != nil { + t.Fatal(err) + } + + webCfg := web.FlagConfig{} + webCfg.WebListenAddresses = &[]string{"127.0.0.1:0"} + webCfgFile := "" + webCfg.WebConfigFile = &webCfgFile + + ff, err := featurecontrol.NewFlags(promslog.NewNopLogger(), "") + if err != nil { + t.Fatal(err) + } + opts := Options{ + ConfigFile: configPath, + DataDir: dir, + Retention: DefaultRetention, + MaintenanceInterval: DefaultMaintenanceInterval, + AlertGCInterval: DefaultAlertGCInterval, + DispatchMaintenanceInterval: DefaultDispatchMaintenanceInterval, + WebConfig: &webCfg, + Logger: promslog.NewNopLogger(), + Registerer: prometheus.NewRegistry(), + Flagger: ff, + } + + app, err := New(opts) + if err != nil { + t.Fatalf("failed to create app with file config: %v", err) + } + defer func() { _ = app.Stop(context.Background()) }() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { _ = app.Start() }() + + select { + case <-ctx.Done(): + t.Fatal("app stopped unexpectedly") + default: + } +} + +func TestStartupWithBothSources(t *testing.T) { + // Create a temporary config file. + dir := t.TempDir() + configPath := filepath.Join(dir, "alertmanager.yml") + data := []byte("route:\n receiver: test\nreceivers:\n- name: test") + if err := os.WriteFile(configPath, data, 0o600); err != nil { + t.Fatal(err) + } + + webCfg := web.FlagConfig{} + webCfg.WebListenAddresses = &[]string{"127.0.0.1:0"} + webCfgFile := "" + webCfg.WebConfigFile = &webCfgFile + ff, _ := featurecontrol.NewFlags(promslog.NewNopLogger(), "") + opts := Options{ + ConfigFile: configPath, + ConfigHTTPURL: "http://example.com/config", + DataDir: dir, + Retention: DefaultRetention, + MaintenanceInterval: DefaultMaintenanceInterval, + AlertGCInterval: DefaultAlertGCInterval, + DispatchMaintenanceInterval: DefaultDispatchMaintenanceInterval, + WebConfig: &webCfg, + Logger: promslog.NewNopLogger(), + Registerer: prometheus.NewRegistry(), + Flagger: ff, + } + + _, err := New(opts) + if err == nil { + t.Fatal("expected error when both config sources are set") + } + if err.Error() != "alertmanager/app: Options.ConfigFile and Options.ConfigHTTPURL are mutually exclusive" { + t.Fatalf("unexpected error message: %v", err) + } +} + +func TestStartupWithNeitherSource(t *testing.T) { + webCfg := web.FlagConfig{} + webCfg.WebListenAddresses = &[]string{"127.0.0.1:0"} + webCfgFile := "" + webCfg.WebConfigFile = &webCfgFile + ff, _ := featurecontrol.NewFlags(promslog.NewNopLogger(), "") + opts := Options{ + DataDir: t.TempDir(), + Retention: DefaultRetention, + MaintenanceInterval: DefaultMaintenanceInterval, + AlertGCInterval: DefaultAlertGCInterval, + DispatchMaintenanceInterval: DefaultDispatchMaintenanceInterval, + WebConfig: &webCfg, + Logger: promslog.NewNopLogger(), + Registerer: prometheus.NewRegistry(), + Flagger: ff, + } + + _, err := New(opts) + if err == nil { + t.Fatal("expected error when no config source is set") + } + if err.Error() != "alertmanager/app: exactly one of Options.ConfigFile or Options.ConfigHTTPURL must be set" { + t.Fatalf("unexpected error message: %v", err) + } +} diff --git a/app/lifecycle_test.go b/app/lifecycle_test.go index 441c1be9f7..e1a9145b55 100644 --- a/app/lifecycle_test.go +++ b/app/lifecycle_test.go @@ -175,7 +175,7 @@ func TestApp_reloadRouterClosedReloadChannel(t *testing.T) { require.NoError(t, os.WriteFile(configPath, []byte(minimalConfig), 0o600)) var reloads atomic.Int64 - coord := config.NewCoordinator(configPath, prometheus.NewRegistry(), promslog.NewNopLogger()) + coord := config.NewCoordinator(config.NewFileLoader(configPath), prometheus.NewRegistry(), promslog.NewNopLogger()) coord.Subscribe(func(*config.Config) error { reloads.Add(1) return nil diff --git a/app/options.go b/app/options.go index aba831f8a3..dfc5bf136c 100644 --- a/app/options.go +++ b/app/options.go @@ -50,6 +50,9 @@ const ( // fields default to their zero value (which generally matches the kingpin // flag default). type Options struct { + // ConfigHTTPURL specifies the HTTP URL to load the Alertmanager configuration from. + // It is mutually exclusive with ConfigFile - exactly one must be specified. + ConfigHTTPURL string // Storage and lifecycle. ConfigFile string DataDir string @@ -158,8 +161,11 @@ func (o *Options) validate() error { } // Storage and config paths. - if o.ConfigFile == "" { - return errors.New("alertmanager/app: Options.ConfigFile is required") + if o.ConfigFile == "" && o.ConfigHTTPURL == "" { + return errors.New("alertmanager/app: exactly one of Options.ConfigFile or Options.ConfigHTTPURL must be set") + } + if o.ConfigFile != "" && o.ConfigHTTPURL != "" { + return errors.New("alertmanager/app: Options.ConfigFile and Options.ConfigHTTPURL are mutually exclusive") } if o.DataDir == "" { return errors.New("alertmanager/app: Options.DataDir is required") diff --git a/app/options_test.go b/app/options_test.go index eb40a18127..2f61da92cc 100644 --- a/app/options_test.go +++ b/app/options_test.go @@ -63,7 +63,14 @@ func TestOptions_Validate(t *testing.T) { {name: "missing logger", mutate: func(o *Options) { o.Logger = nil }}, {name: "missing registerer", mutate: func(o *Options) { o.Registerer = nil }}, {name: "missing flagger", mutate: func(o *Options) { o.Flagger = nil }}, - {name: "missing config file", mutate: func(o *Options) { o.ConfigFile = "" }}, + {name: "missing config source", mutate: func(o *Options) { + o.ConfigFile = "" + o.ConfigHTTPURL = "" + }}, + {name: "both config sources", mutate: func(o *Options) { + o.ConfigFile = "alertmanager.yml" + o.ConfigHTTPURL = "http://example.com/config" + }}, {name: "missing data dir", mutate: func(o *Options) { o.DataDir = "" }}, {name: "zero retention", mutate: func(o *Options) { o.Retention = 0 }}, {name: "zero maintenance interval", mutate: func(o *Options) { o.MaintenanceInterval = 0 }}, @@ -94,6 +101,13 @@ func TestOptions_Validate(t *testing.T) { }) } + t.Run("HTTP config only is valid", func(t *testing.T) { + o := valid() + o.ConfigFile = "" + o.ConfigHTTPURL = "http://example.com/config" + require.NoError(t, o.validate()) + }) + t.Run("systemd socket without listen addresses is valid", func(t *testing.T) { o := valid() o.WebConfig = &web.FlagConfig{ diff --git a/cmd/alertmanager/main.go b/cmd/alertmanager/main.go index 80690e6e7a..48875e8802 100644 --- a/cmd/alertmanager/main.go +++ b/cmd/alertmanager/main.go @@ -48,7 +48,13 @@ func run() int { } var ( - configFile = kingpin.Flag("config.file", "Alertmanager configuration file name.").Default("alertmanager.yml").String() + configFileSet bool + configHTTPURLSet bool + ) + + var ( + configFile = kingpin.Flag("config.file", "Alertmanager configuration file name.").Default(app.DefaultConfigFile).IsSetByUser(&configFileSet).String() + configHTTPURL = kingpin.Flag("config.http-url", "Alertmanager configuration URL (mutually exclusive with --config.file).").IsSetByUser(&configHTTPURLSet).String() dataDir = kingpin.Flag("storage.path", "Base path for data storage.").Default("data/").String() retention = kingpin.Flag("data.retention", "How long to keep data for.").Default("120h").Duration() maintenanceInterval = kingpin.Flag("data.maintenance-interval", "Interval between garbage collection and snapshotting to disk of the silences and the notification logs.").Default("15m").Duration() @@ -97,6 +103,17 @@ func run() int { kingpin.CommandLine.GetFlag("help").Short('h') kingpin.Parse() + if configFileSet && configHTTPURLSet { + kingpin.Fatalf("Need to configure only one of the following --config.file or --config.http-url") + } + + var fileConfig, httpConfig string + if *configHTTPURL != "" { + httpConfig = *configHTTPURL + } else { + fileConfig = *configFile + } + logger := promslog.New(&promslogConfig) prometheus.MustRegister(versioncollector.NewCollector("alertmanager")) @@ -152,7 +169,8 @@ func run() int { }() opts := app.Options{ - ConfigFile: *configFile, + ConfigFile: fileConfig, + ConfigHTTPURL: httpConfig, DataDir: *dataDir, Retention: *retention, MaintenanceInterval: *maintenanceInterval, diff --git a/config/coordinator.go b/config/coordinator.go index 3ec12bc100..ad6bd49c96 100644 --- a/config/coordinator.go +++ b/config/coordinator.go @@ -14,6 +14,7 @@ package config import ( + "context" "crypto/md5" "encoding/binary" "errors" @@ -27,8 +28,8 @@ import ( // Coordinator coordinates Alertmanager configurations beyond the lifetime of a // single configuration. type Coordinator struct { - configFilePath string - logger *slog.Logger + loader ConfigLoader + logger *slog.Logger // Protects config and subscribers mutex sync.Mutex @@ -40,13 +41,12 @@ type Coordinator struct { configSuccessTimeMetric prometheus.Gauge } -// NewCoordinator returns a new coordinator with the given configuration file -// path. It does not yet load the configuration from file. This is done in -// `Reload()`. -func NewCoordinator(configFilePath string, r prometheus.Registerer, l *slog.Logger) *Coordinator { +// NewCoordinator returns a new coordinator with the given configuration loader. +// It does not yet load the configuration. This is done in `Reload()`. +func NewCoordinator(loader ConfigLoader, r prometheus.Registerer, l *slog.Logger) *Coordinator { c := &Coordinator{ - configFilePath: configFilePath, - logger: l, + loader: loader, + logger: l, } c.registerMetrics(r) @@ -91,46 +91,49 @@ func (c *Coordinator) notifySubscribers() error { return nil } -// loadFromFile triggers a configuration load, discarding the old configuration. -func (c *Coordinator) loadFromFile() error { - conf, err := LoadFile(c.configFilePath) +// loadFromSource triggers a configuration load, discarding the old configuration. +func (c *Coordinator) loadFromSource() error { + data, err := c.loader.Load(context.Background()) + if err != nil { + return err + } + conf, err := Load(string(data)) if err != nil { return err } - c.config = conf - return nil } -// Reload triggers a configuration reload from file and notifies all -// configuration change subscribers. +// Reload triggers a configuration reload and notifies all configuration change +// subscribers. func (c *Coordinator) Reload() error { c.mutex.Lock() defer c.mutex.Unlock() + source := c.loader.Source() c.logger.Info( - "Loading configuration file", - "file", c.configFilePath, + "Loading configuration", + "source", source, ) - if err := c.loadFromFile(); err != nil { + if err := c.loadFromSource(); err != nil { c.logger.Error( - "Loading configuration file failed", - "file", c.configFilePath, + "Loading configuration failed", + "source", source, "err", err, ) c.configSuccessMetric.Set(0) return err } c.logger.Info( - "Completed loading of configuration file", - "file", c.configFilePath, + "Completed loading of configuration", + "source", source, ) if err := c.notifySubscribers(); err != nil { c.logger.Error( "one or more config change subscribers failed to apply new config", - "file", c.configFilePath, + "source", source, "err", err, ) c.configSuccessMetric.Set(0) @@ -146,7 +149,7 @@ func (c *Coordinator) Reload() error { } // ApplyConfig accepts an already-loaded configuration, stores it, and -// notifies all subscribers. Use this for the initial load so the file +// notifies all subscribers. Use this for the initial load so the configuration // is only read once. func (c *Coordinator) ApplyConfig(conf *Config) error { c.mutex.Lock() @@ -159,10 +162,11 @@ func (c *Coordinator) ApplyConfig(conf *Config) error { c.config = conf + source := c.loader.Source() if err := c.notifySubscribers(); err != nil { c.logger.Error( "one or more config change subscribers failed to apply new config", - "file", c.configFilePath, + "source", source, "err", err, ) c.configSuccessMetric.Set(0) diff --git a/config/coordinator_test.go b/config/coordinator_test.go index 4ddebb9528..6d6a8b2308 100644 --- a/config/coordinator_test.go +++ b/config/coordinator_test.go @@ -39,7 +39,7 @@ func (r *fakeRegisterer) Unregister(prometheus.Collector) bool { func TestCoordinatorRegistersMetrics(t *testing.T) { fr := fakeRegisterer{} - NewCoordinator("testdata/conf.good.yml", &fr, promslog.NewNopLogger()) + NewCoordinator(NewFileLoader("testdata/conf.good.yml"), &fr, promslog.NewNopLogger()) if len(fr.registeredCollectors) == 0 { t.Error("expected NewCoordinator to register metrics on the given registerer") @@ -48,7 +48,7 @@ func TestCoordinatorRegistersMetrics(t *testing.T) { func TestCoordinatorNotifiesSubscribers(t *testing.T) { callBackCalled := false - c := NewCoordinator("testdata/conf.good.yml", prometheus.NewRegistry(), promslog.NewNopLogger()) + c := NewCoordinator(NewFileLoader("testdata/conf.good.yml"), prometheus.NewRegistry(), promslog.NewNopLogger()) c.Subscribe(func(*Config) error { callBackCalled = true return nil @@ -66,7 +66,7 @@ func TestCoordinatorNotifiesSubscribers(t *testing.T) { func TestCoordinatorFailReloadWhenSubscriberFails(t *testing.T) { errMessage := "something happened" - c := NewCoordinator("testdata/conf.good.yml", prometheus.NewRegistry(), promslog.NewNopLogger()) + c := NewCoordinator(NewFileLoader("testdata/conf.good.yml"), prometheus.NewRegistry(), promslog.NewNopLogger()) c.Subscribe(func(*Config) error { return errors.New(errMessage) diff --git a/config/loader.go b/config/loader.go new file mode 100644 index 0000000000..b7bc00f168 --- /dev/null +++ b/config/loader.go @@ -0,0 +1,115 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package config provides configuration loading utilities. +package config + +import ( + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" +) + +const defaultHTTPConfigTimeout = 30 * time.Second + +// ConfigLoader abstracts where the raw configuration bytes come from. +type ConfigLoader interface { + // Load returns the raw configuration bytes. + Load(ctx context.Context) ([]byte, error) + // Source returns an identifier for this loader suitable for logs (credentials redacted for HTTP). + Source() string +} + +// fileLoader loads configuration from a local file. +type fileLoader struct{ path string } + +// NewFileLoader creates a ConfigLoader that reads from the given file path. +func NewFileLoader(p string) ConfigLoader { return &fileLoader{path: p} } + +// Source implements ConfigLoader. +func (f *fileLoader) Source() string { return f.path } + +// Load implements ConfigLoader for file-based configuration. +// It reads the configuration file from the filesystem and returns the raw bytes. +// Errors are wrapped to preserve the error chain for proper error handling. +func (f *fileLoader) Load(_ context.Context) ([]byte, error) { + data, err := os.ReadFile(f.path) + if err != nil { + return nil, fmt.Errorf("failed to read configuration file: %w", err) + } + return data, nil +} + +// httpLoader loads configuration via a simple HTTP GET request. +type httpLoader struct{ url string } + +// SanitizeURL redacts any credentials from the URL for logging purposes. +func SanitizeURL(rawURL string) string { + parsed, err := url.Parse(rawURL) + if err != nil { + return rawURL + } + + sanitized := rawURL + if parsed.User != nil { + sanitized = parsed.Redacted() + } + + if parsed.RawQuery != "" { + sanitized = strings.Replace(sanitized, parsed.RawQuery, "[redacted]", 1) + } + + return sanitized +} + +// NewHTTPLoader creates a ConfigLoader that fetches the configuration from the given URL. +func NewHTTPLoader(u string) ConfigLoader { return &httpLoader{url: u} } + +// Source implements ConfigLoader. +func (h *httpLoader) Source() string { return SanitizeURL(h.url) } + +// Load implements ConfigLoader for HTTP-based configuration. +// It fetches the configuration from the specified HTTP URL with a request timeout. +// Errors are wrapped to preserve the error chain for proper error handling. +func (h *httpLoader) Load(ctx context.Context) ([]byte, error) { + client := &http.Client{ + Timeout: defaultHTTPConfigTimeout, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP request: %w", err) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read HTTP response body: %w", err) + } + + return data, nil +} diff --git a/config/loader_test.go b/config/loader_test.go new file mode 100644 index 0000000000..c9e1faa375 --- /dev/null +++ b/config/loader_test.go @@ -0,0 +1,181 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package config + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/common/promslog" + "github.com/stretchr/testify/require" +) + +func TestFileLoader(t *testing.T) { + t.Run("successful load", func(t *testing.T) { + loader := NewFileLoader("testdata/conf.good.yml") + data, err := loader.Load(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, data) + }) + + t.Run("missing file", func(t *testing.T) { + loader := NewFileLoader("testdata/nonexistent.yml") + _, err := loader.Load(context.Background()) + require.Error(t, err) + }) + + t.Run("unreadable file", func(t *testing.T) { + // Use a directory path which is guaranteed to fail when trying to read as a file + dir := t.TempDir() + loader := NewFileLoader(dir) // Directory paths cannot be read as files + _, err := loader.Load(context.Background()) + require.Error(t, err) + }) +} + +func TestHTTPLoader(t *testing.T) { + t.Run("successful HTTP 200", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("route:\n receiver: test\nreceivers:\n- name: test")) + })) + defer srv.Close() + + loader := NewHTTPLoader(srv.URL) + data, err := loader.Load(context.Background()) + require.NoError(t, err) + require.Contains(t, string(data), "receiver: test") + }) + + t.Run("HTTP 404", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + })) + defer srv.Close() + + loader := NewHTTPLoader(srv.URL) + _, err := loader.Load(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected HTTP status 404") + }) + + t.Run("HTTP 500", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + loader := NewHTTPLoader(srv.URL) + _, err := loader.Load(context.Background()) + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected HTTP status 500") + }) + + t.Run("network failure", func(t *testing.T) { + loader := NewHTTPLoader("http://127.0.0.1:99999") + _, err := loader.Load(context.Background()) + require.Error(t, err) + }) + + t.Run("timeout", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Never respond + })) + defer srv.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 0) + defer cancel() + + loader := NewHTTPLoader(srv.URL) + _, err := loader.Load(ctx) + require.Error(t, err) + }) + + t.Run("unreadable response body", func(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("invalid: [")) + })) + defer srv.Close() + + loader := NewHTTPLoader(srv.URL) + data, err := loader.Load(context.Background()) + require.NoError(t, err) + require.NotEmpty(t, data) + }) + + t.Run("percent-encoded credentials", func(t *testing.T) { + // Test with percent-encoded credentials in URL + username := "testuser" + password := "p%40ssw%40rd" // p@ssw@rd with @ symbols percent-encoded + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("route:\n receiver: test\nreceivers:\n- name: test")) + })) + defer srv.Close() + + // Create URL with percent-encoded credentials + urlWithCreds := srv.URL + "?username=" + username + "&password=" + password + loader := NewHTTPLoader(urlWithCreds) + data, err := loader.Load(context.Background()) + require.NoError(t, err) + require.Contains(t, string(data), "receiver: test") + }) +} + +func TestCoordinatorReloadWithHTTP(t *testing.T) { + // Start a mutable HTTP server that returns a valid config. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte("route:\n receiver: test\nreceivers:\n- name: test")) + })) + defer srv.Close() + + loader := NewHTTPLoader(srv.URL) + coord := NewCoordinator(loader, prometheus.NewRegistry(), promslog.NewNopLogger()) + + var called bool + coord.Subscribe(func(*Config) error { + called = true + return nil + }) + + err := coord.Reload() + require.NoError(t, err) + require.True(t, called) +} + +func TestCoordinatorReloadWithHTTPFailure(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + loader := NewHTTPLoader(srv.URL) + coord := NewCoordinator(loader, prometheus.NewRegistry(), promslog.NewNopLogger()) + + var called bool + coord.Subscribe(func(*Config) error { + called = true + return nil + }) + + err := coord.Reload() + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected HTTP status 500") + require.False(t, called) +} diff --git a/docs/configuration.md b/docs/configuration.md index be9a73f841..863d7a5fb9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,11 +39,37 @@ To specify which configuration file to load, use the `--config.file` flag. ./alertmanager --config.file=alertmanager.yml ``` +Alternatively, you can load configuration from an HTTP endpoint using the `--config.http-url` flag: + +```bash +./alertmanager --config.http-url=http://config-server/config.yaml +``` + +Note: `--config.file` defaults to `alertmanager.yml`. `--config.http-url` takes precedence when set. Do not pass both flags explicitly. + + The file is written in the [YAML format](http://en.wikipedia.org/wiki/YAML), defined by the scheme described below. Brackets indicate that a parameter is optional. For non-list parameters the value is set to the specified default. +## HTTP Configuration + +Instead of loading configuration from a local file, Alertmanager can load it from an HTTP endpoint: + +```bash +./alertmanager --config.http-url=http://config-server/config.yaml +``` + +The HTTP endpoint must: +- Return a valid YAML configuration +- Respond with HTTP 200 status code +- Be accessible from the Alertmanager process + +Note: `--config.file` defaults to `alertmanager.yml`. `--config.http-url` takes precedence when set. Do not pass both flags explicitly. + +Configuration reload via `SIGHUP` or `POST /-/reload` works the same way with HTTP configuration - it will fetch the latest configuration from the HTTP endpoint. + Generic placeholders are defined as follows: * ``: a duration matching the regular expression `((([0-9]+)y)?(([0-9]+)w)?(([0-9]+)d)?(([0-9]+)h)?(([0-9]+)m)?(([0-9]+)s)?(([0-9]+)ms)?|0)`, e.g. `1d`, `1h30m`, `5m`, `10s`