"""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 != "": 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 "" 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 (stdout + stderr) """ result = subprocess.run( ["docker", "logs", "--tail", str(tail), container_id], capture_output=True, text=True, ) if result.returncode != 0: return f"Failed to get logs: {result.stderr}" logs = result.stdout if result.stderr: if logs: logs += "\n" logs += f"STDERR:\n{result.stderr}" return logs 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}")