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)
|
||||
@@ -0,0 +1,316 @@
|
||||
"""Docker container runtime queries and network management."""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def get_container_id(instance_name: str) -> str | None:
|
||||
"""Get the container ID for a compose service.
|
||||
|
||||
Uses exact name matching to avoid substring collisions with tunnel
|
||||
containers (e.g. tunnel-code-server-... matching code-server-...).
|
||||
Falls back to case-insensitive matching since Docker DNS is case-
|
||||
insensitive but docker inspect is case-sensitive.
|
||||
|
||||
Args:
|
||||
instance_name: The expected container name.
|
||||
|
||||
Returns:
|
||||
Container ID or None if not found.
|
||||
"""
|
||||
expected = instance_name.lower()
|
||||
|
||||
# Fast path: exact match via docker inspect
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.Id}}", expected],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip()
|
||||
|
||||
# Fallback: list all containers and do case-insensitive exact match
|
||||
ps_result = subprocess.run(
|
||||
["docker", "ps", "-a", "--format", "{{.Names}}\t{{.ID}}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if ps_result.returncode == 0:
|
||||
for line in ps_result.stdout.strip().splitlines():
|
||||
parts = line.split("\t")
|
||||
if len(parts) == 2:
|
||||
name, cid = parts
|
||||
if name.lower() == expected:
|
||||
return cid
|
||||
return None
|
||||
|
||||
|
||||
def get_container_name(instance_name: str) -> str | None:
|
||||
"""Get the full container name for a compose service.
|
||||
|
||||
Uses exact name matching via docker inspect to avoid substring collisions.
|
||||
|
||||
Args:
|
||||
instance_name: The exact container name (case-insensitive for Docker).
|
||||
|
||||
Returns:
|
||||
Container name or None if not found.
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "inspect", "-f", "{{.Name}}", instance_name.lower()],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
return result.stdout.strip().lstrip("/")
|
||||
return None
|
||||
|
||||
|
||||
def get_backend_network_name() -> str:
|
||||
"""Auto-detect the actual Docker network name for the backend network.
|
||||
|
||||
Docker Compose prefixes network names with the project directory name
|
||||
(e.g. 'headquarter_backend' instead of 'backend'). We inspect the API
|
||||
container itself to find the real network name it's connected to.
|
||||
|
||||
Returns:
|
||||
The actual Docker network name, or 'backend' as fallback.
|
||||
"""
|
||||
# Try to find the API container by its known name
|
||||
api_container = "hq-api"
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}",
|
||||
api_container,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
networks = result.stdout.strip().split()
|
||||
for net in networks:
|
||||
if "backend" in net.lower():
|
||||
return net
|
||||
# API container is on some network — return the first one
|
||||
return networks[0]
|
||||
return "backend"
|
||||
|
||||
|
||||
def connect_container_to_network(
|
||||
container_name: str, network_name: str | None = None
|
||||
) -> bool:
|
||||
"""Connect a Docker container to an existing network.
|
||||
|
||||
Args:
|
||||
container_name: Name or ID of the container
|
||||
network_name: Name of the Docker network. If None, auto-detects
|
||||
from the API container's own network membership.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
["docker", "network", "connect", network_name, container_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def get_container_ip_on_network(
|
||||
container_id: str, network_name: str | None = None
|
||||
) -> str | None:
|
||||
"""Get a container's IP address on a specific Docker network.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
network_name: Network name. If None, auto-detects from the API container.
|
||||
|
||||
Returns:
|
||||
IP address string, or None if the container is not on that network.
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
f"{{{{.NetworkSettings.Networks.{network_name}.IPAddress}}}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
ip = result.stdout.strip()
|
||||
if ip and ip != "<no value>":
|
||||
return ip
|
||||
return None
|
||||
|
||||
|
||||
def is_container_on_network(container_id: str, network_name: str | None = None) -> bool:
|
||||
"""Check whether a container is already attached to a Docker network.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID or name.
|
||||
network_name: Network name. If None, auto-detects from the API container.
|
||||
|
||||
Returns:
|
||||
True if the container is on the network.
|
||||
"""
|
||||
if network_name is None:
|
||||
network_name = get_backend_network_name()
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
f"{{{{.NetworkSettings.Networks.{network_name}}}}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0 and "<no value>" not in result.stdout
|
||||
|
||||
|
||||
def get_container_status(container_id: str) -> dict[str, Any]:
|
||||
"""Get the status of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
|
||||
Returns:
|
||||
Dict with 'status' (running, exited, restarting, not_found),
|
||||
'exit_code' (int or None), and 'health' (health status or None)
|
||||
"""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"docker",
|
||||
"inspect",
|
||||
"-f",
|
||||
"{{.State.Status}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}",
|
||||
container_id,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {"status": "not_found", "exit_code": None, "health": None}
|
||||
|
||||
parts = result.stdout.strip().split("|")
|
||||
status = parts[0] if parts else "unknown"
|
||||
exit_code = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else None
|
||||
health = parts[2] if len(parts) > 2 and parts[2] != "none" else None
|
||||
|
||||
return {"status": status, "exit_code": exit_code, "health": health}
|
||||
|
||||
|
||||
def wait_for_container_running(
|
||||
container_id: str, timeout: int = 30, interval: float = 2.0
|
||||
) -> dict[str, Any]:
|
||||
"""Wait for a container to reach the running state.
|
||||
|
||||
Polls docker inspect until the container status is "running" or timeout.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
timeout: Maximum seconds to wait
|
||||
interval: Seconds between polls
|
||||
|
||||
Returns:
|
||||
Dict with 'success' (bool), 'status' (str), 'exit_code' (int or None),
|
||||
and 'waited_seconds' (float)
|
||||
"""
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
while time.time() - start_time < timeout:
|
||||
info = get_container_status(container_id)
|
||||
|
||||
if info["status"] == "running":
|
||||
return {
|
||||
"success": True,
|
||||
"status": "running",
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
if info["status"] == "exited":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "exited",
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
if info["status"] == "not_found":
|
||||
return {
|
||||
"success": False,
|
||||
"status": "not_found",
|
||||
"exit_code": None,
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
time.sleep(interval)
|
||||
|
||||
# Timeout reached
|
||||
info = get_container_status(container_id)
|
||||
return {
|
||||
"success": False,
|
||||
"status": info["status"],
|
||||
"exit_code": info["exit_code"],
|
||||
"waited_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
|
||||
def get_container_logs(container_id: str, tail: int = 100) -> str:
|
||||
"""Get the logs of a Docker container.
|
||||
|
||||
Args:
|
||||
container_id: Docker container ID
|
||||
tail: Number of lines to return
|
||||
|
||||
Returns:
|
||||
Container logs
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["docker", "logs", "--tail", str(tail), container_id],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
return f"Failed to get logs: {result.stderr}"
|
||||
|
||||
|
||||
def find_free_port(start: int = 10000, end: int = 20000) -> int:
|
||||
"""Find a free TCP port in the given range.
|
||||
|
||||
Args:
|
||||
start: Start of port range
|
||||
end: End of port range
|
||||
|
||||
Returns:
|
||||
Free port number
|
||||
"""
|
||||
import socket
|
||||
|
||||
for port in range(start, end):
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
if s.connect_ex(("localhost", port)) != 0:
|
||||
return port
|
||||
|
||||
raise RuntimeError(f"No free port found in range {start}-{end}")
|
||||
@@ -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),
|
||||
}
|
||||
Reference in New Issue
Block a user