refactor(backend): add list_running() to VmBackend seam (W1)

operations.vms.list_live_vms needs a backend-agnostic running-VM list for the web dashboard; is_running now shares the single vmrun-list parse.
This commit is contained in:
2026-07-23 15:22:09 +02:00
parent 11a80d26f0
commit b9f6db6bd5
4 changed files with 66 additions and 9 deletions
+11
View File
@@ -79,3 +79,14 @@ class VmBackend(Protocol):
getGuestIPAddress``) for this check — see ``AGENTS.md`` error #10.
"""
...
def list_running(self) -> list[VmHandle]:
"""Return a handle for every VM the backend reports as running.
Powers the read-only inventory in the web UI and orphan
reconciliation. Like :meth:`is_running`, implementations MUST NOT
rely on guest tools (see ``AGENTS.md`` error #10) — a plain
power-state listing is enough. Handle ``identifier`` is the
backend-native reference (a VMX path on Workstation).
"""
...
+24 -9
View File
@@ -140,22 +140,37 @@ class WorkstationVmrunBackend:
lines = lines[1:]
return lines
def list_running(self) -> list[VmHandle]:
"""``vmrun list`` → one :class:`VmHandle` per running VMX path.
Skips the ``Total running VMs: N`` header and any ``Error:`` lines
(``AGENTS.md`` error #9). Does NOT call ``getGuestIPAddress`` (error
#10). The handle ``name`` is the VMX file stem. Returns ``[]`` when
``vmrun list`` fails.
"""
result = self._run("list", check=False)
if result.returncode != 0:
return []
handles: list[VmHandle] = []
for raw in (result.stdout or "").splitlines():
line = raw.strip()
low = line.lower()
if not line or low.startswith("total running") or low.startswith("error:"):
continue
handles.append(VmHandle(identifier=line, name=Path(line).stem))
return handles
def is_running(self, handle: VmHandle) -> bool:
"""``vmrun list`` lookup — does NOT call ``getGuestIPAddress``.
See ``AGENTS.md`` error #10: ``getGuestIPAddress`` blocks for 30-60s
without VMware Tools. ``vmrun list`` returns immediately.
without VMware Tools. ``vmrun list`` returns immediately. Shares the
single parse in :meth:`list_running`.
"""
result = self._run("list", check=False)
if result.returncode != 0:
return False
target = Path(handle.identifier).resolve()
for line in (result.stdout or "").splitlines():
line = line.strip()
if not line or line.lower().startswith("total running"):
continue
for running in self.list_running():
try:
if Path(line).resolve() == target:
if Path(running.identifier).resolve() == target:
return True
except OSError:
continue
+1
View File
@@ -52,3 +52,4 @@ def test_protocol_stubs_are_callable() -> None:
assert b.get_ip(h) is None # line 69
assert b.list_snapshots(h) is None # line 73
assert b.is_running(h) is None # line 81
assert b.list_running() is None # list_running stub
+30
View File
@@ -164,6 +164,36 @@ def test_is_running_returns_false_when_absent(
assert backend.is_running(VmHandle(str(vmx))) is False
# ── list_running ─────────────────────────────────────────────────────────────
def test_list_running_parses_paths_and_skips_noise(
monkeypatch: pytest.MonkeyPatch, fake_vmrun: str
) -> None:
"""Header and ``Error:`` lines are dropped; each VMX becomes a handle."""
stdout = (
"Total running VMs: 2\n"
"/var/lib/ci/build-vms/Clone_a/Clone_a.vmx\n"
"Error: The VMware Tools are not running\n"
"/var/lib/ci/build-vms/Clone_b/Clone_b.vmx\n"
)
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _make_completed(0, stdout=stdout))
backend = WorkstationVmrunBackend(vmrun_path=fake_vmrun)
handles = backend.list_running()
assert [h.identifier for h in handles] == [
"/var/lib/ci/build-vms/Clone_a/Clone_a.vmx",
"/var/lib/ci/build-vms/Clone_b/Clone_b.vmx",
]
assert [h.name for h in handles] == ["Clone_a", "Clone_b"]
def test_list_running_returns_empty_on_failure(
monkeypatch: pytest.MonkeyPatch, fake_vmrun: str
) -> None:
monkeypatch.setattr(subprocess, "run", lambda *a, **kw: _make_completed(1, stderr="boom"))
backend = WorkstationVmrunBackend(vmrun_path=fake_vmrun)
assert backend.list_running() == []
# ── _resolve_vmrun(explicit=None) paths ───────────────────────────────────────
def test_resolve_vmrun_uses_which(monkeypatch: pytest.MonkeyPatch, fake_vmrun: str) -> None: