From 1c245ee19c5081eec1ac565b4870ce3667f37cda Mon Sep 17 00:00:00 2001 From: octo-patch Date: Fri, 17 Apr 2026 11:48:57 +0800 Subject: [PATCH] fix: resolve host IP from Docker bridge network for Rancher Desktop compatibility (fixes #561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--add-host host.docker.internal:host-gateway` works on Docker Desktop and standard Docker Engine but not on Rancher Desktop, where the `host-gateway` keyword is not recognised. Introduce `_get_host_ip()` which reads the gateway of the Docker bridge network (e.g. 172.17.0.1) — a portable, concrete IP that works across Docker Desktop, Docker Engine, and Rancher Desktop. If the network inspect fails for any reason the function falls back to `host-gateway`, preserving the existing behaviour for standard installations. Also add unit tests covering the success path, subprocess failure, and empty-output fallback. --- cli/planoai/docker_cli.py | 33 +++++++++++++++++++++++++- cli/test/test_docker_cli.py | 46 +++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 cli/test/test_docker_cli.py diff --git a/cli/planoai/docker_cli.py b/cli/planoai/docker_cli.py index 0e66c781..3c0a0bf5 100644 --- a/cli/planoai/docker_cli.py +++ b/cli/planoai/docker_cli.py @@ -40,6 +40,35 @@ def docker_remove_container(container: str) -> str: return result.returncode +def _get_host_ip() -> str: + """Resolve the host machine's IP address reachable from inside Docker containers. + + Docker Desktop (Mac/Windows) supports the ``host-gateway`` special keyword, but + Rancher Desktop and some Linux configurations do not. As a more portable + alternative we read the gateway of the default Docker bridge network, which is + the same IP that ``host-gateway`` would resolve to on standard installations. + + Falls back to ``host-gateway`` so existing Docker Desktop setups are unaffected. + """ + result = subprocess.run( + [ + "docker", + "network", + "inspect", + "bridge", + "--format", + "{{range .IPAM.Config}}{{.Gateway}}{{end}}", + ], + capture_output=True, + text=True, + check=False, + ) + ip = result.stdout.strip() + if result.returncode == 0 and ip: + return ip + return "host-gateway" + + def _prepare_docker_config(plano_config_file: str) -> str: """Copy config to a temp file, replacing localhost with host.docker.internal. @@ -88,6 +117,8 @@ def docker_start_plano_detached( item for volume in volume_mappings for item in ("-v", volume) ] + host_ip = _get_host_ip() + options = [ "docker", "run", @@ -98,7 +129,7 @@ def docker_start_plano_detached( *volume_mappings_args, *env_args, "--add-host", - "host.docker.internal:host-gateway", + f"host.docker.internal:{host_ip}", PLANO_DOCKER_IMAGE, ] diff --git a/cli/test/test_docker_cli.py b/cli/test/test_docker_cli.py new file mode 100644 index 00000000..f2b2945a --- /dev/null +++ b/cli/test/test_docker_cli.py @@ -0,0 +1,46 @@ +import subprocess +from unittest import mock + +from planoai.docker_cli import _get_host_ip + + +def test_get_host_ip_returns_bridge_gateway(): + """When docker network inspect succeeds, the bridge gateway IP is returned.""" + fake_result = mock.Mock() + fake_result.returncode = 0 + fake_result.stdout = "172.17.0.1\n" + + with mock.patch("subprocess.run", return_value=fake_result) as mock_run: + ip = _get_host_ip() + + assert ip == "172.17.0.1" + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert "docker" in args + assert "network" in args + assert "inspect" in args + assert "bridge" in args + + +def test_get_host_ip_falls_back_on_failure(): + """When docker network inspect fails, 'host-gateway' is returned as a fallback.""" + fake_result = mock.Mock() + fake_result.returncode = 1 + fake_result.stdout = "" + + with mock.patch("subprocess.run", return_value=fake_result): + ip = _get_host_ip() + + assert ip == "host-gateway" + + +def test_get_host_ip_falls_back_on_empty_output(): + """When docker network inspect returns empty output, 'host-gateway' is returned.""" + fake_result = mock.Mock() + fake_result.returncode = 0 + fake_result.stdout = " " + + with mock.patch("subprocess.run", return_value=fake_result): + ip = _get_host_ip() + + assert ip == "host-gateway"