Alerting: API to return deleted rules (#101429)

This commit is contained in:
Yuri Tseretyan
2025-03-11 12:40:44 -04:00
committed by GitHub
parent 59d87fe3f1
commit 7e4beb2074
7 changed files with 233 additions and 0 deletions
+20
View File
@@ -261,6 +261,26 @@ func (srv RulerSrv) RouteGetRulesGroupConfig(c *contextmodel.ReqContext, namespa
// RouteGetRulesConfig returns all alert rules that are available to the current user
func (srv RulerSrv) RouteGetRulesConfig(c *contextmodel.ReqContext) response.Response {
if strings.ToLower(c.Query("deleted")) == "true" {
if !srv.featureManager.IsEnabledGlobally(featuremgmt.FlagAlertRuleRestore) {
return ErrResp(http.StatusBadRequest, errors.New("restore of deleted rules is not enabled"), "")
}
if !c.SignedInUser.HasRole(identity.RoleAdmin) {
return ErrResp(http.StatusForbidden, errors.New("only admins can get deleted rules"), "")
}
rules, err := srv.store.ListDeletedRules(c.Req.Context(), c.SignedInUser.GetOrgID())
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "failed to get deleted rules")
}
result := apimodels.NamespaceConfigResponse{}
if len(rules) > 0 {
result[""] = []apimodels.GettableRuleGroupConfig{
toGettableRuleGroupConfig("", rules, map[string]ngmodels.Provenance{}, srv.resolveUserIdToNameFn(c.Req.Context())),
}
}
return response.JSON(http.StatusOK, result)
}
namespaceMap, err := srv.store.GetUserVisibleNamespaces(c.Req.Context(), c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
return ErrResp(http.StatusInternalServerError, err, "failed to get namespaces visible to the user")
+1
View File
@@ -23,6 +23,7 @@ type RuleStore interface {
GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAlertRuleByUIDQuery) (*ngmodels.AlertRule, error)
GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) ([]*ngmodels.AlertRule, error)
ListAlertRules(ctx context.Context, query *ngmodels.ListAlertRulesQuery) (ngmodels.RulesGroup, error)
ListDeletedRules(ctx context.Context, orgID int64) ([]*ngmodels.AlertRule, error)
// InsertAlertRules will insert all alert rules passed into the function
// and return the map of uuid to id.
+34
View File
@@ -236,6 +236,40 @@ func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid st
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).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) {
@@ -1954,6 +1954,63 @@ func TestIntegration_ListAlertRules(t *testing.T) {
})
}
func TestIntegration_ListDeletedRules(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
cfg := setting.NewCfg()
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{
BaseInterval: 1 * time.Second,
RuleVersionRecordLimit: -1,
}
sqlStore := db.InitTestDB(t)
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
b := &fakeBus{}
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
store.FeatureToggles = featuremgmt.WithFeatures(featuremgmt.FlagAlertRuleRestore)
orgID := int64(1)
gen := models.RuleGen
gen = gen.With(gen.WithIntervalMatching(store.Cfg.BaseInterval), gen.WithOrgID(orgID))
result, err := store.InsertAlertRules(context.Background(), &models.AlertingUserUID, []models.AlertRule{gen.Generate()})
require.NoError(t, err)
rule, err := store.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{UID: result[0].UID})
require.NoError(t, err)
rule2 := models.CopyRule(rule, gen.WithTitle(util.GenerateShortUID()))
err = store.UpdateAlertRules(context.Background(), &models.AlertingUserUID, []models.UpdateRule{
{
Existing: rule,
New: *rule2,
},
})
require.NoError(t, err)
rule2, err = store.GetAlertRuleByUID(context.Background(), &models.GetAlertRuleByUIDQuery{UID: result[0].UID})
require.NoError(t, err)
versions, err := store.GetAlertRuleVersions(context.Background(), orgID, rule.GUID)
require.NoError(t, err)
require.Len(t, versions, 2)
t.Run("should not return if rule is not deleted", func(t *testing.T) {
list, err := store.ListDeletedRules(context.Background(), orgID)
require.NoError(t, err)
require.Empty(t, list)
})
err = store.DeleteAlertRulesByUID(context.Background(), orgID, &models.AlertingUserUID, rule.UID)
require.NoError(t, err)
t.Run("should return the last deleted rule", func(t *testing.T) {
list, err := store.ListDeletedRules(context.Background(), orgID)
require.NoError(t, err)
require.Len(t, list, 1)
assert.Empty(t, list[0].UID)
assert.Empty(t, rule2.Diff(list[0], "ID", "UID", "DashboardUID", "PanelID"))
})
}
func createTestStore(
sqlStore db.DB,
folderService folder.Service,
+13
View File
@@ -24,6 +24,7 @@ type RuleStore struct {
// OrgID -> RuleGroup -> Namespace -> Rules
Rules map[int64][]*models.AlertRule
History map[string][]*models.AlertRule
Deleted map[int64][]*models.AlertRule
Hook func(cmd any) error // use Hook if you need to intercept some query and return an error
RecordedOps []any
Folders map[int64][]*folder.Folder
@@ -460,3 +461,15 @@ func (f *RuleStore) GetAlertRuleVersions(_ context.Context, orgID int64, guid st
return f.History[guid], nil
}
func (f *RuleStore) ListDeletedRules(_ context.Context, orgID int64) ([]*models.AlertRule, error) {
f.mtx.Lock()
defer f.mtx.Unlock()
defer func() {
f.RecordedOps = append(f.RecordedOps, GenericRecordedQuery{Name: "ListDeletedRules", Params: []any{orgID}})
}()
if err := f.Hook(orgID); err != nil {
return nil, err
}
return f.Deleted[orgID], nil
}
+98
View File
@@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"io"
"maps"
"math/rand"
"net/http"
"path"
@@ -15,6 +16,7 @@ import (
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/uuid"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/prometheus/alertmanager/pkg/labels"
@@ -4645,6 +4647,102 @@ func TestIntegrationRuleVersions(t *testing.T) {
})
}
func TestIntegrationRuleSoftDelete(t *testing.T) {
testinfra.SQLiteIntegrationTest(t)
// Setup Grafana and its Database
dir, p := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{
DisableLegacyAlerting: true,
EnableUnifiedAlerting: true,
EnableQuota: true,
DisableAnonymous: true,
AppModeProduction: true,
EnableFeatureToggles: []string{featuremgmt.FlagAlertRuleRestore},
})
grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, p)
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleAdmin),
Password: "admin",
Login: "admin",
})
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleEditor),
Password: "password",
Login: "editor",
})
adminClient := newAlertingApiClient(grafanaListedAddr, "admin", "admin")
editorClient := newAlertingApiClient(grafanaListedAddr, "editor", "password")
deleted, status, data := adminClient.GetDeletedRulesWithStatus(t)
requireStatusCode(t, http.StatusOK, status, data)
require.Emptyf(t, deleted, "Expected empty list of deleted rules, got %v", deleted)
// Create the namespace we'll save our alerts to.
adminClient.CreateFolder(t, "folder1", "folder1")
var group apimodels.RuleGroupConfigResponse
{ // create rules and some history
postGroupRaw, err := testData.ReadFile(path.Join("test-data", "rulegroup-1-post.json"))
require.NoError(t, err)
var group1 apimodels.PostableRuleGroupConfig
require.NoError(t, json.Unmarshal(postGroupRaw, &group1))
// Create rule under folder1
response := adminClient.PostRulesGroup(t, "folder1", &group1)
require.NotEmptyf(t, response.Created, "Expected created to be set")
// create some versions of the rule
for i := 0; i < 3; i++ {
groups, status := adminClient.GetRulesGroup(t, "folder1", group1.Name)
require.Equal(t, http.StatusAccepted, status)
group1 = convertGettableRuleGroupToPostable(groups.GettableRuleGroupConfig)
group1.Rules[0].Annotations[util.GenerateShortUID()] = util.GenerateShortUID()
_ = adminClient.PostRulesGroup(t, "folder1", &group1)
}
group, status = adminClient.GetRulesGroup(t, "folder1", group1.Name)
require.Equal(t, http.StatusAccepted, status)
}
// deleting group by using editor user
status, body := editorClient.DeleteRulesGroup(t, "folder1", group.Name)
require.Equalf(t, http.StatusAccepted, status, "failed to delete group. Response: %s", body)
t.Run("should see deleted rules", func(t *testing.T) {
rules, status, raw := adminClient.GetDeletedRulesWithStatus(t)
requireStatusCode(t, http.StatusOK, status, raw)
require.Containsf(t, rules, "", "All rules should be in empty folder but got %v", slices.Collect(maps.Keys(rules)))
require.Lenf(t, rules[""], 1, "All deleted rules should be in single group but got %d", len(rules[""]))
require.Equalf(t, "", rules[""][0].Name, "All deleted rules should be in empty group but got %v", rules[""][0].Name)
require.Len(t, rules[""][0].Rules, len(group.Rules))
require.Empty(t, cmp.Diff(group.Rules, rules[""][0].Rules, cmpopts.IgnoreFields(apimodels.GettableGrafanaRule{}, "UID", "Version", "Updated", "UpdatedBy")))
rule := rules[""][0].Rules[0]
require.Equalf(t, "editor", rule.GrafanaManagedAlert.UpdatedBy.Name, "Field 'UpdatedBy' should be set by editor but got %v ", rule.GrafanaManagedAlert.UpdatedBy)
})
t.Run("only admin should be able to see deleted rules", func(t *testing.T) {
t.Run("editor", func(t *testing.T) {
_, status, raw := editorClient.GetDeletedRulesWithStatus(t)
requireStatusCode(t, http.StatusForbidden, status, raw)
})
t.Run("viewer", func(t *testing.T) {
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleViewer),
Password: "password",
Login: "viewer",
})
client := newAlertingApiClient(grafanaListedAddr, "viewer", "password")
_, status, raw := client.GetDeletedRulesWithStatus(t)
requireStatusCode(t, http.StatusForbidden, status, raw)
})
})
}
func newTestingRuleConfig(t *testing.T) apimodels.PostableRuleGroupConfig {
interval, err := model.ParseDuration("1m")
require.NoError(t, err)
+10
View File
@@ -647,6 +647,16 @@ func (a apiClient) GetAllRulesWithStatus(t *testing.T) (apimodels.NamespaceConfi
return result, resp.StatusCode, b
}
func (a apiClient) GetDeletedRulesWithStatus(t *testing.T) (apimodels.NamespaceConfigResponse, int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/ruler/grafana/api/v1/rules", a.url), nil)
require.NoError(t, err)
q := req.URL.Query()
q.Add("deleted", "true")
req.URL.RawQuery = q.Encode()
return sendRequestJSON[apimodels.NamespaceConfigResponse](t, req, http.StatusOK)
}
func (a apiClient) ExportRulesWithStatus(t *testing.T, params *apimodels.AlertRulesExportParameters) (int, string) {
t.Helper()
u, err := url.Parse(fmt.Sprintf("%s/api/ruler/grafana/api/v1/export/rules", a.url))