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

This commit is contained in:
Alexander Akhmetov
2025-02-25 15:49:08 +01:00
committed by GitHub
parent d83db31a23
commit 6eb335a8ce
18 changed files with 836 additions and 125 deletions
@@ -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 {
+2 -2
View File
@@ -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
+10 -10
View File
@@ -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"
]
+10 -10
View File
@@ -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",
+18
View File
@@ -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
+8
View File
@@ -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)
})
+32
View File
@@ -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,
+2 -1
View File
@@ -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 {