#!/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" </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)"