mirror of
https://github.com/grafana/grafana.git
synced 2024-11-24 09:50:29 -06:00
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:
parent
738e023d13
commit
45facbba11
@ -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}
|
||||
|
@ -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{}
|
||||
|
@ -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",
|
||||
|
@ -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"`
|
||||
}
|
||||
|
||||
|
@ -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",
|
||||
|
@ -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",
|
||||
|
@ -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]
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
125
pkg/services/sqlstore/migrations/external_alertmanagers.go
Normal file
125
pkg/services/sqlstore/migrations/external_alertmanagers.go
Normal 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
|
||||
}
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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)};
|
||||
`,
|
||||
};
|
||||
};
|
@ -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`
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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))));
|
||||
}
|
||||
|
@ -286,7 +286,6 @@ export enum AlertmanagerChoice {
|
||||
}
|
||||
|
||||
export interface ExternalAlertmanagerConfig {
|
||||
alertmanagers: string[];
|
||||
alertmanagersChoice: AlertmanagerChoice;
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user