@intentius/chant-lexicon-prometheus 0.105.1 → 0.106.3

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 (91) 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/index.d.ts +1 -1
  7. package/dist/composites/index.d.ts.map +1 -1
  8. package/dist/composites/slo.d.ts +23 -0
  9. package/dist/composites/slo.d.ts.map +1 -1
  10. package/dist/config-model.d.ts +272 -0
  11. package/dist/config-model.d.ts.map +1 -0
  12. package/dist/detect.d.ts.map +1 -1
  13. package/dist/import/embedded.d.ts +7 -0
  14. package/dist/import/embedded.d.ts.map +1 -1
  15. package/dist/import/generator.d.ts +17 -1
  16. package/dist/import/generator.d.ts.map +1 -1
  17. package/dist/import/parser.d.ts +12 -2
  18. package/dist/import/parser.d.ts.map +1 -1
  19. package/dist/import/slo.d.ts.map +1 -1
  20. package/dist/index.d.ts +5 -4
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/integrity.json +5 -4
  23. package/dist/lint/audit-catalog.d.ts.map +1 -1
  24. package/dist/lint/audit-lineage.d.ts +2 -0
  25. package/dist/lint/audit-lineage.d.ts.map +1 -1
  26. package/dist/lint/post-synth/index.d.ts.map +1 -1
  27. package/dist/lint/post-synth/prom-helpers.d.ts +7 -0
  28. package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
  29. package/dist/lint/post-synth/prom225.d.ts +10 -0
  30. package/dist/lint/post-synth/prom225.d.ts.map +1 -0
  31. package/dist/manifest.json +1 -1
  32. package/dist/meta.json +10 -0
  33. package/dist/model.d.ts +1 -0
  34. package/dist/model.d.ts.map +1 -1
  35. package/dist/okf/index.md +3 -0
  36. package/dist/okf/rules/PROM225.md +11 -0
  37. package/dist/okf/types/PrometheusConfig.md +9 -0
  38. package/dist/okf/types/ScrapeConfig.md +9 -0
  39. package/dist/plugin.d.ts.map +1 -1
  40. package/dist/prometheus-config.d.ts +39 -0
  41. package/dist/prometheus-config.d.ts.map +1 -0
  42. package/dist/rules/prom-helpers.ts +10 -1
  43. package/dist/rules/prom225.ts +19 -0
  44. package/dist/serializer.d.ts +14 -10
  45. package/dist/serializer.d.ts.map +1 -1
  46. package/dist/tools.d.ts +9 -1
  47. package/dist/tools.d.ts.map +1 -1
  48. package/dist/validate-config.d.ts +1 -1
  49. package/dist/validate-config.d.ts.map +1 -1
  50. package/dist/validate-relabel.d.ts +14 -0
  51. package/dist/validate-relabel.d.ts.map +1 -0
  52. package/dist/validate.d.ts +2 -1
  53. package/dist/validate.d.ts.map +1 -1
  54. package/package.json +2 -2
  55. package/src/build.ts +73 -0
  56. package/src/catalog.ts +14 -1
  57. package/src/composites/composites.test.ts +72 -2
  58. package/src/composites/index.ts +1 -0
  59. package/src/composites/slo.ts +43 -4
  60. package/src/config-model.ts +327 -0
  61. package/src/detect.ts +5 -4
  62. package/src/generated/lexicon-prometheus.json +10 -0
  63. package/src/import/embedded.test.ts +46 -2
  64. package/src/import/embedded.ts +32 -1
  65. package/src/import/generator.ts +138 -0
  66. package/src/import/parser.test.ts +2 -2
  67. package/src/import/parser.ts +72 -5
  68. package/src/import/prometheus-roundtrip.test.ts +148 -0
  69. package/src/import/slo.ts +15 -0
  70. package/src/import/testdata/prometheus-full.yml +149 -0
  71. package/src/import/testdata/schemastore-prometheus.json +1635 -0
  72. package/src/index.ts +21 -2
  73. package/src/lint/audit-catalog.ts +7 -0
  74. package/src/lint/audit-lineage.test.ts +1 -1
  75. package/src/lint/audit-lineage.ts +2 -0
  76. package/src/lint/post-synth/checks.test.ts +34 -0
  77. package/src/lint/post-synth/index.ts +2 -0
  78. package/src/lint/post-synth/prom-helpers.ts +10 -1
  79. package/src/lint/post-synth/prom225.ts +19 -0
  80. package/src/model.ts +1 -0
  81. package/src/plugin.test.ts +1 -0
  82. package/src/plugin.ts +3 -2
  83. package/src/prometheus-config.test.ts +230 -0
  84. package/src/prometheus-config.ts +71 -0
  85. package/src/schemastore.test.ts +142 -0
  86. package/src/serializer.ts +31 -21
  87. package/src/tools.ts +31 -3
  88. package/src/validate-config.ts +2 -1
  89. package/src/validate-relabel.ts +53 -0
  90. package/src/validate.test.ts +17 -0
  91. package/src/validate.ts +27 -3
