841 lines
39 KiB
Python
841 lines
39 KiB
Python
# 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)
|