diff --git a/repeater/plugins/ipc.py b/repeater/plugins/ipc.py index 331a028..c919e26 100644 --- a/repeater/plugins/ipc.py +++ b/repeater/plugins/ipc.py @@ -74,6 +74,7 @@ class PluginIPCServer: "uninstall": self._op_uninstall, "ui_info": self._op_ui_info, "get_config": self._op_get_config, + "get_runtime": self._op_get_runtime, "set_config": self._op_set_config, "catalogue": self._op_catalogue, "catalogue_install": self._op_catalogue_install, @@ -244,6 +245,9 @@ class PluginIPCServer: def _op_get_config(self, request: dict[str, Any]) -> dict[str, Any]: return self.manager.get_config(self._require_id(request)) + def _op_get_runtime(self, request: dict[str, Any]) -> dict[str, Any]: + return self.manager.get_runtime(self._require_id(request)) + def _op_set_config(self, request: dict[str, Any]) -> dict[str, Any]: config = request.get("config") if not isinstance(config, dict): @@ -364,6 +368,9 @@ class PluginIPCClient: def get_config(self, plugin_id: str) -> dict[str, Any]: return self.call("get_config", plugin_id=plugin_id) + def get_runtime(self, plugin_id: str) -> dict[str, Any]: + return self.call("get_runtime", plugin_id=plugin_id) + def set_config( self, plugin_id: str, config: dict[str, Any], *, restart: bool = False ) -> dict[str, Any]: diff --git a/repeater/plugins/manager.py b/repeater/plugins/manager.py index 663cbcd..016945f 100644 --- a/repeater/plugins/manager.py +++ b/repeater/plugins/manager.py @@ -4,13 +4,12 @@ from __future__ import annotations import json import logging +import shutil import threading from pathlib import Path from typing import Any, Optional -import shutil - -from .catalogue import CatalogueClient, CatalogueError, DEFAULT_CATALOGUE_URL +from .catalogue import DEFAULT_CATALOGUE_URL, CatalogueClient, CatalogueError from .github_releases import ( GitHubReleaseClient, GitHubReleasesError, @@ -268,6 +267,25 @@ class PluginManager: result["restarted"] = restarted return result + def get_runtime(self, plugin_id: str) -> dict[str, Any]: + """Return plugin runtime snapshot from data/runtime.json for UIs.""" + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + paths = self.storage.paths_for(plugin_id) + paths.data_dir.mkdir(parents=True, exist_ok=True) + try: + runtime = self.storage.read_runtime(plugin_id) + except ValueError as exc: + raise PluginManagerError(str(exc), 500) from exc + path = self.storage.runtime_path(plugin_id) + return { + "id": plugin_id, + "path": str(path), + "exists": path.is_file(), + "runtime": runtime, + } + def uninstall(self, plugin_id: str, *, delete_data: bool = False) -> dict[str, Any]: with self._lock: state = self.storage.read_state(plugin_id) diff --git a/repeater/plugins/storage.py b/repeater/plugins/storage.py index bcdd3be..0cbec22 100644 --- a/repeater/plugins/storage.py +++ b/repeater/plugins/storage.py @@ -225,6 +225,10 @@ class PluginStorage: """Path to the plugin-owned config.json under data/.""" return self.paths_for(plugin_id).data_dir / "config.json" + def runtime_path(self, plugin_id: str) -> Path: + """Path to the plugin runtime snapshot under data/.""" + return self.paths_for(plugin_id).data_dir / "runtime.json" + def read_config(self, plugin_id: str) -> dict[str, Any]: """Read plugin-owned data/config.json. Missing file → {}.""" path = self.config_path(plugin_id) @@ -250,6 +254,21 @@ class PluginStorage: self._atomic_write_json(path, config) return path + def read_runtime(self, plugin_id: str) -> dict[str, Any]: + """Read plugin-owned data/runtime.json. Missing file -> {}.""" + path = self.runtime_path(plugin_id) + if not path.is_file(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ValueError(f"invalid plugin runtime.json: {exc}") from exc + if data is None: + return {} + if not isinstance(data, dict): + raise ValueError("plugin runtime.json must be a JSON object") + return data + def tail_log(self, plugin_id: str, lines: int = 200) -> list[str]: paths = self.paths_for(plugin_id) log_path = paths.log_file diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index cc8a2c1..1d42f2a 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -4745,6 +4745,28 @@ paths: '503': description: Plugin manager unavailable + /plugins/runtime: + get: + tags: [Plugins] + summary: Get plugin runtime.json + description: Read the plugin-owned data/runtime.json object. + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - name: id + in: query + required: true + schema: + type: string + responses: + '200': + description: Runtime object + '404': + description: Plugin not found + '503': + description: Plugin manager unavailable + /plugins/install: post: diff --git a/repeater/web/plugin_endpoints.py b/repeater/web/plugin_endpoints.py index 17b8e5e..36d585e 100644 --- a/repeater/web/plugin_endpoints.py +++ b/repeater/web/plugin_endpoints.py @@ -341,6 +341,17 @@ class PluginAPIEndpoints: raise cherrypy.HTTPError(405, "Method Not Allowed") + @cherrypy.expose + @cherrypy.tools.json_out() + def runtime(self, **kwargs): + """GET /api/plugins/runtime — read plugin data/runtime.json.""" + if cherrypy.request.method == "OPTIONS": + return "" + if cherrypy.request.method not in ("GET", "HEAD"): + raise cherrypy.HTTPError(405, "Method Not Allowed") + plugin_id = self._plugin_id_from(kwargs) + return self._handle_ipc(lambda: self._client_or_raise().get_runtime(plugin_id)) + @cherrypy.expose @cherrypy.tools.json_out() def catalogue(self, **kwargs): diff --git a/tests/test_plugin_api_endpoints.py b/tests/test_plugin_api_endpoints.py index 0a1d2b9..b86a789 100644 --- a/tests/test_plugin_api_endpoints.py +++ b/tests/test_plugin_api_endpoints.py @@ -51,6 +51,11 @@ def test_list_and_status_via_client_mock(tmp_path, cherrypy_request): "enabled": True, } mock_client.enable.return_value = {"id": "openhop.demo", "enabled": True, "state": "RUNNING"} + mock_client.get_runtime.return_value = { + "id": "openhop.demo", + "exists": True, + "runtime": {"schema": 1, "running": True}, + } with patch.object(api, "_client_or_raise", return_value=mock_client): cherrypy.request.method = "GET" @@ -67,6 +72,11 @@ def test_list_and_status_via_client_mock(tmp_path, cherrypy_request): enabled = api.enable() assert enabled["success"] is True + cherrypy.request.method = "GET" + runtime = api.runtime(id="openhop.demo") + assert runtime["success"] is True + assert runtime["runtime"]["schema"] == 1 + def test_ui_serving_index_assets_spa_and_disabled(tmp_path): plugins_root = tmp_path / "plugins" diff --git a/tests/test_plugin_ipc.py b/tests/test_plugin_ipc.py index deb3f40..177af80 100644 --- a/tests/test_plugin_ipc.py +++ b/tests/test_plugin_ipc.py @@ -94,6 +94,13 @@ def test_ipc_list_status_start_stop(tmp_path: Path): st = client.status("openhop.demo") assert st["state"] == "STOPPED" + runtime_path = manager.storage.runtime_path("openhop.demo") + runtime_path.parent.mkdir(parents=True, exist_ok=True) + runtime_path.write_text(json.dumps({"schema": 1, "running": True}), encoding="utf-8") + runtime = client.get_runtime("openhop.demo") + assert runtime["exists"] is True + assert runtime["runtime"]["schema"] == 1 + with pytest.raises(PluginIPCError): client.status("missing.plugin") finally: diff --git a/tests/test_plugin_manager_lifecycle.py b/tests/test_plugin_manager_lifecycle.py index c4916fb..3ce2f69 100644 --- a/tests/test_plugin_manager_lifecycle.py +++ b/tests/test_plugin_manager_lifecycle.py @@ -480,6 +480,32 @@ def test_get_set_config_and_optional_restart(tmp_path: Path): assert len(procs) >= 1 +def test_get_runtime_reads_runtime_snapshot_file(tmp_path: Path): + storage = PluginStorage(tmp_path / "plugins") + storage.ensure_plugin_layout("openhop.demo", "0.1.0") + storage.write_state("openhop.demo", {"version": "0.1.0", "enabled": False}) + manager = PluginManager(storage) + + payload = {"schema": 1, "running": True, "metrics": {"active_alerts": 3}} + storage.runtime_path("openhop.demo").write_text(json.dumps(payload), encoding="utf-8") + + runtime = manager.get_runtime("openhop.demo") + assert runtime["exists"] is True + assert runtime["runtime"]["schema"] == 1 + assert runtime["runtime"]["metrics"]["active_alerts"] == 3 + + +def test_get_runtime_returns_empty_when_missing(tmp_path: Path): + storage = PluginStorage(tmp_path / "plugins") + storage.ensure_plugin_layout("openhop.demo", "0.1.0") + storage.write_state("openhop.demo", {"version": "0.1.0", "enabled": False}) + manager = PluginManager(storage) + + runtime = manager.get_runtime("openhop.demo") + assert runtime["exists"] is False + assert runtime["runtime"] == {} + + def test_ensure_pip_wheel_filename_rewrites_invalid_temp_name(tmp_path: Path): """Uploaded temp names like plugin-XXXX.whl must be rewritten for pip.""" import zipfile