@intentius/chant-lexicon-prometheus 0.124.31 → 0.124.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/alertmanager.js +61 -0
- package/dist/api/alertmanager.js +98 -0
- package/dist/api/apply.js +306 -0
- package/dist/api/bind.js +66 -0
- package/dist/api/client.js +110 -0
- package/dist/api/evaluated.js +86 -0
- package/dist/api/fake-servers.js +165 -0
- package/dist/api/index.js +17 -0
- package/dist/api/ruler.js +122 -0
- package/dist/build.js +240 -0
- package/dist/catalog.js +66 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +105 -0
- package/dist/codegen/generate-cli.js +7 -0
- package/dist/codegen/generate.js +42 -0
- package/dist/codegen/package.js +28 -0
- package/dist/collector-metrics.js +225 -0
- package/dist/composites/alert-routing.js +210 -0
- package/dist/composites/catalog.js +344 -0
- package/dist/composites/genai.js +454 -0
- package/dist/composites/index.js +8 -0
- package/dist/composites/red-alerts.js +135 -0
- package/dist/composites/slo.js +353 -0
- package/dist/composites/watchdog.js +110 -0
- package/dist/config-model.js +52 -0
- package/dist/config.js +225 -0
- package/dist/describe-resources.js +299 -0
- package/dist/detect.js +12 -0
- package/dist/duration.js +54 -0
- package/dist/export-resources.js +163 -0
- package/dist/import/alertmanager-live.js +309 -0
- package/dist/import/embedded.js +100 -0
- package/dist/import/generator.js +683 -0
- package/dist/import/parser.js +428 -0
- package/dist/import/slo.js +207 -0
- package/dist/index.js +33 -0
- package/dist/init-templates.js +119 -0
- package/dist/integrations.js +33 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +60 -0
- package/dist/lint/audit-lineage.js +42 -0
- package/dist/lint/post-synth/index.js +68 -0
- package/dist/lint/post-synth/prom-helpers.js +157 -0
- package/dist/lint/post-synth/prom101.js +13 -0
- package/dist/lint/post-synth/prom102.js +13 -0
- package/dist/lint/post-synth/prom103.js +13 -0
- package/dist/lint/post-synth/prom104.js +13 -0
- package/dist/lint/post-synth/prom105.js +13 -0
- package/dist/lint/post-synth/prom106.js +13 -0
- package/dist/lint/post-synth/prom107.js +13 -0
- package/dist/lint/post-synth/prom201.js +13 -0
- package/dist/lint/post-synth/prom202.js +13 -0
- package/dist/lint/post-synth/prom203.js +13 -0
- package/dist/lint/post-synth/prom204.js +13 -0
- package/dist/lint/post-synth/prom205.js +13 -0
- package/dist/lint/post-synth/prom206.js +13 -0
- package/dist/lint/post-synth/prom207.js +13 -0
- package/dist/lint/post-synth/prom208.js +13 -0
- package/dist/lint/post-synth/prom209.js +13 -0
- package/dist/lint/post-synth/prom210.js +13 -0
- package/dist/lint/post-synth/prom211.js +17 -0
- package/dist/lint/post-synth/prom212.js +14 -0
- package/dist/lint/post-synth/prom213.js +16 -0
- package/dist/lint/post-synth/prom214.js +14 -0
- package/dist/lint/post-synth/prom215.js +14 -0
- package/dist/lint/post-synth/prom216.js +15 -0
- package/dist/lint/post-synth/prom217.js +14 -0
- package/dist/lint/post-synth/prom218.js +15 -0
- package/dist/lint/post-synth/prom219.js +14 -0
- package/dist/lint/post-synth/prom220.js +14 -0
- package/dist/lint/post-synth/prom221.js +15 -0
- package/dist/lint/post-synth/prom222.js +14 -0
- package/dist/lint/post-synth/prom223.js +15 -0
- package/dist/lint/post-synth/prom224.js +14 -0
- package/dist/lint/post-synth/prom225.js +15 -0
- package/dist/lint/post-synth/prom301.js +19 -0
- package/dist/lint/rules/index.js +8 -0
- package/dist/lint/rules/literal-credential.js +127 -0
- package/dist/lint/rules/prom-ast.js +65 -0
- package/dist/lint/rules/promql-literal.js +65 -0
- package/dist/lint/rules/slo-literal.js +79 -0
- package/dist/lsp/completions.js +11 -0
- package/dist/lsp/hover.js +21 -0
- package/dist/manifest.json +1 -1
- package/dist/matchers.js +131 -0
- package/dist/model.js +108 -0
- package/dist/op/activities/index.js +20 -0
- package/dist/op/activities/promtool.js +87 -0
- package/dist/op/activities/rule-audit.js +128 -0
- package/dist/op/activities/rules-loaded.js +73 -0
- package/dist/op/activities/silence.js +123 -0
- package/dist/op/activity-contracts.js +51 -0
- package/dist/op/audit-op.js +28 -0
- package/dist/op/builders.js +57 -0
- package/dist/op/slo-rule-tests.js +148 -0
- package/dist/package-cli.js +14 -0
- package/dist/pin.js +13 -0
- package/dist/plugin.js +143 -0
- package/dist/prometheus-config.js +38 -0
- package/dist/promql-analysis.js +343 -0
- package/dist/promql.js +44 -0
- package/dist/rule-eval.js +466 -0
- package/dist/rules.js +97 -0
- package/dist/serializer.js +59 -0
- package/dist/skill-defs.js +41 -0
- package/dist/tools.js +85 -0
- package/dist/validate-cli.js +7 -0
- package/dist/validate-config.js +556 -0
- package/dist/validate-integrations.js +388 -0
- package/dist/validate-relabel.js +52 -0
- package/dist/validate-security.js +135 -0
- package/dist/validate.js +69 -0
- package/package.json +10 -10
- package/src/codegen/package.ts +2 -2
package/dist/tools.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `promtool check rules`, `promtool check config` and `amtool check-config`, when they are installed.
|
|
3
|
+
*
|
|
4
|
+
* The lexicon's own checks cover structure, references and PromQL syntax.
|
|
5
|
+
* The upstream tools add what only Prometheus and Alertmanager know (PromQL
|
|
6
|
+
* types, template parsing in annotations, every receiver field). They are
|
|
7
|
+
* not bundled and not run by `chant build`, so a build gives the same answer
|
|
8
|
+
* everywhere; tests, e2e runs and CI jobs call these helpers, which report
|
|
9
|
+
* `ran: false` when the binary isn't on PATH instead of failing.
|
|
10
|
+
*/
|
|
11
|
+
import { spawnSync } from "child_process";
|
|
12
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
13
|
+
import { tmpdir } from "os";
|
|
14
|
+
import { dirname, join } from "path";
|
|
15
|
+
/** True when `bin` is on PATH (or is a path that runs). */
|
|
16
|
+
export function hasTool(bin) {
|
|
17
|
+
const r = spawnSync(bin, ["--version"], { encoding: "utf-8" });
|
|
18
|
+
return !r.error && r.status === 0;
|
|
19
|
+
}
|
|
20
|
+
function runOnText(bin, args, filename, text) {
|
|
21
|
+
if (!hasTool(bin))
|
|
22
|
+
return { ran: false, ok: false, output: "" };
|
|
23
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-prometheus-"));
|
|
24
|
+
try {
|
|
25
|
+
const file = join(dir, filename);
|
|
26
|
+
writeFileSync(file, text);
|
|
27
|
+
const r = spawnSync(bin, args(file), { encoding: "utf-8" });
|
|
28
|
+
return { ran: true, ok: r.status === 0, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
rmSync(dir, { recursive: true, force: true });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** Run `promtool check rules` over rule file YAML. `bin` defaults to `promtool` on PATH. */
|
|
35
|
+
export function promtoolCheckRules(yaml, bin = process.env.PROMTOOL ?? "promtool") {
|
|
36
|
+
return runOnText(bin, (f) => ["check", "rules", f], "rules.yml", yaml);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Run `promtool check config` over `prometheus.yml` text. The config is written
|
|
40
|
+
* as `prometheus.yml` in a directory of its own, and `files` (path relative to
|
|
41
|
+
* that directory to contents) are written beside it: `check config` also reads
|
|
42
|
+
* the rule files, certificates and credentials files the config names, so a
|
|
43
|
+
* config that names any needs them here. `bin` defaults to `promtool` on PATH.
|
|
44
|
+
*/
|
|
45
|
+
export function promtoolCheckConfig(yaml, files = {}, bin = process.env.PROMTOOL ?? "promtool") {
|
|
46
|
+
if (!hasTool(bin))
|
|
47
|
+
return { ran: false, ok: false, output: "" };
|
|
48
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-prometheus-"));
|
|
49
|
+
try {
|
|
50
|
+
writeFileSync(join(dir, "prometheus.yml"), yaml);
|
|
51
|
+
for (const [path, content] of Object.entries(files)) {
|
|
52
|
+
const target = join(dir, path);
|
|
53
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
54
|
+
writeFileSync(target, content);
|
|
55
|
+
}
|
|
56
|
+
const r = spawnSync(bin, ["check", "config", "prometheus.yml"], { cwd: dir, encoding: "utf-8" });
|
|
57
|
+
return { ran: true, ok: r.status === 0, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
|
|
58
|
+
}
|
|
59
|
+
finally {
|
|
60
|
+
rmSync(dir, { recursive: true, force: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Run `promtool test rules` over rule file YAML and a unit-test file for it.
|
|
65
|
+
* The test file's `rule_files:` should name `rules.yml`, which is where the
|
|
66
|
+
* rule file is written beside it. `bin` defaults to `promtool` on PATH.
|
|
67
|
+
*/
|
|
68
|
+
export function promtoolTestRules(rulesYaml, testYaml, bin = process.env.PROMTOOL ?? "promtool") {
|
|
69
|
+
if (!hasTool(bin))
|
|
70
|
+
return { ran: false, ok: false, output: "" };
|
|
71
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-prometheus-"));
|
|
72
|
+
try {
|
|
73
|
+
writeFileSync(join(dir, "rules.yml"), rulesYaml);
|
|
74
|
+
writeFileSync(join(dir, "rules.test.yml"), testYaml);
|
|
75
|
+
const r = spawnSync(bin, ["test", "rules", "rules.test.yml"], { cwd: dir, encoding: "utf-8" });
|
|
76
|
+
return { ran: true, ok: r.status === 0, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
rmSync(dir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Run `amtool check-config` over `alertmanager.yml` text. `bin` defaults to `amtool` on PATH. */
|
|
83
|
+
export function amtoolCheckConfig(yaml, bin = process.env.AMTOOL ?? "amtool") {
|
|
84
|
+
return runOnText(bin, (f) => ["check-config", f], "alertmanager.yml", yaml);
|
|
85
|
+
}
|
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule file and Alertmanager config checks, as plain functions over the
|
|
3
|
+
* parsed files.
|
|
4
|
+
*
|
|
5
|
+
* The post-synth checks in `lint/post-synth/` are thin wrappers around these,
|
|
6
|
+
* so the same rules run anywhere the files exist: on a build's output, on a
|
|
7
|
+
* `PrometheusRule`'s groups, or on a file parsed by some other tool.
|
|
8
|
+
*/
|
|
9
|
+
import { durationMs, formatDuration, isValidDuration } from "./duration.js";
|
|
10
|
+
import { matcherMatches, parseMatchers } from "./matchers.js";
|
|
11
|
+
import { isAlertingRuleConfig, isRecordingRuleConfig, } from "./model.js";
|
|
12
|
+
import { checkPromql } from "./promql.js";
|
|
13
|
+
import { hasCondition, histogramProblems, isMultiWindow, keepsLabel, nonCounterRates, parsePromql, readsOnlyOverTime, regexProblems, resultLabels, selectors, } from "./promql-analysis.js";
|
|
14
|
+
import { validateGlobalSettings, validateReceiverIntegrations } from "./validate-integrations.js";
|
|
15
|
+
import { validateGlobalSecurity, validateReceiverSecurity } from "./validate-security.js";
|
|
16
|
+
function str(v) {
|
|
17
|
+
return typeof v === "string" ? v : String(v);
|
|
18
|
+
}
|
|
19
|
+
// ── Rule files ──────────────────────────────────────────────────────
|
|
20
|
+
function ruleLabel(group, index, rule) {
|
|
21
|
+
const r = rule;
|
|
22
|
+
const name = typeof r?.record === "string" ? r.record : typeof r?.alert === "string" ? r.alert : `rule ${index + 1}`;
|
|
23
|
+
return `${group.name}/${name}`;
|
|
24
|
+
}
|
|
25
|
+
/** The static labels an alert carries: the group's labels under its own. */
|
|
26
|
+
function alertLabels(group, rule) {
|
|
27
|
+
return { ...(group.labels ?? {}), ...(rule.labels ?? {}) };
|
|
28
|
+
}
|
|
29
|
+
/** The `$labels.x`, `.Labels.x` and `index $labels "x"` references in a template. */
|
|
30
|
+
function templateLabels(template) {
|
|
31
|
+
const out = new Set();
|
|
32
|
+
for (const re of [/\$labels\.([A-Za-z_][A-Za-z0-9_]*)/g, /\.Labels\.([A-Za-z_][A-Za-z0-9_]*)/g, /index\s+\$labels\s+"([^"]+)"/g]) {
|
|
33
|
+
for (const m of template.matchAll(re))
|
|
34
|
+
out.add(m[1]);
|
|
35
|
+
}
|
|
36
|
+
return [...out];
|
|
37
|
+
}
|
|
38
|
+
/** `level:metric:operations`: three or more non-empty parts separated by colons. */
|
|
39
|
+
const RECORD_NAME = /^[a-zA-Z_][a-zA-Z0-9_]*(?::[a-zA-Z0-9_]+){2,}$/;
|
|
40
|
+
/** PROM215, PROM216 and PROM218: what any rule's expression can get wrong. */
|
|
41
|
+
function exprIssues(expr, subject) {
|
|
42
|
+
const issues = [];
|
|
43
|
+
for (const { fn, name } of nonCounterRates(expr)) {
|
|
44
|
+
issues.push({
|
|
45
|
+
code: "PROM215",
|
|
46
|
+
severity: "warning",
|
|
47
|
+
subject,
|
|
48
|
+
message: `rule ${subject} takes ${fn}() of ${name}, whose name does not end in _total, _count, _sum or _bucket; ${fn}() reads only counters, so use delta() or deriv() for a gauge`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
for (const { call, problem } of histogramProblems(expr)) {
|
|
52
|
+
issues.push({
|
|
53
|
+
code: "PROM216",
|
|
54
|
+
severity: "warning",
|
|
55
|
+
subject,
|
|
56
|
+
message: problem === "no-bucket"
|
|
57
|
+
? `rule ${subject} ${call} reads a series without _bucket in its name; a classic histogram's quantile needs its _bucket series`
|
|
58
|
+
: `rule ${subject} ${call} aggregates away the le label; keep le in by (...), or out of without (...)`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
for (const { matcher, problem } of regexProblems(expr)) {
|
|
62
|
+
issues.push({
|
|
63
|
+
code: "PROM218",
|
|
64
|
+
severity: "warning",
|
|
65
|
+
subject,
|
|
66
|
+
message: problem === "literal"
|
|
67
|
+
? `rule ${subject} matcher ${matcher} has no regex metacharacters; use = or != instead`
|
|
68
|
+
: `rule ${subject} matcher ${matcher} is anchored; Prometheus anchors every regex, so drop the ^ and $`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return issues;
|
|
72
|
+
}
|
|
73
|
+
/** PROM211, PROM213, PROM214 and PROM219: alerting rules. */
|
|
74
|
+
function alertIssues(group, rule, subject) {
|
|
75
|
+
const issues = [];
|
|
76
|
+
const expr = typeof rule.expr === "string" ? rule.expr : "";
|
|
77
|
+
// An expression that reads no series (`vector(1)`) is a deliberate always-firing
|
|
78
|
+
// alert, a dead man's switch; PROM211 and PROM213 leave it alone.
|
|
79
|
+
const heartbeat = parsePromql(expr) !== undefined && selectors(expr).length === 0;
|
|
80
|
+
// PROM211: no for, or for: 0s. An expression that already spans a window
|
|
81
|
+
// (every selector read through *_over_time, or the multi-window `and` of two
|
|
82
|
+
// conditions, where the short window does what for would) is left alone. A
|
|
83
|
+
// for that is not a duration is PROM103's.
|
|
84
|
+
const forMs = rule.for === undefined ? 0 : durationMs(str(rule.for));
|
|
85
|
+
if (forMs === 0 && !heartbeat && !readsOnlyOverTime(expr) && !isMultiWindow(expr)) {
|
|
86
|
+
issues.push({
|
|
87
|
+
code: "PROM211",
|
|
88
|
+
severity: "warning",
|
|
89
|
+
subject,
|
|
90
|
+
message: `alert ${subject} has ${rule.for === undefined ? "no for" : `for: ${str(rule.for)}`}, so one evaluation that matches fires it; set for to how long the condition must hold`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// PROM213: no condition
|
|
94
|
+
if (!heartbeat && hasCondition(expr) === false) {
|
|
95
|
+
issues.push({
|
|
96
|
+
code: "PROM213",
|
|
97
|
+
severity: "warning",
|
|
98
|
+
subject,
|
|
99
|
+
message: `alert ${subject} expression has no comparison, so it fires for every series it returns; add a condition such as > 0`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
// PROM214: a template reads a label the expression aggregates away. A label
|
|
103
|
+
// the rule or its group sets is left alone.
|
|
104
|
+
const scope = resultLabels(expr);
|
|
105
|
+
if (scope) {
|
|
106
|
+
const fixed = alertLabels(group, rule);
|
|
107
|
+
const reported = new Set();
|
|
108
|
+
for (const [key, value] of [...Object.entries(rule.annotations ?? {}), ...Object.entries(rule.labels ?? {})]) {
|
|
109
|
+
for (const label of templateLabels(str(value))) {
|
|
110
|
+
if (keepsLabel(scope, label) || label in fixed || reported.has(label))
|
|
111
|
+
continue;
|
|
112
|
+
reported.add(label);
|
|
113
|
+
issues.push({
|
|
114
|
+
code: "PROM214",
|
|
115
|
+
severity: "warning",
|
|
116
|
+
subject,
|
|
117
|
+
message: `alert ${subject} ${key} reads $labels.${label}, which the expression's by or without drops, so it renders empty`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// PROM219: alertname set by hand
|
|
123
|
+
for (const [where, labels] of [
|
|
124
|
+
["labels", rule.labels],
|
|
125
|
+
["group labels", group.labels],
|
|
126
|
+
]) {
|
|
127
|
+
if (labels && Object.prototype.hasOwnProperty.call(labels, "alertname")) {
|
|
128
|
+
issues.push({
|
|
129
|
+
code: "PROM219",
|
|
130
|
+
severity: "warning",
|
|
131
|
+
subject,
|
|
132
|
+
message: `alert ${subject} ${where} set alertname; Prometheus sets alertname to the rule's name and overwrites it`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return issues;
|
|
137
|
+
}
|
|
138
|
+
/** Check a rule file (PROM101-PROM107, PROM211 and PROM213-PROM219). PROM212 is opt-in; see {@link validateRunbookUrls}. */
|
|
139
|
+
export function validateRuleFile(file) {
|
|
140
|
+
const issues = [];
|
|
141
|
+
const groups = Array.isArray(file?.groups) ? file.groups : [];
|
|
142
|
+
const groupNames = new Map();
|
|
143
|
+
const ruleKeys = new Map();
|
|
144
|
+
for (const group of groups) {
|
|
145
|
+
const gname = str(group?.name ?? "");
|
|
146
|
+
groupNames.set(gname, (groupNames.get(gname) ?? 0) + 1);
|
|
147
|
+
if (groupNames.get(gname) === 2) {
|
|
148
|
+
issues.push({
|
|
149
|
+
code: "PROM101",
|
|
150
|
+
severity: "error",
|
|
151
|
+
subject: gname,
|
|
152
|
+
message: `rule group "${gname}" is declared more than once; Prometheus requires group names to be unique within a rule file`,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (gname.trim() === "") {
|
|
156
|
+
issues.push({ code: "PROM105", severity: "error", subject: gname, message: "a rule group has no name" });
|
|
157
|
+
}
|
|
158
|
+
for (const field of ["interval", "query_offset"]) {
|
|
159
|
+
const v = group?.[field];
|
|
160
|
+
if (v !== undefined && !isValidDuration(v)) {
|
|
161
|
+
issues.push({
|
|
162
|
+
code: "PROM103",
|
|
163
|
+
severity: "error",
|
|
164
|
+
subject: gname,
|
|
165
|
+
message: `group "${gname}" ${field} "${str(v)}" is not a Prometheus duration (e.g. 30s, 1m, 1h30m)`,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const rules = Array.isArray(group?.rules) ? group.rules : [];
|
|
170
|
+
rules.forEach((rule, i) => {
|
|
171
|
+
const subject = ruleLabel(group, i, rule);
|
|
172
|
+
const r = rule;
|
|
173
|
+
const recording = isRecordingRuleConfig(rule);
|
|
174
|
+
const alerting = isAlertingRuleConfig(rule);
|
|
175
|
+
// PROM105: shape
|
|
176
|
+
if (recording && alerting) {
|
|
177
|
+
issues.push({ code: "PROM105", severity: "error", subject, message: `rule ${subject} sets both record and alert; a rule is one or the other` });
|
|
178
|
+
}
|
|
179
|
+
else if (!recording && !alerting) {
|
|
180
|
+
issues.push({ code: "PROM105", severity: "error", subject, message: `rule ${subject} sets neither record nor alert` });
|
|
181
|
+
}
|
|
182
|
+
else if (recording) {
|
|
183
|
+
if (rule.record.trim() === "")
|
|
184
|
+
issues.push({ code: "PROM105", severity: "error", subject, message: `a recording rule in group "${gname}" has an empty record name` });
|
|
185
|
+
for (const bad of ["for", "keep_firing_for", "annotations"]) {
|
|
186
|
+
if (r[bad] !== undefined) {
|
|
187
|
+
issues.push({ code: "PROM105", severity: "error", subject, message: `recording rule ${subject} sets ${bad}, which only alerting rules take` });
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
else if (alerting && rule.alert.trim() === "") {
|
|
192
|
+
issues.push({ code: "PROM105", severity: "error", subject, message: `an alerting rule in group "${gname}" has an empty alert name` });
|
|
193
|
+
}
|
|
194
|
+
// PROM103: durations
|
|
195
|
+
for (const field of ["for", "keep_firing_for"]) {
|
|
196
|
+
const v = r[field];
|
|
197
|
+
if (v !== undefined && !isValidDuration(v)) {
|
|
198
|
+
issues.push({
|
|
199
|
+
code: "PROM103",
|
|
200
|
+
severity: "error",
|
|
201
|
+
subject,
|
|
202
|
+
message: `rule ${subject} ${field} "${str(v)}" is not a Prometheus duration (e.g. 5m, 1h)`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// PROM104: PromQL
|
|
207
|
+
const expr = r.expr;
|
|
208
|
+
const checked = checkPromql(typeof expr === "string" ? expr : "");
|
|
209
|
+
if (!checked.ok) {
|
|
210
|
+
issues.push({ code: "PROM104", severity: "error", subject, message: `rule ${subject} expr: ${checked.message}` });
|
|
211
|
+
}
|
|
212
|
+
// PROM102: duplicates (same kind, name and labels anywhere in the file)
|
|
213
|
+
if (recording || alerting) {
|
|
214
|
+
const labels = recording ? { ...(group.labels ?? {}), ...(rule.labels ?? {}) } : alertLabels(group, rule);
|
|
215
|
+
const key = JSON.stringify([recording ? "record" : "alert", recording ? rule.record : rule.alert, Object.entries(labels).sort()]);
|
|
216
|
+
const first = ruleKeys.get(key);
|
|
217
|
+
if (first !== undefined) {
|
|
218
|
+
issues.push({
|
|
219
|
+
code: "PROM102",
|
|
220
|
+
severity: "warning",
|
|
221
|
+
subject,
|
|
222
|
+
message: `rule ${subject} has the same name and labels as ${first}; the two produce the same series and overwrite each other`,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
ruleKeys.set(key, subject);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (alerting) {
|
|
230
|
+
const labels = alertLabels(group, rule);
|
|
231
|
+
// PROM106: severity
|
|
232
|
+
if (!labels.severity) {
|
|
233
|
+
issues.push({
|
|
234
|
+
code: "PROM106",
|
|
235
|
+
severity: "warning",
|
|
236
|
+
subject,
|
|
237
|
+
message: `alert ${subject} has no severity label, so Alertmanager can only route it by name`,
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
// PROM107: summary/description
|
|
241
|
+
const ann = rule.annotations ?? {};
|
|
242
|
+
if (!ann.summary && !ann.description) {
|
|
243
|
+
issues.push({
|
|
244
|
+
code: "PROM107",
|
|
245
|
+
severity: "warning",
|
|
246
|
+
subject,
|
|
247
|
+
message: `alert ${subject} has no summary or description annotation; a notification carries only its labels`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
issues.push(...alertIssues(group, rule, subject));
|
|
251
|
+
}
|
|
252
|
+
// PROM217: recording rule name
|
|
253
|
+
if (recording && !alerting && rule.record.trim() !== "" && !RECORD_NAME.test(rule.record)) {
|
|
254
|
+
issues.push({
|
|
255
|
+
code: "PROM217",
|
|
256
|
+
severity: "warning",
|
|
257
|
+
subject,
|
|
258
|
+
message: `recording rule ${subject} is not named level:metric:operations (e.g. job:http_requests:rate5m)`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
if (checked.ok)
|
|
262
|
+
issues.push(...exprIssues(expr, subject));
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return issues;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* PROM212: alerts without a `runbook_url` annotation. Not part of
|
|
269
|
+
* {@link validateRuleFile}, because the check is opt-in: the lint preset
|
|
270
|
+
* `all`, or a `lint.rules` entry for PROM212, turns it on.
|
|
271
|
+
*/
|
|
272
|
+
export function validateRunbookUrls(file) {
|
|
273
|
+
const issues = [];
|
|
274
|
+
for (const group of Array.isArray(file?.groups) ? file.groups : []) {
|
|
275
|
+
(Array.isArray(group?.rules) ? group.rules : []).forEach((rule, i) => {
|
|
276
|
+
if (!isAlertingRuleConfig(rule) || rule.annotations?.runbook_url)
|
|
277
|
+
return;
|
|
278
|
+
const subject = ruleLabel(group, i, rule);
|
|
279
|
+
issues.push({ code: "PROM212", severity: "warning", subject, message: `alert ${subject} has no runbook_url annotation` });
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
return issues;
|
|
283
|
+
}
|
|
284
|
+
/** Every severity value an alert in these rule files carries, with the alerts carrying it. */
|
|
285
|
+
export function alertSeverities(files) {
|
|
286
|
+
const out = new Map();
|
|
287
|
+
for (const file of files) {
|
|
288
|
+
for (const group of Array.isArray(file?.groups) ? file.groups : []) {
|
|
289
|
+
for (const rule of Array.isArray(group?.rules) ? group.rules : []) {
|
|
290
|
+
if (!isAlertingRuleConfig(rule))
|
|
291
|
+
continue;
|
|
292
|
+
const sev = alertLabels(group, rule).severity;
|
|
293
|
+
if (!sev)
|
|
294
|
+
continue;
|
|
295
|
+
out.set(sev, [...(out.get(sev) ?? []), `${group.name}/${rule.alert}`]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}
|
|
301
|
+
function visitRoutes(root) {
|
|
302
|
+
const out = [];
|
|
303
|
+
const walk = (route, path, depth) => {
|
|
304
|
+
out.push({ route, path, depth });
|
|
305
|
+
(Array.isArray(route?.routes) ? route.routes : []).forEach((child, i) => walk(child, `${path}.routes[${i}]`, depth + 1));
|
|
306
|
+
};
|
|
307
|
+
if (root && typeof root === "object")
|
|
308
|
+
walk(root, "route", 0);
|
|
309
|
+
return out;
|
|
310
|
+
}
|
|
311
|
+
const AM_DURATIONS = ["group_wait", "group_interval", "repeat_interval"];
|
|
312
|
+
/** Alertmanager's defaults for a root route that sets neither (config/config.go). */
|
|
313
|
+
const DEFAULT_GROUP_INTERVAL_MS = 5 * 60000;
|
|
314
|
+
const DEFAULT_REPEAT_INTERVAL_MS = 4 * 3600000;
|
|
315
|
+
/**
|
|
316
|
+
* PROM223: routes whose repeat_interval, as set or inherited, is shorter
|
|
317
|
+
* than their group_interval. Alertmanager only resends when a group is
|
|
318
|
+
* flushed, every group_interval, so the shorter repeat_interval never takes
|
|
319
|
+
* effect. Reported on the route that sets one of the two.
|
|
320
|
+
*/
|
|
321
|
+
function repeatIntervalIssues(root) {
|
|
322
|
+
const issues = [];
|
|
323
|
+
const walk = (route, path, groupMs, repeatMs) => {
|
|
324
|
+
const ownGroup = route.group_interval !== undefined ? durationMs(str(route.group_interval)) : groupMs;
|
|
325
|
+
const ownRepeat = route.repeat_interval !== undefined ? durationMs(str(route.repeat_interval)) : repeatMs;
|
|
326
|
+
if (ownGroup === undefined || ownRepeat === undefined)
|
|
327
|
+
return; // PROM208
|
|
328
|
+
if ((route.group_interval !== undefined || route.repeat_interval !== undefined) && ownRepeat < ownGroup) {
|
|
329
|
+
issues.push({
|
|
330
|
+
code: "PROM223",
|
|
331
|
+
severity: "warning",
|
|
332
|
+
subject: path,
|
|
333
|
+
message: `${path} repeat_interval (${formatDuration(ownRepeat)}) is shorter than its group_interval (${formatDuration(ownGroup)}); notifications repeat no faster than group_interval`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
(Array.isArray(route.routes) ? route.routes : []).forEach((child, i) => walk(child, `${path}.routes[${i}]`, ownGroup, ownRepeat));
|
|
337
|
+
};
|
|
338
|
+
if (root && typeof root === "object")
|
|
339
|
+
walk(root, "route", DEFAULT_GROUP_INTERVAL_MS, DEFAULT_REPEAT_INTERVAL_MS);
|
|
340
|
+
return issues;
|
|
341
|
+
}
|
|
342
|
+
function parsedList(entries) {
|
|
343
|
+
const out = [];
|
|
344
|
+
for (const m of Array.isArray(entries) ? entries : []) {
|
|
345
|
+
const parsed = parseMatchers(str(m));
|
|
346
|
+
if (!parsed.ok)
|
|
347
|
+
return undefined; // PROM206
|
|
348
|
+
out.push(...parsed.matchers);
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
/** A regex's alternatives when every one is a plain string, e.g. `a|b` gives `["a", "b"]`. */
|
|
353
|
+
function literalAlternatives(regex) {
|
|
354
|
+
const parts = regex.split("|");
|
|
355
|
+
return parts.every((p) => !/[.+*?()[\]{}\\^$]/.test(p)) ? parts : undefined;
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Whether some label set matches every matcher. A label whose matchers
|
|
359
|
+
* name values (`=`, or a regex of plain alternatives) is tried with those
|
|
360
|
+
* values; one with only negative or open-ended matchers is taken to have a
|
|
361
|
+
* value that passes.
|
|
362
|
+
*/
|
|
363
|
+
function satisfiable(matchers) {
|
|
364
|
+
const byLabel = new Map();
|
|
365
|
+
for (const m of matchers)
|
|
366
|
+
byLabel.set(m.name, [...(byLabel.get(m.name) ?? []), m]);
|
|
367
|
+
for (const [name, ms] of byLabel) {
|
|
368
|
+
const candidates = new Set();
|
|
369
|
+
let bounded = false;
|
|
370
|
+
for (const m of ms) {
|
|
371
|
+
if (m.op === "=") {
|
|
372
|
+
candidates.add(m.value);
|
|
373
|
+
bounded = true;
|
|
374
|
+
}
|
|
375
|
+
else if (m.op === "=~") {
|
|
376
|
+
const alts = literalAlternatives(m.value);
|
|
377
|
+
if (alts) {
|
|
378
|
+
alts.forEach((a) => candidates.add(a));
|
|
379
|
+
bounded = true;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
if (!bounded)
|
|
384
|
+
continue;
|
|
385
|
+
if (![...candidates].some((v) => ms.every((m) => matcherMatches(m, { [name]: v }))))
|
|
386
|
+
return false;
|
|
387
|
+
}
|
|
388
|
+
return true;
|
|
389
|
+
}
|
|
390
|
+
/** Check an `alertmanager.yml` on its own (PROM201, PROM203-PROM210, PROM220-PROM224). */
|
|
391
|
+
export function validateAlertmanagerConfig(config) {
|
|
392
|
+
const issues = [];
|
|
393
|
+
const receivers = Array.isArray(config?.receivers) ? config.receivers : [];
|
|
394
|
+
const intervals = Array.isArray(config?.time_intervals) ? config.time_intervals : [];
|
|
395
|
+
const global = config?.global ?? {};
|
|
396
|
+
// PROM203: duplicate names
|
|
397
|
+
const seenReceivers = new Set();
|
|
398
|
+
for (const r of receivers) {
|
|
399
|
+
const name = str(r?.name ?? "");
|
|
400
|
+
if (seenReceivers.has(name)) {
|
|
401
|
+
issues.push({ code: "PROM203", severity: "error", subject: name, message: `receiver "${name}" is declared more than once; receiver names must be unique` });
|
|
402
|
+
}
|
|
403
|
+
seenReceivers.add(name);
|
|
404
|
+
}
|
|
405
|
+
const seenIntervals = new Set();
|
|
406
|
+
for (const t of intervals) {
|
|
407
|
+
const name = str(t?.name ?? "");
|
|
408
|
+
if (seenIntervals.has(name)) {
|
|
409
|
+
issues.push({ code: "PROM203", severity: "error", subject: name, message: `time interval "${name}" is declared more than once; time interval names must be unique` });
|
|
410
|
+
}
|
|
411
|
+
seenIntervals.add(name);
|
|
412
|
+
}
|
|
413
|
+
// PROM205: root route shape
|
|
414
|
+
const root = config?.route;
|
|
415
|
+
if (!root || typeof root !== "object") {
|
|
416
|
+
issues.push({
|
|
417
|
+
code: "PROM205",
|
|
418
|
+
severity: "error",
|
|
419
|
+
subject: "route",
|
|
420
|
+
message: "alertmanager.yml has no root route; declare one Route with a receiver and nest the others under it",
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
if (!root.receiver) {
|
|
425
|
+
issues.push({ code: "PROM205", severity: "error", subject: "route", message: "the root route has no receiver; it is the default for every alert no child route takes" });
|
|
426
|
+
}
|
|
427
|
+
if (Array.isArray(root.matchers) && root.matchers.length > 0) {
|
|
428
|
+
issues.push({ code: "PROM205", severity: "error", subject: "route", message: "the root route has matchers; it must match every alert, so put matchers on child routes" });
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
const usedReceivers = new Set();
|
|
432
|
+
for (const { route, path } of visitRoutes(root)) {
|
|
433
|
+
// PROM201: receiver exists
|
|
434
|
+
if (route.receiver !== undefined) {
|
|
435
|
+
const name = str(route.receiver);
|
|
436
|
+
usedReceivers.add(name);
|
|
437
|
+
if (!seenReceivers.has(name)) {
|
|
438
|
+
issues.push({ code: "PROM201", severity: "error", subject: path, message: `${path} sends to receiver "${name}", which is not declared` });
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// PROM204: time intervals exist
|
|
442
|
+
for (const field of ["mute_time_intervals", "active_time_intervals"]) {
|
|
443
|
+
for (const t of route[field] ?? []) {
|
|
444
|
+
if (!seenIntervals.has(str(t))) {
|
|
445
|
+
issues.push({ code: "PROM204", severity: "error", subject: path, message: `${path} ${field} names time interval "${str(t)}", which is not declared` });
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
// PROM206: matchers parse
|
|
450
|
+
for (const m of route.matchers ?? []) {
|
|
451
|
+
const parsed = parseMatchers(str(m));
|
|
452
|
+
if (!parsed.ok)
|
|
453
|
+
issues.push({ code: "PROM206", severity: "error", subject: path, message: `${path} matcher ${parsed.error}` });
|
|
454
|
+
}
|
|
455
|
+
// PROM208: durations
|
|
456
|
+
for (const field of AM_DURATIONS) {
|
|
457
|
+
const v = route[field];
|
|
458
|
+
if (v !== undefined && !isValidDuration(v)) {
|
|
459
|
+
issues.push({ code: "PROM208", severity: "error", subject: path, message: `${path} ${field} "${str(v)}" is not a duration (e.g. 30s, 5m, 4h)` });
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
(Array.isArray(config?.inhibit_rules) ? config.inhibit_rules : []).forEach((rule, i) => {
|
|
464
|
+
for (const field of ["source_matchers", "target_matchers"]) {
|
|
465
|
+
for (const m of rule?.[field] ?? []) {
|
|
466
|
+
const parsed = parseMatchers(str(m));
|
|
467
|
+
if (!parsed.ok) {
|
|
468
|
+
issues.push({ code: "PROM206", severity: "error", subject: `inhibit_rules[${i}]`, message: `inhibit_rules[${i}].${field} matcher ${parsed.error}` });
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
});
|
|
473
|
+
// PROM223: repeat_interval under group_interval
|
|
474
|
+
issues.push(...repeatIntervalIssues(root));
|
|
475
|
+
// PROM224: an inhibit rule an alert can be both source and target of, with no equal
|
|
476
|
+
(Array.isArray(config?.inhibit_rules) ? config.inhibit_rules : []).forEach((rule, i) => {
|
|
477
|
+
if (Array.isArray(rule?.equal) && rule.equal.length > 0)
|
|
478
|
+
return;
|
|
479
|
+
const source = parsedList(rule?.source_matchers);
|
|
480
|
+
const target = parsedList(rule?.target_matchers);
|
|
481
|
+
if (!source || !target || !satisfiable([...source, ...target]))
|
|
482
|
+
return;
|
|
483
|
+
issues.push({
|
|
484
|
+
code: "PROM224",
|
|
485
|
+
severity: "warning",
|
|
486
|
+
subject: `inhibit_rules[${i}]`,
|
|
487
|
+
message: `inhibit_rules[${i}]: one alert can match both source_matchers and target_matchers, and there is no equal, so any such alert firing mutes every other; list the labels the two must share under equal`,
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
// PROM208, PROM210: global settings; PROM220: global TLS
|
|
491
|
+
issues.push(...validateGlobalSettings(global));
|
|
492
|
+
issues.push(...validateGlobalSecurity(global));
|
|
493
|
+
// PROM207: unused receivers; PROM208-PROM210: integrations (./validate-integrations.ts)
|
|
494
|
+
for (const r of receivers) {
|
|
495
|
+
const name = str(r?.name ?? "");
|
|
496
|
+
if (!usedReceivers.has(name)) {
|
|
497
|
+
issues.push({ code: "PROM207", severity: "warning", subject: name, message: `receiver "${name}" is declared but no route sends to it` });
|
|
498
|
+
}
|
|
499
|
+
issues.push(...validateReceiverIntegrations(r, global));
|
|
500
|
+
issues.push(...validateReceiverSecurity(r, global));
|
|
501
|
+
// A receiver with no integrations at all is valid: it is how Alertmanager drops alerts.
|
|
502
|
+
}
|
|
503
|
+
return issues;
|
|
504
|
+
}
|
|
505
|
+
function severityMatchers(route) {
|
|
506
|
+
const out = [];
|
|
507
|
+
for (const m of route.matchers ?? []) {
|
|
508
|
+
const parsed = parseMatchers(str(m));
|
|
509
|
+
if (!parsed.ok)
|
|
510
|
+
return undefined;
|
|
511
|
+
out.push(...parsed.matchers.filter((x) => x.name === "severity"));
|
|
512
|
+
}
|
|
513
|
+
return out;
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* PROM202: every severity an alert carries is taken by some route below the
|
|
517
|
+
* root that matches on `severity`. An alert whose severity no route names
|
|
518
|
+
* falls through to the root route's default receiver.
|
|
519
|
+
*
|
|
520
|
+
* Only matchers on `severity` are read, since other labels (team, service)
|
|
521
|
+
* come from the series and are not known at build time. A route counts when
|
|
522
|
+
* it, or a route above it, matches on `severity`, and every severity matcher
|
|
523
|
+
* on its path accepts the value.
|
|
524
|
+
*/
|
|
525
|
+
export function validateSeverityRouting(ruleFiles, config) {
|
|
526
|
+
const severities = alertSeverities(ruleFiles);
|
|
527
|
+
if (severities.size === 0 || !config?.route)
|
|
528
|
+
return [];
|
|
529
|
+
const paths = [];
|
|
530
|
+
const walk = (route, inherited, depth) => {
|
|
531
|
+
const own = severityMatchers(route);
|
|
532
|
+
if (own === undefined)
|
|
533
|
+
return; // PROM206 reports the unparseable matcher
|
|
534
|
+
const path = [...inherited, ...own];
|
|
535
|
+
if (depth > 0 && path.length > 0)
|
|
536
|
+
paths.push(path);
|
|
537
|
+
for (const child of Array.isArray(route.routes) ? route.routes : [])
|
|
538
|
+
walk(child, path, depth + 1);
|
|
539
|
+
};
|
|
540
|
+
walk(config.route, [], 0);
|
|
541
|
+
const issues = [];
|
|
542
|
+
for (const [severity, alerts] of severities) {
|
|
543
|
+
const labels = { severity };
|
|
544
|
+
const routed = paths.some((path) => path.every((m) => matcherMatches(m, labels)));
|
|
545
|
+
if (!routed) {
|
|
546
|
+
const shown = alerts.slice(0, 3).join(", ") + (alerts.length > 3 ? `, and ${alerts.length - 3} more` : "");
|
|
547
|
+
issues.push({
|
|
548
|
+
code: "PROM202",
|
|
549
|
+
severity: "warning",
|
|
550
|
+
subject: severity,
|
|
551
|
+
message: `no route matches severity="${severity}" (${shown}); those alerts fall through to the root route's default receiver`,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return issues;
|
|
556
|
+
}
|