mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-04-10 21:03:01 +00:00
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:
@@ -67,21 +67,24 @@ class StateReconciliation:
|
||||
state_manager: PluginStateManager,
|
||||
config_manager,
|
||||
plugin_manager,
|
||||
plugins_dir: Path
|
||||
plugins_dir: Path,
|
||||
store_manager=None
|
||||
):
|
||||
"""
|
||||
Initialize reconciliation system.
|
||||
|
||||
|
||||
Args:
|
||||
state_manager: PluginStateManager instance
|
||||
config_manager: ConfigManager instance
|
||||
plugin_manager: PluginManager instance
|
||||
plugins_dir: Path to plugins directory
|
||||
store_manager: Optional PluginStoreManager for auto-repair
|
||||
"""
|
||||
self.state_manager = state_manager
|
||||
self.config_manager = config_manager
|
||||
self.plugin_manager = plugin_manager
|
||||
self.plugins_dir = Path(plugins_dir)
|
||||
self.store_manager = store_manager
|
||||
self.logger = get_logger(__name__)
|
||||
|
||||
def reconcile_state(self) -> ReconciliationResult:
|
||||
@@ -160,18 +163,34 @@ class StateReconciliation:
|
||||
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]]:
|
||||
"""Get plugin state from config file."""
|
||||
state = {}
|
||||
try:
|
||||
config = self.config_manager.load_config()
|
||||
for plugin_id, plugin_config in config.items():
|
||||
if isinstance(plugin_config, dict):
|
||||
state[plugin_id] = {
|
||||
'enabled': plugin_config.get('enabled', False),
|
||||
'version': plugin_config.get('version'),
|
||||
'exists_in_config': True
|
||||
}
|
||||
if not isinstance(plugin_config, dict):
|
||||
continue
|
||||
if plugin_id in self._SYSTEM_CONFIG_KEYS:
|
||||
continue
|
||||
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:
|
||||
self.logger.warning(f"Error reading config state: {e}")
|
||||
return state
|
||||
@@ -263,14 +282,15 @@ class StateReconciliation:
|
||||
|
||||
# Check: Plugin in config but not 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(
|
||||
plugin_id=plugin_id,
|
||||
inconsistency_type=InconsistencyType.PLUGIN_MISSING_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},
|
||||
expected_state={'exists_on_disk': True},
|
||||
can_auto_fix=False
|
||||
can_auto_fix=can_repair
|
||||
))
|
||||
|
||||
# Check: Enabled state mismatch
|
||||
@@ -303,6 +323,9 @@ class StateReconciliation:
|
||||
self.logger.info(f"Fixed: Added {inconsistency.plugin_id} to config")
|
||||
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:
|
||||
# Sync enabled state from state manager to config
|
||||
expected_enabled = inconsistency.expected_state.get('enabled')
|
||||
@@ -317,6 +340,34 @@ class StateReconciliation:
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error fixing inconsistency: {e}", exc_info=True)
|
||||
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
|
||||
|
||||
|
||||
@@ -651,12 +651,40 @@ def _initialize_health_monitor():
|
||||
|
||||
_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
|
||||
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:
|
||||
_initialize_health_monitor()
|
||||
if not _reconciliation_done:
|
||||
_run_startup_reconciliation()
|
||||
|
||||
if __name__ == '__main__':
|
||||
app.run(host='0.0.0.0', port=5000, debug=True)
|
||||
|
||||
@@ -33,6 +33,23 @@ from src.web_interface.secret_helpers import (
|
||||
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
|
||||
config_manager = None
|
||||
plugin_manager = None
|
||||
@@ -2505,24 +2522,14 @@ def get_plugin_config():
|
||||
}
|
||||
|
||||
# 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
|
||||
if not schema_mgr:
|
||||
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_mgr else None
|
||||
|
||||
schema_for_mask = schema_mgr.load_schema(plugin_id, use_cache=True)
|
||||
if not schema_for_mask or 'properties' not in schema_for_mask:
|
||||
return error_response(
|
||||
ErrorCode.CONFIG_LOAD_FAILED,
|
||||
f"Cannot safely return config for {plugin_id}: schema unavailable for secret masking",
|
||||
status_code=500
|
||||
)
|
||||
|
||||
plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties'])
|
||||
if schema_for_mask and 'properties' in schema_for_mask:
|
||||
plugin_config = mask_secret_fields(plugin_config, schema_for_mask['properties'])
|
||||
else:
|
||||
logger.warning("[PluginConfig] Schema unavailable for %s, applying conservative masking", plugin_id)
|
||||
plugin_config = _conservative_mask_config(plugin_config)
|
||||
|
||||
return success_response(data=plugin_config)
|
||||
except Exception as e:
|
||||
|
||||
Reference in New Issue
Block a user