Решение по 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.
197 lines
11 KiB
Bash
197 lines
11 KiB
Bash
#!/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"
|