refactor(vms): extract operations/vms.py seam from commands/vm.py (W1)
This commit is contained in:
@@ -27,6 +27,7 @@ from ci_orchestrator.backends import load_backend
|
||||
from ci_orchestrator.backends.errors import BackendError, BackendNotAvailable
|
||||
from ci_orchestrator.backends.protocol import VmHandle
|
||||
from ci_orchestrator.config import load_config
|
||||
from ci_orchestrator.operations import vms as ops_vms
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover
|
||||
from ci_orchestrator.backends.protocol import VmBackend
|
||||
@@ -190,111 +191,6 @@ def vm_remove(
|
||||
# ────────────────────────────────────────────────────────────────────── cleanup
|
||||
|
||||
|
||||
def _list_orphans(clone_base: Path, max_age_hours: int) -> list[Path]:
|
||||
"""Return orphan clone directories older than ``max_age_hours``."""
|
||||
if not clone_base.is_dir():
|
||||
return []
|
||||
cutoff = datetime.now(tz=UTC) - timedelta(hours=max_age_hours)
|
||||
out: list[Path] = []
|
||||
for entry in clone_base.iterdir():
|
||||
try:
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
mtime = datetime.fromtimestamp(entry.stat().st_mtime, tz=UTC)
|
||||
except OSError:
|
||||
continue
|
||||
if mtime < cutoff:
|
||||
out.append(entry)
|
||||
return out
|
||||
|
||||
|
||||
def _find_vmx(clone_dir: Path) -> Path | None:
|
||||
for entry in clone_dir.iterdir():
|
||||
if entry.is_file() and entry.suffix.lower() == ".vmx":
|
||||
return entry
|
||||
return None
|
||||
|
||||
|
||||
def cleanup_orphans(
|
||||
clone_base: Path,
|
||||
max_age_hours: int,
|
||||
backend: VmBackend | None,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
lock_file: Path | None = None,
|
||||
lock_max_age_minutes: int = 30,
|
||||
) -> int:
|
||||
"""Remove orphaned clone directories. Returns count removed.
|
||||
|
||||
Phase C hook: ``backend`` is used to stop+delete each orphan. If the
|
||||
backend is unavailable, directory removal is still attempted.
|
||||
"""
|
||||
orphans = _list_orphans(clone_base, max_age_hours)
|
||||
if not orphans:
|
||||
click.echo(
|
||||
f"[vm cleanup] no orphaned VMs found (threshold: {max_age_hours} h)."
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
f"[vm cleanup] found {len(orphans)} orphaned director"
|
||||
f"{'y' if len(orphans) == 1 else 'ies'} older than {max_age_hours} h."
|
||||
)
|
||||
|
||||
removed = 0
|
||||
now = datetime.now(tz=UTC)
|
||||
for entry in orphans:
|
||||
age_h = int((now - datetime.fromtimestamp(entry.stat().st_mtime, tz=UTC)).total_seconds() // 3600)
|
||||
if dry_run:
|
||||
click.echo(f"[vm cleanup] would destroy {entry} (age: {age_h} h)")
|
||||
continue
|
||||
click.echo(f"[vm cleanup] processing: {entry}")
|
||||
vmx = _find_vmx(entry)
|
||||
if vmx is not None and backend is not None:
|
||||
handle = VmHandle(identifier=str(vmx))
|
||||
try:
|
||||
backend.stop(handle, hard=True)
|
||||
except BackendError as exc:
|
||||
click.echo(f"[vm cleanup] hard stop ignored: {exc}", err=True)
|
||||
time.sleep(2.0)
|
||||
try:
|
||||
backend.delete(handle)
|
||||
except BackendError as exc:
|
||||
click.echo(
|
||||
f"[vm cleanup] vmrun deleteVM failed: {exc}; falling back to dir removal.",
|
||||
err=True,
|
||||
)
|
||||
elif vmx is None:
|
||||
click.echo(
|
||||
f"[vm cleanup] no .vmx in {entry} - removing directory only.", err=True
|
||||
)
|
||||
|
||||
if _try_remove_dir(entry):
|
||||
click.echo(f"[vm cleanup] removed: {entry}")
|
||||
removed += 1
|
||||
else:
|
||||
click.echo(
|
||||
f"[vm cleanup] could not fully remove: {entry} - manual cleanup needed.",
|
||||
err=True,
|
||||
)
|
||||
|
||||
# Stale lock file cleanup (mirrors Cleanup-OrphanedBuildVMs.ps1).
|
||||
if lock_file is not None and lock_file.is_file():
|
||||
cutoff = datetime.now(tz=UTC) - timedelta(minutes=lock_max_age_minutes)
|
||||
mtime = datetime.fromtimestamp(lock_file.stat().st_mtime, tz=UTC)
|
||||
if mtime < cutoff and not dry_run:
|
||||
age_min = int((datetime.now(tz=UTC) - mtime).total_seconds() // 60)
|
||||
try:
|
||||
lock_file.unlink()
|
||||
click.echo(
|
||||
f"[vm cleanup] removed stale lock file (age: {age_min} min): {lock_file}"
|
||||
)
|
||||
except OSError as exc:
|
||||
click.echo(f"[vm cleanup] could not remove lock file: {exc}", err=True)
|
||||
|
||||
click.echo("[vm cleanup] done.")
|
||||
return removed
|
||||
|
||||
|
||||
@vm.command("cleanup")
|
||||
@click.option(
|
||||
"--clone-base-dir",
|
||||
@@ -348,14 +244,71 @@ def vm_cleanup(
|
||||
)
|
||||
backend = None
|
||||
|
||||
cleanup_orphans(
|
||||
lock_path = Path(lock_file) if lock_file else None
|
||||
|
||||
# Snapshot for messaging: cleanup_orphans() re-derives the same list
|
||||
# internally (nothing is mutated between the two calls above the
|
||||
# per-orphan destroy loop), so the ordering matches 1:1.
|
||||
orphans = ops_vms.list_orphans(base_path, max_age_hours)
|
||||
if not orphans:
|
||||
click.echo(
|
||||
f"[vm cleanup] no orphaned VMs found (threshold: {max_age_hours} h)."
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
f"[vm cleanup] found {len(orphans)} orphaned director"
|
||||
f"{'y' if len(orphans) == 1 else 'ies'} older than {max_age_hours} h."
|
||||
)
|
||||
|
||||
lock_was_stale = False
|
||||
lock_age_min = 0
|
||||
if lock_path is not None and lock_path.is_file():
|
||||
cutoff = datetime.now(tz=UTC) - timedelta(minutes=30)
|
||||
mtime = datetime.fromtimestamp(lock_path.stat().st_mtime, tz=UTC)
|
||||
if mtime < cutoff:
|
||||
lock_was_stale = True
|
||||
lock_age_min = int((datetime.now(tz=UTC) - mtime).total_seconds() // 60)
|
||||
|
||||
results = ops_vms.cleanup_orphans(
|
||||
clone_base=base_path,
|
||||
max_age_hours=max_age_hours,
|
||||
backend=backend,
|
||||
dry_run=what_if,
|
||||
lock_file=Path(lock_file) if lock_file else None,
|
||||
lock_file=lock_path,
|
||||
)
|
||||
|
||||
for orphan, result in zip(orphans, results, strict=True):
|
||||
if what_if:
|
||||
click.echo(
|
||||
f"[vm cleanup] would destroy {orphan.path} (age: {orphan.age_hours} h)"
|
||||
)
|
||||
continue
|
||||
click.echo(f"[vm cleanup] processing: {orphan.path}")
|
||||
if orphan.vmx is None:
|
||||
click.echo(
|
||||
f"[vm cleanup] no .vmx in {orphan.path} - removing directory only.",
|
||||
err=True,
|
||||
)
|
||||
elif result.error is not None:
|
||||
click.echo(
|
||||
f"[vm cleanup] backend error on {orphan.path}: {result.error}",
|
||||
err=True,
|
||||
)
|
||||
if result.dir_removed:
|
||||
click.echo(f"[vm cleanup] removed: {orphan.path}")
|
||||
else:
|
||||
click.echo(
|
||||
f"[vm cleanup] could not fully remove: {orphan.path} - manual cleanup needed.",
|
||||
err=True,
|
||||
)
|
||||
|
||||
if lock_was_stale and not what_if and lock_path is not None and not lock_path.exists():
|
||||
click.echo(
|
||||
f"[vm cleanup] removed stale lock file (age: {lock_age_min} min): {lock_path}"
|
||||
)
|
||||
|
||||
click.echo("[vm cleanup] done.")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────── new
|
||||
|
||||
@@ -686,4 +639,4 @@ def vmware(
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["cleanup_orphans", "vm", "vmware"]
|
||||
__all__ = ["vm", "vmware"]
|
||||
|
||||
Reference in New Issue
Block a user