340 lines
12 KiB
Bash
340 lines
12 KiB
Bash
#!/bin/bash
|
|
#
|
|
# Forgejo Repository Backup Script
|
|
# Version: 2.0.0
|
|
#
|
|
# Backs up all Forgejo git repositories to Azure Blob Storage
|
|
# and logs results to PostgreSQL.
|
|
#
|
|
# Usage: ./forgejo-backup.sh
|
|
#
|
|
# Configuration via environment variables or .env file
|
|
|
|
set -euo pipefail
|
|
|
|
SCRIPT_VERSION="2.0.0"
|
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
HOSTNAME=$(hostname)
|
|
DATE=$(date +%Y-%m-%d)
|
|
TIMESTAMP=$(date +%Y-%m-%d_%H%M%S)
|
|
|
|
# Load configuration from .env if it exists
|
|
if [[ -f "$SCRIPT_DIR/.env" ]]; then
|
|
source "$SCRIPT_DIR/.env"
|
|
fi
|
|
|
|
# Configuration with defaults
|
|
FORGEJO_REPO_PATH="${FORGEJO_REPO_PATH:-/var/lib/forgejo/data/forgejo-repositories}"
|
|
BACKUP_TEMP_DIR="${BACKUP_TEMP_DIR:-/tmp/forgejo-backups}"
|
|
BACKUP_DB_HOST="${BACKUP_DB_HOST:-localhost}"
|
|
BACKUP_DB_PORT="${BACKUP_DB_PORT:-5432}"
|
|
BACKUP_DB_NAME="${BACKUP_DB_NAME:-plantempus}"
|
|
BACKUP_DB_USER="${BACKUP_DB_USER:-backup_writer}"
|
|
BACKUP_DB_PASSWORD="${BACKUP_DB_PASSWORD:-}"
|
|
BACKUP_RETENTION_DAYS="${BACKUP_RETENTION_DAYS:-30}"
|
|
|
|
# Azure Storage Configuration
|
|
AZURE_STORAGE_ACCOUNT="${AZURE_STORAGE_ACCOUNT:-}"
|
|
AZURE_STORAGE_KEY="${AZURE_STORAGE_KEY:-}"
|
|
AZURE_STORAGE_CONTAINER="${AZURE_STORAGE_CONTAINER:-backups}"
|
|
AZURE_STORAGE_PATH="${AZURE_STORAGE_PATH:-forgejo}"
|
|
|
|
# Build Azure destination URL
|
|
AZURE_BLOB_URL="https://${AZURE_STORAGE_ACCOUNT}.blob.core.windows.net/${AZURE_STORAGE_CONTAINER}/${AZURE_STORAGE_PATH}"
|
|
|
|
# Logging functions
|
|
log_info() {
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] INFO: $*"
|
|
}
|
|
|
|
log_error() {
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $*" >&2
|
|
}
|
|
|
|
log_warn() {
|
|
echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARN: $*"
|
|
}
|
|
|
|
# Database logging function
|
|
db_log() {
|
|
local backup_type="$1"
|
|
local source_name="$2"
|
|
local source_path="$3"
|
|
local destination="$4"
|
|
local remote_path="$5"
|
|
local status="$6"
|
|
local size_bytes="${7:-}"
|
|
local error_message="${8:-}"
|
|
local error_code="${9:-}"
|
|
local checksum="${10:-}"
|
|
local started_at="${11:-}"
|
|
local file_count="${12:-}"
|
|
|
|
local duration_ms=""
|
|
if [[ -n "$started_at" && "$status" != "running" ]]; then
|
|
local start_epoch=$(date -d "$started_at" +%s 2>/dev/null || echo "")
|
|
local now_epoch=$(date +%s)
|
|
if [[ -n "$start_epoch" ]]; then
|
|
duration_ms=$(( (now_epoch - start_epoch) * 1000 ))
|
|
fi
|
|
fi
|
|
|
|
local completed_at=""
|
|
if [[ "$status" != "running" ]]; then
|
|
completed_at=$(date '+%Y-%m-%d %H:%M:%S')
|
|
fi
|
|
|
|
PGPASSWORD="$BACKUP_DB_PASSWORD" psql -h "$BACKUP_DB_HOST" -p "$BACKUP_DB_PORT" -U "$BACKUP_DB_USER" -d "$BACKUP_DB_NAME" -q <<EOF
|
|
INSERT INTO backup_logs (
|
|
backup_type, source_name, source_path, destination, remote_path,
|
|
status, size_bytes, file_count, error_message, error_code,
|
|
hostname, script_version, checksum, started_at, completed_at, duration_ms
|
|
) VALUES (
|
|
'$backup_type',
|
|
'$source_name',
|
|
'$source_path',
|
|
'$destination',
|
|
'$remote_path',
|
|
'$status',
|
|
$([ -n "$size_bytes" ] && echo "$size_bytes" || echo "NULL"),
|
|
$([ -n "$file_count" ] && echo "$file_count" || echo "NULL"),
|
|
$([ -n "$error_message" ] && echo "'$(echo "$error_message" | sed "s/'/''/g")'" || echo "NULL"),
|
|
$([ -n "$error_code" ] && echo "'$error_code'" || echo "NULL"),
|
|
'$HOSTNAME',
|
|
'$SCRIPT_VERSION',
|
|
$([ -n "$checksum" ] && echo "'$checksum'" || echo "NULL"),
|
|
$([ -n "$started_at" ] && echo "'$started_at'" || echo "NOW()"),
|
|
$([ -n "$completed_at" ] && echo "'$completed_at'" || echo "NULL"),
|
|
$([ -n "$duration_ms" ] && echo "$duration_ms" || echo "NULL")
|
|
);
|
|
EOF
|
|
}
|
|
|
|
# Update existing log entry status
|
|
db_update_status() {
|
|
local source_name="$1"
|
|
local started_at="$2"
|
|
local status="$3"
|
|
local size_bytes="${4:-}"
|
|
local error_message="${5:-}"
|
|
local error_code="${6:-}"
|
|
local checksum="${7:-}"
|
|
local file_count="${8:-}"
|
|
|
|
local start_epoch=$(date -d "$started_at" +%s 2>/dev/null || echo "")
|
|
local now_epoch=$(date +%s)
|
|
local duration_ms=""
|
|
if [[ -n "$start_epoch" ]]; then
|
|
duration_ms=$(( (now_epoch - start_epoch) * 1000 ))
|
|
fi
|
|
|
|
PGPASSWORD="$BACKUP_DB_PASSWORD" psql -h "$BACKUP_DB_HOST" -p "$BACKUP_DB_PORT" -U "$BACKUP_DB_USER" -d "$BACKUP_DB_NAME" -q <<EOF
|
|
UPDATE backup_logs SET
|
|
status = '$status',
|
|
completed_at = NOW(),
|
|
duration_ms = $([ -n "$duration_ms" ] && echo "$duration_ms" || echo "NULL"),
|
|
size_bytes = $([ -n "$size_bytes" ] && echo "$size_bytes" || echo "size_bytes"),
|
|
file_count = $([ -n "$file_count" ] && echo "$file_count" || echo "file_count"),
|
|
error_message = $([ -n "$error_message" ] && echo "'$(echo "$error_message" | sed "s/'/''/g")'" || echo "error_message"),
|
|
error_code = $([ -n "$error_code" ] && echo "'$error_code'" || echo "error_code"),
|
|
checksum = $([ -n "$checksum" ] && echo "'$checksum'" || echo "checksum")
|
|
WHERE source_name = '$source_name' AND started_at = '$started_at';
|
|
EOF
|
|
}
|
|
|
|
# Upload to Azure Blob Storage using azcopy
|
|
azure_upload() {
|
|
local local_file="$1"
|
|
local remote_path="$2"
|
|
|
|
export AZCOPY_AUTO_LOGIN_TYPE=AZCLI 2>/dev/null || true
|
|
|
|
# Use SAS token or account key authentication
|
|
if [[ -n "$AZURE_STORAGE_KEY" ]]; then
|
|
azcopy copy "$local_file" "${remote_path}?sv=2022-11-02&ss=b&srt=co&sp=rwdlaciytfx&se=2030-01-01T00:00:00Z&st=2024-01-01T00:00:00Z&spr=https&sig=placeholder" \
|
|
--blob-type BlockBlob \
|
|
--overwrite=true \
|
|
2>&1
|
|
else
|
|
# Fallback to az cli
|
|
az storage blob upload \
|
|
--account-name "$AZURE_STORAGE_ACCOUNT" \
|
|
--container-name "$AZURE_STORAGE_CONTAINER" \
|
|
--file "$local_file" \
|
|
--name "${AZURE_STORAGE_PATH}/$DATE/$(basename "$local_file")" \
|
|
--overwrite \
|
|
2>&1
|
|
fi
|
|
}
|
|
|
|
# Upload using az cli (more reliable)
|
|
azure_upload_az() {
|
|
local local_file="$1"
|
|
local blob_name="$2"
|
|
|
|
az storage blob upload \
|
|
--account-name "$AZURE_STORAGE_ACCOUNT" \
|
|
--account-key "$AZURE_STORAGE_KEY" \
|
|
--container-name "$AZURE_STORAGE_CONTAINER" \
|
|
--file "$local_file" \
|
|
--name "$blob_name" \
|
|
--overwrite \
|
|
--only-show-errors \
|
|
2>&1
|
|
}
|
|
|
|
# Backup a single repository
|
|
backup_repo() {
|
|
local repo_path="$1"
|
|
local repo_name=$(echo "$repo_path" | sed "s|$FORGEJO_REPO_PATH/||" | sed 's|\.git$||')
|
|
local safe_name=$(echo "$repo_name" | tr '/' '-')
|
|
local backup_file="$BACKUP_TEMP_DIR/${safe_name}_${TIMESTAMP}.tar.gz"
|
|
local blob_name="${AZURE_STORAGE_PATH}/$DATE/${safe_name}.tar.gz"
|
|
local remote_path="${AZURE_BLOB_URL}/$DATE/${safe_name}.tar.gz"
|
|
local started_at=$(date '+%Y-%m-%d %H:%M:%S')
|
|
|
|
log_info "Backing up: $repo_name"
|
|
|
|
# Log start
|
|
db_log "forgejo_repos" "$repo_name" "$repo_path" "azure_blob" "$remote_path" "running" "" "" "" "" "$started_at" ""
|
|
|
|
# Create tar.gz archive
|
|
if ! tar -czf "$backup_file" -C "$(dirname "$repo_path")" "$(basename "$repo_path")" 2>/tmp/backup_error_$$; then
|
|
local error_msg=$(cat /tmp/backup_error_$$ 2>/dev/null || echo "Unknown tar error")
|
|
rm -f /tmp/backup_error_$$
|
|
log_error "Failed to create archive for $repo_name: $error_msg"
|
|
db_update_status "$repo_name" "$started_at" "failed" "" "$error_msg" "TAR_FAILED" "" ""
|
|
return 1
|
|
fi
|
|
|
|
# Get file info
|
|
local size_bytes=$(stat -c%s "$backup_file" 2>/dev/null || stat -f%z "$backup_file" 2>/dev/null || echo "0")
|
|
local file_count=$(tar -tzf "$backup_file" 2>/dev/null | wc -l || echo "0")
|
|
local checksum=$(sha256sum "$backup_file" 2>/dev/null | cut -d' ' -f1 || shasum -a 256 "$backup_file" 2>/dev/null | cut -d' ' -f1 || echo "")
|
|
|
|
# Upload to Azure Blob Storage
|
|
if ! azure_upload_az "$backup_file" "$blob_name" 2>/tmp/backup_error_$$; then
|
|
local error_msg=$(cat /tmp/backup_error_$$ 2>/dev/null || echo "Unknown Azure upload error")
|
|
rm -f /tmp/backup_error_$$
|
|
log_error "Failed to upload $repo_name to Azure: $error_msg"
|
|
db_update_status "$repo_name" "$started_at" "failed" "$size_bytes" "$error_msg" "AZURE_UPLOAD_FAILED" "" "$file_count"
|
|
rm -f "$backup_file"
|
|
return 1
|
|
fi
|
|
rm -f /tmp/backup_error_$$
|
|
|
|
# Clean up local file
|
|
rm -f "$backup_file"
|
|
|
|
# Log success
|
|
db_update_status "$repo_name" "$started_at" "success" "$size_bytes" "" "" "$checksum" "$file_count"
|
|
log_info "Successfully backed up: $repo_name ($size_bytes bytes)"
|
|
|
|
return 0
|
|
}
|
|
|
|
# Clean up old remote backups
|
|
cleanup_old_backups() {
|
|
log_info "Cleaning up backups older than $BACKUP_RETENTION_DAYS days"
|
|
|
|
local cutoff_date=$(date -d "$BACKUP_RETENTION_DAYS days ago" +%Y-%m-%d 2>/dev/null || date -v-${BACKUP_RETENTION_DAYS}d +%Y-%m-%d)
|
|
|
|
# List blobs and filter by date prefix
|
|
az storage blob list \
|
|
--account-name "$AZURE_STORAGE_ACCOUNT" \
|
|
--account-key "$AZURE_STORAGE_KEY" \
|
|
--container-name "$AZURE_STORAGE_CONTAINER" \
|
|
--prefix "${AZURE_STORAGE_PATH}/" \
|
|
--query "[].name" \
|
|
--output tsv 2>/dev/null | while read -r blob_name; do
|
|
|
|
# Extract date from path (format: forgejo/2024-01-15/repo.tar.gz)
|
|
local blob_date=$(echo "$blob_name" | grep -oP '\d{4}-\d{2}-\d{2}' | head -1 || echo "")
|
|
|
|
if [[ -n "$blob_date" && "$blob_date" < "$cutoff_date" ]]; then
|
|
log_info "Deleting old backup: $blob_name"
|
|
az storage blob delete \
|
|
--account-name "$AZURE_STORAGE_ACCOUNT" \
|
|
--account-key "$AZURE_STORAGE_KEY" \
|
|
--container-name "$AZURE_STORAGE_CONTAINER" \
|
|
--name "$blob_name" \
|
|
--only-show-errors 2>/dev/null || true
|
|
fi
|
|
done
|
|
}
|
|
|
|
# Main backup function
|
|
main() {
|
|
log_info "Starting Forgejo backup (version $SCRIPT_VERSION)"
|
|
log_info "Repository path: $FORGEJO_REPO_PATH"
|
|
log_info "Destination: Azure Blob Storage ($AZURE_STORAGE_ACCOUNT/$AZURE_STORAGE_CONTAINER)"
|
|
|
|
# Verify configuration
|
|
if [[ ! -d "$FORGEJO_REPO_PATH" ]]; then
|
|
log_error "Repository path does not exist: $FORGEJO_REPO_PATH"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -z "$AZURE_STORAGE_ACCOUNT" ]]; then
|
|
log_error "AZURE_STORAGE_ACCOUNT is not set"
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -z "$AZURE_STORAGE_KEY" ]]; then
|
|
log_error "AZURE_STORAGE_KEY is not set"
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v az &> /dev/null; then
|
|
log_error "Azure CLI (az) is not installed. Install with: curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash"
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v psql &> /dev/null; then
|
|
log_error "psql is not installed"
|
|
exit 1
|
|
fi
|
|
|
|
# Verify Azure connection
|
|
if ! az storage container show \
|
|
--account-name "$AZURE_STORAGE_ACCOUNT" \
|
|
--account-key "$AZURE_STORAGE_KEY" \
|
|
--name "$AZURE_STORAGE_CONTAINER" \
|
|
--only-show-errors &>/dev/null; then
|
|
log_error "Cannot connect to Azure Storage container: $AZURE_STORAGE_CONTAINER"
|
|
exit 1
|
|
fi
|
|
|
|
# Create temp directory
|
|
mkdir -p "$BACKUP_TEMP_DIR"
|
|
|
|
# Find and backup all repositories
|
|
local total=0
|
|
local success=0
|
|
local failed=0
|
|
|
|
while IFS= read -r -d '' repo; do
|
|
((total++)) || true
|
|
if backup_repo "$repo"; then
|
|
((success++)) || true
|
|
else
|
|
((failed++)) || true
|
|
fi
|
|
done < <(find "$FORGEJO_REPO_PATH" -maxdepth 3 -type d -name "*.git" -print0 2>/dev/null)
|
|
|
|
# Cleanup old backups
|
|
cleanup_old_backups
|
|
|
|
# Cleanup temp directory
|
|
rmdir "$BACKUP_TEMP_DIR" 2>/dev/null || true
|
|
|
|
# Summary
|
|
log_info "Backup complete: $total total, $success success, $failed failed"
|
|
|
|
if [[ $failed -gt 0 ]]; then
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
# Run main function
|
|
main "$@"
|