#!/usr/bin/env bash # Резервное копирование и восстановление LMS. # # ./deploy/backup.sh backup # БД + файлы, с retention # ./deploy/backup.sh backup --if-running # пропустить, если стек не запущен # ./deploy/backup.sh list # ./deploy/backup.sh verify # ./deploy/backup.sh restore # 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 deploy/backup.sh restore Переменные окружения: 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" <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