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>
264 lines
9.4 KiB
Python
264 lines
9.4 KiB
Python
"""
|
|
GUARDiA ITSM — On-Call 자동 로테이션 & 에스컬레이션 (Enhancement A-5)
|
|
|
|
기능:
|
|
1. 매일 00:05 스케줄러 실행 → 다음날 당직 미배정 시 자동 배정
|
|
2. OncallRotateConfig 의 engineer_list 를 current_index 기준으로 순환
|
|
3. 배정 시 메신저/이메일 알림 발송
|
|
4. 인시던트/SR 에스컬레이션 체인 실행:
|
|
당직자(engineer) → backup_engineer → escalation_to → escalation_chain → ADMIN
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import date, datetime, timedelta
|
|
from typing import List, Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── 로테이션 설정 조회 헬퍼 ──────────────────────────────────────────────────
|
|
|
|
async def _get_rotate_config(db):
|
|
"""OncallRotateConfig 싱글톤 레코드 조회 (없으면 None)."""
|
|
from sqlalchemy import select
|
|
from models import OncallRotateConfig
|
|
|
|
result = await db.execute(
|
|
select(OncallRotateConfig).where(OncallRotateConfig.id == 1)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def get_or_create_rotate_config(db) -> "OncallRotateConfig":
|
|
"""
|
|
설정 레코드가 없으면 기본값으로 생성 후 반환.
|
|
routers/oncall.py 에서 설정 조회/수정 시 사용.
|
|
"""
|
|
from models import OncallRotateConfig
|
|
|
|
cfg = await _get_rotate_config(db)
|
|
if not cfg:
|
|
cfg = OncallRotateConfig(
|
|
id=1,
|
|
is_active=False,
|
|
engineer_list="[]",
|
|
current_index=0,
|
|
rotate_days=1,
|
|
default_shift="ALL_DAY",
|
|
notify_on_assign=True,
|
|
advance_days=1,
|
|
)
|
|
db.add(cfg)
|
|
await db.commit()
|
|
await db.refresh(cfg)
|
|
return cfg
|
|
|
|
|
|
# ── 오늘/내일 당직자 조회 ────────────────────────────────────────────────────
|
|
|
|
async def get_oncall_for_date(target_date: date, db) -> List["OncallSchedule"]:
|
|
"""특정 날짜의 OncallSchedule 목록 반환."""
|
|
from sqlalchemy import select
|
|
from models import OncallSchedule
|
|
|
|
result = await db.execute(
|
|
select(OncallSchedule)
|
|
.where(OncallSchedule.duty_date == target_date)
|
|
.order_by(OncallSchedule.shift)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def get_current_oncall(db) -> Optional["OncallSchedule"]:
|
|
"""오늘 당직자 (ALL_DAY 우선, 없으면 첫 번째 일정) 반환."""
|
|
schedules = await get_oncall_for_date(date.today(), db)
|
|
if not schedules:
|
|
return None
|
|
# ALL_DAY 우선
|
|
for s in schedules:
|
|
if s.shift == "ALL_DAY":
|
|
return s
|
|
return schedules[0]
|
|
|
|
|
|
# ── 자동 로테이션 배정 ───────────────────────────────────────────────────────
|
|
|
|
async def auto_rotate_oncall() -> None:
|
|
"""
|
|
스케줄러에서 매일 00:05 호출.
|
|
|
|
로직:
|
|
1. OncallRotateConfig.is_active == True 확인
|
|
2. (오늘 + advance_days) 날짜에 당직 미배정 확인
|
|
3. engineer_list[current_index] 를 당직자로 OncallSchedule 등록
|
|
4. current_index = (current_index + 1) % len(engineer_list) 업데이트
|
|
5. notify_on_assign == True 이면 메신저 알림 발송
|
|
"""
|
|
from database import SessionLocal
|
|
from models import OncallSchedule, OncallRotateConfig
|
|
from sqlalchemy import select
|
|
|
|
try:
|
|
async with SessionLocal() as db:
|
|
cfg = await _get_rotate_config(db)
|
|
|
|
if not cfg or not cfg.is_active:
|
|
logger.debug("On-Call 자동 로테이션 비활성화 상태 — 스킵")
|
|
return
|
|
|
|
engineers: List[str] = json.loads(cfg.engineer_list or "[]")
|
|
if not engineers:
|
|
logger.debug("On-Call 로테이션 엔지니어 목록 비어있음 — 스킵")
|
|
return
|
|
|
|
target_date = date.today() + timedelta(days=cfg.advance_days)
|
|
|
|
async with SessionLocal() as db:
|
|
# 해당 날짜 당직 존재 여부 확인
|
|
existing = (await db.execute(
|
|
select(OncallSchedule).where(
|
|
OncallSchedule.duty_date == target_date,
|
|
OncallSchedule.shift == cfg.default_shift,
|
|
)
|
|
)).scalars().first()
|
|
|
|
if existing:
|
|
logger.debug(
|
|
"On-Call 자동 로테이션 스킵: %s 날짜 당직 이미 등록됨 (engineer=%s)",
|
|
target_date.isoformat(), existing.engineer,
|
|
)
|
|
return
|
|
|
|
# 로테이션 배정
|
|
idx = cfg.current_index % len(engineers)
|
|
assigned_engineer = engineers[idx]
|
|
next_idx = (idx + 1) % len(engineers)
|
|
|
|
# 에스컬레이션 체인에서 backup / escalation 설정
|
|
chain: List[str] = json.loads(cfg.escalation_chain or "[]")
|
|
backup = chain[0] if chain else None
|
|
escalation = chain[1] if len(chain) > 1 else None
|
|
|
|
schedule = OncallSchedule(
|
|
duty_date = target_date,
|
|
shift = cfg.default_shift,
|
|
engineer = assigned_engineer,
|
|
backup_engineer = backup,
|
|
escalation_to = escalation,
|
|
note = f"[자동 배정] 로테이션 순번 {idx}",
|
|
created_by = "oncall-rotator",
|
|
)
|
|
db.add(schedule)
|
|
|
|
# current_index 업데이트
|
|
cfg_obj = await db.get(OncallRotateConfig, 1)
|
|
if cfg_obj:
|
|
cfg_obj.current_index = next_idx
|
|
cfg_obj.updated_at = datetime.now()
|
|
|
|
await db.commit()
|
|
await db.refresh(schedule)
|
|
|
|
logger.info(
|
|
"On-Call 자동 배정 완료: date=%s shift=%s engineer=%s (next_idx=%d)",
|
|
target_date.isoformat(), cfg.default_shift, assigned_engineer, next_idx,
|
|
)
|
|
|
|
# 알림 발송
|
|
if cfg.notify_on_assign:
|
|
await _notify_oncall_assigned(
|
|
target_date, cfg.default_shift, assigned_engineer, backup
|
|
)
|
|
|
|
except Exception as exc:
|
|
logger.error("On-Call 자동 로테이션 오류: %s", exc, exc_info=True)
|
|
|
|
|
|
# ── 에스컬레이션 체인 실행 ────────────────────────────────────────────────────
|
|
|
|
async def escalate_oncall(incident_title: str, db) -> Optional[str]:
|
|
"""
|
|
인시던트/장애 발생 시 당직자 → 백업 → escalation_to → ADMIN 순으로 에스컬레이션.
|
|
|
|
Returns:
|
|
에스컬레이션 대상 username 또는 None
|
|
"""
|
|
from models import User, UserRole
|
|
from sqlalchemy import select
|
|
|
|
schedule = await get_current_oncall(db)
|
|
|
|
if schedule:
|
|
# 1차: 당직자 본인
|
|
primary = schedule.engineer
|
|
|
|
# 2차: 백업 담당자
|
|
backup = schedule.backup_engineer
|
|
|
|
# 3차: 에스컬레이션 지정 담당자
|
|
escalation = schedule.escalation_to
|
|
else:
|
|
primary = backup = escalation = None
|
|
|
|
# 4차: 로테이션 설정의 escalation_chain
|
|
chain_fallback: List[str] = []
|
|
cfg = await _get_rotate_config(db)
|
|
if cfg and cfg.escalation_chain:
|
|
chain_fallback = json.loads(cfg.escalation_chain)
|
|
|
|
# 5차: ADMIN 사용자
|
|
admin_user = (await db.execute(
|
|
select(User).where(
|
|
User.role == UserRole.ADMIN,
|
|
User.is_active.is_(True),
|
|
).limit(1)
|
|
)).scalars().first()
|
|
admin = admin_user.username if admin_user else None
|
|
|
|
# 순서대로 첫 번째 유효한 대상 찾기
|
|
candidates = [primary, backup, escalation] + chain_fallback + ([admin] if admin else [])
|
|
target = next((c for c in candidates if c), None)
|
|
|
|
if target:
|
|
msg = (
|
|
f"🚨 On-Call 에스컬레이션\n"
|
|
f"장애/인시던트: {incident_title}\n"
|
|
f"에스컬레이션 대상: {target}\n"
|
|
f"일시: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
|
|
)
|
|
try:
|
|
from core.notify import send_messenger
|
|
await send_messenger(room="ops", text=msg, title=f"[온콜 에스컬레이션] {incident_title}")
|
|
except Exception as e:
|
|
logger.warning("On-Call 에스컬레이션 알림 실패: %s", e)
|
|
|
|
return target
|
|
|
|
|
|
# ── 내부 알림 헬퍼 ────────────────────────────────────────────────────────────
|
|
|
|
async def _notify_oncall_assigned(
|
|
duty_date: date,
|
|
shift: str,
|
|
engineer: str,
|
|
backup: Optional[str],
|
|
) -> None:
|
|
"""당직 자동 배정 알림 발송."""
|
|
try:
|
|
from core.notify import send_messenger
|
|
msg = (
|
|
f"📋 On-Call 자동 배정 알림\n"
|
|
f"날짜: {duty_date.isoformat()}\n"
|
|
f"시프트: {shift}\n"
|
|
f"당직자: {engineer}\n"
|
|
f"백업: {backup or '미지정'}"
|
|
)
|
|
await send_messenger(
|
|
room="ops",
|
|
text=msg,
|
|
title=f"[온콜 배정] {duty_date.isoformat()} {engineer}",
|
|
)
|
|
except Exception as exc:
|
|
logger.warning("On-Call 배정 알림 실패 (무시): %s", exc)
|