feat(template): add deploy-win — UUP download + ISO patch + deploy WS2025
New `ci template deploy-win` builds the Windows Server 2025 template VM on the Linux host, mirroring the legacy Deploy-WinBuild2025.ps1 ten-step flow in Python: - winimage/uup.py: resolve the newest (or pinned) WS2025 build via the uupdump API and build a cached, MS-signed ISO via the uup converter. - winimage/autounattend.py + assets/: render autounattend.xml and the guest post-install.ps1 (ported from the PS heredoc; VMware Tools now mounted as a 2nd CD, scanned at first logon). - winimage/iso_patch.py: xorriso repack injecting the answer files at the ISO root and swapping the EFI boot image to efisys_noprompt.bin (no "press any key" prompt — essential for an unattended headless install). - commands/template.py: the deploy-win command + _winbuild_vmx_lines (the 82-key VMX), _remove_vmx_lines, _wait_install_flag, _vmrun_soft_stop. Acquire → patch → VMDK → VMX → install → flag wait → strip media + NIC e1000e→vmxnet3 → verify IP → cold snapshot. Deterministic by build (--build), with --win-iso to bypass UUP. Tests mirror the prepare-win mocking style; total coverage 93%.
This commit is contained in:
@@ -80,6 +80,9 @@ from ci_orchestrator.backends.workstation import WorkstationVmrunBackend
|
||||
from ci_orchestrator.config import load_config
|
||||
from ci_orchestrator.transport.errors import TransportError
|
||||
from ci_orchestrator.transport.winrm import WinRmResult, WinRmTransport
|
||||
from ci_orchestrator.winimage import autounattend as win_autounattend
|
||||
from ci_orchestrator.winimage import iso_patch as win_iso_patch
|
||||
from ci_orchestrator.winimage import uup as win_uup
|
||||
|
||||
|
||||
@click.group()
|
||||
@@ -1895,3 +1898,417 @@ def template_prepare_linux(
|
||||
else:
|
||||
click.echo(f" Snapshot: not taken — name it '{snapshot}' when done manually.")
|
||||
click.echo("[prepare-linux] Provisioning complete.")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# template deploy-win command (Windows Server 2025, Linux host)
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
#: Forward-compatible Workstation guestOS id for Windows Server 2025.
|
||||
_WIN2025_GUEST_OS = "windows2019srvnext-64"
|
||||
|
||||
|
||||
def _remove_vmx_lines(vmx_path: Path, prefix: str) -> int:
|
||||
"""Remove every VMX line whose key starts with ``prefix``. Returns count."""
|
||||
text = vmx_path.read_text(encoding="ascii", errors="replace")
|
||||
kept: list[str] = []
|
||||
removed = 0
|
||||
pattern = re.compile(rf"^\s*{re.escape(prefix)}", re.IGNORECASE)
|
||||
for line in text.splitlines():
|
||||
if pattern.match(line):
|
||||
removed += 1
|
||||
else:
|
||||
kept.append(line)
|
||||
vmx_path.write_text("\n".join(kept) + "\n", encoding="ascii")
|
||||
return removed
|
||||
|
||||
|
||||
def _winbuild_vmx_lines(
|
||||
*,
|
||||
vm_name: str,
|
||||
mem_mb: int,
|
||||
vcpu_count: int,
|
||||
cores_per_socket: int,
|
||||
vmdk_name: str,
|
||||
install_iso: str,
|
||||
tools_iso: str,
|
||||
) -> list[str]:
|
||||
"""Build the WinBuild2025 VMX (ports Deploy-WinBuild2025.ps1 Step 5).
|
||||
|
||||
Boot NIC is e1000e; sata0:0 = patched install ISO, sata0:1 = VMware Tools
|
||||
ISO. ``deploy-win`` removes the SATA/USB controllers and switches the NIC to
|
||||
vmxnet3 after the unattended install drops install_complete.flag.
|
||||
"""
|
||||
return [
|
||||
'.encoding = "windows-1252"',
|
||||
'config.version = "8"',
|
||||
'virtualHW.version = "21"',
|
||||
f'displayName = "{vm_name}"',
|
||||
f'guestOS = "{_WIN2025_GUEST_OS}"',
|
||||
'firmware = "efi"',
|
||||
'uefi.secureBoot.enabled = "FALSE"',
|
||||
f'memSize = "{mem_mb}"',
|
||||
f'numvcpus = "{vcpu_count}"',
|
||||
f'cpuid.coresPerSocket = "{cores_per_socket}"',
|
||||
'tools.syncTime = "TRUE"',
|
||||
'tools.upgrade.policy = "manual"',
|
||||
'powerType.powerOff = "soft"',
|
||||
'powerType.powerOn = "soft"',
|
||||
'powerType.suspend = "soft"',
|
||||
'powerType.reset = "soft"',
|
||||
'uuid.action = "create"',
|
||||
'pciBridge0.present = "TRUE"',
|
||||
'pciBridge4.present = "TRUE"',
|
||||
'pciBridge4.virtualDev = "pcieRootPort"',
|
||||
'pciBridge4.functions = "8"',
|
||||
'pciBridge5.present = "TRUE"',
|
||||
'pciBridge5.virtualDev = "pcieRootPort"',
|
||||
'pciBridge5.functions = "8"',
|
||||
'pciBridge6.present = "TRUE"',
|
||||
'pciBridge6.virtualDev = "pcieRootPort"',
|
||||
'pciBridge6.functions = "8"',
|
||||
'pciBridge7.present = "TRUE"',
|
||||
'pciBridge7.virtualDev = "pcieRootPort"',
|
||||
'pciBridge7.functions = "8"',
|
||||
'nvme0.present = "TRUE"',
|
||||
'nvme0.pciSlotNumber = "224"',
|
||||
'nvme0:0.present = "TRUE"',
|
||||
f'nvme0:0.fileName = "{vmdk_name}"',
|
||||
'nvme0:0.deviceType = "disk"',
|
||||
'sata0.present = "TRUE"',
|
||||
'sata0.pciSlotNumber = "32"',
|
||||
'sata0:0.present = "TRUE"',
|
||||
'sata0:0.deviceType = "cdrom-image"',
|
||||
f'sata0:0.fileName = "{install_iso}"',
|
||||
'sata0:0.startConnected = "TRUE"',
|
||||
'sata0:1.present = "TRUE"',
|
||||
'sata0:1.deviceType = "cdrom-image"',
|
||||
f'sata0:1.fileName = "{tools_iso}"',
|
||||
'sata0:1.startConnected = "TRUE"',
|
||||
'ethernet0.present = "TRUE"',
|
||||
'ethernet0.virtualDev = "e1000e"',
|
||||
'ethernet0.connectionType = "nat"',
|
||||
'ethernet0.addressType = "generated"',
|
||||
'ethernet0.startConnected = "TRUE"',
|
||||
'ethernet0.pciSlotNumber = "192"',
|
||||
'usb.present = "TRUE"',
|
||||
'usb.pciSlotNumber = "34"',
|
||||
'ehci.present = "TRUE"',
|
||||
'ehci.pciSlotNumber = "35"',
|
||||
'usb_xhci.present = "TRUE"',
|
||||
'usb_xhci.pciSlotNumber = "33"',
|
||||
'svga.present = "TRUE"',
|
||||
'svga.graphicsMemoryKB = "262144"',
|
||||
'floppy0.present = "FALSE"',
|
||||
'sound.present = "FALSE"',
|
||||
'vmci0.present = "TRUE"',
|
||||
'vmci0.pciSlotNumber = "36"',
|
||||
'hpet0.present = "TRUE"',
|
||||
'mks.enable3d = "FALSE"',
|
||||
'snapshot.action = "keep"',
|
||||
]
|
||||
|
||||
|
||||
def _vmrun_tools_running(vmrun: str, vmx: str) -> bool:
|
||||
"""True when VMware Tools report 'running' inside the guest."""
|
||||
proc = subprocess.run(
|
||||
[vmrun, "-T", "ws", "checkToolsState", str(vmx)],
|
||||
capture_output=True, text=True, timeout=30, check=False,
|
||||
)
|
||||
return proc.stdout.strip().lower() == "running"
|
||||
|
||||
|
||||
def _wait_install_flag(
|
||||
vmrun: str,
|
||||
vmx: str,
|
||||
*,
|
||||
admin_user: str,
|
||||
admin_password: str,
|
||||
flag_path: str,
|
||||
timeout_s: float,
|
||||
poll_s: float,
|
||||
) -> bool:
|
||||
"""Poll until the guest drops ``flag_path`` (Tools must be running first)."""
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
if _vmrun_tools_running(vmrun, vmx):
|
||||
proc = subprocess.run(
|
||||
[vmrun, "-gu", admin_user, "-gp", admin_password,
|
||||
"fileExistsInGuest", str(vmx), flag_path],
|
||||
capture_output=True, text=True, timeout=60, check=False,
|
||||
)
|
||||
if "the file exists" in proc.stdout.strip().lower():
|
||||
return True
|
||||
click.echo(" ... Tools up; waiting for install_complete.flag ...")
|
||||
else:
|
||||
click.echo(" ... waiting for VMware Tools to report running ...")
|
||||
time.sleep(poll_s)
|
||||
return False
|
||||
|
||||
|
||||
def _vmrun_soft_stop(vmrun: str, vmx: str, *, timeout_s: float = 300.0) -> None:
|
||||
"""Soft-stop the VM, falling back to hard stop after ``timeout_s``."""
|
||||
subprocess.run(
|
||||
[vmrun, "-T", "ws", "stop", str(vmx), "soft"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
listing = subprocess.run(
|
||||
[vmrun, "-T", "ws", "list"], capture_output=True, text=True, check=False,
|
||||
)
|
||||
if str(vmx) not in listing.stdout:
|
||||
return
|
||||
time.sleep(5)
|
||||
subprocess.run(
|
||||
[vmrun, "-T", "ws", "stop", str(vmx), "hard"],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
|
||||
|
||||
@template.command("deploy-win")
|
||||
@click.option("--vmx", "vmx_path_str", default=None, metavar="PATH",
|
||||
help="VMX to create (default: <CI_TEMPLATES>/WinBuild2025/WinBuild2025.vmx).")
|
||||
@click.option("--vm-name", default="WinBuild2025", show_default=True, help="VM display name.")
|
||||
@click.option("--build", "uup_build", default=None,
|
||||
help="Pin a UUP build number (e.g. 26100.32860). Default: newest WS2025.")
|
||||
@click.option("--win-iso", "win_iso_str", default=None, metavar="PATH",
|
||||
help="Use this Windows ISO instead of downloading from UUP dump.")
|
||||
@click.option("--edition", default="SERVERSTANDARD", show_default=True, help="UUP edition.")
|
||||
@click.option("--lang", default="en-us", show_default=True, help="UUP language pack.")
|
||||
@click.option("--image-index", default=2, show_default=True, help="install.wim image index.")
|
||||
@click.option("--product-key", default="TVRH6-WHNXV-R9WG3-9XRFY-MY832", show_default=True)
|
||||
@click.option("--admin-user", default="Administrator", show_default=True)
|
||||
@click.option("--admin-password", default="WinBuild!", show_default=True)
|
||||
@click.option("--computer-name", default="WINBUILD-2025", show_default=True)
|
||||
@click.option("--locale-os", default="en-US", show_default=True)
|
||||
@click.option("--locale-user", default="it-IT", show_default=True)
|
||||
@click.option("--keyboard", default="0410:00000410", show_default=True)
|
||||
@click.option("--timezone", default="W. Europe Standard Time", show_default=True)
|
||||
@click.option("--disk-size-gb", default=80, show_default=True)
|
||||
@click.option("--memory-mb", default=6144, show_default=True)
|
||||
@click.option("--vcpu-count", default=4, show_default=True)
|
||||
@click.option("--cores-per-socket", default=2, show_default=True)
|
||||
@click.option("--tools-iso", "tools_iso_str", default=None, metavar="PATH",
|
||||
help="VMware Tools ISO (default: <CI_ROOT>/iso/VMware-tools-windows.iso).")
|
||||
@click.option("--snapshot-name", default="PostInstall", show_default=True)
|
||||
@click.option("--gui/--nogui", "gui", default=False, show_default=True,
|
||||
help="Start the VM with the Workstation window (needs an X display).")
|
||||
@click.option("--flag-timeout", default=5400, show_default=True,
|
||||
help="Seconds to wait for install_complete.flag.")
|
||||
@click.option("--flag-poll", default=30, show_default=True, help="Flag poll interval (s).")
|
||||
@click.option("--vdisk-manager", "vdisk_manager_str", default=None)
|
||||
@click.option("--force", is_flag=True, default=False, help="Overwrite an existing VM directory.")
|
||||
@click.option("--start-from-step", default=1, type=click.IntRange(1, 10), show_default=True)
|
||||
def template_deploy_win(
|
||||
vmx_path_str: str | None,
|
||||
vm_name: str,
|
||||
uup_build: str | None,
|
||||
win_iso_str: str | None,
|
||||
edition: str,
|
||||
lang: str,
|
||||
image_index: int,
|
||||
product_key: str,
|
||||
admin_user: str,
|
||||
admin_password: str,
|
||||
computer_name: str,
|
||||
locale_os: str,
|
||||
locale_user: str,
|
||||
keyboard: str,
|
||||
timezone: str,
|
||||
disk_size_gb: int,
|
||||
memory_mb: int,
|
||||
vcpu_count: int,
|
||||
cores_per_socket: int,
|
||||
tools_iso_str: str | None,
|
||||
snapshot_name: str,
|
||||
gui: bool,
|
||||
flag_timeout: int,
|
||||
flag_poll: int,
|
||||
vdisk_manager_str: str | None,
|
||||
force: bool,
|
||||
start_from_step: int,
|
||||
) -> None:
|
||||
"""Deploy the Windows Server 2025 build VM template (Linux VMware host).
|
||||
|
||||
Downloads the latest WS2025 ISO from UUP dump (deterministic, build-pinned),
|
||||
patches it (inject autounattend.xml + post-install.ps1 + no-prompt boot),
|
||||
runs the unattended install, swaps the NIC to vmxnet3 and snapshots the
|
||||
template. Mirrors the legacy Deploy-WinBuild2025.ps1 ten-step flow.
|
||||
"""
|
||||
config = load_config()
|
||||
vmx_path = (
|
||||
Path(vmx_path_str).resolve()
|
||||
if vmx_path_str
|
||||
else config.paths.templates / "WinBuild2025" / "WinBuild2025.vmx"
|
||||
)
|
||||
vm_dir = vmx_path.parent
|
||||
iso_dir = config.paths.root / "iso"
|
||||
tools_iso = Path(tools_iso_str) if tools_iso_str else iso_dir / "VMware-tools-windows.iso"
|
||||
vmdk_name = vmx_path.stem + ".vmdk"
|
||||
vmdk_path = vm_dir / vmdk_name
|
||||
patched_iso = vm_dir / "install-noprompt.iso"
|
||||
flag_path = r"C:\Windows\Temp\install_complete.flag"
|
||||
|
||||
vmrun = config.vmrun_path or shutil.which("vmrun") or "vmrun"
|
||||
vdisk_mgr = _find_vdisk_manager(vdisk_manager_str)
|
||||
|
||||
params = win_autounattend.WinUnattendParams(
|
||||
admin_user=admin_user, admin_password=admin_password,
|
||||
computer_name=computer_name, product_key=product_key,
|
||||
image_index=image_index, locale_os=locale_os, locale_user=locale_user,
|
||||
keyboard=keyboard, timezone=timezone,
|
||||
)
|
||||
|
||||
if start_from_step > 1:
|
||||
click.echo(f" [SKIP] Steps 1..{start_from_step - 1} -- starting at Step {start_from_step}")
|
||||
|
||||
# ── Step 1: preflight ──────────────────────────────────────────────────────
|
||||
_deploy_step("Step 1: preflight", 1, start_from_step)
|
||||
if start_from_step <= 1:
|
||||
try:
|
||||
win_autounattend.validate_computer_name(computer_name)
|
||||
except ValueError as exc:
|
||||
raise click.ClickException(f"[PreFlight] {exc}") from exc
|
||||
checks: list[tuple[str, bool]] = [
|
||||
(f"vmrun found: {vmrun}", bool(shutil.which(vmrun) or Path(vmrun).is_file())),
|
||||
(f"vmware-vdiskmanager found: {vdisk_mgr}", Path(vdisk_mgr).is_file()),
|
||||
("xorriso in PATH", shutil.which("xorriso") is not None),
|
||||
(f"VMware Tools ISO: {tools_iso}", tools_iso.is_file()),
|
||||
]
|
||||
for label, ok in checks:
|
||||
if not ok:
|
||||
raise click.ClickException(f"[PreFlight] {label}")
|
||||
click.echo(f" [OK] {label}")
|
||||
if win_iso_str is None:
|
||||
missing = win_uup.check_tools()
|
||||
if missing:
|
||||
raise click.ClickException(
|
||||
"[PreFlight] missing UUP tools: " + ", ".join(missing)
|
||||
)
|
||||
click.echo(" [OK] UUP conversion tools present")
|
||||
if vm_dir.exists():
|
||||
if not force:
|
||||
raise click.ClickException(
|
||||
f"[PreFlight] VM directory exists: {vm_dir}\n Use --force to overwrite."
|
||||
)
|
||||
click.echo(f" [WARN] --force -- reusing VM directory: {vm_dir}")
|
||||
_clean_force_artifacts(vm_dir, vmx_path.stem)
|
||||
else:
|
||||
vm_dir.mkdir(parents=True, exist_ok=True)
|
||||
click.echo(f" [OK] VM directory ready: {vm_dir}")
|
||||
|
||||
# ── Step 2: render answer files ────────────────────────────────────────────
|
||||
_deploy_step("Step 2: render autounattend.xml + post-install.ps1", 2, start_from_step)
|
||||
autounattend_xml = win_autounattend.render_autounattend(params)
|
||||
post_install_ps1 = win_autounattend.render_post_install(params)
|
||||
if start_from_step <= 2:
|
||||
click.echo(" [OK] answer files rendered")
|
||||
|
||||
# ── Step 3: acquire base ISO (UUP dump) ────────────────────────────────────
|
||||
_deploy_step("Step 3: acquire Windows Server 2025 ISO", 3, start_from_step)
|
||||
if win_iso_str is not None:
|
||||
base_iso = Path(win_iso_str)
|
||||
if start_from_step <= 3 and not base_iso.is_file():
|
||||
raise click.ClickException(f"--win-iso not found: {base_iso}")
|
||||
click.echo(f" [OK] using supplied ISO: {base_iso}")
|
||||
else:
|
||||
build = win_uup.resolve_build(build=uup_build)
|
||||
click.echo(f" resolved build: {build.build} ({build.uuid})")
|
||||
base_iso = win_uup.build_iso(build, dest_dir=iso_dir, lang=lang, edition=edition)
|
||||
|
||||
# ── Step 4: patch ISO (inject answer files + no-prompt boot) ───────────────
|
||||
_deploy_step("Step 4: patch ISO (inject autounattend + no-prompt boot)", 4, start_from_step)
|
||||
if start_from_step <= 4:
|
||||
win_iso_patch.patch_iso(
|
||||
base_iso,
|
||||
autounattend_xml=autounattend_xml,
|
||||
post_install_ps1=post_install_ps1,
|
||||
out_iso=patched_iso,
|
||||
work_dir=vm_dir,
|
||||
)
|
||||
|
||||
# ── Step 5: create VMDK ────────────────────────────────────────────────────
|
||||
_deploy_step("Step 5: create VMDK", 5, start_from_step)
|
||||
if start_from_step <= 5:
|
||||
if vmdk_path.exists():
|
||||
vmdk_path.unlink()
|
||||
subprocess.run(
|
||||
[vdisk_mgr, "-c", "-s", f"{disk_size_gb}GB", "-a", "lsilogic", "-t", "0",
|
||||
str(vmdk_path)],
|
||||
check=True,
|
||||
)
|
||||
click.echo(f" [OK] VMDK created: {vmdk_path}")
|
||||
|
||||
# ── Step 6: write VMX ──────────────────────────────────────────────────────
|
||||
_deploy_step("Step 6: write VMX", 6, start_from_step)
|
||||
if start_from_step <= 6:
|
||||
lines = _winbuild_vmx_lines(
|
||||
vm_name=vm_name, mem_mb=memory_mb, vcpu_count=vcpu_count,
|
||||
cores_per_socket=cores_per_socket, vmdk_name=vmdk_name,
|
||||
install_iso=str(patched_iso), tools_iso=str(tools_iso),
|
||||
)
|
||||
vmx_path.write_text("\n".join(lines) + "\n", encoding="ascii")
|
||||
click.echo(f" [OK] VMX written: {vmx_path}")
|
||||
|
||||
# ── Step 7: power on for unattended install ────────────────────────────────
|
||||
_deploy_step("Step 7: power on (unattended install)", 7, start_from_step)
|
||||
if start_from_step <= 7:
|
||||
gui_mode = "gui" if gui else "nogui"
|
||||
subprocess.Popen(
|
||||
[vmrun, "-T", "ws", "start", str(vmx_path), gui_mode]
|
||||
)
|
||||
time.sleep(10)
|
||||
click.echo(" [OK] VM powered on")
|
||||
|
||||
# ── Step 8: wait for install_complete.flag ─────────────────────────────────
|
||||
_deploy_step("Step 8: wait for install_complete.flag", 8, start_from_step)
|
||||
if start_from_step <= 8:
|
||||
ok = _wait_install_flag(
|
||||
vmrun, str(vmx_path), admin_user=admin_user, admin_password=admin_password,
|
||||
flag_path=flag_path, timeout_s=float(flag_timeout), poll_s=float(flag_poll),
|
||||
)
|
||||
if not ok:
|
||||
raise click.ClickException(
|
||||
f"[Deploy Step 8] install_complete.flag not seen within {flag_timeout}s."
|
||||
)
|
||||
click.echo(" [OK] install_complete.flag detected")
|
||||
|
||||
# ── Step 9: stop, strip install media, swap NIC to vmxnet3 ─────────────────
|
||||
_deploy_step("Step 9: strip install media + swap NIC to vmxnet3", 9, start_from_step)
|
||||
if start_from_step <= 9:
|
||||
_vmrun_soft_stop(vmrun, str(vmx_path))
|
||||
for prefix in ("sata0", "usb", "ehci", "usb_xhci"):
|
||||
_remove_vmx_lines(vmx_path, prefix)
|
||||
_patch_vmx_key(vmx_path, "ethernet0.virtualDev", "vmxnet3")
|
||||
click.echo(" [OK] SATA/USB removed, NIC -> vmxnet3")
|
||||
|
||||
# ── Step 10: verify vmxnet3 IP, then cold snapshot ─────────────────────────
|
||||
_deploy_step("Step 10: verify network + snapshot", 10, start_from_step)
|
||||
if start_from_step <= 10:
|
||||
gui_mode = "gui" if gui else "nogui"
|
||||
subprocess.Popen(
|
||||
[vmrun, "-T", "ws", "start", str(vmx_path), gui_mode]
|
||||
)
|
||||
guest_ip = _poll_guest_ip_deploy(vmrun, str(vmx_path), timeout=600.0)
|
||||
if not guest_ip:
|
||||
raise click.ClickException("[Deploy Step 10] no guest IP after vmxnet3 swap.")
|
||||
click.echo(f" [OK] vmxnet3 guest IP: {guest_ip}")
|
||||
_vmrun_soft_stop(vmrun, str(vmx_path))
|
||||
subprocess.run(
|
||||
[vmrun, "-T", "ws", "snapshot", str(vmx_path), snapshot_name], check=True,
|
||||
)
|
||||
listing = subprocess.run(
|
||||
[vmrun, "-T", "ws", "listSnapshots", str(vmx_path)],
|
||||
capture_output=True, text=True, check=False,
|
||||
)
|
||||
if snapshot_name not in listing.stdout:
|
||||
raise click.ClickException(
|
||||
f"[Deploy Step 10] snapshot '{snapshot_name}' not found after creation."
|
||||
)
|
||||
click.echo(f" [OK] snapshot '{snapshot_name}' created (cold)")
|
||||
|
||||
click.echo("\n=== deploy-win complete ===")
|
||||
click.echo(f" VMX: {vmx_path}")
|
||||
click.echo(f" Snapshot: {snapshot_name}")
|
||||
click.echo(f" Next: ci template prepare-win --vmx {vmx_path}")
|
||||
|
||||
Reference in New Issue
Block a user