fix: logging verbosity now actually applies + per-service log levels
Unit Tests / test (push) Successful in 12m34s
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:
@@ -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)."""
|
||||
|
||||
Reference in New Issue
Block a user