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
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""
Tests for the `logging` section of cell_config (ConfigManager):
- default schema present after first load
- round-trip persistence of python + container levels
- migration from the legacy config/api/log_levels.json side-file
- invalid-level rejection
- inclusion in the backed-up cell_config
"""
import sys
import os
import json
import tempfile
import shutil
import unittest
from pathlib import Path
api_dir = Path(__file__).parent.parent / 'api'
sys.path.insert(0, str(api_dir))
from config_manager import ConfigManager
def _make_cm(tmp):
config_file = os.path.join(tmp, 'cell_config.json')
data_dir = os.path.join(tmp, 'data')
os.makedirs(data_dir, exist_ok=True)
return ConfigManager(config_file, data_dir)
class TestLoggingSchema(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmp, ignore_errors=True)
def test_default_logging_config_present(self):
cm = _make_cm(self.tmp)
cfg = cm.get_logging_config()
self.assertEqual(cfg['python']['root'], 'INFO')
self.assertEqual(cfg['python']['services']['network'], 'INFO')
self.assertEqual(cfg['containers']['caddy'], 'INFO')
self.assertEqual(cfg['containers']['coredns'], 'INFO')
def test_set_and_get_python_level_round_trip(self):
cm = _make_cm(self.tmp)
cm.set_python_log_level('network', 'DEBUG')
cm.set_python_log_level('root', 'WARNING')
# Re-load from disk to prove persistence.
cm2 = _make_cm(self.tmp)
cfg = cm2.get_logging_config()
self.assertEqual(cfg['python']['services']['network'], 'DEBUG')
self.assertEqual(cfg['python']['root'], 'WARNING')
def test_set_and_get_container_level_round_trip(self):
cm = _make_cm(self.tmp)
cm.set_container_log_level('coredns', 'DEBUG')
cm2 = _make_cm(self.tmp)
self.assertEqual(cm2.get_logging_config()['containers']['coredns'], 'DEBUG')
def test_invalid_python_level_rejected(self):
cm = _make_cm(self.tmp)
with self.assertRaises(ValueError):
cm.set_python_log_level('network', 'LOUD')
def test_invalid_container_level_rejected(self):
cm = _make_cm(self.tmp)
with self.assertRaises(ValueError):
cm.set_container_log_level('caddy', 'chatty')
def test_migration_from_legacy_log_levels_json(self):
# Legacy side-file lived at config/api/log_levels.json (next to cell_config).
api_cfg_dir = os.path.join(self.tmp, 'api')
os.makedirs(api_cfg_dir, exist_ok=True)
with open(os.path.join(api_cfg_dir, 'log_levels.json'), 'w') as f:
json.dump({'network': 'DEBUG', 'email': 'WARNING', 'bogus': 'INFO'}, f)
cm = _make_cm(self.tmp)
cfg = cm.get_logging_config()
self.assertEqual(cfg['python']['services']['network'], 'DEBUG')
self.assertEqual(cfg['python']['services']['email'], 'WARNING')
# Unknown service names from the legacy file are ignored.
self.assertNotIn('bogus', cfg['python']['services'])
def test_logging_section_is_part_of_persisted_config(self):
"""The logging section lives in cell_config (already in the backup set)."""
cm = _make_cm(self.tmp)
cm.set_python_log_level('vault', 'ERROR')
with open(cm.config_file) as f:
on_disk = json.load(f)
self.assertIn('logging', on_disk)
self.assertEqual(on_disk['logging']['python']['services']['vault'], 'ERROR')
if __name__ == '__main__':
unittest.main()