125 lines
5.9 KiB
Python
Executable File
125 lines
5.9 KiB
Python
Executable File
"""RustProxy administration API helpers for NPM migration."""
|
|
from __future__ import annotations
|
|
|
|
from migrate_npm_core import *
|
|
|
|
class RustProxyClient:
|
|
def __init__(self, base_url: str, *, ca_file: Path | None = None, insecure_tls: bool = False):
|
|
parsed = urllib.parse.urlsplit(base_url.rstrip("/"))
|
|
if parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.path not in {"", "/"}:
|
|
raise MigrationError("--rustproxy must be an http(s) origin, e.g. https://rustproxy.example:9090")
|
|
if parsed.query or parsed.fragment or parsed.username or parsed.password:
|
|
raise MigrationError("--rustproxy must not contain credentials, query or fragment")
|
|
self.base_url = urllib.parse.urlunsplit((parsed.scheme, parsed.netloc, "", "", ""))
|
|
jar = http.cookiejar.CookieJar()
|
|
handlers: list[Any] = [urllib.request.HTTPCookieProcessor(jar)]
|
|
if parsed.scheme == "https":
|
|
if insecure_tls:
|
|
context = ssl._create_unverified_context() # Explicit operator opt-in for local/self-signed admin TLS.
|
|
else:
|
|
context = ssl.create_default_context(cafile=str(ca_file) if ca_file else None)
|
|
handlers.append(urllib.request.HTTPSHandler(context=context))
|
|
self.opener = urllib.request.build_opener(*handlers)
|
|
self.csrf = ""
|
|
|
|
def request(self, method: str, path: str, body: dict[str, Any] | None = None, *, authenticated_write: bool = False) -> Any:
|
|
data = None if body is None else json.dumps(body, separators=(",", ":")).encode()
|
|
headers = {"Accept": "application/json", "X-RustProxy-Request": "1"}
|
|
if data is not None:
|
|
headers["Content-Type"] = "application/json"
|
|
if method != "GET":
|
|
headers["Origin"] = self.base_url
|
|
if authenticated_write:
|
|
headers["X-CSRF-Token"] = self.csrf
|
|
request = urllib.request.Request(self.base_url + path, data=data, headers=headers, method=method)
|
|
try:
|
|
with self.opener.open(request, timeout=10) as response:
|
|
payload = response.read(1024 * 1024)
|
|
except urllib.error.HTTPError as error:
|
|
payload = error.read(65536)
|
|
try:
|
|
detail = json.loads(payload).get("error", payload.decode("utf-8", "replace"))
|
|
except Exception:
|
|
detail = payload.decode("utf-8", "replace")
|
|
raise MigrationError(f"RustProxy API {method} {path} failed with HTTP {error.code}: {detail}") from error
|
|
except (OSError, urllib.error.URLError) as error:
|
|
raise MigrationError(f"RustProxy API connection failed: {error}") from error
|
|
try:
|
|
return json.loads(payload)
|
|
except json.JSONDecodeError as error:
|
|
raise MigrationError(f"RustProxy API returned invalid JSON for {path}") from error
|
|
|
|
def login(self, username: str, password: str) -> None:
|
|
info = self.request("POST", "/api/auth/login", {"username": username, "password": password})
|
|
token = info.get("csrf_token") if isinstance(info, dict) else None
|
|
if not isinstance(token, str) or not token:
|
|
session = self.request("GET", "/api/auth/session")
|
|
token = session.get("csrf_token") if isinstance(session, dict) else None
|
|
if not isinstance(token, str) or not token:
|
|
raise MigrationError("RustProxy login succeeded but no CSRF token was returned")
|
|
self.csrf = token
|
|
|
|
def config(self) -> dict[str, Any]:
|
|
value = self.request("GET", "/api/config")
|
|
if not isinstance(value, dict) or not isinstance(value.get("revision"), int) or not isinstance(value.get("config"), dict):
|
|
raise MigrationError("unexpected RustProxy /api/config response")
|
|
return value
|
|
|
|
def apply(self, revision: int, config: dict[str, Any]) -> dict[str, Any]:
|
|
value = self.request("PUT", "/api/config", {"expected_revision": revision, "config": config}, authenticated_write=True)
|
|
if not isinstance(value, dict):
|
|
raise MigrationError("unexpected RustProxy PUT /api/config response")
|
|
return value
|
|
|
|
|
|
|
|
def write_private_json(path: Path, value: dict[str, Any]) -> None:
|
|
path = path.expanduser()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
if path.is_symlink():
|
|
raise MigrationError(f"refusing to write migration plan through symlink: {path}")
|
|
flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC
|
|
if hasattr(os, "O_NOFOLLOW"):
|
|
flags |= os.O_NOFOLLOW
|
|
fd = os.open(path, flags, 0o600)
|
|
try:
|
|
os.fchmod(fd, 0o600)
|
|
payload = (json.dumps(value, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
|
|
with os.fdopen(fd, "wb", closefd=False) as stream:
|
|
stream.write(payload)
|
|
stream.flush()
|
|
os.fsync(stream.fileno())
|
|
finally:
|
|
os.close(fd)
|
|
|
|
def split_ids(values: list[str] | None) -> list[str] | None:
|
|
if values is None:
|
|
return None
|
|
out: list[str] = []
|
|
for value in values:
|
|
for item in value.split(","):
|
|
item = item.strip()
|
|
if item and item not in out:
|
|
out.append(item)
|
|
return out
|
|
|
|
|
|
def print_report(decisions: list[Decision]) -> None:
|
|
imported = sum(1 for d in decisions if d.action == "import")
|
|
skipped = sum(1 for d in decisions if d.action == "skip")
|
|
print(f"NPM proxy hosts: {len(decisions)}; import: {imported}; skip: {skipped}")
|
|
for decision in decisions:
|
|
print(f"\n[{decision.action.upper()}] NPM #{decision.npm_id}")
|
|
if decision.hosts:
|
|
print(" hosts: " + ", ".join(decision.hosts))
|
|
if decision.backend:
|
|
print(" backend: " + decision.backend)
|
|
if decision.rustproxy_ids:
|
|
print(" ids: " + ", ".join(decision.rustproxy_ids))
|
|
if decision.reason:
|
|
print(" reason: " + decision.reason)
|
|
for warning in decision.warnings or []:
|
|
print(" warning: " + warning)
|
|
|
|
|