captaincore/app/backup/generate
2026-03-22 16:47:37 -04:00

314 lines
No EOL
13 KiB
Bash
Executable file

#!/usr/bin/env bash
#
# Backups one or more sites.
#
# `captaincore backup generate <site>`
#
# [--skip-remote]
# Skips saving to Restic backup repo
#
# [--skip-db]
# Skips database backup
#
# [--skip-if-recent=<timeframe>]
# Skip if backup generated or last checked within the specified timeframe (e.g., "24h", "7d").
#
if [ ${#@} -ne 1 ]; then
echo -e "${COLOR_RED}Error:${COLOR_NORMAL} Requires a <site>"
exit
fi
while read config; do
if [[ "$config" == "Error:"* ]]; then
continue
fi
declare "$config"
done <<< "$(captaincore config fetch --captain-id=$CAPTAIN_ID)"
site=$1
root_path="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"; root_path=${root_path%app*}
run_command() {
if [[ $SKIP_REMOTE == true ]]; then
echo "Skipping remote"
fi
# Extract environment
if [[ "$site" == *"-staging"* ]]; then
environment=staging
fi
if [[ "$site" == *"-production"* ]]; then
environment=production
fi
if [[ "$site" != *"-"* ]]; then
environment=production
fi
# Load site configs
IFS=$'\n'$'\r'; for line in $(captaincore site get $site --bash --captain-id=$CAPTAIN_ID); do declare "$line"; done; unset IFS
# Site found, start the backup
if [[ $domain == "" ]]; then
echo "Error: $site missing domain. Skipping backup."
exit
fi
# --------------------------------------------------
# Locking Mechanism
# Prevent concurrent backups on the same environment
# --------------------------------------------------
# Ensure the directory exists
mkdir -p "$path/${site}_${site_id}/${environment}"
lock_file="$path/${site}_${site_id}/${environment}/backup.lock"
# Check if lock file exists
if [ -f "$lock_file" ]; then
local locked_pid
locked_pid=$(cat "$lock_file")
# Check if the process ID in the lock file is actually running
if [ -n "$locked_pid" ] && ps -p "$locked_pid" > /dev/null 2>&1; then
echo "Skipping $site ($environment) - Backup currently in process (PID: $locked_pid)"
exit 0
else
# Process is dead, lock is stale. Clean up silently and proceed.
rm -f "$lock_file"
fi
fi
# Create the lock file with the current Process ID
echo $$ > "$lock_file"
# Ensure lock file is removed on script exit (Success, Error, or Interrupt)
cleanup_lock() {
rm -f "$lock_file"
}
trap cleanup_lock EXIT
trap 'exit 130' INT TERM
# --------------------------------------------------
# Auto-Resume / Skip Logic
# Check if we processed this recently based on --skip-if-recent flag
# --------------------------------------------------
if [[ -n "$SKIP_IF_RECENT" ]]; then
backup_list_file="$path/${site}_${site_id}/${environment}/backups/list.json"
should_skip=$(php ${CAPTAINCORE_PATH}/lib/local-scripts/check-last-run.php "$backup_list_file" "$SKIP_IF_RECENT")
if [[ "$should_skip" == "true" ]]; then
echo "Skipping $site ($environment) - Backup exists/checked within last $SKIP_IF_RECENT."
exit 0
fi
fi
# --------------------------------------------------
# Append trailing slash if home_directory exist
if [ "$home_directory" != "" ]; then
home_directory="${home_directory}/"
fi
# Define config file
rclone_config_file="$path/${site}_${site_id}/rclone.conf"
vault_file="$path/${site}_${site_id}/$environment/vault.txt"
if [[ ! -f "$rclone_config_file" || ! -f "$vault_file" ]]; then
captaincore site key-generate $site --captain-id=$CAPTAIN_ID
fi
# Detect broken vault.txt (missing B2 bucket on line 1)
if [[ -f "$vault_file" ]] && [[ -z "$(sed -n '1p' "$vault_file")" ]]; then
echo "$(date +'%Y-%m-%d %H:%M') Broken vault.txt for $site ($environment). Regenerating..."
rm -f "$vault_file"
captaincore site key-generate $site --captain-id=$CAPTAIN_ID
fi
# Lookup rclone
remote_check=$( rclone config show $environment --config="$rclone_config_file" )
remote_backup_check=$( rclone config show backup --config="$rclone_config_file" )
if [[ $remote_check == *"Couldn't find type of fs"* || $remote_backup_check == *"Couldn't find type of fs"* ]]; then
echo "$(date +'%Y-%m-%d %H:%M') Generating rclone configs for $site"
captaincore site key-generate $site --captain-id=$CAPTAIN_ID
fi
# Captures FTP errors in $ftp_output and file listing to log file
echo "$(date +'%Y-%m-%d %H:%M') Connecting to $site ($environment)..."
ftp_output=$( { timeout 30 rclone lsd ${environment}:$home_directory --config="$rclone_config_file" --contimeout=15s --timeout=30s ; } 2>&1 )
# If connection failed, regenerate rclone config and retry once
if [[ $? -ne 0 ]]; then
echo "$(date +'%Y-%m-%d %H:%M') Connection failed for $site ($environment). Regenerating configs..."
captaincore site key-generate $site --captain-id=$CAPTAIN_ID
ftp_output=$( { timeout 30 rclone lsd ${environment}:$home_directory --config="$rclone_config_file" --contimeout=15s --timeout=30s ; } 2>&1 )
# If connection still fails after rclone config regeneration, try refreshing SSH credentials from provider
if [[ $? -ne 0 ]]; then
echo "$(date +'%Y-%m-%d %H:%M') Connection still failing. Attempting SSH credential refresh from provider..."
if captaincore site ssh-refresh ${site}-${environment} --captain-id=$CAPTAIN_ID; then
echo "$(date +'%Y-%m-%d %H:%M') Credentials updated. Retrying connection..."
# Re-read site configs since credentials changed
IFS=$'\n'$'\r'; for line in $(captaincore site get $site --bash --captain-id=$CAPTAIN_ID); do declare "$line"; done; unset IFS
ftp_output=$( { timeout 30 rclone lsd ${environment}:$home_directory --config="$rclone_config_file" --contimeout=15s --timeout=30s ; } 2>&1 )
fi
fi
fi
ftp_search_for_wordpress=$( echo "$ftp_output" | perl -wnE'say for /wp-admin/g' )
# Handle FTP errors
if [[ $ftp_search_for_wordpress != "wp-admin"* ]]; then
wordpress_not_found=true
fi
if [[ "$backup_mode" == "local" ]]; then
# Incremental backup locally with rclone
echo "$(date +'%Y-%m-%d %H:%M') Begin incremental backup ${site}-${environment} to local"
if [[ $SKIP_DB != true ]] && [[ $wordpress_not_found != true ]]; then
# Database backup
captaincore ssh ${site}-${environment} --script="db-backup" --captain-id=$CAPTAIN_ID
if [[ "$provider" == "wpengine" ]]; then
rclone sync ${environment}:_wpeprivate/database-backup.sql $path/${site}_${site_id}/${environment}/backup/ --config="$rclone_config_file"
fi
if [[ "$provider" == "kinsta" ]]; then
rclone sync ${environment}:private/database-backup.sql $path/${site}_${site_id}/${environment}/backup/ --config="$rclone_config_file"
fi
if [[ "$provider" == "rocketdotnet" ]]; then
rclone sync ${environment}:tmp/database-backup.sql $path/${site}_${site_id}/${environment}/backup/ --config="$rclone_config_file"
fi
fi
mkdir -p $path/${site}_${site_id}/${environment}/backup/
# Backup site locally
if [[ "$wp_content" != "wp-content" ]]; then
echo "$(date +'%Y-%m-%d %H:%M') Backing up ${site}-${environment} alternative wp-content location ($wp_content)"
rclone sync ${environment}:$home_directory $path/${site}_${site_id}/${environment}/backup/ --exclude-from="${CAPTAINCORE_PATH}/lib/excludes" --filter="+ $wp_content/**" --filter="- wp-content/**" --filter="- content/**" --config="$rclone_config_file"
else
rclone sync ${environment}:$home_directory $path/${site}_${site_id}/${environment}/backup/ --exclude-from="${CAPTAINCORE_PATH}/lib/excludes" --filter="- content/**" --config="$rclone_config_file"
fi
# Incremental backup upload to Restic
if [[ $SKIP_REMOTE != true ]]; then
echo "$(date +'%Y-%m-%d %H:%M') Storing $site to backup archive"
if [[ $( restic snapshots --repo rclone:$rclone_backup/${site}_${site_id}/${environment}/restic-repo --password-file="${CAPTAINCORE_PATH}/data/restic.key" ) == "" ]]; then
echo "Generating restic repo for $site"
restic init --quiet --repo rclone:$rclone_backup/${site}_${site_id}/${environment}/restic-repo --password-file="${CAPTAINCORE_PATH}/data/restic.key"
# Back up the repo key locally
captaincore backup key-backup ${site}-${environment} --captain-id=$CAPTAIN_ID 2>/dev/null || true
fi
cd $path/${site}_${site_id}/${environment}/backup/
restic backup . --quiet --repo rclone:$rclone_backup/${site}_${site_id}/${environment}/restic-repo --exclude-file="${CAPTAINCORE_PATH}/lib/restic-excludes" --password-file="${CAPTAINCORE_PATH}/data/restic.key"
captaincore backup list-generate ${site}-${environment} --captain-id=$CAPTAIN_ID > /dev/null
captaincore backup get-generate ${site}-${environment} --captain-id=$CAPTAIN_ID > /dev/null
captaincore backup verify ${site}-${environment} --captain-id=$CAPTAIN_ID
fi
captaincore usage-update ${site}-${environment} --captain-id=$CAPTAIN_ID
# --------------------------------------------------
# Touch list.json to update its modification timestamp
# This marks the time this site was last processed.
# --------------------------------------------------
backup_list_file="$path/${site}_${site_id}/${environment}/backups/list.json"
if [ -f "$backup_list_file" ]; then
touch "$backup_list_file"
fi
# --------------------------------------------------
exit
fi
# --- 1. Generate a unique, random name for the temporary payload file ---
do_script_path="${root_path}lib/remote-scripts/vault"
random_token=$(head /dev/urandom | LC_ALL=C tr -dc 'a-zA-Z0-9' | head -c 8)
payload_file="$path_tmp/do_vault_payload-${CAPTAIN_ID}-${site}-${environment}_${random_token}.sh"
echo "📝 Creating temporary payload file at: $payload_file"
# --- 2. Read secrets and write 'export' commands to the payload file ---
# This block reads each line from your secrets file and builds the export commands.
{
echo "export B2_BUCKET='$(sed -n 1p "$vault_file")'"
echo "export B2_PATH='$(sed -n 2p "$vault_file")'"
echo "export B2_ACCOUNT_ID='$(sed -n 3p "$vault_file")'"
echo "export B2_ACCOUNT_KEY='$(sed -n 4p "$vault_file")'"
echo "export RESTIC_PASSWORD='$(sed -n 5p "$vault_file")'"
echo "export EMAIL_NOTIFY=$captaincore_admin_email"
} > "$payload_file"
# --- 3. Append the main _do script to the payload file ---
tail -n +2 "$do_script_path" >> "$payload_file"
# Error handling in case the secrets file is not found
if [ $? -ne 0 ]; then
echo "❌ Error: Failed to create export commands. Check vault_file path: '$vault_file'" >&2
rm -f "$payload_file"
exit 1
fi
# Test SSH connectivity before vault create
ssh_test=$( captaincore ssh ${site}-${environment} --command="echo ok" --captain-id=$CAPTAIN_ID 2>&1 )
if [[ "$ssh_test" != *"ok"* ]]; then
echo "$(date +'%Y-%m-%d %H:%M') SSH test failed for $site. Attempting credential refresh..."
if captaincore site ssh-refresh ${site}-${environment} --captain-id=$CAPTAIN_ID; then
# Re-read site configs since credentials changed
IFS=$'\n'$'\r'; for line in $(captaincore site get $site --bash --captain-id=$CAPTAIN_ID); do declare "$line"; done; unset IFS
fi
fi
ssh_string=$( captaincore ssh ${site}-${environment} --debug --command="bash -s -- vault create" --captain-id=$CAPTAIN_ID )
# --- Execute the command and capture output for piggybacked snapshot data ---
echo "🚀 Executing remote vault command..."
vault_output_file="$path_tmp/vault_output-${CAPTAIN_ID}-${site}-${environment}_${random_token}.log"
cat $payload_file | eval $ssh_string 2>&1 | tee "$vault_output_file"
# --- 5. Clean up the local temporary file ---
echo "🔥 Cleaning up temporary payload file..."
rm "$payload_file"
# Extract piggybacked snapshot JSON from vault create output
snapshots_json=$(sed -n '/---CAPTAINCORE_SNAPSHOTS_BEGIN---/,/---CAPTAINCORE_SNAPSHOTS_END---/{//d;p}' "$vault_output_file")
rm -f "$vault_output_file"
if [[ -n "$snapshots_json" ]] && [[ "$snapshots_json" =~ ^\[ ]]; then
# Save to temp file for Go commands
snapshots_file="$path_tmp/snapshots-${CAPTAIN_ID}-${site}-${environment}_${random_token}.json"
echo "$snapshots_json" > "$snapshots_file"
# Use piggybacked data instead of running restic locally (avoids cache buildup)
captaincore backup list-generate ${site}-${environment} --from-file="$snapshots_file" --captain-id=$CAPTAIN_ID > /dev/null
captaincore backup verify ${site}-${environment} --from-file="$snapshots_file" --captain-id=$CAPTAIN_ID
rm -f "$snapshots_file"
else
# Fallback to traditional method if piggybacked data not found
captaincore backup list-generate ${site}-${environment} --captain-id=$CAPTAIN_ID > /dev/null
captaincore backup verify ${site}-${environment} --captain-id=$CAPTAIN_ID
fi
captaincore backup get-generate ${site}-${environment} --captain-id=$CAPTAIN_ID > /dev/null
captaincore usage-update ${site}-${environment} --captain-id=$CAPTAIN_ID
# Explicitly touch the list file for direct mode
backup_list_file="$path/${site}_${site_id}/${environment}/backups/list.json"
if [ -f "$backup_list_file" ]; then
touch "$backup_list_file"
fi
}
run_command