Files
unraid-mcp/unraid_mcp/config/logging.py
Jacob Magar 316193c04b refactor: comprehensive code review fixes across 31 files
Addresses all critical, high, medium, and low issues from full codebase
review. 494 tests pass, ruff clean, ty type-check clean.

Security:
- Add tool_error_handler context manager (exceptions.py) — standardised
  error handling, eliminates 11 bare except-reraise patterns
- Remove unused exception subclasses (ConfigurationError, UnraidAPIError,
  SubscriptionError, ValidationError, IdempotentOperationError)
- Harden GraphQL subscription query validator with allow-list and
  forbidden-keyword regex (diagnostics.py)
- Add input validation for rclone create_remote config_data: injection,
  path-traversal, and key-count limits (rclone.py)
- Validate notifications importance enum before GraphQL request (notifications.py)
- Sanitise HTTP/network/JSON error messages — no raw exception strings
  leaked to clients (client.py)
- Strip path/creds from displayed API URL via _safe_display_url (health.py)
- Enable Ruff S (bandit) rule category in pyproject.toml
- Harden container mutations to strict-only matching — no fuzzy/substring
  for destructive operations (docker.py)

Performance:
- Token-bucket rate limiter (90 tokens, 9 req/s) with 429 retry backoff (client.py)
- Lazy asyncio.Lock init via _get_client_lock() — fixes event-loop
  module-load crash (client.py)
- Double-checked locking in get_http_client() for fast-path (client.py)
- Short hex container ID fast-path skips list fetch (docker.py)
- Cap resource_data log content to 1 MB / 5,000 lines (manager.py)
- Reset reconnect counter after 30 s stable connection (manager.py)
- Move tail_lines validation to module level; enforce 10,000 line cap
  (storage.py, docker.py)
- force_terminal=True removed from logging RichHandler (logging.py)

Architecture:
- Register diagnostic tools in server startup (server.py)
- Move ALL_ACTIONS computation to module level in all tools
- Consolidate format_kb / format_bytes into shared core/utils.py
- Add _safe_get() helper in core/utils.py for nested dict traversal
- Extract _analyze_subscription_status() from health.py diagnose handler
- Validate required config at startup — fail fast with CRITICAL log (server.py)

Code quality:
- Remove ~90 lines of dead Rich formatting helpers from logging.py
- Remove dead self.websocket attribute from SubscriptionManager
- Remove dead setup_uvicorn_logging() wrapper
- Move _VALID_IMPORTANCE to module level (N806 fix)
- Add slots=True to all three dataclasses (SubscriptionData, SystemHealth, APIResponse)
- Fix None rendering as literal "None" string in info.py summaries
- Change fuzzy-match log messages from INFO to DEBUG (docker.py)
- UTC-aware datetimes throughout (manager.py, diagnostics.py)

Infrastructure:
- Upgrade base image python:3.11-slim → python:3.12-slim (Dockerfile)
- Add non-root appuser (UID/GID 1000) with HEALTHCHECK (Dockerfile)
- Add read_only, cap_drop: ALL, tmpfs /tmp to docker-compose.yml
- Single-source version via importlib.metadata (pyproject.toml → __init__.py)
- Add open_timeout to all websockets.connect() calls

Tests:
- Update error message matchers to match sanitised messages (test_client.py)
- Fix patch targets for UNRAID_API_URL → utils module (test_subscriptions.py)
- Fix importance="info" → importance="normal" (test_notifications.py, http_layer)
- Fix naive datetime fixtures → UTC-aware (test_subscriptions.py)

Co-authored-by: Claude <claude@anthropic.com>
2026-02-18 01:02:13 -05:00

254 lines
8.1 KiB
Python

