@intentius/chant-lexicon-prometheus 0.96.0 → 0.98.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 +5 -0
- 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/generator.d.ts +35 -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/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 +21 -0
- package/dist/model.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/dist/rules/literal-credential.ts +17 -11
- 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/generated-types.e2e.test.ts +75 -0
- package/src/import/generator.test.ts +121 -0
- package/src/import/generator.ts +570 -0
- package/src/import/parser.test.ts +120 -0
- package/src/import/parser.ts +348 -0
- package/src/import/roundtrip.test.ts +321 -0
- package/src/import/slo.ts +175 -0
- package/src/import/testdata/alertmanager-full.yml +89 -0
- package/src/import/testdata/fixtures.ts +114 -0
- package/src/import/testdata/rules-full.yml +42 -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/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/lint/rules/literal-credential.ts +17 -11
- 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 +28 -0
- package/src/model.ts +22 -0
- package/src/plugin.ts +10 -0
- package/src/skills/chant-prometheus-alertmanager.md +4 -0
- package/src/skills/chant-prometheus.md +11 -0
|
@@ -0,0 +1,570 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `TemplateIR` -> TypeScript, for `chant import`.
|
|
3
|
+
*
|
|
4
|
+
* Writes the files the way the lexicon's examples are written: nested
|
|
5
|
+
* settings lifted into named consts typed by the model (COR001), at most
|
|
6
|
+
* eight declarables per module (COR009), and an `export { ... }` list at the
|
|
7
|
+
* end of each module (COR004 flags an exported declaration nothing in its
|
|
8
|
+
* file uses).
|
|
9
|
+
*
|
|
10
|
+
* A rule file becomes `rules.ts`: per group, its rules as a `Rule[]` const
|
|
11
|
+
* of plain objects, the same shape `ruleGroupConfig` emits, and a
|
|
12
|
+
* `RuleGroup`. A group an `Slo()` built is written as that `Slo` call in
|
|
13
|
+
* `slos.ts` instead (see ./slo.ts).
|
|
14
|
+
*
|
|
15
|
+
* An `alertmanager.yml` becomes `receivers.ts`, `time-intervals.ts`,
|
|
16
|
+
* `routes.ts` (the root `Route`, its children as `RouteProps` objects),
|
|
17
|
+
* `inhibit-rules.ts` and `settings.ts`. Routes name their receivers and time
|
|
18
|
+
* intervals by the declared variable, imported from the module that
|
|
19
|
+
* declares it; a name nothing declares stays a string, and PROM201 / PROM204
|
|
20
|
+
* report it.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { GeneratedFile, TypeScriptGenerator } from "@intentius/chant/import/generator";
|
|
24
|
+
import type { TemplateIR } from "@intentius/chant/import/parser";
|
|
25
|
+
import type {
|
|
26
|
+
AlertmanagerConfig,
|
|
27
|
+
InhibitRuleConfig,
|
|
28
|
+
ReceiverConfig,
|
|
29
|
+
RouteConfig,
|
|
30
|
+
RuleFileConfig,
|
|
31
|
+
RuleGroupConfig,
|
|
32
|
+
TimeIntervalConfig,
|
|
33
|
+
} from "../model";
|
|
34
|
+
import {
|
|
35
|
+
ALERTMANAGER_RESOURCE_TYPE,
|
|
36
|
+
RULE_FILE_RESOURCE_TYPE,
|
|
37
|
+
type AlertmanagerResourceProperties,
|
|
38
|
+
type RuleFileResourceProperties,
|
|
39
|
+
} from "./parser";
|
|
40
|
+
import { recognizeSlo } from "./slo";
|
|
41
|
+
|
|
42
|
+
const PACKAGE = "@intentius/chant-lexicon-prometheus";
|
|
43
|
+
|
|
44
|
+
const RESERVED = new Set(
|
|
45
|
+
(
|
|
46
|
+
"break case catch class const continue debugger default delete do else enum export extends false finally for " +
|
|
47
|
+
"function if import in instanceof new null return super switch this throw true try typeof var void while with " +
|
|
48
|
+
"yield let static implements interface package private protected public await arguments eval undefined"
|
|
49
|
+
).split(" "),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
53
|
+
|
|
54
|
+
function words(text: string): string[] {
|
|
55
|
+
return text.split(/[^A-Za-z0-9]+/).filter((w) => w !== "");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function camel(text: string): string {
|
|
59
|
+
return words(text)
|
|
60
|
+
.map((w, i) => (i === 0 ? w.charAt(0).toLowerCase() + w.slice(1) : w.charAt(0).toUpperCase() + w.slice(1)))
|
|
61
|
+
.join("");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function pascal(text: string): string {
|
|
65
|
+
return words(text)
|
|
66
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
67
|
+
.join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── TypeScript literals ──────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/** An expression written as is: a reference to a declared variable. */
|
|
73
|
+
class Code {
|
|
74
|
+
constructor(readonly code: string) {}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const MAX_LINE = 100;
|
|
78
|
+
|
|
79
|
+
function propertyKey(k: string): string {
|
|
80
|
+
return IDENT.test(k) ? k : JSON.stringify(k);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A string literal: double-quoted, single-quoted when that saves escaping
|
|
85
|
+
* `"` (PromQL label matchers read better), or a template literal when the
|
|
86
|
+
* string spans lines, as a multi-line `expr: |` does.
|
|
87
|
+
*/
|
|
88
|
+
function stringLiteral(s: string): string {
|
|
89
|
+
if (s.includes("\n")) return `\`${s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${")}\``;
|
|
90
|
+
const double = JSON.stringify(s);
|
|
91
|
+
if (!s.includes('"') || s.includes("'")) return double;
|
|
92
|
+
return `'${double.slice(1, -1).replace(/\\"/g, '"')}'`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function scalarLiteral(v: unknown): string {
|
|
96
|
+
if (v instanceof Code) return v.code;
|
|
97
|
+
if (v === null) return "null";
|
|
98
|
+
if (typeof v === "string") return stringLiteral(v);
|
|
99
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
100
|
+
return JSON.stringify(String(v));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function inline(v: unknown): string {
|
|
104
|
+
if (Array.isArray(v)) return `[${v.map(inline).join(", ")}]`;
|
|
105
|
+
if (typeof v === "object" && v !== null && !(v instanceof Code)) {
|
|
106
|
+
const items = Object.entries(v)
|
|
107
|
+
.filter(([, x]) => x !== undefined)
|
|
108
|
+
.map(([k, x]) => `${propertyKey(k)}: ${inline(x)}`);
|
|
109
|
+
return items.length === 0 ? "{}" : `{ ${items.join(", ")} }`;
|
|
110
|
+
}
|
|
111
|
+
return scalarLiteral(v);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A value as a TypeScript expression, starting at column `col`, indented by `indent` spaces. */
|
|
115
|
+
export function tsLiteral(v: unknown, indent: number, col = indent): string {
|
|
116
|
+
const one = inline(v);
|
|
117
|
+
if (!one.includes("\n") && col + one.length + 2 <= MAX_LINE) return one;
|
|
118
|
+
const pad = " ".repeat(indent + 2);
|
|
119
|
+
const close = " ".repeat(indent);
|
|
120
|
+
if (Array.isArray(v)) {
|
|
121
|
+
if (v.length === 0) return "[]";
|
|
122
|
+
return `[\n${v.map((x) => `${pad}${tsLiteral(x, indent + 2)},`).join("\n")}\n${close}]`;
|
|
123
|
+
}
|
|
124
|
+
if (typeof v === "object" && v !== null && !(v instanceof Code)) {
|
|
125
|
+
const lines: string[] = [];
|
|
126
|
+
for (const [k, x] of Object.entries(v)) {
|
|
127
|
+
if (x === undefined) continue;
|
|
128
|
+
const key = `${pad}${propertyKey(k)}: `;
|
|
129
|
+
lines.push(`${key}${tsLiteral(x, indent + 2, key.length)},`);
|
|
130
|
+
}
|
|
131
|
+
return lines.length === 0 ? "{}" : `{\n${lines.join("\n")}\n${close}}`;
|
|
132
|
+
}
|
|
133
|
+
return scalarLiteral(v);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** An object literal whose entries are already-rendered expressions, starting at column `col`. */
|
|
137
|
+
function objectOf(entries: Array<[string, string]>, indent: number, col: number): string {
|
|
138
|
+
if (entries.length === 0) return "{}";
|
|
139
|
+
const prop = ([k, v]: [string, string]) =>
|
|
140
|
+
k === v && IDENT.test(k) ? k : k.startsWith("...") ? k : `${propertyKey(k)}: ${v}`;
|
|
141
|
+
const one = `{ ${entries.map(prop).join(", ")} }`;
|
|
142
|
+
if (!one.includes("\n") && col + one.length + 2 <= MAX_LINE) return one;
|
|
143
|
+
const pad = " ".repeat(indent + 2);
|
|
144
|
+
return `{\n${entries.map((e) => `${pad}${prop(e)},`).join("\n")}\n${" ".repeat(indent)}}`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** True for a value COR001 wants in a named const rather than inline in a constructor. */
|
|
148
|
+
function needsHoist(v: unknown): boolean {
|
|
149
|
+
if (v instanceof Code) return false;
|
|
150
|
+
if (Array.isArray(v)) return v.some((x) => typeof x === "object" && x !== null && !(x instanceof Code));
|
|
151
|
+
return typeof v === "object" && v !== null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// ── modules ──────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
/** COR009's default: at most this many declarables per file. */
|
|
157
|
+
const DECLARABLES_PER_FILE = 8;
|
|
158
|
+
|
|
159
|
+
/** One generated module: what it imports, declares and exports. */
|
|
160
|
+
class Module {
|
|
161
|
+
readonly values = new Set<string>();
|
|
162
|
+
readonly types = new Set<string>();
|
|
163
|
+
readonly local = new Map<string, Set<string>>();
|
|
164
|
+
readonly body: string[] = [];
|
|
165
|
+
readonly exports: string[] = [];
|
|
166
|
+
constructor(
|
|
167
|
+
readonly path: string,
|
|
168
|
+
readonly summary: string,
|
|
169
|
+
) {}
|
|
170
|
+
|
|
171
|
+
use(name: string, from?: Module): void {
|
|
172
|
+
if (!from) this.values.add(name);
|
|
173
|
+
else if (from !== this) {
|
|
174
|
+
const spec = `./${from.path.replace(/\.ts$/, "")}`;
|
|
175
|
+
const set = this.local.get(spec) ?? new Set<string>();
|
|
176
|
+
set.add(name);
|
|
177
|
+
this.local.set(spec, set);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
block(lines: string[]): void {
|
|
182
|
+
if (this.body.length > 0) this.body.push("");
|
|
183
|
+
this.body.push(...lines);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
render(): string {
|
|
187
|
+
const byName = (a: string, b: string) => a.localeCompare(b, "en", { sensitivity: "base" });
|
|
188
|
+
const names = [...this.values, ...[...this.types].map((t) => `type ${t}`)].sort((a, b) =>
|
|
189
|
+
byName(a.replace(/^type /, ""), b.replace(/^type /, "")),
|
|
190
|
+
);
|
|
191
|
+
const lines = [`/** ${this.summary} */`];
|
|
192
|
+
if (names.length > 0) {
|
|
193
|
+
const one = `import { ${names.join(", ")} } from "${PACKAGE}";`;
|
|
194
|
+
lines.push(one.length <= MAX_LINE ? one : `import {\n${names.map((n) => ` ${n},`).join("\n")}\n} from "${PACKAGE}";`);
|
|
195
|
+
}
|
|
196
|
+
for (const [spec, set] of [...this.local].sort(([a], [b]) => byName(a, b))) {
|
|
197
|
+
const list = [...set].sort(byName);
|
|
198
|
+
const one = `import { ${list.join(", ")} } from "${spec}";`;
|
|
199
|
+
lines.push(one.length <= MAX_LINE ? one : `import {\n${list.map((n) => ` ${n},`).join("\n")}\n} from "${spec}";`);
|
|
200
|
+
}
|
|
201
|
+
lines.push("", ...this.body);
|
|
202
|
+
if (this.exports.length > 0) {
|
|
203
|
+
const one = `export { ${this.exports.join(", ")} };`;
|
|
204
|
+
lines.push("", one.length <= MAX_LINE ? one : `export {\n${this.exports.map((e) => ` ${e},`).join("\n")}\n};`);
|
|
205
|
+
}
|
|
206
|
+
return `${lines.join("\n")}\n`;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Split `items` into modules of at most `DECLARABLES_PER_FILE`, named `base.ts` or `base-1.ts`, `base-2.ts`, … */
|
|
211
|
+
function chunk<T>(items: T[], base: string, summary: string): Array<[Module, T[]]> {
|
|
212
|
+
if (items.length === 0) return [];
|
|
213
|
+
if (items.length <= DECLARABLES_PER_FILE) return [[new Module(`${base}.ts`, summary), items]];
|
|
214
|
+
const out: Array<[Module, T[]]> = [];
|
|
215
|
+
for (let i = 0; i < items.length; i += DECLARABLES_PER_FILE) {
|
|
216
|
+
out.push([new Module(`${base}-${out.length + 1}.ts`, summary), items.slice(i, i + DECLARABLES_PER_FILE)]);
|
|
217
|
+
}
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Variable names, unique across every module of one import. */
|
|
222
|
+
class Names {
|
|
223
|
+
private readonly taken = new Set<string>();
|
|
224
|
+
constructor(reserved: string[]) {
|
|
225
|
+
for (const r of reserved) this.taken.add(r);
|
|
226
|
+
}
|
|
227
|
+
/** `base`, or `base` + `suffix` when that is taken or not an identifier, then numbered. */
|
|
228
|
+
claim(text: string, suffix: string, fallback: string): string {
|
|
229
|
+
return this.claimWith(text, suffix, fallback, []);
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Like `claim`, and also reserves `name + member` for each member: a
|
|
233
|
+
* composite's members are declared under those names.
|
|
234
|
+
*/
|
|
235
|
+
claimWith(text: string, suffix: string, fallback: string, members: string[]): string {
|
|
236
|
+
const free = (n: string) => !this.taken.has(n) && !RESERVED.has(n) && members.every((m) => !this.taken.has(`${n}${m}`));
|
|
237
|
+
let base = camel(text);
|
|
238
|
+
if (base === "" || /^[0-9]/.test(base)) base = camel(`${fallback} ${text}`);
|
|
239
|
+
if (!free(base)) base = `${base}${suffix}`;
|
|
240
|
+
let out = base;
|
|
241
|
+
for (let n = 2; !free(out); n++) out = `${base}${n}`;
|
|
242
|
+
this.taken.add(out);
|
|
243
|
+
for (const m of members) this.taken.add(`${out}${m}`);
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The lexicon's exports a generated module may import; no variable takes one of these names. */
|
|
249
|
+
const LEXICON_NAMES = [
|
|
250
|
+
"RuleGroup",
|
|
251
|
+
"Slo",
|
|
252
|
+
"Rule",
|
|
253
|
+
"LabelSet",
|
|
254
|
+
"Route",
|
|
255
|
+
"RouteProps",
|
|
256
|
+
"Receiver",
|
|
257
|
+
"TimeInterval",
|
|
258
|
+
"TimePeriodConfig",
|
|
259
|
+
"InhibitRule",
|
|
260
|
+
"AlertmanagerSettings",
|
|
261
|
+
"AlertmanagerGlobalConfig",
|
|
262
|
+
"AlertmanagerTracingConfig",
|
|
263
|
+
"WebhookConfig",
|
|
264
|
+
"EmailConfig",
|
|
265
|
+
"SlackConfig",
|
|
266
|
+
"PagerDutyConfig",
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
// ── rule files ───────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/** Generate the TypeScript declaring one rule file. */
|
|
272
|
+
export function generateRuleFileFiles(file: RuleFileConfig): GeneratedFile[] {
|
|
273
|
+
const names = new Names(LEXICON_NAMES);
|
|
274
|
+
const slos: Array<{ group: RuleGroupConfig; props: NonNullable<ReturnType<typeof recognizeSlo>> }> = [];
|
|
275
|
+
const groups: RuleGroupConfig[] = [];
|
|
276
|
+
for (const g of file.groups) {
|
|
277
|
+
const props = recognizeSlo(g);
|
|
278
|
+
if (props) slos.push({ group: g, props });
|
|
279
|
+
else groups.push(g);
|
|
280
|
+
}
|
|
281
|
+
// Each Slo declares its rule group as `<name>Rules`, so those names are claimed first.
|
|
282
|
+
const sloVars = slos.map(({ props }) => names.claimWith(props.name, "Slo", "slo", ["Rules"]));
|
|
283
|
+
|
|
284
|
+
const modules: Module[] = [];
|
|
285
|
+
for (const [mod, items] of chunk(groups, "rules", "Rule groups")) {
|
|
286
|
+
modules.push(mod);
|
|
287
|
+
mod.use("RuleGroup");
|
|
288
|
+
for (const g of items) {
|
|
289
|
+
const v = names.claim(g.name, "Group", "group");
|
|
290
|
+
const lines: string[] = [];
|
|
291
|
+
const entries: Array<[string, string]> = [["name", stringLiteral(g.name)]];
|
|
292
|
+
if (g.interval !== undefined) entries.push(["interval", stringLiteral(g.interval)]);
|
|
293
|
+
if (g.query_offset !== undefined) entries.push(["query_offset", stringLiteral(g.query_offset)]);
|
|
294
|
+
if (g.limit !== undefined) entries.push(["limit", String(g.limit)]);
|
|
295
|
+
if (g.labels !== undefined) {
|
|
296
|
+
mod.types.add("LabelSet");
|
|
297
|
+
const c = names.claim(`${v} labels`, "", "group");
|
|
298
|
+
const head = `const ${c}: LabelSet = `;
|
|
299
|
+
lines.push(`${head}${tsLiteral(g.labels, 0, head.length)};`);
|
|
300
|
+
entries.push(["labels", c]);
|
|
301
|
+
}
|
|
302
|
+
mod.types.add("Rule");
|
|
303
|
+
const rulesConst = names.claim(`${v} rules`, "", "group");
|
|
304
|
+
const head = `const ${rulesConst}: Rule[] = `;
|
|
305
|
+
lines.push(`${head}${tsLiteral(g.rules, 0, head.length)};`);
|
|
306
|
+
entries.push(["rules", rulesConst]);
|
|
307
|
+
const ctor = `const ${v} = new RuleGroup(`;
|
|
308
|
+
lines.push(`${ctor}${objectOf(entries, 0, ctor.length)});`);
|
|
309
|
+
mod.block(lines);
|
|
310
|
+
mod.exports.push(v);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
for (const [mod, items] of chunk(slos, "slos", "Service level objectives, each built by Slo() to its rule group")) {
|
|
315
|
+
modules.push(mod);
|
|
316
|
+
mod.use("Slo");
|
|
317
|
+
for (const { props } of items) {
|
|
318
|
+
const v = sloVars[slos.findIndex((s) => s.props === props)];
|
|
319
|
+
const head = `const ${v} = Slo(`;
|
|
320
|
+
mod.block([`${head}${tsLiteral(props, 0, head.length)});`]);
|
|
321
|
+
mod.exports.push(v);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return modules.map((m) => ({ path: m.path, content: m.render() }));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ── alertmanager.yml ─────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
/** Receiver fields the lexicon types, with the type each list is declared as. */
|
|
331
|
+
const RECEIVER_FIELD_TYPES: Record<string, string> = {
|
|
332
|
+
webhook_configs: "WebhookConfig[]",
|
|
333
|
+
email_configs: "EmailConfig[]",
|
|
334
|
+
slack_configs: "SlackConfig[]",
|
|
335
|
+
pagerduty_configs: "PagerDutyConfig[]",
|
|
336
|
+
labels: "LabelSet",
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
/** `global:` fields the lexicon types. */
|
|
340
|
+
const GLOBAL_FIELDS = new Set([
|
|
341
|
+
"resolve_timeout",
|
|
342
|
+
"smtp_from",
|
|
343
|
+
"smtp_smarthost",
|
|
344
|
+
"smtp_hello",
|
|
345
|
+
"smtp_auth_username",
|
|
346
|
+
"smtp_auth_password",
|
|
347
|
+
"smtp_auth_password_file",
|
|
348
|
+
"smtp_auth_secret",
|
|
349
|
+
"smtp_auth_secret_file",
|
|
350
|
+
"smtp_auth_identity",
|
|
351
|
+
"smtp_require_tls",
|
|
352
|
+
"slack_api_url",
|
|
353
|
+
"slack_api_url_file",
|
|
354
|
+
"pagerduty_url",
|
|
355
|
+
"http_config",
|
|
356
|
+
]);
|
|
357
|
+
|
|
358
|
+
/** A comment naming the fields carried as data, wrapped at the line limit. */
|
|
359
|
+
function untypedComment(what: string, keys: string[]): string[] {
|
|
360
|
+
const text = `${what} ${keys.join(", ")} ${keys.length === 1 ? "is" : "are"} not typed by this lexicon, so ${keys.length === 1 ? "it is" : "they are"} carried as data.`;
|
|
361
|
+
const lines: string[] = [];
|
|
362
|
+
let cur = "//";
|
|
363
|
+
for (const w of text.split(" ")) {
|
|
364
|
+
if (cur.length + 1 + w.length > MAX_LINE) {
|
|
365
|
+
lines.push(cur);
|
|
366
|
+
cur = "//";
|
|
367
|
+
}
|
|
368
|
+
cur += ` ${w}`;
|
|
369
|
+
}
|
|
370
|
+
lines.push(cur);
|
|
371
|
+
return lines;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Generate the TypeScript declaring one `alertmanager.yml`. */
|
|
375
|
+
export function generateAlertmanagerFiles(config: AlertmanagerConfig): GeneratedFile[] {
|
|
376
|
+
const names = new Names([...LEXICON_NAMES, "global", "tracing"]);
|
|
377
|
+
const modules: Module[] = [];
|
|
378
|
+
const receiverVars = new Map<string, { v: string; mod: Module }>();
|
|
379
|
+
const intervalVars = new Map<string, { v: string; mod: Module }>();
|
|
380
|
+
|
|
381
|
+
// Receivers.
|
|
382
|
+
for (const [mod, items] of chunk(config.receivers ?? [], "receivers", "Receivers: where notifications go")) {
|
|
383
|
+
modules.push(mod);
|
|
384
|
+
mod.use("Receiver");
|
|
385
|
+
for (const r of items) {
|
|
386
|
+
const v = names.claim(r.name, "Receiver", "receiver");
|
|
387
|
+
receiverVars.set(r.name, { v, mod });
|
|
388
|
+
const lines: string[] = [];
|
|
389
|
+
const entries: Array<[string, string]> = [["name", stringLiteral(r.name)]];
|
|
390
|
+
const untyped: Record<string, unknown> = {};
|
|
391
|
+
for (const [key, value] of Object.entries(r as ReceiverConfig & Record<string, unknown>)) {
|
|
392
|
+
if (key === "name" || value === undefined) continue;
|
|
393
|
+
const type = RECEIVER_FIELD_TYPES[key];
|
|
394
|
+
if (!type) {
|
|
395
|
+
untyped[key] = value;
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
if (!needsHoist(value)) {
|
|
399
|
+
entries.push([key, tsLiteral(value, 2, 4 + key.length)]);
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
mod.types.add(type.replace(/\[\]$/, ""));
|
|
403
|
+
const c = names.claim(`${v} ${key.replace(/_configs$/, "")}`, "Config", "receiver");
|
|
404
|
+
const head = `const ${c}: ${type} = `;
|
|
405
|
+
lines.push(`${head}${tsLiteral(value, 0, head.length)};`);
|
|
406
|
+
entries.push([key, c]);
|
|
407
|
+
}
|
|
408
|
+
if (Object.keys(untyped).length > 0) {
|
|
409
|
+
const c = names.claim(`${v} untyped`, "", "receiver");
|
|
410
|
+
lines.push(...untypedComment(`Receiver "${r.name}":`, Object.keys(untyped)));
|
|
411
|
+
const head = `const ${c} = `;
|
|
412
|
+
lines.push(`${head}${tsLiteral(untyped, 0, head.length)};`);
|
|
413
|
+
entries.push([`...${c}`, c]);
|
|
414
|
+
}
|
|
415
|
+
const ctor = `const ${v} = new Receiver(`;
|
|
416
|
+
lines.push(`${ctor}${objectOf(entries, 0, ctor.length)});`);
|
|
417
|
+
mod.block(lines);
|
|
418
|
+
mod.exports.push(v);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Time intervals.
|
|
423
|
+
for (const [mod, items] of chunk(config.time_intervals ?? [], "time-intervals", "Time intervals routes mute or activate on")) {
|
|
424
|
+
modules.push(mod);
|
|
425
|
+
mod.use("TimeInterval");
|
|
426
|
+
for (const t of items as TimeIntervalConfig[]) {
|
|
427
|
+
const v = names.claim(t.name, "Interval", "interval");
|
|
428
|
+
intervalVars.set(t.name, { v, mod });
|
|
429
|
+
const lines: string[] = [];
|
|
430
|
+
let periods = tsLiteral(t.time_intervals, 2, 20);
|
|
431
|
+
if (needsHoist(t.time_intervals)) {
|
|
432
|
+
mod.types.add("TimePeriodConfig");
|
|
433
|
+
const c = names.claim(`${v} periods`, "", "interval");
|
|
434
|
+
const head = `const ${c}: TimePeriodConfig[] = `;
|
|
435
|
+
lines.push(`${head}${tsLiteral(t.time_intervals, 0, head.length)};`);
|
|
436
|
+
periods = c;
|
|
437
|
+
}
|
|
438
|
+
const ctor = `const ${v} = new TimeInterval(`;
|
|
439
|
+
lines.push(`${ctor}${objectOf([["name", stringLiteral(t.name)], ["time_intervals", periods]], 0, ctor.length)});`);
|
|
440
|
+
mod.block(lines);
|
|
441
|
+
mod.exports.push(v);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// The routing tree: one Route, its children as plain RouteProps.
|
|
446
|
+
if (config.route) {
|
|
447
|
+
const mod = new Module("routes.ts", "The routing tree: the root route and its children, tried in order");
|
|
448
|
+
modules.push(mod);
|
|
449
|
+
mod.use("Route");
|
|
450
|
+
const receiverRef = (name: string): unknown => {
|
|
451
|
+
const r = receiverVars.get(name);
|
|
452
|
+
if (!r) return name;
|
|
453
|
+
mod.use(r.v, r.mod);
|
|
454
|
+
return new Code(r.v);
|
|
455
|
+
};
|
|
456
|
+
const intervalRef = (name: string): unknown => {
|
|
457
|
+
const t = intervalVars.get(name);
|
|
458
|
+
if (!t) return name;
|
|
459
|
+
mod.use(t.v, t.mod);
|
|
460
|
+
return new Code(t.v);
|
|
461
|
+
};
|
|
462
|
+
const withRefs = (route: RouteConfig): Record<string, unknown> => {
|
|
463
|
+
const out: Record<string, unknown> = {};
|
|
464
|
+
for (const [k, x] of Object.entries(route)) {
|
|
465
|
+
if (x === undefined) continue;
|
|
466
|
+
if (k === "receiver") out[k] = receiverRef(x as string);
|
|
467
|
+
else if (k === "mute_time_intervals" || k === "active_time_intervals") out[k] = (x as string[]).map(intervalRef);
|
|
468
|
+
else if (k === "routes") out[k] = (x as RouteConfig[]).map(withRefs);
|
|
469
|
+
else out[k] = x;
|
|
470
|
+
}
|
|
471
|
+
return out;
|
|
472
|
+
};
|
|
473
|
+
const root = withRefs(config.route);
|
|
474
|
+
const v = names.claim("root", "Route", "route");
|
|
475
|
+
const lines: string[] = [];
|
|
476
|
+
const entries: Array<[string, string]> = [];
|
|
477
|
+
for (const [key, value] of Object.entries(root)) {
|
|
478
|
+
if (!needsHoist(value)) {
|
|
479
|
+
entries.push([key, tsLiteral(value, 2, 4 + key.length)]);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const type = key === "routes" ? "RouteProps[]" : "LabelSet";
|
|
483
|
+
mod.types.add(type.replace(/\[\]$/, ""));
|
|
484
|
+
const c = names.claim(`${v} ${key === "routes" ? "children" : key}`, "", "route");
|
|
485
|
+
const head = `const ${c}: ${type} = `;
|
|
486
|
+
lines.push(`${head}${tsLiteral(value, 0, head.length)};`);
|
|
487
|
+
entries.push([key, c]);
|
|
488
|
+
}
|
|
489
|
+
const ctor = `const ${v} = new Route(`;
|
|
490
|
+
lines.push(`${ctor}${objectOf(entries, 0, ctor.length)});`);
|
|
491
|
+
mod.block(lines);
|
|
492
|
+
mod.exports.push(v);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Inhibit rules.
|
|
496
|
+
let inhibitCount = 0;
|
|
497
|
+
for (const [mod, items] of chunk(config.inhibit_rules ?? [], "inhibit-rules", "Inhibit rules: alerts that mute others while they fire")) {
|
|
498
|
+
modules.push(mod);
|
|
499
|
+
mod.use("InhibitRule");
|
|
500
|
+
items.forEach((rule: InhibitRuleConfig) => {
|
|
501
|
+
inhibitCount++;
|
|
502
|
+
const v = names.claim(rule.name ?? `inhibit rule ${inhibitCount}`, "Rule", "inhibit");
|
|
503
|
+
const entries: Array<[string, string]> = Object.entries(rule)
|
|
504
|
+
.filter(([, x]) => x !== undefined)
|
|
505
|
+
.map(([k, x]) => [k, tsLiteral(x, 2, 4 + k.length)]);
|
|
506
|
+
const ctor = `const ${v} = new InhibitRule(`;
|
|
507
|
+
mod.block([`${ctor}${objectOf(entries, 0, ctor.length)});`]);
|
|
508
|
+
mod.exports.push(v);
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// global, templates and tracing.
|
|
513
|
+
if (config.global !== undefined || config.templates !== undefined || config.tracing !== undefined) {
|
|
514
|
+
const mod = new Module("settings.ts", "global settings, notification templates and tracing");
|
|
515
|
+
modules.push(mod);
|
|
516
|
+
mod.use("AlertmanagerSettings");
|
|
517
|
+
const lines: string[] = [];
|
|
518
|
+
const entries: Array<[string, string]> = [];
|
|
519
|
+
if (config.global !== undefined) {
|
|
520
|
+
const typed: Array<[string, unknown]> = [];
|
|
521
|
+
const untyped: Record<string, unknown> = {};
|
|
522
|
+
for (const [k, x] of Object.entries(config.global)) {
|
|
523
|
+
if (GLOBAL_FIELDS.has(k)) typed.push([k, x]);
|
|
524
|
+
else untyped[k] = x;
|
|
525
|
+
}
|
|
526
|
+
mod.types.add("AlertmanagerGlobalConfig");
|
|
527
|
+
if (Object.keys(untyped).length > 0) {
|
|
528
|
+
lines.push(...untypedComment("global:", Object.keys(untyped)));
|
|
529
|
+
const head = "const globalUntyped = ";
|
|
530
|
+
lines.push(`${head}${tsLiteral(untyped, 0, head.length)};`);
|
|
531
|
+
const body: Array<[string, string]> = [["...globalUntyped", "globalUntyped"], ...typed.map(([k, x]): [string, string] => [k, tsLiteral(x, 2, 4 + k.length)])];
|
|
532
|
+
lines.push(`const global: AlertmanagerGlobalConfig = ${objectOf(body, 0, 40)};`);
|
|
533
|
+
} else {
|
|
534
|
+
const head = "const global: AlertmanagerGlobalConfig = ";
|
|
535
|
+
lines.push(`${head}${tsLiteral(Object.fromEntries(typed), 0, head.length)};`);
|
|
536
|
+
}
|
|
537
|
+
entries.push(["global", "global"]);
|
|
538
|
+
}
|
|
539
|
+
if (config.templates !== undefined) entries.push(["templates", tsLiteral(config.templates, 2, 15)]);
|
|
540
|
+
if (config.tracing !== undefined) {
|
|
541
|
+
mod.types.add("AlertmanagerTracingConfig");
|
|
542
|
+
const head = "const tracing: AlertmanagerTracingConfig = ";
|
|
543
|
+
lines.push(`${head}${tsLiteral(config.tracing, 0, head.length)};`);
|
|
544
|
+
entries.push(["tracing", "tracing"]);
|
|
545
|
+
}
|
|
546
|
+
const v = names.claim("settings", "", "settings");
|
|
547
|
+
const ctor = `const ${v} = new AlertmanagerSettings(`;
|
|
548
|
+
lines.push(`${ctor}${objectOf(entries, 0, ctor.length)});`);
|
|
549
|
+
mod.block(lines);
|
|
550
|
+
mod.exports.push(v);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
return modules.map((m) => ({ path: m.path, content: m.render() }));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The rule file and `alertmanager.yml` TypeScript generator `chant import` runs. */
|
|
557
|
+
export class PrometheusGenerator implements TypeScriptGenerator {
|
|
558
|
+
generate(ir: TemplateIR): GeneratedFile[] {
|
|
559
|
+
const files: GeneratedFile[] = [];
|
|
560
|
+
for (const r of ir.resources) {
|
|
561
|
+
if (r.type === RULE_FILE_RESOURCE_TYPE) {
|
|
562
|
+
files.push(...generateRuleFileFiles((r.properties as unknown as RuleFileResourceProperties).file));
|
|
563
|
+
} else if (r.type === ALERTMANAGER_RESOURCE_TYPE) {
|
|
564
|
+
files.push(...generateAlertmanagerFiles((r.properties as unknown as AlertmanagerResourceProperties).config));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
// Core writes the first file of a generate() call as a whole module for some layouts; never hand it nothing.
|
|
568
|
+
return files.length > 0 ? files : [{ path: "rules.ts", content: "// The imported file declares nothing.\n" }];
|
|
569
|
+
}
|
|
570
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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
|
+
});
|
|
100
|
+
|
|
101
|
+
test("dates and times stay strings, as the Go loaders read them", () => {
|
|
102
|
+
const parsed = parsePrometheusYaml(lines("receivers: [{ name: x }]", "time_intervals: [{ name: t, time_intervals: [{ years: [2026-01-01] }] }]"));
|
|
103
|
+
if (parsed.kind !== "alertmanager") throw new Error("expected alertmanager.yml");
|
|
104
|
+
expect(parsed.config.time_intervals?.[0].time_intervals[0].years).toEqual(["2026-01-01"]);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("PrometheusParser", () => {
|
|
109
|
+
test("carries the whole file as one IR resource", () => {
|
|
110
|
+
const rules = new PrometheusParser().parse("groups:\n - name: a\n rules: []\n");
|
|
111
|
+
expect(rules.resources).toEqual([{ logicalId: "ruleFile", type: RULE_FILE_RESOURCE_TYPE, properties: { file: { groups: [{ name: "a", rules: [] }] } } }]);
|
|
112
|
+
const am = new PrometheusParser().parse("receivers:\n - name: x\n");
|
|
113
|
+
expect(am.resources.map((r) => r.type)).toEqual([ALERTMANAGER_RESOURCE_TYPE]);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("matcherString escapes the value the way Alertmanager's matcher parser reads it", () => {
|
|
118
|
+
expect(matcherString("a", "=", 'x"y\\z')).toBe('a="x\\"y\\\\z"');
|
|
119
|
+
expect(matcherString("a", "=~", "line\nbreak")).toBe('a=~"line\\nbreak"');
|
|
120
|
+
});
|