refactor(vms): extract operations/vms.py seam from commands/vm.py (W1)

This commit is contained in:
2026-07-23 15:22:18 +02:00
parent b9f6db6bd5
commit b0415f36e4
4 changed files with 821 additions and 212 deletions
+61 -108
View File
@@ -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"]
+313
View File
@@ -0,0 +1,313 @@
"""Pure VM-lifecycle operations shared by the CLI and (future) web UI.
Extracted from ``commands/vm.py`` (W1 of the web-UI plan). Zero ``click``,
zero ``print`` — everything here returns a dataclass. Presentation stays in
the CLI command wrappers (and, later, the web routes).
"""
from __future__ import annotations
import contextlib
import shutil
import time
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING
from ci_orchestrator.backends.errors import BackendError
from ci_orchestrator.backends.protocol import VmHandle
if TYPE_CHECKING: # pragma: no cover
from ci_orchestrator.backends.protocol import VmBackend
from ci_orchestrator.config import Config
# Matches the default ``vm cleanup --max-age-hours`` threshold; used to flag
# a live VM as an orphan candidate in ``list_live_vms``.
_ORPHAN_AGE_HOURS = 4
@dataclass(frozen=True)
class LiveVm:
"""A currently-running VM as reported by the backend."""
name: str
vmx: str
ip: str | None
guest_os: str
uptime_sec: int | None
is_orphan: bool
@dataclass(frozen=True)
class Orphan:
"""A clone directory older than the cleanup threshold."""
path: Path
vmx: Path | None
age_hours: int
@dataclass(frozen=True)
class RemovalResult:
"""Outcome of destroying a single VM (used by both remove and cleanup)."""
target: str
stopped: bool
deleted: bool
dir_removed: bool
error: str | None
# ────────────────────────────────────────────────────────────────── helpers
def _try_remove_dir(path: Path) -> bool:
"""Best-effort recursive directory removal. Returns True if path is gone."""
if not path.exists():
return True
shutil.rmtree(path, ignore_errors=True)
return not path.exists()
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 _is_under(path: Path, base: Path) -> bool:
"""Return True if ``base`` is an ancestor of ``path`` (resolved)."""
try:
return base.resolve() in path.resolve().parents
except OSError:
return False
def _read_guest_os(vmx: Path) -> str:
"""Best-effort parse of the ``guestOS = "..."`` line from a VMX file."""
try:
text = vmx.read_text(encoding="utf-8", errors="replace")
except OSError:
return ""
for line in text.splitlines():
if "=" not in line:
continue
key, _, rhs = line.partition("=")
if key.strip().lower() != "guestos":
continue
rhs = rhs.strip()
if rhs.startswith('"') and rhs.endswith('"') and len(rhs) >= 2:
return rhs[1:-1]
return rhs
return ""
# ─────────────────────────────────────────────────────────────── live VMs
def list_live_vms(backend: VmBackend, config: Config) -> list[LiveVm]:
"""Return every VM the backend reports as running, enriched for display.
Never raises on a single bad VM — a handle that can't be inspected is
still returned with degraded fields rather than aborting the whole list.
"""
try:
handles = backend.list_running()
except BackendError:
return []
now = datetime.now(tz=UTC)
out: list[LiveVm] = []
for h in handles:
vmx = Path(h.identifier)
name = h.name or vmx.stem
guest_os = _read_guest_os(vmx)
try:
ip = backend.get_ip(h, timeout=0)
except BackendError:
ip = None
under_base = _is_under(vmx, config.paths.build_vms)
uptime_sec: int | None = None
if under_base:
try:
mtime = datetime.fromtimestamp(vmx.parent.stat().st_mtime, tz=UTC)
uptime_sec = int((now - mtime).total_seconds())
except OSError:
uptime_sec = None
is_orphan = False
if under_base and uptime_sec is not None:
age_hours = uptime_sec // 3600
is_orphan = age_hours >= _ORPHAN_AGE_HOURS
out.append(
LiveVm(
name=name,
vmx=str(vmx),
ip=ip,
guest_os=guest_os,
uptime_sec=uptime_sec,
is_orphan=is_orphan,
)
)
return out
# ───────────────────────────────────────────────────────────────── orphans
def list_orphans(clone_base: Path, max_age_hours: int) -> list[Orphan]:
"""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)
now = datetime.now(tz=UTC)
out: list[Orphan] = []
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:
age_hours = int((now - mtime).total_seconds() // 3600)
out.append(Orphan(path=entry, vmx=_find_vmx(entry), age_hours=age_hours))
return out
# ───────────────────────────────────────────────────────────────── removal
def remove_vm(backend: VmBackend, vmx: Path, *, dry_run: bool = False) -> RemovalResult:
"""Stop and permanently delete a single VM. Pure extraction of the destroy sequence.
Swallows :class:`BackendError` from stop/delete (recorded in the result
flags/``error``); never raises.
"""
target = str(vmx)
if dry_run:
return RemovalResult(target=target, stopped=False, deleted=False, dir_removed=False, error=None)
handle = VmHandle(identifier=target)
first_error: str | None = None
stopped = False
try:
backend.stop(handle, hard=True)
stopped = True
except BackendError as exc:
first_error = first_error or str(exc)
time.sleep(2.0)
deleted = False
last_err: str | None = None
for delay in (0, 3, 6):
if delay > 0:
time.sleep(delay)
try:
backend.delete(handle)
deleted = True
break
except BackendError as exc:
last_err = str(exc)
if not deleted and last_err is not None:
first_error = first_error or last_err
dir_removed = _try_remove_dir(vmx.parent)
return RemovalResult(
target=target,
stopped=stopped,
deleted=deleted,
dir_removed=dir_removed,
error=first_error,
)
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,
) -> list[RemovalResult]:
"""Remove orphaned clone directories. Returns one :class:`RemovalResult` per orphan.
``backend`` is used to stop+delete each orphan's VMX before removing its
directory. If the backend is unavailable (``None``), directory removal
is still attempted.
"""
orphans = list_orphans(clone_base, max_age_hours)
results: list[RemovalResult] = []
for orphan in orphans:
entry = orphan.path
target = str(orphan.vmx) if orphan.vmx is not None else str(entry)
if dry_run:
results.append(
RemovalResult(target=target, stopped=False, deleted=False, dir_removed=False, error=None)
)
continue
error: str | None = None
stopped = False
deleted = False
vmx = orphan.vmx
if vmx is not None and backend is not None:
handle = VmHandle(identifier=str(vmx))
try:
backend.stop(handle, hard=True)
stopped = True
except BackendError as exc:
error = error or str(exc)
time.sleep(2.0)
try:
backend.delete(handle)
deleted = True
except BackendError as exc:
error = error or str(exc)
dir_removed = _try_remove_dir(entry)
results.append(
RemovalResult(
target=target,
stopped=stopped,
deleted=deleted,
dir_removed=dir_removed,
error=error,
)
)
# 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:
with contextlib.suppress(OSError):
lock_file.unlink()
return results
__all__ = [
"LiveVm",
"Orphan",
"RemovalResult",
"cleanup_orphans",
"list_live_vms",
"list_orphans",
"remove_vm",
]