@@ -16,12 +16,19 @@
16
16
  * ConfigMap then holds the config as the serializer writes it: the same
17
17
  * config, with Alertmanager's deprecated spellings rewritten as the
18
18
  * standalone import rewrites them.
19
+ *
20
+ * A `prometheus.yml` held as text in a ConfigMap is imported exactly as
21
+ * `chant import prometheus.yml` would import it, and becomes
22
+ * `prometheusConfigYaml([...])` over every scrape job and the sections
23
+ * besides them. The ConfigMap then holds the config as the serializer
24
+ * writes it: scrape jobs sorted by name, sections in Prometheus's order.
19
25
  */
20
26
 
21
27
  import type { EmbeddedContentImporter, EmbeddedImport } from "@intentius/chant/import/embedded";
22
28
  import { looksLikeAlertmanagerConfig, looksLikeRuleFile } from "../model";
29
+ import { looksLikePrometheusConfig } from "../config-model";
23
30
  import { parsePrometheusYaml } from "./parser";
24
- import { generateAlertmanager, generateRuleFile } from "./generator";
31
+ import { generateAlertmanager, generatePrometheusConfig, generateRuleFile } from "./generator";
25
32
 
26
33
  const PACKAGE = "@intentius/chant-lexicon-prometheus";
27
34
 
@@ -74,3 +81,27 @@ export const alertmanagerImporter: EmbeddedContentImporter = {
74
81
  };
75
82
  },
76
83
  };
84
+
85
+ export const prometheusConfigImporter: EmbeddedContentImporter = {
86
+ what: "a prometheus.yml",
87
+
88
+ matches(content) {
89
+ return content.select === undefined && typeof content.text === "string" && looksLikePrometheusConfig(content.document);
90
+ },
91
+
92
+ import(content): EmbeddedImport {
93
+ const parsed = parsePrometheusYaml(content.text!);
94
+ if (parsed.kind !== "prometheus") throw new Error("this is not a prometheus.yml");
95
+ const { files, declarations } = generatePrometheusConfig(parsed.config);
96
+ if (declarations.length === 0) throw new Error("the import declared nothing");
97
+ return {
98
+ files,
99
+ value: {
100
+ bindings: declarations.map((d) => ({ from: d.path, name: d.name })),
101
+ shape: "list",
102
+ through: { from: PACKAGE, name: "prometheusConfigYaml" },
103
+ },
104
+ warnings: parsed.warnings,
105
+ };
106
+ },
107
+ };
@@ -24,6 +24,8 @@ import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import
24
24
  import type { TemplateIR } from "@intentius/chant/import/parser";
