feat(ops): offsite-бэкапы и проверки профилей

- deploy/backup.sh: BACKUP_OFFSITE_TARGET (rsync или rclone:remote:path)
  выгружает готовый архив после локальной ротации; неудача выгрузки только
  предупреждает. Ротация на приёмнике — опционально
  (BACKUP_OFFSITE_KEEP_DAYS, rclone delete --min-age). Имя архива получает
  суффикс, если бэкап запущен дважды в одну секунду.
- deploy/install.sh: offsite-настройки в .env (BACKUP_OFFSITE_TARGET,
  BACKUP_OFFSITE_KEEP_DAYS) отдельным разделом; детектор незаполненных
  плейсхолдеров учитывает цифры в именах (FAIL2BAN_ENABLED); dry-run больше не
  требует прав на /opt/glchat — явность GLCHAT_ETC_DIR фиксируется до
  подключения common.sh, иначе ветка временного каталога была недостижима.
- scripts/test-profiles.sh: сверка таблицы профилей 10.10 и одинакового набора
  PROFILE_* (46 проверок), make profiles-test / ops-test.
- scripts/test-backup.sh: бэкап и offsite на синтетическом инстансе — локальный
  rsync-приёмник, шим rclone, недоступный хост, нечисловой срок (12 проверок),
  make backup-test.
