mirror of
https://github.com/prometheus/prometheus.git
synced 2025-02-21 03:16:00 -08:00
* rulefmt: add tests with YAML aliases for Alert/Record/Expr Altough somewhat discouraged in favour of using proper configuration management tools to generate full YAML, it can still be useful in some situations to use YAML anchors/aliases in rules. The current implementation is however confusing: aliases will work everywhere except on the alert/record name and expr This first commit adds (failing) tests to illustrate the issue, the next one fixes it. The YAML test file is intentionally filled with anchors and aliases. Although this is probably not representative of a real-world use case (which would have less of them), it errs on the safer side. Signed-off-by: François HORTA <fhorta@scaleway.com> * rulefmt: support YAML aliases for Alert/Record/Expr This fixes the use of YAML aliases in alert/recording rule names and expressions. A side effect of this change is that the RuleNode YAML type is no longer propagated deeper in the codebase, instead the generic Rule type can now be used. Signed-off-by: François HORTA <fhorta@scaleway.com> * rulefmt: Add test for YAML merge combined with aliases Currently this does work, but adding a test for the related functionally here makes sense. Signed-off-by: David Leadbeater <dgl@dgl.cx> * rulefmt: Rebase to latest changes Signed-off-by: David Leadbeater <dgl@dgl.cx> --------- Signed-off-by: François HORTA <fhorta@scaleway.com> Signed-off-by: David Leadbeater <dgl@dgl.cx> Co-authored-by: David Leadbeater <dgl@dgl.cx>
376 lines
10 KiB
Go
376 lines
10 KiB
Go
// Copyright 2017 The Prometheus Authors
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package rulefmt
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/prometheus/common/model"
|
|
"gopkg.in/yaml.v3"
|
|
|
|
"github.com/prometheus/prometheus/model/timestamp"
|
|
"github.com/prometheus/prometheus/promql"
|
|
"github.com/prometheus/prometheus/promql/parser"
|
|
"github.com/prometheus/prometheus/template"
|
|
)
|
|
|
|
// Error represents semantic errors on parsing rule groups.
|
|
type Error struct {
|
|
Group string
|
|
Rule int
|
|
RuleName string
|
|
Err WrappedError
|
|
}
|
|
|
|
// Error prints the error message in a formatted string.
|
|
func (err *Error) Error() string {
|
|
if err.Err.err == nil {
|
|
return ""
|
|
}
|
|
if err.Err.nodeAlt != nil {
|
|
return fmt.Sprintf("%d:%d: %d:%d: group %q, rule %d, %q: %v", err.Err.node.Line, err.Err.node.Column, err.Err.nodeAlt.Line, err.Err.nodeAlt.Column, err.Group, err.Rule, err.RuleName, err.Err.err)
|
|
}
|
|
if err.Err.node != nil {
|
|
return fmt.Sprintf("%d:%d: group %q, rule %d, %q: %v", err.Err.node.Line, err.Err.node.Column, err.Group, err.Rule, err.RuleName, err.Err.err)
|
|
}
|
|
return fmt.Sprintf("group %q, rule %d, %q: %v", err.Group, err.Rule, err.RuleName, err.Err.err)
|
|
}
|
|
|
|
// Unwrap unpacks wrapped error for use in errors.Is & errors.As.
|
|
func (err *Error) Unwrap() error {
|
|
return &err.Err
|
|
}
|
|
|
|
// WrappedError wraps error with the yaml node which can be used to represent
|
|
// the line and column numbers of the error.
|
|
type WrappedError struct {
|
|
err error
|
|
node *yaml.Node
|
|
nodeAlt *yaml.Node
|
|
}
|
|
|
|
// Error prints the error message in a formatted string.
|
|
func (we *WrappedError) Error() string {
|
|
if we.err == nil {
|
|
return ""
|
|
}
|
|
if we.nodeAlt != nil {
|
|
return fmt.Sprintf("%d:%d: %d:%d: %v", we.node.Line, we.node.Column, we.nodeAlt.Line, we.nodeAlt.Column, we.err)
|
|
}
|
|
if we.node != nil {
|
|
return fmt.Sprintf("%d:%d: %v", we.node.Line, we.node.Column, we.err)
|
|
}
|
|
return we.err.Error()
|
|
}
|
|
|
|
// Unwrap unpacks wrapped error for use in errors.Is & errors.As.
|
|
func (we *WrappedError) Unwrap() error {
|
|
return we.err
|
|
}
|
|
|
|
// RuleGroups is a set of rule groups that are typically exposed in a file.
|
|
type RuleGroups struct {
|
|
Groups []RuleGroup `yaml:"groups"`
|
|
}
|
|
|
|
type ruleGroups struct {
|
|
Groups []ruleGroupNode `yaml:"groups"`
|
|
}
|
|
|
|
// Validate validates all rules in the rule groups.
|
|
func (g *RuleGroups) Validate(node ruleGroups) (errs []error) {
|
|
set := map[string]struct{}{}
|
|
|
|
for j, g := range g.Groups {
|
|
if g.Name == "" {
|
|
errs = append(errs, fmt.Errorf("%d:%d: Groupname must not be empty", node.Groups[j].Line, node.Groups[j].Column))
|
|
}
|
|
|
|
if _, ok := set[g.Name]; ok {
|
|
errs = append(
|
|
errs,
|
|
fmt.Errorf("%d:%d: groupname: \"%s\" is repeated in the same file", node.Groups[j].Line, node.Groups[j].Column, g.Name),
|
|
)
|
|
}
|
|
|
|
for k, v := range g.Labels {
|
|
if !model.LabelName(k).IsValid() || k == model.MetricNameLabel {
|
|
errs = append(
|
|
errs, fmt.Errorf("invalid label name: %s", k),
|
|
)
|
|
}
|
|
|
|
if !model.LabelValue(v).IsValid() {
|
|
errs = append(
|
|
errs, fmt.Errorf("invalid label value: %s", v),
|
|
)
|
|
}
|
|
}
|
|
|
|
set[g.Name] = struct{}{}
|
|
|
|
for i, r := range g.Rules {
|
|
for _, node := range r.Validate(node.Groups[j].Rules[i]) {
|
|
var ruleName string
|
|
if r.Alert != "" {
|
|
ruleName = r.Alert
|
|
} else {
|
|
ruleName = r.Record
|
|
}
|
|
errs = append(errs, &Error{
|
|
Group: g.Name,
|
|
Rule: i + 1,
|
|
RuleName: ruleName,
|
|
Err: node,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// RuleGroup is a list of sequentially evaluated recording and alerting rules.
|
|
type RuleGroup struct {
|
|
Name string `yaml:"name"`
|
|
Interval model.Duration `yaml:"interval,omitempty"`
|
|
QueryOffset *model.Duration `yaml:"query_offset,omitempty"`
|
|
Limit int `yaml:"limit,omitempty"`
|
|
Rules []Rule `yaml:"rules"`
|
|
Labels map[string]string `yaml:"labels,omitempty"`
|
|
}
|
|
|
|
// ruleGroupNode adds yaml.v3 layer to support line and columns outputs for invalid rule groups.
|
|
type ruleGroupNode struct {
|
|
yaml.Node
|
|
Name string `yaml:"name"`
|
|
Interval model.Duration `yaml:"interval,omitempty"`
|
|
QueryOffset *model.Duration `yaml:"query_offset,omitempty"`
|
|
Limit int `yaml:"limit,omitempty"`
|
|
Rules []ruleNode `yaml:"rules"`
|
|
Labels map[string]string `yaml:"labels,omitempty"`
|
|
}
|
|
|
|
// Rule describes an alerting or recording rule.
|
|
type Rule struct {
|
|
Record string `yaml:"record,omitempty"`
|
|
Alert string `yaml:"alert,omitempty"`
|
|
Expr string `yaml:"expr"`
|
|
For model.Duration `yaml:"for,omitempty"`
|
|
KeepFiringFor model.Duration `yaml:"keep_firing_for,omitempty"`
|
|
Labels map[string]string `yaml:"labels,omitempty"`
|
|
Annotations map[string]string `yaml:"annotations,omitempty"`
|
|
}
|
|
|
|
// ruleNode adds yaml.v3 layer to support line and column outputs for invalid rules.
|
|
type ruleNode struct {
|
|
Record yaml.Node `yaml:"record,omitempty"`
|
|
Alert yaml.Node `yaml:"alert,omitempty"`
|
|
Expr yaml.Node `yaml:"expr"`
|
|
For model.Duration `yaml:"for,omitempty"`
|
|
KeepFiringFor model.Duration `yaml:"keep_firing_for,omitempty"`
|
|
Labels map[string]string `yaml:"labels,omitempty"`
|
|
Annotations map[string]string `yaml:"annotations,omitempty"`
|
|
}
|
|
|
|
// Validate the rule and return a list of encountered errors.
|
|
func (r *Rule) Validate(node ruleNode) (nodes []WrappedError) {
|
|
if r.Record != "" && r.Alert != "" {
|
|
nodes = append(nodes, WrappedError{
|
|
err: errors.New("only one of 'record' and 'alert' must be set"),
|
|
node: &node.Record,
|
|
nodeAlt: &node.Alert,
|
|
})
|
|
}
|
|
if r.Record == "" && r.Alert == "" {
|
|
nodes = append(nodes, WrappedError{
|
|
err: errors.New("one of 'record' or 'alert' must be set"),
|
|
node: &node.Record,
|
|
nodeAlt: &node.Alert,
|
|
})
|
|
}
|
|
|
|
if r.Expr == "" {
|
|
nodes = append(nodes, WrappedError{
|
|
err: errors.New("field 'expr' must be set in rule"),
|
|
node: &node.Expr,
|
|
})
|
|
} else if _, err := parser.ParseExpr(r.Expr); err != nil {
|
|
nodes = append(nodes, WrappedError{
|
|
err: fmt.Errorf("could not parse expression: %w", err),
|
|
node: &node.Expr,
|
|
})
|
|
}
|
|
if r.Record != "" {
|
|
if len(r.Annotations) > 0 {
|
|
nodes = append(nodes, WrappedError{
|
|
err: errors.New("invalid field 'annotations' in recording rule"),
|
|
node: &node.Record,
|
|
})
|
|
}
|
|
if r.For != 0 {
|
|
nodes = append(nodes, WrappedError{
|
|
err: errors.New("invalid field 'for' in recording rule"),
|
|
node: &node.Record,
|
|
})
|
|
}
|
|
if r.KeepFiringFor != 0 {
|
|
nodes = append(nodes, WrappedError{
|
|
err: errors.New("invalid field 'keep_firing_for' in recording rule"),
|
|
node: &node.Record,
|
|
})
|
|
}
|
|
if !model.IsValidMetricName(model.LabelValue(r.Record)) {
|
|
nodes = append(nodes, WrappedError{
|
|
err: fmt.Errorf("invalid recording rule name: %s", r.Record),
|
|
node: &node.Record,
|
|
})
|
|
}
|
|
// While record is a valid UTF-8 it's common mistake to put PromQL expression in the record name.
|
|
// Disallow "{}" chars.
|
|
if strings.Contains(r.Record, "{") || strings.Contains(r.Record, "}") {
|
|
nodes = append(nodes, WrappedError{
|
|
err: fmt.Errorf("braces present in the recording rule name; should it be in expr?: %s", r.Record),
|
|
node: &node.Record,
|
|
})
|
|
}
|
|
}
|
|
|
|
for k, v := range r.Labels {
|
|
if !model.LabelName(k).IsValid() || k == model.MetricNameLabel {
|
|
nodes = append(nodes, WrappedError{
|
|
err: fmt.Errorf("invalid label name: %s", k),
|
|
})
|
|
}
|
|
|
|
if !model.LabelValue(v).IsValid() {
|
|
nodes = append(nodes, WrappedError{
|
|
err: fmt.Errorf("invalid label value: %s", v),
|
|
})
|
|
}
|
|
}
|
|
|
|
for k := range r.Annotations {
|
|
if !model.LabelName(k).IsValid() {
|
|
nodes = append(nodes, WrappedError{
|
|
err: fmt.Errorf("invalid annotation name: %s", k),
|
|
})
|
|
}
|
|
}
|
|
|
|
for _, err := range testTemplateParsing(r) {
|
|
nodes = append(nodes, WrappedError{err: err})
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
// testTemplateParsing checks if the templates used in labels and annotations
|
|
// of the alerting rules are parsed correctly.
|
|
func testTemplateParsing(rl *Rule) (errs []error) {
|
|
if rl.Alert == "" {
|
|
// Not an alerting rule.
|
|
return errs
|
|
}
|
|
|
|
// Trying to parse templates.
|
|
tmplData := template.AlertTemplateData(map[string]string{}, map[string]string{}, "", promql.Sample{})
|
|
defs := []string{
|
|
"{{$labels := .Labels}}",
|
|
"{{$externalLabels := .ExternalLabels}}",
|
|
"{{$externalURL := .ExternalURL}}",
|
|
"{{$value := .Value}}",
|
|
}
|
|
parseTest := func(text string) error {
|
|
tmpl := template.NewTemplateExpander(
|
|
context.TODO(),
|
|
strings.Join(append(defs, text), ""),
|
|
"__alert_"+rl.Alert,
|
|
tmplData,
|
|
model.Time(timestamp.FromTime(time.Now())),
|
|
nil,
|
|
nil,
|
|
nil,
|
|
)
|
|
return tmpl.ParseTest()
|
|
}
|
|
|
|
// Parsing Labels.
|
|
for k, val := range rl.Labels {
|
|
err := parseTest(val)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("label %q: %w", k, err))
|
|
}
|
|
}
|
|
|
|
// Parsing Annotations.
|
|
for k, val := range rl.Annotations {
|
|
err := parseTest(val)
|
|
if err != nil {
|
|
errs = append(errs, fmt.Errorf("annotation %q: %w", k, err))
|
|
}
|
|
}
|
|
|
|
return errs
|
|
}
|
|
|
|
// Parse parses and validates a set of rules.
|
|
func Parse(content []byte, ignoreUnknownFields bool) (*RuleGroups, []error) {
|
|
var (
|
|
groups RuleGroups
|
|
node ruleGroups
|
|
errs []error
|
|
)
|
|
|
|
decoder := yaml.NewDecoder(bytes.NewReader(content))
|
|
if !ignoreUnknownFields {
|
|
decoder.KnownFields(true)
|
|
}
|
|
err := decoder.Decode(&groups)
|
|
// Ignore io.EOF which happens with empty input.
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
errs = append(errs, err)
|
|
}
|
|
err = yaml.Unmarshal(content, &node)
|
|
if err != nil {
|
|
errs = append(errs, err)
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
return nil, errs
|
|
}
|
|
|
|
return &groups, groups.Validate(node)
|
|
}
|
|
|
|
// ParseFile reads and parses rules from a file.
|
|
func ParseFile(file string, ignoreUnknownFields bool) (*RuleGroups, []error) {
|
|
b, err := os.ReadFile(file)
|
|
if err != nil {
|
|
return nil, []error{fmt.Errorf("%s: %w", file, err)}
|
|
}
|
|
rgs, errs := Parse(b, ignoreUnknownFields)
|
|
for i := range errs {
|
|
errs[i] = fmt.Errorf("%s: %w", file, errs[i])
|
|
}
|
|
return rgs, errs
|
|
}
|