Ctrl K
ComunidadGuías…

backup.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# vps-github-backup — daily full-server snapshot pushed to a private GitHub repo.
#
# Each run rebuilds a staging tree (code archives, database dumps, system config,
# raw asset folders), commits it as a single parentless commit and force-pushes it,
# so the remote always holds exactly one up-to-date snapshot. Unchanged raw files
# are deduplicated by git and are only uploaded once.
#
# Usage: backup.sh [-c /path/to/backup.conf] [--dry-run]
set -uo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
CONF="$HERE/backup.conf"
DRY_RUN=0
while [ $# -gt 0 ]; do
  case "$1" in
    -c|--config) CONF="$2"; shift 2 ;;
    --dry-run)   DRY_RUN=1; shift ;;
    *) echo "unknown option: $1" >&2; exit 2 ;;
  esac
done
[ -r "$CONF" ] || { echo "config not found: $CONF" >&2; exit 2; }

# ---- defaults (override in backup.conf) -------------------------------------
REPO_REMOTE=""
SSH_KEY=""
STAGING=/var/backups/vps-github-backup
STATE_DIR=/var/lib/vps-github-backup
LOG=/var/log/vps-github-backup.log
LOG_MAX_KB=5120
ZSTD_LEVEL=10
SPLIT_MB=95
MIN_FREE_GB=5
MIN_DUMP_BYTES=1000
TAR_DIRS=()
TAR_EXCLUDES=(node_modules .git __pycache__ "*.log" logs)
RAW_DIRS=()
SQLITE_DBS=()
POSTGRES=0
MYSQL=0
REDIS=0
REDIS_RDB=/var/lib/redis/dump.rdb
REDIS_CLI_ARGS=()
ETC_PATHS=(etc/nginx etc/letsencrypt etc/cron.d etc/systemd/system etc/hosts)
PM2=0
GIT_NAME="vps-github-backup"
GIT_EMAIL="backup@localhost"
# shellcheck source=backup.conf.example
source "$CONF"
export DISCORD_WEBHOOK_URL="${DISCORD_WEBHOOK_URL:-}" DISCORD_BOT_TOKEN="${DISCORD_BOT_TOKEN:-}" \
       DISCORD_USER_IDS="${DISCORD_USER_IDS:-}"

[ -n "$REPO_REMOTE" ] || { echo "REPO_REMOTE is not set in $CONF" >&2; exit 2; }
for bin in git tar zstd split flock python3; do
  command -v "$bin" >/dev/null || { echo "missing dependency: $bin" >&2; exit 2; }
done

# The staging directory is wiped on every run: refuse anything that looks like a real folder.
STAGING="${STAGING%/}"
case "$STAGING" in
  ""|/|/bin|/boot|/dev|/etc|/home|/lib*|/opt|/proc|/root|/run|/sbin|/srv|/sys|/tmp|/usr|/var|/var/backups|/var/lib|/var/www)
    echo "refusing to use STAGING=$STAGING (it is wiped on every run)" >&2; exit 2 ;;
esac
MARK=.vps-github-backup-staging
if [ -d "$STAGING" ] && [ -n "$(ls -A "$STAGING" 2>/dev/null)" ] && [ ! -e "$STAGING/$MARK" ]; then
  echo "refusing to use non-empty $STAGING: it was not created by vps-github-backup" >&2; exit 2
fi

mkdir -p "$STATE_DIR" "$(dirname "$LOG")"
exec 9>"$STATE_DIR/lock"
flock -n 9 || { echo "another backup is already running"; exit 0; }
if [ "$DRY_RUN" != 1 ]; then
  # keep the log small: trim to the last 2000 lines once it passes LOG_MAX_KB
  if [ -f "$LOG" ] && [ "$(du -k "$LOG" | cut -f1)" -gt "$LOG_MAX_KB" ]; then
    tail -n 2000 "$LOG" > "$LOG.tmp" && mv "$LOG.tmp" "$LOG"
  fi
  exec >>"$LOG" 2>&1
fi

TS=$(date -u +%Y-%m-%dT%H:%MZ)
T0=$(date +%s)
ERRS=()
fail() { echo "  FAIL: $1"; ERRS+=("$1"); }
notify() { python3 "$HERE/notify.py" "$1" || echo "  (notification failed)"; }
zst() { zstd -q -f -T0 "-$ZSTD_LEVEL" "$@"; }
# unique, readable archive name from a path: /opt/my app -> opt_my-app
pname() { local p="${1%/}"; p="${p#/}"; echo "$p" | tr '/ ' '_-'; }
as_postgres() { if command -v sudo >/dev/null; then sudo -u postgres "$@"; else runuser -u postgres -- "$@"; fi; }
[ -n "$SSH_KEY" ] && export GIT_SSH_COMMAND="ssh -i $SSH_KEY -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"

echo "=== $TS start$([ "$DRY_RUN" = 1 ] && echo ' (dry run)') ==="

# ---- staging repo ------------------------------------------------------------
mkdir -p "$STAGING" && cd "$STAGING" || { notify "🔴 **Backup failed** — cannot use staging dir \`$STAGING\`"; exit 1; }
[ -d .git ] || git init -q
git symbolic-ref HEAD refs/heads/main
git remote add origin "$REPO_REMOTE" 2>/dev/null || git remote set-url origin "$REPO_REMOTE"
git config user.name "$GIT_NAME"; git config user.email "$GIT_EMAIL"; git config core.compression 1
find . -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +
touch "$MARK"
mkdir -p code db system assets

