mirror of https://github.com/grafana/grafana
prometheushacktoberfestmetricsmonitoringalertinggrafanagoinfluxdbmysqlpostgresanalyticsdata-visualizationdashboardbusiness-intelligenceelasticsearch
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1256 lines
44 KiB
1256 lines
44 KiB
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/util/xorm"
|
|
|
|
"github.com/grafana/grafana/pkg/apimachinery/identity"
|
|
"github.com/grafana/grafana/pkg/infra/db"
|
|
"github.com/grafana/grafana/pkg/services/accesscontrol"
|
|
"github.com/grafana/grafana/pkg/services/dashboards"
|
|
"github.com/grafana/grafana/pkg/services/featuremgmt"
|
|
"github.com/grafana/grafana/pkg/services/folder"
|
|
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"
|
|
)
|
|
|
|
// AlertRuleMaxTitleLength is the maximum length of the alert rule title
|
|
const AlertRuleMaxTitleLength = 190
|
|
|
|
// AlertRuleMaxRuleGroupNameLength is the maximum length of the alert rule group name
|
|
const AlertRuleMaxRuleGroupNameLength = 190
|
|
|
|
var (
|
|
ErrOptimisticLock = errors.New("version conflict while updating a record in the database with optimistic locking")
|
|
)
|
|
|
|
// DeleteAlertRulesByUID is a handler for deleting an alert rule.
|
|
func (st DBstore) DeleteAlertRulesByUID(ctx context.Context, orgID int64, user *ngmodels.UserUID, permanently bool, ruleUID ...string) error {
|
|
if len(ruleUID) == 0 {
|
|
return nil
|
|
}
|
|
logger := st.Logger.New("org_id", orgID, "rule_uids", ruleUID)
|
|
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
|
rows, err := sess.Table(alertRule{}).Where("org_id = ?", orgID).In("uid", ruleUID).Delete(alertRule{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Debug("Deleted alert rules", "count", rows)
|
|
if rows > 0 {
|
|
keys := make([]ngmodels.AlertRuleKey, 0, len(ruleUID))
|
|
for _, uid := range ruleUID {
|
|
keys = append(keys, ngmodels.AlertRuleKey{OrgID: orgID, UID: uid})
|
|
}
|
|
_ = st.Bus.Publish(ctx, &RuleChangeEvent{
|
|
RuleKeys: keys,
|
|
})
|
|
}
|
|
|
|
rows, err = sess.Table("alert_instance").Where("rule_org_id = ?", orgID).In("rule_uid", ruleUID).Delete(alertRule{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Debug("Deleted alert instances", "count", rows)
|
|
|
|
rows, err = sess.Table("alert_rule_state").Where("org_id = ?", orgID).In("rule_uid", ruleUID).Delete(alertRule{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Debug("Deleted alert rule state", "count", rows)
|
|
|
|
var versions []alertRuleVersion
|
|
if st.FeatureToggles.IsEnabledGlobally(featuremgmt.FlagAlertRuleRestore) && st.Cfg.DeletedRuleRetention > 0 && !permanently { // save deleted version only if retention is greater than 0
|
|
versions, err = st.getLatestVersionOfRulesByUID(ctx, orgID, ruleUID)
|
|
if err != nil {
|
|
logger.Error("Failed to get latest version of deleted alert rules. The recovery will not be possible", "error", err)
|
|
}
|
|
for idx := range versions {
|
|
version := &versions[idx]
|
|
version.ID = 0
|
|
version.RuleUID = ""
|
|
version.Created = TimeNow()
|
|
version.CreatedBy = nil
|
|
if user != nil {
|
|
version.CreatedBy = util.Pointer(string(*user))
|
|
}
|
|
}
|
|
}
|
|
|
|
rows, err = sess.Table(alertRuleVersion{}).Where("rule_org_id = ?", orgID).In("rule_uid", ruleUID).Delete(alertRule{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logger.Debug("Deleted alert rule versions", "count", rows)
|
|
|
|
if len(versions) > 0 {
|
|
_, err = sess.Insert(versions)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to persist deleted rule for recovery: %w", err)
|
|
}
|
|
logger.Debug("Inserted alert rule versions for recovery", "count", len(versions))
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (st DBstore) getLatestVersionOfRulesByUID(ctx context.Context, orgID int64, ruleUIDs []string) ([]alertRuleVersion, error) {
|
|
var result []alertRuleVersion
|
|
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
args, in := getINSubQueryArgs(ruleUIDs)
|
|
// take only the latest versions of each rule by GUID
|
|
rows, err := sess.SQL(fmt.Sprintf(`
|
|
SELECT v1.* FROM alert_rule_version AS v1
|
|
INNER JOIN (
|
|
SELECT rule_guid, MAX(id) AS id
|
|
FROM alert_rule_version
|
|
WHERE rule_org_id = ?
|
|
AND rule_uid IN (%s)
|
|
GROUP BY rule_guid
|
|
) AS v2 ON v1.rule_guid = v2.rule_guid AND v1.id = v2.id
|
|
`, strings.Join(in, ",")), append([]any{orgID}, args...)...).Rows(new(alertRuleVersion))
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result = make([]alertRuleVersion, 0, len(ruleUIDs))
|
|
for rows.Next() {
|
|
rule := new(alertRuleVersion)
|
|
err = rows.Scan(rule)
|
|
if err != nil {
|
|
st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "getLatestVersionOfRulesByUID", "error", err)
|
|
continue
|
|
}
|
|
result = append(result, *rule)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// IncreaseVersionForAllRulesInNamespaces Increases version for all rules that have specified namespace. Returns all rules that belong to the namespaces
|
|
func (st DBstore) IncreaseVersionForAllRulesInNamespaces(ctx context.Context, orgID int64, namespaceUIDs []string) ([]ngmodels.AlertRuleKeyWithVersion, error) {
|
|
var keys []ngmodels.AlertRuleKeyWithVersion
|
|
err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
|
now := TimeNow()
|
|
namespaceUIDsArgs, in := getINSubQueryArgs(namespaceUIDs)
|
|
sql := fmt.Sprintf(
|
|
"UPDATE alert_rule SET version = version + 1, updated = ? WHERE org_id = ? AND namespace_uid IN (%s)",
|
|
strings.Join(in, ","),
|
|
)
|
|
args := make([]interface{}, 0, 3+len(namespaceUIDsArgs))
|
|
args = append(args, sql, now, orgID)
|
|
args = append(args, namespaceUIDsArgs...)
|
|
|
|
_, err := sess.Exec(args...)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return sess.Table(alertRule{}).Where("org_id = ?", orgID).In("namespace_uid", namespaceUIDs).Find(&keys)
|
|
})
|
|
return keys, err
|
|
}
|
|
|
|
// GetAlertRuleByUID is a handler for retrieving an alert rule from that database by its UID and organisation ID.
|
|
// It returns ngmodels.ErrAlertRuleNotFound if no alert rule is found for the provided ID.
|
|
func (st DBstore) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAlertRuleByUIDQuery) (result *ngmodels.AlertRule, err error) {
|
|
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
alertRule := alertRule{OrgID: query.OrgID, UID: query.UID}
|
|
has, err := sess.Get(&alertRule)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !has {
|
|
return ngmodels.ErrAlertRuleNotFound
|
|
}
|
|
r, err := alertRuleToModelsAlertRule(alertRule, st.Logger)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to convert alert rule: %w", err)
|
|
}
|
|
result = &r
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid string) ([]*ngmodels.AlertRule, error) {
|
|
alertRules := make([]*ngmodels.AlertRule, 0)
|
|
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
rows, err := sess.Table(new(alertRuleVersion)).Where("rule_org_id = ? AND rule_guid = ?", orgID, guid).Asc("id").Rows(new(alertRuleVersion))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Deserialize each rule separately in case any of them contain invalid JSON.
|
|
var previousVersion *alertRuleVersion
|
|
for rows.Next() {
|
|
rule := new(alertRuleVersion)
|
|
err = rows.Scan(rule)
|
|
if err != nil {
|
|
st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "GetAlertRuleVersions", "error", err)
|
|
continue
|
|
}
|
|
// skip version that has no diff with previous version
|
|
// this is pretty basic comparison, it may have false negatives
|
|
if previousVersion != nil && previousVersion.EqualSpec(*rule) {
|
|
continue
|
|
}
|
|
converted, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(*rule), st.Logger)
|
|
if err != nil {
|
|
st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "GetAlertRuleVersions", "error", err, "version_id", rule.ID)
|
|
continue
|
|
}
|
|
previousVersion = rule
|
|
alertRules = append(alertRules, &converted)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
slices.SortFunc(alertRules, func(a, b *ngmodels.AlertRule) int {
|
|
if a.ID > b.ID {
|
|
return -1
|
|
}
|
|
if a.ID < b.ID {
|
|
return 1
|
|
}
|
|
return 0
|
|
})
|
|
return alertRules, nil
|
|
}
|
|
|
|
// ListDeletedRules retrieves a list of deleted alert rules for the specified organization ID from the database.
|
|
// It ensures that only the latest version of each rule is included and filters out invalid or duplicated versions.
|
|
// Returns a slice of *models.AlertRule or an error if the operation fails.
|
|
func (st DBstore) ListDeletedRules(ctx context.Context, orgID int64) ([]*ngmodels.AlertRule, error) {
|
|
alertRules := make([]*ngmodels.AlertRule, 0)
|
|
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
// take only the latest versions of each rule by GUID
|
|
rows, err := sess.Table(alertRuleVersion{}).Where("rule_org_id = ? AND rule_uid = ''", orgID).Desc("created", "id").Rows(alertRuleVersion{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Deserialize each rule separately in case any of them contain invalid JSON.
|
|
for rows.Next() {
|
|
rule := new(alertRuleVersion)
|
|
err = rows.Scan(rule)
|
|
if err != nil {
|
|
st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "GetAlertRuleVersions", "error", err)
|
|
continue
|
|
}
|
|
converted, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(*rule), st.Logger)
|
|
if err != nil {
|
|
st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "GetAlertRuleVersions", "error", err, "version_id", rule.ID)
|
|
continue
|
|
}
|
|
alertRules = append(alertRules, &converted)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return alertRules, nil
|
|
}
|
|
|
|
// GetRuleByID retrieves models.AlertRule by ID.
|
|
// It returns models.ErrAlertRuleNotFound if no alert rule is found for the provided ID.
|
|
func (st DBstore) GetRuleByID(ctx context.Context, query ngmodels.GetAlertRuleByIDQuery) (result *ngmodels.AlertRule, err error) {
|
|
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
alertRule := alertRule{OrgID: query.OrgID, ID: query.ID}
|
|
has, err := sess.Get(&alertRule)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !has {
|
|
return ngmodels.ErrAlertRuleNotFound
|
|
}
|
|
r, err := alertRuleToModelsAlertRule(alertRule, st.Logger)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to convert alert rule: %w", err)
|
|
}
|
|
result = &r
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
// GetAlertRulesGroupByRuleUID is a handler for retrieving a group of alert rules from that database by UID and organisation ID of one of rules that belong to that group.
|
|
func (st DBstore) GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) (result []*ngmodels.AlertRule, err error) {
|
|
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
var rules []alertRule
|
|
err := sess.Table("alert_rule").Alias("a").Join(
|
|
"INNER",
|
|
"alert_rule AS b", "a.org_id = b.org_id AND a.namespace_uid = b.namespace_uid AND a.rule_group = b.rule_group AND b.uid = ?", query.UID,
|
|
).Where("a.org_id = ?", query.OrgID).Select("a.*").Find(&rules)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// MySQL by default compares strings without case-sensitivity, make sure we keep the case-sensitive comparison.
|
|
var groupName, namespaceUID string
|
|
// find the rule, which group we fetch
|
|
for _, rule := range rules {
|
|
if rule.UID == query.UID {
|
|
groupName = rule.RuleGroup
|
|
namespaceUID = rule.NamespaceUID
|
|
break
|
|
}
|
|
}
|
|
result = make([]*ngmodels.AlertRule, 0, len(rules))
|
|
// MySQL (and potentially other databases) can use case-insensitive comparison.
|
|
// This code makes sure we return groups that only exactly match the filter.
|
|
for _, rule := range rules {
|
|
if rule.RuleGroup != groupName || rule.NamespaceUID != namespaceUID {
|
|
continue
|
|
}
|
|
convert, err := alertRuleToModelsAlertRule(rule, st.Logger)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to convert alert rule %q: %w", rule.UID, err)
|
|
}
|
|
result = append(result, &convert)
|
|
}
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
// InsertAlertRules is a handler for creating/updating alert rules.
|
|
// Returns the UID and ID of rules that were created in the same order as the input rules.
|
|
func (st DBstore) InsertAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error) {
|
|
ids := make([]ngmodels.AlertRuleKeyWithId, 0, len(rules))
|
|
keys := make([]ngmodels.AlertRuleKey, 0, len(rules))
|
|
return ids, st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
|
newRules := make([]alertRule, 0, len(rules))
|
|
ruleVersions := make([]alertRuleVersion, 0, len(rules))
|
|
for i := range rules {
|
|
r := rules[i]
|
|
if r.UID == "" {
|
|
uid, err := GenerateNewAlertRuleUID(sess, r.OrgID, r.Title)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to generate UID for alert rule %q: %w", r.Title, err)
|
|
}
|
|
r.UID = uid
|
|
}
|
|
r.Version = 1
|
|
if err := st.validateAlertRule(r); err != nil {
|
|
return err
|
|
}
|
|
if err := (&r).PreSave(TimeNow, user); err != nil {
|
|
return err
|
|
}
|
|
|
|
converted, err := alertRuleFromModelsAlertRule(r)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to convert alert rule %q to storage model: %w", r.Title, err)
|
|
}
|
|
|
|
// assign unique identifier that will identify resource across space and time. The probability of collision is so low that we do not need to check for uniqueness.
|
|
// The unique keys will ensure uniqueness in rule and versions tables
|
|
converted.GUID = uuid.NewString()
|
|
|
|
newRules = append(newRules, converted)
|
|
ruleVersions = append(ruleVersions, alertRuleToAlertRuleVersion(converted))
|
|
}
|
|
if len(newRules) > 0 {
|
|
// we have to insert the rules one by one as otherwise we are
|
|
// not able to fetch the inserted id as it's not supported by xorm
|
|
for i := range newRules {
|
|
if _, err := sess.Insert(&newRules[i]); err != nil {
|
|
if st.SQLStore.GetDialect().IsUniqueConstraintViolation(err) {
|
|
return ruleConstraintViolationToErr(rules[i], err)
|
|
}
|
|
return fmt.Errorf("failed to create new rules: %w", err)
|
|
}
|
|
r := newRules[i]
|
|
key := ngmodels.AlertRuleKey{OrgID: r.OrgID, UID: r.UID}
|
|
|
|
ids = append(ids, ngmodels.AlertRuleKeyWithId{AlertRuleKey: key, ID: r.ID})
|
|
keys = append(keys, key)
|
|
}
|
|
}
|
|
|
|
if len(ruleVersions) > 0 {
|
|
if _, err := sess.Insert(&ruleVersions); err != nil {
|
|
return fmt.Errorf("failed to create new rule versions: %w", err)
|
|
}
|
|
}
|
|
|
|
if len(keys) > 0 {
|
|
_ = st.Bus.Publish(ctx, &RuleChangeEvent{
|
|
RuleKeys: keys,
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// UpdateAlertRules is a handler for updating alert rules.
|
|
func (st DBstore) UpdateAlertRules(ctx context.Context, user *ngmodels.UserUID, rules []ngmodels.UpdateRule) error {
|
|
return st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error {
|
|
err := st.preventIntermediateUniqueConstraintViolations(sess, rules)
|
|
if err != nil {
|
|
return fmt.Errorf("failed when preventing intermediate unique constraint violation: %w", err)
|
|
}
|
|
|
|
ruleVersions := make([]alertRuleVersion, 0, len(rules))
|
|
keys := make([]ngmodels.AlertRuleKey, 0, len(rules))
|
|
for i := range rules {
|
|
// We do indexed access way to avoid "G601: Implicit memory aliasing in for loop."
|
|
// Doing this will be unnecessary with go 1.22 https://stackoverflow.com/a/68247837/767660
|
|
r := rules[i]
|
|
r.New.ID = r.Existing.ID
|
|
r.New.Version = r.Existing.Version // xorm will take care of increasing it (see https://xorm.io/docs/chapter-06/1.lock/)
|
|
r.New.GUID = r.Existing.GUID
|
|
if err := st.validateAlertRule(r.New); err != nil {
|
|
return err
|
|
}
|
|
if err := (&r.New).PreSave(TimeNow, user); err != nil {
|
|
return err
|
|
}
|
|
converted, err := alertRuleFromModelsAlertRule(r.New)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to convert alert rule %s to storage model: %w", r.New.UID, err)
|
|
}
|
|
// no way to update multiple rules at once
|
|
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(r.New, err)
|
|
}
|
|
return fmt.Errorf("failed to update rule [%s] %s: %w", r.New.UID, r.New.Title, err)
|
|
}
|
|
return fmt.Errorf("%w: alert rule UID %s version %d", ErrOptimisticLock, r.New.UID, r.New.Version)
|
|
}
|
|
v := alertRuleToAlertRuleVersion(converted)
|
|
v.Version++
|
|
v.ParentVersion = r.Existing.Version
|
|
|
|
// check if there is diff between existing and new, and if no, skip saving version.
|
|
existingConverted, err := alertRuleFromModelsAlertRule(*r.Existing)
|
|
if err != nil || !alertRuleToAlertRuleVersion(existingConverted).EqualSpec(v) {
|
|
ruleVersions = append(ruleVersions, v)
|
|
}
|
|
|
|
keys = append(keys, ngmodels.AlertRuleKey{OrgID: r.New.OrgID, UID: r.New.UID})
|
|
}
|
|
if len(ruleVersions) > 0 {
|
|
if _, err := sess.Insert(&ruleVersions); err != nil {
|
|
return fmt.Errorf("failed to create new rule versions: %w", err)
|
|
}
|
|
st.deleteOldAlertRuleVersions(ctx, sess, ruleVersions)
|
|
}
|
|
if len(keys) > 0 {
|
|
_ = st.Bus.Publish(ctx, &RuleChangeEvent{
|
|
RuleKeys: keys,
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (st DBstore) deleteOldAlertRuleVersions(ctx context.Context, sess *db.Session, versions []alertRuleVersion) {
|
|
if st.Cfg.RuleVersionRecordLimit < 1 {
|
|
return
|
|
}
|
|
logger := st.Logger.FromContext(ctx)
|
|
for _, rv := range versions {
|
|
deleteTo := rv.Version - int64(st.Cfg.RuleVersionRecordLimit)
|
|
// if the last version is less that retention, do nothing
|
|
if deleteTo <= 1 {
|
|
continue
|
|
}
|
|
logger := logger.New("org_id", rv.RuleOrgID, "rule_uid", rv.RuleUID, "version", rv.Version, "limit", st.Cfg.RulesPerRuleGroupLimit)
|
|
res, err := sess.Exec(`DELETE FROM alert_rule_version WHERE rule_guid = ? AND version <= ?`, rv.RuleGUID, deleteTo)
|
|
if err != nil {
|
|
logger.Error("Failed to delete old alert rule versions", "error", err)
|
|
return
|
|
}
|
|
rows, err := res.RowsAffected()
|
|
if err != nil {
|
|
rows = -1
|
|
}
|
|
if rows != 0 {
|
|
logger.Info("Deleted old alert_rule_version(s)", "deleted", rows)
|
|
}
|
|
}
|
|
}
|
|
|
|
// preventIntermediateUniqueConstraintViolations prevents unique constraint violations caused by an intermediate update.
|
|
// The uniqueness constraint for titles within an org+folder is enforced on every update within a transaction
|
|
// instead of on commit (deferred constraint). This means that there could be a set of updates that will throw
|
|
// a unique constraint violation in an intermediate step even though the final state is valid.
|
|
// For example, a chain of updates RuleA -> RuleB -> RuleC could fail if not executed in the correct order, or
|
|
// a swap of titles RuleA <-> RuleB cannot be executed in any order without violating the constraint.
|
|
func (st DBstore) preventIntermediateUniqueConstraintViolations(sess *db.Session, updates []ngmodels.UpdateRule) error {
|
|
// The exact solution to this is complex and requires determining directed paths and cycles in the update graph,
|
|
// adding in temporary updates to break cycles, and then executing the updates in reverse topological order.
|
|
// This is not implemented here. Instead, we choose a simpler solution that works in all cases but might perform
|
|
// more updates than necessary. This simpler solution makes a determination of whether an intermediate collision
|
|
// could occur and if so, adds a temporary title on all updated rules to break any cycles and remove the need for
|
|
// specific ordering.
|
|
|
|
titleUpdates := make([]ngmodels.UpdateRule, 0)
|
|
for _, update := range updates {
|
|
if update.Existing.Title != update.New.Title {
|
|
titleUpdates = append(titleUpdates, update)
|
|
}
|
|
}
|
|
|
|
// If there is no overlap then an intermediate unique constraint violation is not possible. If there is an overlap,
|
|
// then there is the possibility of intermediate unique constraint violation.
|
|
if !newTitlesOverlapExisting(titleUpdates) {
|
|
return nil
|
|
}
|
|
st.Logger.Debug("Detected possible intermediate unique constraint violation, creating temporary title updates", "updates", len(titleUpdates))
|
|
|
|
for _, update := range titleUpdates {
|
|
r := update.Existing
|
|
u := uuid.New().String()
|
|
|
|
// Some defensive programming in case the temporary title is somehow persisted it will still be recognizable.
|
|
uniqueTempTitle := r.Title + u
|
|
if len(uniqueTempTitle) > AlertRuleMaxTitleLength {
|
|
uniqueTempTitle = r.Title[:AlertRuleMaxTitleLength-len(u)] + uuid.New().String()
|
|
}
|
|
|
|
if updated, err := sess.ID(r.ID).Cols("title").Update(&alertRule{Title: uniqueTempTitle, Version: r.Version}); err != nil || updated == 0 {
|
|
if err != nil {
|
|
return fmt.Errorf("failed to set temporary rule title [%s] %s: %w", r.UID, r.Title, err)
|
|
}
|
|
return fmt.Errorf("%w: alert rule UID %s version %d", ErrOptimisticLock, r.UID, r.Version)
|
|
}
|
|
// Otherwise optimistic locking will conflict on the 2nd update.
|
|
r.Version++
|
|
// For consistency.
|
|
r.Title = uniqueTempTitle
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// newTitlesOverlapExisting returns true if any new titles overlap with existing titles.
|
|
// It does so in a case-insensitive manner as some supported databases perform case-insensitive comparisons.
|
|
func newTitlesOverlapExisting(rules []ngmodels.UpdateRule) bool {
|
|
existingTitles := make(map[string]struct{}, len(rules))
|
|
for _, r := range rules {
|
|
existingTitles[strings.ToLower(r.Existing.Title)] = struct{}{}
|
|
}
|
|
|
|
// Check if there is any overlap between lower case existing and new titles.
|
|
for _, r := range rules {
|
|
if _, ok := existingTitles[strings.ToLower(r.New.Title)]; ok {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// CountInFolder is a handler for retrieving the number of alert rules of
|
|
// specific organisation associated with a given namespace (parent folder).
|
|
func (st DBstore) CountInFolders(ctx context.Context, orgID int64, folderUIDs []string, _ identity.Requester) (int64, error) {
|
|
if len(folderUIDs) == 0 {
|
|
return 0, nil
|
|
}
|
|
var count int64
|
|
var err error
|
|
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
args := make([]any, 0, len(folderUIDs))
|
|
for _, folderUID := range folderUIDs {
|
|
args = append(args, folderUID)
|
|
}
|
|
q := sess.Table("alert_rule").Where("org_id = ?", orgID).Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Repeat("?,", len(folderUIDs)-1)+"?"), args...)
|
|
count, err = q.Count()
|
|
return err
|
|
})
|
|
return count, err
|
|
}
|
|
|
|
// ListAlertRules is a handler for retrieving alert rules of specific organisation.
|
|
func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (result ngmodels.RulesGroup, err error) {
|
|
err = st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
q := sess.Table("alert_rule")
|
|
|
|
if query.OrgID >= 0 {
|
|
q = q.Where("org_id = ?", query.OrgID)
|
|
}
|
|
|
|
if query.DashboardUID != "" {
|
|
q = q.Where("dashboard_uid = ?", query.DashboardUID)
|
|
if query.PanelID != 0 {
|
|
q = q.Where("panel_id = ?", query.PanelID)
|
|
}
|
|
}
|
|
|
|
if len(query.NamespaceUIDs) > 0 {
|
|
args, in := getINSubQueryArgs(query.NamespaceUIDs)
|
|
q = q.Where(fmt.Sprintf("namespace_uid IN (%s)", strings.Join(in, ",")), args...)
|
|
}
|
|
|
|
if len(query.RuleUIDs) > 0 {
|
|
args, in := getINSubQueryArgs(query.RuleUIDs)
|
|
q = q.Where(fmt.Sprintf("uid IN (%s)", strings.Join(in, ",")), args...)
|
|
}
|
|
|
|
var groupsMap map[string]struct{}
|
|
if len(query.RuleGroups) > 0 {
|
|
groupsMap = make(map[string]struct{})
|
|
args, in := getINSubQueryArgs(query.RuleGroups)
|
|
q = q.Where(fmt.Sprintf("rule_group IN (%s)", strings.Join(in, ",")), args...)
|
|
for _, group := range query.RuleGroups {
|
|
groupsMap[group] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if query.ReceiverName != "" {
|
|
q, err = st.filterByContentInNotificationSettings(query.ReceiverName, q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if query.TimeIntervalName != "" {
|
|
q, err = st.filterByContentInNotificationSettings(query.TimeIntervalName, q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if query.ImportedPrometheusRule != nil {
|
|
q, err = st.filterImportedPrometheusRules(*query.ImportedPrometheusRule, q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
q = q.Asc("namespace_uid", "rule_group", "rule_group_idx", "id")
|
|
|
|
alertRules := make([]*ngmodels.AlertRule, 0)
|
|
rule := new(alertRule)
|
|
rows, err := q.Rows(rule)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
_ = rows.Close()
|
|
}()
|
|
|
|
// Deserialize each rule separately in case any of them contain invalid JSON.
|
|
for rows.Next() {
|
|
rule := new(alertRule)
|
|
err = rows.Scan(rule)
|
|
if err != nil {
|
|
st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "ListAlertRules", "error", err)
|
|
continue
|
|
}
|
|
converted, err := alertRuleToModelsAlertRule(*rule, st.Logger)
|
|
if err != nil {
|
|
st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "ListAlertRules", "error", err)
|
|
continue
|
|
}
|
|
if query.ReceiverName != "" { // remove false-positive hits from the result
|
|
if !slices.ContainsFunc(converted.NotificationSettings, func(settings ngmodels.NotificationSettings) bool {
|
|
return settings.Receiver == query.ReceiverName
|
|
}) {
|
|
continue
|
|
}
|
|
}
|
|
if query.TimeIntervalName != "" {
|
|
if !slices.ContainsFunc(converted.NotificationSettings, func(settings ngmodels.NotificationSettings) bool {
|
|
return slices.Contains(settings.MuteTimeIntervals, query.TimeIntervalName) || slices.Contains(settings.ActiveTimeIntervals, query.TimeIntervalName)
|
|
}) {
|
|
continue
|
|
}
|
|
}
|
|
if query.ImportedPrometheusRule != nil { // remove false-positive hits from the result
|
|
if *query.ImportedPrometheusRule != converted.ImportedFromPrometheus() {
|
|
continue
|
|
}
|
|
}
|
|
// MySQL (and potentially other databases) can use case-insensitive comparison.
|
|
// This code makes sure we return groups that only exactly match the filter.
|
|
if groupsMap != nil {
|
|
if _, ok := groupsMap[converted.RuleGroup]; !ok {
|
|
continue
|
|
}
|
|
}
|
|
alertRules = append(alertRules, &converted)
|
|
}
|
|
|
|
result = alertRules
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
// Count returns either the number of the alert rules under a specific org (if orgID is not zero)
|
|
// or the number of all the alert rules
|
|
func (st DBstore) Count(ctx context.Context, orgID int64) (int64, error) {
|
|
type result struct {
|
|
Count int64
|
|
}
|
|
|
|
r := result{}
|
|
err := st.SQLStore.WithDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
|
rawSQL := "SELECT COUNT(*) as count from alert_rule"
|
|
args := make([]any, 0)
|
|
if orgID != 0 {
|
|
rawSQL += " WHERE org_id=?"
|
|
args = append(args, orgID)
|
|
}
|
|
if _, err := sess.SQL(rawSQL, args...).Get(&r); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
})
|
|
return r.Count, err
|
|
}
|
|
|
|
func (st DBstore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error) {
|
|
var interval int64 = 0
|
|
return interval, st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
ruleGroups := make([]alertRule, 0)
|
|
err := sess.Find(
|
|
&ruleGroups,
|
|
alertRule{OrgID: orgID, RuleGroup: ruleGroup, NamespaceUID: namespaceUID},
|
|
)
|
|
if len(ruleGroups) == 0 {
|
|
return ngmodels.ErrAlertRuleGroupNotFound.Errorf("")
|
|
}
|
|
interval = ruleGroups[0].IntervalSeconds
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (st DBstore) GetAlertRulesKeysForScheduling(ctx context.Context) ([]ngmodels.AlertRuleKeyWithVersion, error) {
|
|
var result []ngmodels.AlertRuleKeyWithVersion
|
|
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
alertRulesSql := sess.Table("alert_rule").Select("org_id, uid, version")
|
|
var disabledOrgs []int64
|
|
|
|
for orgID := range st.Cfg.DisabledOrgs {
|
|
disabledOrgs = append(disabledOrgs, orgID)
|
|
}
|
|
|
|
if len(disabledOrgs) > 0 {
|
|
alertRulesSql = alertRulesSql.NotIn("org_id", disabledOrgs)
|
|
}
|
|
|
|
if err := alertRulesSql.Find(&result); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
// GetAlertRulesForScheduling returns a short version of all alert rules except those that belong to an excluded list of organizations
|
|
func (st DBstore) GetAlertRulesForScheduling(ctx context.Context, query *ngmodels.GetAlertRulesForSchedulingQuery) error {
|
|
var rules []*ngmodels.AlertRule
|
|
return st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
var disabledOrgs []int64
|
|
for orgID := range st.Cfg.DisabledOrgs {
|
|
disabledOrgs = append(disabledOrgs, orgID)
|
|
}
|
|
|
|
alertRulesSql := sess.Table("alert_rule")
|
|
if len(disabledOrgs) > 0 {
|
|
alertRulesSql.NotIn("org_id", disabledOrgs)
|
|
}
|
|
|
|
var groupsMap map[string]struct{}
|
|
if len(query.RuleGroups) > 0 {
|
|
alertRulesSql.In("rule_group", query.RuleGroups)
|
|
groupsMap = make(map[string]struct{}, len(query.RuleGroups))
|
|
for _, group := range query.RuleGroups {
|
|
groupsMap[group] = struct{}{}
|
|
}
|
|
}
|
|
|
|
rule := new(alertRule)
|
|
rows, err := alertRulesSql.Rows(rule)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch alert rules: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := rows.Close(); err != nil {
|
|
st.Logger.Error("Unable to close rows session", "error", err)
|
|
}
|
|
}()
|
|
// Deserialize each rule separately in case any of them contain invalid JSON.
|
|
for rows.Next() {
|
|
rule := new(alertRule)
|
|
err = rows.Scan(rule)
|
|
if err != nil {
|
|
st.Logger.Error("Invalid rule found in DB store, ignoring it", "func", "GetAlertRulesForScheduling", "error", err)
|
|
continue
|
|
}
|
|
converted, err := alertRuleToModelsAlertRule(*rule, st.Logger)
|
|
if err != nil {
|
|
st.Logger.Error("Invalid rule found in DB store, cannot convert it", "func", "GetAlertRulesForScheduling", "error", err)
|
|
continue
|
|
}
|
|
// MySQL (and potentially other databases) uses case-insensitive comparison.
|
|
// This code makes sure we return groups that only exactly match the filter
|
|
if groupsMap != nil {
|
|
if _, ok := groupsMap[converted.RuleGroup]; !ok { // compare groups using case-sensitive logic.
|
|
continue
|
|
}
|
|
}
|
|
if st.FeatureToggles.IsEnabled(ctx, featuremgmt.FlagAlertingQueryOptimization) {
|
|
if optimizations, err := OptimizeAlertQueries(converted.Data); err != nil {
|
|
st.Logger.Error("Could not migrate rule from range to instant query", "rule", rule.UID, "err", err)
|
|
} else if len(optimizations) > 0 {
|
|
st.Logger.Info("Migrated rule from range to instant query", "rule", rule.UID, "migrated_queries", len(optimizations))
|
|
}
|
|
}
|
|
rules = append(rules, &converted)
|
|
}
|
|
|
|
query.ResultRules = rules
|
|
|
|
if query.PopulateFolders {
|
|
query.ResultFoldersTitles = map[ngmodels.FolderKey]string{}
|
|
uids := map[int64]map[string]struct{}{}
|
|
for _, r := range rules {
|
|
om, ok := uids[r.OrgID]
|
|
if !ok {
|
|
om = make(map[string]struct{})
|
|
uids[r.OrgID] = om
|
|
}
|
|
om[r.NamespaceUID] = struct{}{}
|
|
}
|
|
for orgID, uids := range uids {
|
|
schedulerUser := accesscontrol.BackgroundUser("grafana_scheduler", orgID, org.RoleAdmin,
|
|
[]accesscontrol.Permission{
|
|
{
|
|
Action: dashboards.ActionFoldersRead, Scope: dashboards.ScopeFoldersAll,
|
|
},
|
|
})
|
|
|
|
folders, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{
|
|
OrgID: orgID,
|
|
UIDs: maps.Keys(uids),
|
|
WithFullpath: true,
|
|
SignedInUser: schedulerUser,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("failed to fetch a list of folders that contain alert rules: %w", err)
|
|
}
|
|
for _, f := range folders {
|
|
query.ResultFoldersTitles[ngmodels.FolderKey{OrgID: f.OrgID, UID: f.UID}] = f.Fullpath
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// DeleteInFolder deletes the rules contained in a given folder along with their associated data.
|
|
func (st DBstore) DeleteInFolders(ctx context.Context, orgID int64, folderUIDs []string, user identity.Requester) error {
|
|
for _, folderUID := range folderUIDs {
|
|
evaluator := accesscontrol.EvalPermission(accesscontrol.ActionAlertingRuleDelete, dashboards.ScopeFoldersProvider.GetResourceScopeUID(folderUID))
|
|
canSave, err := st.AccessControl.Evaluate(ctx, user, evaluator)
|
|
if err != nil {
|
|
st.Logger.Error("Failed to evaluate access control", "error", err)
|
|
return err
|
|
}
|
|
if !canSave {
|
|
st.Logger.Error("user is not allowed to delete alert rules in folder", "folder", folderUID, "user")
|
|
return dashboards.ErrFolderAccessDenied
|
|
}
|
|
|
|
rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{
|
|
OrgID: orgID,
|
|
NamespaceUIDs: []string{folderUID},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
uids := make([]string, 0, len(rules))
|
|
for _, tgt := range rules {
|
|
if tgt != nil {
|
|
uids = append(uids, tgt.UID)
|
|
}
|
|
}
|
|
|
|
if err := st.DeleteAlertRulesByUID(ctx, orgID, ngmodels.NewUserUID(user), false, uids...); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Kind returns the name of the alert rule type of entity.
|
|
func (st DBstore) Kind() string { return entity.StandardKindAlertRule }
|
|
|
|
// GenerateNewAlertRuleUID generates a unique UID for a rule.
|
|
// This is set as a variable so that the tests can override it.
|
|
// The ruleTitle is only used by the mocked functions.
|
|
var GenerateNewAlertRuleUID = func(sess *db.Session, orgID int64, ruleTitle string) (string, error) {
|
|
for i := 0; i < 3; i++ {
|
|
uid := util.GenerateShortUID()
|
|
|
|
exists, err := sess.Where("org_id=? AND uid=?", orgID, uid).Get(&alertRule{})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
if !exists {
|
|
return uid, nil
|
|
}
|
|
}
|
|
|
|
return "", ngmodels.ErrAlertRuleFailedGenerateUniqueUID
|
|
}
|
|
|
|
// validateAlertRule validates the alert rule including db-level restrictions on field lengths.
|
|
func (st DBstore) validateAlertRule(alertRule ngmodels.AlertRule) error {
|
|
if err := alertRule.ValidateAlertRule(st.Cfg); err != nil {
|
|
return err
|
|
}
|
|
|
|
// enforce max name length.
|
|
if len(alertRule.Title) > AlertRuleMaxTitleLength {
|
|
return fmt.Errorf("%w: name length should not be greater than %d", ngmodels.ErrAlertRuleFailedValidation, AlertRuleMaxTitleLength)
|
|
}
|
|
|
|
// enforce max rule group name length.
|
|
if len(alertRule.RuleGroup) > AlertRuleMaxRuleGroupNameLength {
|
|
return fmt.Errorf("%w: rule group name length should not be greater than %d", ngmodels.ErrAlertRuleFailedValidation, AlertRuleMaxRuleGroupNameLength)
|
|
}
|
|
|
|
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 []alertRule
|
|
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
query := sess.Table(alertRule{}).Select("uid, notification_settings").Where("org_id = ?", q.OrgID)
|
|
hasFilter := false
|
|
if q.ReceiverName != "" {
|
|
var err error
|
|
query, err = st.filterByContentInNotificationSettings(q.ReceiverName, query)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hasFilter = true
|
|
}
|
|
if q.TimeIntervalName != "" {
|
|
var err error
|
|
query, err = st.filterByContentInNotificationSettings(q.TimeIntervalName, query)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hasFilter = true
|
|
}
|
|
if !hasFilter {
|
|
query = query.And("notification_settings IS NOT NULL AND notification_settings <> 'null' AND notification_settings <> ''")
|
|
}
|
|
return query.Find(&rules)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := make(map[ngmodels.AlertRuleKey][]ngmodels.NotificationSettings, len(rules))
|
|
for _, rule := range rules {
|
|
if rule.NotificationSettings == "" {
|
|
continue
|
|
}
|
|
converted, err := parseNotificationSettings(rule.NotificationSettings)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to convert notification settings %s to models: %w", rule.UID, err)
|
|
}
|
|
ns := make([]ngmodels.NotificationSettings, 0, len(rule.NotificationSettings))
|
|
for _, setting := range converted {
|
|
if q.ReceiverName != "" && 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
|
|
continue
|
|
}
|
|
if q.TimeIntervalName != "" && !slices.Contains(setting.MuteTimeIntervals, q.TimeIntervalName) && !slices.Contains(setting.ActiveTimeIntervals, q.TimeIntervalName) {
|
|
continue
|
|
}
|
|
ns = append(ns, setting)
|
|
}
|
|
if len(ns) > 0 {
|
|
key := ngmodels.AlertRuleKey{
|
|
OrgID: q.OrgID,
|
|
UID: rule.UID,
|
|
}
|
|
result[key] = ns
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (st DBstore) filterByContentInNotificationSettings(value string, sess *xorm.Session) (*xorm.Session, error) {
|
|
if value == "" {
|
|
return sess, nil
|
|
}
|
|
// marshall string according to JSON rules so we follow escaping rules.
|
|
b, err := json.Marshal(value)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshall string for notification settings content filter: %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, `\`, `\\`), `"`, `\"`)
|
|
}
|
|
sql, param := st.SQLStore.GetDialect().LikeOperator("notification_settings", true, search, true)
|
|
return sess.And(sql, param), nil
|
|
}
|
|
|
|
func (st DBstore) filterImportedPrometheusRules(value bool, sess *xorm.Session) (*xorm.Session, error) {
|
|
if value {
|
|
// Filter for rules that have both prometheus_style_rule and original_rule_definition in metadata
|
|
return sess.And(
|
|
"metadata LIKE ? AND metadata LIKE ?",
|
|
"%prometheus_style_rule%",
|
|
"%original_rule_definition%",
|
|
), nil
|
|
}
|
|
// Filter for rules that don't have prometheus_style_rule and original_rule_definition in metadata
|
|
return sess.And(
|
|
"metadata NOT LIKE ? AND metadata NOT LIKE ?",
|
|
"%prometheus_style_rule%",
|
|
"%original_rule_definition%",
|
|
), nil
|
|
}
|
|
|
|
func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string, validateProvenance func(ngmodels.Provenance) bool, dryRun bool) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, 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 nil, nil, err
|
|
}
|
|
if len(rules) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
provenances, err := st.GetProvenances(ctx, orgID, (&ngmodels.AlertRule{}).ResourceType())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var invalidProvenance []ngmodels.AlertRuleKey
|
|
result := make([]ngmodels.AlertRuleKey, 0, len(rules))
|
|
updates := make([]ngmodels.UpdateRule, 0, len(rules))
|
|
for _, rule := range rules {
|
|
provenance, ok := provenances[rule.UID]
|
|
if !ok {
|
|
provenance = ngmodels.ProvenanceNone
|
|
}
|
|
if !validateProvenance(provenance) {
|
|
invalidProvenance = append(invalidProvenance, rule.GetKey())
|
|
}
|
|
if len(invalidProvenance) > 0 { // do not do any fixes if there is at least one rule with not allowed provenance
|
|
continue
|
|
}
|
|
|
|
result = append(result, rule.GetKey())
|
|
|
|
if dryRun {
|
|
continue
|
|
}
|
|
|
|
r := rule.Copy()
|
|
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,
|
|
})
|
|
}
|
|
if len(invalidProvenance) > 0 {
|
|
return nil, invalidProvenance, nil
|
|
}
|
|
if dryRun {
|
|
return result, nil, nil
|
|
}
|
|
// Provide empty user identifier to ensure it's clear that the rule update was made by the system
|
|
// and not by the user who changed the receiver's title.
|
|
return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates)
|
|
}
|
|
|
|
// RenameTimeIntervalInNotificationSettings renames all rules that use old time interval name to the new name.
|
|
// Before renaming, it checks that all rules that need to be updated have allowed provenance status, and skips updating
|
|
// if at least one rule does not have allowed provenance.
|
|
// It returns a tuple:
|
|
// - a collection of models.AlertRuleKey of rules that were updated,
|
|
// - a collection of rules that have invalid provenance status,
|
|
// - database error
|
|
func (st DBstore) RenameTimeIntervalInNotificationSettings(
|
|
ctx context.Context,
|
|
orgID int64,
|
|
oldTimeInterval, newTimeInterval string,
|
|
validateProvenance func(ngmodels.Provenance) bool,
|
|
dryRun bool,
|
|
) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, 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,
|
|
TimeIntervalName: oldTimeInterval,
|
|
})
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if len(rules) == 0 {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
provenances, err := st.GetProvenances(ctx, orgID, (&ngmodels.AlertRule{}).ResourceType())
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
var invalidProvenance []ngmodels.AlertRuleKey
|
|
result := make([]ngmodels.AlertRuleKey, 0, len(rules))
|
|
updates := make([]ngmodels.UpdateRule, 0, len(rules))
|
|
for _, rule := range rules {
|
|
provenance, ok := provenances[rule.UID]
|
|
if !ok {
|
|
provenance = ngmodels.ProvenanceNone
|
|
}
|
|
if !validateProvenance(provenance) {
|
|
invalidProvenance = append(invalidProvenance, rule.GetKey())
|
|
}
|
|
if len(invalidProvenance) > 0 { // do not do any fixes if there is at least one rule with not allowed provenance
|
|
continue
|
|
}
|
|
|
|
result = append(result, rule.GetKey())
|
|
|
|
if dryRun {
|
|
continue
|
|
}
|
|
|
|
r := rule.Copy()
|
|
for idx := range r.NotificationSettings {
|
|
for mtIdx := range r.NotificationSettings[idx].MuteTimeIntervals {
|
|
if r.NotificationSettings[idx].MuteTimeIntervals[mtIdx] == oldTimeInterval {
|
|
r.NotificationSettings[idx].MuteTimeIntervals[mtIdx] = newTimeInterval
|
|
}
|
|
}
|
|
for mtIdx := range r.NotificationSettings[idx].ActiveTimeIntervals {
|
|
if r.NotificationSettings[idx].ActiveTimeIntervals[mtIdx] == oldTimeInterval {
|
|
r.NotificationSettings[idx].ActiveTimeIntervals[mtIdx] = newTimeInterval
|
|
}
|
|
}
|
|
}
|
|
|
|
updates = append(updates, ngmodels.UpdateRule{
|
|
Existing: rule,
|
|
New: *r,
|
|
})
|
|
}
|
|
if len(invalidProvenance) > 0 {
|
|
return nil, invalidProvenance, nil
|
|
}
|
|
if dryRun {
|
|
return result, nil, nil
|
|
}
|
|
// Provide empty user identifier to ensure it's clear that the rule update was made by the system
|
|
// and not by the user who changed the receiver's title.
|
|
return result, nil, st.UpdateAlertRules(ctx, &ngmodels.AlertingUserUID, updates)
|
|
}
|
|
|
|
func ruleConstraintViolationToErr(rule ngmodels.AlertRule, err error) error {
|
|
msg := err.Error()
|
|
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}
|
|
return ngmodels.ErrAlertRuleConflictVerbose(existingPartialAlertRule, rule, errors.New("rule UID under the same organisation should be unique"))
|
|
} else {
|
|
return ngmodels.ErrAlertRuleConflict(rule, err)
|
|
}
|
|
}
|
|
|
|
// GetNamespacesByRuleUID returns a map of rule UIDs to their namespace UID.
|
|
func (st DBstore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uids ...string) (map[string]string, error) {
|
|
result := make(map[string]string)
|
|
err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error {
|
|
var rules []alertRule
|
|
err := sess.Table(alertRule{}).Select("uid, namespace_uid").Where("org_id = ?", orgID).In("uid", uids).Find(&rules)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, rule := range rules {
|
|
result[rule.UID] = rule.NamespaceUID
|
|
}
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func (st DBstore) CleanUpDeletedAlertRules(ctx context.Context) (int64, error) {
|
|
affectedRows := int64(-1)
|
|
err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
|
expire := TimeNow().Add(-st.Cfg.DeletedRuleRetention)
|
|
st.Logger.Debug("Permanently remove expired deleted rules", "deletedBefore", expire)
|
|
result, err := sess.Exec("DELETE FROM alert_rule_version WHERE rule_uid='' AND created <= ?", expire)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affectedRows, err = result.RowsAffected()
|
|
if err != nil {
|
|
st.Logger.Warn("Failed to get rows affected by the delete operation", "error", err)
|
|
}
|
|
return nil
|
|
})
|
|
return affectedRows, err
|
|
}
|
|
|
|
func getINSubQueryArgs[T any](inputSlice []T) ([]any, []string) {
|
|
args := make([]any, 0, len(inputSlice))
|
|
in := make([]string, 0, len(inputSlice))
|
|
for _, t := range inputSlice {
|
|
args = append(args, t)
|
|
in = append(in, "?")
|
|
}
|
|
|
|
return args, in
|
|
}
|
|
|
|
func (st DBstore) DeleteRuleFromTrashByGUID(ctx context.Context, orgID int64, ruleGUID string) (int64, error) {
|
|
affectedRows := int64(-1)
|
|
err := st.SQLStore.WithTransactionalDbSession(ctx, func(sess *sqlstore.DBSession) error {
|
|
st.Logger.FromContext(ctx).Debug("Deleting a deleted rule by GUID", "ruleGUID", ruleGUID)
|
|
result, err := sess.Exec("DELETE FROM alert_rule_version WHERE rule_uid='' AND rule_org_id = ? AND rule_guid = ? ", orgID, ruleGUID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
affectedRows, err = result.RowsAffected()
|
|
if err != nil {
|
|
st.Logger.FromContext(ctx).Warn("Failed to get rows affected by the delete operation", "error", err)
|
|
}
|
|
return nil
|
|
})
|
|
return affectedRows, err
|
|
}
|
|
|