Проект разделен на два репозитория
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,6 @@
|
||||
.env
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
report*.json
|
||||
@@ -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
|
||||
```
|
||||
@@ -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())
|
||||
@@ -0,0 +1,4 @@
|
||||
boto3
|
||||
psycopg[binary]
|
||||
python-dotenv
|
||||
requests
|
||||
@@ -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()
|
||||
@@ -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. Публичный трафик одной машины не проксируется через другую.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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".
|
||||
|
||||
Reference in New Issue
Block a user