#!/usr/bin/env bash set -Eeuo pipefail umask 077 SOURCE_DIR="${AGRARIAN_PROJECT_SOURCE:-/mnt/projects/AgrarianGameBulid}" MOUNT_POINT="${AGRARIAN_BACKUP_MOUNT:-/mnt/backups/linastorage}" BACKUP_ROOT="${AGRARIAN_BACKUP_ROOT:-$MOUNT_POINT/backups/agrarian-game/project}" RESTIC_REPOSITORY="${AGRARIAN_RESTIC_REPOSITORY:-$BACKUP_ROOT/restic-repository}" RESTIC_PASSWORD_FILE="${AGRARIAN_RESTIC_PASSWORD_FILE:-/root/.backup-secrets/agrarian-project-restic.password}" STATE_DIR="${AGRARIAN_BACKUP_STATE_DIR:-$BACKUP_ROOT/state}" LOCK_FILE="${AGRARIAN_BACKUP_LOCK:-/run/lock/agrarian-project-backup.lock}" LOG_TAG="${AGRARIAN_BACKUP_LOG_TAG:-agrarian-project-backup}" DRY_RUN=0 FORCE=0 MANUAL=0 EXCLUDES=( --exclude "$SOURCE_DIR/DerivedDataCache" --exclude "$SOURCE_DIR/Intermediate" --exclude "$SOURCE_DIR/Saved" --exclude "$SOURCE_DIR/Binaries" --exclude "$SOURCE_DIR/Builds" --exclude "$SOURCE_DIR/.git/index.lock" --exclude "$SOURCE_DIR/.git/lfs/tmp" --exclude "$SOURCE_DIR/.git/logs" --exclude "$SOURCE_DIR/.vs" --exclude "$SOURCE_DIR/AGRARIAN_BACKUP_MANIFEST.txt" --exclude "$SOURCE_DIR/AGRARIAN_CRITICAL_SHA256SUMS" --exclude "$SOURCE_DIR/AGRARIAN_RSYNC_LOG.txt" ) log() { printf '[%s] %s\n' "$(date -Is)" "$*" } die() { log "ERROR: $*" exit 1 } usage() { cat <<'USAGE' Usage: agrarian_project_backup.sh [--force] [--manual] [--dry-run] Creates an incremental deduplicated restic backup of the Agrarian Unreal project on Linastorage. Generated Unreal cache/build folders are excluded; Git metadata and local source data are included. USAGE } parse_args() { while [[ $# -gt 0 ]]; do case "$1" in --force) FORCE=1 ;; --manual) MANUAL=1 FORCE=1 ;; --dry-run) DRY_RUN=1 ;; -h|--help) usage exit 0 ;; *) die "Unknown argument: $1" ;; esac shift done } restic_cmd() { RESTIC_PASSWORD_FILE="$RESTIC_PASSWORD_FILE" \ restic -r "$RESTIC_REPOSITORY" "$@" } require_environment() { [[ -d "$SOURCE_DIR" ]] || die "Project source directory not found: $SOURCE_DIR" [[ -f "$SOURCE_DIR/AgrarianGame.uproject" ]] || die "Missing AgrarianGame.uproject under $SOURCE_DIR" findmnt -rn "$MOUNT_POINT" >/dev/null || die "$MOUNT_POINT is not mounted" mkdir -p "$BACKUP_ROOT" "$STATE_DIR" "$(dirname "$RESTIC_PASSWORD_FILE")" [[ -w "$BACKUP_ROOT" ]] || die "$BACKUP_ROOT is not writable" command -v restic >/dev/null || die "restic is required" } ensure_password_file() { if [[ -f "$RESTIC_PASSWORD_FILE" ]]; then chmod 0600 "$RESTIC_PASSWORD_FILE" return 0 fi log "Creating root-owned restic password file: $RESTIC_PASSWORD_FILE" openssl rand -base64 48 > "$RESTIC_PASSWORD_FILE" chmod 0600 "$RESTIC_PASSWORD_FILE" } ensure_repository() { if [[ -f "$RESTIC_REPOSITORY/config" ]]; then return 0 fi log "Initializing restic repository: $RESTIC_REPOSITORY" mkdir -p "$RESTIC_REPOSITORY" restic_cmd init } source_signature() { local tmp tmp="$(mktemp)" { git -C "$SOURCE_DIR" rev-parse HEAD 2>/dev/null || true git -C "$SOURCE_DIR" status --porcelain=v1 2>/dev/null || true find "$SOURCE_DIR" \ \( -path "$SOURCE_DIR/DerivedDataCache" \ -o -path "$SOURCE_DIR/Intermediate" \ -o -path "$SOURCE_DIR/Saved" \ -o -path "$SOURCE_DIR/Binaries" \ -o -path "$SOURCE_DIR/Builds" \ -o -path "$SOURCE_DIR/.git/lfs/tmp" \ -o -path "$SOURCE_DIR/.git/logs" \ -o -path "$SOURCE_DIR/.vs" \) -prune \ -o -type f -printf '%P\t%s\t%T@\n' 2>/dev/null \ | sort } > "$tmp" sha256sum "$tmp" | awk '{print $1}' rm -f -- "$tmp" } write_manifest() { local signature="$1" local manifest="$STATE_DIR/LATEST_MANIFEST.txt" { echo "backup_timestamp=$(date -Is)" echo "host=$(hostname -f 2>/dev/null || hostname)" echo "source=$SOURCE_DIR" echo "mount=$MOUNT_POINT" echo "repository=$RESTIC_REPOSITORY" echo "signature=$signature" echo "backup_mode=$([[ "$MANUAL" == "1" ]] && echo manual || echo scheduled)" echo "git_head=$(git -C "$SOURCE_DIR" rev-parse HEAD 2>/dev/null || echo unavailable)" echo echo "[excluded]" printf '%s\n' "${EXCLUDES[@]}" } > "$manifest" } backup_project() { local signature="$1" local mode_tag="scheduled" [[ "$MANUAL" == "1" ]] && mode_tag="manual" log "Starting restic backup for Agrarian project" restic_cmd backup \ --one-file-system \ --tag agrarian-game \ --tag project \ --tag "$mode_tag" \ "${EXCLUDES[@]}" \ "$SOURCE_DIR" write_manifest "$signature" printf '%s\n' "$signature" > "$STATE_DIR/LAST_SOURCE_SIGNATURE" log "Applying restic retention policy" restic_cmd forget \ --tag agrarian-game \ --tag project \ --keep-within 7d \ --keep-daily 30 \ --keep-weekly 12 \ --keep-monthly 12 \ --prune restic_cmd snapshots --tag agrarian-game --tag project > "$STATE_DIR/LATEST_SNAPSHOTS.txt" log "Project backup completed" logger -t "$LOG_TAG" "restic project backup completed successfully" } main() { parse_args "$@" exec 9>"$LOCK_FILE" if ! flock -n 9; then log "Another Agrarian project backup is already running; skipping this trigger" logger -t "$LOG_TAG" "another project backup is already running; skipped" exit 0 fi require_environment ensure_password_file ensure_repository local signature previous_signature signature="$(source_signature)" previous_signature="$(cat "$STATE_DIR/LAST_SOURCE_SIGNATURE" 2>/dev/null || true)" if [[ "$FORCE" != "1" && "$signature" == "$previous_signature" ]]; then log "No project changes detected since latest restic backup; skipping" logger -t "$LOG_TAG" "no project changes detected; skipped" exit 0 fi if [[ "$DRY_RUN" == "1" ]]; then log "Dry run: project changes detected; restic snapshot would be created" exit 0 fi backup_project "$signature" } main "$@"