mirror of
https://github.com/pyMC-dev/pyMC_Repeater.git
synced 2026-10-31 04:01: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
@@ -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)
|
||||
Reference in new issue
Block a user