From 70f3c0a88ab31f5f41a0c80517b8f7d2b64f1597 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 20 Aug 2026 23:38:45 +0300 Subject: [PATCH] =?UTF-8?q?fix(ops):=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=BA=D0=B0=20=D1=82=D1=80=D0=B5=D0=B9=D0=BB=D0=B5=D1=80?= =?UTF-8?q?=D0=B0=20=D0=B4=D0=B0=D0=BC=D0=BF=D0=B0=20=D0=BF=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D0=BB=D0=B0=20=D0=BD=D0=B0=20grep=20=E2=80=94=20=D0=B2?= =?UTF-8?q?=D0=B5=D0=B4=D1=83=D1=89=D0=B8=D0=B5=20--=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=D0=BD=D0=B8=D0=BC=D0=B0=D0=BB=D0=B8=D1=81=D1=8C=20=D0=B7=D0=B0?= =?UTF-8?q?=20=D0=BE=D0=BF=D1=86=D0=B8=D0=B8=20(#2203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify_dump_integrity() в ops/backup.sh и tradein-mvp/deploy/backup-tradein-db.sh делала `grep -qF "$trailer"`, где $trailer = "-- PostgreSQL database dump complete". Ведущие -- в аргументе grep трактует как конец опций/флаг, без разделителя команда падает: `grep: unrecognized option '-- PostgreSQL...'`. Проверка из-за этого ВСЕГДА возвращала "трейлера нет" — не потому что дамп оборван, а потому что сама проверка не могла выполниться. Вызывающий код удалял только что созданный валидный дамп и завершался с ошибкой; ретеншен не успевал отработать (ранний exit) — свежие бэкапы не создавались никогда, старые копии оставались молча. Воспроизведено вручную на проде: bash /opt/gendesign/tradein-mvp/deploy/backup-tradein-db.sh удалил свежий дамп с сообщением "дамп оборван?". Фикс: `grep -qF -- "$trailer"` — `--` явно завершает список опций grep. Регрессионный тест (backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py) исполняет РЕАЛЬНУЮ verify_dump_integrity() из обоих скриптов на настоящем gzip-потоке через gunzip|tail|grep — не читает исходник текстом. Проверено локально: падает на добаговой версии с тем же "unrecognized option", зелен на исправленной. --- .../test_2203_backup_trailer_grep_dashdash.py | 160 ++++++++++++++++++ ops/backup.sh | 2 +- tradein-mvp/deploy/backup-tradein-db.sh | 2 +- 3 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py diff --git a/backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py b/backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py new file mode 100644 index 00000000..b944536b --- /dev/null +++ b/backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py @@ -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}" + ) diff --git a/ops/backup.sh b/ops/backup.sh index cd0d49f5..4c6c92b2 100755 --- a/ops/backup.sh +++ b/ops/backup.sh @@ -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 diff --git a/tradein-mvp/deploy/backup-tradein-db.sh b/tradein-mvp/deploy/backup-tradein-db.sh index 39eb3644..fc523de3 100755 --- a/tradein-mvp/deploy/backup-tradein-db.sh +++ b/tradein-mvp/deploy/backup-tradein-db.sh @@ -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 -- 2.45.3