"""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}" )