ci: гейт против публикации занятых на VPS портов в workflow (#2757) #2759
2 changed files with 147 additions and 2 deletions
|
|
@ -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:
|
||||
|
|
|
|||
125
scripts/check-workflow-ports.py
Normal file
125
scripts/check-workflow-ports.py
Normal 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())
|
||||
Loading…
Add table
Reference in a new issue