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>
263 lines
8.6 KiB
Python
263 lines
8.6 KiB
Python
"""
|
||
GUARDiA ITSM — SLA 타이머 & 자동 에스컬레이션 (Enhancement A-2)
|
||
|
||
기능:
|
||
1. SR 생성 시 SLA 마감 시각 계산 (기관 sla_hours × 우선순위 배수)
|
||
2. 매 30분 스케줄러 실행 → 마감 초과 SR 탐지
|
||
3. 에스컬레이션: assigned_to → 해당 기관 PM → ADMIN 순으로 재배정
|
||
4. 메신저/이메일 알림 발송
|
||
|
||
우선순위별 SLA 배수:
|
||
CRITICAL → 0.5× (가장 짧음)
|
||
HIGH → 0.75×
|
||
MEDIUM → 1.0× (기준값)
|
||
LOW → 2.0×
|
||
|
||
예시: 기관 sla_hours=4, CRITICAL SR → 마감 = 생성 후 2시간
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
from datetime import datetime, timedelta, timezone
|
||
from typing import Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 우선순위별 SLA 시간 배수
|
||
_SLA_MULTIPLIER: dict[str, float] = {
|
||
"CRITICAL": 0.50,
|
||
"HIGH": 0.75,
|
||
"MEDIUM": 1.00,
|
||
"LOW": 2.00,
|
||
}
|
||
|
||
# 기관 SLA 미설정 시 기본값 (시간)
|
||
_DEFAULT_SLA_HOURS = 8
|
||
|
||
|
||
def compute_sla_deadline(
|
||
created_at: datetime,
|
||
sla_hours: int,
|
||
priority: str,
|
||
) -> datetime:
|
||
"""
|
||
SR 생성 시각 + (sla_hours × 우선순위 배수)로 SLA 마감 시각 계산.
|
||
|
||
Args:
|
||
created_at: SR 생성 시각 (UTC 또는 로컬 naive datetime)
|
||
sla_hours: 기관 SLA 기준 시간 (Institution.sla_hours)
|
||
priority: CRITICAL / HIGH / MEDIUM / LOW
|
||
|
||
Returns:
|
||
SLA 마감 datetime
|
||
"""
|
||
multiplier = _SLA_MULTIPLIER.get(priority.upper(), 1.0)
|
||
delta_hours = sla_hours * multiplier
|
||
return created_at + timedelta(hours=delta_hours)
|
||
|
||
|
||
def is_sla_breached(sla_deadline: Optional[datetime]) -> bool:
|
||
"""현재 시각이 SLA 마감을 초과했는지 확인."""
|
||
if not sla_deadline:
|
||
return False
|
||
now = datetime.now()
|
||
# timezone-aware vs naive 혼용 방지
|
||
if sla_deadline.tzinfo is not None:
|
||
now = datetime.now(timezone.utc)
|
||
return now > sla_deadline
|
||
|
||
|
||
def sla_remaining_minutes(sla_deadline: Optional[datetime]) -> Optional[int]:
|
||
"""
|
||
SLA 잔여 시간(분). 음수면 초과.
|
||
sla_deadline이 None 이면 None 반환.
|
||
"""
|
||
if not sla_deadline:
|
||
return None
|
||
now = datetime.now()
|
||
if sla_deadline.tzinfo is not None:
|
||
now = datetime.now(timezone.utc)
|
||
delta = sla_deadline - now
|
||
return int(delta.total_seconds() / 60)
|
||
|
||
|
||
# ── SR 생성 시 SLA 마감 자동 계산 ─────────────────────────────────────────────
|
||
|
||
async def set_sla_on_create(sr_id: str, db) -> None:
|
||
"""
|
||
SR 생성 직후 호출 — 기관 sla_hours 조회 후 sla_deadline 계산·저장.
|
||
|
||
routers/tasks.py의 create_sr() 내부에서 호출:
|
||
from core.sla import set_sla_on_create
|
||
await set_sla_on_create(sr.sr_id, db)
|
||
"""
|
||
from sqlalchemy import select
|
||
from models import SRRequest, Institution
|
||
|
||
sr = (await db.execute(
|
||
select(SRRequest).where(SRRequest.sr_id == sr_id)
|
||
)).scalars().first()
|
||
|
||
if not sr or sr.sla_deadline:
|
||
return
|
||
|
||
# 기관 SLA 시간 조회
|
||
sla_hours = _DEFAULT_SLA_HOURS
|
||
if sr.inst_id:
|
||
inst = (await db.execute(
|
||
select(Institution).where(Institution.id == sr.inst_id)
|
||
)).scalars().first()
|
||
if inst and inst.sla_hours:
|
||
sla_hours = inst.sla_hours
|
||
|
||
sr.sla_deadline = compute_sla_deadline(sr.created_at, sla_hours, sr.priority)
|
||
await db.commit()
|
||
logger.debug(
|
||
"SLA 마감 설정: sr_id=%s priority=%s deadline=%s",
|
||
sr_id, sr.priority, sr.sla_deadline,
|
||
)
|
||
|
||
|
||
# ── 에스컬레이션 ─────────────────────────────────────────────────────────────
|
||
|
||
async def escalate_sr(sr_id: str, db) -> Optional[str]:
|
||
"""
|
||
SLA 초과 SR을 에스컬레이션합니다.
|
||
|
||
에스컬레이션 체인:
|
||
1. 기관 담당자(role=PM) → ITSM PM 사용자
|
||
2. 없으면 → ADMIN 사용자
|
||
|
||
Returns:
|
||
에스컬레이션 대상 username 또는 None
|
||
"""
|
||
from sqlalchemy import select
|
||
from models import SRRequest, User, UserRole, Institution, InstContact
|
||
|
||
sr = (await db.execute(
|
||
select(SRRequest).where(SRRequest.sr_id == sr_id)
|
||
)).scalars().first()
|
||
|
||
if not sr or sr.escalated_at:
|
||
return None # 이미 에스컬레이션됨
|
||
|
||
escalate_target: Optional[str] = None
|
||
|
||
# 1차: 기관 PM 연락처 조회
|
||
if sr.inst_id:
|
||
contact = (await db.execute(
|
||
select(InstContact).where(
|
||
InstContact.inst_id == sr.inst_id,
|
||
InstContact.role == "PM",
|
||
InstContact.is_active.is_(True),
|
||
).order_by(InstContact.is_primary.desc())
|
||
)).scalars().first()
|
||
if contact and contact.email:
|
||
escalate_target = contact.email
|
||
|
||
# 2차: ITSM PM 사용자 조회
|
||
if not escalate_target:
|
||
pm_user = (await db.execute(
|
||
select(User).where(
|
||
User.role == UserRole.PM,
|
||
User.is_active.is_(True),
|
||
).limit(1)
|
||
)).scalars().first()
|
||
if pm_user:
|
||
escalate_target = pm_user.username
|
||
|
||
# 3차: ADMIN 사용자 조회
|
||
if not escalate_target:
|
||
admin = (await db.execute(
|
||
select(User).where(
|
||
User.role == UserRole.ADMIN,
|
||
User.is_active.is_(True),
|
||
).limit(1)
|
||
)).scalars().first()
|
||
if admin:
|
||
escalate_target = admin.username
|
||
|
||
if not escalate_target:
|
||
logger.warning("SLA 에스컬레이션 대상 없음: sr_id=%s", sr_id)
|
||
return None
|
||
|
||
now = datetime.now()
|
||
previous_assignee = sr.assigned_to
|
||
sr.assigned_to = escalate_target
|
||
sr.escalated_at = now
|
||
sr.escalated_to = escalate_target
|
||
sr.sla_breached = True
|
||
sr.updated_at = now
|
||
await db.commit()
|
||
|
||
logger.warning(
|
||
"SLA 에스컬레이션: sr_id=%s %s → %s",
|
||
sr_id, previous_assignee, escalate_target,
|
||
)
|
||
return escalate_target
|
||
|
||
|
||
# ── 스케줄러 작업 — 매 30분 실행 ─────────────────────────────────────────────
|
||
|
||
async def check_sla_violations() -> None:
|
||
"""
|
||
SLA 마감이 지났으나 미완료 상태인 SR을 탐지하여 에스컬레이션.
|
||
|
||
core/scheduler.py start_scheduler()에서 등록:
|
||
scheduler.add_job(check_sla_violations, 'interval', minutes=30, ...)
|
||
"""
|
||
from database import SessionLocal
|
||
from models import SRRequest, SRStatus
|
||
from sqlalchemy import select, and_
|
||
from core.notify import send_messenger
|
||
|
||
# 최종 상태가 아닌 SR 목록
|
||
terminal = {SRStatus.COMPLETED, SRStatus.REJECTED, SRStatus.FAILED_ROLLBACK}
|
||
|
||
try:
|
||
async with SessionLocal() as db:
|
||
now = datetime.now()
|
||
# sla_deadline 초과 + 미완료 + 에스컬레이션 미발생
|
||
candidates = (await db.execute(
|
||
select(SRRequest).where(
|
||
and_(
|
||
SRRequest.sla_deadline.isnot(None),
|
||
SRRequest.sla_deadline < now,
|
||
SRRequest.sla_breached.is_(False),
|
||
SRRequest.status.notin_([s.value for s in terminal]),
|
||
)
|
||
)
|
||
)).scalars().all()
|
||
|
||
if not candidates:
|
||
logger.debug("SLA 위반 SR 없음")
|
||
return
|
||
|
||
logger.warning("SLA 위반 SR 탐지: %d건", len(candidates))
|
||
|
||
for sr in candidates:
|
||
async with SessionLocal() as db:
|
||
target = await escalate_sr(sr.sr_id, db)
|
||
if target:
|
||
# 에스컬레이션 알림
|
||
overdue_min = abs(sla_remaining_minutes(sr.sla_deadline) or 0)
|
||
msg = (
|
||
f"🚨 SLA 위반 에스컬레이션\n"
|
||
f"SR: {sr.sr_id}\n"
|
||
f"제목: {sr.title}\n"
|
||
f"우선순위: {sr.priority}\n"
|
||
f"초과 시간: {overdue_min // 60}시간 {overdue_min % 60}분\n"
|
||
f"담당자 변경: {sr.escalated_to}"
|
||
)
|
||
try:
|
||
await send_messenger(
|
||
room="ops",
|
||
text=msg,
|
||
title=f"[SLA 위반] {sr.sr_id}",
|
||
)
|
||
except Exception:
|
||
pass # 알림 실패는 에스컬레이션을 막지 않음
|
||
|
||
except Exception as exc:
|
||
logger.error("SLA 위반 점검 오류: %s", exc, exc_info=True)
|