fix(scripts): migrate to rclone from aws-cli and add cleanup actions
Some checks failed
Deployment CI/CD / validate (push) Has been cancelled
Deployment CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-06-06 14:24:17 +03:30
parent 09952319e8
commit caf482e9f4
4 changed files with 177 additions and 79 deletions

View File

@@ -4,9 +4,9 @@ POSTGRES_PASSWORD=postgres
S3_BACKUP_BUCKET= S3_BACKUP_BUCKET=
S3_BACKUP_PREFIX=qlockify S3_BACKUP_PREFIX=qlockify
S3_BACKUP_REGION=us-east-1
S3_BACKUP_ENDPOINT_URL= S3_BACKUP_ENDPOINT_URL=
S3_BACKUP_ACCESS_KEY_ID= S3_BACKUP_ACCESS_KEY_ID=
S3_BACKUP_SECRET_ACCESS_KEY= S3_BACKUP_SECRET_ACCESS_KEY=
BACKUP_ENCRYPTION_PASSPHRASE= BACKUP_ENCRYPTION_PASSPHRASE=
BACKUP_LOCAL_KEEP_LATEST=1 BACKUP_LOCAL_KEEP_LATEST=3
BACKUP_REMOTE_KEEP_LATEST=7

View File

@@ -267,7 +267,7 @@ Install backup upload prerequisites:
```bash ```bash
sudo apt update sudo apt update
sudo apt install -y awscli openssl sudo apt install -y rclone openssl
``` ```
Configure encrypted S3-compatible backup uploads in `./.env`: Configure encrypted S3-compatible backup uploads in `./.env`:
@@ -275,21 +275,23 @@ Configure encrypted S3-compatible backup uploads in `./.env`:
```bash ```bash
S3_BACKUP_BUCKET=qlockify-backups S3_BACKUP_BUCKET=qlockify-backups
S3_BACKUP_PREFIX=qlockify S3_BACKUP_PREFIX=qlockify
S3_BACKUP_REGION=us-east-1
S3_BACKUP_ENDPOINT_URL=https://c284984.parspack.net S3_BACKUP_ENDPOINT_URL=https://c284984.parspack.net
S3_BACKUP_ACCESS_KEY_ID= S3_BACKUP_ACCESS_KEY_ID=
S3_BACKUP_SECRET_ACCESS_KEY= S3_BACKUP_SECRET_ACCESS_KEY=
BACKUP_ENCRYPTION_PASSPHRASE= BACKUP_ENCRYPTION_PASSPHRASE=
BACKUP_LOCAL_KEEP_LATEST=1 BACKUP_LOCAL_KEEP_LATEST=3
BACKUP_REMOTE_KEEP_LATEST=7
``` ```
Upload an encrypted backup to S3: Upload an encrypted backup to S3-compatible object storage with rclone:
```bash ```bash
chmod +x ./scripts/backup-upload-s3.sh chmod +x ./scripts/backup-upload-s3.sh
./scripts/backup-upload-s3.sh ./scripts/backup-upload-s3.sh
``` ```
After a successful upload, the remote storage keeps only the latest `BACKUP_REMOTE_KEEP_LATEST` encrypted `.tar.gz.enc` files. The server keeps only the latest `BACKUP_LOCAL_KEEP_LATEST` plaintext `.tar.gz` files under `./backups/latest/`; encrypted backup files are not kept locally.
Restore the latest encrypted backup from S3: Restore the latest encrypted backup from S3:
```bash ```bash

161
scripts/backup-upload-s3.sh Normal file → Executable file
View File

