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
125 lines
6.2 KiB
Python
125 lines
6.2 KiB
Python
#!/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())
|