Install approved plugin wheels directly from R2

This commit is contained in:
yellowcooln committed 2026-09-04 12:13:08 -04:00
1 parent 3e008cac26
commit eb8f934124
14 files changed
+534 -124

No files matched your search

-3
View File
@@ -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=
-4
View File
@@ -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
View File
@@ -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.
+147 -6
View File
@@ -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)
+4 -9
View File
@@ -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
View File
@@ -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)
+2 -1
View File
@@ -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)
+7 -7
View File
@@ -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:
+1 -1
View File
@@ -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()
+9 -5
View File
@@ -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():
+68
View File
@@ -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
-21
View File
@@ -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"}
+4 -12
View File
@@ -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"])
+141
View File
@@ -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() == []