from __future__ import annotations import json from datetime import UTC, datetime from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import ConflictError, NotFoundError, ValidationError from app.core.ids import new_id from app.models.entities import Backend from app.repositories.backend_repository import BackendRepository from app.services.storage_service import StorageService class BackendNotFoundError(NotFoundError): code = "backend_not_found" message = "Backend does not exist." class BackendConfigError(ValidationError): code = "backend_config_invalid" message = "Backend configuration is invalid." SUPPORTED_BACKEND_TYPES = {"local", "s3"} SUPPORTED_STABILITY_CLASSES = {"local", "stable", "opportunistic"} class BackendService: def __init__(self, session: AsyncSession) -> None: self.session = session self.repository = BackendRepository(session) self.storage_service = StorageService(session) async def list_backends(self) -> list[Backend]: return await self.repository.list_all() async def create_backend( self, *, name: str, backend_type: str, stability_class: str, read_priority: int, write_priority: int, config: dict, ) -> Backend: cleaned_name = name.strip() if not cleaned_name: raise BackendConfigError("Backend name must not be empty.") if backend_type not in SUPPORTED_BACKEND_TYPES: raise BackendConfigError(f"Unsupported backend type: {backend_type}.") if stability_class not in SUPPORTED_STABILITY_CLASSES: raise BackendConfigError(f"Unsupported stability class: {stability_class}.") if await self.repository.get_by_name(cleaned_name) is not None: raise ConflictError("A backend with this name already exists.") normalized_config = self._validate_backend_config(backend_type, config) backend = Backend( id=new_id("bkd"), name=cleaned_name, type=backend_type, stability_class=stability_class, read_priority=read_priority, write_priority=write_priority, is_enabled=True, config_json=json.dumps(normalized_config), ) created = await self.repository.create(backend) await self.session.commit() return created async def check_backend(self, backend_id: str) -> tuple[Backend, str, str | None]: backend = await self._get_backend(backend_id) try: status, detail = await self.storage_service.check_backend(backend) except Exception as exc: backend.last_health_status = "unhealthy" backend.last_health_checked_at = datetime.now(UTC) await self.repository.save(backend) await self.session.commit() return backend, "unhealthy", str(exc) backend.last_health_status = status backend.last_health_checked_at = datetime.now(UTC) await self.repository.save(backend) await self.session.commit() return backend, status, detail async def disable_backend(self, backend_id: str) -> Backend: backend = await self._get_backend(backend_id) backend.is_enabled = False saved = await self.repository.save(backend) await self.session.commit() return saved async def _get_backend(self, backend_id: str) -> Backend: backend = await self.repository.get_by_id(backend_id) if backend is None: raise BackendNotFoundError() return backend @staticmethod def _validate_backend_config(backend_type: str, config: dict) -> dict: if backend_type == "local": base_path = config.get("base_path") if not isinstance(base_path, str) or not base_path.strip(): raise BackendConfigError("Local backend config requires a non-empty base_path.") return {"base_path": base_path} if backend_type == "s3": bucket = config.get("bucket") if not isinstance(bucket, str) or not bucket.strip(): raise BackendConfigError("S3 backend config requires a non-empty bucket.") normalized = {"bucket": bucket} for key in ("region", "endpoint_url", "access_key_id", "secret_access_key"): value = config.get(key) if value is not None: normalized[key] = value return normalized raise BackendConfigError(f"Unsupported backend type: {backend_type}.")