Files
local-ci-cd-system/src/ci_orchestrator/commands/vm.py
T
Simone 43a69b82db feat: B5 — port retention/template-backup to Python, deploy systemd timers
- Add `retention run` command (ports Invoke-RetentionPolicy.ps1): purges
  old artifact/log dirs with aggressive mode when disk space is low.
- Add `template backup` command (ports Backup-CITemplate.ps1): 7z -mx=1
  compressed archives in /var/lib/ci/backups/, prunes to keep-count=3,
  stops/restarts act-runner.service around the copy.
- Update ci-retention-policy.service and ci-backup-template.service to use
  Python; pwsh is no longer required on the Linux host.
- Fix ci-watch-runner-health.service: pass --service-name act-runner
  (Linux service name, not Windows act_runner default).
- Fix _list_orphans in vm.py: wrap is_dir() inside the OSError try block
  so a stat() failure on an entry is silently skipped rather than raised.
- Mark B5 complete in PhaseB-user-checklist.md.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-05-21 20:35:07 +02:00

487 lines
16 KiB
Python

"""``vm`` sub-commands.
Phase A2 ports the leaf VM management scripts:
* ``vm remove`` — replaces ``scripts/Remove-BuildVM.ps1``
* ``vm cleanup`` — replaces ``scripts/Cleanup-OrphanedBuildVMs.ps1``
Phase C hook: ``vm cleanup`` accepts an optional :class:`VmBackend`
dependency so an ESXi backend can later supply a folder-scoped VM list
instead of scanning the local filesystem.
"""
from __future__ import annotations
import shutil
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import TYPE_CHECKING
import click
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
if TYPE_CHECKING: # pragma: no cover
from ci_orchestrator.backends.protocol import VmBackend
# ────────────────────────────────────────────────────────────────────── helpers
def _make_backend(vmrun_path: str | None) -> VmBackend:
"""Build a backend honouring an optional vmrun override."""
config = load_config()
if vmrun_path is not None:
from ci_orchestrator.backends.workstation import WorkstationVmrunBackend
return WorkstationVmrunBackend(vmrun_path=vmrun_path)
return load_backend(config)
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 _stop_with_fallback(backend: VmBackend, handle: VmHandle) -> None:
"""Soft-stop, then hard-stop on failure. Both errors are tolerated."""
try:
backend.stop(handle, hard=False)
except BackendError as exc:
click.echo(f"[vm remove] soft stop failed: {exc}", err=True)
try:
if backend.is_running(handle):
click.echo("[vm remove] still running, forcing hard stop...")
backend.stop(handle, hard=True)
except BackendError as exc:
click.echo(f"[vm remove] hard stop failed: {exc}", err=True)
# ─────────────────────────────────────────────────────────────────────── group
@click.group("vm")
def vm() -> None:
"""VM lifecycle commands."""
# ─────────────────────────────────────────────────────────────────────── remove
@vm.command("remove")
@click.option(
"--vmx",
"--vm-path",
"vmx",
required=True,
help="Path to the clone VMX file to destroy.",
)
@click.option("--vmrun-path", "vmrun_path", default=None, help="Override vmrun.exe path.")
@click.option(
"--graceful-timeout",
"--graceful-timeout-seconds",
"graceful_timeout",
type=click.IntRange(1, 60),
default=15,
show_default=True,
help="Seconds to wait for soft stop before forcing.",
)
@click.option(
"--force",
is_flag=True,
default=False,
help="Skip soft stop and go straight to hard stop.",
)
def vm_remove(
vmx: str,
vmrun_path: str | None,
graceful_timeout: int,
force: bool,
) -> None:
"""Stop and permanently delete an ephemeral build VM."""
vmx_path = Path(vmx)
clone_dir = vmx_path.parent
click.echo(f"[vm remove] destroying VM: {vmx}")
if not vmx_path.is_file():
click.echo(
f"[vm remove] VMX file not found - nothing to remove: {vmx}",
err=True,
)
# Still try to clean up a partial clone directory.
if clone_dir.exists() and clone_dir != vmx_path:
_try_remove_dir(clone_dir)
return
try:
backend = _make_backend(vmrun_path)
except BackendNotAvailable as exc:
click.echo(
f"[vm remove] vmrun unavailable ({exc}); attempting directory removal only.",
err=True,
)
_try_remove_dir(clone_dir)
return
handle = VmHandle(identifier=vmx)
if force:
try:
backend.stop(handle, hard=True)
except BackendError as exc:
click.echo(f"[vm remove] hard stop failed: {exc}", err=True)
else:
click.echo(f"[vm remove] sending soft stop (timeout: {graceful_timeout}s)...")
_stop_with_fallback(backend, handle)
# Brief settle: VMware may keep file locks for a moment after stop.
deadline = time.monotonic() + 20.0
while time.monotonic() < deadline:
try:
if not backend.is_running(handle):
break
except BackendError:
break
time.sleep(2.0)
click.echo("[vm remove] deleting VM...")
deleted = False
last_err: str = ""
for delay in (0, 3, 6):
if delay > 0:
click.echo(f"[vm remove] retrying deleteVM in {delay}s...")
time.sleep(delay)
try:
backend.delete(handle)
deleted = True
break
except BackendError as exc:
last_err = str(exc)
if not deleted:
click.echo(
f"[vm remove] vmrun deleteVM failed after retries: {last_err}", err=True
)
click.echo("[vm remove] falling back to manual directory removal.")
if clone_dir.exists():
click.echo(f"[vm remove] removing clone directory: {clone_dir}")
if _try_remove_dir(clone_dir):
click.echo("[vm remove] clone directory removed.")
else:
click.echo(
f"[vm remove] could not fully remove: {clone_dir} - manual cleanup needed.",
err=True,
)
click.echo("[vm remove] VM destruction complete.")
# ────────────────────────────────────────────────────────────────────── 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",
"--clone-base",
"clone_base_dir",
default=None,
help="Directory containing ephemeral VM clones (defaults to config.paths.build_vms).",
)
@click.option(
"--max-age-hours",
type=click.IntRange(0, 168),
default=4,
show_default=True,
)
@click.option("--vmrun-path", "vmrun_path", default=None, help="Override vmrun.exe path.")
@click.option(
"--lock-file",
default=None,
help="Path to a stale vm-start lock file to remove if older than 30 minutes.",
)
@click.option(
"--what-if",
"--whatif",
"what_if",
is_flag=True,
default=False,
help="List orphans without destroying them.",
)
def vm_cleanup(
clone_base_dir: str | None,
max_age_hours: int,
vmrun_path: str | None,
lock_file: str | None,
what_if: bool,
) -> None:
"""Destroy ephemeral build VMs left behind after a crash or timeout."""
config = load_config()
base_path = Path(clone_base_dir) if clone_base_dir else config.paths.build_vms
if not base_path.exists():
click.echo(
f"[vm cleanup] clone base dir not found: {base_path} - nothing to do."
)
return
try:
backend: VmBackend | None = _make_backend(vmrun_path)
except BackendNotAvailable as exc:
click.echo(
f"[vm cleanup] vmrun not available ({exc}); will attempt directory removal only.",
err=True,
)
backend = None
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,
)
# ─────────────────────────────────────────────────────────────────────── new
@vm.command("new")
@click.option(
"--template",
"--template-path",
"template",
required=True,
help="Identifier of the template VM (Workstation: VMX path; ESXi: managed-object ref).",
)
@click.option(
"--snapshot",
"--snapshot-name",
"snapshot",
default="BaseClean",
show_default=True,
help="Snapshot name on the template to clone from.",
)
@click.option(
"--clone-base-dir",
"clone_base_dir",
required=True,
help="Directory under which the new clone folder will be created.",
)
@click.option(
"--job-id",
"job_id",
required=True,
help="Unique job identifier (used to name the clone folder).",
)
@click.option(
"--vmrun-path",
"vmrun_path",
default=None,
help="Override vmrun binary path.",
)
@click.option(
"--guest-os",
type=click.Choice(["windows", "linux"], case_sensitive=False),
default="windows",
show_default=True,
help="Informational; reserved for backend hints in Phase C.",
)
@click.option(
"--start",
"start_vm",
is_flag=True,
default=False,
help="Start the clone headless immediately after creation.",
)
def vm_new(
template: str,
snapshot: str,
clone_base_dir: str,
job_id: str,
vmrun_path: str | None,
guest_os: str,
start_vm: bool,
) -> None:
"""Create a linked clone of the template VM for an ephemeral CI build.
Mirrors ``scripts/New-BuildVM.ps1``. Prints the clone identifier
(Workstation: VMX path) on stdout on success.
Phase C hook: ``--template`` is treated as an opaque string; the backend
is responsible for interpretation. Workstation builds the destination
path under ``--clone-base-dir`` from ``--job-id`` + timestamp.
"""
del guest_os # currently informational only
# Verify the template identifier looks plausible: for Workstation it is
# a VMX path. For other backends in Phase C this validation will move
# into the backend itself.
template_path = Path(template)
if not template_path.is_file():
raise click.ClickException(f"Template VMX not found: {template}")
base = Path(clone_base_dir)
base.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now(tz=UTC).strftime("%Y%m%d_%H%M%S")
clone_name = f"Clone_{job_id}_{timestamp}"
clone_dir = base / clone_name
clone_vmx = clone_dir / f"{clone_name}.vmx"
click.echo("[vm new] creating linked clone...", err=True)
click.echo(f" template : {template}", err=True)
click.echo(f" snapshot : {snapshot}", err=True)
click.echo(f" clone vmx: {clone_vmx}", err=True)
try:
backend = _make_backend(vmrun_path)
except BackendNotAvailable as exc:
raise click.ClickException(f"vmrun unavailable: {exc}") from exc
start = time.monotonic()
try:
handle = backend.clone_linked(
template=str(template_path),
snapshot=snapshot,
name=clone_name,
destination=str(clone_vmx),
)
except BackendError as exc:
# Clean up partial clone directory if vmrun left one behind.
if clone_dir.exists():
_try_remove_dir(clone_dir)
raise click.ClickException(f"clone failed: {exc}") from exc
if not clone_vmx.is_file():
raise click.ClickException(
f"backend reported success but clone VMX is missing: {clone_vmx}"
)
elapsed = time.monotonic() - start
click.echo(f"[vm new] clone created in {elapsed:.1f}s", err=True)
if start_vm:
click.echo("[vm new] starting VM headless...", err=True)
try:
backend.start(handle, headless=True)
except BackendError as exc:
raise click.ClickException(f"vmrun start failed: {exc}") from exc
click.echo("[vm new] VM started.", err=True)
# Final stdout line: the identifier itself, so PS callers can capture it.
click.echo(handle.identifier)
__all__ = ["cleanup_orphans", "vm"]