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>
398 lines
19 KiB
Markdown
398 lines
19 KiB
Markdown
# 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`).
|