@intentius/chant-lexicon-prometheus 0.106.4 → 0.108.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 (111) hide show
  1. package/README.md +6 -1
  2. package/dist/api/alertmanager.d.ts +56 -0
  3. package/dist/api/alertmanager.d.ts.map +1 -0
  4. package/dist/api/apply.d.ts +126 -0
  5. package/dist/api/apply.d.ts.map +1 -0
  6. package/dist/api/bind.d.ts +49 -0
  7. package/dist/api/bind.d.ts.map +1 -0
  8. package/dist/api/client.d.ts +76 -0
  9. package/dist/api/client.d.ts.map +1 -0
  10. package/dist/api/evaluated.d.ts +36 -0
  11. package/dist/api/evaluated.d.ts.map +1 -0
  12. package/dist/api/fake-servers.d.ts +74 -0
  13. package/dist/api/fake-servers.d.ts.map +1 -0
  14. package/dist/api/index.d.ts +18 -0
  15. package/dist/api/index.d.ts.map +1 -0
  16. package/dist/api/ruler.d.ts +87 -0
  17. package/dist/api/ruler.d.ts.map +1 -0
  18. package/dist/composites/alert-routing.d.ts +104 -0
  19. package/dist/composites/alert-routing.d.ts.map +1 -0
  20. package/dist/composites/catalog.d.ts.map +1 -1
  21. package/dist/composites/index.d.ts +6 -0
  22. package/dist/composites/index.d.ts.map +1 -1
  23. package/dist/composites/red-alerts.d.ts +96 -0
  24. package/dist/composites/red-alerts.d.ts.map +1 -0
  25. package/dist/composites/watchdog.d.ts +69 -0
  26. package/dist/composites/watchdog.d.ts.map +1 -0
  27. package/dist/config.d.ts +273 -0
  28. package/dist/config.d.ts.map +1 -0
  29. package/dist/describe-resources.d.ts +48 -0
  30. package/dist/describe-resources.d.ts.map +1 -0
  31. package/dist/export-resources.d.ts +41 -0
  32. package/dist/export-resources.d.ts.map +1 -0
  33. package/dist/import/alertmanager-live.d.ts +35 -0
  34. package/dist/import/alertmanager-live.d.ts.map +1 -0
  35. package/dist/import/parser.d.ts +12 -0
  36. package/dist/import/parser.d.ts.map +1 -1
  37. package/dist/index.d.ts +6 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/integrity.json +3 -3
  40. package/dist/lint/post-synth/prom-helpers.d.ts +11 -3
  41. package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
  42. package/dist/manifest.json +1 -1
  43. package/dist/op/activities/index.d.ts +25 -0
  44. package/dist/op/activities/index.d.ts.map +1 -0
  45. package/dist/op/activities/promtool.d.ts +78 -0
  46. package/dist/op/activities/promtool.d.ts.map +1 -0
  47. package/dist/op/activities/rule-audit.d.ts +65 -0
  48. package/dist/op/activities/rule-audit.d.ts.map +1 -0
  49. package/dist/op/activities/rules-loaded.d.ts +60 -0
  50. package/dist/op/activities/rules-loaded.d.ts.map +1 -0
  51. package/dist/op/activities/silence.d.ts +80 -0
  52. package/dist/op/activities/silence.d.ts.map +1 -0
  53. package/dist/op/activity-contracts.d.ts +113 -0
  54. package/dist/op/activity-contracts.d.ts.map +1 -0
  55. package/dist/op/audit-op.d.ts +38 -0
  56. package/dist/op/audit-op.d.ts.map +1 -0
  57. package/dist/op/builders.d.ts +57 -0
  58. package/dist/op/builders.d.ts.map +1 -0
  59. package/dist/op/slo-rule-tests.d.ts +44 -0
  60. package/dist/op/slo-rule-tests.d.ts.map +1 -0
  61. package/dist/plugin.d.ts.map +1 -1
  62. package/dist/rules/prom-helpers.ts +38 -3
  63. package/package.json +19 -3
  64. package/src/api/alertmanager.ts +122 -0
  65. package/src/api/apply.test.ts +218 -0
  66. package/src/api/apply.ts +375 -0
  67. package/src/api/bind.ts +87 -0
  68. package/src/api/client.ts +138 -0
  69. package/src/api/evaluated.test.ts +78 -0
  70. package/src/api/evaluated.ts +106 -0
  71. package/src/api/fake-servers.ts +209 -0
  72. package/src/api/index.ts +29 -0
  73. package/src/api/ruler.ts +168 -0
  74. package/src/api/testdata/am-status-original.yml +85 -0
  75. package/src/composites/alert-routing.test.ts +163 -0
  76. package/src/composites/alert-routing.ts +277 -0
  77. package/src/composites/catalog.test.ts +10 -2
  78. package/src/composites/catalog.ts +214 -0
  79. package/src/composites/index.ts +14 -0
  80. package/src/composites/red-alerts.test.ts +182 -0
  81. package/src/composites/red-alerts.ts +214 -0
  82. package/src/composites/watchdog.test.ts +85 -0
  83. package/src/composites/watchdog.ts +142 -0
  84. package/src/config.test.ts +93 -0
  85. package/src/config.ts +288 -0
  86. package/src/describe-resources.test.ts +325 -0
  87. package/src/describe-resources.ts +328 -0
  88. package/src/export-resources.test.ts +253 -0
  89. package/src/export-resources.ts +193 -0
  90. package/src/import/alertmanager-live.test.ts +66 -0
  91. package/src/import/alertmanager-live.ts +291 -0
  92. package/src/import/parser.ts +18 -1
  93. package/src/import/roundtrip.test.ts +1 -0
  94. package/src/index.ts +37 -0
  95. package/src/lint/post-synth/prom-helpers.ts +38 -3
  96. package/src/op/activities/index.ts +31 -0
  97. package/src/op/activities/promtool.test.ts +107 -0
  98. package/src/op/activities/promtool.ts +149 -0
  99. package/src/op/activities/rule-audit.test.ts +89 -0
  100. package/src/op/activities/rule-audit.ts +168 -0
  101. package/src/op/activities/rules-loaded.test.ts +61 -0
  102. package/src/op/activities/rules-loaded.ts +98 -0
  103. package/src/op/activities/silence.test.ts +107 -0
  104. package/src/op/activities/silence.ts +175 -0
  105. package/src/op/activity-contracts.test.ts +141 -0
  106. package/src/op/activity-contracts.ts +96 -0
  107. package/src/op/audit-op.ts +54 -0
  108. package/src/op/builders.ts +82 -0
  109. package/src/op/slo-rule-tests.test.ts +84 -0
  110. package/src/op/slo-rule-tests.ts +173 -0
  111. package/src/plugin.ts +16 -0
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Live export (#3371): `chant import --from <env>` against a fake Mimir
3
+ * ruler, a fake plain Prometheus and fake Alertmanagers
4
+ * (./api/fake-servers.ts), the ruler and the Alertmanager behind real HTTP
5
+ * servers on localhost. The last test runs core's `liveImportFromPlugins`
6
+ * over the plugin, bound by environment variables, and reads the files it
7
+ * writes.
8
+ */
9
+
10
+ import { mkdtempSync, readFileSync, readdirSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
14
+ import { exportResources, type PrometheusExportOptions } from "./export-resources";
15
+ import { fakeAlertmanager, fakeHttp, fakeRuler, serveFake, type FakeAlertmanagerState, type FakeRulerState } from "./api/fake-servers";
16
+ import { EVALUATED_IMPORT_WARNING } from "./api/evaluated";
17
+ import { PrometheusGenerator } from "./import/generator";
18
+ import { ALERTMANAGER_RESOURCE_TYPE, RULE_FILE_RESOURCE_TYPE, type AlertmanagerResourceProperties, type RuleFileResourceProperties } from "./import/parser";
19
+ import { Slo } from "./composites/slo";
20
+ import { ruleGroupConfig } from "./rules";
21
+ import { prometheusPlugin } from "./plugin";
22
+ import type { RawRuleGroup } from "./api/ruler";
23
+ import type { TemplateIR } from "@intentius/chant/import/parser";
24
+
25
+ const sloGroup = ruleGroupConfig(
26
+ Slo({ name: "checkout", objective: 0.99, window: "30d", sli: { errors: "sum(rate(e[{{window}}]))", total: "sum(rate(t[{{window}}]))" } }).rules,
27
+ ) as unknown as RawRuleGroup;
28
+
29
+ const apiGroup: RawRuleGroup = {
30
+ name: "api",
31
+ interval: "30s",
32
+ // Mimir keeps fields a rule file does not have; the importer names them.
33
+ source_tenants: ["shop", "payments"],
34
+ rules: [{ alert: "ApiDown", expr: 'up{job="api"} == 0', for: "5m", labels: { severity: "page" } }],
35
+ };
36
+
37
+ function rulerState(): FakeRulerState {
38
+ return {
39
+ kind: "mimir",
40
+ tenant: "shop",
41
+ namespaces: {
42
+ shop: [apiGroup, sloGroup],
43
+ platform: [{ name: "infra", rules: [{ record: "node:up:sum", expr: 'sum(up{job="node"})' }] }],
44
+ other: [{ name: "foreign", rules: [{ record: "a:b", expr: "sum(a)" }] }],
45
+ },
46
+ };
47
+ }
48
+
49
+ const original = readFileSync(join(import.meta.dirname, "api", "testdata", "am-status-original.yml"), "utf8");
50
+
51
+ let ruler = rulerState();
52
+ const rulerCalls: string[] = [];
53
+ let am: FakeAlertmanagerState = { kind: "alertmanager", original };
54
+ let rulerUrl = "";
55
+ let amUrl = "";
56
+ const closers: Array<() => Promise<void>> = [];
57
+
58
+ beforeAll(async () => {
59
+ const r = await serveFake((req) => fakeRuler(ruler, rulerCalls)(req));
60
+ const a = await serveFake((req) => fakeAlertmanager(am)(req));
61
+ rulerUrl = r.url;
62
+ amUrl = a.url;
63
+ closers.push(r.close, a.close);
64
+ });
65
+ afterAll(async () => {
66
+ for (const c of closers) await c();
67
+ });
68
+
69
+ function profile(ruler: Record<string, unknown> | undefined = { kind: "mimir", url: rulerUrl, tenant: "shop", namespace: "shop", groupNamespaces: { infra: "platform" } }) {
70
+ return { prometheus: { profiles: { prod: { ...(ruler ? { ruler } : {}), alertmanager: { url: amUrl } } } } } as PrometheusExportOptions["config"];
71
+ }
72
+
73
+ function exportProd(opts: Partial<PrometheusExportOptions> = {}) {
74
+ return exportResources({ environment: "prod", config: profile(), env: {}, ...opts });
75
+ }
76
+
77
+ const ruleFile = (ir: TemplateIR) => (ir.resources.find((r) => r.type === RULE_FILE_RESOURCE_TYPE)?.properties as unknown as RuleFileResourceProperties | undefined)?.file;
78
+ const amConfig = (ir: TemplateIR) => (ir.resources.find((r) => r.type === ALERTMANAGER_RESOURCE_TYPE)?.properties as unknown as AlertmanagerResourceProperties | undefined)?.config;
79
+
80
+ describe("rule groups from a Mimir ruler", () => {
81
+ it("reads the declared namespaces only, and the groups parse as a rule file's would", async () => {
82
+ ruler = rulerState();
83
+ am = { kind: "alertmanager", original };
84
+ rulerCalls.length = 0;
85
+ const ir = await exportProd();
86
+ expect(ruleFile(ir)?.groups.map((g) => g.name)).toEqual(["api", "slo-checkout", "infra"]);
87
+ expect(ruleFile(ir)?.groups[0]).toEqual({ name: "api", interval: "30s", rules: [{ alert: "ApiDown", expr: 'up{job="api"} == 0', for: "5m", labels: { severity: "page" } }] });
88
+ expect(rulerCalls).toEqual(["GET /prometheus/config/v1/rules/shop", "GET /prometheus/config/v1/rules/platform"]);
89
+ expect(ir.warnings?.some((w) => w.startsWith('ruler namespace "shop": group "api": "source_tenants" is not a rule group field'))).toBe(true);
90
+ });
91
+
92
+ it("generates RuleGroups, and the group an Slo built as that Slo", async () => {
93
+ ruler = rulerState();
94
+ const files = Object.fromEntries(new PrometheusGenerator().generate(await exportProd({ selector: { type: "Prometheus::Rules::RuleGroup" } })).map((f) => [f.path, f.content]));
95
+ expect(files["slos.ts"]).toContain("const checkout = Slo({");
96
+ expect(files["rules.ts"]).toContain("new RuleGroup({");
97
+ expect(files["rules.ts"]).toContain('name: "infra"');
98
+ });
99
+
100
+ it("a selector names one group and leaves the Alertmanager out", async () => {
101
+ ruler = rulerState();
102
+ const ir = await exportProd({ selector: { type: "Prometheus::Rules::RuleGroup", name: "infra" } });
103
+ expect(ruleFile(ir)?.groups.map((g) => g.name)).toEqual(["infra"]);
104
+ expect(amConfig(ir)).toBeUndefined();
105
+ });
106
+
107
+ it("a declared namespace the ruler does not have is a warning, not an error", async () => {
108
+ ruler = { ...rulerState(), namespaces: { shop: [apiGroup] } };
109
+ const ir = await exportProd();
110
+ expect(ruleFile(ir)?.groups.map((g) => g.name)).toEqual(["api"]);
111
+ expect(ir.warnings).toContain(`ruler namespace "platform" has no rule groups on ${rulerUrl}`);
112
+ });
113
+
114
+ it("a group read from a namespace the profile does not give it is imported with a warning", async () => {
115
+ ruler = { ...rulerState(), namespaces: { shop: [apiGroup], platform: [{ name: "infra", rules: [] }, { name: "stray", rules: [] }] } };
116
+ const ir = await exportProd();
117
+ expect(ir.warnings?.some((w) => w.startsWith('rule group "stray" was read from ruler namespace "platform"'))).toBe(true);
118
+ expect(ir.warnings?.some((w) => w.includes('"infra" was read from'))).toBe(false);
119
+ });
120
+
121
+ it("the same group name in two namespaces imports the first, with a warning", async () => {
122
+ ruler = { ...rulerState(), namespaces: { shop: [apiGroup], platform: [{ ...apiGroup, interval: "1m" }] } };
123
+ const ir = await exportProd();
124
+ expect(ruleFile(ir)?.groups).toHaveLength(1);
125
+ expect(ruleFile(ir)?.groups[0].interval).toBe("30s");
126
+ expect(ir.warnings?.some((w) => w.startsWith('rule group "api" is in both namespace "shop" and namespace "platform"'))).toBe(true);
127
+ });
128
+
129
+ it("with no namespace declared, an ad-hoc import reads every namespace and says so; with owned it reads none", async () => {
130
+ ruler = rulerState();
131
+ const adHoc = { PROMETHEUS_RULER_URL: rulerUrl, PROMETHEUS_RULER_TENANT: "shop" };
132
+ let ir = await exportResources({ environment: "dev", config: {}, env: adHoc, selector: { type: "Prometheus::Rules::RuleGroup" } });
133
+ expect(ruleFile(ir)?.groups.map((g) => g.name).sort()).toEqual(["api", "foreign", "infra", "slo-checkout"]);
134
+ expect(ir.warnings?.some((w) => w.includes("declares no ruler namespace, so every namespace of the tenant was read (shop, platform, other)"))).toBe(true);
135
+ rulerCalls.length = 0;
136
+ ir = await exportResources({ environment: "dev", config: {}, env: adHoc, owned: true, selector: { type: "Prometheus::Rules::RuleGroup" } });
137
+ expect(ir.resources).toEqual([]);
138
+ expect(rulerCalls).toEqual([]);
139
+ expect(ir.warnings?.some((w) => w.startsWith("no rule groups are imported with --owned"))).toBe(true);
140
+ });
141
+
142
+ it("a refused tenant fails the export rather than importing nothing", async () => {
143
+ ruler = rulerState();
144
+ await expect(exportProd({ config: profile({ kind: "mimir", url: rulerUrl, tenant: "nope", namespace: "shop" }) })).rejects.toThrow(/returned 401/);
145
+ });
146
+ });
147
+
148
+ describe("rule groups from a plain Prometheus", () => {
149
+ const state: FakeRulerState = {
150
+ kind: "prometheus",
151
+ namespaces: {},
152
+ evaluated: [
153
+ {
154
+ name: "api",
155
+ file: "/etc/prometheus/api.yml",
156
+ interval: 30,
157
+ rules: [{ type: "alerting", name: "ApiDown", query: 'up{job="api"} == 0', duration: 300, labels: { severity: "page" }, annotations: {}, health: "ok" }],
158
+ },
159
+ { name: "node", file: "/etc/prometheus/node.yml", interval: 60, rules: [{ type: "recording", name: "node:up:sum", query: 'sum(up{job="node"})', health: "ok" }] },
160
+ ],
161
+ };
162
+ const options = (ruler: Record<string, unknown>, extra: Partial<PrometheusExportOptions> = {}) =>
163
+ exportResources({
164
+ environment: "prod",
165
+ config: { prometheus: { profiles: { prod: { ruler: { kind: "prometheus", url: "http://prometheus.test", ...ruler } } } } } as PrometheusExportOptions["config"],
166
+ env: {},
167
+ http: { ruler: fakeHttp(fakeRuler(state)) },
168
+ ...extra,
169
+ });
170
+
171
+ it("maps /api/v1/rules back to rule-file groups and says what that loses", async () => {
172
+ const ir = await options({});
173
+ expect(ruleFile(ir)?.groups).toEqual([
174
+ { name: "api", interval: "30s", rules: [{ alert: "ApiDown", expr: 'up{job="api"} == 0', for: "5m", labels: { severity: "page" } }] },
175
+ { name: "node", interval: "1m", rules: [{ record: "node:up:sum", expr: 'sum(up{job="node"})' }] },
176
+ ]);
177
+ expect(ir.warnings).toContain(EVALUATED_IMPORT_WARNING);
178
+ expect(ir.warnings?.some((w) => w.startsWith("not imported, Alertmanager:"))).toBe(true);
179
+ });
180
+
181
+ it("a named rule file bounds what is read, and owned with none names nothing", async () => {
182
+ expect(ruleFile(await options({ namespace: "/etc/prometheus/node.yml" }))?.groups.map((g) => g.name)).toEqual(["node"]);
183
+ const owned = await options({}, { owned: true });
184
+ expect(owned.resources).toEqual([]);
185
+ });
186
+ });
187
+
188
+ describe("the Alertmanager config", () => {
189
+ it("from /api/v2/status: Alertmanager's defaults are taken out and masked secrets are named", async () => {
190
+ am = { kind: "alertmanager", original };
191
+ const ir = await exportProd({ selector: { type: "Prometheus::Alertmanager::Config" } });
192
+ const config = amConfig(ir);
193
+ expect(config?.global).toEqual({ smtp_from: "am@example.com", smtp_smarthost: "smtp.example.com:587" });
194
+ expect(config?.receivers?.map((r) => r.name)).toEqual(["team", "pager"]);
195
+ expect(config?.route).toMatchObject({ receiver: "team", routes: [{ receiver: "pager", matchers: ['severity="page"'] }] });
196
+ expect(ruleFile(ir)).toBeUndefined();
197
+ const secrets = ir.warnings?.find((w) => w.includes('read "<secret>"'));
198
+ expect(secrets).toContain("receivers[0].webhook_configs[0].url");
199
+ expect(secrets).toContain("receivers[1].pagerduty_configs[0].routing_key");
200
+ });
201
+
202
+ it("verbatim keeps what Alertmanager wrote", async () => {
203
+ am = { kind: "alertmanager", original };
204
+ const config = amConfig(await exportProd({ verbatim: true, selector: { type: "Prometheus::Alertmanager::Route" } }));
205
+ expect(config?.global).toMatchObject({ resolve_timeout: "5m", smtp_hello: "localhost" });
206
+ });
207
+
208
+ it("from Mimir's /api/v1/alerts: the config as uploaded, with the templates named in a warning", async () => {
209
+ const uploaded = "route:\n receiver: team\n group_wait: 30s\nreceivers:\n - name: team\n webhook_configs:\n - url: http://hooks.example.com/team\n send_resolved: true\n";
210
+ const state: FakeAlertmanagerState = { kind: "mimir", tenant: "shop", uploaded: { alertmanager_config: uploaded, template_files: { "team.tmpl": '{{ define "x" }}y{{ end }}' } } };
211
+ const ir = await exportResources({
212
+ environment: "prod",
213
+ config: { prometheus: { profiles: { prod: { alertmanager: { kind: "mimir", url: "http://mimir.test", tenant: "shop" } } } } },
214
+ env: {},
215
+ http: { alertmanager: fakeHttp(fakeAlertmanager(state), { "x-scope-orgid": "shop" }) },
216
+ });
217
+ // Uploaded as written: send_resolved: true stays, and group_wait is not taken for a default.
218
+ expect(amConfig(ir)).toEqual({
219
+ route: { receiver: "team", group_wait: "30s" },
220
+ receivers: [{ name: "team", webhook_configs: [{ url: "http://hooks.example.com/team", send_resolved: true }] }],
221
+ });
222
+ expect(ir.warnings?.some((w) => w.includes("notification templates (team.tmpl) are not imported"))).toBe(true);
223
+ expect(ir.warnings?.some((w) => w.startsWith("not imported, rule groups:"))).toBe(true);
224
+ });
225
+ });
226
+
227
+ it("neither endpoint bound is an error that says why for both", async () => {
228
+ await expect(exportResources({ environment: "prod", config: {}, env: {} })).rejects.toThrow(/rule groups: .*; Alertmanager: /);
229
+ });
230
+
231
+ it("chant import --from writes the ruler's groups and the Alertmanager's config as source", async () => {
232
+ ruler = rulerState();
233
+ am = { kind: "alertmanager", original };
234
+ const { liveImportFromPlugins } = await import("@intentius/chant/cli/commands/import");
235
+ const dir = mkdtempSync(join(tmpdir(), "prom-import-live-"));
236
+ vi.stubEnv("PROMETHEUS_RULER_URL", rulerUrl);
237
+ vi.stubEnv("PROMETHEUS_RULER_TENANT", "shop");
238
+ vi.stubEnv("PROMETHEUS_RULER_NAMESPACE", "shop");
239
+ vi.stubEnv("ALERTMANAGER_URL", amUrl);
240
+ try {
241
+ const result = await liveImportFromPlugins([prometheusPlugin], { environment: "nowhere", lexicon: "prometheus", output: join(dir, "src"), force: true });
242
+ expect(result.error).toBeUndefined();
243
+ expect(result.success).toBe(true);
244
+ const written = readdirSync(join(dir, "src")).sort();
245
+ expect(written).toEqual(expect.arrayContaining(["rules.ts", "slos.ts", "receivers.ts", "routes.ts"]));
246
+ expect(readFileSync(join(dir, "src", "slos.ts"), "utf8")).toContain("Slo({");
247
+ expect(readFileSync(join(dir, "src", "rules.ts"), "utf8")).not.toContain("foreign");
248
+ expect(result.warnings.some((w) => w.includes('read "<secret>"'))).toBe(true);
249
+ } finally {
250
+ vi.unstubAllEnvs();
251
+ rmSync(dir, { recursive: true, force: true });
252
+ }
253
+ });
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Live export for prometheus (#3371): `chant import --from <env>` writes the
3
+ * environment's rule groups and Alertmanager config as chant TypeScript.
4
+ *
5
+ * All I/O is here. What is read goes through the file importer's own parse
6
+ * (./import/parser.ts) into the same IR, so a group read from a ruler and
7
+ * the same group in a rule file generate the same source through the same
8
+ * `PrometheusGenerator`, `Slo` recognition (./import/slo.ts) included.
9
+ *
10
+ * - Rule groups, from the environment's ruler (./api/ruler.ts). On Mimir,
11
+ * Cortex and Loki only the namespaces the profile declares are read
12
+ * (`namespace` and `groupNamespaces`, see ./config.ts); that is the
13
+ * ownership boundary, as a stack bounds a CloudFormation export. An ad-hoc
14
+ * read with no namespace declared reads every namespace of the tenant and
15
+ * says so, and with `owned` reads none. On a plain Prometheus the groups
16
+ * come from `/api/v1/rules` and are mapped back to the rule-file shape
17
+ * (./api/evaluated.ts), which is lossy, and the export says how.
18
+ * - The Alertmanager config, from `/api/v2/status` or, on Mimir and
19
+ * Cortex, `/api/v1/alerts` (./api/alertmanager.ts). Alertmanager's status
20
+ * config is its loaded config marshalled again; unless `verbatim`, what
21
+ * that adds is taken out (./import/alertmanager-live.ts). Secrets read
22
+ * `<secret>` and are named in a warning.
23
+ * - `selector.type` is `Prometheus::Rules::RuleGroup` (with `selector.name`
24
+ * a group name) or any `Prometheus::Alertmanager::*` type, which selects
25
+ * the whole config.
26
+ *
27
+ * Neither endpoint being bound is an error; one of them being unbound is a
28
+ * warning, so a project with only rule groups imports them.
29
+ */
30
+
31
+ import type { ExportedTemplate, ResourceSelector } from "@intentius/chant/lexicon";
32
+ import type { ResourceIR } from "@intentius/chant/import/parser";
33
+ import { bindEndpoints, type BindOptions } from "./api/bind";
34
+ import { EVALUATED_IMPORT_WARNING, evaluatedToRuleGroup } from "./api/evaluated";
35
+ import type { RulerApi, RawRuleGroup } from "./api/ruler";
36
+ import type { AlertmanagerApi } from "./api/alertmanager";
37
+ import { declaredNamespaces, isUnresolvedTarget, namespaceOfGroup } from "./config";
38
+ import {
39
+ ALERTMANAGER_RESOURCE_TYPE,
40
+ RULE_FILE_RESOURCE_TYPE,
41
+ loadPrometheusYaml,
42
+ parseAlertmanagerDocument,
43
+ parseRuleFileDocument,
44
+ type AlertmanagerResourceProperties,
45
+ type RuleFileResourceProperties,
46
+ } from "./import/parser";
47
+ import { maskedSecretPaths, stripAlertmanagerDefaults } from "./import/alertmanager-live";
48
+ import type { RuleGroupConfig } from "./model";
49
+ import { RULE_GROUP_TYPE } from "./rules";
50
+
51
+ export interface PrometheusExportOptions extends Omit<BindOptions, "environment"> {
52
+ environment: string;
53
+ stack?: string;
54
+ region?: string;
55
+ selector?: ResourceSelector;
56
+ owned?: boolean;
57
+ verbatim?: boolean;
58
+ }
59
+
60
+ const ALERTMANAGER_TYPE_PREFIX = "Prometheus::Alertmanager::";
61
+
62
+ function isObject(v: unknown): v is Record<string, unknown> {
63
+ return typeof v === "object" && v !== null && !Array.isArray(v);
64
+ }
65
+
66
+ /** The groups of the declared namespaces (or, ad hoc, of every namespace), each with where it came from. */
67
+ async function rulerGroups(ruler: RulerApi, owned: boolean | undefined, warnings: string[]): Promise<Array<{ namespace: string; raw: RawRuleGroup[] }>> {
68
+ const declared = declaredNamespaces(ruler.target);
69
+ if (declared.length === 0) {
70
+ if (owned) {
71
+ warnings.push(`no rule groups are imported with --owned: ${ruler.target.source} declares no ruler namespace, and the namespace is the ownership boundary`);
72
+ return [];
73
+ }
74
+ const all = await ruler.listNamespaces();
75
+ const names = Object.keys(all);
76
+ if (names.length > 0) {
77
+ warnings.push(
78
+ `${ruler.target.source} declares no ruler namespace, so every namespace of the tenant was read (${names.join(", ")}); ` +
79
+ "set prometheus.profiles.<env>.ruler.namespace to bound what is imported and what an apply may change",
80
+ );
81
+ }
82
+ return names.map((namespace) => ({ namespace, raw: all[namespace] }));
83
+ }
84
+ const out: Array<{ namespace: string; raw: RawRuleGroup[] }> = [];
85
+ for (const namespace of declared) {
86
+ const raw = await ruler.readNamespace(namespace);
87
+ if (raw === undefined) warnings.push(`ruler namespace "${namespace}" has no rule groups on ${ruler.target.url}`);
88
+ else out.push({ namespace, raw });
89
+ }
90
+ return out;
91
+ }
92
+
93
+ async function exportRuleGroups(ruler: RulerApi, options: PrometheusExportOptions, warnings: string[]): Promise<RuleGroupConfig[]> {
94
+ const name = options.selector?.name;
95
+ const groups: RuleGroupConfig[] = [];
96
+ const seen = new Map<string, string>();
97
+ const keep = (group: RuleGroupConfig, from: string) => {
98
+ if (name !== undefined && group.name !== name) return;
99
+ const first = seen.get(group.name);
100
+ if (first !== undefined) {
101
+ warnings.push(`rule group "${group.name}" is in both ${first} and ${from}; the one in ${first} is imported, since a rule file holds one group of a name`);
102
+ return;
103
+ }
104
+ seen.set(group.name, from);
105
+ groups.push(group);
106
+ };
107
+
108
+ if (ruler.target.kind === "prometheus") {
109
+ const files = declaredNamespaces(ruler.target);
110
+ if (files.length === 0 && options.owned) {
111
+ warnings.push(`no rule groups are imported with --owned: ${ruler.target.source} names no rule file, and a rule file carries no ownership marker`);
112
+ return [];
113
+ }
114
+ const evaluated = (await ruler.evaluated()).filter((g) => files.length === 0 || files.includes(g.file));
115
+ if (evaluated.length > 0) warnings.push(EVALUATED_IMPORT_WARNING);
116
+ for (const g of evaluated) keep(evaluatedToRuleGroup(g), `rule file "${g.file}"`);
117
+ return groups;
118
+ }
119
+
120
+ for (const { namespace, raw } of await rulerGroups(ruler, options.owned, warnings)) {
121
+ const parsed = parseRuleFileDocument({ groups: raw });
122
+ for (const w of parsed.warnings) warnings.push(`ruler namespace "${namespace}": ${w}`);
123
+ for (const g of parsed.file.groups) {
124
+ keep(g, `namespace "${namespace}"`);
125
+ if (seen.get(g.name) === `namespace "${namespace}"` && namespaceOfGroup(ruler.target, g.name) !== namespace) {
126
+ warnings.push(
127
+ `rule group "${g.name}" was read from ruler namespace "${namespace}", which a rule file cannot say; ` +
128
+ `name it under prometheus.profiles.<env>.ruler.groupNamespaces so it is observed and applied there`,
129
+ );
130
+ }
131
+ }
132
+ }
133
+ return groups;
134
+ }
135
+
136
+ async function exportAlertmanager(am: AlertmanagerApi, verbatim: boolean | undefined, warnings: string[]): Promise<ResourceIR | undefined> {
137
+ const live = await am.readConfig();
138
+ if (live === undefined) {
139
+ warnings.push(`${am.target.url} has no Alertmanager config for this tenant`);
140
+ return undefined;
141
+ }
142
+ const loaded = loadPrometheusYaml(live.text);
143
+ if (!isObject(loaded)) throw new Error(`the Alertmanager config read from ${live.address} is not a YAML mapping`);
144
+ const doc = live.remarshalled && !verbatim ? stripAlertmanagerDefaults(loaded) : loaded;
145
+ const masked = maskedSecretPaths(doc);
146
+ if (masked.length > 0) {
147
+ warnings.push(
148
+ `Alertmanager masks secrets in ${live.address}; these read "<secret>" and need their real values (a *_file path, say): ${masked.join(", ")}`,
149
+ );
150
+ }
151
+ if (live.templateFiles) {
152
+ warnings.push(`the Alertmanager's notification templates (${Object.keys(live.templateFiles).join(", ")}) are not imported; templates: names files, not their contents`);
153
+ }
154
+ const parsed = parseAlertmanagerDocument(doc);
155
+ warnings.push(...parsed.warnings.map((w) => `alertmanager: ${w}`));
156
+ return {
157
+ logicalId: "alertmanager",
158
+ type: ALERTMANAGER_RESOURCE_TYPE,
159
+ properties: { config: parsed.config } satisfies AlertmanagerResourceProperties as unknown as Record<string, unknown>,
160
+ };
161
+ }
162
+
163
+ export async function exportResources(options: PrometheusExportOptions): Promise<ExportedTemplate> {
164
+ const { ruler, alertmanager } = await bindEndpoints(options);
165
+ const type = options.selector?.type;
166
+ const wantRules = type === undefined || type === RULE_GROUP_TYPE || type === RULE_FILE_RESOURCE_TYPE;
167
+ const wantAlertmanager = (type === undefined || type.startsWith(ALERTMANAGER_TYPE_PREFIX)) && options.selector?.name === undefined;
168
+
169
+ const unbound = [
170
+ ...(wantRules && isUnresolvedTarget(ruler) ? [`rule groups: ${ruler.detail}`] : []),
171
+ ...(wantAlertmanager && isUnresolvedTarget(alertmanager) ? [`Alertmanager: ${alertmanager.detail}`] : []),
172
+ ];
173
+ const wanted = (wantRules ? 1 : 0) + (wantAlertmanager ? 1 : 0);
174
+ if (wanted > 0 && unbound.length === wanted) throw new Error(`nothing to read for environment "${options.environment}": ${unbound.join("; ")}`);
175
+
176
+ const warnings: string[] = unbound.map((u) => `not imported, ${u}`);
177
+ const resources: ResourceIR[] = [];
178
+ if (wantRules && !isUnresolvedTarget(ruler)) {
179
+ const groups = await exportRuleGroups(ruler, options, warnings);
180
+ if (groups.length > 0) {
181
+ resources.push({
182
+ logicalId: "ruleFile",
183
+ type: RULE_FILE_RESOURCE_TYPE,
184
+ properties: { file: { groups } } satisfies RuleFileResourceProperties as unknown as Record<string, unknown>,
185
+ });
186
+ }
187
+ }
188
+ if (wantAlertmanager && !isUnresolvedTarget(alertmanager)) {
189
+ const resource = await exportAlertmanager(alertmanager, options.verbatim, warnings);
190
+ if (resource) resources.push(resource);
191
+ }
192
+ return { resources, parameters: [], warnings };
193
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Alertmanager's re-marshalled `/api/v2/status` config back to the declared
3
+ * shape (#3371). The fixture is written by hand in the shape
4
+ * `Config.String()` gives at Alertmanager v0.34.1 (defaults written out,
5
+ * globals copied into each integration, secrets masked); a stack run
6
+ * against a real Alertmanager is what confirms it.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { describe, expect, test } from "vitest";
12
+ import { maskedSecretPaths, stripAlertmanagerDefaults } from "./alertmanager-live";
13
+ import { loadPrometheusYaml } from "./parser";
14
+
15
+ const original = readFileSync(join(import.meta.dirname, "..", "api", "testdata", "am-status-original.yml"), "utf8");
16
+ const doc = loadPrometheusYaml(original) as Record<string, unknown>;
17
+
18
+ describe("stripAlertmanagerDefaults", () => {
19
+ const stripped = stripAlertmanagerDefaults(doc);
20
+
21
+ test("global keeps only what differs from DefaultGlobalConfig", () => {
22
+ expect(stripped.global).toEqual({ smtp_from: "am@example.com", smtp_smarthost: "smtp.example.com:587" });
23
+ });
24
+
25
+ test("routes lose continue: false and keep matchers, group_by and time intervals", () => {
26
+ expect(stripped.route).toEqual({
27
+ receiver: "team",
28
+ group_by: ["alertname"],
29
+ routes: [{ receiver: "pager", matchers: ['severity="page"'], mute_time_intervals: ["weekends"] }],
30
+ });
31
+ });
32
+
33
+ test("integrations lose inherited globals, their own defaults and zero values, and keep what was set", () => {
34
+ expect(stripped.receivers).toEqual([
35
+ { name: "team", webhook_configs: [{ url: "<secret>" }] },
36
+ {
37
+ name: "pager",
38
+ // require_tls: false differs from the global true, so it was set and stays.
39
+ email_configs: [{ to: "oncall@example.com", require_tls: false }],
40
+ pagerduty_configs: [{ routing_key: "<secret>", details: { team: "payments" } }],
41
+ },
42
+ ]);
43
+ });
44
+
45
+ test("inhibit rules and time intervals are kept, an empty templates list is not", () => {
46
+ expect(stripped.inhibit_rules).toEqual([{ source_matchers: ['severity="page"'], target_matchers: ['severity="ticket"'], equal: ["alertname"] }]);
47
+ expect(stripped.time_intervals).toEqual([{ name: "weekends", time_intervals: [{ weekdays: ["saturday", "sunday"] }] }]);
48
+ expect("templates" in stripped).toBe(false);
49
+ });
50
+
51
+ test("a webhook that turned send_resolved off keeps it", () => {
52
+ const out = stripAlertmanagerDefaults({ receivers: [{ name: "x", webhook_configs: [{ send_resolved: false, url: "http://x" }] }] });
53
+ expect(out.receivers).toEqual([{ name: "x", webhook_configs: [{ send_resolved: false, url: "http://x" }] }]);
54
+ });
55
+
56
+ test("the input is not changed", () => {
57
+ expect((loadPrometheusYaml(original) as Record<string, unknown>).global).toEqual(doc.global);
58
+ });
59
+ });
60
+
61
+ test("maskedSecretPaths names every <secret>", () => {
62
+ expect(maskedSecretPaths(stripAlertmanagerDefaults(doc))).toEqual([
63
+ "receivers[0].webhook_configs[0].url",
64
+ "receivers[1].pagerduty_configs[0].routing_key",
65
+ ]);
66
+ });