Wrote v2 of app-backup scripts for JR IT Services.

This commit is contained in:
2026-02-11 14:12:21 +01:00
parent 77b3b65bfb
commit 66ae90611a
3 changed files with 438 additions and 508 deletions

View File

@@ -2,14 +2,18 @@
set -Eeuo pipefail
umask 027
# ---------- Logging ----------
# ==============================================================================
# app-restore.sh
# - Restore from a "run folder" (local dir or rclone remote folder)
# - Applies archives per component (meta/db/wordpress/nextcloud/nextcloud-data/gitea...)
# ==============================================================================
LOG_DIR="/var/log/app-backup"
mkdir -p "$LOG_DIR"
ts="$(date '+%Y-%m-%d_%H-%M-%S')"
LOG_FILE="${LOG_DIR}/app-restore_${ts}.log"
exec > >(tee -a "$LOG_FILE" | systemd-cat -t app-restore -p info) 2>&1
# ---------- Config ----------
CONFIG_FILE="/etc/app-backup/app-backup.conf"
if [[ -r "$CONFIG_FILE" ]]; then
# shellcheck disable=SC1090
@@ -19,33 +23,35 @@ else
exit 2
fi
# ---------- Defaults ----------
: "${WORKDIR:=/var/backups/app-backup}"
: "${RESTORE_ROOT:=${WORKDIR}/restore}"
: "${RCLONE_REMOTE:=onedrive:Sicherung}"
: "${RCLONE_BIN:=rclone}"
: "${ARCHIVE_PREFIX:=appbackup}"
: "${DRY_RUN:=false}" # true = show what would happen
: "${RESTORE_DB:=true}" # true/false
: "${RESTORE_FILES:=true}" # true/false
: "${RESTORE_STRICT_DELETE:=false}" # true = rsync --delete on restore
: "${RCLONE_BIN:=rclone}"
: "${RCLONE_REMOTE_BASE:=OneDrive:Sicherung/JRITServerBackups/$(hostname -s)}"
: "${DRY_RUN:=false}"
: "${RESTORE_DB:=true}"
: "${RESTORE_FILES:=true}"
: "${RESTORE_STRICT_DELETE:=false}"
: "${ENABLE_NEXTCLOUD_MAINTENANCE:=true}"
: "${NC_OCC_USER:=apache}"
: "${NC_FILES_SCAN_AFTER_RESTORE:=false}"
: "${ENABLE_GITEA_SERVICE_STOP:=true}"
: "${GITEA_SERVICE_NAME:=gitea}"
: "${ENABLE_HTTPD_STOP:=false}"
: "${HTTPD_SERVICE_NAME:=httpd}"
: "${ENABLE_PHPFPM_STOP:=false}"
: "${PHPFPM_SERVICE_NAME:=php-fpm}"
die() { echo "ERROR: $*"; exit 1; }
have() { command -v "$1" >/dev/null 2>&1; }
run_cmd() {
if [[ "${DRY_RUN}" == "true" ]]; then
echo "[DRY_RUN] $*"
else
"$@"
fi
}
run_cmd() { [[ "${DRY_RUN}" == "true" ]] && echo "[DRY_RUN] $*" || "$@"; }
# Nextcloud maintenance-mode safety trap
NC_MAINTENANCE_ON=false
nc_maintenance_off() {
if [[ "${NC_MAINTENANCE_ON}" == "true" ]]; then
@@ -55,215 +61,176 @@ nc_maintenance_off() {
fi
}
on_exit() {
local exit_code=$?
nc_maintenance_off
exit "${exit_code}"
}
GITEA_WAS_STOPPED=false
HTTPD_WAS_STOPPED=false
PHPFPM_WAS_STOPPED=false
gitea_start() { [[ "${GITEA_WAS_STOPPED}" == "true" ]] && { echo "-- Starting gitea (trap)"; run_cmd systemctl start "${GITEA_SERVICE_NAME}" || true; GITEA_WAS_STOPPED=false; }; }
httpd_start() { [[ "${HTTPD_WAS_STOPPED}" == "true" ]] && { echo "-- Starting httpd (trap)"; run_cmd systemctl start "${HTTPD_SERVICE_NAME}" || true; HTTPD_WAS_STOPPED=false; }; }
phpfpm_start(){ [[ "${PHPFPM_WAS_STOPPED}" == "true" ]] && { echo "-- Starting php-fpm (trap)"; run_cmd systemctl start "${PHPFPM_SERVICE_NAME}" || true; PHPFPM_WAS_STOPPED=false; }; }
on_exit() { local ec=$?; nc_maintenance_off; gitea_start; httpd_start; phpfpm_start; exit "${ec}"; }
trap on_exit EXIT
# ---------- Preconditions ----------
[[ $EUID -eq 0 ]] || die "Must run as root."
for t in tar rsync flock df find stat; do
have "$t" || die "Missing required tool: $t"
done
for t in tar rsync flock df find stat; do have "$t" || die "Missing required tool: $t"; done
mkdir -p "$WORKDIR" "$RESTORE_ROOT" "$LOG_DIR"
# ---------- Locking ----------
LOCKFILE="/run/app-backup.lock"
exec 9>"$LOCKFILE"
if ! flock -n 9; then
die "Another backup/restore already running (lock: $LOCKFILE)"
fi
flock -n 9 || die "Another backup/restore already running (lock: $LOCKFILE)"
# ---------- Input ----------
# Usage:
# app-restore.sh /path/to/appbackup_YYYY-mm-dd_HH-MM-SS.tar.zst
# or
# app-restore.sh /path/to/appbackup_YYYY-mm-dd_HH-MM-SS.tar.gz
# or
# app-restore.sh --remote appbackup_YYYY-mm-dd_HH-MM-SS.tar.zst
# (copies from RCLONE_REMOTE to RESTORE_ROOT first)
ARCHIVE_PATH=""
REMOTE_NAME=""
usage() {
cat <<EOF
Usage:
$0 --remote-run <run_folder_name> # e.g. ${ARCHIVE_PREFIX}_2026-02-11_02-31-28
$0 --local-run <path_to_run_dir> # directory containing archives
Options:
--dry-run
--no-db
--no-files
EOF
}
if [[ "${1:-}" == "--remote" ]]; then
REMOTE_NAME="${2:-}"
[[ -n "$REMOTE_NAME" ]] || die "Usage: $0 --remote <archive_filename>"
have "$RCLONE_BIN" || die "rclone missing but --remote used"
REMOTE_RUN=""
LOCAL_RUN=""
while [[ $# -gt 0 ]]; do
case "$1" in
--remote-run) REMOTE_RUN="${2:-}"; shift 2;;
--local-run) LOCAL_RUN="${2:-}"; shift 2;;
--dry-run) DRY_RUN=true; shift;;
--no-db) RESTORE_DB=false; shift;;
--no-files) RESTORE_FILES=false; shift;;
-h|--help) usage; exit 0;;
*) die "Unknown arg: $1";;
esac
done
[[ -z "${REMOTE_RUN}" && -z "${LOCAL_RUN}" ]] && { usage; exit 2; }
RUN_DIR="${RESTORE_ROOT}/run_${ts}"
DOWNLOAD_DIR="${RUN_DIR}/downloads"
EXTRACT_DIR="${RUN_DIR}/extract"
mkdir -p "$DOWNLOAD_DIR" "$EXTRACT_DIR"
if [[ -n "${REMOTE_RUN}" ]]; then
have "$RCLONE_BIN" || die "rclone missing but --remote-run used"
remote_path="${RCLONE_REMOTE_BASE}/${REMOTE_RUN}"
echo "-- Fetching archives from remote: ${remote_path} -> ${DOWNLOAD_DIR}"
run_cmd "$RCLONE_BIN" copy "${remote_path}" "${DOWNLOAD_DIR}" --checksum --log-level INFO
SRC_DIR="${DOWNLOAD_DIR}"
else
ARCHIVE_PATH="${1:-}"
[[ -n "$ARCHIVE_PATH" ]] || die "Usage: $0 <archive_file.tar.zst|tar.gz> OR $0 --remote <archive_filename>"
[[ -d "${LOCAL_RUN}" ]] || die "Local run dir not found: ${LOCAL_RUN}"
SRC_DIR="${LOCAL_RUN}"
fi
echo "== app-restore start: ${ts} =="
echo "-- Config: ${CONFIG_FILE}"
echo "-- Log: ${LOG_FILE}"
echo "-- Source dir: ${SRC_DIR}"
echo "-- DRY_RUN: ${DRY_RUN}"
echo "-- RESTORE_FILES: ${RESTORE_FILES}"
echo "-- RESTORE_DB: ${RESTORE_DB}"
echo "-- STRICT_DELETE: ${RESTORE_STRICT_DELETE}"
# ---------- Fetch from remote if requested ----------
if [[ -n "$REMOTE_NAME" ]]; then
ARCHIVE_PATH="${RESTORE_ROOT}/${REMOTE_NAME}"
echo "-- Fetching from remote: ${RCLONE_REMOTE}/${REMOTE_NAME} -> ${ARCHIVE_PATH}"
run_cmd "$RCLONE_BIN" copy "${RCLONE_REMOTE}/${REMOTE_NAME}" "${RESTORE_ROOT}" --checksum --log-level INFO
detect_tar_flags() { case "$1" in *.tar.zst) echo "--zstd" ;; *.tar.gz) echo "-z" ;; *) die "Unsupported archive: $1" ;; esac; }
extract_archive() {
local f="$1" flags; flags="$(detect_tar_flags "$f")"
echo "-- Extract: $(basename "$f") -> ${EXTRACT_DIR}"
[[ "${DRY_RUN}" == "true" ]] && echo "[DRY_RUN] tar ${flags} -xf $f -C ${EXTRACT_DIR}" || tar ${flags} -xf "$f" -C "$EXTRACT_DIR"
}
pick_one() { ls -1 "${SRC_DIR}"/$1 2>/dev/null | sort | tail -n 1 || true; }
# stop services (optional)
if [[ "${ENABLE_HTTPD_STOP}" == "true" ]] && systemctl is-active --quiet "${HTTPD_SERVICE_NAME}"; then
echo "-- Stopping httpd for restore: ${HTTPD_SERVICE_NAME}"
run_cmd systemctl stop "${HTTPD_SERVICE_NAME}"; HTTPD_WAS_STOPPED=true
fi
if [[ "${ENABLE_PHPFPM_STOP}" == "true" ]] && systemctl is-active --quiet "${PHPFPM_SERVICE_NAME}"; then
echo "-- Stopping php-fpm for restore: ${PHPFPM_SERVICE_NAME}"
run_cmd systemctl stop "${PHPFPM_SERVICE_NAME}"; PHPFPM_WAS_STOPPED=true
fi
if [[ "${ENABLE_GITEA:-false}" == "true" && "${ENABLE_GITEA_SERVICE_STOP}" == "true" ]] && systemctl is-active --quiet "${GITEA_SERVICE_NAME}"; then
echo "-- Stopping gitea for restore: ${GITEA_SERVICE_NAME}"
run_cmd systemctl stop "${GITEA_SERVICE_NAME}"; GITEA_WAS_STOPPED=true
fi
[[ -f "$ARCHIVE_PATH" ]] || die "Archive not found: $ARCHIVE_PATH"
# ---------- Detect compression ----------
ARCHIVE_BASENAME="$(basename "$ARCHIVE_PATH")"
IS_ZSTD=false
IS_GZIP=false
case "$ARCHIVE_BASENAME" in
*.tar.zst) IS_ZSTD=true ;;
*.tar.gz) IS_GZIP=true ;;
*)
# fallback: try file(1)
if have file; then
ftype="$(file -b "$ARCHIVE_PATH" || true)"
if echo "$ftype" | grep -qi zstd; then
IS_ZSTD=true
elif echo "$ftype" | grep -qi gzip; then
IS_GZIP=true
else
die "Cannot detect archive compression for: $ARCHIVE_PATH"
fi
else
die "Unknown archive extension and file(1) not available: $ARCHIVE_PATH"
fi
;;
esac
if [[ "$IS_ZSTD" == "true" ]]; then
have zstd || die "zstd archive but zstd missing"
elif [[ "$IS_GZIP" == "true" ]]; then
have gzip || die "gzip archive but gzip missing"
# nextcloud maintenance
if [[ "${ENABLE_NEXTCLOUD:-false}" == "true" && "${ENABLE_NEXTCLOUD_MAINTENANCE}" == "true" ]] && [[ -d "${NC_DIR}" && -f "${NC_DIR}/occ" ]]; then
echo "-- Nextcloud maintenance mode ON..."
run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --on
NC_MAINTENANCE_ON=true
fi
# ---------- Extract ----------
RUN_DIR="${RESTORE_ROOT}/run_${ts}"
STAGING_DIR="${RUN_DIR}/staging"
mkdir -p "$STAGING_DIR"
# extract archives
meta_arc="$(pick_one "${ARCHIVE_PREFIX}_*_meta.tar.*")"; [[ -n "$meta_arc" ]] && extract_archive "$meta_arc" || true
db_arc="$(pick_one "${ARCHIVE_PREFIX}_*_db.tar.*")"
wp_arc="$(pick_one "${ARCHIVE_PREFIX}_*_wordpress.tar.*")"
nc_arc="$(pick_one "${ARCHIVE_PREFIX}_*_nextcloud.tar.*")"
ncd_arc="$(pick_one "${ARCHIVE_PREFIX}_*_nextcloud-data.tar.*")"
g_arc="$(pick_one "${ARCHIVE_PREFIX}_*_gitea.tar.*")"
g_etc_arc="$(pick_one "${ARCHIVE_PREFIX}_*_gitea-etc.tar.*")"
echo "-- Extracting archive to: ${STAGING_DIR}"
if [[ "$IS_ZSTD" == "true" ]]; then
run_cmd tar --zstd -xf "$ARCHIVE_PATH" -C "$STAGING_DIR"
elif [[ "$IS_GZIP" == "true" ]]; then
run_cmd tar -xzf "$ARCHIVE_PATH" -C "$STAGING_DIR"
fi
[[ -n "$db_arc" ]] && extract_archive "$db_arc" || true
[[ -n "$wp_arc" && "${RESTORE_FILES}" == "true" ]] && extract_archive "$wp_arc" || true
[[ -n "$nc_arc" && "${RESTORE_FILES}" == "true" ]] && extract_archive "$nc_arc" || true
[[ -n "$ncd_arc" && "${RESTORE_FILES}" == "true" ]] && extract_archive "$ncd_arc" || true
[[ -n "$g_arc" && "${RESTORE_FILES}" == "true" ]] && extract_archive "$g_arc" || true
[[ -n "$g_etc_arc" && "${RESTORE_FILES}" == "true" ]] && extract_archive "$g_etc_arc" || true
[[ -d "$STAGING_DIR/files" ]] || die "Invalid archive content: missing files/ in extracted staging"
# ---------- Maintenance mode (Nextcloud) ----------
if [[ "${ENABLE_NEXTCLOUD}" == "true" && "${ENABLE_NEXTCLOUD_MAINTENANCE}" == "true" ]]; then
if [[ -d "${NC_DIR}" && -f "${NC_DIR}/occ" ]]; then
echo "-- Nextcloud maintenance mode ON..."
run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:mode --on
NC_MAINTENANCE_ON=true
else
echo "WARN: Nextcloud directory/occ not found at NC_DIR=${NC_DIR} - cannot toggle maintenance mode"
fi
fi
# ---------- Restore files ----------
rsync_restore_dir() {
local src="$1"
local dst="$2"
local src="$1" dst="$2"
[[ -d "$src" ]] || die "Restore source missing: $src"
mkdir -p "$dst"
local delete_flag=()
if [[ "${RESTORE_STRICT_DELETE}" == "true" ]]; then
delete_flag=(--delete)
fi
run_cmd rsync -aHAX --numeric-ids --info=stats2 \
"${delete_flag[@]}" \
"$src"/ "$dst"/
local del=(); [[ "${RESTORE_STRICT_DELETE}" == "true" ]] && del=(--delete)
run_cmd rsync -aHAX --numeric-ids --info=stats2 "${del[@]}" "$src"/ "$dst"/
}
if [[ "${RESTORE_FILES}" == "true" ]]; then
echo "-- Restoring files..."
if [[ "${ENABLE_WORDPRESS}" == "true" ]]; then
echo "-- Restore WordPress (webroot) to: ${WP_DIR}"
# Backup excluded nextcloud/ automatically, so this should not overwrite Nextcloud.
rsync_restore_dir "$STAGING_DIR/files/wordpress" "${WP_DIR}"
if [[ -d "${EXTRACT_DIR}/files/wordpress" && "${ENABLE_WORDPRESS:-false}" == "true" ]]; then
echo "-- WordPress -> ${WP_DIR}"
rsync_restore_dir "${EXTRACT_DIR}/files/wordpress" "${WP_DIR}"
fi
if [[ "${ENABLE_NEXTCLOUD}" == "true" ]]; then
echo "-- Restore Nextcloud code to: ${NC_DIR}"
rsync_restore_dir "$STAGING_DIR/files/nextcloud" "${NC_DIR}"
if [[ "${ENABLE_NEXTCLOUD_DATA}" == "true" ]]; then
echo "-- Restore Nextcloud data to: ${NC_DATA_DIR}"
rsync_restore_dir "$STAGING_DIR/files/nextcloud-data" "${NC_DATA_DIR}"
fi
if [[ "${ENABLE_NEXTCLOUD:-false}" == "true" ]]; then
[[ -d "${EXTRACT_DIR}/files/nextcloud" ]] && { echo "-- Nextcloud code -> ${NC_DIR}"; rsync_restore_dir "${EXTRACT_DIR}/files/nextcloud" "${NC_DIR}"; }
: "${NC_DATA_DIR:=${NC_DIR%/}/data}"
[[ -d "${EXTRACT_DIR}/files/nextcloud-data" && "${ENABLE_NEXTCLOUD_DATA:-true}" == "true" ]] && { echo "-- Nextcloud data -> ${NC_DATA_DIR}"; rsync_restore_dir "${EXTRACT_DIR}/files/nextcloud-data" "${NC_DATA_DIR}"; }
fi
if [[ "${ENABLE_MAIL}" == "true" ]]; then
echo "-- Restore mail files..."
[[ -d "$STAGING_DIR/files/mail" && -n "${MAIL_DIR:-}" ]] && rsync_restore_dir "$STAGING_DIR/files/mail" "${MAIL_DIR}" || true
[[ -d "$STAGING_DIR/files/postfix" && -n "${POSTFIX_DIR:-}" ]] && rsync_restore_dir "$STAGING_DIR/files/postfix" "${POSTFIX_DIR}" || true
[[ -d "$STAGING_DIR/files/dovecot" && -n "${DOVECOT_DIR:-}" ]] && rsync_restore_dir "$STAGING_DIR/files/dovecot" "${DOVECOT_DIR}" || true
if [[ "${ENABLE_GITEA:-false}" == "true" ]]; then
: "${GITEA_DATA_DIR:=/var/lib/gitea/data}"
[[ -d "${EXTRACT_DIR}/files/gitea-data" ]] && { echo "-- Gitea data -> ${GITEA_DATA_DIR}"; rsync_restore_dir "${EXTRACT_DIR}/files/gitea-data" "${GITEA_DATA_DIR}"; }
: "${GITEA_ETC_DIR:=/etc/gitea}"
[[ -d "${EXTRACT_DIR}/files/gitea-etc" && -n "${GITEA_ETC_DIR:-}" ]] && { echo "-- Gitea etc -> ${GITEA_ETC_DIR}"; rsync_restore_dir "${EXTRACT_DIR}/files/gitea-etc" "${GITEA_ETC_DIR}"; }
fi
else
echo "-- RESTORE_FILES=false (skipping file restore)"
echo "-- RESTORE_FILES=false (skipping)"
fi
# ---------- Restore databases ----------
mysql_restore_sql() {
local cnf="$1"
local db="$2"
local sql_file="$3"
local cnf="$1" db="$2" sql="$3"
[[ -r "$cnf" ]] || die "DB CNF not readable: $cnf"
[[ -r "$sql_file" ]] || die "SQL file not readable: $sql_file"
echo "-- Import DB: ${db} from ${sql_file}"
run_cmd mysql --defaults-extra-file="$cnf" "$db" < "$sql_file"
[[ -r "$sql" ]] || die "SQL not readable: $sql"
have mysql || die "mysql client missing"
echo "-- Import MySQL/MariaDB DB: ${db} from $(basename "$sql")"
run_cmd mysql --defaults-extra-file="$cnf" "$db" < "$sql"
}
if [[ "${RESTORE_DB}" == "true" ]]; then
if [[ "${RESTORE_DB}" == "true" && -d "${EXTRACT_DIR}/db" ]]; then
echo "-- Restoring databases..."
wp_sql="$(ls -1 "${EXTRACT_DIR}/db"/wordpress_*.sql 2>/dev/null | sort | tail -n 1 || true)"
nc_sql="$(ls -1 "${EXTRACT_DIR}/db"/nextcloud_*.sql 2>/dev/null | sort | tail -n 1 || true)"
g_sql="$(ls -1 "${EXTRACT_DIR}/db"/gitea_*.sql 2>/dev/null | sort | tail -n 1 || true)"
if [[ -n "${WP_DB_NAME:-}" ]]; then
wp_sql="$(ls -1 "$STAGING_DIR/db"/wordpress_*.sql 2>/dev/null | tail -n 1 || true)"
if [[ -n "$wp_sql" ]]; then
mysql_restore_sql "${WP_DB_CNF}" "${WP_DB_NAME}" "$wp_sql"
else
echo "WARN: No WordPress SQL dump found in archive."
fi
fi
if [[ -n "${NC_DB_NAME:-}" ]]; then
nc_sql="$(ls -1 "$STAGING_DIR/db"/nextcloud_*.sql 2>/dev/null | tail -n 1 || true)"
if [[ -n "$nc_sql" ]]; then
mysql_restore_sql "${NC_DB_CNF}" "${NC_DB_NAME}" "$nc_sql"
else
echo "WARN: No Nextcloud SQL dump found in archive."
fi
fi
[[ -n "${WP_DB_NAME:-}" && -n "$wp_sql" ]] && mysql_restore_sql "${WP_DB_CNF}" "${WP_DB_NAME}" "$wp_sql" || echo "WARN: WP DB dump missing"
[[ -n "${NC_DB_NAME:-}" && -n "$nc_sql" ]] && mysql_restore_sql "${NC_DB_CNF}" "${NC_DB_NAME}" "$nc_sql" || echo "WARN: NC DB dump missing"
[[ "${ENABLE_GITEA:-false}" == "true" && -n "${GITEA_DB_NAME:-}" && -n "$g_sql" ]] && mysql_restore_sql "${GITEA_DB_CNF}" "${GITEA_DB_NAME}" "$g_sql" || true
else
echo "-- RESTORE_DB=false (skipping DB restore)"
echo "-- RESTORE_DB=false or no db dump present (skipping)"
fi
# ---------- Post-restore Nextcloud steps ----------
if [[ "${ENABLE_NEXTCLOUD}" == "true" && -d "${NC_DIR}" && -f "${NC_DIR}/occ" ]]; then
if [[ "${ENABLE_NEXTCLOUD:-false}" == "true" && -d "${NC_DIR}" && -f "${NC_DIR}/occ" ]]; then
echo "-- Nextcloud post-restore: maintenance:repair"
run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" maintenance:repair || true
echo "-- Nextcloud post-restore: files:scan (optional, can be slow)"
if [[ "${NC_FILES_SCAN_AFTER_RESTORE}" == "true" ]]; then
echo "-- Nextcloud post-restore: files:scan --all"
run_cmd sudo -u "${NC_OCC_USER}" php "${NC_DIR}/occ" files:scan --all || true
else
echo "-- Skipping files:scan (set NC_FILES_SCAN_AFTER_RESTORE=true to enable)"
fi
if [[ "${ENABLE_NEXTCLOUD_MAINTENANCE}" == "true" ]]; then
@@ -273,6 +240,10 @@ if [[ "${ENABLE_NEXTCLOUD}" == "true" && -d "${NC_DIR}" && -f "${NC_DIR}/occ" ]]
fi
fi
gitea_start
phpfpm_start
httpd_start
echo "== app-restore done: ${ts} =="
echo "-- Extracted staging kept at: ${STAGING_DIR}"
echo "-- Working dir: ${RUN_DIR}"
echo "-- Log: ${LOG_FILE}"