guardia-itsm/routers/finops.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

558 lines
20 KiB
Python

"""
E-5: FinOps 비용 분석
기능:
1. 서버/인프라 운영 비용 등록 및 조회
2. 월별·분기별 비용 트렌드 분석
3. 서비스/부서별 비용 배분 (Cost Allocation)
4. 비용 이상 탐지 (전월 대비 임계치 초과)
5. 비용 절감 권고 (미사용·저활용 자원 감지)
6. 예산 대비 실적 (Budget vs Actual)
7. Ollama sLLM 기반 비용 최적화 코멘트 생성
엔드포인트:
POST /api/finops/costs — 비용 항목 등록
GET /api/finops/costs — 비용 목록 조회
GET /api/finops/summary — 월별 비용 요약
GET /api/finops/trend — 비용 트렌드 (N개월)
GET /api/finops/allocation — 서비스별 비용 배분
GET /api/finops/anomalies — 비용 이상 탐지
GET /api/finops/recommendations — 비용 절감 권고
POST /api/finops/budget — 예산 등록/수정
GET /api/finops/budget — 예산 대비 실적
GET /api/finops/optimize — AI 비용 최적화 분석
"""
from __future__ import annotations
import logging
from datetime import datetime, date, timedelta
from typing import Dict, List, Optional
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from core.auth import get_current_user
from database import get_db
from models import User, UserRole
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/finops", tags=["finops"])
# ── 인메모리 저장소 ─────────────────────────────────────────────────────────
_costs: Dict[str, Dict] = {} # cost_id -> cost record
_budgets: Dict[str, Dict] = {} # "YYYY-MM" -> budget record
# ── 비용 카테고리 ────────────────────────────────────────────────────────────
COST_CATEGORIES = {
"SERVER": "서버 운영비",
"NETWORK": "네트워크/통신비",
"STORAGE": "스토리지 비용",
"LICENSE": "소프트웨어 라이선스",
"MAINTENANCE": "유지보수비",
"PERSONNEL": "인건비",
"OTHER": "기타",
}
SERVICES = ["MES", "ERP", "GROUPWARE", "SECURITY", "INFRA", "BACKUP", "OTHER"]
# ── Pydantic 스키마 ──────────────────────────────────────────────────────────
class CostIn(BaseModel):
year: int
month: int # 1~12
category: str # COST_CATEGORIES key
service: str = "INFRA" # SERVICES
amount: float # 원화 (원)
description: str = ""
server_id: Optional[int] = None
department: str = "IT"
class BudgetIn(BaseModel):
year: int
month: int
amount: float
service: str = "ALL"
# ── 헬퍼 ────────────────────────────────────────────────────────────────────
def _gen_cost_id() -> str:
return f"COST-{datetime.utcnow().strftime('%Y%m%d')}-{uuid4().hex[:6].upper()}"
def _filter_costs(year: int, month: Optional[int] = None,
service: Optional[str] = None) -> List[Dict]:
result = []
for c in _costs.values():
if c["year"] != year:
continue
if month is not None and c["month"] != month:
continue
if service is not None and c["service"] != service:
continue
result.append(c)
return result
def _sum_costs(costs: List[Dict]) -> float:
return round(sum(c["amount"] for c in costs), 2)
# ── 엔드포인트 ───────────────────────────────────────────────────────────────
@router.post("/costs", status_code=201)
async def create_cost(
body: CostIn,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""비용 항목 등록 (PM/ADMIN)."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
if body.category not in COST_CATEGORIES:
raise HTTPException(400, f"유효하지 않은 카테고리: {body.category}. "
f"허용: {list(COST_CATEGORIES)}")
if not (1 <= body.month <= 12):
raise HTTPException(400, "월은 1~12 사이여야 합니다.")
if body.amount < 0:
raise HTTPException(400, "비용은 0 이상이어야 합니다.")
if body.service not in SERVICES:
raise HTTPException(400, f"유효하지 않은 서비스: {body.service}")
cost_id = _gen_cost_id()
record = {
"cost_id": cost_id,
"year": body.year,
"month": body.month,
"category": body.category,
"service": body.service,
"amount": body.amount,
"description": body.description,
"server_id": body.server_id,
"department": body.department,
"created_by": current_user.username,
"created_at": datetime.utcnow().isoformat(),
}
_costs[cost_id] = record
logger.info("비용 등록: %s %.0f원 by %s", cost_id, body.amount, current_user.username)
return record
@router.get("/costs")
async def list_costs(
year: int = Query(...),
month: Optional[int] = Query(None),
service: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""비용 목록 조회."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
items = _filter_costs(year, month, service)
return {"total": len(items), "total_amount": _sum_costs(items), "items": items}
@router.get("/summary")
async def cost_summary(
year: int = Query(...),
month: int = Query(...),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""월별 비용 요약 (카테고리·서비스별 집계)."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
if not (1 <= month <= 12):
raise HTTPException(400, "월은 1~12 사이여야 합니다.")
items = _filter_costs(year, month)
total = _sum_costs(items)
by_category: Dict[str, float] = {}
by_service: Dict[str, float] = {}
for c in items:
cat = c["category"]
svc = c["service"]
by_category[cat] = round(by_category.get(cat, 0) + c["amount"], 2)
by_service[svc] = round(by_service.get(svc, 0) + c["amount"], 2)
# 전월 비교
prev_month = month - 1 if month > 1 else 12
prev_year = year if month > 1 else year - 1
prev_items = _filter_costs(prev_year, prev_month)
prev_total = _sum_costs(prev_items)
mom_change = round((total - prev_total) / prev_total * 100, 1) if prev_total > 0 else 0.0
# 예산 대비
budget_key = f"{year}-{month:02d}"
budget_rec = _budgets.get(budget_key)
budget_amt = budget_rec["amount"] if budget_rec else None
budget_rate = round(total / budget_amt * 100, 1) if budget_amt else None
return {
"year": year,
"month": month,
"period": f"{year}{month}",
"total_amount": total,
"by_category": {
k: {"amount": v, "label": COST_CATEGORIES.get(k, k),
"pct": round(v / total * 100, 1) if total > 0 else 0}
for k, v in sorted(by_category.items(), key=lambda x: -x[1])
},
"by_service": {
k: {"amount": v, "pct": round(v / total * 100, 1) if total > 0 else 0}
for k, v in sorted(by_service.items(), key=lambda x: -x[1])
},
"mom_change_pct": mom_change,
"budget": {
"amount": budget_amt,
"usage_pct": budget_rate,
"status": (
"OVER" if budget_rate and budget_rate > 110 else
"WARNING" if budget_rate and budget_rate > 90 else
"OK"
) if budget_rate else "NO_BUDGET",
},
"item_count": len(items),
}
@router.get("/trend")
async def cost_trend(
months: int = Query(6, ge=1, le=24),
service: Optional[str] = Query(None),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""최근 N개월 비용 트렌드."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
now = datetime.utcnow()
trend = []
for i in range(months - 1, -1, -1):
# i개월 전
target_date = (now.replace(day=1) - timedelta(days=1) * (i * 30))
y, m = target_date.year, target_date.month
items = _filter_costs(y, m, service)
total = _sum_costs(items)
trend.append({
"year": y,
"month": m,
"period": f"{y}-{m:02d}",
"total_amount": total,
"item_count": len(items),
})
# 추세 계산 (첫 달 대비 마지막 달)
first_amt = trend[0]["total_amount"] if trend else 0
last_amt = trend[-1]["total_amount"] if trend else 0
trend_pct = round((last_amt - first_amt) / first_amt * 100, 1) if first_amt > 0 else 0.0
return {
"months": months,
"service": service or "ALL",
"data": trend,
"trend_pct": trend_pct,
"trend_dir": "UP" if trend_pct > 5 else "DOWN" if trend_pct < -5 else "STABLE",
}
@router.get("/allocation")
async def cost_allocation(
year: int = Query(...),
month: int = Query(...),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""서비스별 비용 배분 (Cost Allocation)."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
items = _filter_costs(year, month)
total = _sum_costs(items)
allocation: Dict[str, Dict] = {}
for svc in SERVICES:
svc_items = [c for c in items if c["service"] == svc]
svc_total = _sum_costs(svc_items)
svc_pct = round(svc_total / total * 100, 1) if total > 0 else 0.0
by_cat = {}
for c in svc_items:
cat = c["category"]
by_cat[cat] = round(by_cat.get(cat, 0) + c["amount"], 2)
allocation[svc] = {
"amount": svc_total,
"pct": svc_pct,
"item_count": len(svc_items),
"by_category": by_cat,
}
return {
"year": year,
"month": month,
"total": total,
"allocation": allocation,
}
@router.get("/anomalies")
async def cost_anomalies(
year: int = Query(...),
month: int = Query(...),
threshold: float = Query(30.0, ge=5.0, le=200.0), # % 초과시 이상
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""비용 이상 탐지 (전월 대비 임계치 초과 서비스 감지)."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
prev_month = month - 1 if month > 1 else 12
prev_year = year if month > 1 else year - 1
anomalies = []
for svc in SERVICES:
curr_items = _filter_costs(year, month, svc)
prev_items = _filter_costs(prev_year, prev_month, svc)
curr_amt = _sum_costs(curr_items)
prev_amt = _sum_costs(prev_items)
if prev_amt <= 0:
continue
change_pct = round((curr_amt - prev_amt) / prev_amt * 100, 1)
if abs(change_pct) >= threshold:
anomalies.append({
"service": svc,
"curr_amount": curr_amt,
"prev_amount": prev_amt,
"change_pct": change_pct,
"direction": "UP" if change_pct > 0 else "DOWN",
"severity": "CRITICAL" if abs(change_pct) >= 50 else "WARNING",
})
anomalies.sort(key=lambda x: abs(x["change_pct"]), reverse=True)
return {
"year": year,
"month": month,
"threshold_pct": threshold,
"anomaly_count": len(anomalies),
"anomalies": anomalies,
}
@router.get("/recommendations")
async def cost_recommendations(
year: int = Query(...),
month: int = Query(...),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""비용 절감 권고사항 생성."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
items = _filter_costs(year, month)
total = _sum_costs(items)
recs = []
# 단순 규칙 기반 권고
by_cat: Dict[str, float] = {}
for c in items:
cat = c["category"]
by_cat[cat] = round(by_cat.get(cat, 0) + c["amount"], 2)
if total > 0:
license_pct = by_cat.get("LICENSE", 0) / total * 100
if license_pct > 30:
recs.append({
"type": "LICENSE_REVIEW",
"severity": "HIGH",
"message": f"소프트웨어 라이선스 비용이 전체의 {license_pct:.1f}%입니다. "
"미사용 라이선스 해지를 검토하세요.",
"potential_saving_pct": 10,
})
maintenance_pct = by_cat.get("MAINTENANCE", 0) / total * 100
if maintenance_pct > 25:
recs.append({
"type": "MAINTENANCE_REVIEW",
"severity": "MEDIUM",
"message": f"유지보수 비용({maintenance_pct:.1f}%)이 높습니다. "
"계약 재협상 또는 자체 유지보수 전환을 검토하세요.",
"potential_saving_pct": 5,
})
# 전월 대비 급증 서비스 경고
prev_month = month - 1 if month > 1 else 12
prev_year = year if month > 1 else year - 1
for svc in SERVICES:
curr = _sum_costs(_filter_costs(year, month, svc))
prev = _sum_costs(_filter_costs(prev_year, prev_month, svc))
if prev > 0 and (curr - prev) / prev > 0.5:
recs.append({
"type": "COST_SPIKE",
"severity": "HIGH",
"message": f"{svc} 서비스 비용이 전월 대비 "
f"{(curr-prev)/prev*100:.0f}% 급증했습니다. 원인 조사가 필요합니다.",
"potential_saving_pct": 15,
})
if not recs:
recs.append({
"type": "NORMAL",
"severity": "INFO",
"message": "현재 비용 구조가 정상 범위입니다.",
"potential_saving_pct": 0,
})
total_potential = sum(r.get("potential_saving_pct", 0) for r in recs if r["type"] != "NORMAL")
return {
"year": year,
"month": month,
"total_amount": total,
"recommendation_count": len(recs),
"recommendations": recs,
"total_potential_saving_pct": total_potential,
}
@router.post("/budget", status_code=201)
async def set_budget(
body: BudgetIn,
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""예산 등록 또는 수정 (ADMIN)."""
if current_user.role != UserRole.ADMIN:
raise HTTPException(403, "ADMIN 권한이 필요합니다.")
if not (1 <= body.month <= 12):
raise HTTPException(400, "월은 1~12 사이여야 합니다.")
if body.amount < 0:
raise HTTPException(400, "예산은 0 이상이어야 합니다.")
key = f"{body.year}-{body.month:02d}"
_budgets[key] = {
"year": body.year,
"month": body.month,
"service": body.service,
"amount": body.amount,
"set_by": current_user.username,
"set_at": datetime.utcnow().isoformat(),
}
logger.info("예산 설정: %s %s %.0f", key, body.service, body.amount)
return {"message": f"{body.year}{body.month}월 예산이 설정되었습니다.",
"budget": _budgets[key]}
@router.get("/budget")
async def get_budget(
year: int = Query(...),
month: int = Query(...),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""예산 대비 실적 조회."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
key = f"{year}-{month:02d}"
budget_rec = _budgets.get(key)
items = _filter_costs(year, month)
actual = _sum_costs(items)
if not budget_rec:
return {
"year": year, "month": month,
"budget": None,
"actual": actual,
"status": "NO_BUDGET",
"message": "해당 월 예산이 설정되지 않았습니다.",
}
budget_amt = budget_rec["amount"]
variance = round(actual - budget_amt, 2)
usage_pct = round(actual / budget_amt * 100, 1) if budget_amt > 0 else 0.0
status = (
"OVER" if usage_pct > 110 else
"WARNING" if usage_pct > 90 else
"OK"
)
return {
"year": year,
"month": month,
"budget": budget_amt,
"actual": actual,
"variance": variance,
"usage_pct": usage_pct,
"status": status,
"remaining": round(budget_amt - actual, 2),
"item_count": len(items),
}
@router.get("/optimize")
async def ai_optimize(
year: int = Query(...),
month: int = Query(...),
current_user: User = Depends(get_current_user),
_db: AsyncSession = Depends(get_db),
):
"""Ollama sLLM 기반 비용 최적화 분석."""
if current_user.role not in (UserRole.ADMIN, UserRole.PM):
raise HTTPException(403, "PM/ADMIN 권한이 필요합니다.")
items = _filter_costs(year, month)
total = _sum_costs(items)
by_cat: Dict[str, float] = {}
for c in items:
cat = c["category"]
by_cat[cat] = round(by_cat.get(cat, 0) + c["amount"], 2)
# Ollama sLLM 최적화 분석 (내부 LLM only)
llm_analysis: Optional[str] = None
try:
import httpx
prompt = (
"다음 IT 인프라 비용 현황을 분석하여 비용 절감 방안을 한국어로 3-5가지 제시하세요.\n\n"
f"월: {year}{month}\n"
f"총 비용: {total:,.0f}\n"
+ "\n".join(f"{COST_CATEGORIES.get(k, k)}: {v:,.0f}" for k, v in by_cat.items())
)
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.post(
"http://localhost:11434/api/generate",
json={"model": "llama3", "prompt": prompt, "stream": False},
)
if resp.status_code == 200:
llm_analysis = resp.json().get("response", "").strip()
except Exception:
pass
# 폴백: 규칙 기반 분석
if not llm_analysis:
tips = [
"유휴 서버 자원을 정기적으로 점검하고 불필요한 서버는 폐기하세요.",
"소프트웨어 라이선스 사용 현황을 분기마다 감사하여 미사용 라이선스를 해지하세요.",
"유지보수 계약을 통합·재협상하여 규모의 경제를 활용하세요.",
"네트워크 대역폭 사용 패턴을 분석하여 과잉 회선을 축소하세요.",
"스토리지 티어링(hot/warm/cold)을 적용하여 보관 비용을 절감하세요.",
]
llm_analysis = "\n".join(f"{i+1}. {t}" for i, t in enumerate(tips))
return {
"year": year,
"month": month,
"total_amount": total,
"by_category": by_cat,
"optimization": llm_analysis,
"llm_used": "ollama/llama3",
"disclaimer": "AI 분석은 참고용입니다. 실제 절감 가능 여부는 현장 검토 후 판단하세요.",
}