feat(V2): IBKR key rotation admin endpoints + health probe
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,9 @@ from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
from cerbero_mcp.exchanges.ibkr.key_rotation import KeyRotationManager
|
||||
|
||||
MAX_RECORDS = 10000
|
||||
DEFAULT_LIMIT = 1000
|
||||
@@ -155,4 +158,86 @@ def make_admin_router() -> APIRouter:
|
||||
},
|
||||
}
|
||||
|
||||
class _IBKRRotateConfirmReq(BaseModel):
|
||||
new_consumer_key: str
|
||||
new_access_token: str
|
||||
new_access_token_secret: str
|
||||
|
||||
@r.post("/ibkr/rotate-keys/start")
|
||||
async def _ibkr_rotate_start(env: str, request: Request):
|
||||
if env not in ("testnet", "mainnet"):
|
||||
raise HTTPException(400, detail={"error": "invalid env"})
|
||||
settings = request.app.state.settings
|
||||
creds = settings.ibkr.credentials(env)
|
||||
mgr = KeyRotationManager(
|
||||
signature_key_path=creds["signature_key_path"],
|
||||
encryption_key_path=creds["encryption_key_path"],
|
||||
)
|
||||
rotations = getattr(request.app.state, "ibkr_rotations", None)
|
||||
if rotations is None:
|
||||
rotations = {}
|
||||
request.app.state.ibkr_rotations = rotations
|
||||
rotations[env] = mgr
|
||||
return await mgr.start()
|
||||
|
||||
@r.post("/ibkr/rotate-keys/confirm")
|
||||
async def _ibkr_rotate_confirm(
|
||||
env: str, body: _IBKRRotateConfirmReq, request: Request,
|
||||
):
|
||||
if env not in ("testnet", "mainnet"):
|
||||
raise HTTPException(400, detail={"error": "invalid env"})
|
||||
rotations = getattr(request.app.state, "ibkr_rotations", {}) or {}
|
||||
mgr = rotations.get(env)
|
||||
if mgr is None:
|
||||
raise HTTPException(409, detail={"error": "rotation not started"})
|
||||
|
||||
settings = request.app.state.settings
|
||||
if env == "testnet":
|
||||
settings.ibkr.consumer_key_testnet = body.new_consumer_key
|
||||
settings.ibkr.access_token_testnet = body.new_access_token
|
||||
settings.ibkr.access_token_secret_testnet = SecretStr(body.new_access_token_secret)
|
||||
else:
|
||||
settings.ibkr.consumer_key_live = body.new_consumer_key
|
||||
settings.ibkr.access_token_live = body.new_access_token
|
||||
settings.ibkr.access_token_secret_live = SecretStr(body.new_access_token_secret)
|
||||
|
||||
registry = request.app.state.registry
|
||||
registry._clients.pop(("ibkr", env), None)
|
||||
|
||||
async def _validate() -> bool:
|
||||
try:
|
||||
client = await registry.get("ibkr", env)
|
||||
await client._request("GET", "/iserver/auth/status", skip_tickle=True)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
try:
|
||||
return await mgr.confirm(validate=_validate)
|
||||
finally:
|
||||
rotations.pop(env, None)
|
||||
|
||||
@r.post("/ibkr/rotate-keys/abort")
|
||||
async def _ibkr_rotate_abort(env: str, request: Request):
|
||||
rotations = getattr(request.app.state, "ibkr_rotations", {}) or {}
|
||||
mgr = rotations.pop(env, None)
|
||||
if mgr is None:
|
||||
return {"aborted": False, "reason": "no rotation in progress"}
|
||||
return await mgr.abort()
|
||||
|
||||
@r.post("/ibkr/health")
|
||||
async def _ibkr_health(request: Request):
|
||||
registry = request.app.state.registry
|
||||
out: dict[str, Any] = {}
|
||||
for env in ("testnet", "mainnet"):
|
||||
try:
|
||||
client = await registry.get("ibkr", env)
|
||||
status = await client._request(
|
||||
"GET", "/iserver/auth/status", skip_tickle=True
|
||||
)
|
||||
out[env] = {"healthy": True, "status": status}
|
||||
except Exception as e:
|
||||
out[env] = {"healthy": False, "error": str(e)[:200]}
|
||||
return out
|
||||
|
||||
return r
|
||||
|
||||
Reference in New Issue
Block a user