feat: add storage-service container with pluggable backends (Phase 1)
New FastAPI microservice (port 8020) providing unified blob storage via PUT/GET/DELETE/LIST HTTP API. Local filesystem backend is the default (zero extra deps). S3-compatible and WebDAV backends are built in. Backend is switchable at runtime via POST /migrate, which copies all objects to the new backend, verifies each one, atomically switches, then cleans up the old backend. WebDAV XML parsing uses defusedxml to prevent XXE attacks. Wired into docker-compose (storage_data volume) and registered in the backend service-health poller as 'storage-service'. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.services.backend_manager import get_backend
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok", "backend": get_backend().driver_name}
|
||||
@@ -0,0 +1,88 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.services import migration
|
||||
from app.services.backend_manager import build_backend, switch_backend
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MigrateRequest(BaseModel):
|
||||
driver: str
|
||||
config: dict = {}
|
||||
|
||||
|
||||
class BackendConfigRequest(BaseModel):
|
||||
driver: str
|
||||
config: dict = {}
|
||||
|
||||
|
||||
@router.post("/migrate", status_code=202)
|
||||
async def start_migration(body: MigrateRequest, background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Validate the new backend, then start an async migration job that:
|
||||
1. Copies all objects from the current backend to the new one
|
||||
2. Verifies every object
|
||||
3. Atomically switches the active backend
|
||||
4. Deletes all objects from the old backend
|
||||
|
||||
Returns 409 if a migration is already in progress.
|
||||
Returns 400 if the new backend config fails validation.
|
||||
"""
|
||||
if migration.is_in_progress():
|
||||
raise HTTPException(status_code=409, detail="A migration is already in progress")
|
||||
|
||||
# Reset status and enter validating state before any async work
|
||||
migration._status.state = "validating"
|
||||
migration._status.total = 0
|
||||
migration._status.done = 0
|
||||
migration._status.failed = 0
|
||||
migration._status.errors.clear()
|
||||
|
||||
try:
|
||||
new_backend = build_backend(body.driver, body.config)
|
||||
await new_backend.test_connection()
|
||||
except Exception as exc:
|
||||
migration._status.state = "idle"
|
||||
raise HTTPException(status_code=400, detail=f"Backend validation failed: {exc}")
|
||||
|
||||
background_tasks.add_task(migration.run_migration, new_backend)
|
||||
return {"status": "started", "driver": body.driver}
|
||||
|
||||
|
||||
@router.get("/migrate/status")
|
||||
async def migration_status():
|
||||
"""Poll this to track migration progress."""
|
||||
return migration.get_status()
|
||||
|
||||
|
||||
@router.delete("/migrate", status_code=204)
|
||||
async def cancel_migration():
|
||||
"""
|
||||
Request cancellation of a running migration.
|
||||
The old backend remains active. Returns 409 if no migration is running.
|
||||
"""
|
||||
cancelled = await migration.cancel()
|
||||
if not cancelled:
|
||||
raise HTTPException(status_code=409, detail="No cancellable migration in progress")
|
||||
|
||||
|
||||
@router.patch("/backend-config", status_code=204)
|
||||
async def update_backend_config(body: BackendConfigRequest):
|
||||
"""
|
||||
Reconfigure the active backend without migrating data (e.g. update S3 credentials
|
||||
for the same endpoint, or switch back to local after a failed migration).
|
||||
|
||||
Use POST /migrate when you need data to be moved to the new backend.
|
||||
"""
|
||||
if migration.is_in_progress():
|
||||
raise HTTPException(status_code=409, detail="Cannot reconfigure while migration is in progress")
|
||||
try:
|
||||
new_backend = build_backend(body.driver, body.config)
|
||||
await new_backend.test_connection()
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Backend validation failed: {exc}")
|
||||
switch_backend(new_backend)
|
||||
@@ -0,0 +1,60 @@
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import Response
|
||||
|
||||
from app.services.backend_manager import get_backend
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _validate_key(key: str) -> str:
|
||||
"""Reject path traversal. Key may contain '/' for nested objects (e.g. user/doc.pdf)."""
|
||||
parts = key.split("/")
|
||||
if ".." in parts:
|
||||
raise HTTPException(status_code=400, detail="Invalid key: path traversal not allowed")
|
||||
return key
|
||||
|
||||
|
||||
@router.put("/objects/{bucket}/{key:path}", status_code=204)
|
||||
async def put_object(bucket: str, key: str, request: Request):
|
||||
"""Upload raw bytes. Body is read as-is (application/octet-stream)."""
|
||||
_validate_key(key)
|
||||
data = await request.body()
|
||||
try:
|
||||
await get_backend().put(bucket, key, data)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/objects/{bucket}/{key:path}")
|
||||
async def get_object(bucket: str, key: str):
|
||||
"""Download raw bytes."""
|
||||
_validate_key(key)
|
||||
try:
|
||||
data = await get_backend().get(bucket, key)
|
||||
except KeyError:
|
||||
raise HTTPException(status_code=404, detail="Object not found")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
return Response(content=data, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.delete("/objects/{bucket}/{key:path}", status_code=204)
|
||||
async def delete_object(bucket: str, key: str):
|
||||
"""Delete an object. No-op if it does not exist."""
|
||||
_validate_key(key)
|
||||
try:
|
||||
await get_backend().delete(bucket, key)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/objects/{bucket}")
|
||||
async def list_objects(bucket: str):
|
||||
"""List all keys in a bucket."""
|
||||
try:
|
||||
keys = await get_backend().list_keys(bucket)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc))
|
||||
return {"bucket": bucket, "keys": keys}
|
||||
Reference in New Issue
Block a user