food-market/docs/RUNBOOK.md
nns cf760fab10
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
feat(s26): flaky-test detection + observability dashboards (8/8 ✓ 10/10 cert)
После 24 спринтов regress-suite разросся; нестабильность блокирует
доверие. Этот спринт: ловит flaky тесты, добавляет observability
(Grafana + Prometheus alerts + RUNBOOK), сертифицирует 10× cert-прогон.

1. tests/regression/find-flaky.sh — 10× прогон + JSON-агрегатор →
   docs/flaky-tests.md (per-test pass/fail sequence + reproduce).
2. OrgFactory.signupWithRetry теперь honors Retry-After header
   (api-client.ts:ApiError.retryAfterSec). Stage rate-limit поднят:
   RATE_SIGNUP_HOUR=5000, RATE_PER_IP_MIN=5000 (~/food-market-stage/deploy/.env).
3. fullyParallel=true + workers=4 = тесты идут в недетерминированном
   порядке; isolation работает (OrgFactory per-test).
4. workers=4 даёт **2.4× ускорение** (66.6s → 27.7s). Worker-scoped
   фикстура lib/worker-org.ts добавлена как opt-in.
5. deploy/grafana/dashboards/quality-watchdog.json (10 панелей:
   smoke success ratio 7d, incidents, multi-tenant violations,
   current emoji, p95 by endpoint, step failures, RPS, DB p95,
   docs posted, disk free) + dashboards/README.md.
   quality-watchdog.sh пишет Prometheus textfile экспорт в
   ~/.fm-watchdog/textfile/quality_watchdog.prom для node_exporter.
6. deploy/prometheus/alerts.yml — 10 правил, 4 группы (uptime,
   errors, database, quality-watchdog). MultiTenantViolation = P0.
   deploy/prometheus/prometheus.yml — reference config.
7. docs/RUNBOOK.md +178 строк: action per alert (api-down,
   rps-drop, http-errors-spike/growing, doc-posting-errors,
   db-p95-high, disk-free-low, watchdog-red, multi-tenant-violation,
   watchdog-incident). Junior-friendly с конкретными командами.

**Cert-прогон (10× workers=4):** 420/420 passed, 0 flaky, avg 30.1s/run,
total 300.6s (< 5min budget).

Изменения вне репо:
- ~/food-market-stage/deploy/.env — RATE_* limits bumped.
- ~/quality-watchdog.sh — добавлен .prom textfile экспорт.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 14:44:19 +05:00

