cloudflare/pint
Publicmirrored from https://github.com/cloudflare/pintAvailable
cmd/pint/scan.go
300lines · modecode
| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "log/slog" |
| 8 | "regexp" |
| 9 | "strconv" |
| 10 | "sync" |
| 11 | "time" |
| 12 | |
| 13 | "github.com/prometheus/prometheus/model/rulefmt" |
| 14 | "go.uber.org/atomic" |
| 15 | |
| 16 | "github.com/cloudflare/pint/internal/checks" |
| 17 | "github.com/cloudflare/pint/internal/comments" |
| 18 | "github.com/cloudflare/pint/internal/config" |
| 19 | "github.com/cloudflare/pint/internal/discovery" |
| 20 | "github.com/cloudflare/pint/internal/parser" |
| 21 | "github.com/cloudflare/pint/internal/promapi" |
| 22 | "github.com/cloudflare/pint/internal/reporter" |
| 23 | ) |
| 24 | |
| 25 | var ( |
| 26 | yamlErrRe = regexp.MustCompile("^yaml: line (.+): (.+)") |
| 27 | yamlUnmarshalErrRe = regexp.MustCompile("^yaml: unmarshal errors:\n line (.+): (.+)") |
| 28 | rulefmtGroupRe = regexp.MustCompile("^([0-9]+):[0-9]+: group \".+\", rule [0-9]+, (.+)") |
| 29 | rulefmtGroupnameRe = regexp.MustCompile("^([0-9]+):[0-9]+: (groupname: .+)") |
| 30 | ) |
| 31 | |
| 32 | const ( |
| 33 | yamlParseReporter = "yaml/parse" |
| 34 | ignoreFileReporter = "ignore/file" |
| 35 | pintCommentReporter = "pint/comment" |
| 36 | ) |
| 37 | |
| 38 | func tryDecodingYamlError(err error) (l int, s string) { |
| 39 | s = err.Error() |
| 40 | |
| 41 | werr := &rulefmt.WrappedError{} |
| 42 | if errors.As(err, &werr) { |
| 43 | if uerr := werr.Unwrap(); uerr != nil { |
| 44 | s = uerr.Error() |
| 45 | } |
| 46 | } |
| 47 | |
| 48 | for _, re := range []*regexp.Regexp{yamlErrRe, yamlUnmarshalErrRe, rulefmtGroupRe, rulefmtGroupnameRe} { |
| 49 | parts := re.FindStringSubmatch(err.Error()) |
| 50 | if len(parts) > 2 { |
| 51 | line, err2 := strconv.Atoi(parts[1]) |
| 52 | if err2 != nil || line <= 0 { |
| 53 | return 1, s |
| 54 | } |
| 55 | return line, parts[2] |
| 56 | } |
| 57 | } |
| 58 | return 1, s |
| 59 | } |
| 60 | |
| 61 | func checkRules(ctx context.Context, workers int, isOffline bool, gen *config.PrometheusGenerator, cfg config.Config, entries []discovery.Entry) (summary reporter.Summary, err error) { |
| 62 | if isOffline { |
| 63 | slog.Info("Offline mode, skipping Prometheus discovery") |
| 64 | } else { |
| 65 | if len(entries) > 0 { |
| 66 | if err = gen.GenerateDynamic(ctx); err != nil { |
| 67 | return summary, err |
| 68 | } |
| 69 | slog.Debug("Generated all Prometheus servers", slog.Int("count", gen.Count())) |
| 70 | } else { |
| 71 | slog.Info("No rules found, skipping Prometheus discovery") |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | checkIterationChecks.Set(0) |
| 76 | checkIterationChecksDone.Set(0) |
| 77 | |
| 78 | start := time.Now() |
| 79 | defer func() { |
| 80 | lastRunDuration.Set(time.Since(start).Seconds()) |
| 81 | }() |
| 82 | |
| 83 | jobs := make(chan scanJob, workers*5) |
| 84 | results := make(chan reporter.Report, workers*5) |
| 85 | wg := sync.WaitGroup{} |
| 86 | |
| 87 | ctx = context.WithValue(ctx, promapi.AllPrometheusServers, gen.Servers()) |
| 88 | for _, s := range cfg.Check { |
| 89 | settings, _ := s.Decode() |
| 90 | key := checks.SettingsKey(s.Name) |
| 91 | ctx = context.WithValue(ctx, key, settings) |
| 92 | } |
| 93 | |
| 94 | for w := 1; w <= workers; w++ { |
| 95 | wg.Add(1) |
| 96 | go func() { |
| 97 | defer wg.Done() |
| 98 | scanWorker(ctx, jobs, results) |
| 99 | }() |
| 100 | } |
| 101 | |
| 102 | go func() { |
| 103 | defer close(results) |
| 104 | wg.Wait() |
| 105 | }() |
| 106 | |
| 107 | var onlineChecksCount, offlineChecksCount, checkedEntriesCount atomic.Int64 |
| 108 | go func() { |
| 109 | for _, entry := range entries { |
| 110 | switch { |
| 111 | case entry.State == discovery.Excluded: |
| 112 | continue |
| 113 | case entry.PathError != nil && entry.State == discovery.Removed: |
| 114 | continue |
| 115 | case entry.Rule.Error.Err != nil && entry.State == discovery.Removed: |
| 116 | continue |
| 117 | case entry.PathError == nil && entry.Rule.Error.Err == nil: |
| 118 | if entry.Rule.RecordingRule != nil { |
| 119 | rulesParsedTotal.WithLabelValues(config.RecordingRuleType).Inc() |
| 120 | slog.Debug("Found recording rule", |
| 121 | slog.String("path", entry.SourcePath), |
| 122 | slog.String("record", entry.Rule.RecordingRule.Record.Value), |
| 123 | slog.String("lines", entry.Rule.Lines.String()), |
| 124 | ) |
| 125 | } |
| 126 | if entry.Rule.AlertingRule != nil { |
| 127 | rulesParsedTotal.WithLabelValues(config.AlertingRuleType).Inc() |
| 128 | slog.Debug("Found alerting rule", |
| 129 | slog.String("path", entry.SourcePath), |
| 130 | slog.String("alert", entry.Rule.AlertingRule.Alert.Value), |
| 131 | slog.String("lines", entry.Rule.Lines.String()), |
| 132 | ) |
| 133 | } |
| 134 | |
| 135 | checkedEntriesCount.Inc() |
| 136 | checkList := cfg.GetChecksForRule(ctx, gen, entry, entry.DisabledChecks) |
| 137 | for _, check := range checkList { |
| 138 | checkIterationChecks.Inc() |
| 139 | if check.Meta().IsOnline { |
| 140 | onlineChecksCount.Inc() |
| 141 | } else { |
| 142 | offlineChecksCount.Inc() |
| 143 | } |
| 144 | jobs <- scanJob{entry: entry, allEntries: entries, check: check} |
| 145 | } |
| 146 | default: |
| 147 | if entry.Rule.Error.Err != nil { |
| 148 | slog.Debug("Found invalid rule", |
| 149 | slog.String("path", entry.SourcePath), |
| 150 | slog.String("lines", entry.Rule.Lines.String()), |
| 151 | ) |
| 152 | rulesParsedTotal.WithLabelValues(config.InvalidRuleType).Inc() |
| 153 | } |
| 154 | jobs <- scanJob{entry: entry, allEntries: entries, check: nil} |
| 155 | } |
| 156 | } |
| 157 | defer close(jobs) |
| 158 | }() |
| 159 | |
| 160 | for result := range results { |
| 161 | summary.Report(result) |
| 162 | } |
| 163 | summary.SortReports() |
| 164 | summary.Duration = time.Since(start) |
| 165 | summary.TotalEntries = len(entries) |
| 166 | summary.CheckedEntries = checkedEntriesCount.Load() |
| 167 | summary.OnlineChecks = onlineChecksCount.Load() |
| 168 | summary.OfflineChecks = offlineChecksCount.Load() |
| 169 | |
| 170 | lastRunTime.SetToCurrentTime() |
| 171 | |
| 172 | return summary, nil |
| 173 | } |
| 174 | |
| 175 | type scanJob struct { |
| 176 | check checks.RuleChecker |
| 177 | allEntries []discovery.Entry |
| 178 | entry discovery.Entry |
| 179 | } |
| 180 | |
| 181 | func scanWorker(ctx context.Context, jobs <-chan scanJob, results chan<- reporter.Report) { |
| 182 | for job := range jobs { |
| 183 | select { |
| 184 | case <-ctx.Done(): |
| 185 | return |
| 186 | default: |
| 187 | var commentErr comments.CommentError |
| 188 | var ignoreErr discovery.FileIgnoreError |
| 189 | switch { |
| 190 | case errors.As(job.entry.PathError, &ignoreErr): |
| 191 | results <- reporter.Report{ |
| 192 | ReportedPath: job.entry.ReportedPath, |
| 193 | SourcePath: job.entry.SourcePath, |
| 194 | ModifiedLines: job.entry.ModifiedLines, |
| 195 | Problem: checks.Problem{ |
| 196 | Lines: parser.LineRange{ |
| 197 | First: ignoreErr.Line, |
| 198 | Last: ignoreErr.Line, |
| 199 | }, |
| 200 | Reporter: ignoreFileReporter, |
| 201 | Text: ignoreErr.Error(), |
| 202 | Severity: checks.Information, |
| 203 | }, |
| 204 | Owner: job.entry.Owner, |
| 205 | } |
| 206 | case errors.As(job.entry.PathError, &commentErr): |
| 207 | results <- reporter.Report{ |
| 208 | ReportedPath: job.entry.ReportedPath, |
| 209 | SourcePath: job.entry.SourcePath, |
| 210 | ModifiedLines: job.entry.ModifiedLines, |
| 211 | Problem: checks.Problem{ |
| 212 | Lines: parser.LineRange{ |
| 213 | First: commentErr.Line, |
| 214 | Last: commentErr.Line, |
| 215 | }, |
| 216 | Reporter: pintCommentReporter, |
| 217 | Text: fmt.Sprintf("This comment is not a valid pint control comment: %s", commentErr.Error()), |
| 218 | Severity: checks.Warning, |
| 219 | }, |
| 220 | Owner: job.entry.Owner, |
| 221 | } |
| 222 | case job.entry.PathError != nil: |
| 223 | line, e := tryDecodingYamlError(job.entry.PathError) |
| 224 | results <- reporter.Report{ |
| 225 | ReportedPath: job.entry.ReportedPath, |
| 226 | SourcePath: job.entry.SourcePath, |
| 227 | ModifiedLines: job.entry.ModifiedLines, |
| 228 | Problem: checks.Problem{ |
| 229 | Lines: parser.LineRange{ |
| 230 | First: line, |
| 231 | Last: line, |
| 232 | }, |
| 233 | Reporter: yamlParseReporter, |
| 234 | Text: fmt.Sprintf("YAML parser returned an error when reading this file: `%s`.", e), |
| 235 | Details: `pint cannot read this file because YAML parser returned an error. |
| 236 | This usually means that you have an indention error or the file doesn't have the YAML structure required by Prometheus for [recording](https://prometheus.io/docs/prometheus/latest/configuration/recording_rules/) and [alerting](https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/) rules. |
| 237 | If this file is a template that will be rendered into valid YAML then you can instruct pint to ignore some lines using comments, see [pint docs](https://cloudflare.github.io/pint/ignoring.html). |
| 238 | `, |
| 239 | Severity: checks.Fatal, |
| 240 | }, |
| 241 | Owner: job.entry.Owner, |
| 242 | } |
| 243 | case job.entry.Rule.Error.Err != nil: |
| 244 | results <- reporter.Report{ |
| 245 | ReportedPath: job.entry.ReportedPath, |
| 246 | SourcePath: job.entry.SourcePath, |
| 247 | ModifiedLines: job.entry.ModifiedLines, |
| 248 | Rule: job.entry.Rule, |
| 249 | Problem: checks.Problem{ |
| 250 | Lines: parser.LineRange{ |
| 251 | First: job.entry.Rule.Error.Line, |
| 252 | Last: job.entry.Rule.Error.Line, |
| 253 | }, |
| 254 | Reporter: yamlParseReporter, |
| 255 | Text: fmt.Sprintf("This rule is not a valid Prometheus rule: `%s`.", job.entry.Rule.Error.Err.Error()), |
| 256 | Details: `This Prometheus rule is not valid. |
| 257 | This usually means that it's missing some required fields.`, |
| 258 | Severity: checks.Fatal, |
| 259 | }, |
| 260 | Owner: job.entry.Owner, |
| 261 | } |
| 262 | default: |
| 263 | if job.entry.State == discovery.Unknown { |
| 264 | slog.Warn( |
| 265 | "Bug: unknown rule state", |
| 266 | slog.String("path", job.entry.ReportedPath), |
| 267 | slog.Int("line", job.entry.Rule.Lines.First), |
| 268 | slog.String("name", job.entry.Rule.Name()), |
| 269 | ) |
| 270 | } |
| 271 | |
| 272 | start := time.Now() |
| 273 | problems := job.check.Check(ctx, job.entry.ReportedPath, job.entry.Rule, job.allEntries) |
| 274 | checkDuration.WithLabelValues(job.check.Reporter()).Observe(time.Since(start).Seconds()) |
| 275 | for _, problem := range problems { |
| 276 | results <- reporter.Report{ |
| 277 | ReportedPath: job.entry.ReportedPath, |
| 278 | SourcePath: job.entry.SourcePath, |
| 279 | ModifiedLines: job.entry.ModifiedLines, |
| 280 | Rule: job.entry.Rule, |
| 281 | Problem: problem, |
| 282 | Owner: job.entry.Owner, |
| 283 | } |
| 284 | } |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | checkIterationChecksDone.Inc() |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func submitReports(reps []reporter.Reporter, summary reporter.Summary) (err error) { |
| 293 | for _, rep := range reps { |
| 294 | err = rep.Submit(summary) |
| 295 | if err != nil { |
| 296 | return err |
| 297 | } |
| 298 | } |
| 299 | return nil |
| 300 | } |