All checks were successful
CI Trade-In / backend-tests (pull_request) Has been skipped
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m12s
CI / backend-tests (pull_request) Successful in 17m35s
CI Trade-In / changes (pull_request) Successful in 8s
CI / frontend-tests (pull_request) Successful in 1m11s
Публичный лендинг отдавался БЕЗ витрины: без ленты сделок, без строк сверки
«прогноз против факта», без подписи разброса. Страница про точность — без
единого доказательства. Отдавалось 106 КБ вместо 241 КБ.
Причина. ПТИЦА и МЕРА — разные compose-проекты, но оба назвали свой сервис
backend и оба подключены к общей сети gendesign_shared. Изнутри фронта:
backend → 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА)
tradein-backend → 172.18.0.6
BACKEND_URL=http://backend:8000 уводил серверный рендер в бэкенд ПТИЦЫ, тот
отвечал 401 no authenticated user, и страница рендерилась пустой.
Отказ тихий вдвойне. fetch не бросает — приходит валидный HTTP-ответ, просто
чужой. И имя двоится, поэтому часть перегенераций попадала в правильный адрес:
утром страница была с данными, к обеду без них, и это выглядело случайной
поломкой, а не ошибкой конфигурации.
DATABASE_URL болен тем же: @postgres:5432 мог уйти в базу ПТИЦЫ. Там спасало
лишь несовпадение кредов — отказ вместо тихого чтения не тех данных. Полагаться
на это нельзя: защита держится на том, что у чужой базы нет пользователя с
нашим паролем. Переведён на однозначное имя во всех трёх сервисах.
Гейт check-compose-ambiguous-hosts.py: пересечение имён сервисов обоих compose
и запрет ссылаться на них как на хост. Селфтест по конвенции соседних гейтов —
он провалился дважды на моих же фикстурах (в них не было общего имени, то есть
ловить было нечего), и это ровно то, ради чего селфтест и нужен.
Фальсификация на настоящем файле: возврат backend:8000 даёт точную строку 479,
возврат @postgres — все четыре ссылки.
162 lines
8.2 KiB
Python
Executable file
162 lines
8.2 KiB
Python
Executable file
#!/usr/bin/env python3
|
||
"""Гейт: сервисы МЕРЫ не ссылаются друг на друга именем, которое двоится (#3282).
|
||
|
||
ПОЧЕМУ. ПТИЦА и МЕРА — разные compose-проекты, но оба назвали свои сервисы
|
||
`backend`, `postgres`, `frontend`, и оба подключены к общей сети
|
||
`gendesign_shared`. Docker отдаёт ОБА адреса на такое имя, клиент берёт любой:
|
||
|
||
изнутри tradein-frontend:
|
||
backend → 172.18.0.6 (МЕРА) + 172.18.0.9 (ПТИЦА)
|
||
tradein-backend → 172.18.0.6
|
||
|
||
30.08.2026 это уронило публичный лендинг. `BACKEND_URL=http://backend:8000`
|
||
уводил серверный рендер в бэкенд ПТИЦЫ, тот отвечал `401 no authenticated
|
||
user`, и страница рендерилась БЕЗ витрины: без ленты сделок, без строк сверки
|
||
«прогноз против факта» и без подписи разброса. Страница про точность — без
|
||
единого доказательства.
|
||
|
||
Отказ был ТИХИЙ вдвойне. Во-первых, `fetch` не бросает: приходит валидный HTTP-
|
||
ответ, просто чужой. Во-вторых, имя двоится, поэтому часть перегенераций
|
||
попадала в правильный адрес — утром страница отдавалась с данными, к обеду без
|
||
них, и выглядело это как случайная поломка, а не как ошибка конфигурации.
|
||
|
||
У DSN та же болезнь и худшая цена: `@postgres:5432/tradein` мог уйти в базу
|
||
ПТИЦЫ. Там спасало лишь несовпадение кредов — то есть отказ вместо тихого
|
||
чтения не тех данных. На такую защиту полагаться нельзя: она держится на том,
|
||
что у чужой базы нет пользователя с нашим паролем.
|
||
|
||
ЧТО ДЕЛАЕТ. Берёт имена сервисов ОБОИХ compose, находит пересечение (это и есть
|
||
двоящиеся имена) и проверяет, что в compose МЕРЫ нет ссылок на них как на хост
|
||
— ни `http://<имя>:порт`, ни `@<имя>:порт` в DSN. Лечение всегда одно:
|
||
подставить `container_name` (`tradein-backend`, `tradein-postgres`), он
|
||
однозначен по построению.
|
||
|
||
ЧЕГО НЕ ЛОВИТ. Ссылки, собираемые в рантайме из кусков, и имена, попадающие в
|
||
контейнер иным путём (`.env` на хосте, секреты CI). Гейт читает только оба
|
||
compose-файла.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
REPO = Path(__file__).resolve().parent.parent
|
||
MERA_COMPOSE = REPO / "tradein-mvp" / "docker-compose.prod.yml"
|
||
PTICA_COMPOSE = REPO / "docker-compose.prod.yml"
|
||
|
||
# Имя сервиса верхнего уровня: ровно два пробела, затем имя и двоеточие.
|
||
_SERVICE_RE = re.compile(r"^ ([a-z][a-z0-9_-]*):\s*$", re.M)
|
||
# Ссылка на хост: `//имя:порт` (URL) либо `@имя:порт` (DSN).
|
||
_HOST_RE = re.compile(r"(?://|@)([a-z][a-z0-9_-]*):\d+")
|
||
|
||
|
||
def service_names(text: str) -> set[str]:
|
||
"""Имена сервисов верхнего уровня. Тома и сети сюда не попадают: они
|
||
объявляются в своих секциях с тем же отступом, поэтому список заведомо шире
|
||
нужного — и это безопасная сторона, лишнее имя даст лишнюю проверку, а не
|
||
пропуск."""
|
||
return set(_SERVICE_RE.findall(text))
|
||
|
||
|
||
def violations(mera: str, ptica: str) -> list[str]:
|
||
ambiguous = service_names(mera) & service_names(ptica)
|
||
if not ambiguous:
|
||
return []
|
||
found: list[str] = []
|
||
for num, line in enumerate(mera.splitlines(), 1):
|
||
stripped = line.strip()
|
||
if stripped.startswith("#"):
|
||
continue
|
||
for host in _HOST_RE.findall(line):
|
||
if host in ambiguous:
|
||
found.append(f"{MERA_COMPOSE.name}:{num}: хост «{host}» двоится → {stripped[:90]}")
|
||
return found
|
||
|
||
|
||
def selftest() -> int:
|
||
"""Гейт обязан уметь краснеть — иначе зелёный ничего не значит."""
|
||
ptica = "services:\n backend:\n image: x\n postgres:\n image: y\n"
|
||
|
||
# У фикстуры МЕРЫ обязан быть сервис с ИМЕНЕМ ИЗ ПТИЦЫ — иначе пересечение
|
||
# пусто, проверять нечего, и «не поймал» означало бы «нечего было ловить».
|
||
# На этом селфтест и провалился в первой редакции.
|
||
bad = (
|
||
"services:\n backend:\n image: x\n"
|
||
" frontend:\n environment:\n URL: \"http://backend:8000\"\n"
|
||
)
|
||
if not violations(bad, ptica):
|
||
print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в URL не пойман", file=sys.stderr)
|
||
return 1
|
||
|
||
bad_dsn = (
|
||
"services:\n postgres:\n image: y\n"
|
||
" backend:\n environment:\n DSN: \"pg://u:p@postgres:5432/db\"\n"
|
||
)
|
||
if not violations(bad_dsn, ptica):
|
||
print("СЕЛФТЕСТ ПРОВАЛЕН: двоящийся хост в DSN не пойман", file=sys.stderr)
|
||
return 1
|
||
|
||
ok = (
|
||
"services:\n backend:\n image: x\n"
|
||
" frontend:\n environment:\n URL: \"http://tradein-backend:8000\"\n"
|
||
)
|
||
if violations(ok, ptica):
|
||
print("СЕЛФТЕСТ ПРОВАЛЕН: однозначное имя признано нарушением", file=sys.stderr)
|
||
return 1
|
||
|
||
# Закомментированная строка — не конфигурация.
|
||
commented = (
|
||
"services:\n backend:\n image: x\n"
|
||
" frontend:\n environment:\n # URL: \"http://backend:8000\"\n"
|
||
)
|
||
if violations(commented, ptica):
|
||
print("СЕЛФТЕСТ ПРОВАЛЕН: комментарий принят за ссылку", file=sys.stderr)
|
||
return 1
|
||
|
||
print("селфтест пройден: гейт краснеет на URL и на DSN, молчит на однозначном имени")
|
||
return 0
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("--selftest", action="store_true", help="проверить, что гейт умеет краснеть")
|
||
args = ap.parse_args()
|
||
|
||
if args.selftest:
|
||
return selftest()
|
||
|
||
for path in (MERA_COMPOSE, PTICA_COMPOSE):
|
||
if not path.exists():
|
||
print(f"НЕ НАЙДЕН: {path}", file=sys.stderr)
|
||
return 1
|
||
|
||
mera = MERA_COMPOSE.read_text(encoding="utf-8")
|
||
ptica = PTICA_COMPOSE.read_text(encoding="utf-8")
|
||
|
||
shared = service_names(mera) & service_names(ptica)
|
||
if not shared:
|
||
print("пересекающихся имён сервисов нет — проверять нечего")
|
||
return 0
|
||
|
||
found = violations(mera, ptica)
|
||
if not found:
|
||
print(f"ok: двоятся имена {sorted(shared)}, но ссылок на них как на хост нет")
|
||
return 0
|
||
|
||
print("Ссылка на ДВОЯЩЕЕСЯ имя сервиса:\n", file=sys.stderr)
|
||
for line in found:
|
||
print(f" {line}", file=sys.stderr)
|
||
print(
|
||
"\nОба продукта делят сеть gendesign_shared, поэтому такое имя резолвится\n"
|
||
"в два адреса и клиент берёт любой. Подставьте container_name\n"
|
||
"(tradein-backend, tradein-postgres) — он однозначен.",
|
||
file=sys.stderr,
|
||
)
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|