Files
LEDMatrix/src/config_manager.py
T
ChuckandClaude Opus 5.5 7b90759252 fix: /errors stack traces, Wi-Fi disconnect and save, plugin fonts, API cache TTL (#636)
* 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>
2026-09-24 17:32:29 -04:00

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