576 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Runbook — операционные процедуры food-market
Что делать, когда что-то идёт не так, или когда нужно сделать
неавтоматическую операцию.
## Контактные точки
| Что | Где |
|---|---|
| Stage URL | https://test.admin.food-market.kz |
| Prod URL | https://admin.food-market.kz (план, ещё не задеплоен) |
| Stage VM | `nns@192.168.1.190` (через ssh, prod-vm в локалке) |
| Dev VM (этот хост) | `nns@<this>` — здесь крутится локальный API/Postgres + локальный Forgejo + локальный Docker registry |
| Forgejo (primary git) | http://127.0.0.1:3000/nns/food-market.git |
| GitHub (mirror) | https://github.com/nurdotnet/food-market (только зеркало) |
| Local Docker registry | `192.168.1.193:5001` (memory: `local_docker_registry`) |
| Hangfire Dashboard (stage) | https://test.admin.food-market.kz/hangfire — только SuperAdmin |
| Swagger (stage) | https://test.admin.food-market.kz/swagger |
## Health-чеки
| Endpoint | Что значит | Что делать при 503 |
|---|---|---|
| `GET /health` | Процесс отвечает | Контейнер живёт, проблема в обвязке (nginx/cert/DNS). |
| `GET /health/live` | Процесс жив (без проверок) | То же. |
| `GET /health/ready` | БД отвечает + миграции применены | См. ниже «Health/ready упал». |
| `GET /metrics` | Prometheus exposition | Если 404 — приложение не стартануло. |
### `/health/ready` упал
1. `ssh nns@192.168.1.190 'docker logs --tail 100 food-market-stage-api'`
стек ошибки на старте.
2. Типичные причины:
- **Миграция упала**: ищем `Failed executing DbCommand` / `relation
"..." already exists`. Решение: миграция конфликтует со снапшотом
БД. Возможно её надо переписать с `IF NOT EXISTS` (см.
`Phase6e_RetailSaleReturns.cs` как пример «defensive migration»).
- **OpenIddict cert pass mismatch**: переменная
`OpenIddict__CertPassword` в docker-compose env'е не совпадает с
паролем PFX-файла → `CryptographicException: PKCS12 password incorrect`.
- **Connection refused**: Postgres контейнер не успел подняться.
`depends_on.condition: service_healthy` должно это покрывать,
но если healthcheck не успел — `docker compose restart api`.
3. Если фикс требует кода — `~/deploy-stage.sh` после правки.
## Деплой на stage
```bash
~/deploy-stage.sh
```
Скрипт делает:
1. `docker build` api и web с локальным registry в качестве кеша.
2. `docker push` обоих образов в `192.168.1.193:5001`.
3. `ssh nns@192.168.1.190``docker compose -p food-market-stage pull api web``up -d --force-recreate`.
4. Ждёт `https://test.admin.food-market.kz/health/ready` до 30с.
**Важно**: проект `docker compose` называется `food-market-stage`
(флаг `-p food-market-stage`). См. инцидент ниже про project name.
## Бэкап и восстановление
### Расписание
systemd-таймер `food-market-backup.timer` (см. `deploy/`) запускается
**каждый день в 03:00 локального времени** prod-vm. Запускается через
`OnCalendar=*-*-* 03:00:00` + `Persistent=true` (догоняет пропущенные
если сервер был выключен).
Скрипт `food-market-backup.sh`:
- `pg_dump -Fc` из контейнера `food-market-postgres``db-<TS>.dump`.
- `tar czf` каталога `/opt/food-market-data/uploads``uploads-<TS>.tgz`.
- Удаляет файлы старше 30 дней (`FM_BACKUP_RETENTION_DAYS`).
Папка: `/opt/food-market-data/backups/`.
### Ручной бэкап
```bash
ssh nns@192.168.1.190 'sudo /opt/food-market/deploy/food-market-backup.sh'
```
Или из репо разработчика:
```bash
deploy/backup.sh --remote 192.168.1.190:5434 # PG в Docker exposed на 5434
```
### Recovery drill (RTO ≈ 25 секунд на сегодняшних данных)
Sprint 15 — verified восстановление stage'а в свежий PG-контейнер на dev-vm:
| Шаг | Время |
|---|---|
| `pg_dump -Fc` из stage-postgres | **~2 секунды** (на 1.5k чеков / 200 продуктов) |
| Создать чистый Docker-контейнер `postgres:16-alpine` | ~1 сек |
| `pg_restore --clean --if-exists --no-owner --no-privileges` | **~4 секунды** |
| Поднять API против восстановленной БД | ~19 сек (cold-start dotnet + migrations) |
| `/health/ready``{"status":"Healthy"}` | подтверждено |
| **Всего RTO (single-instance)** | **~25 секунд** |
Команды, выполненные в drill'е:
```bash
# 1. Снять бэкап со stage'а
ssh nns@192.168.1.190 'docker exec food-market-stage-postgres-1 \
pg_dump -U food_market -d food_market -Fc -f /tmp/drill.dump'
ssh nns@192.168.1.190 'docker cp food-market-stage-postgres-1:/tmp/drill.dump /tmp/drill.dump'
scp nns@192.168.1.190:/tmp/drill.dump /tmp/drill.dump
# 2. Чистый PG
docker run -d --name drill-pg \
-e POSTGRES_DB=food_market \
-e POSTGRES_USER=food_market \
-e POSTGRES_PASSWORD=drill_pass \
-p 127.0.0.1:5499:5432 postgres:16-alpine
# 3. Восстановление
docker cp /tmp/drill.dump drill-pg:/tmp/drill.dump
docker exec drill-pg pg_restore -U food_market -d food_market \
--clean --if-exists --no-owner --no-privileges /tmp/drill.dump
# 4. Проверка: API на восстановленной БД
ASPNETCORE_ENVIRONMENT=Production \
ConnectionStrings__Default="Host=localhost;Port=5499;Database=food_market;Username=food_market;Password=drill_pass" \
Hangfire__Enabled=false \
ASPNETCORE_URLS="http://127.0.0.1:5099" \
RateLimiting__Enabled=false \
dotnet run --project src/food-market.api
curl http://127.0.0.1:5099/health/ready
# → {"status":"Healthy", checks:[{"name":"database","status":"Healthy",
# "description":"БД доступна, миграции применены."}]}
# Очистка
docker rm -f drill-pg
```
Для прод-данных большего объёма (50k+ чеков) RTO будет ~2-5 минут — но
порядок остаётся: pg_restore линейно по данным + API startup константный.
### Восстановление БД из дампа
> ⚠️ Перезаписывает данные. Сначала остановить API.
```bash
ssh nns@192.168.1.190
cd /opt/food-market
# 1. Остановить API/Web, оставить Postgres
docker compose -p food-market-stage stop api web
# 2. Применить дамп
DUMP=/opt/food-market-data/backups/db-YYYYMMDD-HHMMSS.dump
docker exec -i food-market-stage-postgres \
pg_restore -U food_market -d food_market \
--clean --if-exists --no-owner --no-privileges \
< "$DUMP"
# 3. Поднять API обратно — миграции применятся автоматически (idempotent)
docker compose -p food-market-stage up -d api web
# 4. Проверить
curl https://test.admin.food-market.kz/health/ready
```
### Восстановление uploads
```bash
ssh nns@192.168.1.190
cd /opt/food-market-data
sudo tar xzf backups/uploads-YYYYMMDD-HHMMSS.tgz
# Содержимое восстанавливается в текущий каталог (uploads/...)
```
### Полный disaster-recovery (новый сервер)
1. Поднять Docker, склонировать репо в `/opt/food-market`.
2. Скопировать бэкапы в `/opt/food-market-data/backups/`.
3. Запустить пустой стек:
```bash
cd /opt/food-market/deploy
docker compose -p food-market-stage up -d postgres
docker compose -p food-market-stage exec postgres pg_isready
```
4. Применить дамп (см. выше).
5. Восстановить uploads.
6. Запустить остальное: `docker compose -p food-market-stage up -d`.
7. Поднять nginx + сертификат (см. `docs/stage-access.md`).
8. Включить таймер бэкапов:
```bash
sudo cp deploy/food-market-backup.{service,timer} /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now food-market-backup.timer
```
## Перенос на другой сервер
1. На старом — снять свежий бэкап вручную.
2. На новом — поднять Docker, склонировать репо, восстановить (см. выше).
3. Обновить DNS A-запись `admin.food-market.kz` на новый IP.
4. Дождаться распространения DNS (TTL).
5. Старый сервер — выключить через 24 часа (для гарантии).
## Смена SDK-версии
> ⚠️ `global.json` фиксирует `8.0.417` с `rollForward: latestFeature`.
> Менять только когда вышел новый patch и Microsoft анонсировал
> EOL текущего. memory: НЕ переключать systemwide postgres версию.
1. На dev-машине: `dotnet --list-sdks` — проверить что новая версия
установлена.
2. Обновить `global.json` → новый patch.
3. `dotnet build` + `dotnet test`.
4. `deploy/Dockerfile.api` — обновить `FROM mirror/dotnet-sdk:X.Y`
(если тэг изменился).
5. `~/deploy-stage.sh` — задеплоить, проверить `/health/ready`.
6. Verify-suite (Playwright или вручную smoke).
7. Только после этого — менять на prod-машине.
## Логи
| Где | Что |
|---|---|
| `docker logs food-market-stage-api` (по контейнеру) | Console JSON Serilog. |
| `/opt/food-market-data/api-logs/` (Docker volume) | Файлы Serilog rolling. |
| `journalctl -u food-market-backup.service --no-pager` | Логи бэкапа. |
| Hangfire Dashboard `/hangfire` | Состояние фоновых джобов, истории, ошибки. |
Формат JSON-логов — структурированный, каждая запись содержит
`CorrelationId`, `OrgId`, `UserId` (через `LogEnrichmentMiddleware`).
Поиск по `CorrelationId` восстанавливает полный trace запроса.
## Метрики
Prometheus scrape: `GET /metrics` (без auth). Локально в проекте нет
prometheus-сервера — на stage его тоже пока нет; план — поднять
prometheus + grafana отдельным compose'ом и proxy через nginx.
Ключевые метрики (`food-market.api/Infrastructure/Observability/AppMetrics.cs`):
- `food_market_posted_total{document_type="..."}` — счётчик post'ов.
- `food_market_unposted_total{document_type="..."}` — счётчик unpost'ов.
- `food_market_db_query_duration_seconds_*` — гистограмма EF-запросов
(interceptor).
- Стандартные prometheus-net: `http_requests_received_total`,
`http_request_duration_seconds`, `dotnet_collection_count_total`,
etc.
## Известные инциденты
### Инцидент 1: docker-compose project name
**Симптом** (наблюдался при первой миграции на новый stage):
- `docker compose pull && up -d` создавали контейнеры с именами
`deploy-api-1` вместо ожидаемых `food-market-stage-api`.
- Healthcheck'и `depends_on` отрабатывали по новым именам, но nginx
configurated на старые — 502 Bad Gateway.
**Причина**: `docker compose` берёт project name из имени каталога,
если не указан `-p`. Каталог `deploy/` → project=`deploy` → контейнеры
с префиксом `deploy-`. Старые контейнеры с префиксом `food-market-stage-`
оставались стопнутыми, новые поднялись параллельно (Docker не считает
их дубликатами потому что имена разные).
**Решение**: всегда передавать `-p food-market-stage`. Сделано в
`~/deploy-stage.sh`. На prod ставить аналогичный wrapper-скрипт,
не запускать `docker compose` голым из `/opt/food-market/deploy`.
**Превенция**: в будущем — `COMPOSE_PROJECT_NAME=food-market-stage`
в `/etc/environment` на серверах, чтобы голый `docker compose` тоже
не промахивался.
### Инцидент 2: GHCR network flakiness
**Симптом**: docker push/pull в `ghcr.io` периодически зависает на
2-5 минут или падает по TCP-таймауту.
**Причина**: исходящая сеть с dev-vm к github.com нестабильна
(memory: `network_github_flaky`).
**Решение**: используем **локальный Docker registry** на
`192.168.1.193:5001` как primary, ghcr только как mirror (для
external CI/CD когда понадобится). Stage compose тянет с локального
(`REGISTRY=192.168.1.193:5001`). См. memory `local_docker_registry`.
### Инцидент 3: OpenIddict cert rotation
**Симптом**: после `docker compose down -v` (с удалением volume
`api-data`) OpenIddict не может расшифровать существующие refresh-токены
→ все пользователи разлогинены.
**Причина**: keys из `App_Data/oidc-keys/` пропали вместе с volume.
**Решение / превенция**:
- НИКОГДА не делать `down -v` на stage/prod без явного намерения.
- Хранить `App_Data` volume отдельно: `volumes: api-data:` с
`external: true` (план).
- Бэкап `App_Data` вместе с БД (TODO: добавить в `food-market-backup.sh`).
### Инцидент 4: rate-limiter eager-config
**Симптом** (в integration-тестах): тесты падают с `429 Too Many Requests`
после ~5 signup'ов.
**Причина**: `RateLimiting:Enabled=true` (default) читается ЭАГЕРНО при
регистрации сервисов; `ConfigureAppConfiguration` в `WebApplicationFactory`
применяется позже и не успевает override'нуть.
**Решение**: в integration-тестах ставим `RateLimiting__Enabled=false`
через переменную окружения **до** создания factory. Сделано в
`ApiFactory` static-конструкторе. Memory: `test_suites_setup`.
### Инцидент 5: Telegram chat-id привязка
**Симптом**: владелец org вводит chat_id, сервер тестирует отправку →
`403 Forbidden` от Telegram API.
**Причина**: пользователь не отправил `/start` боту перед привязкой,
бот не может писать первым.
**Решение / превенция**: UI показывает инструкцию «1. Откройте бота → `/start`.
2. Получите chat_id у `@userinfobot`. 3. Введите.» Это идёт сверху на
странице привязки. Бэкенд возвращает ошибку с понятным текстом.
### Инцидент 6: Identity password policy
**Симптом**: signup-форма принимает пароль `12345`, потом
`/connect/token` отшивает «Invalid credentials» — потому что Identity
сам не разрешил создать пользователя с таким паролем, но контроллер
проглотил ошибку.
**Превенция**: контроллер `AuthController.Signup` теперь возвращает
`IdentityResult.Errors` массивом → фронт показывает причину.
## Troubleshooting на стороне БД
### Большой `org_audit_log`
`prune-audit-log` каждый день чистит >180 дней; если каталог-tenant
делал массовый импорт (10к товаров за раз), таблица может вырасти на
порядок. Проверка:
```sql
SELECT pg_size_pretty(pg_total_relation_size('org_audit_log'));
SELECT count(*) FROM org_audit_log WHERE created_at < now() - interval '30 days';
```
Ручная чистка:
```sql
DELETE FROM org_audit_log WHERE created_at < now() - interval '90 days';
VACUUM ANALYZE org_audit_log;
```
### Stock-агрегат расходится с движениями
Инвариант: `stocks.quantity = SUM(stock_movements.quantity)` per
`(product_id, store_id)`. Если разошёлся (баг где-то не вызвали
`IStockService.ApplyMovementAsync`):
```sql
-- найти расхождения
SELECT s.product_id, s.store_id, s.quantity AS cached,
COALESCE(SUM(m.quantity), 0) AS actual
FROM stocks s
LEFT JOIN stock_movements m
ON m.product_id = s.product_id AND m.store_id = s.store_id
GROUP BY s.product_id, s.store_id, s.quantity
HAVING s.quantity <> COALESCE(SUM(m.quantity), 0);
-- пересчитать всё (под maintenance window!)
UPDATE stocks s SET quantity = COALESCE((
SELECT SUM(quantity) FROM stock_movements m
WHERE m.product_id = s.product_id AND m.store_id = s.store_id
), 0);
```
### `__EFMigrationsHistory` рассинхрон
Бывает после ручной правки миграции после её применения.
```sql
SELECT * FROM "__EFMigrationsHistory" ORDER BY 1 DESC LIMIT 5;
```
Если в коде есть миграция, которой нет в таблице — `db.Database.Migrate()`
попытается её применить (что обычно и нужно). Если в таблице есть запись,
а файла нет — обратное направление (миграция была удалена) — `Migrate()`
не упадёт, но фокус с EF Tools перестанет работать, см. memory
`feedback_ef_migrations`.
## Sprint 26 — Alert response (`deploy/prometheus/alerts.yml`)
Каждый alert в `alerts.yml` имеет `runbook` label — anchor сюда.
Junior-разработчик находит alert в Telegram, кликает runbook_url, попадает
на соответствующий раздел.
### api-down
**Alert:** `ApiDown``up{job="food-market-api"} == 0` 1 минуту.
**Что значит:** Prometheus не может scrap'нуть `/metrics`. API либо упал,
либо порт недоступен.
**Действия:**
1. `curl -fsS https://test.admin.food-market.kz/health/ready`
подтверди что API недоступен (или вернулся).
2. `ssh nns@192.168.1.190 'docker ps | grep food-market-stage-api'`
контейнер живой?
3. Если контейнер в `Restarting`: `docker logs --tail 200 food-market-stage-api-1`
— стек ошибки старта (часто миграция / OpenIddict cert mismatch).
4. Если контейнер OK, но не отвечает: `docker exec food-market-stage-api-1 curl
-s http://localhost:8080/health` (внутренний порт). Если внутри отвечает,
проблема в nginx/proxy цепочке.
5. Recovery: `cd ~/food-market-stage/deploy && docker compose -p
food-market-stage up -d --force-recreate api`.
6. Если не помогло: `~/deploy-stage.sh` с локального dev-vm (полный
build+push+restart).
### rps-drop
**Alert:** `RpsDropped50Percent` — RPS за 5 мин <50% от того же окна час
назад, при условии что было >0.5 rps.
**Действия:**
1. `curl https://test.admin.food-market.kz/health/ready` — API живой?
2. `ssh nns@192.168.1.190 'docker logs --tail 50 food-market-stage-api-1'`
— внезапные ошибки на старте/в логе.
3. Проверь DNS из дома/мобильной сети: `dig admin.food-market.kz`
возможно сломалась запись.
4. Откати последний deploy: `cd ~/food-market-stage/deploy && git log -1
--format=%H && docker compose pull && docker compose up -d`. Если
откат на предыдущий image помог — баг в новом коде, см. логи.
### http-errors-spike
**Alert:** `HttpErrorsSpike` — доля 5xx >10% уже 5 минут.
**Действия:**
1. Logs: `ssh nns@192.168.1.190 'docker logs --tail 200 food-market-stage-api-1 | grep -iE "(error|exception)"'`.
2. Какая ручка валится: `curl https://test.admin.food-market.kz/metrics |
grep 'http_requests_received_total.*code="5'` — топ-3 контроллеров.
3. Hangfire-failed: `curl -u admin /hangfire/jobs/failed` (нужен
SuperAdmin login).
4. Часто — БД упала. См. `db-p95-high` раздел ниже.
5. Если баг локален (только одна ручка валится): найди фикс, deploy.
### http-errors-growing
**Alert:** `HttpErrorRateGrowing` — производная 5xx растёт >10%/min 10 мин.
**Действия:** Постепенная деградация, не emergency. Часто — память течёт
или коннекшен-пул исчерпывается.
1. Memory: `docker stats food-market-stage-api-1` (ratio %).
2. PG connections: `psql ... -c "SELECT count(*), state FROM pg_stat_activity GROUP BY state"`
— если `idle in transaction` много, есть leak.
3. Restart api: `docker compose -p food-market-stage restart api`
куплено время.
4. Найди корень в логах — кто часто получает Exception.
### doc-posting-errors
**Alert:** `DocumentPostingErrors` — типа документов > 0.05 ошибки/сек 5 мин.
**Действия:**
1. `docker logs food-market-stage-api-1 | grep "Posting failed"`
ищи название документа в логе.
2. Hangfire failed: документы постятся через Hangfire-job — посмотри
`/hangfire/jobs/failed`.
3. Stock-инвариант: `Posting failed: stock would be negative` означает
попытку списать больше чем есть. Это бизнес-уровневая ошибка, не баг.
Сообщи владельцу org. Если ошибок много — возможно баг в pre-validate.
4. Concurrent posting: `Posting failed: serialization conflict` — это
Sprint 23 `SerializationConflictMiddleware` ловит и возвращает 409.
Если не возвращает 409 а 500 — middleware сломался, проверь deploy.
### db-p95-high
**Alert:** `DbQueryP95High` — p95 DB-запросов >500ms 10 минут подряд.
**Действия:**
1. Самые медленные запросы:
```sql
SELECT calls, mean_exec_time, total_exec_time, query
FROM pg_stat_statements ORDER BY mean_exec_time DESC LIMIT 10;
```
2. Если статистика устарела: `ANALYZE` всей БД (`vacuum-top-tables`
Hangfire-job делает это раз в неделю, см. `DatabaseMaintenanceJobs`).
3. Lock'и: `SELECT * FROM pg_locks WHERE NOT granted;` — заблокирована ли
какая-то таблица.
4. Disk: см. `disk-free-low` ниже — если IO упирается в диск.
5. Если новый медленный запрос в логе после deploy — откати relevant
контроллер.
### disk-free-low
**Alert:** `DiskFreeLow`< 5 ГБ свободно на mount.
**Действия:**
1. `df -h` какой mount упал.
2. Logs: `du -sh /var/lib/docker/containers/*/`. Логи Docker'a иногда
разрастаются. Truncate: `truncate -s 0 /var/lib/docker/containers/*/.log`.
3. БД growth: `psql -c "SELECT pg_size_pretty(pg_database_size('food_market'))"`.
Если >50 ГБ — запусти PruneStockMovements + VACUUM FULL под maintenance
(см. ниже).
4. Quality-watchdog test orgs: `PruneQualityTestOrgs` Hangfire-job (cron
02:30 UTC) удаляет старые `quality-{epoch}-*` org'и (см.
`[[sprint25_done]]`). Если не отработал: trigger вручную через
`/hangfire`.
5. Очисти `~/.fm-watchdog/quality.log.*` старее 14 дней (auto-ротация уже есть).
### watchdog-red
**Alert:** `WatchdogLastRunRed` — quality-watchdog последний прогон красный
>5 мин.
**Действия:**
1. Открой `docs/quality-status.md` в репо — сразу видно какой шаг упал.
2. Тот же шаг сам и воспроизведёшь:
```bash
/home/nns/quality-watchdog.sh # сразу прогоняет всё
tail -50 ~/.fm-watchdog/quality.log # детали последнего шага
```
3. Дальше зависит от шага:
- `health` — см. `api-down`.
- `auth_me` / `products` / `signalr` — см. `http-errors-spike`.
- `multi_tenant` — см. `multi-tenant-violation` (КРИТИЧНО).
- `perf` — см. `db-p95-high`.
- `ui_flow` — Playwright-тест. Прогон вручную: `cd tests/regression &&
pnpm exec playwright test flows/03-catalog.spec.ts --grep "3.1" --headed`.
### multi-tenant-violation 🚨
**Alert:** `MultiTenantViolation` — шаг multi_tenant упал в последнем часу.
**ЭТО P0.** Org B видит данные A — это утечка между арендаторами.
**Действия (немедленные):**
1. Останови stage'е приём новых signup'ов (косвенно — поставь
`RateLimiting__SignupPerIpPerHour=0`, redeploy api).
2. `tail -100 ~/.fm-watchdog/quality.log` — детали leak'а (`get_code`,
`list_total`).
3. В коде проверь `AppDbContext.ApplyTenantQueryFilter` (см. `[[sprint22_done]]`):
```bash
grep -n "ApplyTenantQueryFilter\|IgnoreQueryFilters" src/food-market.infrastructure/Persistence/
```
Кто-то добавил `IgnoreQueryFilters()` где не надо? Это самая частая
причина leak'ов.
4. Воспроизведи руками: создай 2 org'и (`curl POST /api/auth/signup` × 2),
токен для каждого, попробуй cross-access. Если воспроизводится —
фикс ASAP.
5. После фикса — `~/deploy-stage.sh`, дождись зелёного watchdog'a.
6. Если на prod уже катилось: notify владельцев (через Telegram-summary
job), audit-log за последние 48ч (`/api/admin/audit?since=...`) на
подозрительные cross-org операции.
### watchdog-incident
**Alert:** `WatchdogIncidentCreated` — 2+ подряд красных прогона ⇒ incident-файл.
**Действия:**
1. `ls -lt ~/.fm-watchdog/incident-*.txt | head -3` — последние инциденты.
2. `cat ~/.fm-watchdog/incident-{...}.txt` — описание + действия.
3. Server-Claude автоматически получит этот файл в очередь (через
`~/fm-watchdog.sh` → ротацию queue). Не дублируй — он начнёт фикс сам.
4. Если хочешь форсировать вмешательство: тот же файл sent'нул в
`~/.fm-watchdog/queue/0000-incident-XXX.txt` (uname-prefix `0000`
первый в очереди).
## Что НЕ делать
- НЕ менять `global.json` без явного решения (CLAUDE.md).
- НЕ переключать systemwide postgres версию через brew (поломает
смежные проекты в `~/Documents/devprojects/`).
- НЕ запускать `docker compose down -v` на stage/prod (потеря volume).
- НЕ делать миграции через `dotnet ef migrations add` — снапшот в репо
не синхронный с моделью, генератор выдаст ерунду. Пишем руками.
- НЕ редактировать тот же файл одновременно с Mac-Claude (memory:
`feedback_serialize_edits`).