import os import platform import logging import subprocess import psutil from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QMessageBox, QFileDialog, QSpinBox, QDialog, QProgressBar, QComboBox, QCheckBox, QGroupBox, QFormLayout, QDialogButtonBox ) from PySide6.QtCore import Qt, Signal, QThread from vm_manager import VMManager import win32gui import win32con import win32process from PySide6.QtWidgets import QApplication import time logger = logging.getLogger("HyperVDeployer.GUI") class ImportProgressDialog(QDialog): """VM 가져오기 진행 상황을 표시하는 다이얼로그""" # 진행 상황 업데이트 시그널 update_total_progress = Signal(int, int) # current, total update_vm_progress = Signal(str, int) # status_text, progress_percent def __init__(self, total_vms, parent=None): super().__init__(parent) self.setWindowTitle("VM 가져오기") self.setWindowFlags(Qt.Dialog | Qt.WindowStaysOnTopHint) self.setModal(True) self.setMinimumWidth(400) # 레이아웃 설정 layout = QVBoxLayout(self) # 전체 진행 상황 self.total_label = QLabel(f"전체 진행 상황: 0/{total_vms}") layout.addWidget(self.total_label) self.total_progress = QProgressBar() self.total_progress.setRange(0, total_vms) self.total_progress.setValue(0) self.total_progress.setFormat("%v/%m (%p%)") layout.addWidget(self.total_progress) # 현재 VM 진행 상황 self.current_label = QLabel("현재 작업: 준비 중...") layout.addWidget(self.current_label) self.vm_progress = QProgressBar() self.vm_progress.setRange(0, 100) self.vm_progress.setValue(0) self.vm_progress.setFormat("%p%") layout.addWidget(self.vm_progress) # 시그널 연결 self.update_total_progress.connect(self._update_total) self.update_vm_progress.connect(self._update_vm) # 다이얼로그를 부모 위젯의 중앙에 위치시킴 if parent: self.move( parent.x() + parent.width()//2 - self.width()//2, parent.y() + parent.height()//2 - self.height()//2 ) def _update_total(self, current, total): """전체 진행 상황 업데이트""" self.total_label.setText(f"전체 진행 상황: {current}/{total}") self.total_progress.setValue(current) def _update_vm(self, status_text, progress_percent): """현재 VM 진행 상황 업데이트""" self.current_label.setText(f"현재 작업: {status_text}") self.vm_progress.setValue(progress_percent) class VMSettingsDialog(QDialog): """VM 설정 다이얼로그""" def __init__(self, settings, parent=None): super().__init__(parent) self.setWindowTitle("VM 설정") self.setModal(True) self.settings = settings.copy() layout = QVBoxLayout(self) # 메모리 설정 memory_group = QGroupBox("메모리 설정") memory_layout = QFormLayout() self.dynamic_memory = QCheckBox("동적 메모리 사용") self.dynamic_memory.setChecked(settings["dynamic_memory"]) self.dynamic_memory.stateChanged.connect(self._on_dynamic_memory_changed) memory_layout.addRow(self.dynamic_memory) self.memory_gb = QSpinBox() self.memory_gb.setRange(1, 64) self.memory_gb.setValue(settings["memory_gb"]) memory_layout.addRow("고정 메모리 (GB):", self.memory_gb) self.memory_min = QSpinBox() self.memory_min.setRange(1, 64) self.memory_min.setValue(settings["memory_minimum_gb"]) memory_layout.addRow("최소 메모리 (GB):", self.memory_min) self.memory_max = QSpinBox() self.memory_max.setRange(1, 64) self.memory_max.setValue(settings["memory_maximum_gb"]) memory_layout.addRow("최대 메모리 (GB):", self.memory_max) memory_group.setLayout(memory_layout) layout.addWidget(memory_group) # 프로세서 설정 processor_group = QGroupBox("프로세서 설정") processor_layout = QFormLayout() self.processor_count = QSpinBox() self.processor_count.setRange(1, 16) self.processor_count.setValue(settings["processor_count"]) processor_layout.addRow("프로세서 수:", self.processor_count) processor_group.setLayout(processor_layout) layout.addWidget(processor_group) # 네트워크 설정 network_group = QGroupBox("네트워크 설정") network_layout = QFormLayout() self.virtual_switch = QComboBox() # 가상 스위치 목록 가져오기 switches = self._get_virtual_switches() self.virtual_switch.addItems(switches) current_switch = settings["virtual_switch"] if current_switch in switches: self.virtual_switch.setCurrentText(current_switch) network_layout.addRow("가상 스위치:", self.virtual_switch) network_group.setLayout(network_layout) layout.addWidget(network_group) # 체크포인트 설정 checkpoint_group = QGroupBox("체크포인트 설정") checkpoint_layout = QFormLayout() self.checkpoint_type = QComboBox() self.checkpoint_type.addItems(["Standard", "Production", "ProductionOnly", "Disabled"]) self.checkpoint_type.setCurrentText(settings["checkpoint_type"]) checkpoint_layout.addRow("체크포인트 타입:", self.checkpoint_type) checkpoint_group.setLayout(checkpoint_layout) layout.addWidget(checkpoint_group) # 버튼 buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons) self._on_dynamic_memory_changed() def _on_dynamic_memory_changed(self): """동적 메모리 체크박스 상태에 따라 UI 업데이트""" is_dynamic = self.dynamic_memory.isChecked() self.memory_gb.setEnabled(not is_dynamic) self.memory_min.setEnabled(is_dynamic) self.memory_max.setEnabled(is_dynamic) def _get_virtual_switches(self): """Hyper-V 가상 스위치 목록 가져오기""" try: cmd = ( "Get-VMSwitch | " "Select-Object -ExpandProperty Name | " "ConvertTo-Json" ) result = subprocess.run( ["powershell", "-NoProfile", "-Command", cmd], capture_output=True, text=True ) if result.returncode == 0: import json switches = json.loads(result.stdout) if isinstance(switches, str): switches = [switches] return switches except Exception as e: logger.error("가상 스위치 목록 가져오기 실패: %s", e) return ["Default Switch"] def get_settings(self): """설정 값 반환""" self.settings.update({ "dynamic_memory": self.dynamic_memory.isChecked(), "memory_gb": self.memory_gb.value(), "memory_minimum_gb": self.memory_min.value(), "memory_maximum_gb": self.memory_max.value(), "processor_count": self.processor_count.value(), "virtual_switch": self.virtual_switch.currentText(), "checkpoint_type": self.checkpoint_type.currentText() }) return self.settings class VMDeployWorker(QThread): """VM 배포 작업을 처리하는 워커 스레드""" finished = Signal(bool, str) # success, error_message vm_progress = Signal(str, int) # status_text, progress_percent total_progress = Signal(int, int) # current, total def __init__(self, vm_manager, source_path, count, base_name): super().__init__() self.vm_manager = vm_manager self.source_path = source_path self.count = count self.base_name = base_name # 프로그레스 시그널 연결 self.vm_manager.progress_callback = self._progress_callback def _progress_callback(self, progress_type, *args): """VMManager로부터의 진행상황 콜백""" if progress_type == "vm": self.vm_progress.emit(*args) elif progress_type == "total": self.total_progress.emit(*args) def run(self): try: if os.path.isdir(self.source_path): extract_dir = self.source_path logger.info("로컬 폴더 사용: %s", self.source_path) else: # ZIP 다운로드 및 압축 해제 self.vm_progress.emit("ZIP 파일 다운로드 중...", 0) zip_path, tmp = self.vm_manager.download_zip(self.source_path) self.vm_progress.emit("ZIP 파일 압축 해제 중...", 10) extract_dir = os.path.join(tmp, "vm_folder") os.makedirs(extract_dir, exist_ok=True) self.vm_manager.extract_zip(zip_path, extract_dir) # VM 생성 self.vm_progress.emit("VM 가져오기 시작...", 20) self.vm_manager.import_multiple_vms(extract_dir, self.count, self.base_name) self.finished.emit(True, "") except Exception as e: logger.exception("VM 배포 실패") self.finished.emit(False, str(e)) class MainWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle("Hyper-V VM 자동 배포 도구") self.resize(700, 450) self.vm_manager = VMManager() # parent_widget은 나중에 설정 self.deploy_worker = None self.progress_dialog = None layout = QVBoxLayout(self) # ── 환경 점검 ── self.lbl_win = QLabel("Windows 버전: 점검 중...") self.lbl_cpu = QLabel("CPU 모델: 점검 중...") self.lbl_vtx = QLabel("가상화(FW) 활성화: 점검 중...") self.lbl_hv = QLabel("Hyper-V 설치 여부: 점검 중...") for lbl in (self.lbl_win, self.lbl_cpu, self.lbl_vtx, self.lbl_hv): lbl.setAlignment(Qt.AlignLeft) layout.addWidget(lbl) # ── 자원 체크 ── self.lbl_res = QLabel("자원 체크: 실행 후 확인") self.lbl_res.setAlignment(Qt.AlignLeft) layout.addWidget(self.lbl_res) row_count = QHBoxLayout() row_count.addWidget(QLabel("생성할 VM 개수:")) self.spin_count = QSpinBox() self.spin_count.setMinimum(1) self.spin_count.setMaximum(1) row_count.addWidget(self.spin_count) layout.addLayout(row_count) # ── URL 입력 ── row_url = QHBoxLayout() row_url.addWidget(QLabel("VM ZIP URL:")) self.le_url = QLineEdit() row_url.addWidget(self.le_url) layout.addLayout(row_url) # ── 로컬 폴더 ── row_folder = QHBoxLayout() row_folder.addWidget(QLabel("로컬 VM 폴더:")) self.le_folder = QLineEdit() row_folder.addWidget(self.le_folder) btn_browse = QPushButton("폴더 선택") btn_browse.clicked.connect(self.browse_folder) row_folder.addWidget(btn_browse) layout.addLayout(row_folder) # ── VM 이름 ── row_name = QHBoxLayout() row_name.addWidget(QLabel("베이스 VM 이름:")) self.le_vmname = QLineEdit("PartTimerVM") row_name.addWidget(self.le_vmname) layout.addLayout(row_name) # ── 실행 버튼 ── self.btn_run = QPushButton("가져오기 및 설정 실행") self.btn_run.clicked.connect(self.on_run) layout.addWidget(self.btn_run, alignment=Qt.AlignCenter) # 최초 점검 self.run_env_checks() self.run_res_checks() def browse_folder(self): fld = QFileDialog.getExistingDirectory(self, "VM 폴더 선택") if fld: self.le_folder.setText(fld) def run_env_checks(self): errors = [] warnings = [] # Windows 버전 체크 if platform.system() != "Windows": errors.append({ "title": "운영체제 오류", "message": "이 프로그램은 Windows에서만 실행할 수 있습니다.", "solution": "Windows 10 이상의 운영체제에서 실행해주세요." }) elif int(platform.version().split(".")[0]) < 10: errors.append({ "title": "Windows 버전 오류", "message": f"현재 Windows 버전({platform.release()})이 지원되지 않습니다.", "solution": "Windows 10 이상으로 업그레이드하거나, 최신 Windows 업데이트를 설치해주세요." }) else: self.lbl_win.setText(f"Windows {platform.release()} — OK") self.lbl_win.setStyleSheet("color: green;") # CPU 정보 & 가상화(FW) 활성화 try: info = self.vm_manager.get_cpu_info() name = info.get("Name", "").strip() virt = info.get("VirtualizationFirmwareEnabled", False) self.lbl_cpu.setText(f"CPU 모델: {name}") self.lbl_cpu.setStyleSheet("color: black;") if not virt: errors.append({ "title": "가상화 비활성화", "message": "BIOS/UEFI에서 가상화가 비활성화되어 있습니다.", "solution": ( "다음 단계를 따라 가상화를 활성화해주세요:\n" "1. PC를 재시작하고 BIOS/UEFI 설정으로 진입\n" "2. 'Virtualization Technology', 'Intel VT-x/AMD-V' 또는 'SVM Mode' 찾기\n" "3. 해당 옵션을 활성화\n" "4. 설정 저장 후 재시작" ) }) else: self.lbl_vtx.setText("가상화(FW) 활성화됨 — OK") self.lbl_vtx.setStyleSheet("color: green;") except Exception as e: logger.exception("CPU 점검 오류") errors.append({ "title": "CPU 정보 오류", "message": "CPU 정보를 확인할 수 없습니다.", "solution": "관리자 권한으로 프로그램을 실행해주세요." }) # Hyper-V 설치 여부 try: state = subprocess.run( ["powershell","-NoProfile","-Command", "(Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All).State"], capture_output=True, text=True ).stdout.strip().lower() if state != "enabled": errors.append({ "title": "Hyper-V 미설치", "message": "Hyper-V가 설치되어 있지 않습니다.", "solution": ( "다음 방법 중 하나로 Hyper-V를 설치해주세요:\n\n" "방법 1) Windows 기능 켜기/끄기 사용:\n" "1. 제어판 → 프로그램 → Windows 기능 켜기/끄기\n" "2. 'Hyper-V' 체크박스 선택\n" "3. 확인 후 재시작\n\n" "방법 2) PowerShell 사용 (관리자 권한):\n" "1. PowerShell을 관리자 권한으로 실행\n" "2. 다음 명령어 실행:\n" " Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All\n" "3. 완료 후 재시작" ) }) else: self.lbl_hv.setText("Hyper-V 설치됨 — OK") self.lbl_hv.setStyleSheet("color: green;") except Exception: errors.append({ "title": "Hyper-V 확인 오류", "message": "Hyper-V 설치 상태를 확인할 수 없습니다.", "solution": "관리자 권한으로 프로그램을 실행해주세요." }) # 메모리 체크 try: mem = psutil.virtual_memory() if mem.available < 4 * 1024 * 1024 * 1024: # 4GB warnings.append({ "title": "메모리 부족", "message": f"사용 가능한 메모리가 부족합니다. (현재 여유: {mem.available/(1024**3):.1f}GB)", "solution": ( "원활한 VM 실행을 위해:\n" "1. 불필요한 프로그램을 종료하거나\n" "2. VM 설정에서 메모리 크기를 줄이세요." ) }) except Exception: warnings.append({ "title": "메모리 확인 오류", "message": "메모리 상태를 확인할 수 없습니다.", "solution": "관리자 권한으로 프로그램을 실행해주세요." }) # 오류/경고 메시지 표시 if errors: error_text = "다음 문제들을 해결해야 VM을 생성할 수 있습니다:\n\n" for error in errors: error_text += f"[{error['title']}]\n" error_text += f"문제: {error['message']}\n" error_text += f"해결방법:\n{error['solution']}\n\n" QMessageBox.critical(self, "환경 설정 오류", error_text) self.btn_run.setEnabled(False) elif warnings: warning_text = "다음 문제들이 발견되었습니다:\n\n" for warning in warnings: warning_text += f"[{warning['title']}]\n" warning_text += f"문제: {warning['message']}\n" warning_text += f"권장사항:\n{warning['solution']}\n\n" warning_text += "\nVM 생성은 가능하지만, 성능에 영향을 줄 수 있습니다." QMessageBox.warning(self, "환경 설정 경고", warning_text) self.btn_run.setEnabled(True) else: self.btn_run.setEnabled(True) def run_res_checks(self): tot_m, free_m, tot_d, free_d, max_v = self.vm_manager.check_resources() self.lbl_res.setText( f"메모리: {free_m:.1f}/{tot_m:.1f} GB, " f"디스크: {free_d:.1f}/{tot_d:.1f} GB → " f"추천 생성 가능 VM: {max_v}대" ) self.spin_count.setMaximum(max_v if max_v>0 else 1) # self.spin_count.setValue(max_v if max_v>0 else 1) self.spin_count.setValue(1) def on_run(self): url = self.le_url.text().strip() folder = self.le_folder.text().strip() base = self.le_vmname.text().strip() count = self.spin_count.value() if not base or (not url and not folder): QMessageBox.warning(self, "입력 오류", "이름과 URL/폴더를 모두 지정하세요.") return # VM 설정 다이얼로그 표시 settings_dialog = VMSettingsDialog(self.vm_manager.DEFAULT_SETTINGS, self) if settings_dialog.exec() != QDialog.Accepted: return # VM 매니저에 설정 적용 self.vm_manager.settings.update(settings_dialog.get_settings()) # 최신 자원 정보 갱신 self.run_res_checks() # 프로그레스 다이얼로그 초기화 및 표시 if self.progress_dialog: self.progress_dialog.close() self.progress_dialog = ImportProgressDialog(count, self) self.progress_dialog.setWindowModality(Qt.ApplicationModal) self.progress_dialog.show() QApplication.processEvents() # UI 업데이트 강제 # VM 매니저에 프로그레스 다이얼로그 연결 self.vm_manager.parent_widget = self # 배포 작업 시작 source = folder if folder else url self.deploy_worker = VMDeployWorker(self.vm_manager, source, count, base) # 시그널 연결 self.deploy_worker.finished.connect(self._on_deploy_finished) self.deploy_worker.vm_progress.connect(self.progress_dialog.update_vm_progress.emit) self.deploy_worker.total_progress.connect(self.progress_dialog.update_total_progress.emit) # 작업 시작 self.deploy_worker.start() # 버튼 비활성화 self.btn_run.setEnabled(False) def _on_deploy_finished(self, success, error_message): """배포 완료 처리""" self.progress_dialog.accept() if success: message = ("VM 배포 완료!\n\n" "VM이 중지 상태로 생성되었습니다.\n" "Hyper-V 관리자에서 다음 설정을 확인한 후 VM을 시작해주세요:\n\n" "• 메모리 설정 (동적 메모리 포함)\n" "• 프로세서 설정\n" "• 네트워크 어댑터 설정\n" "• 가상 하드 디스크 경로\n\n" "Hyper-V 관리자를 열까요?") reply = QMessageBox.question( self, "배포 완료", message, QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes ) if reply == QMessageBox.Yes: try: self.open_hyperv_manager() except Exception as e: logger.warning("Hyper-V 관리자 실행 중 오류 발생: %s", e) QMessageBox.information( self, "알림", "Hyper-V 관리자를 자동으로 열 수 없습니다.\n" "수동으로 'virtmgmt.msc'를 실행하거나\n" "시작 메뉴에서 'Hyper-V 관리자'를 검색하여 실행해주세요." ) else: QMessageBox.critical( self, "배포 실패", f"VM 배포 중 오류가 발생했습니다:\n\n{error_message}" ) # UI 상태 복원 self.setEnabled(True) self.btn_run.setText("배포 시작") self.btn_run.setEnabled(True) def open_hyperv_manager(self): """Hyper-V 관리자 실행""" try: # virtmgmt.msc 실행 proc = subprocess.Popen(["mmc", "virtmgmt.msc"]) # 잠시 대기 후 창을 앞으로 가져오기 시도 QApplication.processEvents() time.sleep(2) try: import win32gui import win32con def enum_win(hwnd, pid): if win32gui.GetWindowThreadProcessId(hwnd)[1] == pid: try: # 창이 보이는 경우에만 활성화 시도 if win32gui.IsWindowVisible(hwnd): win32gui.ShowWindow(hwnd, win32con.SW_RESTORE) win32gui.SetForegroundWindow(hwnd) except Exception as e: # SetForegroundWindow 실패는 무시하고 계속 진행 logger.warning("창 활성화 실패 (무시됨): %s", e) return False # 첫 번째 창만 처리 return True # 프로세스 정보가 있는 경우에만 창 활성화 시도 if hasattr(proc, 'pid') and proc.pid: win32gui.EnumWindows(enum_win, proc.pid) except ImportError: logger.info("win32gui 모듈이 없어 창 활성화를 건너뜁니다.") except Exception as e: logger.warning("창 활성화 중 오류 발생 (무시됨): %s", e) except Exception as e: logger.error("Hyper-V 관리자 실행 실패: %s", e) QMessageBox.warning(self, "오류", f"Hyper-V 관리자를 실행할 수 없습니다:\n{e}")