"""Port conflict detection before deploying a stack.""" from __future__ import annotations import os from typing import Optional import yaml from config import settings from docker_client import DockerError, get_client, safe_call # --------------------------------------------------------------------------- # # Parse compose `ports:` entries # --------------------------------------------------------------------------- # def parse_compose_ports(yaml_str: str) -> list[dict]: """Return [{host_port:int, protocol:'tcp'|'udp', service:str}].""" try: data = yaml.safe_load(yaml_str) or {} except yaml.YAMLError: return [] services = data.get("services") or {} out: list[dict] = [] for svc_name, svc in services.items(): if not isinstance(svc, dict): continue for entry in svc.get("ports", []) or []: parsed = _parse_port_entry(entry) if parsed: parsed["service"] = svc_name out.append(parsed) return out def _parse_port_entry(entry) -> Optional[dict]: # Long form: {target, published, protocol} if isinstance(entry, dict): published = entry.get("published") if published is None: return None try: host_port = int(str(published).split("-")[0]) except ValueError: return None return {"host_port": host_port, "protocol": entry.get("protocol", "tcp")} # Short form string: "[ip:]host:container[/proto]" or "container" s = str(entry) proto = "tcp" if "/" in s: s, proto = s.rsplit("/", 1) parts = s.split(":") # No host mapping (only container port) -> random host port, no conflict. if len(parts) == 1: return None # host:container or ip:host:container host = parts[-2] try: host_port = int(host.split("-")[0]) except ValueError: return None return {"host_port": host_port, "protocol": proto} # --------------------------------------------------------------------------- # # Host bound ports # --------------------------------------------------------------------------- # def _read_proc_net(name: str) -> set[int]: ports: set[int] = set() path = os.path.join(settings.HOST_PROC_PATH, "net", name) if not os.path.isfile(path): path = os.path.join("/proc/net", name) try: with open(path, "r", encoding="utf-8") as fh: lines = fh.readlines()[1:] except OSError: return ports for line in lines: cols = line.split() if len(cols) < 4: continue local = cols[1] # hexip:hexport state = cols[3] # TCP listen state is 0A; for UDP accept all. if name.startswith("tcp") and state != "0A": continue try: port = int(local.split(":")[1], 16) ports.add(port) except (IndexError, ValueError): continue return ports def host_listening_ports() -> dict[str, set[int]]: return { "tcp": _read_proc_net("tcp") | _read_proc_net("tcp6"), "udp": _read_proc_net("udp") | _read_proc_net("udp6"), } COMPOSE_PROJECT_LABEL = "com.docker.compose.project" def docker_bound_ports() -> dict[tuple[int, str], tuple[str, Optional[str]]]: """Return {(host_port, proto): (container_name, compose_project)}. The compose project is taken from the container's compose label so a stack's own running containers can be reliably recognised on re-deploy, regardless of explicit container_name or name separator differences. """ out: dict[tuple[int, str], tuple[str, Optional[str]]] = {} try: client = get_client() for c in safe_call(client.containers.list): project = (c.labels or {}).get(COMPOSE_PROJECT_LABEL) bindings = (c.attrs.get("NetworkSettings") or {}).get("Ports") or {} for container_port, hosts in bindings.items(): if not hosts: continue proto = container_port.split("/")[-1] if "/" in container_port else "tcp" for h in hosts: hp = h.get("HostPort") if hp: out[(int(hp), proto)] = (c.name, project) except (DockerError, ValueError): pass return out # --------------------------------------------------------------------------- # # Detect conflicts # --------------------------------------------------------------------------- # def detect_conflicts(yaml_str: str, ignore_stack: Optional[str] = None) -> list[dict]: wanted = parse_compose_ports(yaml_str) host_ports = host_listening_ports() docker_ports = docker_bound_ports() conflicts: list[dict] = [] for w in wanted: port = w["host_port"] proto = w.get("protocol", "tcp") used_by = None owner = docker_ports.get((port, proto)) if owner: owner_name, owner_project = owner # A container from the same stack (re-deploy) is not a conflict. # Match on the compose project label, falling back to the legacy # name-prefix check for containers without the label. if ignore_stack and ( owner_project == ignore_stack or owner_name.startswith(f"{ignore_stack}-") ): continue used_by = f"container {owner_name}" elif port in host_ports.get(proto, set()): used_by = "host process" if used_by: conflicts.append( { "port": port, "protocol": proto, "service": w.get("service"), "used_by": used_by, } ) return conflicts