fix: logging verbosity now actually applies + per-service log levels
Unit Tests / test (push) Successful in 12m34s

Root causes fixed:
- Dead LOG_LEVEL globals() lookup pinned root logger at INFO regardless of
  PIC_LOG_LEVEL env or config; replaced with _resolve_root_log_level() +
  apply_root_log_level() which sets both root logger and all attached handlers
  at startup and on runtime re-apply.
- set_service_level() only set the named 'pic.<service>' logger; bare module
  loggers (e.g. 'caddy_manager') were never reached, so per-service log files
  stayed 0 bytes. Fixed via _SERVICE_MODULE_LOGGERS map covering all managers.
- Log viewer GET /api/logs had no level filter; added ?level= query param.
- Per-service log levels lived in an out-of-band config/api/log_levels.json
  side-file with no validation; migrated into ConfigManager under a new
  'logging' section ({python:{root,services}, containers:{caddy,coredns,
  wireguard,mailserver,api}}) with get/set helpers, invalid-level rejection,
  and one-time migration from the old file on first load.

New capabilities:
- Container log levels: Caddy (injects global log { level X } + hot reload),
  CoreDNS (DEBUG enables log plugin, else errors-only), WireGuard/mailserver
  via pending_restart path.
- PUT /api/logs/verbosity accepts {python, containers} dict; returns per-entry
  applied:hot|pending_restart status.
- Webui Logs page gains two-section Verbosity tab (Python services + Container
  services) with needs-restart badges.
- managers.py wires per-service loggers before manager instantiation and
  re-applies persisted levels from ConfigManager; legacy log_levels.json read
  removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:14:01 -04:00
parent 89aed4efe0
commit 13074f56cb
15 changed files with 726 additions and 158 deletions
+105
View File
@@ -44,6 +44,31 @@ _BACKUP_EXCLUDE_FILES = (
logger = logging.getLogger(__name__)
# Valid Python logging levels for the `logging` config section.
_VALID_LOG_LEVELS = ('DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL')
# Per-service Python loggers exposed in the verbosity panel.
_LOGGING_PYTHON_SERVICES = (
'network', 'wireguard', 'email', 'calendar',
'files', 'routing', 'vault', 'api',
)
# Container services whose log level we can influence (hot for caddy/coredns,
# pending_restart for env-driven containers).
_LOGGING_CONTAINERS = ('caddy', 'coredns', 'wireguard', 'mailserver', 'api')
def _default_logging_config() -> Dict[str, Any]:
"""Return the default `logging` section for cell_config."""
return {
'python': {
'root': 'INFO',
'services': {svc: 'INFO' for svc in _LOGGING_PYTHON_SERVICES},
},
'containers': {c: 'INFO' for c in _LOGGING_CONTAINERS},
}
class ConfigManager:
"""Centralized configuration management for all services (unified config)"""
@@ -75,6 +100,7 @@ class ConfigManager:
# Phase 5: ensure connectivity section exists with empty defaults.
if 'connectivity' not in self.configs:
self.configs['connectivity'] = {'exits': {}, 'peer_exit_map': {}}
self._ensure_logging_config()
if not self.config_file.exists():
self._save_all_configs()
# Silent migration: when DDNS is active but the internal domain is still
@@ -976,6 +1002,85 @@ class ConfigManager:
ident.setdefault('service_ips', {}).pop(service_id, None)
self._save_all_configs()
# ── Logging verbosity configuration ───────────────────────────────────
def _ensure_logging_config(self) -> None:
"""Ensure a well-formed `logging` section exists, migrating the legacy
config/api/log_levels.json side-file on first load.
The legacy file held a flat {service: LEVEL} map for the picell.* python
loggers. It is read once and merged in; the section then becomes the
single source of truth (the side-file is ignored thereafter).
"""
cfg = self.configs.get('logging')
if not isinstance(cfg, dict):
cfg = _default_logging_config()
self.configs['logging'] = cfg
python = cfg.setdefault('python', {})
if not python.get('root') or python['root'] not in _VALID_LOG_LEVELS:
python['root'] = 'INFO'
services = python.setdefault('services', {})
for svc in _LOGGING_PYTHON_SERVICES:
if services.get(svc) not in _VALID_LOG_LEVELS:
services.setdefault(svc, 'INFO')
containers = cfg.setdefault('containers', {})
for c in _LOGGING_CONTAINERS:
if containers.get(c) not in _VALID_LOG_LEVELS:
containers.setdefault(c, 'INFO')
# One-time migration from the legacy side-file.
if not cfg.get('_migrated_log_levels'):
legacy = self.config_file.parent / 'api' / 'log_levels.json'
legacy_flat = self.config_file.parent / 'log_levels.json'
for path in (legacy, legacy_flat):
try:
if path.exists():
with open(path) as lf:
for svc, lvl in (json.load(lf) or {}).items():
if (isinstance(lvl, str)
and lvl.upper() in _VALID_LOG_LEVELS
and svc in services):
services[svc] = lvl.upper()
except Exception as e:
logger.warning('log_levels.json migration skipped (%s): %s', path, e)
cfg['_migrated_log_levels'] = True
def get_logging_config(self) -> Dict[str, Any]:
"""Return the full logging config (python + containers sections)."""
self._ensure_logging_config()
cfg = self.configs['logging']
return {
'python': {
'root': cfg['python']['root'],
'services': dict(cfg['python']['services']),
},
'containers': dict(cfg['containers']),
}
def set_python_log_level(self, service: str, level: str) -> None:
"""Persist a python service (or 'root') log level. Raises ValueError on
an invalid level."""
level = (level or '').upper()
if level not in _VALID_LOG_LEVELS:
raise ValueError(f"Invalid log level: {level!r}")
self._ensure_logging_config()
python = self.configs['logging']['python']
if service == 'root':
python['root'] = level
else:
python.setdefault('services', {})[service] = level
self._save_all_configs()
def set_container_log_level(self, container: str, level: str) -> None:
"""Persist a container log level. Raises ValueError on an invalid level."""
level = (level or '').upper()
if level not in _VALID_LOG_LEVELS:
raise ValueError(f"Invalid log level: {level!r}")
self._ensure_logging_config()
self.configs['logging']['containers'][container] = level
self._save_all_configs()
# Phase 5 — Extended connectivity configuration helpers
def get_connectivity_config(self) -> Dict[str, Any]:
"""Return the full connectivity config (exits + peer_exit_map)."""