Нормализовать переводы строк в 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:
Rustam Khalimov
2026-09-11 19:01:42 +03:00
parent 39c9bdc1b7
commit 713d554dc2
751 changed files with 94507 additions and 94486 deletions
+196 -196
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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).
+98 -98
View File
@@ -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
+56 -56
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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