feat: complete client runtime foundation
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
"""Bounded, read-only startup probes for local service capabilities."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from http.cookiejar import CookieJar
|
||||
from typing import Any
|
||||
from urllib import error, parse, request
|
||||
|
||||
from archive_clients.config import ServiceConfig
|
||||
from archive_control.v1 import common_pb2
|
||||
|
||||
|
||||
_MAX_RESPONSE_BYTES = 1024 * 1024
|
||||
_QB_VERSION = re.compile(r"^v?(\d+)\.(\d+)(?:\.|$)")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ServiceProbe:
|
||||
service: str
|
||||
state: int
|
||||
checked_at: datetime
|
||||
version: str = ""
|
||||
api_version: str = ""
|
||||
detail: str = ""
|
||||
device_id: str = ""
|
||||
libtorrent_version: str = ""
|
||||
|
||||
|
||||
class _NoRedirect(request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *_: Any, **__: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def probe_qbittorrent(
|
||||
config: ServiceConfig, timeout: float = 10
|
||||
) -> ServiceProbe:
|
||||
checked_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
opener = request.build_opener(
|
||||
request.HTTPCookieProcessor(CookieJar()), _NoRedirect()
|
||||
)
|
||||
credentials = parse.urlencode({
|
||||
"username": config.username,
|
||||
"password": config.read_password(),
|
||||
}).encode("utf-8")
|
||||
login = request.Request(
|
||||
_url(config.endpoint, "/api/v2/auth/login"),
|
||||
data=credentials,
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
if _read(opener.open(login, timeout=timeout)).strip() != "Ok.":
|
||||
raise PermissionError("qBittorrent authentication failed")
|
||||
version = _text_get(opener, config.endpoint, "/api/v2/app/version", timeout)
|
||||
api_version = _text_get(
|
||||
opener, config.endpoint, "/api/v2/app/webapiVersion", timeout
|
||||
)
|
||||
build = _json_get(
|
||||
opener, config.endpoint, "/api/v2/app/buildInfo", timeout
|
||||
)
|
||||
libtorrent = build.get("libtorrent", "")
|
||||
if not all(isinstance(value, str) and value for value in (
|
||||
version, api_version, libtorrent,
|
||||
)):
|
||||
raise ValueError("qBittorrent returned incomplete version data")
|
||||
if not _supported_qb_version(version):
|
||||
return ServiceProbe(
|
||||
"qbittorrent", common_pb2.HEALTH_STATE_UNHEALTHY,
|
||||
checked_at, version=version, api_version=api_version,
|
||||
detail="unsupported qBittorrent version",
|
||||
libtorrent_version=libtorrent,
|
||||
)
|
||||
return ServiceProbe(
|
||||
"qbittorrent", common_pb2.HEALTH_STATE_HEALTHY, checked_at,
|
||||
version=version, api_version=api_version,
|
||||
libtorrent_version=libtorrent,
|
||||
)
|
||||
except (PermissionError, error.HTTPError) as exc:
|
||||
detail = "authentication failed" if isinstance(
|
||||
exc, PermissionError
|
||||
) or getattr(exc, "code", 0) in {401, 403} else "HTTP probe failed"
|
||||
return ServiceProbe(
|
||||
"qbittorrent", common_pb2.HEALTH_STATE_UNHEALTHY,
|
||||
checked_at, detail=detail,
|
||||
)
|
||||
except (error.URLError, OSError, UnicodeError, ValueError, json.JSONDecodeError):
|
||||
return ServiceProbe(
|
||||
"qbittorrent", common_pb2.HEALTH_STATE_DEGRADED,
|
||||
checked_at, detail="service unavailable or incompatible",
|
||||
)
|
||||
|
||||
|
||||
def probe_syncthing(
|
||||
config: ServiceConfig, timeout: float = 10
|
||||
) -> ServiceProbe:
|
||||
checked_at = datetime.now(timezone.utc)
|
||||
try:
|
||||
opener = request.build_opener(_NoRedirect())
|
||||
headers = {"X-API-Key": config.read_api_key() or ""}
|
||||
version = _json_get(
|
||||
opener, config.endpoint, "/rest/system/version", timeout, headers
|
||||
)
|
||||
status = _json_get(
|
||||
opener, config.endpoint, "/rest/system/status", timeout, headers
|
||||
)
|
||||
service_version = version.get("longVersion") or version.get("version")
|
||||
device_id = status.get("myID")
|
||||
if not isinstance(service_version, str) or not service_version:
|
||||
raise ValueError("Syncthing returned no version")
|
||||
if not isinstance(device_id, str) or not device_id:
|
||||
raise ValueError("Syncthing returned no device ID")
|
||||
return ServiceProbe(
|
||||
"syncthing", common_pb2.HEALTH_STATE_HEALTHY, checked_at,
|
||||
version=service_version, device_id=device_id,
|
||||
)
|
||||
except error.HTTPError as exc:
|
||||
detail = (
|
||||
"authentication failed"
|
||||
if exc.code in {401, 403} else "HTTP probe failed"
|
||||
)
|
||||
return ServiceProbe(
|
||||
"syncthing", common_pb2.HEALTH_STATE_UNHEALTHY,
|
||||
checked_at, detail=detail,
|
||||
)
|
||||
except (error.URLError, OSError, UnicodeError, ValueError, json.JSONDecodeError):
|
||||
return ServiceProbe(
|
||||
"syncthing", common_pb2.HEALTH_STATE_DEGRADED,
|
||||
checked_at, detail="service unavailable or incompatible",
|
||||
)
|
||||
|
||||
|
||||
def _url(endpoint: str, path: str) -> str:
|
||||
return f"{endpoint.rstrip('/')}{path}"
|
||||
|
||||
|
||||
def _supported_qb_version(version: str) -> bool:
|
||||
match = _QB_VERSION.match(version)
|
||||
if match is None:
|
||||
return False
|
||||
major, minor = int(match.group(1)), int(match.group(2))
|
||||
return major == 5 or (major == 4 and minor in {5, 6})
|
||||
|
||||
|
||||
def _text_get(
|
||||
opener: Any, endpoint: str, path: str, timeout: float,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> str:
|
||||
call = request.Request(_url(endpoint, path), headers=headers or {})
|
||||
return _read(opener.open(call, timeout=timeout)).strip()
|
||||
|
||||
|
||||
def _json_get(
|
||||
opener: Any, endpoint: str, path: str, timeout: float,
|
||||
headers: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
value = json.loads(_text_get(opener, endpoint, path, timeout, headers))
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("service response is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _read(response: Any) -> str:
|
||||
with response:
|
||||
data = response.read(_MAX_RESPONSE_BYTES + 1)
|
||||
if len(data) > _MAX_RESPONSE_BYTES:
|
||||
raise ValueError("service response exceeds limit")
|
||||
return data.decode("utf-8")
|
||||
Reference in New Issue
Block a user