diff --git a/CHANGELOG.md b/CHANGELOG.md index e6af406..7318ac1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added: new config options to the prometheus section of config: - `queryOffset`: Specify offset(delay) of the query (useful for consistency if using remote write for example). - `queryLookback`: How long into the past to look in queries supporting time range (just metadata queries for now). +- Added: New validator `alertNameMatchesRegexp` to check if the alert name matches the regexp. +- Added: New validator `groupNameMatchesRegexp` to check if the rule group name matches the regexp. +- Added: New validator `recordedMetricNameMatchesRegexp` to check if the recorded metric name matches the regexp. - Fixed: Loading glob patterns in the file paths to rules - Fixed: Params of the `expressionCanBeEvaluated` validator were ignored, this is now fixed. - Updated: Prometheus and other dependencies diff --git a/docs/validations.md b/docs/validations.md index 973fe94..d451096 100644 --- a/docs/validations.md +++ b/docs/validations.md @@ -10,6 +10,8 @@ All the supported validations are listed here. The validations are grouped by th - [`hasValidPartialResponseStrategy`](#hasvalidpartialresponsestrategy) - [`maxRulesPerGroup`](#maxrulespergroup) - [`hasValidLimit`](#hasvalidlimit) + - [`groupNameMatchesRegexp`](#groupnamematchesregexp) + - [`hasAllowedQueryOffset`](#hasallowedqueryoffset) - [Universal rule validators](#universal-rule-validators) - [Labels](#labels) - [`hasLabels`](#haslabels) @@ -53,7 +55,9 @@ All the supported validations are listed here. The validations are grouped by th - [Other](#other-1) - [`forIsNotLongerThan`](#forisnotlongerthan) - [`keepFiringForIsNotLongerThan`](#keepfiringforisnotlongerthan) + - [`alertNameMatchesRegexp`](#alertnamematchesregexp) - [Recording rules validators](#recording-rules-validators) + - [`recordedMetricNameMatchesRegexp`](#recordedmetricnamematchesregexp) @@ -120,6 +124,24 @@ params: limit: 10 ``` +### `groupNameMatchesRegexp` + +Fails if the group name does not match the specified regular expression. + +```yaml +params: + regexp: "[A-Z]\s+" +``` + +### `hasAllowedQueryOffset` + +Fails if the rule group has the `query_offset` out of the configured range. + +```yaml +params: + minimum: + maximum: # Optional, default is infinity +``` ## Universal rule validators Validators that can be used on `All rules`, `Recording rule` and `Alert` scopes. @@ -464,5 +486,23 @@ params: limit: "1h" ``` +#### `alertNameMatchesRegexp` + +Fails if the alert name does not match the specified regular expression. + +```yaml +params: + regexp: "[A-Z]\s+" +``` + ## Recording rules validators Validators that can be used on `Recording rule` scope. + +#### `recordedMetricNameMatchesRegexp` + +Fails if the name of the recorded metric does not match the specified regular expression. + +```yaml +params: + regexp: "[^:]+:[^:]+:[^:]+" +``` diff --git a/pkg/validator/alert.go b/pkg/validator/alert.go index 593fdae..7b3b2ca 100644 --- a/pkg/validator/alert.go +++ b/pkg/validator/alert.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "net/url" + "regexp" "strings" "time" @@ -100,3 +101,38 @@ func (h validateLabelTemplates) Validate(_ unmarshaler.RuleGroup, rule rulefmt.R } return errs } + +func newAlertNameMatchesRegexp(paramsConfig yaml.Node) (Validator, error) { + params := struct { + Regexp string `yaml:"regexp"` + }{} + if err := paramsConfig.Decode(¶ms); err != nil { + return nil, err + } + if params.Regexp == "" { + return nil, fmt.Errorf("missing pattern") + } + r, err := regexp.Compile(params.Regexp) + if err != nil { + return nil, fmt.Errorf("invalid pattern %s: %w", params.Regexp, err) + } + return &alertNameMatchesRegexp{ + pattern: r, + }, nil +} + +type alertNameMatchesRegexp struct { + pattern *regexp.Regexp +} + +func (h alertNameMatchesRegexp) String() string { + return fmt.Sprintf("Alert name matches regexp: %s", h.pattern.String()) +} + +func (h alertNameMatchesRegexp) Validate(_ unmarshaler.RuleGroup, rule rulefmt.Rule, _ *prometheus.Client) []error { + var errs []error + if !h.pattern.MatchString(rule.Alert) { + errs = append(errs, fmt.Errorf("alert name %s does not match pattern %s", rule.Alert, h.pattern.String())) + } + return errs +} diff --git a/pkg/validator/config.go b/pkg/validator/config.go index 80fa230..550932e 100644 --- a/pkg/validator/config.go +++ b/pkg/validator/config.go @@ -40,11 +40,14 @@ var registeredUniversalRuleValidators = map[string]validatorCreator{ "hasSourceTenantsForMetrics": newHasSourceTenantsForMetrics, } -var registeredRecordingRuleValidators = map[string]validatorCreator{} +var registeredRecordingRuleValidators = map[string]validatorCreator{ + "recordedMetricNameMatchesRegexp": newRecordedMetricNameMatchesRegexp, +} var registeredAlertValidators = map[string]validatorCreator{ "forIsNotLongerThan": newForIsNotLongerThan, "keepFiringForIsNotLongerThan": newKeepFiringForIsNotLongerThan, + "alertNameMatchesRegexp": newAlertNameMatchesRegexp, "validateAnnotationTemplates": newValidateAnnotationTemplates, "annotationIsValidPromQL": newAnnotationIsValidPromQL, @@ -63,6 +66,8 @@ var registeredGroupValidators = map[string]validatorCreator{ "hasValidPartialResponseStrategy": newHasValidPartialResponseStrategy, "maxRulesPerGroup": newMaxRulesPerGroup, "hasAllowedLimit": newHasAllowedLimit, + "groupNameMatchesRegexp": newGroupNameMatchesRegexp, + "hasAllowedQueryOffset": newHasAllowedQueryOffset, } var ( diff --git a/pkg/validator/group.go b/pkg/validator/group.go index 25a2615..1225524 100644 --- a/pkg/validator/group.go +++ b/pkg/validator/group.go @@ -2,6 +2,7 @@ package validator import ( "fmt" + "regexp" "strings" "github.com/fusakla/promruval/v2/pkg/prometheus" @@ -185,3 +186,77 @@ func (h hasAllowedLimit) Validate(group unmarshaler.RuleGroup, _ rulefmt.Rule, _ } return []error{} } + +func newHasAllowedQueryOffset(paramsConfig yaml.Node) (Validator, error) { + params := struct { + Minimum model.Duration `yaml:"minimum"` + Maximum model.Duration `yaml:"maximum"` + }{} + if err := paramsConfig.Decode(¶ms); err != nil { + return nil, err + } + if params.Minimum > params.Maximum { + return nil, fmt.Errorf("minimum is greater than maximum") + } + if params.Maximum == 0 && params.Minimum == 0 { + return nil, fmt.Errorf("minimum or maximum must be set") + } + if params.Maximum == 0 { + params.Maximum = model.Duration(1<<63 - 1) + } + + return &hasAllowedQueryOffset{min: params.Minimum, max: params.Maximum}, nil +} + +type hasAllowedQueryOffset struct { + min model.Duration + max model.Duration +} + +func (h hasAllowedQueryOffset) String() string { + return fmt.Sprintf("group query_offset is higher than %s and lowed then %s", h.min, h.max) +} + +func (h hasAllowedQueryOffset) Validate(group unmarshaler.RuleGroup, _ rulefmt.Rule, _ *prometheus.Client) []error { + if group.QueryOffset > h.max { + return []error{fmt.Errorf("group has query_offset %s, allowed maximum is %s", group.QueryOffset, h.max)} + } else if group.QueryOffset < h.min { + return []error{fmt.Errorf("group has query_offset %s, allowed minimum is %s", group.QueryOffset, h.min)} + } + return []error{} +} + +func newGroupNameMatchesRegexp(paramsConfig yaml.Node) (Validator, error) { + params := struct { + Regexp string `yaml:"regexp"` + }{} + if err := paramsConfig.Decode(¶ms); err != nil { + return nil, err + } + if params.Regexp == "" { + return nil, fmt.Errorf("missing regexp") + } + r, err := regexp.Compile(params.Regexp) + if err != nil { + return nil, fmt.Errorf("invalid regexp %s: %w", params.Regexp, err) + } + return &groupNameMatchesRegexp{ + pattern: r, + }, nil +} + +type groupNameMatchesRegexp struct { + pattern *regexp.Regexp +} + +func (h groupNameMatchesRegexp) String() string { + return fmt.Sprintf("Group name matches regexp: %s", h.pattern.String()) +} + +func (h groupNameMatchesRegexp) Validate(group unmarshaler.RuleGroup, _ rulefmt.Rule, _ *prometheus.Client) []error { + var errs []error + if !h.pattern.MatchString(group.Name) { + errs = append(errs, fmt.Errorf("group name %s does not match regexp %s", group.Name, h.pattern.String())) + } + return errs +} diff --git a/pkg/validator/recording_rule.go b/pkg/validator/recording_rule.go new file mode 100644 index 0000000..29967dc --- /dev/null +++ b/pkg/validator/recording_rule.go @@ -0,0 +1,46 @@ +package validator + +import ( + "fmt" + "regexp" + + "github.com/fusakla/promruval/v2/pkg/prometheus" + "github.com/fusakla/promruval/v2/pkg/unmarshaler" + "github.com/prometheus/prometheus/model/rulefmt" + "gopkg.in/yaml.v3" +) + +func newRecordedMetricNameMatchesRegexp(paramsConfig yaml.Node) (Validator, error) { + params := struct { + Regexp string `yaml:"regexp"` + }{} + if err := paramsConfig.Decode(¶ms); err != nil { + return nil, err + } + if params.Regexp == "" { + return nil, fmt.Errorf("missing regexp") + } + r, err := regexp.Compile(params.Regexp) + if err != nil { + return nil, fmt.Errorf("invalid regexp %s: %w", params.Regexp, err) + } + return &recordedMetricNameMatchesRegexp{ + pattern: r, + }, nil +} + +type recordedMetricNameMatchesRegexp struct { + pattern *regexp.Regexp +} + +func (h recordedMetricNameMatchesRegexp) String() string { + return fmt.Sprintf("Recorded metric name matches regexp: %s", h.pattern.String()) +} + +func (h recordedMetricNameMatchesRegexp) Validate(_ unmarshaler.RuleGroup, rule rulefmt.Rule, _ *prometheus.Client) []error { + var errs []error + if !h.pattern.MatchString(rule.Record) { + errs = append(errs, fmt.Errorf("recorded metric name %s does not match pattern %s", rule.Alert, h.pattern.String())) + } + return errs +} diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go index 6db35bb..0e021b2 100644 --- a/pkg/validator/validator_test.go +++ b/pkg/validator/validator_test.go @@ -266,6 +266,23 @@ var testCases = []struct { {name: "logQlExpressionUsesFiltersFirst_OK", validator: logQlExpressionUsesFiltersFirst{}, rule: rulefmt.Rule{Expr: `{job="foo"} |= "foo" | logfmt`}, expectedErrors: 0}, {name: "logQlExpressionUsesFiltersFirst_Invalid", validator: logQlExpressionUsesFiltersFirst{}, rule: rulefmt.Rule{Expr: `{job="foo"} | logfmt |= "foo"`}, expectedErrors: 1}, {name: "logQlExpressionUsesFiltersFirst_Invalid", validator: logQlExpressionUsesFiltersFirst{}, rule: rulefmt.Rule{Expr: `{job="foo"} |= "foo" | logfmt |= "bar"`}, expectedErrors: 1}, + + // alertNameMatchesRegexp + {name: "alertNameMatchesRegexp_Valid", validator: alertNameMatchesRegexp{pattern: regexp.MustCompile("Foo.*")}, rule: rulefmt.Rule{Alert: `FooBAr`}, expectedErrors: 0}, + {name: "alertNameMatchesRegexp_NotMatch", validator: alertNameMatchesRegexp{pattern: regexp.MustCompile("Foo.*")}, rule: rulefmt.Rule{Alert: `Bar`}, expectedErrors: 1}, + + // recordedMetricNameMatchesRegexp + {name: "recordedMetricNameMatchesRegexp_Matches", validator: recordedMetricNameMatchesRegexp{pattern: regexp.MustCompile("[^:]+:[^:]+:[^:]+")}, rule: rulefmt.Rule{Record: `cluster:foo_bar:avg`}, expectedErrors: 0}, + {name: "recordedMetricNameMatchesRegexp_notMatches", validator: recordedMetricNameMatchesRegexp{pattern: regexp.MustCompile("[^:]+:[^:]+:[^:]+")}, rule: rulefmt.Rule{Record: `foo_bar`}, expectedErrors: 1}, + + // hasAllowedQueryOffset + {name: "hasAllowedQueryOffset_valid", validator: hasAllowedQueryOffset{min: model.Duration(time.Second), max: model.Duration(time.Minute)}, group: unmarshaler.RuleGroup{QueryOffset: model.Duration(time.Second * 30)}, expectedErrors: 0}, + {name: "hasAllowedQueryOffset_tooHigh", validator: hasAllowedQueryOffset{min: model.Duration(time.Second), max: model.Duration(time.Minute)}, group: unmarshaler.RuleGroup{QueryOffset: model.Duration(time.Minute * 2)}, expectedErrors: 1}, + {name: "hasAllowedQueryOffset_tooLow", validator: hasAllowedQueryOffset{min: model.Duration(time.Minute), max: model.Duration(time.Hour)}, group: unmarshaler.RuleGroup{QueryOffset: model.Duration(time.Second)}, expectedErrors: 1}, + + // groupNameMatchesRegexp + {name: "groupNameMatchesRegexp_valid", validator: groupNameMatchesRegexp{pattern: regexp.MustCompile(`^[A-Z]\S+$`)}, group: unmarshaler.RuleGroup{Name: "TestGroup"}, expectedErrors: 0}, + {name: "groupNameMatchesRegexp_invalid", validator: groupNameMatchesRegexp{pattern: regexp.MustCompile(`^[A-Z]\S+$`)}, group: unmarshaler.RuleGroup{Name: "Test Group"}, expectedErrors: 1}, } func Test(t *testing.T) {