fix: enhance backup-site.sh with improved error handling, new options for backup management, and detailed usage instructions

This commit is contained in:
duthink 2025-11-15 17:13:43 +05:30
parent d09023ceea
commit 69e3654be7

View file

@ -8,218 +8,468 @@ set -euo pipefail
# Colors
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
# Helper functions
echo_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
echo_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
echo_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
echo_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
echo_error() { echo -e "${RED}[ERROR]${NC} $1"; }
echo_debug() { [[ "${DEBUG:-}" == "1" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; }
echo_debug() { [[ "${DEBUG:-0}" == "1" ]] && echo -e "${BLUE}[DEBUG]${NC} $1" || true; }
log_action() { echo "$(date '+%Y-%m-%d %H:%M:%S') - $1" >> "/tmp/erpnext-backup-$(date '+%Y%m%d').log"; }
# Cleanup on error
cleanup() {
local exit_code=$?
[[ $exit_code -ne 0 ]] && echo_error "Script failed with exit code $exit_code" && log_action "FAILED: ${SITE_NAME:-unknown}"
exit $exit_code
local exit_code=$?
if [[ $exit_code -ne 0 ]]; then
echo_error "Script failed with exit code $exit_code"
log_action "FAILED: ${SITE_NAME:-unknown}"
fi
exit $exit_code
}
trap cleanup EXIT
# Configuration
PROJECT_NAME="${PROJECT_NAME:-erpnext-production}"
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
HOST_BACKUP_ROOT="${HOST_BACKUP_ROOT:-./backups}"
HOST_BACKUP_LAYOUT="${HOST_BACKUP_LAYOUT:-flat}"
AUTO_COPY="${AUTO_COPY:-0}"
CLEANUP_OLD="${CLEANUP_OLD:-0}"
CLEANUP_POLICY="${CLEANUP_POLICY:-}"
HOST_ONLY="${HOST_ONLY:-0}"
COMPOSE_FILE="${COMPOSE_FILE:-production.yaml}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PRODUCTION_DIR="$(dirname "$SCRIPT_DIR")"
# Docker helpers
dc_exec() { docker compose --project-name "$PROJECT_NAME" exec backend "$@"; }
dc_cmd() { docker compose --project-name "$PROJECT_NAME" "$@"; }
COMPOSE_PATH="$PRODUCTION_DIR/$COMPOSE_FILE"
cd "$PRODUCTION_DIR"
# Help function
[[ -f "$COMPOSE_PATH" ]] || { echo_error "Compose file '$COMPOSE_PATH' not found"; exit 1; }
dc_exec() { docker compose --project-name "$PROJECT_NAME" -f "$COMPOSE_PATH" exec backend "$@"; }
dc_cmd() { docker compose --project-name "$PROJECT_NAME" -f "$COMPOSE_PATH" "$@"; }
show_help() {
cat << EOF
cat <<EOF
Usage: $0 <site-name> [options]
Options:
--with-files Include files in backup
--compress Compress the backup
--auto-copy Copy backups to host ./backups/
--cleanup-old Remove backups older than BACKUP_RETENTION_DAYS
--encrypt Encrypt with GPG (requires BACKUP_PASSPHRASE env var)
--debug Enable debug output
-h, --help Show this help
--with-files Include public/private files in the bench backup
--compress Compress the SQL dump (bench flag)
--auto-copy Copy the new backup files to the host (flat layout by default)
--flat-host-path Store host copies directly in \$HOST_BACKUP_ROOT (default)
--nested-host-path Store host copies in \$HOST_BACKUP_ROOT/<site>/<timestamp>/
--host-only Delete container copies after a successful host copy
--cleanup-old[=policy] Remove stale backups. Policy examples:
(empty) → BACKUP_RETENTION_DAYS days
7 → files older than 7 days
keep:5 → keep newest 5 runs
latest → keep only backups from this run
--retention-days N Deprecated alias for --cleanup-old N
--encrypt Encrypt host copies with GPG (needs BACKUP_PASSPHRASE)
--debug Verbose logging
-h, --help Show this help
Environment Variables:
PROJECT_NAME Docker project name (default: erpnext-production)
BACKUP_RETENTION_DAYS Keep backups for N days (default: 30)
BACKUP_PASSPHRASE GPG encryption passphrase
AUTO_COPY Auto-copy to host (set to 1)
CLEANUP_OLD Auto-cleanup old backups (set to 1)
PROJECT_NAME Docker Compose project name (default: erpnext-production)
BACKUP_RETENTION_DAYS Default days to keep when no policy passed (default: 30)
HOST_BACKUP_ROOT Host destination directory (default: ./backups)
HOST_BACKUP_LAYOUT "flat" (default) or "nested"
AUTO_COPY Set to 1 to copy on every run
HOST_ONLY Set to 1 to delete container copies when AUTO_COPY=1
CLEANUP_OLD Set to 1 to always prune when script runs
CLEANUP_POLICY Default cleanup policy (keep:7, latest, etc.)
BACKUP_PASSPHRASE Required when --encrypt is used
COMPOSE_FILE Compose file relative to production/ (default: production.yaml)
Examples:
$0 erp.example.com --with-files --auto-copy
BACKUP_PASSPHRASE='secret' $0 erp.example.com --encrypt --auto-copy
Decryption:
gpg --decrypt backup-file.gpg > backup-file
$0 erp.example.com --with-files --auto-copy --cleanup-old keep:7
$0 erp.example.com --with-files --auto-copy --host-only --cleanup-old latest
AUTO_COPY=1 CLEANUP_OLD=1 CLEANUP_POLICY=keep:5 $0 erp.example.com
EOF
}
# Parse arguments
SITE_NAME="" WITH_FILES="" COMPRESS=""
AUTO_COPY="${AUTO_COPY:-0}" CLEANUP_OLD="${CLEANUP_OLD:-0}" ENCRYPT="${ENCRYPT:-0}"
require_int() {
local value="$1"; shift
[[ "$value" =~ ^[0-9]+$ ]] || { echo_error "$*"; exit 1; }
}
set_policy() {
[[ "$CLEANUP_OLD" -ne 1 ]] && { CLEANUP_MODE=""; CLEANUP_VALUE=""; return; }
local policy_value="$1"
[[ -z "$policy_value" ]] && policy_value="$BACKUP_RETENTION_DAYS"
case "$policy_value" in
latest|keep-latest)
CLEANUP_MODE="keep"
CLEANUP_VALUE=1
;;
keep:*)
local keep_count="${policy_value#keep:}"
require_int "$keep_count" "keep:<n> expects an integer"
(( keep_count < 1 )) && keep_count=1
CLEANUP_MODE="keep"
CLEANUP_VALUE="$keep_count"
;;
days:*)
local day_count="${policy_value#days:}"
require_int "$day_count" "days:<n> expects an integer"
CLEANUP_MODE="days"
CLEANUP_VALUE="$day_count"
;;
"")
CLEANUP_MODE="days"
CLEANUP_VALUE="$BACKUP_RETENTION_DAYS"
;;
*)
if [[ "$policy_value" =~ ^[0-9]+$ ]]; then
if [[ "$policy_value" -eq 0 ]]; then
CLEANUP_MODE="keep"
CLEANUP_VALUE=1
else
CLEANUP_MODE="days"
CLEANUP_VALUE="$policy_value"
fi
else
echo_error "Invalid cleanup policy: $policy_value"
exit 1
fi
;;
esac
}
cleanup_container_days() {
local days="$1"
local minutes=$((days * 1440))
echo_info "Pruning container backups older than $days day(s)"
dc_exec bash -lc "find '$BACKUP_PATH' -maxdepth 1 -type f -mmin +$minutes -delete" || true
}
cleanup_container_keep() {
local keep_runs="$1"
echo_info "Keeping newest $keep_runs container backup run(s)"
mapfile -t prefixes < <(dc_exec bash -lc "cd '$BACKUP_PATH' && ls -1t 2>/dev/null | awk -F'-' '!seen[$1]++ {print $1}'") || true
if [[ ${#prefixes[@]} -le $keep_runs ]]; then
echo_info "Nothing to prune in container"
return
fi
prefixes=(${prefixes[@]:$keep_runs})
for prefix in "${prefixes[@]}"; do
[[ -z "$prefix" ]] && continue
dc_exec bash -lc "find '$BACKUP_PATH' -maxdepth 1 -type f -name '${prefix}-*' -delete"
done
}
cleanup_host_days() {
local days="$1"
if [[ "$HOST_LAYOUT_MODE" == "nested" ]]; then
[[ -d "$HOST_SITE_ROOT" ]] || return
echo_info "Pruning host backups older than $days day(s)"
find "$HOST_SITE_ROOT" -mindepth 1 -maxdepth 1 -type d -mtime +$days -print -exec rm -rf {} + || true
else
[[ -d "$HOST_BACKUP_ROOT" ]] || return
echo_info "Pruning host backup files older than $days day(s)"
find "$HOST_BACKUP_ROOT" -maxdepth 1 -type f -name "*-${SITE_FILE_KEY}-*" -mtime +$days -print -delete || true
fi
}
cleanup_host_keep() {
local keep_runs="$1"
if [[ "$HOST_LAYOUT_MODE" == "nested" ]]; then
[[ -d "$HOST_SITE_ROOT" ]] || return
mapfile -t dirs < <(ls -1dt "$HOST_SITE_ROOT"/* 2>/dev/null) || true
if [[ ${#dirs[@]} -le $keep_runs ]]; then
return
fi
echo_info "Keeping newest $keep_runs host backup run(s)"
for dir in "${dirs[@]:$keep_runs}"; do
rm -rf "$dir"
done
else
[[ -d "$HOST_BACKUP_ROOT" ]] || return
mapfile -t prefixes < <(find "$HOST_BACKUP_ROOT" -maxdepth 1 -type f -name "*-${SITE_FILE_KEY}-*" -printf '%f\n' | sort -r | awk -F'-' '!seen[$1]++ {print $1}') || true
if [[ ${#prefixes[@]} -le $keep_runs ]]; then
return
fi
echo_info "Keeping newest $keep_runs host backup run(s)"
prefixes=(${prefixes[@]:$keep_runs})
for prefix in "${prefixes[@]}"; do
find "$HOST_BACKUP_ROOT" -maxdepth 1 -type f -name "${prefix}-${SITE_FILE_KEY}-*" -delete
done
fi
}
copy_backups_to_host() {
[[ "$AUTO_COPY" -ne 1 ]] && return
[[ ${#BACKUP_FILES[@]} -eq 0 ]] && return
local dest
if [[ "$HOST_LAYOUT_MODE" == "nested" ]]; then
dest="$HOST_SITE_ROOT/$CURRENT_PREFIX"
else
dest="$HOST_BACKUP_ROOT"
fi
mkdir -p "$dest"
BACKEND_CONTAINER=$(dc_cmd ps -q backend)
[[ -z "$BACKEND_CONTAINER" ]] && { echo_error "Backend container not running"; exit 1; }
HOST_RUN_FILES=()
for file_path in "${BACKUP_FILES[@]}"; do
local base target
base=$(basename "$file_path")
target="$dest/$base"
if docker cp "${BACKEND_CONTAINER}:${file_path}" "$target" 2>/dev/null; then
echo_info " → Copied $base"
HOST_RUN_FILES+=("$target")
else
echo_warn " ✗ Failed to copy $base"
fi
done
if [[ ${#HOST_RUN_FILES[@]} -gt 0 ]]; then
HOST_COPY_SUCCESS=1
HOST_RUN_DIR="$dest"
echo_info "✓ Host copy complete: $dest"
else
echo_warn "No files copied to host"
fi
}
encrypt_host_backups() {
[[ "$ENCRYPT" -ne 1 ]] && return
[[ "$HOST_COPY_SUCCESS" -ne 1 ]] && { echo_warn "Cannot encrypt host copy missing"; return; }
command -v gpg >/dev/null 2>&1 || { echo_error "GPG not installed"; exit 1; }
[[ -z "${BACKUP_PASSPHRASE:-}" ]] && { echo_error "BACKUP_PASSPHRASE not set"; exit 1; }
local encrypted=0
for backup_file in "${HOST_RUN_FILES[@]}"; do
[[ -f "$backup_file" ]] || continue
if echo "$BACKUP_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--symmetric --cipher-algo AES256 -o "${backup_file}.gpg" "$backup_file"; then
rm -f "$backup_file"
((encrypted++))
echo_info " ✓ Encrypted $(basename "${backup_file}.gpg")"
else
echo_warn " ✗ Encryption failed for $(basename "$backup_file")"
fi
done
echo_info "Encrypted $encrypted file(s)"
log_action "SUCCESS: Encrypted $encrypted files"
}
remove_container_backups() {
[[ "$HOST_ONLY" -ne 1 ]] && return
echo_info "Removing container backups (host-only mode)"
dc_exec bash -lc "find '$BACKUP_PATH' -maxdepth 1 -type f -delete" || true
}
verify_backup_presence() {
if [[ "$AUTO_COPY" -eq 1 ]]; then
if [[ "$HOST_COPY_SUCCESS" -ne 1 ]] || [[ ${#HOST_RUN_FILES[@]} -eq 0 ]]; then
echo_error "Host backup missing after copy attempt"
exit 1
fi
for backup_file in "${HOST_RUN_FILES[@]}"; do
[[ -f "$backup_file" || -f "${backup_file}.gpg" ]] && continue
echo_error "Host backup file missing: $(basename "$backup_file")"
exit 1
done
else
for base in "${BACKUP_BASENAMES[@]}"; do
if ! dc_exec test -f "$BACKUP_PATH/$base"; then
echo_error "Container backup file missing: $base"
exit 1
fi
done
fi
}
print_host_summary() {
[[ "$AUTO_COPY" -ne 1 ]] && { echo_info "Use --auto-copy to mirror backups onto the host"; return; }
if [[ "$HOST_LAYOUT_MODE" == "nested" ]]; then
echo_info "Latest host backups:"
ls -lht "$HOST_SITE_ROOT" 2>/dev/null | head -n 6 || true
else
echo_info "Latest host backup files:"
if [[ -d "$HOST_BACKUP_ROOT" ]]; then
(ls -lht "$HOST_BACKUP_ROOT" 2>/dev/null | grep "$SITE_FILE_KEY" | head -n 6) || true
fi
fi
}
# Runtime values
SITE_NAME=""
WITH_FILES=0
COMPRESS=0
ENCRYPT=0
DEBUG="${DEBUG:-0}"
CLEANUP_MODE=""
CLEANUP_VALUE=""
HOST_LAYOUT_OVERRIDE=""
HOST_LAYOUT_MODE=""
HOST_SITE_ROOT=""
SITE_SAFE_NAME=""
SITE_FILE_KEY=""
BACKUP_PATH=""
MARKER=""
CURRENT_PREFIX=""
BACKEND_CONTAINER=""
HOST_COPY_SUCCESS=0
HOST_RUN_DIR=""
declare -a BACKUP_FILES=()
declare -a BACKUP_BASENAMES=()
declare -a HOST_RUN_FILES=()
# Parse arguments
while [[ $# -gt 0 ]]; do
case $1 in
-h|--help) show_help; exit 0 ;;
--with-files) WITH_FILES="--with-files"; shift ;;
--compress) COMPRESS="--compress"; shift ;;
--auto-copy) AUTO_COPY=1; shift ;;
--cleanup-old) CLEANUP_OLD=1; shift ;;
--encrypt) ENCRYPT=1; shift ;;
--debug) DEBUG=1; shift ;;
*) [[ -z "$SITE_NAME" ]] && SITE_NAME="$1" || { echo_error "Unknown: $1"; show_help; exit 1; }; shift ;;
esac
case "$1" in
-h|--help) show_help; exit 0 ;;
--with-files) WITH_FILES=1; shift ;;
--compress) COMPRESS=1; shift ;;
--auto-copy) AUTO_COPY=1; shift ;;
--host-only) HOST_ONLY=1; AUTO_COPY=1; shift ;;
--flat-host-path) AUTO_COPY=1; HOST_LAYOUT_OVERRIDE="flat"; shift ;;
--nested-host-path) AUTO_COPY=1; HOST_LAYOUT_OVERRIDE="nested"; shift ;;
--cleanup-old)
CLEANUP_OLD=1
if [[ -n "${2:-}" && ! "${2}" =~ ^- ]]; then
CLEANUP_POLICY="$2"
shift 2
else
shift
fi
;;
--cleanup-old=*)
CLEANUP_OLD=1
CLEANUP_POLICY="${1#*=}"
shift
;;
--retention-days)
CLEANUP_OLD=1
[[ -n "${2:-}" ]] || { echo_error "--retention-days needs an integer"; exit 1; }
CLEANUP_POLICY="$2"
shift 2
;;
--retention-days=*)
CLEANUP_OLD=1
CLEANUP_POLICY="${1#*=}"
shift
;;
--encrypt) ENCRYPT=1; shift ;;
--debug) DEBUG=1; shift ;;
*)
if [[ -z "$SITE_NAME" ]]; then
SITE_NAME="$1"
shift
else
echo_error "Unknown argument: $1"
show_help
exit 1
fi
;;
esac
done
# Get site name if not provided
if [[ -z "$SITE_NAME" ]]; then
echo_warn "Site name required"
read -p "Enter site name (e.g., erp.example.com): " SITE_NAME
read -rp "Enter site name (e.g., erp.example.com): " SITE_NAME
fi
[[ -z "$SITE_NAME" ]] && { echo_error "Site name cannot be empty"; exit 1; }
[[ -z "$SITE_NAME" ]] && { echo_error "Site name is required"; exit 1; }
# Validate site name format
if [[ ! "$SITE_NAME" =~ ^[a-zA-Z0-9][a-zA-Z0-9.-]*[a-zA-Z0-9]$ ]]; then
echo_warn "Site name format looks unusual: $SITE_NAME"
read -p "Continue? (y/N): " -n 1 -r; echo
[[ ! $REPLY =~ ^[Yy]$ ]] && exit 1
fi
[[ "$BACKUP_RETENTION_DAYS" =~ ^[0-9]+$ ]] || { echo_error "BACKUP_RETENTION_DAYS must be a non-negative integer"; exit 1; }
echo_debug "Site: $SITE_NAME, Project: $PROJECT_NAME"
HOST_LAYOUT_MODE="${HOST_LAYOUT_OVERRIDE:-$HOST_BACKUP_LAYOUT}"
HOST_LAYOUT_MODE="${HOST_LAYOUT_MODE,,}"
case "$HOST_LAYOUT_MODE" in
flat|nested) ;;
*) echo_warn "Unknown HOST_BACKUP_LAYOUT '$HOST_LAYOUT_MODE', defaulting to flat"; HOST_LAYOUT_MODE="flat" ;;
esac
# Check encryption requirements
if [[ "$ENCRYPT" == "1" ]]; then
[[ -z "${BACKUP_PASSPHRASE:-}" ]] && { echo_error "BACKUP_PASSPHRASE not set"; exit 1; }
command -v gpg >/dev/null 2>&1 || { echo_error "GPG not installed"; exit 1; }
echo_debug "Encryption ready"
fi
SITE_SAFE_NAME="${SITE_NAME//[^A-Za-z0-9._-]/_}"
SITE_FILE_KEY="$(echo "$SITE_NAME" | sed 's/[^A-Za-z0-9]/_/g')"
HOST_SITE_ROOT="$HOST_BACKUP_ROOT/$SITE_SAFE_NAME"
BACKUP_PATH="/home/frappe/frappe-bench/sites/$SITE_NAME/private/backups"
MARKER="/tmp/backup-${SITE_NAME//[^A-Za-z0-9]/-}-$$.marker"
# Validate environment
[[ ! -f "production.yaml" ]] && { echo_error "production.yaml not found"; exit 1; }
set_policy "$CLEANUP_POLICY"
echo_debug "Site: $SITE_NAME"
docker info >/dev/null 2>&1 || { echo_error "Docker not running"; exit 1; }
dc_exec echo "test" >/dev/null 2>&1 || { echo_error "Backend container not running"; exit 1; }
# Verify site exists
echo_info "Verifying site: $SITE_NAME"
if ! dc_exec bench --site "$SITE_NAME" list-apps >/dev/null 2>&1; then
echo_error "Site '$SITE_NAME' not found"
echo_info "Available sites:"
dc_exec find /home/frappe/frappe-bench/sites -maxdepth 1 -type d \( -name "*.local" -o -name "*.*" \) | \
sed 's|.*/||' | grep -v "^$" || echo " None found"
exit 1
echo_error "Site '$SITE_NAME' not found"
exit 1
fi
# Create backup
dc_exec bash -lc "touch '$MARKER'"
echo_info "Creating backup for: $SITE_NAME"
log_action "STARTED: $SITE_NAME"
BACKUP_TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
if ! dc_exec bench --site "$SITE_NAME" backup $WITH_FILES $COMPRESS; then
echo_error "Backup command failed!"
log_action "FAILED: Backup command"
exit 1
bench_cmd=(bench --site "$SITE_NAME" backup)
(( WITH_FILES )) && bench_cmd+=(--with-files)
(( COMPRESS )) && bench_cmd+=(--compress)
if ! dc_exec "${bench_cmd[@]}"; then
echo_error "Backup command failed"
log_action "FAILED: backup command"
exit 1
fi
# Verify backup files
BACKUP_PATH="/home/frappe/frappe-bench/sites/$SITE_NAME/private/backups"
BACKUP_FILES=$(dc_exec find "$BACKUP_PATH" -type f -mmin -2 2>/dev/null | tr -d '\r' || echo "")
mapfile -t BACKUP_FILES < <(dc_exec bash -lc "find '$BACKUP_PATH' -maxdepth 1 -type f -newer '$MARKER' -print") || true
dc_exec bash -lc "rm -f '$MARKER'" || true
if [[ -z "$BACKUP_FILES" ]]; then
echo_error "No backup files found!"
log_action "FAILED: No files"
exit 1
if [[ ${#BACKUP_FILES[@]} -eq 0 ]]; then
echo_error "No backup files detected"
log_action "FAILED: no files"
exit 1
fi
# Display backup info
BACKUP_COUNT=$(echo "$BACKUP_FILES" | wc -l)
echo_info "✓ Created $BACKUP_COUNT file(s)"
echo_info "Files:"
TOTAL_SIZE=0
while IFS= read -r file; do
[[ -z "$file" ]] && continue
SIZE_BYTES=$(dc_exec stat -c%s "$file" 2>/dev/null | tr -d '\r' || echo "0")
SIZE_HR=$(numfmt --to=iec-i --suffix=B "$SIZE_BYTES" 2>/dev/null || echo "$SIZE_BYTES bytes")
echo_info " - $(basename "$file") ($SIZE_HR)"
TOTAL_SIZE=$((TOTAL_SIZE + SIZE_BYTES))
done <<< "$BACKUP_FILES"
for file_path in "${BACKUP_FILES[@]}"; do
[[ -z "$file_path" ]] && continue
base=$(basename "$file_path")
BACKUP_BASENAMES+=("$base")
size_bytes=$(dc_exec stat -c%s "$file_path" 2>/dev/null | tr -d '\r' || echo "0")
TOTAL_SIZE=$((TOTAL_SIZE + size_bytes))
size_hr=$(numfmt --to=iec-i --suffix=B "$size_bytes" 2>/dev/null || echo "$size_bytes bytes")
echo_info " - $base ($size_hr)"
done
CURRENT_PREFIX="${BACKUP_BASENAMES[0]%%-*}"
[[ -z "$CURRENT_PREFIX" ]] && CURRENT_PREFIX="$(date '+%Y%m%d_%H%M%S')"
[[ $TOTAL_SIZE -gt 0 ]] && echo_info "Total size: $(numfmt --to=iec-i --suffix=B "$TOTAL_SIZE" 2>/dev/null || echo "$TOTAL_SIZE bytes")"
log_action "SUCCESS: ${#BACKUP_FILES[@]} files, $TOTAL_SIZE bytes"
log_action "SUCCESS: $BACKUP_COUNT files, $TOTAL_SIZE bytes"
# Auto-copy to host
if [[ "$AUTO_COPY" == "1" ]]; then
echo_info "Copying to host ./backups/"
mkdir -p ./backups
BACKEND_CONTAINER=$(dc_cmd ps -q backend)
if docker cp "${BACKEND_CONTAINER}:${BACKUP_PATH}/." ./backups/ 2>/dev/null; then
echo_info "✓ Copied to ./backups/"
# Encrypt if requested
if [[ "$ENCRYPT" == "1" ]]; then
echo_info "Encrypting backups..."
TIMESTAMP_PREFIX="${BACKUP_TIMESTAMP:0:12}"
RECENT_BACKUPS=$(find ./backups -type f -mmin -1 -name "${TIMESTAMP_PREFIX}*" ! -name "*.gpg" 2>/dev/null || true)
if [[ -n "$RECENT_BACKUPS" ]]; then
ENCRYPTED_COUNT=0
while IFS= read -r backup_file; do
[[ ! -f "$backup_file" ]] && continue
if echo "$BACKUP_PASSPHRASE" | gpg --batch --yes --passphrase-fd 0 \
--symmetric --cipher-algo AES256 -o "${backup_file}.gpg" "$backup_file" 2>/dev/null; then
[[ -f "${backup_file}.gpg" ]] && rm -f "$backup_file" && ((ENCRYPTED_COUNT++))
echo_info " ✓ Encrypted: $(basename "${backup_file}.gpg")"
else
echo_warn " ✗ Encryption failed: $(basename "$backup_file")"
fi
done <<< "$RECENT_BACKUPS"
echo_info "✓ Encrypted $ENCRYPTED_COUNT file(s)"
log_action "SUCCESS: Encrypted $ENCRYPTED_COUNT files"
fi
fi
# Show latest files
echo_info "Latest in ./backups/:"
ls -lht ./backups/ | head -n 6 || true
else
echo_warn "Copy failed. Files remain in container."
fi
if [[ "$AUTO_COPY" -eq 1 ]]; then
if [[ "$HOST_LAYOUT_MODE" == "nested" ]]; then
echo_info "Copying backups to $HOST_SITE_ROOT/$CURRENT_PREFIX"
else
echo_info "Copying backups to $HOST_BACKUP_ROOT"
fi
copy_backups_to_host
encrypt_host_backups
else
echo_info "Backup location: ${BACKUP_PATH}/"
echo_info "To copy: docker cp \$(docker compose -p $PROJECT_NAME ps -q backend):${BACKUP_PATH}/. ./backups/"
echo_info "Backups stored inside container: $BACKUP_PATH"
fi
# Cleanup old backups
if [[ "$CLEANUP_OLD" == "1" ]] && [[ "$BACKUP_RETENTION_DAYS" -gt 0 ]]; then
echo_info "Cleaning backups older than $BACKUP_RETENTION_DAYS days"
# Container cleanup
dc_exec find "$BACKUP_PATH" -type f -mtime +$BACKUP_RETENTION_DAYS -delete 2>/dev/null && \
echo_info "✓ Container cleanup done" || echo_warn "Container cleanup failed"
# Host cleanup
if [[ -d "./backups" ]]; then
DELETED=$(find "./backups" -type f -mtime +$BACKUP_RETENTION_DAYS 2>/dev/null | wc -l)
[[ "$DELETED" -gt 0 ]] && find "./backups" -type f -mtime +$BACKUP_RETENTION_DAYS -delete 2>/dev/null
echo_info "✓ Host cleanup: removed $DELETED file(s)"
fi
remove_container_backups
if [[ "$CLEANUP_OLD" -eq 1 ]]; then
if [[ "$CLEANUP_MODE" == "days" ]]; then
cleanup_container_days "$CLEANUP_VALUE"
[[ "$AUTO_COPY" -eq 1 ]] && cleanup_host_days "$CLEANUP_VALUE"
elif [[ "$CLEANUP_MODE" == "keep" ]]; then
cleanup_container_keep "$CLEANUP_VALUE"
[[ "$AUTO_COPY" -eq 1 ]] && cleanup_host_keep "$CLEANUP_VALUE"
fi
fi
echo_info "✓ Backup completed successfully!"
verify_backup_presence
print_host_summary
echo_info "✓ Backup completed successfully!"