diff --git a/fablednetmon/config.py b/fablednetmon/config.py index f6a50b9..99886a2 100644 --- a/fablednetmon/config.py +++ b/fablednetmon/config.py @@ -1,63 +1,81 @@ +# fablednetmon/config.py from __future__ import annotations +import logging import os +import secrets from pathlib import Path from typing import Any -import yaml -from dotenv import load_dotenv -REQUIRED_KEYS = ["secret_key", "database.url"] -_PREFIX = "FABLEDNETMON_" +logger = logging.getLogger(__name__) + +_SECRET_KEY_FILE = Path("/data/secret.key") -def load_config(config_path: Path | str | None = None) -> dict[str, Any]: - """Load config from YAML file with env var overrides. +def load_bootstrap(config_path: Path | str | None = None) -> dict[str, str]: + """Return the minimum bootstrap config: database_url and secret_key. - Env var naming: FABLEDNETMON_ + uppercased dotted path, dots -> __ - Example: database.url -> FABLEDNETMON_DATABASE__URL - List values are never overridden by env vars. + This is the only config read from files/env vars at startup. + Everything else is stored in the app_settings DB table. + + config_path is optional — used only for backwards compatibility with + existing config.yaml deployments. The file is not required. """ + from dotenv import load_dotenv load_dotenv() + raw: dict[str, Any] = {} if config_path is None: config_path = Path("config.yaml") config_path = Path(config_path) - if config_path.exists(): + import yaml with config_path.open() as f: - cfg: dict[str, Any] = yaml.safe_load(f) or {} - else: - cfg = {} + raw = yaml.safe_load(f) or {} - # 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) + database_url = ( + os.environ.get("FABLEDNETMON_DATABASE_URL") + or os.environ.get("FABLEDNETMON_DATABASE__URL") + or raw.get("database", {}).get("url") + ) + if not database_url: + raise ValueError( + "Database URL is required. Set FABLEDNETMON_DATABASE_URL env var " + "or add 'database.url' to config.yaml." + ) - # 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] + secret_key = _resolve_secret_key(raw) + plugin_dir = ( + os.environ.get("FABLEDNETMON_PLUGIN_DIR") + or raw.get("plugin_dir", "plugins") + ) - return cfg + return { + "database_url": database_url, + "secret_key": secret_key, + "plugin_dir": plugin_dir, + } -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 +def _resolve_secret_key(raw: dict) -> str: + """Resolve secret_key: env var → file → auto-generate.""" + from_env = os.environ.get("FABLEDNETMON_SECRET_KEY") or raw.get("secret_key") + if from_env: + return from_env + + if _SECRET_KEY_FILE.exists(): + key = _SECRET_KEY_FILE.read_text().strip() + if key: + return key + + key = secrets.token_hex(32) + try: + _SECRET_KEY_FILE.parent.mkdir(parents=True, exist_ok=True) + _SECRET_KEY_FILE.write_text(key) + logger.info("Generated new secret key and saved to %s", _SECRET_KEY_FILE) + except OSError as exc: + logger.warning( + "Could not write secret key to %s (%s). " + "Key will not persist across restarts.", + _SECRET_KEY_FILE, exc, + ) + return key