Metadata-Version: 2.4
Name: amlint
Version: 0.4.0
Summary: Semantic linter for Prometheus Alertmanager configs
Author: danikdanik2013
License-Expression: MIT
Project-URL: Homepage, https://github.com/danikdanik2013/amlint
Project-URL: Repository, https://github.com/danikdanik2013/amlint
Project-URL: Bug Tracker, https://github.com/danikdanik2013/amlint/issues
Keywords: alertmanager,prometheus,linter,devops,monitoring
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: System Administrators
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: System :: Monitoring
Classifier: Topic :: Utilities
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: pyyaml>=5.1
Requires-Dist: rich>=13
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == "dev"
Requires-Dist: pytest-cov>=4; extra == "dev"
Requires-Dist: ruff>=0.4; extra == "dev"
Requires-Dist: argcomplete>=3; extra == "dev"
Requires-Dist: tomli>=2; python_version < "3.11" and extra == "dev"
Provides-Extra: completions
Requires-Dist: argcomplete>=3; extra == "completions"
Provides-Extra: docs
Requires-Dist: mkdocs-material>=9; extra == "docs"
Dynamic: license-file

# amlint

[![CI](https://github.com/danikdanik2013/amlint/actions/workflows/ci.yml/badge.svg)](https://github.com/danikdanik2013/amlint/actions/workflows/ci.yml)
[![Docs](https://img.shields.io/badge/docs-github.io-blue)](https://danikdanik2013.github.io/amlint)
[![Coverage](https://coveralls.io/repos/github/danikdanik2013/amlint/badge.svg?branch=main)](https://coveralls.io/github/danikdanik2013/amlint)
[![PyPI](https://img.shields.io/pypi/v/amlint)](https://pypi.org/project/amlint)
[![Python](https://img.shields.io/pypi/pyversions/amlint)](https://pypi.org/project/amlint)
[![Docker](https://ghcr-badge.egpl.dev/danikdanik2013/amlint/size?tag=latest&label=docker)](https://github.com/danikdanik2013/amlint/pkgs/container/amlint)

Semantic linter for Prometheus Alertmanager configs.

Alertmanager's own `amtool check-config` already validates most schema-level mistakes —
undefined receivers, missing integration fields, bad regex — and refuses to start over them.
**amlint covers that same ground in one dependency-free CLI with JSON/SARIF/diff output,
plus 13 checks that amtool doesn't do: bugs that are structurally valid, so Alertmanager
starts and runs, and just silently doesn't do what you meant.**

## Why

Alertmanager configs are YAML routing trees with inhibition rules and receivers. The
checks that actually earn their keep are the ones amtool can't do, because they're not
schema violations — they're valid configs that behave wrong:

- catch-all branch before specific ones → specific branches **are unreachable** (config loads fine)
- inhibition without `equal` → silences unrelated alerts, config loads fine, you think it's quiet
- two sibling routes with identical matchers → the second **never fires**, no error anywhere
- `group_wait` longer than `group_interval`, or `repeat_interval` shorter than `group_interval` → timing that quietly doesn't do what the numbers suggest
- receiver defined but never routed to, or routed to but has no integration configured

None of this fails `amtool check-config` or stops Alertmanager from starting. Run
`amlint list` to see which of the 31 checks are these vs. which duplicate amtool's own
validation (included for convenience — one tool, one output format, no Go binary needed).

## Install

```bash
pip install amlint
```

Or with Docker (no Python required):

```bash
docker run --rm -v $(pwd):/cfg ghcr.io/danikdanik2013/amlint check /cfg/alertmanager.yml
```

## Usage

```bash
amlint check alertmanager.yml
amlint check prod.yml staging.yml           # multiple files
cat alertmanager.yml | amlint check -       # stdin
amlint check alertmanager.yml --strict      # WARN also exits non-zero
amlint check alertmanager.yml --format json
amlint check alertmanager.yml --format sarif > results.sarif  # GitHub Code Scanning
amlint check alertmanager.yml --format github  # PR annotations straight from a workflow step
amlint check alertmanager.yml --ignore empty-receiver,unused-receiver
amlint check alertmanager.yml --only undefined-receiver,bad-regex  # run only these checks
amlint check alertmanager.yml --exit-zero   # always exits 0, just show findings
amlint diff old.yml new.yml                 # what changed: findings, and alerts routed differently
amlint tree alertmanager.yml                # visualize the route tree, issues inline
amlint route alertmanager.yml team=infra severity=critical  # where does this alert go?
amlint test alertmanager.yml tests.yml      # routing unit tests: labels -> expected receivers
amlint init > alertmanager.yml              # generate minimal valid config
amlint list                                 # all check codes with level + description
amlint explain undefined-receiver           # detailed explanation + examples
```

**Project config** — `.amlint.yml` or `pyproject.toml [tool.amlint]`:

```yaml
# .amlint.yml
ignore:
  - empty-receiver
strict: true
severity:
  unused-receiver: error   # upgrade info → error
```

```toml
# pyproject.toml
[tool.amlint]
ignore = ["empty-receiver"]
strict = true
```

**Inline suppressions** — silence a check for one spot, with the reason next to it:

```yaml
# amlint: disable-file=global-resolve-timeout-missing     ← top of file: whole file

receivers:
  - name: blackhole   # amlint: disable=empty-receiver     ← this line
  # amlint: disable=unused-receiver                       ← or the line below
  - name: spare
```

**Shell completions (bash/zsh):**
```bash
pip install "amlint[completions]"
eval "$(register-python-argcomplete amlint)"  # add to ~/.zshrc or ~/.bashrc
```

Example output:

```
  ✖  ERROR  [undefined-receiver]  ·  alertmanager.yml:10:7  ·  route.routes[1]
     Route references receiver 'pager-team' which is not defined in receivers. Alerts matched here will be dropped.

  ⚠  WARN   [unreachable-route]  ·  alertmanager.yml:8:7  ·  route.routes[0]
     Catch-all route (no matchers) with continue:false will intercept all alerts — 2 subsequent sibling(s) are unreachable.

  ✖ 2 errors  ·  ⚠ 5 warnings  ·  ℹ 3 info
```

Every finding carries `file:line:col`, so terminals and editors can jump straight to it.

Exit code `1` on ERROR — ready for CI. `--strict` makes WARN block too.

## Routing tests

`amlint route` shows where one alert goes: the routes it matches, its receivers, and the
`group_by` and timings it inherits. `amlint test` turns that into unit tests for CI:

```yaml
# tests.yml
- name: critical infra alerts page infra and the on-call
  labels: {team: infra, severity: critical}
  expect: [infra-pager, pager]   # any order
- labels: {team: unknown}
  expect: [default]
```

```bash
amlint test alertmanager.yml tests.yml   # exit 1 on any failing case, with a diff
```

The simulation follows Alertmanager's own routing code: `continue: true` fan-out,
inherited settings, fully anchored Go RE2 regexes, missing labels matching as `""`.
`amlint diff` uses it too: after the findings it lists the label sets that two configs
route differently, e.g. `team=db, severity=critical: pagerduty-db → slack-db`.
Details in [docs/usage.md](docs/usage.md#route).

## CI example

```yaml
# .github/workflows/lint.yml
- name: Lint Alertmanager config
  run: amlint check alertmanager.yml --strict --format github  # findings annotate the PR diff
```

### As a GitHub Action

```yaml
- uses: actions/checkout@v7
- uses: danikdanik2013/amlint@v0.4.0
  with:
    config-path: alertmanager.yml
    strict: true
```

No `pip install` step needed. Supports `--ignore`/`--only` via inputs and optional SARIF upload
for PR annotations — see the [GitHub Action docs](https://danikdanik2013.github.io/amlint/github-action/).

## Checks

| code | level | what it catches |
|------|-------|-----------------|
| `undefined-receiver` | error | route references a receiver that doesn't exist |
| `invalid-root-route` | error | root route has no `receiver`, or has matchers / `continue` / time intervals |
| `bad-regex` | error | regex fails to compile or uses syntax Go RE2 rejects (lookarounds, backrefs) |
| `no-root-route` | error | no root `route` defined |
| `duplicate-receiver` | error | receiver name defined more than once |
| `undefined-time-interval` | error | `mute_time_intervals` / `active_time_intervals` references unknown interval |
| `email-no-smarthost` | error | `email_configs` without `smarthost` and no global SMTP |
| `webhook-no-url` | error | `webhook_configs` without `url` or `url_file` |
| `pagerduty-no-routing-key` | error | `pagerduty_configs` without `routing_key` |
| `slack-no-api-url` | error | `slack_configs` without `api_url` and no global |
| `opsgenie-no-api-key` | error | `opsgenie_configs` without `api_key` and no global |
| `msteams-no-webhook-url` | error | `msteams_configs` without `webhook_url` |
| `telegram-no-bot-token` | error | `telegram_configs` without `bot_token` |
| `discord-no-webhook-url` | error | `discord_configs` without `webhook_url` |
| `victorops-no-api-key` | error | `victorops_configs` without `api_key` and no global |
| `wechat-no-corp-id` | error | `wechat_configs` without `corp_id` and no global |
| `sns-no-target` | error | `sns_configs` missing `topic_arn`/`phone_number`/`target_arn` |
| `template-file-missing` | error/warn | `templates:` references a file that doesn't exist |
| `inhibit-no-equal` | warn | inhibition without `equal` silences too broadly |
| `unreachable-route` | warn | catch-all hides subsequent sibling routes |
| `groupby-ellipsis` | warn | `...` mixed with explicit labels in `group_by` |
| `repeat-before-group` | warn | `repeat_interval` shorter than `group_interval` |
| `circular-inhibition` | warn | two inhibition rules that silence each other |
| `wait-exceeds-interval` | warn | `group_wait` longer than `group_interval` |
| `empty-receiver` | warn/info | receiver has no integration configured — alerts will be dropped |
| `unused-receiver` | info | receiver defined but not used in any route |
| `inhibit-same-match` | info | source and target match the same label value |
| `useless-continue` | info | `continue:true` on the last sibling route has no effect |
| `deep-nesting` | info | route tree deeper than 5 levels |
| `global-resolve-timeout-missing` | info | `global.resolve_timeout` not set; defaults to 5m |
| `route-match-collision` | warn | two sibling routes with identical matchers |

Run `amlint explain <code>` for detailed description and examples of any check.

## Tests

```bash
python3 -m pytest test_linter.py -v
```

## License

MIT