- scripts/test-install.sh: проверка offsite-ключей в .env после установки.
This commit is contained in:
2026-09-22 18:44:50 +03:00
parent af03cfb063
commit 3151327f6e
7 changed files with 407 additions and 6 deletions
+73 -1
View File
@@ -9,6 +9,7 @@ BACKUP_DIR="${GLCHAT_DATA_DIR}/backups"
KEEP_DAYS_DEFAULT=7
log() { printf '[backup] %s\n' "$*"; }
warn() { printf '[backup] внимание: %s\n' "$*" >&2; }
die() {
printf '[backup] ошибка: %s\n' "$*" >&2
exit 1
@@ -20,10 +21,35 @@ set -a && . "$ENV_FILE" && set +a
KEEP_DAYS="${BACKUP_KEEP_DAYS:-$KEEP_DAYS_DEFAULT}"
AGE_RECIPIENT="${AGE_RECIPIENT:-}"
# Выгрузка копии наружу: rsync-цель (user@host:/path) или `rclone:remote:path`
# (AGENT.md 10.8, опциональный offsite). Ошибка выгрузки не отменяет локальный
# бэкап: о ней пишем предупреждение.
OFFSITE_TARGET="${BACKUP_OFFSITE_TARGET:-}"
# Ротация на принимающей стороне — только по явной настройке: пусто значит
# «копим копии, чистим вручную».
OFFSITE_KEEP_DAYS="${BACKUP_OFFSITE_KEEP_DAYS:-}"
case "$OFFSITE_KEEP_DAYS" in
"") ;;
*[!0-9]*)
warn "BACKUP_OFFSITE_KEEP_DAYS='${OFFSITE_KEEP_DAYS}' не число — offsite-ротация выключена"
OFFSITE_KEEP_DAYS=""
;;
esac
STAMP="$(date -u +%Y%m%dT%H%M%SZ)"
# Два запуска в одну секунду не должны затирать друг друга: имя с суффиксом.
SUFFIX=""
suffix_n=1
while [ -e "${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz" ] ||
[ -e "${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz.age" ]; do
SUFFIX="-${suffix_n}"
suffix_n=$((suffix_n + 1))
done
WORK_DIR="$(mktemp -d)"
ARCHIVE="${BACKUP_DIR}/glchat-${STAMP}.tar.gz"
ARCHIVE="${BACKUP_DIR}/glchat-${STAMP}${SUFFIX}.tar.gz"
TARGET="${ARCHIVE}.age"
# ARCHIVE_OR_TARGET — готовый файл бэкапа (с шифрованием или без): его выгружает
# offsite-копирование.
ARCHIVE_OR_TARGET=""
cleanup() { rm -rf "$WORK_DIR"; }
trap cleanup EXIT
@@ -71,13 +97,59 @@ if [ -n "$AGE_RECIPIENT" ]; then
age -r "$AGE_RECIPIENT" -o "$TARGET" "$ARCHIVE" || die "age-шифрование не удалось"
rm -f "$ARCHIVE"
chmod 600 "$TARGET"
ARCHIVE_OR_TARGET="$TARGET"
log "готово: ${TARGET} ($(du -h "$TARGET" | cut -f1))"
else
chmod 600 "$ARCHIVE"
ARCHIVE_OR_TARGET="$ARCHIVE"
log "готово (БЕЗ шифрования, AGE_RECIPIENT не задан): ${ARCHIVE}"
fi
log "ротация: оставляем ${KEEP_DAYS} дн."
find "$BACKUP_DIR" -maxdepth 1 -name 'glchat-*.tar.gz*' -mtime "+${KEEP_DAYS}" -print -delete || true
# Offsite идёт после локальной ротации: наружу уезжает уже актуальный набор.
# Неудача выгрузки не отменяет локальный бэкап — только предупреждение.
if [ -n "$OFFSITE_TARGET" ]; then
log "выгружаем копию: ${OFFSITE_TARGET}"
offsite_ok=0
case "$OFFSITE_TARGET" in
rclone:*)
remote="${OFFSITE_TARGET#rclone:}"
if command -v rclone >/dev/null 2>&1; then
rclone copy "$ARCHIVE_OR_TARGET" "$remote" && offsite_ok=1
else
warn "rclone не установлен: offsite-копия не сделана"
fi
;;
*)
if command -v rsync >/dev/null 2>&1; then
# Цель — каталог на приёмнике; без --delete копии только копятся.
rsync -az --partial "$ARCHIVE_OR_TARGET" "${OFFSITE_TARGET%/}/" && offsite_ok=1
else
warn "rsync не установлен: offsite-копия не сделана"
fi
;;
esac
if [ "$offsite_ok" = "1" ]; then
log "offsite-копия отправлена"
else
warn "offsite-копия не отправлена (локальный бэкап на месте)"
fi
if [ -n "$OFFSITE_KEEP_DAYS" ]; then
case "$OFFSITE_TARGET" in
rclone:*)
if [ "$offsite_ok" = "1" ]; then
rclone delete --min-age "${OFFSITE_KEEP_DAYS}d" "${OFFSITE_TARGET#rclone:}" &&
log "offsite-ротация: удалены копии старше ${OFFSITE_KEEP_DAYS} дн." ||
warn "offsite-ротация не удалась"
fi
;;
*)
warn "offsite-ротация по возрасту поддержана только для rclone — почистите приёмник вручную"
;;
esac
fi
fi
log "бэкапов в каталоге: $(find "$BACKUP_DIR" -maxdepth 1 -name 'glchat-*.tar.gz*' | wc -l)"
+31 -5
View File
@@ -37,6 +37,13 @@ if [ -z "$DEPLOY_DIR" ] || [ ! -f "${DEPLOY_DIR}/lib/common.sh" ]; then
exec bash "${TMP_REPO}/deploy/install.sh" "$@"
fi
# Путь установки задан явно? Спрашиваем до common.sh: он подставляет дефолт
# /opt/glchat, после чего отличить явное значение от дефолтного нельзя.
GLCHAT_ETC_DIR_EXPLICIT=0
if [ -n "${GLCHAT_ETC_DIR:-}" ]; then
GLCHAT_ETC_DIR_EXPLICIT=1
fi
# shellcheck source=lib/common.sh
. "${DEPLOY_DIR}/lib/common.sh"
@@ -111,9 +118,6 @@ GLCHAT_FORCE_OS="${GLCHAT_FORCE_OS:-0}"
GLCHAT_ASSUME_YES="${GLCHAT_ASSUME_YES:-0}"
TARGET_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
if [ "$DRY_RUN" = "1" ] && [ -z "${GLCHAT_ETC_DIR:-}" ]; then
TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)"
fi
GLCHAT_ETC_DIR="$TARGET_DIR"
GLCHAT_DATA_DIR="${TARGET_DIR}/data"
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
@@ -256,6 +260,18 @@ parse_args() {
*) die "неизвестный аргумент: $1 (см. --help)" ;;
esac
done
# Dry-run обязан быть безвредным: если /opt/glchat недоступен для записи и
# путь не задан явно — пишем .env и конфиги во временный каталог.
if [ "$DRY_RUN" = "1" ] && [ "$GLCHAT_ETC_DIR_EXPLICIT" = "0" ] && [ ! -w "$TARGET_DIR" ]; then
local unwritable="$TARGET_DIR"
TARGET_DIR="$(mktemp -d /tmp/glchat-dry-run.XXXXXX)"
GLCHAT_ETC_DIR="$TARGET_DIR"
GLCHAT_DATA_DIR="${TARGET_DIR}/data"
GLCHAT_INSTALL_LOG="${TARGET_DIR}/install.log"
GLCHAT_ENV_FILE="${TARGET_DIR}/.env"
info "каталог ${unwritable} недоступен для записи: dry-run работает во временном ${TARGET_DIR}"
fi
}
interactive_defaults() {
@@ -768,7 +784,6 @@ LIVEKIT_API_KEY=@LIVEKIT_API_KEY@
LIVEKIT_API_SECRET=@LIVEKIT_API_SECRET@
TURN_RELAY_USER=@TURN_RELAY_USER@
TURN_RELAY_PASSWORD=@TURN_RELAY_PASSWORD@
AGE_RECIPIENT=@AGE_RECIPIENT@
# --- профиль производительности ---
SQLITE_READ_POOL=@SQLITE_READ_POOL@
@@ -793,6 +808,13 @@ SHARE_MAX_LIMIT=@SHARE_MAX@
BACKUP_KEEP_DAYS=@BACKUP_KEEP@
SWAP_SIZE_GB=@SWAP_SIZE@
# --- бэкапы: offsite-выгрузка (опция, AGENT.md 10.8) ---
# Цель: rsync (user@host:/path) или rclone:remote:path; пусто — только локально.
BACKUP_OFFSITE_TARGET=@BACKUP_OFFSITE_TARGET@
# Ротация на приёмнике (дни, только rclone); пусто — копим без ротации.
BACKUP_OFFSITE_KEEP_DAYS=@BACKUP_OFFSITE_KEEP_DAYS@
AGE_RECIPIENT=@AGE_RECIPIENT@
# --- функции ---
REGISTRATION_ENABLED=@REGISTRATION_ENABLED@
ANTI_BOT_ENABLED=@ANTI_BOT_ENABLED@
@@ -861,6 +883,9 @@ LIVEKIT_API_KEY=${LIVEKIT_API_KEY}
LIVEKIT_API_SECRET=${LIVEKIT_API_SECRET}
LIVEKIT_PUBLIC_URL=${LIVEKIT_PUBLIC_URL}
# Offsite-бэкапы: цель и срок хранения задаются вручную после установки.
BACKUP_OFFSITE_TARGET=${BACKUP_OFFSITE_TARGET:-}
BACKUP_OFFSITE_KEEP_DAYS=${BACKUP_OFFSITE_KEEP_DAYS:-}
AGE_RECIPIENT=${AGE_RECIPIENT:-}
SQLITE_READ_POOL=${PROFILE_SQLITE_READ_POOL}
SQLITE_CACHE_SIZE=-${PROFILE_SQLITE_CACHE_MB}000
@@ -907,7 +932,8 @@ for line in open(pairs_path, encoding="utf-8"):
for key, value in values.items():
template = template.replace("@" + key + "@", value)
leftovers = [t for t in __import__("re").findall(r"@[A-Z_]{2,}@", template)]
# В именах плейсхолдеров есть цифры (FAIL2BAN_ENABLED) — их тоже ловим.
leftovers = [t for t in __import__("re").findall(r"@[A-Z0-9_]{2,}@", template)]
open(template_path, "w", encoding="utf-8").write(template)
if leftovers:
print("незаполненные плейсхолдеры: " + ", ".join(sorted(set(leftovers))), file=sys.stderr)