@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,291 @@
1
+ /**
2
+ * Alertmanager's `/api/v2/status` config, back to the shape a person writes
3
+ * (#3371).
4
+ *
5
+ * `config.original` is the loaded config marshalled again, not the file
6
+ * (`Config.String()` in Alertmanager's `config/config.go`). Against
7
+ * Alertmanager v0.34.1 (`PROMETHEUS_PIN.alertmanager`) that adds three
8
+ * kinds of thing a file did not say:
9
+ *
10
+ * - `global:` with every default of `DefaultGlobalConfig()` written out:
11
+ * `resolve_timeout: 5m`, the default `http_config`, `smtp_hello`,
12
+ * `smtp_require_tls`, `smtp_tls_config` and the integration API URLs.
13
+ * - every receiver integration with the settings it inherited from `global:`
14
+ * (`http_config`, an email config's `smarthost`, `from` and SMTP auth, a
15
+ * Slack config's `api_url`, a PagerDuty config's `url`, and so on), as
16
+ * `Config.UnmarshalYAML` copies them in.
17
+ * - every integration's own defaults (`DefaultEmailConfig`,
18
+ * `DefaultSlackConfig`, `DefaultPagerdutyConfig`, ...) and the zero values
19
+ * of fields marshalled without `omitempty` (`url_file: ""`,
20
+ * `max_alerts: 0`, `continue: false`).
21
+ *
22
+ * {@link stripAlertmanagerDefaults} takes those out, so an import writes
23
+ * what a person would have declared. It drops a value only where it equals
24
+ * the default or the global it was copied from; a value someone set to the
25
+ * default is indistinguishable from one they left out, and goes too, which
26
+ * builds to the same config. `verbatim` skips it. Secrets read `<secret>`
27
+ * either way (see {@link maskedSecretPaths}).
28
+ */
29
+
30
+ type Json = Record<string, unknown>;
31
+
32
+ function isObject(v: unknown): v is Json {
33
+ return typeof v === "object" && v !== null && !Array.isArray(v);
34
+ }
35
+
36
+ function canonical(v: unknown): unknown {
37
+ if (Array.isArray(v)) return v.map(canonical);
38
+ if (isObject(v)) return Object.fromEntries(Object.keys(v).sort().map((k) => [k, canonical(v[k])]));
39
+ return v;
40
+ }
41
+
42
+ function same(a: unknown, b: unknown): boolean {
43
+ return JSON.stringify(canonical(a)) === JSON.stringify(canonical(b));
44
+ }
45
+
46
+ /** Empty, false, zero or null: what a field marshalled without `omitempty` holds when nobody set it. */
47
+ function isZero(v: unknown): boolean {
48
+ if (v === null || v === undefined || v === "" || v === false || v === 0) return true;
49
+ if (Array.isArray(v)) return v.length === 0;
50
+ if (isObject(v)) return Object.values(v).every(isZero);
51
+ return false;
52
+ }
53
+
54
+ /** `commoncfg.DefaultHTTPClientConfig`, marshalled. */
55
+ const DEFAULT_HTTP_CONFIG = { follow_redirects: true, enable_http2: true };
56
+
57
+ function isDefaultHttpConfig(v: unknown): boolean {
58
+ if (!isObject(v)) return false;
59
+ const rest = Object.fromEntries(Object.entries(v).filter(([k]) => !(k in DEFAULT_HTTP_CONFIG)));
60
+ return Object.entries(DEFAULT_HTTP_CONFIG).every(([k, d]) => v[k] === undefined || v[k] === d) && isZero(rest);
61
+ }
62
+
63
+ /** `DefaultGlobalConfig()` at the pin. `http_config` and `smtp_tls_config` are compared by {@link isDefaultHttpConfig} and {@link isZero}. */
64
+ const GLOBAL_DEFAULTS: Json = {
65
+ resolve_timeout: "5m",
66
+ smtp_hello: "localhost",
67
+ smtp_require_tls: true,
68
+ pagerduty_url: "https://events.pagerduty.com/v2/enqueue",
69
+ opsgenie_api_url: "https://api.opsgenie.com/",
70
+ wechat_api_url: "https://qyapi.weixin.qq.com/cgi-bin/",
71
+ victorops_api_url: "https://alert.victorops.com/integrations/generic/20131114/alert/",
72
+ telegram_api_url: "https://api.telegram.org",
73
+ webex_api_url: "https://webexapis.com/v1/messages",
74
+ rocketchat_api_url: "https://open.rocket.chat/",
75
+ slack_app_url: "https://slack.com/api/chat.postMessage",
76
+ };
77
+
78
+ /** Fields `Config.UnmarshalYAML` copies from `global:` into each integration, integration field to global key. */
79
+ const INHERITED: Record<string, Record<string, string>> = {
80
+ email_configs: {
81
+ tls_config: "smtp_tls_config",
82
+ smarthost: "smtp_smarthost",
83
+ from: "smtp_from",
84
+ hello: "smtp_hello",
85
+ auth_username: "smtp_auth_username",
86
+ auth_password: "smtp_auth_password",
87
+ auth_password_file: "smtp_auth_password_file",
88
+ auth_secret: "smtp_auth_secret",
89
+ auth_secret_file: "smtp_auth_secret_file",
90
+ auth_identity: "smtp_auth_identity",
91
+ require_tls: "smtp_require_tls",
92
+ force_implicit_tls: "smtp_force_implicit_tls",
93
+ },
94
+ slack_configs: {
95
+ app_url: "slack_app_url",
96
+ app_token: "slack_app_token",
97
+ app_token_file: "slack_app_token_file",
98
+ api_url: "slack_api_url",
99
+ api_url_file: "slack_api_url_file",
100
+ },
101
+ pagerduty_configs: { url: "pagerduty_url" },
102
+ opsgenie_configs: { api_url: "opsgenie_api_url", api_key: "opsgenie_api_key", api_key_file: "opsgenie_api_key_file" },
103
+ wechat_configs: { api_url: "wechat_api_url", api_secret: "wechat_api_secret", api_secret_file: "wechat_api_secret_file", corp_id: "wechat_api_corp_id" },
104
+ victorops_configs: { api_url: "victorops_api_url", api_key: "victorops_api_key", api_key_file: "victorops_api_key_file" },
105
+ telegram_configs: { api_url: "telegram_api_url", bot_token: "telegram_bot_token", bot_token_file: "telegram_bot_token_file" },
106
+ webex_configs: { api_url: "webex_api_url" },
107
+ rocketchat_configs: {
108
+ api_url: "rocketchat_api_url",
109
+ token: "rocketchat_token",
110
+ token_file: "rocketchat_token_file",
111
+ token_id: "rocketchat_token_id",
112
+ token_id_file: "rocketchat_token_id_file",
113
+ },
114
+ mattermost_configs: { webhook_url: "mattermost_webhook_url", webhook_url_file: "mattermost_webhook_url_file" },
115
+ jira_configs: { api_url: "jira_api_url" },
116
+ };
117
+
118
+ /** Each integration's `Default<Kind>Config` at the pin, the fields it sets. Kinds not listed keep what they read. */
119
+ const INTEGRATION_DEFAULTS: Record<string, Json> = {
120
+ webhook_configs: { send_resolved: true },
121
+ email_configs: { send_resolved: false, html: '{{ template "email.default.html" . }}' },
122
+ slack_configs: {
123
+ send_resolved: false,
124
+ color: '{{ template "slack.default.color" . }}',
125
+ username: '{{ template "slack.default.username" . }}',
126
+ title: '{{ template "slack.default.title" . }}',
127
+ title_link: '{{ template "slack.default.titlelink" . }}',
128
+ icon_emoji: '{{ template "slack.default.iconemoji" . }}',
129
+ icon_url: '{{ template "slack.default.iconurl" . }}',
130
+ pretext: '{{ template "slack.default.pretext" . }}',
131
+ text: '{{ template "slack.default.text" . }}',
132
+ fallback: '{{ template "slack.default.fallback" . }}',
133
+ callback_id: '{{ template "slack.default.callbackid" . }}',
134
+ footer: '{{ template "slack.default.footer" . }}',
135
+ },
136
+ pagerduty_configs: {
137
+ send_resolved: true,
138
+ description: '{{ template "pagerduty.default.description" .}}',
139
+ client: '{{ template "pagerduty.default.client" . }}',
140
+ client_url: '{{ template "pagerduty.default.clientURL" . }}',
141
+ },
142
+ webex_configs: { send_resolved: true, message: '{{ template "webex.default.message" . }}' },
143
+ rocketchat_configs: {
144
+ send_resolved: false,
145
+ color: '{{ if eq .Status "firing" }}red{{ else }}green{{ end }}',
146
+ emoji: '{{ template "rocketchat.default.emoji" . }}',
147
+ icon_url: '{{ template "rocketchat.default.iconurl" . }}',
148
+ text: '{{ template "rocketchat.default.text" . }}',
149
+ title: '{{ template "rocketchat.default.title" . }}',
150
+ title_link: '{{ template "rocketchat.default.titlelink" . }}',
151
+ },
152
+ wechat_configs: {
153
+ send_resolved: false,
154
+ message: '{{ template "wechat.default.message" . }}',
155
+ to_user: '{{ template "wechat.default.to_user" . }}',
156
+ to_party: '{{ template "wechat.default.to_party" . }}',
157
+ to_tag: '{{ template "wechat.default.to_tag" . }}',
158
+ agent_id: '{{ template "wechat.default.agent_id" . }}',
159
+ },
160
+ victorops_configs: {
161
+ send_resolved: true,
162
+ message_type: "CRITICAL",
163
+ state_message: '{{ template "victorops.default.state_message" . }}',
164
+ entity_display_name: '{{ template "victorops.default.entity_display_name" . }}',
165
+ monitoring_tool: '{{ template "victorops.default.monitoring_tool" . }}',
166
+ },
167
+ pushover_configs: {
168
+ send_resolved: true,
169
+ title: '{{ template "pushover.default.title" . }}',
170
+ message: '{{ template "pushover.default.message" . }}',
171
+ url: '{{ template "pushover.default.url" . }}',
172
+ priority: '{{ if eq .Status "firing" }}2{{ else }}0{{ end }}',
173
+ retry: "1m",
174
+ expire: "1h",
175
+ },
176
+ sns_configs: { send_resolved: true, subject: '{{ template "sns.default.subject" . }}', message: '{{ template "sns.default.message" . }}' },
177
+ };
178
+
179
+ /** `DefaultEmailSubject`, which an email config's `headers` gain when they set no Subject. */
180
+ const DEFAULT_EMAIL_SUBJECT = '{{ template "email.default.subject" . }}';
181
+
182
+ /** `DefaultPagerdutyDetails`, merged into every PagerDuty config's `details`. */
183
+ const DEFAULT_PAGERDUTY_DETAILS: Json = {
184
+ firing: "{{ .Alerts.Firing | toJson }}",
185
+ resolved: "{{ .Alerts.Resolved | toJson }}",
186
+ num_firing: "{{ .Alerts.Firing | len }}",
187
+ num_resolved: "{{ .Alerts.Resolved | len }}",
188
+ };
189
+
190
+ function stripIntegration(kind: string, raw: Json, global: Json): Json {
191
+ const out: Json = { ...raw };
192
+ const inherited = INHERITED[kind] ?? {};
193
+ const defaults = INTEGRATION_DEFAULTS[kind] ?? {};
194
+ for (const [field, globalKey] of Object.entries(inherited)) {
195
+ if (field in out && global[globalKey] !== undefined && same(out[field], global[globalKey])) delete out[field];
196
+ else if (field in out && global[globalKey] === undefined && isZero(out[field]) && field !== "require_tls") delete out[field];
197
+ }
198
+ if ("http_config" in out && (global.http_config !== undefined ? same(out.http_config, global.http_config) : isDefaultHttpConfig(out.http_config))) {
199
+ delete out.http_config;
200
+ }
201
+ for (const [field, d] of Object.entries(defaults)) {
202
+ if (field in out && same(out[field], d)) delete out[field];
203
+ }
204
+ if (kind === "email_configs" && isObject(out.headers)) {
205
+ const headers = { ...out.headers };
206
+ if (headers.Subject === DEFAULT_EMAIL_SUBJECT) delete headers.Subject;
207
+ if (Object.keys(headers).length === 0) delete out.headers;
208
+ else out.headers = headers;
209
+ }
210
+ if (kind === "pagerduty_configs" && isObject(out.details)) {
211
+ const details = Object.fromEntries(Object.entries(out.details).filter(([k, v]) => !same(v, DEFAULT_PAGERDUTY_DETAILS[k])));
212
+ if (Object.keys(details).length === 0) delete out.details;
213
+ else out.details = details;
214
+ }
215
+ // What is left at its zero value was marshalled without omitempty. A field with a known default or a global to
216
+ // inherit from survived the comparisons above because it differs (`send_resolved: false` on a webhook,
217
+ // `require_tls: false` under a global `true`), and `send_resolved` of a kind with no known default stays.
218
+ for (const [k, v] of Object.entries(out)) {
219
+ if (k === "send_resolved" || k in defaults) continue;
220
+ if (k in inherited && global[inherited[k]] !== undefined) continue;
221
+ if (isZero(v)) delete out[k];
222
+ }
223
+ return out;
224
+ }
225
+
226
+ function stripRoute(raw: unknown): unknown {
227
+ if (!isObject(raw)) return raw;
228
+ const out: Json = {};
229
+ for (const [k, v] of Object.entries(raw)) {
230
+ if (k === "routes" && Array.isArray(v)) {
231
+ if (v.length > 0) out.routes = v.map(stripRoute);
232
+ continue;
233
+ }
234
+ if (isZero(v)) continue;
235
+ out[k] = v;
236
+ }
237
+ return out;
238
+ }
239
+
240
+ function stripGlobal(raw: Json): Json {
241
+ const out: Json = {};
242
+ for (const [k, v] of Object.entries(raw)) {
243
+ if (k in GLOBAL_DEFAULTS && same(v, GLOBAL_DEFAULTS[k])) continue;
244
+ if (k === "http_config" && isDefaultHttpConfig(v)) continue;
245
+ if (k === "smtp_require_tls" && v === false) {
246
+ out[k] = v;
247
+ continue;
248
+ }
249
+ if (isZero(v)) continue;
250
+ out[k] = v;
251
+ }
252
+ return out;
253
+ }
254
+
255
+ /** The re-marshalled config with what Alertmanager added taken out (see the module comment). Pure; `doc` is not changed. */
256
+ export function stripAlertmanagerDefaults(doc: Json): Json {
257
+ const global = isObject(doc.global) ? doc.global : {};
258
+ const out: Json = {};
259
+ for (const [section, value] of Object.entries(doc)) {
260
+ if (section === "global") {
261
+ const g = stripGlobal(global);
262
+ if (Object.keys(g).length > 0) out.global = g;
263
+ } else if (section === "receivers" && Array.isArray(value)) {
264
+ out.receivers = value.map((r) => {
265
+ if (!isObject(r)) return r;
266
+ const receiver: Json = {};
267
+ for (const [k, v] of Object.entries(r)) {
268
+ if (k.endsWith("_configs") && Array.isArray(v)) {
269
+ if (v.length > 0) receiver[k] = v.map((c) => (isObject(c) ? stripIntegration(k, c, global) : c));
270
+ } else if (k === "name" || !isZero(v)) receiver[k] = v;
271
+ }
272
+ return receiver;
273
+ });
274
+ } else if (section === "route") {
275
+ out.route = stripRoute(value);
276
+ } else if (section === "inhibit_rules" && Array.isArray(value)) {
277
+ if (value.length > 0) out.inhibit_rules = value.map((r) => (isObject(r) ? Object.fromEntries(Object.entries(r).filter(([, v]) => !isZero(v))) : r));
278
+ } else if (!isZero(value)) {
279
+ out[section] = value;
280
+ }
281
+ }
282
+ return out;
283
+ }
284
+
285
+ /** The paths that read `<secret>`, the mask Alertmanager writes over every secret it marshals. */
286
+ export function maskedSecretPaths(doc: unknown, at = ""): string[] {
287
+ if (doc === "<secret>") return [at];
288
+ if (Array.isArray(doc)) return doc.flatMap((v, i) => maskedSecretPaths(v, `${at}[${i}]`));
289
+ if (isObject(doc)) return Object.entries(doc).flatMap(([k, v]) => maskedSecretPaths(v, at ? `${at}.${k}` : k));
290
+ return [];
291
+ }
@@ -386,10 +386,27 @@ const PROM_VERSION = PROMETHEUS_PIN.prometheus.version;
386
386
 
