feat(ops): сторож расхождения «прод ↔ main» — недоехавший деплой перестаёт быть молчаливым (#3029) #3125
3 changed files with 350 additions and 0 deletions
117
.forgejo/workflows/deploy-drift.yml
Normal file
117
.forgejo/workflows/deploy-drift.yml
Normal file
|
|
@ -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
|
||||||
146
backend/tests/ops/test_3029_deploy_drift.py
Normal file
146
backend/tests/ops/test_3029_deploy_drift.py
Normal file
|
|
@ -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, "сторож перестал быть периодическим"
|
||||||
87
scripts/check-deploy-drift.sh
Normal file
87
scripts/check-deploy-drift.sh
Normal file
|
|
@ -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
|
||||||
Loading…
Add table
Reference in a new issue