feat: add incremental backups, S3 cleanup, and cron scheduling
Incremental backups using BC API's lastModifiedDateTime filter to only
export records changed since the last successful run. Runs every 15
minutes via cron, with a daily full backup for complete snapshots.
bc-export.ps1:
- Add -SinceDateTime parameter for incremental filtering
- Append $filter=lastModifiedDateTime gt {timestamp} to all entity URLs
- Exit code 2 when no records changed (skip archive/upload)
- Record mode and sinceDateTime in export-metadata.json
bc-backup.sh:
- Accept --mode full|incremental flag (default: incremental)
- State file (last-run-state.json) tracks last successful run timestamp
- Auto-fallback to full when no state file exists
- Skip archive/encrypt/upload when incremental finds 0 changes
- Lock file (.backup.lock) prevents overlapping cron runs
- S3 keys organized by mode: backups/full/ vs backups/incremental/
bc-cleanup.sh (new):
- Lists all S3 objects under backups/ prefix
- Deletes objects older than RETENTION_DAYS (default 30)
- Handles pagination for large buckets
- Gracefully handles COMPLIANCE-locked objects
bc-backup.conf.template:
- Add BACKUP_MODE_DEFAULT option
cron-examples.txt:
- Recommended setup: 15-min incremental + daily full + daily cleanup
- Alternative schedules (30-min, hourly)
- Systemd timer examples
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
114
bc-backup.sh
114
bc-backup.sh
@@ -2,6 +2,7 @@
|
||||
#
|
||||
# Business Central SaaS Automated Backup Script
|
||||
# Extracts BC data via API, encrypts, and uploads to S3 with immutability
|
||||
# Supports full and incremental (delta) backup modes
|
||||
#
|
||||
|
||||
set -euo pipefail
|
||||
@@ -11,6 +12,8 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONFIG_FILE="${SCRIPT_DIR}/bc-backup.conf"
|
||||
LOG_DIR="${SCRIPT_DIR}/logs"
|
||||
WORK_DIR="${SCRIPT_DIR}/temp"
|
||||
STATE_FILE="${SCRIPT_DIR}/last-run-state.json"
|
||||
LOCK_FILE="${SCRIPT_DIR}/.backup.lock"
|
||||
|
||||
# Ensure log directory exists
|
||||
mkdir -p "${LOG_DIR}"
|
||||
@@ -25,6 +28,40 @@ log_error() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" | tee -a "${LOG_DIR}/backup.log" >&2
|
||||
}
|
||||
|
||||
# Lock file management - prevent overlapping runs
|
||||
cleanup() {
|
||||
rm -f "${LOCK_FILE}"
|
||||
}
|
||||
|
||||
if [[ -f "${LOCK_FILE}" ]]; then
|
||||
lock_pid=$(cat "${LOCK_FILE}" 2>/dev/null || true)
|
||||
if [[ -n "${lock_pid}" ]] && kill -0 "${lock_pid}" 2>/dev/null; then
|
||||
log "Another backup is already running (PID ${lock_pid}), exiting"
|
||||
exit 0
|
||||
else
|
||||
log "Stale lock file found (PID ${lock_pid} not running), removing"
|
||||
rm -f "${LOCK_FILE}"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo $$ > "${LOCK_FILE}"
|
||||
trap cleanup EXIT
|
||||
|
||||
# Parse arguments
|
||||
BACKUP_MODE=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--mode)
|
||||
BACKUP_MODE="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
log_error "Unknown argument: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Load configuration
|
||||
if [[ ! -f "${CONFIG_FILE}" ]]; then
|
||||
log_error "Configuration file not found: ${CONFIG_FILE}"
|
||||
@@ -33,6 +70,17 @@ fi
|
||||
|
||||
source "${CONFIG_FILE}"
|
||||
|
||||
# Use config default if no CLI arg
|
||||
if [[ -z "${BACKUP_MODE}" ]]; then
|
||||
BACKUP_MODE="${BACKUP_MODE_DEFAULT:-incremental}"
|
||||
fi
|
||||
|
||||
# Validate mode
|
||||
if [[ "${BACKUP_MODE}" != "full" && "${BACKUP_MODE}" != "incremental" ]]; then
|
||||
log_error "Invalid backup mode: ${BACKUP_MODE}. Must be 'full' or 'incremental'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Validate required configuration
|
||||
required_vars=(
|
||||
"AZURE_TENANT_ID"
|
||||
@@ -59,19 +107,38 @@ S3_TOOL="${S3_TOOL:-awscli}"
|
||||
MAX_RETRIES="${MAX_RETRIES:-3}"
|
||||
CLEANUP_LOCAL="${CLEANUP_LOCAL:-true}"
|
||||
|
||||
# Determine SinceDateTime for incremental mode
|
||||
SINCE_DATETIME=""
|
||||
if [[ "${BACKUP_MODE}" == "incremental" ]]; then
|
||||
if [[ -f "${STATE_FILE}" ]]; then
|
||||
SINCE_DATETIME=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['lastSuccessfulRun'])" "${STATE_FILE}" 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "${SINCE_DATETIME}" ]]; then
|
||||
log "No previous run state found, falling back to full backup"
|
||||
BACKUP_MODE="full"
|
||||
fi
|
||||
fi
|
||||
|
||||
log "========================================="
|
||||
log "Starting Business Central backup process"
|
||||
log "========================================="
|
||||
log "Mode: ${BACKUP_MODE}"
|
||||
log "Environment: ${BC_ENVIRONMENT_NAME}"
|
||||
log "S3 Bucket: ${S3_BUCKET}"
|
||||
log "Retention: ${RETENTION_DAYS} days"
|
||||
if [[ -n "${SINCE_DATETIME}" ]]; then
|
||||
log "Changes since: ${SINCE_DATETIME}"
|
||||
fi
|
||||
|
||||
# Record the run start time (UTC) before export begins
|
||||
RUN_START_TIME=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
|
||||
# Generate timestamp for backup filename
|
||||
TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
|
||||
BACKUP_FILENAME="bc_backup_${BC_ENVIRONMENT_NAME}_${TIMESTAMP}"
|
||||
BACKUP_FILENAME="bc_backup_${BC_ENVIRONMENT_NAME}_${TIMESTAMP}_${BACKUP_MODE}"
|
||||
|
||||
# Step 1: Extract data using PowerShell script (BC API v2.0)
|
||||
log "Step 1: Extracting data via BC API v2.0"
|
||||
log "Step 1: Extracting data via BC API v2.0 (${BACKUP_MODE})"
|
||||
|
||||
export AZURE_TENANT_ID
|
||||
export AZURE_CLIENT_ID
|
||||
@@ -82,8 +149,23 @@ export WORK_DIR
|
||||
|
||||
EXPORT_DIR="${WORK_DIR}/${BACKUP_FILENAME}"
|
||||
|
||||
if ! pwsh -File "${SCRIPT_DIR}/bc-export.ps1" -OutputPath "${EXPORT_DIR}"; then
|
||||
log_error "Data export failed"
|
||||
PWSH_ARGS=(-File "${SCRIPT_DIR}/bc-export.ps1" -OutputPath "${EXPORT_DIR}")
|
||||
if [[ -n "${SINCE_DATETIME}" ]]; then
|
||||
PWSH_ARGS+=(-SinceDateTime "${SINCE_DATETIME}")
|
||||
fi
|
||||
|
||||
pwsh_exit=0
|
||||
pwsh "${PWSH_ARGS[@]}" || pwsh_exit=$?
|
||||
|
||||
if [[ ${pwsh_exit} -eq 2 ]]; then
|
||||
# Exit code 2 = success but no records changed
|
||||
log "No changes detected since ${SINCE_DATETIME}, skipping backup"
|
||||
# Clean up empty export dir
|
||||
rm -rf "${EXPORT_DIR}" 2>/dev/null || true
|
||||
exit 0
|
||||
elif [[ ${pwsh_exit} -ne 0 ]]; then
|
||||
log_error "Data export failed (exit code ${pwsh_exit})"
|
||||
rm -rf "${EXPORT_DIR}" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -137,15 +219,13 @@ fi
|
||||
# Step 3: Upload to S3 with object lock
|
||||
log "Step 3: Uploading encrypted backup to S3"
|
||||
|
||||
S3_KEY="backups/${BACKUP_FILENAME}.tar.gz.gpg"
|
||||
S3_KEY="backups/${BACKUP_MODE}/${BACKUP_FILENAME}.tar.gz.gpg"
|
||||
S3_URI="s3://${S3_BUCKET}/${S3_KEY}"
|
||||
|
||||
# Calculate retention date
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
# macOS date command
|
||||
RETENTION_DATE=$(date -u -v+${RETENTION_DAYS}d '+%Y-%m-%dT%H:%M:%S')
|
||||
else
|
||||
# Linux date command
|
||||
RETENTION_DATE=$(date -u -d "+${RETENTION_DAYS} days" '+%Y-%m-%dT%H:%M:%S')
|
||||
fi
|
||||
|
||||
@@ -154,7 +234,6 @@ upload_success=false
|
||||
if [[ "${S3_TOOL}" == "awscli" ]]; then
|
||||
log "Using AWS CLI for upload"
|
||||
|
||||
# Upload with object lock retention
|
||||
if aws s3api put-object \
|
||||
--bucket "${S3_BUCKET}" \
|
||||
--key "${S3_KEY}" \
|
||||
@@ -162,14 +241,13 @@ if [[ "${S3_TOOL}" == "awscli" ]]; then
|
||||
--endpoint-url "${S3_ENDPOINT}" \
|
||||
--object-lock-mode COMPLIANCE \
|
||||
--object-lock-retain-until-date "${RETENTION_DATE}Z" \
|
||||
--metadata "backup-timestamp=${TIMESTAMP},environment=${BC_ENVIRONMENT_NAME},encrypted=true,type=api-extract"; then
|
||||
--metadata "backup-timestamp=${TIMESTAMP},environment=${BC_ENVIRONMENT_NAME},encrypted=true,type=api-extract,mode=${BACKUP_MODE}"; then
|
||||
upload_success=true
|
||||
fi
|
||||
|
||||
elif [[ "${S3_TOOL}" == "s3cmd" ]]; then
|
||||
log "Using s3cmd for upload"
|
||||
|
||||
# Upload file first
|
||||
if s3cmd put \
|
||||
--host="${S3_ENDPOINT#*://}" \
|
||||
--host-bucket="${S3_ENDPOINT#*://}" \
|
||||
@@ -177,8 +255,6 @@ elif [[ "${S3_TOOL}" == "s3cmd" ]]; then
|
||||
"${S3_URI}"; then
|
||||
|
||||
log "File uploaded, attempting to set object lock retention"
|
||||
# Note: s3cmd may not support object lock natively
|
||||
# Fallback to aws cli for setting retention if available
|
||||
if command -v aws &> /dev/null; then
|
||||
aws s3api put-object-retention \
|
||||
--bucket "${S3_BUCKET}" \
|
||||
@@ -226,13 +302,20 @@ elif [[ "${S3_TOOL}" == "s3cmd" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 5: Cleanup
|
||||
# Step 5: Update state file (after successful upload)
|
||||
log "Step 5: Updating run state"
|
||||
cat > "${STATE_FILE}" << EOF
|
||||
{"lastSuccessfulRun": "${RUN_START_TIME}", "lastMode": "${BACKUP_MODE}", "lastFile": "${S3_KEY}"}
|
||||
EOF
|
||||
log "State saved: lastSuccessfulRun=${RUN_START_TIME}"
|
||||
|
||||
# Step 6: Cleanup
|
||||
if [[ "${CLEANUP_LOCAL}" == "true" ]]; then
|
||||
log "Step 5: Cleaning up local files"
|
||||
log "Step 6: Cleaning up local files"
|
||||
rm -f "${ENCRYPTED_FILE}"
|
||||
log "Local encrypted file removed"
|
||||
else
|
||||
log "Step 5: Skipping cleanup (CLEANUP_LOCAL=false)"
|
||||
log "Step 6: Skipping cleanup (CLEANUP_LOCAL=false)"
|
||||
log "Encrypted backup retained at: ${ENCRYPTED_FILE}"
|
||||
fi
|
||||
|
||||
@@ -241,6 +324,7 @@ find "${LOG_DIR}" -name "backup.log.*" -mtime +30 -delete 2>/dev/null || true
|
||||
|
||||
log "========================================="
|
||||
log "Backup completed successfully"
|
||||
log "Mode: ${BACKUP_MODE}"
|
||||
log "Backup file: ${S3_KEY}"
|
||||
log "Size: ${ENCRYPTED_SIZE}"
|
||||
log "========================================="
|
||||
|
||||
Reference in New Issue
Block a user