Files
stackpilot/backend/services/port_service.py
menzeljandClaude Opus 4.8 f65ec5f268 Fix: recognise a stack's own running ports on re-deploy (0.21.2)
Port-conflict check now matches the stack's own containers via the
compose project label instead of a fragile container-name prefix, so
editing + deploying a running stack no longer reports false conflicts
(explicit container_name or '_' name separator).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-08 16:31:52 +00:00

174 lines
5.7 KiB
Python

"""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