feat: configuration loader with env var overrides

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-17 18:43:57 -04:00
parent dec4155ffa
commit f8b82b65a3
4 changed files with 140 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
# Copy to .env for Docker / local development secrets
# These override values in config.yaml
FABLEDNETMON_SECRET_KEY=change-me
FABLEDNETMON_DATABASE__URL=postgresql+asyncpg://fablednetmon:password@localhost/fablednetmon
FABLEDNETMON_SMTP__PASSWORD=
+36
View File
@@ -0,0 +1,36 @@
# FabledNetMon configuration example
# Copy to config.yaml and fill in your values.
# Secrets can be set via env vars: FABLEDNETMON_<UPPER__PATH>
secret_key: "change-me-to-a-random-string"
database:
url: "postgresql+asyncpg://fablednetmon:password@localhost/fablednetmon"
session:
lifetime_hours: 8
data:
retention_days: 90
# plugin_dir: ./plugins # optional; defaults to ./plugins relative to config.yaml
plugins: {}
# traefik:
# enabled: true
# metrics_url: "http://localhost:8080/metrics"
# scrape_interval_seconds: 60
smtp:
host: ""
port: 587
tls: true
username: ""
password: "" # or: FABLEDNETMON_SMTP__PASSWORD env var
recipients: []
# - admin@example.com
webhook:
url: "" # leave empty to disable
template: |
{"content": "**{{ alert.state }}** — {{ alert.resource }} — {{ alert.rule_name }} ({{ alert.metric }} = {{ alert.value }})"}
+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
import os
from pathlib import Path
from typing import Any
import yaml
from dotenv import load_dotenv
REQUIRED_KEYS = ["secret_key", "database.url"]
_PREFIX = "FABLEDNETMON_"
def load_config(config_path: Path | str | None = None) -> dict[str, Any]:
"""Load config from YAML file with env var overrides.
Env var naming: FABLEDNETMON_ + uppercased dotted path, dots -> __
Example: database.url -> FABLEDNETMON_DATABASE__URL
List values are never overridden by env vars.
"""
load_dotenv()
if config_path is None:
config_path = Path("config.yaml")
config_path = Path(config_path)
if config_path.exists():
with config_path.open() as f:
cfg: dict[str, Any] = yaml.safe_load(f) or {}
else:
cfg = {}
# Apply env var overrides (scalars only)
for key, value in os.environ.items():
if not key.startswith(_PREFIX):
continue
path = key[len(_PREFIX):].lower().split("__")
_set_nested(cfg, path, value)
# Validate required keys
for dotted in REQUIRED_KEYS:
parts = dotted.split(".")
node = cfg
for part in parts:
if not isinstance(node, dict) or part not in node:
raise ValueError(
f"Required config key '{dotted}' is missing. "
f"Set it in config.yaml or via env var "
f"FABLEDNETMON_{dotted.upper().replace('.', '__')}"
)
node = node[part]
return cfg
def _set_nested(cfg: dict, path: list[str], value: str) -> None:
"""Set a scalar value at a nested dict path. Skips if target is a list."""
node = cfg
for part in path[:-1]:
node = node.setdefault(part, {})
key = path[-1]
existing = node.get(key)
if isinstance(existing, list):
return # never override list values
node[key] = value
+35
View File
@@ -0,0 +1,35 @@
import os
import textwrap
import pytest
from fablednetmon.config import load_config
def test_load_minimal_config(tmp_path):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text(textwrap.dedent("""\
database:
url: postgresql+asyncpg://user:pass@localhost/fablednetmon
secret_key: test-secret
"""))
cfg = load_config(cfg_file)
assert cfg["database"]["url"] == "postgresql+asyncpg://user:pass@localhost/fablednetmon"
assert cfg["secret_key"] == "test-secret"
def test_env_var_overrides_scalar(tmp_path, monkeypatch):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("database:\n url: original\nsecret_key: s\n")
monkeypatch.setenv("FABLEDNETMON_DATABASE__URL", "overridden")
cfg = load_config(cfg_file)
assert cfg["database"]["url"] == "overridden"
def test_env_var_does_not_override_list(tmp_path, monkeypatch):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("smtp:\n recipients:\n - a@b.com\nsecret_key: s\ndatabase:\n url: x\n")
monkeypatch.setenv("FABLEDNETMON_SMTP__RECIPIENTS", "bad")
cfg = load_config(cfg_file)
assert cfg["smtp"]["recipients"] == ["a@b.com"]
def test_missing_required_key_raises(tmp_path):
cfg_file = tmp_path / "config.yaml"
cfg_file.write_text("{}")
with pytest.raises(ValueError, match="secret_key"):
load_config(cfg_file)