mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 22:35:08 +00:00
Stage 2 of the web plugin catalog, after #688. - The display publishes a plugin runtime snapshot (plugin_runtime.py) to the shared cache: per plugin loaded, lifecycle state, a short redacted error summary, the version it loaded and when, plus published_at / stale_after / running. Written on change (throttled to 10 s; the RUNNING/ENABLED flip of an ordinary update is not a change) and once a minute otherwise; cleanup() publishes running: false. - The web reads it back and restores loaded / state / error_info in /api/v3/plugins/installed (plus loaded_version, loaded_at and data.runtime). Only a live snapshot counts; stale, stopped or missing answers null and says which. - data/plugin_state.json is retired: every reader and writer moved to config + disk (desired) or the snapshot (observed). Nothing in it was non-derivable, so nothing is migrated and an existing file is left unread. The web-side PluginStateManager (state_manager.py) is removed; the display's plugin_state.PluginStateManager is the only state machine. - StateReconciliation compares config + disk with the snapshot, reporting enabled-but-not-loaded and older-version-loaded as no_action findings. - Backups list installed manifests with enabled from config.json. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
291 lines
12 KiB
Python
291 lines
12 KiB
Python
"""Plugin operation status and history, and plugin state reconciliation.
|
|
|
|
Routes decorate the shared `api_v3` Blueprint from the package `__init__`,
|
|
so their endpoint names do not depend on which module they live in.
|
|
"""
|
|
from web_interface.blueprints.api_v3 import (
|
|
ErrorCode, Path, Response, _coerce_to_bool, api_v3, error_response,
|
|
exception_error_response, json, jsonify, logger, os, request, stat,
|
|
success_response, tempfile,
|
|
)
|
|
import web_interface.blueprints.api_v3 as _pkg
|
|
|
|
|
|
@api_v3.route('/plugins/operation/<operation_id>', methods=['GET'])
|
|
def get_operation_status(operation_id):
|
|
"""Get status of a plugin operation"""
|
|
try:
|
|
if not api_v3.operation_queue:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Operation queue not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
operation = api_v3.operation_queue.get_operation_status(operation_id)
|
|
if not operation:
|
|
return error_response(
|
|
ErrorCode.PLUGIN_NOT_FOUND,
|
|
f'Operation {operation_id} not found',
|
|
status_code=404
|
|
)
|
|
|
|
return success_response(data=operation.to_dict())
|
|
except Exception as e:
|
|
return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
|
|
|
|
|
|
@api_v3.route('/plugins/operation/history', methods=['GET'])
|
|
def get_operation_history() -> Response:
|
|
"""Get operation history from the audit log."""
|
|
if not api_v3.operation_history:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Operation history not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
try:
|
|
limit = request.args.get('limit', 50, type=int)
|
|
plugin_id = request.args.get('plugin_id')
|
|
operation_type = request.args.get('operation_type')
|
|
except (ValueError, TypeError) as e:
|
|
return error_response(ErrorCode.INVALID_INPUT, f'Invalid query parameter: {e}', status_code=400)
|
|
|
|
try:
|
|
history = api_v3.operation_history.get_history(
|
|
limit=limit,
|
|
plugin_id=plugin_id,
|
|
operation_type=operation_type
|
|
)
|
|
except (AttributeError, RuntimeError) as e:
|
|
return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
|
|
|
|
return success_response(data=[record.to_dict() for record in history])
|
|
|
|
|
|
@api_v3.route('/plugins/operation/history', methods=['DELETE'])
|
|
def clear_operation_history() -> Response:
|
|
"""Clear operation history."""
|
|
if not api_v3.operation_history:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Operation history not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
try:
|
|
api_v3.operation_history.clear_history()
|
|
except (OSError, RuntimeError) as e:
|
|
return exception_error_response(e, ErrorCode.SYSTEM_ERROR, with_context=False)
|
|
|
|
return success_response(message='Operation history cleared')
|
|
|
|
|
|
def _reconciler(store_manager=None):
|
|
"""A StateReconciliation over config + disk (desired) and the display's
|
|
runtime snapshot (observed); None when the catalog is not set up."""
|
|
if not api_v3.plugin_catalog or not api_v3.config_manager:
|
|
return None
|
|
from src.plugin_system.state_reconciliation import StateReconciliation
|
|
return StateReconciliation(
|
|
config_manager=api_v3.config_manager,
|
|
plugins_dir=Path(api_v3.plugin_catalog.plugins_dir),
|
|
store_manager=store_manager,
|
|
runtime_source=_pkg._plugin_runtime_view,
|
|
)
|
|
|
|
|
|
def _operation_times(plugin_ids):
|
|
"""``installed_at`` / ``last_updated`` per plugin from the operation
|
|
history: the newest successful install, and the newest successful
|
|
install or update. None where the history has no record (it keeps the
|
|
last 1000 operations, and can be cleared)."""
|
|
times = {pid: {'installed_at': None, 'last_updated': None} for pid in plugin_ids}
|
|
history = getattr(api_v3, 'operation_history', None)
|
|
if not history or not times:
|
|
return times
|
|
try:
|
|
records = history.get_history(limit=100000)
|
|
except Exception:
|
|
logger.debug("[PluginState] Could not read the operation history", exc_info=True)
|
|
return times
|
|
for record in records: # newest first
|
|
entry = times.get(record.plugin_id)
|
|
if entry is None or record.status != 'success':
|
|
continue
|
|
when = record.timestamp.isoformat()
|
|
if record.operation_type == 'install' and entry['installed_at'] is None:
|
|
entry['installed_at'] = when
|
|
if record.operation_type in ('install', 'update') and entry['last_updated'] is None:
|
|
entry['last_updated'] = when
|
|
return times
|
|
|
|
|
|
def _plugin_state_status(record):
|
|
"""The old plugin_state.json ``status`` vocabulary, derived."""
|
|
if record.get('state') == 'error':
|
|
return 'error'
|
|
if not record.get('installed'):
|
|
return 'unknown'
|
|
return 'enabled' if record.get('enabled') else 'disabled'
|
|
|
|
|
|
@api_v3.route('/plugins/state', methods=['GET'])
|
|
def get_plugin_state():
|
|
"""Every plugin's state: desired (config + disk) and observed (the
|
|
display's runtime snapshot).
|
|
|
|
Built on each request -- there is no state file any more
|
|
(data/plugin_state.json is retired). ``loaded``, ``state``,
|
|
``error_info``, ``loaded_version`` and ``loaded_at`` are null unless the
|
|
display's snapshot is live; ``runtime`` beside ``data`` says whether it is.
|
|
"""
|
|
try:
|
|
reconciler = _reconciler()
|
|
if reconciler is None:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Plugin catalog not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
states = reconciler.plugin_states()
|
|
times = _operation_times(states.keys())
|
|
for plugin_id, record in states.items():
|
|
record['status'] = _plugin_state_status(record)
|
|
record.update(times.get(plugin_id, {}))
|
|
runtime = _pkg._plugin_runtime_view().describe()
|
|
|
|
plugin_id = request.args.get('plugin_id')
|
|
if plugin_id:
|
|
state = states.get(plugin_id)
|
|
if not state:
|
|
return error_response(
|
|
ErrorCode.PLUGIN_NOT_FOUND,
|
|
f'Plugin {plugin_id} is neither installed nor configured',
|
|
context={'plugin_id': plugin_id},
|
|
status_code=404
|
|
)
|
|
return success_response(data=state, extra={'runtime': runtime})
|
|
return success_response(data=states, extra={'runtime': runtime})
|
|
except Exception as e:
|
|
return exception_error_response(e, ErrorCode.SYSTEM_ERROR)
|
|
|
|
|
|
@api_v3.route('/plugins/state/reconcile', methods=['POST'])
|
|
def reconcile_plugin_state():
|
|
"""Reconcile desired state (config + disk) with what is installed and
|
|
what the display reports running."""
|
|
try:
|
|
# Parse optional `force` flag from request body, guarding against
|
|
# non-dict bodies (bare string, array, null) that would raise AttributeError.
|
|
payload = request.get_json(silent=True)
|
|
if not isinstance(payload, dict):
|
|
payload = {}
|
|
force = _coerce_to_bool(payload.get('force', False))
|
|
|
|
reconciler = _reconciler()
|
|
if reconciler is None:
|
|
return error_response(
|
|
ErrorCode.SYSTEM_ERROR,
|
|
'Config manager or plugin catalog not initialized',
|
|
status_code=500
|
|
)
|
|
|
|
result = reconciler.reconcile_state(force=force)
|
|
|
|
return success_response(
|
|
data={
|
|
'inconsistencies_found': len(result.inconsistencies_found),
|
|
'inconsistencies_fixed': len(result.inconsistencies_fixed),
|
|
'inconsistencies_manual': len(result.inconsistencies_manual),
|
|
'inconsistencies': [
|
|
{
|
|
'plugin_id': inc.plugin_id,
|
|
'type': inc.inconsistency_type.value,
|
|
'description': inc.description,
|
|
'fix_action': inc.fix_action.value
|
|
}
|
|
for inc in result.inconsistencies_found
|
|
],
|
|
'fixed': [
|
|
{
|
|
'plugin_id': inc.plugin_id,
|
|
'type': inc.inconsistency_type.value,
|
|
'description': inc.description
|
|
}
|
|
for inc in result.inconsistencies_fixed
|
|
],
|
|
'manual_fix_required': [
|
|
{
|
|
'plugin_id': inc.plugin_id,
|
|
'type': inc.inconsistency_type.value,
|
|
'description': inc.description
|
|
}
|
|
for inc in result.inconsistencies_manual
|
|
]
|
|
},
|
|
message=result.message
|
|
)
|
|
except Exception as e:
|
|
return exception_error_response(e, ErrorCode.SYSTEM_ERROR)
|
|
|
|
|
|
def _drop_stale_reconciliation_findings(unresolved):
|
|
"""Re-check a stored reconciliation verdict against current state.
|
|
|
|
The verdict is a snapshot written once per run, and a run that could not
|
|
apply a fix also refuses to retry -- so a resolved condition was reported
|
|
indefinitely. Best-effort: any failure here returns the list untouched,
|
|
because showing a stale warning beats failing the endpoint.
|
|
|
|
Both sets come from the reconciliation module's own extractors rather than
|
|
being re-derived here. That matters for correctness, not tidiness: a plain
|
|
set(load_config()) also contains system keys, the secrets-file keys merged
|
|
in by load_config(), and non-dict values, and any directory holding a
|
|
manifest.json would count as installed even if that manifest does not
|
|
parse. Either looseness clears findings that are still true -- and a secrets
|
|
key read as a plugin is the very bug the filter exists to stop reporting.
|
|
"""
|
|
try:
|
|
from src.plugin_system.state_reconciliation import (
|
|
config_plugin_ids, disk_plugin_ids, ignored_config_keys,
|
|
still_unresolved,
|
|
)
|
|
|
|
cm = api_v3.config_manager
|
|
plugins_dir = getattr(api_v3.plugin_catalog, 'plugins_dir', None)
|
|
installed = disk_plugin_ids(plugins_dir) if plugins_dir else set()
|
|
config_keys = config_plugin_ids(cm.load_config() or {},
|
|
ignored_config_keys(cm, installed))
|
|
return still_unresolved(unresolved, config_keys, installed)
|
|
except Exception:
|
|
logger.debug("[Reconciliation] Could not re-check stored findings", exc_info=True)
|
|
return unresolved
|
|
|
|
|
|
@api_v3.route('/plugins/reconciliation-status', methods=['GET'])
|
|
def get_reconciliation_status():
|
|
"""Return the result of the last startup reconciliation from /tmp status file."""
|
|
_recon_path = os.path.join(tempfile.gettempdir(), "ledmatrix_reconciliation.json")
|
|
try:
|
|
st = os.lstat(_recon_path)
|
|
except FileNotFoundError:
|
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
|
if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode):
|
|
logger.warning("[Reconciliation] Status file is not a regular file: %s", _recon_path)
|
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
|
try:
|
|
with open(_recon_path) as _f:
|
|
data = json.load(_f)
|
|
if data.get('unresolved'):
|
|
data['unresolved'] = _drop_stale_reconciliation_findings(data['unresolved'])
|
|
return jsonify({'status': 'success', 'data': data})
|
|
except json.JSONDecodeError:
|
|
logger.exception("[Reconciliation] Failed to parse status file: %s", _recon_path)
|
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|
|
except PermissionError:
|
|
logger.exception("[Reconciliation] Permission denied reading status file: %s", _recon_path)
|
|
return jsonify({'status': 'success', 'data': {'done': False, 'unresolved': []}})
|