#!/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())