189 lines
8.7 KiB
Bash
Executable File
189 lines
8.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Restore is intentionally two-phase: verify/extract everything, then touch targets.
|
|
set -Eeuo pipefail
|
|
SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
|
|
LIB_DIR="$SCRIPT_DIR/../lib/app-backup"
|
|
[[ -f $LIB_DIR/common.sh ]] || LIB_DIR="$SCRIPT_DIR/../lib"
|
|
# shellcheck source=lib/common.sh
|
|
source "$LIB_DIR/common.sh"
|
|
CONFIG_FILE=/etc/app-backup/app-backup.conf
|
|
LOCAL_RUN=''; REMOTE_RUN=''; MODE=plan; YES=false; LEGACY=false
|
|
FILES=true; DATABASES=true; COMPONENTS=''
|
|
while (( $# )); do
|
|
case $1 in
|
|
--config) CONFIG_FILE=${2:?Missing config}; shift 2 ;;
|
|
--local-run) LOCAL_RUN=${2:?Missing local run}; shift 2 ;;
|
|
--remote-run) REMOTE_RUN=${2:?Missing remote run}; shift 2 ;;
|
|
--verify-only) MODE=verify; shift ;;
|
|
--extract-only) MODE=extract; shift ;;
|
|
--dry-run) MODE=plan; shift ;;
|
|
--apply) MODE=apply; shift ;;
|
|
--yes) YES=true; shift ;;
|
|
--legacy-unverified) LEGACY=true; shift ;;
|
|
--no-db) DATABASES=false; shift ;;
|
|
--no-files) FILES=false; shift ;;
|
|
--components) COMPONENTS=${2:?Missing comma-separated components}; shift 2 ;;
|
|
--help)
|
|
echo 'Usage: app-restore.sh [--config FILE] (--local-run DIR | --remote-run NAME)'
|
|
echo ' [--dry-run | --verify-only | --extract-only | --apply --yes]'
|
|
echo ' [--no-db] [--no-files] [--components wordpress,db-wordpress,...]'
|
|
echo ' Legacy archives: --legacy-unverified --extract-only'
|
|
exit 0 ;;
|
|
*) die "Unknown argument: $1" ;;
|
|
esac
|
|
done
|
|
[[ -n $LOCAL_RUN && -z $REMOTE_RUN || -z $LOCAL_RUN && -n $REMOTE_RUN ]] || die 'Choose exactly one backup source'
|
|
[[ $MODE != apply || $YES == true ]] || die 'Applying a restore requires --apply --yes (overwrites files and database objects)'
|
|
[[ $LEGACY == false || $MODE == extract ]] || die 'Unverified legacy backups support --extract-only exclusively'
|
|
OPERATION=restore
|
|
load_config
|
|
init_runtime
|
|
check_space "$RESTORE_ROOT"
|
|
STAGING_DIR=$(mktemp -d "$RESTORE_ROOT/${RUN_ID}.XXXXXX")
|
|
INPUT="$STAGING_DIR/input"
|
|
mkdir -m 700 "$INPUT"
|
|
if [[ -n $REMOTE_RUN ]]; then
|
|
[[ $REMOTE_RUN =~ ^[a-zA-Z0-9][a-zA-Z0-9_-]+$ && $REMOTE_RUN == "$ARCHIVE_PREFIX"_* ]] || die 'Unsafe remote run name'
|
|
check_remote
|
|
rc copy "${RCLONE_REMOTE_BASE%/}/$REMOTE_RUN" "$INPUT"
|
|
else
|
|
[[ -d $LOCAL_RUN && ! -L $LOCAL_RUN ]] || die 'Local run must be an existing directory'
|
|
# Resolve overlap separately: archive sources are allowed under WORKDIR, but
|
|
# recursive copies into the freshly created restore workspace are forbidden.
|
|
input_real=$(realpath -e "$LOCAL_RUN")
|
|
stage_real=$(realpath -e "$STAGING_DIR")
|
|
[[ $stage_real != "$input_real" && $stage_real != "$input_real/"* ]] || die 'Backup source contains restore workspace'
|
|
rsync -a -- "$LOCAL_RUN/" "$INPUT/" 8>&- 9>&-
|
|
fi
|
|
if [[ $LEGACY == true ]]; then
|
|
echo 'WARNING: Legacy backup has no trusted manifest/completeness proof.'
|
|
shopt -s nullglob
|
|
legacy=("$INPUT"/*.tar.gz "$INPUT"/*.tar.zst)
|
|
(( ${#legacy[@]} )) || die 'No legacy archives found'
|
|
# Validate the entire batch BEFORE extracting the first file. Legacy roots differ.
|
|
for archive in "${legacy[@]}"; do
|
|
[[ ! -L $archive ]] || die 'Symlink archive refused'
|
|
base=$(basename "$archive")
|
|
[[ $base =~ ^${ARCHIVE_PREFIX}_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{2}-[0-9]{2}-[0-9]{2}_(meta|db|wordpress|nextcloud|nextcloud-data|gitea|gitea-etc|mail|mail-home|postfix|dovecot)\.tar\.(gz|zst)$ ]] || die "Unknown legacy filename: $base"
|
|
label=${BASH_REMATCH[1]}
|
|
case $label in meta|db) root=$label ;; gitea) root=files/gitea-data ;; *) root=files/$label ;; esac
|
|
helper tar "$archive" "$root"
|
|
done
|
|
mkdir "$STAGING_DIR/extracted"
|
|
for archive in "${legacy[@]}"; do
|
|
legacy_dest="$STAGING_DIR/extracted/$(basename "$archive")"
|
|
mkdir "$legacy_dest"
|
|
tar --extract --file "$archive" --acls --xattrs --selinux --numeric-owner --same-owner -C "$legacy_dest"
|
|
done
|
|
echo "Legacy extraction retained for MANUAL review: $STAGING_DIR/extracted"
|
|
STAGING_DIR=''
|
|
exit 0
|
|
fi
|
|
helper verify "$INPUT"
|
|
if [[ $MODE == verify ]]; then echo 'Verification passed; no application changes.'; exit 0; fi
|
|
helper list "$INPUT" > "$STAGING_DIR/archive-list.tsv"
|
|
if [[ $MODE == extract ]]; then
|
|
mkdir "$STAGING_DIR/extracted"
|
|
while IFS=$'\t' read -r label file _; do
|
|
mkdir "$STAGING_DIR/extracted/$label"
|
|
tar --extract --file "$INPUT/$file" --acls --xattrs --selinux --numeric-owner --same-owner -C "$STAGING_DIR/extracted/$label"
|
|
done < "$STAGING_DIR/archive-list.tsv"
|
|
echo "Verified extraction retained: $STAGING_DIR/extracted"
|
|
STAGING_DIR=''
|
|
exit 0
|
|
fi
|
|
build_plan
|
|
# Selection is validated against both the local configuration and the backup set.
|
|
SELECTED=(); SELECTED_ROOTS=(); TARGETS=(); IS_DB=(); CNFS=()
|
|
selected() { [[ -z $COMPONENTS || ",$COMPONENTS," == *",$1,"* ]]; }
|
|
for i in "${!LABELS[@]}"; do
|
|
if [[ $FILES == true ]] && selected "${LABELS[i]}"; then
|
|
SELECTED+=("${LABELS[i]}"); SELECTED_ROOTS+=("${ROOTS[i]}")
|
|
TARGETS+=("${SOURCES[i]}"); IS_DB+=(false); CNFS+=('')
|
|
fi
|
|
done
|
|
for i in "${!DB_LABELS[@]}"; do
|
|
if [[ $DATABASES == true ]] && selected "${DB_LABELS[i]}"; then
|
|
SELECTED+=("${DB_LABELS[i]}"); SELECTED_ROOTS+=("db/${DB_LABELS[i]#db-}.sql")
|
|
TARGETS+=("${DB_NAMES[i]}"); IS_DB+=(true); CNFS+=("${DB_CNFS[i]}")
|
|
fi
|
|
done
|
|
(( ${#SELECTED[@]} )) || die 'No components selected'
|
|
if [[ -n $COMPONENTS ]]; then
|
|
IFS=, read -r -a requested <<< "$COMPONENTS"
|
|
for label in "${requested[@]}"; do
|
|
[[ " ${SELECTED[*]} " == *" $label "* ]] || die "Unknown, disabled or excluded component: $label"
|
|
done
|
|
fi
|
|
for i in "${!SELECTED[@]}"; do
|
|
label=${SELECTED[i]}
|
|
awk -F '\t' -v label="$label" '$1==label {found=1} END {exit !found}' "$STAGING_DIR/archive-list.tsv" || die "Required component absent: $label"
|
|
# Indexed paths must retain their meaning when lists in config are edited.
|
|
if [[ $label == extra-* || $label == mail-home-* ]]; then
|
|
python3 - "$INPUT/manifest.json" "$label" "${TARGETS[i]}" <<'PY'
|
|
import json,sys
|
|
manifest,label,target=sys.argv[1:]
|
|
source=next(a['source'] for a in json.load(open(manifest))['archives'] if a['label']==label)
|
|
if source != target:
|
|
sys.exit('Indexed source path differs from configuration; restore via --extract-only and review mapping')
|
|
PY
|
|
fi
|
|
echo "RESTORE $label -> ${TARGETS[i]} (database=${IS_DB[i]})"
|
|
done
|
|
if [[ $MODE == plan ]]; then
|
|
echo "Verified restore plan. Strict deletion: $RESTORE_STRICT_DELETE. Use --apply --yes to execute."
|
|
exit 0
|
|
fi
|
|
mkdir "$STAGING_DIR/extracted"
|
|
# Each component gets an independent extraction root, so links in one component
|
|
# cannot redirect extraction of a later component into another tree.
|
|
while IFS=$'\t' read -r label file _; do
|
|
mkdir "$STAGING_DIR/extracted/$label"
|
|
tar --extract --file "$INPUT/$file" --acls --xattrs --selinux --numeric-owner --same-owner -C "$STAGING_DIR/extracted/$label"
|
|
done < "$STAGING_DIR/archive-list.tsv"
|
|
if [[ $MODE == extract ]]; then
|
|
echo "Verified extraction retained: $STAGING_DIR/extracted"
|
|
STAGING_DIR=''
|
|
exit 0
|
|
fi
|
|
# Validate every destination and DB connection before stopping a single service.
|
|
for i in "${!SELECTED[@]}"; do
|
|
if [[ ${IS_DB[i]} == true ]]; then
|
|
secure_file "${CNFS[i]}"
|
|
(( (8#$(stat -c %a "${CNFS[i]}") & 8#077) == 0 )) || die 'Restore credentials require chmod 600'
|
|
need "$DB_CLIENT_BIN"
|
|
"$DB_CLIENT_BIN" --defaults-extra-file="${CNFS[i]}" --database="${TARGETS[i]}" --execute='SELECT 1' >/dev/null
|
|
else
|
|
check_source_path "${TARGETS[i]}"
|
|
fi
|
|
done
|
|
if [[ $ENABLE_NEXTCLOUD == true && $ENABLE_NEXTCLOUD_MAINTENANCE != true ]]; then
|
|
die 'Applying a restore with Nextcloud enabled requires maintenance-mode management'
|
|
fi
|
|
quiesce
|
|
MUTATION_STARTED=true
|
|
save_recovery
|
|
for i in "${!SELECTED[@]}"; do
|
|
assert_services_quiescent
|
|
label=${SELECTED[i]}
|
|
source_path="$STAGING_DIR/extracted/$label/${SELECTED_ROOTS[i]}"
|
|
if [[ ${IS_DB[i]} == true ]]; then
|
|
[[ -f $source_path && ! -L $source_path && -s $source_path ]] || die 'SQL dump missing/empty'
|
|
"$DB_CLIENT_BIN" --defaults-extra-file="${CNFS[i]}" --default-character-set=utf8mb4 --database="${TARGETS[i]}" < "$source_path"
|
|
else
|
|
exclusions "$label"
|
|
if [[ $RESTORE_STRICT_DELETE == true ]]; then EXCLUDES+=(--delete-delay); fi
|
|
rsync -aHAX --numeric-ids "${EXCLUDES[@]}" -- "$source_path/" "${TARGETS[i]}/" 8>&- 9>&-
|
|
fi
|
|
done
|
|
assert_services_quiescent
|
|
# A backup captured maintenance=true. Enforce the recorded pre-restore state even
|
|
# if the existing installation already was in maintenance mode.
|
|
if [[ " ${SELECTED[*]} " == *' nextcloud '* || " ${SELECTED[*]} " == *' db-nextcloud '* ]]; then
|
|
occ maintenance:mode --on
|
|
occ maintenance:data-fingerprint
|
|
fi
|
|
resume_services || die 'Restore applied, but service recovery failed'
|
|
MUTATION_STARTED=false
|
|
echo 'SUCCESS: selected components restored. Perform application-level smoke tests.'
|