diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 66d5fd7f..0719e2a3 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -12,8 +12,18 @@ name: CI # единственный real-Postgres тест (tests/sql/ mv_layout) self-skip'ается через # connectivity-probe. PDF-тесты (WeasyPrint) РЕАЛЬНО ИДУТ здесь (libpango # установлен ниже), тогда как на macOS-dev они runtime-skip'аются. -# FUTURE: добавить `postgis/postgis:16-3.4` service + гонять mv_layout — см. -# .github/workflows/ci.yml как образец service-блока. +# +# FUTURE: захочется добавить сюда живой postgis и гонять mv_layout — ⚠️ НЕ через +# `services:` с публикацией порта (#2757). Раннер запускает и job, и сервис- +# контейнеры с `--network host`, а на 5432 этого же хоста слушает БОЕВОЙ +# Postgres: контейнер порт не займёт, а `localhost:5432` из job'а — это прод. +# В #2745 так и вышло, спасло только несовпадение пароля. Образец правильного +# способа (docker run в bridge-сети БЕЗ публикации, готовность по TCP, коннект +# по IP контейнера) — в .forgejo/workflows/ci-tradein.yml, шаг «Поднять Postgres +# и собрать схему tradein». В .github/workflows/ci.yml лежит ровно анти-пример +# (`ports: 5432:5432`) — он безвреден только потому, что GitHub Actions у нас не +# исполняется; копировать оттуда нельзя. Гейт ниже (Guard: host-port collisions) +# уронит сборку, если такая публикация всё же появится. on: # ТОЛЬКО pull_request — НЕТ push-триггера на feature-ветки (CI-шторм #1709). # WHY: раньше был и push: [feat/**,fix/**,...]. Каждый коммит в ветку с открытым @@ -45,6 +55,16 @@ jobs: frontend: ${{ steps.filter.outputs.frontend }} steps: - uses: actions/checkout@v4 + + - name: "Guard: host-port collisions in workflows (#2757)" + # Шагом в changes-job, а не отдельным job'ом: этот job и так бежит на + # КАЖДОМ PR и уже сделал checkout — гейт стоит ~1с и не занимает + # дефицитный слот раннера. Падение = merge заблокирован. + # python3 есть в образе раннера (catthehacker/ubuntu:act-latest, 3.12.3). + run: | + python3 scripts/check-workflow-ports.py --selftest + python3 scripts/check-workflow-ports.py + - uses: dorny/paths-filter@v3 id: filter with: diff --git a/scripts/check-workflow-ports.py b/scripts/check-workflow-ports.py new file mode 100644 index 00000000..887c1169 --- /dev/null +++ b/scripts/check-workflow-ports.py @@ -0,0 +1,125 @@ +#!/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())