fix: auto-repair missing plugins and graceful config fallback

Plugins whose directories are missing (failed update, migration, etc.)
now get automatically reinstalled from the store on startup. The config
endpoint no longer returns a hard 500 when a schema is unavailable —
it falls back to conservative key-name-based masking so the settings
page stays functional.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ChuckBuilds
2026-03-25 14:49:36 -04:00
parent 81a022dbe8
commit 2619c0d893
3 changed files with 115 additions and 29 deletions

View File

@@ -67,21 +67,24 @@ class StateReconciliation:
state_manager: PluginStateManager, state_manager: PluginStateManager,
config_manager, config_manager,
plugin_manager, plugin_manager,
plugins_dir: Path plugins_dir: Path,
store_manager=None
): ):
""" """
Initialize reconciliation system. Initialize reconciliation system.
Args: Args:
state_manager: PluginStateManager instance state_manager: PluginStateManager instance
config_manager: ConfigManager instance config_manager: ConfigManager instance
plugin_manager: PluginManager instance plugin_manager: PluginManager instance
plugins_dir: Path to plugins directory plugins_dir: Path to plugins directory
store_manager: Optional PluginStoreManager for auto-repair
""" """
self.state_manager = state_manager self.state_manager = state_manager
self.config_manager = config_manager self.config_manager = config_manager
self.plugin_manager = plugin_manager self.plugin_manager = plugin_manager
self.plugins_dir = Path(plugins_dir) self.plugins_dir = Path(plugins_dir)
self.store_manager = store_manager
self.logger = get_logger(__name__) self.logger = get_logger(__name__)
def reconcile_state(self) -> ReconciliationResult: def reconcile_state(self) -> ReconciliationResult:
@@ -160,18 +163,34 @@ class StateReconciliation:
message=f"Reconciliation failed: {str(e)}" message=f"Reconciliation failed: {str(e)}"
) )
# Top-level config keys that are NOT plugins
_SYSTEM_CONFIG_KEYS = frozenset({
'web_display_autostart', 'timezone', 'location', 'display',
'plugin_system', 'vegas_scroll_speed', 'vegas_separator_width',
'vegas_target_fps', 'vegas_buffer_ahead', 'vegas_plugin_order',
'vegas_excluded_plugins', 'vegas_scroll_enabled', 'logging',
'dim_schedule', 'network', 'system', 'schedule',
})
def _get_config_state(self) -> Dict[str, Dict[str, Any]]: def _get_config_state(self) -> Dict[str, Dict[str, Any]]:
"""Get plugin state from config file.""" """Get plugin state from config file."""
state = {} state = {}
try: try:
config = self.config_manager.load_config() config = self.config_manager.load_config()
for plugin_id, plugin_config in config.items(): for plugin_id, plugin_config in config.items():
if isinstance(plugin_config, dict): if not isinstance(plugin_config, dict):
state[plugin_id] = { continue
'enabled': plugin_config.get('enabled', False), if plugin_id in self._SYSTEM_CONFIG_KEYS:
'version': plugin_config.get('version'), continue
'exists_in_config': True if 'enabled' not in plugin_config:
} continue
if '.standalone-backup-' in plugin_id:
continue
state[plugin_id] = {
'enabled': plugin_config.get('enabled', False),
'version': plugin_config.get('version'),
'exists_in_config': True
}
except Exception as e: except Exception as e:
self.logger.warning(f"Error reading config state: {e}") self.logger.warning(f"Error reading config state: {e}")
return state return state
@@ -263,14 +282,15 @@ class StateReconciliation:
# Check: Plugin in config but not on disk # Check: Plugin in config but not on disk
if config.get('exists_in_config') and not disk.get('exists_on_disk'): if config.get('exists_in_config') and not disk.get('exists_on_disk'):
can_repair = self.store_manager is not None
inconsistencies.append(Inconsistency( inconsistencies.append(Inconsistency(
plugin_id=plugin_id, plugin_id=plugin_id,
inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK, inconsistency_type=InconsistencyType.PLUGIN_MISSING_ON_DISK,
description=f"Plugin {plugin_id} in config but not on disk", description=f"Plugin {plugin_id} in config but not on disk",
fix_action=FixAction.MANUAL_FIX_REQUIRED, fix_action=FixAction.AUTO_FIX if can_repair else FixAction.MANUAL_FIX_REQUIRED,
current_state={'exists_on_disk': False}, current_state={'exists_on_disk': False},
expected_state={'exists_on_disk': True}, expected_state={'exists_on_disk': True},
can_auto_fix=False can_auto_fix=can_repair
)) ))
# Check: Enabled state mismatch # Check: Enabled state mismatch
@@ -303,6 +323,9 @@ class StateReconciliation:
self.logger.info(f"Fixed: Added {inconsistency.plugin_id} to config") self.logger.info(f"Fixed: Added {inconsistency.plugin_id} to config")
return True return True
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_MISSING_ON_DISK:
return self._auto_repair_missing_plugin(inconsistency.plugin_id)
elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH: elif inconsistency.inconsistency_type == InconsistencyType.PLUGIN_ENABLED_MISMATCH:
# Sync enabled state from state manager to config # Sync enabled state from state manager to config
expected_enabled = inconsistency.expected_state.get('enabled') expected_enabled = inconsistency.expected_state.get('enabled')
@@ -317,6 +340,34 @@ class StateReconciliation:
except Exception as e: except Exception as e:
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True) self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
return False return False
return False
def _auto_repair_missing_plugin(self, plugin_id: str) -> bool:
"""Attempt to reinstall a missing plugin from the store."""
if not self.store_manager:
return False
# Try the plugin_id as-is, then without 'ledmatrix-' prefix
candidates = [plugin_id]
if plugin_id.startswith('ledmatrix-'):
candidates.append(plugin_id[len('ledmatrix-'):])
for candidate_id in candidates:
try:
self.logger.info("[AutoRepair] Attempting to reinstall missing plugin: %s", candidate_id)
result = self.store_manager.install_plugin(candidate_id)
if isinstance(result, dict):
success = result.get('success', False)
else:
success = bool(result)
if success:
self.logger.info("[AutoRepair] Successfully reinstalled plugin: %s (config key: %s)", candidate_id, plugin_id)
return True
except Exception as e:
self.logger.error("[AutoRepair] Error reinstalling %s: %s", candidate_id, e, exc_info=True)
self.logger.warning("[AutoRepair] Could not reinstall %s from store", plugin_id)
return False return False

