Alerting: API to create rule groups using mimirtool (#100558)

What is this feature?

Adds an API endpoint to create alert rules with mimirtool:

- POST /convert/prometheus/config/v1/rules/{NamespaceTitle} - Accepts a single rule group in a Prometheus YAML format and creates or updates a Grafana rule group from it.

The endpoint uses the conversion package from #100224.

Key parts

The API works similarly to the provisioning API. If the rule does not exist, it will be created, otherwise updated. Any rules not present in the new group will be deleted, ensuring the group is fully synchronized with the provided configuration.

Since the API works with namespace titles (folders), the handler automatically creates a folder in the root based on the provided title if it does not exist. It also requires a special header, X-Grafana-Alerting-Datasource-UID. This header specifies which datasource to use for the new rules.

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