refactor: split services/docker.py into docker/ package
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.
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
"""Docker services package for container and compose operations."""
|
||||
|
||||
from src.services.docker.compose import (
|
||||
execute_compose_command,
|
||||
render_compose_template,
|
||||
sort_volumes_by_specificity,
|
||||
write_compose_file,
|
||||
)
|
||||
from src.services.docker.config_staging import (
|
||||
ensure_instance_directory,
|
||||
write_config_files,
|
||||
write_env_file,
|
||||
)
|
||||
from src.services.docker.container import (
|
||||
connect_container_to_network,
|
||||
find_free_port,
|
||||
get_backend_network_name,
|
||||
get_container_id,
|
||||
get_container_ip_on_network,
|
||||
get_container_logs,
|
||||
get_container_name,
|
||||
get_container_status,
|
||||
is_container_on_network,
|
||||
wait_for_container_running,
|
||||
)
|
||||
from src.services.docker.tunnel import (
|
||||
check_tunnel_health,
|
||||
recreate_tunnel,
|
||||
start_tunnel,
|
||||
stop_tunnel,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"check_tunnel_health",
|
||||
"connect_container_to_network",
|
||||
"ensure_instance_directory",
|
||||
"execute_compose_command",
|
||||
"find_free_port",
|
||||
"get_backend_network_name",
|
||||
"get_container_id",
|
||||
"get_container_ip_on_network",
|
||||
"get_container_logs",
|
||||
"get_container_name",
|
||||
"get_container_status",
|
||||
"is_container_on_network",
|
||||
"recreate_tunnel",
|
||||
"render_compose_template",
|
||||
"sort_volumes_by_specificity",
|
||||
"start_tunnel",
|
||||
"stop_tunnel",
|
||||
"wait_for_container_running",
|
||||
"write_compose_file",
|
||||
"write_config_files",
|
||||
"write_env_file",
|
||||
]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Docker Compose file generation and manipulation."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
|
||||
"""Sort volume strings so parent paths come before child paths.
|
||||
|
||||
Docker Compose mounts volumes in array order. A later mount at a parent
|
||||
path hides earlier mounts at child paths. By sorting shallow paths first
|
||||
and deep paths last, deeper (more specific) mounts overlay correctly.
|
||||
|
||||
Volume format: source:target or source:target:type
|
||||
|
||||
Args:
|
||||
volumes: List of Docker volume mount strings.
|
||||
|
||||
Returns:
|
||||
Sorted list with parent paths before child paths.
|
||||
"""
|
||||
|
||||
def _target_depth(vol: str) -> int:
|
||||
parts = vol.split(":")
|
||||
if len(parts) < 2:
|
||||
return 0
|
||||
target = parts[1].rstrip("/")
|
||||
if not target or target == "/":
|
||||
return 0
|
||||
return target.count("/")
|
||||
|
||||
# Detect duplicate targets and warn
|
||||
targets = []
|
||||
for vol in volumes:
|
||||
parts = vol.split(":")
|
||||
targets.append(parts[1] if len(parts) > 1 else "")
|
||||
dupes = [t for t, c in Counter(targets).items() if c > 1]
|
||||
if dupes:
|
||||
logger.warning("Duplicate mount targets detected: %s", dupes)
|
||||
|
||||
# Stable sort: parent paths first, child paths last
|
||||
return sorted(volumes, key=_target_depth)
|
||||
|
||||
|
||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||
"""Render a Docker Compose template with variable substitution.
|
||||
|
||||
Args:
|
||||
template: The compose template string
|
||||
variables: Dictionary of variable names to values
|
||||
|
||||
Returns:
|
||||
Rendered compose file content
|
||||
"""
|
||||
result = template
|
||||
for key, value in variables.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
result = result.replace(placeholder, str(value))
|
||||
return result
|
||||
|
||||
|
||||
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||
"""Write the rendered compose file to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
content: Rendered compose content
|
||||
|
||||
Returns:
|
||||
Path to the compose file
|
||||
"""
|
||||
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||
compose_path.write_text(content)
|
||||
return str(compose_path)
|
||||
|
||||
|
||||
def execute_compose_command(
|
||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
"""Execute a docker compose command.
|
||||
|
||||
Args:
|
||||
compose_path: Path to docker-compose.yml
|
||||
action: The compose action (up, down, start, stop, restart)
|
||||
timeout: Command timeout in seconds
|
||||
env_file: Optional path to .env file for environment variables
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
instance_dir = Path(compose_path).parent
|
||||
|
||||
cmd = ["docker", "compose", "-f", compose_path]
|
||||
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
if action == "up":
|
||||
cmd.extend(["up", "-d", "--force-recreate"])
|
||||
elif action == "down":
|
||||
cmd.extend(["down", "-v"])
|
||||
elif action in ("start", "stop", "restart"):
|
||||
cmd.append(action)
|
||||
else:
|
||||
raise ValueError(f"Unknown compose action: {action}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(instance_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Staging configuration files into instance directories."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||
"""Create and return the instance directory path.
|
||||
|
||||
Args:
|
||||
instance_id: Unique instance identifier
|
||||
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||
|
||||
Returns:
|
||||
Absolute path to instance directory
|
||||
"""
|
||||
if base_path is None:
|
||||
from src.config import Settings
|
||||
|
||||
base_path = Settings().instance_base_path
|
||||
instance_dir = Path(base_path) / instance_id
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(instance_dir.absolute())
|
||||
|
||||
|
||||
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||
"""Write environment variables to a .env file.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
env_vars: Dictionary of env var names to values
|
||||
|
||||
Returns:
|
||||
Path to the env file
|
||||
"""
|
||||
env_path = Path(instance_dir) / ".env"
|
||||
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||
env_path.write_text("\n".join(lines) + "\n")
|
||||
return str(env_path)
|
||||
|
||||
|
||||
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
"""Write config files to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
files: Dictionary of file paths (relative to instance dir) to content
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
for file_path, content in files.items():
|
||||
# Ensure the path is within the instance directory (security)
|
||||
full_path = instance_path / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(instance_path.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
@@ -1,181 +1,13 @@
|
||||
"""Docker service for managing tool instances."""
|
||||
"""Docker container runtime queries and network management."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def sort_volumes_by_specificity(volumes: list[str]) -> list[str]:
|
||||
"""Sort volume strings so parent paths come before child paths.
|
||||
|
||||
Docker Compose mounts volumes in array order. A later mount at a parent
|
||||
path hides earlier mounts at child paths. By sorting shallow paths first
|
||||
and deep paths last, deeper (more specific) mounts overlay correctly.
|
||||
|
||||
Volume format: source:target or source:target:type
|
||||
|
||||
Args:
|
||||
volumes: List of Docker volume mount strings.
|
||||
|
||||
Returns:
|
||||
Sorted list with parent paths before child paths.
|
||||
"""
|
||||
|
||||
def _target_depth(vol: str) -> int:
|
||||
parts = vol.split(":")
|
||||
if len(parts) < 2:
|
||||
return 0
|
||||
target = parts[1].rstrip("/")
|
||||
if not target or target == "/":
|
||||
return 0
|
||||
return target.count("/")
|
||||
|
||||
# Detect duplicate targets and warn
|
||||
targets = []
|
||||
for vol in volumes:
|
||||
parts = vol.split(":")
|
||||
targets.append(parts[1] if len(parts) > 1 else "")
|
||||
dupes = [t for t, c in Counter(targets).items() if c > 1]
|
||||
if dupes:
|
||||
logger.warning("Duplicate mount targets detected: %s", dupes)
|
||||
|
||||
# Stable sort: parent paths first, child paths last
|
||||
return sorted(volumes, key=_target_depth)
|
||||
|
||||
|
||||
def render_compose_template(template: str, variables: dict[str, Any]) -> str:
|
||||
"""Render a Docker Compose template with variable substitution.
|
||||
|
||||
Args:
|
||||
template: The compose template string
|
||||
variables: Dictionary of variable names to values
|
||||
|
||||
Returns:
|
||||
Rendered compose file content
|
||||
"""
|
||||
result = template
|
||||
for key, value in variables.items():
|
||||
placeholder = f"{{{{{key}}}}}"
|
||||
result = result.replace(placeholder, str(value))
|
||||
return result
|
||||
|
||||
|
||||
def ensure_instance_directory(instance_id: str, base_path: str | None = None) -> str:
|
||||
"""Create and return the instance directory path.
|
||||
|
||||
Args:
|
||||
instance_id: Unique instance identifier
|
||||
base_path: Base directory for all instances (defaults to Settings.instance_base_path)
|
||||
|
||||
Returns:
|
||||
Absolute path to instance directory
|
||||
"""
|
||||
if base_path is None:
|
||||
from src.config import Settings
|
||||
|
||||
base_path = Settings().instance_base_path
|
||||
instance_dir = Path(base_path) / instance_id
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
return str(instance_dir.absolute())
|
||||
|
||||
|
||||
def write_compose_file(instance_dir: str, content: str) -> str:
|
||||
"""Write the rendered compose file to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
content: Rendered compose content
|
||||
|
||||
Returns:
|
||||
Path to the compose file
|
||||
"""
|
||||
compose_path = Path(instance_dir) / "docker-compose.yml"
|
||||
compose_path.write_text(content)
|
||||
return str(compose_path)
|
||||
|
||||
|
||||
def write_env_file(instance_dir: str, env_vars: dict[str, str]) -> str:
|
||||
"""Write environment variables to a .env file.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
env_vars: Dictionary of env var names to values
|
||||
|
||||
Returns:
|
||||
Path to the env file
|
||||
"""
|
||||
env_path = Path(instance_dir) / ".env"
|
||||
lines = [f'{key}="{value}"' for key, value in env_vars.items()]
|
||||
env_path.write_text("\n".join(lines) + "\n")
|
||||
return str(env_path)
|
||||
|
||||
|
||||
def write_config_files(instance_dir: str, files: dict[str, str]) -> None:
|
||||
"""Write config files to the instance directory.
|
||||
|
||||
Args:
|
||||
instance_dir: Path to instance directory
|
||||
files: Dictionary of file paths (relative to instance dir) to content
|
||||
"""
|
||||
instance_path = Path(instance_dir)
|
||||
for file_path, content in files.items():
|
||||
# Ensure the path is within the instance directory (security)
|
||||
full_path = instance_path / file_path
|
||||
try:
|
||||
full_path.resolve().relative_to(instance_path.resolve())
|
||||
except ValueError:
|
||||
raise ValueError(f"File path '{file_path}' escapes instance directory")
|
||||
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
full_path.write_text(content)
|
||||
|
||||
|
||||
def execute_compose_command(
|
||||
compose_path: str, action: str, timeout: int = 60, env_file: str | None = None
|
||||
) -> tuple[int, str, str]:
|
||||
"""Execute a docker compose command.
|
||||
|
||||
Args:
|
||||
compose_path: Path to docker-compose.yml
|
||||
action: The compose action (up, down, start, stop, restart)
|
||||
timeout: Command timeout in seconds
|
||||
env_file: Optional path to .env file for environment variables
|
||||
|
||||
Returns:
|
||||
Tuple of (returncode, stdout, stderr)
|
||||
"""
|
||||
instance_dir = Path(compose_path).parent
|
||||
|
||||
cmd = ["docker", "compose", "-f", compose_path]
|
||||
|
||||
if env_file:
|
||||
cmd.extend(["--env-file", env_file])
|
||||
|
||||
if action == "up":
|
||||
cmd.extend(["up", "-d", "--force-recreate"])
|
||||
elif action == "down":
|
||||
cmd.extend(["down", "-v"])
|
||||
elif action in ("start", "stop", "restart"):
|
||||
cmd.append(action)
|
||||
else:
|
||||
raise ValueError(f"Unknown compose action: {action}")
|
||||
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=str(instance_dir),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def get_container_id(instance_name: str) -> str | None:
|
||||
"""Get the container ID for a compose service.
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
"""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),
|
||||
}
|
||||
@@ -1,283 +1,8 @@
|
||||
"""Clean tunnel service using cloudflared containers on the backend network.
|
||||
"""Tunnel service — re-exported from docker.tunnel for backward compatibility."""
|
||||
|
||||
Design:
|
||||
- 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).
|
||||
- This avoids host port conflicts and DNS resolution issues.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any
|
||||
|
||||
from src.services.docker 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_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,
|
||||
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_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)
|
||||
|
||||
if not url:
|
||||
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"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),
|
||||
}
|
||||
from src.services.docker.tunnel import ( # noqa: F401
|
||||
check_tunnel_health,
|
||||
recreate_tunnel,
|
||||
start_tunnel,
|
||||
stop_tunnel,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user