@intentius/chant-lexicon-prometheus 0.96.0 → 0.98.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.
Files changed (53) hide show
  1. package/README.md +5 -0
  2. package/dist/alertmanager.d.ts +4 -2
  3. package/dist/alertmanager.d.ts.map +1 -1
  4. package/dist/build.d.ts.map +1 -1
  5. package/dist/codegen/docs.d.ts.map +1 -1
  6. package/dist/import/generator.d.ts +35 -0
  7. package/dist/import/generator.d.ts.map +1 -0
  8. package/dist/import/parser.d.ts +52 -0
  9. package/dist/import/parser.d.ts.map +1 -0
  10. package/dist/import/slo.d.ts +15 -0
  11. package/dist/import/slo.d.ts.map +1 -0
  12. package/dist/integrity.json +7 -7
  13. package/dist/lint/rules/literal-credential.d.ts.map +1 -1
  14. package/dist/lint/rules/prom-ast.d.ts +8 -0
  15. package/dist/lint/rules/prom-ast.d.ts.map +1 -1
  16. package/dist/lint/rules/promql-literal.d.ts.map +1 -1
  17. package/dist/manifest.json +1 -1
  18. package/dist/model.d.ts +21 -0
  19. package/dist/model.d.ts.map +1 -1
  20. package/dist/plugin.d.ts.map +1 -1
  21. package/dist/rules/literal-credential.ts +17 -11
  22. package/dist/rules/prom-ast.ts +36 -0
  23. package/dist/rules/promql-literal.ts +15 -1
  24. package/dist/skills/chant-prometheus-alertmanager.md +4 -0
  25. package/dist/skills/chant-prometheus.md +11 -0
  26. package/package.json +2 -2
  27. package/src/alertmanager.ts +4 -1
  28. package/src/build.ts +2 -0
  29. package/src/codegen/docs.ts +5 -1
  30. package/src/import/cli.test.ts +98 -0
  31. package/src/import/generated-types.e2e.test.ts +75 -0
  32. package/src/import/generator.test.ts +121 -0
  33. package/src/import/generator.ts +570 -0
  34. package/src/import/parser.test.ts +120 -0
  35. package/src/import/parser.ts +348 -0
  36. package/src/import/roundtrip.test.ts +321 -0
  37. package/src/import/slo.ts +175 -0
  38. package/src/import/testdata/alertmanager-full.yml +89 -0
  39. package/src/import/testdata/fixtures.ts +114 -0
  40. package/src/import/testdata/rules-full.yml +42 -0
  41. package/src/import/testdata/upstream/alertmanager-route-labels.yml +65 -0
  42. package/src/import/testdata/upstream/alertmanager-simple.yml +130 -0
  43. package/src/import/testdata/upstream/prometheus-alerting-rules.yml +15 -0
  44. package/src/import/testdata/upstream/prometheus-alerting-templates.yml +23 -0
  45. package/src/import/testdata/upstream/prometheus-recording-rules.yml +7 -0
  46. package/src/lint/rules/literal-credential.ts +17 -11
  47. package/src/lint/rules/prom-ast.ts +36 -0
  48. package/src/lint/rules/promql-literal.ts +15 -1
  49. package/src/lint/rules/rules.test.ts +28 -0
  50. package/src/model.ts +22 -0
  51. package/src/plugin.ts +10 -0
  52. package/src/skills/chant-prometheus-alertmanager.md +4 -0
  53. package/src/skills/chant-prometheus.md +11 -0
