@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.
- 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/slo.d.ts +10 -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/generator.d.ts +3 -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 +4 -3
- 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.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 +43 -1
- package/src/composites/slo.ts +22 -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/generator.ts +120 -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/index.ts +20 -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 +1 -0
- package/src/prometheus-config.test.ts +230 -0
- package/src/prometheus-config.ts +71 -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.ts +1 -1
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
|
@@ -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(", "),
|
|
@@ -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
|
+
}
|
package/src/serializer.ts
CHANGED
|
@@ -1,30 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Prometheus serializer.
|
|
3
3
|
*
|
|
4
|
-
* Emits the
|
|
4
|
+
* Emits the files a Prometheus setup reads:
|
|
5
5
|
*
|
|
6
6
|
* - the rule file (`groups:`), from every `RuleGroup` in the build, the file
|
|
7
7
|
* `rule_files:` points at and `promtool check rules` reads;
|
|
8
|
+
* - `prometheus.yml`, from the `PrometheusConfig` and `ScrapeConfig`
|
|
9
|
+
* entities, the file `promtool check config` reads;
|
|
8
10
|
* - `alertmanager.yml`, from the `Route`, `Receiver`, `InhibitRule`,
|
|
9
11
|
* `TimeInterval` and `AlertmanagerSettings` entities, the file
|
|
10
12
|
* `amtool check-config` reads.
|
|
11
13
|
*
|
|
12
|
-
* The
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* child routes keep the order they are written in, which is the
|
|
17
|
-
* matters to Prometheus and Alertmanager; groups,
|
|
18
|
-
* intervals, whose order means nothing, are sorted by name.
|
|
14
|
+
* The first of those a build produces, in that order, is the primary output
|
|
15
|
+
* and the others are written beside it: the rule file, then `prometheus.yml`
|
|
16
|
+
* (`PROMETHEUS_FILE`), then `alertmanager.yml`. A project that declares only
|
|
17
|
+
* rule groups, or only Alertmanager entities, sees no change. Rules inside a
|
|
18
|
+
* group and child routes keep the order they are written in, which is the
|
|
19
|
+
* order that matters to Prometheus and Alertmanager; groups, scrape jobs,
|
|
20
|
+
* receivers and time intervals, whose order means nothing, are sorted by name.
|
|
19
21
|
*
|
|
20
|
-
*
|
|
21
|
-
* stamp.
|
|
22
|
+
* None of these files has a metadata channel, so there is no ownership marker
|
|
23
|
+
* to stamp.
|
|
22
24
|
*/
|
|
23
25
|
|
|
24
26
|
import type { Declarable } from "@intentius/chant/declarable";
|
|
25
27
|
import type { Serializer, SerializerResult } from "@intentius/chant/serializer";
|
|
26
28
|
import type { LexiconOutput } from "@intentius/chant/lexicon-output";
|
|
27
|
-
import { buildAlertmanagerConfig, buildRuleFile, emitYaml } from "./build";
|
|
29
|
+
import { buildAlertmanagerConfig, buildPrometheusConfig, buildRuleFile, emitYaml } from "./build";
|
|
30
|
+
|
|
31
|
+
/** The filename `prometheus.yml` is written under when it sits beside a rule file or `alertmanager.yml`. */
|
|
32
|
+
export const PROMETHEUS_FILE = "prometheus.yml";
|
|
28
33
|
|
|
29
34
|
/** The filename `alertmanager.yml` is written under when it sits beside a rule file. */
|
|
30
35
|
export const ALERTMANAGER_FILE = "alertmanager.yml";
|
|
@@ -35,20 +40,25 @@ export const prometheusSerializer: Serializer = {
|
|
|
35
40
|
|
|
36
41
|
serialize(entities: Map<string, Declarable>, _outputs?: LexiconOutput[]): string | SerializerResult {
|
|
37
42
|
const rules = buildRuleFile(entities);
|
|
43
|
+
const prom = buildPrometheusConfig(entities);
|
|
38
44
|
const am = buildAlertmanagerConfig(entities);
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
45
|
+
const warnings = [...prom.warnings, ...am.warnings];
|
|
46
|
+
|
|
47
|
+
// The files this build writes, in the order of the primary: the rule file,
|
|
48
|
+
// then prometheus.yml, then alertmanager.yml.
|
|
49
|
+
const out: Array<[string, string]> = [];
|
|
50
|
+
if (rules.groups.length > 0) out.push(["rules", emitYaml(rules.config)]);
|
|
51
|
+
if (prom.count > 0) out.push([PROMETHEUS_FILE, emitYaml(prom.config)]);
|
|
52
|
+
if (am.count > 0) out.push([ALERTMANAGER_FILE, emitYaml(am.config)]);
|
|
42
53
|
|
|
43
|
-
if (
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
const text = emitYaml(am.config);
|
|
47
|
-
return warnings.length === 0 ? text : { primary: text, warnings };
|
|
54
|
+
if (out.length === 0) return "";
|
|
55
|
+
if (out.length === 1) {
|
|
56
|
+
return warnings.length === 0 ? out[0][1] : { primary: out[0][1], warnings };
|
|
48
57
|
}
|
|
58
|
+
const [[, primary], ...rest] = out;
|
|
49
59
|
return {
|
|
50
|
-
primary
|
|
51
|
-
files:
|
|
60
|
+
primary,
|
|
61
|
+
files: Object.fromEntries(rest),
|
|
52
62
|
...(warnings.length > 0 ? { warnings } : {}),
|
|
53
63
|
};
|
|
54
64
|
},
|
package/src/tools.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `promtool check rules` and `amtool check-config`, when they are installed.
|
|
2
|
+
* `promtool check rules`, `promtool check config` and `amtool check-config`, when they are installed.
|
|
3
3
|
*
|
|
4
4
|
* The lexicon's own checks cover structure, references and PromQL syntax.
|
|
5
5
|
* The upstream tools add what only Prometheus and Alertmanager know (PromQL
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { spawnSync } from "child_process";
|
|
13
|
-
import { mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
13
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
|
|
14
14
|
import { tmpdir } from "os";
|
|
15
|
-
import { join } from "path";
|
|
15
|
+
import { dirname, join } from "path";
|
|
16
16
|
|
|
17
17
|
export interface ToolResult {
|
|
18
18
|
/** False when the binary isn't installed; `ok` and `output` are then meaningless. */
|
|
@@ -46,6 +46,34 @@ export function promtoolCheckRules(yaml: string, bin = process.env.PROMTOOL ?? "
|
|
|
46
46
|
return runOnText(bin, (f) => ["check", "rules", f], "rules.yml", yaml);
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Run `promtool check config` over `prometheus.yml` text. The config is written
|
|
51
|
+
* as `prometheus.yml` in a directory of its own, and `files` (path relative to
|
|
52
|
+
* that directory to contents) are written beside it: `check config` also reads
|
|
53
|
+
* the rule files, certificates and credentials files the config names, so a
|
|
54
|
+
* config that names any needs them here. `bin` defaults to `promtool` on PATH.
|
|
55
|
+
*/
|
|
56
|
+
export function promtoolCheckConfig(
|
|
57
|
+
yaml: string,
|
|
58
|
+
files: Record<string, string> = {},
|
|
59
|
+
bin = process.env.PROMTOOL ?? "promtool",
|
|
60
|
+
): ToolResult {
|
|
61
|
+
if (!hasTool(bin)) return { ran: false, ok: false, output: "" };
|
|
62
|
+
const dir = mkdtempSync(join(tmpdir(), "chant-prometheus-"));
|
|
63
|
+
try {
|
|
64
|
+
writeFileSync(join(dir, "prometheus.yml"), yaml);
|
|
65
|
+
for (const [path, content] of Object.entries(files)) {
|
|
66
|
+
const target = join(dir, path);
|
|
67
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
68
|
+
writeFileSync(target, content);
|
|
69
|
+
}
|
|
70
|
+
const r = spawnSync(bin, ["check", "config", "prometheus.yml"], { cwd: dir, encoding: "utf-8" });
|
|
71
|
+
return { ran: true, ok: r.status === 0, output: `${r.stdout ?? ""}${r.stderr ?? ""}` };
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
49
77
|
/**
|
|
50
78
|
* Run `promtool test rules` over rule file YAML and a unit-test file for it.
|
|
51
79
|
* The test file's `rule_files:` should name `rules.yml`, which is where the
|
package/src/validate-config.ts
CHANGED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PROM225: a `labeldrop` or `labelkeep` relabel step carries a field the
|
|
3
|
+
* action does not take.
|
|
4
|
+
*
|
|
5
|
+
* Both actions match `regex` against label names, so `source_labels`,
|
|
6
|
+
* `separator`, `target_label`, `modulus` and `replacement` have nothing to
|
|
7
|
+
* act on. Prometheus rejects the file when it loads it (the importer's
|
|
8
|
+
* fixture was fixed for the same reason, #3537).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { PrometheusConfigFile } from "./config-model";
|
|
12
|
+
import type { PrometheusIssue } from "./validate-config";
|
|
13
|
+
|
|
14
|
+
const RELABEL_KEYS = new Set(["relabel_configs", "metric_relabel_configs", "alert_relabel_configs", "write_relabel_configs"]);
|
|
15
|
+
const FOREIGN_FIELDS = ["source_labels", "separator", "target_label", "modulus", "replacement"] as const;
|
|
16
|
+
|
|
17
|
+
/** Every relabel step in a `prometheus.yml`, with where it sits, e.g. `scrape_configs[0].relabel_configs[1]`. */
|
|
18
|
+
function relabelSteps(node: unknown, path: string, out: Array<{ path: string; step: Record<string, unknown> }>): void {
|
|
19
|
+
if (Array.isArray(node)) {
|
|
20
|
+
node.forEach((v, i) => relabelSteps(v, `${path}[${i}]`, out));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (typeof node !== "object" || node === null) return;
|
|
24
|
+
for (const [key, value] of Object.entries(node)) {
|
|
25
|
+
const here = path ? `${path}.${key}` : key;
|
|
26
|
+
if (RELABEL_KEYS.has(key) && Array.isArray(value)) {
|
|
27
|
+
value.forEach((step, i) => {
|
|
28
|
+
if (typeof step === "object" && step !== null && !Array.isArray(step)) out.push({ path: `${here}[${i}]`, step: step as Record<string, unknown> });
|
|
29
|
+
});
|
|
30
|
+
} else {
|
|
31
|
+
relabelSteps(value, here, out);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** PROM225 over one `prometheus.yml`. */
|
|
37
|
+
export function validateRelabelFields(config: PrometheusConfigFile): PrometheusIssue[] {
|
|
38
|
+
const steps: Array<{ path: string; step: Record<string, unknown> }> = [];
|
|
39
|
+
relabelSteps(config, "", steps);
|
|
40
|
+
const issues: PrometheusIssue[] = [];
|
|
41
|
+
for (const { path, step } of steps) {
|
|
42
|
+
if (step.action !== "labeldrop" && step.action !== "labelkeep") continue;
|
|
43
|
+
const extra = FOREIGN_FIELDS.filter((f) => step[f] !== undefined);
|
|
44
|
+
if (extra.length === 0) continue;
|
|
45
|
+
issues.push({
|
|
46
|
+
code: "PROM225",
|
|
47
|
+
severity: "error",
|
|
48
|
+
subject: path,
|
|
49
|
+
message: `${path}: a ${String(step.action)} step matches regex against label names and takes no ${extra.join(", ")}; Prometheus rejects the file, so remove ${extra.length === 1 ? "it" : "them"}`,
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return issues;
|
|
53
|
+
}
|
package/src/validate.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { validateAlertmanagerConfig, validateRuleFile } from "./validate-config"
|
|
|
14
14
|
export type { ValidateCheck, ValidateResult } from "@intentius/chant/codegen/validate";
|
|
15
15
|
|
|
16
16
|
/** Every entity class the package must keep exporting. */
|
|
17
|
-
export const REQUIRED_NAMES = ["RuleGroup", "Route", "Receiver", "InhibitRule", "TimeInterval", "AlertmanagerSettings"];
|
|
17
|
+
export const REQUIRED_NAMES = ["RuleGroup", "ScrapeConfig", "PrometheusConfig", "Route", "Receiver", "InhibitRule", "TimeInterval", "AlertmanagerSettings"];
|
|
18
18
|
|
|
19
19
|
export async function validate(): Promise<ValidateResult> {
|
|
20
20
|
const checks: ValidateCheck[] = [];
|