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