mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Alerting: Support for simplified notification settings in rule API (#81011)
* Add notification settings to storage\domain and API models. Settings are a slice to workaround XORM mapping * Support validation of notification settings when rules are updated * Implement route generator for Alertmanager configuration. That fetches all notification settings. * Update multi-tenant Alertmanager to run the generator before applying the configuration. * Add notification settings labels to state calculation * update the Multi-tenant Alertmanager to provide validation for notification settings * update GET API so only admins can see auto-gen
This commit is contained in:
@@ -2,12 +2,14 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/exp/maps"
|
||||
"golang.org/x/exp/slices"
|
||||
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
@@ -18,8 +20,10 @@ import (
|
||||
ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models"
|
||||
"github.com/grafana/grafana/pkg/services/org"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore"
|
||||
"github.com/grafana/grafana/pkg/services/sqlstore/migrator"
|
||||
"github.com/grafana/grafana/pkg/services/store/entity"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
// AlertRuleMaxTitleLength is the maximum length of the alert rule title
|
||||
@@ -141,22 +145,23 @@ func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRu
|
||||
}
|
||||
newRules = append(newRules, r)
|
||||
ruleVersions = append(ruleVersions, ngmodels.AlertRuleVersion{
|
||||
RuleUID: r.UID,
|
||||
RuleOrgID: r.OrgID,
|
||||
RuleNamespaceUID: r.NamespaceUID,
|
||||
RuleGroup: r.RuleGroup,
|
||||
ParentVersion: 0,
|
||||
Version: r.Version,
|
||||
Created: r.Updated,
|
||||
Condition: r.Condition,
|
||||
Title: r.Title,
|
||||
Data: r.Data,
|
||||
IntervalSeconds: r.IntervalSeconds,
|
||||
NoDataState: r.NoDataState,
|
||||
ExecErrState: r.ExecErrState,
|
||||
For: r.For,
|
||||
Annotations: r.Annotations,
|
||||
Labels: r.Labels,
|
||||
RuleUID: r.UID,
|
||||
RuleOrgID: r.OrgID,
|
||||
RuleNamespaceUID: r.NamespaceUID,
|
||||
RuleGroup: r.RuleGroup,
|
||||
ParentVersion: 0,
|
||||
Version: r.Version,
|
||||
Created: r.Updated,
|
||||
Condition: r.Condition,
|
||||
Title: r.Title,
|
||||
Data: r.Data,
|
||||
IntervalSeconds: r.IntervalSeconds,
|
||||
NoDataState: r.NoDataState,
|
||||
ExecErrState: r.ExecErrState,
|
||||
For: r.For,
|
||||
Annotations: r.Annotations,
|
||||
Labels: r.Labels,
|
||||
NotificationSettings: r.NotificationSettings,
|
||||
})
|
||||
}
|
||||
if len(newRules) > 0 {
|
||||
@@ -216,23 +221,24 @@ func (st DBstore) UpdateAlertRules(ctx context.Context, rules []ngmodels.UpdateR
|
||||
}
|
||||
parentVersion = r.Existing.Version
|
||||
ruleVersions = append(ruleVersions, ngmodels.AlertRuleVersion{
|
||||
RuleOrgID: r.New.OrgID,
|
||||
RuleUID: r.New.UID,
|
||||
RuleNamespaceUID: r.New.NamespaceUID,
|
||||
RuleGroup: r.New.RuleGroup,
|
||||
RuleGroupIndex: r.New.RuleGroupIndex,
|
||||
ParentVersion: parentVersion,
|
||||
Version: r.New.Version + 1,
|
||||
Created: r.New.Updated,
|
||||
Condition: r.New.Condition,
|
||||
Title: r.New.Title,
|
||||
Data: r.New.Data,
|
||||
IntervalSeconds: r.New.IntervalSeconds,
|
||||
NoDataState: r.New.NoDataState,
|
||||
ExecErrState: r.New.ExecErrState,
|
||||
For: r.New.For,
|
||||
Annotations: r.New.Annotations,
|
||||
Labels: r.New.Labels,
|
||||
RuleOrgID: r.New.OrgID,
|
||||
RuleUID: r.New.UID,
|
||||
RuleNamespaceUID: r.New.NamespaceUID,
|
||||
RuleGroup: r.New.RuleGroup,
|
||||
RuleGroupIndex: r.New.RuleGroupIndex,
|
||||
ParentVersion: parentVersion,
|
||||
Version: r.New.Version + 1,
|
||||
Created: r.New.Updated,
|
||||
Condition: r.New.Condition,
|
||||
Title: r.New.Title,
|
||||
Data: r.New.Data,
|
||||
IntervalSeconds: r.New.IntervalSeconds,
|
||||
NoDataState: r.New.NoDataState,
|
||||
ExecErrState: r.New.ExecErrState,
|
||||
For: r.New.For,
|
||||
Annotations: r.New.Annotations,
|
||||
Labels: r.New.Labels,
|
||||
NotificationSettings: r.New.NotificationSettings,
|
||||
})
|
||||
}
|
||||
if len(ruleVersions) > 0 {
|
||||
@@ -365,6 +371,13 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
|
||||
q = q.Where("rule_group = ?", query.RuleGroup)
|
||||
}
|
||||
|
||||
if query.ReceiverName != "" {
|
||||
q, err = st.filterByReceiverName(query.ReceiverName, q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
q = q.Asc("namespace_uid", "rule_group", "rule_group_idx", "id")
|
||||
|
||||
alertRules := make([]*ngmodels.AlertRule, 0)
|
||||
@@ -385,6 +398,13 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
|
||||
st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "ListAlertRules", "error", err)
|
||||
continue
|
||||
}
|
||||
if query.ReceiverName != "" { // remove false-positive hits from the result
|
||||
if !slices.ContainsFunc(rule.NotificationSettings, func(settings ngmodels.NotificationSettings) bool {
|
||||
return settings.Receiver == query.ReceiverName
|
||||
}) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
alertRules = append(alertRules, rule)
|
||||
}
|
||||
|
||||
@@ -648,3 +668,91 @@ func (st DBstore) validateAlertRule(alertRule ngmodels.AlertRule) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListNotificationSettings fetches all notification settings for given organization
|
||||
func (st DBstore) ListNotificationSettings(ctx context.Context, q ngmodels.ListNotificationSettingsQuery) (map[ngmodels.AlertRuleKey][]ngmodels.NotificationSettings, error) {
|
||||
var rules []ngmodels.AlertRule
|
||||
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
||||
query := sess.Table(ngmodels.AlertRule{}).Select("uid, notification_settings").Where("org_id = ?", q.OrgID)
|
||||
if q.ReceiverName != "" {
|
||||
var err error
|
||||
query, err = st.filterByReceiverName(q.ReceiverName, query)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
query = query.And("notification_settings IS NOT NULL AND notification_settings <> 'null'")
|
||||
}
|
||||
return query.Find(&rules)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[ngmodels.AlertRuleKey][]ngmodels.NotificationSettings, len(rules))
|
||||
for _, rule := range rules {
|
||||
var ns []ngmodels.NotificationSettings
|
||||
if q.ReceiverName != "" { // if filter by receiver name is specified, perform fine filtering on client to avoid false-positives
|
||||
for _, setting := range rule.NotificationSettings {
|
||||
if q.ReceiverName == setting.Receiver { // currently, there can be only one setting. If in future there are more, we will return all settings of a rule that has a setting with receiver
|
||||
ns = rule.NotificationSettings
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ns = rule.NotificationSettings
|
||||
}
|
||||
if len(ns) > 0 {
|
||||
key := ngmodels.AlertRuleKey{
|
||||
OrgID: q.OrgID,
|
||||
UID: rule.UID,
|
||||
}
|
||||
result[key] = rule.NotificationSettings
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (st DBstore) filterByReceiverName(receiver string, sess *xorm.Session) (*xorm.Session, error) {
|
||||
if receiver == "" {
|
||||
return sess, nil
|
||||
}
|
||||
// marshall string according to JSON rules so we follow escaping rules.
|
||||
b, err := json.Marshal(receiver)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshall receiver name query: %w", err)
|
||||
}
|
||||
var search = string(b)
|
||||
if st.SQLStore.GetDialect().DriverName() != migrator.SQLite {
|
||||
// this escapes escaped double quote (\") to \\\"
|
||||
search = strings.ReplaceAll(strings.ReplaceAll(search, `\`, `\\`), `"`, `\"`)
|
||||
}
|
||||
return sess.And(fmt.Sprintf("notification_settings %s ?", st.SQLStore.GetDialect().LikeStr()), "%"+search+"%"), nil
|
||||
}
|
||||
|
||||
func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string) (int, error) {
|
||||
// fetch entire rules because Update method requires it because it copies rules to version table
|
||||
rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{
|
||||
OrgID: orgID,
|
||||
ReceiverName: oldReceiver,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
var updates []ngmodels.UpdateRule
|
||||
for _, rule := range rules {
|
||||
r := ngmodels.CopyRule(rule)
|
||||
for idx := range r.NotificationSettings {
|
||||
if r.NotificationSettings[idx].Receiver == oldReceiver {
|
||||
r.NotificationSettings[idx].Receiver = newReceiver
|
||||
}
|
||||
}
|
||||
updates = append(updates, ngmodels.UpdateRule{
|
||||
Existing: rule,
|
||||
New: *r,
|
||||
})
|
||||
}
|
||||
return len(updates), st.UpdateAlertRules(ctx, updates)
|
||||
}
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
"github.com/grafana/grafana/pkg/bus"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -650,6 +652,168 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
|
||||
require.ErrorContains(t, err, deref[0].NamespaceUID)
|
||||
}
|
||||
|
||||
func TestIntegrationAlertRulesNotificationSettings(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
sqlStore := db.InitTestDB(t)
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedAlerting.BaseInterval = 1 * time.Second
|
||||
store := &DBstore{
|
||||
SQLStore: sqlStore,
|
||||
FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()),
|
||||
Logger: log.New("test-dbstore"),
|
||||
Cfg: cfg.UnifiedAlerting,
|
||||
}
|
||||
|
||||
uniqueUids := &sync.Map{}
|
||||
receiverName := "receiver\"-" + uuid.NewString()
|
||||
rules := models.GenerateAlertRules(3, models.AlertRuleGen(models.WithOrgID(1), withIntervalMatching(store.Cfg.BaseInterval), models.WithUniqueUID(uniqueUids)))
|
||||
receiveRules := models.GenerateAlertRules(3,
|
||||
models.AlertRuleGen(
|
||||
models.WithOrgID(1),
|
||||
withIntervalMatching(store.Cfg.BaseInterval),
|
||||
models.WithUniqueUID(uniqueUids),
|
||||
models.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithReceiver(receiverName)))))
|
||||
noise := models.GenerateAlertRules(3,
|
||||
models.AlertRuleGen(
|
||||
models.WithOrgID(1),
|
||||
withIntervalMatching(store.Cfg.BaseInterval),
|
||||
models.WithUniqueUID(uniqueUids),
|
||||
models.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithMuteTimeIntervals(receiverName))))) // simulate collision of names of receiver and mute timing
|
||||
deref := make([]models.AlertRule, 0, len(rules)+len(receiveRules)+len(noise))
|
||||
for _, rule := range append(append(rules, receiveRules...), noise...) {
|
||||
r := *rule
|
||||
r.ID = 0
|
||||
deref = append(deref, r)
|
||||
}
|
||||
|
||||
_, err := store.InsertAlertRules(context.Background(), deref)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("should find rules by receiver name", func(t *testing.T) {
|
||||
expectedUIDs := map[string]struct{}{}
|
||||
for _, rule := range receiveRules {
|
||||
expectedUIDs[rule.UID] = struct{}{}
|
||||
}
|
||||
actual, err := store.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
|
||||
OrgID: 1,
|
||||
ReceiverName: receiverName,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, actual, len(expectedUIDs))
|
||||
for _, rule := range actual {
|
||||
assert.Contains(t, expectedUIDs, rule.UID)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RenameReceiverInNotificationSettings should update all rules that refer to the old receiver", func(t *testing.T) {
|
||||
newName := "new-receiver"
|
||||
affected, err := store.RenameReceiverInNotificationSettings(context.Background(), 1, receiverName, newName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, len(receiveRules), affected)
|
||||
|
||||
expectedUIDs := map[string]struct{}{}
|
||||
for _, rule := range receiveRules {
|
||||
expectedUIDs[rule.UID] = struct{}{}
|
||||
}
|
||||
actual, err := store.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
|
||||
OrgID: 1,
|
||||
ReceiverName: newName,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, actual, len(expectedUIDs))
|
||||
for _, rule := range actual {
|
||||
assert.Contains(t, expectedUIDs, rule.UID)
|
||||
}
|
||||
|
||||
actual, err = store.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{
|
||||
OrgID: 1,
|
||||
ReceiverName: receiverName,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, actual)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationListNotificationSettings(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
|
||||
sqlStore := db.InitTestDB(t)
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedAlerting.BaseInterval = 1 * time.Second
|
||||
store := &DBstore{
|
||||
SQLStore: sqlStore,
|
||||
FolderService: setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()),
|
||||
Logger: log.New("test-dbstore"),
|
||||
Cfg: cfg.UnifiedAlerting,
|
||||
}
|
||||
|
||||
uids := &sync.Map{}
|
||||
titles := &sync.Map{}
|
||||
receiverName := `receiver%"-👍'test`
|
||||
rulesWithNotifications := models.GenerateAlertRules(5, models.AlertRuleGen(
|
||||
models.WithOrgID(1),
|
||||
models.WithUniqueUID(uids),
|
||||
models.WithUniqueTitle(titles),
|
||||
withIntervalMatching(store.Cfg.BaseInterval),
|
||||
models.WithNotificationSettingsGen(models.NotificationSettingsGen(models.NSMuts.WithReceiver(receiverName))),
|
||||
))
|
||||
rulesInOtherOrg := models.GenerateAlertRules(5, models.AlertRuleGen(
|
||||
models.WithOrgID(2),
|
||||
models.WithUniqueUID(uids),
|
||||
models.WithUniqueTitle(titles),
|
||||
withIntervalMatching(store.Cfg.BaseInterval),
|
||||
models.WithNotificationSettingsGen(models.NotificationSettingsGen()),
|
||||
))
|
||||
rulesWithNoNotifications := models.GenerateAlertRules(5, models.AlertRuleGen(
|
||||
models.WithOrgID(1),
|
||||
models.WithUniqueUID(uids),
|
||||
models.WithUniqueTitle(titles),
|
||||
withIntervalMatching(store.Cfg.BaseInterval),
|
||||
models.WithNoNotificationSettings(),
|
||||
))
|
||||
deref := make([]models.AlertRule, 0, len(rulesWithNotifications)+len(rulesWithNoNotifications)+len(rulesInOtherOrg))
|
||||
for _, rule := range append(append(rulesWithNotifications, rulesWithNoNotifications...), rulesInOtherOrg...) {
|
||||
r := *rule
|
||||
r.ID = 0
|
||||
deref = append(deref, r)
|
||||
}
|
||||
|
||||
_, err := store.InsertAlertRules(context.Background(), deref)
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := store.ListNotificationSettings(context.Background(), models.ListNotificationSettingsQuery{OrgID: 1})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result, len(rulesWithNotifications))
|
||||
for _, rule := range rulesWithNotifications {
|
||||
if !assert.Contains(t, result, rule.GetKey()) {
|
||||
continue
|
||||
}
|
||||
assert.EqualValues(t, rule.NotificationSettings, result[rule.GetKey()])
|
||||
}
|
||||
|
||||
t.Run("should list notification settings by receiver name", func(t *testing.T) {
|
||||
expectedUIDs := map[models.AlertRuleKey]struct{}{}
|
||||
for _, rule := range rulesWithNotifications {
|
||||
expectedUIDs[rule.GetKey()] = struct{}{}
|
||||
}
|
||||
|
||||
actual, err := store.ListNotificationSettings(context.Background(), models.ListNotificationSettingsQuery{
|
||||
OrgID: 1,
|
||||
ReceiverName: receiverName,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, actual, len(expectedUIDs))
|
||||
for ruleKey := range actual {
|
||||
assert.Contains(t, expectedUIDs, ruleKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// createAlertRule creates an alert rule in the database and returns it.
|
||||
// If a generator is not specified, uniqueness of primary key is not guaranteed.
|
||||
func createRule(t *testing.T, store *DBstore, generate func() *models.AlertRule) *models.AlertRule {
|
||||
|
||||
@@ -31,6 +31,27 @@ func (c *GroupDelta) IsEmpty() bool {
|
||||
return len(c.Update)+len(c.New)+len(c.Delete) == 0
|
||||
}
|
||||
|
||||
// NewOrUpdatedNotificationSettings returns a list of notification settings that are either new or updated in the group.
|
||||
func (c *GroupDelta) NewOrUpdatedNotificationSettings() []models.NotificationSettings {
|
||||
var settings []models.NotificationSettings
|
||||
for _, rule := range c.New {
|
||||
if len(rule.NotificationSettings) > 0 {
|
||||
settings = append(settings, rule.NotificationSettings...)
|
||||
}
|
||||
}
|
||||
for _, delta := range c.Update {
|
||||
if len(delta.New.NotificationSettings) == 0 {
|
||||
continue
|
||||
}
|
||||
d := delta.Diff.GetDiffsForField("NotificationSettings")
|
||||
if len(d) == 0 {
|
||||
continue
|
||||
}
|
||||
settings = append(settings, delta.New.NotificationSettings...)
|
||||
}
|
||||
return settings
|
||||
}
|
||||
|
||||
type RuleReader interface {
|
||||
ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) (models.RulesGroup, error)
|
||||
GetAlertRulesGroupByRuleUID(ctx context.Context, query *models.GetAlertRulesGroupByRuleUIDQuery) ([]*models.AlertRule, error)
|
||||
|
||||
Reference in New Issue
Block a user