"""Tests for ``ci_orchestrator vm remove`` and ``vm cleanup``. Migrated from Pester ``tests/Remove-BuildVM.Tests.ps1`` (now removed). """ from __future__ import annotations import time from datetime import datetime, timedelta from pathlib import Path from typing import Any import pytest from click.testing import CliRunner import ci_orchestrator.commands.vm as vm_module from ci_orchestrator.__main__ import cli from ci_orchestrator.backends.errors import BackendOperationFailed from ci_orchestrator.backends.protocol import VmHandle class _FakeBackend: def __init__( self, *, running_after_stop: bool = False, delete_fails_first: int = 0, ) -> None: self.calls: list[tuple[str, Any]] = [] self._running = True self._running_after_stop = running_after_stop self._delete_fails_remaining = delete_fails_first def stop(self, handle: VmHandle, hard: bool = False) -> None: self.calls.append(("stop", hard)) if not hard or not self._running_after_stop: self._running = False def is_running(self, _h: VmHandle) -> bool: return self._running def delete(self, _h: VmHandle) -> None: self.calls.append(("delete", None)) if self._delete_fails_remaining > 0: self._delete_fails_remaining -= 1 raise BackendOperationFailed("deleteVM", 1, "transient") @pytest.fixture(autouse=True) def _no_sleep(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(time, "sleep", lambda _s: None) monkeypatch.setattr(vm_module.time, "sleep", lambda _s: None) def _make_clone_dir(tmp_path: Path, name: str = "clone") -> tuple[Path, Path]: clone_dir = tmp_path / name clone_dir.mkdir() vmx = clone_dir / f"{name}.vmx" vmx.write_text('config.version = "8"', encoding="utf-8") return clone_dir, vmx # ── vm remove ────────────────────────────────────────────────────────────── def test_vm_remove_missing_vmx_returns_without_error(tmp_path: Path) -> None: """Pester migration: returns without error when VMX does not exist.""" missing = tmp_path / "ghost" / "ghost.vmx" result = CliRunner().invoke(cli, ["vm", "remove", "--vmx", str(missing)]) assert result.exit_code == 0, result.output assert "nothing to remove" in result.output def test_vm_remove_partial_clone_dir_is_removed(tmp_path: Path) -> None: """Pester migration: partial clone dir without VMX is still removed.""" clone_dir, vmx = _make_clone_dir(tmp_path, "partial") vmx.unlink() # leave the directory but no VMX result = CliRunner().invoke(cli, ["vm", "remove", "--vmx", str(vmx)]) assert result.exit_code == 0 assert not clone_dir.exists() def test_vm_remove_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: """Pester migration: removes the clone dir when VMX exists and stop+delete succeed.""" clone_dir, vmx = _make_clone_dir(tmp_path, "live") backend = _FakeBackend() monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend) result = CliRunner().invoke( cli, ["vm", "remove", "--vmx", str(vmx), "--graceful-timeout", "1"] ) assert result.exit_code == 0, result.output assert ("delete", None) in backend.calls assert not clone_dir.exists() def test_vm_remove_force_skips_soft_stop(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: _, vmx = _make_clone_dir(tmp_path, "force") backend = _FakeBackend() monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend) result = CliRunner().invoke(cli, ["vm", "remove", "--vmx", str(vmx), "--force"]) assert result.exit_code == 0, result.output # No soft stop was issued. soft_stops = [c for c in backend.calls if c == ("stop", False)] assert soft_stops == [] hard_stops = [c for c in backend.calls if c == ("stop", True)] assert hard_stops != [] def test_vm_remove_delete_retry_succeeds(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: clone_dir, vmx = _make_clone_dir(tmp_path, "retry") backend = _FakeBackend(delete_fails_first=2) monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend) result = CliRunner().invoke(cli, ["vm", "remove", "--vmx", str(vmx)]) assert result.exit_code == 0 delete_calls = [c for c in backend.calls if c[0] == "delete"] assert len(delete_calls) == 3 # 2 failures + 1 success assert not clone_dir.exists() # ── vm cleanup ───────────────────────────────────────────────────────────── def _age_dir(path: Path, hours: float) -> None: ts = (datetime.now() - timedelta(hours=hours)).timestamp() import os os.utime(path, (ts, ts)) def test_vm_cleanup_dry_run_lists_orphans(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: base = tmp_path / "build-vms" base.mkdir() fresh, _ = _make_clone_dir(base, "fresh") old, _ = _make_clone_dir(base, "old") _age_dir(old, hours=10) _age_dir(fresh, hours=0) # Make sure backend isn't constructed (vmrun absent on test host). monkeypatch.setattr(vm_module, "_make_backend", lambda _v: None) result = CliRunner().invoke( cli, [ "vm", "cleanup", "--clone-base-dir", str(base), "--max-age-hours", "4", "--what-if", ], ) assert result.exit_code == 0, result.output assert "would destroy" in result.output assert old.exists() # dry run did NOT delete assert fresh.exists() def test_vm_cleanup_destroys_old_dirs(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: base = tmp_path / "build-vms" base.mkdir() old, _ = _make_clone_dir(base, "old") _age_dir(old, hours=10) backend = _FakeBackend() monkeypatch.setattr(vm_module, "_make_backend", lambda _v: backend) result = CliRunner().invoke( cli, [ "vm", "cleanup", "--clone-base-dir", str(base), "--max-age-hours", "4", ], ) assert result.exit_code == 0, result.output assert not old.exists() # Backend was used to stop+delete. assert any(c[0] == "delete" for c in backend.calls) def test_vm_cleanup_handles_missing_base_dir(tmp_path: Path) -> None: missing = tmp_path / "does-not-exist" result = CliRunner().invoke( cli, ["vm", "cleanup", "--clone-base-dir", str(missing)] ) assert result.exit_code == 0 assert "nothing to do" in result.output def test_vm_cleanup_removes_stale_lock(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: base = tmp_path / "bv" base.mkdir() lock = tmp_path / "vm-start.lock" lock.write_text("x") _age_dir(lock, hours=1) monkeypatch.setattr(vm_module, "_make_backend", lambda _v: None) result = CliRunner().invoke( cli, [ "vm", "cleanup", "--clone-base-dir", str(base), "--lock-file", str(lock), ], ) assert result.exit_code == 0 assert not lock.exists()