diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index f64b17bfc5e..6aa6514e979 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -21,7 +21,9 @@ const ( ) const ( - queryRefID = "query" + // QueryRefID is the ID used for the main query in converted Prometheus rules. + // It is exported to be used in template rendering for Prometheus compatibility mode. + QueryRefID = "query" prometheusMathRefID = "prometheus_math" thresholdRefID = "threshold" ) @@ -198,7 +200,7 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom if isRecordingRule { record = &models.Record{ - From: queryRefID, + From: QueryRefID, Metric: rule.Record, TargetDatasourceUID: p.cfg.DatasourceUID, } @@ -220,6 +222,17 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom labels := make(map[string]string, len(rule.Labels)+len(promGroup.Labels)) maps.Copy(labels, promGroup.Labels) maps.Copy(labels, rule.Labels) + err = convertTemplates(labels) + if err != nil { + return models.AlertRule{}, fmt.Errorf("failed to convert labels: %w", err) + } + + annotations := make(map[string]string, len(rule.Annotations)) + maps.Copy(annotations, rule.Annotations) + err = convertTemplates(annotations) + if err != nil { + return models.AlertRule{}, fmt.Errorf("failed to convert annotations: %w", err) + } originalRuleDefinition, err := yaml.Marshal(rule) if err != nil { @@ -234,7 +247,7 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom Condition: query[len(query)-1].RefID, NoDataState: p.cfg.NoDataState, ExecErrState: p.cfg.ExecErrState, - Annotations: rule.Annotations, + Annotations: annotations, Labels: labels, For: forInterval, RuleGroup: promGroup.Name, diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index 2281a9da1c0..23a0a524a2a 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -196,7 +196,7 @@ func TestPrometheusRulesToGrafana(t *testing.T) { if promRule.Record != "" { require.Equal(t, fmt.Sprintf("[%s] %s", tc.promGroup.Name, promRule.Record), grafanaRule.Title) require.NotNil(t, grafanaRule.Record) - require.Equal(t, grafanaRule.Record.From, queryRefID) + require.Equal(t, grafanaRule.Record.From, QueryRefID) require.Equal(t, promRule.Record, grafanaRule.Record.Metric) require.Equal(t, tc.config.DatasourceUID, grafanaRule.Record.TargetDatasourceUID) } else { @@ -209,16 +209,23 @@ func TestPrometheusRulesToGrafana(t *testing.T) { } require.Equal(t, expectedFor, grafanaRule.For, tc.name) - expectedLabels := make(map[string]string, len(promRule.Labels)+len(tc.promGroup.Labels)) - maps.Copy(expectedLabels, tc.promGroup.Labels) - maps.Copy(expectedLabels, promRule.Labels) - uidData := fmt.Sprintf("%d|%s|%s|%d", tc.orgID, tc.namespace, tc.promGroup.Name, j) u := uuid.NewSHA1(uuid.NameSpaceOID, []byte(uidData)) require.Equal(t, u.String(), grafanaRule.UID, tc.name) + expectedLabels := make(map[string]string, len(promRule.Labels)+len(tc.promGroup.Labels)) + maps.Copy(expectedLabels, tc.promGroup.Labels) + maps.Copy(expectedLabels, promRule.Labels) require.Equal(t, expectedLabels, grafanaRule.Labels, tc.name) - require.Equal(t, promRule.Annotations, grafanaRule.Annotations, tc.name) + + var expectedAnnotations map[string]string + if promRule.Annotations == nil { + expectedAnnotations = map[string]string{} + } else { + expectedAnnotations = promRule.Annotations + } + require.Equal(t, expectedAnnotations, 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) diff --git a/pkg/services/ngalert/prom/query.go b/pkg/services/ngalert/prom/query.go index 74aed34f83f..cb9ce2f4f8d 100644 --- a/pkg/services/ngalert/prom/query.go +++ b/pkg/services/ngalert/prom/query.go @@ -25,7 +25,7 @@ func createQueryNode(datasourceUID, datasourceType, expr string, fromTimeRange, "expr": expr, "instant": true, "range": false, - "refId": queryRefID, + "refId": QueryRefID, } if datasourceType == datasources.DS_LOKI { @@ -40,7 +40,7 @@ func createQueryNode(datasourceUID, datasourceType, expr string, fromTimeRange, return models.AlertQuery{ DatasourceUID: datasourceUID, Model: modelJSON, - RefID: queryRefID, + RefID: QueryRefID, RelativeTimeRange: models.RelativeTimeRange{ From: models.Duration(fromTimeRange + evaluationOffset), To: models.Duration(0 + evaluationOffset), @@ -66,7 +66,7 @@ func createMathNode() (models.AlertQuery, error) { Type: expr.QueryTypeMath, }, MathQuery: expr.MathQuery{ - Expression: fmt.Sprintf("is_number($%[1]s) || is_nan($%[1]s) || is_inf($%[1]s)", queryRefID), + Expression: fmt.Sprintf("is_number($%[1]s) || is_nan($%[1]s) || is_inf($%[1]s)", QueryRefID), }, } diff --git a/pkg/services/ngalert/prom/template.go b/pkg/services/ngalert/prom/template.go new file mode 100644 index 00000000000..f2c04300246 --- /dev/null +++ b/pkg/services/ngalert/prom/template.go @@ -0,0 +1,132 @@ +package prom + +import ( + "reflect" + "strings" + "text/template/parse" +) + +const ( + PrometheusModeTemplateCall = "{{- _prometheusMode -}}" +) + +func convertTemplates(m map[string]string) error { + for k, v := range m { + nv, err := convertTemplate(v) + if err != nil { + return err + } + m[k] = nv + } + + return nil +} + +// convertTemplate analyzes a template string to determine if it uses Prometheus-style +// value references ($value or .Value). If it does, it adds a _prometheusMode function call +// to the beginning of the template to switch the template rendering to Prometheus compatibility mode. +// This allows templates converted from Prometheus alerting rules to continue working without modification. +func convertTemplate(tmpl string) (string, error) { + searchFor := map[string]struct{}{ + "$value": {}, + ".Value": {}, + "$.Value": {}, + } + + // Create a parser with SkipFuncCheck to avoid errors with unknown functions + p := parse.New("tmpl") + p.Mode = parse.ParseComments | parse.SkipFuncCheck + + // Add temporary prefix with the variables that are expected by the template, + // otherwise the parsing fails. + tmpPrefix := `{{$value := ""}}{{$labels := ""}}` + + tree, err := p.Parse(tmpPrefix+tmpl, "{{", "}}", map[string]*parse.Tree{}) + if err != nil { + return "", err + } + + // Search for the variables in the template + found := walkNodes(tree.Root, searchFor) + if found { + return PrometheusModeTemplateCall + tmpl, nil + } + + return tmpl, nil +} + +// walkNodes recursively traverses the AST to find all variable nodes with certain names +// and returns the boolean indicating whether they are present in the template. +func walkNodes(node parse.Node, searchFor map[string]struct{}) bool { + var found bool + + var walk func(node parse.Node) + walk = func(node parse.Node) { + if node == nil || reflect.ValueOf(node).IsNil() { + return + } + + switch n := node.(type) { + case *parse.VariableNode: + if _, ok := searchFor[strings.Join(n.Ident, ".")]; ok { + found = true + } + + case *parse.FieldNode: + if _, ok := searchFor["."+strings.Join(n.Ident, ".")]; ok { + found = true + } + + case *parse.ActionNode: + walk(n.Pipe) + + case *parse.ChainNode: + walk(n.Node) + + case *parse.CommandNode: + for _, arg := range n.Args { + walk(arg) + } + + case *parse.ListNode: + for _, child := range n.Nodes { + walk(child) + } + + case *parse.PipeNode: + for _, cmd := range n.Cmds { + walk(cmd) + } + + case *parse.IfNode: + walk(n.Pipe) + walk(n.List) + if n.ElseList != nil { + walk(n.ElseList) + } + + case *parse.RangeNode: + walk(n.Pipe) + walk(n.List) + if n.ElseList != nil { + walk(n.ElseList) + } + + case *parse.WithNode: + walk(n.Pipe) + walk(n.List) + if n.ElseList != nil { + walk(n.ElseList) + } + + case *parse.TemplateNode: + if n.Pipe != nil { + walk(n.Pipe) + } + } + } + + walk(node) + + return found +} diff --git a/pkg/services/ngalert/prom/template_test.go b/pkg/services/ngalert/prom/template_test.go new file mode 100644 index 00000000000..1b6dda97c6f --- /dev/null +++ b/pkg/services/ngalert/prom/template_test.go @@ -0,0 +1,180 @@ +package prom + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestConvertTemplates(t *testing.T) { + testCases := []struct { + name string + templates map[string]string + expected map[string]string + shouldFail bool + }{ + { + name: "simple template with $value", + templates: map[string]string{ + "template": "Value is {{$value}}", + }, + expected: map[string]string{ + "template": "{{- _prometheusMode -}}Value is {{$value}}", + }, + shouldFail: false, + }, + { + name: "simple template with .Value", + templates: map[string]string{ + "template": "Value is {{.Value}}", + }, + expected: map[string]string{ + "template": "{{- _prometheusMode -}}Value is {{.Value}}", + }, + shouldFail: false, + }, + { + name: "template with $.Value", + templates: map[string]string{ + "template": ` + {{- range .Items}} + Value: {{.Value}} + Global Value: {{$.Value}} + {{- end}} + `, + }, + expected: map[string]string{ + "template": `{{- _prometheusMode -}} + {{- range .Items}} + Value: {{.Value}} + Global Value: {{$.Value}} + {{- end}} + `, + }, + shouldFail: false, + }, + { + name: "complex template with $value in if statement", + templates: map[string]string{ + "template": "{{if gt $value 100.0}}Too high{{else}}Good{{end}}", + }, + expected: map[string]string{ + "template": "{{- _prometheusMode -}}{{if gt $value 100.0}}Too high{{else}}Good{{end}}", + }, + shouldFail: false, + }, + { + name: "complex template with .Value in if statement", + templates: map[string]string{ + "template": "{{if gt .Value 100.0}}Too high{{else}}Good{{end}}", + }, + expected: map[string]string{ + "template": "{{- _prometheusMode -}}{{if gt .Value 100.0}}Too high{{else}}Good{{end}}", + }, + shouldFail: false, + }, + { + name: "template with $value in nested structures", + templates: map[string]string{ + "template": "{{range .Values}}{{if eq . $value}}Match{{end}}{{end}}", + }, + expected: map[string]string{ + "template": "{{- _prometheusMode -}}{{range .Values}}{{if eq . $value}}Match{{end}}{{end}}", + }, + shouldFail: false, + }, + { + name: "template without $value or .Value", + templates: map[string]string{ + "template": "This template does not use the values", + }, + expected: map[string]string{ + "template": "This template does not use the values", + }, + shouldFail: false, + }, + { + name: "template with $labels", + templates: map[string]string{ + "template": "Instance: {{ $labels.instance }}", + }, + expected: map[string]string{ + "template": "Instance: {{ $labels.instance }}", + }, + shouldFail: false, + }, + { + name: "template with both $value and $labels", + templates: map[string]string{ + "template": "{{ $labels.instance }} has value {{ $value }}", + }, + expected: map[string]string{ + "template": "{{- _prometheusMode -}}{{ $labels.instance }} has value {{ $value }}", + }, + shouldFail: false, + }, + { + name: "template with multiple $value occurrences", + templates: map[string]string{ + "template": "First: {{ $value }}, Second: {{ $value }}", + }, + expected: map[string]string{ + "template": "{{- _prometheusMode -}}First: {{ $value }}, Second: {{ $value }}", + }, + shouldFail: false, + }, + { + name: "multiple templates with mixed patterns", + templates: map[string]string{ + "summary": "Instance {{ $labels.instance }} has high CPU usage", + "description": "{{ $labels.instance }} has value {{ $value }}", + }, + expected: map[string]string{ + "summary": "Instance {{ $labels.instance }} has high CPU usage", + "description": "{{- _prometheusMode -}}{{ $labels.instance }} has value {{ $value }}", + }, + shouldFail: false, + }, + { + name: "all templates with values", + templates: map[string]string{ + "summary": "Value is {{ $value }}", + "description": "Detailed value is {{ .Value }}", + }, + expected: map[string]string{ + "summary": "{{- _prometheusMode -}}Value is {{ $value }}", + "description": "{{- _prometheusMode -}}Detailed value is {{ .Value }}", + }, + shouldFail: false, + }, + { + name: "no templates with values", + templates: map[string]string{ + "summary": "Instance {{ $labels.instance }} is down", + "description": "Instance {{ $labels.instance }} has been down for more than 5 minutes", + }, + expected: map[string]string{ + "summary": "Instance {{ $labels.instance }} is down", + "description": "Instance {{ $labels.instance }} has been down for more than 5 minutes", + }, + shouldFail: false, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + input := make(map[string]string) + for k, v := range tc.templates { + input[k] = v + } + + err := convertTemplates(input) + if tc.shouldFail { + require.Error(t, err) + } else { + require.NoError(t, err) + require.Equal(t, tc.expected, input) + } + }) + } +} diff --git a/pkg/services/ngalert/state/template/template.go b/pkg/services/ngalert/state/template/template.go index ebd541d527d..da7891ea7c0 100644 --- a/pkg/services/ngalert/state/template/template.go +++ b/pkg/services/ngalert/state/template/template.go @@ -8,14 +8,16 @@ import ( "sort" "strconv" "strings" + "text/template" "time" "github.com/prometheus/common/model" "github.com/prometheus/prometheus/model/timestamp" "github.com/prometheus/prometheus/promql" - "github.com/prometheus/prometheus/template" + promTemplate "github.com/prometheus/prometheus/template" "github.com/grafana/grafana/pkg/services/ngalert/eval" + "github.com/grafana/grafana/pkg/services/ngalert/prom" ) type Labels map[string]string @@ -71,16 +73,53 @@ func NewValues(captures map[string]eval.NumberValueCapture) map[string]Value { } type Data struct { - Labels Labels - Values map[string]Value - Value string + Labels Labels + Values map[string]Value + evaluationString string + + prometheusMode bool + prometheusQueryValue string } func NewData(labels map[string]string, res eval.Result) Data { + values := NewValues(res.Values) + + value := res.EvaluationString + if v, ok := values[prom.QueryRefID]; ok { + value = fmt.Sprintf("%g", v.Value) + } + return Data{ - Labels: labels, - Values: NewValues(res.Values), - Value: res.EvaluationString, + Labels: labels, + Values: values, + evaluationString: res.EvaluationString, + prometheusQueryValue: value, + } +} + +// Value returns the value to be used in templates. +// In standard mode, it returns the full evaluation string. +// In Prometheus compatibility mode (when _prometheusMode function is called), +// it returns only the numeric value of the query result. +func (d Data) Value() string { + if d.prometheusMode { + return d.prometheusQueryValue + } else { + return d.evaluationString + } +} + +// makePrometheusModeFunc returns a template function that when called, switches the template +// rendering to Prometheus compatibility mode. In this mode, $value and .Value will return +// the numeric value of the query result instead of the full evaluation string. +// +// This function is primarily used when converting Prometheus alert rules to Grafana +// to maintain compatibility with existing Prometheus templates that expect +// $value and .Value to behave like in Prometheus. +func makePrometheusModeFunc(d *Data) func() string { + return func() string { + d.prometheusMode = true + return "" } } @@ -103,7 +142,14 @@ func Expand(ctx context.Context, name, tmpl string, data Data, externalURL *url. // add __alert_ to avoid possible conflicts with other templates name = "__alert_" + name // add variables for the labels and values to the beginning of the template - tmpl = "{{- $labels := .Labels -}}{{- $values := .Values -}}{{- $value := .Value -}}" + tmpl + + // if the template starts with {{ _prometheusMode }}, we need to add the variables after that + // to override the $value. + if strings.HasPrefix(tmpl, prom.PrometheusModeTemplateCall) { + tmpl = prom.PrometheusModeTemplateCall + "{{- $labels := .Labels -}}{{- $values := .Values -}}{{- $value := .Value -}}" + tmpl[len(prom.PrometheusModeTemplateCall):] + } else { + tmpl = "{{- $labels := .Labels -}}{{- $values := .Values -}}{{- $value := .Value -}}" + tmpl + } // ctx and queryFunc are no-ops as `query()` is not supported in Grafana queryFunc := func(context.Context, string, time.Time) (promql.Vector, error) { return nil, nil @@ -112,8 +158,11 @@ func Expand(ctx context.Context, name, tmpl string, data Data, externalURL *url. // Use missingkey=invalid so missing data shows instead of the type's default value options := []string{"missingkey=invalid"} - expander := template.NewTemplateExpander(ctx, tmpl, name, data, tm, queryFunc, externalURL, options) + expander := promTemplate.NewTemplateExpander(ctx, tmpl, name, &data, tm, queryFunc, externalURL, options) expander.Funcs(defaultFuncs) + expander.Funcs(template.FuncMap{ + "_prometheusMode": makePrometheusModeFunc(&data), + }) result, err := expander.Expand() if err != nil { diff --git a/pkg/services/ngalert/state/template/template_test.go b/pkg/services/ngalert/state/template/template_test.go index 11982b45505..99d28df0c17 100644 --- a/pkg/services/ngalert/state/template/template_test.go +++ b/pkg/services/ngalert/state/template/template_test.go @@ -3,6 +3,7 @@ package template import ( "context" "errors" + "fmt" "net/url" "testing" @@ -447,3 +448,72 @@ func TestExpandTemplate(t *testing.T) { }) } } + +func TestPrometheusModeTemplate(t *testing.T) { + ctx := context.Background() + + values := map[string]eval.NumberValueCapture{ + "A": { + Var: "A", + Labels: map[string]string{"instance": "server1"}, + Value: util.Pointer(42.5), + }, + "query": { + Var: "query", + Labels: map[string]string{"instance": "server1"}, + Value: util.Pointer(100.0), + }, + } + + result := eval.Result{ + Values: values, + EvaluationString: "[ var='A' labels={instance=server1} value=12.3 ], [ var='query' labels={instance=server1} value=100 ]", + } + + testCases := []struct { + name string + template string + expected string + }{ + { + name: "Standard template with $labels", + template: "Instance {{ $labels.instance }} has high CPU", + expected: "Instance server1 has high CPU", + }, + { + name: "Standard template with $value", + template: "Value is {{ $value }}", + expected: fmt.Sprintf("Value is %s", result.EvaluationString), + }, + { + name: "Standard template with .Value", + template: "Value is {{ $.Value }}", + expected: fmt.Sprintf("Value is %s", result.EvaluationString), + }, + { + name: "Template with prometheus mode and $value", + template: "{{- _prometheusMode -}}Value is {{ $value }}", + expected: "Value is 100", + }, + { + name: "Template with prometheus mode and .Value", + template: "{{- _prometheusMode -}}Value is {{ .Value }}", + expected: "Value is 100", + }, + { + name: "Template with prometheus mode and $.Value", + template: "{{- _prometheusMode -}}Value is {{ $.Value }}", + expected: "Value is 100", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + labels := map[string]string{"alertname": "HighCPU", "severity": "critical", "instance": "server1"} + data := NewData(labels, result) + result, err := Expand(ctx, "test", tc.template, data, nil, result.EvaluatedAt) + require.NoError(t, err) + require.Equal(t, tc.expected, result) + }) + } +}