Alerting: Add rule conversion package (#100224)

This commit is contained in:
Alexander Akhmetov
2025-02-12 19:38:48 +02:00
committed by GitHub
parent e2a101cde3
commit 3cc4320aa9
6 changed files with 584 additions and 6 deletions
+11 -1
View File
@@ -295,7 +295,8 @@ type AlertRule struct {
}
type AlertRuleMetadata struct {
EditorSettings EditorSettings `json:"editor_settings"`
EditorSettings EditorSettings `json:"editor_settings"`
PrometheusStyleRule *PrometheusStyleRule `json:"prometheus_style_rule,omitempty"`
}
type EditorSettings struct {
@@ -303,6 +304,10 @@ type EditorSettings struct {
SimplifiedNotificationsSection bool `json:"simplified_notifications_section"`
}
type PrometheusStyleRule struct {
OriginalRuleDefinition string `json:"original_rule_definition,omitempty"`
}
// Namespaced describes a class of resources that are stored in a specific namespace.
type Namespaced interface {
GetNamespaceUID() string
@@ -748,6 +753,11 @@ func (alertRule *AlertRule) Copy() *AlertRule {
}
}
if alertRule.Metadata.PrometheusStyleRule != nil {
prometheusStyleRule := *alertRule.Metadata.PrometheusStyleRule
result.Metadata.PrometheusStyleRule = &prometheusStyleRule
}
for _, s := range alertRule.NotificationSettings {
result.NotificationSettings = append(result.NotificationSettings, CopyNotificationSettings(s))
}
+30 -5
View File
@@ -841,7 +841,7 @@ func TestDiff(t *testing.T) {
}
})
t.Run("should detect changes in Metadata", func(t *testing.T) {
t.Run("should detect changes in Metadata.EditorSettings", func(t *testing.T) {
rule1 := RuleGen.With(RuleGen.WithMetadata(AlertRuleMetadata{EditorSettings: EditorSettings{
SimplifiedQueryAndExpressionsSection: false,
SimplifiedNotificationsSection: false,
@@ -858,6 +858,21 @@ func TestDiff(t *testing.T) {
"Metadata.EditorSettings.SimplifiedNotificationsSection",
}, diff.Paths())
})
t.Run("should detect changes in Metadata.PrometheusStyleRule", func(t *testing.T) {
rule1 := RuleGen.With(RuleGen.WithMetadata(AlertRuleMetadata{PrometheusStyleRule: &PrometheusStyleRule{
OriginalRuleDefinition: "data",
}})).GenerateRef()
rule2 := CopyRule(rule1, RuleGen.WithMetadata(AlertRuleMetadata{PrometheusStyleRule: &PrometheusStyleRule{
OriginalRuleDefinition: "updated data",
}}))
diff := rule1.Diff(rule2)
assert.ElementsMatch(t, []string{
"Metadata.PrometheusStyleRule.OriginalRuleDefinition",
}, diff.Paths())
})
}
func TestSortByGroupIndex(t *testing.T) {
@@ -940,11 +955,21 @@ func TestAlertRuleGetKeyWithGroup(t *testing.T) {
}
func TestAlertRuleCopy(t *testing.T) {
for i := 0; i < 100; i++ {
rule := RuleGen.GenerateRef()
t.Run("should return a copy of the rule", func(t *testing.T) {
for i := 0; i < 100; i++ {
rule := RuleGen.GenerateRef()
copied := rule.Copy()
require.Empty(t, rule.Diff(copied))
}
})
t.Run("should create a copy of the prometheus rule definition from the metadata", func(t *testing.T) {
rule := RuleGen.With(RuleGen.WithMetadata(AlertRuleMetadata{PrometheusStyleRule: &PrometheusStyleRule{
OriginalRuleDefinition: "data",
}})).GenerateRef()
copied := rule.Copy()
require.Empty(t, rule.Diff(copied))
}
require.NotSame(t, rule.Metadata.PrometheusStyleRule, copied.Metadata.PrometheusStyleRule)
})
}
// This test makes sure the default generator
+220
View File
@@ -0,0 +1,220 @@
package prom
import (
"encoding/json"
"fmt"
"time"
"gopkg.in/yaml.v3"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
type Config struct {
DatasourceUID string
DatasourceType string
FromTimeRange *time.Duration
EvaluationOffset *time.Duration
ExecErrState models.ExecutionErrorState
NoDataState models.NoDataState
RecordingRules RulesConfig
AlertRules RulesConfig
}
type RulesConfig struct {
IsPaused bool
}
var (
defaultTimeRange = 600 * time.Second
defaultEvaluationOffset = 0 * time.Minute
defaultConfig = Config{
FromTimeRange: &defaultTimeRange,
EvaluationOffset: &defaultEvaluationOffset,
ExecErrState: models.ErrorErrState,
NoDataState: models.NoData,
}
)
type Converter struct {
cfg Config
}
func NewConverter(cfg Config) (*Converter, error) {
if cfg.DatasourceUID == "" {
return nil, fmt.Errorf("datasource UID is required")
}
if cfg.DatasourceType == "" {
return nil, fmt.Errorf("datasource type is required")
}
if cfg.FromTimeRange == nil {
cfg.FromTimeRange = defaultConfig.FromTimeRange
}
if cfg.EvaluationOffset == nil {
cfg.EvaluationOffset = defaultConfig.EvaluationOffset
}
if cfg.ExecErrState == "" {
cfg.ExecErrState = defaultConfig.ExecErrState
}
if cfg.NoDataState == "" {
cfg.NoDataState = defaultConfig.NoDataState
}
if cfg.DatasourceType != datasources.DS_PROMETHEUS && cfg.DatasourceType != datasources.DS_LOKI {
return nil, fmt.Errorf("invalid datasource type: %s", cfg.DatasourceType)
}
return &Converter{
cfg: cfg,
}, nil
}
// 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)
}
}
grafanaGroup, err := p.convertRuleGroup(orgID, namespaceUID, group)
if err != nil {
return nil, fmt.Errorf("failed to convert rule group '%s': %w", group.Name, err)
}
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)
for i, rule := range promGroup.Rules {
gr, err := p.convertRule(orgID, namespaceUID, promGroup.Name, rule)
if err != nil {
return nil, fmt.Errorf("failed to convert Prometheus rule '%s' to Grafana rule: %w", rule.Alert, err)
}
gr.RuleGroupIndex = i + 1
gr.IntervalSeconds = int64(interval.Seconds())
// Check rule title uniqueness within the group.
uniqueNames[gr.Title]++
if val := uniqueNames[gr.Title]; val > 1 {
gr.Title = fmt.Sprintf("%s (%d)", gr.Title, val)
}
rules = append(rules, gr)
}
result := &models.AlertRuleGroup{
FolderUID: namespaceUID,
Interval: int64(interval.Seconds()),
Rules: rules,
Title: promGroup.Name,
}
return result, nil
}
func (p *Converter) convertRule(orgID int64, namespaceUID, group string, rule PrometheusRule) (models.AlertRule, error) {
var forInterval time.Duration
if rule.For != nil {
forInterval = time.Duration(*rule.For)
}
queryNode, err := createAlertQueryNode(p.cfg.DatasourceUID, p.cfg.DatasourceType, rule.Expr, *p.cfg.FromTimeRange, *p.cfg.EvaluationOffset)
if err != nil {
return models.AlertRule{}, err
}
var title string
if rule.Record != "" {
title = rule.Record
} else {
title = rule.Alert
}
labels := make(map[string]string, len(rule.Labels)+1)
for k, v := range rule.Labels {
labels[k] = v
}
originalRuleDefinition, err := yaml.Marshal(rule)
if err != nil {
return models.AlertRule{}, fmt.Errorf("failed to marshal original rule definition: %w", err)
}
result := models.AlertRule{
OrgID: orgID,
NamespaceUID: namespaceUID,
Title: title,
Data: []models.AlertQuery{queryNode},
Condition: "A",
NoDataState: p.cfg.NoDataState,
ExecErrState: p.cfg.ExecErrState,
Annotations: rule.Annotations,
Labels: labels,
For: forInterval,
RuleGroup: group,
Metadata: models.AlertRuleMetadata{
PrometheusStyleRule: &models.PrometheusStyleRule{
OriginalRuleDefinition: string(originalRuleDefinition),
},
},
}
if rule.Record != "" {
result.Record = &models.Record{
From: "A",
Metric: rule.Record,
}
result.IsPaused = p.cfg.RecordingRules.IsPaused
} else {
result.IsPaused = p.cfg.AlertRules.IsPaused
}
return result, nil
}
func createAlertQueryNode(datasourceUID, datasourceType, expr string, fromTimeRange, evaluationOffset time.Duration) (models.AlertQuery, error) {
modelData := map[string]interface{}{
"datasource": map[string]interface{}{
"type": datasourceType,
"uid": datasourceUID,
},
"expr": expr,
"instant": true,
"range": false,
"refId": "A",
}
if datasourceType == datasources.DS_LOKI {
modelData["queryType"] = "instant"
}
modelJSON, err := json.Marshal(modelData)
if err != nil {
return models.AlertQuery{}, err
}
return models.AlertQuery{
DatasourceUID: datasourceUID,
Model: modelJSON,
RefID: "A",
RelativeTimeRange: models.RelativeTimeRange{
From: models.Duration(fromTimeRange + evaluationOffset),
To: models.Duration(0 + evaluationOffset),
},
}, nil
}
+192
View File
@@ -0,0 +1,192 @@
package prom
import (
"testing"
"time"
prommodel "github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
"github.com/grafana/grafana/pkg/services/datasources"
"github.com/grafana/grafana/pkg/services/ngalert/models"
)
func TestPrometheusRulesToGrafana(t *testing.T) {
fiveMin := prommodel.Duration(5 * time.Minute)
testCases := []struct {
name string
orgID int64
namespace string
promGroup PrometheusRuleGroup
config Config
expectError bool
}{
{
name: "valid rule group",
orgID: 1,
namespace: "some-namespace-uid",
promGroup: PrometheusRuleGroup{
Name: "test-group-1",
Interval: prommodel.Duration(10 * time.Second),
Rules: []PrometheusRule{
{
Alert: "alert-1",
Expr: "cpu_usage > 80",
For: &fiveMin,
Labels: map[string]string{
"severity": "critical",
},
Annotations: map[string]string{
"summary": "CPU usage is critical",
},
},
},
},
expectError: false,
},
{
name: "rules with keep_firing_for are not supported",
orgID: 1,
namespace: "namespaceUID",
promGroup: PrometheusRuleGroup{
Name: "test-group-1",
Interval: prommodel.Duration(1 * time.Minute),
Rules: []PrometheusRule{
{
Alert: "alert-1",
Expr: "up == 0",
KeepFiringFor: &fiveMin,
},
},
},
expectError: true,
},
{
name: "rule with empty interval",
orgID: 1,
namespace: "namespaceUID",
promGroup: PrometheusRuleGroup{
Name: "test-group-1",
Rules: []PrometheusRule{
{
Alert: "alert-1",
Expr: "up == 0",
},
},
},
expectError: false,
},
{
name: "recording rule",
orgID: 1,
namespace: "namespaceUID",
promGroup: PrometheusRuleGroup{
Name: "test-group-1",
Rules: []PrometheusRule{
{
Record: "some_metric",
Expr: "sum(rate(http_requests_total[5m]))",
},
},
},
expectError: false,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
tc.config.DatasourceUID = "datasource-uid"
tc.config.DatasourceType = datasources.DS_PROMETHEUS
converter, err := NewConverter(tc.config)
require.NoError(t, err)
grafanaGroup, err := converter.PrometheusRulesToGrafana(tc.orgID, tc.namespace, tc.promGroup)
if tc.expectError {
require.Error(t, err, tc.name)
return
}
require.NoError(t, err, tc.name)
require.Equal(t, tc.promGroup.Name, grafanaGroup.Title, tc.name)
expectedInterval := int64(time.Duration(tc.promGroup.Interval).Seconds())
require.Equal(t, expectedInterval, grafanaGroup.Interval, tc.name)
require.Equal(t, len(tc.promGroup.Rules), len(grafanaGroup.Rules), tc.name)
for j, promRule := range tc.promGroup.Rules {
grafanaRule := grafanaGroup.Rules[j]
if promRule.Record != "" {
require.Equal(t, promRule.Record, grafanaRule.Title)
} else {
require.Equal(t, promRule.Alert, grafanaRule.Title)
}
var expectedFor time.Duration
if promRule.For != nil {
expectedFor = time.Duration(*promRule.For)
}
require.Equal(t, expectedFor, grafanaRule.For, tc.name)
expectedLabels := make(map[string]string, len(promRule.Labels)+1)
for k, v := range promRule.Labels {
expectedLabels[k] = v
}
require.Equal(t, expectedLabels, grafanaRule.Labels, tc.name)
require.Equal(t, promRule.Annotations, grafanaRule.Annotations, tc.name)
require.Equal(t, models.Duration(0*time.Minute), grafanaRule.Data[0].RelativeTimeRange.To)
require.Equal(t, models.Duration(10*time.Minute), grafanaRule.Data[0].RelativeTimeRange.From)
originalRuleDefinition, err := yaml.Marshal(promRule)
require.NoError(t, err)
require.Equal(t, string(originalRuleDefinition), grafanaRule.Metadata.PrometheusStyleRule.OriginalRuleDefinition)
}
})
}
}
func TestPrometheusRulesToGrafanaWithDuplicateRuleNames(t *testing.T) {
cfg := Config{
DatasourceUID: "datasource-uid",
DatasourceType: datasources.DS_PROMETHEUS,
}
converter, err := NewConverter(cfg)
require.NoError(t, err)
promGroup := PrometheusRuleGroup{
Name: "test-group-1",
Interval: prommodel.Duration(10 * time.Second),
Rules: []PrometheusRule{
{
Alert: "alert",
Expr: "up",
},
{
Alert: "alert",
Expr: "up",
},
{
Alert: "another alert",
Expr: "up",
},
{
Alert: "alert",
Expr: "up",
},
},
}
group, err := converter.PrometheusRulesToGrafana(1, "namespaceUID", promGroup)
require.NoError(t, err)
require.Equal(t, "test-group-1", group.Title)
require.Len(t, group.Rules, 4)
require.Equal(t, "alert", group.Rules[0].Title)
require.Equal(t, "alert (2)", group.Rules[1].Title)
require.Equal(t, "another alert", group.Rules[2].Title)
require.Equal(t, "alert (3)", group.Rules[3].Title)
}
+25
View File
@@ -0,0 +1,25 @@
package prom
import (
prommodel "github.com/prometheus/common/model"
)
type PrometheusRulesFile struct {
Groups []PrometheusRuleGroup `yaml:"groups"`
}
type PrometheusRuleGroup struct {
Name string `yaml:"name"`
Interval prommodel.Duration `yaml:"interval"`
Rules []PrometheusRule `yaml:"rules"`
}
type PrometheusRule struct {
Alert string `yaml:"alert,omitempty"`
Expr string `yaml:"expr,omitempty"`
For *prommodel.Duration `yaml:"for,omitempty"`
KeepFiringFor *prommodel.Duration `yaml:"keep_firing_for,omitempty"`
Labels map[string]string `yaml:"labels,omitempty"`
Annotations map[string]string `yaml:"annotations,omitempty"`
Record string `yaml:"record,omitempty"`
}
+106
View File
@@ -0,0 +1,106 @@
package prom
import (
"testing"
"time"
prommodel "github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func TestPrometheusRulesFileYAML(t *testing.T) {
interval := prommodel.Duration(5 * time.Minute)
alertFor := prommodel.Duration(10 * time.Minute)
keepFiring := prommodel.Duration(15 * time.Minute)
tests := []struct {
name string
input PrometheusRulesFile
expectedYAML string
}{
{
name: "simple alert rule and a recording rule",
input: PrometheusRulesFile{
Groups: []PrometheusRuleGroup{
{
Name: "test_group",
Interval: interval,
Rules: []PrometheusRule{
{
Alert: "alert-1",
Expr: "vector(0) > 90",
For: &alertFor,
KeepFiringFor: &keepFiring,
Labels: map[string]string{
"team": "alerting",
},
Annotations: map[string]string{
"summary": "some summary",
"description": "some description",
},
},
{
Record: "vector(1)",
},
},
},
},
},
expectedYAML: `
groups:
- name: test_group
interval: 5m
rules:
- alert: alert-1
expr: vector(0) > 90
for: 10m
keep_firing_for: 15m
labels:
team: alerting
annotations:
description: some description
summary: some summary
- record: vector(1)
`,
},
{
name: "empty rules file",
input: PrometheusRulesFile{
Groups: []PrometheusRuleGroup{},
},
expectedYAML: `groups: []`,
},
{
name: "empty group",
input: PrometheusRulesFile{
Groups: []PrometheusRuleGroup{
{
Name: "empty_group",
Interval: interval,
Rules: []PrometheusRule{},
},
},
},
expectedYAML: `
groups:
- name: empty_group
interval: 5m
rules: []`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
yamlData, err := yaml.Marshal(tt.input)
require.NoError(t, err, "Failed to marshal to YAML")
require.YAMLEq(t, tt.expectedYAML, string(yamlData))
var parsed PrometheusRulesFile
err = yaml.Unmarshal(yamlData, &parsed)
require.NoError(t, err, "Failed to unmarshal from YAML")
require.Equal(t, tt.input, parsed)
})
}
}