fix: recreate tunnel uses container IP directly for reliable connectivity

Old instances may have auto-generated Docker Compose container names
that don't match instance.name.lower(), causing DNS resolution failures
for the tunnel. Also, old instances may not be on the backend network.

- apps/api/src/services/docker.py: add get_container_ip_on_network() and
  is_container_on_network() helpers
- apps/api/src/services/tunnel.py: start_tunnel() and recreate_tunnel() now
  accept an optional target_url parameter to override the default name-based URL
- apps/api/src/api/tool_instances.py: recreate_tunnel_endpoint now:
  1. Looks up the tool container (by stored container_id or name)
  2. Ensures it's connected to the backend network
  3. Gets the container's IP on that network
  4. Passes the IP as the explicit tunnel target

This guarantees the tunnel can reach the tool container regardless of
naming or network state.

Quality gates: ruff clean
This commit is contained in:
2026-05-30 14:29:55 +02:00
parent 321b4e3d0e
commit 4cc433a1b8
3 changed files with 129 additions and 5 deletions
+60
View File
@@ -286,6 +286,66 @@ def connect_container_to_network(
return result.returncode == 0
def get_container_ip_on_network(
container_id: str, network_name: str | None = None
) -> str | None:
"""Get a container's IP address on a specific Docker network.
Args:
container_id: Docker container ID or name.
network_name: Network name. If None, auto-detects from the API container.
Returns:
IP address string, or None if the container is not on that network.
"""
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run(
[
"docker",
"inspect",
"-f",
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
container_id,
],
capture_output=True,
text=True,
)
if result.returncode == 0:
ip = result.stdout.strip()
if ip and ip != "<no value>":
return ip
return None
def is_container_on_network(
container_id: str, network_name: str | None = None
) -> bool:
"""Check whether a container is already attached to a Docker network.
Args:
container_id: Docker container ID or name.
network_name: Network name. If None, auto-detects from the API container.
Returns:
True if the container is on the network.
"""
if network_name is None:
network_name = get_backend_network_name()
result = subprocess.run(
[
"docker",
"inspect",
"-f",
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
container_id,
],
capture_output=True,
text=True,
)
return result.returncode == 0 and "<no value>" not in result.stdout
def get_container_status(container_id: str) -> dict[str, Any]:
"""Get the status of a Docker container.
+20 -5
View File
@@ -81,7 +81,10 @@ def _get_container_exit_code(tunnel_name: str) -> int | None:
def start_tunnel(
instance_name: str, container_port: int, timeout: int = 30
instance_name: str,
container_port: int,
timeout: int = 30,
target_url: str | None = None,
) -> dict[str, str]:
"""Start a temporary Cloudflare tunnel for an instance.
@@ -89,6 +92,8 @@ def start_tunnel(
instance_name: The tool instance name (used for tunnel naming).
container_port: The port the tool container listens on internally.
timeout: Seconds to wait for the tunnel URL.
target_url: Optional explicit URL to proxy to. If omitted, derives
http://{instance_name.lower()}:{container_port}.
Returns:
Dict with 'url' and 'container_name'.
@@ -99,7 +104,8 @@ def start_tunnel(
_cleanup_stale_tunnel(tunnel_name)
# Target the tool container by name on the backend network
target_url = f"http://{instance_name.lower()}:{container_port}"
if target_url is None:
target_url = f"http://{instance_name.lower()}:{container_port}"
cmd = [
"docker",
@@ -180,10 +186,19 @@ def stop_tunnel(instance_name: str) -> None:
logger.debug("Stopped and removed tunnel container %s", tunnel_name)
def recreate_tunnel(instance_name: str, container_port: int) -> dict[str, str]:
"""Recreate a tunnel for an instance."""
def recreate_tunnel(
instance_name: str, container_port: int, target_url: str | None = None
) -> dict[str, str]:
"""Recreate a tunnel for an instance.
Args:
instance_name: The tool instance name.
container_port: The port the tool container listens on internally.
target_url: Optional explicit origin URL. If omitted, derives
http://{instance_name.lower()}:{container_port}.
"""
stop_tunnel(instance_name)
return start_tunnel(instance_name, container_port)
return start_tunnel(instance_name, container_port, target_url=target_url)
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]: