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
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", зелен на исправленной.
160 lines
8.6 KiB
Python
160 lines
8.6 KiB
Python
"""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}"
|
||
)
|