@intentius/chant-lexicon-prometheus 0.101.0 → 0.102.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 (57) hide show
  1. package/README.md +1 -1
  2. package/dist/alertmanager.d.ts.map +1 -1
  3. package/dist/composites/genai.d.ts +1 -1
  4. package/dist/composites/genai.d.ts.map +1 -1
  5. package/dist/composites/slo.d.ts +1 -1
  6. package/dist/composites/slo.d.ts.map +1 -1
  7. package/dist/import/embedded.d.ts +12 -3
  8. package/dist/import/embedded.d.ts.map +1 -1
  9. package/dist/import/generator.d.ts +14 -0
  10. package/dist/import/generator.d.ts.map +1 -1
  11. package/dist/import/parser.d.ts.map +1 -1
  12. package/dist/init-templates.d.ts.map +1 -1
  13. package/dist/integrations.d.ts.map +1 -1
  14. package/dist/integrity.json +7 -6
  15. package/dist/lint/audit-catalog.d.ts.map +1 -1
  16. package/dist/lint/audit-lineage.d.ts +33 -0
  17. package/dist/lint/audit-lineage.d.ts.map +1 -0
  18. package/dist/lint/post-synth/index.d.ts.map +1 -1
  19. package/dist/lint/post-synth/prom-helpers.d.ts +1 -1
  20. package/dist/lint/post-synth/prom208.d.ts +1 -1
  21. package/dist/lint/post-synth/prom209.d.ts +1 -1
  22. package/dist/lint/post-synth/prom210.d.ts +8 -0
  23. package/dist/lint/post-synth/prom210.d.ts.map +1 -0
  24. package/dist/manifest.json +1 -1
  25. package/dist/okf/index.md +1 -0
  26. package/dist/okf/rules/PROM210.md +11 -0
  27. package/dist/rule-eval.d.ts.map +1 -1
  28. package/dist/rules/prom-helpers.ts +1 -1
  29. package/dist/rules/prom208.ts +1 -1
  30. package/dist/rules/prom209.ts +1 -1
  31. package/dist/rules/prom210.ts +17 -0
  32. package/dist/skill-defs.d.ts +1 -1
  33. package/dist/skill-defs.d.ts.map +1 -1
  34. package/dist/skills/chant-prometheus-alertmanager.md +3 -1
  35. package/dist/validate-config.d.ts +2 -2
  36. package/dist/validate-config.d.ts.map +1 -1
  37. package/dist/validate-integrations.d.ts +37 -0
  38. package/dist/validate-integrations.d.ts.map +1 -0
  39. package/package.json +3 -3
  40. package/src/import/embedded.test.ts +52 -3
  41. package/src/import/embedded.ts +37 -5
  42. package/src/import/generator.ts +19 -1
  43. package/src/lint/audit-catalog.ts +13 -2
  44. package/src/lint/audit-lineage.ts +69 -0
  45. package/src/lint/post-synth/index.ts +2 -0
  46. package/src/lint/post-synth/post-synth.test.ts +34 -0
  47. package/src/lint/post-synth/prom-helpers.ts +1 -1
  48. package/src/lint/post-synth/prom208.ts +1 -1
  49. package/src/lint/post-synth/prom209.ts +1 -1
  50. package/src/lint/post-synth/prom210.ts +17 -0
  51. package/src/plugin.test.ts +1 -0
  52. package/src/plugin.ts +2 -2
  53. package/src/skills/chant-prometheus-alertmanager.md +3 -1
  54. package/src/testdata/integration-cases.ts +96 -0
  55. package/src/tools.test.ts +6 -0
  56. package/src/validate-config.ts +8 -31
  57. package/src/validate-integrations.ts +389 -0
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import { embeddedDocument, type EmbeddedContent } from "@intentius/chant/import/embedded";
3
3
  import { prometheusPlugin } from "../plugin";
4
- import { ruleGroupsImporter } from "./embedded";
4
+ import { alertmanagerImporter, ruleGroupsImporter } from "./embedded";
5
5
 
