Skip to content

Commit 707a9a5

Browse files
authored
Merge pull request #40 from grafana/lint-rules
Introduce the `rules lint` command
2 parents 4b4631f + 9b579c3 commit 707a9a5

File tree

25 files changed

+11508
-10
lines changed

25 files changed

+11508
-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 to 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.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,5 +24,6 @@ require (
2424
google.golang.org/api v0.8.0
2525
gopkg.in/alecthomas/kingpin.v2 v2.2.6
2626
gopkg.in/yaml.v2 v2.2.2
27+
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86
2728
sigs.k8s.io/yaml v1.1.0
2829
)

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,6 +849,8 @@ gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bl
849849
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
850850
gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
851851
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
852+
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86 h1:OfFoIUYv/me30yv7XlMy4F9RJw8DEm8WQ6QG1Ph4bH0=
853+
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
852854
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
853855
honnef.co/go/tools v0.0.0-20180920025451-e3ad64cb4ed3/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
854856
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

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+
}

vendor/gopkg.in/yaml.v3/.travis.yml

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/gopkg.in/yaml.v3/LICENSE

Lines changed: 50 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/gopkg.in/yaml.v3/NOTICE

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)