mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-30 19:51:59 +01:00
Install approved plugin wheels directly from R2
This commit is contained in:
1 parent
3e008cac26
commit
eb8f934124
14 files changed
+534
-124
No files matched your search
@@ -32,6 +32,3 @@ SPI_GID=989
|
||||
# image yourself instead of pulling OPENHOP_REPEATER_IMAGE.
|
||||
PUID=15888
|
||||
PGID=15888
|
||||
# Optional fine-grained GitHub token for plugin catalogue release lookups.
|
||||
# Public-repository read access is sufficient. Leave empty for anonymous access.
|
||||
OPENHOP_PLUGIN_GITHUB_TOKEN=
|
||||
@@ -3,10 +3,6 @@ services:
|
||||
image: ${OPENHOP_REPEATER_IMAGE:-${PYMC_REPEATER_IMAGE:-openhop/openhop-repeater:main}}
|
||||
container_name: openhop-repeater
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Optional fine-grained, read-only token for catalogue release lookups.
|
||||
# It raises GitHub's API limit; it is never sent to release asset hosts.
|
||||
OPENHOP_PLUGIN_GITHUB_TOKEN: ${OPENHOP_PLUGIN_GITHUB_TOKEN:-}
|
||||
ports:
|
||||
- 8000:8000
|
||||
devices:
|
||||
|
||||
+16
-12
@@ -240,16 +240,15 @@ curl -X DELETE -H "Authorization: Bearer $TOKEN" \
|
||||
|
||||
## 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.
|
||||
Repeater browses a curated static catalogue and installs the exact plugin wheel
|
||||
approved there. The catalogue and wheels are hosted on openHop's R2-backed
|
||||
origin, so normal installs and update checks do not call the GitHub API.
|
||||
|
||||
Default catalogue URL:
|
||||
|
||||
@@ -264,20 +263,24 @@ plugins:
|
||||
catalogue_url: "https://repeater-plugins.openhop.dev/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).
|
||||
Catalogue schema 2 records each plugin's currently approved `version`, exact
|
||||
`wheel_url`, and lowercase `sha256`. The manager downloads only from
|
||||
`https://repeater-plugins.openhop.dev/plugins/`, verifies the checksum before
|
||||
installation, and confirms the wheel manifest ID and version match the
|
||||
catalogue. Publishing a newer GitHub Release does not make it available until
|
||||
the catalogue maintainers approve and publish that exact wheel.
|
||||
|
||||
Schema 1 repository-only catalogues remain readable for compatibility with
|
||||
custom deployments, but the default openHop catalogue uses schema 2 and needs
|
||||
no GitHub token.
|
||||
|
||||
GitHub permits anonymous lookups, but the anonymous API limit is low and may be
|
||||
shared by multiple containers behind the same public address. Docker operators
|
||||
can set `OPENHOP_PLUGIN_GITHUB_TOKEN` in `.env` to a fine-grained token with
|
||||
read-only access to public repository metadata. The token is used only for
|
||||
`api.github.com` release lookups and is not sent to release asset URLs.
|
||||
|
||||
```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)
|
||||
# Install the currently approved catalogue version (enabled by default)
|
||||
curl -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"id":"openhop.nomad"}' \
|
||||
@@ -294,5 +297,6 @@ curl -X POST -H "Authorization: Bearer $TOKEN" \
|
||||
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.
|
||||
R2 catalogue or wheel outages do not affect already-installed plugins or
|
||||
Repeater startup. Local `.whl` install continues to work unchanged.
|
||||
|
||||
@@ -2,25 +2,33 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
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
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
logger = logging.getLogger("PluginCatalogue")
|
||||
|
||||
DEFAULT_CATALOGUE_URL = "https://repeater-plugins.openhop.dev/catalogue.json"
|
||||
DEFAULT_CACHE_TTL_SECONDS = 600 # 10 minutes
|
||||
SUPPORTED_SCHEMA = 1
|
||||
SUPPORTED_SCHEMAS = (1, 2)
|
||||
USER_AGENT = "openhop-repeater-plugin-manager/1.0"
|
||||
APPROVED_WHEEL_ORIGIN = "https://repeater-plugins.openhop.dev"
|
||||
MAX_WHEEL_BYTES = 100 * 1024 * 1024
|
||||
|
||||
_REPO_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
||||
_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]*$")
|
||||
_SHA256_RE = re.compile(r"^[a-f0-9]{64}$")
|
||||
_ssl_ctx: Optional[ssl.SSLContext] = None
|
||||
|
||||
|
||||
@@ -37,6 +45,24 @@ def _ssl_context() -> ssl.SSLContext:
|
||||
return _ssl_ctx
|
||||
|
||||
|
||||
def _validate_approved_wheel_url(plugin_id: str, version: str, wheel_url: str) -> None:
|
||||
parsed = urlsplit(wheel_url)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
expected_prefix = f"/plugins/{plugin_id}/{version}/"
|
||||
if (
|
||||
origin != APPROVED_WHEEL_ORIGIN
|
||||
or not parsed.path.startswith(expected_prefix)
|
||||
or not parsed.path.endswith(".whl")
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
):
|
||||
raise CatalogueError(
|
||||
f"wheel_url must be an approved R2 wheel under "
|
||||
f"{APPROVED_WHEEL_ORIGIN}{expected_prefix}",
|
||||
code=400,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CataloguePlugin:
|
||||
id: str
|
||||
@@ -45,6 +71,13 @@ class CataloguePlugin:
|
||||
repository: str
|
||||
category: str = ""
|
||||
logo: str = ""
|
||||
version: Optional[str] = None
|
||||
wheel_url: Optional[str] = None
|
||||
sha256: Optional[str] = None
|
||||
|
||||
@property
|
||||
def has_approved_wheel(self) -> bool:
|
||||
return bool(self.version and self.wheel_url and self.sha256)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {
|
||||
@@ -57,6 +90,12 @@ class CataloguePlugin:
|
||||
out["category"] = self.category
|
||||
if self.logo:
|
||||
out["logo"] = self.logo
|
||||
if self.version:
|
||||
out["version"] = self.version
|
||||
if self.wheel_url:
|
||||
out["wheel_url"] = self.wheel_url
|
||||
if self.sha256:
|
||||
out["sha256"] = self.sha256
|
||||
return out
|
||||
|
||||
|
||||
@@ -82,9 +121,9 @@ 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:
|
||||
if schema not in SUPPORTED_SCHEMAS:
|
||||
raise CatalogueError(
|
||||
f"unsupported catalogue schema {schema!r}; expected {SUPPORTED_SCHEMA}",
|
||||
f"unsupported catalogue schema {schema!r}; expected one of {SUPPORTED_SCHEMAS}",
|
||||
code=400,
|
||||
)
|
||||
raw_plugins = data.get("plugins")
|
||||
@@ -94,6 +133,7 @@ def parse_catalogue(data: Any) -> Catalogue:
|
||||
plugins: list[CataloguePlugin] = []
|
||||
seen_ids: set[str] = set()
|
||||
seen_repos: set[str] = set()
|
||||
seen_wheel_urls: 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)
|
||||
@@ -144,6 +184,33 @@ def parse_catalogue(data: Any) -> Catalogue:
|
||||
code=400,
|
||||
)
|
||||
|
||||
version = None
|
||||
wheel_url = None
|
||||
sha256 = None
|
||||
if schema == 2:
|
||||
raw_version = item.get("version")
|
||||
raw_wheel_url = item.get("wheel_url")
|
||||
raw_sha256 = item.get("sha256")
|
||||
if not isinstance(raw_version, str) or not raw_version.strip():
|
||||
raise CatalogueError(f"plugins[{idx}].version is required", code=400)
|
||||
if not isinstance(raw_wheel_url, str) or not raw_wheel_url.strip():
|
||||
raise CatalogueError(f"plugins[{idx}].wheel_url is required", code=400)
|
||||
if not isinstance(raw_sha256, str) or not _SHA256_RE.fullmatch(raw_sha256):
|
||||
raise CatalogueError(
|
||||
f"plugins[{idx}].sha256 must be a lowercase SHA-256 digest",
|
||||
code=400,
|
||||
)
|
||||
version = raw_version.strip()
|
||||
wheel_url = raw_wheel_url.strip()
|
||||
sha256 = raw_sha256
|
||||
try:
|
||||
_validate_approved_wheel_url(plugin_id, version, wheel_url)
|
||||
except CatalogueError as exc:
|
||||
raise CatalogueError(f"plugins[{idx}].{exc}", code=exc.code) from exc
|
||||
if wheel_url in seen_wheel_urls:
|
||||
raise CatalogueError(f"duplicate wheel URL: {wheel_url}", code=400)
|
||||
seen_wheel_urls.add(wheel_url)
|
||||
|
||||
seen_ids.add(plugin_id)
|
||||
seen_repos.add(repository)
|
||||
plugins.append(
|
||||
@@ -154,13 +221,16 @@ def parse_catalogue(data: Any) -> Catalogue:
|
||||
repository=repository,
|
||||
category=category.strip(),
|
||||
logo=logo,
|
||||
version=version,
|
||||
wheel_url=wheel_url,
|
||||
sha256=sha256,
|
||||
)
|
||||
)
|
||||
return Catalogue(schema=SUPPORTED_SCHEMA, plugins=tuple(plugins))
|
||||
return Catalogue(schema=int(schema), plugins=tuple(plugins))
|
||||
|
||||
|
||||
class CatalogueClient:
|
||||
"""Fetch and cache the curated plugin catalogue JSON."""
|
||||
"""Fetch, cache, and download from the curated plugin catalogue."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -178,7 +248,7 @@ class CatalogueClient:
|
||||
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
|
||||
return urllib.request.urlopen( # nosec B310 - validated HTTPS URLs
|
||||
request, timeout=timeout, context=_ssl_context()
|
||||
)
|
||||
|
||||
@@ -238,3 +308,74 @@ class CatalogueClient:
|
||||
if plugin is None:
|
||||
raise CatalogueError(f"plugin not in catalogue: {plugin_id}", code=404)
|
||||
return plugin
|
||||
|
||||
def download_wheel(self, plugin: CataloguePlugin, dest_dir: Path | str) -> Path:
|
||||
"""Download and verify one schema-2 approved wheel from R2."""
|
||||
if not plugin.version or not plugin.wheel_url or not plugin.sha256:
|
||||
raise CatalogueError(
|
||||
f"catalogue entry {plugin.id} does not contain approved wheel metadata",
|
||||
code=400,
|
||||
)
|
||||
_validate_approved_wheel_url(plugin.id, plugin.version, plugin.wheel_url)
|
||||
if not _SHA256_RE.fullmatch(plugin.sha256):
|
||||
raise CatalogueError(f"invalid approved checksum for {plugin.id}", code=400)
|
||||
|
||||
destination_dir = Path(dest_dir)
|
||||
destination_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination = destination_dir / Path(urlsplit(plugin.wheel_url).path).name
|
||||
fd, temporary_name = tempfile.mkstemp(prefix=".wheel-", dir=str(destination_dir))
|
||||
digest = hashlib.sha256()
|
||||
total = 0
|
||||
request = urllib.request.Request(
|
||||
plugin.wheel_url,
|
||||
headers={
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "application/octet-stream",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as output:
|
||||
try:
|
||||
with self._opener(request, timeout=120.0) as response:
|
||||
final_url = getattr(response, "geturl", lambda: plugin.wheel_url)()
|
||||
_validate_approved_wheel_url(
|
||||
plugin.id,
|
||||
plugin.version,
|
||||
final_url or plugin.wheel_url,
|
||||
)
|
||||
while True:
|
||||
chunk = response.read(1024 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > MAX_WHEEL_BYTES:
|
||||
raise CatalogueError(
|
||||
"approved plugin wheel exceeds 100 MiB", code=502
|
||||
)
|
||||
digest.update(chunk)
|
||||
output.write(chunk)
|
||||
except CatalogueError:
|
||||
raise
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise CatalogueError(
|
||||
f"approved plugin wheel is unavailable (HTTP {exc.code})", code=502
|
||||
) from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise CatalogueError("approved plugin wheel is unavailable", code=502) from exc
|
||||
except Exception as exc:
|
||||
raise CatalogueError("approved plugin wheel is unavailable", code=502) from exc
|
||||
|
||||
if total == 0:
|
||||
raise CatalogueError("approved plugin wheel is empty", code=502)
|
||||
if digest.hexdigest() != plugin.sha256:
|
||||
raise CatalogueError(
|
||||
f"approved plugin wheel checksum mismatch for {plugin.id}", code=502
|
||||
)
|
||||
os.replace(temporary_name, destination)
|
||||
return destination
|
||||
finally:
|
||||
try:
|
||||
Path(temporary_name).unlink(missing_ok=True)
|
||||
except OSError as exc:
|
||||
logger.debug("Could not remove temporary wheel %s: %s", temporary_name, exc)
|
||||
@@ -125,12 +125,10 @@ class GitHubReleaseClient:
|
||||
cache_ttl: float = DEFAULT_CACHE_TTL_SECONDS,
|
||||
opener: Optional[Callable[..., Any]] = None,
|
||||
user_agent: str = USER_AGENT,
|
||||
token: str | None = None,
|
||||
):
|
||||
self.cache_ttl = cache_ttl
|
||||
self._opener = opener or self._default_open
|
||||
self.user_agent = user_agent
|
||||
self.token = str(token or "").strip() or None
|
||||
# repo -> (monotonic_ts, list[PluginRelease])
|
||||
self._cache: dict[str, tuple[float, list[PluginRelease]]] = {}
|
||||
|
||||
@@ -262,15 +260,12 @@ class GitHubReleaseClient:
|
||||
|
||||
def _fetch_releases_json(self, repo: str) -> list[Any]:
|
||||
url = f"{GITHUB_API_BASE}/repos/{repo}/releases?per_page=30"
|
||||
headers = {
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "application/vnd.github+json",
|
||||
}
|
||||
if self.token:
|
||||
headers["Authorization"] = f"Bearer {self.token}"
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
headers=headers,
|
||||
headers={
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
try:
|
||||
|
||||
+135
-43
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
@@ -49,9 +48,7 @@ class PluginManager:
|
||||
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(
|
||||
token=os.environ.get("OPENHOP_PLUGIN_GITHUB_TOKEN")
|
||||
)
|
||||
self.github = github_client or GitHubReleaseClient()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Load state and start enabled service plugins."""
|
||||
@@ -321,17 +318,24 @@ class PluginManager:
|
||||
"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
|
||||
# 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
|
||||
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)
|
||||
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)
|
||||
@@ -344,7 +348,7 @@ class PluginManager:
|
||||
version: Optional[str] = None,
|
||||
force_refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Download catalogue plugin wheel and install via existing installer."""
|
||||
"""Download an approved catalogue wheel and install it."""
|
||||
try:
|
||||
entry = self.catalogue.get_plugin(plugin_id, force_refresh=force_refresh)
|
||||
except CatalogueError as exc:
|
||||
@@ -353,15 +357,27 @@ class PluginManager:
|
||||
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
|
||||
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)
|
||||
@@ -373,6 +389,12 @@ class PluginManager:
|
||||
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,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
try:
|
||||
@@ -399,6 +421,30 @@ class PluginManager:
|
||||
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,
|
||||
@@ -440,7 +486,7 @@ class PluginManager:
|
||||
version: Optional[str] = None,
|
||||
force_refresh: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Download a newer (or pinned) release and reinstall via existing installer."""
|
||||
"""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)
|
||||
@@ -455,31 +501,71 @@ class PluginManager:
|
||||
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
|
||||
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"] = release.version
|
||||
status["latestVersion"] = approved_entry.version
|
||||
status["updated"] = False
|
||||
return status
|
||||
except GitHubReleasesError as exc:
|
||||
raise PluginManagerError(str(exc), int(exc.code)) from exc
|
||||
|
||||
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:
|
||||
try:
|
||||
wheel_path = self.github.download_wheel(release, staging)
|
||||
except GitHubReleasesError as exc:
|
||||
raise PluginManagerError(str(exc), int(exc.code)) from exc
|
||||
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)
|
||||
@@ -490,6 +576,12 @@ class PluginManager:
|
||||
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,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
# Stop running process before swapping release
|
||||
@@ -513,7 +605,7 @@ class PluginManager:
|
||||
return self.enable(plugin_id)
|
||||
result = self.status(plugin_id)
|
||||
result["updated"] = True
|
||||
result["latestVersion"] = release.version
|
||||
result["latestVersion"] = target_version
|
||||
return result
|
||||
finally:
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
@@ -503,7 +503,8 @@ class PluginRuntime:
|
||||
log_fp = open(paths.log_file, "a", encoding="utf-8", buffering=1)
|
||||
|
||||
env = os.environ.copy()
|
||||
# Manager-only credentials must never become plugin credentials.
|
||||
# Never expose a stale manager-only credential left behind by an
|
||||
# upgraded deployment to third-party plugin processes.
|
||||
env.pop("OPENHOP_PLUGIN_GITHUB_TOKEN", None)
|
||||
env["OPENHOP_PLUGIN_ID"] = plugin_id
|
||||
env["OPENHOP_PLUGIN_DATA"] = str(paths.data_dir)
|
||||
|
||||
@@ -4604,15 +4604,15 @@ paths:
|
||||
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.
|
||||
install state and the currently approved catalogue version.
|
||||
Failures contacting the R2 catalogue do not affect installed plugins.
|
||||
parameters:
|
||||
- name: refresh
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: boolean
|
||||
description: Force refresh of catalogue and release caches
|
||||
description: Force refresh of the catalogue cache
|
||||
responses:
|
||||
'200':
|
||||
description: Catalogue entries
|
||||
@@ -4626,8 +4626,8 @@ paths:
|
||||
- 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.
|
||||
Download the exact R2 wheel approved by catalogue version and SHA-256,
|
||||
verify its manifest, install it, and enable the plugin.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -4640,7 +4640,7 @@ paths:
|
||||
type: string
|
||||
version:
|
||||
type: string
|
||||
description: Optional release version (with or without leading v)
|
||||
description: Optional assertion of the currently approved version
|
||||
responses:
|
||||
'200':
|
||||
description: Installed plugin status
|
||||
@@ -4673,7 +4673,7 @@ paths:
|
||||
post:
|
||||
tags:
|
||||
- Plugins
|
||||
summary: Update an installed plugin from GitHub Releases
|
||||
summary: Update an installed plugin to the approved catalogue version
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
|
||||
@@ -408,7 +408,7 @@ class PluginAPIEndpoints:
|
||||
@cherrypy.tools.json_out()
|
||||
@cherrypy.tools.json_in(force=False)
|
||||
def update(self, **kwargs):
|
||||
"""POST /api/plugins/update {id, version?} — update from GitHub Releases."""
|
||||
"""POST /api/plugins/update {id, version?} — install approved catalogue update."""
|
||||
if cherrypy.request.method == "OPTIONS":
|
||||
return ""
|
||||
self._require_post()
|
||||
|
||||
@@ -21,12 +21,16 @@ def test_container_smoke_script_checks_install_restart_and_shutdown():
|
||||
assert "EXPECTED_PLUGIN_PID" in script
|
||||
|
||||
|
||||
def test_compose_exposes_service_scoped_github_token():
|
||||
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||
docs = (ROOT / "docs/plugins.md").read_text(encoding="utf-8")
|
||||
def test_r2_catalogue_needs_no_github_token():
|
||||
paths = [
|
||||
ROOT / ".env.example",
|
||||
ROOT / "docker-compose.yml",
|
||||
ROOT / "docs/plugins.md",
|
||||
ROOT / "repeater/plugins/manager.py",
|
||||
]
|
||||
|
||||
assert "OPENHOP_PLUGIN_GITHUB_TOKEN" in compose
|
||||
assert "OPENHOP_PLUGIN_GITHUB_TOKEN" in docs
|
||||
for path in paths:
|
||||
assert "OPENHOP_PLUGIN_GITHUB_TOKEN" not in path.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_image_uses_tini_to_reap_orphaned_plugin_processes():
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from urllib.error import HTTPError, URLError
|
||||
@@ -30,6 +31,25 @@ VALID = {
|
||||
],
|
||||
}
|
||||
|
||||
APPROVED_WHEEL = b"approved-wheel"
|
||||
VALID_V2 = {
|
||||
"schema": 2,
|
||||
"plugins": [
|
||||
{
|
||||
"id": "openhop.nomad",
|
||||
"name": "NOMAD Bridge",
|
||||
"description": "desc",
|
||||
"repository": "openhop-dev/openhop-nomad-plugin",
|
||||
"version": "0.1.0",
|
||||
"wheel_url": (
|
||||
"https://repeater-plugins.openhop.dev/plugins/openhop.nomad/0.1.0/"
|
||||
"openhop_nomad_plugin-0.1.0-py3-none-any.whl"
|
||||
),
|
||||
"sha256": hashlib.sha256(APPROVED_WHEEL).hexdigest(),
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_default_catalogue_uses_openhop_r2_endpoint():
|
||||
assert DEFAULT_CATALOGUE_URL == "https://repeater-plugins.openhop.dev/catalogue.json"
|
||||
@@ -43,6 +63,47 @@ def test_parse_valid_catalogue():
|
||||
assert cat.get("openhop.nomad").repository == "openhop-dev/openhop-nomad-plugin"
|
||||
|
||||
|
||||
def test_parse_v2_approved_release_metadata():
|
||||
cat = parse_catalogue(VALID_V2)
|
||||
plugin = cat.get("openhop.nomad")
|
||||
|
||||
assert cat.schema == 2
|
||||
assert plugin.version == "0.1.0"
|
||||
assert plugin.wheel_url.endswith("openhop_nomad_plugin-0.1.0-py3-none-any.whl")
|
||||
assert plugin.sha256 == hashlib.sha256(APPROVED_WHEEL).hexdigest()
|
||||
|
||||
|
||||
def test_download_v2_wheel_verifies_checksum(tmp_path):
|
||||
def opener(request: Request, timeout: float = 30.0):
|
||||
assert request.full_url == VALID_V2["plugins"][0]["wheel_url"]
|
||||
return _FakeResp(APPROVED_WHEEL)
|
||||
|
||||
plugin = parse_catalogue(VALID_V2).plugins[0]
|
||||
path = CatalogueClient(opener=opener).download_wheel(plugin, tmp_path)
|
||||
|
||||
assert path.read_bytes() == APPROVED_WHEEL
|
||||
|
||||
|
||||
def test_download_v2_wheel_rejects_checksum_mismatch(tmp_path):
|
||||
def opener(request: Request, timeout: float = 30.0):
|
||||
return _FakeResp(b"tampered")
|
||||
|
||||
plugin = parse_catalogue(VALID_V2).plugins[0]
|
||||
with pytest.raises(CatalogueError, match="checksum"):
|
||||
CatalogueClient(opener=opener).download_wheel(plugin, tmp_path)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_download_v2_wheel_rejects_redirect_outside_approved_origin(tmp_path):
|
||||
def opener(request: Request, timeout: float = 30.0):
|
||||
return _FakeResp(APPROVED_WHEEL, final_url="https://example.com/plugin.whl")
|
||||
|
||||
plugin = parse_catalogue(VALID_V2).plugins[0]
|
||||
with pytest.raises(CatalogueError, match="approved R2"):
|
||||
CatalogueClient(opener=opener).download_wheel(plugin, tmp_path)
|
||||
assert list(tmp_path.iterdir()) == []
|
||||
|
||||
|
||||
def test_unsupported_schema():
|
||||
with pytest.raises(CatalogueError, match="unsupported catalogue schema"):
|
||||
parse_catalogue({"schema": 99, "plugins": []})
|
||||
@@ -109,6 +170,13 @@ def test_duplicate_repository():
|
||||
|
||||
|
||||
class _FakeResp(io.BytesIO):
|
||||
def __init__(self, payload: bytes, *, final_url: str | None = None):
|
||||
super().__init__(payload)
|
||||
self._final_url = final_url
|
||||
|
||||
def geturl(self):
|
||||
return self._final_url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
|
||||
@@ -148,27 +148,6 @@ def test_download_wheel(tmp_path: Path):
|
||||
assert path.name.endswith(".whl")
|
||||
|
||||
|
||||
def test_github_token_authenticates_api_but_not_asset_download(tmp_path: Path):
|
||||
payload = [_release("v0.1.0")]
|
||||
seen_headers = []
|
||||
|
||||
def opener(request: Request, timeout: float = 30.0):
|
||||
seen_headers.append((request.full_url, dict(request.header_items())))
|
||||
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, token="test-token")
|
||||
client.download_latest_wheel("org/repo", tmp_path)
|
||||
|
||||
api_headers = next(headers for url, headers in seen_headers if "api.github.com" in url)
|
||||
asset_headers = next(headers for url, headers in seen_headers if url.endswith(".whl"))
|
||||
assert api_headers["Authorization"] == "Bearer test-token"
|
||||
assert "Authorization" not in asset_headers
|
||||
|
||||
|
||||
def test_rate_limit():
|
||||
def opener(request: Request, timeout: float = 30.0):
|
||||
headers = {"X-RateLimit-Remaining": "0"}
|
||||
|
||||
@@ -52,14 +52,6 @@ def _make_wheel(path: Path, manifest: dict, ui_files: dict[str, str] | None = No
|
||||
return path
|
||||
|
||||
|
||||
def test_default_github_client_uses_service_scoped_token(tmp_path: Path, monkeypatch):
|
||||
monkeypatch.setenv("OPENHOP_PLUGIN_GITHUB_TOKEN", "service-token")
|
||||
|
||||
manager = PluginManager(PluginStorage(tmp_path / "plugins"))
|
||||
|
||||
assert manager.github.token == "service-token"
|
||||
|
||||
|
||||
def test_install_creates_layout_and_isolated_venv(tmp_path: Path, monkeypatch):
|
||||
storage = PluginStorage(tmp_path / "plugins")
|
||||
calls = []
|
||||
@@ -200,13 +192,12 @@ def test_manager_starts_enabled_plugin(tmp_path: Path):
|
||||
def test_enable_start_stop_disable(tmp_path: Path, monkeypatch):
|
||||
storage = PluginStorage(tmp_path / "plugins")
|
||||
procs: list[FakeProc] = []
|
||||
child_environments = []
|
||||
monkeypatch.setenv("OPENHOP_PLUGIN_GITHUB_TOKEN", "must-not-reach-plugin")
|
||||
spawn_envs: list[dict[str, str]] = []
|
||||
|
||||
def fake_popen(cmd, **kwargs):
|
||||
spawn_envs.append(kwargs["env"])
|
||||
p = FakeProc(pid=1000 + len(procs))
|
||||
procs.append(p)
|
||||
child_environments.append(kwargs["env"])
|
||||
# Write a line to log fd if provided
|
||||
log_fp = kwargs.get("stdout")
|
||||
if log_fp and hasattr(log_fp, "write"):
|
||||
@@ -242,10 +233,12 @@ def test_enable_start_stop_disable(tmp_path: Path, monkeypatch):
|
||||
)
|
||||
manager.install(wheel)
|
||||
|
||||
monkeypatch.setenv("OPENHOP_PLUGIN_GITHUB_TOKEN", "legacy-token")
|
||||
st = manager.enable("openhop.demo")
|
||||
assert st["enabled"] is True
|
||||
assert st["state"] == PluginState.RUNNING.value
|
||||
assert len(procs) == 1
|
||||
assert "OPENHOP_PLUGIN_GITHUB_TOKEN" not in spawn_envs[0]
|
||||
|
||||
st = manager.stop_plugin("openhop.demo")
|
||||
assert st["state"] == PluginState.STOPPED.value
|
||||
@@ -259,7 +252,6 @@ def test_enable_start_stop_disable(tmp_path: Path, monkeypatch):
|
||||
st = manager.disable("openhop.demo")
|
||||
assert st["enabled"] is False
|
||||
assert st["state"] == PluginState.DISABLED.value
|
||||
assert all("OPENHOP_PLUGIN_GITHUB_TOKEN" not in env for env in child_environments)
|
||||
|
||||
logs = manager.logs("openhop.demo", tail=10)
|
||||
assert any("hello" in line for line in logs["lines"])
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
"""Approved R2 catalogue install and update flows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock
|
||||
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
|
||||
|
||||
|
||||
class _FakeResp(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
|
||||
def _make_wheel(path: Path, plugin_id: str, version: str) -> bytes:
|
||||
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 archive:
|
||||
archive.writestr(
|
||||
f"share/openhop/plugins/{plugin_id}/openhop-plugin.json",
|
||||
json.dumps(manifest),
|
||||
)
|
||||
archive.writestr("ui/index.html", "<html></html>")
|
||||
dist = f"test_plugin-{version}.dist-info"
|
||||
archive.writestr(
|
||||
f"{dist}/METADATA",
|
||||
f"Metadata-Version: 2.1\nName: test-plugin\nVersion: {version}\n",
|
||||
)
|
||||
archive.writestr(
|
||||
f"{dist}/WHEEL",
|
||||
"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
|
||||
)
|
||||
archive.writestr(f"{dist}/RECORD", "")
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def _entry(version: str, wheel: bytes) -> dict:
|
||||
return {
|
||||
"id": "openhop.demo",
|
||||
"name": "Demo",
|
||||
"description": "demo plugin",
|
||||
"repository": "openhop-dev/demo-plugin",
|
||||
"category": "test",
|
||||
"version": version,
|
||||
"wheel_url": (
|
||||
f"https://repeater-plugins.openhop.dev/plugins/openhop.demo/{version}/"
|
||||
f"demo-{version}-py3-none-any.whl"
|
||||
),
|
||||
"sha256": hashlib.sha256(wheel).hexdigest(),
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def r2_manager(tmp_path: Path):
|
||||
wheels = {
|
||||
"0.1.0": _make_wheel(tmp_path / "demo-0.1.0.whl", "openhop.demo", "0.1.0"),
|
||||
"0.2.0": _make_wheel(tmp_path / "demo-0.2.0.whl", "openhop.demo", "0.2.0"),
|
||||
}
|
||||
state = {"entry": _entry("0.1.0", wheels["0.1.0"]), "requests": []}
|
||||
|
||||
def opener(request: Request, timeout: float = 30.0):
|
||||
state["requests"].append(request.full_url)
|
||||
if request.full_url.endswith("catalogue.json"):
|
||||
return _FakeResp(json.dumps({"schema": 2, "plugins": [state["entry"]]}).encode())
|
||||
for version, wheel in wheels.items():
|
||||
if request.full_url == _entry(version, wheel)["wheel_url"]:
|
||||
return _FakeResp(wheel)
|
||||
raise AssertionError(request.full_url)
|
||||
|
||||
catalogue = CatalogueClient("https://example.test/catalogue.json", opener=opener)
|
||||
github = MagicMock(spec=GitHubReleaseClient)
|
||||
manager = PluginManager(
|
||||
PluginStorage(tmp_path / "plugins"),
|
||||
catalogue_client=catalogue,
|
||||
github_client=github,
|
||||
)
|
||||
manager._r2_test_state = state # type: ignore[attr-defined]
|
||||
manager._r2_test_wheels = wheels # type: ignore[attr-defined]
|
||||
return manager
|
||||
|
||||
|
||||
def test_schema2_install_uses_only_approved_r2_wheel(r2_manager: PluginManager):
|
||||
status = r2_manager.install_from_catalogue("openhop.demo")
|
||||
|
||||
assert status["version"] == "0.1.0"
|
||||
assert status["enabled"] is True
|
||||
assert status["source"] == "catalogue"
|
||||
assert all("api.github.com" not in url for url in r2_manager._r2_test_state["requests"])
|
||||
r2_manager.github.latest_stable.assert_not_called()
|
||||
r2_manager.github.download_latest_wheel.assert_not_called()
|
||||
|
||||
|
||||
def test_schema2_catalogue_version_controls_updates(r2_manager: PluginManager):
|
||||
r2_manager.install_from_catalogue("openhop.demo")
|
||||
wheels = r2_manager._r2_test_wheels
|
||||
r2_manager._r2_test_state["entry"] = _entry("0.2.0", wheels["0.2.0"])
|
||||
r2_manager.catalogue.clear_cache()
|
||||
|
||||
check = r2_manager.check_update("openhop.demo", force_refresh=True)
|
||||
assert check["latestVersion"] == "0.2.0"
|
||||
assert check["updateAvailable"] is True
|
||||
|
||||
updated = r2_manager.update_plugin("openhop.demo", force_refresh=True)
|
||||
assert updated["version"] == "0.2.0"
|
||||
assert updated["enabled"] is True
|
||||
r2_manager.github.latest_stable.assert_not_called()
|
||||
r2_manager.github.download_wheel.assert_not_called()
|
||||
|
||||
|
||||
def test_schema2_rejects_unapproved_requested_version(r2_manager: PluginManager):
|
||||
with pytest.raises(PluginManagerError, match="not approved"):
|
||||
r2_manager.install_from_catalogue("openhop.demo", version="9.9.9")
|
||||
|
||||
|
||||
def test_schema2_checksum_failure_does_not_install(r2_manager: PluginManager):
|
||||
r2_manager._r2_test_state["entry"]["sha256"] = "0" * 64
|
||||
r2_manager.catalogue.clear_cache()
|
||||
|
||||
with pytest.raises(PluginManagerError, match="checksum"):
|
||||
r2_manager.install_from_catalogue("openhop.demo", force_refresh=True)
|
||||
assert r2_manager.storage.list_plugin_ids() == []
|
||||
Reference in new issue
Block a user