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
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)
|
|
|