from __future__ import annotations import json from datetime import UTC, datetime from sqlalchemy.ext.asyncio import AsyncSession from app.core.backend_config import ( SECRET_CONFIG_KEYS, BackendConfigError, merge_backend_config, split_backend_config, supported_backend_types, validate_backend_config, ) from app.core.exceptions import ConflictError, NotFoundError from app.core.ids import new_id from app.core.secret_codec import decrypt_secret_config, encrypt_secret_config from app.models.entities import Backend from app.repositories.backend_repository import BackendRepository from app.repositories.blob_replica_repository import BlobReplicaRepository from app.services.local_storage_service import DEFAULT_LOCAL_BACKEND_ID from app.services.storage_service import StorageService class BackendNotFoundError(NotFoundError): code = "backend_not_found" message = "Backend does not exist." class BackendDeleteConflictError(ConflictError): code = "backend_delete_conflict" message = "Backend cannot be deleted." class BackendDisableConflictError(ConflictError): code = "backend_disable_conflict" message = "Backend cannot be disabled." SUPPORTED_BACKEND_TYPES = supported_backend_types() SUPPORTED_STABILITY_CLASSES = {"local", "stable", "opportunistic"} class BackendService: def __init__(self, session: AsyncSession) -> None: self.session = session self.repository = BackendRepository(session) self.blob_replica_repository = BlobReplicaRepository(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.") public_config, secret_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, public_config_json=json.dumps(public_config), secret_config_json=encrypt_secret_config(secret_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) if backend.type == "local_directory": enabled_local_backends = await self.repository.list_enabled_by_type("local_directory") if backend.is_enabled and len(enabled_local_backends) <= 1: raise BackendDisableConflictError("At least one enabled local_directory backend is required.") backend.is_enabled = False saved = await self.repository.save(backend) await self.session.commit() return saved async def enable_backend(self, backend_id: str) -> Backend: backend = await self._get_backend(backend_id) backend.is_enabled = True saved = await self.repository.save(backend) await self.session.commit() return saved async def delete_backend(self, backend_id: str) -> None: backend = await self._get_backend(backend_id) if backend.id == DEFAULT_LOCAL_BACKEND_ID: raise BackendDeleteConflictError("The default local backend cannot be deleted.") if backend.is_enabled: raise BackendDeleteConflictError("Disable the backend before deleting it.") if await self.repository.is_referenced_by_policy(backend.id): raise BackendDeleteConflictError("Backend is still referenced by a placement policy.") replica_count = await self.blob_replica_repository.count_by_backend(backend.id) if replica_count > 0: raise BackendDeleteConflictError("Backend still has stored blob replicas.") await self.repository.delete(backend) await self.session.commit() async def update_backend( self, backend_id: str, *, name: str | None = None, stability_class: str | None = None, read_priority: int | None = None, write_priority: int | None = None, config: dict | None = None, ) -> Backend: backend = await self._get_backend(backend_id) if name is not None: cleaned_name = name.strip() if not cleaned_name: raise BackendConfigError("Backend name must not be empty.") existing = await self.repository.get_by_name(cleaned_name) if existing is not None and existing.id != backend.id: raise ConflictError("A backend with this name already exists.") backend.name = cleaned_name if stability_class is not None: if stability_class not in SUPPORTED_STABILITY_CLASSES: raise BackendConfigError(f"Unsupported stability class: {stability_class}.") backend.stability_class = stability_class if read_priority is not None: backend.read_priority = read_priority if write_priority is not None: backend.write_priority = write_priority if config is not None: public_config = json.loads(backend.public_config_json) secret_config = decrypt_secret_config(backend.secret_config_json) next_config = self._merge_config_update( backend.type, public_config=public_config, secret_config=secret_config, config_patch=config, ) next_public_config, next_secret_config = self._validate_backend_config(backend.type, next_config) backend.public_config_json = json.dumps(next_public_config) backend.secret_config_json = encrypt_secret_config(next_secret_config) 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) -> tuple[dict, dict]: return split_backend_config(backend_type, config) @staticmethod def _merge_config_update( backend_type: str, *, public_config: dict, secret_config: dict, config_patch: dict, ) -> dict: merged = merge_backend_config(public_config, secret_config) normalized_patch = dict(config_patch) for key in list(normalized_patch): if key in SECRET_CONFIG_KEYS and not normalized_patch[key]: normalized_patch.pop(key) merged.update(normalized_patch) return validate_backend_config(backend_type, merged)