guardia-itsm/routers/work.py
DESKTOP-TKLFCPRython 64c27c3509 feat(itsm): G-1~G-12 확장 기능 + 하네스/봇/설치스크립트 구현
G-1: 메신저 Webhook Relay + _send_to_room 실제 httpx 호출 구현
G-2: POST /api/tasks/bulk SR 대량작업 엔드포인트 (최대 100건)
G-3: 라이선스 만료 알림 스케줄러 (매일 09:00 KST)
G-4: 체험판 upgrade_banner 필드 + license.py 배너 로직
G-5: core/auto_rca.py + incidents/problem auto-rca 엔드포인트
G-6: core/deploy_impact.py + vibe impact-analysis 엔드포인트
G-7: core/ticket_classifier.py + SR 생성 시 AI 분류 + ai-suggestion API
G-8: VulnPatchRecord 모델 + vuln_scan 패치추적 4개 엔드포인트
G-9: core/jira_sync.py + gateway Jira/Confluence 연동 엔드포인트
G-10: core/push_notify.py + routers/push.py + PushSubscription 모델
G-11: approvals 다중승인 (위임/서명/기한초과/마감연장)
G-12: alembic.ini + migrations/ + cicd/migrate_to_postgres.sh

하네스: guardia-orchestrator 확장기능 Phase 반영
봇명령어: /sr /status /license /bulk 슬래시 명령어 추가
설치스크립트: setup/ (Ubuntu, CentOS, RHEL, Windows) --test 옵션 포함

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 18:18:52 +09:00

258 lines
11 KiB
Python

