#!/usr/bin/env python3 """Shared utilities for ns8-backup-monitor. This module provides two thin helpers used by every other module in the package: load_config() Locate and parse the YAML configuration file. The caller may supply an explicit path; when omitted, a built-in list of well-known locations is probed in order. setup_logging() Initialise the root Python logger from the ``logging`` section of the parsed configuration. A StreamHandler (stdout) is always added so that journald captures output when the service runs under systemd. An optional rotating file handler is added when ``logging.file`` is set. Both functions are called once during start-up from ``__main__.py`` before any other module-level loggers are used. """ import logging import logging.handlers import sys from pathlib import Path try: import yaml except ImportError: # PyYAML is the only non-stdlib dependency. We defer the error to # load_config() so that a helpful message is shown at runtime instead of # a bare ImportError traceback. yaml = None # --------------------------------------------------------------------------- # Default config search paths # --------------------------------------------------------------------------- # Tried in order when no explicit --config argument is given. # The last entry (config/config.yml) allows running directly from the repo # root during development without a system-wide installation. DEFAULT_CONFIG_PATHS = [ "/etc/ns8-backup-monitor/config.yml", # production install path "/opt/ns8-backup-monitor/config/config.yml", # alternative install layout "config/config.yml", # development / repo root ] def load_config(path: str = None) -> dict: """Locate and parse the YAML configuration file. Args: path: Explicit filesystem path to ``config.yml``. When ``None``, ``DEFAULT_CONFIG_PATHS`` is probed in order and the first existing file is used. Returns: Parsed configuration as a plain Python dict. Returns an empty dict if the file exists but is empty. Raises: ImportError: PyYAML is not installed. FileNotFoundError: No config file found at any of the probed paths. """ if yaml is None: raise ImportError("PyYAML not installed. Run: pip3 install pyyaml") # Use the caller-supplied path or fall back to the default search list. paths = [path] if path else DEFAULT_CONFIG_PATHS for p in paths: if p and Path(p).exists(): with open(p) as f: return yaml.safe_load(f) or {} raise FileNotFoundError( f"No config file found. Tried: {paths}\n" "Copy config/config.yml.example to config/config.yml and edit it." ) def setup_logging(config: dict): """Initialise the root logger from the ``logging`` section of *config*. Called once from ``__main__.py`` before any module-level loggers emit records. Subsequent calls are no-ops because ``basicConfig`` is idempotent once handlers are attached. Configuration keys (all optional): logging.level : Python log level name, e.g. ``INFO``, ``DEBUG``. Defaults to ``INFO``. logging.file : Absolute path for the rotating log file. When empty or absent, only stdout is used. The rotating file handler caps each file at 5 MB and keeps 3 backups, which limits total disk usage to ~20 MB regardless of uptime. Args: config: Parsed configuration dictionary (output of ``load_config``). """ log_cfg = config.get("logging", {}) # Resolve the log level; fall back to INFO for unknown names. level = getattr(logging, log_cfg.get("level", "INFO").upper(), logging.INFO) log_file = log_cfg.get("file", "") # Always log to stdout so journald captures output via # StandardOutput=journal in the systemd unit. handlers = [logging.StreamHandler(sys.stdout)] if log_file: # Rotating file handler: 5 MB per file, 3 rotated backups kept. handlers.append( logging.handlers.RotatingFileHandler( log_file, maxBytes=5 * 1024 * 1024, backupCount=3 ) ) logging.basicConfig( level=level, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", handlers=handlers, )