Skip to content

Commit a87812e

Browse files
committed
Introduce the rules lint command
This command lints a rules file. The linter's aim is not verify correctness but just YAML and PromQL expression formatting within the rule file. It is meant to always edit in place but has a flag to perform a dry-run. This closes #39 and allows us to have a more consistent diff whilst running `rules prepare`
1 parent bad86f2 commit a87812e

File tree

4 files changed

+171
-10
lines changed

4 files changed

+171
-10
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ This command will load each rule group in the specified files and load them into
6060

6161
cortextool rules load ./example_rules_one.yaml ./example_rules_two.yaml ...
6262

63+
#### Rules Lint
64+
65+
This command lints a rules file. The linter's aim is not verify correctness but just YAML and PromQL expression formatting within the rule file. This command always edits in place, you can use the dry run flag (`-n`) if you'd like to perform a trial run that does not make any changes.
66+
67+
cortextool rules lint -n ./example_rules_one.yaml ./example_rules_two.yaml ...
68+
6369
#### Rules Prepare
6470

6571
This command prepares a rules file for upload to Cortex. It lints all your PromQL expressions and adds an specific label to your PromQL query aggregations in the file. Unlike, the previous command this one does not interact with your Cortex cluster.

pkg/commands/rules.go

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import (
1717
"github.com/grafana/cortextool/pkg/rules"
1818
log "github.com/sirupsen/logrus"
1919
"gopkg.in/alecthomas/kingpin.v2"
20-
"gopkg.in/yaml.v2"
20+
yamlv3 "gopkg.in/yaml.v3"
2121
)
2222

