""" iDRAC Job Monitoring Service (Redfish 버전) 기존 Flask 앱의 backend/services/ 디렉토리에 추가하세요. 기존 idrac_jobs.py를 이 파일로 교체하거나 redfish_jobs.py로 저장하세요. """ import logging logging.basicConfig(level=logging.DEBUG) import ipaddress import time import logging import os from datetime import datetime, timezone from typing import List, Dict, Any, Optional, Tuple from collections import OrderedDict from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path from .redfish_client import RedfishClient, AuthenticationError, NotSupportedError logger = logging.getLogger(__name__) # ──────────────────────────────────────────────────────────── # 설정 (환경변수 또는 기본값) # ──────────────────────────────────────────────────────────── IDRAC_USER = os.getenv("IDRAC_USER", "root") IDRAC_PASS = os.getenv("IDRAC_PASS", "calvin") MAX_WORKERS = int(os.getenv("MAX_WORKERS", "32")) REDFISH_TIMEOUT = int(os.getenv("REDFISH_TIMEOUT", "15")) VERIFY_SSL = os.getenv("VERIFY_SSL", "False").lower() == "true" IP_LIST_PATH = os.getenv("IDRAC_IP_LIST", "data/server_list/idrac_ip_list.txt") # ──────────────────────────────────────────────────────────── # IP 유효성 검증 # ──────────────────────────────────────────────────────────── def validate_ip(ip: str) -> bool: """IP 주소 유효성 검증""" try: ipaddress.ip_address(ip.strip()) return True except ValueError: return False def parse_ip_list(text: str) -> List[str]: """텍스트에서 IP 목록 파싱""" if not text: return [] raw = text.replace(",", "\n").replace(";", "\n") ips = [] seen = set() for line in raw.splitlines(): line = line.strip() if not line or line.startswith("#"): continue for part in line.split(): part = part.strip() if not part or part.startswith("#"): continue if validate_ip(part) and part not in seen: seen.add(part) ips.append(part) elif not validate_ip(part): logger.warning(f"Invalid IP address: {part}") return ips def load_ip_list(path: str = IP_LIST_PATH) -> List[str]: """파일에서 IP 목록 로드""" try: file_path = Path(path) if not file_path.exists(): logger.warning(f"IP list file not found: {path}") return [] with open(file_path, "r", encoding="utf-8") as f: content = f.read() ips = parse_ip_list(content) logger.info(f"Loaded {len(ips)} IPs from {path}") return ips except Exception as e: logger.error(f"Failed to load IP list from {path}: {e}") return [] # ──────────────────────────────────────────────────────────── # Job 상태 판별 # ──────────────────────────────────────────────────────────── ACTIVE_KEYWORDS = ( "running", "scheduled", "progress", "starting", "queued", "pending", "preparing", "applying" ) DONE_KEYWORDS = ( "completed", "success", "succeeded", "failed", "error", "aborted", "canceled", "cancelled" ) def is_active_status(status: Optional[str], message: Optional[str] = None) -> bool: """Job이 활성 상태인지 확인""" s = (status or "").strip().lower() m = (message or "").strip().lower() return any(k in s for k in ACTIVE_KEYWORDS) or any(k in m for k in ACTIVE_KEYWORDS) def is_done_status(status: Optional[str]) -> bool: """Job이 완료 상태인지 확인""" s = (status or "").strip().lower() return any(k in s for k in DONE_KEYWORDS) # ──────────────────────────────────────────────────────────── # 날짜/시간 파싱 # ──────────────────────────────────────────────────────────── def parse_iso_datetime(dt_str: Optional[str]) -> Optional[float]: """ISO 8601 날짜 문자열을 timestamp로 변환""" if not dt_str: return None try: dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00")) return dt.timestamp() except Exception as e: logger.debug(f"Failed to parse datetime '{dt_str}': {e}") return None def iso_now() -> str: """현재 시간을 ISO 8601 포맷으로 반환""" return datetime.now(timezone.utc).isoformat() # ──────────────────────────────────────────────────────────── # LRU 캐시 # ──────────────────────────────────────────────────────────── class LRUJobCache: """Job 캐시 (LRU 방식)""" def __init__(self, max_size: int = 10000): self.cache: OrderedDict[Tuple[str, str], Dict[str, Any]] = OrderedDict() self.max_size = max_size self.last_gc = time.time() def _make_key(self, ip: str, job: Dict[str, Any]) -> Tuple[str, str]: """캐시 키 생성""" jid = (job.get("JID") or "").strip() if jid: return (ip, jid) name = (job.get("Name") or "").strip() return (ip, f"NOJID::{name}") def get(self, key: Tuple[str, str]) -> Optional[Dict[str, Any]]: """캐시에서 조회""" if key in self.cache: self.cache.move_to_end(key) return self.cache[key] return None def set(self, key: Tuple[str, str], value: Dict[str, Any]): """캐시에 저장""" if key in self.cache: self.cache.move_to_end(key) self.cache[key] = value if len(self.cache) > self.max_size: self.cache.popitem(last=False) def keys(self) -> List[Tuple[str, str]]: """모든 키 반환""" return list(self.cache.keys()) def pop(self, key: Tuple[str, str], default=None): """캐시에서 제거""" return self.cache.pop(key, default) def clear_for_ips(self, current_ips: set): """현재 IP 목록에 없는 항목 제거""" removed = 0 for key in list(self.cache.keys()): if key[0] not in current_ips: self.cache.pop(key) removed += 1 if removed > 0: logger.info(f"Cleared {removed} cache entries for removed IPs") def gc(self, max_age_seconds: float): """오래된 캐시 항목 제거""" now = time.time() cutoff = now - max_age_seconds removed = 0 for key in list(self.cache.keys()): entry = self.cache[key] if entry.get("last_seen", 0) < cutoff: self.cache.pop(key) removed += 1 if removed > 0: logger.info(f"Cache GC: removed {removed} entries") self.last_gc = now # ──────────────────────────────────────────────────────────── # Job 스캐너 # ──────────────────────────────────────────────────────────── def scan_single_ip(ip: str) -> Dict[str, Any]: """ 단일 IP에서 Job 조회 Returns: { "ip": str, "ok": bool, "error": str (실패 시), "jobs": List[Dict] } """ if not validate_ip(ip): return { "ip": ip, "ok": False, "error": "Invalid IP address", "jobs": [] } try: with RedfishClient(ip, IDRAC_USER, IDRAC_PASS, REDFISH_TIMEOUT, VERIFY_SSL) as client: jobs = client.get_jobs() return { "ip": ip, "ok": True, "jobs": jobs } except AuthenticationError: return { "ip": ip, "ok": False, "error": "Authentication failed", "jobs": [] } except NotSupportedError: return { "ip": ip, "ok": False, "error": "Redfish API not supported (old iDRAC?)", "jobs": [] } except TimeoutError as e: return { "ip": ip, "ok": False, "error": f"Timeout: {str(e)}", "jobs": [] } except ConnectionError as e: return { "ip": ip, "ok": False, "error": f"Connection failed: {str(e)}", "jobs": [] } except Exception as e: logger.exception(f"Unexpected error for {ip}") return { "ip": ip, "ok": False, "error": f"Unexpected error: {str(e)[:100]}", "jobs": [] } def scan_all(ips: List[str], method: str = "redfish", max_workers: int = MAX_WORKERS) -> List[Dict[str, Any]]: """ 여러 IP를 병렬로 스캔 Args: ips: IP 목록 method: "redfish" (racadm은 하위 호환용) max_workers: 병렬 워커 수 Returns: IP별 결과 리스트 (정렬됨) """ if not ips: return [] logger.info(f"Scanning {len(ips)} IPs with {max_workers} workers (method: {method})") start_time = time.time() results = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: futures = { executor.submit(scan_single_ip, ip): ip for ip in ips } for future in as_completed(futures): results.append(future.result()) elapsed = time.time() - start_time logger.info(f"Scan completed in {elapsed:.2f}s") return sorted(results, key=lambda x: x["ip"])