#!/usr/bin/env bash # glchat: проверка бэкапа без восстановления (AGENT.md 10.8). # # Расшифровывает архив, распаковывает во временный каталог, проверяет целостность # базы (PRAGMA integrity_check), схему и состав данных, сверяет манифест. # Живые данные не трогает: можно запускать на рабочем инстансе. 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" log() { printf '[verify] %s\n' "$*"; } die() { printf '[verify] ошибка: %s\n' "$*" >&2 exit 1 } ARCHIVE="${1:-}" [ -n "$ARCHIVE" ] || die "использование: glchat verify-backup <файл.tar.gz[.age]>" [ -f "$ARCHIVE" ] || die "файл не найден: $ARCHIVE" if [ -f "$ENV_FILE" ]; then # shellcheck disable=SC1090 set -a && . "$ENV_FILE" && set +a fi WORK_DIR="$(mktemp -d)" cleanup() { rm -rf "$WORK_DIR"; } trap cleanup EXIT # 1. Расшифровка: age-архив требует приватный ключ. case "$ARCHIVE" in *.age) command -v age >/dev/null 2>&1 || die "age не установлен" KEY_FILE="${AGE_KEY_FILE:-${GLCHAT_DATA_DIR}/backups/age-key.txt}" [ -f "$KEY_FILE" ] || die "не найден приватный age-ключ (${KEY_FILE}); укажите AGE_KEY_FILE" log "расшифровываем $(basename "$ARCHIVE")" age -d -i "$KEY_FILE" -o "${WORK_DIR}/backup.tar.gz" "$ARCHIVE" || die "не удалось расшифровать бэкап (проверьте ключ)" ;; *) cp "$ARCHIVE" "${WORK_DIR}/backup.tar.gz" ;; esac # 2. Распаковка и обязательные файлы. log "распаковываем" tar -xzf "${WORK_DIR}/backup.tar.gz" -C "$WORK_DIR" || die "архив повреждён" [ -f "${WORK_DIR}/glchat.db" ] || die "в архиве нет glchat.db — это не бэкап glchat" stats() { printf '%s' "$1"; } DB="${WORK_DIR}/glchat.db" # 3. Целостность базы: sqlite3 есть на хосте или в контейнере приложения. sqlite_query() { local query="$1" if command -v sqlite3 >/dev/null 2>&1; then sqlite3 -readonly "$DB" "$query" return fi if docker ps --format '{{.Names}}' | grep -q '^glchat-app$'; then docker cp "$DB" glchat-app:/tmp/verify-backup.db >/dev/null docker exec glchat-app sh -c "sqlite3 -readonly /tmp/verify-backup.db \"$query\"" docker exec glchat-app rm -f /tmp/verify-backup.db >/dev/null 2>&1 || true return fi die "нет sqlite3 ни на хосте, ни в контейнере glchat-app" } log "проверяем целостность базы" integrity="$(sqlite_query 'PRAGMA integrity_check;')" [ "$integrity" = "ok" ] || die "integrity_check вернул: ${integrity}" log "integrity_check: ok" foreign_keys="$(sqlite_query 'PRAGMA foreign_key_check;' || true)" [ -z "$foreign_keys" ] || die "нарушены внешние ключи: ${foreign_keys}" log "foreign_key_check: чисто" schema_version="$(sqlite_query 'SELECT MAX(version_id) FROM goose_db_version;' 2>/dev/null || echo '?')" users="$(sqlite_query 'SELECT COUNT(*) FROM users WHERE deleted_at IS NULL;')" guilds="$(sqlite_query 'SELECT COUNT(*) FROM guilds;')" messages="$(sqlite_query 'SELECT COUNT(*) FROM messages;')" files="$(sqlite_query 'SELECT COUNT(*) FROM files;')" # 4. Манифест: подсказывает, с какого инстанса снят бэкап. manifest="нет" if [ -f "${WORK_DIR}/MANIFEST.txt" ]; then manifest="$(tr '\n' ' ' <"${WORK_DIR}/MANIFEST.txt")" fi files_on_disk=0 if [ -d "${WORK_DIR}/files" ]; then files_on_disk="$(find "${WORK_DIR}/files" -type f | wc -l | tr -d ' ')" fi printf '\n=== проверка бэкапа ===\n' printf ' архив: %s\n' "$(basename "$ARCHIVE")" printf ' схема: %s\n' "$schema_version" printf ' пользователи: %s\n' "$users" printf ' серверы: %s\n' "$guilds" printf ' сообщения: %s\n' "$messages" printf ' файлов в БД: %s (на диске: %s)\n' "$files" "$files_on_disk" printf ' манифест: %s\n' "$manifest" # 5. Файлы: если в базе есть записи о файлах, каталог не должен быть пустым. if [ "${files:-0}" -gt 0 ] && [ "${files_on_disk:-0}" -eq 0 ]; then die "в базе ${files} файлов, а в архиве нет каталога files — бэкап неполный" fi log "бэкап пригоден к восстановлению"