diff --git a/production/scripts/backup-site.sh b/production/scripts/backup-site.sh index f4877f8f..fb7c7100 100755 --- a/production/scripts/backup-site.sh +++ b/production/scripts/backup-site.sh @@ -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 < [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/// + --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: 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: 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!" \ No newline at end of file +verify_backup_presence +print_host_summary + +echo_info "✓ Backup completed successfully!"