#!/bin/bash # ============================================================================= # FundTracker Project Backup # ============================================================================= # Backs up: # 1. Complete FundTracker project/site files # 2. FundTracker PostgreSQL database # # Creates ONE compressed archive containing both. # # Project files: # /Kingdezignsnas16/fundtracker # # PostgreSQL: # Container: fundtracker_db # Database: fundtracker # User: fundtracker # # Backup destination: # /export/kingdezignsnas-16/Public/07 - Projects/NAS16/fundtracker # # Retention: # Keeps the 10 most recent backups. # # Email: # Uses OMV Postfix / Gmail relay via sendmail. # # Designed to run as ROOT from: # OMV -> System -> Scheduled Jobs # # Backup destination: /export/kingdezignsnas-16/Public/07 - Projects/NAS16/fundtracker # Script location: /usr/scripts/fundtracker/fundtracker-backup.sh # ============================================================================= set -uo pipefail # ----------------------------------------------------------------------------- # CONFIGURATION # ----------------------------------------------------------------------------- HOSTNAME_LABEL=$(hostname -s 2>/dev/null || echo "NAS16") REPORT_TO="rufus.king@kingdezigns.com" REPORT_FROM="backup-monitor@nas16.local" # FundTracker project files PROJECT_DIR="/Kingdezignsnas16/fundtracker" # PostgreSQL container DB_CONTAINER="fundtracker_db" DB_NAME="fundtracker" DB_USER="fundtracker" # Backup destination BACKUP_ROOT="/export/kingdezignsnas-16/Public/07 - Projects/NAS16/fundtracker" # Temporary working directory TMP_BASE="/tmp/fundtracker-backup" # Keep this many completed backups RETAIN_COUNT=10 # Date/time DATE=$(date +%Y-%m-%d) TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) REPORT_TIME=$(date "+%Y-%m-%d %H:%M:%S") TMP_DIR="${TMP_BASE}/fundtracker-${TIMESTAMP}" LOG_FILE="${BACKUP_ROOT}/backup.log" # Variable placeholder for version (populated dynamically during run) SITE_VERSION="unknown" ARCHIVE_NAME="fundtracker-backup-${TIMESTAMP}.tar.gz" # ----------------------------------------------------------------------------- # STATUS TRACKING # ----------------------------------------------------------------------------- OVERALL_STATUS="SUCCESS" SECTION_ROWS_HTML="" WARNING_COUNT=0 ERROR_COUNT=0 add_section_row() { local section="$1" local status="$2" local detail="$3" case "$status" in OK) BADGE="✓ OK" ;; WARNING) BADGE="⚠ WARNING" WARNING_COUNT=$((WARNING_COUNT + 1)) [[ "$OVERALL_STATUS" == "SUCCESS" ]] && OVERALL_STATUS="WARNING" ;; ERROR) BADGE="✗ ERROR" ERROR_COUNT=$((ERROR_COUNT + 1)) OVERALL_STATUS="FAILED" ;; SKIPPED) BADGE="— SKIPPED" ;; esac SECTION_ROWS_HTML+=" $section $BADGE $detail " } # ----------------------------------------------------------------------------- # LOGGING # ----------------------------------------------------------------------------- log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" } log_section() { echo "" | tee -a "$LOG_FILE" echo "------------------------------------------------------------" | tee -a "$LOG_FILE" log "$1" echo "------------------------------------------------------------" | tee -a "$LOG_FILE" } # ----------------------------------------------------------------------------- # INITIALIZE # ----------------------------------------------------------------------------- mkdir -p "$BACKUP_ROOT" log_section "FundTracker Backup Starting" ARCHIVE_SIZE="N/A" BACKUP_COUNT=0 # ----------------------------------------------------------------------------- # PRE-FLIGHT CHECK 1 — NAS16 STORAGE # ----------------------------------------------------------------------------- if [ ! -d "/export/kingdezignsnas-16" ]; then log "ERROR: /export/kingdezignsnas-16 is not mounted or accessible." add_section_row \ "NAS16 Storage" \ "ERROR" \ "/export/kingdezignsnas-16 is not accessible" else log "NAS16 storage is accessible." add_section_row \ "NAS16 Storage" \ "OK" \ "/export/kingdezignsnas-16 is accessible" fi # ----------------------------------------------------------------------------- # PRE-FLIGHT CHECK 2 — FUNDTRACKER PROJECT # ----------------------------------------------------------------------------- if [ ! -d "$PROJECT_DIR" ]; then log "ERROR: FundTracker project directory not found:" log "$PROJECT_DIR" add_section_row \ "FundTracker Files" \ "ERROR" \ "$PROJECT_DIR does not exist" else log "FundTracker project directory found." add_section_row \ "FundTracker Files" \ "OK" \ "$PROJECT_DIR is accessible" fi # ----------------------------------------------------------------------------- # PRE-FLIGHT CHECK 3 — DOCKER # ----------------------------------------------------------------------------- if ! command -v docker >/dev/null 2>&1; then log "ERROR: Docker command not found." add_section_row \ "Docker" \ "ERROR" \ "Docker command is not available" else add_section_row \ "Docker" \ "OK" \ "Docker command available" fi # ----------------------------------------------------------------------------- # PRE-FLIGHT CHECK 4 — DATABASE CONTAINER # ----------------------------------------------------------------------------- if docker ps --format '{{.Names}}' 2>/dev/null | grep -qx "$DB_CONTAINER"; then log "PostgreSQL container is running: $DB_CONTAINER" add_section_row \ "PostgreSQL Container" \ "OK" \ "$DB_CONTAINER is running" else log "ERROR: PostgreSQL container $DB_CONTAINER is not running." add_section_row \ "PostgreSQL Container" \ "ERROR" \ "$DB_CONTAINER is not running" fi # ----------------------------------------------------------------------------- # STOP HERE IF PRE-FLIGHT FAILED # ----------------------------------------------------------------------------- if [ "$OVERALL_STATUS" = "FAILED" ]; then log "Pre-flight checks failed. Backup will not proceed." FINAL_ARCHIVE="${BACKUP_ROOT}/${ARCHIVE_NAME}" else # ========================================================================= # QUERY SITE VERSION FROM DATABASE # ========================================================================= log_section "Retrieving site version from database" QUERY_VERSION=$(docker exec "$DB_CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" -t -A -c \ "SELECT version_number FROM project_versions WHERE status = 'deployed' ORDER BY deployed_at DESC NULLS LAST LIMIT 1;" 2>/dev/null || true) QUERY_VERSION="$(echo "${QUERY_VERSION}" | xargs)" if [ -n "$QUERY_VERSION" ]; then SITE_VERSION="$QUERY_VERSION" ARCHIVE_NAME="fundtracker-v${SITE_VERSION}-backup-${TIMESTAMP}.tar.gz" log "Deployed site version detected: v${SITE_VERSION}" add_section_row \ "Project Version Check" \ "OK" \ "Detected deployed version v${SITE_VERSION}" else log "WARNING: Could not retrieve deployed version from 'project_versions' table. Using default naming." add_section_row \ "Project Version Check" \ "WARNING" \ "Version query returned empty; falling back to standard naming" fi FINAL_ARCHIVE="${BACKUP_ROOT}/${ARCHIVE_NAME}" # Create temporary working directory mkdir -p "$TMP_DIR" # ========================================================================= # SECTION 1 — DATABASE BACKUP # ========================================================================= log_section "Backing up FundTracker PostgreSQL database" DB_DIR="${TMP_DIR}/database" mkdir -p "$DB_DIR" DB_DUMP="${DB_DIR}/fundtracker.sql" # pg_dump runs inside the PostgreSQL container. # No database password is stored in this backup script. if docker exec "$DB_CONTAINER" \ pg_dump \ -U "$DB_USER" \ "$DB_NAME" \ > "$DB_DUMP" 2>>"$LOG_FILE"; then log "PostgreSQL dump completed successfully." # Compress the SQL dump if gzip "$DB_DUMP"; then DB_SIZE=$(du -sh "${DB_DUMP}.gz" | cut -f1) log "Database dump compressed." log "Database backup size: $DB_SIZE" add_section_row \ "PostgreSQL Database" \ "OK" \ "${DB_NAME} dumped and compressed · ${DB_SIZE}" else log "ERROR: Could not compress PostgreSQL dump." add_section_row \ "PostgreSQL Database" \ "ERROR" \ "pg_dump succeeded but gzip failed" fi else log "ERROR: PostgreSQL pg_dump failed." rm -f "$DB_DUMP" add_section_row \ "PostgreSQL Database" \ "ERROR" \ "pg_dump failed for database ${DB_NAME}" fi # ========================================================================= # SECTION 2 — SITE / PROJECT FILES # ========================================================================= log_section "Backing up FundTracker project files" SITE_DIR="${TMP_DIR}/fundtracker" mkdir -p "$SITE_DIR" if rsync -a "$PROJECT_DIR/" "$SITE_DIR/" 2>&1 | tee -a "$LOG_FILE"; then FILE_COUNT=$(find "$SITE_DIR" -type f 2>/dev/null | wc -l) log "FundTracker project files copied successfully." log "Files copied: $FILE_COUNT" add_section_row \ "FundTracker Project Files" \ "OK" \ "${FILE_COUNT} file(s) backed up" else log "ERROR: rsync failed while backing up FundTracker files." add_section_row \ "FundTracker Project Files" \ "ERROR" \ "File backup failed" fi # ========================================================================= # SECTION 3 — MANIFEST # ========================================================================= log_section "Creating backup manifest" MANIFEST="${TMP_DIR}/MANIFEST.txt" cat > "$MANIFEST" <>"$LOG_FILE"; then ARCHIVE_SIZE=$(du -sh "$FINAL_ARCHIVE" | cut -f1) log "Archive created successfully." log "Archive: $FINAL_ARCHIVE" log "Size: $ARCHIVE_SIZE" add_section_row \ "Archive Creation" \ "OK" \ "${ARCHIVE_NAME} · ${ARCHIVE_SIZE}" else log "ERROR: tar failed while creating archive." add_section_row \ "Archive Creation" \ "ERROR" \ "tar failed — archive was not created" fi fi # ========================================================================= # SECTION 5 — VERIFY ARCHIVE # ========================================================================= if [ -f "$FINAL_ARCHIVE" ]; then log_section "Verifying backup archive" if tar -tzf "$FINAL_ARCHIVE" >/dev/null 2>&1; then log "Archive verification successful." add_section_row \ "Archive Verification" \ "OK" \ "Archive can be opened and read successfully" else log "ERROR: Archive verification failed." add_section_row \ "Archive Verification" \ "ERROR" \ "tar could not read the completed archive" fi else log "ERROR: Final backup archive does not exist." add_section_row \ "Archive Verification" \ "ERROR" \ "Final archive was not created" fi # ========================================================================= # SECTION 6 — CLEANUP # ========================================================================= log_section "Cleaning up temporary files" if rm -rf "$TMP_DIR"; then log "Temporary backup files removed." add_section_row \ "Temporary File Cleanup" \ "OK" \ "Temporary files removed" else log "WARNING: Could not completely remove temporary directory." add_section_row \ "Temporary File Cleanup" \ "WARNING" \ "Temporary directory could not be completely removed" fi # ========================================================================= # SECTION 7 — ROTATE OLD BACKUPS # ========================================================================= log_section "Rotating old FundTracker backups" BACKUP_LIST=$(ls -1t \ "${BACKUP_ROOT}"/fundtracker-*.tar.gz \ 2>/dev/null || true) BACKUP_COUNT=$(echo "$BACKUP_LIST" | grep -c . || true) DELETED_COUNT=0 if [ "$BACKUP_COUNT" -gt "$RETAIN_COUNT" ]; then DELETE_LIST=$(echo "$BACKUP_LIST" | tail -n +$((RETAIN_COUNT + 1))) while IFS= read -r OLD_BACKUP; do [ -z "$OLD_BACKUP" ] && continue rm -f "$OLD_BACKUP" log "Removed old backup: $(basename "$OLD_BACKUP")" DELETED_COUNT=$((DELETED_COUNT + 1)) done <<< "$DELETE_LIST" add_section_row \ "Backup Rotation" \ "OK" \ "Kept ${RETAIN_COUNT} most recent · removed ${DELETED_COUNT} old backup(s)" else add_section_row \ "Backup Rotation" \ "OK" \ "${BACKUP_COUNT} / ${RETAIN_COUNT} backups retained" fi fi # ============================================================================= # FINAL STATUS # ============================================================================= log_section "FundTracker Backup Complete" log "Site Version: ${SITE_VERSION}" log "Archive: ${FINAL_ARCHIVE:-N/A}" log "Size: ${ARCHIVE_SIZE:-N/A}" log "Status: ${OVERALL_STATUS}" # ============================================================================= # EMAIL STATUS BANNER # ============================================================================= case "$OVERALL_STATUS" in SUCCESS) BADGE_HTML="
✅ SUCCESS
" BANNER_HTML="✅  FundTracker backup completed successfully — no action required." ;; WARNING) BADGE_HTML="
⚠️ WARNING
" BANNER_HTML="⚠️  FundTracker backup completed with warnings — review recommended." ;; FAILED) BADGE_HTML="
🚨 FAILED
" BANNER_HTML="🚨  FundTracker backup failed — immediate attention required!" ;; esac # ============================================================================= # ARCHIVE CARD # ============================================================================= if [ -f "${FINAL_ARCHIVE:-}" ]; then ARCHIVE_CARD="
🗜️ Backup Archive
Filename
${ARCHIVE_NAME}
Size
${ARCHIVE_SIZE}
Retained
${BACKUP_COUNT} / ${RETAIN_COUNT}
" else ARCHIVE_CARD="" fi # ============================================================================= # HTML EMAIL # ============================================================================= HTML_BODY=$(cat <
$BANNER_HTML $ARCHIVE_CARD
FundTracker Backup Monitor
💾 $HOSTNAME_LABEL
/usr/scripts/fundtracker/fundtracker-backup.sh
$REPORT_TIME  |  $WARNING_COUNT warning(s) · $ERROR_COUNT error(s)
$BADGE_HTML
📋 Backup Section Results
$SECTION_ROWS_HTML
Section Status Detail
📊 Backup Summary
Host $HOSTNAME_LABEL
Run Time $REPORT_TIME
Site Version v$SITE_VERSION
Project Files $PROJECT_DIR
Database PostgreSQL / $DB_NAME
Destination $BACKUP_ROOT
Archive $ARCHIVE_NAME
Archive Size $ARCHIVE_SIZE
Backups on Disk $BACKUP_COUNT of $RETAIN_COUNT max retained
Warnings $WARNING_COUNT
Errors $ERROR_COUNT
Overall Status $OVERALL_STATUS
Automated Backup Monitor  |  $HOSTNAME_LABEL  |  KingDezigns FundTracker
EOF ) # ============================================================================= # SEND EMAIL # ============================================================================= SUBJECT="[FundTracker Backup] $HOSTNAME_LABEL — $OVERALL_STATUS | $REPORT_TIME" { echo "To: $REPORT_TO" echo "From: $REPORT_FROM" echo "Subject: $SUBJECT" echo "MIME-Version: 1.0" echo "Content-Type: text/html; charset=UTF-8" echo "" echo "$HTML_BODY" } | sendmail -t log "Email sent to $REPORT_TO — Overall status: $OVERALL_STATUS" log "===== FundTracker backup script finished =====" # ============================================================================= # EXIT STATUS # ============================================================================= if [ "$OVERALL_STATUS" = "FAILED" ]; then exit 1 else exit 0 fi