@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.
- package/README.md +5 -2
- package/dist/build.d.ts +17 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/catalog.d.ts +1 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/composites/index.d.ts +1 -1
- package/dist/composites/index.d.ts.map +1 -1
- package/dist/composites/slo.d.ts +23 -0
- package/dist/composites/slo.d.ts.map +1 -1
- package/dist/config-model.d.ts +272 -0
- package/dist/config-model.d.ts.map +1 -0
- package/dist/detect.d.ts.map +1 -1
- package/dist/import/embedded.d.ts +7 -0
- package/dist/import/embedded.d.ts.map +1 -1
- package/dist/import/generator.d.ts +17 -1
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts +12 -2
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/import/slo.d.ts.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +5 -4
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +2 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -1
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/prom-helpers.d.ts +7 -0
- package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/prom225.d.ts +10 -0
- package/dist/lint/post-synth/prom225.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/meta.json +10 -0
- package/dist/model.d.ts +1 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/okf/index.md +3 -0
- package/dist/okf/rules/PROM225.md +11 -0
- package/dist/okf/types/PrometheusConfig.md +9 -0
- package/dist/okf/types/ScrapeConfig.md +9 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/prometheus-config.d.ts +39 -0
- package/dist/prometheus-config.d.ts.map +1 -0
- package/dist/rules/prom-helpers.ts +10 -1
- package/dist/rules/prom225.ts +19 -0
- package/dist/serializer.d.ts +14 -10
- package/dist/serializer.d.ts.map +1 -1
- package/dist/tools.d.ts +9 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/validate-config.d.ts +1 -1
- package/dist/validate-config.d.ts.map +1 -1
- package/dist/validate-relabel.d.ts +14 -0
- package/dist/validate-relabel.d.ts.map +1 -0
- package/dist/validate.d.ts +2 -1
- package/dist/validate.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/build.ts +73 -0
- package/src/catalog.ts +14 -1
- package/src/composites/composites.test.ts +72 -2
- package/src/composites/index.ts +1 -0
- package/src/composites/slo.ts +43 -4
- package/src/config-model.ts +327 -0
- package/src/detect.ts +5 -4
- package/src/generated/lexicon-prometheus.json +10 -0
- package/src/import/embedded.test.ts +46 -2
- package/src/import/embedded.ts +32 -1
- package/src/import/generator.ts +138 -0
- package/src/import/parser.test.ts +2 -2
- package/src/import/parser.ts +72 -5
- package/src/import/prometheus-roundtrip.test.ts +148 -0
- package/src/import/slo.ts +15 -0
- package/src/import/testdata/prometheus-full.yml +149 -0
- package/src/import/testdata/schemastore-prometheus.json +1635 -0
- package/src/index.ts +21 -2
- package/src/lint/audit-catalog.ts +7 -0
- package/src/lint/audit-lineage.test.ts +1 -1
- package/src/lint/audit-lineage.ts +2 -0
- package/src/lint/post-synth/checks.test.ts +34 -0
- package/src/lint/post-synth/index.ts +2 -0
- package/src/lint/post-synth/prom-helpers.ts +10 -1
- package/src/lint/post-synth/prom225.ts +19 -0
- package/src/model.ts +1 -0
- package/src/plugin.test.ts +1 -0
- package/src/plugin.ts +3 -2
- package/src/prometheus-config.test.ts +230 -0
- package/src/prometheus-config.ts +71 -0
- package/src/schemastore.test.ts +142 -0
- package/src/serializer.ts +31 -21
- package/src/tools.ts +31 -3
- package/src/validate-config.ts +2 -1
- package/src/validate-relabel.ts +53 -0
- package/src/validate.test.ts +17 -0
- package/src/validate.ts +27 -3
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 {
|
|
@@ -84,6 +102,7 @@ export {
|
|
|
84
102
|
sloMetrics,
|
|
85
103
|
sloPropsProblem,
|
|
86
104
|
sliExprProblem,
|
|
105
|
+
eventCount,
|
|
87
106
|
DEFAULT_BURN_RATES,
|
|
88
107
|
SLO_WINDOW_PLACEHOLDER,
|
|
89
108
|
type SloProps,
|
|
@@ -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
|
+
};
|
package/src/model.ts
CHANGED
|
@@ -104,6 +104,7 @@ export function ruleName(rule: RuleConfig): string {
|
|
|
104
104
|
// ── Alertmanager ────────────────────────────────────────────────────
|
|
105
105
|
|
|
106
106
|
export * from "./integrations";
|
|
107
|
+
export * from "./config-model";
|
|
107
108
|
|
|
108
109
|
/** One entry under `receivers:`. */
|
|
109
110
|
export interface ReceiverConfig {
|
package/src/plugin.test.ts
CHANGED
package/src/plugin.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { hover } from "./lsp/hover";
|
|
|
11
11
|
import { detectTemplate } from "./detect";
|
|
12
12
|
import { PrometheusParser } from "./import/parser";
|
|
13
13
|
import { PrometheusGenerator } from "./import/generator";
|
|
14
|
-
import { alertmanagerImporter, ruleGroupsImporter } from "./import/embedded";
|
|
14
|
+
import { alertmanagerImporter, prometheusConfigImporter, ruleGroupsImporter } from "./import/embedded";
|
|
15
15
|
import { DEFAULT_TEMPLATE, RULES_TEMPLATE, SLO_STYLE_TEMPLATE, SLO_TEMPLATE } from "./init-templates";
|
|
16
16
|
import { prometheusSkills } from "./skill-defs";
|
|
17
17
|
import { CATALOG } from "./catalog";
|
|
@@ -57,6 +57,7 @@ export const prometheusPlugin: LexiconPlugin = {
|
|
|
57
57
|
const { prometheus, alertmanager } = PROMETHEUS_PIN;
|
|
58
58
|
console.error(`prometheus: ${CATALOG.length} entities`);
|
|
59
59
|
console.error(` rule file (${prometheus.source} ${prometheus.version}): RuleGroup, recording and alerting rules`);
|
|
60
|
+
console.error(` prometheus.yml (${prometheus.source} ${prometheus.version}): ScrapeConfig, PrometheusConfig`);
|
|
60
61
|
console.error(
|
|
61
62
|
` alertmanager.yml (${alertmanager.source} ${alertmanager.version}): ` +
|
|
62
63
|
CATALOG.filter((c) => c.file === "alertmanager.yml").map((c) => c.className).join(", "),
|
|
@@ -133,7 +134,7 @@ export const prometheusPlugin: LexiconPlugin = {
|
|
|
133
134
|
},
|
|
134
135
|
|
|
135
136
|
embeddedImporters() {
|
|
136
|
-
return [ruleGroupsImporter, alertmanagerImporter];
|
|
137
|
+
return [ruleGroupsImporter, alertmanagerImporter, prometheusConfigImporter];
|
|
137
138
|
},
|
|
138
139
|
|
|
139
140
|
// `chant init --lexicon prometheus [--template rules|slo-style|slo]`; see ./init-templates.ts.
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prometheus.yml` as entities: `PrometheusConfig` and `ScrapeConfig` build to
|
|
3
|
+
* the file, the serializer writes it beside the other files, and
|
|
4
|
+
* `promtool check config` accepts it when `promtool` is on PATH.
|
|
5
|
+
*
|
|
6
|
+
* The import round trip is in import/prometheus-roundtrip.test.ts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test } from "vitest";
|
|
10
|
+
import { load } from "js-yaml";
|
|
11
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
12
|
+
import { buildPrometheusConfig, prometheusConfigYaml } from "./build";
|
|
13
|
+
import { PrometheusConfig, ScrapeConfig, isPrometheusConfig, isScrapeConfig } from "./prometheus-config";
|
|
14
|
+
import { prometheusSerializer, PROMETHEUS_FILE, ALERTMANAGER_FILE } from "./serializer";
|
|
15
|
+
import { Receiver, Route, RuleGroup } from "./index";
|
|
16
|
+
import { hasTool, promtoolCheckConfig } from "./tools";
|
|
17
|
+
import { CATALOG } from "./catalog";
|
|
18
|
+
import { detectTemplate } from "./detect";
|
|
19
|
+
import { PrometheusParser } from "./import/parser";
|
|
20
|
+
import type { SerializerResult } from "@intentius/chant/serializer";
|
|
21
|
+
|
|
22
|
+
const PROMTOOL = hasTool(process.env.PROMTOOL ?? "promtool");
|
|
23
|
+
|
|
24
|
+
function entities(record: Record<string, unknown>): Map<string, Declarable> {
|
|
25
|
+
return new Map(Object.entries(record) as Array<[string, Declarable]>);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** What terragucci's observability/prometheus.ts emits today. */
|
|
29
|
+
const terragucci = () => ({
|
|
30
|
+
config: new PrometheusConfig({ global: { scrape_interval: "5s" } }),
|
|
31
|
+
collector: new ScrapeConfig({ job_name: "otel-collector", static_configs: [{ targets: ["otel-collector:8889"] }] }),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const TERRAGUCCI_YAML = `global:
|
|
35
|
+
scrape_interval: 5s
|
|
36
|
+
scrape_configs:
|
|
37
|
+
- job_name: otel-collector
|
|
38
|
+
static_configs:
|
|
39
|
+
- targets:
|
|
40
|
+
- otel-collector:8889
|
|
41
|
+
`;
|
|
42
|
+
|
|
43
|
+
describe("PrometheusConfig and ScrapeConfig", () => {
|
|
44
|
+
test("the guards tell the two entities apart", () => {
|
|
45
|
+
const { config, collector } = terragucci();
|
|
46
|
+
expect(isPrometheusConfig(config)).toBe(true);
|
|
47
|
+
expect(isScrapeConfig(config)).toBe(false);
|
|
48
|
+
expect(isScrapeConfig(collector)).toBe(true);
|
|
49
|
+
expect(isPrometheusConfig(collector)).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("both are in the catalog", () => {
|
|
53
|
+
const names = CATALOG.filter((c) => c.file === "prometheus.yml").map((c) => c.className);
|
|
54
|
+
expect(names.sort()).toEqual(["PrometheusConfig", "ScrapeConfig"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("the config terragucci emits today builds to the same text", () => {
|
|
58
|
+
expect(prometheusConfigYaml(Object.values(terragucci()) as unknown as Declarable[])).toBe(TERRAGUCCI_YAML);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("sections come out in Prometheus's order and scrape jobs are sorted by name", () => {
|
|
62
|
+
const built = buildPrometheusConfig([
|
|
63
|
+
new ScrapeConfig({ job_name: "zeta", static_configs: [{ targets: ["z:1"] }] }),
|
|
64
|
+
new ScrapeConfig({ job_name: "alpha", static_configs: [{ targets: ["a:1"] }] }),
|
|
65
|
+
new PrometheusConfig({
|
|
66
|
+
otlp: { promote_resource_attributes: ["service.name"] },
|
|
67
|
+
rule_files: ["rules/*.yml"],
|
|
68
|
+
global: { scrape_interval: "15s", external_labels: { cluster: "dev" } },
|
|
69
|
+
alerting: { alertmanagers: [{ static_configs: [{ targets: ["alertmanager:9093"] }] }] },
|
|
70
|
+
remote_write: [{ url: "http://mimir:9009/api/v1/push" }],
|
|
71
|
+
}),
|
|
72
|
+
]);
|
|
73
|
+
expect(Object.keys(built.config)).toEqual(["global", "alerting", "rule_files", "scrape_configs", "remote_write", "otlp"]);
|
|
74
|
+
expect(built.config.scrape_configs?.map((j) => j.job_name)).toEqual(["alpha", "zeta"]);
|
|
75
|
+
expect(built.warnings).toEqual([]);
|
|
76
|
+
expect(built.count).toBe(3);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("scrape jobs written inline in PrometheusConfig are merged with declared ones", () => {
|
|
80
|
+
const declared = new ScrapeConfig({ job_name: "declared", static_configs: [{ targets: ["d:1"] }] });
|
|
81
|
+
const built = buildPrometheusConfig([
|
|
82
|
+
declared,
|
|
83
|
+
new PrometheusConfig({ scrape_configs: [declared, { job_name: "inline", static_configs: [{ targets: ["i:1"] }] }] }),
|
|
84
|
+
]);
|
|
85
|
+
expect(built.config.scrape_configs?.map((j) => j.job_name)).toEqual(["declared", "inline"]);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("undefined fields are dropped", () => {
|
|
89
|
+
const yaml = prometheusConfigYaml([new ScrapeConfig({ job_name: "a", scrape_interval: undefined, honor_labels: false })]);
|
|
90
|
+
expect(load(yaml)).toEqual({ scrape_configs: [{ job_name: "a", honor_labels: false }] });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("a repeated job_name and a second PrometheusConfig are warnings", () => {
|
|
94
|
+
const built = buildPrometheusConfig([
|
|
95
|
+
new ScrapeConfig({ job_name: "dup" }),
|
|
96
|
+
new ScrapeConfig({ job_name: "dup" }),
|
|
97
|
+
new PrometheusConfig({ global: { scrape_interval: "1s" } }),
|
|
98
|
+
new PrometheusConfig({ global: { scrape_interval: "2s" } }),
|
|
99
|
+
]);
|
|
100
|
+
expect(built.warnings).toHaveLength(2);
|
|
101
|
+
expect(built.config.global?.scrape_interval).toBe("1s");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("an unlisted discovery kind is carried as written", () => {
|
|
105
|
+
const yaml = prometheusConfigYaml([
|
|
106
|
+
new ScrapeConfig({ job_name: "docker", docker_sd_configs: [{ host: "unix:///var/run/docker.sock" }] }),
|
|
107
|
+
]);
|
|
108
|
+
expect(load(yaml)).toEqual({
|
|
109
|
+
scrape_configs: [{ job_name: "docker", docker_sd_configs: [{ host: "unix:///var/run/docker.sock" }] }],
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("prometheus serializer with prometheus.yml", () => {
|
|
115
|
+
test("a build with only scrape configs writes prometheus.yml as the primary output", () => {
|
|
116
|
+
expect(prometheusSerializer.serialize(entities(terragucci()))).toBe(TERRAGUCCI_YAML);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("a build with no prometheus.yml entities is unchanged", () => {
|
|
120
|
+
const out = prometheusSerializer.serialize(
|
|
121
|
+
entities({ g: new RuleGroup({ name: "g", rules: [{ record: "a:b", expr: "sum(up)" }] }) }),
|
|
122
|
+
);
|
|
123
|
+
expect(typeof out).toBe("string");
|
|
124
|
+
expect(out as string).toMatch(/^groups:/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("with rule groups, prometheus.yml is written beside the rule file", () => {
|
|
128
|
+
const out = prometheusSerializer.serialize(
|
|
129
|
+
entities({ ...terragucci(), g: new RuleGroup({ name: "g", rules: [{ record: "a:b", expr: "sum(up)" }] }) }),
|
|
130
|
+
) as SerializerResult;
|
|
131
|
+
expect(out.primary).toMatch(/^groups:/);
|
|
132
|
+
expect(out.files).toEqual({ [PROMETHEUS_FILE]: TERRAGUCCI_YAML });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("with rule groups and Alertmanager entities, all three files are written", () => {
|
|
136
|
+
const hook = new Receiver({ name: "hook", webhook_configs: [{ url: "http://hook:8080/" }] });
|
|
137
|
+
const out = prometheusSerializer.serialize(
|
|
138
|
+
entities({
|
|
139
|
+
...terragucci(),
|
|
140
|
+
g: new RuleGroup({ name: "g", rules: [{ record: "a:b", expr: "sum(up)" }] }),
|
|
141
|
+
route: new Route({ receiver: hook }),
|
|
142
|
+
}),
|
|
143
|
+
) as SerializerResult;
|
|
144
|
+
expect(out.primary).toMatch(/^groups:/);
|
|
145
|
+
expect(Object.keys(out.files ?? {})).toEqual([PROMETHEUS_FILE, ALERTMANAGER_FILE]);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("with Alertmanager entities only, prometheus.yml is primary and alertmanager.yml beside it", () => {
|
|
149
|
+
const hook = new Receiver({ name: "hook", webhook_configs: [{ url: "http://hook:8080/" }] });
|
|
150
|
+
const out = prometheusSerializer.serialize(entities({ ...terragucci(), route: new Route({ receiver: hook }) })) as SerializerResult;
|
|
151
|
+
expect(out.primary).toBe(TERRAGUCCI_YAML);
|
|
152
|
+
expect(Object.keys(out.files ?? {})).toEqual([ALERTMANAGER_FILE]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("warnings travel with the output", () => {
|
|
156
|
+
const out = prometheusSerializer.serialize(
|
|
157
|
+
entities({ a: new ScrapeConfig({ job_name: "dup" }), b: new ScrapeConfig({ job_name: "dup" }) }),
|
|
158
|
+
) as SerializerResult;
|
|
159
|
+
expect(out.warnings).toHaveLength(1);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("prometheus.yml detection and parsing", () => {
|
|
164
|
+
test("a prometheus.yml is a template of this lexicon; a bare global block or a collector config is not", () => {
|
|
165
|
+
expect(detectTemplate(load(TERRAGUCCI_YAML))).toBe(true);
|
|
166
|
+
expect(detectTemplate({ global: { scrape_interval: "5s" } })).toBe(true);
|
|
167
|
+
expect(detectTemplate({ global: { resolve_timeout: "5m" } })).toBe(false);
|
|
168
|
+
expect(detectTemplate({ receivers: { otlp: {} }, exporters: {} })).toBe(false);
|
|
169
|
+
expect(detectTemplate({ apiVersion: "v1", kind: "ConfigMap", global: { scrape_interval: "5s" } })).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("the parser names what it does not carry", () => {
|
|
173
|
+
const ir = new PrometheusParser().parse(`scrape_configs:
|
|
174
|
+
- job_name: a
|
|
175
|
+
docker_sd_configs: [{ host: "unix:///x.sock" }]
|
|
176
|
+
- static_configs: [{ targets: ["x:1"] }]
|
|
177
|
+
bogus: 1
|
|
178
|
+
`);
|
|
179
|
+
expect(ir.resources[0].type).toBe("Prometheus::Config");
|
|
180
|
+
expect(ir.warnings).toHaveLength(3);
|
|
181
|
+
expect(ir.warnings?.join("\n")).toContain("docker_sd_configs");
|
|
182
|
+
expect(ir.warnings?.join("\n")).toContain("has no job_name");
|
|
183
|
+
expect(ir.warnings?.join("\n")).toContain('"bogus"');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
describe("promtool check config", () => {
|
|
188
|
+
test.skipIf(!PROMTOOL)("accepts the config terragucci emits", () => {
|
|
189
|
+
const r = promtoolCheckConfig(prometheusConfigYaml(Object.values(terragucci()) as unknown as Declarable[]));
|
|
190
|
+
expect(r.ok, r.output).toBe(true);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test.skipIf(!PROMTOOL)("accepts a config using the typed sections", () => {
|
|
194
|
+
const yaml = prometheusConfigYaml([
|
|
195
|
+
new PrometheusConfig({
|
|
196
|
+
global: { scrape_interval: "15s", scrape_timeout: "10s", evaluation_interval: "30s", external_labels: { cluster: "dev" } },
|
|
197
|
+
alerting: { alertmanagers: [{ scheme: "http", api_version: "v2", static_configs: [{ targets: ["alertmanager:9093"] }] }] },
|
|
198
|
+
rule_files: ["rules.yml"],
|
|
199
|
+
remote_write: [
|
|
200
|
+
{
|
|
201
|
+
url: "http://mimir:9009/api/v1/push",
|
|
202
|
+
queue_config: { capacity: 10000 },
|
|
203
|
+
write_relabel_configs: [{ source_labels: ["__name__"], regex: "debug_.*", action: "drop" }],
|
|
204
|
+
},
|
|
205
|
+
],
|
|
206
|
+
otlp: { promote_resource_attributes: ["service.name"] },
|
|
207
|
+
}),
|
|
208
|
+
new ScrapeConfig({
|
|
209
|
+
job_name: "app",
|
|
210
|
+
scrape_interval: "5s",
|
|
211
|
+
params: { format: ["prometheus"] },
|
|
212
|
+
static_configs: [{ targets: ["app:8080"], labels: { tier: "web" } }],
|
|
213
|
+
metric_relabel_configs: [{ source_labels: ["__name__"], regex: "go_.*", action: "drop" }],
|
|
214
|
+
}),
|
|
215
|
+
new ScrapeConfig({
|
|
216
|
+
job_name: "dns",
|
|
217
|
+
dns_sd_configs: [{ names: ["api.service.internal"], type: "A", port: 9100 }],
|
|
218
|
+
relabel_configs: [{ source_labels: ["__address__"], target_label: "host" }],
|
|
219
|
+
}),
|
|
220
|
+
]);
|
|
221
|
+
const r = promtoolCheckConfig(yaml, { "rules.yml": "groups:\n - name: g\n rules:\n - record: a:b\n expr: sum(up)\n" });
|
|
222
|
+
expect(r.ok, r.output).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test.skipIf(!PROMTOOL)("rejects a config Prometheus rejects", () => {
|
|
226
|
+
const r = promtoolCheckConfig(prometheusConfigYaml([new ScrapeConfig({ job_name: "bad", scrape_interval: "1s", scrape_timeout: "5s" })]));
|
|
227
|
+
expect(r.ran).toBe(true);
|
|
228
|
+
expect(r.ok).toBe(false);
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PrometheusConfig` and `ScrapeConfig`: the entities that serialize to
|
|
3
|
+
* `prometheus.yml`.
|
|
4
|
+
*
|
|
5
|
+
* A `ScrapeConfig` is one scrape job. `PrometheusConfig` holds the rest of the
|
|
6
|
+
* file (`global`, `alerting`, `rule_files`, `remote_write`, `remote_read`,
|
|
7
|
+
* `otlp`) and is optional: a build that declares only `ScrapeConfig`s still
|
|
8
|
+
* writes a `prometheus.yml`. A project that declares neither writes none, so
|
|
9
|
+
* a project that builds only rule files sees no change.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createResource } from "@intentius/chant/runtime";
|
|
13
|
+
import type { Declarable } from "@intentius/chant/declarable";
|
|
14
|
+
import type { PrometheusConfigSections, ScrapeJobConfig } from "./model";
|
|
15
|
+
|
|
16
|
+
export const SCRAPE_CONFIG_TYPE = "Prometheus::Config::ScrapeConfig";
|
|
17
|
+
export const PROMETHEUS_CONFIG_TYPE = "Prometheus::Config::PrometheusConfig";
|
|
18
|
+
|
|
19
|
+
type Ctor<P, E> = new (props: P) => E;
|
|
20
|
+
|
|
21
|
+
function entityClass<P, E>(entityType: string, className: string): Ctor<P, E> {
|
|
22
|
+
const Base = createResource(entityType, "prometheus", {}) as unknown as (this: object, props: Record<string, unknown>) => void;
|
|
23
|
+
const Cls = function (this: object, props: P) {
|
|
24
|
+
Base.call(this, (props ?? {}) as unknown as Record<string, unknown>);
|
|
25
|
+
};
|
|
26
|
+
Object.defineProperty(Cls, "name", { value: className });
|
|
27
|
+
return Cls as unknown as Ctor<P, E>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── ScrapeConfig ────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
export type ScrapeConfigProps = ScrapeJobConfig;
|
|
33
|
+
|
|
34
|
+
export interface ScrapeConfigEntity extends Declarable {
|
|
35
|
+
readonly props: ScrapeConfigProps;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One scrape job: where its targets come from (`static_configs` or a service
|
|
40
|
+
* discovery list), how to scrape them, and how to relabel what comes back.
|
|
41
|
+
*/
|
|
42
|
+
export const ScrapeConfig = entityClass<ScrapeConfigProps, ScrapeConfigEntity>(SCRAPE_CONFIG_TYPE, "ScrapeConfig");
|
|
43
|
+
|
|
44
|
+
// ── PrometheusConfig ────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
export interface PrometheusConfigProps extends PrometheusConfigSections {
|
|
47
|
+
/** Scrape jobs written inline, as plain objects. Merged with the declared `ScrapeConfig`s. */
|
|
48
|
+
scrape_configs?: Array<ScrapeConfigEntity | ScrapeConfigProps>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PrometheusConfigEntity extends Declarable {
|
|
52
|
+
readonly props: PrometheusConfigProps;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The sections of `prometheus.yml` other than the scrape jobs. Declare at most one. */
|
|
56
|
+
export const PrometheusConfig = entityClass<PrometheusConfigProps, PrometheusConfigEntity>(PROMETHEUS_CONFIG_TYPE, "PrometheusConfig");
|
|
57
|
+
|
|
58
|
+
// ── Guards ──────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function isType<T>(type: string) {
|
|
61
|
+
return (value: unknown): value is T =>
|
|
62
|
+
typeof value === "object" && value !== null && (value as Declarable).entityType === type;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const isScrapeConfig = isType<ScrapeConfigEntity>(SCRAPE_CONFIG_TYPE);
|
|
66
|
+
export const isPrometheusConfig = isType<PrometheusConfigEntity>(PROMETHEUS_CONFIG_TYPE);
|
|
67
|
+
|
|
68
|
+
/** True for an entity that goes into `prometheus.yml`. */
|
|
69
|
+
export function isPrometheusConfigEntity(value: unknown): boolean {
|
|
70
|
+
return isScrapeConfig(value) || isPrometheusConfig(value);
|
|
71
|
+
}
|