@intentius/chant-lexicon-prometheus 0.97.0 → 0.99.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 (69) hide show
  1. package/README.md +6 -1
  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/embedded.d.ts +14 -0
  7. package/dist/import/embedded.d.ts.map +1 -0
  8. package/dist/import/generator.d.ts +50 -0
  9. package/dist/import/generator.d.ts.map +1 -0
  10. package/dist/import/parser.d.ts +52 -0
  11. package/dist/import/parser.d.ts.map +1 -0
  12. package/dist/import/slo.d.ts +15 -0
  13. package/dist/import/slo.d.ts.map +1 -0
  14. package/dist/integrations.d.ts +531 -0
  15. package/dist/integrations.d.ts.map +1 -0
  16. package/dist/integrity.json +7 -7
  17. package/dist/lint/rules/literal-credential.d.ts.map +1 -1
  18. package/dist/lint/rules/prom-ast.d.ts +8 -0
  19. package/dist/lint/rules/prom-ast.d.ts.map +1 -1
  20. package/dist/lint/rules/promql-literal.d.ts.map +1 -1
  21. package/dist/manifest.json +1 -1
  22. package/dist/model.d.ts +77 -121
  23. package/dist/model.d.ts.map +1 -1
  24. package/dist/plugin.d.ts.map +1 -1
  25. package/dist/rules/literal-credential.ts +52 -15
  26. package/dist/rules/prom-ast.ts +36 -0
  27. package/dist/rules/promql-literal.ts +15 -1
  28. package/dist/skills/chant-prometheus-alertmanager.md +4 -0
  29. package/dist/skills/chant-prometheus.md +11 -0
  30. package/package.json +2 -2
  31. package/src/alertmanager.ts +4 -1
  32. package/src/build.ts +2 -0
  33. package/src/codegen/docs.ts +5 -1
  34. package/src/import/cli.test.ts +98 -0
  35. package/src/import/embedded.test.ts +56 -0
  36. package/src/import/embedded.ts +44 -0
  37. package/src/import/generated-types.e2e.test.ts +77 -0
  38. package/src/import/generator.test.ts +138 -0
  39. package/src/import/generator.ts +578 -0
  40. package/src/import/parser.test.ts +133 -0
  41. package/src/import/parser.ts +365 -0
  42. package/src/import/roundtrip.test.ts +358 -0
  43. package/src/import/slo.ts +175 -0
  44. package/src/import/testdata/alertmanager-full.yml +89 -0
  45. package/src/import/testdata/alertmanager-integrations.yml +215 -0
  46. package/src/import/testdata/fixtures.ts +122 -0
  47. package/src/import/testdata/rules-full.yml +42 -0
  48. package/src/import/testdata/upstream/alertmanager-conf-good.yml +120 -0
  49. package/src/import/testdata/upstream/alertmanager-mattermost-default-webhook-url-file.yml +16 -0
  50. package/src/import/testdata/upstream/alertmanager-opsgenie-default-apikey-file.yml +27 -0
  51. package/src/import/testdata/upstream/alertmanager-rocketchat-default-token-file.yml +25 -0
  52. package/src/import/testdata/upstream/alertmanager-route-labels.yml +65 -0
  53. package/src/import/testdata/upstream/alertmanager-simple.yml +130 -0
  54. package/src/import/testdata/upstream/alertmanager-sns-topic-arn.yml +17 -0
  55. package/src/import/testdata/upstream/alertmanager-telegram-default-bot-token-file.yml +16 -0
  56. package/src/import/testdata/upstream/alertmanager-victorops-default-apikey-file.yml +23 -0
  57. package/src/import/testdata/upstream/alertmanager-wechat-default-api-secret-file.yml +18 -0
  58. package/src/import/testdata/upstream/prometheus-alerting-rules.yml +15 -0
  59. package/src/import/testdata/upstream/prometheus-alerting-templates.yml +23 -0
  60. package/src/import/testdata/upstream/prometheus-recording-rules.yml +7 -0
  61. package/src/integrations.ts +538 -0
  62. package/src/lint/rules/literal-credential.ts +52 -15
  63. package/src/lint/rules/prom-ast.ts +36 -0
  64. package/src/lint/rules/promql-literal.ts +15 -1
  65. package/src/lint/rules/rules.test.ts +58 -0
  66. package/src/model.ts +157 -109
  67. package/src/plugin.ts +15 -0
  68. package/src/skills/chant-prometheus-alertmanager.md +4 -0
  69. package/src/skills/chant-prometheus.md +11 -0
