mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 06:15:09 +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>
443 lines
18 KiB
Python
443 lines
18 KiB
Python
"""The display publishes its plugin runtime state; the web reads it back.
|
|
|
|
Only the display runs plugins, so only it knows which ones are loaded, where
|
|
each is in its lifecycle, why one failed and which version it runs. It
|
|
publishes that as one snapshot in the shared cache (plugin_runtime), and the
|
|
web interface fills /api/v3/plugins/installed's ``loaded`` / ``state`` /
|
|
``error_info`` from it -- but only while the snapshot is live. A stale,
|
|
stopped or missing snapshot is reported as such, with those fields null.
|
|
|
|
The cross-process tests use two CacheManagers over one temporary directory,
|
|
the arrangement of the real services (which share /var/cache/ledmatrix).
|
|
"""
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from src.cache_manager import CacheManager # noqa: E402
|
|
from src.plugin_system import plugin_runtime as rt # noqa: E402
|
|
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
|
|
from src.plugin_system.plugin_runtime import ( # noqa: E402
|
|
PLUGIN_RUNTIME_KEY, PluginRuntimePublisher, read_plugin_runtime,
|
|
view_from_snapshot,
|
|
)
|
|
from src.plugin_system.plugin_state import PluginState, PluginStateManager # noqa: E402
|
|
from test._api_v3_test_helpers import api_v3_client, api_v3_module # noqa: F401,E402
|
|
|
|
|
|
class FakeClock:
|
|
def __init__(self, now=1000.0):
|
|
self.now = now
|
|
|
|
def __call__(self):
|
|
return self.now
|
|
|
|
|
|
@pytest.fixture
|
|
def shared_cache(tmp_path, monkeypatch):
|
|
"""Two cache managers over one directory: the display's and the web's."""
|
|
monkeypatch.setattr(CacheManager, "_get_writable_cache_dir", lambda self: str(tmp_path))
|
|
display_cache, web_cache = CacheManager(), CacheManager()
|
|
yield display_cache, web_cache
|
|
display_cache.stop_cleanup_thread()
|
|
web_cache.stop_cleanup_thread()
|
|
|
|
|
|
def _publisher(cache, states, wall=None, mono=None):
|
|
return PluginRuntimePublisher(cache, states, clock=mono or FakeClock(),
|
|
wall_clock=wall or FakeClock(1_800_000_000.0))
|
|
|
|
|
|
# --- The state machine: what counts as a change -----------------------------
|
|
|
|
class TestChangeCount:
|
|
def test_an_ordinary_update_is_not_a_change(self):
|
|
"""ENABLED -> RUNNING -> ENABLED is every update() call. Publishing it
|
|
would be an SD-card write per plugin update."""
|
|
states = PluginStateManager()
|
|
states.set_state("clock", PluginState.ENABLED)
|
|
before = states.change_count
|
|
for _ in range(50):
|
|
states.set_state("clock", PluginState.RUNNING)
|
|
states.set_state("clock", PluginState.ENABLED)
|
|
assert states.change_count == before
|
|
assert states.runtime_records()["clock"]["state"] == "enabled"
|
|
|
|
def test_running_is_published_as_enabled(self):
|
|
states = PluginStateManager()
|
|
states.set_state("clock", PluginState.RUNNING)
|
|
assert states.runtime_records()["clock"]["state"] == "enabled"
|
|
|
|
@pytest.mark.parametrize("change", [
|
|
lambda s: s.set_state("clock", PluginState.ERROR, error=ValueError("x")),
|
|
lambda s: s.set_state_with_error("clock", PluginState.ENABLED, {"error": "x"}),
|
|
lambda s: s.record_loaded("clock", "1.0.0"),
|
|
lambda s: s.set_state("clock", PluginState.DISABLED),
|
|
lambda s: s.clear_state("clock"),
|
|
lambda s: s.set_state("weather", PluginState.LOADED),
|
|
])
|
|
def test_reader_visible_changes_move_it(self, change):
|
|
states = PluginStateManager()
|
|
states.set_state("clock", PluginState.ENABLED)
|
|
before = states.change_count
|
|
change(states)
|
|
assert states.change_count > before
|
|
|
|
def test_recovering_from_an_error_is_a_change(self):
|
|
states = PluginStateManager()
|
|
states.set_state_with_error("clock", PluginState.ENABLED, {"error": "timeout"})
|
|
before = states.change_count
|
|
states.set_state("clock", PluginState.ENABLED) # next update succeeded
|
|
assert states.change_count > before
|
|
assert states.runtime_records()["clock"]["error_info"] is None
|
|
|
|
def test_clear_state_forgets_the_loaded_record(self):
|
|
states = PluginStateManager()
|
|
states.set_state("clock", PluginState.ENABLED)
|
|
states.record_loaded("clock", "1.0.0", loaded_at=5.0)
|
|
assert states.runtime_records()["clock"]["loaded"] is True
|
|
states.clear_state("clock")
|
|
assert states.runtime_records() == {}
|
|
|
|
|
|
class TestPluginManagerRecordsWhatItLoaded:
|
|
@pytest.fixture
|
|
def pm(self, tmp_path):
|
|
plugins_dir = tmp_path / "plugins"
|
|
(plugins_dir / "demo").mkdir(parents=True)
|
|
manager = PluginManager(plugins_dir=str(plugins_dir))
|
|
manager.plugin_manifests["demo"] = {"id": "demo", "name": "Demo", "version": "2.1.0"}
|
|
manager.schema_manager = MagicMock()
|
|
manager.schema_manager.get_schema_path.return_value = None
|
|
manager.plugin_loader = MagicMock()
|
|
manager.plugin_loader.find_plugin_directory.return_value = plugins_dir / "demo"
|
|
manager.plugin_loader.load_plugin.return_value = (MagicMock(spec=["on_enable"]), None)
|
|
return manager
|
|
|
|
def test_load_records_the_manifest_version(self, pm):
|
|
assert pm.load_plugin("demo") is True
|
|
record = pm.state_manager.runtime_records()["demo"]
|
|
assert record["loaded"] is True
|
|
assert record["state"] == "enabled"
|
|
assert record["version"] == "2.1.0"
|
|
assert isinstance(record["loaded_at"], float)
|
|
|
|
def test_unload_forgets_it(self, pm):
|
|
pm.load_plugin("demo")
|
|
pm.unload_plugin("demo")
|
|
assert "demo" not in pm.state_manager.runtime_records()
|
|
|
|
def test_a_failed_load_is_an_error_and_not_loaded(self, pm):
|
|
pm.plugin_loader.load_plugin.side_effect = ImportError("No module named 'requests'")
|
|
assert pm.load_plugin("demo") is False
|
|
record = pm.state_manager.runtime_records()["demo"]
|
|
assert record["loaded"] is False
|
|
assert record["state"] == "error"
|
|
assert "requests" in record["error_info"]["error"]
|
|
|
|
|
|
# --- Publishing -------------------------------------------------------------
|
|
|
|
class TestPublisher:
|
|
def test_first_tick_publishes(self):
|
|
cache = MagicMock()
|
|
states = PluginStateManager()
|
|
states.set_state("clock", PluginState.ENABLED)
|
|
states.record_loaded("clock", "1.0.0", loaded_at=10.0)
|
|
|
|
assert _publisher(cache, states).tick() is True
|
|
|
|
key, snapshot = cache.set.call_args.args
|
|
assert key == PLUGIN_RUNTIME_KEY
|
|
assert snapshot["running"] is True
|
|
assert snapshot["published_at"] == 1_800_000_000.0
|
|
assert snapshot["stale_after"] == rt.STALE_AFTER
|
|
assert snapshot["plugins"] == {"clock": {
|
|
"loaded": True, "state": "enabled", "error": None,
|
|
"version": "1.0.0", "loaded_at": 10.0}}
|
|
|
|
def test_changes_are_throttled_and_quiet_displays_refresh(self):
|
|
cache = MagicMock()
|
|
states = PluginStateManager()
|
|
mono = FakeClock()
|
|
publisher = _publisher(cache, states, mono=mono)
|
|
publisher.tick()
|
|
assert cache.set.call_count == 1
|
|
|
|
# Nothing changed: no write until the refresh is due.
|
|
mono.now += rt.REFRESH_INTERVAL - 1
|
|
assert publisher.tick() is False
|
|
mono.now += 1
|
|
assert publisher.tick() is True
|
|
assert cache.set.call_count == 2
|
|
|
|
# A burst of changes is written at most once per MIN_INTERVAL.
|
|
states.set_state("a", PluginState.LOADED)
|
|
mono.now += 1
|
|
assert publisher.tick() is False
|
|
mono.now += rt.MIN_INTERVAL
|
|
assert publisher.tick() is True
|
|
assert cache.set.call_count == 3
|
|
|
|
def test_updates_alone_cause_no_writes(self):
|
|
"""A display running plugins but changing nothing writes once a
|
|
minute, however many update() calls it makes."""
|
|
cache = MagicMock()
|
|
states = PluginStateManager()
|
|
states.set_state("clock", PluginState.ENABLED)
|
|
mono = FakeClock()
|
|
publisher = _publisher(cache, states, mono=mono)
|
|
publisher.tick()
|
|
for _ in range(11): # 55 s of 5 s ticks
|
|
states.set_state("clock", PluginState.RUNNING)
|
|
states.set_state("clock", PluginState.ENABLED)
|
|
mono.now += rt.TICK_INTERVAL
|
|
publisher.tick()
|
|
assert cache.set.call_count == 1
|
|
|
|
def test_errors_are_redacted_and_short(self):
|
|
cache = MagicMock()
|
|
states = PluginStateManager()
|
|
secret = "https://api.example.com/v1?api_key=SUPERSECRET123&q=" + "x" * 500
|
|
states.set_state("weather", PluginState.ERROR, error=ConnectionError(secret))
|
|
|
|
_publisher(cache, states).tick()
|
|
|
|
error = cache.set.call_args.args[1]["plugins"]["weather"]["error"]
|
|
assert "SUPERSECRET123" not in json.dumps(error)
|
|
assert len(error["message"]) <= 200
|
|
assert error["type"] == "ConnectionError"
|
|
assert isinstance(error["at"], float)
|
|
assert error["recoverable"] is False
|
|
|
|
def test_stop_publishes_stopped(self):
|
|
cache = MagicMock()
|
|
states = PluginStateManager()
|
|
states.record_loaded("clock", "1.0.0")
|
|
publisher = _publisher(cache, states)
|
|
publisher.tick()
|
|
|
|
publisher.stop()
|
|
|
|
snapshot = cache.set.call_args.args[1]
|
|
assert snapshot["running"] is False
|
|
assert snapshot["plugins"] == {}
|
|
|
|
def test_a_failing_cache_never_raises(self):
|
|
cache = MagicMock()
|
|
cache.set.side_effect = OSError("read-only file system")
|
|
publisher = _publisher(cache, PluginStateManager())
|
|
assert publisher.tick() is False
|
|
publisher.stop() # also swallowed
|
|
|
|
def test_the_thread_starts_and_stops(self):
|
|
cache = MagicMock()
|
|
publisher = rt.start_plugin_runtime_publisher(cache, PluginStateManager())
|
|
try:
|
|
assert publisher is not None
|
|
finally:
|
|
publisher.stop()
|
|
assert cache.set.call_args.args[1]["running"] is False
|
|
|
|
|
|
# --- Reading: live, stale, stopped, unknown ---------------------------------
|
|
|
|
def _snapshot(published_at, running=True, plugins=None, **extra):
|
|
base = {"schema": rt.SNAPSHOT_SCHEMA, "running": running,
|
|
"published_at": published_at, "stale_after": 180.0,
|
|
"plugins": plugins or {}}
|
|
base.update(extra)
|
|
return base
|
|
|
|
|
|
class TestReader:
|
|
NOW = 1_800_000_000.0
|
|
CLOCK = {"loaded": True, "state": "enabled", "error": None,
|
|
"version": "1.0.0", "loaded_at": 5.0}
|
|
|
|
def test_live(self):
|
|
view = view_from_snapshot(_snapshot(self.NOW - 30, plugins={"clock": self.CLOCK}),
|
|
now=self.NOW)
|
|
assert view.status == "live"
|
|
assert view.plugin("clock") == {"loaded": True, "state": "enabled",
|
|
"error_info": None, "loaded_version": "1.0.0",
|
|
"loaded_at": 5.0}
|
|
# Listed nowhere in a live snapshot: not loaded.
|
|
assert view.plugin("weather")["loaded"] is False
|
|
assert view.plugin("weather")["state"] == "unloaded"
|
|
assert view.describe()["age_seconds"] == 30.0
|
|
|
|
def test_stale_reports_nothing(self):
|
|
view = view_from_snapshot(_snapshot(self.NOW - 181, plugins={"clock": self.CLOCK}),
|
|
now=self.NOW)
|
|
assert view.status == "stale"
|
|
assert view.plugin("clock") == {"loaded": None, "state": None, "error_info": None,
|
|
"loaded_version": None, "loaded_at": None}
|
|
assert view.describe()["status"] == "stale"
|
|
|
|
def test_stopped_reports_nothing(self):
|
|
view = view_from_snapshot(_snapshot(self.NOW - 1, running=False), now=self.NOW)
|
|
assert view.status == "stopped"
|
|
assert view.plugin("clock")["loaded"] is None
|
|
|
|
@pytest.mark.parametrize("snapshot", [
|
|
None, "junk", [], {}, {"schema": 99, "running": True, "published_at": NOW},
|
|
_snapshot(None), _snapshot("yesterday"), _snapshot(float("nan")),
|
|
])
|
|
def test_unknown(self, snapshot):
|
|
view = view_from_snapshot(snapshot, now=self.NOW)
|
|
assert view.status == "unknown"
|
|
assert view.plugin("clock")["state"] is None
|
|
|
|
def test_a_little_in_the_future_is_live_far_is_stale(self):
|
|
"""The Pi has no RTC: its clock steps at NTP sync."""
|
|
assert view_from_snapshot(_snapshot(self.NOW + 60), now=self.NOW).status == "live"
|
|
assert view_from_snapshot(_snapshot(self.NOW + 3600), now=self.NOW).status == "stale"
|
|
|
|
def test_a_corrupt_stale_after_is_bounded(self):
|
|
forever = _snapshot(self.NOW - 7200, stale_after=1e12)
|
|
assert view_from_snapshot(forever, now=self.NOW).status == "stale"
|
|
eager = _snapshot(self.NOW - 20, stale_after=0)
|
|
assert view_from_snapshot(eager, now=self.NOW).status == "live"
|
|
|
|
def test_no_cache_manager_or_a_failing_one_is_unknown(self):
|
|
assert read_plugin_runtime(None).status == "unknown"
|
|
cache = MagicMock()
|
|
cache.get.side_effect = OSError("gone")
|
|
assert read_plugin_runtime(cache).status == "unknown"
|
|
|
|
|
|
# --- Across processes, through the real cache and the real routes -----------
|
|
|
|
class TestAcrossProcesses:
|
|
def test_what_the_display_publishes_the_web_reads(self, shared_cache):
|
|
display_cache, web_cache = shared_cache
|
|
states = PluginStateManager()
|
|
states.set_state("clock", PluginState.ENABLED)
|
|
states.record_loaded("clock", "1.0.0")
|
|
publisher = PluginRuntimePublisher(display_cache, states)
|
|
|
|
assert read_plugin_runtime(web_cache).status == "unknown"
|
|
publisher.tick()
|
|
view = read_plugin_runtime(web_cache)
|
|
assert view.status == "live"
|
|
assert view.plugin("clock")["loaded"] is True
|
|
|
|
# memory_ttl=0 on the reading side: a later write is seen at once.
|
|
publisher.stop()
|
|
assert read_plugin_runtime(web_cache).status == "stopped"
|
|
|
|
def test_a_snapshot_left_by_a_dead_display_goes_stale(self, shared_cache):
|
|
display_cache, web_cache = shared_cache
|
|
states = PluginStateManager()
|
|
states.record_loaded("clock", "1.0.0")
|
|
old = FakeClock(1_000_000.0) # long ago
|
|
PluginRuntimePublisher(display_cache, states, wall_clock=old).tick()
|
|
assert read_plugin_runtime(web_cache).status == "stale"
|
|
|
|
|
|
@pytest.fixture
|
|
def web_listing(api_v3_module, api_v3_client, shared_cache, tmp_path): # noqa: F811
|
|
"""GET /api/v3/plugins/installed over one real plugin and the shared cache."""
|
|
display_cache, web_cache = shared_cache
|
|
api = api_v3_module.api_v3
|
|
api.cache_manager = web_cache
|
|
api.plugin_catalog.plugins_dir = str(tmp_path / "plugins")
|
|
api.plugin_catalog.get_all_plugin_info = MagicMock(return_value=[
|
|
{"id": "clock", "name": "Clock", "version": "1.1.0"},
|
|
{"id": "weather", "name": "Weather", "version": "3.0.0"},
|
|
])
|
|
api.plugin_store_manager.get_registry_info = MagicMock(return_value=None)
|
|
api.plugin_store_manager._get_local_git_info = MagicMock(return_value=None)
|
|
api.config_manager.load_config = MagicMock(return_value={
|
|
"clock": {"enabled": True}, "weather": {"enabled": True}})
|
|
|
|
def get():
|
|
response = api_v3_client.get("/api/v3/plugins/installed")
|
|
assert response.status_code == 200, response.get_data(as_text=True)
|
|
data = response.get_json()["data"]
|
|
return {p["id"]: p for p in data["plugins"]}, data["runtime"]
|
|
|
|
return display_cache, get
|
|
|
|
|
|
class TestInstalledPluginsRoute:
|
|
def _display(self, display_cache):
|
|
states = PluginStateManager()
|
|
states.set_state("clock", PluginState.ENABLED)
|
|
states.record_loaded("clock", "1.0.0", loaded_at=123.0)
|
|
states.set_state("weather", PluginState.ERROR,
|
|
error=RuntimeError("token=abc123 rejected"))
|
|
return PluginRuntimePublisher(display_cache, states)
|
|
|
|
def test_restores_loaded_state_and_error_info(self, web_listing):
|
|
display_cache, get = web_listing
|
|
self._display(display_cache).tick()
|
|
|
|
plugins, runtime = get()
|
|
|
|
assert runtime["status"] == "live"
|
|
clock, weather = plugins["clock"], plugins["weather"]
|
|
assert (clock["loaded"], clock["state"], clock["error_info"]) == (True, "enabled", None)
|
|
# The display runs 1.0.0; 1.1.0 is on disk (an update awaiting restart).
|
|
assert clock["version"] == "1.1.0" and clock["loaded_version"] == "1.0.0"
|
|
assert clock["loaded_at"] == 123.0
|
|
assert weather["loaded"] is False and weather["state"] == "error"
|
|
assert weather["error_info"]["type"] == "RuntimeError"
|
|
assert "abc123" not in weather["error_info"]["message"]
|
|
|
|
def test_unknown_before_the_display_has_published(self, web_listing):
|
|
_, get = web_listing
|
|
plugins, runtime = get()
|
|
assert runtime["status"] == "unknown"
|
|
assert all(p["loaded"] is None and p["state"] is None and p["error_info"] is None
|
|
for p in plugins.values())
|
|
# enabled still comes from config.json.
|
|
assert plugins["clock"]["enabled"] is True
|
|
|
|
def test_stopped_display_is_not_reported_as_running(self, web_listing):
|
|
display_cache, get = web_listing
|
|
publisher = self._display(display_cache)
|
|
publisher.tick()
|
|
publisher.stop()
|
|
|
|
plugins, runtime = get()
|
|
|
|
assert runtime["status"] == "stopped"
|
|
assert plugins["clock"]["loaded"] is None
|
|
|
|
def test_stale_snapshot_is_not_reported_as_truth(self, web_listing):
|
|
display_cache, get = web_listing
|
|
states = PluginStateManager()
|
|
states.record_loaded("clock", "1.0.0")
|
|
PluginRuntimePublisher(display_cache, states,
|
|
wall_clock=FakeClock(1_000_000.0)).tick()
|
|
|
|
plugins, runtime = get()
|
|
|
|
assert runtime["status"] == "stale"
|
|
assert runtime["age_seconds"] > rt.STALE_AFTER
|
|
assert plugins["clock"]["loaded"] is None
|
|
|
|
|
|
class TestDisplayControllerStopsThePublisher:
|
|
def test_cleanup_publishes_stopped(self, monkeypatch):
|
|
# display_manager binds the hardware rgbmatrix module unless
|
|
# EMULATOR=true is set before import (test_initial_update_budget.py).
|
|
monkeypatch.setenv("EMULATOR", "true")
|
|
from src.display_controller import DisplayController
|
|
controller = DisplayController.__new__(DisplayController)
|
|
publisher = MagicMock()
|
|
controller._plugin_runtime_publisher = publisher
|
|
controller.plugin_manager = None
|
|
controller.vegas_coordinator = None
|
|
controller.sync_manager = None
|
|
controller.display_manager = MagicMock()
|
|
controller.cleanup()
|
|
publisher.stop.assert_called_once_with()
|