diff --git a/src/ci_orchestrator/commands/vm.py b/src/ci_orchestrator/commands/vm.py index e1911dc..15761cf 100644 --- a/src/ci_orchestrator/commands/vm.py +++ b/src/ci_orchestrator/commands/vm.py @@ -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"] diff --git a/tests/python/test_commands_vm.py b/tests/python/test_commands_vm.py index 172863d..18fb5e7 100644 --- a/tests/python/test_commands_vm.py +++ b/tests/python/test_commands_vm.py @@ -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