#!/usr/bin/env python3 """Гейт: сервис-контейнер сборки не должен публиковать порт, занятый на VPS (#2757). ПОЧЕМУ. Forgejo-раннер запускает и job, и сервис-контейнеры с `--network host` (проверено: `docker inspect FORGEJO-ACTIONS-TASK-... -f {{.HostConfig.NetworkMode}}` → `host`). На том же хосте слушают боевые сервисы. Поэтому обычный приём `services: postgres` + публикация `5432:5432` не поднимает тестовую БД, а тихо уводит job В ПРОД: занять порт сервис-контейнер не может, а `localhost:5432` из job'а — это боевой Postgres. В #2745 отказ спас только несовпадение пароля. Список занятых портов — не догадки, а замер `ss -ltnp` на bot-server 2026-08-06. Опровергнутая по ходу предпосылка issue: Redis / CouchDB / GlitchTip НА ХОСТЕ НЕ СЛУШАЮТ (в `docker ps` у них `6379/tcp`, `5984/tcp`, `8000/tcp` без `->`, они живут в bridge-сетях) — сервис-контейнер с их портами ни с чем не столкнётся, и запрещать их значило бы ронять сборку на ровном месте. Правильный способ дать сборке БД — как в ci-tradein.yml: явный `docker run` в bridge-сети БЕЗ публикации порта + строка подключения по IP контейнера. Запуск: python3 scripts/check-workflow-ports.py [--selftest] """ from __future__ import annotations import re import sys from pathlib import Path # host-порт -> кто его уже занял (ss -ltnp на bot-server, 2026-08-06) RESERVED: dict[int, str] = { 22: "sshd хоста", 53: "systemd-resolved (127.0.0.53)", 80: "gendesign-caddy-1 (0.0.0.0:80)", 443: "gendesign-caddy-1 (0.0.0.0:443)", 2222: "forgejo, git-over-ssh (0.0.0.0:2222)", 3000: "gendesign-frontend-1 (127.0.0.1:3000)", 5432: "gendesign-postgres-1 — БОЕВАЯ БД (127.0.0.1:5432)", 8000: "gendesign-backend-1 — боевой API (127.0.0.1:8000)", } WORKFLOW_DIR = Path(".forgejo/workflows") # "8080:80", "127.0.0.1:5432:5432", "5432:5432/tcp" -> (host_port, container_port) PORT_PAIR = re.compile(r"(?:(?:\d{1,3}\.){3}\d{1,3}:)?(\d{1,5}):(\d{1,5})(?:/(?:tcp|udp))?") PUBLISH_FLAG = re.compile(r"(?:^|\s)(?:-p|--publish)[=\s]+(\S+)") def scan(text: str) -> list[tuple[int, str, int]]: """-> [(номер строки, строка, занятый host-порт)] для публикаций в ports:/-p.""" hits: list[tuple[int, str, int]] = [] in_ports = False ports_indent = 0 for lineno, raw in enumerate(text.splitlines(), 1): stripped = raw.strip() if not stripped or stripped.startswith("#"): continue # комментарий ничего не запускает (в них живут анти-примеры) indent = len(raw) - len(raw.lstrip()) if in_ports and (not stripped.startswith("- ") or indent <= ports_indent): in_ports = False candidates: list[str] = [] if stripped.startswith("ports:"): rest = stripped[len("ports:") :].strip() if rest: candidates.append(rest) # инлайн-форма: ports: ["5432:5432"] else: in_ports, ports_indent = True, indent elif in_ports: candidates.append(stripped) candidates += PUBLISH_FLAG.findall(raw) # docker run -p / --publish for candidate in candidates: for host_port, _container_port in PORT_PAIR.findall(candidate): if int(host_port) in RESERVED: hits.append((lineno, stripped, int(host_port))) return hits def selftest() -> None: assert scan(" ports:\n - 5432:5432\n") == [(2, "- 5432:5432", 5432)] assert scan(' ports: ["127.0.0.1:8000:8000"]\n')[0][2] == 8000 assert scan(" docker run -p 3000:3000 nginx\n")[0][2] == 3000 assert scan(" docker run --publish=443:443 nginx\n")[0][2] == 443 # безопасные формы и посторонние двоеточия — не должны ронять сборку assert scan(" ports:\n - 5432\n") == [] assert scan(" ports:\n - 15432:5432\n") == [] assert scan(" image: postgis/postgis:16-3.4\n") == [] assert scan(' run: journalctl --since "2026-08-07 22:00"\n') == [] assert scan(" # ports:\n # - 5432:5432\n") == [] assert scan(" env:\n DATABASE_URL: pg://u:p@localhost:5432/test\n") == [] # выход из блока ports: по отступу assert scan(" ports:\n - 9999:9999\n image: x\n - 80:80\n") == [] print("selftest OK") def main() -> int: if "--selftest" in sys.argv: selftest() return 0 if not WORKFLOW_DIR.is_dir(): print(f"::error::{WORKFLOW_DIR} не найден — запускать из корня репозитория") return 1 failed = False for path in sorted(WORKFLOW_DIR.glob("*.yml")): for lineno, line, port in scan(path.read_text(encoding="utf-8")): failed = True print( f"::error file={path},line={lineno}::публикация порта {port} — он занят " f"на VPS ({RESERVED[port]}). Раннер работает в сети хоста: контейнер " f"порт не займёт, а job уйдёт в этот прод-сервис (#2757). Поднимай " f"сервис через `docker run` в bridge-сети БЕЗ публикации и ходи по IP " f"контейнера — образец в .forgejo/workflows/ci-tradein.yml. || {line}" ) if failed: return 1 print("✓ ни один workflow не публикует занятый на VPS порт") return 0 if __name__ == "__main__": raise SystemExit(main())