Initial project upload
This commit is contained in:
@@ -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)}'
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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"])
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user