Инициализировать репозиторий «Дейл»

Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
This commit is contained in:
Rustam Khalimov
2026-09-11 02:50:17 +03:00
commit 9e07568ddd
1402 changed files with 177470 additions and 0 deletions
+196
View File
@@ -0,0 +1,196 @@
#!/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"