@intentius/chant-lexicon-prometheus 0.97.0 → 0.99.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -1
- package/dist/alertmanager.d.ts +4 -2
- package/dist/alertmanager.d.ts.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/codegen/docs.d.ts.map +1 -1
- package/dist/import/embedded.d.ts +14 -0
- package/dist/import/embedded.d.ts.map +1 -0
- package/dist/import/generator.d.ts +50 -0
- package/dist/import/generator.d.ts.map +1 -0
- package/dist/import/parser.d.ts +52 -0
- package/dist/import/parser.d.ts.map +1 -0
- package/dist/import/slo.d.ts +15 -0
- package/dist/import/slo.d.ts.map +1 -0
- package/dist/integrations.d.ts +531 -0
- package/dist/integrations.d.ts.map +1 -0
- package/dist/integrity.json +7 -7
- package/dist/lint/rules/literal-credential.d.ts.map +1 -1
- package/dist/lint/rules/prom-ast.d.ts +8 -0
- package/dist/lint/rules/prom-ast.d.ts.map +1 -1
- package/dist/lint/rules/promql-literal.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/model.d.ts +77 -121
- package/dist/model.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/literal-credential.ts +52 -15
- package/dist/rules/prom-ast.ts +36 -0
- package/dist/rules/promql-literal.ts +15 -1
- package/dist/skills/chant-prometheus-alertmanager.md +4 -0
- package/dist/skills/chant-prometheus.md +11 -0
- package/package.json +2 -2
- package/src/alertmanager.ts +4 -1
- package/src/build.ts +2 -0
- package/src/codegen/docs.ts +5 -1
- package/src/import/cli.test.ts +98 -0
- package/src/import/embedded.test.ts +56 -0
- package/src/import/embedded.ts +44 -0
- package/src/import/generated-types.e2e.test.ts +77 -0
- package/src/import/generator.test.ts +138 -0
- package/src/import/generator.ts +578 -0
- package/src/import/parser.test.ts +133 -0
- package/src/import/parser.ts +365 -0
- package/src/import/roundtrip.test.ts +358 -0
- package/src/import/slo.ts +175 -0
- package/src/import/testdata/alertmanager-full.yml +89 -0
- package/src/import/testdata/alertmanager-integrations.yml +215 -0
- package/src/import/testdata/fixtures.ts +122 -0
- package/src/import/testdata/rules-full.yml +42 -0
- package/src/import/testdata/upstream/alertmanager-conf-good.yml +120 -0
- package/src/import/testdata/upstream/alertmanager-mattermost-default-webhook-url-file.yml +16 -0
- package/src/import/testdata/upstream/alertmanager-opsgenie-default-apikey-file.yml +27 -0
- package/src/import/testdata/upstream/alertmanager-rocketchat-default-token-file.yml +25 -0
- package/src/import/testdata/upstream/alertmanager-route-labels.yml +65 -0
- package/src/import/testdata/upstream/alertmanager-simple.yml +130 -0
- package/src/import/testdata/upstream/alertmanager-sns-topic-arn.yml +17 -0
- package/src/import/testdata/upstream/alertmanager-telegram-default-bot-token-file.yml +16 -0
- package/src/import/testdata/upstream/alertmanager-victorops-default-apikey-file.yml +23 -0
- package/src/import/testdata/upstream/alertmanager-wechat-default-api-secret-file.yml +18 -0
- package/src/import/testdata/upstream/prometheus-alerting-rules.yml +15 -0
- package/src/import/testdata/upstream/prometheus-alerting-templates.yml +23 -0
- package/src/import/testdata/upstream/prometheus-recording-rules.yml +7 -0
- package/src/integrations.ts +538 -0
- package/src/lint/rules/literal-credential.ts +52 -15
- package/src/lint/rules/prom-ast.ts +36 -0
- package/src/lint/rules/promql-literal.ts +15 -1
- package/src/lint/rules/rules.test.ts +58 -0
- package/src/model.ts +157 -109
- package/src/plugin.ts +15 -0
- package/src/skills/chant-prometheus-alertmanager.md +4 -0
- package/src/skills/chant-prometheus.md +11 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { matcherString, parsePrometheusYaml, PrometheusParser, RULE_FILE_RESOURCE_TYPE, ALERTMANAGER_RESOURCE_TYPE } from "./parser";
|
|
3
|
+
|
|
4
|
+
const lines = (...l: string[]) => `${l.join("\n")}\n`;
|
|
5
|
+
|
|
6
|
+
describe("parsePrometheusYaml", () => {
|
|
7
|
+
test("tells a rule file from an alertmanager.yml", () => {
|
|
8
|
+
expect(parsePrometheusYaml("groups:\n - name: a\n rules: []\n").kind).toBe("rules");
|
|
9
|
+
expect(parsePrometheusYaml("route:\n receiver: x\nreceivers:\n - name: x\n").kind).toBe("alertmanager");
|
|
10
|
+
expect(parsePrometheusYaml("receivers:\n - name: x\n").kind).toBe("alertmanager");
|
|
11
|
+
});
|
|
12
|
+
|
|
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/);
|
|
15
|
+
expect(() => parsePrometheusYaml("- a\n- b\n")).toThrow(/YAML mapping/);
|
|
16
|
+
expect(() => parsePrometheusYaml("")).toThrow(/neither/);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test("a rule's values are strings, label values included, and a group's limit a number", () => {
|
|
20
|
+
const parsed = parsePrometheusYaml(
|
|
21
|
+
lines("groups:", " - name: g", " limit: 5", " labels: { tier: 1 }", " rules:", " - record: a:b", " expr: 1", " labels: { on: true }"),
|
|
22
|
+
);
|
|
23
|
+
if (parsed.kind !== "rules") throw new Error("expected a rule file");
|
|
24
|
+
expect(parsed.file.groups[0]).toEqual({ name: "g", limit: 5, labels: { tier: "1" }, rules: [{ record: "a:b", expr: "1", labels: { on: "true" } }] });
|
|
25
|
+
expect(parsed.warnings).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("names the rule file fields it does not carry", () => {
|
|
29
|
+
const parsed = parsePrometheusYaml(
|
|
30
|
+
lines("groups:", " - name: g", " source_tenants: [a]", " rules:", " - alert: A", " expr: up == 0", " severity: page", "namespace: x"),
|
|
31
|
+
);
|
|
32
|
+
expect(parsed.warnings).toEqual([
|
|
33
|
+
'top-level key "namespace" is not part of a rule file; it is not carried',
|
|
34
|
+
'group "g": "source_tenants" is not a rule group field; it is not carried',
|
|
35
|
+
'group "g" rules[0].severity is not a rule field; it is not carried',
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("route and inhibit rule match maps become matchers, with quotes and backslashes escaped", () => {
|
|
40
|
+
const parsed = parsePrometheusYaml(
|
|
41
|
+
lines(
|
|
42
|
+
"route:",
|
|
43
|
+
" receiver: x",
|
|
44
|
+
" match: { team: 'a\"b' }",
|
|
45
|
+
" match_re: { service: '^api\\.v[0-9]+$' }",
|
|
46
|
+
" matchers: ['env=\"prod\"']",
|
|
47
|
+
"inhibit_rules:",
|
|
48
|
+
" - source_match: { severity: page }",
|
|
49
|
+
" target_match_re: { severity: 'ticket|info' }",
|
|
50
|
+
"receivers:",
|
|
51
|
+
" - name: x",
|
|
52
|
+
),
|
|
53
|
+
);
|
|
54
|
+
if (parsed.kind !== "alertmanager") throw new Error("expected alertmanager.yml");
|
|
55
|
+
expect(parsed.config.route?.matchers).toEqual(['env="prod"', 'team="a\\"b"', 'service=~"^api\\\\.v[0-9]+$"']);
|
|
56
|
+
expect(parsed.config.inhibit_rules).toEqual([{ source_matchers: ['severity="page"'], target_matchers: ['severity=~"ticket|info"'] }]);
|
|
57
|
+
expect(parsed.warnings).toHaveLength(3);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("the top-level mute_time_intervals join time_intervals, and unknown sections are named", () => {
|
|
61
|
+
const parsed = parsePrometheusYaml(
|
|
62
|
+
lines(
|
|
63
|
+
"receivers: [{ name: x }]",
|
|
64
|
+
"time_intervals: [{ name: a, time_intervals: [{ weekdays: [monday] }] }]",
|
|
65
|
+
"mute_time_intervals: [{ name: b, time_intervals: [{ weekdays: [sunday] }] }]",
|
|
66
|
+
"event_recorder: { enabled: true }",
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
if (parsed.kind !== "alertmanager") throw new Error("expected alertmanager.yml");
|
|
70
|
+
expect(parsed.config.time_intervals?.map((t) => t.name)).toEqual(["a", "b"]);
|
|
71
|
+
expect(parsed.warnings).toEqual([
|
|
72
|
+
'top-level section "event_recorder" is not one chant declares; it is not carried',
|
|
73
|
+
"the top-level mute_time_intervals (b) are declared as TimeIntervals and written under time_intervals, which Alertmanager reads the same way",
|
|
74
|
+
]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("receivers are carried whole, merge keys resolved, templates and *_file values as written", () => {
|
|
78
|
+
const parsed = parsePrometheusYaml(
|
|
79
|
+
lines(
|
|
80
|
+
"receivers:",
|
|
81
|
+
" - name: chat",
|
|
82
|
+
" slack_configs:",
|
|
83
|
+
" - &base { api_url_file: /s/url, title: '{{ .CommonLabels.alertname }}' }",
|
|
84
|
+
" - { <<: *base, channel: '#b' }",
|
|
85
|
+
" opsgenie_configs: [{ api_key: literal }]",
|
|
86
|
+
),
|
|
87
|
+
);
|
|
88
|
+
if (parsed.kind !== "alertmanager") throw new Error("expected alertmanager.yml");
|
|
89
|
+
expect(parsed.config.receivers).toEqual([
|
|
90
|
+
{
|
|
91
|
+
name: "chat",
|
|
92
|
+
slack_configs: [
|
|
93
|
+
{ api_url_file: "/s/url", title: "{{ .CommonLabels.alertname }}" },
|
|
94
|
+
{ api_url_file: "/s/url", title: "{{ .CommonLabels.alertname }}", channel: "#b" },
|
|
95
|
+
],
|
|
96
|
+
opsgenie_configs: [{ api_key: "literal" }],
|
|
97
|
+
},
|
|
98
|
+
]);
|
|
99
|
+
expect(parsed.warnings).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a receiver or global key Alertmanager doesn't define is carried and named in a warning", () => {
|
|
103
|
+
const parsed = parsePrometheusYaml(
|
|
104
|
+
lines("global: { resolve_timeout: 5m, pigeon_loft: roof }", "receivers:", " - name: chat", " pigeon_configs: [{ loft: roof }]"),
|
|
105
|
+
);
|
|
106
|
+
if (parsed.kind !== "alertmanager") throw new Error("expected alertmanager.yml");
|
|
107
|
+
expect(parsed.config.receivers).toEqual([{ name: "chat", pigeon_configs: [{ loft: "roof" }] }]);
|
|
108
|
+
expect(parsed.warnings).toEqual([
|
|
109
|
+
"global: pigeon_loft is not a global field in Alertmanager v0.34.1; carried as data, untyped",
|
|
110
|
+
'receiver "chat": pigeon_configs is not a receiver field in Alertmanager v0.34.1; carried as data, untyped',
|
|
111
|
+
]);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("dates and times stay strings, as the Go loaders read them", () => {
|
|
115
|
+
const parsed = parsePrometheusYaml(lines("receivers: [{ name: x }]", "time_intervals: [{ name: t, time_intervals: [{ years: [2026-01-01] }] }]"));
|
|
116
|
+
if (parsed.kind !== "alertmanager") throw new Error("expected alertmanager.yml");
|
|
117
|
+
expect(parsed.config.time_intervals?.[0].time_intervals[0].years).toEqual(["2026-01-01"]);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("PrometheusParser", () => {
|
|
122
|
+
test("carries the whole file as one IR resource", () => {
|
|
123
|
+
const rules = new PrometheusParser().parse("groups:\n - name: a\n rules: []\n");
|
|
124
|
+
expect(rules.resources).toEqual([{ logicalId: "ruleFile", type: RULE_FILE_RESOURCE_TYPE, properties: { file: { groups: [{ name: "a", rules: [] }] } } }]);
|
|
125
|
+
const am = new PrometheusParser().parse("receivers:\n - name: x\n");
|
|
126
|
+
expect(am.resources.map((r) => r.type)).toEqual([ALERTMANAGER_RESOURCE_TYPE]);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("matcherString escapes the value the way Alertmanager's matcher parser reads it", () => {
|
|
131
|
+
expect(matcherString("a", "=", 'x"y\\z')).toBe('a="x\\"y\\\\z"');
|
|
132
|
+
expect(matcherString("a", "=~", "line\nbreak")).toBe('a=~"line\\nbreak"');
|
|
133
|
+
});
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule file or `alertmanager.yml` -> `TemplateIR`, for `chant import`.
|
|
3
|
+
*
|
|
4
|
+
* Either file is one document whose parts name each other (a route names a
|
|
5
|
+
* receiver and time intervals), so the IR carries the whole file as one
|
|
6
|
+
* resource and the generator lays out the modules itself, turning those
|
|
7
|
+
* names into imports of declared entities. Core splits an IR with more than
|
|
8
|
+
* three resources into one generate() call per category and keeps only the
|
|
9
|
+
* first file of each, which would lose the imports between a route and the
|
|
10
|
+
* receivers it names.
|
|
11
|
+
*
|
|
12
|
+
* Values are carried as written: `*_file` credential paths, Go templates in
|
|
13
|
+
* annotations and notifier fields, and literal credentials, which PROM001
|
|
14
|
+
* then reports in the generated source. What the lexicon has no place for is
|
|
15
|
+
* named in `warnings`. Two deprecated Alertmanager spellings are rewritten to
|
|
16
|
+
* the ones Alertmanager treats the same way: `match` / `match_re` (and the
|
|
17
|
+
* inhibit rules' `source_match*` / `target_match*`) become matcher strings,
|
|
18
|
+
* and the top-level `mute_time_intervals` list joins `time_intervals`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import * as jsYaml from "js-yaml";
|
|
22
|
+
import type { TemplateIR, TemplateParser } from "@intentius/chant/import/parser";
|
|
23
|
+
import {
|
|
24
|
+
ALERTMANAGER_GLOBAL_FIELDS,
|
|
25
|
+
RECEIVER_INTEGRATIONS,
|
|
26
|
+
looksLikeAlertmanagerConfig,
|
|
27
|
+
looksLikeRuleFile,
|
|
28
|
+
type AlertmanagerConfig,
|
|
29
|
+
type InhibitRuleConfig,
|
|
30
|
+
type ReceiverConfig,
|
|
31
|
+
type RouteConfig,
|
|
32
|
+
type RuleConfig,
|
|
33
|
+
type RuleFileConfig,
|
|
34
|
+
type RuleGroupConfig,
|
|
35
|
+
type TimeIntervalConfig,
|
|
36
|
+
} from "../model";
|
|
37
|
+
import { PROMETHEUS_PIN } from "../pin";
|
|
38
|
+
|
|
39
|
+
/** The IR resource type for a whole rule file. */
|
|
40
|
+
export const RULE_FILE_RESOURCE_TYPE = "Prometheus::RuleFile";
|
|
41
|
+
/** The IR resource type for a whole `alertmanager.yml`. */
|
|
42
|
+
export const ALERTMANAGER_RESOURCE_TYPE = "Prometheus::Alertmanager::Config";
|
|
43
|
+
|
|
44
|
+
/** `properties` of the `Prometheus::RuleFile` resource. */
|
|
45
|
+
export interface RuleFileResourceProperties {
|
|
46
|
+
file: RuleFileConfig;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `properties` of the `Prometheus::Alertmanager::Config` resource. */
|
|
50
|
+
export interface AlertmanagerResourceProperties {
|
|
51
|
+
config: AlertmanagerConfig;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What the parser read. */
|
|
55
|
+
export type ParsedPrometheusFile =
|
|
56
|
+
| { kind: "rules"; file: RuleFileConfig; warnings: string[] }
|
|
57
|
+
| { kind: "alertmanager"; config: AlertmanagerConfig; warnings: string[] };
|
|
58
|
+
|
|
59
|
+
// YAML 1.2 core types plus `<<` merge keys, which Alertmanager configs use to
|
|
60
|
+
// share notifier settings. The default schema would also turn an unquoted
|
|
61
|
+
// date into a Date, which neither Go loader does.
|
|
62
|
+
// js-yaml exports its built-in types at runtime; @types/js-yaml does not declare them.
|
|
63
|
+
const MERGE_TYPE = (jsYaml as unknown as { types: { merge: jsYaml.Type } }).types.merge;
|
|
64
|
+
const YAML_SCHEMA = jsYaml.CORE_SCHEMA.extend({ implicit: [MERGE_TYPE] });
|
|
65
|
+
|
|
66
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
67
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A label set with every value a string, as Prometheus and Alertmanager read one. */
|
|
71
|
+
function labelSet(v: unknown, at: string, warnings: string[]): Record<string, string> | undefined {
|
|
72
|
+
if (v === null || v === undefined) return undefined;
|
|
73
|
+
if (!isPlainObject(v)) {
|
|
74
|
+
warnings.push(`${at} is not a mapping; it is not carried`);
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const out: Record<string, string> = {};
|
|
78
|
+
for (const [k, x] of Object.entries(v)) out[k] = x === null ? "" : String(x);
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stringList(v: unknown, at: string, warnings: string[]): string[] | undefined {
|
|
83
|
+
if (v === null || v === undefined) return undefined;
|
|
84
|
+
if (!Array.isArray(v)) {
|
|
85
|
+
warnings.push(`${at} is not a list; it is not carried`);
|
|
86
|
+
return undefined;
|
|
87
|
+
}
|
|
88
|
+
return v.map((x) => String(x));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── rule files ──────────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
const GROUP_KEYS = ["name", "interval", "query_offset", "limit", "labels", "rules"];
|
|
94
|
+
const RULE_KEYS = ["record", "alert", "expr", "for", "keep_firing_for", "labels", "annotations"];
|
|
95
|
+
|
|
96
|
+
function parseRule(raw: unknown, at: string, warnings: string[]): RuleConfig | undefined {
|
|
97
|
+
if (!isPlainObject(raw)) {
|
|
98
|
+
warnings.push(`${at} is not a mapping; it is not carried`);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
for (const k of Object.keys(raw)) {
|
|
102
|
+
if (!RULE_KEYS.includes(k)) warnings.push(`${at}.${k} is not a rule field; it is not carried`);
|
|
103
|
+
}
|
|
104
|
+
const out: Record<string, unknown> = {};
|
|
105
|
+
for (const k of ["record", "alert", "expr", "for", "keep_firing_for"]) {
|
|
106
|
+
const v = raw[k];
|
|
107
|
+
if (v !== undefined && v !== null) out[k] = String(v);
|
|
108
|
+
}
|
|
109
|
+
const labels = labelSet(raw.labels, `${at}.labels`, warnings);
|
|
110
|
+
if (labels) out.labels = labels;
|
|
111
|
+
const annotations = labelSet(raw.annotations, `${at}.annotations`, warnings);
|
|
112
|
+
if (annotations) out.annotations = annotations;
|
|
113
|
+
return out as unknown as RuleConfig;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseRuleFile(doc: Record<string, unknown>, warnings: string[]): RuleFileConfig {
|
|
117
|
+
for (const k of Object.keys(doc)) {
|
|
118
|
+
if (k !== "groups") warnings.push(`top-level key "${k}" is not part of a rule file; it is not carried`);
|
|
119
|
+
}
|
|
120
|
+
const groups: RuleGroupConfig[] = [];
|
|
121
|
+
const list = Array.isArray(doc.groups) ? doc.groups : [];
|
|
122
|
+
list.forEach((raw, i) => {
|
|
123
|
+
const at = `groups[${i}]`;
|
|
124
|
+
if (!isPlainObject(raw)) {
|
|
125
|
+
warnings.push(`${at} is not a mapping; it is not carried`);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const name = String(raw.name ?? "");
|
|
129
|
+
const where = `group "${name}"`;
|
|
130
|
+
for (const k of Object.keys(raw)) {
|
|
131
|
+
if (!GROUP_KEYS.includes(k)) warnings.push(`${where}: "${k}" is not a rule group field; it is not carried`);
|
|
132
|
+
}
|
|
133
|
+
const group: Record<string, unknown> = { name };
|
|
134
|
+
if (raw.interval !== undefined && raw.interval !== null) group.interval = String(raw.interval);
|
|
135
|
+
if (raw.query_offset !== undefined && raw.query_offset !== null) group.query_offset = String(raw.query_offset);
|
|
136
|
+
if (raw.limit !== undefined && raw.limit !== null) group.limit = Number(raw.limit);
|
|
137
|
+
const labels = labelSet(raw.labels, `${where} labels`, warnings);
|
|
138
|
+
if (labels) group.labels = labels;
|
|
139
|
+
const rules: RuleConfig[] = [];
|
|
140
|
+
(Array.isArray(raw.rules) ? raw.rules : []).forEach((r, j) => {
|
|
141
|
+
const rule = parseRule(r, `${where} rules[${j}]`, warnings);
|
|
142
|
+
if (rule) rules.push(rule);
|
|
143
|
+
});
|
|
144
|
+
group.rules = rules;
|
|
145
|
+
groups.push(group as unknown as RuleGroupConfig);
|
|
146
|
+
});
|
|
147
|
+
return { groups };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── alertmanager.yml ────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
const AM_SECTIONS = ["global", "templates", "route", "inhibit_rules", "receivers", "time_intervals", "mute_time_intervals", "tracing"];
|
|
153
|
+
const ROUTE_KEYS = [
|
|
154
|
+
"receiver",
|
|
155
|
+
"group_by",
|
|
156
|
+
"continue",
|
|
157
|
+
"matchers",
|
|
158
|
+
"match",
|
|
159
|
+
"match_re",
|
|
160
|
+
"group_wait",
|
|
161
|
+
"group_interval",
|
|
162
|
+
"repeat_interval",
|
|
163
|
+
"mute_time_intervals",
|
|
164
|
+
"active_time_intervals",
|
|
165
|
+
"labels",
|
|
166
|
+
"routes",
|
|
167
|
+
];
|
|
168
|
+
|
|
169
|
+
/** A matcher string Alertmanager parses back to `name op value`. */
|
|
170
|
+
export function matcherString(name: string, op: "=" | "=~", value: string): string {
|
|
171
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
172
|
+
return `${name}${op}"${escaped}"`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** `match` and `match_re` maps as matcher strings, in the order written. */
|
|
176
|
+
function matchersFrom(match: unknown, matchRe: unknown, at: string, warnings: string[]): string[] {
|
|
177
|
+
const out: string[] = [];
|
|
178
|
+
for (const [value, op, key] of [
|
|
179
|
+
[match, "=", "match"],
|
|
180
|
+
[matchRe, "=~", "match_re"],
|
|
181
|
+
] as const) {
|
|
182
|
+
if (value === undefined || value === null) continue;
|
|
183
|
+
if (!isPlainObject(value)) {
|
|
184
|
+
warnings.push(`${at}.${key} is not a mapping; it is not carried`);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
for (const [k, v] of Object.entries(value)) out.push(matcherString(k, op, String(v)));
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function parseRoute(raw: unknown, at: string, warnings: string[]): RouteConfig {
|
|
193
|
+
if (!isPlainObject(raw)) {
|
|
194
|
+
warnings.push(`${at} is not a mapping; it is carried as an empty route`);
|
|
195
|
+
return {};
|
|
196
|
+
}
|
|
197
|
+
for (const k of Object.keys(raw)) {
|
|
198
|
+
if (!ROUTE_KEYS.includes(k)) warnings.push(`${at}.${k} is not a route field; it is not carried`);
|
|
199
|
+
}
|
|
200
|
+
const out: RouteConfig = {};
|
|
201
|
+
if (raw.receiver !== undefined && raw.receiver !== null) out.receiver = String(raw.receiver);
|
|
202
|
+
const groupBy = stringList(raw.group_by, `${at}.group_by`, warnings);
|
|
203
|
+
if (groupBy) out.group_by = groupBy;
|
|
204
|
+
if (typeof raw.continue === "boolean") out.continue = raw.continue;
|
|
205
|
+
const matchers = stringList(raw.matchers, `${at}.matchers`, warnings) ?? [];
|
|
206
|
+
const converted = matchersFrom(raw.match, raw.match_re, at, warnings);
|
|
207
|
+
if (converted.length > 0) {
|
|
208
|
+
warnings.push(`${at}: match/match_re are written as matchers (${converted.join(", ")}), which Alertmanager treats the same way`);
|
|
209
|
+
}
|
|
210
|
+
if (matchers.length + converted.length > 0 || raw.matchers !== undefined) out.matchers = [...matchers, ...converted];
|
|
211
|
+
for (const k of ["group_wait", "group_interval", "repeat_interval"] as const) {
|
|
212
|
+
if (raw[k] !== undefined && raw[k] !== null) out[k] = String(raw[k]);
|
|
213
|
+
}
|
|
214
|
+
const mute = stringList(raw.mute_time_intervals, `${at}.mute_time_intervals`, warnings);
|
|
215
|
+
if (mute) out.mute_time_intervals = mute;
|
|
216
|
+
const active = stringList(raw.active_time_intervals, `${at}.active_time_intervals`, warnings);
|
|
217
|
+
if (active) out.active_time_intervals = active;
|
|
218
|
+
const labels = labelSet(raw.labels, `${at}.labels`, warnings);
|
|
219
|
+
if (labels) out.labels = labels;
|
|
220
|
+
if (Array.isArray(raw.routes)) out.routes = raw.routes.map((r, i) => parseRoute(r, `${at}.routes[${i}]`, warnings));
|
|
221
|
+
else if (raw.routes !== undefined && raw.routes !== null) warnings.push(`${at}.routes is not a list; it is not carried`);
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parseInhibitRule(raw: unknown, at: string, warnings: string[]): InhibitRuleConfig | undefined {
|
|
226
|
+
if (!isPlainObject(raw)) {
|
|
227
|
+
warnings.push(`${at} is not a mapping; it is not carried`);
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
const known = ["name", "source_matchers", "source_match", "source_match_re", "target_matchers", "target_match", "target_match_re", "equal"];
|
|
231
|
+
for (const k of Object.keys(raw)) {
|
|
232
|
+
if (!known.includes(k)) warnings.push(`${at}.${k} is not an inhibit rule field; it is not carried`);
|
|
233
|
+
}
|
|
234
|
+
const out: InhibitRuleConfig = {};
|
|
235
|
+
if (raw.name !== undefined && raw.name !== null) out.name = String(raw.name);
|
|
236
|
+
for (const side of ["source", "target"] as const) {
|
|
237
|
+
const matchers = stringList(raw[`${side}_matchers`], `${at}.${side}_matchers`, warnings) ?? [];
|
|
238
|
+
const converted = matchersFrom(raw[`${side}_match`], raw[`${side}_match_re`], at, warnings);
|
|
239
|
+
if (converted.length > 0) {
|
|
240
|
+
warnings.push(
|
|
241
|
+
`${at}: ${side}_match/${side}_match_re are written as ${side}_matchers (${converted.join(", ")}), which Alertmanager treats the same way`,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
if (matchers.length + converted.length > 0 || raw[`${side}_matchers`] !== undefined) {
|
|
245
|
+
out[`${side}_matchers`] = [...matchers, ...converted];
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const equal = stringList(raw.equal, `${at}.equal`, warnings);
|
|
249
|
+
if (equal) out.equal = equal;
|
|
250
|
+
return out;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function parseTimeIntervals(raw: unknown, at: string, warnings: string[]): TimeIntervalConfig[] {
|
|
254
|
+
if (raw === undefined || raw === null) return [];
|
|
255
|
+
if (!Array.isArray(raw)) {
|
|
256
|
+
warnings.push(`${at} is not a list; it is not carried`);
|
|
257
|
+
return [];
|
|
258
|
+
}
|
|
259
|
+
const out: TimeIntervalConfig[] = [];
|
|
260
|
+
raw.forEach((t, i) => {
|
|
261
|
+
if (!isPlainObject(t) || typeof t.name !== "string") {
|
|
262
|
+
warnings.push(`${at}[${i}] has no name; it is not carried`);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
out.push({ name: t.name, time_intervals: (Array.isArray(t.time_intervals) ? t.time_intervals : []) as TimeIntervalConfig["time_intervals"] });
|
|
266
|
+
});
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const AM_VERSION = PROMETHEUS_PIN.alertmanager.version;
|
|
271
|
+
const RECEIVER_KEYS = new Set<string>(["name", "labels", ...RECEIVER_INTEGRATIONS]);
|
|
272
|
+
const GLOBAL_KEYS = new Set<string>(ALERTMANAGER_GLOBAL_FIELDS);
|
|
273
|
+
|
|
274
|
+
function parseAlertmanager(doc: Record<string, unknown>, warnings: string[]): AlertmanagerConfig {
|
|
275
|
+
for (const k of Object.keys(doc)) {
|
|
276
|
+
if (!AM_SECTIONS.includes(k)) warnings.push(`top-level section "${k}" is not one chant declares; it is not carried`);
|
|
277
|
+
}
|
|
278
|
+
const config: AlertmanagerConfig = {};
|
|
279
|
+
if (isPlainObject(doc.global)) {
|
|
280
|
+
config.global = doc.global as AlertmanagerConfig["global"];
|
|
281
|
+
const unknown = Object.keys(doc.global).filter((k) => !GLOBAL_KEYS.has(k));
|
|
282
|
+
if (unknown.length > 0) {
|
|
283
|
+
warnings.push(`global: ${unknown.join(", ")} ${unknown.length === 1 ? "is not a global field" : "are not global fields"} in Alertmanager ${AM_VERSION}; carried as data, untyped`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
else if (doc.global !== undefined && doc.global !== null) warnings.push("global is not a mapping; it is not carried");
|
|
287
|
+
const templates = stringList(doc.templates, "templates", warnings);
|
|
288
|
+
if (templates) config.templates = templates;
|
|
289
|
+
if (doc.route !== undefined && doc.route !== null) config.route = parseRoute(doc.route, "route", warnings);
|
|
290
|
+
if (Array.isArray(doc.inhibit_rules)) {
|
|
291
|
+
config.inhibit_rules = doc.inhibit_rules
|
|
292
|
+
.map((r, i) => parseInhibitRule(r, `inhibit_rules[${i}]`, warnings))
|
|
293
|
+
.filter((r): r is InhibitRuleConfig => r !== undefined);
|
|
294
|
+
} else if (doc.inhibit_rules !== undefined && doc.inhibit_rules !== null) {
|
|
295
|
+
warnings.push("inhibit_rules is not a list; it is not carried");
|
|
296
|
+
}
|
|
297
|
+
if (Array.isArray(doc.receivers)) {
|
|
298
|
+
const receivers: ReceiverConfig[] = [];
|
|
299
|
+
doc.receivers.forEach((r, i) => {
|
|
300
|
+
if (!isPlainObject(r) || r.name === undefined || r.name === null) {
|
|
301
|
+
warnings.push(`receivers[${i}] has no name; it is not carried`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const unknown = Object.keys(r).filter((k) => !RECEIVER_KEYS.has(k));
|
|
305
|
+
if (unknown.length > 0) {
|
|
306
|
+
warnings.push(`receiver "${String(r.name)}": ${unknown.join(", ")} ${unknown.length === 1 ? "is not a receiver field" : "are not receiver fields"} in Alertmanager ${AM_VERSION}; carried as data, untyped`);
|
|
307
|
+
}
|
|
308
|
+
receivers.push({ ...r, name: String(r.name) } as ReceiverConfig);
|
|
309
|
+
});
|
|
310
|
+
config.receivers = receivers;
|
|
311
|
+
} else if (doc.receivers !== undefined && doc.receivers !== null) {
|
|
312
|
+
warnings.push("receivers is not a list; it is not carried");
|
|
313
|
+
}
|
|
314
|
+
const intervals = parseTimeIntervals(doc.time_intervals, "time_intervals", warnings);
|
|
315
|
+
const legacy = parseTimeIntervals(doc.mute_time_intervals, "mute_time_intervals", warnings);
|
|
316
|
+
if (legacy.length > 0) {
|
|
317
|
+
warnings.push(
|
|
318
|
+
`the top-level mute_time_intervals (${legacy.map((t) => t.name).join(", ")}) are declared as TimeIntervals and written under time_intervals, which Alertmanager reads the same way`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
if (intervals.length + legacy.length > 0) config.time_intervals = [...intervals, ...legacy];
|
|
322
|
+
if (isPlainObject(doc.tracing)) config.tracing = doc.tracing as AlertmanagerConfig["tracing"];
|
|
323
|
+
else if (doc.tracing !== undefined && doc.tracing !== null) warnings.push("tracing is not a mapping; it is not carried");
|
|
324
|
+
return config;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ── entry points ────────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
/** Parse a rule file or `alertmanager.yml`, telling them apart by shape. */
|
|
330
|
+
export function parsePrometheusYaml(content: string): ParsedPrometheusFile {
|
|
331
|
+
const warnings: string[] = [];
|
|
332
|
+
const doc = content.trim() === "" ? {} : jsYaml.load(content, { schema: YAML_SCHEMA });
|
|
333
|
+
if (!isPlainObject(doc)) {
|
|
334
|
+
throw new Error("a Prometheus rule file or alertmanager.yml is a YAML mapping; this document is not one");
|
|
335
|
+
}
|
|
336
|
+
if (looksLikeRuleFile(doc)) return { kind: "rules", file: parseRuleFile(doc, warnings), warnings };
|
|
337
|
+
if (looksLikeAlertmanagerConfig(doc)) return { kind: "alertmanager", config: parseAlertmanager(doc, warnings), warnings };
|
|
338
|
+
if ("groups" in doc) {
|
|
339
|
+
// A rule file with a malformed group still imports; the bad parts are named.
|
|
340
|
+
return { kind: "rules", file: parseRuleFile(doc, warnings), warnings };
|
|
341
|
+
}
|
|
342
|
+
throw new Error(
|
|
343
|
+
"this YAML is neither a Prometheus rule file (groups: of named rule lists) nor an alertmanager.yml (route: or receivers:)",
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The rule file and `alertmanager.yml` parser `chant import` runs. */
|
|
348
|
+
export class PrometheusParser implements TemplateParser {
|
|
349
|
+
parse(content: string): TemplateIR {
|
|
350
|
+
const parsed = parsePrometheusYaml(content);
|
|
351
|
+
const resource =
|
|
352
|
+
parsed.kind === "rules"
|
|
353
|
+
? {
|
|
354
|
+
logicalId: "ruleFile",
|
|
355
|
+
type: RULE_FILE_RESOURCE_TYPE,
|
|
356
|
+
properties: { file: parsed.file } satisfies RuleFileResourceProperties as unknown as Record<string, unknown>,
|
|
357
|
+
}
|
|
358
|
+
: {
|
|
359
|
+
logicalId: "alertmanager",
|
|
360
|
+
type: ALERTMANAGER_RESOURCE_TYPE,
|
|
361
|
+
properties: { config: parsed.config } satisfies AlertmanagerResourceProperties as unknown as Record<string, unknown>,
|
|
362
|
+
};
|
|
363
|
+
return { resources: [resource], parameters: [], warnings: parsed.warnings };
|
|
364
|
+
}
|
|
365
|
+
}
|