# 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@` — здесь крутится локальный 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-.dump`. - `tar czf` каталога `/opt/food-market-data/uploads` → `uploads-.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 ``` ### Восстановление БД из дампа > ⚠️ Перезаписывает данные. Сначала остановить 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`).