mirror of
https://github.com/ChuckBuilds/LEDMatrix.git
synced 2026-10-04 14:25:08 +00:00
refactor(web): read plugins through a PluginCatalog; only the display runs them (#688)
The web process built its own PluginManager and loaded plugins into itself: store installs and updates loaded or reloaded a web-side copy, and config saves and enable/disable called on_config_change, on_enable and on_disable on it. None of that reached the panel, and /plugins/installed reported runtime state from those copies. - Add PluginCatalog (src/plugin_system/plugin_catalog.py): manifests, directories, display modes, installed version, schema and config reads, with no way to run a plugin. app.py and both blueprints use it; the plugin_manager blueprint attribute is gone. - Remove every lifecycle call from the web routes. Config changes already reach the display through ConfigService (on_config_change) and the enabled-set reconcile. - Health and metrics readers move to api_v3.health_tracker / resource_monitor. /plugins/installed reports loaded/state/error_info as null (the display does not publish them) and enabled by the display's rule. - Store install, update and uninstall answer restart_required when the running display will not pick the change up by itself (display_restart_required). The restart banner follows the flag via window.noteRestartRequired instead of the /config/main URL heuristic; /config/main now sends restart_required: true. - The one remaining in-process import of plugin code (Starlark helper modules, oauth_flow action scripts) goes through _import_plugin_code_in_web_process() until a web-entry contract. - /plugins/installed reports vegas_participation (from #682) from the user's setting or the manifest, with vegas_participation_source; when only the plugin's code decides it, null with source 'runtime', since the web process no longer has plugin instances to ask. - Check & Update All keeps its restart flags when the final list refresh fails, and asks for a restart when an enabled plugin's first request got no answer and the re-sent one found it up to date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,7 +29,7 @@ from web_interface.blueprints import api_v3 as api_v3_module # noqa: E402
|
||||
from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
|
||||
_MANAGER_ATTRS = (
|
||||
'config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'config_manager', 'plugin_catalog', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ from web_interface.blueprints.api_v3 import api_v3 # noqa: E402
|
||||
URL = "/api/v3/backup/restore"
|
||||
|
||||
_MANAGER_ATTRS = (
|
||||
'config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
'config_manager', 'plugin_catalog', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager',
|
||||
)
|
||||
|
||||
@@ -43,7 +43,7 @@ def env(tmp_path):
|
||||
config_manager.template_path = str(tmp_path / "no-template.json")
|
||||
|
||||
_SENTINEL = object()
|
||||
attrs = ('config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
attrs = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager',
|
||||
'schema_manager', 'operation_queue', 'operation_history',
|
||||
'cache_manager')
|
||||
|
||||
@@ -93,13 +93,12 @@ def env(tmp_path, api_v3_module):
|
||||
api = api_v3_module.api_v3
|
||||
api.config_manager = config_manager
|
||||
api.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
||||
api.plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID},
|
||||
api.plugin_catalog.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID},
|
||||
NEWS_ID: {"id": NEWS_ID}}
|
||||
api.plugin_manager.get_plugin.return_value = None
|
||||
|
||||
class Env:
|
||||
client = build_app(api).test_client()
|
||||
plugin_manager = api.plugin_manager
|
||||
plugin_catalog = api.plugin_catalog
|
||||
|
||||
@staticmethod
|
||||
def stored(plugin_id=PLUGIN_ID):
|
||||
@@ -183,19 +182,18 @@ class TestReset:
|
||||
assert calls == [True]
|
||||
assert env.stored()["stock_symbols"] == ["AAPL"]
|
||||
|
||||
def test_reset_notifies_the_plugin_with_its_prepared_config(self, env):
|
||||
plugin = MagicMock()
|
||||
env.plugin_manager.get_plugin.return_value = plugin
|
||||
env.plugin_manager.prepare_plugin_config.side_effect = (
|
||||
lambda _pid, raw: {**raw, "prepared": True})
|
||||
def test_reset_runs_no_plugin_code_in_the_web_process(self, env):
|
||||
# The running plugin gets the reset config from the display's config
|
||||
# watcher (on_config_change there, with the prepared section). The
|
||||
# catalog has no get_plugin, so a route that still reached for a
|
||||
# plugin instance here would fail this request.
|
||||
assert not hasattr(env.plugin_catalog, "get_plugin")
|
||||
|
||||
response = env.client.post("/api/v3/plugins/config/reset",
|
||||
json={"plugin_id": PLUGIN_ID})
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
handed_over = plugin.on_config_change.call_args.args[0]
|
||||
assert handed_over["prepared"] is True
|
||||
assert handed_over["stock_symbols"] == ["AAPL"]
|
||||
assert env.stored()["stock_symbols"] == ["AAPL"]
|
||||
|
||||
def test_a_failed_save_is_reported(self, env, monkeypatch):
|
||||
failed = MagicMock(message="disk full")
|
||||
|
||||
@@ -79,11 +79,10 @@ def env(tmp_path):
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
|
||||
plugin_manager.plugins_dir = plugins_dir
|
||||
plugin_manager.get_plugin.return_value = None
|
||||
|
||||
api_v3.config_manager = config_manager
|
||||
api_v3.schema_manager = schema_manager
|
||||
api_v3.plugin_manager = plugin_manager
|
||||
api_v3.plugin_catalog = plugin_manager
|
||||
api_v3.plugin_store_manager = MagicMock()
|
||||
api_v3.saved_repositories_manager = MagicMock()
|
||||
api_v3.operation_queue = MagicMock()
|
||||
|
||||
@@ -39,7 +39,7 @@ EXPECTED = {
|
||||
'details': describe_exception(FORCED),
|
||||
}
|
||||
|
||||
MANAGERS = ("config_manager", "plugin_manager", "plugin_store_manager",
|
||||
MANAGERS = ("config_manager", "plugin_catalog", "plugin_store_manager",
|
||||
"saved_repositories_manager", "schema_manager", "operation_queue",
|
||||
"plugin_state_manager", "operation_history", "cache_manager")
|
||||
|
||||
@@ -222,7 +222,7 @@ class TestPluginActionStep1:
|
||||
from unittest.mock import MagicMock
|
||||
manager = MagicMock()
|
||||
manager.get_plugin_directory.return_value = str(plugin_dir)
|
||||
monkeypatch.setattr(api_v3, "plugin_manager", manager, raising=False)
|
||||
monkeypatch.setattr(api_v3, "plugin_catalog", manager, raising=False)
|
||||
app = Flask(__name__)
|
||||
app.register_blueprint(api_v3, url_prefix="/api/v3")
|
||||
|
||||
|
||||
@@ -113,7 +113,7 @@ def post(tmp_path):
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
for k in ("config_manager", "schema_manager", "plugin_catalog")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
@@ -137,9 +137,7 @@ def post(tmp_path):
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
a.api_v3.plugin_catalog = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
|
||||
@@ -99,7 +99,7 @@ def post(tmp_path):
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
for k in ("config_manager", "schema_manager", "plugin_catalog")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
@@ -122,9 +122,7 @@ def post(tmp_path):
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
a.api_v3.plugin_catalog = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
|
||||
@@ -115,19 +115,17 @@ def app_client(tmp_path):
|
||||
cm.save_config_atomic.side_effect = _save
|
||||
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.plugins_dir = plugins_dir
|
||||
pm.get_plugin.return_value = None
|
||||
pm.get_plugin_info.return_value = {"name": "Demo", "version": "1.0.0"}
|
||||
sm = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
||||
|
||||
names = ("config_manager", "schema_manager", "plugin_manager")
|
||||
names = ("config_manager", "schema_manager", "plugin_catalog")
|
||||
originals = {(bp, k): getattr(bp, k, None)
|
||||
for bp in (api.api_v3, pages.pages_v3) for k in names}
|
||||
for bp in (api.api_v3, pages.pages_v3):
|
||||
bp.config_manager = cm
|
||||
bp.schema_manager = sm
|
||||
bp.plugin_manager = pm
|
||||
bp.plugin_catalog = pm
|
||||
|
||||
base = Path(pages.__file__).resolve().parent.parent
|
||||
app = Flask(__name__, template_folder=str(base / "templates"),
|
||||
|
||||
@@ -72,7 +72,7 @@ STORED = {
|
||||
"skin_options": {"accent": "#00ff00"},
|
||||
}
|
||||
|
||||
_ATTRS = ('config_manager', 'plugin_manager', 'plugin_store_manager',
|
||||
_ATTRS = ('config_manager', 'plugin_catalog', 'plugin_store_manager',
|
||||
'plugin_state_manager', 'saved_repositories_manager', 'schema_manager',
|
||||
'operation_queue', 'operation_history', 'cache_manager')
|
||||
|
||||
@@ -96,13 +96,12 @@ def env(tmp_path):
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.plugin_manifests = {PLUGIN_ID: {"id": PLUGIN_ID}}
|
||||
plugin_manager.plugins_dir = plugins_dir
|
||||
plugin_manager.get_plugin.return_value = None
|
||||
|
||||
for name in _ATTRS:
|
||||
setattr(api_v3, name, MagicMock())
|
||||
api_v3.config_manager = config_manager
|
||||
api_v3.schema_manager = SchemaManager(plugins_dir=plugins_dir, project_root=tmp_path)
|
||||
api_v3.plugin_manager = plugin_manager
|
||||
api_v3.plugin_catalog = plugin_manager
|
||||
api_v3.operation_queue = None
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
@@ -110,7 +110,7 @@ def post(tmp_path):
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
for k in ("config_manager", "schema_manager", "plugin_catalog")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
@@ -134,9 +134,7 @@ def post(tmp_path):
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
a.api_v3.plugin_catalog = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
|
||||
@@ -71,7 +71,7 @@ def render(tmp_path):
|
||||
from web_interface.blueprints import pages_v3 as pv
|
||||
from src.plugin_system.schema_manager import SchemaManager
|
||||
|
||||
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||
orig_pm = getattr(pv.pages_v3, "plugin_catalog", None)
|
||||
orig_cm = getattr(pv.pages_v3, "config_manager", None)
|
||||
orig_sm = getattr(pv.pages_v3, "schema_manager", None)
|
||||
|
||||
@@ -86,8 +86,7 @@ def render(tmp_path):
|
||||
pm = MagicMock()
|
||||
pm.plugins_dir = str(tmp_path / "plugins")
|
||||
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
|
||||
pm.get_plugin.return_value = None
|
||||
pv.pages_v3.plugin_manager = pm
|
||||
pv.pages_v3.plugin_catalog = pm
|
||||
|
||||
cm = MagicMock()
|
||||
cm.load_config.return_value = {plugin_id: {"enabled": True}}
|
||||
@@ -111,7 +110,7 @@ def render(tmp_path):
|
||||
try:
|
||||
yield _render
|
||||
finally:
|
||||
pv.pages_v3.plugin_manager = orig_pm
|
||||
pv.pages_v3.plugin_catalog = orig_pm
|
||||
pv.pages_v3.config_manager = orig_cm
|
||||
pv.pages_v3.schema_manager = orig_sm
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ def make_client(tmp_path):
|
||||
"""
|
||||
from web_interface.blueprints import pages_v3 as pv
|
||||
|
||||
original_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||
original_pm = getattr(pv.pages_v3, "plugin_catalog", None)
|
||||
|
||||
def _build(plugins_dir=None, plugin_manager=_UNSET):
|
||||
base = PROJECT_ROOT / "web_interface"
|
||||
@@ -64,7 +64,7 @@ def make_client(tmp_path):
|
||||
if plugin_manager is _UNSET:
|
||||
plugin_manager = MagicMock()
|
||||
plugin_manager.plugins_dir = str(plugins_dir or tmp_path)
|
||||
pv.pages_v3.plugin_manager = plugin_manager
|
||||
pv.pages_v3.plugin_catalog = plugin_manager
|
||||
|
||||
app.register_blueprint(pv.pages_v3, url_prefix="")
|
||||
return app.test_client()
|
||||
@@ -72,7 +72,7 @@ def make_client(tmp_path):
|
||||
try:
|
||||
yield _build
|
||||
finally:
|
||||
pv.pages_v3.plugin_manager = original_pm
|
||||
pv.pages_v3.plugin_catalog = original_pm
|
||||
|
||||
|
||||
URL = "/static/plugin-widgets/{}/{}.js"
|
||||
@@ -211,7 +211,7 @@ def config_form(tmp_path):
|
||||
"""Render a plugin's config partial with a temp plugin on disk."""
|
||||
from web_interface.blueprints import pages_v3 as pv
|
||||
|
||||
orig_pm = getattr(pv.pages_v3, "plugin_manager", None)
|
||||
orig_pm = getattr(pv.pages_v3, "plugin_catalog", None)
|
||||
orig_cm = getattr(pv.pages_v3, "config_manager", None)
|
||||
|
||||
def _render(plugin_id="soccer-scoreboard", schema=None, widgets=None,
|
||||
@@ -226,8 +226,7 @@ def config_form(tmp_path):
|
||||
pm.get_plugin_info.return_value = {"id": plugin_id, "name": plugin_id}
|
||||
if version is not None:
|
||||
pm.get_plugin_info.return_value["version"] = version
|
||||
pm.get_plugin.return_value = None
|
||||
pv.pages_v3.plugin_manager = pm
|
||||
pv.pages_v3.plugin_catalog = pm
|
||||
|
||||
cm = MagicMock()
|
||||
cm.load_config.return_value = {plugin_id: {"enabled": True}}
|
||||
@@ -244,7 +243,7 @@ def config_form(tmp_path):
|
||||
try:
|
||||
yield _render
|
||||
finally:
|
||||
pv.pages_v3.plugin_manager = orig_pm
|
||||
pv.pages_v3.plugin_catalog = orig_pm
|
||||
pv.pages_v3.config_manager = orig_cm
|
||||
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ def post(tmp_path):
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
for k in ("config_manager", "schema_manager", "plugin_catalog")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
@@ -86,9 +86,7 @@ def post(tmp_path):
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
a.api_v3.plugin_catalog = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
|
||||
@@ -62,7 +62,7 @@ def post(tmp_path):
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
for k in ("config_manager", "schema_manager", "plugin_catalog")}
|
||||
|
||||
pdir = tmp_path / "plugins" / "demo"
|
||||
pdir.mkdir(parents=True)
|
||||
@@ -85,9 +85,7 @@ def post(tmp_path):
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
a.api_v3.plugin_catalog = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
|
||||
@@ -41,7 +41,7 @@ def store(tmp_path):
|
||||
sm.get_plugin_info.return_value = None
|
||||
sm.update_plugin.return_value = True
|
||||
with patch.object(api_v3, 'plugin_store_manager', sm, create=True), \
|
||||
patch.object(api_v3, 'plugin_manager', None, 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):
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
"""The web process reads plugins; only the display process runs them.
|
||||
|
||||
The web interface used to build its own PluginManager and load plugins into
|
||||
the web process: store installs and updates loaded or reloaded a web-side
|
||||
copy, and config saves and enable/disable called on_config_change,
|
||||
on_enable and on_disable on it. None of that reached the panel -- the
|
||||
display process runs its own instances -- and the web then reported
|
||||
"runtime" state from copies nothing displayed. An update in particular
|
||||
looked applied while the display kept running the old code until it
|
||||
restarted, and nothing said so.
|
||||
|
||||
Now the web process has a PluginCatalog (manifests, schemas, config,
|
||||
installed versions) and nothing that can run a plugin:
|
||||
|
||||
- every route a user drives for a plugin works without importing the
|
||||
plugin's module at all -- the plugin below records any import and any
|
||||
lifecycle call to a file, and the file must never appear;
|
||||
- the catalog reads what is really installed, in plugin-repos/ and in the
|
||||
test fixtures;
|
||||
- a store install, update or uninstall answers ``restart_required`` exactly
|
||||
when the running display will not pick the change up by itself.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from src.config_manager import ConfigManager # noqa: E402
|
||||
from src.plugin_system.plugin_catalog import ( # noqa: E402
|
||||
PluginCatalog, display_restart_required,
|
||||
)
|
||||
from src.plugin_system.schema_manager import SchemaManager # noqa: E402
|
||||
from test._api_v3_test_helpers import api_v3_module # noqa: F401,E402
|
||||
|
||||
PLUGIN_ID = "tripwire"
|
||||
|
||||
# A plugin that leaves evidence of being run: importing its module, building
|
||||
# it, or calling any lifecycle hook appends a line to TRIPWIRE_LOG.
|
||||
MANAGER_PY = '''
|
||||
import os
|
||||
LOG = os.environ.get("TRIPWIRE_LOG") or {log!r}
|
||||
|
||||
def _note(what):
|
||||
with open(LOG, "a", encoding="utf-8") as f:
|
||||
f.write(what + "\\n")
|
||||
|
||||
_note("imported")
|
||||
|
||||
from src.plugin_system.base_plugin import BasePlugin
|
||||
|
||||
|
||||
class TripwirePlugin(BasePlugin):
|
||||
def __init__(self, *args, **kwargs):
|
||||
_note("instantiated")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def update(self):
|
||||
_note("update")
|
||||
|
||||
def display(self, force_clear=False):
|
||||
_note("display")
|
||||
|
||||
def on_config_change(self, new_config):
|
||||
_note("on_config_change")
|
||||
|
||||
def on_enable(self):
|
||||
_note("on_enable")
|
||||
|
||||
def on_disable(self):
|
||||
_note("on_disable")
|
||||
'''
|
||||
|
||||
SCHEMA = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"enabled": {"type": "boolean", "default": False},
|
||||
"message": {"type": "string", "default": "hello"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _write_plugin(plugins_dir, log, version="1.0.0", plugin_id=PLUGIN_ID):
|
||||
plugin_dir = plugins_dir / plugin_id
|
||||
plugin_dir.mkdir(parents=True, exist_ok=True)
|
||||
(plugin_dir / "manifest.json").write_text(json.dumps({
|
||||
"id": plugin_id, "name": "Tripwire", "version": version,
|
||||
"entry_point": "manager.py", "class_name": "TripwirePlugin",
|
||||
"display_modes": ["tripwire"],
|
||||
}), encoding="utf-8")
|
||||
(plugin_dir / "config_schema.json").write_text(json.dumps(SCHEMA), encoding="utf-8")
|
||||
(plugin_dir / "manager.py").write_text(MANAGER_PY.format(log=str(log)), encoding="utf-8")
|
||||
return plugin_dir
|
||||
|
||||
|
||||
class Web:
|
||||
"""The api_v3 and pages_v3 blueprints over a real catalog and config."""
|
||||
|
||||
def __init__(self, api_v3_module, tmp_path, monkeypatch, enabled=True):
|
||||
self.log = tmp_path / "tripwire.log"
|
||||
monkeypatch.setenv("TRIPWIRE_LOG", str(self.log))
|
||||
self.plugins_dir = tmp_path / "plugin-repos"
|
||||
self.plugins_dir.mkdir()
|
||||
_write_plugin(self.plugins_dir, self.log)
|
||||
|
||||
self.config_file = tmp_path / "config.json"
|
||||
self.config_file.write_text(json.dumps(
|
||||
{PLUGIN_ID: {"enabled": enabled, "message": "hi"}}), encoding="utf-8")
|
||||
self.config_manager = ConfigManager(
|
||||
config_path=str(self.config_file),
|
||||
secrets_path=str(tmp_path / "config_secrets.json"))
|
||||
self.config_manager.template_path = str(tmp_path / "no-template.json")
|
||||
self.schema_manager = SchemaManager(plugins_dir=self.plugins_dir, project_root=tmp_path,
|
||||
config_manager=self.config_manager)
|
||||
self.catalog = PluginCatalog(self.plugins_dir, self.config_manager, self.schema_manager)
|
||||
|
||||
api = self.api = api_v3_module.api_v3
|
||||
api.config_manager = self.config_manager
|
||||
api.schema_manager = self.schema_manager
|
||||
api.plugin_catalog = self.catalog
|
||||
store = api.plugin_store_manager
|
||||
store.plugins_dir = str(self.plugins_dir)
|
||||
store.get_registry_info.return_value = None
|
||||
store.get_plugin_info.return_value = None
|
||||
store._get_local_git_info.return_value = None
|
||||
store.install_plugin.return_value = True
|
||||
store.uninstall_plugin.return_value = True
|
||||
store.update_plugin.return_value = True
|
||||
|
||||
from web_interface.blueprints import pages_v3 as pages_module
|
||||
pages = pages_module.pages_v3
|
||||
for name, value in (("config_manager", self.config_manager),
|
||||
("schema_manager", self.schema_manager),
|
||||
("plugin_catalog", self.catalog)):
|
||||
monkeypatch.setattr(pages, name, value, raising=False)
|
||||
|
||||
app = Flask(__name__, template_folder=str(PROJECT_ROOT / "web_interface" / "templates"))
|
||||
app.config["TESTING"] = True
|
||||
app.register_blueprint(api, url_prefix="/api/v3")
|
||||
app.register_blueprint(pages, url_prefix="")
|
||||
self.client = app.test_client()
|
||||
|
||||
def ran(self):
|
||||
"""What the plugin recorded: [] when none of its code ever ran."""
|
||||
return self.log.read_text(encoding="utf-8").split() if self.log.exists() else []
|
||||
|
||||
def stored(self):
|
||||
return json.loads(self.config_file.read_text(encoding="utf-8")).get(PLUGIN_ID, {})
|
||||
|
||||
def post(self, url, body):
|
||||
response = self.client.post(url, json=body)
|
||||
assert response.status_code == 200, (url, response.get_json())
|
||||
return response.get_json()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def web(api_v3_module, tmp_path, monkeypatch):
|
||||
return Web(api_v3_module, tmp_path, monkeypatch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disabled_web(api_v3_module, tmp_path, monkeypatch):
|
||||
return Web(api_v3_module, tmp_path, monkeypatch, enabled=False)
|
||||
|
||||
|
||||
def _bump_version(web, version):
|
||||
"""What a store update does to the files on disk."""
|
||||
def update(plugin_id):
|
||||
_write_plugin(web.plugins_dir, web.log, version=version, plugin_id=plugin_id)
|
||||
return True
|
||||
web.api.plugin_store_manager.update_plugin.side_effect = update
|
||||
|
||||
|
||||
class TestTheWebProcessNeverRunsAPlugin:
|
||||
"""Every plugin route, against a plugin that reports being run."""
|
||||
|
||||
def test_the_tripwire_works(self, web, tmp_path):
|
||||
# Sanity: importing the module does leave the mark this suite checks for.
|
||||
import importlib.util
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"tripwire_probe", web.plugins_dir / PLUGIN_ID / "manager.py")
|
||||
spec.loader.exec_module(importlib.util.module_from_spec(spec))
|
||||
assert web.ran() == ["imported"]
|
||||
|
||||
def test_listing_the_installed_plugins(self, web):
|
||||
body = web.client.get("/api/v3/plugins/installed").get_json()
|
||||
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.
|
||||
assert entry["loaded"] is None and entry["state"] is None
|
||||
# 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"]) == (
|
||||
None, "runtime")
|
||||
assert web.ran() == []
|
||||
|
||||
def test_enabling_and_disabling(self, web):
|
||||
web.post("/api/v3/plugins/toggle", {"plugin_id": PLUGIN_ID, "enabled": False})
|
||||
assert web.stored()["enabled"] is False
|
||||
web.post("/api/v3/plugins/toggle", {"plugin_id": PLUGIN_ID, "enabled": True})
|
||||
assert web.stored()["enabled"] is True
|
||||
assert web.ran() == []
|
||||
|
||||
def test_saving_its_config(self, web):
|
||||
web.post("/api/v3/plugins/config",
|
||||
{"plugin_id": PLUGIN_ID, "config": {"message": "changed"}})
|
||||
assert web.stored()["message"] == "changed"
|
||||
assert web.ran() == []
|
||||
|
||||
def test_saving_its_section_through_the_main_config(self, web):
|
||||
body = web.post("/api/v3/config/main", {PLUGIN_ID: {"message": "via main"}})
|
||||
assert web.stored()["message"] == "via main"
|
||||
assert body["restart_required"] is True
|
||||
assert web.ran() == []
|
||||
|
||||
def test_resetting_its_config(self, web):
|
||||
web.post("/api/v3/plugins/config/reset", {"plugin_id": PLUGIN_ID})
|
||||
assert web.stored()["message"] == "hello"
|
||||
assert web.ran() == []
|
||||
|
||||
def test_rendering_its_settings_page(self, web):
|
||||
response = web.client.get(f"/partials/plugin-config/{PLUGIN_ID}")
|
||||
assert response.status_code == 200
|
||||
assert "Tripwire" in response.get_data(as_text=True)
|
||||
assert web.ran() == []
|
||||
|
||||
def test_updating_it(self, web):
|
||||
_bump_version(web, "1.1.0")
|
||||
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
assert body["data"]["update_status"] == "updated"
|
||||
assert web.catalog.get_installed_version(PLUGIN_ID) == "1.1.0"
|
||||
assert web.ran() == []
|
||||
|
||||
def test_installing_it(self, web):
|
||||
web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
|
||||
assert PLUGIN_ID in web.catalog.plugin_manifests
|
||||
assert web.ran() == []
|
||||
|
||||
def test_uninstalling_it(self, web):
|
||||
web.post("/api/v3/plugins/uninstall", {"plugin_id": PLUGIN_ID})
|
||||
web.api.plugin_store_manager.uninstall_plugin.assert_called_once_with(PLUGIN_ID)
|
||||
assert web.ran() == []
|
||||
|
||||
def test_no_plugin_module_is_registered_in_this_process(self, web):
|
||||
web.client.get("/api/v3/plugins/installed")
|
||||
web.post("/api/v3/plugins/toggle", {"plugin_id": PLUGIN_ID, "enabled": True})
|
||||
assert not [name for name, mod in list(sys.modules.items())
|
||||
if str(web.plugins_dir) in str(getattr(mod, "__file__", "") or "")]
|
||||
|
||||
def test_the_web_app_has_a_catalog_and_no_plugin_manager(self):
|
||||
"""app.py wires a PluginCatalog, and nothing named like a manager."""
|
||||
source = (PROJECT_ROOT / "web_interface" / "app.py").read_text(encoding="utf-8")
|
||||
assert "PluginCatalog(" in source
|
||||
assert "PluginManager" not in source
|
||||
|
||||
|
||||
class TestNoLifecycleCallsInWebCode:
|
||||
"""A static backstop for the behavioural tests above: the web process's
|
||||
own code has no call that runs a plugin. Comments may mention the hooks;
|
||||
calls may not."""
|
||||
|
||||
FORBIDDEN = (".on_enable(", ".on_disable(", ".on_config_change(",
|
||||
".load_plugin(", ".unload_plugin(", ".reload_plugin(",
|
||||
".get_plugin(", ".update(force", ".display(force_clear",
|
||||
# Vegas participation past config and manifest calls the
|
||||
# plugin's hooks; the installed route reports it from files.
|
||||
"resolve_vegas_participation(", "legacy_vegas_participation(",
|
||||
".get_vegas_participation(")
|
||||
|
||||
def _code_lines(self):
|
||||
for path in sorted((PROJECT_ROOT / "web_interface").rglob("*.py")):
|
||||
for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
code = line.split("#", 1)[0]
|
||||
yield path.relative_to(PROJECT_ROOT), number, code
|
||||
|
||||
def test_no_web_module_calls_a_plugin_lifecycle_hook(self):
|
||||
offenders = [f"{path}:{number}: {code.strip()}"
|
||||
for path, number, code in self._code_lines()
|
||||
if any(call in code for call in self.FORBIDDEN)]
|
||||
assert offenders == []
|
||||
|
||||
def test_plugin_code_is_imported_in_exactly_one_place(self):
|
||||
"""exec_module in the web blueprints happens only in the seam named
|
||||
for it (Starlark helpers and oauth_flow action scripts)."""
|
||||
blueprints = PROJECT_ROOT / "web_interface" / "blueprints"
|
||||
hits = [f"{path.name}:{n}" for path in sorted(blueprints.rglob("*.py"))
|
||||
for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1)
|
||||
if "exec_module(" in line.split("#", 1)[0]]
|
||||
assert len(hits) == 1, hits
|
||||
source = (blueprints / "api_v3" / "__init__.py").read_text(encoding="utf-8")
|
||||
seam = source.index("def _import_plugin_code_in_web_process(")
|
||||
assert source.index("exec_module(", seam) < source.index("\ndef ", seam + 1)
|
||||
|
||||
|
||||
class TestStoreOperationsReachTheDisplay:
|
||||
"""Each answer says whether the display needs a restart to show it."""
|
||||
|
||||
def test_an_update_of_a_plugin_the_display_runs_asks_for_a_restart(self, web):
|
||||
_bump_version(web, "2.0.0")
|
||||
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
assert body["restart_required"] is True
|
||||
assert "restart the display" in body["restart_message"]
|
||||
|
||||
def test_an_update_of_a_disabled_plugin_does_not(self, disabled_web):
|
||||
_bump_version(disabled_web, "2.0.0")
|
||||
body = disabled_web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
assert body["data"]["update_status"] == "updated"
|
||||
assert body["restart_required"] is False
|
||||
assert "restart_message" not in body
|
||||
|
||||
def test_an_update_that_changed_nothing_does_not(self, web):
|
||||
body = web.post("/api/v3/plugins/update", {"plugin_id": PLUGIN_ID})
|
||||
assert body["data"]["update_status"] == "up_to_date"
|
||||
assert body["restart_required"] is False
|
||||
|
||||
def test_installing_a_plugin_config_already_enables_asks_for_a_restart(self, web):
|
||||
body = web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
|
||||
assert body["restart_required"] is True
|
||||
|
||||
def test_installing_a_disabled_plugin_does_not(self, disabled_web):
|
||||
# Enabling it later loads it live, from disk, in the display.
|
||||
body = disabled_web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
|
||||
assert body["restart_required"] is False
|
||||
|
||||
def test_the_queued_install_carries_the_flag_in_the_operation_result(self, web):
|
||||
results = []
|
||||
|
||||
def enqueue(operation_type, plugin_id, operation_callback=None):
|
||||
results.append(operation_callback(MagicMock()))
|
||||
return "op-1"
|
||||
|
||||
web.api.operation_queue = MagicMock()
|
||||
web.api.operation_queue.enqueue_operation.side_effect = enqueue
|
||||
web.post("/api/v3/plugins/install", {"plugin_id": PLUGIN_ID})
|
||||
assert results[0]["success"] is True
|
||||
assert results[0]["restart_required"] is True
|
||||
|
||||
def test_install_from_url_carries_the_flag(self, web):
|
||||
web.api.plugin_store_manager.install_from_url.return_value = {
|
||||
"success": True, "plugin_id": PLUGIN_ID}
|
||||
body = web.post("/api/v3/plugins/install-from-url", {"repo_url": "https://example.invalid/r"})
|
||||
assert body["restart_required"] is True
|
||||
|
||||
def test_uninstall_that_removes_the_config_needs_no_restart(self, web):
|
||||
# The removed section reaches the display's config watcher, and its
|
||||
# reconcile unloads the plugin.
|
||||
body = web.post("/api/v3/plugins/uninstall", {"plugin_id": PLUGIN_ID})
|
||||
assert body["restart_required"] is False
|
||||
assert PLUGIN_ID not in json.loads(web.config_file.read_text(encoding="utf-8"))
|
||||
|
||||
def test_uninstall_that_keeps_an_enabled_config_asks_for_a_restart(self, web):
|
||||
body = web.post("/api/v3/plugins/uninstall",
|
||||
{"plugin_id": PLUGIN_ID, "preserve_config": True})
|
||||
assert body["restart_required"] is True
|
||||
|
||||
@pytest.mark.parametrize("action,enabled,kwargs,expected", [
|
||||
("install", False, {}, False),
|
||||
("install", True, {}, True),
|
||||
("update", True, {"changed": True}, True),
|
||||
("update", True, {"changed": False}, False),
|
||||
("update", False, {"changed": True}, False),
|
||||
("uninstall", True, {}, False),
|
||||
("uninstall", True, {"preserve_config": True}, True),
|
||||
("uninstall", False, {"preserve_config": True}, False),
|
||||
])
|
||||
def test_the_rule(self, action, enabled, kwargs, expected):
|
||||
assert display_restart_required(action, enabled, **kwargs) is expected
|
||||
|
||||
def test_an_unknown_action_is_a_programming_error(self):
|
||||
with pytest.raises(ValueError):
|
||||
display_restart_required("reinstall", True)
|
||||
|
||||
|
||||
class TestCatalogReadsWhatIsInstalled:
|
||||
"""Real plugin directories: the repository's plugin-repos/ and fixtures."""
|
||||
|
||||
@staticmethod
|
||||
def _manifests(root):
|
||||
found = {}
|
||||
for child in sorted(Path(root).iterdir()):
|
||||
manifest = child / "manifest.json"
|
||||
if child.is_dir() and manifest.exists():
|
||||
data = json.loads(manifest.read_text(encoding="utf-8"))
|
||||
if isinstance(data, dict) and data.get("id"):
|
||||
found[data["id"]] = (child, data)
|
||||
return found
|
||||
|
||||
@pytest.mark.parametrize("root", [
|
||||
PROJECT_ROOT / "plugin-repos",
|
||||
PROJECT_ROOT / "test" / "fixtures" / "plugins",
|
||||
], ids=["plugin-repos", "fixtures"])
|
||||
def test_every_installed_plugin_is_read_from_its_files(self, root):
|
||||
expected = self._manifests(root)
|
||||
assert expected, f"no plugins under {root}"
|
||||
before = set(sys.modules)
|
||||
schema_manager = SchemaManager(plugins_dir=root, project_root=PROJECT_ROOT)
|
||||
catalog = PluginCatalog(root, schema_manager=schema_manager)
|
||||
|
||||
assert set(catalog.discover_plugins()) == set(expected)
|
||||
for plugin_id, (plugin_dir, manifest) in expected.items():
|
||||
assert catalog.get_manifest(plugin_id) == manifest
|
||||
assert catalog.get_plugin_directory(plugin_id) == str(plugin_dir)
|
||||
assert catalog.get_installed_version(plugin_id) == manifest.get("version", "")
|
||||
assert catalog.get_plugin_display_modes(plugin_id) == manifest.get("display_modes", [])
|
||||
if (plugin_dir / "config_schema.json").exists():
|
||||
schema = catalog.get_schema(plugin_id, use_cache=False)
|
||||
assert isinstance(schema, dict) and "properties" in schema, plugin_id
|
||||
|
||||
imported = [name for name in set(sys.modules) - before
|
||||
if str(root) in str(getattr(sys.modules[name], "__file__", "") or "")]
|
||||
assert imported == [], "reading the catalog imported plugin code"
|
||||
|
||||
def test_a_mode_finds_its_plugin(self):
|
||||
catalog = PluginCatalog(PROJECT_ROOT / "test" / "fixtures" / "plugins")
|
||||
catalog.discover_plugins()
|
||||
assert catalog.find_plugin_for_mode("CI-Fixture") == "ci-fixture-plugin"
|
||||
assert catalog.find_plugin_for_mode("nope") is None
|
||||
|
||||
def test_discovery_follows_the_directory(self, tmp_path):
|
||||
plugins = tmp_path / "plugin-repos"
|
||||
root = PROJECT_ROOT / "test" / "fixtures" / "plugins" / "ci-fixture-plugin"
|
||||
shutil.copytree(root, plugins / "ledmatrix-ci-fixture-plugin")
|
||||
(plugins / "broken").mkdir()
|
||||
(plugins / "broken" / "manifest.json").write_text("[]", encoding="utf-8")
|
||||
catalog = PluginCatalog(plugins)
|
||||
|
||||
assert catalog.discover_plugins() == ["ci-fixture-plugin"]
|
||||
# Installed as ledmatrix-<id>, found by its manifest id.
|
||||
assert catalog.get_plugin_directory("ci-fixture-plugin").endswith("ledmatrix-ci-fixture-plugin")
|
||||
# Not a plain name: refused, never joined onto the plugins directory.
|
||||
assert catalog.get_plugin_directory("../plugin-repos") is None
|
||||
|
||||
shutil.rmtree(plugins / "ledmatrix-ci-fixture-plugin")
|
||||
assert catalog.discover_plugins() == []
|
||||
assert catalog.get_manifest("ci-fixture-plugin") is None
|
||||
|
||||
def test_enabled_follows_the_display_rule(self, tmp_path):
|
||||
config = MagicMock()
|
||||
config.load_config.return_value = {"a": {"enabled": True}, "b": {}, "c": "junk"}
|
||||
catalog = PluginCatalog(tmp_path, config_manager=config)
|
||||
assert catalog.is_enabled("a") is True
|
||||
# The display runs a plugin only when its section says so.
|
||||
assert catalog.is_enabled("b") is False
|
||||
assert catalog.is_enabled("c") is False
|
||||
assert catalog.is_enabled("missing") is False
|
||||
assert catalog.get_config("c") == {}
|
||||
|
||||
def test_it_has_nothing_that_runs_a_plugin(self, tmp_path):
|
||||
catalog = PluginCatalog(tmp_path)
|
||||
for name in ("load_plugin", "unload_plugin", "reload_plugin", "get_plugin",
|
||||
"plugins", "run_scheduled_updates"):
|
||||
assert not hasattr(catalog, name), name
|
||||
@@ -189,7 +189,7 @@ def _make_post(tmp_path, schema, stored, plugin_id="demo"):
|
||||
from web_interface.blueprints import api_v3 as a
|
||||
|
||||
originals = {k: getattr(a.api_v3, k, None)
|
||||
for k in ("config_manager", "schema_manager", "plugin_manager")}
|
||||
for k in ("config_manager", "schema_manager", "plugin_catalog")}
|
||||
|
||||
pdir = tmp_path / "plugins" / plugin_id
|
||||
pdir.mkdir(parents=True)
|
||||
@@ -213,9 +213,7 @@ def _make_post(tmp_path, schema, stored, plugin_id="demo"):
|
||||
a.api_v3.schema_manager = SchemaManager(plugins_dir=tmp_path / "plugins",
|
||||
project_root=tmp_path)
|
||||
pm = MagicMock()
|
||||
pm.plugins = {}
|
||||
pm.get_plugin.return_value = None
|
||||
a.api_v3.plugin_manager = pm
|
||||
a.api_v3.plugin_catalog = pm
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["TESTING"] = True
|
||||
|
||||
Reference in New Issue
Block a user