fix(cloudflared): add app binding diagnostics and improve readiness check
- Add _check_app_binding() to detect if app is bound to 127.0.0.1 instead of 0.0.0.0 (common cause of tunnel 'app error 0') - Improve curl readiness check: wait up to 30s, check HTTP status codes (accept 2xx, 3xx, 401, 403 as 'ready') - Log curl stderr for connection debugging - Log binding diagnosis when external connectivity fails Quality gates: pytest 42 passed
This commit is contained in:
@@ -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}")
|
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(
|
def start_cloudflared_tunnel(
|
||||||
container_name: str, port: int, timeout: int = 30
|
container_name: str, port: int, timeout: int = 30
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
@@ -405,9 +486,11 @@ def start_cloudflared_tunnel(
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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)
|
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(
|
check = subprocess.run(
|
||||||
[
|
[
|
||||||
"curl",
|
"curl",
|
||||||
@@ -416,21 +499,59 @@ def start_cloudflared_tunnel(
|
|||||||
"/dev/null",
|
"/dev/null",
|
||||||
"-w",
|
"-w",
|
||||||
"%{http_code}",
|
"%{http_code}",
|
||||||
|
"--max-time",
|
||||||
|
"3",
|
||||||
f"http://{container_name}:{port}",
|
f"http://{container_name}:{port}",
|
||||||
],
|
],
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
|
status_str = check.stdout.strip()
|
||||||
logger.info(
|
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:
|
try:
|
||||||
break
|
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)
|
time.sleep(1)
|
||||||
else:
|
|
||||||
|
if not accessible:
|
||||||
logger.warning(
|
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
|
# Run cloudflared in background, capture output
|
||||||
|
|||||||
Reference in New Issue
Block a user