Проект разделен на два репозитория

This commit is contained in:
mi
2026-08-14 15:42:45 +03:00
parent e06a77ee1d
commit bbef7a30c9
521 changed files with 2597 additions and 2302 deletions
+53
View File
@@ -0,0 +1,53 @@
# Скопируйте в .env, заполните секреты и выполните:
# chmod 600 .env
# python3 infratest.py --mode full
# Managed PostgreSQL. Указывайте адрес из ВМ и CA-файл на самой ВМ.
HAN_PG_HOST=managed-pg.private.example
HAN_PG_PORT=6432
HAN_PG_DATABASE=han_chat
HAN_PG_SSLMODE=verify-full
HAN_PG_SSLROOTCERT=/opt/han-chat/secrets/pg/ca.pem
# Имена ролей и схем совпадают с контрактом HAN Chat.
HAN_PG_USER_HAN_APP=han_app
HAN_PG_SCHEMA_HAN_APP=han_app
HAN_PG_PASSWORD_HAN_APP=
HAN_PG_USER_BITRIX=bitrix_local_app
HAN_PG_SCHEMA_BITRIX=bitrix_local
HAN_PG_PASSWORD_BITRIX=
HAN_PG_USER_BITRIX_SYNC=bitrix_sync_user
HAN_PG_SCHEMA_BITRIX_SYNC=bitrix_sync
HAN_PG_PASSWORD_BITRIX_SYNC=
HAN_PG_USER_MESSAGE_SAFETY=message_safety_app
HAN_PG_SCHEMA_MESSAGE_SAFETY=message_safety
HAN_PG_PASSWORD_MESSAGE_SAFETY=
HAN_PG_USER_KEYCLOAK=keycloak_user
HAN_PG_SCHEMA_KEYCLOAK=keycloak
HAN_PG_PASSWORD_KEYCLOAK=
# Selectel S3: основной RW-ключ API backend.
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
# Не задавайте регион, если приложение также использует значение SDK по умолчанию.
SELECTEL_S3_REGION=
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents
SELECTEL_S3_ACCESS_KEY=
SELECTEL_S3_SECRET_KEY=
# Отдельный read-only ключ message-safety: R только для quarantine.
SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=
# Точный origin веб-приложения для CORS-проверки.
PUBLIC_WEB_URL=https://chat.example.ru
# Проверки после миграций Alembic включайте только после deployment/scripts/migrate.sh.
INFRATEST_CHECK_MIGRATIONS=false
INFRATEST_CHECK_CORS=true
INFRATEST_TIMEOUT_SECONDS=15
+6
View File
@@ -0,0 +1,6 @@
.env
.venv/
.ruff_cache/
__pycache__/
*.pyc
report*.json
+186
View File
@@ -0,0 +1,186 @@
# Инфраструктурный тест HAN Chat
Автономный тест запускается непосредственно на ВМ и проверяет managed PostgreSQL и
три приватных Selectel S3-бакета в соответствии с контрактами HAN Chat. Для запуска
приложение и Docker Compose не требуются.
Тест не создаёт постоянных данных:
- PostgreSQL DDL/DML выполняются в транзакции и завершаются `ROLLBACK`;
- S3-объекты создаются только под уникальным префиксом `infratest/<uuid>/`;
- все S3-объекты удаляются в блоке очистки даже при ошибке одной из проверок.
## Что проверяется
### PostgreSQL
- подключение пяти сервисных ролей с TLS `verify-full`;
- `current_user`, `search_path`, наличие своей схемы и отсутствие `USAGE` на четыре
чужие схемы;
- `CREATE TABLE`, `INSERT`, `SELECT` и фактическое отсутствие таблицы после
`ROLLBACK`;
- расширение `pgcrypto`;
- опционально — ключевые таблицы и Alembic revision после миграций.
Проверяются роли:
- `han_app``han_app`;
- `bitrix_local_app``bitrix_local`;
- `bitrix_sync_user``bitrix_sync`;
- `message_safety_app``message_safety`;
- `keycloak_user``keycloak`.
### S3
- `HeadBucket` для quarantine, attachments и documents;
- прямые `PutObject`, `HeadObject`, `GetObject`, `ListObjectsV2` с ВМ;
- presigned PUT и GET с побайтовой проверкой содержимого;
- проектный переход `CopyObject` из quarantine в attachments с удалением
исходного объекта;
- запрет анонимного GET;
- CORS preflight для точного `PUBLIC_WEB_URL`;
- отдельный ключ message-safety: чтение/list только quarantine, запрет записи,
удаления и доступа к attachments/documents.
Multipart API не проверяется: текущая реализация HAN Chat использует single PUT.
Прикладные сценарии авторизации, создания диалога и отправки сообщения также не
входят в этот инструмент — это E2E приложения, а не проверка инфраструктуры.
## Подготовка на ВМ
Рекомендуется скопировать всю папку в отдельный каталог:
```bash
mkdir -p /opt/han-chat/infratest
cd /opt/han-chat/infratest
```
Установите Python 3.10+ и создайте изолированное окружение:
```bash
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install --upgrade pip
python3 -m pip install -r requirements.txt
```
Создайте настройки:
```bash
cp .env.example .env
chmod 600 .env
nano .env
```
В `.env` нужны сервисные пароли PostgreSQL, основной S3 RW-ключ и отдельный
quarantine read-only ключ. Административный пароль PostgreSQL не используется.
`HAN_PG_SSLROOTCERT` должен указывать на CA-файл по пути, доступному из процесса
на ВМ, а не на контейнерный путь `/run/secrets/pg-ca.pem`.
Не передавайте секреты аргументами командной строки и не прикладывайте `.env` к
отчётам. Файл исключён через `.gitignore`.
## Запуск
Полная самочищающаяся проверка:
```bash
cd /opt/han-chat/infratest
. .venv/bin/activate
python3 infratest.py --mode full
```
Проверка без DDL и записи в S3:
```bash
python3 infratest.py --mode readonly
```
Другой файл настроек и JSON-отчёт:
```bash
python3 infratest.py \
--env-file /secure/path/infratest.env \
--mode full \
--json-report report.json
```
Скрипт возвращает:
- `0` — нет проваленных проверок;
- `1` — одна или несколько инфраструктурных проверок завершились ошибкой;
- `2` — неверная конфигурация или отсутствуют Python-зависимости.
В консоль и JSON не выводятся пароли, access keys и query-параметры presigned URL.
## Запуск до и после миграций
До запуска Alembic оставьте:
```dotenv
INFRATEST_CHECK_MIGRATIONS=false
```
После успешного `deployment/scripts/migrate.sh` измените значение на:
```dotenv
INFRATEST_CHECK_MIGRATIONS=true
```
В этом режиме дополнительно проверяются ключевые таблицы схем `han_app`,
`bitrix_local`, `bitrix_sync` и ожидаемая revision `0001_initial` для `han_app`.
## Ожидаемая IAM-модель
Основной `SELECTEL_S3_ACCESS_KEY` должен иметь:
- RW на quarantine, attachments и documents;
- `ListBucket`, `GetBucketLocation`, `PutObject`, `GetObject`, `DeleteObject`;
- `CopyObject` обеспечивается правами чтения источника и записи назначения.
Ключ `SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY` должен иметь только:
- `ListBucket`, `GetBucketLocation`, `GetObject` для quarantine;
- никаких прав на Put/Delete;
- никаких прав на attachments и documents.
Если IAM-политика ограничивает основной ключ другими object prefixes, разрешите
служебный префикс `infratest/*` либо запускайте только `--mode readonly`.
## CORS
При `INFRATEST_CHECK_CORS=true` quarantine должен отвечать на preflight по
**vHosted** URL (`<bucket>.<s3_domain>`). У Selectel CORS не работает на
path-style адресации, даже если presigned PUT с сервера проходит.
Требования к правилу CORS на `han-chat-quarantine`:
- бакет с включённой Virtual-Hosted адресацией;
- origin — точное значение `PUBLIC_WEB_URL`, без wildcard;
- method — `PUT` (можно также `GET`, `HEAD`, `POST`);
- headers — минимум `content-type`; wildcard `x-amz-*` в панели Selectel обычно
**не работает**, надёжнее указать `*` или перечислить заголовки явно.
Чтобы временно исключить CORS из диагностики:
```dotenv
INFRATEST_CHECK_CORS=false
```
## Интерпретация результата
- `PASS` — контракт подтверждён реальной операцией;
- `FAIL` — контракт нарушен или ресурс недоступен;
- `SKIP` — проверка отключена режимом или настройкой.
При `FAIL` сначала проверьте сетевые ACL ВМ, CA/hostname PostgreSQL, GRANT и
`search_path`, затем IAM/CORS бакетов. После аварийного прерывания процесса можно
найти остатки только под префиксом `infratest/`; удалять другие ключи не требуется.
## Локальные unit-тесты
Они не обращаются к PostgreSQL или S3:
```bash
python3 -m unittest -v test_infratest.py
```
+857
View File
@@ -0,0 +1,857 @@
#!/usr/bin/env python3
"""Самоочищающийся инфраструктурный тест PostgreSQL и S3 для HAN Chat."""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import uuid
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlsplit, urlunsplit
try:
import boto3
import psycopg
import requests
from botocore.config import Config as BotoConfig
from botocore.exceptions import ClientError
from dotenv import dotenv_values
from psycopg import sql
except ImportError as exc: # pragma: no cover - понятная ошибка до запуска тестов
print(
f"Не установлена зависимость {exc.name!r}. "
"Выполните: python3 -m pip install -r requirements.txt",
file=sys.stderr,
)
raise SystemExit(2) from exc
URL_PATTERN = re.compile(r"https?://[^\s]+")
EXPECTED_ROLES = (
("han_app", "han_app", "HAN_PG_PASSWORD_HAN_APP"),
("bitrix_local_app", "bitrix_local", "HAN_PG_PASSWORD_BITRIX"),
("bitrix_sync_user", "bitrix_sync", "HAN_PG_PASSWORD_BITRIX_SYNC"),
("message_safety_app", "message_safety", "HAN_PG_PASSWORD_MESSAGE_SAFETY"),
("keycloak_user", "keycloak", "HAN_PG_PASSWORD_KEYCLOAK"),
)
EXPECTED_TABLES = {
"han_app": (
"alembic_version",
"app_settings",
"dialogs",
"messages",
"message_attachments",
"documents",
"sync_queue",
),
"bitrix_local": (
"alembic_version",
"portal_installations",
"dialog_sessions",
"inbox_events",
"outbound_messages",
),
"bitrix_sync": ("alembic_version",),
}
class ConfigError(ValueError):
"""Ошибка пользовательской конфигурации."""
def as_bool(value: str | None, default: bool = False) -> bool:
if value is None or not value.strip():
return default
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "on"}:
return True
if normalized in {"0", "false", "no", "off"}:
return False
raise ConfigError(f"Ожидалось логическое значение, получено: {value!r}")
def sanitize(value: Any) -> str:
"""Удаляет секреты и query-параметры URL из диагностического текста."""
text = str(value)
text = URL_PATTERN.sub(
lambda match: urlunsplit(
(*urlsplit(match.group(0))[:3], "", "")
),
text,
)
text = re.sub(
r"(?i)\b(password|secret|access[_-]?key|authorization|token|dsn)"
r"\s*[:=]\s*[^\s,;]+",
lambda match: f"{match.group(1)}=<redacted>",
text,
)
return text[:1000]
def virtual_hosted_bucket_url(endpoint: str, bucket: str, key: str = "") -> str:
"""Собирает vHosted URL бакета для CORS preflight (Selectel не поддерживает CORS на path-style)."""
parsed = urlsplit(endpoint.rstrip("/"))
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise ConfigError("SELECTEL_S3_ENDPOINT_URL должен быть абсолютным https URL")
path = f"/{key.lstrip('/')}" if key else "/"
return urlunsplit((parsed.scheme, f"{bucket}.{parsed.netloc}", path, "", ""))
def require(values: dict[str, str], name: str) -> str:
value = values.get(name, "").strip()
if not value:
raise ConfigError(f"Не задана обязательная переменная {name}")
return value
@dataclass(frozen=True)
class PgRole:
user: str
schema: str
password: str = field(repr=False)
@dataclass(frozen=True)
class Settings:
pg_host: str
pg_port: int
pg_database: str
pg_sslmode: str
pg_sslrootcert: str
pg_roles: tuple[PgRole, ...]
s3_endpoint: str
s3_region: str | None
buckets: dict[str, str]
s3_access_key: str = field(repr=False)
s3_secret_key: str = field(repr=False)
s3_read_access_key: str = field(repr=False)
s3_read_secret_key: str = field(repr=False)
public_web_url: str | None
check_cors: bool
check_migrations: bool
timeout_seconds: int
@classmethod
def from_env_file(cls, path: Path) -> "Settings":
if not path.is_file():
raise ConfigError(f"Файл настроек не найден: {path}")
raw = dotenv_values(path)
values = {
key: str(value)
for key, value in raw.items()
if value is not None
}
# Явно экспортированные переменные имеют приоритет над файлом.
values.update({key: value for key, value in os.environ.items() if key in values})
sslmode = values.get("HAN_PG_SSLMODE", "verify-full").strip()
if sslmode != "verify-full":
raise ConfigError("HAN_PG_SSLMODE должен быть verify-full")
ca_path = require(values, "HAN_PG_SSLROOTCERT")
if not Path(ca_path).is_file():
raise ConfigError(f"CA-файл PostgreSQL не найден: {ca_path}")
endpoint = require(values, "SELECTEL_S3_ENDPOINT_URL")
parsed_endpoint = urlsplit(endpoint)
if parsed_endpoint.scheme != "https" or not parsed_endpoint.netloc:
raise ConfigError("SELECTEL_S3_ENDPOINT_URL должен быть корректным HTTPS URL")
roles = tuple(
PgRole(
values.get(f"HAN_PG_USER_{env_suffix}", default_user).strip(),
values.get(f"HAN_PG_SCHEMA_{env_suffix}", default_schema).strip(),
require(values, password_env),
)
for default_user, default_schema, password_env in EXPECTED_ROLES
for env_suffix in (
{
"han_app": "HAN_APP",
"bitrix_local": "BITRIX",
"bitrix_sync": "BITRIX_SYNC",
"message_safety": "MESSAGE_SAFETY",
"keycloak": "KEYCLOAK",
}[default_schema],
)
)
timeout = int(values.get("INFRATEST_TIMEOUT_SECONDS", "15"))
if timeout < 1 or timeout > 300:
raise ConfigError("INFRATEST_TIMEOUT_SECONDS должен быть от 1 до 300")
public_web_url = values.get("PUBLIC_WEB_URL", "").strip() or None
check_cors = as_bool(values.get("INFRATEST_CHECK_CORS"), True)
if check_cors and not public_web_url:
raise ConfigError("Для CORS-проверки задайте PUBLIC_WEB_URL")
return cls(
pg_host=require(values, "HAN_PG_HOST"),
pg_port=int(values.get("HAN_PG_PORT", "5432")),
pg_database=require(values, "HAN_PG_DATABASE"),
pg_sslmode=sslmode,
pg_sslrootcert=ca_path,
pg_roles=roles,
s3_endpoint=endpoint.rstrip("/"),
s3_region=values.get("SELECTEL_S3_REGION", "").strip() or None,
buckets={
"quarantine": require(values, "SELECTEL_S3_BUCKET_QUARANTINE"),
"attachments": require(values, "SELECTEL_S3_BUCKET_ATTACHMENTS"),
"documents": require(values, "SELECTEL_S3_BUCKET_DOCUMENTS"),
},
s3_access_key=require(values, "SELECTEL_S3_ACCESS_KEY"),
s3_secret_key=require(values, "SELECTEL_S3_SECRET_KEY"),
s3_read_access_key=require(
values, "SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY"
),
s3_read_secret_key=require(
values, "SELECTEL_S3_QUARANTINE_READ_SECRET_KEY"
),
public_web_url=public_web_url,
check_cors=check_cors,
check_migrations=as_bool(
values.get("INFRATEST_CHECK_MIGRATIONS"), False
),
timeout_seconds=timeout,
)
@dataclass(frozen=True)
class Result:
check: str
status: str
detail: str
class Reporter:
def __init__(self) -> None:
self.results: list[Result] = []
def add(self, check: str, status: str, detail: Any) -> None:
result = Result(check, status, sanitize(detail))
self.results.append(result)
print(f"[{status:4}] {check}: {result.detail}")
def run(
self,
check: str,
operation: Callable[[], Any],
success_detail: str = "OK",
) -> bool:
try:
detail = operation()
except Exception as exc: # noqa: BLE001 - каждая проверка должна продолжить отчёт
self.add(check, "FAIL", f"{type(exc).__name__}: {exc}")
return False
self.add(check, "PASS", success_detail if detail is None else detail)
return True
def skip(self, check: str, detail: str) -> None:
self.add(check, "SKIP", detail)
@property
def exit_code(self) -> int:
return 1 if any(item.status == "FAIL" for item in self.results) else 0
def write_json(self, path: Path) -> None:
path.write_text(
json.dumps(
[item.__dict__ for item in self.results],
ensure_ascii=False,
indent=2,
)
+ "\n",
encoding="utf-8",
)
class InfraTest:
def __init__(self, settings: Settings, reporter: Reporter, mode: str) -> None:
self.settings = settings
self.reporter = reporter
self.mode = mode
self.run_id = uuid.uuid4().hex
self.prefix = f"infratest/{self.run_id}"
self.payload = (
b"HAN Chat infrastructure test\n"
+ self.run_id.encode("ascii")
+ b"\n"
)
self.api_s3 = self._s3_client(
settings.s3_access_key, settings.s3_secret_key
)
self.read_s3 = self._s3_client(
settings.s3_read_access_key, settings.s3_read_secret_key
)
self.created_objects: set[tuple[str, str]] = set()
def _s3_client(self, access_key: str, secret_key: str):
kwargs: dict[str, Any] = {
"service_name": "s3",
"endpoint_url": self.settings.s3_endpoint,
"aws_access_key_id": access_key,
"aws_secret_access_key": secret_key,
"config": BotoConfig(
connect_timeout=self.settings.timeout_seconds,
read_timeout=self.settings.timeout_seconds,
retries={"max_attempts": 2},
s3={"addressing_style": "virtual"},
),
}
if self.settings.s3_region:
kwargs["region_name"] = self.settings.s3_region
return boto3.client(**kwargs)
def _pg_connect(self, role: PgRole):
return psycopg.connect(
host=self.settings.pg_host,
port=self.settings.pg_port,
dbname=self.settings.pg_database,
user=role.user,
password=role.password,
sslmode=self.settings.pg_sslmode,
sslrootcert=self.settings.pg_sslrootcert,
connect_timeout=self.settings.timeout_seconds,
application_name=f"han-infratest-{self.run_id[:8]}",
)
def run(self) -> None:
self.run_postgres()
self.run_s3()
def run_postgres(self) -> None:
print("\nPostgreSQL")
schemas = tuple(role.schema for role in self.settings.pg_roles)
for role in self.settings.pg_roles:
connection_ok = self.reporter.run(
f"postgres.{role.user}.connect_tls",
lambda role=role: self._check_pg_connection(role),
)
if not connection_ok:
continue
self.reporter.run(
f"postgres.{role.user}.schema_isolation",
lambda role=role: self._check_schema_isolation(role, schemas),
)
if self.mode == "full":
self.reporter.run(
f"postgres.{role.user}.ddl_dml_rollback",
lambda role=role: self._check_pg_write(role),
)
else:
self.reporter.skip(
f"postgres.{role.user}.ddl_dml_rollback",
"режим readonly",
)
self.reporter.run("postgres.pgcrypto", self._check_pgcrypto)
if self.settings.check_migrations:
self.reporter.run("postgres.migrations", self._check_migrations)
else:
self.reporter.skip(
"postgres.migrations",
"INFRATEST_CHECK_MIGRATIONS=false",
)
def _check_pg_connection(self, role: PgRole) -> str:
with self._pg_connect(role) as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT current_user, current_setting('search_path'),
EXISTS (
SELECT 1 FROM pg_stat_ssl
WHERE pid = pg_backend_pid() AND ssl
)
"""
)
current_user, search_path, tls_active = cur.fetchone()
if current_user != role.user:
raise AssertionError(
f"current_user={current_user!r}, ожидался {role.user!r}"
)
if role.schema not in search_path.split(","):
raise AssertionError(
f"search_path={search_path!r}, ожидалась {role.schema!r}"
)
if not tls_active:
raise AssertionError("соединение установлено без TLS")
return f"TLS active, search_path={search_path}"
def _check_schema_isolation(
self, role: PgRole, all_schemas: tuple[str, ...]
) -> str:
with self._pg_connect(role) as conn:
with conn.cursor() as cur:
cur.execute(
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.schemata
WHERE schema_name = %s
), has_schema_privilege(current_user, %s, 'USAGE')
""",
(role.schema, role.schema),
)
schema_exists, own_usage = cur.fetchone()
if not schema_exists or not own_usage:
raise AssertionError(
f"нет схемы или USAGE для {role.schema}"
)
unexpected = []
for other in all_schemas:
if other == role.schema:
continue
cur.execute(
"SELECT has_schema_privilege(current_user, %s, 'USAGE')",
(other,),
)
if cur.fetchone()[0]:
unexpected.append(other)
if unexpected:
raise AssertionError(
"обнаружен USAGE на чужие схемы: " + ", ".join(unexpected)
)
return f"доступ только к схеме {role.schema}"
def _check_pg_write(self, role: PgRole) -> str:
table_name = f"_infratest_{self.run_id}"
with self._pg_connect(role) as conn:
try:
with conn.cursor() as cur:
cur.execute(
sql.SQL("CREATE TABLE {}.{} (id integer PRIMARY KEY, value text)")
.format(sql.Identifier(role.schema), sql.Identifier(table_name))
)
cur.execute(
sql.SQL("INSERT INTO {}.{} VALUES (%s, %s)").format(
sql.Identifier(role.schema), sql.Identifier(table_name)
),
(1, "ok"),
)
cur.execute(
sql.SQL("SELECT value FROM {}.{} WHERE id = %s").format(
sql.Identifier(role.schema), sql.Identifier(table_name)
),
(1,),
)
if cur.fetchone()[0] != "ok":
raise AssertionError("прочитано неожиданное значение")
finally:
conn.rollback()
with conn.cursor() as cur:
cur.execute("SELECT to_regclass(%s)", (f"{role.schema}.{table_name}",))
if cur.fetchone()[0] is not None:
raise AssertionError("таблица сохранилась после ROLLBACK")
return "CREATE/INSERT/SELECT успешны, DDL откачен"
def _check_pgcrypto(self) -> str:
role = next(item for item in self.settings.pg_roles if item.schema == "han_app")
with self._pg_connect(role) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname='pgcrypto')"
)
if not cur.fetchone()[0]:
raise AssertionError("расширение pgcrypto не установлено")
return "расширение pgcrypto установлено"
def _check_migrations(self) -> str:
role_by_schema = {role.schema: role for role in self.settings.pg_roles}
missing: list[str] = []
revisions: dict[str, str] = {}
for schema, tables in EXPECTED_TABLES.items():
with self._pg_connect(role_by_schema[schema]) as conn:
with conn.cursor() as cur:
for table in tables:
cur.execute("SELECT to_regclass(%s)", (f"{schema}.{table}",))
if cur.fetchone()[0] is None:
missing.append(f"{schema}.{table}")
cur.execute(
sql.SQL("SELECT version_num FROM {}.alembic_version LIMIT 1")
.format(sql.Identifier(schema))
)
row = cur.fetchone()
revisions[schema] = row[0] if row else "<empty>"
if missing:
raise AssertionError("нет таблиц: " + ", ".join(missing))
if revisions.get("han_app") != "0001_initial":
raise AssertionError(
f"han_app revision={revisions.get('han_app')!r}, ожидался '0001_initial'"
)
return "таблицы и Alembic revisions присутствуют"
def run_s3(self) -> None:
print("\nSelectel S3")
reachable: dict[str, bool] = {}
for logical_name, bucket in self.settings.buckets.items():
reachable[logical_name] = self.reporter.run(
f"s3.{logical_name}.head_bucket",
lambda bucket=bucket: self.api_s3.head_bucket(Bucket=bucket),
f"бакет {bucket} доступен",
)
self.reporter.run(
"s3.quarantine_read_key.boundaries",
self._check_read_key_bucket_boundaries,
)
if self.mode != "full":
for name in (
"direct_vm_operations",
"presigned_put_get",
"copy_promote",
"cors",
"anonymous_access",
"quarantine_read_key.object_permissions",
):
self.reporter.skip(f"s3.{name}", "режим readonly")
return
if not all(reachable.values()):
self.reporter.add(
"s3.full_operations",
"FAIL",
"полные проверки невозможны: не все бакеты доступны",
)
return
try:
direct_keys: dict[str, str] = {}
for logical_name, bucket in self.settings.buckets.items():
key = f"{self.prefix}/direct-{logical_name}.bin"
direct_keys[logical_name] = key
self.reporter.run(
f"s3.{logical_name}.direct_vm_operations",
lambda bucket=bucket, key=key: self._check_direct_object(
bucket, key
),
)
presigned_key = f"{self.prefix}/presigned.bin"
presigned_ok = self.reporter.run(
"s3.quarantine.presigned_put_get",
lambda: self._check_presigned(presigned_key),
)
if presigned_ok:
promoted_key = f"{self.prefix}/promoted.bin"
self.reporter.run(
"s3.quarantine_to_attachments.copy_promote",
lambda: self._check_promote(presigned_key, promoted_key),
)
privacy_key = direct_keys["documents"]
self.reporter.run(
"s3.documents.anonymous_access_denied",
lambda: self._check_anonymous_denied(
self.settings.buckets["documents"], privacy_key
),
)
self.reporter.run(
"s3.quarantine_read_key.object_permissions",
lambda: self._check_read_key_object_permissions(
direct_keys["quarantine"],
direct_keys["attachments"],
direct_keys["documents"],
),
)
if self.settings.check_cors:
cors_key = f"{self.prefix}/cors.bin"
self.reporter.run(
"s3.quarantine.cors_preflight",
lambda: self._check_cors(cors_key),
)
else:
self.reporter.skip(
"s3.quarantine.cors_preflight",
"INFRATEST_CHECK_CORS=false",
)
finally:
self._cleanup()
def _check_direct_object(self, bucket: str, key: str) -> str:
self.api_s3.put_object(
Bucket=bucket,
Key=key,
Body=self.payload,
ContentType="application/octet-stream",
Metadata={"infratest-run": self.run_id},
)
self.created_objects.add((bucket, key))
head = self.api_s3.head_object(Bucket=bucket, Key=key)
if head["ContentLength"] != len(self.payload):
raise AssertionError("HeadObject вернул неверный размер")
response = self.api_s3.get_object(Bucket=bucket, Key=key)
if response["Body"].read() != self.payload:
raise AssertionError("GetObject вернул другие байты")
listed = self.api_s3.list_objects_v2(
Bucket=bucket, Prefix=key, MaxKeys=2
).get("Contents", [])
if not any(item["Key"] == key for item in listed):
raise AssertionError("ListObjectsV2 не вернул тестовый объект")
return "Put/Head/Get/List с ВМ успешны"
def _check_presigned(self, key: str) -> str:
bucket = self.settings.buckets["quarantine"]
put_url = self.api_s3.generate_presigned_url(
"put_object",
Params={
"Bucket": bucket,
"Key": key,
"ContentType": "application/octet-stream",
},
ExpiresIn=300,
)
response = requests.put(
put_url,
data=self.payload,
headers={"Content-Type": "application/octet-stream"},
timeout=self.settings.timeout_seconds,
)
response.raise_for_status()
self.created_objects.add((bucket, key))
self.api_s3.head_object(Bucket=bucket, Key=key)
get_url = self.api_s3.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=300,
)
downloaded = requests.get(
get_url, timeout=self.settings.timeout_seconds
)
downloaded.raise_for_status()
if downloaded.content != self.payload:
raise AssertionError("presigned GET вернул другие байты")
return "presigned PUT/GET успешны, содержимое совпадает"
def _check_promote(self, source_key: str, destination_key: str) -> str:
quarantine = self.settings.buckets["quarantine"]
attachments = self.settings.buckets["attachments"]
self.api_s3.copy_object(
Bucket=attachments,
Key=destination_key,
CopySource={"Bucket": quarantine, "Key": source_key},
)
self.created_objects.add((attachments, destination_key))
copied = self.api_s3.get_object(
Bucket=attachments, Key=destination_key
)["Body"].read()
if copied != self.payload:
raise AssertionError("скопированный объект повреждён")
self.api_s3.delete_object(Bucket=quarantine, Key=source_key)
self.created_objects.discard((quarantine, source_key))
try:
self.api_s3.head_object(Bucket=quarantine, Key=source_key)
except ClientError as exc:
if exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode") != 404:
raise
else:
raise AssertionError("исходный quarantine-объект не удалён")
return "CopyObject успешен, исходный объект удалён"
def _check_anonymous_denied(self, bucket: str, key: str) -> str:
signed_url = self.api_s3.generate_presigned_url(
"get_object",
Params={"Bucket": bucket, "Key": key},
ExpiresIn=300,
)
parsed = urlsplit(signed_url)
unsigned_url = urlunsplit(
(parsed.scheme, parsed.netloc, parsed.path, "", "")
)
response = requests.get(
unsigned_url,
allow_redirects=False,
timeout=self.settings.timeout_seconds,
)
if response.status_code not in {401, 403}:
raise AssertionError(
f"анонимный GET вернул HTTP {response.status_code}, ожидался 401/403"
)
return f"анонимный GET запрещён (HTTP {response.status_code})"
@staticmethod
def _assert_denied(operation: Callable[[], Any], description: str) -> None:
try:
operation()
except ClientError as exc:
status = exc.response.get("ResponseMetadata", {}).get("HTTPStatusCode")
code = exc.response.get("Error", {}).get("Code", "")
if status in {401, 403} or code in {
"AccessDenied",
"AllAccessDisabled",
"InvalidAccessKeyId",
}:
return
raise
raise AssertionError(f"операция неожиданно разрешена: {description}")
def _check_read_key_bucket_boundaries(self) -> str:
quarantine = self.settings.buckets["quarantine"]
self.read_s3.head_bucket(Bucket=quarantine)
self.read_s3.list_objects_v2(Bucket=quarantine, MaxKeys=1)
for logical_name in ("attachments", "documents"):
bucket = self.settings.buckets[logical_name]
self._assert_denied(
lambda bucket=bucket: self.read_s3.list_objects_v2(
Bucket=bucket, MaxKeys=1
),
f"read-key ListBucket {logical_name}",
)
return "read-key видит только quarantine"
def _check_read_key_object_permissions(
self,
quarantine_key: str,
attachments_key: str,
documents_key: str,
) -> str:
quarantine = self.settings.buckets["quarantine"]
attachments = self.settings.buckets["attachments"]
documents = self.settings.buckets["documents"]
response = self.read_s3.get_object(
Bucket=quarantine, Key=quarantine_key
)
if response["Body"].read() != self.payload:
raise AssertionError("read-key получил повреждённые данные")
denied_put_key = f"{self.prefix}/read-key-must-not-put.bin"
try:
self._assert_denied(
lambda: self.read_s3.put_object(
Bucket=quarantine, Key=denied_put_key, Body=self.payload
),
"read-key PutObject quarantine",
)
finally:
# Если политика ошибочно разрешила PUT, удалить объект API-ключом.
try:
self.api_s3.delete_object(
Bucket=quarantine, Key=denied_put_key
)
except Exception: # noqa: BLE001 - основная очистка будет продолжена
pass
self._assert_denied(
lambda: self.read_s3.delete_object(
Bucket=quarantine, Key=quarantine_key
),
"read-key DeleteObject quarantine",
)
self._assert_denied(
lambda: self.read_s3.get_object(
Bucket=attachments, Key=attachments_key
),
"read-key GetObject attachments",
)
self._assert_denied(
lambda: self.read_s3.get_object(
Bucket=documents, Key=documents_key
),
"read-key GetObject documents",
)
return "GET quarantine разрешён; запись, удаление и другие бакеты запрещены"
def _check_cors(self, key: str) -> str:
# Selectel обрабатывает CORS только на vHosted URL; path-style presigned URL
# возвращает 405 даже при корректной конфигурации бакета.
url = virtual_hosted_bucket_url(
self.settings.s3_endpoint,
self.settings.buckets["quarantine"],
key,
)
response = requests.options(
url,
headers={
"Origin": self.settings.public_web_url or "",
"Access-Control-Request-Method": "PUT",
"Access-Control-Request-Headers": "content-type,x-amz-meta-infratest",
},
timeout=self.settings.timeout_seconds,
)
if response.status_code not in {200, 204}:
raise AssertionError(f"preflight вернул HTTP {response.status_code}")
allow_origin = response.headers.get("Access-Control-Allow-Origin")
if allow_origin != self.settings.public_web_url:
raise AssertionError(
f"Access-Control-Allow-Origin={allow_origin!r}, "
f"ожидался точный PUBLIC_WEB_URL"
)
allow_methods = response.headers.get("Access-Control-Allow-Methods", "")
if "PUT" not in allow_methods.upper():
raise AssertionError("CORS не разрешает PUT")
allow_headers = response.headers.get("Access-Control-Allow-Headers", "")
if "content-type" not in allow_headers.lower():
raise AssertionError(
f"CORS не разрешает content-type: {allow_headers!r}"
)
return "CORS разрешает PUT с PUBLIC_WEB_URL через vHosted URL"
def _cleanup(self) -> None:
failures: list[str] = []
for bucket, key in sorted(self.created_objects):
try:
self.api_s3.delete_object(Bucket=bucket, Key=key)
except Exception as exc: # noqa: BLE001 - удалить остальные объекты
failures.append(f"{bucket}/{key}: {sanitize(exc)}")
if failures:
self.reporter.add(
"s3.cleanup",
"FAIL",
"не удалены тестовые объекты: " + "; ".join(failures),
)
else:
self.reporter.add(
"s3.cleanup",
"PASS",
f"удалены все объекты префикса {self.prefix}",
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Проверка PostgreSQL и Selectel S3 для HAN Chat"
)
parser.add_argument(
"--env-file",
type=Path,
default=Path(__file__).with_name(".env"),
help="путь к .env (по умолчанию .env рядом со скриптом)",
)
parser.add_argument(
"--mode",
choices=("readonly", "full"),
default="full",
help="full выполняет самочищающиеся DDL/S3 операции",
)
parser.add_argument(
"--json-report",
type=Path,
help="сохранить обезличенный JSON-отчёт",
)
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
try:
settings = Settings.from_env_file(args.env_file.resolve())
except (ConfigError, ValueError) as exc:
print(f"Ошибка конфигурации: {sanitize(exc)}", file=sys.stderr)
return 2
reporter = Reporter()
test = InfraTest(settings, reporter, args.mode)
print(f"HAN Chat infrastructure test: mode={args.mode}, run={test.run_id[:8]}")
test.run()
if args.json_report:
reporter.write_json(args.json_report)
passed = sum(item.status == "PASS" for item in reporter.results)
failed = sum(item.status == "FAIL" for item in reporter.results)
skipped = sum(item.status == "SKIP" for item in reporter.results)
print(f"\nИтог: PASS={passed}, FAIL={failed}, SKIP={skipped}")
return reporter.exit_code
if __name__ == "__main__":
raise SystemExit(main())
+4
View File
@@ -0,0 +1,4 @@
boto3
psycopg[binary]
python-dotenv
requests
+162
View File
@@ -0,0 +1,162 @@
from __future__ import annotations
import io
import json
import os
import tempfile
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
from infratest import (
ConfigError,
Reporter,
Settings,
as_bool,
sanitize,
virtual_hosted_bucket_url,
)
def valid_env(ca_path: Path) -> str:
return f"""
HAN_PG_HOST=db.example.test
HAN_PG_PORT=6432
HAN_PG_DATABASE=han_chat
HAN_PG_SSLMODE=verify-full
HAN_PG_SSLROOTCERT={ca_path}
HAN_PG_PASSWORD_HAN_APP=han-password
HAN_PG_PASSWORD_BITRIX=bitrix-password
HAN_PG_PASSWORD_BITRIX_SYNC=sync-password
HAN_PG_PASSWORD_MESSAGE_SAFETY=safety-password
HAN_PG_PASSWORD_KEYCLOAK=keycloak-password
SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru
SELECTEL_S3_BUCKET_QUARANTINE=quarantine
SELECTEL_S3_BUCKET_ATTACHMENTS=attachments
SELECTEL_S3_BUCKET_DOCUMENTS=documents
SELECTEL_S3_ACCESS_KEY=api-access
SELECTEL_S3_SECRET_KEY=api-secret
SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=read-access
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=read-secret
PUBLIC_WEB_URL=https://chat.example.test
INFRATEST_CHECK_CORS=true
INFRATEST_CHECK_MIGRATIONS=false
INFRATEST_TIMEOUT_SECONDS=10
""".strip()
class SettingsTests(unittest.TestCase):
def test_loads_complete_configuration_and_default_roles(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
ca = root / "ca.pem"
ca.write_text("test ca", encoding="utf-8")
env_file = root / ".env"
env_file.write_text(valid_env(ca), encoding="utf-8")
with patch.dict(os.environ, {}, clear=True):
settings = Settings.from_env_file(env_file)
self.assertEqual(settings.pg_host, "db.example.test")
self.assertEqual(settings.pg_port, 6432)
self.assertEqual(
[(role.user, role.schema) for role in settings.pg_roles],
[
("han_app", "han_app"),
("bitrix_local_app", "bitrix_local"),
("bitrix_sync_user", "bitrix_sync"),
("message_safety_app", "message_safety"),
("keycloak_user", "keycloak"),
],
)
self.assertEqual(settings.buckets["documents"], "documents")
self.assertTrue(settings.check_cors)
self.assertFalse(settings.check_migrations)
def test_rejects_non_verifying_postgres_tls(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
ca = root / "ca.pem"
ca.write_text("test ca", encoding="utf-8")
env_file = root / ".env"
env_file.write_text(
valid_env(ca).replace(
"HAN_PG_SSLMODE=verify-full",
"HAN_PG_SSLMODE=require",
),
encoding="utf-8",
)
with patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(ConfigError, "verify-full"):
Settings.from_env_file(env_file)
def test_requires_public_url_when_cors_enabled(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
ca = root / "ca.pem"
ca.write_text("test ca", encoding="utf-8")
env_file = root / ".env"
env_file.write_text(
valid_env(ca).replace(
"PUBLIC_WEB_URL=https://chat.example.test",
"PUBLIC_WEB_URL=",
),
encoding="utf-8",
)
with patch.dict(os.environ, {}, clear=True):
with self.assertRaisesRegex(ConfigError, "PUBLIC_WEB_URL"):
Settings.from_env_file(env_file)
class UrlTests(unittest.TestCase):
def test_virtual_hosted_bucket_url(self) -> None:
url = virtual_hosted_bucket_url(
"https://s3.storage.selcloud.ru",
"han-chat-quarantine",
"infratest/key.bin",
)
self.assertEqual(
url,
"https://han-chat-quarantine.s3.storage.selcloud.ru/infratest/key.bin",
)
class SafetyTests(unittest.TestCase):
def test_sanitize_redacts_secret_and_url_query(self) -> None:
value = (
"password=do-not-print "
"url=https://bucket.example/object?X-Amz-Credential=secret&X-Amz-Signature=x"
)
sanitized = sanitize(value)
self.assertNotIn("do-not-print", sanitized)
self.assertNotIn("X-Amz", sanitized)
self.assertNotIn("Signature", sanitized)
self.assertIn("password=<redacted>", sanitized)
self.assertIn("https://bucket.example/object", sanitized)
def test_reporter_exit_code_and_json_are_secret_free(self) -> None:
reporter = Reporter()
with redirect_stdout(io.StringIO()):
reporter.add("ok", "PASS", "url=https://example.test/a?token=secret")
reporter.add("bad", "FAIL", "password=hidden")
with tempfile.TemporaryDirectory() as directory:
report_path = Path(directory) / "report.json"
reporter.write_json(report_path)
data = json.loads(report_path.read_text(encoding="utf-8"))
self.assertEqual(reporter.exit_code, 1)
serialized = json.dumps(data)
self.assertNotIn("hidden", serialized)
self.assertNotIn("?token", serialized)
def test_boolean_parser(self) -> None:
self.assertTrue(as_bool("yes"))
self.assertFalse(as_bool("OFF"))
with self.assertRaises(ConfigError):
as_bool("sometimes")
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -0,0 +1,14 @@
# module-03. Проектная спецификация корневого `nginx`
> Статус: указатель. Канонический контракт и профильные спецификации VM разнесены.
> Этот файл сохраняет стабильный путь `module-03-nginx.md` для существующих ссылок.
Nginx разрезан так, чтобы репозитории ВМ1 и ВМ2 не тащили чужой routing, а TLS, request id, ACME и запрет `/internal/` не разъезжались.
| Документ | Роль |
|---|---|
| [`../architectory/arch-08-nginx.md`](../architectory/arch-08-nginx.md) | Архитектурный контракт: независимый nginx на каждой VM, TLS/ACME, request id, forwarded headers, internal 404, JSON access log, Compose hardening. Не кастомизировать в репозитории VM так, чтобы сломать контракт. Compose-сети и published ports остаются в [`../architectory/arch-03-docker-compose-blueprint.md`](../architectory/arch-03-docker-compose-blueprint.md). |
| [`module-03-nginx-vm1.md`](module-03-nginx-vm1.md) | Реализация на ВМ1: SPA, `/api/`, `/auth/`, WS, Bitrix local app, SMS callback, CSP/CORS, public cache. |
| [`module-03-nginx-vm2.md`](module-03-nginx-vm2.md) | Реализация на ВМ2: exact CRM webhook, source IP allow-list, private `8443` Message Safety. |
Агент ВМ1 читает arch-08 + спецификацию ВМ1. Агент ВМ2 читает arch-08 + спецификацию ВМ2. Публичный трафик одной машины не проксируется через другую.
+14
View File
@@ -0,0 +1,14 @@
# module-04. Проектная спецификация Redis
> Статус: указатель. Канонический контракт и профильные спецификации VM разнесены.
> Этот файл сохраняет стабильный путь `module-04-redis.md` для существующих ссылок.
Redis разрезан так, чтобы репозитории ВМ1 и ВМ2 не тащили чужие ключи и URL, а инварианты (не source of truth, TTL, ACL prefix, AOF) не разъезжались.
| Документ | Роль |
|---|---|
| [`../architectory/arch-09-redis.md`](../architectory/arch-09-redis.md) | Архитектурный контракт: Redis не очередь и не OTP store, формат ключей, Lua governance, AOF/RDB, ACL/network, eviction, restore. Не кастомизировать в репозитории VM так, чтобы сломать контракт. |
| [`module-04-redis-vm1.md`](module-04-redis-vm1.md) | Реализация на ВМ1: DB0 rate/idempotency, DB1 realtime/coordination, legacy DB2 stub до cutover. |
| [`module-04-redis-vm2.md`](module-04-redis-vm2.md) | Реализация на ВМ2: Redis Safety hot cache/rate/wakeup; task/lease остаются в PostgreSQL. |
Агент ВМ1 читает arch-09 + спецификацию ВМ1. Агент ВМ2 читает arch-09 + спецификацию ВМ2. Экземпляры не шарят hostname, volume и credentials.
+14
View File
@@ -0,0 +1,14 @@
# module-09. Наблюдаемость production-like контура
> Статус: указатель. Канонический контракт и профильные спецификации VM разнесены.
> Этот файл сохраняет стабильный путь `module-09-observability.md` для существующих ссылок.
Наблюдаемость разрезана так, чтобы репозитории ВМ1 и ВМ2 не тащили чужой контур, а общие поля логов и redaction не разъезжались.
| Документ | Роль |
|---|---|
| [`../architectory/arch-07-observability.md`](../architectory/arch-07-observability.md) | Архитектурный контракт: Collector, JSON stdout, resource attributes, W3C, redaction, sampling, SLO, SigNoz, общие runbooks. Не кастомизировать в репозитории VM. |
| [`module-09-observability-vm1.md`](module-09-observability-vm1.md) | Реализация на ВМ1: nginx edge, `api-backend`, Keycloak, `bitrix-local-app`, Redis приложения, дашборды и алерты этой машины. |
| [`module-09-observability-vm2.md`](module-09-observability-vm2.md) | Реализация на ВМ2: nginx webhook/private, `message-safety`, `bitrix-sync`, Redis Safety, дашборды и алерты этой машины. |
Агент ВМ1 читает arch-07 + спецификацию ВМ1. Агент ВМ2 читает arch-07 + спецификацию ВМ2. Сквозной путь «frontend → nginx → API → Safety → Open Lines» связывается `request_id` / `trace_id`, а не общим Compose.
+15
View File
@@ -0,0 +1,15 @@
# module-10. Runbook развёртывания HAN Chat
> Статус: указатель. Канонический контракт и профильные runbook VM разнесены.
> Этот файл сохраняет стабильный путь `module-10-deployment-runbook.md` для существующих ссылок.
> Команды существующего stub-контура применимы только до production Safety cutover и явно отмечены как legacy в профильных файлах.
Развёртывание разрезано так, чтобы репозитории ВМ1 и ВМ2 не тащили чужой Compose и секреты, а VPC, managed PG, S3, роли `deploy` и порядок Safety cutover не разъезжались.
| Документ | Роль |
|---|---|
| [`../architectory/arch-10-deployment.md`](../architectory/arch-10-deployment.md) | Контракт: независимый deploy, SG/DNS, PG/S3, hardening, TLS процедура, сквозной startup/cutover, backup/rollback/DR. Не кастомизировать в репозитории VM так, чтобы сломать границы. OS-роли — [`../architectory/arch-06-service-hosting-security.md`](../architectory/arch-06-service-hosting-security.md). |
| [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) | Runbook ВМ1: edge, API, Keycloak, SMS, local app, frontend, legacy Safety gate, `MESSAGE_SAFETY_URL`. |
| [`module-10-deployment-vm2.md`](module-10-deployment-vm2.md) | Runbook ВМ2: webhook/private nginx, Safety, ClamAV, `bitrix-sync`, MOCK, Freshclam, reprovision. |
Агент ВМ1 читает arch-10 + runbook ВМ1. Агент ВМ2 читает arch-10 + runbook ВМ2. Сквозной Gate 14/15/cutover требует оба контура, но выполняется разными репозиториями и systemd-units.
+140
View File
@@ -0,0 +1,140 @@
> Статус: исходный концепт и история обсуждения. Каноническая реализационная постановка после принятия решений — [`module-07-bitrix-sync.md`](module-07-bitrix-sync.md). При расхождении применяется module-07.
>
> Актуализация 2026-08-06: production-проверка reconciliation использует `crm.item.list`, `entityTypeId=3`, фильтры `>=updatedTime`, `opened=1`, `ufCrm_1778692456=1`. Указанные ниже ранние варианты `UF_CRM_6A70C275346A7`, `Y/N` и поиск по двум телефонным маскам сохранены только как история и не являются реализационным контрактом.
MCP Server Bitrix24 с документацией https://mcp-dev.bitrix24.tech/mcp
### 1. Границы релиза
1.1. Что входит в **первый** релиз sync:
`contact.map_or_create`, `contact.update`,
передача в Битрикс24 тэга о том, что пользователь зарегистрирован в приложении
webhook Bitrix→App при изменении данных у пользователей, зарегистрированных в приложении
обновление сведений о данных пользователя при изменении их в Битрик24,
ведение бизнес-логов с конфликтами и ошибкам, требующих внимания.
создание и отслеживание статуса alerts по конфликтам
1.2. Что явно **вне scope**:
Lead/Deal,
документы компании в профиль,
merge контактов,
ручной replay API
1.3. Можно ли выпускать без двусторонности (только App→Bitrix), или webhook обязателен сразу?
вебхук обязателен в первой сборке
### 2. Сущности и маппинг полей
2.1 Какие поля `ClientProfile` / `UserIdentity` синхронизируем (в скобках поле в битрикс24):
`full_name` ↔ name
`citizenship` ↔ ufCrm_1768493029,
`russian_phone` ↔ phone,
`email` ↔ email
Значение citizenship возвращается ИД. Справочник Битрикс можно загрузить
curl -sS -G 'https://<portal-name>/rest/<user-id>/<token>/crm.contact.userfield.list' \
--data-urlencode 'filter[FIELD_NAME]=uf_crm_1768493029'
При запросах важно соблюдать следующий принцип: сервис проектируется таким образом, чтобы запрашивать минимум необходимой информации.
Т.е.
а) если мы синхронизинуем 5 полей, то мы запрашиваем ровно 5 нужных полей. Не запрашиваем все данные по клиенту.
б) если нам надо синхронизировать контакт по телефону - мы ищем в Б24 контакт только по этому телефону. Не запрашиваем список контактов.
Второй момент, выявленный в ходе тестирования:
Номер телефона может храниться в двух форматах - "+7xxxxxxxxxx", "+7 (xxx) xxx-xxxx"
Соответственно, по каждому искомому телефону делаем запрос по двум маскам, указанным выше.
Также сервис следует проектировать с учетом ограничений Битрикс24 по кол-ву обращений к АПИ (генерируем очередь и делаем запросы по расписанию через batch).
Пример запроса требуемых данных для одного пользователя:
curl -X POST \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
-d '{"entityTypeId":3,"select":["id","name","phone","ufCrm_1768493029","createdTime"],"filter":{"@phone":["+7xxxxxxxxxx","+7 (xxx) xxx-xxxx"],"opened":"Y"}}' \
https://<portal-name>/rest/<user-id>/<token>/crm.item.list
2.2. Нужен ли флаг «контакт зарегистрирован в приложении» в Bitrix, и в каком поле?
Нужен (поле UF_CRM_6A70C275346A7: Y/N).
### 3. Правила матчинга Contact
3.1. Ключ поиска: только телефон (как выше указывал телефон хранится по одной из двух масок; возможно в документации есть более стабильные методы поиска контакта по номеру телефона)
3.2. Управление конфликтами:
Для разбора конфликтов должны создаваться alert: задача в Б24 (смарт процесс "Конфликты синхронизации") и бизнес-лог в БД с типом проблемы, деталями, ссылкой на ИД задачи в Б24 и ее текущим статусом.
Жизненный цикл alert: alert создается СС, обработка alert осуществляется в Б24 через смарт процесс "Конфликты синхронизации", БД регулярно опрашивает статус задач. После успешного разбора конфликта, СС корректирует статус в БД на "Завершено". ИД alerts нумеруются сиквенсом и передаются в Б24 при постановке задачи.
Описанный механизм разбора конфликтов - используется для разбора бизнес-расхождений. Он не используется в случае технических сбоев или ошибок приложения.
3.3. Жизненный цикл пользователя приложения в контексте связи с контактом (на примере 1 пользователя):
а) Клиент зарегистрировался по номеру телефона.
СС запрашивает в Б24, есть ли клиенты с указанным номером телефона.
Варианты:
В0. 0 контактов. СС должен инициировать create, получить информацию об ИД нового пользователя, записать к себе ИД в Б24, в Б24 передать флаг привязки к Приложению.
В1.1. 1 контакт + контакт не имеет связи с приложением. СС должен записать к себе ИД в Б24, в Б24 передать флаг привязки к Приложению. Если при этом в приложении флаг уже был, нам не важно.
В1.2. 1 контакт + контакт уже привязан к другому активному пользователю приложения. СС должен инициировать create, получить информацию об ИД нового пользователя, записать к себе ИД в Б24, в Б24 передать флаг привязки к Приложению. Плюс СС создает alert об ошибке привязки.
В2.1. 2 и более. СС выбирает самый новый контакт по creationtime. Выбранный контакт не имеет связи с приложением. СС должен записать к себе ИД в Б24, в Б24 передать флаг привязки к Приложению. Плюс СС создает alert наличии дублей контактов: необходимо проверить актуальность контактов, корректность закрепления.
В2.2. 2 и более. СС выбирает самый новый контакт по creationtime. Выбранный контакт уже привязан к другому активному пользователю приложения. СС должен инициировать create, получить информацию об ИД нового пользователя, записать к себе ИД в Б24, в Б24 передать флаг привязки к Приложению. Плюс СС создает alert об ошибке привязки: с указанием деталей события (в том числе информация о наличии нескольких контактов).
б) Процесс обновления данных.
Сейчас реализовываем только движение Б24 -> Приложение.
Б24 отслеживает изменения данных контактов, у которых есть признак UF_CRM_6A70C275346A7 = 'Y'
При изменении Б24 направляет вебхук СС
СС при получении вебхука складывает в очередь задание на обновление данных соответствующего контакта.
По расписанию выполняется запрос данных контактов из Б24. И обновляются сведения. Если в ответ на запрос по контакту не найдены данные, формируется alert (Пользователь приложения привязан к несуществующему контакту).
в) Клиент удалил профиль из приложения.
Обновляем признак UF_CRM_6A70C275346A7 = 'N'. Деактивируем учетную запись.
Механизм изменения привязки ИД Битрикс24 к пользователю приложения - администратор вносит изменения напрямую в БД
### 4. Очередь и worker
4.1. Этот пункт - примерное видение. По нему можно смело предлагать улучшения, тк. получается несколько связанных асинхронных процессов.
В схеме han_app хранятся бизнесовые задачи на обновление данных. Должен быть воркер, который забирает эти задачи и стартует требуемые сценарии (map_or_create, update, delete, alert_create, alert_status_update).
Каждый сценарий предполагает набор действий и запросов в Б24. Обработчик сценария в рамках его исполнения формирует задачи на обмен данными с Б24 (один сценарий может предолагать несколько связанных задач). В следующем пункте расписал примерные запросы в Б24, которые могут возникать в ходе сценария. Тебе нужно более подробно сформулировать сценарий и действия.
Задачи на обмен с Б24 складываются в соответствующую таблицу в схеме sync. Обмен данными с Б24 осуществляется через механизм Батчей. Кроме операций синхронизации документов (не входят в текущую реализацию). Один вложенный в батч запрос должен относиться к одной задаче синхронизации.
Отправка запроса осуществляется по шедулеру (по умолчанию каждые 5 секунд). Для этого отдельный воркер собирает задачи, находящиеся в статусе pending + кол-во tryes <max_retries (не более 20 штук в один батч). Задачи отбираются по принципу fifo. Если задач меньше 20, то батч формируется из меньшего числа задач. Если задач 0, то батч не формируется, запрос в битрикс не отправляется.
Отобранные задачи переводятся в следующий статус (модель статусов предложи сам). После получения ответа успешные ответы переходят на следующий статус, неуспешные ответы, требующие retry, возвращаются в статус pending, кол-во tries + 1. Успешный ответ, касающийся задачи, сохраняем в таблице с задачами.
Триггер (или шедулер) проверяет наличие задач в статусе pending + кол-во tries >=max_retries. Если находит, переводит в статус fail + отбрасывает бизнес-лог для разбора (в Битрикс24 задача не создается).
Значения параметров прописываются в настройках сервиса в БД в схеме sync_service, изменения параметров в БД должны применяться сервисом без перезагрузки.
Какие у меня архитектурные сложности возникли, ограничивающие возможность полноценно поставить требования:
1) Битрикс24 допускает до 5 api запросов в секунду. По идее при высокой нагрузке это может означать что регулярность шедулера можно настроить до 0,2 секунд. Я не понимаю, нужно ли тут какой-то параллелиризм вводить? или БД с одним шедулером справится, тк Б24 достаточно быстро отвечает. Но что произойдет, если Б24 за 0.2 секунды не ответит? Тут нужна твоя экспертиза и варианты.
2) Как правильно организовать работу сценариев. Сценарий может содержать несколько последовательных действий, требующих обмена с Б24 и не требующих. Жизненный цикл отработки сценария начинается с момента, как я забрал задачу из han_app, или ее поставил сам СС. Держать в оперативной памяти сценарий на протяжении его жизненного цикла неправильно, тк очередь задач может забиться и либо все рухнет, либо сценарии перестанут запускаться - формируется точка отказа. Правильнее сценарий разделить на условно-атомарные операции, и раскладывать их в таблицу. И тогда какие-то воркеры могут быстро бегать по этой таблице, находить очередную операцию в рамках сценария, которая ждет исполнения, исполнять ее, переводить в статус "исполнена", чтобы активировать к возможности исполнения следующую задачу. Мне этот вариант кажется более отказоустойчивым и управляемым (плюс в любой момент, даже если грохнется сервис, можно запуститься с того места, где он грохнулся, и никакие сценарии не оборвутся). Но я не до конца понимаю, как тут управлять производительностью (можно ли много воркеров запускать, чтобы они параллельно работали, и в какой момент это нужно делать).
4.2. Виды запросов в Б24 с привязкой к сценариям:
Сценарий регистрации пользователя:
1: поиск контакта по номеру телефона
2: создание нового контакта либо
3: обновление сведений контактов (изменений флага UF_CRM_6A70C275346A7)
Сценарий обновления
4: запрос данных по контакту
Сценарий удаления профиля:
3: обновление сведений контактов (изменений флага UF_CRM_6A70C275346A7)
Сценарий обработки alerts:
5: поставить задачу в Б24
6: узнать статус задачи в Б24
... возможно еще понадобятся.
Используемые виды запросов хранятся в справочнике видов запросов и могут использоваться сервисом синхронизации в рамках запущенных процессов.
4.3. Сервис синхронизации должен ставить задачи на синхронизацию с использованием утвержденных видов запросов в Б24.
4.4. Готовы ли триггеры App DB и GRANT для `bitrix_sync_user` , или это часть той же постановки?
Триггеры 'contact.map_or_create' и 'contact.update' при создании и изменении данных в таблицах пользователей готовы и работают. Действующие триггеры необходимо проверить, что они не будут пытаться синхронизировать изменения, полученные от битрикс24 и залитые в БД. Процессов постановки задач на обновление данных пользователя из-за вебхука от Б24, нет.
### 5. Bitrix24: доступ и webhook
5.1. Способ доступа к CRM REST: я верно понимаю, что local-app безопаснее входящего вебхука? Т.к невозможно, даже зная токен, отправить запрос в Б24? Если это так, то логично создать в Б24 еще одно локальное приложение и получать данные через него. Для вебхуков от Б24 делаем соответствующую ссылку с секретом.
5.2. Кто настраивает робота в Bitrix (какие события/поля). Сформируй требования к роботу и исходящему вебхуку, я настрою робот.
5.3. Есть ли тестовый портал отдельно от prod. Нет, портал один. При этом для разделения теста и прода будут использоваться различные local-app. Чтобы не смешивать базу, набор тестовых пользователей будет задаваться маской "нелегитимных" номеров.
### 6. Надёжность, безопасность, ops
6.1. Rate limit / квоты Bitrix REST: политика при `QUERY_LIMIT_EXCEEDED`?
Кол-во попыток определено в п.4.1. Если не удалось - отбрасываем бизнес-логи. Правило универсально как для ошибок синхронизации, так и для QUERY_LIMIT_EXCEEDED. Можешь предложить вариант лучше, если есть идеи.
6.2. PII в логах/метриках: секреты в логах не допускаются. PII в логах маскируются: два первых читаемых и два последних читаемых символа поля отображаем как есть, остальные символы маскируем. Ошибки логируем как есть - т.к. сервис внутренний и недоступен пользователям.
6.3. Cutover: stub → full sync на уже накопленной очереди — replay всех pending или только новых? Управляем через .env: replay = true, значит replay всех pending. replay = false, значит все pending при раскатке сервиса переводим в статус "Cancelled".