mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
refactor(plugins): the display publishes plugin runtime state; retire plugin_state.json (#690)
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>
This commit is contained in:
@@ -9,7 +9,6 @@ from pathlib import Path
|
||||
|
||||
from src.plugin_system.operation_queue import PluginOperationQueue
|
||||
from src.plugin_system.operation_types import OperationType
|
||||
from src.plugin_system.state_manager import PluginStateManager
|
||||
from src.plugin_system.operation_history import OperationHistory
|
||||
|
||||
|
||||
@@ -22,12 +21,10 @@ class TestPluginOperationsIntegration(unittest.TestCase):
|
||||
|
||||
# Initialize components
|
||||
self.operation_queue = PluginOperationQueue(max_history=100)
|
||||
|
||||
self.state_manager = PluginStateManager(
|
||||
state_file=str(self.temp_dir / "state.json"),
|
||||
auto_save=True
|
||||
)
|
||||
|
||||
|
||||
# No state manager: installed / enabled / version are read from disk
|
||||
# and config.json, not from a plugin_state.json record.
|
||||
|
||||
self.operation_history = OperationHistory(
|
||||
history_file=str(self.temp_dir / "history.json"),
|
||||
max_records=100
|
||||
@@ -64,22 +61,15 @@ class TestPluginOperationsIntegration(unittest.TestCase):
|
||||
operation_id=operation_id
|
||||
)
|
||||
self.assertIsNotNone(history_id)
|
||||
|
||||
# Update state manager
|
||||
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
|
||||
|
||||
# Verify state
|
||||
state = self.state_manager.get_plugin_state(plugin_id)
|
||||
self.assertIsNotNone(state)
|
||||
self.assertEqual(state.version, "1.0.0")
|
||||
|
||||
|
||||
# Verify history
|
||||
history = self.operation_history.get_history(plugin_id=plugin_id)
|
||||
self.assertEqual([r.operation_type for r in history], ["install"])
|
||||
|
||||
def test_update_operation_flow(self):
|
||||
"""Test complete update operation flow."""
|
||||
plugin_id = "test-plugin"
|
||||
|
||||
# First, mark as installed
|
||||
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
|
||||
|
||||
# Enqueue update operation
|
||||
operation_id = self.operation_queue.enqueue_operation(
|
||||
OperationType.UPDATE,
|
||||
@@ -97,20 +87,14 @@ class TestPluginOperationsIntegration(unittest.TestCase):
|
||||
operation_id=operation_id
|
||||
)
|
||||
|
||||
# Update state
|
||||
self.state_manager.update_plugin_state(plugin_id, {"version": "2.0.0"})
|
||||
|
||||
# Verify state
|
||||
state = self.state_manager.get_plugin_state(plugin_id)
|
||||
self.assertEqual(state.version, "2.0.0")
|
||||
|
||||
# Verify history
|
||||
history = self.operation_history.get_history(plugin_id=plugin_id)
|
||||
self.assertEqual([r.operation_type for r in history], ["update"])
|
||||
|
||||
def test_uninstall_operation_flow(self):
|
||||
"""Test complete uninstall operation flow."""
|
||||
plugin_id = "test-plugin"
|
||||
|
||||
# First, mark as installed
|
||||
self.state_manager.set_plugin_installed(plugin_id, "1.0.0")
|
||||
|
||||
# Enqueue uninstall operation
|
||||
operation_id = self.operation_queue.enqueue_operation(
|
||||
OperationType.UNINSTALL,
|
||||
@@ -127,13 +111,10 @@ class TestPluginOperationsIntegration(unittest.TestCase):
|
||||
operation_id=operation_id
|
||||
)
|
||||
|
||||
# Update state - remove plugin state
|
||||
self.state_manager.remove_plugin_state(plugin_id)
|
||||
|
||||
# Verify state
|
||||
state = self.state_manager.get_plugin_state(plugin_id)
|
||||
self.assertIsNone(state)
|
||||
|
||||
# Verify history
|
||||
history = self.operation_history.get_history(plugin_id=plugin_id)
|
||||
self.assertEqual([r.operation_type for r in history], ["uninstall"])
|
||||
|
||||
def test_operation_history_tracking(self):
|
||||
"""Test that operations are tracked in history."""
|
||||
plugin_id = "test-plugin"
|
||||
|
||||
@@ -30,7 +30,7 @@ from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
_MANAGER_ATTRS = (
|
||||
'config_manager', 'plugin_catalog', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
)
|
||||
_SENTINEL = object()
|
||||
|
||||
@@ -33,7 +33,7 @@ URL = "/api/v3/backup/restore"
|
||||
|
||||
_MANAGER_ATTRS = (
|
||||
'config_manager', 'plugin_catalog', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
)
|
||||
_SENTINEL = object()
|
||||
|
||||
@@ -44,7 +44,7 @@ def env(tmp_path):
|
||||
|
||||
_SENTINEL = object()
|
||||
attrs = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager',
|
||||
'saved_repositories_manager',
|
||||
'schema_manager', 'operation_queue', 'operation_history',
|
||||
'cache_manager')
|
||||
originals = {name: getattr(api_v3, name, _SENTINEL) for name in attrs}
|
||||
|
||||
@@ -86,7 +86,6 @@ def env(tmp_path):
|
||||
api_v3.plugin_store_manager = MagicMock()
|
||||
api_v3.saved_repositories_manager = MagicMock()
|
||||
api_v3.operation_queue = MagicMock()
|
||||
api_v3.plugin_state_manager = MagicMock()
|
||||
api_v3.operation_history = MagicMock()
|
||||
api_v3.cache_manager = MagicMock()
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ EXPECTED = {
|
||||
|
||||
MANAGERS = ("config_manager", "plugin_catalog", "plugin_store_manager",
|
||||
"saved_repositories_manager", "schema_manager", "operation_queue",
|
||||
"plugin_state_manager", "operation_history", "cache_manager")
|
||||
"operation_history", "cache_manager")
|
||||
|
||||
|
||||
class Boom:
|
||||
|
||||
@@ -73,7 +73,7 @@ STORED = {
|
||||
}
|
||||
|
||||
_ATTRS = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager')
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""data/plugin_state.json is retired; /plugins/state and reconciliation read
|
||||
config + disk (desired) and the display's runtime snapshot (observed).
|
||||
|
||||
The file held, per plugin, an enabled flag (a copy of config.json's), a
|
||||
version (a copy of the manifest's, when anything set it at all), a status
|
||||
derived from those two, and install/update timestamps that only
|
||||
GET /api/v3/plugins/state ever returned -- the operation history records the
|
||||
same events. Nothing in it was needed that cannot be derived, so it is not
|
||||
migrated: nothing reads or writes it any more, and an existing file is left
|
||||
in place, unread. These tests hold that line and check the replacements.
|
||||
"""
|
||||
import ast
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.plugin_system.operation_history import OperationRecord
|
||||
from src.plugin_system.plugin_runtime import SNAPSHOT_SCHEMA, view_from_snapshot
|
||||
from src.plugin_system.state_reconciliation import StateReconciliation
|
||||
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _code_strings(path):
|
||||
"""String constants in a module, docstrings excluded."""
|
||||
tree = ast.parse(path.read_text(encoding="utf-8"))
|
||||
docstrings = {id(node.value) for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)}
|
||||
return [node.value for node in ast.walk(tree)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, str)
|
||||
and id(node) not in docstrings]
|
||||
|
||||
|
||||
def test_no_code_reads_or_writes_the_state_file():
|
||||
offenders = []
|
||||
for root in ("src", "web_interface", "scripts"):
|
||||
for path in (REPO / root).rglob("*.py"):
|
||||
if any("plugin_state.json" in s for s in _code_strings(path)):
|
||||
offenders.append(str(path.relative_to(REPO)))
|
||||
assert offenders == []
|
||||
|
||||
|
||||
def test_the_web_side_state_manager_is_gone():
|
||||
import importlib.util
|
||||
assert importlib.util.find_spec("src.plugin_system.state_manager") is None
|
||||
from web_interface.blueprints.api_v3 import api_v3
|
||||
import web_interface.app as app_module
|
||||
assert not hasattr(app_module, "plugin_state_manager")
|
||||
assert getattr(api_v3, "plugin_state_manager", None) is None
|
||||
|
||||
|
||||
def _install(plugins_dir, plugin_id, version):
|
||||
d = plugins_dir / plugin_id
|
||||
d.mkdir(parents=True)
|
||||
(d / "manifest.json").write_text(json.dumps({"id": plugin_id, "version": version}),
|
||||
encoding="utf-8")
|
||||
|
||||
|
||||
def _live(plugins):
|
||||
now = time.time()
|
||||
return view_from_snapshot({"schema": SNAPSHOT_SCHEMA, "running": True,
|
||||
"published_at": now, "plugins": plugins}, now=now)
|
||||
|
||||
|
||||
def test_an_existing_state_file_changes_nothing(tmp_path):
|
||||
"""A device upgraded with a plugin_state.json that disagrees with config
|
||||
and disk: reconciliation answers exactly as on a device without one."""
|
||||
plugins_dir = tmp_path / "plugin-repos"
|
||||
_install(plugins_dir, "clock", "1.0.0")
|
||||
config = MagicMock()
|
||||
config.load_config.return_value = {"clock": {"enabled": True}}
|
||||
config.get_secrets_path.return_value = str(tmp_path / "none.json")
|
||||
|
||||
def run():
|
||||
r = StateReconciliation(config_manager=config, plugins_dir=plugins_dir)
|
||||
result = r.reconcile_state()
|
||||
return ([i.inconsistency_type for i in result.inconsistencies_found],
|
||||
r.plugin_states())
|
||||
|
||||
without = run()
|
||||
(tmp_path / "data").mkdir()
|
||||
(tmp_path / "data" / "plugin_state.json").write_text(json.dumps({
|
||||
"version": 1, "states": {
|
||||
"clock": {"plugin_id": "clock", "status": "disabled", "enabled": False,
|
||||
"version": "0.0.1"},
|
||||
"gone": {"plugin_id": "gone", "status": "installed", "enabled": True}}}),
|
||||
encoding="utf-8")
|
||||
|
||||
assert run() == without
|
||||
assert without[1]["clock"]["enabled"] is True
|
||||
assert "gone" not in without[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state_api(api_v3_module, api_v3_client, tmp_path, monkeypatch): # noqa: F811
|
||||
api = api_v3_module.api_v3
|
||||
plugins_dir = tmp_path / "plugin-repos"
|
||||
_install(plugins_dir, "clock", "1.1.0")
|
||||
_install(plugins_dir, "weather", "2.0.0")
|
||||
api.plugin_catalog.plugins_dir = str(plugins_dir)
|
||||
api.config_manager.load_config = MagicMock(return_value={
|
||||
"clock": {"enabled": True}, "weather": {"enabled": False},
|
||||
"ghost": {"enabled": True}})
|
||||
api.config_manager.get_secrets_path = MagicMock(return_value=str(tmp_path / "s.json"))
|
||||
api.operation_history.get_history = MagicMock(return_value=[
|
||||
OperationRecord("3", "update", "clock", datetime(2026, 9, 3), "success"),
|
||||
OperationRecord("2", "update", "clock", datetime(2026, 9, 2), "failed"),
|
||||
OperationRecord("1", "install", "clock", datetime(2026, 9, 1), "success"),
|
||||
])
|
||||
observed = {"view": _live({"clock": {"loaded": True, "state": "enabled", "error": None,
|
||||
"version": "1.0.0", "loaded_at": 50.0}})}
|
||||
import web_interface.blueprints.api_v3 as pkg
|
||||
monkeypatch.setattr(pkg, "_plugin_runtime_view", lambda: observed["view"])
|
||||
return api_v3_client, observed
|
||||
|
||||
|
||||
class TestPluginStateRoute:
|
||||
def test_every_plugin_desired_and_observed(self, state_api):
|
||||
client, _ = state_api
|
||||
body = client.get("/api/v3/plugins/state").get_json()
|
||||
|
||||
assert body["status"] == "success"
|
||||
assert body["runtime"]["status"] == "live"
|
||||
data = body["data"]
|
||||
assert set(data) == {"clock", "weather", "ghost"}
|
||||
clock = data["clock"]
|
||||
assert clock["status"] == "enabled"
|
||||
assert (clock["installed"], clock["enabled"], clock["version"]) == (True, True, "1.1.0")
|
||||
assert (clock["loaded"], clock["loaded_version"]) == (True, "1.0.0")
|
||||
assert clock["installed_at"] == "2026-09-01T00:00:00"
|
||||
assert clock["last_updated"] == "2026-09-03T00:00:00"
|
||||
assert data["weather"]["status"] == "disabled"
|
||||
assert data["weather"]["loaded"] is False
|
||||
assert data["weather"]["installed_at"] is None
|
||||
assert data["ghost"]["status"] == "unknown" # configured, not installed
|
||||
|
||||
def test_one_plugin(self, state_api):
|
||||
client, _ = state_api
|
||||
body = client.get("/api/v3/plugins/state?plugin_id=clock").get_json()
|
||||
assert body["data"]["plugin_id"] == "clock"
|
||||
response = client.get("/api/v3/plugins/state?plugin_id=nope")
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_display_not_running_leaves_observed_fields_null(self, state_api):
|
||||
client, observed = state_api
|
||||
observed["view"] = view_from_snapshot({"schema": SNAPSHOT_SCHEMA, "running": False,
|
||||
"published_at": time.time()})
|
||||
body = client.get("/api/v3/plugins/state").get_json()
|
||||
assert body["runtime"]["status"] == "stopped"
|
||||
assert body["data"]["clock"]["loaded"] is None
|
||||
assert body["data"]["clock"]["enabled"] is True
|
||||
|
||||
def test_reconcile_reports_what_the_display_runs(self, state_api):
|
||||
client, _ = state_api
|
||||
body = client.post("/api/v3/plugins/state/reconcile", json={}).get_json()
|
||||
found = {(i["plugin_id"], i["type"], i["fix_action"])
|
||||
for i in body["data"]["inconsistencies"]}
|
||||
assert ("clock", "plugin_version_mismatch", "no_action") in found
|
||||
assert ("ghost", "plugin_missing_on_disk", "manual_fix_required") in found
|
||||
# Reported only; nothing the user must fix beyond the missing plugin.
|
||||
assert [i["plugin_id"] for i in body["data"]["manual_fix_required"]] == ["ghost"]
|
||||
@@ -1,338 +1,352 @@
|
||||
"""
|
||||
Tests for state reconciliation system.
|
||||
|
||||
Desired state is config.json plus the plugins on disk; observed state is
|
||||
the runtime snapshot the display publishes (plugin_runtime). There is no
|
||||
third, persisted record: data/plugin_state.json is retired.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
import tempfile
|
||||
import shutil
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from src.plugin_system.plugin_runtime import (
|
||||
PluginRuntimeView, SNAPSHOT_SCHEMA, view_from_snapshot,
|
||||
)
|
||||
from src.plugin_system.state_reconciliation import (
|
||||
StateReconciliation,
|
||||
InconsistencyType,
|
||||
FixAction,
|
||||
ReconciliationResult
|
||||
)
|
||||
from src.plugin_system.state_manager import PluginStateManager, PluginStateStatus
|
||||
|
||||
|
||||
def live_view(plugins):
|
||||
"""A live runtime view, as read from a fresh snapshot of a running display."""
|
||||
now = time.time()
|
||||
return view_from_snapshot({
|
||||
"schema": SNAPSHOT_SCHEMA, "running": True, "published_at": now,
|
||||
"stale_after": 180, "plugins": plugins,
|
||||
}, now=now)
|
||||
|
||||
|
||||
def stale_view(plugins):
|
||||
"""The same snapshot, read long after the display stopped refreshing it."""
|
||||
now = time.time()
|
||||
return view_from_snapshot({
|
||||
"schema": SNAPSHOT_SCHEMA, "running": True, "published_at": now - 3600,
|
||||
"stale_after": 180, "plugins": plugins,
|
||||
}, now=now)
|
||||
|
||||
|
||||
class TestStateReconciliation(unittest.TestCase):
|
||||
"""Test state reconciliation system."""
|
||||
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.temp_dir = Path(tempfile.mkdtemp())
|
||||
self.plugins_dir = self.temp_dir / "plugins"
|
||||
self.plugins_dir.mkdir()
|
||||
|
||||
# Create mock managers
|
||||
self.state_manager = Mock(spec=PluginStateManager)
|
||||
|
||||
self.config_manager = Mock()
|
||||
self.plugin_manager = Mock()
|
||||
|
||||
# What the display reports; tests replace it.
|
||||
self.observed = PluginRuntimeView(status="unknown")
|
||||
|
||||
# Initialize reconciliation system
|
||||
self.reconciler = StateReconciliation(
|
||||
state_manager=self.state_manager,
|
||||
config_manager=self.config_manager,
|
||||
plugin_manager=self.plugin_manager,
|
||||
plugins_dir=self.plugins_dir
|
||||
plugins_dir=self.plugins_dir,
|
||||
runtime_source=lambda: self.observed,
|
||||
)
|
||||
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test fixtures."""
|
||||
shutil.rmtree(self.temp_dir)
|
||||
|
||||
|
||||
def _install(self, plugin_id, version="1.0.0"):
|
||||
plugin_dir = self.plugins_dir / plugin_id
|
||||
plugin_dir.mkdir()
|
||||
with open(plugin_dir / "manifest.json", 'w') as f:
|
||||
json.dump({"id": plugin_id, "version": version, "name": plugin_id}, f)
|
||||
|
||||
def test_reconcile_no_inconsistencies(self):
|
||||
"""Test reconciliation with no inconsistencies."""
|
||||
# Setup: All states are consistent
|
||||
"""Config, disk and the display agree."""
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True}
|
||||
}
|
||||
|
||||
self.state_manager.get_all_states.return_value = {
|
||||
"plugin1": Mock(
|
||||
enabled=True,
|
||||
status=PluginStateStatus.ENABLED,
|
||||
version="1.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
self.plugin_manager.plugin_manifests = {"plugin1": {}}
|
||||
self.plugin_manager.plugins = {"plugin1": Mock()}
|
||||
|
||||
# Create plugin directory
|
||||
plugin_dir = self.plugins_dir / "plugin1"
|
||||
plugin_dir.mkdir()
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
||||
|
||||
# Run reconciliation
|
||||
self._install("plugin1")
|
||||
self.observed = live_view({"plugin1": {
|
||||
"loaded": True, "state": "enabled", "error": None,
|
||||
"version": "1.0.0", "loaded_at": time.time()}})
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# Verify
|
||||
|
||||
self.assertIsInstance(result, ReconciliationResult)
|
||||
self.assertEqual(len(result.inconsistencies_found), 0)
|
||||
self.assertTrue(result.reconciliation_successful)
|
||||
|
||||
|
||||
def test_plugin_missing_in_config(self):
|
||||
"""Test detection of plugin missing in config."""
|
||||
# Setup: Plugin exists on disk but not in config
|
||||
self.config_manager.load_config.return_value = {}
|
||||
|
||||
self.state_manager.get_all_states.return_value = {}
|
||||
|
||||
self.plugin_manager.plugin_manifests = {}
|
||||
self.plugin_manager.plugins = {}
|
||||
|
||||
# Create plugin directory
|
||||
plugin_dir = self.plugins_dir / "plugin1"
|
||||
plugin_dir.mkdir()
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
||||
|
||||
# Run reconciliation
|
||||
self._install("plugin1")
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# Verify inconsistency detected
|
||||
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
inconsistency = result.inconsistencies_found[0]
|
||||
self.assertEqual(inconsistency.plugin_id, "plugin1")
|
||||
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_IN_CONFIG)
|
||||
self.assertTrue(inconsistency.can_auto_fix)
|
||||
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
|
||||
|
||||
|
||||
def test_plugin_missing_on_disk(self):
|
||||
"""Test detection of plugin missing on disk."""
|
||||
# Setup: Plugin in config but not on disk
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True}
|
||||
}
|
||||
|
||||
self.state_manager.get_all_states.return_value = {}
|
||||
|
||||
self.plugin_manager.plugin_manifests = {}
|
||||
self.plugin_manager.plugins = {}
|
||||
|
||||
# Don't create plugin directory
|
||||
|
||||
# Run reconciliation
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# Verify inconsistency detected
|
||||
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
inconsistency = result.inconsistencies_found[0]
|
||||
self.assertEqual(inconsistency.plugin_id, "plugin1")
|
||||
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_MISSING_ON_DISK)
|
||||
self.assertFalse(inconsistency.can_auto_fix)
|
||||
self.assertEqual(inconsistency.fix_action, FixAction.MANUAL_FIX_REQUIRED)
|
||||
|
||||
def test_enabled_state_mismatch(self):
|
||||
"""Test detection of enabled state mismatch."""
|
||||
# Setup: Config says enabled=True, state manager says enabled=False
|
||||
|
||||
def test_enabled_but_not_loaded_is_reported_not_fixed(self):
|
||||
"""Enabled in config, installed, but the display reports a failed load:
|
||||
a finding that names the error, left alone (the display loads by
|
||||
config on its own) and not counted as needing manual attention."""
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True}
|
||||
}
|
||||
|
||||
self.state_manager.get_all_states.return_value = {
|
||||
"plugin1": Mock(
|
||||
enabled=False,
|
||||
status=PluginStateStatus.DISABLED,
|
||||
version="1.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
self.plugin_manager.plugin_manifests = {"plugin1": {}}
|
||||
self.plugin_manager.plugins = {}
|
||||
|
||||
# Create plugin directory
|
||||
plugin_dir = self.plugins_dir / "plugin1"
|
||||
plugin_dir.mkdir()
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
||||
|
||||
# Run reconciliation
|
||||
self._install("plugin1")
|
||||
self.observed = live_view({"plugin1": {
|
||||
"loaded": False, "state": "error",
|
||||
"error": {"type": "ImportError", "message": "No module named 'x'",
|
||||
"at": time.time(), "recoverable": False},
|
||||
"version": None, "loaded_at": None}})
|
||||
self.config_manager.save_config = Mock()
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# Verify inconsistency detected
|
||||
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
inconsistency = result.inconsistencies_found[0]
|
||||
self.assertEqual(inconsistency.plugin_id, "plugin1")
|
||||
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_ENABLED_MISMATCH)
|
||||
self.assertTrue(inconsistency.can_auto_fix)
|
||||
self.assertEqual(inconsistency.fix_action, FixAction.AUTO_FIX)
|
||||
|
||||
self.assertEqual(inconsistency.fix_action, FixAction.NO_ACTION)
|
||||
self.assertFalse(inconsistency.can_auto_fix)
|
||||
self.assertIn("No module named", inconsistency.description)
|
||||
self.assertEqual(result.inconsistencies_fixed, [])
|
||||
self.assertEqual(result.inconsistencies_manual, [])
|
||||
self.assertTrue(result.reconciliation_successful)
|
||||
self.config_manager.save_config.assert_not_called()
|
||||
|
||||
def test_enabled_and_absent_from_live_snapshot_is_not_loaded(self):
|
||||
"""A plugin a live snapshot does not list is not loaded."""
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True}
|
||||
}
|
||||
self._install("plugin1")
|
||||
self.observed = live_view({})
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
types = [i.inconsistency_type for i in result.inconsistencies_found]
|
||||
self.assertEqual(types, [InconsistencyType.PLUGIN_ENABLED_MISMATCH])
|
||||
|
||||
def test_disabled_and_not_loaded_agrees(self):
|
||||
"""Missing "enabled" is disabled (the display's rule), and a plugin
|
||||
the display has not loaded matches it."""
|
||||
self.config_manager.load_config.return_value = {"plugin1": {}}
|
||||
self._install("plugin1")
|
||||
self.observed = live_view({})
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
self.assertEqual(result.inconsistencies_found, [])
|
||||
|
||||
def test_loaded_at_an_older_version_is_reported(self):
|
||||
"""The display runs 1.0.0 while 2.0.0 is on disk: restart needed."""
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True}
|
||||
}
|
||||
self._install("plugin1", version="2.0.0")
|
||||
self.observed = live_view({"plugin1": {
|
||||
"loaded": True, "state": "enabled", "error": None,
|
||||
"version": "1.0.0", "loaded_at": time.time()}})
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
inconsistency = result.inconsistencies_found[0]
|
||||
self.assertEqual(inconsistency.inconsistency_type, InconsistencyType.PLUGIN_VERSION_MISMATCH)
|
||||
self.assertEqual(inconsistency.fix_action, FixAction.NO_ACTION)
|
||||
self.assertIn("restart", inconsistency.description)
|
||||
self.assertTrue(result.reconciliation_successful)
|
||||
|
||||
def test_stale_or_missing_snapshot_is_not_compared(self):
|
||||
"""Observed state that is not live says nothing: no findings from it."""
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True}
|
||||
}
|
||||
self._install("plugin1")
|
||||
dead = {"plugin1": {"loaded": False, "state": "error", "error": None,
|
||||
"version": None, "loaded_at": None}}
|
||||
|
||||
for observed in (stale_view(dead), PluginRuntimeView(status="stopped"),
|
||||
PluginRuntimeView(status="unknown")):
|
||||
self.observed = observed
|
||||
result = self.reconciler.reconcile_state()
|
||||
self.assertEqual(result.inconsistencies_found, [], observed.status)
|
||||
|
||||
def test_no_runtime_source_is_unknown(self):
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True}
|
||||
}
|
||||
self._install("plugin1")
|
||||
reconciler = StateReconciliation(config_manager=self.config_manager,
|
||||
plugins_dir=self.plugins_dir)
|
||||
self.assertEqual(reconciler.reconcile_state().inconsistencies_found, [])
|
||||
|
||||
def test_runtime_source_that_raises_is_unknown(self):
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True}
|
||||
}
|
||||
self._install("plugin1")
|
||||
|
||||
def boom():
|
||||
raise RuntimeError("cache unreadable")
|
||||
|
||||
reconciler = StateReconciliation(config_manager=self.config_manager,
|
||||
plugins_dir=self.plugins_dir,
|
||||
runtime_source=boom)
|
||||
result = reconciler.reconcile_state()
|
||||
self.assertEqual(result.inconsistencies_found, [])
|
||||
self.assertTrue(result.reconciliation_successful)
|
||||
|
||||
def test_old_constructor_arguments_are_refused(self):
|
||||
"""state_manager / plugin_manager are gone; passing them is an error,
|
||||
not something silently ignored."""
|
||||
with self.assertRaises(TypeError):
|
||||
StateReconciliation(state_manager=Mock(), config_manager=self.config_manager,
|
||||
plugin_manager=Mock(), plugins_dir=self.plugins_dir)
|
||||
|
||||
def test_plugin_states_combines_desired_and_observed(self):
|
||||
"""What /plugins/state serves in place of plugin_state.json."""
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True},
|
||||
"plugin2": {"enabled": False},
|
||||
"ghost": {"enabled": True},
|
||||
}
|
||||
self._install("plugin1", version="1.2.0")
|
||||
self._install("plugin2")
|
||||
self._install("plugin3")
|
||||
self.observed = live_view({"plugin1": {
|
||||
"loaded": True, "state": "enabled", "error": None,
|
||||
"version": "1.2.0", "loaded_at": 1234.0}})
|
||||
|
||||
states = self.reconciler.plugin_states()
|
||||
|
||||
self.assertEqual(set(states), {"plugin1", "plugin2", "plugin3", "ghost"})
|
||||
self.assertEqual(states["plugin1"], {
|
||||
"plugin_id": "plugin1", "installed": True, "version": "1.2.0",
|
||||
"in_config": True, "enabled": True, "loaded": True,
|
||||
"state": "enabled", "error_info": None,
|
||||
"loaded_version": "1.2.0", "loaded_at": 1234.0,
|
||||
})
|
||||
self.assertFalse(states["plugin2"]["enabled"])
|
||||
self.assertIs(states["plugin2"]["loaded"], False)
|
||||
self.assertEqual(states["plugin2"]["state"], "unloaded")
|
||||
self.assertFalse(states["plugin3"]["in_config"])
|
||||
self.assertFalse(states["ghost"]["installed"])
|
||||
|
||||
def test_plugin_states_without_a_live_snapshot_reports_unknown(self):
|
||||
self.config_manager.load_config.return_value = {"plugin1": {"enabled": True}}
|
||||
self._install("plugin1")
|
||||
self.observed = stale_view({"plugin1": {"loaded": True, "state": "enabled"}})
|
||||
|
||||
record = self.reconciler.plugin_states()["plugin1"]
|
||||
|
||||
self.assertTrue(record["enabled"])
|
||||
self.assertIsNone(record["loaded"])
|
||||
self.assertIsNone(record["state"])
|
||||
self.assertIsNone(record["error_info"])
|
||||
|
||||
def test_auto_fix_plugin_missing_in_config(self):
|
||||
"""Test auto-fix of plugin missing in config."""
|
||||
# Setup
|
||||
self.config_manager.load_config.return_value = {}
|
||||
|
||||
self.state_manager.get_all_states.return_value = {}
|
||||
|
||||
self.plugin_manager.plugin_manifests = {}
|
||||
self.plugin_manager.plugins = {}
|
||||
|
||||
# Create plugin directory
|
||||
plugin_dir = self.plugins_dir / "plugin1"
|
||||
plugin_dir.mkdir()
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
||||
|
||||
# Mock save_config to track calls
|
||||
self._install("plugin1")
|
||||
|
||||
saved_configs = []
|
||||
|
||||
def save_config(config):
|
||||
saved_configs.append(config)
|
||||
|
||||
|
||||
self.config_manager.save_config = save_config
|
||||
|
||||
# Run reconciliation
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# Verify fix was attempted
|
||||
|
||||
self.assertEqual(len(result.inconsistencies_fixed), 1)
|
||||
self.assertEqual(len(saved_configs), 1)
|
||||
self.assertIn("plugin1", saved_configs[0])
|
||||
self.assertEqual(saved_configs[0]["plugin1"]["enabled"], False)
|
||||
|
||||
def test_auto_fix_enabled_state_mismatch(self):
|
||||
"""Test auto-fix of enabled state mismatch."""
|
||||
# Setup: Config says enabled=True, state manager says enabled=False
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True}
|
||||
}
|
||||
|
||||
self.state_manager.get_all_states.return_value = {
|
||||
"plugin1": Mock(
|
||||
enabled=False,
|
||||
status=PluginStateStatus.DISABLED,
|
||||
version="1.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
self.plugin_manager.plugin_manifests = {"plugin1": {}}
|
||||
self.plugin_manager.plugins = {}
|
||||
|
||||
# Create plugin directory
|
||||
plugin_dir = self.plugins_dir / "plugin1"
|
||||
plugin_dir.mkdir()
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
||||
|
||||
# Run reconciliation
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# config.json is the source of truth for enabled state. The fix syncs
|
||||
# the state manager to match config (config says True → state set True),
|
||||
# rather than overwriting the config with the stale state value.
|
||||
self.assertEqual(len(result.inconsistencies_fixed), 1)
|
||||
self.state_manager.set_plugin_enabled.assert_called_once_with("plugin1", True)
|
||||
|
||||
def test_multiple_inconsistencies(self):
|
||||
"""Test reconciliation with multiple inconsistencies."""
|
||||
# Setup: Multiple plugins with different issues
|
||||
self.config_manager.load_config.return_value = {
|
||||
"plugin1": {"enabled": True}, # Exists in config but not on disk
|
||||
# plugin2 exists on disk but not in config
|
||||
}
|
||||
|
||||
self.state_manager.get_all_states.return_value = {
|
||||
"plugin1": Mock(
|
||||
enabled=True,
|
||||
status=PluginStateStatus.ENABLED,
|
||||
version="1.0.0"
|
||||
)
|
||||
}
|
||||
|
||||
self.plugin_manager.plugin_manifests = {}
|
||||
self.plugin_manager.plugins = {}
|
||||
|
||||
# Create plugin2 directory (exists on disk but not in config)
|
||||
plugin2_dir = self.plugins_dir / "plugin2"
|
||||
plugin2_dir.mkdir()
|
||||
manifest_path = plugin2_dir / "manifest.json"
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump({"version": "1.0.0", "name": "Plugin 2"}, f)
|
||||
|
||||
# Run reconciliation
|
||||
self._install("plugin2")
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# Verify multiple inconsistencies found
|
||||
|
||||
self.assertGreaterEqual(len(result.inconsistencies_found), 2)
|
||||
|
||||
# Check types
|
||||
inconsistency_types = [inc.inconsistency_type for inc in result.inconsistencies_found]
|
||||
self.assertIn(InconsistencyType.PLUGIN_MISSING_ON_DISK, inconsistency_types)
|
||||
self.assertIn(InconsistencyType.PLUGIN_MISSING_IN_CONFIG, inconsistency_types)
|
||||
|
||||
|
||||
def test_reconciliation_with_exception(self):
|
||||
"""Test reconciliation handles exceptions gracefully."""
|
||||
# Setup: State manager raises exception when getting states
|
||||
self.config_manager.load_config.return_value = {}
|
||||
self.state_manager.get_all_states.side_effect = Exception("State manager error")
|
||||
|
||||
# Run reconciliation
|
||||
self.config_manager.load_config.side_effect = Exception("Config error")
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# Verify error is handled - reconciliation may still succeed if other sources work
|
||||
|
||||
self.assertIsInstance(result, ReconciliationResult)
|
||||
# Note: Reconciliation may still succeed if other sources provide valid state
|
||||
|
||||
|
||||
def test_fix_failure_handling(self):
|
||||
"""Test that fix failures are handled correctly."""
|
||||
# Setup: Plugin missing in config, but save fails
|
||||
self.config_manager.load_config.return_value = {}
|
||||
|
||||
self.state_manager.get_all_states.return_value = {}
|
||||
|
||||
self.plugin_manager.plugin_manifests = {}
|
||||
self.plugin_manager.plugins = {}
|
||||
|
||||
# Create plugin directory
|
||||
plugin_dir = self.plugins_dir / "plugin1"
|
||||
plugin_dir.mkdir()
|
||||
manifest_path = plugin_dir / "manifest.json"
|
||||
with open(manifest_path, 'w') as f:
|
||||
json.dump({"version": "1.0.0", "name": "Plugin 1"}, f)
|
||||
|
||||
# Mock save_config to raise exception
|
||||
self._install("plugin1")
|
||||
self.config_manager.save_config.side_effect = Exception("Save failed")
|
||||
|
||||
# Run reconciliation
|
||||
|
||||
result = self.reconciler.reconcile_state()
|
||||
|
||||
# Verify inconsistency detected but not fixed
|
||||
|
||||
self.assertEqual(len(result.inconsistencies_found), 1)
|
||||
self.assertEqual(len(result.inconsistencies_fixed), 0)
|
||||
self.assertEqual(len(result.inconsistencies_manual), 1)
|
||||
|
||||
|
||||
def test_get_config_state_handles_exception(self):
|
||||
"""Test that _get_config_state handles exceptions."""
|
||||
# Setup: Config manager raises exception
|
||||
self.config_manager.load_config.side_effect = Exception("Config error")
|
||||
|
||||
# Call method directly
|
||||
|
||||
state = self.reconciler._get_config_state()
|
||||
|
||||
# Verify empty state returned
|
||||
|
||||
self.assertEqual(state, {})
|
||||
|
||||
|
||||
def test_get_disk_state_handles_exception(self):
|
||||
"""Test that _get_disk_state handles exceptions."""
|
||||
# Setup: Make plugins_dir inaccessible
|
||||
with patch.object(self.reconciler, 'plugins_dir', create=True) as mock_dir:
|
||||
mock_dir.exists.side_effect = Exception("Disk error")
|
||||
mock_dir.iterdir.side_effect = Exception("Disk error")
|
||||
|
||||
# Call method directly
|
||||
|
||||
state = self.reconciler._get_disk_state()
|
||||
|
||||
# Verify empty state returned
|
||||
|
||||
self.assertEqual(state, {})
|
||||
|
||||
|
||||
@@ -352,15 +366,10 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
|
||||
self.plugins_dir = self.temp_dir / "plugins"
|
||||
self.plugins_dir.mkdir()
|
||||
|
||||
self.state_manager = Mock(spec=PluginStateManager)
|
||||
self.state_manager.get_all_states.return_value = {}
|
||||
self.config_manager = Mock()
|
||||
self.config_manager.load_config.return_value = {
|
||||
"ghost": {"enabled": True}
|
||||
}
|
||||
self.plugin_manager = Mock()
|
||||
self.plugin_manager.plugin_manifests = {}
|
||||
self.plugin_manager.plugins = {}
|
||||
|
||||
# Store manager with an empty registry — install_plugin always fails
|
||||
self.store_manager = Mock()
|
||||
@@ -372,9 +381,7 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
|
||||
self.store_manager.is_plugin_uninstalled.return_value = False
|
||||
|
||||
self.reconciler = StateReconciliation(
|
||||
state_manager=self.state_manager,
|
||||
config_manager=self.config_manager,
|
||||
plugin_manager=self.plugin_manager,
|
||||
plugins_dir=self.plugins_dir,
|
||||
store_manager=self.store_manager,
|
||||
)
|
||||
@@ -483,9 +490,7 @@ class TestStateReconciliationUnrecoverable(unittest.TestCase):
|
||||
)
|
||||
|
||||
reconciler = StateReconciliation(
|
||||
state_manager=self.state_manager,
|
||||
config_manager=self.config_manager,
|
||||
plugin_manager=self.plugin_manager,
|
||||
plugins_dir=self.plugins_dir,
|
||||
store_manager=real_store,
|
||||
)
|
||||
|
||||
@@ -43,7 +43,6 @@ def store(tmp_path):
|
||||
with patch.object(api_v3, 'plugin_store_manager', sm, create=True), \
|
||||
patch.object(api_v3, 'plugin_catalog', None, create=True), \
|
||||
patch.object(api_v3, 'schema_manager', None, create=True), \
|
||||
patch.object(api_v3, 'plugin_state_manager', None, create=True), \
|
||||
patch.object(api_v3, 'operation_history', None, create=True):
|
||||
yield sm
|
||||
|
||||
|
||||
@@ -194,8 +194,11 @@ class TestTheWebProcessNeverRunsAPlugin:
|
||||
entry = next(p for p in body["data"]["plugins"] if p["id"] == PLUGIN_ID)
|
||||
assert entry["version"] == "1.0.0"
|
||||
assert entry["enabled"] is True
|
||||
# Not published by the display, so not invented here.
|
||||
# No display has published a runtime snapshot here, so these are
|
||||
# unknown rather than invented (test_plugin_runtime_snapshot.py
|
||||
# covers a live one).
|
||||
assert entry["loaded"] is None and entry["state"] is None
|
||||
assert body["data"]["runtime"]["status"] == "unknown"
|
||||
# Nothing in its files declares a participation; the display derives
|
||||
# one from its hooks, which are not called here.
|
||||
assert (entry["vegas_participation"], entry["vegas_participation_source"]) == (
|
||||
|
||||
Reference in New Issue
Block a user