feat: configuration loader with env var overrides
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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=
|
||||||
@@ -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 }})"}
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user