""" Admin-only endpoints for storage-service backend configuration. GET /admin/storage-config — current backend driver + health PATCH /admin/storage-config — update backend config (no data migration) POST /admin/storage-config/migrate — start migration to a new backend GET /admin/storage-config/migrate/status — poll migration progress DELETE /admin/storage-config/migrate — cancel in-progress migration All endpoints proxy to storage-service:8020. """ import logging import httpx from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from app.core.config import settings from app.deps import get_current_admin from app.models.user import User router = APIRouter() logger = logging.getLogger(__name__) _STORAGE_BASE = settings.STORAGE_SERVICE_URL class BackendConfigUpdate(BaseModel): driver: str config: dict = {} class MigrateRequest(BaseModel): driver: str config: dict = {} def _storage_url(path: str) -> str: return f"{_STORAGE_BASE}{path}" async def _proxy_get(path: str) -> dict: async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.get(_storage_url(path)) if resp.status_code == 404: raise HTTPException(status_code=404, detail="Not found") resp.raise_for_status() return resp.json() @router.get("/storage-config") async def get_storage_config( _: User = Depends(get_current_admin), ) -> dict: """Return current backend driver and health status.""" async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get(_storage_url("/health")) resp.raise_for_status() return resp.json() @router.patch("/storage-config", status_code=204) async def update_storage_config( body: BackendConfigUpdate, _: User = Depends(get_current_admin), ) -> None: """ Reconfigure the active backend without migrating data. Use when changing credentials for the same backend type, or reverting to local. To move data to a new backend, use POST /admin/storage-config/migrate instead. """ async with httpx.AsyncClient(timeout=15.0) as client: resp = await client.patch( _storage_url("/backend-config"), json={"driver": body.driver, "config": body.config}, ) if resp.status_code == 400: raise HTTPException(status_code=400, detail=resp.json().get("detail", "Validation failed")) if resp.status_code == 409: raise HTTPException(status_code=409, detail="Migration in progress — cannot reconfigure now") resp.raise_for_status() @router.post("/storage-config/migrate", status_code=202) async def start_migration( body: MigrateRequest, _: User = Depends(get_current_admin), ) -> dict: """ Start an async migration to a new backend. Flow: validate new backend → copy all objects → verify → switch → delete old objects. The old backend stays active until 100% of objects are verified on the new one. Poll GET /admin/storage-config/migrate/status to track progress. """ async with httpx.AsyncClient(timeout=30.0) as client: resp = await client.post( _storage_url("/migrate"), json={"driver": body.driver, "config": body.config}, ) if resp.status_code == 400: raise HTTPException(status_code=400, detail=resp.json().get("detail", "Validation failed")) if resp.status_code == 409: raise HTTPException(status_code=409, detail="A migration is already in progress") resp.raise_for_status() return resp.json() @router.get("/storage-config/migrate/status") async def migration_status( _: User = Depends(get_current_admin), ) -> dict: """Poll migration progress. State: idle → validating → migrating → switching → cleaning → done.""" return await _proxy_get("/migrate/status") @router.delete("/storage-config/migrate", status_code=204) async def cancel_migration( _: User = Depends(get_current_admin), ) -> None: """Cancel a running migration. The old backend remains active.""" async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.delete(_storage_url("/migrate")) if resp.status_code == 409: raise HTTPException(status_code=409, detail="No cancellable migration in progress") resp.raise_for_status()