Нормализовать переводы строк в 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.
This commit is contained in:
+196
-196
@@ -1,196 +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"
|
||||
#!/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"
|
||||
|
||||
+15
-15
@@ -1,15 +1,15 @@
|
||||
#!/usr/bin/env sh
|
||||
# build.sh — сборка всех решений Дейла (core + сервисы). CI-шаг «build».
|
||||
set -e
|
||||
|
||||
REPO_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||
|
||||
for SLN in \
|
||||
src/core/Deal.sln \
|
||||
src/telegram-service/Deal.Telegram.sln \
|
||||
src/ai-service/Deal.Ai.sln \
|
||||
src/ml-service/Deal.Ml.sln \
|
||||
src/storage-service/Deal.Storage.sln; do
|
||||
echo "== build: $SLN =="
|
||||
(cd "$REPO_DIR" && dotnet build "$SLN" -v q --nologo)
|
||||
done
|
||||
#!/usr/bin/env sh
|
||||
# build.sh — сборка всех решений Дейла (core + сервисы). CI-шаг «build».
|
||||
set -e
|
||||
|
||||
REPO_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||
|
||||
for SLN in \
|
||||
src/core/Deal.sln \
|
||||
src/telegram-service/Deal.Telegram.sln \
|
||||
src/ai-service/Deal.Ai.sln \
|
||||
src/ml-service/Deal.Ml.sln \
|
||||
src/storage-service/Deal.Storage.sln; do
|
||||
echo "== build: $SLN =="
|
||||
(cd "$REPO_DIR" && dotnet build "$SLN" -v q --nologo)
|
||||
done
|
||||
|
||||
+45
-45
@@ -1,45 +1,45 @@
|
||||
#!/usr/bin/env sh
|
||||
# ci.sh — полный прогон Дейла (BL-CI): сборка всех решений, тесты, скан уязвимых
|
||||
# NuGet-зависимостей, сборка и линтер фронта. Для локального запуска и CI-пайплайна.
|
||||
# sh scripts/ci.sh
|
||||
# Секреты и внешние сервисы не требуются: тесты — юнит (InMemory/fakes), БД/сеть не нужны.
|
||||
set -e
|
||||
|
||||
REPO_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||
|
||||
echo "== CI: сборка =="
|
||||
sh "$REPO_DIR/scripts/build.sh"
|
||||
|
||||
echo
|
||||
echo "== CI: тесты =="
|
||||
sh "$REPO_DIR/scripts/test.sh"
|
||||
|
||||
echo
|
||||
echo "== CI: скан уязвимых NuGet-зависимостей =="
|
||||
for SLN in \
|
||||
src/core/Deal.sln \
|
||||
src/telegram-service/Deal.Telegram.sln \
|
||||
src/ai-service/Deal.Ai.sln \
|
||||
src/ml-service/Deal.Ml.sln \
|
||||
src/storage-service/Deal.Storage.sln; do
|
||||
echo "-- $SLN"
|
||||
OUTPUT=$(cd "$REPO_DIR" && dotnet list "$SLN" package --vulnerable --include-transitive 2>&1 || true)
|
||||
if printf '%s' "$OUTPUT" | grep -qi "has the following vulnerable"; then
|
||||
printf '%s\n' "$OUTPUT"
|
||||
echo "ОШИБКА: уязвимые зависимости в $SLN"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "== CI: сборка фронта =="
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
(cd "$REPO_DIR/src/frontend" && {
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
} && npm run build)
|
||||
else
|
||||
echo " пропуск: npm не установлен (сборка фронта не запущена)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== CI ОК =="
|
||||
#!/usr/bin/env sh
|
||||
# ci.sh — полный прогон Дейла (BL-CI): сборка всех решений, тесты, скан уязвимых
|
||||
# NuGet-зависимостей, сборка и линтер фронта. Для локального запуска и CI-пайплайна.
|
||||
# sh scripts/ci.sh
|
||||
# Секреты и внешние сервисы не требуются: тесты — юнит (InMemory/fakes), БД/сеть не нужны.
|
||||
set -e
|
||||
|
||||
REPO_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||
|
||||
echo "== CI: сборка =="
|
||||
sh "$REPO_DIR/scripts/build.sh"
|
||||
|
||||
echo
|
||||
echo "== CI: тесты =="
|
||||
sh "$REPO_DIR/scripts/test.sh"
|
||||
|
||||
echo
|
||||
echo "== CI: скан уязвимых NuGet-зависимостей =="
|
||||
for SLN in \
|
||||
src/core/Deal.sln \
|
||||
src/telegram-service/Deal.Telegram.sln \
|
||||
src/ai-service/Deal.Ai.sln \
|
||||
src/ml-service/Deal.Ml.sln \
|
||||
src/storage-service/Deal.Storage.sln; do
|
||||
echo "-- $SLN"
|
||||
OUTPUT=$(cd "$REPO_DIR" && dotnet list "$SLN" package --vulnerable --include-transitive 2>&1 || true)
|
||||
if printf '%s' "$OUTPUT" | grep -qi "has the following vulnerable"; then
|
||||
printf '%s\n' "$OUTPUT"
|
||||
echo "ОШИБКА: уязвимые зависимости в $SLN"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
echo "== CI: сборка фронта =="
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
(cd "$REPO_DIR/src/frontend" && {
|
||||
if [ -f package-lock.json ]; then npm ci; else npm install; fi
|
||||
} && npm run build)
|
||||
else
|
||||
echo " пропуск: npm не установлен (сборка фронта не запущена)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== CI ОК =="
|
||||
|
||||
+184
-184
@@ -1,184 +1,184 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deal-backup-lib.sh — общие для scripts/backup.sh и scripts/restore.sh env-дефолты и хелперы
|
||||
# (этап 7 Task 15, Ruling 8). Скрипт НЕ исполняется напрямую: подключается из backup.sh/restore.sh
|
||||
# . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh"
|
||||
#
|
||||
# Параметры — окружение (секреты скрипты НЕ логируют; в лог — только имена файлов и размеры):
|
||||
#
|
||||
# BACKUP_DIR / DEAL_BACKUP_DIR корень бэкапов (по умолчанию <репозиторий>/data/backups)
|
||||
# RETENTION_DAYS / DEAL_RETENTION_DAYS
|
||||
# сколько дней хранить снапшоты (удаление по дате YYYYMMDD в имени; 14)
|
||||
# DEAL_DATA_DIR корень host-каталогов данных (по умолчанию <репозиторий>/data)
|
||||
# DEAL_TAR_DIRS относительные к DEAL_DATA_DIR каталоги для тара (по умолчанию
|
||||
# "attachments ml telegram_sessions"; контейнерный путь каталога
|
||||
# сессий telegram-service — /data/sessions)
|
||||
# DEAL_TAR_VOLUMES если задан (список docker volume'ов через пробел) — файловые данные
|
||||
# берутся из томов busybox-контейнером (Ruling 8), DEAL_TAR_DIRS
|
||||
# игнорируется
|
||||
#
|
||||
# Postgres (docker-режим по умолчанию — exec в контейнер; прямой pg_dump — при заданном DEAL_PG_HOST):
|
||||
# DEAL_PG_HOST / DEAL_PG_PORT (5432) / DEAL_PG_USER (deal) / DEAL_PG_PASSWORD / DEAL_PG_DB (deal)
|
||||
# DEAL_PG_CONTAINER имя контейнера (авто: deal-postgres → compose-сервис 'postgres')
|
||||
#
|
||||
# MinIO / mc (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR; restore — обратно):
|
||||
# DEAL_MINIO_ENDPOINT пусто → host-mc: http://localhost:9000 (dev: опубликованный порт);
|
||||
# docker-mc: http://minio:9000 (алиас compose-сервиса — работает и в
|
||||
# compose.dev, и в compose.prod). prod (compose.prod) порты MinIO НЕ
|
||||
# публикует — хостовый mc его не достанет: нужен docker-режим (дефолт,
|
||||
# endpoint minio:9000) либо достижимый DEAL_MINIO_ENDPOINT.
|
||||
# DEAL_MINIO_ACCESS_KEY (fallback: MINIO_ROOT_USER; иначе deal_minio)
|
||||
# DEAL_MINIO_SECRET_KEY (fallback: MINIO_ROOT_PASSWORD; иначе deal_minio_secret)
|
||||
# DEAL_MINIO_BUCKET (deal-files)
|
||||
# DEAL_MINIO_SKIP 1 → шаг MinIO пропускается (Local-хранилище без MinIO)
|
||||
# DEAL_MINIO_MIRROR_REMOVE restore: 1 → mc mirror --remove (бакет = снимок 1:1)
|
||||
# DEAL_MC_IMAGE docker-режим mc (minio/mc; в проде зафиксируйте тег, напр.
|
||||
# minio/mc:RELEASE.2025-04-16T15-23-31Z)
|
||||
# DEAL_MC_CONTAINER контейнер MinIO для сети (авто: deal-minio → compose-сервис 'minio')
|
||||
set -u
|
||||
|
||||
# --- Корень репозитория и каталоги -----------------------------------------------
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
|
||||
BACKUP_DIR=${BACKUP_DIR:-${DEAL_BACKUP_DIR:-"$REPO_DIR/data/backups"}}
|
||||
RETENTION_DAYS=${RETENTION_DAYS:-${DEAL_RETENTION_DAYS:-14}}
|
||||
DEAL_DATA_DIR=${DEAL_DATA_DIR:-"$REPO_DIR/data"}
|
||||
DEAL_TAR_DIRS=${DEAL_TAR_DIRS:-"attachments ml telegram_sessions"}
|
||||
DEAL_TAR_VOLUMES=${DEAL_TAR_VOLUMES:-""}
|
||||
|
||||
# --- Postgres ----------------------------------------------------------------------
|
||||
DEAL_PG_DB=${DEAL_PG_DB:-deal}
|
||||
DEAL_PG_USER=${DEAL_PG_USER:-deal}
|
||||
DEAL_PG_HOST=${DEAL_PG_HOST:-""}
|
||||
DEAL_PG_PORT=${DEAL_PG_PORT:-5432}
|
||||
DEAL_PG_PASSWORD=${DEAL_PG_PASSWORD:-""}
|
||||
DEAL_PG_CONTAINER=${DEAL_PG_CONTAINER:-""}
|
||||
|
||||
# --- MinIO / mc --------------------------------------------------------------------
|
||||
DEAL_MINIO_BUCKET=${DEAL_MINIO_BUCKET:-deal-files}
|
||||
DEAL_MINIO_ENDPOINT=${DEAL_MINIO_ENDPOINT:-""}
|
||||
DEAL_MINIO_ACCESS_KEY=${DEAL_MINIO_ACCESS_KEY:-${MINIO_ROOT_USER:-deal_minio}}
|
||||
DEAL_MINIO_SECRET_KEY=${DEAL_MINIO_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-deal_minio_secret}}
|
||||
DEAL_MINIO_SKIP=${DEAL_MINIO_SKIP:-0}
|
||||
DEAL_MINIO_MIRROR_REMOVE=${DEAL_MINIO_MIRROR_REMOVE:-0}
|
||||
DEAL_MC_IMAGE=${DEAL_MC_IMAGE:-minio/mc}
|
||||
DEAL_MC_CONTAINER=${DEAL_MC_CONTAINER:-""}
|
||||
|
||||
# Найденные рантайм-значения (заполняют функции ниже)
|
||||
PG_CONTAINER=""
|
||||
MINIO_CONTAINER=""
|
||||
MC_MODE="" # skip | host (клиент mc) | docker (разовый контейнер minio/mc)
|
||||
MC_ENDPOINT="$DEAL_MINIO_ENDPOINT"
|
||||
|
||||
# --- Утилиты -----------------------------------------------------------------------
|
||||
say() { printf '%s\n' "$*"; }
|
||||
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; }
|
||||
die() { log "ОШИБКА: $*"; exit 1; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
docker_ok() { docker info >/dev/null 2>&1; }
|
||||
|
||||
# --- Windows (Git Bash/MSYS): docker не понимает /c/... пути и ломает контейнерные
|
||||
# /out, /in (MSYS конвертирует их в C:/Program Files/Git/...). Отключаем конвертацию
|
||||
# аргументов и переводим host-пути в Windows-вид (C:/...) для -v/-cp. На Linux хелпер
|
||||
# возвращает путь как есть — поведение не меняется.
|
||||
if uname -s 2>/dev/null | grep -qiE 'mingw|msys|cygwin'; then
|
||||
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
|
||||
host_docker_path() { cygpath -m "$1"; }
|
||||
else
|
||||
host_docker_path() { printf '%s' "$1"; }
|
||||
fi
|
||||
|
||||
container_running() { # имя контейнера → 0, если запущен
|
||||
[ -n "${1:-}" ] && [ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null)" = "true" ]
|
||||
}
|
||||
|
||||
compose_container() { # метка com.docker.compose.service → имя running-контейнера (или пусто)
|
||||
docker ps --filter "label=com.docker.compose.service=$1" --format '{{.Names}}' 2>/dev/null | head -n 1 || true
|
||||
}
|
||||
|
||||
# --- Поиск контейнеров Postgres / MinIO --------------------------------------------
|
||||
resolve_pg_container() {
|
||||
local c
|
||||
if [ -n "$DEAL_PG_CONTAINER" ]; then
|
||||
container_running "$DEAL_PG_CONTAINER" \
|
||||
|| die "контейнер DEAL_PG_CONTAINER='$DEAL_PG_CONTAINER' не найден/не запущен"
|
||||
PG_CONTAINER="$DEAL_PG_CONTAINER"
|
||||
return 0
|
||||
fi
|
||||
if container_running deal-postgres; then PG_CONTAINER=deal-postgres; return 0; fi
|
||||
c=$(compose_container postgres)
|
||||
if [ -n "$c" ] && container_running "$c"; then PG_CONTAINER="$c"; return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_minio_container() {
|
||||
local c
|
||||
if [ -n "$DEAL_MC_CONTAINER" ]; then
|
||||
container_running "$DEAL_MC_CONTAINER" \
|
||||
|| die "контейнер DEAL_MC_CONTAINER='$DEAL_MC_CONTAINER' не найден/не запущен"
|
||||
MINIO_CONTAINER="$DEAL_MC_CONTAINER"
|
||||
return 0
|
||||
fi
|
||||
if container_running deal-minio; then MINIO_CONTAINER=deal-minio; return 0; fi
|
||||
c=$(compose_container minio)
|
||||
if [ -n "$c" ] && container_running "$c"; then MINIO_CONTAINER="$c"; return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
minio_network() { # первая docker-сеть контейнера MinIO (для разового контейнера mc)
|
||||
docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' \
|
||||
"$MINIO_CONTAINER" 2>/dev/null | awk '{ print $1 }'
|
||||
}
|
||||
|
||||
# --- mc: выбор режима и запуск -------------------------------------------------------
|
||||
select_mc_mode() { # заполняет MC_MODE / MC_ENDPOINT
|
||||
if [ "$DEAL_MINIO_SKIP" = "1" ] || [ "$DEAL_MINIO_SKIP" = "true" ]; then
|
||||
MC_MODE=skip
|
||||
elif have mc; then
|
||||
MC_MODE=host
|
||||
[ -n "$MC_ENDPOINT" ] || MC_ENDPOINT=http://localhost:9000
|
||||
elif docker_ok; then
|
||||
MC_MODE=docker
|
||||
# minio — алиас compose-сервиса: резолвится и в compose.dev (container_name deal-minio), и в
|
||||
# compose.prod (container_name нет, сервис 'minio'); deal-minio:9000 в prod-сети не существует.
|
||||
[ -n "$MC_ENDPOINT" ] || MC_ENDPOINT=http://minio:9000
|
||||
else
|
||||
die "MinIO: клиент mc не найден и docker недоступен — установите mc или задайте DEAL_MINIO_SKIP=1"
|
||||
fi
|
||||
}
|
||||
|
||||
mc_cmd() { # выполняет «mc <аргументы…>» в выбранном режиме; alias 'deal' — только env MC_HOST_deal
|
||||
# (секрет в конфиг mc на диск НЕ пишется; значение env нигде не логируется)
|
||||
# MC_HOST_deal — единый URL http(s)://user:pass@host:port: схему берём из MC_ENDPOINT (если задана),
|
||||
# иначе http. Убираем схему из host:port — иначе URL получает двойную схему и mc его отвергает.
|
||||
local host_value endpoint scheme
|
||||
endpoint="$MC_ENDPOINT"
|
||||
scheme=http
|
||||
case "$endpoint" in
|
||||
https://*) scheme=https; endpoint=${endpoint#https://} ;;
|
||||
http://*) endpoint=${endpoint#http://} ;;
|
||||
esac
|
||||
host_value="$scheme://$DEAL_MINIO_ACCESS_KEY:$DEAL_MINIO_SECRET_KEY@$endpoint"
|
||||
case "$MC_MODE" in
|
||||
skip)
|
||||
log "MinIO: шаг пропущен (DEAL_MINIO_SKIP=1)"
|
||||
return 0
|
||||
;;
|
||||
host)
|
||||
MC_HOST_deal="$host_value" mc "$@"
|
||||
;;
|
||||
docker)
|
||||
resolve_minio_container \
|
||||
|| die "MinIO: контейнер не найден (искал deal-minio и compose-сервис 'minio') — поднимите стек, задайте DEAL_MINIO_ENDPOINT или DEAL_MINIO_SKIP=1"
|
||||
log "MinIO: разовый контейнер $DEAL_MC_IMAGE (сеть: $(minio_network), бакет $DEAL_MINIO_BUCKET)"
|
||||
docker run --rm -i --network "$(minio_network)" \
|
||||
-e "MC_HOST_deal=$host_value" \
|
||||
-v "$(host_docker_path "$BACKUP_DIR"):$BACKUP_DIR" \
|
||||
"$DEAL_MC_IMAGE" "$@"
|
||||
;;
|
||||
*)
|
||||
die "MinIO: режим mc не выбран (MC_MODE='$MC_MODE') — вызовите select_mc_mode"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# deal-backup-lib.sh — общие для scripts/backup.sh и scripts/restore.sh env-дефолты и хелперы
|
||||
# (этап 7 Task 15, Ruling 8). Скрипт НЕ исполняется напрямую: подключается из backup.sh/restore.sh
|
||||
# . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/deal-backup-lib.sh"
|
||||
#
|
||||
# Параметры — окружение (секреты скрипты НЕ логируют; в лог — только имена файлов и размеры):
|
||||
#
|
||||
# BACKUP_DIR / DEAL_BACKUP_DIR корень бэкапов (по умолчанию <репозиторий>/data/backups)
|
||||
# RETENTION_DAYS / DEAL_RETENTION_DAYS
|
||||
# сколько дней хранить снапшоты (удаление по дате YYYYMMDD в имени; 14)
|
||||
# DEAL_DATA_DIR корень host-каталогов данных (по умолчанию <репозиторий>/data)
|
||||
# DEAL_TAR_DIRS относительные к DEAL_DATA_DIR каталоги для тара (по умолчанию
|
||||
# "attachments ml telegram_sessions"; контейнерный путь каталога
|
||||
# сессий telegram-service — /data/sessions)
|
||||
# DEAL_TAR_VOLUMES если задан (список docker volume'ов через пробел) — файловые данные
|
||||
# берутся из томов busybox-контейнером (Ruling 8), DEAL_TAR_DIRS
|
||||
# игнорируется
|
||||
#
|
||||
# Postgres (docker-режим по умолчанию — exec в контейнер; прямой pg_dump — при заданном DEAL_PG_HOST):
|
||||
# DEAL_PG_HOST / DEAL_PG_PORT (5432) / DEAL_PG_USER (deal) / DEAL_PG_PASSWORD / DEAL_PG_DB (deal)
|
||||
# DEAL_PG_CONTAINER имя контейнера (авто: deal-postgres → compose-сервис 'postgres')
|
||||
#
|
||||
# MinIO / mc (бэкап = выгрузка ИЗ MinIO в BACKUP_DIR; restore — обратно):
|
||||
# DEAL_MINIO_ENDPOINT пусто → host-mc: http://localhost:9000 (dev: опубликованный порт);
|
||||
# docker-mc: http://minio:9000 (алиас compose-сервиса — работает и в
|
||||
# compose.dev, и в compose.prod). prod (compose.prod) порты MinIO НЕ
|
||||
# публикует — хостовый mc его не достанет: нужен docker-режим (дефолт,
|
||||
# endpoint minio:9000) либо достижимый DEAL_MINIO_ENDPOINT.
|
||||
# DEAL_MINIO_ACCESS_KEY (fallback: MINIO_ROOT_USER; иначе deal_minio)
|
||||
# DEAL_MINIO_SECRET_KEY (fallback: MINIO_ROOT_PASSWORD; иначе deal_minio_secret)
|
||||
# DEAL_MINIO_BUCKET (deal-files)
|
||||
# DEAL_MINIO_SKIP 1 → шаг MinIO пропускается (Local-хранилище без MinIO)
|
||||
# DEAL_MINIO_MIRROR_REMOVE restore: 1 → mc mirror --remove (бакет = снимок 1:1)
|
||||
# DEAL_MC_IMAGE docker-режим mc (minio/mc; в проде зафиксируйте тег, напр.
|
||||
# minio/mc:RELEASE.2025-04-16T15-23-31Z)
|
||||
# DEAL_MC_CONTAINER контейнер MinIO для сети (авто: deal-minio → compose-сервис 'minio')
|
||||
set -u
|
||||
|
||||
# --- Корень репозитория и каталоги -----------------------------------------------
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
REPO_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
|
||||
BACKUP_DIR=${BACKUP_DIR:-${DEAL_BACKUP_DIR:-"$REPO_DIR/data/backups"}}
|
||||
RETENTION_DAYS=${RETENTION_DAYS:-${DEAL_RETENTION_DAYS:-14}}
|
||||
DEAL_DATA_DIR=${DEAL_DATA_DIR:-"$REPO_DIR/data"}
|
||||
DEAL_TAR_DIRS=${DEAL_TAR_DIRS:-"attachments ml telegram_sessions"}
|
||||
DEAL_TAR_VOLUMES=${DEAL_TAR_VOLUMES:-""}
|
||||
|
||||
# --- Postgres ----------------------------------------------------------------------
|
||||
DEAL_PG_DB=${DEAL_PG_DB:-deal}
|
||||
DEAL_PG_USER=${DEAL_PG_USER:-deal}
|
||||
DEAL_PG_HOST=${DEAL_PG_HOST:-""}
|
||||
DEAL_PG_PORT=${DEAL_PG_PORT:-5432}
|
||||
DEAL_PG_PASSWORD=${DEAL_PG_PASSWORD:-""}
|
||||
DEAL_PG_CONTAINER=${DEAL_PG_CONTAINER:-""}
|
||||
|
||||
# --- MinIO / mc --------------------------------------------------------------------
|
||||
DEAL_MINIO_BUCKET=${DEAL_MINIO_BUCKET:-deal-files}
|
||||
DEAL_MINIO_ENDPOINT=${DEAL_MINIO_ENDPOINT:-""}
|
||||
DEAL_MINIO_ACCESS_KEY=${DEAL_MINIO_ACCESS_KEY:-${MINIO_ROOT_USER:-deal_minio}}
|
||||
DEAL_MINIO_SECRET_KEY=${DEAL_MINIO_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-deal_minio_secret}}
|
||||
DEAL_MINIO_SKIP=${DEAL_MINIO_SKIP:-0}
|
||||
DEAL_MINIO_MIRROR_REMOVE=${DEAL_MINIO_MIRROR_REMOVE:-0}
|
||||
DEAL_MC_IMAGE=${DEAL_MC_IMAGE:-minio/mc}
|
||||
DEAL_MC_CONTAINER=${DEAL_MC_CONTAINER:-""}
|
||||
|
||||
# Найденные рантайм-значения (заполняют функции ниже)
|
||||
PG_CONTAINER=""
|
||||
MINIO_CONTAINER=""
|
||||
MC_MODE="" # skip | host (клиент mc) | docker (разовый контейнер minio/mc)
|
||||
MC_ENDPOINT="$DEAL_MINIO_ENDPOINT"
|
||||
|
||||
# --- Утилиты -----------------------------------------------------------------------
|
||||
say() { printf '%s\n' "$*"; }
|
||||
log() { printf '[%s] %s\n' "$(date '+%F %T')" "$*"; }
|
||||
die() { log "ОШИБКА: $*"; exit 1; }
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
docker_ok() { docker info >/dev/null 2>&1; }
|
||||
|
||||
# --- Windows (Git Bash/MSYS): docker не понимает /c/... пути и ломает контейнерные
|
||||
# /out, /in (MSYS конвертирует их в C:/Program Files/Git/...). Отключаем конвертацию
|
||||
# аргументов и переводим host-пути в Windows-вид (C:/...) для -v/-cp. На Linux хелпер
|
||||
# возвращает путь как есть — поведение не меняется.
|
||||
if uname -s 2>/dev/null | grep -qiE 'mingw|msys|cygwin'; then
|
||||
export MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*'
|
||||
host_docker_path() { cygpath -m "$1"; }
|
||||
else
|
||||
host_docker_path() { printf '%s' "$1"; }
|
||||
fi
|
||||
|
||||
container_running() { # имя контейнера → 0, если запущен
|
||||
[ -n "${1:-}" ] && [ "$(docker inspect -f '{{.State.Running}}' "$1" 2>/dev/null)" = "true" ]
|
||||
}
|
||||
|
||||
compose_container() { # метка com.docker.compose.service → имя running-контейнера (или пусто)
|
||||
docker ps --filter "label=com.docker.compose.service=$1" --format '{{.Names}}' 2>/dev/null | head -n 1 || true
|
||||
}
|
||||
|
||||
# --- Поиск контейнеров Postgres / MinIO --------------------------------------------
|
||||
resolve_pg_container() {
|
||||
local c
|
||||
if [ -n "$DEAL_PG_CONTAINER" ]; then
|
||||
container_running "$DEAL_PG_CONTAINER" \
|
||||
|| die "контейнер DEAL_PG_CONTAINER='$DEAL_PG_CONTAINER' не найден/не запущен"
|
||||
PG_CONTAINER="$DEAL_PG_CONTAINER"
|
||||
return 0
|
||||
fi
|
||||
if container_running deal-postgres; then PG_CONTAINER=deal-postgres; return 0; fi
|
||||
c=$(compose_container postgres)
|
||||
if [ -n "$c" ] && container_running "$c"; then PG_CONTAINER="$c"; return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
resolve_minio_container() {
|
||||
local c
|
||||
if [ -n "$DEAL_MC_CONTAINER" ]; then
|
||||
container_running "$DEAL_MC_CONTAINER" \
|
||||
|| die "контейнер DEAL_MC_CONTAINER='$DEAL_MC_CONTAINER' не найден/не запущен"
|
||||
MINIO_CONTAINER="$DEAL_MC_CONTAINER"
|
||||
return 0
|
||||
fi
|
||||
if container_running deal-minio; then MINIO_CONTAINER=deal-minio; return 0; fi
|
||||
c=$(compose_container minio)
|
||||
if [ -n "$c" ] && container_running "$c"; then MINIO_CONTAINER="$c"; return 0; fi
|
||||
return 1
|
||||
}
|
||||
|
||||
minio_network() { # первая docker-сеть контейнера MinIO (для разового контейнера mc)
|
||||
docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' \
|
||||
"$MINIO_CONTAINER" 2>/dev/null | awk '{ print $1 }'
|
||||
}
|
||||
|
||||
# --- mc: выбор режима и запуск -------------------------------------------------------
|
||||
select_mc_mode() { # заполняет MC_MODE / MC_ENDPOINT
|
||||
if [ "$DEAL_MINIO_SKIP" = "1" ] || [ "$DEAL_MINIO_SKIP" = "true" ]; then
|
||||
MC_MODE=skip
|
||||
elif have mc; then
|
||||
MC_MODE=host
|
||||
[ -n "$MC_ENDPOINT" ] || MC_ENDPOINT=http://localhost:9000
|
||||
elif docker_ok; then
|
||||
MC_MODE=docker
|
||||
# minio — алиас compose-сервиса: резолвится и в compose.dev (container_name deal-minio), и в
|
||||
# compose.prod (container_name нет, сервис 'minio'); deal-minio:9000 в prod-сети не существует.
|
||||
[ -n "$MC_ENDPOINT" ] || MC_ENDPOINT=http://minio:9000
|
||||
else
|
||||
die "MinIO: клиент mc не найден и docker недоступен — установите mc или задайте DEAL_MINIO_SKIP=1"
|
||||
fi
|
||||
}
|
||||
|
||||
mc_cmd() { # выполняет «mc <аргументы…>» в выбранном режиме; alias 'deal' — только env MC_HOST_deal
|
||||
# (секрет в конфиг mc на диск НЕ пишется; значение env нигде не логируется)
|
||||
# MC_HOST_deal — единый URL http(s)://user:pass@host:port: схему берём из MC_ENDPOINT (если задана),
|
||||
# иначе http. Убираем схему из host:port — иначе URL получает двойную схему и mc его отвергает.
|
||||
local host_value endpoint scheme
|
||||
endpoint="$MC_ENDPOINT"
|
||||
scheme=http
|
||||
case "$endpoint" in
|
||||
https://*) scheme=https; endpoint=${endpoint#https://} ;;
|
||||
http://*) endpoint=${endpoint#http://} ;;
|
||||
esac
|
||||
host_value="$scheme://$DEAL_MINIO_ACCESS_KEY:$DEAL_MINIO_SECRET_KEY@$endpoint"
|
||||
case "$MC_MODE" in
|
||||
skip)
|
||||
log "MinIO: шаг пропущен (DEAL_MINIO_SKIP=1)"
|
||||
return 0
|
||||
;;
|
||||
host)
|
||||
MC_HOST_deal="$host_value" mc "$@"
|
||||
;;
|
||||
docker)
|
||||
resolve_minio_container \
|
||||
|| die "MinIO: контейнер не найден (искал deal-minio и compose-сервис 'minio') — поднимите стек, задайте DEAL_MINIO_ENDPOINT или DEAL_MINIO_SKIP=1"
|
||||
log "MinIO: разовый контейнер $DEAL_MC_IMAGE (сеть: $(minio_network), бакет $DEAL_MINIO_BUCKET)"
|
||||
docker run --rm -i --network "$(minio_network)" \
|
||||
-e "MC_HOST_deal=$host_value" \
|
||||
-v "$(host_docker_path "$BACKUP_DIR"):$BACKUP_DIR" \
|
||||
"$DEAL_MC_IMAGE" "$@"
|
||||
;;
|
||||
*)
|
||||
die "MinIO: режим mc не выбран (MC_MODE='$MC_MODE') — вызовите select_mc_mode"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
+190
-190
@@ -1,190 +1,190 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Заменить дублирующий <summary> реализации на <inheritdoc/> (код-стайл Дейла).
|
||||
|
||||
Консервативно:
|
||||
* собираются тексты <summary> членов интерфейсов (блоки, состоящие только из <summary>);
|
||||
* в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// <inheritdoc />`;
|
||||
* блоки с <param>/<returns>/<remarks>/... и summary самих типов не трогаются.
|
||||
|
||||
Запуск: python scripts/dedup_summary_inheritdoc.py [--apply]
|
||||
Без --apply — dry-run со счётчиками.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
ROOTS = [
|
||||
REPO / "src" / "core",
|
||||
REPO / "src" / "telegram-service",
|
||||
REPO / "src" / "ai-service",
|
||||
REPO / "src" / "ml-service",
|
||||
REPO / "src" / "storage-service",
|
||||
REPO / "src" / "grpc-hosting",
|
||||
]
|
||||
|
||||
INTERFACE_RE = re.compile(
|
||||
r"^\s*(?:public|internal|protected|private)?\s*"
|
||||
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*interface\s+\w+"
|
||||
)
|
||||
TYPE_BASE_RE = re.compile(
|
||||
r"^\s*(?:public|internal|protected|private)?\s*"
|
||||
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*"
|
||||
r"(?:class|struct|record)\s+\w+[^{;]*:\s*(.+?)(?:\{|$)"
|
||||
)
|
||||
IFACE_NAME_RE = re.compile(r"\bI[A-Z]\w*")
|
||||
EXTRA_TAG_RE = re.compile(r"<(?:param|typeparam|returns|remarks|exception|example|value)\b", re.IGNORECASE)
|
||||
SUMMARY_OPEN_RE = re.compile(r"<summary>", re.IGNORECASE)
|
||||
SUMMARY_CLOSE_RE = re.compile(r"</summary>", re.IGNORECASE)
|
||||
|
||||
|
||||
def iter_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for root in ROOTS:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*.cs"):
|
||||
parts = set(path.parts)
|
||||
if "obj" in parts or "bin" in parts:
|
||||
continue
|
||||
if "tests" in path.as_posix().lower():
|
||||
continue
|
||||
files.append(path)
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def normalize(block: list[str]) -> str:
|
||||
text = " ".join(line.strip().lstrip("/").strip() for line in block)
|
||||
text = SUMMARY_CLOSE_RE.sub("", SUMMARY_OPEN_RE.sub("", text))
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def is_summary_only(block: list[str]) -> bool:
|
||||
joined = "\n".join(block)
|
||||
return (
|
||||
bool(SUMMARY_OPEN_RE.search(joined))
|
||||
and bool(SUMMARY_CLOSE_RE.search(joined))
|
||||
and not EXTRA_TAG_RE.search(joined)
|
||||
)
|
||||
|
||||
|
||||
def depth_snapshot(lines: list[str]) -> list[int]:
|
||||
"""Глубина фигурных скобок перед каждой строкой (приблизительно, без строк/комментариев)."""
|
||||
depths: list[int] = []
|
||||
depth = 0
|
||||
for line in lines:
|
||||
depths.append(depth)
|
||||
depth += line.count("{") - line.count("}")
|
||||
return depths
|
||||
|
||||
|
||||
def type_scopes(lines: list[str]):
|
||||
"""Список (start_line, end_line, kind) для интерфейсов и типов с интерфейсной базой.
|
||||
|
||||
kind: "interface" | "type". Границы — по фигурным скобкам объявления.
|
||||
"""
|
||||
depths = depth_snapshot(lines)
|
||||
scopes: list[tuple[int, int, str]] = []
|
||||
stack: list[tuple[int, str, int]] = [] # (depth, kind, start_line)
|
||||
for idx, line in enumerate(lines):
|
||||
depth_before = depths[idx]
|
||||
if INTERFACE_RE.match(line):
|
||||
stack.append((depth_before, "interface", idx))
|
||||
else:
|
||||
m = TYPE_BASE_RE.match(line)
|
||||
if m and IFACE_NAME_RE.search(m.group(1)):
|
||||
stack.append((depth_before, "type", idx))
|
||||
# закрыть блоки на строке закрывающей скобки (глубина после неё возвращается к уровню объявления)
|
||||
while (
|
||||
stack
|
||||
and depths[idx] <= stack[-1][0]
|
||||
and lines[idx].strip().startswith("}")
|
||||
and idx > stack[-1][2]
|
||||
):
|
||||
depth, kind, start = stack.pop()
|
||||
scopes.append((start, idx, kind))
|
||||
for depth, kind, start in stack:
|
||||
scopes.append((start, len(lines) - 1, kind))
|
||||
return scopes
|
||||
|
||||
|
||||
def containing_kinds(scope_kinds: list[tuple[int, int, str]], line_idx: int, depth: int) -> set[str]:
|
||||
kinds: set[str] = set()
|
||||
for start, end, kind in scope_kinds:
|
||||
if start <= line_idx <= end and start != line_idx:
|
||||
kinds.add(kind)
|
||||
return kinds
|
||||
|
||||
|
||||
def collect_interface_summaries(files: list[Path]) -> set[str]:
|
||||
summaries: set[str] = set()
|
||||
for path in files:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
depths = depth_snapshot(lines)
|
||||
scopes = type_scopes(lines)
|
||||
idx = 0
|
||||
while idx < len(lines):
|
||||
if not lines[idx].lstrip().startswith("///"):
|
||||
idx += 1
|
||||
continue
|
||||
start = idx
|
||||
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
|
||||
idx += 1
|
||||
block = lines[start:idx]
|
||||
kinds = containing_kinds(scopes, start, depths[start])
|
||||
if "interface" in kinds and is_summary_only(block):
|
||||
summaries.add(normalize(block))
|
||||
return summaries
|
||||
|
||||
|
||||
def process_file(path: Path, iface_summaries: set[str]) -> tuple[int, list[str]]:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
depths = depth_snapshot(lines)
|
||||
scopes = type_scopes(lines)
|
||||
result: list[str] = []
|
||||
replaced = 0
|
||||
idx = 0
|
||||
while idx < len(lines):
|
||||
if not lines[idx].lstrip().startswith("///"):
|
||||
result.append(lines[idx])
|
||||
idx += 1
|
||||
continue
|
||||
start = idx
|
||||
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
|
||||
idx += 1
|
||||
block = lines[start:idx]
|
||||
kinds = containing_kinds(scopes, start, depths[start])
|
||||
if "type" in kinds and is_summary_only(block) and normalize(block) in iface_summaries:
|
||||
indent = block[0][: len(block[0]) - len(block[0].lstrip())]
|
||||
result.append(f"{indent}/// <inheritdoc />")
|
||||
replaced += 1
|
||||
else:
|
||||
result.extend(block)
|
||||
return replaced, result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
apply = "--apply" in sys.argv
|
||||
files = iter_files()
|
||||
iface_summaries = collect_interface_summaries(files)
|
||||
total = 0
|
||||
changed = 0
|
||||
for path in files:
|
||||
replaced, new_lines = process_file(path, iface_summaries)
|
||||
if not replaced:
|
||||
continue
|
||||
total += replaced
|
||||
changed += 1
|
||||
if apply:
|
||||
original = path.read_text(encoding="utf-8")
|
||||
text = "\n".join(new_lines) + ("\n" if original.endswith("\n") else "")
|
||||
path.write_text(text, encoding="utf-8")
|
||||
mode = "применено" if apply else "dry-run"
|
||||
print(f"{mode}: замен {total} в {changed} файлах (интерфейсных summary: {len(iface_summaries)})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
#!/usr/bin/env python3
|
||||
"""Заменить дублирующий <summary> реализации на <inheritdoc/> (код-стайл Дейла).
|
||||
|
||||
Консервативно:
|
||||
* собираются тексты <summary> членов интерфейсов (блоки, состоящие только из <summary>);
|
||||
* в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// <inheritdoc />`;
|
||||
* блоки с <param>/<returns>/<remarks>/... и summary самих типов не трогаются.
|
||||
|
||||
Запуск: python scripts/dedup_summary_inheritdoc.py [--apply]
|
||||
Без --apply — dry-run со счётчиками.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO = Path(__file__).resolve().parent.parent
|
||||
ROOTS = [
|
||||
REPO / "src" / "core",
|
||||
REPO / "src" / "telegram-service",
|
||||
REPO / "src" / "ai-service",
|
||||
REPO / "src" / "ml-service",
|
||||
REPO / "src" / "storage-service",
|
||||
REPO / "src" / "grpc-hosting",
|
||||
]
|
||||
|
||||
INTERFACE_RE = re.compile(
|
||||
r"^\s*(?:public|internal|protected|private)?\s*"
|
||||
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*interface\s+\w+"
|
||||
)
|
||||
TYPE_BASE_RE = re.compile(
|
||||
r"^\s*(?:public|internal|protected|private)?\s*"
|
||||
r"(?:static\s+|sealed\s+|abstract\s+|partial\s+|unsafe\s+)*"
|
||||
r"(?:class|struct|record)\s+\w+[^{;]*:\s*(.+?)(?:\{|$)"
|
||||
)
|
||||
IFACE_NAME_RE = re.compile(r"\bI[A-Z]\w*")
|
||||
EXTRA_TAG_RE = re.compile(r"<(?:param|typeparam|returns|remarks|exception|example|value)\b", re.IGNORECASE)
|
||||
SUMMARY_OPEN_RE = re.compile(r"<summary>", re.IGNORECASE)
|
||||
SUMMARY_CLOSE_RE = re.compile(r"</summary>", re.IGNORECASE)
|
||||
|
||||
|
||||
def iter_files() -> list[Path]:
|
||||
files: list[Path] = []
|
||||
for root in ROOTS:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*.cs"):
|
||||
parts = set(path.parts)
|
||||
if "obj" in parts or "bin" in parts:
|
||||
continue
|
||||
if "tests" in path.as_posix().lower():
|
||||
continue
|
||||
files.append(path)
|
||||
return sorted(files)
|
||||
|
||||
|
||||
def normalize(block: list[str]) -> str:
|
||||
text = " ".join(line.strip().lstrip("/").strip() for line in block)
|
||||
text = SUMMARY_CLOSE_RE.sub("", SUMMARY_OPEN_RE.sub("", text))
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
|
||||
def is_summary_only(block: list[str]) -> bool:
|
||||
joined = "\n".join(block)
|
||||
return (
|
||||
bool(SUMMARY_OPEN_RE.search(joined))
|
||||
and bool(SUMMARY_CLOSE_RE.search(joined))
|
||||
and not EXTRA_TAG_RE.search(joined)
|
||||
)
|
||||
|
||||
|
||||
def depth_snapshot(lines: list[str]) -> list[int]:
|
||||
"""Глубина фигурных скобок перед каждой строкой (приблизительно, без строк/комментариев)."""
|
||||
depths: list[int] = []
|
||||
depth = 0
|
||||
for line in lines:
|
||||
depths.append(depth)
|
||||
depth += line.count("{") - line.count("}")
|
||||
return depths
|
||||
|
||||
|
||||
def type_scopes(lines: list[str]):
|
||||
"""Список (start_line, end_line, kind) для интерфейсов и типов с интерфейсной базой.
|
||||
|
||||
kind: "interface" | "type". Границы — по фигурным скобкам объявления.
|
||||
"""
|
||||
depths = depth_snapshot(lines)
|
||||
scopes: list[tuple[int, int, str]] = []
|
||||
stack: list[tuple[int, str, int]] = [] # (depth, kind, start_line)
|
||||
for idx, line in enumerate(lines):
|
||||
depth_before = depths[idx]
|
||||
if INTERFACE_RE.match(line):
|
||||
stack.append((depth_before, "interface", idx))
|
||||
else:
|
||||
m = TYPE_BASE_RE.match(line)
|
||||
if m and IFACE_NAME_RE.search(m.group(1)):
|
||||
stack.append((depth_before, "type", idx))
|
||||
# закрыть блоки на строке закрывающей скобки (глубина после неё возвращается к уровню объявления)
|
||||
while (
|
||||
stack
|
||||
and depths[idx] <= stack[-1][0]
|
||||
and lines[idx].strip().startswith("}")
|
||||
and idx > stack[-1][2]
|
||||
):
|
||||
depth, kind, start = stack.pop()
|
||||
scopes.append((start, idx, kind))
|
||||
for depth, kind, start in stack:
|
||||
scopes.append((start, len(lines) - 1, kind))
|
||||
return scopes
|
||||
|
||||
|
||||
def containing_kinds(scope_kinds: list[tuple[int, int, str]], line_idx: int, depth: int) -> set[str]:
|
||||
kinds: set[str] = set()
|
||||
for start, end, kind in scope_kinds:
|
||||
if start <= line_idx <= end and start != line_idx:
|
||||
kinds.add(kind)
|
||||
return kinds
|
||||
|
||||
|
||||
def collect_interface_summaries(files: list[Path]) -> set[str]:
|
||||
summaries: set[str] = set()
|
||||
for path in files:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
depths = depth_snapshot(lines)
|
||||
scopes = type_scopes(lines)
|
||||
idx = 0
|
||||
while idx < len(lines):
|
||||
if not lines[idx].lstrip().startswith("///"):
|
||||
idx += 1
|
||||
continue
|
||||
start = idx
|
||||
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
|
||||
idx += 1
|
||||
block = lines[start:idx]
|
||||
kinds = containing_kinds(scopes, start, depths[start])
|
||||
if "interface" in kinds and is_summary_only(block):
|
||||
summaries.add(normalize(block))
|
||||
return summaries
|
||||
|
||||
|
||||
def process_file(path: Path, iface_summaries: set[str]) -> tuple[int, list[str]]:
|
||||
lines = path.read_text(encoding="utf-8").splitlines()
|
||||
depths = depth_snapshot(lines)
|
||||
scopes = type_scopes(lines)
|
||||
result: list[str] = []
|
||||
replaced = 0
|
||||
idx = 0
|
||||
while idx < len(lines):
|
||||
if not lines[idx].lstrip().startswith("///"):
|
||||
result.append(lines[idx])
|
||||
idx += 1
|
||||
continue
|
||||
start = idx
|
||||
while idx < len(lines) and lines[idx].lstrip().startswith("///"):
|
||||
idx += 1
|
||||
block = lines[start:idx]
|
||||
kinds = containing_kinds(scopes, start, depths[start])
|
||||
if "type" in kinds and is_summary_only(block) and normalize(block) in iface_summaries:
|
||||
indent = block[0][: len(block[0]) - len(block[0].lstrip())]
|
||||
result.append(f"{indent}/// <inheritdoc />")
|
||||
replaced += 1
|
||||
else:
|
||||
result.extend(block)
|
||||
return replaced, result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
apply = "--apply" in sys.argv
|
||||
files = iter_files()
|
||||
iface_summaries = collect_interface_summaries(files)
|
||||
total = 0
|
||||
changed = 0
|
||||
for path in files:
|
||||
replaced, new_lines = process_file(path, iface_summaries)
|
||||
if not replaced:
|
||||
continue
|
||||
total += replaced
|
||||
changed += 1
|
||||
if apply:
|
||||
original = path.read_text(encoding="utf-8")
|
||||
text = "\n".join(new_lines) + ("\n" if original.endswith("\n") else "")
|
||||
path.write_text(text, encoding="utf-8")
|
||||
mode = "применено" if apply else "dry-run"
|
||||
print(f"{mode}: замен {total} в {changed} файлах (интерфейсных summary: {len(iface_summaries)})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
||||
+216
-216
@@ -1,216 +1,216 @@
|
||||
#!/usr/bin/env sh
|
||||
# dev-smoke.sh — сквозной smoke полного dev-стека Дейла (план Task 20, Ruling 12).
|
||||
#
|
||||
# Поднимает ВЕСЬ стек deploy/compose.dev.yml (postgres+minio+ml/ai/telegram+core) в сквозном
|
||||
# gRPC-режиме (Services__*__UseLocal=false заданы в compose) и проверяет:
|
||||
# 1) health ВСЕХ контейнеров (docker healthchecks: postgres/core/ml/ai/telegram, minio — running);
|
||||
# 2) auth: login admin/admin (дефолт TenantBootstrapService; при переопределённом пароле — правка);
|
||||
# 3) /api/tg/status — живой статус через GrpcTelegramClient (idle-форма: telegram-service без сессии);
|
||||
# 4) POST /api/cards → локальная карточка (планово, c_…);
|
||||
# 5) POST /api/cards/{id}/trash — обучающий сигнал spam 1.0 → строка MlOutbox;
|
||||
# 6) флашер MlOutboxFlushScheduler (10 с) → TrainBatch в ml-service: /api/ml/status показывает
|
||||
# reachable:true, stats.outbox:0 и класс "spam" в модели сервиса (learned ≥ 1).
|
||||
#
|
||||
# Живые проверки Telegram-входа (api_id/api_hash/QR) и реальных LLM-вызовов — НЕ выполняются
|
||||
# (нужны креды; см. техдок §13, «ручные проверки»). Скрипт сам гасит стек: trap → docker compose down
|
||||
# (volumes БЕЗ -v — данные dev сохраняются между прогонами). Запуск: scripts/dev-smoke.sh
|
||||
set -u
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
COMPOSE_FILE="deploy/compose.dev.yml"
|
||||
BASE_URL="http://localhost:5080"
|
||||
|
||||
CONTAINERS="deal-postgres deal-minio deal-telegram-service deal-ai-service deal-ml-service deal-core"
|
||||
UP_TIMEOUT_TICKS=120 # 120 × 5 с = 10 мин максимум на здоровье всех контейнеров
|
||||
TICK_SECONDS=5
|
||||
ML_POLL_TICKS=60 # 60 × 5 с = 5 мин на флаш outbox + refresh статуса сервиса
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
JAR="$WORK/cookies.txt"
|
||||
OUT="$WORK/out.txt"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAILED_NAMES=""
|
||||
|
||||
check() { # имя, ожидание HTTP-кода, [фрагменты...]
|
||||
local name="$1" code="$2"
|
||||
shift 2
|
||||
if grep -q "\[HTTP:$code\]" "$OUT"; then
|
||||
for frag in "$@"; do
|
||||
if ! grep -qF "$frag" "$OUT"; then
|
||||
echo " [FAIL] $name (нет фрагмента: $frag)"
|
||||
cat "$OUT"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_NAMES="$FAILED_NAMES|$name"
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo " [PASS] $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " [FAIL] $name (ожидался HTTP $code)"
|
||||
cat "$OUT"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_NAMES="$FAILED_NAMES|$name"
|
||||
fi
|
||||
}
|
||||
|
||||
container_ok() { # имя → 0: running и (если есть healthcheck) healthy; 1: плох; 2: нет контейнера
|
||||
local name="$1" state health
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null) || return 2
|
||||
[ "$state" = "running" ] || return 1
|
||||
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null)
|
||||
[ "$health" = "none" ] || [ "$health" = "healthy" ] || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== очистка: docker compose down (volumes сохраняются) =="
|
||||
(cd "$REPO_DIR" && docker compose -f "$COMPOSE_FILE" down) 2>/dev/null || true
|
||||
rm -rf "$WORK"
|
||||
echo "== очистка завершена =="
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Docker engine недоступен (Docker Desktop выключен?) — запустите Docker и повторите smoke."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR" || exit 1
|
||||
|
||||
echo "== 0. compose config валиден =="
|
||||
if docker compose -f "$COMPOSE_FILE" config --quiet; then
|
||||
echo " [PASS] config"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " [FAIL] docker compose config (файл невалиден)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 1. подъём стека (первый прогон собирает 4 docker-образа — долго) =="
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build || {
|
||||
echo " [FAIL] docker compose up (см. вывод выше)"
|
||||
exit 1
|
||||
}
|
||||
echo " [ok] docker compose up -d --build завершён"
|
||||
|
||||
echo
|
||||
echo "== 2. ожидание health всех контейнеров =="
|
||||
ALL_OK=""
|
||||
i=0
|
||||
while [ $i -lt $UP_TIMEOUT_TICKS ]; do
|
||||
ALL_OK=1
|
||||
for c in $CONTAINERS; do
|
||||
if ! container_ok "$c"; then ALL_OK=""; break; fi
|
||||
done
|
||||
[ -n "$ALL_OK" ] && break
|
||||
i=$((i + 1))
|
||||
sleep $TICK_SECONDS
|
||||
done
|
||||
|
||||
for c in $CONTAINERS; do
|
||||
if container_ok "$c"; then
|
||||
echo " [PASS] $c готов"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " [FAIL] $c не поднялся/не healthy"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_NAMES="$FAILED_NAMES|$c"
|
||||
fi
|
||||
done
|
||||
if [ -z "$ALL_OK" ]; then
|
||||
echo " подробности:"
|
||||
docker compose -f "$COMPOSE_FILE" ps
|
||||
docker compose -f "$COMPOSE_FILE" logs --tail=40 core
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 3. HTTP-проверки core :5080 =="
|
||||
echo
|
||||
echo "-- 3.1 login admin/admin --"
|
||||
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
|
||||
check "login → 200 ok:true" 200 '"ok":true'
|
||||
|
||||
echo
|
||||
echo "-- 3.2 GET /api/tg/status — живой статус через GrpcTelegramClient (telegram-service без сессии) --"
|
||||
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/status" > "$OUT"
|
||||
# Структурная проверка полей §4.9 (точные значения phase/monitored/keysSet зависят от состояния
|
||||
# volume БД: fresh-стек ожидает idle/monitored:0/keysSet:false; после реального Telegram-входа может
|
||||
# быть ready и ненулевой monitored). Живость gRPC-стека доказывают health контейнеров и флашер ML.
|
||||
check "status — idle-форма/поля §4.9 (200)" 200 \
|
||||
'"phase":"' '"connected":' '"monitored":' '"keysSet":'
|
||||
|
||||
echo
|
||||
echo "-- 3.3 GET /api/containers?space=dashboard → реестр колонок --"
|
||||
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/containers?space=dashboard" > "$OUT"
|
||||
check "containers dashboard → 200, есть inbox" 200 '"id":"inbox"'
|
||||
|
||||
echo
|
||||
echo "-- 3.4 POST /api/cards → локальная карточка (c_…, planned) --"
|
||||
curl -s -m 20 -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/cards" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"SMOKE card","containerId":"planned"}' > "$OUT"
|
||||
check "create card → 200, карточка c_… в planned" 200 '"containerId":"planned"'
|
||||
CARD_ID=$(sed -n 's/.*"id":"\(c_[a-zA-Z0-9_]*\)".*/\1/p' "$OUT" | head -1)
|
||||
if [ -z "$CARD_ID" ]; then
|
||||
echo " [FAIL] id карточки не извлечён из ответа create card"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_NAMES="$FAILED_NAMES|create card id"
|
||||
else
|
||||
echo " карточка: $CARD_ID"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- 3.4a GET /api/cards?containerId=planned → карточка в стадии --"
|
||||
if [ -n "$CARD_ID" ]; then
|
||||
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/cards?containerId=planned" > "$OUT"
|
||||
check "list planned → 200, карточка найдена" 200 "\"id\":\"$CARD_ID\""
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- 3.5 POST /api/cards/$CARD_ID/trash → обучающий сигнал spam (MlOutbox) --"
|
||||
if [ -n "$CARD_ID" ]; then
|
||||
curl -s -m 20 -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/cards/$CARD_ID/trash" > "$OUT"
|
||||
check "trash → 200 ok:true" 200 '"ok":true'
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- 3.6 флашер MlOutbox: ждём TrainBatch → ml-service (outbox:0 + класс spam в модели) --"
|
||||
FLUSHED=""
|
||||
i=0
|
||||
while [ $i -lt $ML_POLL_TICKS ]; do
|
||||
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
|
||||
# reachable — живой Status RPC ml-service; outbox — локальная очередь (убывает после успешного
|
||||
# TrainBatch); "spam" — класс в модели сервиса (появляется после рефреша кэша статуса, 15 с).
|
||||
if grep -q '"reachable":true' "$OUT" && grep -q '"outbox":0' "$OUT" && grep -qF '"spam":' "$OUT"; then
|
||||
FLUSHED=1
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep $TICK_SECONDS
|
||||
done
|
||||
if [ -n "$FLUSHED" ]; then
|
||||
echo " [PASS] ML-флашер: outbox выгружен в ml-service (класс spam обучен)"
|
||||
PASS=$((PASS + 1))
|
||||
cat "$OUT"
|
||||
else
|
||||
echo " [FAIL] ML-флашер: outbox не выгружен / ml-service недоступен за отведённое время"
|
||||
cat "$OUT"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_NAMES="$FAILED_NAMES|ml flusher"
|
||||
docker compose -f "$COMPOSE_FILE" logs --tail=40 core | grep -i "флашер\|outbox\|TrainBatch" || true
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== ИТОГ: PASS=$PASS FAIL=$FAIL =="
|
||||
if [ "$FAIL" = "0" ]; then
|
||||
echo "SMOKE ПРОЙДЕН — полный dev-стек работает по gRPC"
|
||||
exit 0
|
||||
fi
|
||||
echo "Провалы:$FAILED_NAMES"
|
||||
exit 1
|
||||
#!/usr/bin/env sh
|
||||
# dev-smoke.sh — сквозной smoke полного dev-стека Дейла (план Task 20, Ruling 12).
|
||||
#
|
||||
# Поднимает ВЕСЬ стек deploy/compose.dev.yml (postgres+minio+ml/ai/telegram+core) в сквозном
|
||||
# gRPC-режиме (Services__*__UseLocal=false заданы в compose) и проверяет:
|
||||
# 1) health ВСЕХ контейнеров (docker healthchecks: postgres/core/ml/ai/telegram, minio — running);
|
||||
# 2) auth: login admin/admin (дефолт TenantBootstrapService; при переопределённом пароле — правка);
|
||||
# 3) /api/tg/status — живой статус через GrpcTelegramClient (idle-форма: telegram-service без сессии);
|
||||
# 4) POST /api/cards → локальная карточка (планово, c_…);
|
||||
# 5) POST /api/cards/{id}/trash — обучающий сигнал spam 1.0 → строка MlOutbox;
|
||||
# 6) флашер MlOutboxFlushScheduler (10 с) → TrainBatch в ml-service: /api/ml/status показывает
|
||||
# reachable:true, stats.outbox:0 и класс "spam" в модели сервиса (learned ≥ 1).
|
||||
#
|
||||
# Живые проверки Telegram-входа (api_id/api_hash/QR) и реальных LLM-вызовов — НЕ выполняются
|
||||
# (нужны креды; см. техдок §13, «ручные проверки»). Скрипт сам гасит стек: trap → docker compose down
|
||||
# (volumes БЕЗ -v — данные dev сохраняются между прогонами). Запуск: scripts/dev-smoke.sh
|
||||
set -u
|
||||
|
||||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
COMPOSE_FILE="deploy/compose.dev.yml"
|
||||
BASE_URL="http://localhost:5080"
|
||||
|
||||
CONTAINERS="deal-postgres deal-minio deal-telegram-service deal-ai-service deal-ml-service deal-core"
|
||||
UP_TIMEOUT_TICKS=120 # 120 × 5 с = 10 мин максимум на здоровье всех контейнеров
|
||||
TICK_SECONDS=5
|
||||
ML_POLL_TICKS=60 # 60 × 5 с = 5 мин на флаш outbox + refresh статуса сервиса
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
JAR="$WORK/cookies.txt"
|
||||
OUT="$WORK/out.txt"
|
||||
PASS=0
|
||||
FAIL=0
|
||||
FAILED_NAMES=""
|
||||
|
||||
check() { # имя, ожидание HTTP-кода, [фрагменты...]
|
||||
local name="$1" code="$2"
|
||||
shift 2
|
||||
if grep -q "\[HTTP:$code\]" "$OUT"; then
|
||||
for frag in "$@"; do
|
||||
if ! grep -qF "$frag" "$OUT"; then
|
||||
echo " [FAIL] $name (нет фрагмента: $frag)"
|
||||
cat "$OUT"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_NAMES="$FAILED_NAMES|$name"
|
||||
return
|
||||
fi
|
||||
done
|
||||
echo " [PASS] $name"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " [FAIL] $name (ожидался HTTP $code)"
|
||||
cat "$OUT"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_NAMES="$FAILED_NAMES|$name"
|
||||
fi
|
||||
}
|
||||
|
||||
container_ok() { # имя → 0: running и (если есть healthcheck) healthy; 1: плох; 2: нет контейнера
|
||||
local name="$1" state health
|
||||
state=$(docker inspect -f '{{.State.Status}}' "$name" 2>/dev/null) || return 2
|
||||
[ "$state" = "running" ] || return 1
|
||||
health=$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$name" 2>/dev/null)
|
||||
[ "$health" = "none" ] || [ "$health" = "healthy" ] || return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
echo
|
||||
echo "== очистка: docker compose down (volumes сохраняются) =="
|
||||
(cd "$REPO_DIR" && docker compose -f "$COMPOSE_FILE" down) 2>/dev/null || true
|
||||
rm -rf "$WORK"
|
||||
echo "== очистка завершена =="
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
if ! docker info >/dev/null 2>&1; then
|
||||
echo "Docker engine недоступен (Docker Desktop выключен?) — запустите Docker и повторите smoke."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$REPO_DIR" || exit 1
|
||||
|
||||
echo "== 0. compose config валиден =="
|
||||
if docker compose -f "$COMPOSE_FILE" config --quiet; then
|
||||
echo " [PASS] config"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " [FAIL] docker compose config (файл невалиден)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 1. подъём стека (первый прогон собирает 4 docker-образа — долго) =="
|
||||
docker compose -f "$COMPOSE_FILE" up -d --build || {
|
||||
echo " [FAIL] docker compose up (см. вывод выше)"
|
||||
exit 1
|
||||
}
|
||||
echo " [ok] docker compose up -d --build завершён"
|
||||
|
||||
echo
|
||||
echo "== 2. ожидание health всех контейнеров =="
|
||||
ALL_OK=""
|
||||
i=0
|
||||
while [ $i -lt $UP_TIMEOUT_TICKS ]; do
|
||||
ALL_OK=1
|
||||
for c in $CONTAINERS; do
|
||||
if ! container_ok "$c"; then ALL_OK=""; break; fi
|
||||
done
|
||||
[ -n "$ALL_OK" ] && break
|
||||
i=$((i + 1))
|
||||
sleep $TICK_SECONDS
|
||||
done
|
||||
|
||||
for c in $CONTAINERS; do
|
||||
if container_ok "$c"; then
|
||||
echo " [PASS] $c готов"
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
echo " [FAIL] $c не поднялся/не healthy"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_NAMES="$FAILED_NAMES|$c"
|
||||
fi
|
||||
done
|
||||
if [ -z "$ALL_OK" ]; then
|
||||
echo " подробности:"
|
||||
docker compose -f "$COMPOSE_FILE" ps
|
||||
docker compose -f "$COMPOSE_FILE" logs --tail=40 core
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== 3. HTTP-проверки core :5080 =="
|
||||
echo
|
||||
echo "-- 3.1 login admin/admin --"
|
||||
curl -s -m 10 -w "\n[HTTP:%{http_code}]" -c "$JAR" -X POST "$BASE_URL/api/auth/login" \
|
||||
-H "Content-Type: application/json" -d '{"login":"admin","password":"admin"}' > "$OUT"
|
||||
check "login → 200 ok:true" 200 '"ok":true'
|
||||
|
||||
echo
|
||||
echo "-- 3.2 GET /api/tg/status — живой статус через GrpcTelegramClient (telegram-service без сессии) --"
|
||||
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/tg/status" > "$OUT"
|
||||
# Структурная проверка полей §4.9 (точные значения phase/monitored/keysSet зависят от состояния
|
||||
# volume БД: fresh-стек ожидает idle/monitored:0/keysSet:false; после реального Telegram-входа может
|
||||
# быть ready и ненулевой monitored). Живость gRPC-стека доказывают health контейнеров и флашер ML.
|
||||
check "status — idle-форма/поля §4.9 (200)" 200 \
|
||||
'"phase":"' '"connected":' '"monitored":' '"keysSet":'
|
||||
|
||||
echo
|
||||
echo "-- 3.3 GET /api/containers?space=dashboard → реестр колонок --"
|
||||
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/containers?space=dashboard" > "$OUT"
|
||||
check "containers dashboard → 200, есть inbox" 200 '"id":"inbox"'
|
||||
|
||||
echo
|
||||
echo "-- 3.4 POST /api/cards → локальная карточка (c_…, planned) --"
|
||||
curl -s -m 20 -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/cards" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"SMOKE card","containerId":"planned"}' > "$OUT"
|
||||
check "create card → 200, карточка c_… в planned" 200 '"containerId":"planned"'
|
||||
CARD_ID=$(sed -n 's/.*"id":"\(c_[a-zA-Z0-9_]*\)".*/\1/p' "$OUT" | head -1)
|
||||
if [ -z "$CARD_ID" ]; then
|
||||
echo " [FAIL] id карточки не извлечён из ответа create card"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_NAMES="$FAILED_NAMES|create card id"
|
||||
else
|
||||
echo " карточка: $CARD_ID"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- 3.4a GET /api/cards?containerId=planned → карточка в стадии --"
|
||||
if [ -n "$CARD_ID" ]; then
|
||||
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/cards?containerId=planned" > "$OUT"
|
||||
check "list planned → 200, карточка найдена" 200 "\"id\":\"$CARD_ID\""
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- 3.5 POST /api/cards/$CARD_ID/trash → обучающий сигнал spam (MlOutbox) --"
|
||||
if [ -n "$CARD_ID" ]; then
|
||||
curl -s -m 20 -w "\n[HTTP:%{http_code}]" -b "$JAR" -X POST "$BASE_URL/api/cards/$CARD_ID/trash" > "$OUT"
|
||||
check "trash → 200 ok:true" 200 '"ok":true'
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- 3.6 флашер MlOutbox: ждём TrainBatch → ml-service (outbox:0 + класс spam в модели) --"
|
||||
FLUSHED=""
|
||||
i=0
|
||||
while [ $i -lt $ML_POLL_TICKS ]; do
|
||||
curl -s -m 15 -w "\n[HTTP:%{http_code}]" -b "$JAR" "$BASE_URL/api/ml/status" > "$OUT"
|
||||
# reachable — живой Status RPC ml-service; outbox — локальная очередь (убывает после успешного
|
||||
# TrainBatch); "spam" — класс в модели сервиса (появляется после рефреша кэша статуса, 15 с).
|
||||
if grep -q '"reachable":true' "$OUT" && grep -q '"outbox":0' "$OUT" && grep -qF '"spam":' "$OUT"; then
|
||||
FLUSHED=1
|
||||
break
|
||||
fi
|
||||
i=$((i + 1))
|
||||
sleep $TICK_SECONDS
|
||||
done
|
||||
if [ -n "$FLUSHED" ]; then
|
||||
echo " [PASS] ML-флашер: outbox выгружен в ml-service (класс spam обучен)"
|
||||
PASS=$((PASS + 1))
|
||||
cat "$OUT"
|
||||
else
|
||||
echo " [FAIL] ML-флашер: outbox не выгружен / ml-service недоступен за отведённое время"
|
||||
cat "$OUT"
|
||||
FAIL=$((FAIL + 1))
|
||||
FAILED_NAMES="$FAILED_NAMES|ml flusher"
|
||||
docker compose -f "$COMPOSE_FILE" logs --tail=40 core | grep -i "флашер\|outbox\|TrainBatch" || true
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "== ИТОГ: PASS=$PASS FAIL=$FAIL =="
|
||||
if [ "$FAIL" = "0" ]; then
|
||||
echo "SMOKE ПРОЙДЕН — полный dev-стек работает по gRPC"
|
||||
exit 0
|
||||
fi
|
||||
echo "Провалы:$FAILED_NAMES"
|
||||
exit 1
|
||||
|
||||
+155
-155
@@ -1,155 +1,155 @@
|
||||
"""Понижение XML-док комментариев с private/internal членов до обычных `//` (код-стайл «Дейл» §5).
|
||||
|
||||
Правило: XML-doc — только на public/protected. Приватные детали реализации — при необходимости короткий
|
||||
обычный комментарий. Скрипт сохраняет текст (теги <param>/<returns> и т.п. разворачиваются в читаемый вид).
|
||||
|
||||
Режимы:
|
||||
python scripts/fix_private_docs.py --preview [N] # показать N примеров (по умолчанию 10)
|
||||
python scripts/fix_private_docs.py --apply # применить
|
||||
python scripts/fix_private_docs.py --check # сколько блоков будет изменено
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = r"C:\telbase\src"
|
||||
EXC = ("bin", "obj", "node_modules", ".git")
|
||||
DOC_RE = re.compile(r"^(\s*)///[ \t]?(.*)$")
|
||||
ACC_RE = re.compile(r"\b(public|protected|private|internal)\b")
|
||||
|
||||
|
||||
def demote(block_content):
|
||||
"""block_content: список строк после '///'. Возвращает список строк текста обычного комментария."""
|
||||
t = "\n".join(block_content)
|
||||
t = re.sub(r"<summary>\s*(.*?)\s*</summary>", r"\1", t, flags=re.S)
|
||||
t = re.sub(r"<remarks>\s*(.*?)\s*</remarks>", r"\1", t, flags=re.S)
|
||||
t = re.sub(r"<typeparamref\s+name=\"([^\"]+)\"\s*/?>", r"\1", t)
|
||||
t = re.sub(r"<paramref\s+name=\"([^\"]+)\"\s*/?>", r"\1", t)
|
||||
t = re.sub(r"<typeparam\s+name=\"([^\"]+)\"\s*>\s*(.*?)\s*</typeparam>", r"\1: \2", t, flags=re.S)
|
||||
t = re.sub(r"<param\s+name=\"([^\"]+)\"\s*>\s*(.*?)\s*</param>", r"\1: \2", t, flags=re.S)
|
||||
t = re.sub(r"<returns>\s*(.*?)\s*</returns>", r"Возвращает: \1", t, flags=re.S)
|
||||
t = re.sub(
|
||||
r"<exception\s+cref=\"([^\"]+)\"\s*>\s*(.*?)\s*</exception>",
|
||||
r"Исключение \1: \2",
|
||||
t,
|
||||
flags=re.S,
|
||||
)
|
||||
t = re.sub(r"<see\s+cref=\"([^\"]+)\"\s*/?>", r"\1", t)
|
||||
t = re.sub(r"<seealso\s+cref=\"[^\"]+\"\s*/?>", "", t)
|
||||
t = re.sub(r"<inheritdoc\s*/?>", "", t)
|
||||
t = re.sub(r"</?c>", "", t)
|
||||
t = re.sub(r"<[^>]+>", "", t)
|
||||
out = []
|
||||
for line in t.split("\n"):
|
||||
s = line.strip()
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def iter_cs():
|
||||
for dp, dn, fn in os.walk(ROOT):
|
||||
dn[:] = [d for d in dn if d not in EXC]
|
||||
for f in fn:
|
||||
if f.endswith(".cs"):
|
||||
yield os.path.join(dp, f)
|
||||
|
||||
|
||||
def find_blocks(lines):
|
||||
"""Возвращает список (start, end, block_content, decl_index, decl)."""
|
||||
res = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
m = DOC_RE.match(lines[i].rstrip("\r"))
|
||||
if not m:
|
||||
i += 1
|
||||
continue
|
||||
start = i
|
||||
block = []
|
||||
while i < len(lines):
|
||||
m2 = DOC_RE.match(lines[i].rstrip("\r"))
|
||||
if not m2:
|
||||
break
|
||||
block.append(m2.group(2))
|
||||
i += 1
|
||||
end = i # exclusive
|
||||
j = i
|
||||
decl = ""
|
||||
while j < len(lines):
|
||||
s = lines[j].strip()
|
||||
if s == "" or s.startswith("["):
|
||||
j += 1
|
||||
continue
|
||||
decl = s
|
||||
break
|
||||
if not decl:
|
||||
continue
|
||||
if not any("<summary>" in b or "</summary>" in b for b in block):
|
||||
continue
|
||||
am = ACC_RE.search(decl)
|
||||
if am and am.group(1) in ("private", "internal"):
|
||||
res.append((start, end, block, decl))
|
||||
return res
|
||||
|
||||
|
||||
def process_file(path, apply):
|
||||
raw = open(path, "rb").read().decode("utf-8")
|
||||
lines = raw.splitlines(keepends=True)
|
||||
plain = [l.rstrip("\r\n") for l in lines]
|
||||
endings = [l[len(l.rstrip("\r\n")):] for l in lines]
|
||||
blocks = find_blocks(plain)
|
||||
if not blocks:
|
||||
return 0, []
|
||||
changes = []
|
||||
for start, end, block, decl in blocks:
|
||||
indent = DOC_RE.match(plain[start]).group(1)
|
||||
comments = demote(block)
|
||||
new_lines = [indent + "//" + (" " + c if c else "") for c in comments]
|
||||
changes.append((start, end, new_lines, decl))
|
||||
# применяем с конца
|
||||
for start, end, new_lines, decl in reversed(changes):
|
||||
ending = endings[start] if endings[start] else (endings[end - 1] if end > start else "\n")
|
||||
replacement = [nl + ending for nl in new_lines]
|
||||
lines[start:end] = replacement
|
||||
if apply:
|
||||
open(path, "w", encoding="utf-8", newline="").write("".join(lines))
|
||||
return len(changes), [(b[0], b[3], demote(b[2])) for b in [(s, e, bl, d) for s, e, bl, d in blocks]]
|
||||
|
||||
|
||||
def main():
|
||||
if "--preview" in sys.argv:
|
||||
n = 10
|
||||
idx = sys.argv.index("--preview")
|
||||
if idx + 1 < len(sys.argv) and sys.argv[idx + 1].isdigit():
|
||||
n = int(sys.argv[idx + 1])
|
||||
shown = 0
|
||||
for path in iter_cs():
|
||||
raw = open(path, "rb").read().decode("utf-8")
|
||||
plain = [l.rstrip("\r\n") for l in raw.split("\n")]
|
||||
for start, end, block, decl in find_blocks(plain):
|
||||
print("FILE:", os.path.relpath(path, r"C:\telbase"), f"line {start + 1}")
|
||||
print(" DECL:", decl[:100])
|
||||
print(" BEFORE:")
|
||||
for b in plain[start:end]:
|
||||
print(" ", b)
|
||||
print(" AFTER:")
|
||||
for c in demote(block):
|
||||
print(" // " + c)
|
||||
print()
|
||||
shown += 1
|
||||
if shown >= n:
|
||||
return
|
||||
return
|
||||
apply = "--apply" in sys.argv
|
||||
total = 0
|
||||
files = 0
|
||||
for path in iter_cs():
|
||||
cnt, _ = process_file(path, apply)
|
||||
if cnt:
|
||||
files += 1
|
||||
total += cnt
|
||||
print(("APPLIED" if apply else "DRY-RUN"), "blocks:", total, "files:", files)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""Понижение XML-док комментариев с private/internal членов до обычных `//` (код-стайл «Дейл» §5).
|
||||
|
||||
Правило: XML-doc — только на public/protected. Приватные детали реализации — при необходимости короткий
|
||||
обычный комментарий. Скрипт сохраняет текст (теги <param>/<returns> и т.п. разворачиваются в читаемый вид).
|
||||
|
||||
Режимы:
|
||||
python scripts/fix_private_docs.py --preview [N] # показать N примеров (по умолчанию 10)
|
||||
python scripts/fix_private_docs.py --apply # применить
|
||||
python scripts/fix_private_docs.py --check # сколько блоков будет изменено
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = r"C:\telbase\src"
|
||||
EXC = ("bin", "obj", "node_modules", ".git")
|
||||
DOC_RE = re.compile(r"^(\s*)///[ \t]?(.*)$")
|
||||
ACC_RE = re.compile(r"\b(public|protected|private|internal)\b")
|
||||
|
||||
|
||||
def demote(block_content):
|
||||
"""block_content: список строк после '///'. Возвращает список строк текста обычного комментария."""
|
||||
t = "\n".join(block_content)
|
||||
t = re.sub(r"<summary>\s*(.*?)\s*</summary>", r"\1", t, flags=re.S)
|
||||
t = re.sub(r"<remarks>\s*(.*?)\s*</remarks>", r"\1", t, flags=re.S)
|
||||
t = re.sub(r"<typeparamref\s+name=\"([^\"]+)\"\s*/?>", r"\1", t)
|
||||
t = re.sub(r"<paramref\s+name=\"([^\"]+)\"\s*/?>", r"\1", t)
|
||||
t = re.sub(r"<typeparam\s+name=\"([^\"]+)\"\s*>\s*(.*?)\s*</typeparam>", r"\1: \2", t, flags=re.S)
|
||||
t = re.sub(r"<param\s+name=\"([^\"]+)\"\s*>\s*(.*?)\s*</param>", r"\1: \2", t, flags=re.S)
|
||||
t = re.sub(r"<returns>\s*(.*?)\s*</returns>", r"Возвращает: \1", t, flags=re.S)
|
||||
t = re.sub(
|
||||
r"<exception\s+cref=\"([^\"]+)\"\s*>\s*(.*?)\s*</exception>",
|
||||
r"Исключение \1: \2",
|
||||
t,
|
||||
flags=re.S,
|
||||
)
|
||||
t = re.sub(r"<see\s+cref=\"([^\"]+)\"\s*/?>", r"\1", t)
|
||||
t = re.sub(r"<seealso\s+cref=\"[^\"]+\"\s*/?>", "", t)
|
||||
t = re.sub(r"<inheritdoc\s*/?>", "", t)
|
||||
t = re.sub(r"</?c>", "", t)
|
||||
t = re.sub(r"<[^>]+>", "", t)
|
||||
out = []
|
||||
for line in t.split("\n"):
|
||||
s = line.strip()
|
||||
if s:
|
||||
out.append(s)
|
||||
return out
|
||||
|
||||
|
||||
def iter_cs():
|
||||
for dp, dn, fn in os.walk(ROOT):
|
||||
dn[:] = [d for d in dn if d not in EXC]
|
||||
for f in fn:
|
||||
if f.endswith(".cs"):
|
||||
yield os.path.join(dp, f)
|
||||
|
||||
|
||||
def find_blocks(lines):
|
||||
"""Возвращает список (start, end, block_content, decl_index, decl)."""
|
||||
res = []
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
m = DOC_RE.match(lines[i].rstrip("\r"))
|
||||
if not m:
|
||||
i += 1
|
||||
continue
|
||||
start = i
|
||||
block = []
|
||||
while i < len(lines):
|
||||
m2 = DOC_RE.match(lines[i].rstrip("\r"))
|
||||
if not m2:
|
||||
break
|
||||
block.append(m2.group(2))
|
||||
i += 1
|
||||
end = i # exclusive
|
||||
j = i
|
||||
decl = ""
|
||||
while j < len(lines):
|
||||
s = lines[j].strip()
|
||||
if s == "" or s.startswith("["):
|
||||
j += 1
|
||||
continue
|
||||
decl = s
|
||||
break
|
||||
if not decl:
|
||||
continue
|
||||
if not any("<summary>" in b or "</summary>" in b for b in block):
|
||||
continue
|
||||
am = ACC_RE.search(decl)
|
||||
if am and am.group(1) in ("private", "internal"):
|
||||
res.append((start, end, block, decl))
|
||||
return res
|
||||
|
||||
|
||||
def process_file(path, apply):
|
||||
raw = open(path, "rb").read().decode("utf-8")
|
||||
lines = raw.splitlines(keepends=True)
|
||||
plain = [l.rstrip("\r\n") for l in lines]
|
||||
endings = [l[len(l.rstrip("\r\n")):] for l in lines]
|
||||
blocks = find_blocks(plain)
|
||||
if not blocks:
|
||||
return 0, []
|
||||
changes = []
|
||||
for start, end, block, decl in blocks:
|
||||
indent = DOC_RE.match(plain[start]).group(1)
|
||||
comments = demote(block)
|
||||
new_lines = [indent + "//" + (" " + c if c else "") for c in comments]
|
||||
changes.append((start, end, new_lines, decl))
|
||||
# применяем с конца
|
||||
for start, end, new_lines, decl in reversed(changes):
|
||||
ending = endings[start] if endings[start] else (endings[end - 1] if end > start else "\n")
|
||||
replacement = [nl + ending for nl in new_lines]
|
||||
lines[start:end] = replacement
|
||||
if apply:
|
||||
open(path, "w", encoding="utf-8", newline="").write("".join(lines))
|
||||
return len(changes), [(b[0], b[3], demote(b[2])) for b in [(s, e, bl, d) for s, e, bl, d in blocks]]
|
||||
|
||||
|
||||
def main():
|
||||
if "--preview" in sys.argv:
|
||||
n = 10
|
||||
idx = sys.argv.index("--preview")
|
||||
if idx + 1 < len(sys.argv) and sys.argv[idx + 1].isdigit():
|
||||
n = int(sys.argv[idx + 1])
|
||||
shown = 0
|
||||
for path in iter_cs():
|
||||
raw = open(path, "rb").read().decode("utf-8")
|
||||
plain = [l.rstrip("\r\n") for l in raw.split("\n")]
|
||||
for start, end, block, decl in find_blocks(plain):
|
||||
print("FILE:", os.path.relpath(path, r"C:\telbase"), f"line {start + 1}")
|
||||
print(" DECL:", decl[:100])
|
||||
print(" BEFORE:")
|
||||
for b in plain[start:end]:
|
||||
print(" ", b)
|
||||
print(" AFTER:")
|
||||
for c in demote(block):
|
||||
print(" // " + c)
|
||||
print()
|
||||
shown += 1
|
||||
if shown >= n:
|
||||
return
|
||||
return
|
||||
apply = "--apply" in sys.argv
|
||||
total = 0
|
||||
files = 0
|
||||
for path in iter_cs():
|
||||
cnt, _ = process_file(path, apply)
|
||||
if cnt:
|
||||
files += 1
|
||||
total += cnt
|
||||
print(("APPLIED" if apply else "DRY-RUN"), "blocks:", total, "files:", files)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+108
-108
@@ -1,108 +1,108 @@
|
||||
"""Приведение XML-док <summary> к блочному виду (действующий код-стайл «Дейл»).
|
||||
|
||||
Правило: открывающий <summary> и закрывающий </summary> — каждый на своей строке.
|
||||
Однострочная запись `/// <summary>текст</summary>` не допускается.
|
||||
|
||||
Режимы:
|
||||
python scripts/fix_summary_blocks.py --check # только посчитать, что изменится
|
||||
python scripts/fix_summary_blocks.py --apply # применить
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = r"C:\telbase\src"
|
||||
EXC = ("bin", "obj", "node_modules", ".git")
|
||||
LINE_RE = re.compile(r"^(\s*)(///)([ \t]?)(.*)$")
|
||||
|
||||
|
||||
def transform(body: str):
|
||||
m = LINE_RE.match(body)
|
||||
if not m:
|
||||
return [body]
|
||||
indent, _, _, content = m.groups()
|
||||
if "<summary>" not in content and "</summary>" not in content:
|
||||
return [body]
|
||||
base = indent + "///"
|
||||
|
||||
def tag(text=""):
|
||||
return base + (" " + text if text else "")
|
||||
|
||||
if "<summary>" in content and "</summary>" in content:
|
||||
pre, rest = content.split("<summary>", 1)
|
||||
between, post = rest.split("</summary>", 1)
|
||||
res = []
|
||||
if pre.strip():
|
||||
res.append(tag(pre.strip()))
|
||||
res.append(tag("<summary>"))
|
||||
if between.strip():
|
||||
res.append(tag(between.strip()))
|
||||
res.append(tag("</summary>"))
|
||||
if post.strip():
|
||||
res.append(tag(post.strip()))
|
||||
return res
|
||||
|
||||
if "<summary>" in content:
|
||||
pre, after = content.split("<summary>", 1)
|
||||
res = []
|
||||
if pre.strip():
|
||||
res.append(tag(pre.strip()))
|
||||
res.append(tag("<summary>"))
|
||||
if after.strip():
|
||||
res.append(tag(after.strip()))
|
||||
return res
|
||||
|
||||
if "</summary>" in content:
|
||||
pre, after = content.split("</summary>", 1)
|
||||
res = []
|
||||
if pre.strip():
|
||||
res.append(tag(pre.strip()))
|
||||
res.append(tag("</summary>"))
|
||||
if after.strip():
|
||||
res.append(tag(after.strip()))
|
||||
return res
|
||||
|
||||
return [body]
|
||||
|
||||
|
||||
def iter_cs():
|
||||
for dp, dn, fn in os.walk(ROOT):
|
||||
dn[:] = [d for d in dn if d not in EXC]
|
||||
for f in fn:
|
||||
if f.endswith(".cs"):
|
||||
yield os.path.join(dp, f)
|
||||
|
||||
|
||||
def process(path, apply):
|
||||
raw = open(path, "rb").read().decode("utf-8")
|
||||
lines = raw.splitlines(keepends=True)
|
||||
out = []
|
||||
changed = False
|
||||
for raw_line in lines:
|
||||
if raw_line.endswith("\r\n"):
|
||||
body, ending = raw_line[:-2], "\r\n"
|
||||
elif raw_line.endswith("\n"):
|
||||
body, ending = raw_line[:-1], "\n"
|
||||
else:
|
||||
body, ending = raw_line, ""
|
||||
new_bodies = transform(body)
|
||||
if new_bodies != [body]:
|
||||
changed = True
|
||||
out.extend(b + ending for b in new_bodies)
|
||||
if changed and apply:
|
||||
open(path, "w", encoding="utf-8", newline="").write("".join(out))
|
||||
return changed
|
||||
|
||||
|
||||
def main():
|
||||
apply = "--apply" in sys.argv
|
||||
total = 0
|
||||
files = 0
|
||||
for path in iter_cs():
|
||||
if process(path, apply):
|
||||
files += 1
|
||||
print(("APPLIED" if apply else "DRY-RUN"), "files changed:", files)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""Приведение XML-док <summary> к блочному виду (действующий код-стайл «Дейл»).
|
||||
|
||||
Правило: открывающий <summary> и закрывающий </summary> — каждый на своей строке.
|
||||
Однострочная запись `/// <summary>текст</summary>` не допускается.
|
||||
|
||||
Режимы:
|
||||
python scripts/fix_summary_blocks.py --check # только посчитать, что изменится
|
||||
python scripts/fix_summary_blocks.py --apply # применить
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
ROOT = r"C:\telbase\src"
|
||||
EXC = ("bin", "obj", "node_modules", ".git")
|
||||
LINE_RE = re.compile(r"^(\s*)(///)([ \t]?)(.*)$")
|
||||
|
||||
|
||||
def transform(body: str):
|
||||
m = LINE_RE.match(body)
|
||||
if not m:
|
||||
return [body]
|
||||
indent, _, _, content = m.groups()
|
||||
if "<summary>" not in content and "</summary>" not in content:
|
||||
return [body]
|
||||
base = indent + "///"
|
||||
|
||||
def tag(text=""):
|
||||
return base + (" " + text if text else "")
|
||||
|
||||
if "<summary>" in content and "</summary>" in content:
|
||||
pre, rest = content.split("<summary>", 1)
|
||||
between, post = rest.split("</summary>", 1)
|
||||
res = []
|
||||
if pre.strip():
|
||||
res.append(tag(pre.strip()))
|
||||
res.append(tag("<summary>"))
|
||||
if between.strip():
|
||||
res.append(tag(between.strip()))
|
||||
res.append(tag("</summary>"))
|
||||
if post.strip():
|
||||
res.append(tag(post.strip()))
|
||||
return res
|
||||
|
||||
if "<summary>" in content:
|
||||
pre, after = content.split("<summary>", 1)
|
||||
res = []
|
||||
if pre.strip():
|
||||
res.append(tag(pre.strip()))
|
||||
res.append(tag("<summary>"))
|
||||
if after.strip():
|
||||
res.append(tag(after.strip()))
|
||||
return res
|
||||
|
||||
if "</summary>" in content:
|
||||
pre, after = content.split("</summary>", 1)
|
||||
res = []
|
||||
if pre.strip():
|
||||
res.append(tag(pre.strip()))
|
||||
res.append(tag("</summary>"))
|
||||
if after.strip():
|
||||
res.append(tag(after.strip()))
|
||||
return res
|
||||
|
||||
return [body]
|
||||
|
||||
|
||||
def iter_cs():
|
||||
for dp, dn, fn in os.walk(ROOT):
|
||||
dn[:] = [d for d in dn if d not in EXC]
|
||||
for f in fn:
|
||||
if f.endswith(".cs"):
|
||||
yield os.path.join(dp, f)
|
||||
|
||||
|
||||
def process(path, apply):
|
||||
raw = open(path, "rb").read().decode("utf-8")
|
||||
lines = raw.splitlines(keepends=True)
|
||||
out = []
|
||||
changed = False
|
||||
for raw_line in lines:
|
||||
if raw_line.endswith("\r\n"):
|
||||
body, ending = raw_line[:-2], "\r\n"
|
||||
elif raw_line.endswith("\n"):
|
||||
body, ending = raw_line[:-1], "\n"
|
||||
else:
|
||||
body, ending = raw_line, ""
|
||||
new_bodies = transform(body)
|
||||
if new_bodies != [body]:
|
||||
changed = True
|
||||
out.extend(b + ending for b in new_bodies)
|
||||
if changed and apply:
|
||||
open(path, "w", encoding="utf-8", newline="").write("".join(out))
|
||||
return changed
|
||||
|
||||
|
||||
def main():
|
||||
apply = "--apply" in sys.argv
|
||||
total = 0
|
||||
files = 0
|
||||
for path in iter_cs():
|
||||
if process(path, apply):
|
||||
files += 1
|
||||
print(("APPLIED" if apply else "DRY-RUN"), "files changed:", files)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
+54
-54
@@ -1,54 +1,54 @@
|
||||
# Нагрузочный смоук-тест API Deal (`scripts/loadtest/`)
|
||||
|
||||
Минимальный прогон «есть ли жизнь и как быстро отвечает» по читающим ручкам API
|
||||
(этап 12, пакет C). Внешних сервисов и записывающих операций нет — только логин
|
||||
и чтение, БД не мутируется.
|
||||
|
||||
## Сценарий
|
||||
|
||||
1. `POST /api/auth/login` `{login, password}` — сессия (`deal_session`).
|
||||
2. В цикле: `GET /api/containers`, затем `GET /api/cards`.
|
||||
3. Метрики: число запросов, ошибки (не-200), RPS, средняя/p95/max латентность.
|
||||
|
||||
Учётные данные по умолчанию — `admin` / `admin` (dev-дефолт bootstrap тенанта).
|
||||
|
||||
## Вариант A — bash + curl (зависимостей нет, кроме curl)
|
||||
|
||||
```bash
|
||||
bash scripts/loadtest/api-loadtest.sh
|
||||
|
||||
# пример с параметрами
|
||||
BASE_URL=http://localhost:5080 LOGIN=admin PASSWORD=admin \
|
||||
DURATION_SECONDS=20 PAUSE_SECONDS=0.1 \
|
||||
bash scripts/loadtest/api-loadtest.sh
|
||||
```
|
||||
|
||||
Переменные окружения: `BASE_URL` (по умолчанию `http://localhost:5080`),
|
||||
`LOGIN`/`PASSWORD`, `DURATION_SECONDS` (15), `PAUSE_SECONDS` (0.2), `CURL_TIMEOUT` (5).
|
||||
Код возврата: `0` — без ошибок, `1` — ошибка логина/конфигурации, `2` — были не-200.
|
||||
|
||||
## Вариант B — k6 (если установлен)
|
||||
|
||||
```bash
|
||||
k6 run scripts/loadtest/deal-loadtest.js
|
||||
BASE_URL=http://localhost:5080 VUS=10 DURATION=30s \
|
||||
k6 run scripts/loadtest/deal-loadtest.js
|
||||
```
|
||||
|
||||
Скрипт логинится один раз в `setup()` и раздаёт куку виртуальным пользователям;
|
||||
пороги проваливают прогон при доле ошибок > 1% или p95 > 500 мс.
|
||||
|
||||
## Как поднять API для прогона
|
||||
|
||||
- **dev (полный стек)**: `docker compose -f deploy/compose.dev.yml up -d --build`,
|
||||
затем прогон против `http://localhost:5080`; после — `docker compose -f deploy/compose.dev.yml down`.
|
||||
- **dev (только core + Postgres)**: см. техдок §13 «Быстрый старт» (Postgres :5433, API :5080).
|
||||
- Прогон короткий (по умолчанию 15–20 с) — это смоук, а не полноценный бенчмарк.
|
||||
|
||||
## Замечания
|
||||
|
||||
- В PROD включён rate-limit (`api` — 600/мин на тенанта, `auth` — 10/мин на IP),
|
||||
поэтому длительные прогоны делайте на dev-стенде или поднимите лимит.
|
||||
- Метрики перфа сервисов дополнительно смотрите в Prometheus/Grafana (профиль
|
||||
`observability`): `http_server_request_duration_seconds_*`, RPS/p95 на дашборде
|
||||
`Deal-Metrics-Overview` (см. техдок §7).
|
||||
# Нагрузочный смоук-тест API Deal (`scripts/loadtest/`)
|
||||
|
||||
Минимальный прогон «есть ли жизнь и как быстро отвечает» по читающим ручкам API
|
||||
(этап 12, пакет C). Внешних сервисов и записывающих операций нет — только логин
|
||||
и чтение, БД не мутируется.
|
||||
|
||||
## Сценарий
|
||||
|
||||
1. `POST /api/auth/login` `{login, password}` — сессия (`deal_session`).
|
||||
2. В цикле: `GET /api/containers`, затем `GET /api/cards`.
|
||||
3. Метрики: число запросов, ошибки (не-200), RPS, средняя/p95/max латентность.
|
||||
|
||||
Учётные данные по умолчанию — `admin` / `admin` (dev-дефолт bootstrap тенанта).
|
||||
|
||||
## Вариант A — bash + curl (зависимостей нет, кроме curl)
|
||||
|
||||
```bash
|
||||
bash scripts/loadtest/api-loadtest.sh
|
||||
|
||||
# пример с параметрами
|
||||
BASE_URL=http://localhost:5080 LOGIN=admin PASSWORD=admin \
|
||||
DURATION_SECONDS=20 PAUSE_SECONDS=0.1 \
|
||||
bash scripts/loadtest/api-loadtest.sh
|
||||
```
|
||||
|
||||
Переменные окружения: `BASE_URL` (по умолчанию `http://localhost:5080`),
|
||||
`LOGIN`/`PASSWORD`, `DURATION_SECONDS` (15), `PAUSE_SECONDS` (0.2), `CURL_TIMEOUT` (5).
|
||||
Код возврата: `0` — без ошибок, `1` — ошибка логина/конфигурации, `2` — были не-200.
|
||||
|
||||
## Вариант B — k6 (если установлен)
|
||||
|
||||
```bash
|
||||
k6 run scripts/loadtest/deal-loadtest.js
|
||||
BASE_URL=http://localhost:5080 VUS=10 DURATION=30s \
|
||||
k6 run scripts/loadtest/deal-loadtest.js
|
||||
```
|
||||
|
||||
Скрипт логинится один раз в `setup()` и раздаёт куку виртуальным пользователям;
|
||||
пороги проваливают прогон при доле ошибок > 1% или p95 > 500 мс.
|
||||
|
||||
## Как поднять API для прогона
|
||||
|
||||
- **dev (полный стек)**: `docker compose -f deploy/compose.dev.yml up -d --build`,
|
||||
затем прогон против `http://localhost:5080`; после — `docker compose -f deploy/compose.dev.yml down`.
|
||||
- **dev (только core + Postgres)**: см. техдок §13 «Быстрый старт» (Postgres :5433, API :5080).
|
||||
- Прогон короткий (по умолчанию 15–20 с) — это смоук, а не полноценный бенчмарк.
|
||||
|
||||
## Замечания
|
||||
|
||||
- В PROD включён rate-limit (`api` — 600/мин на тенанта, `auth` — 10/мин на IP),
|
||||
поэтому длительные прогоны делайте на dev-стенде или поднимите лимит.
|
||||
- Метрики перфа сервисов дополнительно смотрите в Prometheus/Grafana (профиль
|
||||
`observability`): `http_server_request_duration_seconds_*`, RPS/p95 на дашборде
|
||||
`Deal-Metrics-Overview` (см. техдок §7).
|
||||
|
||||
@@ -1,98 +1,98 @@
|
||||
#!/usr/bin/env bash
|
||||
# Простой нагрузочный смоук-тест API Deal (этап 12, пакет C).
|
||||
#
|
||||
# Что делает: логинится (по умолчанию admin/admin), затем в цикле дёргает два
|
||||
# читающих эндпоинта — GET /api/containers и GET /api/cards — до истечения
|
||||
# DURATION_SECONDS, замеряя HTTP-код и время ответа. В конце печатает сводку:
|
||||
# число запросов, ошибки (не 200), RPS, средняя/p95-латентность (сек).
|
||||
#
|
||||
# Зависимости: только bash и curl. Ничего не пишет в БД; читающие ручки.
|
||||
#
|
||||
# Запуск (из корня репозитория, dev-API на :5080):
|
||||
# bash scripts/loadtest/api-loadtest.sh
|
||||
# BASE_URL=http://localhost:5080 LOGIN=admin PASSWORD=admin \
|
||||
# DURATION_SECONDS=20 PAUSE_SECONDS=0.1 bash scripts/loadtest/api-loadtest.sh
|
||||
#
|
||||
# Переменные окружения:
|
||||
# BASE_URL базовый URL API (по умолчанию http://localhost:5080)
|
||||
# LOGIN/PASSWORD учётные данные тенанта (по умолчанию admin/admin — dev-дефолт bootstrap)
|
||||
# DURATION_SECONDS длительность прогона, с (по умолчанию 15)
|
||||
# PAUSE_SECONDS пауза между итерациями, с (по умолчанию 0.2)
|
||||
# CURL_TIMEOUT таймаут запроса, с (по умолчанию 5)
|
||||
#
|
||||
# Внимание: в PROD включён rate-limit (api-политика — 600/мин на тенанта). Для
|
||||
# нагрузочного прогона используйте dev-стенд или поднимите лимит.
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:5080}"
|
||||
LOGIN="${LOGIN:-admin}"
|
||||
PASSWORD="${PASSWORD:-admin}"
|
||||
DURATION_SECONDS="${DURATION_SECONDS:-15}"
|
||||
PAUSE_SECONDS="${PAUSE_SECONDS:-0.2}"
|
||||
CURL_TIMEOUT="${CURL_TIMEOUT:-5}"
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "Ошибка: нужен curl" >&2; exit 127; }
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
cookie_jar="${tmp_dir}/cookies.txt"
|
||||
lat_file="${tmp_dir}/latencies.txt"
|
||||
trap 'rm -rf "${tmp_dir}"' EXIT
|
||||
|
||||
login_code="$(curl -sS -o "${tmp_dir}/login.json" -w '%{http_code}' \
|
||||
-c "${cookie_jar}" -H 'Content-Type: application/json' \
|
||||
-d "{\"login\":\"${LOGIN}\",\"password\":\"${PASSWORD}\"}" \
|
||||
--max-time "${CURL_TIMEOUT}" "${BASE_URL}/api/auth/login" || true)"
|
||||
[ -n "${login_code}" ] || login_code="000"
|
||||
|
||||
if [ "${login_code}" != "200" ]; then
|
||||
echo "LOGIN FAILED (HTTP ${login_code}): $(cat "${tmp_dir}/login.json" 2>/dev/null)" >&2
|
||||
echo "Проверьте BASE_URL=${BASE_URL} и что API поднят." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "login OK (${LOGIN}) → прогон ${DURATION_SECONDS} с, пауза ${PAUSE_SECONDS} с"
|
||||
|
||||
started_at="$(date +%s)"
|
||||
end_at=$((started_at + DURATION_SECONDS))
|
||||
total=0
|
||||
errors=0
|
||||
|
||||
while [ "$(date +%s)" -lt "${end_at}" ]; do
|
||||
for path in /api/containers /api/cards; do
|
||||
result="$(curl -sS -o /dev/null -w '%{http_code} %{time_total}' \
|
||||
-b "${cookie_jar}" --max-time "${CURL_TIMEOUT}" "${BASE_URL}${path}" || true)"
|
||||
[ -n "${result}" ] || result="000 0"
|
||||
code="${result%% *}"
|
||||
elapsed="${result##* }"
|
||||
total=$((total + 1))
|
||||
if [ "${code}" != "200" ]; then
|
||||
errors=$((errors + 1))
|
||||
echo " ! ${path} → HTTP ${code}" >&2
|
||||
fi
|
||||
echo "${elapsed}" >> "${lat_file}"
|
||||
done
|
||||
sleep "${PAUSE_SECONDS}"
|
||||
done
|
||||
|
||||
elapsed_total=$(( $(date +%s) - started_at ))
|
||||
[ "${elapsed_total}" -lt 1 ] && elapsed_total=1
|
||||
|
||||
if [ "${total}" -eq 0 ]; then
|
||||
echo "Запросов не сделано (DURATION_SECONDS слишком мал?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
avg="$(awk '{sum += $1} END {printf "%.4f", sum / NR}' "${lat_file}")"
|
||||
p95_index=$(( (total * 95 + 99) / 100 ))
|
||||
p95="$(sort -n "${lat_file}" | sed -n "${p95_index}p")"
|
||||
max="$(sort -n "${lat_file}" | tail -n 1)"
|
||||
rps="$(awk -v n="${total}" -v d="${elapsed_total}" 'BEGIN {printf "%.2f", n / d}')"
|
||||
|
||||
echo "---------------------------------------------"
|
||||
echo "запросов: ${total}"
|
||||
echo "ошибок: ${errors}"
|
||||
echo "длительность: ${elapsed_total} с"
|
||||
echo "RPS: ${rps}"
|
||||
echo "латентность: avg=${avg}s p95=${p95}s max=${max}s"
|
||||
echo "---------------------------------------------"
|
||||
|
||||
[ "${errors}" -eq 0 ] || exit 2
|
||||
#!/usr/bin/env bash
|
||||
# Простой нагрузочный смоук-тест API Deal (этап 12, пакет C).
|
||||
#
|
||||
# Что делает: логинится (по умолчанию admin/admin), затем в цикле дёргает два
|
||||
# читающих эндпоинта — GET /api/containers и GET /api/cards — до истечения
|
||||
# DURATION_SECONDS, замеряя HTTP-код и время ответа. В конце печатает сводку:
|
||||
# число запросов, ошибки (не 200), RPS, средняя/p95-латентность (сек).
|
||||
#
|
||||
# Зависимости: только bash и curl. Ничего не пишет в БД; читающие ручки.
|
||||
#
|
||||
# Запуск (из корня репозитория, dev-API на :5080):
|
||||
# bash scripts/loadtest/api-loadtest.sh
|
||||
# BASE_URL=http://localhost:5080 LOGIN=admin PASSWORD=admin \
|
||||
# DURATION_SECONDS=20 PAUSE_SECONDS=0.1 bash scripts/loadtest/api-loadtest.sh
|
||||
#
|
||||
# Переменные окружения:
|
||||
# BASE_URL базовый URL API (по умолчанию http://localhost:5080)
|
||||
# LOGIN/PASSWORD учётные данные тенанта (по умолчанию admin/admin — dev-дефолт bootstrap)
|
||||
# DURATION_SECONDS длительность прогона, с (по умолчанию 15)
|
||||
# PAUSE_SECONDS пауза между итерациями, с (по умолчанию 0.2)
|
||||
# CURL_TIMEOUT таймаут запроса, с (по умолчанию 5)
|
||||
#
|
||||
# Внимание: в PROD включён rate-limit (api-политика — 600/мин на тенанта). Для
|
||||
# нагрузочного прогона используйте dev-стенд или поднимите лимит.
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${BASE_URL:-http://localhost:5080}"
|
||||
LOGIN="${LOGIN:-admin}"
|
||||
PASSWORD="${PASSWORD:-admin}"
|
||||
DURATION_SECONDS="${DURATION_SECONDS:-15}"
|
||||
PAUSE_SECONDS="${PAUSE_SECONDS:-0.2}"
|
||||
CURL_TIMEOUT="${CURL_TIMEOUT:-5}"
|
||||
|
||||
command -v curl >/dev/null 2>&1 || { echo "Ошибка: нужен curl" >&2; exit 127; }
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
cookie_jar="${tmp_dir}/cookies.txt"
|
||||
lat_file="${tmp_dir}/latencies.txt"
|
||||
trap 'rm -rf "${tmp_dir}"' EXIT
|
||||
|
||||
login_code="$(curl -sS -o "${tmp_dir}/login.json" -w '%{http_code}' \
|
||||
-c "${cookie_jar}" -H 'Content-Type: application/json' \
|
||||
-d "{\"login\":\"${LOGIN}\",\"password\":\"${PASSWORD}\"}" \
|
||||
--max-time "${CURL_TIMEOUT}" "${BASE_URL}/api/auth/login" || true)"
|
||||
[ -n "${login_code}" ] || login_code="000"
|
||||
|
||||
if [ "${login_code}" != "200" ]; then
|
||||
echo "LOGIN FAILED (HTTP ${login_code}): $(cat "${tmp_dir}/login.json" 2>/dev/null)" >&2
|
||||
echo "Проверьте BASE_URL=${BASE_URL} и что API поднят." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "login OK (${LOGIN}) → прогон ${DURATION_SECONDS} с, пауза ${PAUSE_SECONDS} с"
|
||||
|
||||
started_at="$(date +%s)"
|
||||
end_at=$((started_at + DURATION_SECONDS))
|
||||
total=0
|
||||
errors=0
|
||||
|
||||
while [ "$(date +%s)" -lt "${end_at}" ]; do
|
||||
for path in /api/containers /api/cards; do
|
||||
result="$(curl -sS -o /dev/null -w '%{http_code} %{time_total}' \
|
||||
-b "${cookie_jar}" --max-time "${CURL_TIMEOUT}" "${BASE_URL}${path}" || true)"
|
||||
[ -n "${result}" ] || result="000 0"
|
||||
code="${result%% *}"
|
||||
elapsed="${result##* }"
|
||||
total=$((total + 1))
|
||||
if [ "${code}" != "200" ]; then
|
||||
errors=$((errors + 1))
|
||||
echo " ! ${path} → HTTP ${code}" >&2
|
||||
fi
|
||||
echo "${elapsed}" >> "${lat_file}"
|
||||
done
|
||||
sleep "${PAUSE_SECONDS}"
|
||||
done
|
||||
|
||||
elapsed_total=$(( $(date +%s) - started_at ))
|
||||
[ "${elapsed_total}" -lt 1 ] && elapsed_total=1
|
||||
|
||||
if [ "${total}" -eq 0 ]; then
|
||||
echo "Запросов не сделано (DURATION_SECONDS слишком мал?)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
avg="$(awk '{sum += $1} END {printf "%.4f", sum / NR}' "${lat_file}")"
|
||||
p95_index=$(( (total * 95 + 99) / 100 ))
|
||||
p95="$(sort -n "${lat_file}" | sed -n "${p95_index}p")"
|
||||
max="$(sort -n "${lat_file}" | tail -n 1)"
|
||||
rps="$(awk -v n="${total}" -v d="${elapsed_total}" 'BEGIN {printf "%.2f", n / d}')"
|
||||
|
||||
echo "---------------------------------------------"
|
||||
echo "запросов: ${total}"
|
||||
echo "ошибок: ${errors}"
|
||||
echo "длительность: ${elapsed_total} с"
|
||||
echo "RPS: ${rps}"
|
||||
echo "латентность: avg=${avg}s p95=${p95}s max=${max}s"
|
||||
echo "---------------------------------------------"
|
||||
|
||||
[ "${errors}" -eq 0 ] || exit 2
|
||||
|
||||
@@ -1,56 +1,56 @@
|
||||
// Нагрузочный смоук-тест API Deal на k6 (этап 12, пакет C) — альтернатива
|
||||
// scripts/loadtest/api-loadtest.sh, если в системе есть k6.
|
||||
//
|
||||
// Логин — в setup() (один раз), затем виртуальные пользователи в цикле читают
|
||||
// GET /api/containers и GET /api/cards. Пороги (thresholds) проваливают прогон
|
||||
// при доле ошибок > 1% или p95 > 500 мс.
|
||||
//
|
||||
// Запуск (API dev на :5080):
|
||||
// k6 run scripts/loadtest/deal-loadtest.js
|
||||
// BASE_URL=http://localhost:5080 VUS=10 DURATION=30s k6 run scripts/loadtest/deal-loadtest.js
|
||||
//
|
||||
// Переменные окружения: BASE_URL, LOGIN, PASSWORD, VUS, DURATION.
|
||||
// Внимание: в PROD включён rate-limit (api — 600/мин на тенанта) — гоняйте на dev.
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5080';
|
||||
const LOGIN = __ENV.LOGIN || 'admin';
|
||||
const PASSWORD = __ENV.PASSWORD || 'admin';
|
||||
|
||||
export const options = {
|
||||
vus: __ENV.VUS ? Number(__ENV.VUS) : 5,
|
||||
duration: __ENV.DURATION || '20s',
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<0.01'],
|
||||
http_req_duration: ['p(95)<500'],
|
||||
},
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
const res = http.post(
|
||||
`${BASE_URL}/api/auth/login`,
|
||||
JSON.stringify({ login: LOGIN, password: PASSWORD }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`login failed: HTTP ${res.status} — ${res.body}`);
|
||||
}
|
||||
const cookies = res.cookies['deal_session'];
|
||||
const session = cookies && cookies.length ? cookies[0].value : '';
|
||||
if (!session) {
|
||||
throw new Error('login succeeded but deal_session cookie is missing');
|
||||
}
|
||||
return { session };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const params = { headers: { Cookie: `deal_session=${data.session}` } };
|
||||
const paths = ['/api/containers', '/api/cards'];
|
||||
for (const path of paths) {
|
||||
const res = http.get(`${BASE_URL}${path}`, params);
|
||||
check(res, { [`${path} → 200`]: (r) => r.status === 200 });
|
||||
}
|
||||
sleep(0.2);
|
||||
}
|
||||
// Нагрузочный смоук-тест API Deal на k6 (этап 12, пакет C) — альтернатива
|
||||
// scripts/loadtest/api-loadtest.sh, если в системе есть k6.
|
||||
//
|
||||
// Логин — в setup() (один раз), затем виртуальные пользователи в цикле читают
|
||||
// GET /api/containers и GET /api/cards. Пороги (thresholds) проваливают прогон
|
||||
// при доле ошибок > 1% или p95 > 500 мс.
|
||||
//
|
||||
// Запуск (API dev на :5080):
|
||||
// k6 run scripts/loadtest/deal-loadtest.js
|
||||
// BASE_URL=http://localhost:5080 VUS=10 DURATION=30s k6 run scripts/loadtest/deal-loadtest.js
|
||||
//
|
||||
// Переменные окружения: BASE_URL, LOGIN, PASSWORD, VUS, DURATION.
|
||||
// Внимание: в PROD включён rate-limit (api — 600/мин на тенанта) — гоняйте на dev.
|
||||
|
||||
import http from 'k6/http';
|
||||
import { check, sleep } from 'k6';
|
||||
|
||||
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5080';
|
||||
const LOGIN = __ENV.LOGIN || 'admin';
|
||||
const PASSWORD = __ENV.PASSWORD || 'admin';
|
||||
|
||||
export const options = {
|
||||
vus: __ENV.VUS ? Number(__ENV.VUS) : 5,
|
||||
duration: __ENV.DURATION || '20s',
|
||||
thresholds: {
|
||||
http_req_failed: ['rate<0.01'],
|
||||
http_req_duration: ['p(95)<500'],
|
||||
},
|
||||
};
|
||||
|
||||
export function setup() {
|
||||
const res = http.post(
|
||||
`${BASE_URL}/api/auth/login`,
|
||||
JSON.stringify({ login: LOGIN, password: PASSWORD }),
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
if (res.status !== 200) {
|
||||
throw new Error(`login failed: HTTP ${res.status} — ${res.body}`);
|
||||
}
|
||||
const cookies = res.cookies['deal_session'];
|
||||
const session = cookies && cookies.length ? cookies[0].value : '';
|
||||
if (!session) {
|
||||
throw new Error('login succeeded but deal_session cookie is missing');
|
||||
}
|
||||
return { session };
|
||||
}
|
||||
|
||||
export default function (data) {
|
||||
const params = { headers: { Cookie: `deal_session=${data.session}` } };
|
||||
const paths = ['/api/containers', '/api/cards'];
|
||||
for (const path of paths) {
|
||||
const res = http.get(`${BASE_URL}${path}`, params);
|
||||
check(res, { [`${path} → 200`]: (r) => r.status === 200 });
|
||||
}
|
||||
sleep(0.2);
|
||||
}
|
||||
|
||||
+210
-210
@@ -1,210 +1,210 @@
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# mtls-certs.sh — генерация dev-CA и сертификатов mTLS внутреннего gRPC «Дейла» (этап 7, Ruling 6, Task 13).
|
||||
#
|
||||
# Требуется openssl. Вывод — deploy/certs/:
|
||||
# ca.pem / ca.key — dev-CA (ca.pem — runtime всех процессов, ca.key — только подпись новых
|
||||
# сертификатов; хранится в deploy/certs, в репозиторий/образ не попадает,
|
||||
# см. .dockerignore; права 600);
|
||||
# <service>-server.pfx — серверный сертификат процесса: core, telegram-service, ai-service,
|
||||
# ml-service (SAN: localhost + имя compose-сервиса + host.docker.internal);
|
||||
# deal-client.pfx — общий клиентский сертификат исходящих каналов (core → сервисы,
|
||||
# telegram-service → core-ингресс);
|
||||
# deal-client.crt/.key — тот же клиентский сертификат в PEM (без пароля): нужен docker
|
||||
# healthcheck'ам compose.prod при mTLS — grpc_health_probe принимает
|
||||
# только PEM (флаги -tls-client-cert/-tls-client-key; см. Task 14).
|
||||
#
|
||||
# Сертификаты dev (срок 825 дней — как Let's Encrypt; CA — 10 лет). Пароль PFX — env DEAL_MTLS_CERT_PASSWORD
|
||||
# (по умолчанию deal_mtls_dev_password — dev); в PROD задайте свой и пропишите его же в
|
||||
# DEAL_MTLS_SERVER_CERT_PASSWORD / DEAL_MTLS_CLIENT_CERT_PASSWORD процессов (compose-prod — Task 14).
|
||||
# Повторный запуск без -f отказывается перезаписывать существующую CA (смена CA ломает доверие всех
|
||||
# сертификатов — перегенерация нужна осознанная). Subject-имена задаются config-файлами openssl, а не
|
||||
# -subj, — скрипт работает и в Git Bash (MSYS не «съедает» аргументы вида /CN=...).
|
||||
#
|
||||
# Использование:
|
||||
# scripts/mtls-certs.sh [-f] [DEAL_MTLS_CERT_PASSWORD=...]
|
||||
#
|
||||
# После генерации процессы поднимаются с env (пути — как смонтировано в compose-prod, Task 14):
|
||||
# DEAL_MTLS_ENABLED=1
|
||||
# DEAL_MTLS_CA_PEM=/etc/deal/certs/ca.pem
|
||||
# DEAL_MTLS_SERVER_CERT_PFX=/etc/deal/certs/<service>-server.pfx
|
||||
# DEAL_MTLS_SERVER_CERT_PASSWORD=<тот же пароль>
|
||||
# DEAL_MTLS_CLIENT_CERT_PFX=/etc/deal/certs/deal-client.pfx
|
||||
# DEAL_MTLS_CLIENT_CERT_PASSWORD=<тот же пароль>
|
||||
#
|
||||
# Dev-стек (deploy/compose.dev.yml) остаётся plaintext + service-token — DEAL_MTLS_ENABLED не задаётся.
|
||||
|
||||
set -eu
|
||||
|
||||
# Корень репозитория (каталог скрипта/..).
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
CERT_DIR="$ROOT_DIR/deploy/certs"
|
||||
|
||||
# Пароль PFX: env DEAL_MTLS_CERT_PASSWORD (dev-дефолт — фиксированное значение; PROD — свой).
|
||||
CERT_PASSWORD="${DEAL_MTLS_CERT_PASSWORD:-deal_mtls_dev_password}"
|
||||
|
||||
# Сроки сертификатов (дней): CA — 10 лет, листовые — 825 (как Let's Encrypt).
|
||||
CA_DAYS=3650
|
||||
LEAF_DAYS=825
|
||||
|
||||
# Пространство имён сертификатов: SAN листовых — localhost + имя compose-сервиса + host.docker.internal
|
||||
# (dev-прогон сервиса в контейнере против core на хосте, Ruling 12) + loopback-IP.
|
||||
SERVICES="core telegram-service ai-service ml-service"
|
||||
|
||||
FORCE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-f|--force) FORCE=1 ;;
|
||||
*)
|
||||
echo "mtls-certs: неизвестный аргумент: $arg (ожидалось -f/--force)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
echo "mtls-certs: openssl не найден — установите openssl (например: apt install openssl / brew install openssl)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
if [ -f "$CERT_DIR/ca.pem" ]; then
|
||||
if [ "$FORCE" -eq 0 ]; then
|
||||
echo "mtls-certs: $CERT_DIR/ca.pem уже существует — повторный запуск не перезаписывает CA" >&2
|
||||
echo "mtls-certs: (смена CA ломает доверие выданных сертификатов). Перегенерировать всё: $0 -f" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Принудительная перегенерация: старые сертификаты удаляются целиком (их доверие утеряно вместе с CA).
|
||||
rm -f "$CERT_DIR/ca.pem" "$CERT_DIR/ca.key" "$CERT_DIR/ca.srl" "$CERT_DIR"/*-server.pfx \
|
||||
"$CERT_DIR/deal-client.pfx" "$CERT_DIR/deal-client.crt" "$CERT_DIR/deal-client.key"
|
||||
fi
|
||||
|
||||
# Временный каталог для CSR/конфигов/ключей (всегда чистится, в т.ч. при ошибке).
|
||||
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/deal-mtls.XXXXXX")
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
|
||||
|
||||
echo "mtls-certs: генерирую dev-CA (CN=Deal mTLS Dev CA, ${CA_DAYS} дн.) в $CERT_DIR ..."
|
||||
cat > "$TMP_DIR/ca.cnf" <<'EOF'
|
||||
[req]
|
||||
distinguished_name = ca_dn
|
||||
prompt = no
|
||||
x509_extensions = ca_ext
|
||||
|
||||
[ca_dn]
|
||||
CN = Deal mTLS Dev CA
|
||||
|
||||
[ca_ext]
|
||||
basicConstraints = critical,CA:TRUE
|
||||
keyUsage = critical,keyCertSign,cRLSign,digitalSignature
|
||||
subjectKeyIdentifier = hash
|
||||
EOF
|
||||
openssl req -x509 -newkey rsa:2048 -sha256 -days "$CA_DAYS" -nodes \
|
||||
-config "$TMP_DIR/ca.cnf" \
|
||||
-keyout "$CERT_DIR/ca.key" \
|
||||
-out "$CERT_DIR/ca.pem"
|
||||
chmod 600 "$CERT_DIR/ca.key"
|
||||
chmod 644 "$CERT_DIR/ca.pem"
|
||||
|
||||
# ── Серверные сертификаты процессов (SAN: localhost + compose-имя + host.docker.internal) ──
|
||||
|
||||
for service in $SERVICES; do
|
||||
echo "mtls-certs: серверный сертификат $service (${LEAF_DAYS} дн., SAN: localhost,$service,host.docker.internal) ..."
|
||||
cat > "$TMP_DIR/server-req.cnf" <<EOF
|
||||
[req]
|
||||
distinguished_name = server_dn
|
||||
prompt = no
|
||||
|
||||
[server_dn]
|
||||
CN = $service
|
||||
EOF
|
||||
cat > "$TMP_DIR/server-ext.cnf" <<EOF
|
||||
[server]
|
||||
basicConstraints = critical,CA:FALSE
|
||||
keyUsage = critical,digitalSignature,keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
subjectAltName = DNS:localhost,DNS:${service},DNS:host.docker.internal,IP:127.0.0.1
|
||||
EOF
|
||||
openssl req -new -newkey rsa:2048 -nodes \
|
||||
-config "$TMP_DIR/server-req.cnf" \
|
||||
-keyout "$TMP_DIR/server.key" \
|
||||
-out "$TMP_DIR/server.csr"
|
||||
openssl x509 -req \
|
||||
-in "$TMP_DIR/server.csr" \
|
||||
-CA "$CERT_DIR/ca.pem" \
|
||||
-CAkey "$CERT_DIR/ca.key" \
|
||||
-CAcreateserial \
|
||||
-out "$TMP_DIR/server.crt" \
|
||||
-days "$LEAF_DAYS" -sha256 \
|
||||
-extfile "$TMP_DIR/server-ext.cnf" \
|
||||
-extensions server
|
||||
openssl pkcs12 -export \
|
||||
-out "$CERT_DIR/$service-server.pfx" \
|
||||
-inkey "$TMP_DIR/server.key" \
|
||||
-in "$TMP_DIR/server.crt" \
|
||||
-name "$service-server" \
|
||||
-passout "pass:$CERT_PASSWORD"
|
||||
rm -f "$TMP_DIR/server.key" "$TMP_DIR/server.csr" "$TMP_DIR/server.crt"
|
||||
done
|
||||
|
||||
# ── Общий клиентский сертификат deal-client (исходящие каналы core и telegram-service) ──
|
||||
|
||||
echo "mtls-certs: клиентский сертификат deal-client (${LEAF_DAYS} дн.) ..."
|
||||
cat > "$TMP_DIR/client-req.cnf" <<'EOF'
|
||||
[req]
|
||||
distinguished_name = client_dn
|
||||
prompt = no
|
||||
|
||||
[client_dn]
|
||||
CN = deal-client
|
||||
EOF
|
||||
cat > "$TMP_DIR/client-ext.cnf" <<'EOF'
|
||||
[client]
|
||||
basicConstraints = critical,CA:FALSE
|
||||
keyUsage = critical,digitalSignature
|
||||
extendedKeyUsage = clientAuth
|
||||
EOF
|
||||
openssl req -new -newkey rsa:2048 -nodes \
|
||||
-config "$TMP_DIR/client-req.cnf" \
|
||||
-keyout "$TMP_DIR/client.key" \
|
||||
-out "$TMP_DIR/client.csr"
|
||||
openssl x509 -req \
|
||||
-in "$TMP_DIR/client.csr" \
|
||||
-CA "$CERT_DIR/ca.pem" \
|
||||
-CAkey "$CERT_DIR/ca.key" \
|
||||
-CAcreateserial \
|
||||
-out "$TMP_DIR/client.crt" \
|
||||
-days "$LEAF_DAYS" -sha256 \
|
||||
-extfile "$TMP_DIR/client-ext.cnf" \
|
||||
-extensions client
|
||||
openssl pkcs12 -export \
|
||||
-out "$CERT_DIR/deal-client.pfx" \
|
||||
-inkey "$TMP_DIR/client.key" \
|
||||
-in "$TMP_DIR/client.crt" \
|
||||
-name "deal-client" \
|
||||
-passout "pass:$CERT_PASSWORD"
|
||||
|
||||
# PEM-копии клиентского сертификата для grpc_health_probe compose.prod при mTLS (Task 14): утилита
|
||||
# принимает только PEM и без пароля. Сертификат публичный; приватный ключ — права 600, как ca.key.
|
||||
openssl pkcs12 -in "$CERT_DIR/deal-client.pfx" -clcerts -nokeys -passin "pass:$CERT_PASSWORD" \
|
||||
-out "$CERT_DIR/deal-client.crt"
|
||||
openssl pkcs12 -in "$CERT_DIR/deal-client.pfx" -nocerts -nodes -passin "pass:$CERT_PASSWORD" \
|
||||
-out "$CERT_DIR/deal-client.key"
|
||||
chmod 644 "$CERT_DIR/deal-client.crt"
|
||||
chmod 600 "$CERT_DIR/deal-client.key"
|
||||
|
||||
echo
|
||||
echo "mtls-certs: готово. Файлы в $CERT_DIR:"
|
||||
ls -1 "$CERT_DIR"
|
||||
echo
|
||||
echo "Пароль PFX (DEAL_MTLS_CERT_PASSWORD): $CERT_PASSWORD"
|
||||
echo "Проверка (SAN/подпись): openssl pkcs12 -in $CERT_DIR/<service>-server.pfx -nokeys | openssl verify -CAfile $CERT_DIR/ca.pem"
|
||||
echo
|
||||
echo "Включение mTLS (dev остаётся plaintext — флаг не задаётся; PROD env передаёт compose-prod, Task 14):"
|
||||
echo " DEAL_MTLS_ENABLED=1"
|
||||
echo " DEAL_MTLS_CA_PEM=$CERT_DIR/ca.pem"
|
||||
echo " DEAL_MTLS_SERVER_CERT_PFX=$CERT_DIR/<service>-server.pfx # для каждого процесса свой"
|
||||
echo " DEAL_MTLS_SERVER_CERT_PASSWORD=$CERT_PASSWORD"
|
||||
echo " DEAL_MTLS_CLIENT_CERT_PFX=$CERT_DIR/deal-client.pfx"
|
||||
echo " DEAL_MTLS_CLIENT_CERT_PASSWORD=$CERT_PASSWORD"
|
||||
#!/usr/bin/env sh
|
||||
#
|
||||
# mtls-certs.sh — генерация dev-CA и сертификатов mTLS внутреннего gRPC «Дейла» (этап 7, Ruling 6, Task 13).
|
||||
#
|
||||
# Требуется openssl. Вывод — deploy/certs/:
|
||||
# ca.pem / ca.key — dev-CA (ca.pem — runtime всех процессов, ca.key — только подпись новых
|
||||
# сертификатов; хранится в deploy/certs, в репозиторий/образ не попадает,
|
||||
# см. .dockerignore; права 600);
|
||||
# <service>-server.pfx — серверный сертификат процесса: core, telegram-service, ai-service,
|
||||
# ml-service (SAN: localhost + имя compose-сервиса + host.docker.internal);
|
||||
# deal-client.pfx — общий клиентский сертификат исходящих каналов (core → сервисы,
|
||||
# telegram-service → core-ингресс);
|
||||
# deal-client.crt/.key — тот же клиентский сертификат в PEM (без пароля): нужен docker
|
||||
# healthcheck'ам compose.prod при mTLS — grpc_health_probe принимает
|
||||
# только PEM (флаги -tls-client-cert/-tls-client-key; см. Task 14).
|
||||
#
|
||||
# Сертификаты dev (срок 825 дней — как Let's Encrypt; CA — 10 лет). Пароль PFX — env DEAL_MTLS_CERT_PASSWORD
|
||||
# (по умолчанию deal_mtls_dev_password — dev); в PROD задайте свой и пропишите его же в
|
||||
# DEAL_MTLS_SERVER_CERT_PASSWORD / DEAL_MTLS_CLIENT_CERT_PASSWORD процессов (compose-prod — Task 14).
|
||||
# Повторный запуск без -f отказывается перезаписывать существующую CA (смена CA ломает доверие всех
|
||||
# сертификатов — перегенерация нужна осознанная). Subject-имена задаются config-файлами openssl, а не
|
||||
# -subj, — скрипт работает и в Git Bash (MSYS не «съедает» аргументы вида /CN=...).
|
||||
#
|
||||
# Использование:
|
||||
# scripts/mtls-certs.sh [-f] [DEAL_MTLS_CERT_PASSWORD=...]
|
||||
#
|
||||
# После генерации процессы поднимаются с env (пути — как смонтировано в compose-prod, Task 14):
|
||||
# DEAL_MTLS_ENABLED=1
|
||||
# DEAL_MTLS_CA_PEM=/etc/deal/certs/ca.pem
|
||||
# DEAL_MTLS_SERVER_CERT_PFX=/etc/deal/certs/<service>-server.pfx
|
||||
# DEAL_MTLS_SERVER_CERT_PASSWORD=<тот же пароль>
|
||||
# DEAL_MTLS_CLIENT_CERT_PFX=/etc/deal/certs/deal-client.pfx
|
||||
# DEAL_MTLS_CLIENT_CERT_PASSWORD=<тот же пароль>
|
||||
#
|
||||
# Dev-стек (deploy/compose.dev.yml) остаётся plaintext + service-token — DEAL_MTLS_ENABLED не задаётся.
|
||||
|
||||
set -eu
|
||||
|
||||
# Корень репозитория (каталог скрипта/..).
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
ROOT_DIR=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
|
||||
CERT_DIR="$ROOT_DIR/deploy/certs"
|
||||
|
||||
# Пароль PFX: env DEAL_MTLS_CERT_PASSWORD (dev-дефолт — фиксированное значение; PROD — свой).
|
||||
CERT_PASSWORD="${DEAL_MTLS_CERT_PASSWORD:-deal_mtls_dev_password}"
|
||||
|
||||
# Сроки сертификатов (дней): CA — 10 лет, листовые — 825 (как Let's Encrypt).
|
||||
CA_DAYS=3650
|
||||
LEAF_DAYS=825
|
||||
|
||||
# Пространство имён сертификатов: SAN листовых — localhost + имя compose-сервиса + host.docker.internal
|
||||
# (dev-прогон сервиса в контейнере против core на хосте, Ruling 12) + loopback-IP.
|
||||
SERVICES="core telegram-service ai-service ml-service"
|
||||
|
||||
FORCE=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-f|--force) FORCE=1 ;;
|
||||
*)
|
||||
echo "mtls-certs: неизвестный аргумент: $arg (ожидалось -f/--force)" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if ! command -v openssl >/dev/null 2>&1; then
|
||||
echo "mtls-certs: openssl не найден — установите openssl (например: apt install openssl / brew install openssl)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$CERT_DIR"
|
||||
|
||||
if [ -f "$CERT_DIR/ca.pem" ]; then
|
||||
if [ "$FORCE" -eq 0 ]; then
|
||||
echo "mtls-certs: $CERT_DIR/ca.pem уже существует — повторный запуск не перезаписывает CA" >&2
|
||||
echo "mtls-certs: (смена CA ломает доверие выданных сертификатов). Перегенерировать всё: $0 -f" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Принудительная перегенерация: старые сертификаты удаляются целиком (их доверие утеряно вместе с CA).
|
||||
rm -f "$CERT_DIR/ca.pem" "$CERT_DIR/ca.key" "$CERT_DIR/ca.srl" "$CERT_DIR"/*-server.pfx \
|
||||
"$CERT_DIR/deal-client.pfx" "$CERT_DIR/deal-client.crt" "$CERT_DIR/deal-client.key"
|
||||
fi
|
||||
|
||||
# Временный каталог для CSR/конфигов/ключей (всегда чистится, в т.ч. при ошибке).
|
||||
TMP_DIR=$(mktemp -d "${TMPDIR:-/tmp}/deal-mtls.XXXXXX")
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT HUP INT TERM
|
||||
|
||||
echo "mtls-certs: генерирую dev-CA (CN=Deal mTLS Dev CA, ${CA_DAYS} дн.) в $CERT_DIR ..."
|
||||
cat > "$TMP_DIR/ca.cnf" <<'EOF'
|
||||
[req]
|
||||
distinguished_name = ca_dn
|
||||
prompt = no
|
||||
x509_extensions = ca_ext
|
||||
|
||||
[ca_dn]
|
||||
CN = Deal mTLS Dev CA
|
||||
|
||||
[ca_ext]
|
||||
basicConstraints = critical,CA:TRUE
|
||||
keyUsage = critical,keyCertSign,cRLSign,digitalSignature
|
||||
subjectKeyIdentifier = hash
|
||||
EOF
|
||||
openssl req -x509 -newkey rsa:2048 -sha256 -days "$CA_DAYS" -nodes \
|
||||
-config "$TMP_DIR/ca.cnf" \
|
||||
-keyout "$CERT_DIR/ca.key" \
|
||||
-out "$CERT_DIR/ca.pem"
|
||||
chmod 600 "$CERT_DIR/ca.key"
|
||||
chmod 644 "$CERT_DIR/ca.pem"
|
||||
|
||||
# ── Серверные сертификаты процессов (SAN: localhost + compose-имя + host.docker.internal) ──
|
||||
|
||||
for service in $SERVICES; do
|
||||
echo "mtls-certs: серверный сертификат $service (${LEAF_DAYS} дн., SAN: localhost,$service,host.docker.internal) ..."
|
||||
cat > "$TMP_DIR/server-req.cnf" <<EOF
|
||||
[req]
|
||||
distinguished_name = server_dn
|
||||
prompt = no
|
||||
|
||||
[server_dn]
|
||||
CN = $service
|
||||
EOF
|
||||
cat > "$TMP_DIR/server-ext.cnf" <<EOF
|
||||
[server]
|
||||
basicConstraints = critical,CA:FALSE
|
||||
keyUsage = critical,digitalSignature,keyEncipherment
|
||||
extendedKeyUsage = serverAuth
|
||||
subjectAltName = DNS:localhost,DNS:${service},DNS:host.docker.internal,IP:127.0.0.1
|
||||
EOF
|
||||
openssl req -new -newkey rsa:2048 -nodes \
|
||||
-config "$TMP_DIR/server-req.cnf" \
|
||||
-keyout "$TMP_DIR/server.key" \
|
||||
-out "$TMP_DIR/server.csr"
|
||||
openssl x509 -req \
|
||||
-in "$TMP_DIR/server.csr" \
|
||||
-CA "$CERT_DIR/ca.pem" \
|
||||
-CAkey "$CERT_DIR/ca.key" \
|
||||
-CAcreateserial \
|
||||
-out "$TMP_DIR/server.crt" \
|
||||
-days "$LEAF_DAYS" -sha256 \
|
||||
-extfile "$TMP_DIR/server-ext.cnf" \
|
||||
-extensions server
|
||||
openssl pkcs12 -export \
|
||||
-out "$CERT_DIR/$service-server.pfx" \
|
||||
-inkey "$TMP_DIR/server.key" \
|
||||
-in "$TMP_DIR/server.crt" \
|
||||
-name "$service-server" \
|
||||
-passout "pass:$CERT_PASSWORD"
|
||||
rm -f "$TMP_DIR/server.key" "$TMP_DIR/server.csr" "$TMP_DIR/server.crt"
|
||||
done
|
||||
|
||||
# ── Общий клиентский сертификат deal-client (исходящие каналы core и telegram-service) ──
|
||||
|
||||
echo "mtls-certs: клиентский сертификат deal-client (${LEAF_DAYS} дн.) ..."
|
||||
cat > "$TMP_DIR/client-req.cnf" <<'EOF'
|
||||
[req]
|
||||
distinguished_name = client_dn
|
||||
prompt = no
|
||||
|
||||
[client_dn]
|
||||
CN = deal-client
|
||||
EOF
|
||||
cat > "$TMP_DIR/client-ext.cnf" <<'EOF'
|
||||
[client]
|
||||
basicConstraints = critical,CA:FALSE
|
||||
keyUsage = critical,digitalSignature
|
||||
extendedKeyUsage = clientAuth
|
||||
EOF
|
||||
openssl req -new -newkey rsa:2048 -nodes \
|
||||
-config "$TMP_DIR/client-req.cnf" \
|
||||
-keyout "$TMP_DIR/client.key" \
|
||||
-out "$TMP_DIR/client.csr"
|
||||
openssl x509 -req \
|
||||
-in "$TMP_DIR/client.csr" \
|
||||
-CA "$CERT_DIR/ca.pem" \
|
||||
-CAkey "$CERT_DIR/ca.key" \
|
||||
-CAcreateserial \
|
||||
-out "$TMP_DIR/client.crt" \
|
||||
-days "$LEAF_DAYS" -sha256 \
|
||||
-extfile "$TMP_DIR/client-ext.cnf" \
|
||||
-extensions client
|
||||
openssl pkcs12 -export \
|
||||
-out "$CERT_DIR/deal-client.pfx" \
|
||||
-inkey "$TMP_DIR/client.key" \
|
||||
-in "$TMP_DIR/client.crt" \
|
||||
-name "deal-client" \
|
||||
-passout "pass:$CERT_PASSWORD"
|
||||
|
||||
# PEM-копии клиентского сертификата для grpc_health_probe compose.prod при mTLS (Task 14): утилита
|
||||
# принимает только PEM и без пароля. Сертификат публичный; приватный ключ — права 600, как ca.key.
|
||||
openssl pkcs12 -in "$CERT_DIR/deal-client.pfx" -clcerts -nokeys -passin "pass:$CERT_PASSWORD" \
|
||||
-out "$CERT_DIR/deal-client.crt"
|
||||
openssl pkcs12 -in "$CERT_DIR/deal-client.pfx" -nocerts -nodes -passin "pass:$CERT_PASSWORD" \
|
||||
-out "$CERT_DIR/deal-client.key"
|
||||
chmod 644 "$CERT_DIR/deal-client.crt"
|
||||
chmod 600 "$CERT_DIR/deal-client.key"
|
||||
|
||||
echo
|
||||
echo "mtls-certs: готово. Файлы в $CERT_DIR:"
|
||||
ls -1 "$CERT_DIR"
|
||||
echo
|
||||
echo "Пароль PFX (DEAL_MTLS_CERT_PASSWORD): $CERT_PASSWORD"
|
||||
echo "Проверка (SAN/подпись): openssl pkcs12 -in $CERT_DIR/<service>-server.pfx -nokeys | openssl verify -CAfile $CERT_DIR/ca.pem"
|
||||
echo
|
||||
echo "Включение mTLS (dev остаётся plaintext — флаг не задаётся; PROD env передаёт compose-prod, Task 14):"
|
||||
echo " DEAL_MTLS_ENABLED=1"
|
||||
echo " DEAL_MTLS_CA_PEM=$CERT_DIR/ca.pem"
|
||||
echo " DEAL_MTLS_SERVER_CERT_PFX=$CERT_DIR/<service>-server.pfx # для каждого процесса свой"
|
||||
echo " DEAL_MTLS_SERVER_CERT_PASSWORD=$CERT_PASSWORD"
|
||||
echo " DEAL_MTLS_CLIENT_CERT_PFX=$CERT_DIR/deal-client.pfx"
|
||||
echo " DEAL_MTLS_CLIENT_CERT_PASSWORD=$CERT_PASSWORD"
|
||||
|
||||
+183
-183
@@ -1,183 +1,183 @@
|
||||
#!/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>…);
|
||||
# без 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-<TS>.dump (docker exec deal-postgres); при DEAL_PG_HOST —
|
||||
# прямое pg_restore --clean --if-exists поверх существующей БД;
|
||||
# minio — mc mirror $BACKUP_DIR/minio/backup-<TS>/ → бакет deal-files (восстановление = загрузка
|
||||
# В MinIO); DEAL_MINIO_MIRROR_REMOVE=1 добавляет --remove (бакет = снимок 1:1);
|
||||
# data — распаковка $BACKUP_DIR/data/backup-<TS>.tar.gz в DEAL_DATA_DIR; при DEAL_TAR_VOLUMES —
|
||||
# архивы backup-<TS>.<volume>.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-<TS>.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"
|
||||
#!/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>…);
|
||||
# без 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-<TS>.dump (docker exec deal-postgres); при DEAL_PG_HOST —
|
||||
# прямое pg_restore --clean --if-exists поверх существующей БД;
|
||||
# minio — mc mirror $BACKUP_DIR/minio/backup-<TS>/ → бакет deal-files (восстановление = загрузка
|
||||
# В MinIO); DEAL_MINIO_MIRROR_REMOVE=1 добавляет --remove (бакет = снимок 1:1);
|
||||
# data — распаковка $BACKUP_DIR/data/backup-<TS>.tar.gz в DEAL_DATA_DIR; при DEAL_TAR_VOLUMES —
|
||||
# архивы backup-<TS>.<volume>.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-<TS>.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"
|
||||
|
||||
+23
-23
@@ -1,23 +1,23 @@
|
||||
#!/usr/bin/env sh
|
||||
# test.sh — тесты всех сервисов Дейла + линтер локализации фронта. CI-шаг «test».
|
||||
set -e
|
||||
|
||||
REPO_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||
|
||||
for PROJECT in \
|
||||
src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj \
|
||||
src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj \
|
||||
src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj \
|
||||
src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj \
|
||||
src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj; do
|
||||
echo "== test: $PROJECT =="
|
||||
(cd "$REPO_DIR" && dotnet test "$PROJECT" --nologo --logger "console;verbosity=minimal")
|
||||
done
|
||||
|
||||
echo
|
||||
echo "== frontend: lint:i18n =="
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
(cd "$REPO_DIR/src/frontend" && npm run lint:i18n)
|
||||
else
|
||||
echo " пропуск: npm не установлен (линтер локализации не запущен)"
|
||||
fi
|
||||
#!/usr/bin/env sh
|
||||
# test.sh — тесты всех сервисов Дейла + линтер локализации фронта. CI-шаг «test».
|
||||
set -e
|
||||
|
||||
REPO_DIR=$(cd "$(dirname "$0")/.." && pwd)
|
||||
|
||||
for PROJECT in \
|
||||
src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj \
|
||||
src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj \
|
||||
src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj \
|
||||
src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj \
|
||||
src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj; do
|
||||
echo "== test: $PROJECT =="
|
||||
(cd "$REPO_DIR" && dotnet test "$PROJECT" --nologo --logger "console;verbosity=minimal")
|
||||
done
|
||||
|
||||
echo
|
||||
echo "== frontend: lint:i18n =="
|
||||
if command -v npm >/dev/null 2>&1; then
|
||||
(cd "$REPO_DIR/src/frontend" && npm run lint:i18n)
|
||||
else
|
||||
echo " пропуск: npm не установлен (линтер локализации не запущен)"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user