diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 745baa4..d170780 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -24,5 +24,39 @@ jobs: cache: npm cache-dependency-path: src/frontend/package-lock.json - - name: CI (сборка, тесты, скан уязвимостей, фронт) - run: sh scripts/ci.sh + - name: Сборка 5 решений + run: | + dotnet build src/core/Deal.sln --nologo -v q + dotnet build src/telegram-service/Deal.Telegram.sln --nologo -v q + dotnet build src/ai-service/Deal.Ai.sln --nologo -v q + dotnet build src/ml-service/Deal.Ml.sln --nologo -v q + dotnet build src/storage-service/Deal.Storage.sln --nologo -v q + + - name: Тесты + run: | + dotnet test src/core/tests/Deal.Tests.Unit/Deal.Tests.Unit.csproj --nologo + dotnet test src/telegram-service/Deal.Telegram.Tests/Deal.Telegram.Tests.csproj --nologo --no-build + dotnet test src/ai-service/Deal.Ai.Tests/Deal.Ai.Tests.csproj --nologo --no-build + dotnet test src/ml-service/Deal.Ml.Tests/Deal.Ml.Tests.csproj --nologo --no-build + dotnet test src/storage-service/Deal.Storage.Tests/Deal.Storage.Tests.csproj --nologo --no-build + + - name: Скан уязвимых NuGet-зависимостей + run: | + fail=0 + 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=$(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" + fail=1 + fi + done + exit $fail + + - name: Фронтенд (сборка + линтер i18n) + run: | + cd src/frontend + npm ci + npm run build + npm run lint:i18n diff --git a/.gitignore b/.gitignore index e3b359a..f7d0f31 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ __pycache__/ !deploy/gitea-runner/.env.example deploy/gitea-runner/.env deploy/gitea-runner/data/ + +# Служебные скрипты не входят в репозиторий (правило: в репе только код) — живут только локально +scripts/ diff --git a/README.md b/README.md index 2703acc..3974b86 100644 --- a/README.md +++ b/README.md @@ -31,5 +31,8 @@ cd src/frontend && npm run dev ``` Вход: `admin` / `admin`. Оператор-консоль: `http://localhost:5173/#/operator` (`operator` / `operator`). -Сквозная проверка стека: `sh scripts/dev-smoke.sh`. Сборка: `sh scripts/build.sh`; тесты: `sh scripts/test.sh`; -полный CI-прогон (сборка + тесты + скан уязвимостей + фронт): `sh scripts/ci.sh`. + +Сборка: `dotnet build src/core/Deal.sln` (и аналогично для sln в `src/telegram-service`, `src/ai-service`, +`src/ml-service`, `src/storage-service`). Тесты: `dotnet test src/core/tests/Deal.Tests.Unit`. +Полный CI-прогон (сборка 5 решений + тесты + скан уязвимостей + фронт) — `.gitea/workflows/ci.yml`. +Служебные скрипты в репозиторий не входят — живут только локально, вне кода. diff --git a/docs/spec/Код-стайл-Дейл.md b/docs/spec/Код-стайл-Дейл.md index b7e0d3c..b46658f 100644 --- a/docs/spec/Код-стайл-Дейл.md +++ b/docs/spec/Код-стайл-Дейл.md @@ -268,9 +268,8 @@ ## 13. Автоматизация -- **Исправление существующего кода** (идемпотентные скрипты в `scripts/`): - - `fix_summary_blocks.py --check | --apply` — приводит `` к блочному виду (§5). - - `fix_private_docs.py --check | --preview | --apply` — понижает XML-док с private/internal до `//` (§5). +- **Служебные скрипты (codemod'ы, скрипты сборки/тестов/бэкапов) в репозиторий не входят** — + правило владельца: в репе только код. Актуальные копии живут локально, вне кода. - **Проверка на новом коде**: правила ``-блока и «комментарии только на public» проверяемы статически; задел — линтер (по аналогии с `scripts/i18n-lint.mjs`) и/или анализаторы Roslyn/StyleCop в `Directory.Build.props`. diff --git a/docs/superpowers/STATUS.md b/docs/superpowers/STATUS.md index 75a31ab..832fd64 100644 --- a/docs/superpowers/STATUS.md +++ b/docs/superpowers/STATUS.md @@ -32,6 +32,14 @@ > Сборка 5 sln 0/0; тесты: core **1340/1340**, telegram **130/130**, ai **52/52**, ml **38/38**, storage **9/9** — зелёные. > Детали — `docs/spec/Код-стайл-аудит-2026-09-11.md`, §2. > +> **2026-09-11 (ночь) — Gitea-контур.** Репозиторий запушен (`gitea.khomegeneric.keenetic.pro/rust/Deal`), +> ~4000 спам-пользователей вычищено, регистрация закрыта владельцем. CI перенесён в +> `.gitea/workflows/ci.yml`; первый прогон поймал и закрыл кроссплатформенный баг +> (`ModelPool` валидировал tenant-id через `GetInvalidFileNameChars` — на Linux пропускал `\`); +> валидация заменена на явный белый список. **`scripts/` исключён из репозитория** (правило владельца: +> в репе только код) — скрипты сборки/тестов/бэкапов/кодмоды живут только локально; CI-workflow +> переписан inline. Раннер для CI — на сервере рядом с Gitea (пакет `deploy/gitea-runner/`). +> > **2026-09-11 (поздний вечер) — явные реализации интерфейсов (вариант A, решение владельца).** Правило > владельца: классы напрямую не вызываем (исключения — DTO, хелперы, экстеншены), тесты — через > интерфейсы, моки — NSubstitute, маркерные классы не используем. Сделано: 161 член в 30 прод-файлах diff --git a/scripts/backup.sh b/scripts/backup.sh deleted file mode 100644 index 2f7c8d3..0000000 --- a/scripts/backup.sh +++ /dev/null @@ -1,196 +0,0 @@ -#!/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-/ - 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" diff --git a/scripts/build.sh b/scripts/build.sh deleted file mode 100644 index 2d3297f..0000000 --- a/scripts/build.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/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 diff --git a/scripts/ci.sh b/scripts/ci.sh deleted file mode 100644 index a876f87..0000000 --- a/scripts/ci.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/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 ОК ==" diff --git a/scripts/deal-backup-lib.sh b/scripts/deal-backup-lib.sh deleted file mode 100644 index 590874c..0000000 --- a/scripts/deal-backup-lib.sh +++ /dev/null @@ -1,184 +0,0 @@ -#!/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 -} diff --git a/scripts/dedup_summary_inheritdoc.py b/scripts/dedup_summary_inheritdoc.py deleted file mode 100644 index 9cedda8..0000000 --- a/scripts/dedup_summary_inheritdoc.py +++ /dev/null @@ -1,190 +0,0 @@ -#!/usr/bin/env python3 -"""Заменить дублирующий реализации на (код-стайл Дейла). - -Консервативно: - * собираются тексты членов интерфейсов (блоки, состоящие только из ); - * в типах с интерфейсной базой такой же по тексту блок-док заменяется на `/// `; - * блоки с ///... и 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"", re.IGNORECASE) -SUMMARY_CLOSE_RE = re.compile(r"", 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}/// ") - 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()) diff --git a/scripts/dev-smoke.sh b/scripts/dev-smoke.sh deleted file mode 100644 index 4bf0d92..0000000 --- a/scripts/dev-smoke.sh +++ /dev/null @@ -1,216 +0,0 @@ -#!/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 diff --git a/scripts/fix_private_docs.py b/scripts/fix_private_docs.py deleted file mode 100644 index d2fadf0..0000000 --- a/scripts/fix_private_docs.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Понижение XML-док комментариев с private/internal членов до обычных `//` (код-стайл «Дейл» §5). - -Правило: XML-doc — только на public/protected. Приватные детали реализации — при необходимости короткий -обычный комментарий. Скрипт сохраняет текст (теги / и т.п. разворачиваются в читаемый вид). - -Режимы: - 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"\s*(.*?)\s*", r"\1", t, flags=re.S) - t = re.sub(r"\s*(.*?)\s*", r"\1", t, flags=re.S) - t = re.sub(r"", r"\1", t) - t = re.sub(r"", r"\1", t) - t = re.sub(r"\s*(.*?)\s*", r"\1: \2", t, flags=re.S) - t = re.sub(r"\s*(.*?)\s*", r"\1: \2", t, flags=re.S) - t = re.sub(r"\s*(.*?)\s*", r"Возвращает: \1", t, flags=re.S) - t = re.sub( - r"\s*(.*?)\s*", - r"Исключение \1: \2", - t, - flags=re.S, - ) - t = re.sub(r"", r"\1", t) - t = re.sub(r"", "", t) - t = re.sub(r"", "", t) - t = re.sub(r"", "", 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("" in b or "" 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() diff --git a/scripts/fix_summary_blocks.py b/scripts/fix_summary_blocks.py deleted file mode 100644 index dcfa8f5..0000000 --- a/scripts/fix_summary_blocks.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Приведение XML-док к блочному виду (действующий код-стайл «Дейл»). - -Правило: открывающий и закрывающий — каждый на своей строке. -Однострочная запись `/// текст` не допускается. - -Режимы: - 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 "" not in content and "" not in content: - return [body] - base = indent + "///" - - def tag(text=""): - return base + (" " + text if text else "") - - if "" in content and "" in content: - pre, rest = content.split("", 1) - between, post = rest.split("", 1) - res = [] - if pre.strip(): - res.append(tag(pre.strip())) - res.append(tag("")) - if between.strip(): - res.append(tag(between.strip())) - res.append(tag("")) - if post.strip(): - res.append(tag(post.strip())) - return res - - if "" in content: - pre, after = content.split("", 1) - res = [] - if pre.strip(): - res.append(tag(pre.strip())) - res.append(tag("")) - if after.strip(): - res.append(tag(after.strip())) - return res - - if "" in content: - pre, after = content.split("", 1) - res = [] - if pre.strip(): - res.append(tag(pre.strip())) - res.append(tag("")) - 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() diff --git a/scripts/loadtest/README.md b/scripts/loadtest/README.md deleted file mode 100644 index 3bc4e8a..0000000 --- a/scripts/loadtest/README.md +++ /dev/null @@ -1,54 +0,0 @@ -# Нагрузочный смоук-тест 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). diff --git a/scripts/loadtest/api-loadtest.sh b/scripts/loadtest/api-loadtest.sh deleted file mode 100644 index e9c30fd..0000000 --- a/scripts/loadtest/api-loadtest.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/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 diff --git a/scripts/loadtest/deal-loadtest.js b/scripts/loadtest/deal-loadtest.js deleted file mode 100644 index 73fc83d..0000000 --- a/scripts/loadtest/deal-loadtest.js +++ /dev/null @@ -1,56 +0,0 @@ -// Нагрузочный смоук-тест 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); -} diff --git a/scripts/make_explicit.py b/scripts/make_explicit.py deleted file mode 100644 index 6765151..0000000 --- a/scripts/make_explicit.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -"""Codemod: неявные реализации интерфейсов -> явные (§11, вариант A — прод). - -Только production-код (/tests/ не трогает). Card (DTO) пропускается. -Логика: таблицы членов интерфейсов -> маппинг класс -> интерфейсы (включая partial) -> -конвертация public-члена с совпавшим именем и сигнатурой в `Тип IFoo.Член`. -Ошибочный конверсионный файл откатывается компиляторным циклом (git checkout). - -Запуск: python scripts/make_explicit.py [--apply] [--file <путь>] -""" -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"] -# файлы-исключения: DTO/модели, члены которых — собственный публичный API -EXCLUDE_FILES = {"src/core/Deal.Modules.Cards/Application/Models/Card.cs"} - -IFACE_DECL = re.compile( - r"^\s*(?:public\s+|internal\s+)?(?:partial\s+|sealed\s+|static\s+|unsafe\s+)*interface\s+(\w+)", re.M) -CLASS_DECL = re.compile( - r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*" - r"class\s+(\w+)(?:<[^>]*>)?[^{;]*:\s*([^{;]+)", re.M) -PARTIAL_CLASS = re.compile( - r"^\s*(?:public\s+|internal\s+)?(?:sealed\s+|abstract\s+|static\s+|partial\s+|unsafe\s+)*" - r"class\s+(\w+)(?:<[^>]*>)?\s*(?::[^{;]+)?\{", re.M) -IFNAME = re.compile(r"\bI[A-Z]\w*") -METHOD_DECL = re.compile( - r"^(?P\s*)(?P(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|" - r"virtual\s+|override\s+|sealed\s+|async\s+|new\s+)*)" - r"(?P[\w<>\[\],\s.?]+?)\s(?P\w+)\s*(?:<[^>]*>)?\s*\(") -PROP_DECL = re.compile( - r"^(?P\s*)(?P(?:public\s+|internal\s+|protected\s+|private\s+|static\s+|" - r"virtual\s+|override\s+|sealed\s+|new\s+)*)" - r"(?P[\w<>\[\],\s.?]+?)\s(?P\w+)\s*\{\s*(?P(?:public\s+)?(?:get|set|init)[^}]*)\}") - - -def src_files() -> list[Path]: - out = [] - for root in ROOTS: - if not root.exists(): - continue - for p in root.rglob("*.cs"): - s = p.as_posix().lower() - if "/bin/" in s or "/obj/" in s or "/tests/" in s or ".tests/" in s: - continue - out.append(p) - return out - - -def interface_body(text: str, start: int) -> str: - b = text.find("{", start) - if b == -1: - return "" - depth, end = 0, b - for i in range(b, len(text)): - if text[i] == "{": - depth += 1 - elif text[i] == "}": - depth -= 1 - if depth == 0: - end = i - break - return text[b:end] - - -def norm_types(params: str) -> tuple[str, ...]: - if not params.strip(): - return () - out, depth, cur = [], 0, "" - for ch in params: - if ch in "<([": - depth += 1 - elif ch in ">)]": - depth -= 1 - if ch == "," and depth == 0: - out.append(cur) - cur = "" - else: - cur += ch - out.append(cur) - res = [] - for raw in out: - t = re.sub(r"\s+", " ", raw.strip()) - parts = t.rsplit(" ", 1) - if len(parts) == 2 and not re.search(r"[<>\[\](),]", parts[1]) and parts[1] not in ("*", "&"): - t = parts[0] - res.append(t.replace("?", "").replace(" ", "")) - return tuple(res) - - -def collect_interfaces(files: list[Path]) -> dict[str, dict[str, dict]]: - tables: dict[str, dict[str, dict]] = {} - for p in files: - text = p.read_text(encoding="utf-8") - for m in IFACE_DECL.finditer(text): - name = m.group(1) - body = interface_body(text, m.start()) - lines = body.splitlines() - i = 0 - while i < len(lines): - line = lines[i] - mm = METHOD_DECL.match(line) - if mm and "(" in line: - # сигнатура может быть многострочной (§7: параметры на отдельных строках) - sig_text = line - while sig_text.count("(") > sig_text.count(")") and i + 1 < len(lines): - i += 1 - sig_text += " " + lines[i].strip() - if sig_text.count("(") > sig_text.count(")"): - i += 1 - continue - params = sig_text.split("(", 1)[1].rsplit(")", 1)[0] - tables.setdefault(name, {})[mm.group("name")] = {"kind": "method", "sig": norm_types(params)} - i += 1 - continue - pm = PROP_DECL.match(line) - if pm: - tables.setdefault(name, {})[pm.group("name")] = { - "kind": "prop", "sig": pm.group("type").replace("?", "").replace(" ", "")} - i += 1 - return tables - - -def collect_class_ifaces(files: list[Path]) -> dict[str, set[str]]: - result: dict[str, set[str]] = {} - for p in files: - rel = p.relative_to(REPO).as_posix() - if rel in EXCLUDE_FILES: - continue - text = p.read_text(encoding="utf-8") - for m in CLASS_DECL.finditer(text): - ifaces = set(IFNAME.findall(m.group(2))) - if ifaces: - result.setdefault(m.group(1), set()).update(ifaces) - return result - - -def convert_file(path: Path, cls_ifaces: dict[str, set[str]], itables: dict[str, dict[str, dict]]): - text = path.read_text(encoding="utf-8") - lines = text.splitlines(keepends=True) - local: set[str] = set() - for m in PARTIAL_CLASS.finditer(text): - if m.group(1) in cls_ifaces: - local.add(m.group(1)) - if not local: - return 0, None, [] - allowed = {i for cls in local for i in cls_ifaces[cls] if i in itables} - - changed, notes = 0, [] - i = 0 - while i < len(lines): - line = lines[i] - stripped = line.lstrip() - if stripped.startswith("//") or stripped.startswith("#"): - i += 1 - continue - mm = METHOD_DECL.match(line.rstrip("\r\n")) - pm = None if mm else PROP_DECL.match(line.rstrip("\r\n")) - if not mm and not pm: - i += 1 - continue - kind = "method" if mm else "prop" - decl = mm or pm - mods = decl.group("mods") - if "static" in mods or "public" not in mods: - i += 1 - continue - name = decl.group("name") - if kind == "method": - sig_text, j = line, i - while sig_text.count("(") > sig_text.count(")") and j + 1 < len(lines): - j += 1 - sig_text += lines[j] - if "(" not in sig_text: - i += 1 - continue - sig = norm_types(sig_text.split("(", 1)[1].rsplit(")", 1)[0]) - else: - sig = decl.group("type").replace("?", "").replace(" ", "") - j = i - cands = [ifc for ifc in allowed - if itables[ifc].get(name, {}).get("kind") == kind and itables[ifc][name]["sig"] == sig] - if not cands: - i += 1 - continue - ifc = cands[0] - head = line.rstrip("\r\n") - eol = line[len(head):] - # убрать модификаторы (оставить отступ), затем квалифицировать имя - new_head = re.sub(r"^(\s*)(?:public\s+|internal\s+|protected\s+|private\s+|virtual\s+|" - r"override\s+|sealed\s+|new\s+)+", r"\1", head) - m2 = re.search(r"\s" + re.escape(name) + r"(?![\w])", new_head) - if not m2: - i += 1 - continue - new_head = new_head[:m2.start()] + " " + ifc + "." + name + new_head[m2.end():] - lines[i] = new_head + eol - changed += 1 - notes.append(f"{path.relative_to(REPO).as_posix()}:{i + 1} -> {ifc}.{name}") - i = j + 1 - return changed, ("".join(lines) if changed else None), notes - - -def main() -> int: - apply = "--apply" in sys.argv - only = sys.argv[sys.argv.index("--file") + 1] if "--file" in sys.argv else None - files = src_files() - itables = collect_interfaces(files) - cls_ifaces = collect_class_ifaces(files) - total, per_file = 0, 0 - for p in files: - if only and p.as_posix() != only: - continue - changed, new_text, notes = convert_file(p, cls_ifaces, itables) - if not changed: - continue - total += changed - per_file += 1 - if apply and new_text is not None: - p.write_bytes(new_text.replace("\r\n", "\n").encode("utf-8")) - if not apply: - for n in notes[:3]: - print(" ", n) - print(f"{'apply' if apply else 'dry-run'}: конверсий {total} в {per_file} файлах") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/scripts/mtls-certs.sh b/scripts/mtls-certs.sh deleted file mode 100644 index e955f8b..0000000 --- a/scripts/mtls-certs.sh +++ /dev/null @@ -1,210 +0,0 @@ -#!/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); -# -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/-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" < "$TMP_DIR/server-ext.cnf" < "$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/-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/-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" diff --git a/scripts/restore.sh b/scripts/restore.sh deleted file mode 100644 index 4dcd40c..0000000 --- a/scripts/restore.sh +++ /dev/null @@ -1,183 +0,0 @@ -#!/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 берётся самый свежий снапшот 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-.dump (docker exec deal-postgres); при DEAL_PG_HOST — -# прямое pg_restore --clean --if-exists поверх существующей БД; -# minio — mc mirror $BACKUP_DIR/minio/backup-/ → бакет deal-files (восстановление = загрузка -# В MinIO); DEAL_MINIO_MIRROR_REMOVE=1 добавляет --remove (бакет = снимок 1:1); -# data — распаковка $BACKUP_DIR/data/backup-.tar.gz в DEAL_DATA_DIR; при DEAL_TAR_VOLUMES — -# архивы backup-..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-.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" diff --git a/scripts/test.sh b/scripts/test.sh deleted file mode 100644 index 476e511..0000000 --- a/scripts/test.sh +++ /dev/null @@ -1,23 +0,0 @@ -#!/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