This commit is contained in:
unknown
2026-03-21 07:26:38 +09:00
parent 166d94fca4
commit 40150cd66d
406 changed files with 34179 additions and 55658 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+603 -5
View File
@@ -66,7 +66,7 @@ class DellRedfishClient:
'Mellanox', 'Emulex', 'QLogic'
]
print(f"전체 펌웨어 항목 수: {len(data.get('Members', []))}")
# 로그 간소화: 전체 펌웨어 항목 수 출력 제거
# 각 펌웨어 항목 조회
for idx, member in enumerate(data.get('Members', [])):
@@ -110,7 +110,7 @@ class DellRedfishClient:
# 진행 상황 출력
if (idx + 1) % 10 == 0:
print(f"조회 중... {idx + 1}/{len(data.get('Members', []))}")
pass # 로그 간소화: 진행률 출력 제거
except Exception as e:
print(f"펌웨어 항목 조회 오류 ({idx}): {str(e)}")
@@ -119,16 +119,16 @@ class DellRedfishClient:
# 이름순 정렬
inventory.sort(key=lambda x: x['Name'])
print(f"중요 펌웨어 항목 수: {len(inventory)}")
# 로그 간소화: 중요 펌웨어 항목 수 출력 제거
# 주요 컴포넌트 요약
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:
print(f"✓ BIOS 버전: {bios['Version']}")
pass # 로그 간소화: BIOS 버전 출력 제거
if idrac:
print(f"✓ iDRAC 버전: {idrac['Version']}")
pass # 로그 간소화: iDRAC 버전 출력 제거
return inventory
@@ -395,3 +395,601 @@ class DellRedfishClient:
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 {}
+26 -8
View File
@@ -54,8 +54,16 @@ def save_ip_addresses(ips: str, folder: str | os.PathLike[str]) -> list[tuple[st
# 개별 IP 처리
# ─────────────────────────────────────────────────────────────
def _build_command(script: str, ip_file: str, xml_file: str | None) -> list[str]:
script_path = Path(Config.SCRIPT_FOLDER) / script
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}")
@@ -73,14 +81,23 @@ def _build_command(script: str, ip_file: str, xml_file: str | None) -> list[str]
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) -> None:
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)
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:
@@ -91,6 +108,7 @@ def process_ip(ip_file: str, script: str, xml_file: str | None = None) -> None:
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:
@@ -105,14 +123,14 @@ def process_ip(ip_file: str, script: str, xml_file: str | None = None) -> None:
# 병렬 처리 진입점
# ─────────────────────────────────────────────────────────────
def process_ips_concurrently(ip_files, job_id, observer: Observer, script: str, xml_file: str | None):
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): ip for ip, ip_path in ip_files}
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:
@@ -135,7 +153,7 @@ def process_ips_concurrently(ip_files, job_id, observer: Observer, script: str,
# 외부에서 한 번에 처리(동기)
# ─────────────────────────────────────────────────────────────
def handle_ip_processing(ip_text: str, script: str, xml_file: str | None = None) -> str:
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
@@ -148,5 +166,5 @@ def handle_ip_processing(ip_text: str, script: str, xml_file: str | None = None)
observer.schedule(handler, Config.IDRAC_INFO_FOLDER, recursive=False)
observer.start()
process_ips_concurrently(ip_files, job_id, observer, script, xml_path)
process_ips_concurrently(ip_files, job_id, observer, script, xml_path, profile_name, None)
return job_id
+173
View File
@@ -0,0 +1,173 @@
"""
텔레그램 봇 폴링 서비스
- 백그라운드에서 텔레그램 봇의 업데이트를 폴링
- 인라인 버튼 클릭 처리 (가입 승인/거부)
"""
import logging
from typing import Optional
from telegram import Update
from telegram.ext import Application, CallbackQueryHandler, ContextTypes
from flask import Flask
from backend.models.telegram_bot import TelegramBot
from backend.models.user import User, db
logger = logging.getLogger(__name__)
async def handle_approval_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
"""
텔레그램 인라인 버튼 클릭 처리
callback_data 형식: "approve_{token}" 또는 "reject_{token}"
"""
query = update.callback_query
await query.answer()
data = query.data or ""
logger.info("Received callback: %s", data)
# Flask app 객체는 bot_data에 저장해둔 것을 사용
flask_app: Optional[Flask] = context.application.bot_data.get("flask_app")
if flask_app is None:
logger.error("Flask app context is missing in bot_data")
await query.edit_message_text(
text="❌ 내부 설정 오류로 요청을 처리할 수 없습니다. 관리자에게 문의해 주세요."
)
return
# callback_data 형식 검증
if "_" not in data:
logger.warning("Invalid callback data format: %s", data)
await query.edit_message_text(
text="❌ 유효하지 않은 요청입니다."
)
return
try:
action, token = data.split("_", 1)
except ValueError:
logger.warning("Failed to split callback data: %s", data)
await query.edit_message_text(
text="❌ 유효하지 않은 요청입니다."
)
return
try:
with flask_app.app_context():
# 토큰으로 사용자 찾기
user = User.query.filter_by(approval_token=token).first()
if not user:
await query.edit_message_text(
text="❌ 유효하지 않은 승인 요청입니다.\n(이미 처리되었거나 만료된 요청)"
)
return
if action == "approve":
# 승인 처리
user.is_approved = True
user.is_active = True
user.approval_token = None # 토큰 무효화
db.session.commit()
await query.edit_message_text(
text=(
"✅ 승인 완료!\n\n"
f"👤 사용자: {user.username}\n"
f"📧 이메일: {user.email}\n\n"
"사용자가 이제 로그인할 수 있습니다."
)
)
logger.info("User %s approved", user.username)
elif action == "reject":
# 거부 처리 - 사용자 삭제
username = user.username
email = user.email
db.session.delete(user)
db.session.commit()
await query.edit_message_text(
text=(
"❌ 가입 거부됨\n\n"
f"👤 사용자: {username}\n"
f"📧 이메일: {email}\n\n"
"계정이 삭제되었습니다."
)
)
logger.info("User %s rejected and deleted", username)
else:
logger.warning("Unknown action in callback: %s", action)
await query.edit_message_text(
text="❌ 유효하지 않은 요청입니다."
)
except Exception as e:
logger.exception("Error handling callback: %s", e)
# 예외 내용은 사용자에게 직접 노출하지 않음
try:
db.session.rollback()
except Exception:
logger.exception("DB rollback failed")
await query.edit_message_text(
text="❌ 요청 처리 중 오류가 발생했습니다. 잠시 후 다시 시도하거나 관리자에게 문의해 주세요."
)
def run_polling(flask_app: Flask) -> None:
"""
동기 함수: 백그라운드 스레드에서 직접 호출됨
Application.run_polling() 이 내부에서 asyncio 이벤트 루프를 관리하므로
여기서는 asyncio.run 을 사용하지 않는다.
"""
if flask_app is None:
raise ValueError("flask_app is required for run_polling")
bot_token: Optional[str] = None
bot_name: Optional[str] = None
bot_id: Optional[int] = None
# DB에서 활성 봇 조회
with flask_app.app_context():
bots = TelegramBot.query.filter_by(is_active=True).all()
if not bots:
logger.warning("No active bots found for polling service.")
return
if len(bots) > 1:
logger.warning("Multiple active bots found. Only the first one (%s) will be used.", bots[0].name)
# 첫 번째 활성 봇 사용
bot = bots[0]
# DB 세션 밖에서도 사용할 수 있도록 필요한 정보만 추출 (Detached Instance 에러 방지)
bot_token = bot.token
bot_name = bot.name
bot_id = bot.id
logger.info("Starting polling for bot: %s (ID: %s)", bot_name, bot_id)
if not bot_token:
logger.error("Bot token not found.")
return
# Application 생성
application = Application.builder().token(bot_token).build()
# Flask app을 bot_data에 넣어서 핸들러에서 사용할 수 있게 함
application.bot_data["flask_app"] = flask_app
# 콜백 쿼리 핸들러 등록
application.add_handler(CallbackQueryHandler(handle_approval_callback))
try:
# v20 스타일: run_polling 은 동기 함수이고, 내부에서 이벤트 루프를 직접 관리함
application.run_polling(drop_pending_updates=True, stop_signals=[])
except Exception as e:
logger.exception("Error in bot polling: %s", e)