fix(sf): psql stdin-bootstrap пароля gendesign_reader + COALESCE-protect внешних id (review fixup, #976)
All checks were successful
CI / changes (pull_request) Successful in 6s
CI / changes (push) Successful in 7s
CI / frontend-tests (push) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / backend-tests (push) Successful in 6m23s
CI / backend-tests (pull_request) Successful in 6m21s

- Создан tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql:
  set_config GUC + format('%L') + \o /dev/null — зеркальный паттерн
  ops/db-bootstrap/set_tradein_fdw_password.sql. Пароль не попадает
  в stdout/logs (leak-protected).
- deploy-tradein.yml: шаг bootstrap переписан на stdin-redirect
  (< ops/db-bootstrap/set_gendesign_reader_password.sql) вместо сломанного
  -c "ALTER ROLE ... PASSWORD :'TRADEIN_READER_PASSWORD'", которое давало
  syntax error at ':' (psql не интерполирует :'var' внутри -c строки).
- newbuilding_crossload.py ON CONFLICT DO UPDATE: yandex_jk_id и
  cian_internal_house_id → COALESCE(EXCLUDED.col, table.col), чтобы
  cian-запись не затирала yandex_jk_id NULL'ом и наоборот.
- Тест test_upsert_sql_coalesce_external_ids проверяет наличие COALESCE
  в DO UPDATE секции для обоих id.
This commit is contained in:
bot-backend 2026-06-12 11:26:40 +03:00
parent 1a4778d006
commit 04da30b57a
4 changed files with 94 additions and 5 deletions

View file

@ -322,14 +322,19 @@ jobs:
# SQL migration 101_gendesign_reader_role.sql creates role passwordless;
# password lives only in /opt/gendesign/tradein-mvp/.env.runtime.
# .env.runtime already sourced above (set -a; source .env.runtime).
#
# ⚠️ psql variable substitution (:'pw') НЕ работает внутри -c
# (переменная доходит до сервера as literal → syntax error at ':').
# Решение: передаём SQL через stdin (< file), psql интерполирует
# :'pw' ВНЕ dollar-quoted блока. Детали: ops/db-bootstrap/set_gendesign_reader_password.sql.
if [ -n "${TRADEIN_READER_PASSWORD:-}" ]; then
echo "→ Applying gendesign_reader password from env"
docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \
psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -v ON_ERROR_STOP=on \
-c "ALTER ROLE gendesign_reader PASSWORD :'TRADEIN_READER_PASSWORD'" \
-v "TRADEIN_READER_PASSWORD=${TRADEIN_READER_PASSWORD}"
-v "pw=${TRADEIN_READER_PASSWORD}" \
< ops/db-bootstrap/set_gendesign_reader_password.sql
else
echo "⚠️ TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться"
fi
# Retry backend lifespan hook AFTER migrations applied.

View file

@ -123,8 +123,12 @@ _UPSERT_SQL = text("""
rating = EXCLUDED.rating,
reviews_count = EXCLUDED.reviews_count,
houses_by_turn = EXCLUDED.houses_by_turn,
cian_internal_house_id = EXCLUDED.cian_internal_house_id,
yandex_jk_id = EXCLUDED.yandex_jk_id,
cian_internal_house_id = COALESCE(
EXCLUDED.cian_internal_house_id,
newbuilding_listings.cian_internal_house_id),
yandex_jk_id = COALESCE(
EXCLUDED.yandex_jk_id,
newbuilding_listings.yandex_jk_id),
raw_payload = EXCLUDED.raw_payload,
source_last_scraped_at = EXCLUDED.source_last_scraped_at,
crossloaded_at = now()

View file

@ -143,3 +143,29 @@ def test_upsert_sql_contains_do_update():
"""UPSERT-запрос обновляет строку при конфликте (DO UPDATE SET)."""
sql_text = str(_UPSERT_SQL)
assert "DO UPDATE SET" in sql_text.upper()
def test_upsert_sql_coalesce_external_ids():
"""yandex_jk_id и cian_internal_house_id используют COALESCE в DO UPDATE.
Источники cian/yandex пишут попеременно важно не затирать чужой id NULL'ом.
Проверяем: в секции DO UPDATE каждая из двух колонок встречается после COALESCE,
а не как голое EXCLUDED.<col> (что затёрло бы чужой id NULL'ом).
"""
sql_text = str(_UPSERT_SQL)
sql_upper = sql_text.upper()
do_update_idx = sql_upper.index("DO UPDATE SET")
do_update_section = sql_upper[do_update_idx:]
# COALESCE должен присутствовать в секции DO UPDATE
assert "COALESCE" in do_update_section, "DO UPDATE должен содержать COALESCE"
# yandex_jk_id — строка вида "YANDEX_JK_ID = COALESCE("
assert (
"YANDEX_JK_ID = COALESCE(" in do_update_section
), "yandex_jk_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"
# cian_internal_house_id — строка вида "CIAN_INTERNAL_HOUSE_ID = COALESCE("
assert (
"CIAN_INTERNAL_HOUSE_ID = COALESCE(" in do_update_section
), "cian_internal_house_id в DO UPDATE должен использовать COALESCE чтобы не затирать NULL'ом"

View file

@ -0,0 +1,54 @@
-- Set gendesign_reader password from env.
-- Applied by .forgejo/workflows/deploy-tradein.yml after migrations:
-- psql -v pw="$TRADEIN_READER_PASSWORD" -f ops/db-bootstrap/set_gendesign_reader_password.sql
-- cwd на деплое = /opt/gendesign/tradein-mvp (путь относительный).
--
-- Idempotent: ALTER если роль существует, NOTICE и продолжает если нет
-- (migration 101_gendesign_reader_role.sql может ещё не примениться на первом деплое).
-- Пароль НИКОГДА не хранится в этом файле или в git — только имя переменной.
--
-- Format %L экранирует пароль как SQL string literal — безопасно даже с кавычками.
--
-- psql variable substitution (:'pw') НЕ интерполируется внутри dollar-quoted
-- блока ($$...$$) — это правило psql, не bug. Поэтому password передаём в DO
-- через сессионный GUC (set_config), который psql интерполирует ВНЕ dollar
-- quote, и читаем внутри через current_setting().
-- Reference incident: deploy 2026-05-24 (post-merge PR #503) упал на
-- "syntax error at or near ':'" — :'pw' дошёл до сервера as literal вместо
-- интерполяции при использовании -c вместо stdin + файл.
--
-- Источник переменной pw: TRADEIN_READER_PASSWORD из tradein-mvp/.env.runtime.
-- Передаётся через: psql -v "pw=${TRADEIN_READER_PASSWORD}"
--
-- ⚠️ `set_config(name, value, is_local) -> text` ВОЗВРАЩАЕТ установленное
-- значение. Без `\o /dev/null` psql напечатал бы пароль на stdout → leak в
-- Forgejo Actions deploy logs (retained, visible всем с repo read access).
-- Поэтому оборачиваем оба set_config вызова в `\o /dev/null` / `\o` brackets
-- — output mutes только для этих строк, NOTICE-сообщения из DO block
-- (idempotency signal) остаются видимыми.
--
-- Rollback path: НЕ revert этого файла (вернёт сломанный :'pw' внутри $$).
-- Корректный rollback — unset TRADEIN_READER_PASSWORD в
-- /opt/gendesign/tradein-mvp/.env.runtime на VPS, deploy-tradein.yml
-- тогда пропустит этот шаг полностью.
\o /dev/null
SELECT set_config('app.reader_pw', :'pw', false);
\o
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'gendesign_reader') THEN
EXECUTE format('ALTER ROLE gendesign_reader WITH PASSWORD %L', current_setting('app.reader_pw'));
RAISE NOTICE 'gendesign_reader password set';
ELSE
RAISE NOTICE 'gendesign_reader role missing — migration 101_gendesign_reader_role.sql not applied yet';
END IF;
END $$;
-- Clear GUC after use (defense-in-depth — не оставляем password в session state
-- даже на short connection). Same \o trick — set_config return value is empty
-- string here, но всё равно лишний row в stdout не нужен.
\o /dev/null
SELECT set_config('app.reader_pw', '', false);
\o