#!/usr/bin/env bash # backup.sh — ежедневный бэкап «Дейла» (этап 7, Task 15; Ruling 8). # # Покрывает 4 источника данных Ruling 8: # 1) Postgres (БД deal: схемы public + tenant_*) — pg_dump -Fc (custom, сжатие) → # $BACKUP_DIR/pg/backup-YYYYMMDD-HHMMSS.dump. По умолчанию дамп снимается внутри контейнера # (docker exec deal-postgres, локальный socket — пароль не нужен); при заданном DEAL_PG_HOST — # прямое pg_dump с хоста (DEAL_PG_PORT / DEAL_PG_USER / DEAL_PG_PASSWORD). # 2) MinIO (бакет deal-files — вложения карточек) — mc mirror бакета → # $BACKUP_DIR/minio/backup-YYYYMMDD-HHMMSS/ (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR). # Исполнение mc: клиент с хоста, если есть в PATH; иначе — разовый контейнер minio/mc # (DEAL_MC_IMAGE) в docker-сети контейнера MinIO. Секреты передаются env-алиасом MC_HOST_deal, # mc-конфиг на диск не пишется. При Local-хранилище файлов (MinIO не поднят) — DEAL_MINIO_SKIP=1. # 3) файловые данные на хосте — tar относительных каталогов DEAL_TAR_DIRS внутри DEAL_DATA_DIR # (по умолчанию: attachments, telegram_sessions, ml) → # $BACKUP_DIR/data/backup-YYYYMMDD-HHMMSS.tar.gz. Для развёртывания на docker-томах задайте # DEAL_TAR_VOLUMES (список имён volume'ов) — тар выполнит busybox-контейнер (Ruling 8). # 4) retention — удаление снапшотов старше RETENTION_DAYS дней (дата YYYYMMDD из имени файла/каталога, # по умолчанию 14; при ежедневном запуске хранится ~15 копий — эквивалент find -mtime +N). # # Планировщик — ВНЕ контейнера (Ruling 8): скрипт сам ничего не ставит. Запуск — от пользователя # с доступом к docker. Примеры: # cron (ежедневно в 02:00; «0 2 * * *»): # 0 2 * * * /opt/deal/scripts/backup.sh >> /opt/deal/data/backups/cron.log 2>&1 # systemd (аналог): # [Unit] Description=Deal daily backup # [Timer] OnCalendar=*-*-* 02:00:00 # Persistent=true # [Install] WantedBy=timers.target # [Service] (unit backup.service) Type=oneshot; ExecStart=/opt/deal/scripts/backup.sh # prod (VPS): секреты из deploy/.env.prod (MINIO_ROOT_USER/MINIO_ROOT_PASSWORD/DEAL_PG_PASSWORD # читаются скриптом); BACKUP_DIR вынести из data/. compose.prod НЕ публикует порты MinIO — # хостовый mc не достанет MinIO: mc исполняется docker-контейнером (дефолт), endpoint по умолчанию # http://minio:9000 (алиас compose-сервиса; deal-minio в prod-сети нет). Нестандартная схема — # DEAL_MINIO_ENDPOINT: # 0 2 * * * cd /opt/deal && BACKUP_DIR=/var/backups/deal \ # bash scripts/backup.sh >> /var/backups/deal/cron.log 2>&1 # dev: хостовый mc + опубликованный порт 9000 → http://localhost:9000 (дефолт host-режима); # docker-режим в dev — тоже http://minio:9000 (алиас сервиса compose.dev). # # Код возврата: 0 — все шаги ok (MinIO — осознанно пропущен при DEAL_MINIO_SKIP=1); 1 — сбой любого шага. # Лог — консоль и $BACKUP_DIR/logs/backup-YYYYMM.log. Секреты в лог не попадают. # Требования: bash, GNU date (coreutils), docker (шаги БД/томов/MinIO-docker), mc (MinIO с хоста). # Полный список env — scripts/deal-backup-lib.sh и техдок §13.9. Запускать НЕ параллельно. set -euo pipefail # Общие env-дефолты и хелперы (BACKUP_DIR, RETENTION_DAYS, resolve_pg_container, select_mc_mode, mc_cmd…) # shellcheck source=deal-backup-lib.sh . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh" # --- Валидация и подготовка ---------------------------------------------------------- case "$RETENTION_DAYS" in '' | *[!0-9]*) die "RETENTION_DAYS должно быть целым числом дней (сейчас: '$RETENTION_DAYS')" ;; esac TS=$(date +%Y%m%d-%H%M%S) mkdir -p "$BACKUP_DIR" "$BACKUP_DIR/logs" "$BACKUP_DIR/pg" "$BACKUP_DIR/minio" "$BACKUP_DIR/data" # trap-очистка: незавершённые артефакты ТОЛЬКО текущего прогона (имя с суффиксом .part) cleanup() { rm -rf "$BACKUP_DIR/pg/.backup-$TS.dump.part" \ "$BACKUP_DIR/minio/.backup-$TS.part" \ "$BACKUP_DIR/data/backup-$TS"*.part 2>/dev/null || true } trap 'exit 130' INT TERM trap cleanup EXIT # --- Шаги ---------------------------------------------------------------------------- backup_pg() { # Postgres: pg_dump -Fc (public + tenant_*) local part final part="$BACKUP_DIR/pg/.backup-$TS.dump.part" final="$BACKUP_DIR/pg/backup-$TS.dump" if [ -n "$DEAL_PG_HOST" ]; then have pg_dump || die "задан DEAL_PG_HOST='$DEAL_PG_HOST', но pg_dump не найден в PATH" log "Postgres: прямое pg_dump $DEAL_PG_HOST:$DEAL_PG_PORT/$DEAL_PG_DB (user: $DEAL_PG_USER)" PGPASSWORD="$DEAL_PG_PASSWORD" pg_dump -h "$DEAL_PG_HOST" -p "$DEAL_PG_PORT" \ -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" -Fc -f "$part" || die "pg_dump завершился с ошибкой" else docker_ok || die "Docker недоступен и DEAL_PG_HOST не задан: укажите DEAL_PG_HOST/DEAL_PG_PORT/DEAL_PG_USER (и DEAL_PG_PASSWORD) для прямого pg_dump" resolve_pg_container \ || die "контейнер Postgres не найден (искал deal-postgres и compose-сервис 'postgres'); задайте DEAL_PG_CONTAINER или DEAL_PG_HOST" log "Postgres: docker exec $PG_CONTAINER pg_dump -Fc (БД $DEAL_PG_DB)" docker exec "$PG_CONTAINER" pg_dump -U "$DEAL_PG_USER" -d "$DEAL_PG_DB" -Fc >"$part" \ || die "pg_dump (docker exec) завершился с ошибкой" fi [ -s "$part" ] || die "pg_dump не дал данных (файл пуст) — бэкап БД прерван" mv -f "$part" "$final" || die "не удалось переместить $part" log "Postgres: OK $final ($(du -h "$final" | cut -f1))" } backup_minio() { # MinIO: mc mirror бакета deal-files → BACKUP_DIR/minio/backup-/ local part final select_mc_mode [ "$MC_MODE" = "skip" ] && return 0 part="$BACKUP_DIR/minio/.backup-$TS.part" final="$BACKUP_DIR/minio/backup-$TS" rm -rf "$part" # Пустой бакет: mc mirror целевую директорию не создаёт (копировать нечего) — # готовим $part заранее, чтобы снапшот-каталог существовал и mv ниже не упал. mkdir -p "$part" log "MinIO: mc mirror $DEAL_MINIO_BUCKET → $final (режим mc: $MC_MODE)" mc_cmd mirror --overwrite "deal/$DEAL_MINIO_BUCKET" "$part" \ || die "mc mirror завершился с ошибкой" mv -f "$part" "$final" || die "не удалось переместить $part" log "MinIO: OK $final ($(du -sh "$final" | cut -f1))" } backup_data() { # файловые данные: docker-volume'ы (DEAL_TAR_VOLUMES) или host-каталоги DEAL_TAR_DIRS local vol part final d list missing 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)" part="$BACKUP_DIR/data/backup-$TS.$vol.tar.gz.part" final="$BACKUP_DIR/data/backup-$TS.$vol.tar.gz" log "Data: tar docker volume $vol (busybox) → $final" docker run --rm \ -v "$vol:/data:ro" -v "$(host_docker_path "$BACKUP_DIR/data"):/out" \ busybox tar -czf "/out/backup-$TS.$vol.tar.gz.part" -C /data . \ || die "tar docker volume '$vol' завершился с ошибкой" mv -f "$part" "$final" || die "не удалось переместить $part" log "Data: OK $final ($(du -h "$final" | cut -f1))" done return 0 fi [ -d "$DEAL_DATA_DIR" ] || die "DEAL_DATA_DIR='$DEAL_DATA_DIR' не существует" list="" missing="" for d in $DEAL_TAR_DIRS; do if [ -d "$DEAL_DATA_DIR/$d" ]; then list="$list $d" else missing="$missing $d" fi done if [ -n "$missing" ]; then log "Data: WARNING — каталоги не найдены и пропущены:$missing (создайте их или уберите из DEAL_TAR_DIRS)" fi if [ -z "$list" ]; then log "Data: тарировать нечего (все DEAL_TAR_DIRS отсутствуют) — архив не создаю" return 0 fi part="$BACKUP_DIR/data/backup-$TS.tar.gz.part" final="$BACKUP_DIR/data/backup-$TS.tar.gz" # $list без кавычек — намеренно: список относительных путей через пробел tar -czf "$part" -C "$DEAL_DATA_DIR" $list || die "tar каталогов данных завершился с ошибкой" mv -f "$part" "$final" || die "не удалось переместить $part" log "Data: OK $final ($(du -h "$final" | cut -f1)), содержимое:$list" } run_retention() { # удаление снапшотов старше RETENTION_DAYS (дата YYYYMMDD из имени) local cutoff kept deleted kind f base ts ymd cutoff=$(date -d "$RETENTION_DAYS days ago" +%Y%m%d 2>/dev/null) || { log "Retention: WARNING — GNU date ('date -d') недоступен, retention пропущен (удалите старые снапшоты вручную)" return 0 } kept=0 deleted=0 log "Retention: удаляю снапшоты старше $RETENTION_DAYS дней (дата имени < $cutoff)" for kind in pg minio data; do [ -d "$BACKUP_DIR/$kind" ] || continue for f in "$BACKUP_DIR/$kind"/backup-*; do [ -e "$f" ] || continue base=${f##*/} ts=${base#backup-} ymd=$(printf '%s' "$ts" | cut -c1-8) if [ "$ymd" \< "$cutoff" ]; then rm -rf "$f" log "Retention: удалён $kind/$base" deleted=$((deleted + 1)) else kept=$((kept + 1)) fi done done log "Retention: удалено $deleted, оставлено $kept снапшотов" } # --- Основной поток ------------------------------------------------------------------- main() { log "=== Бэкап «Дейла»: старт (BACKUP_DIR=$BACKUP_DIR, retention=$RETENTION_DAYS дн.) ===" backup_pg || die "шаг Postgres завершился с ошибкой" backup_minio || die "шаг MinIO завершился с ошибкой" backup_data || die "шаг Data завершился с ошибкой" run_retention log "=== Бэкап завершён успешно: $BACKUP_DIR ===" } LOG_FILE="$BACKUP_DIR/logs/backup-$(date +%Y%m).log" main "$@" 2>&1 | tee -a "$LOG_FILE" status=$? exit "$status"