6104f592eb
Split monolithic docker.py into focused modules: - docker/compose.py — compose generation, execute_compose_command, volume sorting - docker/container.py — container status, IP, logs, network, port finding - docker/config_staging.py — instance dir, env file, config file staging - docker/tunnel.py — cloudflared tunnel lifecycle (moved from services/tunnel.py) - docker/__init__.py — re-exports all public symbols for backward compatibility - services/tunnel.py — thin re-export wrapper for backward compatibility Also includes schema extraction files created in prior work: - schemas/config/config_profile.py - schemas/project/*.py - schemas/system/health.py - schemas/tool/*.py - schemas/user/*.py All existing imports like 'from src.services.docker import X' and 'from src.services.tunnel import X' continue to work unchanged. Quality gates: py_compile passed, ruff passed, import test passed.
282 lines
8.4 KiB
Python
282 lines
8.4 KiB
Python
"""Cloudflare tunnel management using cloudflared Docker containers.
|
|
|
|
Each tunnel runs as a Docker container on the same 'backend' network as the API.
|
|
cloudflared connects to the tool container by its Docker Compose service name
|
|
(e.g. http://code-server-headquarter-34837cd3:8443).
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
import subprocess
|
|
from typing import Any
|
|
|
|
from src.services.docker.container import get_backend_network_name
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TUNNEL_IMAGE = "cloudflare/cloudflared:latest"
|
|
|
|
|
|
def _tunnel_container_name(instance_name: str) -> str:
|
|
return f"tunnel-{instance_name.lower()}"
|
|
|
|
|
|
def _ensure_image() -> None:
|
|
"""Pull cloudflared image if not already present."""
|
|
result = subprocess.run(
|
|
["docker", "images", "-q", TUNNEL_IMAGE],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if not result.stdout.strip():
|
|
logger.info("Pulling %s ...", TUNNEL_IMAGE)
|
|
pull = subprocess.run(
|
|
["docker", "pull", TUNNEL_IMAGE],
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if pull.returncode != 0:
|
|
logger.warning("Failed to pull %s: %s", TUNNEL_IMAGE, pull.stderr)
|
|
|
|
|
|
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_tunnel_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_tunnel_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,
|
|
container_port: int,
|
|
timeout: int = 30,
|
|
target_url: str | None = None,
|
|
) -> dict[str, str]:
|
|
"""Start a temporary Cloudflare tunnel for an instance.
|
|
|
|
Args:
|
|
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'.
|
|
"""
|
|
_ensure_image()
|
|
|
|
tunnel_name = _tunnel_container_name(instance_name)
|
|
_cleanup_stale_tunnel(tunnel_name)
|
|
|
|
# Target the tool container by name on the backend network
|
|
if target_url is None:
|
|
target_url = f"http://{instance_name.lower()}:{container_port}"
|
|
|
|
cmd = [
|
|
"docker",
|
|
"run",
|
|
"-d",
|
|
"--network",
|
|
get_backend_network_name(),
|
|
"--name",
|
|
tunnel_name,
|
|
TUNNEL_IMAGE,
|
|
"tunnel",
|
|
"--no-autoupdate",
|
|
"--url",
|
|
target_url,
|
|
]
|
|
|
|
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
|
|
# Exclude api.trycloudflare.com which is the Cloudflare API endpoint,
|
|
# not a tunnel URL. Real tunnel URLs have random subdomains (10+ chars).
|
|
url_pattern = re.compile(r"https://(?!api\.)[a-z0-9-]{10,}\.trycloudflare\.com")
|
|
start_time = __import__("time").time()
|
|
url: str | None = None
|
|
combined_logs = ""
|
|
|
|
while __import__("time").time() - start_time < timeout:
|
|
stdout, stderr = _get_tunnel_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_tunnel_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)
|
|
|
|
if not url:
|
|
stdout, stderr = _get_tunnel_logs(tunnel_name)
|
|
combined_logs = stdout + "\n" + stderr
|
|
exit_code = _get_tunnel_exit_code(tunnel_name)
|
|
|
|
_cleanup_stale_tunnel(tunnel_name)
|
|
raise RuntimeError(
|
|
f"Tunnel {tunnel_name} did not produce a URL within {timeout}s. "
|
|
f"Exit code: {exit_code}. Logs:\n{combined_logs[-3000:]}"
|
|
)
|
|
|
|
# Wait a moment for Cloudflare DNS edge to propagate the new tunnel subdomain
|
|
__import__("time").sleep(2)
|
|
|
|
logger.info(
|
|
"Tunnel %s started for %s → %s (%s)",
|
|
tunnel_name,
|
|
instance_name,
|
|
target_url,
|
|
url,
|
|
)
|
|
return {"url": url, "container_name": tunnel_name}
|
|
|
|
|
|
def stop_tunnel(instance_name: str) -> None:
|
|
"""Stop and remove the tunnel container for an instance."""
|
|
tunnel_name = _tunnel_container_name(instance_name)
|
|
_cleanup_stale_tunnel(tunnel_name)
|
|
logger.debug("Stopped and removed tunnel container %s", tunnel_name)
|
|
|
|
|
|
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, target_url=target_url)
|
|
|
|
|
|
def check_tunnel_health(url: str, timeout: int = 10) -> dict[str, Any]:
|
|
"""Check if a tunnel URL is healthy.
|
|
|
|
Returns:
|
|
Dict with 'tunnel_status', 'status_code', 'healthy', 'error'.
|
|
"""
|
|
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())
|
|
|
|
if 200 <= status_code < 400:
|
|
return {
|
|
"tunnel_status": "healthy",
|
|
"status_code": status_code,
|
|
"healthy": True,
|
|
"error": None,
|
|
}
|
|
if status_code in (502, 503, 504):
|
|
return {
|
|
"tunnel_status": "error_response",
|
|
"status_code": status_code,
|
|
"healthy": False,
|
|
"error": f"Application returned HTTP {status_code}",
|
|
}
|
|
return {
|
|
"tunnel_status": "error_response",
|
|
"status_code": status_code,
|
|
"healthy": False,
|
|
"error": f"HTTP {status_code}",
|
|
}
|
|
except subprocess.TimeoutExpired:
|
|
return {
|
|
"tunnel_status": "unreachable",
|
|
"status_code": None,
|
|
"healthy": False,
|
|
"error": "Tunnel request timed out",
|
|
}
|
|
except (ValueError, Exception) as exc:
|
|
error_str = str(exc).lower()
|
|
if any(
|
|
err in error_str
|
|
for err in [
|
|
"connection refused",
|
|
"econnrefused",
|
|
"could not resolve",
|
|
"nodename",
|
|
]
|
|
):
|
|
return {
|
|
"tunnel_status": "unreachable",
|
|
"status_code": None,
|
|
"healthy": False,
|
|
"error": f"Tunnel unreachable: {exc}",
|
|
}
|
|
return {
|
|
"tunnel_status": "unreachable",
|
|
"status_code": None,
|
|
"healthy": False,
|
|
"error": str(exc),
|
|
}
|