@intentius/chant-lexicon-prometheus 0.96.0 → 0.98.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 (53) hide show
  1. package/README.md +5 -0
  2. package/dist/alertmanager.d.ts +4 -2
  3. package/dist/alertmanager.d.ts.map +1 -1
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/codegen/docs.d.ts.map +1 -1
  6. package/dist/import/generator.d.ts +35 -0
  7. package/dist/import/generator.d.ts.map +1 -0
  8. package/dist/import/parser.d.ts +52 -0
  9. package/dist/import/parser.d.ts.map +1 -0
  10. package/dist/import/slo.d.ts +15 -0
  11. package/dist/import/slo.d.ts.map +1 -0
  12. package/dist/integrity.json +7 -7
  13. package/dist/lint/rules/literal-credential.d.ts.map +1 -1
  14. package/dist/lint/rules/prom-ast.d.ts +8 -0
  15. package/dist/lint/rules/prom-ast.d.ts.map +1 -1
  16. package/dist/lint/rules/promql-literal.d.ts.map +1 -1
  17. package/dist/manifest.json +1 -1
  18. package/dist/model.d.ts +21 -0
  19. package/dist/model.d.ts.map +1 -1
  20. package/dist/plugin.d.ts.map +1 -1
  21. package/dist/rules/literal-credential.ts +17 -11
  22. package/dist/rules/prom-ast.ts +36 -0
  23. package/dist/rules/promql-literal.ts +15 -1
  24. package/dist/skills/chant-prometheus-alertmanager.md +4 -0
  25. package/dist/skills/chant-prometheus.md +11 -0
  26. package/package.json +2 -2
  27. package/src/alertmanager.ts +4 -1
  28. package/src/build.ts +2 -0
  29. package/src/codegen/docs.ts +5 -1
  30. package/src/import/cli.test.ts +98 -0
  31. package/src/import/generated-types.e2e.test.ts +75 -0
  32. package/src/import/generator.test.ts +121 -0
  33. package/src/import/generator.ts +570 -0
  34. package/src/import/parser.test.ts +120 -0
  35. package/src/import/parser.ts +348 -0
  36. package/src/import/roundtrip.test.ts +321 -0
  37. package/src/import/slo.ts +175 -0
  38. package/src/import/testdata/alertmanager-full.yml +89 -0
  39. package/src/import/testdata/fixtures.ts +114 -0
  40. package/src/import/testdata/rules-full.yml +42 -0
  41. package/src/import/testdata/upstream/alertmanager-route-labels.yml +65 -0
  42. package/src/import/testdata/upstream/alertmanager-simple.yml +130 -0
  43. package/src/import/testdata/upstream/prometheus-alerting-rules.yml +15 -0
  44. package/src/import/testdata/upstream/prometheus-alerting-templates.yml +23 -0
  45. package/src/import/testdata/upstream/prometheus-recording-rules.yml +7 -0
  46. package/src/lint/rules/literal-credential.ts +17 -11
  47. package/src/lint/rules/prom-ast.ts +36 -0
  48. package/src/lint/rules/promql-literal.ts +15 -1
  49. package/src/lint/rules/rules.test.ts +28 -0
  50. package/src/model.ts +22 -0
  51. package/src/plugin.ts +10 -0
  52. package/src/skills/chant-prometheus-alertmanager.md +4 -0
  53. package/src/skills/chant-prometheus.md +11 -0
