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,9 @@
|
||||
"""Guest transport layer (WinRM for Windows, SSH for Linux)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ci_orchestrator.transport.errors import TransportError
|
||||
from ci_orchestrator.transport.ssh import SshTransport
|
||||
from ci_orchestrator.transport.winrm import WinRmTransport
|
||||
|
||||
__all__ = ["SshTransport", "TransportError", "WinRmTransport"]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Transport error hierarchy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
"""Base class for transport-layer (WinRM/SSH) failures."""
|
||||
|
||||
|
||||
class TransportConnectError(TransportError):
|
||||
"""Raised when the underlying connection cannot be established."""
|
||||
|
||||
|
||||
class TransportCommandError(TransportError):
|
||||
"""Raised when a remote command exits non-zero."""
|
||||
|
||||
def __init__(self, returncode: int, stdout: str, stderr: str) -> None:
|
||||
super().__init__(
|
||||
f"Remote command failed (exit={returncode}): {(stderr or stdout).strip()[:500]}"
|
||||
)
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
@@ -0,0 +1,150 @@
|
||||
"""SSH transport via ``paramiko``.
|
||||
|
||||
Replaces the ``ssh.exe`` / ``scp.exe`` calls used in
|
||||
``scripts/_Transport.psm1``. By default the host-key policy is
|
||||
``AutoAddPolicy`` with an empty ``known_hosts`` file (i.e. permissive),
|
||||
which mirrors the ``StrictHostKeyChecking=no UserKnownHostsFile=NUL``
|
||||
default used for ephemeral CI build VMs.
|
||||
|
||||
See ``AGENTS.md`` error #12: in PowerShell with ``$ErrorActionPreference =
|
||||
'Stop'`` the native ``ssh-keygen -R`` call could raise terminating errors
|
||||
on stderr output. With paramiko there is no equivalent issue.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from ci_orchestrator.transport.errors import TransportCommandError, TransportConnectError
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
import paramiko
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SshResult:
|
||||
"""Result of a remote SSH command."""
|
||||
|
||||
stdout: str
|
||||
stderr: str
|
||||
returncode: int
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return self.returncode == 0
|
||||
|
||||
|
||||
class SshTransport:
|
||||
"""Thin wrapper around :class:`paramiko.SSHClient`."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
username: str = "ci_build",
|
||||
key_path: str | Path | None = None,
|
||||
port: int = 22,
|
||||
connect_timeout: float = 10.0,
|
||||
known_hosts: str | Path | None = None,
|
||||
) -> None:
|
||||
self._host = host
|
||||
self._username = username
|
||||
self._key_path = Path(key_path) if key_path else None
|
||||
self._port = port
|
||||
self._connect_timeout = connect_timeout
|
||||
self._known_hosts = Path(known_hosts) if known_hosts else None
|
||||
self._client: paramiko.SSHClient | None = None
|
||||
|
||||
# ----------------------------------------------------------- connection
|
||||
def _connect(self) -> paramiko.SSHClient:
|
||||
if self._client is not None:
|
||||
return self._client
|
||||
try:
|
||||
import paramiko
|
||||
except ImportError as exc: # pragma: no cover - dep declared
|
||||
raise TransportConnectError(
|
||||
"paramiko is not installed; run `pip install paramiko`."
|
||||
) from exc
|
||||
|
||||
client = paramiko.SSHClient()
|
||||
if self._known_hosts is not None and self._known_hosts.is_file():
|
||||
client.load_host_keys(str(self._known_hosts))
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
else:
|
||||
# Permissive mode for ephemeral CI VMs (see _Transport.psm1).
|
||||
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||
|
||||
try:
|
||||
client.connect(
|
||||
hostname=self._host,
|
||||
port=self._port,
|
||||
username=self._username,
|
||||
key_filename=str(self._key_path) if self._key_path else None,
|
||||
timeout=self._connect_timeout,
|
||||
allow_agent=False,
|
||||
look_for_keys=False,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise TransportConnectError(
|
||||
f"SSH connection to {self._username}@{self._host}:{self._port} failed: {exc}"
|
||||
) from exc
|
||||
self._client = client
|
||||
return client
|
||||
|
||||
def close(self) -> None:
|
||||
if self._client is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def __enter__(self) -> SshTransport:
|
||||
self._connect()
|
||||
return self
|
||||
|
||||
def __exit__(self, *_exc: object) -> None:
|
||||
self.close()
|
||||
|
||||
# --------------------------------------------------------------- ops
|
||||
def run(self, command: str, *, check: bool = True, timeout: float | None = None) -> SshResult:
|
||||
"""Run ``command`` in the guest shell."""
|
||||
client = self._connect()
|
||||
try:
|
||||
_stdin, stdout, stderr = client.exec_command(command, timeout=timeout)
|
||||
out = stdout.read().decode("utf-8", errors="replace")
|
||||
err = stderr.read().decode("utf-8", errors="replace")
|
||||
rc = stdout.channel.recv_exit_status()
|
||||
except Exception as exc:
|
||||
raise TransportConnectError(f"SSH exec_command failed: {exc}") from exc
|
||||
result = SshResult(stdout=out, stderr=err, returncode=rc)
|
||||
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 via SFTP."""
|
||||
client = self._connect()
|
||||
try:
|
||||
with client.open_sftp() as sftp:
|
||||
sftp.put(str(local_path), remote_path)
|
||||
except Exception as exc:
|
||||
raise TransportConnectError(f"SFTP put failed: {exc}") from exc
|
||||
|
||||
def fetch(self, remote_path: str, local_path: str | Path) -> None:
|
||||
"""Download via SFTP."""
|
||||
client = self._connect()
|
||||
try:
|
||||
with client.open_sftp() as sftp:
|
||||
sftp.get(remote_path, str(local_path))
|
||||
except Exception as exc:
|
||||
raise TransportConnectError(f"SFTP get failed: {exc}") from exc
|
||||
|
||||
def is_ready(self) -> bool:
|
||||
"""Lightweight readiness probe."""
|
||||
try:
|
||||
result = self.run("true", check=False, timeout=5.0)
|
||||
except (TransportConnectError, TransportCommandError):
|
||||
return False
|
||||
return result.ok
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user