163 lines
5.7 KiB
Python
163 lines
5.7 KiB
Python
"""VMware Workstation backend (``vmrun -T ws`` wrapper).
|
|
|
|
Mirrors the behaviour of ``scripts/_Common.psm1`` :func:`Invoke-Vmrun` plus
|
|
the higher-level helpers used by ``New-BuildVM.ps1`` and
|
|
``Wait-VMReady.ps1``. Only ``subprocess`` is used — no PowerShell.
|
|
|
|
Cross-platform: ``vmrun`` lives at different paths on Windows vs Linux;
|
|
when ``vmrun_path`` is not provided, :func:`shutil.which` is consulted and
|
|
finally a Windows default is tried.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
|
|
from ci_orchestrator.backends.errors import BackendNotAvailable, BackendOperationFailed
|
|
from ci_orchestrator.backends.protocol import VmHandle
|
|
|
|
_DEFAULT_WIN_VMRUN = r"C:\Program Files (x86)\VMware\VMware Workstation\vmrun.exe"
|
|
|
|
|
|
def _resolve_vmrun(explicit: str | None) -> str:
|
|
"""Pick a usable ``vmrun`` binary path."""
|
|
if explicit:
|
|
if not Path(explicit).is_file():
|
|
raise BackendNotAvailable(f"vmrun not found at: {explicit}")
|
|
return explicit
|
|
found = shutil.which("vmrun")
|
|
if found:
|
|
return found
|
|
if Path(_DEFAULT_WIN_VMRUN).is_file():
|
|
return _DEFAULT_WIN_VMRUN
|
|
raise BackendNotAvailable(
|
|
"vmrun is not available. Install VMware Workstation Pro or set vmrun_path."
|
|
)
|
|
|
|
|
|
class WorkstationVmrunBackend:
|
|
"""``vmrun -T ws`` backend for VMware Workstation Pro."""
|
|
|
|
def __init__(self, vmrun_path: str | None = None) -> None:
|
|
self._vmrun_path = _resolve_vmrun(vmrun_path)
|
|
|
|
# ------------------------------------------------------------------ utils
|
|
@property
|
|
def vmrun_path(self) -> str:
|
|
return self._vmrun_path
|
|
|
|
def _run(
|
|
self,
|
|
operation: str,
|
|
*args: str,
|
|
check: bool = True,
|
|
timeout: float | None = None,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
"""Run ``vmrun -T ws <operation> <args...>`` and capture output."""
|
|
cmd = [self._vmrun_path, "-T", "ws", operation, *args]
|
|
result = subprocess.run(
|
|
cmd,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
errors="replace",
|
|
timeout=timeout,
|
|
)
|
|
if check and result.returncode != 0:
|
|
raise BackendOperationFailed(
|
|
operation=operation,
|
|
returncode=result.returncode,
|
|
output=(result.stdout or "") + (result.stderr or ""),
|
|
)
|
|
return result
|
|
|
|
# ------------------------------------------------------------- VmBackend
|
|
def clone_linked(
|
|
self,
|
|
template: str,
|
|
snapshot: str,
|
|
name: str,
|
|
destination: str | None = None,
|
|
) -> VmHandle:
|
|
if destination is None:
|
|
destination = str(Path(template).parent.parent / name / f"{name}.vmx")
|
|
# vmrun clone <srcVMX> <destVMX> linked -snapshot=<name> -cloneName=<name>
|
|
self._run(
|
|
"clone",
|
|
template,
|
|
destination,
|
|
"linked",
|
|
f"-snapshot={snapshot}",
|
|
f"-cloneName={name}",
|
|
)
|
|
return VmHandle(identifier=destination, name=name)
|
|
|
|
def start(self, handle: VmHandle, headless: bool = True) -> None:
|
|
mode = "nogui" if headless else "gui"
|
|
self._run("start", handle.identifier, mode)
|
|
|
|
def stop(self, handle: VmHandle, hard: bool = False) -> None:
|
|
mode = "hard" if hard else "soft"
|
|
# stop may legitimately fail if the VM is already off; treat that as ok
|
|
result = self._run("stop", handle.identifier, mode, check=False)
|
|
if result.returncode != 0:
|
|
stderr = (result.stderr or "").lower()
|
|
if "not powered on" in stderr or "is not running" in stderr:
|
|
return
|
|
raise BackendOperationFailed(
|
|
"stop",
|
|
result.returncode,
|
|
(result.stdout or "") + (result.stderr or ""),
|
|
)
|
|
|
|
def delete(self, handle: VmHandle) -> None:
|
|
self._run("deleteVM", handle.identifier)
|
|
|
|
def get_ip(self, handle: VmHandle, timeout: float = 0.0) -> str | None:
|
|
# ``getGuestIPAddress`` requires VMware Tools; tolerate failure.
|
|
args: list[str] = [handle.identifier]
|
|
if timeout > 0:
|
|
args.extend(["-wait"])
|
|
try:
|
|
result = self._run("getGuestIPAddress", *args, check=False, timeout=timeout or None)
|
|
except subprocess.TimeoutExpired:
|
|
return None
|
|
if result.returncode != 0:
|
|
return None
|
|
ip = (result.stdout or "").strip()
|
|
if not ip or ip.lower().startswith("error:"):
|
|
return None
|
|
return ip
|
|
|
|
def list_snapshots(self, handle: VmHandle) -> list[str]:
|
|
result = self._run("listSnapshots", handle.identifier)
|
|
lines = [line.strip() for line in (result.stdout or "").splitlines() if line.strip()]
|
|
# First line is "Total snapshots: N"
|
|
if lines and lines[0].lower().startswith("total snapshots"):
|
|
lines = lines[1:]
|
|
return lines
|
|
|
|
def is_running(self, handle: VmHandle) -> bool:
|
|
"""``vmrun list`` lookup — does NOT call ``getGuestIPAddress``.
|
|
|
|
See ``AGENTS.md`` error #10: ``getGuestIPAddress`` blocks for 30-60s
|
|
without VMware Tools. ``vmrun list`` returns immediately.
|
|
"""
|
|
result = self._run("list", check=False)
|
|
if result.returncode != 0:
|
|
return False
|
|
target = Path(handle.identifier).resolve()
|
|
for line in (result.stdout or "").splitlines():
|
|
line = line.strip()
|
|
if not line or line.lower().startswith("total running"):
|
|
continue
|
|
try:
|
|
if Path(line).resolve() == target:
|
|
return True
|
|
except OSError:
|
|
continue
|
|
return False
|