3151327f6e
- 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 после установки.
156 lines
6.4 KiB
Bash
Executable File
156 lines
6.4 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# glchat: бэкап БД и файлов с age-шифрованием (AGENT.md 10.8).
|
||
set -euo pipefail
|
||
|
||
GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR:-/opt/glchat}"
|
||
GLCHAT_DATA_DIR="${GLCHAT_DATA_DIR:-${GLCHAT_ETC_DIR}/data}"
|
||
ENV_FILE="${GLCHAT_ETC_DIR}/.env"
|
||
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
|
||
}
|
||
|
||
[ -f "$ENV_FILE" ] || die "не найден ${ENV_FILE}"
|
||
# shellcheck disable=SC1090
|
||
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}${SUFFIX}.tar.gz"
|
||
TARGET="${ARCHIVE}.age"
|
||
# ARCHIVE_OR_TARGET — готовый файл бэкапа (с шифрованием или без): его выгружает
|
||
# offsite-копирование.
|
||
ARCHIVE_OR_TARGET=""
|
||
|
||
cleanup() { rm -rf "$WORK_DIR"; }
|
||
trap cleanup EXIT
|
||
|
||
mkdir -p "$BACKUP_DIR"
|
||
chmod 700 "$BACKUP_DIR"
|
||
|
||
log "дамп SQLite (sqlite3 .backup)"
|
||
if command -v sqlite3 >/dev/null 2>&1; then
|
||
sqlite3 "${GLCHAT_DATA_DIR}/db/glchat.db" ".backup '${WORK_DIR}/glchat.db'" ||
|
||
die "не удалось сделать дамп базы"
|
||
else
|
||
docker exec glchat-app true 2>/dev/null || die "sqlite3 не установлен и контейнер недоступен"
|
||
docker cp glchat-app:/app/data/db/glchat.db "${WORK_DIR}/glchat.db"
|
||
fi
|
||
|
||
log "копируем файлы"
|
||
mkdir -p "${WORK_DIR}/files"
|
||
if [ -d "${GLCHAT_DATA_DIR}/files" ]; then
|
||
if command -v rsync >/dev/null 2>&1; then
|
||
rsync -a --delete "${GLCHAT_DATA_DIR}/files/" "${WORK_DIR}/files/"
|
||
else
|
||
cp -a "${GLCHAT_DATA_DIR}/files/." "${WORK_DIR}/files/"
|
||
fi
|
||
fi
|
||
|
||
log "сохраняем конфиги (без секретов)"
|
||
mkdir -p "${WORK_DIR}/config"
|
||
cp -a "${GLCHAT_ETC_DIR}/compose.yaml" "${WORK_DIR}/config/" 2>/dev/null || true
|
||
cp -a "${GLCHAT_ETC_DIR}/livekit" "${WORK_DIR}/config/" 2>/dev/null || true
|
||
cp -a "${GLCHAT_ETC_DIR}/external-proxy.conf" "${WORK_DIR}/config/" 2>/dev/null || true
|
||
cat >"${WORK_DIR}/MANIFEST.txt" <<EOF
|
||
glchat backup
|
||
created_at=${STAMP}
|
||
domain=${DOMAIN:-unknown}
|
||
version=${APP_VERSION:-unknown}
|
||
profile=${PROFILE:-unknown}
|
||
EOF
|
||
|
||
log "упаковываем"
|
||
tar -czf "$ARCHIVE" -C "$WORK_DIR" .
|
||
|
||
if [ -n "$AGE_RECIPIENT" ]; then
|
||
command -v age >/dev/null 2>&1 || die "age не установлен, а AGE_RECIPIENT задан"
|
||
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)"
|