80f6661ad5
Implements Phase A2 of plans/implementation-plan-A-B.md: - commands/wait.py -> wait-ready (replaces Wait-VMReady.ps1) - commands/vm.py -> vm remove + vm cleanup (replaces Remove-BuildVM.ps1, Cleanup-OrphanedBuildVMs.ps1); cleanup accepts injected VmBackend (Phase C ESXi hook preserved) - commands/monitor.py -> monitor disk + monitor runner (replaces Watch-DiskSpace.ps1, Watch-RunnerHealth.ps1) - commands/report.py -> report job (replaces Get-CIJobSummary.ps1) Each PS script reduced to a 3-line shim that delegates to the Python CLI and preserves \0. Tests: 69 pytest cases across new test_commands_*.py modules; ruff clean, mypy --strict clean, coverage 74.5% (>=70 gate).
212 lines
7.2 KiB
Python
212 lines
7.2 KiB
Python
"""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()
|