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"