1
0
mirror of https://github.com/prometheus/alertmanager.git synced 2026-02-05 15:45:34 +01:00
Files
alertmanager/test/cli/acceptance.go
Guido Trotter 8098e2275e Fix alertmanager test port allocation race conditions (#4768)
At the moment we try to allocate ports in the tests, the close them, and
then start alertmanager on those ports. This is very brittle and often
fails.

Fix the race conditions by directly starting alertmanager on
system-allocated free ports (using :0 in the address) and then detecting
the ports used, and using those in the test.

Signed-off-by: Guido Trotter <guido@hudson-trading.com>
Co-authored-by: Guido Trotter <guido@hudson-trading.com>
2025-12-02 12:10:34 -08:00

379 lines
13 KiB
Go

// Copyright 2019 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 test
import (
"errors"
"fmt"
"maps"
"os"
"os/exec"
"regexp"
"strings"
"testing"
"time"
"github.com/go-openapi/strfmt"
"github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/alertmanager/cli/format"
"github.com/prometheus/alertmanager/test/testutils"
)
const (
// nolint:godot
// amtool is the relative path to local amtool binary.
amtool = "../../../amtool"
)
// Re-export common types from testutils.
type (
Collector = testutils.Collector
AcceptanceOpts = testutils.AcceptanceOpts
)
var CompareCollectors = testutils.CompareCollectors
// AcceptanceTest wraps testutils.AcceptanceTest for CLI-based testing.
type AcceptanceTest struct {
*testutils.AcceptanceTest
}
// NewAcceptanceTest returns a new acceptance test.
func NewAcceptanceTest(t *testing.T, opts *AcceptanceOpts) *AcceptanceTest {
return &AcceptanceTest{
AcceptanceTest: testutils.NewAcceptanceTest(t, opts),
}
}
// AmtoolOk verifies that the "amtool" file exists in the correct location for testing,
// and is a regular file.
func AmtoolOk() (bool, error) {
stat, err := os.Stat(amtool)
if err != nil {
return false, fmt.Errorf("error accessing amtool command, try 'make build' to generate the file. %w", err)
} else if stat.IsDir() {
return false, fmt.Errorf("file %s is a directory, expecting a binary executable file", amtool)
}
return true, nil
}
// Alertmanager wraps testutils.Alertmanager and adds CLI-specific methods.
type Alertmanager struct {
*testutils.Alertmanager
}
// AlertmanagerCluster wraps testutils.AlertmanagerCluster and adds CLI-specific methods.
type AlertmanagerCluster struct {
*testutils.AlertmanagerCluster
}
// AlertmanagerCluster returns a new AlertmanagerCluster.
func (t *AcceptanceTest) AlertmanagerCluster(conf string, size int) *AlertmanagerCluster {
return &AlertmanagerCluster{
AlertmanagerCluster: t.AcceptanceTest.AlertmanagerCluster(conf, size),
}
}
// Members returns the underlying Alertmanager instances wrapped for CLI testing.
func (amc *AlertmanagerCluster) Members() []*Alertmanager {
baseMembers := amc.AlertmanagerCluster.Members()
wrapped := make([]*Alertmanager, len(baseMembers))
for i, am := range baseMembers {
wrapped[i] = &Alertmanager{Alertmanager: am}
}
return wrapped
}
// AddAlertsAt declares alerts that are to be added to the Alertmanager server
// at a relative point in time.
func (am *Alertmanager) AddAlertsAt(omitEquals bool, at float64, alerts ...*TestAlert) {
am.T.Do(at, func() {
am.AddAlerts(omitEquals, alerts...)
})
}
// AddAlerts declares alerts that are to be added to the Alertmanager server.
// The omitEquals option omits alertname= from the command line args passed to
// amtool and instead uses the alertname value as the first argument to the command.
// For example `amtool alert add foo` instead of `amtool alert add alertname=foo`.
// This has been added to allow certain tests to test adding alerts both with and
// without alertname=. All other tests that use AddAlerts as a fixture can set this
// to false.
func (am *Alertmanager) AddAlerts(omitEquals bool, alerts ...*TestAlert) {
for _, alert := range alerts {
out, err := am.addAlertCommand(omitEquals, alert)
if err != nil {
am.T.Errorf("Error adding alert: %v\nOutput: %s", err, string(out))
}
}
}
func (am *Alertmanager) addAlertCommand(omitEquals bool, alert *TestAlert) ([]byte, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := []string{amURLFlag, "alert", "add"}
// Make a copy of the labels
labels := make(models.LabelSet, len(alert.Labels))
maps.Copy(labels, alert.Labels)
if omitEquals {
// If alertname is present and omitEquals is true then the command should
// be `amtool alert add foo ...` and not `amtool alert add alertname=foo ...`.
if alertname, ok := labels["alertname"]; ok {
args = append(args, alertname)
delete(labels, "alertname")
}
}
for k, v := range labels {
args = append(args, k+"="+v)
}
startsAt := strfmt.DateTime(am.Opts.ExpandTime(alert.StartsAt))
args = append(args, "--start="+startsAt.String())
if alert.EndsAt > alert.StartsAt {
endsAt := strfmt.DateTime(am.Opts.ExpandTime(alert.EndsAt))
args = append(args, "--end="+endsAt.String())
}
cmd := exec.Command(amtool, args...)
return cmd.CombinedOutput()
}
// QueryAlerts uses the amtool cli to query alerts.
func (am *Alertmanager) QueryAlerts(match ...string) ([]TestAlert, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := append([]string{amURLFlag, "alert", "query"}, match...)
cmd := exec.Command(amtool, args...)
output, err := cmd.CombinedOutput()
if err != nil {
return nil, err
}
return parseAlertQueryResponse(output)
}
func parseAlertQueryResponse(data []byte) ([]TestAlert, error) {
alerts := []TestAlert{}
lines := strings.Split(string(data), "\n")
header, lines := lines[0], lines[1:len(lines)-1]
startTimePos := strings.Index(header, "Starts At")
if startTimePos == -1 {
return alerts, errors.New("Invalid header: " + header)
}
summPos := strings.Index(header, "Summary")
if summPos == -1 {
return alerts, errors.New("Invalid header: " + header)
}
for _, line := range lines {
alertName := strings.TrimSpace(line[0:startTimePos])
startTime := strings.TrimSpace(line[startTimePos:summPos])
startsAt, err := time.Parse(format.DefaultDateFormat, startTime)
if err != nil {
return alerts, err
}
summary := strings.TrimSpace(line[summPos:])
alert := TestAlert{
Labels: models.LabelSet{"alertname": alertName},
StartsAt: float64(startsAt.Unix()),
Summary: summary,
}
alerts = append(alerts, alert)
}
return alerts, nil
}
// SetSilence updates or creates the given Silence.
func (amc *AlertmanagerCluster) SetSilence(at float64, sil *TestSilence) {
for _, am := range amc.Members() {
am.SetSilence(at, sil)
}
}
// SetSilence updates or creates the given Silence.
func (am *Alertmanager) SetSilence(at float64, sil *TestSilence) {
out, err := am.addSilenceCommand(sil)
if err != nil {
am.T.Errorf("Unable to set silence %v %v", err, string(out))
}
}
// addSilenceCommand adds a silence using the 'amtool silence add' command.
func (am *Alertmanager) addSilenceCommand(sil *TestSilence) ([]byte, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := []string{amURLFlag, "silence", "add"}
if sil.comment != "" {
args = append(args, "--comment="+sil.comment)
}
args = append(args, sil.match...)
cmd := exec.Command(amtool, args...)
return cmd.CombinedOutput()
}
// QuerySilence queries the current silences using the 'amtool silence query' command.
func (am *Alertmanager) QuerySilence(match ...string) ([]TestSilence, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := append([]string{amURLFlag, "silence", "query"}, match...)
cmd := exec.Command(amtool, args...)
out, err := cmd.CombinedOutput()
if err != nil {
am.T.Error("Silence query command failed: ", err)
}
return parseSilenceQueryResponse(out)
}
// QueryExpiredSilence queries expired silences using the 'amtool silence query --expired --within' command.
func (am *Alertmanager) QueryExpiredSilence(match ...string) ([]TestSilence, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := append([]string{amURLFlag, "silence", "query", "--expired", "--within=1h"}, match...)
cmd := exec.Command(amtool, args...)
out, err := cmd.CombinedOutput()
if err != nil {
am.T.Error("Silence query command failed: ", err)
}
return parseSilenceQueryResponse(out)
}
var silenceHeaderFields = []string{"ID", "Matchers", "Ends At", "Created By", "Comment"}
func parseSilenceQueryResponse(data []byte) ([]TestSilence, error) {
sils := []TestSilence{}
lines := strings.Split(string(data), "\n")
header, lines := lines[0], lines[1:len(lines)-1]
matchersPos := strings.Index(header, silenceHeaderFields[1])
if matchersPos == -1 {
return sils, errors.New("Invalid header: " + header)
}
endsAtPos := strings.Index(header, silenceHeaderFields[2])
if endsAtPos == -1 {
return sils, errors.New("Invalid header: " + header)
}
createdByPos := strings.Index(header, silenceHeaderFields[3])
if createdByPos == -1 {
return sils, errors.New("Invalid header: " + header)
}
commentPos := strings.Index(header, silenceHeaderFields[4])
if commentPos == -1 {
return sils, errors.New("Invalid header: " + header)
}
for _, line := range lines {
id := strings.TrimSpace(line[0:matchersPos])
matchers := strings.TrimSpace(line[matchersPos:endsAtPos])
endsAtString := strings.TrimSpace(line[endsAtPos:createdByPos])
endsAt, err := time.Parse(format.DefaultDateFormat, endsAtString)
if err != nil {
return sils, err
}
createdBy := strings.TrimSpace(line[createdByPos:commentPos])
comment := strings.TrimSpace(line[commentPos:])
silence := TestSilence{
id: id,
endsAt: float64(endsAt.Unix()),
match: strings.Split(matchers, " "),
createdBy: createdBy,
comment: comment,
}
sils = append(sils, silence)
}
return sils, nil
}
// DelSilence deletes the silence with the sid at the given time.
func (amc *AlertmanagerCluster) DelSilence(at float64, sil *TestSilence) {
for _, am := range amc.Members() {
am.DelSilence(at, sil)
}
}
// DelSilence deletes the silence with the sid at the given time.
func (am *Alertmanager) DelSilence(at float64, sil *TestSilence) {
output, err := am.expireSilenceCommand(sil)
if err != nil {
am.T.Errorf("Error expiring silence %v: %s", string(output), err)
return
}
}
// expireSilenceCommand expires a silence using the 'amtool silence expire' command.
func (am *Alertmanager) expireSilenceCommand(sil *TestSilence) ([]byte, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := []string{amURLFlag, "silence", "expire", sil.ID()}
cmd := exec.Command(amtool, args...)
return cmd.CombinedOutput()
}
// ExportSilences exports all silences to JSON format using 'amtool silence query -o json'.
func (am *Alertmanager) ExportSilences() ([]byte, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := []string{amURLFlag, "silence", "query", "-o", "json"}
cmd := exec.Command(amtool, args...)
return cmd.Output()
}
// ImportSilences imports silences from a JSON file using 'amtool silence import'.
func (am *Alertmanager) ImportSilences(filename string) ([]byte, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := []string{amURLFlag, "silence", "import", filename}
cmd := exec.Command(amtool, args...)
return cmd.CombinedOutput()
}
// ExpireSilenceByID expires a silence by its ID using 'amtool silence expire'.
func (am *Alertmanager) ExpireSilenceByID(id string) ([]byte, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := []string{amURLFlag, "silence", "expire", id}
cmd := exec.Command(amtool, args...)
return cmd.CombinedOutput()
}
// ShowRoute shows the routing tree using 'amtool config routes show'.
func (am *Alertmanager) ShowRoute() ([]byte, error) {
return am.showRouteCommand()
}
func (am *Alertmanager) showRouteCommand() ([]byte, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := []string{amURLFlag, "config", "routes", "show"}
cmd := exec.Command(amtool, args...)
return cmd.CombinedOutput()
}
// TestRoute tests label matching against the routing tree using 'amtool config routes test'.
func (am *Alertmanager) TestRoute(labels ...string) ([]byte, error) {
return am.testRouteCommand(labels...)
}
func (am *Alertmanager) testRouteCommand(labels ...string) ([]byte, error) {
amURLFlag := "--alertmanager.url=" + am.getURL("/")
args := append([]string{amURLFlag, "config", "routes", "test"}, labels...)
cmd := exec.Command(amtool, args...)
return cmd.CombinedOutput()
}
func (am *Alertmanager) getURL(path string) string {
return fmt.Sprintf("http://%s%s%s", am.APIAddr(), am.Opts.RoutePrefix, path)
}
// Version runs the 'amtool' command with the --version option and checks
// for appropriate output.
func Version() (string, error) {
cmd := exec.Command(amtool, "--version")
out, err := cmd.CombinedOutput()
if err != nil {
return "", err
}
versionRE := regexp.MustCompile(`^amtool, version (\d+\.\d+\.\d+) *`)
matched := versionRE.FindStringSubmatch(string(out))
if len(matched) != 2 {
return "", errors.New("Unable to match version info regex: " + string(out))
}
return matched[1], nil
}