"""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 from services.compose_service import project_name # --------------------------------------------------------------------------- # # 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 [] if not isinstance(data, dict): return [] services = data.get("services") or {} if not isinstance(services, dict): return [] out: list[dict] = [] for svc_name, svc in services.items(): if not isinstance(svc, dict): continue for entry in svc.get("ports", []) or []: for parsed in _parse_port_entry(entry): parsed["service"] = svc_name out.append(parsed) return out #: A published range is checked port by port, up to this many. _MAX_RANGE = 1024 def _host_ports(spec: str) -> list[int]: """"8080" -> [8080]; "8080-8082" -> [8080, 8081, 8082]; junk -> [].""" try: if "-" in spec: low, high = (int(p) for p in spec.split("-", 1)) if low > high or high - low >= _MAX_RANGE: return [low] return list(range(low, high + 1)) return [int(spec)] except ValueError: return [] def _parse_port_entry(entry) -> list[dict]: # Long form: {target, published, protocol} if isinstance(entry, dict): published = entry.get("published") if published is None: return [] proto = str(entry.get("protocol") or "tcp") return [{"host_port": p, "protocol": proto} for p in _host_ports(str(published))] # 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 [] # host:container or ip:host:container (an IPv6 address has more colons; # the host port is always the second to last part) return [{"host_port": p, "protocol": proto} for p in _host_ports(parts[-2])] # --------------------------------------------------------------------------- # # Host bound ports # --------------------------------------------------------------------------- # def _proc_net_path(name: str) -> str: """The host's socket table, not this container's. ``/proc/net`` is a link to ``self/net`` — the network namespace of whoever reads it — so ``${HOST_PROC_PATH}/net/tcp`` read from inside the StackPilot container lists the container's own sockets (just its API port), and every port a host process listens on went unnoticed. PID 1 of the host's /proc lives in the host's namespace. """ for candidate in ( os.path.join(settings.HOST_PROC_PATH, "1", "net", name), os.path.join(settings.HOST_PROC_PATH, "net", name), ): if os.path.isfile(candidate): return candidate return os.path.join("/proc/net", name) def _read_proc_net(name: str) -> set[int]: ports: set[int] = set() path = _proc_net_path(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 in (ignore_stack, project_name(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