feat: add NetBox plugin store
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urljoin, urlsplit, urlunsplit
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from .validation import (
|
||||
ValidationError,
|
||||
validate_distribution_name,
|
||||
validate_import_name,
|
||||
validate_public_url,
|
||||
validate_sha256,
|
||||
validate_slug,
|
||||
)
|
||||
|
||||
|
||||
class StoreClientError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class URLPolicy:
|
||||
"""Exact scheme/host/port allowlist with an optional path prefix."""
|
||||
|
||||
def __init__(self, allowed_roots: tuple[str, ...] | list[str]):
|
||||
if not allowed_roots:
|
||||
raise ValueError("At least one allowed URL is required.")
|
||||
if any(urlsplit(root).query or urlsplit(root).fragment for root in allowed_roots):
|
||||
raise ValueError("Allowlist roots may not contain a query string or fragment.")
|
||||
self._roots = tuple(self._normalize(validate_public_url(root)) for root in allowed_roots)
|
||||
|
||||
@staticmethod
|
||||
def _normalize(value: str) -> tuple[str, str, int, str]:
|
||||
parsed = urlsplit(value)
|
||||
scheme = parsed.scheme.lower()
|
||||
default_port = 443 if scheme == "https" else 80
|
||||
port = parsed.port or default_port
|
||||
prefix = parsed.path.rstrip("/") or "/"
|
||||
return scheme, parsed.hostname.lower(), port, prefix
|
||||
|
||||
def check(self, value: str) -> str:
|
||||
validate_public_url(value)
|
||||
parsed = urlsplit(value)
|
||||
candidate = self._normalize(value)
|
||||
scheme, host, port, path = candidate
|
||||
for allowed_scheme, allowed_host, allowed_port, prefix in self._roots:
|
||||
path_ok = prefix == "/" or path == prefix or path.startswith(prefix + "/")
|
||||
if (scheme, host, port) == (allowed_scheme, allowed_host, allowed_port) and path_ok:
|
||||
return value
|
||||
raise StoreClientError("URL is outside the configured allowlist.")
|
||||
|
||||
|
||||
class _PolicyRedirectHandler(HTTPRedirectHandler):
|
||||
def __init__(self, policy: URLPolicy):
|
||||
self.policy = policy
|
||||
super().__init__()
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
self.policy.check(newurl)
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Release:
|
||||
version: str
|
||||
download_url: str
|
||||
sha256: str
|
||||
min_netbox_version: str
|
||||
max_netbox_version: str
|
||||
published_at: str
|
||||
approved: bool
|
||||
immutable: bool
|
||||
approved_payload_sha256: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any]) -> "Release":
|
||||
version = str(value.get("version", "")).strip()
|
||||
try:
|
||||
Version(version)
|
||||
except InvalidVersion as exc:
|
||||
raise StoreClientError("Store returned an invalid release version.") from exc
|
||||
digest = str(value.get("sha256") or "").strip()
|
||||
if digest:
|
||||
digest = validate_sha256(digest)
|
||||
approved_payload_sha256 = str(value.get("approved_payload_sha256") or "").strip()
|
||||
if approved_payload_sha256:
|
||||
approved_payload_sha256 = validate_sha256(approved_payload_sha256)
|
||||
download_url = str(value.get("download_url") or value.get("artifact_url") or "").strip()
|
||||
if download_url:
|
||||
validate_public_url(download_url)
|
||||
return cls(
|
||||
version=version,
|
||||
download_url=download_url,
|
||||
sha256=digest,
|
||||
min_netbox_version=str(value.get("min_netbox_version") or "").strip(),
|
||||
max_netbox_version=str(value.get("max_netbox_version") or "").strip(),
|
||||
published_at=str(value.get("published_at") or "").strip(),
|
||||
approved=value.get("approved") is True or value.get("status") == "approved",
|
||||
immutable=value.get("immutable") is True,
|
||||
approved_payload_sha256=approved_payload_sha256,
|
||||
)
|
||||
|
||||
def supports(self, netbox_version: str, plugin: "CatalogPlugin") -> bool:
|
||||
try:
|
||||
current = Version(netbox_version)
|
||||
minimum = Version(self.min_netbox_version or plugin.min_netbox_version or "0")
|
||||
maximum = Version(self.max_netbox_version or plugin.max_netbox_version or "999999")
|
||||
except InvalidVersion:
|
||||
return False
|
||||
return minimum <= current <= maximum
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CatalogPlugin:
|
||||
slug: str
|
||||
name: str
|
||||
summary: str
|
||||
description: str
|
||||
repository_url: str
|
||||
latest_version: str
|
||||
package_name: str
|
||||
import_name: str
|
||||
min_netbox_version: str
|
||||
max_netbox_version: str
|
||||
approved: bool
|
||||
releases: tuple[Release, ...]
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: dict[str, Any]) -> "CatalogPlugin":
|
||||
try:
|
||||
slug = validate_slug(str(value.get("slug", "")))
|
||||
package_name = validate_distribution_name(str(value.get("package_name", "")))
|
||||
import_name = validate_import_name(str(value.get("import_name", "")))
|
||||
except ValidationError as exc:
|
||||
raise StoreClientError(str(exc)) from exc
|
||||
releases_raw = value.get("releases") or []
|
||||
if not isinstance(releases_raw, list):
|
||||
raise StoreClientError("Store returned an invalid releases collection.")
|
||||
releases = tuple(Release.from_mapping(item) for item in releases_raw if isinstance(item, dict))
|
||||
repository_url = str(value.get("repository_url") or "").strip()
|
||||
if repository_url:
|
||||
validate_public_url(repository_url)
|
||||
return cls(
|
||||
slug=slug,
|
||||
name=str(value.get("name") or slug)[:200],
|
||||
summary=str(value.get("summary") or "")[:2_000],
|
||||
description=str(value.get("description") or "")[:250_000],
|
||||
repository_url=repository_url,
|
||||
latest_version=str(value.get("latest_version") or "").strip(),
|
||||
package_name=package_name,
|
||||
import_name=import_name,
|
||||
min_netbox_version=str(value.get("min_netbox_version") or "").strip(),
|
||||
max_netbox_version=str(value.get("max_netbox_version") or "").strip(),
|
||||
approved=value.get("approved") is True or value.get("status") == "approved",
|
||||
releases=releases,
|
||||
)
|
||||
|
||||
def select_release(self, netbox_version: str, requested_version: str = "") -> Release:
|
||||
compatible = [release for release in self.releases if release.supports(netbox_version, self)]
|
||||
if requested_version:
|
||||
compatible = [release for release in compatible if release.version == requested_version]
|
||||
elif self.latest_version:
|
||||
latest = [release for release in compatible if release.version == self.latest_version]
|
||||
if latest:
|
||||
compatible = latest
|
||||
if not compatible:
|
||||
raise StoreClientError("No compatible release is available for this NetBox version.")
|
||||
return max(compatible, key=lambda release: Version(release.version))
|
||||
|
||||
|
||||
class StoreClient:
|
||||
API_LIMIT = 5 * 1024 * 1024
|
||||
MAX_PAGES = 50
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store_url: str,
|
||||
allowed_store_urls: tuple[str, ...],
|
||||
allowed_artifact_urls: tuple[str, ...],
|
||||
*,
|
||||
api_token: str = "",
|
||||
timeout: int = 15,
|
||||
):
|
||||
self.store_policy = URLPolicy(allowed_store_urls)
|
||||
self.artifact_policy = URLPolicy(allowed_artifact_urls)
|
||||
if urlsplit(store_url).query or urlsplit(store_url).fragment:
|
||||
raise StoreClientError("Store base URL may not contain a query string or fragment.")
|
||||
self.store_url = self.store_policy.check(store_url.rstrip("/"))
|
||||
# API authentication may only follow redirects below the configured base URL.
|
||||
self.api_policy = URLPolicy([self.store_url])
|
||||
self.api_token = api_token
|
||||
self.timeout = timeout
|
||||
|
||||
def _request_json(self, url: str) -> Any:
|
||||
self.api_policy.check(url)
|
||||
headers = {"Accept": "application/json", "User-Agent": "netbox-plugin-store/0.1"}
|
||||
if self.api_token:
|
||||
headers["Authorization"] = f"Bearer {self.api_token}"
|
||||
request = Request(url, headers=headers, method="GET")
|
||||
opener = build_opener(_PolicyRedirectHandler(self.api_policy))
|
||||
try:
|
||||
with opener.open(request, timeout=self.timeout) as response:
|
||||
length = response.headers.get("Content-Length")
|
||||
if length and int(length) > self.API_LIMIT:
|
||||
raise StoreClientError("Store response exceeds the size limit.")
|
||||
body = response.read(self.API_LIMIT + 1)
|
||||
except (HTTPError, URLError, TimeoutError, OSError) as exc:
|
||||
raise StoreClientError(f"Store request failed: {type(exc).__name__}") from exc
|
||||
if len(body) > self.API_LIMIT:
|
||||
raise StoreClientError("Store response exceeds the size limit.")
|
||||
try:
|
||||
return json.loads(body.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise StoreClientError("Store returned invalid JSON.") from exc
|
||||
|
||||
def list_plugins(self) -> list[CatalogPlugin]:
|
||||
next_url: str | None = self.store_url + "/api/v1/plugins/"
|
||||
plugins: list[CatalogPlugin] = []
|
||||
pages = 0
|
||||
while next_url:
|
||||
pages += 1
|
||||
if pages > self.MAX_PAGES:
|
||||
raise StoreClientError("Store pagination limit exceeded.")
|
||||
payload = self._request_json(next_url)
|
||||
if isinstance(payload, list):
|
||||
items = payload
|
||||
next_url = None
|
||||
elif isinstance(payload, dict):
|
||||
items = payload.get("results", payload.get("plugins", []))
|
||||
raw_next = payload.get("next")
|
||||
next_url = urljoin(next_url, str(raw_next)) if raw_next else None
|
||||
if next_url:
|
||||
self.api_policy.check(next_url)
|
||||
else:
|
||||
raise StoreClientError("Store returned an invalid catalog payload.")
|
||||
if not isinstance(items, list):
|
||||
raise StoreClientError("Store returned an invalid plugin collection.")
|
||||
plugins.extend(CatalogPlugin.from_mapping(item) for item in items if isinstance(item, dict))
|
||||
return plugins
|
||||
|
||||
def get_plugin(self, slug: str) -> CatalogPlugin:
|
||||
slug = validate_slug(slug)
|
||||
payload = self._request_json(self.store_url + f"/api/v1/plugins/{quote(slug)}/")
|
||||
if not isinstance(payload, dict):
|
||||
raise StoreClientError("Store returned an invalid plugin payload.")
|
||||
return CatalogPlugin.from_mapping(payload)
|
||||
|
||||
def download_artifact(
|
||||
self,
|
||||
url: str,
|
||||
destination: Path,
|
||||
*,
|
||||
expected_sha256: str,
|
||||
timeout: int,
|
||||
max_bytes: int,
|
||||
) -> tuple[str, int]:
|
||||
self.artifact_policy.check(url)
|
||||
expected = validate_sha256(expected_sha256)
|
||||
request = Request(
|
||||
url,
|
||||
headers={"Accept": "application/octet-stream", "User-Agent": "netbox-plugin-store/0.1"},
|
||||
method="GET",
|
||||
)
|
||||
opener = build_opener(_PolicyRedirectHandler(self.artifact_policy))
|
||||
digest = hashlib.sha256()
|
||||
written = 0
|
||||
try:
|
||||
with opener.open(request, timeout=timeout) as response, destination.open("xb") as target:
|
||||
length = response.headers.get("Content-Length")
|
||||
if length and int(length) > max_bytes:
|
||||
raise StoreClientError("Artifact exceeds the configured size limit.")
|
||||
while chunk := response.read(1024 * 1024):
|
||||
written += len(chunk)
|
||||
if written > max_bytes:
|
||||
raise StoreClientError("Artifact exceeds the configured size limit.")
|
||||
digest.update(chunk)
|
||||
target.write(chunk)
|
||||
except StoreClientError:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise
|
||||
except (HTTPError, URLError, TimeoutError, OSError) as exc:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise StoreClientError(f"Artifact download failed: {type(exc).__name__}") from exc
|
||||
actual = digest.hexdigest()
|
||||
if actual != expected:
|
||||
destination.unlink(missing_ok=True)
|
||||
raise StoreClientError("Artifact SHA-256 verification failed.")
|
||||
return actual, written
|
||||
Reference in New Issue
Block a user