Initial project upload

This commit is contained in:
Kim.KANGHEE
2026-05-05 17:14:11 +09:00
commit 122b73d254
282 changed files with 72135 additions and 0 deletions
@@ -0,0 +1,353 @@
"""
Dell 펌웨어 카탈로그 대안 방법들
backend/services/dell_catalog_alternatives.py
Dell의 공식 Catalog.xml이 차단된 경우 사용할 수 있는 대안들
"""
import requests
from typing import List, Dict, Optional
from backend.models.firmware_version import FirmwareVersion, db
from datetime import datetime
class DellFirmwareCatalogAlternatives:
"""Dell 펌웨어 정보를 가져오는 대안 방법들"""
def __init__(self):
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
# ========================================
# 방법 1: Dell Support API (공식 API)
# ========================================
def fetch_from_dell_support_api(self, model: str = "PowerEdge R750") -> List[Dict]:
"""
Dell Support API를 통한 펌웨어 정보 조회
Dell의 공식 Support API 엔드포인트:
https://www.dell.com/support/home/api/
참고: API 키가 필요할 수 있음
"""
try:
# Dell Support API 엔드포인트 (예시)
# 실제 API는 Dell 개발자 포털에서 확인 필요
url = f"https://www.dell.com/support/home/api/products/{model}/drivers"
response = self.session.get(url, timeout=30)
if response.status_code == 200:
data = response.json()
return self._parse_support_api_response(data, model)
else:
print(f"Dell Support API 오류: {response.status_code}")
return []
except Exception as e:
print(f"Dell Support API 조회 실패: {str(e)}")
return []
# ========================================
# 방법 2: Dell TechDirect (파트너 전용)
# ========================================
def fetch_from_techdirect(self, model: str = "PowerEdge R750") -> List[Dict]:
"""
Dell TechDirect API를 통한 펌웨어 정보 조회
TechDirect는 Dell 파트너용 플랫폼
API 키 필요: https://techdirect.dell.com/
"""
# TechDirect API 구현
# 실제 사용 시 API 키 필요
pass
# ========================================
# 방법 3: 로컬 카탈로그 파일 사용
# ========================================
def load_from_local_catalog(self, catalog_path: str) -> List[Dict]:
"""
로컬에 저장된 Catalog.xml 파일 사용
사용법:
1. Dell 공식 사이트에서 수동으로 Catalog.xml 다운로드
2. 로컬에 저장
3. 이 함수로 파싱
Args:
catalog_path: 로컬 Catalog.xml 파일 경로
"""
try:
from xml.etree import ElementTree as ET
with open(catalog_path, 'r', encoding='utf-8') as f:
content = f.read()
root = ET.fromstring(content)
return self._parse_catalog_xml(root)
except Exception as e:
print(f"로컬 카탈로그 파일 로드 실패: {str(e)}")
return []
# ========================================
# 방법 4: iDRAC에서 직접 조회
# ========================================
def fetch_from_idrac(self, idrac_ip: str, username: str, password: str) -> List[Dict]:
"""
iDRAC Redfish API를 통해 사용 가능한 업데이트 조회
iDRAC는 Dell Update Service를 통해 사용 가능한 업데이트를 확인할 수 있음
장점:
- 실제 서버에 맞는 정확한 펌웨어 정보
- 외부 카탈로그 불필요
단점:
- 각 서버마다 조회 필요
- 네트워크 연결 필요
"""
try:
from backend.services.idrac_redfish_client import DellRedfishClient
client = DellRedfishClient(idrac_ip, username, password)
# UpdateService에서 사용 가능한 업데이트 조회
url = f"https://{idrac_ip}/redfish/v1/UpdateService"
response = client.session.get(url, timeout=30)
if response.status_code == 200:
data = response.json()
# UpdateService의 Actions 확인
# SimpleUpdate 또는 CheckForUpdate 액션 사용
return self._parse_idrac_updates(data)
return []
except Exception as e:
print(f"iDRAC 업데이트 조회 실패: {str(e)}")
return []
# ========================================
# 방법 5: 수동 입력 (가장 간단하고 확실)
# ========================================
def create_manual_catalog(self) -> List[Dict]:
"""
수동으로 작성한 펌웨어 버전 정보
Dell 공식 사이트에서 확인한 최신 버전을 수동으로 입력
https://www.dell.com/support/
장점:
- 가장 확실하고 안정적
- 외부 의존성 없음
- 검증된 버전만 사용
단점:
- 수동 업데이트 필요
"""
manual_catalog = [
{
'component_name': 'BIOS',
'latest_version': '2.15.2',
'server_model': 'PowerEdge R750',
'vendor': 'Dell',
'release_date': '2024-03-15',
'download_url': 'https://www.dell.com/support/home/drivers/driversdetails?driverid=...',
'notes': 'PowerEdge R750 BIOS - 2024년 3월 릴리즈',
'is_critical': False
},
{
'component_name': 'iDRAC',
'latest_version': '7.00.00.00',
'server_model': None, # 모든 모델
'vendor': 'Dell',
'release_date': '2024-02-20',
'download_url': 'https://www.dell.com/support/home/drivers/driversdetails?driverid=...',
'notes': 'iDRAC9 최신 펌웨어 (14G/15G/16G 공용)',
'is_critical': True
},
{
'component_name': 'PERC H755',
'latest_version': '25.5.9.0001',
'server_model': 'PowerEdge R750',
'vendor': 'Dell',
'release_date': '2024-01-10',
'notes': 'PERC H755 RAID 컨트롤러',
'is_critical': False
},
# 더 많은 컴포넌트 추가...
]
return manual_catalog
# ========================================
# 방법 6: Dell Repository Manager (DRM)
# ========================================
def fetch_from_drm_export(self, drm_export_path: str) -> List[Dict]:
"""
Dell Repository Manager (DRM)에서 내보낸 데이터 사용
DRM은 Dell의 공식 펌웨어 관리 도구
https://www.dell.com/support/kbdoc/en-us/000177083/
사용법:
1. DRM 설치 및 실행
2. 필요한 서버 모델 선택
3. 카탈로그 내보내기
4. 내보낸 파일을 이 함수로 파싱
"""
try:
# DRM 내보내기 파일 파싱 로직
# XML 또는 CSV 형식일 수 있음
pass
except Exception as e:
print(f"DRM 내보내기 파일 로드 실패: {str(e)}")
return []
# ========================================
# 헬퍼 함수들
# ========================================
def _parse_support_api_response(self, data: Dict, model: str) -> List[Dict]:
"""Dell Support API 응답 파싱"""
# API 응답 구조에 따라 구현
return []
def _parse_catalog_xml(self, root) -> List[Dict]:
"""Catalog.xml 파싱"""
from xml.etree import ElementTree as ET
firmware_list = []
for pkg in root.findall(".//SoftwareComponent"):
name = pkg.findtext("Name")
version = pkg.findtext("Version")
release_date = pkg.findtext("ReleaseDate")
path = pkg.findtext("path")
if name and version:
firmware_list.append({
'component_name': name,
'latest_version': version,
'release_date': release_date,
'download_url': f"https://downloads.dell.com/{path}" if path else None,
'vendor': 'Dell'
})
return firmware_list
def _parse_idrac_updates(self, data: Dict) -> List[Dict]:
"""iDRAC UpdateService 응답 파싱"""
# UpdateService 데이터 파싱
return []
# ========================================
# DB에 저장
# ========================================
def save_to_database(self, firmware_list: List[Dict]) -> int:
"""
펌웨어 목록을 데이터베이스에 저장
Returns:
저장된 항목 수
"""
count = 0
for fw in firmware_list:
# 중복 확인
existing = FirmwareVersion.query.filter_by(
component_name=fw.get('component_name'),
latest_version=fw.get('latest_version'),
server_model=fw.get('server_model')
).first()
if not existing:
version = FirmwareVersion(
component_name=fw.get('component_name'),
latest_version=fw.get('latest_version'),
server_model=fw.get('server_model'),
vendor=fw.get('vendor', 'Dell'),
release_date=fw.get('release_date'),
download_url=fw.get('download_url'),
notes=fw.get('notes'),
is_critical=fw.get('is_critical', False)
)
db.session.add(version)
count += 1
db.session.commit()
return count
# ========================================
# 사용 예시
# ========================================
def sync_firmware_alternative(method: str = 'manual', **kwargs) -> Dict:
"""
대안 방법으로 펌웨어 정보 동기화
Args:
method: 'manual', 'local_catalog', 'idrac', 'support_api' 중 선택
**kwargs: 각 방법에 필요한 추가 인자
Returns:
{'success': bool, 'count': int, 'message': str}
"""
catalog = DellFirmwareCatalogAlternatives()
firmware_list = []
try:
if method == 'manual':
# 가장 권장: 수동으로 작성한 카탈로그
firmware_list = catalog.create_manual_catalog()
elif method == 'local_catalog':
# 로컬 Catalog.xml 파일 사용
catalog_path = kwargs.get('catalog_path')
if not catalog_path:
return {'success': False, 'count': 0, 'message': 'catalog_path 필요'}
firmware_list = catalog.load_from_local_catalog(catalog_path)
elif method == 'idrac':
# iDRAC에서 직접 조회
idrac_ip = kwargs.get('idrac_ip')
username = kwargs.get('username')
password = kwargs.get('password')
if not all([idrac_ip, username, password]):
return {'success': False, 'count': 0, 'message': 'iDRAC 정보 필요'}
firmware_list = catalog.fetch_from_idrac(idrac_ip, username, password)
elif method == 'support_api':
# Dell Support API 사용
model = kwargs.get('model', 'PowerEdge R750')
firmware_list = catalog.fetch_from_dell_support_api(model)
else:
return {'success': False, 'count': 0, 'message': f'알 수 없는 방법: {method}'}
# DB에 저장
count = catalog.save_to_database(firmware_list)
return {
'success': True,
'count': count,
'message': f'{count}개 펌웨어 정보 동기화 완료'
}
except Exception as e:
return {
'success': False,
'count': 0,
'message': f'오류: {str(e)}'
}
+178
View File
@@ -0,0 +1,178 @@
import requests
from xml.etree import ElementTree as ET
from backend.models.firmware_version import FirmwareVersion, db
from datetime import datetime
def get_manual_firmware_catalog(model="PowerEdge R750"):
"""
수동으로 작성한 펌웨어 카탈로그
Dell 공식 사이트에서 확인한 최신 버전 정보
https://www.dell.com/support/
Dell Catalog.xml이 차단된 경우 이 데이터 사용
"""
# 2024년 11월 기준 최신 버전 (정기적으로 업데이트 필요)
catalog = {
"PowerEdge R750": [
{
'component_name': 'BIOS',
'latest_version': '2.15.2',
'release_date': '2024-03-15',
'notes': 'PowerEdge R750 BIOS - 보안 업데이트 포함',
'is_critical': False
},
{
'component_name': 'iDRAC',
'latest_version': '7.00.00.00',
'release_date': '2024-02-20',
'notes': 'iDRAC9 최신 펌웨어',
'is_critical': True
},
{
'component_name': 'PERC H755',
'latest_version': '25.5.9.0001',
'release_date': '2024-01-10',
'notes': 'PERC H755 RAID 컨트롤러',
'is_critical': False
},
{
'component_name': 'CPLD',
'latest_version': '1.0.6',
'release_date': '2023-12-15',
'notes': '시스템 보드 CPLD',
'is_critical': False
},
],
"PowerEdge R640": [
{
'component_name': 'BIOS',
'latest_version': '2.19.2',
'release_date': '2024-02-01',
'notes': 'PowerEdge R640 BIOS',
'is_critical': False
},
{
'component_name': 'iDRAC',
'latest_version': '7.00.00.00',
'release_date': '2024-02-20',
'notes': 'iDRAC9 최신 펌웨어',
'is_critical': True
},
]
}
return catalog.get(model, [])
def sync_dell_catalog(model="PowerEdge R750"):
"""
Dell 펌웨어 정보 동기화
1차: Dell 공식 Catalog.xml 시도
2차: 수동 카탈로그 사용 (Fallback)
"""
count = 0
# 1차 시도: Dell 공식 Catalog.xml
try:
url = "https://downloads.dell.com/catalog/Catalog.xml"
print(f"[INFO] Dell Catalog 다운로드 시도... ({url})")
response = requests.get(url, timeout=30)
response.raise_for_status()
root = ET.fromstring(response.content)
for pkg in root.findall(".//SoftwareComponent"):
# 이 컴포넌트가 지정된 모델에 해당하는지 확인
supported = [
sys.text.strip()
for sys in pkg.findall(".//SupportedSystems/Brand/Model/Display")
if sys.text
]
if model not in supported:
continue
name = pkg.findtext("Name")
version = pkg.findtext("Version")
release_date = pkg.findtext("ReleaseDate")
path = pkg.findtext("path")
vendor = "Dell"
if not name or not version:
continue
# 중복 방지
existing = FirmwareVersion.query.filter_by(
component_name=name,
latest_version=version,
server_model=model
).first()
if not existing:
db.session.add(
FirmwareVersion(
component_name=name,
latest_version=version,
release_date=release_date,
vendor=vendor,
server_model=model,
download_url=f"https://downloads.dell.com/{path}",
)
)
count += 1
db.session.commit()
print(f"{model} 관련 펌웨어 {count}개 동기화 완료 (Dell Catalog)")
return count
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
print(f"[경고] Dell Catalog.xml 접근 불가 (404). 수동 카탈로그 사용...")
else:
print(f"[경고] Dell Catalog 다운로드 실패: {e}. 수동 카탈로그 사용...")
except Exception as e:
print(f"[경고] Dell Catalog 처리 중 오류: {e}. 수동 카탈로그 사용...")
# 2차 시도: 수동 카탈로그 사용
try:
print(f"[INFO] 수동 카탈로그에서 {model} 펌웨어 정보 로드 중...")
manual_catalog = get_manual_firmware_catalog(model)
if not manual_catalog:
print(f"[경고] {model}에 대한 수동 카탈로그 데이터가 없습니다")
return 0
for fw in manual_catalog:
# 중복 방지
existing = FirmwareVersion.query.filter_by(
component_name=fw['component_name'],
latest_version=fw['latest_version'],
server_model=model
).first()
if not existing:
db.session.add(
FirmwareVersion(
component_name=fw['component_name'],
latest_version=fw['latest_version'],
release_date=fw.get('release_date'),
vendor='Dell',
server_model=model,
notes=fw.get('notes'),
is_critical=fw.get('is_critical', False)
)
)
count += 1
db.session.commit()
print(f"{model} 관련 펌웨어 {count}개 동기화 완료 (수동 카탈로그)")
return count
except Exception as e:
print(f"[오류] 수동 카탈로그 처리 실패: {e}")
db.session.rollback()
return 0
+308
View File
@@ -0,0 +1,308 @@
"""
Dell Repository Manager (DRM) 연동 모듈
backend/services/drm_catalog_sync.py
Dell Repository Manager에서 생성한 로컬 리포지토리 카탈로그를 파싱하여
펌웨어 정보를 가져오는 기능
"""
import os
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import List, Dict, Optional
from backend.models.firmware_version import FirmwareVersion, db
from datetime import datetime
class DRMCatalogSync:
"""Dell Repository Manager 카탈로그 동기화"""
def __init__(self, drm_repository_path: str = None):
"""
Args:
drm_repository_path: DRM 리포지토리 경로
예: C:/Dell/Repository 또는 네트워크 경로
"""
self.repository_path = drm_repository_path
self.catalog_file = None
if drm_repository_path:
# DRM은 보통 catalog 폴더에 XML 파일 생성
possible_catalogs = [
os.path.join(drm_repository_path, 'catalog', 'Catalog.xml'),
os.path.join(drm_repository_path, 'Catalog.xml'),
os.path.join(drm_repository_path, 'catalog.xml'),
]
for catalog_path in possible_catalogs:
if os.path.exists(catalog_path):
self.catalog_file = catalog_path
print(f"[INFO] DRM 카탈로그 파일 발견: {catalog_path}")
break
def sync_from_drm_repository(self, model: str = "PowerEdge R750") -> int:
"""
DRM 리포지토리에서 펌웨어 정보 동기화
Args:
model: 서버 모델명
Returns:
동기화된 펌웨어 개수
"""
if not self.catalog_file or not os.path.exists(self.catalog_file):
print(f"[오류] DRM 카탈로그 파일을 찾을 수 없습니다: {self.repository_path}")
return 0
try:
print(f"[INFO] DRM 카탈로그 파싱 중: {self.catalog_file}")
# XML 파싱
tree = ET.parse(self.catalog_file)
root = tree.getroot()
count = 0
# DRM 카탈로그 구조 파싱
for pkg in root.findall(".//SoftwareComponent"):
# 모델 필터링
supported_models = self._get_supported_models(pkg)
if model and model not in supported_models:
continue
# 펌웨어 정보 추출
firmware_info = self._extract_firmware_info(pkg, model)
if firmware_info:
# DB에 저장
if self._save_firmware_to_db(firmware_info):
count += 1
print(f"✓ DRM에서 {model} 관련 펌웨어 {count}개 동기화 완료")
return count
except Exception as e:
print(f"[오류] DRM 카탈로그 동기화 실패: {str(e)}")
import traceback
traceback.print_exc()
return 0
def _get_supported_models(self, pkg_element) -> List[str]:
"""패키지가 지원하는 서버 모델 목록 추출"""
models = []
# Dell 카탈로그 XML 구조
for display in pkg_element.findall(".//SupportedSystems/Brand/Model/Display"):
if display.text:
models.append(display.text.strip())
return models
def _extract_firmware_info(self, pkg_element, model: str) -> Optional[Dict]:
"""패키지에서 펌웨어 정보 추출"""
try:
name = pkg_element.findtext("Name")
version = pkg_element.findtext("vendorVersion") # DRM은 vendorVersion 사용
if not version:
version = pkg_element.findtext("dellVersion") # 또는 dellVersion
release_date = pkg_element.findtext("dateTime")
path = pkg_element.findtext("path")
category = pkg_element.findtext("Category")
# 중요도 판단
importance = pkg_element.findtext("ImportanceDisplay")
is_critical = importance and "Critical" in importance
# 파일 정보
file_name = None
file_size_mb = None
if path:
file_name = os.path.basename(path)
# 실제 파일이 있으면 크기 확인
if self.repository_path:
full_path = os.path.join(self.repository_path, path)
if os.path.exists(full_path):
file_size_mb = os.path.getsize(full_path) // (1024 * 1024)
if not name or not version:
return None
return {
'component_name': name,
'latest_version': version,
'server_model': model,
'vendor': 'Dell',
'release_date': self._parse_date(release_date),
'download_url': f"https://downloads.dell.com/{path}" if path else None,
'file_name': file_name,
'file_size_mb': file_size_mb,
'component_type': category,
'is_critical': is_critical,
'notes': f"DRM에서 동기화 - {category}" if category else "DRM에서 동기화"
}
except Exception as e:
print(f"[경고] 펌웨어 정보 추출 실패: {str(e)}")
return None
def _parse_date(self, date_str: str) -> Optional[str]:
"""날짜 문자열 파싱"""
if not date_str:
return None
try:
# DRM 날짜 형식: 2024-03-15T10:30:00
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return dt.strftime('%Y-%m-%d')
except:
return date_str[:10] if len(date_str) >= 10 else None
def _save_firmware_to_db(self, firmware_info: Dict) -> bool:
"""펌웨어 정보를 DB에 저장"""
try:
# 중복 확인
existing = FirmwareVersion.query.filter_by(
component_name=firmware_info['component_name'],
latest_version=firmware_info['latest_version'],
server_model=firmware_info['server_model']
).first()
if existing:
# 이미 존재하면 업데이트
for key, value in firmware_info.items():
if hasattr(existing, key) and value is not None:
setattr(existing, key, value)
db.session.commit()
return False # 새로 추가된 것은 아님
else:
# 새로 추가
version = FirmwareVersion(**firmware_info)
db.session.add(version)
db.session.commit()
return True
except Exception as e:
print(f"[오류] DB 저장 실패: {str(e)}")
db.session.rollback()
return False
def list_available_models(self) -> List[str]:
"""DRM 리포지토리에서 사용 가능한 모델 목록 조회"""
if not self.catalog_file or not os.path.exists(self.catalog_file):
return []
try:
tree = ET.parse(self.catalog_file)
root = tree.getroot()
models = set()
for display in root.findall(".//SupportedSystems/Brand/Model/Display"):
if display.text:
models.add(display.text.strip())
return sorted(list(models))
except Exception as e:
print(f"[오류] 모델 목록 조회 실패: {str(e)}")
return []
def get_repository_info(self) -> Dict:
"""DRM 리포지토리 정보 조회"""
if not self.catalog_file or not os.path.exists(self.catalog_file):
return {
'exists': False,
'path': self.repository_path,
'catalog_file': None
}
try:
tree = ET.parse(self.catalog_file)
root = tree.getroot()
# 카탈로그 메타데이터
base_location = root.findtext(".//BaseLocation")
release_id = root.findtext(".//ReleaseID")
# 패키지 수 계산
total_packages = len(root.findall(".//SoftwareComponent"))
return {
'exists': True,
'path': self.repository_path,
'catalog_file': self.catalog_file,
'base_location': base_location,
'release_id': release_id,
'total_packages': total_packages,
'available_models': self.list_available_models()
}
except Exception as e:
return {
'exists': True,
'path': self.repository_path,
'catalog_file': self.catalog_file,
'error': str(e)
}
# ========================================
# 편의 함수
# ========================================
def sync_from_drm(repository_path: str, model: str = "PowerEdge R750") -> Dict:
"""
DRM 리포지토리에서 펌웨어 동기화 (간편 함수)
Args:
repository_path: DRM 리포지토리 경로
model: 서버 모델명
Returns:
{'success': bool, 'count': int, 'message': str}
"""
try:
drm = DRMCatalogSync(repository_path)
# 리포지토리 확인
info = drm.get_repository_info()
if not info['exists']:
return {
'success': False,
'count': 0,
'message': f'DRM 리포지토리를 찾을 수 없습니다: {repository_path}'
}
# 동기화
count = drm.sync_from_drm_repository(model)
return {
'success': True,
'count': count,
'message': f'{model} 펌웨어 {count}개 동기화 완료',
'repository_info': info
}
except Exception as e:
return {
'success': False,
'count': 0,
'message': f'DRM 동기화 실패: {str(e)}'
}
def check_drm_repository(repository_path: str) -> Dict:
"""
DRM 리포지토리 상태 확인
Args:
repository_path: DRM 리포지토리 경로
Returns:
리포지토리 정보
"""
drm = DRMCatalogSync(repository_path)
return drm.get_repository_info()
+315
View File
@@ -0,0 +1,315 @@
"""
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"])
+995
View File
@@ -0,0 +1,995 @@
"""
Dell iDRAC REDFISH API 클라이언트
Dell 서버 펌웨어 조회 최적화 버전
"""
import requests
import json
import time
from urllib3.exceptions import InsecureRequestWarning
from requests.auth import HTTPBasicAuth
# SSL 경고 비활성화
requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning)
class DellRedfishClient:
def __init__(self, idrac_ip, username, password):
"""
Dell iDRAC REDFISH 클라이언트 초기화
Args:
idrac_ip: iDRAC IP 주소
username: iDRAC 사용자명
password: iDRAC 비밀번호
"""
self.idrac_ip = idrac_ip
self.base_url = f"https://{idrac_ip}"
self.auth = HTTPBasicAuth(username, password)
self.headers = {'Content-Type': 'application/json'}
self.session = requests.Session()
self.session.verify = False
self.session.auth = self.auth
def check_connection(self):
"""iDRAC 연결 확인"""
try:
url = f"{self.base_url}/redfish/v1"
response = self.session.get(url, timeout=10)
return response.status_code == 200
except Exception as e:
print(f"연결 오류: {str(e)}")
return False
def get_firmware_inventory(self):
"""
현재 설치된 펌웨어 목록 조회 (Dell 최적화)
주요 컴포넌트만 필터링하여 반환
"""
try:
url = f"{self.base_url}/redfish/v1/UpdateService/FirmwareInventory"
response = self.session.get(url, timeout=30)
if response.status_code != 200:
print(f"펌웨어 목록 조회 실패: {response.status_code}")
return []
data = response.json()
inventory = []
# 주요 컴포넌트 타입 (Dell 서버)
important_components = [
'BIOS', 'iDRAC', 'CPLD', 'Diagnostics',
'Driver', 'Firmware', 'USC', 'NIC',
'RAID', 'PERC', 'Storage', 'Backplane',
'HBA', 'Network', 'Intel', 'Broadcom',
'Mellanox', 'Emulex', 'QLogic'
]
# 로그 간소화: 전체 펌웨어 항목 수 출력 제거
# 각 펌웨어 항목 조회
for idx, member in enumerate(data.get('Members', [])):
try:
fw_url = f"{self.base_url}{member.get('@odata.id', '')}"
fw_response = self.session.get(fw_url, timeout=10)
if fw_response.status_code == 200:
fw_data = fw_response.json()
# 이름과 버전 추출 (여러 필드 시도)
name = (fw_data.get('Name') or
fw_data.get('SoftwareId') or
fw_data.get('Id') or
'Unknown')
version = (fw_data.get('Version') or
fw_data.get('VersionString') or
'Unknown')
# 컴포넌트 타입 확인
component_type = fw_data.get('ComponentType', '')
# 중요 컴포넌트만 필터링
is_important = any(comp.lower() in name.lower()
for comp in important_components)
if is_important or component_type:
item = {
'Name': name,
'Version': version,
'ComponentType': component_type,
'Updateable': fw_data.get('Updateable', False),
'Status': fw_data.get('Status', {}).get('Health', 'OK'),
'Id': fw_data.get('Id', ''),
'Description': fw_data.get('Description', ''),
'ReleaseDate': fw_data.get('ReleaseDate', '')
}
inventory.append(item)
# 진행 상황 출력
if (idx + 1) % 10 == 0:
pass # 로그 간소화: 진행률 출력 제거
except Exception as e:
print(f"펌웨어 항목 조회 오류 ({idx}): {str(e)}")
continue
# 이름순 정렬
inventory.sort(key=lambda x: x['Name'])
# 로그 간소화: 중요 펌웨어 항목 수 출력 제거
# 주요 컴포넌트 요약
bios = next((x for x in inventory if 'BIOS' in x['Name']), None)
idrac = next((x for x in inventory if 'iDRAC' in x['Name']), None)
if bios:
pass # 로그 간소화: BIOS 버전 출력 제거
if idrac:
pass # 로그 간소화: iDRAC 버전 출력 제거
return inventory
except Exception as e:
print(f"펌웨어 목록 조회 오류: {str(e)}")
import traceback
traceback.print_exc()
return []
def get_firmware_summary(self):
"""
주요 펌웨어 버전만 간단히 조회
BIOS, iDRAC, PERC 등 핵심 컴포넌트만
"""
try:
inventory = self.get_firmware_inventory()
summary = {
'BIOS': 'N/A',
'iDRAC': 'N/A',
'PERC': 'N/A',
'NIC': 'N/A',
'CPLD': 'N/A'
}
for item in inventory:
name = item['Name']
version = item['Version']
if 'BIOS' in name:
summary['BIOS'] = version
elif 'iDRAC' in name or 'Integrated Dell Remote Access' in name:
summary['iDRAC'] = version
elif 'PERC' in name or 'RAID' in name:
if summary['PERC'] == 'N/A':
summary['PERC'] = version
elif 'NIC' in name or 'Network' in name:
if summary['NIC'] == 'N/A':
summary['NIC'] = version
elif 'CPLD' in name:
summary['CPLD'] = version
return summary
except Exception as e:
print(f"펌웨어 요약 조회 오류: {str(e)}")
return {}
def upload_firmware_staged(self, dup_file_path):
"""
DUP 파일을 iDRAC에 업로드 (스테이징만, 재부팅 시 적용)
Args:
dup_file_path: 업로드할 DUP 파일 경로
Returns:
dict: {'success': bool, 'job_id': str, 'message': str}
"""
import os
if not os.path.exists(dup_file_path):
return {
'success': False,
'job_id': None,
'message': f'파일을 찾을 수 없습니다: {dup_file_path}'
}
file_name = os.path.basename(dup_file_path)
file_size = os.path.getsize(dup_file_path)
print(f"파일 업로드: {file_name} ({file_size / (1024*1024):.2f} MB)")
try:
url = f"{self.base_url}/redfish/v1/UpdateService/MultipartUpload"
# Multipart 업로드 준비
with open(dup_file_path, 'rb') as f:
files = {
'file': (file_name, f, 'application/octet-stream')
}
# targets=INSTALLED 파라미터로 스테이징 모드 설정
data = {
'@Redfish.OperationApplyTime': 'OnReset',
'Targets': []
}
multipart_data = {
'UpdateParameters': (None, json.dumps(data), 'application/json')
}
multipart_data.update(files)
print("업로드 시작...")
response = self.session.post(
url,
files=multipart_data,
auth=self.auth,
verify=False,
timeout=600 # 10분 타임아웃
)
print(f"응답 코드: {response.status_code}")
if response.status_code in [200, 201, 202]:
response_data = response.json()
# Task 또는 Job ID 추출
task_id = None
if '@odata.id' in response_data:
task_id = response_data['@odata.id'].split('/')[-1]
elif 'Id' in response_data:
task_id = response_data['Id']
# Location 헤더에서 Job ID 추출
location = response.headers.get('Location', '')
if location and not task_id:
task_id = location.split('/')[-1]
print(f"업로드 완료! Task/Job ID: {task_id}")
return {
'success': True,
'job_id': task_id or 'STAGED',
'message': '업로드 완료 (재부팅 시 적용)'
}
else:
error_msg = response.text
print(f"업로드 실패: {error_msg}")
return {
'success': False,
'job_id': None,
'message': f'업로드 실패 (코드: {response.status_code})'
}
except Exception as e:
print(f"업로드 오류: {str(e)}")
import traceback
traceback.print_exc()
return {
'success': False,
'job_id': None,
'message': f'업로드 오류: {str(e)}'
}
def get_job_queue(self):
"""Job Queue 조회"""
try:
url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/Jobs"
response = self.session.get(url, timeout=30)
if response.status_code == 200:
data = response.json()
jobs = []
for member in data.get('Members', [])[:20]: # 최근 20개
job_url = f"{self.base_url}{member['@odata.id']}"
job_response = self.session.get(job_url, timeout=10)
if job_response.status_code == 200:
job_data = job_response.json()
jobs.append({
'Id': job_data.get('Id', ''),
'Name': job_data.get('Name', ''),
'JobState': job_data.get('JobState', 'Unknown'),
'PercentComplete': job_data.get('PercentComplete', 0),
'Message': job_data.get('Message', ''),
'StartTime': job_data.get('StartTime', ''),
'EndTime': job_data.get('EndTime', '')
})
return jobs
else:
return []
except Exception as e:
print(f"Job Queue 조회 오류: {str(e)}")
return []
def get_job_status(self, job_id):
"""특정 Job 상태 조회"""
try:
url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/Jobs/{job_id}"
response = self.session.get(url, timeout=10)
if response.status_code == 200:
data = response.json()
return {
'Id': data.get('Id', ''),
'Name': data.get('Name', ''),
'JobState': data.get('JobState', 'Unknown'),
'PercentComplete': data.get('PercentComplete', 0),
'Message': data.get('Message', ''),
'StartTime': data.get('StartTime', ''),
'EndTime': data.get('EndTime', '')
}
else:
return None
except Exception as e:
print(f"Job 상태 조회 오류: {str(e)}")
return None
def delete_job(self, job_id):
"""Job 삭제"""
try:
url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/Jobs/{job_id}"
response = self.session.delete(url, timeout=10)
return response.status_code in [200, 204]
except Exception as e:
print(f"Job 삭제 오류: {str(e)}")
return False
def get_power_status(self):
"""서버 전원 상태 조회"""
try:
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1"
response = self.session.get(url, timeout=10)
if response.status_code == 200:
data = response.json()
return {
'PowerState': data.get('PowerState', 'Unknown'),
'Status': data.get('Status', {}).get('Health', 'Unknown')
}
else:
return {'PowerState': 'Unknown', 'Status': 'Unknown'}
except Exception as e:
print(f"전원 상태 조회 오류: {str(e)}")
return {'PowerState': 'Unknown', 'Status': 'Unknown'}
def reboot_server(self, reset_type='GracefulRestart'):
"""
서버 재부팅
Args:
reset_type: GracefulRestart, ForceRestart, GracefulShutdown, ForceOff, On
"""
try:
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1/Actions/ComputerSystem.Reset"
payload = {'ResetType': reset_type}
response = self.session.post(
url,
data=json.dumps(payload),
headers=self.headers,
timeout=30
)
return response.status_code in [200, 202, 204]
except Exception as e:
print(f"재부팅 오류: {str(e)}")
return False
def power_on_server(self):
"""서버 전원 켜기"""
return self.reboot_server('On')
def power_off_server(self, shutdown_type='GracefulShutdown'):
"""
서버 전원 끄기
Args:
shutdown_type: GracefulShutdown 또는 ForceOff
"""
return self.reboot_server(shutdown_type)
def get_bios_attributes_all(self):
"""
설정된 모든 BIOS 속성 조회
Returns:
dict: {AttributeName: Value, ...}
"""
try:
# 1. BIOS 리소스 조회
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1/Bios"
response = self.session.get(url, timeout=15)
if response.status_code == 200:
data = response.json()
# Attributes가 바로 있는 경우 (표준)
if 'Attributes' in data:
return data['Attributes']
# Future: Attributes가 별도 링크인 경우 처리 필요할 수 있음
return {}
else:
print(f"BIOS 속성 조회 실패: {response.status_code}")
return {}
except Exception as e:
print(f"BIOS 속성 조회 오류: {str(e)}")
return {}
def get_bios_attribute_registry(self):
"""
BIOS Attribute Registry를 조회하여 속성 이름(AttributeName)과
사용자 친화적인 이름(DisplayName)의 매핑 정보를 반환합니다.
Returns:
dict: {AttributeName: DisplayName, ...}
"""
try:
# 1. BIOS 리소스에서 AttributeRegistry 정보 확인
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1/Bios"
response = self.session.get(url, timeout=15)
if response.status_code != 200:
print("BIOS 리소스 조회 실패")
return {}
data = response.json()
registry_name = data.get('AttributeRegistry')
if not registry_name:
print("BIOS Attribute Registry 정보를 찾을 수 없음. 기본값으로 시도합니다.")
registry_name = "BiosAttributeRegistry"
print(f"DEBUG: Looking for BIOS Registry: {registry_name}")
# 2. Registries 컬렉션에서 해당 레지스트리 찾기
reg_url = f"{self.base_url}/redfish/v1/Registries"
reg_res = self.session.get(reg_url, timeout=10)
target_location = None
if reg_res.status_code == 200:
reg_data = reg_res.json()
# 검색할 이름 후보군 (버전 제거 등)
search_candidates = [registry_name]
if '.' in registry_name:
search_candidates.append(registry_name.split('.')[0])
# 'BiosAttributeRegistry'가 없다면 추가 (하드코딩 폴백)
if 'BiosAttributeRegistry' not in search_candidates:
search_candidates.append('BiosAttributeRegistry')
print(f"DEBUG: Registry Candidates: {search_candidates}")
for member in reg_data.get('Members', []):
m_url = member.get('@odata.id', '')
# URL 끝부분(ID) 추출
# /redfish/v1/Registries/BiosAttributeRegistry
m_id = m_url.rstrip('/').split('/')[-1]
match_found = False
for candidate in search_candidates:
if candidate.lower() in m_id.lower():
match_found = True
break
if match_found:
print(f"DEBUG: Found Registry Match: {m_url}")
# 레지스트리 상세 조회
detail_url = f"{self.base_url}{m_url}"
detail_res = self.session.get(detail_url, timeout=10)
if detail_res.status_code == 200:
detail_data = detail_res.json()
# Location 정보 찾기
locations = detail_data.get('Location', [])
for loc in locations:
if loc.get('Language', '').startswith('en'):
target_location = loc.get('Uri')
break
if not target_location and locations:
target_location = locations[0].get('Uri')
if target_location:
break
# 3. Registry 파일(JSON) 다운로드 및 파싱
if target_location:
# URI가 전체 경로가 아닐 수 있음
if not target_location.startswith('http'):
final_url = f"{self.base_url}{target_location}"
else:
final_url = target_location
print(f"Downloading BIOS Registry from: {final_url}")
file_res = self.session.get(final_url, timeout=30)
if file_res.status_code == 200:
reg_json = file_res.json()
mapping = {}
# RegistryEntries 파싱
# 구조: { "RegistryEntries": { "Attributes": [ ... ] } } 또는 바로 Attributes
attributes = []
if 'RegistryEntries' in reg_json:
if 'Attributes' in reg_json['RegistryEntries']:
attributes = reg_json['RegistryEntries']['Attributes']
else:
# 썸네일 등 다른 구조일 수 있음, 여기서는 일반적 구조 따름
pass
elif 'Attributes' in reg_json:
attributes = reg_json['Attributes']
for entry in attributes:
attr_name = entry.get('AttributeName')
display_name = entry.get('DisplayName')
if attr_name and display_name:
mapping[attr_name] = display_name
print(f"BIOS Registry 파싱 완료: {len(mapping)}개 항목")
return mapping
print(f"BIOS Registry 위치를 찾을 수 없음 (TargetLocation: {target_location})")
return {}
except Exception as e:
print(f"BIOS Attribute Registry 조회 오류: {str(e)}")
import traceback
traceback.print_exc()
return {}
def _flatten_dict(self, d, parent_key='', sep='.'):
"""중첩된 딕셔너리를 평탄화 (Nested JSON -> Flat JSON)"""
items = []
for k, v in d.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
elif isinstance(v, list):
# 리스트인 경우 인덱스를 붙여서 평탄화
for i, item in enumerate(v):
if isinstance(item, dict):
items.extend(self._flatten_dict(item, f"{new_key}{sep}{i}", sep=sep).items())
else:
items.append((f"{new_key}{sep}{i}", item))
else:
items.append((new_key, v))
return dict(items)
def get_idrac_attributes_all(self):
"""
설정된 모든 iDRAC 속성 조회 (Attributes + NetworkProtocol + AccountService)
Returns:
dict: {AttributeName: Value, ...} (Flattened)
"""
all_attributes = {}
try:
# 1. 기본 Attributes 조회
url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/Attributes"
response = self.session.get(url, timeout=15)
if response.status_code == 200:
data = response.json()
attrs = data.get('Attributes', data)
# @odata 필드 제거
attrs = {k: v for k, v in attrs.items() if not k.startswith('@odata')}
all_attributes.update(attrs)
# 2. Network Protocol 조회 (NTP, SNMP, IPMI, SSH 등)
net_url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/NetworkProtocol"
net_res = self.session.get(net_url, timeout=10)
if net_res.status_code == 200:
net_data = net_res.json()
# 불필요한 필드 제거
exclude_keys = ['@odata.context', '@odata.id', '@odata.type', 'Name', 'Description', 'Id', 'Status']
filtered_net = {k: v for k, v in net_data.items() if k not in exclude_keys}
# 평탄화하여 병합 (Prefix: NetworkProtocol)
flat_net = self._flatten_dict(filtered_net, parent_key='NetworkProtocol')
all_attributes.update(flat_net)
# 3. Account Service 조회 (LDAP, ActiveDirectory)
# /redfish/v1/AccountService
acc_url = f"{self.base_url}/redfish/v1/AccountService"
acc_res = self.session.get(acc_url, timeout=10)
if acc_res.status_code == 200:
acc_data = acc_res.json()
exclude_keys = ['@odata.context', '@odata.id', '@odata.type', 'Name', 'Description', 'Id', 'Status', 'Roles', 'Accounts']
filtered_acc = {k: v for k, v in acc_data.items() if k not in exclude_keys}
flat_acc = self._flatten_dict(filtered_acc, parent_key='AccountService')
all_attributes.update(flat_acc)
return all_attributes
return all_attributes
except Exception as e:
print(f"iDRAC 속성 조회 오류: {str(e)}")
return all_attributes or {}
def get_idrac_attribute_registry(self):
"""
iDRAC Attribute Registry를 조회하여 속성 이름(AttributeName)과
사용자 친화적인 이름(DisplayName)의 매핑 정보를 반환합니다.
Returns:
dict: {AttributeName: DisplayName, ...}
"""
try:
# 1. iDRAC 리소스에서 AttributeRegistry 정보 확인
url = f"{self.base_url}/redfish/v1/Managers/iDRAC.Embedded.1/Attributes"
response = self.session.get(url, timeout=15)
if response.status_code != 200:
print("iDRAC 리소스 조회 실패")
return {}
data = response.json()
registry_name = data.get('AttributeRegistry')
if not registry_name:
print("iDRAC Attribute Registry 정보를 찾을 수 없음. 기본값으로 시도합니다.")
# iDRAC의 경우 보통 'iDRACRegistry' 또는 'IdracAttributeRegistry' 등일 수 있음
# 하지만 대부분의 Redfish 구현체에서 AttributeRegistry 프로퍼티가 있거나
# Registries 컬렉션에서 찾을 수 있습니다.
registry_name = "iDRACAttributeRegistry"
print(f"DEBUG: Looking for iDRAC Registry: {registry_name}")
# 2. Registries 컬렉션에서 해당 레지스트리 찾기
reg_url = f"{self.base_url}/redfish/v1/Registries"
reg_res = self.session.get(reg_url, timeout=10)
target_location = None
if reg_res.status_code == 200:
reg_data = reg_res.json()
# 검색할 이름 후보군 (버전 제거 등)
search_candidates = [registry_name]
if '.' in registry_name:
search_candidates.append(registry_name.split('.')[0])
# 'iDRACAttributeRegistry'가 없다면 추가 (하드코딩 폴백)
if 'iDRACAttributeRegistry' not in search_candidates:
search_candidates.append('iDRACAttributeRegistry')
if 'iDRACRegistry' not in search_candidates:
search_candidates.append('iDRACRegistry')
print(f"DEBUG: iDRAC Registry Candidates: {search_candidates}")
for member in reg_data.get('Members', []):
m_url = member.get('@odata.id', '')
m_id = m_url.rstrip('/').split('/')[-1]
match_found = False
for candidate in search_candidates:
if candidate.lower() in m_id.lower():
match_found = True
break
if match_found:
print(f"DEBUG: Found iDRAC Registry Match: {m_url}")
# 레지스트리 상세 조회
detail_url = f"{self.base_url}{m_url}"
detail_res = self.session.get(detail_url, timeout=10)
if detail_res.status_code == 200:
detail_data = detail_res.json()
# Location 정보 찾기 (영어 버전 선호)
locations = detail_data.get('Location', [])
for loc in locations:
if loc.get('Language', '').startswith('en'):
target_location = loc.get('Uri')
break
if not target_location and locations:
target_location = locations[0].get('Uri')
if target_location:
break
# 3. Registry 파일(JSON) 다운로드 및 파싱
if target_location:
# URI가 전체 경로가 아닐 수 있음
if not target_location.startswith('http'):
final_url = f"{self.base_url}{target_location}"
else:
final_url = target_location
print(f"Downloading iDRAC Registry from: {final_url}")
file_res = self.session.get(final_url, timeout=30)
if file_res.status_code == 200:
reg_json = file_res.json()
mapping = {}
# RegistryEntries 파싱
attributes = []
if 'RegistryEntries' in reg_json:
if 'Attributes' in reg_json['RegistryEntries']:
attributes = reg_json['RegistryEntries']['Attributes']
else:
pass
elif 'Attributes' in reg_json:
attributes = reg_json['Attributes']
for entry in attributes:
attr_name = entry.get('AttributeName')
display_name = entry.get('DisplayName')
if attr_name and display_name:
mapping[attr_name] = display_name
print(f"iDRAC Registry 파싱 완료: {len(mapping)}개 항목")
return mapping
print(f"iDRAC Registry 위치를 찾을 수 없음 (TargetLocation: {target_location})")
return {}
except Exception as e:
print(f"iDRAC Attribute Registry 조회 오류: {str(e)}")
return {}
def get_system_info_detailed(self):
"""
시스템 상세 정보 (CPU, Memory, Model 등) 조회
"""
try:
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1"
response = self.session.get(url, timeout=15)
if response.status_code != 200:
return None
data = response.json()
info = {
'Model': data.get('Model', 'Unknown'),
'Manufacturer': data.get('Manufacturer', 'Unknown'),
'SerialNumber': data.get('SerialNumber', 'Unknown'),
'PowerState': data.get('PowerState', 'Unknown'),
'BiosVersion': data.get('BiosVersion', 'Unknown'),
'ProcessorSummary': {
'Count': data.get('ProcessorSummary', {}).get('Count', 0),
'Model': data.get('ProcessorSummary', {}).get('Model', 'Unknown'),
'Status': data.get('ProcessorSummary', {}).get('Status', {}).get('Health', 'Unknown')
},
'MemorySummary': {
'TotalSystemMemoryGiB': data.get('MemorySummary', {}).get('TotalSystemMemoryGiB', 0),
'Status': data.get('MemorySummary', {}).get('Status', {}).get('Health', 'Unknown')
},
'HostName': data.get('HostName', '')
}
return info
except Exception as e:
print(f"시스템 상세 조회 오류: {str(e)}")
return None
def get_storage_configuration(self):
"""
RAID 컨트롤러 및 스토리지 구성 정보 조회
Controllers, Volumes(RAID), Drives 정보 포함
"""
try:
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1/Storage"
response = self.session.get(url, timeout=20)
if response.status_code != 200:
print(f"스토리지 조회 실패: {response.status_code}")
return {'Controllers': []}
data = response.json()
controllers = []
for member in data.get('Members', []):
try:
ctrl_url = f"{self.base_url}{member['@odata.id']}"
ctrl_res = self.session.get(ctrl_url, timeout=10)
if ctrl_res.status_code != 200:
continue
ctrl_data = ctrl_res.json()
# 1. 컨트롤러 기본 정보
controller = {
'Name': ctrl_data.get('Name', 'Unknown'),
'Id': ctrl_data.get('Id', ''),
'Model': ctrl_data.get('Model', ''),
'FirmwareVersion': '', # 아래에서 찾음
'Status': ctrl_data.get('Status', {}).get('Health', 'Unknown'),
'Volumes': [],
'Drives': []
}
# 펌웨어 버전 찾기 (Controllers 리스트 내)
for c in ctrl_data.get('Controllers', []):
if 'FirmwareVersion' in c:
controller['FirmwareVersion'] = c['FirmwareVersion']
break
# 2. Volumes (Virtual Disks)
# Volumes가 링크로 되어있는 경우와 바로 있는 경우가 있음
volumes_url = f"{ctrl_url}/Volumes"
vol_res = self.session.get(volumes_url, timeout=10)
if vol_res.status_code == 200:
vol_data = vol_res.json()
for vol_member in vol_data.get('Members', []):
v_url = f"{self.base_url}{vol_member['@odata.id']}"
v_res = self.session.get(v_url, timeout=10)
if v_res.status_code == 200:
v_data = v_res.json()
controller['Volumes'].append({
'Name': v_data.get('Name', ''),
'RaidType': v_data.get('RAIDType', 'Unknown'), # RAID0, RAID1, etc
'CapacityBytes': v_data.get('CapacityBytes', 0),
'BlockSizeBytes': v_data.get('BlockSizeBytes', 0),
'Status': v_data.get('Status', {}).get('Health', 'Unknown')
})
# 3. Drives (Physical Disks)
drives_url = f"{ctrl_url}/Drives"
drv_res = self.session.get(drives_url, timeout=10)
if drv_res.status_code == 200:
drv_data = drv_res.json()
for drv_member in drv_data.get('Members', []):
d_url = f"{self.base_url}{drv_member['@odata.id']}"
d_res = self.session.get(d_url, timeout=10)
if d_res.status_code == 200:
d_data = d_res.json()
controller['Drives'].append({
'Name': d_data.get('Name', ''),
'Model': d_data.get('Model', ''),
'Protocol': d_data.get('Protocol', 'Unknown'), # SATA, SAS, NVMe
'MediaType': d_data.get('MediaType', 'Unknown'), # HDD, SSD
'CapacityBytes': d_data.get('CapacityBytes', 0),
'Status': d_data.get('Status', {}).get('Health', 'Unknown')
})
controllers.append(controller)
except Exception as e:
print(f"컨트롤러 상세 조회 오류 ({member.get('@odata.id')}): {str(e)}")
return {'Controllers': controllers}
except Exception as e:
print(f"스토리지 구성 조회 오류: {str(e)}")
return {'Controllers': []}
def get_nic_configuration(self):
"""
NIC 구성 정보 조회 (NetworkAdapters + DeviceFunctions)
"""
try:
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1/NetworkAdapters"
response = self.session.get(url, timeout=15)
if response.status_code != 200:
print(f"NIC 어댑터 목록 조회 실패: {response.status_code}")
return {'Adapters': []}
data = response.json()
adapters = []
for member in data.get('Members', []):
try:
# 어댑터 기본 정보
adp_url = f"{self.base_url}{member['@odata.id']}"
adp_res = self.session.get(adp_url, timeout=10)
if adp_res.status_code != 200:
continue
adp_data = adp_res.json()
adapter = {
'Id': adp_data.get('Id', ''),
'Name': adp_data.get('Name', 'Unknown'),
'Manufacturer': adp_data.get('Manufacturer', ''),
'Model': adp_data.get('Model', ''),
'SerialNumber': adp_data.get('SerialNumber', ''),
'FirmwareVersion': '', # LinkStatus 등은 Port에 있음
'DeviceFunctions': []
}
# 펌웨어 버전 추출
if 'FirmwarePackageVersion' in adp_data:
adapter['FirmwareVersion'] = adp_data['FirmwarePackageVersion']
# NetworkDeviceFunctions (iSCSI, PXE 등 설정)
funcs_url = f"{adp_url}/NetworkDeviceFunctions"
funcs_res = self.session.get(funcs_url, timeout=10)
if funcs_res.status_code == 200:
funcs_data = funcs_res.json()
for f_mem in funcs_data.get('Members', []):
f_url = f"{self.base_url}{f_mem['@odata.id']}"
f_res = self.session.get(f_url, timeout=10)
if f_res.status_code == 200:
f_data = f_res.json()
# Dell 특화 Attributes 또는 표준 Redfish 속성
func_info = {
'Id': f_data.get('Id', ''),
'Name': f_data.get('Name', ''),
'NetBootPMMode': f_data.get('BootMode', 'Unknown'), # Legacy, UEFI
'iSCSIBoot': {},
'Ethernet': {}
}
# iSCSI Boot 속성 평탄화
if 'iSCSIBoot' in f_data:
func_info['iSCSIBoot'] = f_data['iSCSIBoot']
# 이더넷 속성 (MAC 등)
if 'Ethernet' in f_data:
func_info['Ethernet'] = {
'MACAddress': f_data['Ethernet'].get('MACAddress', ''),
'MTUSize': f_data['Ethernet'].get('MTUSize', '')
}
adapter['DeviceFunctions'].append(func_info)
adapters.append(adapter)
except Exception as e:
print(f"NIC 상세 조회 오류: {str(e)}")
return {'Adapters': adapters}
except Exception as e:
print(f"NIC 구성 조회 오류: {str(e)}")
return {'Adapters': []}
def get_boot_settings(self):
"""
부팅 설정 조회 (Boot Order, Boot Source Override)
"""
try:
url = f"{self.base_url}/redfish/v1/Systems/System.Embedded.1"
response = self.session.get(url, timeout=15)
if response.status_code == 200:
data = response.json()
boot = data.get('Boot', {})
return {
'BootOrder': boot.get('BootOrder', []),
'BootSourceOverrideTarget': boot.get('BootSourceOverrideTarget', 'None'),
'BootSourceOverrideMode': boot.get('BootSourceOverrideMode', 'Legacy'),
'BootSourceOverrideEnabled': boot.get('BootSourceOverrideEnabled', 'Disabled')
}
return {}
except Exception as e:
print(f"부팅 설정 조회 오류: {str(e)}")
return {}
def get_firmware_baseline(self):
"""
Baseline 비교용 펌웨어 목록 (간소화)
"""
try:
inventory = self.get_firmware_inventory()
baseline_fw = {}
for item in inventory:
# 키: 컴포넌트 이름 (중복 시 타입/ID 병기하여 유니크하게 만들 필요 있음)
# 여기서는 단순화를 위해 Name을 Key로 사용하되, 중복되면 덮어씀 (일반적으로 주요 컴포넌트는 유니크함)
# 또는 {ComponentType}_{Name} 형식 추천
key = item['Name']
baseline_fw[key] = item['Version']
return baseline_fw
except Exception as e:
print(f"펌웨어 Baseline 조회 오류: {str(e)}")
return {}
+170
View File
@@ -0,0 +1,170 @@
from __future__ import annotations
from pathlib import Path
import os
import sys
import uuid
import logging
import subprocess
import platform
from concurrent.futures import ThreadPoolExecutor, as_completed
from threading import Lock
from watchdog.observers import Observer
from backend.services.watchdog_handler import FileCreatedHandler
from config import Config
# Job ID별 진행률 (스레드 안전)
_progress: dict[str, int] = {}
_progress_lock = Lock()
def _set_progress(job_id: str, value: int) -> None:
with _progress_lock:
_progress[job_id] = max(0, min(100, int(value)))
def get_progress(job_id: str) -> int:
with _progress_lock:
return int(_progress.get(job_id, 0))
def on_complete(job_id: str) -> None:
_set_progress(job_id, 100)
# ─────────────────────────────────────────────────────────────
# IP 목록 저장
# ─────────────────────────────────────────────────────────────
def save_ip_addresses(ips: str, folder: str | os.PathLike[str]) -> list[tuple[str, str]]:
out_dir = Path(folder)
out_dir.mkdir(parents=True, exist_ok=True)
ip_files: list[tuple[str, str]] = []
for i, raw in enumerate((ips or "").splitlines()):
ip = raw.strip()
if not ip:
continue
file_path = out_dir / f"ip_{i}.txt"
file_path.write_text(ip + "\n", encoding="utf-8")
ip_files.append((ip, str(file_path)))
return ip_files
# ─────────────────────────────────────────────────────────────
# 개별 IP 처리
# ─────────────────────────────────────────────────────────────
def _build_command(script: str, ip_file: str, xml_file: str | None, profile_name: str | None = None) -> list[str]:
# unified 스크립트는 data/scripts/unified 폴더에 있음
# 기존 레거시 스크립트는 data/scripts 폴더에 있음
# script 인자가 "unified/..." 로 시작하면 해당 경로 사용, 아니면 data/scripts 기준
if script.startswith("unified/"):
script_path = Path(Config.SCRIPT_FOLDER) / script
else:
script_path = Path(Config.SCRIPT_FOLDER) / script
if not script_path.exists():
raise FileNotFoundError(f"스크립트를 찾을 수 없습니다: {script_path}")
if script_path.suffix == ".sh":
# Windows에서 .sh 실행은 bash 필요 (Git Bash/WSL 등). 없으면 예외 처리.
if platform.system() == "Windows":
bash = shutil.which("bash") # type: ignore[name-defined]
if not bash:
raise RuntimeError("Windows에서 .sh 스크립트를 실행하려면 bash가 필요합니다.")
cmd = [bash, str(script_path), ip_file]
else:
cmd = [str(script_path), ip_file]
elif script_path.suffix == ".py":
cmd = [sys.executable, str(script_path), ip_file]
else:
raise ValueError(f"지원되지 않는 스크립트 형식: {script_path.suffix}")
# 프로파일이 있으면 추가 (unified 스크립트용)
if profile_name:
cmd.append(profile_name)
if xml_file:
cmd.append(xml_file)
return cmd
def process_ip(ip_file: str, script: str, xml_file: str | None = None, profile_name: str | None = None, slot_priority: str | None = None) -> None:
ip = Path(ip_file).read_text(encoding="utf-8").strip()
cmd = _build_command(script, ip_file, xml_file, profile_name)
# 환경 변수 설정 (기존 환경 변수 복사 + 슬롯 우선순위 추가)
env = os.environ.copy()
if slot_priority:
env["GUID_SLOT_PRIORITY"] = slot_priority
logging.info("🔧 실행 명령: %s", " ".join(cmd))
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True,
cwd=str(Path(Config.SCRIPT_FOLDER)),
timeout=int(os.getenv("SCRIPT_TIMEOUT", "1800")), # 30분 기본
env=env,
)
logging.info("[%s] ✅ stdout:\n%s", ip, result.stdout)
if result.stderr:
logging.warning("[%s] ⚠ stderr:\n%s", ip, result.stderr)
except subprocess.CalledProcessError as e:
logging.error("[%s] ❌ 스크립트 실행 오류(code=%s): %s", ip, e.returncode, e.stderr or e)
except subprocess.TimeoutExpired:
logging.error("[%s] ⏰ 스크립트 실행 타임아웃", ip)
# ─────────────────────────────────────────────────────────────
# 병렬 처리 진입점
# ─────────────────────────────────────────────────────────────
def process_ips_concurrently(ip_files, job_id, observer: Observer, script: str, xml_file: str | None, profile_name: str | None = None, slot_priority: str | None = None):
total = len(ip_files)
completed = 0
_set_progress(job_id, 0)
try:
with ThreadPoolExecutor(max_workers=Config.MAX_WORKERS) as pool:
futures = {pool.submit(process_ip, ip_path, script, xml_file, profile_name, slot_priority): ip for ip, ip_path in ip_files}
for fut in as_completed(futures):
ip = futures[fut]
try:
fut.result()
except Exception as e:
logging.error("%s 처리 중 오류 발생: %s", ip, e)
finally:
completed += 1
if total:
_set_progress(job_id, int(completed * 100 / total))
finally:
try:
observer.stop()
observer.join(timeout=5)
except Exception:
pass
# ─────────────────────────────────────────────────────────────
# 외부에서 한 번에 처리(동기)
# ─────────────────────────────────────────────────────────────
def handle_ip_processing(ip_text: str, script: str, xml_file: str | None = None, profile_name: str | None = None) -> str:
job_id = str(uuid.uuid4())
temp_dir = Path(Config.UPLOAD_FOLDER) / job_id
ip_files = save_ip_addresses(ip_text, temp_dir)
xml_path = str(Path(Config.XML_FOLDER) / xml_file) if xml_file else None
handler = FileCreatedHandler(job_id, len(ip_files))
observer = Observer()
observer.schedule(handler, Config.IDRAC_INFO_FOLDER, recursive=False)
observer.start()
process_ips_concurrently(ip_files, job_id, observer, script, xml_path, profile_name, None)
return job_id
+107
View File
@@ -0,0 +1,107 @@
from __future__ import annotations
from pathlib import Path
import logging
import os
from logging.handlers import TimedRotatingFileHandler
from typing import Optional
from config import Config
_DEF_LEVEL = os.getenv("APP_LOG_LEVEL", "INFO").upper()
_DEF_FMT = "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
def _ensure_log_dir() -> Path:
p = Path(Config.LOG_FOLDER)
p.mkdir(parents=True, exist_ok=True)
return p
def setup_logging(app: Optional[object] = None) -> logging.Logger:
"""앱 전역 로깅을 파일(일단위 회전) + 콘솔로 설정.
- 회전 파일명: YYYY-MM-DD.log
- 중복 핸들러 방지
- Windows/Linux 공통 동작
"""
log_dir = _ensure_log_dir()
log_path = log_dir / "app.log"
# ─────────────────────────────────────────────────────────────
# [Fix] 앱 시작 시점에 app.log가 있고 날짜가 지났다면 강제 로테이션 (백업)
# TimedRotatingFileHandler는 프로세스가 재시작되면 파일의 생성일을 기준으로
# 롤오버를 즉시 수행하지 않고 append 모드로 여는 경우가 많음.
# 이를 보완하기 위해, 직접 날짜를 확인하고 파일을 옮겨준다.
# ─────────────────────────────────────────────────────────────
if log_path.exists():
try:
from datetime import date
# 파일 마지막 수정 시간 확인
mtime = os.path.getmtime(log_path)
file_date = date.fromtimestamp(mtime)
today = date.today()
# "파일 날짜" < "오늘" 이면 백업
if file_date < today:
backup_name = file_date.strftime("%Y-%m-%d.log")
backup_path = log_dir / backup_name
# 이미 백업 파일이 있으면 굳이 덮어쓰거나 하지 않음(안전성)
if not backup_path.exists():
os.rename(log_path, backup_path)
print(f"[Logger] Rotated old log file: app.log -> {backup_name}")
except Exception as e:
# 권한 문제, 파일 잠금(Windows) 등으로 실패 시 무시하고 진행
print(f"[Logger] Failed to force rotate log file: {e}")
root = logging.getLogger()
root.setLevel(_DEF_LEVEL)
root.propagate = False
# 기존 핸들러 제거(중복 방지)
for h in root.handlers[:]:
root.removeHandler(h)
# 파일 로거
file_handler = TimedRotatingFileHandler(
filename=str(log_path), when="midnight", interval=1, backupCount=90, encoding="utf-8", utc=False
)
# 회전 파일명: 2025-09-30.log 형태로
def _namer(default_name: str) -> str:
# default_name: app.log.YYYY-MM-DD
base_dir = os.path.dirname(default_name)
date_str = default_name.rsplit(".", 1)[-1]
return os.path.join(base_dir, f"{date_str}.log")
file_handler.namer = _namer
file_handler.setFormatter(logging.Formatter(_DEF_FMT))
# 콘솔 로거
console = logging.StreamHandler()
console.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
root.addHandler(file_handler)
root.addHandler(console)
if app is not None:
# Flask 앱 로거에도 동일 핸들러 바인딩
app.logger.handlers = root.handlers
app.logger.setLevel(root.level)
# 루트 로거로 전파되면 메시지가 두 번 출력되므로 방지
app.logger.propagate = False
# 제3자 라이브러리 로그 레벨 조정 (너무 시끄러운 경우)
# werkzeug: 기본적인 HTTP 요청 로그(GET/POST 등)를 숨김 (WARNING 이상만 표시)
logging.getLogger("werkzeug").setLevel(logging.WARNING)
logging.getLogger("socketio").setLevel(logging.WARNING)
logging.getLogger("engineio").setLevel(logging.WARNING)
# httpx, telegram 라이브러리의 HTTP 요청 로그 숨기기
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
logging.getLogger("telegram").setLevel(logging.WARNING)
root.debug("Logger initialized | level=%s | file=%s", _DEF_LEVEL, log_path)
return root
+320
View File
@@ -0,0 +1,320 @@
"""
Dell iDRAC Redfish API Client (수정 버전)
절대 경로와 상대 경로 모두 처리
"""
import requests
import urllib3
from typing import Dict, Any, Optional, List
import logging
from functools import wraps
import time
import os
# SSL 경고 비활성화
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
logger = logging.getLogger(__name__)
def retry_on_failure(max_attempts: int = 2, delay: float = 2.0):
"""재시도 데코레이터"""
def decorator(func):
@wraps(func)
def wrapper(*args, **kwargs):
last_exception = None
for attempt in range(max_attempts):
try:
return func(*args, **kwargs)
except (requests.Timeout, requests.ConnectionError) as e:
last_exception = e
if attempt < max_attempts - 1:
logger.warning(f"Attempt {attempt + 1} failed, retrying in {delay}s: {e}")
time.sleep(delay * (attempt + 1))
except Exception as e:
raise
raise last_exception
return wrapper
return decorator
class RedfishClient:
"""Dell iDRAC Redfish API 클라이언트"""
def __init__(
self,
ip: str,
username: str,
password: str,
timeout: Optional[int] = None,
verify_ssl: Optional[bool] = None
):
self.ip = ip
self.base_url = f"https://{ip}/redfish/v1"
self.host_url = f"https://{ip}"
# Config defaults
default_timeout = 15
default_verify = False
try:
from flask import current_app
if current_app:
default_timeout = current_app.config.get("REDFISH_TIMEOUT", 15)
default_verify = current_app.config.get("REDFISH_VERIFY_SSL", False)
except ImportError:
pass
self.timeout = timeout if timeout is not None else default_timeout
self.verify_ssl = verify_ssl if verify_ssl is not None else default_verify
self.session = requests.Session()
self.session.auth = (username, password)
self.session.verify = self.verify_ssl
self.session.headers.update({
"Content-Type": "application/json",
"Accept": "application/json"
})
@retry_on_failure(max_attempts=2, delay=2.0)
def get(self, endpoint: str) -> Dict[str, Any]:
"""
GET 요청
절대 경로와 상대 경로 모두 처리
"""
# 절대 경로 처리 (이미 /redfish/v1로 시작하는 경우)
if endpoint.startswith('/redfish/v1'):
url = f"{self.host_url}{endpoint}"
# 상대 경로 처리
else:
url = f"{self.base_url}{endpoint}"
logger.debug(f"GET {url}")
response = self.session.get(url, timeout=self.timeout)
response.raise_for_status()
return response.json()
def get_jobs(self) -> List[Dict[str, Any]]:
"""
모든 Job 조회
표준 경로와 Dell OEM 경로 모두 시도
"""
jobs = []
# 1. 표준 Redfish Jobs 경로 시도
try:
standard_jobs = self._get_jobs_standard()
jobs.extend(standard_jobs)
except Exception as e:
logger.warning(f"{self.ip}: Standard Jobs endpoint failed: {e}")
# 2. Dell OEM Jobs 경로 시도
try:
oem_jobs = self._get_jobs_dell_oem()
jobs.extend(oem_jobs)
except Exception as e:
logger.warning(f"{self.ip}: Dell OEM Jobs endpoint failed: {e}")
if not jobs:
logger.info(f"{self.ip}: No jobs found")
return []
# 중복 제거 (JID 기준)
seen_jids = set()
unique_jobs = []
for job in jobs:
jid = job.get("JID", "")
if jid and jid not in seen_jids:
seen_jids.add(jid)
unique_jobs.append(job)
logger.info(f"{self.ip}: Retrieved {len(unique_jobs)} unique jobs")
return sorted(unique_jobs, key=lambda x: x.get("JID", ""))
def _get_jobs_standard(self) -> List[Dict[str, Any]]:
"""표준 Redfish Jobs 조회"""
jobs_endpoint = "/Managers/iDRAC.Embedded.1/Jobs"
jobs_collection = self.get(jobs_endpoint)
members = jobs_collection.get("Members", [])
if not members:
return []
jobs = []
for member in members:
job_path = member.get("@odata.id", "")
if not job_path:
continue
try:
job_data = self.get(job_path)
normalized_job = self._normalize_job(job_data)
jobs.append(normalized_job)
except Exception as e:
logger.warning(f"{self.ip}: Failed to get job {job_path}: {e}")
continue
return jobs
def _get_jobs_dell_oem(self) -> List[Dict[str, Any]]:
"""Dell OEM Jobs 조회"""
oem_endpoint = "/Managers/iDRAC.Embedded.1/Oem/Dell/Jobs"
try:
jobs_collection = self.get(oem_endpoint)
except requests.HTTPError as e:
if e.response.status_code == 404:
logger.debug(f"{self.ip}: Dell OEM endpoint not available")
return []
raise
members = jobs_collection.get("Members", [])
if not members:
return []
jobs = []
for member in members:
job_path = member.get("@odata.id", "")
if not job_path:
continue
try:
job_data = self.get(job_path)
normalized_job = self._normalize_job(job_data)
jobs.append(normalized_job)
except Exception as e:
logger.warning(f"{self.ip}: Failed to get Dell OEM job {job_path}: {e}")
continue
return jobs
def _normalize_job(self, job_data: Dict[str, Any]) -> Dict[str, Any]:
"""Redfish Job 데이터를 표준 포맷으로 변환"""
percent = job_data.get("PercentComplete", 0)
if percent is None:
percent = 0
# JobState 매핑
job_state = job_data.get("JobState", "Unknown")
status_map = {
"New": "Scheduled",
"Starting": "Starting",
"Running": "Running",
"Completed": "Completed",
"Failed": "Failed",
"CompletedWithErrors": "Completed with Errors",
"Pending": "Pending",
"Paused": "Paused",
"Stopping": "Stopping",
"Cancelled": "Cancelled",
"Cancelling": "Cancelling"
}
status = status_map.get(job_state, job_state)
# 메시지 처리
messages = job_data.get("Messages", [])
message_text = ""
if messages and isinstance(messages, list):
if messages[0] and isinstance(messages[0], dict):
message_text = messages[0].get("Message", "")
if not message_text:
message_text = job_data.get("Message", "")
return {
"JID": job_data.get("Id", ""),
"Name": job_data.get("Name", ""),
"Status": status,
"PercentComplete": str(percent),
"Message": message_text,
"ScheduledStartTime": job_data.get("ScheduledStartTime", ""),
"LastUpdateTime": job_data.get("EndTime") or job_data.get("StartTime", ""),
}
def export_system_configuration(self, share_parameters: Dict[str, Any], target: str = "ALL") -> str:
"""
시스템 설정 내보내기 (SCP Export)
:param share_parameters: 네트워크 공유 설정 (IP, ShareName, FileName, UserName, Password 등)
:param target: 내보낼 대상 (ALL, IDRAC, BIOS, NIC, RAID)
:return: Job ID
"""
url = f"{self.host_url}/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Oem/DellManager.ExportSystemConfiguration"
payload = {
"ExportFormat": "XML",
"ShareParameters": share_parameters,
"Target": target
}
logger.info(f"{self.ip}: Exporting system configuration to {share_parameters.get('FileName')}")
response = self.session.post(url, json=payload, verify=False)
response.raise_for_status()
# Job ID 추출 (Location 헤더 또는 응답 본문)
job_id = ""
if response.status_code == 202:
location = response.headers.get("Location")
if location:
job_id = location.split("/")[-1]
if not job_id:
# 응답 본문에서 시도 (일부 펌웨어 버전 대응)
try:
data = response.json()
# 일반적인 JID 포맷 확인 필요
# 여기서는 간단히 헤더 우선으로 처리하고 없으면 에러 처리하거나 데이터 파싱
pass
except:
pass
return job_id
def import_system_configuration(self, share_parameters: Dict[str, Any], import_mode: str = "Replace", target: str = "ALL") -> str:
"""
시스템 설정 가져오기 (SCP Import)
:param share_parameters: 네트워크 공유 설정
:param import_mode: Replace, Append 등
:param target: 가져올 대상
:return: Job ID
"""
url = f"{self.host_url}/redfish/v1/Managers/iDRAC.Embedded.1/Actions/Oem/DellManager.ImportSystemConfiguration"
payload = {
"ImportSystemConfigurationXMLFile": share_parameters.get("FileName"),
"ShareParameters": share_parameters,
"ImportMode": import_mode,
"Target": target,
"ShutdownType": "Graceful" # 적용 후 재부팅 방식
}
logger.info(f"{self.ip}: Importing system configuration from {share_parameters.get('FileName')}")
response = self.session.post(url, json=payload, verify=False)
response.raise_for_status()
job_id = ""
if response.status_code == 202:
location = response.headers.get("Location")
if location:
job_id = location.split("/")[-1]
return job_id
def close(self):
"""세션 종료"""
self.session.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.close()
# 커스텀 예외
class AuthenticationError(Exception):
"""인증 실패"""
pass
class NotSupportedError(Exception):
"""지원하지 않는 기능"""
pass
+173
View File
@@ -0,0 +1,173 @@
"""
텔레그램 봇 폴링 서비스
- 백그라운드에서 텔레그램 봇의 업데이트를 폴링
- 인라인 버튼 클릭 처리 (가입 승인/거부)
"""
import logging
from typing import Optional
from telegram import Update
from telegram.ext import Application, CallbackQueryHandler, ContextTypes
from flask import Flask
from backend.models.telegram_bot import TelegramBot
from backend.models.user import User, db
logger = logging.getLogger(__name__)
async def handle_approval_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
텔레그램 인라인 버튼 클릭 처리
callback_data 형식: "approve_{token}" 또는 "reject_{token}"
"""
query = update.callback_query
await query.answer()
data = query.data or ""
logger.info("Received callback: %s", data)
# Flask app 객체는 bot_data에 저장해둔 것을 사용
flask_app: Optional[Flask] = context.application.bot_data.get("flask_app")
if flask_app is None:
logger.error("Flask app context is missing in bot_data")
await query.edit_message_text(
text="❌ 내부 설정 오류로 요청을 처리할 수 없습니다. 관리자에게 문의해 주세요."
)
return
# callback_data 형식 검증
if "_" not in data:
logger.warning("Invalid callback data format: %s", data)
await query.edit_message_text(
text="❌ 유효하지 않은 요청입니다."
)
return
try:
action, token = data.split("_", 1)
except ValueError:
logger.warning("Failed to split callback data: %s", data)
await query.edit_message_text(
text="❌ 유효하지 않은 요청입니다."
)
return
try:
with flask_app.app_context():
# 토큰으로 사용자 찾기
user = User.query.filter_by(approval_token=token).first()
if not user:
await query.edit_message_text(
text="❌ 유효하지 않은 승인 요청입니다.\n(이미 처리되었거나 만료된 요청)"
)
return
if action == "approve":
# 승인 처리
user.is_approved = True
user.is_active = True
user.approval_token = None # 토큰 무효화
db.session.commit()
await query.edit_message_text(
text=(
"✅ 승인 완료!\n\n"
f"👤 사용자: {user.username}\n"
f"📧 이메일: {user.email}\n\n"
"사용자가 이제 로그인할 수 있습니다."
)
)
logger.info("User %s approved", user.username)
elif action == "reject":
# 거부 처리 - 사용자 삭제
username = user.username
email = user.email
db.session.delete(user)
db.session.commit()
await query.edit_message_text(
text=(
"❌ 가입 거부됨\n\n"
f"👤 사용자: {username}\n"
f"📧 이메일: {email}\n\n"
"계정이 삭제되었습니다."
)
)
logger.info("User %s rejected and deleted", username)
else:
logger.warning("Unknown action in callback: %s", action)
await query.edit_message_text(
text="❌ 유효하지 않은 요청입니다."
)
except Exception as e:
logger.exception("Error handling callback: %s", e)
# 예외 내용은 사용자에게 직접 노출하지 않음
try:
db.session.rollback()
except Exception:
logger.exception("DB rollback failed")
await query.edit_message_text(
text="❌ 요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도하거나 관리자에게 문의해 주세요."
)
def run_polling(flask_app: Flask) -> None:
"""
동기 함수: 백그라운드 스레드에서 직접 호출됨
Application.run_polling() 이 내부에서 asyncio 이벤트 루프를 관리하므로
여기서는 asyncio.run 을 사용하지 않는다.
"""
if flask_app is None:
raise ValueError("flask_app is required for run_polling")
bot_token: Optional[str] = None
bot_name: Optional[str] = None
bot_id: Optional[int] = None
# DB에서 활성 봇 조회
with flask_app.app_context():
bots = TelegramBot.query.filter_by(is_active=True).all()
if not bots:
logger.warning("No active bots found for polling service.")
return
if len(bots) > 1:
logger.warning("Multiple active bots found. Only the first one (%s) will be used.", bots[0].name)
# 첫 번째 활성 봇 사용
bot = bots[0]
# DB 세션 밖에서도 사용할 수 있도록 필요한 정보만 추출 (Detached Instance 에러 방지)
bot_token = bot.token
bot_name = bot.name
bot_id = bot.id
logger.info("Starting polling for bot: %s (ID: %s)", bot_name, bot_id)
if not bot_token:
logger.error("Bot token not found.")
return
# Application 생성
application = Application.builder().token(bot_token).build()
# Flask app을 bot_data에 넣어서 핸들러에서 사용할 수 있게 함
application.bot_data["flask_app"] = flask_app
# 콜백 쿼리 핸들러 등록
application.add_handler(CallbackQueryHandler(handle_approval_callback))
try:
# v20 스타일: run_polling 은 동기 함수이고, 내부에서 이벤트 루프를 직접 관리함
application.run_polling(drop_pending_updates=True, stop_signals=[])
except Exception as e:
logger.exception("Error in bot polling: %s", e)
+50
View File
@@ -0,0 +1,50 @@
from __future__ import annotations
import logging
import os
from typing import Optional
from watchdog.events import FileSystemEventHandler
from flask_socketio import SocketIO
# 외부에서 주입되는 socketio 인스턴스 (app.py에서 설정)
socketio: Optional[SocketIO] = None
class FileCreatedHandler(FileSystemEventHandler):
"""파일 생성 감지를 처리하는 Watchdog 핸들러.
- temp_ip 등 임시 파일은 무시
- 감지 시 진행률/로그를 SocketIO로 실시간 브로드캐스트
"""
def __init__(self, job_id: str, total_files: int):
super().__init__()
self.job_id = job_id
self.total_files = max(int(total_files or 0), 0)
self.completed_files = 0
def _broadcast(self, event_name: str, data: dict) -> None:
if not socketio:
return
try:
socketio.emit(event_name, data, namespace="/")
except Exception as e:
logging.warning("[Watchdog] SocketIO 전송 실패: %s", e)
def _should_ignore(self, src_path: str) -> bool:
# 임시 업로드 디렉터리 하위 파일은 무시
return "temp_ip" in src_path.replace("\\", "/")
def on_created(self, event):
if event.is_directory:
return
if self._should_ignore(event.src_path):
return
self.completed_files = min(self.completed_files + 1, self.total_files or 0)
filename = os.path.basename(event.src_path)
msg = f"[Watchdog] 생성된 파일: {filename} ({self.completed_files}/{self.total_files})"
logging.info(msg)
self._broadcast("log_update", {"job_id": self.job_id, "log": msg})
if self.total_files:
progress = int((self.completed_files / self.total_files) * 100)
self._broadcast("progress", {"job_id": self.job_id, "progress": progress})