Alerting: Remove constraints for uniqueness of rule title (#102067)
* fix having duplicated names in same group in the UI --------- Co-authored-by: Sonia Aguilar <soniaaguilarpeiron@gmail.com>
This commit is contained in:
co-authored by
Sonia Aguilar
parent
e948376a95
commit
e39b17d701
@@ -8,6 +8,8 @@ import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
alertmanager_config "github.com/prometheus/alertmanager/config"
|
||||
|
||||
"github.com/grafana/grafana/pkg/api/response"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
@@ -21,7 +23,6 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
|
||||
"github.com/grafana/grafana/pkg/services/ngalert/store"
|
||||
"github.com/grafana/grafana/pkg/util"
|
||||
alertmanager_config "github.com/prometheus/alertmanager/config"
|
||||
)
|
||||
|
||||
const disableProvenanceHeaderName = "X-Disable-Provenance"
|
||||
@@ -357,9 +358,6 @@ func (srv *ProvisioningSrv) RoutePostAlertRule(c *contextmodel.ReqContext, ar de
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, alerting_models.ErrAlertRuleUniqueConstraintViolation) {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
if errors.Is(err, store.ErrOptimisticLock) {
|
||||
return ErrResp(http.StatusConflict, err, "")
|
||||
}
|
||||
@@ -396,9 +394,6 @@ func (srv *ProvisioningSrv) RoutePutAlertRule(c *contextmodel.ReqContext, ar def
|
||||
updated.UID = UID
|
||||
provenance := determineProvenance(c)
|
||||
updatedAlertRule, err := srv.alertRules.UpdateAlertRule(c.Req.Context(), c.SignedInUser, updated, alerting_models.Provenance(provenance))
|
||||
if errors.Is(err, alerting_models.ErrAlertRuleUniqueConstraintViolation) {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
if errors.Is(err, alerting_models.ErrAlertRuleNotFound) {
|
||||
return response.Empty(http.StatusNotFound)
|
||||
}
|
||||
@@ -514,9 +509,6 @@ func (srv *ProvisioningSrv) RoutePutAlertRuleGroup(c *contextmodel.ReqContext, a
|
||||
}
|
||||
provenance := determineProvenance(c)
|
||||
err = srv.alertRules.ReplaceRuleGroup(c.Req.Context(), c.SignedInUser, groupModel, alerting_models.Provenance(provenance))
|
||||
if errors.Is(err, alerting_models.ErrAlertRuleUniqueConstraintViolation) {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
if errors.Is(err, alerting_models.ErrAlertRuleFailedValidation) {
|
||||
return ErrResp(http.StatusBadRequest, err, "")
|
||||
}
|
||||
|
||||
@@ -34,11 +34,10 @@ var (
|
||||
// ErrAlertRuleFailedGenerateUniqueUID is an error for failure to generate alert rule UID
|
||||
ErrAlertRuleFailedGenerateUniqueUID = errors.New("failed to generate alert rule UID")
|
||||
// ErrCannotEditNamespace is an error returned if the user does not have permissions to edit the namespace
|
||||
ErrCannotEditNamespace = errors.New("user does not have permissions to edit the namespace")
|
||||
ErrRuleGroupNamespaceNotFound = errors.New("rule group not found under this namespace")
|
||||
ErrAlertRuleFailedValidation = errors.New("invalid alert rule")
|
||||
ErrAlertRuleUniqueConstraintViolation = errors.New("rule title under the same organisation and folder should be unique")
|
||||
ErrQuotaReached = errors.New("quota has been exceeded")
|
||||
ErrCannotEditNamespace = errors.New("user does not have permissions to edit the namespace")
|
||||
ErrRuleGroupNamespaceNotFound = errors.New("rule group not found under this namespace")
|
||||
ErrAlertRuleFailedValidation = errors.New("invalid alert rule")
|
||||
ErrQuotaReached = errors.New("quota has been exceeded")
|
||||
// ErrNoDashboard is returned when the alert rule does not have a Dashboard UID
|
||||
// in its annotations or the dashboard does not exist.
|
||||
ErrNoDashboard = errors.New("no dashboard")
|
||||
|
||||
@@ -28,11 +28,13 @@ func ErrAlertRuleConflictVerbose(existingPartialRule, rule AlertRule, underlying
|
||||
"RuleUID": rule.UID,
|
||||
"Title": rule.Title,
|
||||
"NamespaceUID": rule.NamespaceUID,
|
||||
"RuleGroup": rule.RuleGroup,
|
||||
},
|
||||
"Existing": map[string]any{
|
||||
"RuleUID": existingPartialRule.UID,
|
||||
"Title": existingPartialRule.Title,
|
||||
"NamespaceUID": existingPartialRule.NamespaceUID,
|
||||
"RuleGroup": existingPartialRule.RuleGroup,
|
||||
},
|
||||
"Error": underlying.Error(),
|
||||
}, Error: underlying})
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
||||
"github.com/grafana/grafana/pkg/infra/db"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
||||
"github.com/grafana/grafana/pkg/services/dashboards"
|
||||
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
||||
@@ -374,7 +373,7 @@ func (st DBstore) InsertAlertRules(ctx context.Context, user *ngmodels.UserUID,
|
||||
for i := range newRules {
|
||||
if _, err := sess.Insert(&newRules[i]); err != nil {
|
||||
if st.SQLStore.GetDialect().IsUniqueConstraintViolation(err) {
|
||||
return ruleConstraintViolationToErr(sess, rules[i], err, st.Logger)
|
||||
return ruleConstraintViolationToErr(rules[i], err)
|
||||
}
|
||||
return fmt.Errorf("failed to create new rules: %w", err)
|
||||
}
|
||||
@@ -432,7 +431,7 @@ func (st DBstore) UpdateAlertRules(ctx context.Context, user *ngmodels.UserUID,
|
||||
if updated, err := sess.ID(r.Existing.ID).AllCols().Omit("rule_guid").Update(converted); err != nil || updated == 0 {
|
||||
if err != nil {
|
||||
if st.SQLStore.GetDialect().IsUniqueConstraintViolation(err) {
|
||||
return ruleConstraintViolationToErr(sess, r.New, err, st.Logger)
|
||||
return ruleConstraintViolationToErr(r.New, err)
|
||||
}
|
||||
return fmt.Errorf("failed to update rule [%s] %s: %w", r.New.UID, r.New.Title, err)
|
||||
}
|
||||
@@ -1201,22 +1200,9 @@ func (st DBstore) RenameTimeIntervalInNotificationSettings(
|
||||
return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates)
|
||||
}
|
||||
|
||||
func ruleConstraintViolationToErr(sess *db.Session, rule ngmodels.AlertRule, err error, logger log.Logger) error {
|
||||
func ruleConstraintViolationToErr(rule ngmodels.AlertRule, err error) error {
|
||||
msg := err.Error()
|
||||
if strings.Contains(msg, "UQE_alert_rule_org_id_namespace_uid_title") || strings.Contains(msg, "alert_rule.org_id, alert_rule.namespace_uid, alert_rule.title") {
|
||||
// return verbose conflicting alert rule error response
|
||||
// see: https://github.com/grafana/grafana/issues/89755
|
||||
var fetched_uid string
|
||||
var existingPartialAlertRule ngmodels.AlertRule
|
||||
ok, uid_fetch_err := sess.Table("alert_rule").Cols("uid").Where("org_id = ? AND title = ? AND namespace_uid = ?", rule.OrgID, rule.Title, rule.NamespaceUID).Get(&fetched_uid)
|
||||
if uid_fetch_err != nil {
|
||||
logger.Error("Error fetching uid from alert_rule table", "reason", uid_fetch_err.Error())
|
||||
}
|
||||
if ok {
|
||||
existingPartialAlertRule = ngmodels.AlertRule{UID: fetched_uid, Title: rule.Title, NamespaceUID: rule.NamespaceUID}
|
||||
}
|
||||
return ngmodels.ErrAlertRuleConflictVerbose(existingPartialAlertRule, rule, ngmodels.ErrAlertRuleUniqueConstraintViolation)
|
||||
} else if strings.Contains(msg, "UQE_alert_rule_org_id_uid") || strings.Contains(msg, "alert_rule.org_id, alert_rule.uid") {
|
||||
if strings.Contains(msg, "UQE_alert_rule_org_id_uid") || strings.Contains(msg, "alert_rule.org_id, alert_rule.uid") {
|
||||
// return verbose conflicting alert rule error response
|
||||
// see: https://github.com/grafana/grafana/issues/89755
|
||||
existingPartialAlertRule := ngmodels.AlertRule{UID: rule.UID}
|
||||
|
||||
@@ -230,257 +230,6 @@ func TestIntegrationUpdateAlertRules(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegrationUpdateAlertRulesWithUniqueConstraintViolation(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
}
|
||||
usr := models.UserUID("test")
|
||||
cfg := setting.NewCfg()
|
||||
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{BaseInterval: time.Duration(rand.Int63n(100)+1) * time.Second}
|
||||
sqlStore := db.InitTestDB(t)
|
||||
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
|
||||
b := &fakeBus{}
|
||||
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
|
||||
|
||||
gen := models.RuleGen
|
||||
createRuleInFolder := func(title string, orgID int64, namespaceUID string) *models.AlertRule {
|
||||
gen := gen.With(
|
||||
gen.WithOrgID(orgID),
|
||||
gen.WithIntervalMatching(store.Cfg.BaseInterval),
|
||||
gen.WithNamespaceUID(namespaceUID),
|
||||
)
|
||||
return createRule(t, store, gen)
|
||||
}
|
||||
|
||||
t.Run("should handle update chains without unique constraint violation", func(t *testing.T) {
|
||||
rule1 := createRuleInFolder("chain-rule1", 1, "my-namespace")
|
||||
rule2 := createRuleInFolder("chain-rule2", 1, "my-namespace")
|
||||
|
||||
newRule1 := models.CopyRule(rule1)
|
||||
newRule2 := models.CopyRule(rule2)
|
||||
newRule1.Title = rule2.Title
|
||||
newRule2.Title = util.GenerateShortUID()
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule1,
|
||||
New: *newRule1,
|
||||
}, {
|
||||
Existing: rule2,
|
||||
New: *newRule2,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
dbrule1 := &alertRule{}
|
||||
dbrule2 := &alertRule{}
|
||||
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
exist, err := sess.Table(alertRule{}).ID(rule1.ID).Get(dbrule1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
|
||||
|
||||
exist, err = sess.Table(alertRule{}).ID(rule2.ID).Get(dbrule2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, newRule1.Title, dbrule1.Title)
|
||||
require.Equal(t, newRule2.Title, dbrule2.Title)
|
||||
})
|
||||
|
||||
t.Run("should handle update chains with cycle without unique constraint violation", func(t *testing.T) {
|
||||
rule1 := createRuleInFolder("cycle-rule1", 1, "my-namespace")
|
||||
rule2 := createRuleInFolder("cycle-rule2", 1, "my-namespace")
|
||||
rule3 := createRuleInFolder("cycle-rule3", 1, "my-namespace")
|
||||
|
||||
newRule1 := models.CopyRule(rule1)
|
||||
newRule2 := models.CopyRule(rule2)
|
||||
newRule3 := models.CopyRule(rule3)
|
||||
newRule1.Title = rule2.Title
|
||||
newRule2.Title = rule3.Title
|
||||
newRule3.Title = rule1.Title
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule1,
|
||||
New: *newRule1,
|
||||
}, {
|
||||
Existing: rule2,
|
||||
New: *newRule2,
|
||||
}, {
|
||||
Existing: rule3,
|
||||
New: *newRule3,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
dbrule1 := &alertRule{}
|
||||
dbrule2 := &alertRule{}
|
||||
dbrule3 := &alertRule{}
|
||||
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
exist, err := sess.Table(alertRule{}).ID(rule1.ID).Get(dbrule1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
|
||||
|
||||
exist, err = sess.Table(alertRule{}).ID(rule2.ID).Get(dbrule2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
|
||||
|
||||
exist, err = sess.Table(alertRule{}).ID(rule3.ID).Get(dbrule3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule3.ID))
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, newRule1.Title, dbrule1.Title)
|
||||
require.Equal(t, newRule2.Title, dbrule2.Title)
|
||||
require.Equal(t, newRule3.Title, dbrule3.Title)
|
||||
})
|
||||
|
||||
t.Run("should handle case-insensitive intermediate collision without unique constraint violation", func(t *testing.T) {
|
||||
rule1 := createRuleInFolder("case-cycle-rule1", 1, "my-namespace")
|
||||
rule2 := createRuleInFolder("case-cycle-rule2", 1, "my-namespace")
|
||||
|
||||
newRule1 := models.CopyRule(rule1)
|
||||
newRule2 := models.CopyRule(rule2)
|
||||
newRule1.Title = strings.ToUpper(rule2.Title)
|
||||
newRule2.Title = strings.ToUpper(rule1.Title)
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule1,
|
||||
New: *newRule1,
|
||||
}, {
|
||||
Existing: rule2,
|
||||
New: *newRule2,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
dbrule1 := &alertRule{}
|
||||
dbrule2 := &alertRule{}
|
||||
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
exist, err := sess.Table(alertRule{}).ID(rule1.ID).Get(dbrule1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
|
||||
|
||||
exist, err = sess.Table(alertRule{}).ID(rule2.ID).Get(dbrule2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, newRule1.Title, dbrule1.Title)
|
||||
require.Equal(t, newRule2.Title, dbrule2.Title)
|
||||
})
|
||||
|
||||
t.Run("should handle update multiple chains in different folders without unique constraint violation", func(t *testing.T) {
|
||||
rule1 := createRuleInFolder("multi-cycle-rule1", 1, "my-namespace")
|
||||
rule2 := createRuleInFolder("multi-cycle-rule2", 1, "my-namespace")
|
||||
rule3 := createRuleInFolder("multi-cycle-rule1", 1, "my-namespace2")
|
||||
rule4 := createRuleInFolder("multi-cycle-rule2", 1, "my-namespace2")
|
||||
|
||||
newRule1 := models.CopyRule(rule1)
|
||||
newRule2 := models.CopyRule(rule2)
|
||||
newRule3 := models.CopyRule(rule3)
|
||||
newRule4 := models.CopyRule(rule4)
|
||||
newRule1.Title = rule2.Title
|
||||
newRule2.Title = rule1.Title
|
||||
newRule3.Title = rule4.Title
|
||||
newRule4.Title = rule3.Title
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule1,
|
||||
New: *newRule1,
|
||||
}, {
|
||||
Existing: rule2,
|
||||
New: *newRule2,
|
||||
}, {
|
||||
Existing: rule3,
|
||||
New: *newRule3,
|
||||
}, {
|
||||
Existing: rule4,
|
||||
New: *newRule4,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
dbrule1 := &alertRule{}
|
||||
dbrule2 := &alertRule{}
|
||||
dbrule3 := &alertRule{}
|
||||
dbrule4 := &alertRule{}
|
||||
err = sqlStore.WithDbSession(context.Background(), func(sess *db.Session) error {
|
||||
exist, err := sess.Table(alertRule{}).ID(rule1.ID).Get(dbrule1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule1.ID))
|
||||
|
||||
exist, err = sess.Table(alertRule{}).ID(rule2.ID).Get(dbrule2)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule2.ID))
|
||||
|
||||
exist, err = sess.Table(alertRule{}).ID(rule3.ID).Get(dbrule3)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule3.ID))
|
||||
|
||||
exist, err = sess.Table(alertRule{}).ID(rule4.ID).Get(dbrule4)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
require.Truef(t, exist, fmt.Sprintf("rule with ID %d does not exist", rule4.ID))
|
||||
return nil
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, newRule1.Title, dbrule1.Title)
|
||||
require.Equal(t, newRule2.Title, dbrule2.Title)
|
||||
require.Equal(t, newRule3.Title, dbrule3.Title)
|
||||
require.Equal(t, newRule4.Title, dbrule4.Title)
|
||||
})
|
||||
|
||||
t.Run("should fail with unique constraint violation", func(t *testing.T) {
|
||||
rule1 := createRuleInFolder("unique-rule1", 1, "my-namespace")
|
||||
rule2 := createRuleInFolder("unique-rule2", 1, "my-namespace")
|
||||
|
||||
newRule1 := models.CopyRule(rule1)
|
||||
newRule2 := models.CopyRule(rule2)
|
||||
newRule2.Title = newRule1.Title
|
||||
|
||||
err := store.UpdateAlertRules(context.Background(), &usr, []models.UpdateRule{{
|
||||
Existing: rule2,
|
||||
New: *newRule2,
|
||||
},
|
||||
})
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleUniqueConstraintViolation)
|
||||
require.NotEqual(t, newRule2.UID, "")
|
||||
require.NotEqual(t, newRule2.Title, "")
|
||||
require.NotEqual(t, newRule2.NamespaceUID, "")
|
||||
require.ErrorContains(t, err, newRule2.UID)
|
||||
require.ErrorContains(t, err, newRule2.Title)
|
||||
require.ErrorContains(t, err, newRule2.NamespaceUID)
|
||||
})
|
||||
}
|
||||
|
||||
func TestIntegration_GetAlertRulesForScheduling(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test")
|
||||
@@ -1078,18 +827,11 @@ func TestIntegrationInsertAlertRules(t *testing.T) {
|
||||
_, err = store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{rules[0]})
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleConflictBase)
|
||||
})
|
||||
t.Run("fail insert rules with the same title in a folder", func(t *testing.T) {
|
||||
t.Run("should not fail insert rules with the same title in a folder", func(t *testing.T) {
|
||||
cp := models.CopyRule(&rules[0])
|
||||
cp.UID = cp.UID + "-new"
|
||||
_, err = store.InsertAlertRules(context.Background(), &usr, []models.AlertRule{*cp})
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleConflictBase)
|
||||
require.ErrorIs(t, err, models.ErrAlertRuleUniqueConstraintViolation)
|
||||
require.NotEqual(t, rules[0].UID, "")
|
||||
require.NotEqual(t, rules[0].Title, "")
|
||||
require.NotEqual(t, rules[0].NamespaceUID, "")
|
||||
require.ErrorContains(t, err, rules[0].UID)
|
||||
require.ErrorContains(t, err, rules[0].Title)
|
||||
require.ErrorContains(t, err, rules[0].NamespaceUID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
t.Run("should not let insert rules with the same UID", func(t *testing.T) {
|
||||
cp := models.CopyRule(&rules[0])
|
||||
|
||||
@@ -153,4 +153,6 @@ func (oss *OSSMigrations) AddMigration(mg *Migrator) {
|
||||
ualert.AddAlertRuleMissingSeriesEvalsToResolve(mg)
|
||||
|
||||
accesscontrol.AddDatasourceDrilldownRemovalMigration(mg)
|
||||
|
||||
ualert.DropTitleUniqueIndexMigration(mg)
|
||||
}
|
||||
|
||||
@@ -195,6 +195,8 @@ func alertInstanceMigration(mg *migrator.Migrator) {
|
||||
}))
|
||||
}
|
||||
|
||||
var titleUniqueIndex = &migrator.Index{Cols: []string{"org_id", "namespace_uid", "title"}, Type: migrator.UniqueIndex}
|
||||
|
||||
func addAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) {
|
||||
// DO NOT EDIT
|
||||
alertRule := migrator.Table{
|
||||
@@ -245,9 +247,7 @@ func addAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64)
|
||||
Cols: []string{"org_id", "title"}, Type: migrator.UniqueIndex,
|
||||
}))
|
||||
|
||||
mg.AddMigration("add index in alert_rule on org_id, namespase_uid and title columns", migrator.NewAddIndexMigration(alertRule, &migrator.Index{
|
||||
Cols: []string{"org_id", "namespace_uid", "title"}, Type: migrator.UniqueIndex,
|
||||
}))
|
||||
mg.AddMigration("add index in alert_rule on org_id, namespase_uid and title columns", migrator.NewAddIndexMigration(alertRule, titleUniqueIndex))
|
||||
|
||||
mg.AddMigration("add dashboard_uid column to alert_rule", migrator.NewAddColumnMigration(
|
||||
migrator.Table{Name: "alert_rule"},
|
||||
@@ -571,3 +571,8 @@ func (c extractAlertmanagerConfigurationHistory) Exec(sess *xorm.Session, migrat
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func DropTitleUniqueIndexMigration(mg *migrator.Migrator) {
|
||||
mg.AddMigration("remove title in folder unique index",
|
||||
migrator.NewDropIndexMigration(migrator.Table{Name: "alert_rule"}, titleUniqueIndex))
|
||||
}
|
||||
|
||||
@@ -1001,28 +1001,20 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) {
|
||||
require.Equal(t, http.StatusAccepted, status)
|
||||
require.Len(t, createdRuleGroup.Rules, 2)
|
||||
|
||||
t.Run("trying to create alert with same title under same folder should fail", func(t *testing.T) {
|
||||
t.Run("trying to create alert with same title under same folder should not fail", func(t *testing.T) {
|
||||
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup.GettableRuleGroupConfig)
|
||||
rulesWithUID.Rules = append(rulesWithUID.Rules, rules.Rules[0]) // Create new copy of first rule.
|
||||
|
||||
_, status, body := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID, false)
|
||||
assert.Equal(t, http.StatusConflict, status)
|
||||
|
||||
var res map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &res))
|
||||
require.Contains(t, res["message"], ngmodels.ErrAlertRuleUniqueConstraintViolation.Error())
|
||||
requireStatusCode(t, http.StatusAccepted, status, body)
|
||||
})
|
||||
|
||||
t.Run("trying to update an alert to the title of an existing alert in the same folder should fail", func(t *testing.T) {
|
||||
t.Run("trying to update an alert to the title of an existing alert in the same folder should not fail", func(t *testing.T) {
|
||||
rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup.GettableRuleGroupConfig)
|
||||
rulesWithUID.Rules[1].GrafanaManagedAlert.Title = "AlwaysFiring"
|
||||
|
||||
_, status, body := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID, false)
|
||||
assert.Equal(t, http.StatusConflict, status)
|
||||
|
||||
var res map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &res))
|
||||
require.Contains(t, res["message"], ngmodels.ErrAlertRuleUniqueConstraintViolation.Error())
|
||||
requireStatusCode(t, http.StatusAccepted, status, body)
|
||||
})
|
||||
|
||||
t.Run("trying to create alert with same title under another folder should succeed", func(t *testing.T) {
|
||||
|
||||
@@ -91,7 +91,7 @@ export const AlertsFolderView = ({ folder }: Props) => {
|
||||
<Stack direction="column" gap={1}>
|
||||
{pageItems.map((currentRule) => (
|
||||
<Card
|
||||
key={currentRule.name}
|
||||
key={Boolean(currentRule.uid) ? currentRule.uid : currentRule.name}
|
||||
href={createViewLink('grafana', currentRule, '')}
|
||||
className={styles.card}
|
||||
data-testid="alert-card-row"
|
||||
|
||||
@@ -184,17 +184,21 @@ export function attachRulerRuleToCombinedRule(rule: CombinedRule, rulerGroup: Ru
|
||||
const combinedRulesFromRuler = rulerGroup.rules.map((rulerRule) =>
|
||||
rulerRuleToCombinedRule(rulerRule, rule.namespace, rule.group)
|
||||
);
|
||||
const existingRulerRulesByName = combinedRulesFromRuler.reduce((acc, rule) => {
|
||||
const sameNameRules = acc.get(rule.name);
|
||||
if (sameNameRules) {
|
||||
sameNameRules.push(rule);
|
||||
const existingRulerRulesByUidOrName = combinedRulesFromRuler.reduce((acc, rule) => {
|
||||
const sameNameOrUidRules = acc.get(getIdToCompare(rule));
|
||||
if (sameNameOrUidRules) {
|
||||
sameNameOrUidRules.push(rule);
|
||||
} else {
|
||||
acc.set(rule.name, [rule]);
|
||||
acc.set(getIdToCompare(rule), [rule]);
|
||||
}
|
||||
return acc;
|
||||
}, new Map<string, CombinedRule[]>());
|
||||
|
||||
const matchingRulerRule = getExistingRuleInGroup(rule.promRule, existingRulerRulesByName, rule.namespace.rulesSource);
|
||||
const matchingRulerRule = getExistingRuleInGroup(
|
||||
rule.promRule,
|
||||
existingRulerRulesByUidOrName,
|
||||
rule.namespace.rulesSource
|
||||
);
|
||||
if (matchingRulerRule) {
|
||||
rule.rulerRule = matchingRulerRule.rulerRule;
|
||||
rule.query = matchingRulerRule.query;
|
||||
@@ -283,15 +287,15 @@ export function addPromGroupsToCombinedNamespace(namespace: CombinedRuleNamespac
|
||||
...calculateGroupTotals(group),
|
||||
};
|
||||
|
||||
const combinedRulesByName = new Map<string, CombinedRule[]>();
|
||||
const combinedRulesByUidOrName = new Map<string, CombinedRule[]>();
|
||||
combinedGroup!.rules.forEach((r) => {
|
||||
// Prometheus rules do not have to be unique by name
|
||||
const existingRule = combinedRulesByName.get(r.name);
|
||||
existingRule ? existingRule.push(r) : combinedRulesByName.set(r.name, [r]);
|
||||
const existingRule = combinedRulesByUidOrName.get(getIdToCompare(r));
|
||||
existingRule ? existingRule.push(r) : combinedRulesByUidOrName.set(getIdToCompare(r), [r]);
|
||||
});
|
||||
|
||||
(group.rules ?? []).forEach((rule) => {
|
||||
const existingRule = getExistingRuleInGroup(rule, combinedRulesByName, namespace.rulesSource);
|
||||
const existingRule = getExistingRuleInGroup(rule, combinedRulesByUidOrName, namespace.rulesSource);
|
||||
if (existingRule) {
|
||||
existingRule.promRule = rule;
|
||||
existingRule.instanceTotals = prometheusRuleType.alertingRule(rule) ? calculateRuleTotals(rule) : {};
|
||||
@@ -405,6 +409,7 @@ function rulerRuleToCombinedRule(
|
||||
group,
|
||||
instanceTotals: {},
|
||||
filteredInstanceTotals: {},
|
||||
uid: rulerRuleType.grafana.rule(rule) ? rule.grafana_alert.uid : undefined,
|
||||
}
|
||||
: rulerRuleType.dataSource.recordingRule(rule)
|
||||
? {
|
||||
@@ -417,6 +422,7 @@ function rulerRuleToCombinedRule(
|
||||
group,
|
||||
instanceTotals: {},
|
||||
filteredInstanceTotals: {},
|
||||
uid: rulerRuleType.grafana.rule(rule) ? rule.grafana_alert.uid : undefined,
|
||||
}
|
||||
: {
|
||||
name: rule.grafana_alert.title,
|
||||
@@ -428,30 +434,31 @@ function rulerRuleToCombinedRule(
|
||||
group,
|
||||
instanceTotals: {},
|
||||
filteredInstanceTotals: {},
|
||||
uid: rulerRuleType.grafana.rule(rule) ? rule.grafana_alert.uid : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// find existing rule in group that matches the given prom rule
|
||||
function getExistingRuleInGroup(
|
||||
rule: Rule,
|
||||
existingCombinedRulesMap: Map<string, CombinedRule[]>,
|
||||
existingCombinedRulesMap: Map<string, CombinedRule[]>, // map by uid
|
||||
rulesSource: RulesSource
|
||||
): CombinedRule | undefined {
|
||||
// Using Map of name-based rules is important performance optimization for the code below
|
||||
// Otherwise we would perform find method multiple times on (possibly) thousands of rules
|
||||
|
||||
const nameMatchingRules = existingCombinedRulesMap.get(rule.name);
|
||||
if (!nameMatchingRules) {
|
||||
const fieldToSearch = getIdToCompare(rule);
|
||||
const uidOrNameMatchingRules = existingCombinedRulesMap.get(fieldToSearch);
|
||||
if (!uidOrNameMatchingRules) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (isGrafanaRulesSource(rulesSource)) {
|
||||
// assume grafana groups have only the one rule. check name anyway because paranoid
|
||||
return nameMatchingRules[0];
|
||||
return uidOrNameMatchingRules[0];
|
||||
}
|
||||
|
||||
// try finding a rule that matches name, labels, annotations and query
|
||||
const strictlyMatchingRule = nameMatchingRules.find(
|
||||
const strictlyMatchingRule = uidOrNameMatchingRules.find(
|
||||
(combinedRule) => !combinedRule.promRule && isCombinedRuleEqualToPromRule(combinedRule, rule, true)
|
||||
);
|
||||
if (strictlyMatchingRule) {
|
||||
@@ -460,7 +467,7 @@ function getExistingRuleInGroup(
|
||||
|
||||
// if that fails, try finding a rule that only matches name, labels and annotations.
|
||||
// loki & prom can sometimes modify the query so it doesnt match, eg `2 > 1` becomes `1`
|
||||
const looselyMatchingRule = nameMatchingRules.find(
|
||||
const looselyMatchingRule = uidOrNameMatchingRules.find(
|
||||
(combinedRule) => !combinedRule.promRule && isCombinedRuleEqualToPromRule(combinedRule, rule, false)
|
||||
);
|
||||
if (looselyMatchingRule) {
|
||||
@@ -591,3 +598,14 @@ export function useCombinedRules(
|
||||
result: rules,
|
||||
};
|
||||
}
|
||||
/**
|
||||
* Returns the ID of the rule to compare.
|
||||
* If the rule has a UID, it will be used, otherwise the rule name will be used.
|
||||
* cloud rules don't have a UID, so the name will be used.
|
||||
*/
|
||||
function getIdToCompare(rule: CombinedRule | Rule): string {
|
||||
if (Boolean(rule.uid)) {
|
||||
return rule.uid ?? '';
|
||||
}
|
||||
return rule.name;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { render as rtlRender, screen } from '@testing-library/react';
|
||||
import { Chance } from 'chance';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { SetupServer, setupServer } from 'msw/node';
|
||||
import { useParams } from 'react-router-dom-v5-compat';
|
||||
@@ -30,8 +31,10 @@ jest.mock('react-router-dom-v5-compat', () => ({
|
||||
const mockFolderName = 'myFolder';
|
||||
const mockFolderUid = '12345';
|
||||
|
||||
const mockRulerRulesResponse = getRulerRulesResponse(mockFolderName, mockFolderUid);
|
||||
const mockPrometheusRulesResponse = getPrometheusRulesResponse(mockFolderName);
|
||||
const random = Chance(1);
|
||||
const rule_uid = random.guid();
|
||||
const mockRulerRulesResponse = getRulerRulesResponse(mockFolderName, mockFolderUid, rule_uid);
|
||||
const mockPrometheusRulesResponse = getPrometheusRulesResponse(mockFolderName, rule_uid);
|
||||
|
||||
describe('browse-dashboards BrowseFolderAlertingPage', () => {
|
||||
(useParams as jest.Mock).mockReturnValue({ uid: mockFolderUid });
|
||||
|
||||
@@ -8,8 +8,8 @@ import {
|
||||
RulerRulesConfigDTO,
|
||||
} from 'app/types/unified-alerting-dto';
|
||||
|
||||
export function getRulerRulesResponse(folderName: string, folderUid: string, seed = 1): RulerRulesConfigDTO {
|
||||
const random = Chance(seed);
|
||||
export function getRulerRulesResponse(folderName: string, folderUid: string, rule_uid: string): RulerRulesConfigDTO {
|
||||
const random = Chance(1);
|
||||
return {
|
||||
[folderName]: [
|
||||
{
|
||||
@@ -43,7 +43,7 @@ export function getRulerRulesResponse(folderName: string, folderUid: string, see
|
||||
},
|
||||
},
|
||||
],
|
||||
uid: random.guid(),
|
||||
uid: rule_uid,
|
||||
namespace_uid: folderUid,
|
||||
rule_group: 'my-group',
|
||||
no_data_state: GrafanaAlertStateDecision.NoData,
|
||||
@@ -57,8 +57,8 @@ export function getRulerRulesResponse(folderName: string, folderUid: string, see
|
||||
};
|
||||
}
|
||||
|
||||
export function getPrometheusRulesResponse(folderName: string, seed = 1): PromRulesResponse {
|
||||
const random = Chance(seed);
|
||||
export function getPrometheusRulesResponse(folderName: string, rule_uid: string): PromRulesResponse {
|
||||
const random = Chance(1);
|
||||
return {
|
||||
status: 'success',
|
||||
data: {
|
||||
@@ -79,6 +79,7 @@ export function getPrometheusRulesResponse(folderName: string, seed = 1): PromRu
|
||||
type: PromRuleType.Alerting,
|
||||
lastEvaluation: '0001-01-01T00:00:00Z',
|
||||
evaluationTime: 0,
|
||||
uid: rule_uid,
|
||||
},
|
||||
],
|
||||
interval: 60,
|
||||
|
||||
@@ -122,6 +122,7 @@ interface PromRuleDTOBase {
|
||||
evaluationTime?: number;
|
||||
lastEvaluation?: string;
|
||||
lastError?: string;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export interface PromAlertingRuleDTO extends PromRuleDTOBase {
|
||||
|
||||
@@ -38,6 +38,7 @@ interface RuleBase {
|
||||
lastEvaluation?: string;
|
||||
evaluationTime?: number;
|
||||
lastError?: string;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
export interface AlertingRule extends RuleBase {
|
||||
@@ -135,6 +136,7 @@ export interface CombinedRule {
|
||||
namespace: CombinedRuleNamespace;
|
||||
instanceTotals: AlertInstanceTotals;
|
||||
filteredInstanceTotals: AlertInstanceTotals;
|
||||
uid?: string;
|
||||
}
|
||||
|
||||
// export type AlertInstanceState = PromAlertingRuleState | 'nodata' | 'error';
|
||||
|
||||
Reference in New Issue
Block a user