FREE=$(df --output=avail -BG "$STAGING" | tail -1 | tr -dc 0-9)
[ "${FREE:-0}" -lt "$MIN_FREE_GB" ] && fail "low disk space: ${FREE}G free (min ${MIN_FREE_GB}G)"

# ---- 1) code / app directories ----------------------------------------------
EX=(); for e in "${TAR_EXCLUDES[@]}"; do EX+=(--exclude="$e"); done
for d in "${TAR_DIRS[@]}"; do
  d="${d%/}"
  [ -d "$d" ] || { fail "missing dir $d"; continue; }
  tar -I "zstd -T0 -$ZSTD_LEVEL" -cf "code/$(pname "$d").tar.zst" "${EX[@]}" -C "$(dirname "$d")" "$(basename "$d")"
  # exit 1 = "file changed as we read it" (live logs/dbs): harmless
  [ $? -le 1 ] || fail "tar $d"
done

# ---- 2) databases -----------------------------------------------------------
for db in "${SQLITE_DBS[@]}"; do
  [ -f "$db" ] || { fail "missing sqlite db $db"; continue; }
  tmp=$(mktemp "/tmp/vgb.XXXXXX")
  { sqlite3 "$db" ".backup '$tmp'" && zst --rm "$tmp" -o "db/$(pname "$db").zst"; } || fail "sqlite dump $db"
  rm -f "$tmp"
done
if [ "$POSTGRES" = 1 ]; then
  as_postgres pg_dumpall | zst -o db/postgres_all.sql.zst || fail "postgres dump"
fi
if [ "$MYSQL" = 1 ]; then
  mysqldump --all-databases --single-transaction --routines --events | zst -o db/mysql_all.sql.zst || fail "mysql dump"
fi
if [ "$REDIS" = 1 ]; then
  { redis-cli "${REDIS_CLI_ARGS[@]}" save | grep -q OK && zst "$REDIS_RDB" -o db/redis_dump.rdb.zst; } || fail "redis dump"
fi
# a dump that is suddenly tiny usually means something is wrong
for f in db/*; do
  [ -e "$f" ] || continue
  [ "$(stat -c%s "$f")" -ge "$MIN_DUMP_BYTES" ] || fail "$(basename "$f") is suspiciously small"
done

# ---- 3) system configuration ------------------------------------------------
existing=(); for p in "${ETC_PATHS[@]}"; do [ -e "/$p" ] && existing+=("$p"); done
if [ ${#existing[@]} -gt 0 ]; then
  tar -I "zstd -T0" -cf system/etc.tar.zst -C / "${existing[@]}"
  [ $? -le 1 ] || fail "tar system config"
fi
crontab -l > system/root.crontab 2>/dev/null
command -v ufw >/dev/null && ufw status numbered > system/ufw_status.txt 2>/dev/null
command -v dpkg >/dev/null && dpkg --get-selections > system/packages.txt
if [ "$PM2" = 1 ]; then
  cp "$HOME/.pm2/dump.pm2" system/pm2_dump.pm2 || fail "pm2 dump missing (run: pm2 save)"
fi
uname -a > system/host.txt

# ---- 4) raw asset folders (deduplicated by git -> uploaded once) -------------
for d in "${RAW_DIRS[@]}"; do
  d="${d%/}"
  [ -d "$d" ] || { fail "missing dir $d"; continue; }
  dest="assets/$(pname "$d")"
  # hard links are instant and free on the same filesystem; fall back to a copy
  cp -al "$d" "$dest" 2>/dev/null || { rm -rf "$dest"; cp -a "$d" "$dest"; } || fail "copy $d"
done

# ---- GitHub rejects files > 100 MB: split them (restore: cat f.part_* > f) ----
find . -path ./.git -prune -o -type f -size +"${SPLIT_MB}M" -print | while read -r f; do
  split -b "${SPLIT_MB}M" -d -a 3 "$f" "$f.part_" && rm -f "$f"
done

cp "$HERE/RESTORE.md" RESTORE.md 2>/dev/null
SIZE=$(du -sh --exclude=.git . | cut -f1)
printf 'snapshot %s\nhost %s\nsize %s\n' "$TS" "$(hostname)" "$SIZE" > SNAPSHOT.txt
echo "  snapshot size $SIZE"

# ---- commit + push -------------------------------------------------------------
# Build a parentless commit straight from the index: no branch switching, so the
# working tree (which may hard-link live files) is never rewritten by git.
if [ "$DRY_RUN" = 1 ]; then
  echo "  dry run: skipping commit/push. Staging tree: $STAGING"
else
  if git add -A && tree=$(git write-tree) \
     && commit=$(git commit-tree "$tree" -m "snapshot $TS") \
     && timeout 3000 git push -q -f origin "$commit:refs/heads/main"; then
    git update-ref refs/heads/main "$commit"
    echo "  pushed $commit"
  else
    fail "git push"
  fi
  git reflog expire --expire=now --all; git gc -q --prune=now
fi

DUR=$(( ($(date +%s) - T0) / 60 ))
if [ ${#ERRS[@]} -eq 0 ]; then
  [ "$DRY_RUN" = 1 ] || date +%s > "$STATE_DIR/last_ok"
  echo "=== $TS done OK (${DUR} min) ==="
else
  notify "$(printf '🔴 **Backup problem on %s** (%s, %s min)\n' "$(hostname)" "$TS" "$DUR"; printf '• %s\n' "${ERRS[@]}"; printf 'Log: `%s`' "$LOG")"
  echo "=== $TS done with ${#ERRS[@]} error(s) ==="
  exit 1
fi