diff --git a/.forgejo/workflows/deploy-drift.yml b/.forgejo/workflows/deploy-drift.yml new file mode 100644 index 00000000..59325849 --- /dev/null +++ b/.forgejo/workflows/deploy-drift.yml @@ -0,0 +1,117 @@ +# Сторож расхождения «код на проде ↔ main» (#3029). +# +# ЗАЧЕМ ОТДЕЛЬНЫЙ ПРОГОН, А НЕ ШАГ В deploy.yml. Шаг внутри деплоя проверяет +# только тот деплой, который запустился. 27.08.2026 прод сутки жил на старом +# коммите ровно потому, что деплой НЕ доезжал: замена IP сервера (#3110) +# осиротила секрет DEPLOY_HOST, deploy.yml падал на i/o timeout, а CI оставался +# зелёным и PR продолжали мержиться. Проверять надо не «прошёл ли прогон», а +# «совпадает ли то, что лежит на проде, с тем, что в main» — это независимый +# вопрос, и задавать его надо по часам, а не по событию деплоя. +# +# Read-only: один SSH и `git rev-parse`. Ничего не деплоит и не меняет. +name: deploy-drift + +on: + workflow_dispatch: {} + schedule: + # Ежечасно в :23 — вне ровного часа, чтобы не толкаться со сторожами + # бэкапов (те ходят в :00). + - cron: '23 * * * *' + # Правка самого сторожа проверяется сразу, а не через час. + push: + branches: [main] + paths: + - 'scripts/check-deploy-drift.sh' + - '.forgejo/workflows/deploy-drift.yml' + +concurrency: + group: deploy-drift + cancel-in-progress: false + +jobs: + drift: + runs-on: ubuntu-latest + timeout-minutes: 5 + + steps: + - name: Checkout repo + uses: actions/checkout@v4 + + - name: Что ждём увидеть на проде + id: expected + run: | + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + echo "epoch=$(git log -1 --format=%ct)" >> "$GITHUB_OUTPUT" + echo "На main: $(git rev-parse --short HEAD) $(git log -1 --format=%s | cut -c1-60)" + + - name: Что лежит на проде + id: deployed + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + # known_hosts, а не SHA256-отпечаток: здесь обычный openssh-клиент. + # Секрет привязан к АДРЕСУ — после смены IP его надо переснять + # (`ssh-keyscan -p <порт> <хост>`), иначе шаг молча уедет в «неизвестно». + DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }} + run: | + SSH_KEY_FILE=$(mktemp) + echo "$DEPLOY_SSH_KEY" > "$SSH_KEY_FILE" + chmod 600 "$SSH_KEY_FILE" + + 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") + else + SSH_HOST_OPTS=(-o StrictHostKeyChecking=no) + echo "::warning title=SSH без проверки подлинности хоста::DEPLOY_KNOWN_HOSTS не задан (#3029)." + fi + + # `safe.directory` — каталог принадлежит деплой-пользователю, а git с + # 2.35 отказывается работать в чужом репозитории. Без этого шаг вернул + # бы пусто, и сторож сказал бы «неизвестно» вместо правды. + RAW=$(ssh -i "$SSH_KEY_FILE" \ + "${SSH_HOST_OPTS[@]}" \ + -o ConnectTimeout=10 \ + -o BatchMode=yes \ + -p "${DEPLOY_PORT:-22}" \ + "${DEPLOY_USER}@${DEPLOY_HOST}" \ + "git -c safe.directory=/opt/gendesign -C /opt/gendesign rev-parse HEAD 2>/dev/null || true" \ + 2>/dev/null | tr -d '[:space:]' || true) + + rm -f "$SSH_KEY_FILE" "$KNOWN_HOSTS_FILE" + echo "sha=${RAW}" >> "$GITHUB_OUTPUT" + if [ -n "$RAW" ]; then + echo "На проде: ${RAW:0:12}" + else + echo "На проде: прочитать не удалось" + fi + + - name: Вердикт + env: + DEPLOYED_SHA: ${{ steps.deployed.outputs.sha }} + EXPECTED_SHA: ${{ steps.expected.outputs.sha }} + EXPECTED_COMMIT_EPOCH: ${{ steps.expected.outputs.epoch }} + # Деплой продукта идёт около десяти минут; тридцать — запас, чтобы + # ежечасный сторож не кричал на деплой, который просто ещё в полёте. + GRACE_MINUTES: '30' + run: | + chmod +x scripts/check-deploy-drift.sh + set +e + ./scripts/check-deploy-drift.sh + rc=$? + set -e + case "$rc" in + 0) exit 0 ;; + 2) + echo "::warning title=Состояние прода неизвестно::Сторож не смог прочитать SHA с прод-хоста (#3029)." + exit 1 + ;; + *) + echo "::error title=Прод отстал от main::Код из main на проде не работает (#3029)." + exit 1 + ;; + esac diff --git a/backend/tests/ops/test_3029_deploy_drift.py b/backend/tests/ops/test_3029_deploy_drift.py new file mode 100644 index 00000000..72a1a92c --- /dev/null +++ b/backend/tests/ops/test_3029_deploy_drift.py @@ -0,0 +1,146 @@ +"""Сторож расхождения «прод ↔ main» исполняется, а не пересказывается (#3029). + +Прод-факт, ради которого сторож появился. 27.08.2026 замена IP сервера (#3110) +осиротила секрет `DEPLOY_HOST`: `deploy.yml` падал на + + 2026/08/27 08:47:10 dial tcp ***:***: i/o timeout + +при этом CI оставался зелёным, PR продолжали мержиться, а прод сутки жил на +позавчерашнем коммите. Расхождение нашлось руками — сверкой двух `git log`. + +ГЛАВНЫЙ ИНВАРИАНТ, который сторожат тесты ниже: «хост не ответил» и «на хосте +не тот код» — РАЗНЫЕ исходы (2 и 1), а не один. Ровно на их смешении и погорели: +недоступность выглядела как обычный красный прогон, и первая команда в разборе +оказалась не та. Если кто-то упростит скрипт до «не совпало → 1», тест покраснеет. + +Тесты исполняют НАСТОЯЩИЙ `scripts/check-deploy-drift.sh` через bash — не копию +логики и не чтение исходника глазами: ошибка в самом bash (несработавшая +подстановка, опечатка в сравнении) видна только при запуске. +""" + +from __future__ import annotations + +import shlex +import subprocess +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "scripts" / "check-deploy-drift.sh" + +MAIN_SHA = "c6c934fb99aa1122334455667788990011223344" +OLD_SHA = "8a4215fe203930b3a01bb33d3c4fadda7e873129" + + +def _run(**env: str) -> subprocess.CompletedProcess[str]: + """Запустить настоящий скрипт с заданными переменными. + + Переменные передаются ПРЕФИКСОМ КОМАНДЫ (`VAR=... bash script`), а не через + `env=` у subprocess: под Windows оболочка git-bash окружение из `env=` не + донесла — скрипт видел пустые значения и тест проверял бы не то, что нужно. + Префикс команды ведёт себя одинаково на всех платформах, а сам скрипт всё так + же читает обычные переменные окружения — контракт не меняется. + """ + assert SCRIPT.is_file(), f"нет {SCRIPT} — сторож переехал, гейт ослеп" + prefix = " ".join(f"{k}={shlex.quote(v)}" for k, v in env.items()) + # Относительный путь + cwd=REPO_ROOT: совпадает с вызовом из workflow и не + # ломается о букву диска в Windows-пути. + cmd = f"{prefix} bash scripts/check-deploy-drift.sh".strip() + return subprocess.run( + ["bash", "-c", cmd], + cwd=str(REPO_ROOT), + capture_output=True, + text=True, + timeout=30, + ) + + +def test_sovpadenie_daet_nol() -> None: + """Прод на том же коммите — тишина и ноль.""" + r = _run(DEPLOYED_SHA=MAIN_SHA[:12], EXPECTED_SHA=MAIN_SHA) + assert r.returncode == 0, r.stdout + r.stderr + assert "OK" in r.stdout + + +def test_korotkiy_sha_s_hosta_sravnivaetsya_po_prefiksu() -> None: + """С хоста приходит короткий SHA, из git — полный; сравнение по префиксу. + + Без этого сторож кричал бы на КАЖДОМ прогоне: `rev-parse --short` и + `rev-parse` не равны как строки никогда. + """ + r = _run(DEPLOYED_SHA=MAIN_SHA[:8], EXPECTED_SHA=MAIN_SHA) + assert r.returncode == 0, r.stdout + r.stderr + + +def test_staroe_rashozhdenie_daet_edinicu() -> None: + """Прод отстал давно — это авария.""" + r = _run( + DEPLOYED_SHA=OLD_SHA, + EXPECTED_SHA=MAIN_SHA, + EXPECTED_COMMIT_EPOCH="1000", + NOW_EPOCH="99999", + ) + assert r.returncode == 1, r.stdout + r.stderr + assert "РАСХОЖДЕНИЕ" in r.stdout + + +def test_svezhiy_kommit_v_lgotnom_periode_ne_krichit() -> None: + """Деплой ещё в полёте — сторож молчит. + + Ежечасный сторож неизбежно попадёт в момент между мержем и концом деплоя. + Без льготного периода он давал бы ложную тревогу несколько раз в неделю, и + его перестали бы читать — это дороже, чем отсутствие сторожа. + """ + r = _run( + DEPLOYED_SHA=OLD_SHA, + EXPECTED_SHA=MAIN_SHA, + EXPECTED_COMMIT_EPOCH="1000", + NOW_EPOCH="1300", + GRACE_MINUTES="30", + ) + assert r.returncode == 0, r.stdout + r.stderr + assert "ПОКА НОРМА" in r.stdout + + +def test_lgota_istekla_krichit() -> None: + """Граница льготы работает в обе стороны, а не «всегда молчим».""" + r = _run( + DEPLOYED_SHA=OLD_SHA, + EXPECTED_SHA=MAIN_SHA, + EXPECTED_COMMIT_EPOCH="1000", + NOW_EPOCH=str(1000 + 31 * 60), + GRACE_MINUTES="30", + ) + assert r.returncode == 1, r.stdout + r.stderr + + +@pytest.mark.parametrize("deployed", ["", "abc"]) +def test_neprochitannoe_sostoyanie_eto_dva_a_ne_odin(deployed: str) -> None: + """Пустой или бессмысленно короткий ответ хоста — исход 2, отдельный от 1. + + Это и есть форма сегодняшней аварии: хост был недоступен. Смешать её с + «прод отстал» — значит послать разбирающегося не туда. + """ + r = _run(DEPLOYED_SHA=deployed, EXPECTED_SHA=MAIN_SHA) + assert r.returncode == 2, r.stdout + r.stderr + assert "НЕИЗВЕСТНО" in r.stdout + + +def test_bez_expected_sha_ne_pritvoryaemsya_chto_vsyo_horosho() -> None: + """Не с чем сверять — это тоже «неизвестно», а не «ок». + + Обратный конец инварианта: сторож, который при поломке собственного входа + возвращает ноль, хуже отсутствующего — он создаёт ложную уверенность. + """ + r = _run(DEPLOYED_SHA=MAIN_SHA, EXPECTED_SHA="") + assert r.returncode == 2, r.stdout + r.stderr + + +def test_workflow_zovyot_imenno_etot_skript() -> None: + """Workflow и скрипт не разъезжаются: имя файла сверяется структурно.""" + wf = REPO_ROOT / ".forgejo" / "workflows" / "deploy-drift.yml" + assert wf.is_file(), "нет workflow сторожа" + text = wf.read_text(encoding="utf-8") + assert "scripts/check-deploy-drift.sh" in text, "workflow зовёт другой скрипт" + assert "schedule:" in text and "cron:" in text, "сторож перестал быть периодическим" diff --git a/scripts/check-deploy-drift.sh b/scripts/check-deploy-drift.sh new file mode 100644 index 00000000..2ecd842f --- /dev/null +++ b/scripts/check-deploy-drift.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +# Сторож расхождения «код на проде ↔ main» (#3029). +# +# ЗАЧЕМ. 27.08.2026 прод сутки жил на коммите позавчерашней давности, и никто +# этого не заметил: замена IP сервера (#3110) осиротила секрет DEPLOY_HOST, +# deploy.yml падал на `dial tcp ***:***: i/o timeout`, а CI при этом оставался +# зелёным и PR продолжали мержиться. Расхождение нашлось только руками — сверкой +# двух `git log`. Проверок, которые смотрят НЕ «прошёл ли прогон», а «доехал ли +# код», не было ни одной. +# +# Скрипт намеренно НЕ ходит по сети: он получает уже снятые значения и выносит +# вердикт. SSH живёт в workflow (там секреты), логика — здесь (её можно +# исполнить в тесте, а не пересказать). +# +# Вход — переменные окружения: +# DEPLOYED_SHA SHA, прочитанный с прод-хоста (может быть коротким) +# EXPECTED_SHA SHA, которого ждём (tip main) +# EXPECTED_COMMIT_EPOCH время коммита EXPECTED_SHA, unix-time (необязательно) +# NOW_EPOCH «сейчас» (необязательно; для тестов) +# GRACE_MINUTES сколько минут расхождение считается нормой (по умолчанию 30) +# +# Коды возврата: +# 0 совпало, либо расхождение моложе GRACE_MINUTES (деплой в полёте) +# 1 расхождение старше GRACE_MINUTES — прод отстал, надо смотреть +# 2 состояние прода прочитать не удалось (пустой DEPLOYED_SHA) +# +# ПОЧЕМУ 2, А НЕ 1. «Хост недоступен» и «хост отдал не тот код» — разные +# аварии с разной первой командой в разборе. Ровно на этом различии сегодня и +# погорели: недоступность выглядела как обычный красный прогон. + +set -euo pipefail + +GRACE_MINUTES="${GRACE_MINUTES:-30}" +NOW_EPOCH="${NOW_EPOCH:-$(date +%s)}" +DEPLOYED_SHA="${DEPLOYED_SHA:-}" +EXPECTED_SHA="${EXPECTED_SHA:-}" +EXPECTED_COMMIT_EPOCH="${EXPECTED_COMMIT_EPOCH:-}" + +if [ -z "$EXPECTED_SHA" ]; then + echo "ОШИБКА: EXPECTED_SHA пуст — сверять не с чем." >&2 + exit 2 +fi + +# Пустой DEPLOYED_SHA = хост не ответил / каталога нет / ключ не подошёл. +if [ -z "$DEPLOYED_SHA" ]; then + echo "НЕИЗВЕСТНО: состояние прод-хоста прочитать не удалось." + echo " Это НЕ «прод отстал» — это «мы не знаем, что на проде»." + echo " Смотреть: доступность хоста, DEPLOY_HOST/DEPLOY_KNOWN_HOSTS, каталог /opt/gendesign." + exit 2 +fi + +# Сравнение по общему префиксу: с хоста приходит короткий SHA, из git — полный. +# Требуем минимум 7 знаков, иначе сравнение бессмысленно. +len_dep=${#DEPLOYED_SHA} +len_exp=${#EXPECTED_SHA} +cmp_len=$(( len_dep < len_exp ? len_dep : len_exp )) + +if [ "$cmp_len" -lt 7 ]; then + echo "НЕИЗВЕСТНО: SHA короче семи знаков (прод='$DEPLOYED_SHA', ждём='$EXPECTED_SHA')." + exit 2 +fi + +if [ "${DEPLOYED_SHA:0:$cmp_len}" = "${EXPECTED_SHA:0:$cmp_len}" ]; then + echo "OK: на проде ${DEPLOYED_SHA} — это и есть текущий main." + exit 0 +fi + +# Расходятся. Пока коммит свежий, это скорее всего деплой в полёте, а не авария. +if [ -n "$EXPECTED_COMMIT_EPOCH" ]; then + age_s=$(( NOW_EPOCH - EXPECTED_COMMIT_EPOCH )) + grace_s=$(( GRACE_MINUTES * 60 )) + if [ "$age_s" -lt 0 ]; then + age_s=0 + fi + if [ "$age_s" -lt "$grace_s" ]; then + echo "ПОКА НОРМА: прод на ${DEPLOYED_SHA}, main на ${EXPECTED_SHA}." + echo " Коммиту ${age_s}с, льготный период ${grace_s}с — вероятно, деплой ещё идёт." + exit 0 + fi + echo "РАСХОЖДЕНИЕ: прод на ${DEPLOYED_SHA}, main на ${EXPECTED_SHA} уже ${age_s}с." +else + echo "РАСХОЖДЕНИЕ: прод на ${DEPLOYED_SHA}, main на ${EXPECTED_SHA}." +fi + +echo " Код, лежащий в main, на проде не работает. Смотреть последний прогон deploy.yml:" +echo " зелёный CI и смерженный PR сами по себе НЕ означают, что деплой доехал." +exit 1