@intentius/chant-lexicon-prometheus 0.124.31 → 0.124.33

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 (114) hide show
  1. package/dist/alertmanager.js +61 -0
  2. package/dist/api/alertmanager.js +98 -0
  3. package/dist/api/apply.js +306 -0
  4. package/dist/api/bind.js +66 -0
  5. package/dist/api/client.js +110 -0
  6. package/dist/api/evaluated.js +86 -0
  7. package/dist/api/fake-servers.js +165 -0
  8. package/dist/api/index.js +17 -0
  9. package/dist/api/ruler.js +122 -0
  10. package/dist/build.js +240 -0
  11. package/dist/catalog.js +66 -0
  12. package/dist/codegen/docs-cli.js +3 -0
  13. package/dist/codegen/docs.js +105 -0
  14. package/dist/codegen/generate-cli.js +7 -0
  15. package/dist/codegen/generate.js +42 -0
  16. package/dist/codegen/package.js +28 -0
  17. package/dist/collector-metrics.js +225 -0
  18. package/dist/composites/alert-routing.js +210 -0
  19. package/dist/composites/catalog.js +344 -0
  20. package/dist/composites/genai.js +454 -0
  21. package/dist/composites/index.js +8 -0
  22. package/dist/composites/red-alerts.js +135 -0
  23. package/dist/composites/slo.js +353 -0
  24. package/dist/composites/watchdog.js +110 -0
  25. package/dist/config-model.js +52 -0
  26. package/dist/config.js +225 -0
  27. package/dist/describe-resources.js +299 -0
  28. package/dist/detect.js +12 -0
  29. package/dist/duration.js +54 -0
  30. package/dist/export-resources.js +163 -0
  31. package/dist/import/alertmanager-live.js +309 -0
  32. package/dist/import/embedded.js +100 -0
  33. package/dist/import/generator.js +683 -0
  34. package/dist/import/parser.js +428 -0
  35. package/dist/import/slo.js +207 -0
  36. package/dist/index.js +33 -0
  37. package/dist/init-templates.js +119 -0
  38. package/dist/integrations.js +33 -0
  39. package/dist/integrity.json +2 -2
  40. package/dist/lint/audit-catalog.js +60 -0
  41. package/dist/lint/audit-lineage.js +42 -0
  42. package/dist/lint/post-synth/index.js +68 -0
  43. package/dist/lint/post-synth/prom-helpers.js +157 -0
  44. package/dist/lint/post-synth/prom101.js +13 -0
  45. package/dist/lint/post-synth/prom102.js +13 -0
  46. package/dist/lint/post-synth/prom103.js +13 -0
  47. package/dist/lint/post-synth/prom104.js +13 -0
  48. package/dist/lint/post-synth/prom105.js +13 -0
  49. package/dist/lint/post-synth/prom106.js +13 -0
  50. package/dist/lint/post-synth/prom107.js +13 -0
  51. package/dist/lint/post-synth/prom201.js +13 -0
  52. package/dist/lint/post-synth/prom202.js +13 -0
  53. package/dist/lint/post-synth/prom203.js +13 -0
  54. package/dist/lint/post-synth/prom204.js +13 -0
  55. package/dist/lint/post-synth/prom205.js +13 -0
  56. package/dist/lint/post-synth/prom206.js +13 -0
  57. package/dist/lint/post-synth/prom207.js +13 -0
  58. package/dist/lint/post-synth/prom208.js +13 -0
  59. package/dist/lint/post-synth/prom209.js +13 -0
  60. package/dist/lint/post-synth/prom210.js +13 -0
  61. package/dist/lint/post-synth/prom211.js +17 -0
  62. package/dist/lint/post-synth/prom212.js +14 -0
  63. package/dist/lint/post-synth/prom213.js +16 -0
  64. package/dist/lint/post-synth/prom214.js +14 -0
  65. package/dist/lint/post-synth/prom215.js +14 -0
  66. package/dist/lint/post-synth/prom216.js +15 -0
  67. package/dist/lint/post-synth/prom217.js +14 -0
  68. package/dist/lint/post-synth/prom218.js +15 -0
  69. package/dist/lint/post-synth/prom219.js +14 -0
  70. package/dist/lint/post-synth/prom220.js +14 -0
  71. package/dist/lint/post-synth/prom221.js +15 -0
  72. package/dist/lint/post-synth/prom222.js +14 -0
  73. package/dist/lint/post-synth/prom223.js +15 -0
  74. package/dist/lint/post-synth/prom224.js +14 -0
  75. package/dist/lint/post-synth/prom225.js +15 -0
  76. package/dist/lint/post-synth/prom301.js +19 -0
  77. package/dist/lint/rules/index.js +8 -0
  78. package/dist/lint/rules/literal-credential.js +127 -0
  79. package/dist/lint/rules/prom-ast.js +65 -0
  80. package/dist/lint/rules/promql-literal.js +65 -0
  81. package/dist/lint/rules/slo-literal.js +79 -0
  82. package/dist/lsp/completions.js +11 -0
  83. package/dist/lsp/hover.js +21 -0
  84. package/dist/manifest.json +1 -1
  85. package/dist/matchers.js +131 -0
  86. package/dist/model.js +108 -0
  87. package/dist/op/activities/index.js +20 -0
  88. package/dist/op/activities/promtool.js +87 -0
  89. package/dist/op/activities/rule-audit.js +128 -0
  90. package/dist/op/activities/rules-loaded.js +73 -0
  91. package/dist/op/activities/silence.js +123 -0
  92. package/dist/op/activity-contracts.js +51 -0
  93. package/dist/op/audit-op.js +28 -0
  94. package/dist/op/builders.js +57 -0
  95. package/dist/op/slo-rule-tests.js +148 -0
  96. package/dist/package-cli.js +14 -0
  97. package/dist/pin.js +13 -0
  98. package/dist/plugin.js +143 -0
  99. package/dist/prometheus-config.js +38 -0
  100. package/dist/promql-analysis.js +343 -0
  101. package/dist/promql.js +44 -0
  102. package/dist/rule-eval.js +466 -0
  103. package/dist/rules.js +97 -0
  104. package/dist/serializer.js +59 -0
  105. package/dist/skill-defs.js +41 -0
  106. package/dist/tools.js +85 -0
  107. package/dist/validate-cli.js +7 -0
  108. package/dist/validate-config.js +556 -0
  109. package/dist/validate-integrations.js +388 -0
  110. package/dist/validate-relabel.js +52 -0
  111. package/dist/validate-security.js +135 -0
  112. package/dist/validate.js +69 -0
  113. package/package.json +10 -10
  114. package/src/codegen/package.ts +2 -2
