name: Deploy Trade-In # Forgejo Actions — отдельный pipeline для подпроекта tradein-mvp/. # Триггерится только на изменения внутри tradein-mvp/ (или этого workflow), # не пересекается с основным deploy.yml. # ── ПОДЛИННОСТЬ ХОСТА (#3029) ──────────────────────────────────────────────── # Переезд 30.08 (#3057) уводит цель деплоя на Selectel, а Forgejo и раннеры # оставляет на Beget — SSH перестаёт быть петлёй и идёт через интернет. В этом # workflow ДВА разных SSH-канала, и закрываются они по-разному: # 1) шаг "Deploy via SSH" (appleboy/ssh-action) → вход `fingerprint`, # секрет DEPLOY_SSH_FINGERPRINT; # 2) шаг "Resolve deployed base SHA" — обычный openssh-клиент, ему нужен # known_hosts, а не SHA256-строка → секрет DEPLOY_KNOWN_HOSTS. # Как снять значения: # ssh-keyscan -t ecdsa -p <порт> <хост> | ssh-keygen -lf - | awk '{print $2}' # → DEPLOY_SSH_FINGERPRINT (с префиксом `SHA256:`; почему именно ecdsa — # см. разбор в deploy.yml: дефолт x/crypto ставит ecdsa выше ed25519) # ssh-keyscan -p <порт> <хост> # → DEPLOY_KNOWN_HOSTS (все типы ключей сразу, без -t) # ПОБАЙТОВО: fingerprint сравнивается как есть, без trim — лишний пробел или # перевод строки при копипасте включает проверку и роняет ssh-шаг с `host key # fingerprint mismatch`. # ПОКА СЕКРЕТЫ НЕ ЗАДАНЫ — поведение прежнее в обоих каналах: пустой fingerprint # у easyssh-proxy v1.5.0 это ssh.InsecureIgnoreHostKey(), а второй шаг остаётся # на StrictHostKeyChecking=no, но печатает громкое предупреждение. Включается # одной настройкой, как INFRA_DEPLOY_HOST (#3059) и fail-open у # TRADEIN_INTERNAL_AUTH_SECRET (#2989). Ничего не удаляем — только добавляем. # ───────────────────────────────────────────────────────────────────────────── on: push: branches: [main] paths: - "tradein-mvp/**" - ".forgejo/workflows/deploy-tradein.yml" workflow_dispatch: # #2950: ОБЩАЯ группа с deploy-tradein.yml — не опечатка и не копипаста. # Оба деплоя ходят по SSH в ОДИН докер-демон (стеки gendesign-* и tradein-* # плюс сам forgejo-runner живут на одной VM), и `docker image prune -af` одного # сносит leases ещё не доехавшего `compose pull` другого: # unable to lease content: lease does not exist: not found # 20.08 так и вышло: run 8083 упал за 5с — прун соседнего деплоя отработал через # 0.4с после обрыва пула. Прод остался на старом коде, при том что голова main # показывала success (зелёным был чужой, Trade-In'овый деплой той же головы). # Разные группы + cancel-in-progress: false не спасают: false сериализует раны # ВНУТРИ группы, а гонка была МЕЖДУ группами. # Цена: деплои ждут друг друга целиком, вместе с билдами (~6 мин). Осознанно: # host-lock (flock) сериализовал бы только докер-секцию, но у него своя отказная # мода — дочерний процесс наследует fd лока и при аварийной смерти job'а лок # залипает (проверено на хосте: после kill -9 лок остался занят). Сериализацию # гарантирует планировщик Forgejo, залипать там нечему. concurrency: group: deploy-prod cancel-in-progress: false env: IMAGE_BACKEND: ghcr.io/lekss361/gendesign-tradein-backend IMAGE_FRONTEND: ghcr.io/lekss361/gendesign-tradein-frontend IMAGE_BROWSER: ghcr.io/lekss361/gendesign-tradein-browser jobs: changes: runs-on: ubuntu-latest outputs: backend: ${{ steps.set-all.outputs.backend || steps.filter.outputs.backend }} frontend: ${{ steps.set-all.outputs.frontend || steps.filter.outputs.frontend }} browser: ${{ steps.set-all.outputs.browser || steps.filter.outputs.browser }} infra: ${{ steps.set-all.outputs.infra || steps.filter.outputs.infra }} # Отдельного `scraper`-признака больше нет (#2679) — см. SCRAPER_RECREATE # в job deploy: scraper/tgbot бегут ТОТ ЖЕ образ, что и backend. app_version: ${{ steps.build-meta.outputs.app_version }} build_sha: ${{ steps.build-meta.outputs.build_sha }} build_date: ${{ steps.build-meta.outputs.build_date }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 # Версия продукта «Мера» (tradein-mvp/VERSION — единственный источник # правды, см. tradein-mvp/CHANGELOG.md) + короткий SHA + дата сборки — # проброшены как build-args в build-backend/build-frontend ниже (см. # tradein-mvp/backend/Dockerfile + tradein-mvp/frontend/Dockerfile). # Считается ОДИН раз здесь, а не в каждой job отдельно. - name: Resolve build metadata (APP_VERSION / BUILD_SHA / BUILD_DATE) id: build-meta run: | echo "app_version=$(tr -d '[:space:]' < tradein-mvp/VERSION)" >> "$GITHUB_OUTPUT" echo "build_sha=${GITHUB_SHA:0:7}" >> "$GITHUB_OUTPUT" echo "build_date=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT" # Resolve base SHA: read last-successfully-deployed SHA from the VPS host file. # The file is written by the deploy job on every successful deploy. # Fail-safe: if we cannot read the file, or the SHA is not an ancestor of HEAD, # we leave DEPLOYED_SHA empty — the next step will then build everything. - name: Resolve deployed base SHA id: resolve-base env: DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} DEPLOY_USER: ${{ secrets.DEPLOY_USER }} DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} # #3029: строки known_hosts прод-хоста. DEPLOY_SSH_FINGERPRINT здесь НЕ # подходит: ниже обычный openssh-клиент, а не Go-клиент ssh-action'а, и # SHA256-отпечаток он на вход не принимает — ему нужен known_hosts. # Получить: ssh-keyscan -p <порт> <хост> (без -t: пусть в секрете лежат # все типы ключей сразу, тогда выбор алгоритма клиентом ничего не ломает). DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} run: | # Write SSH key to a temp file SSH_KEY_FILE=$(mktemp) echo "$DEPLOY_SSH_KEY" > "$SSH_KEY_FILE" chmod 600 "$SSH_KEY_FILE" # #3029: подлинность хоста для ЭТОГО канала. Раньше здесь стояло # безусловное -o StrictHostKeyChecking=no, то есть ключ хоста не # проверялся никогда. После переезда (#3057) соединение идёт через # интернет, поэтому: секрет задан → пишем known_hosts и требуем # StrictHostKeyChecking=yes; не задан → оставляем ровно сегодняшнее # поведение, но ГРОМКО об этом сообщаем. Инертно по умолчанию: пустой # секрет = поведение до этого PR бит в бит. KNOWN_HOSTS_FILE=$(mktemp) if [ -n "${DEPLOY_KNOWN_HOSTS:-}" ]; then printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$KNOWN_HOSTS_FILE" chmod 600 "$KNOWN_HOSTS_FILE" SSH_HOST_OPTS=(-o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$KNOWN_HOSTS_FILE") echo "Подлинность хоста: сверяется по DEPLOY_KNOWN_HOSTS." else SSH_HOST_OPTS=(-o StrictHostKeyChecking=no) echo "::warning title=SSH без проверки подлинности хоста::DEPLOY_KNOWN_HOSTS не задан — ключ прод-хоста НЕ проверяется (#3029). После переезда на Selectel (#3057) этот SSH идёт через интернет: задайте секрет через ssh-keyscan -p <порт> <хост>." echo "################################################################" echo "# ВНИМАНИЕ (#3029): DEPLOY_KNOWN_HOSTS не задан. #" echo "# Подлинность прод-хоста НЕ проверяется — канал уязвим к MITM. #" echo "# Задать секрет: ssh-keyscan -p <порт> <хост> #" echo "################################################################" fi # Try to read the marker file from the VPS. Suppress errors — if host is # unreachable or file missing, RAW_SHA will be empty. # #3029: сюда же попадает и расхождение ключа хоста. Шаг fail-safe по # построению — пустой RAW_SHA уводит в build-all ниже, — поэтому цена # ошибки в known_hosts здесь максимум лишняя полная пересборка, а не # сорванный деплой. Это и делает включение проверки безопасным. SSH_ERR_FILE=$(mktemp) RAW_SHA=$(ssh -i "$SSH_KEY_FILE" \ "${SSH_HOST_OPTS[@]}" \ -o ConnectTimeout=10 \ -p "${DEPLOY_PORT:-22}" \ "${DEPLOY_USER}@${DEPLOY_HOST}" \ "cat /opt/gendesign/.tradein-deployed-sha 2>/dev/null || true" \ 2>"$SSH_ERR_FILE" || true) RAW_SHA=$(echo "$RAW_SHA" | tr -d '[:space:]') # #3029: раньше stderr уходил в /dev/null, и «Host key verification # failed» был неотличим от недоступного хоста — неверный known_hosts # молча читался как штатный фолбэк на полную пересборку. Теперь эта # причина называется отдельно. Fail-safe шага не меняется: RAW_SHA всё # равно пуст, ветка build-all включается ровно как прежде. if grep -qiE 'host key verification failed|remote host identification has changed|no matching host key' "$SSH_ERR_FILE"; then echo '::warning title=Ключ хоста не сошёлся с DEPLOY_KNOWN_HOSTS::Проверка подлинности хоста НЕ прошла (#3029) — это не «хост недоступен», а расхождение known_hosts: сменился ключ хоста либо переехал адрес (#3057). Обновите секрет DEPLOY_KNOWN_HOSTS через ssh-keyscan -p <порт> <хост>. Шаг fail-safe: сейчас включится полная пересборка.' echo "Причина пустого RAW_SHA: проверка ключа хоста, а не недоступность." sed 's/^/ ssh: /' "$SSH_ERR_FILE" elif [ -s "$SSH_ERR_FILE" ]; then echo "ssh stderr (не про ключ хоста — хост недоступен либо иная ошибка):" sed 's/^/ ssh: /' "$SSH_ERR_FILE" fi rm -f "$SSH_KEY_FILE" "$KNOWN_HOSTS_FILE" "$SSH_ERR_FILE" # Validate: non-empty, looks like a git SHA, and is an ancestor of HEAD. DEPLOYED_SHA="" if [ -n "$RAW_SHA" ] && echo "$RAW_SHA" | grep -qE '^[0-9a-f]{40}$'; then if git merge-base --is-ancestor "$RAW_SHA" HEAD 2>/dev/null; then DEPLOYED_SHA="$RAW_SHA" echo "Resolved deployed base: $DEPLOYED_SHA" else echo "WARNING: stored SHA $RAW_SHA is not an ancestor of HEAD — falling back to build-all" fi else echo "No valid deployed SHA found — falling back to build-all" fi echo "deployed_sha=$DEPLOYED_SHA" >> "$GITHUB_OUTPUT" # FAIL-SAFE: if no valid base SHA, emit all=true and skip paths-filter. # This covers: first run, post-force-push, VPS unreachable, corrupt marker. # A spurious full build is always safer than a missed build. - name: Build-all fallback (no base SHA) id: set-all if: steps.resolve-base.outputs.deployed_sha == '' run: | echo "No base SHA — enabling build-all" echo "backend=true" >> "$GITHUB_OUTPUT" echo "frontend=true" >> "$GITHUB_OUTPUT" echo "browser=true" >> "$GITHUB_OUTPUT" echo "infra=true" >> "$GITHUB_OUTPUT" # Cumulative diff: compare deployed SHA → HEAD so that a fast chain of merges # (e.g. backend #1829 then frontend #1830) doesn't lose earlier changes. - uses: dorny/paths-filter@v3 id: filter if: steps.resolve-base.outputs.deployed_sha != '' with: base: ${{ steps.resolve-base.outputs.deployed_sha }} filters: | backend: - 'tradein-mvp/backend/**' # scraper-kit вкомпилирован в backend-образ (build context tradein-mvp/, # scheduler_main импортирует пакет) — kit-only изменение обязано # пересобрать образ, иначе деплой рестартует контейнеры на старом. - 'tradein-mvp/packages/scraper-kit/**' # APP_VERSION запекается build-arg'ом в backend-образ (см. build-backend # ниже + backend/Dockerfile + app/core/version.py) — bump версии БЕЗ # правок кода обязан пересобрать образ, иначе GET /version и колонтитул # PDF продолжат отдавать старое значение при формально «успешном» деплое. - 'tradein-mvp/VERSION' frontend: - 'tradein-mvp/frontend/**' # NEXT_PUBLIC_APP_VERSION build-time (см. frontend/Dockerfile) — та же # причина, что у backend выше. - 'tradein-mvp/VERSION' # /versions статически запекает CHANGELOG.md в билд (см. # frontend/src/app/versions/page.tsx) — правка одного файла БЕЗ # frontend/** иначе не долетала бы до образа. - 'tradein-mvp/CHANGELOG.md' browser: - 'tradein-mvp/browser/**' infra: - 'tradein-mvp/docker-compose.prod.yml' - 'tradein-mvp/deploy/**' - '.forgejo/workflows/deploy-tradein.yml' # УДАЛЁН фильтр `scraper` (#2679, 2026-08-05). Он был allowlist'ом # «файлов, которые исполняет планировщик», и перечислял только то, # что вспомнили. Дважды выстрелило одинаково: # 2026-07-02 (#2188) — fias-dedup доехал до tradein-backend, но не # до tradein-scraper; починили ДОБАВЛЕНИЕМ путей (matching/**, # house_dedup_merge.py) — залатали случай, не механизм; # 2026-08-05 (#2675) — house_imv_backfill.py + product_handlers.py # в списке не значились → планировщик час крутил старый код, # деплой при этом отчитался успехом. # За июнь-август 48% (193 из 402) backend-мержей не попадали ни в # один из путей списка, т.е. половина правок доезжала до scraper'а # только со следующим «удачным» деплоем. Теперь пересоздание # привязано не к списку файлов, а к факту пересборки образа — # см. SCRAPER_RECREATE в job deploy. # Quality gate: pytest MUST pass before any image is built/deployed (#666). # Runs the tradein-mvp/backend suite; a red test blocks build + deploy. # Tests use mocks + a stub DATABASE_URL — no real Postgres/Redis needed. # 1 pre-existing order-dependent test is deselected (see DESELECT note below). test: runs-on: ubuntu-latest needs: changes if: | needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' defaults: run: working-directory: ./tradein-mvp/backend env: # psycopg v3 requires a parseable URL at import time; never connected to. DATABASE_URL: postgresql+psycopg://test:test@localhost:5432/test steps: - uses: actions/checkout@v4 with: # Как в ci-tradein.yml: tests/test_migration_numbering.py (#2683) требует # origin/main и общего предка с HEAD, а даёт их именно depth=0 — при # depth=1 checkout тянет один sha и ветки main в клоне нет. fetch-depth: 0 - name: Install uv # Официальный standalone-инсталлер: системный `pip install uv` на # ubuntu-runner падает с PEP 668 externally-managed-environment (#666 CI). run: | curl -LsSf https://astral.sh/uv/install.sh | sh echo "$HOME/.local/bin" >> "$GITHUB_PATH" - name: Sync deps (incl. dev group — pytest) # Workspace-лок tradein-mvp/uv.lock TRACKED (с воркспейса #2137; gitignored # только старый backend/uv.lock) → --frozen детерминирован и зеркалит # Dockerfile (uv sync --frozen --no-dev). Актуализировано в #2208. run: uv sync --frozen - name: Run pytest (tradein-mvp/backend) # БЕЗ deselect'ов — сьют гоняется целиком, как в ci-tradein.yml. # # Здесь жил `--deselect tests/test_search_api.py::test_search_cache_hit` с # объяснением «падает ТОЛЬКО в whole-suite ordering, в изоляции проходит — # global-state leak из другого модуля». Объяснение было неверным в обеих # половинах: тест падал и в изоляции тоже (401 vs 200), потому что ходил в # /api/v1/search БЕЗ заголовка X-Authenticated-User, а RBAC-гард отвечает на # такое 401. Причина была в самом тесте; заголовок добавлен в #2729, и в # pre-merge гейте deselect снят тогда же. Здесь строка пережила починку ещё # на месяц — файл был занят открытым #2680. Тот смержен, долг закрыт. # # Не добавлять сюда новые deselect'ы: молча выключенный тест — тот же класс # дефекта, что каталог вне пайплайна (#2722). Тест либо чинится, либо # помечается xfail с причиной В КОДЕ, рядом с самим тестом. # # `-rs`: каждый пропуск печатает причину (#2745). Ожидание в этом лэйне — # 13 пропусков, все объявлены в tests/skip_allowlist.txt; неучтённый # пропуск роняет прогон через хук в tests/conftest.py. run: uv run pytest -q -rs build-backend: runs-on: ubuntu-latest needs: [changes, test] if: | needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions) env: GHCR_PAT: ${{ secrets.GHCR_PAT }} run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin - name: Подобрать протёкшие buildx-билдеры (#2869) # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних # созданы 13.08 11:57:43 — ровно тот прогон, что упал с # `no space left on device`. # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не # принадлежит живому прогону (самый долгий job — ~17 минут). run: | now=$(date +%s); reaped=0; kept=0 for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue ts=$(date -d "$created" +%s 2>/dev/null) || continue age_h=$(( (now - ts) / 3600 )) if [ "$age_h" -ge 6 ]; then echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" if docker rm -f "$c" >/dev/null 2>&1; then reaped=$((reaped+1)) else echo "buildx: не удалось убрать $c (не фатально)" fi docker volume rm "${c}_state" >/dev/null 2>&1 || true else kept=$((kept+1)) fi done echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" df -h / | tail -1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 id: buildx - name: Build & push tradein-backend # id + continue-on-error: битый blob в удалённом buildcache-манифесте # валит весь шаг ДО push нового образа — деплой тогда молча # пропускается (#2841), хотя собрать образ можно и без кеша. Ретрай # без cache-from — ниже. id: build continue-on-error: true uses: docker/build-push-action@v6 with: # Context = tradein-mvp/ (uv workspace root): образу нужен packages/scraper-kit # для editable install (#2137). Dockerfile — в backend/. context: ./tradein-mvp file: ./tradein-mvp/backend/Dockerfile push: true labels: | org.opencontainers.image.revision=${{ github.sha }} # APP_VERSION/BUILD_SHA/BUILD_DATE → runtime env в образе (см. # backend/Dockerfile ARG→ENV) — читает app/core/version.py: # GET /api/v1/trade-in/version + колонтитул PDF-отчёта. build-args: | APP_VERSION=${{ needs.changes.outputs.app_version }} BUILD_SHA=${{ needs.changes.outputs.build_sha }} BUILD_DATE=${{ needs.changes.outputs.build_date }} cache-from: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} - name: Retry build & push tradein-backend без кеша (битый buildcache, #2841) # cache-from опущен (источник падения), cache-to ОСТАВЛЕН (ревью #2841 R2, # issue #2): успешный ретрай перезаписывает битый buildcache-тег своими # слоями (mode=max) — это и есть самолечение. Без cache-to здесь порча # оставалась навсегда, следующий прогон снова падал на том же cache-from. if: steps.build.outcome == 'failure' uses: docker/build-push-action@v6 with: context: ./tradein-mvp file: ./tradein-mvp/backend/Dockerfile push: true labels: | org.opencontainers.image.revision=${{ github.sha }} build-args: | APP_VERSION=${{ needs.changes.outputs.app_version }} BUILD_SHA=${{ needs.changes.outputs.build_sha }} BUILD_DATE=${{ needs.changes.outputs.build_date }} cache-to: type=registry,ref=${{ env.IMAGE_BACKEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_BACKEND }}:latest ${{ env.IMAGE_BACKEND }}:${{ github.sha }} - name: Проверить, что tradein-backend:${{ github.sha }} реально в registry (fail-safe, #2841 R2) # НЕ полагается на семантику steps.build.outcome/continue-on-error раннера — # проверяет РЕАЛЬНОЕ состояние registry через buildx (уже настроен выше). # Если act_runner не заполняет outcome, ретрай выше молча НЕ побежит при # упавшем build — этот шаг единственный это заметит: манифеста с этим SHA # не будет → шаг падает БЕЗ continue-on-error → job честно FAILURE → deploy # ниже пропускается вместо накатки старого :latest на прод. run: docker buildx imagetools inspect ${{ env.IMAGE_BACKEND }}:${{ github.sha }} > /dev/null - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в # их `_state`-томах — диск ушёл на 94%, деплой упал с # `no space left on device`. Убираем явно, `if: always()` и `|| true`, # чтобы уборка не могла уронить прогон. if: always() run: | name="${{ steps.buildx.outputs.name }}" if [ -z "$name" ]; then echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" exit 0 fi echo "buildx: убираю билдер $name" docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" build-frontend: runs-on: ubuntu-latest needs: changes if: | needs.changes.outputs.frontend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions) env: GHCR_PAT: ${{ secrets.GHCR_PAT }} run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin - name: Подобрать протёкшие buildx-билдеры (#2869) # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних # созданы 13.08 11:57:43 — ровно тот прогон, что упал с # `no space left on device`. # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не # принадлежит живому прогону (самый долгий job — ~17 минут). run: | now=$(date +%s); reaped=0; kept=0 for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue ts=$(date -d "$created" +%s 2>/dev/null) || continue age_h=$(( (now - ts) / 3600 )) if [ "$age_h" -ge 6 ]; then echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" if docker rm -f "$c" >/dev/null 2>&1; then reaped=$((reaped+1)) else echo "buildx: не удалось убрать $c (не фатально)" fi docker volume rm "${c}_state" >/dev/null 2>&1 || true else kept=$((kept+1)) fi done echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" df -h / | tail -1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 id: buildx # CHANGELOG.md живёт в tradein-mvp/, ОДИН уровень выше build context # (./tradein-mvp/frontend) — Docker не пускает COPY за пределы контекста, # поэтому копируем внутрь ДО build. /versions статически запекает его # содержимое (см. frontend/src/lib/changelog.ts + Dockerfile builder-stage # комментарий). Не влияет на кэш другого шага — читается только этим. - name: Stage CHANGELOG.md into frontend build context run: cp tradein-mvp/CHANGELOG.md tradein-mvp/frontend/CHANGELOG.md - name: Build & push tradein-frontend # id + continue-on-error — см. tradein-backend (#2841): битый blob в # удалённом buildcache не должен ронять сборку и молча пропускать деплой. id: build continue-on-error: true uses: docker/build-push-action@v6 with: context: ./tradein-mvp/frontend push: true labels: | org.opencontainers.image.revision=${{ github.sha }} # basePath=/trade-in baked-in во время build (Next.js) # NB (#2205): НЕ передаём NEXT_PUBLIC_ENABLE_PREVIEW — preview-роут # (/ui-preview/estimate, статичная demo-фикстура) собирается ТОЛЬКО в # dev/CI (a11y/lighthouse). В прод-образе флаг не задан → страница # уходит в notFound (404), не индексируется и не краулится. # NEXT_PUBLIC_APP_VERSION/BUILD_SHA/BUILD_DATE — build-time (Next.js # инлайнит NEXT_PUBLIC_* в статику, runtime env их не подхватит, # см. frontend/Dockerfile комментарий у соответствующих ARG). # NEXT_PUBLIC_YM_ID/GA_ID/YANDEX_VERIFICATION/GOOGLE_VERIFICATION — # ПОКА ПУСТЫЕ: владелец ещё не завёл счётчики Метрики/GA4 и # мета-теги верификации поисковых консолей. Пустая строка = скрипт # счётчика НЕ рендерится вообще (контракт фронта, см. тот же # Dockerfile-комментарий). Когда номера появятся — вписать # литералом сюда И в retry-блок ниже (оба обязательны, иначе # ретрай без кеша уедет без счётчика), и это ТРЕБУЕТ пересборки # образа (build-time bake, не runtime-правка на проде). build-args: | NEXT_PUBLIC_BASE_PATH=/trade-in NEXT_PUBLIC_API_BASE_URL=/trade-in NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} NEXT_PUBLIC_YM_ID= NEXT_PUBLIC_GA_ID= NEXT_PUBLIC_YANDEX_VERIFICATION= NEXT_PUBLIC_GOOGLE_VERIFICATION= cache-from: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} - name: Retry build & push tradein-frontend без кеша (битый buildcache, #2841) # См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to # ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими # слоями (mode=max), это и есть самолечение. if: steps.build.outcome == 'failure' uses: docker/build-push-action@v6 with: context: ./tradein-mvp/frontend push: true labels: | org.opencontainers.image.revision=${{ github.sha }} build-args: | NEXT_PUBLIC_BASE_PATH=/trade-in NEXT_PUBLIC_API_BASE_URL=/trade-in NEXT_PUBLIC_APP_VERSION=${{ needs.changes.outputs.app_version }} NEXT_PUBLIC_BUILD_SHA=${{ needs.changes.outputs.build_sha }} NEXT_PUBLIC_BUILD_DATE=${{ needs.changes.outputs.build_date }} NEXT_PUBLIC_YM_ID= NEXT_PUBLIC_GA_ID= NEXT_PUBLIC_YANDEX_VERIFICATION= NEXT_PUBLIC_GOOGLE_VERIFICATION= cache-to: type=registry,ref=${{ env.IMAGE_FRONTEND }}:buildcache,mode=max tags: | ${{ env.IMAGE_FRONTEND }}:latest ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} - name: Проверить, что tradein-frontend:${{ github.sha }} реально в registry (fail-safe, #2841 R2) # См. tradein-backend выше — не полагается на steps.build.outcome раннера. run: docker buildx imagetools inspect ${{ env.IMAGE_FRONTEND }}:${{ github.sha }} > /dev/null - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в # их `_state`-томах — диск ушёл на 94%, деплой упал с # `no space left on device`. Убираем явно, `if: always()` и `|| true`, # чтобы уборка не могла уронить прогон. if: always() run: | name="${{ steps.buildx.outputs.name }}" if [ -z "$name" ]; then echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" exit 0 fi echo "buildx: убираю билдер $name" docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" build-browser: runs-on: ubuntu-latest needs: changes # tradein-browser несёт camoufox + Firefox-build (#905). Триггерится на # изменения browser/ или infra (compose ссылается на образ) или вручную. if: | needs.changes.outputs.browser == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' steps: - uses: actions/checkout@v4 - name: Login to GHCR (shell-based — docker/login-action@v3 unreliable под Forgejo Actions) env: GHCR_PAT: ${{ secrets.GHCR_PAT }} run: | echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin - name: Подобрать протёкшие buildx-билдеры (#2869) # Билдеры протекают НЕ на обычном падении, а когда job умирает аварийно # (ENOSPC, OOM, отмена concurrency-группой): тогда ни post-step действия, # ни завершающий шаг не выполняются — контейнер job'а уже мёртв. # Замер 13.08: 20 висящих билдеров, созданных в 8 дат за три месяца # (17.05, 30.05, 31.05, 13.06, 17.06, 20.06, 28.06, 05.07) — и ни одного # за пять недель между 05.07 и 13.08, когда аварий не было. Два последних # созданы 13.08 11:57:43 — ровно тот прогон, что упал с # `no space left on device`. # Поэтому чистим ЧУЖОЙ мусор НА ВХОДЕ: всё старше 6 часов заведомо не # принадлежит живому прогону (самый долгий job — ~17 минут). run: | now=$(date +%s); reaped=0; kept=0 for c in $(docker ps -a --filter "name=^buildx_buildkit_builder-" --format '{{.Names}}'); do created=$(docker inspect "$c" --format '{{.Created}}' 2>/dev/null) || continue ts=$(date -d "$created" +%s 2>/dev/null) || continue age_h=$(( (now - ts) / 3600 )) if [ "$age_h" -ge 6 ]; then echo "buildx: убираю протёкший билдер $c (возраст ${age_h} ч)" if docker rm -f "$c" >/dev/null 2>&1; then reaped=$((reaped+1)) else echo "buildx: не удалось убрать $c (не фатально)" fi docker volume rm "${c}_state" >/dev/null 2>&1 || true else kept=$((kept+1)) fi done echo "buildx: убрано протёкших ${reaped}, оставлено свежих ${kept}" df -h / | tail -1 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 id: buildx - name: Build & push tradein-browser # id + continue-on-error — см. tradein-backend выше (#2841): битый blob # в удалённом buildcache не должен ронять сборку и молча пропускать деплой. id: build continue-on-error: true uses: docker/build-push-action@v6 with: context: ./tradein-mvp/browser push: true labels: | org.opencontainers.image.revision=${{ github.sha }} cache-from: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max tags: | ${{ env.IMAGE_BROWSER }}:latest ${{ env.IMAGE_BROWSER }}:${{ github.sha }} - name: Retry build & push tradein-browser без кеша (битый buildcache, #2841) # См. tradein-backend (issue #2, ревью R2): cache-from опущен, cache-to # ОСТАВЛЕН — успешный ретрай перезаписывает битый buildcache-тег своими # слоями (mode=max), это и есть самолечение. if: steps.build.outcome == 'failure' uses: docker/build-push-action@v6 with: context: ./tradein-mvp/browser push: true labels: | org.opencontainers.image.revision=${{ github.sha }} cache-to: type=registry,ref=${{ env.IMAGE_BROWSER }}:buildcache,mode=max tags: | ${{ env.IMAGE_BROWSER }}:latest ${{ env.IMAGE_BROWSER }}:${{ github.sha }} - name: Проверить, что tradein-browser:${{ github.sha }} реально в registry (fail-safe, #2841 R2) # См. tradein-backend выше — не полагается на steps.build.outcome раннера. run: docker buildx imagetools inspect ${{ env.IMAGE_BROWSER }}:${{ github.sha }} > /dev/null - name: Убрать buildx-билдер (#2869 — иначе копятся по одному на прогон) # setup-buildx-action создаёт билдер `docker-container` на КАЖДЫЙ прогон. # Его post-step под Forgejo act_runner не срабатывает, поэтому к 13.08 на # хосте накопилось 20 контейнеров возрастом до двух месяцев и ~19 ГБ в # их `_state`-томах — диск ушёл на 94%, деплой упал с # `no space left on device`. Убираем явно, `if: always()` и `|| true`, # чтобы уборка не могла уронить прогон. if: always() run: | name="${{ steps.buildx.outputs.name }}" if [ -z "$name" ]; then echo "buildx: имя билдера не пришло из outputs — уборка НЕ сработала (см. #2869)" exit 0 fi echo "buildx: убираю билдер $name" docker buildx rm --force "$name" || echo "buildx: не удалось убрать $name (не фатально)" deploy: runs-on: ubuntu-latest needs: [changes, test, build-backend, build-frontend, build-browser] # NB: a failed `test` skips build-backend (result='skipped', not 'failure'), # so we must block deploy on test failure explicitly (#666 quality gate). if: | always() && !cancelled() && needs.test.result != 'failure' && needs.build-backend.result != 'failure' && needs.build-frontend.result != 'failure' && needs.build-browser.result != 'failure' steps: # ── #2950: :latest не старше последнего коммита по компоненту ───────────── # См. комментарий к тому же шагу в deploy.yml и scripts/check-latest-image-revision.sh. # Пути = фильтры job'а changes (backend/frontend/browser + infra), которые # приводят к сборке соответствующего образа. - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Login to GHCR — для imagetools inspect гарда (#2950) env: GHCR_PAT: ${{ secrets.GHCR_PAT }} run: echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin - name: Гард свежести :latest (#2950) run: | INFRA="tradein-mvp/docker-compose.prod.yml tradein-mvp/deploy .forgejo/workflows/deploy-tradein.yml" scripts/check-latest-image-revision.sh "$IMAGE_BACKEND" 900 -- tradein-mvp/backend tradein-mvp/packages/scraper-kit tradein-mvp/VERSION $INFRA scripts/check-latest-image-revision.sh "$IMAGE_FRONTEND" 900 -- tradein-mvp/frontend tradein-mvp/VERSION tradein-mvp/CHANGELOG.md $INFRA scripts/check-latest-image-revision.sh "$IMAGE_BROWSER" 900 -- tradein-mvp/browser $INFRA # #3029: ВИДИМОСТЬ, А НЕ БЛОКИРОВКА. Отсутствие проверки хоста обязано быть # громким: easyssh-proxy v1.5.0 при пустом fingerprint молча оставляет # ssh.InsecureIgnoreHostKey(), и незащищённый деплой выглядит ровно как # защищённый — зелёным. Шаг намеренно НЕ падает: секрета сегодня нет ни у # кого, отказ сломал бы деплой в момент мержа этого PR, а правило здесь — # «инертно по умолчанию, включается одной настройкой». Заведут секрет — # предупреждение исчезнет само. - name: Подлинность хоста — статус проверки (#3029) env: HOST_FINGERPRINT: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} run: | set -euo pipefail if [ -n "${HOST_FINGERPRINT:-}" ]; then echo "Подлинность хоста: сверяется по DEPLOY_SSH_FINGERPRINT." else echo '::warning title=SSH без проверки подлинности хоста::DEPLOY_SSH_FINGERPRINT не задан — ключ хоста НЕ проверяется (#3029): при пустом отпечатке easyssh-proxy молча оставляет InsecureIgnoreHostKey. По этой же SSH-сессии едут GHCR_PAT и секреты Trade-In вместе с DEPLOY_SSH_KEY. После переезда на Selectel (#3057) канал идёт через интернет. Как снять отпечаток — см. шапку этого файла.' echo '###############################################################' echo '# ВНИМАНИЕ (#3029): DEPLOY_SSH_FINGERPRINT не задан.' echo '# Ключ хоста НЕ проверяется — канал уязвим к MITM.' echo '# Как снять отпечаток — см. шапку этого файла.' echo '###############################################################' fi - name: Deploy via SSH uses: appleboy/ssh-action@v1.0.3 env: IMAGE_TAG: latest # Нужен на VPS, чтобы спросить у демона ID подтянутого образа и не # уходить в drain, когда пересоздавать нечего (см. ниже, #2679). IMAGE_BACKEND: ${{ env.IMAGE_BACKEND }} GHCR_PAT: ${{ secrets.GHCR_PAT }} # #2679: backend / scraper / tgbot — ОДИН И ТОТ ЖЕ образ # gendesign-tradein-backend (см. docker-compose.prod.yml: три сервиса, # одна строка image, разный command). Значит вопрос «пересоздавать ли # scraper» — это не «трогали ли его файлы», а «мог ли пересобраться # образ». Условие ОБЯЗАНО совпадать с `if:` джобы build-backend: # backend || infra || workflow_dispatch. Ровно тогда в реестре мог # появиться новый :latest, и оставить scraper на старом — значит # оставить планировщик на старом коде (инцидент #2679). # # Раньше здесь стоял «Phase 0»-компромисс: infra-правки намеренно НЕ # пересоздавали scraper, чтобы не убить многочасовой прогон. Компромисс # больше не нужен — с #1951 перед recreate'ом идёт graceful drain # (ждём scrape_runs до 5 мин) + startup-reap осиротевших строк, а сам # `compose up -d` на неизменившемся образе — no-op. SCRAPER_RECREATE: ${{ needs.changes.outputs.backend == 'true' || needs.changes.outputs.infra == 'true' || github.event_name == 'workflow_dispatch' }} GITHUB_SHA: ${{ github.sha }} with: host: ${{ secrets.DEPLOY_HOST }} username: ${{ secrets.DEPLOY_USER }} key: ${{ secrets.DEPLOY_SSH_KEY }} port: ${{ secrets.DEPLOY_PORT }} # #3029: подлинность хоста. Секрет НЕ задан → пустая строка → easyssh-proxy # оставляет ssh.InsecureIgnoreHostKey(), то есть сегодняшнее поведение. fingerprint: ${{ secrets.DEPLOY_SSH_FINGERPRINT }} envs: IMAGE_TAG,IMAGE_BACKEND,GHCR_PAT,SCRAPER_RECREATE,GITHUB_SHA script: | set -euo pipefail # #2950: взаимное исключение докер-секции двух прод-деплоев. # Деплой ПТИЦЫ и деплой Trade-In ходят по SSH в ОДИН докер-демон — # стеки gendesign-*, tradein-* и сам forgejo-runner живут на этой VM. # Каждый в конце делает `docker image prune -af`, и прун одного сносит # leases ещё не доехавшего `compose pull` другого: # unable to lease content: lease does not exist: not found # 20.08 так и вышло: run 8083 упал за 5с (прун соседа отработал через # 0.4с после обрыва пула), прод остался на старом коде. # # Секция `concurrency: deploy-prod` в шапке обоих workflow этого НЕ # обеспечивает: на Forgejo 10.0.3 (gitea-1.22) workflow-level # concurrency не исполняется — проверено, обе цепочки стартовали на # одном коммите одновременно. Она оставлена как декларация, которая # заработает после обновления Forgejo; сегодня работает вот этот лок. # # Лок держит живой потомок этого скрипта. Если ssh-сессия оборвётся, # докер-команды на хосте продолжат работу — и лок продолжит их # прикрывать, что и требуется. Ожидание ограничено: не дождались за # 900с — падаем с внятным сообщением, а не молча ждём вечно. exec 9>/var/lock/gendesign-docker-deploy.lock # Сначала неблокирующая попытка — чтобы ОЖИДАНИЕ оставляло след в логе. # Без этого работающий лок ненаблюдаем: flock при успехе молчит, и отличить # «второй деплой дождался первого» от «они просто разошлись по времени» # нельзя — а именно это и есть критерий приёмки #2950. if flock -n 9; then echo "→ докер-лок свободен, взят сразу" else echo "→ докер-лок занят соседним деплоем, жду (до 900с)…" lock_wait_started=$(date +%s) if ! flock -w 900 9; then echo "ERROR: не дождался лока докер-деплоя за 900с." echo " Кто держит: ssh на хост, затем fuser -v /var/lock/gendesign-docker-deploy.lock" exit 1 fi echo "→ докер-лок получен через $(( $(date +%s) - lock_wait_started ))с ожидания" fi cd /opt/gendesign # repo уже clone'ен — origin = Forgejo. Подтягиваем последний main. git fetch origin main git reset --hard origin/main cd tradein-mvp # .env.runtime создаётся вручную при первом запуске (см. README-АДМИНУ.md). # Здесь только подгружаем переменные для docker compose (POSTGRES_PASSWORD). if [ ! -f .env.runtime ]; then echo "ERROR: /opt/gendesign/tradein-mvp/.env.runtime отсутствует." echo "Создай его вручную (см. tradein-mvp/README.md или DEPLOY.md)." exit 1 fi chmod 600 .env.runtime set -a; source .env.runtime; set +a # Re-assert +x на deploy-скриптах (#3005, по образцу deploy.yml ops/*.sh из #71). # Cron зовёт backup-tradein-db.sh через `bash`, так что бит ему не нужен — # но любой другой вызов сырым путём не должен зависеть от git-режима файла. chmod +x deploy/*.sh 2>/dev/null || true # External network для Caddy (он в основном gendesign-стеке) docker network inspect gendesign_shared >/dev/null 2>&1 \ || docker network create gendesign_shared # Re-login to GHCR (PAT может быть rotated) echo "$GHCR_PAT" | docker login ghcr.io -u lekss361 --password-stdin # ── Набор compose-файлов (#3059) ────────────────────────────────── # docker-compose.selectel.yml закрепляет рабочий IP api.telegram.org # через extra_hosts у backend и tgbot. Файл существовал с 23.08, но НИ # ОДИН вызов ниже его не подключал — то есть первый же деплой на новом # хосте поднял бы МЕРУ без закрепления, и бот с пересылкой алертов # умерли бы молча: у api.telegram.org семь адресов, а с Selectel # отвечает РОВНО ОДИН (149.154.167.220), и штатный резолвер отдаёт # мёртвый. # # Подключается БЕЗУСЛОВНО, на обоих хостах. Замер 25.08 с Beget: # 149.154.167.220 -> 302 за 0.18 с # реальный Bot API /getMe -> {"ok":false,"error_code":401} — то есть # отвечает именно Telegram, а не заглушка # Условная логика «оверрайд только на Selectel» была бы лишней машинерией # ради хоста, который через переезд перестанет быть продовым. # # NB: файл — оверрайд стека МЕРЫ (проект gendesign-tradein). Подмешивать # его к стеку ПТИЦЫ нельзя: там нет сервиса tgbot, и compose отвергает # весь проект ("has neither an image nor a build context"), а сервис # backend есть в обоих — закрепление молча легло бы на бэкенд Птицы. COMPOSE_FILES="-f docker-compose.prod.yml" if [ -f docker-compose.selectel.yml ]; then COMPOSE_FILES="$COMPOSE_FILES -f docker-compose.selectel.yml" echo "→ compose-оверрайд: docker-compose.selectel.yml подключён (пин api.telegram.org)" else # ПАДАЕМ, а не предупреждаем. Файл git-tracked, а шагом выше сделан # `git reset --hard origin/main` — значит его отсутствие означает не # штатный сценарий, а поломку (удалили/переименовали, не поправив # это место). Предупреждение в зелёном логе здесь было бы ровно тем # классом тихого отказа, от которого защищает сама правка: деплой # «успешен», а бот и пересылка алертов мертвы. Тот же принцип, что у # health-check'ов #2214 ниже по файлу. echo "ERROR: docker-compose.selectel.yml не найден в $(pwd)." echo "ERROR: без него api.telegram.org не закреплён → tgbot и пересылка" echo "ERROR: алертов умрут МОЛЧА (с Selectel отвечает 1 адрес из 7)." echo "ERROR: если файл убран намеренно — снять и эту проверку тем же PR." exit 1 fi export IMAGE_TAG="$IMAGE_TAG" docker compose -p gendesign-tradein $COMPOSE_FILES pull # ── Порядок деплоя (issue #2216): МИГРАЦИИ ДО НОВОГО app-кода ────────── # Раньше backend/frontend/scraper поднимались ПЕРЕД миграциями: при сбое # миграции новый код уже крутился на СТАРОЙ схеме (рассинхрон код↔схема). # Теперь строго: (1) только postgres → (2) ждём готовности БД → # (3) ВЕСЬ блок миграций → (4) app-контейнеры → (5) Caddy + health. # ИНВАРИАНТ ПРИ СБОЕ МИГРАЦИИ: строгий gate делает exit 1 ДО подъёма # нового кода → старые контейнеры продолжают работать на СТАРОМ коде + # СТАРОЙ схеме (консистентная пара). Это и есть цель: никогда «новый # код на старой схеме». Откат = просто ничего не поднимали. # (1) Только БД — чтобы прогнать миграции до нового app-кода. docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps postgres # (2) Ждём готовности postgres (pg_isready в цикле, НЕ тупой sleep). # # `-h 127.0.0.1` ОБЯЗАТЕЛЕН (#2990) — по тому же образцу, что уже в # ci-tradein.yml:157. На пустом томе образ postgres поднимает # ВРЕМЕННЫЙ сервер с listen_addresses='' на время прогона # docker-entrypoint-initdb.d (сюда смонтирован весь # backend/data/sql/*.sql, см. docker-compose.prod.yml). Этот временный # сервер отвечает "accepting connections" по unix-сокету уже через # пару секунд — а pg_isready БЕЗ -h ходит именно по сокету через # `docker compose exec`. Проба зеленела посреди initdb, до того как # цепочка миграций реально доехала до конца, и код ниже (детект # baseline vs пустая БД) видел частично накаченную схему. TCP-порт # 5432 открывается только когда initdb.d полностью отработал и # postgres перезапустился как настоящий сервер — проба по 127.0.0.1 # зеленеет ровно тогда, когда БД реально готова. # # 90 попыток × 2с = до 3 минут: на пустом томе postgres прогоняет # ВСЮ цепочку миграций (270+ файлов) внутри initdb, это медленнее, # чем ожидание живого сервера на непустом томе (обычный деплой). echo "→ Ожидание готовности postgres..." pg_ready="" for i in $(seq 1 90); do if docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ pg_isready -h 127.0.0.1 -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein >/dev/null 2>&1; then pg_ready="yes"; break fi sleep 2 done if [ -z "$pg_ready" ]; then echo "ERROR: postgres не стал ready за отведённое время — прерываю деплой." echo " Новый app-код НЕ поднят; старые контейнеры не тронуты." exit 1 fi echo "→ postgres ready." # (3) Применяем SQL миграции (если есть backend/data/sql/*.sql) — ДО app. # Postgres init load *.sql из /docker-entrypoint-initdb.d ТОЛЬКО при первом # старте volume. Здесь — для повторных миграций после первого запуска. # Tracking через _schema_migrations (порт паттерна из deploy.yml): # каждый .sql применяется РОВНО один раз, failed migration → exit 1 # (никаких swallowed errors). cwd = /opt/gendesign/tradein-mvp. # ИМЕННО ЭТОТ цикл делает main эталоном применённого: всё, что доехало # до main, здесь и применяется, а имя закрепляется в _schema_migrations. # На этом стоит гейт номеров — tests/test_migration_numbering.py (#2683). # Pre-existence detection ДО CREATE TABLE: если таблицы ещё нет, это # первый deploy после внедрения tracking на уже-наполненной prod-БД # (миграции 001-076 живут в схеме). 7 из них (002/003/052/053/054/063/072) # содержат INSERT-seed БЕЗ ON CONFLICT — повторный прогон под строгим # ON_ERROR_STOP упал бы на PK violation. Поэтому: baseline (см. ниже). migrations_table_existed=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ "SELECT to_regclass('public._schema_migrations') IS NOT NULL;" | tr -d '[:space:]') # Sentinel (#2990): отсутствия _schema_migrations НЕДОСТАТОЧНО, чтобы # заключить «схема уже накачена». Ровно два разных состояния дают одно # и то же отсутствие таблицы: # 1) наполненный прод до внедрения tracking → baseline корректен; # 2) ПУСТАЯ БД на новом сервере → baseline пометил бы все миграции # применёнными, ни одной не прогнав, и деплой уехал бы зелёным # на пустой схеме. Отказ тихий и обнаружился бы уже под нагрузкой. # # Раньше различали одной живой таблицей listings — она создаётся # миграцией 002, то есть почти в САМОМ НАЧАЛЕ цепочки. Этого мало: на # пустом томе до фикса ожидания готовности (см. выше, -h 127.0.0.1) # проба зеленела ПОСРЕДИ initdb, когда listings уже создан, а хвост # цепочки — ещё нет; результат — тихий baseline недокачанной схемы. # Фикс готовности эту гонку убирает (TCP открывается только после # полного прохода initdb.d), но сентинел всё равно проверяем по ОБОИМ # концам цепочки как defense-in-depth: если голова и хвост когда-нибудь # разъедутся — это тот самый гоночный симптом, и его надо ловить явно, # а не гадать. # # Хвост — houses_geog_gist_idx, индекс из миграции 270 (#2997, самая # свежая на момент правки #2990). При добавлении новых миграций после # 270 обнови этот сентинел на объект из новой последней миграции. schema_head_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ "SELECT to_regclass('public.listings') IS NOT NULL;" | tr -d '[:space:]') schema_tail_present=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ "SELECT to_regclass('public.houses_geog_gist_idx') IS NOT NULL;" | tr -d '[:space:]') 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 " CREATE TABLE IF NOT EXISTS _schema_migrations ( filename TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); " if [ "$migrations_table_existed" != "t" ] && [ "$schema_head_present" != "t" ] && [ "$schema_tail_present" != "t" ]; then # ПУСТАЯ БД: ни головы, ни хвоста цепочки — baseline пропускаем # намеренно. Цикл ниже применит всю цепочку с нуля под # ON_ERROR_STOP — это и есть штатный путь чистого старта на новом # сервере (initdb.d уже должен был всё применить сам; этот цикл — # подстраховка на случай, если монтирование почему-то не сработало). echo "→ БД пуста (нет ни _schema_migrations, ни listings, ни хвоста цепочки) — baseline ПРОПУЩЕН," echo " вся цепочка миграций будет применена циклом ниже." elif [ "$migrations_table_existed" != "t" ] && [ "$schema_head_present" = "t" ] && [ "$schema_tail_present" = "t" ]; then # BASELINE: таблицы не было, но и голова, и хвост цепочки на месте → # seed ВСЕ текущие миграции как applied БЕЗ их прогона. Это либо # наполненный прод до внедрения tracking, либо чистый старт, где # initdb.d уже честно доехал до конца сам (ожидание готовности это # теперь гарантирует). В обоих случаях повторный прогон не нужен — # помечаем текущим состоянием, чтобы под строгий gate попадали # только НОВЫЕ миграции. INSERT ... ON CONFLICT DO NOTHING — идемпотентно. echo "→ _schema_migrations отсутствовала, но схема на месте целиком (голова + хвост) — baseline существующих миграций (без прогона)" for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do fname=$(basename "$sql_file") echo " baseline: $fname" 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 \ "INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;" done echo "Baseline complete — existing schema marked as applied." elif [ "$migrations_table_existed" != "t" ]; then # НЕОДНОЗНАЧНО: ровно один из концов цепочки на месте, второго нет, # а _schema_migrations отсутствует. Это и есть симптом гонки # готовности (см. комментарий выше) — молча баселайнить тут нельзя: # либо схема реально недокачана (baseline пометил бы недостающий # хвост как применённый без прогона), либо и то и другое пусто, но # тогда tail-check не должен был сработать. Падаем громко, а не # гадаем — новый app-код НЕ поднят, старые контейнеры не тронуты. echo "ERROR: неоднозначное состояние схемы — _schema_migrations нет," echo " listings присутствует=${schema_head_present}, houses_geog_gist_idx присутствует=${schema_tail_present}." echo " Похоже на недокачанную схему (гонка готовности postgres) —" echo " baseline пропущен намеренно, чтобы не пометить недостающие" echo " миграции применёнными без прогона. Прерываю деплой; нужен" echo " ручной разбор состояния тома перед повторным запуском." exit 1 fi for sql_file in $(ls -1 backend/data/sql/*.sql 2>/dev/null | sort); do fname=$(basename "$sql_file") applied=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ "SELECT COUNT(*) FROM _schema_migrations WHERE filename='$fname'" | tr -d '[:space:]') if [ "$applied" = "0" ]; then echo "→ Applying migration: $fname" 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 \ < "$sql_file" \ || { echo "FAILED on migration: $fname"; exit 1; } docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -c \ "INSERT INTO _schema_migrations (filename) VALUES ('$fname') ON CONFLICT DO NOTHING;" else echo "✓ Already applied: $fname" fi done echo "All migrations applied." # (3b) Невалидные индексы после цикла (#2752). Оборванный # CREATE INDEX CONCURRENTLY оставляет индекс с indisvalid=false: # планировщик им НЕ пользуется (проверено — Seq Scan), а поддержка # на записи всё равно платится. Молчит это так (воспроизведено на # PostgreSQL 16.4): CIC упал → деплой красный, миграция не помечена # применённой → следующий деплой прогоняет её заново → `CREATE INDEX # CONCURRENTLY IF NOT EXISTS` видит битый индекс, печатает # «relation already exists, skipping», выходит с кодом 0 → миграция # помечается применённой, а индекс остаётся невалидным навсегда. # Поэтому проверка не в каждом файле DO-блоком, а одна здесь: она # ловит и этот путь, и невалидные индексы любого другого # происхождения (отменённый job, ручной CIC оператором). # На 2026-08-07 на проде таких индексов 0 — это профилактика. invalid_idx=$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ "SELECT string_agg(i.indexrelid::regclass::text || ' на ' || i.indrelid::regclass::text, ', ') FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_namespace n ON n.oid = c.relnamespace WHERE NOT i.indisvalid AND n.nspname NOT IN ('pg_catalog', 'information_schema');" \ | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//') \ || { echo "ERROR: не удалось прочитать pg_index (psql не ответил) — прерываю деплой."; exit 1; } if [ -n "$invalid_idx" ]; then echo "ERROR: в БД есть НЕВАЛИДНЫЕ индексы: $invalid_idx" echo " Это след оборванного CREATE INDEX CONCURRENTLY: планировщик такой" echo " индекс не использует, а re-run миграции с IF NOT EXISTS его не чинит" echo " (тихо пропускает как существующий). Новый app-код НЕ поднят." echo " Лечение вручную на проде: DROP INDEX CONCURRENTLY <имя>; затем" echo " пересоздать индекс и повторить деплой." exit 1 fi echo "✓ невалидных индексов нет." # Bootstrap gendesign_reader password from env (post-migration, #976). # 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 \ -v "pw=${TRADEIN_READER_PASSWORD}" \ < ops/db-bootstrap/set_gendesign_reader_password.sql else echo "WARNING: TRADEIN_READER_PASSWORD not set in .env.runtime — gendesign_reader без пароля, ETL #976 не сможет подключиться" fi # (4) Теперь — новый app-код: схема уже актуальна. # # #1951: раньше scraper пересоздавался ВТОРОЙ отдельной командой `up -d`, # уже ПОСЛЕ browser/backend/frontend. Если это происходило посреди # in-flight sweep'а (avito/cian/rosreestr full-load), процесс убивался на # лету, а осиротевшая scrape_runs-строка сидела status='running' с # замёрзшим heartbeat до периодического 6h zombie-reaper'а — false "hang" # investigation вместо честного deploy-артефакта (см. cian_full_load #404). # Три меры ниже — все devops-only (shell в deploy-скрипте), БЕЗ правок # Python в scraper-стартап-пути (scheduler_main.py / scraper-kit / # app/services/scrapers — намеренно не тронуты, см. PR-описание): # # 1) Атомарный recreate — browser/backend/frontend[+scraper] поднимаются # ОДНИМ `docker compose up -d` инвокейшном (список сервисов собирается # заранее в $SERVICES), а не двумя последовательными командами. # 2) Graceful drain — если scraper будет пересоздан, ждём (до 5 мин, poll # каждые 10s) пока scrape_runs.status='running' не станет 0, ПРЕЖДЕ чем # инициировать recreate. Таймаут не блокирует деплой навсегда — # SIGTERM-drain (#1182 Phase 2/3a) + stop_grace_period 120s остаются # финальной страховкой для того, что не успело дойти до checkpoint'а. # 3) Startup-reap — сразу после recreate помечаем 'cancelled' любые # 'running'-строки, чей heartbeat не обновлялся с МОМЕНТА (по часам # самой БД — SELECT NOW(), без risk clock-skew раннера), взятого # непосредственно перед stop. Такие строки заведомо осиротели ЭТИМ # recreate'ом (старый контейнер физически не может писать heartbeat # после своей остановки) — не ждём 6h периодического reap_zombies(). # 'cancelled' (не 'zombie') — честно отличает «убит деплоем» от # «непонятно завис» (последнее по-прежнему ловит только 6h-reaper). # Порог — по метке времени конкретного recreate, а не по фиксированному # интервалу: не зависит от heartbeat-каденса разных источников и не # рискует ложно отменить НЕ относящийся к этому recreate run (напр. # admin-triggered scrape внутри backend, если backend в этом деплое # не пересоздавался — его heartbeat продолжит расти после checkpoint'а). # tgbot: тот же backend-образ (rebuild уже покрыт filters.backend — # tradein-mvp/backend/** включает app/tgbot_main.py), никакого # in-flight state вроде scrape_runs → пересоздаётся безусловно вместе # с browser/backend/frontend, отдельного graceful-drain не требует. # # ── FRONTEND ЗДЕСЬ НЕТ. ЭТО И ЕСТЬ ЛЕЧЕНИЕ #3274 ───────────────── # Публичный лендинг лежал 30–90 с на КАЖДОМ деплое. Причина не в # том, что подмена контейнера медленная — она занимает полсекунды. # Причина в том, что `docker compose up -d` со СПИСКОМ сервисов # работает в две фазы: сначала create (старый контейнер каждого # сервиса останавливается и УДАЛЯЕТСЯ — иначе занято container_name), # и только потом start, в порядке зависимостей и с ожиданием их # условий. Между фазами фронта уже нет, а нового ещё нет. # # Замер на проде (docker inspect, 10.09, оба деплоя МЕРЫ): # пачка сервисов: tradein-backend создан 15:01:40 → запущен # 15:02:10 = 30 с (и 503 на лендинге в # 15:01:46/15:01:52/15:02:06 — ровно окно); # ОДИН сервис: tradein-frontend создан 16:42:17.5 → запущен # 16:42:18.0 = 0,5 с, 503 в логе нет вообще. # Тот же двухфазный порядок воспроизведён на стенде по меткам # .Created/.StartedAt: соседи создаются сразу, стартуют через 41 с. # # Поэтому фронт пересоздаётся ОТДЕЛЬНОЙ командой ниже, после этой # пачки: в его графе один сервис, фазы create и start идут подряд. # Остаток (~0,5 с) добирает ретрай подключения в Caddy — см. # снипет (tradein_frontend_retry) в caddy/sites/apps.caddy. # Гейт на обе половины: scripts/check-frontend-swap-window.py. SERVICES="browser backend tgbot" SCRAPER_STOP_TS="" scraper_stale="" if [ "${SCRAPER_RECREATE:-true}" = "true" ]; then # Пересоздавать нечего — и ждать нечего (#2679). SCRAPER_RECREATE # истинно и на infra-правках (compose / workflow / deploy/**), а те # почти всегда собирают ТОТ ЖЕ образ по кэшу: digest не меняется, # `up -d` выходит no-op — и платить за него пятиминутным drain'ом, # прерывая многочасовой сбор, не за что. Сравниваем, на том ли # образе бежит scraper, что уже лежит в локальном демоне. # ПОРЯДОК ВАЖЕН: только ПОСЛЕ `docker compose pull` (шаг выше) — # до pull'а под тегом :latest ещё старый образ, сравнение всегда # «совпало» и drain пропускался бы как раз тогда, когда он нужен. # Заодно чинит ложный startup-reap: чекпоинт/reap ниже завязаны на # ЭТОТ же признак и больше не выполняются, когда recreate'а не было # (иначе живой прогон с heartbeat старше чекпоинта помечался бы # 'cancelled', продолжая работать). pulled_image=$(docker image inspect -f '{{.Id}}' "$IMAGE_BACKEND:$IMAGE_TAG" 2>/dev/null || echo "") running_image=$(docker inspect -f '{{.Image}}' tradein-scraper 2>/dev/null || echo "") if [ -n "$pulled_image" ] && [ "$pulled_image" = "$running_image" ]; then echo "→ образ scraper'а не изменился ($pulled_image) — пересоздавать нечего," echo " drain пропускаем, in-flight прогоны не трогаем" else scraper_stale="yes" fi # scraper в $SERVICES в обоих случаях: при совпавшем образе `up -d` # — no-op, но правка самого compose (env/лимиты сервиса) так всё же # доезжает. Ceiling: такой config-only recreate идёт БЕЗ drain'а — # страхуют SIGTERM-drain (#1182) + stop_grace_period 120s, а строку # прогона подчистит периодический 6h zombie-reaper. SERVICES="$SERVICES scraper" else echo "→ backend-образ в этом деплое не пересобирался — tradein-scraper не трогаем" echo " (сверка образов ниже всё равно проверит, что он не отстал)" fi if [ -n "$scraper_stale" ]; then echo "→ новый backend-образ — scraper пересоздаётся вместе с backend (#2679);" echo " ждём слива in-flight scrape_runs (до 5 мин)" drained="" for i in $(seq 1 30); do # NB: не сливать "psql не ответил" с "0 running" — иначе неудачный # прогон психgl молча читается как «слито», и graceful drain # становится no-op именно в момент проблем с БД во время деплоя. running_count="" psql_out="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc \ "SELECT COUNT(*) FROM scrape_runs WHERE status='running';" 2>/dev/null)" \ && running_count="$(printf '%s' "$psql_out" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" if [ -n "$running_count" ] && [ "$running_count" = "0" ]; then drained="yes"; break fi if [ -z "$running_count" ]; then echo " ...не удалось прочитать running_count (psql failed) — считаем как «ещё активен», жду 10s (попытка $i/30)" else echo " ...${running_count} активных run(ов) ещё бегут, жду 10s (попытка $i/30)" fi sleep 10 done if [ -n "$drained" ]; then echo "→ Активных run'ов нет — recreate scraper безопасен." else echo "WARNING: активные scrape_runs остались после 5 мин ожидания — recreate продолжится." echo " SIGTERM-drain (#1182) + stop_grace_period 120s постараются сохранить checkpoint;" echo " startup-reap ниже подчистит то, что не успеет." fi # Checkpoint по часам БД (не раннера) прямо перед recreate. # NB: tr -d '[:space:]' сломан для timestamptz-литерала — убирает и # внутренний пробел между датой и временем ("2026-07-04 06:43" → # "2026-07-0406:43"), CAST(...AS timestamptz) на такое падает молча # (см. WARNING-фолбэк ниже). sed убирает только leading/trailing. SCRAPER_STOP_TS="$(docker compose -p gendesign-tradein -f docker-compose.prod.yml exec -T postgres \ psql -U "${TRADEIN_POSTGRES_USER:-tradein}" -d tradein -tAc "SELECT NOW();" 2>/dev/null | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" || SCRAPER_STOP_TS="" echo "→ scraper checkpoint ts (DB clock): ${SCRAPER_STOP_TS:-unknown}" fi docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps $SERVICES if [ -n "$scraper_stale" ] && [ -n "${SCRAPER_STOP_TS:-}" ]; then echo "→ Startup-reap (#1951): помечаем orphaned running-строки, замороженные recreate'ом" # NB: psql `-c` НЕ поддерживает `:'var'`-подстановку (переменная доходит до # сервера как литерал → syntax error, см. комментарий выше про TRADEIN_READER_PASSWORD) # — поэтому подставляем bash-значением напрямую. SCRAPER_STOP_TS сгенерирован # самим Postgres (SELECT NOW()), не внешний ввод → безопасно. 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 " UPDATE scrape_runs SET status = 'cancelled', finished_at = NOW(), error = 'deploy #1951: tradein-scraper recreated mid-run (startup-reap, checkpoint ${SCRAPER_STOP_TS})' WHERE status = 'running' AND heartbeat_at < CAST('${SCRAPER_STOP_TS}' AS timestamptz); " || echo "WARNING: startup-reap query failed — orphaned runs (if any) fall back to the 6h zombie reaper" fi # (4b) Фронт — ОТДЕЛЬНОЙ командой, один сервис в графе (#3274). # Обоснование и прод-замеры — у SERVICES выше. Здесь важен ПОРЯДОК: # эта команда идёт ПОСЛЕ пачки (backend уже поднят — новый SSR сразу # ходит в новый бэкенд) и ДО `caddy reload` ниже, чтобы reload, как # и раньше, оставался последним касанием прокси. # # ЗАЧЕМ ЖДАТЬ ПОСЛЕ КОМАНДЫ. `up -d` возвращает управление, когда # контейнер ЗАПУЩЕН, а не когда Next начал слушать (на проде между # ними ~0,1 с, см. journald «Ready in 110ms», но это не гарантия). # Полноценная проверка фронта — health-check ниже по файлу, он же # валит деплой при неудаче; здесь только короткая пауза, чтобы # ретрай Caddy (2 с) не пришёлся на ещё не слушающий порт. docker compose -p gendesign-tradein $COMPOSE_FILES up -d --no-deps frontend sleep 1 # (5) `docker restart tradein-backend` БОЛЬШЕ НЕ НУЖЕН (issue #2216). # История (PR #493 / deploy 1156): backend раньше поднимался ПЕРЕД # миграциями, его lifespan-hook (ensure_fdw_user_mapping) падал с # "server gendesign_remote does not exist" — FOREIGN SERVER создаёт # 060_postgres_fdw_extension.sql, ещё не прогнанная на тот момент. # Требовался рестарт для повторной попытки хука. Теперь backend # стартует на шаге (4), т.е. ПОСЛЕ применения миграций (шаг 3) → # lifespan-hook гарантированно видит применённые миграции (FOREIGN # SERVER gendesign_remote существует) уже с первого старта. Рестарт удалён. # Caddy reload — основной Caddyfile содержит inline tradein routes # (см. Caddyfile в корне репы). Reload, чтобы Caddy перечитал DNS # tradein-backend / tradein-frontend (они в gendesign_shared network). cd /opt/gendesign docker compose -p gendesign -f docker-compose.prod.yml exec -T caddy \ caddy reload --config /etc/caddy/Caddyfile || true # Health check — деплой ВАЛИТСЯ, если backend не поднялся (#2214). # Раньше цикл после 30 неуспешных попыток молча продолжал скрипт и # доходил до записи success-маркера → мёртвый backend помечался # «задеплоено». Теперь: флаг healthy выставляется ТОЛЬКО при HTTP 200 # на /health; после цикла — hard exit 1, если флаг пуст. exit 1 # происходит ДО записи .tradein-deployed-sha (маркер пишется последним, # ниже) → следующий прогон changes-job возьмёт корректную базу. # NB set -e: curl стоит в условии `if` (exempt из errexit) — неуспешная # попытка НЕ фатальна, а лишь провоцирует следующую итерацию цикла. healthy="" for i in $(seq 1 30); do if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \ exec -T backend curl -fsS http://localhost:8000/health >/dev/null 2>&1; then healthy="yes"; break fi sleep 1 done if [ -z "$healthy" ]; then echo "ERROR: backend не ответил на /health за 30s — деплой FAILED" exit 1 fi echo "→ backend healthy на /health." # Frontend health check — раньше проверялся ТОЛЬКО backend: сломанный # фронт (500/белый экран после build, или контейнер упавший на старте) # помечался успешным деплоем, отката не происходило (см. заголовок # секции выше). Проверяем изнутри backend-контейнера — он в одной # tradein-net сети с frontend, и curl там уже есть (в отличие от # node:alpine рантайм-образа frontend, где нет ни curl, ни wget — # добавлять их туда ради healthcheck не стали, backend достаточно). # Путь ОБЯЗАН включать /trade-in: basePath запечён в prod-образ на # build (NEXT_PUBLIC_BASE_PATH=/trade-in, см. build-frontend job) — # голый "/" внутри Next вернёт 404, а не что-то живое. "/trade-in/" # редиректит (307) на /trade-in/v2 — curl -f не считает 3xx ошибкой, # так что это чистая liveness-проверка (процесс жив и роутит), # без привязки к тому, что именно сейчас показывает витрина. frontend_healthy="" for i in $(seq 1 30); do if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \ exec -T backend curl -fsS http://frontend:3000/trade-in/ >/dev/null 2>&1; then frontend_healthy="yes"; break fi sleep 1 done if [ -z "$frontend_healthy" ]; then echo "ERROR: frontend не ответил на /trade-in/ за 30s — деплой FAILED" exit 1 fi echo "→ frontend healthy на /trade-in/." # Browser health check — /health в browser/server.py всегда 200, пока # жив сам aiohttp-процесс (см. health_handler: "compose НЕ имеет # healthcheck на browser, только depends_on: service_started" — до # этой правки browser вообще не проверялся никаким деплой-шагом). # Это liveness процесса, НЕ readiness camoufox-инстансов конкретных # источников (те поднимаются лениво на первый /fetch) — но упавший # при старте контейнер (например, битый образ) здесь ловится сразу, # а не молча остаётся мёртвым до первого реального /fetch scraper'ом. browser_healthy="" for i in $(seq 1 30); do if docker compose -p gendesign-tradein -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \ exec -T backend curl -fsS http://browser:3000/health >/dev/null 2>&1; then browser_healthy="yes"; break fi sleep 1 done if [ -z "$browser_healthy" ]; then echo "ERROR: browser не ответил на /health за 30s — деплой FAILED" exit 1 fi echo "→ browser healthy на /health." # tgbot/scraper — те же backend-образ и Dockerfile, но bare python- # процессы БЕЗ ASGI/HTTP-сервера (см. комментарии в tgbot_main.py / # scheduler_main.py: "здесь нет ASGI-приложения"), поэтому HTTP- # healthcheck для них невозможен в принципе. Liveness проверяем по # состоянию контейнера через docker inspect: упавший на старте # процесс (например, ImportError в новом коде) restart-policy # unless-stopped уводит в бесконечный crash-loop — раньше это НИКАК # не блокировало деплой (маркер писался, даже если tgbot/scraper # были мертвы). Двойная проверка (running → пауза → снова running) # снижает шанс поймать контейнер ровно в момент between-restarts # промежуточного "running" внутри crash-loop. # tgbot пересоздаётся на КАЖДОМ деплое (безусловно в $SERVICES); # scraper — только когда SCRAPER_RECREATE (см. блок выше) — поэтому # проверяем только то, что реально входит в текущий $SERVICES. for svc in tgbot scraper; do case " $SERVICES " in *" $svc "*) ;; *) continue ;; esac container_ok="" state="unknown" for i in $(seq 1 15); do state=$(docker inspect -f '{{.State.Status}}' "tradein-$svc" 2>/dev/null || echo "unknown") if [ "$state" = "running" ]; then container_ok="yes"; break fi sleep 1 done if [ -n "$container_ok" ]; then sleep 3 state=$(docker inspect -f '{{.State.Status}}' "tradein-$svc" 2>/dev/null || echo "unknown") if [ "$state" != "running" ]; then container_ok="" fi fi if [ -z "$container_ok" ]; then echo "ERROR: tradein-$svc не в стабильном состоянии running (state='$state') — деплой FAILED" exit 1 fi echo "→ tradein-$svc running." done # Сверка образов backend-семейства (#2679) — последняя проверка перед # маркером «задеплоено». backend/scraper/tgbot бегут ОДИН образ # gendesign-tradein-backend; backend пересоздаётся на каждом деплое # (безусловно в $SERVICES) и потому всегда несёт свежий :latest — # он и есть эталон. Если у scraper или tgbot image ID другой, значит # контейнер остался на старом коде, а деплой без этой проверки # отчитался бы успехом: ровно инцидент 2026-08-05 (#2675 доехал до # tradein-backend, ff98603ba3cc; tradein-scraper остался на # da26154c64a6 часовой давности — а планировщик, единственный # исполнитель домовой оценки, живёт именно там). # Падаем, а не warning'уем: расхождение = правка не работает, и # узнать об этом надо в момент деплоя, а не через месяц. exit 1 идёт # ДО записи .tradein-deployed-sha → следующий прогон возьмёт ту же # базу и пересоберёт всё накопленное (тот же приём, что в health-check). # «Контейнера нет» и «контейнер отстал» — разные аварии и чинятся # по-разному, поэтому сообщения различаются явно. backend_image=$(docker inspect -f '{{.Image}}' tradein-backend 2>/dev/null || echo "") image_mismatch="" if [ -z "$backend_image" ]; then echo "ERROR: контейнера tradein-backend нет — сверять образы не с чем." image_mismatch="yes" fi for svc in scraper tgbot; do svc_image=$(docker inspect -f '{{.Image}}' "tradein-$svc" 2>/dev/null || echo "") if [ -z "$svc_image" ]; then echo "ERROR: контейнера tradein-$svc НЕТ (удалён или не создавался) — это не отставший" echo " образ, а неполный стек: сервис не работает вообще." image_mismatch="yes" elif [ -n "$backend_image" ] && [ "$svc_image" != "$backend_image" ]; then echo "ERROR: tradein-$svc ОТСТАЛ: работает на $svc_image, tradein-backend — на $backend_image" image_mismatch="yes" fi done if [ -n "$image_mismatch" ]; then echo "ERROR: backend-семейство не на одном образе — деплой FAILED (#2679)." echo " Лечение вручную (поднимет отсутствующие, пересоздаст отставшие):" echo " docker compose -p gendesign-tradein \\" echo " -f /opt/gendesign/tradein-mvp/docker-compose.prod.yml \\" echo " up -d --force-recreate --no-deps backend scraper tgbot" exit 1 fi echo "→ образы совпадают: backend/scraper/tgbot на $backend_image." # Cleanup старых образов for repo in ghcr.io/lekss361/gendesign-tradein-backend \ ghcr.io/lekss361/gendesign-tradein-frontend; do docker images "$repo" --format '{{.Repository}}:{{.Tag}}' \ | grep -v ':latest$' | tail -n +3 \ | xargs -r docker rmi 2>/dev/null || true done docker image prune -af || true # Mark this SHA as successfully deployed. # Written LAST — only after all of the above completed without error. # The changes job reads this file on the next run to compute cumulative diff. echo "$GITHUB_SHA" > /opt/gendesign/.tradein-deployed-sha echo "→ Deployed SHA marker updated: $GITHUB_SHA" # Честный итог прогона (#2841). ПРОБЛЕМА: `deploy` пропускается своим `if:` # молча (result=skipped), когда `test` или один из build-* падает (например, # битый blob в buildcache роняет `docker/build-push-action` — до ретрая # выше, #2841). skipped-job не красит прогон явным «FAILED» так, чтобы это # было видно на первый взгляд — итог выглядит зелёным/нейтральным, хотя # tradein-стек на проде не обновился. Эта job бежит ВСЕГДА (`if: always()`, # кроме отмены прогона) и сама падает, если deploy не завершился success — # неважно, пропущен он (test/build упали) или упал сам (SSH/миграция/ # health-check/сверка образов #2679). Красная точка встаёт именно там, где # решение реально принято, а не там, где она случайно оказалась по цепочке if. # ── Смоук публичного периметра МЕРЫ после выкатки (#2917) ────────────────── # # ЗАЧЕМ ЗДЕСЬ. scripts/smoke-mera-perimeter.sh — единственная проверка, которая # видит периметр целиком (короткие адреса, 301 с длинных, публичный API, # закрытость B2B-путей на публичном домене). До этого PR он запускался только # по cron'у 06:17 UTC, то есть регресс жил до суток и находил его либо ночной # прогон, либо владелец. Для правки, чья логика живёт в конфиге прокси, это # единственный настоящий гейт — и он был асинхронным. # # ПОЧЕМУ ОТДЕЛЬНЫЙ JOB, А НЕ ШАГ В deploy. Вердикты разные: «выкатили» и # «периметр цел» — два разных факта, и красный смоук не должен читаться как # неудавшийся деплой. Деплой к этому моменту уже прошёл; смоук говорит, что # именно получилось. # # ПОЧЕМУ ДУБЛИРУЕТСЯ В ДВУХ ПАЙПЛАЙНАХ. Конфиг прокси (deploy.yml) и фронт # МЕРЫ (deploy-tradein.yml) едут раздельно, и сломать периметр может каждый. # `workflow_call` под act_runner не гарантирован, поэтому 20 строк повторены # осознанно вместо зависимости, которая может молча не сработать. perimeter-smoke: runs-on: ubuntu-latest needs: deploy # Только после РЕАЛЬНОЙ выкатки: при skipped/failed проверять нечего, а # красный смоук поверх несостоявшегося деплоя увёл бы разбор не туда. if: always() && needs.deploy.result == 'success' timeout-minutes: 6 steps: - uses: actions/checkout@v4 - name: Дождаться, пока периметр отвечает после пересоздания контейнеров # `up -d --force-recreate` возвращает управление раньше, чем бэкенд # начинает отвечать. Без ожидания смоук ловил бы не регресс, а гонку. # Ждём ДВА признака: лэндинг (Caddy + фронт) и API (бэкенд поднялся) — # одного мало, Caddy отвечает раньше апстрима. run: | set -uo pipefail for i in $(seq 1 30); do page=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://meraocenka.ru/ || true) api=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 https://gendsgn.ru/trade-in/api/v1/me || true) if [ "$page" = "200" ] && [ "$api" = "401" ]; then echo "периметр отвечает (попытка $i): лэндинг $page, API $api" exit 0 fi echo "ждём готовности, попытка $i/30: лэндинг '${page:-нет ответа}', API '${api:-нет ответа}'" sleep 5 done # НЕ падаем здесь: вердикт должен вынести смоук, а не таймаут ожидания. # Иначе «не успел подняться» и «периметр сломан» слились бы в один # красный шаг без разбора. echo "::warning::за 150 с периметр так и не ответил ожидаемо — запускаем смоук, его вывод и будет диагнозом" - name: Смоук периметра run: | chmod +x scripts/smoke-mera-perimeter.sh ./scripts/smoke-mera-perimeter.sh deploy-status: runs-on: ubuntu-latest needs: [test, build-backend, build-frontend, build-browser, deploy] if: always() && !cancelled() steps: - name: Итог прогона — деплой обязан быть success, не skipped/failure run: | echo "test: ${{ needs.test.result }}" echo "build-backend: ${{ needs.build-backend.result }}" echo "build-frontend: ${{ needs.build-frontend.result }}" echo "build-browser: ${{ needs.build-browser.result }}" echo "deploy: ${{ needs.deploy.result }}" if [ "${{ needs.deploy.result }}" != "success" ]; then echo "::error::деплой НЕ прошёл (deploy.result=${{ needs.deploy.result }})." \ "Прогон должен читаться как FAILED, а не как пропущенный шаг (#2841)." \ "Смотри логи test/build-backend/build-frontend/build-browser/deploy выше." exit 1 fi echo "✓ деплой прошёл успешно"