@intentius/chant-lexicon-prometheus 0.106.3 → 0.107.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/api/alertmanager.d.ts +56 -0
- package/dist/api/alertmanager.d.ts.map +1 -0
- package/dist/api/apply.d.ts +126 -0
- package/dist/api/apply.d.ts.map +1 -0
- package/dist/api/bind.d.ts +49 -0
- package/dist/api/bind.d.ts.map +1 -0
- package/dist/api/client.d.ts +76 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/evaluated.d.ts +36 -0
- package/dist/api/evaluated.d.ts.map +1 -0
- package/dist/api/fake-servers.d.ts +74 -0
- package/dist/api/fake-servers.d.ts.map +1 -0
- package/dist/api/index.d.ts +18 -0
- package/dist/api/index.d.ts.map +1 -0
- package/dist/api/ruler.d.ts +87 -0
- package/dist/api/ruler.d.ts.map +1 -0
- package/dist/composites/alert-routing.d.ts +104 -0
- package/dist/composites/alert-routing.d.ts.map +1 -0
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/index.d.ts +6 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/red-alerts.d.ts +96 -0
- package/dist/composites/red-alerts.d.ts.map +1 -0
- package/dist/composites/watchdog.d.ts +69 -0
- package/dist/composites/watchdog.d.ts.map +1 -0
- package/dist/config.d.ts +273 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +48 -0
- package/dist/describe-resources.d.ts.map +1 -0
- package/dist/export-resources.d.ts +41 -0
- package/dist/export-resources.d.ts.map +1 -0
- package/dist/import/alertmanager-live.d.ts +35 -0
- package/dist/import/alertmanager-live.d.ts.map +1 -0
- package/dist/import/parser.d.ts +12 -0
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/lint/post-synth/prom-helpers.d.ts +11 -3
- package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +25 -0
- package/dist/op/activities/index.d.ts.map +1 -0
- package/dist/op/activities/promtool.d.ts +78 -0
- package/dist/op/activities/promtool.d.ts.map +1 -0
- package/dist/op/activities/rule-audit.d.ts +65 -0
- package/dist/op/activities/rule-audit.d.ts.map +1 -0
- package/dist/op/activities/rules-loaded.d.ts +60 -0
- package/dist/op/activities/rules-loaded.d.ts.map +1 -0
- package/dist/op/activities/silence.d.ts +80 -0
- package/dist/op/activities/silence.d.ts.map +1 -0
- package/dist/op/activity-contracts.d.ts +113 -0
- package/dist/op/activity-contracts.d.ts.map +1 -0
- package/dist/op/audit-op.d.ts +38 -0
- package/dist/op/audit-op.d.ts.map +1 -0
- package/dist/op/builders.d.ts +57 -0
- package/dist/op/builders.d.ts.map +1 -0
- package/dist/op/slo-rule-tests.d.ts +44 -0
- package/dist/op/slo-rule-tests.d.ts.map +1 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/prom-helpers.ts +38 -3
- package/package.json +19 -3
- package/src/api/alertmanager.ts +122 -0
- package/src/api/apply.test.ts +218 -0
- package/src/api/apply.ts +375 -0
- package/src/api/bind.ts +87 -0
- package/src/api/client.ts +138 -0
- package/src/api/evaluated.test.ts +78 -0
- package/src/api/evaluated.ts +106 -0
- package/src/api/fake-servers.ts +209 -0
- package/src/api/index.ts +29 -0
- package/src/api/ruler.ts +168 -0
- package/src/api/testdata/am-status-original.yml +85 -0
- package/src/composites/alert-routing.test.ts +163 -0
- package/src/composites/alert-routing.ts +277 -0
- package/src/composites/catalog.test.ts +10 -2
- package/src/composites/catalog.ts +214 -0
- package/src/composites/index.ts +14 -0
- package/src/composites/red-alerts.test.ts +182 -0
- package/src/composites/red-alerts.ts +214 -0
- package/src/composites/watchdog.test.ts +85 -0
- package/src/composites/watchdog.ts +142 -0
- package/src/config.test.ts +93 -0
- package/src/config.ts +288 -0
- package/src/describe-resources.test.ts +325 -0
- package/src/describe-resources.ts +328 -0
- package/src/export-resources.test.ts +253 -0
- package/src/export-resources.ts +193 -0
- package/src/import/alertmanager-live.test.ts +66 -0
- package/src/import/alertmanager-live.ts +291 -0
- package/src/import/parser.ts +18 -1
- package/src/import/roundtrip.test.ts +1 -0
- package/src/index.ts +37 -0
- package/src/lint/post-synth/prom-helpers.ts +38 -3
- package/src/op/activities/index.ts +31 -0
- package/src/op/activities/promtool.test.ts +107 -0
- package/src/op/activities/promtool.ts +149 -0
- package/src/op/activities/rule-audit.test.ts +89 -0
- package/src/op/activities/rule-audit.ts +168 -0
- package/src/op/activities/rules-loaded.test.ts +61 -0
- package/src/op/activities/rules-loaded.ts +98 -0
- package/src/op/activities/silence.test.ts +107 -0
- package/src/op/activities/silence.ts +175 -0
- package/src/op/activity-contracts.test.ts +141 -0
- package/src/op/activity-contracts.ts +96 -0
- package/src/op/audit-op.ts +54 -0
- package/src/op/builders.ts +82 -0
- package/src/op/slo-rule-tests.test.ts +84 -0
- package/src/op/slo-rule-tests.ts +173 -0
- package/src/plugin.ts +16 -0
|
@@ -0,0 +1,149 @@
|
|
|
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
|
+
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { resolve } from "node:path";
|
|
18
|
+
import { nonRetryableFailure } from "@intentius/chant/op";
|
|
19
|
+
import { defaultExec, type ExecRunner } from "@intentius/chant-lexicon-otel/op/activities/exec";
|
|
20
|
+
import {
|
|
21
|
+
amtoolCheckConfig as amtoolCheckConfigText,
|
|
22
|
+
promtoolCheckRules as promtoolCheckRulesText,
|
|
23
|
+
promtoolTestRules as promtoolTestRulesText,
|
|
24
|
+
type ToolResult,
|
|
25
|
+
} from "../../tools";
|
|
26
|
+
|
|
27
|
+
export interface ToolStepResult {
|
|
28
|
+
/** The files checked. */
|
|
29
|
+
files: string[];
|
|
30
|
+
ok: boolean;
|
|
31
|
+
output: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PromtoolCheckRulesArgs {
|
|
35
|
+
/** One rule file or several, e.g. `dist/prometheus/rules.yml`. */
|
|
36
|
+
rules: string | string[];
|
|
37
|
+
/** The promtool binary. Default `$PROMTOOL`, else `promtool`. */
|
|
38
|
+
bin?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PromtoolTestRulesArgs {
|
|
42
|
+
/** The rule file under test, e.g. `dist/prometheus/rules.yml`. */
|
|
43
|
+
rules: string;
|
|
44
|
+
/** Unit-test files, each naming `rules.yml` in `rule_files:`. */
|
|
45
|
+
tests?: string | string[];
|
|
46
|
+
/** Unit-test documents given inline, such as the ones `sloRuleTests()` generates. */
|
|
47
|
+
testYaml?: string | string[];
|
|
48
|
+
/** The promtool binary. Default `$PROMTOOL`, else `promtool`. */
|
|
49
|
+
bin?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface AmtoolCheckConfigArgs {
|
|
53
|
+
/** The Alertmanager config, e.g. `dist/prometheus/alertmanager.yml`. */
|
|
54
|
+
config: string;
|
|
55
|
+
/** The amtool binary. Default `$AMTOOL`, else `amtool`. */
|
|
56
|
+
bin?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface AmtoolRoutesTestArgs {
|
|
60
|
+
/** The Alertmanager config. */
|
|
61
|
+
config: string;
|
|
62
|
+
/** The alert's labels. */
|
|
63
|
+
labels: Record<string, string>;
|
|
64
|
+
/** The receiver, or receivers in order, the labels must route to. */
|
|
65
|
+
expect: string | string[];
|
|
66
|
+
/** The amtool binary. Default `$AMTOOL`, else `amtool`. */
|
|
67
|
+
bin?: string;
|
|
68
|
+
/** Replaces the child process. For tests. */
|
|
69
|
+
_exec?: ExecRunner;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface AmtoolRoutesTestResult {
|
|
73
|
+
config: string;
|
|
74
|
+
labels: Record<string, string>;
|
|
75
|
+
/** The receivers amtool resolved, in order. */
|
|
76
|
+
receivers: string[];
|
|
77
|
+
ok: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const list = (v: string | string[] | undefined): string[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]);
|
|
81
|
+
const read = (path: string): string => readFileSync(resolve(process.cwd(), path), "utf8");
|
|
82
|
+
|
|
83
|
+
function settle(tool: string, files: string[], r: ToolResult, bin: string): ToolStepResult {
|
|
84
|
+
if (!r.ran) throw new Error(`${bin} is not installed (or does not run): ${tool} did not check ${files.join(", ")}. Install it or set bin.`);
|
|
85
|
+
if (!r.ok) throw nonRetryableFailure(`${tool} rejected ${files.join(", ")}:\n${r.output.trim()}`, "PrometheusToolRejected");
|
|
86
|
+
return { files, ok: true, output: r.output.trim() };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const promtoolBin = (bin?: string) => bin ?? process.env.PROMTOOL ?? "promtool";
|
|
90
|
+
const amtoolBin = (bin?: string) => bin ?? process.env.AMTOOL ?? "amtool";
|
|
91
|
+
|
|
92
|
+
/** `promtool check rules` over each rule file. */
|
|
93
|
+
export async function promtoolCheckRules(args: PromtoolCheckRulesArgs): Promise<ToolStepResult> {
|
|
94
|
+
const bin = promtoolBin(args.bin);
|
|
95
|
+
const files = list(args.rules);
|
|
96
|
+
if (files.length === 0) throw new Error("promtoolCheckRules: no rule file given");
|
|
97
|
+
const outputs: string[] = [];
|
|
98
|
+
for (const file of files) outputs.push(settle("promtool check rules", [file], promtoolCheckRulesText(read(file), bin), bin).output);
|
|
99
|
+
return { files, ok: true, output: outputs.join("\n") };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `promtool test rules` over the rule file, once per test file or inline test document. */
|
|
103
|
+
export async function promtoolTestRules(args: PromtoolTestRulesArgs): Promise<ToolStepResult & { tests: number }> {
|
|
104
|
+
const bin = promtoolBin(args.bin);
|
|
105
|
+
const rules = read(args.rules);
|
|
106
|
+
const docs: Array<[string, string]> = [
|
|
107
|
+
...list(args.tests).map((f): [string, string] => [f, read(f)]),
|
|
108
|
+
...list(args.testYaml).map((y, i): [string, string] => [`testYaml[${i}]`, y]),
|
|
109
|
+
];
|
|
110
|
+
if (docs.length === 0) throw new Error("promtoolTestRules: give tests or testYaml (or slos to the builder)");
|
|
111
|
+
const outputs: string[] = [];
|
|
112
|
+
for (const [name, yaml] of docs) outputs.push(settle("promtool test rules", [args.rules, name], promtoolTestRulesText(rules, yaml, bin), bin).output);
|
|
113
|
+
return { files: [args.rules, ...list(args.tests)], ok: true, output: outputs.join("\n"), tests: docs.length };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** `amtool check-config` over the Alertmanager config. */
|
|
117
|
+
export async function amtoolCheckConfig(args: AmtoolCheckConfigArgs): Promise<ToolStepResult> {
|
|
118
|
+
const bin = amtoolBin(args.bin);
|
|
119
|
+
return settle("amtool check-config", [args.config], amtoolCheckConfigText(read(args.config), bin), bin);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The receivers amtool printed: its last non-empty line, comma-separated
|
|
124
|
+
* (`oncall` or `oncall,tickets`).
|
|
125
|
+
*/
|
|
126
|
+
export function parseRoutesOutput(output: string): string[] {
|
|
127
|
+
const lines = output.split("\n").map((l) => l.trim()).filter((l) => l !== "" && !/^warning/i.test(l));
|
|
128
|
+
const last = lines[lines.length - 1] ?? "";
|
|
129
|
+
return last.split(",").map((s) => s.trim()).filter(Boolean);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** `amtool config routes test`: the labels route to the expected receivers, in order. */
|
|
133
|
+
export async function amtoolRoutesTest(args: AmtoolRoutesTestArgs): Promise<AmtoolRoutesTestResult> {
|
|
134
|
+
const exec = args._exec ?? defaultExec;
|
|
135
|
+
const bin = amtoolBin(args.bin);
|
|
136
|
+
const expected = list(args.expect);
|
|
137
|
+
const pairs = Object.entries(args.labels).map(([k, v]) => `${k}=${v}`);
|
|
138
|
+
const r = await exec(bin, ["config", "routes", "test", `--config.file=${resolve(process.cwd(), args.config)}`, ...pairs], { timeoutMs: 30_000 });
|
|
139
|
+
if (r.code === null) throw new Error(`could not run ${bin}: ${r.error ?? "not found"}. Install amtool or set bin.`);
|
|
140
|
+
if (r.code !== 0) throw nonRetryableFailure(`amtool config routes test failed (exit ${r.code}):\n${(r.stdout + r.stderr).trim()}`, "PrometheusToolRejected");
|
|
141
|
+
const receivers = parseRoutesOutput(r.stdout);
|
|
142
|
+
if (receivers.join(",") !== expected.join(",")) {
|
|
143
|
+
throw nonRetryableFailure(
|
|
144
|
+
`${pairs.join(" ")} routes to ${receivers.join(",") || "(none)"} in ${args.config}, expected ${expected.join(",")}`,
|
|
145
|
+
"PrometheusRouteMismatch",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return { config: args.config, labels: args.labels, receivers, ok: true };
|
|
149
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import type { CommandRunner } from "@intentius/chant-lexicon-otel/op/activities/github";
|
|
3
|
+
import { auditedSelectors, ruleAudit } from "./rule-audit";
|
|
4
|
+
import type { ApiRuleGroup } from "./rules-loaded";
|
|
5
|
+
|
|
6
|
+
const GROUPS: ApiRuleGroup[] = [
|
|
7
|
+
{
|
|
8
|
+
name: "slo-checkout",
|
|
9
|
+
rules: [
|
|
10
|
+
{ name: "slo:sli_error:ratio_rate5m", type: "recording", health: "ok", query: 'sum(rate(requests_total{code=~"5.."}[5m])) / sum(rate(requests_total[5m]))' },
|
|
11
|
+
{ name: "SloBurn", type: "alerting", health: "ok", query: 'slo:sli_error:ratio_rate5m{slo="checkout"} > 0.01' },
|
|
12
|
+
],
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
name: "broken",
|
|
16
|
+
rules: [{ name: "Gone", type: "alerting", health: "err", lastError: "many-to-many matching not allowed", query: "absent(up{job=\"gone\"}) and ALERTS" }],
|
|
17
|
+
},
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const NOW = new Date("2026-10-06T12:00:00Z");
|
|
21
|
+
|
|
22
|
+
/** A Prometheus API stand-in. `empty` lists the selectors whose count query returns nothing. */
|
|
23
|
+
function fakePrometheus(alerts: unknown[], empty: string[] = []) {
|
|
24
|
+
const queries: string[] = [];
|
|
25
|
+
const f = (async (input: string | URL | Request) => {
|
|
26
|
+
const url = new URL(String(input));
|
|
27
|
+
if (url.pathname === "/api/v1/rules") return Response.json({ status: "success", data: { groups: GROUPS } });
|
|
28
|
+
if (url.pathname === "/api/v1/alerts") return Response.json({ status: "success", data: { alerts } });
|
|
29
|
+
if (url.pathname === "/api/v1/query") {
|
|
30
|
+
const q = url.searchParams.get("query")!;
|
|
31
|
+
queries.push(q);
|
|
32
|
+
const isEmpty = empty.some((s) => q === `count(last_over_time(${s}[1h]))`);
|
|
33
|
+
return Response.json({ status: "success", data: { resultType: "vector", result: isEmpty ? [] : [{ metric: {}, value: [0, "3"] }] } });
|
|
34
|
+
}
|
|
35
|
+
return new Response("", { status: 404 });
|
|
36
|
+
}) as typeof fetch;
|
|
37
|
+
return { f, queries };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("ruleAudit", () => {
|
|
41
|
+
test("the selectors audited: distinct, without recorded series and ALERTS", () => {
|
|
42
|
+
expect(auditedSelectors(GROUPS)).toEqual(['requests_total', 'requests_total{code=~"5.."}', 'up{job="gone"}']);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("rule errors, alerts past their thresholds, and selectors nothing emits", async () => {
|
|
46
|
+
const alerts = [
|
|
47
|
+
{ labels: { alertname: "SloBurn", slo: "checkout" }, state: "pending", activeAt: "2026-10-06T09:00:00Z" },
|
|
48
|
+
{ labels: { alertname: "SloBurn", slo: "search" }, state: "pending", activeAt: "2026-10-06T11:50:00Z" },
|
|
49
|
+
{ labels: { alertname: "Gone" }, state: "firing", activeAt: "2026-10-04T12:00:00Z" },
|
|
50
|
+
];
|
|
51
|
+
const { f, queries } = fakePrometheus(alerts, ['up{job="gone"}']);
|
|
52
|
+
const r = await ruleAudit({ url: "http://prom:9090", _fetch: f, _now: () => NOW });
|
|
53
|
+
expect(r.findings).toEqual([
|
|
54
|
+
{ kind: "rule-error", subject: "broken", detail: "Gone: many-to-many matching not allowed" },
|
|
55
|
+
{ kind: "pending-too-long", subject: 'SloBurn{slo="checkout"}', detail: "pending for 3.0h" },
|
|
56
|
+
{ kind: "firing-too-long", subject: "Gone{}", detail: "firing for 48.0h" },
|
|
57
|
+
{ kind: "selector-no-series", subject: 'up{job="gone"}', detail: "no series in the last 1h" },
|
|
58
|
+
]);
|
|
59
|
+
expect(queries).toHaveLength(3);
|
|
60
|
+
expect(r.queried).toBe(3);
|
|
61
|
+
expect(r.summary).toContain("| selector-no-series |");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("the selector budget caps the queries; the rest are counted unchecked", async () => {
|
|
65
|
+
const { f, queries } = fakePrometheus([]);
|
|
66
|
+
const r = await ruleAudit({ _fetch: f, selectorBudget: 1, _now: () => NOW });
|
|
67
|
+
expect(queries).toHaveLength(1);
|
|
68
|
+
expect(r.unchecked).toBe(2);
|
|
69
|
+
expect(r.summary).toContain("2 left unchecked by the budget");
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("issue mode keeps one issue current, and only when there are findings", async () => {
|
|
73
|
+
const calls: string[][] = [];
|
|
74
|
+
const run: CommandRunner = async (bin, args) => {
|
|
75
|
+
calls.push([bin, ...args]);
|
|
76
|
+
if (args[1] === "list") return JSON.stringify([{ number: 7, title: "prometheus: rule audit findings", url: "https://x/issues/7" }]);
|
|
77
|
+
return "";
|
|
78
|
+
};
|
|
79
|
+
const { f } = fakePrometheus([]);
|
|
80
|
+
const r = await ruleAudit({ _fetch: f, mode: "issue", _run: run, _now: () => NOW });
|
|
81
|
+
expect(r.issueUrl).toBe("https://x/issues/7");
|
|
82
|
+
expect(calls[1].slice(0, 4)).toEqual(["gh", "issue", "edit", "7"]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("an unreadable rules API fails the audit rather than reporting nothing", async () => {
|
|
86
|
+
const down = (async () => new Response("", { status: 502 })) as unknown as typeof fetch;
|
|
87
|
+
await expect(ruleAudit({ _fetch: down })).rejects.toThrow(/HTTP 502/);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,168 @@
|
|
|
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
|
+
|
|
23
|
+
import { defaultRunner, stickyIssue, type CommandRunner } from "@intentius/chant-lexicon-otel/op/activities/github";
|
|
24
|
+
import { durationMs } from "../../duration";
|
|
25
|
+
import { selectors } from "../../promql-analysis";
|
|
26
|
+
import { fetchRuleGroups, prometheusUrl, type ApiRuleGroup } from "./rules-loaded";
|
|
27
|
+
|
|
28
|
+
export type RuleAuditMode = "report" | "issue";
|
|
29
|
+
|
|
30
|
+
export interface RuleAuditArgs {
|
|
31
|
+
/** Prometheus's base URL. Default `$PROMETHEUS_URL`, else `http://localhost:9090`. */
|
|
32
|
+
url?: string;
|
|
33
|
+
/** A pending alert older than this is a finding. Default `1h`. */
|
|
34
|
+
pendingFor?: string;
|
|
35
|
+
/** A firing alert older than this is a finding. Default `24h`. */
|
|
36
|
+
firingFor?: string;
|
|
37
|
+
/** At most this many selector queries. Default 50. */
|
|
38
|
+
selectorBudget?: number;
|
|
39
|
+
/** How far back a selector's series may last have been written. Default `1h`. */
|
|
40
|
+
lookback?: string;
|
|
41
|
+
mode?: RuleAuditMode;
|
|
42
|
+
/** The issue's title in `issue` mode. Default `prometheus: rule audit findings`. */
|
|
43
|
+
issueTitle?: string;
|
|
44
|
+
_fetch?: typeof fetch;
|
|
45
|
+
_run?: CommandRunner;
|
|
46
|
+
_now?: () => Date;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type RuleAuditFindingKind = "rule-error" | "pending-too-long" | "firing-too-long" | "selector-no-series";
|
|
50
|
+
|
|
51
|
+
export interface RuleAuditFinding {
|
|
52
|
+
kind: RuleAuditFindingKind;
|
|
53
|
+
/** The group, the alert, or the selector. */
|
|
54
|
+
subject: string;
|
|
55
|
+
detail: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface RuleAuditResult {
|
|
59
|
+
mode: RuleAuditMode;
|
|
60
|
+
findings: RuleAuditFinding[];
|
|
61
|
+
/** Selector queries made. */
|
|
62
|
+
queried: number;
|
|
63
|
+
/** Selectors left unchecked by the budget. */
|
|
64
|
+
unchecked: number;
|
|
65
|
+
summary: string;
|
|
66
|
+
issueUrl?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface ApiAlert {
|
|
70
|
+
labels: Record<string, string>;
|
|
71
|
+
state: string;
|
|
72
|
+
activeAt?: string;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function getJson<T>(f: typeof fetch, url: string): Promise<T> {
|
|
76
|
+
const res = await f(url, { signal: AbortSignal.timeout(15_000) });
|
|
77
|
+
if (!res.ok) throw new Error(`GET ${url}: HTTP ${res.status}`);
|
|
78
|
+
const body = (await res.json()) as { status?: string; data?: T };
|
|
79
|
+
if (body.status !== "success" || body.data === undefined) throw new Error(`GET ${url}: status ${body.status ?? "missing"}`);
|
|
80
|
+
return body.data;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The distinct selectors the rules read, without the ones over recorded series and ALERTS. */
|
|
84
|
+
export function auditedSelectors(groups: ApiRuleGroup[]): string[] {
|
|
85
|
+
const recorded = new Set<string>(["ALERTS", "ALERTS_FOR_STATE"]);
|
|
86
|
+
for (const g of groups) for (const r of g.rules ?? []) if (r.type === "recording") recorded.add(r.name);
|
|
87
|
+
const out = new Set<string>();
|
|
88
|
+
for (const g of groups) {
|
|
89
|
+
for (const r of g.rules ?? []) {
|
|
90
|
+
if (!r.query) continue;
|
|
91
|
+
for (const s of selectors(r.query)) {
|
|
92
|
+
if (s.name && recorded.has(s.name)) continue;
|
|
93
|
+
out.add(s.text.replace(/\s+/g, ""));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...out].sort();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function alertName(a: ApiAlert): string {
|
|
101
|
+
const { alertname, ...rest } = a.labels;
|
|
102
|
+
const extra = Object.entries(rest)
|
|
103
|
+
.sort(([x], [y]) => x.localeCompare(y))
|
|
104
|
+
.map(([k, v]) => `${k}="${v}"`)
|
|
105
|
+
.join(",");
|
|
106
|
+
return `${alertname ?? "(unnamed)"}{${extra}}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function renderRuleAuditSummary(findings: RuleAuditFinding[], queried: number, unchecked: number): string {
|
|
110
|
+
let out = "## Prometheus rule audit\n\n";
|
|
111
|
+
if (findings.length === 0) out += "No findings.\n";
|
|
112
|
+
else {
|
|
113
|
+
out += "| Finding | Subject | Detail |\n|---|---|---|\n";
|
|
114
|
+
for (const f of findings) out += `| ${f.kind} | \`${f.subject.replace(/\|/g, "\\|")}\` | ${f.detail.replace(/\|/g, "\\|")} |\n`;
|
|
115
|
+
}
|
|
116
|
+
out += `\n${queried} selector(s) queried${unchecked > 0 ? `, ${unchecked} left unchecked by the budget` : ""}.\n`;
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Audit a live Prometheus's rules, alerts and selectors. */
|
|
121
|
+
export async function ruleAudit(args: RuleAuditArgs = {}): Promise<RuleAuditResult> {
|
|
122
|
+
const f = args._fetch ?? fetch;
|
|
123
|
+
const url = prometheusUrl(args.url);
|
|
124
|
+
const now = (args._now ?? (() => new Date()))().getTime();
|
|
125
|
+
const mode = args.mode ?? "report";
|
|
126
|
+
const findings: RuleAuditFinding[] = [];
|
|
127
|
+
|
|
128
|
+
const groups = await fetchRuleGroups(f, url);
|
|
129
|
+
for (const g of groups) {
|
|
130
|
+
const errored = (g.rules ?? []).filter((r) => r.health === "err");
|
|
131
|
+
for (const r of errored) findings.push({ kind: "rule-error", subject: g.name, detail: `${r.name}: ${r.lastError || "health err"}` });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const pendingMs = durationMs(args.pendingFor ?? "1h");
|
|
135
|
+
const firingMs = durationMs(args.firingFor ?? "24h");
|
|
136
|
+
if (pendingMs === undefined || firingMs === undefined) throw new Error("pendingFor and firingFor are Prometheus durations such as 1h");
|
|
137
|
+
const { alerts } = await getJson<{ alerts: ApiAlert[] }>(f, `${url}/api/v1/alerts`);
|
|
138
|
+
for (const a of alerts) {
|
|
139
|
+
const since = a.activeAt ? Date.parse(a.activeAt) : NaN;
|
|
140
|
+
if (Number.isNaN(since)) continue;
|
|
141
|
+
const age = now - since;
|
|
142
|
+
const hours = (age / 3_600_000).toFixed(1);
|
|
143
|
+
if (a.state === "pending" && age > pendingMs) findings.push({ kind: "pending-too-long", subject: alertName(a), detail: `pending for ${hours}h` });
|
|
144
|
+
if (a.state === "firing" && age > firingMs) findings.push({ kind: "firing-too-long", subject: alertName(a), detail: `firing for ${hours}h` });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const lookback = args.lookback ?? "1h";
|
|
148
|
+
if (durationMs(lookback) === undefined) throw new Error(`lookback ${JSON.stringify(lookback)} is not a Prometheus duration`);
|
|
149
|
+
const all = auditedSelectors(groups);
|
|
150
|
+
const budget = Math.max(0, args.selectorBudget ?? 50);
|
|
151
|
+
const checked = all.slice(0, budget);
|
|
152
|
+
for (const sel of checked) {
|
|
153
|
+
const query = `count(last_over_time(${sel}[${lookback}]))`;
|
|
154
|
+
try {
|
|
155
|
+
const data = await getJson<{ result: unknown[] }>(f, `${url}/api/v1/query?query=${encodeURIComponent(query)}`);
|
|
156
|
+
if (data.result.length === 0) findings.push({ kind: "selector-no-series", subject: sel, detail: `no series in the last ${lookback}` });
|
|
157
|
+
} catch {
|
|
158
|
+
// A query Prometheus refuses is not evidence the series is missing.
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const summary = renderRuleAuditSummary(findings, checked.length, all.length - checked.length);
|
|
163
|
+
const result: RuleAuditResult = { mode, findings, queried: checked.length, unchecked: all.length - checked.length, summary };
|
|
164
|
+
if (mode === "issue" && findings.length > 0) {
|
|
165
|
+
result.issueUrl = await stickyIssue(args._run ?? defaultRunner, process.cwd(), args.issueTitle ?? "prometheus: rule audit findings", summary);
|
|
166
|
+
}
|
|
167
|
+
return result;
|
|
168
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { declaredGroups, groupVerdict, rulesLoadedObserve, type ApiRuleGroup } from "./rules-loaded";
|
|
6
|
+
|
|
7
|
+
function rulesApi(groups: ApiRuleGroup[], extra: Record<string, unknown> = {}): typeof fetch {
|
|
8
|
+
return (async (input: string | URL | Request) => {
|
|
9
|
+
const url = String(input);
|
|
10
|
+
if (url.endsWith("/api/v1/rules")) return Response.json({ status: "success", data: { groups } });
|
|
11
|
+
if (extra[url] !== undefined) return Response.json(extra[url]);
|
|
12
|
+
return new Response("not found", { status: 404 });
|
|
13
|
+
}) as typeof fetch;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const LOADED: ApiRuleGroup[] = [
|
|
17
|
+
{ name: "slo-checkout", file: "/etc/prometheus/rules.yml", rules: [{ name: "slo:sli_error:ratio_rate5m", type: "recording", health: "ok" }] },
|
|
18
|
+
{
|
|
19
|
+
name: "genai",
|
|
20
|
+
rules: [
|
|
21
|
+
{ name: "genai:calls:rate5m", type: "recording", health: "ok" },
|
|
22
|
+
{ name: "GenAiErrors", type: "alerting", health: "err", lastError: 'vector contains metrics with the same labelset after applying rule labels' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
describe("rulesLoadedObserve", () => {
|
|
28
|
+
test("one resource per declared group: loaded and healthy, not loaded, or with a rule in error", async () => {
|
|
29
|
+
const r = await rulesLoadedObserve({ url: "http://prom:9090", groups: ["slo-checkout", "genai", "slo-search"], _fetch: rulesApi(LOADED) });
|
|
30
|
+
expect(r.resources).toEqual([
|
|
31
|
+
{ name: "slo-checkout", status: "in-sync", detail: "1 rule(s) loaded" },
|
|
32
|
+
{ name: "genai", status: "drifted", detail: "GenAiErrors: vector contains metrics with the same labelset after applying rule labels" },
|
|
33
|
+
{ name: "slo-search", status: "drifted", detail: "group not loaded" },
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("the declared groups also come from rule files, without repeats", () => {
|
|
38
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-rules-loaded-"));
|
|
39
|
+
writeFileSync(join(dir, "rules.yml"), "groups:\n - name: slo-checkout\n rules: []\n - name: genai\n rules: []\n");
|
|
40
|
+
expect(declaredGroups({ groups: ["genai", "extra"], rules: "rules.yml" }, dir)).toEqual(["genai", "extra", "slo-checkout"]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("an unreadable API is unknown for every group, never drifted", async () => {
|
|
44
|
+
const down = (async () => new Response("", { status: 503 })) as unknown as typeof fetch;
|
|
45
|
+
const r = await rulesLoadedObserve({ groups: ["a", "b"], _fetch: down });
|
|
46
|
+
expect(r.resources.map((x) => x.status)).toEqual(["unknown", "unknown"]);
|
|
47
|
+
expect(r.resources[0].detail).toMatch(/rules API unreadable: .*HTTP 503/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("no declared group is an error, not an empty observation", async () => {
|
|
51
|
+
await expect(rulesLoadedObserve({ _fetch: rulesApi([]) })).rejects.toThrow(/no groups declared/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a group split across files is judged on all its rules", () => {
|
|
55
|
+
const split: ApiRuleGroup[] = [
|
|
56
|
+
{ name: "g", rules: [{ name: "a", health: "ok" }] },
|
|
57
|
+
{ name: "g", rules: [{ name: "b", health: "err", lastError: "" }] },
|
|
58
|
+
];
|
|
59
|
+
expect(groupVerdict("g", split)).toEqual({ name: "g", status: "drifted", detail: "b: health err" });
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
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
|
+
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
19
|
+
import { resolve } from "node:path";
|
|
20
|
+
import { load } from "js-yaml";
|
|
21
|
+
|
|
22
|
+
export interface RulesLoadedObserveArgs {
|
|
23
|
+
/** Prometheus's base URL. */
|
|
24
|
+
url?: string;
|
|
25
|
+
/** Group names that must be loaded. */
|
|
26
|
+
groups?: string[];
|
|
27
|
+
/** Rule files whose groups must be loaded, relative to the working directory. */
|
|
28
|
+
rules?: string | string[];
|
|
29
|
+
/** Request timeout, ms. Default 10000. */
|
|
30
|
+
timeoutMs?: number;
|
|
31
|
+
/** Replaces fetch. For tests. */
|
|
32
|
+
_fetch?: typeof fetch;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface RulesLoadedObserveResult {
|
|
36
|
+
resources: { name: string; status: "in-sync" | "drifted" | "unknown"; detail: string }[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** One group as `/api/v1/rules` returns it. */
|
|
40
|
+
export interface ApiRuleGroup {
|
|
41
|
+
name: string;
|
|
42
|
+
file?: string;
|
|
43
|
+
rules: Array<{ name: string; type?: string; query?: string; health?: string; lastError?: string; state?: string }>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function prometheusUrl(url?: string): string {
|
|
47
|
+
return (url ?? process.env.PROMETHEUS_URL ?? "http://localhost:9090").replace(/\/+$/, "");
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The group names in a rule file's text. */
|
|
51
|
+
export function ruleFileGroups(text: string): string[] {
|
|
52
|
+
const doc = load(text) as { groups?: Array<{ name?: unknown }> } | null;
|
|
53
|
+
return (doc?.groups ?? []).map((g) => g?.name).filter((n): n is string => typeof n === "string");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The declared groups, in order, without repeats. */
|
|
57
|
+
export function declaredGroups(args: Pick<RulesLoadedObserveArgs, "groups" | "rules">, cwd = process.cwd()): string[] {
|
|
58
|
+
const out = [...(args.groups ?? [])];
|
|
59
|
+
const files = args.rules === undefined ? [] : Array.isArray(args.rules) ? args.rules : [args.rules];
|
|
60
|
+
for (const f of files) out.push(...ruleFileGroups(readFileSync(resolve(cwd, f), "utf8")));
|
|
61
|
+
return [...new Set(out)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `GET /api/v1/rules`'s groups. Throws on anything but a successful answer. */
|
|
65
|
+
export async function fetchRuleGroups(f: typeof fetch, url: string, timeoutMs = 10_000): Promise<ApiRuleGroup[]> {
|
|
66
|
+
const res = await f(`${url}/api/v1/rules`, { signal: AbortSignal.timeout(timeoutMs) });
|
|
67
|
+
if (!res.ok) throw new Error(`GET ${url}/api/v1/rules: HTTP ${res.status}`);
|
|
68
|
+
const body = (await res.json()) as { status?: string; data?: { groups?: ApiRuleGroup[] } };
|
|
69
|
+
if (body.status !== "success" || !Array.isArray(body.data?.groups)) throw new Error(`GET ${url}/api/v1/rules: status ${body.status ?? "missing"}`);
|
|
70
|
+
return body.data.groups;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The verdict for one declared group against the loaded groups. */
|
|
74
|
+
export function groupVerdict(name: string, loaded: ApiRuleGroup[]): RulesLoadedObserveResult["resources"][number] {
|
|
75
|
+
const matches = loaded.filter((g) => g.name === name);
|
|
76
|
+
if (matches.length === 0) return { name, status: "drifted", detail: "group not loaded" };
|
|
77
|
+
const rules = matches.flatMap((g) => g.rules ?? []);
|
|
78
|
+
const errored = rules.filter((r) => r.health === "err");
|
|
79
|
+
if (errored.length > 0) {
|
|
80
|
+
return { name, status: "drifted", detail: errored.map((r) => `${r.name}: ${r.lastError || "health err"}`).join("; ") };
|
|
81
|
+
}
|
|
82
|
+
return { name, status: "in-sync", detail: `${rules.length} rule(s) loaded` };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Observe the declared rule groups: one resource per group. */
|
|
86
|
+
export async function rulesLoadedObserve(args: RulesLoadedObserveArgs): Promise<RulesLoadedObserveResult> {
|
|
87
|
+
const names = declaredGroups(args);
|
|
88
|
+
if (names.length === 0) throw new Error("rulesLoadedObserve: no groups declared (give groups or rules)");
|
|
89
|
+
const url = prometheusUrl(args.url);
|
|
90
|
+
let loaded: ApiRuleGroup[];
|
|
91
|
+
try {
|
|
92
|
+
loaded = await fetchRuleGroups(args._fetch ?? fetch, url, args.timeoutMs);
|
|
93
|
+
} catch (err) {
|
|
94
|
+
const detail = `rules API unreadable: ${(err as Error).message}`;
|
|
95
|
+
return { resources: names.map((name) => ({ name, status: "unknown" as const, detail })) };
|
|
96
|
+
}
|
|
97
|
+
return { resources: names.map((name) => groupVerdict(name, loaded)) };
|
|
98
|
+
}
|