diff --git a/.forgejo/workflows/deploy-tradein.yml b/.forgejo/workflows/deploy-tradein.yml index 3e1428fd..71ee7f53 100644 --- a/.forgejo/workflows/deploy-tradein.yml +++ b/.forgejo/workflows/deploy-tradein.yml @@ -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. diff --git a/backend/app/services/etl/newbuilding_crossload.py b/backend/app/services/etl/newbuilding_crossload.py index 833959ed..6f101599 100644 --- a/backend/app/services/etl/newbuilding_crossload.py +++ b/backend/app/services/etl/newbuilding_crossload.py @@ -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() diff --git a/backend/tests/services/test_newbuilding_crossload.py b/backend/tests/services/test_newbuilding_crossload.py index 4cb732cd..232cc7f1 100644 --- a/backend/tests/services/test_newbuilding_crossload.py +++ b/backend/tests/services/test_newbuilding_crossload.py @@ -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. (что затёрло бы чужой 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'ом" diff --git a/tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql b/tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql new file mode 100644 index 00000000..cb6575b3 --- /dev/null +++ b/tradein-mvp/ops/db-bootstrap/set_gendesign_reader_password.sql @@ -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