Files
PacketStation/tests/server/test_backup_scripts.py
T

288 lines
11 KiB
Python
Raw Normal View History

"""scripts/backup/ps-backup.sh with a fake `docker` on PATH.
No Docker and no database: the fake answers `docker ps`, `docker exec ... pg_dump`, `pg_dumpall` and
`pg_restore --list`, and records every call. Runs on Linux (CI, docker-1) and on Windows when a
Git Bash or Cygwin bash with cygpath is installed; skipped otherwise.
"""
from __future__ import annotations
import os
import re
import shutil
import stat
import subprocess
import sys
from pathlib import Path
import pytest
REPO = Path(__file__).resolve().parents[2]
BACKUP = REPO / "scripts" / "backup" / "ps-backup.sh"
RESTORE = REPO / "scripts" / "backup" / "ps-restore.sh"
WINDOWS = sys.platform == "win32"
FAKE_DOCKER = r"""#!/usr/bin/env bash
# Fake docker for the backup script tests. Behaviour comes from FAKE_* variables.
printf '%s\n' "$*" >> "$FAKE_DOCKER_LOG"
cmd="$1"; shift
case "$cmd" in
ps)
if [[ -n "${FAKE_PS_IDS+x}" ]]; then
[[ -n "$FAKE_PS_IDS" ]] && printf '%s\n' $FAKE_PS_IDS
else
echo pgfake
fi
exit 0 ;;
exec)
[[ "$1" == "-i" ]] && shift
container="$1"; shift
tool="$1"; shift
case "$tool" in
pg_dump)
[[ "$container" == "${FAKE_DUMP_FAIL_CONTAINER:-none}" ]] && { echo "pg_dump: error: fake" >&2; exit 1; }
[[ -n "${FAKE_DUMP_EMPTY:-}" ]] && exit 0
printf 'PGDMP fake dump of %s\n' "$container"
exit 0 ;;
pg_dumpall)
printf 'CREATE ROLE ps_app;\nALTER ROLE ps_app WITH LOGIN;\n'
exit 0 ;;
pg_restore)
input="$(cat)"
[[ -n "${FAKE_RESTORE_FAIL:-}" ]] && { echo "pg_restore: error: input file is too short" >&2; exit 1; }
[[ "$input" == PGDMP* ]] || { echo "pg_restore: error: not a dump" >&2; exit 1; }
printf ';\n; Archive created at 2026-10-21\n;\n215; 1259 16390 TABLE public packets ps_migrate\n'
printf '3801; 0 16390 TABLE DATA public packets ps_migrate\n'
exit 0 ;;
esac ;;
esac
echo "fake docker: unexpected call: $cmd $*" >&2
exit 99
"""
def _find_bash() -> str | None:
if not WINDOWS:
return shutil.which("bash")
candidates = [
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files\Git\usr\bin\bash.exe",
shutil.which("bash") or "",
]
for c in candidates:
if not c or not Path(c).exists() or "system32" in c.lower():
continue
try:
out = subprocess.run(
[c, "-c", "cygpath -u 'C:\\' && command -v find stat mktemp"],
capture_output=True,
text=True,
timeout=30,
)
except OSError:
continue
if out.returncode == 0:
return c
return None
BASH = _find_bash()
pytestmark = pytest.mark.skipif(BASH is None, reason="no usable bash (Linux bash, or Git Bash with cygpath)")
def _posix(p: Path) -> str:
if not WINDOWS:
return str(p)
assert BASH is not None
out = subprocess.run([BASH, "-c", 'cygpath -u "$1"', "_", str(p)], capture_output=True, text=True, check=True)
return out.stdout.strip()
@pytest.fixture()
def fake(tmp_path: Path) -> dict[str, object]:
bindir = tmp_path / "bin"
bindir.mkdir()
docker = bindir / "docker"
docker.write_text(FAKE_DOCKER, encoding="utf-8", newline="\n")
docker.chmod(docker.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
log = tmp_path / "docker.log"
log.touch()
env = {k: v for k, v in os.environ.items() if not k.startswith("FAKE_")}
env["FAKE_DOCKER_LOG"] = _posix(log)
env["DOCKER"] = _posix(docker)
env["PATH"] = str(bindir) + os.pathsep + env.get("PATH", "")
env.pop("KEEP", None)
env.pop("PS_BACKUP_CONFIG", None)
return {"env": env, "log": log, "tmp": tmp_path}
def run_backup(fake: dict[str, object], *args: str, **extra_env: str) -> subprocess.CompletedProcess[str]:
env = dict(fake["env"]) # type: ignore[call-overload]
env.update(extra_env)
assert BASH is not None
return subprocess.run([BASH, _posix(BACKUP), *args], capture_output=True, text=True, env=env, timeout=120)
def stack_args(name: str, folder: Path, container: str = "project:packetstation-test") -> list[str]:
return ["--stack", name, container, "packetstation", _posix(folder)]
def dumps(folder: Path) -> list[str]:
return sorted(p.name for p in folder.glob("ps-*.dump"))
def no_partials(folder: Path) -> bool:
return not [p for p in folder.iterdir() if p.name.endswith(".partial")]
MARKER = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z (ps-\d{8}T\d{6}Z\.dump)(.*)$")
def test_success_writes_dump_globals_and_marker(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
res = run_backup(fake, *stack_args("test", folder))
assert res.returncode == 0, res.stderr
names = dumps(folder)
assert len(names) == 1
dump = folder / names[0]
assert dump.read_text().startswith("PGDMP")
globals_file = folder / names[0].replace(".dump", ".globals.sql")
assert "CREATE ROLE" in globals_file.read_text()
m = MARKER.match((folder / "last-success").read_text().strip())
assert m and m.group(1) == names[0]
assert not (folder / "last-failure").exists()
assert no_partials(folder)
if not WINDOWS:
assert stat.S_IMODE(dump.stat().st_mode) == 0o600
assert stat.S_IMODE(globals_file.stat().st_mode) == 0o600
assert stat.S_IMODE((folder / "last-success").stat().st_mode) == 0o644
calls = fake["log"].read_text() # type: ignore[attr-defined]
assert "label=com.docker.compose.project=packetstation-test" in calls
assert "exec pgfake pg_dump -U postgres -d packetstation -Fc" in calls
assert "pg_dumpall -U postgres --globals-only --no-role-passwords" in calls
assert "exec -i pgfake pg_restore --list" in calls
def test_rotation_keeps_seven(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
folder.mkdir()
old = [f"ps-202601{d:02d}T020000Z" for d in range(1, 9)] # 8 older dumps
for base in old:
(folder / f"{base}.dump").write_text("PGDMP old")
(folder / f"{base}.globals.sql").write_text("-- old")
(folder / "ps-20251231T020000Z.globals.sql").write_text("-- orphan globals")
res = run_backup(fake, *stack_args("test", folder))
assert res.returncode == 0, res.stderr
names = dumps(folder)
assert len(names) == 7
# The newest six old ones and today's stay; the two oldest go with their globals.
assert names[:6] == [f"{b}.dump" for b in old[2:]]
assert not (folder / f"{old[0]}.globals.sql").exists()
assert not (folder / f"{old[1]}.globals.sql").exists()
assert not (folder / "ps-20251231T020000Z.globals.sql").exists()
assert sorted(p.name for p in folder.glob("*.globals.sql")) == [n.replace(".dump", ".globals.sql") for n in names]
def test_keep_is_configurable(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
folder.mkdir()
for d in range(1, 5):
(folder / f"ps-202601{d:02d}T020000Z.dump").write_text("PGDMP old")
res = run_backup(fake, "--keep", "2", *stack_args("test", folder))
assert res.returncode == 0, res.stderr
assert len(dumps(folder)) == 2
def test_failed_restore_list_writes_last_failure(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
folder.mkdir()
(folder / "ps-20260101T020000Z.dump").write_text("PGDMP old")
(folder / "last-success").write_text("2026-01-01T02:00:05Z ps-20260101T020000Z.dump\n")
res = run_backup(fake, *stack_args("test", folder), FAKE_RESTORE_FAIL="1")
assert res.returncode != 0
failure = (folder / "last-failure").read_text().strip()
m = MARKER.match(failure)
assert m, failure
assert "pg_restore --list" in m.group(2)
# Nothing new kept, nothing half-written, the old success left alone.
assert dumps(folder) == ["ps-20260101T020000Z.dump"]
assert not list(folder.glob("ps-*.globals.sql"))
assert no_partials(folder)
assert (folder / "last-success").read_text().startswith("2026-01-01")
assert "FAILED" in res.stderr
def test_failed_pg_dump_and_empty_dump(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
res = run_backup(fake, *stack_args("test", folder, container="pgdirect"), FAKE_DUMP_FAIL_CONTAINER="pgdirect")
assert res.returncode != 0
assert "pg_dump failed" in (folder / "last-failure").read_text()
assert dumps(folder) == []
assert no_partials(folder)
res = run_backup(fake, *stack_args("test", folder, container="pgdirect"), FAKE_DUMP_EMPTY="1")
assert res.returncode != 0
assert "pg_dump wrote nothing" in (folder / "last-failure").read_text()
assert not (folder / "last-success").exists()
def test_no_container_for_project(fake: dict[str, object]) -> None:
folder = fake["tmp"] / "backups" # type: ignore[operator]
res = run_backup(fake, *stack_args("test", folder), FAKE_PS_IDS="")
assert res.returncode != 0
assert "no running postgres container" in (folder / "last-failure").read_text()
res = run_backup(fake, *stack_args("test", folder), FAKE_PS_IDS="aaa bbb")
assert res.returncode != 0
assert "more than one postgres container" in (folder / "last-failure").read_text()
def test_one_stack_fails_the_other_still_runs(fake: dict[str, object]) -> None:
good = fake["tmp"] / "good" # type: ignore[operator]
bad = fake["tmp"] / "bad" # type: ignore[operator]
res = run_backup(
fake,
*stack_args("bad", bad, container="pgbad"),
*stack_args("good", good, container="pggood"),
FAKE_DUMP_FAIL_CONTAINER="pgbad",
)
assert res.returncode == 1
assert (bad / "last-failure").exists() and not (bad / "last-success").exists()
assert (good / "last-success").exists() and not (good / "last-failure").exists()
assert len(dumps(good)) == 1
def test_config_file_and_stack_selection(fake: dict[str, object]) -> None:
tmp: Path = fake["tmp"] # type: ignore[assignment]
a, b = tmp / "a", tmp / "b"
conf = tmp / "backup.conf"
conf.write_text(
"# name container database folder\n"
f"alpha pga packetstation {_posix(a)}\n"
"\n"
f"beta pgb packetstation {_posix(b)}\n",
encoding="utf-8",
newline="\n",
)
res = run_backup(fake, "--config", _posix(conf), "beta")
assert res.returncode == 0, res.stderr
assert not a.exists()
assert len(dumps(b)) == 1
res = run_backup(fake, "--config", _posix(conf), "gamma")
assert res.returncode == 2
res = run_backup(fake, "--config", _posix(tmp / "missing.conf"))
assert res.returncode == 2
def test_scripts_are_strict_and_parse() -> None:
assert BASH is not None
for script in (BACKUP, RESTORE):
text = script.read_text(encoding="utf-8")
assert "\r" not in text
assert "set -euo pipefail" in text
assert "umask 077" in text
res = subprocess.run([BASH, "-n", _posix(script)], capture_output=True, text=True)
assert res.returncode == 0, res.stderr