Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ You can also build just one of the binaries in this repo by passing a name to th
$ make build BINARIES=amtool
```

You can also load configuration from an HTTP endpoint:
```
$ ./alertmanager --config.http-url=http://config-server/config.yaml
```

Note: `--config.file` defaults to `alertmanager.yml`. `--config.http-url` takes precedence when set. Do not pass both flags explicitly.

## Example

This is an example configuration that should cover most relevant aspects of the new YAML configuration format. The full documentation of the configuration can be found [here](https://prometheus.io/docs/alerting/configuration/).
Expand Down
20 changes: 14 additions & 6 deletions app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,23 @@ func (a *App) setup() error {

stopc := make(chan struct{})
var wg sync.WaitGroup
var loader config.ConfigLoader
if opts.ConfigHTTPURL != "" {
loader = config.NewHTTPLoader(opts.ConfigHTTPURL)
logger.Info("Starting Alertmanager in HTTP configuration mode", "source", loader.Source())
} else {
loader = config.NewFileLoader(opts.ConfigFile)
logger.Info("Starting Alertmanager in file configuration mode", "source", loader.Source())
}

// Load config once for both event recorder initialization and the
// first coordinator apply. Subsequent reloads go through
// configCoordinator.Reload() which reads the file again.
initialConf, err := config.LoadFile(opts.ConfigFile)
data, err := loader.Load(context.Background())
if err != nil {
return fmt.Errorf("error loading configuration: %w", err)
}
initialConf, err := config.Load(string(data))
if err != nil {
return fmt.Errorf("error loading configuration file: %w", err)
}
Expand Down Expand Up @@ -457,11 +469,7 @@ func (a *App) setup() error {
})

configLogger := logger.With("component", "configuration")
configCoordinator := config.NewCoordinator(
opts.ConfigFile,
reg,
configLogger,
)
configCoordinator := config.NewCoordinator(loader, reg, configLogger)
a.coordinator = configCoordinator

// The reloader owns the config-scoped subgraph (templates, routes,
Expand Down
195 changes: 195 additions & 0 deletions app/config_loader_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Copyright The Prometheus Authors

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nitpick: this seems to be tests for all sorts of config loading, not just http_config - I think it would make more sense for this file to be called config_loader_test.go or something along those lines.

// 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:
}
Comment on lines +73 to +82

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Observe the app.Start result in both startup tests.

The context is canceled only in deferred cleanup. Both select statements therefore take default immediately. These tests pass even when app.Start() fails.

  • app/config_loader_test.go#L73-L82: send the app.Start() result to a channel and fail if it returns an error or does not complete before a bounded test deadline.
  • app/config_loader_test.go#L122-L130: apply the same result check to the file-source startup test.
📍 Affects 1 file
  • app/config_loader_test.go#L73-L82 (this comment)
  • app/config_loader_test.go#L122-L130
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/config_loader_test.go` around lines 73 - 82, Update both startup tests in
app/config_loader_test.go at lines 73-82 and 122-130 to observe the result of
app.Start: run it in a goroutine, send its returned error through a channel, and
fail on a non-nil error or when no result arrives before a bounded test
deadline; do not rely on the immediately-ready default branch of the current
context select.

}

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)
}
}
2 changes: 1 addition & 1 deletion app/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ func TestApp_reloadRouterClosedReloadChannel(t *testing.T) {
require.NoError(t, os.WriteFile(configPath, []byte(minimalConfig), 0o600))

var reloads atomic.Int64
coord := config.NewCoordinator(configPath, prometheus.NewRegistry(), promslog.NewNopLogger())
coord := config.NewCoordinator(config.NewFileLoader(configPath), prometheus.NewRegistry(), promslog.NewNopLogger())
coord.Subscribe(func(*config.Config) error {
reloads.Add(1)
return nil
Expand Down
10 changes: 8 additions & 2 deletions app/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ const (
// fields default to their zero value (which generally matches the kingpin
// flag default).
type Options struct {
// ConfigHTTPURL specifies the HTTP URL to load the Alertmanager configuration from.
// It is mutually exclusive with ConfigFile - exactly one must be specified.
ConfigHTTPURL string
Comment thread
coderabbitai[bot] marked this conversation as resolved.
// Storage and lifecycle.
ConfigFile string
DataDir string
Expand Down Expand Up @@ -158,8 +161,11 @@ func (o *Options) validate() error {
}

// Storage and config paths.
if o.ConfigFile == "" {
return errors.New("alertmanager/app: Options.ConfigFile is required")
if o.ConfigFile == "" && o.ConfigHTTPURL == "" {
return errors.New("alertmanager/app: exactly one of Options.ConfigFile or Options.ConfigHTTPURL must be set")
}
if o.ConfigFile != "" && o.ConfigHTTPURL != "" {
return errors.New("alertmanager/app: Options.ConfigFile and Options.ConfigHTTPURL are mutually exclusive")
}
if o.DataDir == "" {
return errors.New("alertmanager/app: Options.DataDir is required")
Expand Down
16 changes: 15 additions & 1 deletion app/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }},
Expand Down Expand Up @@ -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{
Expand Down
22 changes: 20 additions & 2 deletions cmd/alertmanager/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@ func run() int {
}

var (
configFile = kingpin.Flag("config.file", "Alertmanager configuration file name.").Default("alertmanager.yml").String()
configFileSet bool
configHTTPURLSet bool
)

var (
configFile = kingpin.Flag("config.file", "Alertmanager configuration file name.").Default(app.DefaultConfigFile).IsSetByUser(&configFileSet).String()
configHTTPURL = kingpin.Flag("config.http-url", "Alertmanager configuration URL (mutually exclusive with --config.file).").IsSetByUser(&configHTTPURLSet).String()
dataDir = kingpin.Flag("storage.path", "Base path for data storage.").Default("data/").String()
retention = kingpin.Flag("data.retention", "How long to keep data for.").Default("120h").Duration()
maintenanceInterval = kingpin.Flag("data.maintenance-interval", "Interval between garbage collection and snapshotting to disk of the silences and the notification logs.").Default("15m").Duration()
Expand Down Expand Up @@ -97,6 +103,17 @@ func run() int {
kingpin.CommandLine.GetFlag("help").Short('h')
kingpin.Parse()

if configFileSet && configHTTPURLSet {
kingpin.Fatalf("Need to configure only one of the following --config.file or --config.http-url")
}

var fileConfig, httpConfig string
if *configHTTPURL != "" {
httpConfig = *configHTTPURL
} else {
fileConfig = *configFile
}

logger := promslog.New(&promslogConfig)

prometheus.MustRegister(versioncollector.NewCollector("alertmanager"))
Expand Down Expand Up @@ -152,7 +169,8 @@ func run() int {
}()

opts := app.Options{
ConfigFile: *configFile,
ConfigFile: fileConfig,
ConfigHTTPURL: httpConfig,
DataDir: *dataDir,
Retention: *retention,
MaintenanceInterval: *maintenanceInterval,
Expand Down
Loading