scripts/port-conflict-detect.sh

#!/usr/bin/env bash
# scripts/port-conflict-detect.sh
# Parse every stack's docker-compose.yml, collect "HOST:CONTAINER" port
# mappings, and warn if two services try to bind the same host port.
# Runs before `docker compose up` in my deploy flow.

set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
. "${HERE}/lib/log.sh"

REPO="${REPO:-/srv/homelab}"

declare -A owner

emit() {
    local stack="$1" port="$2" svc="$3"
    if [[ -n "${owner[${port}]:-}" && "${owner[${port}]}" != "${stack}/${svc}" ]]; then
        log_err "CONFLICT port ${port}: ${owner[${port}]} vs ${stack}/${svc}"
        return 1
    fi
    owner[${port}]="${stack}/${svc}"
    return 0
}

rc=0
while IFS= read -r compose; do
    stack=$(basename "$(dirname "${compose}")")
    # Pull "- HOST:CONTAINER" lines out of the ports: blocks.
    while IFS= read -r line; do
        case "${line}" in
            *ports:*) in_ports=1; continue ;;
            *) ;;
        esac
    done < "${compose}"

    python3 - "${compose}" "${stack}" <<'PY' || rc=1
import sys, yaml
path, stack = sys.argv[1], sys.argv[2]
with open(path) as f:
    data = yaml.safe_load(f) or {}
for svc, spec in (data.get('services') or {}).items():
    for p in spec.get('ports', []) or []:
        s = str(p)
        # strip interface prefix (e.g. "127.0.0.1:8080:8080")
        parts = s.split(':')
        if len(parts) >= 2:
            host = parts[-2]
            print(f"{stack}\t{svc}\t{host}")
PY
done < <(find "${REPO}/stacks" -maxdepth 3 -name 'docker-compose.yml') \
    | while IFS=$'\t' read -r stack svc port; do
          emit "${stack}" "${port}" "${svc}" || rc=1
      done

exit "${rc}"