diff --git a/pkg/services/ngalert/api/api_prometheus.go b/pkg/services/ngalert/api/api_prometheus.go index 3417082fd79..5521c94f463 100644 --- a/pkg/services/ngalert/api/api_prometheus.go +++ b/pkg/services/ngalert/api/api_prometheus.go @@ -2,8 +2,11 @@ package api import ( "encoding/json" + "errors" "fmt" "net/http" + "strconv" + "strings" "time" "github.com/grafana/grafana/pkg/services/ngalert/eval" @@ -51,6 +54,13 @@ func (srv PrometheusSrv) RouteGetAlertStatuses(c *models.ReqContext) response.Re return response.JSON(http.StatusOK, alertResponse) } +func getPanelIDFromRequest(r *http.Request) (int64, error) { + if s := strings.TrimSpace(r.URL.Query().Get("panel_id")); s != "" { + return strconv.ParseInt(s, 10, 64) + } + return 0, nil +} + func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Response { ruleResponse := apimodels.RuleResponse{ DiscoveryBase: apimodels.DiscoveryBase{ @@ -71,9 +81,20 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res namespaceUIDs = append(namespaceUIDs, k) } + dashboardUID := c.Query("dashboard_uid") + panelID, err := getPanelIDFromRequest(c.Req) + if err != nil { + return ErrResp(http.StatusBadRequest, err, "invalid panel_id") + } + if dashboardUID == "" && panelID != 0 { + return ErrResp(http.StatusBadRequest, errors.New("panel_id must be set with dashboard_uid"), "") + } + ruleGroupQuery := ngmodels.ListOrgRuleGroupsQuery{ OrgID: c.SignedInUser.OrgId, NamespaceUIDs: namespaceUIDs, + DashboardUID: dashboardUID, + PanelID: panelID, } if err := srv.store.GetOrgRuleGroups(&ruleGroupQuery); err != nil { ruleResponse.DiscoveryBase.Status = "error" @@ -83,7 +104,9 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *models.ReqContext) response.Res } alertRuleQuery := ngmodels.ListAlertRulesQuery{ - OrgID: c.SignedInUser.OrgId, + OrgID: c.SignedInUser.OrgId, + DashboardUID: dashboardUID, + PanelID: panelID, } if err := srv.store.GetOrgAlertRules(&alertRuleQuery); err != nil { ruleResponse.DiscoveryBase.Status = "error" diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index a215b3f73eb..96d49cd9eaa 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -158,9 +158,20 @@ func (srv RulerSrv) RouteGetRulesConfig(c *models.ReqContext) response.Response namespaceUIDs = append(namespaceUIDs, k) } + dashboardUID := c.Query("dashboard_uid") + panelID, err := getPanelIDFromRequest(c.Req) + if err != nil { + return ErrResp(http.StatusBadRequest, err, "invalid panel_id") + } + if dashboardUID == "" && panelID != 0 { + return ErrResp(http.StatusBadRequest, errors.New("panel_id must be set with dashboard_uid"), "") + } + q := ngmodels.ListAlertRulesQuery{ OrgID: c.SignedInUser.OrgId, NamespaceUIDs: namespaceUIDs, + DashboardUID: dashboardUID, + PanelID: panelID, } if err := srv.store.GetOrgAlertRules(&q); err != nil { @@ -210,7 +221,7 @@ func (srv RulerSrv) RouteGetRulesConfig(c *models.ReqContext) response.Response result[namespace] = append(result[namespace], ruleGroupConfig) } } - return response.JSON(http.StatusAccepted, result) + return response.JSON(http.StatusOK, result) } func (srv RulerSrv) RoutePostNameRulesConfig(c *models.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig) response.Response { diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index c9bbf30ac32..3e2e3c49a7d 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -183,6 +183,14 @@ type GetSilencesParams struct { Filter []string `json:"filter"` } +// swagger:parameters RouteGetRuleStatuses +type GetRuleStatusesParams struct { + // in: query + DashboardUID string + // in: query + PanelID int64 +} + // swagger:model type GettableStatus struct { // cluster diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index ff930e142c0..c8c221491b0 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -86,6 +86,14 @@ type PathRouleGroupConfig struct { Groupname string } +// swagger:parameters RouteGetRulesConfig +type PathGetRulesParams struct { + // in: query + DashboardUID string + // in: query + PanelID int64 +} + // swagger:model type RuleGroupConfigResponse struct { GettableRuleGroupConfig diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index cc27be2750e..699af43a8cc 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -2869,6 +2869,7 @@ "type": "array" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "properties": { "comment": { "description": "comment", @@ -2920,9 +2921,7 @@ "status", "updatedAt" ], - "type": "object", - "x-go-name": "GettableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models" + "type": "object" }, "gettableSilences": { "description": "GettableSilences gettable silences", @@ -3800,6 +3799,17 @@ "description": "gets the evaluation statuses of all rules", "operationId": "RouteGetRuleStatuses", "parameters": [ + { + "in": "query", + "name": "DashboardUID", + "type": "string" + }, + { + "format": "int64", + "in": "query", + "name": "PanelID", + "type": "integer" + }, { "description": "Recipient should be \"grafana\" for requests to be handled by grafana\nand the numeric datasource id for requests to be forwarded to a datasource", "in": "path", @@ -3832,6 +3842,17 @@ "name": "Recipient", "required": true, "type": "string" + }, + { + "in": "query", + "name": "DashboardUID", + "type": "string" + }, + { + "format": "int64", + "in": "query", + "name": "PanelID", + "type": "integer" } ], "produces": [ diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 9828f4d7403..263e3cce322 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -592,6 +592,17 @@ ], "operationId": "RouteGetRuleStatuses", "parameters": [ + { + "type": "string", + "name": "DashboardUID", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "PanelID", + "in": "query" + }, { "type": "string", "description": "Recipient should be \"grafana\" for requests to be handled by grafana\nand the numeric datasource id for requests to be forwarded to a datasource", @@ -627,6 +638,17 @@ "name": "Recipient", "in": "path", "required": true + }, + { + "type": "string", + "name": "DashboardUID", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "name": "PanelID", + "in": "query" } ], "responses": { @@ -3877,6 +3899,7 @@ "$ref": "#/definitions/gettableAlerts" }, "gettableSilence": { + "description": "GettableSilence gettable silence", "type": "object", "required": [ "comment", @@ -3929,8 +3952,6 @@ "x-go-name": "UpdatedAt" } }, - "x-go-name": "GettableSilence", - "x-go-package": "github.com/prometheus/alertmanager/api/v2/models", "$ref": "#/definitions/gettableSilence" }, "gettableSilences": { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index e3c0711b797..04222397c0a 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -58,8 +58,10 @@ type AlertRule struct { Updated time.Time IntervalSeconds int64 Version int64 - UID string `xorm:"uid"` - NamespaceUID string `xorm:"namespace_uid"` + UID string `xorm:"uid"` + NamespaceUID string `xorm:"namespace_uid"` + DashboardUID *string `xorm:"dashboard_uid"` + PanelID *int64 `xorm:"panel_id"` RuleGroup string NoDataState NoDataState ExecErrState ExecutionErrorState @@ -137,6 +139,11 @@ type ListAlertRulesQuery struct { NamespaceUIDs []string ExcludeOrgs []int64 + // DashboardUID and PanelID are optional and allow filtering rules + // to return just those for a dashboard and panel. + DashboardUID string + PanelID int64 + Result []*AlertRule } @@ -156,6 +163,11 @@ type ListRuleGroupAlertRulesQuery struct { NamespaceUID string RuleGroup string + // DashboardUID and PanelID are optional and allow filtering rules + // to return just those for a dashboard and panel. + DashboardUID string + PanelID int64 + Result []*AlertRule } @@ -164,6 +176,11 @@ type ListOrgRuleGroupsQuery struct { OrgID int64 NamespaceUIDs []string + // DashboardUID and PanelID are optional and allow filtering rules + // to return just those for a dashboard and panel. + DashboardUID string + PanelID int64 + Result [][]string } diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 4fcbb892897..4c7b1d0a715 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "strings" "time" @@ -332,6 +333,17 @@ func (st DBstore) GetOrgAlertRules(query *ngmodels.ListAlertRulesQuery) error { q = fmt.Sprintf("%s AND namespace_uid IN (%s)", q, strings.Join(placeholders, ",")) } + if query.DashboardUID != "" { + params = append(params, query.DashboardUID) + q = fmt.Sprintf("%s AND dashboard_uid = ?", q) + if query.PanelID != 0 { + params = append(params, query.PanelID) + q = fmt.Sprintf("%s AND panel_id = ?", q) + } + } + + q = fmt.Sprintf("%s ORDER BY id ASC", q) + if err := sess.SQL(q, params...).Find(&alertRules); err != nil { return err } @@ -359,10 +371,20 @@ func (st DBstore) GetNamespaceAlertRules(query *ngmodels.ListNamespaceAlertRules // GetRuleGroupAlertRules is a handler for retrieving rule group alert rules of specific organisation. func (st DBstore) GetRuleGroupAlertRules(query *ngmodels.ListRuleGroupAlertRulesQuery) error { return st.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { - alertRules := make([]*ngmodels.AlertRule, 0) - q := "SELECT * FROM alert_rule WHERE org_id = ? and namespace_uid = ? and rule_group = ?" - if err := sess.SQL(q, query.OrgID, query.NamespaceUID, query.RuleGroup).Find(&alertRules); err != nil { + args := []interface{}{query.OrgID, query.NamespaceUID, query.RuleGroup} + + if query.DashboardUID != "" { + q = fmt.Sprintf("%s and dashboard_uid = ?", q) + args = append(args, query.DashboardUID) + if query.PanelID != 0 { + q = fmt.Sprintf("%s and panel_id = ?", q) + args = append(args, query.PanelID) + } + } + + alertRules := make([]*ngmodels.AlertRule, 0) + if err := sess.SQL(q, args...).Find(&alertRules); err != nil { return err } @@ -528,6 +550,18 @@ func (st DBstore) UpdateRuleGroup(cmd UpdateRuleGroupCmd) error { new.Labels = r.ApiRuleNode.Labels } + if s := new.Annotations["__dashboardUid__"]; s != "" { + new.DashboardUID = &s + } + + if s := new.Annotations["__panelId__"]; s != "" { + panelID, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return fmt.Errorf("the __panelId__ annotation does not contain a valid Panel ID: %w", err) + } + new.PanelID = &panelID + } + upsertRule := UpsertRule{ New: new, } @@ -569,7 +603,19 @@ func (st DBstore) UpdateRuleGroup(cmd UpdateRuleGroupCmd) error { func (st DBstore) GetOrgRuleGroups(query *ngmodels.ListOrgRuleGroupsQuery) error { return st.SQLStore.WithDbSession(context.Background(), func(sess *sqlstore.DBSession) error { var ruleGroups [][]string - q := "SELECT DISTINCT rule_group, namespace_uid, (select title from dashboard where org_id = alert_rule.org_id and uid = alert_rule.namespace_uid) AS namespace_title FROM alert_rule WHERE org_id = ?" + q := ` +SELECT DISTINCT + rule_group, + namespace_uid, + ( + SELECT title + FROM dashboard + WHERE + org_id = alert_rule.org_id AND + uid = alert_rule.namespace_uid + ) AS namespace_title +FROM alert_rule +WHERE org_id = ?` params := []interface{}{query.OrgID} if len(query.NamespaceUIDs) > 0 { @@ -580,6 +626,16 @@ func (st DBstore) GetOrgRuleGroups(query *ngmodels.ListOrgRuleGroupsQuery) error } q = fmt.Sprintf(" %s AND namespace_uid IN (%s)", q, strings.Join(placeholders, ",")) } + + if query.DashboardUID != "" { + q = fmt.Sprintf("%s and dashboard_uid = ?", q) + params = append(params, query.DashboardUID) + if query.PanelID != 0 { + q = fmt.Sprintf("%s and panel_id = ?", q) + params = append(params, query.PanelID) + } + } + q = fmt.Sprintf(" %s ORDER BY namespace_title", q) if err := sess.SQL(q, params...).Find(&ruleGroups); err != nil { diff --git a/pkg/services/sqlstore/migrations/migrations.go b/pkg/services/sqlstore/migrations/migrations.go index d59926a8b18..e595fc26cd8 100644 --- a/pkg/services/sqlstore/migrations/migrations.go +++ b/pkg/services/sqlstore/migrations/migrations.go @@ -54,6 +54,7 @@ func (*OSSMigrations) AddMigration(mg *Migrator) { ualert.RerunDashAlertMigration(mg) addSecretsMigration(mg) addKVStoreMigrations(mg) + ualert.AddDashboardUIDPanelIDMigration(mg) } func addMigrationLogMigrations(mg *Migrator) { diff --git a/pkg/services/sqlstore/migrations/ualert/tables.go b/pkg/services/sqlstore/migrations/ualert/tables.go index df7255a9845..7e812f3c11f 100644 --- a/pkg/services/sqlstore/migrations/ualert/tables.go +++ b/pkg/services/sqlstore/migrations/ualert/tables.go @@ -204,6 +204,33 @@ func AddAlertRuleMigrations(mg *migrator.Migrator, defaultIntervalSeconds int64) mg.AddMigration("add index in alert_rule on org_id, namespase_uid and title columns", migrator.NewAddIndexMigration(alertRule, &migrator.Index{ Cols: []string{"org_id", "namespace_uid", "title"}, Type: migrator.UniqueIndex, })) + + mg.AddMigration("add dashboard_uid column to alert_rule", migrator.NewAddColumnMigration( + migrator.Table{Name: "alert_rule"}, + &migrator.Column{ + Name: "dashboard_uid", + Type: migrator.DB_NVarchar, + Length: 40, + Nullable: true, + }, + )) + + mg.AddMigration("add panel_id column to alert_rule", migrator.NewAddColumnMigration( + migrator.Table{Name: "alert_rule"}, + &migrator.Column{ + Name: "panel_id", + Type: migrator.DB_BigInt, + Nullable: true, + }, + )) + + mg.AddMigration("add index in alert_rule on org_id, dashboard_uid and panel_id columns", migrator.NewAddIndexMigration( + migrator.Table{Name: "alert_rule"}, + &migrator.Index{ + Name: "IDX_alert_rule_org_id_dashboard_uid_panel_id", + Cols: []string{"org_id", "dashboard_uid", "panel_id"}, + }, + )) } func AddAlertRuleVersionMigrations(mg *migrator.Migrator) { diff --git a/pkg/services/sqlstore/migrations/ualert/ualert.go b/pkg/services/sqlstore/migrations/ualert/ualert.go index fde4ab864cb..71b605eb8a5 100644 --- a/pkg/services/sqlstore/migrations/ualert/ualert.go +++ b/pkg/services/sqlstore/migrations/ualert/ualert.go @@ -101,6 +101,73 @@ func RerunDashAlertMigration(mg *migrator.Migrator) { } } +func AddDashboardUIDPanelIDMigration(mg *migrator.Migrator) { + logs, err := mg.GetMigrationLog() + if err != nil { + mg.Logger.Crit("alert migration failure: could not get migration log", "error", err) + os.Exit(1) + } + + migrationID := "update dashboard_uid and panel_id from existing annotations" + _, migrationRun := logs[migrationID] + ngEnabled := mg.Cfg.UnifiedAlerting.Enabled + undoMigrationID := "undo " + migrationID + + if ngEnabled && !migrationRun { + // If ngalert is enabled and the migration has not been run then run it. + mg.AddMigration(migrationID, &updateDashboardUIDPanelIDMigration{}) + } else if !ngEnabled && migrationRun { + // If ngalert is disabled and the migration has been run then remove it + // from the migration log so it will run if ngalert is re-enabled. + mg.AddMigration(undoMigrationID, &clearMigrationEntry{ + migrationID: migrationID, + }) + } +} + +// updateDashboardUIDPanelIDMigration sets the dashboard_uid and panel_id columns +// from the __dashboardUid__ and __panelId__ annotations. +type updateDashboardUIDPanelIDMigration struct { + migrator.MigrationBase +} + +func (m *updateDashboardUIDPanelIDMigration) SQL(_ migrator.Dialect) string { + return "set dashboard_uid and panel_id migration" +} + +func (m *updateDashboardUIDPanelIDMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { + var results []struct { + ID int64 `xorm:"id"` + Annotations map[string]string `xorm:"annotations"` + } + if err := sess.SQL(`SELECT id, annotations FROM alert_rule`).Find(&results); err != nil { + return fmt.Errorf("failed to get annotations for all alert rules: %w", err) + } + for _, next := range results { + var ( + dashboardUID *string + panelID *int64 + ) + if s, ok := next.Annotations["__dashboardUid__"]; ok { + dashboardUID = &s + } + if s, ok := next.Annotations["__panelId__"]; ok { + i, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return fmt.Errorf("the __panelId__ annotation does not contain a valid Panel ID: %w", err) + } + panelID = &i + } + if _, err := sess.Exec(`UPDATE alert_rule SET dashboard_uid = ?, panel_id = ? WHERE id = ?`, + dashboardUID, + panelID, + next.ID); err != nil { + return fmt.Errorf("failed to set dashboard_uid and panel_id for alert rule: %w", err) + } + } + return nil +} + // clearMigrationEntry removes an entry fromt the migration_log table. // This migration is not recorded in the migration_log so that it can re-run several times. type clearMigrationEntry struct { diff --git a/pkg/tests/api/alerting/api_alertmanager_test.go b/pkg/tests/api/alerting/api_alertmanager_test.go index 3527bfa3d6a..4629af0c466 100644 --- a/pkg/tests/api/alerting/api_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_alertmanager_test.go @@ -722,7 +722,7 @@ func TestDeleteFolderWithRules(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - assert.Equal(t, 202, resp.StatusCode) + assert.Equal(t, 200, resp.StatusCode) re := regexp.MustCompile(`"uid":"([\w|-]+)"`) b = re.ReplaceAll(b, []byte(`"uid":""`)) @@ -833,7 +833,7 @@ func TestDeleteFolderWithRules(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - assert.Equal(t, 202, resp.StatusCode) + assert.Equal(t, 200, resp.StatusCode) assert.JSONEq(t, "{}", string(b)) } } diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index 937619e9025..7b35fdef914 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -264,6 +264,301 @@ func TestPrometheusRules(t *testing.T) { } } +func TestPrometheusRulesFilterByDashboard(t *testing.T) { + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + DisableAnonymous: true, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() + + // Create the namespace under default organisation (orgID = 1) where we'll save our alerts to. + dashboardUID, err := createFolder(t, store, 0, "default") + require.NoError(t, err) + + // Create a user to make authenticated requests + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Password: "password", + Login: "grafana", + }) + + interval, err := model.ParseDuration("10s") + require.NoError(t, err) + + // Now, let's create some rules + { + rules := apimodels.PostableRuleGroupConfig{ + Name: "anotherrulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: interval, + Labels: map[string]string{}, + Annotations: map[string]string{ + "__dashboardUid__": dashboardUID, + "__panelId__": "1", + }, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + 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), + }, + DatasourceUID: "-100", + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + { + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + 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), + }, + DatasourceUID: "-100", + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + NoDataState: apimodels.NoDataState(ngmodels.Alerting), + ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), + }, + }, + }, + } + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err := enc.Encode(&rules) + require.NoError(t, err) + + u := fmt.Sprintf("http://grafana:password@%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) + + assert.Equal(t, resp.StatusCode, 202) + require.JSONEq(t, `{"message":"rule group updated successfully"}`, string(b)) + } + + expectedAllJSON := fmt.Sprintf(` +{ + "status": "success", + "data": { + "groups": [{ + "name": "anotherrulegroup", + "file": "default", + "rules": [{ + "state": "inactive", + "name": "AlwaysFiring", + "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"-100\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]", + "duration": 10, + "annotations": { + "__dashboardUid__": "%s", + "__panelId__": "1" + }, + "labels": null, + "health": "ok", + "lastError": "", + "type": "alerting", + "lastEvaluation": "0001-01-01T00:00:00Z", + "evaluationTime": 0 + }, { + "state": "inactive", + "name": "AlwaysFiringButSilenced", + "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"-100\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]", + "labels": null, + "health": "ok", + "lastError": "", + "type": "alerting", + "lastEvaluation": "0001-01-01T00:00:00Z", + "evaluationTime": 0 + }], + "interval": 60, + "lastEvaluation": "0001-01-01T00:00:00Z", + "evaluationTime": 0 + }] + } +}`, dashboardUID) + expectedFilteredByJSON := fmt.Sprintf(` +{ + "status": "success", + "data": { + "groups": [{ + "name": "anotherrulegroup", + "file": "default", + "rules": [{ + "state": "inactive", + "name": "AlwaysFiring", + "query": "[{\"refId\":\"A\",\"queryType\":\"\",\"relativeTimeRange\":{\"from\":18000,\"to\":10800},\"datasourceUid\":\"-100\",\"model\":{\"expression\":\"2 + 3 \\u003e 1\",\"intervalMs\":1000,\"maxDataPoints\":43200,\"type\":\"math\"}}]", + "duration": 10, + "annotations": { + "__dashboardUid__": "%s", + "__panelId__": "1" + }, + "labels": null, + "health": "ok", + "lastError": "", + "type": "alerting", + "lastEvaluation": "0001-01-01T00:00:00Z", + "evaluationTime": 0 + }], + "interval": 60, + "lastEvaluation": "0001-01-01T00:00:00Z", + "evaluationTime": 0 + }] + } +}`, dashboardUID) + expectedNoneJSON := ` +{ + "status": "success", + "data": { + "groups": [] + } +}` + + // Now, let's see how this looks like. + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + 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, 200, resp.StatusCode) + + require.JSONEq(t, expectedAllJSON, string(b)) + } + + // Now, let's check we get the same rule when filtering by dashboard_uid + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?dashboard_uid=%s", grafanaListedAddr, dashboardUID) + // nolint:gosec + resp, err := http.Get(promRulesURL) + 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, 200, resp.StatusCode) + + require.JSONEq(t, expectedFilteredByJSON, string(b)) + } + + // Now, let's check we get no rules when filtering by an unknown dashboard_uid + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?dashboard_uid=%s", grafanaListedAddr, "abc") + // nolint:gosec + resp, err := http.Get(promRulesURL) + 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, 200, resp.StatusCode) + + require.JSONEq(t, expectedNoneJSON, string(b)) + } + + // Now, let's check we get the same rule when filtering by dashboard_uid and panel_id + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?dashboard_uid=%s&panel_id=1", grafanaListedAddr, dashboardUID) + // nolint:gosec + resp, err := http.Get(promRulesURL) + 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, 200, resp.StatusCode) + + require.JSONEq(t, expectedFilteredByJSON, string(b)) + } + + // Now, let's check we get no rules when filtering by dashboard_uid and unknown panel_id + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?dashboard_uid=%s&panel_id=2", grafanaListedAddr, dashboardUID) + // nolint:gosec + resp, err := http.Get(promRulesURL) + 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, 200, resp.StatusCode) + + require.JSONEq(t, expectedNoneJSON, string(b)) + } + + // Now, let's check an invalid panel_id returns a 400 Bad Request response + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?dashboard_uid=%s&panel_id=invalid", grafanaListedAddr, dashboardUID) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.JSONEq(t, `{"message":"invalid panel_id: strconv.ParseInt: parsing \"invalid\": invalid syntax"}`, string(b)) + } + + // Now, let's check a panel_id without dashboard_uid returns a 400 Bad Request response + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?panel_id=1", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.JSONEq(t, `{"message":"panel_id must be set with dashboard_uid"}`, string(b)) + } +} + func TestPrometheusRulesPermissions(t *testing.T) { dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ DisableLegacyAlerting: true, diff --git a/pkg/tests/api/alerting/api_ruler_test.go b/pkg/tests/api/alerting/api_ruler_test.go index 375382e5f52..e4298e8b228 100644 --- a/pkg/tests/api/alerting/api_ruler_test.go +++ b/pkg/tests/api/alerting/api_ruler_test.go @@ -65,7 +65,7 @@ func TestAlertRulePermissions(t *testing.T) { b, err := ioutil.ReadAll(resp.Body) require.NoError(t, err) - assert.Equal(t, resp.StatusCode, 202) + assert.Equal(t, resp.StatusCode, 200) body, _ := rulesNamespaceWithoutVariableValues(t, b) expectedGetNamespaceResponseBody := ` @@ -187,7 +187,7 @@ func TestAlertRulePermissions(t *testing.T) { b, err = ioutil.ReadAll(resp.Body) require.NoError(t, err) - assert.Equal(t, resp.StatusCode, 202) + assert.Equal(t, resp.StatusCode, 200) body, _ = rulesNamespaceWithoutVariableValues(t, b) expectedGetNamespaceResponseBody = ` @@ -427,3 +427,349 @@ func TestAlertRuleConflictingTitle(t *testing.T) { require.JSONEq(t, `{"message":"rule group updated successfully"}`, string(b)) }) } + +func TestRulerRulesFilterByDashboard(t *testing.T) { + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + EnableFeatureToggles: []string{"ngalert"}, + DisableAnonymous: true, + }) + + grafanaListedAddr, store := testinfra.StartGrafana(t, dir, path) + // override bus to get the GetSignedInUserQuery handler + store.Bus = bus.GetBus() + + // Create the namespace under default organisation (orgID = 1) where we'll save our alerts to. + dashboardUID, err := createFolder(t, store, 0, "default") + require.NoError(t, err) + + // Create a user to make authenticated requests + createUser(t, store, models.CreateUserCommand{ + DefaultOrgRole: string(models.ROLE_EDITOR), + Password: "password", + Login: "grafana", + }) + + interval, err := model.ParseDuration("10s") + require.NoError(t, err) + + // Now, let's create some rules + { + rules := apimodels.PostableRuleGroupConfig{ + Name: "anotherrulegroup", + Rules: []apimodels.PostableExtendedRuleNode{ + { + ApiRuleNode: &apimodels.ApiRuleNode{ + For: interval, + Labels: map[string]string{}, + Annotations: map[string]string{ + "__dashboardUid__": dashboardUID, + "__panelId__": "1", + }, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + 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), + }, + DatasourceUID: "-100", + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + }, + }, + { + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + 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), + }, + DatasourceUID: "-100", + Model: json.RawMessage(`{ + "type": "math", + "expression": "2 + 3 > 1" + }`), + }, + }, + NoDataState: apimodels.NoDataState(ngmodels.Alerting), + ExecErrState: apimodels.ExecutionErrorState(ngmodels.AlertingErrState), + }, + }, + }, + } + buf := bytes.Buffer{} + enc := json.NewEncoder(&buf) + err := enc.Encode(&rules) + require.NoError(t, err) + + u := fmt.Sprintf("http://grafana:password@%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) + + assert.Equal(t, resp.StatusCode, 202) + require.JSONEq(t, `{"message":"rule group updated successfully"}`, string(b)) + } + + expectedAllJSON := fmt.Sprintf(` +{ + "default": [{ + "name": "anotherrulegroup", + "interval": "1m", + "rules": [{ + "expr": "", + "for": "10s", + "annotations": { + "__dashboardUid__": "%s", + "__panelId__": "1" + }, + "grafana_alert": { + "id": 1, + "orgId": 1, + "title": "AlwaysFiring", + "condition": "A", + "data": [{ + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "-100", + "model": { + "expression": "2 + 3 \u003e 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + }], + "updated": "2021-02-21T01:10:30Z", + "intervalSeconds": 60, + "version": 1, + "uid": "uid", + "namespace_uid": "nsuid", + "namespace_id": 1, + "rule_group": "anotherrulegroup", + "no_data_state": "NoData", + "exec_err_state": "Alerting" + } + }, { + "expr": "", + "grafana_alert": { + "id": 2, + "orgId": 1, + "title": "AlwaysFiringButSilenced", + "condition": "A", + "data": [{ + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "-100", + "model": { + "expression": "2 + 3 \u003e 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + }], + "updated": "2021-02-21T01:10:30Z", + "intervalSeconds": 60, + "version": 1, + "uid": "uid", + "namespace_uid": "nsuid", + "namespace_id": 1, + "rule_group": "anotherrulegroup", + "no_data_state": "Alerting", + "exec_err_state": "Alerting" + } + }] + }] +}`, dashboardUID) + expectedFilteredByJSON := fmt.Sprintf(` +{ + "default": [{ + "name": "anotherrulegroup", + "interval": "1m", + "rules": [{ + "expr": "", + "for": "10s", + "annotations": { + "__dashboardUid__": "%s", + "__panelId__": "1" + }, + "grafana_alert": { + "id": 1, + "orgId": 1, + "title": "AlwaysFiring", + "condition": "A", + "data": [{ + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "-100", + "model": { + "expression": "2 + 3 \u003e 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + }], + "updated": "2021-02-21T01:10:30Z", + "intervalSeconds": 60, + "version": 1, + "uid": "uid", + "namespace_uid": "nsuid", + "namespace_id": 1, + "rule_group": "anotherrulegroup", + "no_data_state": "NoData", + "exec_err_state": "Alerting" + } + }] + }] +}`, dashboardUID) + expectedNoneJSON := `{}` + + // Now, let's see how this looks like. + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + 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, 200, resp.StatusCode) + + body, _ := rulesNamespaceWithoutVariableValues(t, b) + require.JSONEq(t, expectedAllJSON, body) + } + + // Now, let's check we get the same rule when filtering by dashboard_uid + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules?dashboard_uid=%s", grafanaListedAddr, dashboardUID) + // nolint:gosec + resp, err := http.Get(promRulesURL) + 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, 200, resp.StatusCode) + + body, _ := rulesNamespaceWithoutVariableValues(t, b) + require.JSONEq(t, expectedFilteredByJSON, body) + } + + // Now, let's check we get no rules when filtering by an unknown dashboard_uid + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules?dashboard_uid=%s", grafanaListedAddr, "abc") + // nolint:gosec + resp, err := http.Get(promRulesURL) + 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, 200, resp.StatusCode) + + require.JSONEq(t, expectedNoneJSON, string(b)) + } + + // Now, let's check we get the same rule when filtering by dashboard_uid and panel_id + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules?dashboard_uid=%s&panel_id=1", grafanaListedAddr, dashboardUID) + // nolint:gosec + resp, err := http.Get(promRulesURL) + 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, 200, resp.StatusCode) + + body, _ := rulesNamespaceWithoutVariableValues(t, b) + require.JSONEq(t, expectedFilteredByJSON, body) + } + + // Now, let's check we get no rules when filtering by dashboard_uid and unknown panel_id + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules?dashboard_uid=%s&panel_id=2", grafanaListedAddr, dashboardUID) + // nolint:gosec + resp, err := http.Get(promRulesURL) + 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, 200, resp.StatusCode) + + require.JSONEq(t, expectedNoneJSON, string(b)) + } + + // Now, let's check an invalid panel_id returns a 400 Bad Request response + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules?dashboard_uid=%s&panel_id=invalid", grafanaListedAddr, dashboardUID) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.JSONEq(t, `{"message":"invalid panel_id: strconv.ParseInt: parsing \"invalid\": invalid syntax"}`, string(b)) + } + + // Now, let's check a panel_id without dashboard_uid returns a 400 Bad Request response + { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/ruler/grafana/api/v1/rules?panel_id=1", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + require.Equal(t, http.StatusBadRequest, resp.StatusCode) + b, err := ioutil.ReadAll(resp.Body) + require.NoError(t, err) + require.JSONEq(t, `{"message":"panel_id must be set with dashboard_uid"}`, string(b)) + } +}