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.
97 lines
2.4 KiB
Python
97 lines
2.4 KiB
Python
"""Pydantic models for the service registry API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
|
|
|
|
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
|
"""Reject credential keys in non-secret service config.
|
|
|
|
Secrets are sent in the separate ``secrets`` mapping; the plain ``config``
|
|
object must never hold them.
|
|
"""
|
|
forbidden = {
|
|
"password",
|
|
"token",
|
|
"secret",
|
|
"api_key",
|
|
"apikey",
|
|
"private_key",
|
|
"passphrase",
|
|
"credential",
|
|
}
|
|
|
|
def _check(value: Any) -> None:
|
|
if isinstance(value, dict):
|
|
for key, child in value.items():
|
|
if key.lower() in forbidden:
|
|
raise ValueError(
|
|
f"Credential key '{key}' is not allowed in service config"
|
|
)
|
|
_check(child)
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
_check(item)
|
|
|
|
_check(config)
|
|
return config
|
|
|
|
|
|
class ServiceInstanceInput(BaseModel):
|
|
"""Payload for creating or updating a service instance."""
|
|
|
|
id: str | None = None
|
|
service_type: str = Field(..., min_length=1)
|
|
name: str = Field(..., min_length=1)
|
|
config: dict[str, Any] = Field(default_factory=dict)
|
|
secrets: dict[str, str] = Field(default_factory=dict)
|
|
enabled: bool = True
|
|
|
|
@field_validator("config")
|
|
@classmethod
|
|
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
|
return _validate_config_keys(value or {})
|
|
|
|
|
|
class ServiceInstance(BaseModel):
|
|
"""Persisted service instance returned by the API (no plaintext secrets)."""
|
|
|
|
id: str
|
|
service_type: str
|
|
name: str
|
|
config: dict[str, Any]
|
|
secrets_set: dict[str, bool]
|
|
enabled: bool
|
|
created_at: int
|
|
updated_at: int
|
|
|
|
|
|
class SecretFieldInfo(BaseModel):
|
|
key: str
|
|
label: str
|
|
required: bool = False
|
|
helper: str | None = None
|
|
|
|
|
|
class WidgetKindInfo(BaseModel):
|
|
kind: str
|
|
name: str
|
|
description: str
|
|
config_schema: dict[str, Any]
|
|
default_config: dict[str, Any]
|
|
refresh_interval_ms: int
|
|
|
|
|
|
class ServiceTypeInfo(BaseModel):
|
|
"""Metadata about a registered service type."""
|
|
|
|
service_type: str
|
|
name: str
|
|
description: str
|
|
config_schema: dict[str, Any]
|
|
secret_fields: list[SecretFieldInfo]
|
|
widget_kinds: list[WidgetKindInfo]
|