@intentius/chant-lexicon-prometheus 0.96.0

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.
Files changed (237) hide show
  1. package/README.md +58 -0
  2. package/dist/alertmanager.d.ts +81 -0
  3. package/dist/alertmanager.d.ts.map +1 -0
  4. package/dist/build.d.ts +33 -0
  5. package/dist/build.d.ts.map +1 -0
  6. package/dist/catalog.d.ts +17 -0
  7. package/dist/catalog.d.ts.map +1 -0
  8. package/dist/codegen/docs-cli.d.ts +3 -0
  9. package/dist/codegen/docs-cli.d.ts.map +1 -0
  10. package/dist/codegen/docs.d.ts +9 -0
  11. package/dist/codegen/docs.d.ts.map +1 -0
  12. package/dist/codegen/generate-cli.d.ts +3 -0
  13. package/dist/codegen/generate-cli.d.ts.map +1 -0
  14. package/dist/codegen/generate.d.ts +19 -0
  15. package/dist/codegen/generate.d.ts.map +1 -0
  16. package/dist/codegen/package.d.ts +7 -0
  17. package/dist/codegen/package.d.ts.map +1 -0
  18. package/dist/composites/catalog.d.ts +3 -0
  19. package/dist/composites/catalog.d.ts.map +1 -0
  20. package/dist/composites/index.d.ts +6 -0
  21. package/dist/composites/index.d.ts.map +1 -0
  22. package/dist/composites/slo.d.ts +205 -0
  23. package/dist/composites/slo.d.ts.map +1 -0
  24. package/dist/detect.d.ts +2 -0
  25. package/dist/detect.d.ts.map +1 -0
  26. package/dist/duration.d.ts +16 -0
  27. package/dist/duration.d.ts.map +1 -0
  28. package/dist/index.d.ts +14 -0
  29. package/dist/index.d.ts.map +1 -0
  30. package/dist/init-templates.d.ts +13 -0
  31. package/dist/init-templates.d.ts.map +1 -0
  32. package/dist/integrity.json +33 -0
  33. package/dist/lint/audit-catalog.d.ts +13 -0
  34. package/dist/lint/audit-catalog.d.ts.map +1 -0
  35. package/dist/lint/post-synth/index.d.ts +3 -0
  36. package/dist/lint/post-synth/index.d.ts.map +1 -0
  37. package/dist/lint/post-synth/prom-helpers.d.ts +33 -0
  38. package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -0
  39. package/dist/lint/post-synth/prom101.d.ts +8 -0
  40. package/dist/lint/post-synth/prom101.d.ts.map +1 -0
  41. package/dist/lint/post-synth/prom102.d.ts +8 -0
  42. package/dist/lint/post-synth/prom102.d.ts.map +1 -0
  43. package/dist/lint/post-synth/prom103.d.ts +8 -0
  44. package/dist/lint/post-synth/prom103.d.ts.map +1 -0
  45. package/dist/lint/post-synth/prom104.d.ts +8 -0
  46. package/dist/lint/post-synth/prom104.d.ts.map +1 -0
  47. package/dist/lint/post-synth/prom105.d.ts +8 -0
  48. package/dist/lint/post-synth/prom105.d.ts.map +1 -0
  49. package/dist/lint/post-synth/prom106.d.ts +8 -0
  50. package/dist/lint/post-synth/prom106.d.ts.map +1 -0
  51. package/dist/lint/post-synth/prom107.d.ts +8 -0
  52. package/dist/lint/post-synth/prom107.d.ts.map +1 -0
  53. package/dist/lint/post-synth/prom201.d.ts +8 -0
  54. package/dist/lint/post-synth/prom201.d.ts.map +1 -0
  55. package/dist/lint/post-synth/prom202.d.ts +8 -0
  56. package/dist/lint/post-synth/prom202.d.ts.map +1 -0
  57. package/dist/lint/post-synth/prom203.d.ts +8 -0
  58. package/dist/lint/post-synth/prom203.d.ts.map +1 -0
  59. package/dist/lint/post-synth/prom204.d.ts +8 -0
  60. package/dist/lint/post-synth/prom204.d.ts.map +1 -0
  61. package/dist/lint/post-synth/prom205.d.ts +8 -0
  62. package/dist/lint/post-synth/prom205.d.ts.map +1 -0
  63. package/dist/lint/post-synth/prom206.d.ts +8 -0
  64. package/dist/lint/post-synth/prom206.d.ts.map +1 -0
  65. package/dist/lint/post-synth/prom207.d.ts +8 -0
  66. package/dist/lint/post-synth/prom207.d.ts.map +1 -0
  67. package/dist/lint/post-synth/prom208.d.ts +8 -0
  68. package/dist/lint/post-synth/prom208.d.ts.map +1 -0
  69. package/dist/lint/post-synth/prom209.d.ts +8 -0
  70. package/dist/lint/post-synth/prom209.d.ts.map +1 -0
  71. package/dist/lint/rules/index.d.ts +7 -0
  72. package/dist/lint/rules/index.d.ts.map +1 -0
  73. package/dist/lint/rules/literal-credential.d.ts +18 -0
  74. package/dist/lint/rules/literal-credential.d.ts.map +1 -0
  75. package/dist/lint/rules/prom-ast.d.ts +14 -0
  76. package/dist/lint/rules/prom-ast.d.ts.map +1 -0
  77. package/dist/lint/rules/promql-literal.d.ts +11 -0
  78. package/dist/lint/rules/promql-literal.d.ts.map +1 -0
  79. package/dist/lint/rules/slo-literal.d.ts +13 -0
  80. package/dist/lint/rules/slo-literal.d.ts.map +1 -0
  81. package/dist/lsp/completions.d.ts +4 -0
  82. package/dist/lsp/completions.d.ts.map +1 -0
  83. package/dist/lsp/hover.d.ts +4 -0
  84. package/dist/lsp/hover.d.ts.map +1 -0
  85. package/dist/manifest.json +8 -0
  86. package/dist/matchers.d.ts +29 -0
  87. package/dist/matchers.d.ts.map +1 -0
  88. package/dist/meta.json +32 -0
  89. package/dist/model.d.ts +262 -0
  90. package/dist/model.d.ts.map +1 -0
  91. package/dist/okf/index.md +34 -0
  92. package/dist/okf/rules/PROM001.md +16 -0
  93. package/dist/okf/rules/PROM002.md +15 -0
  94. package/dist/okf/rules/PROM003.md +11 -0
  95. package/dist/okf/rules/PROM101.md +11 -0
  96. package/dist/okf/rules/PROM102.md +11 -0
  97. package/dist/okf/rules/PROM103.md +11 -0
  98. package/dist/okf/rules/PROM104.md +11 -0
  99. package/dist/okf/rules/PROM105.md +11 -0
  100. package/dist/okf/rules/PROM106.md +11 -0
  101. package/dist/okf/rules/PROM107.md +11 -0
  102. package/dist/okf/rules/PROM201.md +11 -0
  103. package/dist/okf/rules/PROM202.md +11 -0
  104. package/dist/okf/rules/PROM203.md +11 -0
  105. package/dist/okf/rules/PROM204.md +11 -0
  106. package/dist/okf/rules/PROM205.md +11 -0
  107. package/dist/okf/rules/PROM206.md +11 -0
  108. package/dist/okf/rules/PROM207.md +11 -0
  109. package/dist/okf/rules/PROM208.md +11 -0
  110. package/dist/okf/rules/PROM209.md +11 -0
  111. package/dist/okf/types/AlertmanagerSettings.md +13 -0
  112. package/dist/okf/types/InhibitRule.md +9 -0
  113. package/dist/okf/types/Receiver.md +13 -0
  114. package/dist/okf/types/Route.md +9 -0
  115. package/dist/okf/types/RuleGroup.md +13 -0
  116. package/dist/okf/types/TimeInterval.md +9 -0
  117. package/dist/package-cli.d.ts +3 -0
  118. package/dist/package-cli.d.ts.map +1 -0
  119. package/dist/pin.d.ts +20 -0
  120. package/dist/pin.d.ts.map +1 -0
  121. package/dist/plugin.d.ts +10 -0
  122. package/dist/plugin.d.ts.map +1 -0
  123. package/dist/promql.d.ts +25 -0
  124. package/dist/promql.d.ts.map +1 -0
  125. package/dist/rule-eval.d.ts +67 -0
  126. package/dist/rule-eval.d.ts.map +1 -0
  127. package/dist/rules/literal-credential.ts +84 -0
  128. package/dist/rules/prom-ast.ts +30 -0
  129. package/dist/rules/prom-helpers.ts +89 -0
  130. package/dist/rules/prom101.ts +17 -0
  131. package/dist/rules/prom102.ts +17 -0
  132. package/dist/rules/prom103.ts +17 -0
  133. package/dist/rules/prom104.ts +17 -0
  134. package/dist/rules/prom105.ts +17 -0
  135. package/dist/rules/prom106.ts +17 -0
  136. package/dist/rules/prom107.ts +17 -0
  137. package/dist/rules/prom201.ts +17 -0
  138. package/dist/rules/prom202.ts +17 -0
  139. package/dist/rules/prom203.ts +17 -0
  140. package/dist/rules/prom204.ts +17 -0
  141. package/dist/rules/prom205.ts +17 -0
  142. package/dist/rules/prom206.ts +17 -0
  143. package/dist/rules/prom207.ts +17 -0
  144. package/dist/rules/prom208.ts +17 -0
  145. package/dist/rules/prom209.ts +17 -0
  146. package/dist/rules/promql-literal.ts +55 -0
  147. package/dist/rules/slo-literal.ts +81 -0
  148. package/dist/rules.d.ts +59 -0
  149. package/dist/rules.d.ts.map +1 -0
  150. package/dist/serializer.d.ts +27 -0
  151. package/dist/serializer.d.ts.map +1 -0
  152. package/dist/skill-defs.d.ts +3 -0
  153. package/dist/skill-defs.d.ts.map +1 -0
  154. package/dist/skills/chant-prometheus-alertmanager.md +58 -0
  155. package/dist/skills/chant-prometheus-kubernetes.md +48 -0
  156. package/dist/skills/chant-prometheus.md +91 -0
  157. package/dist/tools.d.ts +30 -0
  158. package/dist/tools.d.ts.map +1 -0
  159. package/dist/types/index.d.ts +3 -0
  160. package/dist/validate-cli.d.ts +3 -0
  161. package/dist/validate-cli.d.ts.map +1 -0
  162. package/dist/validate-config.d.ts +35 -0
  163. package/dist/validate-config.d.ts.map +1 -0
  164. package/dist/validate.d.ts +11 -0
  165. package/dist/validate.d.ts.map +1 -0
  166. package/package.json +77 -0
  167. package/src/alertmanager.ts +143 -0
  168. package/src/build.ts +199 -0
  169. package/src/catalog.ts +65 -0
  170. package/src/codegen/docs-cli.ts +4 -0
  171. package/src/codegen/docs.ts +104 -0
  172. package/src/codegen/generate-cli.ts +8 -0
  173. package/src/codegen/generate.ts +49 -0
  174. package/src/codegen/package.ts +41 -0
  175. package/src/composites/catalog.test.ts +37 -0
  176. package/src/composites/catalog.ts +65 -0
  177. package/src/composites/composites.test.ts +256 -0
  178. package/src/composites/index.ts +23 -0
  179. package/src/composites/slo-burn.test.ts +289 -0
  180. package/src/composites/slo.ts +471 -0
  181. package/src/detect.ts +12 -0
  182. package/src/duration.ts +51 -0
  183. package/src/generated/lexicon-prometheus.json +32 -0
  184. package/src/index.ts +97 -0
  185. package/src/init-templates.test.ts +29 -0
  186. package/src/init-templates.ts +76 -0
  187. package/src/lint/audit-catalog.ts +123 -0
  188. package/src/lint/post-synth/index.ts +37 -0
  189. package/src/lint/post-synth/post-synth.test.ts +256 -0
  190. package/src/lint/post-synth/prom-helpers.ts +89 -0
  191. package/src/lint/post-synth/prom101.ts +17 -0
  192. package/src/lint/post-synth/prom102.ts +17 -0
  193. package/src/lint/post-synth/prom103.ts +17 -0
  194. package/src/lint/post-synth/prom104.ts +17 -0
  195. package/src/lint/post-synth/prom105.ts +17 -0
  196. package/src/lint/post-synth/prom106.ts +17 -0
  197. package/src/lint/post-synth/prom107.ts +17 -0
  198. package/src/lint/post-synth/prom201.ts +17 -0
  199. package/src/lint/post-synth/prom202.ts +17 -0
  200. package/src/lint/post-synth/prom203.ts +17 -0
  201. package/src/lint/post-synth/prom204.ts +17 -0
  202. package/src/lint/post-synth/prom205.ts +17 -0
  203. package/src/lint/post-synth/prom206.ts +17 -0
  204. package/src/lint/post-synth/prom207.ts +17 -0
  205. package/src/lint/post-synth/prom208.ts +17 -0
  206. package/src/lint/post-synth/prom209.ts +17 -0
  207. package/src/lint/rules/index.ts +11 -0
  208. package/src/lint/rules/literal-credential.ts +84 -0
  209. package/src/lint/rules/prom-ast.ts +30 -0
  210. package/src/lint/rules/promql-literal.ts +55 -0
  211. package/src/lint/rules/rules.test.ts +114 -0
  212. package/src/lint/rules/slo-literal.ts +81 -0
  213. package/src/lsp/completions.test.ts +23 -0
  214. package/src/lsp/completions.ts +15 -0
  215. package/src/lsp/hover.test.ts +27 -0
  216. package/src/lsp/hover.ts +26 -0
  217. package/src/matchers.ts +132 -0
  218. package/src/model.test.ts +104 -0
  219. package/src/model.ts +290 -0
  220. package/src/package-cli.ts +17 -0
  221. package/src/pin.ts +13 -0
  222. package/src/plugin.test.ts +73 -0
  223. package/src/plugin.ts +129 -0
  224. package/src/promql.ts +46 -0
  225. package/src/rule-eval.ts +397 -0
  226. package/src/rules.ts +127 -0
  227. package/src/serializer.test.ts +179 -0
  228. package/src/serializer.ts +55 -0
  229. package/src/skill-defs.ts +43 -0
  230. package/src/skills/chant-prometheus-alertmanager.md +58 -0
  231. package/src/skills/chant-prometheus-kubernetes.md +48 -0
  232. package/src/skills/chant-prometheus.md +91 -0
  233. package/src/tools.test.ts +119 -0
  234. package/src/tools.ts +70 -0
  235. package/src/validate-cli.ts +7 -0
  236. package/src/validate-config.ts +400 -0
  237. package/src/validate.ts +66 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * promtool and amtool, over output the lexicon builds. Each test runs the
