Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
# Документация ВМ2 Processing
|
||||
|
||||
Здесь находятся профильные спецификации контура ВМ2. Канонические границы, имена и межсервисные контракты задаёт [`architectory`](../../architectory/README.md); при конфликте действует порядок приоритетов из этого README.
|
||||
|
||||
## Состав ВМ2
|
||||
|
||||
- [`module-03-nginx-vm2.md`](module-03-nginx-vm2.md) — отдельный public webhook ingress и private `:8443`.
|
||||
- [`module-04-redis-vm2.md`](module-04-redis-vm2.md) — Redis Safety.
|
||||
- [`module-05-message-safety.md`](module-05-message-safety.md) — Message Safety v2.
|
||||
- [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md) — CRM sync и exact webhook.
|
||||
- [`module-09-observability-vm2.md`](module-09-observability-vm2.md) — telemetry ВМ2.
|
||||
- [`module-10-deployment-vm2.md`](module-10-deployment-vm2.md) — runbook ВМ2.
|
||||
|
||||
Guest API, frontend, Keycloak, SMS и Bitrix Open Lines local app находятся в [`VM1_app/documentation`](../../VM1_app/documentation/README.md). ВМ2 публикует на `443` только два exact CRM webhook; Safety доступен ВМ1 только через private HTTPS `:8443`.
|
||||
@@ -0,0 +1,145 @@
|
||||
# module-03-vm2. Nginx ВМ2 Processing
|
||||
|
||||
> Статус: целевая спецификация nginx на ВМ2.
|
||||
> Канонический контракт (TLS/ACME, request id, internal 404, logs, reload) — [`arch-08-nginx.md`](../../architectory/arch-08-nginx.md).
|
||||
> Контур ВМ1 — [`module-03-nginx-vm1.md`](../../VM1_app/documentation/module-03-nginx-vm1.md). Guest API, SPA, auth и SMS callback на этой машине не публикуются.
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
Nginx ВМ2 — самостоятельный ingress Processing:
|
||||
|
||||
- public `80/443` на отдельном DNS host: ACME/redirect и два exact CRM webhook;
|
||||
- private `8443` с сертификатом internal CA: только server-to-server Message Safety и approved ops.
|
||||
|
||||
Публичный трафик ВМ2 не проходит через ВМ1. Отказ nginx ВМ1 не прерывает приём CRM webhook. SPA, `/api/`, `/auth/`, WS и Direct SMS callback здесь отсутствуют.
|
||||
|
||||
## 2. Routing matrix
|
||||
|
||||
### Public `80/443`
|
||||
|
||||
| Внешний путь | Upstream | Режим |
|
||||
|---|---|---|
|
||||
| exact `/bitrix/sync/webhook/contact` | `bitrix-sync:8080` | public HTTPS Contact event; source IP CIDR/method/body/rate limits, query-token auth в upstream |
|
||||
| exact `/bitrix/sync/webhook/alert` | `bitrix-sync:8080` | public HTTPS smart-process event; те же ограничения |
|
||||
|
||||
До full sync cutover оба exact webhook route закрыты либо возвращают retryable `503`; успешный `2xx ignored` запрещён.
|
||||
|
||||
Query не участвует в exact location matching: URL штатного робота `/bitrix/sync/webhook/<type>?token=...&ID=...` попадает в соответствующий exact route.
|
||||
|
||||
`/internal/`, `/_internal/`, Redis/OTLP/admin/status/config files на public host → `404`. Fallback на ВМ1 или SPA запрещён. Прочие paths/methods → `404/405`.
|
||||
|
||||
### Private `8443`
|
||||
|
||||
| Path | Local upstream | Caller |
|
||||
|---|---|---|
|
||||
| `/internal/safety/v2/*` | `message-safety-api:8080` | api-backend ВМ1 |
|
||||
| exact `/internal/safety/status` | `message-safety-api:8080/health/ready` | api-backend ВМ1, ops; capability snapshot, не correctness gate |
|
||||
| `/internal/sync/v1/*` | `bitrix-sync:8080` | ops/allow-listed service |
|
||||
|
||||
Public и private server blocks не имеют общего fallback. Safety status alias доступен только на private listener; public `/health/ready` и `/internal/safety/status` не публикуются.
|
||||
|
||||
## 3. Webhook allow-list и preflight
|
||||
|
||||
До proxy nginx проверяет непосредственный source IP по version-controlled `BITRIX_WEBHOOK_ALLOWED_CIDRS`; пустой/невалидный список при enabled receiver блокирует deployment. Адрес из недоверенного `X-Forwarded-For` не используется.
|
||||
|
||||
`BITRIX_SYNC_ENABLED`, public route, readiness и allow-list согласуются одним
|
||||
preflight: disabled требует `deny all;`, enabled — reviewed non-empty CIDR и
|
||||
ready receiver. Обратные комбинации блокируют deployment.
|
||||
|
||||
Запрос вне allow-list получает generic `403` без proxy. В безопасном журнале с ограниченным retention сохраняются только timestamp, source IP, route class и outcome; query/body не сохраняются. Telemetry pipeline экспортирует `webhook_rejected_total{receiver,reason="source_ip"}` без IP label. Allow-list не расширяется автоматически: всплеск Contact, восстановленных инкрементальной reconciliation, инициирует проверку rejected-IP журнала, подтверждение принадлежности адреса Битрикс24 и reviewed reload конфигурации.
|
||||
|
||||
Bitrix webhook retries имеют отдельный достаточный burst, проходят source IP allow-list и проверяют query receiver token в сервисе.
|
||||
|
||||
## 4. Private `8443` fail-closed
|
||||
|
||||
До утверждённого Safety cutover active caller allow-list содержит только `deny all;`; после cutover он совпадает с SG/host-firewall источниками ВМ1. Расхождение любого из трёх слоёв (nginx allow-list, SG, host firewall) блокирует rollout.
|
||||
|
||||
Private listener доверяет forwarded headers только от allow-listed private caller.
|
||||
|
||||
## 5. Listeners и TLS
|
||||
|
||||
- `:80` — только ACME challenge и `308 https://$host$request_uri`;
|
||||
- исключение: `/bitrix/sync/webhook/contact|alert` на HTTP возвращают generic `404/426` **без redirect и отражения query token**;
|
||||
- `:443` — только ACME-совместимый TLS и exact CRM webhook;
|
||||
- private `:8443` — internal CA, SAN совпадает с private DNS именем ВМ2;
|
||||
- public и private сертификаты разные; public key недоступен приложению.
|
||||
|
||||
## 6. Timeouts и body limits ВМ2
|
||||
|
||||
CRM webhook: connect/send/read `3s / 30s / 60s` (arch-08). Private Safety не короче caller budget. JSON body webhook ограничен; file bytes через этот nginx не идут. Cache на webhook выключен.
|
||||
|
||||
## 7. Edge rate limits ВМ2
|
||||
|
||||
Отдельные зоны для двух exact webhook (мягкий burst под retries Битрикс24) и `limit_conn`. Зоны guest API/auth/WS/SPA/SMS на ВМ2 не создаются.
|
||||
|
||||
Ответ превышения — `429`, `Retry-After`, request id. Generic `403` для wrong source IP — до proxy, не `429`.
|
||||
|
||||
## 8. Security headers
|
||||
|
||||
Public webhook: не кэшировать, не ослаблять CSP ради SPA (SPA нет). `Server` / `X-Powered-By` скрыты (arch-08). CORS wildcard с credentials запрещён; webhook не требует browser CORS.
|
||||
|
||||
## 9. Health и synthetic ВМ2
|
||||
|
||||
Внутренний `/nginx-health/live` — arch-08 §8. Synthetic отдельно: HTTP redirect/ACME policy, internal 404 на public host, GET webhook `405/404`, valid отдельный cert, POST webhook allowed/denied source IP без помещения query token в shell history или логи. Private `8443` проверяется с VM1/ops host, не из интернета.
|
||||
|
||||
## 10. Layout и Compose ВМ2
|
||||
|
||||
Каркас arch-08 §10 плюс:
|
||||
|
||||
- отдельный template/server block private `8443`;
|
||||
- snippet/allow-list `BITRIX_WEBHOOK_ALLOWED_CIDRS`;
|
||||
- без `websocket.conf` и без `frontend-static`.
|
||||
|
||||
Public ports: `${NGINX_HTTP_PORT}:80`, `${NGINX_HTTPS_PORT}:443`. Private `8443` публикуется только в сеть/SG, разрешённую ВМ1 и ops — не как internet listener. Детали портов/SG — arch-03 / arch-06 / [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
|
||||
|
||||
## 11. Failure behavior ВМ2
|
||||
|
||||
Дополнительно к arch-08 §12:
|
||||
|
||||
- rollback sync: закрыть public webhook routes либо вернуть retryable `503`; возврат к фиктивному `202 ignored` запрещён;
|
||||
- ВМ1 при этом не изменяется;
|
||||
- Safety slow на private `8443`: не маскировать ошибку `2xx`; caller ВМ1 держит poll budget.
|
||||
|
||||
## 12. Валидация и тесты ВМ2
|
||||
|
||||
Автоматические тесты:
|
||||
|
||||
- CRM webhook exact routes принимают query без изменения location matching;
|
||||
- allowed source IP проксируется, wrong IP получает `403` до upstream;
|
||||
- HTTP webhook URL с query token не перенаправляется на HTTPS и не отражает query в `Location`/error;
|
||||
- source-IP rejects попадают в безопасный bounded-retention журнал и low-cardinality telemetry без query/body/IP label;
|
||||
- disabled/`deny all` и enabled/ready/CIDR preflight: обратные комбинации блокируют deployment;
|
||||
- public `/internal/safety/*` и `/internal/sync/*` → `404`;
|
||||
- private exact `/internal/safety/status` возвращает capability readiness Safety, а соседние неразрешённые health paths → `404`;
|
||||
- private `8443` до cutover `deny all`, после cutover только источники ВМ1;
|
||||
- GET webhook `405/404`; прочие paths `404`;
|
||||
- TLS scan public host и отдельная проверка internal CA на `8443`;
|
||||
- ACME/redirect rehearsal без остановки приёма (кроме контролируемого окна);
|
||||
- logs/traces не содержат query token, form body, Authorization.
|
||||
|
||||
Acceptance не использует public host ВМ1 и не требует running nginx ВМ1.
|
||||
|
||||
## 13. Definition of Done ВМ2
|
||||
|
||||
Дополнительно к arch-08 §13:
|
||||
|
||||
- независимые public `80/443` и private `8443`;
|
||||
- routing matrix §2 покрыта;
|
||||
- CRM webhook достигает ВМ2 напрямую и продолжает приниматься при остановленном nginx ВМ1;
|
||||
- CRM webhook ограничен version-controlled source IP CIDR allow-list; query token и form body отсутствуют в access/error logs и traces;
|
||||
- preflight `BITRIX_SYNC_ENABLED` согласован с route/allow-list/readiness;
|
||||
- private Safety не опубликован на public host.
|
||||
|
||||
## 14. TBD ВМ2
|
||||
|
||||
- N1: доверенные WAF/LB CIDR перед public host ВМ2, если появятся; до этого allow-list — непосредственный source IP.
|
||||
- N3: нужен ли публичный health на processing host.
|
||||
- N6: финальный burst webhook зон.
|
||||
|
||||
## 15. Ссылки
|
||||
|
||||
- Контракт: [`arch-08-nginx.md`](../../architectory/arch-08-nginx.md).
|
||||
- ВМ1: [`module-03-nginx-vm1.md`](../../VM1_app/documentation/module-03-nginx-vm1.md).
|
||||
- Указатель: [`module-03-nginx.md`](module-03-nginx.md).
|
||||
- Safety / sync / observability: [`module-05-message-safety.md`](module-05-message-safety.md), [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md), [`module-09-observability-vm2.md`](module-09-observability-vm2.md).
|
||||
- Деплой: [`module-10-deployment-vm2.md`](module-10-deployment-vm2.md).
|
||||
@@ -0,0 +1,107 @@
|
||||
# module-04-vm2. Redis Safety ВМ2 Processing
|
||||
|
||||
> Статус: целевая спецификация Redis на ВМ2.
|
||||
> Канонический контракт (ключи, TTL, Lua, AOF, ACL, eviction) — [`arch-09-redis.md`](../../architectory/arch-09-redis.md).
|
||||
> Redis DB0/DB1 ВМ1 — [`module-04-redis-vm1.md`](../../VM1_app/documentation/module-04-redis-vm1.md). Hostname Redis ВМ1 не используется.
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
Отдельный instance Redis Safety: hot cache, rate limiting caller и optional worker wake-up. PostgreSQL `message_safety.safety_tasks` — единственный queue/lease source (`FOR UPDATE SKIP LOCKED`, fencing generation). Redis не хранит authoritative task state, locks или leases.
|
||||
|
||||
`bitrix-sync` этот Redis не использует. Guest API rate/idempotency/realtime — на ВМ1.
|
||||
|
||||
Cache loss/restart безопасно восстанавливается из PostgreSQL; Redis outage не выключает core Safety.
|
||||
|
||||
## 2. URL и ACL
|
||||
|
||||
```text
|
||||
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<secret>@redis:6379/0
|
||||
```
|
||||
|
||||
Только на ВМ2. Credential доставляется secret file и не входит в общий `.env` ВМ1. `message_safety` ACL: только prefixes `han:safety:*`. `REDIS_URL` / `REDIS_REALTIME_URL` на ВМ2 отсутствуют.
|
||||
|
||||
Logical DB index `/0` — локальный этой instance, не DB0 ВМ1.
|
||||
|
||||
## 3. Карта ключей
|
||||
|
||||
| Key | Тип/value | TTL |
|
||||
|---|---|---|
|
||||
| `han:safety:rl:service:{caller}:{window}` | counter | window+jitter |
|
||||
| `han:safety:text:{analysis_hash}:{rules_version}` | hot text-rules result, monitor rule ids без raw text | active config, seed ≤48h |
|
||||
| `han:safety:verdict:{content_hash}:{config_version}:{detector_bundle}` | hot file verdict cache | active config, seed ≤30d |
|
||||
| `han:safety:link:{url_hash}:{rules_version}:{config_version}` | stable local policy cache | active config, seed ≤48h |
|
||||
| `han:safety:dns:{host_hash}:{rrtype}` | DNS answer; classification повторяется под текущей policy | actual TTL, active hard max seed 900s |
|
||||
| `han:safety:wakeup` | Pub/Sub notification only | no storage |
|
||||
|
||||
Raw message text, file bytes, presigned URL и task/lease documents запрещены.
|
||||
|
||||
Legacy v1 stub DB2 живёт на ВМ1 и **не** создаётся на ВМ2.
|
||||
|
||||
## 4. Lua scripts ВМ2
|
||||
|
||||
Обязательные:
|
||||
|
||||
- rate-limit evaluate для `han:safety:rl:*`;
|
||||
- при необходимости — atomic cache set с TTL/version stamp.
|
||||
|
||||
Lua `safety task get+increment poll` на ВМ2 **запрещён**: poll/lease принадлежат PostgreSQL. `NOSCRIPT` reload — arch-09 §6.
|
||||
|
||||
## 5. Sizing ВМ2
|
||||
|
||||
```text
|
||||
Redis Safety = hot verdict/link/DNS entries + rate windows + Pub/Sub buffers
|
||||
total × 1.5 allocator/fragmentation × 1.3 growth reserve
|
||||
```
|
||||
|
||||
Volume `redis-data` rebuildable: бизнес-restore из dump не требуется.
|
||||
|
||||
## 6. Degraded behavior ВМ2
|
||||
|
||||
При Redis недоступен:
|
||||
|
||||
- production Safety продолжает task claim/poll через PostgreSQL;
|
||||
- hot cache/rate/wakeup деградируют и прогреваются после восстановления;
|
||||
- Redis hot cache не является core gate `/health/ready` (capability map module-05 / arch-03);
|
||||
- `bitrix-sync` не деградирует из-за этого Redis.
|
||||
|
||||
Restore: поднять пустой или проверенный instance; Safety прогревает cache лениво. Не копировать dump в небезопасное место.
|
||||
|
||||
## 7. Metrics ВМ2
|
||||
|
||||
Общие — arch-09 §14 и [`module-09-observability-vm2.md`](module-09-observability-vm2.md). Дополнительно: Safety hot-cache hit/miss, DNS TTL cap, wakeup subscribers, rate-limit decisions caller.
|
||||
|
||||
## 8. Тесты ВМ2
|
||||
|
||||
- ACL: `message_safety` видит только `han:safety:*`; `han:api:*` / `han:rt:*` недоступны;
|
||||
- порт 6379 недоступен с host/public network и с ВМ1 Docker DNS;
|
||||
- Safety cache loss/rebuild, DNS TTL cap и доказательство отсутствия task/lease state в Redis;
|
||||
- rate Lua concurrency;
|
||||
- `NOSCRIPT` reload;
|
||||
- all application keys имеют TTL (кроме Pub/Sub channel);
|
||||
- max value/invalid serialization; raw text/file не сохраняются;
|
||||
- restart with AOF/RDB, corrupted AOF rehearsal, empty restore;
|
||||
- Redis outage не останавливает PostgreSQL claim/poll;
|
||||
- logs/metrics не содержат secret/value/PII/message text.
|
||||
|
||||
## 9. Definition of Done ВМ2
|
||||
|
||||
Дополнительно к arch-09 §15:
|
||||
|
||||
- prefixes `han:safety:*` реализованы; task/lease keys отсутствуют;
|
||||
- cache loss/rebuild и DNS TTL cap доказаны;
|
||||
- health/degraded §6 согласованы с capability readiness Safety;
|
||||
- dashboards/alerts Redis Safety готовы;
|
||||
- `MESSAGE_SAFETY_REDIS_URL` не появляется в репозитории/Compose ВМ1.
|
||||
|
||||
## 10. TBD ВМ2
|
||||
|
||||
- R1/R2: maxmemory и eviction после load profile Safety.
|
||||
- R4: Safety task TTL/recovery margin — в PostgreSQL / [`module-05-message-safety.md`](module-05-message-safety.md), не в Redis.
|
||||
- R3: имя `MESSAGE_SAFETY_REDIS_URL` — arch-04.
|
||||
|
||||
## 11. Ссылки
|
||||
|
||||
- Контракт: [`arch-09-redis.md`](../../architectory/arch-09-redis.md).
|
||||
- ВМ1: [`module-04-redis-vm1.md`](../../VM1_app/documentation/module-04-redis-vm1.md).
|
||||
- Указатель: [`module-04-redis.md`](module-04-redis.md).
|
||||
- Safety: [`module-05-message-safety.md`](module-05-message-safety.md).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,814 @@
|
||||
# module-07. Полная спецификация `bitrix-sync`
|
||||
|
||||
> Статус: целевая постановка первого функционального релиза CRM-синхронизации.
|
||||
> Заменяет прежнюю спецификацию DB-connectivity stub.
|
||||
> Исторический, неканонический концепт: [`archive/sync-service-concept.md`](../../archive/sync-service-concept.md); для реализации использовать только настоящий документ и architectory.
|
||||
> Связанные контракты: [`arch-01-system-architecture.md`](../../architectory/arch-01-system-architecture.md), [`arch-02-api-contracts.md`](../../architectory/arch-02-api-contracts.md), [`module-01-api-backend.md`](../../VM1_app/documentation/module-01-api-backend.md), [`module-10-deployment-vm2.md`](module-10-deployment-vm2.md).
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
`bitrix-sync` асинхронно связывает пользователя HAN с Contact Битрикс24 и поддерживает согласованное состояние профиля без синхронной зависимости пользовательских API от CRM.
|
||||
|
||||
В первый релиз входят:
|
||||
|
||||
- `contact.map_or_create` — первичный поиск/создание Contact и сохранение связи;
|
||||
- `contact.update` — передача принадлежащих App полей в уже связанный Contact;
|
||||
- `contact.deactivate` — снятие признака активной регистрации и закрытие mapping;
|
||||
- `contact.rebind` — управляемое исправление ошибочной связи с другим Contact;
|
||||
- сигнал HTTP-webhook робота Contact → App и обновление локального профиля;
|
||||
- инкрементальная reconciliation на случай потерянных webhook;
|
||||
- business alerts по конфликтам через смарт-процесс Битрикс24;
|
||||
- durable workflow, batch, rate limit, retry, technical DLQ, audit и observability.
|
||||
|
||||
Вне первого релиза:
|
||||
|
||||
- Lead/Deal;
|
||||
- документы компании в профиле;
|
||||
- автоматический merge CRM-дублей;
|
||||
- публичный/manual replay HTTP API;
|
||||
- backfill пользователей, зарегистрированных до cutover;
|
||||
- полная reconciliation всех Contact; в первом релизе выполняется только инкрементальная сверка;
|
||||
- создание `UserIdentity` или `ClientProfile`;
|
||||
- использование OAuth credential `bitrix-local-app`.
|
||||
|
||||
## 2. Принципы
|
||||
|
||||
1. PostgreSQL — source of truth очереди, workflow и mapping; оперативная память не хранит единственное состояние сценария.
|
||||
2. Delivery semantics — at-least-once. Идемпотентность обязательна для каждой внешней операции.
|
||||
3. Сценарий хранится как state machine, а не как заранее созданный произвольный DAG атомарных команд.
|
||||
4. `han_app.sync_queue` содержит бизнес-намерения App; `bitrix_sync.crm_commands` содержит конкретные вызовы CRM.
|
||||
5. Один CRM-command соответствует одному подзапросу `batch`.
|
||||
6. Телефон используется для поиска только при первичном создании связи.
|
||||
7. После формирования связи Contact читается и обновляется только по `b24_id`.
|
||||
8. Запрашиваются только необходимые поля.
|
||||
9. Business conflicts и technical failures имеют разные журналы и каналы эскалации.
|
||||
10. Секреты и PII не записываются в payload очередей, логи, traces и метрики.
|
||||
11. Runtime `bitrix-sync` не выполняет HTTP-вызовов на ВМ1 и не направляет через неё inbound/outbound CRM traffic; взаимодействие с App идёт через managed PostgreSQL.
|
||||
|
||||
## 3. Владение данными и field mapping
|
||||
|
||||
### 3.1. Mastership
|
||||
|
||||
- `UserIdentity.phone_number` — master App/Keycloak.
|
||||
- `ClientProfile.full_name` — master Битрикс24, источник `Contact.NAME`.
|
||||
- `ClientProfile.citizenship` — master Битрикс24.
|
||||
- `ClientProfile.email` — master Битрикс24.
|
||||
- `user_id` и registration flag — служебные поля интеграции с логическими значениями `active`/`inactive`; в подтверждённом фильтре universal CRM `active` кодируется как `1`.
|
||||
- App DB — локальный read model для UI.
|
||||
- `ClientProfile.foreign_phone` не синхронизируется в первом релизе и не должен создавать `contact.update`.
|
||||
|
||||
После первичного map/create App не отправляет в CRM `full_name`, `citizenship` и `email`. Изменения этих полей приходят только через webhook/reconciliation и записываются с `SET LOCAL han.sync_suppress='true'`.
|
||||
|
||||
### 3.2. Non-secret env mapping
|
||||
|
||||
Физические имена полей конкретного портала задаются non-secret env:
|
||||
|
||||
```text
|
||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_1785934432398
|
||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_1778692456
|
||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_1768493029
|
||||
```
|
||||
|
||||
Значения обязательны при `BITRIX_SYNC_ENABLED=true`, проверяются на startup по допустимому формату имени поля. Изменение требует контролируемого restart.
|
||||
|
||||
Для методов universal CRM физическое имя `UF_CRM_<digits>` детерминированно преобразуется в REST-имя `ufCrm_<digits>`. В частности, `BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_1778692456` используется в `crm.item.list` как `ufCrm_1778692456`. Произвольное изменение регистра или иной алгоритм преобразования запрещены; обе формы валидируются на startup.
|
||||
|
||||
### 3.3. Преобразования
|
||||
|
||||
- телефон принимается только в валидном российском E.164 `+7XXXXXXXXXX`;
|
||||
- sync-service не исправляет `8...`, пробелы, скобки или дефисы;
|
||||
- невалидный телефон — permanent business failure до исправления источника;
|
||||
- поиск выполняется `crm.duplicate.findbycomm` с `type=PHONE`, `entity_type=CONTACT`;
|
||||
- `full_name = Contact.NAME`; `LAST_NAME` и `SECOND_NAME` не конкатенируются;
|
||||
- citizenship в App — display value элемента списка;
|
||||
- Bitrix enum ID и момент загрузки справочника сохраняются в sync snapshot;
|
||||
- email: первый синтаксически валидный элемент с `VALUE_TYPE=WORK`, иначе первый валидный элемент;
|
||||
- отсутствие значения в CRM очищает соответствующее CRM-master поле App.
|
||||
|
||||
Справочник citizenship загружается через `crm.contact.userfield.list`/эквивалентный актуальный метод, кэшируется с TTL и обновляется при неизвестном enum ID. Неизвестный ID не превращается в пустое значение: команда retry один раз после refresh, затем создаётся business alert.
|
||||
|
||||
## 4. Идентичность и mapping
|
||||
|
||||
Каноническая связь хранится только в `bitrix_sync.entity_external_mapping`. Схема `han_app` и `ClientProfile` не хранят `b24_id`: для App это внутренняя деталь интеграции.
|
||||
|
||||
Инварианты:
|
||||
|
||||
- у одного active `user_id` не более одного active Contact mapping;
|
||||
- один `b24_id` не может быть активным для двух active пользователей;
|
||||
- исторические mapping не удаляются;
|
||||
- `user_id` в CRM содержит UUID `UserIdentity.id`;
|
||||
- закрытие mapping фиксирует `closed_at`, `close_reason` и correlation/workflow ID;
|
||||
- mapping, rebind workflow и их аудит принадлежат исключительно `bitrix-sync`.
|
||||
|
||||
Перед телефонным поиском workflow проверяет active mapping по `user_id`. Если mapping отсутствует, выполняется поиск по телефону. Если среди найденных Contact уже есть Contact с тем же `user_id`, локальный mapping восстанавливается без повторного create; CRM update допускается только для отличающегося registration flag.
|
||||
|
||||
## 5. Сценарии
|
||||
|
||||
### 5.1. `contact.map_or_create`
|
||||
|
||||
Вход: eligible active `user_id` (identity и profile находятся в допускающем синхронизацию active status), задача из `han_app.sync_queue`.
|
||||
|
||||
Общий порядок:
|
||||
|
||||
1. Получить актуальные `UserIdentity` и `ClientProfile`.
|
||||
2. Проверить active mapping.
|
||||
3. При отсутствии mapping выполнить `crm.duplicate.findbycomm` по телефону.
|
||||
4. Получить только поля найденных Contact: ID, NAME, PHONE, EMAIL, citizenship, `user_id`, registration flag, CREATED_TIME и source update timestamp.
|
||||
5. Если среди результатов есть Contact с тем же `user_id`, восстановить локальный mapping без повторного create, обновить registration flag только при расхождении и перейти к шагу 9.
|
||||
6. Применить ветку В0–В2 (если на предыдущем шаге не завершен сценарий).
|
||||
7. Обновить Contact.
|
||||
8. Создать active mapping в `bitrix_sync`.
|
||||
9. Для существующего/восстановленного Contact записать его CRM-master поля в App с `SET LOCAL han.sync_suppress='true'` и сохранить source snapshot; для созданного Contact сохранить snapshot фактически записанных значений.
|
||||
10. При наличии конфликта создать/обновить business alert.
|
||||
11. Завершить workflow и исходную queue task.
|
||||
|
||||
Ветки:
|
||||
|
||||
- **В0, Contact не найден:** создать Contact с телефоном, доступными начальными значениями, `user_id`, registration flag `active`; после ambiguous timeout повторить поиск по телефону и не повторять create вслепую.
|
||||
- **В1.1, один Contact без active связи:** записать `user_id`, flag `active`, создать mapping.
|
||||
- **В1.2, один Contact связан с другим active user:** создать новый Contact для текущего пользователя и business alert.
|
||||
- **В2.1, несколько Contact, самый новый свободен:** выбрать самый новый по `CREATED_TIME`, затем по числовому ID как deterministic tie-breaker; создать mapping и alert о дублях.
|
||||
- **В2.2, несколько Contact, выбранный связан с другим active user:** создать новый Contact и alert с полным перечнем candidate ID.
|
||||
|
||||
Если найден Contact со старым `user_id` неактивного/удалённого пользователя:
|
||||
|
||||
- закрыть старый active mapping, если он ещё открыт;
|
||||
- записать новый `user_id` в тот же Contact;
|
||||
- создать новый active mapping;
|
||||
- сохранить историю прежней связи.
|
||||
|
||||
### 5.2. `contact.update`
|
||||
|
||||
Вход: изменение App-master данных уже связанного active пользователя. Исключая изменения, связанные с обновлением данных контакта в Битрикс.
|
||||
|
||||
- Найти Contact только по active mapping.
|
||||
- Отправлять только изменившиеся App-master поля: в первом релизе телефон и при необходимости служебные поля.
|
||||
- При отсутствии mapping не выполнять телефонный поиск: создать/coalesce `contact.map_or_create`.
|
||||
- При `crm.contact.get/update` not found пометить mapping `broken`, сохранить последний profile snapshot и создать business alert.
|
||||
|
||||
### 5.3. `contact.deactivate`
|
||||
|
||||
Триггер: переход `UserIdentity.record_status` или `ClientProfile.record_status` из active в inactive/deleted.
|
||||
|
||||
- Найти Contact по active mapping.
|
||||
- Установить registration flag `inactive`.
|
||||
- Закрыть mapping с причиной деактивации.
|
||||
- Не удалять Contact и историю mapping.
|
||||
- Если Contact уже отсутствует, закрыть mapping как `broken/deactivated`, сохранить profile snapshot и создать alert.
|
||||
|
||||
### 5.4. `contact.rebind`
|
||||
|
||||
Перепривязка не выполняется непосредственным `UPDATE` mapping. Администратор вызывает audited procedure:
|
||||
|
||||
```text
|
||||
bitrix_sync.request_bitrix_contact_rebind(
|
||||
user_id,
|
||||
target_b24_id,
|
||||
reason,
|
||||
operator_id
|
||||
)
|
||||
```
|
||||
|
||||
Процедура:
|
||||
|
||||
1. блокирует текущий active mapping пользователя;
|
||||
2. проверяет отсутствие active mapping целевого `b24_id` с другим пользователем;
|
||||
3. создаёт `rebind_request` и workflow `contact.rebind`;
|
||||
4. не изменяет действующий mapping до завершения CRM-команд.
|
||||
|
||||
Worker:
|
||||
|
||||
1. читает прежний и целевой Contact по ID;
|
||||
2. повторно проверяет, что целевой Contact существует и не содержит `user_id` другого active пользователя;
|
||||
3. записывает в целевой Contact текущий `user_id` и registration flag `active`;
|
||||
4. очищает `user_id` и устанавливает flag `inactive` в прежнем Contact, только если там всё ещё находится ожидаемый `user_id`;
|
||||
5. после успеха обеих CRM-команд одной транзакцией закрывает старый mapping, создаёт новый active mapping и завершает `rebind_request`;
|
||||
6. завершает связанный business alert только после успешного изменения mapping.
|
||||
|
||||
Если обновление целевого Contact прошло, а очистка прежнего завершилась временной ошибкой, workflow остаётся `waiting_retry`: новый mapping ещё не активируется, повторная команда проверяет текущее значение и безопасно продолжает сценарий. Все шаги идемпотентны.
|
||||
|
||||
### 5.5. Bitrix24 → App
|
||||
|
||||
1. Webhook принимается и durable сохраняется в `webhook_inbox`.
|
||||
2. Несколько необработанных событий одного Contact coalesce в одно чтение.
|
||||
3. По `b24_id` ищется active mapping.
|
||||
4. Contact запрашивается по ID с минимальным `select`.
|
||||
5. Проверяются `user_id` и registration flag.
|
||||
6. CRM-master поля записываются в App одной транзакцией с `SET LOCAL han.sync_suppress='true'`.
|
||||
7. Обновляются `source_updated_at`, sync snapshot и webhook status.
|
||||
|
||||
Webhook для неизвестного/неактивного mapping подтверждается без изменения App и фиксируется low-cardinality audit. Несовпадение `user_id` с active mapping создаёт business alert и не перезаписывает профиль.
|
||||
|
||||
Если Contact не найден:
|
||||
|
||||
- mapping становится `broken`;
|
||||
- последний snapshot и UI-данные сохраняются;
|
||||
- профиль помечается stale через sync metadata, а не очищается;
|
||||
- создаётся business alert.
|
||||
|
||||
## 6. PostgreSQL-контракты
|
||||
|
||||
### 6.1. `han_app.sync_queue`
|
||||
|
||||
Существующая таблица мигрируется без потери записей. Требуемые поля:
|
||||
|
||||
```text
|
||||
id uuid PK
|
||||
task_type varchar(64)
|
||||
entity_type varchar(64)
|
||||
entity_id uuid
|
||||
dedup_key varchar(255)
|
||||
payload_json jsonb
|
||||
status varchar(16)
|
||||
attempt_count integer
|
||||
next_attempt_at timestamptz
|
||||
locked_by varchar(128) NULL
|
||||
locked_until timestamptz NULL
|
||||
lease_token uuid NULL
|
||||
last_error_code varchar(64) NULL
|
||||
last_error_at timestamptz NULL
|
||||
completed_at timestamptz NULL
|
||||
cancel_reason varchar(255) NULL
|
||||
created_at timestamptz
|
||||
updated_at timestamptz
|
||||
```
|
||||
|
||||
Допустимые статусы:
|
||||
|
||||
```text
|
||||
pending | leased | processed | retry_wait | dead_letter | cancelled
|
||||
```
|
||||
|
||||
Индексы:
|
||||
|
||||
- `(status, next_attempt_at, created_at)` для claim;
|
||||
- `(locked_until) WHERE status='leased'`;
|
||||
- `(entity_type, entity_id, created_at DESC)`;
|
||||
- partial unique dedup только для активных состояний `pending|leased|retry_wait`.
|
||||
|
||||
Глобальная `UNIQUE(dedup_key)` удаляется. Завершённая, отменённая или dead-letter задача не должна навсегда запрещать новое бизнес-событие.
|
||||
|
||||
Claim выполняется короткой транзакцией через `FOR UPDATE SKIP LOCKED`, выставляет `locked_by`, `locked_until`, новый `lease_token`. Любое завершение проверяет тот же token; просроченный worker не может подтвердить чужую lease.
|
||||
|
||||
### 6.2. Trigger contract
|
||||
|
||||
`han_app.enqueue_contact_sync()`:
|
||||
|
||||
- всегда использует `UserIdentity.id` как `entity_id/user_id`;
|
||||
- проверяет `han.sync_suppress`;
|
||||
- не создаёт задачу при `IS NOT DISTINCT FROM` для фактически отслеживаемых полей;
|
||||
- insert active identity/profile создаёт/coalesce `contact.map_or_create`; trigger не читает схему `bitrix_sync`, наличие mapping проверяет worker;
|
||||
- изменение `UserIdentity.phone_number` создаёт `contact.update`;
|
||||
- переход любого record status из active создаёт `contact.deactivate`;
|
||||
- возврат в active создаёт/coalesce `contact.map_or_create`;
|
||||
- изменения CRM-master profile fields сами по себе не создают App→CRM задачу;
|
||||
- trigger и business update находятся в одной транзакции.
|
||||
|
||||
Payload содержит только идентификаторы и безопасные версии, но не копии PII:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"user_id": "uuid",
|
||||
"reason": "identity_phone_changed",
|
||||
"source_updated_at": "2026-08-05T10:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Worker всегда читает актуальное состояние таблиц; payload не считается snapshot профиля.
|
||||
|
||||
### 6.3. `bitrix_sync.entity_external_mapping`
|
||||
|
||||
Таблица принадлежит migration/runtime boundary `bitrix-sync`:
|
||||
|
||||
```text
|
||||
id uuid PK
|
||||
entity_type varchar(64)
|
||||
entity_id uuid
|
||||
external_system varchar(32) = 'bitrix24'
|
||||
external_entity_type varchar(32) = 'contact'
|
||||
external_id varchar(128)
|
||||
status varchar(16) -- active|closed|broken
|
||||
opened_at timestamptz
|
||||
closed_at timestamptz NULL
|
||||
close_reason varchar(64) NULL
|
||||
workflow_id uuid NULL
|
||||
created_at timestamptz
|
||||
updated_at timestamptz
|
||||
```
|
||||
|
||||
Ограничения:
|
||||
|
||||
- partial unique active `(external_system, entity_type, entity_id)`;
|
||||
- partial unique active `(external_system, external_entity_type, external_id)`;
|
||||
- active row не имеет `closed_at`;
|
||||
- closed/broken row имеет `closed_at` или документированную причину broken.
|
||||
|
||||
Администратору не выдаётся произвольный write на таблицу. Procedure `bitrix_sync.request_bitrix_contact_rebind(...)` создаёт durable запрос и workflow, но не изменяет mapping непосредственно.
|
||||
|
||||
Существующая `han_app.entity_external_mapping` мигрируется:
|
||||
|
||||
1. создать целевую таблицу и ограничения в `bitrix_sync`;
|
||||
2. перенести и проверить существующие связи;
|
||||
3. переключить worker/repositories;
|
||||
4. удалить GRANT и зависимости App от старой таблицы;
|
||||
5. удалить `han_app.entity_external_mapping`;
|
||||
6. отдельной contract migration удалить partial index и колонку `ClientProfile.bitrix_contact_id`.
|
||||
|
||||
До contract-фазы запрещено поддерживать две writable копии mapping.
|
||||
|
||||
### 6.4. Таблицы `bitrix_sync`
|
||||
|
||||
Минимальный набор:
|
||||
|
||||
- `workflow_instances` — тип, user/contact ID, state, current step, source task, deadline, outcome, timestamps;
|
||||
- `crm_commands` — workflow, command type, safe request params, status, attempt counters, lease, batch/correlation ID, safe response projection;
|
||||
- `webhook_inbox` — receiver type, event type, optional event ID/source timestamp, entity IDs, received/status timestamps, optional dedup fingerprint и source IP audit metadata;
|
||||
- `business_alerts` — sequence number, fingerprint, type, severity, app user, Contact candidates, selected ID, Bitrix smart-process item ID/stage, occurrence count;
|
||||
- `rebind_requests` — user ID, прежний/целевой Contact ID, reason/operator, workflow, status и audit timestamps;
|
||||
- `contact_snapshots` — IDs, field hashes/versions, citizenship enum ID, CRM source timestamps, last applied source (`webhook|reconciliation|app_create`) и last webhook received/source timestamps; без бесконтрольного дублирования PII;
|
||||
- `settings` и `settings_versions` — runtime business/worker settings с типом, validation status, version и audit;
|
||||
- `technical_dead_letters` — operation/workflow, safe error code, attempt/deadline metadata;
|
||||
- `reconciliation_cursors` — job type, watermark, overlap и last success.
|
||||
|
||||
`crm_commands.command_type` — закрытый code enum/CHECK. БД не хранит произвольные REST method names или шаблоны исполняемых запросов.
|
||||
|
||||
## 7. State machines
|
||||
|
||||
### 7.1. Workflow
|
||||
|
||||
```text
|
||||
created
|
||||
-> running
|
||||
-> waiting_crm
|
||||
-> waiting_retry
|
||||
-> waiting_manual
|
||||
-> succeeded | failed | cancelled
|
||||
```
|
||||
|
||||
Только persisted transition активирует следующий шаг. Один workflow имеет не более одной активной команды, кроме явно независимых операций alert/status.
|
||||
|
||||
### 7.2. CRM command
|
||||
|
||||
```text
|
||||
pending -> leased -> in_flight -> succeeded
|
||||
\-> retry_wait -> pending
|
||||
\-> uncertain -> reconcile -> succeeded|retry_wait|dead_letter
|
||||
\-> dead_letter
|
||||
```
|
||||
|
||||
`uncertain` обязателен для timeout после отправки create/update. Для create reconciliation повторяет поиск по телефону и среди результатов в первую очередь проверяет Contact с тем же `user_id`.
|
||||
|
||||
### 7.3. Webhook
|
||||
|
||||
```text
|
||||
received -> coalesced|processing -> processed
|
||||
\-> retry_wait -> processing
|
||||
\-> dead_letter
|
||||
```
|
||||
|
||||
## 8. Worker, batching и rate limit
|
||||
|
||||
Одна replica первого релиза, но все claims и leases безопасны для будущих нескольких replica.
|
||||
|
||||
- PostgreSQL `LISTEN/NOTIFY` используется только как wake-up optimization;
|
||||
- резервный polling обязателен;
|
||||
- batch flush: достижение configured size либо configured max wait;
|
||||
- default batch size `20`, допустимый диапазон `1..50`;
|
||||
- один HTTP batch может содержать команды разных workflow, если они независимы;
|
||||
- результат каждого подзапроса разбирается отдельно;
|
||||
- FIFO определяется `next_attempt_at, created_at`, но retry не блокирует новые задачи;
|
||||
- общий token bucket на portal/credential;
|
||||
- безопасный default `2` HTTP requests/sec до подтверждения тарифа;
|
||||
- burst и refill конфигурируются;
|
||||
- default max in-flight HTTP requests `2`;
|
||||
- медленный запрос не блокирует новый, пока доступен token и in-flight slot;
|
||||
- несколько replica координируют limiter через PostgreSQL; локальный limiter допустим только при одной replica и явном readiness guard.
|
||||
|
||||
## 9. Retry и DLQ
|
||||
|
||||
Transient:
|
||||
|
||||
- connect/read timeout;
|
||||
- временный DNS/TLS/network failure;
|
||||
- HTTP 408/429/5xx;
|
||||
- `QUERY_LIMIT_EXCEEDED`;
|
||||
- временная недоступность PostgreSQL.
|
||||
|
||||
Политика: exponential backoff с full jitter, `Retry-After`/`operating_reset_at` приоритетнее локального delay, общий retry horizon 24 часа.
|
||||
|
||||
`QUERY_LIMIT_EXCEEDED` не расходует обычный business-attempt budget: он уменьшает limiter rate и переносит команду. `OPERATION_TIME_LIMIT` блокирует только затронутый method class до reset.
|
||||
|
||||
Permanent:
|
||||
|
||||
- invalid field/configuration;
|
||||
- permission denied;
|
||||
- credential rejected;
|
||||
- нарушенный DB invariant.
|
||||
|
||||
Malformed App/CRM business data, включая невалидный телефон и неизвестный citizenship enum после refresh, переводит workflow в `waiting_manual` и создаёт/coalesce business alert; это не technical DLQ. Permanent technical failure немедленно попадает в technical DLQ. Credential/config/permission failure переводит readiness в not-ready или degraded по матрице и создаёт SigNoz alert. Задача в смарт-процессе для technical failures не создаётся.
|
||||
|
||||
В лог/БД сохраняются safe error code, HTTP status, Bitrix error code, attempt/deadline и correlation ID. Сырой response и exception text проходят redaction; утверждение «внутренний сервис» не разрешает логировать PII или секреты.
|
||||
|
||||
## 10. Webhook receivers
|
||||
|
||||
### 10.1. Адреса приёма
|
||||
|
||||
```text
|
||||
Contact receiver:
|
||||
https://<processing-public-host>/bitrix/sync/webhook/contact?token=<contact-receiver-token>
|
||||
|
||||
Alert receiver:
|
||||
https://<processing-public-host>/bitrix/sync/webhook/alert?token=<alert-receiver-token>
|
||||
```
|
||||
|
||||
Эти адреса задаются в HTTP-webhook роботах Битрикс24 и ведут прямо в receiver routes `bitrix-sync`. Это не callbacks local app и не Bitrix event handlers. Установка local app, `event.bind`, OAuth и `application_token` для CRM sync не используются; `auth[member_id]` служит только дополнительной проверкой источника webhook.
|
||||
|
||||
Фактический контракт штатного HTTP-webhook робота Битрикс24:
|
||||
|
||||
```text
|
||||
POST <receiver>?token=<receiver-token>&ID=<entity-id>
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
document_id[0]=crm
|
||||
document_id[1]=CCrmDocumentContact
|
||||
document_id[2]=CONTACT_<contact-id>
|
||||
auth[domain]=<portal-host>
|
||||
auth[client_endpoint]=https://<portal-host>/rest/
|
||||
auth[server_endpoint]=https://oauth.bitrix24.tech/rest/
|
||||
auth[member_id]=<portal-member-id>
|
||||
```
|
||||
|
||||
Для alert `document_id[1]` содержит тип dynamic document, а `document_id[2]=DYNAMIC_<expected-entity-type-id>_<item-id>`. Значение query `ID` должно совпадать с ID из `document_id[2]`; расхождение отклоняется как malformed request.
|
||||
|
||||
Штатный робот не позволяет передать Bearer header, поэтому отдельный высокоэнтропийный Contact/alert receiver token передаётся только в query. Это принятое ограничение платформы, а не предпочтительный способ аутентификации. Токены различаются между receiver и окружениями, сравниваются constant-time и поддерживают контролируемую ротацию с коротким периодом перекрытия.
|
||||
|
||||
Query token не должен попадать в access/error/audit logs, traces, метрики, Referer или diagnostic response. Nginx и приложение логируют нормализованный route без `$request_uri`/query; redirect для receiver routes запрещён. Примеры, fixtures и документация используют только placeholder, реальные значения из наблюдений должны быть отозваны, если они когда-либо сохранялись вне secret manager.
|
||||
|
||||
Публичный nginx ВМ2:
|
||||
|
||||
- принимает только HTTPS;
|
||||
- ограничивает body, request rate и методы;
|
||||
- до проксирования проверяет source IP TCP-соединения по явно заданному allow-list CIDR и возвращает `403` для остальных адресов;
|
||||
- проксирует в локальный upstream `bitrix-sync` по закрытой Docker network;
|
||||
- не логирует body/query secrets;
|
||||
- не публикует internal status;
|
||||
- возвращает generic errors без stack/IDs.
|
||||
|
||||
Allow-list применяется к адресу непосредственного peer, а не к недоверенному `X-Forwarded-For`. Если перед nginx появляется внешний trusted proxy/LB, схема извлечения real IP и список trusted proxy должны пройти отдельный deployment review. Автоматическое добавление IP по входящему запросу запрещено.
|
||||
|
||||
Для отклонённых по IP запросов сохраняются только timestamp, source IP, receiver route и outcome с ограниченным retention; query и body не сохраняются. Счётчики агрегируются по receiver и причине без IP label. Source IP разрешается использовать только для диагностики и контролируемого обновления allow-list.
|
||||
|
||||
Nginx/его telemetry pipeline экспортирует отклонения в общую observability как `webhook_rejected_total{receiver,reason="source_ip"}`; приложение не может сформировать эту метрику, поскольку запрещённый запрос до upstream не доходит.
|
||||
|
||||
Bitrix24 обращается непосредственно к отдельному public host ВМ2. ВМ1 не принимает и не проксирует CRM webhook, не хранит его TLS/route configuration и не является runtime-зависимостью `bitrix-sync`.
|
||||
|
||||
До durable insert receiver проверяет query token, `POST`, `application/x-www-form-urlencoded`, bounded body/query, допустимый document type, entity ID и его совпадение в query/body, ожидаемый alert `entity_type_id`, portal domain и `member_id`. Переданные `client_endpoint`/`server_endpoint` не используются как адреса исходящих запросов. Неверная аутентификация или source IP возвращает `403`, malformed contract — `400`; такие запросы не создают inbox row.
|
||||
|
||||
Робот не передаёт достоверные event ID и occurred timestamp. `received_at` назначается сервером, а порядок и актуальность определяются последующим чтением Contact и нормализованным CRM source timestamp. Каноническое поле universal CRM — `updatedTime`; если точечное чтение выполняется legacy-методом `crm.contact.get`, его `DATE_MODIFY` преобразуется в тот же `source_updated_at` только после contract test эквивалентности. Для событий без стабильного event ID постоянная уникальность по fingerprint не применяется: несколько pending событий одного receiver/entity coalesce в одно чтение, а повторная обработка безопасна по Contact ID и source timestamp. Если Bitrix позднее предоставит стабильный event ID, он может использоваться для точной дедупликации.
|
||||
|
||||
После успешного durable insert возвращается `200/202`; бизнес-обработка в HTTP request не выполняется.
|
||||
|
||||
HTTP-webhook робота не считается гарантированной доставкой, если в портале явно не подтверждена retry policy; поэтому reconciliation обязательна.
|
||||
|
||||
### 10.2. Contact reconciliation
|
||||
|
||||
Для active mapped Contact выполняется инкрементальная сверка:
|
||||
|
||||
- `crm.item.list` с `entityTypeId=3` (Contact);
|
||||
- фильтр `>=updatedTime: watermark-overlap`, `opened: 1` и `<registered-field-rest-name>: 1`;
|
||||
- первый проход выбирает только `id`, затем изменившиеся active mapped Contact читаются по ID с минимальным набором CRM-master и служебных полей;
|
||||
- pagination и batch;
|
||||
- watermark двигается только после полного успешного инкрементального run, а не после отдельной страницы;
|
||||
- overlap обеспечивает повторное чтение границы;
|
||||
- повтор безопасен по Contact ID/source timestamp;
|
||||
- default interval 15 минут;
|
||||
- рассчитано на объём до 10 000 active Contact.
|
||||
|
||||
Канонический запрос поиска кандидатов отправляется `POST` к REST method `crm.item.list` с `Content-Type: application/json` и `Accept: application/json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"entityTypeId": 3,
|
||||
"select": ["id"],
|
||||
"filter": {
|
||||
">=updatedTime": "2026-08-05T09:00:00",
|
||||
"opened": 1,
|
||||
"ufCrm_1778692456": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Timestamp в запросе — вычисленный `watermark-overlap`, а имя custom field — REST-форма `BITRIX_SYNC_CONTACT_REGISTERED_FIELD`; приведённые значения являются примером контракта. Фильтры `opened=1` и registration flag обязательны: reconciliation не сканирует все Contact портала.
|
||||
|
||||
Wire-значения записи registration flag через выбранный add/update method проверяются отдельным contract test портала. Логика сервиса не смешивает `active`/`inactive` с конкретным представлением `1/0` или `Y/N`; repository adapter выполняет подтверждённое преобразование.
|
||||
|
||||
Полная сверка всех Contact в первом релизе не выполняется и не планируется по расписанию. Решение о её внедрении принимается отдельно по production-метрикам потерь и расхождений.
|
||||
|
||||
Каждый инкрементальный run по source attribution и timestamp в `contact_snapshots` считает Contact, для которых CRM-master состояние оказалось новее локального snapshot и было восстановлено reconciliation без ранее обработанного webhook. Всплеск абсолютного числа или доли таких Contact относительно настроенного порога/обычного baseline создаёт ops alert о возможной потере webhook.
|
||||
|
||||
Runbook по этому alert:
|
||||
|
||||
1. сравнить время всплеска с `webhook_rejected_total{reason="source_ip"}` и безопасным журналом отклонённых source IP;
|
||||
2. проверить, что новый адрес действительно принадлежит инфраструктуре Битрикс24/портала, используя согласованный канал или контролируемый probe; одного факта запроса с корректным token недостаточно;
|
||||
3. при подтверждении изменить version-controlled allow-list CIDR, пройти review и применить конфигурацию nginx;
|
||||
4. убедиться, что webhook снова принимаются, а последующие инкрементальные run не находят растущих расхождений.
|
||||
|
||||
Allow-list никогда не расширяется автоматически по метрике или журналу. Инкрементальная reconciliation восстанавливает пропущенные изменения, поэтому блокировка нового легитимного IP ухудшает latency, но не должна приводить к окончательной потере согласованности.
|
||||
|
||||
### 10.3. Alert reconciliation
|
||||
|
||||
Изменение элемента смарт-процесса приходит через alert HTTP-webhook робот. Дополнительно открытые alerts сверяются batch-poll:
|
||||
|
||||
- default каждые 60 минут;
|
||||
- только незавершённые item ID;
|
||||
- terminal stage закрывает локальный alert;
|
||||
- удалённый item переводит alert в `remote_missing` и создаёт ops/business signal согласно типу.
|
||||
|
||||
## 11. Business alerts
|
||||
|
||||
Смарт-процесс «Конфликты синхронизации» создаётся до production enablement.
|
||||
|
||||
Параметры хранятся в versioned `bitrix_sync.settings`:
|
||||
|
||||
- `entity_type_id`;
|
||||
- category/pipeline ID;
|
||||
- stage IDs `new`, `in_progress`, `resolved`, `closed_without_resolution`;
|
||||
- field IDs;
|
||||
- responsible user/group;
|
||||
- reconciliation interval;
|
||||
- SLA один рабочий день.
|
||||
|
||||
Обязательные поля элемента:
|
||||
|
||||
- внутренний `alert_number`;
|
||||
- fingerprint;
|
||||
- type/severity;
|
||||
- app user ID;
|
||||
- current/selected/candidate Contact IDs;
|
||||
- masked details;
|
||||
- occurrence count;
|
||||
- first/last occurrence;
|
||||
- previous alert link;
|
||||
- service correlation ID.
|
||||
|
||||
Один открытый alert существует на `(type, fingerprint)`. Повтор увеличивает `occurrence_count` и обновляет item. После terminal stage новый случай создаёт новый alert со ссылкой на предыдущий.
|
||||
|
||||
После разбора конфликта администратор запускает `bitrix_sync.request_bitrix_contact_rebind(...)`. Простая смена стадии alert не изменяет mapping. Sync-service переводит alert в terminal stage только после успешного `contact.rebind`; произвольный SQL запрещён.
|
||||
|
||||
## 12. Настройки
|
||||
|
||||
### 12.1. Runtime secrets
|
||||
|
||||
Обязательный каталог для secret manager:
|
||||
|
||||
```text
|
||||
BITRIX_SYNC_DATABASE_URL
|
||||
BITRIX_SYNC_CRM_REST_WEBHOOK_URL
|
||||
BITRIX_SYNC_CONTACT_RECEIVER_TOKEN
|
||||
BITRIX_SYNC_ALERT_RECEIVER_TOKEN
|
||||
BITRIX_SYNC_SERVICE_TOKEN
|
||||
```
|
||||
|
||||
`BITRIX_SYNC_CRM_REST_WEBHOOK_URL` — credential-bearing URL входящего webhook Битрикс24, через который sync-service вызывает CRM REST. Он не связан с двумя public receiver URLs ВМ2 и считается единым секретом. Сервис не собирает его из логируемых частей. Receiver tokens также остаются secret-manager values, хотя из-за ограничения робота подставляются в query настроенных URL; доступ к конфигурации роботов Битрикс24 должен быть ограничен.
|
||||
|
||||
Секреты доставляются существующим `han-secrets` из Selectel Secrets Manager в read-only files `/run/han-chat/secrets`; fallback для no-egress VM — root-owned files. Они не хранятся в `.env` или БД.
|
||||
|
||||
### 12.2. Non-secret env
|
||||
|
||||
```text
|
||||
BITRIX_SYNC_ENABLED=true
|
||||
BITRIX_SYNC_MODE=full
|
||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_...
|
||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_1778692456
|
||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_...
|
||||
BITRIX_SYNC_PORTAL_HOST=<approved-host>
|
||||
BITRIX_SYNC_PORTAL_MEMBER_ID=<approved-member-id>
|
||||
BITRIX_SYNC_PUBLIC_BASE_URL=https://<processing-public-host>
|
||||
BITRIX_WEBHOOK_ALLOWED_CIDRS=<comma-separated-cidrs>
|
||||
BITRIX_SYNC_HTTP_TIMEOUT_SEC=10
|
||||
BITRIX_SYNC_DB_POOL_SIZE=5
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
SECRETS_SOURCE=selectel
|
||||
```
|
||||
|
||||
Portal host обязан совпадать с host secret URL, а portal member ID — с `auth[member_id]` входящих webhook; redirect на другой host запрещён. `BITRIX_WEBHOOK_ALLOWED_CIDRS` применяется deployment/nginx, проходит CIDR validation и version-controlled review; пустой allow-list при enabled receiver является ошибкой deployment-конфигурации. Nginx config test и проверка allow-list входят в preflight, поскольку application readiness не может подтвердить фактически загруженную конфигурацию внешнего proxy.
|
||||
|
||||
`BITRIX_SYNC_MODE=full` означает реализацию полного набора сценариев этого модуля, а не запуск full reconciliation. Существующие stub-era `.env.example`, Compose и `deployment/secrets/config.example.json` не являются контрактом full sync. В реализации они обновляются одновременно с кодом и manifest validation; до этого `BITRIX_SYNC_ENABLED=false`. В частности, api-backend не является consumer `BITRIX_SYNC_SERVICE_TOKEN`, а устаревшие `BITRIX_SYNC_CRM_BASE_URL`, `CONTACT_*_INTERVAL_SEC` и `BITRIX_SYNC_CRM_MAX_CONCURRENCY` не переносятся в full mode.
|
||||
|
||||
### 12.3. Hot settings
|
||||
|
||||
В `bitrix_sync.settings`: batch size/wait, claim size, lease TTL, limiter refill/burst, max in-flight, retry base/max/horizon, reconciliation intervals, пороги всплеска восстановленных reconciliation Contact, alert process IDs/stages/fields/SLA.
|
||||
|
||||
Worker читает active validated version, кэширует её и периодически проверяет version. Невалидная новая версия не активируется. Secrets, DSN, field names, portal host/member ID, inbound CIDR allow-list и cutover mode не меняются hot.
|
||||
|
||||
## 13. Минимальные права
|
||||
|
||||
Технический пользователь входящего webhook Б24:
|
||||
- read Contact и перечисленных полей;
|
||||
- add Contact;
|
||||
- update только Contact в разрешённом CRM scope;
|
||||
- read userfield metadata для citizenship;
|
||||
- add/read/update элементы конкретного smart process alerts;
|
||||
- без Lead/Deal delete/export/admin прав;
|
||||
- без доступа к чатам, документам, телефонии и настройкам портала.
|
||||
|
||||
Точные ограничения проверяются smoke вызовами до enablement. Использование webhook администратора с полными правами запрещено.
|
||||
|
||||
DB role `bitrix_sync_user`:
|
||||
- owner/write собственной схемы `bitrix_sync`;
|
||||
- `SELECT, UPDATE` только необходимых колонок `han_app.sync_queue`;
|
||||
- `SELECT` нужных колонок identity/profile;
|
||||
- controlled `UPDATE` только CRM-master полей профиля и `source_updated_at`;
|
||||
- без broad schema write, DDL и доступа к сообщениям/согласиям/документам;
|
||||
- mapping/rebind tables находятся в собственной схеме и не требуют write GRANT на `han_app`;
|
||||
- запись профиля App выполняется только через repository с transaction-local GUC.
|
||||
|
||||
## 14. HTTP health/status
|
||||
|
||||
- `GET /health/live`: process/event loop жив, без DB/CRM вызова.
|
||||
- `GET /health/ready`: validated application config/secrets, PostgreSQL доступен, queue grants работают, workers/limiter живы, webhook routes и portal identity сконфигурированы; краткая CRM outage даёт degraded по stale threshold, а invalid credential — not-ready. Состояние внешнего nginx/CIDR allow-list проверяется deployment preflight и отдельным synthetic probe, а не этим endpoint.
|
||||
- `GET /internal/sync/v1/status`: Bearer service token, internal-only.
|
||||
|
||||
Status содержит mode, queue depth по состояниям, oldest age, active workflows, commands retry/DLQ, webhook lag, reconciliation cursors, limiter state, last CRM success и settings version. PII, URL credential, Contact/user IDs и raw errors не возвращаются.
|
||||
|
||||
## 15. Observability
|
||||
|
||||
Метрики:
|
||||
|
||||
- queue depth/oldest age и claim duration;
|
||||
- workflow/command transitions;
|
||||
- CRM batch size, duration и subcommand outcomes;
|
||||
- limiter tokens/throttle/rate-limit errors;
|
||||
- retry/DLQ counts и age;
|
||||
- webhook accepted/rejected/coalesced/lag по receiver и safe reason, включая отклонение source IP без IP label;
|
||||
- reconciliation scanned/updated/recovered-without-webhook/cursor lag и alert о всплеске восстановлений;
|
||||
- business alerts open/SLA overdue;
|
||||
- mapping invariant violations;
|
||||
- readiness and worker heartbeat.
|
||||
|
||||
Labels только low-cardinality: command type, outcome, safe error code, event type. `user_id`, Contact ID, phone, email и fingerprint не labels.
|
||||
|
||||
JSON logs содержат event name, service/version/environment, request/trace/span/correlation/workflow/command IDs, safe status/error code. PII маскируется минимум до первых двух и последних двух читаемых символов; предпочтительно не логируется вообще. Secrets, URL webhook, request/response body и SQL parameters запрещены.
|
||||
|
||||
## 16. Security
|
||||
|
||||
- approved Bitrix portal — единственный CRM egress `:443`;
|
||||
- TLS verify обязателен;
|
||||
- egress redirect выключен либо повторно проверяет host allow-list;
|
||||
- container non-root, read-only rootfs, dropped capabilities;
|
||||
- public webhook проходит source IP CIDR allow-list и rate/body/method limits nginx ВМ2;
|
||||
- internal status отсутствует в public server block nginx ВМ2;
|
||||
- runtime secret files read-only и service-specific;
|
||||
- constant-time token compare;
|
||||
- query string и form body receiver routes исключены из всех журналов и traces;
|
||||
- JSON/form parsing bounded;
|
||||
- DB payloads не содержат PII snapshots без необходимости;
|
||||
- зависимые образы/dependencies pinned и сканируются;
|
||||
- test/prod — разные credentials, secrets, DB/settings и deployments.
|
||||
|
||||
Разделение тестовых и production Contact на одном портале является внешней организационной ответственностью. Поведение sync-service одинаково; test должен быть prod-like. Это принятое ограничение не отменяет отдельные credentials и endpoint tokens.
|
||||
|
||||
## 17. Cutover
|
||||
|
||||
Cutover не управляется постоянным `replay=false` на каждом startup.
|
||||
|
||||
Одноразовая процедура:
|
||||
|
||||
1. Остановить старый stub/worker.
|
||||
2. Применить migrations и grants.
|
||||
3. Зафиксировать `cutover_watermark`.
|
||||
4. Перевести все существовавшие до watermark `pending/retry` contact-задачи в `cancelled` с причиной `initial_full_sync_cutover`; слово `full` здесь относится к переходу со stub на full mode, а не к reconciliation.
|
||||
5. Не создавать backfill для active пользователей без mapping.
|
||||
6. Проверить env/secrets/settings/fields/rights/webhook.
|
||||
7. Запустить сервис disabled и выполнить preflight.
|
||||
8. Включить обработку только задач после watermark.
|
||||
9. Наблюдать queue/webhook/DLQ/CRM limits.
|
||||
|
||||
Принятое бизнес-ограничение: ранее зарегистрированные пользователи без mapping могут никогда не синхронизироваться, пока новое отслеживаемое событие не создаст задачу. Это не считается дефектом первого релиза.
|
||||
|
||||
Rollback:
|
||||
|
||||
- остановить claims;
|
||||
- дождаться/ограниченно завершить in-flight;
|
||||
- выключить sync без отмены новых pending;
|
||||
- не откатывать уже созданные Contact/mapping автоматически;
|
||||
- сохранить workflow/audit для последующего controlled resume;
|
||||
- DB downgrade не выполняется, если появились production rows нового формата.
|
||||
|
||||
## 18. Тестовая матрица
|
||||
|
||||
### Unit
|
||||
|
||||
- state transitions и запрещённые переходы;
|
||||
- trigger decision/no-op/suppress;
|
||||
- E.164 validation;
|
||||
- branch В0–В2 и deterministic newest;
|
||||
- field mapping/citizenship/email;
|
||||
- coalesce событий без event ID и дедупликация при наличии стабильного event ID;
|
||||
- backoff/jitter/24h horizon;
|
||||
- limiter/batch flush;
|
||||
- query token/domain/member/document ID validation;
|
||||
- PII/secret redaction.
|
||||
|
||||
### Integration PostgreSQL
|
||||
|
||||
- concurrent claim через `SKIP LOCKED`;
|
||||
- lease expiry и fencing stale worker;
|
||||
- trigger + business transaction atomicity;
|
||||
- partial unique active mapping;
|
||||
- миграция mapping из `han_app` в `bitrix_sync` без двух writable copies;
|
||||
- удаление `ClientProfile.bitrix_contact_id` и его partial index после переключения readers;
|
||||
- `contact.rebind` при success, partial CRM failure, retry и конфликте целевого Contact;
|
||||
- `SET LOCAL han.sync_suppress` без leakage в pool;
|
||||
- crash после CRM success до local commit;
|
||||
- settings activation/version rollback;
|
||||
- cutover watermark cancellation.
|
||||
|
||||
### Bitrix contract
|
||||
|
||||
- `crm.duplicate.findbycomm` с `+7XXXXXXXXXX`;
|
||||
- `crm.item.list` для `entityTypeId=3` с `>=updatedTime`, `opened=1` и registration field `=1`;
|
||||
- minimal select;
|
||||
- add/get/update Contact;
|
||||
- custom fields read/write;
|
||||
- citizenship dictionary;
|
||||
- batch mixed success/failure;
|
||||
- `QUERY_LIMIT_EXCEEDED` и `OPERATION_TIME_LIMIT`;
|
||||
- smart-process create/get/update;
|
||||
- Contact/alert HTTP-webhook robot form-urlencoded payload;
|
||||
- missing/invalid/rotated receiver query token;
|
||||
- совпадение query ID с `document_id`, portal domain/member ID и alert entity type.
|
||||
|
||||
### Failure
|
||||
|
||||
- timeout до/после отправки create;
|
||||
- service restart на каждом workflow step;
|
||||
- PostgreSQL/Bitrix/OTEL outage and recovery;
|
||||
- lost/duplicate/out-of-order webhook;
|
||||
- новый легитимный source IP вне allow-list с восстановлением изменений reconciliation;
|
||||
- dead worker and lease recovery;
|
||||
- malformed CRM response;
|
||||
- credential revoked;
|
||||
- mapping invariant violation;
|
||||
- graceful SIGTERM с bounded drain.
|
||||
|
||||
### Load
|
||||
|
||||
- вход 100 событий/мин при lag не более 30 секунд в healthy CRM;
|
||||
- batch utilization;
|
||||
- очередь растёт предсказуемо при throttle и восстанавливается после него;
|
||||
- reconciliation до 10 000 Contact не нарушает основной SLA;
|
||||
- отсутствие DB connection-pool starvation.
|
||||
|
||||
### Security
|
||||
|
||||
- least-privilege negative tests;
|
||||
- public receiver source IP allow-list, size/rate/auth;
|
||||
- отсутствие query token и form body в nginx/app logs, traces и errors;
|
||||
- SSRF/redirect host rejection;
|
||||
- secrets absent in env dump/status/logs/traces;
|
||||
- PII absent in metric labels и technical DLQ;
|
||||
- internal endpoint недоступен через public host ВМ2.
|
||||
|
||||
## 19. Definition of Done
|
||||
|
||||
- три автоматических App→CRM сценария и административный `contact.rebind` durable и идемпотентны;
|
||||
- Contact HTTP-webhook робот и reconciliation обновляют App без echo;
|
||||
- receiver принимает фактический form-urlencoded/query-token контракт, фильтрует source IP и не раскрывает query;
|
||||
- canonical mapping и rebind audit находятся только в `bitrix_sync`;
|
||||
- create восстанавливается после ambiguous outcome без дубля;
|
||||
- business alerts создаются, coalesce и отслеживаются;
|
||||
- technical failures идут в SigNoz/DLQ, а не в business process;
|
||||
- batch/rate limit/retry соответствуют этой спецификации;
|
||||
- secrets доставляются штатным manager и не раскрываются;
|
||||
- health/status/metrics отражают реальное состояние;
|
||||
- migrations, grants, OpenAPI и runbook синхронизированы;
|
||||
- unit/integration/contract/failure/load/security gates пройдены;
|
||||
- cutover и rollback отрепетированы;
|
||||
- stub claims и `crm_sync_implemented=false` удалены из канонических документов.
|
||||
|
||||
## 20. Принятые решения и остаточные риски
|
||||
|
||||
Принято:
|
||||
|
||||
- один портал и отдельный credential-bearing CRM REST webhook технического пользователя;
|
||||
- одна replica с HA-safe leases;
|
||||
- default REST rate 2 HTTP requests/sec;
|
||||
- два HTTP-webhook робота с отдельными query tokens, source IP allow-list и инкрементальной reconciliation;
|
||||
- canonical mapping в `bitrix_sync.entity_external_mapping`;
|
||||
- строгий upstream E.164;
|
||||
- бизнес-конфликты через smart process, technical alerts через SigNoz;
|
||||
- retry horizon 24 часа;
|
||||
- отсутствие initial backfill;
|
||||
- отсутствие scheduled full reconciliation в первом релизе.
|
||||
|
||||
Остаточные риски:
|
||||
|
||||
- организационное разделение test/prod на одном портале не обеспечивается кодом;
|
||||
- query token может быть виден администраторам портала и в интерфейсе настройки робота; риск снижается разграничением доступа, IP allow-list и ротацией, но не устраняется;
|
||||
- IP-адреса отправителей Битрикс24 могут измениться без предварительного уведомления; до review allow-list webhook будут отклоняться, а задержка восстановления ограничена interval инкрементальной reconciliation;
|
||||
- обычный webhook может теряться, поэтому задержка ограничена reconciliation interval;
|
||||
- ручная перепривязка остаётся ops-действием, хотя ограничена stored procedure;
|
||||
- отсутствие backfill оставляет часть старых пользователей без CRM mapping - принято, т.к. нет реальных пользователей;
|
||||
- фактические Bitrix custom field/process IDs появляются только после настройки портала и должны пройти preflight.
|
||||
@@ -0,0 +1,193 @@
|
||||
# module-09-vm2. Наблюдаемость ВМ2 Processing
|
||||
|
||||
> Статус: целевая спецификация реализации наблюдаемости на ВМ2.
|
||||
> Канонический контракт (JSON-лог, redaction, sampling, Collector pipeline, SigNoz) — [`arch-07-observability.md`](../../architectory/arch-07-observability.md). Его поля, labels и `service.namespace` здесь не переопределяются.
|
||||
> Контур ВМ1 в этот документ не входит: [`module-09-observability-vm1.md`](../../VM1_app/documentation/module-09-observability-vm1.md). Корреляция сквозного запроса — по `request_id` / `trace_id`.
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
Документ задаёт, **что агент ВМ2 реализует в Compose, коде, тестах и алертах этой машины**.
|
||||
|
||||
ВМ2 владеет nginx (public CRM webhook + private `8443`), `message-safety`, `bitrix-sync`, `clamd`/`freshclam`, Redis Safety и локальным Collector. Guest bootstrap, Keycloak, `api-backend` и `bitrix-local-app` живут на ВМ1. ВМ2 не использует Docker hostname collector ВМ1.
|
||||
|
||||
Агент ВМ2 не добавляет scrape, дашборды и алерты сервисов ВМ1.
|
||||
|
||||
## 2. Сервисы и `service.name`
|
||||
|
||||
| Компонент | `service.name` |
|
||||
|---|---|
|
||||
| nginx public/private | `nginx` |
|
||||
| `message-safety` API/worker | `message-safety` |
|
||||
| `bitrix-sync` | `bitrix-sync` |
|
||||
| Redis Safety | `redis` |
|
||||
| local Collector | `otel-collector` |
|
||||
|
||||
`clamd` / `freshclam` покрываются host/container metrics и сигналами Safety (signature age, scan lanes), отдельное `service.name` в реестр arch-07 не добавляется без явного решения.
|
||||
|
||||
Различать экземпляр от ВМ1 через `host.name` / `service.instance.id`.
|
||||
|
||||
## 3. Collector на ВМ2
|
||||
|
||||
- отдельный экземпляр в root Compose ВМ2, собственный volume `otel-queue`;
|
||||
- приложения ВМ2 экспортируют OTLP только в `otel-collector:4317` этой машины;
|
||||
- export в SigNoz `192.168.0.5:4317`; hostname collector ВМ1 запрещён;
|
||||
- pipeline, processors, limits, `otel-queue-init` и fail-open — arch-07 §3, §13, §14.
|
||||
- telemetry outage fail-open для Safety readiness, но создаёт alert; business fail-open не отменяет Safety fail-closed на содержимом.
|
||||
|
||||
Scrape targets ВМ2 (кроме самого Collector): Redis Safety exporter, nginx exporter, сервисные `/metrics` `message-safety` и `bitrix-sync`, если они не идут OTLP.
|
||||
|
||||
## 4. Instrumentation
|
||||
|
||||
Правила FastAPI/HTTPX/PG/Redis/S3/workers, JSON access log nginx и hostmetrics — arch-07 §8. Ниже только покрытие ВМ2.
|
||||
|
||||
### 4.1. `message-safety`
|
||||
|
||||
- server spans API с route template; poll `202` не маскирует финальный verdict;
|
||||
- worker spans claim/process/finalize и span links на origin `request_id` / `trace_id` caller;
|
||||
- child spans: PostgreSQL Safety schema, Redis Safety, S3 quarantine, ClamAV/DNS classification без file content и raw URL;
|
||||
- `/health/live` исключить из traces; capability/readiness — metrics и sampled logs;
|
||||
- stub mode (`400`, non-sticky) маркируется как `stub`; production SLO Safety на stub недостоверен.
|
||||
|
||||
### 4.2. `bitrix-sync`
|
||||
|
||||
- server spans webhook (после nginx allow-list);
|
||||
- worker/batch/CRM client spans; Bitrix может не вернуть context — span закрывается результатом;
|
||||
- async queue — span link, не подмена долгого worker trace;
|
||||
- query token, raw payload и download URL не попадают в attributes.
|
||||
|
||||
### 4.3. nginx ВМ2
|
||||
|
||||
Access log по arch-07 §8.4. Route class — bounded set: exact CRM webhook, ACME/redirect, private Safety. Query/body webhook не логируются. Метрика `webhook_rejected_total{receiver,reason="source_ip"}` формируется на nginx, потому что запрещённый запрос до upstream не доходит.
|
||||
|
||||
Без native OTEL module первый server span создаёт `message-safety` или `bitrix-sync`.
|
||||
|
||||
### 4.4. Redis Safety и PostgreSQL
|
||||
|
||||
Exporter и ACL — arch-07 §8.2–8.3. Клиентские pool/queue metrics публикуют Safety и `bitrix-sync`. Keys/values, file bytes и message text не экспортируются.
|
||||
|
||||
### 4.5. Host/Docker ВМ2
|
||||
|
||||
CPU, memory, disk, network, restarts/OOM, Docker daemon, clock sync — arch-07 §8.5. Отдельно контролировать `clamd` restarts, signature age и scan lane saturation.
|
||||
|
||||
## 5. Метрики бизнес-потоков ВМ2
|
||||
|
||||
### Message Safety
|
||||
|
||||
- checks/verdicts по `allow|deny|pending|error`;
|
||||
- `processing_mode`, active/used `config_version`, config activation result, `message_safety_mock_enabled` и forced outcomes по `text|file`/`allow|deny`;
|
||||
- poll duration/count buckets, timeout и recovery backlog age;
|
||||
- stub mode info и terminal `400` отдельно, пока действует test-only контракт;
|
||||
- cache hit, Redis latency, task expired/not-found.
|
||||
|
||||
### Files / S3 со стороны Safety
|
||||
|
||||
- quarantine object age/orphans;
|
||||
- checksum/MIME/size reject;
|
||||
- S3 dependency latency/error by operation and logical bucket.
|
||||
|
||||
Init/complete/promote/presigned download со стороны API — спецификация ВМ1.
|
||||
|
||||
### `bitrix-sync`
|
||||
|
||||
- queue depth/oldest age, workflow/command transitions;
|
||||
- CRM batch latency/subcommand outcome;
|
||||
- limiter/throttle, retry/DLQ;
|
||||
- webhook/reconciliation lag;
|
||||
- mapping invariants и business-alert SLA;
|
||||
- nginx `webhook_rejected_total{receiver,reason="source_ip"}`.
|
||||
|
||||
До module-07 preflight Queue/CRM SLI не включаются; dashboard показывает `sync_disabled`, а не синтетический CRM success.
|
||||
|
||||
UUID/user/session/dialog/task/message id не labels.
|
||||
|
||||
## 6. Dashboards ВМ2
|
||||
|
||||
В SigNoz, с filter `host.name` / environment ВМ2:
|
||||
|
||||
1. **nginx ingress ВМ2**: RPS, 4xx/5xx, upstream latency/status, TLS, cache, **CRM webhook** (accept/reject by source IP, method, rate limit). Без guest API/WS — это ВМ1.
|
||||
2. **message-safety**: capabilities, verdicts, `202` poll, PG queue age/leases/fencing, ClamAV/signature age, file/link cache и DNS dependency. Stub явно маркируется.
|
||||
3. **bitrix-sync**: mode/readiness, queue depth/oldest age, workflow/command transitions, CRM batch latency/subcommand outcome, limiter/throttle, retry/DLQ, webhook/reconciliation lag, mapping invariants и business-alert SLA. До cutover — `sync_disabled`.
|
||||
4. **Redis Safety**: memory/evictions/AOF/latency/clients/keyspace.
|
||||
|
||||
Сквозные Executive/SLO, Business flow и Collector health — arch-07 §10.
|
||||
|
||||
## 7. Alerts ВМ2
|
||||
|
||||
Политика SLO — arch-07 §11. Ниже alerts, которые закрывает on-call ВМ2.
|
||||
|
||||
### Paging
|
||||
|
||||
- oldest safety task >5 минут либо Safety DLQ >0;
|
||||
- Message Safety active config missing/invalid, referenced artifact unavailable или config refresh stale >5 с;
|
||||
- `message_safety_mock_enabled=1` — active page/high-severity alert без auto-resolve по времени; закрывается только после возврата в standard;
|
||||
- bitrix-sync invalid/revoked credential или обязательная configuration/grant missing;
|
||||
- bitrix-sync technical DLQ >0, mapping invariant violation или worker/limiter heartbeat stale;
|
||||
- bitrix-sync queue oldest age >30 с при healthy CRM либо sustained рост;
|
||||
- Contact webhook/reconciliation cursor lag выше двух configured intervals;
|
||||
- Redis Safety unavailable, AOF error или sustained evictions;
|
||||
- Collector ВМ2 exporter queue >80%, dropped/refused telemetry >0 sustained;
|
||||
- TLS expiry public webhook host и private `8443` <14 дней warning, <7 дней page;
|
||||
- disk/OOM/restart loop ВМ2; `clamd` restart loop / stale signatures по runbook module-05.
|
||||
|
||||
### Ticket/warning
|
||||
|
||||
- p95 Safety poll/scan regression 20% release-over-release;
|
||||
- bitrix-sync CRM last success stale, rate-limit errors выше baseline или business alert SLA overdue;
|
||||
- quarantine orphan growth;
|
||||
- cardinality/ingest growth >2× baseline на сериях ВМ2.
|
||||
|
||||
Keycloak login, guest bootstrap и edge API 5xx ВМ1 — не алерты репозитория ВМ2.
|
||||
|
||||
## 8. Docker Compose ВМ2
|
||||
|
||||
Root Compose включает `otel-collector` по arch-07 §14. Сети приложений ВМ2: `backend` + `observability`; nginx — свои public/private сети по arch-03 / [`module-03-nginx-vm2.md`](module-03-nginx-vm2.md).
|
||||
|
||||
Optional profile `observability-local` на ВМ2 по умолчанию выключен: малая VM, extra RAM/disk не закладываются.
|
||||
|
||||
## 9. Runbooks ВМ2
|
||||
|
||||
Общие (Collector not-ready, missing telemetry, remote outage, cardinality, PII) — arch-07 §16.
|
||||
|
||||
### Высокая latency сообщения (hop ВМ2)
|
||||
|
||||
1. Найти task/span по `request_id` caller.
|
||||
2. Разделить Safety API, worker lease, ClamAV/DNS, S3 quarantine, sync queue.
|
||||
3. Проверить circuit, queue age, PG leases/fencing и Redis Safety.
|
||||
4. Не повторять ambiguous scan/send без исходного idempotency key.
|
||||
5. Следовать runbook [`module-05-message-safety.md`](module-05-message-safety.md) / [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md).
|
||||
6. Delay до Safety (nginx/API ВМ1) — инцидент владельца ВМ1.
|
||||
|
||||
### MOCK включён в production-like
|
||||
|
||||
1. Подтвердить `message_safety_mock_enabled=1` и paging alert.
|
||||
2. Не auto-resolve по времени.
|
||||
3. Вернуть standard через root-owned five-command helper; проверить persistent alert closed.
|
||||
4. Canary allow/deny после возврата.
|
||||
|
||||
## 10. Definition of Done ВМ2
|
||||
|
||||
Дополнительно к arch-07 §17:
|
||||
|
||||
- Collector ВМ2 validate + up в root Compose; hostname collector ВМ1 не используется;
|
||||
- инструментированы `message-safety` API/worker и `bitrix-sync`;
|
||||
- nginx JSON parsing, webhook reject metric и private `8443` correlation проверены;
|
||||
- Redis Safety, host/Collector, ClamAV signature age metrics доступны;
|
||||
- дашборды и alerts §6–§7 provisioned либо явно TBD до SigNoz packaging;
|
||||
- stub/MOCK маркируются; production Safety SLO не объявляется на stub;
|
||||
- до sync cutover dashboard `sync_disabled`;
|
||||
- canary secret/PII/message body/file content/presigned URL отсутствуют в сигналах ВМ2;
|
||||
- request с известным `request_id` находится в Safety spans; сквозной nginx ВМ1 → API → Safety — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md) §11 / [`module-10-deployment-vm2.md`](module-10-deployment-vm2.md).
|
||||
|
||||
## 11. TBD и конфликты, принадлежащие ВМ2
|
||||
|
||||
- Conflict module-05: test-only `400` / non-sticky verdict. Dashboards маркируют `stub`; production SLO Safety недостоверен.
|
||||
- Conflict module-07: до preflight/cutover показывать `sync_disabled`, не синтетический CRM success.
|
||||
- O-TBD5 в части nginx OTEL module pinned image ВМ2.
|
||||
|
||||
## 12. Ссылки
|
||||
|
||||
- Контракт: [`arch-07-observability.md`](../../architectory/arch-07-observability.md).
|
||||
- ВМ1: [`module-09-observability-vm1.md`](../../VM1_app/documentation/module-09-observability-vm1.md).
|
||||
- Указатель: [`module-09-observability.md`](module-09-observability.md).
|
||||
- Safety / sync / nginx: [`module-05-message-safety.md`](module-05-message-safety.md), [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md), [`module-03-nginx-vm2.md`](module-03-nginx-vm2.md).
|
||||
- Деплой: [`module-10-deployment-vm2.md`](module-10-deployment-vm2.md).
|
||||
@@ -0,0 +1,185 @@
|
||||
# module-10-vm2. Runbook развёртывания ВМ2 Processing
|
||||
|
||||
> Статус: целевой runbook репозитория ВМ2.
|
||||
> Общий контракт (VPC/SG, PG, S3, роли `deploy`, TLS процедура, порядок cutover) — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
|
||||
> ВМ1 — [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md). Не переносить команды ВМ1 и не шарить Compose/IAM/secrets.
|
||||
|
||||
## 1. Границы
|
||||
|
||||
ВМ2 владеет nginx public `80/443` (только exact CRM webhook) и private `8443` (Message Safety), `message-safety` API/worker, `clamd`/`freshclam`, `bitrix-sync`, Redis Safety, Collector. Guest API, Keycloak, SPA и SMS callback здесь не разворачиваются.
|
||||
|
||||
`<BACKEND_ROOT>` / `<BACKEND_REPO_URL>` — репозиторий ВМ2. Public ACME host — `<PROCESSING_PUBLIC_HOST>`. Private DNS `processing.internal` не публикуется.
|
||||
|
||||
## 2. Sizing и load gates
|
||||
|
||||
Начальный sizing без local Grafana: 4 vCPU, 8 ГБ RAM, 80 ГБ SSD, 4 ГБ swap; Redis 512 МиБ; Collector 512 МиБ + 5–10 ГБ queue; свободный диск ≥30%.
|
||||
|
||||
До real traffic — load test module-05 §15.4:
|
||||
|
||||
- sustained 10 text checks/s: p95 ≤2 с, p99 ≤5 с;
|
||||
- sustained 2 file checks/s на 5 worker slots: среднее processing ≤2.5 с, p95 ≤60 с, public wait ≤300 с;
|
||||
- 100 pending принимаются; 101-й file POST — retryable `503` без новой task;
|
||||
- RPS overflow — `429 + Retry-After`;
|
||||
- long Safety poll не блокирует WS/read API ВМ1;
|
||||
- если gate не пройден — увеличить slots/CPU/clamd scan lanes; production traffic не открывать.
|
||||
|
||||
Monthly availability SLO Safety в MVP не задаётся. Scale-out/ВМ3: sustained CPU/RAM >70%, queue age >30 с, провал performance gates, contention `bitrix-sync` или независимый release cadence. Workers масштабируются первыми по queue depth, `clamd` — scan lanes.
|
||||
|
||||
## 3. Hardening и egress
|
||||
|
||||
Arch-10 §5 / arch-06. Public Docker ports — `80,443`. Private `8443` не internet SG.
|
||||
|
||||
Default-deny egress. Bootstrap window для registry/OS, затем закрыть. Оставить: S3, approved Bitrix portal (`bitrix-sync`), signature CDN (`freshclam`), DNS/NTP, SigNoz `4317`, PG. Постоянный open egress запрещён.
|
||||
|
||||
Отдельный IAM principal Selectel: только VM2 secret names.
|
||||
|
||||
## 4. Release layout и `.env` ВМ2
|
||||
|
||||
Checkout exact SHA. Структура: root Compose, `nginx`, Safety, `bitrix-sync`, Redis Safety, `observability`. Frontend-static нет.
|
||||
|
||||
Секреты ВМ2 (не в `.env` ВМ1):
|
||||
|
||||
- PG DSN роли Safety/`bitrix_sync` + CA;
|
||||
- `MESSAGE_SAFETY_REDIS_URL`;
|
||||
- Safety mode file `/etc/han-chat/message-safety-mode.env` (не repository `.env`);
|
||||
- S3 **read-only quarantine** credentials;
|
||||
- internal TLS PEM `8443`;
|
||||
- Bitrix sync webhook tokens, portal credentials, `BITRIX_WEBHOOK_ALLOWED_CIDRS`;
|
||||
- OTEL exporter secrets.
|
||||
|
||||
`BITRIX_SYNC_ENABLED=false` до §9. Paired tokens local app ВМ1 здесь не живут.
|
||||
|
||||
## 5. Images
|
||||
|
||||
Один immutable image API+worker Safety, отдельные containers. MVP: 1 API + 1 worker с 5 file-worker slots. Build без production secrets. Frontend на ВМ2 не собирается.
|
||||
|
||||
## 6. Root Compose ВМ2
|
||||
|
||||
Сервисы: public/private nginx, `message-safety-api`, `message-safety-worker`, `clamd`, `freshclam`, `bitrix-sync`, Redis Safety, local `otel-collector`.
|
||||
|
||||
Networks: `public` (только nginx webhook/ACME), `backend`, `egress` (freshclam, bitrix-sync, Safety worker → S3/PG/DNS, collector → SigNoz), `observability`. Safety API/clamd/Redis без общего internet egress.
|
||||
|
||||
Volumes: Redis Safety data (rebuildable), ClamAV signatures, ACME public, `otel-queue` + init. Frontend-static нет. Published: nginx 80/443; `8443` только SG ВМ1/ops.
|
||||
|
||||
Compose gate — arch-10 применительно к этому Compose.
|
||||
|
||||
## 7. TLS ВМ2
|
||||
|
||||
Public: arch-10 §9, `-d <PROCESSING_PUBLIC_HOST>`. HTTP webhook paths — без redirect с query token ([`module-03-nginx-vm2.md`](module-03-nginx-vm2.md)).
|
||||
|
||||
Private `8443`: сертификат internal CA, SAN = private DNS. Не Let's Encrypt. Caller ВМ1 проверяет CA bind.
|
||||
|
||||
## 8. Миграции Safety
|
||||
|
||||
Stub не создаёт PG tables. Production v2: schema `message_safety`, lease/fencing smoke. Первая migration создаёт `config_versions` и seed version 1; readiness без ровно одной valid active version не открывается.
|
||||
|
||||
Config-only rollout — отдельный root-owned job под config-admin role: immutable draft, JSON Schema, artifacts, approvals, транзакционный activate. Runtime — только `SELECT` к config. Rollback не реактивирует retired row: клон в новую monotonic version.
|
||||
|
||||
## 9. Controlled rollout `bitrix-sync`
|
||||
|
||||
До `BITRIX_SYNC_ENABLED=true`:
|
||||
|
||||
1. custom Contact fields и env names; universal CRM `UF_CRM_<digits>` → `ufCrm_<digits>`;
|
||||
2. smart process конфликтов и `bitrix_sync.settings`;
|
||||
3. входящий webhook техпользователя, права module-07 §13;
|
||||
4. два HTTP-webhook на `https://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/...?token=...`; query/body не в журналах; local app handler не создавать;
|
||||
5. expand migrations `han_app` (делает ВМ1/api-backend) затем `bitrix_sync`; GRANT и negative tests;
|
||||
6. `cutover_watermark`; отменить pending до него с `initial_full_sync_cutover`;
|
||||
7. не создавать backfill;
|
||||
8. image с sync disabled: `/health/live`, `sync_disabled`, smoke Bitrix methods;
|
||||
9. открыть на nginx ВМ2 только два exact routes для `BITRIX_WEBHOOK_ALLOWED_CIDRS`; valid/invalid source IP; нет запросов на ВМ1;
|
||||
10. включить sync; canary post-watermark; нет CRM ID в App DB;
|
||||
11. canary window: queue age, 429, DLQ, webhook lag, reconciliation, source-IP rejects.
|
||||
|
||||
Rollback: закрыть webhook или `503`; `BITRIX_SYNC_ENABLED=false`; ВМ1 не изменяется; mapping не удалять; schema не downgrade.
|
||||
|
||||
### Изменение source IP Битрикс24
|
||||
|
||||
Сигнал: всплеск Contact из reconciliation + `webhook_rejected_total{reason="source_ip"}`.
|
||||
|
||||
1. Сопоставить bounded-retention журнал rejects; query/body не извлекать.
|
||||
2. Подтвердить адрес как инфраструктуру Битрикс24; query token сам по себе недостаточен.
|
||||
3. Минимальный CIDR в version-controlled allow-list, review, `nginx -t` через deployment unit.
|
||||
4. Safe reload; приём webhook без query/body в logs.
|
||||
5. Rejects прекратились, lag нормализовался.
|
||||
6. Ошибочное расширение — rollback allow-list. Автодобавление IP запрещено.
|
||||
|
||||
## 10. Ordered startup ВМ2
|
||||
|
||||
1. Redis Safety, `otel-queue-init`, Collector;
|
||||
2. `clamd`/`freshclam`, Safety API/worker, `bitrix-sync`;
|
||||
3. nginx последним: оба TLS, exact webhook, capability health, signature age, negative ingress/egress.
|
||||
|
||||
До enablement `bitrix-sync` — `sync_disabled`. Safety v2 capability `text|links|files|worker`. Redis Safety не core gate readiness.
|
||||
|
||||
## 11. Public smoke ВМ2
|
||||
|
||||
```bash
|
||||
curl -I http://<PROCESSING_PUBLIC_HOST>/
|
||||
curl -i https://<PROCESSING_PUBLIC_HOST>/internal/sync/v1/status
|
||||
curl -i https://<PROCESSING_PUBLIC_HOST>/bitrix/sync/webhook/contact
|
||||
openssl s_client -connect <PROCESSING_PUBLIC_HOST>:443 -servername <PROCESSING_PUBLIC_HOST>
|
||||
```
|
||||
|
||||
Expected: HTTP redirect/ACME policy; internal 404; GET webhook 405/404; отдельный cert. POST webhook — отдельный form-urlencoded test allowed/denied IP без query token в history.
|
||||
|
||||
Private `8443` проверяется с ВМ1/ops, не из интернета.
|
||||
|
||||
## 12. Observability ВМ2
|
||||
|
||||
[`module-09-observability-vm2.md`](module-09-observability-vm2.md) + arch-07. Paging: MOCK, Safety config, sync DLQ, queue age.
|
||||
|
||||
## 13. Cutover, rollback, reprovision
|
||||
|
||||
Cutover gates: private TLS chain/SAN; Safety v2 PG migration и lease/fencing smoke; capability `text|links|files|worker`; S3 Gate 4 (Safety read-only); performance §2; egress negative tests. Approvals: Safety Service, Rule Pack, Security, Product, Operations. Только после них ВМ1 переключает `MESSAGE_SAFETY_URL`.
|
||||
|
||||
При потере ВМ2 fail-open запрещён. Reprovision из immutable image; отдельный IAM; Redis пустой; gates повторяются. RTO ≤4 ч; restore rehearsal ≥2 раза в год.
|
||||
|
||||
Rollback ВМ2 не требует изменения nginx ВМ1. Caller rollback — runbook ВМ1.
|
||||
|
||||
## 14. Emergency MOCK и Freshclam
|
||||
|
||||
`deploy` без root login:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/sbin/han-message-safety-mode mock --text-free true --file-free true
|
||||
sudo /usr/local/sbin/han-message-safety-mode mock --text-free true --file-free false
|
||||
sudo /usr/local/sbin/han-message-safety-mode mock --text-free false --file-free true
|
||||
sudo /usr/local/sbin/han-message-safety-mode mock --text-free false --file-free false
|
||||
sudo /usr/local/sbin/han-message-safety-mode standard
|
||||
```
|
||||
|
||||
Helper `root:root 0755`; sudoers только этот executable. Config `root:han-message-safety 0640`, GID `10001`. Нет auto-expiry; incident не закрывать без `standard` и canary. File в MOCK — `scan_status=bypassed`, не `clean`. `deploy` не пишет Compose/Docker.
|
||||
|
||||
`freshclam`: controlled egress только к signature CDN. Seed `max_signature_age_hours=240`, schema max `720`. Stale/failed update выключает только `files` и alert. Новая база — integrity/EICAR, atomic activate; regression — предыдущая валидная база.
|
||||
|
||||
## 15. Rollback, ops, incidents ВМ2
|
||||
|
||||
Redis Safety restore — empty/clean, lazy cache ([`module-04-redis-vm2.md`](module-04-redis-vm2.md)). Routine: signature age, Safety queue, sync DLQ/webhook lag, Collector, disk, egress still deny.
|
||||
|
||||
Incident: Safety timeout — checkpoint, не новый task id; Bitrix down — circuit/DLQ, webhook `503`; MOCK page — вернуть standard; cert processing host и private CA.
|
||||
|
||||
Потеря ВМ2: новая VM в VPC, hardening, private DNS, secrets из VM2 IAM, empty Redis, Safety migrations, nginx webhook+8443, **не** менять public routes ВМ1 до готовности; затем повторить cutover gates.
|
||||
|
||||
## 16. Definition of Done ВМ2
|
||||
|
||||
Дополнительно к arch-10 §12:
|
||||
|
||||
- Compose/nginx/Redis Safety/Collector прошли профильные compose/nginx gates;
|
||||
- private `8443` fail-closed до cutover, затем только источники ВМ1;
|
||||
- webhook allow-list и preflight `BITRIX_SYNC_ENABLED` согласованы;
|
||||
- capability/load gates module-05;
|
||||
- MOCK helper и Freshclam rehearsal;
|
||||
- observability ВМ2 + redaction (нет query/body/file content);
|
||||
- cutover §13 не объявлен выполненным только документацией.
|
||||
|
||||
## 17. TBD ВМ2
|
||||
|
||||
D-TBD5 Safety v2; D-TBD6 bitrix-sync cutover; performance gates уже в §2.
|
||||
|
||||
## 18. Ссылки
|
||||
|
||||
- Контракт: [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
|
||||
- ВМ1: [`module-10-deployment-vm1.md`](../../VM1_app/documentation/module-10-deployment-vm1.md).
|
||||
- Указатель: [`module-10-deployment-runbook.md`](module-10-deployment-runbook.md).
|
||||
- Safety / sync / nginx: [`module-05-message-safety.md`](module-05-message-safety.md), [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md), [`module-03-nginx-vm2.md`](module-03-nginx-vm2.md).
|
||||
Reference in New Issue
Block a user