"""Logging configuration for Unraid MCP Server.
This module sets up structured logging with Rich console and overwrite file handlers
that cap at 10MB and start over (no rotation) for consistent use across all modules.
"""
import logging
from pathlib import Path
from rich.console import Console
from rich.logging import RichHandler
try:
from fastmcp.utilities.logging import get_logger as get_fastmcp_logger
FASTMCP_AVAILABLE = True
except ImportError:
FASTMCP_AVAILABLE = False
from .settings import LOG_FILE_PATH, LOG_LEVEL_STR
# Global Rich console for consistent formatting
console = Console(stderr=True)
class OverwriteFileHandler(logging.FileHandler):
"""Custom file handler that overwrites the log file when it reaches max size."""
def __init__(self, filename, max_bytes=10 * 1024 * 1024, mode="a", encoding=None, delay=False):
"""Initialize the handler.
Args:
filename: Path to the log file
max_bytes: Maximum file size in bytes before overwriting (default: 10MB)
mode: File open mode
encoding: File encoding
delay: Whether to delay file opening
"""
self.max_bytes = max_bytes
self._emit_count = 0
self._check_interval = 100
super().__init__(filename, mode, encoding, delay)
def emit(self, record):
"""Emit a record, checking file size periodically and overwriting if needed."""
self._emit_count += 1
if (
self._emit_count % self._check_interval == 0
and self.stream
and hasattr(self.stream, "name")
):
try:
base_path = Path(self.baseFilename)
if base_path.exists():
file_size = base_path.stat().st_size
if file_size >= self.max_bytes:
# Close current stream
if self.stream:
self.stream.close()
# Remove the old file and start fresh
if base_path.exists():
base_path.unlink()
# Reopen with truncate mode
self.stream = self._open()
# Log a marker that the file was reset
reset_record = logging.LogRecord(
name="UnraidMCPServer.Logging",
level=logging.INFO,
pathname="",
lineno=0,
msg="=== LOG FILE RESET (10MB limit reached) ===",
args=(),
exc_info=None,
)
super().emit(reset_record)
except OSError as e:
import sys
print(
f"WARNING: Log file size check failed: {e}. Continuing without rotation.",
file=sys.stderr,
)
# Emit the original record
super().emit(record)
def _create_shared_file_handler() -> OverwriteFileHandler:
"""Create the single shared file handler for all loggers.
Returns:
Configured OverwriteFileHandler instance
"""
numeric_log_level = getattr(logging, LOG_LEVEL_STR, logging.INFO)
handler = OverwriteFileHandler(LOG_FILE_PATH, max_bytes=10 * 1024 * 1024, encoding="utf-8")
handler.setLevel(numeric_log_level)
handler.setFormatter(
logging.Formatter(
"%(asctime)s - %(name)s - %(levelname)s - %(module)s - %(funcName)s - %(lineno)d - %(message)s"
)
)
return handler
# Single shared file handler — all loggers reuse this instance to avoid
# race conditions from multiple OverwriteFileHandler instances on the same file.
_shared_file_handler = _create_shared_file_handler()
def setup_logger(name: str = "UnraidMCPServer") -> logging.Logger:
"""Set up and configure the logger with console and file handlers.
Args:
name: Logger name (defaults to UnraidMCPServer)
Returns:
Configured logger instance
"""
# Get numeric log level
numeric_log_level = getattr(logging, LOG_LEVEL_STR, logging.INFO)
# Define the logger
logger = logging.getLogger(name)
logger.setLevel(numeric_log_level)
logger.propagate = False # Prevent root logger from duplicating handlers
# Clear any existing handlers
logger.handlers.clear()
# Rich Console Handler for beautiful output
console_handler = RichHandler(
console=console,
show_time=True,
show_level=True,
show_path=False,
rich_tracebacks=True,
tracebacks_show_locals=False,
)
console_handler.setLevel(numeric_log_level)
logger.addHandler(console_handler)
# Reuse the shared file handler
logger.addHandler(_shared_file_handler)
return logger
def configure_fastmcp_logger_with_rich() -> logging.Logger | None:
"""Configure FastMCP logger to use Rich formatting with Nordic colors."""
if not FASTMCP_AVAILABLE:
return None
# Get numeric log level
numeric_log_level = getattr(logging, LOG_LEVEL_STR, logging.INFO)
# Get the FastMCP logger
fastmcp_logger = get_fastmcp_logger("UnraidMCPServer")
# Clear existing handlers
fastmcp_logger.handlers.clear()
fastmcp_logger.propagate = False
# Rich Console Handler
console_handler = RichHandler(
console=console,
show_time=True,
show_level=True,
show_path=False,
rich_tracebacks=True,
tracebacks_show_locals=False,
markup=True,
)
console_handler.setLevel(numeric_log_level)
fastmcp_logger.addHandler(console_handler)
# Reuse the shared file handler
fastmcp_logger.addHandler(_shared_file_handler)
fastmcp_logger.setLevel(numeric_log_level)
# Also configure the root logger to catch any other logs
root_logger = logging.getLogger()
root_logger.handlers.clear()
root_logger.propagate = False
# Rich Console Handler for root logger
root_console_handler = RichHandler(
console=console,
show_time=True,
show_level=True,
show_path=False,
rich_tracebacks=True,
tracebacks_show_locals=False,
markup=True,
)
root_console_handler.setLevel(numeric_log_level)
root_logger.addHandler(root_console_handler)
# Reuse the shared file handler for root logger
root_logger.addHandler(_shared_file_handler)
root_logger.setLevel(numeric_log_level)
return fastmcp_logger
def log_configuration_status(logger: logging.Logger) -> None:
"""Log configuration status at startup.
Args:
logger: Logger instance to use for logging
"""
from .settings import get_config_summary
logger.info(f"Logging initialized (console and file: {LOG_FILE_PATH}).")
config = get_config_summary()
# Log configuration status
if config["api_url_configured"]:
logger.info(f"UNRAID_API_URL loaded: {config['api_url_preview']}")
else:
logger.warning("UNRAID_API_URL not found in environment or .env file.")
if config["api_key_configured"]:
logger.info("UNRAID_API_KEY loaded: ****") # Don't log the key itself
else:
logger.warning("UNRAID_API_KEY not found in environment or .env file.")
logger.info(f"UNRAID_MCP_PORT set to: {config['server_port']}")
logger.info(f"UNRAID_MCP_HOST set to: {config['server_host']}")
logger.info(f"UNRAID_MCP_TRANSPORT set to: {config['transport']}")
logger.info(f"UNRAID_MCP_LOG_LEVEL set to: {config['log_level']}")
if not config["config_valid"]:
logger.error(f"Missing required configuration: {config['missing_config']}")
# Global logger instance - modules can import this directly
if FASTMCP_AVAILABLE:
# Use FastMCP logger with Rich formatting
_fastmcp_logger = configure_fastmcp_logger_with_rich()
logger = _fastmcp_logger if _fastmcp_logger is not None else setup_logger()
else:
# Fallback to our custom logger if FastMCP is not available
logger = setup_logger()
# Also configure FastMCP logger for consistency
configure_fastmcp_logger_with_rich()