#!/usr/bin/env bash set -Eeuo pipefail usage() { cat <<'EOF' Usage: backup-upload-s3.sh Environment: DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment. S3_BACKUP_ENDPOINT_URL S3 endpoint URL. S3_BACKUP_ACCESS_KEY_ID S3 access key. S3_BACKUP_SECRET_ACCESS_KEY S3 secret key. S3_BACKUP_BUCKET Bucket name. S3_BACKUP_PREFIX Object prefix. Defaults to qlockify. BACKUP_ENCRYPTION_PASSPHRASE Encryption passphrase. BACKUP_LOCAL_KEEP_LATEST Number of latest local plaintext backups to keep. Defaults to 3. BACKUP_REMOTE_KEEP_LATEST Number of latest remote encrypted backups to keep. Defaults to 7. EOF } log() { printf '[backup-rclone] %s\n' "$*" } fail() { printf '[backup-rclone] %s\n' "$*" >&2 exit 1 } require_var() { local name="$1" [[ -n "${!name:-}" ]] || fail "$name is required" } load_env() { local env_path="$1" [[ -f "$env_path" ]] || fail "Deployment env file not found: $env_path" set -a # shellcheck disable=SC1090 . "$env_path" set +a } normalize_prefix() { local value="${1:-qlockify}" value="${value#/}" value="${value%/}" printf '%s' "$value" } positive_integer_or_default() { local value="${1:-}" local fallback="$2" if [[ "$value" =~ ^[0-9]+$ ]]; then printf '%s' "$value" else printf '%s' "$fallback" fi } rclone_remote_path() { printf 'parspack:%s/%s' "$S3_BACKUP_BUCKET" "$S3_BACKUP_PREFIX" } cleanup_local_backups() { local keep_count="$1" local latest_dir="$2" [[ "$keep_count" -gt 0 ]] || { rm -rf "$latest_dir" return } mkdir -p "$latest_dir" find "$latest_dir" -maxdepth 1 -type f -name '*.tar.gz.enc' -delete cp "$ARCHIVE_PATH" "$latest_dir/$ARCHIVE_NAME" find "$latest_dir" -maxdepth 1 -type f -name '*.tar.gz' -printf '%T@ %p\n' \ | sort -nr \ | awk -v keep="$keep_count" 'NR > keep {print $2}' \ | xargs -r rm -f log "Kept latest $keep_count plaintext backup(s) locally in: $latest_dir" } cleanup_remote_backups() { local keep_count="$1" local remote_path="$2" local stale_files [[ "$keep_count" -gt 0 ]] || { log "Skipping remote cleanup because BACKUP_REMOTE_KEEP_LATEST is $keep_count" return } stale_files="$( rclone lsf "$remote_path" \ --config "$RCLONE_CONFIG" \ --s3-no-check-bucket \ --files-only \ | grep '\.tar\.gz\.enc$' \ | sort -r \ | awk -v keep="$keep_count" 'NR > keep' \ || true )" if [[ -z "$stale_files" ]]; then log "No remote backups need cleanup" return fi while IFS= read -r stale_file; do [[ -n "$stale_file" ]] || continue log "Deleting old remote backup: $remote_path/$stale_file" rclone deletefile "$remote_path/$stale_file" \ --config "$RCLONE_CONFIG" \ --s3-no-check-bucket done <<< "$stale_files" } if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then usage exit 0 fi command -v rclone >/dev/null 2>&1 || fail "rclone is required" command -v openssl >/dev/null 2>&1 || fail "openssl is required" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" DEPLOY_ROOT="${DEPLOY_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" DEPLOY_ENV="$DEPLOY_ROOT/.env" BACKUP_SCRIPT="$SCRIPT_DIR/backup.sh" [[ -x "$BACKUP_SCRIPT" ]] || fail "Backup script is not executable: $BACKUP_SCRIPT" load_env "$DEPLOY_ENV" require_var S3_BACKUP_ENDPOINT_URL require_var S3_BACKUP_ACCESS_KEY_ID require_var S3_BACKUP_SECRET_ACCESS_KEY require_var S3_BACKUP_BUCKET require_var BACKUP_ENCRYPTION_PASSPHRASE S3_BACKUP_PREFIX="$(normalize_prefix "${S3_BACKUP_PREFIX:-qlockify}")" BACKUP_LOCAL_KEEP_LATEST="$(positive_integer_or_default "${BACKUP_LOCAL_KEEP_LATEST:-3}" 3)" BACKUP_REMOTE_KEEP_LATEST="$(positive_integer_or_default "${BACKUP_REMOTE_KEEP_LATEST:-7}" 7)" WORK_DIR="$(mktemp -d)" trap 'rm -rf "$WORK_DIR"' EXIT PLAIN_DIR="$WORK_DIR/plain" mkdir -p "$PLAIN_DIR" log "Creating local backup archive" DEPLOY_ROOT="$DEPLOY_ROOT" "$BACKUP_SCRIPT" "$PLAIN_DIR" shopt -s nullglob archives=("$PLAIN_DIR"/*.tar.gz) shopt -u nullglob [[ "${#archives[@]}" -eq 1 ]] || fail "Expected exactly one backup archive, found ${#archives[@]}" ARCHIVE_PATH="${archives[0]}" ARCHIVE_NAME="$(basename "$ARCHIVE_PATH")" ENCRYPTED_NAME="$ARCHIVE_NAME.enc" ENCRYPTED_PATH="$WORK_DIR/$ENCRYPTED_NAME" log "Encrypting backup archive" openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \ -in "$ARCHIVE_PATH" \ -out "$ENCRYPTED_PATH" \ -pass env:BACKUP_ENCRYPTION_PASSPHRASE RCLONE_CONFIG="$WORK_DIR/rclone.conf" cat > "$RCLONE_CONFIG" <