ci: гейт против публикации занятых на VPS портов в workflow (#2757)
All checks were successful
CI Trade-In / changes (pull_request) Successful in 7s
CI / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Successful in 1m22s
CI / openapi-codegen-check (pull_request) Successful in 2m30s
CI / backend-tests (pull_request) Successful in 15m29s

Раннер запускает и job, и сервис-контейнеры с `--network host` (проверено:
NetworkMode=host у job-контейнеров), а на хосте слушают боевые сервисы.
Поэтому `services: postgres` + `ports: 5432:5432` не поднимает тестовую БД,
а уводит сборку в БОЕВОЙ Postgres: занять порт контейнер не может, и
`localhost:5432` из job'а — прод. В #2745 спасло несовпадение пароля.

scripts/check-workflow-ports.py ловит публикацию занятого порта в любой форме
(`ports:` блоком, инлайн-списком, `docker run -p/--publish`) и роняет сборку.
Список портов — замер `ss -ltnp` на bot-server 2026-08-06, не догадки:
22, 53, 80, 443, 2222, 3000, 5432, 8000. Redis/CouchDB/GlitchTip в список НЕ
попали: они на хосте не слушают (bridge-сети, порты без публикации), запрет
ронял бы сборку на ровном месте.

Шаг встроен в changes-job ci.yml — тот и так бежит на каждом PR и уже сделал
checkout, отдельный слот дефицитного раннера не занимается.

Заодно снят капкан в шапке ci.yml: комментарий «FUTURE: добавить service —
см. .github/workflows/ci.yml как образец» указывал ровно на анти-пример
(`ports: 5432:5432`), безвредный там лишь потому, что GitHub Actions у нас не
исполняется. Теперь он указывает на рабочий способ из ci-tradein.yml.

Refs #2757, #2745
This commit is contained in:
bot-backend 2026-08-07 02:23:27 +05:00
parent 79f7b8fff3
commit c485fc1d9e
2 changed files with 147 additions and 2 deletions

View file

@ -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:

View file

@ -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())