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:
2026-05-14 17:35:57 +02:00
parent 1bcd40e483
commit 5459672b42
+489
View File
@@ -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