Admin: Departments (name and stations; add shows the three groups IT must create), Profiles (welder stamp, station limits, active) with a CSV import that previews and applies only when clean, Checklists (weld and plain), Security (session, lockout and rate-limit settings with bounds, alerts for the last 24 hours, the integrity result), Access log with filters and a guarded CSV, Who is in, Stamp check, Notifications, and the employeeID flags. Every Admin change is audited. Worker: ad-reconcile (nightly and on "refresh from AD now"), session-refresh (15 minutes), integrity (seals, asset hashes, missing and orphan files, the audit chain), backup-check, notify-send and notify-digest. Notifications route per event type to AD groups, users, mailboxes or the department Supervisor group, immediately or in a daily digest, over STARTTLS verified against the PESCO CA, with dry run on the test stack. Backups: scripts/backup/ps-backup.sh and ps-restore.sh with a systemd timer at 02:00 America/Denver. Restore rehearsed on the test stack 2026-10-21. CI: pip-audit and npm audit. pypdf 6.21.1 and certifi 2026.10.19. Migration 0010. Closes the app part of I6, M3, O1, O2 and S3. Co-Authored-By: Claude Opus 5.5 <[email protected]>
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""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
|