@intentius/chant-lexicon-prometheus 0.106.3 → 0.107.1

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,325 @@
1
+ /**
2
+ * `describeResources` (#3371) against a fake Mimir ruler and a fake
3
+ * Alertmanager behind real HTTP servers on localhost (./api/fake-servers.ts),
4
+ * so the default fetch transport, the tenant header and the token are on
5
+ * the path; and the shared observation conformance suite over the same
6
+ * fakes.
7
+ */
8
+
9
+ import { readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
12
+ import { describeObservationConformance } from "@intentius/chant-test-utils";
13
+ import { normalizeObservation } from "@intentius/chant/observation";
14
+ import { describeResources, type PrometheusObserveOptions } from "./describe-resources";
15
+ import { fakeAlertmanager, fakeHttp, fakeRuler, serveFake, type FakeAlertmanagerState, type FakeRulerState } from "./api/fake-servers";
16
+ import { prometheusPlugin } from "./plugin";
17
+ import { RuleGroup, ruleGroupConfig, type RuleGroupEntity } from "./rules";
18
+ import { InhibitRule, Receiver, Route, TimeInterval, AlertmanagerSettings } from "./alertmanager";
19
+ import { SCRAPE_CONFIG_TYPE } from "./prometheus-config";
20
+ import type { RawRuleGroup } from "./api/ruler";
21
+
22
+ type Entities = PrometheusObserveOptions["entities"];
23
+
24
+ const api = new RuleGroup({
25
+ name: "api",
26
+ interval: "30s",
27
+ rules: [
28
+ { record: "job:http_requests:rate5m", expr: "sum by (job) (rate(http_requests_total[5m]))" },
29
+ { alert: "ApiDown", expr: "up{job=\"api\"} == 0", for: "5m", labels: { severity: "page" } },
30
+ ],
31
+ });
32
+ const infra = new RuleGroup({ name: "infra", rules: [{ record: "node:up:sum", expr: "sum(up{job=\"node\"})" }] });
33
+ const missing = new RuleGroup({ name: "missing", rules: [{ record: "x:y", expr: "sum(x)" }] });
34
+
35
+ const team = new Receiver({ name: "team", webhook_configs: [{ url: "http://hooks.example.com/team" }] });
36
+ const pager = new Receiver({ name: "pager", email_configs: [{ to: "oncall@example.com" }] });
37
+ const ghost = new Receiver({ name: "ghost" });
38
+ const weekends = new TimeInterval({ name: "weekends", time_intervals: [{ weekdays: ["saturday", "sunday"] }] });
39
+ const pageRoute = new Route({ receiver: "pager", matchers: ['severity = "page"'], mute_time_intervals: [weekends] });
40
+ const root = new Route({ receiver: team, group_by: ["alertname"], routes: [pageRoute] });
41
+ const inhibit = new InhibitRule({ source_matchers: ['severity="page"'], target_matchers: ['severity="ticket"'], equal: ["alertname"] });
42
+ const settings = new AlertmanagerSettings({ global: { smtp_from: "am@example.com", smtp_smarthost: "smtp.example.com:587" } });
43
+
44
+ function entities(pairs: Record<string, { entityType: string; props: unknown }>): Entities {
45
+ return new Map(Object.entries(pairs).map(([n, e]) => [n, { entityType: e.entityType, props: e.props as Record<string, unknown> }]));
46
+ }
47
+
48
+ const ALL = entities({
49
+ api,
50
+ infra,
51
+ missing,
52
+ team,
53
+ pager,
54
+ ghost,
55
+ weekends,
56
+ pageRoute,
57
+ root,
58
+ inhibit,
59
+ settings,
60
+ scrape: { entityType: SCRAPE_CONFIG_TYPE, props: { job_name: "node" } },
61
+ });
62
+
63
+ const raw = (g: RuleGroupEntity) => ruleGroupConfig(g) as unknown as RawRuleGroup;
64
+
65
+ function rulerState(overrides: Partial<FakeRulerState> = {}): FakeRulerState {
66
+ return {
67
+ kind: "mimir",
68
+ tenant: "shop",
69
+ namespaces: {
70
+ shop: [raw(api), { name: "checkout", rules: [] }],
71
+ platform: [raw(infra)],
72
+ // Not declared by the project: never read.
73
+ other: [{ name: "foreign", rules: [{ record: "a:b", expr: "sum(a)" }] }],
74
+ },
75
+ evaluated: [
76
+ {
77
+ name: "api",
78
+ file: "shop",
79
+ interval: 30,
80
+ rules: [
81
+ { type: "recording", name: "job:http_requests:rate5m", query: "sum by (job) (rate(http_requests_total[5m]))", health: "err", lastError: "many-to-many matching not allowed" },
82
+ { type: "alerting", name: "ApiDown", query: 'up{job="api"} == 0', duration: 300, health: "ok", state: "firing" },
83
+ ],
84
+ },
85
+ { name: "infra", file: "platform", interval: 60, rules: [{ type: "recording", name: "node:up:sum", query: 'sum(up{job="node"})', health: "ok" }] },
86
+ ],
87
+ ...overrides,
88
+ };
89
+ }
90
+
91
+ const original = readFileSync(join(import.meta.dirname, "api", "testdata", "am-status-original.yml"), "utf8");
92
+ const amState = (overrides: Partial<FakeAlertmanagerState> = {}): FakeAlertmanagerState => ({ kind: "alertmanager", original, version: "0.34.1", ...overrides });
93
+
94
+ const ENV = { MIMIR_TOKEN: "t0k" };
95
+ let rulerUrl = "";
96
+ let amUrl = "";
97
+ let closeRuler: () => Promise<void>;
98
+ let closeAm: () => Promise<void>;
99
+ const rulerCalls: string[] = [];
100
+ let ruler = rulerState();
101
+
102
+ beforeAll(async () => {
103
+ // The handler reads `ruler` on every request, so a test can swap the state.
104
+ const r = await serveFake((req) => {
105
+ if (req.headers.authorization !== "Bearer t0k") return { status: 401, text: "bad token" };
106
+ return fakeRuler(ruler, rulerCalls)(req);
107
+ });
108
+ const a = await serveFake(fakeAlertmanager(amState()));
109
+ rulerUrl = r.url;
110
+ amUrl = a.url;
111
+ closeRuler = r.close;
112
+ closeAm = a.close;
113
+ });
114
+ afterAll(async () => {
115
+ await closeRuler?.();
116
+ await closeAm?.();
117
+ });
118
+
119
+ function config(over: { tenant?: string; withAlertmanager?: boolean } = {}) {
120
+ return {
121
+ prometheus: {
122
+ profiles: {
123
+ prod: {
124
+ ruler: {
125
+ kind: "mimir" as const,
126
+ url: rulerUrl,
127
+ tenant: over.tenant ?? "shop",
128
+ namespace: "shop",
129
+ groupNamespaces: { infra: "platform" },
130
+ token: { env: "MIMIR_TOKEN" },
131
+ },
132
+ ...(over.withAlertmanager === false ? {} : { alertmanager: { url: amUrl } }),
133
+ },
134
+ },
135
+ },
136
+ };
137
+ }
138
+
139
+ function run(opts: Partial<PrometheusObserveOptions> = {}, over: Parameters<typeof config>[0] = {}) {
140
+ return describeResources({
141
+ environment: "prod",
142
+ entityNames: [...ALL.keys()],
143
+ entities: ALL,
144
+ config: config(over),
145
+ env: ENV,
146
+ ...opts,
147
+ });
148
+ }
149
+
150
+ describe("describeResources: rule groups", () => {
151
+ it("reads each group in the namespace the profile gives it, over the config API, with the tenant header", async () => {
152
+ ruler = rulerState();
153
+ const { resources, queried, unobserved } = normalizeObservation(await run());
154
+ expect(resources.api).toMatchObject({ type: "Prometheus::Rules::RuleGroup", physicalId: "shop/api", status: "PRESENT", ownership: "unknown" });
155
+ expect(resources.infra).toMatchObject({ physicalId: "platform/infra", attributes: { namespace: "platform", rules: 1, health: "ok" } });
156
+ expect(queried.api).toBe("/prometheus/config/v1/rules/shop/api");
157
+ expect(queried.infra).toBe("/prometheus/config/v1/rules/platform/infra");
158
+ expect(resources.missing).toBeUndefined();
159
+ expect(unobserved.missing).toBeUndefined();
160
+ expect(queried.missing).toBe("/prometheus/config/v1/rules/shop/missing");
161
+ });
162
+
163
+ it("reports each group's health from /api/v1/rules: err with the first rule error, firing and pending counted", async () => {
164
+ ruler = rulerState();
165
+ const { resources } = normalizeObservation(await run());
166
+ expect(resources.api.attributes).toMatchObject({
167
+ namespace: "shop",
168
+ rules: 2,
169
+ interval: "30s",
170
+ health: "err",
171
+ lastError: "job:http_requests:rate5m: many-to-many matching not allowed",
172
+ failing: 1,
173
+ firing: 1,
174
+ pending: 0,
175
+ });
176
+ });
177
+
178
+ it("never reads a namespace the project does not declare", async () => {
179
+ ruler = rulerState();
180
+ rulerCalls.length = 0;
181
+ await run();
182
+ expect(rulerCalls.length).toBeGreaterThan(0);
183
+ expect(rulerCalls.filter((c) => c.includes("/other"))).toEqual([]);
184
+ // No list of every namespace, either.
185
+ expect(rulerCalls).not.toContain("GET /prometheus/config/v1/rules");
186
+ });
187
+
188
+ it("a group the ruler has not evaluated yet, or a health API that is down, leaves the group present with health unknown", async () => {
189
+ ruler = rulerState({ evaluated: [] });
190
+ let { resources } = normalizeObservation(await run());
191
+ expect(resources.api.attributes).toMatchObject({ health: "unknown" });
192
+ ruler = rulerState({ evaluatedStatus: 503 });
193
+ ({ resources } = normalizeObservation(await run()));
194
+ expect(resources.api).toMatchObject({ status: "PRESENT", attributes: { health: "unknown" } });
195
+ expect(String(resources.api.attributes?.healthDetail)).toContain("returned 503");
196
+ });
197
+
198
+ it("a wrong tenant is no-credentials and a 500 is read-failed, never absent; the Alertmanager entities are still read", async () => {
199
+ ruler = rulerState();
200
+ let { unobserved, resources } = normalizeObservation(await run({}, { tenant: "someone-else" }));
201
+ expect(unobserved.api.reason).toBe("no-credentials");
202
+ expect(unobserved.missing.reason).toBe("no-credentials");
203
+ expect(resources.team).toBeDefined();
204
+ ruler = rulerState({ status: 500 });
205
+ ({ unobserved } = normalizeObservation(await run()));
206
+ expect(unobserved.api.reason).toBe("read-failed");
207
+ });
208
+
209
+ it("a token variable that is not set makes every group no-credentials", async () => {
210
+ ruler = rulerState();
211
+ const { unobserved } = normalizeObservation(await run({ env: {} }));
212
+ expect(unobserved.api).toMatchObject({ reason: "no-credentials" });
213
+ expect(unobserved.api.detail).toContain("MIMIR_TOKEN");
214
+ });
215
+
216
+ it("on a plain Prometheus, groups are read from /api/v1/rules by name", async () => {
217
+ const state: FakeRulerState = {
218
+ kind: "prometheus",
219
+ namespaces: { "/etc/prometheus/rules.yml": [raw(api)] },
220
+ };
221
+ const { resources, queried } = normalizeObservation(
222
+ await describeResources({
223
+ environment: "prod",
224
+ entityNames: ["api", "missing"],
225
+ entities: ALL,
226
+ config: { prometheus: { profiles: { prod: { ruler: { kind: "prometheus", url: "http://prometheus.test" } } } } },
227
+ env: {},
228
+ http: { ruler: fakeHttp(fakeRuler(state)) },
229
+ }),
230
+ );
231
+ expect(resources.api).toMatchObject({ physicalId: "/etc/prometheus/rules.yml/api", attributes: { file: "/etc/prometheus/rules.yml", rules: 2, health: "ok" } });
232
+ expect(resources.missing).toBeUndefined();
233
+ expect(queried.missing).toBe("/api/v1/rules");
234
+ });
235
+ });
236
+
237
+ describe("describeResources: Alertmanager", () => {
238
+ it("finds receivers and time intervals by name, routes by receiver and matchers, inhibit rules by matchers", async () => {
239
+ ruler = rulerState();
240
+ const { resources, queried } = normalizeObservation(await run());
241
+ expect(resources.team).toMatchObject({ type: "Prometheus::Alertmanager::Receiver", physicalId: "receiver/team", attributes: { integrations: ["webhook_configs"] } });
242
+ expect(resources.pager.attributes).toMatchObject({ integrations: ["email_configs", "pagerduty_configs"] });
243
+ expect(resources.ghost).toBeUndefined();
244
+ expect(queried.ghost).toBe("/api/v2/status");
245
+ expect(resources.weekends).toMatchObject({ physicalId: "time_interval/weekends" });
246
+ expect(resources.root).toMatchObject({ physicalId: "route/team{}", attributes: { receiver: "team", routes: 1 } });
247
+ // Declared with spaces around the operator; Alertmanager writes severity="page".
248
+ expect(resources.pageRoute).toMatchObject({ physicalId: 'route/pager{severity="page"}' });
249
+ expect(resources.inhibit).toMatchObject({ physicalId: "inhibit_rule/0" });
250
+ });
251
+
252
+ it("carries the cluster status and version on every entity, and a config digest on the settings", async () => {
253
+ ruler = rulerState();
254
+ const { resources } = normalizeObservation(await run());
255
+ expect(resources.team.attributes).toMatchObject({ cluster: "ready", version: "0.34.1", peers: 1 });
256
+ expect(resources.settings).toMatchObject({ physicalId: "alertmanager" });
257
+ expect(String(resources.settings.attributes?.configDigest)).toMatch(/^[0-9a-f]{16}$/);
258
+ });
259
+
260
+ it("no Alertmanager in the profile is no-binding for its entities only", async () => {
261
+ ruler = rulerState();
262
+ const { unobserved, resources } = normalizeObservation(await run({}, { withAlertmanager: false }));
263
+ expect(unobserved.team.reason).toBe("no-binding");
264
+ expect(unobserved.settings.reason).toBe("no-binding");
265
+ expect(resources.api).toBeDefined();
266
+ });
267
+
268
+ it("a Mimir tenant with no Alertmanager config has every entity absent", async () => {
269
+ const state = amState({ kind: "mimir", tenant: "shop" });
270
+ const { resources, unobserved } = normalizeObservation(
271
+ await describeResources({
272
+ environment: "prod",
273
+ entityNames: ["team", "settings"],
274
+ entities: ALL,
275
+ config: { prometheus: { profiles: { prod: { alertmanager: { kind: "mimir", url: "http://mimir.test", tenant: "shop" } } } } },
276
+ env: {},
277
+ http: { alertmanager: fakeHttp(fakeAlertmanager(state), { "x-scope-orgid": "shop" }) },
278
+ }),
279
+ );
280
+ expect(resources).toEqual({});
281
+ expect(unobserved).toEqual({});
282
+ });
283
+
284
+ it("prometheus.yml entities are unsupported-kind", async () => {
285
+ ruler = rulerState();
286
+ expect(normalizeObservation(await run()).unobserved.scrape.reason).toBe("unsupported-kind");
287
+ });
288
+ });
289
+
290
+ it("owned: true withholds everything present as filtered, since nothing carries a marker", async () => {
291
+ ruler = rulerState();
292
+ const { resources, unobserved } = normalizeObservation(await run({ owned: true }));
293
+ expect(resources).toEqual({});
294
+ expect(unobserved.api.reason).toBe("filtered");
295
+ expect(unobserved.team.reason).toBe("filtered");
296
+ });
297
+
298
+ describeObservationConformance({
299
+ lexicon: "prometheus",
300
+ ownershipChannel: prometheusPlugin.ownershipChannel,
301
+ scenarios: [
302
+ {
303
+ name: "a Mimir ruler and an Alertmanager",
304
+ declared: [...ALL.keys()],
305
+ run: () => {
306
+ ruler = rulerState();
307
+ return run();
308
+ },
309
+ expectPresent: ["api", "infra", "team", "pager", "weekends", "pageRoute", "root", "inhibit", "settings"],
310
+ expectAbsent: ["missing", "ghost"],
311
+ expectUnobserved: ["scrape"],
312
+ },
313
+ {
314
+ name: "owned: true withholds what carries no marker",
315
+ declared: [...ALL.keys()],
316
+ owned: true,
317
+ run: () => {
318
+ ruler = rulerState();
319
+ return run({ owned: true });
320
+ },
321
+ expectAbsent: ["missing", "ghost"],
322
+ expectUnobserved: ["api", "infra", "team", "settings", "scrape"],
323
+ },
324
+ ],
325
+ });
@@ -0,0 +1,328 @@
1
+ /**
2
+ * `describeResources()` for prometheus (#3371): is each declared rule group
3
+ * loaded on the environment's ruler and is it healthy, and is each declared
4
+ * Alertmanager entity in the config the environment's Alertmanager runs.
5
+ *
6
+ * Runs on core's observer harness. `bind()` resolves the ruler and the
7
+ * Alertmanager (./api/bind.ts), each on its own, so a project with only rule
8
+ * groups is not held up by having no Alertmanager. Verdicts:
9
+ *
10
+ * | Entity | Read | Absent when |
11
+ * |---|---|---|
12
+ * | `RuleGroup` on Mimir, Cortex, Loki | the config API's `/{namespace}/{group}`, in the namespace the profile gives the group | 404 |
13
+ * | `RuleGroup` on a plain Prometheus | `/api/v1/rules`, by name (and rule file, when the profile names one) | not listed |
14
+ * | `Receiver`, `TimeInterval` | the loaded Alertmanager config, by name | no entry of that name |
15
+ * | `Route` | the loaded routing tree, by receiver and matchers | no route with both |
16
+ * | `InhibitRule` | the loaded inhibit rules, by matchers and `equal` | no equal rule |
17
+ * | `AlertmanagerSettings` | the loaded config | no config (Mimir: the tenant has none) |
18
+ * | `ScrapeConfig`, `PrometheusConfig` | none: `prometheus.yml` is a file on disk | `unsupported-kind` |
19
+ *
20
+ * A group's health is from `/api/v1/rules` (./api/evaluated.ts): `health`
21
+ * (`ok`, `err`, `unknown`), the first rule error, and how many alerts are
22
+ * firing or pending. A failed health read leaves the group present with
23
+ * `health: "unknown"` and the reason; presence is the config API's answer.
24
+ * The Alertmanager's cluster status and version ride on its entities.
25
+ *
26
+ * Ownership: a rule group and an Alertmanager config carry no marker
27
+ * (./serializer.ts), so every verdict is `unknown`, and an `owned: true`
28
+ * read withholds them as `filtered`. What bounds chant on a ruler is the
29
+ * namespace: groups are only read in the namespaces the profile declares
30
+ * (./config.ts).
31
+ */
32
+
33
+ import { createHash } from "node:crypto";
34
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
35
+ import {
36
+ observeEntities,
37
+ type DeclaredEntity,
38
+ type DescribeResourcesResult,
39
+ type EntityObservation,
40
+ type ObserverAdapter,
41
+ } from "@intentius/chant/observation";
42
+ import { bindEndpoints, classifyPromFailure, type BindOptions, type BoundEndpoints } from "./api/bind";
43
+ import { PromApiError } from "./api/client";
44
+ import { groupHealth } from "./api/evaluated";
45
+ import type { AlertmanagerApi } from "./api/alertmanager";
46
+ import type { RulerApi } from "./api/ruler";
47
+ import { isUnresolvedTarget, namespaceOfGroup, type UnresolvedTarget } from "./config";
48
+ import { INHIBIT_RULE_TYPE, RECEIVER_TYPE, ROUTE_TYPE, SETTINGS_TYPE, TIME_INTERVAL_TYPE } from "./alertmanager";
49
+ import { RULE_GROUP_TYPE } from "./rules";
50
+ import { PROMETHEUS_CONFIG_TYPE, SCRAPE_CONFIG_TYPE } from "./prometheus-config";
51
+ import { loadPrometheusYaml, parseAlertmanagerDocument } from "./import/parser";
52
+ import { stripAlertmanagerDefaults } from "./import/alertmanager-live";
53
+ import type { AlertmanagerConfig, RouteConfig } from "./model";
54
+
55
+ export interface PrometheusObserveOptions extends Omit<BindOptions, "environment"> {
56
+ environment: string;
57
+ buildOutput?: string;
58
+ entityNames: string[];
59
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
60
+ owned?: boolean;
61
+ }
62
+
63
+ type Json = Record<string, unknown>;
64
+
65
+ function isObject(v: unknown): v is Json {
66
+ return typeof v === "object" && v !== null && !Array.isArray(v);
67
+ }
68
+
69
+ /** What `unsupported-kind` says about `prometheus.yml` entities. */
70
+ export const PROMETHEUS_CONFIG_NOT_OBSERVED =
71
+ "prometheus.yml is a file Prometheus reads from disk, not something its API serves for comparison; observe it through the lexicon that deploys it";
72
+
73
+ /** A failed read as the harness's per-entity verdict; anything else is rethrown for the harness to record. */
74
+ function unobservedFrom(err: unknown): EntityObservation {
75
+ if (err instanceof PromApiError) return { unobserved: classifyPromFailure(err) };
76
+ throw err;
77
+ }
78
+
79
+ function unbound(u: UnresolvedTarget): EntityObservation {
80
+ return { unobserved: { reason: u.reason, detail: u.detail } };
81
+ }
82
+
83
+ const FILTERED_DETAIL = "carries no ownership marker, so an owned-only read cannot show it is chant's";
84
+
85
+ // ── rule groups ─────────────────────────────────────────────────────
86
+
87
+ async function healthAttributes(ruler: RulerApi, file: string | undefined, name: string): Promise<Json> {
88
+ try {
89
+ const groups = await ruler.evaluated();
90
+ const g = groups.find((x) => x.name === name && (file === undefined || x.file === file));
91
+ if (!g) return { health: "unknown", healthDetail: `the ruler does not report "${name}" in ${ruler.evaluatedPath} yet` };
92
+ const h = groupHealth(g);
93
+ return {
94
+ health: h.health,
95
+ ...(h.lastError ? { lastError: h.lastError } : {}),
96
+ failing: h.failing,
97
+ firing: h.firing,
98
+ pending: h.pending,
99
+ };
100
+ } catch (err) {
101
+ return { health: "unknown", healthDetail: classifyPromFailure(err).detail };
102
+ }
103
+ }
104
+
105
+ async function observeRuleGroup(ruler: RulerApi, entity: DeclaredEntity, owned: boolean | undefined): Promise<EntityObservation> {
106
+ const name = typeof entity.props.name === "string" ? entity.props.name : entity.name;
107
+ if (ruler.target.kind === "prometheus") {
108
+ const file = namespaceOfGroup(ruler.target, name);
109
+ let groups;
110
+ try {
111
+ groups = await ruler.evaluated();
112
+ } catch (err) {
113
+ return unobservedFrom(err);
114
+ }
115
+ const queried = ruler.evaluatedPath;
116
+ const g = groups.find((x) => x.name === name && (file === undefined || x.file === file));
117
+ if (!g) return { absent: true, queried };
118
+ if (owned) return { unobserved: { reason: "filtered", detail: `rule group "${name}" is loaded but ${FILTERED_DETAIL}` }, queried };
119
+ const h = groupHealth(g);
120
+ return {
121
+ present: {
122
+ type: RULE_GROUP_TYPE,
123
+ physicalId: `${g.file}/${name}`,
124
+ status: "PRESENT",
125
+ ownership: "unknown",
126
+ attributes: {
127
+ file: g.file,
128
+ rules: g.rules.length,
129
+ health: h.health,
130
+ ...(h.lastError ? { lastError: h.lastError } : {}),
131
+ failing: h.failing,
132
+ firing: h.firing,
133
+ pending: h.pending,
134
+ },
135
+ },
136
+ queried,
137
+ };
138
+ }
139
+
140
+ const namespace = namespaceOfGroup(ruler.target, name);
141
+ if (namespace === undefined) {
142
+ return { unobserved: { reason: "no-binding", detail: `${ruler.target.source} names no namespace for rule group "${name}"` } };
143
+ }
144
+ const queried = ruler.groupPath(namespace, name);
145
+ let raw;
146
+ try {
147
+ raw = await ruler.readGroup(namespace, name);
148
+ } catch (err) {
149
+ return unobservedFrom(err);
150
+ }
151
+ if (!raw) return { absent: true, queried };
152
+ if (owned) return { unobserved: { reason: "filtered", detail: `rule group "${name}" is in namespace "${namespace}" but ${FILTERED_DETAIL}` }, queried };
153
+ const meta: ResourceMetadata = {
154
+ type: RULE_GROUP_TYPE,
155
+ physicalId: `${namespace}/${name}`,
156
+ status: "PRESENT",
157
+ ownership: "unknown",
158
+ attributes: {
159
+ namespace,
160
+ rules: Array.isArray(raw.rules) ? raw.rules.length : 0,
161
+ ...(typeof raw.interval === "string" ? { interval: raw.interval } : {}),
162
+ ...(await healthAttributes(ruler, namespace, name)),
163
+ },
164
+ };
165
+ return { present: meta, queried };
166
+ }
167
+
168
+ // ── Alertmanager ────────────────────────────────────────────────────
169
+
170
+ interface LoadedAlertmanager {
171
+ config: AlertmanagerConfig;
172
+ address: string;
173
+ digest: string;
174
+ health: Json;
175
+ }
176
+
177
+ async function loadAlertmanager(am: AlertmanagerApi): Promise<LoadedAlertmanager | undefined> {
178
+ return am.client.once("observe:config", async () => {
179
+ const live = await am.readConfig();
180
+ if (!live) return undefined;
181
+ const loaded = loadPrometheusYaml(live.text);
182
+ if (!isObject(loaded)) throw new PromApiError(502, "GET", live.address, "the Alertmanager config is not a YAML mapping");
183
+ const doc = live.remarshalled ? stripAlertmanagerDefaults(loaded) : loaded;
184
+ const health = (await am.health()) ?? {};
185
+ return {
186
+ config: parseAlertmanagerDocument(doc).config,
187
+ address: live.address,
188
+ digest: createHash("sha256").update(live.text).digest("hex").slice(0, 16),
189
+ health: { ...health },
190
+ };
191
+ });
192
+ }
193
+
194
+ /** `name="value"`, whatever spacing and quoting it was written with. */
195
+ function normalMatcher(m: string): string {
196
+ const match = /^\s*([^=!~\s]+)\s*(=~|!~|!=|=)\s*(.*?)\s*$/.exec(m);
197
+ if (!match) return m.trim();
198
+ let value = match[3];
199
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
200
+ try {
201
+ value = JSON.parse(value) as string;
202
+ } catch {
203
+ value = value.slice(1, -1);
204
+ }
205
+ }
206
+ return `${match[1]}${match[2]}${JSON.stringify(value)}`;
207
+ }
208
+
209
+ function matcherKey(list: unknown): string {
210
+ return (Array.isArray(list) ? list.map((m) => normalMatcher(String(m))) : []).sort().join(",");
211
+ }
212
+
213
+ function nameOf(ref: unknown): string | undefined {
214
+ if (typeof ref === "string") return ref;
215
+ if (isObject(ref) && isObject(ref.props) && typeof ref.props.name === "string") return ref.props.name;
216
+ return undefined;
217
+ }
218
+
219
+ function routeKey(receiver: unknown, matchers: unknown): string {
220
+ return `${nameOf(receiver) ?? ""}{${matcherKey(matchers)}}`;
221
+ }
222
+
223
+ function routesOf(root: RouteConfig | undefined): RouteConfig[] {
224
+ if (!root) return [];
225
+ const out: RouteConfig[] = [root];
226
+ for (const child of root.routes ?? []) out.push(...routesOf(child));
227
+ return out;
228
+ }
229
+
230
+ function inhibitKey(r: unknown): string {
231
+ const rule = isObject(r) ? r : {};
232
+ const equal = Array.isArray(rule.equal) ? [...rule.equal].map(String).sort().join(",") : "";
233
+ return `${matcherKey(rule.source_matchers)}=>${matcherKey(rule.target_matchers)}/${equal}`;
234
+ }
235
+
236
+ async function observeAlertmanagerEntity(am: AlertmanagerApi, entity: DeclaredEntity, owned: boolean | undefined): Promise<EntityObservation> {
237
+ let loaded;
238
+ try {
239
+ loaded = await loadAlertmanager(am);
240
+ } catch (err) {
241
+ return unobservedFrom(err);
242
+ }
243
+ const queried = am.target.kind === "alertmanager" ? am.statusPath : "/api/v1/alerts";
244
+ if (!loaded) return { absent: true, queried };
245
+ const { config, health } = loaded;
246
+ const props = entity.props;
247
+
248
+ let physicalId: string | undefined;
249
+ let attributes: Json = {};
250
+ switch (entity.type) {
251
+ case RECEIVER_TYPE: {
252
+ const r = (config.receivers ?? []).find((x) => x.name === props.name);
253
+ if (r) {
254
+ physicalId = `receiver/${r.name}`;
255
+ attributes = { integrations: Object.keys(r).filter((k) => k.endsWith("_configs")).sort() };
256
+ }
257
+ break;
258
+ }
259
+ case TIME_INTERVAL_TYPE: {
260
+ const t = (config.time_intervals ?? []).find((x) => x.name === props.name);
261
+ if (t) physicalId = `time_interval/${t.name}`;
262
+ break;
263
+ }
264
+ case ROUTE_TYPE: {
265
+ const key = routeKey(props.receiver, props.matchers);
266
+ const r = routesOf(config.route).find((x) => routeKey(x.receiver, x.matchers) === key);
267
+ if (r) {
268
+ physicalId = `route/${key}`;
269
+ attributes = { receiver: r.receiver ?? "", routes: (r.routes ?? []).length };
270
+ }
271
+ break;
272
+ }
273
+ case INHIBIT_RULE_TYPE: {
274
+ const key = inhibitKey(props);
275
+ const i = (config.inhibit_rules ?? []).findIndex((x) => inhibitKey(x) === key);
276
+ if (i >= 0) physicalId = `inhibit_rule/${i}`;
277
+ break;
278
+ }
279
+ case SETTINGS_TYPE:
280
+ physicalId = "alertmanager";
281
+ attributes = { configDigest: loaded.digest, ...(config.templates ? { templates: config.templates.length } : {}) };
282
+ break;
283
+ default:
284
+ return { unobserved: { reason: "unsupported-kind", detail: `no prometheus reader for ${entity.type}` } };
285
+ }
286
+ if (physicalId === undefined) return { absent: true, queried };
287
+ if (owned) return { unobserved: { reason: "filtered", detail: `${entity.name} is in the loaded Alertmanager config but ${FILTERED_DETAIL}` }, queried };
288
+ return {
289
+ present: { type: entity.type, physicalId, status: "PRESENT", ownership: "unknown", attributes: { ...attributes, ...health } },
290
+ queried,
291
+ };
292
+ }
293
+
294
+ // ── harness ─────────────────────────────────────────────────────────
295
+
296
+ const ALERTMANAGER_TYPES = new Set([RECEIVER_TYPE, ROUTE_TYPE, INHIBIT_RULE_TYPE, TIME_INTERVAL_TYPE, SETTINGS_TYPE]);
297
+
298
+ function adapter(options: PrometheusObserveOptions): ObserverAdapter<BoundEndpoints> {
299
+ return {
300
+ bind: () => bindEndpoints({ ...options }),
301
+ classifyBindFailure: (err) => classifyPromFailure(err),
302
+ async read(endpoints, entity): Promise<EntityObservation> {
303
+ if (entity.type === RULE_GROUP_TYPE) {
304
+ return isUnresolvedTarget(endpoints.ruler) ? unbound(endpoints.ruler) : observeRuleGroup(endpoints.ruler, entity, options.owned);
305
+ }
306
+ if (ALERTMANAGER_TYPES.has(entity.type)) {
307
+ return isUnresolvedTarget(endpoints.alertmanager)
308
+ ? unbound(endpoints.alertmanager)
309
+ : observeAlertmanagerEntity(endpoints.alertmanager, entity, options.owned);
310
+ }
311
+ if (entity.type === SCRAPE_CONFIG_TYPE || entity.type === PROMETHEUS_CONFIG_TYPE) {
312
+ return { unobserved: { reason: "unsupported-kind", detail: PROMETHEUS_CONFIG_NOT_OBSERVED } };
313
+ }
314
+ return { unobserved: { reason: "unsupported-kind", detail: `no prometheus reader for ${entity.type}` } };
315
+ },
316
+ };
317
+ }
318
+
319
+ /** Observe every declared prometheus entity against the environment's ruler and Alertmanager. */
320
+ export async function describeResources(options: PrometheusObserveOptions): Promise<DescribeResourcesResult> {
321
+ const declared: DeclaredEntity[] = [];
322
+ for (const name of options.entityNames) {
323
+ const entity = options.entities.get(name);
324
+ if (!entity) continue;
325
+ declared.push({ name, type: entity.entityType, props: entity.props });
326
+ }
327
+ return observeEntities(declared, adapter(options));
328
+ }