#!/bin/bash set -euo pipefail ####################################### # Date format for logging. ####################################### LOG_DATE_FORMAT="%Y-%m-%dT%T" ####################################### # Log a message with a timestamp. # Arguments: # Message to log. ####################################### log() { echo "$(date +"$LOG_DATE_FORMAT") - $*" } ####################################### # Determine operation mode from the environment only. # Valid values: "backup" or "restore". # Default to "backup" if not provided. ####################################### OPERATION_MODE="${OPERATION_MODE:-backup}" ####################################### # Determine backup mode from the environment only. # Valid values: "directory", "postgres", or "s3". # Default to "directory" if not provided. ####################################### BACKUP_MODE="${BACKUP_MODE:-directory}" ####################################### # Check for required external commands. ####################################### REQUIRED_CMDS=(restic curl jq) if [ "$BACKUP_MODE" = "postgres" ]; then if [ "$OPERATION_MODE" = "backup" ]; then REQUIRED_CMDS+=(pg_dump) elif [ "$OPERATION_MODE" = "restore" ]; then REQUIRED_CMDS+=(psql) fi elif [ "$BACKUP_MODE" = "s3" ]; then REQUIRED_CMDS+=(mc) fi for cmd in "${REQUIRED_CMDS[@]}"; do if ! command -v "$cmd" &>/dev/null; then log "Error: Required command '$cmd' is not installed." exit 1 fi done ####################################### # Validate common required environment variables. ####################################### # Gotify notification settings (optional). # Set ENABLE_GOTIFY to "true" to enable notifications, any other value or unset disables them. ENABLE_GOTIFY="${ENABLE_GOTIFY:-true}" if [ "$ENABLE_GOTIFY" = "true" ]; then : "${GOTIFYHOST:?Environment variable GOTIFYHOST is not set (required when ENABLE_GOTIFY=true)}" : "${GOTIFYTOKEN:?Environment variable GOTIFYTOKEN is not set (required when ENABLE_GOTIFY=true)}" : "${GOTIFYTOPIC:?Environment variable GOTIFYTOPIC is not set (required when ENABLE_GOTIFY=true)}" else log "Gotify notifications disabled. Backup status will be logged to console only." fi # Restic encryption password. : "${RESTIC_PASSWORD:?Environment variable RESTIC_PASSWORD is not set}" # Use the repository URI directly from the environment. # Example: export RESTIC_REPOSITORY="rest:http://your-rest-server:8000/backup" : "${RESTIC_REPOSITORY:?Environment variable RESTIC_REPOSITORY is not set}" ####################################### # Validate operation mode. ####################################### case "$OPERATION_MODE" in backup|restore) ;; *) echo "Error: Unknown operation mode '$OPERATION_MODE'. Valid modes are 'backup' and 'restore'." >&2 exit 1 ;; esac ####################################### # Validate mode-specific environment variables. ####################################### case "$BACKUP_MODE" in directory) if [ "$OPERATION_MODE" = "backup" ]; then : "${SOURCEDIR:?Environment variable SOURCEDIR is not set (required for directory backup mode)}" elif [ "$OPERATION_MODE" = "restore" ]; then : "${RESTOREDIR:?Environment variable RESTOREDIR is not set (required for directory restore mode)}" fi ;; postgres) : "${PGHOST:?Environment variable PGHOST is not set (required for PostgreSQL mode)}" : "${PGDATABASE:?Environment variable PGDATABASE is not set (required for PostgreSQL mode)}" : "${PGUSER:?Environment variable PGUSER is not set (required for PostgreSQL mode)}" # Optional: default PGPORT to 5432. : "${PGPORT:=5432}" if [ -z "${PGPASSWORD:-}" ]; then if [ "$OPERATION_MODE" = "backup" ]; then echo "Warning: Environment variable PGPASSWORD is not set. pg_dump may fail if authentication is required." elif [ "$OPERATION_MODE" = "restore" ]; then echo "Warning: Environment variable PGPASSWORD is not set. psql may fail if authentication is required." fi fi ;; s3) : "${S3_BUCKET:?Environment variable S3_BUCKET is not set (required for S3 mode)}" : "${S3_ENDPOINT:?Environment variable S3_ENDPOINT is not set (required for S3 mode)}" : "${MINIO_ACCESS_KEY:?Environment variable MINIO_ACCESS_KEY is not set (required for S3 mode)}" : "${MINIO_SECRET_KEY:?Environment variable MINIO_SECRET_KEY is not set (required for S3 mode)}" # Optional: S3 path prefix : "${S3_PREFIX:=}" ;; *) echo "Error: Unknown backup mode '$BACKUP_MODE'. Valid modes are 'directory', 'postgres', and 's3'." >&2 exit 1 ;; esac ####################################### # Build the Gotify URL (only if Gotify is enabled). ####################################### if [ "$ENABLE_GOTIFY" = "true" ]; then GOTIFYURL="${GOTIFYHOST}/message?token=${GOTIFYTOKEN}" fi ####################################### # Send a notification via Gotify. # Arguments: # message: The message to send. ####################################### send_notification() { local message="$1" # Only send notification if Gotify is enabled if [ "$ENABLE_GOTIFY" != "true" ]; then log "$message" return 0 fi if ! curl -s -X POST "$GOTIFYURL" -F "title=${GOTIFYTOPIC}" -F "message=${message}" >/dev/null; then log "Warning: Failed to send notification with message: ${message}" fi } ####################################### # Run the backup using restic. # The --no-cache flag disables local caching. # Arguments: # $1 - The source directory to back up. ####################################### run_restic_backup() { local source_dir="$1" cd "${source_dir}" log "Starting backup of '${source_dir}' to repository ${RESTIC_REPOSITORY}" # Capture both stdout and stderr in a variable backup_output=$(restic -r "${RESTIC_REPOSITORY}" backup --no-cache --json --verbose . 2>&1) # Optionally, also print the output to the console: echo "$backup_output" # Parse the JSON lines output for the summary message summary=$(echo "$backup_output" | jq -r 'select(.message_type=="summary") | "Snapshot " + (.snapshot_id // "none") + ": " + "files new: " + (.files_new|tostring) + ", files changed: " + (.files_changed|tostring) + ", data added: " + (.data_added|tostring) + " bytes in " + (.total_duration|tostring) + " sec"') # Check exit code of restic backup (assuming restic exits non-zero on error) if [ $? -eq 0 ]; then msg="Backup successful. $summary" log "$msg" send_notification "$msg" else exit_code=$? msg="Backup failed with error code ${exit_code}. $backup_output" log "$msg" send_notification "$msg" exit "$exit_code" fi } ####################################### # Backup a directory (regular mode). ####################################### backup_directory() { run_restic_backup "${SOURCEDIR}" } ####################################### # Backup a PostgreSQL database. # Dumps the database to a temporary directory and then backs it up. ####################################### backup_postgres() { log "Starting PostgreSQL backup for database '${PGDATABASE}' on host '${PGHOST}'" # Create a temporary directory for the database dump. TEMP_BACKUP_DIR=$(mktemp -d) log "Created temporary directory: ${TEMP_BACKUP_DIR}" local dump_file="${TEMP_BACKUP_DIR}/dump.sql" log "Dumping PostgreSQL database to ${dump_file}..." if pg_dump -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" ${PG_DUMP_ARGS:-} "${PGDATABASE}" > "${dump_file}"; then log "Database dump created successfully." else local exit_code=$? local msg="PostgreSQL dump failed with error code ${exit_code}" log "$msg" send_notification "$msg" exit "$exit_code" fi # Back up the directory containing the dump. run_restic_backup "${TEMP_BACKUP_DIR}" } ####################################### # Run the restore using restic. # Arguments: # $1 - The target directory to restore to. # $2 - Optional snapshot ID to restore (defaults to latest). ####################################### run_restic_restore() { local target_dir="$1" local snapshot_id="$2" log "Starting restore from repository ${RESTIC_REPOSITORY} to '${target_dir}'" log "Using snapshot: ${snapshot_id}" # Create target directory if it doesn't exist mkdir -p "${target_dir}" # Capture both stdout and stderr in a variable restore_output=$(restic -r "${RESTIC_REPOSITORY}" restore "${snapshot_id}" --target "${target_dir}" --no-cache --json --verbose 2>&1) # Optionally, also print the output to the console: echo "$restore_output" # Parse the JSON lines output for the summary message summary=$(echo "$restore_output" | jq -r 'select(.message_type=="summary") | "Restore completed: " + (.files_restored|tostring) + " files restored, " + (.bytes_restored|tostring) + " bytes in " + (.total_duration|tostring) + " sec"' 2>/dev/null || echo "Restore completed") # Check exit code of restic restore if [ $? -eq 0 ]; then msg="Restore successful. $summary" log "$msg" send_notification "$msg" else exit_code=$? msg="Restore failed with error code ${exit_code}. $restore_output" log "$msg" send_notification "$msg" exit "$exit_code" fi } ####################################### # Restore a directory (regular mode). ####################################### restore_directory() { local snapshot_id="${RESTORE_SNAPSHOT_ID:-latest}" run_restic_restore "${RESTOREDIR}" "${snapshot_id}" } ####################################### # Restore a PostgreSQL database. # Restores the database dump from the backup and applies it to the database. ####################################### restore_postgres() { local snapshot_id="${RESTORE_SNAPSHOT_ID:-latest}" log "Starting PostgreSQL restore for database '${PGDATABASE}' on host '${PGHOST}'" # Create a temporary directory for the restore. TEMP_RESTORE_DIR=$(mktemp -d) log "Created temporary directory: ${TEMP_RESTORE_DIR}" # Restore the backup to the temporary directory run_restic_restore "${TEMP_RESTORE_DIR}" "${snapshot_id}" local dump_file="${TEMP_RESTORE_DIR}/dump.sql" if [ ! -f "${dump_file}" ]; then local msg="PostgreSQL restore failed. Database dump file not found at ${dump_file}" log "$msg" send_notification "$msg" exit 1 fi log "Restoring PostgreSQL database from ${dump_file}..." if psql -h "${PGHOST}" -p "${PGPORT}" -U "${PGUSER}" -d "${PGDATABASE}" ${PSQL_ARGS:-} < "${dump_file}"; then local msg="PostgreSQL database restored successfully" log "$msg" send_notification "$msg" else local exit_code=$? local msg="PostgreSQL restore failed with error code ${exit_code}" log "$msg" send_notification "$msg" exit "$exit_code" fi } ####################################### # Backup an S3 bucket. # Syncs the S3 bucket to a temporary directory and then backs it up. ####################################### backup_s3() { log "Starting S3 backup for bucket '${S3_BUCKET}' at endpoint '${S3_ENDPOINT}'" # Create a temporary directory for the S3 sync. TEMP_BACKUP_DIR=$(mktemp -d) log "Created temporary directory: ${TEMP_BACKUP_DIR}" # Configure MinIO Client alias local alias_name="backupsidecar" if ! mc alias set "${alias_name}" "${S3_ENDPOINT}" "${MINIO_ACCESS_KEY}" "${MINIO_SECRET_KEY}"; then local msg="Failed to configure MinIO client alias" log "$msg" send_notification "$msg" exit 1 fi # Build S3 path local s3_path="${alias_name}/${S3_BUCKET}" if [ -n "${S3_PREFIX}" ]; then s3_path="${s3_path}/${S3_PREFIX}" fi log "Syncing S3 bucket from ${s3_path} to ${TEMP_BACKUP_DIR}..." if mc mirror "${s3_path}" "${TEMP_BACKUP_DIR}" --remove; then log "S3 sync completed successfully." else local exit_code=$? local msg="S3 sync failed with error code ${exit_code}" log "$msg" send_notification "$msg" exit "$exit_code" fi # Back up the directory containing the S3 content. run_restic_backup "${TEMP_BACKUP_DIR}" } ####################################### # Restore an S3 bucket. # Restores the S3 content from the backup and syncs it back to S3. ####################################### restore_s3() { local snapshot_id="${RESTORE_SNAPSHOT_ID:-latest}" log "Starting S3 restore for bucket '${S3_BUCKET}' at endpoint '${S3_ENDPOINT}'" # Create a temporary directory for the restore. TEMP_RESTORE_DIR=$(mktemp -d) log "Created temporary directory: ${TEMP_RESTORE_DIR}" # Restore the backup to the temporary directory run_restic_restore "${TEMP_RESTORE_DIR}" "${snapshot_id}" # Configure MinIO Client alias local alias_name="backupsidecar" if ! mc alias set "${alias_name}" "${S3_ENDPOINT}" "${MINIO_ACCESS_KEY}" "${MINIO_SECRET_KEY}"; then local msg="Failed to configure MinIO client alias" log "$msg" send_notification "$msg" exit 1 fi # Build S3 path local s3_path="${alias_name}/${S3_BUCKET}" if [ -n "${S3_PREFIX}" ]; then s3_path="${s3_path}/${S3_PREFIX}" fi log "Syncing restored content from ${TEMP_RESTORE_DIR} to ${s3_path}..." if mc mirror "${TEMP_RESTORE_DIR}" "${s3_path}" --remove; then local msg="S3 restore completed successfully" log "$msg" send_notification "$msg" else local exit_code=$? local msg="S3 restore failed with error code ${exit_code}" log "$msg" send_notification "$msg" exit "$exit_code" fi } ####################################### # Cleanup temporary resources. ####################################### cleanup() { if [ -n "${TEMP_BACKUP_DIR:-}" ] && [ -d "${TEMP_BACKUP_DIR}" ]; then rm -rf "${TEMP_BACKUP_DIR}" log "Removed temporary directory ${TEMP_BACKUP_DIR}" fi if [ -n "${TEMP_RESTORE_DIR:-}" ] && [ -d "${TEMP_RESTORE_DIR}" ]; then rm -rf "${TEMP_RESTORE_DIR}" log "Removed temporary directory ${TEMP_RESTORE_DIR}" fi } trap cleanup EXIT ####################################### # Main routine. ####################################### main() { case "$OPERATION_MODE" in backup) case "$BACKUP_MODE" in directory) backup_directory ;; postgres) backup_postgres ;; s3) backup_s3 ;; esac ;; restore) case "$BACKUP_MODE" in directory) restore_directory ;; postgres) restore_postgres ;; s3) restore_s3 ;; esac ;; esac } # Trap termination signals to log and exit cleanly. trap 'log "Script interrupted. Exiting."; exit 1' SIGINT SIGTERM main