Alerting: API to read rule groups using mimirtool (#100674)

pull/101366/head
Alexander Akhmetov 5 months ago committed by GitHub
parent d83db31a23
commit 6eb335a8ce
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 169
      pkg/services/ngalert/api/api_convert_prometheus.go
  2. 280
      pkg/services/ngalert/api/api_convert_prometheus_test.go
  3. 4
      pkg/services/ngalert/api/api_provisioning.go
  4. 1
      pkg/services/ngalert/api/tooling/api.json
  5. 20
      pkg/services/ngalert/api/tooling/definitions/convert_prometheus_api.go
  6. 20
      pkg/services/ngalert/api/tooling/post.json
  7. 20
      pkg/services/ngalert/api/tooling/spec.json
  8. 18
      pkg/services/ngalert/models/alert_rule.go
  9. 8
      pkg/services/ngalert/models/testing.go
  10. 43
      pkg/services/ngalert/provisioning/alert_rules.go
  11. 4
      pkg/services/ngalert/provisioning/alert_rules_test.go
  12. 32
      pkg/services/ngalert/store/alert_rule.go
  13. 58
      pkg/services/ngalert/store/alert_rule_test.go
  14. 3
      pkg/services/ngalert/tests/fakes/rules.go
  15. 166
      pkg/tests/api/alerting/api_convert_prometheus_test.go
  16. 111
      pkg/tests/api/alerting/testing.go
  17. 1
      public/api-merged.json
  18. 1
      public/openapi3.json

@ -1,15 +1,21 @@
package api
import (
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
prommodel "github.com/prometheus/common/model"
"gopkg.in/yaml.v3"
"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/dashboards"
"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"
@ -17,6 +23,7 @@ import (
"github.com/grafana/grafana/pkg/services/ngalert/prom"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/util"
)
const (
@ -39,6 +46,18 @@ func errInvalidHeaderValue(header string) error {
return errInvalidHeaderValueBase.Build(errutil.TemplateData{Public: map[string]any{"Header": header}})
}
// ConvertPrometheusSrv converts Prometheus rules to Grafana rules
// and retrieves them in a Prometheus-compatible format.
//
// It is designed to support mimirtool integration, so that rules that work with Mimir
// can be imported into Grafana. It works similarly to the provisioning API,
// where once a rule group is created, it is marked as "provisioned" (via provenance mechanism)
// and is not editable in the UI.
//
// This service returns only rule groups that were initially imported from Prometheus-compatible sources.
// Rule groups not imported from Prometheus are excluded because their original rule definitions are unavailable.
// When a rule group is converted from Prometheus to Grafana, the original definition is preserved alongside
// the Grafana rule and used for reading requests here.
type ConvertPrometheusSrv struct {
cfg *setting.UnifiedAlertingSettings
logger log.Logger
@ -57,27 +76,117 @@ func NewConvertPrometheusSrv(cfg *setting.UnifiedAlertingSettings, logger log.Lo
}
}
// RouteConvertPrometheusGetRules returns all Grafana-managed alert rules in all namespaces (folders)
// that were imported from a Prometheus-compatible source.
// It responds with a YAML containing a mapping of folders to arrays of Prometheus rule groups.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRules(c *contextmodel.ReqContext) response.Response {
return response.Error(501, "Not implemented", nil)
logger := srv.logger.FromContext(c.Req.Context())
filterOpts := &provisioning.FilterOptions{
ImportedPrometheusRule: util.Pointer(true),
}
groups, err := srv.alertRuleService.GetAlertGroupsWithFolderFullpath(c.Req.Context(), c.SignedInUser, filterOpts)
if err != nil {
logger.Error("Failed to get alert groups", "error", err)
return errorToResponse(err)
}
namespaces, err := grafanaNamespacesToPrometheus(groups)
if err != nil {
logger.Error("Failed to convert Grafana rules to Prometheus format", "error", err)
return errorToResponse(err)
}
return response.YAML(http.StatusOK, namespaces)
}
// RouteConvertPrometheusDeleteNamespace deletes all rule groups that were imported from a Prometheus-compatible source
// within a specified namespace.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteNamespace(c *contextmodel.ReqContext, namespaceTitle string) response.Response {
return response.Error(501, "Not implemented", nil)
}
// RouteConvertPrometheusDeleteRuleGroup deletes a specific rule group if it was imported from a Prometheus-compatible source.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusDeleteRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response {
return response.Error(501, "Not implemented", nil)
}
// RouteConvertPrometheusGetNamespace returns the Grafana-managed alert rules for a specified namespace (folder).
// It responds with a YAML containing a mapping of a single folder to an array of Prometheus rule groups.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetNamespace(c *contextmodel.ReqContext, namespaceTitle string) response.Response {
return response.Error(501, "Not implemented", nil)
logger := srv.logger.FromContext(c.Req.Context())
logger.Debug("Looking up folder in the root by title", "folder_title", namespaceTitle)
namespace, err := srv.ruleStore.GetNamespaceInRootByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
logger.Error("Failed to get folder", "error", err)
return namespaceErrorResponse(err)
}
filterOpts := &provisioning.FilterOptions{
ImportedPrometheusRule: util.Pointer(true),
NamespaceUIDs: []string{namespace.UID},
}
groups, err := srv.alertRuleService.GetAlertGroupsWithFolderFullpath(c.Req.Context(), c.SignedInUser, filterOpts)
if err != nil {
logger.Error("Failed to get alert groups", "error", err)
return errorToResponse(err)
}
ns, err := grafanaNamespacesToPrometheus(groups)
if err != nil {
logger.Error("Failed to convert Grafana rules to Prometheus format", "error", err)
return errorToResponse(err)
}
return response.YAML(http.StatusOK, ns)
}
// RouteConvertPrometheusGetRuleGroup retrieves a single rule group for a given namespace (folder)
// in Prometheus-compatible YAML format if it was imported from a Prometheus-compatible source.
func (srv *ConvertPrometheusSrv) RouteConvertPrometheusGetRuleGroup(c *contextmodel.ReqContext, namespaceTitle string, group string) response.Response {
// 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{})
logger := srv.logger.FromContext(c.Req.Context())
logger.Debug("Looking up folder in the root by title", "folder_title", namespaceTitle)
namespace, err := srv.ruleStore.GetNamespaceInRootByTitle(c.Req.Context(), namespaceTitle, c.SignedInUser.GetOrgID(), c.SignedInUser)
if err != nil {
logger.Error("Failed to get folder", "error", err)
return namespaceErrorResponse(err)
}
filterOpts := &provisioning.FilterOptions{
ImportedPrometheusRule: util.Pointer(true),
NamespaceUIDs: []string{namespace.UID},
RuleGroups: []string{group},
}
groupsWithFolders, err := srv.alertRuleService.GetAlertGroupsWithFolderFullpath(c.Req.Context(), c.SignedInUser, filterOpts)
if err != nil {
logger.Error("Failed to get alert group", "error", err)
return errorToResponse(err)
}
if len(groupsWithFolders) == 0 {
return response.Error(http.StatusNotFound, "Rule group not found", nil)
}
if len(groupsWithFolders) > 1 {
logger.Error("Multiple rule groups found when only one was expected", "folder_title", namespaceTitle, "group", group)
// It shouldn't happen, but if we get more than 1 group, we return an error.
return response.Error(http.StatusInternalServerError, "Multiple rule groups found", nil)
}
promGroup, err := grafanaRuleGroupToPrometheus(groupsWithFolders[0].Title, groupsWithFolders[0].Rules)
if err != nil {
logger.Error("Failed to convert Grafana rule to Prometheus format", "error", err)
return errorToResponse(err)
}
return response.YAML(http.StatusOK, promGroup)
}
// RouteConvertPrometheusPostRuleGroup converts a Prometheus rule group into a Grafana rule group
// and creates or updates it within the specified namespace (folder).
//
// 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 {
logger := srv.logger.FromContext(c.Req.Context())
logger = logger.New("folder_title", namespaceTitle, "group", promGroup.Name)
@ -101,6 +210,7 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroup(c *contextm
group, err := srv.convertToGrafanaRuleGroup(c, ds, ns.UID, promGroup, logger)
if err != nil {
logger.Error("Failed to convert Prometheus rules to Grafana rules", "error", err)
return errorToResponse(err)
}
@ -202,3 +312,54 @@ func parseBooleanHeader(header string, headerName string) (bool, error) {
}
return val, nil
}
func grafanaNamespacesToPrometheus(groups []models.AlertRuleGroupWithFolderFullpath) (map[string][]apimodels.PrometheusRuleGroup, error) {
result := map[string][]apimodels.PrometheusRuleGroup{}
for _, group := range groups {
promGroup, err := grafanaRuleGroupToPrometheus(group.Title, group.Rules)
if err != nil {
return nil, err
}
result[group.FolderFullpath] = append(result[group.FolderFullpath], promGroup)
}
return result, nil
}
func grafanaRuleGroupToPrometheus(group string, rules []models.AlertRule) (apimodels.PrometheusRuleGroup, error) {
if len(rules) == 0 {
return apimodels.PrometheusRuleGroup{}, nil
}
interval := time.Duration(rules[0].IntervalSeconds) * time.Second
promGroup := apimodels.PrometheusRuleGroup{
Name: group,
Interval: prommodel.Duration(interval),
Rules: make([]apimodels.PrometheusRule, len(rules)),
}
for i, rule := range rules {
promDefinition := rule.PrometheusRuleDefinition()
if promDefinition == "" {
return apimodels.PrometheusRuleGroup{}, fmt.Errorf("failed to get the Prometheus definition of the rule with UID %s", rule.UID)
}
var r apimodels.PrometheusRule
if err := yaml.Unmarshal([]byte(promDefinition), &r); err != nil {
return apimodels.PrometheusRuleGroup{}, fmt.Errorf("failed to unmarshal Prometheus rule definition of the rule with UID %s: %w", rule.UID, err)
}
promGroup.Rules[i] = r
}
return promGroup, nil
}
func namespaceErrorResponse(err error) response.Response {
if errors.Is(err, dashboards.ErrFolderAccessDenied) {
// If there is no such folder, the error is ErrFolderAccessDenied.
// We should return 404 in this case, otherwise mimirtool does not work correctly.
return response.Empty(http.StatusNotFound)
}
return toNamespaceErrorResponse(err)
}

