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
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:
commit
63bc576c40
3 changed files with 162 additions and 2 deletions
160
backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py
Normal file
160
backend/tests/ops/test_2203_backup_trailer_grep_dashdash.py
Normal 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}"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue