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>
19 KiB
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 упал
ssh nns@192.168.1.190 'docker logs --tail 100 food-market-stage-api'— стек ошибки на старте.- Типичные причины:
- Миграция упала: ищем
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.
- Миграция упала: ищем
- Если фикс требует кода —
~/deploy-stage.shпосле правки.
Деплой на stage
~/deploy-stage.sh
Скрипт делает:
docker buildapi и web с локальным registry в качестве кеша.docker pushобоих образов в192.168.1.193:5001.ssh nns@192.168.1.190→docker compose -p food-market-stage pull api web→up -d --force-recreate.- Ждёт
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/.
Ручной бэкап
ssh nns@192.168.1.190 'sudo /opt/food-market/deploy/food-market-backup.sh'
Или из репо разработчика:
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'е:
# 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.
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
ssh nns@192.168.1.190
cd /opt/food-market-data
sudo tar xzf backups/uploads-YYYYMMDD-HHMMSS.tgz
# Содержимое восстанавливается в текущий каталог (uploads/...)
Полный disaster-recovery (новый сервер)
- Поднять Docker, склонировать репо в
/opt/food-market. - Скопировать бэкапы в
/opt/food-market-data/backups/. - Запустить пустой стек:
cd /opt/food-market/deploy docker compose -p food-market-stage up -d postgres docker compose -p food-market-stage exec postgres pg_isready - Применить дамп (см. выше).
- Восстановить uploads.
- Запустить остальное:
docker compose -p food-market-stage up -d. - Поднять nginx + сертификат (см.
docs/stage-access.md). - Включить таймер бэкапов:
sudo cp deploy/food-market-backup.{service,timer} /etc/systemd/system/ sudo systemctl daemon-reload sudo systemctl enable --now food-market-backup.timer
Перенос на другой сервер
- На старом — снять свежий бэкап вручную.
- На новом — поднять Docker, склонировать репо, восстановить (см. выше).
- Обновить DNS A-запись
admin.food-market.kzна новый IP. - Дождаться распространения DNS (TTL).
- Старый сервер — выключить через 24 часа (для гарантии).
Смена SDK-версии
⚠️
global.jsonфиксирует8.0.417сrollForward: latestFeature. Менять только когда вышел новый patch и Microsoft анонсировал EOL текущего. memory: НЕ переключать systemwide postgres версию.
- На dev-машине:
dotnet --list-sdks— проверить что новая версия установлена. - Обновить
global.json→ новый patch. dotnet build+dotnet test.deploy/Dockerfile.api— обновитьFROM mirror/dotnet-sdk:X.Y(если тэг изменился).~/deploy-stage.sh— задеплоить, проверить/health/ready.- Verify-suite (Playwright или вручную smoke).
- Только после этого — менять на 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_Datavolume отдельно: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к товаров за раз), таблица может вырасти на
порядок. Проверка:
SELECT pg_size_pretty(pg_total_relation_size('org_audit_log'));
SELECT count(*) FROM org_audit_log WHERE created_at < now() - interval '30 days';
Ручная чистка:
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):
-- найти расхождения
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 рассинхрон
Бывает после ручной правки миграции после её применения.
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).