pypsrp's execute_ps is fully blocking, so Install-CIToolchain / Windows Update output only appeared at the end behind a "still running (Ns)" heartbeat. Add WinRmTransport.run_streaming: drive the script via pypsrp's incremental begin_invoke/poll_invoke, emitting output, Write-Host (information) and error stream lines as they arrive over the same WinRM channel. _run_with_heartbeat now prefers run_streaming (suppressing the CI_EXITCODE marker line) and falls back to the blocking heartbeat only on an unexpected pypsrp error — TransportError still propagates so the 3010 reboot handling is unaffected.
282 lines
9.7 KiB
Python
282 lines
9.7 KiB
Python
"""Tests for WinRmTransport (pypsrp client mocked)."""
|
|
|
|
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.winrm import WinRmTransport
|
|
|
|
|
|
class _FakeClient:
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
self.args = args
|
|
self.kwargs = kwargs
|
|
self.copies: list[tuple[str, str]] = []
|
|
self.fetches: list[tuple[str, str]] = []
|
|
self.scripts: list[str] = []
|
|
self.next_result: tuple[str, str, int] = ("ok", "", 0)
|
|
self.wsman = types.SimpleNamespace(close=lambda: None)
|
|
|
|
def execute_ps(self, script: str) -> tuple[str, str, int]:
|
|
self.scripts.append(script)
|
|
return self.next_result
|
|
|
|
def copy(self, local: str, remote: str) -> None:
|
|
self.copies.append((local, remote))
|
|
|
|
def fetch(self, remote: str, local: str) -> None:
|
|
self.fetches.append((remote, local))
|
|
|
|
|
|
def _install_fake_pypsrp(monkeypatch: pytest.MonkeyPatch, client: _FakeClient) -> None:
|
|
module = types.ModuleType("pypsrp")
|
|
client_module = types.ModuleType("pypsrp.client")
|
|
client_module.Client = lambda *a, **kw: client # type: ignore[attr-defined]
|
|
monkeypatch.setitem(sys.modules, "pypsrp", module)
|
|
monkeypatch.setitem(sys.modules, "pypsrp.client", client_module)
|
|
|
|
|
|
def test_run_returns_result(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
fake.next_result = ("hello\n", "", 0)
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
result = t.run("Write-Output 'hello'")
|
|
assert result.ok
|
|
assert result.stdout == "hello\n"
|
|
assert fake.scripts == ["Write-Output 'hello'"]
|
|
|
|
|
|
def test_run_raises_on_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
fake.next_result = ("", "boom", 7)
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
with pytest.raises(TransportCommandError) as exc:
|
|
t.run("bad")
|
|
assert exc.value.returncode == 7
|
|
|
|
|
|
def test_run_check_false_does_not_raise(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
fake.next_result = ("", "boom", 7)
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
result = t.run("bad", check=False)
|
|
assert not result.ok
|
|
|
|
|
|
def test_copy_and_fetch(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
t.copy("local.txt", "C:/remote.txt")
|
|
t.fetch("C:/remote.txt", "downloaded.txt")
|
|
assert fake.copies == [("local.txt", "C:/remote.txt")]
|
|
assert fake.fetches == [("C:/remote.txt", "downloaded.txt")]
|
|
|
|
|
|
def test_is_ready_handles_connect_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# Make the local import inside _connect succeed but Client raise.
|
|
module = types.ModuleType("pypsrp")
|
|
client_module = types.ModuleType("pypsrp.client")
|
|
|
|
def _boom(*_a: object, **_kw: object) -> None:
|
|
raise OSError("connection refused")
|
|
|
|
client_module.Client = _boom # type: ignore[attr-defined]
|
|
monkeypatch.setitem(sys.modules, "pypsrp", module)
|
|
monkeypatch.setitem(sys.modules, "pypsrp.client", client_module)
|
|
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
assert t.is_ready() is False
|
|
# Direct .run should raise.
|
|
with pytest.raises(TransportConnectError):
|
|
t.run("x")
|
|
|
|
|
|
def test_close_and_context_manager(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
closed: list[bool] = []
|
|
fake.wsman = types.SimpleNamespace(close=lambda: closed.append(True))
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
with t as ctx:
|
|
assert ctx is t
|
|
assert closed == [True]
|
|
# close() on already-closed transport is a no-op.
|
|
t.close()
|
|
|
|
|
|
def test_close_swallows_wsman_exception(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
def _raise() -> None:
|
|
raise RuntimeError("boom")
|
|
fake.wsman = types.SimpleNamespace(close=_raise)
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
t.run("x") # connect once
|
|
t.close() # must not raise
|
|
|
|
|
|
def test_run_normalises_list_stderr(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
fake.next_result = ("out", ["a", "b"], 0) # type: ignore[assignment]
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
result = t.run("x")
|
|
assert result.stderr == "a\nb"
|
|
|
|
|
|
def test_run_normalises_non_iterable_stderr(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class _Weird:
|
|
def __str__(self) -> str:
|
|
return "weird-stderr"
|
|
|
|
fake = _FakeClient()
|
|
fake.next_result = ("out", _Weird(), 0) # type: ignore[assignment]
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
result = t.run("x")
|
|
assert "weird-stderr" in result.stderr
|
|
|
|
|
|
def test_run_execute_ps_raises_wrapped(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
|
|
def _boom(_s: str) -> Any:
|
|
raise RuntimeError("ps boom")
|
|
|
|
fake.execute_ps = _boom # type: ignore[assignment]
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
with pytest.raises(TransportConnectError) as exc:
|
|
t.run("x")
|
|
assert "execute_ps failed" in str(exc.value)
|
|
|
|
|
|
def test_copy_wraps_exception(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
|
|
def _boom(_l: str, _r: str) -> None:
|
|
raise OSError("copy down")
|
|
|
|
fake.copy = _boom # type: ignore[assignment]
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
with pytest.raises(TransportConnectError) as exc:
|
|
t.copy("a", "b")
|
|
assert "copy failed" in str(exc.value)
|
|
|
|
|
|
def test_fetch_wraps_exception(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
|
|
def _boom(_r: str, _l: str) -> None:
|
|
raise OSError("fetch down")
|
|
|
|
fake.fetch = _boom # type: ignore[assignment]
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
with pytest.raises(TransportConnectError) as exc:
|
|
t.fetch("a", "b")
|
|
assert "fetch failed" in str(exc.value)
|
|
|
|
|
|
def test_is_ready_true_when_run_succeeds(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
t = WinRmTransport("10.0.0.1", "user", "pwd")
|
|
assert t.is_ready() is True
|
|
|
|
|
|
def _install_fake_streaming(monkeypatch: pytest.MonkeyPatch, *, with_error: bool = False) -> None:
|
|
"""Inject fake pypsrp.powershell + complex_objects for run_streaming tests."""
|
|
class _State:
|
|
NOT_STARTED, RUNNING, COMPLETED, FAILED, STOPPED, STOPPING, DISCONNECTED = range(7)
|
|
|
|
class _Streams:
|
|
def __init__(self) -> None:
|
|
self.information: list[Any] = []
|
|
self.error: list[Any] = []
|
|
|
|
class _FakePS:
|
|
def __init__(self, _pool: Any) -> None:
|
|
self.state = _State.NOT_STARTED
|
|
self.output: list[Any] = []
|
|
self.streams = _Streams()
|
|
self._poll = 0
|
|
|
|
def add_script(self, script: str) -> None:
|
|
self.script = script
|
|
|
|
def begin_invoke(self) -> None:
|
|
self.state = _State.RUNNING
|
|
|
|
def poll_invoke(self, *_a: Any, **_k: Any) -> None:
|
|
self._poll += 1
|
|
if self._poll == 1:
|
|
self.output.append("line1")
|
|
self.streams.information.append(types.SimpleNamespace(message_data="hostmsg"))
|
|
elif self._poll == 2:
|
|
self.output.append("line2")
|
|
if with_error:
|
|
self.streams.error.append("boom")
|
|
self.state = _State.COMPLETED
|
|
|
|
def end_invoke(self) -> None:
|
|
pass
|
|
|
|
class _FakePool:
|
|
def __init__(self, wsman: Any) -> None:
|
|
self.wsman = wsman
|
|
|
|
def open(self) -> None:
|
|
pass
|
|
|
|
def close(self) -> None:
|
|
pass
|
|
|
|
co = types.ModuleType("pypsrp.complex_objects")
|
|
co.PSInvocationState = _State # type: ignore[attr-defined]
|
|
psmod = types.ModuleType("pypsrp.powershell")
|
|
psmod.PowerShell = _FakePS # type: ignore[attr-defined]
|
|
psmod.RunspacePool = _FakePool # type: ignore[attr-defined]
|
|
monkeypatch.setitem(sys.modules, "pypsrp.complex_objects", co)
|
|
monkeypatch.setitem(sys.modules, "pypsrp.powershell", psmod)
|
|
monkeypatch.setattr("ci_orchestrator.transport.winrm.time.sleep", lambda _s: None)
|
|
|
|
|
|
def test_run_streaming_emits_lines(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
_install_fake_streaming(monkeypatch)
|
|
t = WinRmTransport("10.0.0.1", "u", "p")
|
|
lines: list[str] = []
|
|
res = t.run_streaming("Write-Output 1", lines.append)
|
|
assert lines == ["line1", "hostmsg", "line2"]
|
|
assert res.ok and res.stdout == "line1\nline2"
|
|
|
|
|
|
def test_run_streaming_raises_on_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
_install_fake_streaming(monkeypatch, with_error=True)
|
|
t = WinRmTransport("10.0.0.1", "u", "p")
|
|
with pytest.raises(TransportCommandError):
|
|
t.run_streaming("x", lambda _s: None)
|
|
|
|
|
|
def test_run_streaming_check_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
fake = _FakeClient()
|
|
_install_fake_pypsrp(monkeypatch, fake)
|
|
_install_fake_streaming(monkeypatch, with_error=True)
|
|
t = WinRmTransport("10.0.0.1", "u", "p")
|
|
res = t.run_streaming("x", lambda _s: None, check=False)
|
|
assert not res.ok and "boom" in res.stderr
|