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