"""
Work execution router — work-log CRUD, SSH simulation, completion + messenger notify.
"""
import asyncio
import json
from datetime import datetime
from typing import List, Optional
try:
import httpx
_HTTPX = True
except ImportError:
_HTTPX = False
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import (
AuditLog, Institution, Rating, Server, SRRequest, SRStatus,
User, WorkActionType, WorkLog, WorkLogOut, WorkStepIn, compute_log_hash,
)
router = APIRouter(prefix="/api/work", tags=["work"])
# ── Messenger webhook URL (same machine) ──────────────────────────────────────
MESSENGER_WEBHOOK = "http://localhost:8000/api/messenger/webhook"
NOTIFY_ROOM = "ops" # 알림 채널
# ── SSH 시뮬레이션 템플릿 ─────────────────────────────────────────────────────
_SSH_SIM: dict[str, list[dict]] = {
"RESTART": [
{"action": WorkActionType.SSH_CONNECT,
"content": "SSH 접속 시도 (opsagent@{server}:22)",
"result": "Connected to {server} — OpenSSH_8.7, RHEL 8.9"},
{"action": WorkActionType.SSH_EXEC,
"content": "systemctl stop tomcat9 && sleep 2 && systemctl start tomcat9",
"result": "● tomcat9.service: active (running) since {ts}"},
{"action": WorkActionType.HEALTH_CHECK,
"content": "curl -sf http://localhost:8080/health -o /dev/null -w '%{http_code}'",
"result": "200 OK — 응답 시간 42ms"},
],
"DEPLOY": [
{"action": WorkActionType.SSH_CONNECT,
"content": "SFTP 접속 시도 (opsagent@{server}:22)",
"result": "sftp> Connected to {server}"},
{"action": WorkActionType.SOURCE_MOD,
"content": "파일 전송: put -r ./classes /app/was/webapps/ROOT/WEB-INF/classes/",
"result": "전송 완료 — 23 files, 1.4 MB (0.8s)"},
{"action": WorkActionType.SSH_EXEC,
"content": "systemctl reload tomcat9",
"result": "Reload OK — 클래스 파일 적용됨"},
{"action": WorkActionType.HEALTH_CHECK,
"content": "curl -sf http://localhost:8080/actuator/health",
"result": '{"status":"UP","components":{"db":{"status":"UP"}}}'},
],
"LOG": [
{"action": WorkActionType.SSH_CONNECT,
"content": "SSH 접속 ({server}:22) — 로그 분석 모드",
"result": "Connected"},
{"action": WorkActionType.SSH_EXEC,
"content": "tail -n 200 /app/was/logs/catalina.out | grep -E 'ERROR|WARN'",
"result": (
"[ERROR] 2026-05-24 17:32:11 ORA-01555: snapshot too old\n"
"[WARN] 2026-05-24 17:33:05 Connection pool exhausted (200/200)\n"
"[ERROR] 2026-05-24 17:34:22 java.lang.OutOfMemoryError: GC overhead"
)},
],
"DEFAULT": [
{"action": WorkActionType.SSH_CONNECT,
"content": "SSH 접속 ({server}:22)",
"result": "Connected"},
{"action": WorkActionType.SSH_EXEC,
"content": "작업 수행 중…",
"result": "명령 실행 완료"},
],
}
async def _write_audit(db: AsyncSession, sr_id: str, actor: str,
action: str, detail: str) -> None:
result = await db.execute(
select(AuditLog).where(AuditLog.sr_id == sr_id)
.order_by(AuditLog.id.desc()).limit(1)
)
last = result.scalars().first()
prev_hash = last.log_hash if last else None
ts = datetime.now().isoformat()
log_hash = compute_log_hash(prev_hash, actor, action, detail, ts)
db.add(AuditLog(sr_id=sr_id, actor=actor, action=action,
detail=detail, prev_hash=prev_hash, log_hash=log_hash))
async def _notify_messenger(sr: SRRequest, work_summary: str) -> None:
"""완료 시 메신저 webhook 호출."""
if not _HTTPX:
return
payload = {
"event": "itsm_complete",
"room": NOTIFY_ROOM,
"sr_id": sr.sr_id,
"title": sr.title,
"sr_type": sr.sr_type,
"requested_by": sr.requested_by,
"target_server": sr.target_server or "",
"result_summary": work_summary,
}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
await client.post(MESSENGER_WEBHOOK, json=payload)
except Exception:
pass # 메신저 미동작 시 무시
# ── Endpoints ─────────────────────────────────────────────────────────────────
@router.get("/{sr_id}", response_model=List[WorkLogOut])
async def list_work_logs(sr_id: str, db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user)):
result = await db.execute(
select(WorkLog).where(WorkLog.sr_id == sr_id).order_by(WorkLog.created_at)
)
return result.scalars().all()
@router.post("/{sr_id}/step", response_model=WorkLogOut, status_code=201)
async def add_work_step(sr_id: str, payload: WorkStepIn,
db: AsyncSession = Depends(get_db),
_u: User = Depends(get_current_user)):
r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
if not r.scalars().first():
raise HTTPException(404, "SR을 찾을 수 없습니다.")
log = WorkLog(sr_id=sr_id, **payload.model_dump())
db.add(log)
await db.commit()
await db.refresh(log)
return log
@router.post("/{sr_id}/simulate")
async def simulate_work(sr_id: str, engineer: str = "GUARDiA-AI",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""
전체 작업 흐름 시뮬레이션:
CMDB 확인 → SSH 접속 → 작업 실행 → 헬스체크 → SR COMPLETED → 메신저 알림
"""
r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
sr = r.scalars().first()
if not sr:
raise HTTPException(404, "SR을 찾을 수 없습니다.")
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
server = sr.target_server or "UNKNOWN-SRV"
# ── Step 1: CMDB 자산 확인 ──────────────────────────────────────────────
srv_info = ""
if sr.target_server:
sv = await db.execute(
select(Server).where(Server.server_name == sr.target_server)
)
sv_obj = sv.scalars().first()
if sv_obj:
srv_info = f"{sv_obj.server_role} | {sv_obj.os_type} {sv_obj.os_version} | SSH:22"
cmdb_log = WorkLog(
sr_id=sr_id, engineer=engineer,
action_type=WorkActionType.CMDB_CHECK,
content=f"CMDB 자산 조회: {server}",
result=srv_info or f"{server} — CMDB 등록 서버 확인됨",
is_success=True,
)
db.add(cmdb_log)
# ── SR 상태 자동 전이: RECEIVED/PARSED → PENDING_APPROVAL → APPROVED ────
auto_approve = sr.status in (
SRStatus.RECEIVED, SRStatus.PARSED,
SRStatus.PENDING_APPROVAL, SRStatus.APPROVED
)
if sr.status in (SRStatus.RECEIVED, SRStatus.PARSED):
sr.status = SRStatus.PENDING_APPROVAL
await _write_audit(db, sr_id, engineer, "AUTO_PENDING", "시뮬레이션: 승인 대기 전이")
if sr.status == SRStatus.PENDING_APPROVAL:
sr.status = SRStatus.APPROVED
await _write_audit(db, sr_id, "AUTO_APPROVE", "SR_APPROVED", "시뮬레이션: 자동 승인")
# IN_PROGRESS 전이
sr.status = SRStatus.IN_PROGRESS
sr.updated_at = datetime.now()
await _write_audit(db, sr_id, engineer, "SR_STARTED", "작업 시작")
await db.flush()
# ── Step 2~N: SR type별 SSH 시뮬레이션 ───────────────────────────────────
steps = _SSH_SIM.get(sr.sr_type, _SSH_SIM["DEFAULT"])
result_summary = ""
for step in steps:
wlog = WorkLog(
sr_id=sr_id, engineer=engineer,
action_type=step["action"],
content=step["content"].format(server=server, ts=ts),
result=step["result"].format(server=server, ts=ts),
is_success=True,
)
db.add(wlog)
result_summary = wlog.result
# ── Step Final: RESULT 기록 ───────────────────────────────────────────────
final_msg = f"{sr.title} 처리 완료 — {result_summary[:80]}"
db.add(WorkLog(
sr_id=sr_id, engineer=engineer,
action_type=WorkActionType.RESULT,
content="작업 결과 기록",
result=final_msg, is_success=True,
))
# ── SR → COMPLETED ────────────────────────────────────────────────────────
sr.status = SRStatus.COMPLETED
sr.updated_at = datetime.now()
await _write_audit(db, sr_id, engineer, "SR_COMPLETED", final_msg)
await db.commit()
# ── 메신저 + 이메일 알림 (비동기, 실패 무시) ─────────────────────────────
asyncio.create_task(_notify_messenger(sr, final_msg))
from core.notify import notify_sr_status_changed as _notify
asyncio.create_task(_notify(sr, SRStatus.COMPLETED, final_msg))
return {"status": "COMPLETED", "sr_id": sr_id, "summary": final_msg}
@router.post("/{sr_id}/complete")
async def manual_complete(sr_id: str, engineer: str = "엔지니어",
result_note: str = "수동 완료 처리",
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)):
"""수동 완료 처리 + 메신저 알림."""
r = await db.execute(select(SRRequest).where(SRRequest.sr_id == sr_id))
sr = r.scalars().first()
if not sr:
raise HTTPException(404, "SR을 찾을 수 없습니다.")
sr.status = SRStatus.COMPLETED
sr.updated_at = datetime.now()
db.add(WorkLog(
sr_id=sr_id, engineer=engineer,
action_type=WorkActionType.COMPLETE,
content="수동 완료 처리", result=result_note, is_success=True,
))
await _write_audit(db, sr_id, engineer, "SR_COMPLETED", result_note)
await db.commit()
asyncio.create_task(_notify_messenger(sr, result_note))
from core.notify import notify_sr_status_changed as _notify
asyncio.create_task(_notify(sr, SRStatus.COMPLETED, result_note))
return {"status": "COMPLETED", "sr_id": sr_id}