3391fbc85d
TestResult dataclass + translate_connection_error shared helper in base.py. test_callable field on ServiceDefinition (default None). 7 per-type test_connection routines (qbittorrent, prometheus via Grafana gateway, alertmanager, jellyfin, authentik, ssh_tasks via build_ssh_client, nextcloud). POST /api/services/test endpoint: validation-first (422 on malformed config), dispatch, no-persistence, no-secret-logs. backups has test_callable=None. qBit 'Fails.' → specific auth message (resolves #3 at API layer). Backend: 362 pytest pass (+31 new), ruff clean. Frontend: build green.
226 lines
7.9 KiB
Python
226 lines
7.9 KiB
Python
"""Base classes for service integrations.
|
|
|
|
A *service definition* is a closed, compile-time description of an external service
|
|
the app can talk to (Jellyfin, Prometheus, …). Each definition declares:
|
|
|
|
* its non-secret ``config_schema`` (derived from a Pydantic model),
|
|
* the secret fields it accepts (API keys / tokens),
|
|
* the widget kinds it can contribute to the dashboard (each with its own
|
|
Pydantic-derived config schema).
|
|
|
|
Definitions live in :mod:`media_library_viewer_api.integrations` modules and are
|
|
assembled into the closed :data:`~media_library_viewer_api.integrations.registry.SERVICE_DEFINITIONS`
|
|
map. There is no runtime plugin loading.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
from dataclasses import dataclass, field
|
|
from typing import TYPE_CHECKING, Annotated, Any, Callable
|
|
|
|
import requests
|
|
from pydantic import BaseModel, BeforeValidator, Field
|
|
|
|
if TYPE_CHECKING:
|
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
|
|
|
|
|
def _validate_service_base_url(value: Any) -> str:
|
|
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
|
|
|
Relative hosts (e.g. ``example.com``) break downstream HTTP clients
|
|
because ``requests`` treats them as relative paths, so we fail fast with a
|
|
clear error instead of letting the call silently malfunction.
|
|
"""
|
|
if not isinstance(value, str):
|
|
raise ValueError("base_url must be a string starting with http:// or https://")
|
|
text = value.strip()
|
|
if not text:
|
|
raise ValueError("base_url must not be empty")
|
|
lowered = text.lower()
|
|
if not (lowered.startswith("http://") or lowered.startswith("https://")):
|
|
raise ValueError("base_url must start with http:// or https:// (include the schema)")
|
|
return text
|
|
|
|
|
|
#: Shared annotated type for service ``base_url`` fields. applying the validator
|
|
#: uniformly across every integration so missing schemas are rejected at the
|
|
#: config boundary with a helpful message.
|
|
ServiceBaseUrl = Annotated[
|
|
str,
|
|
Field(description="Absolute URL including the http:// or https:// schema."),
|
|
BeforeValidator(_validate_service_base_url),
|
|
]
|
|
|
|
|
|
class ServiceConfigBase(BaseModel):
|
|
"""Base for per-service non-secret config models.
|
|
|
|
Subclass this in each integration module and declare the connection fields.
|
|
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
|
|
|
|
Connection URLs should use the :data:`ServiceBaseUrl` type so the
|
|
``http(s)://`` schema is enforced consistently across integrations.
|
|
"""
|
|
|
|
|
|
class WidgetConfigBase(BaseModel):
|
|
"""Base for per-widget config models.
|
|
|
|
Subclass this for each widget kind a service provides. Widget configs never
|
|
hold secrets; credentials live on the parent service record.
|
|
"""
|
|
|
|
model_config = {"extra": "forbid"}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class SecretField:
|
|
"""A secret field stored encrypted on the service record."""
|
|
|
|
key: str
|
|
label: str
|
|
required: bool = False
|
|
helper: str | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class WidgetKind:
|
|
"""A widget kind contributed by a service definition."""
|
|
|
|
kind: str
|
|
name: str
|
|
description: str
|
|
config_schema: dict[str, Any]
|
|
default_config: dict[str, Any] = field(default_factory=dict)
|
|
refresh_interval_ms: int = 0
|
|
config_model: type[WidgetConfigBase] | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class TestResult:
|
|
"""Outcome of a credential/connectivity test for a service instance."""
|
|
|
|
ok: bool
|
|
detail: str
|
|
evidence: str | None = None
|
|
|
|
|
|
#: A test routine receives (config, secrets, store). The store is needed for
|
|
#: ssh_tasks (SSH-key resolution). Other types ignore it.
|
|
TestCallable = Callable[[dict[str, Any], dict[str, str], "SettingsStore"], TestResult]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ServiceDefinition:
|
|
"""Closed description of an external service type."""
|
|
|
|
service_type: str
|
|
name: str
|
|
description: str
|
|
config_model: type[ServiceConfigBase]
|
|
secret_fields: list[SecretField]
|
|
widget_kinds: list[WidgetKind]
|
|
test_callable: TestCallable | None = None
|
|
|
|
@property
|
|
def config_schema(self) -> dict[str, Any]:
|
|
"""JSON schema for the service's non-secret config."""
|
|
return self.config_model.model_json_schema()
|
|
|
|
@property
|
|
def secret_keys(self) -> set[str]:
|
|
return {sf.key for sf in self.secret_fields}
|
|
|
|
def widget_kind(self, kind: str) -> WidgetKind | None:
|
|
for wk in self.widget_kinds:
|
|
if wk.kind == kind:
|
|
return wk
|
|
return None
|
|
|
|
|
|
def widget_kind(
|
|
kind: str,
|
|
name: str,
|
|
description: str,
|
|
model_cls: type[WidgetConfigBase],
|
|
*,
|
|
default_config: dict[str, Any] | None = None,
|
|
refresh_interval_ms: int = 0,
|
|
) -> WidgetKind:
|
|
"""Build a :class:`WidgetKind` from a Pydantic widget-config model."""
|
|
schema = model_cls.model_json_schema()
|
|
# Strip Pydantic's title noise so the exposed schema stays clean.
|
|
schema.pop("title", None)
|
|
return WidgetKind(
|
|
kind=kind,
|
|
name=name,
|
|
description=description,
|
|
config_schema=schema,
|
|
default_config=dict(default_config or {}),
|
|
refresh_interval_ms=refresh_interval_ms,
|
|
config_model=model_cls,
|
|
)
|
|
|
|
|
|
def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -> dict[str, Any]:
|
|
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
|
|
instance = model_cls.model_validate(config or {})
|
|
return instance.model_dump(exclude_none=True)
|
|
|
|
|
|
def translate_connection_error(exc: Exception, *, context: str = "") -> TestResult:
|
|
"""Map a common connection/auth exception to a human-friendly TestResult.
|
|
|
|
Handles patterns extracted from ``test_machine_ssh`` (settings.py) plus
|
|
HTTP-client patterns from the widget sources. Each per-type test routine
|
|
calls this for unexpected exceptions, but handles its **type-specific**
|
|
auth failures directly (e.g., qBit ``"Fails."``).
|
|
"""
|
|
message = str(exc)
|
|
lowered = message.lower()
|
|
|
|
# Auth failures (HTTP 401/403)
|
|
if isinstance(exc, requests.HTTPError):
|
|
status_code = exc.response.status_code if exc.response is not None else 0
|
|
if status_code in (401, 403):
|
|
return TestResult(
|
|
ok=False,
|
|
detail=f"Authentication failed — the service rejected the credentials ({status_code}).",
|
|
)
|
|
if "authentication failed" in lowered or "no authentication methods available" in lowered:
|
|
return TestResult(ok=False, detail="Authentication failed — check the credentials, API key, or SSH key.")
|
|
|
|
# Timeout (before OSError check, since requests.Timeout is a subclass of OSError)
|
|
if isinstance(exc, (requests.Timeout, TimeoutError, asyncio.TimeoutError)):
|
|
return TestResult(ok=False, detail="Connection timed out — the service did not respond in time.")
|
|
|
|
# Connection refused / DNS / unreachable
|
|
if isinstance(exc, (requests.ConnectionError, ConnectionRefusedError, OSError)):
|
|
if (
|
|
"name or service not known" in lowered
|
|
or "nodename nor servname" in lowered
|
|
or "getaddrinfo failed" in lowered
|
|
):
|
|
return TestResult(ok=False, detail="Host not found — check the URL/hostname for typos.")
|
|
return TestResult(
|
|
ok=False,
|
|
detail="Connection refused — the service is not reachable at the configured address.",
|
|
)
|
|
|
|
# SSL / certificate errors
|
|
if "ssl" in lowered or "certificate" in lowered:
|
|
return TestResult(ok=False, detail="SSL/TLS error — the service's certificate is invalid or untrusted.")
|
|
|
|
# SSH banner (from test_machine_ssh pattern)
|
|
if "protocol banner" in lowered:
|
|
return TestResult(
|
|
ok=False,
|
|
detail="SSH banner not received — confirm the SSH service is running and the port is correct.",
|
|
)
|
|
|
|
# Fallback
|
|
prefix = f"{context}: " if context else ""
|
|
return TestResult(ok=False, detail=f"{prefix}{message[:200]}")
|