f6003b75ca
- Create services/docker/compose.py — compose file generation and commands - Create services/docker/container.py — container lifecycle and queries - Create services/docker/config_staging.py — config folder file writing - Create services/docker/tunnel.py — Cloudflare tunnel management - Create services/docker/__init__.py — barrel exports - Delete services/docker.py (replaced by package) - All imports in api/tool_instances.py remain functional Quality gates: Python syntax check (pass), imports verified Refs: repo-restructure Task 3.3
147 lines
4.3 KiB
Python
147 lines
4.3 KiB
Python
"""Cloudflare tunnel management for Docker instances."""
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
import signal
|
|
import subprocess
|
|
import time
|
|
from typing import Any
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def start_cloudflared_tunnel(
|
|
container_name: str, port: int, timeout: int = 30
|
|
) -> dict[str, str]:
|
|
"""Start a temporary Cloudflare tunnel for a container.
|
|
|
|
Uses 'cloudflared tunnel --url' to create a temporary tunnel
|
|
with a random trycloudflare.com URL.
|
|
|
|
Args:
|
|
container_name: Name of the Docker container to tunnel to
|
|
port: Port number the container listens on
|
|
timeout: Maximum seconds to wait for tunnel URL
|
|
|
|
Returns:
|
|
Dict with 'url' (the public tunnel URL) and 'pid' (process ID)
|
|
"""
|
|
import select as sel
|
|
|
|
# First verify the container is accessible
|
|
logger.info("Checking connectivity to %s:%d...", container_name, port)
|
|
for attempt in range(10):
|
|
check = subprocess.run(
|
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
|
f"http://{container_name}:{port}"],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=5,
|
|
)
|
|
logger.info("Connectivity check %d: http_code=%s", attempt + 1, check.stdout.strip())
|
|
if check.returncode == 0:
|
|
break
|
|
time.sleep(1)
|
|
else:
|
|
logger.warning("Container %s:%d not responding to curl checks", container_name, port)
|
|
|
|
# Run cloudflared in background, capture output
|
|
logger.info("Starting cloudflared tunnel to http://%s:%d", container_name, port)
|
|
proc = subprocess.Popen(
|
|
["cloudflared", "tunnel", "--url", f"http://{container_name}:{port}"],
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.STDOUT,
|
|
text=True,
|
|
)
|
|
|
|
# Wait for the URL to appear in output
|
|
url_pattern = re.compile(r"https://[a-z0-9-]+\.trycloudflare\.com")
|
|
start_time = time.time()
|
|
url = None
|
|
|
|
while time.time() - start_time < timeout:
|
|
# Read available output
|
|
readable, _, _ = sel.select([proc.stdout], [], [], 1.0)
|
|
if readable:
|
|
line = proc.stdout.readline()
|
|
if line:
|
|
match = url_pattern.search(line)
|
|
if match:
|
|
url = match.group(0)
|
|
break
|
|
|
|
if not url:
|
|
proc.terminate()
|
|
proc.wait(timeout=5)
|
|
raise RuntimeError(
|
|
f"Failed to get tunnel URL within {timeout}s. "
|
|
f"cloudflared output may contain errors."
|
|
)
|
|
|
|
return {"url": url, "pid": str(proc.pid)}
|
|
|
|
|
|
def stop_cloudflared_tunnel(pid: str) -> None:
|
|
"""Stop a cloudflared tunnel process.
|
|
|
|
Args:
|
|
pid: Process ID of the cloudflared tunnel
|
|
"""
|
|
try:
|
|
os.kill(int(pid), signal.SIGTERM)
|
|
except ProcessLookupError:
|
|
pass # Already stopped
|
|
|
|
|
|
def recreate_tunnel(
|
|
container_name: str, port: int, old_pid: str | None = None
|
|
) -> dict[str, str]:
|
|
"""Recreate a temporary Cloudflare tunnel.
|
|
|
|
Stops the old tunnel (if pid provided) and starts a new one.
|
|
|
|
Args:
|
|
container_name: Name of the Docker container to tunnel to
|
|
port: Port number the container listens on
|
|
old_pid: Optional PID of the old tunnel process to stop
|
|
|
|
Returns:
|
|
Dict with 'url' and 'pid' for the new tunnel
|
|
"""
|
|
if old_pid:
|
|
stop_cloudflared_tunnel(old_pid)
|
|
|
|
return start_cloudflared_tunnel(container_name, port)
|
|
|
|
|
|
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|
"""Check if a tunnel URL is healthy.
|
|
|
|
Args:
|
|
url: The tunnel URL to check
|
|
timeout: Request timeout in seconds
|
|
|
|
Returns:
|
|
Dict with 'healthy' (bool) and 'status_code' (int or None)
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
|
|
"--max-time", str(timeout), url],
|
|
capture_output=True,
|
|
text=True,
|
|
timeout=timeout + 5,
|
|
)
|
|
status_code = int(result.stdout.strip())
|
|
return {
|
|
"healthy": 200 <= status_code < 400,
|
|
"status_code": status_code,
|
|
}
|
|
except (ValueError, subprocess.TimeoutExpired, Exception) as e:
|
|
return {
|
|
"healthy": False,
|
|
"status_code": None,
|
|
"error": str(e),
|
|
}
|