@ -1,6 +1,7 @@
package api
import (
"context"
"net/http"
"net/http/httptest"
"testing"
@ -8,14 +9,17 @@ import (
prommodel "github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"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"
"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/models"
"github.com/grafana/grafana/pkg/services/ngalert/provisioning"
"github.com/grafana/grafana/pkg/services/ngalert/tests/fakes"
"github.com/grafana/grafana/pkg/services/user"
@ -45,7 +49,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
}
t.Run("without datasource UID header should return 400", func(t *testing.T) {
srv, _ := createConvertPrometheusSrv(t)
srv, _, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(datasourceUIDHeader, "")
@ -56,7 +60,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("with invalid datasource should return error", func(t *testing.T) {
srv, _ := createConvertPrometheusSrv(t)
srv, _, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(datasourceUIDHeader, "non-existing-ds")
@ -66,7 +70,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("with rule group without evaluation interval should return 202", func(t *testing.T) {
srv, _ := createConvertPrometheusSrv(t)
srv, _, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
@ -103,7 +107,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
srv, _ := createConvertPrometheusSrv(t)
srv, _, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(tc.headerName, tc.headerValue)
@ -136,7 +140,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
srv, _ := createConvertPrometheusSrv(t)
srv, _, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
rc.Req.Header.Set(tc.headerName, tc.headerValue)
@ -148,7 +152,7 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
t.Run("with valid request should return 202", func(t *testing.T) {
srv, _ := createConvertPrometheusSrv(t)
srv, _, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup)
@ -156,7 +160,267 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) {
})
}
func createConvertPrometheusSrv(t *testing.T) (*ConvertPrometheusSrv, datasources.CacheService) {
func TestRouteConvertPrometheusGetRuleGroup(t *testing.T) {
promRule := apimodels.PrometheusRule{
Alert: "test alert",
Expr: "vector(1) > 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "test alert",
},
}
promRuleYAML, err := yaml.Marshal(promRule)
require.NoError(t, err)
t.Run("with non-existent folder should return 404", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetRuleGroup(rc, "non-existent", "test")
require.Equal(t, http.StatusNotFound, response.Status(), string(response.Body()))
})
t.Run("with non-existent group should return 404", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetRuleGroup(rc, "test", "non-existent")
require.Equal(t, http.StatusNotFound, response.Status(), string(response.Body()))
})
t.Run("with valid request should return 200", func(t *testing.T) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t)
rc := createRequestCtx()
// Create two folders in the root folder
fldr := randFolder()
fldr.ParentUID = ""
folderService.ExpectedFolder = fldr
folderService.ExpectedFolders = []*folder.Folder{fldr}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr)
// Create rules in both folders
groupKey := models.GenerateGroupKey(rc.SignedInUser.OrgID)
groupKey.NamespaceUID = fldr.UID
groupKey.RuleGroup = "test-group"
rule := models.RuleGen.
With(models.RuleGen.WithGroupKey(groupKey)).
With(models.RuleGen.WithTitle("TestAlert")).
With(models.RuleGen.WithIntervalSeconds(60)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition(string(promRuleYAML))).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
// Create a rule in another group
groupKeyNotFromProm := models.GenerateGroupKey(rc.SignedInUser.OrgID)
groupKeyNotFromProm.NamespaceUID = fldr.UID
groupKeyNotFromProm.RuleGroup = "test-group-2"
ruleInOtherFolder := models.RuleGen.
With(models.RuleGen.WithGroupKey(groupKeyNotFromProm)).
With(models.RuleGen.WithTitle("in another group")).
With(models.RuleGen.WithIntervalSeconds(60)).
GenerateRef()
ruleStore.PutRule(context.Background(), ruleInOtherFolder)
getResp := srv.RouteConvertPrometheusGetRuleGroup(rc, fldr.Title, groupKey.RuleGroup)
require.Equal(t, http.StatusOK, getResp.Status())
var respGroup apimodels.PrometheusRuleGroup
err := yaml.Unmarshal(getResp.Body(), &respGroup)
require.NoError(t, err)
require.Equal(t, groupKey.RuleGroup, respGroup.Name)
require.Equal(t, prommodel.Duration(time.Duration(rule.IntervalSeconds)*time.Second), respGroup.Interval)
require.Len(t, respGroup.Rules, 1)
require.Equal(t, promRule.Alert, respGroup.Rules[0].Alert)
})
}
func TestRouteConvertPrometheusGetNamespace(t *testing.T) {
promRule1 := apimodels.PrometheusRule{
Alert: "test alert",
Expr: "vector(1) > 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "test alert",
},
}
promRule2 := apimodels.PrometheusRule{
Alert: "test alert 2",
Expr: "vector(1) > 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "also critical",
},
Annotations: map[string]string{
"summary": "test alert 2",
},
}
promGroup1 := apimodels.PrometheusRuleGroup{
Name: "Test Group",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
promRule1,
},
}
promGroup2 := apimodels.PrometheusRuleGroup{
Name: "Test Group 2",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
promRule2,
},
}
t.Run("with non-existent folder should return 404", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetNamespace(rc, "non-existent")
require.Equal(t, http.StatusNotFound, response.Status())
})
t.Run("with valid request should return 200", func(t *testing.T) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t)
rc := createRequestCtx()
// Create two folders in the root folder
fldr := randFolder()
fldr.ParentUID = ""
fldr2 := randFolder()
fldr2.ParentUID = ""
folderService.ExpectedFolders = []*folder.Folder{fldr, fldr2}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr, fldr2)
// Create a Grafana rule for each Prometheus rule
for _, promGroup := range []apimodels.PrometheusRuleGroup{promGroup1, promGroup2} {
groupKey := models.GenerateGroupKey(rc.SignedInUser.OrgID)
groupKey.NamespaceUID = fldr.UID
groupKey.RuleGroup = promGroup.Name
promRuleYAML, err := yaml.Marshal(promGroup.Rules[0])
require.NoError(t, err)
rule := models.RuleGen.
With(models.RuleGen.WithGroupKey(groupKey)).
With(models.RuleGen.WithTitle(promGroup.Rules[0].Alert)).
With(models.RuleGen.WithIntervalSeconds(60)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition(string(promRuleYAML))).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
}
response := srv.RouteConvertPrometheusGetNamespace(rc, fldr.Title)
require.Equal(t, http.StatusOK, response.Status())
var respNamespaces map[string][]apimodels.PrometheusRuleGroup
err := yaml.Unmarshal(response.Body(), &respNamespaces)
require.NoError(t, err)
require.Len(t, respNamespaces, 1)
require.Contains(t, respNamespaces, fldr.Fullpath)
require.ElementsMatch(t, respNamespaces[fldr.Fullpath], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2})
})
}
func TestRouteConvertPrometheusGetRules(t *testing.T) {
promRule1 := apimodels.PrometheusRule{
Alert: "test alert",
Expr: "vector(1) > 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "test alert",
},
}
promRule2 := apimodels.PrometheusRule{
Alert: "test alert 2",
Expr: "vector(1) > 0",
For: util.Pointer(prommodel.Duration(5 * time.Minute)),
Labels: map[string]string{
"severity": "also critical",
},
Annotations: map[string]string{
"summary": "test alert 2",
},
}
promGroup1 := apimodels.PrometheusRuleGroup{
Name: "Test Group",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
promRule1,
},
}
promGroup2 := apimodels.PrometheusRuleGroup{
Name: "Test Group 2",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []apimodels.PrometheusRule{
promRule2,
},
}
t.Run("with no rules should return empty response", func(t *testing.T) {
srv, _, _, _ := createConvertPrometheusSrv(t)
rc := createRequestCtx()
response := srv.RouteConvertPrometheusGetRules(rc)
require.Equal(t, http.StatusOK, response.Status())
var respNamespaces map[string][]apimodels.PrometheusRuleGroup
err := yaml.Unmarshal(response.Body(), &respNamespaces)
require.NoError(t, err)
require.Empty(t, respNamespaces)
})
t.Run("with rules should return 200 with rules", func(t *testing.T) {
srv, _, ruleStore, folderService := createConvertPrometheusSrv(t)
rc := createRequestCtx()
// Create a folder in the root
fldr := randFolder()
fldr.ParentUID = ""
folderService.ExpectedFolders = []*folder.Folder{fldr}
ruleStore.Folders[1] = append(ruleStore.Folders[1], fldr)
// Create a Grafana rule for each Prometheus rule
for _, promGroup := range []apimodels.PrometheusRuleGroup{promGroup1, promGroup2} {
groupKey := models.GenerateGroupKey(rc.SignedInUser.OrgID)
groupKey.NamespaceUID = fldr.UID
groupKey.RuleGroup = promGroup.Name
promRuleYAML, err := yaml.Marshal(promGroup.Rules[0])
require.NoError(t, err)
rule := models.RuleGen.
With(models.RuleGen.WithGroupKey(groupKey)).
With(models.RuleGen.WithTitle(promGroup.Rules[0].Alert)).
With(models.RuleGen.WithIntervalSeconds(60)).
With(models.RuleGen.WithPrometheusOriginalRuleDefinition(string(promRuleYAML))).
GenerateRef()
ruleStore.PutRule(context.Background(), rule)
}
response := srv.RouteConvertPrometheusGetRules(rc)
require.Equal(t, http.StatusOK, response.Status())
var respNamespaces map[string][]apimodels.PrometheusRuleGroup
err := yaml.Unmarshal(response.Body(), &respNamespaces)
require.NoError(t, err)
require.Len(t, respNamespaces, 1)
require.Contains(t, respNamespaces, fldr.Fullpath)
require.ElementsMatch(t, respNamespaces[fldr.Fullpath], []apimodels.PrometheusRuleGroup{promGroup1, promGroup2})
})
}
func createConvertPrometheusSrv(t *testing.T) (*ConvertPrometheusSrv, datasources.CacheService, *fakes.RuleStore, *foldertest.FakeService) {
t.Helper()
ruleStore := fakes.NewRuleStore(t)
@ -195,7 +459,7 @@ func createConvertPrometheusSrv(t *testing.T) (*ConvertPrometheusSrv, datasource
srv := NewConvertPrometheusSrv(cfg, log.NewNopLogger(), ruleStore, dsCache, alertRuleService)
return srv, dsCache
return srv, dsCache, ruleStore, folderService
}
func createRequestCtx() *contextmodel.ReqContext {

@ -77,7 +77,7 @@ type AlertRuleService interface {
DeleteRuleGroup(ctx context.Context, user identity.Requester, folder, group string, provenance alerting_models.Provenance) error
GetAlertRuleWithFolderFullpath(ctx context.Context, u identity.Requester, ruleUID string) (provisioning.AlertRuleWithFolderFullpath, error)
GetAlertRuleGroupWithFolderFullpath(ctx context.Context, u identity.Requester, folder, group string) (alerting_models.AlertRuleGroupWithFolderFullpath, error)
GetAlertGroupsWithFolderFullpath(ctx context.Context, u identity.Requester, folderUIDs []string) ([]alerting_models.AlertRuleGroupWithFolderFullpath, error)
GetAlertGroupsWithFolderFullpath(ctx context.Context, u identity.Requester, opts *provisioning.FilterOptions) ([]alerting_models.AlertRuleGroupWithFolderFullpath, error)
}
func (srv *ProvisioningSrv) RouteGetPolicyTree(c *contextmodel.ReqContext) response.Response {
@ -452,7 +452,7 @@ func (srv *ProvisioningSrv) RouteGetAlertRulesExport(c *contextmodel.ReqContext)
return srv.RouteGetAlertRuleGroupExport(c, folderUIDs[0], group)
}
groupsWithFullpath, err := srv.alertRules.GetAlertGroupsWithFolderFullpath(c.Req.Context(), c.SignedInUser, folderUIDs)
groupsWithFullpath, err := srv.alertRules.GetAlertGroupsWithFolderFullpath(c.Req.Context(), c.SignedInUser, &provisioning.FilterOptions{NamespaceUIDs: folderUIDs})
if err != nil {
return response.ErrOrFallback(http.StatusInternalServerError, "failed to get alert rules", err)
}

@ -4932,6 +4932,7 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/definitions/gettableAlert",
"type": "object"

@ -6,10 +6,10 @@ import (
// swagger:route GET /convert/prometheus/config/v1/rules convert_prometheus RouteConvertPrometheusGetRules
//
// Gets all namespaces with their rule groups in Prometheus format.
// Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace.
//
// Produces:
// - application/json
// - application/yaml
//
// Responses:
// 200: PrometheusNamespace
@ -18,10 +18,10 @@ import (
// swagger:route GET /convert/prometheus/config/v1/rules/{NamespaceTitle} convert_prometheus RouteConvertPrometheusGetNamespace
//
// Gets rules in prometheus format for a given namespace.
// Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder).
//
// Produces:
// - application/json
// - application/yaml
//
// Responses:
// 200: PrometheusNamespace
@ -30,10 +30,10 @@ import (
// swagger:route GET /convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group} convert_prometheus RouteConvertPrometheusGetRuleGroup
//
// Gets a rule group in Prometheus format.
// Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source.
//
// Produces:
// - application/json
// - application/yaml
//
// Responses:
// 200: PrometheusRuleGroup
@ -42,7 +42,9 @@ import (
// swagger:route POST /convert/prometheus/config/v1/rules/{NamespaceTitle} convert_prometheus RouteConvertPrometheusPostRuleGroup
//
// Creates or updates a rule group in Prometheus format.
// Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace.
// 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.
//
// Consumes:
// - application/yaml
@ -59,7 +61,7 @@ import (
// swagger:route DELETE /convert/prometheus/config/v1/rules/{NamespaceTitle} convert_prometheus RouteConvertPrometheusDeleteNamespace
//
// Deletes all rule groups in the given namespace.
// Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace.
//
// Produces:
// - application/json
@ -70,7 +72,7 @@ import (
// swagger:route DELETE /convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group} convert_prometheus RouteConvertPrometheusDeleteRuleGroup
//
// Deletes a rule group in Prometheus format.
// Deletes a specific rule group if it was imported from a Prometheus-compatible source.
//
// Produces:
// - application/json

@ -4770,7 +4770,6 @@
"type": "object"
},
"alertGroups": {
"description": "AlertGroups alert groups",
"items": {
"$ref": "#/definitions/alertGroup",
"type": "object"
@ -6403,7 +6402,7 @@
"get": {
"operationId": "RouteConvertPrometheusGetRules",
"produces": [
"application/json"
"application/yaml"
],
"responses": {
"200": {
@ -6425,7 +6424,7 @@
}
}
},
"summary": "Gets all namespaces with their rule groups in Prometheus format.",
"summary": "Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace.",
"tags": [
"convert_prometheus"
]
@ -6459,7 +6458,7 @@
}
}
},
"summary": "Deletes all rule groups in the given namespace.",
"summary": "Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace.",
"tags": [
"convert_prometheus"
]
@ -6475,7 +6474,7 @@
}
],
"produces": [
"application/json"
"application/yaml"
],
"responses": {
"200": {
@ -6497,7 +6496,7 @@
}
}
},
"summary": "Gets rules in prometheus format for a given namespace.",
"summary": "Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder).",
"tags": [
"convert_prometheus"
]
@ -6506,6 +6505,7 @@
"consumes": [
"application/yaml"
],
"description": "If the group already exists and was not imported from a Prometheus-compatible source initially,\nit will not be replaced and an error will be returned.",
"operationId": "RouteConvertPrometheusPostRuleGroup",
"parameters": [
{
@ -6554,7 +6554,7 @@
}
}
},
"summary": "Creates or updates a rule group in Prometheus format.",
"summary": "Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace.",
"tags": [
"convert_prometheus"
],
@ -6595,7 +6595,7 @@
}
}
},
"summary": "Deletes a rule group in Prometheus format.",
"summary": "Deletes a specific rule group if it was imported from a Prometheus-compatible source.",
"tags": [
"convert_prometheus"
]
@ -6617,7 +6617,7 @@
}
],
"produces": [
"application/json"
"application/yaml"
],
"responses": {
"200": {
@ -6639,7 +6639,7 @@
}
}
},
"summary": "Gets a rule group in Prometheus format.",
"summary": "Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source.",
"tags": [
"convert_prometheus"
]