View File

@@ -651,12 +651,40 @@ def _initialize_health_monitor():
_health_monitor_initialized = True _health_monitor_initialized = True
# Initialize health monitor on first request (using before_request for compatibility) _reconciliation_done = False
def _run_startup_reconciliation():
"""Run state reconciliation on startup to auto-repair missing plugins."""
global _reconciliation_done
if _reconciliation_done:
return
_reconciliation_done = True
try:
from src.plugin_system.state_reconciliation import StateReconciliation
reconciler = StateReconciliation(
state_manager=plugin_state_manager,
config_manager=config_manager,
plugin_manager=plugin_manager,
plugins_dir=plugins_dir,
store_manager=plugin_store_manager
)
result = reconciler.reconcile_state()
if result.inconsistencies_found:
print(f"[Reconciliation] {result.message}")
if result.inconsistencies_fixed:
plugin_manager.discover_plugins()
except Exception as e:
print(f"[Reconciliation] Error: {e}")
# Initialize health monitor and run reconciliation on first request
@app.before_request @app.before_request
def check_health_monitor(): def check_health_monitor():
"""Ensure health monitor is initialized on first request.""" """Ensure health monitor and reconciliation run on first request."""
if not _health_monitor_initialized: if not _health_monitor_initialized:
_initialize_health_monitor() _initialize_health_monitor()
if not _reconciliation_done:
_run_startup_reconciliation()
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=True) app.run(host='0.0.0.0', port=5000, debug=True)

View File

@@ -33,6 +33,23 @@ from src.web_interface.secret_helpers import (
separate_secrets, separate_secrets,
) )
import re
_SECRET_KEY_PATTERN = re.compile(
r'(api_key|api_secret|password|secret|token|auth_key|credential)',
re.IGNORECASE,
)
def _conservative_mask_config(config):
"""Mask string values whose keys look like secrets (no schema available)."""
result = dict(config)
for key, value in result.items():
if isinstance(value, dict):
result[key] = _conservative_mask_config(value)
elif isinstance(value, str) and value and _SECRET_KEY_PATTERN.search(key):
result[key] = ''
return result
# Will be initialized when blueprint is registered # Will be initialized when blueprint is registered
config_manager = None config_manager = None
plugin_manager = None plugin_manager = None
@@ -2505,24 +2522,14 @@ def get_plugin_config():
} }
# Mask secret fields before returning to prevent exposing API keys # Mask secret fields before returning to prevent exposing API keys
# Fail closed — if schema unavailable, refuse to return unmasked config
schema_mgr = api_v3.schema_manager schema_mgr = api_v3.schema_manager
if not schema_mgr: schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True) if schema_mgr else None
return error_response(
ErrorCode.CONFIG_LOAD_FAILED,
f"Cannot safely return config for {plugin_id}: schema manager unavailable",
status_code=500
)
schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True) if schema_for_mask and 'properties' in schema_for_mask:
if not schema_for_mask or 'properties' not in schema_for_mask: plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties'])
return error_response( else:
ErrorCode.CONFIG_LOAD_FAILED, logger.warning("[PluginConfig] Schema unavailable for %s, applying conservative masking", plugin_id)
f"Cannot safely return config for {plugin_id}: schema unavailable for secret masking", plugin_config = _conservative_mask_config(plugin_config)
status_code=500
)
plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties'])
return success_response(data=plugin_config) return success_response(data=plugin_config)
except Exception as e: except Exception as e: