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 from __future__ import annotations
import os
import shutil import shutil
import subprocess
import time import time
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from pathlib import Path from pathlib import Path
@@ -483,4 +485,116 @@ def vm_new(
click.echo(handle.identifier) 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"] __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 result.exit_code == 0
assert "vmrun not available" in result.output 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