Files
manage/backend/src/media_library_viewer_api/routers/services.py
T
Developer 3391fbc85d feat(service-credential-tester): slice 1 — backend test endpoint + per-type routines
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.
2026-07-09 22:41:06 +00:00

210 lines
7.2 KiB
Python

"""REST API for the service registry.
Service instances hold non-secret config and encrypted secrets. Plaintext
secrets are never returned; only the boolean ``secrets_set`` map is exposed.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.integrations.base import TestResult, validate_config
from media_library_viewer_api.integrations.registry import (
SERVICE_DEFINITIONS,
get_service_definition,
require_service_definition,
)
from media_library_viewer_api.models.services import (
SecretFieldInfo,
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
WidgetKindInfo,
)
from media_library_viewer_api.services.settings_store import SettingsStore
router = APIRouter(prefix="/api/services", tags=["services"])
logger = logging.getLogger(__name__)
def _to_type_info(service_type: str) -> ServiceTypeInfo:
definition = require_service_definition(service_type)
return ServiceTypeInfo(
service_type=definition.service_type,
name=definition.name,
description=definition.description,
config_schema=definition.config_schema,
secret_fields=[
SecretFieldInfo(
key=sf.key,
label=sf.label,
required=sf.required,
helper=sf.helper,
)
for sf in definition.secret_fields
],
widget_kinds=[
WidgetKindInfo(
kind=wk.kind,
name=wk.name,
description=wk.description,
config_schema=wk.config_schema,
default_config=wk.default_config,
refresh_interval_ms=wk.refresh_interval_ms,
)
for wk in definition.widget_kinds
],
)
def _to_instance(row: dict[str, Any]) -> ServiceInstance:
"""Build an API response model, surfacing only secret 'set' flags."""
definition = get_service_definition(row["service_type"])
known_secrets = definition.secret_keys if definition else set()
secrets_blob = row.get("secrets") or {}
secrets_set = {key: (key in secrets_blob and bool(secrets_blob[key])) for key in known_secrets}
return ServiceInstance(
id=row["id"],
service_type=row["service_type"],
name=row["name"],
config=row.get("config") or {},
secrets_set=secrets_set,
enabled=row["enabled"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
def _validate_input(body: ServiceInstanceInput) -> None:
"""Validate service_type, config, and secret keys against the definition."""
definition = get_service_definition(body.service_type)
if definition is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown service type: {body.service_type}",
)
try:
validate_config(definition.config_model, body.config)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid service config: {exc}",
) from exc
unknown_secrets = set(body.secrets) - definition.secret_keys
if unknown_secrets:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown secret fields for {body.service_type}: {sorted(unknown_secrets)}",
)
@router.get("/types")
def list_types() -> list[ServiceTypeInfo]:
"""Return metadata for every registered service type."""
return [_to_type_info(service_type) for service_type in sorted(SERVICE_DEFINITIONS)]
@router.get("/instances")
def list_instances(
service_type: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> list[ServiceInstance]:
"""Return all persisted service instances (no plaintext secrets)."""
rows = store.list_services(service_type)
return [_to_instance(row) for row in rows]
@router.post("/instances", status_code=status.HTTP_201_CREATED)
def create_instance(
body: ServiceInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> ServiceInstance:
"""Create a new service instance."""
_validate_input(body)
row = store.upsert_service(
{
"id": body.id,
"service_type": body.service_type,
"name": body.name,
"config": body.config,
"enabled": body.enabled,
},
secret_values=body.secrets,
)
return _to_instance(row)
@router.put("/instances/{service_id}")
def update_instance(
service_id: str,
body: ServiceInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> ServiceInstance:
"""Update an existing service instance."""
existing = store.get_service(service_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
if body.id is not None and body.id != service_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in path does not match ID in body",
)
_validate_input(body)
row = store.upsert_service(
{
"id": service_id,
"service_type": body.service_type,
"name": body.name,
"config": body.config,
"enabled": body.enabled,
},
secret_values=body.secrets,
service_id=service_id,
)
return _to_instance(row)
@router.delete("/instances/{service_id}")
def delete_instance(
service_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, str]:
"""Delete a service instance (cascade-deletes widgets referencing it)."""
existing = store.get_service(service_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
store.delete_service(service_id)
return {"status": "deleted"}
@router.post("/test")
def test_instance(
body: ServiceInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Test connectivity + credentials for unsaved service input.
Validates first (422 on malformed config), dispatches to the per-type
test_callable, and returns ``{ok, detail, evidence}``. Does NOT persist.
"""
_validate_input(body) # raises HTTPException(422) on bad config/type/secrets
definition = require_service_definition(body.service_type)
if definition.test_callable is None:
logger.info("test requested type=%s ok=true (no test_callable)", body.service_type)
return {"ok": True, "detail": "No connection test for this service type", "evidence": None}
try:
result: TestResult = definition.test_callable(body.config, body.secrets, store)
except Exception as exc:
logger.exception("test_callable raised for type=%s", body.service_type)
result = TestResult(ok=False, detail=f"Test failed unexpectedly: {exc}")
logger.info("test requested type=%s ok=%s", body.service_type, result.ok)
return {"ok": result.ok, "detail": result.detail, "evidence": result.evidence}