Repository: prometheus/prometheus (language: go)

Issue:
promtool: Support some kind of “lint” for rules
`promtool check rules` currently is [documented](https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/#syntax-checking-rules) as a way to syntax check rules. However it also checks for duplicate rules, which is closer to a lint check.

There are some features in PromQL that are potentially problematic to use in recording rules now:
- Negative offset is likely incorrect in recording rules (I mentioned some kind of lint [then](https://github.com/prometheus/prometheus/issues/6282#issuecomment-550308545))
- Subqueries are often not a good idea (noted on the [blog post](https://prometheus.io/blog/2019/01/28/subquery-support/))

I propose something like a `--lint` flag where the default is “duplicate-rules” to match current behaviour, but could also accept `--lint=all`, etc. 

(Some overlap with #8607)


Discussion / hints:
This was planned as part of #7779 but that's inactive at the moment for the reviewing process. If we can resume work there, then I can extend the pretty process as described here.
This sounds like a good idea.
Another lint that came up is checking for an annotation: label in alerting rules: #8864.

Fix patch:
diff --git a/cmd/promtool/main.go b/cmd/promtool/main.go
index dbdc825fec2..bf85786aeca 100644
--- a/cmd/promtool/main.go
+++ b/cmd/promtool/main.go
@@ -66,8 +66,14 @@ const (
 	failureExitCode = 1
 	// Exit code 3 is used for "one or more lint issues detected".
 	lintErrExitCode = 3
+
+	lintOptionAll            = "all"
+	lintOptionDuplicateRules = "duplicate-rules"
+	lintOptionNone           = "none"
 )
 
+var lintOptions = []string{lintOptionAll, lintOptionDuplicateRules, lintOptionNone}
+
 func main() {
 	app := kingpin.New(filepath.Base(os.Args[0]), "Tooling for the Prometheus monitoring system.").UsageWriter(os.Stdout)
 	app.Version(version.Print("promtool"))
@@ -86,6 +92,10 @@ func main() {
 		"The config files to check.",
 	).Required().ExistingFiles()
 	checkConfigSyntaxOnly := checkConfigCmd.Flag("syntax-only", "Only check the config file syntax, ignoring file and content validation referenced in the config").Bool()
+	checkConfigLint := checkConfigCmd.Flag(
+		"lint",
+		"Linting checks to apply to the rules specified in the config. Available options are: "+strings.Join(lintOptions, ", ")+". Use --lint=none to disable linting",
+	).Default(lintOptionDuplicateRules).String()
 
 	checkWebConfigCmd := checkCmd.Command("web-config", "Check if the web config files are valid or not.")
 	webConfigFiles := checkWebConfigCmd.Arg(
@@ -98,6 +108,10 @@ func main() {
 		"rule-files",
 		"The rule files to check.",
 	).Required().ExistingFiles()
+	checkRulesLint := checkRulesCmd.Flag(
+		"lint",
+		"Linting checks to apply. Available options are: "+strings.Join(lintOptions, ", ")+". Use --lint=none to disable linting",
+	).Default(lintOptionDuplicateRules).String()
 
 	checkMetricsCmd := checkCmd.Command("metrics", checkMetricsUsage)
 	checkMetricsExtended := checkCmd.Flag("extended", "Print extended information related to the cardinality of the metrics.").Bool()
@@ -222,13 +236,13 @@ func main() {
 		os.Exit(CheckSD(*sdConfigFile, *sdJobName, *sdTimeout))
 
 	case checkConfigCmd.FullCommand():
-		os.Exit(CheckConfig(*agentMode, *checkConfigSyntaxOnly, *configFiles...))
+		os.Exit(CheckConfig(*agentMode, *checkConfigSyntaxOnly, newLintConfig(*checkConfigLint), *configFiles...))
 
 	case checkWebConfigCmd.FullCommand():
 		os.Exit(CheckWebConfig(*webConfigFiles...))
 
 	case checkRulesCmd.FullCommand():
-		os.Exit(CheckRules(*ruleFiles...))
+		os.Exit(CheckRules(newLintConfig(*checkRulesLint), *ruleFiles...))
 
 	case checkMetricsCmd.FullCommand():
 		os.Exit(CheckMetrics(*checkMetricsExtended))
@@ -283,9 +297,39 @@ func main() {
 	}
 }
 
+// nolint:revive
+var lintError = fmt.Errorf("lint error")
+
+type lintConfig struct {
+	all            bool
+	duplicateRules bool
+}
+
+func newLintConfig(stringVal string) lintConfig {
+	items := strings.Split(stringVal, ",")
+	ls := lintConfig{}
+	for _, setting := range items {
+		switch setting {
+		case lintOptionAll:
+			ls.all = true
+		case lintOptionDuplicateRules:
+			ls.duplicateRules = true
+		case lintOptionNone:
+		default:
+			fmt.Printf("WARNING: unknown lint option %s\n", setting)
+		}
+	}
+	return ls
+}
+
+func (ls lintConfig) lintDuplicateRules() bool {
+	return ls.all || ls.duplicateRules
+}
+
 // CheckConfig validates configuration files.
-func CheckConfig(agentMode, checkSyntaxOnly bool, files ...string) int {
+func CheckConfig(agentMode, checkSyntaxOnly bool, lintSettings lintConfig, files ...string) int {
 	failed := false
+	hasLintErrors := false
 
 	for _, f := range files {
 		ruleFiles, err := checkConfig(agentMode, f, checkSyntaxOnly)
@@ -301,18 +345,24 @@ func CheckConfig(agentMode, checkSyntaxOnly bool, files ...string) int {
 		fmt.Println()
 
 		for _, rf := range ruleFiles {
-			if n, errs := checkRules(rf); len(errs) > 0 {
+			if n, errs := checkRules(rf, lintSettings); len(errs) > 0 {
 				fmt.Fprintln(os.Stderr, "  FAILED:")
 				for _, err := range errs {
 					fmt.Fprintln(os.Stderr, "    ", err)
 				}
 				failed = true
+				for _, err := range errs {
+					hasLintErrors = hasLintErrors || errors.Is(err, lintError)
+				}
 			} else {
 				fmt.Printf("  SUCCESS: %d rules found\n", n)
 			}
 			fmt.Println()
 		}
 	}
+	if failed && hasLintErrors {
+		return lintErrExitCode
+	}
 	if failed {
 		return failureExitCode
 	}
@@ -521,28 +571,35 @@ func checkSDFile(filename string) ([]*targetgroup.Group, error) {
 }
 
 // CheckRules validates rule files.
-func CheckRules(files ...string) int {
+func CheckRules(ls lintConfig, files ...string) int {
 	failed := false
+	hasLintErrors := false
 
 	for _, f := range files {
-		if n, errs := checkRules(f); errs != nil {
+		if n, errs := checkRules(f, ls); errs != nil {
 			fmt.Fprintln(os.Stderr, "  FAILED:")
 			for _, e := range errs {
 				fmt.Fprintln(os.Stderr, e.Error())
 			}
 			failed = true
+			for _, err := range errs {
+				hasLintErrors = hasLintErrors || errors.Is(err, lintError)
+			}
 		} else {
 			fmt.Printf("  SUCCESS: %d rules found\n", n)
 		}
 		fmt.Println()
 	}
+	if failed && hasLintErrors {
+		return lintErrExitCode
+	}
 	if failed {
 		return failureExitCode
 	}
 	return successExitCode
 }
 
-func checkRules(filename string) (int, []error) {
+func checkRules(filename string, lintSettings lintConfig) (int, []error) {
 	fmt.Println("Checking", filename)
 
 	rgs, errs := rulefmt.ParseFile(filename)
@@ -555,16 +612,19 @@ func checkRules(filename string) (int, []error) {
 		numRules += len(rg.Rules)
 	}
 
-	dRules := checkDuplicates(rgs.Groups)
-	if len(dRules) != 0 {
-		fmt.Printf("%d duplicate rule(s) found.\n", len(dRules))
-		for _, n := range dRules {
-			fmt.Printf("Metric: %s\nLabel(s):\n", n.metric)
-			for _, l := range n.label {
-				fmt.Printf("\t%s: %s\n", l.Name, l.Value)
+	if lintSettings.lintDuplicateRules() {
+		dRules := checkDuplicates(rgs.Groups)
+		if len(dRules) != 0 {
+			errMessage := fmt.Sprintf("%d duplicate rule(s) found.\n", len(dRules))
+			for _, n := range dRules {
+				errMessage += fmt.Sprintf("Metric: %s\nLabel(s):\n", n.metric)
+				for _, l := range n.label {
+					errMessage += fmt.Sprintf("\t%s: %s\n", l.Name, l.Value)
+				}
 			}
+			errMessage += "Might cause inconsistency while recording expressions"
+			return 0, []error{fmt.Errorf("%w %s", lintError, errMessage)}
 		}
-		fmt.Println("Might cause inconsistency while recording expressions.")
 	}
 
 	return numRules, nil


Test patch: