Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/backup-restore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ jobs:
healthcheck: no-healthcheck
- database: postgresql
healthcheck: no-healthcheck
- database: postgresql
healthcheck: failing-healthcheck

steps:
- name: Checkout
Expand Down
5 changes: 4 additions & 1 deletion docs/backup-and-restore.fa.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,10 @@ Xray داخل پوشه داده دست‌نخورده می‌مانند و کپ

اگر `--fresh` پس از آماده‌سازی نصب شکست خورد، پنل اجرا نمی‌شود و داده‌ها برای
بررسی باقی می‌مانند. پس از اصلاح علت در لاگ، اگر Compose و تنظیمات مقصد ساخته
شده‌اند، با restore معمولی روی همان نصب دوباره امتحان کنید.
شده‌اند، همان فرمان `--fresh` را دوباره اجرا کنید: تلاش قبلی به‌صورت restore معمولی
روی همان نصب ادامه پیدا می‌کند (بکاپ دیگری پذیرفته نمی‌شود) و از پوشه‌های برنامه و داده
نیمه‌کاره آن کپی پیش از جایگزینی نمی‌گیرد. restore معمولی با همان بکاپ هم کار می‌کند،
اما پیش از آن از این پوشه‌ها کپی می‌گیرد.

## بکاپ قدیمی بدون شناسنامه

Expand Down
7 changes: 5 additions & 2 deletions docs/backup-and-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,11 @@ restores can finish earlier databases before a later one fails. Take a separate
current backup before replacing an existing installation. Fresh recovery leaves
application services stopped on failure; it keeps provisioned storage for diagnosis
rather than destroying it or claiming automatic rollback. Once a failed fresh
recovery has provisioned Compose/configuration, diagnose the log and retry with
ordinary restore against that installation.
recovery has provisioned Compose/configuration, diagnose the log, fix the cause
and run the same `--fresh` command again: it continues the earlier attempt as an
ordinary restore of that installation (it refuses a different backup) and makes no
safety copies of its half-provisioned app and data directories. An ordinary restore
of the same backup also works, but copies those directories first.

## Old backups without recovery metadata

Expand Down
1 change: 1 addition & 0 deletions lib/pasarguard-backup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1877,6 +1877,7 @@ backup_command() {
--exclude 'backup-files.sha256'
--exclude '.pasarguard-recovery-compose.json'
--exclude '.pasarguard-destination-compose.yml'
--exclude '.pasarguard-fresh-restore'
--exclude 'pasarguard_ts_compat.*'
--exclude '*_combined.zip'
--exclude 'pasarguard_env_cleaned'
Expand Down
40 changes: 36 additions & 4 deletions lib/pasarguard-restore.sh
Original file line number Diff line number Diff line change
Expand Up @@ -894,6 +894,10 @@ wait_for_recovery_database() {
return 1
}

# Left in APP_DIR by a --fresh run that provisioned the installation but did not
# finish; holds the SHA256 of that backup's checksum inventory.
FRESH_RESTORE_MARKER=".pasarguard-fresh-restore"

# Prepare a fresh deployment using the source images. Refuse existing app/data,
# containers and database storage; this mode is never an in-place downgrade.
# The archived Compose file is installed unchanged. Each recorded source image
Expand Down Expand Up @@ -1069,6 +1073,10 @@ prepare_fresh_restore() {
mkdir -p "$APP_DIR" "$DATA_DIR" || return 1
install -m 600 "$stage/.env" "$ENV_FILE" || return 1
install -m 600 "$recovery_compose" "$COMPOSE_FILE" || return 1
# From here on the directories are no longer empty. Record which backup
# provisioned them, so running the same --fresh command again continues
# this attempt; a successful restore removes the marker.
(umask 077 && sha256sum <"$stage/backup-files.sha256" | awk '{print $1}' >"$APP_DIR/$FRESH_RESTORE_MARKER") || return 1
if [ -n "$db_service" ]; then
colorized_echo blue "Starting only the database; panel migrations stay stopped until the import finishes."
$COMPOSE -f "$COMPOSE_FILE" -p "$APP_NAME" up -d --no-deps "$db_service" >>"$log" 2>&1 || return 1
Expand Down Expand Up @@ -1128,6 +1136,17 @@ restore_command() {
fi
colorized_echo blue "Starting restore process..."

# A previous --fresh run provisioned this installation and then failed.
# The same command continues it as an ordinary restore (checked below to be
# the same backup) instead of refusing the now non-empty directories.
local fresh_resume=false
if [ "$fresh_restore" = true ] && [ "$check_only" = false ] && \
[ -f "$APP_DIR/$FRESH_RESTORE_MARKER" ] && [ ! -L "$APP_DIR/$FRESH_RESTORE_MARKER" ]; then
colorized_echo yellow "A previous --fresh run stopped after provisioning $APP_DIR. Continuing it as an ordinary restore; the database it created will be overwritten."
fresh_restore=false
fresh_resume=true
fi

if [ "$fresh_restore" = false ] && [ "$check_only" = false ]; then
if ! is_pasarguard_installed || [ ! -f "$COMPOSE_FILE" ]; then
colorized_echo red "No installation found. To recover onto an empty server, use: pasarguard restore --fresh /path/to/backup.zip"
Expand Down Expand Up @@ -1230,7 +1249,7 @@ restore_command() {
# application services if they were shut down, and terminate with the error code.
cleanup_and_exit_restore_error() {
local code="${1:-1}"
if [ "$services_stopped" = true ] && [ "$fresh_restore" = false ]; then
if [ "$services_stopped" = true ] && [ "$fresh_restore" = false ] && [ "$fresh_resume" = false ]; then
if [[ "$db_type" == "sqlite" ]]; then
up_pasarguard || echo "Failed to restart pasarguard after SQLite restore failure" >>"$log_file"
else
Expand Down Expand Up @@ -1548,6 +1567,17 @@ restore_command() {
cleanup_and_exit_restore_error 1
fi
if ! print_backup_runtime "$temp_restore_dir"; then cleanup_and_exit_restore_error 1; fi
if [ "$fresh_resume" = true ]; then
local previous_backup="" this_backup=""
previous_backup=$(cat "$APP_DIR/$FRESH_RESTORE_MARKER" 2>/dev/null) || previous_backup=""
if [ -f "$temp_restore_dir/backup-files.sha256" ]; then
this_backup=$(sha256sum <"$temp_restore_dir/backup-files.sha256" | awk '{print $1}')
fi
if [ -z "$this_backup" ] || [ "$previous_backup" != "$this_backup" ]; then
colorized_echo red "The unfinished --fresh run in $APP_DIR used a different backup. Retry with that backup, or restore this one without --fresh."
cleanup_and_exit_restore_error 1
fi
fi

# Load environment variables from extracted .env
colorized_echo blue "Loading configuration from backup..."
Expand Down Expand Up @@ -2249,7 +2279,8 @@ restore_command() {
colorized_echo red "The data directory exclude list is missing (mismatched script libraries). Refusing to sync the data directory."
cleanup_and_exit_restore_error 1
fi
if [ "$fresh_restore" = false ] && [ "$(ls -A "$DATA_DIR" 2>/dev/null)" ]; then
# A continued --fresh run (fresh_resume) has only its own half-provisioned files to copy.
if [ "$fresh_restore" = false ] && [ "$fresh_resume" = false ] && [ "$(ls -A "$DATA_DIR" 2>/dev/null)" ]; then
colorized_echo blue "Backing up current data directory before restore..."
if ! save_data_dir_safety_copy "$DATA_DIR.backup.$(date +%Y%m%d%H%M%S)" "$log_file"; then
colorized_echo red "Failed to save the current data directory before replacement."
Expand Down Expand Up @@ -2303,7 +2334,7 @@ restore_command() {
install_package rsync
fi
mkdir -p "$APP_DIR"
if [ "$fresh_restore" = false ] && [ "$(ls -A "$APP_DIR" 2>/dev/null)" ]; then
if [ "$fresh_restore" = false ] && [ "$fresh_resume" = false ] && [ "$(ls -A "$APP_DIR" 2>/dev/null)" ]; then
colorized_echo blue "Backing up current app directory before restore..."
if ! rsync -a --exclude backup "$APP_DIR/" "$APP_DIR.backup.$(date +%Y%m%d%H%M%S)/" 2>>"$log_file"; then
colorized_echo red "Failed to save the current application files before replacement."
Expand All @@ -2313,7 +2344,7 @@ restore_command() {
if ! rsync -av --exclude 'pasarguard_data' --exclude 'db_backup.sql' --exclude 'db_backup.sqlite' \
--exclude 'db_backup.timescaledb-version' --exclude 'pg_dump' \
--exclude 'backup-runtime.tsv' --exclude 'backup-files.sha256' --exclude '.pasarguard-recovery-compose.json' \
--exclude '.pasarguard-destination-compose.yml' --exclude 'pasarguard_ts_compat.*' \
--exclude '.pasarguard-destination-compose.yml' --exclude "$FRESH_RESTORE_MARKER" --exclude 'pasarguard_ts_compat.*' \
--exclude '*_combined.zip' --exclude 'pasarguard_env_cleaned' \
--exclude 'pasarguard_restore_error.log' --exclude "$sqlite_basename" \
"$temp_restore_dir/" "$APP_DIR/" >>"$log_file" 2>&1; then
Expand Down Expand Up @@ -2370,6 +2401,7 @@ restore_command() {
fi
harden_secret_file "$ENV_FILE"
harden_secret_file "$COMPOSE_FILE"
rm -f "$APP_DIR/$FRESH_RESTORE_MARKER"
rm -rf "$temp_restore_dir"
colorized_echo green "Restore completed successfully!"
colorized_echo green "PasarGuard services have been started. Check panel login, subscriptions and node connections before upgrading."
Expand Down
3 changes: 3 additions & 0 deletions pasarguard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1141,6 +1141,9 @@ install_pasarguard() {

mkdir -p "$DATA_DIR"
mkdir -p "$APP_DIR"
# This installation replaces anything an unfinished `restore --fresh` left
# here; its retry marker must not turn a later --fresh into a restore over it.
rm -f "$APP_DIR/${FRESH_RESTORE_MARKER:-.pasarguard-fresh-restore}"

colorized_echo blue "Fetching .env file"
# Pre-create .env as 0600 (and tighten any pre-existing copy) so the DB,
Expand Down
54 changes: 45 additions & 9 deletions tests/fresh_recovery_roundtrip.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@
# Back up a source installation, remove it completely, recover it with --fresh,
# then check the data, the installed Compose file and the images that run.
#
# Usage: bash tests/fresh_recovery_roundtrip.sh ENGINE [healthcheck|no-healthcheck]
# Usage: bash tests/fresh_recovery_roundtrip.sh ENGINE [healthcheck|no-healthcheck|failing-healthcheck]
# ENGINE: sqlite | mysql | mariadb | postgresql | timescaledb
# no-healthcheck: the archived database service has no Compose healthcheck.
# failing-healthcheck: the archived healthcheck always fails, so the first
# --fresh stops after provisioning; the same command is then run again and
# must finish the recovery.
#
# Needs root (restore --fresh requires it), Docker with Compose v2, sqlite3, jq,
# rsync, zip and unzip. Everything it creates is its own: the Compose project
Expand All @@ -26,9 +29,13 @@ ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
engine="${1:-}"
healthcheck="${2:-healthcheck}"
case "$healthcheck" in
healthcheck | no-healthcheck) ;;
*) echo "usage: $0 ENGINE [healthcheck|no-healthcheck]" >&2; exit 2 ;;
healthcheck | no-healthcheck | failing-healthcheck) ;;
*) echo "usage: $0 ENGINE [healthcheck|no-healthcheck|failing-healthcheck]" >&2; exit 2 ;;
esac
if [ "${1:-}" = sqlite ] && [ "$healthcheck" = failing-healthcheck ]; then
echo "failing-healthcheck needs a database service" >&2
exit 2
fi

app_image="${FRESH_APP_IMAGE:-alpine:3.20}"
panel_ref="pasarguard-recovery-fixture/panel:latest"
Expand All @@ -39,7 +46,7 @@ case "$engine" in
mariadb) db_image="${FRESH_DB_IMAGE:-mariadb:11.4}"; target=/var/lib/mysql; dbservice=mariadb ;;
postgresql) db_image="${FRESH_DB_IMAGE:-postgres:16}"; target=/var/lib/postgresql/data; dbservice=postgresql ;;
timescaledb) db_image="${FRESH_DB_IMAGE:-timescale/timescaledb:2.27.2-pg17}"; target=/var/lib/postgresql/data; dbservice=timescaledb ;;
*) echo "usage: $0 ENGINE [healthcheck|no-healthcheck]" >&2; exit 2 ;;
*) echo "usage: $0 ENGINE [healthcheck|no-healthcheck|failing-healthcheck]" >&2; exit 2 ;;
esac

root=$(mktemp -d "${TMPDIR:-/tmp}/pasarguard-fresh-recovery.XXXXXX")
Expand Down Expand Up @@ -83,7 +90,7 @@ DB_NAME=appdb
SQLALCHEMY_DATABASE_URL="$url"
EOF

# Write the Compose file; $1 is "healthcheck" or "no-healthcheck" for the database.
# Write the Compose file; $1 is the database healthcheck mode (see usage).
write_compose() {
cat >"$COMPOSE_FILE" <<EOF
services:
Expand All @@ -109,7 +116,13 @@ EOF
volumes:
- $DATA_DIR/$dbservice:$target
EOF
[ "$1" = healthcheck ] || return 0
case "$1" in
no-healthcheck) return 0 ;;
failing-healthcheck)
printf ' healthcheck:\n test: ["CMD", "false"]\n interval: 1s\n timeout: 1s\n retries: 1\n' >>"$COMPOSE_FILE"
return 0
;;
esac
case "$engine" in
mysql) printf ' healthcheck:\n test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -u root --password=fixture-password"]\n' ;;
mariadb) printf ' healthcheck:\n test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]\n' ;;
Expand All @@ -129,6 +142,18 @@ run_sql() {
esac
}

