From 362c4d42760fd29afce7f8b3767e2631ebb30224 Mon Sep 17 00:00:00 2001 From: gotjosh Date: Fri, 16 Apr 2021 13:00:07 +0100 Subject: [PATCH] Alerting: Integration test rule creation (#33047) * Alerting: Integration test rule creation * Appease the linter * Cleanup * Make anonymous user role a parameter --- pkg/services/ngalert/api/api_ruler.go | 11 +- pkg/services/ngalert/models/alert_rule.go | 2 + pkg/services/ngalert/store/alert_rule.go | 9 + .../api/alerting/api_alertmanager_test.go | 269 +++++++++++++++++- pkg/tests/testinfra/testinfra.go | 6 + 5 files changed, 295 insertions(+), 2 deletions(-) diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 4942bcc1fd3..0cf713d8361 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -43,8 +43,12 @@ func (srv RulerSrv) RouteDeleteRuleGroupConfig(c *models.ReqContext) response.Re } ruleGroup := c.Params(":Groupname") if err := srv.store.DeleteRuleGroupAlertRules(c.SignedInUser.OrgId, namespace.Uid, ruleGroup); err != nil { - return response.Error(http.StatusInternalServerError, "failed to delete group alert rules", err) + if errors.Is(err, ngmodels.ErrRuleGroupNamespaceNotFound) { + return response.Error(http.StatusNotFound, "failed to delete rule group", err) + } + return response.Error(http.StatusInternalServerError, "failed to delete rule group", err) } + return response.JSON(http.StatusAccepted, util.DynMap{"message": "rule group deleted"}) } @@ -187,6 +191,11 @@ func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConf // TODO check quota // TODO validate UID uniqueness in the payload + //TODO: Should this belong in alerting-api? + if ruleGroupConfig.Name == "" { + return response.Error(http.StatusBadRequest, "rule group name is not valid", nil) + } + if err := srv.store.UpdateRuleGroup(store.UpdateRuleGroupCmd{ OrgID: c.SignedInUser.OrgId, NamespaceUID: namespace.Uid, diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index fc1c98e3490..9d8e6caa708 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -13,6 +13,8 @@ var ( ErrAlertRuleFailedGenerateUniqueUID = errors.New("failed to generate alert rule UID") // ErrCannotEditNamespace is an error returned if the user does not have permissions to edit the namespace ErrCannotEditNamespace = errors.New("user does not have permissions to edit the namespace") + // ErrRuleGroupNamespaceNotFound + ErrRuleGroupNamespaceNotFound = errors.New("rule group not found under this namespace") ) type NoDataState string diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 54309a81137..c3cfbc75714 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -115,6 +115,15 @@ func (st DBstore) DeleteNamespaceAlertRules(orgID int64, namespaceUID string) er // DeleteRuleGroupAlertRules is a handler for deleting rule group alert rules. func (st DBstore) DeleteRuleGroupAlertRules(orgID int64, namespaceUID string, ruleGroup string) error { return st.SQLStore.WithTransactionalDbSession(context.Background(), func(sess *sqlstore.DBSession) error { + exist, err := sess.Exist(&ngmodels.AlertRule{OrgID: orgID, NamespaceUID: namespaceUID, RuleGroup: ruleGroup}) + if err != nil { + return err + } + + if !exist { + return ngmodels.ErrRuleGroupNamespaceNotFound + } + if _, err := sess.Exec("DELETE FROM alert_rule WHERE org_id = ? and namespace_uid = ? and rule_group = ?", orgID, namespaceUID, ruleGroup); err != nil { return err } diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 9d49084252c..4d5bde345b3 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -1,13 +1,23 @@ package alerting import ( + "bytes" + "encoding/json" "fmt" "io/ioutil" "net/http" "testing" + "time" - "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + apimodels "github.com/grafana/alerting-api/pkg/api" + "github.com/grafana/grafana/pkg/components/simplejson" + "github.com/grafana/grafana/pkg/models" + ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/sqlstore" + "github.com/grafana/grafana/pkg/tests/testinfra" ) func TestAlertAndGroupsQuery(t *testing.T) { @@ -50,3 +60,260 @@ func TestAlertAndGroupsQuery(t *testing.T) { require.JSONEq(t, "[]", string(b)) } } + +func TestAlertRuleCRUD(t *testing.T) { + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + AnonymousUserRole: models.ROLE_EDITOR, + }) + store := testinfra.SetUpDatabase(t, dir) + grafanaListedAddr := testinfra.StartGrafana(t, dir, path, store) + + // Create the namespace we'll save our alerts to. + require.NoError(t, createFolder(t, store, 0, "default")) + + // Now, let's create two alerts. + { + rules := apimodels.PostableRuleGroupConfig{ + Name: "arulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + OrgID: 2, + Title: "AlwaysFiring", + Condition: "A", + Data: []ngmodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: ngmodels.RelativeTimeRange{ + From: ngmodels.Duration(time.Duration(5) * time.Hour), + To: ngmodels.Duration(time.Duration(3) * time.Hour), + }, + Model: json.RawMessage(`{ + "datasource": "__expr__", + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + { + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + OrgID: 2, + Title: "AlwaysFiringButSilenced", + Condition: "A", + Data: []ngmodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: ngmodels.RelativeTimeRange{ + From: ngmodels.Duration(time.Duration(5) * time.Hour), + To: ngmodels.Duration(time.Duration(3) * time.Hour), + }, + Model: json.RawMessage(`{ + "datasource": "__expr__", + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + }, + } + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err := enc.Encode(&rules) + require.NoError(t, err) + + u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Post(u, "application/json", &buf) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + fmt.Println(string(b)) + assert.Equal(t, resp.StatusCode, 202) + require.JSONEq(t, `{"message":"rule group updated successfully"}`, string(b)) + } + + // With the rules created, let's make sure that rule definition is stored correctly. + { + u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(u) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + assert.Equal(t, resp.StatusCode, 202) + assert.JSONEq(t, ` +{ + "default":[ + { + "name":"arulegroup", + "interval":"1m", + "rules":[ + { + "expr":"", + "grafana_alert":{ + "id":1, + "orgId":2, + "title":"AlwaysFiring", + "condition":"A", + "data":[ + { + "refId":"A", + "queryType":"", + "relativeTimeRange":{ + "from":18000, + "to":10800 + }, + "model":{ + "datasource":"__expr__", + "datasourceUid":"-100", + "expression":"2 + 3 \u003e 1", + "intervalMs":1000, + "maxDataPoints":100, + "type":"math" + } + } + ], + "updated":"2021-02-21T01:10:30Z", + "intervalSeconds":60, + "version":1, + "uid":"uid", + "namespace_uid":"nsuid", + "namespace_id":1, + "rule_group":"arulegroup", + "no_data_state":"", + "exec_err_state":"" + } + }, + { + "expr":"", + "grafana_alert":{ + "id":2, + "orgId":2, + "title":"AlwaysFiringButSilenced", + "condition":"A", + "data":[ + { + "refId":"A", + "queryType":"", + "relativeTimeRange":{ + "from":18000, + "to":10800 + }, + "model":{ + "datasource":"__expr__", + "datasourceUid":"-100", + "expression":"2 + 3 \u003e 1", + "intervalMs":1000, + "maxDataPoints":100, + "type":"math" + } + } + ], + "updated":"2021-02-21T01:10:30Z", + "intervalSeconds":60, + "version":1, + "uid":"uid", + "namespace_uid":"nsuid", + "namespace_id":1, + "rule_group":"arulegroup", + "no_data_state":"", + "exec_err_state":"" + } + } + ] + } + ] +}`, rulesNamespaceWithoutVariableValues(t, b)) + } + + client := &http.Client{} + // Finally, make sure we can delete it. + { + // If the rule group name does not exists + u := fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default/groupnotexist", grafanaListedAddr) + req, err := http.NewRequest(http.MethodDelete, u, nil) + require.NoError(t, err) + resp, err := client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + require.Equal(t, http.StatusNotFound, resp.StatusCode) + require.JSONEq(t, `{"error":"rule group not found under this namespace", "message": "failed to delete rule group"}`, string(b)) + + // If the rule group name does exist + u = fmt.Sprintf("http://%s/api/ruler/grafana/api/v1/rules/default/arulegroup", grafanaListedAddr) + req, err = http.NewRequest(http.MethodDelete, u, nil) + require.NoError(t, err) + resp, err = client.Do(req) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + b, err = ioutil.ReadAll(resp.Body) + require.NoError(t, err) + + require.Equal(t, http.StatusAccepted, resp.StatusCode) + require.JSONEq(t, `{"message":"rule group deleted"}`, string(b)) + } +} + +// createFolder creates a folder for storing our alerts under. Grafana uses folders as a replacement for alert namespaces to match its permission model. +// We use the dashboard command using IsFolder = true to tell it's a folder, it takes the dashboard as the name of the folder. +func createFolder(t *testing.T, store *sqlstore.SQLStore, folderID int64, folderName string) error { + t.Helper() + + cmd := models.SaveDashboardCommand{ + OrgId: 2, // This is the orgID of the anonymous user. + FolderId: folderID, + IsFolder: true, + Dashboard: simplejson.NewFromAny(map[string]interface{}{ + "title": folderName, + }), + } + _, err := store.SaveDashboard(cmd) + + return err +} + +// rulesNamespaceWithoutVariableValues takes a apimodels.NamespaceConfigResponse JSON-based input and makes the dynamic fields static e.g. uid, dates, etc. +func rulesNamespaceWithoutVariableValues(t *testing.T, b []byte) string { + t.Helper() + + var r apimodels.NamespaceConfigResponse + require.NoError(t, json.Unmarshal(b, &r)) + for _, nodes := range r { + for _, node := range nodes { + for _, rule := range node.Rules { + rule.GrafanaManagedAlert.UID = "uid" + rule.GrafanaManagedAlert.NamespaceUID = "nsuid" + rule.GrafanaManagedAlert.Updated = time.Date(2021, time.Month(2), 21, 1, 10, 30, 0, time.UTC) + } + } + } + + json, err := json.Marshal(&r) + require.NoError(t, err) + return string(json) +} diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 445a44e0efa..9c1e6f3716b 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/grafana/grafana/pkg/infra/fs" + "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/registry" "github.com/grafana/grafana/pkg/server" "github.com/grafana/grafana/pkg/services/sqlstore" @@ -202,6 +203,10 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { _, err = featureSection.NewKey("enable", strings.Join(o.EnableFeatureToggles, " ")) require.NoError(t, err) } + if o.AnonymousUserRole != "" { + _, err = anonSect.NewKey("org_role", string(o.AnonymousUserRole)) + require.NoError(t, err) + } } cfgPath := filepath.Join(cfgDir, "test.ini") @@ -217,4 +222,5 @@ func CreateGrafDir(t *testing.T, opts ...GrafanaOpts) (string, string) { type GrafanaOpts struct { EnableCSP bool EnableFeatureToggles []string + AnonymousUserRole models.RoleType }