"""Plugin lifecycle orchestration.""" from __future__ import annotations import json import logging import os import shutil import stat import tempfile import threading import time from contextlib import contextmanager from functools import wraps from pathlib import Path from typing import Any, Optional from .catalogue import DEFAULT_CATALOGUE_URL, MAX_WHEEL_BYTES, CatalogueClient, CatalogueError 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") PROGRESS_MAX_LINES = 500 PROGRESS_MAX_LOGS = 32 class OperationProgress: """Bounded output log of a plugin's last install or update.""" def __init__(self, plugin_id: str, operation: str): self.plugin_id = plugin_id self.operation = operation self.state = "running" self.error: Optional[str] = None self.started = time.time() self.finished: Optional[float] = None self._lines: list[str] = [] self._dropped = 0 # cursors count every line ever appended self._lock = threading.Lock() def append(self, line: str) -> None: with self._lock: self._lines.append(line) excess = len(self._lines) - PROGRESS_MAX_LINES if excess > 0: del self._lines[:excess] self._dropped += excess def finish(self, error: Optional[str] = None) -> None: with self._lock: self.state = "error" if error else "complete" self.error = error self.finished = time.time() def snapshot(self, since: int = 0) -> dict[str, Any]: with self._lock: start = max(0, min(int(since) - self._dropped, len(self._lines))) return { "id": self.plugin_id, "operation": self.operation, "state": self.state, "error": self.error, "lines": list(self._lines[start:]), "next": self._dropped + len(self._lines), "started": self.started, "finished": self.finished, } def _reported(operation: str): """Record the operation's installer output and outcome for /plugins/progress.""" def decorate(fn): @wraps(fn) def wrapped(self, plugin_id, *args, **kwargs): progress = self._start_progress(plugin_id, operation) previous = self.runtime.on_output self.runtime.on_output = progress.append try: result = fn(self, plugin_id, *args, **kwargs) except Exception as exc: progress.finish(str(exc)) raise finally: self.runtime.on_output = previous if progress.finished is None: progress.finish() return result return wrapped return decorate def _serialized_plugin(fn): """Keep a complete lifecycle transaction exclusive, without queuing callers.""" @wraps(fn) def wrapped(self, plugin_id, *args, **kwargs): with self._operation(plugin_id): return fn(self, plugin_id, *args, **kwargs) return wrapped 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._operations: dict[str, tuple[int, int]] = {} self._progress: dict[str, OperationProgress] = {} self.catalogue = catalogue_client or CatalogueClient(catalogue_url or DEFAULT_CATALOGUE_URL) self.github = github_client or GitHubReleaseClient() @contextmanager def _operation(self, plugin_id: str): owner = threading.get_ident() with self._lock: active = self._operations.get(plugin_id) if active and active[0] != owner: raise PluginManagerError(f"plugin operation already in progress: {plugin_id}", 409) self._operations[plugin_id] = (owner, active[1] + 1 if active else 1) try: yield finally: with self._lock: depth = self._operations[plugin_id][1] if depth == 1: del self._operations[plugin_id] else: self._operations[plugin_id] = (owner, depth - 1) def _start_progress(self, plugin_id: str, operation: str) -> OperationProgress: progress = OperationProgress(plugin_id, operation) with self._lock: self._progress[plugin_id] = progress finished = sorted( (p for p in self._progress.values() if p.state != "running"), key=lambda p: p.finished or 0, ) for old in finished[: max(0, len(self._progress) - PROGRESS_MAX_LOGS)]: del self._progress[old.plugin_id] return progress def progress(self, plugin_id: str, since: int = 0) -> dict[str, Any]: with self._lock: progress = self._progress.get(plugin_id) if progress is None: return { "id": plugin_id, "operation": None, "state": "idle", "error": None, "lines": [], "next": 0, "started": None, "finished": None, } return progress.snapshot(since) @contextmanager def stage_upload(self, wheel_path: str): """Own a bounded regular-file copy before IPC acknowledges processing. Read through one opened descriptor, so unlinking an HTTP upload cannot invalidate an in-progress copy. Never consume the caller's path later. """ root = self.storage.root / ".ipc-staging" root.mkdir(parents=True, exist_ok=True) limit = MAX_WHEEL_BYTES deadline = time.monotonic() + 30 with tempfile.TemporaryDirectory(prefix="install-", dir=root) as directory: source = Path(wheel_path) staged = Path(directory) / source.name fd = os.open(source, os.O_RDONLY | os.O_NONBLOCK) with os.fdopen(fd, "rb") as incoming: metadata = os.fstat(incoming.fileno()) if not stat.S_ISREG(metadata.st_mode): raise PluginManagerError("wheel must be a regular file", 400) if metadata.st_size > limit: raise PluginManagerError("wheel exceeds 100 MiB limit", 413) total = 0 with staged.open("wb") as outgoing: while chunk := incoming.read(1024 * 1024): total += len(chunk) if total > limit: raise PluginManagerError("wheel exceeds 100 MiB limit", 413) if time.monotonic() > deadline: raise PluginManagerError("wheel staging deadline exceeded", 504) outgoing.write(chunk) yield staged 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._operation(manifest.id): 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"]) @_serialized_plugin def enable(self, plugin_id: str) -> dict[str, Any]: 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) @_serialized_plugin def disable(self, plugin_id: str) -> dict[str, Any]: 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) @_serialized_plugin def start_plugin(self, plugin_id: str) -> dict[str, Any]: 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) @_serialized_plugin def stop_plugin(self, plugin_id: str) -> dict[str, Any]: 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) @_serialized_plugin def restart_plugin(self, plugin_id: str) -> dict[str, Any]: 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, } @_serialized_plugin 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) 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 get_runtime(self, plugin_id: str) -> dict[str, Any]: """Return plugin runtime snapshot from data/runtime.json for UIs.""" state = self.storage.read_state(plugin_id) if state is None: raise PluginManagerError(f"plugin not found: {plugin_id}", 404) paths = self.storage.paths_for(plugin_id) paths.data_dir.mkdir(parents=True, exist_ok=True) try: runtime = self.storage.read_runtime(plugin_id) except ValueError as exc: raise PluginManagerError(str(exc), 500) from exc path = self.storage.runtime_path(plugin_id) return { "id": plugin_id, "path": str(path), "exists": path.is_file(), "runtime": runtime, } @_serialized_plugin def uninstall(self, plugin_id: str, *, delete_data: 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) 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, } # Schema 2 carries the openHop-approved version directly. Schema 1 # remains readable for compatibility with custom legacy catalogues. latest_version = entry.version update_available = False if entry.has_approved_wheel: if installed and installed_version and latest_version: update_available = self._version_gt(latest_version, installed_version) else: 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} @_serialized_plugin @_reported("install") def install_from_catalogue( self, plugin_id: str, *, version: Optional[str] = None, force_refresh: bool = False, ) -> dict[str, Any]: """Download an approved catalogue wheel and install it.""" 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: if entry.has_approved_wheel: if version and normalize_version(version) != normalize_version(entry.version or ""): raise PluginManagerError( f"version {version!r} is not approved for {entry.id}; " f"approved version is {entry.version}", 400, ) try: wheel_path = self.catalogue.download_wheel(entry, staging) except CatalogueError as exc: raise PluginManagerError(str(exc), int(exc.code)) from exc else: 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, ) if entry.has_approved_wheel and manifest.version != entry.version: raise PluginManagerError( f"manifest version {manifest.version!r} does not match approved " f"catalogue version {entry.version!r}", 400, ) 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 state.get("source") == "catalogue": 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 if entry.has_approved_wheel: latest_version = entry.version update_available = bool( latest_version and installed_version and self._version_gt(latest_version, installed_version) ) return { "id": plugin_id, "installedVersion": installed_version or None, "latestVersion": latest_version, "updateAvailable": update_available, "repository": entry.repository, "releaseNotes": None, "releaseUrl": None, "releaseTag": None, } 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, } @_serialized_plugin @_reported("update") def update_plugin( self, plugin_id: str, *, version: Optional[str] = None, force_refresh: bool = False, ) -> dict[str, Any]: """Install the newer approved catalogue version when available.""" 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)) approved_entry = None release = None if state.get("source") == "catalogue": try: candidate = self.catalogue.get_plugin(plugin_id, force_refresh=force_refresh) except CatalogueError as exc: raise PluginManagerError(str(exc), int(exc.code)) from exc if candidate.has_approved_wheel: approved_entry = candidate if version and normalize_version(version) != normalize_version( approved_entry.version or "" ): raise PluginManagerError( f"version {version!r} is not approved for {plugin_id}; " f"approved version is {approved_entry.version}", 400, ) if installed_version and not self._version_gt( approved_entry.version or "", installed_version ): status = self.status(plugin_id) status["updateAvailable"] = False status["latestVersion"] = approved_entry.version status["updated"] = False return status if approved_entry is None: # Legacy schema-1 catalogues continue to resolve GitHub Releases. 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 ): 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: if approved_entry is not None: try: wheel_path = self.catalogue.download_wheel(approved_entry, staging) except CatalogueError as exc: raise PluginManagerError(str(exc), int(exc.code)) from exc target_version = approved_entry.version else: if release is None: raise PluginManagerError("could not resolve plugin release", 500) try: wheel_path = self.github.download_wheel(release, staging) except GitHubReleasesError as exc: raise PluginManagerError(str(exc), int(exc.code)) from exc target_version = release.version 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, ) if approved_entry is not None and manifest.version != approved_entry.version: raise PluginManagerError( f"manifest version {manifest.version!r} does not match approved " f"catalogue version {approved_entry.version!r}", 400, ) # 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"] = target_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