@@ -0,0 +1,348 @@
1
+ /**
2
+ * Rule file or `alertmanager.yml` -> `TemplateIR`, for `chant import`.
3
+ *
4
+ * Either file is one document whose parts name each other (a route names a
5
+ * receiver and time intervals), so the IR carries the whole file as one
6
+ * resource and the generator lays out the modules itself, turning those
7
+ * names into imports of declared entities. Core splits an IR with more than
8
+ * three resources into one generate() call per category and keeps only the
9
+ * first file of each, which would lose the imports between a route and the
10
+ * receivers it names.
11
+ *
12
+ * Values are carried as written: `*_file` credential paths, Go templates in
13
+ * annotations and notifier fields, and literal credentials, which PROM001
14
+ * then reports in the generated source. What the lexicon has no place for is
15
+ * named in `warnings`. Two deprecated Alertmanager spellings are rewritten to
16
+ * the ones Alertmanager treats the same way: `match` / `match_re` (and the
17
+ * inhibit rules' `source_match*` / `target_match*`) become matcher strings,
18
+ * and the top-level `mute_time_intervals` list joins `time_intervals`.
19
+ */
20
+
21
+ import * as jsYaml from "js-yaml";
22
+ import type { TemplateIR, TemplateParser } from "@intentius/chant/import/parser";
23
+ import {
24
+ looksLikeAlertmanagerConfig,
25
+ looksLikeRuleFile,
26
+ type AlertmanagerConfig,
27
+ type InhibitRuleConfig,
28
+ type ReceiverConfig,
29
+ type RouteConfig,
30
+ type RuleConfig,
31
+ type RuleFileConfig,
32
+ type RuleGroupConfig,
33
+ type TimeIntervalConfig,
34
+ } from "../model";
35
+
36
+ /** The IR resource type for a whole rule file. */
37
+ export const RULE_FILE_RESOURCE_TYPE = "Prometheus::RuleFile";
38
+ /** The IR resource type for a whole `alertmanager.yml`. */
39
+ export const ALERTMANAGER_RESOURCE_TYPE = "Prometheus::Alertmanager::Config";
40
+
41
+ /** `properties` of the `Prometheus::RuleFile` resource. */
42
+ export interface RuleFileResourceProperties {
43
+ file: RuleFileConfig;
44
+ }
45
+
46
+ /** `properties` of the `Prometheus::Alertmanager::Config` resource. */
47
+ export interface AlertmanagerResourceProperties {
48
+ config: AlertmanagerConfig;
49
+ }
50
+
51
+ /** What the parser read. */
52
+ export type ParsedPrometheusFile =
53
+ | { kind: "rules"; file: RuleFileConfig; warnings: string[] }
54
+ | { kind: "alertmanager"; config: AlertmanagerConfig; warnings: string[] };
55
+
56
+ // YAML 1.2 core types plus `<<` merge keys, which Alertmanager configs use to
57
+ // share notifier settings. The default schema would also turn an unquoted
58
+ // date into a Date, which neither Go loader does.
59
+ // js-yaml exports its built-in types at runtime; @types/js-yaml does not declare them.
60
+ const MERGE_TYPE = (jsYaml as unknown as { types: { merge: jsYaml.Type } }).types.merge;
61
+ const YAML_SCHEMA = jsYaml.CORE_SCHEMA.extend({ implicit: [MERGE_TYPE] });
62
+
63
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
64
+ return typeof v === "object" && v !== null && !Array.isArray(v);
65
+ }
66
+
67
+ /** A label set with every value a string, as Prometheus and Alertmanager read one. */
68
+ function labelSet(v: unknown, at: string, warnings: string[]): Record<string, string> | undefined {
69
+ if (v === null || v === undefined) return undefined;
70
+ if (!isPlainObject(v)) {
71
+ warnings.push(`${at} is not a mapping; it is not carried`);
72
+ return undefined;
73
+ }
74
+ const out: Record<string, string> = {};
75
+ for (const [k, x] of Object.entries(v)) out[k] = x === null ? "" : String(x);
76
+ return out;
77
+ }
78
+
79
+ function stringList(v: unknown, at: string, warnings: string[]): string[] | undefined {
80
+ if (v === null || v === undefined) return undefined;
81
+ if (!Array.isArray(v)) {
82
+ warnings.push(`${at} is not a list; it is not carried`);
83
+ return undefined;
84
+ }
85
+ return v.map((x) => String(x));
86
+ }
87
+
88
+ // ── rule files ──────────────────────────────────────────────────────
89
+
90
+ const GROUP_KEYS = ["name", "interval", "query_offset", "limit", "labels", "rules"];
91
+ const RULE_KEYS = ["record", "alert", "expr", "for", "keep_firing_for", "labels", "annotations"];
92
+
93
+ function parseRule(raw: unknown, at: string, warnings: string[]): RuleConfig | undefined {
94
+ if (!isPlainObject(raw)) {
95
+ warnings.push(`${at} is not a mapping; it is not carried`);
96
+ return undefined;
97
+ }
98
+ for (const k of Object.keys(raw)) {
99
+ if (!RULE_KEYS.includes(k)) warnings.push(`${at}.${k} is not a rule field; it is not carried`);
100
+ }
101
+ const out: Record<string, unknown> = {};
102
+ for (const k of ["record", "alert", "expr", "for", "keep_firing_for"]) {
103
+ const v = raw[k];
104
+ if (v !== undefined && v !== null) out[k] = String(v);
105
+ }
106
+ const labels = labelSet(raw.labels, `${at}.labels`, warnings);
107
+ if (labels) out.labels = labels;
108
+ const annotations = labelSet(raw.annotations, `${at}.annotations`, warnings);
109
+ if (annotations) out.annotations = annotations;
110
+ return out as unknown as RuleConfig;
111
+ }
112
+
113
+ function parseRuleFile(doc: Record<string, unknown>, warnings: string[]): RuleFileConfig {
114
+ for (const k of Object.keys(doc)) {
115
+ if (k !== "groups") warnings.push(`top-level key "${k}" is not part of a rule file; it is not carried`);
116
+ }
117
+ const groups: RuleGroupConfig[] = [];
118
+ const list = Array.isArray(doc.groups) ? doc.groups : [];
119
+ list.forEach((raw, i) => {
120
+ const at = `groups[${i}]`;
121
+ if (!isPlainObject(raw)) {
122
+ warnings.push(`${at} is not a mapping; it is not carried`);
123
+ return;
124
+ }
125
+ const name = String(raw.name ?? "");
126
+ const where = `group "${name}"`;
127
+ for (const k of Object.keys(raw)) {
128
+ if (!GROUP_KEYS.includes(k)) warnings.push(`${where}: "${k}" is not a rule group field; it is not carried`);
129
+ }
130
+ const group: Record<string, unknown> = { name };
131
+ if (raw.interval !== undefined && raw.interval !== null) group.interval = String(raw.interval);
132
+ if (raw.query_offset !== undefined && raw.query_offset !== null) group.query_offset = String(raw.query_offset);
133
+ if (raw.limit !== undefined && raw.limit !== null) group.limit = Number(raw.limit);
134
+ const labels = labelSet(raw.labels, `${where} labels`, warnings);
135
+ if (labels) group.labels = labels;
136
+ const rules: RuleConfig[] = [];
137
+ (Array.isArray(raw.rules) ? raw.rules : []).forEach((r, j) => {
138
+ const rule = parseRule(r, `${where} rules[${j}]`, warnings);
139
+ if (rule) rules.push(rule);
140
+ });
141
+ group.rules = rules;
142
+ groups.push(group as unknown as RuleGroupConfig);
143
+ });
144
+ return { groups };
145
+ }
146
+
147
+ // ── alertmanager.yml ────────────────────────────────────────────────
148
+
149
+ const AM_SECTIONS = ["global", "templates", "route", "inhibit_rules", "receivers", "time_intervals", "mute_time_intervals", "tracing"];
150
+ const ROUTE_KEYS = [
151
+ "receiver",
152
+ "group_by",
153
+ "continue",
154
+ "matchers",
155
+ "match",
156
+ "match_re",
157
+ "group_wait",
158
+ "group_interval",
159
+ "repeat_interval",
160
+ "mute_time_intervals",
161
+ "active_time_intervals",
162
+ "labels",
163
+ "routes",
164
+ ];
165
+
166
+ /** A matcher string Alertmanager parses back to `name op value`. */
167
+ export function matcherString(name: string, op: "=" | "=~", value: string): string {
168
+ const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
169
+ return `${name}${op}"${escaped}"`;
170
+ }
171
+
172
+ /** `match` and `match_re` maps as matcher strings, in the order written. */
173
+ function matchersFrom(match: unknown, matchRe: unknown, at: string, warnings: string[]): string[] {
174
+ const out: string[] = [];
175
+ for (const [value, op, key] of [
176
+ [match, "=", "match"],
177
+ [matchRe, "=~", "match_re"],
178
+ ] as const) {
179
+ if (value === undefined || value === null) continue;
180
+ if (!isPlainObject(value)) {
181
+ warnings.push(`${at}.${key} is not a mapping; it is not carried`);
182
+ continue;
183
+ }
184
+ for (const [k, v] of Object.entries(value)) out.push(matcherString(k, op, String(v)));
185
+ }
186
+ return out;
187
+ }
188
+
189
+ function parseRoute(raw: unknown, at: string, warnings: string[]): RouteConfig {
190
+ if (!isPlainObject(raw)) {
191
+ warnings.push(`${at} is not a mapping; it is carried as an empty route`);
192
+ return {};
193
+ }
194
+ for (const k of Object.keys(raw)) {
195
+ if (!ROUTE_KEYS.includes(k)) warnings.push(`${at}.${k} is not a route field; it is not carried`);
196
+ }
197
+ const out: RouteConfig = {};
198
+ if (raw.receiver !== undefined && raw.receiver !== null) out.receiver = String(raw.receiver);
199
+ const groupBy = stringList(raw.group_by, `${at}.group_by`, warnings);
200
+ if (groupBy) out.group_by = groupBy;
201
+ if (typeof raw.continue === "boolean") out.continue = raw.continue;
202
+ const matchers = stringList(raw.matchers, `${at}.matchers`, warnings) ?? [];
203
+ const converted = matchersFrom(raw.match, raw.match_re, at, warnings);
204
+ if (converted.length > 0) {
205
+ warnings.push(`${at}: match/match_re are written as matchers (${converted.join(", ")}), which Alertmanager treats the same way`);
206
+ }
207
+ if (matchers.length + converted.length > 0 || raw.matchers !== undefined) out.matchers = [...matchers, ...converted];
208
+ for (const k of ["group_wait", "group_interval", "repeat_interval"] as const) {
209
+ if (raw[k] !== undefined && raw[k] !== null) out[k] = String(raw[k]);
210
+ }
211
+ const mute = stringList(raw.mute_time_intervals, `${at}.mute_time_intervals`, warnings);
212
+ if (mute) out.mute_time_intervals = mute;
213
+ const active = stringList(raw.active_time_intervals, `${at}.active_time_intervals`, warnings);
214
+ if (active) out.active_time_intervals = active;
215
+ const labels = labelSet(raw.labels, `${at}.labels`, warnings);
216
+ if (labels) out.labels = labels;
217
+ if (Array.isArray(raw.routes)) out.routes = raw.routes.map((r, i) => parseRoute(r, `${at}.routes[${i}]`, warnings));
218
+ else if (raw.routes !== undefined && raw.routes !== null) warnings.push(`${at}.routes is not a list; it is not carried`);
219
+ return out;
220
+ }
221
+
222
+ function parseInhibitRule(raw: unknown, at: string, warnings: string[]): InhibitRuleConfig | undefined {
223
+ if (!isPlainObject(raw)) {
224
+ warnings.push(`${at} is not a mapping; it is not carried`);
225
+ return undefined;
226
+ }
227
+ const known = ["name", "source_matchers", "source_match", "source_match_re", "target_matchers", "target_match", "target_match_re", "equal"];
228
+ for (const k of Object.keys(raw)) {
229
+ if (!known.includes(k)) warnings.push(`${at}.${k} is not an inhibit rule field; it is not carried`);
230
+ }
231
+ const out: InhibitRuleConfig = {};
232
+ if (raw.name !== undefined && raw.name !== null) out.name = String(raw.name);
233
+ for (const side of ["source", "target"] as const) {
234
+ const matchers = stringList(raw[`${side}_matchers`], `${at}.${side}_matchers`, warnings) ?? [];
235
+ const converted = matchersFrom(raw[`${side}_match`], raw[`${side}_match_re`], at, warnings);
236
+ if (converted.length > 0) {
237
+ warnings.push(
238
+ `${at}: ${side}_match/${side}_match_re are written as ${side}_matchers (${converted.join(", ")}), which Alertmanager treats the same way`,
239
+ );
240
+ }
241
+ if (matchers.length + converted.length > 0 || raw[`${side}_matchers`] !== undefined) {
242
+ out[`${side}_matchers`] = [...matchers, ...converted];
243
+ }
244
+ }
245
+ const equal = stringList(raw.equal, `${at}.equal`, warnings);
246
+ if (equal) out.equal = equal;
247
+ return out;
248
+ }
249
+
250
+ function parseTimeIntervals(raw: unknown, at: string, warnings: string[]): TimeIntervalConfig[] {
251
+ if (raw === undefined || raw === null) return [];
252
+ if (!Array.isArray(raw)) {
253
+ warnings.push(`${at} is not a list; it is not carried`);
254
+ return [];
255
+ }
256
+ const out: TimeIntervalConfig[] = [];
257
+ raw.forEach((t, i) => {
258
+ if (!isPlainObject(t) || typeof t.name !== "string") {
259
+ warnings.push(`${at}[${i}] has no name; it is not carried`);
260
+ return;
261
+ }
262
+ out.push({ name: t.name, time_intervals: (Array.isArray(t.time_intervals) ? t.time_intervals : []) as TimeIntervalConfig["time_intervals"] });
263
+ });
264
+ return out;
265
+ }
266
+
267
+ function parseAlertmanager(doc: Record<string, unknown>, warnings: string[]): AlertmanagerConfig {
268
+ for (const k of Object.keys(doc)) {
269
+ if (!AM_SECTIONS.includes(k)) warnings.push(`top-level section "${k}" is not one chant declares; it is not carried`);
270
+ }
271
+ const config: AlertmanagerConfig = {};
272
+ if (isPlainObject(doc.global)) config.global = doc.global as AlertmanagerConfig["global"];
273
+ else if (doc.global !== undefined && doc.global !== null) warnings.push("global is not a mapping; it is not carried");
274
+ const templates = stringList(doc.templates, "templates", warnings);
275
+ if (templates) config.templates = templates;
276
+ if (doc.route !== undefined && doc.route !== null) config.route = parseRoute(doc.route, "route", warnings);
277
+ if (Array.isArray(doc.inhibit_rules)) {
278
+ config.inhibit_rules = doc.inhibit_rules
279
+ .map((r, i) => parseInhibitRule(r, `inhibit_rules[${i}]`, warnings))
280
+ .filter((r): r is InhibitRuleConfig => r !== undefined);
281
+ } else if (doc.inhibit_rules !== undefined && doc.inhibit_rules !== null) {
282
+ warnings.push("inhibit_rules is not a list; it is not carried");
283
+ }
284
+ if (Array.isArray(doc.receivers)) {
285
+ const receivers: ReceiverConfig[] = [];
286
+ doc.receivers.forEach((r, i) => {
287
+ if (!isPlainObject(r) || r.name === undefined || r.name === null) {
288
+ warnings.push(`receivers[${i}] has no name; it is not carried`);
289
+ return;
290
+ }
291
+ receivers.push({ ...r, name: String(r.name) } as ReceiverConfig);
292
+ });
293
+ config.receivers = receivers;
294
+ } else if (doc.receivers !== undefined && doc.receivers !== null) {
295
+ warnings.push("receivers is not a list; it is not carried");
296
+ }
297
+ const intervals = parseTimeIntervals(doc.time_intervals, "time_intervals", warnings);
298
+ const legacy = parseTimeIntervals(doc.mute_time_intervals, "mute_time_intervals", warnings);
299
+ if (legacy.length > 0) {
300
+ warnings.push(
301
+ `the top-level mute_time_intervals (${legacy.map((t) => t.name).join(", ")}) are declared as TimeIntervals and written under time_intervals, which Alertmanager reads the same way`,
302
+ );
303
+ }
304
+ if (intervals.length + legacy.length > 0) config.time_intervals = [...intervals, ...legacy];
305
+ if (isPlainObject(doc.tracing)) config.tracing = doc.tracing as AlertmanagerConfig["tracing"];
306
+ else if (doc.tracing !== undefined && doc.tracing !== null) warnings.push("tracing is not a mapping; it is not carried");
307
+ return config;
308
+ }
309
+
310
+ // ── entry points ────────────────────────────────────────────────────
311
+
312
+ /** Parse a rule file or `alertmanager.yml`, telling them apart by shape. */
313
+ export function parsePrometheusYaml(content: string): ParsedPrometheusFile {
314
+ const warnings: string[] = [];
315
+ const doc = content.trim() === "" ? {} : jsYaml.load(content, { schema: YAML_SCHEMA });
316
+ if (!isPlainObject(doc)) {
317
+ throw new Error("a Prometheus rule file or alertmanager.yml is a YAML mapping; this document is not one");
318
+ }
319
+ if (looksLikeRuleFile(doc)) return { kind: "rules", file: parseRuleFile(doc, warnings), warnings };
320
+ if (looksLikeAlertmanagerConfig(doc)) return { kind: "alertmanager", config: parseAlertmanager(doc, warnings), warnings };
321
+ if ("groups" in doc) {
322
+ // A rule file with a malformed group still imports; the bad parts are named.
323
+ return { kind: "rules", file: parseRuleFile(doc, warnings), warnings };
324
+ }
325
+ throw new Error(
326
+ "this YAML is neither a Prometheus rule file (groups: of named rule lists) nor an alertmanager.yml (route: or receivers:)",
327
+ );
328
+ }
329
+
330
+ /** The rule file and `alertmanager.yml` parser `chant import` runs. */
331
+ export class PrometheusParser implements TemplateParser {
332
+ parse(content: string): TemplateIR {
333
+ const parsed = parsePrometheusYaml(content);
334
+ const resource =
335
+ parsed.kind === "rules"
336
+ ? {
337
+ logicalId: "ruleFile",
338
+ type: RULE_FILE_RESOURCE_TYPE,
339
+ properties: { file: parsed.file } satisfies RuleFileResourceProperties as unknown as Record<string, unknown>,
340
+ }
341
+ : {
342
+ logicalId: "alertmanager",
343
+ type: ALERTMANAGER_RESOURCE_TYPE,
344
+ properties: { config: parsed.config } satisfies AlertmanagerResourceProperties as unknown as Record<string, unknown>,
345
+ };
346
+ return { resources: [resource], parameters: [], warnings: parsed.warnings };
347
+ }
348
+ }
@@ -0,0 +1,321 @@
1
+ /**
2
+ * Round trips through `chant import`.
3
+ *
4
+ * YAML -> TypeScript -> `chant build` -> YAML must give back the same file
5
+ * (key order, quoting and the order of groups, receivers and time intervals
6
+ * aside) for every fixture: each prometheus example's built rule file and
7
+ * alertmanager.yml, `Slo()` output, a rule file and an alertmanager.yml
8
+ * using every field and the deprecated spellings, and upstream samples from
9
+ * the Prometheus docs and Alertmanager's example configs at the pinned
10
+ * releases. The generated source must lint clean, apart from the literal
11
+ * credentials a fixture carries, which PROM001 must report.
12
+ *
13
+ * Where `promtool` / `amtool` are on PATH (or `PROMTOOL` / `AMTOOL` name
14
+ * them) the re-emitted files are also checked by `promtool check rules` and
15
+ * `amtool check-config`. generated-types.e2e.test.ts type-checks the
16
+ * generated source.
17
+ */
18
+
19
+ import { describe, expect, test } from "vitest";
20
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
21
+ import { join } from "path";
22
+ import { load } from "js-yaml";
23
+ import { build } from "@intentius/chant/build";
24
+ import { lintCommand } from "@intentius/chant/cli/commands/lint";
25
+ import { prometheusSerializer } from "../serializer";
26
+ import { amtoolCheckConfig, hasTool, promtoolCheckRules } from "../tools";
27
+ import type { AlertmanagerConfig, RouteConfig, RuleFileConfig } from "../model";
28
+ import { PrometheusParser } from "./parser";
29
+ import { PrometheusGenerator } from "./generator";
30
+ import { builtFiles, exampleOutputs, pkgDir, read, sloOutputs, UPSTREAM, type BuiltFile } from "./testdata/fixtures";
31
+
32
+ // ── helpers ─────────────────────────────────────────────────────────
33
+
34
+ const byName = (a: { name: string }, b: { name: string }) => a.name.localeCompare(b.name);
35
+
36
+ /** `match` / `match_re` maps as the matcher strings Alertmanager reads them as. */
37
+ function asMatchers(match: unknown, re: unknown): string[] {
38
+ const q = (v: unknown) => JSON.stringify(String(v));
39
+ return [
40
+ ...Object.entries((match ?? {}) as Record<string, unknown>).map(([k, v]) => `${k}=${q(v)}`),
41
+ ...Object.entries((re ?? {}) as Record<string, unknown>).map(([k, v]) => `${k}=~${q(v)}`),
42
+ ];
43
+ }
44
+
45
+ function normalizeRoute(r: RouteConfig & { match?: unknown; match_re?: unknown }): RouteConfig {
46
+ const { match, match_re, ...rest } = r;
47
+ const matchers = [...(rest.matchers ?? []), ...asMatchers(match, match_re)];
48
+ return { ...rest, ...(matchers.length > 0 ? { matchers } : {}), ...(rest.routes ? { routes: rest.routes.map(normalizeRoute) } : {}) };
49
+ }
50
+
51
+ /**
52
+ * A parsed rule file or alertmanager.yml with the differences Prometheus and
53
+ * Alertmanager do not see taken out: groups, receivers and time intervals
54
+ * sorted by name; deprecated `match*` maps and the top-level
55
+ * `mute_time_intervals` in their current spelling; label values as strings.
56
+ */
57
+ function normalize(yaml: string): unknown {
58
+ const doc = (load(yaml) ?? {}) as Record<string, unknown>;
59
+ if ("groups" in doc) {
60
+ const file = doc as unknown as RuleFileConfig;
61
+ const labels = (l?: Record<string, unknown>) => (l ? Object.fromEntries(Object.entries(l).map(([k, v]) => [k, String(v)])) : undefined);
62
+ return {
63
+ groups: [...file.groups].sort(byName).map((g) => ({
64
+ ...g,
65
+ ...(g.labels ? { labels: labels(g.labels) } : {}),
66
+ rules: g.rules.map((r) => ({
67
+ ...r,
68
+ ...(r.labels ? { labels: labels(r.labels) } : {}),
69
+ ...("annotations" in r && r.annotations ? { annotations: labels(r.annotations) } : {}),
70
+ })),
71
+ })),
72
+ };
73
+ }
74
+ const am = doc as AlertmanagerConfig & { mute_time_intervals?: AlertmanagerConfig["time_intervals"] };
75
+ const out: Record<string, unknown> = { ...am };
76
+ delete out.mute_time_intervals;
77
+ if (am.route) out.route = normalizeRoute(am.route);
78
+ if (am.inhibit_rules) {
79
+ out.inhibit_rules = am.inhibit_rules.map((r) => {
80
+ const { source_match, source_match_re, target_match, target_match_re, ...rest } = r as Record<string, unknown>;
81
+ const src = [...((rest.source_matchers as string[]) ?? []), ...asMatchers(source_match, source_match_re)];
82
+ const tgt = [...((rest.target_matchers as string[]) ?? []), ...asMatchers(target_match, target_match_re)];
83
+ return { ...rest, ...(src.length ? { source_matchers: src } : {}), ...(tgt.length ? { target_matchers: tgt } : {}) };
84
+ });
85
+ }
86
+ if (am.receivers) out.receivers = [...am.receivers].sort(byName);
87
+ const intervals = [...(am.time_intervals ?? []), ...(am.mute_time_intervals ?? [])].sort(byName);
88
+ if (intervals.length > 0) out.time_intervals = intervals;
89
+ return out;
90
+ }
91
+
92
+ interface Imported {
93
+ source: string;
94
+ files: string[];
95
+ warnings: string[];
96
+ rules?: string;
97
+ alertmanager?: string;
98
+ buildErrors: unknown[];
99
+ lint: { errorCount: number; warningCount: number; output: string };
100
+ }
101
+
102
+ /** A chant project dir inside the package, so the lexicon resolves as it does for a user. */
103
+ function projectDir(): string {
104
+ const dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-"));
105
+ mkdirSync(join(dir, "src"));
106
+ writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["prometheus"] };\n');
107
+ writeFileSync(join(dir, "package.json"), '{ "name": "prometheus-import-roundtrip", "private": true, "type": "module" }\n');
108
+ return dir;
109
+ }
110
+
111
+ /** YAML files -> IR -> TypeScript, all into one project -> `chant build` -> YAML, and `chant lint` over the source. */
112
+ async function importAndBuild(...yamls: string[]): Promise<Imported> {
113
+ const dir = projectDir();
114
+ try {
115
+ const srcDir = join(dir, "src");
116
+ const sources: string[] = [];
117
+ const files: string[] = [];
118
+ const warnings: string[] = [];
119
+ for (const yaml of yamls) {
120
+ const ir = new PrometheusParser().parse(yaml);
121
+ warnings.push(...(ir.warnings ?? []));
122
+ for (const file of new PrometheusGenerator().generate(ir)) {
123
+ writeFileSync(join(srcDir, file.path), file.content);
124
+ sources.push(`// ${file.path}\n${file.content}`);
125
+ files.push(file.path);
126
+ }
127
+ }
128
+ const result = await build(srcDir, [prometheusSerializer]);
129
+ const lint = await lintCommand({ path: srcDir, format: "stylish" });
130
+ return {
131
+ source: sources.join("\n"),
132
+ files,
133
+ warnings,
134
+ ...builtFiles(result.outputs.get("prometheus")),
135
+ buildErrors: result.errors,
136
+ lint: { errorCount: lint.errorCount, warningCount: lint.warningCount, output: lint.output },
137
+ };
138
+ } finally {
139
+ rmSync(dir, { recursive: true, force: true });
140
+ }
141
+ }
142
+
143
+ /** Every import must rebuild equal and lint clean; a fixture may name lint rules it expects to fire. */
144
+ async function expectRoundTrip(yaml: string, opts: { lintRules?: string[] } = {}): Promise<Imported> {
145
+ const out = await importAndBuild(yaml);
146
+ expect(out.buildErrors).toEqual([]);
147
+ const rebuilt = out.rules ?? out.alertmanager ?? "";
148
+ expect(normalize(rebuilt)).toEqual(normalize(yaml));
149
+ if (opts.lintRules) {
150
+ for (const rule of opts.lintRules) expect(out.lint.output).toContain(rule);
151
+ const ids = [...out.lint.output.matchAll(/\b([A-Z]{3,4}\d{3})\b/g)].map((m) => m[1]);
152
+ expect(ids.filter((id) => !opts.lintRules!.includes(id))).toEqual([]);
153
+ } else {
154
+ if (out.lint.errorCount + out.lint.warningCount > 0) console.log(out.lint.output);
155
+ expect(out.lint.errorCount).toBe(0);
156
+ expect(out.lint.warningCount).toBe(0);
157
+ }
158
+ return out;
159
+ }
160
+
161
+ const PROMTOOL = hasTool(process.env.PROMTOOL ?? "promtool");
162
+ const AMTOOL = hasTool(process.env.AMTOOL ?? "amtool");
163
+
164
+ const upstream = (file: string): BuiltFile => ({ name: file, yaml: read("upstream", file) });
165
+ const handWritten = (file: string): BuiltFile => ({ name: file, yaml: read(file) });
166
+
167
+ // ── YAML -> TypeScript -> YAML ──────────────────────────────────────
168
+
169
+ describe("YAML -> TypeScript -> YAML", () => {
170
+ test("the prometheus examples' built rule files and alertmanager.yml", async () => {
171
+ const outputs = await exampleOutputs();
172
+ expect(outputs.map((o) => o.name)).toEqual([
173
+ "alerting/rules.yml",
174
+ "alerting/alertmanager.yml",
175
+ "getting-started/rules.yml",
176
+ "k3d-stack/rules.yml",
177
+ "k3d-stack/alertmanager.yml",
178
+ "rules-from-data/rules.yml",
179
+ "slo/rules.yml",
180
+ "slo/alertmanager.yml",
181
+ ]);
182
+ for (const { name, yaml } of outputs) {
183
+ const out = await expectRoundTrip(yaml);
184
+ // Import is a fixed point from the built file: the same YAML, text for text.
185
+ expect(out.rules ?? out.alertmanager, name).toBe(yaml);
186
+ expect(out.warnings, name).toEqual([]);
187
+ }
188
+ });
189
+
190
+ test("the alerting example's two files, imported into one project, build back to both", async () => {
191
+ const [rules, am] = (await exampleOutputs()).filter((o) => o.name.startsWith("alerting/"));
192
+ const out = await importAndBuild(rules.yaml, am.yaml);
193
+ expect(out.buildErrors).toEqual([]);
194
+ expect(out.rules).toBe(rules.yaml);
195
+ expect(out.alertmanager).toBe(am.yaml);
196
+ expect(out.lint.errorCount + out.lint.warningCount, out.lint.output).toBe(0);
197
+ expect(out.files).toEqual(["rules.ts", "receivers.ts", "time-intervals.ts", "routes.ts", "inhibit-rules.ts", "settings.ts"]);
198
+ // Routes name receivers and time intervals by variable.
199
+ expect(out.source).toContain('import { defaultReceiver, paymentsOncall, paymentsTickets } from "./receivers";');
200
+ expect(out.source).toContain('import { outsideOfficeHours } from "./time-intervals";');
201
+ expect(out.source).toContain("receiver: paymentsOncall");
202
+ expect(out.source).toContain("mute_time_intervals: [outsideOfficeHours]");
203
+ // *_file credentials are kept as written.
204
+ expect(out.source).toContain('routing_key_file: "/etc/alertmanager/secrets/pagerduty-key"');
205
+ expect(out.source).toContain('smtp_auth_password_file: "/etc/alertmanager/secrets/smtp-password"');
206
+ });
207
+
208
+ test("Slo() output comes back as the Slo declaration", async () => {
209
+ for (const { name, yaml } of sloOutputs()) {
210
+ const out = await expectRoundTrip(yaml);
211
+ expect(out.rules, name).toBe(yaml);
212
+ expect(out.files, name).toEqual(["slos.ts"]);
213
+ expect(out.source, name).toContain(" = Slo({");
214
+ expect(out.source, name).toContain("[{{window}}]");
215
+ expect(out.source, name).not.toContain("new RuleGroup(");
216
+ }
217
+ });
218
+
219
+ test("the slo example's rule file is two Slo declarations with their props", async () => {
220
+ const slo = (await exampleOutputs()).find((o) => o.name === "slo/rules.yml")!;
221
+ const out = await expectRoundTrip(slo.yaml);
222
+ expect(out.source).toContain("const orderAcknowledged = Slo({");
223
+ expect(out.source).toContain('description: "Orders are acknowledged without an error span."');
224
+ expect(out.source).toContain('annotations: { runbook_url: "https://runbooks.example.com/order-ack" }');
225
+ expect(out.source).toContain("export { checkout, orderAcknowledged };");
226
+ });
227
+
228
+ test("a group an Slo built, then edited by hand, stays a RuleGroup", async () => {
229
+ const [first] = sloOutputs();
230
+ const edited = first.yaml.replace("vector(0.999)", "vector(0.998)");
231
+ const out = await expectRoundTrip(edited);
232
+ expect(out.source).toContain("new RuleGroup(");
233
+ expect(out.source).not.toContain("Slo(");
234
+ });
235
+
236
+ test("a rule file using every field", async () => {
237
+ const out = await expectRoundTrip(read("rules-full.yml"));
238
+ expect(out.source).toContain("const nodeLabels: LabelSet = { team: \"platform\", tier: \"1\" };");
239
+ expect(out.source).toContain('query_offset: "30s"');
240
+ expect(out.source).toContain("limit: 100");
241
+ expect(out.source).toContain('keep_firing_for: "5m"');
242
+ // A multi-line expression is a template literal; the PromQL raw string keeps its backticks.
243
+ expect(out.source).toContain("expr: `1 - avg without (cpu, mode) (\n");
244
+ expect(out.source).toContain('"$1", "instance", `(.*):.*`)\'');
245
+ // Go templates are kept as written.
246
+ expect(out.source).toContain('summary: "{{ $labels.instance }} CPU above 90%"');
247
+ expect(out.warnings).toEqual([]);
248
+ });
249
+
250
+ test("an alertmanager.yml using every section and the deprecated spellings", async () => {
251
+ const out = await expectRoundTrip(read("alertmanager-full.yml"), { lintRules: ["PROM001"] });
252
+ // The one literal credential is imported as found, and PROM001 reports it.
253
+ expect(out.source).toContain('auth_password: "hunter2"');
254
+ expect(out.lint.output.match(/PROM001/g)).toHaveLength(1);
255
+ // *_file fields and Go templates are kept as written.
256
+ expect(out.source).toContain('url_file: "/etc/alertmanager/secrets/default-webhook-url"');
257
+ expect(out.source).toContain("description: '{{ template \"pagerduty.default.description\" . }}'");
258
+ expect(out.source).toContain('summary: "{{ .GroupLabels.alertname }} in {{ .GroupLabels.cluster }}"');
259
+ // Integrations and global fields the lexicon does not type are carried as data, spread in.
260
+ expect(out.source).toContain("// Receiver \"oncall\": opsgenie_configs is not typed by this lexicon");
261
+ expect(out.source).toContain("...oncallUntyped,");
262
+ expect(out.source).toContain("...globalUntyped,");
263
+ // Deprecated spellings are rewritten, and each rewrite is named.
264
+ expect(out.source).toContain(`matchers: ['severity="page"', 'service=~"^(api|web)$"']`);
265
+ expect(out.source).toContain(`source_matchers: ['severity="page"']`);
266
+ expect(out.source).toContain("mute_time_intervals: [weekends]");
267
+ expect(out.source).toContain("active_time_intervals: [businessHours]");
268
+ expect(out.warnings).toHaveLength(4);
269
+ expect(out.warnings.join("\n")).toContain("match/match_re are written as matchers");
270
+ expect(out.warnings.join("\n")).toContain("top-level mute_time_intervals (weekends)");
271
+ });
272
+
273
+ for (const file of UPSTREAM) {
274
+ test(`upstream sample: ${file}`, async () => {
275
+ const yaml = read("upstream", file);
276
+ // The Alertmanager example writes placeholder secrets inline, which PROM001 reports.
277
+ const out = await expectRoundTrip(yaml, file === "alertmanager-simple.yml" ? { lintRules: ["PROM001"] } : {});
278
+ expect(out.warnings).toEqual([]);
279
+ if (file === "alertmanager-simple.yml") {
280
+ expect(out.source).toContain('service_key: "<team-X-key>"');
281
+ expect(out.source).toContain("const tracing: AlertmanagerTracingConfig = {");
282
+ expect(out.lint.output.match(/PROM001/g)).toHaveLength(4);
283
+ }
284
+ if (file === "alertmanager-route-labels.yml") {
285
+ expect(out.source).toContain('reason: "database {{ .GroupLabels.database }}"');
286
+ }
287
+ });
288
+ }
289
+ });
290
+
291
+ // ── promtool and amtool ─────────────────────────────────────────────
292
+
293
+ describe("upstream tools accept the re-emitted files", () => {
294
+ const rulesFixtures = async (): Promise<BuiltFile[]> => [
295
+ ...(await exampleOutputs()).filter((o) => o.name.endsWith("rules.yml")),
296
+ ...sloOutputs(),
297
+ handWritten("rules-full.yml"),
298
+ ...UPSTREAM.filter((f) => f.startsWith("prometheus-")).map(upstream),
299
+ ];
300
+ const amFixtures = async (): Promise<BuiltFile[]> => [
301
+ ...(await exampleOutputs()).filter((o) => o.name.endsWith("alertmanager.yml")),
302
+ handWritten("alertmanager-full.yml"),
303
+ ...UPSTREAM.filter((f) => f.startsWith("alertmanager-")).map(upstream),
304
+ ];
305
+
306
+ test.skipIf(!PROMTOOL)("promtool check rules", async () => {
307
+ for (const { name, yaml } of await rulesFixtures()) {
308
+ const { rules } = await importAndBuild(yaml);
309
+ const r = promtoolCheckRules(rules ?? "");
310
+ expect(r.ok, `${name}: ${r.output}`).toBe(true);
311
+ }
312
+ }, 60_000);
313
+
314
+ test.skipIf(!AMTOOL)("amtool check-config", async () => {
315
+ for (const { name, yaml } of await amFixtures()) {
316
+ const { alertmanager } = await importAndBuild(yaml);
317
+ const r = amtoolCheckConfig(alertmanager ?? "");
318
+ expect(r.ok, `${name}: ${r.output}`).toBe(true);
319
+ }
320
+ }, 60_000);
321
+ });