diff --git a/apps/api/src/services/docker.py b/apps/api/src/services/docker.py index 43d152a..f4b67fd 100644 --- a/apps/api/src/services/docker.py +++ b/apps/api/src/services/docker.py @@ -380,6 +380,3 @@ def find_free_port(start: int = 10000, end: int = 20000) -> int: return port raise RuntimeError(f"No free port found in range {start}-{end}") - - - diff --git a/apps/api/src/services/tunnel.py b/apps/api/src/services/tunnel.py index ff86a5b..40d6d7f 100644 --- a/apps/api/src/services/tunnel.py +++ b/apps/api/src/services/tunnel.py @@ -39,7 +39,48 @@ def _ensure_image() -> None: logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr) -def start_tunnel(instance_name: str, published_port: int, timeout: int = 30) -> dict[str, str]: +def _cleanup_stale_tunnel(tunnel_name: str) -> None: + """Remove any existing tunnel container with this name.""" + subprocess.run( + ["docker", "stop", "-t", "3", tunnel_name], + capture_output=True, + text=True, + ) + subprocess.run( + ["docker", "rm", "-f", tunnel_name], + capture_output=True, + text=True, + ) + + +def _get_container_logs(tunnel_name: str) -> tuple[str, str]: + """Get stdout and stderr logs from a container.""" + result = subprocess.run( + ["docker", "logs", tunnel_name], + capture_output=True, + text=True, + ) + return result.stdout, result.stderr + + +def _get_container_exit_code(tunnel_name: str) -> int | None: + """Get exit code of a container if it has exited.""" + result = subprocess.run( + ["docker", "inspect", "-f", "{{.State.ExitCode}}", tunnel_name], + capture_output=True, + text=True, + ) + if result.returncode == 0: + try: + return int(result.stdout.strip()) + except ValueError: + pass + return None + + +def start_tunnel( + instance_name: str, published_port: int, timeout: int = 30 +) -> dict[str, str]: """Start a temporary Cloudflare tunnel for an instance. Args: @@ -53,60 +94,69 @@ def start_tunnel(instance_name: str, published_port: int, timeout: int = 30) -> _ensure_image() tunnel_name = _tunnel_container_name(instance_name) - - # Clean up any stale tunnel container with this name - subprocess.run( - ["docker", "stop", "-t", "3", tunnel_name], - capture_output=True, - text=True, - ) - subprocess.run( - ["docker", "rm", tunnel_name], - capture_output=True, - text=True, - ) + _cleanup_stale_tunnel(tunnel_name) cmd = [ - "docker", "run", "-d", "--rm", - "--network", "host", - "--name", tunnel_name, + "docker", + "run", + "-d", + "--network", + "host", + "--name", + tunnel_name, TUNNEL_IMAGE, - "tunnel", "--url", f"http://localhost:{published_port}", + "tunnel", + "--no-autoupdate", + "--url", + f"http://localhost:{published_port}", ] + logger.debug("Running: %s", " ".join(cmd)) proc = subprocess.run(cmd, capture_output=True, text=True) if proc.returncode != 0: raise RuntimeError( f"Failed to start tunnel container {tunnel_name}: {proc.stderr}" ) + container_id = proc.stdout.strip() + logger.debug("Tunnel container started: %s", container_id) + # Wait for URL to appear in logs url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com") start_time = __import__("time").time() url: str | None = None - logs_result = subprocess.run( - ["docker", "logs", tunnel_name], - capture_output=True, - text=True, - ) + combined_logs = "" while __import__("time").time() - start_time < timeout: - match = url_pattern.search(logs_result.stdout) + stdout, stderr = _get_container_logs(tunnel_name) + combined_logs = stdout + "\n" + stderr + + match = url_pattern.search(combined_logs) if match: url = match.group(0) break + + # Check if container exited early + exit_code = _get_container_exit_code(tunnel_name) + if exit_code is not None and exit_code != 0: + _cleanup_stale_tunnel(tunnel_name) + raise RuntimeError( + f"Tunnel container {tunnel_name} exited with code {exit_code}. " + f"Logs:\n{combined_logs[-3000:]}" + ) + __import__("time").sleep(0.5) - logs_result = subprocess.run( - ["docker", "logs", tunnel_name], - capture_output=True, - text=True, - ) if not url: - subprocess.run(["docker", "stop", "-t", "3", tunnel_name], capture_output=True) + # Capture final state for debugging + stdout, stderr = _get_container_logs(tunnel_name) + combined_logs = stdout + "\n" + stderr + exit_code = _get_container_exit_code(tunnel_name) + + _cleanup_stale_tunnel(tunnel_name) raise RuntimeError( f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. " - f"cloudflared logs:\n{logs_result.stdout[-2000:]}" + f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}" ) logger.info( @@ -120,14 +170,10 @@ def start_tunnel(instance_name: str, published_port: int, timeout: int = 30) -> def stop_tunnel(instance_name: str) -> None: - """Stop the tunnel container for an instance.""" + """Stop and remove the tunnel container for an instance.""" tunnel_name = _tunnel_container_name(instance_name) - subprocess.run( - ["docker", "stop", "-t", "5", tunnel_name], - capture_output=True, - text=True, - ) - logger.debug("Stopped tunnel container %s", tunnel_name) + _cleanup_stale_tunnel(tunnel_name) + logger.debug("Stopped and removed tunnel container %s", tunnel_name) def recreate_tunnel(instance_name: str, published_port: int) -> dict[str, str]: