Files
Deal/scripts/backup.sh
T
Rustam Khalimov 713d554dc2 Нормализовать переводы строк в LF
Решение по TD-STYLE-ANALYZERS: LF — инструменты проекта (Python/Node) пишут LF,
CRLF-.sh не работают на Linux CI (sh scripts/ci.sh), большинство файлов уже были
LF. Добавлен .gitattributes (* text=auto eol=lf, бинарные исключения),
.editorconfig переведён на lf, 1029 файлов конвертированы, git add --renormalize.
Из индекса убраны закравшиеся archive/**/__pycache__/*.pyc.
2026-09-11 19:01:42 +03:00

197 lines
11 KiB
Bash
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
# 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-<TS>/
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"