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,159 @@
|
||||
"""Tests for SshTransport (paramiko mocked).
|
||||
|
||||
Covers AGENTS.md error #12 indirectly: with paramiko there is no native
|
||||
``ssh-keygen`` invocation, so the PowerShell stderr-handling pitfall does
|
||||
not apply. The default permissive host-key policy mirrors the
|
||||
``StrictHostKeyChecking=no`` / ``UserKnownHostsFile=NUL`` defaults of
|
||||
``scripts/_Transport.psm1``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
import types
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from ci_orchestrator.transport.errors import TransportCommandError, TransportConnectError
|
||||
from ci_orchestrator.transport.ssh import SshTransport
|
||||
|
||||
|
||||
class _FakeChannel:
|
||||
def __init__(self, rc: int) -> None:
|
||||
self._rc = rc
|
||||
|
||||
def recv_exit_status(self) -> int:
|
||||
return self._rc
|
||||
|
||||
|
||||
class _FakeStream:
|
||||
def __init__(self, data: bytes, rc: int = 0) -> None:
|
||||
self._data = data
|
||||
self.channel = _FakeChannel(rc)
|
||||
|
||||
def read(self) -> bytes:
|
||||
return self._data
|
||||
|
||||
|
||||
class _FakeSFTP:
|
||||
def __init__(self) -> None:
|
||||
self.puts: list[tuple[str, str]] = []
|
||||
self.gets: list[tuple[str, str]] = []
|
||||
|
||||
def __enter__(self) -> _FakeSFTP:
|
||||
return self
|
||||
|
||||
def __exit__(self, *_e: object) -> None:
|
||||
pass
|
||||
|
||||
def put(self, local: str, remote: str) -> None:
|
||||
self.puts.append((local, remote))
|
||||
|
||||
def get(self, remote: str, local: str) -> None:
|
||||
self.gets.append((remote, local))
|
||||
|
||||
|
||||
class _FakeSSHClient:
|
||||
def __init__(self) -> None:
|
||||
self.connect_kwargs: dict[str, Any] = {}
|
||||
self.policy: Any = None
|
||||
self.next_stdout = b""
|
||||
self.next_stderr = b""
|
||||
self.next_rc = 0
|
||||
self.commands: list[str] = []
|
||||
self.sftp = _FakeSFTP()
|
||||
self.closed = False
|
||||
|
||||
def set_missing_host_key_policy(self, policy: Any) -> None:
|
||||
self.policy = policy
|
||||
|
||||
def load_host_keys(self, _path: str) -> None:
|
||||
pass
|
||||
|
||||
def connect(self, **kwargs: Any) -> None:
|
||||
self.connect_kwargs = kwargs
|
||||
|
||||
def exec_command(self, command: str, timeout: float | None = None) -> Any:
|
||||
self.commands.append(command)
|
||||
return (
|
||||
None,
|
||||
_FakeStream(self.next_stdout, self.next_rc),
|
||||
_FakeStream(self.next_stderr, self.next_rc),
|
||||
)
|
||||
|
||||
def open_sftp(self) -> _FakeSFTP:
|
||||
return self.sftp
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
|
||||
def _install_fake_paramiko(monkeypatch: pytest.MonkeyPatch, client: _FakeSSHClient) -> Any:
|
||||
fake = types.ModuleType("paramiko")
|
||||
fake.SSHClient = lambda: client # type: ignore[attr-defined]
|
||||
fake.AutoAddPolicy = lambda: "auto-add" # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "paramiko", fake)
|
||||
return fake
|
||||
|
||||
|
||||
def test_run_returns_stdout(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeSSHClient()
|
||||
client.next_stdout = b"hello\n"
|
||||
_install_fake_paramiko(monkeypatch, client)
|
||||
t = SshTransport("10.0.0.2", username="ci_build", key_path="/tmp/key")
|
||||
result = t.run("echo hello")
|
||||
assert result.ok
|
||||
assert result.stdout == "hello\n"
|
||||
assert client.commands == ["echo hello"]
|
||||
|
||||
|
||||
def test_run_raises_on_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeSSHClient()
|
||||
client.next_rc = 2
|
||||
client.next_stderr = b"oops"
|
||||
_install_fake_paramiko(monkeypatch, client)
|
||||
t = SshTransport("10.0.0.2", key_path="/tmp/key")
|
||||
with pytest.raises(TransportCommandError):
|
||||
t.run("bad")
|
||||
|
||||
|
||||
def test_default_policy_is_auto_add(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Mirrors permissive `StrictHostKeyChecking=no` default for ephemeral CI VMs."""
|
||||
client = _FakeSSHClient()
|
||||
_install_fake_paramiko(monkeypatch, client)
|
||||
SshTransport("10.0.0.2", key_path="/tmp/key").run("true")
|
||||
assert client.policy == "auto-add"
|
||||
|
||||
|
||||
def test_copy_and_fetch(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = _FakeSSHClient()
|
||||
_install_fake_paramiko(monkeypatch, client)
|
||||
t = SshTransport("10.0.0.2", key_path="/tmp/key")
|
||||
t.copy("local.bin", "/remote/local.bin")
|
||||
t.fetch("/remote/out.bin", "out.bin")
|
||||
assert client.sftp.puts == [("local.bin", "/remote/local.bin")]
|
||||
assert client.sftp.gets == [("/remote/out.bin", "out.bin")]
|
||||
|
||||
|
||||
def test_is_ready_returns_false_on_connect_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
fake = types.ModuleType("paramiko")
|
||||
|
||||
class _Boom:
|
||||
def set_missing_host_key_policy(self, _p: object) -> None:
|
||||
pass
|
||||
|
||||
def connect(self, **_kw: Any) -> None:
|
||||
raise OSError("refused")
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
fake.SSHClient = lambda: _Boom() # type: ignore[attr-defined]
|
||||
fake.AutoAddPolicy = lambda: object() # type: ignore[attr-defined]
|
||||
monkeypatch.setitem(sys.modules, "paramiko", fake)
|
||||
|
||||
t = SshTransport("10.0.0.2", key_path="/tmp/key")
|
||||
assert t.is_ready() is False
|
||||
with pytest.raises(TransportConnectError):
|
||||
t.run("x")
|
||||
Reference in New Issue
Block a user