@intentius/chant-lexicon-prometheus 0.124.31 → 0.124.32

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,123 @@
1
+ /**
2
+ * Alertmanager silences as Op steps (#3369): silence the alerts a change is
3
+ * expected to set off, and expire the silence when the change is done or
4
+ * has failed.
5
+ *
6
+ * `alertmanagerSilence` creates a silence through `POST /api/v2/silences`
7
+ * and records its id in a file under the working directory
8
+ * (`.chant/alertmanager-silences/<record>.json`). Step-output references are
9
+ * not available in an Op's `onFailure` phases, so the record is how a final
10
+ * phase and an `onFailure` phase alike find the silence:
11
+ * `alertmanagerUnsilence({ record })` expires every silence the record
12
+ * holds through `DELETE /api/v2/silence/{id}`, and drops them from it. An
13
+ * already-expired or unknown silence counts as expired, so the step can
14
+ * run in both places.
15
+ *
16
+ * The Alertmanager is `url`, else `$ALERTMANAGER_URL`, else
17
+ * `http://localhost:9093`.
18
+ */
19
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { dirname, join, resolve } from "node:path";
21
+ import { currentOpRun } from "@intentius/chant/op";
22
+ import { durationMs } from "../../duration.js";
23
+ import { parseMatchers } from "../../matchers.js";
24
+ export function alertmanagerUrl(url) {
25
+ return (url ?? process.env.ALERTMANAGER_URL ?? "http://localhost:9093").replace(/\/+$/, "");
26
+ }
27
+ /** The API matchers for `matchers`. */
28
+ export function apiMatchers(matchers) {
29
+ if (!Array.isArray(matchers)) {
30
+ return Object.entries(matchers).map(([name, value]) => ({ name, value, isRegex: false, isEqual: true }));
31
+ }
32
+ const out = [];
33
+ for (const entry of matchers) {
34
+ const parsed = parseMatchers(entry);
35
+ if (!parsed.ok)
36
+ throw new Error(`matcher ${JSON.stringify(entry)}: ${parsed.error}`);
37
+ for (const m of parsed.matchers)
38
+ out.push({ name: m.name, value: m.value, isRegex: m.op.endsWith("~"), isEqual: !m.op.startsWith("!") });
39
+ }
40
+ if (out.length === 0)
41
+ throw new Error("a silence needs at least one matcher");
42
+ return out;
43
+ }
44
+ export function recordPath(record = "silence", recordDir = ".chant/alertmanager-silences", cwd = process.cwd()) {
45
+ if (!/^[A-Za-z0-9._-]+$/.test(record))
46
+ throw new Error(`record ${JSON.stringify(record)}: use letters, digits, dot, dash and underscore`);
47
+ return join(resolve(cwd, recordDir), `${record}.json`);
48
+ }
49
+ function readRecord(path) {
50
+ try {
51
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
52
+ return Array.isArray(parsed.silences) ? parsed.silences : [];
53
+ }
54
+ catch {
55
+ return [];
56
+ }
57
+ }
58
+ function writeRecord(path, silences) {
59
+ mkdirSync(dirname(path), { recursive: true });
60
+ writeFileSync(path, `${JSON.stringify({ silences }, null, 2)}\n`);
61
+ }
62
+ /** Create a silence and record its id. */
63
+ export async function alertmanagerSilence(args) {
64
+ const f = args._fetch ?? fetch;
65
+ const url = alertmanagerUrl(args.url);
66
+ const ms = durationMs(args.duration);
67
+ if (!ms || ms <= 0)
68
+ throw new Error(`duration ${JSON.stringify(args.duration)} is not a Prometheus duration such as 30m`);
69
+ const run = currentOpRun();
70
+ const now = (args._now ?? (() => new Date()))();
71
+ const startsAt = now.toISOString();
72
+ const endsAt = new Date(now.getTime() + ms).toISOString();
73
+ const body = {
74
+ matchers: apiMatchers(args.matchers),
75
+ startsAt,
76
+ endsAt,
77
+ createdBy: args.createdBy ?? (run ? `chant op ${run.op}` : "chant"),
78
+ comment: args.comment ?? (run ? `chant run ${run.op} (${run.runId})` : "chant"),
79
+ };
80
+ const res = await f(`${url}/api/v2/silences`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
81
+ const text = await res.text();
82
+ if (!res.ok)
83
+ throw new Error(`POST ${url}/api/v2/silences: HTTP ${res.status}: ${text.slice(0, 300)}`);
84
+ const silenceId = JSON.parse(text).silenceID;
85
+ if (!silenceId)
86
+ throw new Error(`POST ${url}/api/v2/silences answered without a silenceID: ${text.slice(0, 300)}`);
87
+ const path = recordPath(args.record, args.recordDir);
88
+ writeRecord(path, [...readRecord(path), { silenceId, url, endsAt, ...(run ? { run: run.runId } : {}) }]);
89
+ return { silenceId, url, startsAt, endsAt, record: path };
90
+ }
91
+ /** Expire the recorded silences (or `silenceId`), and drop the expired ones from the record. */
92
+ export async function alertmanagerUnsilence(args = {}) {
93
+ const f = args._fetch ?? fetch;
94
+ const path = recordPath(args.record, args.recordDir);
95
+ const recorded = readRecord(path);
96
+ const targets = args.silenceId
97
+ ? [recorded.find((s) => s.silenceId === args.silenceId) ?? { silenceId: args.silenceId, url: alertmanagerUrl(args.url), endsAt: "" }]
98
+ : recorded;
99
+ const expired = [];
100
+ const failed = [];
101
+ for (const s of targets) {
102
+ const base = args.url ? alertmanagerUrl(args.url) : s.url;
103
+ try {
104
+ const res = await f(`${base}/api/v2/silence/${encodeURIComponent(s.silenceId)}`, { method: "DELETE" });
105
+ const text = await res.text().catch(() => "");
106
+ // Alertmanager answers 404 for an unknown silence and 500 "already expired" for an expired one: both are done.
107
+ if (res.ok || res.status === 404 || /expired/i.test(text))
108
+ expired.push(s.silenceId);
109
+ else
110
+ failed.push({ silenceId: s.silenceId, detail: `HTTP ${res.status}: ${text.slice(0, 200)}` });
111
+ }
112
+ catch (err) {
113
+ failed.push({ silenceId: s.silenceId, detail: err.message });
114
+ }
115
+ }
116
+ const done = new Set(expired);
117
+ if (recorded.length > 0)
118
+ writeRecord(path, recorded.filter((s) => !done.has(s.silenceId)));
119
+ if (failed.length > 0) {
120
+ throw new Error(`could not expire ${failed.length} silence(s): ${failed.map((x) => `${x.silenceId} (${x.detail})`).join("; ")}`);
121
+ }
122
+ return { expired, failed };
123
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Activity contracts for this lexicon's `op/activities` (#3369).
3
+ *
4
+ * Resolved by convention at
5
+ * `@intentius/chant-lexicon-prometheus/op/activity-contracts`:
6
+ * `loadActivityContracts` merges them into the map OPS012 checks each step's
7
+ * args against and OPS013 checks each step-output reference against. Every
8
+ * activity here has one; `rulesLoadedObserve`'s return schema is what lets a
9
+ * `ConvergeOp({ observe: rulesLoadedObserve(...) })` pass OPS013, and
10
+ * `alertmanagerSilence`'s is what a later step reads `.out.silenceId` from.
11
+ *
12
+ * Each `args` schema mirrors the `*Args` interface in `./activities/`,
13
+ * without the `_`-prefixed test seams, and is a `z.strictObject`, so a
14
+ * misspelled key fails the build.
15
+ */
16
+ import { z } from "zod";
17
+ import { activityContract } from "@intentius/chant/op";
18
+ const oneOrMany = z.union([z.string(), z.array(z.string())]);
19
+ const toolResult = z.object({ files: z.array(z.string()), ok: z.boolean(), output: z.string() });
20
+ const resourceStatus = z.enum(["in-sync", "drifted", "unknown"]);
21
+ export const promtoolCheckRulesContract = activityContract("promtoolCheckRules", z.strictObject({ rules: oneOrMany, bin: z.string().optional() }), toolResult);
22
+ export const promtoolTestRulesContract = activityContract("promtoolTestRules", z.strictObject({ rules: z.string(), tests: oneOrMany.optional(), testYaml: oneOrMany.optional(), bin: z.string().optional() }), toolResult.extend({ tests: z.number() }));
23
+ export const amtoolCheckConfigContract = activityContract("amtoolCheckConfig", z.strictObject({ config: z.string(), bin: z.string().optional() }), toolResult);
24
+ export const amtoolRoutesTestContract = activityContract("amtoolRoutesTest", z.strictObject({ config: z.string(), labels: z.record(z.string(), z.string()), expect: oneOrMany, bin: z.string().optional() }), z.object({ config: z.string(), labels: z.record(z.string(), z.string()), receivers: z.array(z.string()), ok: z.boolean() }));
25
+ const record = { record: z.string().optional(), recordDir: z.string().optional() };
26
+ export const alertmanagerSilenceContract = activityContract("alertmanagerSilence", z.strictObject({
27
+ url: z.string().optional(),
28
+ matchers: z.union([z.array(z.string()), z.record(z.string(), z.string())]),
29
+ duration: z.string(),
30
+ comment: z.string().optional(),
31
+ createdBy: z.string().optional(),
32
+ ...record,
33
+ }), z.object({ silenceId: z.string(), url: z.string(), startsAt: z.string(), endsAt: z.string(), record: z.string() }));
34
+ export const alertmanagerUnsilenceContract = activityContract("alertmanagerUnsilence", z.strictObject({ url: z.string().optional(), silenceId: z.string().optional(), ...record }), z.object({ expired: z.array(z.string()), failed: z.array(z.object({ silenceId: z.string(), detail: z.string() })) }));
35
+ export const rulesLoadedObserveContract = activityContract("rulesLoadedObserve", z.strictObject({ url: z.string().optional(), groups: z.array(z.string()).optional(), rules: oneOrMany.optional(), timeoutMs: z.number().optional() }), z.object({ resources: z.array(z.object({ name: z.string(), status: resourceStatus, detail: z.string() })) }));
36
+ export const ruleAuditContract = activityContract("ruleAudit", z.strictObject({
37
+ url: z.string().optional(),
38
+ pendingFor: z.string().optional(),
39
+ firingFor: z.string().optional(),
40
+ selectorBudget: z.number().optional(),
41
+ lookback: z.string().optional(),
42
+ mode: z.enum(["report", "issue"]).optional(),
43
+ issueTitle: z.string().optional(),
44
+ }), z.object({
45
+ mode: z.enum(["report", "issue"]),
46
+ findings: z.array(z.object({ kind: z.enum(["rule-error", "pending-too-long", "firing-too-long", "selector-no-series"]), subject: z.string(), detail: z.string() })),
47
+ queried: z.number(),
48
+ unchecked: z.number(),
49
+ summary: z.string(),
50
+ issueUrl: z.string().optional(),
51
+ }));
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `RuleAuditOp`: what a live Prometheus says about its rules, as an Op with
3
+ * an optional schedule (#3369). One `ruleAudit` step reports groups with
4
+ * health errors, alerts pending or firing past a threshold, and selectors
5
+ * no live target emits (one query per selector, within a budget).
6
+ * `onFinding` is `report` (the default) or `issue`, which keeps one open
7
+ * issue current.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * export const { op } = RuleAuditOp({ name: "rule-audit", url: "http://prometheus:9090", schedule: "0 * * * *" });
12
+ * ```
13
+ */
14
+ import { Op, phase } from "@intentius/chant/op";
15
+ import { ruleAudit } from "./builders.js";
16
+ export function RuleAuditOp(config) {
17
+ const { name, schedule, onFinding, ...rest } = config;
18
+ const defined = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined));
19
+ const step = ruleAudit({ mode: onFinding ?? "report", ...defined });
20
+ const op = Op({
21
+ name,
22
+ overview: "Audit a live Prometheus: rule health, alerts pending or firing too long, and selectors nothing emits",
23
+ labels: { Audit: "true", Surface: "prometheus-rules" },
24
+ ...(schedule ? { schedule: { cron: schedule, overlap: "skip" } } : {}),
25
+ phases: [phase("Audit", [{ ...step, outcomeAttribute: { name: "Findings", from: "findings" } }])],
26
+ });
27
+ return { op };
28
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Typed step builders for this lexicon's Op activities (#3369), following
3
+ * the helm and fly builders: each one's options ARE the activity's own
4
+ * `*Args` interface (minus its `_`-prefixed test seams), every field also
5
+ * takes a `StepOutputRef`, and `profile` and `id` go to the step.
6
+ *
7
+ * `promtoolCheckRules`, `promtoolTestRules` and `amtoolCheckConfig` share
8
+ * their names with the plain functions in `../tools.ts`, which the package
9
+ * root already exports, so those three builders are reached here, at
10
+ * `@intentius/chant-lexicon-prometheus/op/builders`. The root exports the
11
+ * rest.
12
+ */
13
+ import { activity, takeProfileAndId } from "@intentius/chant/op";
14
+ import { sloRuleTests } from "./slo-rule-tests.js";
15
+ function step(fn, defaultProfile) {
16
+ return (args) => {
17
+ const { args: rest, profile, id } = takeProfileAndId(args);
18
+ return activity(fn, rest, { profile: profile ?? defaultProfile, ...(id ? { id } : {}) });
19
+ };
20
+ }
21
+ /** `promtool check rules` over built rule files. Defaults to the `fastIdempotent` profile. */
22
+ export const promtoolCheckRules = step("promtoolCheckRules", "fastIdempotent");
23
+ /**
24
+ * `promtool test rules` over a built rule file. `slos` generates the tests
25
+ * from `Slo` declarations at build time (`sloRuleTests`): each burn-rate
26
+ * pair fires at its rate and not below it. Defaults to the `fastIdempotent`
27
+ * profile.
28
+ */
29
+ export const promtoolTestRules = (args) => {
30
+ const { slos, ...rest } = args;
31
+ if (!slos || slos.length === 0)
32
+ return step("promtoolTestRules", "fastIdempotent")(rest);
33
+ const generated = sloRuleTests(slos);
34
+ const given = rest.testYaml === undefined ? [] : Array.isArray(rest.testYaml) ? rest.testYaml : [rest.testYaml];
35
+ return step("promtoolTestRules", "fastIdempotent")({ ...rest, testYaml: [...given, ...generated] });
36
+ };
37
+ /** `amtool check-config` over a built Alertmanager config. Defaults to the `fastIdempotent` profile. */
38
+ export const amtoolCheckConfig = step("amtoolCheckConfig", "fastIdempotent");
39
+ /** `amtool config routes test`: `labels` route to `expect`. Defaults to the `fastIdempotent` profile. */
40
+ export const amtoolRoutesTest = step("amtoolRoutesTest", "fastIdempotent");
41
+ /**
42
+ * Silence alerts through Alertmanager's API for `duration`, recording the
43
+ * silence id under `record` so `alertmanagerUnsilence` can expire it from
44
+ * a final phase or an `onFailure` phase. Defaults to the `atMostOnce`
45
+ * profile: a retry after a lost answer would create a second silence.
46
+ */
47
+ export const alertmanagerSilence = step("alertmanagerSilence", "atMostOnce");
48
+ /** Expire the silences `record` holds (or `silenceId`). Defaults to the `fastIdempotent` profile. */
49
+ export const alertmanagerUnsilence = (args = {}) => step("alertmanagerUnsilence", "fastIdempotent")(args);
50
+ /**
51
+ * Observe declared rule groups for a `ConvergeOp({ observe })`: one resource
52
+ * per group, drifted when Prometheus has not loaded it or a rule in it has
53
+ * `health: "err"`. Defaults to the `fastIdempotent` profile.
54
+ */
55
+ export const rulesLoadedObserve = step("rulesLoadedObserve", "fastIdempotent");
56
+ /** Audit a live Prometheus's rules, alerts and selectors (the step of `RuleAuditOp`). Defaults to the `fastIdempotent` profile. */
57
+ export const ruleAudit = (args = {}) => step("ruleAudit", "fastIdempotent")(args);
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `promtool test rules` input generated from `Slo` declarations (#3369),
3
+ * lifted from `composites/slo-burn.test.ts`.
4
+ *
5
+ * For each burn-rate pair an `Slo` builds, two synthetic scenarios over a
6
+ * good and a bad request counter: healthy for the pair's long window, then
7
+ * burning the error budget at 1.25 times the pair's factor (it must fire)
8
+ * and at 0.9 times it (it must not). The small evaluator in `rule-eval.ts`
9
+ * runs the SLO's rules over each scenario and says which alerts fire at a
10
+ * few evaluation times around the expected onset; those become the
11
+ * `alert_rule_test` expectations, so `promtool test rules` holds Prometheus
12
+ * to the same answer.
13
+ *
14
+ * The SLI expressions are the author's, so the generator cannot invent the
15
+ * series they read: `good` and `bad` name one series each (`metric{labels}`),
16
+ * counters such that the SLI counts `good` as good events and `bad` as bad
17
+ * ones. The evaluator throws on PromQL it does not implement, which then
18
+ * fails here, at authoring time, rather than in promtool.
19
+ *
20
+ * Pairs whose long window is a day or more step in five minutes, the rest in
21
+ * one, and promtool's `evaluation_interval` is per file, so the result is
22
+ * one test file per step size.
23
+ */
24
+ import { dump } from "js-yaml";
25
+ import { sloMetrics } from "../composites/slo.js";
26
+ import { ruleGroupConfig } from "../rules.js";
27
+ import { RuleEvaluator } from "../rule-eval.js";
28
+ import { durationMs } from "../duration.js";
29
+ const MIN = 60000;
30
+ /** `metric{a="1",b="2"}` as a label set with `__name__`. */
31
+ export function seriesLabels(series) {
32
+ const m = /^\s*([A-Za-z_:][A-Za-z0-9_:]*)\s*(?:\{(.*)\})?\s*$/.exec(series);
33
+ if (!m)
34
+ throw new Error(`"${series}" is not a series: write metric{label="value",...}`);
35
+ const labels = { __name__: m[1] };
36
+ const body = m[2]?.trim();
37
+ if (body) {
38
+ const re = /\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"((?:[^"\\]|\\.)*)"\s*(?:,|$)/y;
39
+ let pos = 0;
40
+ while (pos < body.length) {
41
+ re.lastIndex = pos;
42
+ const p = re.exec(body);
43
+ if (!p)
44
+ throw new Error(`"${series}": label matchers must be name="value" (equality only)`);
45
+ labels[p[1]] = p[2].replace(/\\(.)/g, "$1");
46
+ pos = re.lastIndex;
47
+ }
48
+ }
49
+ return labels;
50
+ }
51
+ /** Counter samples as promtool's `a+bxn` segments. */
52
+ export function seriesValues(values) {
53
+ const parts = [];
54
+ let i = 0;
55
+ while (i < values.length) {
56
+ const start = values[i];
57
+ if (i + 1 >= values.length) {
58
+ parts.push(`${start}`);
59
+ break;
60
+ }
61
+ const inc = values[i + 1] - values[i];
62
+ let j = i + 1;
63
+ while (j + 1 < values.length && Math.abs(values[j + 1] - values[j] - inc) < 1e-9)
64
+ j++;
65
+ parts.push(`${start}+${inc}x${j - i}`);
66
+ i = j + 1;
67
+ }
68
+ return parts.join(" ");
69
+ }
70
+ function isPair(a, b) {
71
+ return a.labels.alertname === b.alert && Object.entries(b.labels).every(([k, v]) => a.labels[k] === v);
72
+ }
73
+ const minutes = (d) => durationMs(d) / MIN;
74
+ /** One scenario: the counters' samples, and the evaluator's firing alerts by minute. */
75
+ function simulate(input, s) {
76
+ const m = sloMetrics(input.slo);
77
+ const rate = input.rate ?? 1000;
78
+ const goodLabels = seriesLabels(input.good);
79
+ const badLabels = seriesLabels(input.bad);
80
+ const ev = new RuleEvaluator([ruleGroupConfig(input.slo.rules)]);
81
+ const total = s.healthy + s.burning;
82
+ const good = [];
83
+ const bad = [];
84
+ let g = 0;
85
+ let e = 0;
86
+ const timeline = new Map();
87
+ for (let minute = 0; minute <= total; minute += s.step) {
88
+ if (minute > 0) {
89
+ const burning = minute > s.healthy && minute <= s.healthy + s.burning;
90
+ const b = burning ? rate * s.step * s.burn * m.errorBudget : 0;
91
+ e += b;
92
+ g += rate * s.step - b;
93
+ }
94
+ good.push(g);
95
+ bad.push(e);
96
+ ev.add(goodLabels, minute * MIN, g);
97
+ ev.add(badLabels, minute * MIN, e);
98
+ timeline.set(minute, ev.step(minute * MIN));
99
+ }
100
+ return { good, bad, timeline };
101
+ }
102
+ function testGroup(input, s, checkAt) {
103
+ const m = sloMetrics(input.slo);
104
+ const alertRules = ruleGroupConfig(input.slo.rules).rules.filter((r) => "alert" in r);
105
+ const { good, bad, timeline } = simulate(input, s);
106
+ return {
107
+ interval: `${s.step}m`,
108
+ input_series: [
109
+ { series: input.good, values: seriesValues(good) },
110
+ { series: input.bad, values: seriesValues(bad) },
111
+ ],
112
+ alert_rule_test: checkAt.map((minute) => ({
113
+ eval_time: `${minute}m`,
114
+ alertname: m.alertName,
115
+ exp_alerts: (timeline.get(minute) ?? [])
116
+ .filter((a) => a.labels.alertname === m.alertName)
117
+ .map((a) => {
118
+ const rule = alertRules.find((r) => Object.entries(r.labels ?? {}).every(([k, v]) => a.labels[k] === v));
119
+ const { alertname: _, ...labels } = a.labels;
120
+ return { exp_labels: labels, ...(rule?.annotations ? { exp_annotations: rule.annotations } : {}) };
121
+ }),
122
+ })),
123
+ };
124
+ }
125
+ /**
126
+ * The `promtool test rules` files for these SLOs, one per step size, each
127
+ * naming `rules.yml` (where `promtoolTestRules` writes the rule file).
128
+ */
129
+ export function sloRuleTests(inputs) {
130
+ const byStep = new Map();
131
+ for (const input of inputs) {
132
+ for (const b of sloMetrics(input.slo).burnRates) {
133
+ const long = minutes(b.long);
134
+ const step = long >= 24 * 60 ? 5 : 1;
135
+ const snap = (x) => Math.round(x / step) * step;
136
+ const above = { step, healthy: long, burning: long, burn: b.factor * 1.25 };
137
+ const fireAt = above.healthy + ((long - step) * b.factor) / above.burn;
138
+ const below = { step, healthy: long, burning: long, burn: b.factor * 0.9 };
139
+ const groups = byStep.get(step) ?? [];
140
+ groups.push(testGroup(input, above, [snap(fireAt - 4 * step), snap(fireAt + 4 * step), above.healthy + long]));
141
+ groups.push(testGroup(input, below, [below.healthy + long]));
142
+ byStep.set(step, groups);
143
+ }
144
+ }
145
+ return [...byStep.entries()]
146
+ .sort(([a], [b]) => a - b)
147
+ .map(([step, tests]) => dump({ rule_files: ["rules.yml"], evaluation_interval: `${step}m`, tests }, { lineWidth: -1 }));
148
+ }
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Thin entry point for `npm run bundle` in lexicon-prometheus.
4
+ */
5
+ import { join, dirname } from "path";
6
+ import { fileURLToPath } from "url";
7
+ import { writeBundleSpec } from "@intentius/chant/codegen/package";
8
+ import { generate, writeGeneratedFiles } from "./codegen/generate.js";
9
+ import { packageLexicon } from "./codegen/package.js";
10
+ const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
11
+ writeGeneratedFiles(await generate({ verbose: true }));
12
+ const { spec, stats } = await packageLexicon({ verbose: true });
13
+ writeBundleSpec(spec, join(pkgDir, "dist"));
14
+ console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
package/dist/pin.js ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The upstream releases this lexicon's hand-written types follow.
3
+ *
4
+ * Neither Prometheus nor Alertmanager publishes a machine-readable schema for
5
+ * its config files, so the types in `model.ts` are written against the Go
6
+ * structs of these releases (`prometheus/model/rulefmt`,
7
+ * `alertmanager/config`). Moving a version is a lexicon change like any
8
+ * other, released with this package.
9
+ */
10
+ export const PROMETHEUS_PIN = Object.freeze({
11
+ prometheus: Object.freeze({ source: "github.com/prometheus/prometheus", version: "v3.15.0" }),
12
+ alertmanager: Object.freeze({ source: "github.com/prometheus/alertmanager", version: "v0.34.1" }),
13
+ });
package/dist/plugin.js ADDED
@@ -0,0 +1,143 @@
1
+ import { createDiffTool } from "@intentius/chant/lexicon-plugin-helpers";
2
+ import { prometheusSerializer } from "./serializer.js";
3
+ import { rules } from "./lint/rules/index.js";
4
+ import { postSynthChecks } from "./lint/post-synth/index.js";
5
+ import { OPT_IN_CHECKS, prometheusAuditCatalog } from "./lint/audit-catalog.js";
6
+ import { completions } from "./lsp/completions.js";
7
+ import { hover } from "./lsp/hover.js";
8
+ import { detectTemplate } from "./detect.js";
9
+ import { PrometheusParser } from "./import/parser.js";
10
+ import { PrometheusGenerator } from "./import/generator.js";
11
+ import { alertmanagerImporter, prometheusConfigImporter, ruleGroupsImporter } from "./import/embedded.js";
12
+ import { DEFAULT_TEMPLATE, RULES_TEMPLATE, SLO_STYLE_TEMPLATE, SLO_TEMPLATE } from "./init-templates.js";
13
+ import { prometheusSkills } from "./skill-defs.js";
14
+ import { CATALOG } from "./catalog.js";
15
+ import { PROMETHEUS_PIN } from "./pin.js";
16
+ import { PROMQL_GRAMMAR } from "./promql.js";
17
+ import { compositeCatalog } from "./composites/catalog.js";
18
+ import { prometheusConfigSchema } from "./config.js";
19
+ const catalogResource = {
20
+ uri: "prometheus:resource-catalog",
21
+ name: "Prometheus entity catalog",
22
+ description: "The rule group and Alertmanager entities this lexicon types, with the Prometheus and Alertmanager releases they follow",
23
+ mimeType: "application/json",
24
+ async handler() {
25
+ return JSON.stringify({ pin: PROMETHEUS_PIN, promql: PROMQL_GRAMMAR, entities: CATALOG });
26
+ },
27
+ };
28
+ /**
29
+ * Prometheus lexicon plugin.
30
+ *
31
+ * Typed recording and alerting rule groups, serialized to a Prometheus rule
32
+ * file, and Alertmanager routes, receivers, inhibit rules and time
33
+ * intervals, serialized to `alertmanager.yml`.
34
+ */
35
+ export const prometheusPlugin = {
36
+ name: "prometheus",
37
+ serializer: prometheusSerializer,
38
+ configSchema: prometheusConfigSchema,
39
+ // ── Required lifecycle methods ────────────────────────────────
40
+ async generate(options) {
41
+ const { generate, writeGeneratedFiles } = await import("./codegen/generate.js");
42
+ writeGeneratedFiles(await generate(options));
43
+ },
44
+ async validate(_options) {
45
+ const { validate } = await import("./validate.js");
46
+ const { printValidationResult } = await import("@intentius/chant/codegen/validate");
47
+ printValidationResult(await validate());
48
+ },
49
+ async coverage(_options) {
50
+ const { prometheus, alertmanager } = PROMETHEUS_PIN;
51
+ console.error(`prometheus: ${CATALOG.length} entities`);
52
+ console.error(` rule file (${prometheus.source} ${prometheus.version}): RuleGroup, recording and alerting rules`);
53
+ console.error(` prometheus.yml (${prometheus.source} ${prometheus.version}): ScrapeConfig, PrometheusConfig`);
54
+ console.error(` alertmanager.yml (${alertmanager.source} ${alertmanager.version}): ` +
55
+ CATALOG.filter((c) => c.file === "alertmanager.yml").map((c) => c.className).join(", "));
56
+ console.error(" receivers typed: webhook, email, slack, pagerduty");
57
+ },
58
+ async package(options) {
59
+ const { packageLexicon } = await import("./codegen/package.js");
60
+ const { writeBundleSpec } = await import("@intentius/chant/codegen/package");
61
+ const { join, dirname } = await import("path");
62
+ const { fileURLToPath } = await import("url");
63
+ const { spec, stats } = await packageLexicon(options);
64
+ const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
65
+ writeBundleSpec(spec, join(pkgDir, "dist"));
66
+ console.error(`Packaged ${stats.resources} entities, ${stats.ruleCount} rules, ${stats.skillCount} skills`);
67
+ },
68
+ // ── Optional extensions ────────────────────────────────────
69
+ lintRules() {
70
+ return rules;
71
+ },
72
+ postSynthChecks() {
73
+ return postSynthChecks;
74
+ },
75
+ auditCatalog() {
76
+ return prometheusAuditCatalog;
77
+ },
78
+ /**
79
+ * `recommended` (what `chant build` reports by default) is every check but
80
+ * the opt-in ones; `all` adds them. A project turns one on with
81
+ * `lint.presets: { prometheus: "all" }`, or by naming it in `lint.rules`
82
+ * (`{ PROM212: "warning" }`), which keeps a check whatever the preset.
83
+ */
84
+ lintPresets() {
85
+ const all = Object.keys(prometheusAuditCatalog);
86
+ return { recommended: all.filter((id) => !OPT_IN_CHECKS.has(id)), all };
87
+ },
88
+ skills: prometheusSkills,
89
+ composites() {
90
+ return compositeCatalog;
91
+ },
92
+ mcpTools() {
93
+ return [
94
+ createDiffTool(prometheusSerializer, "Compare the current rule file and alertmanager.yml output against the previous build", "prometheus"),
95
+ ];
96
+ },
97
+ mcpResources() {
98
+ return [catalogResource];
99
+ },
100
+ detectTemplate(data) {
101
+ return detectTemplate(data);
102
+ },
103
+ templateParser() {
104
+ return new PrometheusParser();
105
+ },
106
+ templateGenerator() {
107
+ return new PrometheusGenerator();
108
+ },
109
+ embeddedImporters() {
110
+ return [ruleGroupsImporter, alertmanagerImporter, prometheusConfigImporter];
111
+ },
112
+ // `chant init --lexicon prometheus [--template rules|slo-style|slo]`; see ./init-templates.ts.
113
+ initTemplates(template) {
114
+ if (template === "rules")
115
+ return RULES_TEMPLATE;
116
+ if (template === "slo-style")
117
+ return SLO_STYLE_TEMPLATE;
118
+ if (template === "slo")
119
+ return SLO_TEMPLATE;
120
+ return DEFAULT_TEMPLATE;
121
+ },
122
+ completionProvider(ctx) {
123
+ return completions(ctx);
124
+ },
125
+ hoverProvider(ctx) {
126
+ return hover(ctx);
127
+ },
128
+ // ── Live observation and export (#3371) ──────────────────────
129
+ // Behind dynamic imports, so `chant build` never loads a transport. The
130
+ // ruler and Alertmanager are named by `prometheus.profiles.<env>` (./config.ts).
131
+ async describeResources(options) {
132
+ const { describeResources } = await import("./describe-resources.js");
133
+ return describeResources(options);
134
+ },
135
+ async exportResources(options) {
136
+ const { exportResources } = await import("./export-resources.js");
137
+ return exportResources(options);
138
+ },
139
+ async docs(options) {
140
+ const { generateDocs } = await import("./codegen/docs.js");
141
+ return generateDocs(options);
142
+ },
143
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * `PrometheusConfig` and `ScrapeConfig`: the entities that serialize to
3
+ * `prometheus.yml`.
4
+ *
5
+ * A `ScrapeConfig` is one scrape job. `PrometheusConfig` holds the rest of the
6
+ * file (`global`, `alerting`, `rule_files`, `remote_write`, `remote_read`,
7
+ * `otlp`) and is optional: a build that declares only `ScrapeConfig`s still
8
+ * writes a `prometheus.yml`. A project that declares neither writes none, so
9
+ * a project that builds only rule files sees no change.
10
+ */
11
+ import { createResource } from "@intentius/chant/runtime";
12
+ export const SCRAPE_CONFIG_TYPE = "Prometheus::Config::ScrapeConfig";
13
+ export const PROMETHEUS_CONFIG_TYPE = "Prometheus::Config::PrometheusConfig";
14
+ function entityClass(entityType, className) {
15
+ const Base = createResource(entityType, "prometheus", {});
16
+ const Cls = function (props) {
17
+ Base.call(this, (props ?? {}));
18
+ };
19
+ Object.defineProperty(Cls, "name", { value: className });
20
+ return Cls;
21
+ }
22
+ /**
23
+ * One scrape job: where its targets come from (`static_configs` or a service
24
+ * discovery list), how to scrape them, and how to relabel what comes back.
25
+ */
26
+ export const ScrapeConfig = entityClass(SCRAPE_CONFIG_TYPE, "ScrapeConfig");
27
+ /** The sections of `prometheus.yml` other than the scrape jobs. Declare at most one. */
28
+ export const PrometheusConfig = entityClass(PROMETHEUS_CONFIG_TYPE, "PrometheusConfig");
29
+ // ── Guards ──────────────────────────────────────────────────────────
30
+ function isType(type) {
31
+ return (value) => typeof value === "object" && value !== null && value.entityType === type;
32
+ }
33
+ export const isScrapeConfig = isType(SCRAPE_CONFIG_TYPE);
34
+ export const isPrometheusConfig = isType(PROMETHEUS_CONFIG_TYPE);
35
+ /** True for an entity that goes into `prometheus.yml`. */
36
+ export function isPrometheusConfigEntity(value) {
37
+ return isScrapeConfig(value) || isPrometheusConfig(value);
38
+ }