food-market/docs/RUNBOOK.md
nns 9588d03bf4 test(s15): axe a11y + focus traps + unit coverage 80% + property tests + backup drill
Sprint 15 финальный — реальные axe + coverage + pg_restore numbers.

Ключевые цифры:
- axe-core: critical=0 on 10 страниц stage'а; serious 12→9
  после фиксов (sidebar contrast + 8 icon-only back-arrow aria-labels).
- Unit coverage: Application 56%→83%, Domain 11%→79%, combined
  60%→80%. Тестов 68→147 (+79).
- Backup recovery drill: RTO ~25 секунд end-to-end
  (pg_dump 2s + pg_restore 4s + dotnet startup 19s).

Что сделано:
1. @axe-core/playwright + stage-ui-15 (10 страниц) + stage-ui-16
   (SR smoke на login: getByLabel, role=alert, aria-describedby,
   keyboard nav).
2. useFocusTrap hook (WCAG 2.4.3 + 2.1.2): return-focus, mount-focus,
   Tab cycle. Подключён к Modal + ConfirmDialog с opt-in
   defaultFocus='cancel'|'confirm'. ConfirmDialog по дефолту фокусит
   Cancel для destructive actions (safer чем Enter→Delete).
3. A11y фиксы:
   • text-slate-400→text-slate-500 в sidebar (contrast 2.63→4.61).
   • 8 страниц edit с back-arrow Link — aria-label + aria-hidden
     на иконке + текст-slate-500 цвет.
   • Modal close button — то же.
   • LoginPage — aria-invalid/aria-describedby/role=alert на
     ошибках валидации.
   • Field component — role="alert" на error span (announce'ит SR).
4. 8 файлов unit-тестов: PhoneNormalization, PagedRequest,
   RequiredGuid, RolePermissions (Domain), DomainPocoSmoke,
   DomainFullPropertyTouch, CatalogDtosSmoke, StockServiceProperty
   (4 seeds × 4 size + batch + 2-product isolation).
5. Backup-drill: pg_dump со stage'а → fresh postgres:16-alpine →
   pg_restore → dotnet run против восстановленной БД → /health/ready
   Healthy. Команды и timing в RUNBOOK.md.
6. Docs review:
   • MULTI-TENANCY чеклист «добавить tenant-сущность» расширен с 6
     до 19 шагов (Domain → EF Config → Migration с Xmin →
     RolePermissions → Validation → Controller + RequiresPermission →
     Audit + SensitiveOpsAudit → property tests).
   • ARCHITECTURE.md — Sprint 13-15 changes таблица.
   • DEVELOPER-GUIDE.md — «что добавилось после первого guide'а» +
     a11y pitfalls в «что НЕ делать».

Stage smoke ✓. Это финальный автономно-безопасный спринт. Дальше
нужен вход от user'а (ОФД keys, MoySklad tokens, Windows для POS,
прод-деплой план, kz-перевод, реальный SMTP).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 14:53:38 +05:00

398 lines
19 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`.
## Что НЕ делать
- НЕ менять `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`).