diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 4fadffba644..15279e59f94 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -328,10 +328,18 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey for _, rule := range finalChanges.New { inserts = append(inserts, *rule) } - _, err = srv.store.InsertAlertRules(tranCtx, inserts) + added, err := srv.store.InsertAlertRules(tranCtx, inserts) if err != nil { return fmt.Errorf("failed to add rules: %w", err) } + if len(added) != len(finalChanges.New) { + logger.Error("Cannot match inserted rules with final changes", "insertedCount", len(added), "changes", len(finalChanges.New)) + } else { + for i, newRule := range finalChanges.New { + newRule.ID = added[i].ID + newRule.UID = added[i].UID + } + } } if len(finalChanges.New) > 0 { @@ -363,12 +371,30 @@ func (srv RulerSrv) updateAlertRulesInGroup(c *contextmodel.ReqContext, groupKey } return ErrResp(http.StatusInternalServerError, err, "failed to update rule group") } + return changesToResponse(finalChanges) +} - if finalChanges.IsEmpty() { - return response.JSON(http.StatusAccepted, util.DynMap{"message": "no changes detected in the rule group"}) +func changesToResponse(finalChanges *store.GroupDelta) response.Response { + body := apimodels.UpdateRuleGroupResponse{ + Message: "rule group updated successfully", + Created: make([]string, 0, len(finalChanges.New)), + Updated: make([]string, 0, len(finalChanges.Update)), + Deleted: make([]string, 0, len(finalChanges.Delete)), } - - return response.JSON(http.StatusAccepted, util.DynMap{"message": "rule group updated successfully"}) + if finalChanges.IsEmpty() { + body.Message = "no changes detected in the rule group" + } else { + for _, r := range finalChanges.New { + body.Created = append(body.Created, r.UID) + } + for _, r := range finalChanges.Update { + body.Updated = append(body.Updated, r.Existing.UID) + } + for _, r := range finalChanges.Delete { + body.Deleted = append(body.Deleted, r.UID) + } + } + return response.JSON(http.StatusAccepted, body) } func toGettableRuleGroupConfig(groupName string, rules ngmodels.RulesGroup, namespaceID int64, provenanceRecords map[string]ngmodels.Provenance) apimodels.GettableRuleGroupConfig { diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index 6e22cc96073..b9ecfe20a89 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -18,7 +18,7 @@ type RuleStore interface { // InsertAlertRules will insert all alert rules passed into the function // and return the map of uuid to id. - InsertAlertRules(ctx context.Context, rule []ngmodels.AlertRule) (map[string]int64, error) + InsertAlertRules(ctx context.Context, rule []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error) UpdateAlertRules(ctx context.Context, rule []ngmodels.UpdateRule) error DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index aed0017864d..93198d78e98 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -3921,6 +3921,32 @@ "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, + "UpdateRuleGroupResponse": { + "properties": { + "created": { + "items": { + "type": "string" + }, + "type": "array" + }, + "deleted": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + }, + "updated": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "Userinfo": { "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", "type": "object" diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index 985f2ce7c35..9378f191931 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -51,7 +51,7 @@ import ( // - application/yaml // // Responses: -// 202: Ack +// 202: UpdateRuleGroupResponse // // swagger:route POST /api/ruler/grafana/api/v1/rules/{Namespace}/export ruler RoutePostRulesGroupForExport @@ -486,3 +486,11 @@ func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error { return fmt.Errorf("invalid duration %v", v) } } + +// swagger:model +type UpdateRuleGroupResponse struct { + Message string `json:"message"` + Created []string `json:"created,omitempty"` + Updated []string `json:"updated,omitempty"` + Deleted []string `json:"deleted,omitempty"` +} diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 83a9226bfe5..3f3948eacc0 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -3920,6 +3920,32 @@ "title": "URL is a custom URL type that allows validation at configuration load time.", "type": "object" }, + "UpdateRuleGroupResponse": { + "properties": { + "created": { + "items": { + "type": "string" + }, + "type": "array" + }, + "deleted": { + "items": { + "type": "string" + }, + "type": "array" + }, + "message": { + "type": "string" + }, + "updated": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "type": "object" + }, "Userinfo": { "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", "type": "object" @@ -5992,9 +6018,9 @@ ], "responses": { "202": { - "description": "Ack", + "description": "UpdateRuleGroupResponse", "schema": { - "$ref": "#/definitions/Ack" + "$ref": "#/definitions/UpdateRuleGroupResponse" } } }, diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 51d2fff56a9..62edc311aa2 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1345,9 +1345,9 @@ ], "responses": { "202": { - "description": "Ack", + "description": "UpdateRuleGroupResponse", "schema": { - "$ref": "#/definitions/Ack" + "$ref": "#/definitions/UpdateRuleGroupResponse" } } } @@ -6902,6 +6902,32 @@ } } }, + "UpdateRuleGroupResponse": { + "type": "object", + "properties": { + "created": { + "type": "array", + "items": { + "type": "string" + } + }, + "deleted": { + "type": "array", + "items": { + "type": "string" + } + }, + "message": { + "type": "string" + }, + "updated": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, "Userinfo": { "description": "The Userinfo type is an immutable encapsulation of username and\npassword details for a URL. An existing Userinfo value is guaranteed\nto have a username set (potentially empty, as allowed by RFC 2396),\nand optionally a password.", "type": "object" diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index b4e79dcc6cb..85c87e06384 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -356,6 +356,11 @@ type AlertRuleKeyWithVersionAndPauseStatus struct { AlertRuleKeyWithVersion `xorm:"extends"` } +type AlertRuleKeyWithId struct { + AlertRuleKey + ID int64 +} + // AlertRuleGroupKey is the identifier of a group of alerts type AlertRuleGroupKey struct { OrgID int64 diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index 1d8abf8aa3b..0c354d0d08c 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -134,9 +134,15 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, rule model if err != nil { return err } - if id, ok := ids[rule.UID]; ok { - rule.ID = id - } else { + var fixed bool + for _, key := range ids { + if key.UID == rule.UID { + rule.ID = key.ID + fixed = true + break + } + } + if !fixed { return errors.New("couldn't find newly created id") } @@ -309,8 +315,8 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, orgID int if err != nil { return fmt.Errorf("failed to insert alert rules: %w", err) } - for uid := range uids { - if err := service.provenanceStore.SetProvenance(ctx, &models.AlertRule{UID: uid}, orgID, provenance); err != nil { + for _, key := range uids { + if err := service.provenanceStore.SetProvenance(ctx, &models.AlertRule{UID: key.UID}, orgID, provenance); err != nil { return err } } diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index cc9b91b4f3a..67daf360b68 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -38,7 +38,7 @@ type RuleStore interface { GetAlertRuleByUID(ctx context.Context, query *models.GetAlertRuleByUIDQuery) (*models.AlertRule, error) ListAlertRules(ctx context.Context, query *models.ListAlertRulesQuery) (models.RulesGroup, error) GetRuleGroupInterval(ctx context.Context, orgID int64, namespaceUID string, ruleGroup string) (int64, error) - InsertAlertRules(ctx context.Context, rule []models.AlertRule) (map[string]int64, error) + InsertAlertRules(ctx context.Context, rule []models.AlertRule) ([]models.AlertRuleKeyWithId, error) UpdateAlertRules(ctx context.Context, rule []models.UpdateRule) error DeleteAlertRulesByUID(ctx context.Context, orgID int64, ruleUID ...string) error GetAlertRulesGroupByRuleUID(ctx context.Context, query *models.GetAlertRulesGroupByRuleUIDQuery) ([]*models.AlertRule, error) diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index ae0f2aeb5ad..a9f49bce1f7 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -116,8 +116,9 @@ func (st DBstore) GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmode } // InsertAlertRules is a handler for creating/updating alert rules. -func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRule) (map[string]int64, error) { - ids := make(map[string]int64, len(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, rules []ngmodels.AlertRule) ([]ngmodels.AlertRuleKeyWithId, error) { + ids := make([]ngmodels.AlertRuleKeyWithId, 0, len(rules)) return ids, st.SQLStore.WithTransactionalDbSession(ctx, func(sess *db.Session) error { newRules := make([]ngmodels.AlertRule, 0, len(rules)) ruleVersions := make([]ngmodels.AlertRuleVersion, 0, len(rules)) @@ -167,7 +168,10 @@ func (st DBstore) InsertAlertRules(ctx context.Context, rules []ngmodels.AlertRu } return fmt.Errorf("failed to create new rules: %w", err) } - ids[newRules[i].UID] = newRules[i].ID + ids = append(ids, ngmodels.AlertRuleKeyWithId{ + AlertRuleKey: newRules[i].GetKey(), + ID: newRules[i].ID, + }) } } diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index c9815f1ca47..b53f112fa83 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -514,6 +514,50 @@ func TestIntegration_GetNamespaceByUID(t *testing.T) { require.Equal(t, uid, actual.UID) } +func TestIntegrationInsertAlertRules(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + cfg.UnifiedAlerting.BaseInterval = 1 * time.Second + store := &DBstore{ + SQLStore: sqlStore, + FolderService: setupFolderService(t, sqlStore, cfg), + Logger: log.New("test-dbstore"), + Cfg: cfg.UnifiedAlerting, + } + + rules := models.GenerateAlertRules(5, models.AlertRuleGen(models.WithOrgID(1), withIntervalMatching(store.Cfg.BaseInterval))) + deref := make([]models.AlertRule, 0, len(rules)) + for _, rule := range rules { + deref = append(deref, *rule) + } + + ids, err := store.InsertAlertRules(context.Background(), deref) + require.NoError(t, err) + require.Len(t, ids, len(rules)) + + dbRules, err := store.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ + OrgID: 1, + }) + require.NoError(t, err) + for idx, keyWithID := range ids { + found := false + for _, rule := range dbRules { + if rule.GetKey() == keyWithID.AlertRuleKey { + expected := rules[idx] + require.Equal(t, keyWithID.ID, rule.ID) + require.Equal(t, expected.Title, rule.Title) + found = true + break + } + } + require.Truef(t, found, "Rule with key %#v was not found in database", keyWithID) + } +} + func createRule(t *testing.T, store *DBstore, generate func() *models.AlertRule) *models.AlertRule { t.Helper() if generate == nil { diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 2e6e547a053..df5d691c85c 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -281,11 +281,11 @@ func (f *RuleStore) UpdateAlertRules(_ context.Context, q []models.UpdateRule) e return nil } -func (f *RuleStore) InsertAlertRules(_ context.Context, q []models.AlertRule) (map[string]int64, error) { +func (f *RuleStore) InsertAlertRules(_ context.Context, q []models.AlertRule) ([]models.AlertRuleKeyWithId, error) { f.mtx.Lock() defer f.mtx.Unlock() f.RecordedOps = append(f.RecordedOps, q) - ids := make(map[string]int64, len(q)) + ids := make([]models.AlertRuleKeyWithId, 0, len(q)) if err := f.Hook(q); err != nil { return ids, err } diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 49f58c1ec1f..7b9f1268316 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -524,7 +524,7 @@ func TestIntegrationAlertAndGroupsQuery(t *testing.T) { }, } - status, _ := apiClient.PostRulesGroup(t, "default", &rules) + _, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, http.StatusAccepted, status) } @@ -664,7 +664,7 @@ func TestIntegrationRulerAccess(t *testing.T) { }, }, } - status, body := tc.client.PostRulesGroup(t, "default", &rules) + _, status, body := tc.client.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, tc.expStatus, status) res := &Response{} err = json.Unmarshal([]byte(body), &res) @@ -1097,7 +1097,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { tc.rule, }, } - status, body := apiClient.PostRulesGroup(t, "default", &rules) + _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) res := &Response{} err = json.Unmarshal([]byte(body), &res) require.NoError(t, err) @@ -1170,9 +1170,12 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { }, }, } - status, body := apiClient.PostRulesGroup(t, "default", &rules) + resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + require.Equal(t, "rule group updated successfully", resp.Message) + assert.Len(t, resp.Created, 2) + assert.Empty(t, resp.Updated) + assert.Empty(t, resp.Deleted) } // With the rules created, let's make sure that rule definition is stored correctly. @@ -1339,7 +1342,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { Interval: interval, } - status, body := apiClient.PostRulesGroup(t, "default", &rules) + _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, http.StatusNotFound, status) var res map[string]any assert.NoError(t, json.Unmarshal([]byte(body), &res)) @@ -1445,7 +1448,7 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { }, Interval: interval, } - status, body := apiClient.PostRulesGroup(t, "default", &rules) + _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, http.StatusBadRequest, status) var res map[string]any require.NoError(t, json.Unmarshal([]byte(body), &res)) @@ -1519,9 +1522,10 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { }, Interval: interval, } - status, body := apiClient.PostRulesGroup(t, "default", &rules) + respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + require.Equal(t, respModel.Updated, []string{ruleUID}) + require.Len(t, respModel.Deleted, 1) // let's make sure that rule definitions are updated correctly. u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) @@ -1637,9 +1641,9 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { }, Interval: interval, } - status, body := apiClient.PostRulesGroup(t, "default", &rules) + respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + require.Equal(t, respModel.Updated, []string{ruleUID}) // let's make sure that rule definitions are updated correctly. u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) @@ -1723,9 +1727,12 @@ func TestIntegrationAlertRuleCRUD(t *testing.T) { }, Interval: interval, } - status, body := apiClient.PostRulesGroup(t, "default", &rules) + respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"no changes detected in the rule group"}`, body) + require.Equal(t, "no changes detected in the rule group", respModel.Message) + assert.Empty(t, respModel.Created) + assert.Empty(t, respModel.Updated) + assert.Empty(t, respModel.Deleted) // let's make sure that rule definitions are updated correctly. u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) @@ -1993,7 +2000,7 @@ func TestIntegrationQuota(t *testing.T) { }, }, } - status, body := apiClient.PostRulesGroup(t, "default", &rules) + _, status, body := apiClient.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, http.StatusForbidden, status) var res map[string]any require.NoError(t, json.Unmarshal([]byte(body), &res)) @@ -2030,9 +2037,9 @@ func TestIntegrationQuota(t *testing.T) { }, } - status, body := apiClient.PostRulesGroup(t, "default", &rules) + respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + require.Len(t, respModel.Updated, 1) // let's make sure that rule definitions are updated correctly. u := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index c44b0101ab7..aa9e7cfcf77 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -11,11 +11,12 @@ import ( "testing" "time" - "github.com/grafana/grafana/pkg/expr" "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/expr" + "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/accesscontrol/resourcepermissions" "github.com/grafana/grafana/pkg/services/featuremgmt" @@ -155,8 +156,10 @@ func TestIntegrationPrometheusRules(t *testing.T) { b, err := io.ReadAll(resp.Body) require.NoError(t, err) - assert.Equal(t, resp.StatusCode, 202) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, string(b)) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + var respModel apimodels.UpdateRuleGroupResponse + require.NoError(t, json.Unmarshal(b, &respModel)) + require.Len(t, respModel.Created, len(rules.Rules)) } // Check that we cannot create a rule that has a panel_id and no dashboard_uid @@ -434,8 +437,10 @@ func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { b, err := io.ReadAll(resp.Body) require.NoError(t, err) - assert.Equal(t, resp.StatusCode, 202) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, string(b)) + assert.Equal(t, http.StatusAccepted, resp.StatusCode) + var respModel apimodels.UpdateRuleGroupResponse + require.NoError(t, json.Unmarshal(b, &respModel)) + require.Len(t, respModel.Created, len(rules.Rules)) } expectedAllJSON := fmt.Sprintf(` diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index 741c29644bb..77ce404e97c 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -71,7 +71,7 @@ func TestIntegrationAlertRulePermissions(t *testing.T) { require.NoError(t, json.Unmarshal(postGroupRaw, &group1)) // Create rule under folder1 - status, response := apiClient.PostRulesGroup(t, "folder1", &group1) + _, status, response := apiClient.PostRulesGroupWithStatus(t, "folder1", &group1) require.Equalf(t, http.StatusAccepted, status, response) postGroupRaw, err = testData.ReadFile(path.Join("test-data", "rulegroup-2-post.json")) @@ -80,7 +80,7 @@ func TestIntegrationAlertRulePermissions(t *testing.T) { require.NoError(t, json.Unmarshal(postGroupRaw, &group2)) // Create rule under folder2 - status, response = apiClient.PostRulesGroup(t, "folder2", &group2) + _, status, response = apiClient.PostRulesGroupWithStatus(t, "folder2", &group2) require.Equalf(t, http.StatusAccepted, status, response) // With the rules created, let's make sure that rule definitions are stored. @@ -322,7 +322,7 @@ func TestIntegrationAlertRulePermissions(t *testing.T) { }) } -func createRule(t *testing.T, client apiClient, folder string) apimodels.PostableRuleGroupConfig { +func createRule(t *testing.T, client apiClient, folder string) (apimodels.PostableRuleGroupConfig, string) { t.Helper() interval, err := model.ParseDuration("1m") @@ -359,10 +359,10 @@ func createRule(t *testing.T, client apiClient, folder string) apimodels.Postabl }, }, } - status, body := client.PostRulesGroup(t, folder, &rules) + resp, status, _ := client.PostRulesGroupWithStatus(t, folder, &rules) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) - return rules + require.Len(t, resp.Created, 1) + return rules, resp.Created[0] } func TestAlertRulePostExport(t *testing.T) { @@ -474,9 +474,9 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { rules := newTestingRuleConfig(t) - status, body := apiClient.PostRulesGroup(t, "folder1", &rules) + respModel, status, _ := apiClient.PostRulesGroupWithStatus(t, "folder1", &rules) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + require.Len(t, respModel.Created, len(rules.Rules)) // fetch the created rules, so we can get the uid's and trigger // and update by reusing the uid's @@ -487,7 +487,7 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup) rulesWithUID.Rules = append(rulesWithUID.Rules, rules.Rules[0]) // Create new copy of first rule. - status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID) + _, status, body := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID) assert.Equal(t, http.StatusInternalServerError, status) var res map[string]any @@ -499,7 +499,7 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { rulesWithUID := convertGettableRuleGroupToPostable(createdRuleGroup) rulesWithUID.Rules[1].GrafanaManagedAlert.Title = "AlwaysFiring" - status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID) + _, status, body := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID) assert.Equal(t, http.StatusInternalServerError, status) var res map[string]any @@ -509,9 +509,9 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { t.Run("trying to create alert with same title under another folder should succeed", func(t *testing.T) { rules := newTestingRuleConfig(t) - status, body := apiClient.PostRulesGroup(t, "folder2", &rules) + resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "folder2", &rules) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + require.Len(t, resp.Created, len(rules.Rules)) }) t.Run("trying to swap titles of existing alerts in the same folder should work", func(t *testing.T) { @@ -521,9 +521,9 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { rulesWithUID.Rules[0].GrafanaManagedAlert.Title = title1 rulesWithUID.Rules[1].GrafanaManagedAlert.Title = title0 - status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID) + resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + require.Len(t, resp.Updated, 2) }) t.Run("trying to update titles of existing alerts in a chain in the same folder should work", func(t *testing.T) { @@ -531,9 +531,9 @@ func TestIntegrationAlertRuleConflictingTitle(t *testing.T) { rulesWithUID.Rules[0].GrafanaManagedAlert.Title = rulesWithUID.Rules[1].GrafanaManagedAlert.Title rulesWithUID.Rules[1].GrafanaManagedAlert.Title = "something new" - status, body := apiClient.PostRulesGroup(t, "folder1", &rulesWithUID) + resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "folder1", &rulesWithUID) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + require.Len(t, resp.Updated, len(rulesWithUID.Rules)) }) } @@ -621,9 +621,9 @@ func TestIntegrationRulerRulesFilterByDashboard(t *testing.T) { }, }, } - status, body := apiClient.PostRulesGroup(t, "default", &rules) + resp, status, _ := apiClient.PostRulesGroupWithStatus(t, "default", &rules) assert.Equal(t, http.StatusAccepted, status) - require.JSONEq(t, `{"message":"rule group updated successfully"}`, body) + require.Len(t, resp.Created, len(rules.Rules)) } expectedAllJSON := fmt.Sprintf(` @@ -905,9 +905,9 @@ func TestIntegrationRuleGroupSequence(t *testing.T) { group1 := generateAlertRuleGroup(5, alertRuleGen()) group2 := generateAlertRuleGroup(5, alertRuleGen()) - status, _ := client.PostRulesGroup(t, folder1Title, &group1) + _, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &group1) require.Equal(t, http.StatusAccepted, status) - status, _ = client.PostRulesGroup(t, folder1Title, &group2) + _, status, _ = client.PostRulesGroupWithStatus(t, folder1Title, &group2) require.Equal(t, http.StatusAccepted, status) t.Run("should persist order of the rules in a group", func(t *testing.T) { @@ -930,7 +930,7 @@ func TestIntegrationRuleGroupSequence(t *testing.T) { for _, rule := range postableGroup1.Rules { expectedUids = append(expectedUids, rule.GrafanaManagedAlert.UID) } - status, _ := client.PostRulesGroup(t, folder1Title, &postableGroup1) + _, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &postableGroup1) require.Equal(t, http.StatusAccepted, status) group1Get = client.GetRulesGroup(t, folder1Title, group1.Name) @@ -956,7 +956,7 @@ func TestIntegrationRuleGroupSequence(t *testing.T) { for _, rule := range postableGroup1.Rules { expectedUids = append(expectedUids, rule.GrafanaManagedAlert.UID) } - status, _ := client.PostRulesGroup(t, folder1Title, &postableGroup1) + _, status, _ := client.PostRulesGroupWithStatus(t, folder1Title, &postableGroup1) require.Equal(t, http.StatusAccepted, status) group1Get = client.GetRulesGroup(t, folder1Title, group1.Name) @@ -1031,7 +1031,7 @@ func TestIntegrationRuleUpdate(t *testing.T) { expected := model.Duration(10 * time.Second) group.Rules[0].ApiRuleNode.For = &expected - status, body := client.PostRulesGroup(t, folder1Title, &group) + _, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) getGroup := client.GetRulesGroup(t, folder1Title, group.Name) require.Equal(t, expected, *getGroup.Rules[0].ApiRuleNode.For) @@ -1039,7 +1039,7 @@ func TestIntegrationRuleUpdate(t *testing.T) { group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) expected = 0 group.Rules[0].ApiRuleNode.For = &expected - status, body = client.PostRulesGroup(t, folder1Title, &group) + _, status, body = client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) getGroup = client.GetRulesGroup(t, folder1Title, group.Name) @@ -1051,7 +1051,7 @@ func TestIntegrationRuleUpdate(t *testing.T) { ds1 := adminClient.CreateTestDatasource(t) group := generateAlertRuleGroup(3, alertRuleGen(withDatasourceQuery(ds1.Body.Datasource.UID))) - status, body := client.PostRulesGroup(t, folder1Title, &group) + _, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) getGroup := client.GetRulesGroup(t, folder1Title, group.Name) @@ -1071,7 +1071,7 @@ func TestIntegrationRuleUpdate(t *testing.T) { getGroup := client.GetRulesGroup(t, folder1Title, groupName) group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) - status, body := client.PostRulesGroup(t, folder1Title, &group) + _, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body) }) t.Run("should not let update rule if it does not fix datasource", func(t *testing.T) { @@ -1079,9 +1079,10 @@ func TestIntegrationRuleUpdate(t *testing.T) { group := convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) group.Rules[0].GrafanaManagedAlert.Title = uuid.NewString() - status, body := client.PostRulesGroup(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) if status == http.StatusAccepted { + assert.Len(t, resp.Deleted, 1) getGroup = client.GetRulesGroup(t, folder1Title, group.Name) assert.NotEqualf(t, group.Rules[0].GrafanaManagedAlert.Title, getGroup.Rules[0].GrafanaManagedAlert.Title, "group was updated") } @@ -1094,8 +1095,9 @@ func TestIntegrationRuleUpdate(t *testing.T) { // remove the last rule. group.Rules = group.Rules[0 : len(group.Rules)-1] - status, body := client.PostRulesGroup(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to delete last rule from group. Response: %s", body) + assert.Len(t, resp.Deleted, 1) getGroup = client.GetRulesGroup(t, folder1Title, group.Name) group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) @@ -1107,8 +1109,11 @@ func TestIntegrationRuleUpdate(t *testing.T) { ds2 := adminClient.CreateTestDatasource(t) withDatasourceQuery(ds2.Body.Datasource.UID)(&group.Rules[0]) - status, body := client.PostRulesGroup(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post noop rule group. Response: %s", body) + assert.Len(t, resp.Deleted, 0) + assert.Len(t, resp.Updated, 2) + assert.Len(t, resp.Created, 0) getGroup = client.GetRulesGroup(t, folder1Title, group.Name) group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) @@ -1217,8 +1222,9 @@ func TestIntegrationRulePause(t *testing.T) { expectedIsPaused := true group.Rules[0].GrafanaManagedAlert.IsPaused = &expectedIsPaused - status, body := client.PostRulesGroup(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) + require.Len(t, resp.Created, 1) getGroup := client.GetRulesGroup(t, folder1Title, group.Name) require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body) require.Equal(t, expectedIsPaused, getGroup.Rules[0].GrafanaManagedAlert.IsPaused) @@ -1229,8 +1235,9 @@ func TestIntegrationRulePause(t *testing.T) { expectedIsPaused := false group.Rules[0].GrafanaManagedAlert.IsPaused = &expectedIsPaused - status, body := client.PostRulesGroup(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) + require.Len(t, resp.Created, 1) getGroup := client.GetRulesGroup(t, folder1Title, group.Name) require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body) require.Equal(t, expectedIsPaused, getGroup.Rules[0].GrafanaManagedAlert.IsPaused) @@ -1240,8 +1247,9 @@ func TestIntegrationRulePause(t *testing.T) { group := generateAlertRuleGroup(1, alertRuleGen()) group.Rules[0].GrafanaManagedAlert.IsPaused = nil - status, body := client.PostRulesGroup(t, folder1Title, &group) + resp, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) + require.Len(t, resp.Created, 1) getGroup := client.GetRulesGroup(t, folder1Title, group.Name) require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body) require.False(t, getGroup.Rules[0].GrafanaManagedAlert.IsPaused) @@ -1297,14 +1305,14 @@ func TestIntegrationRulePause(t *testing.T) { group := generateAlertRuleGroup(1, alertRuleGen()) group.Rules[0].GrafanaManagedAlert.IsPaused = &tc.isPausedInDb - status, body := client.PostRulesGroup(t, folder1Title, &group) + _, status, body := client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) getGroup := client.GetRulesGroup(t, folder1Title, group.Name) require.Equalf(t, http.StatusAccepted, status, "failed to get rule group. Response: %s", body) group = convertGettableRuleGroupToPostable(getGroup.GettableRuleGroupConfig) group.Rules[0].GrafanaManagedAlert.IsPaused = tc.isPausedInBody - status, body = client.PostRulesGroup(t, folder1Title, &group) + _, status, body = client.PostRulesGroupWithStatus(t, folder1Title, &group) require.Equalf(t, http.StatusAccepted, status, "failed to post rule group. Response: %s", body) getGroup = client.GetRulesGroup(t, folder1Title, group.Name) diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 59fe8043690..ae4d1bcb649 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -328,7 +328,7 @@ func (a apiClient) UpdateAlertRuleOrgQuota(t *testing.T, orgID int64, limit int6 assert.Equal(t, http.StatusOK, resp.StatusCode) } -func (a apiClient) PostRulesGroup(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig) (int, string) { +func (a apiClient) PostRulesGroupWithStatus(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig) (apimodels.UpdateRuleGroupResponse, int, string) { t.Helper() buf := bytes.Buffer{} enc := json.NewEncoder(&buf) @@ -344,7 +344,11 @@ func (a apiClient) PostRulesGroup(t *testing.T, folder string, group *apimodels. }() b, err := io.ReadAll(resp.Body) require.NoError(t, err) - return resp.StatusCode, string(b) + var m apimodels.UpdateRuleGroupResponse + if resp.StatusCode == http.StatusAccepted { + require.NoError(t, json.Unmarshal(b, &m)) + } + return m, resp.StatusCode, string(b) } func (a apiClient) PostRulesExportWithStatus(t *testing.T, folder string, group *apimodels.PostableRuleGroupConfig, params *apimodels.ExportQueryParams) (int, string) {