cfec3bb906
- backend/app/routers/storage_config.py: 5 admin-only endpoints proxying storage-service config + migration API (GET/PATCH/POST/DELETE) - backend/app/main.py: register storage_config router - frontend/src/api/client.ts: StorageStatus, MigrationStatus, StorageBackendConfig interfaces + 5 API functions - frontend/src/pages/StorageAdminPage.tsx: full admin UI — backend health dot, driver selector (local/S3/WebDAV), conditional credential fields, Test & Migrate button, live 2s-poll migration progress bar, Cancel - frontend/src/App.tsx: /admin/storage route (AdminRoute guard) - CLAUDE.md: storage enforcement rule, updated Docker tables (6 services, 3 volumes), §20 in merge checklist - backend/CLAUDE.md, frontend/CLAUDE.md, doc-service/CLAUDE.md, ai-service/CLAUDE.md: updated to reflect storage-service integration - tests/ALL_TESTS.md + tests/storage-service_tests.md: §20 (20 tests) - backend/STATUS.md, frontend/STATUS.md: updated with new endpoints/routes - changelog/2026-04-20_storage-service.md: full change log Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
127 lines
4.3 KiB
Python
127 lines
4.3 KiB
Python
"""
|
|
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()
|