feat(vm): add vm open to launch the VMware Workstation GUI

Open a VMX in the interactive Workstation GUI (not the headless vmrun path) for
hands-on debugging of a clone. Spawns `vmware` detached (own session, stdio
detached) and returns the child PID.

Flags: --vmx (required), --power-on (-x), --fullscreen (-X), --new-window (-n),
--display (sudo -u ci-runner strips $DISPLAY, so allow forcing it),
--vmware-path. Warns when no X display is set. 7 tests; vm.py 96%.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 00:11:36 +02:00
parent decdf73202
commit 04ca316f17
2 changed files with 205 additions and 0 deletions
+114
View File
@@ -12,7 +12,9 @@ instead of scanning the local filesystem.
from __future__ import annotations
import os
import shutil
import subprocess
import time
from datetime import UTC, datetime, timedelta
from pathlib import Path
@@ -483,4 +485,116 @@ def vm_new(
click.echo(handle.identifier)
# ────────────────────────────────────────────────────────────────────────── open
def _launch_gui(argv: list[str], env: dict[str, str]) -> int:
"""Spawn the VMware Workstation GUI detached. Returns the child PID.
Isolated so tests can monkeypatch it without spawning a real process. The
GUI must outlive the CLI, so the child is started in its own session with
its stdio detached.
"""
proc = subprocess.Popen( # argv is a fixed list, never a shell string
argv,
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
return proc.pid
@vm.command("open")
@click.option(
"--vmx",
"--vm-path",
"vmx",
required=True,
help="Path to the VMX file to open in the VMware Workstation GUI.",
)
@click.option(
"--vmware-path",
"vmware_path",
default="vmware",
show_default=True,
help="Path to the VMware Workstation GUI binary.",
)
@click.option(
"--power-on",
"power_on",
is_flag=True,
default=False,
help="Power the VM on when it is opened (vmware -x).",
)
@click.option(
"--fullscreen",
is_flag=True,
default=False,
help="Power on and enter full-screen mode (vmware -X). Implies --power-on.",
)
@click.option(
"--new-window",
"new_window",
is_flag=True,
default=False,
help="Open in a new window instead of a tab (vmware -n).",
)
@click.option(
"--display",
"display",
default=None,
help="X display to use (default: inherit $DISPLAY). e.g. ':0'.",
)
def vm_open(
vmx: str,
vmware_path: str,
power_on: bool,
fullscreen: bool,
new_window: bool,
display: str | None,
) -> None:
"""Open a VMX in the VMware Workstation GUI (run as the ci-runner user).
Launches the interactive GUI — unlike the headless ``vmrun`` path the rest
of the orchestrator uses. The GUI needs an X display: when invoked through
``sudo -u ci-runner`` the display is usually stripped, so authorise the
service user first, e.g. ``xhost +SI:localuser:ci-runner``, and pass
``--display :0`` (or export ``DISPLAY``).
"""
vmx_path = Path(vmx)
if not vmx_path.is_file():
raise click.ClickException(f"VMX file not found: {vmx}")
gui = shutil.which(vmware_path) or vmware_path
env = dict(os.environ)
if display:
env["DISPLAY"] = display
if not env.get("DISPLAY"):
click.echo(
"[vm open] WARNING: no X display set ($DISPLAY empty and --display "
"omitted). The GUI will fail to start. Pass --display :0 and run "
"`xhost +SI:localuser:ci-runner` from a desktop session first.",
err=True,
)
argv = [gui]
if new_window:
argv.append("-n")
if fullscreen:
argv.append("-X")
elif power_on:
argv.append("-x")
argv.append(str(vmx_path))
click.echo(f"[vm open] launching VMware GUI: {' '.join(argv)}")
try:
pid = _launch_gui(argv, env)
except (OSError, ValueError) as exc:
raise click.ClickException(f"failed to launch VMware GUI: {exc}") from exc
click.echo(f"[vm open] VMware GUI started (pid {pid}); display={env.get('DISPLAY', '')}")
__all__ = ["cleanup_orphans", "vm"]
+91
View File
@@ -497,3 +497,94 @@ def test_vm_cleanup_backend_unavailable(monkeypatch: pytest.MonkeyPatch, tmp_pat
)
assert result.exit_code == 0
assert "vmrun not available" in result.output
# ── vm open ───────────────────────────────────────────────────────────────────
def _capture_launch(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
"""Patch _launch_gui to record argv/env instead of spawning a process."""
captured: dict[str, Any] = {}
def _fake(argv: list[str], env: dict[str, str]) -> int:
captured["argv"] = argv
captured["env"] = env
return 4242
monkeypatch.setattr(vm_module, "_launch_gui", _fake)
return captured
def test_vm_open_missing_vmx_errors(tmp_path: Path) -> None:
result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(tmp_path / "nope.vmx")])
assert result.exit_code != 0
assert "VMX file not found" in result.output
def test_vm_open_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
vmx = tmp_path / "clone.vmx"
vmx.write_text("config")
cap = _capture_launch(monkeypatch)
monkeypatch.setenv("DISPLAY", ":0")
result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(vmx)])
assert result.exit_code == 0, result.output
assert cap["argv"][-1] == str(vmx)
assert "-x" not in cap["argv"] and "-X" not in cap["argv"]
assert "pid 4242" in result.output
def test_vm_open_power_on_adds_x(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
vmx = tmp_path / "clone.vmx"
vmx.write_text("config")
cap = _capture_launch(monkeypatch)
monkeypatch.setenv("DISPLAY", ":0")
result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(vmx), "--power-on"])
assert result.exit_code == 0, result.output
assert "-x" in cap["argv"]
def test_vm_open_fullscreen_adds_capital_x(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
vmx = tmp_path / "clone.vmx"
vmx.write_text("config")
cap = _capture_launch(monkeypatch)
monkeypatch.setenv("DISPLAY", ":0")
result = CliRunner().invoke(
cli, ["vm", "open", "--vmx", str(vmx), "--fullscreen", "--new-window"]
)
assert result.exit_code == 0, result.output
assert "-X" in cap["argv"] and "-n" in cap["argv"]
def test_vm_open_display_option_overrides_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
vmx = tmp_path / "clone.vmx"
vmx.write_text("config")
cap = _capture_launch(monkeypatch)
monkeypatch.delenv("DISPLAY", raising=False)
result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(vmx), "--display", ":1"])
assert result.exit_code == 0, result.output
assert cap["env"]["DISPLAY"] == ":1"
assert "display=:1" in result.output
def test_vm_open_warns_without_display(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
vmx = tmp_path / "clone.vmx"
vmx.write_text("config")
_capture_launch(monkeypatch)
monkeypatch.delenv("DISPLAY", raising=False)
result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(vmx)])
assert result.exit_code == 0, result.output
assert "no X display" in result.output
def test_vm_open_launch_failure_is_reported(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
vmx = tmp_path / "clone.vmx"
vmx.write_text("config")
def _boom(argv: list[str], env: dict[str, str]) -> int:
raise OSError("vmware not found")
monkeypatch.setattr(vm_module, "_launch_gui", _boom)
monkeypatch.setenv("DISPLAY", ":0")
result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(vmx)])
assert result.exit_code != 0
assert "failed to launch VMware GUI" in result.output