Files

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)