test: add coverage for monitor/vm/winrm to reach 90%% gate
Bring monitor.py 49->99%%, vm.py 79->98%%, winrm.py 76->100%%. Total project coverage 80.10%% -> 90.69%%, satisfying the 90%% gate set in commit 451a61c.
This commit is contained in:
@@ -150,3 +150,512 @@ def test_runner_rate_limit_exits_one(
|
|||||||
)
|
)
|
||||||
assert result.exit_code == 1
|
assert result.exit_code == 1
|
||||||
assert "limit" in result.output
|
assert "limit" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers: _post_webhook ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_webhook_empty_url_returns_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.undo()
|
||||||
|
assert mon._post_webhook("", "msg") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_webhook_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.undo()
|
||||||
|
class _Resp:
|
||||||
|
status = 204
|
||||||
|
|
||||||
|
def __enter__(self) -> _Resp:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_a: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(mon.urllib.request, "urlopen", lambda *_a, **_kw: _Resp())
|
||||||
|
assert mon._post_webhook("https://example.invalid/h", "hi") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_webhook_http_error_returns_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.undo()
|
||||||
|
def _raise(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
raise mon.urllib.error.URLError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mon.urllib.request, "urlopen", _raise)
|
||||||
|
assert mon._post_webhook("https://example.invalid/h", "hi") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_webhook_non_2xx_returns_false(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.undo()
|
||||||
|
class _Resp:
|
||||||
|
status = 500
|
||||||
|
|
||||||
|
def __enter__(self) -> _Resp:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_a: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(mon.urllib.request, "urlopen", lambda *_a, **_kw: _Resp())
|
||||||
|
assert mon._post_webhook("https://example.invalid/h", "hi") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers: _write_windows_event ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_windows_event_non_windows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
# Note: autouse fixture monkeypatches the symbol; call the real impl.
|
||||||
|
monkeypatch.undo()
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
assert mon._write_windows_event("src", 1, "Warning", "msg") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_windows_event_eventcreate_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.undo()
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: None)
|
||||||
|
assert mon._write_windows_event("src", 1, "Warning", "msg") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_windows_event_subprocess_ok(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.undo()
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: r"C:\Windows\System32\eventcreate.exe")
|
||||||
|
|
||||||
|
class _R:
|
||||||
|
returncode = 0
|
||||||
|
stderr = ""
|
||||||
|
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _R())
|
||||||
|
assert mon._write_windows_event("src", 1, "Information", "msg") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_windows_event_subprocess_nonzero(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.undo()
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: r"C:\Windows\System32\eventcreate.exe")
|
||||||
|
|
||||||
|
class _R:
|
||||||
|
returncode = 5
|
||||||
|
stderr = "nope"
|
||||||
|
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _R())
|
||||||
|
assert mon._write_windows_event("src", 1, "Error", "msg") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_write_windows_event_subprocess_oserror(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.undo()
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: r"C:\Windows\System32\eventcreate.exe")
|
||||||
|
|
||||||
|
def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
raise OSError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", _boom)
|
||||||
|
assert mon._write_windows_event("src", 1, "Warning", "msg") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers: _resolve_drive_path ───────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_drive_path_windows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
assert mon._resolve_drive_path("F") == "F:\\"
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_drive_path_non_windows(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
assert mon._resolve_drive_path("/mnt/data") == "/mnt/data"
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers: _service_status ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class _RunResult:
|
||||||
|
def __init__(self, returncode: int = 0, stdout: str = "", stderr: str = "") -> None:
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_status_windows_running(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: r"C:\Windows\System32\sc.exe")
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _RunResult(0, "STATE : 4 RUNNING"))
|
||||||
|
assert mon._service_status("svc") == "Running"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_status_windows_stopped(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: r"C:\Windows\System32\sc.exe")
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _RunResult(0, "STATE : 1 STOPPED"))
|
||||||
|
assert mon._service_status("svc") == "Stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_status_windows_unknown_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: r"C:\Windows\System32\sc.exe")
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _RunResult(0, "STATE : 7 PAUSED"))
|
||||||
|
assert mon._service_status("svc") == "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_status_windows_sc_failed(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: r"C:\Windows\System32\sc.exe")
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _RunResult(1, ""))
|
||||||
|
assert mon._service_status("svc") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_status_windows_subprocess_oserror(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: r"C:\Windows\System32\sc.exe")
|
||||||
|
|
||||||
|
def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
raise OSError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", _boom)
|
||||||
|
assert mon._service_status("svc") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_status_linux_active(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: "/usr/bin/systemctl")
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _RunResult(0, "active\n"))
|
||||||
|
assert mon._service_status("svc") == "Running"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_status_linux_inactive(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: "/usr/bin/systemctl")
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _RunResult(3, "inactive\n"))
|
||||||
|
assert mon._service_status("svc") == "Stopped"
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_status_linux_unknown(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: "/usr/bin/systemctl")
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _RunResult(4, "activating\n"))
|
||||||
|
assert mon._service_status("svc") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_status_linux_no_systemctl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: None)
|
||||||
|
assert mon._service_status("svc") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_status_linux_subprocess_oserror(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: "/usr/bin/systemctl")
|
||||||
|
|
||||||
|
def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
raise OSError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", _boom)
|
||||||
|
assert mon._service_status("svc") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers: _restart_service ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_service_windows_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: r"C:\Windows\System32\sc.exe")
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _RunResult(0, ""))
|
||||||
|
monkeypatch.setattr(mon.time, "sleep", lambda _s: None)
|
||||||
|
monkeypatch.setattr(mon, "_service_status", lambda _s: "Running")
|
||||||
|
assert mon._restart_service("svc") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_service_windows_oserror(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "nt")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: r"C:\Windows\System32\sc.exe")
|
||||||
|
|
||||||
|
def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
raise OSError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", _boom)
|
||||||
|
assert mon._restart_service("svc") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_service_linux_success(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: "/usr/bin/systemctl")
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", lambda *_a, **_kw: _RunResult(0, ""))
|
||||||
|
monkeypatch.setattr(mon.time, "sleep", lambda _s: None)
|
||||||
|
monkeypatch.setattr(mon, "_service_status", lambda _s: "Running")
|
||||||
|
assert mon._restart_service("svc") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_service_linux_no_systemctl(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: None)
|
||||||
|
assert mon._restart_service("svc") is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_restart_service_linux_oserror(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.os, "name", "posix")
|
||||||
|
monkeypatch.setattr(mon.shutil, "which", lambda _n: "/usr/bin/systemctl")
|
||||||
|
|
||||||
|
def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
raise subprocess.TimeoutExpired(cmd="systemctl", timeout=30.0)
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
monkeypatch.setattr(mon.subprocess, "run", _boom)
|
||||||
|
assert mon._restart_service("svc") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers: _load_restart_log / _save_restart_log ─────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_restart_log_missing(tmp_path: Path) -> None:
|
||||||
|
assert mon._load_restart_log(tmp_path / "no.json") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_restart_log_empty(tmp_path: Path) -> None:
|
||||||
|
p = tmp_path / "empty.json"
|
||||||
|
p.write_text(" ")
|
||||||
|
assert mon._load_restart_log(p) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_restart_log_invalid_json(tmp_path: Path) -> None:
|
||||||
|
p = tmp_path / "bad.json"
|
||||||
|
p.write_text("{not json")
|
||||||
|
assert mon._load_restart_log(p) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_restart_log_non_list(tmp_path: Path) -> None:
|
||||||
|
p = tmp_path / "obj.json"
|
||||||
|
p.write_text(json.dumps({"a": 1}))
|
||||||
|
assert mon._load_restart_log(p) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_restart_log_valid(tmp_path: Path) -> None:
|
||||||
|
p = tmp_path / "ok.json"
|
||||||
|
p.write_text(json.dumps(["2026-05-14T10:00:00+00:00"]))
|
||||||
|
assert mon._load_restart_log(p) == ["2026-05-14T10:00:00+00:00"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_load_restart_log_oserror(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
p = tmp_path / "bad.json"
|
||||||
|
p.write_text("[]")
|
||||||
|
|
||||||
|
def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
raise OSError("boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "read_text", _boom)
|
||||||
|
assert mon._load_restart_log(p) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_restart_log_writes_file(tmp_path: Path) -> None:
|
||||||
|
p = tmp_path / "sub" / "log.json"
|
||||||
|
mon._save_restart_log(p, ["a", "b"])
|
||||||
|
assert json.loads(p.read_text()) == ["a", "b"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_restart_log_oserror_swallowed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
def _boom(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
raise OSError("readonly")
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "mkdir", _boom)
|
||||||
|
# Should not raise.
|
||||||
|
mon._save_restart_log(tmp_path / "x" / "log.json", ["a"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers: _gitea_runners_online ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _make_urlopen(payload: Any) -> Any:
|
||||||
|
class _Resp:
|
||||||
|
def __enter__(self) -> _Resp:
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, *_a: Any) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def read(self) -> bytes:
|
||||||
|
return json.dumps(payload).encode("utf-8")
|
||||||
|
|
||||||
|
return lambda *_a, **_kw: _Resp()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_runners_online_counts(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mon.urllib.request,
|
||||||
|
"urlopen",
|
||||||
|
_make_urlopen([{"online": True}, {"online": False}, {"online": True}]),
|
||||||
|
)
|
||||||
|
assert mon._gitea_runners_online("https://g/", "tok") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_runners_online_non_list(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mon.urllib.request, "urlopen", _make_urlopen({"x": 1}))
|
||||||
|
assert mon._gitea_runners_online("https://g/", "tok") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_gitea_runners_online_url_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def _raise(*_a: Any, **_kw: Any) -> Any:
|
||||||
|
raise mon.urllib.error.URLError("nope")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mon.urllib.request, "urlopen", _raise)
|
||||||
|
assert mon._gitea_runners_online("https://g/", "tok") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ── monitor_runner: gitea-online branches & restart-fail path ──────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_keyring(monkeypatch: pytest.MonkeyPatch, password: str = "tok") -> None:
|
||||||
|
class _Cred:
|
||||||
|
username = "u"
|
||||||
|
|
||||||
|
def __init__(self, pw: str) -> None:
|
||||||
|
self.password = pw
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def get(self, _target: str) -> Any:
|
||||||
|
return _Cred(password)
|
||||||
|
|
||||||
|
import ci_orchestrator.credentials as creds
|
||||||
|
|
||||||
|
monkeypatch.setattr(creds, "KeyringCredentialStore", _Store)
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_running_with_gitea_online(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(mon, "_service_status", lambda _s: "Running")
|
||||||
|
monkeypatch.setattr(mon, "_gitea_runners_online", lambda *_a, **_kw: 2)
|
||||||
|
_stub_keyring(monkeypatch)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"monitor", "runner",
|
||||||
|
"--state-dir", str(tmp_path),
|
||||||
|
"--gitea-url", "https://g/",
|
||||||
|
"--gitea-credential-target", "tgt",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "2 runner(s) online" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_running_with_gitea_zero_online(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(mon, "_service_status", lambda _s: "Running")
|
||||||
|
monkeypatch.setattr(mon, "_gitea_runners_online", lambda *_a, **_kw: 0)
|
||||||
|
_stub_keyring(monkeypatch)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"monitor", "runner",
|
||||||
|
"--state-dir", str(tmp_path),
|
||||||
|
"--gitea-url", "https://g/",
|
||||||
|
"--gitea-credential-target", "tgt",
|
||||||
|
"--webhook-url", "https://hook/",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "0 online runners" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_running_with_gitea_api_failure(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(mon, "_service_status", lambda _s: "Running")
|
||||||
|
monkeypatch.setattr(mon, "_gitea_runners_online", lambda *_a, **_kw: None)
|
||||||
|
_stub_keyring(monkeypatch)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"monitor", "runner",
|
||||||
|
"--state-dir", str(tmp_path),
|
||||||
|
"--gitea-url", "https://g/",
|
||||||
|
"--gitea-credential-target", "tgt",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "skipped/failed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_running_with_gitea_keyring_keyerror(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(mon, "_service_status", lambda _s: "Running")
|
||||||
|
|
||||||
|
class _Store:
|
||||||
|
def get(self, _target: str) -> Any:
|
||||||
|
raise KeyError("missing")
|
||||||
|
|
||||||
|
import ci_orchestrator.credentials as creds
|
||||||
|
|
||||||
|
monkeypatch.setattr(creds, "KeyringCredentialStore", _Store)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"monitor", "runner",
|
||||||
|
"--state-dir", str(tmp_path),
|
||||||
|
"--gitea-url", "https://g/",
|
||||||
|
"--gitea-credential-target", "tgt",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "could not load Gitea PAT" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_restart_fails_exits_one(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
statuses = iter(["Stopped", "Stopped"])
|
||||||
|
monkeypatch.setattr(mon, "_service_status", lambda _s: next(statuses))
|
||||||
|
monkeypatch.setattr(mon, "_restart_service", lambda _s: False)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"monitor", "runner",
|
||||||
|
"--state-dir", str(tmp_path),
|
||||||
|
"--webhook-url", "https://hook/",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "restarted" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_rate_limit_with_webhook(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
recent = [datetime.now(tz=UTC).isoformat()] * 3
|
||||||
|
(tmp_path / "runner-restart-log.json").write_text(json.dumps(recent))
|
||||||
|
monkeypatch.setattr(mon, "_service_status", lambda _s: "Stopped")
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"monitor", "runner",
|
||||||
|
"--state-dir", str(tmp_path),
|
||||||
|
"--max-restarts", "3",
|
||||||
|
"--webhook-url", "https://hook/",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "limit" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_runner_pruned_old_entries(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Old timestamps and invalid entries must be pruned."""
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
old = (datetime.now(tz=UTC) - timedelta(hours=2)).isoformat()
|
||||||
|
naive = datetime.now(tz=UTC).replace(tzinfo=None).isoformat()
|
||||||
|
bad = "not-a-timestamp"
|
||||||
|
(tmp_path / "runner-restart-log.json").write_text(json.dumps([old, naive, bad]))
|
||||||
|
statuses = iter(["Stopped", "Running"])
|
||||||
|
monkeypatch.setattr(mon, "_service_status", lambda _s: next(statuses))
|
||||||
|
monkeypatch.setattr(mon, "_restart_service", lambda _s: True)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli, ["monitor", "runner", "--state-dir", str(tmp_path)]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|||||||
@@ -209,3 +209,291 @@ def test_vm_cleanup_removes_stale_lock(monkeypatch: pytest.MonkeyPatch, tmp_path
|
|||||||
)
|
)
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert not lock.exists()
|
assert not lock.exists()
|
||||||
|
|
||||||
|
|
||||||
|
# ── helpers + uncovered branches ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_backend_with_vmrun_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
"""Override path returns a WorkstationVmrunBackend honouring vmrun_path."""
|
||||||
|
from ci_orchestrator.backends.workstation import WorkstationVmrunBackend
|
||||||
|
|
||||||
|
class _StubConfig:
|
||||||
|
pass
|
||||||
|
|
||||||
|
fake_vmrun = tmp_path / "vmrun.exe"
|
||||||
|
fake_vmrun.write_text("x")
|
||||||
|
monkeypatch.setattr(vm_module, "load_config", lambda: _StubConfig())
|
||||||
|
backend = vm_module._make_backend(str(fake_vmrun))
|
||||||
|
assert isinstance(backend, WorkstationVmrunBackend)
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_backend_default_uses_load_backend(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
sentinel = object()
|
||||||
|
monkeypatch.setattr(vm_module, "load_config", lambda: "cfg")
|
||||||
|
monkeypatch.setattr(vm_module, "load_backend", lambda _c: sentinel)
|
||||||
|
assert vm_module._make_backend(None) is sentinel
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_remove_dir_returns_true_when_missing(tmp_path: Path) -> None:
|
||||||
|
assert vm_module._try_remove_dir(tmp_path / "ghost") is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_try_remove_dir_real_removal(tmp_path: Path) -> None:
|
||||||
|
d = tmp_path / "x"
|
||||||
|
(d / "sub").mkdir(parents=True)
|
||||||
|
(d / "sub" / "f.txt").write_text("a")
|
||||||
|
assert vm_module._try_remove_dir(d) is True
|
||||||
|
assert not d.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_with_fallback_soft_error_then_running_with_hard_error() -> None:
|
||||||
|
from ci_orchestrator.backends.errors import BackendOperationFailed
|
||||||
|
|
||||||
|
class _B:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[tuple[str, bool]] = []
|
||||||
|
|
||||||
|
def stop(self, _h: VmHandle, hard: bool = False) -> None:
|
||||||
|
self.calls.append(("stop", hard))
|
||||||
|
raise BackendOperationFailed("stop", 1, "boom")
|
||||||
|
|
||||||
|
def is_running(self, _h: VmHandle) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
b = _B()
|
||||||
|
# Should swallow both errors.
|
||||||
|
vm_module._stop_with_fallback(b, VmHandle(identifier="x.vmx"))
|
||||||
|
assert ("stop", False) in b.calls
|
||||||
|
assert ("stop", True) in b.calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_remove_backend_unavailable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
from ci_orchestrator.backends.errors import BackendNotAvailable
|
||||||
|
|
||||||
|
clone_dir, vmx = _make_clone_dir(tmp_path, "noback")
|
||||||
|
|
||||||
|
def _raise(_v: Any) -> Any:
|
||||||
|
raise BackendNotAvailable("vmrun missing")
|
||||||
|
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", _raise)
|
||||||
|
result = CliRunner().invoke(cli, ["vm", "remove", "--vmx", str(vmx)])
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert "vmrun unavailable" in result.output
|
||||||
|
assert not clone_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_remove_force_hard_stop_error(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
from ci_orchestrator.backends.errors import BackendOperationFailed
|
||||||
|
|
||||||
|
_, vmx = _make_clone_dir(tmp_path, "forcefail")
|
||||||
|
|
||||||
|
class _B:
|
||||||
|
def stop(self, _h: VmHandle, hard: bool = False) -> None:
|
||||||
|
raise BackendOperationFailed("stop", 1, "no")
|
||||||
|
|
||||||
|
def is_running(self, _h: VmHandle) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete(self, _h: VmHandle) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: _B())
|
||||||
|
result = CliRunner().invoke(cli, ["vm", "remove", "--vmx", str(vmx), "--force"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "hard stop failed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_remove_deadline_loop_with_running_then_error(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Cover the deadline loop that breaks on BackendError."""
|
||||||
|
from ci_orchestrator.backends.errors import BackendOperationFailed
|
||||||
|
|
||||||
|
_, vmx = _make_clone_dir(tmp_path, "loop")
|
||||||
|
|
||||||
|
class _B:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._calls = 0
|
||||||
|
|
||||||
|
def stop(self, _h: VmHandle, hard: bool = False) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_running(self, _h: VmHandle) -> bool:
|
||||||
|
self._calls += 1
|
||||||
|
if self._calls == 1:
|
||||||
|
return True
|
||||||
|
raise BackendOperationFailed("isrunning", 1, "lost")
|
||||||
|
|
||||||
|
def delete(self, _h: VmHandle) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Make monotonic strictly advance so the loop runs at least twice but exits.
|
||||||
|
base_t = [0.0]
|
||||||
|
|
||||||
|
def _mono() -> float:
|
||||||
|
base_t[0] += 1.0
|
||||||
|
return base_t[0]
|
||||||
|
|
||||||
|
monkeypatch.setattr(vm_module.time, "monotonic", _mono)
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: _B())
|
||||||
|
result = CliRunner().invoke(cli, ["vm", "remove", "--vmx", str(vmx)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_remove_delete_fails_all_retries(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Delete that fails on every retry triggers fallback dir-removal log."""
|
||||||
|
clone_dir, vmx = _make_clone_dir(tmp_path, "neverdeletes")
|
||||||
|
|
||||||
|
class _B:
|
||||||
|
def stop(self, _h: VmHandle, hard: bool = False) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def is_running(self, _h: VmHandle) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def delete(self, _h: VmHandle) -> None:
|
||||||
|
from ci_orchestrator.backends.errors import BackendOperationFailed
|
||||||
|
raise BackendOperationFailed("delete", 1, "always")
|
||||||
|
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: _B())
|
||||||
|
result = CliRunner().invoke(cli, ["vm", "remove", "--vmx", str(vmx)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "deleteVM failed after retries" in result.output
|
||||||
|
assert not clone_dir.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_remove_clone_dir_removal_failure(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""When _try_remove_dir reports failure, the manual-cleanup notice fires."""
|
||||||
|
_, vmx = _make_clone_dir(tmp_path, "stubborn")
|
||||||
|
backend = _FakeBackend()
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend)
|
||||||
|
monkeypatch.setattr(vm_module, "_try_remove_dir", lambda _p: False)
|
||||||
|
result = CliRunner().invoke(cli, ["vm", "remove", "--vmx", str(vmx)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "manual cleanup needed" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_orphans_non_dir_base(tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "file.txt"
|
||||||
|
f.write_text("x")
|
||||||
|
assert vm_module._list_orphans(f, 4) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_orphans_skips_non_dir_and_oserror(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
base = tmp_path / "b"
|
||||||
|
base.mkdir()
|
||||||
|
(base / "a-file").write_text("not a dir")
|
||||||
|
bad_dir = base / "bad"
|
||||||
|
bad_dir.mkdir()
|
||||||
|
_age_dir(bad_dir, hours=10)
|
||||||
|
|
||||||
|
real_stat = Path.stat
|
||||||
|
|
||||||
|
def _stat(self: Path, *a: Any, **kw: Any) -> Any:
|
||||||
|
if self == bad_dir:
|
||||||
|
raise OSError("nope")
|
||||||
|
return real_stat(self, *a, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "stat", _stat)
|
||||||
|
assert vm_module._list_orphans(base, 4) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_find_vmx_returns_none(tmp_path: Path) -> None:
|
||||||
|
d = tmp_path / "novmx"
|
||||||
|
d.mkdir()
|
||||||
|
(d / "readme.txt").write_text("x")
|
||||||
|
assert vm_module._find_vmx(d) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_orphans_dir_without_vmx_logs_and_removes(tmp_path: Path) -> None:
|
||||||
|
base = tmp_path / "b"
|
||||||
|
base.mkdir()
|
||||||
|
no_vmx = base / "no-vmx"
|
||||||
|
no_vmx.mkdir()
|
||||||
|
_age_dir(no_vmx, hours=10)
|
||||||
|
runner_count = vm_module.cleanup_orphans(
|
||||||
|
clone_base=base, max_age_hours=4, backend=None
|
||||||
|
)
|
||||||
|
assert runner_count == 1
|
||||||
|
assert not no_vmx.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_orphans_backend_stop_error_swallowed(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
from ci_orchestrator.backends.errors import BackendOperationFailed
|
||||||
|
|
||||||
|
base = tmp_path / "b"
|
||||||
|
base.mkdir()
|
||||||
|
old, _ = _make_clone_dir(base, "old1")
|
||||||
|
_age_dir(old, hours=10)
|
||||||
|
|
||||||
|
class _B:
|
||||||
|
def stop(self, _h: VmHandle, hard: bool = False) -> None:
|
||||||
|
raise BackendOperationFailed("stop", 1, "no")
|
||||||
|
|
||||||
|
def delete(self, _h: VmHandle) -> None:
|
||||||
|
raise BackendOperationFailed("delete", 1, "no")
|
||||||
|
|
||||||
|
count = vm_module.cleanup_orphans(
|
||||||
|
clone_base=base, max_age_hours=4, backend=_B()
|
||||||
|
)
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_orphans_dir_removal_failure_logs(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
base = tmp_path / "b"
|
||||||
|
base.mkdir()
|
||||||
|
old, _ = _make_clone_dir(base, "stuck")
|
||||||
|
_age_dir(old, hours=10)
|
||||||
|
monkeypatch.setattr(vm_module, "_try_remove_dir", lambda _p: False)
|
||||||
|
count = vm_module.cleanup_orphans(
|
||||||
|
clone_base=base, max_age_hours=4, backend=None
|
||||||
|
)
|
||||||
|
assert count == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_cleanup_orphans_lock_unlink_oserror(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
base = tmp_path / "b"
|
||||||
|
base.mkdir()
|
||||||
|
lock = tmp_path / "stale.lock"
|
||||||
|
lock.write_text("x")
|
||||||
|
_age_dir(lock, hours=1)
|
||||||
|
|
||||||
|
real_unlink = Path.unlink
|
||||||
|
|
||||||
|
def _boom(self: Path, *a: Any, **kw: Any) -> Any:
|
||||||
|
if self == lock:
|
||||||
|
raise OSError("locked")
|
||||||
|
return real_unlink(self, *a, **kw)
|
||||||
|
|
||||||
|
monkeypatch.setattr(Path, "unlink", _boom)
|
||||||
|
vm_module.cleanup_orphans(
|
||||||
|
clone_base=base, max_age_hours=4, backend=None, lock_file=lock
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_cleanup_backend_unavailable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
from ci_orchestrator.backends.errors import BackendNotAvailable
|
||||||
|
|
||||||
|
base = tmp_path / "build-vms"
|
||||||
|
base.mkdir()
|
||||||
|
|
||||||
|
def _raise(_v: Any) -> Any:
|
||||||
|
raise BackendNotAvailable("no vmrun")
|
||||||
|
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", _raise)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli, ["vm", "cleanup", "--clone-base-dir", str(base)]
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "vmrun not available" in result.output
|
||||||
|
|||||||
@@ -189,6 +189,88 @@ def test_vm_new_pascal_case_aliases(
|
|||||||
assert backend.calls[0]["snapshot"] == "Custom"
|
assert backend.calls[0]["snapshot"] == "Custom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_new_backend_unavailable(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
from ci_orchestrator.backends.errors import BackendNotAvailable
|
||||||
|
|
||||||
|
base = tmp_path / "build-vms"
|
||||||
|
|
||||||
|
def _raise(_v: Any) -> Any:
|
||||||
|
raise BackendNotAvailable("no vmrun")
|
||||||
|
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", _raise)
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"vm", "new",
|
||||||
|
"--template", str(fake_template),
|
||||||
|
"--clone-base-dir", str(base),
|
||||||
|
"--job-id", "novm",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "vmrun unavailable" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_new_partial_clone_dir_cleaned(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Backend creates the partial dir then fails -> dir is removed."""
|
||||||
|
base = tmp_path / "build-vms"
|
||||||
|
|
||||||
|
class _B:
|
||||||
|
def clone_linked(
|
||||||
|
self, template: str, snapshot: str, name: str,
|
||||||
|
destination: str | None = None,
|
||||||
|
) -> Any:
|
||||||
|
assert destination is not None
|
||||||
|
Path(destination).parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
(Path(destination).parent / "stale.txt").write_text("x")
|
||||||
|
raise BackendOperationFailed("clone", 1, "boom")
|
||||||
|
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: _B())
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"vm", "new",
|
||||||
|
"--template", str(fake_template),
|
||||||
|
"--clone-base-dir", str(base),
|
||||||
|
"--job-id", "partial",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "clone failed" in result.output
|
||||||
|
assert not any(p.is_dir() and "partial" in p.name for p in base.iterdir())
|
||||||
|
|
||||||
|
|
||||||
|
def test_vm_new_backend_success_but_missing_vmx(
|
||||||
|
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
"""Backend reports success but VMX file isn't there -> ClickException."""
|
||||||
|
base = tmp_path / "build-vms"
|
||||||
|
|
||||||
|
class _B:
|
||||||
|
def clone_linked(
|
||||||
|
self, template: str, snapshot: str, name: str,
|
||||||
|
destination: str | None = None,
|
||||||
|
) -> VmHandle:
|
||||||
|
return VmHandle(identifier=str(destination), name=name)
|
||||||
|
|
||||||
|
monkeypatch.setattr(vm_module, "_make_backend", lambda _v: _B())
|
||||||
|
result = CliRunner().invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"vm", "new",
|
||||||
|
"--template", str(fake_template),
|
||||||
|
"--clone-base-dir", str(base),
|
||||||
|
"--job-id", "ghost",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "clone VMX is missing" in result.output
|
||||||
|
|
||||||
|
|
||||||
def test_vm_new_creates_base_dir(
|
def test_vm_new_creates_base_dir(
|
||||||
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
|
monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -98,3 +98,98 @@ def test_is_ready_handles_connect_failure(monkeypatch: pytest.MonkeyPatch) -> No
|
|||||||
# Direct .run should raise.
|
# Direct .run should raise.
|
||||||
with pytest.raises(TransportConnectError):
|
with pytest.raises(TransportConnectError):
|
||||||
t.run("x")
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user