amlint 0.1.0__tar.gz
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.
- amlint-0.1.0/PKG-INFO +109 -0
- amlint-0.1.0/README.md +81 -0
- amlint-0.1.0/amlint/__init__.py +0 -0
- amlint-0.1.0/amlint/cli.py +88 -0
- amlint-0.1.0/amlint/linter.py +216 -0
- amlint-0.1.0/amlint.egg-info/PKG-INFO +109 -0
- amlint-0.1.0/amlint.egg-info/SOURCES.txt +11 -0
- amlint-0.1.0/amlint.egg-info/dependency_links.txt +1 -0
- amlint-0.1.0/amlint.egg-info/entry_points.txt +2 -0
- amlint-0.1.0/amlint.egg-info/requires.txt +4 -0
- amlint-0.1.0/amlint.egg-info/top_level.txt +1 -0
- amlint-0.1.0/pyproject.toml +39 -0
- amlint-0.1.0/setup.cfg +4 -0
amlint-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amlint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Semantic linter for Prometheus Alertmanager configs
|
|
5
|
+
Author: danikdanik2013
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/danikdanik2013/amlint
|
|
8
|
+
Project-URL: Repository, https://github.com/danikdanik2013/amlint
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/danikdanik2013/amlint/issues
|
|
10
|
+
Keywords: alertmanager,prometheus,linter,devops,monitoring
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: pyyaml>=5.1
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# amlint
|
|
30
|
+
|
|
31
|
+
Semantic linter for Prometheus Alertmanager configs.
|
|
32
|
+
|
|
33
|
+
`amtool check-config` validates syntax. **amlint validates semantics:**
|
|
34
|
+
will alerts actually reach a receiver, does the inhibition rule do anything,
|
|
35
|
+
are there unreachable routing branches? These are the bugs that burn teams —
|
|
36
|
+
config is valid, alerts silently vanish.
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
Alertmanager configs are YAML routing trees with inhibition rules and receivers.
|
|
41
|
+
The most painful mistakes are syntactically valid:
|
|
42
|
+
|
|
43
|
+
- route references a receiver that doesn't exist → **alerts are dropped**
|
|
44
|
+
- inhibition without `equal` → silences unrelated alerts, you think it's quiet, there's actually a fire
|
|
45
|
+
- catch-all branch before specific ones → specific branches **are unreachable**
|
|
46
|
+
- `match_re` that doesn't compile
|
|
47
|
+
- `group_by` that doesn't behave the way you think
|
|
48
|
+
|
|
49
|
+
`amtool` won't catch any of this. amlint will.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install pyyaml
|
|
55
|
+
pip install -e .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
amlint check alertmanager.yml
|
|
62
|
+
amlint check alertmanager.yml --strict # WARN also exits non-zero
|
|
63
|
+
amlint check alertmanager.yml --format json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Example output:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
ERROR Route references receiver 'pager-team' which is not defined in receivers. Alerts matched here will be dropped.
|
|
70
|
+
↳ route.routes[1] [undefined-receiver]
|
|
71
|
+
|
|
72
|
+
WARN Catch-all route (no matchers) with continue:false will intercept all alerts — 2 subsequent sibling(s) are unreachable.
|
|
73
|
+
↳ route.routes[0] [unreachable-route]
|
|
74
|
+
|
|
75
|
+
2 error · 3 warn · 1 info
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Exit code `1` on ERROR — ready for CI. `--strict` makes WARN block too.
|
|
79
|
+
|
|
80
|
+
## CI example
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
# .github/workflows/lint.yml
|
|
84
|
+
- name: Lint Alertmanager config
|
|
85
|
+
run: amlint check alertmanager.yml --strict
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Checks
|
|
89
|
+
|
|
90
|
+
| code | level | what it catches |
|
|
91
|
+
|------|-------|-----------------|
|
|
92
|
+
| `undefined-receiver` | error | route references a receiver that doesn't exist |
|
|
93
|
+
| `bad-regex` | error | `match_re` pattern fails to compile |
|
|
94
|
+
| `no-root-route` | error | no root `route` defined |
|
|
95
|
+
| `inhibit-no-equal` | warn | inhibition without `equal` silences too broadly |
|
|
96
|
+
| `unreachable-route` | warn | catch-all hides subsequent sibling routes |
|
|
97
|
+
| `groupby-ellipsis` | warn | `...` mixed with explicit labels in `group_by` |
|
|
98
|
+
| `inhibit-same-match` | info | source and target match the same label value |
|
|
99
|
+
| `unused-receiver` | info | receiver defined but not used in any route |
|
|
100
|
+
|
|
101
|
+
## Tests
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python3 -m pytest test_linter.py -v
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
amlint-0.1.0/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# amlint
|
|
2
|
+
|
|
3
|
+
Semantic linter for Prometheus Alertmanager configs.
|
|
4
|
+
|
|
5
|
+
`amtool check-config` validates syntax. **amlint validates semantics:**
|
|
6
|
+
will alerts actually reach a receiver, does the inhibition rule do anything,
|
|
7
|
+
are there unreachable routing branches? These are the bugs that burn teams —
|
|
8
|
+
config is valid, alerts silently vanish.
|
|
9
|
+
|
|
10
|
+
## Why
|
|
11
|
+
|
|
12
|
+
Alertmanager configs are YAML routing trees with inhibition rules and receivers.
|
|
13
|
+
The most painful mistakes are syntactically valid:
|
|
14
|
+
|
|
15
|
+
- route references a receiver that doesn't exist → **alerts are dropped**
|
|
16
|
+
- inhibition without `equal` → silences unrelated alerts, you think it's quiet, there's actually a fire
|
|
17
|
+
- catch-all branch before specific ones → specific branches **are unreachable**
|
|
18
|
+
- `match_re` that doesn't compile
|
|
19
|
+
- `group_by` that doesn't behave the way you think
|
|
20
|
+
|
|
21
|
+
`amtool` won't catch any of this. amlint will.
|
|
22
|
+
|
|
23
|
+
## Install
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install pyyaml
|
|
27
|
+
pip install -e .
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
amlint check alertmanager.yml
|
|
34
|
+
amlint check alertmanager.yml --strict # WARN also exits non-zero
|
|
35
|
+
amlint check alertmanager.yml --format json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Example output:
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
ERROR Route references receiver 'pager-team' which is not defined in receivers. Alerts matched here will be dropped.
|
|
42
|
+
↳ route.routes[1] [undefined-receiver]
|
|
43
|
+
|
|
44
|
+
WARN Catch-all route (no matchers) with continue:false will intercept all alerts — 2 subsequent sibling(s) are unreachable.
|
|
45
|
+
↳ route.routes[0] [unreachable-route]
|
|
46
|
+
|
|
47
|
+
2 error · 3 warn · 1 info
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Exit code `1` on ERROR — ready for CI. `--strict` makes WARN block too.
|
|
51
|
+
|
|
52
|
+
## CI example
|
|
53
|
+
|
|
54
|
+
```yaml
|
|
55
|
+
# .github/workflows/lint.yml
|
|
56
|
+
- name: Lint Alertmanager config
|
|
57
|
+
run: amlint check alertmanager.yml --strict
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Checks
|
|
61
|
+
|
|
62
|
+
| code | level | what it catches |
|
|
63
|
+
|------|-------|-----------------|
|
|
64
|
+
| `undefined-receiver` | error | route references a receiver that doesn't exist |
|
|
65
|
+
| `bad-regex` | error | `match_re` pattern fails to compile |
|
|
66
|
+
| `no-root-route` | error | no root `route` defined |
|
|
67
|
+
| `inhibit-no-equal` | warn | inhibition without `equal` silences too broadly |
|
|
68
|
+
| `unreachable-route` | warn | catch-all hides subsequent sibling routes |
|
|
69
|
+
| `groupby-ellipsis` | warn | `...` mixed with explicit labels in `group_by` |
|
|
70
|
+
| `inhibit-same-match` | info | source and target match the same label value |
|
|
71
|
+
| `unused-receiver` | info | receiver defined but not used in any route |
|
|
72
|
+
|
|
73
|
+
## Tests
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
python3 -m pytest test_linter.py -v
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""amlint CLI."""
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
import yaml
|
|
11
|
+
except ImportError:
|
|
12
|
+
print("pyyaml is required: pip install pyyaml", file=sys.stderr)
|
|
13
|
+
sys.exit(2)
|
|
14
|
+
|
|
15
|
+
from .linter import lint, ERROR, WARN, INFO
|
|
16
|
+
|
|
17
|
+
_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _c(code, s):
|
|
21
|
+
return f"\033[{code}m{s}\033[0m" if _COLOR else s
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
BADGE = {
|
|
25
|
+
ERROR: _c("31", "ERROR"),
|
|
26
|
+
WARN: _c("33", "WARN "),
|
|
27
|
+
INFO: _c("36", "INFO "),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load(path):
|
|
32
|
+
if not os.path.exists(path):
|
|
33
|
+
print(f"File not found: {path}", file=sys.stderr)
|
|
34
|
+
sys.exit(2)
|
|
35
|
+
with open(path) as f:
|
|
36
|
+
try:
|
|
37
|
+
return yaml.safe_load(f) or {}
|
|
38
|
+
except yaml.YAMLError as e:
|
|
39
|
+
print(f"Failed to parse YAML: {e}", file=sys.stderr)
|
|
40
|
+
sys.exit(2)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def main(argv=None):
|
|
44
|
+
p = argparse.ArgumentParser(
|
|
45
|
+
prog="amlint",
|
|
46
|
+
description="Semantic linter for Alertmanager configs. Catches what amtool misses.",
|
|
47
|
+
)
|
|
48
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
49
|
+
pc = sub.add_parser("check", help="validate a config file")
|
|
50
|
+
pc.add_argument("file", help="path to alertmanager.yml")
|
|
51
|
+
pc.add_argument("--format", choices=["text", "json"], default="text")
|
|
52
|
+
pc.add_argument("--strict", action="store_true", help="exit non-zero on WARN as well")
|
|
53
|
+
|
|
54
|
+
args = p.parse_args(argv)
|
|
55
|
+
cfg = load(args.file)
|
|
56
|
+
findings = lint(cfg)
|
|
57
|
+
|
|
58
|
+
if args.format == "json":
|
|
59
|
+
print(json.dumps(
|
|
60
|
+
[{"level": f.level, "code": f.code, "message": f.msg, "where": f.where} for f in findings],
|
|
61
|
+
ensure_ascii=False, indent=2,
|
|
62
|
+
))
|
|
63
|
+
else:
|
|
64
|
+
if not findings:
|
|
65
|
+
print(_c("32", " \u2713 Проблем не знайдено.\n"))
|
|
66
|
+
else:
|
|
67
|
+
print()
|
|
68
|
+
for f in findings:
|
|
69
|
+
loc = _c("2", f" {f.where}") if f.where else ""
|
|
70
|
+
print(f" {BADGE[f.level]} {f.msg}")
|
|
71
|
+
if loc:
|
|
72
|
+
arrow = "\u21b3 "
|
|
73
|
+
print(f" {_c('2', arrow + f.where)} {_c('2', '[' + f.code + ']')}")
|
|
74
|
+
print()
|
|
75
|
+
errs = sum(1 for f in findings if f.level == ERROR)
|
|
76
|
+
warns = sum(1 for f in findings if f.level == WARN)
|
|
77
|
+
infos = sum(1 for f in findings if f.level == INFO)
|
|
78
|
+
print(f" {errs} error \u00b7 {warns} warn \u00b7 {infos} info\n")
|
|
79
|
+
|
|
80
|
+
has_err = any(f.level == ERROR for f in findings)
|
|
81
|
+
has_warn = any(f.level == WARN for f in findings)
|
|
82
|
+
if has_err or (args.strict and has_warn):
|
|
83
|
+
return 1
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
sys.exit(main())
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
amlint - semantic linter for Prometheus Alertmanager configs.
|
|
4
|
+
|
|
5
|
+
amtool check-config validates syntax. amlint validates SEMANTICS:
|
|
6
|
+
whether alerts will actually be delivered, whether inhibition rules
|
|
7
|
+
fire correctly, whether routing branches are reachable.
|
|
8
|
+
|
|
9
|
+
Usage:
|
|
10
|
+
amlint check alertmanager.yml
|
|
11
|
+
amlint check alertmanager.yml --format json
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
ERROR = "error" # config is broken: alerts will be lost
|
|
19
|
+
WARN = "warn" # almost certainly not what you intended
|
|
20
|
+
INFO = "info" # suspicious, worth a look
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Finding:
|
|
24
|
+
__slots__ = ("level", "code", "msg", "where")
|
|
25
|
+
|
|
26
|
+
def __init__(self, level, code, msg, where=""):
|
|
27
|
+
self.level = level
|
|
28
|
+
self.code = code
|
|
29
|
+
self.msg = msg
|
|
30
|
+
self.where = where
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _defined_receivers(cfg):
|
|
34
|
+
return {r.get("name") for r in cfg.get("receivers", []) if r.get("name")}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _walk_routes(node, path="route"):
|
|
38
|
+
"""Yields (route_node, path) for the root and all nested routes."""
|
|
39
|
+
yield node, path
|
|
40
|
+
for i, child in enumerate(node.get("routes", []) or []):
|
|
41
|
+
yield from _walk_routes(child, f"{path}.routes[{i}]")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# CHECK 1: route references a receiver that doesn't exist
|
|
45
|
+
def check_undefined_receivers(cfg):
|
|
46
|
+
out = []
|
|
47
|
+
defined = _defined_receivers(cfg)
|
|
48
|
+
route = cfg.get("route")
|
|
49
|
+
if not route:
|
|
50
|
+
out.append(Finding(ERROR, "no-root-route", "No root 'route' defined.", "route"))
|
|
51
|
+
return out
|
|
52
|
+
for node, path in _walk_routes(route):
|
|
53
|
+
rcv = node.get("receiver")
|
|
54
|
+
if rcv and rcv not in defined:
|
|
55
|
+
out.append(Finding(
|
|
56
|
+
ERROR, "undefined-receiver",
|
|
57
|
+
f"Route references receiver '{rcv}' which is not defined in receivers. "
|
|
58
|
+
f"Alerts matched here will be dropped.",
|
|
59
|
+
path,
|
|
60
|
+
))
|
|
61
|
+
return out
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# CHECK 2: receiver is defined but never used in any route
|
|
65
|
+
def check_unused_receivers(cfg):
|
|
66
|
+
out = []
|
|
67
|
+
defined = _defined_receivers(cfg)
|
|
68
|
+
used = set()
|
|
69
|
+
route = cfg.get("route")
|
|
70
|
+
if route:
|
|
71
|
+
for node, _ in _walk_routes(route):
|
|
72
|
+
if node.get("receiver"):
|
|
73
|
+
used.add(node["receiver"])
|
|
74
|
+
for name in defined - used:
|
|
75
|
+
out.append(Finding(
|
|
76
|
+
INFO, "unused-receiver",
|
|
77
|
+
f"Receiver '{name}' is defined but not referenced by any route.",
|
|
78
|
+
"receivers",
|
|
79
|
+
))
|
|
80
|
+
return out
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# CHECK 3: inhibition rule that can never fire
|
|
84
|
+
def check_dead_inhibitions(cfg):
|
|
85
|
+
"""
|
|
86
|
+
An inhibition rule silences target alerts when a source alert fires,
|
|
87
|
+
but only when labels in 'equal' match. Missing 'equal' means the rule
|
|
88
|
+
will silence across unrelated alerts — almost never intentional.
|
|
89
|
+
"""
|
|
90
|
+
out = []
|
|
91
|
+
for i, rule in enumerate(cfg.get("inhibit_rules", []) or []):
|
|
92
|
+
where = f"inhibit_rules[{i}]"
|
|
93
|
+
src = {**(rule.get("source_match") or {}), **(rule.get("source_matchers_map") or {})}
|
|
94
|
+
tgt = {**(rule.get("target_match") or {}), **(rule.get("target_matchers_map") or {})}
|
|
95
|
+
equal = rule.get("equal", []) or []
|
|
96
|
+
|
|
97
|
+
if not equal and (src or tgt):
|
|
98
|
+
out.append(Finding(
|
|
99
|
+
WARN, "inhibit-no-equal",
|
|
100
|
+
"Inhibition rule has no 'equal' field: it will silence alerts across "
|
|
101
|
+
"unrelated firing sources (no shared label binding them). Almost always a mistake.",
|
|
102
|
+
where,
|
|
103
|
+
))
|
|
104
|
+
|
|
105
|
+
for key in set(src) & set(tgt):
|
|
106
|
+
if src[key] == tgt[key]:
|
|
107
|
+
out.append(Finding(
|
|
108
|
+
INFO, "inhibit-same-match",
|
|
109
|
+
f"source_match and target_match both have '{key}={src[key]}'. "
|
|
110
|
+
f"Check that the rule is not silencing its own source alert.",
|
|
111
|
+
where,
|
|
112
|
+
))
|
|
113
|
+
return out
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# CHECK 4: match_re / matchers: patterns that fail to compile
|
|
117
|
+
_MATCHER_RE = re.compile(r'^([^=!~]+)(=~|!~)(.+)$')
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _regex_patterns(node):
|
|
121
|
+
"""Yield (label, pattern) for all regex matchers in a route node."""
|
|
122
|
+
for label, pattern in (node.get("match_re") or {}).items():
|
|
123
|
+
yield label, pattern
|
|
124
|
+
for m in node.get("matchers") or []:
|
|
125
|
+
hit = _MATCHER_RE.match(str(m))
|
|
126
|
+
if hit:
|
|
127
|
+
yield hit.group(1).strip(), hit.group(3)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def check_bad_regex(cfg):
|
|
131
|
+
out = []
|
|
132
|
+
route = cfg.get("route")
|
|
133
|
+
if not route:
|
|
134
|
+
return out
|
|
135
|
+
for node, path in _walk_routes(route):
|
|
136
|
+
for label, pattern in _regex_patterns(node):
|
|
137
|
+
try:
|
|
138
|
+
re.compile(pattern)
|
|
139
|
+
except re.error as e:
|
|
140
|
+
out.append(Finding(
|
|
141
|
+
ERROR, "bad-regex",
|
|
142
|
+
f"Regex for '{label}' does not compile: {e}",
|
|
143
|
+
path,
|
|
144
|
+
))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# CHECK 5: unreachable sibling routes behind a catch-all
|
|
149
|
+
def check_unreachable_routes(cfg):
|
|
150
|
+
"""
|
|
151
|
+
Alertmanager evaluates sibling routes in order. If a route matches
|
|
152
|
+
and continue is not true, subsequent siblings won't receive the alert.
|
|
153
|
+
A catch-all route (no matchers) with continue:false makes all following
|
|
154
|
+
siblings unreachable.
|
|
155
|
+
"""
|
|
156
|
+
out = []
|
|
157
|
+
route = cfg.get("route")
|
|
158
|
+
if not route:
|
|
159
|
+
return out
|
|
160
|
+
|
|
161
|
+
def scan_siblings(routes, parent_path):
|
|
162
|
+
for i, child in enumerate(routes or []):
|
|
163
|
+
path = f"{parent_path}.routes[{i}]"
|
|
164
|
+
has_matcher = bool(
|
|
165
|
+
child.get("match") or child.get("match_re") or child.get("matchers")
|
|
166
|
+
)
|
|
167
|
+
is_catch_all = not has_matcher
|
|
168
|
+
cont = child.get("continue", False)
|
|
169
|
+
if is_catch_all and not cont and i < len(routes) - 1:
|
|
170
|
+
out.append(Finding(
|
|
171
|
+
WARN, "unreachable-route",
|
|
172
|
+
f"Catch-all route (no matchers) with continue:false will intercept all alerts "
|
|
173
|
+
f"— {len(routes) - i - 1} subsequent sibling(s) are unreachable.",
|
|
174
|
+
path,
|
|
175
|
+
))
|
|
176
|
+
scan_siblings(child.get("routes"), path)
|
|
177
|
+
|
|
178
|
+
scan_siblings(route.get("routes"), "route")
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# CHECK 6: group_by with '...' mixed with explicit labels
|
|
183
|
+
def check_groupby(cfg):
|
|
184
|
+
out = []
|
|
185
|
+
route = cfg.get("route")
|
|
186
|
+
if not route:
|
|
187
|
+
return out
|
|
188
|
+
for node, path in _walk_routes(route):
|
|
189
|
+
gb = node.get("group_by") or []
|
|
190
|
+
if "..." in gb and len(gb) > 1:
|
|
191
|
+
out.append(Finding(
|
|
192
|
+
WARN, "groupby-ellipsis",
|
|
193
|
+
"group_by contains '...' alongside other labels — '...' already groups by all "
|
|
194
|
+
"labels, making the others redundant. Remove either '...' or the explicit labels.",
|
|
195
|
+
path,
|
|
196
|
+
))
|
|
197
|
+
return out
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
ALL_CHECKS = [
|
|
201
|
+
check_undefined_receivers,
|
|
202
|
+
check_unused_receivers,
|
|
203
|
+
check_dead_inhibitions,
|
|
204
|
+
check_bad_regex,
|
|
205
|
+
check_unreachable_routes,
|
|
206
|
+
check_groupby,
|
|
207
|
+
]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def lint(cfg):
|
|
211
|
+
findings = []
|
|
212
|
+
for check in ALL_CHECKS:
|
|
213
|
+
findings.extend(check(cfg))
|
|
214
|
+
order = {ERROR: 0, WARN: 1, INFO: 2}
|
|
215
|
+
findings.sort(key=lambda f: order[f.level])
|
|
216
|
+
return findings
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: amlint
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Semantic linter for Prometheus Alertmanager configs
|
|
5
|
+
Author: danikdanik2013
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/danikdanik2013/amlint
|
|
8
|
+
Project-URL: Repository, https://github.com/danikdanik2013/amlint
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/danikdanik2013/amlint/issues
|
|
10
|
+
Keywords: alertmanager,prometheus,linter,devops,monitoring
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Classifier: Topic :: Utilities
|
|
23
|
+
Requires-Python: >=3.9
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
Requires-Dist: pyyaml>=5.1
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
28
|
+
|
|
29
|
+
# amlint
|
|
30
|
+
|
|
31
|
+
Semantic linter for Prometheus Alertmanager configs.
|
|
32
|
+
|
|
33
|
+
`amtool check-config` validates syntax. **amlint validates semantics:**
|
|
34
|
+
will alerts actually reach a receiver, does the inhibition rule do anything,
|
|
35
|
+
are there unreachable routing branches? These are the bugs that burn teams —
|
|
36
|
+
config is valid, alerts silently vanish.
|
|
37
|
+
|
|
38
|
+
## Why
|
|
39
|
+
|
|
40
|
+
Alertmanager configs are YAML routing trees with inhibition rules and receivers.
|
|
41
|
+
The most painful mistakes are syntactically valid:
|
|
42
|
+
|
|
43
|
+
- route references a receiver that doesn't exist → **alerts are dropped**
|
|
44
|
+
- inhibition without `equal` → silences unrelated alerts, you think it's quiet, there's actually a fire
|
|
45
|
+
- catch-all branch before specific ones → specific branches **are unreachable**
|
|
46
|
+
- `match_re` that doesn't compile
|
|
47
|
+
- `group_by` that doesn't behave the way you think
|
|
48
|
+
|
|
49
|
+
`amtool` won't catch any of this. amlint will.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install pyyaml
|
|
55
|
+
pip install -e .
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Usage
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
amlint check alertmanager.yml
|
|
62
|
+
amlint check alertmanager.yml --strict # WARN also exits non-zero
|
|
63
|
+
amlint check alertmanager.yml --format json
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Example output:
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
ERROR Route references receiver 'pager-team' which is not defined in receivers. Alerts matched here will be dropped.
|
|
70
|
+
↳ route.routes[1] [undefined-receiver]
|
|
71
|
+
|
|
72
|
+
WARN Catch-all route (no matchers) with continue:false will intercept all alerts — 2 subsequent sibling(s) are unreachable.
|
|
73
|
+
↳ route.routes[0] [unreachable-route]
|
|
74
|
+
|
|
75
|
+
2 error · 3 warn · 1 info
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Exit code `1` on ERROR — ready for CI. `--strict` makes WARN block too.
|
|
79
|
+
|
|
80
|
+
## CI example
|
|
81
|
+
|
|
82
|
+
```yaml
|
|
83
|
+
# .github/workflows/lint.yml
|
|
84
|
+
- name: Lint Alertmanager config
|
|
85
|
+
run: amlint check alertmanager.yml --strict
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## Checks
|
|
89
|
+
|
|
90
|
+
| code | level | what it catches |
|
|
91
|
+
|------|-------|-----------------|
|
|
92
|
+
| `undefined-receiver` | error | route references a receiver that doesn't exist |
|
|
93
|
+
| `bad-regex` | error | `match_re` pattern fails to compile |
|
|
94
|
+
| `no-root-route` | error | no root `route` defined |
|
|
95
|
+
| `inhibit-no-equal` | warn | inhibition without `equal` silences too broadly |
|
|
96
|
+
| `unreachable-route` | warn | catch-all hides subsequent sibling routes |
|
|
97
|
+
| `groupby-ellipsis` | warn | `...` mixed with explicit labels in `group_by` |
|
|
98
|
+
| `inhibit-same-match` | info | source and target match the same label value |
|
|
99
|
+
| `unused-receiver` | info | receiver defined but not used in any route |
|
|
100
|
+
|
|
101
|
+
## Tests
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python3 -m pytest test_linter.py -v
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
amlint/__init__.py
|
|
4
|
+
amlint/cli.py
|
|
5
|
+
amlint/linter.py
|
|
6
|
+
amlint.egg-info/PKG-INFO
|
|
7
|
+
amlint.egg-info/SOURCES.txt
|
|
8
|
+
amlint.egg-info/dependency_links.txt
|
|
9
|
+
amlint.egg-info/entry_points.txt
|
|
10
|
+
amlint.egg-info/requires.txt
|
|
11
|
+
amlint.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
amlint
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "amlint"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Semantic linter for Prometheus Alertmanager configs"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.9"
|
|
7
|
+
license = {text = "MIT"}
|
|
8
|
+
authors = [{name = "danikdanik2013"}]
|
|
9
|
+
keywords = ["alertmanager", "prometheus", "linter", "devops", "monitoring"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 4 - Beta",
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: Developers",
|
|
14
|
+
"Intended Audience :: System Administrators",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: System :: Monitoring",
|
|
22
|
+
"Topic :: Utilities",
|
|
23
|
+
]
|
|
24
|
+
dependencies = ["pyyaml>=5.1"]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=7"]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/danikdanik2013/amlint"
|
|
31
|
+
Repository = "https://github.com/danikdanik2013/amlint"
|
|
32
|
+
"Bug Tracker" = "https://github.com/danikdanik2013/amlint/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
amlint = "amlint.cli:main"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["setuptools>=61", "wheel"]
|
|
39
|
+
build-backend = "setuptools.build_meta"
|
amlint-0.1.0/setup.cfg
ADDED