Alerting: API to return deleted rules (#101429)
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user