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