mirror of
https://github.com/openhop-dev/openhop_repeater.git
synced 2026-10-23 13:32:50 +00:00
557 lines
21 KiB
Python
557 lines
21 KiB
Python
"""Plugin management API endpoints (nested under /api/plugins/)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import re
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
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,
|
|
PluginIPCOutcomeUnknown,
|
|
PluginManagerUnavailable,
|
|
)
|
|
from repeater.plugins.storage import resolve_plugin_socket_path, resolve_plugins_root
|
|
|
|
logger = logging.getLogger("HTTPServer")
|
|
|
|
PROGRESS_POLL_SECONDS = 0.75
|
|
PROGRESS_MAX_SECONDS = 900.0 # the IPC completion budget
|
|
PROGRESS_IDLE_SECONDS = 60.0
|
|
|
|
|
|
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 PluginIPCOutcomeUnknown as exc:
|
|
return {**self._err(str(exc), 504), "outcome": "unknown"}
|
|
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
|
|
body = self._json_body()
|
|
if "delete_data" in body:
|
|
delete_data = bool(body.get("delete_data"))
|
|
|
|
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()))}
|
|
except PluginIPCOutcomeUnknown as exc:
|
|
# A legacy manager can still consume this path after disconnect.
|
|
# Only the new processing acknowledgement proves it owns a copy.
|
|
if not exc.upload_safe:
|
|
cleanup = False
|
|
logger.warning("Retaining unacknowledged plugin upload after lost response")
|
|
raise
|
|
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
|
|
def progress(self, **kwargs):
|
|
"""GET /api/plugins/progress?id=&since=&fresh= — SSE of an install or update."""
|
|
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:
|
|
since = int(kwargs.get("since", 0))
|
|
except (TypeError, ValueError):
|
|
raise cherrypy.HTTPError(400, "since must be an integer")
|
|
fresh = str(kwargs.get("fresh", "")).strip().lower() in {"1", "true", "yes", "on"}
|
|
cherrypy.response.headers["Content-Type"] = "text/event-stream"
|
|
if cherrypy.request.method == "HEAD":
|
|
return ""
|
|
cherrypy.response.headers["Cache-Control"] = "no-cache"
|
|
cherrypy.response.headers["X-Accel-Buffering"] = "no"
|
|
cherrypy.response.headers["Connection"] = "keep-alive"
|
|
return self._progress_events(plugin_id, since, fresh=fresh)
|
|
|
|
progress._cp_config = {"response.stream": True}
|
|
|
|
def _progress_events(
|
|
self,
|
|
plugin_id: str,
|
|
since: int,
|
|
*,
|
|
fresh: bool = False,
|
|
sleep=time.sleep,
|
|
clock=time.monotonic,
|
|
):
|
|
def event(payload: dict) -> str:
|
|
return f"data: {json.dumps(payload)}\n\n"
|
|
|
|
def generate():
|
|
yield event({"type": "connected", "id": plugin_id})
|
|
cursor, last_state, quiet, stale = since, None, 0, None
|
|
opened = clock()
|
|
while True:
|
|
try:
|
|
snap = self._client_or_raise().progress(plugin_id, since=cursor)
|
|
except GeneratorExit:
|
|
return
|
|
except Exception as exc:
|
|
yield event({"type": "done", "state": "error", "error": str(exc)})
|
|
return
|
|
|
|
state, started = snap.get("state"), snap.get("started")
|
|
# fresh: a log already finished when the stream opened is not this operation's
|
|
if fresh and last_state is None and state in ("complete", "error"):
|
|
stale = started
|
|
if stale is not None and started == stale:
|
|
state, snap = "idle", {"next": cursor, "operation": None, "started": None}
|
|
|
|
changed = False
|
|
for line in snap.get("lines") or []:
|
|
yield event({"type": "line", "line": line})
|
|
changed = True
|
|
cursor = int(snap.get("next") or cursor)
|
|
if state != last_state:
|
|
yield event(
|
|
{
|
|
"type": "status",
|
|
"state": state,
|
|
"operation": snap.get("operation"),
|
|
"started": snap.get("started"),
|
|
}
|
|
)
|
|
last_state, changed = state, True
|
|
if state in ("complete", "error"):
|
|
yield event(
|
|
{
|
|
"type": "done",
|
|
"state": state,
|
|
"error": snap.get("error"),
|
|
"started": snap.get("started"),
|
|
}
|
|
)
|
|
return
|
|
now = clock()
|
|
if now >= opened + PROGRESS_MAX_SECONDS:
|
|
yield event(
|
|
{"type": "done", "state": "timeout", "error": "progress stream timed out"}
|
|
)
|
|
return
|
|
if state == "idle" and now >= opened + PROGRESS_IDLE_SECONDS:
|
|
yield event({"type": "done", "state": "idle", "error": None})
|
|
return
|
|
quiet = 0 if changed else quiet + 1
|
|
if quiet and quiet % 4 == 0:
|
|
yield event({"type": "keepalive"})
|
|
sleep(PROGRESS_POLL_SECONDS)
|
|
|
|
return generate()
|
|
|
|
@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 runtime(self, **kwargs):
|
|
"""GET /api/plugins/runtime — read plugin data/runtime.json."""
|
|
if cherrypy.request.method == "OPTIONS":
|
|
return ""
|
|
if cherrypy.request.method not in ("GET", "HEAD"):
|
|
raise cherrypy.HTTPError(405, "Method Not Allowed")
|
|
plugin_id = self._plugin_id_from(kwargs)
|
|
return self._handle_ipc(lambda: self._client_or_raise().get_runtime(plugin_id))
|
|
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out()
|
|
def catalogue(self, **kwargs):
|
|
"""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?} — install approved catalogue update."""
|
|
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)
|