Merge pull request 'P1: лендинг отдавался без витрины — имя сервиса двоится между продуктами' (#3282) from fix/mera-ambiguous-service-names into main
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Successful in 43s
Deploy Trade-In / build-frontend (push) Successful in 2m29s
Deploy Trade-In / test (push) Has been cancelled
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / perimeter-smoke (push) Blocked by required conditions
Deploy Trade-In / deploy-status (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 14s
Deploy Trade-In / build-browser (push) Successful in 43s
Deploy Trade-In / build-frontend (push) Successful in 2m29s
Deploy Trade-In / test (push) Has been cancelled
This commit is contained in:
commit
da6a6b6e82
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 --selftest
|
||||||
python3 scripts/check-caddy-snippet-mounts.py
|
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 синтаксически валиден"
|
- name: "Guard: Caddyfile синтаксически валиден"
|
||||||
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
# Тем же шагом-соседом и по той же причине, что два гейта рядом: бежит
|
||||||
# на КАЖДОМ PR, стоит секунды, падение блокирует merge.
|
# на КАЖДОМ 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 — существующее состояние ответа (так же
|
# Деградация источника в None — существующее состояние ответа (так же
|
||||||
# ведёт себя таймаут), контракт API не меняется.
|
# ведёт себя таймаут), контракт API не меняется.
|
||||||
ESTIMATE_EXTERNAL_SOURCES_BACKGROUND: "true"
|
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"
|
PUBLIC_URL: "https://gendsgn.ru/trade-in"
|
||||||
CORS_ORIGINS: '["https://gendsgn.ru"]'
|
CORS_ORIGINS: '["https://gendsgn.ru"]'
|
||||||
ENVIRONMENT: "production"
|
ENVIRONMENT: "production"
|
||||||
|
|
@ -373,7 +373,7 @@ services:
|
||||||
- path: ./backend/.env.runtime
|
- path: ./backend/.env.runtime
|
||||||
required: false
|
required: false
|
||||||
environment:
|
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"
|
ENVIRONMENT: "production"
|
||||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
||||||
GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}"
|
GENDESIGN_FDW_PASSWORD: "${GENDESIGN_FDW_PASSWORD:-}"
|
||||||
|
|
@ -432,7 +432,7 @@ services:
|
||||||
- path: ./backend/.env.runtime
|
- path: ./backend/.env.runtime
|
||||||
required: false
|
required: false
|
||||||
environment:
|
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"
|
ENVIRONMENT: "production"
|
||||||
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
GLITCHTIP_DSN: "${GLITCHTIP_DSN:-}"
|
||||||
# Этот процесс — не scheduler_main; false на всякий случай, если общий
|
# Этот процесс — не scheduler_main; false на всякий случай, если общий
|
||||||
|
|
@ -468,7 +468,15 @@ services:
|
||||||
# только свой hostname-интерфейс и недоступен как tradein-frontend:3000.
|
# только свой hostname-интерфейс и недоступен как tradein-frontend:3000.
|
||||||
# Принудительно 0.0.0.0 чтобы Caddy достучался.
|
# Принудительно 0.0.0.0 чтобы Caddy достучался.
|
||||||
HOSTNAME: "0.0.0.0"
|
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]
|
depends_on: [backend]
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue