# vm_manager.py import os import tempfile import subprocess import requests import zipfile import psutil import logging import json from pathlib import Path import shutil import glob from PySide6.QtWidgets import QApplication logger = logging.getLogger("HyperVDeployer.VMManager") class VMManager: DEFAULT_SETTINGS = { "base_name": "PartTimer", "memory_gb": 8, "disk_gb": 32, "virtual_switch": "Default Switch", "processor_count": 2, "dynamic_memory": True, "memory_minimum_gb": 2, "memory_maximum_gb": 16, "memory_startup_gb": 4, "checkpoint_type": "Standard", # Standard, Production, ProductionOnly, Disabled "integration_services": { "Guest Service Interface": True, "Heartbeat": True, "Key-Value Pair Exchange": True, "Shutdown": True, "Time Synchronization": True, "VSS": True }, "automatic_stop_action": "Save", # Save, ShutDown, TurnOff "automatic_start_action": "Nothing", # Nothing, StartIfRunning, Start "smart_paging_file_path": None, # None = VHD 경로와 동일 "snapshot_file_path": None, # None = VHD 경로와 동일 } def __init__(self, workdir=None, settings=None, parent_widget=None): """ workdir: 작업용 임시 폴더 (None이면 시스템 tmp) settings: VM 설정 딕셔너리 (None이면 기본값 사용) parent_widget: 프로그레스 다이얼로그의 부모 위젯 """ self.workdir = workdir self.settings = {**self.DEFAULT_SETTINGS, **(settings or {})} self.parent_widget = parent_widget self.progress_callback = None def get_cpu_info(self): """ WMI로 Win32_Processor 정보(JSON) 읽기 """ cmd = ( "Get-WmiObject Win32_Processor | " "Select Name,VirtualizationFirmwareEnabled | " "ConvertTo-Json" ) out = self._run_powershell(cmd) try: info = json.loads(out) except json.JSONDecodeError as e: logger.error("CPU 정보 JSON 파싱 실패: %s", e) raise RuntimeError("CPU 정보 파싱 실패") if isinstance(info, list): info = info[0] logger.info("CPU 정보: %s", info) return info def list_vms(self): """ 현재 호스트에 등록된 VM 이름 목록을 반환(set) """ out = self._run_powershell( 'Get-VM | Select -ExpandProperty Name | ConvertTo-Json' ) try: names = json.loads(out) if isinstance(names, str): names = [names] except json.JSONDecodeError: names = [] return set(names) def check_resources(self, base_path=None): """ 호스트 메모리/디스크(총/여유) 확인 및 VM 최대 생성 대수 계산 return: (total_mem_gb, free_mem_gb, total_disk_gb, free_disk_gb, max_vms) """ base = base_path or os.getcwd() mem = psutil.virtual_memory() total_mem = mem.total / (1024**3) free_mem = mem.available / (1024**3) disk = psutil.disk_usage(base) total_disk = disk.total / (1024**3) free_disk = disk.free / (1024**3) max_by_mem = int(free_mem // self.settings["memory_gb"]) max_by_disk = int(free_disk // self.settings["disk_gb"]) max_vms = min(max_by_mem, max_by_disk) logger.info( "자원: 전체메모리=%.2fGB, 여유메모리=%.2fGB, 전체디스크=%.2fGB, 여유디스크=%.2fGB, 추천VM=%d", total_mem, free_mem, total_disk, free_disk, max_vms ) return total_mem, free_mem, total_disk, free_disk, max_vms def download_zip(self, url): """ URL에서 ZIP 파일 다운로드 return: (zip_path, tmp_root) """ tmp_root = self.workdir or tempfile.mkdtemp(prefix="hv_import_") local_zip = os.path.join(tmp_root, "vm_export.zip") logger.info("ZIP 다운로드 시작: %s", url) with requests.get(url, stream=True) as r: r.raise_for_status() with open(local_zip, "wb") as f: for chunk in r.iter_content(chunk_size=8192): f.write(chunk) logger.info("ZIP 다운로드 완료: %s", local_zip) return local_zip, tmp_root def extract_zip(self, zip_path, extract_to): """ ZIP 압축 해제 """ logger.info("ZIP 압축 해제: %s → %s", zip_path, extract_to) with zipfile.ZipFile(zip_path, "r") as z: z.extractall(extract_to) logger.info("압축 해제 완료") def _get_default_vhd_path(self): """ Hyper-V의 기본 VHD 경로를 가져옴 """ cmd = ( "Get-VMHost | " "Select-Object -ExpandProperty VirtualHardDiskPath" ) try: vhd_path = self._run_powershell(cmd) logger.info("기본 VHD 경로: %s", vhd_path) return vhd_path except RuntimeError as e: logger.warning("기본 VHD 경로를 가져오는데 실패했습니다: %s", e) return "C:\\ProgramData\\Microsoft\\Windows\\Virtual Hard Disks" def _generate_unique_name(self, base_name="PartTimer"): """ 사용 가능한 고유한 VM 이름 생성 base_name에 1부터 시작하는 숫자를 붙여가며 사용 가능한 이름 찾기 """ existing_vms = self.list_vms() counter = 1 vhd_base_path = self._get_default_vhd_path() while True: new_name = f"{base_name}{counter}" vhdx_path = os.path.join(vhd_base_path, f"{new_name}.vhdx") if new_name not in existing_vms and not os.path.exists(vhdx_path): return new_name counter += 1 def _check_powershell_version(self): """ PowerShell 버전과 Hyper-V 모듈 버전 확인 """ ps_version_cmd = "$PSVersionTable.PSVersion.Major" hyperv_version_cmd = ( "Get-Module -Name Hyper-V -ListAvailable | " "Select-Object -ExpandProperty Version | " "Select-Object -First 1" ) try: ps_version = int(self._run_powershell(ps_version_cmd)) hyperv_version = self._run_powershell(hyperv_version_cmd) logger.info("PowerShell 버전: %d, Hyper-V 모듈 버전: %s", ps_version, hyperv_version) return ps_version, hyperv_version except (ValueError, RuntimeError) as e: logger.warning("버전 확인 실패: %s", e) return None, None def _set_network_adapter(self, vm_name): """ VM 네트워크 어댑터 설정 (강화된 버전) """ logger.info("VM 네트워크 어댑터 설정 시작: %s", vm_name) try: # 먼저 현재 네트워크 어댑터 상태 확인 check_cmd = f'Get-VMNetworkAdapter -VMName "{vm_name}" | Select-Object Name, MacAddress, DynamicMacAddressEnabled | ConvertTo-Json' current_state = self._run_powershell(check_cmd) logger.info("현재 네트워크 어댑터 상태: %s", current_state) # 이미 동적 MAC 주소가 활성화되어 있는지 확인 try: adapter_info = json.loads(current_state) if adapter_info.get("DynamicMacAddressEnabled") == True: logger.info("동적 MAC 주소가 이미 활성화되어 있음") return except (json.JSONDecodeError, KeyError): logger.warning("네트워크 어댑터 상태 파싱 실패, 설정 계속 진행") # 동적 MAC 주소 설정 시도 success = False commands = [ # 올바른 매개변수 사용 f'Set-VMNetworkAdapter -VMName "{vm_name}" -DynamicMacAddressEnabled $true', # 대체 방법 1 - VM 객체를 통한 설정 f'Get-VM -Name "{vm_name}" | Get-VMNetworkAdapter | Set-VMNetworkAdapter -DynamicMacAddressEnabled $true', # 대체 방법 2 - 어댑터별 설정 f'Get-VMNetworkAdapter -VMName "{vm_name}" | ForEach-Object {{ Set-VMNetworkAdapter -VMNetworkAdapter $_ -DynamicMacAddressEnabled $true }}' ] for i, cmd in enumerate(commands, 1): try: logger.info("네트워크 어댑터 설정 시도 %d/%d", i, len(commands)) self._run_powershell(cmd) success = True logger.info("네트워크 어댑터 설정 성공 (방법 %d)", i) break except RuntimeError as e: logger.warning("네트워크 어댑터 설정 실패 (방법 %d): %s", i, e) continue if success: # 설정 확인 try: final_state = self._run_powershell(check_cmd) logger.info("변경 후 네트워크 어댑터 상태: %s", final_state) except Exception as e: logger.warning("네트워크 어댑터 상태 확인 실패: %s", e) else: logger.error("모든 네트워크 어댑터 설정 시도 실패") # 네트워크 설정 실패해도 VM 생성은 계속 진행 logger.info("네트워크 설정 실패했지만 VM 생성 계속 진행") except Exception as e: logger.error("네트워크 어댑터 설정 중 예외 발생: %s", e) # 네트워크 설정 실패해도 VM 생성은 계속 진행 logger.info("네트워크 설정 예외 발생했지만 VM 생성 계속 진행") def _generate_vm_id(self): """새로운 VM ID(GUID) 생성""" cmd = "(New-Guid).Guid" return self._run_powershell(cmd) def _modify_vm_config(self, src_config, dst_config, target_name): """ VM 구성 파일(XML)의 설정 수정 """ import xml.etree.ElementTree as ET tree = ET.parse(src_config) root = tree.getroot() # 메모리 설정 memory = root.find(".//memory") if memory is not None: if self.settings["dynamic_memory"]: memory.set("dynamic", "true") memory.set("minimum", str(self.settings["memory_minimum_gb"] * 1024 * 1024 * 1024)) memory.set("maximum", str(self.settings["memory_maximum_gb"] * 1024 * 1024 * 1024)) memory.set("startup", str(self.settings["memory_startup_gb"] * 1024 * 1024 * 1024)) else: memory.set("dynamic", "false") memory.set("startup", str(self.settings["memory_gb"] * 1024 * 1024 * 1024)) # 프로세서 설정 processor = root.find(".//processor") if processor is not None: processor.set("count", str(self.settings["processor_count"])) # 체크포인트 타입 checkpoint = root.find(".//checkpoint") if checkpoint is not None: checkpoint.set("type", self.settings["checkpoint_type"]) # 자동 시작/중지 동작 auto_start = root.find(".//auto_start") if auto_start is not None: auto_start.set("action", self.settings["automatic_start_action"]) auto_stop = root.find(".//auto_stop") if auto_stop is not None: auto_stop.set("action", self.settings["automatic_stop_action"]) # 통합 서비스 설정 integration_services = root.find(".//integration_services") if integration_services is not None: for service in integration_services.findall("service"): name = service.get("name") if name in self.settings["integration_services"]: service.set("enabled", "true" if self.settings["integration_services"][name] else "false") # 네트워크 어댑터 설정 for adapter in root.findall(".//network_adapter"): switch_name = adapter.find("switch_name") if switch_name is not None: switch_name.text = self.settings["virtual_switch"] # 수정된 설정 저장 tree.write(dst_config) logger.info("VM 구성 파일 수정 완료: %s", dst_config) def _prepare_vm_files(self, vm_export_path, target_name): """ VM 파일들을 임시 폴더에 복사하고 설정을 수정 """ # 경로 정규화 vm_export_path = os.path.normpath(vm_export_path) logger.info("VM 내보내기 경로: %s", vm_export_path) # 임시 폴더 생성 temp_export = os.path.join(self.workdir or tempfile.mkdtemp(), "temp_export") os.makedirs(temp_export, exist_ok=True) try: # 새로운 VM ID 생성 (로그용) new_vm_id = self._generate_vm_id() logger.info("새 VM ID 생성: %s", new_vm_id) # Hyper-V 표준 폴더 구조 확인 vm_folders = { "vm": os.path.join(vm_export_path, "Virtual Machines"), "vhd": os.path.join(vm_export_path, "Virtual Hard Disks"), "snapshot": os.path.join(vm_export_path, "Snapshots") } # 폴더 존재 여부 확인 found_folders = 0 for folder_type, folder_path in vm_folders.items(): if os.path.exists(folder_path): logger.info("VM %s 폴더 발견: %s", folder_type, folder_path) found_folders += 1 else: logger.warning("VM %s 폴더 없음: %s", folder_type, folder_path) # 표준 폴더가 하나도 없으면 상위 폴더 확인 if found_folders == 0: parent_path = os.path.dirname(vm_export_path) if os.path.exists(os.path.join(parent_path, "Virtual Machines")): logger.info("상위 폴더에서 VM 폴더 구조 발견") vm_export_path = parent_path vm_folders = { "vm": os.path.join(vm_export_path, "Virtual Machines"), "vhd": os.path.join(vm_export_path, "Virtual Hard Disks"), "snapshot": os.path.join(vm_export_path, "Snapshots") } # 모든 VM 관련 파일 복사 vm_files = [] # Virtual Machines 폴더에서 구성 파일 검색 for ext in ['*.vmcx', '*.xml']: pattern = os.path.join(vm_folders["vm"], "**", ext) vm_files.extend(glob.glob(pattern, recursive=True)) # Virtual Hard Disks 폴더에서 디스크 파일 검색 for ext in ['*.vhd*']: pattern = os.path.join(vm_folders["vhd"], "**", ext) vm_files.extend(glob.glob(pattern, recursive=True)) # 상태 파일들은 존재하는 경우에만 포함 (.vmrs, .vmgs) state_files = [] for ext in ['*.vmrs', '*.vmgs', '*.vsv']: pattern = os.path.join(vm_folders["vm"], "**", ext) found_files = glob.glob(pattern, recursive=True) for file in found_files: if os.path.exists(file) and os.path.getsize(file) > 0: state_files.append(file) logger.info("상태 파일 발견: %s", file) else: logger.warning("상태 파일이 비어있거나 손상됨: %s", file) vm_files.extend(state_files) # 루트 폴더에서도 검색 (예외적인 경우) for ext in ['*.vmcx', '*.vhd*', '*.xml']: pattern = os.path.join(vm_export_path, ext) vm_files.extend(glob.glob(pattern)) if not vm_files: raise RuntimeError( f"VM 파일을 찾을 수 없습니다. 다음 경로를 확인해주세요:\n" f"- {vm_folders['vm']}\n" f"- {vm_folders['vhd']}\n" f"- {vm_folders['snapshot']}" ) logger.info("발견된 VM 파일들:") for file in vm_files: logger.info("- %s", file) # 필수 파일 확인 (.vmcx와 .vhdx) has_vmcx = any(f.lower().endswith('.vmcx') for f in vm_files) has_vhdx = any(f.lower().endswith('.vhdx') for f in vm_files) if not has_vmcx: raise RuntimeError("VM 구성 파일(.vmcx)을 찾을 수 없습니다.") if not has_vhdx: raise RuntimeError("VM 디스크 파일(.vhdx)을 찾을 수 없습니다.") # 파일 복사 및 이름 변경 copied_files = [] for file in vm_files: try: # 파일 존재 및 크기 확인 if not os.path.exists(file): logger.warning("파일이 존재하지 않음: %s", file) continue file_size = os.path.getsize(file) if file_size == 0: logger.warning("빈 파일 건너뜀: %s", file) continue base_name = os.path.basename(file) ext = os.path.splitext(base_name)[1].lower() # 파일 종류에 따라 새 이름 결정 (모든 파일에 target_name 적용) if ext == '.vhdx': new_name = os.path.join(temp_export, f"{target_name}.vhdx") elif ext == '.vmcx': new_name = os.path.join(temp_export, f"{target_name}.vmcx") elif ext == '.vmrs': new_name = os.path.join(temp_export, f"{target_name}.vmrs") elif ext == '.vmgs': new_name = os.path.join(temp_export, f"{target_name}.vmgs") else: # 기타 파일들은 원본 이름에 target_name 접두사 추가 name_part = os.path.splitext(base_name)[0] new_name = os.path.join(temp_export, f"{target_name}_{base_name}") # 파일이 이미 존재하면 덮어쓰기 if os.path.exists(new_name): os.remove(new_name) shutil.copy2(file, new_name) copied_files.append(new_name) logger.info("파일 복사: %s → %s (크기: %d bytes)", file, new_name, file_size) except PermissionError: logger.warning("권한 오류로 파일 복사 실패: %s", file) # 관리자 권한으로 복사 시도 try: cmd = f'Copy-Item -Path "{file}" -Destination "{new_name}" -Force' self._run_powershell(cmd) copied_files.append(new_name) logger.info("관리자 권한으로 파일 복사 성공: %s", file) except Exception as e: logger.error("관리자 권한으로도 파일 복사 실패: %s - %s", file, e) # 필수 파일이 아니면 계속 진행 if not (file.lower().endswith('.vmcx') or file.lower().endswith('.vhdx')): continue raise except Exception as e: logger.error("파일 복사 중 오류: %s - %s", file, e) # 필수 파일이 아니면 계속 진행 if not (file.lower().endswith('.vmcx') or file.lower().endswith('.vhdx')): continue raise logger.info("복사된 파일 목록:") for file in copied_files: logger.info("- %s", file) # 구성 파일 찾기 config = self._find_config(temp_export) # XML 파일인 경우 설정 수정 if config.lower().endswith('.xml'): self._modify_vm_config(config, config, target_name) return temp_export, config except Exception as e: logger.error("VM 파일 준비 실패: %s", e) try: shutil.rmtree(temp_export) except Exception as cleanup_error: logger.warning("임시 폴더 삭제 실패: %s", cleanup_error) raise def import_vm(self, vm_export_path, target_name=None): """ VM 가져오기 및 설정 """ try: # PowerShell/Hyper-V 버전 체크 ps_version, hyperv_version = self._check_powershell_version() logger.info("시스템 버전 - PowerShell: %s, Hyper-V: %s", ps_version, hyperv_version) self._update_vm_progress("시스템 버전 확인 완료", 10) if target_name is None: target_name = self._generate_unique_name(self.settings["base_name"]) else: # target_name이 이미 사용 중이면 새로운 이름 생성 vhd_base_path = self._get_default_vhd_path() vhdx_path = os.path.join(vhd_base_path, f"{target_name}.vhdx") if target_name in self.list_vms() or os.path.exists(vhdx_path): logger.info("지정된 이름 %s이(가) 이미 사용 중입니다. 새 이름을 생성합니다.", target_name) target_name = self._generate_unique_name(target_name) self._update_vm_progress("VM 이름 생성 완료", 20) # VM 파일 준비 temp_export, new_config = self._prepare_vm_files(vm_export_path, target_name) self._update_vm_progress("VM 파일 준비 완료", 30) try: # VHDX 파일 경로 설정 vhd_base_path = self._get_default_vhd_path() smart_paging_path = self.settings["smart_paging_file_path"] or vhd_base_path snapshot_path = self.settings["snapshot_file_path"] or vhd_base_path # 대상 경로에 쓰기 권한 확인 및 폴더 생성 for path in [vhd_base_path, smart_paging_path, snapshot_path]: os.makedirs(path, exist_ok=True) # Import 전 VM 목록 저장 before_vms = self.list_vms() logger.info("Import 전 VM 목록: %s", before_vms) # Compare-VM으로 구성 검사 및 수정 logger.info("VM 구성 검사 시작") # 먼저 단순한 Import-VM 시도 (상태 파일 무시) try: logger.info("단순 Import-VM 시도 (상태 파일 무시)") simple_import_cmd = ( f'Import-VM -Path "{new_config}" ' f'-Copy -GenerateNewId ' f'-VhdDestinationPath "{vhd_base_path}" ' f'-SnapshotFilePath "{snapshot_path}" ' f'-SmartPagingFilePath "{smart_paging_path}"' ) logger.info("Import-VM 명령어: %s", simple_import_cmd) self._run_powershell(simple_import_cmd) logger.info("단순 Import-VM 성공") except Exception as simple_error: logger.warning("단순 Import-VM 실패: %s", simple_error) # Compare-VM으로 문제 분석 및 해결 시도 try: logger.info("Compare-VM으로 문제 분석 시작") compare_cmd = f'Compare-VM -Path "{new_config}" | ConvertTo-Json -Depth 5' compare_result = self._run_powershell(compare_cmd) logger.info("Compare-VM 결과: %s", compare_result) # Compare-VM 결과를 이용한 복구 시도 repair_cmd = ( f'$compareResult = Compare-VM -Path "{new_config}"; ' f'if ($compareResult.Incompatibilities) {{ ' f' Write-Host "호환성 문제 발견, 복구 시도"; ' f' $compareResult | Import-VM -Copy -GenerateNewId ' f' -VhdDestinationPath "{vhd_base_path}" ' f' -SnapshotFilePath "{snapshot_path}" ' f' -SmartPagingFilePath "{smart_paging_path}"; ' f'}} else {{ ' f' Write-Host "호환성 문제 없음, 직접 Import"; ' f' Import-VM -Path "{new_config}" ' f' -Copy -GenerateNewId ' f' -VhdDestinationPath "{vhd_base_path}" ' f' -SnapshotFilePath "{snapshot_path}" ' f' -SmartPagingFilePath "{smart_paging_path}"; ' f'}}' ) logger.info("복구 Import-VM 실행: %s", repair_cmd) self._run_powershell(repair_cmd) logger.info("복구 Import-VM 성공") except Exception as repair_error: logger.error("복구 Import-VM도 실패: %s", repair_error) # 마지막 시도: Register 모드 (파일 이동 없이) try: logger.info("Register 모드로 최종 시도") register_cmd = f'Import-VM -Path "{new_config}" -Register -GenerateNewId' logger.info("Register Import-VM 명령어: %s", register_cmd) self._run_powershell(register_cmd) logger.info("Register Import-VM 성공") except Exception as register_error: logger.error("Register Import-VM도 실패: %s", register_error) raise RuntimeError( f"모든 Import-VM 시도 실패:\n" f"1. 단순 Import: {simple_error}\n" f"2. 복구 Import: {repair_error}\n" f"3. Register Import: {register_error}" ) self._update_vm_progress("VM 가져오기 완료", 50) finally: # 임시 폴더 정리 try: shutil.rmtree(temp_export) except Exception as e: logger.warning("임시 폴더 삭제 실패: %s", e) after = self.list_vms() logger.info("Import 후 VM 목록: %s", after) # 먼저 목표 이름으로 VM이 생성되었는지 확인 if target_name in after: new_name = target_name logger.info("목표 이름으로 VM 생성 확인: %s", new_name) else: # VM 목록 차이로 새로 추가된 VM 찾기 added = after - before_vms if not added: # VM 목록에서 직접 찾기 시도 logger.warning("VM 목록 차이로 찾지 못함, 직접 검색 시도") potential_vms = [vm for vm in after if target_name in vm or vm.startswith(self.settings["base_name"])] if potential_vms: new_name = potential_vms[0] logger.info("직접 검색으로 VM 발견: %s", new_name) else: raise RuntimeError(f"Import된 VM을 찾을 수 없습니다. 현재 VM 목록: {after}") else: new_name = added.pop() logger.info("VM 목록 차이로 새 VM 발견: %s", new_name) # VM 이름 변경 (강화된 로직) logger.info("현재 VM 이름: '%s', 목표 이름: '%s'", new_name, target_name) # 이미 목표 이름이면 이름 변경 불필요 if new_name == target_name: logger.info("VM이 이미 목표 이름으로 생성됨: %s", target_name) else: # 이름이 다르거나 Import된 VM의 기본 패턴인 경우 강제 변경 should_rename = (new_name != target_name) or any(pattern in new_name.lower() for pattern in ['copy', 'clone', 'import']) if should_rename: try: logger.info("VM 이름 변경 시도: %s → %s", new_name, target_name) # 기존 이름과 충돌 확인 existing_vms = self.list_vms() if target_name in existing_vms and target_name != new_name: logger.warning("목표 이름 %s이(가) 이미 존재함, 고유 이름 생성", target_name) target_name = self._generate_unique_name(target_name) logger.info("새로운 목표 이름: %s", target_name) # VM 이름 변경 실행 rename_cmd = f'Rename-VM -VMName "{new_name}" -NewName "{target_name}"' self._run_powershell(rename_cmd) logger.info("VM 이름 변경 성공: %s → %s", new_name, target_name) # 변경 확인 updated_vms = self.list_vms() if target_name in updated_vms: logger.info("VM 이름 변경 확인됨: %s", target_name) else: logger.warning("VM 이름 변경 확인 실패, 현재 VM 목록: %s", updated_vms) except Exception as e: logger.error("VM 이름 변경 실패: %s", e) # 이름 변경에 실패해도 계속 진행 logger.info("이름 변경 실패했지만 계속 진행: %s", new_name) target_name = new_name else: logger.info("VM 이름 변경 불필요: %s", new_name) target_name = new_name self._update_vm_progress("VM 이름 변경 완료", 60) # 맥주소 동적 재할당 (버전별 처리) logger.info("맥주소 동적 할당: %s", target_name) self._set_network_adapter(target_name) self._update_vm_progress("네트워크 설정 완료", 70) # VM 시작 try: # VM 시작 전 상태 확인 vm_state_cmd = f'(Get-VM -Name "{target_name}").State' vm_state = self._run_powershell(vm_state_cmd) logger.info("VM 시작 전 상태: %s", vm_state) if vm_state.lower() == "running": logger.info("VM이 이미 실행 중입니다: %s", target_name) else: # VM 파일 경로 확인 및 수정 logger.info("VM 하드 디스크 경로 확인 및 수정") check_vhd_cmd = f''' $vm = Get-VM -Name "{target_name}" $vhds = $vm | Get-VMHardDiskDrive foreach ($vhd in $vhds) {{ $currentPath = $vhd.Path $fileName = [System.IO.Path]::GetFileName($currentPath) $directory = [System.IO.Path]::GetDirectoryName($currentPath) $newPath = Join-Path $directory "{target_name}.vhdx" Write-Host "현재 VHD 경로: $currentPath" Write-Host "새 VHD 경로: $newPath" # 새 경로의 파일이 존재하는지 확인 if (Test-Path $newPath) {{ Write-Host "새 VHD 파일이 존재함, 경로 업데이트 진행" # VM의 하드 디스크 경로를 새 파일로 변경 Set-VMHardDiskDrive -VMName "{target_name}" -ControllerType $vhd.ControllerType -ControllerNumber $vhd.ControllerNumber -ControllerLocation $vhd.ControllerLocation -Path $newPath Write-Host "VHD 경로 업데이트 완료: $currentPath -> $newPath" }} else {{ Write-Host "새 VHD 파일이 존재하지 않음: $newPath" # 원본 파일을 새 이름으로 복사 if (Test-Path $currentPath) {{ Write-Host "원본 VHD 파일 복사 시작: $currentPath -> $newPath" Copy-Item -Path $currentPath -Destination $newPath -Force Write-Host "VHD 파일 복사 완료" # VM의 하드 디스크 경로를 새 파일로 변경 Set-VMHardDiskDrive -VMName "{target_name}" -ControllerType $vhd.ControllerType -ControllerNumber $vhd.ControllerNumber -ControllerLocation $vhd.ControllerLocation -Path $newPath Write-Host "VHD 경로 업데이트 완료: $currentPath -> $newPath" }} else {{ Write-Host "원본 VHD 파일이 존재하지 않음: $currentPath" }} }} }} # 업데이트 후 확인 Write-Host "=== VHD 경로 업데이트 후 확인 ===" $updatedVhds = Get-VM -Name "{target_name}" | Get-VMHardDiskDrive foreach ($vhd in $updatedVhds) {{ Write-Host "최종 VHD 경로: $($vhd.Path)" }} ''' try: vhd_result = self._run_powershell(check_vhd_cmd) logger.info("VHD 경로 확인 및 업데이트 결과: %s", vhd_result) except Exception as e: logger.warning("VHD 경로 확인 실패 (계속 진행): %s", e) # VM 시작 (사용자 요청에 따라 주석 처리) # logger.info("VM 시작 시도: %s", target_name) # self._run_powershell(f'Start-VM -Name "{target_name}"') # logger.info("VM 시작 성공: %s", target_name) except Exception as e: logger.error("VM 설정 실패: %s", e) # VM 설정 실패해도 VM은 생성되었으므로 계속 진행 logger.info("VM 설정 실패했지만 VM 생성은 완료됨. 수동으로 시작할 수 있습니다.") # VM 생성 완료 - 사용자가 수동으로 시작하도록 함 logger.info("VM 생성 완료: %s", target_name) logger.info("VM은 중지 상태로 생성되었습니다. Hyper-V 관리자에서 설정을 확인한 후 시작해주세요.") self._update_vm_progress("VM 생성 완료 (중지 상태)", 100) logger.info("VM 생성 및 설정 완료: %s", target_name) return target_name except Exception as e: logger.error("VM 가져오기 실패: %s", e) raise def configure_network_and_name(self, vm_name): """ 1) MAC 동적 할당 보장 2) VM 시작 3) PowerShell Direct로 내부 접속 → DHCP 활성화, 컴퓨터 이름 유지 """ logger.info("VM 설정 시작: %s", vm_name) self._run_powershell( f'Set-VMNetworkAdapter -VMName "{vm_name}" -DynamicMacAddress $true' ) self._run_powershell(f'Start-VM -Name "{vm_name}"') guest_script = f""" $if = Get-NetAdapter | Where-Object {{$_.Status -eq "Up"}} | Select -First 1 Get-NetIPAddress -InterfaceIndex $if.IfIndex -AddressFamily IPv4 | Remove-NetIPAddress -Confirm:$false Set-NetIPInterface -InterfaceIndex $if.IfIndex -Dhcp Enabled """ self._run_powershell( f'Invoke-Command -VMName "{vm_name}" -ScriptBlock {{ {guest_script} }}' ) logger.info("네트워크 설정 완료: %s", vm_name) def _find_config(self, export_path): """ export_path가 폴더면 .vmcx/.xml 파일을 찾아 반환, 파일이면 그대로 반환 """ p = Path(export_path) if p.is_file() and p.suffix.lower() in (".vmcx", ".xml"): return str(p) if p.is_dir(): for ext in ("*.vmcx", "*.xml"): files = list(p.rglob(ext)) if files: return str(files[0]) raise RuntimeError("VM 구성 파일(.vmcx 또는 .xml)을 찾을 수 없습니다.") def _run_powershell(self, command): """ PowerShell 명령 실행 헬퍼 """ completed = subprocess.run( ["powershell", "-NoProfile", "-Command", command], capture_output=True, text=True ) if completed.returncode != 0: logger.error("PowerShell 오류: %s", completed.stderr.strip()) raise RuntimeError(completed.stderr.strip()) return completed.stdout.strip() def import_multiple_vms(self, vm_export_path, count, base_name="PartTimer"): """여러 개의 VM을 순차적으로 가져오기""" imported_vms = [] for i in range(count): target_name = f"{base_name}-{i+1}" logger.info("VM %d/%d 가져오기 시작: %s", i+1, count, target_name) self._update_total_progress(i, count) try: vm_name = self.import_vm(vm_export_path, target_name) imported_vms.append(vm_name) except Exception as e: logger.error("VM %s 가져오기 실패: %s", target_name, e) continue self._update_total_progress(count, count) if not imported_vms: raise RuntimeError("VM을 가져오지 못했습니다.") return imported_vms def _update_vm_progress(self, status_text, progress_percent): """현재 VM의 진행 상황 업데이트""" if self.progress_callback: self.progress_callback("vm", status_text, progress_percent) def _update_total_progress(self, current, total): """전체 진행 상황 업데이트""" if self.progress_callback: self.progress_callback("total", current, total)