@@ -0,0 +1,98 @@
1
+ /**
2
+ * `chant import <file>` end to end through core's import command: detection
3
+ * from a project that lists the prometheus lexicon, `--lexicon prometheus`
4
+ * without one, the files written, and `chant build` on them giving the file
5
+ * back.
6
+ */
7
+
8
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
9
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
10
+ import { join } from "path";
11
+ import { load } from "js-yaml";
12
+ import { build } from "@intentius/chant/build";
13
+ import { importCommand } from "@intentius/chant/cli/commands/import";
14
+ import { prometheusSerializer } from "../serializer";
15
+ import { builtFiles, exampleOutputs, pkgDir, read } from "./testdata/fixtures";
16
+
17
+ let dir: string;
18
+
19
+ beforeEach(() => {
20
+ dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-"));
21
+ });
22
+
23
+ afterEach(() => {
24
+ rmSync(dir, { recursive: true, force: true });
25
+ });
26
+
27
+ function write(name: string, content: string): string {
28
+ const path = join(dir, name);
29
+ writeFileSync(path, content);
30
+ return path;
31
+ }
32
+
33
+ async function rebuilt(srcDir: string): Promise<{ rules?: string; alertmanager?: string }> {
34
+ const result = await build(srcDir, [prometheusSerializer]);
35
+ expect(result.errors).toEqual([]);
36
+ return builtFiles(result.outputs.get("prometheus"));
37
+ }
38
+
39
+ describe("chant import", () => {
40
+ test("detects a rule file in a project that lists prometheus", async () => {
41
+ writeFileSync(join(dir, "chant.config.json"), JSON.stringify({ lexicons: ["prometheus"] }));
42
+ const templatePath = write("rules.yml", read("rules-full.yml"));
43
+ const output = join(dir, "src");
44
+
45
+ const result = await importCommand({ templatePath, output });
46
+
47
+ expect(result.error).toBeUndefined();
48
+ expect(result.success).toBe(true);
49
+ expect(result.lexicon).toBe("prometheus");
50
+ expect(result.generatedFiles).toEqual(["rules.ts"]);
51
+ expect(readFileSync(join(output, "rules.ts"), "utf-8")).toContain("new RuleGroup(");
52
+ const { rules } = await rebuilt(output);
53
+ expect(load(rules!)).toEqual(expect.objectContaining({ groups: expect.any(Array) }));
54
+ }, 30_000);
55
+
56
+ test("detects an alertmanager.yml in a project that lists prometheus", async () => {
57
+ writeFileSync(join(dir, "chant.config.json"), JSON.stringify({ lexicons: ["prometheus"] }));
58
+ const am = (await exampleOutputs()).find((o) => o.name === "alerting/alertmanager.yml")!;
59
+ const templatePath = write("alertmanager.yml", am.yaml);
60
+ const output = join(dir, "src");
61
+
62
+ const result = await importCommand({ templatePath, output });
63
+
64
+ expect(result.error).toBeUndefined();
65
+ expect(result.lexicon).toBe("prometheus");
66
+ expect(result.generatedFiles).toEqual(["receivers.ts", "time-intervals.ts", "routes.ts", "inhibit-rules.ts", "settings.ts"]);
67
+ expect((await rebuilt(output)).alertmanager).toBe(am.yaml);
68
+ }, 30_000);
69
+
70
+ test("--lexicon prometheus imports without a project, and prints what it rewrote", async () => {
71
+ const templatePath = write("alertmanager.yml", read("alertmanager-full.yml"));
72
+ const output = join(dir, "src");
73
+
74
+ // Without a project, detection tries every installed lexicon (#2965).
75
+ const detected = await importCommand({ templatePath, output: join(dir, "detected") });
76
+ expect(detected.error).toBeUndefined();
77
+ expect(detected.lexicon).toBe("prometheus");
78
+ expect(detected.detected).toBe(true);
79
+
80
+ const result = await importCommand({ templatePath, output, lexicon: "prometheus" });
81
+
82
+ expect(result.error).toBeUndefined();
83
+ expect(result.success).toBe(true);
84
+ expect(result.lexicon).toBe("prometheus");
85
+ expect(result.warnings.some((w) => w.includes("match/match_re are written as matchers"))).toBe(true);
86
+ const { alertmanager } = await rebuilt(output);
87
+ expect(alertmanager).toContain('- severity="page"');
88
+ expect(alertmanager).toContain("name: weekends");
89
+ }, 30_000);
90
+
91
+ test("--lexicon prometheus on a file that is neither names both shapes", async () => {
92
+ const templatePath = write("collector.yaml", "receivers:\n otlp: {}\nservice:\n pipelines: {}\n");
93
+ const result = await importCommand({ templatePath, output: join(dir, "src"), lexicon: "prometheus" });
94
+ expect(result.success).toBe(false);
95
+ expect(result.error).toContain("rule file");
96
+ expect(result.error).toContain("alertmanager.yml");
97
+ });
98
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The generated source type-checks against the lexicon's types. Compiling
3
+ * the generated projects takes longer than the unit-test budget allows, so
4
+ * this runs with the e2e tests.
5
+ */
6
+ import { describe, expect, test } from "vitest";
7
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
8
+ import { join } from "path";
9
+ import * as ts from "typescript";
10
+ import { PrometheusParser } from "./parser";
11
+ import { PrometheusGenerator } from "./generator";
12
+ import { exampleOutputs, pkgDir, read, repoRoot, sloOutputs, UPSTREAM } from "./testdata/fixtures";
13
+
14
+ type Files = Array<{ path: string; content: string }>;
15
+
16
+ /** Type errors in generated projects, compiled together against the lexicon's source with the repo's compiler options. */
17
+ function typeErrors(projects: Record<string, Files>): string[] {
18
+ const dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-"));
19
+ try {
20
+ const files: string[] = [];
21
+ for (const [name, generated] of Object.entries(projects)) {
22
+ const sub = join(dir, name.replace(/[^A-Za-z0-9]+/g, "-"));
23
+ mkdirSync(sub);
24
+ for (const f of generated) {
25
+ writeFileSync(join(sub, f.path), f.content);
26
+ files.push(join(sub, f.path));
27
+ }
28
+ }
29
+ const configFile = ts.readConfigFile(join(repoRoot, "tsconfig.json"), ts.sys.readFile);
30
+ const parsed = ts.parseJsonConfigFileContent(configFile.config, ts.sys, repoRoot);
31
+ const program = ts.createProgram(files, { ...parsed.options, noEmit: true });
32
+ const real = (f: string) => ts.sys.realpath?.(f) ?? f;
33
+ const fileSet = new Set(files.map(real));
34
+ const diagnostics = ts.getPreEmitDiagnostics(program).filter((d) => d.file && fileSet.has(real(d.file.fileName)));
35
+ // A program that saw none of the files would pass vacuously.
36
+ expect(files.every((f) => program.getSourceFile(f) !== undefined)).toBe(true);
37
+ return diagnostics.map((d) => {
38
+ const at = real(d.file!.fileName).slice(real(dir).length + 1);
39
+ return `${at}: ${ts.flattenDiagnosticMessageText(d.messageText, "\n")}`;
40
+ });
41
+ } finally {
42
+ rmSync(dir, { recursive: true, force: true });
43
+ }
44
+ }
45
+
46
+ const generate = (yaml: string): Files => new PrometheusGenerator().generate(new PrometheusParser().parse(yaml));
47
+
48
+ describe("the generated source type-checks against the lexicon's types", () => {
49
+ test("for every fixture", async () => {
50
+ const projects: Record<string, Files> = {
51
+ rulesFull: generate(read("rules-full.yml")),
52
+ alertmanagerFull: generate(read("alertmanager-full.yml")),
53
+ ...Object.fromEntries(UPSTREAM.map((f) => [f, generate(read("upstream", f))])),
54
+ ...Object.fromEntries(sloOutputs().map((o) => [o.name, generate(o.yaml)])),
55
+ ...Object.fromEntries((await exampleOutputs()).map((o) => [o.name, generate(o.yaml)])),
56
+ };
57
+ expect(typeErrors(projects)).toEqual([]);
58
+ }, 120_000);
59
+
60
+ test("a field outside a notifier's typed config is carried as found, and tsc points at it", () => {
61
+ const yaml = [
62
+ "route:",
63
+ " receiver: chat",
64
+ "receivers:",
65
+ " - name: chat",
66
+ " slack_configs:",
67
+ " - channel: '#alerts'",
68
+ " api_url_file: /etc/alertmanager/slack-url",
69
+ " message_text: '{{ .CommonLabels.alertname }}'",
70
+ "",
71
+ ].join("\n");
72
+ const errors = typeErrors({ slack: generate(yaml) });
73
+ expect(errors).toEqual([expect.stringMatching(/^slack\/receivers\.ts: .*'message_text' does not exist in type 'SlackConfig'/)]);
74
+ }, 120_000);
75
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import type { AlertmanagerConfig, RuleGroupConfig } from "../model";
3
+ import { generateAlertmanagerFiles, generateRuleFileFiles, PrometheusGenerator, tsLiteral } from "./generator";
4
+
5
+ const byPath = (files: Array<{ path: string; content: string }>) => Object.fromEntries(files.map((f) => [f.path, f.content]));
6
+
7
+ describe("rule files", () => {
8
+ test("a group is a Rule[] const of plain objects and a RuleGroup, exported by list", () => {
9
+ const files = byPath(
10
+ generateRuleFileFiles({
11
+ groups: [{ name: "api-slo", interval: "30s", labels: { team: "api" }, rules: [{ record: "a:b", expr: "sum(x)" }] }],
12
+ }),
13
+ );
14
+ expect(files["rules.ts"]).toBe(
15
+ [
16
+ "/** Rule groups */",
17
+ 'import { type LabelSet, type Rule, RuleGroup } from "@intentius/chant-lexicon-prometheus";',
18
+ "",
19
+ 'const apiSloLabels: LabelSet = { team: "api" };',
20
+ 'const apiSloRules: Rule[] = [{ record: "a:b", expr: "sum(x)" }];',
21
+ "const apiSlo = new RuleGroup({",
22
+ ' name: "api-slo",',
23
+ ' interval: "30s",',
24
+ " labels: apiSloLabels,",
25
+ " rules: apiSloRules,",
26
+ "});",
27
+ "",
28
+ "export { apiSlo };",
29
+ "",
30
+ ].join("\n"),
31
+ );
32
+ });
33
+
34
+ test("more than eight groups are split across files, the limit COR009 sets", () => {
35
+ const groups: RuleGroupConfig[] = Array.from({ length: 10 }, (_, i) => ({ name: `g${i}`, rules: [] }));
36
+ const files = generateRuleFileFiles({ groups });
37
+ expect(files.map((f) => f.path)).toEqual(["rules-1.ts", "rules-2.ts"]);
38
+ expect(files[0].content).toContain("export { g0, g1, g2, g3, g4, g5, g6, g7 };");
39
+ expect(files[1].content).toContain("export { g8, g9 };");
40
+ });
41
+
42
+ test("names that clash or are not identifiers get a suffix", () => {
43
+ const files = byPath(
44
+ generateRuleFileFiles({
45
+ groups: [
46
+ { name: "default", rules: [] },
47
+ { name: "2xx", rules: [] },
48
+ { name: "Slo", rules: [] },
49
+ { name: "a.b", rules: [] },
50
+ { name: "a-b", rules: [] },
51
+ ],
52
+ }),
53
+ );
54
+ expect(files["rules.ts"]).toContain("export { defaultGroup, group2xx, slo, aB, aBGroup };");
55
+ });
56
+ });
57
+
58
+ describe("alertmanager.yml", () => {
59
+ const config: AlertmanagerConfig = {
60
+ route: {
61
+ receiver: "fallback",
62
+ routes: [
63
+ { receiver: "pager", matchers: ['severity="page"'], mute_time_intervals: ["nights", "undeclared"] },
64
+ { receiver: "elsewhere" },
65
+ ],
66
+ },
67
+ receivers: [
68
+ { name: "fallback" },
69
+ { name: "pager", pagerduty_configs: [{ routing_key_file: "/k" }] },
70
+ ],
71
+ time_intervals: [{ name: "nights", time_intervals: [{ times: [{ start_time: "00:00", end_time: "06:00" }] }] }],
72
+ };
73
+
74
+ test("routes reference receivers and time intervals by variable; names nothing declares stay strings", () => {
75
+ const files = byPath(generateAlertmanagerFiles(config));
76
+ expect(Object.keys(files)).toEqual(["receivers.ts", "time-intervals.ts", "routes.ts"]);
77
+ expect(files["routes.ts"]).toContain('import { fallback, pager } from "./receivers";');
78
+ expect(files["routes.ts"]).toContain('import { nights } from "./time-intervals";');
79
+ expect(files["routes.ts"]).toContain('mute_time_intervals: [nights, "undeclared"]');
80
+ expect(files["routes.ts"]).toContain('{ receiver: "elsewhere" }');
81
+ expect(files["routes.ts"]).toContain("const root = new Route({ receiver: fallback, routes: rootChildren });");
82
+ });
83
+
84
+ test("an integration the lexicon does not type is spread in from an untyped const, with a comment", () => {
85
+ const files = byPath(
86
+ generateAlertmanagerFiles({ receivers: [{ name: "ops", opsgenie_configs: [{ api_key_file: "/k" }] } as never] }),
87
+ );
88
+ expect(files["receivers.ts"]).toContain(
89
+ [
90
+ '// Receiver "ops": opsgenie_configs is not typed by this lexicon, so it is carried as data.',
91
+ 'const opsUntyped = { opsgenie_configs: [{ api_key_file: "/k" }] };',
92
+ 'const ops = new Receiver({ name: "ops", ...opsUntyped });',
93
+ ].join("\n"),
94
+ );
95
+ });
96
+ });
97
+
98
+ describe("tsLiteral", () => {
99
+ test("multi-line strings are template literals with backticks, backslashes and ${ escaped", () => {
100
+ expect(tsLiteral("a\n`b` \\d ${x}", 0)).toBe("`a\n\\`b\\` \\\\d \\${x}`");
101
+ });
102
+
103
+ test("a string with double quotes is single-quoted, and keys that are not identifiers are quoted", () => {
104
+ expect(tsLiteral({ "a-b": 'x="1"', continue: true }, 0)).toBe(`{ "a-b": 'x="1"', continue: true }`);
105
+ });
106
+ });
107
+
108
+ test("PrometheusGenerator never hands core an empty file list", () => {
109
+ expect(new PrometheusGenerator().generate({ resources: [], parameters: [] })).toHaveLength(1);
110
+ });
111
+
112
+ test("an Slo's variable leaves its member's name, <name>Rules, free", async () => {
113
+ const { Slo } = await import("../composites/slo");
114
+ const { ruleGroupConfig } = await import("../rules");
115
+ const slo = ruleGroupConfig(
116
+ Slo({ name: "checkout", objective: 0.99, window: "30d", sli: { errors: "sum(rate(e[{{window}}]))", total: "sum(rate(t[{{window}}]))" } }).rules,
117
+ );
118
+ const files = byPath(generateRuleFileFiles({ groups: [{ name: "checkout-rules", rules: [] }, slo] }));
119
+ expect(files["slos.ts"]).toContain("const checkout = Slo({");
120
+ expect(files["rules.ts"]).toContain("const checkoutRulesGroup = new RuleGroup(");
121
+ });