From 0e963f2194f84b79bd998db7461d238a3d6a2649 Mon Sep 17 00:00:00 2001 From: Simone Date: Mon, 8 Jun 2026 00:15:22 +0200 Subject: [PATCH] feat(vmgui): GUI launcher command, VMX optional MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add top-level `vmgui` to open the VMware Workstation GUI (renamed from the earlier `vm open`). --vmx is optional: with it the GUI opens that VM, without it the GUI starts bare. Power-on/fullscreen flags ignored (with a notice) when no VMX is given. Needs an X display — sudo -u ci-runner strips $DISPLAY, so --display / xhost; see runbook §4.7. 9 tests; suite green ≥90%. Co-Authored-By: Claude Opus 4.8 --- docs/RUNBOOK-PhaseC.md | 11 ++++--- src/ci_orchestrator/__main__.py | 3 +- src/ci_orchestrator/commands/vm.py | 52 ++++++++++++++++++------------ tests/python/test_commands_vm.py | 36 ++++++++++++++++----- 4 files changed, 68 insertions(+), 34 deletions(-) diff --git a/docs/RUNBOOK-PhaseC.md b/docs/RUNBOOK-PhaseC.md index 4e0c10c..bdecf26 100644 --- a/docs/RUNBOOK-PhaseC.md +++ b/docs/RUNBOOK-PhaseC.md @@ -69,7 +69,7 @@ The script defines two helpers: | `ci artifacts collect ...` | copy artifacts out of a guest | §4 | | `ci vm remove --vmx ...` | stop + delete one clone | §4 | | `ci vm cleanup` | sweep orphaned clones + stale locks | §4 / §6 | -| `ci vm open --vmx ...` | open a VMX in the VMware Workstation GUI | §4 | +| `ci vmgui --vmx ...` | open a VMX in the VMware Workstation GUI | §4 | | `ci creds set --user ...` | store guest credentials in keyring | §5 | | `ci template deploy-linux` | build the Linux template VM | §5 | | `ci template prepare-linux` | provision Linux template over SSH | §5 | @@ -221,9 +221,12 @@ so you can watch/poke a clone by hand. xhost +SI:localuser:ci-runner # then open the VMX as ci-runner, forcing the display: -ci vm open --vmx /var/lib/ci/build-vms/Clone_dbg-1_*/Clone_dbg-1_*.vmx --display :0 -ci vm open --vmx --display :0 --power-on # also power it on (-x) -ci vm open --vmx --display :0 --fullscreen # power on + fullscreen (-X) +ci vmgui --vmx /var/lib/ci/build-vms/Clone_dbg-1_*/Clone_dbg-1_*.vmx --display :0 +ci vmgui --vmx --display :0 --power-on # also power it on (-x) +ci vmgui --vmx --display :0 --fullscreen # power on + fullscreen (-X) + +# bare GUI, no VM loaded (just open Workstation): +ci vmgui --display :0 ``` The GUI needs an X display; `sudo -u ci-runner` strips `$DISPLAY`, so pass `--display :0` (or export `DISPLAY`). Other flags: `--new-window` (`-n`), diff --git a/src/ci_orchestrator/__main__.py b/src/ci_orchestrator/__main__.py index 3449c11..fcd22da 100644 --- a/src/ci_orchestrator/__main__.py +++ b/src/ci_orchestrator/__main__.py @@ -23,7 +23,7 @@ from ci_orchestrator.commands.retention import retention from ci_orchestrator.commands.smoke import smoke from ci_orchestrator.commands.template import template from ci_orchestrator.commands.validate import validate -from ci_orchestrator.commands.vm import vm +from ci_orchestrator.commands.vm import vm, vmgui # Re-export so legacy A1 tests that patched ``cli_module.`` keep working. from ci_orchestrator.commands.wait import ( # noqa: F401 (intentional re-exports) @@ -54,6 +54,7 @@ cli.add_command(bench) cli.add_command(smoke) cli.add_command(validate) cli.add_command(creds) +cli.add_command(vmgui) if __name__ == "__main__": # pragma: no cover diff --git a/src/ci_orchestrator/commands/vm.py b/src/ci_orchestrator/commands/vm.py index 15761cf..e952a66 100644 --- a/src/ci_orchestrator/commands/vm.py +++ b/src/ci_orchestrator/commands/vm.py @@ -506,13 +506,13 @@ def _launch_gui(argv: list[str], env: dict[str, str]) -> int: return proc.pid -@vm.command("open") +@click.command("vmgui") @click.option( "--vmx", "--vm-path", "vmx", - required=True, - help="Path to the VMX file to open in the VMware Workstation GUI.", + default=None, + help="Path to a VMX to open. Omit to launch the GUI with no VM loaded.", ) @click.option( "--vmware-path", @@ -547,25 +547,28 @@ def _launch_gui(argv: list[str], env: dict[str, str]) -> int: default=None, help="X display to use (default: inherit $DISPLAY). e.g. ':0'.", ) -def vm_open( - vmx: str, +def vmgui( + vmx: str | None, 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). + """Open the VMware Workstation GUI, optionally on a VMX (run as ci-runner). 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 + of the orchestrator uses. With ``--vmx`` the GUI opens that VM; without it + the GUI starts bare (no VM loaded). 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}") + vmx_path: Path | None = None + if vmx is not None: + 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 @@ -574,7 +577,7 @@ def vm_open( env["DISPLAY"] = display if not env.get("DISPLAY"): click.echo( - "[vm open] WARNING: no X display set ($DISPLAY empty and --display " + "[vmgui] 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, @@ -583,18 +586,25 @@ def vm_open( argv = [gui] if new_window: argv.append("-n") - if fullscreen: - argv.append("-X") - elif power_on: - argv.append("-x") - argv.append(str(vmx_path)) + if vmx_path is not None: + # Power-on flags only make sense with a VM to open. + if fullscreen: + argv.append("-X") + elif power_on: + argv.append("-x") + argv.append(str(vmx_path)) + elif power_on or fullscreen: + click.echo( + "[vmgui] note: --power-on/--fullscreen ignored without --vmx.", + err=True, + ) - click.echo(f"[vm open] launching VMware GUI: {' '.join(argv)}") + click.echo(f"[vmgui] 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', '')}") + click.echo(f"[vmgui] VMware GUI started (pid {pid}); display={env.get('DISPLAY', '')}") -__all__ = ["cleanup_orphans", "vm"] +__all__ = ["cleanup_orphans", "vm", "vmgui"] diff --git a/tests/python/test_commands_vm.py b/tests/python/test_commands_vm.py index 18fb5e7..c820ea9 100644 --- a/tests/python/test_commands_vm.py +++ b/tests/python/test_commands_vm.py @@ -499,7 +499,7 @@ def test_vm_cleanup_backend_unavailable(monkeypatch: pytest.MonkeyPatch, tmp_pat assert "vmrun not available" in result.output -# ── vm open ─────────────────────────────────────────────────────────────────── +# ── vmgui ─────────────────────────────────────────────────────────────────── def _capture_launch(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: @@ -516,7 +516,7 @@ def _capture_launch(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: def test_vm_open_missing_vmx_errors(tmp_path: Path) -> None: - result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(tmp_path / "nope.vmx")]) + result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(tmp_path / "nope.vmx")]) assert result.exit_code != 0 assert "VMX file not found" in result.output @@ -526,7 +526,7 @@ def test_vm_open_happy_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> vmx.write_text("config") cap = _capture_launch(monkeypatch) monkeypatch.setenv("DISPLAY", ":0") - result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(vmx)]) + result = CliRunner().invoke(cli, ["vmgui", "--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"] @@ -538,7 +538,7 @@ def test_vm_open_power_on_adds_x(monkeypatch: pytest.MonkeyPatch, tmp_path: Path vmx.write_text("config") cap = _capture_launch(monkeypatch) monkeypatch.setenv("DISPLAY", ":0") - result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(vmx), "--power-on"]) + result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(vmx), "--power-on"]) assert result.exit_code == 0, result.output assert "-x" in cap["argv"] @@ -549,7 +549,7 @@ def test_vm_open_fullscreen_adds_capital_x(monkeypatch: pytest.MonkeyPatch, tmp_ cap = _capture_launch(monkeypatch) monkeypatch.setenv("DISPLAY", ":0") result = CliRunner().invoke( - cli, ["vm", "open", "--vmx", str(vmx), "--fullscreen", "--new-window"] + cli, ["vmgui", "--vmx", str(vmx), "--fullscreen", "--new-window"] ) assert result.exit_code == 0, result.output assert "-X" in cap["argv"] and "-n" in cap["argv"] @@ -560,7 +560,7 @@ def test_vm_open_display_option_overrides_env(monkeypatch: pytest.MonkeyPatch, t vmx.write_text("config") cap = _capture_launch(monkeypatch) monkeypatch.delenv("DISPLAY", raising=False) - result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(vmx), "--display", ":1"]) + result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(vmx), "--display", ":1"]) assert result.exit_code == 0, result.output assert cap["env"]["DISPLAY"] == ":1" assert "display=:1" in result.output @@ -571,7 +571,7 @@ def test_vm_open_warns_without_display(monkeypatch: pytest.MonkeyPatch, tmp_path vmx.write_text("config") _capture_launch(monkeypatch) monkeypatch.delenv("DISPLAY", raising=False) - result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(vmx)]) + result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(vmx)]) assert result.exit_code == 0, result.output assert "no X display" in result.output @@ -585,6 +585,26 @@ def test_vm_open_launch_failure_is_reported(monkeypatch: pytest.MonkeyPatch, tmp monkeypatch.setattr(vm_module, "_launch_gui", _boom) monkeypatch.setenv("DISPLAY", ":0") - result = CliRunner().invoke(cli, ["vm", "open", "--vmx", str(vmx)]) + result = CliRunner().invoke(cli, ["vmgui", "--vmx", str(vmx)]) assert result.exit_code != 0 assert "failed to launch VMware GUI" in result.output + + +def test_vm_open_without_vmx_launches_bare_gui(monkeypatch: pytest.MonkeyPatch) -> None: + cap = _capture_launch(monkeypatch) + monkeypatch.setenv("DISPLAY", ":0") + result = CliRunner().invoke(cli, ["vmgui"]) + assert result.exit_code == 0, result.output + # Only the binary (no vmx path, no power-on flags). + assert cap["argv"][1:] == [] or cap["argv"][1:] == ["-n"] + assert "-x" not in cap["argv"] and "-X" not in cap["argv"] + assert "pid 4242" in result.output + + +def test_vm_open_power_on_ignored_without_vmx(monkeypatch: pytest.MonkeyPatch) -> None: + cap = _capture_launch(monkeypatch) + monkeypatch.setenv("DISPLAY", ":0") + result = CliRunner().invoke(cli, ["vmgui", "--power-on"]) + assert result.exit_code == 0, result.output + assert "-x" not in cap["argv"] + assert "ignored without --vmx" in result.output