""" 자동 교정 실행 — 승인 기반 (PAM 패턴 재사용) 드리프트 교정 명령을 관리자 승인 후 SSH 경유로 실행. 롤백 명령 포함. 엔드포인트: GET /api/remediation/jobs — 교정 작업 목록 GET /api/remediation/jobs/{id} — 교정 작업 상세 POST /api/remediation/approve/{id} — 승인 후 실행 POST /api/remediation/reject/{id} — 거부 POST /api/remediation/rollback/{id} — 롤백 실행 """ from __future__ import annotations import logging from datetime import datetime import paramiko from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select, desc from sqlalchemy.ext.asyncio import AsyncSession from core.auth import get_current_user, require_admin_role from core.ssh_exec import _decrypt_password as decrypt_password from database import get_db from models import User, Server, AutoRemediationJob, AuditLog logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/remediation", tags=["자동 교정"]) async def _run_fix(server: Server, cmd: str) -> tuple[bool, str]: """SSH 경유 교정 명령 실행 — 에이전트리스.""" try: pw = decrypt_password(server.os_pw_enc) ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) ssh.connect(server.ip_addr, username=server.ssh_user, password=pw, timeout=10) _, stdout, stderr = ssh.exec_command(cmd, timeout=30) out = stdout.read().decode('utf-8', 'replace').strip() err = stderr.read().decode('utf-8', 'replace').strip() exit_code = stdout.channel.recv_exit_status() ssh.close() if exit_code == 0: return True, out return False, f"exit={exit_code}: {err[:200]}" except Exception as e: return False, str(e)[:200] @router.get("/jobs") async def list_jobs( status: str = None, limit: int = 50, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): q = select(AutoRemediationJob).order_by(desc(AutoRemediationJob.created_at)).limit(limit) if status: q = q.where(AutoRemediationJob.status == status) rows = await db.execute(q) jobs = rows.scalars().all() return [ { "id": j.id, "server_id": j.server_id, "item_key": j.item_key, "fix_cmd": j.fix_cmd[:80] + "..." if len(j.fix_cmd or "") > 80 else j.fix_cmd, "status": j.status, "result": j.result_message, "created_at": j.created_at, "executed_at": j.executed_at, } for j in jobs ] @router.get("/jobs/{job_id}") async def get_job( job_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user), ): row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id)) job = row.scalar_one_or_none() if not job: raise HTTPException(404) return { "id": job.id, "server_id": job.server_id, "item_key": job.item_key, "fix_cmd": job.fix_cmd, "status": job.status, "result": job.result_message, "approved_by": job.approved_by, "created_at": job.created_at, "executed_at": job.executed_at, } @router.post("/approve/{job_id}") async def approve_and_execute( job_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): """승인 후 즉시 교정 실행.""" row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id)) job = row.scalar_one_or_none() if not job: raise HTTPException(404) if job.status != "PENDING_APPROVAL": raise HTTPException(400, f"현재 상태: {job.status}") srv_row = await db.execute(select(Server).where(Server.id == job.server_id)) server = srv_row.scalar_one_or_none() if not server: raise HTTPException(404, "서버 없음") job.status = "EXECUTING" job.approved_by = user.id await db.commit() success, result = await _run_fix(server, job.fix_cmd) job.status = "SUCCESS" if success else "FAILED" job.result_message = result[:500] job.executed_at = datetime.utcnow() # 감사 로그 log = AuditLog( user_id=user.id, action="AUTO_REMEDIATION", detail=f"서버 {server.hostname}: {job.item_key} 교정 {'성공' if success else '실패'}", created_at=datetime.utcnow(), ) db.add(log) await db.commit() return {"ok": success, "job_id": job_id, "status": job.status, "result": result[:200]} @router.post("/reject/{job_id}") async def reject_job( job_id: int, reason: str = "관리자 거부", db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id)) job = row.scalar_one_or_none() if not job: raise HTTPException(404) job.status = "REJECTED" job.result_message = reason await db.commit() return {"ok": True} @router.post("/rollback/{job_id}") async def rollback_job( job_id: int, db: AsyncSession = Depends(get_db), user: User = Depends(require_admin_role), ): """교정 롤백 실행.""" row = await db.execute(select(AutoRemediationJob).where(AutoRemediationJob.id == job_id)) job = row.scalar_one_or_none() if not job or not job.rollback_cmd: raise HTTPException(400, "롤백 명령 없음") if job.status != "SUCCESS": raise HTTPException(400, "실행 완료된 작업만 롤백 가능") srv_row = await db.execute(select(Server).where(Server.id == job.server_id)) server = srv_row.scalar_one_or_none() if not server: raise HTTPException(404) success, result = await _run_fix(server, job.rollback_cmd) job.status = "ROLLED_BACK" if success else "ROLLBACK_FAILED" job.result_message = f"ROLLBACK: {result[:400]}" await db.commit() return {"ok": success, "status": job.status, "result": result[:200]}