2323
const (
@@ -60,6 +60,9 @@ type RuleCommand struct {
6060
InPlaceEdit bool
6161
AggregationLabel string
6262

63+
// Lint Rules Config
64+
LintDryRun bool
65+
6366
DisableColor bool
6467
}
6568

@@ -93,6 +96,9 @@ func (r *RuleCommand) Register(app *kingpin.Application) {
9396
prepareCmd := rulesCmd.
9497
Command("prepare", "modifies a set of rules by including an specific label in aggregations.").
9598
Action(r.prepare)
99+
lintCmd := rulesCmd.
100+
Command("lint", "formats a set of rule files. It reorders keys alphabetically, uses 4 spaces as indentantion, and formats PromQL expressions to a single line.").
101+
Action(r.lint)
96102

97103
// Require Cortex cluster address and tentant ID on all these commands
98104
for _, c := range []*kingpin.CmdClause{listCmd, printRulesCmd, getRuleGroupCmd, deleteRuleGroupCmd, loadRulesCmd, diffRulesCmd, syncRulesCmd} {
@@ -151,6 +157,15 @@ func (r *RuleCommand) Register(app *kingpin.Application) {
151157
"edits the rule file in place",
152158
).Short('i').BoolVar(&r.InPlaceEdit)
153159
prepareCmd.Flag("label", "label to include as part of the aggregations.").Default(defaultPrepareAggregationLabel).Short('l').StringVar(&r.AggregationLabel)
160+
161+
// Lint Command
162+
lintCmd.Arg("rule-files", "The rule files to check.").Required().ExistingFilesVar(&r.RuleFilesList)
163+
lintCmd.Flag("rule-files", "The rule files to check. Flag can be reused to load multiple files.").StringVar(&r.RuleFiles)
164+
lintCmd.Flag(
165+
"rule-dirs",
166+
"Comma seperated list of paths to directories containing rules yaml files. Each file in a directory with a .yml or .yaml suffix will be parsed.",
167+
).StringVar(&r.RuleFilesPath)
168+
lintCmd.Flag("dry-run", "Performs a trial run that doesn't make any changes and (mostly) produces the same outpupt as a real run.").Short('n').BoolVar(&r.LintDryRun)
154169
}
155170

156171
func (r *RuleCommand) setup(k *kingpin.ParseContext) error {
@@ -498,24 +513,67 @@ func (r *RuleCommand) prepare(k *kingpin.ParseContext) error {
498513
}
499514

500515
// now, save all the files
501-
for _, ns := range namespaces {
502-
payload, err := yaml.Marshal(ns)
516+
if err := save(namespaces, r.InPlaceEdit); err != nil {
517+
return err
518+
}
519+
520+
log.Infof("SUCCESS: %d rules found, %d modified expressions", count, mod)
521+
522+
return nil
523+
}
524+
525+
func (r *RuleCommand) lint(k *kingpin.ParseContext) error {
526+
err := r.setupFiles()
527+
if err != nil {
528+
return errors.Wrap(err, "prepare operation unsuccessful, unable to load rules files")
529+
}
530+
531+
namespaces, err := rules.ParseFiles(r.RuleFilesList)
532+
if err != nil {
533+
return errors.Wrap(err, "prepare operation unsuccessful, unable to parse rules files")
534+
}
535+
536+
var count, mod int
537+
for _, ruleNamespace := range namespaces {
538+
c, m, err := ruleNamespace.LintPromQLExpressions()
539+
if err != nil {
540+
return err
541+
}
542+
543+
count += c
544+
mod += m
545+
}
546+
547+
if !r.LintDryRun {
548+
// linting will always in-place edit unless is a dry-run.
549+
if err := save(namespaces, true); err != nil {
550+
return err
551+
}
552+
}
553+
554+
log.Infof("SUCCESS: %d rules found, %d linted expressions", count, mod)
555+
556+
return nil
557+
}
558+
559+
// save saves a set of rule files to to disk. You can specify whenever you want the
560+
// file(s) to be edited in-place.
561+
func save(nss map[string]rules.RuleNamespace, i bool) error {
562+
for _, ns := range nss {
563+
payload, err := yamlv3.Marshal(ns)
503564
if err != nil {
504565
return err
505566
}
506567

507568
filepath := ns.Filepath
508-
if !r.InPlaceEdit {
569+
if !i {
509570
filepath = filepath + ".result"
510571
}
511572

512-
err = ioutil.WriteFile(filepath, payload, 0644)
513-
if err != nil {
573+
if err := ioutil.WriteFile(filepath, payload, 0644); err != nil {
514574
return err
515575
}
516576
}
517577

518-
log.Infof("SUCESS: %d rules found, %d modified expressions", count, mod)
519-
520578
return nil
521579
}

pkg/rules/rules.go

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,38 @@ type RuleNamespace struct {
2020
Groups []rulefmt.RuleGroup `yaml:"groups"`
2121
}
2222

23-
// AggregateBy Modifies the aggregation rules in groups to include a given Label.
23+
// LintPromQLExpressions runs the `expr` from a rule through the PromQL parser and
24+
// compares its output. if it differs from the parser, it uses the parser's instead.
25+
func (r RuleNamespace) LintPromQLExpressions() (int, int, error) {
26+
// `count` represents the number of rules we evalated.
27+
// `mod` represents the number of rules linted.
28+
var count, mod int
29+
for i, group := range r.Groups {
30+
for j, rule := range group.Rules {
31+
log.WithFields(log.Fields{"rule": getRuleName(rule)}).Debugf("linting PromQL")
32+
exp, err := promql.ParseExpr(rule.Expr)
33+
if err != nil {
34+
return count, mod, err
35+
}
36+
37+
count++
38+
if rule.Expr != exp.String() {
39+
log.WithFields(log.Fields{
40+
"rule": getRuleName(rule),
41+
"currentExpr": rule.Expr,
42+
"afterExpr": exp.String(),
43+
}).Debugf("expression differs")
44+
45+
mod++
46+
r.Groups[i].Rules[j].Expr = exp.String()
47+
}
48+
}
49+
}
50+
51+
return count, mod, nil
52+
}
53+
54+
// AggregateBy modifies the aggregation rules in groups to include a given Label.
2455
func (r RuleNamespace) AggregateBy(label string) (int, int, error) {
2556
// `count` represents the number of rules we evalated.
2657
// `mod` represents the number of rules we modified - a modification can either be a lint or adding the
@@ -42,7 +73,12 @@ func (r RuleNamespace) AggregateBy(label string) (int, int, error) {
4273
promql.Inspect(exp, f)
4374

4475
// Only modify the ones that actually changed.
45-
if r.Groups[i].Rules[j].Expr != exp.String() {
76+
if rule.Expr != exp.String() {
77+
log.WithFields(log.Fields{
78+
"rule": getRuleName(rule),
79+
"currentExpr": rule.Expr,
80+
"afterExpr": exp.String(),
81+
}).Debugf("expression differs")
4682
mod++
4783
r.Groups[i].Rules[j].Expr = exp.String()
4884
}

pkg/rules/rules_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package rules
22

33
import (
4+
"errors"
45
"testing"
56

67
"github.com/prometheus/prometheus/pkg/rulefmt"
@@ -95,3 +96,63 @@ func TestAggregateBy(t *testing.T) {
9596
})
9697
}
9798
}
99+
100+
func TestLintPromQLExpressions(t *testing.T) {
101+
tt := []struct {
102+
name string
103+
expr string
104+
expected string
105+
err error
106+
count, modified int
107+
}{
108+
{
109+
name: "it lints simple expressions",
110+
expr: "up != 1",
111+
expected: "up != 1",
112+
count: 1, modified: 1,
113+
err: nil,
114+
},
115+
{
116+
name: "it lints aggregations expressions",
117+
expr: "avg (rate(prometheus_notifications_queue_capacity[5m])) by (cluster, job)",
118+
expected: "avg by(cluster, job) (rate(prometheus_notifications_queue_capacity[5m]))",
119+
count: 1, modified: 1,
120+
err: nil,
121+
},
122+
{
123+
name: "with no opinion",
124+
expr: "build_tag_info > 1",
125+
expected: "build_tag_info > 1",
126+
count: 1, modified: 0,
127+
err: nil,
128+
},
129+
{
130+
name: "with an invalid expression",
131+
expr: "it fails",
132+
expected: "it fails",
133+
count: 0, modified: 0,
134+
err: errors.New(`parse error at char 4: could not parse remaining input "fails"...`),
135+
},
136+
}
137+
138+
for _, tc := range tt {
139+
t.Run(tc.name, func(t *testing.T) {
140+
r := RuleNamespace{Groups: []rulefmt.RuleGroup{{Rules: []rulefmt.Rule{
141+
{Alert: "AName", Expr: tc.expr},
142+
}}}}
143+
144+
c, m, err := r.LintPromQLExpressions()
145+
rexpr := r.Groups[0].Rules[0].Expr
146+
147+
require.Equal(t, tc.count, c)
148+
require.Equal(t, tc.modified, m)
149+
require.Equal(t, tc.expected, rexpr)
150+
151+
if tc.err == nil {
152+
require.NoError(t, err)
153+
} else {
154+
require.EqualError(t, err, tc.err.Error())
155+
}
156+
})
157+
}
158+
}

0 commit comments

Comments
 (0)