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
This commit is contained in:
Fayzal Ghantiwala 2025-03-27 15:30:11 +00:00 committed by GitHub
parent d7be68ab3c
commit 68d8810ecb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 488 additions and 41 deletions

View File

@ -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)
logger = logger.New("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)
}
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,

View File

@ -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

View File

@ -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),

View File

@ -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()}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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": {

View File

@ -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
//

View File

@ -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}": {

View File

@ -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",

View File

@ -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,

View File

@ -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},