From 68d8810ecbc36af6e54cff93036afa70ac8ba6bf Mon Sep 17 00:00:00 2001 From: Fayzal Ghantiwala <114010985+fayzal-g@users.noreply.github.com> Date: Thu, 27 Mar 2025 15:30:11 +0000 Subject: [PATCH] Alerting: API to convert submitted Prometheus rules to GMA (#102231) * placeholder commit * Complete function in api_convert_prometheus.go * MVP before extensive testing * Cleanup * Updated tests * cleanup * Fix random logs and lint * Remove comment * Fix errors after rebase * Update test * Update swagger * swagger * Refactor to accept groups in body * Fix auth tests and some cleanup * Some cleanup before refactoring * Remove unnecessary fields * Also refactor RouteConvertPrometheusPostRuleGroup * Remove unused code * Rebase + cleanup * Update authorization_test * address comments * Regen swagger files * Remove namespace and group filters * Final comments --- .../ngalert/api/api_convert_prometheus.go | 83 +++++--- .../api/api_convert_prometheus_test.go | 194 ++++++++++++++++++ pkg/services/ngalert/api/authorization.go | 4 +- .../ngalert/api/authorization_test.go | 2 +- .../generated_base_api_convert_prometheus.go | 32 +++ .../ngalert/api/prometheus_conversion.go | 42 ++++ pkg/services/ngalert/api/tooling/api.json | 3 +- .../definitions/convert_prometheus_api.go | 30 +++ pkg/services/ngalert/api/tooling/post.json | 62 +++++- pkg/services/ngalert/api/tooling/spec.json | 62 +++++- pkg/services/ngalert/prom/convert.go | 1 - .../ngalert/provisioning/alert_rules.go | 14 ++ 12 files changed, 488 insertions(+), 41 deletions(-) diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index 5cbe398b459..da8655fd07f 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -320,30 +320,24 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRuleGroup(c *contextmo // If the group already exists and was not imported from a Prometheus-compatible source initially, // it will not be replaced and an error will be returned. func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, promGroup apimodels.PrometheusRuleGroup) response.Response { + return srv.RouteConvertPrometheusPostRuleGroups(c, map[string][]apimodels.PrometheusRuleGroup{namespaceTitle: {promGroup}}) +} + +func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *contextmodel.ReqContext, promNamespaces map[string][]apimodels.PrometheusRuleGroup) response.Response { logger := srv.logger.FromContext(c.Req.Context()) + // 1. Parse the appropriate headers workingFolderUID := getWorkingFolderUID(c) - logger = logger.New("folder_title", namespaceTitle, "group", promGroup.Name, "working_folder_uid", workingFolderUID) - - // If we're importing recording rules, we can only import them if the feature is enabled, - // and the feature flag that enables configuring target datasources per-rule is also enabled. - if promGroupHasRecordingRules(promGroup) { - if !srv.cfg.RecordingRules.Enabled { - logger.Error("Cannot import recording rules", "error", errRecordingRulesNotEnabled) - return errorToResponse(errRecordingRulesNotEnabled) - } + logger = logger.New("working_folder_uid", workingFolderUID) - if !srv.featureToggles.IsEnabledGlobally(featuremgmt.FlagGrafanaManagedRecordingRulesDatasources) { - logger.Error("Cannot import recording rules", "error", errRecordingRulesDatasourcesNotEnabled) - return errorToResponse(errRecordingRulesDatasourcesNotEnabled) - } + pauseRecordingRules, err := parseBooleanHeader(c.Req.Header.Get(recordingRulesPausedHeader), recordingRulesPausedHeader) + if err != nil { + return errorToResponse(err) } - logger.Info("Converting Prometheus rule group", "rules", len(promGroup.Rules)) - - ns, errResp := srv.getOrCreateNamespace(c, namespaceTitle, logger, workingFolderUID) - if errResp != nil { - return errResp + pauseAlertRules, err := parseBooleanHeader(c.Req.Header.Get(alertRulesPausedHeader), alertRulesPausedHeader) + if err != nil { + return errorToResponse(err) } datasourceUID := strings.TrimSpace(c.Req.Header.Get(datasourceUIDHeader)) @@ -375,15 +369,44 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextm // to ensure we can return them in this API in Prometheus format. keepOriginalRuleDefinition := provenance == models.ProvenanceConvertedPrometheus - group, err := srv.convertToGrafanaRuleGroup(c, ds, tds, ns.UID, promGroup, keepOriginalRuleDefinition, logger) - if err != nil { - logger.Error("Failed to convert Prometheus rules to Grafana rules", "error", err) - return errorToResponse(err) + // 2. Convert Prometheus Rules to GMA + grafanaGroups := make([]*models.AlertRuleGroup, 0, len(promNamespaces)) + for ns, rgs := range promNamespaces { + logger.Debug("Creating a new namespace", "title", ns) + namespace, errResp := srv.getOrCreateNamespace(c, ns, logger, workingFolderUID) + if errResp != nil { + logger.Error("Failed to create a new namespace", "folder_uid", workingFolderUID) + return errResp + } + + for _, rg := range rgs { + // If we're importing recording rules, we can only import them if the feature is enabled, + // and the feature flag that enables configuring target datasources per-rule is also enabled. + if promGroupHasRecordingRules(rg) { + if !srv.cfg.RecordingRules.Enabled { + logger.Error("Cannot import recording rules", "error", errRecordingRulesNotEnabled) + return errorToResponse(errRecordingRulesNotEnabled) + } + + if !srv.featureToggles.IsEnabledGlobally(featuremgmt.FlagGrafanaManagedRecordingRulesDatasources) { + logger.Error("Cannot import recording rules", "error", errRecordingRulesDatasourcesNotEnabled) + return errorToResponse(errRecordingRulesDatasourcesNotEnabled) + } + } + + grafanaGroup, err := srv.convertToGrafanaRuleGroup(c, ds, tds, namespace.UID, rg, pauseRecordingRules, pauseAlertRules, keepOriginalRuleDefinition, logger) + if err != nil { + logger.Error("Failed to convert Prometheus rules to Grafana rules", "error", err) + return errorToResponse(err) + } + grafanaGroups = append(grafanaGroups, grafanaGroup) + } } - err = srv.alertRuleService.ReplaceRuleGroup(c.Req.Context(), c.SignedInUser, *group, provenance) + // 3. Update the GMA Rules in the DB + err = srv.alertRuleService.ReplaceRuleGroups(c.Req.Context(), c.SignedInUser, grafanaGroups, provenance) if err != nil { - logger.Error("Failed to replace rule group", "error", err) + logger.Error("Failed to replace rule groups", "error", err) return errorToResponse(err) } @@ -416,6 +439,8 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup( tds *datasources.DataSource, namespaceUID string, promGroup apimodels.PrometheusRuleGroup, + pauseRecordingRules bool, + pauseAlertRules bool, keepOriginalRuleDefinition bool, logger log.Logger, ) (*models.AlertRuleGroup, error) { @@ -439,16 +464,6 @@ func (srv *ConvertPrometheusSrv) convertToGrafanaRuleGroup( 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, diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index 720bde8349b..95f42455d13 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -987,6 +987,200 @@ func TestRouteConvertPrometheusDeleteRuleGroup(t *testing.T) { }) } +func TestRouteConvertPrometheusPostRuleGroups(t *testing.T) { + srv, _, ruleStore, folderService := createConvertPrometheusSrv(t) + + req := createRequestCtx() + req.Req.Header.Set(datasourceUIDHeader, existingDSUID) + + // Create test prometheus rules + promAlertRule := apimodels.PrometheusRule{ + Alert: "TestAlert", + Expr: "up == 0", + For: util.Pointer(prommodel.Duration(5 * time.Minute)), + Labels: map[string]string{ + "severity": "critical", + }, + } + + promRecordingRule := apimodels.PrometheusRule{ + Record: "TestRecordingRule", + Expr: "up == 0", + } + + promGroup1 := apimodels.PrometheusRuleGroup{ + Name: "TestGroup1", + Interval: prommodel.Duration(1 * time.Minute), + Rules: []apimodels.PrometheusRule{promAlertRule}, + } + + promGroup2 := apimodels.PrometheusRuleGroup{ + Name: "TestGroup2", + Interval: prommodel.Duration(1 * time.Minute), + Rules: []apimodels.PrometheusRule{promAlertRule}, + } + + promGroup3 := apimodels.PrometheusRuleGroup{ + Name: "TestGroup3", + Interval: prommodel.Duration(1 * time.Minute), + Rules: []apimodels.PrometheusRule{promAlertRule, promRecordingRule}, + } + + promGroups := map[string][]apimodels.PrometheusRuleGroup{ + "namespace1": {promGroup1, promGroup2}, + "namespace2": {promGroup3}, + } + + t.Run("should convert prometheus rules to Grafana rules", func(t *testing.T) { + // Call the endpoint + response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups) + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify the rules were created + rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{ + OrgID: req.SignedInUser.GetOrgID(), + }) + require.NoError(t, err) + require.Len(t, rules, 4) + + // Verify rule content + for _, rule := range rules { + require.Equal(t, int64(60), rule.IntervalSeconds) // 1 minute interval + + // Check that the rule matches one of our original prometheus rules + switch rule.RuleGroup { + case "TestGroup1": + require.Equal(t, "TestAlert", rule.Title) + require.Equal(t, "critical", rule.Labels["severity"]) + require.Equal(t, 5*time.Minute, rule.For) + case "TestGroup2": + require.Equal(t, "TestAlert", rule.Title) + require.Equal(t, "critical", rule.Labels["severity"]) + require.Equal(t, 5*time.Minute, rule.For) + case "TestGroup3": + switch rule.Title { + case "TestAlert": + require.Equal(t, "critical", rule.Labels["severity"]) + require.Equal(t, 5*time.Minute, rule.For) + case "TestRecordingRule": + require.Equal(t, "TestRecordingRule", rule.Record.Metric) + default: + t.Fatalf("unexpected rule title: %s", rule.Title) + } + default: + t.Fatalf("unexpected rule group: %s", rule.RuleGroup) + } + } + }) + + t.Run("should convert Prometheus rules to Grafana rules but pause recording rules", func(t *testing.T) { + clear(ruleStore.Rules) + + req.Req.Header.Set(alertRulesPausedHeader, "false") + req.Req.Header.Set(recordingRulesPausedHeader, "true") + + // Call the endpoint + response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups) + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify the rules were created + rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{ + OrgID: req.SignedInUser.GetOrgID(), + }) + require.NoError(t, err) + require.Len(t, rules, 4) + + // Verify the recording rule is paused + for _, rule := range rules { + if rule.Record != nil { + require.True(t, rule.IsPaused) + } + } + }) + + t.Run("should convert Prometheus rules to Grafana rules but pause alert rules", func(t *testing.T) { + clear(ruleStore.Rules) + + req.Req.Header.Set(alertRulesPausedHeader, "true") + req.Req.Header.Set(recordingRulesPausedHeader, "false") + + // Call the endpoint + response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups) + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify the rules were created + rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{ + OrgID: req.SignedInUser.GetOrgID(), + }) + require.NoError(t, err) + require.Len(t, rules, 4) + + // Verify the alert rule is paused + for _, rule := range rules { + if rule.Record == nil { + require.True(t, rule.IsPaused) + } + } + }) + + t.Run("should convert Prometheus rules to Grafana rules but pause both alert and recording rules", func(t *testing.T) { + clear(ruleStore.Rules) + + req.Req.Header.Set(recordingRulesPausedHeader, "true") + req.Req.Header.Set(alertRulesPausedHeader, "true") + + // Call the endpoint + response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups) + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify the rules were created + rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{ + OrgID: req.SignedInUser.GetOrgID(), + }) + require.NoError(t, err) + require.Len(t, rules, 4) + + // Verify the alert rule is paused + for _, rule := range rules { + require.True(t, rule.IsPaused) + } + }) + + t.Run("convert Prometheus rules to Grafana rules into a specified target folder", func(t *testing.T) { + clear(ruleStore.Rules) + + // Create a target folder to move the rules into + fldr := randFolder() + fldr.ParentUID = "" + folderService.ExpectedFolder = fldr + folderService.ExpectedFolders = []*folder.Folder{fldr} + ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr) + + req.Req.Header.Del(recordingRulesPausedHeader) + req.Req.Header.Del(alertRulesPausedHeader) + req.Req.Header.Set(folderUIDHeader, fldr.UID) + + // Call the endpoint + response := srv.RouteConvertPrometheusPostRuleGroups(req, promGroups) + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify the rules were created + rules, err := ruleStore.ListAlertRules(req.Req.Context(), &models.ListAlertRulesQuery{ + OrgID: req.SignedInUser.GetOrgID(), + }) + + require.NoError(t, err) + require.Len(t, rules, 4) + + for _, rule := range rules { + parentFolders, err := folderService.GetParents(context.Background(), folder.GetParentsQuery{UID: rule.NamespaceUID, OrgID: 1}) + require.NoError(t, err) + require.Len(t, parentFolders, 1) + require.Equal(t, fldr.UID, parentFolders[0].UID) + } + }) +} + type convertPrometheusSrvOptions struct { provenanceStore provisioning.ProvisioningStore fakeAccessControlRuleService *acfakes.FakeRuleService diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 7e5b4403e1b..096b0b4c81c 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -128,7 +128,9 @@ func (api *API) authorize(method, path string) web.Handler { ) case http.MethodPost + "/api/convert/prometheus/config/v1/rules/{NamespaceTitle}", - http.MethodPost + "/api/convert/api/prom/rules/{NamespaceTitle}": + http.MethodPost + "/api/convert/api/prom/rules/{NamespaceTitle}", + http.MethodPost + "/api/convert/prometheus/config/v1/rules", + http.MethodPost + "/api/convert/api/prom/config/v1/rules": eval = ac.EvalAll( ac.EvalPermission(ac.ActionAlertingRuleCreate), ac.EvalPermission(ac.ActionAlertingProvisioningSetStatus), diff --git a/pkg/services/ngalert/api/authorization_test.go b/pkg/services/ngalert/api/authorization_test.go index 13b0f24c36c..0e33d42526e 100644 --- a/pkg/services/ngalert/api/authorization_test.go +++ b/pkg/services/ngalert/api/authorization_test.go @@ -41,7 +41,7 @@ func TestAuthorize(t *testing.T) { } paths[p] = methods } - require.Len(t, paths, 63) + require.Len(t, paths, 64) ac := acmock.New() api := &API{AccessControl: ac, FeatureManager: featuremgmt.WithFeatures()} diff --git a/pkg/services/ngalert/api/generated_base_api_convert_prometheus.go b/pkg/services/ngalert/api/generated_base_api_convert_prometheus.go index 2ee728172c9..1312b74c998 100644 --- a/pkg/services/ngalert/api/generated_base_api_convert_prometheus.go +++ b/pkg/services/ngalert/api/generated_base_api_convert_prometheus.go @@ -25,12 +25,14 @@ type ConvertPrometheusApi interface { RouteConvertPrometheusCortexGetRuleGroup(*contextmodel.ReqContext) response.Response RouteConvertPrometheusCortexGetRules(*contextmodel.ReqContext) response.Response RouteConvertPrometheusCortexPostRuleGroup(*contextmodel.ReqContext) response.Response + RouteConvertPrometheusCortexPostRuleGroups(*contextmodel.ReqContext) response.Response RouteConvertPrometheusDeleteNamespace(*contextmodel.ReqContext) response.Response RouteConvertPrometheusDeleteRuleGroup(*contextmodel.ReqContext) response.Response RouteConvertPrometheusGetNamespace(*contextmodel.ReqContext) response.Response RouteConvertPrometheusGetRuleGroup(*contextmodel.ReqContext) response.Response RouteConvertPrometheusGetRules(*contextmodel.ReqContext) response.Response RouteConvertPrometheusPostRuleGroup(*contextmodel.ReqContext) response.Response + RouteConvertPrometheusPostRuleGroups(*contextmodel.ReqContext) response.Response } func (f *ConvertPrometheusApiHandler) RouteConvertPrometheusCortexDeleteNamespace(ctx *contextmodel.ReqContext) response.Response { @@ -63,6 +65,9 @@ func (f *ConvertPrometheusApiHandler) RouteConvertPrometheusCortexPostRuleGroup( namespaceTitleParam := web.Params(ctx.Req)[":NamespaceTitle"] return f.handleRouteConvertPrometheusCortexPostRuleGroup(ctx, namespaceTitleParam) } +func (f *ConvertPrometheusApiHandler) RouteConvertPrometheusCortexPostRuleGroups(ctx *contextmodel.ReqContext) response.Response { + return f.handleRouteConvertPrometheusCortexPostRuleGroups(ctx) +} func (f *ConvertPrometheusApiHandler) RouteConvertPrometheusDeleteNamespace(ctx *contextmodel.ReqContext) response.Response { // Parse Path Parameters namespaceTitleParam := web.Params(ctx.Req)[":NamespaceTitle"] @@ -93,6 +98,9 @@ func (f *ConvertPrometheusApiHandler) RouteConvertPrometheusPostRuleGroup(ctx *c namespaceTitleParam := web.Params(ctx.Req)[":NamespaceTitle"] return f.handleRouteConvertPrometheusPostRuleGroup(ctx, namespaceTitleParam) } +func (f *ConvertPrometheusApiHandler) RouteConvertPrometheusPostRuleGroups(ctx *contextmodel.ReqContext) response.Response { + return f.handleRouteConvertPrometheusPostRuleGroups(ctx) +} func (api *API) RegisterConvertPrometheusApiEndpoints(srv ConvertPrometheusApi, m *metrics.API) { api.RouteRegister.Group("", func(group routing.RouteRegister) { @@ -168,6 +176,18 @@ func (api *API) RegisterConvertPrometheusApiEndpoints(srv ConvertPrometheusApi, m, ), ) + group.Post( + toMacaronPath("/api/convert/api/prom/config/v1/rules"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodPost, "/api/convert/api/prom/config/v1/rules"), + metrics.Instrument( + http.MethodPost, + "/api/convert/api/prom/config/v1/rules", + api.Hooks.Wrap(srv.RouteConvertPrometheusCortexPostRuleGroups), + m, + ), + ) group.Delete( toMacaronPath("/api/convert/prometheus/config/v1/rules/{NamespaceTitle}"), requestmeta.SetOwner(requestmeta.TeamAlerting), @@ -240,5 +260,17 @@ func (api *API) RegisterConvertPrometheusApiEndpoints(srv ConvertPrometheusApi, m, ), ) + group.Post( + toMacaronPath("/api/convert/prometheus/config/v1/rules"), + requestmeta.SetOwner(requestmeta.TeamAlerting), + requestmeta.SetSLOGroup(requestmeta.SLOGroupHighSlow), + api.authorize(http.MethodPost, "/api/convert/prometheus/config/v1/rules"), + metrics.Instrument( + http.MethodPost, + "/api/convert/prometheus/config/v1/rules", + api.Hooks.Wrap(srv.RouteConvertPrometheusPostRuleGroups), + m, + ), + ) }, middleware.ReqSignedIn) } diff --git a/pkg/services/ngalert/api/prometheus_conversion.go b/pkg/services/ngalert/api/prometheus_conversion.go index 9862c862b9a..13547093ca1 100644 --- a/pkg/services/ngalert/api/prometheus_conversion.go +++ b/pkg/services/ngalert/api/prometheus_conversion.go @@ -83,6 +83,44 @@ func (f *ConvertPrometheusApiHandler) handleRouteConvertPrometheusPostRuleGroup( return f.svc.RouteConvertPrometheusPostRuleGroup(ctx, namespaceTitle, promGroup) } +func (f *ConvertPrometheusApiHandler) handleRouteConvertPrometheusPostRuleGroups(ctx *contextmodel.ReqContext) response.Response { + body, err := io.ReadAll(ctx.Req.Body) + if err != nil { + return errorToResponse(err) + } + defer func() { _ = ctx.Req.Body.Close() }() + + var m string + + // Parse content-type only if it's not empty, + // otherwise we'll assume it's yaml + contentType := ctx.Req.Header.Get("content-type") + if contentType != "" { + m, _, err = mime.ParseMediaType(contentType) + if err != nil { + return errorToResponse(err) + } + } + + var promNamespaces map[string][]apimodels.PrometheusRuleGroup + + switch m { + case "application/yaml", "": + // mimirtool does not send content-type, so if it's empty, we assume it's yaml + if err := yaml.Unmarshal(body, &promNamespaces); err != nil { + return errorToResponse(err) + } + case "application/json": + if err := json.Unmarshal(body, &promNamespaces); err != nil { + return errorToResponse(err) + } + default: + return errorToResponse(errorUnsupportedMediaType.Errorf("unsupported media type: %s, only application/yaml and application/json are supported", m)) + } + + return f.svc.RouteConvertPrometheusPostRuleGroups(ctx, promNamespaces) +} + // cortextool func (f *ConvertPrometheusApiHandler) handleRouteConvertPrometheusCortexGetRules(ctx *contextmodel.ReqContext) response.Response { return f.handleRouteConvertPrometheusGetRules(ctx) @@ -107,3 +145,7 @@ func (f *ConvertPrometheusApiHandler) handleRouteConvertPrometheusCortexGetRuleG func (f *ConvertPrometheusApiHandler) handleRouteConvertPrometheusCortexPostRuleGroup(ctx *contextmodel.ReqContext, namespaceTitle string) response.Response { return f.handleRouteConvertPrometheusPostRuleGroup(ctx, namespaceTitle) } + +func (f *ConvertPrometheusApiHandler) handleRouteConvertPrometheusCortexPostRuleGroups(ctx *contextmodel.ReqContext) response.Response { + return f.handleRouteConvertPrometheusPostRuleGroups(ctx) +} diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 921e66a6dd9..c8bb0e128bb 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -4470,7 +4470,6 @@ "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" @@ -4506,7 +4505,7 @@ "$ref": "#/definitions/Userinfo" } }, - "title": "A URL represents a parsed URL (technically, a URI reference).", + "title": "URL is a custom URL type that allows validation at configuration load time.", "type": "object" }, "UpdateRuleGroupResponse": { 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 6e9f0f2bd4c..2630ef9aeeb 100644 --- a/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go +++ b/pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go @@ -82,6 +82,36 @@ import ( // 403: ForbiddenError // 404: NotFound +// swagger:route POST /convert/prometheus/config/v1/rules convert_prometheus RouteConvertPrometheusPostRuleGroups +// +// Converts the submitted rule groups into Grafana-Managed Rules. +// +// Consumes: +// - application/json +// - application/yaml +// +// Produces: +// - application/json +// +// Responses: +// 202: ConvertPrometheusResponse +// 403: ForbiddenError + +// swagger:route POST /convert/api/prom/config/v1/rules convert_prometheus RouteConvertPrometheusCortexPostRuleGroups +// +// Converts the submitted rule groups into Grafana-Managed Rules. +// +// Consumes: +// - application/json +// - application/yaml +// +// Produces: +// - application/json +// +// Responses: +// 202: ConvertPrometheusResponse +// 403: ForbiddenError + // Route for mimirtool // swagger:route POST /convert/prometheus/config/v1/rules/{NamespaceTitle} convert_prometheus RouteConvertPrometheusPostRuleGroup // diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index d38374f789a..118d5ffe403 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -4470,6 +4470,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" @@ -4505,7 +4506,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": { @@ -5032,6 +5033,7 @@ "type": "object" }, "gettableSilences": { + "description": "GettableSilences gettable silences", "items": { "$ref": "#/definitions/gettableSilence", "type": "object" @@ -6374,6 +6376,36 @@ ] } }, + "/convert/api/prom/config/v1/rules": { + "post": { + "consumes": [ + "application/json", + "application/yaml" + ], + "operationId": "RouteConvertPrometheusCortexPostRuleGroups", + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "summary": "Converts the submitted rule groups into Grafana-Managed Rules.", + "tags": [ + "convert_prometheus" + ] + } + }, "/convert/api/prom/rules": { "get": { "operationId": "RouteConvertPrometheusCortexGetRules", @@ -6651,6 +6683,34 @@ "tags": [ "convert_prometheus" ] + }, + "post": { + "consumes": [ + "application/json", + "application/yaml" + ], + "operationId": "RouteConvertPrometheusPostRuleGroups", + "produces": [ + "application/json" + ], + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + }, + "summary": "Converts the submitted rule groups into Grafana-Managed Rules.", + "tags": [ + "convert_prometheus" + ] } }, "/convert/prometheus/config/v1/rules/{NamespaceTitle}": { diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index a39ac33484d..1df9a717920 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1102,6 +1102,36 @@ } } }, + "/convert/api/prom/config/v1/rules": { + "post": { + "consumes": [ + "application/json", + "application/yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Converts the submitted rule groups into Grafana-Managed Rules.", + "operationId": "RouteConvertPrometheusCortexPostRuleGroups", + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + } + } + }, "/convert/api/prom/rules": { "get": { "produces": [ @@ -1379,6 +1409,34 @@ } } } + }, + "post": { + "consumes": [ + "application/json", + "application/yaml" + ], + "produces": [ + "application/json" + ], + "tags": [ + "convert_prometheus" + ], + "summary": "Converts the submitted rule groups into Grafana-Managed Rules.", + "operationId": "RouteConvertPrometheusPostRuleGroups", + "responses": { + "202": { + "description": "ConvertPrometheusResponse", + "schema": { + "$ref": "#/definitions/ConvertPrometheusResponse" + } + }, + "403": { + "description": "ForbiddenError", + "schema": { + "$ref": "#/definitions/ForbiddenError" + } + } + } } }, "/convert/prometheus/config/v1/rules/{NamespaceTitle}": { @@ -8546,8 +8604,9 @@ } }, "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.", "type": "object", - "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).", "properties": { "ForceQuery": { "type": "boolean" @@ -9108,6 +9167,7 @@ } }, "gettableSilences": { + "description": "GettableSilences gettable silences", "type": "array", "items": { "type": "object", diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index b433a2ec76b..149d9b9eda0 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -203,7 +203,6 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom if err != nil { return models.AlertRule{}, err } - if isRecordingRule { record = &models.Record{ From: queryRefID, diff --git a/pkg/services/ngalert/provisioning/alert_rules.go b/pkg/services/ngalert/provisioning/alert_rules.go index e3639d85ae1..929db7ed7e8 100644 --- a/pkg/services/ngalert/provisioning/alert_rules.go +++ b/pkg/services/ngalert/provisioning/alert_rules.go @@ -450,6 +450,20 @@ func (service *AlertRuleService) ReplaceRuleGroup(ctx context.Context, user iden return service.persistDelta(ctx, user, delta, provenance) } +func (service *AlertRuleService) ReplaceRuleGroups(ctx context.Context, user identity.Requester, groups []*models.AlertRuleGroup, provenance models.Provenance) error { + err := service.xact.InTransaction(ctx, func(ctx context.Context) error { + for _, group := range groups { + err := service.ReplaceRuleGroup(ctx, user, *group, provenance) + if err != nil { + return err + } + } + return nil + }) + + return err +} + func (service *AlertRuleService) DeleteRuleGroup(ctx context.Context, user identity.Requester, namespaceUID, group string, provenance models.Provenance) error { return service.DeleteRuleGroups(ctx, user, provenance, &FilterOptions{ NamespaceUIDs: []string{namespaceUID},