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.
Files changed (34) hide show
  1. {certminder-2.2.0/src/certminder.egg-info → certminder-2.3.0}/PKG-INFO +52 -2
  2. {certminder-2.2.0 → certminder-2.3.0}/README.md +49 -0
  3. {certminder-2.2.0 → certminder-2.3.0}/pyproject.toml +3 -2
  4. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/config.py +82 -0
  5. {certminder-2.2.0 → certminder-2.3.0/src/certminder.egg-info}/PKG-INFO +52 -2
  6. {certminder-2.2.0 → certminder-2.3.0}/src/certminder.egg-info/requires.txt +2 -1
  7. {certminder-2.2.0 → certminder-2.3.0}/tests/test_config.py +283 -0
  8. {certminder-2.2.0 → certminder-2.3.0}/LICENSE +0 -0
  9. {certminder-2.2.0 → certminder-2.3.0}/setup.cfg +0 -0
  10. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/__init__.py +0 -0
  11. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/cli.py +0 -0
  12. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/engine.py +0 -0
  13. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/evaluator.py +0 -0
  14. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/metrics.py +0 -0
  15. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/models.py +0 -0
  16. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/__init__.py +0 -0
  17. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/base.py +0 -0
  18. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/console.py +0 -0
  19. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/email.py +0 -0
  20. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/slack.py +0 -0
  21. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/notifiers/webhook.py +0 -0
  22. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/scheduler.py +0 -0
  23. {certminder-2.2.0 → certminder-2.3.0}/src/certminder/state.py +0 -0
  24. {certminder-2.2.0 → certminder-2.3.0}/src/certminder.egg-info/SOURCES.txt +0 -0
  25. {certminder-2.2.0 → certminder-2.3.0}/src/certminder.egg-info/dependency_links.txt +0 -0
  26. {certminder-2.2.0 → certminder-2.3.0}/src/certminder.egg-info/entry_points.txt +0 -0
  27. {certminder-2.2.0 → certminder-2.3.0}/src/certminder.egg-info/top_level.txt +0 -0
  28. {certminder-2.2.0 → certminder-2.3.0}/tests/test_cli.py +0 -0
  29. {certminder-2.2.0 → certminder-2.3.0}/tests/test_engine.py +0 -0
  30. {certminder-2.2.0 → certminder-2.3.0}/tests/test_evaluator.py +0 -0
  31. {certminder-2.2.0 → certminder-2.3.0}/tests/test_metrics.py +0 -0
  32. {certminder-2.2.0 → certminder-2.3.0}/tests/test_notifiers.py +0 -0
  33. {certminder-2.2.0 → certminder-2.3.0}/tests/test_scheduler.py +0 -0
  34. {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.2.0
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.1.1
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.2.0"
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.1.1",
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.2.0
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.1.1
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
@@ -1,5 +1,6 @@
1
- certinspect>=2.1.1
1
+ certinspect>=2.2.0
2
2
  PyYAML>=6.0.3
3
+ python-dotenv>=1.0.0
3
4
 
4
5
  [dev]
5
6
  pytest>=9.1.1
@@ -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