From b9f6db6bd5f2dad359b7e821403153fc49131466 Mon Sep 17 00:00:00 2001 From: Simone Date: Thu, 23 Jul 2026 15:22:09 +0200 Subject: [PATCH] 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. --- src/ci_orchestrator/backends/protocol.py | 11 +++++++ src/ci_orchestrator/backends/workstation.py | 33 +++++++++++++++------ tests/python/test_backends_protocol.py | 1 + tests/python/test_workstation_backend.py | 30 +++++++++++++++++++ 4 files changed, 66 insertions(+), 9 deletions(-) diff --git a/src/ci_orchestrator/backends/protocol.py b/src/ci_orchestrator/backends/protocol.py index 19af3b9..ce89f49 100644 --- a/src/ci_orchestrator/backends/protocol.py +++ b/src/ci_orchestrator/backends/protocol.py @@ -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). + """ + ... diff --git a/src/ci_orchestrator/backends/workstation.py b/src/ci_orchestrator/backends/workstation.py index 726a8ef..39987c0 100644 --- a/src/ci_orchestrator/backends/workstation.py +++ b/src/ci_orchestrator/backends/workstation.py @@ -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 diff --git a/tests/python/test_backends_protocol.py b/tests/python/test_backends_protocol.py index 03b05f7..c70f766 100644 --- a/tests/python/test_backends_protocol.py +++ b/tests/python/test_backends_protocol.py @@ -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 diff --git a/tests/python/test_workstation_backend.py b/tests/python/test_workstation_backend.py index 80ff87b..1573c2e 100644 --- a/tests/python/test_workstation_backend.py +++ b/tests/python/test_workstation_backend.py @@ -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: