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:
2026-05-13 11:25:07 +02:00
parent 4d957d34b2
commit bd31a3f2f3
26 changed files with 1920 additions and 2 deletions
+30
View File
@@ -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)
+24
View File
@@ -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
+81
View File
@@ -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.
"""
...
+157
View File
@@ -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