@intentius/chant-lexicon-prometheus 0.101.0 → 0.104.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 (157) hide show
  1. package/README.md +3 -1
  2. package/dist/alertmanager.d.ts.map +1 -1
  3. package/dist/collector-metrics.d.ts +58 -0
  4. package/dist/collector-metrics.d.ts.map +1 -0
  5. package/dist/composites/genai.d.ts +1 -1
  6. package/dist/composites/genai.d.ts.map +1 -1
  7. package/dist/composites/slo.d.ts +1 -1
  8. package/dist/composites/slo.d.ts.map +1 -1
  9. package/dist/import/embedded.d.ts +12 -3
  10. package/dist/import/embedded.d.ts.map +1 -1
  11. package/dist/import/generator.d.ts +14 -0
  12. package/dist/import/generator.d.ts.map +1 -1
  13. package/dist/import/parser.d.ts.map +1 -1
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/init-templates.d.ts.map +1 -1
  17. package/dist/integrations.d.ts.map +1 -1
  18. package/dist/integrity.json +23 -7
  19. package/dist/lint/audit-catalog.d.ts +2 -0
  20. package/dist/lint/audit-catalog.d.ts.map +1 -1
  21. package/dist/lint/audit-lineage.d.ts +54 -0
  22. package/dist/lint/audit-lineage.d.ts.map +1 -0
  23. package/dist/lint/post-synth/index.d.ts.map +1 -1
  24. package/dist/lint/post-synth/prom-helpers.d.ts +9 -2
  25. package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
  26. package/dist/lint/post-synth/prom208.d.ts +1 -1
  27. package/dist/lint/post-synth/prom209.d.ts +1 -1
  28. package/dist/lint/post-synth/prom210.d.ts +8 -0
  29. package/dist/lint/post-synth/prom210.d.ts.map +1 -0
  30. package/dist/lint/post-synth/prom211.d.ts +12 -0
  31. package/dist/lint/post-synth/prom211.d.ts.map +1 -0
  32. package/dist/lint/post-synth/prom212.d.ts +9 -0
  33. package/dist/lint/post-synth/prom212.d.ts.map +1 -0
  34. package/dist/lint/post-synth/prom213.d.ts +11 -0
  35. package/dist/lint/post-synth/prom213.d.ts.map +1 -0
  36. package/dist/lint/post-synth/prom214.d.ts +9 -0
  37. package/dist/lint/post-synth/prom214.d.ts.map +1 -0
  38. package/dist/lint/post-synth/prom215.d.ts +9 -0
  39. package/dist/lint/post-synth/prom215.d.ts.map +1 -0
  40. package/dist/lint/post-synth/prom216.d.ts +10 -0
  41. package/dist/lint/post-synth/prom216.d.ts.map +1 -0
  42. package/dist/lint/post-synth/prom217.d.ts +9 -0
  43. package/dist/lint/post-synth/prom217.d.ts.map +1 -0
  44. package/dist/lint/post-synth/prom218.d.ts +10 -0
  45. package/dist/lint/post-synth/prom218.d.ts.map +1 -0
  46. package/dist/lint/post-synth/prom219.d.ts +9 -0
  47. package/dist/lint/post-synth/prom219.d.ts.map +1 -0
  48. package/dist/lint/post-synth/prom220.d.ts +9 -0
  49. package/dist/lint/post-synth/prom220.d.ts.map +1 -0
  50. package/dist/lint/post-synth/prom221.d.ts +10 -0
  51. package/dist/lint/post-synth/prom221.d.ts.map +1 -0
  52. package/dist/lint/post-synth/prom222.d.ts +9 -0
  53. package/dist/lint/post-synth/prom222.d.ts.map +1 -0
  54. package/dist/lint/post-synth/prom223.d.ts +10 -0
  55. package/dist/lint/post-synth/prom223.d.ts.map +1 -0
  56. package/dist/lint/post-synth/prom224.d.ts +9 -0
  57. package/dist/lint/post-synth/prom224.d.ts.map +1 -0
  58. package/dist/lint/post-synth/prom301.d.ts +14 -0
  59. package/dist/lint/post-synth/prom301.d.ts.map +1 -0
  60. package/dist/lint/rules/literal-credential.d.ts.map +1 -1
  61. package/dist/manifest.json +1 -1
  62. package/dist/okf/index.md +16 -0
  63. package/dist/okf/rules/PROM210.md +11 -0
  64. package/dist/okf/rules/PROM211.md +11 -0
  65. package/dist/okf/rules/PROM212.md +11 -0
  66. package/dist/okf/rules/PROM213.md +11 -0
  67. package/dist/okf/rules/PROM214.md +11 -0
  68. package/dist/okf/rules/PROM215.md +11 -0
  69. package/dist/okf/rules/PROM216.md +11 -0
  70. package/dist/okf/rules/PROM217.md +11 -0
  71. package/dist/okf/rules/PROM218.md +11 -0
  72. package/dist/okf/rules/PROM219.md +11 -0
  73. package/dist/okf/rules/PROM220.md +11 -0
  74. package/dist/okf/rules/PROM221.md +11 -0
  75. package/dist/okf/rules/PROM222.md +11 -0
  76. package/dist/okf/rules/PROM223.md +11 -0
  77. package/dist/okf/rules/PROM224.md +11 -0
  78. package/dist/okf/rules/PROM301.md +11 -0
  79. package/dist/plugin.d.ts.map +1 -1
  80. package/dist/promql-analysis.d.ts +82 -0
  81. package/dist/promql-analysis.d.ts.map +1 -0
  82. package/dist/rule-eval.d.ts.map +1 -1
  83. package/dist/rules/literal-credential.ts +4 -0
  84. package/dist/rules/prom-helpers.ts +41 -2
  85. package/dist/rules/prom208.ts +1 -1
  86. package/dist/rules/prom209.ts +1 -1
  87. package/dist/rules/prom210.ts +17 -0
  88. package/dist/rules/prom211.ts +21 -0
  89. package/dist/rules/prom212.ts +18 -0
  90. package/dist/rules/prom213.ts +20 -0
  91. package/dist/rules/prom214.ts +18 -0
  92. package/dist/rules/prom215.ts +18 -0
  93. package/dist/rules/prom216.ts +19 -0
  94. package/dist/rules/prom217.ts +18 -0
  95. package/dist/rules/prom218.ts +19 -0
  96. package/dist/rules/prom219.ts +18 -0
  97. package/dist/rules/prom220.ts +18 -0
  98. package/dist/rules/prom221.ts +19 -0
  99. package/dist/rules/prom222.ts +18 -0
  100. package/dist/rules/prom223.ts +19 -0
  101. package/dist/rules/prom224.ts +18 -0
  102. package/dist/rules/prom301.ts +23 -0
  103. package/dist/skill-defs.d.ts +1 -1
  104. package/dist/skill-defs.d.ts.map +1 -1
  105. package/dist/skills/chant-prometheus-alertmanager.md +3 -1
  106. package/dist/validate-config.d.ts +9 -3
  107. package/dist/validate-config.d.ts.map +1 -1
  108. package/dist/validate-integrations.d.ts +38 -0
  109. package/dist/validate-integrations.d.ts.map +1 -0
  110. package/dist/validate-security.d.ts +13 -0
  111. package/dist/validate-security.d.ts.map +1 -0
  112. package/package.json +3 -3
  113. package/src/collector-metrics.ts +262 -0
  114. package/src/composites/composites.test.ts +2 -5
  115. package/src/import/embedded.test.ts +52 -3
  116. package/src/import/embedded.ts +37 -5
  117. package/src/import/generator.ts +19 -1
  118. package/src/import/testdata/alertmanager-integrations.yml +5 -1
  119. package/src/index.ts +1 -0
  120. package/src/init-templates.test.ts +3 -1
  121. package/src/lint/audit-catalog.ts +115 -2
  122. package/src/lint/audit-lineage.test.ts +74 -0
  123. package/src/lint/audit-lineage.ts +97 -0
  124. package/src/lint/post-synth/checks.test.ts +257 -0
  125. package/src/lint/post-synth/index.ts +32 -0
  126. package/src/lint/post-synth/post-synth.test.ts +37 -1
  127. package/src/lint/post-synth/prom-helpers.ts +41 -2
  128. package/src/lint/post-synth/prom208.ts +1 -1
  129. package/src/lint/post-synth/prom209.ts +1 -1
  130. package/src/lint/post-synth/prom210.ts +17 -0
  131. package/src/lint/post-synth/prom211.ts +21 -0
  132. package/src/lint/post-synth/prom212.ts +18 -0
  133. package/src/lint/post-synth/prom213.ts +20 -0
  134. package/src/lint/post-synth/prom214.ts +18 -0
  135. package/src/lint/post-synth/prom215.ts +18 -0
  136. package/src/lint/post-synth/prom216.ts +19 -0
  137. package/src/lint/post-synth/prom217.ts +18 -0
  138. package/src/lint/post-synth/prom218.ts +19 -0
  139. package/src/lint/post-synth/prom219.ts +18 -0
  140. package/src/lint/post-synth/prom220.ts +18 -0
  141. package/src/lint/post-synth/prom221.ts +19 -0
  142. package/src/lint/post-synth/prom222.ts +18 -0
  143. package/src/lint/post-synth/prom223.ts +19 -0
  144. package/src/lint/post-synth/prom224.ts +18 -0
  145. package/src/lint/post-synth/prom301.test.ts +157 -0
  146. package/src/lint/post-synth/prom301.ts +23 -0
  147. package/src/lint/rules/literal-credential.ts +4 -0
  148. package/src/lint/rules/rules.test.ts +16 -0
  149. package/src/plugin.test.ts +23 -0
  150. package/src/plugin.ts +14 -3
  151. package/src/promql-analysis.ts +365 -0
  152. package/src/skills/chant-prometheus-alertmanager.md +3 -1
  153. package/src/testdata/integration-cases.ts +96 -0
  154. package/src/tools.test.ts +6 -0
  155. package/src/validate-config.ts +279 -33
  156. package/src/validate-integrations.ts +389 -0
  157. package/src/validate-security.ts +143 -0
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The metric names the collector configs in a build emit through their
3
+ * `spanmetrics`, `servicegraph`, `sum` and `signaltometrics` connectors (the
4
+ * last two are how the otel GenAI preset counts tokens and derives the
5
+ * conventions' client metrics), and the PromQL that reads under those
6
+ * connectors' namespaces without matching one of them (PROM301).
7
+ *
8
+ * Grafana's GRAF118 runs `collectorMetricIssues` over panel queries; the
9
+ * prometheus lexicon runs it over rule files. Names follow the otel lexicon's
10
+ * `metric-names.ts`, with each config's `prometheus` and
11
+ * `prometheusremotewrite` exporters' `namespace` and `add_metric_suffixes`.
12
+ * A component under a renamed type (`span_metrics`, `service_graph`,
13
+ * `signal_to_metrics`) is read as the built-in it names, through the otel
14
+ * lexicon's `canonicalTypeOf`.
15
+ *
16
+ * Collector configs come from two places. `chant build` gives a lexicon's
17
+ * checks only that lexicon's output, but every entity in the build, so the
18
+ * otel lexicon's own config is rebuilt from the build's otel entities with
19
+ * `buildCollectorConfig`. Any output document shaped like a collector config,
20
+ * or a Kubernetes ConfigMap holding one, is read as well.
21
+ */
22
+
23
+ import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
24
+ import { buildCollectorConfig } from "@intentius/chant-lexicon-otel/collector";
25
+ import { isOTelComponent } from "@intentius/chant-lexicon-otel/define";
26
+ import { collectorConfigs } from "@intentius/chant-lexicon-otel/lint/post-synth/otel-helpers";
27
+ import {
28
+ prometheusLabel,
29
+ prometheusMetricName,
30
+ serviceGraphNames,
31
+ spanMetricsNames,
32
+ SERVICEGRAPH_NAMESPACE,
33
+ SPANMETRICS_DEFAULT_NAMESPACE,
34
+ type CollectorMetric,
35
+ type PrometheusNaming,
36
+ type SpanMetricsNamingConfig,
37
+ } from "@intentius/chant-lexicon-otel/metric-names";
38
+ import { canonicalTypeOf, type CollectorConfig } from "@intentius/chant-lexicon-otel/model";
39
+ import { parsePromql, selectors } from "./promql-analysis";
40
+
41
+ type SyntaxNode = NonNullable<ReturnType<typeof parsePromql>>;
42
+ type Obj = Record<string, unknown>;
43
+
44
+ /** One metric a collector config emits, as Prometheus sees it. */
45
+ export interface EmittedMetric {
46
+ /** The connector that emits it, e.g. `spanmetrics/genai`. */
47
+ connector: string;
48
+ /** The labels a series of it can carry; undefined when the exporter adds every resource attribute. */
49
+ labels?: Set<string>;
50
+ }
51
+
52
+ /** What the collector configs in a build emit. */
53
+ export interface CollectorMetrics {
54
+ /** The name prefixes the connectors own, e.g. `traces_span_metrics_`. */
55
+ namespaces: string[];
56
+ /** Every emitted Prometheus name. A histogram is its `_bucket`, `_sum` and `_count` series. */
57
+ metrics: Map<string, EmittedMetric[]>;
58
+ }
59
+
60
+ /** Labels the `prometheus` exporter, or the scrape, adds to every series: `job` and `instance` from the resource, and the scope. */
61
+ const ADDED_LABELS = ["job", "instance", "otel_scope_name", "otel_scope_version", "otel_scope_schema_url"];
62
+
63
+ function asObj(v: unknown): Obj {
64
+ return v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : {};
65
+ }
66
+
67
+ /** A component's type, with a renamed built-in's new name read as the old one (`span_metrics/genai` is `spanmetrics`). */
68
+ function typeOf(kind: "connector" | "exporter", id: string): string {
69
+ return canonicalTypeOf(kind, id);
70
+ }
71
+
72
+ function sanitize(name: string): string {
73
+ return name.replace(/[^A-Za-z0-9_:]/g, "_");
74
+ }
75
+
76
+ /** The common dotted prefix of a connector's metric names (`genai.tokens` for `genai.tokens.input` and `.output`). */
77
+ function commonPrefix(names: string[]): string | undefined {
78
+ if (names.length === 0) return undefined;
79
+ const split = names.map((n) => n.split("."));
80
+ const out: string[] = [];
81
+ for (let i = 0; i < split[0].length - 1; i++) {
82
+ if (split.every((s) => s.length - 1 > i && s[i] === split[0][i])) out.push(split[0][i]);
83
+ else break;
84
+ }
85
+ if (out.length === 0 && names.length === 1 && split[0].length > 1) return split[0].slice(0, -1).join(".");
86
+ return out.length > 0 ? out.join(".") : undefined;
87
+ }
88
+
89
+ /** Every collector config in the build: rebuilt from its otel entities, and found in its output. */
90
+ export function buildCollectorConfigs(ctx: PostSynthContext): CollectorConfig[] {
91
+ const configs = collectorConfigs(ctx).map((f) => f.config);
92
+ const otel = [...(ctx.entities?.values() ?? [])].filter((e) => e?.lexicon === "otel");
93
+ if (otel.some(isOTelComponent)) configs.push(buildCollectorConfig(otel).config);
94
+ return configs;
95
+ }
96
+
97
+ /** The metrics the collector configs emit; undefined when there are none. */
98
+ export function collectorMetrics(configs: CollectorConfig[]): CollectorMetrics | undefined {
99
+ if (configs.length === 0) return undefined;
100
+ const namespaces = new Set<string>();
101
+ const metrics = new Map<string, EmittedMetric[]>();
102
+
103
+ for (const config of configs) {
104
+ const exporters = Object.entries(asObj(config.exporters)).filter(([id]) => typeOf("exporter", id) === "prometheus" || typeOf("exporter", id) === "prometheusremotewrite");
105
+ const namings: Array<{ naming: PrometheusNaming; openLabels: boolean }> =
106
+ exporters.length > 0
107
+ ? exporters.map(([, c]) => {
108
+ const e = asObj(c);
109
+ return {
110
+ naming: { ...(typeof e.namespace === "string" ? { namespace: e.namespace } : {}), ...(e.add_metric_suffixes === false ? { add_metric_suffixes: false } : {}) },
111
+ openLabels: asObj(e.resource_to_telemetry_conversion).enabled === true,
112
+ };
113
+ })
114
+ : [{ naming: {}, openLabels: false }];
115
+
116
+ for (const { naming, openLabels } of namings) {
117
+ const prefix = (ns: string) => `${naming.namespace ? `${sanitize(naming.namespace)}_` : ""}${sanitize(ns)}_`;
118
+ // The two connectors' defaults are theirs in every build that has a collector config.
119
+ namespaces.add(prefix(SPANMETRICS_DEFAULT_NAMESPACE));
120
+ namespaces.add(prefix(SERVICEGRAPH_NAMESPACE));
121
+
122
+ const add = (connector: string, m: CollectorMetric, extra: string[] = []) => {
123
+ const labels = openLabels ? undefined : new Set([...m.dimensions.map(prometheusLabel), ...extra.map(prometheusLabel), ...ADDED_LABELS]);
124
+ // A classic histogram is served as three series whatever add_metric_suffixes says.
125
+ const series: Array<[string, Set<string> | undefined]> =
126
+ m.type === "histogram"
127
+ ? [
128
+ [`${m.prometheus}_bucket`, labels ? new Set([...labels, "le"]) : undefined],
129
+ [`${m.prometheus}_sum`, labels],
130
+ [`${m.prometheus}_count`, labels],
131
+ ]
132
+ : [[m.prometheus, labels]];
133
+ for (const [name, l] of series) {
134
+ metrics.set(name, [...(metrics.get(name) ?? []), { connector, ...(l ? { labels: l } : {}) }]);
135
+ }
136
+ };
137
+
138
+ for (const [id, raw] of Object.entries(asObj(config.connectors))) {
139
+ const c = asObj(raw);
140
+ switch (typeOf("connector", id)) {
141
+ case "spanmetrics": {
142
+ const n = spanMetricsNames(c as SpanMetricsNamingConfig, naming);
143
+ if (n.namespace !== "") namespaces.add(prefix(n.namespace));
144
+ for (const m of [n.calls, n.duration, n.events]) if (m) add(id, m);
145
+ break;
146
+ }
147
+ case "servicegraph": {
148
+ const n = serviceGraphNames(c, naming);
149
+ for (const m of [n.requests, n.failed, n.serverDuration, n.clientDuration]) add(id, m);
150
+ break;
151
+ }
152
+ case "sum": {
153
+ const names: string[] = [];
154
+ for (const signal of ["spans", "spanevents", "metrics", "datapoints", "logs"]) {
155
+ for (const [name, def] of Object.entries(asObj(c[signal]))) {
156
+ const attrs = (Array.isArray(asObj(def).attributes) ? (asObj(def).attributes as unknown[]) : []).map((a) => String(asObj(a).key ?? ""));
157
+ names.push(name);
158
+ add(id, { name, prometheus: prometheusMetricName(name, "sum", undefined, naming), type: "sum", dimensions: attrs.filter(Boolean) });
159
+ }
160
+ }
161
+ const ns = commonPrefix(names);
162
+ if (ns) namespaces.add(prefix(ns));
163
+ break;
164
+ }
165
+ case "signaltometrics": {
166
+ const names: string[] = [];
167
+ for (const signal of ["spans", "datapoints", "logs", "profiles"]) {
168
+ for (const def of Array.isArray(c[signal]) ? (c[signal] as unknown[]) : []) {
169
+ const m = asObj(def);
170
+ if (typeof m.name !== "string") continue;
171
+ const type = m.histogram !== undefined || m.exponential_histogram !== undefined ? "histogram" : m.sum !== undefined ? "sum" : undefined;
172
+ if (!type) continue;
173
+ const unit = typeof m.unit === "string" ? m.unit : undefined;
174
+ const attrs = [
175
+ ...(Array.isArray(m.attributes) ? (m.attributes as unknown[]) : []),
176
+ ...(Array.isArray(m.include_resource_attributes) ? (m.include_resource_attributes as unknown[]) : []),
177
+ ].map((a) => String(asObj(a).key ?? ""));
178
+ names.push(m.name);
179
+ add(id, { name: m.name, prometheus: prometheusMetricName(m.name, type, unit, naming), type, ...(unit ? { unit } : {}), dimensions: attrs.filter(Boolean) });
180
+ }
181
+ }
182
+ const ns = commonPrefix(names);
183
+ if (ns) namespaces.add(prefix(ns));
184
+ break;
185
+ }
186
+ }
187
+ }
188
+ }
189
+ }
190
+ return { namespaces: [...namespaces], metrics };
191
+ }
192
+
193
+ // ── PromQL ──────────────────────────────────────────────────────────
194
+
195
+ /** Functions that write a label of their own, so an aggregation over them can group by it. */
196
+ const RELABEL = new Set(["label_replace", "label_join"]);
197
+
198
+ /** Every `by (...)` aggregation in the expression: its labels and the text it aggregates. */
199
+ function byAggregations(top: SyntaxNode, src: string): Array<{ labels: string[]; body: string }> {
200
+ const out: Array<{ labels: string[]; body: string }> = [];
201
+ const walk = (n: SyntaxNode) => {
202
+ if (n.name === "AggregateExpr") {
203
+ const mod = n.getChild("AggregateModifier");
204
+ const body = n.getChild("FunctionCallBody");
205
+ if (mod?.getChild("By") && body) {
206
+ const labels = (mod.getChild("GroupingLabels")?.getChildren("LabelName") ?? []).map((l) => src.slice(l.from, l.to));
207
+ out.push({ labels, body: src.slice(body.from + 1, body.to - 1) });
208
+ }
209
+ }
210
+ for (let c = n.firstChild; c; c = c.nextSibling) walk(c);
211
+ };
212
+ walk(top);
213
+ return out;
214
+ }
215
+
216
+ export interface CollectorMetricIssue {
217
+ kind: "name" | "label";
218
+ metric: string;
219
+ label?: string;
220
+ message: string;
221
+ }
222
+
223
+ /**
224
+ * What a PromQL expression reads under the build's connector namespaces and
225
+ * the build does not emit: a metric name no connector writes, and a
226
+ * `by (...)` label none of the aggregated metrics carries. A name outside
227
+ * every namespace is not read. An aggregation is checked only when every
228
+ * series under it is an emitted metric and nothing under it adds a label
229
+ * (`label_replace`, `label_join`). Selectors come from the lexicon's
230
+ * `promql-analysis.ts`.
231
+ */
232
+ export function collectorMetricIssues(expr: string, emitted: CollectorMetrics): CollectorMetricIssue[] {
233
+ const top = parsePromql(expr);
234
+ if (!top) return [];
235
+ const issues: CollectorMetricIssue[] = [];
236
+ const owned = (name: string) => emitted.namespaces.some((p) => name.startsWith(p));
237
+
238
+ const seen = new Set<string>();
239
+ for (const { name } of selectors(expr)) {
240
+ if (!name || seen.has(name) || !owned(name) || emitted.metrics.has(name)) continue;
241
+ seen.add(name);
242
+ const p = emitted.namespaces.filter((x) => name.startsWith(x)).sort((a, b) => b.length - a.length)[0];
243
+ issues.push({ kind: "name", metric: name, message: `reads ${name}, which no collector config in the build emits under ${p.slice(0, -1)}` });
244
+ }
245
+
246
+ const reported = new Set<string>();
247
+ for (const { labels, body } of byAggregations(top, expr)) {
248
+ const read = selectors(body);
249
+ if (read.length === 0 || read.some((s) => s.within.some((fn) => RELABEL.has(fn)))) continue;
250
+ const metrics = read.map((s) => (s.name ? emitted.metrics.get(s.name) : undefined));
251
+ if (!metrics.every((m) => m !== undefined && m.every((e) => e.labels))) continue;
252
+ const carried = new Set(metrics.flatMap((m) => m!.flatMap((e) => [...e.labels!])));
253
+ const shown = [...new Set(read.map((s) => s.name!))].join(", ");
254
+ for (const label of labels) {
255
+ const key = `${label} ${shown}`;
256
+ if (carried.has(label) || reported.has(key)) continue;
257
+ reported.add(key);
258
+ issues.push({ kind: "label", metric: shown, label, message: `groups ${shown} by ${label}, which is not a dimension the collector config declares for it` });
259
+ }
260
+ }
261
+ return issues;
262
+ }
@@ -184,12 +184,9 @@ describe("the rules an Slo builds", () => {
184
184
  expect(sloMetrics(direct).burnRates).toEqual([]);
185
185
  });
186
186
 
187
- test("the same Slo renders inside the rule file promtool accepts", () => {
187
+ test.skipIf(!hasPromtool)("the same Slo renders inside the rule file promtool accepts", () => {
188
188
  const r = promtoolCheckRules(ruleFileYaml([slo.rules]), PROMTOOL);
189
- if (!hasPromtool) {
190
- expect(r.ran).toBe(false);
191
- return;
192
- }
189
+ expect(r.ran).toBe(true);
193
190
  expect(r.output).toContain("SUCCESS");
194
191
  expect(r.ok).toBe(true);
195
192
  });
@@ -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. */
@@ -50,12 +50,16 @@ route:
50
50
  receivers:
51
51
  - name: chat
52
52
  slack_configs:
53
- - channel: '#alerts'
53
+ - api_url: https://slack.com/api/chat.postMessage
54
+ channel: '#alerts'
54
55
  message_text: '{{ .CommonLabels.alertname }}'
55
56
  callback_id: alerts
56
57
  image_url: https://example.com/graph.png
57
58
  thumb_url: https://example.com/thumb.png
58
59
  update_message: true
60
+ http_config:
61
+ authorization:
62
+ credentials_file: /etc/alertmanager/secrets/slack-bot-token
59
63
  timeout: 10s
60
64
  fields:
61
65
  - title: Severity
package/src/index.ts CHANGED
@@ -66,6 +66,7 @@ export { parseMatchers, matcherMatches, matcher, type Matcher, type MatchOp, typ
66
66
  export { checkPromql, PROMQL_GRAMMAR, type PromqlCheck } from "./promql";
67
67
  export {
68
68
  validateRuleFile,
69
+ validateRunbookUrls,
69
70
  validateAlertmanagerConfig,
70
71
  validateSeverityRouting,
71
72
  alertSeverities,
@@ -24,7 +24,9 @@ describe("init templates", () => {
24
24
  try {
25
25
  expect(result.errors).toEqual([]);
26
26
  expect(result.outputs.get("prometheus")).toBeTruthy();
27
- expect(runPostSynthChecks(postSynthChecks, result)).toEqual([]);
27
+ // As `chant build` reports them: through the default (`recommended`) preset.
28
+ const recommended = new Set(prometheusPlugin.lintPresets!().recommended);
29
+ expect(runPostSynthChecks(postSynthChecks, result).filter((d) => recommended.has(d.checkId))).toEqual([]);
28
30
  const lint = await lintCommand({ path: join(dir, "src"), format: "stylish", fix: false });
29
31
  expect(lint.errorCount + lint.warningCount, lint.output).toBe(0);
30
32
  } finally {
@@ -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,118 @@ 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).",
130
+ ),
131
+ PROM211: outputRule(
132
+ "PROM211",
133
+ "report-only",
134
+ "best-practice",
135
+ "Alerting rule has no for, or for: 0s",
136
+ "Set for to how long the condition must hold before the alert fires, e.g. 5m.",
137
+ ),
138
+ PROM212: outputRule(
139
+ "PROM212",
140
+ "report-only",
141
+ "best-practice",
142
+ "Alerting rule has no runbook_url annotation (opt-in)",
143
+ "Add a runbook_url annotation linking to what the responder should do.",
144
+ ),
145
+ PROM213: outputRule(
146
+ "PROM213",
147
+ "merge-worthy",
148
+ "correctness",
149
+ "Alert expression has no comparison",
150
+ "Add the condition the alert fires on, e.g. > 0.05, or use absent() for a missing series.",
151
+ ),
152
+ PROM214: outputRule(
153
+ "PROM214",
154
+ "merge-worthy",
155
+ "correctness",
156
+ "Alert template reads a label the expression aggregates away",
157
+ "Keep the label in the aggregation's by (...), or stop reading it in the template.",
158
+ ),
159
+ PROM215: outputRule(
160
+ "PROM215",
161
+ "merge-worthy",
162
+ "correctness",
163
+ "rate, irate or increase over a name that is not a counter's",
164
+ "Read a counter (a name ending in _total, _count, _sum or _bucket), or use delta() or deriv() for a gauge.",
165
+ ),
166
+ PROM216: outputRule(
167
+ "PROM216",
168
+ "merge-worthy",
169
+ "correctness",
170
+ "histogram_quantile over a series without _bucket, or without le",
171
+ "Pass the histogram's _bucket series, and keep le in the aggregation: sum by (le, ...) (rate(x_bucket[5m])).",
172
+ ),
173
+ PROM217: outputRule(
174
+ "PROM217",
175
+ "report-only",
176
+ "best-practice",
177
+ "Recording rule name is not level:metric:operations",
178
+ "Name the recorded series level:metric:operations, e.g. job:http_requests:rate5m.",
179
+ ),
180
+ PROM218: outputRule(
181
+ "PROM218",
182
+ "report-only",
183
+ "best-practice",
184
+ "Regex matcher needs no regex, or is anchored",
185
+ 'Use = or != for a plain value, and drop ^ and $ from a regex, which Prometheus anchors already (job=~"api|web").',
186
+ ),
187
+ PROM219: outputRule("PROM219", "merge-worthy", "correctness", "Alerting rule sets the alertname label", "Remove the alertname label; rename the rule instead."),
188
+ PROM220: outputRule(
189
+ "PROM220",
190
+ "merge-worthy",
191
+ "security",
192
+ "Receiver turns off TLS certificate verification",
193
+ "Remove insecure_skip_verify, and set ca_file to the CA that signed the endpoint's certificate.",
194
+ ),
195
+ PROM221: outputRule(
196
+ "PROM221",
197
+ "merge-worthy",
198
+ "security",
199
+ "SMTP credentials sent with require_tls false",
200
+ "Set require_tls (or global.smtp_require_tls) to true, or use force_implicit_tls with an SMTPS port.",
201
+ ),
202
+ PROM222: outputRule(
203
+ "PROM222",
204
+ "merge-worthy",
205
+ "security",
206
+ "Credentials sent to an http:// receiver URL",
207
+ "Point the integration at an https:// URL.",
208
+ ),
209
+ PROM223: outputRule(
210
+ "PROM223",
211
+ "merge-worthy",
212
+ "correctness",
213
+ "repeat_interval is shorter than group_interval",
214
+ "Set repeat_interval to a multiple of group_interval, or lower group_interval.",
215
+ ),
216
+ PROM224: outputRule(
217
+ "PROM224",
218
+ "merge-worthy",
219
+ "correctness",
220
+ "Inhibit rule matches one alert as source and target, with no equal",
221
+ "List the labels source and target must share under equal (e.g. alertname, cluster), or make the matchers exclusive.",
222
+ ),
223
+ PROM301: outputRule(
224
+ "PROM301",
225
+ "merge-worthy",
226
+ "correctness",
227
+ "Rule reads a connector metric no collector in the build emits",
228
+ "Read the name spanMetricsNames(), serviceGraphNames() or genAiMetrics() gives for the declared connector, and group by its declared dimensions, or add the dimension to the connector.",
122
229
  ),
123
230
  };
231
+
232
+ /** Post-synth checks the `recommended` lint preset leaves out (#3363); see `prometheusPlugin.lintPresets()`. */
233
+ export const OPT_IN_CHECKS: ReadonlySet<string> = new Set(["PROM212"]);
234
+
235
+ // Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
236
+ applyLineage(prometheusAuditCatalog, prometheusAuditLineage);