387
387
  // ── entry points ────────────────────────────────────────────────────
388
388
 
389
+ /** Load YAML the way the parser does: YAML 1.2 core types plus `<<` merge keys. */
390
+ export function loadPrometheusYaml(content: string): unknown {
391
+ return content.trim() === "" ? {} : jsYaml.load(content, { schema: YAML_SCHEMA });
392
+ }
393
+
394
+ /** A rule file already loaded (live import builds one from a ruler's groups, ../export-resources.ts). */
395
+ export function parseRuleFileDocument(doc: Record<string, unknown>): { file: RuleFileConfig; warnings: string[] } {
396
+ const warnings: string[] = [];
397
+ return { file: parseRuleFile(doc, warnings), warnings };
398
+ }
399
+
400
+ /** An `alertmanager.yml` already loaded (live import reads one from Alertmanager's API, ../export-resources.ts). */
401
+ export function parseAlertmanagerDocument(doc: Record<string, unknown>): { config: AlertmanagerConfig; warnings: string[] } {
402
+ const warnings: string[] = [];
403
+ return { config: parseAlertmanager(doc, warnings), warnings };
404
+ }
405
+
389
406
  /** Parse a rule file or `alertmanager.yml`, telling them apart by shape. */
390
407
  export function parsePrometheusYaml(content: string): ParsedPrometheusFile {
391
408
  const warnings: string[] = [];
392
- const doc = content.trim() === "" ? {} : jsYaml.load(content, { schema: YAML_SCHEMA });
409
+ const doc = loadPrometheusYaml(content);
393
410
  if (!isPlainObject(doc)) {
394
411
  throw new Error("a Prometheus rule file, alertmanager.yml or prometheus.yml is a YAML mapping; this document is not one");
395
412
  }
@@ -175,6 +175,7 @@ describe("YAML -> TypeScript -> YAML", () => {
175
175
  "getting-started/rules.yml",
176
176
  "k3d-stack/rules.yml",
177
177
  "k3d-stack/alertmanager.yml",
178
+ "observe-converge/rules.yml",
178
179
  "rules-from-data/rules.yml",
179
180
  "slo/rules.yml",
180
181
  "slo/alertmanager.yml",
package/src/index.ts CHANGED
@@ -128,4 +128,41 @@ export {
128
128
  type GenAiBudget,
129
129
  type GenAiAlertInfo,
130
130
  type GenAiQuantileSeries,
131
+ RedAlerts,
132
+ redAlertRules,
133
+ RED_ALERT_NAMES,
134
+ type RedAlertsProps,
135
+ type RedAlertsMembers,
136
+ type RedAlertsInstance,
137
+ type RedAlertOptions,
138
+ type RedErrorRatioAlert,
139
+ type RedLatencyAlert,
140
+ Watchdog,
141
+ WATCHDOG_URL_FILE,
142
+ type WatchdogProps,
143
+ type WatchdogMembers,
144
+ type WatchdogInstance,
145
+ AlertRouting,
146
+ ALERT_ROUTING_LEVELS,
147
+ type AlertRoutingProps,
148
+ type AlertRoutingMembers,
149
+ type AlertRoutingInstance,
150
+ type AlertRoutingLevel,
151
+ type AlertRoutingTeam,
152
+ type AlertRoutingTiming,
153
+ type AlertRoutingReceiver,
131
154
  } from "./composites";
155
+
156
+ // Config namespace (#3371): `prometheus.profiles.<env>` in chant.config.ts, the
157
+ // ruler and Alertmanager each environment observes and imports from. The API
158
+ // clients are `@intentius/chant-lexicon-prometheus/api`.
159
+ export { prometheusConfigSchema, resolveRulerTarget, resolveAlertmanagerTarget, declaredNamespaces, namespaceOfGroup } from "./config";
160
+ export type { PrometheusLexiconConfig, PrometheusProfile, RulerProfile, AlertmanagerProfile, RulerKind, AlertmanagerKind } from "./config";
161
+
162
+ // Op steps (#3369). The activities resolve from
163
+ // `@intentius/chant-lexicon-prometheus/op/activities`; the promtool and amtool
164
+ // check builders share their names with the functions above, so they are at
165
+ // `@intentius/chant-lexicon-prometheus/op/builders`.
166
+ export { amtoolRoutesTest, alertmanagerSilence, alertmanagerUnsilence, rulesLoadedObserve, ruleAudit } from "./op/builders";
167
+ export { RuleAuditOp, type RuleAuditOpConfig, type RuleAuditOpResources } from "./op/audit-op";
168
+ export { sloRuleTests, type SloTestInput } from "./op/slo-rule-tests";
@@ -5,9 +5,9 @@
5
5
  *
6
6
  * Documents are recognized by shape, not by which lexicon emitted them, so a
7
7
  * rule file that reaches the output some other way (a hand-written YAML
8
- * sidecar) is checked too. `PrometheusRule` manifests are not read here;
9
- * their groups are the same `RuleGroup`s and are checked where they are
10
- * declared.
8
+ * sidecar) is checked too. `PrometheusRule` manifests are not read here:
9
+ * `chant build` hands each lexicon's checks only that lexicon's output, so the
10
+ * k8s lexicon's WK8606 runs `prometheusRuleDiagnostics` (below) over them.
11
11
  */
12
12
 
13
13
  import type { PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
@@ -135,3 +135,38 @@ export function routingDiagnostics(ctx: PostSynthContext): PostSynthDiagnostic[]
135
135
  const files = ruleFiles.map((r) => r.config);
136
136
  return alertmanager.flatMap(({ source, config }) => validateSeverityRouting(files, config).map((i) => toDiagnostic(i, source)));
137
137
  }
138
+
139
+ /**
140
+ * The rule-file checks (PROM101-PROM107, PROM211, PROM213-PROM219) over the
141
+ * groups of every `PrometheusRule` in the build, whichever lexicon emitted
142
+ * it. `spec.groups` is a rule file's `groups`, so `validateRuleFile` reads it
143
+ * as is. Findings keep their PROM ids and name the PrometheusRule's namespace
144
+ * and name. PROM212 (runbook URLs) stays opt-in and is not run here.
145
+ */
146
+ export function prometheusRuleDiagnostics(ctx: PostSynthContext): PostSynthDiagnostic[] {
147
+ const out: PostSynthDiagnostic[] = [];
148
+ for (const output of ctx.outputs.values()) {
149
+ const text = typeof output === "string" ? output : (output as SerializerResult).primary;
150
+ if (!text) continue;
151
+ let docs: unknown[];
152
+ try {
153
+ docs = loadAll(text);
154
+ } catch {
155
+ continue;
156
+ }
157
+ for (const raw of docs) {
158
+ const doc = raw as Record<string, any> | null;
159
+ if (!doc || typeof doc !== "object" || doc.kind !== "PrometheusRule") continue;
160
+ if (typeof doc.apiVersion !== "string" || !doc.apiVersion.startsWith("monitoring.coreos.com/")) continue;
161
+ const groups = doc.spec?.groups;
162
+ if (!Array.isArray(groups)) continue;
163
+ const name = String(doc.metadata?.name ?? "");
164
+ const source = `PrometheusRule ${doc.metadata?.namespace ? `${doc.metadata.namespace}/` : ""}${name}`;
165
+ for (const issue of validateRuleFile({ groups } as RuleFileConfig)) {
166
+ if (issue.code === "PROM212") continue;
167
+ out.push({ ...toDiagnostic(issue, source), ...(issue.subject ? {} : { entity: name }) });
168
+ }
169
+ }
170
+ }
171
+ return out;
172
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * prometheus Op activities (#3369), resolved by the core activity registry
3
+ * when a project's `chant.config.ts` lists the `prometheus` lexicon. Every
4
+ * function this module exports is an activity, bound by its export name, so
5
+ * the helpers behind them stay in their own files.
6
+ *
7
+ * - `promtoolCheckRules`, `promtoolTestRules`, `amtoolCheckConfig`: the
8
+ * upstream tools over built files, failing the step when the tool is
9
+ * missing or rejects the file.
10
+ * - `amtoolRoutesTest`: labels route to the expected receivers.
11
+ * - `alertmanagerSilence`, `alertmanagerUnsilence`: a silence around a
12
+ * change, recorded so the final and `onFailure` phases can expire it.
13
+ * - `rulesLoadedObserve`: declared rule groups as the resources of a
14
+ * `ConvergeOp({ observe })`.
15
+ * - `ruleAudit`: the step of `RuleAuditOp`.
16
+ */
17
+ export { promtoolCheckRules, promtoolTestRules, amtoolCheckConfig, amtoolRoutesTest } from "./promtool";
18
+ export type {
19
+ ToolStepResult,
20
+ PromtoolCheckRulesArgs,
21
+ PromtoolTestRulesArgs,
22
+ AmtoolCheckConfigArgs,
23
+ AmtoolRoutesTestArgs,
24
+ AmtoolRoutesTestResult,
25
+ } from "./promtool";
26
+ export { alertmanagerSilence, alertmanagerUnsilence } from "./silence";
27
+ export type { AlertmanagerSilenceArgs, AlertmanagerSilenceResult, AlertmanagerUnsilenceArgs, AlertmanagerUnsilenceResult } from "./silence";
28
+ export { rulesLoadedObserve } from "./rules-loaded";
29
+ export type { RulesLoadedObserveArgs, RulesLoadedObserveResult } from "./rules-loaded";
30
+ export { ruleAudit } from "./rule-audit";
31
+ export type { RuleAuditArgs, RuleAuditResult, RuleAuditFinding, RuleAuditFindingKind, RuleAuditMode } from "./rule-audit";
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The promtool and amtool steps, against stand-in binaries: small shell
3
+ * scripts that answer `--version` and accept or reject a file by its
4
+ * content, run as the real tools would be (the step's own child process).
5
+ * The real tools are exercised by `tools.test.ts` and `slo-burn.test.ts`
6
+ * when they are on PATH.
7
+ */
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { chmodSync, mkdtempSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import type { ExecRunner } from "@intentius/chant-lexicon-otel/op/activities/exec";
14
+ import { amtoolCheckConfig, amtoolRoutesTest, parseRoutesOutput, promtoolCheckRules, promtoolTestRules } from "./promtool";
15
+
16
+ const dir = mkdtempSync(join(tmpdir(), "chant-promtool-step-"));
17
+
18
+ function script(name: string, body: string): string {
19
+ const path = join(dir, name);
20
+ writeFileSync(path, `#!/bin/sh\n${body}\n`);
21
+ chmodSync(path, 0o755);
22
+ return path;
23
+ }
24
+
25
+ const PROMTOOL = script(
26
+ "promtool",
27
+ `case "$1" in
28
+ --version) echo "promtool, version 3.15.0"; exit 0;;
29
+ check) if grep -q BAD "$3"; then echo "FAILED: $3: bad rule" >&2; exit 1; fi; echo "SUCCESS: $3"; exit 0;;
30
+ test) if grep -q FAIL "$3"; then echo "FAILED: alert did not fire" >&2; exit 1; fi; grep -q "rule_files" "$3" || exit 3; test -f rules.yml || exit 4; echo "SUCCESS"; exit 0;;
31
+ esac
32
+ exit 2`,
33
+ );
34
+ const AMTOOL = script(
35
+ "amtool",
36
+ `case "$1" in
37
+ --version) echo "amtool, version 0.34.1"; exit 0;;
38
+ check-config) if grep -q BAD "$2"; then echo "FAILED" >&2; exit 1; fi; echo "SUCCESS"; exit 0;;
39
+ esac
40
+ exit 2`,
41
+ );
42
+
43
+ function file(name: string, text: string): string {
44
+ const path = join(dir, name);
45
+ writeFileSync(path, text);
46
+ return path;
47
+ }
48
+
49
+ const RULES = file("rules.yml", "groups:\n - name: g\n rules:\n - record: x\n expr: up\n");
50
+
51
+ describe("promtool steps", () => {
52
+ test("check rules passes a good file and fails a bad one with the tool's output", async () => {
53
+ await expect(promtoolCheckRules({ rules: RULES, bin: PROMTOOL })).resolves.toMatchObject({ ok: true, files: [RULES] });
54
+ const bad = file("bad.yml", "BAD");
55
+ await expect(promtoolCheckRules({ rules: [RULES, bad], bin: PROMTOOL })).rejects.toThrow(/promtool check rules rejected .*bad\.yml.*bad rule/s);
56
+ });
57
+
58
+ test("a missing binary fails the step: a check that did not run is not a pass", async () => {
59
+ await expect(promtoolCheckRules({ rules: RULES, bin: join(dir, "no-such-promtool") })).rejects.toThrow(/is not installed/);
60
+ });
61
+
62
+ test("test rules runs once per test file and inline document, each beside rules.yml", async () => {
63
+ const t = file("t.test.yml", "rule_files: [rules.yml]\ntests: []\n");
64
+ const r = await promtoolTestRules({ rules: RULES, tests: t, testYaml: ["rule_files: [rules.yml]\ntests: []\n"], bin: PROMTOOL });
65
+ expect(r.tests).toBe(2);
66
+ await expect(promtoolTestRules({ rules: RULES, testYaml: "rule_files: [rules.yml]\n# FAIL\n", bin: PROMTOOL })).rejects.toThrow(/alert did not fire/);
67
+ await expect(promtoolTestRules({ rules: RULES, bin: PROMTOOL })).rejects.toThrow(/give tests or testYaml/);
68
+ });
69
+
70
+ test("amtool check-config", async () => {
71
+ await expect(amtoolCheckConfig({ config: file("am.yml", "route: {}\n"), bin: AMTOOL })).resolves.toMatchObject({ ok: true });
72
+ await expect(amtoolCheckConfig({ config: file("am-bad.yml", "BAD"), bin: AMTOOL })).rejects.toThrow(/amtool check-config rejected/);
73
+ });
74
+ });
75
+
76
+ describe("amtoolRoutesTest", () => {
77
+ test("reads the receivers amtool prints", () => {
78
+ expect(parseRoutesOutput("oncall\n")).toEqual(["oncall"]);
79
+ expect(parseRoutesOutput("oncall,tickets\n")).toEqual(["oncall", "tickets"]);
80
+ expect(parseRoutesOutput("")).toEqual([]);
81
+ });
82
+
83
+ const exec = (stdout: string, code = 0): { exec: ExecRunner; calls: string[][] } => {
84
+ const calls: string[][] = [];
85
+ return {
86
+ calls,
87
+ exec: async (bin, args) => {
88
+ calls.push([bin, ...args]);
89
+ return { code, stdout, stderr: "" };
90
+ },
91
+ };
92
+ };
93
+
94
+ test("passes when the labels route to the expected receivers", async () => {
95
+ const { exec: e, calls } = exec("oncall\n");
96
+ const r = await amtoolRoutesTest({ config: "/tmp/am.yml", labels: { severity: "page", slo: "checkout" }, expect: "oncall", bin: "amtool", _exec: e });
97
+ expect(r.receivers).toEqual(["oncall"]);
98
+ expect(calls[0]).toEqual(["amtool", "config", "routes", "test", "--config.file=/tmp/am.yml", "severity=page", "slo=checkout"]);
99
+ });
100
+
101
+ test("fails when they route elsewhere, naming both", async () => {
102
+ const { exec: e } = exec("tickets\n");
103
+ await expect(amtoolRoutesTest({ config: "/tmp/am.yml", labels: { severity: "page" }, expect: ["oncall"], _exec: e })).rejects.toThrow(
104
+ /severity=page routes to tickets .* expected oncall/,
105
+ );
106
+ });
107
+ });