Alerting: Remove url based external alertmanagers config (#57918)

* Remove URL-based alertmanagers from endpoint config

* WIP

* Add migration and alertmanagers from admin_configuration

* Empty comment removed

* set BasicAuth true when user is present in url

* Remove Alertmanagers from GET /admin_config payload

* Remove URL-based alertmanager configuration from UI

* Fix new uid generation in external alertmanagers migration

* Fix tests for URL-based external alertmanagers

* Fix API tests

* Add more tests, move migration code to separate file, and remove possible am duplicate urls

* Fix edge cases in migration

* Fix imports

* Remove useless fields and fix created_at/updated_at retrieval

Co-authored-by: George Robinson <george.robinson@grafana.com>
Co-authored-by: Konrad Lalik <konrad.lalik@grafana.com>
This commit is contained in:
Alex Moreno 2022-11-10 16:34:13 +01:00 committed by GitHub
parent 738e023d13
commit 45facbba11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 411 additions and 796 deletions

View File

@ -60,7 +60,6 @@ func (srv ConfigSrv) RouteGetNGalertConfig(c *models.ReqContext) response.Respon
}
resp := apimodels.GettableNGalertConfig{
Alertmanagers: cfg.Alertmanagers,
AlertmanagersChoice: apimodels.AlertmanagersChoice(cfg.SendAlertsTo.String()),
}
return response.JSON(http.StatusOK, resp)
@ -81,21 +80,13 @@ func (srv ConfigSrv) RoutePostNGalertConfig(c *models.ReqContext, body apimodels
return response.Error(500, "Couldn't fetch the external Alertmanagers from datasources", err)
}
if sendAlertsTo == ngmodels.ExternalAlertmanagers &&
len(body.Alertmanagers)+len(externalAlertmanagers) < 1 {
if sendAlertsTo == ngmodels.ExternalAlertmanagers && len(externalAlertmanagers) < 1 {
return response.Error(400, "At least one Alertmanager must be provided or configured as a datasource that handles alerts to choose this option", nil)
}
cfg := &ngmodels.AdminConfiguration{
Alertmanagers: body.Alertmanagers,
SendAlertsTo: sendAlertsTo,
OrgID: c.OrgID,
}
if err := cfg.Validate(); err != nil {
msg := "failed to validate admin configuration"
srv.log.Error(msg, "error", err)
return ErrResp(http.StatusBadRequest, err, msg)
SendAlertsTo: sendAlertsTo,
OrgID: c.OrgID,
}
cmd := store.UpdateAdminConfigurationCmd{AdminConfiguration: cfg}

View File

@ -18,23 +18,13 @@ func TestExternalAlertmanagerChoice(t *testing.T) {
tests := []struct {
name string
alertmanagerChoice definitions.AlertmanagersChoice
alertmanagers []string
datasources []*datasources.DataSource
statusCode int
message string
}{
{
name: "setting the choice to external by passing a plain url should succeed",
alertmanagerChoice: definitions.ExternalAlertmanagers,
alertmanagers: []string{"http://localhost:9000"},
datasources: []*datasources.DataSource{},
statusCode: http.StatusCreated,
message: "admin configuration updated",
},
{
name: "setting the choice to external by having a enabled external am datasource should succeed",
alertmanagerChoice: definitions.ExternalAlertmanagers,
alertmanagers: []string{},
datasources: []*datasources.DataSource{
{
OrgId: 1,
@ -51,7 +41,6 @@ func TestExternalAlertmanagerChoice(t *testing.T) {
{
name: "setting the choice to external by having a disabled external am datasource should fail",
alertmanagerChoice: definitions.ExternalAlertmanagers,
alertmanagers: []string{},
datasources: []*datasources.DataSource{
{
OrgId: 1,
@ -66,7 +55,6 @@ func TestExternalAlertmanagerChoice(t *testing.T) {
{
name: "setting the choice to external and having no am configured should fail",
alertmanagerChoice: definitions.ExternalAlertmanagers,
alertmanagers: []string{},
datasources: []*datasources.DataSource{},
statusCode: http.StatusBadRequest,
message: "At least one Alertmanager must be provided or configured as a datasource that handles alerts to choose this option",
@ -74,7 +62,6 @@ func TestExternalAlertmanagerChoice(t *testing.T) {
{
name: "setting the choice to all and having no external am configured should succeed",
alertmanagerChoice: definitions.AllAlertmanagers,
alertmanagers: []string{},
datasources: []*datasources.DataSource{},
statusCode: http.StatusCreated,
message: "admin configuration updated",
@ -82,7 +69,6 @@ func TestExternalAlertmanagerChoice(t *testing.T) {
{
name: "setting the choice to internal should always succeed",
alertmanagerChoice: definitions.InternalAlertmanager,
alertmanagers: []string{},
datasources: []*datasources.DataSource{},
statusCode: http.StatusCreated,
message: "admin configuration updated",
@ -94,7 +80,6 @@ func TestExternalAlertmanagerChoice(t *testing.T) {
t.Run(test.name, func(t *testing.T) {
sut := createAPIAdminSut(t, test.datasources)
resp := sut.RoutePostNGalertConfig(ctx, definitions.PostableNGalertConfig{
Alertmanagers: test.alertmanagers,
AlertmanagersChoice: test.alertmanagerChoice,
})
var res map[string]interface{}

View File

@ -395,11 +395,6 @@
],
"type": "object"
},
"DsPermissionType": {
"description": "Datasource permission\nDescription:\n`0` - No Access\n`1` - Query\nEnum: 0,1",
"format": "int64",
"type": "integer"
},
"Duration": {
"format": "int64",
"title": "Duration is a type used for marshalling durations.",
@ -1046,12 +1041,6 @@
},
"GettableNGalertConfig": {
"properties": {
"alertmanagers": {
"items": {
"type": "string"
},
"type": "array"
},
"alertmanagersChoice": {
"enum": [
"all",
@ -1984,12 +1973,6 @@
},
"PostableNGalertConfig": {
"properties": {
"alertmanagers": {
"items": {
"type": "string"
},
"type": "array"
},
"alertmanagersChoice": {
"enum": [
"all",
@ -3097,9 +3080,6 @@
"Host": {
"type": "string"
},
"OmitHost": {
"type": "boolean"
},
"Opaque": {
"type": "string"
},
@ -3301,7 +3281,6 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
@ -3462,7 +3441,6 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert"
},
@ -3667,6 +3645,7 @@
"type": "array"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",

View File

@ -76,13 +76,11 @@ const (
// swagger:model
type PostableNGalertConfig struct {
Alertmanagers []string `json:"alertmanagers"`
AlertmanagersChoice AlertmanagersChoice `json:"alertmanagersChoice"`
}
// swagger:model
type GettableNGalertConfig struct {
Alertmanagers []string `json:"alertmanagers"`
AlertmanagersChoice AlertmanagersChoice `json:"alertmanagersChoice"`
}

View File

@ -395,11 +395,6 @@
],
"type": "object"
},
"DsPermissionType": {
"description": "Datasource permission\nDescription:\n`0` - No Access\n`1` - Query\nEnum: 0,1",
"format": "int64",
"type": "integer"
},
"Duration": {
"format": "int64",
"title": "Duration is a type used for marshalling durations.",
@ -1046,12 +1041,6 @@
},
"GettableNGalertConfig": {
"properties": {
"alertmanagers": {
"items": {
"type": "string"
},
"type": "array"
},
"alertmanagersChoice": {
"enum": [
"all",
@ -1984,12 +1973,6 @@
},
"PostableNGalertConfig": {
"properties": {
"alertmanagers": {
"items": {
"type": "string"
},
"type": "array"
},
"alertmanagersChoice": {
"enum": [
"all",
@ -3087,6 +3070,7 @@
"type": "object"
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
"properties": {
"ForceQuery": {
"type": "boolean"
@ -3097,9 +3081,6 @@
"Host": {
"type": "string"
},
"OmitHost": {
"type": "boolean"
},
"Opaque": {
"type": "string"
},
@ -3122,7 +3103,7 @@
"$ref": "#/definitions/Userinfo"
}
},
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"type": "object"
},
"Userinfo": {
@ -3302,6 +3283,7 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup"
},
@ -3406,7 +3388,6 @@
"type": "object"
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"properties": {
"annotations": {
"$ref": "#/definitions/labelSet"
@ -3462,13 +3443,13 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert"
},
"type": "array"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"properties": {
"comment": {
"description": "comment",
@ -3517,14 +3498,12 @@
"type": "object"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"items": {
"$ref": "#/definitions/gettableSilence"
},
"type": "array"
},
"integration": {
"description": "Integration integration",
"properties": {
"lastNotifyAttempt": {
"description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time",
@ -3668,6 +3647,7 @@
"type": "array"
},
"postableSilence": {
"description": "PostableSilence postable silence",
"properties": {
"comment": {
"description": "comment",

View File

@ -2828,11 +2828,6 @@
}
}
},
"DsPermissionType": {
"description": "Datasource permission\nDescription:\n`0` - No Access\n`1` - Query\nEnum: 0,1",
"type": "integer",
"format": "int64"
},
"Duration": {
"type": "integer",
"format": "int64",
@ -3483,12 +3478,6 @@
"GettableNGalertConfig": {
"type": "object",
"properties": {
"alertmanagers": {
"type": "array",
"items": {
"type": "string"
}
},
"alertmanagersChoice": {
"type": "string",
"enum": [
@ -4422,12 +4411,6 @@
"PostableNGalertConfig": {
"type": "object",
"properties": {
"alertmanagers": {
"type": "array",
"items": {
"type": "string"
}
},
"alertmanagersChoice": {
"type": "string",
"enum": [
@ -5524,8 +5507,9 @@
}
},
"URL": {
"description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use RawPath, an optional field which only gets\nset if the default encoding is different from Path.\n\nURL's String method uses the EscapedPath method to obtain the path. See the\nEscapedPath method for more details.",
"type": "object",
"title": "URL is a custom URL type that allows validation at configuration load time.",
"title": "A URL represents a parsed URL (technically, a URI reference).",
"properties": {
"ForceQuery": {
"type": "boolean"
@ -5536,9 +5520,6 @@
"Host": {
"type": "string"
},
"OmitHost": {
"type": "boolean"
},
"Opaque": {
"type": "string"
},
@ -5740,6 +5721,7 @@
"$ref": "#/definitions/alertGroup"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"type": "array",
"items": {
"$ref": "#/definitions/alertGroup"
@ -5845,7 +5827,6 @@
}
},
"gettableAlert": {
"description": "GettableAlert gettable alert",
"type": "object",
"required": [
"labels",
@ -5902,7 +5883,6 @@
"$ref": "#/definitions/gettableAlert"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"type": "array",
"items": {
"$ref": "#/definitions/gettableAlert"
@ -5910,6 +5890,7 @@
"$ref": "#/definitions/gettableAlerts"
},
"gettableSilence": {
"description": "GettableSilence gettable silence",
"type": "object",
"required": [
"comment",
@ -5959,7 +5940,6 @@
"$ref": "#/definitions/gettableSilence"
},
"gettableSilences": {
"description": "GettableSilences gettable silences",
"type": "array",
"items": {
"$ref": "#/definitions/gettableSilence"
@ -5967,7 +5947,6 @@
"$ref": "#/definitions/gettableSilences"
},
"integration": {
"description": "Integration integration",
"type": "object",
"required": [
"name",
@ -6112,6 +6091,7 @@
}
},
"postableSilence": {
"description": "PostableSilence postable silence",
"type": "object",
"required": [
"comment",

View File

@ -1,10 +1,7 @@
package models
import (
"crypto/sha256"
"errors"
"fmt"
"net/url"
)
type AlertmanagersChoice int
@ -26,9 +23,6 @@ type AdminConfiguration struct {
ID int64 `xorm:"pk autoincr 'id'"`
OrgID int64 `xorm:"org_id"`
// List of Alertmanager(s) URL to push alerts to.
Alertmanagers []string
// SendAlertsTo indicates which set of alertmanagers will handle the alert.
SendAlertsTo AlertmanagersChoice `xorm:"send_alerts_to"`
@ -36,23 +30,6 @@ type AdminConfiguration struct {
UpdatedAt int64 `xorm:"updated"`
}
func (ac *AdminConfiguration) AsSHA256() string {
h := sha256.New()
_, _ = h.Write([]byte(fmt.Sprintf("%v", ac.Alertmanagers)))
return fmt.Sprintf("%x", h.Sum(nil))
}
func (ac *AdminConfiguration) Validate() error {
for _, u := range ac.Alertmanagers {
_, err := url.Parse(u)
if err != nil {
return err
}
}
return nil
}
// String implements the Stringer interface
func (amc AlertmanagersChoice) String() string {
return alertmanagersChoiceMap[amc]

View File

@ -2,62 +2,11 @@ package models
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/require"
)
func TestAdminConfiguration_AsSHA256(t *testing.T) {
tc := []struct {
name string
ac *AdminConfiguration
ciphertext string
}{
{
name: "AsSHA256",
ac: &AdminConfiguration{Alertmanagers: []string{"http://localhost:9093"}},
ciphertext: "3ec9db375a5ba12f7c7b704922cf4b8e21a31e30d85be2386803829f0ee24410",
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.ciphertext, tt.ac.AsSHA256())
})
}
}
func TestAdminConfiguration_Validate(t *testing.T) {
tc := []struct {
name string
ac *AdminConfiguration
err error
}{
{
name: "should return the first error if any of the Alertmanagers URL is invalid",
ac: &AdminConfiguration{Alertmanagers: []string{"http://localhost:9093", "http://›∂-)Æÿ ñ"}},
err: fmt.Errorf("parse \"http://›∂-)Æÿ ñ\": invalid character \" \" in host name"),
},
{
name: "should not return any errors if all URLs are valid",
ac: &AdminConfiguration{Alertmanagers: []string{"http://localhost:9093"}},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
err := tt.ac.Validate()
if tt.err != nil {
require.EqualError(t, err, tt.err.Error())
return
}
require.NoError(t, err)
})
}
}
func TestStringToAlertmanagersChoice(t *testing.T) {
tests := []struct {
name string

View File

@ -2,9 +2,11 @@ package sender
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"net/url"
"sort"
"sync"
"time"
@ -103,45 +105,31 @@ func (d *AlertsRouter) SyncAndApplyConfigFromDatabase() error {
continue
}
externalAlertmanagers, err := d.alertmanagersFromDatasources(cfg.OrgID)
alertmanagers, err := d.alertmanagersFromDatasources(cfg.OrgID)
if err != nil {
d.logger.Error("Failed to get alertmanagers from datasources",
"org", cfg.OrgID,
"error", err)
d.logger.Error("Failed to get alertmanagers from datasources", "org", cfg.OrgID, "error", err)
continue
}
cfg.Alertmanagers = append(cfg.Alertmanagers, externalAlertmanagers...)
// We have no running sender and no Alertmanager(s) configured, no-op.
if !ok && len(cfg.Alertmanagers) == 0 {
if !ok && len(alertmanagers) == 0 {
d.logger.Debug("No external alertmanagers configured", "org", cfg.OrgID)
continue
}
// We have a running sender but no Alertmanager(s) configured, shut it down.
if ok && len(cfg.Alertmanagers) == 0 {
if ok && len(alertmanagers) == 0 {
d.logger.Info("No external alertmanager(s) configured, sender will be stopped", "org", cfg.OrgID)
delete(orgsFound, cfg.OrgID)
continue
}
// Avoid logging sensitive data
var redactedAMs []string
for _, am := range cfg.Alertmanagers {
parsedAM, err := url.Parse(am)
if err != nil {
d.logger.Error("Failed to parse alertmanager string",
"org", cfg.OrgID,
"error", err)
continue
}
redactedAMs = append(redactedAMs, parsedAM.Redacted())
}
redactedAMs := buildRedactedAMs(d.logger, alertmanagers, cfg.OrgID)
d.logger.Debug("Alertmanagers found in the configuration", "alertmanagers", redactedAMs)
// We have a running sender, check if we need to apply a new config.
amHash := cfg.AsSHA256()
amHash := asSHA256(alertmanagers)
if ok {
if d.externalAlertmanagersCfgHash[cfg.OrgID] == amHash {
d.logger.Debug("Sender configuration is the same as the one running, no-op", "org", cfg.OrgID, "alertmanagers", redactedAMs)
@ -149,7 +137,7 @@ func (d *AlertsRouter) SyncAndApplyConfigFromDatabase() error {
}
d.logger.Info("Applying new configuration to sender", "org", cfg.OrgID, "alertmanagers", redactedAMs, "cfg", cfg.ID)
err := existing.ApplyConfig(cfg)
err := existing.ApplyConfig(cfg.OrgID, cfg.ID, alertmanagers)
if err != nil {
d.logger.Error("Failed to apply configuration", "error", err, "org", cfg.OrgID)
continue
@ -164,7 +152,7 @@ func (d *AlertsRouter) SyncAndApplyConfigFromDatabase() error {
d.externalAlertmanagers[cfg.OrgID] = s
s.Run()
err = s.ApplyConfig(cfg)
err = s.ApplyConfig(cfg.OrgID, cfg.ID, alertmanagers)
if err != nil {
d.logger.Error("Failed to apply configuration", "error", err, "org", cfg.OrgID)
continue
@ -184,7 +172,7 @@ func (d *AlertsRouter) SyncAndApplyConfigFromDatabase() error {
}
d.adminConfigMtx.Unlock()
// We can now stop these externalAlertmanagers w/o having to hold a lock.
// We can now stop these external Alertmanagers w/o having to hold a lock.
for orgID, s := range sendersToStop {
d.logger.Info("Stopping sender", "org", orgID)
s.Stop()
@ -196,6 +184,26 @@ func (d *AlertsRouter) SyncAndApplyConfigFromDatabase() error {
return nil
}
func buildRedactedAMs(l log.Logger, alertmanagers []string, ordId int64) []string {
var redactedAMs []string
for _, am := range alertmanagers {
parsedAM, err := url.Parse(am)
if err != nil {
l.Error("Failed to parse alertmanager string", "org", ordId, "error", err)
continue
}
redactedAMs = append(redactedAMs, parsedAM.Redacted())
}
return redactedAMs
}
func asSHA256(strings []string) string {
h := sha256.New()
sort.Strings(strings)
_, _ = h.Write([]byte(fmt.Sprintf("%v", strings)))
return fmt.Sprintf("%x", h.Sum(nil))
}
func (d *AlertsRouter) alertmanagersFromDatasources(orgID int64) ([]string, error) {
var alertmanagers []string
// We might have alertmanager datasources that are acting as external

View File

@ -10,10 +10,12 @@ import (
"github.com/benbjohnson/clock"
"github.com/go-openapi/strfmt"
"github.com/grafana/grafana/pkg/infra/log/logtest"
models2 "github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/services/datasources"
fake_ds "github.com/grafana/grafana/pkg/services/datasources/fakes"
@ -47,11 +49,20 @@ func TestSendingToExternalAlertmanager(t *testing.T) {
Host: "localhost",
}
ds1 := datasources.DataSource{
Url: fakeAM.Server.URL,
OrgId: ruleKey.OrgID,
Type: datasources.DS_ALERTMANAGER,
JsonData: simplejson.NewFromAny(map[string]interface{}{
"handleGrafanaManagedAlerts": true,
"implementation": "prometheus",
}),
}
alertsRouter := NewAlertsRouter(moa, fakeAdminConfigStore, mockedClock, appUrl, map[int64]struct{}{}, 10*time.Minute,
&fake_ds.FakeDataSourceService{}, fake_secrets.NewFakeSecretsService())
&fake_ds.FakeDataSourceService{DataSources: []*datasources.DataSource{&ds1}}, fake_secrets.NewFakeSecretsService())
mockedGetAdminConfigurations.Return([]*models.AdminConfiguration{
{OrgID: ruleKey.OrgID, Alertmanagers: []string{fakeAM.Server.URL}, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey.OrgID, SendAlertsTo: models.AllAlertmanagers},
}, nil)
// Make sure we sync the configuration at least once before the evaluation happens to guarantee the sender is running
// when the first alert triggers.
@ -105,11 +116,21 @@ func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
Host: "localhost",
}
ds1 := datasources.DataSource{
Url: fakeAM.Server.URL,
OrgId: ruleKey1.OrgID,
Type: datasources.DS_ALERTMANAGER,
JsonData: simplejson.NewFromAny(map[string]interface{}{
"handleGrafanaManagedAlerts": true,
"implementation": "prometheus",
}),
}
fakeDs := &fake_ds.FakeDataSourceService{DataSources: []*datasources.DataSource{&ds1}}
alertsRouter := NewAlertsRouter(moa, fakeAdminConfigStore, mockedClock, appUrl, map[int64]struct{}{}, 10*time.Minute,
&fake_ds.FakeDataSourceService{}, fake_secrets.NewFakeSecretsService())
fakeDs, fake_secrets.NewFakeSecretsService())
mockedGetAdminConfigurations.Return([]*models.AdminConfiguration{
{OrgID: ruleKey1.OrgID, Alertmanagers: []string{fakeAM.Server.URL}, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey1.OrgID, SendAlertsTo: models.AllAlertmanagers},
}, nil)
// Make sure we sync the configuration at least once before the evaluation happens to guarantee the sender is running
@ -122,9 +143,20 @@ func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
assertAlertmanagersStatusForOrg(t, alertsRouter, ruleKey1.OrgID, 1, 0)
// 1. Now, let's assume a new org comes along.
ds2 := datasources.DataSource{
Url: fakeAM.Server.URL,
OrgId: ruleKey2.OrgID,
Type: datasources.DS_ALERTMANAGER,
JsonData: simplejson.NewFromAny(map[string]interface{}{
"handleGrafanaManagedAlerts": true,
"implementation": "prometheus",
}),
}
fakeDs.DataSources = append(fakeDs.DataSources, &ds2)
mockedGetAdminConfigurations.Return([]*models.AdminConfiguration{
{OrgID: ruleKey1.OrgID, Alertmanagers: []string{fakeAM.Server.URL}, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey2.OrgID, Alertmanagers: []string{fakeAM.Server.URL}},
{OrgID: ruleKey1.OrgID, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey2.OrgID},
}, nil)
// If we sync again, new externalAlertmanagers must have spawned.
@ -157,10 +189,20 @@ func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
// 2. Next, let's modify the configuration of an organization by adding an extra alertmanager.
fakeAM2 := NewFakeExternalAlertmanager(t)
ds3 := datasources.DataSource{
Url: fakeAM2.Server.URL,
OrgId: ruleKey2.OrgID,
Type: datasources.DS_ALERTMANAGER,
JsonData: simplejson.NewFromAny(map[string]interface{}{
"handleGrafanaManagedAlerts": true,
"implementation": "prometheus",
}),
}
fakeDs.DataSources = append(fakeDs.DataSources, &ds3)
mockedGetAdminConfigurations.Return([]*models.AdminConfiguration{
{OrgID: ruleKey1.OrgID, Alertmanagers: []string{fakeAM.Server.URL}, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey2.OrgID, Alertmanagers: []string{fakeAM.Server.URL, fakeAM2.Server.URL}},
{OrgID: ruleKey1.OrgID, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey2.OrgID},
}, nil)
// Before we sync, let's grab the existing hash of this particular org.
@ -177,9 +219,10 @@ func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
assertAlertmanagersStatusForOrg(t, alertsRouter, ruleKey2.OrgID, 2, 0)
// 3. Now, let's provide a configuration that fails for OrgID = 1.
fakeDs.DataSources[0].Url = "123://invalid.org"
mockedGetAdminConfigurations.Return([]*models.AdminConfiguration{
{OrgID: ruleKey1.OrgID, Alertmanagers: []string{"123://invalid.org"}, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey2.OrgID, Alertmanagers: []string{fakeAM.Server.URL, fakeAM2.Server.URL}},
{OrgID: ruleKey1.OrgID, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey2.OrgID},
}, nil)
// Before we sync, let's get the current config hash.
@ -188,14 +231,15 @@ func TestSendingToExternalAlertmanager_WithMultipleOrgs(t *testing.T) {
// Now, sync again.
require.NoError(t, alertsRouter.SyncAndApplyConfigFromDatabase())
// The old configuration should still be running.
require.Equal(t, alertsRouter.externalAlertmanagersCfgHash[ruleKey1.OrgID], currentHash)
require.Equal(t, 1, len(alertsRouter.AlertmanagersFor(ruleKey1.OrgID)))
// The old configuration should not be running.
require.NotEqual(t, alertsRouter.externalAlertmanagersCfgHash[ruleKey1.OrgID], currentHash)
require.Equal(t, 0, len(alertsRouter.AlertmanagersFor(ruleKey1.OrgID)))
// If we fix it - it should be applied.
fakeDs.DataSources[0].Url = "notarealalertmanager:3030"
mockedGetAdminConfigurations.Return([]*models.AdminConfiguration{
{OrgID: ruleKey1.OrgID, Alertmanagers: []string{"notarealalertmanager:3030"}, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey2.OrgID, Alertmanagers: []string{fakeAM.Server.URL, fakeAM2.Server.URL}},
{OrgID: ruleKey1.OrgID, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey2.OrgID},
}, nil)
require.NoError(t, alertsRouter.SyncAndApplyConfigFromDatabase())
@ -232,11 +276,20 @@ func TestChangingAlertmanagersChoice(t *testing.T) {
Host: "localhost",
}
ds := datasources.DataSource{
Url: fakeAM.Server.URL,
OrgId: ruleKey.OrgID,
Type: datasources.DS_ALERTMANAGER,
JsonData: simplejson.NewFromAny(map[string]interface{}{
"handleGrafanaManagedAlerts": true,
"implementation": "prometheus",
}),
}
alertsRouter := NewAlertsRouter(moa, fakeAdminConfigStore, mockedClock, appUrl, map[int64]struct{}{},
10*time.Minute, &fake_ds.FakeDataSourceService{}, fake_secrets.NewFakeSecretsService())
10*time.Minute, &fake_ds.FakeDataSourceService{DataSources: []*datasources.DataSource{&ds}}, fake_secrets.NewFakeSecretsService())
mockedGetAdminConfigurations.Return([]*models.AdminConfiguration{
{OrgID: ruleKey.OrgID, Alertmanagers: []string{fakeAM.Server.URL}, SendAlertsTo: models.AllAlertmanagers},
{OrgID: ruleKey.OrgID, SendAlertsTo: models.AllAlertmanagers},
}, nil)
// Make sure we sync the configuration at least once before the evaluation happens to guarantee the sender is running
// when the first alert triggers.
@ -262,7 +315,7 @@ func TestChangingAlertmanagersChoice(t *testing.T) {
// Now, let's change the Alertmanagers choice to send only to the external Alertmanager.
mockedGetAdminConfigurations.Return([]*models.AdminConfiguration{
{OrgID: ruleKey.OrgID, Alertmanagers: []string{fakeAM.Server.URL}, SendAlertsTo: models.ExternalAlertmanagers},
{OrgID: ruleKey.OrgID, SendAlertsTo: models.ExternalAlertmanagers},
}, nil)
// Again, make sure we sync and verify the externalAlertmanagers.
require.NoError(t, alertsRouter.SyncAndApplyConfigFromDatabase())
@ -274,7 +327,7 @@ func TestChangingAlertmanagersChoice(t *testing.T) {
// Finally, let's change the Alertmanagers choice to send only to the internal Alertmanager.
mockedGetAdminConfigurations.Return([]*models.AdminConfiguration{
{OrgID: ruleKey.OrgID, Alertmanagers: []string{fakeAM.Server.URL}, SendAlertsTo: models.InternalAlertmanager},
{OrgID: ruleKey.OrgID, SendAlertsTo: models.InternalAlertmanager},
}, nil)
// Again, make sure we sync and verify the externalAlertmanagers.
@ -441,3 +494,62 @@ func TestBuildExternalURL(t *testing.T) {
})
}
}
func TestAlertManegers_asSHA256(t *testing.T) {
tc := []struct {
name string
amUrls []string
ciphertext string
}{
{
name: "asSHA256",
amUrls: []string{"http://localhost:9093"},
ciphertext: "3ec9db375a5ba12f7c7b704922cf4b8e21a31e30d85be2386803829f0ee24410",
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.ciphertext, asSHA256(tt.amUrls))
})
}
}
func TestAlertManagers_buildRedactedAMs(t *testing.T) {
fakeLogger := logtest.Fake{}
tc := []struct {
name string
orgId int64
amUrls []string
errCalls int
errLog string
errCtx []interface{}
expected []string
}{
{
name: "buildRedactedAMs",
orgId: 1,
amUrls: []string{"http://user:password@localhost:9093"},
errCalls: 0,
errLog: "",
expected: []string{"http://user:xxxxx@localhost:9093"},
},
{
name: "Error building redacted AM URLs",
orgId: 2,
amUrls: []string{"1234://user:password@localhost:9094"},
errCalls: 1,
errLog: "Failed to parse alertmanager string",
expected: nil,
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.expected, buildRedactedAMs(&fakeLogger, tt.amUrls, tt.orgId))
require.Equal(t, tt.errCalls, fakeLogger.ErrorLogs.Calls)
require.Equal(t, tt.errLog, fakeLogger.ErrorLogs.Message)
})
}
}

View File

@ -14,8 +14,6 @@ import (
"github.com/grafana/grafana/pkg/infra/log"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/prometheus/alertmanager/api/v2/models"
"github.com/prometheus/client_golang/prometheus"
common_config "github.com/prometheus/common/config"
@ -63,13 +61,13 @@ func NewExternalAlertmanagerSender() *ExternalAlertmanager {
}
// ApplyConfig syncs a configuration with the sender.
func (s *ExternalAlertmanager) ApplyConfig(cfg *ngmodels.AdminConfiguration) error {
notifierCfg, err := buildNotifierConfig(cfg)
func (s *ExternalAlertmanager) ApplyConfig(orgId, id int64, alertmanagers []string) error {
notifierCfg, err := buildNotifierConfig(alertmanagers)
if err != nil {
return err
}
s.logger = s.logger.New("org", cfg.OrgID, "cfg", cfg.ID)
s.logger = s.logger.New("org", orgId, "cfg", id)
s.logger.Info("Synchronizing config with external Alertmanager group")
if err := s.manager.ApplyConfig(notifierCfg); err != nil {
@ -134,9 +132,9 @@ func (s *ExternalAlertmanager) DroppedAlertmanagers() []*url.URL {
return s.manager.DroppedAlertmanagers()
}
func buildNotifierConfig(cfg *ngmodels.AdminConfiguration) (*config.Config, error) {
amConfigs := make([]*config.AlertmanagerConfig, 0, len(cfg.Alertmanagers))
for _, amURL := range cfg.Alertmanagers {
func buildNotifierConfig(alertmanagers []string) (*config.Config, error) {
amConfigs := make([]*config.AlertmanagerConfig, 0, len(alertmanagers))
for _, amURL := range alertmanagers {
u, err := url.Parse(amURL)
if err != nil {
return nil, err

View File

@ -0,0 +1,125 @@
package migrations
import (
"fmt"
"net/url"
"time"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/sqlstore/migrations/ualert"
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
"github.com/grafana/grafana/pkg/util"
"xorm.io/xorm"
)
func AddExternalAlertmanagerToDatasourceMigration(mg *migrator.Migrator) {
mg.AddMigration("migrate external alertmanagers to datsourcse", &externalAlertmanagerToDatasources{})
}
type externalAlertmanagerToDatasources struct {
migrator.MigrationBase
}
type AdminConfiguration struct {
OrgID int64 `xorm:"org_id"`
Alertmanagers []string
CreatedAt int64 `xorm:"created_at"`
UpdatedAt int64 `xorm:"updated_at"`
}
func (e externalAlertmanagerToDatasources) SQL(dialect migrator.Dialect) string {
return "migrate external alertmanagers to datasource"
}
func (e externalAlertmanagerToDatasources) Exec(sess *xorm.Session, mg *migrator.Migrator) error {
var results []AdminConfiguration
err := sess.SQL("SELECT org_id, alertmanagers, created_at, updated_at FROM ngalert_configuration").Find(&results)
if err != nil {
return err
}
for _, result := range results {
for _, am := range removeDuplicates(result.Alertmanagers) {
u, err := url.Parse(am)
if err != nil {
return err
}
uri := fmt.Sprintf("%s://%s%s", u.Scheme, u.Host, u.Path)
uid, err := generateNewDatasourceUid(sess, result.OrgID)
if err != nil {
return err
}
ds := &datasources.DataSource{
OrgId: result.OrgID,
Name: fmt.Sprintf("alertmanager-%s", uid),
Type: "alertmanager",
Access: "proxy",
Url: uri,
Created: time.Unix(result.CreatedAt, 0),
Updated: time.Unix(result.UpdatedAt, 0),
Uid: uid,
Version: 1,
JsonData: simplejson.NewFromAny(map[string]interface{}{
"handleGrafanaManagedAlerts": true,
"implementation": "prometheus",
}),
SecureJsonData: map[string][]byte{},
}
if u.User != nil {
ds.BasicAuth = true
ds.BasicAuthUser = u.User.Username()
if password, ok := u.User.Password(); ok {
ds.SecureJsonData = ualert.GetEncryptedJsonData(map[string]string{
"basicAuthPassword": password,
})
}
}
rowsAffected, err := sess.Table("data_source").Insert(ds)
if err != nil {
return err
}
if rowsAffected == 0 {
return fmt.Errorf("expected 1 row, got %d", rowsAffected)
}
}
}
return nil
}
func removeDuplicates(strs []string) []string {
var res []string
found := map[string]bool{}
for _, str := range strs {
if found[str] {
continue
}
found[str] = true
res = append(res, str)
}
return res
}
func generateNewDatasourceUid(sess *xorm.Session, orgId int64) (string, error) {
for i := 0; i < 3; i++ {
uid := util.GenerateShortUID()
exists, err := sess.Table("data_source").Where("uid = ? AND org_id = ?", uid, orgId).Exist()
if err != nil {
return "", err
}
if !exists {
return uid, nil
}
}
return "", datasources.ErrDataSourceFailedGenerateUniqueUid
}

View File

@ -105,6 +105,8 @@ func (*OSSMigrations) AddMigration(mg *Migrator) {
accesscontrol.AddSeedAssignmentMigrations(mg)
accesscontrol.AddManagedFolderAlertActionsRepeatFixedMigration(mg)
AddExternalAlertmanagerToDatasourceMigration(mg)
// TODO: This migration will be enabled later in the nested folder feature
// implementation process. It is on hold so we can continue working on the
// store implementation without impacting any grafana instances built off

View File

@ -12,6 +12,8 @@ import (
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/services/datasources"
apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions"
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/services/ngalert/sender"
@ -111,11 +113,64 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) {
require.Equal(t, "At least one Alertmanager must be provided or configured as a datasource that handles alerts to choose this option", res["message"])
}
// Add an alertmanager datasource
{
cmd := datasources.AddDataSourceCommand{
OrgId: 1,
Name: "AM1",
Type: datasources.DS_ALERTMANAGER,
Access: "proxy",
Url: fakeAM1.URL(),
JsonData: simplejson.NewFromAny(map[string]interface{}{
"handleGrafanaManagedAlerts": true,
"implementation": "prometheus",
}),
}
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err := enc.Encode(&cmd)
require.NoError(t, err)
dataSourcesUrl := fmt.Sprintf("http://grafana:password@%s/api/datasources", grafanaListedAddr)
resp := postRequest(t, dataSourcesUrl, buf.String(), http.StatusOK) // nolint
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var res map[string]interface{}
err = json.Unmarshal(b, &res)
require.NoError(t, err)
require.Equal(t, "Datasource added", res["message"])
}
// Add another alertmanager datasource
{
cmd := datasources.AddDataSourceCommand{
OrgId: 1,
Name: "AM2",
Type: datasources.DS_ALERTMANAGER,
Access: "proxy",
Url: fakeAM2.URL(),
JsonData: simplejson.NewFromAny(map[string]interface{}{
"handleGrafanaManagedAlerts": true,
"implementation": "prometheus",
}),
}
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err := enc.Encode(&cmd)
require.NoError(t, err)
dataSourcesUrl := fmt.Sprintf("http://grafana:password@%s/api/datasources", grafanaListedAddr)
resp := postRequest(t, dataSourcesUrl, buf.String(), http.StatusOK) // nolint
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var res map[string]interface{}
err = json.Unmarshal(b, &res)
require.NoError(t, err)
require.Equal(t, "Datasource added", res["message"])
}
// Now, lets re-set external Alertmanagers for main organisation
// and make it so that only the external Alertmanagers handle the alerts.
{
ac := apimodels.PostableNGalertConfig{
Alertmanagers: []string{fakeAM1.URL(), fakeAM2.URL()},
AlertmanagersChoice: apimodels.AlertmanagersChoice(ngmodels.ExternalAlertmanagers.String()),
}
buf := bytes.Buffer{}
@ -139,7 +194,7 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) {
resp := getRequest(t, alertsURL, http.StatusOK) // nolint
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.JSONEq(t, fmt.Sprintf("{\"alertmanagers\":[\"%s\",\"%s\"], \"alertmanagersChoice\": %q}\n", fakeAM1.URL(), fakeAM2.URL(), ngmodels.ExternalAlertmanagers), string(b))
require.JSONEq(t, fmt.Sprintf("{\"alertmanagersChoice\": %q}\n", ngmodels.ExternalAlertmanagers), string(b))
}
// With the configuration set, we should eventually discover those Alertmanagers.
@ -214,12 +269,37 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) {
}, 60*time.Second, 5*time.Second)
}
// Add an alertmanager datasource fot the other organisation
{
cmd := datasources.AddDataSourceCommand{
OrgId: 2,
Name: "AM3",
Type: datasources.DS_ALERTMANAGER,
Access: "proxy",
Url: fakeAM3.URL(),
JsonData: simplejson.NewFromAny(map[string]interface{}{
"handleGrafanaManagedAlerts": true,
"implementation": "prometheus",
}),
}
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err := enc.Encode(&cmd)
require.NoError(t, err)
dataSourcesUrl := fmt.Sprintf("http://admin-42:admin-42@%s/api/datasources", grafanaListedAddr)
resp := postRequest(t, dataSourcesUrl, buf.String(), http.StatusOK) // nolint
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var res map[string]interface{}
err = json.Unmarshal(b, &res)
require.NoError(t, err)
require.Equal(t, "Datasource added", res["message"])
}
// Now, lets re-set external Alertmanagers for the other organisation.
// Sending an empty value for AlertmanagersChoice should default to AllAlertmanagers.
{
ac := apimodels.PostableNGalertConfig{
Alertmanagers: []string{fakeAM3.URL()},
}
ac := apimodels.PostableNGalertConfig{}
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
err := enc.Encode(&ac)
@ -241,7 +321,7 @@ func TestAdminConfiguration_SendingToExternalAlertmanagers(t *testing.T) {
resp := getRequest(t, alertsURL, http.StatusOK) // nolint
b, err := io.ReadAll(resp.Body)
require.NoError(t, err)
require.JSONEq(t, fmt.Sprintf("{\"alertmanagers\":[\"%s\"], \"alertmanagersChoice\": %q}\n", fakeAM3.URL(), ngmodels.AllAlertmanagers), string(b))
require.JSONEq(t, fmt.Sprintf("{\"alertmanagersChoice\": %q}\n", ngmodels.AllAlertmanagers), string(b))
}
// With the configuration set, we should eventually not discover Alertmanagers.

View File

@ -1,139 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { FC, useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Field, FieldArray, Form, Icon, Input, Modal, useStyles2 } from '@grafana/ui';
import { AlertmanagerUrl } from 'app/plugins/datasource/alertmanager/types';
interface Props {
onClose: () => void;
alertmanagers: AlertmanagerUrl[];
onChangeAlertmanagerConfig: (alertmanagers: string[]) => void;
}
export const AddAlertManagerModal: FC<Props> = ({ alertmanagers, onChangeAlertmanagerConfig, onClose }) => {
const styles = useStyles2(getStyles);
const defaultValues: Record<string, AlertmanagerUrl[]> = useMemo(
() => ({
alertmanagers: alertmanagers,
}),
[alertmanagers]
);
const modalTitle = (
<div className={styles.modalTitle}>
<Icon name="bell" className={styles.modalIcon} />
<h3>Add Alertmanager</h3>
</div>
);
const onSubmit = (values: Record<string, AlertmanagerUrl[]>) => {
onChangeAlertmanagerConfig(values.alertmanagers.map((am) => cleanAlertmanagerUrl(am.url)));
onClose();
};
return (
<Modal title={modalTitle} isOpen={true} onDismiss={onClose} className={styles.modal}>
<div className={styles.description}>
We use a service discovery method to find existing Alertmanagers for a given URL.
</div>
<Form onSubmit={onSubmit} defaultValues={defaultValues}>
{({ register, control, errors }) => (
<div>
<FieldArray control={control} name="alertmanagers">
{({ fields, append, remove }) => (
<div className={styles.fieldArray}>
<div className={styles.bold}>Source url</div>
<div className={styles.muted}>
Authentication can be done via URL (e.g. user:password@myalertmanager.com) and only the Alertmanager
v2 API is supported. The suffix is added internally, there is no need to specify it.
</div>
{fields.map((field, index) => {
return (
<Field
invalid={!!errors?.alertmanagers?.[index]}
error="Field is required"
key={`${field.id}-${index}`}
>
<Input
className={styles.input}
defaultValue={field.url}
{...register(`alertmanagers.${index}.url`, { required: true })}
placeholder="http://localhost:9093"
addonAfter={
<Button
aria-label="Remove alertmanager"
type="button"
onClick={() => remove(index)}
variant="destructive"
className={styles.destroyInputRow}
>
<Icon name="trash-alt" />
</Button>
}
/>
</Field>
);
})}
<Button type="button" variant="secondary" onClick={() => append({ url: '' })}>
Add URL
</Button>
</div>
)}
</FieldArray>
<div>
<Button type="submit" onSubmit={() => onSubmit}>
Add Alertmanagers
</Button>
</div>
</div>
)}
</Form>
</Modal>
);
};
function cleanAlertmanagerUrl(url: string): string {
return url.replace(/\/$/, '').replace(/\/api\/v[1|2]\/alerts/i, '');
}
const getStyles = (theme: GrafanaTheme2) => {
const muted = css`
color: ${theme.colors.text.secondary};
`;
return {
description: cx(
css`
margin-bottom: ${theme.spacing(2)};
`,
muted
),
muted: muted,
bold: css`
font-weight: ${theme.typography.fontWeightBold};
`,
modal: css``,
modalIcon: cx(
muted,
css`
margin-right: ${theme.spacing(1)};
`
),
modalTitle: css`
display: flex;
`,
input: css`
margin-bottom: ${theme.spacing(1)};
margin-right: ${theme.spacing(1)};
`,
inputRow: css`
display: flex;
`,
destroyInputRow: css`
padding: ${theme.spacing(1)};
`,
fieldArray: css`
margin-bottom: ${theme.spacing(4)};
`,
};
};

View File

@ -18,7 +18,7 @@ export function ExternalAlertmanagerDataSources({ alertmanagers, inactive }: Ext
return (
<>
<h5>Alertmanagers data sources</h5>
<h5>Alertmanagers Receiving Grafana-managed alerts</h5>
<div className={styles.muted}>
Alertmanager data sources support a configuration setting that allows you to choose to send Grafana-managed
alerts to that Alertmanager. <br />
@ -102,6 +102,8 @@ export function ExternalAMdataSourceCard({ alertmanager, inactive }: ExternalAMd
export const getStyles = (theme: GrafanaTheme2) => ({
muted: css`
font-size: ${theme.typography.bodySmall.fontSize};
line-height: ${theme.typography.bodySmall.lineHeight};
color: ${theme.colors.text.secondary};
`,
externalHeading: css`

View File

@ -1,28 +1,15 @@
import { css, cx } from '@emotion/css';
import React, { useCallback, useEffect, useState } from 'react';
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import {
Alert,
Button,
ConfirmModal,
Field,
HorizontalGroup,
Icon,
RadioButtonGroup,
Tooltip,
useStyles2,
useTheme2,
} from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { Alert, Field, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { loadDataSources } from 'app/features/datasources/state/actions';
import { AlertmanagerChoice } from 'app/plugins/datasource/alertmanager/types';
import { useDispatch } from 'app/types';
import { alertmanagerApi } from '../../api/alertmanagerApi';
import { useExternalAmSelector, useExternalDataSourceAlertmanagers } from '../../hooks/useExternalAmSelector';
import { useExternalDataSourceAlertmanagers } from '../../hooks/useExternalAmSelector';
import { AddAlertManagerModal } from './AddAlertManagerModal';
import { ExternalAlertmanagerDataSources } from './ExternalAlertmanagerDataSources';
const alertmanagerChoices: Array<SelectableValue<AlertmanagerChoice>> = [
@ -34,10 +21,7 @@ const alertmanagerChoices: Array<SelectableValue<AlertmanagerChoice>> = [
export const ExternalAlertmanagers = () => {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const [modalState, setModalState] = useState({ open: false, payload: [{ url: '' }] });
const [deleteModalState, setDeleteModalState] = useState({ open: false, index: 0 });
const externalAlertManagers = useExternalAmSelector();
const externalDsAlertManagers = useExternalDataSourceAlertmanagers();
const {
@ -53,84 +37,15 @@ export const ExternalAlertmanagers = () => {
useGetExternalAlertmanagersQuery(undefined, { pollingInterval: 5000 });
const alertmanagersChoice = externalAlertmanagerConfig?.alertmanagersChoice;
const theme = useTheme2();
useEffect(() => {
dispatch(loadDataSources());
}, [dispatch]);
const onDelete = useCallback(
(index: number) => {
// to delete we need to filter the alertmanager from the list and repost
const newList = (externalAlertManagers ?? [])
.filter((am, i) => i !== index)
.map((am) => {
return am.url;
});
saveExternalAlertManagers({
alertmanagers: newList,
alertmanagersChoice: alertmanagersChoice ?? AlertmanagerChoice.All,
});
setDeleteModalState({ open: false, index: 0 });
},
[externalAlertManagers, saveExternalAlertManagers, alertmanagersChoice]
);
const onEdit = useCallback(() => {
const ams = externalAlertManagers ? [...externalAlertManagers] : [{ url: '' }];
setModalState((state) => ({
...state,
open: true,
payload: ams,
}));
}, [setModalState, externalAlertManagers]);
const onOpenModal = useCallback(() => {
setModalState((state) => {
const ams = externalAlertManagers ? [...externalAlertManagers, { url: '' }] : [{ url: '' }];
return {
...state,
open: true,
payload: ams,
};
});
}, [externalAlertManagers]);
const onCloseModal = useCallback(() => {
setModalState((state) => ({
...state,
open: false,
}));
}, [setModalState]);
const onChangeAlertmanagerChoice = (alertmanagersChoice: AlertmanagerChoice) => {
saveExternalAlertManagers({ alertmanagers: externalAlertManagers.map((am) => am.url), alertmanagersChoice });
saveExternalAlertManagers({ alertmanagersChoice });
};
const onChangeAlertmanagers = (alertmanagers: string[]) => {
saveExternalAlertManagers({
alertmanagers,
alertmanagersChoice: alertmanagersChoice ?? AlertmanagerChoice.All,
});
};
const getStatusColor = (status: string) => {
switch (status) {
case 'active':
return theme.colors.success.main;
case 'pending':
return theme.colors.warning.main;
default:
return theme.colors.error.main;
}
};
const noAlertmanagers = externalAlertManagers?.length === 0;
return (
<div>
<h4>External Alertmanagers</h4>
@ -142,15 +57,10 @@ export const ExternalAlertmanagers = () => {
For more information, refer to our documentation.
</Alert>
<ExternalAlertmanagerDataSources
alertmanagers={externalDsAlertManagers}
inactive={alertmanagersChoice === AlertmanagerChoice.Internal}
/>
<div className={styles.amChoice}>
<Field
label="Send alerts to"
description="Configures how the Grafana alert rule evaluation engine Alertmanager handles your alerts. Internal (Grafana built-in Alertmanager), External (All Alertmanagers configured above), or both."
description="Configures how the Grafana alert rule evaluation engine Alertmanager handles your alerts. Internal (Grafana built-in Alertmanager), External (All Alertmanagers configured below), or both."
>
<RadioButtonGroup
options={alertmanagerChoices}
@ -160,95 +70,10 @@ export const ExternalAlertmanagers = () => {
</Field>
</div>
<h5>Alertmanagers by URL</h5>
<Alert severity="warning" title="Deprecation Notice">
The URL-based configuration of Alertmanagers is deprecated and will be removed in Grafana 9.2.0.
<br />
Use Alertmanager data sources to configure your external Alertmanagers.
</Alert>
<div className={styles.muted}>
You can have your Grafana managed alerts be delivered to one or many external Alertmanager(s) in addition to the
internal Alertmanager by specifying their URLs below.
</div>
<div className={styles.actions}>
{!noAlertmanagers && (
<Button type="button" onClick={onOpenModal}>
Add Alertmanager
</Button>
)}
</div>
{noAlertmanagers ? (
<EmptyListCTA
title="You have not added any external alertmanagers"
onClick={onOpenModal}
buttonTitle="Add Alertmanager"
buttonIcon="bell-slash"
/>
) : (
<>
<table className={cx('filter-table form-inline filter-table--hover', styles.table)}>
<thead>
<tr>
<th>Url</th>
<th>Status</th>
<th style={{ width: '2%' }}>Action</th>
</tr>
</thead>
<tbody>
{externalAlertManagers?.map((am, index) => {
return (
<tr key={index}>
<td>
<span className={styles.url}>{am.url}</span>
{am.actualUrl ? (
<Tooltip content={`Discovered ${am.actualUrl} from ${am.url}`} theme="info">
<Icon name="info-circle" />
</Tooltip>
) : null}
</td>
<td>
<Icon name="heart" style={{ color: getStatusColor(am.status) }} title={am.status} />
</td>
<td>
<HorizontalGroup>
<Button variant="secondary" type="button" onClick={onEdit} aria-label="Edit alertmanager">
<Icon name="pen" />
</Button>
<Button
variant="destructive"
aria-label="Remove alertmanager"
type="button"
onClick={() => setDeleteModalState({ open: true, index })}
>
<Icon name="trash-alt" />
</Button>
</HorizontalGroup>
</td>
</tr>
);
})}
</tbody>
</table>
</>
)}
<ConfirmModal
isOpen={deleteModalState.open}
title="Remove Alertmanager"
body="Are you sure you want to remove this Alertmanager"
confirmText="Remove"
onConfirm={() => onDelete(deleteModalState.index)}
onDismiss={() => setDeleteModalState({ open: false, index: 0 })}
<ExternalAlertmanagerDataSources
alertmanagers={externalDsAlertManagers}
inactive={alertmanagersChoice === AlertmanagerChoice.Internal}
/>
{modalState.open && (
<AddAlertManagerModal
onClose={onCloseModal}
alertmanagers={modalState.payload}
onChangeAlertmanagerConfig={onChangeAlertmanagers}
/>
)}
</div>
);
};
@ -257,9 +82,6 @@ export const getStyles = (theme: GrafanaTheme2) => ({
url: css`
margin-right: ${theme.spacing(1)};
`,
muted: css`
color: ${theme.colors.text.secondary};
`,
actions: css`
margin-top: ${theme.spacing(2)};
display: flex;

View File

@ -8,12 +8,12 @@ import 'whatwg-fetch';
import { DataSourceJsonData, DataSourceSettings } from '@grafana/data';
import { config } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv';
import { AlertmanagerChoice, AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { AlertManagerDataSourceJsonData } from 'app/plugins/datasource/alertmanager/types';
import { mockDataSource, mockDataSourcesStore, mockStore } from '../mocks';
import { mockAlertmanagerConfigResponse, mockAlertmanagersResponse } from '../mocks/alertmanagerApi';
import { mockAlertmanagersResponse } from '../mocks/alertmanagerApi';
import { useExternalAmSelector, useExternalDataSourceAlertmanagers } from './useExternalAmSelector';
import { useExternalDataSourceAlertmanagers } from './useExternalAmSelector';
const server = setupServer();
@ -34,184 +34,6 @@ afterAll(() => {
server.close();
});
describe('useExternalAmSelector', () => {
it('should have one in pending', async () => {
mockAlertmanagersResponse(server, {
data: {
activeAlertManagers: [],
droppedAlertManagers: [],
},
});
mockAlertmanagerConfigResponse(server, {
alertmanagers: ['some/url/to/am'],
alertmanagersChoice: AlertmanagerChoice.All,
});
const store = mockStore(() => null);
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper });
await waitFor(() => result.current.length > 0);
const { current: alertmanagers } = result;
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
status: 'pending',
actualUrl: '',
},
]);
});
it('should have one active, one pending', async () => {
mockAlertmanagersResponse(server, {
data: {
activeAlertManagers: [{ url: 'some/url/to/am/api/v2/alerts' }],
droppedAlertManagers: [],
},
});
mockAlertmanagerConfigResponse(server, {
alertmanagers: ['some/url/to/am', 'some/url/to/am1'],
alertmanagersChoice: AlertmanagerChoice.All,
});
const store = mockStore(() => null);
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper });
await waitFor(() => result.current.length > 0);
const { current: alertmanagers } = result;
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
actualUrl: 'some/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'some/url/to/am1',
actualUrl: '',
status: 'pending',
},
]);
});
it('should have two active', async () => {
mockAlertmanagersResponse(server, {
data: {
activeAlertManagers: [{ url: 'some/url/to/am/api/v2/alerts' }, { url: 'some/url/to/am1/api/v2/alerts' }],
droppedAlertManagers: [],
},
});
mockAlertmanagerConfigResponse(server, {
alertmanagers: ['some/url/to/am', 'some/url/to/am1'],
alertmanagersChoice: AlertmanagerChoice.All,
});
const store = mockStore(() => null);
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper });
await waitFor(() => result.current.length > 0);
const { current: alertmanagers } = result;
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
actualUrl: 'some/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'some/url/to/am1',
actualUrl: 'some/url/to/am1/api/v2/alerts',
status: 'active',
},
]);
});
it('should have one active, one dropped, one pending', async () => {
mockAlertmanagersResponse(server, {
data: {
activeAlertManagers: [{ url: 'some/url/to/am/api/v2/alerts' }],
droppedAlertManagers: [{ url: 'some/dropped/url/api/v2/alerts' }],
},
});
mockAlertmanagerConfigResponse(server, {
alertmanagers: ['some/url/to/am', 'some/url/to/am1'],
alertmanagersChoice: AlertmanagerChoice.All,
});
const store = mockStore(() => null);
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper });
await waitFor(() => result.current.length > 0);
const { current: alertmanagers } = result;
expect(alertmanagers).toEqual([
{
url: 'some/url/to/am',
actualUrl: 'some/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'some/url/to/am1',
actualUrl: '',
status: 'pending',
},
{
url: 'some/dropped/url',
actualUrl: 'some/dropped/url/api/v2/alerts',
status: 'dropped',
},
]);
});
it('The number of alert managers should match config entries when there are multiple entries of the same url', async () => {
mockAlertmanagersResponse(server, {
data: {
activeAlertManagers: [
{ url: 'same/url/to/am/api/v2/alerts' },
{ url: 'same/url/to/am/api/v2/alerts' },
{ url: 'same/url/to/am/api/v2/alerts' },
],
droppedAlertManagers: [],
},
});
mockAlertmanagerConfigResponse(server, {
alertmanagers: ['same/url/to/am', 'same/url/to/am', 'same/url/to/am'],
alertmanagersChoice: AlertmanagerChoice.All,
});
const store = mockStore(() => null);
const wrapper = ({ children }: React.PropsWithChildren<{}>) => <Provider store={store}>{children}</Provider>;
const { result, waitFor } = renderHook(() => useExternalAmSelector(), { wrapper });
await waitFor(() => result.current.length > 0);
const { current: alertmanagers } = result;
expect(alertmanagers.length).toBe(3);
expect(alertmanagers).toEqual([
{
url: 'same/url/to/am',
actualUrl: 'same/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'same/url/to/am',
actualUrl: 'same/url/to/am/api/v2/alerts',
status: 'active',
},
{
url: 'same/url/to/am',
actualUrl: 'same/url/to/am/api/v2/alerts',
status: 'active',
},
]);
});
});
describe('useExternalDataSourceAlertmanagers', () => {
it('Should merge data sources information from config and api responses', async () => {
// Arrange

View File

@ -7,54 +7,6 @@ import { useSelector } from 'app/types';
import { alertmanagerApi } from '../api/alertmanagerApi';
import { getAlertManagerDataSources } from '../utils/datasource';
const SUFFIX_REGEX = /\/api\/v[1|2]\/alerts/i;
type AlertmanagerConfig = { url: string; status: string; actualUrl: string };
export function useExternalAmSelector(): AlertmanagerConfig[] | [] {
const { useGetExternalAlertmanagersQuery, useGetExternalAlertmanagerConfigQuery } = alertmanagerApi;
const { currentData: discoveredAlertmanagers } = useGetExternalAlertmanagersQuery();
const { currentData: alertmanagerConfig } = useGetExternalAlertmanagerConfigQuery();
if (!discoveredAlertmanagers || !alertmanagerConfig) {
return [];
}
const enabledAlertmanagers: AlertmanagerConfig[] = [];
const droppedAlertmanagers: AlertmanagerConfig[] = discoveredAlertmanagers.droppedAlertManagers.map((am) => ({
url: am.url.replace(SUFFIX_REGEX, ''),
status: 'dropped',
actualUrl: am.url,
}));
for (const url of alertmanagerConfig.alertmanagers) {
if (discoveredAlertmanagers.activeAlertManagers.length === 0) {
enabledAlertmanagers.push({
url: url,
status: 'pending',
actualUrl: '',
});
} else {
const matchingActiveAM = discoveredAlertmanagers.activeAlertManagers.find(
(am) => am.url === `${url}/api/v2/alerts`
);
matchingActiveAM
? enabledAlertmanagers.push({
url: matchingActiveAM.url.replace(SUFFIX_REGEX, ''),
status: 'active',
actualUrl: matchingActiveAM.url,
})
: enabledAlertmanagers.push({
url: url,
status: 'pending',
actualUrl: '',
});
}
}
return [...enabledAlertmanagers, ...droppedAlertmanagers];
}
export interface ExternalDataSourceAM {
dataSource: DataSourceInstanceSettings<AlertManagerDataSourceJsonData>;
url?: string;

View File

@ -1,10 +1,7 @@
import { rest } from 'msw';
import { SetupServerApi } from 'msw/node';
import {
ExternalAlertmanagerConfig,
ExternalAlertmanagersResponse,
} from '../../../../plugins/datasource/alertmanager/types';
import { ExternalAlertmanagersResponse } from '../../../../plugins/datasource/alertmanager/types';
import { AlertmanagersChoiceResponse } from '../api/alertmanagerApi';
export function mockAlertmanagerChoiceResponse(server: SetupServerApi, respose: AlertmanagersChoiceResponse) {
@ -14,7 +11,3 @@ export function mockAlertmanagerChoiceResponse(server: SetupServerApi, respose:
export function mockAlertmanagersResponse(server: SetupServerApi, response: ExternalAlertmanagersResponse) {
server.use(rest.get('/api/v1/ngalert/alertmanagers', (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
}
export function mockAlertmanagerConfigResponse(server: SetupServerApi, response: ExternalAlertmanagerConfig) {
server.use(rest.get('/api/v1/ngalert/admin_config', (req, res, ctx) => res(ctx.status(200), ctx.json(response))));
}

View File

@ -286,7 +286,6 @@ export enum AlertmanagerChoice {
}
export interface ExternalAlertmanagerConfig {
alertmanagers: string[];
alertmanagersChoice: AlertmanagerChoice;
}