- академический отпуск: 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
295 lines
9.3 KiB
Bash
Executable File
295 lines
9.3 KiB
Bash
Executable File
#!/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
|