Alerting: Manage remote Alertmanager silences (#75452)

* Alerting: Manage remote Alertmanager silences

* fix typo

* check errors when encoding json in fake external AM

* take path from configured URL, check for nil responses
This commit is contained in:
Santiago 2023-10-02 07:36:11 -03:00 committed by GitHub
parent ec774c901a
commit 73be9449d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 311 additions and 32 deletions

View File

@ -75,7 +75,7 @@ func (srv AlertmanagerSrv) RouteCreateSilence(c *contextmodel.ReqContext, postab
return ErrResp(http.StatusUnauthorized, fmt.Errorf("user is not authorized to %s silences", errAction), "") return ErrResp(http.StatusUnauthorized, fmt.Errorf("user is not authorized to %s silences", errAction), "")
} }
silenceID, err := am.CreateSilence(&postableSilence) silenceID, err := am.CreateSilence(c.Req.Context(), &postableSilence)
if err != nil { if err != nil {
if errors.Is(err, alertingNotify.ErrSilenceNotFound) { if errors.Is(err, alertingNotify.ErrSilenceNotFound) {
return ErrResp(http.StatusNotFound, err, "") return ErrResp(http.StatusNotFound, err, "")
@ -112,7 +112,7 @@ func (srv AlertmanagerSrv) RouteDeleteSilence(c *contextmodel.ReqContext, silenc
return errResp return errResp
} }
if err := am.DeleteSilence(silenceID); err != nil { if err := am.DeleteSilence(c.Req.Context(), silenceID); err != nil {
if errors.Is(err, alertingNotify.ErrSilenceNotFound) { if errors.Is(err, alertingNotify.ErrSilenceNotFound) {
return ErrResp(http.StatusNotFound, err, "") return ErrResp(http.StatusNotFound, err, "")
} }
@ -199,7 +199,7 @@ func (srv AlertmanagerSrv) RouteGetSilence(c *contextmodel.ReqContext, silenceID
return errResp return errResp
} }
gettableSilence, err := am.GetSilence(silenceID) gettableSilence, err := am.GetSilence(c.Req.Context(), silenceID)
if err != nil { if err != nil {
if errors.Is(err, alertingNotify.ErrSilenceNotFound) { if errors.Is(err, alertingNotify.ErrSilenceNotFound) {
return ErrResp(http.StatusNotFound, err, "") return ErrResp(http.StatusNotFound, err, "")
@ -216,7 +216,7 @@ func (srv AlertmanagerSrv) RouteGetSilences(c *contextmodel.ReqContext) response
return errResp return errResp
} }
gettableSilences, err := am.ListSilences(c.QueryStrings("filter")) gettableSilences, err := am.ListSilences(c.Req.Context(), c.QueryStrings("filter"))
if err != nil { if err != nil {
if errors.Is(err, alertingNotify.ErrListSilencesBadPayload) { if errors.Is(err, alertingNotify.ErrListSilencesBadPayload) {
return ErrResp(http.StatusBadRequest, err, "") return ErrResp(http.StatusBadRequest, err, "")

View File

@ -617,7 +617,7 @@ func TestRouteCreateSilence(t *testing.T) {
alertmanagerFor, err := sut.mam.AlertmanagerFor(1) alertmanagerFor, err := sut.mam.AlertmanagerFor(1)
require.NoError(t, err) require.NoError(t, err)
silence.ID = "" silence.ID = ""
newID, err := alertmanagerFor.CreateSilence(&silence) newID, err := alertmanagerFor.CreateSilence(context.Background(), &silence)
require.NoError(t, err) require.NoError(t, err)
silence.ID = newID silence.ID = newID
} }

View File

@ -2,15 +2,17 @@ package notifier
import ( import (
"context" "context"
"errors" "fmt"
"net/http" "net/http"
"net/url" "net/url"
httptransport "github.com/go-openapi/runtime/client" httptransport "github.com/go-openapi/runtime/client"
"github.com/go-openapi/strfmt"
"github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/log"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/models"
amclient "github.com/prometheus/alertmanager/api/v2/client" amclient "github.com/prometheus/alertmanager/api/v2/client"
amsilence "github.com/prometheus/alertmanager/api/v2/client/silence"
) )
type externalAlertmanager struct { type externalAlertmanager struct {
@ -40,15 +42,16 @@ func newExternalAlertmanager(cfg externalAlertmanagerConfig, orgID int64) (*exte
} }
if cfg.URL == "" { if cfg.URL == "" {
return nil, errors.New("empty URL") return nil, fmt.Errorf("empty URL for tenant %s", cfg.TenantID)
} }
u, err := url.Parse(cfg.URL) u, err := url.Parse(cfg.URL)
if err != nil { if err != nil {
return nil, err return nil, err
} }
u = u.JoinPath(amclient.DefaultBasePath)
transport := httptransport.NewWithClient(u.Host, amclient.DefaultBasePath, []string{u.Scheme}, &client) transport := httptransport.NewWithClient(u.Host, u.Path, []string{u.Scheme}, &client)
_, err = Load([]byte(cfg.DefaultConfig)) _, err = Load([]byte(cfg.DefaultConfig))
if err != nil { if err != nil {
@ -74,24 +77,52 @@ func (am *externalAlertmanager) SaveAndApplyDefaultConfig(ctx context.Context) e
return nil return nil
} }
func (am *externalAlertmanager) GetStatus() (apimodels.GettableStatus, error) { func (am *externalAlertmanager) CreateSilence(ctx context.Context, silence *apimodels.PostableSilence) (string, error) {
return apimodels.GettableStatus{}, nil params := amsilence.NewPostSilencesParamsWithContext(ctx).WithSilence(silence)
res, err := am.amClient.Silence.PostSilences(params)
if err != nil {
return "", err
}
return res.Payload.SilenceID, nil
} }
func (am *externalAlertmanager) CreateSilence(*apimodels.PostableSilence) (string, error) { func (am *externalAlertmanager) DeleteSilence(ctx context.Context, silenceID string) error {
return "", nil params := amsilence.NewDeleteSilenceParamsWithContext(ctx).WithSilenceID(strfmt.UUID(silenceID))
} _, err := am.amClient.Silence.DeleteSilence(params)
if err != nil {
func (am *externalAlertmanager) DeleteSilence(string) error { return err
}
return nil return nil
} }
func (am *externalAlertmanager) GetSilence(silenceID string) (apimodels.GettableSilence, error) { func (am *externalAlertmanager) GetSilence(ctx context.Context, silenceID string) (apimodels.GettableSilence, error) {
return apimodels.GettableSilence{}, nil params := amsilence.NewGetSilenceParamsWithContext(ctx).WithSilenceID(strfmt.UUID(silenceID))
res, err := am.amClient.Silence.GetSilence(params)
if err != nil {
return apimodels.GettableSilence{}, err
}
if res != nil {
return *res.Payload, nil
}
// In theory, this should never happen as is not possible for GetSilence to return an empty payload but no error.
return apimodels.GettableSilence{}, fmt.Errorf("unexpected error while trying to fetch silence: %s", silenceID)
} }
func (am *externalAlertmanager) ListSilences([]string) (apimodels.GettableSilences, error) { func (am *externalAlertmanager) ListSilences(ctx context.Context, filter []string) (apimodels.GettableSilences, error) {
return apimodels.GettableSilences{}, nil params := amsilence.NewGetSilencesParamsWithContext(ctx).WithFilter(filter)
res, err := am.amClient.Silence.GetSilences(params)
if err != nil {
return apimodels.GettableSilences{}, err
}
return res.Payload, nil
}
func (am *externalAlertmanager) GetStatus() apimodels.GettableStatus {
return apimodels.GettableStatus{}
} }
func (am *externalAlertmanager) GetAlerts(active, silenced, inhibited bool, filter []string, receiver string) (apimodels.GettableAlerts, error) { func (am *externalAlertmanager) GetAlerts(active, silenced, inhibited bool, filter []string, receiver string) (apimodels.GettableAlerts, error) {
@ -106,8 +137,8 @@ func (am *externalAlertmanager) PutAlerts(postableAlerts apimodels.PostableAlert
return nil return nil
} }
func (am *externalAlertmanager) GetReceivers(ctx context.Context) ([]apimodels.Receiver, error) { func (am *externalAlertmanager) GetReceivers(ctx context.Context) []apimodels.Receiver {
return []apimodels.Receiver{}, nil return []apimodels.Receiver{}
} }
func (am *externalAlertmanager) ApplyConfig(ctx context.Context, config *models.AlertConfiguration) error { func (am *externalAlertmanager) ApplyConfig(ctx context.Context, config *models.AlertConfiguration) error {

View File

@ -1,13 +1,19 @@
package notifier package notifier
import ( import (
"context"
"testing" "testing"
"github.com/go-openapi/strfmt"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
amfake "github.com/grafana/grafana/pkg/services/ngalert/notifier/fake"
amv2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
const validConfig = `{"template_files":{},"alertmanager_config":{"route":{"receiver":"grafana-default-email","group_by":["grafana_folder","alertname"]},"templates":null,"receivers":[{"name":"grafana-default-email","grafana_managed_receiver_configs":[{"uid":"","name":"some other name","type":"email","disableResolveMessage":false,"settings":{"addresses":"\u003cexample@email.com\u003e"},"secureSettings":null}]}]}}`
func TestNewExternalAlertmanager(t *testing.T) { func TestNewExternalAlertmanager(t *testing.T) {
validConfig := `{"template_files":null,"alertmanager_config":{"route":{"receiver":"grafana-default-email","group_by":["grafana_folder","alertname"]},"templates":null,"receivers":[{"name":"grafana-default-email","grafana_managed_receiver_configs":[{"uid":"","name":"email receiver","type":"email","disableResolveMessage":false,"settings":{"addresses":"\u003cexample@email.com\u003e"},"secureSettings":null}]}]}}`
tests := []struct { tests := []struct {
name string name string
url string url string
@ -24,7 +30,7 @@ func TestNewExternalAlertmanager(t *testing.T) {
password: "test", password: "test",
defaultConfig: validConfig, defaultConfig: validConfig,
orgID: 1, orgID: 1,
expErr: "empty URL", expErr: "empty URL for tenant 1234",
}, },
{ {
name: "empty default config", name: "empty default config",
@ -78,3 +84,89 @@ func TestNewExternalAlertmanager(t *testing.T) {
}) })
} }
} }
func TestSilences(t *testing.T) {
const (
tenantID = "1"
password = "password"
)
fakeAm := amfake.NewFakeExternalAlertmanager(t, tenantID, password)
// Using a wrong password should cause an error.
cfg := externalAlertmanagerConfig{
URL: fakeAm.Server.URL + "/alertmanager",
TenantID: tenantID,
BasicAuthPassword: "wrongpassword",
DefaultConfig: validConfig,
}
am, err := newExternalAlertmanager(cfg, 1)
require.NoError(t, err)
_, err = am.ListSilences(context.Background(), []string{})
require.NotNil(t, err)
// Using the correct password should make the request succeed.
cfg.BasicAuthPassword = password
am, err = newExternalAlertmanager(cfg, 1)
require.NoError(t, err)
// We should have no silences at first.
silences, err := am.ListSilences(context.Background(), []string{})
require.NoError(t, err)
require.Equal(t, 0, len(silences))
// Creating a silence should succeed.
testSilence := createSilence("test comment", "1", amv2.Matchers{}, strfmt.NewDateTime(), strfmt.NewDateTime())
silenceID, err := am.CreateSilence(context.Background(), &testSilence)
require.NoError(t, err)
require.NotEmpty(t, silenceID)
// We should be able to retrieve a specific silence.
silence, err := am.GetSilence(context.Background(), silenceID)
require.NoError(t, err)
require.Equal(t, *testSilence.Comment, *silence.Comment)
require.Equal(t, *testSilence.CreatedBy, *silence.CreatedBy)
require.Equal(t, *testSilence.StartsAt, *silence.StartsAt)
require.Equal(t, *testSilence.EndsAt, *silence.EndsAt)
require.Equal(t, testSilence.Matchers, silence.Matchers)
// Trying to retrieve a non-existing silence should fail.
_, err = am.GetSilence(context.Background(), "invalid")
require.Error(t, err)
// After creating another silence, the total amount should be 2.
testSilence2 := createSilence("another test comment", "1", amv2.Matchers{}, strfmt.NewDateTime(), strfmt.NewDateTime())
silenceID2, err := am.CreateSilence(context.Background(), &testSilence2)
require.NoError(t, err)
require.NotEmpty(t, silenceID2)
silences, err = am.ListSilences(context.Background(), []string{})
require.NoError(t, err)
require.Equal(t, 2, len(silences))
require.True(t, *silences[0].ID == silenceID || *silences[0].ID == silenceID2)
require.True(t, *silences[1].ID == silenceID || *silences[1].ID == silenceID2)
// After deleting one of those silences, the total amount should be 2.
err = am.DeleteSilence(context.Background(), silenceID)
require.NoError(t, err)
silences, err = am.ListSilences(context.Background(), []string{})
require.NoError(t, err)
require.Equal(t, 1, len(silences))
// Trying to delete the same error should fail.
err = am.DeleteSilence(context.Background(), silenceID)
require.NotNil(t, err)
}
func createSilence(comment, createdBy string, matchers amv2.Matchers, startsAt, endsAt strfmt.DateTime) apimodels.PostableSilence {
return apimodels.PostableSilence{
Silence: amv2.Silence{
Comment: &comment,
CreatedBy: &createdBy,
Matchers: matchers,
StartsAt: &startsAt,
EndsAt: &endsAt,
},
}
}

View File

@ -0,0 +1,154 @@
package fake
import (
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"
"github.com/go-openapi/strfmt"
alertingNotify "github.com/grafana/alerting/notify"
"github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
"github.com/grafana/grafana/pkg/util"
"github.com/grafana/grafana/pkg/web"
"github.com/stretchr/testify/require"
)
type FakeExternalAlertmanager struct {
t *testing.T
mtx sync.RWMutex
tenantID string
password string
Server *httptest.Server
silences alertingNotify.GettableSilences
}
func NewFakeExternalAlertmanager(t *testing.T, tenantID, password string) *FakeExternalAlertmanager {
t.Helper()
am := &FakeExternalAlertmanager{
t: t,
tenantID: tenantID,
password: password,
mtx: sync.RWMutex{},
}
mux := web.New()
mux.SetURLPrefix("/alertmanager/api/")
mux.UseMiddleware(am.basicAuthMiddleware)
mux.UseMiddleware(am.contentTypeJSONMiddleware)
// Routes
mux.Get("/v2/silences", http.HandlerFunc(am.getSilences))
mux.Get("/v2/silence/:silenceID", http.HandlerFunc(am.getSilence))
mux.Post("/v2/silences", http.HandlerFunc(am.postSilence))
mux.Delete("/v2/silence/:silenceID", http.HandlerFunc(am.deleteSilence))
am.Server = httptest.NewServer(mux)
return am
}
func (am *FakeExternalAlertmanager) getSilences(w http.ResponseWriter, r *http.Request) {
am.mtx.RLock()
if err := json.NewEncoder(w).Encode(am.silences); err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
am.mtx.RUnlock()
}
func (am *FakeExternalAlertmanager) getSilence(w http.ResponseWriter, r *http.Request) {
silenceID, ok := web.Params(r)[":silenceID"]
if !ok {
return
}
am.mtx.RLock()
var matching *alertingNotify.GettableSilence
for _, silence := range am.silences {
if *silence.ID == silenceID {
matching = silence
break
}
}
am.mtx.RUnlock()
if matching == nil {
w.WriteHeader(http.StatusNotFound)
return
}
if err := json.NewEncoder(w).Encode(matching); err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
}
func (am *FakeExternalAlertmanager) postSilence(w http.ResponseWriter, r *http.Request) {
var silence definitions.PostableSilence
require.NoError(am.t, json.NewDecoder(r.Body).Decode(&silence))
updatedAt := strfmt.NewDateTime()
id := util.GenerateShortUID()
am.mtx.Lock()
am.silences = append(am.silences, &alertingNotify.GettableSilence{
ID: &id,
UpdatedAt: &updatedAt,
Silence: silence.Silence,
})
am.mtx.Unlock()
res := map[string]string{"silenceID": id}
if err := json.NewEncoder(w).Encode(res); err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
}
func (am *FakeExternalAlertmanager) deleteSilence(w http.ResponseWriter, r *http.Request) {
silenceID, ok := web.Params(r)[":silenceID"]
if !ok {
return
}
am.mtx.Lock()
defer am.mtx.Unlock()
var newSilences []*alertingNotify.GettableSilence
for _, silence := range am.silences {
if *silence.ID != silenceID {
newSilences = append(newSilences, silence)
}
}
if len(newSilences) == len(am.silences) {
w.WriteHeader(http.StatusNotFound)
return
}
am.silences = newSilences
w.WriteHeader(http.StatusOK)
}
func (am *FakeExternalAlertmanager) basicAuthMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
username, password, ok := r.BasicAuth()
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
if username != am.tenantID || password != am.password || r.Header.Get("X-Scope-OrgID") != am.tenantID {
w.WriteHeader(http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func (am *FakeExternalAlertmanager) contentTypeJSONMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
next.ServeHTTP(w, r)
})
}
func (am *FakeExternalAlertmanager) Close() {
am.Server.Close()
}

View File

@ -35,12 +35,13 @@ type Alertmanager interface {
SaveAndApplyConfig(ctx context.Context, config *apimodels.PostableUserConfig) error SaveAndApplyConfig(ctx context.Context, config *apimodels.PostableUserConfig) error
SaveAndApplyDefaultConfig(ctx context.Context) error SaveAndApplyDefaultConfig(ctx context.Context) error
GetStatus() apimodels.GettableStatus GetStatus() apimodels.GettableStatus
ApplyConfig(context.Context, *models.AlertConfiguration) error
// Silences // Silences
CreateSilence(*apimodels.PostableSilence) (string, error) CreateSilence(context.Context, *apimodels.PostableSilence) (string, error)
DeleteSilence(string) error DeleteSilence(context.Context, string) error
GetSilence(string) (apimodels.GettableSilence, error) GetSilence(context.Context, string) (apimodels.GettableSilence, error)
ListSilences([]string) (apimodels.GettableSilences, error) ListSilences(context.Context, []string) (apimodels.GettableSilences, error)
// Alerts // Alerts
GetAlerts(active, silenced, inhibited bool, filter []string, receiver string) (apimodels.GettableAlerts, error) GetAlerts(active, silenced, inhibited bool, filter []string, receiver string) (apimodels.GettableAlerts, error)
@ -51,7 +52,6 @@ type Alertmanager interface {
GetReceivers(ctx context.Context) []apimodels.Receiver GetReceivers(ctx context.Context) []apimodels.Receiver
TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*TestReceiversResult, error) TestReceivers(ctx context.Context, c apimodels.TestReceiversConfigBodyParams) (*TestReceiversResult, error)
TestTemplate(ctx context.Context, c apimodels.TestTemplatesConfigBodyParams) (*TestTemplatesResults, error) TestTemplate(ctx context.Context, c apimodels.TestTemplatesConfigBodyParams) (*TestTemplatesResults, error)
ApplyConfig(context.Context, *models.AlertConfiguration) error
// State // State
StopAndWait() StopAndWait()

View File

@ -1,21 +1,23 @@
package notifier package notifier
import ( import (
"context"
alertingNotify "github.com/grafana/alerting/notify" alertingNotify "github.com/grafana/alerting/notify"
) )
func (am *alertmanager) ListSilences(filter []string) (alertingNotify.GettableSilences, error) { func (am *alertmanager) ListSilences(_ context.Context, filter []string) (alertingNotify.GettableSilences, error) {
return am.Base.ListSilences(filter) return am.Base.ListSilences(filter)
} }
func (am *alertmanager) GetSilence(silenceID string) (alertingNotify.GettableSilence, error) { func (am *alertmanager) GetSilence(_ context.Context, silenceID string) (alertingNotify.GettableSilence, error) {
return am.Base.GetSilence(silenceID) return am.Base.GetSilence(silenceID)
} }
func (am *alertmanager) CreateSilence(ps *alertingNotify.PostableSilence) (string, error) { func (am *alertmanager) CreateSilence(_ context.Context, ps *alertingNotify.PostableSilence) (string, error) {
return am.Base.CreateSilence(ps) return am.Base.CreateSilence(ps)
} }
func (am *alertmanager) DeleteSilence(silenceID string) error { func (am *alertmanager) DeleteSilence(_ context.Context, silenceID string) error {
return am.Base.DeleteSilence(silenceID) return am.Base.DeleteSilence(silenceID)
} }