From da7d3ec3694b1169719bf1f8844f3c7526110a6e Mon Sep 17 00:00:00 2001 From: jshah-dev Date: Mon, 27 Jul 2026 12:31:33 +0530 Subject: [PATCH 1/5] adding support to read configuration from http endpoint Signed-off-by: jshah-dev --- README.md | 7 ++ app/app.go | 48 ++++++--- app/http_config_test.go | 195 +++++++++++++++++++++++++++++++++++++ app/lifecycle_test.go | 2 +- app/options.go | 10 +- cmd/alertmanager/main.go | 12 ++- config/coordinator.go | 79 +++++++++------ config/coordinator_test.go | 10 +- config/loader.go | 48 +++++++++ config/loader_test.go | 177 +++++++++++++++++++++++++++++++++ docs/configuration.md | 26 +++++ 11 files changed, 557 insertions(+), 57 deletions(-) create mode 100644 app/http_config_test.go create mode 100644 config/loader.go create mode 100644 config/loader_test.go diff --git a/README.md b/README.md index 256363a883..ad26817d82 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` and `--config.http-url` are mutually exclusive - exactly one must be specified. + ## 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..16fcf185b9 100644 --- a/app/app.go +++ b/app/app.go @@ -246,16 +246,28 @@ func (a *App) setup() error { m.clusterEnabled.Set(1) } - stopc := make(chan struct{}) - var wg sync.WaitGroup - - // 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) - if err != nil { - return fmt.Errorf("error loading configuration file: %w", err) - } +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", opts.ConfigHTTPURL) + } else { + loader = config.NewFileLoader(opts.ConfigFile) + logger.Info("Starting Alertmanager in file configuration mode", "source", opts.ConfigFile) + } + + // Load config once for both event recorder initialization and the + // first coordinator apply. Subsequent reloads go through + // configCoordinator.Reload() which reads the file again. + 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) + } hostname, _ := os.Hostname() var eventRec eventrecorder.Recorder @@ -457,11 +469,17 @@ func (a *App) setup() error { }) configLogger := logger.With("component", "configuration") - configCoordinator := config.NewCoordinator( - opts.ConfigFile, - reg, - configLogger, - ) + if opts.ConfigHTTPURL != "" { + loader = config.NewHTTPLoader(opts.ConfigHTTPURL) + } else { + loader = config.NewFileLoader(opts.ConfigFile) + } + configCoordinator := config.NewCoordinator( + loader, + opts.ConfigFile, + reg, + configLogger, + ) a.coordinator = configCoordinator // The reloader owns the config-scoped subgraph (templates, routes, diff --git a/app/http_config_test.go b/app/http_config_test.go new file mode 100644 index 0000000000..aad4f30833 --- /dev/null +++ b/app/http_config_test.go @@ -0,0 +1,195 @@ +// Copyright 2024 Prometheus Team +// 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..cf060fc85a 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), 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..685c397210 100644 --- a/app/options.go +++ b/app/options.go @@ -50,6 +50,7 @@ const ( // fields default to their zero value (which generally matches the kingpin // flag default). type Options struct { + ConfigHTTPURL string // Storage and lifecycle. ConfigFile string DataDir string @@ -111,7 +112,7 @@ type Options struct { // Flagger) and a WebConfig before passing the result to New or Run. func DefaultOptions() Options { return Options{ - ConfigFile: DefaultConfigFile, + ConfigFile: "", DataDir: DefaultDataDir, Retention: DefaultRetention, MaintenanceInterval: DefaultMaintenanceInterval, @@ -158,8 +159,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/cmd/alertmanager/main.go b/cmd/alertmanager/main.go index 80690e6e7a..3405ea70e2 100644 --- a/cmd/alertmanager/main.go +++ b/cmd/alertmanager/main.go @@ -48,7 +48,8 @@ func run() int { } var ( - configFile = kingpin.Flag("config.file", "Alertmanager configuration file name.").Default("alertmanager.yml").String() + configFile = kingpin.Flag("config.file", "Alertmanager configuration file name.").String() + configHTTPURL = kingpin.Flag("config.http-url", "Alertmanager configuration URL (mutually exclusive with --config.file).").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 +98,14 @@ func run() int { kingpin.CommandLine.GetFlag("help").Short('h') kingpin.Parse() + // Validate exactly one configuration source is provided. + if *configFile == "" && *configHTTPURL == "" { + kingpin.Fatalf("Need to configure one of the following --config.file or --config.http-url") + } + if *configFile != "" && *configHTTPURL != "" { + kingpin.Fatalf("Need to configure only one of the following --config.file or --config.http-url") + } + logger := promslog.New(&promslogConfig) prometheus.MustRegister(versioncollector.NewCollector("alertmanager")) @@ -153,6 +162,7 @@ func run() int { opts := app.Options{ ConfigFile: *configFile, + ConfigHTTPURL: *configHTTPURL, DataDir: *dataDir, Retention: *retention, MaintenanceInterval: *maintenanceInterval, diff --git a/config/coordinator.go b/config/coordinator.go index 3ec12bc100..e2e2f1dbe2 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,10 @@ import ( // Coordinator coordinates Alertmanager configurations beyond the lifetime of a // single configuration. type Coordinator struct { - configFilePath string - logger *slog.Logger + loader ConfigLoader + configFilePath string + configSource string // Either file path or HTTP URL for logging + logger *slog.Logger // Protects config and subscribers mutex sync.Mutex @@ -43,10 +46,20 @@ type Coordinator struct { // 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 { - c := &Coordinator{ - configFilePath: configFilePath, - logger: l, +func NewCoordinator(loader ConfigLoader, configFilePath string, r prometheus.Registerer, l *slog.Logger) *Coordinator { + // Determine the source string for logging + source := configFilePath + if source == "" { + // If configFilePath is empty, we're using HTTP + if fl, ok := loader.(*httpLoader); ok { + source = fl.url + } + } + c := &Coordinator{ + loader: loader, + configFilePath: configFilePath, + configSource: source, + logger: l, } c.registerMetrics(r) @@ -93,13 +106,15 @@ func (c *Coordinator) notifySubscribers() error { // loadFromFile triggers a configuration load, discarding the old configuration. func (c *Coordinator) loadFromFile() error { - conf, err := LoadFile(c.configFilePath) + 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 } @@ -109,33 +124,33 @@ func (c *Coordinator) Reload() error { c.mutex.Lock() defer c.mutex.Unlock() - c.logger.Info( - "Loading configuration file", - "file", c.configFilePath, - ) +c.logger.Info( + "Loading configuration", + "source", c.configSource, + ) if err := c.loadFromFile(); err != nil { - c.logger.Error( - "Loading configuration file failed", - "file", c.configFilePath, - "err", err, - ) - c.configSuccessMetric.Set(0) - return err - } - c.logger.Info( - "Completed loading of configuration file", - "file", c.configFilePath, - ) - - if err := c.notifySubscribers(); err != nil { - c.logger.Error( - "one or more config change subscribers failed to apply new config", - "file", c.configFilePath, - "err", err, - ) +c.logger.Error( + "Loading configuration failed", + "source", c.configSource, + "err", err, + ) c.configSuccessMetric.Set(0) return err } +c.logger.Info( + "Completed loading of configuration", + "source", c.configSource, + ) + + if err := c.notifySubscribers(); err != nil { + c.logger.Error( + "one or more config change subscribers failed to apply new config", + "source", c.configSource, + "err", err, + ) + c.configSuccessMetric.Set(0) + return err + } c.configSuccessMetric.Set(1) c.configSuccessTimeMetric.SetToCurrentTime() diff --git a/config/coordinator_test.go b/config/coordinator_test.go index 4ddebb9528..a28b1307e9 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"), "testdata/conf.good.yml", &fr, promslog.NewNopLogger()) if len(fr.registeredCollectors) == 0 { t.Error("expected NewCoordinator to register metrics on the given registerer") @@ -47,8 +47,8 @@ func TestCoordinatorRegistersMetrics(t *testing.T) { } func TestCoordinatorNotifiesSubscribers(t *testing.T) { - callBackCalled := false - c := NewCoordinator("testdata/conf.good.yml", prometheus.NewRegistry(), promslog.NewNopLogger()) + callBackCalled := false + c := NewCoordinator(NewFileLoader("testdata/conf.good.yml"), "testdata/conf.good.yml", prometheus.NewRegistry(), promslog.NewNopLogger()) c.Subscribe(func(*Config) error { callBackCalled = true return nil @@ -65,8 +65,8 @@ func TestCoordinatorNotifiesSubscribers(t *testing.T) { } func TestCoordinatorFailReloadWhenSubscriberFails(t *testing.T) { - errMessage := "something happened" - c := NewCoordinator("testdata/conf.good.yml", prometheus.NewRegistry(), promslog.NewNopLogger()) + errMessage := "something happened" + c := NewCoordinator(NewFileLoader("testdata/conf.good.yml"), "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..4b8a81d7c7 --- /dev/null +++ b/config/loader.go @@ -0,0 +1,48 @@ +// Package config provides configuration loading utilities. +package config + +import ( + "context" + "fmt" + "io" + "net/http" + "os" +) + +// ConfigLoader abstracts where the raw configuration bytes come from. +type ConfigLoader interface { + // Load returns the raw configuration bytes. + Load(ctx context.Context) ([]byte, error) +} + +// 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} } + +func (f *fileLoader) Load(_ context.Context) ([]byte, error) { + return os.ReadFile(f.path) +} + +// httpLoader loads configuration via a simple HTTP GET request. +type httpLoader struct{ url string } + +// NewHTTPLoader creates a ConfigLoader that fetches the configuration from the given URL. +func NewHTTPLoader(u string) ConfigLoader { return &httpLoader{url: u} } + +func (h *httpLoader) Load(ctx context.Context) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode) + } + return io.ReadAll(resp.Body) +} diff --git a/config/loader_test.go b/config/loader_test.go new file mode 100644 index 0000000000..403b48dbd4 --- /dev/null +++ b/config/loader_test.go @@ -0,0 +1,177 @@ +// Copyright 2024 Prometheus Team +// 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" + "os" + "path/filepath" + "runtime" + "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) { + dir := t.TempDir() + badPath := filepath.Join(dir, "bad.yml") + f, _ := os.Create(badPath) + f.Close() + // On Windows, setting permissions to 0o000 may not prevent reading. + // Instead, we can simulate an unreadable file by using a non-existent path. + // Alternatively, we can skip this test on Windows. + // For now, we'll skip this test on Windows. + if runtime.GOOS == "windows" { + t.Skip("Skipping unreadable file test on Windows") + } + os.Chmod(badPath, 0o000) + loader := NewFileLoader(badPath) + _, err := loader.Load(context.Background()) + require.Error(t, err) + os.Chmod(badPath, 0o600) + }) +} + +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) + }) +} + +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, srv.URL, 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, srv.URL, 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..ee2a658ea0 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` and `--config.http-url` are mutually exclusive - exactly one must be specified. + + 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: The `--config.file` and `--config.http-url` flags are mutually exclusive. Exactly one configuration source must be specified. + +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` From decfed0a2d56a935dfc268b78220ea08d1d76d73 Mon Sep 17 00:00:00 2001 From: jshah-dev Date: Mon, 27 Jul 2026 17:14:45 +0530 Subject: [PATCH 2/5] Applying changes suggested in code review Signed-off-by: jshah-dev --- app/app.go | 68 ++++----- app/http_config_test.go | 280 ++++++++++++++++++------------------- app/options.go | 2 + config/coordinator.go | 81 +++++------ config/coordinator_test.go | 8 +- config/loader.go | 109 ++++++++++++--- config/loader_test.go | 18 +-- 7 files changed, 312 insertions(+), 254 deletions(-) diff --git a/app/app.go b/app/app.go index 16fcf185b9..475cc02f1a 100644 --- a/app/app.go +++ b/app/app.go @@ -246,28 +246,30 @@ func (a *App) setup() error { m.clusterEnabled.Set(1) } -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", opts.ConfigHTTPURL) - } else { - loader = config.NewFileLoader(opts.ConfigFile) - logger.Info("Starting Alertmanager in file configuration mode", "source", opts.ConfigFile) - } - - // Load config once for both event recorder initialization and the - // first coordinator apply. Subsequent reloads go through - // configCoordinator.Reload() which reads the file again. - 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) - } + stopc := make(chan struct{}) + var wg sync.WaitGroup + var loader config.ConfigLoader + if opts.ConfigHTTPURL != "" { + loader = config.NewHTTPLoader(opts.ConfigHTTPURL) + // Sanitize URL for logging to avoid credential leakage + sanitizedURL := config.SanitizeURL(opts.ConfigHTTPURL) + logger.Info("Starting Alertmanager in HTTP configuration mode", "source", sanitizedURL) + } else { + loader = config.NewFileLoader(opts.ConfigFile) + logger.Info("Starting Alertmanager in file configuration mode", "source", opts.ConfigFile) + } + + // Load config once for both event recorder initialization and the + // first coordinator apply. Subsequent reloads go through + // configCoordinator.Reload() which reads the file again. + 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) + } hostname, _ := os.Hostname() var eventRec eventrecorder.Recorder @@ -469,17 +471,17 @@ stopc := make(chan struct{}) }) configLogger := logger.With("component", "configuration") - if opts.ConfigHTTPURL != "" { - loader = config.NewHTTPLoader(opts.ConfigHTTPURL) - } else { - loader = config.NewFileLoader(opts.ConfigFile) - } - configCoordinator := config.NewCoordinator( - loader, - opts.ConfigFile, - reg, - configLogger, - ) + if opts.ConfigHTTPURL != "" { + loader = config.NewHTTPLoader(opts.ConfigHTTPURL) + } else { + loader = config.NewFileLoader(opts.ConfigFile) + } + configCoordinator := config.NewCoordinator( + loader, + opts.ConfigFile, + reg, + configLogger, + ) a.coordinator = configCoordinator // The reloader owns the config-scoped subgraph (templates, routes, diff --git a/app/http_config_test.go b/app/http_config_test.go index aad4f30833..511827c23e 100644 --- a/app/http_config_test.go +++ b/app/http_config_test.go @@ -14,149 +14,149 @@ 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" + "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, - } + // 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()) }() + 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: - } + 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()) }() + // 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() }() + go func() { _ = app.Start() }() - select { - case <-ctx.Done(): - t.Fatal("app stopped unexpectedly") - default: - } + 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, - } + // 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 { @@ -168,22 +168,22 @@ func TestStartupWithBothSources(t *testing.T) { } 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, - } + 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 { diff --git a/app/options.go b/app/options.go index 685c397210..35a4e4812d 100644 --- a/app/options.go +++ b/app/options.go @@ -50,6 +50,8 @@ 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 diff --git a/config/coordinator.go b/config/coordinator.go index e2e2f1dbe2..0035d8e8b4 100644 --- a/config/coordinator.go +++ b/config/coordinator.go @@ -28,10 +28,10 @@ import ( // Coordinator coordinates Alertmanager configurations beyond the lifetime of a // single configuration. type Coordinator struct { - loader ConfigLoader - configFilePath string - configSource string // Either file path or HTTP URL for logging - logger *slog.Logger + loader ConfigLoader + configFilePath string + configSource string // Either file path or HTTP URL for logging + logger *slog.Logger // Protects config and subscribers mutex sync.Mutex @@ -47,19 +47,20 @@ type Coordinator struct { // path. It does not yet load the configuration from file. This is done in // `Reload()`. func NewCoordinator(loader ConfigLoader, configFilePath string, r prometheus.Registerer, l *slog.Logger) *Coordinator { - // Determine the source string for logging - source := configFilePath - if source == "" { - // If configFilePath is empty, we're using HTTP - if fl, ok := loader.(*httpLoader); ok { - source = fl.url - } - } - c := &Coordinator{ - loader: loader, - configFilePath: configFilePath, - configSource: source, - logger: l, + // Determine the source string for logging + source := configFilePath + if source == "" { + // If configFilePath is empty, we're using HTTP + if fl, ok := loader.(*httpLoader); ok { + // Sanitize the URL for logging to avoid credential leakage + source = SanitizeURL(fl.url) + } + } + c := &Coordinator{ + loader: loader, + configFilePath: configFilePath, + configSource: source, + logger: l, } c.registerMetrics(r) @@ -124,33 +125,33 @@ func (c *Coordinator) Reload() error { c.mutex.Lock() defer c.mutex.Unlock() -c.logger.Info( - "Loading configuration", - "source", c.configSource, - ) + c.logger.Info( + "Loading configuration", + "source", c.configSource, + ) if err := c.loadFromFile(); err != nil { -c.logger.Error( - "Loading configuration failed", - "source", c.configSource, - "err", err, - ) + c.logger.Error( + "Loading configuration failed", + "source", c.configSource, + "err", err, + ) + c.configSuccessMetric.Set(0) + return err + } + c.logger.Info( + "Completed loading of configuration", + "source", c.configSource, + ) + + if err := c.notifySubscribers(); err != nil { + c.logger.Error( + "one or more config change subscribers failed to apply new config", + "source", c.configSource, + "err", err, + ) c.configSuccessMetric.Set(0) return err } -c.logger.Info( - "Completed loading of configuration", - "source", c.configSource, - ) - - if err := c.notifySubscribers(); err != nil { - c.logger.Error( - "one or more config change subscribers failed to apply new config", - "source", c.configSource, - "err", err, - ) - c.configSuccessMetric.Set(0) - return err - } c.configSuccessMetric.Set(1) c.configSuccessTimeMetric.SetToCurrentTime() diff --git a/config/coordinator_test.go b/config/coordinator_test.go index a28b1307e9..8ee7070c1b 100644 --- a/config/coordinator_test.go +++ b/config/coordinator_test.go @@ -47,8 +47,8 @@ func TestCoordinatorRegistersMetrics(t *testing.T) { } func TestCoordinatorNotifiesSubscribers(t *testing.T) { - callBackCalled := false - c := NewCoordinator(NewFileLoader("testdata/conf.good.yml"), "testdata/conf.good.yml", prometheus.NewRegistry(), promslog.NewNopLogger()) + callBackCalled := false + c := NewCoordinator(NewFileLoader("testdata/conf.good.yml"), "testdata/conf.good.yml", prometheus.NewRegistry(), promslog.NewNopLogger()) c.Subscribe(func(*Config) error { callBackCalled = true return nil @@ -65,8 +65,8 @@ func TestCoordinatorNotifiesSubscribers(t *testing.T) { } func TestCoordinatorFailReloadWhenSubscriberFails(t *testing.T) { - errMessage := "something happened" - c := NewCoordinator(NewFileLoader("testdata/conf.good.yml"), "testdata/conf.good.yml", prometheus.NewRegistry(), promslog.NewNopLogger()) + errMessage := "something happened" + c := NewCoordinator(NewFileLoader("testdata/conf.good.yml"), "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 index 4b8a81d7c7..1225f02973 100644 --- a/config/loader.go +++ b/config/loader.go @@ -2,17 +2,20 @@ package config import ( - "context" - "fmt" - "io" - "net/http" - "os" + "context" + "fmt" + "io" + "net/http" + "net/url" + "os" + "strings" + "time" ) // ConfigLoader abstracts where the raw configuration bytes come from. type ConfigLoader interface { - // Load returns the raw configuration bytes. - Load(ctx context.Context) ([]byte, error) + // Load returns the raw configuration bytes. + Load(ctx context.Context) ([]byte, error) } // fileLoader loads configuration from a local file. @@ -21,28 +24,92 @@ 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} } +// 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) { - return os.ReadFile(f.path) + 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 { + // Try to parse the URL to extract credentials + parsed, err := url.Parse(rawURL) + if err != nil { + // If parsing fails, just return the original URL + return rawURL + } + + // Redact password from URL + if parsed.User != nil { + password, _ := parsed.User.Password() + if password != "" { + // Replace password with *** + userInfo := strings.Replace(rawURL, password, "***", 1) + return userInfo + } + } + + // Redact query parameters that might contain secrets + if parsed.RawQuery != "" { + // This is a simple approach - in production you might want more sophisticated + // secret detection, but for logging purposes this provides basic protection + return strings.Replace(rawURL, parsed.RawQuery, "[redacted]", 1) + } + + return rawURL +} + // NewHTTPLoader creates a ConfigLoader that fetches the configuration from the given URL. func NewHTTPLoader(u string) ConfigLoader { return &httpLoader{url: u} } +// Load implements ConfigLoader for HTTP-based configuration. +// It fetches the configuration from the specified HTTP URL with proper timeouts +// and size limits. The URL is sanitized to prevent credential leakage in logs. +// Errors are wrapped to preserve the error chain for proper error handling. func (h *httpLoader) Load(ctx context.Context) ([]byte, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url, nil) - if err != nil { - return nil, err - } - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode) - } - return io.ReadAll(resp.Body) + // Create a client with timeout to prevent hanging + client := &http.Client{ + Timeout: 30 * time.Second, // 30 second timeout for the entire request + } + + 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 { + // Sanitize URL from error to avoid credential leakage in logs + sanitizedErr := fmt.Errorf("HTTP request failed: %w", err) + return nil, sanitizedErr + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode) + } + + // Limit response body size to prevent memory issues + // 10MB should be more than enough for any reasonable configuration + const maxConfigSize = 10 * 1024 * 1024 // 10MB + limitedReader := io.LimitReader(resp.Body, maxConfigSize) + data, err := io.ReadAll(limitedReader) + if err != nil { + return nil, fmt.Errorf("failed to read HTTP response body: %w", err) + } + + // Check if we hit the size limit + if len(data) >= maxConfigSize { + return nil, fmt.Errorf("configuration size exceeds maximum limit of %d bytes", maxConfigSize) + } + + return data, nil } diff --git a/config/loader_test.go b/config/loader_test.go index 403b48dbd4..4020d268ce 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -17,9 +17,6 @@ import ( "context" "net/http" "net/http/httptest" - "os" - "path/filepath" - "runtime" "testing" "github.com/prometheus/client_golang/prometheus" @@ -42,22 +39,11 @@ func TestFileLoader(t *testing.T) { }) 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() - badPath := filepath.Join(dir, "bad.yml") - f, _ := os.Create(badPath) - f.Close() - // On Windows, setting permissions to 0o000 may not prevent reading. - // Instead, we can simulate an unreadable file by using a non-existent path. - // Alternatively, we can skip this test on Windows. - // For now, we'll skip this test on Windows. - if runtime.GOOS == "windows" { - t.Skip("Skipping unreadable file test on Windows") - } - os.Chmod(badPath, 0o000) - loader := NewFileLoader(badPath) + loader := NewFileLoader(dir) // Directory paths cannot be read as files _, err := loader.Load(context.Background()) require.Error(t, err) - os.Chmod(badPath, 0o600) }) } From 8ccccaf181a8839d572cc48105e73ae33e0a6690 Mon Sep 17 00:00:00 2001 From: jshah-dev Date: Mon, 27 Jul 2026 17:42:21 +0530 Subject: [PATCH 3/5] Applying changes suggested in code review Signed-off-by: jshah-dev --- config/loader.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/config/loader.go b/config/loader.go index 1225f02973..a5048c485c 100644 --- a/config/loader.go +++ b/config/loader.go @@ -52,8 +52,7 @@ func SanitizeURL(rawURL string) string { password, _ := parsed.User.Password() if password != "" { // Replace password with *** - userInfo := strings.Replace(rawURL, password, "***", 1) - return userInfo + rawURL = strings.Replace(rawURL, password, "***", 1) } } @@ -61,7 +60,7 @@ func SanitizeURL(rawURL string) string { if parsed.RawQuery != "" { // This is a simple approach - in production you might want more sophisticated // secret detection, but for logging purposes this provides basic protection - return strings.Replace(rawURL, parsed.RawQuery, "[redacted]", 1) + rawURL = strings.Replace(rawURL, parsed.RawQuery, "[redacted]", 1) } return rawURL From ca956926531ceb9a297f1b5232ef7184612bccb2 Mon Sep 17 00:00:00 2001 From: jshah-dev Date: Tue, 28 Jul 2026 11:44:05 +0530 Subject: [PATCH 4/5] Applying changes suggested in code review Signed-off-by: jshah-dev --- app/http_config_test.go | 2 +- config/loader.go | 30 +++- config/loader_test.go | 344 +++++++++++++++++++++------------------- 3 files changed, 208 insertions(+), 168 deletions(-) diff --git a/app/http_config_test.go b/app/http_config_test.go index 511827c23e..a8cce9b44c 100644 --- a/app/http_config_test.go +++ b/app/http_config_test.go @@ -1,4 +1,4 @@ -// Copyright 2024 Prometheus Team +// 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 diff --git a/config/loader.go b/config/loader.go index a5048c485c..55479f8003 100644 --- a/config/loader.go +++ b/config/loader.go @@ -1,3 +1,16 @@ +// 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 @@ -51,15 +64,24 @@ func SanitizeURL(rawURL string) string { if parsed.User != nil { password, _ := parsed.User.Password() if password != "" { - // Replace password with *** - rawURL = strings.Replace(rawURL, password, "***", 1) + // Use Go's built-in URL redaction for percent-encoded passwords + if strings.Contains(password, "%") { + // This is a percent-encoded password, use Redacted() method + redactedURL := parsed.Redacted() + if redactedURL == "" { + // Fallback to manual replacement if Redacted() fails + redactedURL = strings.Replace(rawURL, password, "***", 1) + } + } else { + // Regular password, use manual replacement + rawURL = strings.Replace(rawURL, password, "***", 1) + } } } // Redact query parameters that might contain secrets if parsed.RawQuery != "" { - // This is a simple approach - in production you might want more sophisticated - // secret detection, but for logging purposes this provides basic protection + // This provides basic protection for logging. rawURL = strings.Replace(rawURL, parsed.RawQuery, "[redacted]", 1) } diff --git a/config/loader_test.go b/config/loader_test.go index 4020d268ce..14f786d7ca 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -1,163 +1,181 @@ -// Copyright 2024 Prometheus Team -// 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) - }) -} - -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, srv.URL, 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, srv.URL, 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) -} +// 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, srv.URL, 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, srv.URL, 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) +} From bb88443eb2022e45d4f3d5fff683a74e0d9561ae Mon Sep 17 00:00:00 2001 From: jshah-dev Date: Mon, 10 Aug 2026 16:24:20 +0530 Subject: [PATCH 5/5] addressing PR review feedback for HTTP config loading Signed-off-by: jshah-dev --- README.md | 2 +- app/app.go | 18 +- ...p_config_test.go => config_loader_test.go} | 0 app/lifecycle_test.go | 2 +- app/options.go | 2 +- app/options_test.go | 16 +- cmd/alertmanager/main.go | 26 +- config/coordinator.go | 52 +-- config/coordinator_test.go | 6 +- config/loader.go | 57 +-- config/loader_test.go | 362 +++++++++--------- docs/configuration.md | 4 +- 12 files changed, 262 insertions(+), 285 deletions(-) rename app/{http_config_test.go => config_loader_test.go} (100%) diff --git a/README.md b/README.md index ad26817d82..68ee3798e1 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ You can also load configuration from an HTTP endpoint: $ ./alertmanager --config.http-url=http://config-server/config.yaml ``` -Note: `--config.file` and `--config.http-url` are mutually exclusive - exactly one must be specified. +Note: `--config.file` defaults to `alertmanager.yml`. `--config.http-url` takes precedence when set. Do not pass both flags explicitly. ## Example diff --git a/app/app.go b/app/app.go index 475cc02f1a..09a045b1c2 100644 --- a/app/app.go +++ b/app/app.go @@ -251,12 +251,10 @@ func (a *App) setup() error { var loader config.ConfigLoader if opts.ConfigHTTPURL != "" { loader = config.NewHTTPLoader(opts.ConfigHTTPURL) - // Sanitize URL for logging to avoid credential leakage - sanitizedURL := config.SanitizeURL(opts.ConfigHTTPURL) - logger.Info("Starting Alertmanager in HTTP configuration mode", "source", sanitizedURL) + 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", opts.ConfigFile) + logger.Info("Starting Alertmanager in file configuration mode", "source", loader.Source()) } // Load config once for both event recorder initialization and the @@ -471,17 +469,7 @@ func (a *App) setup() error { }) configLogger := logger.With("component", "configuration") - if opts.ConfigHTTPURL != "" { - loader = config.NewHTTPLoader(opts.ConfigHTTPURL) - } else { - loader = config.NewFileLoader(opts.ConfigFile) - } - configCoordinator := config.NewCoordinator( - loader, - 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/http_config_test.go b/app/config_loader_test.go similarity index 100% rename from app/http_config_test.go rename to app/config_loader_test.go diff --git a/app/lifecycle_test.go b/app/lifecycle_test.go index cf060fc85a..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(config.NewFileLoader(configPath), 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 35a4e4812d..dfc5bf136c 100644 --- a/app/options.go +++ b/app/options.go @@ -114,7 +114,7 @@ type Options struct { // Flagger) and a WebConfig before passing the result to New or Run. func DefaultOptions() Options { return Options{ - ConfigFile: "", + ConfigFile: DefaultConfigFile, DataDir: DefaultDataDir, Retention: DefaultRetention, MaintenanceInterval: DefaultMaintenanceInterval, 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 3405ea70e2..48875e8802 100644 --- a/cmd/alertmanager/main.go +++ b/cmd/alertmanager/main.go @@ -48,8 +48,13 @@ func run() int { } var ( - configFile = kingpin.Flag("config.file", "Alertmanager configuration file name.").String() - configHTTPURL = kingpin.Flag("config.http-url", "Alertmanager configuration URL (mutually exclusive with --config.file).").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() @@ -98,14 +103,17 @@ func run() int { kingpin.CommandLine.GetFlag("help").Short('h') kingpin.Parse() - // Validate exactly one configuration source is provided. - if *configFile == "" && *configHTTPURL == "" { - kingpin.Fatalf("Need to configure one of the following --config.file or --config.http-url") - } - if *configFile != "" && *configHTTPURL != "" { + 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")) @@ -161,8 +169,8 @@ func run() int { }() opts := app.Options{ - ConfigFile: *configFile, - ConfigHTTPURL: *configHTTPURL, + ConfigFile: fileConfig, + ConfigHTTPURL: httpConfig, DataDir: *dataDir, Retention: *retention, MaintenanceInterval: *maintenanceInterval, diff --git a/config/coordinator.go b/config/coordinator.go index 0035d8e8b4..ad6bd49c96 100644 --- a/config/coordinator.go +++ b/config/coordinator.go @@ -28,10 +28,8 @@ import ( // Coordinator coordinates Alertmanager configurations beyond the lifetime of a // single configuration. type Coordinator struct { - loader ConfigLoader - configFilePath string - configSource string // Either file path or HTTP URL for logging - logger *slog.Logger + loader ConfigLoader + logger *slog.Logger // Protects config and subscribers mutex sync.Mutex @@ -43,24 +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(loader ConfigLoader, configFilePath string, r prometheus.Registerer, l *slog.Logger) *Coordinator { - // Determine the source string for logging - source := configFilePath - if source == "" { - // If configFilePath is empty, we're using HTTP - if fl, ok := loader.(*httpLoader); ok { - // Sanitize the URL for logging to avoid credential leakage - source = SanitizeURL(fl.url) - } - } +// 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{ - loader: loader, - configFilePath: configFilePath, - configSource: source, - logger: l, + loader: loader, + logger: l, } c.registerMetrics(r) @@ -105,8 +91,8 @@ func (c *Coordinator) notifySubscribers() error { return nil } -// loadFromFile triggers a configuration load, discarding the old configuration. -func (c *Coordinator) loadFromFile() error { +// 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 @@ -119,20 +105,21 @@ func (c *Coordinator) loadFromFile() error { 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", - "source", c.configSource, + "source", source, ) - if err := c.loadFromFile(); err != nil { + if err := c.loadFromSource(); err != nil { c.logger.Error( "Loading configuration failed", - "source", c.configSource, + "source", source, "err", err, ) c.configSuccessMetric.Set(0) @@ -140,13 +127,13 @@ func (c *Coordinator) Reload() error { } c.logger.Info( "Completed loading of configuration", - "source", c.configSource, + "source", source, ) if err := c.notifySubscribers(); err != nil { c.logger.Error( "one or more config change subscribers failed to apply new config", - "source", c.configSource, + "source", source, "err", err, ) c.configSuccessMetric.Set(0) @@ -162,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() @@ -175,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 8ee7070c1b..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(NewFileLoader("testdata/conf.good.yml"), "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(NewFileLoader("testdata/conf.good.yml"), "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(NewFileLoader("testdata/conf.good.yml"), "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 index 55479f8003..b7bc00f168 100644 --- a/config/loader.go +++ b/config/loader.go @@ -25,10 +25,14 @@ import ( "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. @@ -37,6 +41,9 @@ 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. @@ -53,52 +60,35 @@ type httpLoader struct{ url string } // SanitizeURL redacts any credentials from the URL for logging purposes. func SanitizeURL(rawURL string) string { - // Try to parse the URL to extract credentials parsed, err := url.Parse(rawURL) if err != nil { - // If parsing fails, just return the original URL return rawURL } - // Redact password from URL + sanitized := rawURL if parsed.User != nil { - password, _ := parsed.User.Password() - if password != "" { - // Use Go's built-in URL redaction for percent-encoded passwords - if strings.Contains(password, "%") { - // This is a percent-encoded password, use Redacted() method - redactedURL := parsed.Redacted() - if redactedURL == "" { - // Fallback to manual replacement if Redacted() fails - redactedURL = strings.Replace(rawURL, password, "***", 1) - } - } else { - // Regular password, use manual replacement - rawURL = strings.Replace(rawURL, password, "***", 1) - } - } + sanitized = parsed.Redacted() } - // Redact query parameters that might contain secrets if parsed.RawQuery != "" { - // This provides basic protection for logging. - rawURL = strings.Replace(rawURL, parsed.RawQuery, "[redacted]", 1) + sanitized = strings.Replace(sanitized, parsed.RawQuery, "[redacted]", 1) } - return rawURL + 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 proper timeouts -// and size limits. The URL is sanitized to prevent credential leakage in logs. +// 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) { - // Create a client with timeout to prevent hanging client := &http.Client{ - Timeout: 30 * time.Second, // 30 second timeout for the entire request + Timeout: defaultHTTPConfigTimeout, } req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.url, nil) @@ -108,9 +98,7 @@ func (h *httpLoader) Load(ctx context.Context) ([]byte, error) { resp, err := client.Do(req) if err != nil { - // Sanitize URL from error to avoid credential leakage in logs - sanitizedErr := fmt.Errorf("HTTP request failed: %w", err) - return nil, sanitizedErr + return nil, fmt.Errorf("HTTP request failed: %w", err) } defer resp.Body.Close() @@ -118,19 +106,10 @@ func (h *httpLoader) Load(ctx context.Context) ([]byte, error) { return nil, fmt.Errorf("unexpected HTTP status %d", resp.StatusCode) } - // Limit response body size to prevent memory issues - // 10MB should be more than enough for any reasonable configuration - const maxConfigSize = 10 * 1024 * 1024 // 10MB - limitedReader := io.LimitReader(resp.Body, maxConfigSize) - data, err := io.ReadAll(limitedReader) + data, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read HTTP response body: %w", err) } - // Check if we hit the size limit - if len(data) >= maxConfigSize { - return nil, fmt.Errorf("configuration size exceeds maximum limit of %d bytes", maxConfigSize) - } - return data, nil } diff --git a/config/loader_test.go b/config/loader_test.go index 14f786d7ca..c9e1faa375 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -1,181 +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, srv.URL, 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, srv.URL, 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) -} +// 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 ee2a658ea0..863d7a5fb9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -45,7 +45,7 @@ Alternatively, you can load configuration from an HTTP endpoint using the `--con ./alertmanager --config.http-url=http://config-server/config.yaml ``` -Note: `--config.file` and `--config.http-url` are mutually exclusive - exactly one must be specified. +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), @@ -66,7 +66,7 @@ The HTTP endpoint must: - Respond with HTTP 200 status code - Be accessible from the Alertmanager process -Note: The `--config.file` and `--config.http-url` flags are mutually exclusive. Exactly one configuration source must be specified. +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.