test(a5): add pytest regression tests for AGENTS.md errors #9-#12
12 dedicated tests covering: (#9) clone_linked surfaces 'should not be powered on' as typed BackendError; (#10) is_running uses 'vmrun list' only and never falls back to getGuestIPAddress (incl. on vmrun list failure); (#11) two consecutive 'vm new' invocations with identical inputs MUST produce distinct clone names/destinations (timestamp suffix), guarding the orchestrator-side safety net against the machine-id duplicate-IP failure mode; (#12) SshTransport defaults to paramiko AutoAddPolicy with no known_hosts, never shells out to ssh-keygen/ssh.exe/scp.exe, wraps paramiko import/exec/SFTP errors as TransportConnectError. Coverage: 80.10% (gate 80%).
This commit is contained in:
@@ -0,0 +1,489 @@
|
|||||||
|
"""Regression tests for ``AGENTS.md`` "Errori frequenti da evitare" #9-#12.
|
||||||
|
|
||||||
|
Each test is a guard against re-introducing a class of bug that already
|
||||||
|
caused incidents in the PowerShell stack and that the Python rewrite must
|
||||||
|
keep fixed. If you find yourself relaxing one of these tests, update
|
||||||
|
``AGENTS.md`` first and explain why the constraint no longer applies.
|
||||||
|
|
||||||
|
Cross-reference:
|
||||||
|
|
||||||
|
* ``AGENTS.md`` §"Errori frequenti da evitare", entries 9-12.
|
||||||
|
* ``plans/implementation-plan-A-B.md`` step A5.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from ci_orchestrator.backends.errors import BackendError, BackendOperationFailed
|
||||||
|
from ci_orchestrator.backends.protocol import VmHandle
|
||||||
|
from ci_orchestrator.backends.workstation import WorkstationVmrunBackend
|
||||||
|
from ci_orchestrator.commands.vm import vm as vm_group
|
||||||
|
from ci_orchestrator.transport.ssh import SshTransport
|
||||||
|
|
||||||
|
# ────────────────────────────────────────────────────────────────────── helpers
|
||||||
|
|
||||||
|
|
||||||
|
def _completed(returncode: int = 0, stdout: str = "", stderr: str = "") -> Any:
|
||||||
|
return subprocess.CompletedProcess(
|
||||||
|
args=[], returncode=returncode, stdout=stdout, stderr=stderr
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def fake_vmrun(tmp_path: Path) -> str:
|
||||||
|
p = tmp_path / "vmrun.exe"
|
||||||
|
p.write_bytes(b"")
|
||||||
|
return str(p)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────── AGENTS.md #9 — powered-on snapshot
|
||||||
|
|
||||||
|
|
||||||
|
class TestError9PoweredOnSnapshot:
|
||||||
|
"""``vmrun clone ... linked -snapshot=<name>`` rejects a snapshot
|
||||||
|
captured with the template VM still powered on (memory ``*.vmem`` files
|
||||||
|
present). The backend MUST surface that error as a typed
|
||||||
|
:class:`BackendError` with the original vmrun message preserved so the
|
||||||
|
operator can act (re-take the snapshot from a fully powered-off VM).
|
||||||
|
"""
|
||||||
|
|
||||||
|
_POWERED_ON_STDERR = (
|
||||||
|
"Error: The virtual machine should not be powered on. "
|
||||||
|
"It is already running.\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_clone_linked_propagates_powered_on_error_typed(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, fake_vmrun: str
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subprocess,
|
||||||
|
"run",
|
||||||
|
lambda *a, **kw: _completed(255, stderr=self._POWERED_ON_STDERR),
|
||||||
|
)
|
||||||
|
backend = WorkstationVmrunBackend(vmrun_path=fake_vmrun)
|
||||||
|
|
||||||
|
with pytest.raises(BackendOperationFailed) as exc_info:
|
||||||
|
backend.clone_linked(
|
||||||
|
template=r"C:\tpl\WinBuild2025\WinBuild2025.vmx",
|
||||||
|
snapshot="BaseClean",
|
||||||
|
name="job-9",
|
||||||
|
destination=r"C:\bvm\job-9\job-9.vmx",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Must be a BackendError subclass (typed, not a bare RuntimeError).
|
||||||
|
assert isinstance(exc_info.value, BackendError)
|
||||||
|
# Operation name must match so callers can pattern-match.
|
||||||
|
assert exc_info.value.operation == "clone"
|
||||||
|
assert exc_info.value.returncode == 255
|
||||||
|
# Original vmrun message must be preserved verbatim for triage.
|
||||||
|
assert "should not be powered on" in exc_info.value.output
|
||||||
|
|
||||||
|
|
||||||
|
# ───────────────────────────────────────────── AGENTS.md #10 — is_running via vmrun list
|
||||||
|
|
||||||
|
|
||||||
|
class TestError10IsRunningUsesVmrunList:
|
||||||
|
"""``vmrun getGuestIPAddress`` blocks 30-60s when VMware Tools are not
|
||||||
|
yet up; using it as a "VM running?" probe makes ``is_running`` appear
|
||||||
|
hung. The backend MUST use ``vmrun list`` parsing instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_is_running_calls_vmrun_list_only(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, fake_vmrun: str
|
||||||
|
) -> None:
|
||||||
|
vmx = tmp_path / "job10" / "job10.vmx"
|
||||||
|
vmx.parent.mkdir()
|
||||||
|
vmx.write_text("")
|
||||||
|
|
||||||
|
captured: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: list[str], **_kwargs: object) -> Any:
|
||||||
|
captured.append(cmd)
|
||||||
|
assert cmd[3] == "list", (
|
||||||
|
f"is_running invoked unexpected vmrun op: {cmd[3]!r} "
|
||||||
|
"(MUST be 'list', see AGENTS.md error #10)"
|
||||||
|
)
|
||||||
|
return _completed(0, stdout=f"Total running VMs: 1\n{vmx}\n")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
backend = WorkstationVmrunBackend(vmrun_path=fake_vmrun)
|
||||||
|
|
||||||
|
assert backend.is_running(VmHandle(str(vmx))) is True
|
||||||
|
|
||||||
|
# Exactly one subprocess call, and its operation MUST be 'list'.
|
||||||
|
assert len(captured) == 1
|
||||||
|
ops = [cmd[3] for cmd in captured]
|
||||||
|
assert ops == ["list"]
|
||||||
|
# Hard guard: no getGuestIPAddress invocation, ever.
|
||||||
|
for cmd in captured:
|
||||||
|
assert "getGuestIPAddress" not in cmd
|
||||||
|
|
||||||
|
def test_is_running_false_when_not_listed(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, fake_vmrun: str
|
||||||
|
) -> None:
|
||||||
|
vmx = tmp_path / "absent.vmx"
|
||||||
|
vmx.write_text("")
|
||||||
|
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: list[str], **_kwargs: object) -> Any:
|
||||||
|
captured.append(cmd[3])
|
||||||
|
return _completed(0, stdout="Total running VMs: 0\n")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
backend = WorkstationVmrunBackend(vmrun_path=fake_vmrun)
|
||||||
|
assert backend.is_running(VmHandle(str(vmx))) is False
|
||||||
|
assert captured == ["list"]
|
||||||
|
assert "getGuestIPAddress" not in captured
|
||||||
|
|
||||||
|
def test_is_running_false_on_vmrun_failure_without_getip_fallback(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, fake_vmrun: str
|
||||||
|
) -> None:
|
||||||
|
"""Even on ``vmrun list`` failure, the backend MUST NOT fall back
|
||||||
|
to ``getGuestIPAddress`` (which would block on Tools)."""
|
||||||
|
vmx = tmp_path / "absent.vmx"
|
||||||
|
vmx.write_text("")
|
||||||
|
|
||||||
|
captured: list[str] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: list[str], **_kwargs: object) -> Any:
|
||||||
|
captured.append(cmd[3])
|
||||||
|
return _completed(1, stderr="vmrun broke")
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
backend = WorkstationVmrunBackend(vmrun_path=fake_vmrun)
|
||||||
|
assert backend.is_running(VmHandle(str(vmx))) is False
|
||||||
|
assert captured == ["list"]
|
||||||
|
assert "getGuestIPAddress" not in captured
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────── AGENTS.md #11 — machine-id / unique clones
|
||||||
|
|
||||||
|
|
||||||
|
class TestError11UniqueCloneNames:
|
||||||
|
"""Ubuntu cloud images ship with a fixed ``/etc/machine-id``. Two
|
||||||
|
clones that share machine-id collide on the VMware DHCP lease and
|
||||||
|
deadlock on duplicate IPs. The template-side fix lives in
|
||||||
|
:file:`template/Prepare-LinuxBuild2404.ps1` (``truncate -s 0
|
||||||
|
/etc/machine-id`` before snapshot).
|
||||||
|
|
||||||
|
On the orchestrator side, the safety net is that ``vm new`` MUST never
|
||||||
|
reuse a clone identifier — otherwise even a correct template would
|
||||||
|
end up cloning into an existing VMX directory and the duplicate-IP
|
||||||
|
failure mode would re-appear from a different angle. We assert that
|
||||||
|
two ``vm new`` invocations with identical inputs produce distinct
|
||||||
|
destination paths (timestamp-suffixed clone names).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _invoke_vm_new(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
fake_vmrun: str,
|
||||||
|
) -> tuple[CliRunner, list[dict[str, str]]]:
|
||||||
|
# Ensure load_config has env keys it expects (no .toml present).
|
||||||
|
for k in ("CI_ROOT", "CI_TEMPLATES", "CI_BUILD_VMS", "CI_ARTIFACTS", "CI_KEYS"):
|
||||||
|
monkeypatch.delenv(k, raising=False)
|
||||||
|
|
||||||
|
template = tmp_path / "tpl" / "tpl.vmx"
|
||||||
|
template.parent.mkdir()
|
||||||
|
template.write_text("")
|
||||||
|
|
||||||
|
clone_base = tmp_path / "clones"
|
||||||
|
|
||||||
|
calls: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
def fake_clone(
|
||||||
|
self: WorkstationVmrunBackend,
|
||||||
|
template: str,
|
||||||
|
snapshot: str,
|
||||||
|
name: str,
|
||||||
|
destination: str | None = None,
|
||||||
|
) -> VmHandle:
|
||||||
|
assert destination is not None
|
||||||
|
# Materialise the clone VMX so vm_new's post-condition passes.
|
||||||
|
dest = Path(destination)
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
dest.write_text("")
|
||||||
|
calls.append(
|
||||||
|
{"name": name, "destination": destination, "snapshot": snapshot}
|
||||||
|
)
|
||||||
|
return VmHandle(identifier=destination, name=name)
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
WorkstationVmrunBackend, "clone_linked", fake_clone, raising=True
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
args = [
|
||||||
|
"new",
|
||||||
|
"--template-path",
|
||||||
|
str(template),
|
||||||
|
"--snapshot-name",
|
||||||
|
"BaseClean",
|
||||||
|
"--clone-base-dir",
|
||||||
|
str(clone_base),
|
||||||
|
"--job-id",
|
||||||
|
"job-11",
|
||||||
|
"--vmrun-path",
|
||||||
|
fake_vmrun,
|
||||||
|
]
|
||||||
|
# Two consecutive runs.
|
||||||
|
for _ in range(2):
|
||||||
|
result = runner.invoke(vm_group, args, catch_exceptions=False)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
return runner, calls
|
||||||
|
|
||||||
|
def test_two_vm_new_invocations_produce_distinct_clones(
|
||||||
|
self,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
fake_vmrun: str,
|
||||||
|
) -> None:
|
||||||
|
# Drive the timestamp generator so the two invocations land in
|
||||||
|
# different seconds (vm_new uses %Y%m%d_%H%M%S).
|
||||||
|
from ci_orchestrator.commands import vm as vm_module
|
||||||
|
|
||||||
|
ticks = iter(["20260514_120000", "20260514_120001"])
|
||||||
|
|
||||||
|
class _FakeDT:
|
||||||
|
@staticmethod
|
||||||
|
def now(tz: object | None = None) -> Any:
|
||||||
|
class _F:
|
||||||
|
def strftime(self_inner, _fmt: str) -> str:
|
||||||
|
return next(ticks)
|
||||||
|
|
||||||
|
return _F()
|
||||||
|
|
||||||
|
monkeypatch.setattr(vm_module, "datetime", _FakeDT)
|
||||||
|
|
||||||
|
_runner, calls = self._invoke_vm_new(monkeypatch, tmp_path, fake_vmrun)
|
||||||
|
|
||||||
|
assert len(calls) == 2
|
||||||
|
names = {c["name"] for c in calls}
|
||||||
|
destinations = {c["destination"] for c in calls}
|
||||||
|
assert len(names) == 2, (
|
||||||
|
f"vm new must never produce duplicate clone names; got {names} "
|
||||||
|
"(see AGENTS.md error #11 — machine-id / DHCP collision)"
|
||||||
|
)
|
||||||
|
assert len(destinations) == 2, (
|
||||||
|
"Two vm new invocations produced the same destination VMX path; "
|
||||||
|
"this would clone over an existing build VM and risks reproducing "
|
||||||
|
"the duplicate-IP failure mode (AGENTS.md error #11)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────── AGENTS.md #12 — no ssh-keygen / AutoAddPolicy
|
||||||
|
|
||||||
|
|
||||||
|
class TestError12SshTransportNoSshKeygen:
|
||||||
|
"""``& ssh-keygen -R <ip> 2>$null`` does NOT suppress stderr in
|
||||||
|
Windows PowerShell 5.1 with ``$ErrorActionPreference='Stop'``: stderr
|
||||||
|
is converted to a terminating ErrorRecord before ``2>$null`` can
|
||||||
|
discard it. The Python rewrite sidesteps the entire problem by using
|
||||||
|
paramiko with :class:`paramiko.AutoAddPolicy` and an empty
|
||||||
|
``known_hosts`` (mirroring ``StrictHostKeyChecking=no
|
||||||
|
UserKnownHostsFile=NUL``). We assert there is no native shell-out to
|
||||||
|
``ssh-keygen`` and that ``AutoAddPolicy`` is installed by default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _install_fake_paramiko(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> tuple[Any, Any]:
|
||||||
|
"""Install a stub paramiko module that records all attribute access."""
|
||||||
|
|
||||||
|
accessed: list[str] = []
|
||||||
|
|
||||||
|
class _Channel:
|
||||||
|
def recv_exit_status(self) -> int:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
class _Stream:
|
||||||
|
channel = _Channel()
|
||||||
|
|
||||||
|
def read(self) -> bytes:
|
||||||
|
return b""
|
||||||
|
|
||||||
|
class _SFTP:
|
||||||
|
def __enter__(self) -> _SFTP:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_e: object) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def put(self, _l: str, _r: str) -> None: # pragma: no cover - unused
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get(self, _r: str, _l: str) -> None: # pragma: no cover - unused
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _Client:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.policy: Any = None
|
||||||
|
self.connect_kwargs: dict[str, Any] = {}
|
||||||
|
self.commands: list[str] = []
|
||||||
|
|
||||||
|
def set_missing_host_key_policy(self, policy: Any) -> None:
|
||||||
|
self.policy = policy
|
||||||
|
|
||||||
|
def load_host_keys(self, _path: str) -> None: # pragma: no cover
|
||||||
|
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, _Stream(), _Stream()
|
||||||
|
|
||||||
|
def open_sftp(self) -> _SFTP: # pragma: no cover - unused
|
||||||
|
return _SFTP()
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
client = _Client()
|
||||||
|
|
||||||
|
class _ModWrap(types.ModuleType):
|
||||||
|
def __getattr__(self, item: str) -> Any:
|
||||||
|
accessed.append(item)
|
||||||
|
raise AttributeError(item)
|
||||||
|
|
||||||
|
fake = _ModWrap("paramiko")
|
||||||
|
# Pre-define exactly the attributes SshTransport is allowed to use.
|
||||||
|
fake.SSHClient = lambda: client # type: ignore[attr-defined]
|
||||||
|
fake.AutoAddPolicy = lambda: "auto-add" # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "paramiko", fake)
|
||||||
|
return client, accessed
|
||||||
|
|
||||||
|
def test_default_policy_is_auto_add_no_known_hosts(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
client, _accessed = self._install_fake_paramiko(monkeypatch)
|
||||||
|
t = SshTransport("10.0.0.99", username="ci_build", key_path="/tmp/k")
|
||||||
|
t.run("true")
|
||||||
|
# AutoAddPolicy is the marker that we're matching the
|
||||||
|
# `StrictHostKeyChecking=no` permissive default.
|
||||||
|
assert client.policy == "auto-add", (
|
||||||
|
"SshTransport must default to paramiko.AutoAddPolicy "
|
||||||
|
"(see AGENTS.md error #12)."
|
||||||
|
)
|
||||||
|
# Ensure the connect call did NOT pass any known_hosts file path,
|
||||||
|
# which would re-introduce the host-key churn problem.
|
||||||
|
assert "key_filename" in client.connect_kwargs
|
||||||
|
|
||||||
|
def test_no_ssh_keygen_invocation(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""SshTransport must not shell out to ``ssh-keygen`` (or anything
|
||||||
|
else): all transport goes through paramiko."""
|
||||||
|
called: list[list[str]] = []
|
||||||
|
|
||||||
|
def fake_run(cmd: list[str], *_a: object, **_kw: object) -> Any:
|
||||||
|
called.append(cmd)
|
||||||
|
return _completed(0)
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "run", fake_run)
|
||||||
|
|
||||||
|
# Also patch Popen as a belt-and-braces guard.
|
||||||
|
def fake_popen(*a: object, **_kw: object) -> Any: # pragma: no cover
|
||||||
|
called.append(list(a))
|
||||||
|
raise AssertionError(
|
||||||
|
"SshTransport must not Popen any external process "
|
||||||
|
"(AGENTS.md error #12)."
|
||||||
|
)
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||||
|
|
||||||
|
client, _accessed = self._install_fake_paramiko(monkeypatch)
|
||||||
|
t = SshTransport("10.0.0.99", key_path="/tmp/k")
|
||||||
|
t.run("uname -a")
|
||||||
|
t.close()
|
||||||
|
|
||||||
|
assert called == [], (
|
||||||
|
"SshTransport spawned a subprocess; it must rely solely on "
|
||||||
|
"paramiko (no ssh-keygen, no ssh.exe, no scp.exe). Calls: "
|
||||||
|
f"{called!r}"
|
||||||
|
)
|
||||||
|
assert client.commands == ["uname -a"]
|
||||||
|
|
||||||
|
def test_known_hosts_optional_param_is_off_by_default(self) -> None:
|
||||||
|
"""The ``known_hosts`` constructor parameter must default to None
|
||||||
|
so ephemeral CI VMs never hit a stale host-key error that would
|
||||||
|
previously have driven the broken ``ssh-keygen -R`` workaround."""
|
||||||
|
t = SshTransport("10.0.0.99", key_path="/tmp/k")
|
||||||
|
# Internal attribute is part of the documented contract for this
|
||||||
|
# regression: flipping the default would silently break ephemeral
|
||||||
|
# VM workflows. If you need to change it, update AGENTS.md #12.
|
||||||
|
assert t._known_hosts is None
|
||||||
|
|
||||||
|
def test_paramiko_missing_raises_typed_error(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""When paramiko is not installed, the transport must raise a
|
||||||
|
typed :class:`TransportConnectError` — not a bare ``ImportError``
|
||||||
|
leaking through. Symmetric to the PowerShell guard that wrapped
|
||||||
|
``ssh.exe`` failures."""
|
||||||
|
from ci_orchestrator.transport.errors import TransportConnectError
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "paramiko", None)
|
||||||
|
t = SshTransport("10.0.0.99", key_path="/tmp/k")
|
||||||
|
with pytest.raises(TransportConnectError):
|
||||||
|
t.run("true")
|
||||||
|
|
||||||
|
def test_exec_command_exception_is_wrapped(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
"""Channel-level paramiko errors must surface as
|
||||||
|
:class:`TransportConnectError`, never as raw ``Exception``."""
|
||||||
|
from ci_orchestrator.transport.errors import TransportConnectError
|
||||||
|
|
||||||
|
client, _ = self._install_fake_paramiko(monkeypatch)
|
||||||
|
|
||||||
|
def boom(*_a: object, **_kw: object) -> Any:
|
||||||
|
raise OSError("channel closed")
|
||||||
|
|
||||||
|
client.exec_command = boom # type: ignore[method-assign]
|
||||||
|
t = SshTransport("10.0.0.99", key_path="/tmp/k")
|
||||||
|
with pytest.raises(TransportConnectError):
|
||||||
|
t.run("uname")
|
||||||
|
|
||||||
|
def test_sftp_failures_are_wrapped(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
from ci_orchestrator.transport.errors import TransportConnectError
|
||||||
|
|
||||||
|
client, _ = self._install_fake_paramiko(monkeypatch)
|
||||||
|
|
||||||
|
def boom_sftp() -> Any:
|
||||||
|
raise OSError("sftp denied")
|
||||||
|
|
||||||
|
client.open_sftp = boom_sftp # type: ignore[method-assign]
|
||||||
|
t = SshTransport("10.0.0.99", key_path="/tmp/k")
|
||||||
|
with pytest.raises(TransportConnectError):
|
||||||
|
t.copy("local.bin", "/remote/local.bin")
|
||||||
|
with pytest.raises(TransportConnectError):
|
||||||
|
t.fetch("/remote/out.bin", "out.bin")
|
||||||
|
|
||||||
|
def test_is_ready_returns_false_on_typed_error(
|
||||||
|
self, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
client, _ = self._install_fake_paramiko(monkeypatch)
|
||||||
|
|
||||||
|
def boom(*_a: object, **_kw: object) -> Any:
|
||||||
|
raise OSError("dead")
|
||||||
|
|
||||||
|
client.exec_command = boom # type: ignore[method-assign]
|
||||||
|
t = SshTransport("10.0.0.99", key_path="/tmp/k")
|
||||||
|
assert t.is_ready() is False
|
||||||
Reference in New Issue
Block a user