@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.
- package/dist/alertmanager.js +61 -0
- package/dist/api/alertmanager.js +98 -0
- package/dist/api/apply.js +306 -0
- package/dist/api/bind.js +66 -0
- package/dist/api/client.js +110 -0
- package/dist/api/evaluated.js +86 -0
- package/dist/api/fake-servers.js +165 -0
- package/dist/api/index.js +17 -0
- package/dist/api/ruler.js +122 -0
- package/dist/build.js +240 -0
- package/dist/catalog.js +66 -0
- package/dist/codegen/docs-cli.js +3 -0
- package/dist/codegen/docs.js +105 -0
- package/dist/codegen/generate-cli.js +7 -0
- package/dist/codegen/generate.js +42 -0
- package/dist/codegen/package.js +28 -0
- package/dist/collector-metrics.js +225 -0
- package/dist/composites/alert-routing.js +210 -0
- package/dist/composites/catalog.js +344 -0
- package/dist/composites/genai.js +454 -0
- package/dist/composites/index.js +8 -0
- package/dist/composites/red-alerts.js +135 -0
- package/dist/composites/slo.js +353 -0
- package/dist/composites/watchdog.js +110 -0
- package/dist/config-model.js +52 -0
- package/dist/config.js +225 -0
- package/dist/describe-resources.js +299 -0
- package/dist/detect.js +12 -0
- package/dist/duration.js +54 -0
- package/dist/export-resources.js +163 -0
- package/dist/import/alertmanager-live.js +309 -0
- package/dist/import/embedded.js +100 -0
- package/dist/import/generator.js +683 -0
- package/dist/import/parser.js +428 -0
- package/dist/import/slo.js +207 -0
- package/dist/index.js +33 -0
- package/dist/init-templates.js +119 -0
- package/dist/integrations.js +33 -0
- package/dist/integrity.json +2 -2
- package/dist/lint/audit-catalog.js +60 -0
- package/dist/lint/audit-lineage.js +42 -0
- package/dist/lint/post-synth/index.js +68 -0
- package/dist/lint/post-synth/prom-helpers.js +157 -0
- package/dist/lint/post-synth/prom101.js +13 -0
- package/dist/lint/post-synth/prom102.js +13 -0
- package/dist/lint/post-synth/prom103.js +13 -0
- package/dist/lint/post-synth/prom104.js +13 -0
- package/dist/lint/post-synth/prom105.js +13 -0
- package/dist/lint/post-synth/prom106.js +13 -0
- package/dist/lint/post-synth/prom107.js +13 -0
- package/dist/lint/post-synth/prom201.js +13 -0
- package/dist/lint/post-synth/prom202.js +13 -0
- package/dist/lint/post-synth/prom203.js +13 -0
- package/dist/lint/post-synth/prom204.js +13 -0
- package/dist/lint/post-synth/prom205.js +13 -0
- package/dist/lint/post-synth/prom206.js +13 -0
- package/dist/lint/post-synth/prom207.js +13 -0
- package/dist/lint/post-synth/prom208.js +13 -0
- package/dist/lint/post-synth/prom209.js +13 -0
- package/dist/lint/post-synth/prom210.js +13 -0
- package/dist/lint/post-synth/prom211.js +17 -0
- package/dist/lint/post-synth/prom212.js +14 -0
- package/dist/lint/post-synth/prom213.js +16 -0
- package/dist/lint/post-synth/prom214.js +14 -0
- package/dist/lint/post-synth/prom215.js +14 -0
- package/dist/lint/post-synth/prom216.js +15 -0
- package/dist/lint/post-synth/prom217.js +14 -0
- package/dist/lint/post-synth/prom218.js +15 -0
- package/dist/lint/post-synth/prom219.js +14 -0
- package/dist/lint/post-synth/prom220.js +14 -0
- package/dist/lint/post-synth/prom221.js +15 -0
- package/dist/lint/post-synth/prom222.js +14 -0
- package/dist/lint/post-synth/prom223.js +15 -0
- package/dist/lint/post-synth/prom224.js +14 -0
- package/dist/lint/post-synth/prom225.js +15 -0
- package/dist/lint/post-synth/prom301.js +19 -0
- package/dist/lint/rules/index.js +8 -0
- package/dist/lint/rules/literal-credential.js +127 -0
- package/dist/lint/rules/prom-ast.js +65 -0
- package/dist/lint/rules/promql-literal.js +65 -0
- package/dist/lint/rules/slo-literal.js +79 -0
- package/dist/lsp/completions.js +11 -0
- package/dist/lsp/hover.js +21 -0
- package/dist/manifest.json +1 -1
- package/dist/matchers.js +131 -0
- package/dist/model.js +108 -0
- package/dist/op/activities/index.js +20 -0
- package/dist/op/activities/promtool.js +87 -0
- package/dist/op/activities/rule-audit.js +128 -0
- package/dist/op/activities/rules-loaded.js +73 -0
- package/dist/op/activities/silence.js +123 -0
- package/dist/op/activity-contracts.js +51 -0
- package/dist/op/audit-op.js +28 -0
- package/dist/op/builders.js +57 -0
- package/dist/op/slo-rule-tests.js +148 -0
- package/dist/package-cli.js +14 -0
- package/dist/pin.js +13 -0
- package/dist/plugin.js +143 -0
- package/dist/prometheus-config.js +38 -0
- package/dist/promql-analysis.js +343 -0
- package/dist/promql.js +44 -0
- package/dist/rule-eval.js +466 -0
- package/dist/rules.js +97 -0
- package/dist/serializer.js +59 -0
- package/dist/skill-defs.js +41 -0
- package/dist/tools.js +85 -0
- package/dist/validate-cli.js +7 -0
- package/dist/validate-config.js +556 -0
- package/dist/validate-integrations.js +388 -0
- package/dist/validate-relabel.js +52 -0
- package/dist/validate-security.js +135 -0
- package/dist/validate.js +69 -0
- package/package.json +10 -10
- package/src/codegen/package.ts +2 -2
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What `/api/v1/rules` says about a group, mapped two ways (#3371): to the
|
|
3
|
+
* group's health for `describeResources`, and, for a plain Prometheus,
|
|
4
|
+
* which has no rule group API, back to the rule-file shape for import.
|
|
5
|
+
*
|
|
6
|
+
* The second mapping is lossy, and says so. `/api/v1/rules` reports rules
|
|
7
|
+
* as Prometheus evaluates them, not as the file wrote them: `query` is the
|
|
8
|
+
* expression as PromQL prints it, durations are seconds, and a group's
|
|
9
|
+
* `interval` is the one it runs on, the global `evaluation_interval` when
|
|
10
|
+
* the group set none. A group's `labels` and `query_offset` are not
|
|
11
|
+
* reported at all.
|
|
12
|
+
*/
|
|
13
|
+
import { formatDuration } from "../duration.js";
|
|
14
|
+
/** A duration in seconds as Prometheus writes one; undefined for zero or a value that is not a number. */
|
|
15
|
+
function seconds(v) {
|
|
16
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v <= 0)
|
|
17
|
+
return undefined;
|
|
18
|
+
return formatDuration(Math.round(v * 1000));
|
|
19
|
+
}
|
|
20
|
+
function labelsOf(v) {
|
|
21
|
+
if (typeof v !== "object" || v === null || Array.isArray(v))
|
|
22
|
+
return undefined;
|
|
23
|
+
const entries = Object.entries(v).map(([k, x]) => [k, String(x)]);
|
|
24
|
+
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
|
25
|
+
}
|
|
26
|
+
/** What the import of evaluated groups leaves out, said once per export. */
|
|
27
|
+
export const EVALUATED_IMPORT_WARNING = "rule groups were read from /api/v1/rules, which reports them as Prometheus evaluates them: expressions are as PromQL prints them, " +
|
|
28
|
+
"a group's interval is the one it runs on (the global evaluation_interval when the group sets none), and group labels and query_offset are not reported";
|
|
29
|
+
/** A group from `/api/v1/rules` in the rule-file shape. */
|
|
30
|
+
export function evaluatedToRuleGroup(group) {
|
|
31
|
+
const rules = group.rules.map((r) => {
|
|
32
|
+
if (r.type === "recording") {
|
|
33
|
+
const labels = labelsOf(r.labels);
|
|
34
|
+
return { record: r.name, expr: r.query, ...(labels ? { labels } : {}) };
|
|
35
|
+
}
|
|
36
|
+
const labels = labelsOf(r.labels);
|
|
37
|
+
const annotations = labelsOf(r.annotations);
|
|
38
|
+
const forDuration = seconds(r.duration);
|
|
39
|
+
const keep = seconds(r.keepFiringFor);
|
|
40
|
+
return {
|
|
41
|
+
alert: r.name,
|
|
42
|
+
expr: r.query,
|
|
43
|
+
...(forDuration ? { for: forDuration } : {}),
|
|
44
|
+
...(keep ? { keep_firing_for: keep } : {}),
|
|
45
|
+
...(labels ? { labels } : {}),
|
|
46
|
+
...(annotations ? { annotations } : {}),
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
const interval = seconds(group.interval);
|
|
50
|
+
return {
|
|
51
|
+
name: group.name,
|
|
52
|
+
...(interval ? { interval } : {}),
|
|
53
|
+
...(typeof group.limit === "number" && group.limit > 0 ? { limit: group.limit } : {}),
|
|
54
|
+
rules,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function groupHealth(group) {
|
|
58
|
+
let failing = 0;
|
|
59
|
+
let unknown = 0;
|
|
60
|
+
let firing = 0;
|
|
61
|
+
let pending = 0;
|
|
62
|
+
let lastError;
|
|
63
|
+
for (const r of group.rules) {
|
|
64
|
+
if (r.health === "err") {
|
|
65
|
+
failing++;
|
|
66
|
+
lastError ?? (lastError = `${r.name}: ${r.lastError ?? "evaluation failed"}`);
|
|
67
|
+
}
|
|
68
|
+
else if (r.health !== "ok")
|
|
69
|
+
unknown++;
|
|
70
|
+
if (r.state === "firing")
|
|
71
|
+
firing++;
|
|
72
|
+
else if (r.state === "pending")
|
|
73
|
+
pending++;
|
|
74
|
+
}
|
|
75
|
+
const health = failing > 0 ? "err" : unknown > 0 || group.rules.length === 0 ? "unknown" : "ok";
|
|
76
|
+
return {
|
|
77
|
+
health,
|
|
78
|
+
...(lastError ? { lastError } : {}),
|
|
79
|
+
failing,
|
|
80
|
+
firing,
|
|
81
|
+
pending,
|
|
82
|
+
...(group.lastEvaluation ? { lastEvaluation: group.lastEvaluation } : {}),
|
|
83
|
+
...(typeof group.evaluationTime === "number" ? { evaluationTime: group.evaluationTime } : {}),
|
|
84
|
+
...(typeof group.interval === "number" ? { interval: group.interval } : {}),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A ruler and an Alertmanager in memory, for the observe and export tests
|
|
3
|
+
* (#3371), and for the ruler apply target's (#3372).
|
|
4
|
+
*
|
|
5
|
+
* Each fake is a request handler over a state the test sets up. It answers
|
|
6
|
+
* the routes ./ruler.ts and ./alertmanager.ts use, in the shapes the
|
|
7
|
+
* projects document: the ruler config API's YAML (a mapping of namespace to
|
|
8
|
+
* groups; one group alone at `/{namespace}/{group}`; 404 for a namespace or
|
|
9
|
+
* group that is not there), `/api/v1/rules` JSON, Alertmanager's
|
|
10
|
+
* `/api/v2/status` and Mimir's `/api/v1/alerts`. With `tenant` set it
|
|
11
|
+
* answers 401 to a request without that `X-Scope-OrgID`, as Mimir does
|
|
12
|
+
* ("no org id").
|
|
13
|
+
*
|
|
14
|
+
* {@link serveFake} puts a handler behind a real HTTP server on localhost,
|
|
15
|
+
* so a test drives the default `fetch` transport, headers included.
|
|
16
|
+
* {@link fakeHttp} hands the same handler in as a {@link PromHttp}.
|
|
17
|
+
*/
|
|
18
|
+
import { createServer } from "node:http";
|
|
19
|
+
import { dump } from "js-yaml";
|
|
20
|
+
import { loadPrometheusYaml } from "../import/parser.js";
|
|
21
|
+
const yaml = (v) => dump(v, { lineWidth: -1, noRefs: true });
|
|
22
|
+
const ok = (text, contentType = "application/yaml") => ({ status: 200, text, contentType });
|
|
23
|
+
const json = (v) => ({ status: 200, text: JSON.stringify(v), contentType: "application/json" });
|
|
24
|
+
const notFound = (what) => ({ status: 404, text: what, contentType: "text/plain" });
|
|
25
|
+
function tenantRefused(tenant, req) {
|
|
26
|
+
if (tenant === undefined)
|
|
27
|
+
return undefined;
|
|
28
|
+
return req.headers["x-scope-orgid"] === tenant ? undefined : { status: 401, text: "no org id", contentType: "text/plain" };
|
|
29
|
+
}
|
|
30
|
+
/** What `/api/v1/rules` reports for a group nobody set an evaluation for: every rule healthy. */
|
|
31
|
+
export function evaluatedOf(file, group) {
|
|
32
|
+
const rules = Array.isArray(group.rules) ? group.rules : [];
|
|
33
|
+
return {
|
|
34
|
+
name: group.name,
|
|
35
|
+
file,
|
|
36
|
+
interval: 60,
|
|
37
|
+
rules: rules.map((r) => typeof r.alert === "string"
|
|
38
|
+
? { type: "alerting", name: r.alert, query: String(r.expr), duration: 0, labels: r.labels ?? {}, annotations: r.annotations ?? {}, health: "ok", state: "inactive" }
|
|
39
|
+
: { type: "recording", name: String(r.record), query: String(r.expr), labels: r.labels ?? {}, health: "ok" }),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/** A ruler over `state`. Writes change `state`, so a test can read back what an apply did. */
|
|
43
|
+
export function fakeRuler(state, calls = []) {
|
|
44
|
+
const prefix = state.prometheusPrefix ?? (state.kind === "prometheus" ? "" : "/prometheus");
|
|
45
|
+
const base = state.kind === "mimir" ? `${prefix}/config/v1/rules` : state.kind === "cortex" ? "/api/v1/rules" : state.kind === "loki" ? "/loki/api/v1/rules" : undefined;
|
|
46
|
+
return (req) => {
|
|
47
|
+
var _a, _b;
|
|
48
|
+
calls.push(`${req.method} ${req.path}`);
|
|
49
|
+
if (state.status !== undefined)
|
|
50
|
+
return { status: state.status, text: "refused" };
|
|
51
|
+
const refused = tenantRefused(state.tenant, req);
|
|
52
|
+
if (refused)
|
|
53
|
+
return refused;
|
|
54
|
+
const route = req.path.split("?")[0];
|
|
55
|
+
if (route === `${prefix}/api/v1/rules` && req.method === "GET") {
|
|
56
|
+
if (state.evaluatedStatus !== undefined)
|
|
57
|
+
return { status: state.evaluatedStatus, text: "evaluation unavailable" };
|
|
58
|
+
const groups = state.evaluated ?? Object.entries(state.namespaces).flatMap(([ns, gs]) => gs.map((g) => evaluatedOf(ns, g)));
|
|
59
|
+
return json({ status: "success", data: { groups } });
|
|
60
|
+
}
|
|
61
|
+
if (base === undefined || (route !== base && !route.startsWith(`${base}/`)))
|
|
62
|
+
return notFound("404 page not found");
|
|
63
|
+
const parts = route.slice(base.length).split("/").filter(Boolean).map(decodeURIComponent);
|
|
64
|
+
if (req.method === "GET") {
|
|
65
|
+
if (parts.length === 0) {
|
|
66
|
+
const nonEmpty = Object.fromEntries(Object.entries(state.namespaces).filter(([, gs]) => gs.length > 0));
|
|
67
|
+
return Object.keys(nonEmpty).length === 0 ? notFound("no rule groups found") : ok(yaml(nonEmpty));
|
|
68
|
+
}
|
|
69
|
+
const groups = state.namespaces[parts[0]];
|
|
70
|
+
if (!groups || groups.length === 0)
|
|
71
|
+
return notFound("no rule groups found");
|
|
72
|
+
if (parts.length === 1)
|
|
73
|
+
return ok(yaml({ [parts[0]]: groups }));
|
|
74
|
+
const g = groups.find((x) => x.name === parts[1]);
|
|
75
|
+
return g ? ok(yaml(g)) : notFound("group does not exist");
|
|
76
|
+
}
|
|
77
|
+
if (req.method === "POST" && parts.length === 1) {
|
|
78
|
+
const doc = loadPrometheusYaml(req.body ?? "");
|
|
79
|
+
const groups = ((_a = state.namespaces)[_b = parts[0]] ?? (_a[_b] = []));
|
|
80
|
+
const i = groups.findIndex((x) => x.name === doc.name);
|
|
81
|
+
if (i >= 0)
|
|
82
|
+
groups[i] = doc;
|
|
83
|
+
else
|
|
84
|
+
groups.push(doc);
|
|
85
|
+
return { status: 202, text: "" };
|
|
86
|
+
}
|
|
87
|
+
if (req.method === "DELETE" && parts.length === 2) {
|
|
88
|
+
const groups = state.namespaces[parts[0]];
|
|
89
|
+
const i = groups ? groups.findIndex((x) => x.name === parts[1]) : -1;
|
|
90
|
+
if (i < 0)
|
|
91
|
+
return notFound("group does not exist");
|
|
92
|
+
groups.splice(i, 1);
|
|
93
|
+
return { status: 202, text: "" };
|
|
94
|
+
}
|
|
95
|
+
return { status: 405, text: "method not allowed" };
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
/** An Alertmanager over `state`: a plain one's `/api/v2/status`, or Mimir's and Cortex's `/api/v1/alerts` and per-tenant status. */
|
|
99
|
+
export function fakeAlertmanager(state, calls = []) {
|
|
100
|
+
const statusPath = state.kind === "alertmanager" ? "/api/v2/status" : "/alertmanager/api/v2/status";
|
|
101
|
+
return (req) => {
|
|
102
|
+
calls.push(`${req.method} ${req.path}`);
|
|
103
|
+
if (state.status !== undefined)
|
|
104
|
+
return { status: state.status, text: "refused" };
|
|
105
|
+
const refused = tenantRefused(state.tenant, req);
|
|
106
|
+
if (refused)
|
|
107
|
+
return refused;
|
|
108
|
+
if (req.path === statusPath && req.method === "GET") {
|
|
109
|
+
const original = state.kind === "alertmanager" ? state.original : state.uploaded?.alertmanager_config;
|
|
110
|
+
if (original === undefined)
|
|
111
|
+
return notFound("the Alertmanager is not configured");
|
|
112
|
+
return json({
|
|
113
|
+
cluster: { name: "01FAKE", peers: [{ address: "10.0.0.1:9094", name: "01FAKE" }], status: state.cluster ?? "ready" },
|
|
114
|
+
config: { original },
|
|
115
|
+
uptime: "2026-10-06T08:00:00.000Z",
|
|
116
|
+
versionInfo: { branch: "HEAD", buildDate: "20260101-00:00:00", buildUser: "root@fake", goVersion: "go1.25", revision: "fake", version: state.version ?? "0.34.1" },
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (req.path === "/api/v1/alerts" && state.kind !== "alertmanager") {
|
|
120
|
+
if (req.method === "GET") {
|
|
121
|
+
if (!state.uploaded)
|
|
122
|
+
return notFound("alertmanager storage object not found");
|
|
123
|
+
return ok(yaml({ template_files: state.uploaded.template_files ?? {}, alertmanager_config: state.uploaded.alertmanager_config }));
|
|
124
|
+
}
|
|
125
|
+
if (req.method === "POST") {
|
|
126
|
+
const doc = loadPrometheusYaml(req.body ?? "");
|
|
127
|
+
state.uploaded = { alertmanager_config: doc.alertmanager_config, template_files: doc.template_files ?? {} };
|
|
128
|
+
return { status: 201, text: "" };
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return notFound("404 page not found");
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
/** A handler as a {@link PromHttp}, with no headers (a tenant-checking fake then refuses; use {@link serveFake} for those). */
|
|
135
|
+
export function fakeHttp(handler, headers = {}) {
|
|
136
|
+
return async (method, path, body) => {
|
|
137
|
+
const res = handler({ method, path, headers, body: body?.text });
|
|
138
|
+
return { status: res.status, text: res.text };
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
function readBody(req) {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
let data = "";
|
|
144
|
+
req.setEncoding("utf8");
|
|
145
|
+
req.on("data", (c) => (data += c));
|
|
146
|
+
req.on("end", () => resolve(data));
|
|
147
|
+
req.on("error", reject);
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** A handler behind a real HTTP server on 127.0.0.1, on a free port. `close()` stops it. */
|
|
151
|
+
export async function serveFake(handler) {
|
|
152
|
+
const server = createServer(async (req, res) => {
|
|
153
|
+
const body = await readBody(req);
|
|
154
|
+
const headers = Object.fromEntries(Object.entries(req.headers).map(([k, v]) => [k, Array.isArray(v) ? v.join(",") : v]));
|
|
155
|
+
const out = handler({ method: req.method ?? "GET", path: req.url ?? "/", headers, ...(body ? { body } : {}) });
|
|
156
|
+
res.writeHead(out.status, { "content-type": out.contentType ?? "text/plain" });
|
|
157
|
+
res.end(out.text);
|
|
158
|
+
});
|
|
159
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
160
|
+
const { port } = server.address();
|
|
161
|
+
return {
|
|
162
|
+
url: `http://127.0.0.1:${port}`,
|
|
163
|
+
close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
|
|
164
|
+
};
|
|
165
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ruler and Alertmanager API clients (#3371), as
|
|
3
|
+
* `@intentius/chant-lexicon-prometheus/api`.
|
|
4
|
+
*
|
|
5
|
+
* One transport for observe, live export and the ruler apply target
|
|
6
|
+
* (#3372): bind with {@link bindEndpoints} (or {@link bindRuler} /
|
|
7
|
+
* {@link bindAlertmanager}), then read and write through {@link RulerApi}
|
|
8
|
+
* and {@link AlertmanagerApi}. The ownership boundary is
|
|
9
|
+
* {@link declaredNamespaces}: nothing outside it is read for import, and an
|
|
10
|
+
* apply must not write or delete outside it.
|
|
11
|
+
*/
|
|
12
|
+
export { PromApiError, PromClient, promHttp, statusVerdict } from "./client.js";
|
|
13
|
+
export { RulerApi } from "./ruler.js";
|
|
14
|
+
export { AlertmanagerApi } from "./alertmanager.js";
|
|
15
|
+
export { evaluatedToRuleGroup, groupHealth } from "./evaluated.js";
|
|
16
|
+
export { bindAlertmanager, bindEndpoints, bindRuler, classifyPromFailure, PromBindingError } from "./bind.js";
|
|
17
|
+
export { ALERTMANAGER_KINDS, RULER_KINDS, declaredNamespaces, namespaceOfGroup, resolveAlertmanagerTarget, resolveRulerTarget, } from "../config.js";
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ruler APIs (#3371): Mimir's, Cortex's and Loki's rule group config
|
|
3
|
+
* API, and the Prometheus-compatible `/api/v1/rules` every one of them (and
|
|
4
|
+
* a plain Prometheus) serves with each group's evaluation state.
|
|
5
|
+
*
|
|
6
|
+
* | Kind | Config API (YAML) | Evaluated rules (JSON) |
|
|
7
|
+
* |---|---|---|
|
|
8
|
+
* | `mimir` | `<prefix>/config/v1/rules[/{namespace}[/{group}]]` | `<prefix>/api/v1/rules` |
|
|
9
|
+
* | `cortex` | `/api/v1/rules[/{namespace}[/{group}]]` | `<prefix>/api/v1/rules` |
|
|
10
|
+
* | `loki` | `/loki/api/v1/rules[/{namespace}[/{group}]]` | `/prometheus/api/v1/rules` |
|
|
11
|
+
* | `prometheus` | none: rule files on disk | `/api/v1/rules` |
|
|
12
|
+
*
|
|
13
|
+
* `<prefix>` is `-http.prometheus-http-prefix`, `/prometheus` unless the
|
|
14
|
+
* profile says otherwise. Mimir does not register Cortex's `/api/v1/rules`
|
|
15
|
+
* config path. Every request carries the target's `X-Scope-OrgID`.
|
|
16
|
+
*
|
|
17
|
+
* The writes (`setGroup`, `deleteGroup`) are here for the ruler apply
|
|
18
|
+
* target (#3372), which reuses this module through
|
|
19
|
+
* `@intentius/chant-lexicon-prometheus/api`. Nothing here deletes a
|
|
20
|
+
* namespace: an apply bounded by the declared namespaces deletes groups one
|
|
21
|
+
* by one, and only inside {@link declaredNamespaces}.
|
|
22
|
+
*/
|
|
23
|
+
import { emitYaml } from "../build.js";
|
|
24
|
+
import { loadPrometheusYaml } from "../import/parser.js";
|
|
25
|
+
import { PromApiError } from "./client.js";
|
|
26
|
+
function isObject(v) {
|
|
27
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
28
|
+
}
|
|
29
|
+
const enc = encodeURIComponent;
|
|
30
|
+
/** A bound ruler: the transport and the target it reaches. */
|
|
31
|
+
export class RulerApi {
|
|
32
|
+
constructor(client, target) {
|
|
33
|
+
this.client = client;
|
|
34
|
+
this.target = target;
|
|
35
|
+
}
|
|
36
|
+
/** The base of the rule group config API, or undefined for a plain Prometheus, which has none. */
|
|
37
|
+
get configPath() {
|
|
38
|
+
switch (this.target.kind) {
|
|
39
|
+
case "mimir":
|
|
40
|
+
return `${this.target.prometheusPrefix}/config/v1/rules`;
|
|
41
|
+
case "cortex":
|
|
42
|
+
return "/api/v1/rules";
|
|
43
|
+
case "loki":
|
|
44
|
+
return "/loki/api/v1/rules";
|
|
45
|
+
case "prometheus":
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/** The Prometheus-compatible rules endpoint, with evaluation state. */
|
|
50
|
+
get evaluatedPath() {
|
|
51
|
+
return `${this.target.prometheusPrefix}/api/v1/rules`;
|
|
52
|
+
}
|
|
53
|
+
requireConfigPath() {
|
|
54
|
+
const p = this.configPath;
|
|
55
|
+
if (p === undefined)
|
|
56
|
+
throw new Error(`a plain Prometheus (${this.target.source}) has no rule group API; its groups come from rule files`);
|
|
57
|
+
return p;
|
|
58
|
+
}
|
|
59
|
+
parseNamespaces(text, path) {
|
|
60
|
+
const doc = loadPrometheusYaml(text);
|
|
61
|
+
if (!isObject(doc))
|
|
62
|
+
throw new PromApiError(502, "GET", path, "the answer is not a YAML mapping of namespaces to rule groups");
|
|
63
|
+
const out = {};
|
|
64
|
+
for (const [ns, groups] of Object.entries(doc)) {
|
|
65
|
+
out[ns] = (Array.isArray(groups) ? groups : []).filter((g) => isObject(g) && typeof g.name === "string");
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
/** Every namespace the tenant has, with its groups. A 404 (Mimir: "no rule groups found") is none. */
|
|
70
|
+
async listNamespaces() {
|
|
71
|
+
const path = this.requireConfigPath();
|
|
72
|
+
const text = await this.client.getText(path);
|
|
73
|
+
return text === undefined ? {} : this.parseNamespaces(text, path);
|
|
74
|
+
}
|
|
75
|
+
/** One namespace's groups; undefined when the ruler has no such namespace. */
|
|
76
|
+
async readNamespace(namespace) {
|
|
77
|
+
const path = `${this.requireConfigPath()}/${enc(namespace)}`;
|
|
78
|
+
const text = await this.client.getText(path);
|
|
79
|
+
if (text === undefined)
|
|
80
|
+
return undefined;
|
|
81
|
+
return this.parseNamespaces(text, path)[namespace] ?? [];
|
|
82
|
+
}
|
|
83
|
+
/** The path {@link readGroup} reads, for `queried`. */
|
|
84
|
+
groupPath(namespace, group) {
|
|
85
|
+
return `${this.requireConfigPath()}/${enc(namespace)}/${enc(group)}`;
|
|
86
|
+
}
|
|
87
|
+
/** One group; undefined when it is not there. */
|
|
88
|
+
async readGroup(namespace, group) {
|
|
89
|
+
const path = this.groupPath(namespace, group);
|
|
90
|
+
const text = await this.client.getText(path);
|
|
91
|
+
if (text === undefined)
|
|
92
|
+
return undefined;
|
|
93
|
+
const doc = loadPrometheusYaml(text);
|
|
94
|
+
if (!isObject(doc) || typeof doc.name !== "string")
|
|
95
|
+
throw new PromApiError(502, "GET", path, "the answer is not a rule group");
|
|
96
|
+
return doc;
|
|
97
|
+
}
|
|
98
|
+
/** Create or replace one group in `namespace` (202 on Mimir, Cortex and Loki). */
|
|
99
|
+
async setGroup(namespace, group) {
|
|
100
|
+
await this.client.send("POST", `${this.requireConfigPath()}/${enc(namespace)}`, { text: emitYaml(group), contentType: "application/yaml" });
|
|
101
|
+
}
|
|
102
|
+
/** Delete one group from `namespace`. */
|
|
103
|
+
async deleteGroup(namespace, group) {
|
|
104
|
+
await this.client.send("DELETE", this.groupPath(namespace, group));
|
|
105
|
+
}
|
|
106
|
+
/** Every group the ruler evaluates, with its health. Read once per client. */
|
|
107
|
+
async evaluated() {
|
|
108
|
+
return this.client.once("evaluated", async () => {
|
|
109
|
+
const path = this.evaluatedPath;
|
|
110
|
+
const body = await this.client.getJson(path);
|
|
111
|
+
if (body === undefined)
|
|
112
|
+
throw new PromApiError(404, "GET", path, "the rules API is not served here");
|
|
113
|
+
if (body.status !== undefined && body.status !== "success")
|
|
114
|
+
throw new PromApiError(502, "GET", path, `status ${body.status}`);
|
|
115
|
+
return (body.data?.groups ?? []).filter((g) => isObject(g) && typeof g.name === "string").map((g) => ({
|
|
116
|
+
...g,
|
|
117
|
+
file: typeof g.file === "string" ? g.file : "",
|
|
118
|
+
rules: Array.isArray(g.rules) ? g.rules.filter(isObject) : [],
|
|
119
|
+
}));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
package/dist/build.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declared entities -> `RuleFileConfig` / `AlertmanagerConfig` -> YAML.
|
|
3
|
+
*
|
|
4
|
+
* The serializer runs these over a build's entities, and a composite in
|
|
5
|
+
* another lexicon (the k8s `PrometheusRule` path, a ConfigMap holding
|
|
6
|
+
* `alertmanager.yml`) runs them over the entities it has. Both get the same
|
|
7
|
+
* config and the same text.
|
|
8
|
+
*/
|
|
9
|
+
import { dump } from "js-yaml";
|
|
10
|
+
import { isAlertmanagerSettings, isInhibitRule, isReceiver, isRoute, isTimeInterval, } from "./alertmanager.js";
|
|
11
|
+
import { isRuleGroup, ruleGroupConfig } from "./rules.js";
|
|
12
|
+
import { isPrometheusConfig, isScrapeConfig, } from "./prometheus-config.js";
|
|
13
|
+
import { PROMETHEUS_CONFIG_SECTIONS } from "./model.js";
|
|
14
|
+
function byName(a, b) {
|
|
15
|
+
return String(a ?? "").localeCompare(String(b ?? ""));
|
|
16
|
+
}
|
|
17
|
+
function entityList(entities) {
|
|
18
|
+
return entities instanceof Map ? [...entities.values()] : [...entities];
|
|
19
|
+
}
|
|
20
|
+
/** A value with `undefined` keys dropped and entities left as they are. */
|
|
21
|
+
function plain(value) {
|
|
22
|
+
if (value === undefined)
|
|
23
|
+
return undefined;
|
|
24
|
+
if (Array.isArray(value))
|
|
25
|
+
return value.map(plain);
|
|
26
|
+
if (typeof value === "object" && value !== null) {
|
|
27
|
+
const toJSON = value.toJSON;
|
|
28
|
+
if (typeof toJSON === "function")
|
|
29
|
+
return plain(toJSON.call(value));
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const [k, v] of Object.entries(value)) {
|
|
32
|
+
if (v !== undefined)
|
|
33
|
+
out[k] = plain(v);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
return value;
|
|
38
|
+
}
|
|
39
|
+
/** The rule file for a set of declared entities. Entities that aren't `RuleGroup`s are ignored. */
|
|
40
|
+
export function buildRuleFile(entities) {
|
|
41
|
+
const groups = [];
|
|
42
|
+
for (const e of entityList(entities)) {
|
|
43
|
+
if (isRuleGroup(e) && !groups.includes(e))
|
|
44
|
+
groups.push(e);
|
|
45
|
+
}
|
|
46
|
+
// Sorted by name: Prometheus evaluates groups independently, so their order
|
|
47
|
+
// carries no meaning, and sorting makes the file the same however the
|
|
48
|
+
// groups were collected (a build's entity map, or a list passed by hand).
|
|
49
|
+
groups.sort((a, b) => byName(a.props.name, b.props.name));
|
|
50
|
+
return { config: { groups: groups.map((g) => ruleGroupConfig(g)) }, groups };
|
|
51
|
+
}
|
|
52
|
+
function receiverName(ref) {
|
|
53
|
+
return isReceiver(ref) ? ref.props.name : String(ref);
|
|
54
|
+
}
|
|
55
|
+
function intervalName(ref) {
|
|
56
|
+
return isTimeInterval(ref) ? ref.props.name : String(ref);
|
|
57
|
+
}
|
|
58
|
+
function routeProps(route) {
|
|
59
|
+
return isRoute(route) ? route.props : route;
|
|
60
|
+
}
|
|
61
|
+
function routeConfig(route) {
|
|
62
|
+
const p = routeProps(route);
|
|
63
|
+
const out = {};
|
|
64
|
+
if (p.receiver !== undefined)
|
|
65
|
+
out.receiver = receiverName(p.receiver);
|
|
66
|
+
if (p.group_by !== undefined)
|
|
67
|
+
out.group_by = [...p.group_by];
|
|
68
|
+
if (p.continue !== undefined)
|
|
69
|
+
out.continue = p.continue;
|
|
70
|
+
if (p.matchers !== undefined)
|
|
71
|
+
out.matchers = [...p.matchers];
|
|
72
|
+
if (p.group_wait !== undefined)
|
|
73
|
+
out.group_wait = p.group_wait;
|
|
74
|
+
if (p.group_interval !== undefined)
|
|
75
|
+
out.group_interval = p.group_interval;
|
|
76
|
+
if (p.repeat_interval !== undefined)
|
|
77
|
+
out.repeat_interval = p.repeat_interval;
|
|
78
|
+
if (p.mute_time_intervals !== undefined)
|
|
79
|
+
out.mute_time_intervals = p.mute_time_intervals.map(intervalName);
|
|
80
|
+
if (p.active_time_intervals !== undefined)
|
|
81
|
+
out.active_time_intervals = p.active_time_intervals.map(intervalName);
|
|
82
|
+
if (p.labels !== undefined)
|
|
83
|
+
out.labels = { ...p.labels };
|
|
84
|
+
if (p.routes !== undefined)
|
|
85
|
+
out.routes = p.routes.map(routeConfig);
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
/** The `alertmanager.yml` for a set of declared entities. Entities that aren't Alertmanager ones are ignored. */
|
|
89
|
+
export function buildAlertmanagerConfig(entities) {
|
|
90
|
+
const all = entityList(entities);
|
|
91
|
+
const warnings = [];
|
|
92
|
+
const receivers = [];
|
|
93
|
+
const intervals = [];
|
|
94
|
+
const routes = [];
|
|
95
|
+
const inhibits = [];
|
|
96
|
+
let settings;
|
|
97
|
+
let count = 0;
|
|
98
|
+
const addReceiver = (r) => {
|
|
99
|
+
if (!receivers.includes(r))
|
|
100
|
+
receivers.push(r);
|
|
101
|
+
};
|
|
102
|
+
const addInterval = (t) => {
|
|
103
|
+
if (!intervals.includes(t))
|
|
104
|
+
intervals.push(t);
|
|
105
|
+
};
|
|
106
|
+
for (const e of all) {
|
|
107
|
+
if (isReceiver(e))
|
|
108
|
+
addReceiver(e);
|
|
109
|
+
else if (isTimeInterval(e))
|
|
110
|
+
addInterval(e);
|
|
111
|
+
else if (isRoute(e))
|
|
112
|
+
routes.push(e);
|
|
113
|
+
else if (isInhibitRule(e))
|
|
114
|
+
inhibits.push(plain(e.props));
|
|
115
|
+
else if (isAlertmanagerSettings(e)) {
|
|
116
|
+
if (settings)
|
|
117
|
+
warnings.push("prometheus: more than one AlertmanagerSettings is declared; the first one is used");
|
|
118
|
+
else
|
|
119
|
+
settings = e;
|
|
120
|
+
}
|
|
121
|
+
else
|
|
122
|
+
continue;
|
|
123
|
+
count++;
|
|
124
|
+
}
|
|
125
|
+
// Receivers and time intervals a route references by entity are part of the
|
|
126
|
+
// config even when not declared on their own, and child Route entities are
|
|
127
|
+
// not roots.
|
|
128
|
+
const children = new Set();
|
|
129
|
+
const walk = (route) => {
|
|
130
|
+
const p = routeProps(route);
|
|
131
|
+
if (isReceiver(p.receiver))
|
|
132
|
+
addReceiver(p.receiver);
|
|
133
|
+
for (const t of [...(p.mute_time_intervals ?? []), ...(p.active_time_intervals ?? [])]) {
|
|
134
|
+
if (isTimeInterval(t))
|
|
135
|
+
addInterval(t);
|
|
136
|
+
}
|
|
137
|
+
for (const child of p.routes ?? []) {
|
|
138
|
+
if (isRoute(child))
|
|
139
|
+
children.add(child);
|
|
140
|
+
walk(child);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
for (const r of routes)
|
|
144
|
+
walk(r);
|
|
145
|
+
const roots = routes.filter((r) => !children.has(r));
|
|
146
|
+
if (roots.length > 1) {
|
|
147
|
+
warnings.push(`prometheus: ${roots.length} root Routes are declared; Alertmanager has one routing tree, so the first is used. Nest the others under its routes.`);
|
|
148
|
+
}
|
|
149
|
+
const config = {};
|
|
150
|
+
if (settings?.props.global)
|
|
151
|
+
config.global = plain(settings.props.global);
|
|
152
|
+
if (settings?.props.templates)
|
|
153
|
+
config.templates = [...settings.props.templates];
|
|
154
|
+
if (roots.length > 0)
|
|
155
|
+
config.route = routeConfig(roots[0]);
|
|
156
|
+
if (inhibits.length > 0)
|
|
157
|
+
config.inhibit_rules = inhibits;
|
|
158
|
+
// Receivers and time intervals are looked up by name, so their order carries
|
|
159
|
+
// no meaning; sorted, the file is the same however they were collected.
|
|
160
|
+
receivers.sort((a, b) => byName(a.props.name, b.props.name));
|
|
161
|
+
intervals.sort((a, b) => byName(a.props.name, b.props.name));
|
|
162
|
+
if (receivers.length > 0)
|
|
163
|
+
config.receivers = receivers.map((r) => plain(r.props));
|
|
164
|
+
if (intervals.length > 0)
|
|
165
|
+
config.time_intervals = intervals.map((t) => plain(t.props));
|
|
166
|
+
if (settings?.props.tracing)
|
|
167
|
+
config.tracing = plain(settings.props.tracing);
|
|
168
|
+
return { config, warnings, count };
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* The `prometheus.yml` for a set of declared entities. Entities that aren't
|
|
172
|
+
* `PrometheusConfig`s or `ScrapeConfig`s are ignored. Sections come out in the
|
|
173
|
+
* order Prometheus documents them, and scrape jobs are sorted by name: the
|
|
174
|
+
* jobs are independent, so their order carries no meaning.
|
|
175
|
+
*/
|
|
176
|
+
export function buildPrometheusConfig(entities) {
|
|
177
|
+
const warnings = [];
|
|
178
|
+
const jobs = [];
|
|
179
|
+
const seen = new Set();
|
|
180
|
+
let main;
|
|
181
|
+
let count = 0;
|
|
182
|
+
const addJob = (job) => {
|
|
183
|
+
if (seen.has(job))
|
|
184
|
+
return;
|
|
185
|
+
seen.add(job);
|
|
186
|
+
jobs.push(plain(isScrapeConfig(job) ? job.props : job));
|
|
187
|
+
};
|
|
188
|
+
for (const e of entityList(entities)) {
|
|
189
|
+
if (isScrapeConfig(e)) {
|
|
190
|
+
addJob(e);
|
|
191
|
+
count++;
|
|
192
|
+
}
|
|
193
|
+
else if (isPrometheusConfig(e)) {
|
|
194
|
+
if (main)
|
|
195
|
+
warnings.push("prometheus: more than one PrometheusConfig is declared; the first one is used");
|
|
196
|
+
else
|
|
197
|
+
main = e;
|
|
198
|
+
count++;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
for (const job of main?.props.scrape_configs ?? [])
|
|
202
|
+
addJob(job);
|
|
203
|
+
const names = new Map();
|
|
204
|
+
for (const j of jobs)
|
|
205
|
+
names.set(j.job_name, (names.get(j.job_name) ?? 0) + 1);
|
|
206
|
+
for (const [name, n] of names) {
|
|
207
|
+
if (n > 1)
|
|
208
|
+
warnings.push(`prometheus: ${n} scrape configs are named "${name}"; Prometheus rejects a file with a repeated job_name`);
|
|
209
|
+
}
|
|
210
|
+
jobs.sort((a, b) => byName(a.job_name, b.job_name));
|
|
211
|
+
const sections = (plain(main?.props ?? {}) ?? {});
|
|
212
|
+
const config = {};
|
|
213
|
+
for (const key of PROMETHEUS_CONFIG_SECTIONS) {
|
|
214
|
+
if (key === "scrape_configs") {
|
|
215
|
+
if (jobs.length > 0)
|
|
216
|
+
config.scrape_configs = jobs;
|
|
217
|
+
}
|
|
218
|
+
else if (sections[key] !== undefined) {
|
|
219
|
+
config[key] = sections[key];
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return { config: config, warnings, count };
|
|
223
|
+
}
|
|
224
|
+
// ── YAML ────────────────────────────────────────────────────────────
|
|
225
|
+
/** Print a rule file or Alertmanager config as YAML, keys in the order given. */
|
|
226
|
+
export function emitYaml(value) {
|
|
227
|
+
return dump(value, { lineWidth: -1, noRefs: true, quotingType: '"', sortKeys: false });
|
|
228
|
+
}
|
|
229
|
+
/** The rule file YAML for a set of declared entities, exactly as the serializer emits it. */
|
|
230
|
+
export function ruleFileYaml(entities) {
|
|
231
|
+
return emitYaml(buildRuleFile(entities).config);
|
|
232
|
+
}
|
|
233
|
+
/** The `alertmanager.yml` text for a set of declared entities, exactly as the serializer emits it. */
|
|
234
|
+
export function alertmanagerYaml(entities) {
|
|
235
|
+
return emitYaml(buildAlertmanagerConfig(entities).config);
|
|
236
|
+
}
|
|
237
|
+
/** The `prometheus.yml` text for a set of declared entities, exactly as the serializer emits it. */
|
|
238
|
+
export function prometheusConfigYaml(entities) {
|
|
239
|
+
return emitYaml(buildPrometheusConfig(entities).config);
|
|
240
|
+
}
|