mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
* fix(errors): record the exception's own stack trace record_error() called traceback.format_exc(), which only sees an exception while its except block is running. plugin_executor records exceptions caught on a worker thread after that block has ended, so every trace on /errors read "NoneType: None". The trace is now built from the exception's __traceback__. The executor's log call had the same problem with exc_info=True and now passes the exception. record_error() also merged LEDMatrixError context into the caller's dict in place; it now works on a copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(wifi): point at configure_wifi_permissions.sh instead of a sudoers list The module docstring told users to grant NOPASSWD sudo on iptables and ip. configure_wifi_permissions.sh refuses those grants on purpose: a wildcard rule for either runs an arbitrary program as root. Point at the script and say why it leaves them out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): disconnect finds the saved profile by SSID disconnect_from_network() asked `nmcli -f NAME,802-11-wireless.ssid connection show` for the profile to take down, but nmcli rejects that column for `connection show`, so the lookup always failed and only the device was disconnected. The per-profile lookup _connect_nmcli() already used is now _find_profile_for_ssid(), and both callers share it. It also splits terse output on the last colon and unescapes "\:", so a profile name containing a colon is found. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(wifi): write wifi_config.json atomically and report a failed save _save_config() opened the file for writing in place and swallowed any error, so a wifi_config.json left owned by root made the web toggle for auto-enabling AP mode report success while nothing was saved, and a crash mid-write could truncate the file. It now uses atomic_write_json, which also keeps the file's owner and shared group when root saves it, and returns False on failure. POST /wifi/ap/auto-enable answers 500 in that case. The file is now written with indent=4, like the other config files. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): resolve plugin:// fonts in the plugin's own directory FontManager looked for a plugin's bundled fonts under Path("plugins") / plugin_id: relative to the process cwd, and not the default install directory (plugin-repos/), so a manifest's plugin:// fonts never loaded. register_plugin_fonts() takes an optional plugin_dir, and PluginManager passes the directory it loaded the plugin from. Callers that omit it get a lookup in the configured plugin_system.plugins_directory, then plugins/, resolved against the install root. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(api-helper): cache responses for the requested cache_ttl APIHelper.get(cache_ttl=...) and set_cache(ttl=...) dropped the ttl on the claim that CacheManager does not support one, but CacheManager.set() takes a ttl, stores it with the entry, and both cache tiers honour it over a reader's max_age. Without it every response expired after the 300-second default read age, whatever the plugin asked for. The ttl is now passed through, and the cache read passes cache_ttl as max_age for entries written without one. The class docstring describes what the helper actually does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(style): one scale range for the schema, element_scale and LogoHelper The generated Scale field allowed 0.1 to 10, element_style's reader capped at 10 with no floor, and LogoHelper accepted 0.05 to 8 and reset anything else to 1.0. A logo scale of 9, which the form accepts, drew at the shipped size. MIN_ELEMENT_SCALE / MAX_ELEMENT_SCALE (0.1, 10.0) in src.element_style are now the schema bounds and the clamp every reader applies through coerce_scale(): a positive number outside the range is clamped, and anything that is not a finite positive number means the default. That also stops element_scale() passing NaN through, since min(nan, 10.0) is nan. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(logos): placeholder lands at the requested path; empty logos list download_missing_logo() wrote its fallback placeholder to <normalize_abbreviation(abbr)>.png in the logo directory rather than to the logo_path the caller passed, so it could return True while nothing existed where the plugin looks (e.g. "TA&M.png" vs "TAANDM.png"). create_placeholder_logo() takes an optional filepath, and download_missing_logo passes the requested one. download_missing_logo_for_team() only caught KeyError, so a team whose "logos" list is empty raised IndexError; it now treats KeyError, IndexError and TypeError as "no logo URL". The placeholder is drawn with PLACEHOLDER_SIZE / PLACEHOLDER_BG, the constants is_placeholder_logo() recognises it by, instead of repeated literals. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(fonts): resolve bundled font paths against the install root TextHelper's default font_dir, the logo placeholder's font and FontManager's font_overrides.json were all relative to the process cwd, so a process started anywhere but the install root (the plugin safety harness, a manual run, a unit without WorkingDirectory) drew with PIL's default face and read no overrides. They now go through font_layout.resolve_asset_path; the overrides file sits in the install root's config/. The resolver docstrings described an order the code does not follow: resolve_asset_path never consults the cwd, and sports_shared's _resolve_font_path tries the cwd first. Both docstrings now say what the code does, and _resolve_font_path calls resolve_asset_path instead of probing FontManager for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(sync): the web UI reads the sync status file the display writes sync_manager writes its status to tempfile.gettempdir(), but GET /api/v3/sync/status read a hardcoded /tmp/led_matrix_sync_status.json and defaulted the port to a literal 5765. Wherever TMPDIR is set (or on any non-/tmp host) the page only ever showed "starting". The endpoint now uses sync_manager.STATUS_FILE and SYNC_PORT. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(http): the rankings resolver sends the project's User-Agent DynamicTeamResolver fetched ESPN rankings with a bare requests.get, so it sent python-requests' default User-Agent, which ESPN rejects; the AP_TOP_N favourites then resolved to nothing. It now sends DEFAULT_HTTP_HEADERS. BaseOddsManager carried its own copy of the User-Agent string and now uses the same shared headers (which also adds Accept-Language). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(backup): record the core release and read the configured plugin dir The manifest's ledmatrix_version came from a VERSION file that does not exist, then from .git/HEAD: a 12-character sha, or "ref: refs/he" when the branch's ref was packed. It is now src.__version__. list_installed_plugins() scanned a hardcoded plugin-repos/, so on an install whose plugin_system.plugins_directory points elsewhere, plugins missing from plugin_state.json were left out of the backup. It now reads the configured directory from config/config.json, defaulting to plugin-repos. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(startup): report a missing display section once A config without a display section produced three errors for the one problem ("Missing required configuration key: display", "Display configuration is missing or empty" and "Display configuration is missing"), and an empty one produced two. _validate_config now reports it once, as a missing key or an empty section, and _validate_display_config leaves it to that. The module docstring said the validator fails fast; nothing in the display service calls raise_on_errors(), so it now says the errors are reported and startup continues. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(wifi): share the copied blocks and name the AP constants - _parse_nmcli_wifi_list() is the one parser behind _scan_nmcli and _scan_nmcli_cached. - _verify_connected(), _wait_for_device_idle(), _failsafe_ap() and _mark_forced() replace blocks that were pasted two or three times in the connect and enable-AP paths. The device-idle wait now checks before its first one-second sleep instead of after it. - _check_command() calls _find_command_path() instead of repeating it. - AP_IP, PORTAL_PORT, AP_PROFILE_NAME and AP_PROFILE_NAMES name values that were spelled out 14, 12, 8 and 2 times; the two deletion loops now walk the same tuple. The iwconfig status path compares the AP address exactly: startswith() also skipped 192.168.4.10-19. - Dropped a second WIFI.SIGNAL query that repeated the first, a no-op "if ssid: continue", the try/except around _connect_wpa_supplicant's constant return, and a second save of a scan scan_networks already saves. - _ensure_wifi_radio_enabled's docstring says it returns True when the radio state cannot be read at all. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(config): drop dead branches and history comments in ConfigManager - The module docstring pointed plugin authors at update_plugin_config(), which does not exist; it now names save_config_atomic() and save_raw_file_content(). - load_config's FileNotFoundError handler tested the message for "config_secrets.json", but a missing secrets file is handled where it is read, so only config.json reaches it; the check is gone. - save_raw_file_content's `file_type == "main" or "secrets"` guard was always true (anything else raised earlier). - get_raw_file_content('secrets') already returns {} for a missing file, so the os.path.exists() in front of two calls to it is gone. - Comments that narrated earlier behaviour are rewritten as what the code does now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(background-data): present-tense comments, drop unused API - Comments that told the history of each fix (what "used to" happen, "the old per-delivery release") now state the invariant the code keeps. - get_statistics() no longer reports a constant 'queue_size': 0, and the uncalled clear_completed_requests() is gone (_cleanup_completed_requests does that job on every completion). Neither is referenced in core, the web UI or the plugin monorepo. shutdown_background_service() has no production caller either, but it is the only way to tear down the get_background_service() singleton, which the tests rely on, so it stays. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(odds): drop the unread cache_ttl and merge the odds_data branches BaseOddsManager loaded base_odds_manager.cache_ttl from config and never used it: cached odds live for the update interval (get_odds' ttl=interval). No core or monorepo code reads the attribute, so it is gone along with its log line. The two consecutive `if odds_data:` blocks are one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(backup): one table for the single-file sections config, secrets, wifi and ytm_auth were each spelled out in create, preview, validate and restore. _SINGLE_FILE_SECTIONS lists them once, with the RestoreOptions flag that restores each, and all four walk it. Restore error messages keep their wording ("Failed to restore <file name>"). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(fonts): drop FontManager's write-only state and duplicate logs - fonts_config, font_metadata and font_dependencies were written and never read; the performance_stats keys font_load_times, render_times, total_renders and the per-call "resolve" timings (_record_performance_metric) likewise. get_performance_stats() reads only the counters that remain. Nothing in core or the plugin monorepo references any of them. - A failed BDF load was logged twice, by _load_bdf_font and again by get_font; get_font's line is the one kept. - Removed "NEW:" and commented-out cozette entries, the "Copy font to assets/fonts" comment on code that copies nothing, and local imports of names the module already imports. The deprecated add_font() now resolves assets/fonts against the install root. The @deprecated methods stay. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(text-helper): cache loaded fonts; drop the pre-textlength fallback TextHelper declared _font_cache, cleared it and reported its size, but never stored anything in it. load_fonts() now keeps each (file, size) it loads there, so clear_font_cache() and get_font_cache_stats() mean what they say and repeated load_fonts() calls reuse the fonts. get_text_width() no longer catches AttributeError for Pillow releases without ImageDraw.textlength; requirements.txt pins Pillow>=12.2. The class docstring describes what the helper does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(common): fix wrong docstrings in api_helper, permission_utils, snapshot_policy - permission_utils called 0o2775 "sticky bit"; the 2 is setgid, which is what makes new files take the directory's group. - snapshot_policy pointed at web_interface/blueprints/api_v3.py, which is a package now; the health check is in api_v3/misc.py. - APIHelper.clear_cache() lost a history note and a fallback to a clear() method that neither CacheManager nor the testing MockCacheManager has. The session headers are built from DEFAULT_HTTP_HEADERS instead of a copy of them, and the module docstring says what the module offers. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(sports): present-tense comments in the shared scoreboard renderers - sports_scroll and sports_game_renderer comments that referred to "this PR", "the old flat 128px card" or what the renderer "previously" did now describe the current behaviour and its reason. - The block explaining why non-finite settings are rejected sat above _score_reserve_width; it describes _center_gap_width and now lives in it. - unshare_element_fonts wrapped its import of font_layout.load_truetype in an `except ImportError` that cannot fire inside core; the import stays at call time so tests can spy on the pinned loader. - sports_card docstrings that told the history of a fix say what the code does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sports-shared): drop dead code, name the ESPN limit - _get_weeks_data asked for limit=1000, which fetch_espn_scoreboard clamps to ESPN_MAX_LIMIT anyway; it now names that constant. Its unused `immediate_events = []` is gone. - _get_season_schedule_dates() returned ("", "") and has no caller in core or the plugin monorepo. - _should_log keeps its warning_type parameter (part of the inherited signature, though nothing in core or the monorepo calls it) and its docstring says the cooldown is shared across types. - An unused ImageFont import is gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(sync): one follower-mode switch, shared panel defaults - The class docstring said the leader sends PNG frames. Frames go over UDP as raw RGB; PNG is only the Vegas scroll image sent over TCP. It now describes both paths. - _enter_follower_mode() replaces the two copies of "note the leader, switch from standalone to follower, log, write status" in the frame and scroll-position handlers. - The rows/cols fallbacks use DEFAULT_ROWS / DEFAULT_COLS from src.display_geometry, as chain_length already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(style): drop _layout_axis, name the layout group title - ElementStyleResolver._layout_axis() had no caller in core or the plugin monorepo. - _element_block_from_spec checked spec['size'] was a dict again after size_spec already had; it reads size_spec. - The "Layout Offsets" title written into three generated schema blocks is _LAYOUT_TITLE. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(logo-helper): say what the placeholder draws; name the 1.5 box factor - _create_placeholder_logo's docstring said it draws the team abbreviation; it draws an outlined grey box and nothing else. The docstring says so, and the "in a real implementation you'd want text" comments are gone. - The 1.5 x panel default logo box, written out six times, is DEFAULT_LOGO_BOX_FACTOR. - ImageDraw is imported with Image at the top of the module. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(logos): drop dead code and a duplicate regex in logo_downloader - _SAFE_LEAGUE_CODE_RE was the same pattern as _SAFE_LEAGUE_RE; both checks use the one. - get_logo_filename_variations reassigned the TA&M case to the list it already had; the function returns the two names directly. - _get_team_name_variations() had no caller in core or the plugin monorepo. - fetch_single_team's docstring was copied from fetch_teams_data; a log message read "for{team_id}". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor: drop the Pillow<9.1 resample shim and a catch-and-reraise - adaptive_images fell back to Image.LANCZOS/NEAREST for Pillow < 9.1; requirements.txt pins Pillow>=12.2. RESAMPLE_LANCZOS and RESAMPLE_NEAREST keep their names (src.common re-exports them). - CacheManager.save_cache caught CacheError only to re-raise it; the disk write is now called directly, with the same result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(api-helper): stop the real CacheManager's cleanup thread The cache-lifetime tests built a CacheManager and left its cleanup thread's class-wide claim on the directory in place, which broke test_cache_cleanup_thread_ownership when it ran later in the session. The fixture now stops the thread on teardown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(changelog): core-common Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
785 lines
36 KiB
Python
785 lines
36 KiB
Python
"""
|
|
Config Manager — reads, writes, and validates ``config/config.json``.
|
|
|
|
:class:`ConfigManager` is the single owner of the on-disk configuration
|
|
files:
|
|
|
|
* ``config/config.json`` — main user-editable configuration.
|
|
* ``config/config_secrets.json`` — sensitive values (API keys, tokens).
|
|
|
|
Every write of either file goes through
|
|
:func:`~src.config_manager_atomic.atomic_write_text`: temp file, fsync,
|
|
rename, directory fsync. A crash or power cut mid-save leaves the old file or
|
|
the new one, never a truncated one. :meth:`ConfigManager.save_config_atomic`
|
|
additionally keeps rotating backups in ``config/backups/``.
|
|
|
|
Plugin configuration
|
|
--------------------
|
|
Plugin configs are stored inside ``config.json`` under the plugin's ID key
|
|
and survive plugin reinstalls. Write them by saving the whole config with
|
|
:meth:`ConfigManager.save_config_atomic` (or
|
|
:meth:`ConfigManager.save_raw_file_content`); never write settings into the
|
|
plugin directory, which a reinstall deletes.
|
|
|
|
Hot-reload
|
|
----------
|
|
:class:`~src.config_service.ConfigService` wraps ``ConfigManager`` and
|
|
detects file changes, broadcasting the new config to registered listeners
|
|
without requiring a restart.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import Dict, Any, Optional, List
|
|
from src.core_config_keys import CORE_CONFIG_KEYS, CORE_SECRETS_KEYS
|
|
from src.exceptions import ConfigError
|
|
from src.logging_config import get_logger
|
|
from src.config_manager_atomic import (
|
|
AtomicConfigManager, SaveResult, SaveResultStatus,
|
|
BackupInfo, ValidationResult, atomic_write_json
|
|
)
|
|
from src.common.permission_utils import (
|
|
ensure_directory_permissions,
|
|
ensure_shared_group_ownership,
|
|
get_config_dir_mode
|
|
)
|
|
|
|
class ConfigManager:
|
|
"""
|
|
Reads and writes the main application configuration files.
|
|
|
|
Wraps :class:`~src.config_manager_atomic.AtomicConfigManager` for safe
|
|
atomic writes with automatic backup and rollback. Also exposes helpers
|
|
for plugin configuration persistence and secret-field masking.
|
|
"""
|
|
def __init__(self, config_path: Optional[str] = None, secrets_path: Optional[str] = None) -> None:
|
|
# Use current working directory as base
|
|
self.config_path: str = config_path or "config/config.json"
|
|
self.secrets_path: str = secrets_path or "config/config_secrets.json"
|
|
self.template_path: str = "config/config.template.json"
|
|
self.config: Dict[str, Any] = {}
|
|
# (mtime_ns, size) signature of (config, secrets, template) at the
|
|
# last successful load. load_config() skips the full re-read (3 file
|
|
# parses + recursive template migration) when nothing changed —
|
|
# ~30 web request handlers call it, some 2-3x per request. Cross-
|
|
# process freshness is preserved: another process's save bumps the
|
|
# mtime, so the next load here re-reads.
|
|
self._loaded_sig: Optional[tuple] = None
|
|
self.logger: logging.Logger = get_logger(__name__)
|
|
|
|
# Initialize atomic config manager
|
|
self._atomic_manager: Optional[AtomicConfigManager] = None
|
|
|
|
def get_config_path(self) -> str:
|
|
"""Return the path to the main config file (``config/config.json``)."""
|
|
return self.config_path
|
|
|
|
def get_secrets_path(self) -> str:
|
|
"""Return the path to the secrets file (``config/config_secrets.json``)."""
|
|
return self.secrets_path
|
|
|
|
def _get_atomic_manager(self) -> AtomicConfigManager:
|
|
"""Get or create atomic config manager instance."""
|
|
if self._atomic_manager is None:
|
|
self._atomic_manager = AtomicConfigManager(
|
|
config_path=self.config_path,
|
|
secrets_path=self.secrets_path
|
|
)
|
|
return self._atomic_manager
|
|
|
|
def save_config_atomic(
|
|
self,
|
|
new_config_data: Dict[str, Any],
|
|
create_backup: bool = True,
|
|
validate_after_write: bool = True
|
|
) -> SaveResult:
|
|
"""
|
|
Save configuration atomically with backup and rollback support.
|
|
|
|
This method provides atomic file operations to prevent corruption
|
|
and enables recovery from failed saves.
|
|
|
|
Args:
|
|
new_config_data: New configuration data to save
|
|
create_backup: Whether to create backup before saving (default: True)
|
|
validate_after_write: Whether to validate after writing (default: True)
|
|
|
|
Returns:
|
|
SaveResult with status and details
|
|
"""
|
|
# Load current secrets to preserve them (raises if unreadable — see
|
|
# _load_secrets_for_save)
|
|
secrets_content = self._load_secrets_for_save()
|
|
|
|
# Strip secrets from main config before saving
|
|
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
|
|
|
|
# The secrets file is only read here, never changed, so it is not
|
|
# handed over for rewriting.
|
|
atomic_mgr = self._get_atomic_manager()
|
|
result = atomic_mgr.save_config_atomic(
|
|
new_config=config_to_write,
|
|
new_secrets=None,
|
|
create_backup=create_backup,
|
|
validate_after_write=validate_after_write
|
|
)
|
|
|
|
# Update in-memory config if save was successful
|
|
if result.status == SaveResultStatus.SUCCESS:
|
|
self.config = new_config_data
|
|
# In-memory config now matches what was just written, so the
|
|
# load_config fast path may return it. It still carries the
|
|
# merged secrets that were stripped on disk; that matches a full
|
|
# reload, because the secrets file was not changed by the save.
|
|
self._loaded_sig = self._files_signature()
|
|
self.logger.info(f"Configuration successfully saved atomically to {os.path.abspath(self.config_path)}")
|
|
elif result.status == SaveResultStatus.ROLLED_BACK:
|
|
# Reload config from file after rollback
|
|
try:
|
|
self.load_config()
|
|
except Exception as e:
|
|
self.logger.error(f"Error reloading config after rollback: {e}")
|
|
|
|
return result
|
|
|
|
def rollback_config(self, backup_version: Optional[str] = None) -> bool:
|
|
"""
|
|
Rollback configuration to a previous backup.
|
|
|
|
Args:
|
|
backup_version: Specific backup version to restore (timestamp string).
|
|
If None, restores most recent backup.
|
|
|
|
Returns:
|
|
True if rollback successful, False otherwise
|
|
"""
|
|
atomic_mgr = self._get_atomic_manager()
|
|
success = atomic_mgr.rollback_config(backup_version)
|
|
|
|
if success:
|
|
# Reload config after rollback
|
|
try:
|
|
self.load_config()
|
|
except Exception as e:
|
|
self.logger.error(f"Error reloading config after rollback: {e}")
|
|
return False
|
|
|
|
return success
|
|
|
|
def list_backups(self) -> List[BackupInfo]:
|
|
"""
|
|
List all available configuration backups.
|
|
|
|
Returns:
|
|
List of BackupInfo objects, sorted by timestamp (newest first)
|
|
"""
|
|
atomic_mgr = self._get_atomic_manager()
|
|
return atomic_mgr.list_backups()
|
|
|
|
def validate_config_file(self, config_path: Optional[str] = None) -> ValidationResult:
|
|
"""
|
|
Validate a configuration file.
|
|
|
|
Args:
|
|
config_path: Path to config file. If None, validates current config_path.
|
|
|
|
Returns:
|
|
ValidationResult with validation status and errors
|
|
"""
|
|
atomic_mgr = self._get_atomic_manager()
|
|
return atomic_mgr.validate_config_file(config_path)
|
|
|
|
def _files_signature(self) -> tuple:
|
|
"""(mtime_ns, size) of config/secrets/template, None for missing —
|
|
cheap staleness probe (3 stats) for the load_config fast path."""
|
|
sig = []
|
|
for path in (self.config_path, self.secrets_path, self.template_path):
|
|
try:
|
|
st = os.stat(path)
|
|
sig.append((st.st_mtime_ns, st.st_size))
|
|
except OSError:
|
|
sig.append(None)
|
|
return tuple(sig)
|
|
|
|
def load_config(self) -> Dict[str, Any]:
|
|
"""Load configuration from JSON files.
|
|
|
|
Fast path: when config.json, config_secrets.json and the template
|
|
are all unchanged since the last successful load (mtime_ns + size),
|
|
the already-parsed self.config is returned without touching the
|
|
files — same aliasing semantics as the full path, which also
|
|
returns self.config.
|
|
"""
|
|
try:
|
|
current_sig = self._files_signature()
|
|
if self.config and self._loaded_sig == current_sig:
|
|
return self.config
|
|
|
|
# Check if config file exists, if not create from template
|
|
if not os.path.exists(self.config_path):
|
|
self._create_config_from_template()
|
|
|
|
# Load main config
|
|
self.logger.info(f"Attempting to load config from: {os.path.abspath(self.config_path)}")
|
|
with open(self.config_path, 'r') as f:
|
|
self.config = json.load(f)
|
|
|
|
# Migrate config to add any new items from template
|
|
self._migrate_config()
|
|
|
|
# Load and merge secrets if they exist (be permissive on errors)
|
|
if os.path.exists(self.secrets_path):
|
|
# Self-heal stale group ownership (e.g. the root-run display
|
|
# service wrote this file before the web user was granted
|
|
# group access) before every load attempt; no-op unless
|
|
# running as root and the group is already wrong.
|
|
ensure_shared_group_ownership(Path(self.secrets_path))
|
|
try:
|
|
with open(self.secrets_path, 'r') as f:
|
|
secrets = json.load(f)
|
|
# Deep merge secrets into config
|
|
self._deep_merge(self.config, secrets)
|
|
except PermissionError as e:
|
|
self.logger.warning(f"Secrets file not readable ({self.secrets_path}): {e}. Continuing without secrets.")
|
|
except (json.JSONDecodeError, OSError) as e:
|
|
self.logger.warning(f"Error reading secrets file ({self.secrets_path}): {e}. Continuing without secrets.")
|
|
|
|
# Signature taken AFTER load + migration (migration may write the
|
|
# config back), so it reflects exactly what was read/written.
|
|
self._loaded_sig = self._files_signature()
|
|
return self.config
|
|
|
|
except FileNotFoundError as e:
|
|
# Only config.json can get here: a missing or unreadable secrets
|
|
# file is handled where it is read.
|
|
error_msg = f"Configuration file not found at {os.path.abspath(self.config_path)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=self.config_path) from e
|
|
except json.JSONDecodeError as e:
|
|
error_msg = f"Error parsing configuration file {os.path.abspath(self.config_path)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=self.config_path) from e
|
|
except (IOError, OSError, PermissionError) as e:
|
|
error_msg = f"Error loading configuration from {os.path.abspath(self.config_path)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=self.config_path) from e
|
|
except Exception as e:
|
|
error_msg = f"Unexpected error loading configuration: {str(e)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=self.config_path) from e
|
|
|
|
@staticmethod
|
|
def _is_parallel_secrets_list(value: Any) -> bool:
|
|
"""True for the parallel-placeholder list shape emitted by
|
|
``secret_helpers.separate_secrets`` for array-item secrets: a
|
|
non-empty list whose elements are ALL dicts (``{}`` marks an item
|
|
with no secrets). Any other list-shaped secrets value is a
|
|
whole-key secret (e.g. a list of secret scalars)."""
|
|
return (isinstance(value, list) and bool(value)
|
|
and all(isinstance(item, dict) for item in value))
|
|
|
|
def _strip_secrets_recursive(self, data_to_filter: Dict[str, Any], secrets: Dict[str, Any]) -> Dict[str, Any]:
|
|
"""Recursively remove secret keys from a dictionary."""
|
|
result = {}
|
|
for key, value in data_to_filter.items():
|
|
if key not in secrets:
|
|
# This key is not in secrets, so we keep it
|
|
result[key] = value
|
|
continue
|
|
sec = secrets[key]
|
|
if isinstance(value, dict) and isinstance(sec, dict):
|
|
# This key is a shared group, recurse
|
|
stripped_sub_dict = self._strip_secrets_recursive(value, sec)
|
|
if stripped_sub_dict: # Only add if there's non-secret data left
|
|
result[key] = stripped_sub_dict
|
|
elif isinstance(value, list) and self._is_parallel_secrets_list(sec):
|
|
# Parallel-list shape from separate_secrets: sec[i] holds the
|
|
# secret fields of value[i] ({} = item i has none). Strip each
|
|
# item and ALWAYS keep the list — indices must survive so the
|
|
# merge-on-load can realign secrets with their items. The
|
|
# regular list's length is authoritative: extra secrets
|
|
# entries are ignored.
|
|
stripped_items = []
|
|
for i, item in enumerate(value):
|
|
s_item = sec[i] if i < len(sec) else {}
|
|
if isinstance(item, dict) and s_item:
|
|
stripped_items.append(self._strip_secrets_recursive(item, s_item))
|
|
else:
|
|
stripped_items.append(item)
|
|
result[key] = stripped_items
|
|
# Else: whole-key secret (scalar, list of secret scalars, or a
|
|
# shape mismatch) -> drop the key entirely. Never leak.
|
|
return result
|
|
|
|
def _load_secrets_for_save(self) -> Dict[str, Any]:
|
|
"""Load config_secrets.json for stripping before a save.
|
|
|
|
A missing secrets file is fine (nothing to strip). But a file that
|
|
EXISTS and cannot be read or parsed means stripping is impossible —
|
|
and the in-memory config being saved has secrets deep-merged into it,
|
|
so proceeding would write them into config.json in plaintext. The
|
|
save raises instead, so the caller (and user) fixes the secrets file
|
|
rather than leaking its contents into the world-readable main config.
|
|
"""
|
|
if not os.path.exists(self.secrets_path):
|
|
return {}
|
|
try:
|
|
with open(self.secrets_path, 'r') as f_secrets:
|
|
return json.load(f_secrets)
|
|
# Only the expected read/parse failures — an unexpected implementation
|
|
# error should propagate as itself, not masquerade as a secrets-file
|
|
# problem. (JSONDecodeError and UnicodeDecodeError are ValueErrors.)
|
|
except (OSError, ValueError, RecursionError) as e:
|
|
error_msg = (
|
|
f"Refusing to save config: secrets file {self.secrets_path} exists "
|
|
f"but could not be loaded ({e}). Saving without it would write "
|
|
f"merged secret values into config.json in plaintext. Fix or "
|
|
f"remove the secrets file, then retry."
|
|
)
|
|
self.logger.error("[Config] %s", error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=self.secrets_path) from e
|
|
|
|
def save_config(self, new_config_data: Dict[str, Any]) -> None:
|
|
"""Save configuration to the main JSON file, stripping out secrets.
|
|
|
|
Raises ConfigError when the secrets file exists but cannot be loaded,
|
|
because stripping would be impossible and secrets would leak into
|
|
config.json.
|
|
"""
|
|
secrets_content = self._load_secrets_for_save()
|
|
|
|
config_to_write = self._strip_secrets_recursive(new_config_data, secrets_content)
|
|
|
|
try:
|
|
atomic_write_json(self.config_path, config_to_write)
|
|
|
|
# Update the in-memory config to the new state (which includes secrets for runtime)
|
|
self.config = new_config_data
|
|
self._loaded_sig = self._files_signature()
|
|
self.logger.info(f"Configuration successfully saved to {os.path.abspath(self.config_path)}")
|
|
if secrets_content:
|
|
self.logger.info("Secret values were preserved in memory and not written to the main config file.")
|
|
|
|
except (IOError, OSError, PermissionError) as e:
|
|
error_msg = f"Error writing configuration to file {os.path.abspath(self.config_path)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=self.config_path) from e
|
|
except Exception as e:
|
|
error_msg = f"Unexpected error occurred while saving configuration: {str(e)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=self.config_path) from e
|
|
|
|
def get_secret(self, key: str) -> Optional[Any]:
|
|
"""Get a secret value by key."""
|
|
try:
|
|
if not os.path.exists(self.secrets_path):
|
|
return None
|
|
with open(self.secrets_path, 'r') as f:
|
|
secrets = json.load(f)
|
|
return secrets.get(key)
|
|
except (json.JSONDecodeError, IOError) as e:
|
|
self.logger.error(f"Error reading secrets file: {e}")
|
|
return None
|
|
|
|
def _deep_merge(self, target: Dict[str, Any], source: Dict[str, Any]) -> None:
|
|
"""Deep merge source dict into target dict.
|
|
|
|
Sole call site: merging config_secrets.json into the loaded config.
|
|
Understands the parallel-list shape separate_secrets emits for
|
|
array-item secrets (see _is_parallel_secrets_list): each secrets
|
|
list item is merged into the config list item at the same index
|
|
({} placeholders skipped). The config list's length is
|
|
authoritative — a user deleting an array item from config.json
|
|
must not have it resurrected from a stale secrets entry."""
|
|
for key, value in source.items():
|
|
if key in target and isinstance(target[key], dict) and isinstance(value, dict):
|
|
self._deep_merge(target[key], value)
|
|
elif (key in target and isinstance(target[key], list)
|
|
and self._is_parallel_secrets_list(value)):
|
|
tlist = target[key]
|
|
for i, s_item in enumerate(value):
|
|
if i >= len(tlist):
|
|
# Interpolate only config-side data here — nothing
|
|
# iterated out of the secrets dict (not even the key
|
|
# name) may reach the log.
|
|
self.logger.warning(
|
|
"A secrets list is longer than the config list it "
|
|
"parallels (config has %d item(s)); ignoring the "
|
|
"extra entries", len(tlist))
|
|
break
|
|
if not s_item:
|
|
continue # {} placeholder: item i has no secrets
|
|
if isinstance(tlist[i], dict):
|
|
self._deep_merge(tlist[i], s_item)
|
|
else:
|
|
tlist[i] = s_item # shape drift; the secret wins
|
|
else:
|
|
# Scalars AND whole-secret scalar arrays: replace (legacy).
|
|
target[key] = value
|
|
|
|
def _create_config_from_template(self) -> None:
|
|
"""Create config.json from template if it doesn't exist."""
|
|
if not os.path.exists(self.template_path):
|
|
error_msg = f"Template file not found at {os.path.abspath(self.template_path)}"
|
|
self.logger.error(error_msg)
|
|
raise ConfigError(error_msg, config_path=self.template_path)
|
|
|
|
self.logger.info(f"Creating config.json from template at {os.path.abspath(self.template_path)}")
|
|
|
|
# Ensure config directory exists with proper permissions
|
|
config_dir = Path(self.config_path).parent
|
|
ensure_directory_permissions(config_dir, get_config_dir_mode())
|
|
|
|
# Copy template to config
|
|
with open(self.template_path, 'r') as template_file:
|
|
template_data = json.load(template_file)
|
|
|
|
atomic_write_json(self.config_path, template_data)
|
|
|
|
self.logger.info(f"Created config.json from template at {os.path.abspath(self.config_path)}")
|
|
|
|
def _migrate_config(self) -> None:
|
|
"""Migrate config to add new items from template with defaults."""
|
|
if not os.path.exists(self.template_path):
|
|
self.logger.warning(f"Template file not found at {os.path.abspath(self.template_path)}, skipping migration")
|
|
return
|
|
|
|
try:
|
|
with open(self.template_path, 'r') as f:
|
|
template_config = json.load(f)
|
|
|
|
# Check if migration is needed
|
|
if self._config_needs_migration(self.config, template_config):
|
|
self.logger.info("Config migration needed - adding new configuration items with defaults")
|
|
|
|
# Create backup of current config
|
|
backup_path = f"{self.config_path}.backup"
|
|
with open(backup_path, 'w') as backup_file:
|
|
json.dump(self.config, backup_file, indent=4)
|
|
self.logger.info(f"Created backup of current config at {os.path.abspath(backup_path)}")
|
|
|
|
# Merge template defaults into current config
|
|
self._merge_template_defaults(self.config, template_config)
|
|
|
|
# save_config_atomic strips the merged secrets back out and
|
|
# keeps the file's owner and mode.
|
|
result = self.save_config_atomic(
|
|
new_config_data=self.config,
|
|
create_backup=False, # Already created backup above
|
|
validate_after_write=False # Skip validation for migration
|
|
)
|
|
|
|
if result.status.value == "success":
|
|
self.logger.info(f"Config migration completed and saved to {os.path.abspath(self.config_path)}")
|
|
else:
|
|
self.logger.warning(f"Config migration completed but save had issues: {result.message}")
|
|
else:
|
|
self.logger.debug("Config is up to date, no migration needed")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error during config migration: {e}")
|
|
# Don't raise - continue with current config
|
|
|
|
def _config_needs_migration(self, current_config: Dict[str, Any], template_config: Dict[str, Any]) -> bool:
|
|
"""Check if config needs migration by comparing with template."""
|
|
return self._has_new_keys(current_config, template_config)
|
|
|
|
def _has_new_keys(self, current: Dict[str, Any], template: Dict[str, Any]) -> bool:
|
|
"""Recursively check if template has keys not in current config."""
|
|
for key, value in template.items():
|
|
if key not in current:
|
|
return True
|
|
if isinstance(value, dict) and isinstance(current[key], dict):
|
|
if self._has_new_keys(current[key], value):
|
|
return True
|
|
return False
|
|
|
|
def _merge_template_defaults(self, current: Dict[str, Any], template: Dict[str, Any]) -> None:
|
|
"""Recursively merge template defaults into current config."""
|
|
for key, value in template.items():
|
|
if key not in current:
|
|
# Add new key with template value
|
|
current[key] = value
|
|
self.logger.debug(f"Added new config key: {key}")
|
|
elif isinstance(value, dict) and isinstance(current[key], dict):
|
|
# Recursively merge nested dictionaries
|
|
self._merge_template_defaults(current[key], value)
|
|
|
|
def get_timezone(self) -> str:
|
|
"""Get the configured timezone."""
|
|
return self.config.get('timezone', 'UTC')
|
|
|
|
def get_display_config(self) -> Dict[str, Any]:
|
|
"""Get display configuration."""
|
|
return self.config.get('display', {})
|
|
|
|
def get_config(self) -> Dict[str, Any]:
|
|
"""Get the full configuration dictionary.
|
|
|
|
Returns:
|
|
The complete configuration dictionary. If config hasn't been loaded yet,
|
|
it will be loaded first.
|
|
"""
|
|
if not self.config:
|
|
self.load_config()
|
|
return self.config
|
|
|
|
def get_raw_file_content(self, file_type: str) -> Dict[str, Any]:
|
|
"""Load raw content of 'main' config or 'secrets' config file."""
|
|
path_to_load = ""
|
|
if file_type == "main":
|
|
path_to_load = self.config_path
|
|
elif file_type == "secrets":
|
|
path_to_load = self.secrets_path
|
|
else:
|
|
raise ValueError("Invalid file_type specified. Must be 'main' or 'secrets'.")
|
|
|
|
if not os.path.exists(path_to_load):
|
|
# If a secrets file doesn't exist, it's not an error, just return empty
|
|
if file_type == "secrets":
|
|
return {}
|
|
error_msg = f"{file_type.capitalize()} configuration file not found at {os.path.abspath(path_to_load)}"
|
|
self.logger.error(error_msg)
|
|
raise ConfigError(error_msg, config_path=path_to_load)
|
|
|
|
if file_type == "secrets":
|
|
# Best-effort self-heal: no-op unless running as root and the
|
|
# group is stale (see load_config for why this can happen).
|
|
ensure_shared_group_ownership(Path(path_to_load))
|
|
|
|
try:
|
|
with open(path_to_load, 'r') as f:
|
|
return json.load(f)
|
|
except json.JSONDecodeError as e:
|
|
error_msg = f"Error parsing {file_type} configuration file: {path_to_load}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=path_to_load) from e
|
|
except PermissionError as e:
|
|
if file_type == "secrets":
|
|
# Match load_config()'s tolerance: a secrets file the web
|
|
# process can't read (e.g. written 0640 by the root-run
|
|
# display service before the group was fixed up) shouldn't
|
|
# 500 the settings page — degrade to "no secrets" instead.
|
|
self.logger.warning(f"Secrets file not readable ({path_to_load}): {e}. Returning empty secrets.")
|
|
return {}
|
|
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=path_to_load) from e
|
|
except (IOError, OSError) as e:
|
|
error_msg = f"Error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=path_to_load) from e
|
|
except Exception as e:
|
|
error_msg = f"Unexpected error loading {file_type} configuration file {path_to_load}: {str(e)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=path_to_load) from e
|
|
|
|
def save_raw_file_content(self, file_type: str, data: Dict[str, Any]) -> None:
|
|
"""Save data directly to 'main' config or 'secrets' config file."""
|
|
path_to_save = ""
|
|
if file_type == "main":
|
|
path_to_save = self.config_path
|
|
elif file_type == "secrets":
|
|
path_to_save = self.secrets_path
|
|
else:
|
|
raise ValueError("Invalid file_type specified. Must be 'main' or 'secrets'.")
|
|
|
|
try:
|
|
# Create directory if it doesn't exist, especially for config/
|
|
path_obj = Path(path_to_save)
|
|
ensure_directory_permissions(path_obj.parent, get_config_dir_mode())
|
|
|
|
# A rename, not an in-place write, so this works even when the
|
|
# existing file isn't writable (as long as the directory is).
|
|
atomic_write_json(path_obj, data)
|
|
|
|
self.logger.info(f"{file_type.capitalize()} configuration successfully saved to {os.path.abspath(path_to_save)}")
|
|
|
|
# The merged self.config is now stale; reload it. A reload failure
|
|
# (a migration error, say) is logged, not raised: the file itself
|
|
# was saved.
|
|
try:
|
|
self.load_config()
|
|
except Exception as reload_error:
|
|
self.logger.warning(
|
|
f"Configuration file saved successfully, but reload failed: {reload_error}. "
|
|
f"The file on disk is valid, but in-memory config may be stale."
|
|
)
|
|
|
|
except PermissionError as e:
|
|
# Provide helpful error message with fix instructions
|
|
import stat
|
|
try:
|
|
import pwd
|
|
if path_obj.exists():
|
|
file_stat = path_obj.stat()
|
|
current_mode = stat.filemode(file_stat.st_mode)
|
|
try:
|
|
file_owner = pwd.getpwuid(file_stat.st_uid).pw_name
|
|
except (ImportError, KeyError):
|
|
file_owner = f"UID {file_stat.st_uid}"
|
|
error_msg = (
|
|
f"Cannot write to {file_type} configuration file {os.path.abspath(path_to_save)}. "
|
|
f"File is owned by {file_owner} with permissions {current_mode}. "
|
|
f"To fix, run: sudo chown $USER:$(id -gn) {path_to_save} && sudo chmod 664 {path_to_save}"
|
|
)
|
|
else:
|
|
# File doesn't exist - check directory permissions
|
|
dir_stat = path_obj.parent.stat()
|
|
dir_mode = stat.filemode(dir_stat.st_mode)
|
|
try:
|
|
dir_owner = pwd.getpwuid(dir_stat.st_uid).pw_name
|
|
except (ImportError, KeyError):
|
|
dir_owner = f"UID {dir_stat.st_uid}"
|
|
error_msg = (
|
|
f"Cannot create {file_type} configuration file {os.path.abspath(path_to_save)}. "
|
|
f"Directory is owned by {dir_owner} with permissions {dir_mode}. "
|
|
f"To fix, run: sudo chown $USER:$(id -gn) {path_obj.parent} && sudo chmod 775 {path_obj.parent}"
|
|
)
|
|
except Exception:
|
|
# Fallback to generic message if we can't get file info
|
|
error_msg = f"Error writing {file_type} configuration to file {os.path.abspath(path_to_save)}: {str(e)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=path_to_save) from e
|
|
except (IOError, OSError) as e:
|
|
error_msg = f"Error writing {file_type} configuration to file {os.path.abspath(path_to_save)}: {str(e)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=path_to_save) from e
|
|
except Exception as e:
|
|
error_msg = f"Unexpected error occurred while saving {file_type} configuration: {str(e)}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=path_to_save) from e
|
|
|
|
def cleanup_plugin_config(self, plugin_id: str, remove_secrets: bool = True) -> None:
|
|
"""
|
|
Remove plugin configuration from both main config and secrets config.
|
|
|
|
Args:
|
|
plugin_id: Plugin identifier to remove
|
|
remove_secrets: If True, also remove plugin secrets
|
|
"""
|
|
try:
|
|
# Load current configs
|
|
main_config = self.get_raw_file_content('main')
|
|
secrets_config = self.get_raw_file_content('secrets') # {} when there is no file
|
|
|
|
# Remove plugin from main config
|
|
if plugin_id in main_config:
|
|
del main_config[plugin_id]
|
|
self.save_raw_file_content('main', main_config)
|
|
self.logger.info(f"Removed plugin {plugin_id} from main configuration")
|
|
|
|
# Remove plugin from secrets config if requested
|
|
if remove_secrets and plugin_id in secrets_config:
|
|
del secrets_config[plugin_id]
|
|
self.save_raw_file_content('secrets', secrets_config)
|
|
self.logger.info(f"Removed plugin {plugin_id} from secrets configuration")
|
|
|
|
except Exception as e:
|
|
error_msg = f"Error cleaning up plugin config for {plugin_id}"
|
|
self.logger.error(error_msg, exc_info=True)
|
|
raise ConfigError(error_msg, config_path=self.config_path, field=plugin_id) from e
|
|
|
|
def cleanup_orphaned_plugin_configs(self, valid_plugin_ids: List[str]) -> List[str]:
|
|
"""
|
|
Remove configuration sections for plugins that are no longer installed.
|
|
|
|
Args:
|
|
valid_plugin_ids: List of currently installed plugin IDs
|
|
|
|
Returns:
|
|
List of plugin IDs that were removed
|
|
"""
|
|
removed = []
|
|
try:
|
|
# Load current configs
|
|
main_config = self.get_raw_file_content('main')
|
|
secrets_config = self.get_raw_file_content('secrets') # {} when there is no file
|
|
|
|
valid_set = set(valid_plugin_ids)
|
|
|
|
# Find orphaned plugins in main config. Core sections (display,
|
|
# schedule, auto_update, ...) are not plugins and never orphans.
|
|
main_plugins = set(main_config.keys()) - CORE_CONFIG_KEYS
|
|
orphaned_main = main_plugins - valid_set
|
|
|
|
# Find orphaned plugins in secrets config
|
|
secrets_plugins = set(secrets_config.keys()) - CORE_CONFIG_KEYS - CORE_SECRETS_KEYS
|
|
orphaned_secrets = secrets_plugins - valid_set
|
|
|
|
all_orphaned = orphaned_main | orphaned_secrets
|
|
|
|
if all_orphaned:
|
|
# Remove from main config
|
|
for plugin_id in orphaned_main:
|
|
del main_config[plugin_id]
|
|
removed.append(plugin_id)
|
|
|
|
# Remove from secrets config
|
|
for plugin_id in orphaned_secrets:
|
|
del secrets_config[plugin_id]
|
|
|
|
# Save updated configs
|
|
if orphaned_main:
|
|
self.save_raw_file_content('main', main_config)
|
|
if orphaned_secrets:
|
|
self.save_raw_file_content('secrets', secrets_config)
|
|
|
|
self.logger.info(f"Cleaned up orphaned plugin configs: {', '.join(all_orphaned)}")
|
|
|
|
return removed
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error cleaning up orphaned plugin configs: {e}")
|
|
return removed
|
|
|
|
def validate_all_plugin_configs(self, plugin_schema_manager=None) -> Dict[str, Dict[str, Any]]:
|
|
"""
|
|
Validate all plugin configurations against their schemas.
|
|
|
|
Args:
|
|
plugin_schema_manager: Optional SchemaManager instance for validation
|
|
|
|
Returns:
|
|
Dict mapping plugin_id to validation results: {
|
|
'valid': bool,
|
|
'errors': list of error messages
|
|
}
|
|
"""
|
|
results = {}
|
|
|
|
if not plugin_schema_manager:
|
|
return results
|
|
|
|
try:
|
|
main_config = self.get_raw_file_content('main')
|
|
|
|
for plugin_id, plugin_config in main_config.items():
|
|
if not isinstance(plugin_config, dict):
|
|
continue
|
|
|
|
# Skip core config sections
|
|
if plugin_id in CORE_CONFIG_KEYS:
|
|
continue
|
|
|
|
schema = plugin_schema_manager.load_schema(plugin_id, use_cache=True)
|
|
if schema:
|
|
is_valid, errors = plugin_schema_manager.validate_config_against_schema(
|
|
plugin_config, schema, plugin_id
|
|
)
|
|
results[plugin_id] = {
|
|
'valid': is_valid,
|
|
'errors': errors
|
|
}
|
|
else:
|
|
results[plugin_id] = {
|
|
'valid': True, # No schema = can't validate, but not an error
|
|
'errors': []
|
|
}
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error validating plugin configs: {e}")
|
|
|
|
return results |