// 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 ( "bytes" "context" "errors" "fmt" "io" "net" "net/http" "os" "os/exec" "path/filepath" "regexp" "strings" "sync" "syscall" "testing" "time" httptransport "github.com/go-openapi/runtime/client" "github.com/go-openapi/strfmt" apiclient "github.com/prometheus/alertmanager/api/v2/client" "github.com/prometheus/alertmanager/api/v2/client/general" "github.com/prometheus/alertmanager/api/v2/models" "github.com/prometheus/alertmanager/cli/format" ) const ( // nolint:godot // amtool is the relative path to local amtool binary. amtool = "../../../amtool" ) // AcceptanceTest provides declarative definition of given inputs and expected // output of an Alertmanager setup. type AcceptanceTest struct { *testing.T opts *AcceptanceOpts amc *AlertmanagerCluster collectors []*Collector actions map[float64][]func() } // AcceptanceOpts defines configuration parameters for an acceptance test. type AcceptanceOpts struct { RoutePrefix string Tolerance time.Duration baseTime time.Time } func (opts *AcceptanceOpts) alertString(a *models.GettableAlert) string { if a.EndsAt == nil || time.Time(*a.EndsAt).IsZero() { return fmt.Sprintf("%v[%v:]", a, opts.relativeTime(time.Time(*a.StartsAt))) } return fmt.Sprintf("%v[%v:%v]", a, opts.relativeTime(time.Time(*a.StartsAt)), opts.relativeTime(time.Time(*a.EndsAt))) } // expandTime returns the absolute time for the relative time // calculated from the test's base time. func (opts *AcceptanceOpts) expandTime(rel float64) time.Time { return opts.baseTime.Add(time.Duration(rel * float64(time.Second))) } // expandTime returns the relative time for the given time // calculated from the test's base time. func (opts *AcceptanceOpts) relativeTime(act time.Time) float64 { return float64(act.Sub(opts.baseTime)) / float64(time.Second) } // NewAcceptanceTest returns a new acceptance test with the base time // set to the current time. func NewAcceptanceTest(t *testing.T, opts *AcceptanceOpts) *AcceptanceTest { test := &AcceptanceTest{ T: t, opts: opts, actions: map[float64][]func(){}, } return test } // freeAddress returns a new listen address not currently in use. func freeAddress() string { // Let the OS allocate a free address, close it and hope // it is still free when starting Alertmanager. l, err := net.Listen("tcp4", "localhost:0") if err != nil { panic(err) } defer func() { if err := l.Close(); err != nil { panic(err) } }() return l.Addr().String() } // 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 } // Do sets the given function to be executed at the given time. func (t *AcceptanceTest) Do(at float64, f func()) { t.actions[at] = append(t.actions[at], f) } // AlertmanagerCluster returns a new AlertmanagerCluster that allows starting a // cluster of Alertmanager instances on random ports. func (t *AcceptanceTest) AlertmanagerCluster(conf string, size int) *AlertmanagerCluster { amc := AlertmanagerCluster{} for i := 0; i < size; i++ { am := &Alertmanager{ t: t, opts: t.opts, } dir, err := os.MkdirTemp("", "am_test") if err != nil { t.Fatal(err) } am.dir = dir cf, err := os.Create(filepath.Join(dir, "config.yml")) if err != nil { t.Fatal(err) } am.confFile = cf am.UpdateConfig(conf) am.apiAddr = freeAddress() am.clusterAddr = freeAddress() transport := httptransport.New(am.apiAddr, t.opts.RoutePrefix+"/api/v2/", nil) am.clientV2 = apiclient.New(transport, strfmt.Default) amc.ams = append(amc.ams, am) } t.amc = &amc return &amc } // Collector returns a new collector bound to the test instance. func (t *AcceptanceTest) Collector(name string) *Collector { co := &Collector{ t: t.T, name: name, opts: t.opts, collected: map[float64][]models.GettableAlerts{}, expected: map[Interval][]models.GettableAlerts{}, } t.collectors = append(t.collectors, co) return co } // Run starts all Alertmanagers and runs queries against them. It then checks // whether all expected notifications have arrived at the expected receiver. func (t *AcceptanceTest) Run() { errc := make(chan error) for _, am := range t.amc.ams { am.errc = errc defer func(am *Alertmanager) { am.Terminate() am.cleanup() t.Logf("stdout:\n%v", am.cmd.Stdout) t.Logf("stderr:\n%v", am.cmd.Stderr) }(am) } err := t.amc.Start() if err != nil { t.T.Fatal(err) } // Set the reference time right before running the test actions to avoid // test failures due to slow setup of the test environment. t.opts.baseTime = time.Now() go t.runActions() var latest float64 for _, coll := range t.collectors { if l := coll.latest(); l > latest { latest = l } } deadline := t.opts.expandTime(latest) select { case <-time.After(time.Until(deadline)): // continue case err := <-errc: t.Error(err) } } // runActions performs the stored actions at the defined times. func (t *AcceptanceTest) runActions() { var wg sync.WaitGroup for at, fs := range t.actions { ts := t.opts.expandTime(at) wg.Add(len(fs)) for _, f := range fs { go func(f func()) { time.Sleep(time.Until(ts)) f() wg.Done() }(f) } } wg.Wait() } type buffer struct { b bytes.Buffer mtx sync.Mutex } func (b *buffer) Write(p []byte) (int, error) { b.mtx.Lock() defer b.mtx.Unlock() return b.b.Write(p) } func (b *buffer) String() string { b.mtx.Lock() defer b.mtx.Unlock() return b.b.String() } // Alertmanager encapsulates an Alertmanager process and allows // declaring alerts being pushed to it at fixed points in time. type Alertmanager struct { t *AcceptanceTest opts *AcceptanceOpts apiAddr string clusterAddr string clientV2 *apiclient.AlertmanagerAPI cmd *exec.Cmd confFile *os.File dir string errc chan<- error } // AlertmanagerCluster represents a group of Alertmanager instances // acting as a cluster. type AlertmanagerCluster struct { ams []*Alertmanager } // Start the Alertmanager cluster and wait until it is ready to receive. func (amc *AlertmanagerCluster) Start() error { var peerFlags []string for _, am := range amc.ams { peerFlags = append(peerFlags, "--cluster.peer="+am.clusterAddr) } for _, am := range amc.ams { err := am.Start(peerFlags) if err != nil { return fmt.Errorf("starting alertmanager cluster: %w", err) } } for _, am := range amc.ams { err := am.WaitForCluster(len(amc.ams)) if err != nil { return fmt.Errorf("waiting alertmanager cluster: %w", err) } } return nil } // Members returns the underlying slice of cluster members. func (amc *AlertmanagerCluster) Members() []*Alertmanager { return amc.ams } // Start the alertmanager and wait until it is ready to receive. func (am *Alertmanager) Start(additionalArg []string) error { am.t.Helper() args := []string{ "--config.file", am.confFile.Name(), "--log.level", "debug", "--web.listen-address", am.apiAddr, "--storage.path", am.dir, "--cluster.listen-address", am.clusterAddr, "--cluster.settle-timeout", "0s", } if am.opts.RoutePrefix != "" { args = append(args, "--web.route-prefix", am.opts.RoutePrefix) } args = append(args, additionalArg...) cmd := exec.Command("../../../alertmanager", args...) if am.cmd == nil { var outb, errb buffer cmd.Stdout = &outb cmd.Stderr = &errb } else { cmd.Stdout = am.cmd.Stdout cmd.Stderr = am.cmd.Stderr } am.cmd = cmd if err := am.cmd.Start(); err != nil { return fmt.Errorf("starting alertmanager failed: %w", err) } go func() { if err := am.cmd.Wait(); err != nil { am.errc <- err } }() time.Sleep(50 * time.Millisecond) for i := 0; i < 10; i++ { resp, err := http.Get(am.getURL("/")) if err != nil { time.Sleep(500 * time.Millisecond) continue } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return fmt.Errorf("starting alertmanager failed: expected HTTP status '200', got '%d'", resp.StatusCode) } _, err = io.ReadAll(resp.Body) if err != nil { return fmt.Errorf("starting alertmanager failed: %w", err) } return nil } return fmt.Errorf("starting alertmanager failed: timeout") } // WaitForCluster waits for the Alertmanager instance to join a cluster with the // given size. func (am *Alertmanager) WaitForCluster(size int) error { params := general.NewGetStatusParams() params.WithContext(context.Background()) var status general.GetStatusOK // Poll for 2s for i := 0; i < 20; i++ { status, err := am.clientV2.General.GetStatus(params) if err != nil { return err } if len(status.Payload.Cluster.Peers) == size { return nil } time.Sleep(100 * time.Millisecond) } return fmt.Errorf( "failed to wait for Alertmanager instance %q to join cluster: expected %v peers, but got %v", am.clusterAddr, size, len(status.Payload.Cluster.Peers), ) } // Terminate kills the underlying Alertmanager cluster processes and removes intermediate // data. func (amc *AlertmanagerCluster) Terminate() { for _, am := range amc.ams { am.Terminate() } } // Terminate kills the underlying Alertmanager process and remove intermediate // data. func (am *Alertmanager) Terminate() { am.t.Helper() if err := syscall.Kill(am.cmd.Process.Pid, syscall.SIGTERM); err != nil { am.t.Fatalf("Error sending SIGTERM to Alertmanager process: %v", err) } } // Reload sends the reloading signal to the Alertmanager instances. func (amc *AlertmanagerCluster) Reload() { for _, am := range amc.ams { am.Reload() } } // Reload sends the reloading signal to the Alertmanager process. func (am *Alertmanager) Reload() { am.t.Helper() if err := syscall.Kill(am.cmd.Process.Pid, syscall.SIGHUP); err != nil { am.t.Fatalf("Error sending SIGHUP to Alertmanager process: %v", err) } } func (am *Alertmanager) cleanup() { am.t.Helper() if err := os.RemoveAll(am.confFile.Name()); err != nil { am.t.Errorf("Error removing test config file %q: %v", am.confFile.Name(), err) } } // 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 } // 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)) for k, v := range alert.labels { labels[k] = v } 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.ams { 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.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.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.ams { 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() } // UpdateConfig rewrites the configuration file for the Alertmanager cluster. It // does not initiate config reloading. func (amc *AlertmanagerCluster) UpdateConfig(conf string) { for _, am := range amc.ams { am.UpdateConfig(conf) } } // UpdateConfig rewrites the configuration file for the Alertmanager. It does not // initiate config reloading. func (am *Alertmanager) UpdateConfig(conf string) { if _, err := am.confFile.WriteString(conf); err != nil { am.t.Fatal(err) return } if err := am.confFile.Sync(); err != nil { am.t.Fatal(err) return } } 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() } 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) }