Native VM install kit: systemd units, launcher, install and upgrade scripts; backups, restore and doctor without docker exec; DEPLOY.md VM, NPM and NFS sections
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -1,12 +1,14 @@
|
||||
"""scripts/backup/ps-backup.sh with a fake `docker` on PATH.
|
||||
"""The VM backup and restore scripts (scripts/backup) and the VM kit's shell parts (deploy/vm).
|
||||
|
||||
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.
|
||||
No PostgreSQL, no systemd and no root: fake `pg_dump`, `pg_dumpall`, `pg_restore` and `psql` sit in a
|
||||
folder given as PG_BINDIR, a fake user switch stands in for setpriv (PG_AS), and a fake `systemd-run`
|
||||
prints what ps-run would start. Every fake records its calls. 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 hashlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -20,46 +22,65 @@ import pytest
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
BACKUP = REPO / "scripts" / "backup" / "ps-backup.sh"
|
||||
RESTORE = REPO / "scripts" / "backup" / "ps-restore.sh"
|
||||
VM = REPO / "deploy" / "vm"
|
||||
UNITS = VM / "systemd"
|
||||
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
|
||||
# One script, written under each tool's name. It dispatches on its own name.
|
||||
FAKE_PG = r"""#!/usr/bin/env bash
|
||||
tool="$(basename "$0")"
|
||||
printf '%s %s\n' "$tool" "$*" >> "$FAKE_LOG"
|
||||
case "$tool" in
|
||||
asuser)
|
||||
exec "$@" ;;
|
||||
pg_dump)
|
||||
db=""
|
||||
while [[ $# -gt 0 ]]; do [[ "$1" == "-d" ]] && db="$2"; shift; done
|
||||
[[ "$db" == "${FAKE_DUMP_FAIL_DB:-none}" ]] && { echo "pg_dump: error: fake" >&2; exit 1; }
|
||||
[[ -n "${FAKE_DUMP_EMPTY:-}" ]] && exit 0
|
||||
printf 'PGDMP fake dump of %s\n' "$db"
|
||||
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; }
|
||||
if [[ " $* " == *" --list "* ]]; then
|
||||
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'
|
||||
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 ;;
|
||||
psql)
|
||||
sql="$(cat)"
|
||||
printf '%s\n' "$sql" | sed 's/^/ sql: /' >> "$FAKE_LOG"
|
||||
case "$sql" in
|
||||
*"FROM pg_database"*) echo "${FAKE_DB_EXISTS:-0}" ;;
|
||||
*"FROM pg_roles"*) echo "${FAKE_ROLE_EXISTS:-1}" ;;
|
||||
*"FROM assets"*) cat "$FAKE_ASSET_ROWS" ;;
|
||||
*"relkind"*) printf 'packets\t3\n' ;;
|
||||
*"CREATE ROLE"*) printf '%s\n' "${FAKE_GLOBALS_ERR:-}" >&2 ;;
|
||||
esac
|
||||
exit 0 ;;
|
||||
esac
|
||||
echo "fake docker: unexpected call: $cmd $*" >&2
|
||||
echo "fake: unexpected tool $tool" >&2
|
||||
exit 99
|
||||
"""
|
||||
|
||||
FAKE_SYSTEMD_RUN = r"""#!/usr/bin/env bash
|
||||
for a in "$@"; do printf '%s\n' "$a"; done
|
||||
"""
|
||||
|
||||
FAKE_PACKETSTATION = r"""#!/usr/bin/env bash
|
||||
printf 'args=%s\n' "$*"
|
||||
for v in PG_APP_PASSWORD_FILE PG_MIGRATE_PASSWORD_FILE VISUAL_PASSWORD_FILE MFILES_PASSWORD_FILE \
|
||||
SESSION_KEY_FILE SEAL_KEY_FILE STATIC_DIR PS_BUILD WORKER_BEAT_FILE TZ; do
|
||||
printf '%s=%s\n' "$v" "${!v-<unset>}"
|
||||
done
|
||||
printf 'MFILES_PASSWORD=%s\n' "${MFILES_PASSWORD-<unset>}"
|
||||
"""
|
||||
|
||||
|
||||
def _find_bash() -> str | None:
|
||||
if not WINDOWS:
|
||||
@@ -74,7 +95,7 @@ def _find_bash() -> str | None:
|
||||
continue
|
||||
try:
|
||||
out = subprocess.run(
|
||||
[c, "-c", "cygpath -u 'C:\\' && command -v find stat mktemp"],
|
||||
[c, "-c", "cygpath -u 'C:\\' && command -v find stat mktemp sha256sum"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
@@ -87,7 +108,7 @@ def _find_bash() -> str | None:
|
||||
|
||||
|
||||
BASH = _find_bash()
|
||||
pytestmark = pytest.mark.skipif(BASH is None, reason="no usable bash (Linux bash, or Git Bash with cygpath)")
|
||||
needs_bash = pytest.mark.skipif(BASH is None, reason="no usable bash (Linux bash, or Git Bash with cygpath)")
|
||||
|
||||
|
||||
def _posix(p: Path) -> str:
|
||||
@@ -98,33 +119,45 @@ def _posix(p: Path) -> str:
|
||||
return out.stdout.strip()
|
||||
|
||||
|
||||
def _write_exec(path: Path, text: str) -> None:
|
||||
path.write_text(text, encoding="utf-8", newline="\n")
|
||||
path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def fake(tmp_path: Path) -> dict[str, object]:
|
||||
bindir = tmp_path / "bin"
|
||||
bindir = tmp_path / "pgbin"
|
||||
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"
|
||||
for tool in ("asuser", "pg_dump", "pg_dumpall", "pg_restore", "psql"):
|
||||
_write_exec(bindir / tool, FAKE_PG)
|
||||
log = tmp_path / "calls.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)
|
||||
env["FAKE_LOG"] = _posix(log)
|
||||
env["PG_BINDIR"] = _posix(bindir)
|
||||
env["PG_AS"] = _posix(bindir / "asuser")
|
||||
for k in ("KEEP", "PS_BACKUP_CONFIG"):
|
||||
env.pop(k, None)
|
||||
return {"env": env, "log": log, "tmp": tmp_path}
|
||||
|
||||
|
||||
def run_backup(fake: dict[str, object], *args: str, **extra_env: str) -> subprocess.CompletedProcess[str]:
|
||||
def _run(script: Path, 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)
|
||||
return subprocess.run([BASH, _posix(script), *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 run_backup(fake: dict[str, object], *args: str, **extra_env: str) -> subprocess.CompletedProcess[str]:
|
||||
return _run(BACKUP, fake, *args, **extra_env)
|
||||
|
||||
|
||||
def run_restore(fake: dict[str, object], *args: str, **extra_env: str) -> subprocess.CompletedProcess[str]:
|
||||
return _run(RESTORE, fake, *args, **extra_env)
|
||||
|
||||
|
||||
def stack_args(name: str, folder: Path, database: str = "packetstation") -> list[str]:
|
||||
return ["--stack", name, database, _posix(folder)]
|
||||
|
||||
|
||||
def dumps(folder: Path) -> list[str]:
|
||||
@@ -138,6 +171,10 @@ def no_partials(folder: Path) -> bool:
|
||||
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)(.*)$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- ps-backup
|
||||
|
||||
|
||||
@needs_bash
|
||||
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))
|
||||
@@ -157,12 +194,14 @@ def test_success_writes_dump_globals_and_marker(fake: dict[str, object]) -> None
|
||||
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
|
||||
# pg_dump and pg_dumpall go through the user switch; pg_restore --list needs no database and no switch.
|
||||
assert re.search(r"^asuser \S*/pg_dump -d packetstation -Fc$", calls, re.M), calls
|
||||
assert re.search(r"^asuser \S*/pg_dumpall --globals-only --no-role-passwords$", calls, re.M), calls
|
||||
assert re.search(r"^pg_restore --list$", calls, re.M), calls
|
||||
assert "docker" not in calls
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_rotation_keeps_seven(fake: dict[str, object]) -> None:
|
||||
folder = fake["tmp"] / "backups" # type: ignore[operator]
|
||||
folder.mkdir()
|
||||
@@ -183,6 +222,7 @@ def test_rotation_keeps_seven(fake: dict[str, object]) -> None:
|
||||
assert sorted(p.name for p in folder.glob("*.globals.sql")) == [n.replace(".dump", ".globals.sql") for n in names]
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_keep_is_configurable(fake: dict[str, object]) -> None:
|
||||
folder = fake["tmp"] / "backups" # type: ignore[operator]
|
||||
folder.mkdir()
|
||||
@@ -191,8 +231,10 @@ def test_keep_is_configurable(fake: dict[str, object]) -> None:
|
||||
res = run_backup(fake, "--keep", "2", *stack_args("test", folder))
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert len(dumps(folder)) == 2
|
||||
assert run_backup(fake, "--keep", "0", *stack_args("test", folder)).returncode == 2
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_failed_restore_list_writes_last_failure(fake: dict[str, object]) -> None:
|
||||
folder = fake["tmp"] / "backups" # type: ignore[operator]
|
||||
folder.mkdir()
|
||||
@@ -212,39 +254,39 @@ def test_failed_restore_list_writes_last_failure(fake: dict[str, object]) -> Non
|
||||
assert "FAILED" in res.stderr
|
||||
|
||||
|
||||
@needs_bash
|
||||
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")
|
||||
res = run_backup(fake, *stack_args("test", folder, database="nosuchdb"), FAKE_DUMP_FAIL_DB="nosuchdb")
|
||||
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")
|
||||
res = run_backup(fake, *stack_args("test", folder), 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:
|
||||
@needs_bash
|
||||
def test_bad_database_name_is_refused(fake: dict[str, object]) -> None:
|
||||
folder = fake["tmp"] / "backups" # type: ignore[operator]
|
||||
res = run_backup(fake, *stack_args("test", folder), FAKE_PS_IDS="")
|
||||
res = run_backup(fake, *stack_args("test", folder, database="x;rm -rf"))
|
||||
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()
|
||||
assert "not a plain name" in (folder / "last-failure").read_text()
|
||||
assert "pg_dump" not in fake["log"].read_text() # type: ignore[attr-defined]
|
||||
|
||||
|
||||
@needs_bash
|
||||
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",
|
||||
*stack_args("bad", bad, database="baddb"),
|
||||
*stack_args("good", good),
|
||||
FAKE_DUMP_FAIL_DB="baddb",
|
||||
)
|
||||
assert res.returncode == 1
|
||||
assert (bad / "last-failure").exists() and not (bad / "last-success").exists()
|
||||
@@ -252,15 +294,13 @@ def test_one_stack_fails_the_other_still_runs(fake: dict[str, object]) -> None:
|
||||
assert len(dumps(good)) == 1
|
||||
|
||||
|
||||
@needs_bash
|
||||
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",
|
||||
f"# name database folder\nalpha packetstation {_posix(a)}\n\nbeta packetstation {_posix(b)}\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
@@ -275,13 +315,382 @@ def test_config_file_and_stack_selection(fake: dict[str, object]) -> None:
|
||||
res = run_backup(fake, "--config", _posix(tmp / "missing.conf"))
|
||||
assert res.returncode == 2
|
||||
|
||||
# The old docker-1 format (with a container column) is refused, not misread.
|
||||
conf.write_text(f"test project:packetstation-test packetstation {_posix(a)}\n", encoding="utf-8", newline="\n")
|
||||
res = run_backup(fake, "--config", _posix(conf))
|
||||
assert res.returncode == 2
|
||||
assert "name database folder" in res.stderr
|
||||
|
||||
def test_scripts_are_strict_and_parse() -> None:
|
||||
|
||||
# ---------------------------------------------------------------- ps-restore
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_restore_into_a_fresh_database(fake: dict[str, object]) -> None:
|
||||
tmp: Path = fake["tmp"] # type: ignore[assignment]
|
||||
dump = tmp / "ps-20261030T080000Z.dump"
|
||||
dump.write_text("PGDMP fake")
|
||||
res = run_restore(fake, "--dump", _posix(dump), "--db", "ps_restored")
|
||||
assert res.returncode == 0, res.stderr
|
||||
calls = fake["log"].read_text() # type: ignore[attr-defined]
|
||||
assert "CREATE DATABASE %I OWNER %I ENCODING %L LOCALE %L TEMPLATE template0" in calls
|
||||
assert "-v name=ps_restored -v owner=ps_migrate -v loc=en_US.UTF-8" in calls
|
||||
assert "GRANT CONNECT, TEMPORARY" in calls
|
||||
assert re.search(r"^asuser \S*/pg_restore -d ps_restored --exit-on-error --single-transaction$", calls, re.M)
|
||||
assert "packets\t3" in res.stdout
|
||||
assert "DROP DATABASE" not in calls
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_restore_guards(fake: dict[str, object]) -> None:
|
||||
tmp: Path = fake["tmp"] # type: ignore[assignment]
|
||||
dump = tmp / "ps-1.dump"
|
||||
dump.write_text("PGDMP fake")
|
||||
|
||||
res = run_restore(fake, "--dump", _posix(dump), "--db", "ps_restored", FAKE_DB_EXISTS="1")
|
||||
assert res.returncode == 1
|
||||
assert "exists. Give --replace" in res.stderr
|
||||
|
||||
res = run_restore(fake, "--dump", _posix(dump), "--db", "packetstation", "--replace", FAKE_DB_EXISTS="1")
|
||||
assert res.returncode == 1
|
||||
assert "refused for packetstation" in res.stderr
|
||||
|
||||
res = run_restore(fake, "--dump", _posix(dump), "--db", "Bad-Name")
|
||||
assert res.returncode == 1
|
||||
|
||||
hashed = tmp / "ps-1.globals.sql"
|
||||
hashed.write_text("CREATE ROLE ps_app;\nALTER ROLE ps_app WITH LOGIN PASSWORD 'SCRAM-SHA-256$4096:abc';\n")
|
||||
res = run_restore(fake, "--globals", _posix(hashed))
|
||||
assert res.returncode == 1
|
||||
assert "password hashes" in res.stderr
|
||||
|
||||
res = run_restore(fake)
|
||||
assert res.returncode == 1
|
||||
assert "nothing to do" in res.stderr
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_restore_replace_drops_first(fake: dict[str, object]) -> None:
|
||||
dump = fake["tmp"] / "ps-1.dump" # type: ignore[operator]
|
||||
dump.write_text("PGDMP fake")
|
||||
res = run_restore(fake, "--dump", _posix(dump), "--db", "ps_restored", "--replace", FAKE_DB_EXISTS="1")
|
||||
assert res.returncode == 0, res.stderr
|
||||
calls = fake["log"].read_text() # type: ignore[attr-defined]
|
||||
assert calls.index("DROP DATABASE") < calls.index("CREATE DATABASE")
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_restore_globals_skips_existing_roles(fake: dict[str, object]) -> None:
|
||||
g = fake["tmp"] / "ps-1.globals.sql" # type: ignore[operator]
|
||||
g.write_text("CREATE ROLE ps_app;\nALTER ROLE ps_app WITH LOGIN;\n")
|
||||
res = run_restore(fake, "--globals", _posix(g), FAKE_GLOBALS_ERR='ERROR: role "ps_app" already exists')
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert "1 statements skipped" in res.stderr
|
||||
res = run_restore(fake, "--globals", _posix(g), FAKE_GLOBALS_ERR="ERROR: syntax error")
|
||||
assert res.returncode == 1
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_restore_assets_never_overwrite_and_verify(fake: dict[str, object]) -> None:
|
||||
tmp: Path = fake["tmp"] # type: ignore[assignment]
|
||||
src = tmp / "src"
|
||||
(src / "ab").mkdir(parents=True)
|
||||
(src / "ab" / "one").write_bytes(b"one")
|
||||
(src / "ab" / "two").write_bytes(b"two")
|
||||
dst = tmp / "assets"
|
||||
(dst / "ab").mkdir(parents=True)
|
||||
(dst / "ab" / "two").write_bytes(b"kept") # an existing file is never replaced
|
||||
# 0:0 so a run as root (CI containers) has an owner that exists; as a normal user there is no chown.
|
||||
res = run_restore(fake, "--assets", _posix(src), "--assets-dir", _posix(dst), "--assets-owner", "0:0")
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert (dst / "ab" / "one").read_bytes() == b"one"
|
||||
assert (dst / "ab" / "two").read_bytes() == b"kept"
|
||||
|
||||
def rows(files: dict[str, bytes]) -> str:
|
||||
return "".join(f"{hashlib.sha256(v).hexdigest()} {k}\n" for k, v in sorted(files.items()))
|
||||
|
||||
rows_file = tmp / "rows"
|
||||
rows_file.write_text(rows({"ab/one": b"one", "ab/two": b"kept"}), newline="\n")
|
||||
args = ("--verify-assets", "--db", "ps_restored", "--assets-dir", _posix(dst))
|
||||
res = run_restore(fake, *args, FAKE_ASSET_ROWS=_posix(rows_file))
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert "2 rows, 2 match, 0 differ, 0 missing, 0 files with no row" in res.stderr
|
||||
|
||||
(dst / "ab" / "stray").write_bytes(b"x")
|
||||
rows_file.write_text(rows({"ab/one": b"one", "ab/two": b"two", "ab/gone": b"g"}), newline="\n")
|
||||
res = run_restore(fake, *args, FAKE_ASSET_ROWS=_posix(rows_file))
|
||||
assert res.returncode == 1
|
||||
assert "3 rows, 1 match, 1 differ, 1 missing, 1 files with no row" in res.stderr
|
||||
assert "ORPHAN: ab/stray" in res.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- deploy/vm: launcher and ps-run
|
||||
|
||||
|
||||
def _release(tmp: Path) -> Path:
|
||||
rel = tmp / "opt" / "sha-abc1234"
|
||||
(rel / "deploy" / "vm").mkdir(parents=True)
|
||||
(rel / "venv" / "bin").mkdir(parents=True)
|
||||
(rel / "static").mkdir()
|
||||
shutil.copy(VM / "ps-launch.sh", rel / "deploy" / "vm" / "ps-launch.sh")
|
||||
(rel / "deploy" / "vm" / "ps-launch.sh").chmod(0o755)
|
||||
_write_exec(rel / "venv" / "bin" / "packetstation", FAKE_PACKETSTATION)
|
||||
(rel / "BUILD").write_text("sha-abc1234\n", newline="\n")
|
||||
return rel
|
||||
|
||||
|
||||
def _launch(rel: Path, env: dict[str, str], *args: str) -> dict[str, str]:
|
||||
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
|
||||
base = {k: v for k, v in os.environ.items() if not k.endswith("_FILE") and k != "CREDENTIALS_DIRECTORY"}
|
||||
base.pop("MFILES_PASSWORD", None)
|
||||
base.update(env)
|
||||
res = subprocess.run(
|
||||
[BASH, _posix(rel / "deploy" / "vm" / "ps-launch.sh"), *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=base,
|
||||
timeout=60,
|
||||
)
|
||||
assert res.returncode == 0, res.stderr
|
||||
return dict(line.split("=", 1) for line in res.stdout.splitlines())
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_launcher_maps_credentials_and_sets_release_paths(tmp_path: Path) -> None:
|
||||
rel = _release(tmp_path)
|
||||
creds = tmp_path / "creds"
|
||||
creds.mkdir()
|
||||
(creds / "postgres_app_password").write_text("pw-app")
|
||||
(creds / "mfileskit_password").write_text("mf-secret\r\n")
|
||||
(creds / "seal_key").write_text("k" * 32)
|
||||
out = _launch(
|
||||
rel,
|
||||
{
|
||||
"CREDENTIALS_DIRECTORY": _posix(creds),
|
||||
"VISUAL_PASSWORD_FILE": "/etc/packetstation/secrets/visualkit_password",
|
||||
"STATIC_DIR": "/elsewhere",
|
||||
"PS_BUILD": "wrong",
|
||||
},
|
||||
"worker",
|
||||
)
|
||||
c = _posix(creds)
|
||||
assert out["args"] == "worker"
|
||||
assert out["PG_APP_PASSWORD_FILE"] == f"{c}/postgres_app_password"
|
||||
assert out["SEAL_KEY_FILE"] == f"{c}/seal_key"
|
||||
assert out["MFILES_PASSWORD_FILE"] == f"{c}/mfileskit_password"
|
||||
# Not a credential of this unit: the env file's value stays and nothing is invented.
|
||||
assert out["VISUAL_PASSWORD_FILE"] == "/etc/packetstation/secrets/visualkit_password"
|
||||
assert out["PG_MIGRATE_PASSWORD_FILE"] == "<unset>"
|
||||
# Exported from the file, trailing CR LF dropped (the entrypoint drops a CR; the launcher reads with $(<)).
|
||||
assert out["MFILES_PASSWORD"] == "mf-secret"
|
||||
# The release wins over the env file for these.
|
||||
assert out["STATIC_DIR"] == f"{_posix(rel)}/static"
|
||||
assert out["PS_BUILD"] == "sha-abc1234"
|
||||
assert out["WORKER_BEAT_FILE"] == "/run/packetstation/worker.beat"
|
||||
assert out["TZ"] == "UTC"
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_launcher_without_credentials_defaults_to_app(tmp_path: Path) -> None:
|
||||
rel = _release(tmp_path)
|
||||
out = _launch(rel, {"MFILES_PASSWORD_FILE": "/nonexistent/mfileskit_password"})
|
||||
assert out["args"] == "app"
|
||||
assert out["MFILES_PASSWORD"] == "<unset>"
|
||||
assert out["PG_APP_PASSWORD_FILE"] == "<unset>"
|
||||
|
||||
|
||||
@needs_bash
|
||||
def test_ps_run_passes_env_file_credentials_and_sandbox(tmp_path: Path) -> None:
|
||||
rel = _release(tmp_path)
|
||||
secrets = tmp_path / "secrets"
|
||||
secrets.mkdir()
|
||||
(secrets / "postgres_app_password").write_text("x")
|
||||
(secrets / "ldap_bind_password").write_text("y")
|
||||
env_file = tmp_path / "packetstation.env"
|
||||
env_file.write_text("STACK=test\n", newline="\n")
|
||||
fake_run = tmp_path / "systemd-run"
|
||||
_write_exec(fake_run, FAKE_SYSTEMD_RUN)
|
||||
assert BASH is not None
|
||||
env = dict(os.environ)
|
||||
env.update(
|
||||
PS_RUN_ALLOW_NONROOT="1",
|
||||
SYSTEMD_RUN=_posix(fake_run),
|
||||
PS_ENV_FILE_PATH=_posix(env_file),
|
||||
PS_SECRETS_DIR=_posix(secrets),
|
||||
PS_CURRENT=_posix(rel),
|
||||
)
|
||||
res = subprocess.run(
|
||||
[BASH, _posix(VM / "ps-run.sh"), "doctor", "--skip", "visual"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
timeout=60,
|
||||
)
|
||||
assert res.returncode == 0, res.stderr
|
||||
argv = res.stdout.splitlines()
|
||||
s = _posix(secrets)
|
||||
assert argv[:5] == ["--wait", "--pipe", "--collect", "--quiet", "--service-type=exec"]
|
||||
assert "User=packetstation" in argv and "Group=packetstation" in argv
|
||||
assert f"EnvironmentFile={_posix(env_file)}" in argv
|
||||
assert "ProtectSystem=strict" in argv and "NoNewPrivileges=yes" in argv
|
||||
assert f"InaccessiblePaths=-{s}" in argv
|
||||
creds = [a for a in argv if a.startswith("LoadCredential=")]
|
||||
assert creds == [
|
||||
f"LoadCredential=postgres_app_password:{s}/postgres_app_password",
|
||||
f"LoadCredential=ldap_bind_password:{s}/ldap_bind_password",
|
||||
]
|
||||
assert argv[-4:] == [f"{_posix(rel)}/deploy/vm/ps-launch.sh", "doctor", "--skip", "visual"]
|
||||
|
||||
env.pop("PS_RUN_ALLOW_NONROOT")
|
||||
if sys.platform != "win32" and os.geteuid() != 0:
|
||||
res = subprocess.run([BASH, _posix(VM / "ps-run.sh"), "doctor"], capture_output=True, text=True, env=env)
|
||||
assert res.returncode == 2
|
||||
assert "run as root" in res.stderr
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- static checks
|
||||
|
||||
|
||||
SCRIPTS = [BACKUP, RESTORE, *sorted(VM.glob("*.sh"))]
|
||||
|
||||
|
||||
@needs_bash
|
||||
@pytest.mark.parametrize("script", SCRIPTS, ids=lambda p: p.name)
|
||||
def test_scripts_are_strict_and_parse(script: Path) -> None:
|
||||
assert BASH is not None
|
||||
text = script.read_text(encoding="utf-8")
|
||||
assert "\r" not in text
|
||||
assert text.startswith("#!/usr/bin/env bash\n")
|
||||
assert "set -euo pipefail" in text
|
||||
if script in (BACKUP, RESTORE):
|
||||
assert "umask 077" in text
|
||||
res = subprocess.run([BASH, "-n", _posix(script)], capture_output=True, text=True)
|
||||
assert res.returncode == 0, res.stderr
|
||||
if script.name != "make-release.sh":
|
||||
assert "docker exec" not in text and "docker run" not in text
|
||||
res = subprocess.run([BASH, "-n", _posix(script)], capture_output=True, text=True)
|
||||
assert res.returncode == 0, res.stderr
|
||||
|
||||
|
||||
Unit = dict[str, dict[str, list[str]]]
|
||||
|
||||
|
||||
def _unit(name: str) -> Unit:
|
||||
"""A unit file as {section: {key: [values]}}. systemd repeats keys (LoadCredential=, ReadWritePaths=)."""
|
||||
out: Unit = {}
|
||||
section = ""
|
||||
for raw in (UNITS / name).read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if line.startswith("[") and line.endswith("]"):
|
||||
section = line[1:-1]
|
||||
out.setdefault(section, {})
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
out[section].setdefault(key.strip(), []).append(value.strip())
|
||||
return out
|
||||
|
||||
|
||||
def _values(u: Unit, section: str, key: str) -> list[str]:
|
||||
return u.get(section, {}).get(key, [])
|
||||
|
||||
|
||||
LAUNCH = "/opt/packetstation/current/deploy/vm/ps-launch.sh"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("unit", "command", "creds"),
|
||||
[
|
||||
("packetstation-migrate.service", "migrate", {"postgres_migrate_password", "postgres_app_password"}),
|
||||
(
|
||||
"packetstation-app.service",
|
||||
"app",
|
||||
{"postgres_app_password", "ldap_bind_password", "session_key", "seal_key"},
|
||||
),
|
||||
(
|
||||
"packetstation-worker.service",
|
||||
"worker",
|
||||
{"postgres_app_password", "visualkit_password", "mfileskit_password", "ldap_bind_password", "seal_key"},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_service_units(unit: str, command: str, creds: set[str]) -> None:
|
||||
text = (UNITS / unit).read_text(encoding="utf-8")
|
||||
assert "\r" not in text
|
||||
cp = _unit(unit)
|
||||
svc = "Service"
|
||||
assert _values(cp, svc, "ExecStart") == [f"{LAUNCH} {command}"]
|
||||
assert _values(cp, svc, "User") == ["packetstation"]
|
||||
assert _values(cp, svc, "EnvironmentFile") == ["/etc/packetstation/packetstation.env"]
|
||||
for key, want in (
|
||||
("NoNewPrivileges", "yes"),
|
||||
("ProtectSystem", "strict"),
|
||||
("ProtectHome", "yes"),
|
||||
("PrivateTmp", "yes"),
|
||||
("InaccessiblePaths", "/etc/packetstation/secrets"),
|
||||
("CapabilityBoundingSet", ""),
|
||||
):
|
||||
assert _values(cp, svc, key) == [want], key
|
||||
loaded = {v.split(":", 1)[0] for v in _values(cp, svc, "LoadCredential")}
|
||||
assert loaded == creds
|
||||
for v in _values(cp, svc, "LoadCredential"):
|
||||
name, path = v.split(":", 1)
|
||||
assert path == f"/etc/packetstation/secrets/{name}"
|
||||
rw = _values(cp, svc, "ReadWritePaths")
|
||||
if command == "migrate":
|
||||
assert rw == []
|
||||
else:
|
||||
# The app runs the archive check and the worker copies: both need the archive mount.
|
||||
assert rw == ["/var/lib/packetstation/assets", "-/mnt/packetstation-archive"]
|
||||
assert "packetstation-migrate.service" in _values(cp, "Unit", "Requires")[0]
|
||||
|
||||
|
||||
def test_app_unit_needs_the_firewall_table() -> None:
|
||||
cp = _unit("packetstation-app.service")
|
||||
assert _values(cp, "Service", "ExecStartPre") == ["+/usr/sbin/nft list table inet packetstation"]
|
||||
fw = _unit("packetstation-firewall.service")
|
||||
assert _values(fw, "Service", "ExecStart") == ["/usr/sbin/nft -f /etc/packetstation/packetstation.nft"]
|
||||
|
||||
|
||||
def test_launcher_knows_every_credential_the_units_load() -> None:
|
||||
launch = (VM / "ps-launch.sh").read_text(encoding="utf-8")
|
||||
run = (VM / "ps-run.sh").read_text(encoding="utf-8")
|
||||
names: set[str] = set()
|
||||
for unit in UNITS.glob("*.service"):
|
||||
names |= {v.split(":", 1)[0] for v in _values(_unit(unit.name), "Service", "LoadCredential")}
|
||||
for n in names:
|
||||
assert re.search(rf"(^|')({n}) [A-Z_]+_FILE'?$", launch, re.M), n
|
||||
assert n in run, n
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stack", ["prod", "test"])
|
||||
def test_env_examples(stack: str) -> None:
|
||||
text = (VM / f"{stack}.env.example").read_text(encoding="utf-8")
|
||||
assert "\r" not in text
|
||||
values = dict(line.split("=", 1) for line in text.splitlines() if line and not line.startswith("#") and "=" in line)
|
||||
assert values["STACK"] == stack
|
||||
assert values["PG_HOST"] == "/var/run/postgresql"
|
||||
assert values["TRUSTED_PROXY_HOST"] == "192.168.101.12"
|
||||
assert values["ASSETS_DIR"] == "/var/lib/packetstation/assets"
|
||||
assert values["BACKUP_DIR"] == "/var/backups/packetstation"
|
||||
assert "#ARCHIVE_ROOT=/mnt/packetstation-archive" in text
|
||||
for k, v in values.items():
|
||||
if k.endswith("_FILE"):
|
||||
assert v.startswith("/etc/packetstation/secrets/"), k
|
||||
assert "PASSWORD=" not in f"{k}=", k
|
||||
for k in ("STATIC_DIR", "PS_BUILD", "WORKER_BEAT_FILE", "MFILES_CA_BUNDLE"):
|
||||
assert k not in values, k
|
||||
if stack == "prod":
|
||||
assert (values["VISUAL_SERVER"], values["VISUAL_DATABASE"]) == ("vsql.pesco.com", "PESCOINC")
|
||||
assert values["NOTIFY_DRY_RUN"] == "false"
|
||||
else:
|
||||
assert (values["VISUAL_SERVER"], values["VISUAL_DATABASE"]) == ("'vsql.pesco.com\\DEV'", "PESCOINCDEV")
|
||||
assert values["NOTIFY_DRY_RUN"] == "true"
|
||||
|
||||
|
||||
def test_backup_units() -> None:
|
||||
svc = (REPO / "scripts" / "backup" / "packetstation-backup.service").read_text(encoding="utf-8")
|
||||
timer = (REPO / "scripts" / "backup" / "packetstation-backup.timer").read_text(encoding="utf-8")
|
||||
assert "ExecStart=/usr/local/sbin/ps-backup --config /etc/packetstation/backup.conf" in svc
|
||||
assert "docker" not in svc.lower()
|
||||
assert "OnCalendar=*-*-* 02:00:00 America/Denver" in timer
|
||||
assert "Persistent=true" in timer
|
||||
|
||||
Reference in New Issue
Block a user