- itsm/ -> workspace/guardia-itsm/ - manager/ -> workspace/guardia-manager/ - app/ -> workspace/guardia-messenger/ - manual/ -> workspace/guardia-docs/ workspace/zioinfo-web/ unchanged. git mv preserves full commit history. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
309 lines
10 KiB
Python
309 lines
10 KiB
Python
"""
|
|
DR(재해복구) 자동화 API.
|
|
|
|
엔드포인트:
|
|
GET /api/dr/scenarios 시나리오 목록
|
|
POST /api/dr/scenarios 시나리오 등록 (ADMIN)
|
|
GET /api/dr/scenarios/{id} 시나리오 상세
|
|
PUT /api/dr/scenarios/{id} 시나리오 수정 (ADMIN)
|
|
POST /api/dr/test 복구 테스트 실행
|
|
GET /api/dr/test/{id} 테스트 결과 조회
|
|
GET /api/dr/tests 테스트 이력 목록
|
|
POST /api/dr/backup-verify 백업 무결성 검증
|
|
POST /api/dr/failover/{scenario_id} Failover 실행 (ADMIN)
|
|
GET /api/dr/rto-rpo RTO/RPO 현황
|
|
GET /api/dr/dashboard DR 전체 현황
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
from typing import List, Optional
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select, desc
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from core.auth import get_current_user, require_admin_role
|
|
from database import get_db
|
|
from models import DRScenario, DRTest, User, UserRole
|
|
|
|
logger = logging.getLogger(__name__)
|
|
router = APIRouter(prefix="/api/dr", tags=["dr"])
|
|
|
|
|
|
# ── 권한 ─────────────────────────────────────────────────────────────────────
|
|
|
|
def _require_ops(current_user: User = Depends(get_current_user)) -> User:
|
|
if current_user.role not in (UserRole.ADMIN, UserRole.PM, UserRole.ENGINEER):
|
|
raise HTTPException(403, "DR 접근 권한이 없습니다.")
|
|
return current_user
|
|
|
|
|
|
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
|
|
|
|
class ScenarioCreate(BaseModel):
|
|
name: str
|
|
scenario_type: str = "SERVER_FAILURE" # SITE_FAILURE | SERVER_FAILURE | DATA_CORRUPTION
|
|
primary_server_id: Optional[int] = None
|
|
standby_server_id: Optional[int] = None
|
|
rto_minutes: Optional[int] = 240
|
|
rpo_minutes: Optional[int] = 60
|
|
failover_steps: Optional[list] = []
|
|
healthcheck_url: Optional[str] = None
|
|
|
|
|
|
class ScenarioOut(BaseModel):
|
|
id: int
|
|
name: str
|
|
scenario_type: str
|
|
rto_minutes: Optional[int]
|
|
rpo_minutes: Optional[int]
|
|
healthcheck_url: Optional[str]
|
|
last_test_at: Optional[datetime]
|
|
last_test_result: Optional[str]
|
|
is_active: bool
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class TestRequest(BaseModel):
|
|
scenario_id: int
|
|
test_type: str = "RECOVERY" # BACKUP_VERIFY | RECOVERY
|
|
|
|
|
|
class TestOut(BaseModel):
|
|
id: int
|
|
scenario_id: int
|
|
test_type: str
|
|
status: str
|
|
rto_actual: Optional[int]
|
|
rpo_actual: Optional[int]
|
|
result_detail: Optional[dict]
|
|
started_at: datetime
|
|
completed_at: Optional[datetime]
|
|
triggered_by: Optional[str]
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class BackupVerifyRequest(BaseModel):
|
|
server_name: str
|
|
|
|
|
|
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/scenarios", response_model=List[ScenarioOut])
|
|
async def list_scenarios(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(_require_ops),
|
|
):
|
|
"""DR 시나리오 목록."""
|
|
q = await db.execute(
|
|
select(DRScenario).where(DRScenario.is_active == True).order_by(DRScenario.name)
|
|
)
|
|
return q.scalars().all()
|
|
|
|
|
|
@router.post("/scenarios", response_model=ScenarioOut, status_code=201)
|
|
async def create_scenario(
|
|
body: ScenarioCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_admin_role),
|
|
):
|
|
"""DR 시나리오 등록 (ADMIN 전용)."""
|
|
scenario = DRScenario(**body.model_dump())
|
|
db.add(scenario)
|
|
await db.commit()
|
|
await db.refresh(scenario)
|
|
return scenario
|
|
|
|
|
|
@router.get("/scenarios/{scenario_id}", response_model=ScenarioOut)
|
|
async def get_scenario(
|
|
scenario_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(_require_ops),
|
|
):
|
|
q = await db.execute(select(DRScenario).where(DRScenario.id == scenario_id))
|
|
sc = q.scalar_one_or_none()
|
|
if not sc:
|
|
raise HTTPException(404, "시나리오를 찾을 수 없습니다.")
|
|
return sc
|
|
|
|
|
|
@router.put("/scenarios/{scenario_id}", response_model=ScenarioOut)
|
|
async def update_scenario(
|
|
scenario_id: int,
|
|
body: ScenarioCreate,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_admin_role),
|
|
):
|
|
q = await db.execute(select(DRScenario).where(DRScenario.id == scenario_id))
|
|
sc = q.scalar_one_or_none()
|
|
if not sc:
|
|
raise HTTPException(404, "시나리오를 찾을 수 없습니다.")
|
|
for k, v in body.model_dump().items():
|
|
setattr(sc, k, v)
|
|
await db.commit()
|
|
await db.refresh(sc)
|
|
return sc
|
|
|
|
|
|
@router.post("/test", response_model=TestOut)
|
|
async def run_recovery_test(
|
|
body: TestRequest,
|
|
background_tasks: BackgroundTasks,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(_require_ops),
|
|
):
|
|
"""복구 테스트 실행. 백그라운드로 실행되고 test_id를 즉시 반환."""
|
|
from core.dr_engine import DREngine
|
|
engine = DREngine()
|
|
|
|
if body.test_type == "BACKUP_VERIFY":
|
|
# 빠른 검증 — 동기 처리
|
|
q = await db.execute(select(DRScenario).where(DRScenario.id == body.scenario_id))
|
|
sc = q.scalar_one_or_none()
|
|
if not sc:
|
|
raise HTTPException(404, "시나리오를 찾을 수 없습니다.")
|
|
test = DRTest(
|
|
scenario_id=body.scenario_id,
|
|
test_type="BACKUP_VERIFY",
|
|
status="RUNNING",
|
|
triggered_by=current_user.username,
|
|
started_at=datetime.now(),
|
|
result_detail={},
|
|
)
|
|
db.add(test)
|
|
await db.commit()
|
|
await db.refresh(test)
|
|
background_tasks.add_task(
|
|
_run_test_bg, body.scenario_id, test.id, current_user.username
|
|
)
|
|
return test
|
|
|
|
result = await engine.run_recovery_test(db, body.scenario_id, current_user.username)
|
|
if not result.get("test_id"):
|
|
raise HTTPException(500, result.get("error", "테스트 실행 실패"))
|
|
|
|
q = await db.execute(select(DRTest).where(DRTest.id == result["test_id"]))
|
|
return q.scalar_one()
|
|
|
|
|
|
async def _run_test_bg(scenario_id: int, test_id: int, triggered_by: str):
|
|
"""백그라운드 테스트 실행 태스크."""
|
|
from database import SessionLocal
|
|
from core.dr_engine import DREngine
|
|
async with SessionLocal() as db:
|
|
engine = DREngine()
|
|
await engine.run_recovery_test(db, scenario_id, triggered_by)
|
|
|
|
|
|
@router.get("/test/{test_id}", response_model=TestOut)
|
|
async def get_test_result(
|
|
test_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(_require_ops),
|
|
):
|
|
q = await db.execute(select(DRTest).where(DRTest.id == test_id))
|
|
t = q.scalar_one_or_none()
|
|
if not t:
|
|
raise HTTPException(404, "테스트 결과를 찾을 수 없습니다.")
|
|
return t
|
|
|
|
|
|
@router.get("/tests", response_model=List[TestOut])
|
|
async def list_tests(
|
|
scenario_id: Optional[int] = None,
|
|
limit: int = 20,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(_require_ops),
|
|
):
|
|
"""테스트 이력 목록."""
|
|
q = select(DRTest).order_by(desc(DRTest.started_at)).limit(limit)
|
|
if scenario_id:
|
|
q = q.where(DRTest.scenario_id == scenario_id)
|
|
result = await db.execute(q)
|
|
return result.scalars().all()
|
|
|
|
|
|
@router.post("/backup-verify")
|
|
async def verify_backup(
|
|
body: BackupVerifyRequest,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(_require_ops),
|
|
):
|
|
"""서버 백업 무결성 검증 (SSH → SHA-256 확인)."""
|
|
from core.dr_engine import DREngine
|
|
result = await DREngine().verify_backup(db, body.server_name)
|
|
if not result["success"]:
|
|
raise HTTPException(400, result.get("error", "백업 검증 실패"))
|
|
return result
|
|
|
|
|
|
@router.post("/failover/{scenario_id}")
|
|
async def execute_failover(
|
|
scenario_id: int,
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(require_admin_role),
|
|
):
|
|
"""
|
|
Failover 실행 (ADMIN 전용).
|
|
시뮬레이션 모드로 실행 — 실제 서비스 전환은 confirm=true 파라미터 필요.
|
|
"""
|
|
from core.dr_engine import DREngine
|
|
result = await DREngine().run_recovery_test(db, scenario_id, current_user.username)
|
|
return {
|
|
"message": "Failover 테스트 실행 완료",
|
|
"test_id": result.get("test_id"),
|
|
"status": result.get("status"),
|
|
"rto_actual_minutes": result.get("rto_actual_minutes"),
|
|
}
|
|
|
|
|
|
@router.get("/rto-rpo")
|
|
async def get_rto_rpo(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(_require_ops),
|
|
):
|
|
"""RTO/RPO 목표 대비 실적 현황."""
|
|
from core.dr_engine import DREngine
|
|
return await DREngine().get_rto_rpo_stats(db)
|
|
|
|
|
|
@router.get("/dashboard")
|
|
async def get_dashboard(
|
|
db: AsyncSession = Depends(get_db),
|
|
current_user: User = Depends(_require_ops),
|
|
):
|
|
"""DR 전체 현황 대시보드."""
|
|
sc_q = await db.execute(select(DRScenario).where(DRScenario.is_active == True))
|
|
scenarios = sc_q.scalars().all()
|
|
|
|
test_q = await db.execute(
|
|
select(DRTest).order_by(desc(DRTest.started_at)).limit(10)
|
|
)
|
|
recent_tests = test_q.scalars().all()
|
|
|
|
pass_count = sum(1 for sc in scenarios if sc.last_test_result == "PASS")
|
|
fail_count = sum(1 for sc in scenarios if sc.last_test_result == "FAIL")
|
|
|
|
return {
|
|
"total_scenarios": len(scenarios),
|
|
"pass_count": pass_count,
|
|
"fail_count": fail_count,
|
|
"untested_count": len(scenarios) - pass_count - fail_count,
|
|
"recent_tests": [
|
|
{
|
|
"test_id": t.id,
|
|
"scenario_id": t.scenario_id,
|
|
"test_type": t.test_type,
|
|
"status": t.status,
|
|
"started_at": t.started_at.isoformat(),
|
|
}
|
|
for t in recent_tests
|
|
],
|
|
}
|