chap-checker 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.
- chap_checker-0.1.0/PKG-INFO +139 -0
- chap_checker-0.1.0/README.md +111 -0
- chap_checker-0.1.0/pyproject.toml +124 -0
- chap_checker-0.1.0/src/chap_checker/__init__.py +10 -0
- chap_checker-0.1.0/src/chap_checker/__main__.py +6 -0
- chap_checker-0.1.0/src/chap_checker/alerts/__init__.py +6 -0
- chap_checker-0.1.0/src/chap_checker/alerts/base.py +99 -0
- chap_checker-0.1.0/src/chap_checker/alerts/slack.py +125 -0
- chap_checker-0.1.0/src/chap_checker/checks/__init__.py +37 -0
- chap_checker-0.1.0/src/chap_checker/checks/base.py +127 -0
- chap_checker-0.1.0/src/chap_checker/checks/dhis2_chap_modeling_app.py +118 -0
- chap_checker-0.1.0/src/chap_checker/checks/dhis2_chap_ping.py +56 -0
- chap_checker-0.1.0/src/chap_checker/checks/dhis2_chap_route.py +121 -0
- chap_checker-0.1.0/src/chap_checker/checks/dhis2_chap_system_info.py +103 -0
- chap_checker-0.1.0/src/chap_checker/checks/dhis2_ping.py +95 -0
- chap_checker-0.1.0/src/chap_checker/checks/dhis2_system_info.py +89 -0
- chap_checker-0.1.0/src/chap_checker/cli.py +951 -0
- chap_checker-0.1.0/src/chap_checker/client.py +112 -0
- chap_checker-0.1.0/src/chap_checker/config.py +255 -0
- chap_checker-0.1.0/src/chap_checker/dashboard.py +702 -0
- chap_checker-0.1.0/src/chap_checker/logging.py +32 -0
- chap_checker-0.1.0/src/chap_checker/output.py +69 -0
- chap_checker-0.1.0/src/chap_checker/runner.py +151 -0
- chap_checker-0.1.0/src/chap_checker/state.py +13 -0
- chap_checker-0.1.0/src/chap_checker/state_store.py +184 -0
- chap_checker-0.1.0/src/chap_checker/web.py +337 -0
- chap_checker-0.1.0/src/chap_checker/web_ui/_state.js +151 -0
- chap_checker-0.1.0/src/chap_checker/web_ui/index.html +102 -0
- chap_checker-0.1.0/src/chap_checker/web_ui/src/app.jsx +448 -0
- chap_checker-0.1.0/src/chap_checker/web_ui/src/card.jsx +537 -0
- chap_checker-0.1.0/src/chap_checker/web_ui/src/palette.jsx +105 -0
- chap_checker-0.1.0/src/chap_checker/web_ui/src/tweaks-panel.jsx +568 -0
- chap_checker-0.1.0/src/chap_checker/web_ui/vendor/babel.min.js +4 -0
- chap_checker-0.1.0/src/chap_checker/web_ui/vendor/react-dom.production.min.js +267 -0
- chap_checker-0.1.0/src/chap_checker/web_ui/vendor/react.production.min.js +31 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: chap-checker
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Health-check CLI for DHIS2 instances integrated with chap-core; cron-friendly with Slack alerts on status transitions.
|
|
5
|
+
Keywords: dhis2,chap,chap-core,healthcheck,monitoring,slack,cli
|
|
6
|
+
Author: Morten Hansen
|
|
7
|
+
Author-email: Morten Hansen <morten@dhis2.org>
|
|
8
|
+
License: AGPL-3.0-or-later
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: Intended Audience :: System Administrators
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Topic :: System :: Monitoring
|
|
15
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
16
|
+
Requires-Dist: typer>=0.24.2
|
|
17
|
+
Requires-Dist: httpx>=0.28.1
|
|
18
|
+
Requires-Dist: pydantic>=2.9.0
|
|
19
|
+
Requires-Dist: rich>=13.9.0
|
|
20
|
+
Requires-Dist: textual>=8.0.0
|
|
21
|
+
Requires-Dist: fastapi>=0.115.0
|
|
22
|
+
Requires-Dist: uvicorn[standard]>=0.30.0
|
|
23
|
+
Requires-Python: >=3.13
|
|
24
|
+
Project-URL: Homepage, https://github.com/dhis2-chap/chap-checker
|
|
25
|
+
Project-URL: Repository, https://github.com/dhis2-chap/chap-checker
|
|
26
|
+
Project-URL: Issues, https://github.com/dhis2-chap/chap-checker/issues
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# chap-checker
|
|
30
|
+
|
|
31
|
+
[](https://github.com/dhis2-chap/chap-checker/actions/workflows/ci.yml)
|
|
32
|
+
[](https://pypi.org/project/chap-checker/)
|
|
33
|
+
[](https://www.python.org/downloads/)
|
|
34
|
+
[](https://www.gnu.org/licenses/agpl-3.0)
|
|
35
|
+
[](https://dhis2-chap.github.io/chap-checker/)
|
|
36
|
+
|
|
37
|
+
A small command-line health-check and alerting tool for DHIS2 instances that
|
|
38
|
+
integrate with `chap-core` via a DHIS2 route. Cron-friendly, with optional
|
|
39
|
+
Slack alerts on status transitions and a Textual TUI dashboard for the
|
|
40
|
+
at-a-glance "leave it on a TV" view.
|
|
41
|
+
|
|
42
|
+
**Documentation:** <https://dhis2-chap.github.io/chap-checker>
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
# One-shot run without installing (no PATH pollution):
|
|
48
|
+
uvx chap-checker --version
|
|
49
|
+
uvx chap-checker verify --url https://dhis2.example.com --token-env DHIS2_TOKEN
|
|
50
|
+
|
|
51
|
+
# Persistent install into uv's isolated tool environment:
|
|
52
|
+
uv tool install chap-checker
|
|
53
|
+
chap-checker --version
|
|
54
|
+
|
|
55
|
+
# Upgrade to the latest release later:
|
|
56
|
+
uv tool upgrade chap-checker
|
|
57
|
+
|
|
58
|
+
# Or, if you're embedding into another uv project:
|
|
59
|
+
uv add chap-checker
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Quick start
|
|
63
|
+
|
|
64
|
+
Ad-hoc against a single instance (credentials resolved safely):
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# With a DHIS2 Personal Access Token (recommended on modern servers):
|
|
68
|
+
export PROD_TOKEN=...
|
|
69
|
+
chap-checker verify \
|
|
70
|
+
--url https://dhis2.example.com \
|
|
71
|
+
--token-env PROD_TOKEN
|
|
72
|
+
|
|
73
|
+
# With a password (Basic auth) read from a named env var:
|
|
74
|
+
export PROD_PASSWORD=...
|
|
75
|
+
chap-checker verify \
|
|
76
|
+
--url https://dhis2.example.com \
|
|
77
|
+
--username admin \
|
|
78
|
+
--password-env PROD_PASSWORD
|
|
79
|
+
|
|
80
|
+
# Omit --password / --token entirely and you'll be prompted on the
|
|
81
|
+
# terminal (hidden input). DHIS2_TOKEN / DHIS2_PASSWORD env vars
|
|
82
|
+
# work as defaults too. Passing --password / --token inline still
|
|
83
|
+
# works but is discouraged - the value lands in shell history and
|
|
84
|
+
# `ps` output. Token and password flags are mutually exclusive.
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Multiple instances in `./chap-checker.toml`:
|
|
88
|
+
|
|
89
|
+
```toml
|
|
90
|
+
[instances.prod]
|
|
91
|
+
url = "https://dhis2.example.com"
|
|
92
|
+
username = "ops"
|
|
93
|
+
password_env = "PROD_PASS"
|
|
94
|
+
alerts = ["slack"]
|
|
95
|
+
|
|
96
|
+
[alerts.slack]
|
|
97
|
+
webhook_url_env = "SLACK_WEBHOOK_URL"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Then `chap-checker verify` runs every configured instance and pages Slack on
|
|
101
|
+
status transitions. See [chap-checker.toml.example](./chap-checker.toml.example)
|
|
102
|
+
for the full template.
|
|
103
|
+
|
|
104
|
+
The TUI:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
chap-checker dashboard
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Built-in checks
|
|
111
|
+
|
|
112
|
+
Two namespaces — `dhis2_*` probes DHIS2 itself, `dhis2_chap_*` probes
|
|
113
|
+
chap-core through the DHIS2 route. Each tile in the dashboard, each row in
|
|
114
|
+
`chap-checker checks list`, each entry in the JSON output:
|
|
115
|
+
|
|
116
|
+
- `dhis2_ping` — `/api/me`
|
|
117
|
+
- `dhis2_system_info` — `/api/system/info`
|
|
118
|
+
- `dhis2_chap_route` — `/api/routes?filter=code:eq:chap`
|
|
119
|
+
- `dhis2_chap_ping` — `/api/routes/chap/run/health`
|
|
120
|
+
- `dhis2_chap_system_info` — `/api/routes/chap/run/system/info`
|
|
121
|
+
- `dhis2_chap_modeling_app` — `/api/apps` (matched by `app_hub_id`)
|
|
122
|
+
|
|
123
|
+
Full reference + endpoint details: [Built-in checks](https://dhis2-chap.github.io/chap-checker/guides/checks/).
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
```bash
|
|
128
|
+
make install
|
|
129
|
+
make lint # ruff + mypy + pyright
|
|
130
|
+
make test
|
|
131
|
+
make docs # serve docs locally
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
See [Development](https://dhis2-chap.github.io/chap-checker/guides/development/)
|
|
135
|
+
for repo layout and house rules.
|
|
136
|
+
|
|
137
|
+
## License
|
|
138
|
+
|
|
139
|
+
AGPL-3.0-or-later
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# chap-checker
|
|
2
|
+
|
|
3
|
+
[](https://github.com/dhis2-chap/chap-checker/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/chap-checker/)
|
|
5
|
+
[](https://www.python.org/downloads/)
|
|
6
|
+
[](https://www.gnu.org/licenses/agpl-3.0)
|
|
7
|
+
[](https://dhis2-chap.github.io/chap-checker/)
|
|
8
|
+
|
|
9
|
+
A small command-line health-check and alerting tool for DHIS2 instances that
|
|
10
|
+
integrate with `chap-core` via a DHIS2 route. Cron-friendly, with optional
|
|
11
|
+
Slack alerts on status transitions and a Textual TUI dashboard for the
|
|
12
|
+
at-a-glance "leave it on a TV" view.
|
|
13
|
+
|
|
14
|
+
**Documentation:** <https://dhis2-chap.github.io/chap-checker>
|
|
15
|
+
|
|
16
|
+
## Install
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
# One-shot run without installing (no PATH pollution):
|
|
20
|
+
uvx chap-checker --version
|
|
21
|
+
uvx chap-checker verify --url https://dhis2.example.com --token-env DHIS2_TOKEN
|
|
22
|
+
|
|
23
|
+
# Persistent install into uv's isolated tool environment:
|
|
24
|
+
uv tool install chap-checker
|
|
25
|
+
chap-checker --version
|
|
26
|
+
|
|
27
|
+
# Upgrade to the latest release later:
|
|
28
|
+
uv tool upgrade chap-checker
|
|
29
|
+
|
|
30
|
+
# Or, if you're embedding into another uv project:
|
|
31
|
+
uv add chap-checker
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
Ad-hoc against a single instance (credentials resolved safely):
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# With a DHIS2 Personal Access Token (recommended on modern servers):
|
|
40
|
+
export PROD_TOKEN=...
|
|
41
|
+
chap-checker verify \
|
|
42
|
+
--url https://dhis2.example.com \
|
|
43
|
+
--token-env PROD_TOKEN
|
|
44
|
+
|
|
45
|
+
# With a password (Basic auth) read from a named env var:
|
|
46
|
+
export PROD_PASSWORD=...
|
|
47
|
+
chap-checker verify \
|
|
48
|
+
--url https://dhis2.example.com \
|
|
49
|
+
--username admin \
|
|
50
|
+
--password-env PROD_PASSWORD
|
|
51
|
+
|
|
52
|
+
# Omit --password / --token entirely and you'll be prompted on the
|
|
53
|
+
# terminal (hidden input). DHIS2_TOKEN / DHIS2_PASSWORD env vars
|
|
54
|
+
# work as defaults too. Passing --password / --token inline still
|
|
55
|
+
# works but is discouraged - the value lands in shell history and
|
|
56
|
+
# `ps` output. Token and password flags are mutually exclusive.
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Multiple instances in `./chap-checker.toml`:
|
|
60
|
+
|
|
61
|
+
```toml
|
|
62
|
+
[instances.prod]
|
|
63
|
+
url = "https://dhis2.example.com"
|
|
64
|
+
username = "ops"
|
|
65
|
+
password_env = "PROD_PASS"
|
|
66
|
+
alerts = ["slack"]
|
|
67
|
+
|
|
68
|
+
[alerts.slack]
|
|
69
|
+
webhook_url_env = "SLACK_WEBHOOK_URL"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Then `chap-checker verify` runs every configured instance and pages Slack on
|
|
73
|
+
status transitions. See [chap-checker.toml.example](./chap-checker.toml.example)
|
|
74
|
+
for the full template.
|
|
75
|
+
|
|
76
|
+
The TUI:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
chap-checker dashboard
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Built-in checks
|
|
83
|
+
|
|
84
|
+
Two namespaces — `dhis2_*` probes DHIS2 itself, `dhis2_chap_*` probes
|
|
85
|
+
chap-core through the DHIS2 route. Each tile in the dashboard, each row in
|
|
86
|
+
`chap-checker checks list`, each entry in the JSON output:
|
|
87
|
+
|
|
88
|
+
- `dhis2_ping` — `/api/me`
|
|
89
|
+
- `dhis2_system_info` — `/api/system/info`
|
|
90
|
+
- `dhis2_chap_route` — `/api/routes?filter=code:eq:chap`
|
|
91
|
+
- `dhis2_chap_ping` — `/api/routes/chap/run/health`
|
|
92
|
+
- `dhis2_chap_system_info` — `/api/routes/chap/run/system/info`
|
|
93
|
+
- `dhis2_chap_modeling_app` — `/api/apps` (matched by `app_hub_id`)
|
|
94
|
+
|
|
95
|
+
Full reference + endpoint details: [Built-in checks](https://dhis2-chap.github.io/chap-checker/guides/checks/).
|
|
96
|
+
|
|
97
|
+
## Development
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
make install
|
|
101
|
+
make lint # ruff + mypy + pyright
|
|
102
|
+
make test
|
|
103
|
+
make docs # serve docs locally
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
See [Development](https://dhis2-chap.github.io/chap-checker/guides/development/)
|
|
107
|
+
for repo layout and house rules.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
AGPL-3.0-or-later
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "chap-checker"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Health-check CLI for DHIS2 instances integrated with chap-core; cron-friendly with Slack alerts on status transitions."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Morten Hansen", email = "morten@dhis2.org" }]
|
|
7
|
+
license = { text = "AGPL-3.0-or-later" }
|
|
8
|
+
requires-python = ">=3.13"
|
|
9
|
+
keywords = ["dhis2", "chap", "chap-core", "healthcheck", "monitoring", "slack", "cli"]
|
|
10
|
+
classifiers = [
|
|
11
|
+
"Development Status :: 3 - Alpha",
|
|
12
|
+
"Intended Audience :: Developers",
|
|
13
|
+
"Intended Audience :: System Administrators",
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Programming Language :: Python :: 3.13",
|
|
16
|
+
"Topic :: System :: Monitoring",
|
|
17
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"typer>=0.24.2",
|
|
21
|
+
"httpx>=0.28.1",
|
|
22
|
+
"pydantic>=2.9.0",
|
|
23
|
+
"rich>=13.9.0",
|
|
24
|
+
"textual>=8.0.0",
|
|
25
|
+
"fastapi>=0.115.0",
|
|
26
|
+
"uvicorn[standard]>=0.30.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/dhis2-chap/chap-checker"
|
|
31
|
+
Repository = "https://github.com/dhis2-chap/chap-checker"
|
|
32
|
+
Issues = "https://github.com/dhis2-chap/chap-checker/issues"
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
chap-checker = "chap_checker.cli:main"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"coverage[toml]>=7.6.0",
|
|
40
|
+
"mkdocs>=1.6.0",
|
|
41
|
+
"mkdocs-material>=9.5.0",
|
|
42
|
+
"mypy>=1.13.0",
|
|
43
|
+
"pyright>=1.1.400",
|
|
44
|
+
"pytest>=8.3.0",
|
|
45
|
+
"pytest-asyncio>=0.24.0",
|
|
46
|
+
"ruff>=0.7.0",
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
[build-system]
|
|
50
|
+
requires = ["uv_build>=0.9.0,<0.12.0"]
|
|
51
|
+
build-backend = "uv_build"
|
|
52
|
+
|
|
53
|
+
[tool.ruff]
|
|
54
|
+
target-version = "py313"
|
|
55
|
+
line-length = 120
|
|
56
|
+
|
|
57
|
+
[tool.ruff.lint]
|
|
58
|
+
fixable = ["ALL"]
|
|
59
|
+
select = ["E", "W", "F", "I", "D"]
|
|
60
|
+
ignore = ["D203", "D213"]
|
|
61
|
+
|
|
62
|
+
[tool.ruff.lint.pydocstyle]
|
|
63
|
+
convention = "google"
|
|
64
|
+
|
|
65
|
+
[tool.ruff.lint.per-file-ignores]
|
|
66
|
+
"tests/**/*.py" = ["D"]
|
|
67
|
+
"**/__init__.py" = ["D104"]
|
|
68
|
+
"src/**/*.py" = ["D102", "D105", "D107"]
|
|
69
|
+
|
|
70
|
+
[tool.ruff.format]
|
|
71
|
+
quote-style = "double"
|
|
72
|
+
indent-style = "space"
|
|
73
|
+
skip-magic-trailing-comma = false
|
|
74
|
+
docstring-code-format = true
|
|
75
|
+
docstring-code-line-length = "dynamic"
|
|
76
|
+
|
|
77
|
+
[tool.pytest.ini_options]
|
|
78
|
+
asyncio_mode = "auto"
|
|
79
|
+
testpaths = ["tests"]
|
|
80
|
+
norecursedirs = [".git", ".venv", "__pycache__"]
|
|
81
|
+
|
|
82
|
+
[tool.coverage.run]
|
|
83
|
+
branch = true
|
|
84
|
+
relative_files = true
|
|
85
|
+
source = ["chap_checker"]
|
|
86
|
+
|
|
87
|
+
[tool.coverage.report]
|
|
88
|
+
exclude_also = ["if TYPE_CHECKING:"]
|
|
89
|
+
precision = 2
|
|
90
|
+
show_missing = true
|
|
91
|
+
skip_covered = true
|
|
92
|
+
|
|
93
|
+
[tool.mypy]
|
|
94
|
+
python_version = "3.13"
|
|
95
|
+
warn_return_any = true
|
|
96
|
+
warn_unused_configs = true
|
|
97
|
+
disallow_untyped_defs = true
|
|
98
|
+
check_untyped_defs = true
|
|
99
|
+
no_implicit_optional = true
|
|
100
|
+
warn_unused_ignores = true
|
|
101
|
+
strict_equality = true
|
|
102
|
+
mypy_path = ["src"]
|
|
103
|
+
|
|
104
|
+
[[tool.mypy.overrides]]
|
|
105
|
+
module = "tests.*"
|
|
106
|
+
disallow_untyped_defs = false
|
|
107
|
+
|
|
108
|
+
[tool.pyright]
|
|
109
|
+
include = ["src", "tests"]
|
|
110
|
+
exclude = ["**/.venv"]
|
|
111
|
+
pythonVersion = "3.13"
|
|
112
|
+
typeCheckingMode = "strict"
|
|
113
|
+
useLibraryCodeForTypes = true
|
|
114
|
+
reportPrivateUsage = false
|
|
115
|
+
reportUnusedFunction = false
|
|
116
|
+
reportUnknownMemberType = false
|
|
117
|
+
reportUnknownArgumentType = false
|
|
118
|
+
reportUnknownParameterType = false
|
|
119
|
+
reportUnknownVariableType = false
|
|
120
|
+
reportUnknownLambdaType = false
|
|
121
|
+
reportMissingTypeArgument = false
|
|
122
|
+
reportMissingTypeStubs = false
|
|
123
|
+
reportMissingImports = "warning"
|
|
124
|
+
reportMissingModuleSource = false
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""chap-checker - run a suite of checks against a DHIS2 server."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("chap-checker")
|
|
7
|
+
except PackageNotFoundError: # pragma: no cover
|
|
8
|
+
__version__ = "0.0.0"
|
|
9
|
+
|
|
10
|
+
__all__ = ["__version__"]
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Alerter implementations (currently: Slack Incoming Webhook)."""
|
|
2
|
+
|
|
3
|
+
from chap_checker.alerts.base import Alerter, AlerterBinding, Transition, TransitionKind
|
|
4
|
+
from chap_checker.alerts.slack import SlackAlerter
|
|
5
|
+
|
|
6
|
+
__all__ = ["Alerter", "AlerterBinding", "SlackAlerter", "Transition", "TransitionKind"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Alerter protocol and the Transition pydantic model that drives it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from typing import ClassVar, Literal, Protocol, TypeVar, runtime_checkable
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, ConfigDict
|
|
10
|
+
|
|
11
|
+
from chap_checker.checks.base import Status
|
|
12
|
+
|
|
13
|
+
TransitionKind = Literal["failure", "recovery"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Transition(BaseModel):
|
|
17
|
+
"""A single status change worth alerting on.
|
|
18
|
+
|
|
19
|
+
Emitted by :func:`chap_checker.state_store.compute_transitions` when a
|
|
20
|
+
check's status flips between OK and a non-OK family member.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
kind: TransitionKind
|
|
24
|
+
target_name: str
|
|
25
|
+
target_url: str
|
|
26
|
+
check_name: str
|
|
27
|
+
previous_status: Status
|
|
28
|
+
current_status: Status
|
|
29
|
+
message: str
|
|
30
|
+
duration_ms: float
|
|
31
|
+
occurred_at: datetime
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@runtime_checkable
|
|
35
|
+
class Alerter(Protocol):
|
|
36
|
+
"""Protocol all alerters implement.
|
|
37
|
+
|
|
38
|
+
Implementations *may* raise on delivery failure - the dispatcher
|
|
39
|
+
(:func:`chap_checker.cli._dispatch_alerts`) catches the exception, logs
|
|
40
|
+
it, and skips the state-file save so the transition is retried on the
|
|
41
|
+
next run. Alert delivery failures never change the run's exit code
|
|
42
|
+
regardless of whether the alerter raises or swallows.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
name: ClassVar[str]
|
|
46
|
+
|
|
47
|
+
async def notify(self, transitions: list[Transition]) -> None:
|
|
48
|
+
"""Send the given transitions out-of-band.
|
|
49
|
+
|
|
50
|
+
May raise on transport / non-2xx responses; the dispatcher decides
|
|
51
|
+
whether to surface the failure.
|
|
52
|
+
"""
|
|
53
|
+
...
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class AlerterBinding(BaseModel):
|
|
57
|
+
"""Pairs a runtime :class:`Alerter` instance with the statuses it cares about."""
|
|
58
|
+
|
|
59
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
60
|
+
|
|
61
|
+
alerter: Alerter
|
|
62
|
+
notify_on: set[Status]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
_ALERTER_CLASSES: dict[str, type[Alerter]] = {}
|
|
66
|
+
|
|
67
|
+
_TAlerter = TypeVar("_TAlerter", bound=type[Alerter])
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def register_alerter(name: str) -> Callable[[_TAlerter], _TAlerter]:
|
|
71
|
+
"""Class decorator: register an alerter class under ``name``.
|
|
72
|
+
|
|
73
|
+
This is currently a discovery aid - the CLI's per-alerter config wiring
|
|
74
|
+
(:func:`chap_checker.cli._build_alerters`) still instantiates known
|
|
75
|
+
alerters by hand, but the registry makes new alerters introspectable
|
|
76
|
+
and is the seam for a future plugin-style config layer.
|
|
77
|
+
|
|
78
|
+
Example:
|
|
79
|
+
@register_alerter("slack")
|
|
80
|
+
class SlackAlerter:
|
|
81
|
+
name = "slack"
|
|
82
|
+
...
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def deco(cls: _TAlerter) -> _TAlerter:
|
|
86
|
+
_ALERTER_CLASSES[name] = cls
|
|
87
|
+
return cls
|
|
88
|
+
|
|
89
|
+
return deco
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def alerter_class(name: str) -> type[Alerter] | None:
|
|
93
|
+
"""Return the registered alerter class for ``name``, or ``None``."""
|
|
94
|
+
return _ALERTER_CLASSES.get(name)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def all_alerter_classes() -> dict[str, type[Alerter]]:
|
|
98
|
+
"""Return a copy of the alerter-class registry keyed by name."""
|
|
99
|
+
return dict(_ALERTER_CLASSES)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Slack Incoming Webhook alerter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import ClassVar, Literal
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
from chap_checker.alerts.base import Transition, register_alerter
|
|
11
|
+
from chap_checker.checks.base import Status
|
|
12
|
+
from chap_checker.logging import get_logger
|
|
13
|
+
|
|
14
|
+
_log = get_logger("alerts.slack")
|
|
15
|
+
|
|
16
|
+
# Slack's status colors (from their brand guide; render well on dark and light).
|
|
17
|
+
_COLOR_BY_STATUS: dict[Status, str] = {
|
|
18
|
+
Status.OK: "#2EB67D", # green
|
|
19
|
+
Status.WARN: "#ECB22E", # yellow
|
|
20
|
+
Status.FAIL: "#E01E5A", # red
|
|
21
|
+
Status.ERROR: "#E01E5A", # red (same family as FAIL)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SlackBlockText(BaseModel):
|
|
26
|
+
"""Slack Block Kit text element (``plain_text`` or ``mrkdwn``)."""
|
|
27
|
+
|
|
28
|
+
type: Literal["plain_text", "mrkdwn"]
|
|
29
|
+
text: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SlackBlock(BaseModel):
|
|
33
|
+
"""Single Slack Block Kit block (header or section)."""
|
|
34
|
+
|
|
35
|
+
type: Literal["header", "section"]
|
|
36
|
+
text: SlackBlockText
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class SlackAttachment(BaseModel):
|
|
40
|
+
"""Legacy Slack attachment that gives Block Kit a colored left border.
|
|
41
|
+
|
|
42
|
+
Block Kit blocks on their own can't render the vertical color stripe;
|
|
43
|
+
wrapping the same blocks in an attachment with ``color`` gets it back.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
color: str
|
|
47
|
+
blocks: list[SlackBlock]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SlackPayload(BaseModel):
|
|
51
|
+
"""Body posted to a Slack Incoming Webhook."""
|
|
52
|
+
|
|
53
|
+
text: str
|
|
54
|
+
blocks: list[SlackBlock]
|
|
55
|
+
attachments: list[SlackAttachment]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@register_alerter("slack")
|
|
59
|
+
class SlackAlerter:
|
|
60
|
+
"""POST a Block Kit message to a Slack Incoming Webhook URL.
|
|
61
|
+
|
|
62
|
+
Always raises on transport / 5xx so the cron-side dispatcher can decide
|
|
63
|
+
whether to commit state (it doesn't) and whether to surface the failure
|
|
64
|
+
in exit code (it doesn't - just logs).
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
name: ClassVar[str] = "slack"
|
|
68
|
+
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
webhook_url: str,
|
|
72
|
+
timeout_s: float = 10.0,
|
|
73
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
self._webhook_url = webhook_url
|
|
76
|
+
self._timeout_s = timeout_s
|
|
77
|
+
self._transport = transport
|
|
78
|
+
|
|
79
|
+
async def notify(self, transitions: list[Transition]) -> None:
|
|
80
|
+
"""POST the transitions to Slack.
|
|
81
|
+
|
|
82
|
+
Raises on transport errors or non-2xx responses. The caller is
|
|
83
|
+
responsible for deciding what to do with the failure (the cron-side
|
|
84
|
+
dispatcher swallows it but skips the state save so the transition
|
|
85
|
+
retries next run).
|
|
86
|
+
"""
|
|
87
|
+
if not transitions:
|
|
88
|
+
return
|
|
89
|
+
payload = _build_payload(transitions)
|
|
90
|
+
async with httpx.AsyncClient(timeout=self._timeout_s, transport=self._transport) as client:
|
|
91
|
+
response = await client.post(self._webhook_url, json=payload.model_dump(mode="json"))
|
|
92
|
+
if response.status_code >= 400:
|
|
93
|
+
raise RuntimeError(f"slack webhook returned {response.status_code}: {response.text[:200]}")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _color_for(transition: Transition) -> str:
|
|
97
|
+
if transition.kind == "recovery":
|
|
98
|
+
return _COLOR_BY_STATUS[Status.OK]
|
|
99
|
+
return _COLOR_BY_STATUS.get(transition.current_status, _COLOR_BY_STATUS[Status.FAIL])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_payload(transitions: list[Transition]) -> SlackPayload:
|
|
103
|
+
failures = [t for t in transitions if t.kind == "failure"]
|
|
104
|
+
recoveries = [t for t in transitions if t.kind == "recovery"]
|
|
105
|
+
failure_word = "failure" if len(failures) == 1 else "failures"
|
|
106
|
+
recovery_word = "recovery" if len(recoveries) == 1 else "recoveries"
|
|
107
|
+
summary = f"chap-checker: {len(failures)} new {failure_word}, {len(recoveries)} {recovery_word}"
|
|
108
|
+
|
|
109
|
+
header_blocks: list[SlackBlock] = [
|
|
110
|
+
SlackBlock(type="header", text=SlackBlockText(type="plain_text", text=summary)),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
attachments: list[SlackAttachment] = []
|
|
114
|
+
for t in transitions:
|
|
115
|
+
label = "FAILURE" if t.kind == "failure" else "RECOVERY"
|
|
116
|
+
status_label = t.current_status.value.upper()
|
|
117
|
+
body = f"*{label} — `{t.target_name}`*\n{t.target_url}\n`{t.check_name}` *{status_label}* {t.message}"
|
|
118
|
+
attachments.append(
|
|
119
|
+
SlackAttachment(
|
|
120
|
+
color=_color_for(t),
|
|
121
|
+
blocks=[SlackBlock(type="section", text=SlackBlockText(type="mrkdwn", text=body))],
|
|
122
|
+
)
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return SlackPayload(text=summary, blocks=header_blocks, attachments=attachments)
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Check implementations.
|
|
2
|
+
|
|
3
|
+
Importing this package triggers each check module, which registers itself
|
|
4
|
+
via :func:`chap_checker.checks.base.register_check`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from chap_checker.checks import (
|
|
8
|
+
dhis2_chap_modeling_app,
|
|
9
|
+
dhis2_chap_ping,
|
|
10
|
+
dhis2_chap_route,
|
|
11
|
+
dhis2_chap_system_info,
|
|
12
|
+
dhis2_ping,
|
|
13
|
+
dhis2_system_info,
|
|
14
|
+
)
|
|
15
|
+
from chap_checker.checks.base import (
|
|
16
|
+
Check,
|
|
17
|
+
CheckResult,
|
|
18
|
+
Status,
|
|
19
|
+
all_checks,
|
|
20
|
+
register_check,
|
|
21
|
+
resolve_checks,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Check",
|
|
26
|
+
"CheckResult",
|
|
27
|
+
"Status",
|
|
28
|
+
"all_checks",
|
|
29
|
+
"dhis2_chap_modeling_app",
|
|
30
|
+
"dhis2_chap_ping",
|
|
31
|
+
"dhis2_chap_route",
|
|
32
|
+
"dhis2_chap_system_info",
|
|
33
|
+
"dhis2_ping",
|
|
34
|
+
"dhis2_system_info",
|
|
35
|
+
"register_check",
|
|
36
|
+
"resolve_checks",
|
|
37
|
+
]
|