diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index d179d92..fd05903 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -384,6 +384,87 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int: raise RuntimeError(f"No free port found in range {start}-{end}") +def _check_app_binding( + container_name: str, port: int +) -> dict[str, str | bool]: + """Diagnose whether the app is bound to 127.0.0.1 or 0.0.0.0. + + Checks from both inside the container (localhost) and outside + (via Docker network) to detect binding issues. + + Returns: + Dict with 'internal_ok', 'external_ok', 'internal_status', + 'external_status', and 'diagnosis'. + """ + import subprocess + + result: dict[str, Any] = { + "internal_ok": False, + "external_ok": False, + "internal_status": None, + "external_status": None, + "diagnosis": "unknown", + } + + # Check from inside the container (loopback) + internal = subprocess.run( + [ + "docker", + "exec", + container_name, + "sh", + "-c", + f"curl -s -o /dev/null -w '%{{http_code}}' http://localhost:{port}", + ], + capture_output=True, + text=True, + timeout=5, + ) + if internal.returncode == 0: + try: + result["internal_status"] = int(internal.stdout.strip()) + result["internal_ok"] = result["internal_status"] > 0 + except ValueError: + pass + + # Check from outside the container (Docker network) + external = subprocess.run( + [ + "curl", + "-s", + "-o", + "/dev/null", + "-w", + "%{http_code}", + f"http://{container_name}:{port}", + ], + capture_output=True, + text=True, + timeout=5, + ) + if external.returncode == 0: + try: + result["external_status"] = int(external.stdout.strip()) + result["external_ok"] = result["external_status"] > 0 + except ValueError: + pass + + # Diagnose binding issue + if result["internal_ok"] and not result["external_ok"]: + result["diagnosis"] = ( + f"App appears to be bound to 127.0.0.1:{port} inside the container. " + f"It must bind to 0.0.0.0:{port} to be accessible from the tunnel." + ) + elif result["internal_ok"] and result["external_ok"]: + result["diagnosis"] = "App is accessible on both interfaces." + elif not result["internal_ok"] and not result["external_ok"]: + result["diagnosis"] = f"App is not responding on port {port} at all." + else: + result["diagnosis"] = "Unexpected binding state." + + return result + + def start_cloudflared_tunnel( container_name: str, port: int, timeout: int = 30 ) -> dict[str, str]: @@ -405,9 +486,11 @@ def start_cloudflared_tunnel( logger = logging.getLogger(__name__) - # First verify the container is accessible + # First verify the container is accessible from the Docker network logger.info("Checking connectivity to %s:%d...", container_name, port) - for attempt in range(10): + accessible = False + last_status = None + for attempt in range(30): # 30 attempts × 1s = 30s max wait for app startup check = subprocess.run( [ "curl", @@ -416,21 +499,59 @@ def start_cloudflared_tunnel( "/dev/null", "-w", "%{http_code}", + "--max-time", + "3", f"http://{container_name}:{port}", ], capture_output=True, text=True, timeout=5, ) + status_str = check.stdout.strip() logger.info( - "Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip() + "Connectivity check %d/%d: http_code=%s (rc=%d)", + attempt + 1, + 30, + status_str, + check.returncode, ) - if check.returncode == 0: - break + try: + last_status = int(status_str) + # Accept 2xx, 3xx, 401, 403 as "app is listening" + if last_status in (401, 403) or 200 <= last_status < 400: + accessible = True + logger.info( + "App on %s:%d is ready (HTTP %d)", + container_name, + port, + last_status, + ) + break + except ValueError: + pass + + if check.returncode != 0: + logger.debug( + "curl failed: stderr=%s", check.stderr.strip() if check.stderr else "" + ) time.sleep(1) - else: + + if not accessible: logger.warning( - "Container %s:%d not responding to curl checks", container_name, port + "Container %s:%d not responding after 30s (last status: %s). " + "Running binding diagnostics...", + container_name, + port, + last_status, + ) + diagnosis = _check_app_binding(container_name, port) + logger.warning( + "Binding diagnosis: internal=%s (HTTP %s), external=%s (HTTP %s). %s", + diagnosis["internal_ok"], + diagnosis["internal_status"], + diagnosis["external_ok"], + diagnosis["external_status"], + diagnosis["diagnosis"], ) # Run cloudflared in background, capture output