diff --git a/config.yaml.example b/config.yaml.example index a57a8d6..1d39c6c 100644 --- a/config.yaml.example +++ b/config.yaml.example @@ -635,6 +635,19 @@ storage: # - 1 hour resolution for 1 year +# Plugin manager (optional external process lifecycle) +# plugins: +# # Master switch for the openhop-plugin-manager service (default: enabled when omitted) +# enabled: true +# # Install root (default: {storage.storage_dir}/plugins) +# # root: "/var/lib/openhop_repeater/plugins" +# # Unix domain socket used by Repeater HTTP → plugin manager IPC +# # socket: "/var/lib/openhop_repeater/plugin-manager.sock" + # Optional override for the curated catalogue (default: openhop-dev/openhop-plugin-catalogue) + # catalogue_url: "https://raw.githubusercontent.com/openhop-dev/openhop-plugin-catalogue/main/catalogue.json" + + + mqtt_brokers: iata_code: "Test" # e.g., "SFO", "LHR", "Test" status_interval: 300 # How often a status message is sent (in seconds) diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 4c6f835..e211dc2 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -127,4 +127,15 @@ fi merge_config_from_example "${CONFIG_PATH}" +# Optionally start the plugin manager alongside Repeater (same container). +# Disable with OPENHOP_PLUGIN_MANAGER=0. Failures here must not block Repeater. +PLUGIN_MANAGER_ENABLED="${OPENHOP_PLUGIN_MANAGER:-1}" +if [ "${PLUGIN_MANAGER_ENABLED}" != "0" ] && [ "${PLUGIN_MANAGER_ENABLED}" != "false" ]; then + if command -v openhop-plugin-manager >/dev/null 2>&1; then + openhop-plugin-manager --config "${CONFIG_PATH}" & + elif python3 -c 'import repeater.plugins' >/dev/null 2>&1; then + python3 -m repeater.plugins --config "${CONFIG_PATH}" & + fi +fi + exec python3 -m repeater.main --config "${CONFIG_PATH}" diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..d68887b --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,280 @@ +# openHop Plugins + +The plugin system installs and supervises **external** applications around the +Repeater. Plugins are not imported into the Repeater process. They talk to +existing interfaces (REST, WebSocket/SSE, Companion frame server, static web). + +## Layout + +Default root (under Repeater storage): + +```text +/var/lib/openhop_repeater/plugins/ +└── openhop.nomad/ + ├── releases/0.1.0/ + │ ├── openhop-plugin.json + │ ├── ui/ # optional application UI assets + │ └── venv/ # isolated Python environment + ├── current -> releases/0.1.0 + ├── data/ # persistent plugin-owned data + ├── logs/plugin.log + └── state.json # manager metadata only (id, version, enabled) +``` + +- Code and virtualenv are **version-specific**. +- `data/` is **not** version-specific and is never interpreted by the manager. +- IPC socket default: `/var/lib/openhop_repeater/plugin-manager.sock`. + +Override with config: + +```yaml +plugins: + enabled: true + # root: "/var/lib/openhop_repeater/plugins" + # socket: "/var/lib/openhop_repeater/plugin-manager.sock" +``` + +## Manifest (`openhop-plugin.json`) + +Schema version **1**: + +```json +{ + "schema": 1, + "id": "openhop.nomad", + "name": "NOMAD Bridge", + "version": "0.1.0", + "runtime": { + "type": "python", + "entrypoint": "meshcore-nomad-bridge" + } +} +``` + +UI-only application plugin: + +```json +{ + "schema": 1, + "id": "openhop.console", + "name": "openHop Console", + "version": "1.0.0", + "ui": { + "type": "application", + "entry": "ui/index.html" + } +} +``` + +A plugin may declare both `runtime` and `ui`. + +## Plugin manager service + +Native installs: + +```bash +sudo cp openhop-plugin-manager.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now openhop-plugin-manager +``` + +The manager starts enabled service plugins on boot and supervises crashes +(restarts with a simple crash-loop limit: 5 unexpected exits in 60s → `FAILED`). + +Repeater does **not** require the manager. If the socket is missing, plugin API +calls return HTTP 503 and normal Repeater operation continues. + +## Install a local wheel + +Via API (authenticated): + +```bash +curl -X POST -H "Authorization: Bearer $TOKEN" \ + -F "wheel=@openhop_nomad_plugin-0.1.0-py3-none-any.whl" \ + http://127.0.0.1:8000/api/plugins/install +``` + +Or JSON with a host path: + +```bash +curl -X POST -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"wheel_path":"/tmp/plugin.whl"}' \ + http://127.0.0.1:8000/api/plugins/install +``` + +Installation creates an isolated venv under the plugin release directory and +**never** mutates the Repeater virtualenv. + +## Enable / disable / lifecycle + +```bash +# Enable (also starts service plugins) +curl -X POST -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"id":"openhop.nomad"}' \ + http://127.0.0.1:8000/api/plugins/enable + +curl -X POST .../api/plugins/disable -d '{"id":"openhop.nomad"}' +curl -X POST .../api/plugins/start -d '{"id":"openhop.nomad"}' +curl -X POST .../api/plugins/stop -d '{"id":"openhop.nomad"}' +curl -X POST .../api/plugins/restart -d '{"id":"openhop.nomad"}' +``` + +List / status / logs: + +```bash +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/api/plugins/ +curl -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8000/api/plugins/openhop.nomad +curl -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:8000/api/plugins/logs?id=openhop.nomad&tail=100" +``` + +## Plugin configuration + +Each plugin owns `$OPENHOP_PLUGIN_DATA/config.json`. The manager treats it as an +opaque JSON object — it does not validate plugin-specific keys. + +Plugins can declare editor defaults in the manifest: + +```json +{ + "config": { + "defaults": { + "meshcore_host": "127.0.0.1", + "nomad_url": "http://127.0.0.1:8080" + } + } +} +``` + +Optionally ship `config.default.json` next to `openhop-plugin.json` in the wheel +(`share/openhop/plugins//`). On install, if `data/config.json` is missing, +the manager seeds it from those defaults. The settings UI shows defaults when no +saved config exists, and offers **Reset to defaults**. + +API: + +```bash +# Read +curl -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:8000/api/plugins/settings?id=openhop.nomad" + +# Write (optionally restart service plugins) +curl -X POST -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "id": "openhop.nomad", + "restart": true, + "config": { + "nomad_url": "http://192.168.0.170:8080", + "nomad_model": "qwen2.5:3b-instruct", + "meshcore_host": "127.0.0.1", + "meshcore_port": 5001 + } + }' \ + http://127.0.0.1:8000/api/plugins/settings +``` + +The Plugins page in the web UI provides a Settings dialog for the same file. + +## Service plugin environment + +Started processes receive: + +| Variable | Example | +|----------|---------| +| `OPENHOP_PLUGIN_ID` | `openhop.nomad` | +| `OPENHOP_PLUGIN_DATA` | `/var/lib/openhop_repeater/plugins/openhop.nomad/data` | + +Plugin-specific configuration (for example NOMAD `config.json`) lives under +`$OPENHOP_PLUGIN_DATA` and is owned by the plugin. + +## Application UI plugins + +Enabled UI plugins are served by Repeater at: + +```text +/plugins/{id}/ +/plugins/{id}/assets/... +``` + +Path traversal is rejected. Missing paths fall back to the manifest `ui.entry` +document (SPA-friendly). Disabled plugins are not exposed. + +## Uninstall + +Default **keeps** `data/`: + +```bash +curl -X DELETE -H "Authorization: Bearer $TOKEN" \ + http://127.0.0.1:8000/api/plugins/openhop.nomad +``` + +Delete data as well: + +```bash +curl -X DELETE -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:8000/api/plugins/openhop.nomad?delete_data=true" +``` + +## NOMAD acceptance + +1. Build the `openhop-nomad-plugin` wheel. +2. Install via `/api/plugins/install`. +3. Write `$OPENHOP_PLUGIN_DATA/config.json` with `nomad_url` / `nomad_model`. +4. Enable the plugin — `meshcore-nomad-bridge` should report `RUNNING`. +5. The bridge talks to the Companion frame server (default `127.0.0.1:5001`). + +## Future work (not in this version) + +- GitHub release installation and catalogue +- Automatic updates / rollback between versions +- Web Component dashboard widgets and settings panels +- Plugin management UI page +- Permission scopes, signing, non-Python runtimes +- Plugin SDK + +## Plugin catalogue + +Repeater can browse a curated static catalogue and install plugins from GitHub Releases. + +Default catalogue URL: + +```text +https://raw.githubusercontent.com/openhop-dev/openhop-plugin-catalogue/main/catalogue.json +``` + +Override with: + +```yaml +plugins: + catalogue_url: "https://raw.githubusercontent.com/openhop-dev/openhop-plugin-catalogue/main/catalogue.json" +``` + +The catalogue lists plugin **identity and repository only**. Versions and wheel assets come from GitHub Releases on each plugin repo (draft and prerelease tags are ignored by default). + +```bash +# List catalogue (annotated with installed / latest when available) +curl -H "Authorization: Bearer $TOKEN" \ + http://127.0.0.1:8000/api/plugins/catalogue + +# Install latest stable release of a catalogue plugin (enabled by default) +curl -X POST -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"id":"openhop.nomad"}' \ + http://127.0.0.1:8000/api/plugins/catalogue_install + +# Check for updates +curl -H "Authorization: Bearer $TOKEN" \ + "http://127.0.0.1:8000/api/plugins/updates?id=openhop.nomad" + +# Apply update +curl -X POST -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"id":"openhop.nomad"}' \ + http://127.0.0.1:8000/api/plugins/update +``` + +Catalogue or GitHub outages do not affect already-installed plugins or Repeater startup. Local `.whl` install continues to work unchanged. + diff --git a/manage.sh b/manage.sh index 06f8149..ca0b2f7 100755 --- a/manage.sh +++ b/manage.sh @@ -679,6 +679,7 @@ install_repeater() { echo "29"; echo "# Installing files..." cp "$SCRIPT_DIR/manage.sh" "$INSTALL_DIR/" 2>/dev/null || true cp "$SCRIPT_DIR/openhop-repeater.service" "$INSTALL_DIR/" 2>/dev/null || true + cp "$SCRIPT_DIR/openhop-plugin-manager.service" "$INSTALL_DIR/" 2>/dev/null || true cp "$SCRIPT_DIR/radio-settings.json" "$DATA_DIR/" 2>/dev/null || true cp "$SCRIPT_DIR/radio-presets.json" "$DATA_DIR/" 2>/dev/null || true @@ -690,6 +691,9 @@ install_repeater() { echo "55"; echo "# Installing systemd service..." cp "$SCRIPT_DIR/openhop-repeater.service" /etc/systemd/system/ + if [ -f "$SCRIPT_DIR/openhop-plugin-manager.service" ]; then + cp "$SCRIPT_DIR/openhop-plugin-manager.service" /etc/systemd/system/openhop-plugin-manager.service + fi systemctl daemon-reload echo "58"; echo "# Installing udev rules for CH341..." @@ -856,6 +860,9 @@ UPGRADEEOF echo "75"; echo "# Starting service..." systemctl enable "$SERVICE_NAME" + if [ -f /etc/systemd/system/openhop-plugin-manager.service ]; then + systemctl enable openhop-plugin-manager 2>/dev/null || true + fi echo "90"; echo "# Installation files complete..." ) | "$DIALOG" --backtitle "openHop Repeater Management" --title "Installing" --gauge "Setting up openHop Repeater..." 8 70 0 @@ -950,6 +957,10 @@ UPGRADEEOF error "Failed to start ${SERVICE_NAME}" return 1 fi + if [ -f /etc/systemd/system/openhop-plugin-manager.service ]; then + systemctl enable openhop-plugin-manager >/dev/null 2>&1 || true + systemctl start openhop-plugin-manager >/dev/null 2>&1 || true + fi # Show final results sleep 2 @@ -1183,6 +1194,15 @@ upgrade_repeater() { if ! cp "$SCRIPT_DIR/openhop-repeater.service" /etc/systemd/system/; then echo " ⚠ Warning: Failed to update service file – old service file may remain" fi + if [ -f "$SCRIPT_DIR/openhop-plugin-manager.service" ]; then + if cp "$SCRIPT_DIR/openhop-plugin-manager.service" /etc/systemd/system/openhop-plugin-manager.service; then + echo " ✓ openhop-plugin-manager.service installed" + else + echo " ⚠ Warning: Failed to install openhop-plugin-manager.service" + fi + else + echo " ⚠ openhop-plugin-manager.service not found in $SCRIPT_DIR (skipped)" + fi cp "$SCRIPT_DIR/radio-settings.json" "$DATA_DIR/" 2>/dev/null || true cp "$SCRIPT_DIR/radio-presets.json" "$DATA_DIR/" 2>/dev/null || true echo " ✓ Files updated" @@ -1428,6 +1448,24 @@ UPGRADEEOF echo " ✓ Service was not running/enabled before upgrade; no restart required" fi + # Plugin manager is optional but should be installed/enabled on upgrade when present. + # Failures here must not fail the Repeater upgrade. + if [ -f /etc/systemd/system/openhop-plugin-manager.service ]; then + if systemctl enable openhop-plugin-manager >/dev/null 2>&1; then + echo " ✓ openhop-plugin-manager enabled" + else + echo " ⚠ Could not enable openhop-plugin-manager" + fi + if systemctl restart openhop-plugin-manager >/dev/null 2>&1; then + echo " ✓ openhop-plugin-manager restarted" + elif systemctl start openhop-plugin-manager >/dev/null 2>&1; then + echo " ✓ openhop-plugin-manager started" + else + echo " ⚠ openhop-plugin-manager installed but failed to start (Repeater still OK)" + systemctl status openhop-plugin-manager --no-pager -l 2>/dev/null | head -20 || true + fi + fi + local new_version new_version=$(get_version) @@ -1508,6 +1546,8 @@ uninstall_repeater() { echo "" echo ">>> Stopping and disabling service..." + systemctl stop openhop-plugin-manager 2>/dev/null || true + systemctl disable openhop-plugin-manager 2>/dev/null || true systemctl stop "$SERVICE_NAME" 2>/dev/null || true systemctl disable "$SERVICE_NAME" 2>/dev/null || true @@ -1520,6 +1560,7 @@ uninstall_repeater() { echo "40"; echo "# Removing service files..." rm -f /etc/systemd/system/openhop-repeater.service + rm -f /etc/systemd/system/openhop-plugin-manager.service systemctl daemon-reload echo "50"; echo "# Removing polkit and sudoers rules..." diff --git a/openhop-plugin-manager.service b/openhop-plugin-manager.service new file mode 100644 index 0000000..b7f7046 --- /dev/null +++ b/openhop-plugin-manager.service @@ -0,0 +1,34 @@ +# Systemd service file template for openHop Plugin Manager. +# Install as /etc/systemd/system/openhop-plugin-manager.service +# +# Supervises installed plugins as child processes. Does not replace +# openhop-repeater.service. Repeater continues to work if this unit is stopped. + +[Unit] +Description=openHop Plugin Manager +After=network-online.target openhop-repeater.service +Wants=network-online.target +# Soft dependency: manager can start without Repeater, and vice versa + +[Service] +Type=simple +User=repeater +Group=repeater +WorkingDirectory=/var/lib/openhop_repeater + +ExecStart=/opt/openhop_repeater/venv/bin/openhop-plugin-manager --config /etc/openhop_repeater/config.yaml + +Restart=on-failure +RestartSec=5 +TimeoutStopSec=15 + +MemoryHigh=256M + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=openhop-plugin-manager + +ReadWritePaths=/var/log/openhop_repeater /var/lib/openhop_repeater /etc/openhop_repeater + +[Install] +WantedBy=multi-user.target diff --git a/pyproject.toml b/pyproject.toml index e3bd09e..cd0211f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,6 +60,7 @@ dev = [ [project.scripts] openhop-repeater = "repeater.main:main" +openhop-plugin-manager = "repeater.plugins.__main__:main" pymc-cli = "repeater.local_cli:main" [tool.setuptools.packages.find] diff --git a/repeater/plugins/__init__.py b/repeater/plugins/__init__.py new file mode 100644 index 0000000..4a2c6ca --- /dev/null +++ b/repeater/plugins/__init__.py @@ -0,0 +1,16 @@ +"""openHop Repeater plugin manager library. + +Plugins are external processes/applications managed outside the main Repeater +process. This package handles install layout, lifecycle, and local IPC only. +""" + +from .manifest import ManifestError, PluginManifest, parse_manifest +from .storage import PluginPaths, PluginStorage + +__all__ = [ + "ManifestError", + "PluginManifest", + "parse_manifest", + "PluginPaths", + "PluginStorage", +] diff --git a/repeater/plugins/__main__.py b/repeater/plugins/__main__.py new file mode 100644 index 0000000..3f62949 --- /dev/null +++ b/repeater/plugins/__main__.py @@ -0,0 +1,133 @@ +"""CLI entry for the openHop plugin manager process. + +Usage: + openhop-plugin-manager --config /etc/openhop_repeater/config.yaml + python -m repeater.plugins --plugins-root /var/lib/openhop_repeater/plugins +""" + +from __future__ import annotations + +import argparse +import logging +import signal +import sys +import time +from pathlib import Path +from typing import Any, Optional + +from .ipc import PluginIPCServer +from .manager import PluginManager +from .runtime import PluginRuntime +from .storage import ( + PluginStorage, + resolve_catalogue_url, + resolve_plugin_socket_path, + resolve_plugins_root, +) + + +def _load_config(config_path: Optional[str]) -> dict[str, Any]: + if not config_path: + return {} + try: + from repeater.config import load_config + + return load_config(config_path) + except Exception: + # Fall back to raw YAML if full load_config has radio side-effects + import yaml + + path = Path(config_path) + if not path.is_file(): + return {} + with open(path, encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + return data if isinstance(data, dict) else {} + + +def build_arg_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser(description="openHop plugin manager") + p.add_argument("--config", default=None, help="Path to Repeater config.yaml") + p.add_argument( + "--plugins-root", + default=None, + help="Override plugin install root (default: {storage_dir}/plugins)", + ) + p.add_argument( + "--socket", + default=None, + help="Unix domain socket path (default: {storage_dir}/plugin-manager.sock)", + ) + p.add_argument( + "--log-level", + default="INFO", + help="Logging level (default: INFO)", + ) + return p + + +def main(argv: Optional[list[str]] = None) -> int: + args = build_arg_parser().parse_args(argv) + logging.basicConfig( + level=getattr(logging, str(args.log_level).upper(), logging.INFO), + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + ) + logger = logging.getLogger("PluginManagerMain") + + config = _load_config(args.config) + plugins_cfg = config.get("plugins") if isinstance(config.get("plugins"), dict) else {} + if plugins_cfg.get("enabled") is False: + logger.error("plugins.enabled is false in config; refusing to start") + return 2 + + if args.plugins_root: + plugins_root = Path(args.plugins_root).expanduser().resolve() + else: + plugins_root = resolve_plugins_root(config) + + if args.socket: + socket_path = Path(args.socket).expanduser().resolve() + else: + socket_path = resolve_plugin_socket_path(config) + + storage = PluginStorage(plugins_root) + runtime = PluginRuntime(storage) + manager = PluginManager(storage, runtime, catalogue_url=resolve_catalogue_url(config)) + server = PluginIPCServer(socket_path, manager) + + stop = {"flag": False} + + def _handle_signal(signum, frame): # noqa: ARG001 + logger.info("Signal %s received, shutting down", signum) + stop["flag"] = True + + for sig in (signal.SIGINT, signal.SIGTERM): + try: + signal.signal(sig, _handle_signal) + except Exception: + pass + + logger.info("Plugin root: %s", plugins_root) + logger.info("IPC socket: %s", socket_path) + manager.start() + server.start() + + try: + while not stop["flag"]: + time.sleep(0.5) + finally: + logger.info("Stopping plugin manager") + try: + server.stop() + except Exception as exc: + logger.debug("IPC stop: %s", exc) + try: + manager.stop_all() + except Exception as exc: + logger.debug("manager stop_all: %s", exc) + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/repeater/plugins/catalogue.py b/repeater/plugins/catalogue.py new file mode 100644 index 0000000..1b96b5f --- /dev/null +++ b/repeater/plugins/catalogue.py @@ -0,0 +1,226 @@ +"""Curated openHop plugin catalogue client (static JSON over HTTPS).""" + +from __future__ import annotations + +import json +import logging +import re +import ssl +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any, Callable, Optional + +logger = logging.getLogger("PluginCatalogue") + +DEFAULT_CATALOGUE_URL = ( + "https://raw.githubusercontent.com/openhop-dev/openhop-plugin-catalogue/main/catalogue.json" +) +DEFAULT_CACHE_TTL_SECONDS = 600 # 10 minutes +SUPPORTED_SCHEMA = 1 +USER_AGENT = "openhop-repeater-plugin-manager/1.0" + +_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +_ssl_ctx: Optional[ssl.SSLContext] = None + + +class CatalogueError(Exception): + def __init__(self, message: str, *, code: int = 502): + super().__init__(message) + self.code = code + + +def _ssl_context() -> ssl.SSLContext: + global _ssl_ctx + if _ssl_ctx is None: + _ssl_ctx = ssl.create_default_context() + return _ssl_ctx + + +@dataclass(frozen=True) +class CataloguePlugin: + id: str + name: str + description: str + repository: str + category: str = "" + + def to_dict(self) -> dict[str, Any]: + out: dict[str, Any] = { + "id": self.id, + "name": self.name, + "description": self.description, + "repository": self.repository, + } + if self.category: + out["category"] = self.category + return out + + +@dataclass(frozen=True) +class Catalogue: + schema: int + plugins: tuple[CataloguePlugin, ...] + + def get(self, plugin_id: str) -> Optional[CataloguePlugin]: + for plugin in self.plugins: + if plugin.id == plugin_id: + return plugin + return None + + def to_dict(self) -> dict[str, Any]: + return { + "schema": self.schema, + "plugins": [p.to_dict() for p in self.plugins], + } + + +def parse_catalogue(data: Any) -> Catalogue: + if not isinstance(data, dict): + raise CatalogueError("catalogue must be a JSON object", code=400) + schema = data.get("schema") + if schema != SUPPORTED_SCHEMA: + raise CatalogueError( + f"unsupported catalogue schema {schema!r}; expected {SUPPORTED_SCHEMA}", + code=400, + ) + raw_plugins = data.get("plugins") + if not isinstance(raw_plugins, list): + raise CatalogueError("catalogue.plugins must be an array", code=400) + + plugins: list[CataloguePlugin] = [] + seen_ids: set[str] = set() + seen_repos: set[str] = set() + for idx, item in enumerate(raw_plugins): + if not isinstance(item, dict): + raise CatalogueError(f"plugins[{idx}] must be an object", code=400) + plugin_id = item.get("id") + name = item.get("name") + description = item.get("description", "") + repository = item.get("repository") + category = item.get("category", "") or "" + + if not isinstance(plugin_id, str) or not plugin_id.strip(): + raise CatalogueError(f"plugins[{idx}].id is required", code=400) + plugin_id = plugin_id.strip() + if not _ID_RE.match(plugin_id): + raise CatalogueError(f"plugins[{idx}].id is invalid: {plugin_id!r}", code=400) + if plugin_id in seen_ids: + raise CatalogueError(f"duplicate plugin id: {plugin_id}", code=400) + + if not isinstance(name, str) or not name.strip(): + raise CatalogueError(f"plugins[{idx}].name is required", code=400) + if description is None: + description = "" + if not isinstance(description, str): + raise CatalogueError(f"plugins[{idx}].description must be a string", code=400) + if not isinstance(repository, str) or not repository.strip(): + raise CatalogueError(f"plugins[{idx}].repository is required", code=400) + repository = repository.strip() + if not _REPO_RE.match(repository): + raise CatalogueError( + f"plugins[{idx}].repository must be owner/repo: {repository!r}", + code=400, + ) + if repository in seen_repos: + raise CatalogueError(f"duplicate repository: {repository}", code=400) + if category is None: + category = "" + if not isinstance(category, str): + raise CatalogueError(f"plugins[{idx}].category must be a string", code=400) + + seen_ids.add(plugin_id) + seen_repos.add(repository) + plugins.append( + CataloguePlugin( + id=plugin_id, + name=name.strip(), + description=description.strip(), + repository=repository, + category=category.strip(), + ) + ) + return Catalogue(schema=SUPPORTED_SCHEMA, plugins=tuple(plugins)) + + +class CatalogueClient: + """Fetch and cache the curated plugin catalogue JSON.""" + + def __init__( + self, + url: str = DEFAULT_CATALOGUE_URL, + *, + cache_ttl: float = DEFAULT_CACHE_TTL_SECONDS, + opener: Optional[Callable[..., Any]] = None, + user_agent: str = USER_AGENT, + ): + self.url = str(url or DEFAULT_CATALOGUE_URL).strip() or DEFAULT_CATALOGUE_URL + self.cache_ttl = cache_ttl + self._opener = opener or self._default_open + self.user_agent = user_agent + self._cached_at: float = 0.0 + self._cached: Optional[Catalogue] = None + + def _default_open(self, request: urllib.request.Request, timeout: float = 30.0): + return urllib.request.urlopen( # nosec B310 - configured catalogue URL + request, timeout=timeout, context=_ssl_context() + ) + + def clear_cache(self) -> None: + self._cached = None + self._cached_at = 0.0 + + def fetch(self, *, force_refresh: bool = False) -> Catalogue: + now = time.monotonic() + if ( + not force_refresh + and self._cached is not None + and (now - self._cached_at) < self.cache_ttl + ): + return self._cached + + request = urllib.request.Request( + self.url, + headers={ + "User-Agent": self.user_agent, + "Accept": "application/json", + }, + method="GET", + ) + try: + with self._opener(request, timeout=30.0) as resp: + body = resp.read() + except urllib.error.HTTPError as exc: + raise CatalogueError( + f"Plugin catalogue is currently unavailable (HTTP {exc.code})", + code=502, + ) from exc + except urllib.error.URLError as exc: + raise CatalogueError( + "Plugin catalogue is currently unavailable", + code=502, + ) from exc + except Exception as exc: + raise CatalogueError( + "Plugin catalogue is currently unavailable", + code=502, + ) from exc + + try: + data = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CatalogueError(f"invalid catalogue JSON: {exc}", code=502) from exc + + catalogue = parse_catalogue(data) + self._cached = catalogue + self._cached_at = now + return catalogue + + def get_plugin(self, plugin_id: str, *, force_refresh: bool = False) -> CataloguePlugin: + catalogue = self.fetch(force_refresh=force_refresh) + plugin = catalogue.get(plugin_id) + if plugin is None: + raise CatalogueError(f"plugin not in catalogue: {plugin_id}", code=404) + return plugin diff --git a/repeater/plugins/github_releases.py b/repeater/plugins/github_releases.py new file mode 100644 index 0000000..0deef90 --- /dev/null +++ b/repeater/plugins/github_releases.py @@ -0,0 +1,342 @@ +"""Minimal GitHub Releases client for plugin wheel discovery and download.""" + +from __future__ import annotations + +import json +import logging +import re +import ssl +import tempfile +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Optional + +logger = logging.getLogger("PluginGitHubReleases") + +GITHUB_API_BASE = "https://api.github.com" +DEFAULT_CACHE_TTL_SECONDS = 600 # 10 minutes +USER_AGENT = "openhop-repeater-plugin-manager/1.0" + +_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$") +_ssl_ctx: Optional[ssl.SSLContext] = None + + +class GitHubReleasesError(Exception): + """User-facing GitHub Releases failure.""" + + def __init__(self, message: str, *, code: int = 502): + super().__init__(message) + self.code = code + + +class GitHubRateLimitError(GitHubReleasesError): + def __init__(self, message: str = "GitHub API rate limit exceeded"): + super().__init__(message, code=429) + + +def _ssl_context() -> ssl.SSLContext: + global _ssl_ctx + if _ssl_ctx is None: + _ssl_ctx = ssl.create_default_context() + return _ssl_ctx + + +def normalize_repository(repository: str) -> str: + repo = str(repository or "").strip() + if repo.startswith("https://github.com/"): + repo = repo[len("https://github.com/") :] + if repo.startswith("http://github.com/"): + repo = repo[len("http://github.com/") :] + repo = repo.strip("/") + if repo.endswith(".git"): + repo = repo[: -len(".git")] + if not _REPO_RE.match(repo): + raise GitHubReleasesError(f"invalid repository format: {repository!r}", code=400) + return repo + + +def normalize_version(tag_or_version: str) -> str: + """Strip a leading ``v`` from release tags (``v1.2.3`` → ``1.2.3``).""" + value = str(tag_or_version or "").strip() + if value.lower().startswith("v") and len(value) > 1 and value[1].isdigit(): + return value[1:] + return value + + +def _parse_version_key(version: str): + try: + from packaging.version import Version + + return Version(normalize_version(version)) + except Exception: + return normalize_version(version) + + +@dataclass(frozen=True) +class ReleaseAsset: + name: str + browser_download_url: str + size: int = 0 + content_type: str = "" + + +@dataclass(frozen=True) +class PluginRelease: + tag: str + version: str + name: str + body: str + prerelease: bool + draft: bool + html_url: str + published_at: str + assets: tuple[ReleaseAsset, ...] + + def wheel_assets(self) -> list[ReleaseAsset]: + return [a for a in self.assets if a.name.endswith(".whl")] + + +def select_wheel_asset(release: PluginRelease) -> ReleaseAsset: + wheels = release.wheel_assets() + if not wheels: + raise GitHubReleasesError( + f"release {release.tag} has no .whl asset", + code=400, + ) + if len(wheels) > 1: + names = ", ".join(a.name for a in wheels) + raise GitHubReleasesError( + f"release {release.tag} has multiple .whl assets ({names}); " + "attach exactly one plugin wheel", + code=400, + ) + return wheels[0] + + +class GitHubReleaseClient: + """Fetch and cache GitHub Releases for ``owner/repo`` plugin projects.""" + + def __init__( + self, + *, + cache_ttl: float = DEFAULT_CACHE_TTL_SECONDS, + opener: Optional[Callable[..., Any]] = None, + user_agent: str = USER_AGENT, + ): + self.cache_ttl = cache_ttl + self._opener = opener or self._default_open + self.user_agent = user_agent + # repo -> (monotonic_ts, list[PluginRelease]) + self._cache: dict[str, tuple[float, list[PluginRelease]]] = {} + + def _default_open(self, request: urllib.request.Request, timeout: float = 30.0): + return urllib.request.urlopen( # nosec B310 - https only to api.github.com / github + request, timeout=timeout, context=_ssl_context() + ) + + def clear_cache(self, repository: Optional[str] = None) -> None: + if repository is None: + self._cache.clear() + return + self._cache.pop(normalize_repository(repository), None) + + def list_releases( + self, + repository: str, + *, + include_prereleases: bool = False, + force_refresh: bool = False, + ) -> list[PluginRelease]: + repo = normalize_repository(repository) + now = time.monotonic() + if not force_refresh and repo in self._cache: + ts, cached = self._cache[repo] + if (now - ts) < self.cache_ttl: + return self._filter(cached, include_prereleases=include_prereleases) + + raw = self._fetch_releases_json(repo) + releases = [self._parse_release(item) for item in raw if isinstance(item, dict)] + # Keep drafts out of cache entirely + releases = [r for r in releases if not r.draft] + releases.sort(key=lambda r: _parse_version_key(r.version), reverse=True) + self._cache[repo] = (now, releases) + return self._filter(releases, include_prereleases=include_prereleases) + + @staticmethod + def _filter(releases: list[PluginRelease], *, include_prereleases: bool) -> list[PluginRelease]: + if include_prereleases: + return list(releases) + return [r for r in releases if not r.prerelease] + + def latest_stable( + self, repository: str, *, force_refresh: bool = False + ) -> Optional[PluginRelease]: + releases = self.list_releases( + repository, include_prereleases=False, force_refresh=force_refresh + ) + return releases[0] if releases else None + + def find_release( + self, + repository: str, + version: str, + *, + include_prereleases: bool = False, + force_refresh: bool = False, + ) -> PluginRelease: + target = normalize_version(version) + releases = self.list_releases( + repository, + include_prereleases=include_prereleases, + force_refresh=force_refresh, + ) + for rel in releases: + if normalize_version(rel.version) == target or rel.tag == version: + return rel + raise GitHubReleasesError( + f"release version {version!r} not found for {repository}", + code=404, + ) + + def download_wheel( + self, + release: PluginRelease, + dest_dir: Path | str, + *, + asset: Optional[ReleaseAsset] = None, + ) -> Path: + """Download the release wheel into dest_dir; return the local path.""" + dest_dir = Path(dest_dir) + dest_dir.mkdir(parents=True, exist_ok=True) + chosen = asset or select_wheel_asset(release) + dest = dest_dir / Path(chosen.name).name + if not dest.name.endswith(".whl"): + raise GitHubReleasesError(f"refusing non-wheel asset: {chosen.name}", code=400) + + request = urllib.request.Request( + chosen.browser_download_url, + headers={ + "User-Agent": self.user_agent, + "Accept": "application/octet-stream", + }, + method="GET", + ) + try: + with self._opener(request, timeout=120.0) as resp: + data = resp.read() + except GitHubReleasesError: + raise + except urllib.error.HTTPError as exc: + raise self._map_http_error(exc) from exc + except urllib.error.URLError as exc: + raise GitHubReleasesError(f"failed to download wheel: {exc.reason}", code=502) from exc + except Exception as exc: + raise GitHubReleasesError(f"failed to download wheel: {exc}", code=502) from exc + + if not data: + raise GitHubReleasesError("downloaded wheel is empty", code=502) + dest.write_bytes(data) + return dest + + def download_latest_wheel( + self, + repository: str, + dest_dir: Path | str, + *, + version: Optional[str] = None, + force_refresh: bool = False, + ) -> tuple[PluginRelease, Path]: + if version: + release = self.find_release(repository, version, force_refresh=force_refresh) + else: + release = self.latest_stable(repository, force_refresh=force_refresh) + if release is None: + raise GitHubReleasesError(f"no stable releases found for {repository}", code=404) + path = self.download_wheel(release, dest_dir) + return release, path + + def _fetch_releases_json(self, repo: str) -> list[Any]: + url = f"{GITHUB_API_BASE}/repos/{repo}/releases?per_page=30" + request = urllib.request.Request( + url, + headers={ + "User-Agent": self.user_agent, + "Accept": "application/vnd.github+json", + }, + method="GET", + ) + try: + with self._opener(request, timeout=30.0) as resp: + body = resp.read() + except GitHubReleasesError: + raise + except urllib.error.HTTPError as exc: + raise self._map_http_error(exc) from exc + except urllib.error.URLError as exc: + raise GitHubReleasesError( + f"GitHub Releases unavailable: {exc.reason}", code=502 + ) from exc + except Exception as exc: + raise GitHubReleasesError(f"GitHub Releases unavailable: {exc}", code=502) from exc + + try: + data = json.loads(body.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise GitHubReleasesError(f"invalid GitHub Releases response: {exc}", code=502) from exc + if not isinstance(data, list): + raise GitHubReleasesError("unexpected GitHub Releases response shape", code=502) + return data + + @staticmethod + def _map_http_error(exc: urllib.error.HTTPError) -> GitHubReleasesError: + if exc.code == 403: + # Rate limit or auth — treat 403 with rate-limit headers as 429 + remaining = exc.headers.get("X-RateLimit-Remaining") if exc.headers else None + if remaining == "0" or "rate limit" in str(exc.reason).lower(): + return GitHubRateLimitError("GitHub API rate limit exceeded; try again later") + return GitHubReleasesError(f"GitHub API forbidden ({exc.reason})", code=403) + if exc.code == 404: + return GitHubReleasesError("GitHub repository or releases not found", code=404) + return GitHubReleasesError(f"GitHub API error HTTP {exc.code}: {exc.reason}", code=502) + + @staticmethod + def _parse_release(item: dict[str, Any]) -> PluginRelease: + tag = str(item.get("tag_name") or "").strip() + assets_raw = item.get("assets") or [] + assets: list[ReleaseAsset] = [] + if isinstance(assets_raw, list): + for raw in assets_raw: + if not isinstance(raw, dict): + continue + name = str(raw.get("name") or "").strip() + url = str(raw.get("browser_download_url") or "").strip() + if not name or not url: + continue + assets.append( + ReleaseAsset( + name=name, + browser_download_url=url, + size=int(raw.get("size") or 0), + content_type=str(raw.get("content_type") or ""), + ) + ) + return PluginRelease( + tag=tag, + version=normalize_version(tag), + name=str(item.get("name") or tag), + body=str(item.get("body") or ""), + prerelease=bool(item.get("prerelease")), + draft=bool(item.get("draft")), + html_url=str(item.get("html_url") or ""), + published_at=str(item.get("published_at") or ""), + assets=tuple(assets), + ) + + +def make_download_temp_dir(parent: Path | str) -> Path: + parent = Path(parent) + parent.mkdir(parents=True, exist_ok=True) + return Path(tempfile.mkdtemp(prefix="openhop-plugin-download-", dir=str(parent))) diff --git a/repeater/plugins/ipc.py b/repeater/plugins/ipc.py new file mode 100644 index 0000000..01fa9a2 --- /dev/null +++ b/repeater/plugins/ipc.py @@ -0,0 +1,401 @@ +"""JSON-lines Unix domain socket IPC between Repeater and the plugin manager.""" + +from __future__ import annotations + +import json +import logging +import os +import socket +import threading +from pathlib import Path +from typing import Any, Callable, Optional + +from .manager import PluginManager, PluginManagerError + +logger = logging.getLogger("PluginIPC") + +DEFAULT_TIMEOUT = 30.0 +MAX_LINE_BYTES = 8 * 1024 * 1024 # install paths are short; keep headroom + + +class PluginIPCError(Exception): + def __init__(self, message: str, code: int = 500): + super().__init__(message) + self.code = code + + +class PluginManagerUnavailable(PluginIPCError): + def __init__(self, message: str = "plugin manager unavailable"): + super().__init__(message, code=503) + + +def _read_line(conn: socket.socket, max_bytes: int = MAX_LINE_BYTES) -> bytes: + buf = bytearray() + while True: + chunk = conn.recv(4096) + if not chunk: + break + buf.extend(chunk) + if b"\n" in chunk: + break + if len(buf) > max_bytes: + raise PluginIPCError("IPC message too large", 413) + if not buf: + return b"" + # Take first line only + line, _, _rest = bytes(buf).partition(b"\n") + return line + + +def _send_msg(conn: socket.socket, payload: dict[str, Any]) -> None: + data = (json.dumps(payload, separators=(",", ":")) + "\n").encode("utf-8") + conn.sendall(data) + + +class PluginIPCServer: + """Serve plugin manager operations over a Unix domain socket.""" + + def __init__(self, socket_path: Path | str, manager: PluginManager): + self.socket_path = Path(socket_path) + self.manager = manager + self._sock: Optional[socket.socket] = None + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + self._handlers: dict[str, Callable[[dict[str, Any]], Any]] = { + "list": self._op_list, + "status": self._op_status, + "install": self._op_install, + "enable": self._op_enable, + "disable": self._op_disable, + "start": self._op_start, + "stop": self._op_stop, + "restart": self._op_restart, + "logs": self._op_logs, + "uninstall": self._op_uninstall, + "ui_info": self._op_ui_info, + "get_config": self._op_get_config, + "set_config": self._op_set_config, + "catalogue": self._op_catalogue, + "catalogue_install": self._op_catalogue_install, + "check_update": self._op_check_update, + "update": self._op_update, + "ping": self._op_ping, + } + + def start(self) -> None: + self.socket_path.parent.mkdir(parents=True, exist_ok=True) + if self.socket_path.exists(): + try: + self.socket_path.unlink() + except OSError as exc: + logger.warning("Could not remove stale socket %s: %s", self.socket_path, exc) + + self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self._sock.bind(str(self.socket_path)) + try: + os.chmod(self.socket_path, 0o660) + except OSError: + pass + self._sock.listen(16) + self._sock.settimeout(1.0) + self._stop.clear() + self._thread = threading.Thread(target=self._serve_loop, name="plugin-ipc", daemon=True) + self._thread.start() + logger.info("Plugin manager IPC listening on %s", self.socket_path) + + def stop(self) -> None: + self._stop.set() + sock = self._sock + self._sock = None + if sock is not None: + try: + sock.close() + except OSError: + pass + thread = self._thread + if thread and thread.is_alive(): + thread.join(timeout=3) + self._thread = None + if self.socket_path.exists(): + try: + self.socket_path.unlink() + except OSError: + pass + + def _serve_loop(self) -> None: + assert self._sock is not None + while not self._stop.is_set(): + try: + conn, _ = self._sock.accept() + except socket.timeout: + continue + except OSError: + if self._stop.is_set(): + break + continue + try: + self._handle_connection(conn) + except Exception as exc: + logger.debug("IPC connection error: %s", exc) + finally: + try: + conn.close() + except OSError: + pass + + def _handle_connection(self, conn: socket.socket) -> None: + conn.settimeout(DEFAULT_TIMEOUT) + raw = _read_line(conn) + if not raw: + return + req_id: Any = None + try: + request = json.loads(raw.decode("utf-8")) + if not isinstance(request, dict): + raise PluginIPCError("request must be a JSON object", 400) + req_id = request.get("id") + op = request.get("op") + if not isinstance(op, str) or op not in self._handlers: + raise PluginIPCError(f"unknown op: {op!r}", 400) + result = self._handlers[op](request) + _send_msg(conn, {"id": req_id, "ok": True, "result": result}) + except PluginManagerError as exc: + _send_msg( + conn, + {"id": req_id, "ok": False, "error": str(exc), "code": int(exc.code)}, + ) + except PluginIPCError as exc: + _send_msg( + conn, + {"id": req_id, "ok": False, "error": str(exc), "code": int(exc.code)}, + ) + except Exception as exc: + logger.exception("IPC handler failure") + _send_msg( + conn, + {"id": req_id, "ok": False, "error": str(exc), "code": 500}, + ) + + def _op_ping(self, request: dict[str, Any]) -> dict[str, Any]: + return {"pong": True} + + def _op_list(self, request: dict[str, Any]) -> dict[str, Any]: + return {"plugins": self.manager.list_plugins()} + + def _require_id(self, request: dict[str, Any]) -> str: + plugin_id = request.get("plugin_id") or request.get("id_name") + # Avoid clashing with request correlation "id" + if plugin_id is None: + plugin_id = request.get("plugin") + if not isinstance(plugin_id, str) or not plugin_id.strip(): + raise PluginIPCError("plugin_id is required", 400) + return plugin_id.strip() + + def _op_status(self, request: dict[str, Any]) -> dict[str, Any]: + return self.manager.status(self._require_id(request)) + + def _op_install(self, request: dict[str, Any]) -> dict[str, Any]: + wheel_path = request.get("wheel_path") + if not isinstance(wheel_path, str) or not wheel_path.strip(): + raise PluginIPCError("wheel_path is required", 400) + return self.manager.install(wheel_path.strip()) + + def _op_enable(self, request: dict[str, Any]) -> dict[str, Any]: + return self.manager.enable(self._require_id(request)) + + def _op_disable(self, request: dict[str, Any]) -> dict[str, Any]: + return self.manager.disable(self._require_id(request)) + + def _op_start(self, request: dict[str, Any]) -> dict[str, Any]: + return self.manager.start_plugin(self._require_id(request)) + + def _op_stop(self, request: dict[str, Any]) -> dict[str, Any]: + return self.manager.stop_plugin(self._require_id(request)) + + def _op_restart(self, request: dict[str, Any]) -> dict[str, Any]: + return self.manager.restart_plugin(self._require_id(request)) + + def _op_logs(self, request: dict[str, Any]) -> dict[str, Any]: + tail = request.get("tail", 200) + try: + tail_i = int(tail) + except (TypeError, ValueError) as exc: + raise PluginIPCError("tail must be an integer", 400) from exc + return self.manager.logs(self._require_id(request), tail=tail_i) + + def _op_uninstall(self, request: dict[str, Any]) -> dict[str, Any]: + delete_data = bool(request.get("delete_data", False)) + return self.manager.uninstall(self._require_id(request), delete_data=delete_data) + + def _op_ui_info(self, request: dict[str, Any]) -> dict[str, Any]: + plugin_id = self._require_id(request) + status = self.manager.status(plugin_id) + root = self.manager.ui_root_for(plugin_id) + return { + **status, + "ui_root": str(root) if root else None, + } + + def _op_get_config(self, request: dict[str, Any]) -> dict[str, Any]: + return self.manager.get_config(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): + raise PluginIPCError("config must be a JSON object", 400) + restart = bool(request.get("restart", False)) + return self.manager.set_config(self._require_id(request), config, restart=restart) + + def _op_catalogue(self, request: dict[str, Any]) -> dict[str, Any]: + force = bool(request.get("force_refresh") or request.get("refresh")) + return self.manager.list_catalogue(force_refresh=force) + + def _op_catalogue_install(self, request: dict[str, Any]) -> dict[str, Any]: + plugin_id = self._require_id(request) + version = request.get("version") + if version is not None: + version = str(version).strip() or None + force = bool(request.get("force_refresh") or request.get("refresh")) + return self.manager.install_from_catalogue(plugin_id, version=version, force_refresh=force) + + def _op_check_update(self, request: dict[str, Any]) -> dict[str, Any]: + plugin_id = self._require_id(request) + force = bool(request.get("force_refresh") or request.get("refresh")) + return self.manager.check_update(plugin_id, force_refresh=force) + + def _op_update(self, request: dict[str, Any]) -> dict[str, Any]: + plugin_id = self._require_id(request) + version = request.get("version") + if version is not None: + version = str(version).strip() or None + force = bool(request.get("force_refresh") or request.get("refresh")) + return self.manager.update_plugin(plugin_id, version=version, force_refresh=force) + + +class PluginIPCClient: + """Client used by the Repeater HTTP layer.""" + + def __init__(self, socket_path: Path | str, timeout: float = DEFAULT_TIMEOUT): + self.socket_path = Path(socket_path) + self.timeout = timeout + self._next_id = 1 + self._lock = threading.Lock() + + def available(self) -> bool: + return self.socket_path.exists() + + def call(self, op: str, **params: Any) -> Any: + if not self.available(): + raise PluginManagerUnavailable() + + with self._lock: + req_id = self._next_id + self._next_id += 1 + + request = {"id": req_id, "op": op, **params} + try: + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.settimeout(self.timeout) + sock.connect(str(self.socket_path)) + except OSError as exc: + raise PluginManagerUnavailable(f"plugin manager unavailable: {exc}") from exc + + try: + _send_msg(sock, request) + raw = _read_line(sock) + if not raw: + raise PluginManagerUnavailable("empty response from plugin manager") + try: + response = json.loads(raw.decode("utf-8")) + except json.JSONDecodeError as exc: + raise PluginIPCError(f"invalid IPC response: {exc}", 502) from exc + if not isinstance(response, dict): + raise PluginIPCError("invalid IPC response type", 502) + if not response.get("ok"): + code = int(response.get("code") or 500) + err = str(response.get("error") or "plugin manager error") + raise PluginIPCError(err, code) + return response.get("result") + finally: + try: + sock.close() + except OSError: + pass + + def list_plugins(self) -> list[dict[str, Any]]: + result = self.call("list") or {} + return list(result.get("plugins") or []) + + def status(self, plugin_id: str) -> dict[str, Any]: + return self.call("status", plugin_id=plugin_id) + + def install(self, wheel_path: str) -> dict[str, Any]: + return self.call("install", wheel_path=wheel_path) + + def enable(self, plugin_id: str) -> dict[str, Any]: + return self.call("enable", plugin_id=plugin_id) + + def disable(self, plugin_id: str) -> dict[str, Any]: + return self.call("disable", plugin_id=plugin_id) + + def start(self, plugin_id: str) -> dict[str, Any]: + return self.call("start", plugin_id=plugin_id) + + def stop(self, plugin_id: str) -> dict[str, Any]: + return self.call("stop", plugin_id=plugin_id) + + def restart(self, plugin_id: str) -> dict[str, Any]: + return self.call("restart", plugin_id=plugin_id) + + def logs(self, plugin_id: str, tail: int = 200) -> dict[str, Any]: + return self.call("logs", plugin_id=plugin_id, tail=tail) + + def uninstall(self, plugin_id: str, delete_data: bool = False) -> dict[str, Any]: + return self.call("uninstall", plugin_id=plugin_id, delete_data=delete_data) + + def ui_info(self, plugin_id: str) -> dict[str, Any]: + return self.call("ui_info", plugin_id=plugin_id) + + def get_config(self, plugin_id: str) -> dict[str, Any]: + return self.call("get_config", plugin_id=plugin_id) + + def set_config( + self, plugin_id: str, config: dict[str, Any], *, restart: bool = False + ) -> dict[str, Any]: + return self.call("set_config", plugin_id=plugin_id, config=config, restart=restart) + + def catalogue(self, *, force_refresh: bool = False) -> dict[str, Any]: + return self.call("catalogue", force_refresh=bool(force_refresh)) + + def catalogue_install( + self, + plugin_id: str, + *, + version: Optional[str] = None, + force_refresh: bool = False, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "plugin_id": plugin_id, + "force_refresh": bool(force_refresh), + } + if version: + payload["version"] = version + return self.call("catalogue_install", **payload) + + def check_update(self, plugin_id: str, *, force_refresh: bool = False) -> dict[str, Any]: + return self.call("check_update", plugin_id=plugin_id, force_refresh=bool(force_refresh)) + + def update( + self, + plugin_id: str, + *, + version: Optional[str] = None, + force_refresh: bool = False, + ) -> dict[str, Any]: + payload: dict[str, Any] = { + "plugin_id": plugin_id, + "force_refresh": bool(force_refresh), + } + if version: + payload["version"] = version + return self.call("update", **payload) diff --git a/repeater/plugins/manager.py b/repeater/plugins/manager.py new file mode 100644 index 0000000..b0d5338 --- /dev/null +++ b/repeater/plugins/manager.py @@ -0,0 +1,532 @@ +"""Plugin lifecycle orchestration.""" + +from __future__ import annotations + +import json +import logging +import threading +from pathlib import Path +from typing import Any, Optional + +import shutil + +from .catalogue import CatalogueClient, CatalogueError, DEFAULT_CATALOGUE_URL +from .github_releases import ( + GitHubReleaseClient, + GitHubReleasesError, + make_download_temp_dir, + normalize_version, +) +from .manifest import ManifestError, load_manifest_from_wheel +from .runtime import PluginRuntime, PluginState +from .storage import PluginStorage + +logger = logging.getLogger("PluginManager") + + +class PluginManagerError(Exception): + """Base error for plugin manager operations.""" + + def __init__(self, message: str, code: int = 400): + super().__init__(message) + self.code = code + + +class PluginManager: + """High-level install / enable / start / stop / uninstall API.""" + + def __init__( + self, + storage: PluginStorage, + runtime: Optional[PluginRuntime] = None, + *, + catalogue_client: Optional[CatalogueClient] = None, + github_client: Optional[GitHubReleaseClient] = None, + catalogue_url: Optional[str] = None, + ): + self.storage = storage + self.runtime = runtime or PluginRuntime(storage) + self._lock = threading.RLock() + self.catalogue = catalogue_client or CatalogueClient(catalogue_url or DEFAULT_CATALOGUE_URL) + self.github = github_client or GitHubReleaseClient() + + def start(self) -> None: + """Load state and start enabled service plugins.""" + self.storage.ensure_root() + self.runtime.start_supervisor() + for plugin_id in self.storage.list_plugin_ids(): + state = self.storage.read_state(plugin_id) or {} + if not state.get("enabled", False): + self.runtime._runtime_state[plugin_id] = PluginState.DISABLED + continue + manifest = self.storage.load_current_manifest(plugin_id) + if manifest is None or manifest.runtime is None: + self.runtime._runtime_state[plugin_id] = PluginState.STOPPED + continue + try: + self.runtime.start(plugin_id) + except Exception as exc: + logger.error("Failed to start enabled plugin %s: %s", plugin_id, exc) + self.runtime._runtime_state[plugin_id] = PluginState.FAILED + + def stop_all(self) -> None: + for plugin_id in list(self.storage.list_plugin_ids()): + try: + self.runtime.stop(plugin_id) + except Exception as exc: + logger.debug("stop_all %s: %s", plugin_id, exc) + self.runtime.stop_supervisor() + + def list_plugins(self) -> list[dict[str, Any]]: + result = [] + for plugin_id in self.storage.list_plugin_ids(): + try: + result.append(self.runtime.status_dict(plugin_id)) + except KeyError: + continue + return result + + def status(self, plugin_id: str) -> dict[str, Any]: + try: + return self.runtime.status_dict(plugin_id) + except KeyError as exc: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) from exc + + def install(self, wheel_path: str | Path) -> dict[str, Any]: + wheel_path = Path(wheel_path) + try: + manifest = load_manifest_from_wheel(wheel_path) + except ManifestError as exc: + raise PluginManagerError(str(exc), 400) from exc + with self._lock: + try: + info = self.runtime.install_wheel(wheel_path, manifest) + except Exception as exc: + raise PluginManagerError(f"install failed: {exc}", 500) from exc + logger.info("Installed plugin %s@%s", info["id"], info["version"]) + # Return full status + return self.status(info["id"]) + + def enable(self, plugin_id: str) -> dict[str, Any]: + with self._lock: + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + state = dict(state) + state["enabled"] = True + self.storage.write_state(plugin_id, state) + manifest = self.storage.load_current_manifest(plugin_id) + if manifest and manifest.runtime is not None: + try: + self.runtime.start(plugin_id) + except Exception as exc: + raise PluginManagerError(f"enable start failed: {exc}", 500) from exc + else: + self.runtime._runtime_state[plugin_id] = PluginState.STOPPED + return self.status(plugin_id) + + def disable(self, plugin_id: str) -> dict[str, Any]: + with self._lock: + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + try: + self.runtime.stop(plugin_id, mark_disabled=True) + except Exception as exc: + logger.warning("disable stop error for %s: %s", plugin_id, exc) + state = dict(state) + state["enabled"] = False + self.storage.write_state(plugin_id, state) + self.runtime._runtime_state[plugin_id] = PluginState.DISABLED + return self.status(plugin_id) + + def start_plugin(self, plugin_id: str) -> dict[str, Any]: + with self._lock: + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + if not state.get("enabled", False): + raise PluginManagerError( + f"plugin is disabled; enable it before starting: {plugin_id}", 409 + ) + try: + self.runtime.start(plugin_id) + except Exception as exc: + raise PluginManagerError(f"start failed: {exc}", 500) from exc + return self.status(plugin_id) + + def stop_plugin(self, plugin_id: str) -> dict[str, Any]: + with self._lock: + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + try: + self.runtime.stop(plugin_id) + except Exception as exc: + raise PluginManagerError(f"stop failed: {exc}", 500) from exc + return self.status(plugin_id) + + def restart_plugin(self, plugin_id: str) -> dict[str, Any]: + with self._lock: + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + if not state.get("enabled", False): + raise PluginManagerError( + f"plugin is disabled; enable it before restarting: {plugin_id}", 409 + ) + try: + self.runtime.restart(plugin_id) + except Exception as exc: + raise PluginManagerError(f"restart failed: {exc}", 500) from exc + return self.status(plugin_id) + + def logs(self, plugin_id: str, tail: int = 200) -> dict[str, Any]: + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + lines = self.storage.tail_log(plugin_id, lines=tail) + return {"id": plugin_id, "lines": lines, "tail": tail} + + def get_config(self, plugin_id: str) -> dict[str, Any]: + """Return plugin config for the editor. + + ``config`` is the value to show in the UI: saved data/config.json if + present, otherwise plugin-declared defaults (manifest / config.default.json). + ``saved`` is only what is on disk; ``defaults`` is the plugin template. + """ + 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: + saved = self.storage.read_config(plugin_id) + except ValueError as exc: + raise PluginManagerError(str(exc), 500) from exc + defaults = self.runtime.resolve_config_defaults(plugin_id) + exists = self.storage.config_path(plugin_id).is_file() + # Editor payload: prefer saved file; fall back to defaults when empty/missing + if exists and saved: + config = saved + elif exists and not saved and not defaults: + config = {} + elif not exists: + config = dict(defaults) + else: + # Empty saved file — still show defaults as a helpful starting point + config = dict(defaults) if defaults else saved + return { + "id": plugin_id, + "path": str(self.storage.config_path(plugin_id)), + "exists": exists, + "defaults": defaults, + "saved": saved, + "config": config, + } + + def set_config( + self, + plugin_id: str, + config: dict[str, Any], + *, + restart: bool = False, + ) -> dict[str, Any]: + """Write plugin-owned data/config.json. Optionally restart a service plugin.""" + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + if not isinstance(config, dict): + raise PluginManagerError("config must be a JSON object", 400) + # Soft size guard — prevents accidental huge payloads + try: + encoded = json.dumps(config) + except (TypeError, ValueError) as exc: + raise PluginManagerError(f"config is not JSON-serializable: {exc}", 400) from exc + if len(encoded.encode("utf-8")) > 256 * 1024: + raise PluginManagerError("config exceeds 256 KiB limit", 413) + + with self._lock: + try: + self.storage.write_config(plugin_id, config) + except ValueError as exc: + raise PluginManagerError(str(exc), 400) from exc + + restarted = False + if restart and state.get("enabled", False): + manifest = self.storage.load_current_manifest(plugin_id) + if manifest and manifest.runtime is not None: + try: + self.runtime.restart(plugin_id) + restarted = True + except Exception as exc: + raise PluginManagerError( + f"config saved but restart failed: {exc}", 500 + ) from exc + + result = self.get_config(plugin_id) + result["restarted"] = restarted + return result + + def uninstall(self, plugin_id: str, *, delete_data: bool = False) -> dict[str, Any]: + with self._lock: + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + try: + self.runtime.stop(plugin_id, mark_disabled=True) + except Exception as exc: + logger.warning("uninstall stop error for %s: %s", plugin_id, exc) + self.storage.remove_release_code(plugin_id, keep_data=not delete_data) + self.runtime._runtime_state.pop(plugin_id, None) + self.runtime._crash_times.pop(plugin_id, None) + self.runtime._exit_codes.pop(plugin_id, None) + return { + "id": plugin_id, + "uninstalled": True, + "data_deleted": bool(delete_data), + } + + def _version_gt(self, left: str, right: str) -> bool: + """Return True if left > right using packaging.version when available.""" + try: + from packaging.version import Version + + return Version(normalize_version(left)) > Version(normalize_version(right)) + except Exception: + return normalize_version(left) > normalize_version(right) + + def _download_dir(self) -> Path: + return self.storage.root / ".download" + + def list_catalogue(self, *, force_refresh: bool = False) -> dict[str, Any]: + """Fetch catalogue entries and annotate with install/update info.""" + try: + catalogue = self.catalogue.fetch(force_refresh=force_refresh) + except CatalogueError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + + plugins_out: list[dict[str, Any]] = [] + for entry in catalogue.plugins: + installed_state = self.storage.read_state(entry.id) + installed = installed_state is not None + installed_version = ( + str(installed_state.get("version") or "") if installed_state else None + ) + row: dict[str, Any] = { + **entry.to_dict(), + "installed": installed, + "installedVersion": installed_version if installed else None, + } + # Enrich with latest stable when possible (cached); failures are non-fatal per row + latest_version = None + update_available = False + try: + latest = self.github.latest_stable(entry.repository, force_refresh=force_refresh) + if latest is not None: + latest_version = latest.version + if installed and installed_version: + update_available = self._version_gt(latest_version, installed_version) + except GitHubReleasesError as exc: + row["releasesError"] = str(exc) + row["latestVersion"] = latest_version + row["updateAvailable"] = bool(update_available) + plugins_out.append(row) + return {"schema": catalogue.schema, "plugins": plugins_out} + + def install_from_catalogue( + self, + plugin_id: str, + *, + version: Optional[str] = None, + force_refresh: bool = False, + ) -> dict[str, Any]: + """Download catalogue plugin wheel and install via existing installer.""" + try: + entry = self.catalogue.get_plugin(plugin_id, force_refresh=force_refresh) + except CatalogueError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + + download_root = self._download_dir() + staging = make_download_temp_dir(download_root) + try: + try: + release, wheel_path = self.github.download_latest_wheel( + entry.repository, + staging, + version=version, + force_refresh=force_refresh, + ) + except GitHubReleasesError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + + try: + manifest = load_manifest_from_wheel(wheel_path) + except ManifestError as exc: + raise PluginManagerError(str(exc), 400) from exc + + if manifest.id != entry.id: + raise PluginManagerError( + f"manifest id {manifest.id!r} does not match catalogue id {entry.id!r}", + 400, + ) + + with self._lock: + try: + info = self.runtime.install_wheel(wheel_path, manifest) + except Exception as exc: + raise PluginManagerError(f"install failed: {exc}", 500) from exc + # Record catalogue provenance (repository locked at install time) + st = self.storage.read_state(info["id"]) or {} + st = dict(st) + st["version"] = info["version"] + st["source"] = "catalogue" + st["repository"] = entry.repository + # Keep enabled flag from install_wheel; enable next + self.storage.write_state(info["id"], st) + + # Catalogue installs are enabled by default + return self.enable(info["id"]) + finally: + shutil.rmtree(staging, ignore_errors=True) + + def check_update(self, plugin_id: str, *, force_refresh: bool = False) -> dict[str, Any]: + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + installed_version = str(state.get("version") or "") + repository = state.get("repository") + if not repository: + return { + "id": plugin_id, + "installedVersion": installed_version or None, + "latestVersion": None, + "updateAvailable": False, + "repository": None, + "updatesAvailable": False, + "reason": "repository unknown (local install); update check unavailable", + } + + try: + latest = self.github.latest_stable(str(repository), force_refresh=force_refresh) + except GitHubReleasesError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + + latest_version = latest.version if latest else None + update_available = bool( + latest_version + and installed_version + and self._version_gt(latest_version, installed_version) + ) + notes = latest.body if latest and update_available else None + return { + "id": plugin_id, + "installedVersion": installed_version or None, + "latestVersion": latest_version, + "updateAvailable": update_available, + "repository": str(repository), + "releaseNotes": notes, + "releaseUrl": latest.html_url if latest else None, + "releaseTag": latest.tag if latest else None, + } + + def update_plugin( + self, + plugin_id: str, + *, + version: Optional[str] = None, + force_refresh: bool = False, + ) -> dict[str, Any]: + """Download a newer (or pinned) release and reinstall via existing installer.""" + state = self.storage.read_state(plugin_id) + if state is None: + raise PluginManagerError(f"plugin not found: {plugin_id}", 404) + repository = state.get("repository") + if not repository: + raise PluginManagerError( + "update unavailable: plugin has no repository metadata " + "(reinstall from catalogue or set repository)", + 400, + ) + repository = str(repository) + installed_version = str(state.get("version") or "") + was_enabled = bool(state.get("enabled", False)) + + # Resolve target release + try: + if version: + release = self.github.find_release(repository, version, force_refresh=force_refresh) + else: + release = self.github.latest_stable(repository, force_refresh=force_refresh) + if release is None: + raise PluginManagerError(f"no stable releases found for {repository}", 404) + if installed_version and not self._version_gt(release.version, installed_version): + # Already up to date — return status without changing + status = self.status(plugin_id) + status["updateAvailable"] = False + status["latestVersion"] = release.version + status["updated"] = False + return status + except GitHubReleasesError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + + download_root = self._download_dir() + staging = make_download_temp_dir(download_root) + try: + try: + wheel_path = self.github.download_wheel(release, staging) + except GitHubReleasesError as exc: + raise PluginManagerError(str(exc), int(exc.code)) from exc + + try: + manifest = load_manifest_from_wheel(wheel_path) + except ManifestError as exc: + raise PluginManagerError(str(exc), 400) from exc + if manifest.id != plugin_id: + raise PluginManagerError( + f"manifest id {manifest.id!r} does not match installed id {plugin_id!r}", + 400, + ) + + with self._lock: + # Stop running process before swapping release + try: + self.runtime.stop(plugin_id) + except Exception as exc: + logger.debug("update stop %s: %s", plugin_id, exc) + try: + info = self.runtime.install_wheel(wheel_path, manifest) + except Exception as exc: + raise PluginManagerError(f"update install failed: {exc}", 500) from exc + st = self.storage.read_state(info["id"]) or {} + st = dict(st) + st["version"] = info["version"] + st["source"] = st.get("source") or "catalogue" + st["repository"] = repository # never silently retarget + st["enabled"] = was_enabled + self.storage.write_state(info["id"], st) + + if was_enabled: + return self.enable(plugin_id) + result = self.status(plugin_id) + result["updated"] = True + result["latestVersion"] = release.version + return result + finally: + shutil.rmtree(staging, ignore_errors=True) + + def ui_root_for(self, plugin_id: str) -> Optional[Path]: + """Return the release directory for an enabled UI application plugin.""" + state = self.storage.read_state(plugin_id) + if state is None or not state.get("enabled", False): + return None + manifest = self.storage.load_current_manifest(plugin_id) + if manifest is None or manifest.ui is None: + return None + paths = self.storage.paths_for(plugin_id) + if paths.current_link.exists() or paths.current_link.is_symlink(): + return paths.current_link.resolve() + version = state.get("version") + if version: + return paths.release_dir(str(version)) + return None diff --git a/repeater/plugins/manifest.py b/repeater/plugins/manifest.py new file mode 100644 index 0000000..0070c62 --- /dev/null +++ b/repeater/plugins/manifest.py @@ -0,0 +1,255 @@ +"""Plugin manifest parsing and validation (schema 1).""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +PLUGIN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$") +VERSION_RE = re.compile( + r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:[-+][0-9A-Za-z.-]+)?$" +) +ENTRYPOINT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+-]*$") +SUPPORTED_SCHEMA = 1 +SUPPORTED_RUNTIME_TYPES = frozenset({"python"}) +SUPPORTED_UI_TYPES = frozenset({"application"}) +MANIFEST_FILENAME = "openhop-plugin.json" + + +class ManifestError(ValueError): + """Raised when a plugin manifest is invalid.""" + + +@dataclass(frozen=True) +class RuntimeSpec: + type: str + entrypoint: str + + +@dataclass(frozen=True) +class UISpec: + type: str + entry: str + + +@dataclass(frozen=True) +class PluginManifest: + schema: int + id: str + name: str + version: str + description: str = "" + runtime: Optional[RuntimeSpec] = None + ui: Optional[UISpec] = None + # Optional default values for data/config.json (opaque object). + config_defaults: Optional[dict[str, Any]] = None + + def to_dict(self) -> dict[str, Any]: + data: dict[str, Any] = { + "schema": self.schema, + "id": self.id, + "name": self.name, + "version": self.version, + } + if self.description: + data["description"] = self.description + if self.runtime is not None: + data["runtime"] = { + "type": self.runtime.type, + "entrypoint": self.runtime.entrypoint, + } + if self.ui is not None: + data["ui"] = { + "type": self.ui.type, + "entry": self.ui.entry, + } + if self.config_defaults is not None: + data["config"] = {"defaults": self.config_defaults} + return data + + +def _reject_path_traversal(value: str, field: str) -> None: + normalized = value.replace("\\", "/") + if normalized.startswith("/") or normalized.startswith("~"): + raise ManifestError(f"{field} must be a relative path") + parts = [p for p in normalized.split("/") if p not in ("", ".")] + if any(p == ".." for p in parts): + raise ManifestError(f"{field} must not contain parent-directory segments") + if "\x00" in value: + raise ManifestError(f"{field} contains a null byte") + + +def _validate_plugin_id(plugin_id: str) -> str: + if not isinstance(plugin_id, str) or not plugin_id.strip(): + raise ManifestError("id is required") + plugin_id = plugin_id.strip() + if ".." in plugin_id or "/" in plugin_id or "\\" in plugin_id: + raise ManifestError("id must not contain path separators or '..'") + if not PLUGIN_ID_RE.match(plugin_id): + raise ManifestError( + "id must match ^[a-z0-9][a-z0-9._-]*$ (lowercase letters, digits, . _ -)" + ) + if len(plugin_id) > 128: + raise ManifestError("id is too long") + return plugin_id + + +def _validate_version(version: str) -> str: + if not isinstance(version, str) or not version.strip(): + raise ManifestError("version is required") + version = version.strip() + if not VERSION_RE.match(version): + raise ManifestError("version must look like a semantic version (e.g. 1.2.3)") + return version + + +def _validate_runtime(raw: Any) -> RuntimeSpec: + if not isinstance(raw, dict): + raise ManifestError("runtime must be an object") + rtype = raw.get("type") + if rtype not in SUPPORTED_RUNTIME_TYPES: + raise ManifestError( + f"unsupported runtime.type {rtype!r}; supported: {sorted(SUPPORTED_RUNTIME_TYPES)}" + ) + entrypoint = raw.get("entrypoint") + if not isinstance(entrypoint, str) or not entrypoint.strip(): + raise ManifestError("runtime.entrypoint is required") + entrypoint = entrypoint.strip() + if "/" in entrypoint or "\\" in entrypoint or entrypoint in {".", ".."}: + raise ManifestError("runtime.entrypoint must be a bare console-script name") + if not ENTRYPOINT_RE.match(entrypoint): + raise ManifestError("runtime.entrypoint contains unsafe characters") + return RuntimeSpec(type=str(rtype), entrypoint=entrypoint) + + +def _validate_ui(raw: Any) -> UISpec: + if not isinstance(raw, dict): + raise ManifestError("ui must be an object") + utype = raw.get("type") + if utype not in SUPPORTED_UI_TYPES: + raise ManifestError( + f"unsupported ui.type {utype!r}; supported: {sorted(SUPPORTED_UI_TYPES)}" + ) + entry = raw.get("entry") + if not isinstance(entry, str) or not entry.strip(): + raise ManifestError("ui.entry is required") + entry = entry.strip().replace("\\", "/") + _reject_path_traversal(entry, "ui.entry") + if not entry: + raise ManifestError("ui.entry is required") + return UISpec(type=str(utype), entry=entry) + + +def parse_manifest(data: Any) -> PluginManifest: + """Validate and parse a schema-1 plugin manifest object.""" + if not isinstance(data, dict): + raise ManifestError("manifest must be a JSON object") + + schema = data.get("schema") + if schema != SUPPORTED_SCHEMA: + raise ManifestError(f"unsupported manifest schema {schema!r}; expected {SUPPORTED_SCHEMA}") + + plugin_id = _validate_plugin_id(data.get("id")) + name = data.get("name") + if not isinstance(name, str) or not name.strip(): + raise ManifestError("name is required") + name = name.strip() + version = _validate_version(data.get("version")) + + description = data.get("description", "") + if description is None: + description = "" + if not isinstance(description, str): + raise ManifestError("description must be a string") + + runtime_raw = data.get("runtime") + ui_raw = data.get("ui") + if runtime_raw is None and ui_raw is None: + raise ManifestError("manifest must declare runtime and/or ui") + + runtime = _validate_runtime(runtime_raw) if runtime_raw is not None else None + ui = _validate_ui(ui_raw) if ui_raw is not None else None + config_defaults = _validate_config_defaults(data.get("config")) + + return PluginManifest( + schema=SUPPORTED_SCHEMA, + id=plugin_id, + name=name, + version=version, + description=description.strip(), + runtime=runtime, + ui=ui, + config_defaults=config_defaults, + ) + + +def _validate_config_defaults(raw: Any) -> Optional[dict[str, Any]]: + """Parse optional config.defaults object from the manifest. + + Expected shape: + "config": { "defaults": { ... } } + """ + if raw is None: + return None + if not isinstance(raw, dict): + raise ManifestError("config must be an object") + defaults = raw.get("defaults") + if defaults is None: + return None + if not isinstance(defaults, dict): + raise ManifestError("config.defaults must be a JSON object") + try: + encoded = json.dumps(defaults) + except (TypeError, ValueError) as exc: + raise ManifestError(f"config.defaults is not JSON-serializable: {exc}") from exc + if len(encoded.encode("utf-8")) > 256 * 1024: + raise ManifestError("config.defaults exceeds 256 KiB") + return dict(defaults) + + +def load_manifest_file(path: Path | str) -> PluginManifest: + path = Path(path) + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise ManifestError(f"unable to read manifest: {exc}") from exc + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise ManifestError(f"invalid JSON in manifest: {exc}") from exc + return parse_manifest(data) + + +def load_manifest_from_wheel(wheel_path: Path | str) -> PluginManifest: + """Extract and validate openhop-plugin.json from a local wheel.""" + import zipfile + + wheel_path = Path(wheel_path) + if not wheel_path.is_file(): + raise ManifestError(f"wheel not found: {wheel_path}") + + try: + with zipfile.ZipFile(wheel_path) as zf: + candidates = [ + name + for name in zf.namelist() + if name.endswith(f"/{MANIFEST_FILENAME}") or name == MANIFEST_FILENAME + ] + if not candidates: + raise ManifestError(f"wheel does not contain {MANIFEST_FILENAME}") + # Prefer share/openhop/plugins/... layout, else first match + preferred = [c for c in candidates if "share/openhop/plugins/" in c] + chosen = preferred[0] if preferred else candidates[0] + raw = zf.read(chosen) + except zipfile.BadZipFile as exc: + raise ManifestError(f"invalid wheel archive: {exc}") from exc + + try: + data = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ManifestError(f"invalid manifest JSON in wheel: {exc}") from exc + return parse_manifest(data) diff --git a/repeater/plugins/runtime.py b/repeater/plugins/runtime.py new file mode 100644 index 0000000..2758e05 --- /dev/null +++ b/repeater/plugins/runtime.py @@ -0,0 +1,645 @@ +"""Virtualenv installation and process supervision for plugins.""" + +from __future__ import annotations + +import logging +import os +import signal +import subprocess # nosec B404 - argument arrays only, never shell=True +import sys +import threading +import time +from collections import deque +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path +from typing import Callable, Optional + +from .manifest import PluginManifest +from .storage import PluginPaths, PluginStorage + +logger = logging.getLogger("PluginRuntime") + +STOP_TIMEOUT_SECONDS = 5.0 +CRASH_WINDOW_SECONDS = 60.0 +CRASH_MAX_EXITS = 5 +LOG_MAX_BYTES = 5 * 1024 * 1024 # rotate simply by truncating when oversized + + +class PluginState(str, Enum): + DISABLED = "DISABLED" + STOPPED = "STOPPED" + STARTING = "STARTING" + RUNNING = "RUNNING" + STOPPING = "STOPPING" + FAILED = "FAILED" + + +@dataclass +class ProcessHandle: + plugin_id: str + process: subprocess.Popen + log_fp: object + started_at: float = field(default_factory=time.monotonic) + + +class PluginRuntime: + """Installs wheels into isolated venvs and supervises plugin processes.""" + + def __init__( + self, + storage: PluginStorage, + *, + python_executable: Optional[str] = None, + stop_timeout: float = STOP_TIMEOUT_SECONDS, + crash_window: float = CRASH_WINDOW_SECONDS, + crash_max_exits: int = CRASH_MAX_EXITS, + popen_factory: Optional[Callable[..., subprocess.Popen]] = None, + run_factory: Optional[Callable[..., subprocess.CompletedProcess]] = None, + ): + self.storage = storage + self.python_executable = python_executable or sys.executable + self.stop_timeout = stop_timeout + self.crash_window = crash_window + self.crash_max_exits = crash_max_exits + self._popen = popen_factory or subprocess.Popen + self._run = run_factory or subprocess.run + self._lock = threading.RLock() + self._handles: dict[str, ProcessHandle] = {} + self._runtime_state: dict[str, PluginState] = {} + self._crash_times: dict[str, deque[float]] = {} + self._exit_codes: dict[str, Optional[int]] = {} + self._supervise_stop = threading.Event() + self._supervise_thread: Optional[threading.Thread] = None + + # ------------------------------------------------------------------ # + # Installation + # ------------------------------------------------------------------ # + + def install_wheel(self, wheel_path: Path | str, manifest: PluginManifest) -> dict: + """Create release dir + venv and pip-install the wheel. Does not enable/start.""" + wheel_path = Path(wheel_path).resolve() + if not wheel_path.is_file(): + raise FileNotFoundError(f"wheel not found: {wheel_path}") + if not wheel_path.name.endswith(".whl"): + raise ValueError("install source must be a .whl file") + + plugin_id = manifest.id + version = manifest.version + paths = self.storage.ensure_plugin_layout(plugin_id, version) + self.storage.write_manifest(plugin_id, version, manifest) + + # Extract optional UI assets from the wheel into the release dir + self._extract_ui_assets(wheel_path, paths.release_dir(version), manifest) + # Also extract optional config.default.json next to the manifest if present + self._extract_config_default_file(wheel_path, paths.release_dir(version)) + + venv_path = paths.venv_dir(version) + if manifest.runtime is not None: + self._create_venv(venv_path) + self._pip_install(venv_path, wheel_path) + + # Seed data/config.json from defaults when the user has no saved config yet + self._seed_config_if_missing(plugin_id, manifest, paths.release_dir(version)) + + # Preserve enabled flag across reinstall + prev = self.storage.read_state(plugin_id) or {} + enabled = bool(prev.get("enabled", False)) + self.storage.set_current(plugin_id, version) + self.storage.write_state(plugin_id, {"version": version, "enabled": enabled}) + + with self._lock: + if plugin_id not in self._runtime_state: + self._runtime_state[plugin_id] = ( + PluginState.DISABLED if not enabled else PluginState.STOPPED + ) + + return { + "id": plugin_id, + "version": version, + "enabled": enabled, + "release_dir": str(paths.release_dir(version)), + "venv_dir": str(venv_path) if manifest.runtime else None, + "data_dir": str(paths.data_dir), + } + + def _create_venv(self, venv_path: Path) -> None: + if venv_path.exists(): + # Reinstall into existing venv is ok; recreate if broken + py = self._venv_python(venv_path) + if py.is_file(): + return + venv_path.parent.mkdir(parents=True, exist_ok=True) + result = self._run( + [self.python_executable, "-m", "venv", str(venv_path)], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError( + f"venv creation failed ({result.returncode}): {result.stderr or result.stdout}" + ) + + def _pip_install(self, venv_path: Path, wheel_path: Path) -> None: + py = self._venv_python(venv_path) + if not py.is_file(): + raise RuntimeError(f"venv python missing: {py}") + install_path = self._ensure_pip_wheel_filename(wheel_path) + try: + result = self._run( + [str(py), "-m", "pip", "install", "--upgrade", str(install_path)], + check=False, + capture_output=True, + text=True, + ) + finally: + if install_path != wheel_path and install_path.exists(): + try: + install_path.unlink() + except OSError: + pass + if result.returncode != 0: + raise RuntimeError( + f"pip install failed ({result.returncode}): {result.stderr or result.stdout}" + ) + + @staticmethod + def _looks_like_wheel_filename(name: str) -> bool: + # PEP 427: {distribution}-{version}(-{build})?-{py}-{abi}-{plat}.whl + if not name.endswith(".whl"): + return False + stem = name[: -len(".whl")] + return stem.count("-") >= 4 + + @classmethod + def _ensure_pip_wheel_filename(cls, wheel_path: Path) -> Path: + """Return a path whose basename pip will accept as a wheel filename.""" + if cls._looks_like_wheel_filename(wheel_path.name): + return wheel_path + + import re + import shutil + import zipfile + + name = None + version = None + try: + with zipfile.ZipFile(wheel_path) as zf: + for member in zf.namelist(): + if member.endswith(".dist-info/METADATA") and member.count("/") == 1: + text = zf.read(member).decode("utf-8", errors="replace") + for line in text.splitlines(): + if line.startswith("Name:"): + name = line.split(":", 1)[1].strip() + elif line.startswith("Version:"): + version = line.split(":", 1)[1].strip() + break + except zipfile.BadZipFile: + name = None + + def _normalize(value: str) -> str: + value = value.strip().replace("-", "_") + value = re.sub(r"[^A-Za-z0-9._]", "_", value) + return value or "package" + + dist = _normalize(name or "package") + ver = _normalize(version or "0.0.0") + # py3-none-any is valid for pure-Python wheels and accepted by pip. + fixed_name = f"{dist}-{ver}-py3-none-any.whl" + fixed_path = wheel_path.with_name(fixed_name) + if fixed_path.exists() and fixed_path != wheel_path: + fixed_path.unlink() + shutil.copy2(wheel_path, fixed_path) + logger.info( + "Rewrote wheel filename for pip: %s -> %s", + wheel_path.name, + fixed_path.name, + ) + return fixed_path + + @staticmethod + def _venv_python(venv_path: Path) -> Path: + if os.name == "nt": + return venv_path / "Scripts" / "python.exe" + return venv_path / "bin" / "python" + + @staticmethod + def _venv_bin_dir(venv_path: Path) -> Path: + if os.name == "nt": + return venv_path / "Scripts" + return venv_path / "bin" + + def resolve_entrypoint(self, paths: PluginPaths, version: str, entrypoint: str) -> Path: + bin_dir = self._venv_bin_dir(paths.venv_dir(version)) + candidate = (bin_dir / entrypoint).resolve() + bin_root = bin_dir.resolve() + try: + candidate.relative_to(bin_root) + except ValueError as exc: + raise RuntimeError("entrypoint path escapes plugin venv") from exc + if not candidate.is_file(): + # Windows may use .exe + exe = Path(str(candidate) + ".exe") + if exe.is_file(): + return exe + raise FileNotFoundError(f"entrypoint not found in venv: {entrypoint}") + return candidate + + def _extract_ui_assets( + self, wheel_path: Path, release_dir: Path, manifest: PluginManifest + ) -> None: + if manifest.ui is None: + return + import zipfile + + entry = manifest.ui.entry.replace("\\", "/") + # Copy any archive members under the entry's top-level directory (e.g. ui/) + top = entry.split("/", 1)[0] + prefixes = (f"{top}/",) + try: + with zipfile.ZipFile(wheel_path) as zf: + for name in zf.namelist(): + # Also copy bare openhop-plugin.json already handled + rel = None + for prefix in prefixes: + # Match ".../ui/..." or "ui/..." + idx = name.find(prefix) + if idx >= 0: + rel = name[idx:] + break + if rel is None: + continue + if rel.endswith("/"): + continue + # Reject traversal in archive member names + if ".." in Path(rel).parts: + continue + dest = release_dir / rel + dest.parent.mkdir(parents=True, exist_ok=True) + with zf.open(name) as src, open(dest, "wb") as out: + out.write(src.read()) + except zipfile.BadZipFile as exc: + raise RuntimeError(f"invalid wheel while extracting UI: {exc}") from exc + + # Ensure entry file exists if packaged at package root differently + entry_path = release_dir / entry + if not entry_path.is_file(): + logger.warning( + "UI entry %s not found in release dir after wheel extract for %s", + entry, + manifest.id, + ) + + def _extract_config_default_file(self, wheel_path: Path, release_dir: Path) -> None: + """Copy optional config.default.json from the wheel into the release dir.""" + import zipfile + + try: + with zipfile.ZipFile(wheel_path) as zf: + candidates = [ + name + for name in zf.namelist() + if name.endswith("/config.default.json") or name == "config.default.json" + ] + if not candidates: + return + preferred = [c for c in candidates if "share/openhop/plugins/" in c] + chosen = preferred[0] if preferred else candidates[0] + if ".." in Path(chosen).parts: + return + dest = release_dir / "config.default.json" + with zf.open(chosen) as src, open(dest, "wb") as out: + out.write(src.read()) + except zipfile.BadZipFile: + return + + def resolve_config_defaults( + self, plugin_id: str, manifest: Optional[PluginManifest] = None + ) -> dict: + """Load defaults from manifest and/or release config.default.json.""" + if manifest is None: + manifest = self.storage.load_current_manifest(plugin_id) + defaults: dict = {} + if manifest and manifest.config_defaults: + defaults.update(manifest.config_defaults) + + paths = self.storage.paths_for(plugin_id) + candidates: list[Path] = [] + if paths.current_link.exists() or paths.current_link.is_symlink(): + candidates.append(paths.current_link / "config.default.json") + version = self.storage.current_version(plugin_id) + if version: + try: + candidates.append(paths.release_dir(version) / "config.default.json") + except ValueError: + pass + candidates.append(paths.manifest_path().parent / "config.default.json") + + seen: set[str] = set() + for candidate in candidates: + key = str(candidate) + if key in seen: + continue + seen.add(key) + if not candidate.is_file(): + continue + try: + import json + + data = json.loads(candidate.read_text(encoding="utf-8")) + if isinstance(data, dict): + defaults.update(data) + break + except (OSError, json.JSONDecodeError, ValueError) as exc: + logger.warning("Ignoring invalid %s: %s", candidate, exc) + return defaults + + def _seed_config_if_missing( + self, plugin_id: str, manifest: PluginManifest, release_dir: Path + ) -> None: + """Write data/config.json from defaults when the user has no saved file yet.""" + config_path = self.storage.config_path(plugin_id) + if config_path.is_file(): + return + defaults = {} + if manifest.config_defaults: + defaults.update(manifest.config_defaults) + default_file = release_dir / "config.default.json" + if default_file.is_file(): + try: + import json + + data = json.loads(default_file.read_text(encoding="utf-8")) + if isinstance(data, dict): + defaults.update(data) + except (OSError, json.JSONDecodeError, ValueError) as exc: + logger.warning("Could not read config.default.json for %s: %s", plugin_id, exc) + if not defaults: + return + try: + self.storage.write_config(plugin_id, defaults) + logger.info("Seeded default config.json for plugin %s", plugin_id) + except ValueError as exc: + logger.warning("Could not seed config for %s: %s", plugin_id, exc) + + # ------------------------------------------------------------------ # + # Process lifecycle + # ------------------------------------------------------------------ # + + def get_state(self, plugin_id: str) -> PluginState: + with self._lock: + persisted = self.storage.read_state(plugin_id) + if persisted is not None and not persisted.get("enabled", False): + # Keep FAILED visible even when disabled? Spec: disable → DISABLED + current = self._runtime_state.get(plugin_id) + if current in (PluginState.STOPPING, PluginState.STARTING): + return current + return PluginState.DISABLED + if plugin_id in self._handles: + proc = self._handles[plugin_id].process + if proc.poll() is None: + return PluginState.RUNNING + return self._runtime_state.get(plugin_id, PluginState.STOPPED) + + def start(self, plugin_id: str) -> PluginState: + with self._lock: + state = self.storage.read_state(plugin_id) + if state is None: + raise KeyError(f"plugin not installed: {plugin_id}") + if not state.get("enabled", False): + # Allow explicit start only when enabled — callers enable first + raise RuntimeError(f"plugin is disabled: {plugin_id}") + + existing = self._handles.get(plugin_id) + if existing and existing.process.poll() is None: + self._runtime_state[plugin_id] = PluginState.RUNNING + return PluginState.RUNNING + + manifest = self.storage.load_current_manifest(plugin_id) + if manifest is None: + raise RuntimeError(f"manifest missing for {plugin_id}") + if manifest.runtime is None: + # UI-only: nothing to start + self._runtime_state[plugin_id] = PluginState.STOPPED + return PluginState.STOPPED + + version = str(state.get("version") or manifest.version) + paths = self.storage.paths_for(plugin_id) + exe = self.resolve_entrypoint(paths, version, manifest.runtime.entrypoint) + + self._runtime_state[plugin_id] = PluginState.STARTING + paths.logs_dir.mkdir(parents=True, exist_ok=True) + self._rotate_log_if_needed(paths.log_file) + log_fp = open(paths.log_file, "a", encoding="utf-8", buffering=1) + + env = os.environ.copy() + env["OPENHOP_PLUGIN_ID"] = plugin_id + env["OPENHOP_PLUGIN_DATA"] = str(paths.data_dir) + + try: + proc = self._popen( + [str(exe)], + stdout=log_fp, + stderr=subprocess.STDOUT, + env=env, + cwd=str(paths.data_dir), + shell=False, + ) + except Exception: + log_fp.close() + self._runtime_state[plugin_id] = PluginState.FAILED + raise + + self._handles[plugin_id] = ProcessHandle( + plugin_id=plugin_id, process=proc, log_fp=log_fp + ) + self._runtime_state[plugin_id] = PluginState.RUNNING + self._exit_codes[plugin_id] = None + logger.info("Started plugin %s pid=%s", plugin_id, proc.pid) + return PluginState.RUNNING + + def stop(self, plugin_id: str, *, mark_disabled: bool = False) -> PluginState: + with self._lock: + handle = self._handles.get(plugin_id) + self._runtime_state[plugin_id] = ( + PluginState.DISABLED if mark_disabled else PluginState.STOPPING + ) + if handle is None: + self._runtime_state[plugin_id] = ( + PluginState.DISABLED if mark_disabled else PluginState.STOPPED + ) + return self._runtime_state[plugin_id] + + proc = handle.process + if proc.poll() is not None: + self._cleanup_handle(plugin_id) + self._runtime_state[plugin_id] = ( + PluginState.DISABLED if mark_disabled else PluginState.STOPPED + ) + return self._runtime_state[plugin_id] + + try: + proc.send_signal(signal.SIGTERM) + except ProcessLookupError: + pass + except Exception as exc: + logger.debug("SIGTERM failed for %s: %s", plugin_id, exc) + + # Wait outside lock + deadline = time.monotonic() + self.stop_timeout + while time.monotonic() < deadline: + if proc.poll() is not None: + break + time.sleep(0.05) + + if proc.poll() is None: + try: + proc.kill() + except ProcessLookupError: + pass + try: + proc.wait(timeout=2) + except Exception: + pass + + with self._lock: + self._cleanup_handle(plugin_id) + self._runtime_state[plugin_id] = ( + PluginState.DISABLED if mark_disabled else PluginState.STOPPED + ) + logger.info("Stopped plugin %s", plugin_id) + return self._runtime_state[plugin_id] + + def restart(self, plugin_id: str) -> PluginState: + self.stop(plugin_id) + return self.start(plugin_id) + + def _cleanup_handle(self, plugin_id: str) -> None: + handle = self._handles.pop(plugin_id, None) + if handle is None: + return + try: + if handle.process.poll() is not None: + self._exit_codes[plugin_id] = handle.process.returncode + except Exception: + pass + try: + handle.log_fp.close() + except Exception: + pass + + @staticmethod + def _rotate_log_if_needed(log_file: Path) -> None: + try: + if log_file.is_file() and log_file.stat().st_size > LOG_MAX_BYTES: + rotated = log_file.with_suffix(".log.1") + if rotated.exists(): + rotated.unlink() + log_file.rename(rotated) + except OSError: + pass + + # ------------------------------------------------------------------ # + # Supervision + # ------------------------------------------------------------------ # + + def start_supervisor(self) -> None: + if self._supervise_thread and self._supervise_thread.is_alive(): + return + self._supervise_stop.clear() + self._supervise_thread = threading.Thread( + target=self._supervise_loop, name="plugin-supervisor", daemon=True + ) + self._supervise_thread.start() + + def stop_supervisor(self) -> None: + self._supervise_stop.set() + thread = self._supervise_thread + if thread and thread.is_alive(): + thread.join(timeout=2) + self._supervise_thread = None + + def _supervise_loop(self) -> None: + while not self._supervise_stop.wait(0.5): + try: + self._check_crashes() + except Exception as exc: + logger.debug("supervisor tick error: %s", exc) + + def _check_crashes(self) -> None: + with self._lock: + finished: list[tuple[str, int]] = [] + for plugin_id, handle in list(self._handles.items()): + code = handle.process.poll() + if code is None: + continue + finished.append((plugin_id, code)) + + for plugin_id, code in finished: + self._cleanup_handle(plugin_id) + state = self.storage.read_state(plugin_id) or {} + if not state.get("enabled", False): + self._runtime_state[plugin_id] = PluginState.DISABLED + continue + # Expected stop paths set STOPPING/DISABLED already + current = self._runtime_state.get(plugin_id) + if current in (PluginState.STOPPING, PluginState.DISABLED, PluginState.STOPPED): + if current != PluginState.DISABLED: + self._runtime_state[plugin_id] = PluginState.STOPPED + continue + + logger.warning("Plugin %s exited unexpectedly code=%s", plugin_id, code) + self._exit_codes[plugin_id] = code + times = self._crash_times.setdefault(plugin_id, deque()) + now = time.monotonic() + times.append(now) + while times and (now - times[0]) > self.crash_window: + times.popleft() + if len(times) >= self.crash_max_exits: + logger.error( + "Plugin %s crash-looped (%d exits in %ss) → FAILED", + plugin_id, + len(times), + self.crash_window, + ) + self._runtime_state[plugin_id] = PluginState.FAILED + continue + + # Restart + try: + self._runtime_state[plugin_id] = PluginState.STOPPED + self.start(plugin_id) + except Exception as exc: + logger.error("Failed to restart plugin %s: %s", plugin_id, exc) + self._runtime_state[plugin_id] = PluginState.FAILED + + def status_dict(self, plugin_id: str) -> dict: + state = self.storage.read_state(plugin_id) + if state is None: + raise KeyError(plugin_id) + manifest = self.storage.load_current_manifest(plugin_id) + paths = self.storage.paths_for(plugin_id) + runtime_state = self.get_state(plugin_id) + pid = None + with self._lock: + handle = self._handles.get(plugin_id) + if handle and handle.process.poll() is None: + pid = handle.process.pid + last_exit = self._exit_codes.get(plugin_id) + return { + "id": plugin_id, + "name": manifest.name if manifest else plugin_id, + "version": state.get("version"), + "enabled": bool(state.get("enabled", False)), + "state": runtime_state.value, + "pid": pid, + "last_exit_code": last_exit, + "has_runtime": bool(manifest and manifest.runtime), + "has_ui": bool(manifest and manifest.ui), + "ui_entry": manifest.ui.entry if manifest and manifest.ui else None, + "data_dir": str(paths.data_dir), + "description": manifest.description if manifest else "", + "source": state.get("source") or "local", + "repository": state.get("repository") or None, + } diff --git a/repeater/plugins/storage.py b/repeater/plugins/storage.py new file mode 100644 index 0000000..bcdd3be --- /dev/null +++ b/repeater/plugins/storage.py @@ -0,0 +1,332 @@ +"""Plugin filesystem layout and manager-owned state.""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import tempfile +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Optional + +from .manifest import MANIFEST_FILENAME, PluginManifest, load_manifest_file + +logger = logging.getLogger("PluginStorage") + +STATE_FILENAME = "state.json" +DEFAULT_PLUGINS_SUBDIR = "plugins" +DEFAULT_SOCKET_NAME = "plugin-manager.sock" + + +@dataclass(frozen=True) +class PluginPaths: + root: Path + plugin_id: str + + @property + def plugin_dir(self) -> Path: + return self.root / self.plugin_id + + @property + def releases_dir(self) -> Path: + return self.plugin_dir / "releases" + + @property + def current_link(self) -> Path: + return self.plugin_dir / "current" + + @property + def data_dir(self) -> Path: + return self.plugin_dir / "data" + + @property + def logs_dir(self) -> Path: + return self.plugin_dir / "logs" + + @property + def log_file(self) -> Path: + return self.logs_dir / "plugin.log" + + @property + def state_file(self) -> Path: + return self.plugin_dir / STATE_FILENAME + + def release_dir(self, version: str) -> Path: + _assert_safe_segment(version, "version") + return self.releases_dir / version + + def venv_dir(self, version: str) -> Path: + return self.release_dir(version) / "venv" + + def manifest_path(self, version: Optional[str] = None) -> Path: + if version is None: + return self.current_link / MANIFEST_FILENAME + return self.release_dir(version) / MANIFEST_FILENAME + + +def _assert_safe_segment(value: str, field: str) -> None: + if not value or value in {".", ".."} or "/" in value or "\\" in value or "\x00" in value: + raise ValueError(f"unsafe {field}: {value!r}") + + +def safe_join(root: Path, *parts: str) -> Path: + """Join path parts under root, rejecting traversal.""" + root = root.resolve() + candidate = root.joinpath(*parts).resolve() + try: + candidate.relative_to(root) + except ValueError as exc: + raise ValueError(f"path escapes root: {candidate}") from exc + return candidate + + +class PluginStorage: + """Manages plugin directories and manager-owned state.json files.""" + + def __init__(self, plugins_root: Path | str): + self.root = Path(plugins_root).expanduser().resolve() + + def ensure_root(self) -> None: + self.root.mkdir(parents=True, exist_ok=True) + + def paths_for(self, plugin_id: str) -> PluginPaths: + _assert_safe_segment(plugin_id, "plugin_id") + if ".." in plugin_id: + raise ValueError(f"unsafe plugin_id: {plugin_id!r}") + return PluginPaths(root=self.root, plugin_id=plugin_id) + + def list_plugin_ids(self) -> list[str]: + if not self.root.exists(): + return [] + ids = [] + for child in sorted(self.root.iterdir()): + if child.is_dir() and (child / STATE_FILENAME).is_file(): + ids.append(child.name) + return ids + + def read_state(self, plugin_id: str) -> Optional[dict[str, Any]]: + path = self.paths_for(plugin_id).state_file + if not path.is_file(): + return None + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + logger.warning("Failed to read state for %s: %s", plugin_id, exc) + return None + if not isinstance(data, dict): + return None + return data + + def write_state(self, plugin_id: str, state: dict[str, Any]) -> None: + paths = self.paths_for(plugin_id) + paths.plugin_dir.mkdir(parents=True, exist_ok=True) + # Preserve existing optional fields unless explicitly overwritten. + prev = self.read_state(plugin_id) or {} + payload = { + "id": plugin_id, + "version": str(state.get("version", prev.get("version", ""))), + "enabled": bool(state.get("enabled", prev.get("enabled", False))), + } + # Optional install provenance (catalogue vs local wheel) + source = state.get("source", prev.get("source")) + if source is not None and str(source).strip(): + payload["source"] = str(source).strip() + repository = state.get("repository", prev.get("repository")) + if repository is not None and str(repository).strip(): + payload["repository"] = str(repository).strip() + self._atomic_write_json(paths.state_file, payload) + + def ensure_plugin_layout(self, plugin_id: str, version: str) -> PluginPaths: + paths = self.paths_for(plugin_id) + paths.plugin_dir.mkdir(parents=True, exist_ok=True) + paths.releases_dir.mkdir(parents=True, exist_ok=True) + paths.release_dir(version).mkdir(parents=True, exist_ok=True) + paths.data_dir.mkdir(parents=True, exist_ok=True) + paths.logs_dir.mkdir(parents=True, exist_ok=True) + return paths + + def set_current(self, plugin_id: str, version: str) -> None: + paths = self.paths_for(plugin_id) + target = paths.release_dir(version) + if not target.is_dir(): + raise FileNotFoundError(f"release directory missing: {target}") + link = paths.current_link + # Relative symlink keeps layout portable + rel = Path("releases") / version + tmp = paths.plugin_dir / f".current.tmp.{os.getpid()}" + if tmp.exists() or tmp.is_symlink(): + tmp.unlink() + tmp.symlink_to(rel) + os.replace(tmp, link) + + def current_version(self, plugin_id: str) -> Optional[str]: + state = self.read_state(plugin_id) + if state and state.get("version"): + return str(state["version"]) + paths = self.paths_for(plugin_id) + if paths.current_link.is_symlink() or paths.current_link.exists(): + try: + resolved = paths.current_link.resolve() + return resolved.name + except OSError: + return None + return None + + def load_current_manifest(self, plugin_id: str) -> Optional[PluginManifest]: + paths = self.paths_for(plugin_id) + manifest_path = paths.manifest_path() + if not manifest_path.is_file(): + version = self.current_version(plugin_id) + if version: + manifest_path = paths.manifest_path(version) + if not manifest_path.is_file(): + return None + return load_manifest_file(manifest_path) + + def write_manifest(self, plugin_id: str, version: str, manifest: PluginManifest) -> Path: + paths = self.ensure_plugin_layout(plugin_id, version) + dest = paths.manifest_path(version) + self._atomic_write_json(dest, manifest.to_dict()) + return dest + + def remove_release_code(self, plugin_id: str, keep_data: bool = True) -> None: + paths = self.paths_for(plugin_id) + if paths.current_link.exists() or paths.current_link.is_symlink(): + try: + paths.current_link.unlink() + except OSError as exc: + logger.debug("current unlink failed for %s: %s", plugin_id, exc) + if paths.releases_dir.exists(): + shutil.rmtree(paths.releases_dir, ignore_errors=True) + if paths.state_file.exists(): + try: + paths.state_file.unlink() + except OSError as exc: + logger.debug("state unlink failed for %s: %s", plugin_id, exc) + if not keep_data: + if paths.data_dir.exists(): + shutil.rmtree(paths.data_dir, ignore_errors=True) + if paths.logs_dir.exists(): + shutil.rmtree(paths.logs_dir, ignore_errors=True) + # Remove plugin dir if empty-ish + if paths.plugin_dir.exists(): + try: + remaining = list(paths.plugin_dir.iterdir()) + if not remaining: + paths.plugin_dir.rmdir() + else: + shutil.rmtree(paths.plugin_dir, ignore_errors=True) + except OSError: + shutil.rmtree(paths.plugin_dir, ignore_errors=True) + + def config_path(self, plugin_id: str) -> Path: + """Path to the plugin-owned config.json under data/.""" + return self.paths_for(plugin_id).data_dir / "config.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) + 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 config.json: {exc}") from exc + if data is None: + return {} + if not isinstance(data, dict): + raise ValueError("plugin config.json must be a JSON object") + return data + + def write_config(self, plugin_id: str, config: dict[str, Any]) -> Path: + """Atomically write plugin-owned data/config.json (opaque object).""" + if not isinstance(config, dict): + raise ValueError("config must be a JSON object") + paths = self.paths_for(plugin_id) + paths.data_dir.mkdir(parents=True, exist_ok=True) + path = self.config_path(plugin_id) + self._atomic_write_json(path, config) + return path + + def tail_log(self, plugin_id: str, lines: int = 200) -> list[str]: + paths = self.paths_for(plugin_id) + log_path = paths.log_file + if not log_path.is_file(): + return [] + lines = max(1, min(int(lines), 5000)) + try: + # Simple tail: read whole file if small; otherwise scan from end + data = log_path.read_text(encoding="utf-8", errors="replace") + except OSError: + return [] + all_lines = data.splitlines() + return all_lines[-lines:] + + @staticmethod + def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + fd, tmp_name = tempfile.mkstemp(prefix=".tmp-", dir=str(path.parent)) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2, sort_keys=True) + fh.write("\n") + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp_name, path) + finally: + if os.path.exists(tmp_name): + try: + os.unlink(tmp_name) + except OSError: + pass + + +def resolve_plugins_root( + config: Optional[dict[str, Any]] = None, + *, + storage_dir: Optional[Path | str] = None, +) -> Path: + """Resolve plugins root from config or storage_dir.""" + config = config or {} + plugins_cfg = config.get("plugins") if isinstance(config.get("plugins"), dict) else {} + configured = plugins_cfg.get("root") or plugins_cfg.get("plugins_dir") + if configured: + return Path(str(configured)).expanduser().resolve() + if storage_dir is None: + from repeater.config import resolve_storage_dir + + storage_dir = resolve_storage_dir(config) + return Path(storage_dir).expanduser().resolve() / DEFAULT_PLUGINS_SUBDIR + + +def resolve_plugin_socket_path( + config: Optional[dict[str, Any]] = None, + *, + storage_dir: Optional[Path | str] = None, +) -> Path: + config = config or {} + plugins_cfg = config.get("plugins") if isinstance(config.get("plugins"), dict) else {} + configured = plugins_cfg.get("socket") or plugins_cfg.get("socket_path") + if configured: + return Path(str(configured)).expanduser().resolve() + if storage_dir is None: + from repeater.config import resolve_storage_dir + + storage_dir = resolve_storage_dir(config) + return Path(storage_dir).expanduser().resolve() / DEFAULT_SOCKET_NAME + + +def resolve_catalogue_url(config: Optional[dict] = None) -> str: + """Return configured catalogue URL or the built-in default.""" + from .catalogue import DEFAULT_CATALOGUE_URL + + config = config or {} + plugins_cfg = config.get("plugins") if isinstance(config, dict) else None + if not isinstance(plugins_cfg, dict): + return DEFAULT_CATALOGUE_URL + raw = plugins_cfg.get("catalogue_url") or plugins_cfg.get("catalogue") + if isinstance(raw, str) and raw.strip(): + return raw.strip() + return DEFAULT_CATALOGUE_URL diff --git a/repeater/web/__init__.py b/repeater/web/__init__.py index eb91a64..8a45e14 100644 --- a/repeater/web/__init__.py +++ b/repeater/web/__init__.py @@ -1,6 +1,7 @@ from .api_endpoints import APIEndpoints from .cad_calibration_engine import CADCalibrationEngine from .http_server import HTTPStatsServer, LogBuffer, StatsApp, _log_buffer +from .plugin_endpoints import PluginAPIEndpoints from .update_endpoints import UpdateAPIEndpoints __all__ = [ @@ -9,6 +10,7 @@ __all__ = [ "LogBuffer", "APIEndpoints", "CADCalibrationEngine", + "PluginAPIEndpoints", "UpdateAPIEndpoints", "_log_buffer", ] diff --git a/repeater/web/api_endpoints.py b/repeater/web/api_endpoints.py index 544c745..d2bb2c0 100644 --- a/repeater/web/api_endpoints.py +++ b/repeater/web/api_endpoints.py @@ -40,6 +40,7 @@ from .auth.middleware import require_auth from .auth_endpoints import AuthAPIEndpoints from .cad_calibration_engine import CADCalibrationEngine from .companion_endpoints import CompanionAPIEndpoints +from .plugin_endpoints import PluginAPIEndpoints from .update_endpoints import UpdateAPIEndpoints logger = logging.getLogger("HTTPServer") @@ -168,6 +169,16 @@ POLICY_GROUP_KINDS = { # GET /api/update/channels - List available release channels (branches) # POST /api/update/set_channel - Switch release channel {"channel": "dev"} +# Plugins (managed via local plugin-manager IPC) +# GET /api/plugins/ - List installed plugins +# GET /api/plugins/{id} - Plugin status +# POST /api/plugins/install - Install local wheel (multipart or wheel_path) +# POST /api/plugins/enable - Enable plugin {"id"} +# POST /api/plugins/disable - Disable plugin {"id"} +# POST /api/plugins/start|stop|restart - Lifecycle {"id"} +# GET /api/plugins/logs?id= - Tail plugin logs +# DELETE /api/plugins/{id} - Uninstall (keeps data by default) + # Setup Wizard # GET /api/needs_setup - Check if repeater needs initial setup # GET /api/site_info - Get site identification name (public, no auth required) @@ -241,6 +252,9 @@ class APIEndpoints: # Create nested update object for /api/update/* routes self.update = UpdateAPIEndpoints() + # Create nested plugins object for /api/plugins/* routes + self.plugins = PluginAPIEndpoints(self.config, self._config_path) + def _is_cors_enabled(self): return self.config.get("web", {}).get("cors_enabled", False) diff --git a/repeater/web/http_server.py b/repeater/web/http_server.py index 05fb589..567e89c 100644 --- a/repeater/web/http_server.py +++ b/repeater/web/http_server.py @@ -292,6 +292,74 @@ class StatsApp: cherrypy.response.headers["Content-Type"] = guessed_type or "application/octet-stream" return target.read_bytes() + def _serve_plugin_ui(self, plugin_id: str, relative_parts: tuple[str, ...]): + """Serve static assets for an enabled application UI plugin.""" + from repeater.plugins.storage import PluginStorage, resolve_plugins_root, safe_join + + try: + plugins_root = resolve_plugins_root(self.config) + storage = PluginStorage(plugins_root) + state = storage.read_state(plugin_id) + if state is None or not state.get("enabled", False): + raise cherrypy.NotFound() + manifest = storage.load_current_manifest(plugin_id) + if manifest is None or manifest.ui is None: + raise cherrypy.NotFound() + paths = storage.paths_for(plugin_id) + if paths.current_link.exists() or paths.current_link.is_symlink(): + release_root = paths.current_link.resolve() + else: + version = state.get("version") + if not version: + raise cherrypy.NotFound() + release_root = paths.release_dir(str(version)) + if not release_root.is_dir(): + raise cherrypy.NotFound() + + entry = manifest.ui.entry.replace("\\", "/") + entry_parts = tuple(p for p in entry.split("/") if p) + if not entry_parts: + raise cherrypy.NotFound() + + # Document root is the directory containing the entry file so a + # relative Vite base (./assets/...) works at /plugins/{id}/. + # Example: entry ui/index.html → doc_root = release/ui + if len(entry_parts) > 1: + doc_root = safe_join(release_root, *entry_parts[:-1]) + entry_name = entry_parts[-1] + else: + doc_root = release_root + entry_name = entry_parts[0] + + if not relative_parts: + serve_parts = (entry_name,) + else: + serve_parts = relative_parts + + try: + target = safe_join(doc_root, *serve_parts) + except ValueError: + raise cherrypy.HTTPError(400, "invalid path") + + if target.is_file(): + guessed_type, _ = mimetypes.guess_type(str(target)) + cherrypy.response.headers["Content-Type"] = ( + guessed_type or "application/octet-stream" + ) + return target.read_bytes() + + # SPA fallback to entry HTML for client-side routes + entry_target = safe_join(doc_root, entry_name) + if entry_target.is_file(): + cherrypy.response.headers["Content-Type"] = "text/html; charset=utf-8" + return entry_target.read_bytes() + raise cherrypy.NotFound() + except cherrypy.HTTPError: + raise + except Exception as exc: + logger.debug("Plugin UI serve error for %s: %s", plugin_id, exc) + raise cherrypy.NotFound() + @cherrypy.expose def favicon_ico(self): """Serve the favicon bundled with the compiled frontend.""" @@ -324,6 +392,13 @@ class StatsApp: if args and args[0] == "api": raise cherrypy.NotFound() + # Application UI plugins: /plugins/{id}/... + if args and args[0] == "plugins": + if len(args) < 2: + raise cherrypy.NotFound() + plugin_id = args[1] + return self._serve_plugin_ui(plugin_id, tuple(args[2:])) + # Handle WebSocket routes if ( args diff --git a/repeater/web/openapi.yaml b/repeater/web/openapi.yaml index 03eb921..80e7fbe 100644 --- a/repeater/web/openapi.yaml +++ b/repeater/web/openapi.yaml @@ -58,6 +58,8 @@ tags: description: Access Control Lists - name: Room Server description: Room server messaging + - name: Plugins + description: External plugin install and lifecycle management paths: # ============================================================================ @@ -4549,8 +4551,493 @@ paths: schema: type: object + # ============================================================================ + # Plugins + # ============================================================================ + /plugins: + get: + tags: [Plugins] + summary: List installed plugins + description: | + List plugins installed by the local plugin manager. + Returns 503 when the plugin manager process is not running. + security: + - BearerAuth: [] + - ApiKeyAuth: [] + responses: + '200': + description: Plugin list + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + plugins: + type: array + items: + $ref: '#/components/schemas/PluginStatus' + '503': + description: Plugin manager unavailable + + /plugins/catalogue: + get: + tags: + - Plugins + summary: List curated plugin catalogue + description: > + Fetch the curated openHop plugin catalogue and annotate entries with + install state and latest stable GitHub Release when available. + Failures contacting the catalogue or GitHub do not affect installed plugins. + parameters: + - name: refresh + in: query + required: false + schema: + type: boolean + description: Force refresh of catalogue and release caches + responses: + '200': + description: Catalogue entries + '502': + description: Catalogue unavailable + '503': + description: Plugin manager unavailable + /plugins/catalogue_install: + post: + tags: + - Plugins + summary: Install a plugin from the catalogue + description: > + Resolve the catalogue entry to its GitHub repository, download the latest + (or pinned) stable wheel, install via the existing plugin installer, and enable. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + version: + type: string + description: Optional release version (with or without leading v) + responses: + '200': + description: Installed plugin status + '400': + description: Invalid request or manifest id mismatch + '503': + description: Plugin manager unavailable + /plugins/updates: + get: + tags: + - Plugins + summary: Check plugin update availability + parameters: + - name: id + in: query + required: true + schema: + type: string + - name: refresh + in: query + required: false + schema: + type: boolean + responses: + '200': + description: Update check result + '404': + description: Plugin not found + /plugins/update: + post: + tags: + - Plugins + summary: Update an installed plugin from GitHub Releases + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + version: + type: string + responses: + '200': + description: Updated plugin status + '400': + description: Update unavailable or invalid + /plugins/settings: + get: + tags: [Plugins] + summary: Get plugin config.json + description: Read the opaque plugin-owned data/config.json object. + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - name: id + in: query + required: true + schema: + type: string + responses: + '200': + description: Config object + '404': + description: Plugin not found + '503': + description: Plugin manager unavailable + post: + tags: [Plugins] + summary: Set plugin config.json + description: | + Replace plugin-owned data/config.json with a JSON object. + Optional restart applies to enabled service plugins. + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id, config] + properties: + id: + type: string + config: + type: object + additionalProperties: true + restart: + type: boolean + default: false + responses: + '200': + description: Saved + '400': + description: Invalid config + '503': + description: Plugin manager unavailable + + + /plugins/install: + post: + tags: [Plugins] + summary: Install a local plugin wheel + description: | + Install a Python wheel containing openhop-plugin.json into an isolated + virtualenv. Accepts multipart field `wheel` or JSON `wheel_path`. + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + wheel: + type: string + format: binary + application/json: + schema: + type: object + properties: + wheel_path: + type: string + description: Absolute path to a .whl on the Repeater host + responses: + '200': + description: Installed + '400': + description: Invalid wheel or manifest + '503': + description: Plugin manager unavailable + + /plugins/enable: + post: + tags: [Plugins] + summary: Enable a plugin + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + example: openhop.nomad + responses: + '200': + description: Enabled + '404': + description: Not found + '503': + description: Plugin manager unavailable + + /plugins/disable: + post: + tags: [Plugins] + summary: Disable a plugin + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + responses: + '200': + description: Disabled + '404': + description: Not found + '503': + description: Plugin manager unavailable + + /plugins/start: + post: + tags: [Plugins] + summary: Start a plugin process + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + responses: + '200': + description: Started + '409': + description: Plugin disabled + '503': + description: Plugin manager unavailable + + /plugins/stop: + post: + tags: [Plugins] + summary: Stop a plugin process + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + responses: + '200': + description: Stopped + '503': + description: Plugin manager unavailable + + /plugins/restart: + post: + tags: [Plugins] + summary: Restart a plugin process + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + responses: + '200': + description: Restarted + '503': + description: Plugin manager unavailable + + /plugins/logs: + get: + tags: [Plugins] + summary: Tail plugin logs + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - name: id + in: query + required: true + schema: + type: string + - name: tail + in: query + required: false + schema: + type: integer + default: 200 + responses: + '200': + description: Log lines + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + id: + type: string + lines: + type: array + items: + type: string + '503': + description: Plugin manager unavailable + + /plugins/uninstall: + post: + tags: [Plugins] + summary: Uninstall a plugin + description: Stops the plugin and removes release code. Keeps data/ unless delete_data is true. + security: + - BearerAuth: [] + - ApiKeyAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [id] + properties: + id: + type: string + delete_data: + type: boolean + default: false + responses: + '200': + description: Uninstalled + '503': + description: Plugin manager unavailable + + /plugins/{id}: + get: + tags: [Plugins] + summary: Get plugin status + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Status + content: + application/json: + schema: + allOf: + - type: object + properties: + success: + type: boolean + - $ref: '#/components/schemas/PluginStatus' + '404': + description: Not found + '503': + description: Plugin manager unavailable + delete: + tags: [Plugins] + summary: Uninstall plugin by id + security: + - BearerAuth: [] + - ApiKeyAuth: [] + parameters: + - name: id + in: path + required: true + schema: + type: string + - name: delete_data + in: query + required: false + schema: + type: boolean + default: false + responses: + '200': + description: Uninstalled + '503': + description: Plugin manager unavailable + + components: schemas: + + PluginStatus: + type: object + properties: + id: + type: string + example: openhop.nomad + name: + type: string + version: + type: string + enabled: + type: boolean + state: + type: string + enum: [DISABLED, STOPPED, STARTING, RUNNING, STOPPING, FAILED] + pid: + type: integer + nullable: true + has_runtime: + type: boolean + has_ui: + type: boolean + ui_entry: + type: string + nullable: true + data_dir: + type: string + description: + type: string SuccessResponse: type: object properties: diff --git a/repeater/web/plugin_endpoints.py b/repeater/web/plugin_endpoints.py new file mode 100644 index 0000000..6f817a5 --- /dev/null +++ b/repeater/web/plugin_endpoints.py @@ -0,0 +1,432 @@ +"""Plugin management API endpoints (nested under /api/plugins/).""" + +from __future__ import annotations + +import logging +import re +import shutil +import tempfile +from pathlib import Path +from typing import Any, Optional + +import cherrypy + +from repeater.config import resolve_storage_dir +from repeater.plugins.ipc import PluginIPCClient, PluginIPCError, PluginManagerUnavailable +from repeater.plugins.storage import resolve_plugin_socket_path, resolve_plugins_root + +logger = logging.getLogger("HTTPServer") + + +class PluginAPIEndpoints: + """CherryPy nested app for /api/plugins/*.""" + + def __init__(self, config: Optional[dict] = None, config_path: Optional[str] = None): + self.config = config or {} + self.config_path = config_path + self._client: Optional[PluginIPCClient] = None + + def _socket_path(self) -> Path: + storage_dir = resolve_storage_dir(self.config, config_path=self.config_path) + return resolve_plugin_socket_path(self.config, storage_dir=storage_dir) + + def _client_or_raise(self) -> PluginIPCClient: + path = self._socket_path() + if self._client is None or self._client.socket_path != path: + self._client = PluginIPCClient(path) + if not self._client.available(): + raise PluginManagerUnavailable("Plugin manager unavailable") + return self._client + + def _ok(self, data: Any = None) -> dict: + if data is None: + return {"success": True} + if isinstance(data, dict): + return {"success": True, **data} + return {"success": True, "data": data} + + def _err(self, msg: str, status: int = 400) -> dict: + cherrypy.response.status = status + return {"success": False, "error": str(msg)} + + def _handle_ipc(self, fn): + try: + return self._ok(fn()) + except PluginManagerUnavailable as exc: + return self._err(str(exc), 503) + except PluginIPCError as exc: + return self._err(str(exc), int(exc.code)) + except cherrypy.HTTPError: + raise + except Exception as exc: + logger.exception("Plugin API error") + return self._err(str(exc), 500) + + def _require_post(self) -> None: + if cherrypy.request.method != "POST": + raise cherrypy.HTTPError(405, "Method Not Allowed") + + def _json_body(self) -> dict: + body = {} + try: + body = getattr(cherrypy.request, "json", None) or {} + except Exception: + body = {} + if not isinstance(body, dict): + return {} + return body + + def _plugin_id_from(self, kwargs: dict, body: Optional[dict] = None) -> str: + body = body or {} + plugin_id = ( + kwargs.get("id") + or kwargs.get("plugin_id") + or body.get("id") + or body.get("plugin_id") + or "" + ) + plugin_id = str(plugin_id).strip() + if not plugin_id: + raise cherrypy.HTTPError(400, "id is required") + return plugin_id + + # ------------------------------------------------------------------ # + # Routes + # ------------------------------------------------------------------ # + + @cherrypy.expose + @cherrypy.tools.json_out() + def index(self, **kwargs): + """GET /api/plugins/ — list installed plugins.""" + if cherrypy.request.method == "OPTIONS": + return "" + if cherrypy.request.method not in ("GET", "HEAD"): + raise cherrypy.HTTPError(405) + + def _do(): + client = self._client_or_raise() + return {"plugins": client.list_plugins()} + + return self._handle_ipc(_do) + + @cherrypy.expose + @cherrypy.tools.json_out() + def default(self, *args, **kwargs): + """GET/DELETE /api/plugins/{id} — status or uninstall.""" + if cherrypy.request.method == "OPTIONS": + return "" + if not args: + raise cherrypy.HTTPError(404) + plugin_id = str(args[0]).strip() + if not plugin_id or plugin_id.startswith("."): + raise cherrypy.HTTPError(400, "invalid plugin id") + + if cherrypy.request.method in ("GET", "HEAD"): + + def _status(): + return self._client_or_raise().status(plugin_id) + + return self._handle_ipc(_status) + + if cherrypy.request.method == "DELETE": + delete_data = False + raw = kwargs.get("delete_data", "false") + if isinstance(raw, str): + delete_data = raw.strip().lower() in {"1", "true", "yes", "on"} + else: + delete_data = bool(raw) + # JSON body optional + try: + body = self._json_body() + if "delete_data" in body: + delete_data = bool(body.get("delete_data")) + except Exception: + pass + + def _uninstall(): + return self._client_or_raise().uninstall(plugin_id, delete_data=delete_data) + + return self._handle_ipc(_uninstall) + + raise cherrypy.HTTPError(405) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in(force=False) + def install(self, wheel=None, **kwargs): + """POST /api/plugins/install — multipart wheel upload or JSON wheel_path.""" + if cherrypy.request.method == "OPTIONS": + return "" + try: + self._require_post() + except cherrypy.HTTPError: + raise + + def _do(): + client = self._client_or_raise() + storage_dir = resolve_storage_dir(self.config, config_path=self.config_path) + plugins_root = resolve_plugins_root(self.config, storage_dir=storage_dir) + upload_dir = plugins_root / ".upload" + upload_dir.mkdir(parents=True, exist_ok=True) + + wheel_path: Optional[Path] = None + cleanup = False + + # Multipart file field "wheel" + if wheel is not None: + raw_name = getattr(wheel, "filename", None) or "plugin.whl" + # Keep only the basename; pip requires a PEP 427 wheel filename. + basename = Path(str(raw_name)).name + if not basename.endswith(".whl"): + raise cherrypy.HTTPError(400, "uploaded file must be a .whl") + # Sanitize basename only; pip needs a PEP 427 wheel name. + # Runtime rewrites from METADATA if the name is still invalid. + safe_name = re.sub(r"[^A-Za-z0-9._+-]", "_", basename) + if not safe_name.endswith(".whl"): + safe_name = f"{safe_name}.whl" + staging = Path(tempfile.mkdtemp(prefix="plugin-upload-", dir=str(upload_dir))) + wheel_path = staging / safe_name + cleanup = True + try: + with open(wheel_path, "wb") as out: + while True: + chunk = wheel.file.read(1024 * 1024) + if not chunk: + break + out.write(chunk) + except Exception: + shutil.rmtree(staging, ignore_errors=True) + raise + else: + body = self._json_body() + raw_path = body.get("wheel_path") or kwargs.get("wheel_path") + if not raw_path: + raise cherrypy.HTTPError( + 400, "multipart field 'wheel' or JSON 'wheel_path' is required" + ) + wheel_path = Path(str(raw_path)).expanduser() + if not wheel_path.is_file(): + raise cherrypy.HTTPError(400, f"wheel_path not found: {wheel_path}") + if not wheel_path.name.endswith(".whl"): + raise cherrypy.HTTPError(400, "wheel_path must point to a .whl file") + + try: + return {"plugin": client.install(str(wheel_path.resolve()))} + finally: + if cleanup and wheel_path is not None: + # Remove staging dir (preferred) or lone temp file + parent = wheel_path.parent + try: + if parent.name.startswith("plugin-upload-") and parent.is_dir(): + shutil.rmtree(parent, ignore_errors=True) + else: + wheel_path.unlink(missing_ok=True) + except OSError: + pass + + return self._handle_ipc(_do) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in(force=False) + def enable(self, **kwargs): + if cherrypy.request.method == "OPTIONS": + return "" + self._require_post() + body = self._json_body() + plugin_id = self._plugin_id_from(kwargs, body) + return self._handle_ipc(lambda: {"plugin": self._client_or_raise().enable(plugin_id)}) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in(force=False) + def disable(self, **kwargs): + if cherrypy.request.method == "OPTIONS": + return "" + self._require_post() + body = self._json_body() + plugin_id = self._plugin_id_from(kwargs, body) + return self._handle_ipc(lambda: {"plugin": self._client_or_raise().disable(plugin_id)}) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in(force=False) + def start(self, **kwargs): + if cherrypy.request.method == "OPTIONS": + return "" + self._require_post() + body = self._json_body() + plugin_id = self._plugin_id_from(kwargs, body) + return self._handle_ipc(lambda: {"plugin": self._client_or_raise().start(plugin_id)}) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in(force=False) + def stop(self, **kwargs): + if cherrypy.request.method == "OPTIONS": + return "" + self._require_post() + body = self._json_body() + plugin_id = self._plugin_id_from(kwargs, body) + return self._handle_ipc(lambda: {"plugin": self._client_or_raise().stop(plugin_id)}) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in(force=False) + def restart(self, **kwargs): + if cherrypy.request.method == "OPTIONS": + return "" + self._require_post() + body = self._json_body() + plugin_id = self._plugin_id_from(kwargs, body) + return self._handle_ipc(lambda: {"plugin": self._client_or_raise().restart(plugin_id)}) + + @cherrypy.expose + @cherrypy.tools.json_out() + def logs(self, **kwargs): + if cherrypy.request.method == "OPTIONS": + return "" + if cherrypy.request.method not in ("GET", "HEAD"): + raise cherrypy.HTTPError(405) + plugin_id = self._plugin_id_from(kwargs) + try: + tail = int(kwargs.get("tail", 200)) + except (TypeError, ValueError): + raise cherrypy.HTTPError(400, "tail must be an integer") + return self._handle_ipc(lambda: self._client_or_raise().logs(plugin_id, tail=tail)) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in(force=False) + def uninstall(self, **kwargs): + """POST /api/plugins/uninstall {id, delete_data?} — alternate to DELETE.""" + if cherrypy.request.method == "OPTIONS": + return "" + self._require_post() + body = self._json_body() + plugin_id = self._plugin_id_from(kwargs, body) + delete_data = bool(body.get("delete_data", False)) + return self._handle_ipc( + lambda: self._client_or_raise().uninstall(plugin_id, delete_data=delete_data) + ) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in(force=False) + def settings(self, **kwargs): + """GET/POST /api/plugins/settings — read or write plugin data/config.json. + + Named ``settings`` (not ``config``) because CherryPy reserves ``config`` + on controllers and would otherwise dispatch to default("config"). + + GET ?id=openhop.nomad + POST {"id": "...", "config": {...}, "restart": false} + """ + if cherrypy.request.method == "OPTIONS": + return "" + + if cherrypy.request.method in ("GET", "HEAD"): + plugin_id = self._plugin_id_from(kwargs) + return self._handle_ipc(lambda: self._client_or_raise().get_config(plugin_id)) + + if cherrypy.request.method == "POST": + body = self._json_body() + plugin_id = self._plugin_id_from(kwargs, body) + config = body.get("config") + if not isinstance(config, dict): + return self._err("config must be a JSON object", 400) + restart = bool(body.get("restart", False)) + + def _set(): + return self._client_or_raise().set_config(plugin_id, config, restart=restart) + + return self._handle_ipc(_set) + + raise cherrypy.HTTPError(405, "Method Not Allowed") + + @cherrypy.expose + @cherrypy.tools.json_out() + def catalogue(self, **kwargs): + """GET /api/plugins/catalogue — curated catalogue + install/update hints.""" + if cherrypy.request.method == "OPTIONS": + return "" + if cherrypy.request.method not in ("GET", "HEAD"): + raise cherrypy.HTTPError(405) + raw = kwargs.get("refresh") or kwargs.get("force_refresh") or "0" + if isinstance(raw, str): + force = raw.strip().lower() in {"1", "true", "yes", "on"} + else: + force = bool(raw) + + def _do(): + return self._client_or_raise().catalogue(force_refresh=force) + + return self._handle_ipc(_do) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in(force=False) + def catalogue_install(self, **kwargs): + """POST /api/plugins/catalogue_install {id, version?}.""" + if cherrypy.request.method == "OPTIONS": + return "" + self._require_post() + body = self._json_body() + plugin_id = self._plugin_id_from(kwargs, body) + version = body.get("version") or kwargs.get("version") + if version is not None: + version = str(version).strip() or None + force = bool(body.get("force_refresh") or body.get("refresh") or False) + + def _do(): + return { + "plugin": self._client_or_raise().catalogue_install( + plugin_id, version=version, force_refresh=force + ) + } + + return self._handle_ipc(_do) + + @cherrypy.expose + @cherrypy.tools.json_out() + def updates(self, **kwargs): + """GET /api/plugins/updates?id= — check GitHub Releases for updates.""" + if cherrypy.request.method == "OPTIONS": + return "" + if cherrypy.request.method not in ("GET", "HEAD"): + raise cherrypy.HTTPError(405) + plugin_id = self._plugin_id_from(kwargs) + raw = kwargs.get("refresh") or kwargs.get("force_refresh") or "0" + if isinstance(raw, str): + force = raw.strip().lower() in {"1", "true", "yes", "on"} + else: + force = bool(raw) + + def _do(): + return self._client_or_raise().check_update(plugin_id, force_refresh=force) + + return self._handle_ipc(_do) + + @cherrypy.expose + @cherrypy.tools.json_out() + @cherrypy.tools.json_in(force=False) + def update(self, **kwargs): + """POST /api/plugins/update {id, version?} — update from GitHub Releases.""" + if cherrypy.request.method == "OPTIONS": + return "" + self._require_post() + body = self._json_body() + plugin_id = self._plugin_id_from(kwargs, body) + version = body.get("version") or kwargs.get("version") + if version is not None: + version = str(version).strip() or None + force = bool(body.get("force_refresh") or body.get("refresh") or False) + + def _do(): + return { + "plugin": self._client_or_raise().update( + plugin_id, version=version, force_refresh=force + ) + } + + return self._handle_ipc(_do) diff --git a/scripts/check_openapi_contract.py b/scripts/check_openapi_contract.py index 4626b4d..4b630c0 100644 --- a/scripts/check_openapi_contract.py +++ b/scripts/check_openapi_contract.py @@ -245,6 +245,8 @@ def _collect_routes() -> dict[str, RouteInfo]: (WEB_DIR / "companion_endpoints.py", "CompanionAPIEndpoints", ["/companion"]), # Nested /api/update/* endpoints are described as /update/* when documented. (WEB_DIR / "update_endpoints.py", "UpdateAPIEndpoints", ["/update"]), + # Nested /api/plugins/* endpoints are described as /plugins/*. + (WEB_DIR / "plugin_endpoints.py", "PluginAPIEndpoints", ["/plugins"]), # Auth top-level endpoints are mounted at /auth/* (WEB_DIR / "auth_endpoints.py", "AuthEndpoints", ["/auth"]), # Token sub-resource is exposed both under /auth and /api/auth in current routing. diff --git a/tests/test_engine.py b/tests/test_engine.py index 4dc370c..88e6724 100644 --- a/tests/test_engine.py +++ b/tests/test_engine.py @@ -1525,7 +1525,6 @@ class TestStatistics: assert handler.config["radios"][0]["modem_tcp"]["token"] == "nested-secret" -@pytest.mark.asyncio class TestNeighbourLinkObservation: @staticmethod def _observe_with_tracker(handler, pkt, *, rssi: float, snr: float, is_duplicate: bool) -> None: @@ -1546,6 +1545,7 @@ class TestNeighbourLinkObservation: is_duplicate=is_duplicate, ) + @pytest.mark.asyncio async def test_received_flood_with_non_empty_path_creates_link_for_final_hash(self, handler): handler.config["repeater"]["mode"] = "monitor" pkt = _make_hashed_flood_packet(["11", "22", "33"], hash_size=1) @@ -1557,6 +1557,7 @@ class TestNeighbourLinkObservation: assert snapshot[0]["peer_hash"] == "33" assert snapshot[0]["path_hash_size"] == 1 + @pytest.mark.asyncio async def test_uses_last_path_element_not_first_for_upstream_peer(self, handler): handler.config["repeater"]["mode"] = "monitor" pkt = _make_hashed_flood_packet(["AA", "BB", "CC"], hash_size=1) @@ -1567,6 +1568,7 @@ class TestNeighbourLinkObservation: assert snapshot[0]["peer_hash"] == "CC" assert snapshot[0]["peer_hash"] != "AA" + @pytest.mark.asyncio async def test_empty_path_flood_creates_no_neighbour_link(self, handler): handler.config["repeater"]["mode"] = "monitor" pkt = _make_hashed_flood_packet([], hash_size=1) @@ -1575,6 +1577,7 @@ class TestNeighbourLinkObservation: assert handler.neighbour_link_tracker.snapshot() == [] + @pytest.mark.asyncio async def test_direct_packets_create_no_neighbour_link(self, handler): handler.config["repeater"]["mode"] = "monitor" pkt = _make_direct_packet(path=bytes([LOCAL_HASH, 0x44])) @@ -1583,6 +1586,7 @@ class TestNeighbourLinkObservation: assert handler.neighbour_link_tracker.snapshot() == [] + @pytest.mark.asyncio async def test_trace_packets_create_no_neighbour_link(self, handler): handler.config["repeater"]["mode"] = "monitor" pkt = _make_hashed_flood_packet(["11", "22"], hash_size=1, payload_type=PAYLOAD_TYPE_TRACE) @@ -1591,6 +1595,7 @@ class TestNeighbourLinkObservation: assert handler.neighbour_link_tracker.snapshot() == [] + @pytest.mark.asyncio async def test_local_transmissions_create_no_neighbour_link(self, handler): handler.config["repeater"]["mode"] = "no_tx" pkt = _make_hashed_flood_packet(["11", "22"], hash_size=1) @@ -1599,6 +1604,7 @@ class TestNeighbourLinkObservation: assert handler.neighbour_link_tracker.snapshot() == [] + @pytest.mark.asyncio async def test_transport_flood_is_observed(self, handler): handler.config["repeater"]["mode"] = "monitor" pkt = _make_hashed_transport_flood_packet(["19", "2A", "3B"], hash_size=1) @@ -1609,6 +1615,7 @@ class TestNeighbourLinkObservation: assert len(snapshot) == 1 assert snapshot[0]["peer_hash"] == "3B" + @pytest.mark.asyncio async def test_neighbour_link_score_uses_shared_flood_metrics(self, handler): handler.config["repeater"]["mode"] = "monitor" pkt = _make_hashed_flood_packet(["55"], hash_size=1) diff --git a/tests/test_plugin_api_endpoints.py b/tests/test_plugin_api_endpoints.py new file mode 100644 index 0000000..0a1d2b9 --- /dev/null +++ b/tests/test_plugin_api_endpoints.py @@ -0,0 +1,121 @@ +"""Tests for plugin API endpoints and static UI serving.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import cherrypy +import pytest + +from repeater.plugins.manifest import PluginManifest, UISpec +from repeater.plugins.storage import PluginStorage +from repeater.web.http_server import StatsApp +from repeater.web.plugin_endpoints import PluginAPIEndpoints + + +class _Resp: + def __init__(self): + self.status = 200 + self.headers = {} + + +@pytest.fixture +def cherrypy_request(): + cherrypy.request.method = "GET" + cherrypy.request.json = {} + cherrypy.response = _Resp() + yield + cherrypy.response = _Resp() + + +def test_list_returns_503_when_manager_missing(tmp_path, cherrypy_request): + cfg = {"storage": {"storage_dir": str(tmp_path)}} + api = PluginAPIEndpoints(cfg) + cherrypy.request.method = "GET" + result = api.index() + assert result["success"] is False + assert cherrypy.response.status == 503 + + +def test_list_and_status_via_client_mock(tmp_path, cherrypy_request): + cfg = {"storage": {"storage_dir": str(tmp_path)}} + api = PluginAPIEndpoints(cfg) + mock_client = MagicMock() + mock_client.available.return_value = True + mock_client.list_plugins.return_value = [ + {"id": "openhop.demo", "state": "STOPPED", "enabled": False} + ] + mock_client.status.return_value = { + "id": "openhop.demo", + "state": "RUNNING", + "enabled": True, + } + mock_client.enable.return_value = {"id": "openhop.demo", "enabled": True, "state": "RUNNING"} + + with patch.object(api, "_client_or_raise", return_value=mock_client): + cherrypy.request.method = "GET" + listed = api.index() + assert listed["success"] is True + assert listed["plugins"][0]["id"] == "openhop.demo" + + status = api.default("openhop.demo") + assert status["success"] is True + assert status["state"] == "RUNNING" + + cherrypy.request.method = "POST" + cherrypy.request.json = {"id": "openhop.demo"} + enabled = api.enable() + assert enabled["success"] is True + + +def test_ui_serving_index_assets_spa_and_disabled(tmp_path): + plugins_root = tmp_path / "plugins" + storage = PluginStorage(plugins_root) + plugin_id = "openhop.console" + version = "1.0.0" + storage.ensure_plugin_layout(plugin_id, version) + storage.write_manifest( + plugin_id, + version, + PluginManifest( + schema=1, + id=plugin_id, + name="Console", + version=version, + ui=UISpec(type="application", entry="ui/index.html"), + ), + ) + storage.set_current(plugin_id, version) + release = storage.paths_for(plugin_id).release_dir(version) + (release / "ui").mkdir(parents=True) + (release / "ui" / "index.html").write_text("ok") + (release / "ui" / "assets").mkdir() + (release / "ui" / "assets" / "app.js").write_text("console.log(1)") + + app = StatsApp(config={"storage": {"storage_dir": str(tmp_path)}}) + + # Disabled → 404 + storage.write_state(plugin_id, {"version": version, "enabled": False}) + with pytest.raises(cherrypy.HTTPError): + # NotFound is HTTPError subclass in cherrypy + try: + app._serve_plugin_ui(plugin_id, ()) + except cherrypy.NotFound: + raise cherrypy.HTTPError(404) + + storage.write_state(plugin_id, {"version": version, "enabled": True}) + + index = app._serve_plugin_ui(plugin_id, ()) + assert b"ok" in index + + # Paths are relative to the entry directory (ui/), matching Vite base './' + asset = app._serve_plugin_ui(plugin_id, ("assets", "app.js")) + assert b"console.log" in asset + + # SPA fallback + spa = app._serve_plugin_ui(plugin_id, ("dashboard", "settings")) + assert b"ok" in spa + + # Path traversal rejected + with pytest.raises(cherrypy.HTTPError): + app._serve_plugin_ui(plugin_id, ("..", "..", "etc", "passwd")) diff --git a/tests/test_plugin_catalogue.py b/tests/test_plugin_catalogue.py new file mode 100644 index 0000000..fc3f517 --- /dev/null +++ b/tests/test_plugin_catalogue.py @@ -0,0 +1,158 @@ +"""Unit tests for the curated plugin catalogue client.""" + +from __future__ import annotations + +import io +import json +from urllib.error import HTTPError, URLError +from urllib.request import Request + +import pytest + +from repeater.plugins.catalogue import ( + CatalogueClient, + CatalogueError, + parse_catalogue, +) + + +VALID = { + "schema": 1, + "plugins": [ + { + "id": "openhop.nomad", + "name": "NOMAD Bridge", + "description": "desc", + "repository": "openhop-dev/openhop-nomad-plugin", + "category": "integration", + } + ], +} + + +def test_parse_valid_catalogue(): + cat = parse_catalogue(VALID) + assert cat.schema == 1 + assert len(cat.plugins) == 1 + assert cat.plugins[0].id == "openhop.nomad" + assert cat.get("openhop.nomad").repository == "openhop-dev/openhop-nomad-plugin" + + +def test_unsupported_schema(): + with pytest.raises(CatalogueError, match="unsupported catalogue schema"): + parse_catalogue({"schema": 99, "plugins": []}) + + +def test_invalid_repository(): + bad = { + "schema": 1, + "plugins": [ + { + "id": "a.b", + "name": "A", + "description": "", + "repository": "not-a-repo", + } + ], + } + with pytest.raises(CatalogueError, match="owner/repo"): + parse_catalogue(bad) + + +def test_duplicate_plugin_id(): + data = { + "schema": 1, + "plugins": [ + { + "id": "openhop.a", + "name": "A", + "description": "", + "repository": "org/a", + }, + { + "id": "openhop.a", + "name": "B", + "description": "", + "repository": "org/b", + }, + ], + } + with pytest.raises(CatalogueError, match="duplicate plugin id"): + parse_catalogue(data) + + +def test_duplicate_repository(): + data = { + "schema": 1, + "plugins": [ + { + "id": "openhop.a", + "name": "A", + "description": "", + "repository": "org/same", + }, + { + "id": "openhop.b", + "name": "B", + "description": "", + "repository": "org/same", + }, + ], + } + with pytest.raises(CatalogueError, match="duplicate repository"): + parse_catalogue(data) + + +class _FakeResp(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +def test_fetch_and_cache(): + calls = {"n": 0} + + def opener(request: Request, timeout: float = 30.0): + calls["n"] += 1 + assert "catalogue.json" in request.full_url or request.full_url.endswith("x") + return _FakeResp(json.dumps(VALID).encode()) + + client = CatalogueClient("https://example.test/catalogue.json", cache_ttl=60, opener=opener) + a = client.fetch() + client.fetch() # cached + assert a.plugins[0].id == "openhop.nomad" + assert calls["n"] == 1 + c = client.fetch(force_refresh=True) + assert c.plugins[0].id == "openhop.nomad" + assert calls["n"] == 2 + + +def test_lookup(): + def opener(request: Request, timeout: float = 30.0): + return _FakeResp(json.dumps(VALID).encode()) + + client = CatalogueClient("https://example.test/c.json", opener=opener) + plugin = client.get_plugin("openhop.nomad") + assert plugin.name == "NOMAD Bridge" + with pytest.raises(CatalogueError, match="not in catalogue"): + client.get_plugin("missing.plugin") + + +def test_catalogue_unavailable(): + def opener(request: Request, timeout: float = 30.0): + raise URLError("offline") + + client = CatalogueClient("https://example.test/c.json", opener=opener) + with pytest.raises(CatalogueError, match="unavailable"): + client.fetch() + + +def test_http_error(): + def opener(request: Request, timeout: float = 30.0): + raise HTTPError(request.full_url, 500, "err", hdrs=None, fp=None) + + client = CatalogueClient("https://example.test/c.json", opener=opener) + with pytest.raises(CatalogueError, match="unavailable"): + client.fetch() diff --git a/tests/test_plugin_catalogue_install.py b/tests/test_plugin_catalogue_install.py new file mode 100644 index 0000000..0b8c4a6 --- /dev/null +++ b/tests/test_plugin_catalogue_install.py @@ -0,0 +1,244 @@ +"""Catalogue install / update flows against a real PluginManager + mocked HTTP.""" + +from __future__ import annotations + +import io +import json +import zipfile +from pathlib import Path +from urllib.request import Request + +import pytest + +from repeater.plugins.catalogue import CatalogueClient +from repeater.plugins.github_releases import GitHubReleaseClient +from repeater.plugins.manager import PluginManager, PluginManagerError +from repeater.plugins.storage import PluginStorage + + +def _make_wheel(path: Path, plugin_id: str, version: str) -> Path: + path.parent.mkdir(parents=True, exist_ok=True) + manifest = { + "schema": 1, + "id": plugin_id, + "name": plugin_id, + "version": version, + "description": "test", + "ui": {"type": "application", "entry": "ui/index.html"}, + } + with zipfile.ZipFile(path, "w") as zf: + zf.writestr( + f"share/openhop/plugins/{plugin_id}/openhop-plugin.json", + json.dumps(manifest), + ) + zf.writestr("ui/index.html", "") + dist = f"test_plugin-{version}.dist-info" + zf.writestr( + f"{dist}/METADATA", + f"Metadata-Version: 2.1\nName: test-plugin\nVersion: {version}\n", + ) + zf.writestr( + f"{dist}/WHEEL", "Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n" + ) + zf.writestr(f"{dist}/RECORD", "") + return path + + +class _FakeResp(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +CATALOGUE = { + "schema": 1, + "plugins": [ + { + "id": "openhop.demo", + "name": "Demo", + "description": "demo plugin", + "repository": "openhop-dev/demo-plugin", + "category": "test", + } + ], +} + + +def _release(tag: str, wheel_name: str, url: str): + return { + "tag_name": tag, + "name": tag, + "body": f"notes {tag}", + "draft": False, + "prerelease": False, + "html_url": f"https://github.com/openhop-dev/demo-plugin/releases/tag/{tag}", + "published_at": "2026-01-01T00:00:00Z", + "assets": [ + { + "name": wheel_name, + "browser_download_url": url, + "size": 100, + "content_type": "application/octet-stream", + } + ], + } + + +@pytest.fixture +def manager(tmp_path: Path): + wheels = { + "https://example.test/demo-0.1.0.whl": _make_wheel( + tmp_path / "w0.1.0.whl", "openhop.demo", "0.1.0" + ), + "https://example.test/demo-0.2.0.whl": _make_wheel( + tmp_path / "w0.2.0.whl", "openhop.demo", "0.2.0" + ), + "https://example.test/wrong-id.whl": _make_wheel( + tmp_path / "wrong.whl", "other.id", "0.1.0" + ), + } + releases_v1 = [ + _release("v0.1.0", "demo-0.1.0-py3-none-any.whl", "https://example.test/demo-0.1.0.whl") + ] + state = {"releases": releases_v1} + + def opener(request: Request, timeout: float = 30.0): + url = request.full_url + if url.endswith("catalogue.json"): + return _FakeResp(json.dumps(CATALOGUE).encode()) + if "api.github.com" in url and "/releases" in url: + return _FakeResp(json.dumps(state["releases"]).encode()) + if url in wheels: + return _FakeResp(wheels[url].read_bytes()) + raise AssertionError(url) + + storage = PluginStorage(tmp_path / "plugins") + cat = CatalogueClient("https://example.test/catalogue.json", opener=opener) + gh = GitHubReleaseClient(opener=opener) + mgr = PluginManager(storage, catalogue_client=cat, github_client=gh) + mgr._test_state = state # type: ignore[attr-defined] + return mgr + + +def test_catalogue_install_enables_and_sets_source(manager: PluginManager, tmp_path: Path): + status = manager.install_from_catalogue("openhop.demo") + assert status["id"] == "openhop.demo" + assert status["enabled"] is True + assert status["version"] == "0.1.0" + assert status["source"] == "catalogue" + assert status["repository"] == "openhop-dev/demo-plugin" + # data dir exists and survives + data = Path(status["data_dir"]) + assert data.is_dir() + (data / "config.json").write_text('{"kept": true}', encoding="utf-8") + + # update available after bumping releases + manager._test_state["releases"] = [ # type: ignore[attr-defined] + { + "tag_name": "v0.2.0", + "name": "v0.2.0", + "body": "newer", + "draft": False, + "prerelease": False, + "html_url": "https://github.com/x/y/releases/tag/v0.2.0", + "published_at": "2026-02-01T00:00:00Z", + "assets": [ + { + "name": "demo-0.2.0-py3-none-any.whl", + "browser_download_url": "https://example.test/demo-0.2.0.whl", + "size": 1, + "content_type": "application/octet-stream", + } + ], + }, + { + "tag_name": "v0.1.0", + "name": "v0.1.0", + "body": "", + "draft": False, + "prerelease": False, + "html_url": "", + "published_at": "", + "assets": [ + { + "name": "demo-0.1.0-py3-none-any.whl", + "browser_download_url": "https://example.test/demo-0.1.0.whl", + "size": 1, + "content_type": "application/octet-stream", + } + ], + }, + ] + manager.github.clear_cache() + check = manager.check_update("openhop.demo", force_refresh=True) + assert check["updateAvailable"] is True + assert check["latestVersion"] == "0.2.0" + + updated = manager.update_plugin("openhop.demo", force_refresh=True) + assert updated["version"] == "0.2.0" + assert updated["enabled"] is True + assert json.loads((data / "config.json").read_text(encoding="utf-8"))["kept"] is True + # temp download cleaned + download = manager.storage.root / ".download" + if download.exists(): + assert list(download.iterdir()) == [] or all( + not p.name.startswith("openhop-plugin-download-") or not p.exists() + for p in download.iterdir() + ) + + +def test_manifest_id_mismatch_rejected(manager: PluginManager): + # Point catalogue download at wrong-id wheel by swapping releases + manager._test_state["releases"] = [ # type: ignore[attr-defined] + { + "tag_name": "v0.1.0", + "name": "v0.1.0", + "body": "", + "draft": False, + "prerelease": False, + "html_url": "", + "published_at": "", + "assets": [ + { + "name": "wrong-0.1.0-py3-none-any.whl", + "browser_download_url": "https://example.test/wrong-id.whl", + "size": 1, + "content_type": "application/octet-stream", + } + ], + } + ] + manager.github.clear_cache() + with pytest.raises(PluginManagerError, match="does not match catalogue id"): + manager.install_from_catalogue("openhop.demo", force_refresh=True) + + +def test_same_version_no_update(manager: PluginManager): + manager.install_from_catalogue("openhop.demo") + check = manager.check_update("openhop.demo") + assert check["updateAvailable"] is False + status = manager.update_plugin("openhop.demo") + assert status.get("updated") is False + assert status["version"] == "0.1.0" + + +def test_list_catalogue_annotates_installed(manager: PluginManager): + listed = manager.list_catalogue() + assert listed["plugins"][0]["installed"] is False + manager.install_from_catalogue("openhop.demo") + manager.github.clear_cache() + listed2 = manager.list_catalogue(force_refresh=True) + row = listed2["plugins"][0] + assert row["installed"] is True + assert row["installedVersion"] == "0.1.0" + assert row["latestVersion"] == "0.1.0" + + +def test_local_install_update_unavailable(manager: PluginManager, tmp_path: Path): + wheel = _make_wheel(tmp_path / "local.whl", "openhop.local", "1.0.0") + manager.install(wheel) + check = manager.check_update("openhop.local") + assert check["updateAvailable"] is False + assert "unavailable" in (check.get("reason") or "").lower() diff --git a/tests/test_plugin_github_releases.py b/tests/test_plugin_github_releases.py new file mode 100644 index 0000000..84dd475 --- /dev/null +++ b/tests/test_plugin_github_releases.py @@ -0,0 +1,174 @@ +"""Unit tests for GitHub Releases plugin client.""" + +from __future__ import annotations + +import io +import json +from pathlib import Path +from urllib.error import HTTPError +from urllib.request import Request + +import pytest + +from repeater.plugins.github_releases import ( + GitHubRateLimitError, + GitHubReleaseClient, + GitHubReleasesError, + PluginRelease, + ReleaseAsset, + normalize_version, + select_wheel_asset, +) + + +def _release( + tag: str, + *, + draft: bool = False, + prerelease: bool = False, + assets: list[dict] | None = None, +): + return { + "tag_name": tag, + "name": tag, + "body": f"notes for {tag}", + "draft": draft, + "prerelease": prerelease, + "html_url": f"https://github.com/org/repo/releases/tag/{tag}", + "published_at": "2026-01-01T00:00:00Z", + "assets": assets + or [ + { + "name": f"pkg-{tag.lstrip('v')}-py3-none-any.whl", + "browser_download_url": f"https://example.test/{tag}.whl", + "size": 12, + "content_type": "application/octet-stream", + } + ], + } + + +class _FakeResp(io.BytesIO): + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + +def test_normalize_version(): + assert normalize_version("v1.2.3") == "1.2.3" + assert normalize_version("1.2.3") == "1.2.3" + assert normalize_version("V0.1.0") == "0.1.0" + + +def test_stable_release_discovery_and_ordering(): + payload = [ + _release("v0.1.0"), + _release("v0.3.0"), + _release("v0.2.0", prerelease=True), + _release("v0.4.0", draft=True), + ] + + def opener(request: Request, timeout: float = 30.0): + if "api.github.com" in request.full_url: + return _FakeResp(json.dumps(payload).encode()) + raise AssertionError(request.full_url) + + client = GitHubReleaseClient(opener=opener, cache_ttl=60) + releases = client.list_releases("openhop-dev/openhop-nomad-plugin") + assert [r.version for r in releases] == ["0.3.0", "0.1.0"] + latest = client.latest_stable("openhop-dev/openhop-nomad-plugin") + assert latest.version == "0.3.0" + + +def test_wheel_asset_selection(): + rel = PluginRelease( + tag="v1.0.0", + version="1.0.0", + name="1.0.0", + body="", + prerelease=False, + draft=False, + html_url="", + published_at="", + assets=( + ReleaseAsset("notes.txt", "https://x/notes.txt"), + ReleaseAsset("pkg-1.0.0-py3-none-any.whl", "https://x/pkg.whl"), + ), + ) + assert select_wheel_asset(rel).name.endswith(".whl") + + none = PluginRelease( + tag="v1", + version="1", + name="", + body="", + prerelease=False, + draft=False, + html_url="", + published_at="", + assets=(ReleaseAsset("x.zip", "https://x/x.zip"),), + ) + with pytest.raises(GitHubReleasesError, match="no .whl"): + select_wheel_asset(none) + + many = PluginRelease( + tag="v1", + version="1", + name="", + body="", + prerelease=False, + draft=False, + html_url="", + published_at="", + assets=( + ReleaseAsset("a.whl", "https://x/a.whl"), + ReleaseAsset("b.whl", "https://x/b.whl"), + ), + ) + with pytest.raises(GitHubReleasesError, match="multiple"): + select_wheel_asset(many) + + +def test_download_wheel(tmp_path: Path): + payload = [_release("v0.1.0")] + + def opener(request: Request, timeout: float = 30.0): + if "api.github.com" in request.full_url: + return _FakeResp(json.dumps(payload).encode()) + if request.full_url.endswith(".whl"): + return _FakeResp(b"wheel-bytes") + raise AssertionError(request.full_url) + + client = GitHubReleaseClient(opener=opener) + release, path = client.download_latest_wheel("org/repo", tmp_path) + assert release.version == "0.1.0" + assert path.read_bytes() == b"wheel-bytes" + assert path.name.endswith(".whl") + + +def test_rate_limit(): + def opener(request: Request, timeout: float = 30.0): + headers = {"X-RateLimit-Remaining": "0"} + raise HTTPError(request.full_url, 403, "rate limit", hdrs=headers, fp=None) + + client = GitHubReleaseClient(opener=opener) + with pytest.raises(GitHubRateLimitError): + client.list_releases("org/repo") + + +def test_cache_behaviour(): + calls = {"n": 0} + payload = [_release("v1.0.0")] + + def opener(request: Request, timeout: float = 30.0): + calls["n"] += 1 + return _FakeResp(json.dumps(payload).encode()) + + client = GitHubReleaseClient(opener=opener, cache_ttl=60) + client.list_releases("org/repo") + client.list_releases("org/repo") + assert calls["n"] == 1 + client.list_releases("org/repo", force_refresh=True) + assert calls["n"] == 2 diff --git a/tests/test_plugin_ipc.py b/tests/test_plugin_ipc.py new file mode 100644 index 0000000..deb3f40 --- /dev/null +++ b/tests/test_plugin_ipc.py @@ -0,0 +1,107 @@ +"""IPC tests for plugin manager Unix socket protocol.""" + +from __future__ import annotations + +import json +import subprocess +import zipfile +from pathlib import Path + +import pytest + +from repeater.plugins.ipc import ( + PluginIPCClient, + PluginIPCError, + PluginIPCServer, + PluginManagerUnavailable, +) +from repeater.plugins.manager import PluginManager +from repeater.plugins.runtime import PluginRuntime +from repeater.plugins.storage import PluginStorage + + +class FakeProc: + def __init__(self, pid=1): + self.pid = pid + self._code = None + + def poll(self): + return self._code + + def send_signal(self, sig): + self._code = 0 + + def kill(self): + self._code = -9 + + def wait(self, timeout=None): + return self._code + + +def _wheel(path: Path) -> Path: + manifest = { + "schema": 1, + "id": "openhop.demo", + "name": "Demo", + "version": "0.1.0", + "runtime": {"type": "python", "entrypoint": "demo-cli"}, + } + with zipfile.ZipFile(path, "w") as zf: + zf.writestr( + "share/openhop/plugins/openhop.demo/openhop-plugin.json", + json.dumps(manifest), + ) + return path + + +def _manager(tmp_path: Path) -> tuple[PluginManager, Path]: + storage = PluginStorage(tmp_path / "plugins") + + def fake_run(cmd, **kwargs): + if "venv" in cmd: + venv = Path(cmd[-1]) + (venv / "bin").mkdir(parents=True, exist_ok=True) + (venv / "bin" / "python").write_text("x") + (venv / "bin" / "python").chmod(0o755) + if "pip" in cmd: + Path(cmd[0]).parent.joinpath("demo-cli").write_text("x") + Path(cmd[0]).parent.joinpath("demo-cli").chmod(0o755) + return subprocess.CompletedProcess(cmd, 0, "", "") + + runtime = PluginRuntime(storage, run_factory=fake_run, popen_factory=lambda *a, **k: FakeProc()) + # macOS AF_UNIX path length is limited; keep the socket under /tmp. + sock_path = Path(f"/tmp/oh-pm-{tmp_path.name}.sock") + return PluginManager(storage, runtime), sock_path + + +def test_ipc_list_status_start_stop(tmp_path: Path): + manager, sock_path = _manager(tmp_path) + wheel = _wheel(tmp_path / "demo-0.1.0-py3-none-any.whl") + manager.install(wheel) + + server = PluginIPCServer(sock_path, manager) + server.start() + try: + client = PluginIPCClient(sock_path) + plugins = client.list_plugins() + assert any(p["id"] == "openhop.demo" for p in plugins) + + client.enable("openhop.demo") + st = client.status("openhop.demo") + assert st["state"] == "RUNNING" + + client.stop("openhop.demo") + st = client.status("openhop.demo") + assert st["state"] == "STOPPED" + + with pytest.raises(PluginIPCError): + client.status("missing.plugin") + finally: + server.stop() + manager.stop_all() + + +def test_manager_unavailable(): + client = PluginIPCClient("/tmp/definitely-missing-openhop-plugin-manager.sock") + with pytest.raises(PluginManagerUnavailable): + client.list_plugins() diff --git a/tests/test_plugin_manager_lifecycle.py b/tests/test_plugin_manager_lifecycle.py new file mode 100644 index 0000000..a1ddce1 --- /dev/null +++ b/tests/test_plugin_manager_lifecycle.py @@ -0,0 +1,425 @@ +"""Lifecycle and install tests for the plugin manager.""" + +from __future__ import annotations + +import json +import subprocess +import zipfile +from pathlib import Path + +import pytest + +from repeater.plugins.manager import PluginManager, PluginManagerError +from repeater.plugins.manifest import PluginManifest, RuntimeSpec +from repeater.plugins.runtime import PluginRuntime, PluginState +from repeater.plugins.storage import PluginStorage + + +class FakeProc: + def __init__(self, pid: int = 4242): + self.pid = pid + self._code = None + self.signals = [] + + def poll(self): + return self._code + + def send_signal(self, sig): + self.signals.append(sig) + self._code = 0 + + def kill(self): + self._code = -9 + + def wait(self, timeout=None): + return self._code + + def exit(self, code=1): + self._code = code + + +def _make_wheel(path: Path, manifest: dict, ui_files: dict[str, str] | None = None) -> Path: + with zipfile.ZipFile(path, "w") as zf: + zf.writestr( + f"share/openhop/plugins/{manifest['id']}/openhop-plugin.json", + json.dumps(manifest), + ) + if ui_files: + for name, content in ui_files.items(): + zf.writestr(name, content) + return path + + +def test_install_creates_layout_and_isolated_venv(tmp_path: Path, monkeypatch): + storage = PluginStorage(tmp_path / "plugins") + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(list(cmd)) + # Simulate venv creation by writing python binary + if "-m" in cmd and "venv" in cmd: + venv = Path(cmd[-1]) + bin_dir = venv / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + (bin_dir / "python").write_text("#!/bin/sh\n") + (bin_dir / "python").chmod(0o755) + if "-m" in cmd and "pip" in cmd: + # create entrypoint after pip install + py = Path(cmd[0]) + entry = py.parent / "demo-cli" + entry.write_text("#!/bin/sh\n") + entry.chmod(0o755) + return subprocess.CompletedProcess(cmd, 0, "", "") + + runtime = PluginRuntime(storage, run_factory=fake_run) + manager = PluginManager(storage, runtime) + + wheel = tmp_path / "demo-0.1.0-py3-none-any.whl" + _make_wheel( + wheel, + { + "schema": 1, + "id": "openhop.demo", + "name": "Demo", + "version": "0.1.0", + "runtime": {"type": "python", "entrypoint": "demo-cli"}, + }, + ) + + status = manager.install(wheel) + assert status["id"] == "openhop.demo" + assert status["enabled"] is False + paths = storage.paths_for("openhop.demo") + assert paths.data_dir.is_dir() + assert paths.venv_dir("0.1.0").is_dir() + assert any("venv" in c for c in calls) + assert any("pip" in c for c in calls) + # Ensure we never invoked the system pip against a non-venv target alone + for c in calls: + if "pip" in c: + assert str(paths.venv_dir("0.1.0")) in c[0] or "venv" in "".join(c) + + +def test_enable_start_stop_disable(tmp_path: Path): + storage = PluginStorage(tmp_path / "plugins") + procs: list[FakeProc] = [] + + def fake_popen(cmd, **kwargs): + p = FakeProc(pid=1000 + len(procs)) + procs.append(p) + # Write a line to log fd if provided + log_fp = kwargs.get("stdout") + if log_fp and hasattr(log_fp, "write"): + log_fp.write("hello from plugin\n") + return p + + def fake_run(cmd, **kwargs): + if "-m" in cmd and "venv" in cmd: + venv = Path(cmd[-1]) + (venv / "bin").mkdir(parents=True, exist_ok=True) + (venv / "bin" / "python").write_text("x") + (venv / "bin" / "python").chmod(0o755) + if "-m" in cmd and "pip" in cmd: + py = Path(cmd[0]) + ep = py.parent / "demo-cli" + ep.write_text("x") + ep.chmod(0o755) + return subprocess.CompletedProcess(cmd, 0, "", "") + + runtime = PluginRuntime(storage, popen_factory=fake_popen, run_factory=fake_run) + manager = PluginManager(storage, runtime) + + wheel = tmp_path / "demo-0.1.0-py3-none-any.whl" + _make_wheel( + wheel, + { + "schema": 1, + "id": "openhop.demo", + "name": "Demo", + "version": "0.1.0", + "runtime": {"type": "python", "entrypoint": "demo-cli"}, + }, + ) + manager.install(wheel) + + st = manager.enable("openhop.demo") + assert st["enabled"] is True + assert st["state"] == PluginState.RUNNING.value + assert len(procs) == 1 + + st = manager.stop_plugin("openhop.demo") + assert st["state"] == PluginState.STOPPED.value + + st = manager.start_plugin("openhop.demo") + assert st["state"] == PluginState.RUNNING.value + + st = manager.restart_plugin("openhop.demo") + assert st["state"] == PluginState.RUNNING.value + + st = manager.disable("openhop.demo") + assert st["enabled"] is False + assert st["state"] == PluginState.DISABLED.value + + logs = manager.logs("openhop.demo", tail=10) + assert any("hello" in line for line in logs["lines"]) + + +def test_crash_loop_becomes_failed(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": True}) + storage.write_manifest( + "openhop.demo", + "0.1.0", + PluginManifest( + schema=1, + id="openhop.demo", + name="Demo", + version="0.1.0", + runtime=RuntimeSpec(type="python", entrypoint="demo-cli"), + ), + ) + storage.set_current("openhop.demo", "0.1.0") + paths = storage.paths_for("openhop.demo") + bin_dir = paths.venv_dir("0.1.0") / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + (bin_dir / "demo-cli").write_text("x") + (bin_dir / "demo-cli").chmod(0o755) + + procs: list[FakeProc] = [] + + def fake_popen(cmd, **kwargs): + p = FakeProc(pid=2000 + len(procs)) + procs.append(p) + return p + + runtime = PluginRuntime( + storage, + popen_factory=fake_popen, + crash_max_exits=3, + crash_window=60.0, + ) + runtime.start("openhop.demo") + assert runtime.get_state("openhop.demo") == PluginState.RUNNING + + # Unexpected exits → restart until FAILED + for _ in range(3): + procs[-1].exit(1) + runtime._check_crashes() + + assert runtime.get_state("openhop.demo") == PluginState.FAILED + assert len(procs) >= 3 + + +def test_unexpected_exit_restarts(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": True}) + storage.write_manifest( + "openhop.demo", + "0.1.0", + PluginManifest( + schema=1, + id="openhop.demo", + name="Demo", + version="0.1.0", + runtime=RuntimeSpec(type="python", entrypoint="demo-cli"), + ), + ) + storage.set_current("openhop.demo", "0.1.0") + bin_dir = storage.paths_for("openhop.demo").venv_dir("0.1.0") / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + (bin_dir / "demo-cli").write_text("x") + (bin_dir / "demo-cli").chmod(0o755) + + procs: list[FakeProc] = [] + + def fake_popen(cmd, **kwargs): + p = FakeProc(pid=3000 + len(procs)) + procs.append(p) + return p + + runtime = PluginRuntime(storage, popen_factory=fake_popen, crash_max_exits=5) + runtime.start("openhop.demo") + procs[0].exit(1) + runtime._check_crashes() + assert runtime.get_state("openhop.demo") == PluginState.RUNNING + assert len(procs) == 2 + + +def test_uninstall_keeps_data_by_default(tmp_path: Path): + storage = PluginStorage(tmp_path / "plugins") + + def fake_run(cmd, **kwargs): + if "venv" in cmd: + venv = Path(cmd[-1]) + (venv / "bin").mkdir(parents=True, exist_ok=True) + (venv / "bin" / "python").write_text("x") + (venv / "bin" / "python").chmod(0o755) + if "pip" in cmd: + Path(cmd[0]).parent.joinpath("demo-cli").write_text("x") + Path(cmd[0]).parent.joinpath("demo-cli").chmod(0o755) + return subprocess.CompletedProcess(cmd, 0, "", "") + + runtime = PluginRuntime(storage, run_factory=fake_run, popen_factory=lambda *a, **k: FakeProc()) + manager = PluginManager(storage, runtime) + wheel = tmp_path / "demo-0.1.0-py3-none-any.whl" + _make_wheel( + wheel, + { + "schema": 1, + "id": "openhop.demo", + "name": "Demo", + "version": "0.1.0", + "runtime": {"type": "python", "entrypoint": "demo-cli"}, + }, + ) + manager.install(wheel) + paths = storage.paths_for("openhop.demo") + (paths.data_dir / "keep.txt").write_text("keep") + manager.enable("openhop.demo") + manager.uninstall("openhop.demo", delete_data=False) + assert (paths.data_dir / "keep.txt").is_file() + assert storage.read_state("openhop.demo") is None + + +def test_manager_start_enables_boot_plugins(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": True}) + storage.write_manifest( + "openhop.demo", + "0.1.0", + PluginManifest( + schema=1, + id="openhop.demo", + name="Demo", + version="0.1.0", + runtime=RuntimeSpec(type="python", entrypoint="demo-cli"), + ), + ) + storage.set_current("openhop.demo", "0.1.0") + bin_dir = storage.paths_for("openhop.demo").venv_dir("0.1.0") / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + (bin_dir / "demo-cli").write_text("x") + (bin_dir / "demo-cli").chmod(0o755) + + started = [] + + def fake_popen(cmd, **kwargs): + started.append(cmd) + return FakeProc() + + runtime = PluginRuntime(storage, popen_factory=fake_popen) + manager = PluginManager(storage, runtime) + manager.start() + assert started + manager.stop_all() + + +def test_get_config_uses_manifest_defaults_when_unsaved(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}) + storage.write_manifest( + "openhop.demo", + "0.1.0", + PluginManifest( + schema=1, + id="openhop.demo", + name="Demo", + version="0.1.0", + runtime=RuntimeSpec(type="python", entrypoint="demo-cli"), + config_defaults={"meshcore_host": "127.0.0.1", "nomad_url": "http://x"}, + ), + ) + storage.set_current("openhop.demo", "0.1.0") + manager = PluginManager(storage) + cfg = manager.get_config("openhop.demo") + assert cfg["exists"] is False + assert cfg["defaults"]["meshcore_host"] == "127.0.0.1" + assert cfg["config"]["nomad_url"] == "http://x" + + +def test_get_set_config_and_optional_restart(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": True}) + storage.write_manifest( + "openhop.demo", + "0.1.0", + PluginManifest( + schema=1, + id="openhop.demo", + name="Demo", + version="0.1.0", + runtime=RuntimeSpec(type="python", entrypoint="demo-cli"), + ), + ) + storage.set_current("openhop.demo", "0.1.0") + bin_dir = storage.paths_for("openhop.demo").venv_dir("0.1.0") / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + (bin_dir / "demo-cli").write_text("x") + (bin_dir / "demo-cli").chmod(0o755) + + procs: list[FakeProc] = [] + + def fake_popen(cmd, **kwargs): + p = FakeProc(pid=4000 + len(procs)) + procs.append(p) + return p + + runtime = PluginRuntime(storage, popen_factory=fake_popen) + manager = PluginManager(storage, runtime) + + cfg = manager.get_config("openhop.demo") + assert cfg["config"] == {} + assert cfg["exists"] is False + + saved = manager.set_config( + "openhop.demo", + {"nomad_url": "http://example", "nomad_model": "qwen"}, + restart=True, + ) + assert saved["config"]["nomad_url"] == "http://example" + assert saved["exists"] is True + assert saved["restarted"] is True + assert len(procs) >= 1 + + +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 + + bad = tmp_path / "plugin-p7kja66a.whl" + with zipfile.ZipFile(bad, "w") as zf: + zf.writestr( + "openhop_nomad_plugin-0.1.0.dist-info/METADATA", + "Metadata-Version: 2.1\nName: openhop-nomad-plugin\nVersion: 0.1.0\n", + ) + zf.writestr("pkg/__init__.py", "") + + fixed = PluginRuntime._ensure_pip_wheel_filename(bad) + assert fixed.name == "openhop_nomad_plugin-0.1.0-py3-none-any.whl" + assert fixed.is_file() + assert PluginRuntime._looks_like_wheel_filename(fixed.name) + + +def test_start_disabled_raises(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}) + storage.write_manifest( + "openhop.demo", + "0.1.0", + PluginManifest( + schema=1, + id="openhop.demo", + name="Demo", + version="0.1.0", + runtime=RuntimeSpec(type="python", entrypoint="demo-cli"), + ), + ) + manager = PluginManager(storage) + with pytest.raises(PluginManagerError): + manager.start_plugin("openhop.demo") diff --git a/tests/test_plugin_manifest.py b/tests/test_plugin_manifest.py new file mode 100644 index 0000000..fed6c72 --- /dev/null +++ b/tests/test_plugin_manifest.py @@ -0,0 +1,152 @@ +"""Tests for plugin manifest parsing.""" + +import json +import zipfile +from pathlib import Path + +import pytest + +from repeater.plugins.manifest import ManifestError, load_manifest_from_wheel, parse_manifest + + +def test_valid_service_plugin(): + m = parse_manifest( + { + "schema": 1, + "id": "openhop.nomad", + "name": "NOMAD Bridge", + "version": "0.1.0", + "runtime": {"type": "python", "entrypoint": "meshcore-nomad-bridge"}, + } + ) + assert m.id == "openhop.nomad" + assert m.runtime.entrypoint == "meshcore-nomad-bridge" + assert m.ui is None + + +def test_valid_ui_application_plugin(): + m = parse_manifest( + { + "schema": 1, + "id": "openhop.console", + "name": "Console", + "version": "1.0.0", + "ui": {"type": "application", "entry": "ui/index.html"}, + } + ) + assert m.ui.entry == "ui/index.html" + assert m.runtime is None + + +def test_valid_hybrid_plugin(): + m = parse_manifest( + { + "schema": 1, + "id": "openhop.hybrid", + "name": "Hybrid", + "version": "2.3.4", + "runtime": {"type": "python", "entrypoint": "hybrid-svc"}, + "ui": {"type": "application", "entry": "web/index.html"}, + } + ) + assert m.runtime is not None and m.ui is not None + + +def test_unsupported_schema(): + with pytest.raises(ManifestError, match="schema"): + parse_manifest( + { + "schema": 99, + "id": "x.y", + "name": "X", + "version": "1.0.0", + "runtime": {"type": "python", "entrypoint": "x"}, + } + ) + + +def test_unsafe_plugin_id(): + with pytest.raises(ManifestError): + parse_manifest( + { + "schema": 1, + "id": "../etc", + "name": "X", + "version": "1.0.0", + "runtime": {"type": "python", "entrypoint": "x"}, + } + ) + with pytest.raises(ManifestError): + parse_manifest( + { + "schema": 1, + "id": "Bad_ID", + "name": "X", + "version": "1.0.0", + "runtime": {"type": "python", "entrypoint": "x"}, + } + ) + + +def test_unsafe_ui_path(): + with pytest.raises(ManifestError, match="parent-directory"): + parse_manifest( + { + "schema": 1, + "id": "openhop.x", + "name": "X", + "version": "1.0.0", + "ui": {"type": "application", "entry": "../../etc/passwd"}, + } + ) + + +def test_unsupported_runtime(): + with pytest.raises(ManifestError, match="runtime.type"): + parse_manifest( + { + "schema": 1, + "id": "openhop.x", + "name": "X", + "version": "1.0.0", + "runtime": {"type": "docker", "entrypoint": "x"}, + } + ) + + +def test_requires_runtime_or_ui(): + with pytest.raises(ManifestError, match="runtime and/or ui"): + parse_manifest({"schema": 1, "id": "openhop.x", "name": "X", "version": "1.0.0"}) + + +def test_manifest_config_defaults(): + m = parse_manifest( + { + "schema": 1, + "id": "openhop.demo", + "name": "Demo", + "version": "0.1.0", + "runtime": {"type": "python", "entrypoint": "demo"}, + "config": {"defaults": {"host": "127.0.0.1", "port": 1}}, + } + ) + assert m.config_defaults == {"host": "127.0.0.1", "port": 1} + assert m.to_dict()["config"]["defaults"]["port"] == 1 + + +def test_load_manifest_from_wheel(tmp_path: Path): + wheel = tmp_path / "demo-0.1.0-py3-none-any.whl" + manifest = { + "schema": 1, + "id": "openhop.demo", + "name": "Demo", + "version": "0.1.0", + "runtime": {"type": "python", "entrypoint": "demo-cli"}, + } + with zipfile.ZipFile(wheel, "w") as zf: + zf.writestr( + "share/openhop/plugins/openhop.demo/openhop-plugin.json", + json.dumps(manifest), + ) + loaded = load_manifest_from_wheel(wheel) + assert loaded.id == "openhop.demo" diff --git a/tests/test_plugin_storage.py b/tests/test_plugin_storage.py new file mode 100644 index 0000000..109befa --- /dev/null +++ b/tests/test_plugin_storage.py @@ -0,0 +1,79 @@ +"""Tests for plugin filesystem layout and state.""" + +from pathlib import Path + +import pytest + +from repeater.plugins.manifest import PluginManifest, RuntimeSpec +from repeater.plugins.storage import PluginStorage, safe_join + + +def test_safe_path_generation(tmp_path: Path): + storage = PluginStorage(tmp_path / "plugins") + paths = storage.paths_for("openhop.demo") + assert paths.data_dir == storage.root / "openhop.demo" / "data" + assert paths.venv_dir("1.0.0").name == "venv" + + +def test_safe_join_rejects_traversal(tmp_path: Path): + root = tmp_path / "root" + root.mkdir() + (root / "ok.txt").write_text("x") + assert safe_join(root, "ok.txt").is_file() + with pytest.raises(ValueError): + safe_join(root, "..", "etc", "passwd") + + +def test_version_dirs_separate_and_data_survives(tmp_path: Path): + storage = PluginStorage(tmp_path / "plugins") + plugin_id = "openhop.demo" + storage.ensure_plugin_layout(plugin_id, "1.0.0") + storage.ensure_plugin_layout(plugin_id, "2.0.0") + paths = storage.paths_for(plugin_id) + (paths.data_dir / "config.json").write_text('{"a":1}') + storage.write_state(plugin_id, {"version": "1.0.0", "enabled": False}) + storage.write_manifest( + plugin_id, + "1.0.0", + PluginManifest( + schema=1, + id=plugin_id, + name="Demo", + version="1.0.0", + runtime=RuntimeSpec(type="python", entrypoint="demo"), + ), + ) + storage.set_current(plugin_id, "1.0.0") + + assert paths.release_dir("1.0.0").is_dir() + assert paths.release_dir("2.0.0").is_dir() + assert (paths.data_dir / "config.json").read_text() == '{"a":1}' + + # Reinstall/remove code keeps data by default + storage.remove_release_code(plugin_id, keep_data=True) + assert (paths.data_dir / "config.json").is_file() + assert not paths.releases_dir.exists() + assert storage.read_state(plugin_id) is None + + +def test_config_read_write_roundtrip(tmp_path: Path): + storage = PluginStorage(tmp_path / "plugins") + plugin_id = "openhop.demo" + storage.ensure_plugin_layout(plugin_id, "1.0.0") + storage.write_state(plugin_id, {"version": "1.0.0", "enabled": False}) + + assert storage.read_config(plugin_id) == {} + storage.write_config(plugin_id, {"nomad_url": "http://127.0.0.1:8080", "one_shot": True}) + assert storage.read_config(plugin_id)["nomad_url"] == "http://127.0.0.1:8080" + assert storage.config_path(plugin_id).is_file() + + +def test_uninstall_delete_data(tmp_path: Path): + storage = PluginStorage(tmp_path / "plugins") + plugin_id = "openhop.demo" + storage.ensure_plugin_layout(plugin_id, "1.0.0") + paths = storage.paths_for(plugin_id) + (paths.data_dir / "x").write_text("y") + storage.write_state(plugin_id, {"version": "1.0.0", "enabled": True}) + storage.remove_release_code(plugin_id, keep_data=False) + assert not paths.data_dir.exists()