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
+156
View File
@@ -0,0 +1,156 @@
"""WinRM transport via ``pypsrp``.
Replaces the ``New-PSSession`` / ``Invoke-Command`` calls used in the
PowerShell scripts. CI build VMs use a self-signed TLS certificate on
WinRM port 5986, so SSL validation is disabled by default — appropriate
for an isolated lab network (see ``scripts/_Common.psm1``).
"""
from __future__ import annotations
import contextlib
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from ci_orchestrator.transport.errors import TransportCommandError, TransportConnectError
if TYPE_CHECKING: # pragma: no cover
from pypsrp.client import Client
@dataclass(frozen=True)
class WinRmResult:
"""Result of a remote PowerShell invocation."""
stdout: str
stderr: str
returncode: int
@property
def ok(self) -> bool:
return self.returncode == 0
class WinRmTransport:
"""Thin wrapper around :class:`pypsrp.client.Client`.
Parameters
----------
host:
IP or DNS name of the guest.
username, password:
Credentials. Pass via :class:`~ci_orchestrator.credentials.Credential`
in caller code; do not embed plaintext.
port:
WinRM HTTPS port (default 5986).
cert_validation:
When ``False`` (default), self-signed certs are accepted — required
for the ephemeral build VMs in this lab.
"""
def __init__(
self,
host: str,
username: str,
password: str,
*,
port: int = 5986,
ssl: bool = True,
cert_validation: bool = False,
connection_timeout: int = 30,
) -> None:
self._host = host
self._username = username
self._password = password
self._port = port
self._ssl = ssl
self._cert_validation = cert_validation
self._connection_timeout = connection_timeout
self._client: Client | None = None
# ----------------------------------------------------------- connection
def _connect(self) -> Client:
if self._client is not None:
return self._client
try:
from pypsrp.client import Client # local import: optional dep at runtime
except ImportError as exc: # pragma: no cover - dep declared in pyproject
raise TransportConnectError(
"pypsrp is not installed; run `pip install pypsrp`."
) from exc
try:
self._client = Client(
self._host,
username=self._username,
password=self._password,
port=self._port,
ssl=self._ssl,
cert_validation=self._cert_validation,
connection_timeout=self._connection_timeout,
)
except Exception as exc:
raise TransportConnectError(
f"WinRM connection to {self._host}:{self._port} failed: {exc}"
) from exc
return self._client
def close(self) -> None:
if self._client is not None:
with contextlib.suppress(Exception):
self._client.wsman.close()
self._client = None
def __enter__(self) -> WinRmTransport:
self._connect()
return self
def __exit__(self, *_exc: object) -> None:
self.close()
# --------------------------------------------------------------- ops
def run(self, script: str, *, check: bool = True) -> WinRmResult:
"""Run a PowerShell script block in the guest."""
client = self._connect()
try:
stdout, stderr, returncode = client.execute_ps(script)
except Exception as exc:
raise TransportConnectError(f"WinRM execute_ps failed: {exc}") from exc
# pypsrp returns stderr as a list of PSRP error records in some versions;
# normalise to a string.
stderr_any: Any = stderr
if isinstance(stderr_any, str):
stderr_str = stderr_any
else:
try:
stderr_str = "\n".join(str(s) for s in stderr_any)
except TypeError:
stderr_str = str(stderr_any)
result = WinRmResult(stdout=stdout, stderr=stderr_str, returncode=int(returncode))
if check and not result.ok:
raise TransportCommandError(result.returncode, result.stdout, result.stderr)
return result
def copy(self, local_path: str | Path, remote_path: str) -> None:
"""Upload a file to the guest via PSRP."""
client = self._connect()
try:
client.copy(str(local_path), remote_path)
except Exception as exc:
raise TransportConnectError(f"WinRM copy failed: {exc}") from exc
def fetch(self, remote_path: str, local_path: str | Path) -> None:
"""Download a file from the guest via PSRP."""
client = self._connect()
try:
client.fetch(remote_path, str(local_path))
except Exception as exc:
raise TransportConnectError(f"WinRM fetch failed: {exc}") from exc
def is_ready(self) -> bool:
"""Lightweight readiness probe: run ``$true`` and check exit code."""
try:
result = self.run("$true | Out-Null", check=False)
except (TransportConnectError, TransportCommandError):
return False
return result.ok