3
+ * real binary when it is on PATH (or named by $PROMTOOL / $AMTOOL) and skips
4
+ * otherwise; the structural checks in post-synth.test.ts always run.
5
+ */
6
+ import { describe, expect, test } from "vitest";
7
+ import {
8
+ AlertmanagerSettings,
9
+ InhibitRule,
10
+ Receiver,
11
+ Route,
12
+ RuleGroup,
13
+ TimeInterval,
14
+ alertmanagerYaml,
15
+ amtoolCheckConfig,
16
+ hasTool,
17
+ promtoolCheckRules,
18
+ ruleFileYaml,
19
+ } from "./index";
20
+
21
+ const PROMTOOL = process.env.PROMTOOL ?? "promtool";
22
+ const AMTOOL = process.env.AMTOOL ?? "amtool";
23
+ const hasPromtool = hasTool(PROMTOOL);
24
+ const hasAmtool = hasTool(AMTOOL);
25
+
26
+ function groups() {
27
+ return [
28
+ new RuleGroup({
29
+ name: "api",
30
+ interval: "30s",
31
+ query_offset: "1m",
32
+ labels: { team: "api" },
33
+ rules: [
34
+ {
35
+ record: "job:http_errors:ratio5m",
36
+ expr: 'sum by (job) (rate(http_requests_total{code=~"5.."}[5m]))\n / sum by (job) (rate(http_requests_total[5m]))',
37
+ },
38
+ {
39
+ alert: "ApiErrors",
40
+ expr: "job:http_errors:ratio5m > 0.05",
41
+ for: "10m",
42
+ keep_firing_for: "5m",
43
+ labels: { severity: "page" },
44
+ annotations: { summary: "{{ $labels.job }} 5xx ratio is {{ $value | humanizePercentage }}" },
45
+ },
46
+ { alert: "ApiErrors", expr: "job:http_errors:ratio5m > 0.01", for: "1h", labels: { severity: "ticket" }, annotations: { summary: "x: y" } },
47
+ ],
48
+ }),
49
+ new RuleGroup({ name: "limits", limit: 10, rules: [{ alert: "TooMany", expr: "count(up) > 100", labels: { severity: "ticket" } }] }),
50
+ ];
51
+ }
52
+
53
+ function alertmanager() {
54
+ const oncall = new Receiver({
55
+ name: "oncall",
56
+ pagerduty_configs: [{ routing_key_file: "/etc/alertmanager/pd", severity: "critical" }],
57
+ slack_configs: [{ api_url_file: "/etc/alertmanager/slack", channel: "#alerts", send_resolved: true }],
58
+ });
59
+ const mail = new Receiver({
60
+ name: "mail",
61
+ email_configs: [{ to: "team@example.com", auth_username: "am", auth_password_file: "/etc/alertmanager/smtp" }],
62
+ });
63
+ const sink = new Receiver({ name: "default", webhook_configs: [{ url: "http://sink:8080/", max_alerts: 10, timeout: "10s" }] });
64
+ const weekend = new TimeInterval({
65
+ name: "weekend",
66
+ time_intervals: [{ weekdays: ["saturday", "sunday"] }, { times: [{ start_time: "18:00", end_time: "24:00" }], location: "Europe/Berlin" }],
67
+ });
68
+ return [
69
+ new AlertmanagerSettings({ global: { resolve_timeout: "5m", smtp_smarthost: "smtp.example.com:587", smtp_from: "am@example.com" } }),
70
+ new Route({
71
+ receiver: sink,
72
+ group_by: ["alertname", "job"],
73
+ group_wait: "30s",
74
+ group_interval: "5m",
75
+ repeat_interval: "4h",
76
+ routes: [
77
+ { matchers: ['severity="page"'], receiver: oncall, continue: true },
78
+ { matchers: ['severity=~"ticket|info"'], receiver: mail, mute_time_intervals: [weekend] },
79
+ ],
80
+ }),
81
+ new InhibitRule({ source_matchers: ['severity="page"'], target_matchers: ['severity="ticket"'], equal: ["alertname"] }),
82
+ ];
83
+ }
84
+
85
+ describe("promtool check rules", () => {
86
+ test.skipIf(!hasPromtool)("accepts the rule file the lexicon builds", () => {
87
+ const r = promtoolCheckRules(ruleFileYaml(groups()), PROMTOOL);
88
+ expect(r.ran).toBe(true);
89
+ expect(r.output).toContain("SUCCESS");
90
+ expect(r.ok).toBe(true);
91
+ });
92
+
93
+ test.skipIf(!hasPromtool)("rejects what PROM101 flags, so the two agree", () => {
94
+ const [g] = groups();
95
+ const r = promtoolCheckRules(ruleFileYaml([g, new RuleGroup({ name: "api", rules: [] })]), PROMTOOL);
96
+ expect(r.ok).toBe(false);
97
+ });
98
+
99
+ test("reports ran: false when the binary is missing", () => {
100
+ expect(promtoolCheckRules("groups: []\n", "/nonexistent/promtool")).toEqual({ ran: false, ok: false, output: "" });
101
+ });
102
+ });
103
+
104
+ describe("amtool check-config", () => {
105
+ test.skipIf(!hasAmtool)("accepts the alertmanager.yml the lexicon builds", () => {
106
+ const r = amtoolCheckConfig(alertmanagerYaml(alertmanager()), AMTOOL);
107
+ expect(r.ran).toBe(true);
108
+ expect(r.ok, r.output).toBe(true);
109
+ });
110
+
111
+ test.skipIf(!hasAmtool)("rejects what PROM201 flags, so the two agree", () => {
112
+ const r = amtoolCheckConfig(alertmanagerYaml([new Route({ receiver: "nobody" })]), AMTOOL);
113
+ expect(r.ok).toBe(false);
114
+ });
115
+
116
+ test("reports ran: false when the binary is missing", () => {
117
+ expect(amtoolCheckConfig("route: {}\n", "/nonexistent/amtool").ran).toBe(false);
118
+ });
119
+ });
package/src/tools.ts ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * `promtool check rules` 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
+
12
+ import { spawnSync } from "child_process";
13
+ import { mkdtempSync, rmSync, writeFileSync } from "fs";
14
+ import { tmpdir } from "os";
15
+ import { join } from "path";
16
+
17
+ export interface ToolResult {
18
+ /** False when the binary isn't installed; `ok` and `output` are then meaningless. */
19
+ ran: boolean;
20
+ ok: boolean;
21
+ /** Combined stdout and stderr. */
22
+ output: string;
23
+ }
24
+
25
+ /** True when `bin` is on PATH (or is a path that runs). */
26
+ export function hasTool(bin: string): boolean {
27
+ const r = spawnSync(bin, ["--version"], { encoding: "utf-8" });
28
+ return !r.error && r.status === 0;
29
+ }
30
+
31
+ function runOnText(bin: string, args: (file: string) => string[], filename: string, text: string): ToolResult {
32
+ if (!hasTool(bin)) return { ran: false, ok: false, output: "" };
33
+ const dir = mkdtempSync(join(tmpdir(), "chant-prometheus-"));
34
+ try {
35
+ const file = join(dir, filename);
36
+ writeFileSync(file, text);
37
+ const r = spawnSync(bin, args(file), { encoding: "utf-8" });
38
+ return { ran: true, ok: r.status === 0, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
39
+ } finally {
40
+ rmSync(dir, { recursive: true, force: true });
41
+ }
42
+ }
43
+
44
+ /** Run `promtool check rules` over rule file YAML. `bin` defaults to `promtool` on PATH. */
45
+ export function promtoolCheckRules(yaml: string, bin = process.env.PROMTOOL ?? "promtool"): ToolResult {
46
+ return runOnText(bin, (f) => ["check", "rules", f], "rules.yml", yaml);
47
+ }
48
+
49
+ /**
50
+ * Run `promtool test rules` over rule file YAML and a unit-test file for it.
51
+ * The test file's `rule_files:` should name `rules.yml`, which is where the
52
+ * rule file is written beside it. `bin` defaults to `promtool` on PATH.
53
+ */
54
+ export function promtoolTestRules(rulesYaml: string, testYaml: string, bin = process.env.PROMTOOL ?? "promtool"): ToolResult {
55
+ if (!hasTool(bin)) return { ran: false, ok: false, output: "" };
56
+ const dir = mkdtempSync(join(tmpdir(), "chant-prometheus-"));
57
+ try {
58
+ writeFileSync(join(dir, "rules.yml"), rulesYaml);
59
+ writeFileSync(join(dir, "rules.test.yml"), testYaml);
60
+ const r = spawnSync(bin, ["test", "rules", "rules.test.yml"], { cwd: dir, encoding: "utf-8" });
61
+ return { ran: true, ok: r.status === 0, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
62
+ } finally {
63
+ rmSync(dir, { recursive: true, force: true });
64
+ }
65
+ }
66
+
67
+ /** Run `amtool check-config` over `alertmanager.yml` text. `bin` defaults to `amtool` on PATH. */
68
+ export function amtoolCheckConfig(yaml: string, bin = process.env.AMTOOL ?? "amtool"): ToolResult {
69
+ return runOnText(bin, (f) => ["check-config", f], "alertmanager.yml", yaml);
70
+ }
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env tsx
2
+ import { printValidationResult } from "@intentius/chant/codegen/validate";
3
+ import { validate } from "./validate";
4
+
5
+ const result = await validate();
6
+ printValidationResult(result);
7
+ if (!result.success) process.exit(1);
@@ -0,0 +1,400 @@
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
+
10
+ import { isValidDuration } from "./duration";
11
+ import { matcherMatches, parseMatchers, type Matcher } from "./matchers";
12
+ import {
13
+ isAlertingRuleConfig,
14
+ isRecordingRuleConfig,
15
+ type AlertmanagerConfig,
16
+ type AlertingRuleConfig,
17
+ type LabelSet,
18
+ type RouteConfig,
19
+ type RuleFileConfig,
20
+ type RuleGroupConfig,
21
+ } from "./model";
22
+ import { checkPromql } from "./promql";
23
+
24
+ export type PrometheusIssueCode =
25
+ | "PROM101"
26
+ | "PROM102"
27
+ | "PROM103"
28
+ | "PROM104"
29
+ | "PROM105"
30
+ | "PROM106"
31
+ | "PROM107"
32
+ | "PROM201"
33
+ | "PROM202"
34
+ | "PROM203"
35
+ | "PROM204"
36
+ | "PROM205"
37
+ | "PROM206"
38
+ | "PROM207"
39
+ | "PROM208"
40
+ | "PROM209";
41
+
42
+ export interface PrometheusIssue {
43
+ code: PrometheusIssueCode;
44
+ severity: "error" | "warning";
45
+ message: string;
46
+ /** What the issue is about: a group, a rule (`group/rule`), a receiver or a route. */
47
+ subject?: string;
48
+ }
49
+
50
+ function str(v: unknown): string {
51
+ return typeof v === "string" ? v : String(v);
52
+ }
53
+
54
+ // ── Rule files ──────────────────────────────────────────────────────
55
+
56
+ function ruleLabel(group: RuleGroupConfig, index: number, rule: unknown): string {
57
+ const r = rule as Record<string, unknown>;
58
+ const name = typeof r?.record === "string" ? r.record : typeof r?.alert === "string" ? r.alert : `rule ${index + 1}`;
59
+ return `${group.name}/${name}`;
60
+ }
61
+
62
+ /** The static labels an alert carries: the group's labels under its own. */
63
+ function alertLabels(group: RuleGroupConfig, rule: AlertingRuleConfig): LabelSet {
64
+ return { ...(group.labels ?? {}), ...(rule.labels ?? {}) };
65
+ }
66
+
67
+ /** Check a rule file (PROM101-PROM107). */
68
+ export function validateRuleFile(file: RuleFileConfig): PrometheusIssue[] {
69
+ const issues: PrometheusIssue[] = [];
70
+ const groups = Array.isArray(file?.groups) ? file.groups : [];
71
+ const groupNames = new Map<string, number>();
72
+ const ruleKeys = new Map<string, string>();
73
+
74
+ for (const group of groups) {
75
+ const gname = str(group?.name ?? "");
76
+ groupNames.set(gname, (groupNames.get(gname) ?? 0) + 1);
77
+ if (groupNames.get(gname) === 2) {
78
+ issues.push({
79
+ code: "PROM101",
80
+ severity: "error",
81
+ subject: gname,
82
+ message: `rule group "${gname}" is declared more than once; Prometheus requires group names to be unique within a rule file`,
83
+ });
84
+ }
85
+ if (gname.trim() === "") {
86
+ issues.push({ code: "PROM105", severity: "error", subject: gname, message: "a rule group has no name" });
87
+ }
88
+
89
+ for (const field of ["interval", "query_offset"] as const) {
90
+ const v = group?.[field];
91
+ if (v !== undefined && !isValidDuration(v)) {
92
+ issues.push({
93
+ code: "PROM103",
94
+ severity: "error",
95
+ subject: gname,
96
+ message: `group "${gname}" ${field} "${str(v)}" is not a Prometheus duration (e.g. 30s, 1m, 1h30m)`,
97
+ });
98
+ }
99
+ }
100
+
101
+ const rules = Array.isArray(group?.rules) ? group.rules : [];
102
+ rules.forEach((rule, i) => {
103
+ const subject = ruleLabel(group, i, rule);
104
+ const r = rule as unknown as Record<string, unknown>;
105
+ const recording = isRecordingRuleConfig(rule);
106
+ const alerting = isAlertingRuleConfig(rule);
107
+
108
+ // PROM105: shape
109
+ if (recording && alerting) {
110
+ issues.push({ code: "PROM105", severity: "error", subject, message: `rule ${subject} sets both record and alert; a rule is one or the other` });
111
+ } else if (!recording && !alerting) {
112
+ issues.push({ code: "PROM105", severity: "error", subject, message: `rule ${subject} sets neither record nor alert` });
113
+ } else if (recording) {
114
+ if (rule.record.trim() === "") issues.push({ code: "PROM105", severity: "error", subject, message: `a recording rule in group "${gname}" has an empty record name` });
115
+ for (const bad of ["for", "keep_firing_for", "annotations"]) {
116
+ if (r[bad] !== undefined) {
117
+ issues.push({ code: "PROM105", severity: "error", subject, message: `recording rule ${subject} sets ${bad}, which only alerting rules take` });
118
+ }
119
+ }
120
+ } else if (alerting && (rule as AlertingRuleConfig).alert.trim() === "") {
121
+ issues.push({ code: "PROM105", severity: "error", subject, message: `an alerting rule in group "${gname}" has an empty alert name` });
122
+ }
123
+
124
+ // PROM103: durations
125
+ for (const field of ["for", "keep_firing_for"]) {
126
+ const v = r[field];
127
+ if (v !== undefined && !isValidDuration(v)) {
128
+ issues.push({
129
+ code: "PROM103",
130
+ severity: "error",
131
+ subject,
132
+ message: `rule ${subject} ${field} "${str(v)}" is not a Prometheus duration (e.g. 5m, 1h)`,
133
+ });
134
+ }
135
+ }
136
+
137
+ // PROM104: PromQL
138
+ const expr = r.expr;
139
+ const checked = checkPromql(typeof expr === "string" ? expr : "");
140
+ if (!checked.ok) {
141
+ issues.push({ code: "PROM104", severity: "error", subject, message: `rule ${subject} expr: ${checked.message}` });
142
+ }
143
+
144
+ // PROM102: duplicates (same kind, name and labels anywhere in the file)
145
+ if (recording || alerting) {
146
+ const labels = recording ? { ...(group.labels ?? {}), ...(rule.labels ?? {}) } : alertLabels(group, rule as AlertingRuleConfig);
147
+ const key = JSON.stringify([recording ? "record" : "alert", recording ? rule.record : (rule as AlertingRuleConfig).alert, Object.entries(labels).sort()]);
148
+ const first = ruleKeys.get(key);
149
+ if (first !== undefined) {
150
+ issues.push({
151
+ code: "PROM102",
152
+ severity: "warning",
153
+ subject,
154
+ message: `rule ${subject} has the same name and labels as ${first}; the two produce the same series and overwrite each other`,
155
+ });
156
+ } else {
157
+ ruleKeys.set(key, subject);
158
+ }
159
+ }
160
+
161
+ if (alerting) {
162
+ const labels = alertLabels(group, rule as AlertingRuleConfig);
163
+ // PROM106: severity
164
+ if (!labels.severity) {
165
+ issues.push({
166
+ code: "PROM106",
167
+ severity: "warning",
168
+ subject,
169
+ message: `alert ${subject} has no severity label, so Alertmanager can only route it by name`,
170
+ });
171
+ }
172
+ // PROM107: summary/description
173
+ const ann = (rule as AlertingRuleConfig).annotations ?? {};
174
+ if (!ann.summary && !ann.description) {
175
+ issues.push({
176
+ code: "PROM107",
177
+ severity: "warning",
178
+ subject,
179
+ message: `alert ${subject} has no summary or description annotation; a notification carries only its labels`,
180
+ });
181
+ }
182
+ }
183
+ });
184
+ }
185
+ return issues;
186
+ }
187
+
188
+ /** Every severity value an alert in these rule files carries, with the alerts carrying it. */
189
+ export function alertSeverities(files: RuleFileConfig[]): Map<string, string[]> {
190
+ const out = new Map<string, string[]>();
191
+ for (const file of files) {
192
+ for (const group of Array.isArray(file?.groups) ? file.groups : []) {
193
+ for (const rule of Array.isArray(group?.rules) ? group.rules : []) {
194
+ if (!isAlertingRuleConfig(rule)) continue;
195
+ const sev = alertLabels(group, rule).severity;
196
+ if (!sev) continue;
197
+ out.set(sev, [...(out.get(sev) ?? []), `${group.name}/${rule.alert}`]);
198
+ }
199
+ }
200
+ }
201
+ return out;
202
+ }
203
+
204
+ // ── Alertmanager ────────────────────────────────────────────────────
205
+
206
+ interface RouteVisit {
207
+ route: RouteConfig;
208
+ path: string;
209
+ depth: number;
210
+ }
211
+
212
+ function visitRoutes(root: RouteConfig | undefined): RouteVisit[] {
213
+ const out: RouteVisit[] = [];
214
+ const walk = (route: RouteConfig, path: string, depth: number) => {
215
+ out.push({ route, path, depth });
216
+ (Array.isArray(route?.routes) ? route.routes : []).forEach((child, i) => walk(child, `${path}.routes[${i}]`, depth + 1));
217
+ };
218
+ if (root && typeof root === "object") walk(root, "route", 0);
219
+ return out;
220
+ }
221
+
222
+ const AM_DURATIONS = ["group_wait", "group_interval", "repeat_interval"] as const;
223
+
224
+ /** Check an `alertmanager.yml` on its own (PROM201, PROM203-PROM209). */
225
+ export function validateAlertmanagerConfig(config: AlertmanagerConfig): PrometheusIssue[] {
226
+ const issues: PrometheusIssue[] = [];
227
+ const receivers = Array.isArray(config?.receivers) ? config.receivers : [];
228
+ const intervals = Array.isArray(config?.time_intervals) ? config.time_intervals : [];
229
+ const global = config?.global ?? {};
230
+
231
+ // PROM203: duplicate names
232
+ const seenReceivers = new Set<string>();
233
+ for (const r of receivers) {
234
+ const name = str(r?.name ?? "");
235
+ if (seenReceivers.has(name)) {
236
+ issues.push({ code: "PROM203", severity: "error", subject: name, message: `receiver "${name}" is declared more than once; receiver names must be unique` });
237
+ }
238
+ seenReceivers.add(name);
239
+ }
240
+ const seenIntervals = new Set<string>();
241
+ for (const t of intervals) {
242
+ const name = str(t?.name ?? "");
243
+ if (seenIntervals.has(name)) {
244
+ issues.push({ code: "PROM203", severity: "error", subject: name, message: `time interval "${name}" is declared more than once; time interval names must be unique` });
245
+ }
246
+ seenIntervals.add(name);
247
+ }
248
+
249
+ // PROM205: root route shape
250
+ const root = config?.route;
251
+ if (!root || typeof root !== "object") {
252
+ issues.push({
253
+ code: "PROM205",
254
+ severity: "error",
255
+ subject: "route",
256
+ message: "alertmanager.yml has no root route; declare one Route with a receiver and nest the others under it",
257
+ });
258
+ } else {
259
+ if (!root.receiver) {
260
+ 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" });
261
+ }
262
+ if (Array.isArray(root.matchers) && root.matchers.length > 0) {
263
+ 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" });
264
+ }
265
+ }
266
+
267
+ const usedReceivers = new Set<string>();
268
+ for (const { route, path } of visitRoutes(root)) {
269
+ // PROM201: receiver exists
270
+ if (route.receiver !== undefined) {
271
+ const name = str(route.receiver);
272
+ usedReceivers.add(name);
273
+ if (!seenReceivers.has(name)) {
274
+ issues.push({ code: "PROM201", severity: "error", subject: path, message: `${path} sends to receiver "${name}", which is not declared` });
275
+ }
276
+ }
277
+ // PROM204: time intervals exist
278
+ for (const field of ["mute_time_intervals", "active_time_intervals"] as const) {
279
+ for (const t of route[field] ?? []) {
280
+ if (!seenIntervals.has(str(t))) {
281
+ issues.push({ code: "PROM204", severity: "error", subject: path, message: `${path} ${field} names time interval "${str(t)}", which is not declared` });
282
+ }
283
+ }
284
+ }
285
+ // PROM206: matchers parse
286
+ for (const m of route.matchers ?? []) {
287
+ const parsed = parseMatchers(str(m));
288
+ if (!parsed.ok) issues.push({ code: "PROM206", severity: "error", subject: path, message: `${path} matcher ${parsed.error}` });
289
+ }
290
+ // PROM208: durations
291
+ for (const field of AM_DURATIONS) {
292
+ const v = route[field];
293
+ if (v !== undefined && !isValidDuration(v)) {
294
+ issues.push({ code: "PROM208", severity: "error", subject: path, message: `${path} ${field} "${str(v)}" is not a duration (e.g. 30s, 5m, 4h)` });
295
+ }
296
+ }
297
+ }
298
+
299
+ (Array.isArray(config?.inhibit_rules) ? config.inhibit_rules : []).forEach((rule, i) => {
300
+ for (const field of ["source_matchers", "target_matchers"] as const) {
301
+ for (const m of rule?.[field] ?? []) {
302
+ const parsed = parseMatchers(str(m));
303
+ if (!parsed.ok) {
304
+ issues.push({ code: "PROM206", severity: "error", subject: `inhibit_rules[${i}]`, message: `inhibit_rules[${i}].${field} matcher ${parsed.error}` });
305
+ }
306
+ }
307
+ }
308
+ });
309
+
310
+ if (global.resolve_timeout !== undefined && !isValidDuration(global.resolve_timeout)) {
311
+ issues.push({ code: "PROM208", severity: "error", subject: "global", message: `global.resolve_timeout "${str(global.resolve_timeout)}" is not a duration` });
312
+ }
313
+
314
+ // PROM207: unused receivers; PROM209: incomplete integrations
315
+ for (const r of receivers) {
316
+ const name = str(r?.name ?? "");
317
+ if (!usedReceivers.has(name)) {
318
+ issues.push({ code: "PROM207", severity: "warning", subject: name, message: `receiver "${name}" is declared but no route sends to it` });
319
+ }
320
+ for (const [i, w] of (r.webhook_configs ?? []).entries()) {
321
+ if (!w.url && !w.url_file) issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" webhook_configs[${i}] has no url or url_file` });
322
+ if (w.timeout !== undefined && !isValidDuration(w.timeout)) {
323
+ issues.push({ code: "PROM208", severity: "error", subject: name, message: `receiver "${name}" webhook_configs[${i}].timeout "${str(w.timeout)}" is not a duration` });
324
+ }
325
+ }
326
+ for (const [i, s] of (r.slack_configs ?? []).entries()) {
327
+ if (!s.api_url && !s.api_url_file && !global.slack_api_url && !global.slack_api_url_file) {
328
+ issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" slack_configs[${i}] has no api_url or api_url_file, and global sets no slack_api_url` });
329
+ }
330
+ }
331
+ for (const [i, p] of (r.pagerduty_configs ?? []).entries()) {
332
+ if (!p.routing_key && !p.routing_key_file && !p.service_key && !p.service_key_file) {
333
+ issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" pagerduty_configs[${i}] has no routing_key(_file) or service_key(_file)` });
334
+ }
335
+ }
336
+ for (const [i, e] of (r.email_configs ?? []).entries()) {
337
+ if (!e.to) issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" email_configs[${i}] has no to address` });
338
+ if (!e.smarthost && !global.smtp_smarthost) {
339
+ issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" email_configs[${i}] has no smarthost, and global sets no smtp_smarthost` });
340
+ }
341
+ if (!e.from && !global.smtp_from) {
342
+ issues.push({ code: "PROM209", severity: "error", subject: name, message: `receiver "${name}" email_configs[${i}] has no from address, and global sets no smtp_from` });
343
+ }
344
+ }
345
+ // A receiver with no integrations at all is valid: it is how Alertmanager drops alerts.
346
+ }
347
+
348
+ return issues;
349
+ }
350
+
351
+ function severityMatchers(route: RouteConfig): Matcher[] | undefined {
352
+ const out: Matcher[] = [];
353
+ for (const m of route.matchers ?? []) {
354
+ const parsed = parseMatchers(str(m));
355
+ if (!parsed.ok) return undefined;
356
+ out.push(...parsed.matchers.filter((x) => x.name === "severity"));
357
+ }
358
+ return out;
359
+ }
360
+
361
+ /**
362
+ * PROM202: every severity an alert carries is taken by some route below the
363
+ * root that matches on `severity`. An alert whose severity no route names
364
+ * falls through to the root route's default receiver.
365
+ *
366
+ * Only matchers on `severity` are read, since other labels (team, service)
367
+ * come from the series and are not known at build time. A route counts when
368
+ * it, or a route above it, matches on `severity`, and every severity matcher
369
+ * on its path accepts the value.
370
+ */
371
+ export function validateSeverityRouting(ruleFiles: RuleFileConfig[], config: AlertmanagerConfig): PrometheusIssue[] {
372
+ const severities = alertSeverities(ruleFiles);
373
+ if (severities.size === 0 || !config?.route) return [];
374
+
375
+ const paths: Matcher[][] = [];
376
+ const walk = (route: RouteConfig, inherited: Matcher[], depth: number) => {
377
+ const own = severityMatchers(route);
378
+ if (own === undefined) return; // PROM206 reports the unparseable matcher
379
+ const path = [...inherited, ...own];
380
+ if (depth > 0 && path.length > 0) paths.push(path);
381
+ for (const child of Array.isArray(route.routes) ? route.routes : []) walk(child, path, depth + 1);
382
+ };
383
+ walk(config.route, [], 0);
384
+
385
+ const issues: PrometheusIssue[] = [];
386
+ for (const [severity, alerts] of severities) {
387
+ const labels = { severity };
388
+ const routed = paths.some((path) => path.every((m) => matcherMatches(m, labels)));
389
+ if (!routed) {
390
+ const shown = alerts.slice(0, 3).join(", ") + (alerts.length > 3 ? `, and ${alerts.length - 3} more` : "");
391
+ issues.push({
392
+ code: "PROM202",
393
+ severity: "warning",
394
+ subject: severity,
395
+ message: `no route matches severity="${severity}" (${shown}); those alerts fall through to the root route's default receiver`,
396
+ });
397
+ }
398
+ }
399
+ return issues;
400
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Validate the prometheus lexicon's own artifacts: every class is in the
3
+ * registry, every entity constructs and serializes, and a sample rule file
4
+ * and Alertmanager config pass the lexicon's own checks.
5
+ */
6
+
7
+ import type { ValidateCheck, ValidateResult } from "@intentius/chant/codegen/validate";
8
+ import { CATALOG, lexiconRegistry } from "./catalog";
9
+ import { RuleGroup } from "./rules";
10
+ import { AlertmanagerSettings, InhibitRule, Receiver, Route, TimeInterval } from "./alertmanager";
11
+ import { buildAlertmanagerConfig, buildRuleFile } from "./build";
12
+ import { validateAlertmanagerConfig, validateRuleFile } from "./validate-config";
13
+
14
+ export type { ValidateCheck, ValidateResult } from "@intentius/chant/codegen/validate";
15
+
16
+ /** Every entity class the package must keep exporting. */
17
+ export const REQUIRED_NAMES = ["RuleGroup", "Route", "Receiver", "InhibitRule", "TimeInterval", "AlertmanagerSettings"];
18
+
19
+ export async function validate(): Promise<ValidateResult> {
20
+ const checks: ValidateCheck[] = [];
21
+ const registry = lexiconRegistry();
22
+
23
+ const missing = REQUIRED_NAMES.filter((n) => !(n in registry));
24
+ checks.push(
25
+ missing.length === 0
26
+ ? { name: "required-names", ok: true }
27
+ : { name: "required-names", ok: false, error: `Missing required names: ${missing.join(", ")}` },
28
+ );
29
+
30
+ checks.push(
31
+ CATALOG.length === Object.keys(registry).length
32
+ ? { name: "catalog-matches-registry", ok: true }
33
+ : { name: "catalog-matches-registry", ok: false, error: "catalog and registry disagree" },
34
+ );
35
+
36
+ const group = new RuleGroup({
37
+ name: "sample",
38
+ interval: "30s",
39
+ rules: [
40
+ { record: "job:up:sum", expr: "sum by (job) (up)" },
41
+ { alert: "TargetDown", expr: "up == 0", for: "5m", labels: { severity: "page" }, annotations: { summary: "a target is down" } },
42
+ ],
43
+ });
44
+ const ruleIssues = validateRuleFile(buildRuleFile([group]).config);
45
+ checks.push(
46
+ ruleIssues.length === 0
47
+ ? { name: "rule-file-roundtrip", ok: true }
48
+ : { name: "rule-file-roundtrip", ok: false, error: ruleIssues.map((i) => i.message).join("; ") },
49
+ );
50
+
51
+ const hook = new Receiver({ name: "hook", webhook_configs: [{ url: "http://hook:8080/" }] });
52
+ const quiet = new TimeInterval({ name: "weekend", time_intervals: [{ weekdays: ["saturday", "sunday"] }] });
53
+ const entities = [
54
+ new AlertmanagerSettings({ global: { resolve_timeout: "5m" } }),
55
+ new Route({ receiver: hook, routes: [{ matchers: ['severity="page"'], receiver: hook, mute_time_intervals: [quiet] }] }),
56
+ new InhibitRule({ source_matchers: ['severity="page"'], target_matchers: ['severity="ticket"'], equal: ["alertname"] }),
57
+ ];
58
+ const amIssues = validateAlertmanagerConfig(buildAlertmanagerConfig(entities).config);
59
+ checks.push(
60
+ amIssues.length === 0
61
+ ? { name: "alertmanager-roundtrip", ok: true }
62
+ : { name: "alertmanager-roundtrip", ok: false, error: amIssues.map((i) => i.message).join("; ") },
63
+ );
64
+
65
+ return { success: checks.every((c) => c.ok), checks };
66
+ }