feat(vmgui): GUI launcher command, VMX optional
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 <noreply@anthropic.com>
This commit is contained in:
@@ -69,7 +69,7 @@ The script defines two helpers:
|
|||||||
| `ci artifacts collect ...` | copy artifacts out of a guest | §4 |
|
| `ci artifacts collect ...` | copy artifacts out of a guest | §4 |
|
||||||
| `ci vm remove --vmx ...` | stop + delete one clone | §4 |
|
| `ci vm remove --vmx ...` | stop + delete one clone | §4 |
|
||||||
| `ci vm cleanup` | sweep orphaned clones + stale locks | §4 / §6 |
|
| `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 creds set --user ...` | store guest credentials in keyring | §5 |
|
||||||
| `ci template deploy-linux` | build the Linux template VM | §5 |
|
| `ci template deploy-linux` | build the Linux template VM | §5 |
|
||||||
| `ci template prepare-linux` | provision Linux template over SSH | §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
|
xhost +SI:localuser:ci-runner
|
||||||
|
|
||||||
# then open the VMX as ci-runner, forcing the display:
|
# 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 vmgui --vmx /var/lib/ci/build-vms/Clone_dbg-1_*/Clone_dbg-1_*.vmx --display :0
|
||||||
ci vm open --vmx <path> --display :0 --power-on # also power it on (-x)
|
ci vmgui --vmx <path> --display :0 --power-on # also power it on (-x)
|
||||||
ci vm open --vmx <path> --display :0 --fullscreen # power on + fullscreen (-X)
|
ci vmgui --vmx <path> --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
|
The GUI needs an X display; `sudo -u ci-runner` strips `$DISPLAY`, so pass
|
||||||
`--display :0` (or export `DISPLAY`). Other flags: `--new-window` (`-n`),
|
`--display :0` (or export `DISPLAY`). Other flags: `--new-window` (`-n`),
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ from ci_orchestrator.commands.retention import retention
|
|||||||
from ci_orchestrator.commands.smoke import smoke
|
from ci_orchestrator.commands.smoke import smoke
|
||||||
from ci_orchestrator.commands.template import template
|
from ci_orchestrator.commands.template import template
|
||||||
from ci_orchestrator.commands.validate import validate
|
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.<name>`` keep working.
|
# Re-export so legacy A1 tests that patched ``cli_module.<name>`` keep working.
|
||||||
from ci_orchestrator.commands.wait import ( # noqa: F401 (intentional re-exports)
|
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(smoke)
|
||||||
cli.add_command(validate)
|
cli.add_command(validate)
|
||||||
cli.add_command(creds)
|
cli.add_command(creds)
|
||||||
|
cli.add_command(vmgui)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
|||||||
@@ -506,13 +506,13 @@ def _launch_gui(argv: list[str], env: dict[str, str]) -> int:
|
|||||||
return proc.pid
|
return proc.pid
|
||||||
|
|
||||||
|
|
||||||
@vm.command("open")
|
@click.command("vmgui")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--vmx",
|
"--vmx",
|
||||||
"--vm-path",
|
"--vm-path",
|
||||||
"vmx",
|
"vmx",
|
||||||
required=True,
|
default=None,
|
||||||
help="Path to the VMX file to open in the VMware Workstation GUI.",
|
help="Path to a VMX to open. Omit to launch the GUI with no VM loaded.",
|
||||||
)
|
)
|
||||||
@click.option(
|
@click.option(
|
||||||
"--vmware-path",
|
"--vmware-path",
|
||||||
@@ -547,22 +547,25 @@ def _launch_gui(argv: list[str], env: dict[str, str]) -> int:
|
|||||||
default=None,
|
default=None,
|
||||||
help="X display to use (default: inherit $DISPLAY). e.g. ':0'.",
|
help="X display to use (default: inherit $DISPLAY). e.g. ':0'.",
|
||||||
)
|
)
|
||||||
def vm_open(
|
def vmgui(
|
||||||
vmx: str,
|
vmx: str | None,
|
||||||
vmware_path: str,
|
vmware_path: str,
|
||||||
power_on: bool,
|
power_on: bool,
|
||||||
fullscreen: bool,
|
fullscreen: bool,
|
||||||
new_window: bool,
|
new_window: bool,
|
||||||
display: str | None,
|
display: str | None,
|
||||||
) -> 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
|
Launches the interactive GUI — unlike the headless ``vmrun`` path the rest
|
||||||
of the orchestrator uses. The GUI needs an X display: when invoked through
|
of the orchestrator uses. With ``--vmx`` the GUI opens that VM; without it
|
||||||
``sudo -u ci-runner`` the display is usually stripped, so authorise the
|
the GUI starts bare (no VM loaded). The GUI needs an X display: when invoked
|
||||||
service user first, e.g. ``xhost +SI:localuser:ci-runner``, and pass
|
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``).
|
``--display :0`` (or export ``DISPLAY``).
|
||||||
"""
|
"""
|
||||||
|
vmx_path: Path | None = None
|
||||||
|
if vmx is not None:
|
||||||
vmx_path = Path(vmx)
|
vmx_path = Path(vmx)
|
||||||
if not vmx_path.is_file():
|
if not vmx_path.is_file():
|
||||||
raise click.ClickException(f"VMX file not found: {vmx}")
|
raise click.ClickException(f"VMX file not found: {vmx}")
|
||||||
@@ -574,7 +577,7 @@ def vm_open(
|
|||||||
env["DISPLAY"] = display
|
env["DISPLAY"] = display
|
||||||
if not env.get("DISPLAY"):
|
if not env.get("DISPLAY"):
|
||||||
click.echo(
|
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 "
|
"omitted). The GUI will fail to start. Pass --display :0 and run "
|
||||||
"`xhost +SI:localuser:ci-runner` from a desktop session first.",
|
"`xhost +SI:localuser:ci-runner` from a desktop session first.",
|
||||||
err=True,
|
err=True,
|
||||||
@@ -583,18 +586,25 @@ def vm_open(
|
|||||||
argv = [gui]
|
argv = [gui]
|
||||||
if new_window:
|
if new_window:
|
||||||
argv.append("-n")
|
argv.append("-n")
|
||||||
|
if vmx_path is not None:
|
||||||
|
# Power-on flags only make sense with a VM to open.
|
||||||
if fullscreen:
|
if fullscreen:
|
||||||
argv.append("-X")
|
argv.append("-X")
|
||||||
elif power_on:
|
elif power_on:
|
||||||
argv.append("-x")
|
argv.append("-x")
|
||||||
argv.append(str(vmx_path))
|
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:
|
try:
|
||||||
pid = _launch_gui(argv, env)
|
pid = _launch_gui(argv, env)
|
||||||
except (OSError, ValueError) as exc:
|
except (OSError, ValueError) as exc:
|
||||||
raise click.ClickException(f"failed to launch VMware GUI: {exc}") from 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"]
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ def test_vm_cleanup_backend_unavailable(monkeypatch: pytest.MonkeyPatch, tmp_pat
|
|||||||
assert "vmrun not available" in result.output
|
assert "vmrun not available" in result.output
|
||||||
|
|
||||||
|
|
||||||
# ── vm open ───────────────────────────────────────────────────────────────────
|
# ── vmgui ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
def _capture_launch(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]:
|
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:
|
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 result.exit_code != 0
|
||||||
assert "VMX file not found" in result.output
|
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")
|
vmx.write_text("config")
|
||||||
cap = _capture_launch(monkeypatch)
|
cap = _capture_launch(monkeypatch)
|
||||||
monkeypatch.setenv("DISPLAY", ":0")
|
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 result.exit_code == 0, result.output
|
||||||
assert cap["argv"][-1] == str(vmx)
|
assert cap["argv"][-1] == str(vmx)
|
||||||
assert "-x" not in cap["argv"] and "-X" not in cap["argv"]
|
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")
|
vmx.write_text("config")
|
||||||
cap = _capture_launch(monkeypatch)
|
cap = _capture_launch(monkeypatch)
|
||||||
monkeypatch.setenv("DISPLAY", ":0")
|
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 result.exit_code == 0, result.output
|
||||||
assert "-x" in cap["argv"]
|
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)
|
cap = _capture_launch(monkeypatch)
|
||||||
monkeypatch.setenv("DISPLAY", ":0")
|
monkeypatch.setenv("DISPLAY", ":0")
|
||||||
result = CliRunner().invoke(
|
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 result.exit_code == 0, result.output
|
||||||
assert "-X" in cap["argv"] and "-n" in cap["argv"]
|
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")
|
vmx.write_text("config")
|
||||||
cap = _capture_launch(monkeypatch)
|
cap = _capture_launch(monkeypatch)
|
||||||
monkeypatch.delenv("DISPLAY", raising=False)
|
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 result.exit_code == 0, result.output
|
||||||
assert cap["env"]["DISPLAY"] == ":1"
|
assert cap["env"]["DISPLAY"] == ":1"
|
||||||
assert "display=:1" in result.output
|
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")
|
vmx.write_text("config")
|
||||||
_capture_launch(monkeypatch)
|
_capture_launch(monkeypatch)
|
||||||
monkeypatch.delenv("DISPLAY", raising=False)
|
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 result.exit_code == 0, result.output
|
||||||
assert "no X display" in 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.setattr(vm_module, "_launch_gui", _boom)
|
||||||
monkeypatch.setenv("DISPLAY", ":0")
|
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 result.exit_code != 0
|
||||||
assert "failed to launch VMware GUI" in result.output
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user