diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..12a831e --- /dev/null +++ b/.env.example @@ -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= diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..3ea8968 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,36 @@ +# FabledNetMon configuration example +# Copy to config.yaml and fill in your values. +# Secrets can be set via env vars: FABLEDNETMON_ + +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 }})"} diff --git a/fablednetmon/config.py b/fablednetmon/config.py new file mode 100644 index 0000000..f6a50b9 --- /dev/null +++ b/fablednetmon/config.py @@ -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 diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..3a53711 --- /dev/null +++ b/tests/test_config.py @@ -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)