прод: лендинг ходил в чужой бэкенд — имя сервиса двоится между продуктами
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
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 — все четыре ссылки.
This commit is contained in:
parent
999f10e78b
commit
f31cb56081
3 changed files with 190 additions and 4 deletions
|
|
@ -82,6 +82,22 @@ jobs:
|
|||
python3 scripts/check-caddy-snippet-mounts.py --selftest
|
||||
python3 scripts/check-caddy-snippet-mounts.py
|
||||
|
||||
- name: "Guard: сервисы МЕРЫ не ссылаются на двоящееся имя"
|
||||
# ПТИЦА и МЕРА — разные compose-проекты, но оба назвали сервисы
|
||||
# `backend`/`postgres`/`frontend` и оба сидят в общей сети
|
||||
# gendesign_shared. Docker отдаёт на такое имя ДВА адреса, клиент
|
||||
# берёт любой.
|
||||
#
|
||||
# 30.08.2026 это уронило публичный лендинг: BACKEND_URL вёл в бэкенд
|
||||
# ПТИЦЫ, тот отвечал 401, и страница про точность рендерилась БЕЗ
|
||||
# ленты сделок, без строк сверки и без подписи разброса — то есть без
|
||||
# единого доказательства. Отказ тихий: fetch не бросает, приходит
|
||||
# валидный чужой ответ; а из-за двоения часть перегенераций попадала
|
||||
# в правильный адрес, и поломка выглядела случайной.
|
||||
run: |
|
||||
python3 scripts/check-compose-ambiguous-hosts.py --selftest
|
||||
python3 scripts/check-compose-ambiguous-hosts.py
|
||||
|
||||
- name: "Guard: Caddyfile синтаксически валиден"
|
||||
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
||||
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
|
||||
|
|
|
|||
162
scripts/check-compose-ambiguous-hosts.py
Executable file
162
scripts/check-compose-ambiguous-hosts.py
Executable file
|
|
@ -0,0 +1,162 @@
|
|||
#!/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())
|
||||
|
|
@ -294,7 +294,7 @@ services:
|
|||
# Деградация источника в None — существующее состояние ответа (так же
|
||||
# ведёт себя таймаут), контракт API не меняется.
|
||||
ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true"
|
||||
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
|
||||
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
|
||||
PUBLIC_URL: "https://gendsgn.ru/trade-in"
|
||||
CORS_ORIGINS: '["https://gendsgn.ru"]'
|
||||
ENVIRONMENT: "production"
|
||||
|
|
@ -373,7 +373,7 @@ services:
|
|||
- path: ./backend/.env.runtime
|
||||
required: false
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
|
||||
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
|
||||
ENVIRONMENT: "production"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
||||
GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}"
|
||||
|
|
@ -432,7 +432,7 @@ services:
|
|||
- path: ./backend/.env.runtime
|
||||
required: false
|
||||
environment:
|
||||
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@postgres:5432/tradein"
|
||||
DATABASE_URL: "postgresql+psycopg://${TRADEIN_POSTGRES_USER:-tradein}:${TRADEIN_POSTGRES_PASSWORD}@tradein-postgres:5432/tradein"
|
||||
ENVIRONMENT: "production"
|
||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
||||
# Этот процесс — не scheduler_main; false на всякий случай, если общий
|
||||
|
|
@ -468,7 +468,15 @@ services:
|
|||
# только свой hostname-интерфейс и недоступен как tradein-frontend:3000.
|
||||
# Принудительно 0.0.0.0 чтобы Caddy достучался.
|
||||
HOSTNAME: "0.0.0.0"
|
||||
BACKEND_URL: "http://backend:8000" # internal SSR
|
||||
# ИМЯ ОБЯЗАНО БЫТЬ ОДНОЗНАЧНЫМ. Оба продукта назвали свой сервис
|
||||
# `backend`, и оба подключены к общей сети gendesign_shared —
|
||||
# изнутри фронта `backend` резолвится в ДВА адреса (tradein 172.18.0.6
|
||||
# и ПТИЦА 172.18.0.9), клиент берёт любой. Попав в ПТИЦУ, SSR получает
|
||||
# 401 и рендерит лендинг БЕЗ витрины — без ленты, строк сверки и
|
||||
# подписи разброса, то есть страница про точность без доказательств.
|
||||
# Отказ тихий: fetch не бросает, приходит валидный 401.
|
||||
# `tradein-backend` — container_name, он однозначен.
|
||||
BACKEND_URL: "http://tradein-backend:8000" # internal SSR
|
||||
depends_on: [backend]
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue