diff --git a/backend/main.py b/backend/main.py index 6640b19..d14f098 100644 --- a/backend/main.py +++ b/backend/main.py @@ -55,7 +55,7 @@ async def lifespan(app: FastAPI): schedule_task.cancel() -app = FastAPI(title="StackPilot", version="0.21.1", lifespan=lifespan) +app = FastAPI(title="StackPilot", version="0.21.2", lifespan=lifespan) app.add_middleware( CORSMiddleware, diff --git a/backend/services/port_service.py b/backend/services/port_service.py index eb5465f..fb205c3 100644 --- a/backend/services/port_service.py +++ b/backend/services/port_service.py @@ -103,12 +103,21 @@ def host_listening_ports() -> dict[str, set[int]]: } -def docker_bound_ports() -> dict[tuple[int, str], str]: - """Return {(host_port, proto): container_name}.""" - out: dict[tuple[int, str], str] = {} +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: @@ -117,7 +126,7 @@ def docker_bound_ports() -> dict[tuple[int, str], str]: for h in hosts: hp = h.get("HostPort") if hp: - out[(int(hp), proto)] = c.name + out[(int(hp), proto)] = (c.name, project) except (DockerError, ValueError): pass return out @@ -140,10 +149,16 @@ def detect_conflicts(yaml_str: str, ignore_stack: Optional[str] = None) -> list[ 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. - if ignore_stack and owner.startswith(f"{ignore_stack}-"): + # 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}" + used_by = f"container {owner_name}" elif port in host_ports.get(proto, set()): used_by = "host process" if used_by: