#!/bin/bash # # Script Name: AppBackup-restic.sh # Description: # Creates a backup of a web application and optionally its MySQL database. # The script archives the application home directory locally and backs it # up to a Backblaze B2 repository using Restic. # # Usage: # AppBackup-restic.sh --appname --appuser [--apppassword ] # # Author: Charlie Silverman # Version: 20261031-01 # # Requirements: # restic, jq, awk, tar, mysqldump (when --apppassword is used), # mailx or mail (for email notifications), numfmt (optional, for # human-readable sizes). # Restic environment file: /root/.restic-env # # Versioning: # Versions use the format YYYYMMDD-xx, where YYYYMMDD is the release date # and xx is the release number for that day, starting at 01. # # Revision History: # 1.0 - Initial version. # 1.1 - Added logging to file. # 1.2 - Added strict error handling, input validation, and FQDN support. # 1.3 - Added trap-based error logging and improved operational visibility. # 1.4 - Added application-specific log files, interrupt handling, # and enhanced logging. # 1.5 - Added command-line help. # 1.6 - Replaced positional parameters with named command-line options # and added support for short/long option formats. # 1.7 (a) - Added --no-tablespaces to mysqldump. Added --single-transaction # to eliminate the need to lock tables during backup. # 1.7 (b) - Added email delivery of current execution log on success, # failure, or interruption. # 20261031-01 - Consolidated the two divergent 1.7 versions into a single # script. Adopted YYYYMMDD-xx versioning scheme. Version is now # defined once (SCRIPT_VERSION) and used in help and logs. # Removed SFTP upload; backups always go to Restic. # Restic snapshot details (ID, parent, tags, file/dir counts, # data added, snapshot size, duration) are now written to the # logs. restic backup now runs with --json and the details are # read from its backup summary. Added required-command check. # Temporary restic output is removed on failure or interrupt. # set -o errexit set -o nounset set -o pipefail SCRIPT_VERSION="20261031-01" NOTIFY_EMAIL="ackitsme3@gmail.com" #------------------------------------------------------------------------------ # Help Function #------------------------------------------------------------------------------ show_help() { cat << EOF AppBackup-restic.sh v${SCRIPT_VERSION} DESCRIPTION Creates a backup of a web application's files and optionally its MySQL database. The backup is sent to a Backblaze B2 repository using Restic. USAGE AppBackup-restic.sh --appname --appuser [--apppassword ] OPTIONS -a, --appname Name of the application and MySQL database. Required. -u, --appuser Linux account that owns the application files. Required. -p, --apppassword MySQL password. If omitted, the database backup is skipped. Optional. -h, --help Display this help and exit. EXAMPLES AppBackup-restic.sh \\ --appname KISNetManager \\ --appuser kisuser \\ --apppassword MySecurePassword AppBackup-restic.sh \\ -a CustomerPortal \\ -u portaluser LOG FILES Historical Log: /tmp/AppBackup-.log Current Run Log: /tmp/AppBackup--YYYYMMDD_HHMMSS.log EMAIL NOTIFICATIONS Current execution logs are emailed to: ${NOTIFY_EMAIL} Success, failure, and interruption events each generate a separate email. AUTHOR Charlie Silverman VERSION ${SCRIPT_VERSION} EOF } #------------------------------------------------------------------------------ # Process Command Line Arguments #------------------------------------------------------------------------------ AppName="" AppUser="" AppPassword="" while [[ $# -gt 0 ]]; do case "$1" in -a|--appname) AppName="$2" shift 2 ;; --appname=*) AppName="${1#*=}" shift ;; -u|--appuser) AppUser="$2" shift 2 ;; --appuser=*) AppUser="${1#*=}" shift ;; -p|--apppassword) AppPassword="$2" shift 2 ;; --apppassword=*) AppPassword="${1#*=}" shift ;; -h|--help) show_help exit 0 ;; *) echo "ERROR: Unknown option '$1'" echo show_help exit 1 ;; esac done #------------------------------------------------------------------------------ # Validate Required Parameters #------------------------------------------------------------------------------ if [[ -z "$AppName" || -z "$AppUser" ]]; then echo "ERROR: --appname and --appuser are required." echo show_help exit 1 fi #------------------------------------------------------------------------------ # Runtime Variables #------------------------------------------------------------------------------ DATE=$(date "+%Y%m%d_%H%M%S") HOSTNAME_FQDN="$(hostname -f)" SAFE_APPNAME=$(echo "$AppName" | tr -cs '[:alnum:]_-' '_') LOGFILE="/tmp/AppBackup-${SAFE_APPNAME}.log" RUNLOG="/tmp/AppBackup-${SAFE_APPNAME}-${DATE}.log" RESTIC_OUTPUT="" touch "$RUNLOG" #------------------------------------------------------------------------------ # Logging Function #------------------------------------------------------------------------------ log() { local msg msg="$(date '+%Y-%m-%d %H:%M:%S') [$$] $1" echo "$msg" | tee -a "$RUNLOG" } #------------------------------------------------------------------------------ # Email Function #------------------------------------------------------------------------------ send_email_log() { local subject="$1" if command -v mailx >/dev/null 2>&1; then mailx -s "$subject" "$NOTIFY_EMAIL" < "$RUNLOG" elif command -v mail >/dev/null 2>&1; then mail -s "$subject" "$NOTIFY_EMAIL" < "$RUNLOG" else log "WARNING: mailx/mail command not found. Email not sent." fi } #------------------------------------------------------------------------------ # Convert a byte count to a human-readable size (e.g. 1.23GiB) #------------------------------------------------------------------------------ human_bytes() { local bytes="${1:-0}" if command -v numfmt >/dev/null 2>&1; then numfmt --to=iec-i --suffix=B --format="%.2f" "$bytes" 2>/dev/null \ || echo "${bytes} B" else echo "${bytes} B" fi } #------------------------------------------------------------------------------ # Write Restic snapshot details from a backup summary (JSON) to the log #------------------------------------------------------------------------------ log_snapshot_info() { local summary="$1" local snapshot_id parent_id local files_new files_changed files_unmodified local dirs_new dirs_changed dirs_unmodified local data_added data_added_packed local total_files total_bytes total_duration snapshot_id=$(jq -r '.snapshot_id // "none"' <<< "$summary") parent_id=$(restic snapshots "$snapshot_id" --json 2>/dev/null \ | jq -r '.[0].parent // "none"' 2>/dev/null || echo "unknown") files_new=$(jq -r '.files_new // 0' <<< "$summary") files_changed=$(jq -r '.files_changed // 0' <<< "$summary") files_unmodified=$(jq -r '.files_unmodified // 0' <<< "$summary") dirs_new=$(jq -r '.dirs_new // 0' <<< "$summary") dirs_changed=$(jq -r '.dirs_changed // 0' <<< "$summary") dirs_unmodified=$(jq -r '.dirs_unmodified // 0' <<< "$summary") data_added=$(jq -r '.data_added // 0' <<< "$summary") data_added_packed=$(jq -r '.data_added_packed // empty' <<< "$summary") total_files=$(jq -r '.total_files_processed // 0' <<< "$summary") total_bytes=$(jq -r '.total_bytes_processed // 0' <<< "$summary") total_duration=$(jq -r '.total_duration // 0' <<< "$summary") log "---------------- Restic Snapshot -----------------" log "Snapshot ID: ${snapshot_id:0:8} (${snapshot_id})" log "Parent snapshot: ${parent_id:0:8}" log "Tags: apache, website, ${AppName}, ${HOSTNAME_FQDN}" log "Files: ${files_new} new, ${files_changed} changed, ${files_unmodified} unmodified" log "Directories: ${dirs_new} new, ${dirs_changed} changed, ${dirs_unmodified} unmodified" if [[ -n "$data_added_packed" ]]; then log "Data added: $(human_bytes "$data_added") ($(human_bytes "$data_added_packed") stored)" else log "Data added: $(human_bytes "$data_added")" fi log "Snapshot size: $(human_bytes "$total_bytes") in ${total_files} files" log "Duration: $(awk -v d="$total_duration" 'BEGIN{printf "%.1f", d}') seconds" log "--------------------------------------------------" } #------------------------------------------------------------------------------ # Remove temporary Restic output #------------------------------------------------------------------------------ cleanup_restic_output() { if [[ -n "${RESTIC_OUTPUT:-}" ]]; then rm -f "${RESTIC_OUTPUT}" RESTIC_OUTPUT="" fi } #------------------------------------------------------------------------------ # Error Handling #------------------------------------------------------------------------------ error_handler() { local exit_code=$? local line_number=$1 log "ERROR: Backup failed on line ${line_number} with exit code ${exit_code}" cleanup_restic_output send_email_log \ "FAILED: AppBackup ${AppName} (${HOSTNAME_FQDN})" exit "${exit_code}" } interrupt_handler() { log "ERROR: Backup interrupted by signal" cleanup_restic_output send_email_log \ "INTERRUPTED: AppBackup ${AppName} (${HOSTNAME_FQDN})" exit 1 } trap 'error_handler $LINENO' ERR trap interrupt_handler SIGINT SIGTERM #------------------------------------------------------------------------------ # Startup Logging #------------------------------------------------------------------------------ log "=================================================================" log "Starting backup job (AppBackup-restic.sh v${SCRIPT_VERSION})" log "Application: $AppName" log "Application User: $AppUser" log "Host: $HOSTNAME_FQDN" log "Historical Log: $LOGFILE" log "Run Log: $RUNLOG" log "=================================================================" #------------------------------------------------------------------------------ # Verify Requirements #------------------------------------------------------------------------------ for cmd in restic jq awk tar; do if ! command -v "$cmd" >/dev/null 2>&1; then log "ERROR: Missing required command: $cmd" send_email_log \ "FAILED: AppBackup ${AppName} (${HOSTNAME_FQDN})" exit 1 fi done #------------------------------------------------------------------------------ # Restic Repository Configuration #------------------------------------------------------------------------------ export RESTIC_REPOSITORY="s3:https://s3.us-west-004.backblazeb2.com/ackitsbucket/docker" export RESTIC_PASSWORD_FILE="/root/.restic-password" #------------------------------------------------------------------------------ # Host Specific Configuration #------------------------------------------------------------------------------ HostPrefix="" if [[ "$HOSTNAME_FQDN" == "ackitserver.ackcentuate.com" ]]; then if [[ "$AppName" == "KISNetManager" ]]; then HostPrefix="dev-" fi fi ArchiveFile="${AppName}-${DATE}.tar.bz2" #------------------------------------------------------------------------------ # Prepare Backup Workspace #------------------------------------------------------------------------------ log "Changing to backup directory" cd /home/Backups #------------------------------------------------------------------------------ # Database Backup #------------------------------------------------------------------------------ if [[ -n "$AppPassword" ]]; then log "Creating MySQL database backup" mkdir -p "/home/${AppUser}/SQLBackup" mysqldump \ --no-tablespaces \ --single-transaction \ -u "$AppUser" \ --password="$AppPassword" \ "$AppName" \ > "/home/${AppUser}/SQLBackup/${AppName}.sqldump" log "MySQL dump completed successfully" else log "Database password not supplied. Skipping MySQL backup." fi #------------------------------------------------------------------------------ # Remove Previous Backup Archives #------------------------------------------------------------------------------ log "Removing previous backup archives" rm -f /home/Backups/"${AppName}"*.tar.bz* 2>/dev/null || true rm -f /home/Backups/"${HostPrefix}${AppName}"*.tar.bz* 2>/dev/null || true #------------------------------------------------------------------------------ # Create Archive #------------------------------------------------------------------------------ log "Creating archive ${HostPrefix}${ArchiveFile}" tar cjf \ "/home/Backups/${HostPrefix}${ArchiveFile}" \ "/home/${AppUser}/" log "Archive created successfully" #------------------------------------------------------------------------------ # Upload Backup #------------------------------------------------------------------------------ log "Loading Restic environment" source /root/.restic-env log "Starting Restic backup" # Capture restic's JSON output in a temporary file. Errors and warnings # still go to the run log via stderr. RESTIC_OUTPUT="$(mktemp /tmp/restic-backup-XXXXXX.json)" restic backup "/home/${AppUser}/" \ --json \ --tag apache \ --tag website \ --tag "$AppName" \ --tag "$HOSTNAME_FQDN" \ > "$RESTIC_OUTPUT" 2>> "$RUNLOG" log "Restic backup completed successfully" #------------------------------------------------------------------------------ # Log Restic Snapshot Details #------------------------------------------------------------------------------ summary=$(jq -cR 'fromjson? | select(.message_type == "summary")' \ "$RESTIC_OUTPUT" | tail -n 1) cleanup_restic_output if [[ -n "$summary" ]]; then log_snapshot_info "$summary" else log "WARNING: Restic returned no snapshot summary for ${AppName}" fi #------------------------------------------------------------------------------ # Cleanup #------------------------------------------------------------------------------ if [[ -n "$AppPassword" ]]; then log "Removing temporary SQL backup files" rm -rf "/home/${AppUser}/SQLBackup" fi #------------------------------------------------------------------------------ # Completion #------------------------------------------------------------------------------ log "Backup completed successfully" log "Application: $AppName" log "Archive: ${HostPrefix}${ArchiveFile}" log "Host: $HOSTNAME_FQDN" log "=================================================================" #------------------------------------------------------------------------------ # Append Current Run Log To Historical Log #------------------------------------------------------------------------------ cat "$RUNLOG" >> "$LOGFILE" #------------------------------------------------------------------------------ # Email Current Run Log #------------------------------------------------------------------------------ send_email_log \ "SUCCESS: AppBackup ${AppName} (${HOSTNAME_FQDN})" #------------------------------------------------------------------------------ # Cleanup Run Log #------------------------------------------------------------------------------ rm -f "$RUNLOG" exit 0