#!/usr/bin/env bash # restore.sh — восстановление «Дейла» из снапшотов scripts/backup.sh (этап 7 Task 15, Ruling 8). # # ВАЖНО — порядок применения (БД/тома не должны быть заняты; скрипт сервисы НЕ останавливает): # 1) остановите core и сервисы: # dev: docker compose -f deploy/compose.dev.yml stop core telegram-service ml-service # prod: docker compose --env-file deploy/.env.prod -f deploy/compose.prod.yml stop # 2) bash scripts/restore.sh [all|pg|minio|data] [TS] # TS — метка времени снапшота вида 20260908-021500 (из имени файла backup-…); # без TS берётся самый свежий снапшот Postgres, остальные шаги — тот же TS; # 3) поднимите сервисы обратно (docker compose … start core telegram-service ml-service). # # Шаги (зеркально backup.sh, те же env — см. scripts/deal-backup-lib.sh и техдок §13.9): # pg — БД deal целиком (public + tenant_*): docker-режим — dropdb+createdb, затем pg_restore # из $BACKUP_DIR/pg/backup-.dump (docker exec deal-postgres); при DEAL_PG_HOST — # прямое pg_restore --clean --if-exists поверх существующей БД; # minio — mc mirror $BACKUP_DIR/minio/backup-/ → бакет deal-files (восстановление = загрузка # В MinIO); DEAL_MINIO_MIRROR_REMOVE=1 добавляет --remove (бакет = снимок 1:1); # data — распаковка $BACKUP_DIR/data/backup-.tar.gz в DEAL_DATA_DIR; при DEAL_TAR_VOLUMES — # архивы backup-..tar.gz распаковываются в соответствующие docker volume'ы # (busybox-контейнер). # # ⚠ Восстановление — OVERLAY (дописывание поверх текущих данных), лишние объекты НЕ удаляются: # файл/объект, которого нет в снапшоте, останется. Для бакета MinIO строгий снимок 1:1 — # DEAL_MINIO_MIRROR_REMOVE=1 (mc mirror --remove); для data-каталогов/томов при необходимости # очистите целевой каталог/том вручную ПЕРЕД распаковкой. # # Код возврата: 0 — все запрошенные шаги ok; 1 — сбой. Лог — консоль и # $BACKUP_DIR/logs/restore-YYYYMM.log. Секреты в лог не попадают. # Реальный прогон и тест восстановления — ⚠ Manual (нужен поднятый docker-стек). set -euo pipefail # Общие env-дефолты и хелперы (BACKUP_DIR, resolve_pg_container, select_mc_mode, mc_cmd…) # shellcheck source=deal-backup-lib.sh . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh" # --- Аргументы и валидация ------------------------------------------------------------- say_usage() { say "использование: restore.sh [all|pg|minio|data] [TS]" say " TS — метка снапшота YYYYMMDD-HHMMSS (по умолчанию — самый свежий)" } STEP=${1:-all} TS_ARG=${2:-""} case "$STEP" in all | pg | minio | data) ;; *) say_usage; die "неизвестный шаг '$STEP'" ;; esac case "$TS_ARG" in '') ;; [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) ;; *) say_usage; die "TS должен быть вида YYYYMMDD-HHMMSS (сейчас: '$TS_ARG')" ;; esac mkdir -p "$BACKUP_DIR/logs" latest_pg_ts() { # самая свежая метка среди дампов pg ls -1 "$BACKUP_DIR"/pg/backup-*.dump 2>/dev/null \ | sort | tail -n 1 | sed 's#.*/backup-##; s/\.dump$//' } resolve_pg_ts() { # выбирает TS_ARG: из аргумента или самый свежий pg-снапшот if [ -n "$TS_ARG" ]; then return 0; fi TS_ARG=$(latest_pg_ts) if [ -z "$TS_ARG" ]; then die "в $BACKUP_DIR/pg нет дампов backup-*.dump — сначала выполните scripts/backup.sh" fi log "Выбран снапшот: $TS_ARG" } # --- Шаги -------------------------------------------------------------------------------- restore_pg() { # БД deal целиком (public + tenant_*) local dump resolve_pg_ts dump="$BACKUP_DIR/pg/backup-$TS_ARG.dump" [ -f "$dump" ] || die "дамп не найден: $dump" if [ -n "$DEAL_PG_HOST" ]; then have pg_restore || die "задан DEAL_PG_HOST, но pg_restore не найден в PATH" log "Postgres: прямое pg_restore --clean --if-exists → $DEAL_PG_HOST:$DEAL_PG_PORT/$DEAL_PG_DB" PGPASSWORD="$DEAL_PG_PASSWORD" pg_restore --clean --if-exists --no-owner --exit-on-error \ -h "$DEAL_PG_HOST" -p "$DEAL_PG_PORT" -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" "$dump" \ || die "pg_restore завершился с ошибкой" else docker_ok || die "Docker недоступен и DEAL_PG_HOST не задан — укажите DEAL_PG_HOST… для прямого pg_restore" resolve_pg_container \ || die "контейнер Postgres не найден (искал deal-postgres и compose-сервис 'postgres'); задайте DEAL_PG_CONTAINER или DEAL_PG_HOST" log "Postgres: docker cp $dump → $PG_CONTAINER, dropdb + createdb + pg_restore" docker cp "$(host_docker_path "$dump")" "$PG_CONTAINER:/tmp/deal-restore.dump" || die "docker cp дампа не удался" # БД пересоздаётся целиком — снимок всех схем консистентен (core/сервисы должны быть остановлены!) docker exec "$PG_CONTAINER" dropdb -U "$DEAL_PG_USER" --if-exists "$DEAL_PG_DB" \ || die "dropdb не удался (есть активные подключения? остановите core/сервисы)" docker exec "$PG_CONTAINER" createdb -U "$DEAL_PG_USER" -O "$DEAL_PG_USER" "$DEAL_PG_DB" \ || die "createdb не удался" docker exec "$PG_CONTAINER" pg_restore -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" --no-owner --exit-on-error \ /tmp/deal-restore.dump || die "pg_restore завершился с ошибкой" docker exec "$PG_CONTAINER" rm -f /tmp/deal-restore.dump || true fi log "Postgres: OK (БД $DEAL_PG_DB из $dump)" } restore_minio() { # mc mirror снапшота → бакет deal-files local snap select_mc_mode [ "$MC_MODE" = "skip" ] && return 0 if [ -n "$TS_ARG" ]; then snap="$BACKUP_DIR/minio/backup-$TS_ARG" [ -d "$snap" ] || die "снапшот MinIO не найден: $snap" else snap=$(ls -1d "$BACKUP_DIR"/minio/backup-* 2>/dev/null | sort | tail -n 1) [ -n "$snap" ] || die "в $BACKUP_DIR/minio нет снапшотов backup-*" fi log "MinIO: mc mirror $snap → бакет $DEAL_MINIO_BUCKET (режим mc: $MC_MODE)" if [ "$DEAL_MINIO_MIRROR_REMOVE" = "1" ] || [ "$DEAL_MINIO_MIRROR_REMOVE" = "true" ]; then mc_cmd mirror --overwrite --remove "$snap" "deal/$DEAL_MINIO_BUCKET" \ || die "mc mirror (restore) завершился с ошибкой" else mc_cmd mirror --overwrite "$snap" "deal/$DEAL_MINIO_BUCKET" \ || die "mc mirror (restore) завершился с ошибкой" fi log "MinIO: OK ($snap → $DEAL_MINIO_BUCKET)" } restore_data() { # распаковка архивов данных: docker volume'ы (DEAL_TAR_VOLUMES) или DEAL_DATA_DIR local vol arc if [ -n "$DEAL_TAR_VOLUMES" ]; then docker_ok || die "задан DEAL_TAR_VOLUMES, но Docker недоступен" for vol in $DEAL_TAR_VOLUMES; do docker volume inspect "$vol" >/dev/null 2>&1 \ || die "docker volume '$vol' не существует (docker volume ls)" if [ -n "$TS_ARG" ]; then arc="$BACKUP_DIR/data/backup-$TS_ARG.$vol.tar.gz" [ -f "$arc" ] || arc="" else arc=$(ls -1 "$BACKUP_DIR"/data/backup-*."$vol".tar.gz 2>/dev/null | sort | tail -n 1) fi if [ -z "$arc" ] || [ ! -f "$arc" ]; then log "Data: WARNING — архив тома '$vol' не найден, пропускаю" continue fi log "Data: распаковка $(basename "$arc") → docker volume $vol (busybox)" docker run --rm \ -v "$(host_docker_path "$BACKUP_DIR/data"):/in:ro" -v "$vol:/data" \ busybox tar -xzf "/in/$(basename "$arc")" -C /data \ || die "распаковка в volume '$vol' завершилась с ошибкой" log "Data: OK ($(basename "$arc") → $vol)" done return 0 fi [ -d "$DEAL_DATA_DIR" ] || die "DEAL_DATA_DIR='$DEAL_DATA_DIR' не существует (создайте каталог)" if [ -n "$TS_ARG" ]; then arc="$BACKUP_DIR/data/backup-$TS_ARG.tar.gz" else # только host-архивы вида backup-YYYYMMDD-HHMMSS.tar.gz (архивы томов содержат имя volume) arc=$(ls -1 "$BACKUP_DIR"/data/backup-*.tar.gz 2>/dev/null \ | grep -E 'backup-[0-9]{8}-[0-9]{6}\.tar\.gz$' | sort | tail -n 1) fi [ -n "$arc" ] && [ -f "$arc" ] || die "архив данных не найден в $BACKUP_DIR/data (backup-.tar.gz)" log "Data: распаковка $(basename "$arc") → $DEAL_DATA_DIR" tar -xzf "$arc" -C "$DEAL_DATA_DIR" || die "распаковка архива данных завершилась с ошибкой" log "Data: OK ($(basename "$arc") → $DEAL_DATA_DIR)" } # --- Основной поток ------------------------------------------------------------------------ main() { log "=== Восстановление «Дейла»: старт (шаг: $STEP, BACKUP_DIR=$BACKUP_DIR) ===" case "$STEP" in all) restore_pg || return 1 restore_minio || return 1 restore_data || return 1 ;; pg) restore_pg || return 1 ;; minio) restore_minio || return 1 ;; data) restore_data || return 1 ;; esac log "=== Восстановление завершено (шаг: $STEP, TS: ${TS_ARG:-последний}) ===" } LOG_FILE="$BACKUP_DIR/logs/restore-$(date +%Y%m).log" main "$@" 2>&1 | tee -a "$LOG_FILE" status=$? exit "$status"