6
6
  const GROUPS = [
7
7
  { name: "api", rules: [{ alert: "ApiDown", expr: "up{job=\"api\"} == 0", for: "5m" }] },
@@ -25,8 +25,8 @@ const site = (over: Partial<EmbeddedContent>): EmbeddedContent => ({
25
25
  });
26
26
 
27
27
  describe("rule groups embedded in another lexicon's resource (#2962)", () => {
28
- test("the plugin registers the importer", () => {
29
- expect(prometheusPlugin.embeddedImporters?.()).toEqual([ruleGroupsImporter]);
28
+ test("the plugin registers the importers", () => {
29
+ expect(prometheusPlugin.embeddedImporters?.()).toEqual([ruleGroupsImporter, alertmanagerImporter]);
30
30
  });
31
31
 
32
32
  test("matches a PrometheusRule's groups and a rule file held as text; not an alertmanager.yml", () => {
@@ -54,3 +54,52 @@ describe("rule groups embedded in another lexicon's resource (#2962)", () => {
54
54
  expect(out.value.bindings).toEqual([{ from: "rules.ts", name: "api" }]);
55
55
  });
56
56
  });
57
+
58
+ const ALERTMANAGER = `route:
59
+ receiver: team
60
+ routes:
61
+ - matchers: [severity="critical"]
62
+ receiver: pager
63
+ mute_time_intervals: [nights]
64
+ receivers:
65
+ - name: team
66
+ - name: pager
67
+ time_intervals:
68
+ - name: nights
69
+ time_intervals:
70
+ - times: [{ start_time: "22:00", end_time: "24:00" }]
71
+ inhibit_rules:
72
+ - source_matchers: [severity="critical"]
73
+ target_matchers: [severity="warning"]
74
+ templates: [/etc/alertmanager/*.tmpl]
75
+ `;
76
+
77
+ describe("an alertmanager.yml embedded in another lexicon's resource (#3031)", () => {
78
+ const configMap = (text: string) =>
79
+ site({ hostType: "K8s::Core::ConfigMap", location: 'ConfigMap am data["alertmanager.yml"]', text, document: embeddedDocument(text) });
80
+
81
+ test("matches an alertmanager.yml held as text; not a rule file, nor a selected member", () => {
82
+ expect(alertmanagerImporter.matches(configMap(ALERTMANAGER))).toBe(true);
83
+ expect(alertmanagerImporter.matches(configMap(RULE_FILE))).toBe(false);
84
+ expect(alertmanagerImporter.matches(site({ document: { groups: GROUPS }, select: "groups" }))).toBe(false);
85
+ expect(ruleGroupsImporter.matches(configMap(ALERTMANAGER))).toBe(false);
86
+ });
87
+
88
+ test("becomes alertmanagerYaml over every declaration the standalone import writes", () => {
89
+ const out = alertmanagerImporter.import(configMap(ALERTMANAGER));
90
+ expect(out.files.map((f) => f.path)).toEqual(["receivers.ts", "time-intervals.ts", "routes.ts", "inhibit-rules.ts", "settings.ts"]);
91
+ expect(out.value).toEqual({
92
+ bindings: [
93
+ { from: "receivers.ts", name: "team" },
94
+ { from: "receivers.ts", name: "pager" },
95
+ { from: "time-intervals.ts", name: "nights" },
96
+ { from: "routes.ts", name: "root" },
97
+ { from: "inhibit-rules.ts", name: "inhibitRule1" },
98
+ { from: "settings.ts", name: "settings" },
99
+ ],
100
+ shape: "list",
101
+ through: { from: "@intentius/chant-lexicon-prometheus", name: "alertmanagerYaml" },
102
+ });
103
+ expect(out.warnings).toEqual([]);
104
+ });
105
+ });
@@ -1,19 +1,27 @@
1
1
  /**
2
- * Rule groups embedded in another lexicon's resource, for `chant import`
3
- * (#2962): a k8s `PrometheusRule`'s `spec.groups`, or a rule file held as
4
- * text in a ConfigMap.
2
+ * Prometheus and Alertmanager content embedded in another lexicon's
3
+ * resource, for `chant import`: a k8s `PrometheusRule`'s `spec.groups`, or a
4
+ * rule file held as text in a ConfigMap (#2962), and an `alertmanager.yml`
5
+ * held as text in a ConfigMap (#3031).
5
6
  *
6
7
  * The groups are imported exactly as `chant import rules.yml` would import
7
8
  * them, into a directory of its own. `spec.groups` becomes the list of the
8
9
  * declared groups (an `Slo`'s by its `rules` member), which the k8s
9
10
  * serializer renders as the same groups; a rule file becomes
10
11
  * `ruleFileYaml([...])`, the text the prometheus serializer writes.
12
+ *
13
+ * An `alertmanager.yml` is imported exactly as `chant import alertmanager.yml`
14
+ * would import it, and becomes `alertmanagerYaml([...])` over every receiver,
15
+ * time interval, root route, inhibit rule and settings it declares. The
16
+ * ConfigMap then holds the config as the serializer writes it: the same
17
+ * config, with Alertmanager's deprecated spellings rewritten as the
18
+ * standalone import rewrites them.
11
19
  */
12
20
 
13
21
  import type { EmbeddedContentImporter, EmbeddedImport } from "@intentius/chant/import/embedded";
14
- import { looksLikeRuleFile } from "../model";
22
+ import { looksLikeAlertmanagerConfig, looksLikeRuleFile } from "../model";
15
23
  import { parsePrometheusYaml } from "./parser";
16
- import { generateRuleFile } from "./generator";
24
+ import { generateAlertmanager, generateRuleFile } from "./generator";
17
25
 
18
26
  const PACKAGE = "@intentius/chant-lexicon-prometheus";
19
27
 
@@ -42,3 +50,27 @@ export const ruleGroupsImporter: EmbeddedContentImporter = {
42
50
  };
43
51
  },
44
52
  };
53
+
54
+ export const alertmanagerImporter: EmbeddedContentImporter = {
55
+ what: "an Alertmanager config",
56
+
57
+ matches(content) {
58
+ return content.select === undefined && typeof content.text === "string" && looksLikeAlertmanagerConfig(content.document);
59
+ },
60
+
61
+ import(content): EmbeddedImport {
62
+ const parsed = parsePrometheusYaml(content.text!);
63
+ if (parsed.kind !== "alertmanager") throw new Error("this is not an alertmanager.yml");
64
+ const { files, declarations } = generateAlertmanager(parsed.config);
65
+ if (declarations.length === 0) throw new Error("the import declared nothing");
66
+ return {
67
+ files,
68
+ value: {
69
+ bindings: declarations.map((d) => ({ from: d.path, name: d.name })),
70
+ shape: "list",
71
+ through: { from: PACKAGE, name: "alertmanagerYaml" },
72
+ },
73
+ warnings: parsed.warnings,
74
+ };
75
+ },
76
+ };
@@ -381,6 +381,21 @@ function untypedComment(what: string, keys: string[]): string[] {
381
381
 
382
382
  /** Generate the TypeScript declaring one `alertmanager.yml`. */
383
383
  export function generateAlertmanagerFiles(config: AlertmanagerConfig): GeneratedFile[] {
384
+ return generateAlertmanager(config).files;
385
+ }
386
+
387
+ /** A declaration an `alertmanager.yml` import exports: its module and variable. */
388
+ export interface AlertmanagerDeclaration {
389
+ readonly path: string;
390
+ readonly name: string;
391
+ }
392
+
393
+ /**
394
+ * Generate the TypeScript declaring one `alertmanager.yml`, and list every
395
+ * declaration it exports, module by module (#3031: a ConfigMap holding the
396
+ * file becomes `alertmanagerYaml([...])` over them).
397
+ */
398
+ export function generateAlertmanager(config: AlertmanagerConfig): { files: GeneratedFile[]; declarations: AlertmanagerDeclaration[] } {
384
399
  const names = new Names([...LEXICON_NAMES, "global", "tracing"]);
385
400
  const modules: Module[] = [];
386
401
  const receiverVars = new Map<string, { v: string; mod: Module }>();
@@ -558,7 +573,10 @@ export function generateAlertmanagerFiles(config: AlertmanagerConfig): Generated
558
573
  mod.exports.push(v);
559
574
  }
560
575
 
561
- return modules.map((m) => ({ path: m.path, content: m.render() }));
576
+ return {
577
+ files: modules.map((m) => ({ path: m.path, content: m.render() })),
578
+ declarations: modules.flatMap((m) => m.exports.map((name) => ({ path: m.path, name }))),
579
+ };
562
580
  }
563
581
 
564
582
  /** The rule file and `alertmanager.yml` TypeScript generator `chant import` runs. */
@@ -9,7 +9,8 @@
9
9
  * listed for a reader who meets them in a lint report.
10
10
  */
11
11
 
12
- import { auditRule, type RuleMeta } from "@intentius/chant/audit/catalog";
12
+ import { applyLineage, auditRule, type RuleMeta } from "@intentius/chant/audit/catalog";
13
+ import { prometheusAuditLineage } from "./audit-lineage";
13
14
 
14
15
  function sourceRule(id: string, category: RuleMeta["category"], title: string, remediation: string): RuleMeta {
15
16
  return { id, tier: "merge-worthy", fixKind: "guidance", category, title, remediation, yamlBased: false };
@@ -118,6 +119,16 @@ export const prometheusAuditCatalog: Record<string, RuleMeta> = {
118
119
  "merge-worthy",
119
120
  "correctness",
120
121
  "Receiver integration missing its destination or credential",
121
- "Set the integration's url, api_url, routing_key or to/smarthost/from (or their *_file and global equivalents).",
122
+ "Set the integration's destination and credential (url, webhook_url, api_key, routing_key, chat_id, room_id, to/smarthost/from, ...), through its *_file or global equivalent where there is one.",
123
+ ),
124
+ PROM210: outputRule(
125
+ "PROM210",
126
+ "merge-worthy",
127
+ "correctness",
128
+ "Integration or global setting Alertmanager rejects",
129
+ "Set one of each value and its *_file, and use a value Alertmanager allows (e.g. message_type text or markdown, parse_mode Markdown, MarkdownV2 or HTML).",
122
130
  ),
123
131
  };
132
+
133
+ // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
134
+ applyLineage(prometheusAuditCatalog, prometheusAuditLineage);
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Prior art for the prometheus lexicon's audit rules: the tools whose checks
3
+ * cover the same condition, credited per rule. See
4
+ * packages/core/src/audit/prior-art.ts for the registry, the relation
5
+ * vocabulary, and why this is credit rather than authority. Kept by hand.
6
+ *
7
+ * Mapped against each tool's own check list (#2916):
8
+ *
9
+ * - promtool check rules parses a rule file with Prometheus's rulefmt
10
+ * package (model/rulefmt/rulefmt.go): repeated group names, record/alert
11
+ * exclusivity, annotations/for/keep_firing_for on recording rules, durations,
12
+ * and PromQL syntax. It has no per-rule ids, so those credits omit `rule`.
13
+ * - amtool check-config loads an Alertmanager config with the same code the
14
+ * server uses (config/config.go): undefined receivers and time intervals,
15
+ * non-unique names, a root route that is missing, has no receiver or has
16
+ * matchers, matcher syntax, durations, missing integration destinations and
17
+ * credentials, and "at most one of X and X_file". No per-rule ids either.
18
+ * - pint (cloudflare/pint, docs/checks/) is a rule linter with named checks.
19
+ * promql/syntax, rule/duplicate, rule/label, alerts/annotation and
20
+ * alerts/for map onto chant rules. rule/label and alerts/annotation are
21
+ * configurable "this label or annotation must exist" checks that pint ships
22
+ * with nothing enabled, so they overlap PROM106 and PROM107 and no more.
23
+ *
24
+ * Deliberately without a credit from these tools:
25
+ * - PROM001 and PROM003: a literal credential in TypeScript source, and the
26
+ * Slo objective/window model. Neither tool reads either.
27
+ * - PROM202 (severity not routed) and PROM207 (receiver never routed to):
28
+ * check-config accepts both, and `amtool config routes test` answers a
29
+ * different question (where one given label set goes).
30
+ */
31
+ import type { Lineage } from "@intentius/chant/audit/catalog";
32
+
33
+ const RULEFMT = "https://github.com/prometheus/prometheus/blob/main/model/rulefmt/rulefmt.go";
34
+ const AM_CONFIG = "https://github.com/prometheus/alertmanager/blob/main/config/config.go";
35
+ const PINT = "https://github.com/cloudflare/pint/blob/main/docs/checks";
36
+
37
+ const amtool = (relation: Lineage["relation"]): Lineage => ({
38
+ tool: "amtool",
39
+ url: AM_CONFIG,
40
+ relation,
41
+ });
42
+
43
+ export const prometheusAuditLineage: Record<string, Lineage[]> = {
44
+ PROM002: [
45
+ { tool: "promtool", url: RULEFMT, relation: "equivalent" },
46
+ { tool: "pint", rule: "promql/syntax", url: `${PINT}/promql/syntax.md`, relation: "equivalent" },
47
+ ],
48
+ PROM101: [{ tool: "promtool", url: RULEFMT, relation: "equivalent" }],
49
+ PROM102: [{ tool: "pint", rule: "rule/duplicate", url: `${PINT}/rule/duplicate.md`, relation: "overlaps" }],
50
+ PROM103: [
51
+ { tool: "promtool", url: RULEFMT, relation: "equivalent" },
52
+ { tool: "pint", rule: "alerts/for", url: `${PINT}/alerts/for.md`, relation: "overlaps" },
53
+ ],
54
+ PROM104: [
55
+ { tool: "promtool", url: RULEFMT, relation: "equivalent" },
56
+ { tool: "pint", rule: "promql/syntax", url: `${PINT}/promql/syntax.md`, relation: "equivalent" },
57
+ ],
58
+ PROM105: [{ tool: "promtool", url: RULEFMT, relation: "equivalent" }],
59
+ PROM106: [{ tool: "pint", rule: "rule/label", url: `${PINT}/rule/label.md`, relation: "overlaps" }],
60
+ PROM107: [{ tool: "pint", rule: "alerts/annotation", url: `${PINT}/alerts/annotation.md`, relation: "overlaps" }],
61
+ PROM201: [amtool("equivalent")],
62
+ PROM203: [amtool("equivalent")],
63
+ PROM204: [amtool("equivalent")],
64
+ PROM205: [amtool("equivalent")],
65
+ PROM206: [amtool("equivalent")],
66
+ PROM208: [amtool("equivalent")],
67
+ PROM209: [amtool("overlaps")],
68
+ PROM210: [amtool("overlaps")],
69
+ };
@@ -16,6 +16,7 @@ import { prom206 } from "./prom206";
16
16
  import { prom207 } from "./prom207";
17
17
  import { prom208 } from "./prom208";
18
18
  import { prom209 } from "./prom209";
19
+ import { prom210 } from "./prom210";
19
20
 
20
21
  export const postSynthChecks: PostSynthCheck[] = [
21
22
  prom101,
@@ -34,4 +35,5 @@ export const postSynthChecks: PostSynthCheck[] = [
34
35
  prom207,
35
36
  prom208,
36
37
  prom209,
38
+ prom210,
37
39
  ];
@@ -1,6 +1,8 @@
1
1
  import { describe, expect, test } from "vitest";
2
2
  import { makePostSynthCtx, makePostSynthCtxFromFiles } from "@intentius/chant-test-utils";
3
3
  import { postSynthChecks } from "./index";
4
+ import { RECEIVER_INTEGRATION_TYPES } from "../../integrations";
5
+ import { INTEGRATION_CASES, SLACK_APP_URL, amWith } from "../../testdata/integration-cases";
4
6
  import { prom101 } from "./prom101";
5
7
  import { prom102 } from "./prom102";
6
8
  import { prom103 } from "./prom103";
@@ -17,6 +19,7 @@ import { prom206 } from "./prom206";
17
19
  import { prom207 } from "./prom207";
18
20
  import { prom208 } from "./prom208";
19
21
  import { prom209 } from "./prom209";
22
+ import { prom210 } from "./prom210";
20
23
 
21
24
  const RULES = `groups:
22
25
  - name: api
@@ -245,6 +248,37 @@ receivers:
245
248
  });
246
249
  });
247
250
 
251
+ const integrationCodes = (text: string) =>
252
+ [prom208, prom209, prom210].flatMap((c) => c.check(amOnly(text))).map((d) => d.checkId).sort();
253
+
254
+ describe("PROM208-PROM210 on every Alertmanager integration", () => {
255
+ test.each(INTEGRATION_CASES)("%s", (_label, key, entry, global, codes) => {
256
+ expect(integrationCodes(amWith(key, entry, global))).toEqual(codes);
257
+ });
258
+
259
+ test("every integration has a passing and a failing case", () => {
260
+ for (const key of Object.keys(RECEIVER_INTEGRATION_TYPES)) {
261
+ const cases = INTEGRATION_CASES.filter((c) => c[1] === key);
262
+ expect(cases.some((c) => c[4].length === 0), `${key} passing`).toBe(true);
263
+ expect(cases.some((c) => c[4].length > 0), `${key} failing`).toBe(true);
264
+ }
265
+ });
266
+
267
+ test("findings name the receiver and the entry", () => {
268
+ const [d] = prom209.check(amOnly(amWith("opsgenie_configs", {})));
269
+ expect(d).toMatchObject({ checkId: "PROM209", severity: "error", entity: "r" });
270
+ expect(d.message).toContain('receiver "r" opsgenie_configs[0] has no api_key');
271
+ });
272
+
273
+ test("PROM210 and PROM208 in global", () => {
274
+ const am = (global: Record<string, unknown>) => JSON.stringify({ global, route: { receiver: "r" }, receivers: [{ name: "r" }] });
275
+ expect(integrationCodes(am({ slack_app_token_file: "/t", slack_api_url: "https://hooks.slack.com/x" }))).toEqual(["PROM210"]);
276
+ expect(integrationCodes(am({ slack_app_token_file: "/t", slack_api_url: SLACK_APP_URL }))).toEqual([]);
277
+ expect(integrationCodes(am({ smtp_auth_password: "x", smtp_auth_password_file: "/p", smtp_smarthost: "smtp" }))).toEqual(["PROM210", "PROM210"]);
278
+ expect(integrationCodes(am({ resolve_timeout: "1.5m" }))).toEqual(["PROM208"]);
279
+ });
280
+ });
281
+
248
282
  describe("documents from other lexicons", () => {
249
283
  test("a k8s manifest and a collector config are not read as ours", () => {
250
284
  const ctx = makePostSynthCtx(
@@ -71,7 +71,7 @@ export function ruleFileDiagnostics(ctx: PostSynthContext, code: PrometheusIssue
71
71
  );
72
72
  }
73
73
 
74
- /** Diagnostics for one Alertmanager code (PROM201, PROM203-PROM209) across every `alertmanager.yml` in the output. */
74
+ /** Diagnostics for one Alertmanager code (PROM201, PROM203-PROM210) across every `alertmanager.yml` in the output. */
75
75
  export function alertmanagerDiagnostics(ctx: PostSynthContext, code: PrometheusIssueCode): PostSynthDiagnostic[] {
76
76
  return prometheusDocs(ctx).alertmanager.flatMap(({ source, config }) =>
77
77
  validateAlertmanagerConfig(config)
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * PROM208: An Alertmanager duration is not a duration
3
3
  *
4
- * group_wait, group_interval, repeat_interval, resolve_timeout and webhook timeout take durations such as 30s or 4h.
4
+ * group_wait, group_interval, repeat_interval, resolve_timeout and Jira reopen_duration take Prometheus durations (30s, 4h, 1d); the timeout of webhook, Slack, PagerDuty and incident.io, and Pushover retry, expire and ttl take Go durations (10s, 1m30s, 500ms).
5
5
  */
6
6
 
7
7
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * PROM209: A receiver integration is missing its destination or credential
3
3
  *
4
- * A webhook needs url or url_file, Slack an api_url (or the global one), PagerDuty a routing_key or service_key, and email a to address, a smarthost and a from address (or their global defaults).
4
+ * Every one of Alertmanager's 18 integrations is checked for the destination, credential and required fields its config validation asks for, with the global defaults it falls back to: e.g. a webhook url, an Opsgenie api_key (or global.opsgenie_api_key), a Telegram chat_id and bot token, a Webex room_id and authorization, an SNS target, a Jira project and issue_type.
5
5
  */
6
6
 
7
7
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * PROM210: A receiver integration or global setting is one Alertmanager rejects
3
+ *
4
+ * Two settings that exclude each other (a value and its *_file, a Slack api_url and app_token, Pushover html and monospace, two SNS targets), or a value outside the allowed set (WeChat message_type, Telegram parse_mode, Jira api_type, an Opsgenie responder type, a VictorOps reserved custom field, a smarthost that is not host:port, a Slack or Mattermost field without title and value).
5
+ */
6
+
7
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
8
+ import { alertmanagerDiagnostics } from "./prom-helpers";
9
+
10
+ export const prom210: PostSynthCheck = {
11
+ id: "PROM210",
12
+ description: "A receiver integration or global setting is one Alertmanager rejects",
13
+
14
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
15
+ return alertmanagerDiagnostics(ctx, "PROM210");
16
+ },
17
+ };
@@ -37,6 +37,7 @@ describe("prometheus plugin", () => {
37
37
  "PROM207",
38
38
  "PROM208",
39
39
  "PROM209",
40
+ "PROM210",
40
41
  ]);
41
42
  });
42
43
 
package/src/plugin.ts CHANGED
@@ -11,7 +11,7 @@ import { hover } from "./lsp/hover";
11
11
  import { detectTemplate } from "./detect";
12
12
  import { PrometheusParser } from "./import/parser";
13
13
  import { PrometheusGenerator } from "./import/generator";
14
- import { ruleGroupsImporter } from "./import/embedded";
14
+ import { alertmanagerImporter, ruleGroupsImporter } from "./import/embedded";
15
15
  import { DEFAULT_TEMPLATE, RULES_TEMPLATE, SLO_STYLE_TEMPLATE, SLO_TEMPLATE } from "./init-templates";
16
16
  import { prometheusSkills } from "./skill-defs";
17
17
  import { CATALOG } from "./catalog";
@@ -122,7 +122,7 @@ export const prometheusPlugin: LexiconPlugin = {
122
122
  },
123
123
 
124
124
  embeddedImporters() {
125
- return [ruleGroupsImporter];
125
+ return [ruleGroupsImporter, alertmanagerImporter];
126
126
  },
127
127
 
128
128
  // `chant init --lexicon prometheus [--template rules|slo-style|slo]`; see ./init-templates.ts.
@@ -57,6 +57,8 @@ Import it rather than retyping it: `chant import alertmanager.yml --output src`
57
57
  - Routes name receivers and time intervals that exist (PROM201, PROM204). Reference the entity rather than a string and TypeScript does most of this.
58
58
  - Every `severity` an alert in the same build root carries is matched by some route below the root (PROM202). The check only sees one build root (chant #1939): keep the rules and the Alertmanager config in the same one, or it goes quiet.
59
59
  - Credentials go in `*_file` fields. Alertmanager does not expand environment variables in its config, and a literal `api_url`, `routing_key` or `auth_password` is flagged (PROM001).
60
- - Each integration has somewhere to send (PROM209): a webhook `url`, a Slack `api_url` (or `global.slack_api_url`), a PagerDuty key, an email `to`/`smarthost`/`from` (or the global SMTP defaults).
60
+ - Each integration has its destination, credential and required fields, or the `global` default it falls back to (PROM209): a webhook `url`, a Slack `api_url` or app token, an Opsgenie `api_key`, a Telegram `chat_id` and bot token, a Webex `room_id` and `http_config.authorization`, one SNS target, a Jira `project` and `issue_type`, an email `to`/`smarthost`/`from`, and so on.
61
+ - No setting Alertmanager rejects (PROM210): a value and its `*_file` both set, a Slack `api_url` with an app token, `update_message` without `api_url: https://slack.com/api/chat.postMessage`, a WeChat `message_type` other than `text`/`markdown`, a Telegram `parse_mode` other than `Markdown`/`MarkdownV2`/`HTML`.
62
+ - Durations parse (PROM208). Route timers and `resolve_timeout` take `1d`; the integration `timeout`s and Pushover `retry`/`expire`/`ttl` are Go durations and take `24h`, not `1d`.
61
63
 
62
64
  Run `amtoolCheckConfig(alertmanagerYaml(entities))` in a test to have `amtool check-config` confirm it when installed.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Alertmanager receiver integrations that pass and fail PROM208-PROM210: at
3
+ * least one of each per integration, after Alertmanager v0.34.1's
4
+ * UnmarshalYAML validations (sources in ../validate-integrations.ts).
5
+ * post-synth.test.ts checks the codes; tools.test.ts has amtool reject the
6
+ * failing ones when it is installed.
7
+ *
8
+ * Each case: a label, the receiver key, one entry, an optional `global:`,
9
+ * and the PROM208-PROM210 codes it produces, sorted.
10
+ */
11
+
12
+ export type IntegrationCase = [label: string, key: string, entry: Record<string, unknown>, global: Record<string, unknown> | undefined, codes: string[]];
13
+ const PD = "https://events.pagerduty.com/v2/enqueue";
14
+ export const SLACK_APP_URL = "https://slack.com/api/chat.postMessage";
15
+ export const INTEGRATION_CASES: IntegrationCase[] = [
16
+ ["discord ok", "discord_configs", { webhook_url_file: "/s" }, undefined, []],
17
+ ["discord without a webhook", "discord_configs", {}, undefined, ["PROM209"]],
18
+ ["discord with url and file", "discord_configs", { webhook_url: "https://d/x", webhook_url_file: "/s" }, undefined, ["PROM210"]],
19
+ ["email ok", "email_configs", { to: "a@b.c", from: "am@b.c", smarthost: "smtp:25" }, undefined, []],
20
+ ["email on global SMTP", "email_configs", { to: "a@b.c" }, { smtp_smarthost: "smtp:25", smtp_from: "am@b.c" }, []],
21
+ ["email with nothing", "email_configs", {}, undefined, ["PROM209", "PROM209", "PROM209"]],
22
+ ["email smarthost without port", "email_configs", { to: "a@b.c", from: "am@b.c", smarthost: "smtp" }, undefined, ["PROM210"]],
23
+ ["email duplicate header", "email_configs", { to: "a@b.c", from: "f@b.c", smarthost: "s:25", headers: { subject: "a", Subject: "b" } }, undefined, ["PROM210"]],
24
+ ["email threading without thread_by_date", "email_configs", { to: "a@b.c", from: "f@b.c", smarthost: "s:25", threading: { enabled: true } }, undefined, ["PROM210"]],
25
+ ["email threading with References", "email_configs", { to: "a@b.c", from: "f@b.c", smarthost: "s:25", headers: { references: "x" }, threading: { enabled: true, thread_by_date: "daily" } }, undefined, ["PROM210"]],
26
+ ["incident.io ok", "incidentio_configs", { url: "https://i/x", alert_source_token_file: "/t", timeout: "1.5s" }, undefined, []],
27
+ ["incident.io without url", "incidentio_configs", {}, undefined, ["PROM209"]],
28
+ ["incident.io http_config without a credential", "incidentio_configs", { url: "https://i/x", http_config: { follow_redirects: true } }, undefined, ["PROM209"]],
29
+ ["incident.io token and authorization", "incidentio_configs", { url: "https://i/x", alert_source_token_file: "/t", http_config: { authorization: { credentials_file: "/c" } } }, undefined, ["PROM210"]],
30
+ ["incident.io Prometheus-only duration", "incidentio_configs", { url_file: "/u", timeout: "1d" }, undefined, ["PROM208"]],
31
+ ["jira ok", "jira_configs", { api_url: "https://j/x", project: "OPS", issue_type: "Bug", reopen_duration: "1d" }, undefined, []],
32
+ ["jira on global api_url", "jira_configs", { project: "OPS", issue_type: "Bug" }, { jira_api_url: "https://j/x" }, []],
33
+ ["jira with nothing", "jira_configs", {}, undefined, ["PROM209", "PROM209", "PROM209"]],
34
+ ["jira api_type", "jira_configs", { api_url: "https://j/x", project: "OPS", issue_type: "Bug", api_type: "server" }, undefined, ["PROM210"]],
35
+ ["jira Go-only duration", "jira_configs", { api_url: "https://j/x", project: "OPS", issue_type: "Bug", reopen_duration: "1.5h" }, undefined, ["PROM208"]],
36
+ ["mattermost ok", "mattermost_configs", { webhook_url_file: "/m" }, undefined, []],
37
+ ["mattermost on global webhook", "mattermost_configs", {}, { mattermost_webhook_url_file: "/m" }, []],
38
+ ["mattermost without a webhook", "mattermost_configs", {}, undefined, ["PROM209"]],
39
+ ["mattermost field without value", "mattermost_configs", { webhook_url_file: "/m", attachments: [{ fields: [{ title: "a" }] }] }, undefined, ["PROM210"]],
40
+ ["msteams ok", "msteams_configs", { webhook_url_file: "/t" }, undefined, []],
41
+ ["msteams without a webhook", "msteams_configs", {}, undefined, ["PROM209"]],
42
+ ["msteamsv2 ok", "msteamsv2_configs", { webhook_url_file: "/t" }, undefined, []],
43
+ ["msteamsv2 with url and file", "msteamsv2_configs", { webhook_url: "https://t/x", webhook_url_file: "/t" }, undefined, ["PROM210"]],
44
+ ["opsgenie ok", "opsgenie_configs", { api_key_file: "/k", responders: [{ name: "ops", type: "Team" }, { id: "1", type: "{{ .CommonLabels.kind }}" }] }, undefined, []],
45
+ ["opsgenie on global api_key", "opsgenie_configs", {}, { opsgenie_api_key_file: "/k" }, []],
46
+ ["opsgenie without api_key", "opsgenie_configs", {}, undefined, ["PROM209"]],
47
+ ["opsgenie responders", "opsgenie_configs", { api_key_file: "/k", responders: [{ type: "team" }, { name: "x", type: "group" }, { name: "y" }] }, undefined, ["PROM209", "PROM209", "PROM210"]],
48
+ ["pagerduty ok", "pagerduty_configs", { routing_key_file: "/k", url: PD, timeout: "30s" }, undefined, []],
49
+ ["pagerduty without a key", "pagerduty_configs", {}, undefined, ["PROM209"]],
50
+ ["pagerduty key and file", "pagerduty_configs", { service_key: "x", service_key_file: "/k" }, undefined, ["PROM210"]],
51
+ ["pagerduty timeout without unit", "pagerduty_configs", { routing_key_file: "/k", timeout: "10" }, undefined, ["PROM208"]],
52
+ ["pushover ok", "pushover_configs", { user_key_file: "/u", token_file: "/t", retry: "30s", expire: "1h", ttl: "1h30m" }, undefined, []],
53
+ ["pushover without credentials", "pushover_configs", {}, undefined, ["PROM209", "PROM209"]],
54
+ ["pushover html and monospace", "pushover_configs", { user_key_file: "/u", token_file: "/t", html: true, monospace: true }, undefined, ["PROM210"]],
55
+ ["pushover retry in days", "pushover_configs", { user_key_file: "/u", token_file: "/t", retry: "1d" }, undefined, ["PROM208"]],
56
+ ["rocketchat ok", "rocketchat_configs", { token_id_file: "/i", token_file: "/t" }, undefined, []],
57
+ ["rocketchat on global tokens", "rocketchat_configs", {}, { rocketchat_token_id_file: "/i", rocketchat_token_file: "/t" }, []],
58
+ ["rocketchat without tokens", "rocketchat_configs", {}, undefined, ["PROM209", "PROM209"]],
59
+ ["rocketchat token and file", "rocketchat_configs", { token_id_file: "/i", token: "x", token_file: "/t" }, undefined, ["PROM210"]],
60
+ ["slack ok", "slack_configs", { api_url_file: "/s", timeout: "500ms" }, undefined, []],
61
+ ["slack on global app token", "slack_configs", {}, { slack_app_token_file: "/t" }, []],
62
+ ["slack update_message with the app URL", "slack_configs", { api_url: SLACK_APP_URL, update_message: true, http_config: { authorization: { credentials_file: "/t" } } }, undefined, []],
63
+ ["slack without a url or token", "slack_configs", { channel: "#a" }, undefined, ["PROM209"]],
64
+ ["slack local authorization blocks the global token", "slack_configs", { http_config: { authorization: { credentials_file: "/t" } } }, { slack_app_token_file: "/t" }, ["PROM209"]],
65
+ ["slack url and token", "slack_configs", { api_url_file: "/s", app_token_file: "/t" }, undefined, ["PROM210"]],
66
+ ["slack update_message on a webhook", "slack_configs", { api_url_file: "/s", update_message: true }, undefined, ["PROM210"]],
67
+ ["slack app token and authorization", "slack_configs", { app_token_file: "/t", http_config: { bearer_token_file: "/b" } }, undefined, ["PROM210"]],
68
+ ["slack fields and actions", "slack_configs", { api_url_file: "/s", fields: [{ title: "a" }], actions: [{ type: "button", text: "x" }, { type: "button", text: "y", name: "n", confirm: {} }] }, undefined, ["PROM210", "PROM210", "PROM210"]],
69
+ ["slack timeout", "slack_configs", { api_url_file: "/s", timeout: "5 s" }, undefined, ["PROM208"]],
70
+ ["sns ok", "sns_configs", { topic_arn: "arn:aws:sns:x" }, undefined, []],
71
+ ["sns without a target", "sns_configs", {}, undefined, ["PROM209"]],
72
+ ["sns with two targets", "sns_configs", { topic_arn: "arn:aws:sns:x", target_arn: "arn:aws:sns:y" }, undefined, ["PROM210"]],
73
+ ["telegram ok", "telegram_configs", { bot_token_file: "/t", chat_id: 12, parse_mode: "MarkdownV2" }, undefined, []],
74
+ ["telegram on global bot token", "telegram_configs", { chat_id_file: "/c" }, { telegram_bot_token_file: "/t" }, []],
75
+ ["telegram with nothing", "telegram_configs", {}, undefined, ["PROM209", "PROM209"]],
76
+ ["telegram chat_id and file, bad parse_mode", "telegram_configs", { bot_token_file: "/t", chat_id: 12, chat_id_file: "/c", parse_mode: "markdown" }, undefined, ["PROM210", "PROM210"]],
77
+ ["victorops ok", "victorops_configs", { routing_key: "ops", api_key_file: "/k" }, undefined, []],
78
+ ["victorops on global api_key", "victorops_configs", { routing_key: "ops" }, { victorops_api_key_file: "/k" }, []],
79
+ ["victorops with nothing", "victorops_configs", {}, undefined, ["PROM209", "PROM209"]],
80
+ ["victorops reserved custom field", "victorops_configs", { routing_key: "ops", api_key_file: "/k", custom_fields: { entity_id: "x" } }, undefined, ["PROM210"]],
81
+ ["webex ok", "webex_configs", { room_id: "r", http_config: { bearer_token_file: "/t" } }, undefined, []],
82
+ ["webex with nothing", "webex_configs", {}, undefined, ["PROM209", "PROM209"]],
83
+ ["webex with only a global authorization", "webex_configs", { room_id: "r" }, { http_config: { authorization: { credentials_file: "/t" } } }, ["PROM209"]],
84
+ ["webhook ok", "webhook_configs", { url: "http://sink/", timeout: "1m30s" }, undefined, []],
85
+ ["webhook without url", "webhook_configs", {}, undefined, ["PROM209"]],
86
+ ["webhook url and file", "webhook_configs", { url: "http://sink/", url_file: "/u" }, undefined, ["PROM210"]],
87
+ ["webhook timeout in days", "webhook_configs", { url: "http://sink/", timeout: "1d" }, undefined, ["PROM208"]],
88
+ ["wechat ok", "wechat_configs", { api_secret_file: "/s", corp_id: "c", message_type: "markdown" }, undefined, []],
89
+ ["wechat on global secret and corp", "wechat_configs", {}, { wechat_api_secret_file: "/s", wechat_api_corp_id: "c" }, []],
90
+ ["wechat with nothing", "wechat_configs", {}, undefined, ["PROM209", "PROM209"]],
91
+ ["wechat message_type", "wechat_configs", { api_secret_file: "/s", corp_id: "c", message_type: "html" }, undefined, ["PROM210"]],
92
+ ];
93
+
94
+ /** An `alertmanager.yml`, as JSON, with one receiver `r` holding `entry` under `key`. */
95
+ export const amWith = (key: string, entry: unknown, global?: Record<string, unknown>): string =>
96
+ JSON.stringify({ ...(global ? { global } : {}), route: { receiver: "r" }, receivers: [{ name: "r", [key]: [entry] }] });
package/src/tools.test.ts CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  promtoolCheckRules,
18
18
  ruleFileYaml,
19
19
  } from "./index";
20
+ import { INTEGRATION_CASES, amWith } from "./testdata/integration-cases";
20
21
 
21
22
  const PROMTOOL = process.env.PROMTOOL ?? "promtool";
22
23
  const AMTOOL = process.env.AMTOOL ?? "amtool";
@@ -113,6 +114,11 @@ describe("amtool check-config", () => {
113
114
  expect(r.ok).toBe(false);
114
115
  });
115
116
 
117
+ test.skipIf(!hasAmtool).each(INTEGRATION_CASES.filter((c) => c[4].length > 0))("rejects what PROM208-PROM210 flag: %s", (_label, key, entry, global) => {
118
+ const r = amtoolCheckConfig(amWith(key, entry, global), AMTOOL);
119
+ expect(r.ok, r.output).toBe(false);
120
+ });
121
+
116
122
  test("reports ran: false when the binary is missing", () => {
117
123
  expect(amtoolCheckConfig("route: {}\n", "/nonexistent/amtool").ran).toBe(false);
118
124
  });
@@ -20,6 +20,7 @@ import {
20
20
  type RuleGroupConfig,
21
21
  } from "./model";
22
22
  import { checkPromql } from "./promql";
23
+ import { validateGlobalSettings, validateReceiverIntegrations } from "./validate-integrations";
23
24
 
24
25
  export type PrometheusIssueCode =
25
26
  | "PROM101"
@@ -37,7 +38,8 @@ export type PrometheusIssueCode =
37
38
  | "PROM206"
38
39
  | "PROM207"
39
40
  | "PROM208"
40
- | "PROM209";
41
+ | "PROM209"
42
+ | "PROM210";
41
43
 
42
44
  export interface PrometheusIssue {
43
45
  code: PrometheusIssueCode;
@@ -221,7 +223,7 @@ function visitRoutes(root: RouteConfig | undefined): RouteVisit[] {
221
223
 
222
224
  const AM_DURATIONS = ["group_wait", "group_interval", "repeat_interval"] as const;
223
225
 
224
- /** Check an `alertmanager.yml` on its own (PROM201, PROM203-PROM209). */
226
+ /** Check an `alertmanager.yml` on its own (PROM201, PROM203-PROM210). */
225
227
  export function validateAlertmanagerConfig(config: AlertmanagerConfig): PrometheusIssue[] {
226
228
  const issues: PrometheusIssue[] = [];
227
229
  const receivers = Array.isArray(config?.receivers) ? config.receivers : [];
@@ -307,41 +309,16 @@ export function validateAlertmanagerConfig(config: AlertmanagerConfig): Promethe
307
309
  }
308
310
  });
309
311
 
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
- }
312
+ // PROM208, PROM210: global settings
313
+ issues.push(...validateGlobalSettings(global));
313
314
 
314
- // PROM207: unused receivers; PROM209: incomplete integrations
315
+ // PROM207: unused receivers; PROM208-PROM210: integrations (./validate-integrations.ts)
315
316
  for (const r of receivers) {
316
317
  const name = str(r?.name ?? "");
317
318
  if (!usedReceivers.has(name)) {
318
319
  issues.push({ code: "PROM207", severity: "warning", subject: name, message: `receiver "${name}" is declared but no route sends to it` });
319
320
  }
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
- }
321
+ issues.push(...validateReceiverIntegrations(r, global));
345
322
  // A receiver with no integrations at all is valid: it is how Alertmanager drops alerts.
346
323
  }
347
324