8cdeadd6dd
PR 1 of 4 for the runtime service registry change. - Add Fernet encryption helper (services/secrets.py) with a required MANAGE_ENCRYPTION_KEY; validate it on startup. - Add closed integrations/ registry with Pydantic config + widget-config definitions for grafana, prometheus, jellyfin, nextcloud, and ssh_tasks. - Add services + service_task_runs tables and SettingsStore CRUD with cascade-delete (defensive until widgets carry service_id). - Add /api/services/types and /api/services/instances CRUD (encrypted secrets, secrets_set flags only; never plaintext). - Declare cryptography as a direct dependency. - Require MANAGE_ENCRYPTION_KEY in compose + .env.example + README. - Add 25 backend tests (registry, encryption, CRUD, cascade, task-run history). Verification: ruff clean; pytest 225 passed; frontend lint/build green.
183 lines
6.0 KiB
Python
183 lines
6.0 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 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"}
|