package prom import ( "fmt" "maps" "time" "github.com/google/uuid" "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/services/datasources" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/util" ) const ( // ruleUIDLabel is a special label that can be used to set a custom UID for a Prometheus // alert rule when converting it to a Grafana alert rule. If this label is not present, // a stable UID will be generated automatically based on the rule's data. ruleUIDLabel = "__grafana_alert_rule_uid__" queryRefID = "query" prometheusMathRefID = "prometheus_math" thresholdRefID = "threshold" ) var ( ErrInvalidDatasourceType = errutil.ValidationFailed( "alerting.invalidDatasourceType", errutil.WithPublicMessage("Datasource type must be Prometheus or Loki to import rules."), ) ErrInvalidTargetDatasourceType = errutil.ValidationFailed( "alerting.invalidTargetDatasourceType", errutil.WithPublicMessage("Target datasource type must be Prometheus for recording rules."), ) ) // Config defines the configuration options for the Prometheus to Grafana rules converter. type Config struct { // DataSourceUID is the UID of the datasource the rules are querying. DatasourceUID string DatasourceType string // TargetDatasourceUID is the UID of the datasource the recording rules are writing to. // If not set, it defaults to DataSourceUID. TargetDatasourceUID string TargetDatasourceType 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 NoDataState models.NoDataState // KeepOriginalRuleDefinition indicates whether the original Prometheus rule definition // if saved to the alert rule metadata. If not, then it will not be possible to convert // the alert rule back to Prometheus format. KeepOriginalRuleDefinition *bool RecordingRules RulesConfig AlertRules RulesConfig NotificationSettings []models.NotificationSettings // ExtraLabels are labels that will be added to all rules during conversion. // These labels have the lowest precedence and can be overridden by group or rule labels. ExtraLabels map[string]string } // RulesConfig contains configuration that applies to either recording or alerting rules. type RulesConfig struct { IsPaused bool } var ( defaultTimeRange = 600 * time.Second defaultEvaluationOffset = 0 * time.Minute defaultConfig = Config{ FromTimeRange: &defaultTimeRange, EvaluationOffset: &defaultEvaluationOffset, ExecErrState: models.OkErrState, NoDataState: models.OK, KeepOriginalRuleDefinition: util.Pointer(true), } ) type Converter struct { cfg Config } // NewConverter creates a new Converter instance with the provided configuration. // It validates the configuration and returns an error if any required fields are missing // or if the configuration is invalid. func NewConverter(cfg Config) (*Converter, error) { if cfg.DatasourceUID == "" { return nil, fmt.Errorf("datasource UID is required") } if cfg.TargetDatasourceUID == "" { cfg.TargetDatasourceUID = cfg.DatasourceUID cfg.TargetDatasourceType = cfg.DatasourceType } 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 } if cfg.EvaluationOffset == nil { cfg.EvaluationOffset = defaultConfig.EvaluationOffset } if cfg.ExecErrState == "" { cfg.ExecErrState = defaultConfig.ExecErrState } if cfg.NoDataState == "" { cfg.NoDataState = defaultConfig.NoDataState } if cfg.KeepOriginalRuleDefinition == nil { cfg.KeepOriginalRuleDefinition = defaultConfig.KeepOriginalRuleDefinition } if cfg.DatasourceType != datasources.DS_PROMETHEUS && cfg.DatasourceType != datasources.DS_LOKI { return nil, ErrInvalidDatasourceType.Errorf("invalid datasource type: %s, must be prometheus or loki", 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) { if err := group.Validate(); err != nil { return nil, 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 (p *Converter) convertRuleGroup(orgID int64, namespaceUID string, promGroup PrometheusRuleGroup) (*models.AlertRuleGroup, error) { 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, 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()) uid, err := getUID(orgID, namespaceUID, promGroup.Name, i, rule) if err != nil { return nil, fmt.Errorf("failed to generate UID for rule '%s': %w", gr.Title, err) } gr.UID = uid rules = append(rules, gr) } result := &models.AlertRuleGroup{ FolderUID: namespaceUID, Interval: int64(interval.Seconds()), Rules: rules, Title: promGroup.Name, } return result, nil } // getUID returns a UID for a Prometheus rule. // If the rule has a special label its value is used. // Otherwise, a stable UUID is generated by using a hash of the rule's data. func getUID(orgID int64, namespaceUID string, group string, position int, promRule PrometheusRule) (string, error) { if uid, ok := promRule.Labels[ruleUIDLabel]; ok { if err := util.ValidateUID(uid); err != nil { return "", fmt.Errorf("invalid UID label value: %s; %w", uid, err) } return uid, nil } // Generate stable UUID based on the orgID, namespace, group and position. uidData := fmt.Sprintf("%d|%s|%s|%d", orgID, namespaceUID, group, position) u := uuid.NewSHA1(uuid.NameSpaceOID, []byte(uidData)) return u.String(), nil } func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup PrometheusRuleGroup, rule PrometheusRule) (models.AlertRule, error) { var forInterval time.Duration if rule.For != nil { forInterval = time.Duration(*rule.For) } var keepFiringFor time.Duration if rule.KeepFiringFor != nil { keepFiringFor = time.Duration(*rule.KeepFiringFor) } var query []models.AlertQuery var title string var isPaused bool var record *models.Record var err error isRecordingRule := rule.Record != "" query, err = p.createQuery(rule.Expr, isRecordingRule, promGroup) if err != nil { return models.AlertRule{}, err } if isRecordingRule { if p.cfg.TargetDatasourceType != datasources.DS_PROMETHEUS { return models.AlertRule{}, ErrInvalidTargetDatasourceType.Errorf("invalid target datasource type: %s, must be prometheus", p.cfg.TargetDatasourceType) } record = &models.Record{ From: queryRefID, Metric: rule.Record, TargetDatasourceUID: p.cfg.TargetDatasourceUID, } isPaused = p.cfg.RecordingRules.IsPaused title = rule.Record } else { isPaused = p.cfg.AlertRules.IsPaused title = rule.Alert } labels := make(map[string]string, len(rule.Labels)+len(promGroup.Labels)+len(p.cfg.ExtraLabels)+1) if !isRecordingRule { maps.Copy(labels, p.cfg.ExtraLabels) } maps.Copy(labels, promGroup.Labels) maps.Copy(labels, rule.Labels) // Save the merged group-level + rule-level labels to the original rule, // to ensure that they are saved to the original YAML rule definition. if rule.Labels == nil { rule.Labels = make(map[string]string) } maps.Copy(rule.Labels, labels) // Add a special label to indicate that this rule was converted from a Prometheus rule. labels[models.ConvertedPrometheusRuleLabel] = "true" 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: query, Condition: query[len(query)-1].RefID, NoDataState: p.cfg.NoDataState, ExecErrState: p.cfg.ExecErrState, Annotations: rule.Annotations, Labels: labels, For: forInterval, KeepFiringFor: keepFiringFor, RuleGroup: promGroup.Name, IsPaused: isPaused, Record: record, } if !isRecordingRule { result.NotificationSettings = p.cfg.NotificationSettings // MissingSeriesEvalsToResolve is set to 1 to match the Prometheus behaviour. // Prometheus resolves alerts as soon as the series disappears. // By setting this value to 1 we ensure that the alert is resolved on the first evaluation // that doesn't have the series. result.MissingSeriesEvalsToResolve = util.Pointer[int64](1) } if p.cfg.KeepOriginalRuleDefinition != nil && *p.cfg.KeepOriginalRuleDefinition { result.Metadata.PrometheusStyleRule = &models.PrometheusStyleRule{ OriginalRuleDefinition: string(originalRuleDefinition), } } return result, nil } // createQuery constructs the alert query nodes for a given Prometheus rule expression. // It returns a slice of AlertQuery that represent the evaluation steps for the rule. // // For recording rules it generates a single query node that // executes the PromQL query in the configured datasource. // // For alerting rules, it generates three query nodes: // 1. Query Node (query): Executes the PromQL query using the configured datasource. // 2. Math Node (prometheus_math): Applies a math expression "is_number($query) || is_nan($query) || is_inf($query)". // 3. Threshold Node (threshold): Gets the result from the math node and checks that it's greater than 0. // // This is needed to ensure that we keep the Prometheus behaviour, where any returned result // is considered alerting, and only when the query returns no data is the alert treated as normal. func (p *Converter) createQuery(expr string, isRecordingRule bool, promGroup PrometheusRuleGroup) ([]models.AlertQuery, error) { // If evaluation offset is set on the group level, use that, otherwise use the global evaluation offset. var evaluationOffset time.Duration if promGroup.QueryOffset != nil { evaluationOffset = time.Duration(*promGroup.QueryOffset) } else { evaluationOffset = *p.cfg.EvaluationOffset } queryNode, err := createQueryNode(p.cfg.DatasourceUID, p.cfg.DatasourceType, expr, *p.cfg.FromTimeRange, evaluationOffset) if err != nil { return nil, err } if isRecordingRule { return []models.AlertQuery{queryNode}, nil } mathNode, err := createMathNode() if err != nil { return nil, err } thresholdNode, err := createThresholdNode() if err != nil { return nil, err } return []models.AlertQuery{queryNode, mathNode, thresholdNode}, nil }