Merge pull request 'fix(ops): проверка трейлера дампа падала на grep — ведущие -- принимались за опции (#2203)' (#3013) from fix/backup-trailer-grep-dashdash into main
All checks were successful
Deploy / changes (push) Successful in 7s
Deploy / build-frontend (push) Has been skipped
Deploy Trade-In / changes (push) Successful in 11s
Deploy / deploy-caddy (push) Has been skipped
Deploy / build-backend (push) Successful in 41s
Deploy / build-worker (push) Successful in 42s
Deploy Trade-In / build-browser (push) Successful in 39s
Deploy / deploy (push) Successful in 1m56s
Deploy / deploy-status (push) Successful in 1s
Deploy / perimeter-smoke (push) Successful in 9s
Deploy Trade-In / build-frontend (push) Successful in 2m51s
Deploy Trade-In / test (push) Successful in 3m45s
Deploy Trade-In / build-backend (push) Successful in 33s
Deploy Trade-In / deploy (push) Successful in 6m31s
Deploy Trade-In / deploy-status (push) Successful in 1s
Deploy Trade-In / perimeter-smoke (push) Successful in 9s

This commit is contained in:
lekss361 2026-08-20 20:57:31 +00:00
commit 63bc576c40
3 changed files with 162 additions and 2 deletions

View file

@ -0,0 +1,160 @@
"""Regression: verify_dump_integrity() больше не удаляет валидные дампы (#2203).
Что произошло. В обоих бэкап-скриптах (`ops/backup.sh`,
`tradein-mvp/deploy/backup-tradein-db.sh`) проверка трейлера была:
gunzip -c "$file" | tail -5 | grep -qF "$trailer"
`$trailer` это `"-- PostgreSQL database dump complete"` (и `... cluster dump
complete` для globals). Строка начинается с `--`, а GNU grep трактует ведущие
`--` в аргументе как конец списка опций / саму опцию без разделителя `--`
перед паттерном grep падает:
grep: unrecognized option '-- PostgreSQL database dump complete'
Проверка ВСЕГДА возвращала «трейлера нет» не потому что дамп оборван, а
потому что сама grep-команда не может выполниться. Вызывающий код удалял
только что созданный ВАЛИДНЫЙ дамп и завершался с ошибкой; ретеншен не
успевал отработать (ранний exit) свежие бэкапы не создавались никогда,
старые копии оставались молча.
Воспроизведено вручную на проде: `bash
/opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh` удалил свежий дамп с
сообщением «дамп оборван?».
Фикс `grep -qF -- "$trailer"`: `--` явно завершает опции grep, дальше
только позиционные аргументы, ведущие `--` в самом трейлере больше не путают
grep с флагом.
ПОЧЕМУ ЭТОТ КЛАСС БАГОВ НЕ ПОЙМАЛИ РАНЬШЕ: ни один тест не исполнял
verify_dump_integrity() на реальном gzip-потоке только читали/ревьюили
исходник глазами, а `grep -qF "текст, начинающийся с --"` выглядит
безобидно, пока не запущен. Тест ниже исполняет РЕАЛЬНУЮ функцию
verify_dump_integrity(), извлечённую из обоих скриптов (не копию, не
пересказ), через ту же связку `gunzip -c | tail -5 | grep`, что и в проде
регресс (пропажа `--`) уронит его немедленно.
"""
from __future__ import annotations
import gzip
import shutil
import subprocess
from pathlib import Path
import pytest
# backend/tests/ops/<этот файл> → корень репозитория
REPO_ROOT = Path(__file__).resolve().parents[3]
SCRIPTS = {
"ops/backup.sh": "-- PostgreSQL database dump complete",
"tradein-mvp/deploy/backup-tradein-db.sh": "-- PostgreSQL database dump complete",
}
# `shutil.which`, а не голое "bash" в subprocess.run: на Windows с установленным
# WSL голое имя резолвится Windows-у CreateProcess В СИСТЕМНУЮ ДИРЕКТОРИЮ РАНЬШЕ
# PATH и находит `System32\bash.exe` (лаунчер WSL) вместо Git Bash. Этот лаунчер
# ломает `-c` со скриптом из нескольких `;`-разделённых команд — каждая часть
# выполняется как будто в НОВОЙ оболочке, состояние (переменные, включая
# результат mktemp) между ними не сохраняется. `shutil.which` ищет по PATH как
# обычно и находит настоящий Git Bash, где всё работает штатно.
BASH = shutil.which("bash")
if BASH is None: # pragma: no cover - тестовое окружение без bash не запустит эти тесты
pytest.skip("bash не найден в PATH — тест требует shell-исполнения", allow_module_level=True)
def _extract_function(script_path: Path) -> str:
"""Достаёт тело verify_dump_integrity() из файла — не весь скрипт.
Весь файл source'ить нельзя: ниже функции в обоих скриптах идёт секция
`# --- run ---` / `mkdir -p "$BACKUP_DIR"` и далее реальный
`docker exec ... pg_dump` этого мы не хотим исполнять в тесте.
"""
assert script_path.is_file(), f"нет {script_path} — переехал скрипт, гейт ослеп"
lines = script_path.read_text(encoding="utf-8").splitlines()
start = next(i for i, line in enumerate(lines) if line.startswith("verify_dump_integrity() {"))
end = next(i for i in range(start, len(lines)) if lines[i] == "}")
body = "\n".join(lines[start : end + 1])
assert "grep" in body, f"{script_path}: извлечённое тело не похоже на функцию с grep"
return body
def _run_verify(script_rel: str, trailer: str, gz_content: bytes) -> subprocess.CompletedProcess:
"""Гоняет РЕАЛЬНУЮ verify_dump_integrity() из скрипта на временном .gz.
`mktemp`/`cat > "$tmpfile"` внутри bash не python `tempfile` чтобы не
протаскивать windows-путь через границу python/bash (локальная разработка
идёт под Git Bash на Windows).
"""
func_src = _extract_function(REPO_ROOT / script_rel)
harness = f"""
set -u
log() {{ :; }} # заглушка — сигнатура log() одна и та же в обоих скриптах
{func_src}
tmpfile=$(mktemp --suffix=.sql.gz)
trap 'rm -f "$tmpfile"' EXIT
cat > "$tmpfile"
verify_dump_integrity "$tmpfile" "$1" "test-dump"
"""
return subprocess.run(
[BASH, "-c", harness, "bash", trailer],
input=gz_content,
capture_output=True,
timeout=10,
)
def _gz(text: str) -> bytes:
return gzip.compress(text.encode("utf-8"))
def _stderr(result: subprocess.CompletedProcess) -> str:
return result.stderr.decode("utf-8", errors="replace")
@pytest.mark.parametrize("script_rel,trailer", SCRIPTS.items())
def test_verify_dump_integrity_accepts_valid_dump_with_trailer(
script_rel: str, trailer: str
) -> None:
"""Дамп с трейлером последней строкой — валиден (return 0)."""
content = f"CREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\n{trailer}\n"
result = _run_verify(script_rel, trailer, _gz(content))
assert result.returncode == 0, (
f"{script_rel}: валидный дамп с трейлером в последних 5 строках отклонён "
f"(rc={result.returncode}). stderr:\n{_stderr(result)}"
)
@pytest.mark.parametrize("script_rel,trailer", SCRIPTS.items())
def test_verify_dump_integrity_rejects_truncated_dump(script_rel: str, trailer: str) -> None:
"""Дамп без трейлера (оборван на записи) — return 1, не 0."""
content = "CREATE TABLE t (id int);\nINSERT INTO t VALUES (1);\n" # без трейлера
result = _run_verify(script_rel, trailer, _gz(content))
assert result.returncode == 1, (
f"{script_rel}: оборванный дамп должен быть отклонён (rc=1), получили "
f"rc={result.returncode}. stderr:\n{_stderr(result)}"
)
@pytest.mark.parametrize("script_rel,trailer", SCRIPTS.items())
def test_verify_dump_integrity_grep_does_not_choke_on_leading_dashdash(
script_rel: str, trailer: str
) -> None:
"""Регресс-гвоздь #2203: grep не должен падать 'unrecognized option'.
Трейлер начинается с `--`; без `--`-разделителя перед паттерном именно
так и было в проде grep не мог выполниться, и проверка ВСЕГДА
возвращала «трейлера нет» даже на валидном дампе.
"""
content = f"x\n{trailer}\n"
result = _run_verify(script_rel, trailer, _gz(content))
stderr = _stderr(result)
assert "unrecognized option" not in stderr, (
f"{script_rel}: grep споткнулся о ведущие '--' в трейлере — нет "
f"разделителя `--` перед паттерном (#2203). stderr:\n{stderr}"
)
assert result.returncode == 0, (
f"{script_rel}: валидный дамп с '--'-трейлером всё ещё отклоняется "
f"(rc={result.returncode}). stderr:\n{stderr}"
)

View file

@ -71,7 +71,7 @@ verify_dump_integrity() {
log "ERROR: ${label} failed gzip integrity check -> $file" >&2
return 1
fi
if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF "$trailer"; then
if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then
log "ERROR: ${label} missing trailer '${trailer}' -> $file (truncated dump?)" >&2
return 1
fi

View file

@ -56,7 +56,7 @@ verify_dump_integrity() {
log "ОШИБКА: ${label} не прошёл проверку gzip -> $file" >&2
return 1
fi
if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF "$trailer"; then
if ! gunzip -c "$file" 2>/dev/null | tail -5 | grep -qF -- "$trailer"; then
log "ОШИБКА: ${label} без трейлера '${trailer}' -> $file (дамп оборван?)" >&2
return 1
fi