diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index e11c5eb1add..66be69d0079 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -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)) } diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go index 6594732c04b..9f6c8b52b96 100644 --- a/pkg/services/ngalert/models/alert_rule_test.go +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -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 diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go new file mode 100644 index 00000000000..d14a37aa9f5 --- /dev/null +++ b/pkg/services/ngalert/prom/convert.go @@ -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 +} diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go new file mode 100644 index 00000000000..f175686fd3d --- /dev/null +++ b/pkg/services/ngalert/prom/convert_test.go @@ -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) +} diff --git a/pkg/services/ngalert/prom/models.go b/pkg/services/ngalert/prom/models.go new file mode 100644 index 00000000000..f7e8bbfc95b --- /dev/null +++ b/pkg/services/ngalert/prom/models.go @@ -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"` +} diff --git a/pkg/services/ngalert/prom/models_test.go b/pkg/services/ngalert/prom/models_test.go new file mode 100644 index 00000000000..fbf7d65a847 --- /dev/null +++ b/pkg/services/ngalert/prom/models_test.go @@ -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) + }) + } +}