@@ -0,0 +1,358 @@
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 { RECEIVER_INTEGRATION_TYPES, type AlertmanagerConfig, type RouteConfig, type 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
+ // Every integration and global field is typed.
260
+ expect(out.source).toContain("const oncallOpsgenie: OpsGenieConfig[] = [");
261
+ expect(out.source).toContain("const dbChatMsteams: MSTeamsConfig[] = [");
262
+ expect(out.source).toContain('opsgenie_api_key_file: "/etc/alertmanager/secrets/opsgenie-key"');
263
+ expect(out.source).not.toContain("Untyped");
264
+ // Deprecated spellings are rewritten, and each rewrite is named.
265
+ expect(out.source).toContain(`matchers: ['severity="page"', 'service=~"^(api|web)$"']`);
266
+ expect(out.source).toContain(`source_matchers: ['severity="page"']`);
267
+ expect(out.source).toContain("mute_time_intervals: [weekends]");
268
+ expect(out.source).toContain("active_time_intervals: [businessHours]");
269
+ expect(out.warnings).toHaveLength(4);
270
+ expect(out.warnings.join("\n")).toContain("match/match_re are written as matchers");
271
+ expect(out.warnings.join("\n")).toContain("top-level mute_time_intervals (weekends)");
272
+ });
273
+
274
+ /**
275
+ * What each upstream sample is expected to raise: literal credentials
276
+ * (Alertmanager's own examples write placeholder secrets inline), which
277
+ * PROM001 reports, and deprecated `match` spellings, which import warns
278
+ * about. A sample not listed raises nothing.
279
+ */
280
+ const UPSTREAM_EXPECT: Record<string, { prom001?: number; warnings?: number }> = {
281
+ "alertmanager-simple.yml": { prom001: 4 },
282
+ // smtp_auth_password, slack_api_url, three routing_keys, the VictorOps and Opsgenie api_keys, Pushover's token and user_key.
283
+ "alertmanager-conf-good.yml": { prom001: 9, warnings: 9 },
284
+ "alertmanager-opsgenie-default-apikey-file.yml": { warnings: 1 },
285
+ "alertmanager-rocketchat-default-token-file.yml": { warnings: 1 },
286
+ "alertmanager-victorops-default-apikey-file.yml": { warnings: 1 },
287
+ "alertmanager-wechat-default-api-secret-file.yml": { prom001: 1 },
288
+ };
289
+
290
+ test("an alertmanager.yml using every receiver integration", async () => {
291
+ const out = await expectRoundTrip(read("alertmanager-integrations.yml"));
292
+ expect(out.warnings).toEqual([]);
293
+ expect(out.source).not.toContain("Untyped");
294
+ for (const type of Object.values(RECEIVER_INTEGRATION_TYPES)) expect(out.source).toMatch(new RegExp(`: ${type}\\[\\] = `));
295
+ // Fields the common four integrations gained, and structured values, keep their types.
296
+ expect(out.source).toContain('message_text: "{{ .CommonLabels.alertname }}"');
297
+ expect(out.source).toContain('threading: { enabled: true, thread_by_date: "daily" }');
298
+ expect(out.source).toContain("chat_id: -1001234567890");
299
+ expect(out.source).toContain('description: { template: \'{{ template "jira.default.description" . }}\', enable_update: false }');
300
+ expect(out.source).toContain('http_headers: {');
301
+ });
302
+
303
+ for (const file of UPSTREAM) {
304
+ test(`upstream sample: ${file}`, async () => {
305
+ const yaml = read("upstream", file);
306
+ const expected = UPSTREAM_EXPECT[file] ?? {};
307
+ const out = await expectRoundTrip(yaml, expected.prom001 ? { lintRules: ["PROM001"] } : {});
308
+ expect(out.lint.output.match(/PROM001/g) ?? []).toHaveLength(expected.prom001 ?? 0);
309
+ expect(out.warnings).toHaveLength(expected.warnings ?? 0);
310
+ for (const w of out.warnings) expect(w).toMatch(/match(_re)? are written as/);
311
+ // Every integration and global field is typed: nothing is carried as untyped data.
312
+ expect(out.source).not.toContain("Untyped");
313
+ if (file === "alertmanager-simple.yml") {
314
+ expect(out.source).toContain('service_key: "<team-X-key>"');
315
+ expect(out.source).toContain("const tracing: AlertmanagerTracingConfig = {");
316
+ }
317
+ if (file === "alertmanager-route-labels.yml") {
318
+ expect(out.source).toContain('reason: "database {{ .GroupLabels.database }}"');
319
+ }
320
+ if (file === "alertmanager-sns-topic-arn.yml") {
321
+ expect(out.source).toContain("const snsApiNotificationsSns: SNSConfig[] = [");
322
+ }
323
+ });
324
+ }
325
+ });
326
+
327
+ // ── promtool and amtool ─────────────────────────────────────────────
328
+
329
+ describe("upstream tools accept the re-emitted files", () => {
330
+ const rulesFixtures = async (): Promise<BuiltFile[]> => [
331
+ ...(await exampleOutputs()).filter((o) => o.name.endsWith("rules.yml")),
332
+ ...sloOutputs(),
333
+ handWritten("rules-full.yml"),
334
+ ...UPSTREAM.filter((f) => f.startsWith("prometheus-")).map(upstream),
335
+ ];
336
+ const amFixtures = async (): Promise<BuiltFile[]> => [
337
+ ...(await exampleOutputs()).filter((o) => o.name.endsWith("alertmanager.yml")),
338
+ handWritten("alertmanager-full.yml"),
339
+ handWritten("alertmanager-integrations.yml"),
340
+ ...UPSTREAM.filter((f) => f.startsWith("alertmanager-")).map(upstream),
341
+ ];
342
+
343
+ test.skipIf(!PROMTOOL)("promtool check rules", async () => {
344
+ for (const { name, yaml } of await rulesFixtures()) {
345
+ const { rules } = await importAndBuild(yaml);
346
+ const r = promtoolCheckRules(rules ?? "");
347
+ expect(r.ok, `${name}: ${r.output}`).toBe(true);
348
+ }
349
+ }, 60_000);
350
+
351
+ test.skipIf(!AMTOOL)("amtool check-config", async () => {
352
+ for (const { name, yaml } of await amFixtures()) {
353
+ const { alertmanager } = await importAndBuild(yaml);
354
+ const r = amtoolCheckConfig(alertmanager ?? "");
355
+ expect(r.ok, `${name}: ${r.output}`).toBe(true);
356
+ }
357
+ }, 60_000);
358
+ });
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Recognise a rule group that `Slo()` built, so the importer can write the
3
+ * `Slo` declaration instead of its forty-odd lines of rules.
4
+ *
5
+ * The group's name, recorded series, objective and burn-rate alerts give
6
+ * back candidate props; the candidate is accepted only when `Slo()` builds
7
+ * it to the same group, rule for rule. Anything else (a hand-edited
8
+ * threshold, an extra rule, a different description) fails that check and
9
+ * the group is imported as a plain `RuleGroup`.
10
+ */
11
+
12
+ import { Slo, DEFAULT_BURN_RATES, SLO_WINDOW_PLACEHOLDER, type SloAlertTier, type SloProps, type BurnRateWindow } from "../composites/slo";
13
+ import { ruleGroupConfig } from "../rules";
14
+ import { durationMs } from "../duration";
15
+ import { isAlertingRuleConfig, isRecordingRuleConfig, type AlertingRuleConfig, type LabelSet, type RuleGroupConfig } from "../model";
16
+
17
+ const RATIO_PREFIX = "slo:sli_error:ratio_rate";
18
+ const OBJECTIVE = "slo:objective:ratio";
19
+ const BUDGET = "slo:error_budget:remaining";
20
+ const DEFAULT_ALERT_NAME = "ErrorBudgetBurn";
21
+ const PAIR_LABELS = new Set(["slo", "severity", "long_window", "short_window"]);
22
+ const DESCRIPTION_TAIL = "The error ratio over both the last ";
23
+
24
+ /** Objects with their keys sorted, so two configs compare equal whatever order their keys were written in. */
25
+ function canonical(v: unknown): unknown {
26
+ if (Array.isArray(v)) return v.map(canonical);
27
+ if (typeof v === "object" && v !== null) {
28
+ return Object.fromEntries(
29
+ Object.keys(v)
30
+ .sort()
31
+ .map((k) => [k, canonical((v as Record<string, unknown>)[k])]),
32
+ );
33
+ }
34
+ return v;
35
+ }
36
+
37
+ function same(a: unknown, b: unknown): boolean {
38
+ return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
39
+ }
40
+
41
+ /** The SLI expressions a recorded error ratio was built from, with the window put back as `{{window}}`. */
42
+ function sliFrom(expr: string, window: string): SloProps["sli"] | undefined {
43
+ const unwindow = (s: string) => s.split(`[${window}]`).join(`[${SLO_WINDOW_PLACEHOLDER}]`);
44
+ const goodHead = "1 - (\n (";
45
+ const goodTail = ")\n)";
46
+ if (expr.startsWith(goodHead) && expr.endsWith(goodTail)) {
47
+ const inner = expr.slice(goodHead.length, -goodTail.length);
48
+ const at = inner.indexOf(")\n /\n (");
49
+ if (at === -1) return undefined;
50
+ return { good: unwindow(inner.slice(0, at)), total: unwindow(inner.slice(at + ")\n /\n (".length)) };
51
+ }
52
+ if (expr.startsWith("(") && expr.endsWith(")")) {
53
+ const inner = expr.slice(1, -1);
54
+ const at = inner.indexOf(")\n/\n(");
55
+ if (at === -1) return undefined;
56
+ return { errors: unwindow(inner.slice(0, at)), total: unwindow(inner.slice(at + ")\n/\n(".length)) };
57
+ }
58
+ return undefined;
59
+ }
60
+
61
+ function extras(set: LabelSet | undefined, skip: (k: string) => boolean): LabelSet | undefined {
62
+ const out: LabelSet = {};
63
+ for (const [k, v] of Object.entries(set ?? {})) if (!skip(k)) out[k] = v;
64
+ return Object.keys(out).length > 0 ? out : undefined;
65
+ }
66
+
67
+ function isDefaultPairs(tier: "page" | "ticket", pairs: BurnRateWindow[], factors: number[], windowMs: (w: string) => number, sloWindow: string): boolean {
68
+ const defaults = DEFAULT_BURN_RATES[tier];
69
+ if (defaults.length !== pairs.length) return false;
70
+ return defaults.every((d, i) => {
71
+ const p = pairs[i];
72
+ const factor = Number(((d.budgetConsumed! * windowMs(sloWindow)) / windowMs(d.long)).toPrecision(6));
73
+ return p.long === d.long && p.short === d.short && factor === factors[i];
74
+ });
75
+ }
76
+
77
+ /** The `Slo` props that build exactly `group`, or `undefined` when no `Slo()` call does. */
78
+ export function recognizeSlo(group: RuleGroupConfig): SloProps | undefined {
79
+ if (!group.name.startsWith("slo-") || group.query_offset !== undefined || group.limit !== undefined) return undefined;
80
+ const name = group.name.slice("slo-".length);
81
+ const records = group.rules.filter(isRecordingRuleConfig);
82
+ const alerts = group.rules.filter(isAlertingRuleConfig) as AlertingRuleConfig[];
83
+ if (records.length + alerts.length !== group.rules.length) return undefined;
84
+
85
+ const objectiveAt = records.findIndex((r) => r.record === OBJECTIVE);
86
+ if (objectiveAt < 1 || records[objectiveAt + 1]?.record !== BUDGET) return undefined;
87
+ const objective = Number(/^vector\((.+)\)$/.exec(records[objectiveAt].expr)?.[1]);
88
+ if (!Number.isFinite(objective)) return undefined;
89
+ const windowRecord = records[objectiveAt - 1].record;
90
+ if (!windowRecord.startsWith(RATIO_PREFIX)) return undefined;
91
+ const window = windowRecord.slice(RATIO_PREFIX.length);
92
+
93
+ // Candidate SLIs: from each alert window's ratio, or from the whole-window
94
+ // ratio when there are no alerts.
95
+ const ratioRecords = records.slice(0, objectiveAt - 1);
96
+ const sources = ratioRecords.length > 0 ? ratioRecords : [records[objectiveAt - 1]];
97
+ const slis = sources
98
+ .map((r) => (r.record.startsWith(RATIO_PREFIX) ? sliFrom(r.expr, r.record.slice(RATIO_PREFIX.length)) : undefined))
99
+ .filter((s): s is SloProps["sli"] => s !== undefined);
100
+ if (slis.length === 0) return undefined;
101
+
102
+ // Alert tiers: `page` and `ticket` by severity, or custom severities in the order they appear.
103
+ const alertName = alerts[0]?.alert ?? DEFAULT_ALERT_NAME;
104
+ const bySeverity = new Map<string, AlertingRuleConfig[]>();
105
+ for (const a of alerts) {
106
+ const severity = a.labels?.severity;
107
+ if (severity === undefined || a.alert !== alertName) return undefined;
108
+ bySeverity.set(severity, [...(bySeverity.get(severity) ?? []), a]);
109
+ }
110
+ if (bySeverity.size > 2) return undefined;
111
+ const severities = [...bySeverity.keys()];
112
+ const tierOf = new Map<string, "page" | "ticket">();
113
+ for (const s of severities) if (s === "page" || s === "ticket") tierOf.set(s, s);
114
+ for (const s of severities) {
115
+ if (tierOf.has(s)) continue;
116
+ const free = (["page", "ticket"] as const).find((t) => ![...tierOf.values()].includes(t));
117
+ if (!free) return undefined;
118
+ tierOf.set(s, free);
119
+ }
120
+
121
+ let description: string | undefined;
122
+ const alerting: NonNullable<SloProps["alerting"]> = {};
123
+ for (const tier of ["page", "ticket"] as const) {
124
+ const severity = [...tierOf].find(([, t]) => t === tier)?.[0];
125
+ if (severity === undefined) {
126
+ alerting[tier] = false;
127
+ continue;
128
+ }
129
+ const tierAlerts = bySeverity.get(severity)!;
130
+ const first = tierAlerts[0];
131
+ const pairs: BurnRateWindow[] = [];
132
+ const factors: number[] = [];
133
+ for (const a of tierAlerts) {
134
+ const long = a.labels?.long_window;
135
+ const short = a.labels?.short_window;
136
+ const factor = Number(/> \(([^ ]+) \* /.exec(a.expr)?.[1]);
137
+ if (long === undefined || short === undefined || !Number.isFinite(factor)) return undefined;
138
+ pairs.push({ long, short, factor });
139
+ factors.push(factor);
140
+ }
141
+ const t: SloAlertTier = {};
142
+ t.burnRates = isDefaultPairs(tier, pairs, factors, (w) => durationMs(w) ?? 0, window) ? "default" : pairs;
143
+ if (severity !== tier) t.severity = severity;
144
+ if (first.for !== undefined) t.for = first.for;
145
+ const labels = extras(first.labels, (k) => PAIR_LABELS.has(k));
146
+ if (labels) t.labels = labels;
147
+ const annotations = extras(first.annotations, (k) => k === "summary" || k === "description");
148
+ if (annotations) t.annotations = annotations;
149
+ const desc = first.annotations?.description ?? "";
150
+ const at = desc.indexOf(DESCRIPTION_TAIL);
151
+ if (at > 0 && description === undefined) description = desc.slice(0, at).trimEnd();
152
+ const onlyDefault = t.burnRates === "default" && Object.keys(t).length === 1;
153
+ if (!onlyDefault) alerting[tier] = t;
154
+ }
155
+ if (alertName !== DEFAULT_ALERT_NAME) alerting.alertName = alertName;
156
+
157
+ for (const sli of slis) {
158
+ const props: SloProps = {
159
+ name,
160
+ objective,
161
+ window,
162
+ ...(description ? { description } : {}),
163
+ sli,
164
+ ...(Object.keys(alerting).length > 0 ? { alerting } : {}),
165
+ ...(group.labels ? { labels: group.labels } : {}),
166
+ ...(group.interval !== undefined ? { interval: group.interval } : {}),
167
+ };
168
+ try {
169
+ if (same(ruleGroupConfig(Slo(props).rules), ruleGroupConfig(group))) return props;
170
+ } catch {
171
+ // Not buildable from these props; try the next candidate.
172
+ }
173
+ }
174
+ return undefined;
175
+ }
@@ -0,0 +1,89 @@
1
+ # Deprecated spellings (match, match_re, source_match, the top-level
2
+ # mute_time_intervals), integrations beside the common four (opsgenie,
3
+ # msteams) and their global fields, *_file credentials, Go templates, a YAML
4
+ # anchor with a merge key, and one literal credential.
5
+ global:
6
+ resolve_timeout: 5m
7
+ smtp_smarthost: smtp.example.com:587
8
+ smtp_from: alertmanager@example.com
9
+ smtp_auth_username: alertmanager
10
+ smtp_auth_password_file: /etc/alertmanager/secrets/smtp-password
11
+ slack_api_url_file: /etc/alertmanager/secrets/slack-url
12
+ opsgenie_api_key_file: /etc/alertmanager/secrets/opsgenie-key
13
+ http_config:
14
+ follow_redirects: true
15
+ templates:
16
+ - /etc/alertmanager/templates/*.tmpl
17
+ route:
18
+ receiver: default
19
+ group_by: [alertname, cluster]
20
+ group_wait: 30s
21
+ labels:
22
+ summary: '{{ .GroupLabels.alertname }} in {{ .GroupLabels.cluster }}'
23
+ routes:
24
+ - match:
25
+ severity: page
26
+ match_re:
27
+ service: ^(api|web)$
28
+ receiver: oncall
29
+ continue: true
30
+ - matchers: ['team="db"']
31
+ receiver: db-chat
32
+ mute_time_intervals: [weekends]
33
+ active_time_intervals: [business-hours]
34
+ - match:
35
+ alertname: Watchdog
36
+ receiver: heartbeat
37
+ repeat_interval: 1m
38
+ inhibit_rules:
39
+ - name: page-mutes-ticket
40
+ source_match:
41
+ severity: page
42
+ target_matchers: ['severity="ticket"']
43
+ equal: [alertname, cluster]
44
+ receivers:
45
+ - name: default
46
+ webhook_configs:
47
+ - url_file: /etc/alertmanager/secrets/default-webhook-url
48
+ max_alerts: 10
49
+ - name: oncall
50
+ pagerduty_configs:
51
+ - routing_key_file: /etc/alertmanager/secrets/pagerduty-key
52
+ description: '{{ template "pagerduty.default.description" . }}'
53
+ details:
54
+ firing: '{{ .Alerts.Firing | len }}'
55
+ opsgenie_configs:
56
+ - api_key_file: /etc/alertmanager/secrets/opsgenie-key
57
+ priority: P1
58
+ - name: db-chat
59
+ slack_configs:
60
+ - &slack
61
+ channel: '#db-alerts'
62
+ title: '{{ .CommonLabels.alertname }}'
63
+ text: >-
64
+ {{ range .Alerts }}{{ .Annotations.summary }}
65
+ {{ end }}
66
+ send_resolved: true
67
+ - <<: *slack
68
+ channel: '#db-oncall'
69
+ msteams_configs:
70
+ - webhook_url_file: /etc/alertmanager/secrets/teams-url
71
+ - name: heartbeat
72
+ email_configs:
73
+ - to: heartbeat@example.com
74
+ auth_username: heartbeat
75
+ auth_password: hunter2
76
+ headers:
77
+ Subject: '[heartbeat] {{ .CommonLabels.alertname }}'
78
+ time_intervals:
79
+ - name: business-hours
80
+ time_intervals:
81
+ - weekdays: ['monday:friday']
82
+ times:
83
+ - start_time: '09:00'
84
+ end_time: '17:00'
85
+ location: Europe/Berlin
86
+ mute_time_intervals:
87
+ - name: weekends
88
+ time_intervals:
89
+ - weekdays: [saturday, sunday]