#!/usr/bin/env bash # Gitea per-push backup: bundles the pushed repo + snapshots SQLite DB. # Invoked from each repo's hooks/post-receive.d/zzz-backup shim. # Failures NEVER block the push — git push has already succeeded by the time we run. set -u # Git hooks run with $HOME pointing oddly (the repo dir or similar), so tools # like rclone can't find ~/.config/rclone/rclone.conf. Force it. export HOME="/home/ubuntu" BACKUP_ROOT="/home/ubuntu/gitea-backups" GITEA_REPOS="/home/ubuntu/gitea/data/gitea-repositories" GITEA_DB="/home/ubuntu/gitea/data/gitea.db" RETENTION_DAYS=7 LOG="${BACKUP_ROOT}/logs/backup.log" STATUS="${BACKUP_ROOT}/logs/last-status" # S3 (offsite). Empty S3_BUCKET disables the upload step entirely. S3_BUCKET="toqqer-gitea-backup" S3_REMOTE="s3" # rclone remote name (configured in ~/.config/rclone/rclone.conf) ts="$(date -u +%Y-%m-%dT%H-%M-%SZ)" day="$(date -u +%Y-%m-%d)" log() { printf '%s %s\n' "$(date -u +%FT%TZ)" "$*" >> "$LOG"; } # git invokes the hook with $GIT_DIR set to the bare repo path repo_path="${GIT_DIR:-$(pwd)}" repo_path="$(cd "$repo_path" && pwd)" # derive owner/name from the path: .../gitea-repositories//.git rel="${repo_path#${GITEA_REPOS}/}" owner="${rel%%/*}" name="${rel#*/}" name="${name%.git}" if [[ -z "$owner" || -z "$name" || "$owner" == "$rel" ]]; then log "SKIP: could not parse owner/name from $repo_path" echo "FAIL ${ts} parse-error ${repo_path}" > "$STATUS" exit 0 # never block the push fi log "START ${owner}/${name}" # ---- 1) repo bundle --------------------------------------------------------- bundle_dir="${BACKUP_ROOT}/repos/${owner}/${name}" mkdir -p "$bundle_dir" bundle_file="${bundle_dir}/${ts}.bundle" if git -C "$repo_path" bundle create "$bundle_file" --all 2>>"$LOG"; then bundle_size=$(stat -c %s "$bundle_file" 2>/dev/null || echo 0) log "OK bundle ${owner}/${name} -> ${bundle_file} (${bundle_size} bytes)" else log "FAIL bundle ${owner}/${name}" echo "FAIL ${ts} bundle ${owner}/${name}" > "$STATUS" # continue to DB backup anyway fi # ---- 2) SQLite hot backup (via python3, avoids sqlite3 CLI dependency) ---- db_file="${BACKUP_ROOT}/db/${ts}-gitea.db" if python3 -c " import sqlite3, sys src = sqlite3.connect('${GITEA_DB}') dst = sqlite3.connect('${db_file}') src.backup(dst) dst.close(); src.close() " 2>>"$LOG"; then if gzip -f "$db_file" 2>>"$LOG"; then db_size=$(stat -c %s "${db_file}.gz" 2>/dev/null || echo 0) log "OK db -> ${db_file}.gz (${db_size} bytes)" else log "FAIL gzip db ${db_file}" fi else log "FAIL sqlite .backup -> ${db_file}" fi # ---- 3) S3 offsite upload -------------------------------------------------- # Layout: s3:///YYYY-MM-DD/repos///.bundle # s3:///YYYY-MM-DD/db/-gitea.db.gz # 7-day retention enforced by the bucket's lifecycle policy, NOT here. s3_status="skipped" if [[ -n "$S3_BUCKET" ]] && command -v rclone >/dev/null 2>&1; then s3_bundle_target="${S3_REMOTE}:${S3_BUCKET}/${day}/repos/${owner}/${name}/${ts}.bundle" s3_db_target="${S3_REMOTE}:${S3_BUCKET}/${day}/db/${ts}-gitea.db.gz" s3_ok=1 if [[ -f "$bundle_file" ]]; then if rclone copyto --no-traverse "$bundle_file" "$s3_bundle_target" 2>>"$LOG"; then log "OK s3 bundle -> ${s3_bundle_target}" else log "FAIL s3 bundle -> ${s3_bundle_target}" s3_ok=0 fi fi if [[ -f "${db_file}.gz" ]]; then if rclone copyto --no-traverse "${db_file}.gz" "$s3_db_target" 2>>"$LOG"; then log "OK s3 db -> ${s3_db_target}" else log "FAIL s3 db -> ${s3_db_target}" s3_ok=0 fi fi s3_status=$([[ $s3_ok -eq 1 ]] && echo "ok" || echo "fail") fi # ---- 4) cleanup is handled by retention.sh (daily cron, "keep newest N dates"). ---- # Push-triggered cleanup was removed because age-based cleanup would empty the # bucket during quiet periods. retention.sh keeps the most-recent N date-folders # regardless of how old they are. echo "OK ${ts} ${owner}/${name} s3=${s3_status}" > "$STATUS" log "END ${owner}/${name} s3=${s3_status}" exit 0