feat(ops): проверка бэкапа и уборка файлов без записи
- glchat verify-backup: расшифровка age, integrity_check, foreign_key_check, схема и состав данных, сверка с манифестом; живые данные не трогает - чистка удаляет файлы на диске, у которых не осталось записи в базе (так оставались вложения удалённых серверов), и пустые подкаталоги; работает через os.Root, чтобы символическая ссылка не вывела за каталог файлов - удаление сервера теперь чистит и содержимое файлов, а не только записи - install.sh пишет значения .env с пробелами в кавычках: файл подключается через `set -a && . .env`, иначе имя главного сервера выполнялось как команда - тесты: Go на уборку файлов без записи (свежие не трогаются, пустые каталоги удаляются)
This commit is contained in:
@@ -18,6 +18,7 @@ glchat — управление инстансом
|
||||
glchat restart [service] перезапуск стека или сервиса
|
||||
glchat backup бэкап БД и файлов (age-шифрование)
|
||||
glchat restore <файл> восстановление из бэкапа
|
||||
glchat verify-backup <файл> проверить бэкап, не трогая данные
|
||||
glchat update [--channel X] обновление (бэкап → pull → up → smoke)
|
||||
glchat reset-password <email> сброс пароля пользователя (фаза 1)
|
||||
glchat make-admin <email> выдать права инстанс-админа (фаза 1)
|
||||
@@ -151,6 +152,17 @@ cmd_version() {
|
||||
[ -f "$ENV_FILE" ] && grep -E '^(APP_VERSION|IMAGE_TAG|DOMAIN|PROFILE)=' "$ENV_FILE" || true
|
||||
}
|
||||
|
||||
# cmd_verify_backup проверяет архив бэкапа: расшифровка, целостность базы и
|
||||
# состав данных (AGENT.md 10.8).
|
||||
cmd_verify_backup() {
|
||||
require_stack
|
||||
[ -n "${1:-}" ] || {
|
||||
echo "использование: glchat verify-backup <файл.tar.gz[.age]>" >&2
|
||||
exit 2
|
||||
}
|
||||
GLCHAT_ETC_DIR="${GLCHAT_ETC_DIR}" bash "${GLCHAT_ETC_DIR}/verify-backup.sh" "$@"
|
||||
}
|
||||
|
||||
cmd_reindex() {
|
||||
require_stack
|
||||
${COMPOSE} exec -T app glchat reindex
|
||||
@@ -182,6 +194,7 @@ main() {
|
||||
profile) cmd_profile "$@" ;;
|
||||
security-check) cmd_security_check "$@" ;;
|
||||
reindex) cmd_reindex "$@" ;;
|
||||
verify-backup) cmd_verify_backup "$@" ;;
|
||||
cleanup) cmd_cleanup "$@" ;;
|
||||
change-domain) cmd_change_domain "$@" ;;
|
||||
uninstall) cmd_uninstall "$@" ;;
|
||||
|
||||
+4
-2
@@ -814,8 +814,10 @@ ENVEOF
|
||||
cat >"$pairs_file" <<PAIREOF
|
||||
DOMAIN=${DOMAIN}
|
||||
FILES_DOMAIN=${FILES_DOMAIN_RESOLVED}
|
||||
INSTANCE_NAME=${INSTANCE_NAME}
|
||||
MAIN_GUILD_NAME=${MAIN_GUILD_NAME}
|
||||
# Значения с пробелами — в кавычках: .env подключается через `set -a && . .env`,
|
||||
# иначе оболочка попытается выполнить имя главного сервера как команду.
|
||||
INSTANCE_NAME="${INSTANCE_NAME}"
|
||||
MAIN_GUILD_NAME="${MAIN_GUILD_NAME}"
|
||||
PROFILE=${PROFILE_RESOLVED}
|
||||
PROXY_MODE=${PROXY_MODE}
|
||||
IMAGE_TAG=${IMAGE_TAG}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/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 "бэкап пригоден к восстановлению"
|
||||
Reference in New Issue
Block a user