25
25
  import type {
26
26
  AlertmanagerConfig,
27
+ PrometheusConfigFile,
28
+ ScrapeJobConfig,
27
29
  InhibitRuleConfig,
28
30
  ReceiverConfig,
29
31
  RouteConfig,
@@ -35,8 +37,10 @@ import { ALERTMANAGER_GLOBAL_FIELDS, RECEIVER_INTEGRATION_TYPES } from "../model
35
37
  import { PROMETHEUS_PIN } from "../pin";
36
38
  import {
37
39
  ALERTMANAGER_RESOURCE_TYPE,
40
+ PROMETHEUS_RESOURCE_TYPE,
38
41
  RULE_FILE_RESOURCE_TYPE,
39
42
  type AlertmanagerResourceProperties,
43
+ type PrometheusResourceProperties,
40
44
  type RuleFileResourceProperties,
41
45
  } from "./parser";
42
46
  import { recognizeSlo } from "./slo";
@@ -259,6 +263,22 @@ const LEXICON_NAMES = [
259
263
  "TimeInterval",
260
264
  "TimePeriodConfig",
261
265
  "InhibitRule",
266
+ "ScrapeConfig",
267
+ "ScrapeConfigProps",
268
+ "PrometheusConfig",
269
+ "StaticConfig",
270
+ "RelabelConfig",
271
+ "PrometheusGlobalConfig",
272
+ "PrometheusAlertingConfig",
273
+ "RemoteWriteConfig",
274
+ "RemoteReadConfig",
275
+ "OtlpConfig",
276
+ "KubernetesSDConfig",
277
+ "FileSDConfig",
278
+ "HttpSDConfig",
279
+ "DnsSDConfig",
280
+ "Ec2SDConfig",
281
+ "ConsulSDConfig",
262
282
  "AlertmanagerSettings",
263
283
  "AlertmanagerGlobalConfig",
264
284
  "AlertmanagerTracingConfig",
@@ -579,6 +599,122 @@ export function generateAlertmanager(config: AlertmanagerConfig): { files: Gener
579
599
  };
580
600
  }
581
601
 
602
+ // ── prometheus.yml ───────────────────────────────────────────────────
603
+
604
+ /** The type each hoisted scrape config field is declared as; others use `ScrapeConfigProps["field"]`. */
605
+ const SCRAPE_FIELD_TYPES: Record<string, string> = {
606
+ static_configs: "StaticConfig[]",
607
+ relabel_configs: "RelabelConfig[]",
608
+ metric_relabel_configs: "RelabelConfig[]",
609
+ params: "Record<string, string[]>",
610
+ kubernetes_sd_configs: "KubernetesSDConfig[]",
611
+ file_sd_configs: "FileSDConfig[]",
612
+ http_sd_configs: "HttpSDConfig[]",
613
+ dns_sd_configs: "DnsSDConfig[]",
614
+ ec2_sd_configs: "Ec2SDConfig[]",
615
+ consul_sd_configs: "ConsulSDConfig[]",
616
+ };
617
+
618
+ /** The type each top-level section is declared as. */
619
+ const SECTION_TYPES: Record<string, string> = {
620
+ global: "PrometheusGlobalConfig",
621
+ alerting: "PrometheusAlertingConfig",
622
+ remote_write: "RemoteWriteConfig[]",
623
+ remote_read: "RemoteReadConfig[]",
624
+ otlp: "OtlpConfig",
625
+ storage: "Record<string, unknown>",
626
+ tracing: "Record<string, unknown>",
627
+ runtime: "Record<string, unknown>",
628
+ };
629
+
630
+ /** Generate the TypeScript declaring one `prometheus.yml`. */
631
+ export function generatePrometheusConfigFiles(config: PrometheusConfigFile): GeneratedFile[] {
632
+ return generatePrometheusConfig(config).files;
633
+ }
634
+
635
+ /** A declaration a `prometheus.yml` import exports: its module and variable. */
636
+ export interface PrometheusConfigDeclaration {
637
+ readonly path: string;
638
+ readonly name: string;
639
+ }
640
+
641
+ /**
642
+ * Generate the TypeScript declaring one `prometheus.yml`, and list every
643
+ * declaration it exports, module by module (a ConfigMap holding the file
644
+ * becomes `prometheusConfigYaml([...])` over them).
645
+ */
646
+ export function generatePrometheusConfig(config: PrometheusConfigFile): { files: GeneratedFile[]; declarations: PrometheusConfigDeclaration[] } {
647
+ const names = new Names([...LEXICON_NAMES, ...Object.keys(SECTION_TYPES)]);
648
+ const modules: Module[] = [];
649
+
650
+ for (const [mod, items] of chunk(config.scrape_configs ?? [], "scrape-configs", "Scrape jobs: where Prometheus pulls metrics from")) {
651
+ modules.push(mod);
652
+ mod.use("ScrapeConfig");
653
+ for (const job of items as ScrapeJobConfig[]) {
654
+ const v = names.claim(job.job_name, "Scrape", "scrape");
655
+ const lines: string[] = [];
656
+ const entries: Array<[string, string]> = [["job_name", stringLiteral(job.job_name)]];
657
+ for (const [key, value] of Object.entries(job)) {
658
+ if (key === "job_name" || value === undefined) continue;
659
+ if (!needsHoist(value)) {
660
+ entries.push([key, tsLiteral(value, 2, 4 + key.length)]);
661
+ continue;
662
+ }
663
+ const known = SCRAPE_FIELD_TYPES[key];
664
+ const type = known ?? (key.endsWith("_sd_configs") ? undefined : `ScrapeConfigProps["${key}"]`);
665
+ const c = names.claim(`${v} ${key.replace(/_configs$/, "")}`, "Config", "scrape");
666
+ if (type) {
667
+ if (type.startsWith("ScrapeConfigProps")) mod.types.add("ScrapeConfigProps");
668
+ else if (!type.startsWith("Record<")) mod.types.add(type.replace(/\[\]$/, ""));
669
+ const head = `const ${c}: ${type} = `;
670
+ lines.push(`${head}${tsLiteral(value, 0, head.length)};`);
671
+ } else {
672
+ lines.push(`// ${key} is not a discovery kind the lexicon types, so it is carried as data, untyped.`);
673
+ const head = `const ${c} = `;
674
+ lines.push(`${head}${tsLiteral(value, 0, head.length)};`);
675
+ }
676
+ entries.push([key, c]);
677
+ }
678
+ const ctor = `const ${v} = new ScrapeConfig(`;
679
+ lines.push(`${ctor}${objectOf(entries, 0, ctor.length)});`);
680
+ mod.block(lines);
681
+ mod.exports.push(v);
682
+ }
683
+ }
684
+
685
+ const sectionKeys = Object.keys(config).filter((k) => k !== "scrape_configs");
686
+ if (sectionKeys.length > 0) {
687
+ const mod = new Module("prometheus.ts", "The prometheus.yml sections besides the scrape jobs");
688
+ modules.push(mod);
689
+ mod.use("PrometheusConfig");
690
+ const lines: string[] = [];
691
+ const entries: Array<[string, string]> = [];
692
+ for (const key of sectionKeys) {
693
+ const value = (config as Record<string, unknown>)[key];
694
+ const type = SECTION_TYPES[key];
695
+ if (!type || !needsHoist(value)) {
696
+ entries.push([key, tsLiteral(value, 2, 4 + key.length)]);
697
+ continue;
698
+ }
699
+ if (!type.startsWith("Record<")) mod.types.add(type.replace(/\[\]$/, ""));
700
+ const c = names.claim(key.replace(/_/g, " "), "Config", "prometheus");
701
+ const head = `const ${c}: ${type} = `;
702
+ lines.push(`${head}${tsLiteral(value, 0, head.length)};`);
703
+ entries.push([key, c]);
704
+ }
705
+ const v = names.claim("prometheus", "Settings", "prometheus");
706
+ const ctor = `const ${v} = new PrometheusConfig(`;
707
+ lines.push(`${ctor}${objectOf(entries, 0, ctor.length)});`);
708
+ mod.block(lines);
709
+ mod.exports.push(v);
710
+ }
711
+
712
+ return {
713
+ files: modules.map((m) => ({ path: m.path, content: m.render() })),
714
+ declarations: modules.flatMap((m) => m.exports.map((name) => ({ path: m.path, name }))),
715
+ };
716
+ }
717
+
582
718
  /** The rule file and `alertmanager.yml` TypeScript generator `chant import` runs. */
583
719
  export class PrometheusGenerator implements TypeScriptGenerator {
584
720
  generate(ir: TemplateIR): GeneratedFile[] {
@@ -586,6 +722,8 @@ export class PrometheusGenerator implements TypeScriptGenerator {
586
722
  for (const r of ir.resources) {
587
723
  if (r.type === RULE_FILE_RESOURCE_TYPE) {
588
724
  files.push(...generateRuleFileFiles((r.properties as unknown as RuleFileResourceProperties).file));
725
+ } else if (r.type === PROMETHEUS_RESOURCE_TYPE) {
726
+ files.push(...generatePrometheusConfigFiles((r.properties as unknown as PrometheusResourceProperties).config));
589
727
  } else if (r.type === ALERTMANAGER_RESOURCE_TYPE) {
590
728
  files.push(...generateAlertmanagerFiles((r.properties as unknown as AlertmanagerResourceProperties).config));
591
729
  }
@@ -11,9 +11,9 @@ describe("parsePrometheusYaml", () => {
11
11
  });
12
12
 
13
13
  test("rejects a document that is neither, and one that is not a mapping", () => {
14
- expect(() => parsePrometheusYaml("receivers:\n otlp: {}\n")).toThrow(/neither a Prometheus rule file/);
14
+ expect(() => parsePrometheusYaml("receivers:\n otlp: {}\n")).toThrow(/is not a Prometheus rule file .*an alertmanager\.yml .*or a prometheus\.yml/);
15
15
  expect(() => parsePrometheusYaml("- a\n- b\n")).toThrow(/YAML mapping/);
16
- expect(() => parsePrometheusYaml("")).toThrow(/neither/);
16
+ expect(() => parsePrometheusYaml("")).toThrow(/is not a Prometheus rule file/);
17
17
  });
18
18
 
19
19
  test("a rule's values are strings, label values included, and a group's limit a number", () => {
@@ -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)) {