@intentius/chant-lexicon-prometheus 0.105.0 → 0.106.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.
Files changed (80) hide show
  1. package/README.md +5 -2
  2. package/dist/build.d.ts +17 -0
  3. package/dist/build.d.ts.map +1 -1
  4. package/dist/catalog.d.ts +1 -1
  5. package/dist/catalog.d.ts.map +1 -1
  6. package/dist/composites/slo.d.ts +10 -0
  7. package/dist/composites/slo.d.ts.map +1 -1
  8. package/dist/config-model.d.ts +272 -0
  9. package/dist/config-model.d.ts.map +1 -0
  10. package/dist/detect.d.ts.map +1 -1
  11. package/dist/import/generator.d.ts +3 -1
  12. package/dist/import/generator.d.ts.map +1 -1
  13. package/dist/import/parser.d.ts +12 -2
  14. package/dist/import/parser.d.ts.map +1 -1
  15. package/dist/import/slo.d.ts.map +1 -1
  16. package/dist/index.d.ts +4 -3
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/integrity.json +5 -4
  19. package/dist/lint/audit-catalog.d.ts.map +1 -1
  20. package/dist/lint/audit-lineage.d.ts +2 -0
  21. package/dist/lint/audit-lineage.d.ts.map +1 -1
  22. package/dist/lint/post-synth/index.d.ts.map +1 -1
  23. package/dist/lint/post-synth/prom-helpers.d.ts +7 -0
  24. package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
  25. package/dist/lint/post-synth/prom225.d.ts +10 -0
  26. package/dist/lint/post-synth/prom225.d.ts.map +1 -0
  27. package/dist/manifest.json +1 -1
  28. package/dist/meta.json +10 -0
  29. package/dist/model.d.ts +1 -0
  30. package/dist/model.d.ts.map +1 -1
  31. package/dist/okf/index.md +3 -0
  32. package/dist/okf/rules/PROM225.md +11 -0
  33. package/dist/okf/types/PrometheusConfig.md +9 -0
  34. package/dist/okf/types/ScrapeConfig.md +9 -0
  35. package/dist/plugin.d.ts.map +1 -1
  36. package/dist/prometheus-config.d.ts +39 -0
  37. package/dist/prometheus-config.d.ts.map +1 -0
  38. package/dist/rules/prom-helpers.ts +10 -1
  39. package/dist/rules/prom225.ts +19 -0
  40. package/dist/serializer.d.ts +14 -10
  41. package/dist/serializer.d.ts.map +1 -1
  42. package/dist/tools.d.ts +9 -1
  43. package/dist/tools.d.ts.map +1 -1
  44. package/dist/validate-config.d.ts +1 -1
  45. package/dist/validate-config.d.ts.map +1 -1
  46. package/dist/validate-relabel.d.ts +14 -0
  47. package/dist/validate-relabel.d.ts.map +1 -0
  48. package/dist/validate.d.ts.map +1 -1
  49. package/package.json +2 -2
  50. package/src/build.ts +73 -0
  51. package/src/catalog.ts +14 -1
  52. package/src/composites/composites.test.ts +43 -1
  53. package/src/composites/slo.ts +22 -4
  54. package/src/config-model.ts +327 -0
  55. package/src/detect.ts +5 -4
  56. package/src/generated/lexicon-prometheus.json +10 -0
  57. package/src/import/generator.ts +120 -0
  58. package/src/import/parser.test.ts +2 -2
  59. package/src/import/parser.ts +72 -5
  60. package/src/import/prometheus-roundtrip.test.ts +148 -0
  61. package/src/import/slo.ts +15 -0
  62. package/src/import/testdata/prometheus-full.yml +149 -0
  63. package/src/index.ts +20 -2
  64. package/src/lint/audit-catalog.ts +7 -0
  65. package/src/lint/audit-lineage.test.ts +1 -1
  66. package/src/lint/audit-lineage.ts +2 -0
  67. package/src/lint/post-synth/checks.test.ts +34 -0
  68. package/src/lint/post-synth/index.ts +2 -0
  69. package/src/lint/post-synth/prom-helpers.ts +10 -1
  70. package/src/lint/post-synth/prom225.ts +19 -0
  71. package/src/model.ts +1 -0
  72. package/src/plugin.test.ts +1 -0
  73. package/src/plugin.ts +1 -0
  74. package/src/prometheus-config.test.ts +230 -0
  75. package/src/prometheus-config.ts +71 -0
  76. package/src/serializer.ts +31 -21
  77. package/src/tools.ts +31 -3
  78. package/src/validate-config.ts +2 -1
  79. package/src/validate-relabel.ts +53 -0
  80. package/src/validate.ts +1 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Rule file or `alertmanager.yml` -> `TemplateIR`, for `chant import`.
2
+ * Rule file, `prometheus.yml` or `alertmanager.yml` -> `TemplateIR`, for `chant import`.
3
3
  *
4
4
  * Either file is one document whose parts name each other (a route names a
5
5
  * receiver and time intervals), so the IR carries the whole file as one
