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:
Yuri Tseretyan
2024-02-15 09:45:10 -05:00
committed by GitHub
parent ff916d9c15
commit 1eebd2a4de
60 changed files with 3466 additions and 304 deletions

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)