@@ -0,0 +1,79 @@
1
+ import { ts } from "@intentius/chant/lint/typescript";
2
+ import { calleeName, literalText, position, propertyName } from "./prom-ast.js";
3
+ import { sliExprProblem } from "../../composites/slo.js";
4
+ import { durationMs, isValidDuration } from "../../duration.js";
5
+ /** A numeric literal, with an optional leading minus, as a number. */
6
+ function literalNumber(node) {
7
+ if (ts.isNumericLiteral(node))
8
+ return Number(node.text);
9
+ if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken && ts.isNumericLiteral(node.operand)) {
10
+ return -Number(node.operand.text);
11
+ }
12
+ return undefined;
13
+ }
14
+ function objectProps(node) {
15
+ const out = new Map();
16
+ if (!ts.isObjectLiteralExpression(node))
17
+ return out;
18
+ for (const p of node.properties) {
19
+ const name = propertyName(p);
20
+ if (name !== undefined && ts.isPropertyAssignment(p))
21
+ out.set(name, p.initializer);
22
+ }
23
+ return out;
24
+ }
25
+ /**
26
+ * PROM003: an `Slo` whose literal objective, window or SLI expression can
27
+ * not build.
28
+ *
29
+ * `Slo()` throws on the same problems when the build runs it; this rule
30
+ * reports them in the editor, at the literal: an objective outside (0, 1),
31
+ * a window that is not a positive Prometheus duration, and an SLI
32
+ * expression without `{{window}}` or that is not PromQL once it is filled
33
+ * in. Values built at runtime are left to the constructor.
34
+ */
35
+ export const sloLiteralRule = {
36
+ id: "PROM003",
37
+ severity: "error",
38
+ category: "correctness",
39
+ description: "An Slo's literal objective, window or SLI expression is invalid",
40
+ check(context) {
41
+ const diagnostics = [];
42
+ const source = context.sourceFile;
43
+ const report = (node, message) => diagnostics.push({ ruleId: "PROM003", severity: "error", message, file: context.filePath, ...position(source, node) });
44
+ const visit = (node) => {
45
+ if ((ts.isCallExpression(node) || ts.isNewExpression(node)) && calleeName(node) === "Slo") {
46
+ const arg = node.arguments?.[0];
47
+ if (arg) {
48
+ const props = objectProps(arg);
49
+ const objective = props.get("objective");
50
+ const o = objective ? literalNumber(objective) : undefined;
51
+ if (objective && o !== undefined && !(o > 0 && o < 1)) {
52
+ report(objective, `Slo objective must be strictly between 0 and 1 (e.g. 0.995 for 99.5%), got ${o}`);
53
+ }
54
+ const window = props.get("window");
55
+ const w = window ? literalText(window) : undefined;
56
+ if (window && w !== undefined && (!isValidDuration(w) || durationMs(w) === 0)) {
57
+ report(window, `Slo window must be a positive Prometheus duration (e.g. 28d or 30d), got ${JSON.stringify(w)}`);
58
+ }
59
+ const sli = props.get("sli");
60
+ if (sli) {
61
+ for (const [key, expr] of objectProps(sli)) {
62
+ if (key !== "good" && key !== "errors" && key !== "total")
63
+ continue;
64
+ const text = literalText(expr);
65
+ if (text === undefined)
66
+ continue;
67
+ const problem = sliExprProblem(text);
68
+ if (problem)
69
+ report(expr, `Slo sli.${key} ${problem}`);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ ts.forEachChild(node, visit);
75
+ };
76
+ visit(source);
77
+ return diagnostics;
78
+ },
79
+ };
@@ -0,0 +1,11 @@
1
+ import { LexiconIndex, lexiconCompletions } from "@intentius/chant/lsp/lexicon-providers";
2
+ import { lexiconRegistry } from "../catalog.js";
3
+ let cachedIndex = null;
4
+ function getIndex() {
5
+ cachedIndex ?? (cachedIndex = new LexiconIndex(lexiconRegistry()));
6
+ return cachedIndex;
7
+ }
8
+ /** Completions for the prometheus entity classes: RuleGroup and the Alertmanager kinds. */
9
+ export function completions(ctx) {
10
+ return lexiconCompletions(ctx, getIndex(), "Prometheus entity");
11
+ }
@@ -0,0 +1,21 @@
1
+ import { LexiconIndex, lexiconHover } from "@intentius/chant/lsp/lexicon-providers";
2
+ import { CATALOG, lexiconRegistry } from "../catalog.js";
3
+ import { PROMETHEUS_PIN } from "../pin.js";
4
+ let cachedIndex = null;
5
+ function getIndex() {
6
+ cachedIndex ?? (cachedIndex = new LexiconIndex(lexiconRegistry()));
7
+ return cachedIndex;
8
+ }
9
+ /** Hover for prometheus entity classes: what the entity is and which file it lands in. */
10
+ export function hover(ctx) {
11
+ return lexiconHover(ctx, getIndex(), resourceHover);
12
+ }
13
+ function resourceHover(className, entry) {
14
+ const cat = CATALOG.find((c) => c.className === className);
15
+ const lines = [`**${className}**`, "", `prometheus type: \`${entry.resourceType}\``];
16
+ if (cat) {
17
+ const pin = cat.file === "rule file" ? PROMETHEUS_PIN.prometheus : PROMETHEUS_PIN.alertmanager;
18
+ lines.push("", cat.description, "", `Serializes into the ${cat.file}. Typed against ${pin.source} ${pin.version}.`);
19
+ }
20
+ return { contents: lines.join("\n") };
21
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "prometheus",
3
- "version": "0.124.31",
3
+ "version": "0.124.33",
4
4
  "chantVersion": ">=0.1.0",
5
5
  "namespace": "Prometheus",
6
6
  "intrinsics": [],
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Alertmanager label matchers: `severity="critical"`, `team=~"db|infra"`,
3
+ * `env!="dev"`.
4
+ *
5
+ * A matcher is a label name, an operator (`=`, `!=`, `=~`, `!~`) and a value,
6
+ * quoted or not. One list entry may also hold several comma-separated
7
+ * matchers in braces (`{a="1", b="2"}`), which Alertmanager accepts too.
8
+ * Regex values are anchored at both ends, as Alertmanager anchors them.
9
+ */
10
+ const NAME = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
+ function readQuoted(s, start) {
12
+ let i = start + 1;
13
+ let out = "";
14
+ while (i < s.length) {
15
+ const c = s[i];
16
+ if (c === "\\") {
17
+ const next = s[i + 1];
18
+ if (next === undefined)
19
+ return undefined;
20
+ out += next === "n" ? "\n" : next === "t" ? "\t" : next;
21
+ i += 2;
22
+ continue;
23
+ }
24
+ if (c === '"')
25
+ return { value: out, end: i + 1 };
26
+ out += c;
27
+ i++;
28
+ }
29
+ return undefined;
30
+ }
31
+ function parseOne(text) {
32
+ const s = text.trim();
33
+ const opMatch = /(=~|!~|!=|=)/.exec(s);
34
+ if (!opMatch)
35
+ return `"${s}" has no operator; use =, !=, =~ or !~`;
36
+ let name = s.slice(0, opMatch.index).trim();
37
+ if (name.startsWith('"') && name.endsWith('"') && name.length >= 2)
38
+ name = name.slice(1, -1);
39
+ else if (!NAME.test(name))
40
+ return `"${name}" is not a label name`;
41
+ if (name === "")
42
+ return `"${s}" has an empty label name`;
43
+ const op = opMatch[1];
44
+ const rest = s.slice(opMatch.index + op.length).trim();
45
+ let value;
46
+ if (rest.startsWith('"')) {
47
+ const q = readQuoted(rest, 0);
48
+ if (!q)
49
+ return `"${s}" has an unterminated quoted value`;
50
+ if (rest.slice(q.end).trim() !== "")
51
+ return `"${s}" has text after its quoted value`;
52
+ value = q.value;
53
+ }
54
+ else {
55
+ if (/["{}=!~,]/.test(rest))
56
+ return `"${s}" has an unquoted value with reserved characters; quote it`;
57
+ value = rest;
58
+ }
59
+ if (op === "=~" || op === "!~") {
60
+ try {
61
+ new RegExp(`^(?:${value})$`);
62
+ }
63
+ catch (err) {
64
+ return `"${s}" has an invalid regular expression: ${err instanceof Error ? err.message : String(err)}`;
65
+ }
66
+ }
67
+ return { name, op, value };
68
+ }
69
+ /** Split `a="x,y", b=z` on the commas that are outside quotes. */
70
+ function splitTopLevel(s) {
71
+ const parts = [];
72
+ let depthQuote = false;
73
+ let cur = "";
74
+ for (let i = 0; i < s.length; i++) {
75
+ const c = s[i];
76
+ if (c === "\\" && depthQuote) {
77
+ cur += c + (s[i + 1] ?? "");
78
+ i++;
79
+ continue;
80
+ }
81
+ if (c === '"')
82
+ depthQuote = !depthQuote;
83
+ if (c === "," && !depthQuote) {
84
+ parts.push(cur);
85
+ cur = "";
86
+ continue;
87
+ }
88
+ cur += c;
89
+ }
90
+ parts.push(cur);
91
+ return parts.map((p) => p.trim()).filter((p) => p !== "");
92
+ }
93
+ /** Parse one entry of a `matchers:` list. */
94
+ export function parseMatchers(entry) {
95
+ let s = entry.trim();
96
+ if (s === "")
97
+ return { ok: false, error: "empty matcher" };
98
+ if (s.startsWith("{")) {
99
+ if (!s.endsWith("}"))
100
+ return { ok: false, error: `"${entry}" opens a brace it never closes` };
101
+ s = s.slice(1, -1);
102
+ }
103
+ const matchers = [];
104
+ for (const part of splitTopLevel(s)) {
105
+ const m = parseOne(part);
106
+ if (typeof m === "string")
107
+ return { ok: false, error: m };
108
+ matchers.push(m);
109
+ }
110
+ if (matchers.length === 0)
111
+ return { ok: false, error: `"${entry}" holds no matcher` };
112
+ return { ok: true, matchers };
113
+ }
114
+ /** Whether one matcher matches a label set (a missing label reads as the empty string, as in Alertmanager). */
115
+ export function matcherMatches(m, labels) {
116
+ const v = labels[m.name] ?? "";
117
+ switch (m.op) {
118
+ case "=":
119
+ return v === m.value;
120
+ case "!=":
121
+ return v !== m.value;
122
+ case "=~":
123
+ return new RegExp(`^(?:${m.value})$`).test(v);
124
+ case "!~":
125
+ return !new RegExp(`^(?:${m.value})$`).test(v);
126
+ }
127
+ }
128
+ /** Build the matcher string for `name op value`, quoting the value. */
129
+ export function matcher(name, op, value) {
130
+ return `${name}${op}${JSON.stringify(value)}`;
131
+ }
package/dist/model.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The plain-data model of a Prometheus rule file and an Alertmanager config.
3
+ *
4
+ * Everything else in this lexicon reads or writes these shapes: the serializer
5
+ * builds them from declared entities, the YAML emitter prints them, the checks
6
+ * validate them, and other lexicons (the k8s `PrometheusRule`, the SLO
7
+ * composite, Grafana panels) read them. They are exactly the files' own
8
+ * layouts, so a parsed rule file or `alertmanager.yml` already is one and
9
+ * needs no conversion.
10
+ *
11
+ * Field names keep the files' snake_case (`keep_firing_for`, `group_wait`),
12
+ * so a declaration reads the same as the file it produces and the upstream
13
+ * docs apply word for word.
14
+ */
15
+ import { RECEIVER_INTEGRATION_TYPES, } from "./integrations.js";
16
+ export function isRecordingRuleConfig(rule) {
17
+ return typeof rule === "object" && rule !== null && typeof rule.record === "string";
18
+ }
19
+ export function isAlertingRuleConfig(rule) {
20
+ return typeof rule === "object" && rule !== null && typeof rule.alert === "string";
21
+ }
22
+ /** The rule's name: `record` for a recording rule, `alert` for an alerting rule. */
23
+ export function ruleName(rule) {
24
+ return isRecordingRuleConfig(rule) ? rule.record : rule.alert;
25
+ }
26
+ // ── Alertmanager ────────────────────────────────────────────────────
27
+ export * from "./integrations.js";
28
+ export * from "./config-model.js";
29
+ /** Every `global:` field, as a list, for code that reads a parsed file. */
30
+ export const ALERTMANAGER_GLOBAL_FIELDS = Object.freeze(Object.keys({
31
+ resolve_timeout: true,
32
+ http_config: true,
33
+ jira_api_url: true,
34
+ smtp_from: true,
35
+ smtp_hello: true,
36
+ smtp_smarthost: true,
37
+ smtp_auth_username: true,
38
+ smtp_auth_password: true,
39
+ smtp_auth_password_file: true,
40
+ smtp_auth_secret: true,
41
+ smtp_auth_secret_file: true,
42
+ smtp_auth_identity: true,
43
+ smtp_require_tls: true,
44
+ smtp_tls_config: true,
45
+ smtp_force_implicit_tls: true,
46
+ slack_api_url: true,
47
+ slack_api_url_file: true,
48
+ slack_app_token: true,
49
+ slack_app_token_file: true,
50
+ slack_app_url: true,
51
+ pagerduty_url: true,
52
+ opsgenie_api_url: true,
53
+ opsgenie_api_key: true,
54
+ opsgenie_api_key_file: true,
55
+ wechat_api_url: true,
56
+ wechat_api_secret: true,
57
+ wechat_api_secret_file: true,
58
+ wechat_api_corp_id: true,
59
+ victorops_api_url: true,
60
+ victorops_api_key: true,
61
+ victorops_api_key_file: true,
62
+ telegram_api_url: true,
63
+ telegram_bot_token: true,
64
+ telegram_bot_token_file: true,
65
+ webex_api_url: true,
66
+ rocketchat_api_url: true,
67
+ rocketchat_token: true,
68
+ rocketchat_token_file: true,
69
+ rocketchat_token_id: true,
70
+ rocketchat_token_id_file: true,
71
+ mattermost_webhook_url: true,
72
+ mattermost_webhook_url_file: true,
73
+ }));
74
+ /** The receiver integrations, every one Alertmanager defines. */
75
+ export const RECEIVER_INTEGRATIONS = Object.keys(RECEIVER_INTEGRATION_TYPES);
76
+ /** True when a parsed document has the shape of a Prometheus rule file. */
77
+ export function looksLikeRuleFile(value) {
78
+ if (typeof value !== "object" || value === null || Array.isArray(value))
79
+ return false;
80
+ const v = value;
81
+ const groups = v.groups;
82
+ if (!Array.isArray(groups))
83
+ return false;
84
+ // Grafana's alerting provisioning files also hold `groups:` of named rule lists. Prometheus reads rule
85
+ // files strictly (unknown keys are errors), so `apiVersion`, a group `folder` or a rule's `data` means Grafana.
86
+ if ("apiVersion" in v)
87
+ return false;
88
+ return groups.every((g) => {
89
+ if (typeof g !== "object" || g === null)
90
+ return false;
91
+ const group = g;
92
+ if (typeof group.name !== "string" || !Array.isArray(group.rules) || "folder" in group)
93
+ return false;
94
+ return !group.rules.some((r) => typeof r === "object" && r !== null && "data" in r);
95
+ });
96
+ }
97
+ /** True when a parsed document has the shape of an `alertmanager.yml`. */
98
+ export function looksLikeAlertmanagerConfig(value) {
99
+ if (typeof value !== "object" || value === null || Array.isArray(value))
100
+ return false;
101
+ const v = value;
102
+ const route = v.route;
103
+ const receivers = v.receivers;
104
+ const hasRoute = typeof route === "object" && route !== null && !Array.isArray(route);
105
+ const hasReceivers = Array.isArray(receivers);
106
+ // A k8s manifest never has both at the top level; a collector config has neither.
107
+ return (hasRoute || hasReceivers) && !("apiVersion" in v) && !("kind" in v);
108
+ }
@@ -0,0 +1,20 @@
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.js";
18
+ export { alertmanagerSilence, alertmanagerUnsilence } from "./silence.js";
19
+ export { rulesLoadedObserve } from "./rules-loaded.js";
20
+ export { ruleAudit } from "./rule-audit.js";
@@ -0,0 +1,87 @@
1
+ /**
2
+ * `promtool` and `amtool` as Op steps (#3369), over the functions in
3
+ * `../../tools.ts`.
4
+ *
5
+ * Those functions report `ran: false` when the binary is missing, so tests
6
+ * and CI can skip them. A step does not: a check that did not run must not
7
+ * read as a check that passed, so a missing binary fails the step, and so
8
+ * does a file the tool rejects, with the tool's output as the message.
9
+ *
10
+ * Paths are relative to the working directory. `promtoolTestRules` writes
11
+ * the rule file as `rules.yml` beside each test file, so a test file's
12
+ * `rule_files:` names `rules.yml`; the tests `sloRuleTests()` generates
13
+ * from `Slo` declarations do.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { resolve } from "node:path";
17
+ import { nonRetryableFailure } from "@intentius/chant/op";
18
+ import { defaultExec } from "@intentius/chant-lexicon-otel/op/activities/exec";
19
+ import { amtoolCheckConfig as amtoolCheckConfigText, promtoolCheckRules as promtoolCheckRulesText, promtoolTestRules as promtoolTestRulesText, } from "../../tools.js";
20
+ const list = (v) => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
21
+ const read = (path) => readFileSync(resolve(process.cwd(), path), "utf8");
22
+ function settle(tool, files, r, bin) {
23
+ if (!r.ran)
24
+ throw new Error(`${bin} is not installed (or does not run): ${tool} did not check ${files.join(", ")}. Install it or set bin.`);
25
+ if (!r.ok)
26
+ throw nonRetryableFailure(`${tool} rejected ${files.join(", ")}:\n${r.output.trim()}`, "PrometheusToolRejected");
27
+ return { files, ok: true, output: r.output.trim() };
28
+ }
29
+ const promtoolBin = (bin) => bin ?? process.env.PROMTOOL ?? "promtool";
30
+ const amtoolBin = (bin) => bin ?? process.env.AMTOOL ?? "amtool";
31
+ /** `promtool check rules` over each rule file. */
32
+ export async function promtoolCheckRules(args) {
33
+ const bin = promtoolBin(args.bin);
34
+ const files = list(args.rules);
35
+ if (files.length === 0)
36
+ throw new Error("promtoolCheckRules: no rule file given");
37
+ const outputs = [];
38
+ for (const file of files)
39
+ outputs.push(settle("promtool check rules", [file], promtoolCheckRulesText(read(file), bin), bin).output);
40
+ return { files, ok: true, output: outputs.join("\n") };
41
+ }
42
+ /** `promtool test rules` over the rule file, once per test file or inline test document. */
43
+ export async function promtoolTestRules(args) {
44
+ const bin = promtoolBin(args.bin);
45
+ const rules = read(args.rules);
46
+ const docs = [
47
+ ...list(args.tests).map((f) => [f, read(f)]),
48
+ ...list(args.testYaml).map((y, i) => [`testYaml[${i}]`, y]),
49
+ ];
50
+ if (docs.length === 0)
51
+ throw new Error("promtoolTestRules: give tests or testYaml (or slos to the builder)");
52
+ const outputs = [];
53
+ for (const [name, yaml] of docs)
54
+ outputs.push(settle("promtool test rules", [args.rules, name], promtoolTestRulesText(rules, yaml, bin), bin).output);
55
+ return { files: [args.rules, ...list(args.tests)], ok: true, output: outputs.join("\n"), tests: docs.length };
56
+ }
57
+ /** `amtool check-config` over the Alertmanager config. */
58
+ export async function amtoolCheckConfig(args) {
59
+ const bin = amtoolBin(args.bin);
60
+ return settle("amtool check-config", [args.config], amtoolCheckConfigText(read(args.config), bin), bin);
61
+ }
62
+ /**
63
+ * The receivers amtool printed: its last non-empty line, comma-separated
64
+ * (`oncall` or `oncall,tickets`).
65
+ */
66
+ export function parseRoutesOutput(output) {
67
+ const lines = output.split("\n").map((l) => l.trim()).filter((l) => l !== "" && !/^warning/i.test(l));
68
+ const last = lines[lines.length - 1] ?? "";
69
+ return last.split(",").map((s) => s.trim()).filter(Boolean);
70
+ }
71
+ /** `amtool config routes test`: the labels route to the expected receivers, in order. */
72
+ export async function amtoolRoutesTest(args) {
73
+ const exec = args._exec ?? defaultExec;
74
+ const bin = amtoolBin(args.bin);
75
+ const expected = list(args.expect);
76
+ const pairs = Object.entries(args.labels).map(([k, v]) => `${k}=${v}`);
77
+ const r = await exec(bin, ["config", "routes", "test", `--config.file=${resolve(process.cwd(), args.config)}`, ...pairs], { timeoutMs: 30000 });
78
+ if (r.code === null)
79
+ throw new Error(`could not run ${bin}: ${r.error ?? "not found"}. Install amtool or set bin.`);
80
+ if (r.code !== 0)
81
+ throw nonRetryableFailure(`amtool config routes test failed (exit ${r.code}):\n${(r.stdout + r.stderr).trim()}`, "PrometheusToolRejected");
82
+ const receivers = parseRoutesOutput(r.stdout);
83
+ if (receivers.join(",") !== expected.join(",")) {
84
+ throw nonRetryableFailure(`${pairs.join(" ")} routes to ${receivers.join(",") || "(none)"} in ${args.config}, expected ${expected.join(",")}`, "PrometheusRouteMismatch");
85
+ }
86
+ return { config: args.config, labels: args.labels, receivers, ok: true };
87
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * `ruleAudit`: what a live Prometheus says about its rules (#3369), the
3
+ * activity behind `RuleAuditOp`.
4
+ *
5
+ * - groups with health errors: every rule `/api/v1/rules` reports with
6
+ * `health: "err"`, with its `lastError`.
7
+ * - alerts pending or firing past a threshold, from `/api/v1/alerts`: a
8
+ * pending alert older than `pendingFor` is one whose `for` never
9
+ * completes (a flapping condition), and a firing one older than
10
+ * `firingFor` is one nobody is acting on.
11
+ * - selectors no live target emits: each distinct vector selector in the
12
+ * loaded rules' expressions, queried as
13
+ * `count(last_over_time(<selector>[<lookback>]))`. An empty answer means
14
+ * the rule reads a series nothing writes (a renamed metric, a dropped
15
+ * label, a target that is gone). Selectors over series the rules record
16
+ * themselves, and over `ALERTS`, are left out. One query per selector, at
17
+ * most `selectorBudget` of them; the rest are counted as unchecked.
18
+ *
19
+ * `mode` is `report` (return the findings) or `issue` (keep one open issue
20
+ * current with them).
21
+ */
22
+ import { defaultRunner, stickyIssue } from "@intentius/chant-lexicon-otel/op/activities/github";
23
+ import { durationMs } from "../../duration.js";
24
+ import { selectors } from "../../promql-analysis.js";
25
+ import { fetchRuleGroups, prometheusUrl } from "./rules-loaded.js";
26
+ async function getJson(f, url) {
27
+ const res = await f(url, { signal: AbortSignal.timeout(15000) });
28
+ if (!res.ok)
29
+ throw new Error(`GET ${url}: HTTP ${res.status}`);
30
+ const body = (await res.json());
31
+ if (body.status !== "success" || body.data === undefined)
32
+ throw new Error(`GET ${url}: status ${body.status ?? "missing"}`);
33
+ return body.data;
34
+ }
35
+ /** The distinct selectors the rules read, without the ones over recorded series and ALERTS. */
36
+ export function auditedSelectors(groups) {
37
+ const recorded = new Set(["ALERTS", "ALERTS_FOR_STATE"]);
38
+ for (const g of groups)
39
+ for (const r of g.rules ?? [])
40
+ if (r.type === "recording")
41
+ recorded.add(r.name);
42
+ const out = new Set();
43
+ for (const g of groups) {
44
+ for (const r of g.rules ?? []) {
45
+ if (!r.query)
46
+ continue;
47
+ for (const s of selectors(r.query)) {
48
+ if (s.name && recorded.has(s.name))
49
+ continue;
50
+ out.add(s.text.replace(/\s+/g, ""));
51
+ }
52
+ }
53
+ }
54
+ return [...out].sort();
55
+ }
56
+ function alertName(a) {
57
+ const { alertname, ...rest } = a.labels;
58
+ const extra = Object.entries(rest)
59
+ .sort(([x], [y]) => x.localeCompare(y))
60
+ .map(([k, v]) => `${k}="${v}"`)
61
+ .join(",");
62
+ return `${alertname ?? "(unnamed)"}{${extra}}`;
63
+ }
64
+ export function renderRuleAuditSummary(findings, queried, unchecked) {
65
+ let out = "## Prometheus rule audit\n\n";
66
+ if (findings.length === 0)
67
+ out += "No findings.\n";
68
+ else {
69
+ out += "| Finding | Subject | Detail |\n|---|---|---|\n";
70
+ for (const f of findings)
71
+ out += `| ${f.kind} | \`${f.subject.replace(/\|/g, "\\|")}\` | ${f.detail.replace(/\|/g, "\\|")} |\n`;
72
+ }
73
+ out += `\n${queried} selector(s) queried${unchecked > 0 ? `, ${unchecked} left unchecked by the budget` : ""}.\n`;
74
+ return out;
75
+ }
76
+ /** Audit a live Prometheus's rules, alerts and selectors. */
77
+ export async function ruleAudit(args = {}) {
78
+ const f = args._fetch ?? fetch;
79
+ const url = prometheusUrl(args.url);
80
+ const now = (args._now ?? (() => new Date()))().getTime();
81
+ const mode = args.mode ?? "report";
82
+ const findings = [];
83
+ const groups = await fetchRuleGroups(f, url);
84
+ for (const g of groups) {
85
+ const errored = (g.rules ?? []).filter((r) => r.health === "err");
86
+ for (const r of errored)
87
+ findings.push({ kind: "rule-error", subject: g.name, detail: `${r.name}: ${r.lastError || "health err"}` });
88
+ }
89
+ const pendingMs = durationMs(args.pendingFor ?? "1h");
90
+ const firingMs = durationMs(args.firingFor ?? "24h");
91
+ if (pendingMs === undefined || firingMs === undefined)
92
+ throw new Error("pendingFor and firingFor are Prometheus durations such as 1h");
93
+ const { alerts } = await getJson(f, `${url}/api/v1/alerts`);
94
+ for (const a of alerts) {
95
+ const since = a.activeAt ? Date.parse(a.activeAt) : NaN;
96
+ if (Number.isNaN(since))
97
+ continue;
98
+ const age = now - since;
99
+ const hours = (age / 3600000).toFixed(1);
100
+ if (a.state === "pending" && age > pendingMs)
101
+ findings.push({ kind: "pending-too-long", subject: alertName(a), detail: `pending for ${hours}h` });
102
+ if (a.state === "firing" && age > firingMs)
103
+ findings.push({ kind: "firing-too-long", subject: alertName(a), detail: `firing for ${hours}h` });
104
+ }
105
+ const lookback = args.lookback ?? "1h";
106
+ if (durationMs(lookback) === undefined)
107
+ throw new Error(`lookback ${JSON.stringify(lookback)} is not a Prometheus duration`);
108
+ const all = auditedSelectors(groups);
109
+ const budget = Math.max(0, args.selectorBudget ?? 50);
110
+ const checked = all.slice(0, budget);
111
+ for (const sel of checked) {
112
+ const query = `count(last_over_time(${sel}[${lookback}]))`;
113
+ try {
114
+ const data = await getJson(f, `${url}/api/v1/query?query=${encodeURIComponent(query)}`);
115
+ if (data.result.length === 0)
116
+ findings.push({ kind: "selector-no-series", subject: sel, detail: `no series in the last ${lookback}` });
117
+ }
118
+ catch {
119
+ // A query Prometheus refuses is not evidence the series is missing.
120
+ }
121
+ }
122
+ const summary = renderRuleAuditSummary(findings, checked.length, all.length - checked.length);
123
+ const result = { mode, findings, queried: checked.length, unchecked: all.length - checked.length, summary };
124
+ if (mode === "issue" && findings.length > 0) {
125
+ result.issueUrl = await stickyIssue(args._run ?? defaultRunner, process.cwd(), args.issueTitle ?? "prometheus: rule audit findings", summary);
126
+ }
127
+ return result;
128
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Declared rule groups as resources a `ConvergeOp` observes (#3369).
3
+ *
4
+ * `rulesLoadedObserve` reads `GET /api/v1/rules` and returns one resource
5
+ * per declared group:
6
+ *
7
+ * - `drifted` when Prometheus has not loaded the group (the rule file is not
8
+ * mounted, did not parse, or the reload has not happened), or when a rule
9
+ * in it has `health: "err"`, with the rule's `lastError` as the detail;
10
+ * - `in-sync` otherwise;
11
+ * - `unknown`, for every group, when the API cannot be read.
12
+ *
13
+ * The declared groups are `groups`, plus the groups of each rule file in
14
+ * `rules` (read on each tick, so a rebuilt file is what is observed).
15
+ * Prometheus is `url`, else `$PROMETHEUS_URL`, else `http://localhost:9090`.
16
+ */
17
+ import { readFileSync } from "node:fs";
18
+ import { resolve } from "node:path";
19
+ import { load } from "js-yaml";
20
+ export function prometheusUrl(url) {
21
+ return (url ?? process.env.PROMETHEUS_URL ?? "http://localhost:9090").replace(/\/+$/, "");
22
+ }
23
+ /** The group names in a rule file's text. */
24
+ export function ruleFileGroups(text) {
25
+ const doc = load(text);
26
+ return (doc?.groups ?? []).map((g) => g?.name).filter((n) => typeof n === "string");
27
+ }
28
+ /** The declared groups, in order, without repeats. */
29
+ export function declaredGroups(args, cwd = process.cwd()) {
30
+ const out = [...(args.groups ?? [])];
31
+ const files = args.rules === undefined ? [] : Array.isArray(args.rules) ? args.rules : [args.rules];
32
+ for (const f of files)
33
+ out.push(...ruleFileGroups(readFileSync(resolve(cwd, f), "utf8")));
34
+ return [...new Set(out)];
35
+ }
36
+ /** `GET /api/v1/rules`'s groups. Throws on anything but a successful answer. */
37
+ export async function fetchRuleGroups(f, url, timeoutMs = 10000) {
38
+ const res = await f(`${url}/api/v1/rules`, { signal: AbortSignal.timeout(timeoutMs) });
39
+ if (!res.ok)
40
+ throw new Error(`GET ${url}/api/v1/rules: HTTP ${res.status}`);
41
+ const body = (await res.json());
42
+ if (body.status !== "success" || !Array.isArray(body.data?.groups))
43
+ throw new Error(`GET ${url}/api/v1/rules: status ${body.status ?? "missing"}`);
44
+ return body.data.groups;
45
+ }
46
+ /** The verdict for one declared group against the loaded groups. */
47
+ export function groupVerdict(name, loaded) {
48
+ const matches = loaded.filter((g) => g.name === name);
49
+ if (matches.length === 0)
50
+ return { name, status: "drifted", detail: "group not loaded" };
51
+ const rules = matches.flatMap((g) => g.rules ?? []);
52
+ const errored = rules.filter((r) => r.health === "err");
53
+ if (errored.length > 0) {
54
+ return { name, status: "drifted", detail: errored.map((r) => `${r.name}: ${r.lastError || "health err"}`).join("; ") };
55
+ }
56
+ return { name, status: "in-sync", detail: `${rules.length} rule(s) loaded` };
57
+ }
58
+ /** Observe the declared rule groups: one resource per group. */
59
+ export async function rulesLoadedObserve(args) {
60
+ const names = declaredGroups(args);
61
+ if (names.length === 0)
62
+ throw new Error("rulesLoadedObserve: no groups declared (give groups or rules)");
63
+ const url = prometheusUrl(args.url);
64
+ let loaded;
65
+ try {
66
+ loaded = await fetchRuleGroups(args._fetch ?? fetch, url, args.timeoutMs);
67
+ }
68
+ catch (err) {
69
+ const detail = `rules API unreadable: ${err.message}`;
70
+ return { resources: names.map((name) => ({ name, status: "unknown", detail })) };
71
+ }
72
+ return { resources: names.map((name) => groupVerdict(name, loaded)) };
73
+ }