@@ -24,7 +24,9 @@ import {
24
24
  ALERTMANAGER_GLOBAL_FIELDS,
25
25
  RECEIVER_INTEGRATIONS,
26
26
  looksLikeAlertmanagerConfig,
27
+ looksLikePrometheusConfig,
27
28
  looksLikeRuleFile,
29
+ PROMETHEUS_CONFIG_SECTIONS,
28
30
  type AlertmanagerConfig,
29
31
  type InhibitRuleConfig,
30
32
  type ReceiverConfig,
@@ -32,8 +34,11 @@ import {
32
34
  type RuleConfig,
33
35
  type RuleFileConfig,
34
36
  type RuleGroupConfig,
37
+ type PrometheusConfigFile,
38
+ type ScrapeJobConfig,
35
39
  type TimeIntervalConfig,
36
40
  } from "../model";
41
+ import { TYPED_SD_KINDS } from "../model";
37
42
  import { PROMETHEUS_PIN } from "../pin";
38
43
 
39
44
  /** The IR resource type for a whole rule file. */
@@ -41,6 +46,14 @@ export const RULE_FILE_RESOURCE_TYPE = "Prometheus::RuleFile";
41
46
  /** The IR resource type for a whole `alertmanager.yml`. */
42
47
  export const ALERTMANAGER_RESOURCE_TYPE = "Prometheus::Alertmanager::Config";
43
48
 
49
+ /** The IR resource type for a whole `prometheus.yml`. */
50
+ export const PROMETHEUS_RESOURCE_TYPE = "Prometheus::Config";
51
+
52
+ /** `properties` of the `Prometheus::Config` resource. */
53
+ export interface PrometheusResourceProperties {
54
+ config: PrometheusConfigFile;
55
+ }
56
+
44
57
  /** `properties` of the `Prometheus::RuleFile` resource. */
45
58
  export interface RuleFileResourceProperties {
46
59
  file: RuleFileConfig;
@@ -54,7 +67,8 @@ export interface AlertmanagerResourceProperties {
54
67
  /** What the parser read. */
55
68
  export type ParsedPrometheusFile =
56
69
  | { kind: "rules"; file: RuleFileConfig; warnings: string[] }
57
- | { kind: "alertmanager"; config: AlertmanagerConfig; warnings: string[] };
70
+ | { kind: "alertmanager"; config: AlertmanagerConfig; warnings: string[] }
71
+ | { kind: "prometheus"; config: PrometheusConfigFile; warnings: string[] };
58
72
 
59
73
  // YAML 1.2 core types plus `<<` merge keys, which Alertmanager configs use to
60
74
  // share notifier settings. The default schema would also turn an unquoted
@@ -324,6 +338,52 @@ function parseAlertmanager(doc: Record<string, unknown>, warnings: string[]): Al
324
338
  return config;
325
339
  }
326
340
 
341
+ // ── prometheus.yml ──────────────────────────────────────────────────
342
+
343
+ function parsePrometheusConfig(doc: Record<string, unknown>, warnings: string[]): PrometheusConfigFile {
344
+ const config: Record<string, unknown> = {};
345
+ for (const k of Object.keys(doc)) {
346
+ if (!PROMETHEUS_CONFIG_SECTIONS.includes(k)) warnings.push(`top-level section "${k}" is not one Prometheus ${PROM_VERSION} defines; it is not carried`);
347
+ }
348
+ const jobNames = new Set<string>();
349
+ for (const key of PROMETHEUS_CONFIG_SECTIONS) {
350
+ const v = doc[key];
351
+ if (v === undefined || v === null) continue;
352
+ if (key === "rule_files" || key === "scrape_config_files") {
353
+ const list = stringList(v, key, warnings);
354
+ if (list) config[key] = list;
355
+ } else if (key === "scrape_configs") {
356
+ if (!Array.isArray(v)) {
357
+ warnings.push("scrape_configs is not a list; it is not carried");
358
+ continue;
359
+ }
360
+ const jobs: ScrapeJobConfig[] = [];
361
+ v.forEach((raw, i) => {
362
+ if (!isPlainObject(raw) || typeof raw.job_name !== "string") {
363
+ warnings.push(`scrape_configs[${i}] has no job_name; it is not carried`);
364
+ return;
365
+ }
366
+ if (jobNames.has(raw.job_name)) warnings.push(`scrape_configs: job_name "${raw.job_name}" is used more than once; Prometheus rejects that`);
367
+ jobNames.add(raw.job_name);
368
+ const sd = Object.keys(raw).filter((k) => k.endsWith("_sd_configs") && !TYPED_SD_KINDS.includes(k));
369
+ if (sd.length > 0) warnings.push(`job "${raw.job_name}": ${sd.join(", ")} ${sd.length === 1 ? "is" : "are"} carried as data, untyped`);
370
+ jobs.push(raw as unknown as ScrapeJobConfig);
371
+ });
372
+ config.scrape_configs = jobs;
373
+ } else if (key === "remote_write" || key === "remote_read") {
374
+ if (Array.isArray(v)) config[key] = v;
375
+ else warnings.push(`${key} is not a list; it is not carried`);
376
+ } else if (isPlainObject(v)) {
377
+ config[key] = v;
378
+ } else {
379
+ warnings.push(`${key} is not a mapping; it is not carried`);
380
+ }
381
+ }
382
+ return config as PrometheusConfigFile;
383
+ }
384
+
385
+ const PROM_VERSION = PROMETHEUS_PIN.prometheus.version;
386
+
327
387
  // ── entry points ────────────────────────────────────────────────────
328
388
 
329
389
  /** Parse a rule file or `alertmanager.yml`, telling them apart by shape. */
@@ -331,16 +391,17 @@ export function parsePrometheusYaml(content: string): ParsedPrometheusFile {
331
391
  const warnings: string[] = [];
332
392
  const doc = content.trim() === "" ? {} : jsYaml.load(content, { schema: YAML_SCHEMA });
333
393
  if (!isPlainObject(doc)) {
334
- throw new Error("a Prometheus rule file or alertmanager.yml is a YAML mapping; this document is not one");
394
+ throw new Error("a Prometheus rule file, alertmanager.yml or prometheus.yml is a YAML mapping; this document is not one");
335
395
  }
336
396
  if (looksLikeRuleFile(doc)) return { kind: "rules", file: parseRuleFile(doc, warnings), warnings };
337
397
  if (looksLikeAlertmanagerConfig(doc)) return { kind: "alertmanager", config: parseAlertmanager(doc, warnings), warnings };
398
+ if (looksLikePrometheusConfig(doc)) return { kind: "prometheus", config: parsePrometheusConfig(doc, warnings), warnings };
338
399
  if ("groups" in doc) {
339
400
  // A rule file with a malformed group still imports; the bad parts are named.
340
401
  return { kind: "rules", file: parseRuleFile(doc, warnings), warnings };
341
402
  }
342
403
  throw new Error(
343
- "this YAML is neither a Prometheus rule file (groups: of named rule lists) nor an alertmanager.yml (route: or receivers:)",
404
+ "this YAML is not a Prometheus rule file (groups: of named rule lists), an alertmanager.yml (route: or receivers:) or a prometheus.yml (scrape_configs: and the like)",
344
405
  );
345
406
  }
346
407
 
@@ -349,7 +410,13 @@ export class PrometheusParser implements TemplateParser {
349
410
  parse(content: string): TemplateIR {
350
411
  const parsed = parsePrometheusYaml(content);
351
412
  const resource =
352
- parsed.kind === "rules"
413
+ parsed.kind === "prometheus"
414
+ ? {
415
+ logicalId: "prometheus",
416
+ type: PROMETHEUS_RESOURCE_TYPE,
417
+ properties: { config: parsed.config } satisfies PrometheusResourceProperties as unknown as Record<string, unknown>,
418
+ }
419
+ : parsed.kind === "rules"
353
420
  ? {
354
421
  logicalId: "ruleFile",
355
422
  type: RULE_FILE_RESOURCE_TYPE,
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `prometheus.yml` through `chant import`: YAML -> TypeScript -> `chant build`
3
+ * -> YAML must give back the same file (key order and quoting aside), the
4
+ * generated source must lint clean, and `promtool check config` must accept
5
+ * both the fixture and what comes back, when `promtool` is on PATH (or
6
+ * `PROMTOOL` names it).
7
+ */
8
+
9
+ import { describe, expect, test } from "vitest";
10
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
11
+ import { join } from "path";
12
+ import { load } from "js-yaml";
13
+ import { build } from "@intentius/chant/build";
14
+ import { lintCommand } from "@intentius/chant/cli/commands/lint";
15
+ import { prometheusSerializer, PROMETHEUS_FILE } from "../serializer";
16
+ import { hasTool, promtoolCheckConfig } from "../tools";
17
+ import { PrometheusParser } from "./parser";
18
+ import { PrometheusGenerator } from "./generator";
19
+ import { pkgDir, read } from "./testdata/fixtures";
20
+ import type { SerializerResult } from "@intentius/chant/serializer";
21
+
22
+ const PROMTOOL = hasTool(process.env.PROMTOOL ?? "promtool");
23
+
24
+ /** The `prometheus.yml` of one build output: the primary output, or the file beside a rule file. */
25
+ function prometheusYml(out: string | SerializerResult | undefined): string {
26
+ if (out === undefined || typeof out === "string") return out ?? "";
27
+ return out.files?.[PROMETHEUS_FILE] ?? out.primary;
28
+ }
29
+
30
+ function projectDir(): string {
31
+ const dir = mkdtempSync(join(pkgDir, ".roundtrip-tmp-"));
32
+ mkdirSync(join(dir, "src"));
33
+ writeFileSync(join(dir, "chant.config.ts"), 'export default { lexicons: ["prometheus"] };\n');
34
+ writeFileSync(join(dir, "package.json"), '{ "name": "prometheus-config-roundtrip", "private": true, "type": "module" }\n');
35
+ return dir;
36
+ }
37
+
38
+ async function importAndBuild(yaml: string) {
39
+ const dir = projectDir();
40
+ try {
41
+ const srcDir = join(dir, "src");
42
+ const ir = new PrometheusParser().parse(yaml);
43
+ const files: string[] = [];
44
+ const sources: string[] = [];
45
+ for (const file of new PrometheusGenerator().generate(ir)) {
46
+ writeFileSync(join(srcDir, file.path), file.content);
47
+ files.push(file.path);
48
+ sources.push(`// ${file.path}\n${file.content}`);
49
+ }
50
+ const result = await build(srcDir, [prometheusSerializer]);
51
+ const lint = await lintCommand({ path: srcDir, format: "stylish" });
52
+ return {
53
+ files,
54
+ source: sources.join("\n"),
55
+ warnings: ir.warnings ?? [],
56
+ yaml: prometheusYml(result.outputs.get("prometheus")),
57
+ buildErrors: result.errors,
58
+ lint: { errorCount: lint.errorCount, warningCount: lint.warningCount, output: lint.output },
59
+ };
60
+ } finally {
61
+ rmSync(dir, { recursive: true, force: true });
62
+ }
63
+ }
64
+
65
+ type Doc = Record<string, unknown> & { scrape_configs?: Array<{ job_name: string }> };
66
+
67
+ /** A parsed prometheus.yml with the scrape jobs sorted by name, the one order the serializer changes. */
68
+ function normalize(yaml: string): Doc {
69
+ const doc = (load(yaml) ?? {}) as Doc;
70
+ if (doc.scrape_configs) doc.scrape_configs = [...doc.scrape_configs].sort((a, b) => a.job_name.localeCompare(b.job_name));
71
+ return doc;
72
+ }
73
+
74
+ /** Files the fixture's rule_files glob and promtool's checks read. */
75
+ const COMPANIONS = { "rules/a.yml": "groups:\n - name: g\n rules:\n - record: a:b\n expr: sum(up)\n" };
76
+
77
+ describe("prometheus.yml: YAML -> TypeScript -> YAML", () => {
78
+ test("a config using every typed section and discovery kind", async () => {
79
+ const fixture = read("prometheus-full.yml");
80
+ const out = await importAndBuild(fixture);
81
+ expect(out.buildErrors).toEqual([]);
82
+ expect(normalize(out.yaml)).toEqual(normalize(fixture));
83
+ if (out.lint.errorCount + out.lint.warningCount > 0) console.log(out.lint.output);
84
+ expect(out.lint.errorCount).toBe(0);
85
+ expect(out.lint.warningCount).toBe(0);
86
+
87
+ expect(out.files).toEqual(["scrape-configs-1.ts", "scrape-configs-2.ts", "prometheus.ts"]);
88
+ expect(out.source).toContain("new ScrapeConfig(");
89
+ expect(out.source).toContain("new PrometheusConfig(");
90
+ expect(out.source).toMatch(/: StaticConfig\[\] = /);
91
+ expect(out.source).toMatch(/: RelabelConfig\[\] = /);
92
+ expect(out.source).toMatch(/: KubernetesSDConfig\[\] = /);
93
+ expect(out.source).toMatch(/: FileSDConfig\[\] = /);
94
+ expect(out.source).toMatch(/: HttpSDConfig\[\] = /);
95
+ expect(out.source).toMatch(/: DnsSDConfig\[\] = /);
96
+ expect(out.source).toMatch(/: Ec2SDConfig\[\] = /);
97
+ expect(out.source).toMatch(/: ConsulSDConfig\[\] = /);
98
+ expect(out.source).toMatch(/: RemoteWriteConfig\[\] = /);
99
+ expect(out.source).toMatch(/: RemoteReadConfig\[\] = /);
100
+ expect(out.source).toMatch(/: PrometheusGlobalConfig = /);
101
+ expect(out.source).toMatch(/: PrometheusAlertingConfig = /);
102
+ expect(out.source).toMatch(/: OtlpConfig = /);
103
+
104
+ // The one discovery kind the lexicon does not type is named, and kept.
105
+ expect(out.source).toContain("docker_sd_configs is not a discovery kind the lexicon types");
106
+ expect(out.warnings).toHaveLength(1);
107
+ expect(out.warnings[0]).toContain("docker_sd_configs");
108
+ });
109
+
110
+ test("the config terragucci emits is a fixed point", async () => {
111
+ const yaml = `global:
112
+ scrape_interval: 5s
113
+ scrape_configs:
114
+ - job_name: otel-collector
115
+ static_configs:
116
+ - targets:
117
+ - otel-collector:8889
118
+ `;
119
+ const out = await importAndBuild(yaml);
120
+ expect(out.buildErrors).toEqual([]);
121
+ expect(out.yaml).toBe(yaml);
122
+ expect(out.warnings).toEqual([]);
123
+ expect(out.lint.errorCount + out.lint.warningCount, out.lint.output).toBe(0);
124
+ expect(out.files).toEqual(["scrape-configs.ts", "prometheus.ts"]);
125
+ });
126
+
127
+ test("scrape jobs only: no PrometheusConfig is declared", async () => {
128
+ const out = await importAndBuild("scrape_configs:\n - job_name: a\n static_configs:\n - targets:\n - a:1\n");
129
+ expect(out.files).toEqual(["scrape-configs.ts"]);
130
+ expect(out.source).not.toContain("PrometheusConfig(");
131
+ });
132
+
133
+ test("a rule file is still a rule file", async () => {
134
+ const ir = new PrometheusParser().parse("groups:\n - name: g\n rules:\n - record: a:b\n expr: sum(up)\n");
135
+ expect(ir.resources[0].type).toBe("Prometheus::RuleFile");
136
+ });
137
+ });
138
+
139
+ describe("promtool check config", () => {
140
+ test.skipIf(!PROMTOOL)("accepts the fixture and the file imported from it", async () => {
141
+ const fixture = read("prometheus-full.yml");
142
+ const before = promtoolCheckConfig(fixture, COMPANIONS);
143
+ expect(before.ok, before.output).toBe(true);
144
+ const out = await importAndBuild(fixture);
145
+ const after = promtoolCheckConfig(out.yaml, COMPANIONS);
146
+ expect(after.ok, after.output).toBe(true);
147
+ }, 60_000);
148
+ });
package/src/import/slo.ts CHANGED
@@ -41,6 +41,21 @@ function same(a: unknown, b: unknown): boolean {
41
41
  /** The SLI expressions a recorded error ratio was built from, with the window put back as `{{window}}`. */
42
42
  function sliFrom(expr: string, window: string): SloProps["sli"] | undefined {
43
43
  const unwindow = (s: string) => s.split(`[${window}]`).join(`[${SLO_WINDOW_PLACEHOLDER}]`);
44
+ // Since #3548 a window with no events records nothing: `((E) or 0 * ((T) > 0)) / ((T) > 0)`, and the good/total form
45
+ // as `1 - (((G) or 0 * ((T) > 0)) / ((T) > 0))`.
46
+ const idle = (head: string, divide: string, tail: string, key: "errors" | "good"): SloProps["sli"] | undefined => {
47
+ if (!expr.startsWith(head) || !expr.endsWith(tail)) return undefined;
48
+ const body = expr.slice(head.length, expr.length - tail.length);
49
+ const orAt = body.indexOf(") or 0 * ((");
50
+ const divAt = body.indexOf(`) > 0))${divide}((`);
51
+ if (orAt === -1 || divAt < orAt) return undefined;
52
+ const total = body.slice(divAt + `) > 0))${divide}((`.length);
53
+ return { [key]: unwindow(body.slice(0, orAt)), total: unwindow(total) } as SloProps["sli"];
54
+ };
55
+ const idleErrors = idle("((", "\n/\n", ") > 0)", "errors");
56
+ if (idleErrors) return idleErrors;
57
+ const idleGood = idle("1 - (\n ((", "\n /\n ", ") > 0)\n)", "good");
58
+ if (idleGood) return idleGood;
44
59
  const goodHead = "1 - (\n (";
45
60
  const goodTail = ")\n)";
46
61
  if (expr.startsWith(goodHead) && expr.endsWith(goodTail)) {
@@ -0,0 +1,149 @@
1
+ # A prometheus.yml using the typed sections and every typed service discovery kind.
2
+ global:
3
+ scrape_interval: 15s
4
+ scrape_timeout: 10s
5
+ evaluation_interval: 30s
6
+ external_labels:
7
+ cluster: prod-eu
8
+ replica: a
9
+ query_log_file: /var/log/prometheus/queries.log
10
+ body_size_limit: 10MB
11
+ sample_limit: 50000
12
+ label_limit: 64
13
+ target_limit: 500
14
+ alerting:
15
+ alert_relabel_configs:
16
+ - regex: replica
17
+ action: labeldrop
18
+ alertmanagers:
19
+ - scheme: http
20
+ path_prefix: /alertmanager
21
+ timeout: 10s
22
+ api_version: v2
23
+ static_configs:
24
+ - targets: ["alertmanager-0:9093", "alertmanager-1:9093"]
25
+ rule_files:
26
+ - rules/*.yml
27
+ scrape_configs:
28
+ - job_name: prometheus
29
+ static_configs:
30
+ - targets: ["localhost:9090"]
31
+ labels:
32
+ tier: control
33
+ - job_name: otel-collector
34
+ scrape_interval: 5s
35
+ scrape_timeout: 4s
36
+ metrics_path: /metrics
37
+ honor_labels: true
38
+ honor_timestamps: false
39
+ params:
40
+ format: [prometheus]
41
+ static_configs:
42
+ - targets: ["otel-collector:8889"]
43
+ metric_relabel_configs:
44
+ - source_labels: [__name__]
45
+ regex: "go_.*"
46
+ action: drop
47
+ - source_labels: [job, instance]
48
+ separator: "/"
49
+ target_label: job_instance
50
+ replacement: "$1"
51
+ action: replace
52
+ - job_name: kubernetes-pods
53
+ scheme: https
54
+ tls_config:
55
+ insecure_skip_verify: true
56
+ authorization:
57
+ type: Bearer
58
+ credentials: sample-token
59
+ kubernetes_sd_configs:
60
+ - role: pod
61
+ api_server: https://kubernetes.default.svc:443
62
+ namespaces:
63
+ names: [default, observability]
64
+ selectors:
65
+ - role: pod
66
+ label: app=api
67
+ attach_metadata:
68
+ node: true
69
+ relabel_configs:
70
+ - source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
71
+ regex: "true"
72
+ action: keep
73
+ - action: labelmap
74
+ regex: __meta_kubernetes_pod_label_(.+)
75
+ - source_labels: [__address__]
76
+ modulus: 4
77
+ target_label: __tmp_hash
78
+ action: hashmod
79
+ - job_name: files
80
+ file_sd_configs:
81
+ - files: [targets/*.json, targets/*.yml]
82
+ refresh_interval: 1m
83
+ - job_name: http-targets
84
+ basic_auth:
85
+ username: scraper
86
+ password: sample-password
87
+ http_sd_configs:
88
+ - url: https://targets.example.com/sd
89
+ refresh_interval: 2m
90
+ - job_name: dns-targets
91
+ dns_sd_configs:
92
+ - names: [api.service.internal]
93
+ type: A
94
+ port: 9100
95
+ refresh_interval: 30s
96
+ - job_name: ec2-nodes
97
+ ec2_sd_configs:
98
+ - region: eu-west-1
99
+ port: 9100
100
+ refresh_interval: 5m
101
+ filters:
102
+ - name: tag:monitoring
103
+ values: ["true"]
104
+ - job_name: consul-services
105
+ bearer_token: sample-bearer
106
+ consul_sd_configs:
107
+ - server: consul.service.internal:8500
108
+ datacenter: dc1
109
+ services: [api, worker]
110
+ tags: [metrics]
111
+ node_meta:
112
+ rack: r1
113
+ refresh_interval: 1m
114
+ - job_name: docker-hosts
115
+ docker_sd_configs:
116
+ - host: unix:///var/run/docker.sock
117
+ remote_write:
118
+ - url: https://mimir.example.com/api/v1/push
119
+ name: mimir
120
+ remote_timeout: 30s
121
+ headers:
122
+ X-Scope-OrgID: tenant-1
123
+ send_exemplars: true
124
+ basic_auth:
125
+ username: writer
126
+ password: sample-password
127
+ write_relabel_configs:
128
+ - source_labels: [__name__]
129
+ regex: "debug_.*"
130
+ action: drop
131
+ queue_config:
132
+ capacity: 10000
133
+ max_shards: 50
134
+ max_samples_per_send: 2000
135
+ batch_send_deadline: 5s
136
+ metadata_config:
137
+ send: true
138
+ send_interval: 1m
139
+ remote_read:
140
+ - url: https://mimir.example.com/prometheus/api/v1/read
141
+ name: mimir-read
142
+ read_recent: true
143
+ required_matchers:
144
+ env: prod
145
+ otlp:
146
+ promote_resource_attributes: [service.name, k8s.namespace.name]
147
+ translation_strategy: UnderscoreEscapingWithSuffixes
148
+ runtime:
149
+ gogc: 75
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  // Plugin and serializer
4
4
  export { prometheusPlugin } from "./plugin";
5
- export { prometheusSerializer, ALERTMANAGER_FILE } from "./serializer";
5
+ export { prometheusSerializer, ALERTMANAGER_FILE, PROMETHEUS_FILE } from "./serializer";
6
6
 
7
7
  // Rule groups
8
8
  export {
@@ -50,16 +50,34 @@ export {
50
50
  type AlertmanagerSettingsEntity,
51
51
  } from "./alertmanager";
52
52
 
53
+ // prometheus.yml
54
+ export {
55
+ PrometheusConfig,
56
+ ScrapeConfig,
57
+ PROMETHEUS_CONFIG_TYPE,
58
+ SCRAPE_CONFIG_TYPE,
59
+ isPrometheusConfig,
60
+ isScrapeConfig,
61
+ isPrometheusConfigEntity,
62
+ type PrometheusConfigProps,
63
+ type PrometheusConfigEntity,
64
+ type ScrapeConfigProps,
65
+ type ScrapeConfigEntity,
66
+ } from "./prometheus-config";
67
+
53
68
  // The plain-data model, building, YAML and checks
54
69
  export * from "./model";
55
70
  export {
56
71
  buildRuleFile,
57
72
  buildAlertmanagerConfig,
73
+ buildPrometheusConfig,
58
74
  ruleFileYaml,
59
75
  alertmanagerYaml,
76
+ prometheusConfigYaml,
60
77
  emitYaml,
61
78
  type BuiltRuleFile,
62
79
  type BuiltAlertmanager,
80
+ type BuiltPrometheusConfig,
63
81
  } from "./build";
64
82
  export { isValidDuration, durationMs, formatDuration } from "./duration";
65
83
  export { parseMatchers, matcherMatches, matcher, type Matcher, type MatchOp, type ParsedMatchers } from "./matchers";
@@ -76,7 +94,7 @@ export {
76
94
  export { PROMETHEUS_PIN } from "./pin";
77
95
 
78
96
  // promtool and amtool, when installed
79
- export { promtoolCheckRules, promtoolTestRules, amtoolCheckConfig, hasTool, type ToolResult } from "./tools";
97
+ export { promtoolCheckRules, promtoolCheckConfig, promtoolTestRules, amtoolCheckConfig, hasTool, type ToolResult } from "./tools";
80
98
 
81
99
  // Composites
82
100
  export {
@@ -220,6 +220,13 @@ export const prometheusAuditCatalog: Record<string, RuleMeta> = {
220
220
  "Inhibit rule matches one alert as source and target, with no equal",
221
221
  "List the labels source and target must share under equal (e.g. alertname, cluster), or make the matchers exclusive.",
222
222
  ),
223
+ PROM225: outputRule(
224
+ "PROM225",
225
+ "merge-worthy",
226
+ "correctness",
227
+ "labeldrop or labelkeep relabel step carries a field it does not take",
228
+ "Remove source_labels, separator, target_label, modulus and replacement from the step; keep only regex (and action).",
229
+ ),
223
230
  PROM301: outputRule(
224
231
  "PROM301",
225
232
  "merge-worthy",
@@ -36,7 +36,7 @@ describe("prometheus audit lineage (#2916, #3136)", () => {
36
36
  });
37
37
 
38
38
  test("rules no upstream tool checks carry no credit", () => {
39
- for (const id of ["PROM001", "PROM003", "PROM202", "PROM207", "PROM216", "PROM219", "PROM220", "PROM221", "PROM222", "PROM223", "PROM224", "PROM301"]) {
39
+ for (const id of ["PROM001", "PROM003", "PROM202", "PROM207", "PROM216", "PROM219", "PROM220", "PROM221", "PROM222", "PROM223", "PROM224", "PROM225", "PROM301"]) {
40
40
  expect(prometheusAuditCatalog[id].lineage, id).toBeUndefined();
41
41
  }
42
42
  });
@@ -46,6 +46,8 @@
46
46
  * - PROM220-PROM224: check-config accepts insecure_skip_verify, SMTP auth
47
47
  * without TLS, credentials over http://, a repeat_interval under
48
48
  * group_interval, and an inhibit rule without equal.
49
+ * - PROM225: promtool check config rejects it, but the check reads a built
50
+ * prometheus.yml, which no tool above does.
49
51
  * - PROM301: no tool here reads a rule file together with the collector
50
52
  * config that produces its metrics.
51
53
  */
@@ -10,6 +10,7 @@ import { build } from "@intentius/chant/build";
10
10
  import { runPostSynthChecks } from "@intentius/chant/lint/post-synth";
11
11
  import { genAiMetrics } from "@intentius/chant-lexicon-otel/genai";
12
12
  import { postSynthChecks } from "./index";
13
+ import { prom225 } from "./prom225";
13
14
  import { OPT_IN_CHECKS } from "../audit-catalog";
14
15
  import { prometheusSerializer } from "../../serializer";
15
16
  import { emitYaml } from "../../build";
@@ -255,3 +256,36 @@ describe("the lexicon's own rules report none of the checks", () => {
255
256
  expect(diags).toEqual([]);
256
257
  });
257
258
  });
259
+
260
+ describe("PROM225 labeldrop and labelkeep fields", () => {
261
+ const prom = (step: Record<string, unknown>) =>
262
+ JSON.stringify({ scrape_configs: [{ job_name: "j", static_configs: [{ targets: ["a:1"] }], metric_relabel_configs: [step] }] });
263
+ const run = (text: string) => prom225.check(makePostSynthCtx("prometheus", text));
264
+
265
+ test("a labeldrop with only regex reports nothing", () => {
266
+ expect(run(prom({ action: "labeldrop", regex: "tmp_.*" }))).toEqual([]);
267
+ expect(run(prom({ action: "replace", source_labels: ["a"], target_label: "b" }))).toEqual([]);
268
+ });
269
+
270
+ test("each foreign field on labeldrop or labelkeep is reported once per step", () => {
271
+ for (const action of ["labeldrop", "labelkeep"]) {
272
+ for (const field of ["source_labels", "separator", "target_label", "modulus", "replacement"]) {
273
+ const diags = run(prom({ action, regex: "x", [field]: field === "modulus" ? 2 : field === "source_labels" ? ["a"] : "v" }));
274
+ expect(diags.map((d) => d.checkId), `${action} ${field}`).toEqual(["PROM225"]);
275
+ expect(diags[0].entity).toBe("scrape_configs[0].metric_relabel_configs[0]");
276
+ expect(diags[0].message).toContain(field);
277
+ }
278
+ }
279
+ });
280
+
281
+ test("steps under remote_write and alerting are read too", () => {
282
+ const text = JSON.stringify({
283
+ alerting: { alert_relabel_configs: [{ action: "labelkeep", regex: "a", replacement: "$1" }] },
284
+ remote_write: [{ url: "http://r", write_relabel_configs: [{ action: "labeldrop", regex: "a", separator: ";" }] }],
285
+ });
286
+ expect(run(text).map((d) => d.entity)).toEqual([
287
+ "alerting.alert_relabel_configs[0]",
288
+ "remote_write[0].write_relabel_configs[0]",
289
+ ]);
290
+ });
291
+ });
@@ -31,6 +31,7 @@ import { prom221 } from "./prom221";
31
31
  import { prom222 } from "./prom222";
32
32
  import { prom223 } from "./prom223";
33
33
  import { prom224 } from "./prom224";
34
+ import { prom225 } from "./prom225";
34
35
  import { prom301 } from "./prom301";
35
36
 
36
37
  export const postSynthChecks: PostSynthCheck[] = [
@@ -65,5 +66,6 @@ export const postSynthChecks: PostSynthCheck[] = [
65
66
  prom222,
66
67
  prom223,
67
68
  prom224,
69
+ prom225,
68
70
  prom301,
69
71
  ];
@@ -14,6 +14,8 @@ import type { PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lin
14
14
  import type { SerializerResult } from "@intentius/chant/serializer";
15
15
  import { loadAll } from "js-yaml";
16
16
  import { buildCollectorConfigs, collectorMetricIssues, collectorMetrics } from "../../collector-metrics";
17
+ import { looksLikePrometheusConfig, type PrometheusConfigFile } from "../../config-model";
18
+ import { validateRelabelFields } from "../../validate-relabel";
17
19
  import { looksLikeAlertmanagerConfig, looksLikeRuleFile, type AlertmanagerConfig, type RuleFileConfig } from "../../model";
18
20
  import {
19
21
  validateAlertmanagerConfig,
@@ -27,11 +29,12 @@ import {
27
29
  export interface FoundDocs {
28
30
  ruleFiles: Array<{ source: string; config: RuleFileConfig }>;
29
31
  alertmanager: Array<{ source: string; config: AlertmanagerConfig }>;
32
+ prometheus: Array<{ source: string; config: PrometheusConfigFile }>;
30
33
  }
31
34
 
32
35
  /** Every rule file and Alertmanager config in the output. Parsed with js-yaml rather than `ctx.docs`, which keeps block scalars and flow lists as strings. */
33
36
  export function prometheusDocs(ctx: PostSynthContext): FoundDocs {
34
- const found: FoundDocs = { ruleFiles: [], alertmanager: [] };
37
+ const found: FoundDocs = { ruleFiles: [], alertmanager: [], prometheus: [] };
35
38
  for (const [lexicon, output] of ctx.outputs) {
36
39
  const texts: Array<[string, string]> =
37
40
  typeof output === "string"
@@ -48,6 +51,7 @@ export function prometheusDocs(ctx: PostSynthContext): FoundDocs {
48
51
  for (const doc of docs) {
49
52
  if (looksLikeRuleFile(doc)) found.ruleFiles.push({ source, config: doc });
50
53
  else if (looksLikeAlertmanagerConfig(doc)) found.alertmanager.push({ source, config: doc });
54
+ else if (looksLikePrometheusConfig(doc)) found.prometheus.push({ source, config: doc });
51
55
  }
52
56
  }
53
57
  }
@@ -82,6 +86,11 @@ export function alertmanagerDiagnostics(ctx: PostSynthContext, code: PrometheusI
82
86
  );
83
87
  }
84
88
 
89
+ /** PROM225 across every `prometheus.yml` in the output. */
90
+ export function relabelDiagnostics(ctx: PostSynthContext): PostSynthDiagnostic[] {
91
+ return prometheusDocs(ctx).prometheus.flatMap(({ source, config }) => validateRelabelFields(config).map((i) => toDiagnostic(i, source)));
92
+ }
93
+
85
94
  /** PROM212 across every rule file in the output. Opt-in: the lexicon's `recommended` lint preset leaves it out (see ../../plugin.ts). */
86
95
  export function runbookDiagnostics(ctx: PostSynthContext): PostSynthDiagnostic[] {
87
96
  return prometheusDocs(ctx).ruleFiles.flatMap(({ source, config }) => validateRunbookUrls(config).map((i) => toDiagnostic(i, source)));
@@ -0,0 +1,19 @@
1
+ /**
2
+ * PROM225: A labeldrop or labelkeep relabel step carries a field it does not take
3
+ *
4
+ * Both actions match regex against label names, so source_labels, separator,
5
+ * target_label, modulus and replacement do nothing, and Prometheus rejects the
6
+ * file at load. Reads every prometheus.yml in the output.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { relabelDiagnostics } from "./prom-helpers";
11
+
12
+ export const prom225: PostSynthCheck = {
13
+ id: "PROM225",
14
+ description: "A labeldrop or labelkeep relabel step carries a field it does not take",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ return relabelDiagnostics(ctx);
18
+ },
19
+ };