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
+9
View File
@@ -0,0 +1,9 @@
"""Local CI/CD orchestrator package.
Phase A: Python rewrite of the PowerShell-based orchestrator.
See plans/implementation-plan-A-B.md for the full scope.
"""
__version__ = "0.1.0"
__all__ = ["__version__"]
+123
View File
@@ -0,0 +1,123 @@
"""CLI entry point.
Phase A1 ships the ``wait-ready`` PoC sub-command. Subsequent steps add
``vm``, ``build``, ``artifacts``, ``monitor``, ``report``, and ``job``.
"""
from __future__ import annotations
import sys
import time
import click
from ci_orchestrator import __version__
from ci_orchestrator.backends import load_backend
from ci_orchestrator.backends.errors import BackendError
from ci_orchestrator.backends.protocol import VmHandle
from ci_orchestrator.config import load_config
from ci_orchestrator.credentials import KeyringCredentialStore
from ci_orchestrator.transport.errors import TransportError
from ci_orchestrator.transport.ssh import SshTransport
from ci_orchestrator.transport.winrm import WinRmTransport
@click.group()
@click.version_option(__version__, prog_name="ci-orchestrator")
def cli() -> None:
"""Local CI/CD orchestrator (Phase A)."""
@cli.command("wait-ready")
@click.option("--vmx", required=True, help="Path to the guest VMX.")
@click.option(
"--guest-os",
type=click.Choice(["windows", "linux"]),
default="windows",
show_default=True,
)
@click.option("--timeout", type=float, default=300.0, show_default=True)
@click.option(
"--poll-interval", type=float, default=5.0, show_default=True, help="Seconds between probes."
)
@click.option(
"--credential-target",
default=None,
help="Override credential target name (defaults to config.guest_cred_target).",
)
@click.option("--ssh-user", default="ci_build", show_default=True)
def wait_ready(
vmx: str,
guest_os: str,
timeout: float,
poll_interval: float,
credential_target: str | None,
ssh_user: str,
) -> None:
"""Poll a guest until WinRM (Windows) or SSH (Linux) is responsive."""
config = load_config()
backend = load_backend(config)
handle = VmHandle(identifier=vmx)
deadline = time.monotonic() + timeout
# Step 1: wait until backend reports the VM as running.
click.echo(f"[wait-ready] waiting for backend to report VM running: {vmx}")
while time.monotonic() < deadline:
try:
if backend.is_running(handle):
break
except BackendError as exc:
click.echo(f"[wait-ready] backend probe error: {exc}", err=True)
time.sleep(poll_interval)
else:
click.echo("[wait-ready] timeout waiting for VM to be running.", err=True)
sys.exit(2)
# Step 2: wait for an IP.
ip: str | None = None
while time.monotonic() < deadline:
try:
ip = backend.get_ip(handle)
except BackendError:
ip = None
if ip:
break
time.sleep(poll_interval)
if not ip:
click.echo("[wait-ready] timeout waiting for guest IP.", err=True)
sys.exit(3)
click.echo(f"[wait-ready] guest IP: {ip}")
# Step 3: probe the transport layer.
if guest_os == "windows":
store = KeyringCredentialStore()
target = credential_target or config.guest_cred_target
cred = store.get(target)
while time.monotonic() < deadline:
try:
with WinRmTransport(ip, cred.username, cred.password) as t:
if t.is_ready():
click.echo("[wait-ready] WinRM ready.")
return
except TransportError as exc:
click.echo(f"[wait-ready] WinRM probe failed: {exc}", err=True)
time.sleep(poll_interval)
else:
key_path = config.ssh_key_path
while time.monotonic() < deadline:
try:
with SshTransport(ip, username=ssh_user, key_path=key_path) as t:
if t.is_ready():
click.echo("[wait-ready] SSH ready.")
return
except TransportError as exc:
click.echo(f"[wait-ready] SSH probe failed: {exc}", err=True)
time.sleep(poll_interval)
click.echo("[wait-ready] timeout waiting for transport to be ready.", err=True)
sys.exit(4)
if __name__ == "__main__": # pragma: no cover
cli()
+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
+163
View File
@@ -0,0 +1,163 @@
"""Configuration loading for the CI orchestrator.
Resolution order (later wins):
1. OS-aware defaults (Windows: F:\\CI\\..., Linux: /var/lib/ci/...).
2. Optional ``config.toml`` file (path via ``CI_CONFIG`` env var or
``$CI_ROOT/config.toml`` if present).
3. Environment variables (``CI_ROOT``, ``CI_TEMPLATES``, ``CI_BUILD_VMS``,
``CI_ARTIFACTS``, ``CI_KEYS``).
Phase C hook: a ``[backend]`` section in ``config.toml`` selects the VM
backend implementation; the field is read but currently only the
``workstation`` value is supported.
"""
from __future__ import annotations
import os
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
_ENV_KEYS = ("CI_ROOT", "CI_TEMPLATES", "CI_BUILD_VMS", "CI_ARTIFACTS", "CI_KEYS")
@dataclass(frozen=True)
class Paths:
"""Filesystem paths used by the orchestrator."""
root: Path
templates: Path
build_vms: Path
artifacts: Path
keys: Path
@dataclass(frozen=True)
class BackendConfig:
"""Backend selector. ``type`` is currently always 'workstation'."""
type: str = "workstation"
options: dict[str, Any] = field(default_factory=dict)
@dataclass(frozen=True)
class Config:
"""Top-level configuration object."""
paths: Paths
backend: BackendConfig
vmrun_path: str | None = None
ssh_key_path: Path | None = None
guest_cred_target: str = "BuildVMGuest"
def _platform_defaults() -> Paths:
root = Path(r"F:\CI") if os.name == "nt" else Path("/var/lib/ci")
return Paths(
root=root,
templates=root / "Templates" if os.name == "nt" else root / "templates",
build_vms=root / "BuildVMs" if os.name == "nt" else root / "build-vms",
artifacts=root / "Artifacts" if os.name == "nt" else root / "artifacts",
keys=root / "keys" if os.name == "nt" else Path("/etc/ci/keys"),
)
def _load_toml(path: Path) -> dict[str, Any]:
with path.open("rb") as fh:
return tomllib.load(fh)
def _merge_paths(base: Paths, overrides: dict[str, Any]) -> Paths:
def pick(key: str, current: Path) -> Path:
val = overrides.get(key)
return Path(val) if val else current
return Paths(
root=pick("root", base.root),
templates=pick("templates", base.templates),
build_vms=pick("build_vms", base.build_vms),
artifacts=pick("artifacts", base.artifacts),
keys=pick("keys", base.keys),
)
def _apply_env(paths: Paths, env: dict[str, str]) -> Paths:
mapping = {
"CI_ROOT": "root",
"CI_TEMPLATES": "templates",
"CI_BUILD_VMS": "build_vms",
"CI_ARTIFACTS": "artifacts",
"CI_KEYS": "keys",
}
overrides: dict[str, Any] = {}
for env_key, attr in mapping.items():
val = env.get(env_key)
if val:
overrides[attr] = val
return _merge_paths(paths, overrides)
def load_config(
config_path: Path | None = None,
env: dict[str, str] | None = None,
) -> Config:
"""Build a :class:`Config` from defaults, optional TOML file, and env vars.
Parameters
----------
config_path:
Explicit path to a TOML file. If ``None``, looks at ``$CI_CONFIG`` then
``$CI_ROOT/config.toml``.
env:
Environment dict (defaults to :data:`os.environ`). Injected for tests.
"""
if env is None:
env = dict(os.environ)
paths = _platform_defaults()
# Resolve config file
toml_path: Path | None = None
if config_path is not None:
toml_path = config_path
elif env.get("CI_CONFIG"):
toml_path = Path(env["CI_CONFIG"])
else:
candidate = paths.root / "config.toml"
if candidate.is_file():
toml_path = candidate
backend = BackendConfig()
vmrun_path: str | None = env.get("CI_VMRUN_PATH")
ssh_key_path = Path(env["CI_SSH_KEY_PATH"]) if env.get("CI_SSH_KEY_PATH") else None
guest_cred_target = env.get("CI_GUEST_CRED_TARGET", "BuildVMGuest")
if toml_path and toml_path.is_file():
data = _load_toml(toml_path)
paths = _merge_paths(paths, data.get("paths", {}))
backend_section = data.get("backend", {})
if backend_section:
backend = BackendConfig(
type=str(backend_section.get("type", "workstation")),
options={k: v for k, v in backend_section.items() if k != "type"},
)
if vmrun_path is None:
vmrun_path = data.get("vmrun_path")
if ssh_key_path is None and data.get("ssh_key_path"):
ssh_key_path = Path(data["ssh_key_path"])
guest_cred_target = data.get("guest_cred_target", guest_cred_target)
paths = _apply_env(paths, env)
return Config(
paths=paths,
backend=backend,
vmrun_path=vmrun_path,
ssh_key_path=ssh_key_path,
guest_cred_target=guest_cred_target,
)
__all__ = ["BackendConfig", "Config", "Paths", "load_config"]
+59
View File
@@ -0,0 +1,59 @@
"""Credential store abstraction.
Wraps the ``keyring`` library so callers do not depend on it directly.
Phase B note: the same interface is used on both Windows (Credential
Manager) and Linux (Secret Service / file-based vault); see
``plans/implementation-plan-A-B.md`` step B3.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class Credential:
"""Username + secret pair retrieved from a credential store."""
username: str
password: str
class CredentialStore(Protocol):
"""Read-only credential lookup."""
def get(self, target: str) -> Credential:
"""Return the credential associated with ``target``.
Raises :class:`KeyError` if the target is unknown.
"""
...
class KeyringCredentialStore:
"""Default :class:`CredentialStore` backed by the ``keyring`` library."""
def __init__(self, service: str = "ci") -> None:
self._service = service
def get(self, target: str) -> Credential:
try:
import keyring
except ImportError as exc: # pragma: no cover - dep declared
raise RuntimeError(
"keyring is not installed; run `pip install keyring`."
) from exc
cred = keyring.get_credential(target, None)
if cred is None:
# Fallback: some backends only support get_password and need the
# username to be the same as the target.
password = keyring.get_password(self._service, target)
if password is None:
raise KeyError(f"Credential '{target}' not found in keyring.")
return Credential(username=target, password=password)
return Credential(username=cred.username, password=cred.password)
__all__ = ["Credential", "CredentialStore", "KeyringCredentialStore"]
@@ -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"]
+23
View File
@@ -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
+150
View File
@@ -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
+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