@ -1105,12 +1105,12 @@
"/convert/prometheus/config/v1/rules": {
"get": {
"produces": [
"application/json"
"application/yaml"
],
"tags": [
"convert_prometheus"
],
"summary": "Gets all namespaces with their rule groups in Prometheus format.",
"summary": "Gets all Grafana-managed alert rules that were imported from Prometheus-compatible sources, grouped by namespace.",
"operationId": "RouteConvertPrometheusGetRules",
"responses": {
"200": {
@ -1137,12 +1137,12 @@
"/convert/prometheus/config/v1/rules/{NamespaceTitle}": {
"get": {
"produces": [
"application/json"
"application/yaml"
],
"tags": [
"convert_prometheus"
],
"summary": "Gets rules in prometheus format for a given namespace.",
"summary": "Gets Grafana-managed alert rules that were imported from Prometheus-compatible sources for a specified namespace (folder).",
"operationId": "RouteConvertPrometheusGetNamespace",
"parameters": [
{
@ -1174,6 +1174,7 @@
}
},
"post": {
"description": "If the group already exists and was not imported from a Prometheus-compatible source initially,\nit will not be replaced and an error will be returned.",
"consumes": [
"application/yaml"
],
@ -1183,7 +1184,7 @@
"tags": [
"convert_prometheus"
],
"summary": "Creates or updates a rule group in Prometheus format.",
"summary": "Converts a Prometheus rule group into a Grafana rule group and creates or updates it within the specified namespace.",
"operationId": "RouteConvertPrometheusPostRuleGroup",
"parameters": [
{
@ -1238,7 +1239,7 @@
"tags": [
"convert_prometheus"
],
"summary": "Deletes all rule groups in the given namespace.",
"summary": "Deletes all rule groups that were imported from Prometheus-compatible sources within the specified namespace.",
"operationId": "RouteConvertPrometheusDeleteNamespace",
"parameters": [
{
@ -1267,12 +1268,12 @@
"/convert/prometheus/config/v1/rules/{NamespaceTitle}/{Group}": {
"get": {
"produces": [
"application/json"
"application/yaml"
],
"tags": [
"convert_prometheus"
],
"summary": "Gets a rule group in Prometheus format.",
"summary": "Gets a single rule group in Prometheus-compatible format if it was imported from a Prometheus-compatible source.",
"operationId": "RouteConvertPrometheusGetRuleGroup",
"parameters": [
{
@ -1316,7 +1317,7 @@
"tags": [
"convert_prometheus"
],
"summary": "Deletes a rule group in Prometheus format.",
"summary": "Deletes a specific rule group if it was imported from a Prometheus-compatible source.",
"operationId": "RouteConvertPrometheusDeleteRuleGroup",
"parameters": [
{
@ -8710,7 +8711,6 @@
}
},
"alertGroups": {
"description": "AlertGroups alert groups",
"type": "array",
"items": {
"type": "object",

@ -394,6 +394,22 @@ func WithoutInternalLabels() LabelOption {
}
}
func (alertRule *AlertRule) ImportedFromPrometheus() bool {
if alertRule.Metadata.PrometheusStyleRule == nil {
return false
}
return alertRule.Metadata.PrometheusStyleRule.OriginalRuleDefinition != ""
}
func (alertRule *AlertRule) PrometheusRuleDefinition() string {
if !alertRule.ImportedFromPrometheus() {
return ""
}
return alertRule.Metadata.PrometheusStyleRule.OriginalRuleDefinition
}
// GetLabels returns the labels specified as part of the alert rule.
func (alertRule *AlertRule) GetLabels(opts ...LabelOption) map[string]string {
labels := alertRule.Labels
@ -806,6 +822,8 @@ type ListAlertRulesQuery struct {
ReceiverName string
TimeIntervalName string
ImportedPrometheusRule *bool
}
// CountAlertRulesQuery is the query for counting alert rules

@ -216,6 +216,14 @@ func (a *AlertRuleMutators) WithEditorSettingsSimplifiedNotificationsSection(ena
}
}
func (a *AlertRuleMutators) WithPrometheusOriginalRuleDefinition(definition string) AlertRuleMutator {
return func(rule *AlertRule) {
rule.Metadata.PrometheusStyleRule = &PrometheusStyleRule{
OriginalRuleDefinition: definition,
}
}
}
func (a *AlertRuleMutators) WithGroupIndex(groupIndex int) AlertRuleMutator {
return func(rule *AlertRule) {
rule.RuleGroupIndex = groupIndex

@ -262,12 +262,41 @@ func (service *AlertRuleService) CreateAlertRule(ctx context.Context, user ident
return rule, nil
}
// FilterOptions provides filtering for alert rule queries.
// All fields are optional and will be applied as filters if provided.
type FilterOptions struct {
ImportedPrometheusRule *bool
RuleGroups []string
NamespaceUIDs []string
}
func (opts *FilterOptions) apply(q models.ListAlertRulesQuery) models.ListAlertRulesQuery {
if opts == nil {
return q
}
if opts.ImportedPrometheusRule != nil {
q.ImportedPrometheusRule = opts.ImportedPrometheusRule
}
if len(opts.NamespaceUIDs) > 0 {
q.NamespaceUIDs = opts.NamespaceUIDs
}
if len(opts.RuleGroups) > 0 {
q.RuleGroups = opts.RuleGroups
}
return q
}
func (service *AlertRuleService) GetRuleGroup(ctx context.Context, user identity.Requester, namespaceUID, group string) (models.AlertRuleGroup, error) {
q := models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
NamespaceUIDs: []string{namespaceUID},
RuleGroups: []string{group},
}
ruleList, err := service.ruleStore.ListAlertRules(ctx, &q)
if err != nil {
return models.AlertRuleGroup{}, err
@ -748,15 +777,17 @@ func (service *AlertRuleService) GetAlertRuleGroupWithFolderFullpath(ctx context
return res, nil
}
// GetAlertGroupsWithFolderFullpath returns all groups with folder's full path in the folders identified by folderUID that have at least one alert. If argument folderUIDs is nil or empty - returns groups in all folders.
func (service *AlertRuleService) GetAlertGroupsWithFolderFullpath(ctx context.Context, user identity.Requester, folderUIDs []string) ([]models.AlertRuleGroupWithFolderFullpath, error) {
// GetAlertGroupsWithFolderFullpath returns all groups that have at least one alert with the full folder path for each group.
// It queries all alert rules for the user's organization, applies optional filtering specified in filterOpts,
// and groups the rules by groups. The function then fetches folder details (including the full path)
// for each namespace (folder UID) associated with the rule groups. If the user lacks blanket read permissions,
// only the groups that the user is authorized to view are returned.
func (service *AlertRuleService) GetAlertGroupsWithFolderFullpath(ctx context.Context, user identity.Requester, filterOpts *FilterOptions) ([]models.AlertRuleGroupWithFolderFullpath, error) {
q := models.ListAlertRulesQuery{
OrgID: user.GetOrgID(),
}
if len(folderUIDs) > 0 {
q.NamespaceUIDs = folderUIDs
}
q = filterOpts.apply(q)
ruleList, err := service.ruleStore.ListAlertRules(ctx, &q)
if err != nil {

@ -1644,7 +1644,7 @@ func TestProvisiongWithFullpath(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, namespaceTitle, res2.FolderFullpath)
res3, err := ruleService.GetAlertGroupsWithFolderFullpath(context.Background(), &signedInUser, []string{namespaceUID})
res3, err := ruleService.GetAlertGroupsWithFolderFullpath(context.Background(), &signedInUser, &FilterOptions{NamespaceUIDs: []string{namespaceUID}})
require.NoError(t, err)
assert.Equal(t, namespaceTitle, res3[0].FolderFullpath)
})
@ -1675,7 +1675,7 @@ func TestProvisiongWithFullpath(t *testing.T) {
require.NoError(t, err)
assert.Equal(t, "my-namespace/my-other-namespace containing multiple \\/\\/", res2.FolderFullpath)
res3, err := ruleService.GetAlertGroupsWithFolderFullpath(context.Background(), &signedInUser, []string{otherNamespaceUID})
res3, err := ruleService.GetAlertGroupsWithFolderFullpath(context.Background(), &signedInUser, &FilterOptions{NamespaceUIDs: []string{otherNamespaceUID}})
require.NoError(t, err)
assert.Equal(t, "my-namespace/my-other-namespace containing multiple \\/\\/", res3[0].FolderFullpath)
})

@ -554,6 +554,13 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
}
}
if query.ImportedPrometheusRule != nil {
q, err = st.filterImportedPrometheusRules(*query.ImportedPrometheusRule, q)
if err != nil {
return err
}
}
q = q.Asc("namespace_uid", "rule_group", "rule_group_idx", "id")
alertRules := make([]*ngmodels.AlertRule, 0)
@ -593,6 +600,14 @@ func (st DBstore) ListAlertRules(ctx context.Context, query *ngmodels.ListAlertR
continue
}
}
if query.ImportedPrometheusRule != nil { // remove false-positive hits from the result
hasOriginalRuleDefinition := converted.Metadata.PrometheusStyleRule != nil && len(converted.Metadata.PrometheusStyleRule.OriginalRuleDefinition) > 0
if *query.ImportedPrometheusRule && !hasOriginalRuleDefinition {
continue
} else if !*query.ImportedPrometheusRule && hasOriginalRuleDefinition {
continue
}
}
// MySQL (and potentially other databases) can use case-insensitive comparison.
// This code makes sure we return groups that only exactly match the filter.
if groupsMap != nil {
@ -928,6 +943,23 @@ func (st DBstore) filterByContentInNotificationSettings(value string, sess *xorm
return sess.And(fmt.Sprintf("notification_settings %s ?", st.SQLStore.GetDialect().LikeStr()), "%"+search+"%"), nil
}
func (st DBstore) filterImportedPrometheusRules(value bool, sess *xorm.Session) (*xorm.Session, error) {
if value {
// Filter for rules that have both prometheus_style_rule and original_rule_definition in metadata
return sess.And(
"metadata LIKE ? AND metadata LIKE ?",
"%prometheus_style_rule%",
"%original_rule_definition%",
), nil
}
// Filter for rules that don't have prometheus_style_rule and original_rule_definition in metadata
return sess.And(
"metadata NOT LIKE ? AND metadata NOT LIKE ?",
"%prometheus_style_rule%",
"%original_rule_definition%",
), nil
}
func (st DBstore) RenameReceiverInNotificationSettings(ctx context.Context, orgID int64, oldReceiver, newReceiver string, validateProvenance func(ngmodels.Provenance) bool, dryRun bool) ([]ngmodels.AlertRuleKey, []ngmodels.AlertRuleKey, error) {
// fetch entire rules because Update method requires it because it copies rules to version table
rules, err := st.ListAlertRules(ctx, &ngmodels.ListAlertRulesQuery{

@ -1828,6 +1828,64 @@ func TestIntegration_AlertRuleVersionsCleanup(t *testing.T) {
})
}
func TestIntegration_ListAlertRules(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test")
}
sqlStore := db.InitTestDB(t)
cfg := setting.NewCfg()
cfg.UnifiedAlerting = setting.UnifiedAlertingSettings{
BaseInterval: time.Duration(rand.Int63n(100)) * time.Second,
}
folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures())
b := &fakeBus{}
orgID := int64(1)
ruleGen := models.RuleGen
ruleGen = ruleGen.With(
ruleGen.WithIntervalMatching(cfg.UnifiedAlerting.BaseInterval),
ruleGen.WithOrgID(orgID),
)
t.Run("filter by ImportedPrometheusRule", func(t *testing.T) {
store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b)
regularRule := createRule(t, store, ruleGen)
importedRule := createRule(t, store, ruleGen.With(
models.RuleMuts.WithPrometheusOriginalRuleDefinition("data"),
))
tc := []struct {
name string
importedPrometheusRule *bool
expectedRules []*models.AlertRule
}{
{
name: "should return only imported prometheus rules when filter is true",
importedPrometheusRule: util.Pointer(true),
expectedRules: []*models.AlertRule{importedRule},
},
{
name: "should return only non-imported rules when filter is false",
importedPrometheusRule: util.Pointer(false),
expectedRules: []*models.AlertRule{regularRule},
},
{
name: "should return all rules when filter is not set",
importedPrometheusRule: nil,
expectedRules: []*models.AlertRule{regularRule, importedRule},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
query := &models.ListAlertRulesQuery{
OrgID: orgID,
ImportedPrometheusRule: tt.importedPrometheusRule,
}
result, err := store.ListAlertRules(context.Background(), query)
require.NoError(t, err)
require.ElementsMatch(t, tt.expectedRules, result)
})
}
})
}
func createTestStore(
sqlStore db.DB,
folderService folder.Service,

@ -11,6 +11,7 @@ import (
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/infra/metrics"
"github.com/grafana/grafana/pkg/services/dashboards"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/services/ngalert/models"
"github.com/grafana/grafana/pkg/util"
@ -289,7 +290,7 @@ func (f *RuleStore) GetNamespaceInRootByTitle(ctx context.Context, title string,
}
}
return nil, fmt.Errorf("namespace with title '%s' not found", title)
return nil, dashboards.ErrFolderNotFound
}
func (f *RuleStore) UpdateAlertRules(_ context.Context, _ *models.UserUID, q []models.UpdateRule) error {

@ -1,6 +1,7 @@
package alerting
import (
"net/http"
"testing"
"time"
@ -15,31 +16,8 @@ import (
"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{
var (
promGroup1 = apimodels.PrometheusRuleGroup{
Name: "test-group-1",
Interval: prommodel.Duration(60 * time.Second),
Rules: []apimodels.PrometheusRule{
@ -80,7 +58,7 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) {
},
}
promGroup2 := apimodels.PrometheusRuleGroup{
promGroup2 = apimodels.PrometheusRuleGroup{
Name: "test-group-2",
Interval: prommodel.Duration(60 * time.Second),
Rules: []apimodels.PrometheusRule{
@ -99,24 +77,128 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) {
},
}
promGroup3 = apimodels.PrometheusRuleGroup{
Name: "test-group-3",
Interval: prommodel.Duration(60 * time.Second),
Rules: []apimodels.PrometheusRule{
{
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",
},
},
},
}
)
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 users 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")
createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{
DefaultOrgRole: string(org.RoleViewer),
Password: "password",
Login: "viewer",
})
viewerClient := newAlertingApiClient(grafanaListedAddr, "viewer", "password")
namespace1 := "test-namespace-1"
namespace2 := "test-namespace-2"
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)
t.Run("create rule groups and get them back", func(t *testing.T) {
_, status, body := apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil)
requireStatusCode(t, http.StatusAccepted, status, body)
_, status, body = apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup2, nil)
requireStatusCode(t, http.StatusAccepted, status, body)
// create a third group in a different namespace
_, status, body = apiClient.ConvertPrometheusPostRuleGroup(t, namespace2, ds.Body.Datasource.UID, promGroup3, nil)
requireStatusCode(t, http.StatusAccepted, status, body)
ns, _, _ := apiClient.GetAllRulesWithStatus(t)
// And a non-provisioned rule in another namespace
namespace3UID := util.GenerateShortUID()
apiClient.CreateFolder(t, namespace3UID, "folder")
createRule(t, apiClient, namespace3UID)
require.Len(t, ns[namespace], 2)
// Now get the first group
group1 := apiClient.ConvertPrometheusGetRuleGroupRules(t, namespace1, promGroup1.Name)
require.Equal(t, promGroup1, group1)
rulesByGroupName := map[string][]apimodels.GettableExtendedRuleNode{}
for _, group := range ns[namespace] {
rulesByGroupName[group.Name] = append(rulesByGroupName[group.Name], group.Rules...)
// Get namespace1
ns1 := apiClient.ConvertPrometheusGetNamespaceRules(t, namespace1)
expectedNs1 := map[string][]apimodels.PrometheusRuleGroup{
namespace1: {promGroup1, promGroup2},
}
require.Equal(t, expectedNs1, ns1)
require.Len(t, rulesByGroupName[promGroup1.Name], 3)
require.Len(t, rulesByGroupName[promGroup2.Name], 1)
// Get all namespaces
namespaces := apiClient.ConvertPrometheusGetAllRules(t)
expectedNamespaces := map[string][]apimodels.PrometheusRuleGroup{
namespace1: {promGroup1, promGroup2},
namespace2: {promGroup3},
}
require.Equal(t, expectedNamespaces, namespaces)
})
t.Run("without permissions to create folders cannot create rule groups either", func(t *testing.T) {
_, status, raw := viewerClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, nil)
requireStatusCode(t, http.StatusForbidden, status, raw)
})
}
func TestIntegrationConvertPrometheusEndpoints_CreatePausedRules(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 users 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")
ds := apiClient.CreateDatasource(t, datasources.DS_PROMETHEUS)
namespace1 := "test-namespace-1"
namespace1UID := util.GenerateShortUID()
apiClient.CreateFolder(t, namespace1UID, namespace1)
t.Run("when pausing header is set, rules should be paused", func(t *testing.T) {
tests := []struct {
@ -155,21 +237,17 @@ func TestIntegrationConvertPrometheusEndpoints(t *testing.T) {
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)
apiClient.ConvertPrometheusPostRuleGroup(t, namespace1, ds.Body.Datasource.UID, promGroup1, headers)
rulesByGroupName := map[string][]apimodels.GettableExtendedRuleNode{}
for _, group := range ns[namespace] {
rulesByGroupName[group.Name] = append(rulesByGroupName[group.Name], group.Rules...)
}
gr, _, _ := apiClient.GetRulesGroupWithStatus(t, namespace1UID, promGroup1.Name)
require.Len(t, rulesByGroupName[promGroup1.Name], 3)
require.Len(t, gr.Rules, 3)
pausedRecordingRules := 0
pausedAlertRules := 0
for _, rule := range rulesByGroupName[promGroup1.Name] {
for _, rule := range gr.Rules {
if rule.GrafanaManagedAlert.IsPaused {
if rule.GrafanaManagedAlert.Record != nil {
pausedRecordingRules++

@ -546,14 +546,14 @@ func (a apiClient) PostSilence(t *testing.T, s apimodels.PostableSilence) (apimo
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/silences", a.url), bytes.NewReader(b))
require.NoError(t, err)
req.Header.Set("Content-Type", "application/json")
return sendRequest[apimodels.PostSilencesOKBody](t, req, http.StatusAccepted)
return sendRequestJSON[apimodels.PostSilencesOKBody](t, req, http.StatusAccepted)
}
func (a apiClient) GetSilence(t *testing.T, id string) (apimodels.GettableSilence, int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/silence/%s", a.url, id), nil)
require.NoError(t, err)
return sendRequest[apimodels.GettableSilence](t, req, http.StatusOK)
return sendRequestJSON[apimodels.GettableSilence](t, req, http.StatusOK)
}
func (a apiClient) GetSilences(t *testing.T, filters ...string) (apimodels.GettableSilences, int, string) {
@ -568,7 +568,7 @@ func (a apiClient) GetSilences(t *testing.T, filters ...string) (apimodels.Getta
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
require.NoError(t, err)
return sendRequest[apimodels.GettableSilences](t, req, http.StatusOK)
return sendRequestJSON[apimodels.GettableSilences](t, req, http.StatusOK)
}
func (a apiClient) DeleteSilence(t *testing.T, id string) (any, int, string) {
@ -580,7 +580,7 @@ func (a apiClient) DeleteSilence(t *testing.T, id string) (any, int, string) {
Message string `json:"message"`
}
return sendRequest[dynamic](t, req, http.StatusOK)
return sendRequestJSON[dynamic](t, req, http.StatusOK)
}
func (a apiClient) GetRulesGroup(t *testing.T, folder string, group string) (apimodels.RuleGroupConfigResponse, int) {
@ -694,7 +694,7 @@ func (a apiClient) GetRuleGroupProvisioning(t *testing.T, folderUID string, grou
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/provisioning/folder/%s/rule-groups/%s", a.url, folderUID, groupName), nil)
require.NoError(t, err)
return sendRequest[apimodels.AlertRuleGroup](t, req, http.StatusOK)
return sendRequestJSON[apimodels.AlertRuleGroup](t, req, http.StatusOK)
}
func (a apiClient) CreateOrUpdateRuleGroupProvisioning(t *testing.T, group apimodels.AlertRuleGroup) (apimodels.AlertRuleGroup, int, string) {
@ -709,7 +709,7 @@ func (a apiClient) CreateOrUpdateRuleGroupProvisioning(t *testing.T, group apimo
require.NoError(t, err)
req.Header.Add("Content-Type", "application/json")
return sendRequest[apimodels.AlertRuleGroup](t, req, http.StatusOK)
return sendRequestJSON[apimodels.AlertRuleGroup](t, req, http.StatusOK)
}
func (a apiClient) SubmitRuleForBacktesting(t *testing.T, config apimodels.BacktestConfig) (int, string) {
@ -808,7 +808,7 @@ func (a apiClient) GetAllMuteTimingsWithStatus(t *testing.T) (apimodels.MuteTimi
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/provisioning/mute-timings", a.url), nil)
require.NoError(t, err)
return sendRequest[apimodels.MuteTimings](t, req, http.StatusOK)
return sendRequestJSON[apimodels.MuteTimings](t, req, http.StatusOK)
}
func (a apiClient) GetMuteTimingByNameWithStatus(t *testing.T, name string) (apimodels.MuteTimeInterval, int, string) {
@ -817,7 +817,7 @@ func (a apiClient) GetMuteTimingByNameWithStatus(t *testing.T, name string) (api
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/provisioning/mute-timings/%s", a.url, name), nil)
require.NoError(t, err)
return sendRequest[apimodels.MuteTimeInterval](t, req, http.StatusOK)
return sendRequestJSON[apimodels.MuteTimeInterval](t, req, http.StatusOK)
}
func (a apiClient) CreateMuteTimingWithStatus(t *testing.T, interval apimodels.MuteTimeInterval) (apimodels.MuteTimeInterval, int, string) {
@ -832,7 +832,7 @@ func (a apiClient) CreateMuteTimingWithStatus(t *testing.T, interval apimodels.M
req.Header.Add("Content-Type", "application/json")
require.NoError(t, err)
return sendRequest[apimodels.MuteTimeInterval](t, req, http.StatusCreated)
return sendRequestJSON[apimodels.MuteTimeInterval](t, req, http.StatusCreated)
}
func (a apiClient) EnsureMuteTiming(t *testing.T, interval apimodels.MuteTimeInterval) {
@ -854,7 +854,7 @@ func (a apiClient) UpdateMuteTimingWithStatus(t *testing.T, interval apimodels.M
req.Header.Add("Content-Type", "application/json")
require.NoError(t, err)
return sendRequest[apimodels.MuteTimeInterval](t, req, http.StatusAccepted)
return sendRequestJSON[apimodels.MuteTimeInterval](t, req, http.StatusAccepted)
}
func (a apiClient) DeleteMuteTimingWithStatus(t *testing.T, name string) (int, string) {
@ -908,7 +908,7 @@ func (a apiClient) GetRouteWithStatus(t *testing.T) (apimodels.Route, int, strin
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/provisioning/policies", a.url), nil)
require.NoError(t, err)
return sendRequest[apimodels.Route](t, req, http.StatusOK)
return sendRequestJSON[apimodels.Route](t, req, http.StatusOK)
}
func (a apiClient) GetRoute(t *testing.T) apimodels.Route {
@ -989,7 +989,7 @@ func (a apiClient) GetRuleHistoryWithStatus(t *testing.T, ruleUID string) (data.
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
require.NoError(t, err)
return sendRequest[data.Frame](t, req, http.StatusOK)
return sendRequestJSON[data.Frame](t, req, http.StatusOK)
}
func (a apiClient) GetAllTimeIntervalsWithStatus(t *testing.T) ([]apimodels.GettableTimeIntervals, int, string) {
@ -998,7 +998,7 @@ func (a apiClient) GetAllTimeIntervalsWithStatus(t *testing.T) ([]apimodels.Gett
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/notifications/time-intervals", a.url), nil)
require.NoError(t, err)
return sendRequest[[]apimodels.GettableTimeIntervals](t, req, http.StatusOK)
return sendRequestJSON[[]apimodels.GettableTimeIntervals](t, req, http.StatusOK)
}
func (a apiClient) GetTimeIntervalByNameWithStatus(t *testing.T, name string) (apimodels.GettableTimeIntervals, int, string) {
@ -1007,7 +1007,7 @@ func (a apiClient) GetTimeIntervalByNameWithStatus(t *testing.T, name string) (a
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/v1/notifications/time-intervals/%s", a.url, name), nil)
require.NoError(t, err)
return sendRequest[apimodels.GettableTimeIntervals](t, req, http.StatusOK)
return sendRequestJSON[apimodels.GettableTimeIntervals](t, req, http.StatusOK)
}
func (a apiClient) CreateReceiverWithStatus(t *testing.T, receiver apimodels.EmbeddedContactPoint) (apimodels.EmbeddedContactPoint, int, string) {
@ -1022,7 +1022,7 @@ func (a apiClient) CreateReceiverWithStatus(t *testing.T, receiver apimodels.Emb
req.Header.Add("Content-Type", "application/json")
require.NoError(t, err)
return sendRequest[apimodels.EmbeddedContactPoint](t, req, http.StatusAccepted)
return sendRequestJSON[apimodels.EmbeddedContactPoint](t, req, http.StatusAccepted)
}
func (a apiClient) EnsureReceiver(t *testing.T, receiver apimodels.EmbeddedContactPoint) {
@ -1075,33 +1075,33 @@ func (a apiClient) GetAlertmanagerConfigWithStatus(t *testing.T) (apimodels.Gett
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/alertmanager/grafana/config/api/v1/alerts", a.url), nil)
require.NoError(t, err)
return sendRequest[apimodels.GettableUserConfig](t, req, http.StatusOK)
return sendRequestJSON[apimodels.GettableUserConfig](t, req, http.StatusOK)
}
func (a apiClient) GetActiveAlertsWithStatus(t *testing.T) (apimodels.AlertGroups, int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/alertmanager/grafana/api/v2/alerts/groups", a.url), nil)
require.NoError(t, err)
return sendRequest[apimodels.AlertGroups](t, req, http.StatusOK)
return sendRequestJSON[apimodels.AlertGroups](t, req, http.StatusOK)
}
func (a apiClient) GetRuleVersionsWithStatus(t *testing.T, ruleUID string) (apimodels.GettableRuleVersions, int, string) {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/ruler/grafana/api/v1/rule/%s/versions", a.url, ruleUID), nil)
require.NoError(t, err)
return sendRequest[apimodels.GettableRuleVersions](t, req, http.StatusOK)
return sendRequestJSON[apimodels.GettableRuleVersions](t, req, http.StatusOK)
}
func (a apiClient) GetRuleByUID(t *testing.T, ruleUID string) apimodels.GettableExtendedRuleNode {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/ruler/grafana/api/v1/rule/%s", a.url, ruleUID), nil)
require.NoError(t, err)
rule, status, raw := sendRequest[apimodels.GettableExtendedRuleNode](t, req, http.StatusOK)
rule, status, raw := sendRequestJSON[apimodels.GettableExtendedRuleNode](t, req, http.StatusOK)
requireStatusCode(t, http.StatusOK, status, raw)
return rule
}
func (a apiClient) ConvertPrometheusPostRuleGroup(t *testing.T, namespaceTitle, datasourceUID string, promGroup apimodels.PrometheusRuleGroup, headers map[string]string) {
func (a apiClient) ConvertPrometheusPostRuleGroup(t *testing.T, namespaceTitle, datasourceUID string, promGroup apimodels.PrometheusRuleGroup, headers map[string]string) (apimodels.ConvertPrometheusResponse, int, string) {
t.Helper()
data, err := yaml.Marshal(promGroup)
@ -1116,30 +1116,85 @@ func (a apiClient) ConvertPrometheusPostRuleGroup(t *testing.T, namespaceTitle,
req.Header.Add(key, value)
}
_, status, raw := sendRequest[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted)
requireStatusCode(t, http.StatusAccepted, status, raw)
return sendRequestJSON[apimodels.ConvertPrometheusResponse](t, req, http.StatusAccepted)
}
func (a apiClient) ConvertPrometheusGetRuleGroupRules(t *testing.T, namespaceTitle, groupName string) apimodels.PrometheusRuleGroup {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/convert/prometheus/config/v1/rules/%s/%s", a.url, namespaceTitle, groupName), nil)
require.NoError(t, err)
rule, status, raw := sendRequestYAML[apimodels.PrometheusRuleGroup](t, req, http.StatusOK)
requireStatusCode(t, http.StatusOK, status, raw)
return rule
}
func sendRequest[T any](t *testing.T, req *http.Request, successStatusCode int) (T, int, string) {
func (a apiClient) ConvertPrometheusGetNamespaceRules(t *testing.T, namespaceTitle string) map[string][]apimodels.PrometheusRuleGroup {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/convert/prometheus/config/v1/rules/%s", a.url, namespaceTitle), nil)
require.NoError(t, err)
ns, status, raw := sendRequestYAML[map[string][]apimodels.PrometheusRuleGroup](t, req, http.StatusOK)
requireStatusCode(t, http.StatusOK, status, raw)
return ns
}
func (a apiClient) ConvertPrometheusGetAllRules(t *testing.T) map[string][]apimodels.PrometheusRuleGroup {
t.Helper()
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/api/convert/prometheus/config/v1/rules", a.url), nil)
require.NoError(t, err)
result, status, raw := sendRequestYAML[map[string][]apimodels.PrometheusRuleGroup](t, req, http.StatusOK)
requireStatusCode(t, http.StatusOK, status, raw)
return result
}
func sendRequestRaw(t *testing.T, req *http.Request) ([]byte, int, error) {
t.Helper()
client := &http.Client{}
resp, err := client.Do(req)
require.NoError(t, err)
if err != nil {
return nil, 0, err
}
defer func() {
_ = resp.Body.Close()
}()
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
if err != nil {
return nil, 0, err
}
return body, resp.StatusCode, nil
}
func sendRequestJSON[T any](t *testing.T, req *http.Request, successStatusCode int) (T, int, string) {
t.Helper()
var result T
if resp.StatusCode != successStatusCode {
return result, resp.StatusCode, string(body)
body, statusCode, err := sendRequestRaw(t, req)
require.NoError(t, err)
if statusCode != successStatusCode {
return result, statusCode, string(body)
}
err = json.Unmarshal(body, &result)
require.NoError(t, err)
return result, resp.StatusCode, string(body)
return result, statusCode, string(body)
}
func sendRequestYAML[T any](t *testing.T, req *http.Request, successStatusCode int) (T, int, string) {
t.Helper()
var result T
body, statusCode, err := sendRequestRaw(t, req)
require.NoError(t, err)
if statusCode != successStatusCode {
return result, statusCode, string(body)
}
err = yaml.Unmarshal(body, &result)
require.NoError(t, err)
return result, statusCode, string(body)
}
func requireStatusCode(t *testing.T, expected, actual int, response string) {

@ -22771,6 +22771,7 @@
}
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"type": "array",
"items": {
"type": "object",

@ -12838,6 +12838,7 @@
"type": "object"
},
"gettableAlerts": {
"description": "GettableAlerts gettable alerts",
"items": {
"$ref": "#/components/schemas/gettableAlert"
},

Loading…
Cancel
Save