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:
2026-02-16 10:22:08 +01:00
parent b407e2aeb7
commit 3bad3ad171
6 changed files with 334 additions and 92 deletions

View File

@@ -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 "========================================="