""" logging - Centralized logging configuration for Vacuum Wall. Call :func:`setup_logging` once at application startup. All other modules obtain a logger via ``logging.getLogger(__name__)``. Output: * **stderr** (StreamHandler) - captured by systemd journald * **data/logs/vacuum-wall.log** (RotatingFileHandler) - persisted for viewing via the WebUI ``/logs`` page. """ import contextlib import logging import os import sys from logging.handlers import RotatingFileHandler from pathlib import Path PROJECT_DIR = Path(__file__).resolve().parent.parent _LOG_DIR = PROJECT_DIR / "data" / "logs" _LOG_FILE = _LOG_DIR / "vacuum-wall.log" _MAX_BYTES = 5 * 1024 * 1024 # 5 MB _BACKUP_COUNT = 3 _LOG_FMT = "[%(asctime)s] %(levelname)-8s %(name)s %(message)s" _DATE_FMT = "%Y-%m-%d %H:%M:%S" _initialized = False def setup_logging(level: str | None = None) -> None: """Configure and enable root-level logging for the application. Safe to call multiple times; subsequent calls are no-ops. Args: level: Override log level string (e.g. ``"DEBUG"``). If ``None``, reads ``VACUUM_WALL_LOG_LEVEL`` from the environment, defaulting to ``"INFO"``. """ global _initialized if _initialized: return _initialized = True if level is None: level = os.environ.get("VACUUM_WALL_LOG_LEVEL", "INFO").upper() valid_levels = { "DEBUG": logging.DEBUG, "INFO": logging.INFO, "WARNING": logging.WARNING, "ERROR": logging.ERROR, "CRITICAL": logging.CRITICAL, } numeric = valid_levels.get(level, logging.INFO) root = logging.getLogger() root.setLevel(numeric) fmt = logging.Formatter(_LOG_FMT, datefmt=_DATE_FMT) # stderr handler — feeds systemd journal sh = logging.StreamHandler(sys.stderr) sh.setFormatter(fmt) root.addHandler(sh) class GroupWriteHandler(RotatingFileHandler): """RotatingFileHandler that always opens files with group-write mode. Ensures the log file is group-writable so both the WebUI process (vacuum-wall user) and daemon process (vacuum-walld user) can write to it when they share a group. """ def _open(self): """Override to open log file with group-write permissions (0664). Temporarily clears umask to ensure the file is writable by both the WebUI process (vacuum-wall user) and daemon process (vacuum-walld user) when they share a group. On existing stale files, chmod's to 0664. Returns: Open file object in append mode. """ # Ensure group-write on an existing stale file (e.g. left by the # other process with a stricter umask at creation time). with contextlib.suppress(OSError): os.chmod(self.baseFilename, 0o664) # Temporarily clear group-write umask bits so os.open's mode is # not masked away. old = os.umask(0o002) try: fd = os.open( self.baseFilename, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o664 ) finally: os.umask(old) return os.fdopen(fd, "a", errors="backslashreplace") def doRollover(self): """Override to enforce group-write on rotated files.""" super().doRollover() # Set group-write on all log files (current + backups) base = Path(self.baseFilename) for suffix in ("", ".1", ".2", ".3"): fp = str(base.parent / base.name + suffix) with contextlib.suppress(OSError): os.chmod(fp, 0o664) # rotating file handler with group-write permissions _LOG_DIR.mkdir(parents=True, exist_ok=True) try: fh = GroupWriteHandler( str(_LOG_FILE), maxBytes=_MAX_BYTES, backupCount=_BACKUP_COUNT, ) fh.setFormatter(fmt) root.addHandler(fh) except PermissionError: # Log file exists but is not writable (e.g. stale file from the other # process created with a stricter umask). Fall back to stderr-only. print( f"WARNING: cannot open log file {_LOG_FILE}, " "logging to stderr only. Fix with: chmod g+w " f"{_LOG_FILE}", file=sys.stderr, ) # Silence noisy third-party loggers in production for name in ("werkzeug", "urllib3"): logging.getLogger(name).setLevel(logging.WARNING)