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

19 KiB
Raw Blame History

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

~/deploy-stage.sh

Скрипт делает:

  1. docker build api и web с локальным registry в качестве кеша.
  2. docker push обоих образов в 192.168.1.193:5001.
  3. ssh nns@192.168.1.190docker compose -p food-market-stage pull api webup -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-postgresdb-<TS>.dump.
  • tar czf каталога /opt/food-market-data/uploadsuploads-<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 (новый сервер)

  1. Поднять Docker, склонировать репо в /opt/food-market.
  2. Скопировать бэкапы в /opt/food-market-data/backups/.
  3. Запустить пустой стек:
    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. Включить таймер бэкапов:
    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к товаров за раз), таблица может вырасти на порядок. Проверка:

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).