gendesign/backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py
bot-backend 70f3c0a88a
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 9s
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 1m59s
CI / backend-tests (pull_request) Successful in 17m7s
fix(ops): проверка трейлера дампа падала на grep — ведущие -- принимались за опции (#2203)
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", зелен
на исправленной.
2026-08-20 23:38:45 +03:00

160 lines
8.6 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.

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