Files
LMS/deploy/backup.sh
Vladislav Kopnin 9412d87fed fix: закрыть P0/P1 расхождения с целевой моделью
- академический отпуск: readOnly, запрет submit/start/finish защиты
- черновики лабораторных скрыты от студентов в списке/деталях/по UUID
- удалено действие «Убрать из группы» из UI и API
- импорт: restoreInactiveStatuses, skipped-отчёт, разблокировка архивного аккаунта
- глобальные материалы STUDENTS/TEACHERS с JWT-фильтрацией и сменой аудитории
- LDD-AUTH-005 переведён на unit-evidence, clean check проходит по P0
- backup: безопасный парсинг .env, verify-latest, кавычки cron
- robots/noindex, комментарии без автора, профиль без черновиков
- docs/18-goal-audit.md; baseline 57 claims / 366 тестов / release PASS
2026-10-29 18:58:21 +03:00

295 lines
9.3 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# Резервное копирование и восстановление LMS.
#
# ./deploy/backup.sh backup # БД + файлы, с retention
# ./deploy/backup.sh backup --if-running # пропустить, если стек не запущен
# ./deploy/backup.sh list
# ./deploy/backup.sh verify <dir>
# ./deploy/backup.sh restore <dir> # destructive, требует ввода RESTORE
#
# Переменные:
# LMS_BACKUP_DIR каталог бэкапов (по умолчанию ./backups)
# LMS_BACKUP_KEEP_DAYS сколько дней хранить (по умолчанию 14)
# LMS_COMPOSE_PROJECT имя compose-проекта (по умолчанию lms)
set -euo pipefail
cd "$(dirname "$0")/.."
COMPOSE_FILE="docker-compose.prod.yml"
ENV_FILE=".env.prod"
BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}"
KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}"
PROJECT="${LMS_COMPOSE_PROJECT:-lms}"
die() {
echo "Ошибка: $*" >&2
exit 1
}
info() {
echo ">> $*"
}
usage() {
cat >&2 <<'EOF'
usage:
deploy/backup.sh backup [--if-running]
deploy/backup.sh list
deploy/backup.sh verify-latest
deploy/backup.sh verify <backup-dir>
deploy/backup.sh restore <backup-dir>
Переменные окружения: LMS_BACKUP_DIR, LMS_BACKUP_KEEP_DAYS, LMS_COMPOSE_PROJECT.
EOF
exit 1
}
require_docker() {
command -v docker >/dev/null 2>&1 || die "нужен Docker"
docker compose version >/dev/null 2>&1 || die "нужен Docker Compose v2"
}
load_env() {
[[ -f "$ENV_FILE" ]] || die "не найден $ENV_FILE"
# Не source-им .env.prod: значения с пробелами (например, cron) иначе
# исполняются как команды, а произвольный shell-код из файла опасен.
local key value line
while IFS= read -r line; do
[[ "$line" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]] || continue
key="${line%%=*}"
value="${line#*=}"
value="${value%$'\r'}"
value="${value#\"}"; value="${value%\"}"
value="${value#\'\'}"; value="${value%\'\'}"
case "$key" in
LMS_DB_NAME | LMS_DB_USER | LMS_DB_PASSWORD | LMS_WEB_PORT | \
LMS_BACKUP_DIR | LMS_BACKUP_KEEP_DAYS | LMS_COMPOSE_PROJECT)
export "$key=$value"
;;
esac
done <"$ENV_FILE"
: "${LMS_DB_NAME:=lms}"
: "${LMS_DB_USER:=lms}"
BACKUP_ROOT="${LMS_BACKUP_DIR:-$PWD/backups}"
KEEP_DAYS="${LMS_BACKUP_KEEP_DAYS:-14}"
PROJECT="${LMS_COMPOSE_PROJECT:-lms}"
}
compose() {
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" "$@"
}
postgres_running() {
compose ps --status running --services 2>/dev/null | grep -qx 'postgres'
}
ensure_volumes() {
local volume
for volume in "${PROJECT}_lms_submissions" "${PROJECT}_lms_assets"; do
docker volume inspect "$volume" >/dev/null 2>&1 || die "не найден volume $volume"
done
}
backup() {
local if_running=0
[[ "${1:-}" == "--if-running" ]] && if_running=1
require_docker
load_env
if ! postgres_running; then
if ((if_running)); then
info "PostgreSQL не запущен — backup пропущен"
return 0
fi
die "PostgreSQL не запущен; запустите стек или используйте --if-running"
fi
ensure_volumes
mkdir -p "$BACKUP_ROOT"
chmod 700 "$BACKUP_ROOT"
# Не даём двум backup-процессам писать одновременно.
exec 9>"$BACKUP_ROOT/.backup.lock"
command -v flock >/dev/null 2>&1 || die "нужен flock (util-linux)"
flock -n 9 || die "уже выполняется другой backup"
local timestamp dir tmp
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
dir="$BACKUP_ROOT/lms-$timestamp"
tmp="$dir.tmp"
[[ ! -e "$dir" ]] || die "каталог уже существует: $dir"
rm -rf "$tmp"
mkdir -p "$tmp"
# shellcheck disable=SC2064
trap "rm -rf '$tmp'" EXIT
info "Дамп базы данных..."
compose exec -T postgres sh -c \
'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" --format=custom --no-owner --no-privileges' \
>"$tmp/db.dump"
[[ -s "$tmp/db.dump" ]] || die "pg_dump вернул пустой файл"
info "Архив файлов (решения и материалы)..."
docker run --rm \
-v "${PROJECT}_lms_submissions:/submissions:ro" \
-v "${PROJECT}_lms_assets:/assets:ro" \
-v "$tmp":/backup \
postgres:18 sh -c \
'tar czf /backup/submissions.tar.gz -C /submissions . && tar czf /backup/assets.tar.gz -C /assets .'
gzip -t "$tmp/submissions.tar.gz"
gzip -t "$tmp/assets.tar.gz"
cat >"$tmp/manifest.txt" <<EOF
backup_version=1
created_at=$timestamp
db_name=$LMS_DB_NAME
db_user=$LMS_DB_USER
compose_project=$PROJECT
keep_days=$KEEP_DAYS
EOF
(
cd "$tmp"
sha256sum db.dump submissions.tar.gz assets.tar.gz >SHA256SUMS
)
chmod 700 "$tmp"
mv "$tmp" "$dir"
trap - EXIT
info "Backup готов: $dir"
du -sh "$dir"/* | sed 's/^/ /'
if [[ "$KEEP_DAYS" =~ ^[0-9]+$ ]] && ((KEEP_DAYS > 0)); then
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' \
-mtime +"$KEEP_DAYS" -print -exec rm -rf {} + 2>/dev/null || true
fi
}
list_backups() {
if [[ -f "$ENV_FILE" ]]; then
load_env
fi
if [[ ! -d "$BACKUP_ROOT" ]]; then
echo "Каталог бэкапов не найден: $BACKUP_ROOT"
return 0
fi
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -printf '%f\n' | sort
}
latest_backup() {
if [[ -f "$ENV_FILE" ]]; then
load_env
fi
[[ -d "$BACKUP_ROOT" ]] || return 0
find "$BACKUP_ROOT" -mindepth 1 -maxdepth 1 -type d -name 'lms-*' -print 2>/dev/null | sort | tail -n 1 || true
}
verify_latest_backup() {
local dir
dir="$(latest_backup)"
if [[ -z "$dir" ]]; then
info "Backup ещё не создавался — проверять нечего"
return 0
fi
verify_backup "$dir"
}
verify_backup() {
local dir="${1:-}"
[[ -n "$dir" ]] || usage
[[ -d "$dir" ]] || die "нет каталога: $dir"
dir="$(cd "$dir" && pwd)"
[[ -f "$dir/SHA256SUMS" ]] || die "нет SHA256SUMS"
[[ -s "$dir/db.dump" ]] || die "нет db.dump"
[[ -s "$dir/submissions.tar.gz" ]] || die "нет submissions.tar.gz"
[[ -s "$dir/assets.tar.gz" ]] || die "нет assets.tar.gz"
require_docker
(
cd "$dir"
sha256sum -c SHA256SUMS
)
gzip -t "$dir/submissions.tar.gz"
gzip -t "$dir/assets.tar.gz"
docker run --rm -v "$dir":/backup:ro postgres:18 \
pg_restore --list /backup/db.dump >/dev/null
info "Backup корректен: $dir"
}
restore_backup() {
local dir="${1:-}"
[[ -n "$dir" ]] || usage
[[ -d "$dir" ]] || die "нет каталога: $dir"
dir="$(cd "$dir" && pwd)"
verify_backup "$dir"
load_env
postgres_running || die "PostgreSQL не запущен"
ensure_volumes
echo
echo "Будут восстановлены БД '$LMS_DB_NAME' и файлы из:"
echo " $dir"
echo "Текущие данные будут удалены."
read -r -p "Введите RESTORE для продолжения: " answer
[[ "$answer" == "RESTORE" ]] || die "восстановление отменено"
info "Останавливаю прикладные сервисы..."
compose stop web gateway identity academic progress >/dev/null 2>&1 || true
info "Пересоздаю базу данных..."
compose exec -T postgres sh -c \
'dropdb -U "$POSTGRES_USER" --if-exists "$POSTGRES_DB" && createdb -U "$POSTGRES_USER" "$POSTGRES_DB"'
info "Восстанавливаю БД..."
compose exec -T postgres sh -c \
'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --no-owner --no-privileges' \
<"$dir/db.dump"
info "Восстанавливаю файлы..."
docker run --rm \
-v "${PROJECT}_lms_submissions:/submissions" \
-v "$dir":/backup:ro \
postgres:18 sh -c \
'find /submissions -mindepth 1 -maxdepth 1 -exec rm -rf {} +; tar xzf /backup/submissions.tar.gz -C /submissions'
docker run --rm \
-v "${PROJECT}_lms_assets:/assets" \
-v "$dir":/backup:ro \
postgres:18 sh -c \
'find /assets -mindepth 1 -maxdepth 1 -exec rm -rf {} +; tar xzf /backup/assets.tar.gz -C /assets'
info "Запускаю сервисы..."
compose up -d
local attempt
for attempt in $(seq 1 40); do
if curl -fsS "http://127.0.0.1:${LMS_WEB_PORT:-8090}/actuator/health" >/dev/null 2>&1; then
info "Восстановление завершено, web здоров"
return 0
fi
sleep 2
done
die "сервисы не поднялись за 80 секунд; проверьте ./deploy/deploy.sh logs web"
}
case "${1:-backup}" in
backup)
shift
backup "${1:-}"
;;
list)
list_backups
;;
verify-latest)
verify_latest_backup
;;
verify)
verify_backup "${2:-}"
;;
restore)
restore_backup "${2:-}"
;;
*)
usage
;;
esac