Phase A1: bootstrap Python ci_orchestrator package
- pyproject.toml (hatchling) with ruff/mypy/pytest dev deps - src/ci_orchestrator/: config, credentials, backends (Protocol + WorkstationVmrunBackend), transport (WinRM via pypsrp, SSH via paramiko) - CLI entry point with PoC 'wait-ready' subcommand (Windows + Linux guests) - tests/python/: 35 unit tests, 83% coverage, all backends/transport mocked - gitea/workflows/lint.yml: new 'python' job (ruff + mypy --strict + pytest --cov-fail-under=70) - config.example.toml + README setup section + .gitignore Python entries Neutral VmBackend Protocol prepared for Phase C ESXi extension. See plans/implementation-plan-A-B.md step A1.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
"""VM backend implementations.
|
||||
|
||||
The :class:`~ci_orchestrator.backends.protocol.VmBackend` Protocol defines
|
||||
the neutral interface every backend must satisfy. Phase A ships only the
|
||||
:class:`~ci_orchestrator.backends.workstation.WorkstationVmrunBackend`;
|
||||
Phase C will add an ESXi backend behind the same interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ci_orchestrator.backends.protocol import VmBackend, VmHandle, VmState
|
||||
from ci_orchestrator.backends.workstation import WorkstationVmrunBackend
|
||||
|
||||
__all__ = ["VmBackend", "VmHandle", "VmState", "WorkstationVmrunBackend", "load_backend"]
|
||||
|
||||
|
||||
def load_backend(config: object) -> VmBackend:
|
||||
"""Factory: pick a backend implementation from ``config.backend.type``.
|
||||
|
||||
Phase C hook: when the ESXi backend lands, this function will dispatch
|
||||
on ``config.backend.type``. For now only ``workstation`` is supported.
|
||||
"""
|
||||
backend_type = getattr(getattr(config, "backend", None), "type", "workstation")
|
||||
if backend_type != "workstation":
|
||||
raise NotImplementedError(
|
||||
f"Backend type '{backend_type}' is not implemented. "
|
||||
"Only 'workstation' is available in Phase A."
|
||||
)
|
||||
vmrun_path = getattr(config, "vmrun_path", None)
|
||||
return WorkstationVmrunBackend(vmrun_path=vmrun_path)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Backend error hierarchy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class BackendError(RuntimeError):
|
||||
"""Base error for VM backend failures."""
|
||||
|
||||
|
||||
class BackendNotAvailable(BackendError):
|
||||
"""Backend tooling (e.g. ``vmrun``) is missing or unusable."""
|
||||
|
||||
|
||||
class BackendOperationFailed(BackendError):
|
||||
"""A backend operation (clone, start, ...) returned a non-zero status."""
|
||||
|
||||
def __init__(self, operation: str, returncode: int, output: str) -> None:
|
||||
super().__init__(
|
||||
f"Backend operation '{operation}' failed "
|
||||
f"(exit={returncode}): {output.strip() or '<no output>'}"
|
||||
)
|
||||
self.operation = operation
|
||||
self.returncode = returncode
|
||||
self.output = output
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Neutral VM backend Protocol.
|
||||
|
||||
Phase C hook: keep names hypervisor-agnostic. ``clone_linked``, ``start``,
|
||||
``stop``, ``delete``, ``get_ip``, ``list_snapshots`` map cleanly onto both
|
||||
``vmrun`` and pyVmomi (vSphere). Identifiers are opaque strings (a VMX path
|
||||
on Workstation, a managed object reference on ESXi).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
class VmState(StrEnum):
|
||||
"""Coarse VM power state, normalised across backends."""
|
||||
|
||||
POWERED_OFF = "powered_off"
|
||||
POWERED_ON = "powered_on"
|
||||
SUSPENDED = "suspended"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VmHandle:
|
||||
"""Opaque reference to a VM managed by a backend.
|
||||
|
||||
``identifier`` is backend-specific (a VMX path for Workstation, a
|
||||
managed object reference for vSphere). Callers MUST treat it as opaque.
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
name: str | None = None
|
||||
|
||||
|
||||
class VmBackend(Protocol):
|
||||
"""Hypervisor-agnostic VM operations.
|
||||
|
||||
All methods raise :class:`~ci_orchestrator.backends.errors.BackendError`
|
||||
on failure. Implementations are free to add backend-specific kwargs but
|
||||
MUST honour the documented signature for the listed parameters.
|
||||
"""
|
||||
|
||||
def clone_linked(
|
||||
self,
|
||||
template: str,
|
||||
snapshot: str,
|
||||
name: str,
|
||||
destination: str | None = None,
|
||||
) -> VmHandle:
|
||||
"""Create a linked clone of ``template`` at snapshot ``snapshot``."""
|
||||
...
|
||||
|
||||
def start(self, handle: VmHandle, headless: bool = True) -> None:
|
||||
"""Power on the VM."""
|
||||
...
|
||||
|
||||
def stop(self, handle: VmHandle, hard: bool = False) -> None:
|
||||
"""Power off the VM. ``hard=True`` requests a forced power-off."""
|
||||
...
|
||||
|
||||
def delete(self, handle: VmHandle) -> None:
|
||||
"""Remove the VM from inventory and delete its files."""
|
||||
...
|
||||
|
||||
def get_ip(self, handle: VmHandle, timeout: float = 0.0) -> str | None:
|
||||
"""Return the guest's primary IP, or ``None`` if not yet known."""
|
||||
...
|
||||
|
||||
def list_snapshots(self, handle: VmHandle) -> list[str]:
|
||||
"""Return snapshot names defined on ``handle``."""
|
||||
...
|
||||
|
||||
def is_running(self, handle: VmHandle) -> bool:
|
||||
"""Return ``True`` if the VM appears in the running-VM list.
|
||||
|
||||
Implementations MUST NOT rely on guest tools (e.g. ``vmrun
|
||||
getGuestIPAddress``) for this check — see ``AGENTS.md`` error #10.
|
||||
"""
|
||||
...
|
||||
@@ -0,0 +1,157 @@
|
||||
"""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"])
|
||||
result = self._run("getGuestIPAddress", *args, check=False, timeout=timeout or None)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
ip = (result.stdout or "").strip()
|
||||
return ip or None
|
||||
|
||||
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
|
||||
Reference in New Issue
Block a user