guardia-itsm/core/cache.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

287 lines
10 KiB
Python

"""
GUARDiA ITSM — 캐시 레이어 (Enhancement F-2)
Redis 우선, 미설치/연결 실패 시 인메모리 LRU 캐시로 자동 폴백.
기능:
1. 비동기 get/set/delete/invalidate
2. 접두어 기반 패턴 삭제 (invalidate_prefix)
3. 함수 결과 캐싱 데코레이터 (cached)
4. 캐시 통계 (히트/미스/에러 카운터)
5. 상태 조회 엔드포인트용 헬퍼
주요 캐시 키:
itsm:tasks:list:{hash} — SR 목록 (30초)
itsm:stats:summary — 대시보드 통계 (60초)
itsm:server:list — 서버 목록 (120초)
itsm:oncall:today — 오늘 당직자 (300초)
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import os
import time
from collections import OrderedDict
from functools import wraps
from typing import Any, Callable, Optional
logger = logging.getLogger(__name__)
# ── 설정 ─────────────────────────────────────────────────────────────────────
REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0")
CACHE_ENABLED = os.environ.get("CACHE_ENABLED", "true").lower() == "true"
CACHE_KEY_PREFIX = "itsm:"
_DEFAULT_TTL_SEC = 60
_MEMORY_MAX_ITEMS = 1000
# ── 통계 카운터 ───────────────────────────────────────────────────────────────
_stats = {"hits": 0, "misses": 0, "errors": 0, "sets": 0}
# ═══════════════════════════════════════════════════════════════════════════════
# ── 인메모리 LRU 캐시 (Redis 폴백) ─────────────────────────────────────────────
# ═══════════════════════════════════════════════════════════════════════════════
class _MemoryCache:
"""TTL 지원 인메모리 LRU 캐시."""
def __init__(self, maxsize: int = _MEMORY_MAX_ITEMS):
self._store: OrderedDict[str, tuple[Any, float]] = OrderedDict()
self._maxsize = maxsize
def get(self, key: str) -> Optional[Any]:
if key not in self._store:
return None
value, expires_at = self._store[key]
if time.monotonic() > expires_at:
del self._store[key]
return None
self._store.move_to_end(key)
return value
def set(self, key: str, value: Any, ttl: int = _DEFAULT_TTL_SEC) -> None:
if key in self._store:
self._store.move_to_end(key)
self._store[key] = (value, time.monotonic() + ttl)
if len(self._store) > self._maxsize:
self._store.popitem(last=False)
def delete(self, key: str) -> None:
self._store.pop(key, None)
def keys_with_prefix(self, prefix: str) -> list[str]:
return [k for k in self._store if k.startswith(prefix)]
def flush(self) -> int:
n = len(self._store)
self._store.clear()
return n
def info(self) -> dict:
now = time.monotonic()
live = sum(1 for _, (_, exp) in self._store.items() if exp > now)
return {"backend": "memory", "items_total": len(self._store), "items_live": live}
_mem_cache = _MemoryCache()
_redis_client: Optional[Any] = None # aioredis / redis.asyncio 클라이언트
# ── Redis 연결 ────────────────────────────────────────────────────────────────
async def _get_redis():
"""Redis 클라이언트 싱글톤 (연결 실패 시 None)."""
global _redis_client
if _redis_client is not None:
return _redis_client
try:
import redis.asyncio as aioredis
client = aioredis.from_url(REDIS_URL, decode_responses=True, socket_timeout=1.0)
await client.ping()
_redis_client = client
logger.info("Redis 캐시 연결 성공: %s", REDIS_URL)
return _redis_client
except Exception as exc:
logger.info("Redis 미연결, 인메모리 캐시 사용: %s", exc)
return None
async def close_redis() -> None:
"""앱 종료 시 Redis 연결 해제."""
global _redis_client
if _redis_client:
await _redis_client.aclose()
_redis_client = None
# ═══════════════════════════════════════════════════════════════════════════════
# ── 공개 API ──────────────────────────────────────────────────────────────────
# ═══════════════════════════════════════════════════════════════════════════════
def _full_key(key: str) -> str:
return f"{CACHE_KEY_PREFIX}{key}"
async def cache_get(key: str) -> Optional[Any]:
"""캐시에서 값 조회. 없으면 None."""
if not CACHE_ENABLED:
return None
full_key = _full_key(key)
try:
redis = await _get_redis()
if redis:
raw = await redis.get(full_key)
if raw is None:
_stats["misses"] += 1
return None
_stats["hits"] += 1
return json.loads(raw)
else:
val = _mem_cache.get(full_key)
if val is None:
_stats["misses"] += 1
else:
_stats["hits"] += 1
return val
except Exception as exc:
_stats["errors"] += 1
logger.debug("cache_get 오류: key=%s err=%s", key, exc)
return None
async def cache_set(key: str, value: Any, ttl: int = _DEFAULT_TTL_SEC) -> bool:
"""캐시에 값 저장."""
if not CACHE_ENABLED:
return False
full_key = _full_key(key)
try:
redis = await _get_redis()
if redis:
await redis.setex(full_key, ttl, json.dumps(value, default=str))
else:
_mem_cache.set(full_key, value, ttl)
_stats["sets"] += 1
return True
except Exception as exc:
_stats["errors"] += 1
logger.debug("cache_set 오류: key=%s err=%s", key, exc)
return False
async def cache_delete(key: str) -> bool:
"""캐시에서 특정 키 삭제."""
if not CACHE_ENABLED:
return True
full_key = _full_key(key)
try:
redis = await _get_redis()
if redis:
await redis.delete(full_key)
else:
_mem_cache.delete(full_key)
return True
except Exception as exc:
_stats["errors"] += 1
logger.debug("cache_delete 오류: key=%s err=%s", key, exc)
return False
async def cache_invalidate_prefix(prefix: str) -> int:
"""접두어로 시작하는 모든 캐시 키 삭제."""
if not CACHE_ENABLED:
return 0
full_prefix = _full_key(prefix)
count = 0
try:
redis = await _get_redis()
if redis:
keys = await redis.keys(f"{full_prefix}*")
if keys:
count = await redis.delete(*keys)
else:
keys = _mem_cache.keys_with_prefix(full_prefix)
for k in keys:
_mem_cache.delete(k)
count = len(keys)
except Exception as exc:
_stats["errors"] += 1
logger.debug("cache_invalidate_prefix 오류: prefix=%s err=%s", prefix, exc)
return count
def make_cache_key(*args, **kwargs) -> str:
"""인자 목록으로 캐시 키 생성 (MD5 해시)."""
raw = json.dumps({"args": list(args), "kwargs": kwargs}, sort_keys=True, default=str)
return hashlib.md5(raw.encode()).hexdigest()[:16]
# ── 캐시 데코레이터 ───────────────────────────────────────────────────────────
def cached(key_prefix: str, ttl: int = _DEFAULT_TTL_SEC):
"""
비동기 함수 결과 캐싱 데코레이터.
사용 예::
@cached("tasks:list", ttl=30)
async def get_tasks(...):
...
"""
def decorator(func: Callable):
@wraps(func)
async def wrapper(*args, **kwargs):
cache_key = f"{key_prefix}:{make_cache_key(*args, **kwargs)}"
cached_val = await cache_get(cache_key)
if cached_val is not None:
return cached_val
result = await func(*args, **kwargs)
await cache_set(cache_key, result, ttl)
return result
return wrapper
return decorator
# ── 캐시 상태 조회 ────────────────────────────────────────────────────────────
async def cache_info() -> dict:
"""캐시 상태 정보 반환 (관리자 대시보드용)."""
try:
redis = await _get_redis()
if redis:
info = await redis.info("stats")
backend_info = {
"backend": "redis",
"url": REDIS_URL.split("@")[-1], # 비밀번호 마스킹
"keyspace_hits": info.get("keyspace_hits", 0),
"keyspace_misses": info.get("keyspace_misses", 0),
}
else:
backend_info = _mem_cache.info()
except Exception:
backend_info = {"backend": "memory (redis unavailable)", **_mem_cache.info()}
return {
"enabled": CACHE_ENABLED,
"backend": backend_info,
"app_stats": dict(_stats),
}
async def cache_flush_all() -> int:
"""전체 캐시 초기화 (테스트/관리자 전용)."""
try:
redis = await _get_redis()
if redis:
keys = await redis.keys(f"{CACHE_KEY_PREFIX}*")
if keys:
return await redis.delete(*keys)
return 0
else:
return _mem_cache.flush()
except Exception as exc:
logger.warning("cache_flush_all 오류: %s", exc)
return 0