From b641fd64f923fdff443059788e71139c1b50be35 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 25 Feb 2025 11:26:36 +0100 Subject: [PATCH] Alerting: API to create rule groups using mimirtool (#100558) What is this feature? Adds an API endpoint to create alert rules with mimirtool: - POST /convert/prometheus/config/v1/rules/{NamespaceTitle} - Accepts a single rule group in a Prometheus YAML format and creates or updates a Grafana rule group from it. The endpoint uses the conversion package from #100224. Key parts The API works similarly to the provisioning API. If the rule does not exist, it will be created, otherwise updated. Any rules not present in the new group will be deleted, ensuring the group is fully synchronized with the provided configuration. Since the API works with namespace titles (folders), the handler automatically creates a folder in the root based on the provided title if it does not exist. It also requires a special header, X-Grafana-Alerting-Datasource-UID. This header specifies which datasource to use for the new rules. If the rule group's evaluation interval is not specified, it uses the DefaultRuleEvaluationInterval from settings. --- pkg/services/ngalert/api/api.go | 6 +- .../ngalert/api/api_convert_prometheus.go | 176 +++++++++++++- .../api/api_convert_prometheus_test.go | 212 +++++++++++++++++ pkg/services/ngalert/api/authorization.go | 21 +- pkg/services/ngalert/api/persist.go | 2 + pkg/services/ngalert/api/tooling/api.json | 5 +- .../definitions/convert_prometheus_api.go | 6 +- pkg/services/ngalert/api/tooling/post.json | 7 +- pkg/services/ngalert/api/tooling/spec.json | 7 +- pkg/services/ngalert/models/provisioning.go | 4 +- pkg/services/ngalert/prom/convert.go | 28 +-- pkg/services/ngalert/prom/convert_test.go | 46 ++-- pkg/services/ngalert/prom/models.go | 14 ++ pkg/services/ngalert/store/alert_rule.go | 30 --- pkg/services/ngalert/store/alert_rule_test.go | 52 ----- pkg/services/ngalert/store/namespace.go | 97 ++++++++ pkg/services/ngalert/store/namespace_test.go | 220 ++++++++++++++++++ pkg/services/ngalert/tests/fakes/rules.go | 34 +++ .../alerting/api_convert_prometheus_test.go | 196 ++++++++++++++++ pkg/tests/api/alerting/testing.go | 28 ++- public/api-merged.json | 2 - public/openapi3.json | 2 - 22 files changed, 1049 insertions(+), 146 deletions(-) create mode 100644 pkg/services/ngalert/api/api_convert_prometheus_test.go create mode 100644 pkg/services/ngalert/store/namespace.go create mode 100644 pkg/services/ngalert/store/namespace_test.go create mode 100644 pkg/tests/api/alerting/api_convert_prometheus_test.go diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index 8973ca6cce7..10d50a6b0b5 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -187,8 +187,8 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { }), m) if api.FeatureManager.IsEnabledGlobally(featuremgmt.FlagAlertingConversionAPI) { - api.RegisterConvertPrometheusApiEndpoints(NewConvertPrometheusApi(&ConvertPrometheusSrv{ - logger: logger, - }), m) + api.RegisterConvertPrometheusApiEndpoints(NewConvertPrometheusApi( + NewConvertPrometheusSrv(&api.Cfg.UnifiedAlerting, logger, api.RuleStore, api.DatasourceCache, api.AlertRules), + ), m) } } diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index babe4f1bca4..c27bf9b118a 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -1,14 +1,60 @@ package api import ( + "fmt" + "net/http" + "strconv" + "strings" + "github.com/grafana/grafana/pkg/api/response" + "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/infra/log" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/folder" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/prom" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" + "github.com/grafana/grafana/pkg/setting" ) +const ( + datasourceUIDHeader = "X-Grafana-Alerting-Datasource-UID" + recordingRulesPausedHeader = "X-Grafana-Alerting-Recording-Rules-Paused" + alertRulesPausedHeader = "X-Grafana-Alerting-Alert-Rules-Paused" +) + +var ( + errDatasourceUIDHeaderMissing = errutil.ValidationFailed( + "alerting.datasourceUIDHeaderMissing", + errutil.WithPublicMessage(fmt.Sprintf("Missing datasource UID header: %s", datasourceUIDHeader)), + ).Errorf("missing datasource UID header") + + errInvalidHeaderValueMsg = "Invalid value for header {{.Public.Header}}: must be 'true' or 'false'" + errInvalidHeaderValueBase = errutil.ValidationFailed("aleting.invalidHeaderValue").MustTemplate(errInvalidHeaderValueMsg, errutil.WithPublic(errInvalidHeaderValueMsg)) +) + +func errInvalidHeaderValue(header string) error { + return errInvalidHeaderValueBase.Build(errutil.TemplateData{Public: map[string]any{"Header": header}}) +} + type ConvertPrometheusSrv struct { - logger log.Logger + cfg *setting.UnifiedAlertingSettings + logger log.Logger + ruleStore RuleStore + datasourceCache datasources.CacheService + alertRuleService *provisioning.AlertRuleService +} + +func NewConvertPrometheusSrv(cfg *setting.UnifiedAlertingSettings, logger log.Logger, ruleStore RuleStore, datasourceCache datasources.CacheService, alertRuleService *provisioning.AlertRuleService) *ConvertPrometheusSrv { + return &ConvertPrometheusSrv{ + cfg: cfg, + logger: logger, + ruleStore: ruleStore, + datasourceCache: datasourceCache, + alertRuleService: alertRuleService, + } } func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRules(c *contextmodel.ReqContext) response.Response { @@ -28,9 +74,131 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetNamespace(c *contextmo } func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response { - return response.Error(501, "Not implemented", nil) + // Just to make the mimirtool rules load work. It first checks if the group exists, and if the endpoint returns 501 it fails. + return response.YAML(http.StatusOK, apimodels.PrometheusRuleGroup{}) } -func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, prometheusGroup apimodels.PrometheusRuleGroup) response.Response { - return response.Error(501, "Not implemented", nil) +func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, promGroup apimodels.PrometheusRuleGroup) response.Response { + logger := srv.logger.FromContext(c.Req.Context()) + logger = logger.New("folder_title", namespaceTitle, "group", promGroup.Name) + + logger.Info("Converting Prometheus rule group", "rules", len(promGroup.Rules)) + + ns, errResp := srv.getOrCreateNamespace(c, namespaceTitle, logger) + if errResp != nil { + return errResp + } + + datasourceUID := strings.TrimSpace(c.Req.Header.Get(datasourceUIDHeader)) + if datasourceUID == "" { + return response.Err(errDatasourceUIDHeaderMissing) + } + ds, err := srv.datasourceCache.GetDatasourceByUID(c.Req.Context(), datasourceUID, c.SignedInUser, c.SkipDSCache) + if err != nil { + logger.Error("Failed to get datasource", "datasource_uid", datasourceUID, "error", err) + return errorToResponse(err) + } + + group, err := srv.convertToGrafanaRuleGroup(c, ds, ns.UID, promGroup, logger) + if err != nil { + return errorToResponse(err) + } + + err = srv.alertRuleService.ReplaceRuleGroup(c.Req.Context(), c.SignedInUser, *group, models.ProvenanceConvertedPrometheus) + if err != nil { + logger.Error("Failed to replace rule group", "error", err) + return errorToResponse(err) + } + + return response.JSON(http.StatusAccepted, map[string]string{"status": "success"}) +} + +func (srv *ConvertPrometheusSrv) getOrCreateNamespace(c *contextmodel.ReqContext, title string, logger log.Logger) (*folder.Folder, response.Response) { + logger.Debug("Getting or creating a new folder") + + ns, err := srv.ruleStore.GetOrCreateNamespaceInRootByTitle( + c.Req.Context(), + title, + c.SignedInUser.GetOrgID(), + c.SignedInUser, + ) + if err != nil { + logger.Error("Failed to get or create a new folder", "error", err) + return nil, toNamespaceErrorResponse(err) + } + + logger.Debug("Using folder for the converted rules", "folder_uid", ns.UID) + + return ns, nil +} + +func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup(c *contextmodel.ReqContext, ds *datasources.DataSource, namespaceUID string, promGroup apimodels.PrometheusRuleGroup, logger log.Logger) (*models.AlertRuleGroup, error) { + logger.Info("Converting Prometheus rules to Grafana rules", "rules", len(promGroup.Rules), "folder_uid", namespaceUID, "datasource_uid", ds.UID, "datasource_type", ds.Type) + + rules := make([]prom.PrometheusRule, len(promGroup.Rules)) + for i, r := range promGroup.Rules { + rules[i] = prom.PrometheusRule{ + Alert: r.Alert, + Expr: r.Expr, + For: r.For, + KeepFiringFor: r.KeepFiringFor, + Labels: r.Labels, + Annotations: r.Annotations, + Record: r.Record, + } + } + group := prom.PrometheusRuleGroup{ + Name: promGroup.Name, + Interval: promGroup.Interval, + Rules: rules, + } + + pauseRecordingRules, err := parseBooleanHeader(c.Req.Header.Get(recordingRulesPausedHeader), recordingRulesPausedHeader) + if err != nil { + return nil, err + } + + pauseAlertRules, err := parseBooleanHeader(c.Req.Header.Get(alertRulesPausedHeader), alertRulesPausedHeader) + if err != nil { + return nil, err + } + + converter, err := prom.NewConverter( + prom.Config{ + DatasourceUID: ds.UID, + DatasourceType: ds.Type, + DefaultInterval: srv.cfg.DefaultRuleEvaluationInterval, + RecordingRules: prom.RulesConfig{ + IsPaused: pauseRecordingRules, + }, + AlertRules: prom.RulesConfig{ + IsPaused: pauseAlertRules, + }, + }, + ) + if err != nil { + logger.Error("Failed to create Prometheus converter", "datasource_uid", ds.UID, "datasource_type", ds.Type, "error", err) + return nil, err + } + + grafanaGroup, err := converter.PrometheusRulesToGrafana(c.SignedInUser.GetOrgID(), namespaceUID, group) + if err != nil { + logger.Error("Failed to convert Prometheus rules to Grafana rules", "error", err) + return nil, err + } + + return grafanaGroup, nil +} + +// parseBooleanHeader parses a boolean header value, returning an error if the header +// is present but invalid. If the header is not present, returns (false, nil). +func parseBooleanHeader(header string, headerName string) (bool, error) { + if header == "" { + return false, nil + } + val, err := strconv.ParseBool(header) + if err != nil { + return false, errInvalidHeaderValue(headerName) + } + return val, nil } diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go new file mode 100644 index 00000000000..8dea6f3f178 --- /dev/null +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -0,0 +1,212 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "testing" + "time" + + prommodel "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" + "github.com/grafana/grafana/pkg/services/datasources" + dsfakes "github.com/grafana/grafana/pkg/services/datasources/fakes" + "github.com/grafana/grafana/pkg/services/folder/foldertest" + acfakes "github.com/grafana/grafana/pkg/services/ngalert/accesscontrol/fakes" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/ngalert/provisioning" + "github.com/grafana/grafana/pkg/services/ngalert/tests/fakes" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" + "github.com/grafana/grafana/pkg/web" +) + +const ( + existingDSUID = "test-ds" +) + +func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) { + simpleGroup := apimodels.PrometheusRuleGroup{ + Name: "Test Group", + Interval: prommodel.Duration(1 * time.Minute), + Rules: []apimodels.PrometheusRule{ + { + Alert: "TestAlert", + Expr: "up == 0", + For: util.Pointer(prommodel.Duration(5 * time.Minute)), + Labels: map[string]string{ + "severity": "critical", + }, + }, + }, + } + + t.Run("without datasource UID header should return 400", func(t *testing.T) { + srv, _ := createConvertPrometheusSrv(t) + rc := createRequestCtx() + rc.Req.Header.Set(datasourceUIDHeader, "") + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", apimodels.PrometheusRuleGroup{}) + + require.Equal(t, http.StatusBadRequest, response.Status()) + require.Contains(t, string(response.Body()), "Missing datasource UID header") + }) + + t.Run("with invalid datasource should return error", func(t *testing.T) { + srv, _ := createConvertPrometheusSrv(t) + rc := createRequestCtx() + rc.Req.Header.Set(datasourceUIDHeader, "non-existing-ds") + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", apimodels.PrometheusRuleGroup{}) + + require.Equal(t, http.StatusNotFound, response.Status()) + }) + + t.Run("with rule group without evaluation interval should return 202", func(t *testing.T) { + srv, _ := createConvertPrometheusSrv(t) + rc := createRequestCtx() + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + require.Equal(t, http.StatusAccepted, response.Status()) + }) + + t.Run("with valid pause header values should return 202", func(t *testing.T) { + testCases := []struct { + name string + headerName string + headerValue string + }{ + { + name: "true recording rules pause value", + headerName: recordingRulesPausedHeader, + headerValue: "true", + }, + { + name: "false recording rules pause value", + headerName: recordingRulesPausedHeader, + headerValue: "false", + }, + { + name: "true alert rules pause value", + headerName: alertRulesPausedHeader, + headerValue: "true", + }, + { + name: "false alert rules pause value", + headerName: alertRulesPausedHeader, + headerValue: "false", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + srv, _ := createConvertPrometheusSrv(t) + rc := createRequestCtx() + rc.Req.Header.Set(tc.headerName, tc.headerValue) + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + require.Equal(t, http.StatusAccepted, response.Status()) + }) + } + }) + + t.Run("with invalid pause header values should return 400", func(t *testing.T) { + testCases := []struct { + name string + headerName string + headerValue string + expectedError string + }{ + { + name: "invalid recording rules pause value", + headerName: recordingRulesPausedHeader, + headerValue: "invalid", + expectedError: "Invalid value for header X-Grafana-Alerting-Recording-Rules-Paused: must be 'true' or 'false'", + }, + { + name: "invalid alert rules pause value", + headerName: alertRulesPausedHeader, + headerValue: "invalid", + expectedError: "Invalid value for header X-Grafana-Alerting-Alert-Rules-Paused: must be 'true' or 'false'", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + srv, _ := createConvertPrometheusSrv(t) + rc := createRequestCtx() + rc.Req.Header.Set(tc.headerName, tc.headerValue) + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + require.Equal(t, http.StatusBadRequest, response.Status()) + require.Contains(t, string(response.Body()), tc.expectedError) + }) + } + }) + + t.Run("with valid request should return 202", func(t *testing.T) { + srv, _ := createConvertPrometheusSrv(t) + rc := createRequestCtx() + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + require.Equal(t, http.StatusAccepted, response.Status()) + }) +} + +func createConvertPrometheusSrv(t *testing.T) (*ConvertPrometheusSrv, datasources.CacheService) { + t.Helper() + + ruleStore := fakes.NewRuleStore(t) + folder := randFolder() + ruleStore.Folders[1] = append(ruleStore.Folders[1], folder) + + dsCache := &dsfakes.FakeCacheService{} + ds := &datasources.DataSource{ + UID: existingDSUID, + Type: datasources.DS_PROMETHEUS, + } + dsCache.DataSources = append(dsCache.DataSources, ds) + + quotas := &provisioning.MockQuotaChecker{} + quotas.EXPECT().LimitOK() + + folderService := foldertest.NewFakeService() + + alertRuleService := provisioning.NewAlertRuleService( + ruleStore, + fakes.NewFakeProvisioningStore(), + folderService, + quotas, + &provisioning.NopTransactionManager{}, + 60, + 10, + 100, + log.New("test"), + &provisioning.NotificationSettingsValidatorProviderFake{}, + &acfakes.FakeRuleService{}, + ) + + cfg := &setting.UnifiedAlertingSettings{ + DefaultRuleEvaluationInterval: 1 * time.Minute, + } + + srv := NewConvertPrometheusSrv(cfg, log.NewNopLogger(), ruleStore, dsCache, alertRuleService) + + return srv, dsCache +} + +func createRequestCtx() *contextmodel.ReqContext { + req := httptest.NewRequest("GET", "http://localhost", nil) + req.Header.Set(datasourceUIDHeader, existingDSUID) + + return &contextmodel.ReqContext{ + Context: &web.Context{ + Req: req, + Resp: web.NewResponseWriter("GET", httptest.NewRecorder()), + }, + SignedInUser: &user.SignedInUser{OrgID: 1}, + } +} diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 155f3e0131e..a1cedae4b73 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -132,23 +132,26 @@ func (api *API) authorize(method, path string) web.Handler { ) case http.MethodGet + "/api/convert/prometheus/config/v1/rules": - eval = ac.EvalPermission(ac.ActionAlertingRuleRead) + eval = ac.EvalAll( + ac.EvalPermission(ac.ActionAlertingRuleRead), + ac.EvalPermission(dashboards.ActionFoldersRead), + ) case http.MethodPost + "/api/convert/prometheus/config/v1/rules/{NamespaceTitle}": eval = ac.EvalAll( - ac.EvalPermission(dashboards.ActionFoldersWrite), - ac.EvalPermission(ac.ActionAlertingRuleRead), - ac.EvalPermission(ac.ActionAlertingRuleUpdate), ac.EvalPermission(ac.ActionAlertingRuleCreate), - ac.EvalPermission(ac.ActionAlertingRuleDelete), + ac.EvalPermission(ac.ActionAlertingProvisioningSetStatus), ) case http.MethodDelete + "/api/convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group}", http.MethodDelete + "/api/convert/prometheus/config/v1/rules/{NamespaceTitle}": - eval = ac.EvalAll( - ac.EvalPermission(ac.ActionAlertingRuleDelete), - ac.EvalPermission(ac.ActionAlertingRuleRead), - ac.EvalPermission(dashboards.ActionFoldersRead), + eval = ac.EvalAny( + ac.EvalAll( + ac.EvalPermission(ac.ActionAlertingRuleRead), + ac.EvalPermission(dashboards.ActionFoldersRead), + ac.EvalPermission(ac.ActionAlertingRuleDelete), + ac.EvalPermission(ac.ActionAlertingProvisioningSetStatus), + ), ) // Alert Instances and Silences diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index 952db94f1f5..57eda01631d 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -15,6 +15,8 @@ type RuleStore interface { // by returning map[string]struct{} instead of map[string]*folder.Folder GetUserVisibleNamespaces(context.Context, int64, identity.Requester) (map[string]*folder.Folder, error) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) + GetNamespaceInRootByTitle(ctx context.Context, fullpath string, orgID int64, user identity.Requester) (*folder.Folder, error) + GetOrCreateNamespaceInRootByTitle(ctx context.Context, title string, orgID int64, user identity.Requester) (*folder.Folder, error) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAlertRuleByUIDQuery) (*ngmodels.AlertRule, error) GetAlertRulesGroupByRuleUID(ctx context.Context, query *ngmodels.GetAlertRulesGroupByRuleUIDQuery) ([]*ngmodels.AlertRule, error) diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 7ed5ae04ba0..0a2fb0af0d4 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -4493,6 +4493,7 @@ "type": "object" }, "URL": { + "description": "The general form represented is:\n\n[scheme:][//[userinfo@]host][/]path[?query][#fragment]\n\nURLs that do not start with a slash after the scheme are interpreted as:\n\nscheme:opaque[?query][#fragment]\n\nThe Host field contains the host and port subcomponents of the URL.\nWhen the port is present, it is separated from the host with a colon.\nWhen the host is an IPv6 address, it must be enclosed in square brackets:\n\"[fe80::1]:80\". The [net.JoinHostPort] function combines a host and port\ninto a string suitable for the Host field, adding square brackets to\nthe host when necessary.\n\nNote that the Path field is stored in decoded form: /%47%6f%2f becomes /Go/.\nA consequence is that it is impossible to tell which slashes in the Path were\nslashes in the raw URL and which were %2f. This distinction is rarely important,\nbut when it is, the code should use the [URL.EscapedPath] method, which preserves\nthe original encoding of Path.\n\nThe RawPath field is an optional field which is only set when the default\nencoding of Path is different from the escaped path. See the EscapedPath method\nfor more details.\n\nURL's String method uses the EscapedPath method to obtain the path.", "properties": { "ForceQuery": { "type": "boolean" @@ -4528,7 +4529,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "URL is a custom URL type that allows validation at configuration load time.", + "title": "A URL represents a parsed URL (technically, a URI reference).", "type": "object" }, "UpdateRuleGroupResponse": { @@ -4931,7 +4932,6 @@ "type": "object" }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert", "type": "object" @@ -5056,7 +5056,6 @@ "type": "object" }, "gettableSilences": { - "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence", "type": "object" diff --git a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go index b2bae42eacb..5b6454d1d84 100644 --- a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go +++ b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go @@ -84,11 +84,11 @@ type RouteConvertPrometheusPostRuleGroupParams struct { // in: path NamespaceTitle string // in: header - DatasourceUID string `json:"x-datasource-uid"` + DatasourceUID string `json:"x-grafana-alerting-datasource-uid"` // in: header - RecordingRulesPaused bool `json:"x-recording-rules-paused"` + RecordingRulesPaused bool `json:"x-grafana-alerting-recording-rules-paused"` // in: header - AlertRulesPaused bool `json:"x-alert-rules-paused"` + AlertRulesPaused bool `json:"x-grafana-alerting-alert-rules-paused"` // in:body Body PrometheusRuleGroup } diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 26e062391cf..825af6c52af 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -4932,6 +4932,7 @@ "type": "object" }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/definitions/gettableAlert", "type": "object" @@ -6515,17 +6516,17 @@ }, { "in": "header", - "name": "x-datasource-uid", + "name": "x-grafana-alerting-datasource-uid", "type": "string" }, { "in": "header", - "name": "x-recording-rules-paused", + "name": "x-grafana-alerting-recording-rules-paused", "type": "boolean" }, { "in": "header", - "name": "x-alert-rules-paused", + "name": "x-grafana-alerting-alert-rules-paused", "type": "boolean" }, { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index f42d1f7c408..e8f3f798fda 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1194,17 +1194,17 @@ }, { "type": "string", - "name": "x-datasource-uid", + "name": "x-grafana-alerting-datasource-uid", "in": "header" }, { "type": "boolean", - "name": "x-recording-rules-paused", + "name": "x-grafana-alerting-recording-rules-paused", "in": "header" }, { "type": "boolean", - "name": "x-alert-rules-paused", + "name": "x-grafana-alerting-alert-rules-paused", "in": "header" }, { @@ -8872,6 +8872,7 @@ } }, "gettableAlerts": { + "description": "GettableAlerts gettable alerts", "type": "array", "items": { "type": "object", diff --git a/pkg/services/ngalert/models/provisioning.go b/pkg/services/ngalert/models/provisioning.go index 7933830588b..d792e821939 100644 --- a/pkg/services/ngalert/models/provisioning.go +++ b/pkg/services/ngalert/models/provisioning.go @@ -8,10 +8,12 @@ const ( ProvenanceNone Provenance = "" ProvenanceAPI Provenance = "api" ProvenanceFile Provenance = "file" + // ProvenanceConvertedPrometheus is used for objects converted from Prometheus definitions. + ProvenanceConvertedPrometheus Provenance = "converted_prometheus" ) var ( - KnownProvenances = []Provenance{ProvenanceNone, ProvenanceAPI, ProvenanceFile} + KnownProvenances = []Provenance{ProvenanceNone, ProvenanceAPI, ProvenanceFile, ProvenanceConvertedPrometheus} ) // Provisionable represents a resource that can be created through a provisioning mechanism, such as Terraform or config file. diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index bb9d4c3d616..e2f46b74341 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -27,8 +27,11 @@ const ( // Config defines the configuration options for the Prometheus to Grafana rules converter. type Config struct { - DatasourceUID string - DatasourceType string + DatasourceUID string + DatasourceType string + // DefaultInterval is the default interval for rules in the groups that + // don't have Interval set. + DefaultInterval time.Duration FromTimeRange *time.Duration EvaluationOffset *time.Duration ExecErrState models.ExecutionErrorState @@ -68,6 +71,9 @@ func NewConverter(cfg Config) (*Converter, error) { if cfg.DatasourceType == "" { return nil, fmt.Errorf("datasource type is required") } + if cfg.DefaultInterval == 0 { + return nil, fmt.Errorf("default evaluation interval is required") + } if cfg.FromTimeRange == nil { cfg.FromTimeRange = defaultConfig.FromTimeRange } @@ -93,9 +99,8 @@ func NewConverter(cfg Config) (*Converter, error) { // PrometheusRulesToGrafana converts a Prometheus rule group into Grafana Alerting rule group. func (p *Converter) PrometheusRulesToGrafana(orgID int64, namespaceUID string, group PrometheusRuleGroup) (*models.AlertRuleGroup, error) { for _, rule := range group.Rules { - err := validatePrometheusRule(rule) - if err != nil { - return nil, fmt.Errorf("invalid Prometheus rule '%s': %w", rule.Alert, err) + if err := rule.Validate(); err != nil { + return nil, err } } @@ -107,18 +112,15 @@ func (p *Converter) PrometheusRulesToGrafana(orgID int64, namespaceUID string, g return grafanaGroup, nil } -func validatePrometheusRule(rule PrometheusRule) error { - if rule.KeepFiringFor != nil { - return fmt.Errorf("keep_firing_for is not supported") - } - - return nil -} - func (p *Converter) convertRuleGroup(orgID int64, namespaceUID string, promGroup PrometheusRuleGroup) (*models.AlertRuleGroup, error) { uniqueNames := map[string]int{} rules := make([]models.AlertRule, 0, len(promGroup.Rules)) + interval := time.Duration(promGroup.Interval) + if interval == 0 { + interval = p.cfg.DefaultInterval + } + for i, rule := range promGroup.Rules { gr, err := p.convertRule(orgID, namespaceUID, promGroup.Name, rule) if err != nil { diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index 3bad4795fcb..332cb356804 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -19,7 +19,7 @@ import ( ) func TestPrometheusRulesToGrafana(t *testing.T) { - fiveMin := prommodel.Duration(5 * time.Minute) + defaultInterval := 2 * time.Minute testCases := []struct { name string @@ -40,7 +40,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) { { Alert: "alert-1", Expr: "cpu_usage > 80", - For: &fiveMin, + For: util.Pointer(prommodel.Duration(5 * time.Minute)), Labels: map[string]string{ "severity": "critical", }, @@ -63,14 +63,14 @@ func TestPrometheusRulesToGrafana(t *testing.T) { { Alert: "alert-1", Expr: "up == 0", - KeepFiringFor: &fiveMin, + KeepFiringFor: util.Pointer(prommodel.Duration(5 * time.Minute)), }, }, }, expectError: true, }, { - name: "rule with empty interval", + name: "rule group with empty interval", orgID: 1, namespace: "namespaceUID", promGroup: PrometheusRuleGroup{ @@ -89,7 +89,8 @@ func TestPrometheusRulesToGrafana(t *testing.T) { orgID: 1, namespace: "namespaceUID", promGroup: PrometheusRuleGroup{ - Name: "test-group-1", + Name: "test-group-1", + Interval: prommodel.Duration(10 * time.Second), Rules: []PrometheusRule{ { Record: "some_metric", @@ -105,6 +106,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) { t.Run(tc.name, func(t *testing.T) { tc.config.DatasourceUID = "datasource-uid" tc.config.DatasourceType = datasources.DS_PROMETHEUS + tc.config.DefaultInterval = defaultInterval converter, err := NewConverter(tc.config) require.NoError(t, err) @@ -117,7 +119,11 @@ func TestPrometheusRulesToGrafana(t *testing.T) { require.NoError(t, err, tc.name) require.Equal(t, tc.promGroup.Name, grafanaGroup.Title, tc.name) + expectedInterval := int64(time.Duration(tc.promGroup.Interval).Seconds()) + if expectedInterval == 0 { + expectedInterval = int64(defaultInterval.Seconds()) + } require.Equal(t, expectedInterval, grafanaGroup.Interval, tc.name) require.Equal(t, len(tc.promGroup.Rules), len(grafanaGroup.Rules), tc.name) @@ -164,8 +170,9 @@ func TestPrometheusRulesToGrafana(t *testing.T) { func TestPrometheusRulesToGrafanaWithDuplicateRuleNames(t *testing.T) { cfg := Config{ - DatasourceUID: "datasource-uid", - DatasourceType: datasources.DS_PROMETHEUS, + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + DefaultInterval: 2 * time.Minute, } converter, err := NewConverter(cfg) require.NoError(t, err) @@ -257,8 +264,9 @@ func TestCreateThresholdNode(t *testing.T) { func TestPrometheusRulesToGrafana_NodesInRules(t *testing.T) { cfg := Config{ - DatasourceUID: "datasource-uid", - DatasourceType: datasources.DS_PROMETHEUS, + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + DefaultInterval: 2 * time.Minute, } converter, err := NewConverter(cfg) require.NoError(t, err) @@ -344,8 +352,9 @@ func TestPrometheusRulesToGrafana_UID(t *testing.T) { } converter, err := NewConverter(Config{ - DatasourceUID: "datasource-uid", - DatasourceType: datasources.DS_PROMETHEUS, + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + DefaultInterval: 2 * time.Minute, }) require.NoError(t, err) @@ -372,8 +381,9 @@ func TestPrometheusRulesToGrafana_UID(t *testing.T) { namespace := "some-namespace" converter, err := NewConverter(Config{ - DatasourceUID: "datasource-uid", - DatasourceType: datasources.DS_PROMETHEUS, + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + DefaultInterval: 2 * time.Minute, }) require.NoError(t, err) @@ -390,8 +400,9 @@ func TestPrometheusRulesToGrafana_UID(t *testing.T) { namespace := "some-namespace" converter, err := NewConverter(Config{ - DatasourceUID: "datasource-uid", - DatasourceType: datasources.DS_PROMETHEUS, + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + DefaultInterval: 2 * time.Minute, }) require.NoError(t, err) @@ -408,8 +419,9 @@ func TestPrometheusRulesToGrafana_UID(t *testing.T) { namespace := "some-namespace" converter, err := NewConverter(Config{ - DatasourceUID: "datasource-uid", - DatasourceType: datasources.DS_PROMETHEUS, + DatasourceUID: "datasource-uid", + DatasourceType: datasources.DS_PROMETHEUS, + DefaultInterval: 2 * time.Minute, }) require.NoError(t, err) diff --git a/pkg/services/ngalert/prom/models.go b/pkg/services/ngalert/prom/models.go index f7e8bbfc95b..cbf57b2015c 100644 --- a/pkg/services/ngalert/prom/models.go +++ b/pkg/services/ngalert/prom/models.go @@ -2,6 +2,12 @@ package prom import ( prommodel "github.com/prometheus/common/model" + + "github.com/grafana/grafana/pkg/apimachinery/errutil" +) + +var ( + ErrPrometheusRuleValidationFailed = errutil.ValidationFailed("alerting.prometheusRuleInvalid") ) type PrometheusRulesFile struct { @@ -23,3 +29,11 @@ type PrometheusRule struct { Annotations map[string]string `yaml:"annotations,omitempty"` Record string `yaml:"record,omitempty"` } + +func (r *PrometheusRule) Validate() error { + if r.KeepFiringFor != nil { + return ErrPrometheusRuleValidationFailed.Errorf("keep_firing_for is not supported") + } + + return nil +} diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index d688d6bb53d..0d11d9eaaaa 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -648,36 +648,6 @@ func (st DBstore) GetRuleGroupInterval(ctx context.Context, orgID int64, namespa }) } -// GetUserVisibleNamespaces returns the folders that are visible to the user -func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, user identity.Requester) (map[string]*folder.Folder, error) { - folders, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{ - OrgID: orgID, - WithFullpath: true, - SignedInUser: user, - }) - if err != nil { - return nil, err - } - - namespaceMap := make(map[string]*folder.Folder) - for _, f := range folders { - namespaceMap[f.UID] = f - } - return namespaceMap, nil -} - -// GetNamespaceByUID is a handler for retrieving a namespace by its UID. Alerting rules follow a Grafana folder-like structure which we call namespaces. -func (st DBstore) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) { - f, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{OrgID: orgID, UIDs: []string{uid}, WithFullpath: true, SignedInUser: user}) - if err != nil { - return nil, err - } - if len(f) == 0 { - return nil, dashboards.ErrFolderAccessDenied - } - return f[0], nil -} - func (st DBstore) GetAlertRulesKeysForScheduling(ctx context.Context) ([]ngmodels.AlertRuleKeyWithVersion, error) { var result []ngmodels.AlertRuleKeyWithVersion err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 79b8f704681..6f6f9453e18 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -782,58 +782,6 @@ func TestIntegration_DeleteAlertRulesByUID(t *testing.T) { }) } -func TestIntegration_GetNamespaceByUID(t *testing.T) { - if testing.Short() { - t.Skip("skipping integration test") - } - - sqlStore := db.InitTestDB(t) - cfg := setting.NewCfg() - folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) - b := &fakeBus{} - logger := log.New("test-dbstore") - store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) - - u := &user.SignedInUser{ - UserID: 1, - OrgID: 1, - OrgRole: org.RoleAdmin, - IsGrafanaAdmin: true, - } - - uid := uuid.NewString() - parentUid := uuid.NewString() - title := "folder/title" - parentTitle := "parent-title" - createFolder(t, store, parentUid, parentTitle, 1, "") - createFolder(t, store, uid, title, 1, parentUid) - - actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u) - require.NoError(t, err) - require.Equal(t, title, actual.Title) - require.Equal(t, uid, actual.UID) - require.Equal(t, title, actual.Fullpath) - - t.Run("error when user does not have permissions", func(t *testing.T) { - someUser := &user.SignedInUser{ - UserID: 2, - OrgID: 1, - OrgRole: org.RoleViewer, - } - _, err = store.GetNamespaceByUID(context.Background(), uid, 1, someUser) - require.ErrorIs(t, err, dashboards.ErrFolderAccessDenied) - }) - - t.Run("when nested folders are enabled full path should be populated with correct value", func(t *testing.T) { - store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) - actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u) - require.NoError(t, err) - require.Equal(t, title, actual.Title) - require.Equal(t, uid, actual.UID) - require.Equal(t, "parent-title/folder\\/title", actual.Fullpath) - }) -} - func TestIntegrationInsertAlertRules(t *testing.T) { if testing.Short() { t.Skip("skipping integration test") diff --git a/pkg/services/ngalert/store/namespace.go b/pkg/services/ngalert/store/namespace.go new file mode 100644 index 00000000000..9279ff840f9 --- /dev/null +++ b/pkg/services/ngalert/store/namespace.go @@ -0,0 +1,97 @@ +package store + +import ( + "context" + "errors" + "sort" + + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/folder" +) + +// GetUserVisibleNamespaces returns the folders that are visible to the user +func (st DBstore) GetUserVisibleNamespaces(ctx context.Context, orgID int64, user identity.Requester) (map[string]*folder.Folder, error) { + folders, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{ + OrgID: orgID, + WithFullpath: true, + SignedInUser: user, + }) + if err != nil { + return nil, err + } + + namespaceMap := make(map[string]*folder.Folder) + for _, f := range folders { + namespaceMap[f.UID] = f + } + return namespaceMap, nil +} + +// GetNamespaceByUID is a handler for retrieving a namespace by its UID. Alerting rules follow a Grafana folder-like structure which we call namespaces. +func (st DBstore) GetNamespaceByUID(ctx context.Context, uid string, orgID int64, user identity.Requester) (*folder.Folder, error) { + f, err := st.FolderService.GetFolders(ctx, folder.GetFoldersQuery{OrgID: orgID, UIDs: []string{uid}, WithFullpath: true, SignedInUser: user}) + if err != nil { + return nil, err + } + if len(f) == 0 { + return nil, dashboards.ErrFolderAccessDenied + } + return f[0], nil +} + +// GetNamespaceInRootByTitle gets namespace by its title in the root folder. +func (st DBstore) GetNamespaceInRootByTitle(ctx context.Context, title string, orgID int64, user identity.Requester) (*folder.Folder, error) { + q := &folder.GetChildrenQuery{ + UID: folder.RootFolderUID, + OrgID: orgID, + SignedInUser: user, + } + folders, err := st.FolderService.GetChildren(ctx, q) + if err != nil { + return nil, err + } + + foundByTitle := []*folder.Folder{} + for _, f := range folders { + if f.Title == title && f.ParentUID == folder.RootFolderUID { + foundByTitle = append(foundByTitle, f) + } + } + + if len(foundByTitle) == 0 { + return nil, dashboards.ErrFolderAccessDenied + } + + // Sort by UID to return the first folder in case of multiple folders with the same title + sort.Slice(foundByTitle, func(i, j int) bool { + return foundByTitle[i].UID < foundByTitle[j].UID + }) + + return foundByTitle[0], nil +} + +// GetOrCreateNamespaceInRootByTitle gets or creates a namespace by title in the _root_ folder. +func (st DBstore) GetOrCreateNamespaceInRootByTitle(ctx context.Context, title string, orgID int64, user identity.Requester) (*folder.Folder, error) { + var f *folder.Folder + var err error + + f, err = st.GetNamespaceInRootByTitle(ctx, title, orgID, user) + if err != nil && !errors.Is(err, dashboards.ErrFolderAccessDenied) { + return nil, err + } + + if f == nil { + cmd := &folder.CreateFolderCommand{ + OrgID: orgID, + Title: title, + SignedInUser: user, + } + f, err = st.FolderService.Create(ctx, cmd) + if err != nil { + return nil, err + } + } + + return f, nil +} diff --git a/pkg/services/ngalert/store/namespace_test.go b/pkg/services/ngalert/store/namespace_test.go new file mode 100644 index 00000000000..8ba163a1080 --- /dev/null +++ b/pkg/services/ngalert/store/namespace_test.go @@ -0,0 +1,220 @@ +package store + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" +) + +func TestIntegration_GetUserVisibleNamespaces(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + b := &fakeBus{} + logger := log.New("test-dbstore") + store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) + + admin := &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: org.RoleAdmin, + IsGrafanaAdmin: true, + } + + folders := []struct { + uid string + title string + parentUid string + }{ + {uid: uuid.NewString(), title: "folder1", parentUid: ""}, + {uid: uuid.NewString(), title: "folder2", parentUid: ""}, + {uid: uuid.NewString(), title: "nested/folder", parentUid: ""}, + } + + for _, f := range folders { + createFolder(t, store, f.uid, f.title, 1, f.parentUid) + } + + t.Run("returns all folders", func(t *testing.T) { + namespaces, err := store.GetUserVisibleNamespaces(context.Background(), 1, admin) + require.NoError(t, err) + require.Len(t, namespaces, len(folders)) + }) + + t.Run("returns empty list for a non existing org", func(t *testing.T) { + emptyOrgID := int64(999) + namespaces, err := store.GetUserVisibleNamespaces(context.Background(), emptyOrgID, admin) + require.NoError(t, err) + require.Empty(t, namespaces) + }) +} + +func TestIntegration_GetNamespaceByUID(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + b := &fakeBus{} + logger := log.New("test-dbstore") + store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) + + u := &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: org.RoleAdmin, + IsGrafanaAdmin: true, + } + + uid := uuid.NewString() + parentUid := uuid.NewString() + title := "folder/title" + parentTitle := "parent-title" + createFolder(t, store, parentUid, parentTitle, 1, "") + createFolder(t, store, uid, title, 1, parentUid) + + actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u) + require.NoError(t, err) + require.Equal(t, title, actual.Title) + require.Equal(t, uid, actual.UID) + require.Equal(t, title, actual.Fullpath) + + t.Run("error when user does not have permissions", func(t *testing.T) { + someUser := &user.SignedInUser{ + UserID: 2, + OrgID: 1, + OrgRole: org.RoleViewer, + } + _, err = store.GetNamespaceByUID(context.Background(), uid, 1, someUser) + require.ErrorIs(t, err, dashboards.ErrFolderAccessDenied) + }) + + t.Run("error when folder does not exist", func(t *testing.T) { + nonExistentUID := uuid.NewString() + _, err := store.GetNamespaceByUID(context.Background(), nonExistentUID, 1, u) + require.ErrorIs(t, err, dashboards.ErrFolderAccessDenied) + }) + + t.Run("when nested folders are enabled full path should be populated with correct value", func(t *testing.T) { + store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + actual, err := store.GetNamespaceByUID(context.Background(), uid, 1, u) + require.NoError(t, err) + require.Equal(t, title, actual.Title) + require.Equal(t, uid, actual.UID) + require.Equal(t, "parent-title/folder\\/title", actual.Fullpath) + }) +} + +func TestIntegration_GetNamespaceInRootByTitle(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + b := &fakeBus{} + logger := log.New("test-dbstore") + store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) + store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + + u := &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: org.RoleAdmin, + IsGrafanaAdmin: true, + } + + uid := uuid.NewString() + title := "folder-title" + createFolder(t, store, uid, title, 1, "") + + actual, err := store.GetNamespaceInRootByTitle(context.Background(), title, 1, u) + require.NoError(t, err) + require.Equal(t, title, actual.Title) + require.Equal(t, uid, actual.UID) +} + +func TestIntegration_GetOrCreateNamespaceInRootByTitle(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test") + } + + u := &user.SignedInUser{ + UserID: 1, + OrgID: 1, + OrgRole: org.RoleAdmin, + IsGrafanaAdmin: true, + } + + setupStore := func(t *testing.T) *DBstore { + sqlStore := db.InitTestDB(t) + cfg := setting.NewCfg() + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + b := &fakeBus{} + logger := log.New("test-dbstore") + store := createTestStore(sqlStore, folderService, logger, cfg.UnifiedAlerting, b) + store.FolderService = setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures(featuremgmt.FlagNestedFolders)) + + return store + } + + t.Run("should create folder when it does not exist", func(t *testing.T) { + store := setupStore(t) + + f, err := store.GetOrCreateNamespaceInRootByTitle(context.Background(), "new folder", 1, u) + require.NoError(t, err) + require.Equal(t, "new folder", f.Title) + require.NotEmpty(t, f.UID) + + folders, err := store.FolderService.GetFolders( + context.Background(), + folder.GetFoldersQuery{ + OrgID: 1, + WithFullpath: true, + SignedInUser: u, + }, + ) + require.NoError(t, err) + require.Len(t, folders, 1) + }) + + t.Run("should return existing folder when it exists", func(t *testing.T) { + store := setupStore(t) + + title := "existing folder" + createFolder(t, store, "", title, 1, "") + f, err := store.GetOrCreateNamespaceInRootByTitle(context.Background(), title, 1, u) + require.NoError(t, err) + require.Equal(t, title, f.Title) + + folders, err := store.FolderService.GetFolders( + context.Background(), + folder.GetFoldersQuery{ + OrgID: 1, + WithFullpath: true, + SignedInUser: u, + }, + ) + require.NoError(t, err) + require.Len(t, folders, 1) + }) +} diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 5cdabe62857..15ebf90f40c 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -258,6 +258,40 @@ func (f *RuleStore) GetNamespaceByUID(_ context.Context, uid string, orgID int64 return nil, fmt.Errorf("not found") } +func (f *RuleStore) GetOrCreateNamespaceInRootByTitle(ctx context.Context, title string, orgID int64, user identity.Requester) (*folder.Folder, error) { + f.mtx.Lock() + defer f.mtx.Unlock() + + for _, folder := range f.Folders[orgID] { + if folder.Title == title { + return folder, nil + } + } + + newFolder := &folder.Folder{ + ID: rand.Int63(), // nolint:staticcheck + UID: util.GenerateShortUID(), + Title: title, + Fullpath: "fullpath_" + title, + } + + f.Folders[orgID] = append(f.Folders[orgID], newFolder) + return newFolder, nil +} + +func (f *RuleStore) GetNamespaceInRootByTitle(ctx context.Context, title string, orgID int64, user identity.Requester) (*folder.Folder, error) { + f.mtx.Lock() + defer f.mtx.Unlock() + + for _, folder := range f.Folders[orgID] { + if folder.Title == title && folder.ParentUID == "" { + return folder, nil + } + } + + return nil, fmt.Errorf("namespace with title '%s' not found", title) +} + func (f *RuleStore) UpdateAlertRules(_ context.Context, _ *models.UserUID, q []models.UpdateRule) error { f.mtx.Lock() defer f.mtx.Unlock() diff --git a/pkg/tests/api/alerting/api_convert_prometheus_test.go b/pkg/tests/api/alerting/api_convert_prometheus_test.go new file mode 100644 index 00000000000..326063a15ce --- /dev/null +++ b/pkg/tests/api/alerting/api_convert_prometheus_test.go @@ -0,0 +1,196 @@ +package alerting + +import ( + "testing" + "time" + + prommodel "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/datasources" + apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" + "github.com/grafana/grafana/pkg/services/org" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/util" +) + +func TestIntegrationConvertPrometheusEndpoints(t *testing.T) { + testinfra.SQLiteIntegrationTest(t) + + // Setup Grafana and its Database + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + EnableFeatureToggles: []string{"alertingConversionAPI"}, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + // Create a user to make authenticated requests + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleAdmin), + Password: "password", + Login: "admin", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "admin", "password") + namespace := "test-namespace" + + promGroup1 := apimodels.PrometheusRuleGroup{ + Name: "test-group-1", + Interval: prommodel.Duration(60 * time.Second), + Rules: []apimodels.PrometheusRule{ + // Recording rule + { + Record: "test:requests:rate5m", + Expr: "sum(rate(test_requests_total[5m])) by (job)", + Labels: map[string]string{ + "env": "prod", + "team": "infra", + }, + }, + // Two alerting rules + { + Alert: "HighMemoryUsage", + Expr: "process_memory_usage > 80", + For: util.Pointer(prommodel.Duration(5 * time.Minute)), + Labels: map[string]string{ + "severity": "warning", + "team": "alerting", + }, + Annotations: map[string]string{ + "annotation-1": "value-1", + "annotation-2": "value-2", + }, + }, + { + Alert: "ServiceDown", + Expr: "up == 0", + For: util.Pointer(prommodel.Duration(2 * time.Minute)), + Labels: map[string]string{ + "severity": "critical", + }, + Annotations: map[string]string{ + "annotation-1": "value-1", + }, + }, + }, + } + + promGroup2 := apimodels.PrometheusRuleGroup{ + Name: "test-group-2", + Interval: prommodel.Duration(60 * time.Second), + Rules: []apimodels.PrometheusRule{ + { + Alert: "HighDiskUsage", + Expr: "disk_usage > 80", + For: util.Pointer(prommodel.Duration(1 * time.Minute)), + Labels: map[string]string{ + "severity": "low", + "team": "alerting", + }, + Annotations: map[string]string{ + "annotation-5": "value-5", + }, + }, + }, + } + + ds := apiClient.CreateDatasource(t, datasources.DS_PROMETHEUS) + + t.Run("create two rule groups and get them back", func(t *testing.T) { + apiClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup1, nil) + apiClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup2, nil) + + ns, _, _ := apiClient.GetAllRulesWithStatus(t) + + require.Len(t, ns[namespace], 2) + + rulesByGroupName := map[string][]apimodels.GettableExtendedRuleNode{} + for _, group := range ns[namespace] { + rulesByGroupName[group.Name] = append(rulesByGroupName[group.Name], group.Rules...) + } + + require.Len(t, rulesByGroupName[promGroup1.Name], 3) + require.Len(t, rulesByGroupName[promGroup2.Name], 1) + }) + + t.Run("when pausing header is set, rules should be paused", func(t *testing.T) { + tests := []struct { + name string + recordingPaused bool + alertPaused bool + }{ + { + name: "do not pause rules", + recordingPaused: false, + alertPaused: false, + }, + { + name: "pause recording rules", + recordingPaused: true, + alertPaused: false, + }, + { + name: "pause alert rules", + recordingPaused: false, + alertPaused: true, + }, + { + name: "pause both recording and alert rules", + recordingPaused: true, + alertPaused: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + headers := map[string]string{} + if tc.recordingPaused { + headers["X-Grafana-Alerting-Recording-Rules-Paused"] = "true" + } + if tc.alertPaused { + headers["X-Grafana-Alerting-Alert-Rules-Paused"] = "true" + } + apiClient.ConvertPrometheusPostRuleGroup(t, namespace, ds.Body.Datasource.UID, promGroup1, headers) + + ns, _, _ := apiClient.GetAllRulesWithStatus(t) + + rulesByGroupName := map[string][]apimodels.GettableExtendedRuleNode{} + for _, group := range ns[namespace] { + rulesByGroupName[group.Name] = append(rulesByGroupName[group.Name], group.Rules...) + } + + require.Len(t, rulesByGroupName[promGroup1.Name], 3) + + pausedRecordingRules := 0 + pausedAlertRules := 0 + + for _, rule := range rulesByGroupName[promGroup1.Name] { + if rule.GrafanaManagedAlert.IsPaused { + if rule.GrafanaManagedAlert.Record != nil { + pausedRecordingRules++ + } else { + pausedAlertRules++ + } + } + } + + if tc.recordingPaused { + require.Equal(t, 1, pausedRecordingRules) + } else { + require.Equal(t, 0, pausedRecordingRules) + } + + if tc.alertPaused { + require.Equal(t, 2, pausedAlertRules) + } else { + require.Equal(t, 0, pausedAlertRules) + } + }) + } + }) +} diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index 5272883af9e..de927dc890c 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -18,6 +18,7 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/expr" @@ -752,7 +753,13 @@ func (a apiClient) SubmitRuleForTesting(t *testing.T, config apimodels.PostableE func (a apiClient) CreateTestDatasource(t *testing.T) (result api.CreateOrUpdateDatasourceResponse) { t.Helper() - payload := fmt.Sprintf(`{"name":"TestData-%s","type":"testdata","access":"proxy","isDefault":false}`, uuid.NewString()) + return a.CreateDatasource(t, "testdata") +} + +func (a apiClient) CreateDatasource(t *testing.T, dsType string) (result api.CreateOrUpdateDatasourceResponse) { + t.Helper() + + payload := fmt.Sprintf(`{"name":"TestDatasource-%s","type":"%s","access":"proxy","isDefault":false}`, uuid.NewString(), dsType) buf := bytes.Buffer{} buf.Write([]byte(payload)) @@ -1094,6 +1101,25 @@ func (a apiClient) GetRuleByUID(t *testing.T, ruleUID string) apimodels.Gettable return rule } +func (a apiClient) ConvertPrometheusPostRuleGroup(t *testing.T, namespaceTitle, datasourceUID string, promGroup apimodels.PrometheusRuleGroup, headers map[string]string) { + t.Helper() + + data, err := yaml.Marshal(promGroup) + require.NoError(t, err) + buf := bytes.NewReader(data) + + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/convert/prometheus/config/v1/rules/%s", a.url, namespaceTitle), buf) + require.NoError(t, err) + req.Header.Add("X-Grafana-Alerting-Datasource-UID", datasourceUID) + + for key, value := range headers { + req.Header.Add(key, value) + } + + _, status, raw := sendRequest[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted) + requireStatusCode(t, http.StatusAccepted, status, raw) +} + func sendRequest[T any](t *testing.T, req *http.Request, successStatusCode int) (T, int, string) { t.Helper() client := &http.Client{} diff --git a/public/api-merged.json b/public/api-merged.json index 09bd1962ae9..dc455b6a36f 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -22771,7 +22771,6 @@ } }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "type": "array", "items": { "type": "object", @@ -22896,7 +22895,6 @@ } }, "gettableSilences": { - "description": "GettableSilences gettable silences", "type": "array", "items": { "type": "object", diff --git a/public/openapi3.json b/public/openapi3.json index e5be6cd60ce..fabc90f92cf 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -12838,7 +12838,6 @@ "type": "object" }, "gettableAlerts": { - "description": "GettableAlerts gettable alerts", "items": { "$ref": "#/components/schemas/gettableAlert" }, @@ -12962,7 +12961,6 @@ "type": "object" }, "gettableSilences": { - "description": "GettableSilences gettable silences", "items": { "$ref": "#/components/schemas/gettableSilence" },