@intentius/chant-lexicon-prometheus 0.101.0 → 0.104.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 +3 -1
- package/dist/alertmanager.d.ts.map +1 -1
- package/dist/collector-metrics.d.ts +58 -0
- package/dist/collector-metrics.d.ts.map +1 -0
- package/dist/composites/genai.d.ts +1 -1
- package/dist/composites/genai.d.ts.map +1 -1
- package/dist/composites/slo.d.ts +1 -1
- package/dist/composites/slo.d.ts.map +1 -1
- package/dist/import/embedded.d.ts +12 -3
- package/dist/import/embedded.d.ts.map +1 -1
- package/dist/import/generator.d.ts +14 -0
- package/dist/import/generator.d.ts.map +1 -1
- package/dist/import/parser.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/init-templates.d.ts.map +1 -1
- package/dist/integrations.d.ts.map +1 -1
- package/dist/integrity.json +23 -7
- package/dist/lint/audit-catalog.d.ts +2 -0
- package/dist/lint/audit-catalog.d.ts.map +1 -1
- package/dist/lint/audit-lineage.d.ts +54 -0
- package/dist/lint/audit-lineage.d.ts.map +1 -0
- package/dist/lint/post-synth/index.d.ts.map +1 -1
- package/dist/lint/post-synth/prom-helpers.d.ts +9 -2
- package/dist/lint/post-synth/prom-helpers.d.ts.map +1 -1
- package/dist/lint/post-synth/prom208.d.ts +1 -1
- package/dist/lint/post-synth/prom209.d.ts +1 -1
- package/dist/lint/post-synth/prom210.d.ts +8 -0
- package/dist/lint/post-synth/prom210.d.ts.map +1 -0
- package/dist/lint/post-synth/prom211.d.ts +12 -0
- package/dist/lint/post-synth/prom211.d.ts.map +1 -0
- package/dist/lint/post-synth/prom212.d.ts +9 -0
- package/dist/lint/post-synth/prom212.d.ts.map +1 -0
- package/dist/lint/post-synth/prom213.d.ts +11 -0
- package/dist/lint/post-synth/prom213.d.ts.map +1 -0
- package/dist/lint/post-synth/prom214.d.ts +9 -0
- package/dist/lint/post-synth/prom214.d.ts.map +1 -0
- package/dist/lint/post-synth/prom215.d.ts +9 -0
- package/dist/lint/post-synth/prom215.d.ts.map +1 -0
- package/dist/lint/post-synth/prom216.d.ts +10 -0
- package/dist/lint/post-synth/prom216.d.ts.map +1 -0
- package/dist/lint/post-synth/prom217.d.ts +9 -0
- package/dist/lint/post-synth/prom217.d.ts.map +1 -0
- package/dist/lint/post-synth/prom218.d.ts +10 -0
- package/dist/lint/post-synth/prom218.d.ts.map +1 -0
- package/dist/lint/post-synth/prom219.d.ts +9 -0
- package/dist/lint/post-synth/prom219.d.ts.map +1 -0
- package/dist/lint/post-synth/prom220.d.ts +9 -0
- package/dist/lint/post-synth/prom220.d.ts.map +1 -0
- package/dist/lint/post-synth/prom221.d.ts +10 -0
- package/dist/lint/post-synth/prom221.d.ts.map +1 -0
- package/dist/lint/post-synth/prom222.d.ts +9 -0
- package/dist/lint/post-synth/prom222.d.ts.map +1 -0
- package/dist/lint/post-synth/prom223.d.ts +10 -0
- package/dist/lint/post-synth/prom223.d.ts.map +1 -0
- package/dist/lint/post-synth/prom224.d.ts +9 -0
- package/dist/lint/post-synth/prom224.d.ts.map +1 -0
- package/dist/lint/post-synth/prom301.d.ts +14 -0
- package/dist/lint/post-synth/prom301.d.ts.map +1 -0
- package/dist/lint/rules/literal-credential.d.ts.map +1 -1
- package/dist/manifest.json +1 -1
- package/dist/okf/index.md +16 -0
- package/dist/okf/rules/PROM210.md +11 -0
- package/dist/okf/rules/PROM211.md +11 -0
- package/dist/okf/rules/PROM212.md +11 -0
- package/dist/okf/rules/PROM213.md +11 -0
- package/dist/okf/rules/PROM214.md +11 -0
- package/dist/okf/rules/PROM215.md +11 -0
- package/dist/okf/rules/PROM216.md +11 -0
- package/dist/okf/rules/PROM217.md +11 -0
- package/dist/okf/rules/PROM218.md +11 -0
- package/dist/okf/rules/PROM219.md +11 -0
- package/dist/okf/rules/PROM220.md +11 -0
- package/dist/okf/rules/PROM221.md +11 -0
- package/dist/okf/rules/PROM222.md +11 -0
- package/dist/okf/rules/PROM223.md +11 -0
- package/dist/okf/rules/PROM224.md +11 -0
- package/dist/okf/rules/PROM301.md +11 -0
- package/dist/plugin.d.ts.map +1 -1
- package/dist/promql-analysis.d.ts +82 -0
- package/dist/promql-analysis.d.ts.map +1 -0
- package/dist/rule-eval.d.ts.map +1 -1
- package/dist/rules/literal-credential.ts +4 -0
- package/dist/rules/prom-helpers.ts +41 -2
- package/dist/rules/prom208.ts +1 -1
- package/dist/rules/prom209.ts +1 -1
- package/dist/rules/prom210.ts +17 -0
- package/dist/rules/prom211.ts +21 -0
- package/dist/rules/prom212.ts +18 -0
- package/dist/rules/prom213.ts +20 -0
- package/dist/rules/prom214.ts +18 -0
- package/dist/rules/prom215.ts +18 -0
- package/dist/rules/prom216.ts +19 -0
- package/dist/rules/prom217.ts +18 -0
- package/dist/rules/prom218.ts +19 -0
- package/dist/rules/prom219.ts +18 -0
- package/dist/rules/prom220.ts +18 -0
- package/dist/rules/prom221.ts +19 -0
- package/dist/rules/prom222.ts +18 -0
- package/dist/rules/prom223.ts +19 -0
- package/dist/rules/prom224.ts +18 -0
- package/dist/rules/prom301.ts +23 -0
- package/dist/skill-defs.d.ts +1 -1
- package/dist/skill-defs.d.ts.map +1 -1
- package/dist/skills/chant-prometheus-alertmanager.md +3 -1
- package/dist/validate-config.d.ts +9 -3
- package/dist/validate-config.d.ts.map +1 -1
- package/dist/validate-integrations.d.ts +38 -0
- package/dist/validate-integrations.d.ts.map +1 -0
- package/dist/validate-security.d.ts +13 -0
- package/dist/validate-security.d.ts.map +1 -0
- package/package.json +3 -3
- package/src/collector-metrics.ts +262 -0
- package/src/composites/composites.test.ts +2 -5
- package/src/import/embedded.test.ts +52 -3
- package/src/import/embedded.ts +37 -5
- package/src/import/generator.ts +19 -1
- package/src/import/testdata/alertmanager-integrations.yml +5 -1
- package/src/index.ts +1 -0
- package/src/init-templates.test.ts +3 -1
- package/src/lint/audit-catalog.ts +115 -2
- package/src/lint/audit-lineage.test.ts +74 -0
- package/src/lint/audit-lineage.ts +97 -0
- package/src/lint/post-synth/checks.test.ts +257 -0
- package/src/lint/post-synth/index.ts +32 -0
- package/src/lint/post-synth/post-synth.test.ts +37 -1
- package/src/lint/post-synth/prom-helpers.ts +41 -2
- package/src/lint/post-synth/prom208.ts +1 -1
- package/src/lint/post-synth/prom209.ts +1 -1
- package/src/lint/post-synth/prom210.ts +17 -0
- package/src/lint/post-synth/prom211.ts +21 -0
- package/src/lint/post-synth/prom212.ts +18 -0
- package/src/lint/post-synth/prom213.ts +20 -0
- package/src/lint/post-synth/prom214.ts +18 -0
- package/src/lint/post-synth/prom215.ts +18 -0
- package/src/lint/post-synth/prom216.ts +19 -0
- package/src/lint/post-synth/prom217.ts +18 -0
- package/src/lint/post-synth/prom218.ts +19 -0
- package/src/lint/post-synth/prom219.ts +18 -0
- package/src/lint/post-synth/prom220.ts +18 -0
- package/src/lint/post-synth/prom221.ts +19 -0
- package/src/lint/post-synth/prom222.ts +18 -0
- package/src/lint/post-synth/prom223.ts +19 -0
- package/src/lint/post-synth/prom224.ts +18 -0
- package/src/lint/post-synth/prom301.test.ts +157 -0
- package/src/lint/post-synth/prom301.ts +23 -0
- package/src/lint/rules/literal-credential.ts +4 -0
- package/src/lint/rules/rules.test.ts +16 -0
- package/src/plugin.test.ts +23 -0
- package/src/plugin.ts +14 -3
- package/src/promql-analysis.ts +365 -0
- package/src/skills/chant-prometheus-alertmanager.md +3 -1
- package/src/testdata/integration-cases.ts +96 -0
- package/src/tools.test.ts +6 -0
- package/src/validate-config.ts +279 -33
- package/src/validate-integrations.ts +389 -0
- package/src/validate-security.ts +143 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a parsed PromQL expression: the selectors it reads, the labels its
|
|
3
|
+
* result keeps, and whether it filters with a comparison. The rule-file
|
|
4
|
+
* checks PROM211-PROM219 (./validate-config.ts) are built on these.
|
|
5
|
+
*
|
|
6
|
+
* The tree is the one `checkPromql` (./promql.ts) parses with
|
|
7
|
+
* `@prometheus-io/lezer-promql`. Every function here returns nothing for an
|
|
8
|
+
* expression that does not parse; PROM104 reports that.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { parser } from "@prometheus-io/lezer-promql";
|
|
12
|
+
import type { MatchOp } from "./matchers";
|
|
13
|
+
|
|
14
|
+
type SyntaxNode = ReturnType<typeof parser.parse>["topNode"];
|
|
15
|
+
|
|
16
|
+
/** The expression under the tree's `PromQL` root, or undefined when it does not parse. */
|
|
17
|
+
export function parsePromql(expr: string): SyntaxNode | undefined {
|
|
18
|
+
if (typeof expr !== "string" || expr.trim() === "") return undefined;
|
|
19
|
+
const tree = parser.parse(expr);
|
|
20
|
+
let broken = false;
|
|
21
|
+
tree.iterate({
|
|
22
|
+
enter: (n) => {
|
|
23
|
+
if (n.type.isError) broken = true;
|
|
24
|
+
return broken ? false : undefined;
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
return broken ? undefined : (tree.topNode.firstChild ?? undefined);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function text(n: SyntaxNode, src: string): string {
|
|
31
|
+
return src.slice(n.from, n.to);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The value of a PromQL string literal: double- or single-quoted with escapes, or a raw backtick string. */
|
|
35
|
+
export function stringValue(literal: string): string {
|
|
36
|
+
if (literal.startsWith("`")) return literal.slice(1, -1);
|
|
37
|
+
const body = literal.slice(1, -1);
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(`"${literal.startsWith("'") ? body.replace(/\\'/g, "'").replace(/"/g, '\\"') : body}"`) as string;
|
|
40
|
+
} catch {
|
|
41
|
+
return body;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function children(n: SyntaxNode): SyntaxNode[] {
|
|
46
|
+
const out: SyntaxNode[] = [];
|
|
47
|
+
for (let c = n.firstChild; c; c = c.nextSibling) out.push(c);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The arguments of a function call or aggregation body, without the brackets and commas. */
|
|
52
|
+
function args(body: SyntaxNode | null): SyntaxNode[] {
|
|
53
|
+
return body ? children(body).filter((c) => c.name !== "(" && c.name !== ")" && c.name !== ",") : [];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function unwrap(n: SyntaxNode): SyntaxNode {
|
|
57
|
+
let cur = n;
|
|
58
|
+
while (cur.name === "ParenExpr" && cur.firstChild) cur = children(cur).find((c) => c.name !== "(" && c.name !== ")") ?? cur;
|
|
59
|
+
return cur;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface SelectorMatcher {
|
|
63
|
+
name: string;
|
|
64
|
+
op: MatchOp;
|
|
65
|
+
value: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** One vector selector in an expression. */
|
|
69
|
+
export interface Selector {
|
|
70
|
+
/** The metric name, from the identifier or a `__name__="..."` matcher; undefined when neither names one. */
|
|
71
|
+
name?: string;
|
|
72
|
+
matchers: SelectorMatcher[];
|
|
73
|
+
/** The selector as written. */
|
|
74
|
+
text: string;
|
|
75
|
+
/** The functions it sits inside, innermost first, e.g. `["rate", "sum"]` for `sum(rate(x[5m]))`. */
|
|
76
|
+
within: string[];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function selectorOf(n: SyntaxNode, src: string, within: string[]): Selector {
|
|
80
|
+
const matchers: SelectorMatcher[] = [];
|
|
81
|
+
const list = n.getChild("LabelMatchers");
|
|
82
|
+
for (const c of list ? children(list) : []) {
|
|
83
|
+
if (c.name !== "UnquotedLabelMatcher" && c.name !== "QuotedLabelMatcher") continue;
|
|
84
|
+
const label = c.getChild("LabelName") ?? c.getChild("QuotedLabelName");
|
|
85
|
+
const op = c.getChild("MatchOp");
|
|
86
|
+
const value = c.getChild("StringLiteral");
|
|
87
|
+
if (!label || !op || !value) continue;
|
|
88
|
+
const name = label.name === "QuotedLabelName" ? stringValue(text(label, src)) : text(label, src);
|
|
89
|
+
matchers.push({ name, op: text(op, src) as MatchOp, value: stringValue(text(value, src)) });
|
|
90
|
+
}
|
|
91
|
+
const ident = n.getChild("Identifier");
|
|
92
|
+
const byName = matchers.find((m) => m.name === "__name__" && m.op === "=");
|
|
93
|
+
const name = ident ? text(ident, src) : byName?.value;
|
|
94
|
+
return { name, matchers, text: text(n, src), within };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The name of a function call or aggregation node, e.g. `rate` or `sum`. */
|
|
98
|
+
function callName(n: SyntaxNode, src: string): string | undefined {
|
|
99
|
+
if (n.name === "FunctionCall") {
|
|
100
|
+
const id = n.getChild("FunctionIdentifier");
|
|
101
|
+
return id ? text(id, src) : undefined;
|
|
102
|
+
}
|
|
103
|
+
if (n.name === "AggregateExpr") {
|
|
104
|
+
const op = n.getChild("AggregateOp");
|
|
105
|
+
return op ? text(op, src) : undefined;
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Every vector selector in the expression, with the functions around it. */
|
|
111
|
+
export function selectors(expr: string): Selector[] {
|
|
112
|
+
const top = parsePromql(expr);
|
|
113
|
+
if (!top) return [];
|
|
114
|
+
const out: Selector[] = [];
|
|
115
|
+
const walk = (n: SyntaxNode, within: string[]) => {
|
|
116
|
+
if (n.name === "VectorSelector") {
|
|
117
|
+
out.push(selectorOf(n, expr, within));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const fn = callName(n, expr);
|
|
121
|
+
const inner = fn ? [fn, ...within] : within;
|
|
122
|
+
for (const c of children(n)) walk(c, inner);
|
|
123
|
+
};
|
|
124
|
+
walk(top, []);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const COMPARISON = new Set(["Gtr", "Lss", "Gte", "Lte", "Eql", "Neq"]);
|
|
129
|
+
|
|
130
|
+
function binaryParts(n: SyntaxNode): { lhs: SyntaxNode; op: SyntaxNode; rhs: SyntaxNode; bool: boolean; modifier?: SyntaxNode } {
|
|
131
|
+
const parts = children(n);
|
|
132
|
+
return {
|
|
133
|
+
lhs: parts[0],
|
|
134
|
+
op: parts[1],
|
|
135
|
+
rhs: parts[parts.length - 1],
|
|
136
|
+
bool: parts.some((p) => p.name === "BoolModifier"),
|
|
137
|
+
modifier: parts.find((p) => p.name === "MatchingModifierClause"),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Functions whose result is itself a condition: they return a series only when something is missing. */
|
|
142
|
+
const CONDITION_FUNCTIONS = new Set(["absent", "absent_over_time"]);
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Whether the expression filters its result: a comparison without `bool`,
|
|
146
|
+
* `absent()`/`absent_over_time()`, or `unless`, anywhere a series can pass
|
|
147
|
+
* through to the result. An expression without one returns every series it
|
|
148
|
+
* reads, so an alert on it fires for all of them (pint alerts/comparison).
|
|
149
|
+
* `or` needs a condition on both sides, since either side alone reaches the
|
|
150
|
+
* result.
|
|
151
|
+
*/
|
|
152
|
+
export function hasCondition(expr: string): boolean | undefined {
|
|
153
|
+
const top = parsePromql(expr);
|
|
154
|
+
if (!top) return undefined;
|
|
155
|
+
const walk = (raw: SyntaxNode): boolean => {
|
|
156
|
+
const n = unwrap(raw);
|
|
157
|
+
switch (n.name) {
|
|
158
|
+
case "BinaryExpr": {
|
|
159
|
+
const { lhs, op, rhs, bool } = binaryParts(n);
|
|
160
|
+
if (COMPARISON.has(op.name)) return !bool || walk(lhs) || walk(rhs);
|
|
161
|
+
if (op.name === "Unless") return true;
|
|
162
|
+
if (op.name === "Or") return walk(lhs) && walk(rhs);
|
|
163
|
+
return walk(lhs) || walk(rhs);
|
|
164
|
+
}
|
|
165
|
+
case "FunctionCall": {
|
|
166
|
+
const fn = callName(n, expr) ?? "";
|
|
167
|
+
if (CONDITION_FUNCTIONS.has(fn)) return true;
|
|
168
|
+
return args(n.getChild("FunctionCallBody")).some(walk);
|
|
169
|
+
}
|
|
170
|
+
case "AggregateExpr":
|
|
171
|
+
return args(n.getChild("FunctionCallBody")).some(walk);
|
|
172
|
+
case "UnaryExpr":
|
|
173
|
+
case "OffsetExpr":
|
|
174
|
+
case "StepInvariantExpr":
|
|
175
|
+
return children(n).some(walk);
|
|
176
|
+
case "SubqueryExpr":
|
|
177
|
+
case "MatrixSelector":
|
|
178
|
+
return n.firstChild ? walk(n.firstChild) : false;
|
|
179
|
+
default:
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
return walk(top);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The labels an expression's result carries, as far as the expression says:
|
|
188
|
+
* `{ only }` when it keeps exactly these labels (`sum by (job)`), `{ except }`
|
|
189
|
+
* when it keeps every label but these (`sum without (instance)`), or
|
|
190
|
+
* undefined when it keeps whatever the series it reads carry.
|
|
191
|
+
*/
|
|
192
|
+
export type LabelScope = { only: Set<string> } | { except: Set<string> };
|
|
193
|
+
|
|
194
|
+
const KEEPS_INPUT_LABELS = new Set(["topk", "bottomk", "limitk", "limit_ratio"]);
|
|
195
|
+
const NO_LABELS = new Set(["vector", "time", "scalar", "pi"]);
|
|
196
|
+
|
|
197
|
+
function grouping(mod: SyntaxNode | null, src: string): { by: boolean; labels: string[] } | undefined {
|
|
198
|
+
if (!mod) return undefined;
|
|
199
|
+
const labels = (mod.getChild("GroupingLabels")?.getChildren("LabelName") ?? []).map((l) => text(l, src));
|
|
200
|
+
return { by: !!mod.getChild("By"), labels };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function minus(scope: LabelScope | undefined, drop: string[]): LabelScope {
|
|
204
|
+
if (!scope) return { except: new Set(drop) };
|
|
205
|
+
if ("only" in scope) return { only: new Set([...scope.only].filter((l) => !drop.includes(l))) };
|
|
206
|
+
return { except: new Set([...scope.except, ...drop]) };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function plus(scope: LabelScope | undefined, add: string[]): LabelScope | undefined {
|
|
210
|
+
if (!scope) return undefined;
|
|
211
|
+
if ("only" in scope) return { only: new Set([...scope.only, ...add]) };
|
|
212
|
+
return { except: new Set([...scope.except].filter((l) => !add.includes(l))) };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The labels the expression's result keeps; see {@link LabelScope}. */
|
|
216
|
+
export function resultLabels(expr: string): LabelScope | undefined {
|
|
217
|
+
const top = parsePromql(expr);
|
|
218
|
+
if (!top) return undefined;
|
|
219
|
+
const walk = (raw: SyntaxNode): LabelScope | undefined => {
|
|
220
|
+
const n = unwrap(raw);
|
|
221
|
+
switch (n.name) {
|
|
222
|
+
case "NumberDurationLiteral":
|
|
223
|
+
case "StringLiteral":
|
|
224
|
+
return { only: new Set() };
|
|
225
|
+
case "AggregateExpr": {
|
|
226
|
+
const op = callName(n, expr) ?? "";
|
|
227
|
+
const body = args(n.getChild("FunctionCallBody"));
|
|
228
|
+
const last = body[body.length - 1];
|
|
229
|
+
const inner = last ? walk(last) : undefined;
|
|
230
|
+
if (KEEPS_INPUT_LABELS.has(op)) return inner;
|
|
231
|
+
if (op === "count_values") return undefined;
|
|
232
|
+
const g = grouping(n.getChild("AggregateModifier"), expr);
|
|
233
|
+
if (!g) return { only: new Set() };
|
|
234
|
+
return g.by ? { only: new Set(g.labels) } : minus(inner, g.labels);
|
|
235
|
+
}
|
|
236
|
+
case "FunctionCall": {
|
|
237
|
+
const fn = callName(n, expr) ?? "";
|
|
238
|
+
if (NO_LABELS.has(fn)) return { only: new Set() };
|
|
239
|
+
const a = args(n.getChild("FunctionCallBody"));
|
|
240
|
+
if (fn === "histogram_quantile" || fn === "histogram_fraction") return minus(walk(a[a.length - 1]), ["le"]);
|
|
241
|
+
if (fn === "label_replace" || fn === "label_join") {
|
|
242
|
+
const dst = a[1]?.name === "StringLiteral" ? stringValue(text(a[1], expr)) : undefined;
|
|
243
|
+
return dst === undefined ? undefined : plus(walk(a[0]), [dst]);
|
|
244
|
+
}
|
|
245
|
+
if (CONDITION_FUNCTIONS.has(fn)) return undefined;
|
|
246
|
+
const vector = a.find((x) => x.name !== "NumberDurationLiteral" && x.name !== "StringLiteral");
|
|
247
|
+
return vector ? walk(vector) : { only: new Set() };
|
|
248
|
+
}
|
|
249
|
+
case "BinaryExpr": {
|
|
250
|
+
const { lhs, op, rhs, modifier } = binaryParts(n);
|
|
251
|
+
const l = walk(lhs);
|
|
252
|
+
const r = walk(rhs);
|
|
253
|
+
const lScalar = unwrap(lhs).name === "NumberDurationLiteral";
|
|
254
|
+
const rScalar = unwrap(rhs).name === "NumberDurationLiteral";
|
|
255
|
+
if (lScalar && !rScalar) return r;
|
|
256
|
+
if (rScalar) return l;
|
|
257
|
+
if (op.name === "And" || op.name === "Unless") return l;
|
|
258
|
+
if (op.name === "Or") {
|
|
259
|
+
if (l && r && "only" in l && "only" in r) return { only: new Set([...l.only, ...r.only]) };
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
if (!modifier) return l;
|
|
263
|
+
const on = !!modifier.getChild("On");
|
|
264
|
+
const groupLabels = modifier.getChildren("GroupingLabels").map((g) => g.getChildren("LabelName").map((x) => text(x, expr)));
|
|
265
|
+
const matching = groupLabels[0] ?? [];
|
|
266
|
+
const extra = groupLabels[1] ?? [];
|
|
267
|
+
if (modifier.getChild("GroupLeft")) return plus(l, extra);
|
|
268
|
+
if (modifier.getChild("GroupRight")) return plus(r, extra);
|
|
269
|
+
// One-to-one: `on` keeps only the matching labels, `ignoring` drops them.
|
|
270
|
+
return on ? { only: new Set(matching.filter((x) => !l || ("only" in l ? l.only.has(x) : !l.except.has(x)))) } : minus(l, matching);
|
|
271
|
+
}
|
|
272
|
+
case "UnaryExpr":
|
|
273
|
+
case "OffsetExpr":
|
|
274
|
+
case "StepInvariantExpr":
|
|
275
|
+
return walk(children(n).find((c) => c.name !== "UnaryOp" && c.name !== "Offset" && c.name !== "OffsetDurationExpr") ?? n.firstChild!);
|
|
276
|
+
default:
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
return walk(top);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Whether a label survives a scope. An undefined scope keeps every label. */
|
|
284
|
+
export function keepsLabel(scope: LabelScope | undefined, label: string): boolean {
|
|
285
|
+
if (!scope) return true;
|
|
286
|
+
return "only" in scope ? scope.only.has(label) : !scope.except.has(label);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** One `histogram_quantile` call and what is wrong with its histogram argument. */
|
|
290
|
+
export interface HistogramProblem {
|
|
291
|
+
call: string;
|
|
292
|
+
problem: "no-bucket" | "no-le";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** `histogram_quantile` calls over a selector with no `_bucket` in its name, or an aggregation that drops `le`. */
|
|
296
|
+
export function histogramProblems(expr: string): HistogramProblem[] {
|
|
297
|
+
const top = parsePromql(expr);
|
|
298
|
+
if (!top) return [];
|
|
299
|
+
const out: HistogramProblem[] = [];
|
|
300
|
+
const walk = (n: SyntaxNode) => {
|
|
301
|
+
if (n.name === "FunctionCall" && callName(n, expr) === "histogram_quantile") {
|
|
302
|
+
const a = args(n.getChild("FunctionCallBody"));
|
|
303
|
+
const histogram = a[1];
|
|
304
|
+
if (histogram) {
|
|
305
|
+
const call = text(n, expr);
|
|
306
|
+
const names = selectors(text(histogram, expr)).flatMap((s) => (s.name ? [s.name] : []));
|
|
307
|
+
if (names.some((x) => !x.includes("_bucket"))) out.push({ call, problem: "no-bucket" });
|
|
308
|
+
else if (!keepsLabel(resultLabels(text(histogram, expr)), "le")) out.push({ call, problem: "no-le" });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
for (const c of children(n)) walk(c);
|
|
312
|
+
};
|
|
313
|
+
walk(top);
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const COUNTER_FUNCTIONS = new Set(["rate", "irate", "increase"]);
|
|
318
|
+
const COUNTER_SUFFIX = /_(total|count|sum|bucket)$/;
|
|
319
|
+
|
|
320
|
+
/** `rate`, `irate` or `increase` read straight from a named selector whose name does not end in a counter suffix. */
|
|
321
|
+
export function nonCounterRates(expr: string): Array<{ fn: string; name: string }> {
|
|
322
|
+
return selectors(expr).flatMap((s) => {
|
|
323
|
+
const fn = s.within[0];
|
|
324
|
+
if (!fn || !COUNTER_FUNCTIONS.has(fn) || !s.name || COUNTER_SUFFIX.test(s.name)) return [];
|
|
325
|
+
return [{ fn, name: s.name }];
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
const REGEX_META = /[.+*?()[\]{}|\\^$]/;
|
|
330
|
+
|
|
331
|
+
/** A regex matcher that could be an equality matcher, or that carries anchors Prometheus already adds. */
|
|
332
|
+
export interface RegexProblem {
|
|
333
|
+
matcher: string;
|
|
334
|
+
problem: "literal" | "anchored";
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The `=~` and `!~` matchers in an expression that need no regex, or that are anchored (pint promql/regexp). */
|
|
338
|
+
export function regexProblems(expr: string): RegexProblem[] {
|
|
339
|
+
const out: RegexProblem[] = [];
|
|
340
|
+
for (const s of selectors(expr)) {
|
|
341
|
+
for (const m of s.matchers) {
|
|
342
|
+
if (m.op !== "=~" && m.op !== "!~") continue;
|
|
343
|
+
const shown = `${m.name}${m.op}${JSON.stringify(m.value)}`;
|
|
344
|
+
if (!REGEX_META.test(m.value)) out.push({ matcher: shown, problem: "literal" });
|
|
345
|
+
else if (m.value.startsWith("^") || (m.value.endsWith("$") && !m.value.endsWith("\\$"))) out.push({ matcher: shown, problem: "anchored" });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
return out;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Whether every selector in the expression is read through a `*_over_time` function, so the expression already spans a window. */
|
|
352
|
+
export function readsOnlyOverTime(expr: string): boolean {
|
|
353
|
+
const all = selectors(expr);
|
|
354
|
+
return all.length > 0 && all.every((s) => s.within.some((fn) => fn.endsWith("_over_time")));
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** Whether the expression is two conditions joined by `and`: the multi-window form, where the short window does what `for` would. */
|
|
358
|
+
export function isMultiWindow(expr: string): boolean {
|
|
359
|
+
const top = parsePromql(expr);
|
|
360
|
+
if (!top) return false;
|
|
361
|
+
const n = unwrap(top);
|
|
362
|
+
if (n.name !== "BinaryExpr") return false;
|
|
363
|
+
const { lhs, op, rhs } = binaryParts(n);
|
|
364
|
+
return op.name === "And" && hasCondition(text(lhs, expr)) === true && hasCondition(text(rhs, expr)) === true;
|
|
365
|
+
}
|
|
@@ -57,6 +57,8 @@ Import it rather than retyping it: `chant import alertmanager.yml --output src`
|
|
|
57
57
|
- Routes name receivers and time intervals that exist (PROM201, PROM204). Reference the entity rather than a string and TypeScript does most of this.
|
|
58
58
|
- Every `severity` an alert in the same build root carries is matched by some route below the root (PROM202). The check only sees one build root (chant #1939): keep the rules and the Alertmanager config in the same one, or it goes quiet.
|
|
59
59
|
- Credentials go in `*_file` fields. Alertmanager does not expand environment variables in its config, and a literal `api_url`, `routing_key` or `auth_password` is flagged (PROM001).
|
|
60
|
-
- Each integration has
|
|
60
|
+
- Each integration has its destination, credential and required fields, or the `global` default it falls back to (PROM209): a webhook `url`, a Slack `api_url` or app token, an Opsgenie `api_key`, a Telegram `chat_id` and bot token, a Webex `room_id` and `http_config.authorization`, one SNS target, a Jira `project` and `issue_type`, an email `to`/`smarthost`/`from`, and so on.
|
|
61
|
+
- No setting Alertmanager rejects (PROM210): a value and its `*_file` both set, a Slack `api_url` with an app token, `update_message` without `api_url: https://slack.com/api/chat.postMessage`, a WeChat `message_type` other than `text`/`markdown`, a Telegram `parse_mode` other than `Markdown`/`MarkdownV2`/`HTML`.
|
|
62
|
+
- Durations parse (PROM208). Route timers and `resolve_timeout` take `1d`; the integration `timeout`s and Pushover `retry`/`expire`/`ttl` are Go durations and take `24h`, not `1d`.
|
|
61
63
|
|
|
62
64
|
Run `amtoolCheckConfig(alertmanagerYaml(entities))` in a test to have `amtool check-config` confirm it when installed.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alertmanager receiver integrations that pass and fail PROM208-PROM210: at
|
|
3
|
+
* least one of each per integration, after Alertmanager v0.34.1's
|
|
4
|
+
* UnmarshalYAML validations (sources in ../validate-integrations.ts).
|
|
5
|
+
* post-synth.test.ts checks the codes; tools.test.ts has amtool reject the
|
|
6
|
+
* failing ones when it is installed.
|
|
7
|
+
*
|
|
8
|
+
* Each case: a label, the receiver key, one entry, an optional `global:`,
|
|
9
|
+
* and the PROM208-PROM210 codes it produces, sorted.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export type IntegrationCase = [label: string, key: string, entry: Record<string, unknown>, global: Record<string, unknown> | undefined, codes: string[]];
|
|
13
|
+
const PD = "https://events.pagerduty.com/v2/enqueue";
|
|
14
|
+
export const SLACK_APP_URL = "https://slack.com/api/chat.postMessage";
|
|
15
|
+
export const INTEGRATION_CASES: IntegrationCase[] = [
|
|
16
|
+
["discord ok", "discord_configs", { webhook_url_file: "/s" }, undefined, []],
|
|
17
|
+
["discord without a webhook", "discord_configs", {}, undefined, ["PROM209"]],
|
|
18
|
+
["discord with url and file", "discord_configs", { webhook_url: "https://d/x", webhook_url_file: "/s" }, undefined, ["PROM210"]],
|
|
19
|
+
["email ok", "email_configs", { to: "a@b.c", from: "am@b.c", smarthost: "smtp:25" }, undefined, []],
|
|
20
|
+
["email on global SMTP", "email_configs", { to: "a@b.c" }, { smtp_smarthost: "smtp:25", smtp_from: "am@b.c" }, []],
|
|
21
|
+
["email with nothing", "email_configs", {}, undefined, ["PROM209", "PROM209", "PROM209"]],
|
|
22
|
+
["email smarthost without port", "email_configs", { to: "a@b.c", from: "am@b.c", smarthost: "smtp" }, undefined, ["PROM210"]],
|
|
23
|
+
["email duplicate header", "email_configs", { to: "a@b.c", from: "f@b.c", smarthost: "s:25", headers: { subject: "a", Subject: "b" } }, undefined, ["PROM210"]],
|
|
24
|
+
["email threading without thread_by_date", "email_configs", { to: "a@b.c", from: "f@b.c", smarthost: "s:25", threading: { enabled: true } }, undefined, ["PROM210"]],
|
|
25
|
+
["email threading with References", "email_configs", { to: "a@b.c", from: "f@b.c", smarthost: "s:25", headers: { references: "x" }, threading: { enabled: true, thread_by_date: "daily" } }, undefined, ["PROM210"]],
|
|
26
|
+
["incident.io ok", "incidentio_configs", { url: "https://i/x", alert_source_token_file: "/t", timeout: "1.5s" }, undefined, []],
|
|
27
|
+
["incident.io without url", "incidentio_configs", {}, undefined, ["PROM209"]],
|
|
28
|
+
["incident.io http_config without a credential", "incidentio_configs", { url: "https://i/x", http_config: { follow_redirects: true } }, undefined, ["PROM209"]],
|
|
29
|
+
["incident.io token and authorization", "incidentio_configs", { url: "https://i/x", alert_source_token_file: "/t", http_config: { authorization: { credentials_file: "/c" } } }, undefined, ["PROM210"]],
|
|
30
|
+
["incident.io Prometheus-only duration", "incidentio_configs", { url_file: "/u", timeout: "1d" }, undefined, ["PROM208"]],
|
|
31
|
+
["jira ok", "jira_configs", { api_url: "https://j/x", project: "OPS", issue_type: "Bug", reopen_duration: "1d" }, undefined, []],
|
|
32
|
+
["jira on global api_url", "jira_configs", { project: "OPS", issue_type: "Bug" }, { jira_api_url: "https://j/x" }, []],
|
|
33
|
+
["jira with nothing", "jira_configs", {}, undefined, ["PROM209", "PROM209", "PROM209"]],
|
|
34
|
+
["jira api_type", "jira_configs", { api_url: "https://j/x", project: "OPS", issue_type: "Bug", api_type: "server" }, undefined, ["PROM210"]],
|
|
35
|
+
["jira Go-only duration", "jira_configs", { api_url: "https://j/x", project: "OPS", issue_type: "Bug", reopen_duration: "1.5h" }, undefined, ["PROM208"]],
|
|
36
|
+
["mattermost ok", "mattermost_configs", { webhook_url_file: "/m" }, undefined, []],
|
|
37
|
+
["mattermost on global webhook", "mattermost_configs", {}, { mattermost_webhook_url_file: "/m" }, []],
|
|
38
|
+
["mattermost without a webhook", "mattermost_configs", {}, undefined, ["PROM209"]],
|
|
39
|
+
["mattermost field without value", "mattermost_configs", { webhook_url_file: "/m", attachments: [{ fields: [{ title: "a" }] }] }, undefined, ["PROM210"]],
|
|
40
|
+
["msteams ok", "msteams_configs", { webhook_url_file: "/t" }, undefined, []],
|
|
41
|
+
["msteams without a webhook", "msteams_configs", {}, undefined, ["PROM209"]],
|
|
42
|
+
["msteamsv2 ok", "msteamsv2_configs", { webhook_url_file: "/t" }, undefined, []],
|
|
43
|
+
["msteamsv2 with url and file", "msteamsv2_configs", { webhook_url: "https://t/x", webhook_url_file: "/t" }, undefined, ["PROM210"]],
|
|
44
|
+
["opsgenie ok", "opsgenie_configs", { api_key_file: "/k", responders: [{ name: "ops", type: "Team" }, { id: "1", type: "{{ .CommonLabels.kind }}" }] }, undefined, []],
|
|
45
|
+
["opsgenie on global api_key", "opsgenie_configs", {}, { opsgenie_api_key_file: "/k" }, []],
|
|
46
|
+
["opsgenie without api_key", "opsgenie_configs", {}, undefined, ["PROM209"]],
|
|
47
|
+
["opsgenie responders", "opsgenie_configs", { api_key_file: "/k", responders: [{ type: "team" }, { name: "x", type: "group" }, { name: "y" }] }, undefined, ["PROM209", "PROM209", "PROM210"]],
|
|
48
|
+
["pagerduty ok", "pagerduty_configs", { routing_key_file: "/k", url: PD, timeout: "30s" }, undefined, []],
|
|
49
|
+
["pagerduty without a key", "pagerduty_configs", {}, undefined, ["PROM209"]],
|
|
50
|
+
["pagerduty key and file", "pagerduty_configs", { service_key: "x", service_key_file: "/k" }, undefined, ["PROM210"]],
|
|
51
|
+
["pagerduty timeout without unit", "pagerduty_configs", { routing_key_file: "/k", timeout: "10" }, undefined, ["PROM208"]],
|
|
52
|
+
["pushover ok", "pushover_configs", { user_key_file: "/u", token_file: "/t", retry: "30s", expire: "1h", ttl: "1h30m" }, undefined, []],
|
|
53
|
+
["pushover without credentials", "pushover_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
54
|
+
["pushover html and monospace", "pushover_configs", { user_key_file: "/u", token_file: "/t", html: true, monospace: true }, undefined, ["PROM210"]],
|
|
55
|
+
["pushover retry in days", "pushover_configs", { user_key_file: "/u", token_file: "/t", retry: "1d" }, undefined, ["PROM208"]],
|
|
56
|
+
["rocketchat ok", "rocketchat_configs", { token_id_file: "/i", token_file: "/t" }, undefined, []],
|
|
57
|
+
["rocketchat on global tokens", "rocketchat_configs", {}, { rocketchat_token_id_file: "/i", rocketchat_token_file: "/t" }, []],
|
|
58
|
+
["rocketchat without tokens", "rocketchat_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
59
|
+
["rocketchat token and file", "rocketchat_configs", { token_id_file: "/i", token: "x", token_file: "/t" }, undefined, ["PROM210"]],
|
|
60
|
+
["slack ok", "slack_configs", { api_url_file: "/s", timeout: "500ms" }, undefined, []],
|
|
61
|
+
["slack on global app token", "slack_configs", {}, { slack_app_token_file: "/t" }, []],
|
|
62
|
+
["slack update_message with the app URL", "slack_configs", { api_url: SLACK_APP_URL, update_message: true, http_config: { authorization: { credentials_file: "/t" } } }, undefined, []],
|
|
63
|
+
["slack without a url or token", "slack_configs", { channel: "#a" }, undefined, ["PROM209"]],
|
|
64
|
+
["slack local authorization blocks the global token", "slack_configs", { http_config: { authorization: { credentials_file: "/t" } } }, { slack_app_token_file: "/t" }, ["PROM209"]],
|
|
65
|
+
["slack url and token", "slack_configs", { api_url_file: "/s", app_token_file: "/t" }, undefined, ["PROM210"]],
|
|
66
|
+
["slack update_message on a webhook", "slack_configs", { api_url_file: "/s", update_message: true }, undefined, ["PROM210"]],
|
|
67
|
+
["slack app token and authorization", "slack_configs", { app_token_file: "/t", http_config: { bearer_token_file: "/b" } }, undefined, ["PROM210"]],
|
|
68
|
+
["slack fields and actions", "slack_configs", { api_url_file: "/s", fields: [{ title: "a" }], actions: [{ type: "button", text: "x" }, { type: "button", text: "y", name: "n", confirm: {} }] }, undefined, ["PROM210", "PROM210", "PROM210"]],
|
|
69
|
+
["slack timeout", "slack_configs", { api_url_file: "/s", timeout: "5 s" }, undefined, ["PROM208"]],
|
|
70
|
+
["sns ok", "sns_configs", { topic_arn: "arn:aws:sns:x" }, undefined, []],
|
|
71
|
+
["sns without a target", "sns_configs", {}, undefined, ["PROM209"]],
|
|
72
|
+
["sns with two targets", "sns_configs", { topic_arn: "arn:aws:sns:x", target_arn: "arn:aws:sns:y" }, undefined, ["PROM210"]],
|
|
73
|
+
["telegram ok", "telegram_configs", { bot_token_file: "/t", chat_id: 12, parse_mode: "MarkdownV2" }, undefined, []],
|
|
74
|
+
["telegram on global bot token", "telegram_configs", { chat_id_file: "/c" }, { telegram_bot_token_file: "/t" }, []],
|
|
75
|
+
["telegram with nothing", "telegram_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
76
|
+
["telegram chat_id and file, bad parse_mode", "telegram_configs", { bot_token_file: "/t", chat_id: 12, chat_id_file: "/c", parse_mode: "markdown" }, undefined, ["PROM210", "PROM210"]],
|
|
77
|
+
["victorops ok", "victorops_configs", { routing_key: "ops", api_key_file: "/k" }, undefined, []],
|
|
78
|
+
["victorops on global api_key", "victorops_configs", { routing_key: "ops" }, { victorops_api_key_file: "/k" }, []],
|
|
79
|
+
["victorops with nothing", "victorops_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
80
|
+
["victorops reserved custom field", "victorops_configs", { routing_key: "ops", api_key_file: "/k", custom_fields: { entity_id: "x" } }, undefined, ["PROM210"]],
|
|
81
|
+
["webex ok", "webex_configs", { room_id: "r", http_config: { bearer_token_file: "/t" } }, undefined, []],
|
|
82
|
+
["webex with nothing", "webex_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
83
|
+
["webex with only a global authorization", "webex_configs", { room_id: "r" }, { http_config: { authorization: { credentials_file: "/t" } } }, ["PROM209"]],
|
|
84
|
+
["webhook ok", "webhook_configs", { url: "http://sink/", timeout: "1m30s" }, undefined, []],
|
|
85
|
+
["webhook without url", "webhook_configs", {}, undefined, ["PROM209"]],
|
|
86
|
+
["webhook url and file", "webhook_configs", { url: "http://sink/", url_file: "/u" }, undefined, ["PROM210"]],
|
|
87
|
+
["webhook timeout in days", "webhook_configs", { url: "http://sink/", timeout: "1d" }, undefined, ["PROM208"]],
|
|
88
|
+
["wechat ok", "wechat_configs", { api_secret_file: "/s", corp_id: "c", message_type: "markdown" }, undefined, []],
|
|
89
|
+
["wechat on global secret and corp", "wechat_configs", {}, { wechat_api_secret_file: "/s", wechat_api_corp_id: "c" }, []],
|
|
90
|
+
["wechat with nothing", "wechat_configs", {}, undefined, ["PROM209", "PROM209"]],
|
|
91
|
+
["wechat message_type", "wechat_configs", { api_secret_file: "/s", corp_id: "c", message_type: "html" }, undefined, ["PROM210"]],
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
/** An `alertmanager.yml`, as JSON, with one receiver `r` holding `entry` under `key`. */
|
|
95
|
+
export const amWith = (key: string, entry: unknown, global?: Record<string, unknown>): string =>
|
|
96
|
+
JSON.stringify({ ...(global ? { global } : {}), route: { receiver: "r" }, receivers: [{ name: "r", [key]: [entry] }] });
|
package/src/tools.test.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
promtoolCheckRules,
|
|
18
18
|
ruleFileYaml,
|
|
19
19
|
} from "./index";
|
|
20
|
+
import { INTEGRATION_CASES, amWith } from "./testdata/integration-cases";
|
|
20
21
|
|
|
21
22
|
const PROMTOOL = process.env.PROMTOOL ?? "promtool";
|
|
22
23
|
const AMTOOL = process.env.AMTOOL ?? "amtool";
|
|
@@ -113,6 +114,11 @@ describe("amtool check-config", () => {
|
|
|
113
114
|
expect(r.ok).toBe(false);
|
|
114
115
|
});
|
|
115
116
|
|
|
117
|
+
test.skipIf(!hasAmtool).each(INTEGRATION_CASES.filter((c) => c[4].length > 0))("rejects what PROM208-PROM210 flag: %s", (_label, key, entry, global) => {
|
|
118
|
+
const r = amtoolCheckConfig(amWith(key, entry, global), AMTOOL);
|
|
119
|
+
expect(r.ok, r.output).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
116
122
|
test("reports ran: false when the binary is missing", () => {
|
|
117
123
|
expect(amtoolCheckConfig("route: {}\n", "/nonexistent/amtool").ran).toBe(false);
|
|
118
124
|
});
|