gendesign/backend/tests/ops/test_3029_deploy_drift.py
bot-backend de56b8ae01
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / backend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Successful in 2m8s
CI / backend-tests (pull_request) Successful in 17m30s
feat(ops): сторож расхождения «прод ↔ main» — молчаливый недоехавший деплой становится видимым (#3029)
27.08 прод сутки жил на позавчерашнем коммите, и это нашлось только руками.
Замена IP сервера (#3110) осиротила секрет DEPLOY_HOST: deploy.yml падал на
`dial tcp ***:***: i/o timeout`, при этом CI оставался зелёным, PR
продолжали мержиться, а deploy-infra.yml исправно обновлял Beget и создавал
впечатление, что всё в порядке.

Проверок, которые спрашивают не «прошёл ли прогон», а «доехал ли код», не
было ни одной. Этот сторож — ровно такая: ежечасно читает HEAD с прод-хоста
и сверяет с tip main.

Три исхода вместо двух. «Хост не ответил» (2) отделён от «на хосте не тот
код» (1) — это разные аварии с разной первой командой в разборе, и сегодня
погорели именно на их смешении: недоступность выглядела как обычный красный
прогон. Льготный период 30 минут гасит ложную тревогу на деплое, который
ещё в полёте: ежечасный сторож неизбежно попадёт в окно между мержем и
концом выката, а сторож, которого научились игнорировать, хуже отсутствующего.

Логика вынесена в scripts/check-deploy-drift.sh и не ходит по сети — SSH
живёт в workflow, где секреты. Благодаря этому тест ИСПОЛНЯЕТ настоящий
скрипт, а не пересказывает его: ошибка в самом bash видна только при запуске.

Read-only: один ssh и git rev-parse, ничего не деплоит.
2026-08-27 12:35:43 +03:00

146 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Сторож расхождения «прод ↔ 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, "сторож перестал быть периодическим"