From b2b31f4b6e05c5eba8c89ad0edb5b4355c0f6f57 Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 14 May 2026 23:14:08 +0200 Subject: [PATCH] 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. --- tests/python/test_commands_monitor.py | 509 ++++++++++++++++++++++++++ tests/python/test_commands_vm.py | 288 +++++++++++++++ tests/python/test_commands_vm_new.py | 82 +++++ tests/python/test_winrm_transport.py | 95 +++++ 4 files changed, 974 insertions(+) diff --git a/tests/python/test_commands_monitor.py b/tests/python/test_commands_monitor.py index cf229fb..b359dac 100644 --- a/tests/python/test_commands_monitor.py +++ b/tests/python/test_commands_monitor.py @@ -150,3 +150,512 @@ def test_runner_rate_limit_exits_one( ) assert result.exit_code == 1 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 diff --git a/tests/python/test_commands_vm.py b/tests/python/test_commands_vm.py index 2103410..172863d 100644 --- a/tests/python/test_commands_vm.py +++ b/tests/python/test_commands_vm.py @@ -209,3 +209,291 @@ def test_vm_cleanup_removes_stale_lock(monkeypatch: pytest.MonkeyPatch, tmp_path ) assert result.exit_code == 0 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 diff --git a/tests/python/test_commands_vm_new.py b/tests/python/test_commands_vm_new.py index ae06a5d..addc12a 100644 --- a/tests/python/test_commands_vm_new.py +++ b/tests/python/test_commands_vm_new.py @@ -189,6 +189,88 @@ def test_vm_new_pascal_case_aliases( 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( monkeypatch: pytest.MonkeyPatch, fake_template: Path, tmp_path: Path ) -> None: diff --git a/tests/python/test_winrm_transport.py b/tests/python/test_winrm_transport.py index 1fa2e23..7c5a33e 100644 --- a/tests/python/test_winrm_transport.py +++ b/tests/python/test_winrm_transport.py @@ -98,3 +98,98 @@ def test_is_ready_handles_connect_failure(monkeypatch: pytest.MonkeyPatch) -> No # 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