@intentius/chant-lexicon-prometheus 0.100.0 → 0.102.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/dist/alertmanager.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/composites/catalog.d.ts.map +1 -1
- package/dist/composites/genai.d.ts +224 -0
- package/dist/composites/genai.d.ts.map +1 -0
- package/dist/composites/index.d.ts +2 -0
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/slo.d.ts +1 -1
- package/dist/composites/slo.d.ts.map +1 -1
- package/dist/import/embedded.d.ts +12 -3
- package/dist/import/embedded.d.ts.map +1 -1
- package/dist/import/generator.d.ts +14 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/init-templates.d.ts +11 -2
- package/dist/init-templates.d.ts.map +1 -1
- package/dist/integrations.d.ts.map +1 -1
- package/dist/integrity.json +8 -7
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +33 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/prom-helpers.d.ts +1 -1
- package/dist/lint/post-synth/prom208.d.ts +1 -1
- package/dist/lint/post-synth/prom209.d.ts +1 -1
- package/dist/lint/post-synth/prom210.d.ts +8 -0
- package/dist/lint/post-synth/prom210.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +1 -0
- package/dist/okf/rules/PROM210.md +11 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rule-eval.d.ts +10 -4
- package/dist/rule-eval.d.ts.map +1 -1
- package/dist/rules/prom-helpers.ts +1 -1
- package/dist/rules/prom208.ts +1 -1
- package/dist/rules/prom209.ts +1 -1
- package/dist/rules/prom210.ts +17 -0
- package/dist/skill-defs.d.ts +1 -1
- package/dist/skill-defs.d.ts.map +1 -1
- package/dist/skills/chant-prometheus-alertmanager.md +3 -1
- package/dist/skills/chant-prometheus.md +22 -0
- package/dist/validate-config.d.ts +2 -2
- package/dist/validate-config.d.ts.map +1 -1
- package/dist/validate-integrations.d.ts +37 -0
- package/dist/validate-integrations.d.ts.map +1 -0
- package/package.json +4 -3
- package/src/codegen/docs.ts +5 -0
- package/src/composites/catalog.test.ts +1 -1
- package/src/composites/catalog.ts +70 -0
- package/src/composites/genai.test.ts +307 -0
- package/src/composites/genai.ts +678 -0
- package/src/composites/index.ts +15 -0
- package/src/composites/slo-burn.test.ts +25 -1
- package/src/import/embedded.test.ts +52 -3
- package/src/import/embedded.ts +37 -5
- package/src/import/generator.ts +19 -1
- package/src/index.ts +14 -0
- package/src/init-templates.test.ts +37 -11
- package/src/init-templates.ts +75 -6
- package/src/lint/audit-catalog.ts +13 -2
- package/src/lint/audit-lineage.ts +69 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/post-synth.test.ts +34 -0
- package/src/lint/post-synth/prom-helpers.ts +1 -1
- package/src/lint/post-synth/prom208.ts +1 -1
- package/src/lint/post-synth/prom209.ts +1 -1
- package/src/lint/post-synth/prom210.ts +17 -0
- package/src/plugin.test.ts +1 -0
- package/src/plugin.ts +8 -4
- package/src/rule-eval.ts +72 -7
- package/src/skills/chant-prometheus-alertmanager.md +3 -1
- package/src/skills/chant-prometheus.md +22 -0
- package/src/testdata/integration-cases.ts +96 -0
- package/src/tools.test.ts +6 -0
- package/src/typecheck.test.ts +88 -0
- package/src/validate-config.ts +8 -31
- package/src/validate-integrations.ts +389 -0
package/src/composites/index.ts
CHANGED
|
@@ -21,3 +21,18 @@ export type {
|
|
|
21
21
|
SloMetrics,
|
|
22
22
|
SloBurnRate,
|
|
23
23
|
} from "./slo";
|
|
24
|
+
export { GenAiRules, genAiRuleMetrics } from "./genai";
|
|
25
|
+
export type {
|
|
26
|
+
GenAiRulesProps,
|
|
27
|
+
GenAiRulesMembers,
|
|
28
|
+
GenAiRulesInstance,
|
|
29
|
+
GenAiRuleMetrics,
|
|
30
|
+
GenAiPrice,
|
|
31
|
+
GenAiAlerting,
|
|
32
|
+
GenAiAlertOptions,
|
|
33
|
+
GenAiRatioAlert,
|
|
34
|
+
GenAiLatencyAlert,
|
|
35
|
+
GenAiBudget,
|
|
36
|
+
GenAiAlertInfo,
|
|
37
|
+
GenAiQuantileSeries,
|
|
38
|
+
} from "./genai";
|
|
@@ -259,8 +259,32 @@ describe.skipIf(!hasPromtool)("each pair fires at its burn rate and not below it
|
|
|
259
259
|
|
|
260
260
|
describe("rule-eval", () => {
|
|
261
261
|
test("throws on PromQL it does not implement instead of guessing", () => {
|
|
262
|
-
const ev = new RuleEvaluator([{ name: "g", rules: [{ record: "x", expr: "
|
|
262
|
+
const ev = new RuleEvaluator([{ name: "g", rules: [{ record: "x", expr: "max_over_time(a[5m])" }] }]);
|
|
263
263
|
expect(() => ev.step(0)).toThrow(/not supported|needs a range/);
|
|
264
|
+
const left = new RuleEvaluator([{ name: "g", rules: [{ record: "x", expr: "a / ignoring (t) group_left (u) b" }] }]);
|
|
265
|
+
expect(() => left.step(0)).toThrow(/group_left with labels is not supported/);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("histogram_quantile interpolates inside the bucket the rank falls in", () => {
|
|
269
|
+
const ev = new RuleEvaluator([]);
|
|
270
|
+
for (const [le, n] of [["0.5", 20], ["1", 60], ["2", 100], ["+Inf", 100]] as const) ev.add({ __name__: "d_bucket", job: "a", le }, 0, n);
|
|
271
|
+
for (const [le, n] of [["1", 0], ["+Inf", 10]] as const) ev.add({ __name__: "d_bucket", job: "b", le }, 0, n);
|
|
272
|
+
const at = (q: number) => Object.fromEntries(ev.query(`histogram_quantile(${q}, d_bucket)`, 0).map((e) => [e.labels.job, e.value]));
|
|
273
|
+
// a: rank 50 is 30 of the 40 in (0.5, 1]; rank 95 is 35 of the 40 in (1, 2].
|
|
274
|
+
expect(at(0.5).a).toBeCloseTo(0.875, 9);
|
|
275
|
+
expect(at(0.95).a).toBeCloseTo(1.875, 9);
|
|
276
|
+
// b: every observation is above the highest finite bound, which is what Prometheus returns.
|
|
277
|
+
expect(at(0.5).b).toBe(1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("group_left matches many series on the left to one on the right", () => {
|
|
281
|
+
const ev = new RuleEvaluator([]);
|
|
282
|
+
ev.add({ __name__: "errs", m: "x", t: "timeout" }, 0, 2);
|
|
283
|
+
ev.add({ __name__: "errs", m: "x", t: "refused" }, 0, 3);
|
|
284
|
+
ev.add({ __name__: "reqs", m: "x" }, 0, 10);
|
|
285
|
+
const r = ev.query("errs / ignoring (t) group_left reqs", 0);
|
|
286
|
+
expect(r.map((e) => [e.labels.t, e.value]).sort()).toEqual([["refused", 0.3], ["timeout", 0.2]]);
|
|
287
|
+
expect(() => ev.query("errs / ignoring (t) reqs", 0)).toThrow(/needs group_left/);
|
|
264
288
|
});
|
|
265
289
|
|
|
266
290
|
test("evaluates sums, ratios and set operators the way Prometheus does", () => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { embeddedDocument, type EmbeddedContent } from "@intentius/chant/import/embedded";
|
|
3
3
|
import { prometheusPlugin } from "../plugin";
|
|
4
|
-
import { ruleGroupsImporter } from "./embedded";
|
|
4
|
+
import { alertmanagerImporter, ruleGroupsImporter } from "./embedded";
|
|
5
5
|
|
|
6
6
|
const GROUPS = [
|
|
7
7
|
{ name: "api", rules: [{ alert: "ApiDown", expr: "up{job=\"api\"} == 0", for: "5m" }] },
|
|
@@ -25,8 +25,8 @@ const site = (over: Partial<EmbeddedContent>): EmbeddedContent => ({
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
describe("rule groups embedded in another lexicon's resource (#2962)", () => {
|
|
28
|
-
test("the plugin registers the
|
|
29
|
-
expect(prometheusPlugin.embeddedImporters?.()).toEqual([ruleGroupsImporter]);
|
|
28
|
+
test("the plugin registers the importers", () => {
|
|
29
|
+
expect(prometheusPlugin.embeddedImporters?.()).toEqual([ruleGroupsImporter, alertmanagerImporter]);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
test("matches a PrometheusRule's groups and a rule file held as text; not an alertmanager.yml", () => {
|
|
@@ -54,3 +54,52 @@ describe("rule groups embedded in another lexicon's resource (#2962)", () => {
|
|
|
54
54
|
expect(out.value.bindings).toEqual([{ from: "rules.ts", name: "api" }]);
|
|
55
55
|
});
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
const ALERTMANAGER = `route:
|
|
59
|
+
receiver: team
|
|
60
|
+
routes:
|
|
61
|
+
- matchers: [severity="critical"]
|
|
62
|
+
receiver: pager
|
|
63
|
+
mute_time_intervals: [nights]
|
|
64
|
+
receivers:
|
|
65
|
+
- name: team
|
|
66
|
+
- name: pager
|
|
67
|
+
time_intervals:
|
|
68
|
+
- name: nights
|
|
69
|
+
time_intervals:
|
|
70
|
+
- times: [{ start_time: "22:00", end_time: "24:00" }]
|
|
71
|
+
inhibit_rules:
|
|
72
|
+
- source_matchers: [severity="critical"]
|
|
73
|
+
target_matchers: [severity="warning"]
|
|
74
|
+
templates: [/etc/alertmanager/*.tmpl]
|
|
75
|
+
`;
|
|
76
|
+
|
|
77
|
+
describe("an alertmanager.yml embedded in another lexicon's resource (#3031)", () => {
|
|
78
|
+
const configMap = (text: string) =>
|
|
79
|
+
site({ hostType: "K8s::Core::ConfigMap", location: 'ConfigMap am data["alertmanager.yml"]', text, document: embeddedDocument(text) });
|
|
80
|
+
|
|
81
|
+
test("matches an alertmanager.yml held as text; not a rule file, nor a selected member", () => {
|
|
82
|
+
expect(alertmanagerImporter.matches(configMap(ALERTMANAGER))).toBe(true);
|
|
83
|
+
expect(alertmanagerImporter.matches(configMap(RULE_FILE))).toBe(false);
|
|
84
|
+
expect(alertmanagerImporter.matches(site({ document: { groups: GROUPS }, select: "groups" }))).toBe(false);
|
|
85
|
+
expect(ruleGroupsImporter.matches(configMap(ALERTMANAGER))).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("becomes alertmanagerYaml over every declaration the standalone import writes", () => {
|
|
89
|
+
const out = alertmanagerImporter.import(configMap(ALERTMANAGER));
|
|
90
|
+
expect(out.files.map((f) => f.path)).toEqual(["receivers.ts", "time-intervals.ts", "routes.ts", "inhibit-rules.ts", "settings.ts"]);
|
|
91
|
+
expect(out.value).toEqual({
|
|
92
|
+
bindings: [
|
|
93
|
+
{ from: "receivers.ts", name: "team" },
|
|
94
|
+
{ from: "receivers.ts", name: "pager" },
|
|
95
|
+
{ from: "time-intervals.ts", name: "nights" },
|
|
96
|
+
{ from: "routes.ts", name: "root" },
|
|
97
|
+
{ from: "inhibit-rules.ts", name: "inhibitRule1" },
|
|
98
|
+
{ from: "settings.ts", name: "settings" },
|
|
99
|
+
],
|
|
100
|
+
shape: "list",
|
|
101
|
+
through: { from: "@intentius/chant-lexicon-prometheus", name: "alertmanagerYaml" },
|
|
102
|
+
});
|
|
103
|
+
expect(out.warnings).toEqual([]);
|
|
104
|
+
});
|
|
105
|
+
});
|
package/src/import/embedded.ts
CHANGED
|
@@ -1,19 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* text in a ConfigMap.
|
|
2
|
+
* Prometheus and Alertmanager content embedded in another lexicon's
|
|
3
|
+
* resource, for `chant import`: a k8s `PrometheusRule`'s `spec.groups`, or a
|
|
4
|
+
* rule file held as text in a ConfigMap (#2962), and an `alertmanager.yml`
|
|
5
|
+
* held as text in a ConfigMap (#3031).
|
|
5
6
|
*
|
|
6
7
|
* The groups are imported exactly as `chant import rules.yml` would import
|
|
7
8
|
* them, into a directory of its own. `spec.groups` becomes the list of the
|
|
8
9
|
* declared groups (an `Slo`'s by its `rules` member), which the k8s
|
|
9
10
|
* serializer renders as the same groups; a rule file becomes
|
|
10
11
|
* `ruleFileYaml([...])`, the text the prometheus serializer writes.
|
|
12
|
+
*
|
|
13
|
+
* An `alertmanager.yml` is imported exactly as `chant import alertmanager.yml`
|
|
14
|
+
* would import it, and becomes `alertmanagerYaml([...])` over every receiver,
|
|
15
|
+
* time interval, root route, inhibit rule and settings it declares. The
|
|
16
|
+
* ConfigMap then holds the config as the serializer writes it: the same
|
|
17
|
+
* config, with Alertmanager's deprecated spellings rewritten as the
|
|
18
|
+
* standalone import rewrites them.
|
|
11
19
|
*/
|
|
12
20
|
|
|
13
21
|
import type { EmbeddedContentImporter, EmbeddedImport } from "@intentius/chant/import/embedded";
|
|
14
|
-
import { looksLikeRuleFile } from "../model";
|
|
22
|
+
import { looksLikeAlertmanagerConfig, looksLikeRuleFile } from "../model";
|
|
15
23
|
import { parsePrometheusYaml } from "./parser";
|
|
16
|
-
import { generateRuleFile } from "./generator";
|
|
24
|
+
import { generateAlertmanager, generateRuleFile } from "./generator";
|
|
17
25
|
|
|
18
26
|
const PACKAGE = "@intentius/chant-lexicon-prometheus";
|
|
19
27
|
|
|
@@ -42,3 +50,27 @@ export const ruleGroupsImporter: EmbeddedContentImporter = {
|
|
|
42
50
|
};
|
|
43
51
|
},
|
|
44
52
|
};
|
|
53
|
+
|
|
54
|
+
export const alertmanagerImporter: EmbeddedContentImporter = {
|
|
55
|
+
what: "an Alertmanager config",
|
|
56
|
+
|
|
57
|
+
matches(content) {
|
|
58
|
+
return content.select === undefined && typeof content.text === "string" && looksLikeAlertmanagerConfig(content.document);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
import(content): EmbeddedImport {
|
|
62
|
+
const parsed = parsePrometheusYaml(content.text!);
|
|
63
|
+
if (parsed.kind !== "alertmanager") throw new Error("this is not an alertmanager.yml");
|
|
64
|
+
const { files, declarations } = generateAlertmanager(parsed.config);
|
|
65
|
+
if (declarations.length === 0) throw new Error("the import declared nothing");
|
|
66
|
+
return {
|
|
67
|
+
files,
|
|
68
|
+
value: {
|
|
69
|
+
bindings: declarations.map((d) => ({ from: d.path, name: d.name })),
|
|
70
|
+
shape: "list",
|
|
71
|
+
through: { from: PACKAGE, name: "alertmanagerYaml" },
|
|
72
|
+
},
|
|
73
|
+
warnings: parsed.warnings,
|
|
74
|
+
};
|
|
75
|
+
},
|
|
76
|
+
};
|
package/src/import/generator.ts
CHANGED
|
@@ -381,6 +381,21 @@ function untypedComment(what: string, keys: string[]): string[] {
|
|
|
381
381
|
|
|
382
382
|
/** Generate the TypeScript declaring one `alertmanager.yml`. */
|
|
383
383
|
export function generateAlertmanagerFiles(config: AlertmanagerConfig): GeneratedFile[] {
|
|
384
|
+
return generateAlertmanager(config).files;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** A declaration an `alertmanager.yml` import exports: its module and variable. */
|
|
388
|
+
export interface AlertmanagerDeclaration {
|
|
389
|
+
readonly path: string;
|
|
390
|
+
readonly name: string;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Generate the TypeScript declaring one `alertmanager.yml`, and list every
|
|
395
|
+
* declaration it exports, module by module (#3031: a ConfigMap holding the
|
|
396
|
+
* file becomes `alertmanagerYaml([...])` over them).
|
|
397
|
+
*/
|
|
398
|
+
export function generateAlertmanager(config: AlertmanagerConfig): { files: GeneratedFile[]; declarations: AlertmanagerDeclaration[] } {
|
|
384
399
|
const names = new Names([...LEXICON_NAMES, "global", "tracing"]);
|
|
385
400
|
const modules: Module[] = [];
|
|
386
401
|
const receiverVars = new Map<string, { v: string; mod: Module }>();
|
|
@@ -558,7 +573,10 @@ export function generateAlertmanagerFiles(config: AlertmanagerConfig): Generated
|
|
|
558
573
|
mod.exports.push(v);
|
|
559
574
|
}
|
|
560
575
|
|
|
561
|
-
return
|
|
576
|
+
return {
|
|
577
|
+
files: modules.map((m) => ({ path: m.path, content: m.render() })),
|
|
578
|
+
declarations: modules.flatMap((m) => m.exports.map((name) => ({ path: m.path, name }))),
|
|
579
|
+
};
|
|
562
580
|
}
|
|
563
581
|
|
|
564
582
|
/** The rule file and `alertmanager.yml` TypeScript generator `chant import` runs. */
|
package/src/index.ts
CHANGED
|
@@ -94,4 +94,18 @@ export {
|
|
|
94
94
|
type SloInstance,
|
|
95
95
|
type SloMetrics,
|
|
96
96
|
type SloBurnRate,
|
|
97
|
+
GenAiRules,
|
|
98
|
+
genAiRuleMetrics,
|
|
99
|
+
type GenAiRulesProps,
|
|
100
|
+
type GenAiRulesMembers,
|
|
101
|
+
type GenAiRulesInstance,
|
|
102
|
+
type GenAiRuleMetrics,
|
|
103
|
+
type GenAiPrice,
|
|
104
|
+
type GenAiAlerting,
|
|
105
|
+
type GenAiAlertOptions,
|
|
106
|
+
type GenAiRatioAlert,
|
|
107
|
+
type GenAiLatencyAlert,
|
|
108
|
+
type GenAiBudget,
|
|
109
|
+
type GenAiAlertInfo,
|
|
110
|
+
type GenAiQuantileSeries,
|
|
97
111
|
} from "./composites";
|
|
@@ -1,29 +1,55 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "fs";
|
|
3
|
-
import { tmpdir } from "os";
|
|
4
3
|
import { join } from "path";
|
|
4
|
+
import { load } from "js-yaml";
|
|
5
5
|
import { build } from "@intentius/chant/build";
|
|
6
6
|
import { lintCommand } from "@intentius/chant/cli/commands/lint";
|
|
7
|
-
import { initTemplates } from "./init-templates";
|
|
8
|
-
import { prometheusSerializer } from "./serializer";
|
|
9
|
-
import { postSynthChecks } from "./lint/post-synth";
|
|
10
7
|
import { runPostSynthChecks } from "@intentius/chant/lint/post-synth";
|
|
8
|
+
import { prometheusPlugin } from "./plugin";
|
|
9
|
+
import { TEMPLATE_NAMES } from "./init-templates";
|
|
10
|
+
import { postSynthChecks } from "./lint/post-synth";
|
|
11
|
+
|
|
12
|
+
async function built(name: string | undefined) {
|
|
13
|
+
const dir = mkdtempSync(join(import.meta.dirname, "..", ".init-template-"));
|
|
14
|
+
mkdirSync(join(dir, "src"));
|
|
15
|
+
for (const [file, text] of Object.entries(prometheusPlugin.initTemplates!(name).src)) writeFileSync(join(dir, "src", file), text);
|
|
16
|
+
return { dir, result: await build(join(dir, "src"), [prometheusPlugin.serializer]) };
|
|
17
|
+
}
|
|
11
18
|
|
|
19
|
+
// Every template builds, passes every PROM check with nothing to report, and
|
|
20
|
+
// lints clean.
|
|
12
21
|
describe("init templates", () => {
|
|
13
|
-
test.each([undefined,
|
|
14
|
-
const dir =
|
|
22
|
+
test.each([undefined, ...TEMPLATE_NAMES])("%s builds, lints clean and passes every check", async (name) => {
|
|
23
|
+
const { dir, result } = await built(name);
|
|
15
24
|
try {
|
|
16
|
-
mkdirSync(join(dir, "src"));
|
|
17
|
-
for (const [file, text] of Object.entries(initTemplates(name).src)) writeFileSync(join(dir, "src", file), text);
|
|
18
|
-
const result = await build(join(dir, "src"), [prometheusSerializer]);
|
|
19
25
|
expect(result.errors).toEqual([]);
|
|
20
26
|
expect(result.outputs.get("prometheus")).toBeTruthy();
|
|
21
|
-
|
|
22
|
-
expect(diags).toEqual([]);
|
|
27
|
+
expect(runPostSynthChecks(postSynthChecks, result)).toEqual([]);
|
|
23
28
|
const lint = await lintCommand({ path: join(dir, "src"), format: "stylish", fix: false });
|
|
24
29
|
expect(lint.errorCount + lint.warningCount, lint.output).toBe(0);
|
|
25
30
|
} finally {
|
|
26
31
|
rmSync(dir, { recursive: true, force: true });
|
|
27
32
|
}
|
|
28
33
|
});
|
|
34
|
+
|
|
35
|
+
test("the slo template builds the SLO's rules and routes both severities its alerts carry", async () => {
|
|
36
|
+
const { dir, result } = await built("slo");
|
|
37
|
+
try {
|
|
38
|
+
const out = result.outputs.get("prometheus") as { primary: string; files: Record<string, string> };
|
|
39
|
+
const rules = load(out.primary) as { groups: Array<{ name: string; rules: Array<{ alert?: string; labels?: Record<string, string> }> }> };
|
|
40
|
+
expect(rules.groups.map((g) => g.name)).toEqual(["slo-checkout"]);
|
|
41
|
+
const severities = new Set(rules.groups[0].rules.filter((r) => r.alert).map((r) => r.labels?.severity));
|
|
42
|
+
expect([...severities].sort()).toEqual(["page", "ticket"]);
|
|
43
|
+
const am = load(out.files["alertmanager.yml"]) as { route: { routes: Array<{ matchers: string[] }> }; inhibit_rules: unknown[] };
|
|
44
|
+
expect(am.route.routes.flatMap((r) => r.matchers)).toEqual(['severity="page"', 'severity="ticket"']);
|
|
45
|
+
expect(am.inhibit_rules).toEqual([{ source_matchers: ['severity="page"'], target_matchers: ['severity="ticket"'], equal: ["slo"] }]);
|
|
46
|
+
} finally {
|
|
47
|
+
rmSync(dir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("an unknown template name falls back to the default", () => {
|
|
52
|
+
expect(prometheusPlugin.initTemplates!("no-such-template")).toBe(prometheusPlugin.initTemplates!());
|
|
53
|
+
for (const name of TEMPLATE_NAMES) expect(prometheusPlugin.initTemplates!(name)).not.toBe(prometheusPlugin.initTemplates!());
|
|
54
|
+
});
|
|
29
55
|
});
|
package/src/init-templates.ts
CHANGED
|
@@ -6,7 +6,11 @@
|
|
|
6
6
|
* - `rules`: rule groups only, for a setup whose Alertmanager config lives
|
|
7
7
|
* elsewhere.
|
|
8
8
|
* - `slo-style`: a recording rule per window and alerts on two severities,
|
|
9
|
-
* the shape SLO burn-rate rules take.
|
|
9
|
+
* the shape SLO burn-rate rules take, written out as plain rules.
|
|
10
|
+
* - `slo`: an SLO declared with the `Slo` composite, which builds its error
|
|
11
|
+
* ratios, error budget and multiwindow burn-rate alerts, and the
|
|
12
|
+
* Alertmanager routing for the page and ticket severities those alerts
|
|
13
|
+
* carry, with a page muting the same SLO's ticket.
|
|
10
14
|
*/
|
|
11
15
|
import type { InitTemplateSet } from "@intentius/chant/lexicon";
|
|
12
16
|
|
|
@@ -69,8 +73,73 @@ const burnAlerts = new RuleGroup({ name: "burn-alerts", rules: alerting });
|
|
|
69
73
|
export { errorRatios, burnAlerts };
|
|
70
74
|
`;
|
|
71
75
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
// ── slo ────────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
const SLO_DECLARATION = `/**
|
|
79
|
+
* The SLO: 99.9% of checkout requests answer without a 5xx over 30 days.
|
|
80
|
+
* \`Slo\` builds one rule group: the error ratio over every window its alerts
|
|
81
|
+
* read, the error budget left, and burn-rate alerts that page on a fast burn
|
|
82
|
+
* (severity "page") and open a ticket on a slow one (severity "ticket").
|
|
83
|
+
* \`sloMetrics(checkout)\` returns the recorded series names, for a dashboard
|
|
84
|
+
* or another rule to read.
|
|
85
|
+
*/
|
|
86
|
+
import { Slo } from "@intentius/chant-lexicon-prometheus";
|
|
87
|
+
|
|
88
|
+
const sli = {
|
|
89
|
+
errors: 'sum(rate(http_requests_total{job="checkout",code=~"5.."}[{{window}}]))',
|
|
90
|
+
total: 'sum(rate(http_requests_total{job="checkout"}[{{window}}]))',
|
|
91
|
+
};
|
|
92
|
+
const team = { team: "payments" };
|
|
93
|
+
|
|
94
|
+
const checkout = Slo({
|
|
95
|
+
name: "checkout",
|
|
96
|
+
objective: 0.999,
|
|
97
|
+
window: "30d",
|
|
98
|
+
description: "Checkout requests answer without a 5xx.",
|
|
99
|
+
sli,
|
|
100
|
+
labels: team,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
export { checkout };
|
|
104
|
+
`;
|
|
105
|
+
|
|
106
|
+
const SLO_ALERTMANAGER = `/**
|
|
107
|
+
* Routing for the SLO's alerts, in the same build root so PROM202 checks that
|
|
108
|
+
* both severities they carry have a route.
|
|
109
|
+
*/
|
|
110
|
+
import { InhibitRule, Receiver, Route, type RouteProps, type WebhookConfig } from "@intentius/chant-lexicon-prometheus";
|
|
111
|
+
|
|
112
|
+
const pager: WebhookConfig[] = [{ url: "http://pager-bridge:8080/alerts" }];
|
|
113
|
+
const oncall = new Receiver({ name: "oncall", webhook_configs: pager });
|
|
114
|
+
|
|
115
|
+
const ticketing: WebhookConfig[] = [{ url: "http://ticket-bridge:8080/alerts" }];
|
|
116
|
+
const tickets = new Receiver({ name: "tickets", webhook_configs: ticketing });
|
|
117
|
+
|
|
118
|
+
const fallback = new Receiver({ name: "default" });
|
|
119
|
+
|
|
120
|
+
const bySlo = ["alertname", "slo"];
|
|
121
|
+
const children: RouteProps[] = [
|
|
122
|
+
{ matchers: ['severity="page"'], receiver: oncall },
|
|
123
|
+
{ matchers: ['severity="ticket"'], receiver: tickets },
|
|
124
|
+
];
|
|
125
|
+
const root = new Route({ receiver: fallback, group_by: bySlo, routes: children });
|
|
126
|
+
|
|
127
|
+
// A page for an SLO mutes its ticket: the fast burn already has someone on it.
|
|
128
|
+
const pageSource = ['severity="page"'];
|
|
129
|
+
const ticketTarget = ['severity="ticket"'];
|
|
130
|
+
const sameSlo = ["slo"];
|
|
131
|
+
const pageMutesTicket = new InhibitRule({ source_matchers: pageSource, target_matchers: ticketTarget, equal: sameSlo });
|
|
132
|
+
|
|
133
|
+
export { oncall, tickets, fallback, root, pageMutesTicket };
|
|
134
|
+
`;
|
|
135
|
+
|
|
136
|
+
export const DEFAULT_TEMPLATE: InitTemplateSet = { src: { "rules.ts": RULES, "alertmanager.ts": ALERTMANAGER } };
|
|
137
|
+
|
|
138
|
+
export const RULES_TEMPLATE: InitTemplateSet = { src: { "rules.ts": RULES } };
|
|
139
|
+
|
|
140
|
+
export const SLO_STYLE_TEMPLATE: InitTemplateSet = { src: { "rules.ts": SLO_STYLE } };
|
|
141
|
+
|
|
142
|
+
export const SLO_TEMPLATE: InitTemplateSet = { src: { "slo.ts": SLO_DECLARATION, "alertmanager.ts": SLO_ALERTMANAGER } };
|
|
143
|
+
|
|
144
|
+
/** The template names `chant init --lexicon prometheus --template <name>` takes, besides the default. */
|
|
145
|
+
export const TEMPLATE_NAMES = ["rules", "slo-style", "slo"] as const;
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* listed for a reader who meets them in a lint report.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import { auditRule, type RuleMeta } from "@intentius/chant/audit/catalog";
|
|
12
|
+
import { applyLineage, auditRule, type RuleMeta } from "@intentius/chant/audit/catalog";
|
|
13
|
+
import { prometheusAuditLineage } from "./audit-lineage";
|
|
13
14
|
|
|
14
15
|
function sourceRule(id: string, category: RuleMeta["category"], title: string, remediation: string): RuleMeta {
|
|
15
16
|
return { id, tier: "merge-worthy", fixKind: "guidance", category, title, remediation, yamlBased: false };
|
|
@@ -118,6 +119,16 @@ export const prometheusAuditCatalog: Record<string, RuleMeta> = {
|
|
|
118
119
|
"merge-worthy",
|
|
119
120
|
"correctness",
|
|
120
121
|
"Receiver integration missing its destination or credential",
|
|
121
|
-
"Set the integration's url,
|
|
122
|
+
"Set the integration's destination and credential (url, webhook_url, api_key, routing_key, chat_id, room_id, to/smarthost/from, ...), through its *_file or global equivalent where there is one.",
|
|
123
|
+
),
|
|
124
|
+
PROM210: outputRule(
|
|
125
|
+
"PROM210",
|
|
126
|
+
"merge-worthy",
|
|
127
|
+
"correctness",
|
|
128
|
+
"Integration or global setting Alertmanager rejects",
|
|
129
|
+
"Set one of each value and its *_file, and use a value Alertmanager allows (e.g. message_type text or markdown, parse_mode Markdown, MarkdownV2 or HTML).",
|
|
122
130
|
),
|
|
123
131
|
};
|
|
132
|
+
|
|
133
|
+
// Prior art credits live beside the rules in ./audit-lineage.ts (see core audit/prior-art.ts).
|
|
134
|
+
applyLineage(prometheusAuditCatalog, prometheusAuditLineage);
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prior art for the prometheus lexicon's audit rules: the tools whose checks
|
|
3
|
+
* cover the same condition, credited per rule. See
|
|
4
|
+
* packages/core/src/audit/prior-art.ts for the registry, the relation
|
|
5
|
+
* vocabulary, and why this is credit rather than authority. Kept by hand.
|
|
6
|
+
*
|
|
7
|
+
* Mapped against each tool's own check list (#2916):
|
|
8
|
+
*
|
|
9
|
+
* - promtool check rules parses a rule file with Prometheus's rulefmt
|
|
10
|
+
* package (model/rulefmt/rulefmt.go): repeated group names, record/alert
|
|
11
|
+
* exclusivity, annotations/for/keep_firing_for on recording rules, durations,
|
|
12
|
+
* and PromQL syntax. It has no per-rule ids, so those credits omit `rule`.
|
|
13
|
+
* - amtool check-config loads an Alertmanager config with the same code the
|
|
14
|
+
* server uses (config/config.go): undefined receivers and time intervals,
|
|
15
|
+
* non-unique names, a root route that is missing, has no receiver or has
|
|
16
|
+
* matchers, matcher syntax, durations, missing integration destinations and
|
|
17
|
+
* credentials, and "at most one of X and X_file". No per-rule ids either.
|
|
18
|
+
* - pint (cloudflare/pint, docs/checks/) is a rule linter with named checks.
|
|
19
|
+
* promql/syntax, rule/duplicate, rule/label, alerts/annotation and
|
|
20
|
+
* alerts/for map onto chant rules. rule/label and alerts/annotation are
|
|
21
|
+
* configurable "this label or annotation must exist" checks that pint ships
|
|
22
|
+
* with nothing enabled, so they overlap PROM106 and PROM107 and no more.
|
|
23
|
+
*
|
|
24
|
+
* Deliberately without a credit from these tools:
|
|
25
|
+
* - PROM001 and PROM003: a literal credential in TypeScript source, and the
|
|
26
|
+
* Slo objective/window model. Neither tool reads either.
|
|
27
|
+
* - PROM202 (severity not routed) and PROM207 (receiver never routed to):
|
|
28
|
+
* check-config accepts both, and `amtool config routes test` answers a
|
|
29
|
+
* different question (where one given label set goes).
|
|
30
|
+
*/
|
|
31
|
+
import type { Lineage } from "@intentius/chant/audit/catalog";
|
|
32
|
+
|
|
33
|
+
const RULEFMT = "https://github.com/prometheus/prometheus/blob/main/model/rulefmt/rulefmt.go";
|
|
34
|
+
const AM_CONFIG = "https://github.com/prometheus/alertmanager/blob/main/config/config.go";
|
|
35
|
+
const PINT = "https://github.com/cloudflare/pint/blob/main/docs/checks";
|
|
36
|
+
|
|
37
|
+
const amtool = (relation: Lineage["relation"]): Lineage => ({
|
|
38
|
+
tool: "amtool",
|
|
39
|
+
url: AM_CONFIG,
|
|
40
|
+
relation,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export const prometheusAuditLineage: Record<string, Lineage[]> = {
|
|
44
|
+
PROM002: [
|
|
45
|
+
{ tool: "promtool", url: RULEFMT, relation: "equivalent" },
|
|
46
|
+
{ tool: "pint", rule: "promql/syntax", url: `${PINT}/promql/syntax.md`, relation: "equivalent" },
|
|
47
|
+
],
|
|
48
|
+
PROM101: [{ tool: "promtool", url: RULEFMT, relation: "equivalent" }],
|
|
49
|
+
PROM102: [{ tool: "pint", rule: "rule/duplicate", url: `${PINT}/rule/duplicate.md`, relation: "overlaps" }],
|
|
50
|
+
PROM103: [
|
|
51
|
+
{ tool: "promtool", url: RULEFMT, relation: "equivalent" },
|
|
52
|
+
{ tool: "pint", rule: "alerts/for", url: `${PINT}/alerts/for.md`, relation: "overlaps" },
|
|
53
|
+
],
|
|
54
|
+
PROM104: [
|
|
55
|
+
{ tool: "promtool", url: RULEFMT, relation: "equivalent" },
|
|
56
|
+
{ tool: "pint", rule: "promql/syntax", url: `${PINT}/promql/syntax.md`, relation: "equivalent" },
|
|
57
|
+
],
|
|
58
|
+
PROM105: [{ tool: "promtool", url: RULEFMT, relation: "equivalent" }],
|
|
59
|
+
PROM106: [{ tool: "pint", rule: "rule/label", url: `${PINT}/rule/label.md`, relation: "overlaps" }],
|
|
60
|
+
PROM107: [{ tool: "pint", rule: "alerts/annotation", url: `${PINT}/alerts/annotation.md`, relation: "overlaps" }],
|
|
61
|
+
PROM201: [amtool("equivalent")],
|
|
62
|
+
PROM203: [amtool("equivalent")],
|
|
63
|
+
PROM204: [amtool("equivalent")],
|
|
64
|
+
PROM205: [amtool("equivalent")],
|
|
65
|
+
PROM206: [amtool("equivalent")],
|
|
66
|
+
PROM208: [amtool("equivalent")],
|
|
67
|
+
PROM209: [amtool("overlaps")],
|
|
68
|
+
PROM210: [amtool("overlaps")],
|
|
69
|
+
};
|
|
@@ -16,6 +16,7 @@ import { prom206 } from "./prom206";
|
|
|
16
16
|
import { prom207 } from "./prom207";
|
|
17
17
|
import { prom208 } from "./prom208";
|
|
18
18
|
import { prom209 } from "./prom209";
|
|
19
|
+
import { prom210 } from "./prom210";
|
|
19
20
|
|
|
20
21
|
export const postSynthChecks: PostSynthCheck[] = [
|
|
21
22
|
prom101,
|
|
@@ -34,4 +35,5 @@ export const postSynthChecks: PostSynthCheck[] = [
|
|
|
34
35
|
prom207,
|
|
35
36
|
prom208,
|
|
36
37
|
prom209,
|
|
38
|
+
prom210,
|
|
37
39
|
];
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, expect, test } from "vitest";
|
|
2
2
|
import { makePostSynthCtx, makePostSynthCtxFromFiles } from "@intentius/chant-test-utils";
|
|
3
3
|
import { postSynthChecks } from "./index";
|
|
4
|
+
import { RECEIVER_INTEGRATION_TYPES } from "../../integrations";
|
|
5
|
+
import { INTEGRATION_CASES, SLACK_APP_URL, amWith } from "../../testdata/integration-cases";
|
|
4
6
|
import { prom101 } from "./prom101";
|
|
5
7
|
import { prom102 } from "./prom102";
|
|
6
8
|
import { prom103 } from "./prom103";
|
|
@@ -17,6 +19,7 @@ import { prom206 } from "./prom206";
|
|
|
17
19
|
import { prom207 } from "./prom207";
|
|
18
20
|
import { prom208 } from "./prom208";
|
|
19
21
|
import { prom209 } from "./prom209";
|
|
22
|
+
import { prom210 } from "./prom210";
|
|
20
23
|
|
|
21
24
|
const RULES = `groups:
|
|
22
25
|
- name: api
|
|
@@ -245,6 +248,37 @@ receivers:
|
|
|
245
248
|
});
|
|
246
249
|
});
|
|
247
250
|
|
|
251
|
+
const integrationCodes = (text: string) =>
|
|
252
|
+
[prom208, prom209, prom210].flatMap((c) => c.check(amOnly(text))).map((d) => d.checkId).sort();
|
|
253
|
+
|
|
254
|
+
describe("PROM208-PROM210 on every Alertmanager integration", () => {
|
|
255
|
+
test.each(INTEGRATION_CASES)("%s", (_label, key, entry, global, codes) => {
|
|
256
|
+
expect(integrationCodes(amWith(key, entry, global))).toEqual(codes);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("every integration has a passing and a failing case", () => {
|
|
260
|
+
for (const key of Object.keys(RECEIVER_INTEGRATION_TYPES)) {
|
|
261
|
+
const cases = INTEGRATION_CASES.filter((c) => c[1] === key);
|
|
262
|
+
expect(cases.some((c) => c[4].length === 0), `${key} passing`).toBe(true);
|
|
263
|
+
expect(cases.some((c) => c[4].length > 0), `${key} failing`).toBe(true);
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("findings name the receiver and the entry", () => {
|
|
268
|
+
const [d] = prom209.check(amOnly(amWith("opsgenie_configs", {})));
|
|
269
|
+
expect(d).toMatchObject({ checkId: "PROM209", severity: "error", entity: "r" });
|
|
270
|
+
expect(d.message).toContain('receiver "r" opsgenie_configs[0] has no api_key');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("PROM210 and PROM208 in global", () => {
|
|
274
|
+
const am = (global: Record<string, unknown>) => JSON.stringify({ global, route: { receiver: "r" }, receivers: [{ name: "r" }] });
|
|
275
|
+
expect(integrationCodes(am({ slack_app_token_file: "/t", slack_api_url: "https://hooks.slack.com/x" }))).toEqual(["PROM210"]);
|
|
276
|
+
expect(integrationCodes(am({ slack_app_token_file: "/t", slack_api_url: SLACK_APP_URL }))).toEqual([]);
|
|
277
|
+
expect(integrationCodes(am({ smtp_auth_password: "x", smtp_auth_password_file: "/p", smtp_smarthost: "smtp" }))).toEqual(["PROM210", "PROM210"]);
|
|
278
|
+
expect(integrationCodes(am({ resolve_timeout: "1.5m" }))).toEqual(["PROM208"]);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
248
282
|
describe("documents from other lexicons", () => {
|
|
249
283
|
test("a k8s manifest and a collector config are not read as ours", () => {
|
|
250
284
|
const ctx = makePostSynthCtx(
|
|
@@ -71,7 +71,7 @@ export function ruleFileDiagnostics(ctx: PostSynthContext, code: PrometheusIssue
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
/** Diagnostics for one Alertmanager code (PROM201, PROM203-
|
|
74
|
+
/** Diagnostics for one Alertmanager code (PROM201, PROM203-PROM210) across every `alertmanager.yml` in the output. */
|
|
75
75
|
export function alertmanagerDiagnostics(ctx: PostSynthContext, code: PrometheusIssueCode): PostSynthDiagnostic[] {
|
|
76
76
|
return prometheusDocs(ctx).alertmanager.flatMap(({ source, config }) =>
|
|
77
77
|
validateAlertmanagerConfig(config)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PROM208: An Alertmanager duration is not a duration
|
|
3
3
|
*
|
|
4
|
-
* group_wait, group_interval, repeat_interval, resolve_timeout and
|
|
4
|
+
* group_wait, group_interval, repeat_interval, resolve_timeout and Jira reopen_duration take Prometheus durations (30s, 4h, 1d); the timeout of webhook, Slack, PagerDuty and incident.io, and Pushover retry, expire and ttl take Go durations (10s, 1m30s, 500ms).
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* PROM209: A receiver integration is missing its destination or credential
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Every one of Alertmanager's 18 integrations is checked for the destination, credential and required fields its config validation asks for, with the global defaults it falls back to: e.g. a webhook url, an Opsgenie api_key (or global.opsgenie_api_key), a Telegram chat_id and bot token, a Webex room_id and authorization, an SNS target, a Jira project and issue_type.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROM210: A receiver integration or global setting is one Alertmanager rejects
|
|
3
|
+
*
|
|
4
|
+
* Two settings that exclude each other (a value and its *_file, a Slack api_url and app_token, Pushover html and monospace, two SNS targets), or a value outside the allowed set (WeChat message_type, Telegram parse_mode, Jira api_type, an Opsgenie responder type, a VictorOps reserved custom field, a smarthost that is not host:port, a Slack or Mattermost field without title and value).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
8
|
+
import { alertmanagerDiagnostics } from "./prom-helpers";
|
|
9
|
+
|
|
10
|
+
export const prom210: PostSynthCheck = {
|
|
11
|
+
id: "PROM210",
|
|
12
|
+
description: "A receiver integration or global setting is one Alertmanager rejects",
|
|
13
|
+
|
|
14
|
+
check(ctx: PostSynthContext): PostSynthDiagnostic[] {
|
|
15
|
+
return alertmanagerDiagnostics(ctx, "PROM210");
|
|
16
|
+
},
|
|
17
|
+
};
|