certminder 2.2.0__tar.gz → 2.3.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.
- {certminder-2.2.0/src/certminder.egg-info → certminder-2.3.0}/PKG-INFO +52 -2
- {certminder-2.2.0 → certminder-2.3.0}/README.md +49 -0
- {certminder-2.2.0 → certminder-2.3.0}/pyproject.toml +3 -2
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/config.py +82 -0
- {certminder-2.2.0 → certminder-2.3.0/src/certminder.egg-info}/PKG-INFO +52 -2
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder.egg-info/requires.txt +2 -1
- {certminder-2.2.0 → certminder-2.3.0}/tests/test_config.py +283 -0
- {certminder-2.2.0 → certminder-2.3.0}/LICENSE +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/setup.cfg +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/__init__.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/cli.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/engine.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/evaluator.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/metrics.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/models.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/__init__.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/base.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/console.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/email.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/slack.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/webhook.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/scheduler.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder/state.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder.egg-info/SOURCES.txt +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder.egg-info/dependency_links.txt +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder.egg-info/entry_points.txt +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/src/certminder.egg-info/top_level.txt +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/tests/test_cli.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/tests/test_engine.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/tests/test_evaluator.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/tests/test_metrics.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/tests/test_notifiers.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/tests/test_scheduler.py +0 -0
- {certminder-2.2.0 → certminder-2.3.0}/tests/test_state.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: certminder
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Continuous TLS certificate monitoring and alerting, powered by certinspect.
|
|
5
5
|
Author-email: Michele Angrisano <michele.angrisano@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,8 +20,9 @@ Classifier: Topic :: Security
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: certinspect>=2.
|
|
23
|
+
Requires-Dist: certinspect>=2.2.0
|
|
24
24
|
Requires-Dist: PyYAML>=6.0.3
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
25
26
|
Provides-Extra: dev
|
|
26
27
|
Requires-Dist: pytest>=9.1.1; extra == "dev"
|
|
27
28
|
Requires-Dist: ruff>=0.16.5; extra == "dev"
|
|
@@ -149,6 +150,55 @@ sorted: `expiry` (default — the soonest-expiring certificates first, already
|
|
|
149
150
|
expired ones at the very top), `severity` (worst first), or `none` (in the order
|
|
150
151
|
problems were detected).
|
|
151
152
|
|
|
153
|
+
### Using environment variables in the file
|
|
154
|
+
|
|
155
|
+
Any string value anywhere in the file — a notifier secret, a target `host`,
|
|
156
|
+
`state_file`, `cafile`, `secrets_file` itself, etc. — can reference an
|
|
157
|
+
environment variable with Docker Compose-style `${VAR}` or `$VAR` syntax.
|
|
158
|
+
`$$` is a literal dollar sign. There is no default-value fallback: a variable
|
|
159
|
+
that isn't set is a hard error, so a missing value fails loudly instead of
|
|
160
|
+
being sent empty or interpolated as a literal `${VAR}` string.
|
|
161
|
+
|
|
162
|
+
The value is resolved `env > file`: the real environment first, then a `.env`
|
|
163
|
+
file next to the config (parsed with
|
|
164
|
+
[`python-dotenv`](https://pypi.org/project/python-dotenv/), so comments,
|
|
165
|
+
quoting and `export` prefixes all work as expected). Point at a different
|
|
166
|
+
file with a top-level `secrets_file:` (a relative path resolves against the
|
|
167
|
+
config's directory, and is itself resolved against the real environment
|
|
168
|
+
only, since the `.env` file it names doesn't exist yet to resolve it
|
|
169
|
+
against). Because the real environment wins, an `export`-ed shell variable
|
|
170
|
+
overrides the file for a single run.
|
|
171
|
+
|
|
172
|
+
```yaml
|
|
173
|
+
# certminder.yml
|
|
174
|
+
secrets_file: secrets.env # optional; a .env next to the config is auto-loaded
|
|
175
|
+
targets:
|
|
176
|
+
- host: ${TARGET_HOST}
|
|
177
|
+
notifiers:
|
|
178
|
+
- type: slack
|
|
179
|
+
webhook_url: ${SLACK_WEBHOOK_URL}
|
|
180
|
+
- type: webhook
|
|
181
|
+
url: https://example.com/hook
|
|
182
|
+
headers:
|
|
183
|
+
Authorization: "Bearer ${WEBHOOK_TOKEN}"
|
|
184
|
+
- type: email
|
|
185
|
+
host: smtp.example.com
|
|
186
|
+
from_addr: alerts@example.com
|
|
187
|
+
to: [ops@example.com]
|
|
188
|
+
password: ${SMTP_PASSWORD}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```dotenv
|
|
192
|
+
# .env — keep it out of version control
|
|
193
|
+
TARGET_HOST=internal.example.com
|
|
194
|
+
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T00/B00/xxxx
|
|
195
|
+
WEBHOOK_TOKEN=abc123
|
|
196
|
+
SMTP_PASSWORD=SuperSecret
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Copy [`.env.example`](.env.example) to `.env` and fill in real values as a
|
|
200
|
+
starting point.
|
|
201
|
+
|
|
152
202
|
### Grouping targets (shared settings)
|
|
153
203
|
|
|
154
204
|
Put targets that share settings — say a whole installation behind its own
|
|
@@ -120,6 +120,55 @@ sorted: `expiry` (default — the soonest-expiring certificates first, already
|
|
|
120
120
|
expired ones at the very top), `severity` (worst first), or `none` (in the order
|
|
121
121
|
problems were detected).
|
|
122
122
|
|
|
123
|
+
### Using environment variables in the file
|
|
124
|
+
|
|
125
|
+
Any string value anywhere in the file — a notifier secret, a target `host`,
|
|
126
|
+
`state_file`, `cafile`, `secrets_file` itself, etc. — can reference an
|
|
127
|
+
environment variable with Docker Compose-style `${VAR}` or `$VAR` syntax.
|
|
128
|
+
`$$` is a literal dollar sign. There is no default-value fallback: a variable
|
|
129
|
+
that isn't set is a hard error, so a missing value fails loudly instead of
|
|
130
|
+
being sent empty or interpolated as a literal `${VAR}` string.
|
|
131
|
+
|
|
132
|
+
The value is resolved `env > file`: the real environment first, then a `.env`
|
|
133
|
+
file next to the config (parsed with
|
|
134
|
+
[`python-dotenv`](https://pypi.org/project/python-dotenv/), so comments,
|
|
135
|
+
quoting and `export` prefixes all work as expected). Point at a different
|
|
136
|
+
file with a top-level `secrets_file:` (a relative path resolves against the
|
|
137
|
+
config's directory, and is itself resolved against the real environment
|
|
138
|
+
only, since the `.env` file it names doesn't exist yet to resolve it
|
|
139
|
+
against). Because the real environment wins, an `export`-ed shell variable
|
|
140
|
+
overrides the file for a single run.
|
|
141
|
+
|
|
142
|
+
```yaml
|
|
143
|
+
# certminder.yml
|
|
144
|
+
secrets_file: secrets.env # optional; a .env next to the config is auto-loaded
|
|
145
|
+
targets:
|
|
146
|
+
- host: ${TARGET_HOST}
|
|
147
|
+
notifiers:
|
|
148
|
+
- type: slack
|
|
149
|
+
webhook_url: ${SLACK_WEBHOOK_URL}
|
|
150
|
+
- type: webhook
|
|
151
|
+
url: https://example.com/hook
|
|
152
|
+
headers:
|
|
153
|
+
Authorization: "Bearer ${WEBHOOK_TOKEN}"
|
|
154
|
+
- type: email
|
|
155
|
+
host: smtp.example.com
|
|
156
|
+
from_addr: alerts@example.com
|
|
157
|
+
to: [ops@example.com]
|
|
158
|
+
password: ${SMTP_PASSWORD}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```dotenv
|
|
162
|
+
# .env — keep it out of version control
|
|
163
|
+
TARGET_HOST=internal.example.com
|
|
164
|
+
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T00/B00/xxxx
|
|
165
|
+
WEBHOOK_TOKEN=abc123
|
|
166
|
+
SMTP_PASSWORD=SuperSecret
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Copy [`.env.example`](.env.example) to `.env` and fill in real values as a
|
|
170
|
+
starting point.
|
|
171
|
+
|
|
123
172
|
### Grouping targets (shared settings)
|
|
124
173
|
|
|
125
174
|
Put targets that share settings — say a whole installation behind its own
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "certminder"
|
|
7
|
-
version = "2.
|
|
7
|
+
version = "2.3.0"
|
|
8
8
|
description = "Continuous TLS certificate monitoring and alerting, powered by certinspect."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -24,8 +24,9 @@ classifiers = [
|
|
|
24
24
|
"Topic :: Security",
|
|
25
25
|
]
|
|
26
26
|
dependencies = [
|
|
27
|
-
"certinspect>=2.
|
|
27
|
+
"certinspect>=2.2.0",
|
|
28
28
|
"PyYAML>=6.0.3",
|
|
29
|
+
"python-dotenv>=1.0.0",
|
|
29
30
|
]
|
|
30
31
|
|
|
31
32
|
[project.optional-dependencies]
|
|
@@ -2,12 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
import os
|
|
5
6
|
import re
|
|
6
7
|
from dataclasses import dataclass, field
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any
|
|
9
10
|
|
|
10
11
|
import yaml
|
|
12
|
+
from dotenv import dotenv_values
|
|
11
13
|
|
|
12
14
|
from certminder.models import EventKind, Target
|
|
13
15
|
|
|
@@ -122,6 +124,75 @@ def _build_target(raw: dict[str, Any], defaults: dict[str, Any]) -> Target:
|
|
|
122
124
|
return Target(**merged)
|
|
123
125
|
|
|
124
126
|
|
|
127
|
+
def _load_environment(config_path: Path, secrets_file: str | None) -> dict[str, str]:
|
|
128
|
+
"""Merge a secrets ``.env`` file with the real environment (env wins).
|
|
129
|
+
|
|
130
|
+
Parsing is delegated to ``python-dotenv``'s ``dotenv_values``, which reads
|
|
131
|
+
(without touching the real environment) rather than mutates ``os.environ``,
|
|
132
|
+
so this stays a pure merge: a variable already set in the real environment
|
|
133
|
+
overrides the file, so an ``export`` wins for a single run. Without
|
|
134
|
+
``secrets_file`` a ``.env`` next to the config is read if present; an
|
|
135
|
+
explicit ``secrets_file`` (a relative path resolves against the config's
|
|
136
|
+
directory) must exist.
|
|
137
|
+
"""
|
|
138
|
+
if secrets_file:
|
|
139
|
+
env_path = Path(secrets_file).expanduser()
|
|
140
|
+
if not env_path.is_absolute():
|
|
141
|
+
env_path = config_path.parent / env_path
|
|
142
|
+
if not env_path.is_file():
|
|
143
|
+
raise ConfigError(f"secrets_file not found: {env_path}")
|
|
144
|
+
else:
|
|
145
|
+
env_path = config_path.parent / ".env"
|
|
146
|
+
file_vars = dotenv_values(env_path) if env_path.is_file() else {}
|
|
147
|
+
for key, value in file_vars.items():
|
|
148
|
+
if value is None:
|
|
149
|
+
raise ConfigError(f"{env_path}: {key!r} has no value (expected KEY=VALUE)")
|
|
150
|
+
return {**file_vars, **os.environ}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
_VAR_RE = re.compile(
|
|
154
|
+
r"\$\$|\$\{(?P<braced>[A-Za-z_][A-Za-z0-9_]*)\}|\$(?P<bare>[A-Za-z_][A-Za-z0-9_]*)"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _interpolate(value: str, env: dict[str, str]) -> str:
|
|
159
|
+
"""Substitute Docker Compose-style ``${VAR}``/``$VAR`` in a string.
|
|
160
|
+
|
|
161
|
+
``$$`` is a literal dollar sign. Every referenced variable must be set (in
|
|
162
|
+
the merged environment: the real environment, then a ``.env`` file) — there
|
|
163
|
+
is no default-value fallback, so a missing secret fails loudly.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def repl(match: re.Match[str]) -> str:
|
|
167
|
+
if match.group(0) == "$$":
|
|
168
|
+
return "$"
|
|
169
|
+
name = match.group("braced") or match.group("bare")
|
|
170
|
+
if name not in env:
|
|
171
|
+
raise ConfigError(
|
|
172
|
+
f"environment variable {name!r} (referenced in {value!r}) is not set"
|
|
173
|
+
)
|
|
174
|
+
return env[name]
|
|
175
|
+
|
|
176
|
+
return _VAR_RE.sub(repl, value)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _interpolate_value(value: Any, env: dict[str, str]) -> Any:
|
|
180
|
+
"""Recursively substitute ``${VAR}``/``$VAR`` in every string, anywhere.
|
|
181
|
+
|
|
182
|
+
Applies to the whole parsed YAML, not just notifier secrets: a target
|
|
183
|
+
``host``, a ``cafile`` path, ``state_file``, an ``expect`` entry, etc. can
|
|
184
|
+
all reference ``${VAR}``. Non-string values (int, bool, None, ...) pass
|
|
185
|
+
through unchanged.
|
|
186
|
+
"""
|
|
187
|
+
if isinstance(value, str):
|
|
188
|
+
return _interpolate(value, env)
|
|
189
|
+
if isinstance(value, dict):
|
|
190
|
+
return {k: _interpolate_value(v, env) for k, v in value.items()}
|
|
191
|
+
if isinstance(value, list):
|
|
192
|
+
return [_interpolate_value(item, env) for item in value]
|
|
193
|
+
return value
|
|
194
|
+
|
|
195
|
+
|
|
125
196
|
def load_config(path: str | Path) -> Config:
|
|
126
197
|
"""Read, parse and validate the configuration at ``path``."""
|
|
127
198
|
path = Path(path).expanduser()
|
|
@@ -136,6 +207,17 @@ def load_config(path: str | Path) -> Config:
|
|
|
136
207
|
if not isinstance(data, dict):
|
|
137
208
|
raise ConfigError("top-level configuration must be a mapping")
|
|
138
209
|
|
|
210
|
+
# secrets_file itself is resolved against the real environment only: the
|
|
211
|
+
# .env file it points at doesn't exist yet to resolve it against.
|
|
212
|
+
raw_secrets_file = data.get("secrets_file")
|
|
213
|
+
secrets_file = (
|
|
214
|
+
_interpolate(raw_secrets_file, os.environ)
|
|
215
|
+
if isinstance(raw_secrets_file, str)
|
|
216
|
+
else raw_secrets_file
|
|
217
|
+
)
|
|
218
|
+
env = _load_environment(path, secrets_file)
|
|
219
|
+
data = _interpolate_value(data, env)
|
|
220
|
+
|
|
139
221
|
raw_targets = data.get("targets") or []
|
|
140
222
|
defaults = data.get("defaults") or {}
|
|
141
223
|
targets = [_build_target(t, defaults) for t in raw_targets]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: certminder
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Continuous TLS certificate monitoring and alerting, powered by certinspect.
|
|
5
5
|
Author-email: Michele Angrisano <michele.angrisano@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,8 +20,9 @@ Classifier: Topic :: Security
|
|
|
20
20
|
Requires-Python: >=3.12
|
|
21
21
|
Description-Content-Type: text/markdown
|
|
22
22
|
License-File: LICENSE
|
|
23
|
-
Requires-Dist: certinspect>=2.
|
|
23
|
+
Requires-Dist: certinspect>=2.2.0
|
|
24
24
|
Requires-Dist: PyYAML>=6.0.3
|
|
25
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
25
26
|
Provides-Extra: dev
|
|
26
27
|
Requires-Dist: pytest>=9.1.1; extra == "dev"
|
|
27
28
|
Requires-Dist: ruff>=0.16.5; extra == "dev"
|
|
@@ -149,6 +150,55 @@ sorted: `expiry` (default — the soonest-expiring certificates first, already
|
|
|
149
150
|
expired ones at the very top), `severity` (worst first), or `none` (in the order
|
|
150
151
|
problems were detected).
|
|
151
152
|
|
|
153
|
+
### Using environment variables in the file
|
|
154
|
+
|
|
155
|
+
Any string value anywhere in the file — a notifier secret, a target `host`,
|
|
156
|
+
`state_file`, `cafile`, `secrets_file` itself, etc. — can reference an
|
|
157
|
+
environment variable with Docker Compose-style `${VAR}` or `$VAR` syntax.
|
|
158
|
+
`$$` is a literal dollar sign. There is no default-value fallback: a variable
|
|
159
|
+
that isn't set is a hard error, so a missing value fails loudly instead of
|
|
160
|
+
being sent empty or interpolated as a literal `${VAR}` string.
|
|
161
|
+
|
|
162
|
+
The value is resolved `env > file`: the real environment first, then a `.env`
|
|
163
|
+
file next to the config (parsed with
|
|
164
|
+
[`python-dotenv`](https://pypi.org/project/python-dotenv/), so comments,
|
|
165
|
+
quoting and `export` prefixes all work as expected). Point at a different
|
|
166
|
+
file with a top-level `secrets_file:` (a relative path resolves against the
|
|
167
|
+
config's directory, and is itself resolved against the real environment
|
|
168
|
+
only, since the `.env` file it names doesn't exist yet to resolve it
|
|
169
|
+
against). Because the real environment wins, an `export`-ed shell variable
|
|
170
|
+
overrides the file for a single run.
|
|
171
|
+
|
|
172
|
+
```yaml
|
|
173
|
+
# certminder.yml
|
|
174
|
+
secrets_file: secrets.env # optional; a .env next to the config is auto-loaded
|
|
175
|
+
targets:
|
|
176
|
+
- host: ${TARGET_HOST}
|
|
177
|
+
notifiers:
|
|
178
|
+
- type: slack
|
|
179
|
+
webhook_url: ${SLACK_WEBHOOK_URL}
|
|
180
|
+
- type: webhook
|
|
181
|
+
url: https://example.com/hook
|
|
182
|
+
headers:
|
|
183
|
+
Authorization: "Bearer ${WEBHOOK_TOKEN}"
|
|
184
|
+
- type: email
|
|
185
|
+
host: smtp.example.com
|
|
186
|
+
from_addr: alerts@example.com
|
|
187
|
+
to: [ops@example.com]
|
|
188
|
+
password: ${SMTP_PASSWORD}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```dotenv
|
|
192
|
+
# .env — keep it out of version control
|
|
193
|
+
TARGET_HOST=internal.example.com
|
|
194
|
+
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T00/B00/xxxx
|
|
195
|
+
WEBHOOK_TOKEN=abc123
|
|
196
|
+
SMTP_PASSWORD=SuperSecret
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Copy [`.env.example`](.env.example) to `.env` and fill in real values as a
|
|
200
|
+
starting point.
|
|
201
|
+
|
|
152
202
|
### Grouping targets (shared settings)
|
|
153
203
|
|
|
154
204
|
Put targets that share settings — say a whole installation behind its own
|
|
@@ -336,3 +336,286 @@ def test_network_robustness_keys_accepted(tmp_path):
|
|
|
336
336
|
assert t.retries == 2
|
|
337
337
|
assert t.connect_timeout == 3
|
|
338
338
|
assert t.read_timeout == 8
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_braced_var_reads_from_environment(tmp_path, monkeypatch):
|
|
342
|
+
monkeypatch.setenv("SMTP_PASS", "s3cret")
|
|
343
|
+
cfg = load_config(
|
|
344
|
+
_write(
|
|
345
|
+
tmp_path,
|
|
346
|
+
"""
|
|
347
|
+
notifiers:
|
|
348
|
+
- type: email
|
|
349
|
+
password: ${SMTP_PASS}
|
|
350
|
+
targets:
|
|
351
|
+
- host: example.com
|
|
352
|
+
""",
|
|
353
|
+
)
|
|
354
|
+
)
|
|
355
|
+
assert cfg.notifiers[0].options["password"] == "s3cret"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_bare_var_reads_from_environment(tmp_path, monkeypatch):
|
|
359
|
+
monkeypatch.setenv("SMTP_PASS", "s3cret")
|
|
360
|
+
cfg = load_config(
|
|
361
|
+
_write(
|
|
362
|
+
tmp_path,
|
|
363
|
+
"""
|
|
364
|
+
notifiers:
|
|
365
|
+
- type: email
|
|
366
|
+
password: $SMTP_PASS
|
|
367
|
+
targets:
|
|
368
|
+
- host: example.com
|
|
369
|
+
""",
|
|
370
|
+
)
|
|
371
|
+
)
|
|
372
|
+
assert cfg.notifiers[0].options["password"] == "s3cret"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def test_missing_variable_is_error(tmp_path, monkeypatch):
|
|
376
|
+
monkeypatch.delenv("NO_SUCH_SECRET", raising=False)
|
|
377
|
+
path = _write(
|
|
378
|
+
tmp_path,
|
|
379
|
+
"""
|
|
380
|
+
notifiers:
|
|
381
|
+
- type: slack
|
|
382
|
+
webhook_url: ${NO_SUCH_SECRET}
|
|
383
|
+
targets:
|
|
384
|
+
- host: example.com
|
|
385
|
+
""",
|
|
386
|
+
)
|
|
387
|
+
with pytest.raises(ConfigError):
|
|
388
|
+
load_config(path)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def test_var_resolves_inside_nested_headers(tmp_path, monkeypatch):
|
|
392
|
+
monkeypatch.setenv("WEBHOOK_TOKEN", "abc123")
|
|
393
|
+
cfg = load_config(
|
|
394
|
+
_write(
|
|
395
|
+
tmp_path,
|
|
396
|
+
"""
|
|
397
|
+
notifiers:
|
|
398
|
+
- type: webhook
|
|
399
|
+
url: https://example.com/hook
|
|
400
|
+
headers:
|
|
401
|
+
Authorization: "Bearer ${WEBHOOK_TOKEN}"
|
|
402
|
+
targets:
|
|
403
|
+
- host: example.com
|
|
404
|
+
""",
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
headers = cfg.notifiers[0].options["headers"]
|
|
408
|
+
assert headers["Authorization"] == "Bearer abc123"
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def test_var_resolves_inside_list_items(tmp_path, monkeypatch):
|
|
412
|
+
monkeypatch.setenv("OPS_EMAIL", "ops@example.com")
|
|
413
|
+
cfg = load_config(
|
|
414
|
+
_write(
|
|
415
|
+
tmp_path,
|
|
416
|
+
"""
|
|
417
|
+
notifiers:
|
|
418
|
+
- type: email
|
|
419
|
+
host: smtp.example.com
|
|
420
|
+
from_addr: alerts@example.com
|
|
421
|
+
to: ["${OPS_EMAIL}"]
|
|
422
|
+
targets:
|
|
423
|
+
- host: example.com
|
|
424
|
+
""",
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
assert cfg.notifiers[0].options["to"] == ["ops@example.com"]
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_double_dollar_is_literal_dollar(tmp_path, monkeypatch):
|
|
431
|
+
cfg = load_config(
|
|
432
|
+
_write(
|
|
433
|
+
tmp_path,
|
|
434
|
+
"""
|
|
435
|
+
notifiers:
|
|
436
|
+
- type: email
|
|
437
|
+
password: "pa$$word"
|
|
438
|
+
targets:
|
|
439
|
+
- host: example.com
|
|
440
|
+
""",
|
|
441
|
+
)
|
|
442
|
+
)
|
|
443
|
+
assert cfg.notifiers[0].options["password"] == "pa$word"
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def test_var_resolves_in_target_fields(tmp_path, monkeypatch):
|
|
447
|
+
monkeypatch.setenv("TARGET_HOST", "internal.example.com")
|
|
448
|
+
cfg = load_config(
|
|
449
|
+
_write(
|
|
450
|
+
tmp_path,
|
|
451
|
+
"""
|
|
452
|
+
targets:
|
|
453
|
+
- host: ${TARGET_HOST}
|
|
454
|
+
label: prod-${TARGET_HOST}
|
|
455
|
+
""",
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
assert cfg.targets[0].host == "internal.example.com"
|
|
459
|
+
assert cfg.targets[0].label == "prod-internal.example.com"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def test_var_resolves_in_top_level_settings(tmp_path, monkeypatch):
|
|
463
|
+
monkeypatch.setenv("STATE_DIR", str(tmp_path / "state"))
|
|
464
|
+
cfg = load_config(
|
|
465
|
+
_write(
|
|
466
|
+
tmp_path,
|
|
467
|
+
"""
|
|
468
|
+
state_file: ${STATE_DIR}/state.json
|
|
469
|
+
targets:
|
|
470
|
+
- host: example.com
|
|
471
|
+
""",
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
assert cfg.state_file == (tmp_path / "state" / "state.json")
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def test_secrets_file_path_resolves_against_real_environment(tmp_path, monkeypatch):
|
|
478
|
+
monkeypatch.setenv("SECRETS_NAME", "secrets.env")
|
|
479
|
+
monkeypatch.delenv("SMTP_PASSWORD", raising=False)
|
|
480
|
+
(tmp_path / "secrets.env").write_text("SMTP_PASSWORD=explicit-secret\n")
|
|
481
|
+
cfg = load_config(
|
|
482
|
+
_write(
|
|
483
|
+
tmp_path,
|
|
484
|
+
"""
|
|
485
|
+
secrets_file: ${SECRETS_NAME}
|
|
486
|
+
notifiers:
|
|
487
|
+
- type: email
|
|
488
|
+
password: ${SMTP_PASSWORD}
|
|
489
|
+
targets:
|
|
490
|
+
- host: example.com
|
|
491
|
+
""",
|
|
492
|
+
)
|
|
493
|
+
)
|
|
494
|
+
assert cfg.notifiers[0].options["password"] == "explicit-secret"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def test_env_file_next_to_config_supplies_variable(tmp_path, monkeypatch):
|
|
498
|
+
monkeypatch.delenv("SLACK_WEBHOOK_URL", raising=False)
|
|
499
|
+
(tmp_path / ".env").write_text(
|
|
500
|
+
"SLACK_WEBHOOK_URL=https://hooks.example/from-file\n"
|
|
501
|
+
)
|
|
502
|
+
cfg = load_config(
|
|
503
|
+
_write(
|
|
504
|
+
tmp_path,
|
|
505
|
+
"""
|
|
506
|
+
notifiers:
|
|
507
|
+
- type: slack
|
|
508
|
+
webhook_url: ${SLACK_WEBHOOK_URL}
|
|
509
|
+
targets:
|
|
510
|
+
- host: example.com
|
|
511
|
+
""",
|
|
512
|
+
)
|
|
513
|
+
)
|
|
514
|
+
assert cfg.notifiers[0].options["webhook_url"] == "https://hooks.example/from-file"
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def test_real_environment_overrides_env_file(tmp_path, monkeypatch):
|
|
518
|
+
(tmp_path / ".env").write_text(
|
|
519
|
+
"SLACK_WEBHOOK_URL=https://hooks.example/from-file\n"
|
|
520
|
+
)
|
|
521
|
+
monkeypatch.setenv("SLACK_WEBHOOK_URL", "https://hooks.example/from-environment")
|
|
522
|
+
cfg = load_config(
|
|
523
|
+
_write(
|
|
524
|
+
tmp_path,
|
|
525
|
+
"""
|
|
526
|
+
notifiers:
|
|
527
|
+
- type: slack
|
|
528
|
+
webhook_url: ${SLACK_WEBHOOK_URL}
|
|
529
|
+
targets:
|
|
530
|
+
- host: example.com
|
|
531
|
+
""",
|
|
532
|
+
)
|
|
533
|
+
)
|
|
534
|
+
assert (
|
|
535
|
+
cfg.notifiers[0].options["webhook_url"]
|
|
536
|
+
== "https://hooks.example/from-environment"
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def test_env_file_preserves_case_and_strips_quotes(tmp_path, monkeypatch):
|
|
541
|
+
monkeypatch.delenv("SMTP_PASSWORD", raising=False)
|
|
542
|
+
(tmp_path / ".env").write_text('SMTP_PASSWORD="pa%word"\n')
|
|
543
|
+
cfg = load_config(
|
|
544
|
+
_write(
|
|
545
|
+
tmp_path,
|
|
546
|
+
"""
|
|
547
|
+
notifiers:
|
|
548
|
+
- type: email
|
|
549
|
+
password: ${SMTP_PASSWORD}
|
|
550
|
+
targets:
|
|
551
|
+
- host: example.com
|
|
552
|
+
""",
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
assert cfg.notifiers[0].options["password"] == "pa%word"
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_env_file_skips_blank_lines_and_comments(tmp_path, monkeypatch):
|
|
559
|
+
monkeypatch.delenv("SMTP_PASSWORD", raising=False)
|
|
560
|
+
(tmp_path / ".env").write_text("# a comment\n\nSMTP_PASSWORD=s3cret\n")
|
|
561
|
+
cfg = load_config(
|
|
562
|
+
_write(
|
|
563
|
+
tmp_path,
|
|
564
|
+
"""
|
|
565
|
+
notifiers:
|
|
566
|
+
- type: email
|
|
567
|
+
password: ${SMTP_PASSWORD}
|
|
568
|
+
targets:
|
|
569
|
+
- host: example.com
|
|
570
|
+
""",
|
|
571
|
+
)
|
|
572
|
+
)
|
|
573
|
+
assert cfg.notifiers[0].options["password"] == "s3cret"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def test_explicit_secrets_file_relative_to_config(tmp_path, monkeypatch):
|
|
577
|
+
monkeypatch.delenv("SMTP_PASSWORD", raising=False)
|
|
578
|
+
(tmp_path / "secrets.env").write_text("SMTP_PASSWORD=explicit-secret\n")
|
|
579
|
+
cfg = load_config(
|
|
580
|
+
_write(
|
|
581
|
+
tmp_path,
|
|
582
|
+
"""
|
|
583
|
+
secrets_file: secrets.env
|
|
584
|
+
notifiers:
|
|
585
|
+
- type: email
|
|
586
|
+
password: ${SMTP_PASSWORD}
|
|
587
|
+
targets:
|
|
588
|
+
- host: example.com
|
|
589
|
+
""",
|
|
590
|
+
)
|
|
591
|
+
)
|
|
592
|
+
assert cfg.notifiers[0].options["password"] == "explicit-secret"
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def test_missing_explicit_secrets_file_is_error(tmp_path):
|
|
596
|
+
path = _write(
|
|
597
|
+
tmp_path,
|
|
598
|
+
"""
|
|
599
|
+
secrets_file: nope.env
|
|
600
|
+
targets:
|
|
601
|
+
- host: example.com
|
|
602
|
+
""",
|
|
603
|
+
)
|
|
604
|
+
with pytest.raises(ConfigError):
|
|
605
|
+
load_config(path)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def test_env_file_without_equals_is_error(tmp_path):
|
|
609
|
+
(tmp_path / ".env").write_text("oops-no-equals\n")
|
|
610
|
+
path = _write(
|
|
611
|
+
tmp_path,
|
|
612
|
+
"""
|
|
613
|
+
notifiers:
|
|
614
|
+
- type: email
|
|
615
|
+
password: ${SMTP_PASSWORD}
|
|
616
|
+
targets:
|
|
617
|
+
- host: example.com
|
|
618
|
+
""",
|
|
619
|
+
)
|
|
620
|
+
with pytest.raises(ConfigError):
|
|
621
|
+
load_config(path)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|