You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

212 lines
8.3 KiB

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)