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 @@
|
||||
"""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__"]
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
@@ -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