# Wait up to 120 s until the database answers over TCP inside its container,
# whatever its healthcheck says.
wait_until_database_answers() {
local cid="$1" port=5432 attempt
case "$engine" in mysql | mariadb) port=3306 ;; esac
for ((attempt = 0; attempt < 60; attempt++)); do
if recovery_database_responds "$cid" "$engine" "$port" >/dev/null 2>&1; then return 0; fi
sleep 2
done
return 1
}

# The source always starts with a healthcheck so the fixture data can be written
# once the database is ready; the archived Compose file decides what --fresh sees.
write_compose healthcheck
Expand Down Expand Up @@ -162,7 +187,18 @@ fi
[ ! -e "$APP_DIR" ] || fail "--check created the application directory"

started=$(date +%s)
if [ "$healthcheck" = failing-healthcheck ]; then
if (restore_command "$root/recovery.zip" --fresh --yes); then
fail "--fresh succeeded although the database never became healthy"
fi
[ -f "$APP_DIR/.pasarguard-fresh-restore" ] || fail "no retry marker after the failed --fresh"
# As an administrator would after reading the log: let the database finish
# starting, then run the same command again.
wait_until_database_answers "$(dc ps -q "$dbservice")" || fail "database did not answer after the failed --fresh"
echo "first --fresh stopped as expected; running the same command again"
fi
(restore_command "$root/recovery.zip" --fresh --yes)
[ ! -e "$APP_DIR/.pasarguard-fresh-restore" ] || fail "retry marker left after a successful restore"
echo "fresh recovery took $(($(date +%s) - started))s"

[ "$(cat "$DATA_DIR/sentinel.txt")" = original-state ] || fail "data file not restored"
Expand All @@ -179,10 +215,10 @@ done <"$root/runtime.tsv"

if [ "$engine" != sqlite ]; then
dc restart "$dbservice"
dc up -d --wait --wait-timeout 180 "$dbservice"
[ "$healthcheck" = failing-healthcheck ] || dc up -d --wait --wait-timeout 180 "$dbservice"
cid=$(dc ps -q "$dbservice")
if [ "$healthcheck" = no-healthcheck ]; then
wait_for_recovery_database "$cid" "$engine" "" "$root/wait.log" || fail "database did not answer after restart"
if [ "$healthcheck" != healthcheck ]; then
wait_until_database_answers "$cid" || fail "database did not answer after restart"
fi
fi
[ "$(run_sql "$cid" 'SELECT value FROM ci_recovery;')" = 42 ] || fail "database row not restored"
Expand Down
Loading
Loading