# 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
```

### Восстановление БД из дампа

> ⚠️ Перезаписывает данные. Сначала остановить 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`).