@@ -8,23 +8,25 @@ Usage:
Environment: Environment:
DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment. DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment.
S3_BACKUP_BUCKET S3 bucket name.
S3_BACKUP_PREFIX S3 object prefix. Defaults to qlockify. S3_BACKUP_ENDPOINT_URL S3 endpoint URL.
S3_BACKUP_REGION S3 region. Defaults to us-east-1.
S3_BACKUP_ENDPOINT_URL Optional S3-compatible endpoint URL.
S3_BACKUP_ACCESS_KEY_ID S3 access key. S3_BACKUP_ACCESS_KEY_ID S3 access key.
S3_BACKUP_SECRET_ACCESS_KEY S3 secret key. S3_BACKUP_SECRET_ACCESS_KEY S3 secret key.
BACKUP_ENCRYPTION_PASSPHRASE Passphrase used to encrypt backup archives. S3_BACKUP_BUCKET Bucket name.
BACKUP_LOCAL_KEEP_LATEST Set to 1 to keep latest encrypted archive locally. 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 EOF
} }
log() { log() {
printf '[backup-s3] %s\n' "$*" printf '[backup-rclone] %s\n' "$*"
} }
fail() { fail() {
printf '[backup-s3] %s\n' "$*" >&2 printf '[backup-rclone] %s\n' "$*" >&2
exit 1 exit 1
} }
@@ -35,26 +37,93 @@ require_var() {
load_env() { load_env() {
local env_path="$1" local env_path="$1"
[[ -f "$env_path" ]] || fail "Deployment env file not found: $env_path" [[ -f "$env_path" ]] || fail "Deployment env file not found: $env_path"
set -a set -a
# shellcheck disable=SC1090 # shellcheck disable=SC1090
. "$env_path" . "$env_path"
set +a set +a
} }
aws_s3() { normalize_prefix() {
if [[ -n "${S3_BACKUP_ENDPOINT_URL:-}" ]]; then local value="${1:-qlockify}"
aws --endpoint-url "$S3_BACKUP_ENDPOINT_URL" s3 "$@"
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 else
aws s3 "$@" printf '%s' "$fallback"
fi fi
} }
normalize_prefix() { rclone_remote_path() {
local value="${1:-qlockify}" printf 'parspack:%s/%s' "$S3_BACKUP_BUCKET" "$S3_BACKUP_PREFIX"
value="${value#/}" }
value="${value%/}"
printf '%s' "$value" 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 if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
@@ -62,29 +131,28 @@ if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
exit 0 exit 0
fi fi
command -v aws >/dev/null 2>&1 || fail "aws CLI is required" command -v rclone >/dev/null 2>&1 || fail "rclone is required"
command -v openssl >/dev/null 2>&1 || fail "openssl is required" command -v openssl >/dev/null 2>&1 || fail "openssl is required"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DEPLOY_ROOT="${DEPLOY_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" DEPLOY_ROOT="${DEPLOY_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}"
DEPLOY_ENV="$DEPLOY_ROOT/.env" DEPLOY_ENV="$DEPLOY_ROOT/.env"
BACKUP_SCRIPT="$SCRIPT_DIR/backup.sh" BACKUP_SCRIPT="$SCRIPT_DIR/backup.sh"
[[ -x "$BACKUP_SCRIPT" ]] || fail "Backup script is not executable: $BACKUP_SCRIPT" [[ -x "$BACKUP_SCRIPT" ]] || fail "Backup script is not executable: $BACKUP_SCRIPT"
load_env "$DEPLOY_ENV" load_env "$DEPLOY_ENV"
S3_BACKUP_PREFIX="$(normalize_prefix "${S3_BACKUP_PREFIX:-qlockify}")" require_var S3_BACKUP_ENDPOINT_URL
S3_BACKUP_REGION="${S3_BACKUP_REGION:-us-east-1}"
require_var S3_BACKUP_BUCKET
require_var S3_BACKUP_ACCESS_KEY_ID require_var S3_BACKUP_ACCESS_KEY_ID
require_var S3_BACKUP_SECRET_ACCESS_KEY require_var S3_BACKUP_SECRET_ACCESS_KEY
require_var S3_BACKUP_BUCKET
require_var BACKUP_ENCRYPTION_PASSPHRASE require_var BACKUP_ENCRYPTION_PASSPHRASE
export AWS_ACCESS_KEY_ID="$S3_BACKUP_ACCESS_KEY_ID" S3_BACKUP_PREFIX="$(normalize_prefix "${S3_BACKUP_PREFIX:-qlockify}")"
export AWS_SECRET_ACCESS_KEY="$S3_BACKUP_SECRET_ACCESS_KEY" BACKUP_LOCAL_KEEP_LATEST="$(positive_integer_or_default "${BACKUP_LOCAL_KEEP_LATEST:-3}" 3)"
export AWS_DEFAULT_REGION="$S3_BACKUP_REGION" BACKUP_REMOTE_KEEP_LATEST="$(positive_integer_or_default "${BACKUP_REMOTE_KEEP_LATEST:-7}" 7)"
export AWS_EC2_METADATA_DISABLED=true
WORK_DIR="$(mktemp -d)" WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT trap 'rm -rf "$WORK_DIR"' EXIT
@@ -93,36 +161,53 @@ PLAIN_DIR="$WORK_DIR/plain"
mkdir -p "$PLAIN_DIR" mkdir -p "$PLAIN_DIR"
log "Creating local backup archive" log "Creating local backup archive"
DEPLOY_ROOT="$DEPLOY_ROOT" "$BACKUP_SCRIPT" "$PLAIN_DIR" DEPLOY_ROOT="$DEPLOY_ROOT" "$BACKUP_SCRIPT" "$PLAIN_DIR"
shopt -s nullglob shopt -s nullglob
archives=("$PLAIN_DIR"/*.tar.gz) archives=("$PLAIN_DIR"/*.tar.gz)
shopt -u nullglob shopt -u nullglob
[[ "${#archives[@]}" -eq 1 ]] || fail "Expected exactly one backup archive, found ${#archives[@]}" [[ "${#archives[@]}" -eq 1 ]] || fail "Expected exactly one backup archive, found ${#archives[@]}"
ARCHIVE_PATH="${archives[0]}" ARCHIVE_PATH="${archives[0]}"
ARCHIVE_NAME="$(basename "$ARCHIVE_PATH")" ARCHIVE_NAME="$(basename "$ARCHIVE_PATH")"
ENCRYPTED_NAME="$ARCHIVE_NAME.enc" ENCRYPTED_NAME="$ARCHIVE_NAME.enc"
ENCRYPTED_PATH="$WORK_DIR/$ENCRYPTED_NAME" ENCRYPTED_PATH="$WORK_DIR/$ENCRYPTED_NAME"
S3_URI="s3://$S3_BACKUP_BUCKET/$S3_BACKUP_PREFIX/$ENCRYPTED_NAME"
log "Encrypting backup archive" log "Encrypting backup archive"
openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \ openssl enc -aes-256-cbc -salt -pbkdf2 -iter 200000 \
-in "$ARCHIVE_PATH" \ -in "$ARCHIVE_PATH" \
-out "$ENCRYPTED_PATH" \ -out "$ENCRYPTED_PATH" \
-pass env:BACKUP_ENCRYPTION_PASSPHRASE -pass env:BACKUP_ENCRYPTION_PASSPHRASE
rm -f "$ARCHIVE_PATH" RCLONE_CONFIG="$WORK_DIR/rclone.conf"
log "Uploading encrypted backup to $S3_URI" cat > "$RCLONE_CONFIG" <<EOF
aws_s3 cp "$ENCRYPTED_PATH" "$S3_URI" --only-show-errors [parspack]
type = s3
provider = Other
access_key_id = $S3_BACKUP_ACCESS_KEY_ID
secret_access_key = $S3_BACKUP_SECRET_ACCESS_KEY
endpoint = $S3_BACKUP_ENDPOINT_URL
acl = private
force_path_style = true
EOF
if [[ "${BACKUP_LOCAL_KEEP_LATEST:-1}" == "1" ]]; then REMOTE_PATH="$(rclone_remote_path)"
LATEST_DIR="$DEPLOY_ROOT/backups/latest"
mkdir -p "$LATEST_DIR"
find "$LATEST_DIR" -type f -name '*.tar.gz.enc' -delete
cp "$ENCRYPTED_PATH" "$LATEST_DIR/$ENCRYPTED_NAME"
log "Kept latest encrypted backup locally: $LATEST_DIR/$ENCRYPTED_NAME"
fi
log "S3 backup upload completed: $S3_URI" log "Uploading encrypted backup to $REMOTE_PATH/$ENCRYPTED_NAME"
rclone copy \
"$ENCRYPTED_PATH" \
"$REMOTE_PATH" \
--config "$RCLONE_CONFIG" \
--s3-no-check-bucket \
--progress
cleanup_local_backups "$BACKUP_LOCAL_KEEP_LATEST" "$DEPLOY_ROOT/backups/latest"
cleanup_remote_backups "$BACKUP_REMOTE_KEEP_LATEST" "$REMOTE_PATH"
log "Backup upload completed successfully"

81
scripts/restore-from-s3.sh Normal file → Executable file
View File

@@ -15,8 +15,7 @@ Environment:
DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment. DEPLOY_ROOT Deployment directory. Defaults to ~/qlockify-deployment.
S3_BACKUP_BUCKET S3 bucket name. S3_BACKUP_BUCKET S3 bucket name.
S3_BACKUP_PREFIX S3 object prefix. Defaults to qlockify. S3_BACKUP_PREFIX S3 object prefix. Defaults to qlockify.
S3_BACKUP_REGION S3 region. Defaults to us-east-1. S3_BACKUP_ENDPOINT_URL S3-compatible endpoint URL.
S3_BACKUP_ENDPOINT_URL Optional S3-compatible endpoint URL.
S3_BACKUP_ACCESS_KEY_ID S3 access key. S3_BACKUP_ACCESS_KEY_ID S3 access key.
S3_BACKUP_SECRET_ACCESS_KEY S3 secret key. S3_BACKUP_SECRET_ACCESS_KEY S3 secret key.
BACKUP_ENCRYPTION_PASSPHRASE Passphrase used to decrypt backup archives. BACKUP_ENCRYPTION_PASSPHRASE Passphrase used to decrypt backup archives.
@@ -27,11 +26,11 @@ EOF
} }
log() { log() {
printf '[restore-s3] %s\n' "$*" printf '[restore-rclone] %s\n' "$*"
} }
fail() { fail() {
printf '[restore-s3] %s\n' "$*" >&2 printf '[restore-rclone] %s\n' "$*" >&2
exit 1 exit 1
} }
@@ -49,14 +48,6 @@ load_env() {
set +a set +a
} }
aws_s3() {
if [[ -n "${S3_BACKUP_ENDPOINT_URL:-}" ]]; then
aws --endpoint-url "$S3_BACKUP_ENDPOINT_URL" s3 "$@"
else
aws s3 "$@"
fi
}
normalize_prefix() { normalize_prefix() {
local value="${1:-qlockify}" local value="${1:-qlockify}"
value="${value#/}" value="${value#/}"
@@ -64,12 +55,19 @@ normalize_prefix() {
printf '%s' "$value" printf '%s' "$value"
} }
latest_object_key() { rclone_remote_path() {
aws_s3 ls "s3://$S3_BACKUP_BUCKET/$S3_BACKUP_PREFIX/" --recursive \ printf 'parspack:%s/%s' "$S3_BACKUP_BUCKET" "$S3_BACKUP_PREFIX"
| awk '{print $4}' \ }
latest_object_name() {
rclone lsf "$REMOTE_PATH" \
--config "$RCLONE_CONFIG" \
--s3-no-check-bucket \
--files-only \
| grep '\.tar\.gz\.enc$' \ | grep '\.tar\.gz\.enc$' \
| sort \ | sort \
| tail -n 1 | tail -n 1 \
|| true
} }
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
@@ -83,7 +81,7 @@ REQUESTED_KEY="${1:-}"
exit 1 exit 1
} }
command -v aws >/dev/null 2>&1 || fail "aws CLI is required" command -v rclone >/dev/null 2>&1 || fail "rclone is required"
command -v openssl >/dev/null 2>&1 || fail "openssl is required" command -v openssl >/dev/null 2>&1 || fail "openssl is required"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -95,35 +93,48 @@ RESTORE_SCRIPT="$SCRIPT_DIR/restore.sh"
load_env "$DEPLOY_ENV" load_env "$DEPLOY_ENV"
S3_BACKUP_PREFIX="$(normalize_prefix "${S3_BACKUP_PREFIX:-qlockify}")" S3_BACKUP_PREFIX="$(normalize_prefix "${S3_BACKUP_PREFIX:-qlockify}")"
S3_BACKUP_REGION="${S3_BACKUP_REGION:-us-east-1}"
require_var S3_BACKUP_BUCKET require_var S3_BACKUP_BUCKET
require_var S3_BACKUP_ENDPOINT_URL
require_var S3_BACKUP_ACCESS_KEY_ID require_var S3_BACKUP_ACCESS_KEY_ID
require_var S3_BACKUP_SECRET_ACCESS_KEY require_var S3_BACKUP_SECRET_ACCESS_KEY
require_var BACKUP_ENCRYPTION_PASSPHRASE require_var BACKUP_ENCRYPTION_PASSPHRASE
export AWS_ACCESS_KEY_ID="$S3_BACKUP_ACCESS_KEY_ID"
export AWS_SECRET_ACCESS_KEY="$S3_BACKUP_SECRET_ACCESS_KEY"
export AWS_DEFAULT_REGION="$S3_BACKUP_REGION"
export AWS_EC2_METADATA_DISABLED=true
if [[ "$REQUESTED_KEY" == "latest" ]]; then
log "Resolving latest encrypted backup object"
OBJECT_KEY="$(latest_object_key)"
[[ -n "$OBJECT_KEY" ]] || fail "No encrypted backups found in s3://$S3_BACKUP_BUCKET/$S3_BACKUP_PREFIX/"
else
OBJECT_KEY="${REQUESTED_KEY#s3://$S3_BACKUP_BUCKET/}"
fi
WORK_DIR="$(mktemp -d)" WORK_DIR="$(mktemp -d)"
trap 'rm -rf "$WORK_DIR"' EXIT trap 'rm -rf "$WORK_DIR"' EXIT
ENCRYPTED_PATH="$WORK_DIR/$(basename "$OBJECT_KEY")" RCLONE_CONFIG="$WORK_DIR/rclone.conf"
cat > "$RCLONE_CONFIG" <<EOF
[parspack]
type = s3
provider = Other
access_key_id = $S3_BACKUP_ACCESS_KEY_ID
secret_access_key = $S3_BACKUP_SECRET_ACCESS_KEY
endpoint = $S3_BACKUP_ENDPOINT_URL
acl = private
force_path_style = true
EOF
REMOTE_PATH="$(rclone_remote_path)"
if [[ "$REQUESTED_KEY" == "latest" ]]; then
log "Resolving latest encrypted backup object"
OBJECT_NAME="$(latest_object_name)"
[[ -n "$OBJECT_NAME" ]] || fail "No encrypted backups found in $REMOTE_PATH"
else
OBJECT_NAME="${REQUESTED_KEY##*/}"
fi
ENCRYPTED_PATH="$WORK_DIR/$OBJECT_NAME"
DECRYPTED_PATH="$WORK_DIR/${ENCRYPTED_PATH##*/}" DECRYPTED_PATH="$WORK_DIR/${ENCRYPTED_PATH##*/}"
DECRYPTED_PATH="${DECRYPTED_PATH%.enc}" DECRYPTED_PATH="${DECRYPTED_PATH%.enc}"
log "Downloading encrypted backup from s3://$S3_BACKUP_BUCKET/$OBJECT_KEY" log "Downloading encrypted backup from $REMOTE_PATH/$OBJECT_NAME"
aws_s3 cp "s3://$S3_BACKUP_BUCKET/$OBJECT_KEY" "$ENCRYPTED_PATH" --only-show-errors rclone copyto "$REMOTE_PATH/$OBJECT_NAME" "$ENCRYPTED_PATH" \
--config "$RCLONE_CONFIG" \
--s3-no-check-bucket \
--progress
log "Decrypting backup archive" log "Decrypting backup archive"
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
@@ -134,4 +145,4 @@ openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
log "Restoring decrypted backup archive" log "Restoring decrypted backup archive"
DEPLOY_ROOT="$DEPLOY_ROOT" "$RESTORE_SCRIPT" "$DECRYPTED_PATH" DEPLOY_ROOT="$DEPLOY_ROOT" "$RESTORE_SCRIPT" "$DECRYPTED_PATH"
log "S3 restore completed from: s3://$S3_BACKUP_BUCKET/$OBJECT_KEY" log "S3 restore completed from: $REMOTE_PATH/$OBJECT_NAME"