Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7a60636fd9 | |||
| fdfdeaffb4 | |||
| 85df788f2d | |||
| 44db38f6fe | |||
| 465a70d488 | |||
| f989097484 | |||
| 728b9826a3 | |||
| c1e49fb15d | |||
| d2415fcfeb | |||
| 6e24278b10 |
@@ -0,0 +1,6 @@
|
|||||||
|
.env
|
||||||
|
*.log
|
||||||
|
node_modules/
|
||||||
|
__pycache__/
|
||||||
|
for_bugs_exchange/
|
||||||
|
*.tar.gz
|
||||||
@@ -89,8 +89,9 @@ IDGTL_SMS_CALLBACK_PUBLIC_URL=https://chat.example.ru/callbacks/idgtl/sms
|
|||||||
BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
|
BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
|
||||||
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
|
BITRIX_API_INBOX_PATH=/internal/openlines/v1/inbox
|
||||||
BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox
|
BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox
|
||||||
|
# Example only: set the actual VM2 private DNS name in the deployment .env.
|
||||||
MESSAGE_SAFETY_URL=https://processing.internal:8443
|
MESSAGE_SAFETY_URL=https://processing.internal:8443
|
||||||
# Docker extra_hosts mapping for VM2 private listener: <hostname>=<private-ip>.
|
# Docker extra_hosts mapping must use the same configured hostname: <hostname>=<private-ip>.
|
||||||
MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4
|
MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4
|
||||||
MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem
|
MESSAGE_SAFETY_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem
|
||||||
MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2
|
MESSAGE_SAFETY_API_PREFIX=/internal/safety/v2
|
||||||
@@ -114,6 +115,7 @@ SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
|
|||||||
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
|
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
|
||||||
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||||
|
HOST_NAME=vm1-production
|
||||||
OTEL_SERVICE_NAME_API=api-backend
|
OTEL_SERVICE_NAME_API=api-backend
|
||||||
OTEL_SERVICE_NAME_SMS_API=sms-service
|
OTEL_SERVICE_NAME_SMS_API=sms-service
|
||||||
OTEL_SERVICE_NAME_SMS_WORKER=sms-worker
|
OTEL_SERVICE_NAME_SMS_WORKER=sms-worker
|
||||||
@@ -125,6 +127,8 @@ OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
|
|||||||
OTEL_REMOTE_TLS_INSECURE=false
|
OTEL_REMOTE_TLS_INSECURE=false
|
||||||
# SDK отправляет все spans локальному Collector; решение о хранении принимает tail_sampling.
|
# SDK отправляет все spans локальному Collector; решение о хранении принимает tail_sampling.
|
||||||
OTEL_TRACES_SAMPLER=always_on
|
OTEL_TRACES_SAMPLER=always_on
|
||||||
|
# Операторский canary image; обязателен digest из утверждённого release record.
|
||||||
|
TELEMETRYGEN_IMAGE=ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:<64-hex>
|
||||||
# OTEL_QUEUE_SIZE пока документирует целевую ёмкость, конфигурация Collector закреплена в YAML.
|
# OTEL_QUEUE_SIZE пока документирует целевую ёмкость, конфигурация Collector закреплена в YAML.
|
||||||
OTEL_QUEUE_SIZE=10000
|
OTEL_QUEUE_SIZE=10000
|
||||||
|
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ han-notification-draft-cleanup-worker
|
|||||||
Токены генерируются `openssl rand -hex 32`. `MESSAGE_SAFETY_SERVICE_TOKEN`
|
Токены генерируются `openssl rand -hex 32`. `MESSAGE_SAFETY_SERVICE_TOKEN`
|
||||||
сохраняется как caller secret API backend; S3 credentials сервису Safety не
|
сохраняется как caller secret API backend; S3 credentials сервису Safety не
|
||||||
передаются. В production подключение PostgreSQL должно использовать TLS. Target
|
передаются. В production подключение PostgreSQL должно использовать TLS. Target
|
||||||
`MESSAGE_SAFETY_URL=https://processing.internal:8443`, API prefix
|
задаётся через `MESSAGE_SAFETY_URL=https://<private-vm2-name>:8443`
|
||||||
`/internal/safety/v2`; certificate проверяется по CA из
|
(`processing.internal` — только пример), API prefix `/internal/safety/v2`;
|
||||||
|
certificate проверяется по CA из
|
||||||
`MESSAGE_SAFETY_CA_HOST_PATH`, plaintext HTTP запрещён.
|
`MESSAGE_SAFETY_CA_HOST_PATH`, plaintext HTTP запрещён.
|
||||||
|
|
||||||
Smoke-сценарий `producer_test`: отправить `POST
|
Smoke-сценарий `producer_test`: отправить `POST
|
||||||
@@ -83,3 +84,18 @@ pytest
|
|||||||
зависимости read API. Remote Message Safety не выключает чтение/общую readiness:
|
зависимости read API. Remote Message Safety не выключает чтение/общую readiness:
|
||||||
send endpoint отдельно проверяет требуемую capability и fail-closed возвращает
|
send endpoint отдельно проверяет требуемую capability и fail-closed возвращает
|
||||||
`503`, если ВМ2 недоступна.
|
`503`, если ВМ2 недоступна.
|
||||||
|
|
||||||
|
## Публичная конфигурация мобильных обновлений
|
||||||
|
|
||||||
|
`GET /api/v1/public/app-config` возвращает строгий объект `mobile_update` с
|
||||||
|
политиками `google_play`, `rustore` и `app_store`. Для включённого магазина
|
||||||
|
обязательны `latest_build`, `minimum_build`, `latest_version` и HTTPS `store_url`;
|
||||||
|
`minimum_build` не может превышать `latest_build`. Для отключённого магазина
|
||||||
|
эти поля возвращаются как `null`. Опциональное `release_notes` также возвращается
|
||||||
|
как `null`, если в settings задана пустая строка. Канонический URL RuStore:
|
||||||
|
`https://www.rustore.ru/catalog/app/ru.han.chat`.
|
||||||
|
|
||||||
|
Ответ содержит `ETag`, поддерживает `If-None-Match` с ответом `304` и в
|
||||||
|
production-like конфигурации кэшируется клиентом и nginx 60 секунд. Изменения
|
||||||
|
политики применяются через штатный идемпотентный `seed-settings`; некорректные
|
||||||
|
пороги, типы и URL блокируют загрузку settings.
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Persist W3C trace context for asynchronous delivery.
|
||||||
|
|
||||||
|
Revision ID: 0013_delivery_trace_context
|
||||||
|
Revises: 0012_safety_v2_checkpoint
|
||||||
|
Create Date: 2026-09-03
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0013_delivery_trace_context"
|
||||||
|
down_revision: str | None = "0012_safety_v2_checkpoint"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE han_app.delivery_outbox
|
||||||
|
ADD COLUMN IF NOT EXISTS traceparent varchar(55)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE han_app.safety_tasks
|
||||||
|
ADD COLUMN IF NOT EXISTS traceparent varchar(55)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE han_app.delivery_outbox
|
||||||
|
DROP COLUMN IF EXISTS traceparent
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE han_app.safety_tasks
|
||||||
|
DROP COLUMN IF EXISTS traceparent
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -11,6 +11,12 @@ from sqlalchemy.dialects.postgresql import insert
|
|||||||
|
|
||||||
from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings
|
from app.chat_settings import CHAT_MESSAGE_MAX_LENGTH_KEY, validate_chat_settings
|
||||||
from app.db import AppSetting, Database
|
from app.db import AppSetting, Database
|
||||||
|
from app.mobile_update_settings import (
|
||||||
|
MOBILE_UPDATE_BOOLEAN_KEYS,
|
||||||
|
MOBILE_UPDATE_INTEGER_KEYS,
|
||||||
|
MOBILE_UPDATE_SETTING_KEYS,
|
||||||
|
validate_mobile_update_settings,
|
||||||
|
)
|
||||||
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
||||||
from app.settings import get_settings
|
from app.settings import get_settings
|
||||||
|
|
||||||
@@ -38,12 +44,25 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
|
|||||||
raise ValueError(f"{key}: type must be integer")
|
raise ValueError(f"{key}: type must be integer")
|
||||||
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer":
|
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "integer":
|
||||||
raise ValueError(f"{key}: type must be integer")
|
raise ValueError(f"{key}: type must be integer")
|
||||||
|
if key in MOBILE_UPDATE_BOOLEAN_KEYS and value_type != "boolean":
|
||||||
|
raise ValueError(f"{key}: type must be boolean")
|
||||||
|
if key in MOBILE_UPDATE_INTEGER_KEYS and value_type != "integer":
|
||||||
|
raise ValueError(f"{key}: type must be integer")
|
||||||
|
if (
|
||||||
|
key in MOBILE_UPDATE_SETTING_KEYS
|
||||||
|
- MOBILE_UPDATE_BOOLEAN_KEYS
|
||||||
|
- MOBILE_UPDATE_INTEGER_KEYS
|
||||||
|
and value_type != "string"
|
||||||
|
):
|
||||||
|
raise ValueError(f"{key}: type must be string")
|
||||||
if not isinstance(raw.get("public"), bool):
|
if not isinstance(raw.get("public"), bool):
|
||||||
raise ValueError(f"{key}: public must be a boolean")
|
raise ValueError(f"{key}: public must be a boolean")
|
||||||
if key in OTP_SETTING_KEYS and raw["public"]:
|
if key in OTP_SETTING_KEYS and raw["public"]:
|
||||||
raise ValueError(f"{key}: OTP setting must not be public")
|
raise ValueError(f"{key}: OTP setting must not be public")
|
||||||
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]:
|
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["public"]:
|
||||||
raise ValueError(f"{key}: setting must be public")
|
raise ValueError(f"{key}: setting must be public")
|
||||||
|
if key in MOBILE_UPDATE_SETTING_KEYS and not raw["public"]:
|
||||||
|
raise ValueError(f"{key}: mobile update setting must be public")
|
||||||
description = raw.get("description")
|
description = raw.get("description")
|
||||||
if description is not None and not isinstance(description, str):
|
if description is not None and not isinstance(description, str):
|
||||||
raise ValueError(f"{key}: description must be a string")
|
raise ValueError(f"{key}: description must be a string")
|
||||||
@@ -59,6 +78,9 @@ def load_seed(path: Path) -> list[dict[str, Any]]:
|
|||||||
)
|
)
|
||||||
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
|
validate_otp_settings({row["setting_key"]: row["setting_value"] for row in rows})
|
||||||
validate_chat_settings({row["setting_key"]: row["setting_value"] for row in rows})
|
validate_chat_settings({row["setting_key"]: row["setting_value"] for row in rows})
|
||||||
|
validate_mobile_update_settings(
|
||||||
|
{row["setting_key"]: row["setting_value"] for row in rows}
|
||||||
|
)
|
||||||
return rows
|
return rows
|
||||||
|
|
||||||
|
|
||||||
@@ -68,6 +90,8 @@ def serialize_value(key: str, value_type: str, value: Any) -> str:
|
|||||||
raise ValueError(f"{key}: boolean value expected")
|
raise ValueError(f"{key}: boolean value expected")
|
||||||
return str(value).lower()
|
return str(value).lower()
|
||||||
if value_type == "integer":
|
if value_type == "integer":
|
||||||
|
if key in MOBILE_UPDATE_INTEGER_KEYS and value is None:
|
||||||
|
return ""
|
||||||
if not isinstance(value, int) or isinstance(value, bool):
|
if not isinstance(value, int) or isinstance(value, bool):
|
||||||
raise ValueError(f"{key}: integer value expected")
|
raise ValueError(f"{key}: integer value expected")
|
||||||
return str(value)
|
return str(value)
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ class SafetyTask(Base):
|
|||||||
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
||||||
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
attachment_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
|
||||||
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
quarantine_object_key: Mapped[str | None] = mapped_column(String(1024))
|
||||||
|
traceparent: Mapped[str | None] = mapped_column(String(55))
|
||||||
status: Mapped[str] = mapped_column(String(16))
|
status: Mapped[str] = mapped_column(String(16))
|
||||||
processing_mode: Mapped[str | None] = mapped_column(String(16))
|
processing_mode: Mapped[str | None] = mapped_column(String(16))
|
||||||
config_version: Mapped[int | None] = mapped_column(BigInteger)
|
config_version: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
@@ -228,6 +229,7 @@ class DeliveryOutbox(Base):
|
|||||||
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
message_id: Mapped[uuid.UUID] = mapped_column(ForeignKey(f"{SCHEMA}.messages.id"), unique=True)
|
||||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
|
payload_json: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||||
|
traceparent: Mapped[str | None] = mapped_column(String(55))
|
||||||
status: Mapped[str] = mapped_column(String(16), default="pending")
|
status: Mapped[str] = mapped_column(String(16), default="pending")
|
||||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, MutableMapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
REDACTED = "[REDACTED]"
|
REDACTED = "[REDACTED]"
|
||||||
@@ -42,6 +42,6 @@ def sanitize_value(value: Any) -> Any:
|
|||||||
def redact_event(
|
def redact_event(
|
||||||
_logger: Any,
|
_logger: Any,
|
||||||
_method_name: str,
|
_method_name: str,
|
||||||
event_dict: dict[str, Any],
|
event_dict: MutableMapping[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> MutableMapping[str, Any]:
|
||||||
return sanitize_value(event_dict)
|
return sanitize_value(event_dict)
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ from app.schemas import (
|
|||||||
MessageRequest,
|
MessageRequest,
|
||||||
OpenLinesInbox,
|
OpenLinesInbox,
|
||||||
OtpSettingsResponse,
|
OtpSettingsResponse,
|
||||||
|
PublicAppConfigResponse,
|
||||||
SessionStartRequest,
|
SessionStartRequest,
|
||||||
decode_cursor,
|
decode_cursor,
|
||||||
encode_cursor,
|
encode_cursor,
|
||||||
@@ -88,11 +89,22 @@ from app.services import (
|
|||||||
start_session,
|
start_session,
|
||||||
)
|
)
|
||||||
from app.settings import get_settings
|
from app.settings import get_settings
|
||||||
from app.telemetry import add_trace_context, current_trace_id, init_telemetry, instrument_fastapi
|
from app.telemetry import (
|
||||||
|
TelemetryRuntime,
|
||||||
|
add_trace_context,
|
||||||
|
current_trace_id,
|
||||||
|
init_telemetry,
|
||||||
|
instrument_fastapi,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: str) -> None:
|
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
|
||||||
logging.basicConfig(level=level, format="%(message)s")
|
logging.basicConfig(level=level, format="%(message)s")
|
||||||
|
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
|
||||||
|
telemetry.logging_handler.addFilter(
|
||||||
|
lambda record: not record.name.startswith("opentelemetry")
|
||||||
|
)
|
||||||
|
logging.getLogger().addHandler(telemetry.logging_handler)
|
||||||
structlog.configure(
|
structlog.configure(
|
||||||
processors=[
|
processors=[
|
||||||
structlog.contextvars.merge_contextvars,
|
structlog.contextvars.merge_contextvars,
|
||||||
@@ -101,7 +113,10 @@ def configure_logging(level: str) -> None:
|
|||||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||||
structlog.stdlib.add_log_level,
|
structlog.stdlib.add_log_level,
|
||||||
structlog.processors.JSONRenderer(),
|
structlog.processors.JSONRenderer(),
|
||||||
]
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -134,7 +149,7 @@ async def refresh_jwks_cache(app: FastAPI) -> None:
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
telemetry = init_telemetry()
|
telemetry = init_telemetry()
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level, telemetry)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.db = Database(settings.database_url)
|
app.state.db = Database(settings.database_url)
|
||||||
app.state.http = httpx.AsyncClient()
|
app.state.http = httpx.AsyncClient()
|
||||||
@@ -177,7 +192,7 @@ async def lifespan(app: FastAPI):
|
|||||||
telemetry.shutdown()
|
telemetry.shutdown()
|
||||||
|
|
||||||
|
|
||||||
EXPECTED_API_DB_REVISION = "0012_safety_v2_checkpoint"
|
EXPECTED_API_DB_REVISION = "0013_delivery_trace_context"
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -192,6 +207,15 @@ app.include_router(notification_router)
|
|||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def etag_matches(if_none_match: str | None, etag: str) -> bool:
|
||||||
|
if not if_none_match:
|
||||||
|
return False
|
||||||
|
return any(
|
||||||
|
candidate.strip().removeprefix("W/") in {"*", etag}
|
||||||
|
for candidate in if_none_match.split(",")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def client_ip(request: Request) -> str | None:
|
def client_ip(request: Request) -> str | None:
|
||||||
"""Trust forwarded client addresses only from configured reverse proxies."""
|
"""Trust forwarded client addresses only from configured reverse proxies."""
|
||||||
peer = request.client.host if request.client else ""
|
peer = request.client.host if request.client else ""
|
||||||
@@ -501,8 +525,17 @@ async def ready(request: Request, db: Session):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/public/app-config", tags=["public"])
|
@app.get(
|
||||||
async def app_config(request: Request, response: Response, settings: SnapshotDep):
|
"/api/v1/public/app-config",
|
||||||
|
tags=["public"],
|
||||||
|
response_model=PublicAppConfigResponse,
|
||||||
|
responses={304: {"description": "Cached configuration is still current"}},
|
||||||
|
)
|
||||||
|
async def app_config(
|
||||||
|
request: Request,
|
||||||
|
settings: SnapshotDep,
|
||||||
|
if_none_match: Annotated[str | None, Header()] = None,
|
||||||
|
):
|
||||||
await enforce_limit(
|
await enforce_limit(
|
||||||
request,
|
request,
|
||||||
"ip",
|
"ip",
|
||||||
@@ -511,12 +544,15 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
|
|||||||
settings.limit("rate_limit.public_endpoints.per_ip"),
|
settings.limit("rate_limit.public_endpoints.per_ip"),
|
||||||
fail_closed=False,
|
fail_closed=False,
|
||||||
)
|
)
|
||||||
response.headers["Cache-Control"] = (
|
headers = {"Cache-Control": (
|
||||||
f"public, max-age={settings.integer('security.public_cache.max_age_seconds')}"
|
f"public, max-age={settings.integer('security.public_cache.max_age_seconds')}"
|
||||||
)
|
)}
|
||||||
response.headers["ETag"] = f'"{settings.version}"'
|
etag = f'"{settings.version}"'
|
||||||
|
headers["ETag"] = etag
|
||||||
|
if etag_matches(if_none_match, etag):
|
||||||
|
return Response(status_code=304, headers=headers)
|
||||||
values = settings.values
|
values = settings.values
|
||||||
return {
|
body = {
|
||||||
"auth": {
|
"auth": {
|
||||||
"phone_enabled": settings.boolean("auth.phone.enabled"),
|
"phone_enabled": settings.boolean("auth.phone.enabled"),
|
||||||
"password_enabled": settings.boolean("auth.password.enabled"),
|
"password_enabled": settings.boolean("auth.password.enabled"),
|
||||||
@@ -557,7 +593,28 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
"ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")},
|
"ux": {"idle_timeout_minutes": settings.integer("ux.session.idle_timeout_minutes")},
|
||||||
|
"mobile_update": {
|
||||||
|
store: {
|
||||||
|
"enabled": settings.boolean(f"mobile_update.{store}.enabled"),
|
||||||
|
"latest_build": (
|
||||||
|
settings.integer(f"mobile_update.{store}.latest_build")
|
||||||
|
if values[f"mobile_update.{store}.latest_build"]
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"minimum_build": (
|
||||||
|
settings.integer(f"mobile_update.{store}.minimum_build")
|
||||||
|
if values[f"mobile_update.{store}.minimum_build"]
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
"latest_version": values[f"mobile_update.{store}.latest_version"] or None,
|
||||||
|
"store_url": values[f"mobile_update.{store}.store_url"] or None,
|
||||||
|
"release_notes": values[f"mobile_update.{store}.release_notes"] or None,
|
||||||
|
}
|
||||||
|
for store in ("google_play", "rustore", "app_store")
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
validated = PublicAppConfigResponse.model_validate(body)
|
||||||
|
return JSONResponse(validated.model_dump(mode="json"), headers=headers)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/v1/public/content", tags=["public"])
|
@app.get("/api/v1/public/content", tags=["public"])
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
from collections.abc import Mapping
|
||||||
|
from urllib.parse import parse_qs, urlparse
|
||||||
|
|
||||||
|
MOBILE_UPDATE_STORES = ("google_play", "rustore", "app_store")
|
||||||
|
MOBILE_UPDATE_FIELDS = (
|
||||||
|
"enabled",
|
||||||
|
"latest_build",
|
||||||
|
"minimum_build",
|
||||||
|
"latest_version",
|
||||||
|
"store_url",
|
||||||
|
"release_notes",
|
||||||
|
)
|
||||||
|
MOBILE_UPDATE_SETTING_KEYS = {
|
||||||
|
f"mobile_update.{store}.{field}"
|
||||||
|
for store in MOBILE_UPDATE_STORES
|
||||||
|
for field in MOBILE_UPDATE_FIELDS
|
||||||
|
}
|
||||||
|
MOBILE_UPDATE_BOOLEAN_KEYS = {
|
||||||
|
f"mobile_update.{store}.enabled" for store in MOBILE_UPDATE_STORES
|
||||||
|
}
|
||||||
|
MOBILE_UPDATE_INTEGER_KEYS = {
|
||||||
|
f"mobile_update.{store}.{field}"
|
||||||
|
for store in MOBILE_UPDATE_STORES
|
||||||
|
for field in ("latest_build", "minimum_build")
|
||||||
|
}
|
||||||
|
|
||||||
|
ANDROID_PACKAGE = "ru.han.chat"
|
||||||
|
|
||||||
|
|
||||||
|
def validate_mobile_update_settings(values: Mapping[str, str]) -> None:
|
||||||
|
for store in MOBILE_UPDATE_STORES:
|
||||||
|
prefix = f"mobile_update.{store}"
|
||||||
|
required = [f"{prefix}.{field}" for field in MOBILE_UPDATE_FIELDS]
|
||||||
|
present = [key for key in required if key in values]
|
||||||
|
if not present:
|
||||||
|
continue
|
||||||
|
if len(present) != len(required):
|
||||||
|
missing = sorted(set(required) - values.keys())
|
||||||
|
raise ValueError(f"{prefix}: incomplete policy; missing {missing}")
|
||||||
|
|
||||||
|
enabled = _boolean(values[f"{prefix}.enabled"], f"{prefix}.enabled")
|
||||||
|
latest_build = _optional_build(values[f"{prefix}.latest_build"], f"{prefix}.latest_build")
|
||||||
|
minimum_build = _optional_build(
|
||||||
|
values[f"{prefix}.minimum_build"], f"{prefix}.minimum_build"
|
||||||
|
)
|
||||||
|
latest_version = values[f"{prefix}.latest_version"].strip()
|
||||||
|
store_url = values[f"{prefix}.store_url"].strip()
|
||||||
|
release_notes = values[f"{prefix}.release_notes"].strip()
|
||||||
|
if len(release_notes) > 4000:
|
||||||
|
raise ValueError(f"{prefix}.release_notes: value must not exceed 4000 characters")
|
||||||
|
|
||||||
|
if not enabled:
|
||||||
|
if any(
|
||||||
|
value not in (None, "")
|
||||||
|
for value in (
|
||||||
|
latest_build,
|
||||||
|
minimum_build,
|
||||||
|
latest_version,
|
||||||
|
store_url,
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError(f"{prefix}: disabled policy fields must be empty")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if latest_build is None or minimum_build is None:
|
||||||
|
raise ValueError(f"{prefix}: enabled policy requires build thresholds")
|
||||||
|
if minimum_build > latest_build:
|
||||||
|
raise ValueError(f"{prefix}: minimum_build must not exceed latest_build")
|
||||||
|
if not latest_version:
|
||||||
|
raise ValueError(f"{prefix}: enabled policy requires latest_version")
|
||||||
|
_validate_store_url(store, store_url, f"{prefix}.store_url")
|
||||||
|
|
||||||
|
|
||||||
|
def _boolean(raw: str, key: str) -> bool:
|
||||||
|
if raw not in {"true", "false"}:
|
||||||
|
raise ValueError(f"{key}: canonical boolean value expected")
|
||||||
|
return raw == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_build(raw: str, key: str) -> int | None:
|
||||||
|
if raw == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
value = int(raw)
|
||||||
|
except ValueError as error:
|
||||||
|
raise ValueError(f"{key}: integer value expected") from error
|
||||||
|
if str(value) != raw or value < 1:
|
||||||
|
raise ValueError(f"{key}: positive canonical integer value expected")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_store_url(store: str, raw: str, key: str) -> None:
|
||||||
|
parsed = urlparse(raw)
|
||||||
|
if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password:
|
||||||
|
raise ValueError(f"{key}: absolute HTTPS URL expected")
|
||||||
|
if parsed.fragment:
|
||||||
|
raise ValueError(f"{key}: URL fragments are not allowed")
|
||||||
|
|
||||||
|
host = parsed.hostname.lower()
|
||||||
|
if store == "google_play":
|
||||||
|
package = parse_qs(parsed.query).get("id", [])
|
||||||
|
valid = host == "play.google.com" and parsed.path == "/store/apps/details"
|
||||||
|
valid = valid and package == [ANDROID_PACKAGE]
|
||||||
|
elif store == "rustore":
|
||||||
|
valid = host == "www.rustore.ru" and parsed.path == (
|
||||||
|
f"/catalog/app/{ANDROID_PACKAGE}"
|
||||||
|
)
|
||||||
|
valid = valid and not parsed.query
|
||||||
|
else:
|
||||||
|
valid = host == "apps.apple.com" and "/id" in parsed.path
|
||||||
|
if not valid:
|
||||||
|
raise ValueError(f"{key}: URL does not match the {store} application")
|
||||||
@@ -27,6 +27,96 @@ class OtpSettingsResponse(StrictModel):
|
|||||||
cache_ttl_seconds: int = Field(strict=True, gt=0)
|
cache_ttl_seconds: int = Field(strict=True, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicAuthConfig(StrictModel):
|
||||||
|
phone_enabled: bool
|
||||||
|
password_enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
class PublicOperatorConfig(StrictModel):
|
||||||
|
call_phone: str = Field(min_length=1, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicMessagesConfig(StrictModel):
|
||||||
|
max_text_length: int = Field(strict=True, ge=1, le=CHAT_MESSAGE_TRANSPORT_MAX_LENGTH)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicConsentConfig(StrictModel):
|
||||||
|
required: bool
|
||||||
|
document_url: HttpUrl
|
||||||
|
version: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicPersonalDataConsentConfig(PublicConsentConfig):
|
||||||
|
privacy_policy_document_url: HttpUrl
|
||||||
|
|
||||||
|
|
||||||
|
class PublicConsentsConfig(StrictModel):
|
||||||
|
personal_data: PublicPersonalDataConsentConfig
|
||||||
|
user_agreement: PublicConsentConfig
|
||||||
|
marketing: PublicConsentConfig
|
||||||
|
|
||||||
|
|
||||||
|
class PublicAttachmentsConfig(StrictModel):
|
||||||
|
allowed_extensions: list[str]
|
||||||
|
allowed_mime_types: list[str]
|
||||||
|
max_size_mb: int = Field(strict=True, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicNotificationConfig(StrictModel):
|
||||||
|
carousel_autoplay_enabled: bool
|
||||||
|
carousel_autoplay_interval_ms: int = Field(strict=True, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class PublicUxConfig(StrictModel):
|
||||||
|
idle_timeout_minutes: int = Field(strict=True, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class MobileStoreUpdatePolicy(StrictModel):
|
||||||
|
enabled: bool
|
||||||
|
latest_build: int | None = Field(strict=True, ge=1)
|
||||||
|
minimum_build: int | None = Field(strict=True, ge=1)
|
||||||
|
latest_version: str | None = Field(min_length=1, max_length=64)
|
||||||
|
store_url: HttpUrl | None
|
||||||
|
release_notes: str | None = Field(max_length=4000)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def validate_policy(self) -> "MobileStoreUpdatePolicy":
|
||||||
|
release_fields = (
|
||||||
|
self.latest_build,
|
||||||
|
self.minimum_build,
|
||||||
|
self.latest_version,
|
||||||
|
self.store_url,
|
||||||
|
)
|
||||||
|
if self.enabled and any(value is None for value in release_fields):
|
||||||
|
raise ValueError("enabled update policy requires all release fields")
|
||||||
|
if not self.enabled and any(value is not None for value in release_fields):
|
||||||
|
raise ValueError("disabled update policy must not expose release fields")
|
||||||
|
if (
|
||||||
|
self.minimum_build is not None
|
||||||
|
and self.latest_build is not None
|
||||||
|
and self.minimum_build > self.latest_build
|
||||||
|
):
|
||||||
|
raise ValueError("minimum_build must not exceed latest_build")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class MobileUpdatePolicy(StrictModel):
|
||||||
|
google_play: MobileStoreUpdatePolicy
|
||||||
|
rustore: MobileStoreUpdatePolicy
|
||||||
|
app_store: MobileStoreUpdatePolicy
|
||||||
|
|
||||||
|
|
||||||
|
class PublicAppConfigResponse(StrictModel):
|
||||||
|
auth: PublicAuthConfig
|
||||||
|
operator: PublicOperatorConfig
|
||||||
|
messages: PublicMessagesConfig
|
||||||
|
consents: PublicConsentsConfig
|
||||||
|
attachments: PublicAttachmentsConfig
|
||||||
|
notification: PublicNotificationConfig
|
||||||
|
ux: PublicUxConfig
|
||||||
|
mobile_update: MobileUpdatePolicy
|
||||||
|
|
||||||
|
|
||||||
class Device(StrictModel):
|
class Device(StrictModel):
|
||||||
platform: Literal["ios", "android", "web"]
|
platform: Literal["ios", "android", "web"]
|
||||||
app_version: str = Field(min_length=1, max_length=64)
|
app_version: str = Field(min_length=1, max_length=64)
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ from app.integrations import (
|
|||||||
SafetyClient,
|
SafetyClient,
|
||||||
fresh_openlines_payload,
|
fresh_openlines_payload,
|
||||||
)
|
)
|
||||||
|
from app.mobile_update_settings import (
|
||||||
|
MOBILE_UPDATE_BOOLEAN_KEYS,
|
||||||
|
MOBILE_UPDATE_INTEGER_KEYS,
|
||||||
|
MOBILE_UPDATE_SETTING_KEYS,
|
||||||
|
validate_mobile_update_settings,
|
||||||
|
)
|
||||||
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
from app.otp_settings import OTP_SETTING_KEYS, validate_otp_settings
|
||||||
from app.realtime import RealtimeFanout
|
from app.realtime import RealtimeFanout
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
@@ -52,6 +58,7 @@ from app.schemas import (
|
|||||||
encode_cursor,
|
encode_cursor,
|
||||||
)
|
)
|
||||||
from app.settings import Settings
|
from app.settings import Settings
|
||||||
|
from app.telemetry import current_traceparent
|
||||||
|
|
||||||
MESSAGE_SAFETY_REPLIES = {
|
MESSAGE_SAFETY_REPLIES = {
|
||||||
"text": (
|
"text": (
|
||||||
@@ -89,6 +96,7 @@ async def ensure_delivery_outbox(
|
|||||||
external_chat_id: uuid.UUID,
|
external_chat_id: uuid.UUID,
|
||||||
payload_json: dict[str, Any],
|
payload_json: dict[str, Any],
|
||||||
next_attempt_at: datetime,
|
next_attempt_at: datetime,
|
||||||
|
traceparent: str | None = None,
|
||||||
) -> DeliveryOutbox:
|
) -> DeliveryOutbox:
|
||||||
"""Create the per-message outbox row or return the concurrent winner."""
|
"""Create the per-message outbox row or return the concurrent winner."""
|
||||||
statement = (
|
statement = (
|
||||||
@@ -98,6 +106,7 @@ async def ensure_delivery_outbox(
|
|||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
external_chat_id=external_chat_id,
|
external_chat_id=external_chat_id,
|
||||||
payload_json=payload_json,
|
payload_json=payload_json,
|
||||||
|
traceparent=traceparent,
|
||||||
next_attempt_at=next_attempt_at,
|
next_attempt_at=next_attempt_at,
|
||||||
)
|
)
|
||||||
.on_conflict_do_nothing(index_elements=[DeliveryOutbox.message_id])
|
.on_conflict_do_nothing(index_elements=[DeliveryOutbox.message_id])
|
||||||
@@ -117,6 +126,67 @@ async def ensure_delivery_outbox(
|
|||||||
return outbox
|
return outbox
|
||||||
|
|
||||||
|
|
||||||
|
OPENLINES_FALLBACK_DISPLAY_NAME = "Новый клиент HAN"
|
||||||
|
|
||||||
|
|
||||||
|
def openlines_display_name(full_name: str | None) -> str:
|
||||||
|
name = (full_name or "").strip()
|
||||||
|
return name or OPENLINES_FALLBACK_DISPLAY_NAME
|
||||||
|
|
||||||
|
|
||||||
|
def openlines_user_payload(user: UserIdentity, profile: ClientProfile | None) -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
"id": str(user.id),
|
||||||
|
"display_name": openlines_display_name(profile.full_name if profile else None),
|
||||||
|
"phone": user.phone_number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def load_active_client_profile(
|
||||||
|
session: AsyncSession, user_id: uuid.UUID
|
||||||
|
) -> ClientProfile | None:
|
||||||
|
return (
|
||||||
|
await session.execute(
|
||||||
|
select(ClientProfile).where(
|
||||||
|
ClientProfile.user_id == user_id, ClientProfile.record_status == "A"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
def openlines_delivery_payload(
|
||||||
|
*,
|
||||||
|
message: Message,
|
||||||
|
dialog_id: uuid.UUID,
|
||||||
|
user: UserIdentity,
|
||||||
|
profile: ClientProfile | None,
|
||||||
|
attachment: MessageAttachment | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
files: list[dict[str, Any]] = []
|
||||||
|
if attachment:
|
||||||
|
files.append(
|
||||||
|
{
|
||||||
|
"attachment_id": str(attachment.id),
|
||||||
|
"name": attachment.safe_file_name,
|
||||||
|
"mime_type": attachment.mime_type,
|
||||||
|
"size_bytes": attachment.size_bytes,
|
||||||
|
"_storage_bucket": attachment.storage_bucket,
|
||||||
|
"_object_key": attachment.object_key,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"message_id": str(message.id),
|
||||||
|
"external_chat_id": str(dialog_id),
|
||||||
|
"occurred_at": message.occurred_at.isoformat(),
|
||||||
|
"user": openlines_user_payload(user, profile),
|
||||||
|
"message": {
|
||||||
|
"content_kind": message.content_kind,
|
||||||
|
"text": message.text,
|
||||||
|
"files": files,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class DomainError(Exception):
|
class DomainError(Exception):
|
||||||
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
|
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
|
||||||
self.code, self.status, self.message = code, status, message
|
self.code, self.status, self.message = code, status, message
|
||||||
@@ -165,7 +235,7 @@ REQUIRED_SETTINGS = {
|
|||||||
"rate_limit.notification_upload.per_user",
|
"rate_limit.notification_upload.per_user",
|
||||||
"rate_limit.notifications_public.per_ip",
|
"rate_limit.notifications_public.per_ip",
|
||||||
CHAT_MESSAGE_MAX_LENGTH_KEY,
|
CHAT_MESSAGE_MAX_LENGTH_KEY,
|
||||||
} | OTP_SETTING_KEYS
|
} | OTP_SETTING_KEYS | MOBILE_UPDATE_SETTING_KEYS
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
@@ -225,15 +295,33 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
|
|||||||
invalid_metadata = sorted(
|
invalid_metadata = sorted(
|
||||||
row.setting_key
|
row.setting_key
|
||||||
for row in rows
|
for row in rows
|
||||||
if row.setting_key in OTP_SETTING_KEYS
|
if (
|
||||||
and (row.value_type != "integer" or row.is_public)
|
row.setting_key in OTP_SETTING_KEYS
|
||||||
|
and (row.value_type != "integer" or row.is_public)
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
row.setting_key in MOBILE_UPDATE_BOOLEAN_KEYS
|
||||||
|
and (row.value_type != "boolean" or not row.is_public)
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
row.setting_key in MOBILE_UPDATE_INTEGER_KEYS
|
||||||
|
and (row.value_type != "integer" or not row.is_public)
|
||||||
|
)
|
||||||
|
or (
|
||||||
|
row.setting_key
|
||||||
|
in MOBILE_UPDATE_SETTING_KEYS
|
||||||
|
- MOBILE_UPDATE_BOOLEAN_KEYS
|
||||||
|
- MOBILE_UPDATE_INTEGER_KEYS
|
||||||
|
and (row.value_type != "string" or not row.is_public)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
if invalid_metadata:
|
if invalid_metadata:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"OTP settings must have integer type and be private: {invalid_metadata}"
|
f"Settings metadata is invalid: {invalid_metadata}"
|
||||||
)
|
)
|
||||||
validate_otp_settings(values)
|
validate_otp_settings(values)
|
||||||
validate_chat_settings(values)
|
validate_chat_settings(values)
|
||||||
|
validate_mobile_update_settings(values)
|
||||||
except ValueError as error:
|
except ValueError as error:
|
||||||
raise DomainError(
|
raise DomainError(
|
||||||
"dependency_unavailable",
|
"dependency_unavailable",
|
||||||
@@ -954,6 +1042,7 @@ async def send_message(
|
|||||||
message_id=message.id,
|
message_id=message.id,
|
||||||
attachment_id=attachment.id if attachment else None,
|
attachment_id=attachment.id if attachment else None,
|
||||||
quarantine_object_key=attachment.quarantine_object_key if attachment else None,
|
quarantine_object_key=attachment.quarantine_object_key if attachment else None,
|
||||||
|
traceparent=current_traceparent(),
|
||||||
status="polling",
|
status="polling",
|
||||||
processing_mode=verdict["processing_mode"],
|
processing_mode=verdict["processing_mode"],
|
||||||
config_version=verdict["config_version"],
|
config_version=verdict["config_version"],
|
||||||
@@ -1032,38 +1121,23 @@ async def send_message(
|
|||||||
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
|
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
|
||||||
)
|
)
|
||||||
message.safety_status = "allowed"
|
message.safety_status = "allowed"
|
||||||
|
profile = await load_active_client_profile(session, user.id)
|
||||||
outbox = await ensure_delivery_outbox(
|
outbox = await ensure_delivery_outbox(
|
||||||
session,
|
session,
|
||||||
message_id=message.id,
|
message_id=message.id,
|
||||||
external_chat_id=dialog_id,
|
external_chat_id=dialog_id,
|
||||||
payload_json={
|
payload_json=openlines_delivery_payload(
|
||||||
"message_id": str(message.id),
|
message=message,
|
||||||
"external_chat_id": str(dialog_id),
|
dialog_id=dialog_id,
|
||||||
"occurred_at": message.occurred_at.isoformat(),
|
user=user,
|
||||||
"user": {"id": str(user.id), "display_name": user.phone_number},
|
profile=profile,
|
||||||
"message": {
|
attachment=attachment,
|
||||||
"content_kind": message.content_kind,
|
),
|
||||||
"text": message.text,
|
|
||||||
"files": (
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"attachment_id": str(attachment.id),
|
|
||||||
"name": attachment.safe_file_name,
|
|
||||||
"mime_type": attachment.mime_type,
|
|
||||||
"size_bytes": attachment.size_bytes,
|
|
||||||
"_storage_bucket": attachment.storage_bucket,
|
|
||||||
"_object_key": attachment.object_key,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if attachment
|
|
||||||
else []
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
# Keep the row recoverable after a process crash, but do not let the
|
# Keep the row recoverable after a process crash, but do not let the
|
||||||
# delivery worker race the synchronous first attempt.
|
# delivery worker race the synchronous first attempt.
|
||||||
next_attempt_at=datetime.now(UTC)
|
next_attempt_at=datetime.now(UTC)
|
||||||
+ timedelta(seconds=settings.bitrix_local_app_http_timeout_sec + 5),
|
+ timedelta(seconds=settings.bitrix_local_app_http_timeout_sec + 5),
|
||||||
|
traceparent=current_traceparent(),
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await publish_message_status(fanout, message, settings)
|
await publish_message_status(fanout, message, settings)
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import MutableMapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from opentelemetry import metrics, trace
|
from opentelemetry import metrics, trace
|
||||||
|
from opentelemetry._logs import set_logger_provider
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||||
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
|
from opentelemetry.instrumentation.botocore import BotocoreInstrumentor
|
||||||
@@ -13,7 +17,9 @@ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
|||||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||||
from opentelemetry.instrumentation.redis import RedisInstrumentor
|
from opentelemetry.instrumentation.redis import RedisInstrumentor
|
||||||
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
||||||
from opentelemetry.propagate import set_global_textmap
|
from opentelemetry.propagate import extract, inject, set_global_textmap
|
||||||
|
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
||||||
|
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
||||||
from opentelemetry.sdk.metrics import MeterProvider
|
from opentelemetry.sdk.metrics import MeterProvider
|
||||||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||||
from opentelemetry.sdk.resources import Resource
|
from opentelemetry.sdk.resources import Resource
|
||||||
@@ -27,8 +33,11 @@ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapProp
|
|||||||
class TelemetryRuntime:
|
class TelemetryRuntime:
|
||||||
tracer_provider: TracerProvider
|
tracer_provider: TracerProvider
|
||||||
meter_provider: MeterProvider
|
meter_provider: MeterProvider
|
||||||
|
logger_provider: LoggerProvider
|
||||||
|
logging_handler: LoggingHandler
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
|
self.logger_provider.shutdown()
|
||||||
self.meter_provider.shutdown()
|
self.meter_provider.shutdown()
|
||||||
self.tracer_provider.shutdown()
|
self.tracer_provider.shutdown()
|
||||||
|
|
||||||
@@ -79,11 +88,29 @@ def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
|
|||||||
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||||
metrics.set_meter_provider(meter_provider)
|
metrics.set_meter_provider(meter_provider)
|
||||||
|
|
||||||
|
logger_provider = LoggerProvider(resource=resource)
|
||||||
|
logger_provider.add_log_record_processor(
|
||||||
|
BatchLogRecordProcessor(
|
||||||
|
OTLPLogExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||||
|
max_queue_size=2048,
|
||||||
|
schedule_delay_millis=5000,
|
||||||
|
max_export_batch_size=512,
|
||||||
|
export_timeout_millis=3000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
set_logger_provider(logger_provider)
|
||||||
|
logging_handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
|
||||||
|
|
||||||
HTTPXClientInstrumentor().instrument()
|
HTTPXClientInstrumentor().instrument()
|
||||||
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||||
RedisInstrumentor().instrument()
|
RedisInstrumentor().instrument()
|
||||||
BotocoreInstrumentor().instrument()
|
BotocoreInstrumentor().instrument()
|
||||||
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
|
_runtime = TelemetryRuntime(
|
||||||
|
tracer_provider,
|
||||||
|
meter_provider,
|
||||||
|
logger_provider,
|
||||||
|
logging_handler,
|
||||||
|
)
|
||||||
return _runtime
|
return _runtime
|
||||||
|
|
||||||
|
|
||||||
@@ -97,8 +124,8 @@ def instrument_fastapi(app: FastAPI) -> None:
|
|||||||
def add_trace_context(
|
def add_trace_context(
|
||||||
_logger: Any,
|
_logger: Any,
|
||||||
_method_name: str,
|
_method_name: str,
|
||||||
event_dict: dict[str, Any],
|
event_dict: MutableMapping[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> MutableMapping[str, Any]:
|
||||||
context = trace.get_current_span().get_span_context()
|
context = trace.get_current_span().get_span_context()
|
||||||
if context.is_valid:
|
if context.is_valid:
|
||||||
event_dict["trace_id"] = format(context.trace_id, "032x")
|
event_dict["trace_id"] = format(context.trace_id, "032x")
|
||||||
@@ -109,3 +136,17 @@ def add_trace_context(
|
|||||||
def current_trace_id() -> str | None:
|
def current_trace_id() -> str | None:
|
||||||
context = trace.get_current_span().get_span_context()
|
context = trace.get_current_span().get_span_context()
|
||||||
return format(context.trace_id, "032x") if context.is_valid else None
|
return format(context.trace_id, "032x") if context.is_valid else None
|
||||||
|
|
||||||
|
|
||||||
|
def current_traceparent() -> str | None:
|
||||||
|
carrier: dict[str, str] = {}
|
||||||
|
inject(carrier)
|
||||||
|
return carrier.get("traceparent")
|
||||||
|
|
||||||
|
|
||||||
|
def origin_links(traceparent: str | None) -> list[trace.Link]:
|
||||||
|
if not traceparent:
|
||||||
|
return []
|
||||||
|
context = extract({"traceparent": traceparent})
|
||||||
|
span_context = trace.get_current_span(context).get_span_context()
|
||||||
|
return [trace.Link(span_context)] if span_context.is_valid else []
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import redis.asyncio as redis
|
import redis.asyncio as redis
|
||||||
import structlog
|
import structlog
|
||||||
|
from opentelemetry import trace
|
||||||
from sqlalchemy import delete, select
|
from sqlalchemy import delete, select
|
||||||
|
|
||||||
from app.db import (
|
from app.db import (
|
||||||
@@ -25,20 +27,58 @@ from app.integrations import (
|
|||||||
SafetyClient,
|
SafetyClient,
|
||||||
fresh_openlines_payload,
|
fresh_openlines_payload,
|
||||||
)
|
)
|
||||||
|
from app.logging_security import redact_event
|
||||||
from app.notification_models import ClientUploadDraft
|
from app.notification_models import ClientUploadDraft
|
||||||
from app.notification_service import expire_notifications
|
from app.notification_service import expire_notifications
|
||||||
from app.realtime import RealtimeFanout
|
from app.realtime import RealtimeFanout
|
||||||
from app.services import (
|
from app.services import (
|
||||||
ensure_delivery_outbox,
|
ensure_delivery_outbox,
|
||||||
|
load_active_client_profile,
|
||||||
load_settings,
|
load_settings,
|
||||||
|
openlines_delivery_payload,
|
||||||
publish_dialog_status,
|
publish_dialog_status,
|
||||||
publish_message_status,
|
publish_message_status,
|
||||||
)
|
)
|
||||||
from app.settings import Settings, get_settings
|
from app.settings import Settings, get_settings
|
||||||
|
from app.telemetry import TelemetryRuntime, add_trace_context, init_telemetry, origin_links
|
||||||
|
|
||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
|
||||||
|
logging.basicConfig(level=level, format="%(message)s")
|
||||||
|
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
|
||||||
|
telemetry.logging_handler.addFilter(
|
||||||
|
lambda record: not record.name.startswith("opentelemetry")
|
||||||
|
)
|
||||||
|
logging.getLogger().addHandler(telemetry.logging_handler)
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
add_trace_context,
|
||||||
|
redact_event,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_worker(service_name: str, target: Callable[[], Awaitable[None]]) -> None:
|
||||||
|
settings = get_settings()
|
||||||
|
telemetry = init_telemetry(service_name)
|
||||||
|
configure_logging(settings.log_level, telemetry)
|
||||||
|
structlog.contextvars.bind_contextvars(**{"service.name": service_name})
|
||||||
|
try:
|
||||||
|
asyncio.run(target())
|
||||||
|
finally:
|
||||||
|
if telemetry:
|
||||||
|
telemetry.shutdown()
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def worker_http_clients(
|
async def worker_http_clients(
|
||||||
settings: Settings,
|
settings: Settings,
|
||||||
@@ -59,6 +99,7 @@ async def delivery_once(
|
|||||||
worker_id: str,
|
worker_id: str,
|
||||||
batch_size: int = 20,
|
batch_size: int = 20,
|
||||||
) -> int:
|
) -> int:
|
||||||
|
tracer = trace.get_tracer("han.api.delivery-worker")
|
||||||
async with db.sessions() as session:
|
async with db.sessions() as session:
|
||||||
rows = (
|
rows = (
|
||||||
(
|
(
|
||||||
@@ -86,33 +127,43 @@ async def delivery_once(
|
|||||||
row = await session.get(DeliveryOutbox, row_id, with_for_update=True)
|
row = await session.get(DeliveryOutbox, row_id, with_for_update=True)
|
||||||
if row is None:
|
if row is None:
|
||||||
continue
|
continue
|
||||||
message = None
|
with (
|
||||||
dialog = None
|
tracer.start_as_current_span(
|
||||||
try:
|
"delivery.process",
|
||||||
payload = await fresh_openlines_payload(row.payload_json, s3)
|
links=origin_links(row.traceparent),
|
||||||
await client.send(row.message_id, payload, f"worker-{worker_id}")
|
),
|
||||||
row.status = "delivered"
|
structlog.contextvars.bound_contextvars(
|
||||||
message = await session.get(Message, row.message_id)
|
delivery_outbox_id=str(row.id),
|
||||||
|
message_id=str(row.message_id),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
message = None
|
||||||
|
dialog = None
|
||||||
|
try:
|
||||||
|
payload = await fresh_openlines_payload(row.payload_json, s3)
|
||||||
|
await client.send(row.message_id, payload, f"worker-{worker_id}")
|
||||||
|
row.status = "delivered"
|
||||||
|
message = await session.get(Message, row.message_id)
|
||||||
|
if message:
|
||||||
|
message.delivery_status = "delivered"
|
||||||
|
dialog = await session.get(Dialog, message.dialog_id)
|
||||||
|
if dialog:
|
||||||
|
dialog.status = "waiting_for_company"
|
||||||
|
dialog.last_message_at = datetime.now(UTC)
|
||||||
|
except DependencyFailure:
|
||||||
|
row.attempt_count += 1
|
||||||
|
row.status = "dead_letter" if row.attempt_count >= 12 else "retry"
|
||||||
|
row.next_attempt_at = datetime.now(UTC) + timedelta(
|
||||||
|
seconds=min(3600, 2**row.attempt_count)
|
||||||
|
)
|
||||||
|
row.last_error_code = "dependency_unavailable"
|
||||||
|
row.locked_at = None
|
||||||
|
row.locked_by = None
|
||||||
|
await session.commit()
|
||||||
if message:
|
if message:
|
||||||
message.delivery_status = "delivered"
|
await publish_message_status(fanout, message, settings)
|
||||||
dialog = await session.get(Dialog, message.dialog_id)
|
if dialog:
|
||||||
if dialog:
|
await publish_dialog_status(fanout, dialog)
|
||||||
dialog.status = "waiting_for_company"
|
|
||||||
dialog.last_message_at = datetime.now(UTC)
|
|
||||||
except DependencyFailure:
|
|
||||||
row.attempt_count += 1
|
|
||||||
row.status = "dead_letter" if row.attempt_count >= 12 else "retry"
|
|
||||||
row.next_attempt_at = datetime.now(UTC) + timedelta(
|
|
||||||
seconds=min(3600, 2**row.attempt_count)
|
|
||||||
)
|
|
||||||
row.last_error_code = "dependency_unavailable"
|
|
||||||
row.locked_at = None
|
|
||||||
row.locked_by = None
|
|
||||||
await session.commit()
|
|
||||||
if message:
|
|
||||||
await publish_message_status(fanout, message, settings)
|
|
||||||
if dialog:
|
|
||||||
await publish_dialog_status(fanout, dialog)
|
|
||||||
return len(ids)
|
return len(ids)
|
||||||
|
|
||||||
|
|
||||||
@@ -125,6 +176,7 @@ async def safety_once(
|
|||||||
worker_id: str,
|
worker_id: str,
|
||||||
batch_size: int = 20,
|
batch_size: int = 20,
|
||||||
) -> int:
|
) -> int:
|
||||||
|
tracer = trace.get_tracer("han.api.safety-recovery-worker")
|
||||||
async with db.sessions() as session:
|
async with db.sessions() as session:
|
||||||
rows = (
|
rows = (
|
||||||
(
|
(
|
||||||
@@ -153,7 +205,14 @@ async def safety_once(
|
|||||||
continue
|
continue
|
||||||
message = None
|
message = None
|
||||||
try:
|
try:
|
||||||
verdict = await safety.poll(task.poll_location, f"worker-{worker_id}")
|
with tracer.start_as_current_span(
|
||||||
|
"safety.recovery.poll",
|
||||||
|
links=origin_links(task.traceparent),
|
||||||
|
):
|
||||||
|
verdict = await safety.poll(
|
||||||
|
task.poll_location,
|
||||||
|
f"worker-{worker_id}",
|
||||||
|
)
|
||||||
message = await session.get(Message, task.message_id)
|
message = await session.get(Message, task.message_id)
|
||||||
attachment = (
|
attachment = (
|
||||||
await session.get(MessageAttachment, task.attachment_id)
|
await session.get(MessageAttachment, task.attachment_id)
|
||||||
@@ -189,38 +248,20 @@ async def safety_once(
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
if dialog and user:
|
if dialog and user:
|
||||||
|
profile = await load_active_client_profile(session, user.id)
|
||||||
await ensure_delivery_outbox(
|
await ensure_delivery_outbox(
|
||||||
session,
|
session,
|
||||||
message_id=message.id,
|
message_id=message.id,
|
||||||
external_chat_id=dialog.id,
|
external_chat_id=dialog.id,
|
||||||
payload_json={
|
payload_json=openlines_delivery_payload(
|
||||||
"message_id": str(message.id),
|
message=message,
|
||||||
"external_chat_id": str(dialog.id),
|
dialog_id=dialog.id,
|
||||||
"occurred_at": message.occurred_at.isoformat(),
|
user=user,
|
||||||
"user": {
|
profile=profile,
|
||||||
"id": str(user.id),
|
attachment=attachment,
|
||||||
"display_name": user.phone_number,
|
),
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"content_kind": message.content_kind,
|
|
||||||
"text": message.text,
|
|
||||||
"files": (
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"attachment_id": str(attachment.id),
|
|
||||||
"name": attachment.safe_file_name,
|
|
||||||
"mime_type": attachment.mime_type,
|
|
||||||
"size_bytes": attachment.size_bytes,
|
|
||||||
"_storage_bucket": attachment.storage_bucket,
|
|
||||||
"_object_key": attachment.object_key,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
if attachment
|
|
||||||
else []
|
|
||||||
),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
next_attempt_at=datetime.now(UTC),
|
next_attempt_at=datetime.now(UTC),
|
||||||
|
traceparent=task.traceparent,
|
||||||
)
|
)
|
||||||
elif verdict["_status"] == 403 and message:
|
elif verdict["_status"] == 403 and message:
|
||||||
message.safety_processing_mode = verdict["processing_mode"]
|
message.safety_processing_mode = verdict["processing_mode"]
|
||||||
@@ -382,20 +423,20 @@ async def notification_draft_cleanup_loop() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def delivery_main() -> None:
|
def delivery_main() -> None:
|
||||||
asyncio.run(loop("delivery"))
|
run_worker("delivery-worker", lambda: loop("delivery"))
|
||||||
|
|
||||||
|
|
||||||
def safety_main() -> None:
|
def safety_main() -> None:
|
||||||
asyncio.run(loop("safety"))
|
run_worker("safety-recovery-worker", lambda: loop("safety"))
|
||||||
|
|
||||||
|
|
||||||
def cleanup_main() -> None:
|
def cleanup_main() -> None:
|
||||||
asyncio.run(loop("cleanup"))
|
run_worker("cleanup-worker", lambda: loop("cleanup"))
|
||||||
|
|
||||||
|
|
||||||
def notification_expire_main() -> None:
|
def notification_expire_main() -> None:
|
||||||
asyncio.run(notification_expire_loop())
|
run_worker("notification-expire-worker", notification_expire_loop)
|
||||||
|
|
||||||
|
|
||||||
def notification_draft_cleanup_main() -> None:
|
def notification_draft_cleanup_main() -> None:
|
||||||
asyncio.run(notification_draft_cleanup_loop())
|
run_worker("notification-draft-cleanup-worker", notification_draft_cleanup_loop)
|
||||||
|
|||||||
@@ -19,20 +19,15 @@ paths:
|
|||||||
/api/v1/public/app-config:
|
/api/v1/public/app-config:
|
||||||
get:
|
get:
|
||||||
operationId: getPublicAppConfig
|
operationId: getPublicAppConfig
|
||||||
|
parameters:
|
||||||
|
- {name: If-None-Match, in: header, schema: {type: string}}
|
||||||
responses:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: Public application configuration
|
description: Public application configuration
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema: {$ref: "#/components/schemas/PublicAppConfigResponse"}
|
||||||
type: object
|
"304": {description: Cached configuration is still current}
|
||||||
required: [messages]
|
|
||||||
properties:
|
|
||||||
messages:
|
|
||||||
type: object
|
|
||||||
required: [max_text_length]
|
|
||||||
properties:
|
|
||||||
max_text_length: {type: integer, minimum: 1, maximum: 10000}
|
|
||||||
/api/v1/public/content:
|
/api/v1/public/content:
|
||||||
get:
|
get:
|
||||||
operationId: getPublicContent
|
operationId: getPublicContent
|
||||||
@@ -405,6 +400,105 @@ components:
|
|||||||
description: Catalog action is not allowed
|
description: Catalog action is not allowed
|
||||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||||
schemas:
|
schemas:
|
||||||
|
PublicAppConfigResponse:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [auth, operator, messages, consents, attachments, notification, ux, mobile_update]
|
||||||
|
properties:
|
||||||
|
auth: {$ref: "#/components/schemas/PublicAuthConfig"}
|
||||||
|
operator: {$ref: "#/components/schemas/PublicOperatorConfig"}
|
||||||
|
messages: {$ref: "#/components/schemas/PublicMessagesConfig"}
|
||||||
|
consents: {$ref: "#/components/schemas/PublicConsentsConfig"}
|
||||||
|
attachments: {$ref: "#/components/schemas/PublicAttachmentsConfig"}
|
||||||
|
notification: {$ref: "#/components/schemas/PublicNotificationConfig"}
|
||||||
|
ux: {$ref: "#/components/schemas/PublicUxConfig"}
|
||||||
|
mobile_update: {$ref: "#/components/schemas/MobileUpdatePolicy"}
|
||||||
|
PublicAuthConfig:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [phone_enabled, password_enabled]
|
||||||
|
properties:
|
||||||
|
phone_enabled: {type: boolean}
|
||||||
|
password_enabled: {type: boolean}
|
||||||
|
PublicOperatorConfig:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [call_phone]
|
||||||
|
properties:
|
||||||
|
call_phone: {type: string, minLength: 1, maxLength: 32}
|
||||||
|
PublicMessagesConfig:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [max_text_length]
|
||||||
|
properties:
|
||||||
|
max_text_length: {type: integer, minimum: 1, maximum: 10000}
|
||||||
|
PublicConsentConfig:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [required, document_url, version]
|
||||||
|
properties:
|
||||||
|
required: {type: boolean}
|
||||||
|
document_url: {type: string, format: uri, minLength: 1}
|
||||||
|
version: {type: string, minLength: 1, maxLength: 64}
|
||||||
|
PublicPersonalDataConsentConfig:
|
||||||
|
allOf:
|
||||||
|
- {$ref: "#/components/schemas/PublicConsentConfig"}
|
||||||
|
- type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [required, document_url, version, privacy_policy_document_url]
|
||||||
|
properties:
|
||||||
|
required: {type: boolean}
|
||||||
|
document_url: {type: string, format: uri, minLength: 1}
|
||||||
|
version: {type: string, minLength: 1, maxLength: 64}
|
||||||
|
privacy_policy_document_url: {type: string, format: uri, minLength: 1}
|
||||||
|
PublicConsentsConfig:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [personal_data, user_agreement, marketing]
|
||||||
|
properties:
|
||||||
|
personal_data: {$ref: "#/components/schemas/PublicPersonalDataConsentConfig"}
|
||||||
|
user_agreement: {$ref: "#/components/schemas/PublicConsentConfig"}
|
||||||
|
marketing: {$ref: "#/components/schemas/PublicConsentConfig"}
|
||||||
|
PublicAttachmentsConfig:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [allowed_extensions, allowed_mime_types, max_size_mb]
|
||||||
|
properties:
|
||||||
|
allowed_extensions: {type: array, items: {type: string}}
|
||||||
|
allowed_mime_types: {type: array, items: {type: string}}
|
||||||
|
max_size_mb: {type: integer, minimum: 1}
|
||||||
|
PublicNotificationConfig:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [carousel_autoplay_enabled, carousel_autoplay_interval_ms]
|
||||||
|
properties:
|
||||||
|
carousel_autoplay_enabled: {type: boolean}
|
||||||
|
carousel_autoplay_interval_ms: {type: integer, minimum: 1}
|
||||||
|
PublicUxConfig:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [idle_timeout_minutes]
|
||||||
|
properties:
|
||||||
|
idle_timeout_minutes: {type: integer, minimum: 1}
|
||||||
|
MobileStoreUpdatePolicy:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [enabled, latest_build, minimum_build, latest_version, store_url, release_notes]
|
||||||
|
properties:
|
||||||
|
enabled: {type: boolean}
|
||||||
|
latest_build: {type: [integer, "null"], minimum: 1}
|
||||||
|
minimum_build: {type: [integer, "null"], minimum: 1}
|
||||||
|
latest_version: {type: [string, "null"], minLength: 1, maxLength: 64}
|
||||||
|
store_url: {type: [string, "null"], format: uri, minLength: 1}
|
||||||
|
release_notes: {type: [string, "null"], maxLength: 4000}
|
||||||
|
MobileUpdatePolicy:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [google_play, rustore, app_store]
|
||||||
|
properties:
|
||||||
|
google_play: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"}
|
||||||
|
rustore: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"}
|
||||||
|
app_store: {$ref: "#/components/schemas/MobileStoreUpdatePolicy"}
|
||||||
OtpSettingsResponse:
|
OtpSettingsResponse:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@@ -304,7 +304,11 @@ async def test_openlines_payload_gets_fresh_download_url_without_storage_fields(
|
|||||||
"message_id": str(uuid.uuid4()),
|
"message_id": str(uuid.uuid4()),
|
||||||
"external_chat_id": str(uuid.uuid4()),
|
"external_chat_id": str(uuid.uuid4()),
|
||||||
"occurred_at": "2026-07-10T12:00:00+00:00",
|
"occurred_at": "2026-07-10T12:00:00+00:00",
|
||||||
"user": {"id": str(uuid.uuid4()), "display_name": "+79990000000"},
|
"user": {
|
||||||
|
"id": str(uuid.uuid4()),
|
||||||
|
"display_name": "Новый клиент HAN",
|
||||||
|
"phone": "+79990000000",
|
||||||
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"content_kind": "file",
|
"content_kind": "file",
|
||||||
"text": "",
|
"text": "",
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from pydantic import SecretStr
|
|||||||
from app.main import (
|
from app.main import (
|
||||||
EXPECTED_API_DB_REVISION,
|
EXPECTED_API_DB_REVISION,
|
||||||
app,
|
app,
|
||||||
|
app_config,
|
||||||
otp_settings,
|
otp_settings,
|
||||||
refresh_jwks_cache,
|
refresh_jwks_cache,
|
||||||
websocket_token,
|
websocket_token,
|
||||||
@@ -152,13 +153,116 @@ def test_committed_openapi_server_does_not_double_api_prefix() -> None:
|
|||||||
assert committed["servers"] == [{"url": "/"}]
|
assert committed["servers"] == [{"url": "/"}]
|
||||||
|
|
||||||
|
|
||||||
def test_public_config_contract_exposes_message_length() -> None:
|
def test_public_config_contract_is_strict_and_exposes_mobile_update() -> None:
|
||||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
generated = app.openapi()
|
||||||
response = committed["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
|
response = generated["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
|
||||||
messages = response["content"]["application/json"]["schema"]["properties"]["messages"]
|
schema_ref = response["content"]["application/json"]["schema"]["$ref"]
|
||||||
|
schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]]
|
||||||
|
|
||||||
assert messages["required"] == ["max_text_length"]
|
assert schema["additionalProperties"] is False
|
||||||
assert messages["properties"]["max_text_length"]["maximum"] == 10_000
|
assert set(schema["required"]) == {
|
||||||
|
"auth",
|
||||||
|
"operator",
|
||||||
|
"messages",
|
||||||
|
"consents",
|
||||||
|
"attachments",
|
||||||
|
"notification",
|
||||||
|
"ux",
|
||||||
|
"mobile_update",
|
||||||
|
}
|
||||||
|
mobile_ref = schema["properties"]["mobile_update"]["$ref"]
|
||||||
|
mobile = generated["components"]["schemas"][mobile_ref.rsplit("/", 1)[-1]]
|
||||||
|
assert mobile["additionalProperties"] is False
|
||||||
|
assert set(mobile["required"]) == {"google_play", "rustore", "app_store"}
|
||||||
|
store_ref = mobile["properties"]["rustore"]["$ref"]
|
||||||
|
store = generated["components"]["schemas"][store_ref.rsplit("/", 1)[-1]]
|
||||||
|
assert "release_notes" in store["required"]
|
||||||
|
release_notes = store["properties"]["release_notes"]
|
||||||
|
assert {"type": "string", "maxLength": 4000} in release_notes["anyOf"]
|
||||||
|
assert {"type": "null"} in release_notes["anyOf"]
|
||||||
|
assert "304" in generated["paths"]["/api/v1/public/app-config"]["get"]["responses"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_public_config_returns_mobile_policy_and_supports_etag(monkeypatch) -> None:
|
||||||
|
values = {
|
||||||
|
"rate_limit.public_endpoints.per_ip": "60/minute",
|
||||||
|
"security.public_cache.max_age_seconds": "60",
|
||||||
|
"auth.phone.enabled": "true",
|
||||||
|
"auth.password.enabled": "false",
|
||||||
|
"operator.call.phone": "+74999591007",
|
||||||
|
"chat.message.max_length": "4000",
|
||||||
|
"consent.personal_data.required": "true",
|
||||||
|
"consent.personal_data.document_url": "https://example.ru/personal",
|
||||||
|
"consent.privacy_policy.document_url": "https://example.ru/privacy",
|
||||||
|
"consent.personal_data.version": "2026-06-10",
|
||||||
|
"consent.user_agreement.required": "true",
|
||||||
|
"consent.user_agreement.document_url": "https://example.ru/agreement",
|
||||||
|
"consent.user_agreement.version": "2026-06-10",
|
||||||
|
"consent.marketing.required": "false",
|
||||||
|
"consent.marketing.document_url": "https://example.ru/marketing",
|
||||||
|
"consent.marketing.version": "2026-06-10",
|
||||||
|
"chat.attachments.allowed_extensions": "jpg,pdf",
|
||||||
|
"chat.attachments.allowed_mime_types": "image/jpeg,application/pdf",
|
||||||
|
"chat.attachments.max_size_mb": "5",
|
||||||
|
"notification.carousel.autoplay_enabled": "false",
|
||||||
|
"notification.carousel.autoplay_interval_ms": "5000",
|
||||||
|
"ux.session.idle_timeout_minutes": "30",
|
||||||
|
"mobile_update.google_play.enabled": "true",
|
||||||
|
"mobile_update.google_play.latest_build": "2",
|
||||||
|
"mobile_update.google_play.minimum_build": "1",
|
||||||
|
"mobile_update.google_play.latest_version": "1.0.1",
|
||||||
|
"mobile_update.google_play.store_url": (
|
||||||
|
"https://play.google.com/store/apps/details?id=ru.han.chat"
|
||||||
|
),
|
||||||
|
"mobile_update.google_play.release_notes": "",
|
||||||
|
"mobile_update.rustore.enabled": "true",
|
||||||
|
"mobile_update.rustore.latest_build": "2",
|
||||||
|
"mobile_update.rustore.minimum_build": "1",
|
||||||
|
"mobile_update.rustore.latest_version": "1.0.1",
|
||||||
|
"mobile_update.rustore.store_url": (
|
||||||
|
"https://www.rustore.ru/catalog/app/ru.han.chat"
|
||||||
|
),
|
||||||
|
"mobile_update.rustore.release_notes": "",
|
||||||
|
"mobile_update.app_store.enabled": "false",
|
||||||
|
"mobile_update.app_store.latest_build": "",
|
||||||
|
"mobile_update.app_store.minimum_build": "",
|
||||||
|
"mobile_update.app_store.latest_version": "",
|
||||||
|
"mobile_update.app_store.store_url": "",
|
||||||
|
"mobile_update.app_store.release_notes": "",
|
||||||
|
}
|
||||||
|
settings = SettingsSnapshot(values, "settings-version")
|
||||||
|
request = SimpleNamespace(
|
||||||
|
headers={},
|
||||||
|
client=None,
|
||||||
|
app=SimpleNamespace(state=SimpleNamespace()),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def no_limit(*args, **kwargs) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr("app.main.enforce_limit", no_limit)
|
||||||
|
|
||||||
|
response = await app_config(request, settings)
|
||||||
|
body = json.loads(response.body)
|
||||||
|
assert response.headers["etag"] == '"settings-version"'
|
||||||
|
assert response.headers["cache-control"] == "public, max-age=60"
|
||||||
|
assert body["mobile_update"]["google_play"]["latest_build"] == 2
|
||||||
|
assert body["mobile_update"]["google_play"]["release_notes"] is None
|
||||||
|
assert body["mobile_update"]["rustore"]["store_url"] == (
|
||||||
|
"https://www.rustore.ru/catalog/app/ru.han.chat"
|
||||||
|
)
|
||||||
|
assert body["mobile_update"]["app_store"] == {
|
||||||
|
"enabled": False,
|
||||||
|
"latest_build": None,
|
||||||
|
"minimum_build": None,
|
||||||
|
"latest_version": None,
|
||||||
|
"store_url": None,
|
||||||
|
"release_notes": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
cached = await app_config(request, settings, 'W/"settings-version", "old"')
|
||||||
|
assert cached.status_code == 304
|
||||||
|
assert cached.headers["etag"] == '"settings-version"'
|
||||||
|
|
||||||
|
|
||||||
def test_otp_settings_contract_is_strict_and_complete() -> None:
|
def test_otp_settings_contract_is_strict_and_complete() -> None:
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ def test_production_like_seed_contains_all_mandatory_settings() -> None:
|
|||||||
assert values["otp.phone.ttl_seconds"] == "60"
|
assert values["otp.phone.ttl_seconds"] == "60"
|
||||||
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
|
assert values["otp.phone.sms_order_timeout_ms"] == "3000"
|
||||||
assert values["chat.message.max_length"] == "4000"
|
assert values["chat.message.max_length"] == "4000"
|
||||||
|
assert values["mobile_update.google_play.latest_build"] == "2"
|
||||||
|
assert values["mobile_update.google_play.minimum_build"] == "1"
|
||||||
|
assert values["mobile_update.google_play.latest_version"] == "1.0.1"
|
||||||
|
assert values["mobile_update.rustore.latest_build"] == "2"
|
||||||
|
assert values["mobile_update.rustore.store_url"] == (
|
||||||
|
"https://www.rustore.ru/catalog/app/ru.han.chat"
|
||||||
|
)
|
||||||
|
assert values["mobile_update.rustore.release_notes"] == ""
|
||||||
|
assert values["mobile_update.app_store.enabled"] == "false"
|
||||||
|
assert values["mobile_update.app_store.latest_build"] == ""
|
||||||
|
assert values["security.public_cache.max_age_seconds"] == "60"
|
||||||
|
|
||||||
|
|
||||||
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
|
def test_seed_rejects_invalid_typed_value(tmp_path: Path) -> None:
|
||||||
@@ -76,3 +87,98 @@ def test_seed_rejects_invalid_chat_message_max_length(tmp_path: Path, value: int
|
|||||||
|
|
||||||
with pytest.raises(ValueError, match="value must be between 1 and 10000"):
|
with pytest.raises(ValueError, match="value must be between 1 and 10000"):
|
||||||
load_seed(path)
|
load_seed(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _mobile_policy_yaml(
|
||||||
|
*,
|
||||||
|
store: str = "google_play",
|
||||||
|
enabled: bool = True,
|
||||||
|
latest_build: int = 2,
|
||||||
|
minimum_build: int = 1,
|
||||||
|
store_url: str = "https://play.google.com/store/apps/details?id=ru.han.chat",
|
||||||
|
) -> str:
|
||||||
|
enabled_yaml = str(enabled).lower()
|
||||||
|
return (
|
||||||
|
"schema_version: 1\nsettings:\n"
|
||||||
|
f" mobile_update.{store}.enabled: "
|
||||||
|
f"{{type: boolean, value: {enabled_yaml}, public: true}}\n"
|
||||||
|
f" mobile_update.{store}.latest_build: "
|
||||||
|
f"{{type: integer, value: {latest_build}, public: true}}\n"
|
||||||
|
f" mobile_update.{store}.minimum_build: "
|
||||||
|
f"{{type: integer, value: {minimum_build}, public: true}}\n"
|
||||||
|
f' mobile_update.{store}.latest_version: '
|
||||||
|
'{type: string, value: "1.0.1", public: true}\n'
|
||||||
|
f' mobile_update.{store}.store_url: '
|
||||||
|
f'{{type: string, value: "{store_url}", public: true}}\n'
|
||||||
|
f' mobile_update.{store}.release_notes: '
|
||||||
|
'{type: string, value: "", public: true}\n'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_rejects_inverted_mobile_build_thresholds(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "settings.yaml"
|
||||||
|
path.write_text(
|
||||||
|
_mobile_policy_yaml(latest_build=1, minimum_build=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="minimum_build must not exceed latest_build"):
|
||||||
|
load_seed(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_rejects_wrong_mobile_store_url(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "settings.yaml"
|
||||||
|
path.write_text(
|
||||||
|
_mobile_policy_yaml(
|
||||||
|
store_url="https://play.google.com/store/apps/details?id=other.package"
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="does not match the google_play application"):
|
||||||
|
load_seed(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_rejects_non_https_mobile_store_url(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "settings.yaml"
|
||||||
|
path.write_text(
|
||||||
|
_mobile_policy_yaml(
|
||||||
|
store_url="http://play.google.com/store/apps/details?id=ru.han.chat"
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="absolute HTTPS URL expected"):
|
||||||
|
load_seed(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_accepts_canonical_rustore_url(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "settings.yaml"
|
||||||
|
path.write_text(
|
||||||
|
_mobile_policy_yaml(
|
||||||
|
store="rustore",
|
||||||
|
store_url="https://www.rustore.ru/catalog/app/ru.han.chat",
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = load_seed(path)
|
||||||
|
assert any(
|
||||||
|
row["setting_key"] == "mobile_update.rustore.store_url"
|
||||||
|
and row["setting_value"] == "https://www.rustore.ru/catalog/app/ru.han.chat"
|
||||||
|
for row in rows
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_seed_rejects_legacy_rustore_url(tmp_path: Path) -> None:
|
||||||
|
path = tmp_path / "settings.yaml"
|
||||||
|
path.write_text(
|
||||||
|
_mobile_policy_yaml(
|
||||||
|
store="rustore",
|
||||||
|
store_url="https://apps.rustore.ru/app/ru.han.chat",
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="does not match the rustore application"):
|
||||||
|
load_seed(path)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from app.schemas import (
|
|||||||
from app.services import (
|
from app.services import (
|
||||||
MESSAGE_SAFETY_REPLIES,
|
MESSAGE_SAFETY_REPLIES,
|
||||||
ensure_delivery_outbox,
|
ensure_delivery_outbox,
|
||||||
|
openlines_user_payload,
|
||||||
safety_reply_message,
|
safety_reply_message,
|
||||||
safety_task_recovery_at,
|
safety_task_recovery_at,
|
||||||
)
|
)
|
||||||
@@ -85,6 +86,20 @@ def test_safety_recovery_starts_after_synchronous_polling_window() -> None:
|
|||||||
assert (safety_task_recovery_at(now, settings) - now).total_seconds() == 307
|
assert (safety_task_recovery_at(now, settings) - now).total_seconds() == 307
|
||||||
|
|
||||||
|
|
||||||
|
def test_openlines_user_payload_uses_profile_name_and_phone() -> None:
|
||||||
|
user = SimpleNamespace(id=uuid.uuid4(), phone_number="+79991234567")
|
||||||
|
named = openlines_user_payload(user, SimpleNamespace(full_name="Иван Иванов"))
|
||||||
|
assert named == {
|
||||||
|
"id": str(user.id),
|
||||||
|
"display_name": "Иван Иванов",
|
||||||
|
"phone": "+79991234567",
|
||||||
|
}
|
||||||
|
assert openlines_user_payload(user, SimpleNamespace(full_name=" "))["display_name"] == (
|
||||||
|
"Новый клиент HAN"
|
||||||
|
)
|
||||||
|
assert openlines_user_payload(user, None)["display_name"] == "Новый клиент HAN"
|
||||||
|
|
||||||
|
|
||||||
async def test_delivery_outbox_returns_concurrent_insert_winner() -> None:
|
async def test_delivery_outbox_returns_concurrent_insert_winner() -> None:
|
||||||
existing = object()
|
existing = object()
|
||||||
session = SimpleNamespace(
|
session = SimpleNamespace(
|
||||||
|
|||||||
@@ -23,3 +23,19 @@ def test_structlog_processor_adds_active_trace_context_only() -> None:
|
|||||||
assert len(result["trace_id"]) == 32
|
assert len(result["trace_id"]) == 32
|
||||||
assert len(result["span_id"]) == 16
|
assert len(result["span_id"]) == 16
|
||||||
assert set(result) == {"event", "request_id", "trace_id", "span_id"}
|
assert set(result) == {"event", "request_id", "trace_id", "span_id"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_active_span_does_not_invent_trace_context() -> None:
|
||||||
|
result = telemetry.add_trace_context(None, "info", {"event": "safe"})
|
||||||
|
assert result == {"event": "safe"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_async_origin_creates_link_without_parenting_worker_span() -> None:
|
||||||
|
tracer = TracerProvider().get_tracer("test")
|
||||||
|
with tracer.start_as_current_span("request"):
|
||||||
|
traceparent = telemetry.current_traceparent()
|
||||||
|
origin_trace_id = telemetry.current_trace_id()
|
||||||
|
|
||||||
|
links = telemetry.origin_links(traceparent)
|
||||||
|
assert len(links) == 1
|
||||||
|
assert format(links[0].context.trace_id, "032x") == origin_trace_id
|
||||||
|
|||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
"""Persist request and W3C trace context for inbox forwarding.
|
||||||
|
|
||||||
|
Revision ID: 0002_inbox_trace_context
|
||||||
|
Revises: 0001_bitrix_local_schema
|
||||||
|
Create Date: 2026-09-03
|
||||||
|
"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0002_inbox_trace_context"
|
||||||
|
down_revision: str | None = "0001_bitrix_local"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE bitrix_local.inbox_events
|
||||||
|
ADD COLUMN IF NOT EXISTS request_id varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS traceparent varchar(55)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE bitrix_local.outbound_messages
|
||||||
|
ADD COLUMN IF NOT EXISTS request_id varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS traceparent varchar(55)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE bitrix_local.delivery_ack_outbox
|
||||||
|
ADD COLUMN IF NOT EXISTS request_id varchar(64),
|
||||||
|
ADD COLUMN IF NOT EXISTS traceparent varchar(55)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE bitrix_local.inbox_events
|
||||||
|
DROP COLUMN IF EXISTS traceparent,
|
||||||
|
DROP COLUMN IF EXISTS request_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE bitrix_local.outbound_messages
|
||||||
|
DROP COLUMN IF EXISTS traceparent,
|
||||||
|
DROP COLUMN IF EXISTS request_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
ALTER TABLE bitrix_local.delivery_ack_outbox
|
||||||
|
DROP COLUMN IF EXISTS traceparent,
|
||||||
|
DROP COLUMN IF EXISTS request_id
|
||||||
|
"""
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections.abc import Mapping, MutableMapping
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
REDACTED = "[REDACTED]"
|
||||||
|
_SENSITIVE_KEY = re.compile(
|
||||||
|
r"(authorization|cookie|password|passwd|secret|token|api[_-]?key|"
|
||||||
|
r"database[_-]?url|dsn|callback[_-]?url|phone|email|message|payload)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_URI_USERINFO = re.compile(r"(?P<scheme>[a-z][a-z0-9+.-]*://)[^/@\s]+@", re.IGNORECASE)
|
||||||
|
_QUERY_SECRET = re.compile(
|
||||||
|
r"(?P<prefix>[?&](?:token|access_token|api_key|key|secret|password)=)[^&#\s]+",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_AUTH_VALUE = re.compile(r"\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_text(value: str) -> str:
|
||||||
|
value = _URI_USERINFO.sub(r"\g<scheme>[REDACTED]@", value)
|
||||||
|
value = _QUERY_SECRET.sub(r"\g<prefix>[REDACTED]", value)
|
||||||
|
return _AUTH_VALUE.sub(r"\1 [REDACTED]", value)
|
||||||
|
|
||||||
|
|
||||||
|
def sanitize_value(value: Any) -> Any:
|
||||||
|
if isinstance(value, str):
|
||||||
|
return sanitize_text(value)
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {
|
||||||
|
str(key): REDACTED if _SENSITIVE_KEY.search(str(key)) else sanitize_value(item)
|
||||||
|
for key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [sanitize_value(item) for item in value]
|
||||||
|
if isinstance(value, tuple):
|
||||||
|
return tuple(sanitize_value(item) for item in value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def redact_event(
|
||||||
|
_logger: Any,
|
||||||
|
_method_name: str,
|
||||||
|
event_dict: MutableMapping[str, Any],
|
||||||
|
) -> MutableMapping[str, Any]:
|
||||||
|
return sanitize_value(event_dict)
|
||||||
@@ -16,11 +16,13 @@ from typing import Annotated, Any, Literal
|
|||||||
from urllib.parse import quote, urlparse
|
from urllib.parse import quote, urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import structlog
|
||||||
import uvicorn
|
import uvicorn
|
||||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from opentelemetry import trace
|
||||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from sqlalchemy import func, or_, select, text
|
from sqlalchemy import func, or_, select, text
|
||||||
@@ -37,9 +39,19 @@ from app.models import (
|
|||||||
PortalInstallation,
|
PortalInstallation,
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
|
from app.logging_security import redact_event
|
||||||
from app.postgres import create_postgres_engine
|
from app.postgres import create_postgres_engine
|
||||||
|
from app.telemetry import (
|
||||||
|
TelemetryRuntime,
|
||||||
|
add_trace_context,
|
||||||
|
current_traceparent,
|
||||||
|
init_telemetry,
|
||||||
|
instrument_fastapi,
|
||||||
|
origin_links,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("bitrix-local-app")
|
logger = structlog.get_logger("bitrix-local-app")
|
||||||
|
EXPECTED_BITRIX_DB_REVISION = "0002_inbox_trace_context"
|
||||||
|
|
||||||
BITRIX_SENDER_PREFIX = re.compile(
|
BITRIX_SENDER_PREFIX = re.compile(
|
||||||
r"^\[b\][^\r\n\[]+:\[/b\]\s*(?:\[br\]\s*)?",
|
r"^\[b\][^\r\n\[]+:\[/b\]\s*(?:\[br\]\s*)?",
|
||||||
@@ -57,6 +69,7 @@ CONNECTOR_ICON_DATA_URI = "data:image/svg+xml," + quote(
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(extra="ignore")
|
model_config = SettingsConfigDict(extra="ignore")
|
||||||
app_env: str = "production-like"
|
app_env: str = "production-like"
|
||||||
|
log_level: str = "INFO"
|
||||||
bitrix_database_url: str
|
bitrix_database_url: str
|
||||||
bitrix_client_id: str
|
bitrix_client_id: str
|
||||||
bitrix_client_secret: str
|
bitrix_client_secret: str
|
||||||
@@ -78,6 +91,28 @@ class Settings(BaseSettings):
|
|||||||
bitrix_worker_poll_sec: float = Field(default=1, ge=0.05, le=30)
|
bitrix_worker_poll_sec: float = Field(default=1, ge=0.05, le=30)
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
|
||||||
|
logging.basicConfig(level=level, format="%(message)s")
|
||||||
|
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
|
||||||
|
telemetry.logging_handler.addFilter(
|
||||||
|
lambda record: not record.name.startswith("opentelemetry")
|
||||||
|
)
|
||||||
|
logging.getLogger().addHandler(telemetry.logging_handler)
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
add_trace_context,
|
||||||
|
redact_event,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TokenCipher:
|
class TokenCipher:
|
||||||
def __init__(self, encoded_key: str, version: str) -> None:
|
def __init__(self, encoded_key: str, version: str) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -114,6 +149,7 @@ class UserDto(BaseModel):
|
|||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
display_name: str = Field(min_length=1, max_length=255)
|
display_name: str = Field(min_length=1, max_length=255)
|
||||||
|
phone: str | None = Field(default=None, min_length=1, max_length=32)
|
||||||
|
|
||||||
|
|
||||||
class FileDto(BaseModel):
|
class FileDto(BaseModel):
|
||||||
@@ -308,8 +344,8 @@ def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
normalized_file["_bitrix_file_id"] = str(file_id)
|
normalized_file["_bitrix_file_id"] = str(file_id)
|
||||||
if not normalized_file["download_url"] and not file_id:
|
if not normalized_file["download_url"] and not file_id:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"inbound file has no supported download reference; keys=%s",
|
"inbound_file.unsupported_reference",
|
||||||
sorted(str(key) for key in item),
|
keys=sorted(str(key) for key in item),
|
||||||
)
|
)
|
||||||
files.append(normalized_file)
|
files.append(normalized_file)
|
||||||
if not text_value and not files:
|
if not text_value and not files:
|
||||||
@@ -497,6 +533,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
|
telemetry = init_telemetry()
|
||||||
|
configure_logging(cfg.log_level, telemetry)
|
||||||
engine = create_postgres_engine(
|
engine = create_postgres_engine(
|
||||||
cfg.bitrix_database_url,
|
cfg.bitrix_database_url,
|
||||||
pool_size=5,
|
pool_size=5,
|
||||||
@@ -526,6 +564,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
await task
|
await task
|
||||||
await app.state.http.aclose()
|
await app.state.http.aclose()
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
if telemetry:
|
||||||
|
telemetry.shutdown()
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="HAN Bitrix24 Local App",
|
title="HAN Bitrix24 Local App",
|
||||||
@@ -539,9 +579,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
async def request_context(request: Request, call_next):
|
async def request_context(request: Request, call_next):
|
||||||
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||||
response = await call_next(request)
|
with structlog.contextvars.bound_contextvars(
|
||||||
response.headers["X-Request-ID"] = request.state.request_id
|
request_id=request.state.request_id,
|
||||||
return response
|
**{"service.name": "bitrix-local-app"},
|
||||||
|
):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = request.state.request_id
|
||||||
|
return response
|
||||||
|
|
||||||
@app.exception_handler(HTTPException)
|
@app.exception_handler(HTTPException)
|
||||||
async def http_error(_: Request, exc: HTTPException):
|
async def http_error(_: Request, exc: HTTPException):
|
||||||
@@ -578,9 +622,22 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
async with request.app.state.sessions() as session:
|
async with request.app.state.sessions() as session:
|
||||||
portal = await active_portal(session)
|
portal = await active_portal(session)
|
||||||
await session.scalar(select(func.now()))
|
await session.scalar(select(func.now()))
|
||||||
|
revision = await session.scalar(
|
||||||
|
text("SELECT version_num FROM bitrix_local.alembic_version")
|
||||||
|
)
|
||||||
worker_ok = all(not task.done() for task in request.app.state.workers)
|
worker_ok = all(not task.done() for task in request.app.state.workers)
|
||||||
if portal and portal.install_status == "installed" and worker_ok:
|
if (
|
||||||
|
portal
|
||||||
|
and portal.install_status == "installed"
|
||||||
|
and worker_ok
|
||||||
|
and revision == EXPECTED_BITRIX_DB_REVISION
|
||||||
|
):
|
||||||
return {"status": "ready", "portal": "installed", "workers": "running"}
|
return {"status": "ready", "portal": "installed", "workers": "running"}
|
||||||
|
if revision != EXPECTED_BITRIX_DB_REVISION:
|
||||||
|
return JSONResponse(
|
||||||
|
{"status": "not_ready", "reason": "migration_required"},
|
||||||
|
status_code=503,
|
||||||
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"status": "not_ready", "reason": "portal_not_installed"}, status_code=503
|
{"status": "not_ready", "reason": "portal_not_installed"}, status_code=503
|
||||||
)
|
)
|
||||||
@@ -654,7 +711,12 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
normalized = normalize_event(payload)
|
normalized = normalize_event(payload)
|
||||||
if normalized is None:
|
if normalized is None:
|
||||||
return {"status": "ignored"}
|
return {"status": "ignored"}
|
||||||
created = await save_inbox(request.app, normalized)
|
created = await save_inbox(
|
||||||
|
request.app,
|
||||||
|
normalized,
|
||||||
|
request.state.request_id,
|
||||||
|
current_traceparent(),
|
||||||
|
)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"status": "accepted" if created else "duplicate"},
|
{"status": "accepted" if created else "duplicate"},
|
||||||
status_code=202 if created else 200,
|
status_code=202 if created else 200,
|
||||||
@@ -697,7 +759,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
"Idempotency-Key must equal message_id",
|
"Idempotency-Key must equal message_id",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
body = dto.model_dump(mode="json")
|
body = dto.model_dump(mode="json", exclude_none=True)
|
||||||
fp = canonical_fingerprint(body)
|
fp = canonical_fingerprint(body)
|
||||||
async with request.app.state.sessions() as session:
|
async with request.app.state.sessions() as session:
|
||||||
row = await session.scalar(
|
row = await session.scalar(
|
||||||
@@ -730,6 +792,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
external_chat_id=dto.external_chat_id,
|
external_chat_id=dto.external_chat_id,
|
||||||
request_fingerprint=fp,
|
request_fingerprint=fp,
|
||||||
payload_json=body,
|
payload_json=body,
|
||||||
|
request_id=request.state.request_id,
|
||||||
|
traceparent=current_traceparent(),
|
||||||
status="sending",
|
status="sending",
|
||||||
lease_until=now() + timedelta(seconds=cfg.bitrix_http_timeout_sec + 5),
|
lease_until=now() + timedelta(seconds=cfg.bitrix_http_timeout_sec + 5),
|
||||||
)
|
)
|
||||||
@@ -767,12 +831,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
)
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"outbound delivery failed",
|
"outbound.delivery_failed",
|
||||||
extra={
|
request_id=request.state.request_id,
|
||||||
"request_id": request.state.request_id,
|
message_id=str(dto.message_id),
|
||||||
"message_id": str(dto.message_id),
|
error_type=type(exc).__name__,
|
||||||
"error_type": type(exc).__name__,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
async with request.app.state.sessions() as session:
|
async with request.app.state.sessions() as session:
|
||||||
current = await session.get(OutboundMessage, row.id, with_for_update=True)
|
current = await session.get(OutboundMessage, row.id, with_for_update=True)
|
||||||
@@ -844,6 +906,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
result = await reconcile_setup(request.app)
|
result = await reconcile_setup(request.app)
|
||||||
return {"status": "completed" if all(result.values()) else "partial", "steps": result}
|
return {"status": "completed" if all(result.values()) else "partial", "steps": result}
|
||||||
|
|
||||||
|
instrument_fastapi(app)
|
||||||
return app
|
return app
|
||||||
|
|
||||||
|
|
||||||
@@ -959,7 +1022,12 @@ async def uninstall_payload(app: FastAPI, payload: dict[str, Any]) -> None:
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
async def save_inbox(app: FastAPI, normalized: dict[str, Any]) -> bool:
|
async def save_inbox(
|
||||||
|
app: FastAPI,
|
||||||
|
normalized: dict[str, Any],
|
||||||
|
request_id: str,
|
||||||
|
traceparent: str | None,
|
||||||
|
) -> bool:
|
||||||
fingerprint = hashlib.sha256(
|
fingerprint = hashlib.sha256(
|
||||||
json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()
|
json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
@@ -970,6 +1038,8 @@ async def save_inbox(app: FastAPI, normalized: dict[str, Any]) -> bool:
|
|||||||
bitrix_message_id=normalized["bitrix_message_id"],
|
bitrix_message_id=normalized["bitrix_message_id"],
|
||||||
payload_fingerprint=fingerprint,
|
payload_fingerprint=fingerprint,
|
||||||
normalized_json=normalized,
|
normalized_json=normalized,
|
||||||
|
request_id=request_id,
|
||||||
|
traceparent=traceparent,
|
||||||
)
|
)
|
||||||
async with app.state.sessions() as session:
|
async with app.state.sessions() as session:
|
||||||
session.add(row)
|
session.add(row)
|
||||||
@@ -992,17 +1062,11 @@ def extract_delivery(result: dict[str, Any]) -> tuple[int | None, str | None, st
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]:
|
def imconnector_send_fields(connector: str, line: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
async with app.state.sessions() as session:
|
|
||||||
row = await session.get(OutboundMessage, row_id)
|
|
||||||
portal = await active_portal(session)
|
|
||||||
if not row or not portal:
|
|
||||||
raise RuntimeError("portal_not_installed")
|
|
||||||
payload = row.payload_json
|
|
||||||
message = payload["message"]
|
message = payload["message"]
|
||||||
fields: dict[str, Any] = {
|
fields: dict[str, Any] = {
|
||||||
"CONNECTOR": app.state.settings.bitrix_connector_id,
|
"CONNECTOR": connector,
|
||||||
"LINE": app.state.settings.bitrix_open_line_id,
|
"LINE": line,
|
||||||
"MESSAGES[0][user][id]": payload["user"]["id"],
|
"MESSAGES[0][user][id]": payload["user"]["id"],
|
||||||
"MESSAGES[0][user][name]": payload["user"]["display_name"],
|
"MESSAGES[0][user][name]": payload["user"]["display_name"],
|
||||||
"MESSAGES[0][message][id]": payload["message_id"],
|
"MESSAGES[0][message][id]": payload["message_id"],
|
||||||
@@ -1010,10 +1074,28 @@ async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]:
|
|||||||
"MESSAGES[0][message][text]": message["text"],
|
"MESSAGES[0][message][text]": message["text"],
|
||||||
"MESSAGES[0][chat][id]": payload["external_chat_id"],
|
"MESSAGES[0][chat][id]": payload["external_chat_id"],
|
||||||
}
|
}
|
||||||
if message["files"]:
|
phone = payload["user"].get("phone")
|
||||||
|
if phone:
|
||||||
|
fields["MESSAGES[0][user][phone]"] = phone
|
||||||
|
if message.get("files"):
|
||||||
file = message["files"][0]
|
file = message["files"][0]
|
||||||
fields["MESSAGES[0][message][files][0][url]"] = file["download_url"]
|
fields["MESSAGES[0][message][files][0][url]"] = file["download_url"]
|
||||||
fields["MESSAGES[0][message][files][0][name]"] = file["name"]
|
fields["MESSAGES[0][message][files][0][name]"] = file["name"]
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
async def deliver_outbound(app: FastAPI, row_id: uuid.UUID) -> dict[str, Any]:
|
||||||
|
async with app.state.sessions() as session:
|
||||||
|
row = await session.get(OutboundMessage, row_id)
|
||||||
|
portal = await active_portal(session)
|
||||||
|
if not row or not portal:
|
||||||
|
raise RuntimeError("portal_not_installed")
|
||||||
|
payload = row.payload_json
|
||||||
|
fields = imconnector_send_fields(
|
||||||
|
app.state.settings.bitrix_connector_id,
|
||||||
|
app.state.settings.bitrix_open_line_id,
|
||||||
|
payload,
|
||||||
|
)
|
||||||
result = await app.state.bitrix.call(portal, "imconnector.send.messages", fields)
|
result = await app.state.bitrix.call(portal, "imconnector.send.messages", fields)
|
||||||
chat_id, session_id, bitrix_message_id = extract_delivery(result)
|
chat_id, session_id, bitrix_message_id = extract_delivery(result)
|
||||||
response = {
|
response = {
|
||||||
@@ -1155,8 +1237,9 @@ async def worker_loop(app: FastAPI, kind: str) -> None:
|
|||||||
await process_setup(app)
|
await process_setup(app)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.error(
|
logger.error(
|
||||||
"worker iteration failed",
|
"worker.iteration_failed",
|
||||||
extra={"worker_kind": kind, "error_type": type(exc).__name__},
|
worker_kind=kind,
|
||||||
|
error_type=type(exc).__name__,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(app.state.stop.wait(), app.state.settings.bitrix_worker_poll_sec)
|
await asyncio.wait_for(app.state.stop.wait(), app.state.settings.bitrix_worker_poll_sec)
|
||||||
@@ -1169,45 +1252,57 @@ async def process_inbox(app: FastAPI) -> None:
|
|||||||
row = await claim_one(session, InboxEvent, ["received", "retry"])
|
row = await claim_one(session, InboxEvent, ["received", "retry"])
|
||||||
if not row:
|
if not row:
|
||||||
return
|
return
|
||||||
try:
|
tracer = trace.get_tracer("han.bitrix.inbox")
|
||||||
payload = json.loads(json.dumps(row.normalized_json))
|
with (
|
||||||
await resolve_inbound_file_urls(app, payload)
|
tracer.start_as_current_span(
|
||||||
response = await app.state.http.post(
|
"bitrix.inbox.forward",
|
||||||
app.state.settings.bitrix_api_forward_url,
|
links=origin_links(row.traceparent),
|
||||||
json=payload,
|
),
|
||||||
headers={
|
structlog.contextvars.bound_contextvars(
|
||||||
"Authorization": f"Bearer {app.state.settings.bitrix_api_forward_token}",
|
request_id=row.request_id,
|
||||||
"X-Request-ID": str(uuid.uuid4()),
|
inbox_id=str(row.id),
|
||||||
},
|
**{"service.name": "bitrix-local-app"},
|
||||||
follow_redirects=False,
|
),
|
||||||
)
|
):
|
||||||
duplicate = response.status_code in {200, 204}
|
try:
|
||||||
if response.status_code != 201 and not duplicate:
|
payload = json.loads(json.dumps(row.normalized_json))
|
||||||
response.raise_for_status()
|
await resolve_inbound_file_urls(app, payload)
|
||||||
async with app.state.sessions() as session:
|
response = await app.state.http.post(
|
||||||
current = await session.get(InboxEvent, row.id, with_for_update=True)
|
app.state.settings.bitrix_api_forward_url,
|
||||||
current.status = "ack_pending"
|
json=payload,
|
||||||
current.api_ack_status = "duplicate" if duplicate else "created"
|
headers={
|
||||||
current.lease_until = None
|
"Authorization": f"Bearer {app.state.settings.bitrix_api_forward_token}",
|
||||||
session.add(
|
"X-Request-ID": row.request_id or str(uuid.uuid4()),
|
||||||
DeliveryAckOutbox(
|
},
|
||||||
inbox_event_id=current.id,
|
follow_redirects=False,
|
||||||
payload_json={
|
|
||||||
"external_chat_id": str(current.external_chat_id),
|
|
||||||
"bitrix_message_id": current.bitrix_message_id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
await session.commit()
|
duplicate = response.status_code in {200, 204}
|
||||||
except Exception as exc:
|
if response.status_code != 201 and not duplicate:
|
||||||
logger.error(
|
response.raise_for_status()
|
||||||
"inbound forward failed",
|
async with app.state.sessions() as session:
|
||||||
extra={
|
current = await session.get(InboxEvent, row.id, with_for_update=True)
|
||||||
"inbox_id": str(row.id),
|
current.status = "ack_pending"
|
||||||
"error_type": type(exc).__name__,
|
current.api_ack_status = "duplicate" if duplicate else "created"
|
||||||
},
|
current.lease_until = None
|
||||||
)
|
session.add(
|
||||||
await mark_retry(app, InboxEvent, row.id, "api_forward_failed")
|
DeliveryAckOutbox(
|
||||||
|
inbox_event_id=current.id,
|
||||||
|
request_id=current.request_id,
|
||||||
|
traceparent=current.traceparent,
|
||||||
|
payload_json={
|
||||||
|
"external_chat_id": str(current.external_chat_id),
|
||||||
|
"bitrix_message_id": current.bitrix_message_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"inbound.forward_failed",
|
||||||
|
inbox_id=str(row.id),
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
)
|
||||||
|
await mark_retry(app, InboxEvent, row.id, "api_forward_failed")
|
||||||
|
|
||||||
|
|
||||||
async def resolve_inbound_file_urls(app: FastAPI, payload: dict[str, Any]) -> None:
|
async def resolve_inbound_file_urls(app: FastAPI, payload: dict[str, Any]) -> None:
|
||||||
@@ -1239,27 +1334,41 @@ async def process_ack(app: FastAPI) -> None:
|
|||||||
portal = await active_portal(session)
|
portal = await active_portal(session)
|
||||||
if not row or not portal:
|
if not row or not portal:
|
||||||
return
|
return
|
||||||
try:
|
tracer = trace.get_tracer("han.bitrix.ack")
|
||||||
await app.state.bitrix.call(
|
with (
|
||||||
portal,
|
tracer.start_as_current_span(
|
||||||
"imconnector.send.status.delivery",
|
"bitrix.delivery_ack.process",
|
||||||
{
|
links=origin_links(row.traceparent),
|
||||||
"CONNECTOR": app.state.settings.bitrix_connector_id,
|
),
|
||||||
"LINE": app.state.settings.bitrix_open_line_id,
|
structlog.contextvars.bound_contextvars(
|
||||||
"MESSAGES[0][im][chat_id]": row.payload_json["external_chat_id"],
|
request_id=row.request_id,
|
||||||
"MESSAGES[0][message][id]": row.payload_json["bitrix_message_id"] or "",
|
ack_outbox_id=str(row.id),
|
||||||
},
|
**{"service.name": "bitrix-local-app"},
|
||||||
)
|
),
|
||||||
async with app.state.sessions() as session:
|
):
|
||||||
current = await session.get(DeliveryAckOutbox, row.id, with_for_update=True)
|
try:
|
||||||
event = await session.get(InboxEvent, current.inbox_event_id, with_for_update=True)
|
await app.state.bitrix.call(
|
||||||
current.status = "completed"
|
portal,
|
||||||
current.lease_until = None
|
"imconnector.send.status.delivery",
|
||||||
event.status = "completed"
|
{
|
||||||
event.delivery_ack_status = "sent"
|
"CONNECTOR": app.state.settings.bitrix_connector_id,
|
||||||
await session.commit()
|
"LINE": app.state.settings.bitrix_open_line_id,
|
||||||
except Exception:
|
"MESSAGES[0][im][chat_id]": row.payload_json["external_chat_id"],
|
||||||
await mark_retry(app, DeliveryAckOutbox, row.id, "delivery_ack_failed")
|
"MESSAGES[0][message][id]": row.payload_json["bitrix_message_id"] or "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async with app.state.sessions() as session:
|
||||||
|
current = await session.get(DeliveryAckOutbox, row.id, with_for_update=True)
|
||||||
|
event = await session.get(
|
||||||
|
InboxEvent, current.inbox_event_id, with_for_update=True
|
||||||
|
)
|
||||||
|
current.status = "completed"
|
||||||
|
current.lease_until = None
|
||||||
|
event.status = "completed"
|
||||||
|
event.delivery_ack_status = "sent"
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
await mark_retry(app, DeliveryAckOutbox, row.id, "delivery_ack_failed")
|
||||||
|
|
||||||
|
|
||||||
async def process_outbound(app: FastAPI) -> None:
|
async def process_outbound(app: FastAPI) -> None:
|
||||||
@@ -1272,20 +1381,30 @@ async def process_outbound(app: FastAPI) -> None:
|
|||||||
)
|
)
|
||||||
if not row:
|
if not row:
|
||||||
return
|
return
|
||||||
try:
|
tracer = trace.get_tracer("han.bitrix.outbound")
|
||||||
await asyncio.wait_for(
|
with (
|
||||||
deliver_outbound(app, row.id),
|
tracer.start_as_current_span(
|
||||||
timeout=app.state.settings.bitrix_http_timeout_sec,
|
"bitrix.outbound.process",
|
||||||
)
|
links=origin_links(row.traceparent),
|
||||||
except Exception as exc:
|
),
|
||||||
logger.error(
|
structlog.contextvars.bound_contextvars(
|
||||||
"outbound retry failed",
|
request_id=row.request_id,
|
||||||
extra={
|
outbound_id=str(row.id),
|
||||||
"outbound_id": str(row.id),
|
**{"service.name": "bitrix-local-app"},
|
||||||
"error_type": type(exc).__name__,
|
),
|
||||||
},
|
):
|
||||||
)
|
try:
|
||||||
await mark_retry(app, OutboundMessage, row.id, "bitrix_delivery_failed")
|
await asyncio.wait_for(
|
||||||
|
deliver_outbound(app, row.id),
|
||||||
|
timeout=app.state.settings.bitrix_http_timeout_sec,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"outbound.retry_failed",
|
||||||
|
outbound_id=str(row.id),
|
||||||
|
error_type=type(exc).__name__,
|
||||||
|
)
|
||||||
|
await mark_retry(app, OutboundMessage, row.id, "bitrix_delivery_failed")
|
||||||
|
|
||||||
|
|
||||||
async def process_setup(app: FastAPI) -> None:
|
async def process_setup(app: FastAPI) -> None:
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ class InboxEvent(Common, Base):
|
|||||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
payload_fingerprint: Mapped[str] = mapped_column(String(64))
|
payload_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||||
normalized_json: Mapped[dict] = mapped_column(JSON)
|
normalized_json: Mapped[dict] = mapped_column(JSON)
|
||||||
|
request_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
traceparent: Mapped[str | None] = mapped_column(String(55))
|
||||||
status: Mapped[str] = mapped_column(String(32), default="received")
|
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
@@ -149,6 +151,8 @@ class OutboundMessage(Common, Base):
|
|||||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||||
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||||
payload_json: Mapped[dict] = mapped_column(JSON)
|
payload_json: Mapped[dict] = mapped_column(JSON)
|
||||||
|
request_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
traceparent: Mapped[str | None] = mapped_column(String(55))
|
||||||
status: Mapped[str] = mapped_column(String(32), default="received")
|
status: Mapped[str] = mapped_column(String(32), default="received")
|
||||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||||
response_json: Mapped[dict | None] = mapped_column(JSON)
|
response_json: Mapped[dict | None] = mapped_column(JSON)
|
||||||
@@ -165,6 +169,8 @@ class DeliveryAckOutbox(Common, Base):
|
|||||||
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.inbox_events.id"), unique=True
|
UUID(as_uuid=True), ForeignKey(f"{SCHEMA}.inbox_events.id"), unique=True
|
||||||
)
|
)
|
||||||
payload_json: Mapped[dict] = mapped_column(JSON)
|
payload_json: Mapped[dict] = mapped_column(JSON)
|
||||||
|
request_id: Mapped[str | None] = mapped_column(String(64))
|
||||||
|
traceparent: Mapped[str | None] = mapped_column(String(55))
|
||||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=now)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from collections.abc import MutableMapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from opentelemetry import metrics, trace
|
||||||
|
from opentelemetry._logs import set_logger_provider
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||||
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||||
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||||
|
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
||||||
|
from opentelemetry.propagate import extract, inject, set_global_textmap
|
||||||
|
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
||||||
|
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
||||||
|
from opentelemetry.sdk.metrics import MeterProvider
|
||||||
|
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||||
|
from opentelemetry.sdk.resources import Resource
|
||||||
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||||
|
from opentelemetry.sdk.trace.sampling import ALWAYS_ON
|
||||||
|
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TelemetryRuntime:
|
||||||
|
tracer_provider: TracerProvider
|
||||||
|
meter_provider: MeterProvider
|
||||||
|
logger_provider: LoggerProvider
|
||||||
|
logging_handler: LoggingHandler
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self.logger_provider.shutdown()
|
||||||
|
self.meter_provider.shutdown()
|
||||||
|
self.tracer_provider.shutdown()
|
||||||
|
|
||||||
|
|
||||||
|
_runtime: TelemetryRuntime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_telemetry() -> TelemetryRuntime | None:
|
||||||
|
global _runtime
|
||||||
|
if _runtime is not None:
|
||||||
|
return _runtime
|
||||||
|
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
|
||||||
|
if not endpoint:
|
||||||
|
return None
|
||||||
|
resource = Resource.create(
|
||||||
|
{
|
||||||
|
"service.name": os.getenv("OTEL_SERVICE_NAME", "bitrix-local-app"),
|
||||||
|
"service.namespace": "han-chat",
|
||||||
|
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
|
||||||
|
"deployment.environment": os.getenv("APP_ENV", "production-like"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
insecure = endpoint.startswith("http://")
|
||||||
|
set_global_textmap(TraceContextTextMapPropagator())
|
||||||
|
tracer_provider = TracerProvider(resource=resource, sampler=ALWAYS_ON)
|
||||||
|
tracer_provider.add_span_processor(
|
||||||
|
BatchSpanProcessor(OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3))
|
||||||
|
)
|
||||||
|
trace.set_tracer_provider(tracer_provider)
|
||||||
|
metric_reader = PeriodicExportingMetricReader(
|
||||||
|
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||||
|
export_interval_millis=30000,
|
||||||
|
export_timeout_millis=3000,
|
||||||
|
)
|
||||||
|
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||||
|
metrics.set_meter_provider(meter_provider)
|
||||||
|
logger_provider = LoggerProvider(resource=resource)
|
||||||
|
logger_provider.add_log_record_processor(
|
||||||
|
BatchLogRecordProcessor(
|
||||||
|
OTLPLogExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||||
|
max_queue_size=2048,
|
||||||
|
max_export_batch_size=512,
|
||||||
|
export_timeout_millis=3000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
set_logger_provider(logger_provider)
|
||||||
|
logging_handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
|
||||||
|
HTTPXClientInstrumentor().instrument()
|
||||||
|
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||||
|
_runtime = TelemetryRuntime(
|
||||||
|
tracer_provider, meter_provider, logger_provider, logging_handler
|
||||||
|
)
|
||||||
|
return _runtime
|
||||||
|
|
||||||
|
|
||||||
|
def instrument_fastapi(app: FastAPI) -> None:
|
||||||
|
FastAPIInstrumentor.instrument_app(app, excluded_urls="/health/live,/health/ready")
|
||||||
|
|
||||||
|
|
||||||
|
def add_trace_context(
|
||||||
|
_logger: Any, _method_name: str, event_dict: MutableMapping[str, Any]
|
||||||
|
) -> MutableMapping[str, Any]:
|
||||||
|
context = trace.get_current_span().get_span_context()
|
||||||
|
if context.is_valid:
|
||||||
|
event_dict["trace_id"] = format(context.trace_id, "032x")
|
||||||
|
event_dict["span_id"] = format(context.span_id, "016x")
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
|
def current_traceparent() -> str | None:
|
||||||
|
carrier: dict[str, str] = {}
|
||||||
|
inject(carrier)
|
||||||
|
return carrier.get("traceparent")
|
||||||
|
|
||||||
|
|
||||||
|
def origin_links(traceparent: str | None) -> list[trace.Link]:
|
||||||
|
if not traceparent:
|
||||||
|
return []
|
||||||
|
context = extract({"traceparent": traceparent})
|
||||||
|
span_context = trace.get_current_span(context).get_span_context()
|
||||||
|
return [trace.Link(span_context)] if span_context.is_valid else []
|
||||||
@@ -91,6 +91,7 @@ components:
|
|||||||
properties:
|
properties:
|
||||||
id: {type: string, format: uuid}
|
id: {type: string, format: uuid}
|
||||||
display_name: {type: string, maxLength: 255}
|
display_name: {type: string, maxLength: 255}
|
||||||
|
phone: {type: string, minLength: 1, maxLength: 32}
|
||||||
message:
|
message:
|
||||||
type: object
|
type: object
|
||||||
additionalProperties: false
|
additionalProperties: false
|
||||||
|
|||||||
@@ -8,9 +8,16 @@ dependencies = [
|
|||||||
"cryptography>=45,<46",
|
"cryptography>=45,<46",
|
||||||
"fastapi>=0.116,<1",
|
"fastapi>=0.116,<1",
|
||||||
"httpx>=0.28,<1",
|
"httpx>=0.28,<1",
|
||||||
|
"opentelemetry-api>=1.44,<2",
|
||||||
|
"opentelemetry-exporter-otlp-proto-grpc>=1.44,<2",
|
||||||
|
"opentelemetry-instrumentation-fastapi>=0.65b0,<1",
|
||||||
|
"opentelemetry-instrumentation-httpx>=0.65b0,<1",
|
||||||
|
"opentelemetry-instrumentation-sqlalchemy>=0.65b0,<1",
|
||||||
|
"opentelemetry-sdk>=1.44,<2",
|
||||||
"pydantic-settings>=2.10,<3",
|
"pydantic-settings>=2.10,<3",
|
||||||
"python-multipart>=0.0.20,<1",
|
"python-multipart>=0.0.20,<1",
|
||||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||||
|
"structlog>=25,<26",
|
||||||
"uvicorn[standard]>=0.35,<1",
|
"uvicorn[standard]>=0.35,<1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -19,16 +19,29 @@ os.environ.setdefault(
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.logging_security import REDACTED, sanitize_value
|
||||||
from app.main import (
|
from app.main import (
|
||||||
BitrixClient,
|
BitrixClient,
|
||||||
TokenCipher,
|
TokenCipher,
|
||||||
canonical_fingerprint,
|
canonical_fingerprint,
|
||||||
|
imconnector_send_fields,
|
||||||
normalize_event,
|
normalize_event,
|
||||||
resolve_inbound_file_urls,
|
resolve_inbound_file_urls,
|
||||||
retry_delay,
|
retry_delay,
|
||||||
safely_retryable,
|
safely_retryable,
|
||||||
validate_portal,
|
validate_portal,
|
||||||
)
|
)
|
||||||
|
from app.telemetry import add_trace_context, origin_links
|
||||||
|
|
||||||
|
|
||||||
|
def test_bitrix_logs_redact_secrets_and_do_not_invent_trace_ids():
|
||||||
|
value = sanitize_value(
|
||||||
|
{"authorization": "Bearer secret", "url": "https://example.test/?token=secret"}
|
||||||
|
)
|
||||||
|
assert value["authorization"] == REDACTED
|
||||||
|
assert "secret" not in value["url"]
|
||||||
|
assert add_trace_context(None, "info", {"event": "safe"}) == {"event": "safe"}
|
||||||
|
assert origin_links("invalid") == []
|
||||||
|
|
||||||
|
|
||||||
def test_token_cipher_binds_aad():
|
def test_token_cipher_binds_aad():
|
||||||
@@ -278,3 +291,23 @@ async def test_bitrix_call_refreshes_and_retries_once_after_401():
|
|||||||
assert result == {"ok": True}
|
assert result == {"ok": True}
|
||||||
assert auth_values == ["old-access", "new-access"]
|
assert auth_values == ["old-access", "new-access"]
|
||||||
assert refresh_calls == [False, True]
|
assert refresh_calls == [False, True]
|
||||||
|
|
||||||
|
|
||||||
|
def test_imconnector_send_fields_maps_name_and_phone():
|
||||||
|
payload = {
|
||||||
|
"message_id": "mid",
|
||||||
|
"external_chat_id": "chat",
|
||||||
|
"occurred_at": "2026-07-10T09:00:00Z",
|
||||||
|
"user": {"id": "uid", "display_name": "Иван Иванов", "phone": "+79990000000"},
|
||||||
|
"message": {"text": "hello", "files": []},
|
||||||
|
}
|
||||||
|
fields = imconnector_send_fields("han_mobile_app", "8", payload)
|
||||||
|
assert fields["MESSAGES[0][user][name]"] == "Иван Иванов"
|
||||||
|
assert fields["MESSAGES[0][user][phone]"] == "+79990000000"
|
||||||
|
legacy = {
|
||||||
|
**payload,
|
||||||
|
"user": {"id": "uid", "display_name": "+79990000000"},
|
||||||
|
}
|
||||||
|
legacy_fields = imconnector_send_fields("han_mobile_app", "8", legacy)
|
||||||
|
assert "MESSAGES[0][user][phone]" not in legacy_fields
|
||||||
|
assert legacy_fields["MESSAGES[0][user][name]"] == "+79990000000"
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
# Runbook внедрения soft/force update мобильного приложения
|
||||||
|
|
||||||
|
## 1. Назначение и границы
|
||||||
|
|
||||||
|
Документ описывает выпуск нативных версий HAN Chat через Google Play, RuStore
|
||||||
|
и App Store и последующее включение `soft`/`force update` через публичную
|
||||||
|
backend-политику.
|
||||||
|
|
||||||
|
Механика не использует EAS OTA Update. Пользователь всегда направляется в
|
||||||
|
магазин, соответствующий каналу установленной сборки:
|
||||||
|
|
||||||
|
- `google_play`;
|
||||||
|
- `rustore`;
|
||||||
|
- `app_store`.
|
||||||
|
|
||||||
|
Канал встраивается в бинарный файл через
|
||||||
|
`EXPO_PUBLIC_DISTRIBUTION_STORE`. Поэтому Android-сборки Google Play и RuStore
|
||||||
|
нужно собирать отдельно.
|
||||||
|
|
||||||
|
Решение принимает мобильный клиент:
|
||||||
|
|
||||||
|
- `current_build < minimum_build` — обязательное обновление (`force`);
|
||||||
|
- `minimum_build <= current_build < latest_build` — мягкое обновление (`soft`);
|
||||||
|
- `current_build >= latest_build` — карточка не показывается.
|
||||||
|
|
||||||
|
`latest_version` используется только в интерфейсе. Сравнение выполняется по
|
||||||
|
целому Android `versionCode` или iOS `buildNumber`.
|
||||||
|
|
||||||
|
## 2. Ответственные и данные окна выпуска
|
||||||
|
|
||||||
|
Перед началом назначьте:
|
||||||
|
|
||||||
|
- ответственного за EAS Build;
|
||||||
|
- ответственного за Google Play Console;
|
||||||
|
- ответственного за RuStore Console;
|
||||||
|
- ответственного за App Store Connect, если выпускается iOS;
|
||||||
|
- оператора ВМ1, применяющего backend settings;
|
||||||
|
- владельца решения о переводе soft update в force update.
|
||||||
|
|
||||||
|
Создайте запись окна выпуска:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Маркетинговая версия:
|
||||||
|
Git commit/tag мобильного приложения:
|
||||||
|
Git commit/tag backend:
|
||||||
|
Google Play EAS build ID:
|
||||||
|
Google Play versionCode:
|
||||||
|
RuStore EAS build ID:
|
||||||
|
RuStore versionCode:
|
||||||
|
App Store EAS build ID:
|
||||||
|
App Store buildNumber:
|
||||||
|
Дата полной доступности каждого релиза:
|
||||||
|
Минимальная поддерживаемая сборка каждого канала:
|
||||||
|
```
|
||||||
|
|
||||||
|
Не вычисляйте пороги по порядку запуска команд. Записывайте фактические значения
|
||||||
|
из завершённой EAS-сборки и подтверждайте их в консоли соответствующего магазина.
|
||||||
|
|
||||||
|
## 3. Важная особенность EAS remote version
|
||||||
|
|
||||||
|
В проекте используется:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Android remote `versionCode` привязан к application ID `ru.han.chat`. Профили
|
||||||
|
`google-play` и `rustore` используют один application ID и общий счётчик.
|
||||||
|
|
||||||
|
Если текущее remote-значение равно `6`, последовательная сборка обычно даст:
|
||||||
|
|
||||||
|
1. первая Android-сборка — `versionCode=7`;
|
||||||
|
2. вторая Android-сборка — `versionCode=8`.
|
||||||
|
|
||||||
|
Это допустимо. Политики Google Play и RuStore независимы, поэтому в backend
|
||||||
|
следует указать `latest_build=7` для одного канала и `latest_build=8` для
|
||||||
|
другого, если именно такие артефакты опубликованы.
|
||||||
|
|
||||||
|
Локальный `android.versionCode` в `app.config.ts` не является источником истины
|
||||||
|
при remote version source. Источник истины для rollout — опубликованный
|
||||||
|
артефакт магазина.
|
||||||
|
|
||||||
|
## 4. Предварительные проверки
|
||||||
|
|
||||||
|
### 4.1. Мобильное приложение
|
||||||
|
|
||||||
|
На локальной машине:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM4_Expo-mobile
|
||||||
|
npm run typecheck
|
||||||
|
npm test
|
||||||
|
npx eas-cli whoami
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверьте:
|
||||||
|
|
||||||
|
- `version` в `app.config.ts` соответствует выпускаемой маркетинговой версии;
|
||||||
|
- профиль `google-play` содержит `EXPO_PUBLIC_DISTRIBUTION_STORE=google_play`;
|
||||||
|
- профиль `rustore` содержит `EXPO_PUBLIC_DISTRIBUTION_STORE=rustore`;
|
||||||
|
- профиль `app-store` содержит `EXPO_PUBLIC_DISTRIBUTION_STORE=app_store`;
|
||||||
|
- `preview` и `development` не включают store policy;
|
||||||
|
- production API указывает на `https://chat.han0107.ru`.
|
||||||
|
|
||||||
|
### 4.2. Backend
|
||||||
|
|
||||||
|
На локальной машине:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM1_app\codebase\backend\api-backend
|
||||||
|
python -m pytest
|
||||||
|
|
||||||
|
cd ..
|
||||||
|
python -m pytest tests
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверьте, что backend-релиз содержит:
|
||||||
|
|
||||||
|
- строгий объект `mobile_update` в `/api/v1/public/app-config`;
|
||||||
|
- поддержку `ETag` и `If-None-Match`;
|
||||||
|
- TTL app-config 60 секунд;
|
||||||
|
- валидацию build numbers и store URL;
|
||||||
|
- актуальный `openapi.yaml`;
|
||||||
|
- nginx `proxy_cache_valid 200 60s` для app-config.
|
||||||
|
|
||||||
|
## 5. Безопасное внедрение backend до выпуска приложения
|
||||||
|
|
||||||
|
Сначала разверните backend-код и nginx, затем мобильные сборки. Старые клиенты
|
||||||
|
игнорируют новую секцию `mobile_update`.
|
||||||
|
|
||||||
|
До публикации новой версии политика должна быть безопасной:
|
||||||
|
|
||||||
|
- либо канал отключён;
|
||||||
|
- либо `latest_build` не превышает уже опубликованный build;
|
||||||
|
- `minimum_build` не должен внезапно исключать поддерживаемые версии.
|
||||||
|
|
||||||
|
Начальные `latest_build=2` при фактической установленной сборке `6` не показывают
|
||||||
|
карточку и поэтому безопасны как временное no-op состояние.
|
||||||
|
|
||||||
|
Развёртывание backend выполняйте по основному production runbook:
|
||||||
|
|
||||||
|
`deployment/RUNBOOK.production.ru.md`.
|
||||||
|
|
||||||
|
После обновления образов и конфигурации оператор ВМ1 выполняет штатный job:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Команда идемпотентна и включает `validate_settings`. Невалидная комбинация
|
||||||
|
порогов или URL должна завершить job ошибкой.
|
||||||
|
|
||||||
|
Проверка публичного контракта с внешней машины:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsS https://chat.han0107.ru/api/v1/public/app-config
|
||||||
|
ETAG="$(curl -fsSI https://chat.han0107.ru/api/v1/public/app-config \
|
||||||
|
| awk -F': ' 'tolower($1)=="etag" {gsub("\r","",$2); print $2}')"
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}\n' \
|
||||||
|
-H "If-None-Match: $ETAG" \
|
||||||
|
https://chat.han0107.ru/api/v1/public/app-config
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидается:
|
||||||
|
|
||||||
|
- первый запрос — `200`;
|
||||||
|
- в ответе присутствуют три store policy;
|
||||||
|
- повторный запрос с актуальным ETag — `304`;
|
||||||
|
- `Cache-Control` содержит `max-age=60`.
|
||||||
|
|
||||||
|
## 6. Получение текущего remote build number
|
||||||
|
|
||||||
|
На локальной машине:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM4_Expo-mobile
|
||||||
|
|
||||||
|
npx eas-cli build:version:get --platform android --profile google-play
|
||||||
|
npx eas-cli build:version:get --platform android --profile rustore
|
||||||
|
npx eas-cli build:version:get --platform ios --profile app-store
|
||||||
|
```
|
||||||
|
|
||||||
|
Для автоматической обработки:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npx eas-cli build:version:get --platform android --profile google-play --json
|
||||||
|
```
|
||||||
|
|
||||||
|
Одинаковое значение для двух Android-профилей до сборки ожидаемо: они используют
|
||||||
|
общий application ID. Каждая последующая Android-сборка с `autoIncrement`
|
||||||
|
увеличивает общий счётчик.
|
||||||
|
|
||||||
|
### 6.1. Тестовый APK с каналом RuStore
|
||||||
|
|
||||||
|
Профиль `preview-rustore` создаёт внутренний APK, обращается к
|
||||||
|
`https://dev-chat.han0107.ru` и встраивает канал `rustore`:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npx eas-cli build:version:get --platform android --profile preview-rustore
|
||||||
|
npx eas-cli build --profile preview-rustore --platform android
|
||||||
|
```
|
||||||
|
|
||||||
|
У профиля задано `autoIncrement: false`, поэтому тестовая сборка не расходует
|
||||||
|
следующий production `versionCode`. Пусть фактический build APK равен `B`. Для
|
||||||
|
проверки на dev-backend задайте:
|
||||||
|
|
||||||
|
```text
|
||||||
|
soft: latest_build = B + 1, minimum_build <= B
|
||||||
|
force: latest_build = B + 1, minimum_build = B + 1
|
||||||
|
none: latest_build <= B
|
||||||
|
```
|
||||||
|
|
||||||
|
Меняйте только RuStore policy dev-окружения и применяйте её через штатный
|
||||||
|
`seed-settings` этого окружения. Не используйте тестовые пороги на production.
|
||||||
|
|
||||||
|
После отказа от soft update запись сохраняется в SecureStore без TTL. Для
|
||||||
|
повторной проверки той же политики очистите данные приложения либо увеличьте
|
||||||
|
`latest_build`.
|
||||||
|
|
||||||
|
APK использует package `ru.han.chat` и может заменить установленную
|
||||||
|
store-сборку. Для теста предпочтительно отдельное устройство.
|
||||||
|
|
||||||
|
## 7. Создание store-сборок
|
||||||
|
|
||||||
|
Рекомендуется собирать и публиковать магазины по одному, сразу записывая
|
||||||
|
фактический build number:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npx eas-cli build --profile google-play --platform android
|
||||||
|
npx eas-cli build --profile rustore --platform android
|
||||||
|
npx eas-cli build --profile app-store --platform ios
|
||||||
|
```
|
||||||
|
|
||||||
|
App Store-команду не выполняйте, пока не настроены Apple credentials, Apple App
|
||||||
|
ID и рабочий `store_url`.
|
||||||
|
|
||||||
|
После каждой сборки:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npx eas-cli build:list --platform android --limit 5
|
||||||
|
npx eas-cli build:view <BUILD_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
Зафиксируйте:
|
||||||
|
|
||||||
|
- EAS build ID;
|
||||||
|
- commit;
|
||||||
|
- channel/profile;
|
||||||
|
- `version`;
|
||||||
|
- фактический `versionCode`/`buildNumber`;
|
||||||
|
- checksum скачанного артефакта, если он используется в процедуре публикации.
|
||||||
|
|
||||||
|
Не запускайте вторую Android-сборку, пока не записан номер первой.
|
||||||
|
|
||||||
|
## 8. Публикация и проверка магазинов
|
||||||
|
|
||||||
|
### 8.1. Google Play
|
||||||
|
|
||||||
|
1. Загрузите AAB в требуемый track.
|
||||||
|
2. Убедитесь, что Console показывает ожидаемый `versionCode`.
|
||||||
|
3. Проведите internal/closed testing.
|
||||||
|
4. Проверьте установку и переход по ссылке:
|
||||||
|
`https://play.google.com/store/apps/details?id=ru.han.chat`.
|
||||||
|
5. Зафиксируйте процент rollout и время полной доступности.
|
||||||
|
|
||||||
|
### 8.2. RuStore
|
||||||
|
|
||||||
|
1. Загрузите предназначенный для RuStore артефакт.
|
||||||
|
2. Убедитесь, что Console показывает фактический `versionCode`.
|
||||||
|
3. Проведите тестирование канала.
|
||||||
|
4. Проверьте страницу:
|
||||||
|
`https://www.rustore.ru/catalog/app/ru.han.chat`.
|
||||||
|
5. Зафиксируйте статус модерации и время доступности.
|
||||||
|
|
||||||
|
### 8.3. App Store
|
||||||
|
|
||||||
|
До включения политики:
|
||||||
|
|
||||||
|
1. получите Apple App ID;
|
||||||
|
2. опубликуйте и проверьте сборку в App Store Connect/TestFlight;
|
||||||
|
3. укажите канонический URL `https://apps.apple.com/.../id<APPLE_ID>`;
|
||||||
|
4. подтвердите фактический `buildNumber`;
|
||||||
|
5. только после этого установите `enabled: true`.
|
||||||
|
|
||||||
|
## 9. Включение soft update
|
||||||
|
|
||||||
|
Изменяйте
|
||||||
|
`deployment/app-settings.production-like.yaml` отдельно для каждого магазина.
|
||||||
|
|
||||||
|
Пример, если Google Play опубликовал build `7`, а RuStore — build `8`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mobile_update.google_play.enabled: {type: boolean, value: true, public: true}
|
||||||
|
mobile_update.google_play.latest_build: {type: integer, value: 7, public: true}
|
||||||
|
mobile_update.google_play.minimum_build: {type: integer, value: 1, public: true}
|
||||||
|
mobile_update.google_play.latest_version: {type: string, value: "1.0.1", public: true}
|
||||||
|
|
||||||
|
mobile_update.rustore.enabled: {type: boolean, value: true, public: true}
|
||||||
|
mobile_update.rustore.latest_build: {type: integer, value: 8, public: true}
|
||||||
|
mobile_update.rustore.minimum_build: {type: integer, value: 1, public: true}
|
||||||
|
mobile_update.rustore.latest_version: {type: string, value: "1.0.1", public: true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Выбор `minimum_build` требует отдельного решения:
|
||||||
|
|
||||||
|
- оставить `1` — все более старые builds получают soft update;
|
||||||
|
- установить `6` — builds `1–5` немедленно получают force update, а build `6`
|
||||||
|
получает soft update;
|
||||||
|
- установить новый build (`7` или `8`) — все предыдущие builds получают force.
|
||||||
|
|
||||||
|
Для первого rollout рекомендуется сохранить прежний минимальный поддерживаемый
|
||||||
|
build и включить только soft update.
|
||||||
|
|
||||||
|
После review и merge настроек оператор ВМ1 выполняет:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
/usr/local/sbin/han-vm1-compose --profile ops run --rm seed-settings
|
||||||
|
```
|
||||||
|
|
||||||
|
Подождите до 60 секунд и повторно запросите app-config. Если внешний nginx уже
|
||||||
|
имел закешированный ответ, допускайте до двух минут на проверку с разных
|
||||||
|
клиентов, но не продолжайте rollout при значении старше ожидаемого.
|
||||||
|
|
||||||
|
## 10. Приёмка soft update
|
||||||
|
|
||||||
|
Используйте реальное устройство со старой store-сборкой каждого канала.
|
||||||
|
|
||||||
|
Проверьте:
|
||||||
|
|
||||||
|
1. При cold start появляется «Доступно обновление».
|
||||||
|
2. Указаны правильные текущая и новая версии.
|
||||||
|
3. Кнопка открывает правильный магазин, а не другой Android-магазин.
|
||||||
|
4. «Позже», крестик и Android Back закрывают карточку.
|
||||||
|
5. После отказа карточка той же `latest_build` не появляется при следующем
|
||||||
|
запуске: отказ хранится в SecureStore без TTL.
|
||||||
|
6. После увеличения `latest_build` появляется новая карточка.
|
||||||
|
7. После установки нового build карточка исчезает.
|
||||||
|
8. При недоступном backend приложение не блокируется.
|
||||||
|
|
||||||
|
Если продукту требуется повторное напоминание через интервал, текущую механику
|
||||||
|
следует изменить отдельно: сейчас soft-dismiss действует до появления нового
|
||||||
|
`latest_build`, очистки данных или переустановки.
|
||||||
|
|
||||||
|
## 11. Перевод в force update
|
||||||
|
|
||||||
|
Force разрешено включать только когда обязательный build:
|
||||||
|
|
||||||
|
- прошёл модерацию;
|
||||||
|
- доступен в нужном production track;
|
||||||
|
- доступен всем пользователям, которых затронет `minimum_build`;
|
||||||
|
- устанавливается и запускается;
|
||||||
|
- корректно открывается по `store_url`;
|
||||||
|
- backend и store не находятся в инциденте.
|
||||||
|
|
||||||
|
Для Google Play build `7`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mobile_update.google_play.latest_build: {type: integer, value: 7, public: true}
|
||||||
|
mobile_update.google_play.minimum_build: {type: integer, value: 7, public: true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Для RuStore build `8`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mobile_update.rustore.latest_build: {type: integer, value: 8, public: true}
|
||||||
|
mobile_update.rustore.minimum_build: {type: integer, value: 8, public: true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Не повышайте minimum одного магазина только потому, что релиз доступен в другом.
|
||||||
|
|
||||||
|
После изменения снова примените `seed-settings`, проверьте app-config и
|
||||||
|
протестируйте старую сборку:
|
||||||
|
|
||||||
|
- force-карточка не имеет крестика и кнопки «Позже»;
|
||||||
|
- Android Back не закрывает её;
|
||||||
|
- кнопка открывает правильный магазин;
|
||||||
|
- после возврата без установки карточка остаётся;
|
||||||
|
- после установки поддерживаемого build блокировка исчезает.
|
||||||
|
|
||||||
|
## 12. App Store policy
|
||||||
|
|
||||||
|
Пока Apple App ID неизвестен, политика должна оставаться полностью выключенной:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mobile_update.app_store.enabled: {type: boolean, value: false, public: true}
|
||||||
|
mobile_update.app_store.latest_build: {type: integer, value: null, public: true}
|
||||||
|
mobile_update.app_store.minimum_build: {type: integer, value: null, public: true}
|
||||||
|
mobile_update.app_store.latest_version: {type: string, value: "", public: true}
|
||||||
|
mobile_update.app_store.store_url: {type: string, value: "", public: true}
|
||||||
|
mobile_update.app_store.release_notes: {type: string, value: "", public: true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Отключённая политика не должна содержать частично заполненные release-поля:
|
||||||
|
backend отклонит такую конфигурацию.
|
||||||
|
|
||||||
|
## 13. Откат
|
||||||
|
|
||||||
|
### 13.1. Немедленно снять force
|
||||||
|
|
||||||
|
Понизьте `minimum_build` до последнего подтверждённого поддерживаемого значения,
|
||||||
|
не меняя `latest_build`, затем примените seed.
|
||||||
|
|
||||||
|
Пример:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mobile_update.google_play.latest_build: {type: integer, value: 7, public: true}
|
||||||
|
mobile_update.google_play.minimum_build: {type: integer, value: 1, public: true}
|
||||||
|
```
|
||||||
|
|
||||||
|
После успешного получения новой политики клиент снимет force-блокировку.
|
||||||
|
Кратковременная сетевая ошибка сохраняет уже показанный force до следующей
|
||||||
|
успешной проверки, поэтому дополнительно подтвердите доступность app-config.
|
||||||
|
|
||||||
|
### 13.2. Полностью отключить канал
|
||||||
|
|
||||||
|
Установите `enabled=false`, integer-поля в `null`, строковые release-поля в
|
||||||
|
пустую строку:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
mobile_update.google_play.enabled: {type: boolean, value: false, public: true}
|
||||||
|
mobile_update.google_play.latest_build: {type: integer, value: null, public: true}
|
||||||
|
mobile_update.google_play.minimum_build: {type: integer, value: null, public: true}
|
||||||
|
mobile_update.google_play.latest_version: {type: string, value: "", public: true}
|
||||||
|
mobile_update.google_play.store_url: {type: string, value: "", public: true}
|
||||||
|
mobile_update.google_play.release_notes: {type: string, value: "", public: true}
|
||||||
|
```
|
||||||
|
|
||||||
|
Не откатывайте уже использованный store build number и не публикуйте другой
|
||||||
|
артефакт с тем же `versionCode`/`buildNumber`.
|
||||||
|
|
||||||
|
### 13.3. Дефект новой версии
|
||||||
|
|
||||||
|
Если новая версия дефектна:
|
||||||
|
|
||||||
|
1. не направляйте на неё новых пользователей — отключите policy или верните
|
||||||
|
`latest_build` к безопасному опубликованному build;
|
||||||
|
2. остановите rollout в соответствующем магазине;
|
||||||
|
3. выпустите исправленную сборку с новым build number;
|
||||||
|
4. после публикации укажите новый `latest_build`;
|
||||||
|
5. только после приёмки принимайте решение о новом `minimum_build`.
|
||||||
|
|
||||||
|
## 14. Наблюдение после включения
|
||||||
|
|
||||||
|
В течение окна наблюдения контролируйте:
|
||||||
|
|
||||||
|
- `5xx` и latency `/api/v1/public/app-config`;
|
||||||
|
- долю `200/304`;
|
||||||
|
- ошибки rate limit публичного endpoint;
|
||||||
|
- доступность страниц магазинов;
|
||||||
|
- crash/error rate новой мобильной версии;
|
||||||
|
- обращения о циклической force-карточке;
|
||||||
|
- соответствие фактического store build политике каждого канала.
|
||||||
|
|
||||||
|
Stop conditions:
|
||||||
|
|
||||||
|
- URL ведёт не в тот магазин или не на HAN Chat;
|
||||||
|
- опубликованный build ниже `latest_build`;
|
||||||
|
- часть rollout-групп не может скачать minimum build;
|
||||||
|
- app-config отдаёт старую или частичную политику дольше двух минут;
|
||||||
|
- новая версия не запускается или не проходит авторизацию;
|
||||||
|
- force нельзя снять успешным изменением backend policy.
|
||||||
|
|
||||||
|
## 15. Контрольный чек-лист
|
||||||
|
|
||||||
|
- [ ] Backend с `mobile_update` развёрнут до мобильного rollout.
|
||||||
|
- [ ] ETag/304 и TTL 60 секунд проверены извне.
|
||||||
|
- [ ] Фактические build numbers записаны после каждой EAS-сборки.
|
||||||
|
- [ ] Build numbers подтверждены в консолях магазинов.
|
||||||
|
- [ ] Google Play и RuStore thresholds заполнены независимо.
|
||||||
|
- [ ] Soft update проверен на старой сборке каждого канала.
|
||||||
|
- [ ] Soft-dismiss и повторный показ для нового latest build проверены.
|
||||||
|
- [ ] Force включается только после полной доступности minimum build.
|
||||||
|
- [ ] App Store остаётся disabled до получения Apple App ID.
|
||||||
|
- [ ] Процедура отката проверена до включения force.
|
||||||
|
- [ ] Итоговые значения политики и время применения записаны в журнал выпуска.
|
||||||
@@ -339,6 +339,21 @@ downgrade запрещены.
|
|||||||
|
|
||||||
## 9. Упорядоченный первый запуск
|
## 9. Упорядоченный первый запуск
|
||||||
|
|
||||||
|
До preflight установите host Collector из pinned release artifact. Версию и
|
||||||
|
SHA-256 возьмите из утверждённого release record, не из ответа GitHub API:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export OTEL_HOST_COLLECTOR_VERSION='0.117.0'
|
||||||
|
export OTEL_HOST_COLLECTOR_SHA256='90710c909a30fc3b89dd0e389c13f9e57ebbb87d6a0dc51fdde0bf03499a5f25'
|
||||||
|
deployment/scripts/setup-vm.sh
|
||||||
|
/usr/local/bin/otelcol-contrib validate \
|
||||||
|
--config=deployment/observability/otel-host-collector.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup-vm.sh` устанавливает и enable-ит unit, но не запускает host Collector.
|
||||||
|
Он работает без Docker socket; offsets и exporter queue находятся в
|
||||||
|
`/var/lib/han-otel/host-collector`.
|
||||||
|
|
||||||
Первый запуск выполняет root через фиксированный launcher:
|
Первый запуск выполняет root через фиксированный launcher:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -363,6 +378,8 @@ docker kill --signal HUP "$NGINX_ID" >/dev/null
|
|||||||
unset NGINX_ID
|
unset NGINX_ID
|
||||||
systemctl enable han-secrets@production.service han-stack@production.service
|
systemctl enable han-secrets@production.service han-stack@production.service
|
||||||
systemctl start han-stack@production.service
|
systemctl start han-stack@production.service
|
||||||
|
systemctl start han-host-otel-collector@production.service
|
||||||
|
systemctl --no-pager status han-host-otel-collector@production.service
|
||||||
```
|
```
|
||||||
|
|
||||||
`han-stack@production` становится единственным routine lifecycle interface.
|
`han-stack@production` становится единственным routine lifecycle interface.
|
||||||
@@ -376,6 +393,9 @@ stack unit. Обновление active/exited oneshot всегда требуе
|
|||||||
```sh
|
```sh
|
||||||
curl -sS -o /dev/null -w '%{http_code}\n' http://<PUBLIC_HOST>/
|
curl -sS -o /dev/null -w '%{http_code}\n' http://<PUBLIC_HOST>/
|
||||||
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
|
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
|
||||||
|
ETAG="$(curl -fsSI https://<PUBLIC_HOST>/api/v1/public/app-config | awk -F': ' 'tolower($1)=="etag" {gsub("\r","",$2); print $2}')"
|
||||||
|
curl -sS -o /dev/null -w '%{http_code}\n' \
|
||||||
|
-H "If-None-Match: $ETAG" https://<PUBLIC_HOST>/api/v1/public/app-config
|
||||||
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
|
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
|
||||||
curl -sS -o /dev/null -w '%{http_code}\n' \
|
curl -sS -o /dev/null -w '%{http_code}\n' \
|
||||||
https://<PUBLIC_HOST>/internal/safety/v2/messages/check
|
https://<PUBLIC_HOST>/internal/safety/v2/messages/check
|
||||||
@@ -383,7 +403,12 @@ openssl s_client -connect <PUBLIC_HOST>:443 -servername <PUBLIC_HOST> \
|
|||||||
-verify_hostname <PUBLIC_HOST> -verify_return_error </dev/null
|
-verify_hostname <PUBLIC_HOST> -verify_return_error </dev/null
|
||||||
```
|
```
|
||||||
|
|
||||||
Ожидается `308`, public endpoints `200`, internal route `404`, valid chain.
|
Ожидается `308`, public endpoints `200`, повторный app-config `304`, internal
|
||||||
|
route `404`, valid chain. В app-config проверьте `mobile_update`: Google Play и
|
||||||
|
RuStore включены (`latest_build=2`, `minimum_build=1`, `latest_version=1.0.1`,
|
||||||
|
RuStore URL `https://www.rustore.ru/catalog/app/ru.han.chat`), App Store
|
||||||
|
отключён, его release-поля равны `null`; пустой `release_notes` у всех политик
|
||||||
|
также возвращается как `null`.
|
||||||
Проверьте guest/auth PKCE/OTP, SMS mode, Open Lines, idempotency, ownership,
|
Проверьте guest/auth PKCE/OTP, SMS mode, Open Lines, idempotency, ownership,
|
||||||
rate limits, WS reconciliation, S3 quarantine/promote/deny и Safety v2
|
rate limits, WS reconciliation, S3 quarantine/promote/deny и Safety v2
|
||||||
allow/deny/pending/timeout. Safety status `stub` не принимается.
|
allow/deny/pending/timeout. Safety status `stub` не принимается.
|
||||||
@@ -427,11 +452,25 @@ staging, выполнять config test и HUP.
|
|||||||
5xx/auth/Safety/PG/Redis/OOM/disk/OTEL queue/TLS. Отправьте только fake canary
|
5xx/auth/Safety/PG/Redis/OOM/disk/OTEL queue/TLS. Отправьте только fake canary
|
||||||
token/PII markers и докажите их отсутствие в logs/traces.
|
token/PII markers и докажите их отсутствие в logs/traces.
|
||||||
|
|
||||||
|
В SigNoz один canary request должен дать ровно по одному application log от
|
||||||
|
nginx/API/Bitrix, а `trace_id`/`span_id` application logs должны открывать
|
||||||
|
соответствующий span. Platform logs ищутся по `host.name`, `service.name` и
|
||||||
|
временному окну. Проверьте отсутствие `unknown-container`, дублей Python logs,
|
||||||
|
логов обоих Collector и экспоненциального роста ingest. После restart host
|
||||||
|
Collector старые записи не должны replay-иться: offsets сохраняются.
|
||||||
|
|
||||||
|
KESL 12.4 устанавливается и принимается только по отдельному операторскому
|
||||||
|
runbook `deployment/kesl/RUNBOOK.KESL.ru.md`. Не совмещайте установку,
|
||||||
|
полную/контейнерную антивирусную проверку или изменение File Threat Protection
|
||||||
|
с deploy, миграциями, PG backup, TLS renewal и перезапуском Docker/стека.
|
||||||
|
Изменение политики KESL не является частью обычного application release.
|
||||||
|
|
||||||
Перед reboot проверьте admin SSH и provider console:
|
Перед reboot проверьте admin SSH и provider console:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
systemctl is-enabled docker.service han-chat-docker-firewall.service \
|
systemctl is-enabled docker.service han-chat-docker-firewall.service \
|
||||||
han-secrets@production.service han-stack@production.service certbot.timer
|
han-secrets@production.service han-stack@production.service \
|
||||||
|
han-host-otel-collector@production.service certbot.timer
|
||||||
systemctl reboot
|
systemctl reboot
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -452,10 +491,13 @@ systemctl daemon-reload
|
|||||||
systemctl restart han-secrets@production.service
|
systemctl restart han-secrets@production.service
|
||||||
/opt/han-chat/current/backend/deployment/preflight.sh
|
/opt/han-chat/current/backend/deployment/preflight.sh
|
||||||
systemctl restart han-stack@production.service
|
systemctl restart han-stack@production.service
|
||||||
|
systemctl restart han-host-otel-collector@production.service
|
||||||
```
|
```
|
||||||
|
|
||||||
Повторите smoke и зафиксируйте digests. Не удаляйте current/previous release,
|
Повторите smoke и зафиксируйте digests. Не удаляйте current/previous release,
|
||||||
active images, evidence или volumes. После incompatible migration используйте
|
active images, evidence, volumes или `/var/lib/han-otel`: offsets/queue должны
|
||||||
|
пережить rollback. Если previous release не содержит совместимого host config,
|
||||||
|
остановите host unit до возврата совместимой версии. После incompatible migration используйте
|
||||||
forward fix либо согласованный PG PITR + S3/Bitrix reconciliation в maintenance
|
forward fix либо согласованный PG PITR + S3/Bitrix reconciliation в maintenance
|
||||||
window; Redis восстанавливается пустым и прогревается из PG.
|
window; Redis восстанавливается пустым и прогревается из PG.
|
||||||
|
|
||||||
|
|||||||
@@ -47,5 +47,23 @@ settings:
|
|||||||
notification.expire_job.run_at: {type: string, value: "00:01", public: false}
|
notification.expire_job.run_at: {type: string, value: "00:01", public: false}
|
||||||
notification.upload_draft.ttl_days: {type: integer, value: 7, public: false}
|
notification.upload_draft.ttl_days: {type: integer, value: 7, public: false}
|
||||||
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
|
ux.session.idle_timeout_minutes: {type: integer, value: 30, public: true}
|
||||||
|
mobile_update.google_play.enabled: {type: boolean, value: true, public: true}
|
||||||
|
mobile_update.google_play.latest_build: {type: integer, value: 2, public: true}
|
||||||
|
mobile_update.google_play.minimum_build: {type: integer, value: 1, public: true}
|
||||||
|
mobile_update.google_play.latest_version: {type: string, value: "1.0.1", public: true}
|
||||||
|
mobile_update.google_play.store_url: {type: string, value: "https://play.google.com/store/apps/details?id=ru.han.chat", public: true}
|
||||||
|
mobile_update.google_play.release_notes: {type: string, value: "", public: true}
|
||||||
|
mobile_update.rustore.enabled: {type: boolean, value: true, public: true}
|
||||||
|
mobile_update.rustore.latest_build: {type: integer, value: 2, public: true}
|
||||||
|
mobile_update.rustore.minimum_build: {type: integer, value: 1, public: true}
|
||||||
|
mobile_update.rustore.latest_version: {type: string, value: "1.0.1", public: true}
|
||||||
|
mobile_update.rustore.store_url: {type: string, value: "https://www.rustore.ru/catalog/app/ru.han.chat", public: true}
|
||||||
|
mobile_update.rustore.release_notes: {type: string, value: "", public: true}
|
||||||
|
mobile_update.app_store.enabled: {type: boolean, value: false, public: true}
|
||||||
|
mobile_update.app_store.latest_build: {type: integer, value: null, public: true}
|
||||||
|
mobile_update.app_store.minimum_build: {type: integer, value: null, public: true}
|
||||||
|
mobile_update.app_store.latest_version: {type: string, value: "", public: true}
|
||||||
|
mobile_update.app_store.store_url: {type: string, value: "", public: true}
|
||||||
|
mobile_update.app_store.release_notes: {type: string, value: "", public: true}
|
||||||
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
|
security.cors.allowed_origins: {type: string_list, value: "https://chat.example.ru", public: false}
|
||||||
security.public_cache.max_age_seconds: {type: integer, value: 3600, public: false}
|
security.public_cache.max_age_seconds: {type: integer, value: 60, public: false}
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=HAN Chat VM1 host telemetry collector (%i)
|
||||||
|
Requires=han-secrets@%i.service
|
||||||
|
After=docker.service han-secrets@%i.service network-online.target
|
||||||
|
Wants=docker.service network-online.target
|
||||||
|
ConditionPathIsExecutable=/usr/local/bin/otelcol-contrib
|
||||||
|
ConditionPathIsDirectory=/opt/han-chat/current/backend
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=root
|
||||||
|
Group=root
|
||||||
|
EnvironmentFile=/etc/han/vm1.env
|
||||||
|
ExecStartPre=/usr/local/bin/otelcol-contrib validate --config=/opt/han-chat/current/backend/deployment/observability/otel-host-collector.yaml
|
||||||
|
ExecStart=/usr/local/bin/otelcol-contrib --config=/opt/han-chat/current/backend/deployment/observability/otel-host-collector.yaml
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=10s
|
||||||
|
TimeoutStopSec=30
|
||||||
|
UMask=0077
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectKernelLogs=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
ProtectClock=yes
|
||||||
|
RestrictRealtime=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
CapabilityBoundingSet=
|
||||||
|
AmbientCapabilities=
|
||||||
|
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
|
||||||
|
ReadOnlyPaths=/opt/han-chat/current/backend/deployment/observability
|
||||||
|
ReadOnlyPaths=/var/lib/docker/containers
|
||||||
|
ReadOnlyPaths=/var/log/journal
|
||||||
|
ReadOnlyPaths=/run/log/journal
|
||||||
|
ReadOnlyPaths=/run/han-chat/secrets
|
||||||
|
ReadWritePaths=/var/lib/han-otel/host-collector
|
||||||
|
LimitCORE=0
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
# Карта доказательств АВЗ.1 и АВЗ.2 для ВМ1
|
||||||
|
|
||||||
|
Форма заполняется оператором после выполнения `RUNBOOK.KESL.ru.md`. Она не
|
||||||
|
должна содержать activation code, ключи, токены, DSN, environment, содержимое
|
||||||
|
secret-файлов, персональные данные или тестовый файл EICAR.
|
||||||
|
|
||||||
|
Ниже зафиксирован **тестовый пилот** на `devhanapp` (4 ГБ RAM). Это не
|
||||||
|
автоматическая приёмка боевой среды.
|
||||||
|
|
||||||
|
## 1. Идентификация изменения
|
||||||
|
|
||||||
|
- Change ID: тестовый пилот KESL 12.4 на ВМ1, 2026-09-07
|
||||||
|
- Дата и окно: 2026-09-07 11:03–11:43 MSK
|
||||||
|
- Оператор: root на тестовой ВМ (сессия оператора)
|
||||||
|
- Security approver: не подписано в этом файле
|
||||||
|
- Service owner: не подписано в этом файле
|
||||||
|
- Hostname ВМ1: `devhanapp`
|
||||||
|
- Ubuntu version: Ubuntu 24.04.4 LTS
|
||||||
|
- Kernel version: `6.8.0-138-generic` x86_64
|
||||||
|
- KESL package/version: `kesl` `12.4.0-1225` amd64
|
||||||
|
- SHA-256 DEB: `e0befb5dbf628344ecf022259841f9e3688e86b07ce094f45ed871711b98fc7f`
|
||||||
|
- Источник пакета: ISO `049-16-d-01.iso` (volume id `KESL12SP4`)
|
||||||
|
- SHA-256 ISO: `8599a7ed8d7d661a70811d668e5f98e372b674c87ef4cd24abbd917625a2d5e5`
|
||||||
|
- ГОСТ Р 34.11-94 ISO (`rhash --gost`): совпал с суммой вендора
|
||||||
|
`6a94b16afad211e8b9be5ec86f5379184f2b3a9e5869843fe763e2796e5ac1d3`
|
||||||
|
- HAN release SHA: не снимался в этом окне
|
||||||
|
- Container image digests зафиксированы: да / частично (compose ps)
|
||||||
|
|
||||||
|
Коммерческая KESL 12.4 не должна быть обозначена как сертифицированная ФСТЭК
|
||||||
|
сборка. Решение о допустимости коммерческой версии и ссылка на модель угроз:
|
||||||
|
|
||||||
|
- Решение: коммерческая 12.4 выбрана из‑за Ubuntu 24.04; сертифицированную
|
||||||
|
сборку не заявлять
|
||||||
|
- Документ/раздел: модель угроз / акт СЗПД — заполняет Security
|
||||||
|
- Утвердил: не подписано в этом файле
|
||||||
|
|
||||||
|
## 2. Входной baseline
|
||||||
|
|
||||||
|
- Все steady-state контейнеры healthy/running: да (13 running / 15 total,
|
||||||
|
2 oneshot)
|
||||||
|
- Restart count: без новых restart в окне пилота
|
||||||
|
- Public smoke: в этом окне не повторялся
|
||||||
|
- Negative port probes: в этом окне не повторялись
|
||||||
|
- CPU: контейнеры < 4% кроме кратких всплесков
|
||||||
|
- Available RAM: до KESL ~1.9 ГБ; после пилота ~2.3 ГБ (часть ушла в swap)
|
||||||
|
- Swap activity: до установки ~524 КиБ; после ~675 МБ
|
||||||
|
- Disk free: после growpart 19 ГБ из 30 ГБ
|
||||||
|
- IO wait: не снимался отдельно
|
||||||
|
- API p95: не снимался
|
||||||
|
- Redis latency / blocked clients: не снимались
|
||||||
|
- OTEL queue: не снималась
|
||||||
|
- Открытые до установки проблемы: публичный SSH 22; RAM 4 ГБ ниже
|
||||||
|
production-gate
|
||||||
|
|
||||||
|
Stop conditions и численные пороги утверждены:
|
||||||
|
|
||||||
|
- p95/Redis: для теста не применялись
|
||||||
|
- available RAM/swap: stop при available < 512 МБ; не достигнуто
|
||||||
|
- IO wait: не применялся
|
||||||
|
- disk: свободно > 10 ГБ
|
||||||
|
- health/restarts: новых unhealthy не было
|
||||||
|
|
||||||
|
## 3. АВЗ.1 — реализация антивирусной защиты
|
||||||
|
|
||||||
|
Нормативная опора:
|
||||||
|
|
||||||
|
- Приказ ФСТЭК России № 21, приложение, АВЗ.1 — «Реализация
|
||||||
|
антивирусной защиты»;
|
||||||
|
- пункт 8.6 — обнаружение вредоносных программ/информации и реагирование.
|
||||||
|
|
||||||
|
Необходимые доказательства:
|
||||||
|
|
||||||
|
- [x] `kesl` active.
|
||||||
|
- [x] Лицензия действительна (`The key is valid`, subscription active).
|
||||||
|
- [x] File Threat Protection (ID 1) имеет состояние `Started`.
|
||||||
|
- [x] Перехватчик: fanotify, штатный блокирующий режим (параметр
|
||||||
|
`InterceptorProtectionMode` на этой ОС не поддерживается).
|
||||||
|
- [x] ActionOnThreat = `DisinfectDeleteIfNotPossible` либо иное утверждённое
|
||||||
|
блокирующее/лечащее действие.
|
||||||
|
- [x] ScanArchived = `No` для real-time защиты.
|
||||||
|
- [x] Исключения ограничены тремя утверждёнными hot-data mountpoint.
|
||||||
|
- [x] Контролируемый EICAR заблокирован/обезврежен/помещён в карантин.
|
||||||
|
- [x] Событие EICAR зарегистрировано в журнале KESL.
|
||||||
|
- [x] После теста EICAR отсутствует вне карантина и тестовый каталог удалён.
|
||||||
|
- [ ] Public smoke и health после включения Block успешны.
|
||||||
|
(health контейнеров подтверждён; внешний smoke не запускался)
|
||||||
|
- [x] UFW и `HAN-CHAT-DOCKER` не изменены.
|
||||||
|
- [ ] За 24 часа нет новых restart/OOM/5xx и неприемлемой деградации.
|
||||||
|
|
||||||
|
Артефакты без секретов:
|
||||||
|
|
||||||
|
- `systemctl is-active kesl`: `active`
|
||||||
|
- `kesl-control --app-info`: 12.4.0.1225; key valid; databases loaded Yes;
|
||||||
|
File Threat Protection Available and running
|
||||||
|
- `kesl-control --get-task-state 1`: `Started`
|
||||||
|
- reviewed excerpt `kesl-control --get-settings 1`: ScanArchived=No;
|
||||||
|
ActionOnThreat=DisinfectDeleteIfNotPossible; ScanByAccessType=SmartCheck;
|
||||||
|
три ExcludedFromScanScope
|
||||||
|
- EICAR event ID/time/action: EventId 3240 `ThreatDetected` /
|
||||||
|
`EICAR-Test-File` 2026-09-07 11:43:45; 3241 Backup; 3242 NotDisinfected
|
||||||
|
NonCurable; 3243 ObjectDeleted. Сработало **Scan_File (ODS)**, не OAS
|
||||||
|
on-access: файл успели записать на диск до scan-file.
|
||||||
|
- smoke result/time: не выполнялся
|
||||||
|
- firewall comparison: `HAN-CHAT-DOCKER` без новых правил KESL
|
||||||
|
- 24h resource comparison: не выдержан
|
||||||
|
|
||||||
|
Вывод по АВЗ.1: **реализована на тестовой ВМ** (обнаружение + backup +
|
||||||
|
удаление). Ограничение: on-access не перехватил создание EICAR; реакция
|
||||||
|
доказана on-demand. Для боя повторить on-access (запись + чтение файла)
|
||||||
|
на ресурсах ≥16 ГБ.
|
||||||
|
|
||||||
|
## 4. АВЗ.2 — обновление баз признаков вредоносных программ
|
||||||
|
|
||||||
|
Нормативная опора:
|
||||||
|
|
||||||
|
- Приказ ФСТЭК России № 21, приложение, АВЗ.2 — «Обновление базы данных
|
||||||
|
признаков вредоносных компьютерных программ (вирусов)».
|
||||||
|
|
||||||
|
Необходимые доказательства:
|
||||||
|
|
||||||
|
- [x] Update (ID 6) завершилась успешно.
|
||||||
|
- [x] Базы загружены.
|
||||||
|
- [x] Дата выпуска баз актуальна на момент проверки.
|
||||||
|
- [x] Расписание Update = `Hourly` (`StartTime=2026/Sep/07 11:32:38;1`).
|
||||||
|
- [ ] Утверждён alert/регламент на ошибку и устаревание баз.
|
||||||
|
- [ ] Назначен ответственный за ежедневный контроль.
|
||||||
|
- [ ] Проверено успешное автоматическое обновление после ручного запуска.
|
||||||
|
(ручной Update успешен; первый hourly цикл ещё не наблюдался)
|
||||||
|
|
||||||
|
Артефакты без секретов:
|
||||||
|
|
||||||
|
- `kesl-control --app-info`: databases loaded Yes; last release
|
||||||
|
2026-09-07 11:25:00
|
||||||
|
- `kesl-control --get-task-state 6`: Stopped после успешного ручного запуска
|
||||||
|
- `kesl-control --get-schedule 6`: RuleType=Hourly; interval 1 hour
|
||||||
|
- время последнего успешного автоматического Update: не наблюдалось
|
||||||
|
- ссылка на alert/регламент: не создан
|
||||||
|
- ответственный: не назначен
|
||||||
|
|
||||||
|
Вывод по АВЗ.2: **реализована на тестовой ВМ** (ручное обновление +
|
||||||
|
почасовое расписание). Для боя нужны наблюдаемый hourly цикл и alert на сбой.
|
||||||
|
|
||||||
|
## 5. Связанные меры
|
||||||
|
|
||||||
|
### РСБ.1–3, РСБ.7
|
||||||
|
|
||||||
|
- [x] Определены события: detection, remediation/quarantine, component stop,
|
||||||
|
update failure, stale bases, license failure.
|
||||||
|
(фиксируются в журнале KESL; пример ThreatDetected/ObjectDeleted)
|
||||||
|
- [x] Определён состав полей: time, host, component/task, threat, object,
|
||||||
|
action, result, severity.
|
||||||
|
- [ ] Определены срок и место хранения.
|
||||||
|
(по умолчанию `/var/opt/kaspersky/kesl/private/storage/events.db`)
|
||||||
|
- [x] Доступ к журналу ограничен; изменение/удаление контролируется.
|
||||||
|
(root-only `kesl-control -E`)
|
||||||
|
- [ ] Экспорт в syslog/SIEM включён либо документирован локальный контроль.
|
||||||
|
|
||||||
|
Ссылка на регламент и настройки: локальный журнал KESL; SIEM не подключался.
|
||||||
|
|
||||||
|
### АНЗ.2
|
||||||
|
|
||||||
|
- [x] Контролируется версия и жизненный цикл самого KESL, а не только баз.
|
||||||
|
(12.4.0.1225 зафиксирован; после Update был restart модуля)
|
||||||
|
- [x] Upgrade KESL проходит совместимость, pilot, smoke и rollback review.
|
||||||
|
(процедура в `RUNBOOK.KESL.ru.md`)
|
||||||
|
- [x] Обновление kernel/Docker вызывает повторную проверку совместимости.
|
||||||
|
|
||||||
|
Ссылка на регламент: `deployment/kesl/RUNBOOK.KESL.ru.md`
|
||||||
|
|
||||||
|
## 6. Исключения и компенсирующие проверки
|
||||||
|
|
||||||
|
Для каждого исключения укажите точный фактический mountpoint, владельца,
|
||||||
|
причину, риск, компенсирующую проверку и дату пересмотра.
|
||||||
|
|
||||||
|
### Redis data
|
||||||
|
|
||||||
|
- Mountpoint: `/var/lib/docker/volumes/han-chat_redis-data/_data`
|
||||||
|
- Причина: AOF/RDB, latency-sensitive write path.
|
||||||
|
- Компенсация: host File Threat Protection вне volume; on-demand scan-file
|
||||||
|
release; container monitoring недоступен по лицензии
|
||||||
|
- Владелец: Operations ВМ1
|
||||||
|
- Review date: при переносе на боевые ресурсы / не позднее 1 месяца
|
||||||
|
|
||||||
|
### OTEL queue
|
||||||
|
|
||||||
|
- Mountpoint: `/var/lib/docker/volumes/han-chat_otel-queue/_data`
|
||||||
|
- Причина: persistent high-churn telemetry queue.
|
||||||
|
- Компенсация: как выше
|
||||||
|
- Владелец: Operations ВМ1
|
||||||
|
- Review date: при переносе на боевые ресурсы / не позднее 1 месяца
|
||||||
|
|
||||||
|
### nginx cache
|
||||||
|
|
||||||
|
- Mountpoint: `/var/lib/docker/volumes/han-chat_nginx-cache/_data`
|
||||||
|
- Причина: regenerable high-churn cache.
|
||||||
|
- Компенсация: как выше
|
||||||
|
- Владелец: Operations ВМ1
|
||||||
|
- Review date: при переносе на боевые ресурсы / не позднее 1 месяца
|
||||||
|
|
||||||
|
Иных исключений нет / перечислить отдельно с утверждением Security:
|
||||||
|
иных исключений нет. `/var/lib/docker` целиком не исключался.
|
||||||
|
|
||||||
|
Лицензионное ограничение: Container Monitoring / Container Scan недоступны.
|
||||||
|
|
||||||
|
## 7. Проверка отката
|
||||||
|
|
||||||
|
- [x] Команда `kesl-control --stop-task 1` проверена документально.
|
||||||
|
- [x] Процедура остановки KESL доступна break-glass admin.
|
||||||
|
- [x] Процедура `apt-get purge kesl` проверена по документации текущей версии.
|
||||||
|
- [x] Откат не использует `docker compose down -v` и не удаляет volumes.
|
||||||
|
- [x] После отката предусмотрены smoke, health и firewall checks.
|
||||||
|
- [x] Reboot выполняется только отдельным согласованным окном при необходимости.
|
||||||
|
|
||||||
|
Результат rehearsal/desk check: полный uninstall в этом окне не выполнялся;
|
||||||
|
рабочий откат FTP — `--stop-task 1`.
|
||||||
|
|
||||||
|
## 8. Итоговая приёмка
|
||||||
|
|
||||||
|
- АВЗ.1: **принято для тестовой ВМ**, с оговоркой on-access.
|
||||||
|
- АВЗ.2: **принято для тестовой ВМ**, с оговоркой ненаблюдавшегося hourly
|
||||||
|
цикла и отсутствия alert.
|
||||||
|
- Ограничения/остаточные риски:
|
||||||
|
- 4 ГБ RAM, swap ~675 МБ; профиль `ScanMemoryLimit=512` /
|
||||||
|
`MaxMemory=1024MB` **не переносить** в production;
|
||||||
|
- коммерческая, не сертифицированная ФСТЭК сборка;
|
||||||
|
- KSN выключен;
|
||||||
|
- сетевые компоненты KESL остановлены сознательно;
|
||||||
|
- container monitoring недоступен по лицензии;
|
||||||
|
- OAS не удалил EICAR в момент записи; реакция через ODS;
|
||||||
|
- внешний smoke и 24h наблюдение не закрыты;
|
||||||
|
- публичный SSH 22 остаётся открытым (не KESL).
|
||||||
|
- Следующий review: перед боевым внедрением после увеличения RAM ≥16 ГБ.
|
||||||
|
- Operations, ФИО/подпись/дата:
|
||||||
|
- Security, ФИО/подпись/дата:
|
||||||
|
- Service owner, ФИО/подпись/дата:
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
# KESL 12.4 standalone на production ВМ1
|
||||||
|
|
||||||
|
Это операторский runbook для поэтапного внедрения Kaspersky Endpoint Security
|
||||||
|
для Linux 12.4 на Ubuntu 24.04 ВМ1. Команды выполняются только персональной
|
||||||
|
ролью `admin` через `sudo`; repository automation этот runbook не запускает.
|
||||||
|
|
||||||
|
Цель: реализовать АВЗ.1 (обнаружение и реагирование) и АВЗ.2 (обновление баз)
|
||||||
|
без деградации Docker-стека HAN Chat.
|
||||||
|
|
||||||
|
Не выполняйте установку одновременно с deploy, миграциями, backup, ротацией
|
||||||
|
секретов, TLS renewal, перезапуском Docker или host reboot.
|
||||||
|
|
||||||
|
Официальная документация:
|
||||||
|
|
||||||
|
- [программные требования](https://support.kaspersky.ru/kes-for-linux/12.4.0/197645);
|
||||||
|
- [краткое руководство по установке](https://support.kaspersky.ru/kes-for-linux/12.4.0/install/16099);
|
||||||
|
- [автоматическая первоначальная настройка](https://support.kaspersky.ru/kes-for-linux/12.4.0/197909);
|
||||||
|
- [параметры autoinstall.ini](https://support.kaspersky.ru/kes-for-linux/12.4.0/197593);
|
||||||
|
- [ограничение CPU и памяти](https://support.kaspersky.ru/kes-for-linux/12.4.0/264979);
|
||||||
|
- [настройка File Threat Protection](https://support.kaspersky.ru/kes-for-linux/12.4.0/248490);
|
||||||
|
- [проверка контейнеров](https://support.kaspersky.ru/kes-for-linux/12.4.0/197612);
|
||||||
|
- [удаление DEB-пакета](https://support.kaspersky.ru/kes-for-linux/12.4.0/197596).
|
||||||
|
|
||||||
|
## 0. Участники, входные данные и stop conditions
|
||||||
|
|
||||||
|
До окна работ зафиксируйте:
|
||||||
|
|
||||||
|
- change ID, время окна, оператора, approver Security и on-call;
|
||||||
|
- hostname/IP ВМ1, фактическую версию Ubuntu и ядра;
|
||||||
|
- точное имя, версию и SHA-256 полученного от Kaspersky DEB-пакета;
|
||||||
|
- источник пакета и лицензию/код активации;
|
||||||
|
- текущий release SHA и image digests;
|
||||||
|
- место хранения evidence вне immutable release.
|
||||||
|
|
||||||
|
Значения лицензии, activation code, proxy credentials и секреты HAN нельзя
|
||||||
|
помещать в репозиторий, shell history, журналы или evidence.
|
||||||
|
|
||||||
|
Немедленно остановитесь, если:
|
||||||
|
|
||||||
|
- ОС, архитектура или ядро отсутствуют в матрице KESL 12.4;
|
||||||
|
- уже установлен другой антивирус или неизвестная версия KESL;
|
||||||
|
- свободно менее 4 ГБ или нет рабочего swap;
|
||||||
|
- до установки есть unhealthy/restarting контейнеры, 5xx или дефицит ресурсов;
|
||||||
|
- не совпал SHA-256 пакета;
|
||||||
|
- после этапа выросли restart count, 5xx, Redis latency/blocked clients,
|
||||||
|
OTEL queue или host IO wait сверх согласованного порога;
|
||||||
|
- File Threat Protection изменил UFW/iptables или доступность портов;
|
||||||
|
- базы не загрузились либо лицензия недействительна.
|
||||||
|
|
||||||
|
Рекомендуемые пороги отката для пилота (утвердить до установки):
|
||||||
|
|
||||||
|
- новый unhealthy/restart любого steady-state сервиса;
|
||||||
|
- публичный smoke не проходит два запуска подряд;
|
||||||
|
- host available memory менее 2 ГБ или начинается устойчивый swap-in/swap-out;
|
||||||
|
- IO wait более 10% в течение 5 минут;
|
||||||
|
- p95 API/Redis latency выросла более чем на 20% от baseline в течение 10 минут;
|
||||||
|
- свободное место уменьшилось ниже 10 ГБ или ниже 15%.
|
||||||
|
|
||||||
|
### 0.1. Исключение только для constrained test VM
|
||||||
|
|
||||||
|
На тестовой ВМ допускается пилот с 4 ГБ RAM без увеличения памяти только по
|
||||||
|
явному решению владельца среды. Это не отменяет production-gate и не является
|
||||||
|
обоснованием для переноса той же конфигурации в боевую среду.
|
||||||
|
|
||||||
|
Обязательные ограничения такого пилота:
|
||||||
|
|
||||||
|
- provider snapshot/console и оператор доступны до начала;
|
||||||
|
- `ScanMemoryLimit=512`, `MaxMemory=1024MB`;
|
||||||
|
- `UseOnDemandCPULimit=Yes`, `OnDemandCPULimit=15`;
|
||||||
|
- не запускать full filesystem scan и проверку архивов;
|
||||||
|
- ODS и ContainerScan выполнять по одному объекту, не одновременно;
|
||||||
|
- сначала Update и health, затем один ограниченный `scan-file`, затем File Threat
|
||||||
|
Protection; `InterceptorProtectionMode` на fanotify недоступен;
|
||||||
|
- остановить KESL при available memory менее 512 МБ, устойчивом swap IO,
|
||||||
|
появлении host/container OOM, restart или провале smoke;
|
||||||
|
- до режима `Block` требуется отдельное подтверждение стабильности.
|
||||||
|
|
||||||
|
Перед production-внедрением повторить sizing и baseline на боевых ресурсах;
|
||||||
|
test-профиль 512/1024 МБ автоматически не переносить.
|
||||||
|
|
||||||
|
## 1. Read-only preflight и baseline
|
||||||
|
|
||||||
|
Команды разделены на небольшие блоки: сохраните вывод каждого блока в
|
||||||
|
change record, предварительно проверив отсутствие секретов. Не публикуйте
|
||||||
|
полный `docker inspect`, Compose config или environment.
|
||||||
|
|
||||||
|
### 1.1. Host и совместимость
|
||||||
|
|
||||||
|
```sh
|
||||||
|
date -Is
|
||||||
|
hostnamectl
|
||||||
|
uname -a
|
||||||
|
dpkg --print-architecture
|
||||||
|
findmnt -no TARGET,SOURCE,FSTYPE,OPTIONS / /var/lib/docker /tmp 2>/dev/null
|
||||||
|
free -h
|
||||||
|
swapon --show
|
||||||
|
df -hT / /var/lib/docker /tmp
|
||||||
|
df -ih / /var/lib/docker /tmp
|
||||||
|
systemctl is-active docker fail2ban ufw
|
||||||
|
dpkg-query -W -f='${Package}\t${Version}\t${Status}\n' \
|
||||||
|
kesl kesl-gui kav4fs 2>/dev/null || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверка проходит только для `amd64`/поддерживаемой архитектуры, Ubuntu 24.04
|
||||||
|
LTS и поддерживаемого KESL ядра. Требования Kaspersky — минимум 2 ГБ RAM,
|
||||||
|
1 ГБ swap и 4 ГБ свободного диска, но этого недостаточно для данной ВМ:
|
||||||
|
Compose-лимиты суммарно около 11,6 ГБ. При RAM менее 16 ГБ установка требует
|
||||||
|
отдельного решения владельца сервиса о доступном запасе.
|
||||||
|
|
||||||
|
### 1.2. Docker и приложение
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker info --format \
|
||||||
|
'driver={{.Driver}} root={{.DockerRootDir}} containers={{.Containers}} running={{.ContainersRunning}}'
|
||||||
|
/usr/local/sbin/han-vm1-compose ps
|
||||||
|
docker ps --format \
|
||||||
|
'table {{.Names}}\t{{.Status}}\t{{.Image}}'
|
||||||
|
docker stats --no-stream --format \
|
||||||
|
'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.BlockIO}}\t{{.PIDs}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Сохраните список и состояние именованных volumes без содержимого:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
for volume in redis-data otel-queue nginx-cache; do
|
||||||
|
docker volume ls --format '{{.Name}}' |
|
||||||
|
while IFS= read -r name; do
|
||||||
|
case "$name" in
|
||||||
|
*"$volume"*)
|
||||||
|
docker volume inspect --format \
|
||||||
|
'{{.Name}}\t{{.Mountpoint}}' "$name"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
В change record перенесите три фактических mountpoint. Не подставляйте
|
||||||
|
предполагаемый Compose prefix.
|
||||||
|
|
||||||
|
### 1.3. Health, edge и firewall
|
||||||
|
|
||||||
|
Под root на ВМ:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl --no-pager status \
|
||||||
|
han-secrets@production.service han-stack@production.service
|
||||||
|
iptables -S HAN-CHAT-DOCKER
|
||||||
|
iptables -L HAN-CHAT-DOCKER -n -v
|
||||||
|
ufw status verbose
|
||||||
|
journalctl --since '-30 min' --no-pager \
|
||||||
|
-u han-stack@production.service -p warning
|
||||||
|
```
|
||||||
|
|
||||||
|
С trusted external host выполните smoke из
|
||||||
|
`deployment/RUNBOOK.production.ru.md`, раздел 10. Если запускается repository
|
||||||
|
`deployment/scripts/smoke.sh`, он должен использовать штатный secret launcher;
|
||||||
|
не печатайте resolved environment.
|
||||||
|
|
||||||
|
Снимите из штатного observability baseline:
|
||||||
|
|
||||||
|
- API request rate, 5xx и p50/p95/p99 latency;
|
||||||
|
- Redis latency, blocked clients и memory;
|
||||||
|
- container restart/OOM count;
|
||||||
|
- host CPU, available RAM, swap, disk IO/IO wait;
|
||||||
|
- OTEL exporter failures и queue depth.
|
||||||
|
|
||||||
|
Без доступных baseline и rollback approver к установке не переходить.
|
||||||
|
|
||||||
|
## 2. Проверка пакета и подготовка
|
||||||
|
|
||||||
|
GUI на сервер не устанавливается. Дистрибутив и DEB копируются в root-only
|
||||||
|
staging `/root/kesl-install` и удаляются после приёмки. ISO в
|
||||||
|
`/var/lib/han-deploy/incoming` не распаковывается на месте и не остаётся
|
||||||
|
смонтированным после извлечения пакета.
|
||||||
|
|
||||||
|
Для тестовой ВМ ожидаемый ISO: `049-16-d-01.iso`, volume id `KESL12SP4`.
|
||||||
|
|
||||||
|
Вендор публикует контрольную сумму **ФИКС 2.0.2 / ГОСТ Р 34.11-94, программно**,
|
||||||
|
а не SHA-256. Это разные алгоритмы: оба дают 64 hex-символа, поэтому
|
||||||
|
`sha256sum --check` против суммы с сайта закономерно не совпадает.
|
||||||
|
|
||||||
|
Ожидаемая сумма вендора (ГОСТ Р 34.11-94):
|
||||||
|
|
||||||
|
```
|
||||||
|
6a94b16afad211e8b9be5ec86f5379184f2b3a9e5869843fe763e2796e5ac1d3
|
||||||
|
```
|
||||||
|
|
||||||
|
Фактический SHA-256 полученного ISO (для локального учёта, не для сверки с
|
||||||
|
сайтом ФИКС):
|
||||||
|
|
||||||
|
```
|
||||||
|
8599a7ed8d7d661a70811d668e5f98e372b674c87ef4cd24abbd917625a2d5e5
|
||||||
|
```
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ISO='/var/lib/han-deploy/incoming/049-16-d-01.iso'
|
||||||
|
SIG='/var/lib/han-deploy/incoming/049-16-d-01.sig'
|
||||||
|
|
||||||
|
ls -l "$ISO" "$SIG"
|
||||||
|
file "$ISO" "$SIG"
|
||||||
|
sha256sum "$ISO"
|
||||||
|
command -v rhash || true
|
||||||
|
openssl list -digest-algorithms 2>/dev/null | grep -i gost || true
|
||||||
|
```
|
||||||
|
|
||||||
|
Если доступен `rhash`, посчитайте ГОСТ и сравните с суммой вендора. ФИКС
|
||||||
|
может использовать другой набор S-блоков/порядок байт, поэтому проверьте
|
||||||
|
несколько вариантов:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rhash --gost "$ISO"
|
||||||
|
rhash --gost --gost-reverse "$ISO"
|
||||||
|
rhash --gost-cryptopro "$ISO"
|
||||||
|
rhash --gost-cryptopro --gost-reverse "$ISO"
|
||||||
|
```
|
||||||
|
|
||||||
|
Для ISO `049-16-d-01.iso` сумма вендора совпала с `rhash --gost`.
|
||||||
|
|
||||||
|
Подлинность принимается, если совпала ГОСТ-сумма **или** одновременно
|
||||||
|
выполнены все условия ниже:
|
||||||
|
|
||||||
|
- volume id ISO = `KESL12SP4`;
|
||||||
|
- внутри есть `kesl/kesl_12.4.0-1225_amd64.deb`;
|
||||||
|
- SHA-256 ISO зафиксирован в evidence;
|
||||||
|
- источник — официальный канал вендора.
|
||||||
|
|
||||||
|
Файл `.sig` без доверенного публичного ключа не заменяет эту проверку.
|
||||||
|
|
||||||
|
Только после совпадения хеша смонтируйте ISO только для чтения и найдите
|
||||||
|
`kesl_*_amd64.deb` без GUI:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
install -d -m 0700 -o root -g root /root/kesl-install /mnt/kesl-iso
|
||||||
|
mount -o ro,loop "$ISO" /mnt/kesl-iso
|
||||||
|
find /mnt/kesl-iso -type f \( -iname '*kesl*' -o -iname '*.deb' \) | sort
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидается имя вида `kesl_12.4.0-*_amd64.deb`. Не копируйте `kesl-gui`, i386,
|
||||||
|
arm64, RPM, KSC agent и Windows-пакеты. Если внутри только 12.0.x или нет
|
||||||
|
amd64 DEB — stop: для Ubuntu 24.04 нужен KESL 12.4.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
install -m 0600 -o root -g root \
|
||||||
|
<KESL_12_4_AMD64_DEB_FROM_ISO> /root/kesl-install/kesl.deb
|
||||||
|
dpkg-deb -f /root/kesl-install/kesl.deb Package Version Architecture
|
||||||
|
umount /mnt/kesl-iso
|
||||||
|
rmdir /mnt/kesl-iso
|
||||||
|
```
|
||||||
|
|
||||||
|
Не продолжайте при package name не `kesl`, неверной архитектуре или версии не
|
||||||
|
12.4.x.
|
||||||
|
|
||||||
|
Перед установкой сохраните только безопасные snapshots:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp -a /etc/docker/daemon.json /root/kesl-install/docker-daemon.before.json
|
||||||
|
iptables-save > /root/kesl-install/iptables.before
|
||||||
|
ufw status verbose > /root/kesl-install/ufw.before
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Установка с отключённой защитой
|
||||||
|
|
||||||
|
Установка изменяет host и выполняется только в maintenance window.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
apt-get install /root/kesl-install/kesl.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
Создайте `/root/kesl-install/autoinstall.ini` с mode `0600`. Значения EULA,
|
||||||
|
Privacy Policy и KSN должны быть осознанно согласованы с Security/Legal, а не
|
||||||
|
скопированы механически:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
KSVLA_MODE=No
|
||||||
|
ENDPOINT_AGENT_MODE=No
|
||||||
|
EULA_AGREED=<Yes_AFTER_APPROVAL>
|
||||||
|
PRIVACY_POLICY_AGREED=<Yes_AFTER_APPROVAL>
|
||||||
|
USE_KSN=<Yes_OR_No_AFTER_APPROVAL>
|
||||||
|
GROUP_CLEAN=Yes
|
||||||
|
LOCALE=ru_RU.UTF-8
|
||||||
|
INSTALL_LICENSE=None
|
||||||
|
UPDATER_SOURCE=KLServers
|
||||||
|
UPDATE_EXECUTE=No
|
||||||
|
KERNEL_SRCS_INSTALL=No
|
||||||
|
USE_GUI=No
|
||||||
|
CONFIGURE_SELINUX=No
|
||||||
|
DISABLE_PROTECTION=Yes
|
||||||
|
INTERCEPTOR_MODE=UseFanotify
|
||||||
|
ENABLE_TRACES_ON_FIRST_STARTUP=No
|
||||||
|
```
|
||||||
|
|
||||||
|
Для Ubuntu AppArmor значение `CONFIGURE_SELINUX=No` ожидаемо. `UseFanotify`
|
||||||
|
не требует сборки стороннего kernel module. Если выбран KSN, документируйте
|
||||||
|
передачу данных и правовое основание.
|
||||||
|
|
||||||
|
Первоначальная настройка:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod 0600 /root/kesl-install/autoinstall.ini
|
||||||
|
/opt/kaspersky/kesl/bin/kesl-setup.pl \
|
||||||
|
--autoinstall=/root/kesl-install/autoinstall.ini
|
||||||
|
echo "kesl-setup exit=$?"
|
||||||
|
# Exit 71 with INSTALL_LICENSE=None is expected: setup treats None as a
|
||||||
|
# code. Do not rerun setup. Continue if kesl.service is active and
|
||||||
|
# File_Threat_Protection is Stopped.
|
||||||
|
systemctl --no-pager status kesl
|
||||||
|
kesl-control --app-info
|
||||||
|
kesl-control --supported-tech-info
|
||||||
|
kesl-control --get-task-list
|
||||||
|
```
|
||||||
|
|
||||||
|
Активацию кодом выполняйте только после успешного `kesl-setup.pl` и до
|
||||||
|
загрузки баз. Код не помещайте в `autoinstall.ini`, репозиторий, evidence,
|
||||||
|
чат и историю shell:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
set +o history
|
||||||
|
unset HISTFILE
|
||||||
|
read -rsp 'KESL activation code: ' KESL_CODE; echo
|
||||||
|
kesl-control --add-active-key "$KESL_CODE"
|
||||||
|
unset KESL_CODE
|
||||||
|
set -o history
|
||||||
|
kesl-control -L --query
|
||||||
|
kesl-control --app-info
|
||||||
|
```
|
||||||
|
|
||||||
|
Нужен исходящий доступ к серверам активации Kaspersky. В выводе лицензии
|
||||||
|
оставьте только статус «ключ действителен» и срок; сам код не копируйте.
|
||||||
|
|
||||||
|
## 4. Ресурсные ограничения до первого scan
|
||||||
|
|
||||||
|
В KESL 12.4 `ScanMemoryLimit` по умолчанию равен 8192 МБ, а `MaxMemory=auto`
|
||||||
|
может разрешить до 50% доступной RAM. Для ВМ1 эти defaults не принимаются без
|
||||||
|
измерений.
|
||||||
|
|
||||||
|
Выберите значения по фактическому baseline:
|
||||||
|
|
||||||
|
- constrained test VM, 4 ГБ RAM: `ScanMemoryLimit=512`,
|
||||||
|
`MaxMemory=1024MB`, `OnDemandCPULimit=15`; только по разделу 0.1;
|
||||||
|
- 16 ГБ RAM: начните с `ScanMemoryLimit=1024`, `MaxMemory=2048MB`;
|
||||||
|
- 24–32 ГБ RAM: начните с `ScanMemoryLimit=2048`, `MaxMemory=4096MB`;
|
||||||
|
- иной размер: согласуйте значения; `ScanMemoryLimit` должен быть ниже
|
||||||
|
`MaxMemory`, а после резервирования KESL у приложения должен оставаться
|
||||||
|
исходный запас.
|
||||||
|
|
||||||
|
Сначала сохраните исходные настройки:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --get-app-settings \
|
||||||
|
--file /root/kesl-install/app-settings.before.ini
|
||||||
|
install -m 0600 -o root -g root \
|
||||||
|
/var/opt/kaspersky/kesl/common/kesl.ini \
|
||||||
|
/root/kesl-install/kesl.ini.before
|
||||||
|
```
|
||||||
|
|
||||||
|
Установите CPU limit для ODS/ContainerScan:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --set-app-settings \
|
||||||
|
UseOnDemandCPULimit=Yes OnDemandCPULimit=<15_FOR_TEST_OR_APPROVED_VALUE>
|
||||||
|
```
|
||||||
|
|
||||||
|
Для изменения `ScanMemoryLimit` и `MaxMemory` следуйте официальной процедуре:
|
||||||
|
остановите KESL, внесите значения в секцию `[General]` файла
|
||||||
|
`/var/opt/kaspersky/kesl/common/kesl.ini`, затем запустите KESL. Не заменяйте
|
||||||
|
файл целиком и не применяйте шаблон из репозитория как готовый конфиг.
|
||||||
|
|
||||||
|
После запуска проверьте:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl is-active kesl
|
||||||
|
kesl-control --get-app-settings
|
||||||
|
kesl-control --app-info
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Обновление баз — АВЗ.2
|
||||||
|
|
||||||
|
Запустите предустановленную задачу Update (ID 6) и дождитесь результата:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --get-settings 6
|
||||||
|
kesl-control --get-schedule 6
|
||||||
|
kesl-control --start-task 6 -W
|
||||||
|
kesl-control --get-task-state 6
|
||||||
|
kesl-control --app-info
|
||||||
|
```
|
||||||
|
|
||||||
|
Проверьте действующую лицензию, `Базы приложения загружены: Да`, свежую дату
|
||||||
|
выпуска баз и успешное завершение Update. Затем задайте почасовой запуск:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
START="$(LC_ALL=C date +'%Y/%b/%d %H:%M:%S;1')"
|
||||||
|
kesl-control --set-schedule 6 \
|
||||||
|
RuleType=Hourly \
|
||||||
|
"StartTime=${START}" \
|
||||||
|
RunMissedStartRules=No \
|
||||||
|
RandomInterval=0
|
||||||
|
kesl-control --get-schedule 6
|
||||||
|
```
|
||||||
|
|
||||||
|
Для 12.4 одного `RuleType=Hourly` недостаточно: нужен `StartTime` вида
|
||||||
|
`2026/Sep/07 12:00:00;1` (английское имя месяца, интервал 1 час). `LC_ALL=C`
|
||||||
|
обязателен, иначе локаль `ru_RU` подставит русское имя месяца. До успешного
|
||||||
|
автообновления АВЗ.2 не принимается.
|
||||||
|
|
||||||
|
Сразу после обновления повторите разделы 1.2–1.3. При деградации выполните
|
||||||
|
rollback из раздела 11.
|
||||||
|
|
||||||
|
## 6. Исключения hot-data
|
||||||
|
|
||||||
|
Исключения создаются только после получения фактических mountpoint в разделе
|
||||||
|
1.2. Разрешены три области:
|
||||||
|
|
||||||
|
- `<REDIS_DATA_MOUNTPOINT>` — AOF/RDB;
|
||||||
|
- `<OTEL_QUEUE_MOUNTPOINT>` — persistent telemetry queue;
|
||||||
|
- `<NGINX_CACHE_MOUNTPOINT>` — regenerable cache.
|
||||||
|
|
||||||
|
До изменения экспортируйте параметры:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --get-settings 1 \
|
||||||
|
--file /root/kesl-install/file-threat.before.ini
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавьте обычные исключения File Threat Protection:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --set-settings 1 \
|
||||||
|
--add-exclusion <REDIS_DATA_MOUNTPOINT>
|
||||||
|
kesl-control --set-settings 1 \
|
||||||
|
--add-exclusion <OTEL_QUEUE_MOUNTPOINT>
|
||||||
|
kesl-control --set-settings 1 \
|
||||||
|
--add-exclusion <NGINX_CACHE_MOUNTPOINT>
|
||||||
|
kesl-control --get-settings 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Не исключать:
|
||||||
|
|
||||||
|
- весь `/var/lib/docker`, `/var/lib/docker/overlay2` или все volumes;
|
||||||
|
- `/opt/han-chat/releases` и `/opt/han-chat/current`;
|
||||||
|
- `/var/lib/han-deploy/incoming`;
|
||||||
|
- `/etc/han`, `/run/han-chat`, `/etc/letsencrypt`;
|
||||||
|
- `/tmp`, `/var/tmp`, `/root` или весь filesystem.
|
||||||
|
|
||||||
|
Обычное исключение из scan может не исключить файловый перехват. Не создавайте
|
||||||
|
bind mounts и не добавляйте `ExcludedMountPoint` в первой итерации. Это
|
||||||
|
допустимо только если измерена деградация и Security письменно принял
|
||||||
|
компенсацию плановой проверкой/container scan.
|
||||||
|
|
||||||
|
## 7. Пилот задач проверки
|
||||||
|
|
||||||
|
Убедитесь, что все ODS/ContainerScan schedules, кроме Update, пока ручные:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --get-task-list
|
||||||
|
kesl-control --get-schedule 2
|
||||||
|
kesl-control --get-schedule 18
|
||||||
|
kesl-control --set-schedule 2 RuleType=Manual
|
||||||
|
kesl-control --set-schedule 18 RuleType=Manual
|
||||||
|
```
|
||||||
|
|
||||||
|
Идентификаторы подтвердите через `--get-task-list`; не применяйте команды,
|
||||||
|
если тип задачи не совпадает.
|
||||||
|
|
||||||
|
### 7.1. Ограниченная on-demand проверка host
|
||||||
|
|
||||||
|
Сначала проверьте небольшой immutable release, не корень filesystem:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --scan-file /opt/han-chat/current/backend \
|
||||||
|
--action Inform
|
||||||
|
```
|
||||||
|
|
||||||
|
В первом пилоте действие `Inform` не изменяет release. Проверьте результат,
|
||||||
|
events, ресурсы и application health:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control -E --query -n 100 --reverse
|
||||||
|
kesl-control --get-statistic
|
||||||
|
/usr/local/sbin/han-vm1-compose ps
|
||||||
|
docker stats --no-stream
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2. Проверка контейнеров
|
||||||
|
|
||||||
|
Перед scan снимите список running containers. Проверяйте по одному объекту,
|
||||||
|
начиная с stateless/oneshot image, не Redis и не Keycloak:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker ps --format '{{.Names}}\t{{.Image}}'
|
||||||
|
kesl-control --get-settings 19
|
||||||
|
kesl-control --scan-container <STATELESS_CONTAINER_OR_IMAGE>
|
||||||
|
```
|
||||||
|
|
||||||
|
После успешного одиночного теста задачу `Container_Scan` (ID 18) можно
|
||||||
|
назначить еженедельно в согласованное время. Перед этим проверьте параметры:
|
||||||
|
по умолчанию `ContainerScanAction=StopContainerIfFailed`; production-контейнер
|
||||||
|
не должен останавливаться из-за технической ошибки сканирования. Итоговое
|
||||||
|
действие отдельно утверждает Security.
|
||||||
|
|
||||||
|
Container scan после deploy выполняется только после завершения smoke, а не
|
||||||
|
одновременно с pull/start/migrations.
|
||||||
|
|
||||||
|
## 8. Ступенчатое включение File Threat Protection — АВЗ.1
|
||||||
|
|
||||||
|
`DISABLE_PROTECTION=Yes` отключает компоненты после setup. До старта сохраните
|
||||||
|
параметры и убедитесь, что `ScanArchived=No`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --get-settings 1
|
||||||
|
kesl-control --get-task-state 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Для пилота запустите File Threat Protection. На Ubuntu 24.04 с `fanotify`
|
||||||
|
параметр `InterceptorProtectionMode` **не поддерживается** (`Unsupported
|
||||||
|
setting`): перехватчик работает в штатном блокирующем режиме на время
|
||||||
|
проверки. Отдельный `Notify` недоступен; откат — остановка задачи 1.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --start-task 1
|
||||||
|
kesl-control --get-task-state 1
|
||||||
|
kesl-control --app-info
|
||||||
|
```
|
||||||
|
|
||||||
|
Пилот длится минимум 15–30 минут на constrained test VM и 2–4 часа на
|
||||||
|
боевых ресурсах. Каждые 15 минут проверяйте метрики раздела 1, события KESL
|
||||||
|
и firewall. АВЗ.1 выполняется при `Started` + `ActionOnThreat=DisinfectDeleteIfNotPossible`.
|
||||||
|
|
||||||
|
Перед приёмкой:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --set-settings 1 \
|
||||||
|
ActionOnThreat=DisinfectDeleteIfNotPossible ScanArchived=No
|
||||||
|
kesl-control --get-task-state 1
|
||||||
|
kesl-control --app-info
|
||||||
|
```
|
||||||
|
|
||||||
|
При latency/swap/unhealthy выполните `kesl-control --stop-task 1`. Не
|
||||||
|
расширяйте исключения вслепую. Network Threat Protection, Firewall
|
||||||
|
Management, Web Threat Protection и Behavior Detection не запускайте.
|
||||||
|
|
||||||
|
## 9. Приёмочный тест и evidence
|
||||||
|
|
||||||
|
Тест EICAR выполняется только с письменным разрешением Security в отдельном
|
||||||
|
безопасном каталоге, не в release, volume, backup, secret или upload path.
|
||||||
|
Используйте официальную контрольную строку/файл с сайта EICAR/Kaspersky; этот
|
||||||
|
репозиторий намеренно не содержит тестовый образец.
|
||||||
|
|
||||||
|
До теста:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
install -d -m 0700 -o root -g root /root/kesl-eicar-test
|
||||||
|
date -Is
|
||||||
|
kesl-control --app-info
|
||||||
|
kesl-control --get-task-state 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Ожидается блокирование/лечение/карантин и событие KESL. Не прикладывайте сам
|
||||||
|
образец к evidence. Сохраните:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --app-info
|
||||||
|
kesl-control --get-task-list
|
||||||
|
kesl-control --get-settings 1
|
||||||
|
kesl-control --get-schedule 6
|
||||||
|
kesl-control -E --query -n 100 --reverse
|
||||||
|
```
|
||||||
|
|
||||||
|
Очистите тестовый каталог после подтверждения реакции. Копию EICAR в Backup
|
||||||
|
удалите точечно, не весь Backup:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control -B --query --reverse -n 20
|
||||||
|
kesl-control -B --mass-remove --query "DetectName == 'EICAR-Test-File'"
|
||||||
|
```
|
||||||
|
|
||||||
|
Заполните `deployment/kesl/EVIDENCE.AVZ.ru.md`.
|
||||||
|
|
||||||
|
## 10. Финальные проверки
|
||||||
|
|
||||||
|
На ВМ:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl is-active kesl docker \
|
||||||
|
han-secrets@production.service han-stack@production.service
|
||||||
|
kesl-control --app-info
|
||||||
|
kesl-control --get-task-state 1
|
||||||
|
kesl-control --get-task-state 6
|
||||||
|
/usr/local/sbin/han-vm1-compose ps
|
||||||
|
iptables -S HAN-CHAT-DOCKER
|
||||||
|
ufw status verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
С внешнего trusted host повторите production smoke и negative port probes.
|
||||||
|
Сравните метрики минимум за 24 часа. Не выполняйте reboot только ради KESL.
|
||||||
|
Если пакет/ядро явно запросили reboot, проведите его отдельным окном по
|
||||||
|
reboot gate основного production-runbook.
|
||||||
|
|
||||||
|
После приёмки удалите package/autoinstall и временные snapshots с ВМ только
|
||||||
|
после переноса разрешённого evidence:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rm -rf /root/kesl-install /root/kesl-eicar-test
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Rollback
|
||||||
|
|
||||||
|
### 11.1. До включения блокирующей защиты
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --stop-task 1 2>/dev/null || true
|
||||||
|
apt-get purge kesl
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart han-chat-docker-firewall.service
|
||||||
|
/usr/local/sbin/han-vm1-compose ps
|
||||||
|
iptables -S HAN-CHAT-DOCKER
|
||||||
|
ufw status verbose
|
||||||
|
```
|
||||||
|
|
||||||
|
Повторите smoke и resource checks. Docker и application stack без причины не
|
||||||
|
перезапускайте.
|
||||||
|
|
||||||
|
### 11.2. При инциденте после включения Block
|
||||||
|
|
||||||
|
Сначала минимально обратимое действие:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kesl-control --stop-task 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Если управление KESL не отвечает:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
systemctl stop kesl
|
||||||
|
```
|
||||||
|
|
||||||
|
Затем восстановите доступность и соберите события. Полный `apt-get purge kesl`
|
||||||
|
выполняйте только по решению change approver. Reboot — только если он требуется
|
||||||
|
для удаления/ядра и есть отдельное окно.
|
||||||
|
|
||||||
|
Нельзя выполнять `docker compose down -v`, удалять volumes, чистить Redis AOF,
|
||||||
|
пересоздавать VM или менять firewall ради обхода проблемы KESL.
|
||||||
|
|
||||||
|
## 12. Эксплуатационный режим
|
||||||
|
|
||||||
|
- Update (ID 6): каждый час; alert при ошибке или устаревании баз.
|
||||||
|
- File Threat Protection (ID 1): постоянно, `Block`,
|
||||||
|
`DisinfectDeleteIfNotPossible`, `ScanArchived=No`.
|
||||||
|
- ODS: еженедельно в низкую нагрузку после подтверждения resource budget.
|
||||||
|
- ContainerScan: еженедельно и после deploy, только после smoke.
|
||||||
|
- Ежедневно: статус лицензии, компонентов, дата баз и ошибки KESL.
|
||||||
|
- Ежемесячно: review исключений и фактической нагрузки.
|
||||||
|
- После upgrade KESL/kernel/Docker: повтор пилота, smoke и evidence delta.
|
||||||
|
|
||||||
|
Любое новое исключение должно иметь владельца, причину, срок пересмотра,
|
||||||
|
компенсирующую проверку и подтверждение Security.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# KESL 12.4 standalone policy decisions for HAN Chat VM1.
|
||||||
|
#
|
||||||
|
# REFERENCE ONLY: this is deliberately not a complete kesl-control import file.
|
||||||
|
# Export the settings from the installed build, review the diff, and apply
|
||||||
|
# individual values by the commands in RUNBOOK.KESL.ru.md. Importing a partial
|
||||||
|
# or version-mismatched file can reset settings that are not listed here.
|
||||||
|
#
|
||||||
|
# This file contains no license, activation code, proxy credentials, hostname,
|
||||||
|
# IP address, secret or environment value.
|
||||||
|
|
||||||
|
[deployment]
|
||||||
|
product_major_minor=12.4
|
||||||
|
mode=standard_standalone
|
||||||
|
gui=disabled
|
||||||
|
update_source=KLServers
|
||||||
|
interceptor=fanotify
|
||||||
|
network_features=disabled
|
||||||
|
ksn=<Yes_OR_No_AFTER_SECURITY_AND_LEGAL_APPROVAL>
|
||||||
|
|
||||||
|
[resource_budget]
|
||||||
|
# Choose from measured host capacity; see runbook section 4.
|
||||||
|
scan_memory_limit_mb=<1024_OR_APPROVED_VALUE>
|
||||||
|
max_memory=<2048MB_OR_APPROVED_VALUE>
|
||||||
|
use_on_demand_cpu_limit=Yes
|
||||||
|
on_demand_cpu_limit_percent=25
|
||||||
|
|
||||||
|
[constrained_test_vm_override]
|
||||||
|
# Explicitly approved only for the 4 GB non-production VM. Never copy this
|
||||||
|
# profile to production without new sizing and baseline.
|
||||||
|
scan_memory_limit_mb=512
|
||||||
|
max_memory=1024MB
|
||||||
|
use_on_demand_cpu_limit=Yes
|
||||||
|
on_demand_cpu_limit_percent=15
|
||||||
|
full_filesystem_scan=forbidden
|
||||||
|
scan_archived=No
|
||||||
|
parallel_ods_and_container_scan=forbidden
|
||||||
|
stop_available_memory_mb=512
|
||||||
|
|
||||||
|
[update_task_6]
|
||||||
|
rule_type=Hourly
|
||||||
|
required_result=completed_successfully
|
||||||
|
required_bases_loaded=Yes
|
||||||
|
stale_bases_alert=<APPROVED_THRESHOLD>
|
||||||
|
|
||||||
|
[file_threat_protection_task_1]
|
||||||
|
steady_state=Started
|
||||||
|
interceptor_protection_mode=Block
|
||||||
|
action_on_threat=DisinfectDeleteIfNotPossible
|
||||||
|
scan_archived=No
|
||||||
|
|
||||||
|
[file_threat_exclusions]
|
||||||
|
# Replace placeholders only with mountpoints returned by docker volume inspect.
|
||||||
|
# Do not guess the Compose project prefix.
|
||||||
|
item_0000=<REDIS_DATA_MOUNTPOINT>
|
||||||
|
item_0001=<OTEL_QUEUE_MOUNTPOINT>
|
||||||
|
item_0002=<NGINX_CACHE_MOUNTPOINT>
|
||||||
|
|
||||||
|
[forbidden_broad_exclusions]
|
||||||
|
item_0000=/var/lib/docker
|
||||||
|
item_0001=/var/lib/docker/overlay2
|
||||||
|
item_0002=/opt/han-chat
|
||||||
|
item_0003=/var/lib/han-deploy/incoming
|
||||||
|
item_0004=/etc/han
|
||||||
|
item_0005=/run/han-chat
|
||||||
|
item_0006=/tmp
|
||||||
|
item_0007=/
|
||||||
|
|
||||||
|
[on_demand_scan]
|
||||||
|
initial_scope=/opt/han-chat/current/backend
|
||||||
|
initial_action=Inform
|
||||||
|
steady_schedule=<APPROVED_WEEKLY_LOW_LOAD_WINDOW>
|
||||||
|
|
||||||
|
[container_scan_task_18]
|
||||||
|
initial_schedule=Manual
|
||||||
|
steady_schedule=<APPROVED_WEEKLY_LOW_LOAD_WINDOW>
|
||||||
|
post_deploy=after_smoke_only
|
||||||
|
# Review before enabling: the vendor default can stop a container when scan
|
||||||
|
# fails technically.
|
||||||
|
container_scan_action=<SECURITY_APPROVED_NON_DISRUPTIVE_VALUE>
|
||||||
|
|
||||||
|
[evidence]
|
||||||
|
application_info=required
|
||||||
|
license_valid=required
|
||||||
|
bases_loaded_and_fresh=required
|
||||||
|
file_threat_task_started=required
|
||||||
|
update_schedule_hourly=required
|
||||||
|
eicar_block_or_remediation_event=required
|
||||||
|
application_smoke_after_each_stage=required
|
||||||
|
firewall_unchanged=required
|
||||||
|
resource_comparison_24h=required
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
extensions:
|
||||||
|
file_storage:
|
||||||
|
directory: /var/lib/han-otel/host-collector
|
||||||
|
timeout: 10s
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
filelog/docker:
|
||||||
|
include: [/var/lib/docker/containers/*/*-json.log]
|
||||||
|
start_at: end
|
||||||
|
include_file_path: true
|
||||||
|
storage: file_storage
|
||||||
|
operators:
|
||||||
|
- type: json_parser
|
||||||
|
id: docker-json
|
||||||
|
timestamp:
|
||||||
|
parse_from: attributes.time
|
||||||
|
layout_type: gotime
|
||||||
|
layout: "2006-01-02T15:04:05.000000000Z07:00"
|
||||||
|
- type: regex_parser
|
||||||
|
id: docker-service-label
|
||||||
|
parse_from: attributes["attrs"]["tag"]
|
||||||
|
if: 'attributes["attrs"]["tag"] != nil'
|
||||||
|
regex: '^(?P<docker_service>nginx|keycloak|redis)$'
|
||||||
|
- type: move
|
||||||
|
from: attributes.docker_service
|
||||||
|
to: resource["service.name"]
|
||||||
|
- type: move
|
||||||
|
from: attributes.stream
|
||||||
|
to: attributes["log.iostream"]
|
||||||
|
- type: move
|
||||||
|
from: attributes.log
|
||||||
|
to: body
|
||||||
|
- type: json_parser
|
||||||
|
id: structured-body
|
||||||
|
parse_from: body
|
||||||
|
parse_to: attributes
|
||||||
|
if: 'body matches "^\\s*\\{"'
|
||||||
|
on_error: send
|
||||||
|
journald/host:
|
||||||
|
directory: /var/log/journal
|
||||||
|
units:
|
||||||
|
- docker.service
|
||||||
|
- han-stack@production.service
|
||||||
|
- han-secrets@production.service
|
||||||
|
- han-chat-docker-firewall.service
|
||||||
|
- certbot.service
|
||||||
|
- fail2ban.service
|
||||||
|
priority: info
|
||||||
|
start_at: end
|
||||||
|
storage: file_storage
|
||||||
|
|
||||||
|
processors:
|
||||||
|
memory_limiter:
|
||||||
|
check_interval: 1s
|
||||||
|
limit_mib: 192
|
||||||
|
spike_limit_mib: 48
|
||||||
|
resource/common:
|
||||||
|
attributes:
|
||||||
|
- {key: service.namespace, value: han-chat, action: upsert}
|
||||||
|
- {key: deployment.environment, value: "${env:APP_ENV}", action: upsert}
|
||||||
|
- {key: service.version, value: "${env:RELEASE_VERSION}", action: upsert}
|
||||||
|
- {key: host.name, value: "${env:HOST_NAME}", action: upsert}
|
||||||
|
transform/journal:
|
||||||
|
error_mode: ignore
|
||||||
|
log_statements:
|
||||||
|
- context: log
|
||||||
|
statements:
|
||||||
|
- set(resource.attributes["service.name"], attributes["_SYSTEMD_UNIT"]) where resource.attributes["service.name"] == nil and attributes["_SYSTEMD_UNIT"] != nil
|
||||||
|
- set(attributes["event.source"], "journald") where attributes["_SYSTEMD_UNIT"] != nil
|
||||||
|
- set(attributes["event.source"], "docker-json-file") where attributes["_SYSTEMD_UNIT"] == nil
|
||||||
|
- set(attributes["route.class"], "api") where resource.attributes["service.name"] == "nginx" and IsMatch(attributes["uri"], "^/api/")
|
||||||
|
- set(attributes["route.class"], "auth") where resource.attributes["service.name"] == "nginx" and IsMatch(attributes["uri"], "^/auth/")
|
||||||
|
- set(attributes["route.class"], "bitrix") where resource.attributes["service.name"] == "nginx" and IsMatch(attributes["uri"], "^/bitrix/")
|
||||||
|
- set(attributes["route.class"], "callbacks") where resource.attributes["service.name"] == "nginx" and IsMatch(attributes["uri"], "^/callbacks/")
|
||||||
|
- set(attributes["route.class"], "static") where resource.attributes["service.name"] == "nginx" and attributes["route.class"] == nil
|
||||||
|
- replace_pattern(body, "(?i)(Bearer|Basic)\\s+[A-Za-z0-9._~+/=-]+", "$$1 [REDACTED]") where IsString(body)
|
||||||
|
- replace_pattern(body, "(?i)(token|secret|password|code)=([^&\\s]+)", "$$1=[REDACTED]") where IsString(body)
|
||||||
|
- set(body, "nginx.access") where resource.attributes["service.name"] == "nginx" and attributes["log.iostream"] == "stdout"
|
||||||
|
- set(body, "keycloak.event") where resource.attributes["service.name"] == "keycloak" and attributes["log.iostream"] == "stdout"
|
||||||
|
attributes/redact:
|
||||||
|
actions:
|
||||||
|
- {key: authorization, action: delete}
|
||||||
|
- {key: http.request.header.authorization, action: delete}
|
||||||
|
- {key: http.request.header.cookie, action: delete}
|
||||||
|
- {key: http.response.header.set-cookie, action: delete}
|
||||||
|
- {key: url.query, action: delete}
|
||||||
|
- {key: url.full, action: delete}
|
||||||
|
- {key: http.target, action: delete}
|
||||||
|
- {key: http.request.body, action: delete}
|
||||||
|
- {key: db.statement, action: delete}
|
||||||
|
- {key: message, action: delete}
|
||||||
|
- {key: payload, action: delete}
|
||||||
|
- {key: user.phone, action: delete}
|
||||||
|
- {key: user.email, action: delete}
|
||||||
|
- {key: remote_addr, action: delete}
|
||||||
|
- {key: user_agent, action: delete}
|
||||||
|
- {key: uri, action: delete}
|
||||||
|
filter/allowlist:
|
||||||
|
error_mode: ignore
|
||||||
|
logs:
|
||||||
|
log_record:
|
||||||
|
- 'attributes["_SYSTEMD_UNIT"] == nil and resource.attributes["service.name"] != "nginx" and resource.attributes["service.name"] != "keycloak" and resource.attributes["service.name"] != "redis"'
|
||||||
|
- 'resource.attributes["service.name"] == "otel-collector" or resource.attributes["service.name"] == "otel-host-collector"'
|
||||||
|
filter/noise:
|
||||||
|
error_mode: ignore
|
||||||
|
logs:
|
||||||
|
log_record:
|
||||||
|
- 'resource.attributes["service.name"] == "nginx" and severity_number < SEVERITY_NUMBER_WARN and IsMatch(body, "(/health/live|/nginx-health/live|/.well-known/acme-challenge/)")'
|
||||||
|
batch:
|
||||||
|
timeout: 5s
|
||||||
|
send_batch_size: 512
|
||||||
|
send_batch_max_size: 1024
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
otlp/remote:
|
||||||
|
endpoint: "${env:OTEL_REMOTE_ENDPOINT}"
|
||||||
|
headers:
|
||||||
|
authorization: ${file:/run/han-chat/secrets/OTEL_REMOTE_AUTH_HEADER}
|
||||||
|
tls:
|
||||||
|
insecure: "${env:OTEL_REMOTE_TLS_INSECURE}"
|
||||||
|
sending_queue:
|
||||||
|
enabled: true
|
||||||
|
queue_size: 5000
|
||||||
|
storage: file_storage
|
||||||
|
retry_on_failure:
|
||||||
|
enabled: true
|
||||||
|
initial_interval: 5s
|
||||||
|
max_interval: 30s
|
||||||
|
max_elapsed_time: 0s
|
||||||
|
|
||||||
|
service:
|
||||||
|
extensions: [file_storage]
|
||||||
|
pipelines:
|
||||||
|
logs:
|
||||||
|
receivers: [filelog/docker, journald/host]
|
||||||
|
processors:
|
||||||
|
[memory_limiter, resource/common, filter/allowlist, filter/noise, transform/journal, attributes/redact, batch]
|
||||||
|
exporters: [otlp/remote]
|
||||||
|
telemetry:
|
||||||
|
logs:
|
||||||
|
level: error
|
||||||
|
metrics:
|
||||||
|
level: none
|
||||||
@@ -19,6 +19,7 @@ value() {
|
|||||||
[ -f "$ROOT/docker-compose.yml" ] || fail "root docker-compose.yml is missing"
|
[ -f "$ROOT/docker-compose.yml" ] || fail "root docker-compose.yml is missing"
|
||||||
[ -f "$ENV_FILE" ] || fail ".env is missing"
|
[ -f "$ENV_FILE" ] || fail ".env is missing"
|
||||||
[ -f "$MANIFEST" ] || fail "runtime secret manifest is missing"
|
[ -f "$MANIFEST" ] || fail "runtime secret manifest is missing"
|
||||||
|
[ -d /var/log/journal ] || fail "persistent journald directory is missing"
|
||||||
[ -L /opt/han-chat/current ] || fail "/opt/han-chat/current must be a root-controlled release link"
|
[ -L /opt/han-chat/current ] || fail "/opt/han-chat/current must be a root-controlled release link"
|
||||||
[ "$(/usr/bin/stat -c '%U:%G' /opt/han-chat/current)" = root:root ] ||
|
[ "$(/usr/bin/stat -c '%U:%G' /opt/han-chat/current)" = root:root ] ||
|
||||||
fail "active release link must be root:root"
|
fail "active release link must be root:root"
|
||||||
@@ -59,6 +60,8 @@ if [ -d "$ROOT" ]; then
|
|||||||
$ROOT/docker-compose.yml
|
$ROOT/docker-compose.yml
|
||||||
$ROOT/deployment/preflight.sh
|
$ROOT/deployment/preflight.sh
|
||||||
$ROOT/deployment/han-stack@.service
|
$ROOT/deployment/han-stack@.service
|
||||||
|
$ROOT/deployment/han-host-otel-collector@.service
|
||||||
|
$ROOT/deployment/observability/otel-host-collector.yaml
|
||||||
$ROOT/deployment/scripts/tls-deploy-hook.sh
|
$ROOT/deployment/scripts/tls-deploy-hook.sh
|
||||||
$ROOT/deployment/secrets/han-compose
|
$ROOT/deployment/secrets/han-compose
|
||||||
$ROOT/deployment/secrets/han-secrets
|
$ROOT/deployment/secrets/han-secrets
|
||||||
@@ -91,6 +94,10 @@ if [ -f "$ENV_FILE" ]; then
|
|||||||
[ "$(value NGINX_TLS_ENABLED)" = true ] || fail "NGINX_TLS_ENABLED must be true"
|
[ "$(value NGINX_TLS_ENABLED)" = true ] || fail "NGINX_TLS_ENABLED must be true"
|
||||||
[ "$(value NGINX_HTTP_PORT)" = 80 ] || fail "nginx must publish host port 80"
|
[ "$(value NGINX_HTTP_PORT)" = 80 ] || fail "nginx must publish host port 80"
|
||||||
[ "$(value NGINX_HTTPS_PORT)" = 443 ] || fail "nginx must publish host port 443"
|
[ "$(value NGINX_HTTPS_PORT)" = 443 ] || fail "nginx must publish host port 443"
|
||||||
|
[ -n "$(value HOST_NAME)" ] || fail "HOST_NAME is required for telemetry correlation"
|
||||||
|
echo "$(value TELEMETRYGEN_IMAGE)" |
|
||||||
|
grep -Eq '^ghcr\.io/.+@sha256:[a-f0-9]{64}$' ||
|
||||||
|
fail "TELEMETRYGEN_IMAGE must be pinned by digest"
|
||||||
[ "$(value NGINX_TLS_CERTIFICATE)" = /run/tls/fullchain.pem ] ||
|
[ "$(value NGINX_TLS_CERTIFICATE)" = /run/tls/fullchain.pem ] ||
|
||||||
fail "nginx certificate must use staged /run/tls/fullchain.pem"
|
fail "nginx certificate must use staged /run/tls/fullchain.pem"
|
||||||
[ "$(value NGINX_TLS_CERTIFICATE_KEY)" = /run/tls/privkey.pem ] ||
|
[ "$(value NGINX_TLS_CERTIFICATE_KEY)" = /run/tls/privkey.pem ] ||
|
||||||
@@ -278,6 +285,39 @@ if [ -f "$MANIFEST" ]; then
|
|||||||
IFS=$old_ifs
|
IFS=$old_ifs
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
host_otel_config="$ROOT/deployment/observability/otel-host-collector.yaml"
|
||||||
|
if [ -f "$host_otel_config" ]; then
|
||||||
|
grep -Fq 'filelog/docker' "$host_otel_config" ||
|
||||||
|
fail "host collector must include Docker filelog"
|
||||||
|
grep -Fq 'journald/host' "$host_otel_config" ||
|
||||||
|
fail "host collector must include journald allow-list"
|
||||||
|
grep -Fq 'filter/allowlist' "$host_otel_config" ||
|
||||||
|
fail "host collector must drop non-allowlisted containers"
|
||||||
|
grep -Fq 'file_storage' "$host_otel_config" ||
|
||||||
|
fail "host collector must persist offsets and exporter queue"
|
||||||
|
! grep -Fq '/var/run/docker.sock' "$host_otel_config" ||
|
||||||
|
fail "Docker socket access is forbidden"
|
||||||
|
fi
|
||||||
|
[ -x /usr/local/bin/otelcol-contrib ] ||
|
||||||
|
fail "pinned host otelcol-contrib is not installed"
|
||||||
|
otel_checksum=/usr/local/share/han-otel/otelcol-contrib.sha256
|
||||||
|
otel_version=/usr/local/share/han-otel/otelcol-contrib.version
|
||||||
|
[ -f "$otel_checksum" ] || fail "host Collector checksum record is missing"
|
||||||
|
[ -f "$otel_version" ] || fail "host Collector version record is missing"
|
||||||
|
if [ -f "$otel_checksum" ]; then
|
||||||
|
(cd / && sha256sum -c "$otel_checksum") ||
|
||||||
|
fail "host Collector binary checksum mismatch"
|
||||||
|
fi
|
||||||
|
if [ -x /usr/local/bin/otelcol-contrib ] && [ -f "$otel_version" ]; then
|
||||||
|
/usr/local/bin/otelcol-contrib --version 2>&1 |
|
||||||
|
grep -Fq "$(cat "$otel_version")" ||
|
||||||
|
fail "host Collector binary version mismatch"
|
||||||
|
fi
|
||||||
|
if [ -x /usr/local/bin/otelcol-contrib ] && [ -f "$host_otel_config" ]; then
|
||||||
|
/usr/local/bin/otelcol-contrib validate --config="$host_otel_config" ||
|
||||||
|
fail "host collector config validation failed"
|
||||||
|
fi
|
||||||
|
|
||||||
if [ -x "$ROOT/scripts/validate-env" ] && [ -f "$ENV_FILE" ] && [ -f "$MANIFEST" ]; then
|
if [ -x "$ROOT/scripts/validate-env" ] && [ -f "$ENV_FILE" ] && [ -f "$MANIFEST" ]; then
|
||||||
"$ROOT/scripts/validate-env" "$ENV_FILE" --runtime-manifest "$MANIFEST" ||
|
"$ROOT/scripts/validate-env" "$ENV_FILE" --runtime-manifest "$MANIFEST" ||
|
||||||
fail "config/runtime validator rejected the production inputs"
|
fail "config/runtime validator rejected the production inputs"
|
||||||
|
|||||||
@@ -1,58 +1,78 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Создаёт 9 персональных уведомлений всех видов контура P для одного user_id.
|
# Создаёт 9 персональных уведомлений всех видов контура P для одного user_id.
|
||||||
# Запускать на ВМ из каталога backend: /opt/han-chat/backend
|
# Запускать на ВМ1 под root/admin (sudo -i). Секреты не читаются из /etc/han/vm1.env.
|
||||||
#
|
#
|
||||||
# cd /opt/han-chat/backend
|
# sed -i 's/\r$//' /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
|
||||||
# sed -i 's/\r$//' deployment/scripts/seed-personal-notifications-test.sh
|
# chmod +x /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
|
||||||
# chmod +x deployment/scripts/seed-personal-notifications-test.sh
|
# /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
|
||||||
# ./deployment/scripts/seed-personal-notifications-test.sh
|
|
||||||
#
|
#
|
||||||
# Токен берётся из .env (NOTIFICATIONS_TOKEN_PRODUCER_TEST) — тот же, что у api-backend.
|
# Токен: runtime-файл NOTIFICATIONS_TOKEN_PRODUCER_TEST
|
||||||
# При старте api-backend синхронизирует hash токена в notification_sources.
|
# (/run/han-chat/secrets/…, тот же, что у api-backend). Hash в notification_sources
|
||||||
|
# синхронизируется при старте api-backend.
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
cd "$(dirname "$0")/../.."
|
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
|
||||||
|
if [[ -f "${SCRIPT_DIR}/../../docker-compose.yml" ]]; then
|
||||||
|
cd "${SCRIPT_DIR}/../.."
|
||||||
|
else
|
||||||
|
cd "${HAN_DEPLOY_DIR:-/opt/han-chat/current/backend}"
|
||||||
|
fi
|
||||||
|
|
||||||
ENV_FILE="${ENV_FILE:-.env}"
|
CONFIG_FILE="${CONFIG_FILE:-/etc/han/vm1.env}"
|
||||||
|
SECRETS_LAUNCHER="${SECRETS_LAUNCHER:-/usr/local/lib/han-secrets/han-secrets}"
|
||||||
|
if [[ ! -x "$SECRETS_LAUNCHER" ]]; then
|
||||||
|
SECRETS_LAUNCHER="deployment/secrets/han-secrets"
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ ! -f "$ENV_FILE" ]]; then
|
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||||
echo "Не найден $ENV_FILE. Запускайте из каталога backend." >&2
|
echo "Не найден конфиг ${CONFIG_FILE} (ожидается /etc/han/vm1.env)." >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
env_value() {
|
if [[ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]]; then
|
||||||
python3 - "$ENV_FILE" "$1" <<'PY'
|
[[ -x "$SECRETS_LAUNCHER" ]] || {
|
||||||
import sys
|
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
|
||||||
from pathlib import Path
|
exit 66
|
||||||
|
}
|
||||||
|
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
path, wanted = sys.argv[1:]
|
compose() {
|
||||||
for raw in Path(path).read_text(encoding="utf-8").splitlines():
|
if [[ -x /usr/local/sbin/han-vm1-compose ]]; then
|
||||||
line = raw.strip()
|
/usr/local/sbin/han-vm1-compose "$@"
|
||||||
if not line or line.startswith("#") or "=" not in line:
|
else
|
||||||
continue
|
docker compose --env-file "$CONFIG_FILE" "$@"
|
||||||
key, value = line.split("=", 1)
|
fi
|
||||||
if key.strip() == wanted:
|
|
||||||
value = value.strip()
|
|
||||||
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
||||||
value = value[1:-1]
|
|
||||||
print(value)
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
raise SystemExit(f"missing environment variable: {wanted}")
|
|
||||||
PY
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trim_token() {
|
trim_token() {
|
||||||
printf '%s' "$1" | tr -d '\r\n\t '
|
printf '%s' "$1" | tr -d '\r\n\t '
|
||||||
}
|
}
|
||||||
|
|
||||||
|
read_secret_file() {
|
||||||
|
local path="${1:-}"
|
||||||
|
[[ -n "$path" && -r "$path" ]] || return 1
|
||||||
|
python3 - "$path" <<'PY'
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
print(Path(sys.argv[1]).read_text(encoding="utf-8").strip())
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
TOKEN="$(trim_token "${NOTIFICATIONS_TOKEN_PRODUCER_TEST:-}")"
|
TOKEN="$(trim_token "${NOTIFICATIONS_TOKEN_PRODUCER_TEST:-}")"
|
||||||
if [[ -z "$TOKEN" ]]; then
|
if [[ -z "$TOKEN" ]]; then
|
||||||
TOKEN="$(trim_token "$(env_value NOTIFICATIONS_TOKEN_PRODUCER_TEST 2>/dev/null || true)")"
|
TOKEN="$(trim_token "$(read_secret_file "${NOTIFICATIONS_TOKEN_PRODUCER_TEST_FILE:-}" 2>/dev/null || true)")"
|
||||||
fi
|
fi
|
||||||
if [[ -z "$TOKEN" ]]; then
|
if [[ -z "$TOKEN" ]]; then
|
||||||
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (из .env не найден): " TOKEN
|
TOKEN="$(trim_token "$(read_secret_file "${HAN_RUNTIME_SECRET_DIR:-/run/han-chat/secrets}/NOTIFICATIONS_TOKEN_PRODUCER_TEST" 2>/dev/null || true)")"
|
||||||
|
fi
|
||||||
|
if [[ -z "$TOKEN" ]]; then
|
||||||
|
TOKEN="$(trim_token "$(compose exec -T api-backend python3 -c 'import os; print(os.getenv("NOTIFICATIONS_TOKEN_PRODUCER_TEST") or "")' 2>/dev/null || true)")"
|
||||||
|
fi
|
||||||
|
if [[ -z "$TOKEN" ]]; then
|
||||||
|
read -rsp "NOTIFICATIONS_TOKEN_PRODUCER_TEST (runtime secret не найден): " TOKEN
|
||||||
echo
|
echo
|
||||||
TOKEN="$(trim_token "$TOKEN")"
|
TOKEN="$(trim_token "$TOKEN")"
|
||||||
fi
|
fi
|
||||||
@@ -65,7 +85,7 @@ if [[ -z "${TOKEN}" || -z "${USER_ID}" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Токен: ${#TOKEN} символов (первые 8: ${TOKEN:0:8}…)"
|
echo "Токен: ${#TOKEN} символов (значение не печатается)."
|
||||||
|
|
||||||
PAYLOAD_DIR="$(mktemp -d)"
|
PAYLOAD_DIR="$(mktemp -d)"
|
||||||
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
|
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
|
||||||
@@ -103,22 +123,48 @@ create_notification() {
|
|||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
echo "========== ${label} =========="
|
echo "========== ${label} =========="
|
||||||
docker run --rm \
|
compose exec -T \
|
||||||
--network han-chat-backend \
|
-e "NOTIFICATIONS_PRODUCER_TOKEN=${TOKEN}" \
|
||||||
-v "${payload_file}:/payload.json:ro" \
|
api-backend python3 -c "
|
||||||
curlimages/curl:latest \
|
import os
|
||||||
-sS -i \
|
import sys
|
||||||
-X POST \
|
import urllib.error
|
||||||
'http://api-backend:8000/internal/notifications/v1/notifications' \
|
import urllib.request
|
||||||
-H "Authorization: Bearer ${TOKEN}" \
|
|
||||||
-H 'Content-Type: application/json; charset=utf-8' \
|
payload = sys.stdin.buffer.read()
|
||||||
--data-binary @/payload.json
|
request = urllib.request.Request(
|
||||||
|
'http://127.0.0.1:8000/internal/notifications/v1/notifications',
|
||||||
|
data=payload,
|
||||||
|
method='POST',
|
||||||
|
headers={
|
||||||
|
'Authorization': 'Bearer ' + os.environ['NOTIFICATIONS_PRODUCER_TOKEN'],
|
||||||
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(request, timeout=60) as response:
|
||||||
|
sys.stdout.write('HTTP/1.1 %s\n' % response.status)
|
||||||
|
for key, value in response.headers.items():
|
||||||
|
sys.stdout.write('%s: %s\n' % (key, value))
|
||||||
|
sys.stdout.write('\n')
|
||||||
|
sys.stdout.write(response.read().decode('utf-8', errors='replace'))
|
||||||
|
sys.stdout.write('\n')
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
sys.stdout.write('HTTP/1.1 %s\n' % exc.code)
|
||||||
|
for key, value in exc.headers.items():
|
||||||
|
sys.stdout.write('%s: %s\n' % (key, value))
|
||||||
|
sys.stdout.write('\n')
|
||||||
|
sys.stdout.write(exc.read().decode('utf-8', errors='replace'))
|
||||||
|
sys.stdout.write('\n')
|
||||||
|
if exc.code >= 500 or exc.code in (401, 403):
|
||||||
|
raise SystemExit(1)
|
||||||
|
" <"$payload_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
prepare_docs_in_s3() {
|
prepare_docs_in_s3() {
|
||||||
echo >&2
|
echo >&2
|
||||||
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
|
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
|
||||||
docker compose --env-file "$ENV_FILE" exec -T \
|
compose exec -T \
|
||||||
-e "USER_ID=${USER_ID}" \
|
-e "USER_ID=${USER_ID}" \
|
||||||
-e "RUN_ID=${RUN_ID}" \
|
-e "RUN_ID=${RUN_ID}" \
|
||||||
api-backend python3 - <<'PY'
|
api-backend python3 - <<'PY'
|
||||||
@@ -443,6 +489,7 @@ echo
|
|||||||
echo "========== Готово =========="
|
echo "========== Готово =========="
|
||||||
echo "External ID prefix: ${RUN_ID}-*"
|
echo "External ID prefix: ${RUN_ID}-*"
|
||||||
echo
|
echo
|
||||||
echo "Если видите 401: проверьте NOTIFICATIONS_TOKEN_PRODUCER_TEST в .env и перезапустите api-backend."
|
echo "Если видите 401: проверьте runtime-секрет NOTIFICATIONS_TOKEN_PRODUCER_TEST"
|
||||||
echo " grep NOTIFICATIONS_TOKEN_PRODUCER_TEST .env"
|
echo "и hash в han_app.notification_sources, затем пересоздайте api-backend:"
|
||||||
echo " docker compose --env-file .env up -d --force-recreate api-backend"
|
echo " test -r /run/han-chat/secrets/NOTIFICATIONS_TOKEN_PRODUCER_TEST"
|
||||||
|
echo " /usr/local/sbin/han-vm1-compose up -d --force-recreate api-backend"
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ HARDEN_SSH="${HARDEN_SSH:-false}"
|
|||||||
LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"
|
LOCK_ACCOUNT_PASSWORDS="${LOCK_ACCOUNT_PASSWORDS:-true}"
|
||||||
RESET_UFW="${RESET_UFW:-true}"
|
RESET_UFW="${RESET_UFW:-true}"
|
||||||
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
|
SKIP_APT_UPGRADE="${SKIP_APT_UPGRADE:-false}"
|
||||||
|
OTEL_HOST_COLLECTOR_VERSION="${OTEL_HOST_COLLECTOR_VERSION:-}"
|
||||||
|
OTEL_HOST_COLLECTOR_SHA256="${OTEL_HOST_COLLECTOR_SHA256:-}"
|
||||||
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm1-setup.log}"
|
LOG_FILE="${LOG_FILE:-/var/log/han-chat-vm1-setup.log}"
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
@@ -120,6 +122,13 @@ configure_time() {
|
|||||||
timedatectl set-ntp true
|
timedatectl set-ntp true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
configure_journald() {
|
||||||
|
step "Persistent journald для host telemetry"
|
||||||
|
install -d -m 2755 -o root -g systemd-journal /var/log/journal
|
||||||
|
systemd-tmpfiles --create --prefix /var/log/journal
|
||||||
|
journalctl --flush
|
||||||
|
}
|
||||||
|
|
||||||
install_authorized_key() {
|
install_authorized_key() {
|
||||||
local user=$1 source=$2 target="/home/${1}/.ssh/authorized_keys"
|
local user=$1 source=$2 target="/home/${1}/.ssh/authorized_keys"
|
||||||
[[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный key file ${source}"
|
[[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный key file ${source}"
|
||||||
@@ -422,7 +431,7 @@ install_deploy_sudoers() {
|
|||||||
step "Exact sudoers для deploy"
|
step "Exact sudoers для deploy"
|
||||||
cat >/etc/sudoers.d/deploy <<'EOF'
|
cat >/etc/sudoers.d/deploy <<'EOF'
|
||||||
Cmnd_Alias HAN_VM1_UNITS = /usr/bin/systemctl start han-secrets@production.service, /usr/bin/systemctl restart han-secrets@production.service, /usr/bin/systemctl start han-stack@production.service, /usr/bin/systemctl restart han-stack@production.service, /usr/bin/systemctl stop han-stack@production.service
|
Cmnd_Alias HAN_VM1_UNITS = /usr/bin/systemctl start han-secrets@production.service, /usr/bin/systemctl restart han-secrets@production.service, /usr/bin/systemctl start han-stack@production.service, /usr/bin/systemctl restart han-stack@production.service, /usr/bin/systemctl stop han-stack@production.service
|
||||||
Cmnd_Alias HAN_VM1_STATUS = /usr/bin/systemctl --no-pager status han-secrets@production.service, /usr/bin/systemctl --no-pager status han-stack@production.service, /usr/bin/journalctl --no-pager -u han-secrets@production.service, /usr/bin/journalctl --no-pager -u han-stack@production.service
|
Cmnd_Alias HAN_VM1_STATUS = /usr/bin/systemctl --no-pager status han-secrets@production.service, /usr/bin/systemctl --no-pager status han-stack@production.service, /usr/bin/systemctl --no-pager status han-host-otel-collector@production.service, /usr/bin/journalctl --no-pager -u han-secrets@production.service, /usr/bin/journalctl --no-pager -u han-stack@production.service, /usr/bin/journalctl --no-pager -u han-host-otel-collector@production.service
|
||||||
deploy ALL=(root) NOPASSWD: HAN_VM1_UNITS, HAN_VM1_STATUS
|
deploy ALL=(root) NOPASSWD: HAN_VM1_UNITS, HAN_VM1_STATUS
|
||||||
EOF
|
EOF
|
||||||
chmod 0440 /etc/sudoers.d/deploy
|
chmod 0440 /etc/sudoers.d/deploy
|
||||||
@@ -447,6 +456,15 @@ install_release_helpers_if_possible() {
|
|||||||
[[ -x "${deployment}/scripts/tls-deploy-hook.sh" ]] || die "TLS hook не executable"
|
[[ -x "${deployment}/scripts/tls-deploy-hook.sh" ]] || die "TLS hook не executable"
|
||||||
[[ -x "${source_dir}/han-compose" ]] || die "han-compose не executable"
|
[[ -x "${source_dir}/han-compose" ]] || die "han-compose не executable"
|
||||||
[[ -x "${source_dir}/han-secrets" ]] || die "han-secrets не executable"
|
[[ -x "${source_dir}/han-secrets" ]] || die "han-secrets не executable"
|
||||||
|
[[ -f "${deployment}/han-host-otel-collector@.service" ]] \
|
||||||
|
|| die "han-host-otel-collector@.service отсутствует"
|
||||||
|
[[ -f "${deployment}/observability/otel-host-collector.yaml" ]] \
|
||||||
|
|| die "otel-host-collector.yaml отсутствует"
|
||||||
|
[[ "$OTEL_HOST_COLLECTOR_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] \
|
||||||
|
|| die "Задайте OTEL_HOST_COLLECTOR_VERSION=x.y.z"
|
||||||
|
[[ "$OTEL_HOST_COLLECTOR_SHA256" =~ ^[a-f0-9]{64}$ ]] \
|
||||||
|
|| die "Задайте проверенный OTEL_HOST_COLLECTOR_SHA256"
|
||||||
|
[[ "$(uname -m)" == x86_64 ]] || die "Host Collector artifact рассчитан на amd64"
|
||||||
if getent group "$tls_group" >/dev/null; then
|
if getent group "$tls_group" >/dev/null; then
|
||||||
[[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \
|
[[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \
|
||||||
|| die "han-nginx-tls имеет неожиданный GID"
|
|| die "han-nginx-tls имеет неожиданный GID"
|
||||||
@@ -472,6 +490,27 @@ install_release_helpers_if_possible() {
|
|||||||
install -m 0644 -o root -g root \
|
install -m 0644 -o root -g root \
|
||||||
"${deployment}/han-stack@.service" \
|
"${deployment}/han-stack@.service" \
|
||||||
/etc/systemd/system/han-stack@.service
|
/etc/systemd/system/han-stack@.service
|
||||||
|
local otel_archive otel_tmp otel_url
|
||||||
|
otel_archive="$(mktemp)"
|
||||||
|
otel_tmp="$(mktemp -d)"
|
||||||
|
otel_url="https://github.com/open-telemetry/opentelemetry-collector-releases/releases/download/v${OTEL_HOST_COLLECTOR_VERSION}/otelcol-contrib_${OTEL_HOST_COLLECTOR_VERSION}_linux_amd64.tar.gz"
|
||||||
|
curl --fail --location --proto '=https' --tlsv1.2 \
|
||||||
|
--output "$otel_archive" "$otel_url"
|
||||||
|
printf '%s %s\n' "$OTEL_HOST_COLLECTOR_SHA256" "$otel_archive" | sha256sum -c -
|
||||||
|
tar -xzf "$otel_archive" -C "$otel_tmp" otelcol-contrib
|
||||||
|
install -m 0755 -o root -g root "$otel_tmp/otelcol-contrib" /usr/local/bin/otelcol-contrib
|
||||||
|
install -d -m 0755 -o root -g root /usr/local/share/han-otel
|
||||||
|
sha256sum /usr/local/bin/otelcol-contrib \
|
||||||
|
>/usr/local/share/han-otel/otelcol-contrib.sha256
|
||||||
|
printf '%s\n' "$OTEL_HOST_COLLECTOR_VERSION" \
|
||||||
|
>/usr/local/share/han-otel/otelcol-contrib.version
|
||||||
|
chmod 0644 /usr/local/share/han-otel/otelcol-contrib.sha256 \
|
||||||
|
/usr/local/share/han-otel/otelcol-contrib.version
|
||||||
|
rm -rf "$otel_archive" "$otel_tmp"
|
||||||
|
install -d -m 0700 -o root -g root /var/lib/han-otel/host-collector
|
||||||
|
install -m 0644 -o root -g root \
|
||||||
|
"${deployment}/han-host-otel-collector@.service" \
|
||||||
|
/etc/systemd/system/han-host-otel-collector@.service
|
||||||
install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy
|
install -d -m 0755 -o root -g root /etc/letsencrypt/renewal-hooks/deploy
|
||||||
install -m 0755 -o root -g root \
|
install -m 0755 -o root -g root \
|
||||||
"${deployment}/scripts/tls-deploy-hook.sh" \
|
"${deployment}/scripts/tls-deploy-hook.sh" \
|
||||||
@@ -481,6 +520,7 @@ install_release_helpers_if_possible() {
|
|||||||
/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
|
/usr/local/share/doc/han-secrets/SELECTEL_RUNBOOK.ru.md
|
||||||
systemctl daemon-reload
|
systemctl daemon-reload
|
||||||
systemctl enable certbot.timer
|
systemctl enable certbot.timer
|
||||||
|
systemctl enable han-host-otel-collector@production.service
|
||||||
log "Helpers установлены; application units не запущены"
|
log "Helpers установлены; application units не запущены"
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,6 +579,7 @@ main() {
|
|||||||
check_os
|
check_os
|
||||||
update_system
|
update_system
|
||||||
configure_time
|
configure_time
|
||||||
|
configure_journald
|
||||||
create_host_roles
|
create_host_roles
|
||||||
configure_account_passwords
|
configure_account_passwords
|
||||||
configure_layout
|
configure_layout
|
||||||
|
|||||||
@@ -16,11 +16,38 @@ compose() { docker compose --env-file "$CONFIG_FILE" "$@"; }
|
|||||||
|
|
||||||
NETWORK="${OBSERVABILITY_NETWORK:-han-chat-observability}"
|
NETWORK="${OBSERVABILITY_NETWORK:-han-chat-observability}"
|
||||||
COLLECTOR_SERVICE="${COLLECTOR_SERVICE:-otel-collector}"
|
COLLECTOR_SERVICE="${COLLECTOR_SERVICE:-otel-collector}"
|
||||||
|
TELEMETRYGEN_IMAGE="${TELEMETRYGEN_IMAGE:-}"
|
||||||
errors=0
|
errors=0
|
||||||
|
|
||||||
ok() { printf 'OK %s\n' "$*"; }
|
ok() { printf 'OK %s\n' "$*"; }
|
||||||
fail() { printf 'FAIL %s\n' "$*" >&2; errors=$((errors + 1)); }
|
fail() { printf 'FAIL %s\n' "$*" >&2; errors=$((errors + 1)); }
|
||||||
|
|
||||||
|
if systemctl is-active --quiet han-host-otel-collector@production.service; then
|
||||||
|
ok "Host Collector service is running"
|
||||||
|
else
|
||||||
|
fail "Host Collector service is not running"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if /usr/local/bin/otelcol-contrib validate \
|
||||||
|
--config=deployment/observability/otel-host-collector.yaml >/dev/null 2>&1; then
|
||||||
|
ok "Host Collector configuration is valid"
|
||||||
|
else
|
||||||
|
fail "Host Collector configuration is invalid"
|
||||||
|
fi
|
||||||
|
|
||||||
|
host_bad_logs="$(
|
||||||
|
journalctl --since=-10min --no-pager \
|
||||||
|
-u han-host-otel-collector@production.service 2>&1 |
|
||||||
|
grep -Ei 'queue is full|permission denied|connection refused|tls:|Unauthenticated|Permanent error' ||
|
||||||
|
true
|
||||||
|
)"
|
||||||
|
if [[ -z "$host_bad_logs" ]]; then
|
||||||
|
ok "No host Collector errors in last 10 minutes"
|
||||||
|
else
|
||||||
|
fail "Host Collector reports read/export/queue errors"
|
||||||
|
printf '%s\n' "$host_bad_logs" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
collector_id="$(compose ps -q "$COLLECTOR_SERVICE" 2>/dev/null || true)"
|
collector_id="$(compose ps -q "$COLLECTOR_SERVICE" 2>/dev/null || true)"
|
||||||
if [[ -n "$collector_id" ]] &&
|
if [[ -n "$collector_id" ]] &&
|
||||||
[[ "$(docker inspect --format '{{.State.Status}}' "$collector_id")" == running ]]; then
|
[[ "$(docker inspect --format '{{.State.Status}}' "$collector_id")" == running ]]; then
|
||||||
@@ -43,12 +70,16 @@ PY
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
docker run --rm --network "$NETWORK" \
|
if [[ ! "$TELEMETRYGEN_IMAGE" =~ @sha256:[a-f0-9]{64}$ ]]; then
|
||||||
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
|
fail "TELEMETRYGEN_IMAGE must be pinned by sha256 digest"
|
||||||
|
elif docker run --rm --network "$NETWORK" \
|
||||||
|
"$TELEMETRYGEN_IMAGE" \
|
||||||
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
|
traces --otlp-endpoint otel-collector:4317 --otlp-insecure \
|
||||||
--service han-chat-e2e-canary --traces 100 --rate 20 >/dev/null \
|
--service han-chat-e2e-canary --traces 100 --rate 20 >/dev/null; then
|
||||||
&& ok "100 canary traces submitted" \
|
ok "100 canary traces submitted"
|
||||||
|| fail "telemetrygen failed"
|
else
|
||||||
|
fail "telemetrygen failed"
|
||||||
|
fi
|
||||||
|
|
||||||
bad_logs="$(
|
bad_logs="$(
|
||||||
compose logs --since=10m "$COLLECTOR_SERVICE" 2>&1 |
|
compose logs --since=10m "$COLLECTOR_SERVICE" 2>&1 |
|
||||||
@@ -72,4 +103,12 @@ cat <<'EOF'
|
|||||||
service.namespace = han-chat
|
service.namespace = han-chat
|
||||||
Затем выполните synthetic API request и проверьте общий trace между
|
Затем выполните synthetic API request и проверьте общий trace между
|
||||||
api-backend и dependency spans, service.version и deployment.environment.
|
api-backend и dependency spans, service.version и deployment.environment.
|
||||||
|
В Logs проверьте:
|
||||||
|
host.name = <VM1 HOST_NAME>
|
||||||
|
service.name IN (nginx, keycloak, redis, api-backend, sms-service,
|
||||||
|
sms-worker, bitrix-local-app)
|
||||||
|
У canary application log trace_id/span_id должны открывать соответствующий
|
||||||
|
span. Python events должны встречаться один раз, unknown-container и
|
||||||
|
otel-host-collector отсутствовать. Fake token/PII marker не должен находиться
|
||||||
|
ни в logs, ни в traces.
|
||||||
EOF
|
EOF
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export default function ProfileScreen() {
|
|||||||
<Feather name="user" size={40} color={colors.primaryForeground} />
|
<Feather name="user" size={40} color={colors.primaryForeground} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={stylesLocal.name}>{fullName}</Text>
|
<Text style={stylesLocal.name}>{fullName}</Text>
|
||||||
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Мигрант"}</Text>
|
<Text style={styles.muted}>{personal?.citizenship ? `Гражданство: ${personal.citizenship}` : "Гражданство не указано"}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{(profile.isLoading || documents.isLoading) && <View style={{ padding: spacing.lg }}><Loading /></View>}
|
{(profile.isLoading || documents.isLoading) && <View style={{ padding: spacing.lg }}><Loading /></View>}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ x-api-secrets: &api-secrets
|
|||||||
|
|
||||||
x-api-runtime: &api-runtime
|
x-api-runtime: &api-runtime
|
||||||
image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest}
|
image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest}
|
||||||
environment:
|
environment: &api-environment
|
||||||
<<: *api-secret-environment
|
<<: *api-secret-environment
|
||||||
APP_ENV: ${APP_ENV:-production-like}
|
APP_ENV: ${APP_ENV:-production-like}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
@@ -167,6 +167,8 @@ services:
|
|||||||
KC_HTTP_RELATIVE_PATH: /auth
|
KC_HTTP_RELATIVE_PATH: /auth
|
||||||
KC_HEALTH_ENABLED: "true"
|
KC_HEALTH_ENABLED: "true"
|
||||||
KC_METRICS_ENABLED: "true"
|
KC_METRICS_ENABLED: "true"
|
||||||
|
KC_LOG_CONSOLE_OUTPUT: json
|
||||||
|
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-info}
|
||||||
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL}
|
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL}
|
||||||
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL:?PUBLIC_WEB_URL is required for realm import}
|
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL:?PUBLIC_WEB_URL is required for realm import}
|
||||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
|
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
|
||||||
@@ -185,6 +187,8 @@ services:
|
|||||||
- keycloak_settings_bridge_token
|
- keycloak_settings_bridge_token
|
||||||
- keycloak_sms_service_token
|
- keycloak_sms_service_token
|
||||||
command: ["start", "--optimized", "--import-realm"]
|
command: ["start", "--optimized", "--import-realm"]
|
||||||
|
labels:
|
||||||
|
com.han.service.name: keycloak
|
||||||
expose: ["8080", "9000"]
|
expose: ["8080", "9000"]
|
||||||
volumes:
|
volumes:
|
||||||
- type: bind
|
- type: bind
|
||||||
@@ -209,7 +213,10 @@ services:
|
|||||||
core: {soft: 0, hard: 0}
|
core: {soft: 0, hard: 0}
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options: {max-size: "50m", max-file: "5"}
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "5"
|
||||||
|
tag: keycloak
|
||||||
|
|
||||||
sms-service:
|
sms-service:
|
||||||
<<: *sms-runtime
|
<<: *sms-runtime
|
||||||
@@ -269,6 +276,9 @@ services:
|
|||||||
delivery-worker:
|
delivery-worker:
|
||||||
<<: *api-runtime
|
<<: *api-runtime
|
||||||
command: ["han-delivery-worker"]
|
command: ["han-delivery-worker"]
|
||||||
|
environment:
|
||||||
|
<<: *api-environment
|
||||||
|
OTEL_SERVICE_NAME: delivery-worker
|
||||||
depends_on:
|
depends_on:
|
||||||
api-backend: {condition: service_healthy}
|
api-backend: {condition: service_healthy}
|
||||||
bitrix-local-app: {condition: service_healthy}
|
bitrix-local-app: {condition: service_healthy}
|
||||||
@@ -283,6 +293,9 @@ services:
|
|||||||
safety-recovery-worker:
|
safety-recovery-worker:
|
||||||
<<: *api-runtime
|
<<: *api-runtime
|
||||||
command: ["han-safety-worker"]
|
command: ["han-safety-worker"]
|
||||||
|
environment:
|
||||||
|
<<: *api-environment
|
||||||
|
OTEL_SERVICE_NAME: safety-recovery-worker
|
||||||
depends_on:
|
depends_on:
|
||||||
api-backend: {condition: service_healthy}
|
api-backend: {condition: service_healthy}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -296,6 +309,9 @@ services:
|
|||||||
cleanup-worker:
|
cleanup-worker:
|
||||||
<<: *api-runtime
|
<<: *api-runtime
|
||||||
command: ["han-cleanup-worker"]
|
command: ["han-cleanup-worker"]
|
||||||
|
environment:
|
||||||
|
<<: *api-environment
|
||||||
|
OTEL_SERVICE_NAME: cleanup-worker
|
||||||
depends_on:
|
depends_on:
|
||||||
api-backend: {condition: service_healthy}
|
api-backend: {condition: service_healthy}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -309,6 +325,9 @@ services:
|
|||||||
notification-expire-worker:
|
notification-expire-worker:
|
||||||
<<: *api-runtime
|
<<: *api-runtime
|
||||||
command: ["han-notification-expire-worker"]
|
command: ["han-notification-expire-worker"]
|
||||||
|
environment:
|
||||||
|
<<: *api-environment
|
||||||
|
OTEL_SERVICE_NAME: notification-expire-worker
|
||||||
depends_on:
|
depends_on:
|
||||||
api-backend: {condition: service_healthy}
|
api-backend: {condition: service_healthy}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -322,6 +341,9 @@ services:
|
|||||||
notification-draft-cleanup-worker:
|
notification-draft-cleanup-worker:
|
||||||
<<: *api-runtime
|
<<: *api-runtime
|
||||||
command: ["han-notification-draft-cleanup-worker"]
|
command: ["han-notification-draft-cleanup-worker"]
|
||||||
|
environment:
|
||||||
|
<<: *api-environment
|
||||||
|
OTEL_SERVICE_NAME: notification-draft-cleanup-worker
|
||||||
depends_on:
|
depends_on:
|
||||||
api-backend: {condition: service_healthy}
|
api-backend: {condition: service_healthy}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -346,6 +368,10 @@ services:
|
|||||||
BITRIX_API_FORWARD_TOKEN_FILE: /run/secrets/bitrix_api_forward_token
|
BITRIX_API_FORWARD_TOKEN_FILE: /run/secrets/bitrix_api_forward_token
|
||||||
BITRIX_TOKEN_ENCRYPTION_KEY_FILE: /run/secrets/bitrix_token_encryption_key
|
BITRIX_TOKEN_ENCRYPTION_KEY_FILE: /run/secrets/bitrix_token_encryption_key
|
||||||
APP_ENV: ${APP_ENV:-production-like}
|
APP_ENV: ${APP_ENV:-production-like}
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://otel-collector:4317}
|
||||||
|
OTEL_SERVICE_NAME: bitrix-local-app
|
||||||
BITRIX_CLIENT_ID: ${BITRIX_CLIENT_ID}
|
BITRIX_CLIENT_ID: ${BITRIX_CLIENT_ID}
|
||||||
BITRIX_CONNECTOR_ID: ${BITRIX_CONNECTOR_ID:-han_mobile_app}
|
BITRIX_CONNECTOR_ID: ${BITRIX_CONNECTOR_ID:-han_mobile_app}
|
||||||
BITRIX_CONNECTOR_NAME: ${BITRIX_CONNECTOR_NAME:-HAN Mobile App}
|
BITRIX_CONNECTOR_NAME: ${BITRIX_CONNECTOR_NAME:-HAN Mobile App}
|
||||||
|
|||||||
@@ -51,8 +51,10 @@ non-secret `PUBLIC_WEB_URL` environment variable. Keycloak resolves the
|
|||||||
initial `--import-realm`.
|
initial `--import-realm`.
|
||||||
|
|
||||||
Use exact Expo universal/app links and web origins. Do not replace them with
|
Use exact Expo universal/app links and web origins. Do not replace them with
|
||||||
wildcards. `${PUBLIC_WEB_URL}/auth/callback` and `han-chat://auth/callback` are
|
wildcards. `${PUBLIC_WEB_URL}/auth/callback`, `${PUBLIC_WEB_URL}/mobile/oidc/callback`
|
||||||
allow-listed by the initial realm import.
|
and `han-chat://auth/callback` are allow-listed by the initial realm import.
|
||||||
|
Android Custom Tabs cannot follow a custom-scheme 302, so the mobile client uses
|
||||||
|
the HTTPS bridge page and then opens `han-chat://auth/callback`.
|
||||||
|
|
||||||
The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and `currentSchema=keycloak`. The database role must have privileges only on schema `keycloak`.
|
The JDBC URL must use the managed PostgreSQL private endpoint, TLS verification and `currentSchema=keycloak`. The database role must have privileges only on schema `keycloak`.
|
||||||
|
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
"fullScopeAllowed": false,
|
"fullScopeAllowed": false,
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"${PUBLIC_WEB_URL}/auth/callback",
|
"${PUBLIC_WEB_URL}/auth/callback",
|
||||||
|
"${PUBLIC_WEB_URL}/mobile/oidc/callback",
|
||||||
"han-chat://auth/callback"
|
"han-chat://auth/callback"
|
||||||
],
|
],
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
|
|||||||
+1
@@ -29,6 +29,7 @@ class RealmContractTest {
|
|||||||
assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]"));
|
assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]"));
|
||||||
assertTrue(realm.contains("\"han-chat://auth/callback\""));
|
assertTrue(realm.contains("\"han-chat://auth/callback\""));
|
||||||
assertTrue(realm.contains("\"${PUBLIC_WEB_URL}/auth/callback\""));
|
assertTrue(realm.contains("\"${PUBLIC_WEB_URL}/auth/callback\""));
|
||||||
|
assertTrue(realm.contains("\"${PUBLIC_WEB_URL}/mobile/oidc/callback\""));
|
||||||
assertTrue(realm.contains("\"${PUBLIC_WEB_URL}\""));
|
assertTrue(realm.contains("\"${PUBLIC_WEB_URL}\""));
|
||||||
assertFalse(realm.contains("chat.han0107.ru"));
|
assertFalse(realm.contains("chat.han0107.ru"));
|
||||||
}
|
}
|
||||||
|
|||||||
+2
@@ -17,6 +17,8 @@ otpResendCountdown=Отправить повторно через
|
|||||||
otpResend=Отправить код снова
|
otpResend=Отправить код снова
|
||||||
authBack=Назад
|
authBack=Назад
|
||||||
verifyOtp=Подтвердить
|
verifyOtp=Подтвердить
|
||||||
|
otpVerifying=Проверяем...
|
||||||
|
otpSubmitUnavailable=Не удалось завершить вход. Если код уже принят, закройте окно и откройте приложение.
|
||||||
mockMode=Тестовый режим отправки кода
|
mockMode=Тестовый режим отправки кода
|
||||||
phoneInvalid=Проверьте формат номера телефона.
|
phoneInvalid=Проверьте формат номера телефона.
|
||||||
otpInvalid=Код неверен, истёк или уже использован.
|
otpInvalid=Код неверен, истёк или уже использован.
|
||||||
|
|||||||
@@ -61,12 +61,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button id="han-otp-submit" class="han-primary-button" type="submit" disabled>
|
<div id="han-otp-submit-error" class="han-error han-otp-error" role="alert" hidden>
|
||||||
|
<span class="han-error-icon">!</span>
|
||||||
|
<span>${msg("otpSubmitUnavailable")}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="han-otp-submit" class="han-primary-button" type="submit"
|
||||||
|
data-submitting-label="${msg("otpVerifying")}" disabled>
|
||||||
${msg("verifyOtp")}
|
${msg("verifyOtp")}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<script src="${url.resourcesPath}/js/han-login.js?v=5"></script>
|
<script src="${url.resourcesPath}/js/han-login.js?v=7"></script>
|
||||||
<#if captchaEnabled!false>
|
<#if captchaEnabled!false>
|
||||||
<script src="https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&onload=hanCaptchaOnload"
|
<script src="https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&onload=hanCaptchaOnload"
|
||||||
async defer></script>
|
async defer></script>
|
||||||
|
|||||||
@@ -56,16 +56,28 @@
|
|||||||
|
|
||||||
function initOtpForm() {
|
function initOtpForm() {
|
||||||
var fields = Array.prototype.slice.call(document.querySelectorAll(".han-otp-digit"));
|
var fields = Array.prototype.slice.call(document.querySelectorAll(".han-otp-digit"));
|
||||||
|
var form = document.getElementById("kc-otp-form");
|
||||||
var hidden = document.getElementById("otp");
|
var hidden = document.getElementById("otp");
|
||||||
var submit = document.getElementById("han-otp-submit");
|
var submit = document.getElementById("han-otp-submit");
|
||||||
if (!fields.length || !hidden || !submit) return;
|
var submitError = document.getElementById("han-otp-submit-error");
|
||||||
|
if (!fields.length || !form || !hidden || !submit) return;
|
||||||
|
var submitLabel = submit.textContent;
|
||||||
|
var submitting = false;
|
||||||
|
|
||||||
|
function submitForm() {
|
||||||
|
if (typeof form.requestSubmit === "function") {
|
||||||
|
form.requestSubmit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
HTMLFormElement.prototype.submit.call(form);
|
||||||
|
}
|
||||||
|
|
||||||
function syncOtp() {
|
function syncOtp() {
|
||||||
fields.forEach(function (field) {
|
fields.forEach(function (field) {
|
||||||
field.classList.toggle("han-filled", Boolean(field.value));
|
field.classList.toggle("han-filled", Boolean(field.value));
|
||||||
});
|
});
|
||||||
hidden.value = fields.map(function (field) { return field.value; }).join("");
|
hidden.value = fields.map(function (field) { return field.value; }).join("");
|
||||||
submit.disabled = hidden.value.length !== fields.length;
|
submit.disabled = submitting || hidden.value.length !== fields.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
fields.forEach(function (field, index) {
|
fields.forEach(function (field, index) {
|
||||||
@@ -106,6 +118,22 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
syncOtp();
|
syncOtp();
|
||||||
|
submit.addEventListener("click", function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
syncOtp();
|
||||||
|
if (submit.disabled || submitting) return;
|
||||||
|
submitting = true;
|
||||||
|
submit.disabled = true;
|
||||||
|
submit.textContent = submit.getAttribute("data-submitting-label") || submitLabel;
|
||||||
|
if (submitError) submitError.hidden = true;
|
||||||
|
submitForm();
|
||||||
|
window.setTimeout(function () {
|
||||||
|
submitting = false;
|
||||||
|
submit.textContent = submitLabel;
|
||||||
|
syncOtp();
|
||||||
|
if (submitError) submitError.hidden = false;
|
||||||
|
}, 12000);
|
||||||
|
});
|
||||||
|
|
||||||
var countdown = document.getElementById("han-resend-countdown");
|
var countdown = document.getElementById("han-resend-countdown");
|
||||||
var countdownValue = countdown && countdown.querySelector("strong");
|
var countdownValue = countdown && countdown.querySelector("strong");
|
||||||
@@ -126,9 +154,17 @@
|
|||||||
}
|
}
|
||||||
updateCountdown();
|
updateCountdown();
|
||||||
if (expiresAt > Date.now()) timer = window.setInterval(updateCountdown, 1000);
|
if (expiresAt > Date.now()) timer = window.setInterval(updateCountdown, 1000);
|
||||||
resend.addEventListener("click", function () {
|
resend.addEventListener("click", function (event) {
|
||||||
if (document.getElementById("han-captcha-container")) return;
|
if (document.getElementById("han-captcha-container")) return;
|
||||||
window.setTimeout(function () { resend.disabled = true; }, 0);
|
event.preventDefault();
|
||||||
|
resend.disabled = true;
|
||||||
|
var action = document.createElement("input");
|
||||||
|
action.type = "hidden";
|
||||||
|
action.name = "otp_action";
|
||||||
|
action.value = "resend";
|
||||||
|
form.appendChild(action);
|
||||||
|
submitForm();
|
||||||
|
window.setTimeout(function () { resend.disabled = false; }, 12000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +178,11 @@
|
|||||||
var widgetId = null;
|
var widgetId = null;
|
||||||
var executing = false;
|
var executing = false;
|
||||||
var pendingSubmitter = null;
|
var pendingSubmitter = null;
|
||||||
|
var executionTimeout = null;
|
||||||
|
|
||||||
function showError() {
|
function showError() {
|
||||||
|
if (executionTimeout) window.clearTimeout(executionTimeout);
|
||||||
|
executionTimeout = null;
|
||||||
executing = false;
|
executing = false;
|
||||||
if (pendingSubmitter) pendingSubmitter.disabled = false;
|
if (pendingSubmitter) pendingSubmitter.disabled = false;
|
||||||
pendingSubmitter = null;
|
pendingSubmitter = null;
|
||||||
@@ -162,6 +201,8 @@
|
|||||||
invisible: true,
|
invisible: true,
|
||||||
hl: "ru",
|
hl: "ru",
|
||||||
callback: function (token) {
|
callback: function (token) {
|
||||||
|
if (executionTimeout) window.clearTimeout(executionTimeout);
|
||||||
|
executionTimeout = null;
|
||||||
if (!token || !pendingSubmitter) {
|
if (!token || !pendingSubmitter) {
|
||||||
showError();
|
showError();
|
||||||
return;
|
return;
|
||||||
@@ -209,6 +250,7 @@
|
|||||||
if (pendingSubmitter) pendingSubmitter.disabled = true;
|
if (pendingSubmitter) pendingSubmitter.disabled = true;
|
||||||
tokenInput.value = "";
|
tokenInput.value = "";
|
||||||
try {
|
try {
|
||||||
|
executionTimeout = window.setTimeout(showError, 12000);
|
||||||
window.smartCaptcha.execute(widgetId);
|
window.smartCaptcha.execute(widgetId);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
showError();
|
showError();
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ RUN apt-get update \
|
|||||||
COPY nginx.conf.template /etc/nginx/templates-src/nginx.conf.template
|
COPY nginx.conf.template /etc/nginx/templates-src/nginx.conf.template
|
||||||
COPY templates /etc/nginx/templates-src/sites
|
COPY templates /etc/nginx/templates-src/sites
|
||||||
COPY snippets /etc/nginx/snippets
|
COPY snippets /etc/nginx/snippets
|
||||||
|
COPY static /etc/nginx/static
|
||||||
COPY scripts/entrypoint.sh /usr/local/bin/han-nginx-entrypoint
|
COPY scripts/entrypoint.sh /usr/local/bin/han-nginx-entrypoint
|
||||||
RUN sed -i 's/\r$//' /usr/local/bin/han-nginx-entrypoint \
|
RUN sed -i 's/\r$//' /usr/local/bin/han-nginx-entrypoint \
|
||||||
&& /bin/sh -n /usr/local/bin/han-nginx-entrypoint \
|
&& /bin/sh -n /usr/local/bin/han-nginx-entrypoint \
|
||||||
&& chmod 0555 /usr/local/bin/han-nginx-entrypoint \
|
&& chmod 0555 /usr/local/bin/han-nginx-entrypoint \
|
||||||
&& find /etc/nginx/templates-src /etc/nginx/snippets -type f -exec chmod 0444 {} +
|
&& find /etc/nginx/templates-src /etc/nginx/snippets /etc/nginx/static -type f -exec chmod 0444 {} +
|
||||||
ENTRYPOINT ["/usr/local/bin/han-nginx-entrypoint"]
|
ENTRYPOINT ["/usr/local/bin/han-nginx-entrypoint"]
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
nginx:
|
nginx:
|
||||||
image: ${NGINX_IMAGE:?NGINX_IMAGE must be pinned by digest}
|
image: ${NGINX_IMAGE:?NGINX_IMAGE must be pinned by digest}
|
||||||
|
labels:
|
||||||
|
com.han.service.name: nginx
|
||||||
environment:
|
environment:
|
||||||
APP_ENV: ${APP_ENV:-production-like}
|
APP_ENV: ${APP_ENV:-production-like}
|
||||||
PUBLIC_HOST: ${PUBLIC_HOST}
|
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||||
@@ -64,4 +66,7 @@ services:
|
|||||||
nofile: {soft: 65536, hard: 65536}
|
nofile: {soft: 65536, hard: 65536}
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options: {max-size: "50m", max-file: "5"}
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "5"
|
||||||
|
tag: nginx
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||||
|
<title>HAN Chat</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 32px 24px;
|
||||||
|
background: #fff;
|
||||||
|
color: #252525;
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
p { margin: 0; line-height: 1.5; }
|
||||||
|
a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 220px;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #030213;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<p>Вход выполнен. Возвращаем в приложение…</p>
|
||||||
|
<p>
|
||||||
|
<a id="han-open-app" href="han-chat://auth/callbackHAN_CALLBACK_QUERY">Открыть HAN Chat</a>
|
||||||
|
</p>
|
||||||
|
<script src="/mobile/oidc/callback.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
(function () {
|
||||||
|
var target = "han-chat://auth/callback" + window.location.search + window.location.hash;
|
||||||
|
var link = document.getElementById("han-open-app");
|
||||||
|
if (link) link.href = target;
|
||||||
|
window.location.replace(target);
|
||||||
|
})();
|
||||||
@@ -73,7 +73,9 @@ server {
|
|||||||
proxy_cache_methods GET HEAD;
|
proxy_cache_methods GET HEAD;
|
||||||
proxy_cache_bypass $http_authorization;
|
proxy_cache_bypass $http_authorization;
|
||||||
proxy_no_cache $http_authorization $upstream_http_set_cookie;
|
proxy_no_cache $http_authorization $upstream_http_set_cookie;
|
||||||
proxy_cache_valid 200 1h;
|
proxy_cache_revalidate on;
|
||||||
|
proxy_cache_valid 200 60s;
|
||||||
|
proxy_cache_valid 304 60s;
|
||||||
proxy_pass http://api_backend;
|
proxy_pass http://api_backend;
|
||||||
}
|
}
|
||||||
location = /api/v1/public/content {
|
location = /api/v1/public/content {
|
||||||
@@ -139,6 +141,23 @@ server {
|
|||||||
add_header Cache-Control "no-cache";
|
add_header Cache-Control "no-cache";
|
||||||
include /etc/nginx/generated/security-headers.conf;
|
include /etc/nginx/generated/security-headers.conf;
|
||||||
}
|
}
|
||||||
|
location = /mobile/oidc/callback {
|
||||||
|
default_type text/html;
|
||||||
|
charset utf-8;
|
||||||
|
alias /etc/nginx/static/mobile-oidc-callback.html;
|
||||||
|
sub_filter_once on;
|
||||||
|
sub_filter_types text/html;
|
||||||
|
sub_filter HAN_CALLBACK_QUERY ?$args;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
include /etc/nginx/generated/security-headers.conf;
|
||||||
|
}
|
||||||
|
location = /mobile/oidc/callback.js {
|
||||||
|
default_type text/javascript;
|
||||||
|
charset utf-8;
|
||||||
|
alias /etc/nginx/static/mobile-oidc-callback.js;
|
||||||
|
add_header Cache-Control "no-store" always;
|
||||||
|
include /etc/nginx/generated/security-headers.conf;
|
||||||
|
}
|
||||||
location ^~ /auth/resources/ {
|
location ^~ /auth/resources/ {
|
||||||
include /etc/nginx/snippets/proxy-keycloak.conf;
|
include /etc/nginx/snippets/proxy-keycloak.conf;
|
||||||
proxy_pass http://keycloak_upstream;
|
proxy_pass http://keycloak_upstream;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
APP_ENV: ${APP_ENV:-production-like}
|
APP_ENV: ${APP_ENV:-production-like}
|
||||||
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
|
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
|
||||||
|
HOST_NAME: ${HOST_NAME:?HOST_NAME is required}
|
||||||
OTEL_REMOTE_ENDPOINT: ${OTEL_REMOTE_ENDPOINT}
|
OTEL_REMOTE_ENDPOINT: ${OTEL_REMOTE_ENDPOINT}
|
||||||
OTEL_REMOTE_TLS_INSECURE: ${OTEL_REMOTE_TLS_INSECURE:-false}
|
OTEL_REMOTE_TLS_INSECURE: ${OTEL_REMOTE_TLS_INSECURE:-false}
|
||||||
expose: ["4317", "4318", "13133", "8888"]
|
expose: ["4317", "4318", "13133", "8888"]
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ processors:
|
|||||||
- {key: service.namespace, value: han-chat, action: upsert}
|
- {key: service.namespace, value: han-chat, action: upsert}
|
||||||
- {key: deployment.environment, value: "${env:APP_ENV}", action: upsert}
|
- {key: deployment.environment, value: "${env:APP_ENV}", action: upsert}
|
||||||
- {key: service.version, value: "${env:RELEASE_VERSION}", action: upsert}
|
- {key: service.version, value: "${env:RELEASE_VERSION}", action: upsert}
|
||||||
|
- {key: host.name, value: "${env:HOST_NAME}", action: upsert}
|
||||||
attributes/redact:
|
attributes/redact:
|
||||||
actions:
|
actions:
|
||||||
- {key: http.request.header.authorization, action: delete}
|
- {key: http.request.header.authorization, action: delete}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
redis:
|
redis:
|
||||||
image: ${REDIS_IMAGE:?REDIS_IMAGE must be pinned by digest}
|
image: ${REDIS_IMAGE:?REDIS_IMAGE must be pinned by digest}
|
||||||
|
labels:
|
||||||
|
com.han.service.name: redis
|
||||||
environment:
|
environment:
|
||||||
REDIS_MAXMEMORY: ${REDIS_MAXMEMORY:-384mb}
|
REDIS_MAXMEMORY: ${REDIS_MAXMEMORY:-384mb}
|
||||||
REDIS_EVICTION_POLICY: ${REDIS_EVICTION_POLICY:-volatile-lru}
|
REDIS_EVICTION_POLICY: ${REDIS_EVICTION_POLICY:-volatile-lru}
|
||||||
@@ -32,7 +34,10 @@ services:
|
|||||||
nofile: {soft: 65536, hard: 65536}
|
nofile: {soft: 65536, hard: 65536}
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
options: {max-size: "50m", max-file: "5"}
|
options:
|
||||||
|
max-size: "50m"
|
||||||
|
max-file: "5"
|
||||||
|
tag: redis
|
||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
redis_api_password:
|
redis_api_password:
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ from urllib.parse import urlparse
|
|||||||
REQUIRED_CONFIG = {
|
REQUIRED_CONFIG = {
|
||||||
"SECRETS_SOURCE", "APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "HAN_PG_PORT",
|
"SECRETS_SOURCE", "APP_ENV", "RELEASE_VERSION", "HAN_PG_HOST", "HAN_PG_PORT",
|
||||||
"HAN_PG_DATABASE", "PG_CA_HOST_PATH", "KEYCLOAK_DB_URL", "KEYCLOAK_DB_USERNAME",
|
"HAN_PG_DATABASE", "PG_CA_HOST_PATH", "KEYCLOAK_DB_URL", "KEYCLOAK_DB_USERNAME",
|
||||||
"PUBLIC_HOST", "PUBLIC_WEB_URL",
|
"PUBLIC_HOST", "PUBLIC_WEB_URL", "HOST_NAME",
|
||||||
"PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL",
|
"PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL",
|
||||||
"KEYCLOAK_INTERNAL_URL", "KEYCLOAK_REALM", "KEYCLOAK_SMS_SERVICE_URL",
|
"KEYCLOAK_INTERNAL_URL", "KEYCLOAK_REALM", "KEYCLOAK_SMS_SERVICE_URL",
|
||||||
"IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
|
"IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
|
||||||
"MESSAGE_SAFETY_URL", "MESSAGE_SAFETY_EXTRA_HOST",
|
"MESSAGE_SAFETY_URL", "MESSAGE_SAFETY_EXTRA_HOST",
|
||||||
"MESSAGE_SAFETY_CA_HOST_PATH", "MESSAGE_SAFETY_API_PREFIX",
|
"MESSAGE_SAFETY_CA_HOST_PATH", "MESSAGE_SAFETY_API_PREFIX",
|
||||||
|
"OTEL_REMOTE_ENDPOINT", "OTEL_REMOTE_TLS_INSECURE", "TELEMETRYGEN_IMAGE",
|
||||||
}
|
}
|
||||||
REQUIRED_RUNTIME = {
|
REQUIRED_RUNTIME = {
|
||||||
"DATABASE_URL", "BITRIX_DATABASE_URL",
|
"DATABASE_URL", "BITRIX_DATABASE_URL",
|
||||||
@@ -197,14 +198,29 @@ def validate_shared(env: dict[str, str], errors: list[str]) -> None:
|
|||||||
errors.append(f"{key}: ожидается http URL с Docker DNS service name")
|
errors.append(f"{key}: ожидается http URL с Docker DNS service name")
|
||||||
if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"):
|
if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/auth"):
|
||||||
errors.append("KEYCLOAK_INTERNAL_URL: внутренний URL должен заканчиваться на /auth")
|
errors.append("KEYCLOAK_INTERNAL_URL: внутренний URL должен заканчиваться на /auth")
|
||||||
if env.get("MESSAGE_SAFETY_URL", "").rstrip("/") != "https://processing.internal:8443":
|
safety_url = env.get("MESSAGE_SAFETY_URL", "").rstrip("/")
|
||||||
|
parsed_safety_url = urlparse(safety_url)
|
||||||
|
try:
|
||||||
|
safety_port = parsed_safety_url.port
|
||||||
|
except ValueError:
|
||||||
|
safety_port = None
|
||||||
|
safety_host = parsed_safety_url.hostname or ""
|
||||||
|
if (
|
||||||
|
parsed_safety_url.scheme != "https"
|
||||||
|
or safety_port != 8443
|
||||||
|
or not re.fullmatch(r"[A-Za-z0-9.-]+", safety_host)
|
||||||
|
or safety_host.lower() in {"message-safety", "localhost", "127.0.0.1"}
|
||||||
|
or parsed_safety_url.path
|
||||||
|
or parsed_safety_url.params
|
||||||
|
or parsed_safety_url.query
|
||||||
|
or parsed_safety_url.fragment
|
||||||
|
):
|
||||||
errors.append(
|
errors.append(
|
||||||
"MESSAGE_SAFETY_URL: ожидается remote TLS endpoint "
|
"MESSAGE_SAFETY_URL: ожидается remote TLS endpoint "
|
||||||
"https://processing.internal:8443"
|
"https://<private VM2 hostname>:8443, не local/stub service"
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
extra_host, extra_ip = env.get("MESSAGE_SAFETY_EXTRA_HOST", "").rsplit("=", 1)
|
extra_host, extra_ip = env.get("MESSAGE_SAFETY_EXTRA_HOST", "").rsplit("=", 1)
|
||||||
safety_host = urlparse(env.get("MESSAGE_SAFETY_URL", "")).hostname
|
|
||||||
address = ipaddress.ip_address(extra_ip)
|
address = ipaddress.ip_address(extra_ip)
|
||||||
if extra_host != safety_host or not address.is_private:
|
if extra_host != safety_host or not address.is_private:
|
||||||
raise ValueError
|
raise ValueError
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping, MutableMapping
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
REDACTED = "[REDACTED]"
|
REDACTED = "[REDACTED]"
|
||||||
@@ -42,6 +42,6 @@ def sanitize_value(value: Any) -> Any:
|
|||||||
def redact_event(
|
def redact_event(
|
||||||
_logger: Any,
|
_logger: Any,
|
||||||
_method_name: str,
|
_method_name: str,
|
||||||
event_dict: dict[str, Any],
|
event_dict: MutableMapping[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> MutableMapping[str, Any]:
|
||||||
return sanitize_value(event_dict)
|
return sanitize_value(event_dict)
|
||||||
|
|||||||
@@ -32,13 +32,18 @@ from app.service import (
|
|||||||
read_message,
|
read_message,
|
||||||
)
|
)
|
||||||
from app.settings import get_settings
|
from app.settings import get_settings
|
||||||
from app.telemetry import add_trace_context, init_telemetry, instrument_fastapi
|
from app.telemetry import TelemetryRuntime, add_trace_context, init_telemetry, instrument_fastapi
|
||||||
|
|
||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: str) -> None:
|
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
|
||||||
logging.basicConfig(level=level, format="%(message)s")
|
logging.basicConfig(level=level, format="%(message)s")
|
||||||
|
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
|
||||||
|
telemetry.logging_handler.addFilter(
|
||||||
|
lambda record: not record.name.startswith("opentelemetry")
|
||||||
|
)
|
||||||
|
logging.getLogger().addHandler(telemetry.logging_handler)
|
||||||
structlog.configure(
|
structlog.configure(
|
||||||
processors=[
|
processors=[
|
||||||
structlog.contextvars.merge_contextvars,
|
structlog.contextvars.merge_contextvars,
|
||||||
@@ -47,7 +52,10 @@ def configure_logging(level: str) -> None:
|
|||||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||||
structlog.stdlib.add_log_level,
|
structlog.stdlib.add_log_level,
|
||||||
structlog.processors.JSONRenderer(),
|
structlog.processors.JSONRenderer(),
|
||||||
]
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +63,7 @@ def configure_logging(level: str) -> None:
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
telemetry = init_telemetry()
|
telemetry = init_telemetry()
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level, telemetry)
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.db = Database(settings.database_url)
|
app.state.db = Database(settings.database_url)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,17 +1,23 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
from collections.abc import MutableMapping
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from opentelemetry import metrics, trace
|
from opentelemetry import metrics, trace
|
||||||
|
from opentelemetry._logs import set_logger_provider
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||||
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||||
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||||
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
||||||
from opentelemetry.propagate import set_global_textmap
|
from opentelemetry.propagate import set_global_textmap
|
||||||
|
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
||||||
|
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
||||||
from opentelemetry.sdk.metrics import MeterProvider
|
from opentelemetry.sdk.metrics import MeterProvider
|
||||||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||||
from opentelemetry.sdk.resources import Resource
|
from opentelemetry.sdk.resources import Resource
|
||||||
@@ -25,8 +31,11 @@ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapProp
|
|||||||
class TelemetryRuntime:
|
class TelemetryRuntime:
|
||||||
tracer_provider: TracerProvider
|
tracer_provider: TracerProvider
|
||||||
meter_provider: MeterProvider
|
meter_provider: MeterProvider
|
||||||
|
logger_provider: LoggerProvider
|
||||||
|
logging_handler: LoggingHandler
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
|
self.logger_provider.shutdown()
|
||||||
self.meter_provider.shutdown()
|
self.meter_provider.shutdown()
|
||||||
self.tracer_provider.shutdown()
|
self.tracer_provider.shutdown()
|
||||||
|
|
||||||
@@ -53,7 +62,9 @@ def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
|
|||||||
if not endpoint:
|
if not endpoint:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
resource = _resource(service_name or os.getenv("OTEL_SERVICE_NAME", "sms-service"))
|
resource = _resource(
|
||||||
|
service_name or os.getenv("OTEL_SERVICE_NAME", "sms-service") or "sms-service"
|
||||||
|
)
|
||||||
insecure = endpoint.startswith("http://")
|
insecure = endpoint.startswith("http://")
|
||||||
set_global_textmap(TraceContextTextMapPropagator())
|
set_global_textmap(TraceContextTextMapPropagator())
|
||||||
|
|
||||||
@@ -77,9 +88,27 @@ def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
|
|||||||
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||||
metrics.set_meter_provider(meter_provider)
|
metrics.set_meter_provider(meter_provider)
|
||||||
|
|
||||||
|
logger_provider = LoggerProvider(resource=resource)
|
||||||
|
logger_provider.add_log_record_processor(
|
||||||
|
BatchLogRecordProcessor(
|
||||||
|
OTLPLogExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||||
|
max_queue_size=2048,
|
||||||
|
schedule_delay_millis=5000,
|
||||||
|
max_export_batch_size=512,
|
||||||
|
export_timeout_millis=3000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
set_logger_provider(logger_provider)
|
||||||
|
logging_handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
|
||||||
|
|
||||||
HTTPXClientInstrumentor().instrument()
|
HTTPXClientInstrumentor().instrument()
|
||||||
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||||
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
|
_runtime = TelemetryRuntime(
|
||||||
|
tracer_provider,
|
||||||
|
meter_provider,
|
||||||
|
logger_provider,
|
||||||
|
logging_handler,
|
||||||
|
)
|
||||||
return _runtime
|
return _runtime
|
||||||
|
|
||||||
|
|
||||||
@@ -93,8 +122,8 @@ def instrument_fastapi(app: FastAPI) -> None:
|
|||||||
def add_trace_context(
|
def add_trace_context(
|
||||||
_logger: Any,
|
_logger: Any,
|
||||||
_method_name: str,
|
_method_name: str,
|
||||||
event_dict: dict[str, Any],
|
event_dict: MutableMapping[str, Any],
|
||||||
) -> dict[str, Any]:
|
) -> MutableMapping[str, Any]:
|
||||||
context = trace.get_current_span().get_span_context()
|
context = trace.get_current_span().get_span_context()
|
||||||
if context.is_valid:
|
if context.is_valid:
|
||||||
event_dict["trace_id"] = format(context.trace_id, "032x")
|
event_dict["trace_id"] = format(context.trace_id, "032x")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
import httpx
|
import httpx
|
||||||
import structlog
|
import structlog
|
||||||
from opentelemetry import trace
|
from opentelemetry import trace
|
||||||
|
from opentelemetry.propagate import extract
|
||||||
from prometheus_client import start_http_server
|
from prometheus_client import start_http_server
|
||||||
from sqlalchemy import and_, func, or_, select, update
|
from sqlalchemy import and_, func, or_, select, update
|
||||||
|
|
||||||
@@ -26,14 +27,27 @@ from app.metrics import (
|
|||||||
from app.provider import IdgtlClient, IdgtlConfig
|
from app.provider import IdgtlClient, IdgtlConfig
|
||||||
from app.service import RuntimeSettings, load_runtime_settings
|
from app.service import RuntimeSettings, load_runtime_settings
|
||||||
from app.settings import Settings, get_settings
|
from app.settings import Settings, get_settings
|
||||||
from app.telemetry import add_trace_context, init_telemetry
|
from app.telemetry import TelemetryRuntime, add_trace_context, init_telemetry
|
||||||
|
|
||||||
log = structlog.get_logger()
|
log = structlog.get_logger()
|
||||||
MAX_CONNECT_ATTEMPTS = 3
|
MAX_CONNECT_ATTEMPTS = 3
|
||||||
|
|
||||||
|
|
||||||
def configure_logging(level: str) -> None:
|
def origin_links(traceparent: str | None) -> list[trace.Link]:
|
||||||
|
if not traceparent:
|
||||||
|
return []
|
||||||
|
context = extract({"traceparent": traceparent})
|
||||||
|
span_context = trace.get_current_span(context).get_span_context()
|
||||||
|
return [trace.Link(span_context)] if span_context.is_valid else []
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str, telemetry: TelemetryRuntime | None = None) -> None:
|
||||||
logging.basicConfig(level=level, format="%(message)s")
|
logging.basicConfig(level=level, format="%(message)s")
|
||||||
|
if telemetry and telemetry.logging_handler not in logging.getLogger().handlers:
|
||||||
|
telemetry.logging_handler.addFilter(
|
||||||
|
lambda record: not record.name.startswith("opentelemetry")
|
||||||
|
)
|
||||||
|
logging.getLogger().addHandler(telemetry.logging_handler)
|
||||||
structlog.configure(
|
structlog.configure(
|
||||||
processors=[
|
processors=[
|
||||||
structlog.contextvars.merge_contextvars,
|
structlog.contextvars.merge_contextvars,
|
||||||
@@ -42,7 +56,10 @@ def configure_logging(level: str) -> None:
|
|||||||
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
structlog.processors.TimeStamper(fmt="iso", utc=True, key="timestamp"),
|
||||||
structlog.stdlib.add_log_level,
|
structlog.stdlib.add_log_level,
|
||||||
structlog.processors.JSONRenderer(),
|
structlog.processors.JSONRenderer(),
|
||||||
]
|
],
|
||||||
|
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||||
|
wrapper_class=structlog.stdlib.BoundLogger,
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -177,7 +194,7 @@ async def update_queue_metrics(db: Database) -> None:
|
|||||||
async def worker_loop(stop: asyncio.Event) -> None:
|
async def worker_loop(stop: asyncio.Event) -> None:
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
telemetry = init_telemetry("sms-worker")
|
telemetry = init_telemetry("sms-worker")
|
||||||
configure_logging(settings.log_level)
|
configure_logging(settings.log_level, telemetry)
|
||||||
structlog.contextvars.bind_contextvars(**{"service.name": "sms-worker"})
|
structlog.contextvars.bind_contextvars(**{"service.name": "sms-worker"})
|
||||||
metrics_server, metrics_thread = start_http_server(
|
metrics_server, metrics_thread = start_http_server(
|
||||||
settings.metrics_port,
|
settings.metrics_port,
|
||||||
@@ -200,8 +217,17 @@ async def worker_loop(stop: asyncio.Event) -> None:
|
|||||||
await update_queue_metrics(db)
|
await update_queue_metrics(db)
|
||||||
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
|
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
|
||||||
continue
|
continue
|
||||||
process_span = tracer.start_span("sms.process", start_time=claim_started_ns)
|
process_span = tracer.start_span(
|
||||||
with trace.use_span(process_span, end_on_exit=True):
|
"sms.process",
|
||||||
|
start_time=claim_started_ns,
|
||||||
|
links=origin_links(message.traceparent),
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
trace.use_span(process_span, end_on_exit=True),
|
||||||
|
structlog.contextvars.bound_contextvars(
|
||||||
|
sms_message_id=str(message.id)
|
||||||
|
),
|
||||||
|
):
|
||||||
claim_span = tracer.start_span("sms.claim", start_time=claim_started_ns)
|
claim_span = tracer.start_span("sms.claim", start_time=claim_started_ns)
|
||||||
claim_span.set_attribute("sms.claimed", True)
|
claim_span.set_attribute("sms.claimed", True)
|
||||||
claim_span.end(end_time=claim_finished_ns)
|
claim_span.end(end_time=claim_finished_ns)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from opentelemetry.sdk.trace import TracerProvider
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
|
|
||||||
from app import telemetry
|
from app import telemetry
|
||||||
|
from app.worker import origin_links
|
||||||
|
|
||||||
|
|
||||||
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
|
def test_telemetry_is_fail_open_without_endpoint(monkeypatch) -> None:
|
||||||
@@ -19,3 +20,8 @@ def test_structlog_processor_adds_active_trace_context() -> None:
|
|||||||
assert result["event"] == "sms.safe"
|
assert result["event"] == "sms.safe"
|
||||||
assert len(result["trace_id"]) == 32
|
assert len(result["trace_id"]) == 32
|
||||||
assert len(result["span_id"]) == 16
|
assert len(result["span_id"]) == 16
|
||||||
|
|
||||||
|
|
||||||
|
def test_sms_worker_rejects_invalid_origin_context() -> None:
|
||||||
|
assert origin_links(None) == []
|
||||||
|
assert origin_links("00-invalid") == []
|
||||||
|
|||||||
@@ -348,9 +348,63 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
source = service_main.read_text(encoding="utf-8")
|
source = service_main.read_text(encoding="utf-8")
|
||||||
self.assertNotIn("route=request.url.path", source)
|
self.assertNotIn("route=request.url.path", source)
|
||||||
self.assertIn('getattr(request.scope.get("route"), "path"', source)
|
self.assertIn('getattr(request.scope.get("route"), "path"', source)
|
||||||
|
for telemetry_path in (
|
||||||
|
ROOT / "api-backend/app/telemetry.py",
|
||||||
|
ROOT / "sms-service/app/telemetry.py",
|
||||||
|
ROOT / "bitrix-local-app/app/telemetry.py",
|
||||||
|
):
|
||||||
|
telemetry = telemetry_path.read_text(encoding="utf-8")
|
||||||
|
self.assertIn("OTLPLogExporter", telemetry)
|
||||||
|
self.assertIn("BatchLogRecordProcessor", telemetry)
|
||||||
|
self.assertIn("LoggingHandler", telemetry)
|
||||||
worker = (ROOT / "sms-service/app/worker.py").read_text(encoding="utf-8")
|
worker = (ROOT / "sms-service/app/worker.py").read_text(encoding="utf-8")
|
||||||
for span_name in ("sms.claim", "sms.process", "sms.provider", "sms.save_result"):
|
for span_name in ("sms.claim", "sms.process", "sms.provider", "sms.save_result"):
|
||||||
self.assertIn(f'"{span_name}"', worker)
|
self.assertIn(f'"{span_name}"', worker)
|
||||||
|
bitrix = (ROOT / "bitrix-local-app/app/main.py").read_text(encoding="utf-8")
|
||||||
|
self.assertIn('EXPECTED_BITRIX_DB_REVISION = "0002_inbox_trace_context"', bitrix)
|
||||||
|
self.assertIn("links=origin_links(row.traceparent)", bitrix)
|
||||||
|
bitrix_migration = (
|
||||||
|
ROOT / "bitrix-local-app/alembic/versions/0002_inbox_trace_context.py"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
self.assertIn('down_revision: str | None = "0001_bitrix_local"', bitrix_migration)
|
||||||
|
|
||||||
|
def test_host_otel_collector_is_hardened_and_allowlisted(self) -> None:
|
||||||
|
config = (
|
||||||
|
ROOT / "deployment/observability/otel-host-collector.yaml"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
unit = (ROOT / "deployment/han-host-otel-collector@.service").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
setup = (ROOT / "deployment/scripts/setup-vm.sh").read_text(encoding="utf-8")
|
||||||
|
for required in (
|
||||||
|
"filelog/docker",
|
||||||
|
"journald/host",
|
||||||
|
"filter/allowlist",
|
||||||
|
"storage: file_storage",
|
||||||
|
'layout: "2006-01-02T15:04:05.000000000Z07:00"',
|
||||||
|
'set(body, "nginx.access")',
|
||||||
|
'set(body, "keycloak.event")',
|
||||||
|
'{key: uri, action: delete}',
|
||||||
|
'resource.attributes["service.name"] != "nginx"',
|
||||||
|
'resource.attributes["service.name"] != "keycloak"',
|
||||||
|
'resource.attributes["service.name"] != "redis"',
|
||||||
|
):
|
||||||
|
self.assertIn(required, config)
|
||||||
|
self.assertNotIn("/var/run/docker.sock", config + unit)
|
||||||
|
self.assertNotIn('from: attributes["service.name"]', config)
|
||||||
|
self.assertIn("CapabilityBoundingSet=", unit)
|
||||||
|
self.assertIn("NoNewPrivileges=yes", unit)
|
||||||
|
self.assertIn("ReadOnlyPaths=/var/lib/docker/containers", unit)
|
||||||
|
self.assertIn("ReadWritePaths=/var/lib/han-otel/host-collector", unit)
|
||||||
|
self.assertIn("OTEL_HOST_COLLECTOR_SHA256", setup)
|
||||||
|
self.assertIn("sha256sum -c -", setup)
|
||||||
|
self.assertIn("journalctl --flush", setup)
|
||||||
|
self.assertNotIn("systemctl start han-host-otel-collector", setup)
|
||||||
|
verify = (
|
||||||
|
ROOT / "deployment/scripts/verify-observability.sh"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
self.assertIn("TELEMETRYGEN_IMAGE", verify)
|
||||||
|
self.assertNotIn("telemetrygen:latest", verify)
|
||||||
|
|
||||||
def test_settings_cli_and_workers_are_deployable(self) -> None:
|
def test_settings_cli_and_workers_are_deployable(self) -> None:
|
||||||
cli = ROOT / "api-backend/app/cli"
|
cli = ROOT / "api-backend/app/cli"
|
||||||
@@ -442,7 +496,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
dedup_fix,
|
dedup_fix,
|
||||||
)
|
)
|
||||||
self.assertIn('down_revision: str | None = "0009_chat_message_max"', dedup_fix)
|
self.assertIn('down_revision: str | None = "0009_chat_message_max"', dedup_fix)
|
||||||
self.assertIn('EXPECTED_API_DB_REVISION = "0012_safety_v2_checkpoint"', main)
|
self.assertIn('EXPECTED_API_DB_REVISION = "0013_delivery_trace_context"', main)
|
||||||
|
|
||||||
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
|
def test_consent_audit_migration_supports_existing_and_fresh_databases(self) -> None:
|
||||||
migration = (
|
migration = (
|
||||||
|
|||||||
@@ -152,6 +152,28 @@ class SecretHygieneTests(unittest.TestCase):
|
|||||||
self.assertNotEqual(result.returncode, 0)
|
self.assertNotEqual(result.returncode, 0)
|
||||||
self.assertIn(expected_error, result.stderr)
|
self.assertIn(expected_error, result.stderr)
|
||||||
|
|
||||||
|
def test_validator_accepts_configured_private_safety_hostname(self) -> None:
|
||||||
|
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
||||||
|
configured = (
|
||||||
|
example.replace(
|
||||||
|
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false",
|
||||||
|
"KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=true",
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"MESSAGE_SAFETY_URL=https://processing.internal:8443",
|
||||||
|
"MESSAGE_SAFETY_URL=https://safety.vm2.corp.internal:8443",
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
"MESSAGE_SAFETY_EXTRA_HOST=processing.internal=192.168.0.4",
|
||||||
|
"MESSAGE_SAFETY_EXTRA_HOST=safety.vm2.corp.internal=192.168.0.4",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
config = Path(directory) / ".env"
|
||||||
|
config.write_text(configured, encoding="utf-8")
|
||||||
|
result = self.run_validator(config)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -960,7 +960,7 @@ Inbound operator file:
|
|||||||
- validate count/size/MIME and URL scheme/host policy;
|
- validate count/size/MIME and URL scheme/host policy;
|
||||||
- protect against SSRF: no redirects to private/link-local ranges, DNS rebinding checks, max bytes streaming;
|
- protect against SSRF: no redirects to private/link-local ranges, DNS rebinding checks, max bytes streaming;
|
||||||
- download with timeout to temporary stream, never local persistent disk;
|
- download with timeout to temporary stream, never local persistent disk;
|
||||||
- Message Safety/ClamAV не вызываются; остаточный malware-риск доверенного Bitrix24-channel принят для MVP;
|
- Message Safety/KESL не вызываются; остаточный malware-риск доверенного Bitrix24-channel принят для MVP;
|
||||||
- upload directly to S3-data attachments;
|
- upload directly to S3-data attachments;
|
||||||
- only then atomically save attachment/message and ack inbox.
|
- only then atomically save attachment/message and ack inbox.
|
||||||
|
|
||||||
@@ -1085,7 +1085,7 @@ Runtime refresh: poll `MAX(updated_at)` каждые 30 секунд; новый
|
|||||||
|
|
||||||
`SELECTEL_S3_QUARANTINE_READ_*` принадлежит `message-safety`, не должен передаваться контейнеру API. Новые env сначала документируются в arch-04.
|
`SELECTEL_S3_QUARANTINE_READ_*` принадлежит `message-safety`, не должен передаваться контейнеру API. Новые env сначала документируются в arch-04.
|
||||||
|
|
||||||
В production validator принимает только remote `https://<private-vm2-name>:8443`, требует читаемый `MESSAGE_SAFETY_CA_FILE`, отклоняет plaintext и cross-host Docker hostname. Host bind CA задаётся runbook-переменной `MESSAGE_SAFETY_CA_HOST_PATH`; runtime использует только container path `MESSAGE_SAFETY_CA_FILE`.
|
В production validator принимает remote `https://<private-vm2-name>:8443`, требует читаемый `MESSAGE_SAFETY_CA_FILE`, отклоняет plaintext и local/stub Docker hostname. Конкретное private DNS-имя ВМ2 определяется внутренним доменом окружения и задаётся в `MESSAGE_SAFETY_URL` и согласованном `MESSAGE_SAFETY_EXTRA_HOST`; каноническое имя не требуется. Host bind CA задаётся runbook-переменной `MESSAGE_SAFETY_CA_HOST_PATH`; runtime использует только container path `MESSAGE_SAFETY_CA_FILE`.
|
||||||
|
|
||||||
## 19. Rate limiting
|
## 19. Rate limiting
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Nginx ВМ1 — публичная точка входа приложения: f
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS |
|
| `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS |
|
||||||
| `/auth/` | `keycloak:8080` | OIDC/OTP, prefix/hostname согласован с issuer |
|
| `/auth/` | `keycloak:8080` | OIDC/OTP, prefix/hostname согласован с issuer |
|
||||||
|
| exact `/mobile/oidc/callback` | static nginx | HTTPS-мост Android Custom Tabs → `han-chat://auth/callback` |
|
||||||
| exact `/callbacks/idgtl/sms` | `sms-service:8080` | public HTTPS POST Direct; IP allowlist + Basic auth в upstream |
|
| exact `/callbacks/idgtl/sms` | `sms-service:8080` | public HTTPS POST Direct; IP allowlist + Basic auth в upstream |
|
||||||
| `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS |
|
| `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS |
|
||||||
| exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy |
|
| exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy |
|
||||||
|
|||||||
@@ -111,7 +111,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
|||||||
"occurred_at": "2026-07-10T09:00:00Z",
|
"occurred_at": "2026-07-10T09:00:00Z",
|
||||||
"user": {
|
"user": {
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
"display_name": "Клиент HAN"
|
"display_name": "Новый клиент HAN",
|
||||||
|
"phone": "+79990000000"
|
||||||
},
|
},
|
||||||
"message": {
|
"message": {
|
||||||
"content_kind": "text",
|
"content_kind": "text",
|
||||||
@@ -128,7 +129,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
|||||||
"message_id": "uuid",
|
"message_id": "uuid",
|
||||||
"external_chat_id": "uuid",
|
"external_chat_id": "uuid",
|
||||||
"occurred_at": "2026-07-10T09:00:00Z",
|
"occurred_at": "2026-07-10T09:00:00Z",
|
||||||
"user": {"id": "uuid", "display_name": "Клиент HAN"},
|
"user": {"id": "uuid", "display_name": "Новый клиент HAN", "phone": "+79990000000"},
|
||||||
"message": {
|
"message": {
|
||||||
"content_kind": "file",
|
"content_kind": "file",
|
||||||
"text": "",
|
"text": "",
|
||||||
@@ -148,7 +149,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
|||||||
- `external_chat_id` строго UUID и равен App `dialog_id`;
|
- `external_chat_id` строго UUID и равен App `dialog_id`;
|
||||||
- `text` xor один file; unknown fields запрещены;
|
- `text` xor один file; unknown fields запрещены;
|
||||||
- signed URL не сохраняется в обычные логи и редактируется в durable payload по истечении необходимости;
|
- signed URL не сохраняется в обычные логи и редактируется в durable payload по истечении необходимости;
|
||||||
- PII профиля не требуется; телефон/email не передаются;
|
- `display_name` — `ClientProfile.full_name`, если пусто — `Новый клиент HAN`;
|
||||||
|
- телефон клиента передаётся в `user.phone` и мапится в `MESSAGES[0][user][phone]` для CRM-лида; email не передаётся;
|
||||||
- fingerprint строится по стабильным полям без signed query;
|
- fingerprint строится по стабильным полям без signed query;
|
||||||
- тот же key/fingerprint возвращает прежний результат;
|
- тот же key/fingerprint возвращает прежний результат;
|
||||||
- тот же key с иным fingerprint → `409 idempotency_key_reused`.
|
- тот же key с иным fingerprint → `409 idempotency_key_reused`.
|
||||||
@@ -198,7 +200,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
|||||||
|
|
||||||
1. Аутентифицировать caller и зарезервировать `outbound_messages` по `message_id`.
|
1. Аутентифицировать caller и зарезервировать `outbound_messages` по `message_id`.
|
||||||
2. При completed вернуть сохранённый sanitized result.
|
2. При completed вернуть сохранённый sanitized result.
|
||||||
3. Собрать `MESSAGES` Bitrix: `user.id`, `message.id/date/text/files`, `chat.id`.
|
3. Собрать `MESSAGES` Bitrix: `user.id/name/phone`, `message.id/date/text/files`, `chat.id`.
|
||||||
4. Вызвать `imconnector.send.messages` с `CONNECTOR=han_mobile_app`, `LINE=8`.
|
4. Вызвать `imconnector.send.messages` с `CONNECTOR=han_mobile_app`, `LINE=8`.
|
||||||
5. Извлечь `CHAT_ID`, session `ID`, Bitrix message id из допускаемых вариантов ответа.
|
5. Извлечь `CHAT_ID`, session `ID`, Bitrix message id из допускаемых вариантов ответа.
|
||||||
6. В одной транзакции upsert `dialog_sessions`, записать result, status `delivered`.
|
6. В одной транзакции upsert `dialog_sessions`, записать result, status `delivered`.
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ han-chat-frontend
|
|||||||
|
|
||||||
```text
|
```text
|
||||||
https://tohin.ru/auth/callback
|
https://tohin.ru/auth/callback
|
||||||
|
https://tohin.ru/mobile/oidc/callback
|
||||||
han-chat://auth/callback
|
han-chat://auth/callback
|
||||||
<Expo native scheme/callback, exact value после сборки>
|
<Expo native scheme/callback, exact value после сборки>
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
Документ задаёт, **что агент ВМ1 реализует в Compose, коде, тестах и алертах этой машины**.
|
Документ задаёт, **что агент ВМ1 реализует в Compose, коде, тестах и алертах этой машины**.
|
||||||
|
|
||||||
ВМ1 владеет публичным edge, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контуром, Redis DB0/DB1 и локальным Collector. Message Safety, `bitrix-sync`, ClamAV и Redis Safety живут на ВМ2; ВМ1 только вызывает Safety по private HTTPS и продолжает trace.
|
ВМ1 владеет публичным edge, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контуром, Redis DB0/DB1 и локальным Collector. Message Safety, `bitrix-sync` и Redis Safety живут на ВМ2; KESL 12.4 и его локальный scan-broker работают на host ВМ2. ВМ1 только вызывает Safety по private HTTPS и продолжает trace.
|
||||||
|
|
||||||
Агент ВМ1 не добавляет scrape, дашборды и алерты сервисов ВМ2.
|
Агент ВМ1 не добавляет scrape, дашборды и алерты сервисов ВМ2.
|
||||||
|
|
||||||
@@ -22,8 +22,14 @@
|
|||||||
| Keycloak | `keycloak` |
|
| Keycloak | `keycloak` |
|
||||||
| `sms-service` | `sms-service` |
|
| `sms-service` | `sms-service` |
|
||||||
| `sms-worker` | `sms-worker` |
|
| `sms-worker` | `sms-worker` |
|
||||||
|
| delivery worker | `delivery-worker` |
|
||||||
|
| safety recovery worker | `safety-recovery-worker` |
|
||||||
|
| quarantine cleanup worker | `cleanup-worker` |
|
||||||
|
| notification expiration worker | `notification-expire-worker` |
|
||||||
|
| notification draft cleanup worker | `notification-draft-cleanup-worker` |
|
||||||
| Redis DB0/DB1 | `redis` |
|
| Redis DB0/DB1 | `redis` |
|
||||||
| local Collector | `otel-collector` |
|
| local Collector | `otel-collector` |
|
||||||
|
| host telemetry agent | `otel-host-collector` |
|
||||||
|
|
||||||
SMS-метрики и алерты детализированы в [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md); имена зарегистрированы в arch-07 §4.
|
SMS-метрики и алерты детализированы в [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md); имена зарегистрированы в arch-07 §4.
|
||||||
|
|
||||||
@@ -36,6 +42,22 @@ SMS-метрики и алерты детализированы в [`module-11-i
|
|||||||
- export в SigNoz `192.168.0.5:4317`; hostname collector ВМ2 не используется;
|
- export в SigNoz `192.168.0.5:4317`; hostname collector ВМ2 не используется;
|
||||||
- pipeline, processors, limits, `otel-queue-init` и fail-open — arch-07 §3, §13, §14.
|
- pipeline, processors, limits, `otel-queue-init` и fail-open — arch-07 §3, §13, §14.
|
||||||
|
|
||||||
|
Python-сервисы экспортируют structured logs через OTLP Logs SDK в Compose
|
||||||
|
Collector. JSON stdout сохраняется только как bounded аварийный buffer и не
|
||||||
|
собирается повторно.
|
||||||
|
|
||||||
|
Отдельный hardened host agent собирает по allow-list:
|
||||||
|
|
||||||
|
- Docker `json-file` для nginx, Keycloak и Redis;
|
||||||
|
- journal units `docker`, `han-stack`, `han-secrets`, Docker firewall,
|
||||||
|
certbot и fail2ban;
|
||||||
|
- UFW log, если он включён на host.
|
||||||
|
|
||||||
|
Host agent не имеет доступа к Docker socket, исключает собственные логи и
|
||||||
|
Compose Collector, хранит offsets/queue в `/var/lib/han-otel/host-collector`
|
||||||
|
и экспортирует напрямую в тот же SigNoz. Platform events без активного span
|
||||||
|
не получают искусственные trace IDs и ищутся по host/service/time window.
|
||||||
|
|
||||||
Scrape targets ВМ1 (кроме самого Collector): Keycloak management metrics, Redis exporter приложения, nginx exporter, сервисные `/metrics` `api-backend` и `bitrix-local-app`, если они не идут OTLP.
|
Scrape targets ВМ1 (кроме самого Collector): Keycloak management metrics, Redis exporter приложения, nginx exporter, сервисные `/metrics` `api-backend` и `bitrix-local-app`, если они не идут OTLP.
|
||||||
|
|
||||||
## 4. Instrumentation
|
## 4. Instrumentation
|
||||||
|
|||||||
@@ -49,7 +49,9 @@ webhooks собственного host. Compose, IAM principal и secret bundle
|
|||||||
performance, egress и rollback gates своего runbook.
|
performance, egress и rollback gates своего runbook.
|
||||||
2. ВМ1 использует
|
2. ВМ1 использует
|
||||||
`MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS>:8443` и root-owned
|
`MESSAGE_SAFETY_URL=https://<VM2_PRIVATE_DNS>:8443` и root-owned
|
||||||
`MESSAGE_SAFETY_CA_HOST_PATH`.
|
`MESSAGE_SAFETY_CA_HOST_PATH`. Значение `<VM2_PRIVATE_DNS>` определяется
|
||||||
|
внутренним DNS-доменом окружения и задаётся в `.env`; фиксированное
|
||||||
|
каноническое имя не требуется.
|
||||||
3. Internal CA читается фактическим UID API и не читается посторонним UID.
|
3. Internal CA читается фактическим UID API и не читается посторонним UID.
|
||||||
4. Service token paired, private route/SG разрешают `8443` только от ВМ1/ops.
|
4. Service token paired, private route/SG разрешают `8443` только от ВМ1/ops.
|
||||||
5. Local `message-safety`, Redis DB2, local Safety rules env и stub fallback
|
5. Local `message-safety`, Redis DB2, local Safety rules env и stub fallback
|
||||||
|
|||||||
Binary file not shown.
@@ -10,9 +10,11 @@ import { AuthOtp } from './pages/AuthOtp';
|
|||||||
import { AuthLoading } from './pages/AuthLoading';
|
import { AuthLoading } from './pages/AuthLoading';
|
||||||
import { AuthConsent } from './pages/AuthConsent';
|
import { AuthConsent } from './pages/AuthConsent';
|
||||||
import { Root } from './pages/Root';
|
import { Root } from './pages/Root';
|
||||||
|
import { UpdateProvider } from './contexts/UpdateContext';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
|
<UpdateProvider>
|
||||||
<MemoryRouter initialEntries={['/']}>
|
<MemoryRouter initialEntries={['/']}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Root />}>
|
<Route path="/" element={<Root />}>
|
||||||
@@ -30,5 +32,6 @@ export default function App() {
|
|||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
|
</UpdateProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||||
|
import { ArrowUpCircle, AlertTriangle, X } from "lucide-react";
|
||||||
|
import { useUpdate } from "../contexts/UpdateContext";
|
||||||
|
|
||||||
|
export function UpdateModal() {
|
||||||
|
const { config, dismissUpdate } = useUpdate();
|
||||||
|
|
||||||
|
if (!config) return null;
|
||||||
|
|
||||||
|
const isStrict = config.type === "strict";
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
if (config.updateUrl) {
|
||||||
|
window.open(config.updateUrl, "_blank", "noopener,noreferrer");
|
||||||
|
}
|
||||||
|
// В strict-режиме не закрываем — пользователь обязан обновиться
|
||||||
|
if (!isStrict) {
|
||||||
|
dismissUpdate();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenChange = (open: boolean) => {
|
||||||
|
if (!open && !isStrict) {
|
||||||
|
dismissUpdate();
|
||||||
|
}
|
||||||
|
// В strict-режиме игнорируем попытки закрыть
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Root open={true} onOpenChange={handleOpenChange}>
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
{/* Оверлей: в strict-режиме pointer-events-none отключаем клик по фону */}
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
className={[
|
||||||
|
"fixed inset-0 z-50 bg-black/60 backdrop-blur-sm",
|
||||||
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||||
|
isStrict ? "pointer-events-none" : "",
|
||||||
|
].join(" ")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
// В strict-режиме блокируем закрытие по Escape и клику вне
|
||||||
|
onEscapeKeyDown={(e) => isStrict && e.preventDefault()}
|
||||||
|
onPointerDownOutside={(e) => isStrict && e.preventDefault()}
|
||||||
|
onInteractOutside={(e) => isStrict && e.preventDefault()}
|
||||||
|
className={[
|
||||||
|
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
|
||||||
|
"w-full max-w-[360px] rounded-2xl border border-border bg-background shadow-2xl",
|
||||||
|
"data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
|
||||||
|
"focus:outline-none",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{/* Иконка и шапка */}
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"flex flex-col items-center gap-3 rounded-t-2xl px-6 pt-7 pb-5",
|
||||||
|
isStrict
|
||||||
|
? "bg-destructive/10"
|
||||||
|
: "bg-primary/5",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"flex items-center justify-center w-14 h-14 rounded-full",
|
||||||
|
isStrict
|
||||||
|
? "bg-destructive/15 text-destructive"
|
||||||
|
: "bg-primary/10 text-primary",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{isStrict ? (
|
||||||
|
<AlertTriangle className="w-7 h-7" strokeWidth={2} />
|
||||||
|
) : (
|
||||||
|
<ArrowUpCircle className="w-7 h-7" strokeWidth={2} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center">
|
||||||
|
<DialogPrimitive.Title className="text-lg font-semibold text-foreground leading-tight">
|
||||||
|
{isStrict ? "Обновление обязательно" : "Доступно обновление"}
|
||||||
|
</DialogPrimitive.Title>
|
||||||
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
|
Версия{" "}
|
||||||
|
<span className="font-medium text-foreground">
|
||||||
|
{config.newVersion}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Тело */}
|
||||||
|
<div className="px-6 py-5 space-y-4">
|
||||||
|
<DialogPrimitive.Description className="text-sm text-muted-foreground leading-relaxed text-center">
|
||||||
|
{isStrict ? (
|
||||||
|
<>
|
||||||
|
Версия <strong>{config.currentVersion}</strong> больше не
|
||||||
|
поддерживается. Для продолжения работы необходимо установить
|
||||||
|
обновление.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
Вышла новая версия приложения. Вы можете обновить его сейчас
|
||||||
|
или сделать это позже.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Description>
|
||||||
|
|
||||||
|
{config.releaseNotes && (
|
||||||
|
<div className="rounded-xl bg-muted/60 px-4 py-3 text-xs text-muted-foreground leading-relaxed">
|
||||||
|
{config.releaseNotes}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Плашка текущей / новой версии */}
|
||||||
|
<div className="flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="rounded-md bg-muted px-2 py-1">
|
||||||
|
{config.currentVersion}
|
||||||
|
</span>
|
||||||
|
<span>→</span>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"rounded-md px-2 py-1 font-medium",
|
||||||
|
isStrict
|
||||||
|
? "bg-destructive/10 text-destructive"
|
||||||
|
: "bg-primary/10 text-primary",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{config.newVersion}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопки */}
|
||||||
|
<div className="px-6 pb-6 flex flex-col gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleUpdate}
|
||||||
|
className={[
|
||||||
|
"w-full rounded-xl py-3 text-sm font-semibold transition-opacity active:opacity-80",
|
||||||
|
isStrict
|
||||||
|
? "bg-destructive text-destructive-foreground"
|
||||||
|
: "bg-primary text-primary-foreground",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
Обновить приложение
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{!isStrict && (
|
||||||
|
<button
|
||||||
|
onClick={dismissUpdate}
|
||||||
|
className="w-full rounded-xl py-3 text-sm font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/60"
|
||||||
|
>
|
||||||
|
Позже
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Крестик только в soft-режиме */}
|
||||||
|
{!isStrict && (
|
||||||
|
<DialogPrimitive.Close
|
||||||
|
onClick={dismissUpdate}
|
||||||
|
className="absolute right-4 top-4 rounded-full p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground focus:outline-none"
|
||||||
|
aria-label="Закрыть"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
</DialogPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import React, { createContext, useContext, useState, useCallback } from "react";
|
||||||
|
|
||||||
|
export type UpdateType = "soft" | "strict";
|
||||||
|
|
||||||
|
export interface UpdateConfig {
|
||||||
|
type: UpdateType;
|
||||||
|
currentVersion: string;
|
||||||
|
newVersion: string;
|
||||||
|
updateUrl?: string;
|
||||||
|
releaseNotes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateContextValue {
|
||||||
|
config: UpdateConfig | null;
|
||||||
|
showUpdate: (cfg: UpdateConfig) => void;
|
||||||
|
dismissUpdate: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UpdateContext = createContext<UpdateContextValue | null>(null);
|
||||||
|
|
||||||
|
export function UpdateProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [config, setConfig] = useState<UpdateConfig | null>(null);
|
||||||
|
|
||||||
|
const showUpdate = useCallback((cfg: UpdateConfig) => {
|
||||||
|
setConfig(cfg);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dismissUpdate = useCallback(() => {
|
||||||
|
setConfig(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UpdateContext.Provider value={{ config, showUpdate, dismissUpdate }}>
|
||||||
|
{children}
|
||||||
|
</UpdateContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdate() {
|
||||||
|
const ctx = useContext(UpdateContext);
|
||||||
|
if (!ctx) throw new Error("useUpdate must be used inside UpdateProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
@@ -3,13 +3,44 @@ import { Notifications } from '../components/Notifications';
|
|||||||
import { PopularQuestions } from '../components/PopularQuestions';
|
import { PopularQuestions } from '../components/PopularQuestions';
|
||||||
import { ChatInput } from '../components/ChatInput';
|
import { ChatInput } from '../components/ChatInput';
|
||||||
import { QuickActions } from '../components/QuickActions';
|
import { QuickActions } from '../components/QuickActions';
|
||||||
|
import { useUpdate } from '../contexts/UpdateContext';
|
||||||
|
|
||||||
export function Home() {
|
export function Home() {
|
||||||
|
const { showUpdate } = useUpdate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Компактный логотип HAN */}
|
{/* Компактный логотип HAN */}
|
||||||
<HanLogo />
|
<HanLogo />
|
||||||
|
|
||||||
|
{/* Demo: триггеры для тестирования диалогов обновления */}
|
||||||
|
<div className="px-4 py-2 flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
showUpdate({
|
||||||
|
type: "soft",
|
||||||
|
currentVersion: "2.4.1",
|
||||||
|
newVersion: "2.5.0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-xl border border-border bg-muted/40 py-2 text-xs font-medium text-muted-foreground hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Soft update
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
showUpdate({
|
||||||
|
type: "strict",
|
||||||
|
currentVersion: "1.8.0",
|
||||||
|
newVersion: "2.5.0",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="flex-1 rounded-xl border border-destructive/30 bg-destructive/5 py-2 text-xs font-medium text-destructive hover:bg-destructive/10 transition-colors"
|
||||||
|
>
|
||||||
|
Strict update
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Уведомления от компании */}
|
{/* Уведомления от компании */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<Notifications />
|
<Notifications />
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Outlet, useLocation } from 'react-router';
|
import { Outlet, useLocation } from 'react-router';
|
||||||
import { Header } from '../components/Header';
|
import { Header } from '../components/Header';
|
||||||
|
import { UpdateModal } from '../components/UpdateModal';
|
||||||
|
|
||||||
export function Root() {
|
export function Root() {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -14,6 +15,8 @@ export function Root() {
|
|||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<UpdateModal />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -1,13 +1,15 @@
|
|||||||
# Non-secret VM2 deployment manifest. Never add DSNs, passwords, tokens or keys.
|
# Non-secret VM2 deployment manifest. Never add DSNs, passwords, tokens or keys.
|
||||||
APP_ENV=production-like
|
APP_ENV=production-like
|
||||||
RELEASE_VERSION=<immutable-release>
|
RELEASE_VERSION=<immutable-release>
|
||||||
|
LOG_LEVEL=INFO
|
||||||
SECRETS_SOURCE=selectel
|
SECRETS_SOURCE=selectel
|
||||||
MESSAGE_SAFETY_IMAGE=<registry>/han-message-safety@sha256:<digest>
|
MESSAGE_SAFETY_IMAGE=<registry>/han-message-safety@sha256:<digest>
|
||||||
BITRIX_SYNC_IMAGE=<registry>/han-bitrix-sync@sha256:<digest>
|
BITRIX_SYNC_IMAGE=<registry>/han-bitrix-sync@sha256:<digest>
|
||||||
NGINX_IMAGE=nginxinc/nginx-unprivileged@sha256:<reviewed-digest>
|
NGINX_IMAGE=nginxinc/nginx-unprivileged@sha256:<reviewed-digest>
|
||||||
REDIS_IMAGE=redis@sha256:<reviewed-digest>
|
REDIS_IMAGE=redis@sha256:<reviewed-digest>
|
||||||
CLAMAV_IMAGE=clamav/clamav@sha256:<reviewed-digest>
|
|
||||||
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib@sha256:<reviewed-digest>
|
OTEL_COLLECTOR_IMAGE=otel/opentelemetry-collector-contrib@sha256:<reviewed-digest>
|
||||||
|
REDIS_EXPORTER_IMAGE=oliver006/redis_exporter@sha256:<reviewed-digest>
|
||||||
|
NGINX_EXPORTER_IMAGE=nginx/nginx-prometheus-exporter@sha256:<reviewed-digest>
|
||||||
|
|
||||||
PROCESSING_PUBLIC_HOST=<processing-public-host>
|
PROCESSING_PUBLIC_HOST=<processing-public-host>
|
||||||
PROCESSING_PRIVATE_BIND_ADDRESS=<vm2-private-ip>
|
PROCESSING_PRIVATE_BIND_ADDRESS=<vm2-private-ip>
|
||||||
@@ -17,8 +19,7 @@ MESSAGE_SAFETY_HOST=0.0.0.0
|
|||||||
MESSAGE_SAFETY_PORT=8080
|
MESSAGE_SAFETY_PORT=8080
|
||||||
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
|
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
|
||||||
MESSAGE_SAFETY_DNS_RESOLVERS=<vpc-resolver-ip>
|
MESSAGE_SAFETY_DNS_RESOLVERS=<vpc-resolver-ip>
|
||||||
MESSAGE_SAFETY_CLAMAV_HOST=clamd
|
MESSAGE_SAFETY_ANTIVIRUS_SOCKET=/run/han-kesl/scan.sock
|
||||||
MESSAGE_SAFETY_CLAMAV_PORT=3310
|
|
||||||
MESSAGE_SAFETY_ARTIFACTS_DIR=/app/app/artifacts
|
MESSAGE_SAFETY_ARTIFACTS_DIR=/app/app/artifacts
|
||||||
MESSAGE_SAFETY_MODE_FILE=/etc/han-chat/message-safety-mode.env
|
MESSAGE_SAFETY_MODE_FILE=/etc/han-chat/message-safety-mode.env
|
||||||
|
|
||||||
@@ -27,9 +28,10 @@ SELECTEL_S3_BUCKET_QUARANTINE=<quarantine-bucket>
|
|||||||
|
|
||||||
BITRIX_SYNC_ENABLED=false
|
BITRIX_SYNC_ENABLED=false
|
||||||
BITRIX_SYNC_MODE=disabled
|
BITRIX_SYNC_MODE=disabled
|
||||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_<digits>
|
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_<latin_letters_or_digits>
|
||||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_<digits>
|
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_<latin_letters_or_digits>
|
||||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_<digits>
|
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_<latin_letters_or_digits>
|
||||||
|
BITRIX_SYNC_CONTACT_SOURCE=<bitrix-contact-source-id>
|
||||||
BITRIX_SYNC_PORTAL_HOST=<approved-portal>.bitrix24.ru
|
BITRIX_SYNC_PORTAL_HOST=<approved-portal>.bitrix24.ru
|
||||||
BITRIX_SYNC_PORTAL_MEMBER_ID=<approved-member-id>
|
BITRIX_SYNC_PORTAL_MEMBER_ID=<approved-member-id>
|
||||||
BITRIX_SYNC_PUBLIC_BASE_URL=https://<processing-public-host>
|
BITRIX_SYNC_PUBLIC_BASE_URL=https://<processing-public-host>
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ BITRIX_SYNC_PUBLIC_BASE_URL=https://sync.example.ru
|
|||||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_100
|
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_100
|
||||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_101
|
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_101
|
||||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_102
|
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_102
|
||||||
|
BITRIX_SYNC_CONTACT_SOURCE=WEB
|
||||||
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS=203.0.113.0/24
|
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS=203.0.113.0/24
|
||||||
BITRIX_SYNC_HTTP_TIMEOUT_SEC=10
|
BITRIX_SYNC_HTTP_TIMEOUT_SEC=10
|
||||||
BITRIX_SYNC_DB_POOL_SIZE=5
|
BITRIX_SYNC_DB_POOL_SIZE=5
|
||||||
|
# Optional: without this endpoint the service keeps JSON stdout and runs normally.
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=
|
||||||
|
RELEASE_VERSION=unknown
|
||||||
|
APP_ENV=production-like
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ COPY --chown=10001:10001 alembic.ini openapi.yaml /srv/
|
|||||||
WORKDIR /srv
|
WORKDIR /srv
|
||||||
USER 10001:10001
|
USER 10001:10001
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT ["han-bitrix-sync-api"]
|
CMD ["han-bitrix-sync-api"]
|
||||||
|
|||||||
@@ -16,6 +16,22 @@ Disabled mode требует только `BITRIX_SYNC_ENABLED=false` и
|
|||||||
валидирует весь каталог secret files, portal identity, custom fields, HTTPS host
|
валидирует весь каталог secret files, portal identity, custom fields, HTTPS host
|
||||||
lock и непустой CIDR allow-list до startup.
|
lock и непустой CIDR allow-list до startup.
|
||||||
|
|
||||||
|
## Наблюдаемость
|
||||||
|
|
||||||
|
JSON stdout включён всегда. Если задан стандартный
|
||||||
|
`OTEL_EXPORTER_OTLP_ENDPOINT`, traces, metrics и логи дополнительно отправляются
|
||||||
|
напрямую по OTLP/gRPC через bounded batch queues. Ошибка инициализации, экспорта
|
||||||
|
или остановки телеметрии не меняет readiness и не останавливает API, worker или
|
||||||
|
reconciliation. Процессы различаются как `bitrix-sync-api`,
|
||||||
|
`bitrix-sync-worker` и `bitrix-sync-reconciliation`; namespace остаётся
|
||||||
|
`han-chat`.
|
||||||
|
|
||||||
|
FastAPI (кроме `/health/live`), SQLAlchemy и HTTPX инструментированы. Перед
|
||||||
|
stdout и OTLP выполняется application redaction: query/token/credential URL,
|
||||||
|
headers, payload, PII, object keys и SQL не экспортируются. Бизнес-метрики
|
||||||
|
используют только закрытые множества labels; UUID и внешние идентификаторы в
|
||||||
|
labels/spans не записываются.
|
||||||
|
|
||||||
## Границы безопасности
|
## Границы безопасности
|
||||||
|
|
||||||
- CRM credential URL используется как единый секрет; redirect выключен, TLS
|
- CRM credential URL используется как единый секрет; redirect выключен, TLS
|
||||||
@@ -54,6 +70,15 @@ gates: локальный managed PostgreSQL не поднимается Compose
|
|||||||
4. Заполнить и активировать валидную `business_alerts` settings version:
|
4. Заполнить и активировать валидную `business_alerts` settings version:
|
||||||
entity/category/stage/field IDs и responsible party. Placeholder `null`
|
entity/category/stage/field IDs и responsible party. Placeholder `null`
|
||||||
запрещает alert receiver.
|
запрещает alert receiver.
|
||||||
|
|
||||||
|
`value_json` настройки `business_alerts` использует ключи `entity_type_id`,
|
||||||
|
`category_id`, `stage_new`, опциональный `responsible_id` и объект `field_ids`.
|
||||||
|
В `field_ids` REST-имена пользовательских полей сопоставляются ключам
|
||||||
|
`alert_number`, `fingerprint`, `alert_type`, `severity`, `app_user_id`,
|
||||||
|
`selected_external_id`, `occurrence_count`,
|
||||||
|
`first_occurred_at`, `last_occurred_at`, `workflow_id`.
|
||||||
|
Конфликтующие Contact передаются в стандартное поле `contactIds`, а значение
|
||||||
|
`BITRIX_SYNC_CONTACT_SOURCE` — в стандартное поле `sourceId`.
|
||||||
5. Проверить least-privilege credential negative tests; credential администратора
|
5. Проверить least-privilege credential negative tests; credential администратора
|
||||||
запрещён.
|
запрещён.
|
||||||
6. Валидировать nginx exact routes, no-redirect HTTP policy, body/rate limits,
|
6. Валидировать nginx exact routes, no-redirect HTTP policy, body/rate limits,
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from urllib.parse import urlsplit
|
|||||||
from pydantic import Field, SecretStr, model_validator
|
from pydantic import Field, SecretStr, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
FIELD_RE = re.compile(r"^UF_CRM_[0-9]+$")
|
FIELD_RE = re.compile(r"^UF_CRM_[A-Za-z0-9]+$")
|
||||||
MEMBER_RE = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
|
MEMBER_RE = re.compile(r"^[A-Za-z0-9_-]{8,128}$")
|
||||||
|
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ class Settings(BaseSettings):
|
|||||||
contact_user_id_field: str | None = None
|
contact_user_id_field: str | None = None
|
||||||
contact_registered_field: str | None = None
|
contact_registered_field: str | None = None
|
||||||
contact_citizenship_field: str | None = None
|
contact_citizenship_field: str | None = None
|
||||||
|
contact_source: str | None = Field(default=None, min_length=1, max_length=128)
|
||||||
webhook_allowed_cidrs: str = ""
|
webhook_allowed_cidrs: str = ""
|
||||||
|
|
||||||
http_timeout_sec: float = Field(default=10, ge=1, le=60)
|
http_timeout_sec: float = Field(default=10, ge=1, le=60)
|
||||||
@@ -97,6 +98,7 @@ class Settings(BaseSettings):
|
|||||||
"contact_user_id_field": self.contact_user_id_field,
|
"contact_user_id_field": self.contact_user_id_field,
|
||||||
"contact_registered_field": self.contact_registered_field,
|
"contact_registered_field": self.contact_registered_field,
|
||||||
"contact_citizenship_field": self.contact_citizenship_field,
|
"contact_citizenship_field": self.contact_citizenship_field,
|
||||||
|
"contact_source": self.contact_source,
|
||||||
}
|
}
|
||||||
missing = [name for name, value in required.items() if not value]
|
missing = [name for name, value in required.items() if not value]
|
||||||
if missing:
|
if missing:
|
||||||
@@ -107,9 +109,13 @@ class Settings(BaseSettings):
|
|||||||
"contact_citizenship_field",
|
"contact_citizenship_field",
|
||||||
):
|
):
|
||||||
if not FIELD_RE.fullmatch(str(getattr(self, name))):
|
if not FIELD_RE.fullmatch(str(getattr(self, name))):
|
||||||
raise ValueError(f"{name} must match UF_CRM_<digits>")
|
raise ValueError(f"{name} must match UF_CRM_<latin letters or digits>")
|
||||||
if not MEMBER_RE.fullmatch(str(self.portal_member_id)):
|
if not MEMBER_RE.fullmatch(str(self.portal_member_id)):
|
||||||
raise ValueError("portal_member_id has invalid format")
|
raise ValueError("portal_member_id has invalid format")
|
||||||
|
assert self.contact_source
|
||||||
|
self.contact_source = self.contact_source.strip()
|
||||||
|
if not self.contact_source:
|
||||||
|
raise ValueError("contact_source cannot be blank")
|
||||||
|
|
||||||
crm = urlsplit(self.crm_rest_webhook_url.get_secret_value())
|
crm = urlsplit(self.crm_rest_webhook_url.get_secret_value())
|
||||||
public = urlsplit(str(self.public_base_url))
|
public = urlsplit(str(self.public_base_url))
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import ssl
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from enum import StrEnum
|
from enum import StrEnum
|
||||||
|
from time import monotonic
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from urllib.parse import urljoin, urlsplit
|
from urllib.parse import urljoin, urlsplit
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from opentelemetry.trace import SpanKind
|
||||||
|
|
||||||
|
from app.telemetry import record_crm, record_retry, safe_span
|
||||||
|
|
||||||
|
|
||||||
class CrmOutcome(StrEnum):
|
class CrmOutcome(StrEnum):
|
||||||
@@ -49,6 +53,28 @@ class CrmClient:
|
|||||||
await self._client.aclose()
|
await self._client.aclose()
|
||||||
|
|
||||||
async def call(self, method: str, params: dict[str, Any], *, mutating: bool) -> CrmResult:
|
async def call(self, method: str, params: dict[str, Any], *, mutating: bool) -> CrmResult:
|
||||||
|
started = monotonic()
|
||||||
|
outcome = "error"
|
||||||
|
operation = method.rsplit(".", 1)[-1]
|
||||||
|
try:
|
||||||
|
with safe_span(
|
||||||
|
"bitrix_sync.crm.request",
|
||||||
|
kind=SpanKind.CLIENT,
|
||||||
|
attributes={"crm.operation": operation},
|
||||||
|
):
|
||||||
|
result = await self._call(method, params, mutating=mutating)
|
||||||
|
outcome = result.outcome.value
|
||||||
|
if result.outcome in {
|
||||||
|
CrmOutcome.RETRY,
|
||||||
|
CrmOutcome.UNCERTAIN,
|
||||||
|
CrmOutcome.RATE_LIMITED,
|
||||||
|
}:
|
||||||
|
record_retry("crm")
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
record_crm(method, outcome, monotonic() - started)
|
||||||
|
|
||||||
|
async def _call(self, method: str, params: dict[str, Any], *, mutating: bool) -> CrmResult:
|
||||||
if method not in ALLOWED_METHODS:
|
if method not in ALLOWED_METHODS:
|
||||||
raise ValueError("unapproved CRM method")
|
raise ValueError("unapproved CRM method")
|
||||||
url = urljoin(self._base_url, method + ".json")
|
url = urljoin(self._base_url, method + ".json")
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import uuid
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import bindparam, text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.crm import CrmClient, CrmOutcome, CrmResult
|
from app.crm import CrmClient, CrmOutcome, CrmResult
|
||||||
@@ -14,10 +15,38 @@ from app.domain import (
|
|||||||
choose_newest,
|
choose_newest,
|
||||||
full_jitter_delay,
|
full_jitter_delay,
|
||||||
parse_crm_datetime,
|
parse_crm_datetime,
|
||||||
|
safe_hash,
|
||||||
select_email,
|
select_email,
|
||||||
validate_phone,
|
validate_phone,
|
||||||
)
|
)
|
||||||
from app.repository import LeasedTask, LeasedWebhook, Profile, Repository
|
from app.repository import LeasedTask, LeasedWebhook, Profile, Repository
|
||||||
|
from app.telemetry import (
|
||||||
|
record_business_alert,
|
||||||
|
record_dead_letter,
|
||||||
|
record_limiter,
|
||||||
|
record_retry,
|
||||||
|
record_transition,
|
||||||
|
)
|
||||||
|
|
||||||
|
INSERT_CRM_COMMAND = text(
|
||||||
|
"""
|
||||||
|
INSERT INTO bitrix_sync.crm_commands
|
||||||
|
(id,workflow_id,command_type,safe_request,status,attempt_count,
|
||||||
|
next_attempt_at,created_at,updated_at)
|
||||||
|
VALUES (:id,:workflow_id,:type,:safe_request,'in_flight',1,now(),now(),now())
|
||||||
|
"""
|
||||||
|
).bindparams(bindparam("safe_request", type_=JSONB()))
|
||||||
|
|
||||||
|
UPDATE_CRM_COMMAND = text(
|
||||||
|
"""
|
||||||
|
UPDATE bitrix_sync.crm_commands
|
||||||
|
SET status=:status,safe_error_code=:error,http_status=:http_status,
|
||||||
|
safe_response=:safe_response,
|
||||||
|
completed_at=CASE WHEN :terminal THEN now() END,
|
||||||
|
updated_at=now()
|
||||||
|
WHERE id=:id
|
||||||
|
"""
|
||||||
|
).bindparams(bindparam("safe_response", type_=JSONB()))
|
||||||
|
|
||||||
|
|
||||||
class BusinessConflict(Exception):
|
class BusinessConflict(Exception):
|
||||||
@@ -59,6 +88,7 @@ class WorkflowEngine:
|
|||||||
await self._manual(workflow_id, exc.code, task.user_id)
|
await self._manual(workflow_id, exc.code, task.user_id)
|
||||||
await self.repository.complete_task(task, workflow_id)
|
await self.repository.complete_task(task, workflow_id)
|
||||||
except RetryableWorkflow as exc:
|
except RetryableWorkflow as exc:
|
||||||
|
record_retry("task")
|
||||||
delay = exc.retry_after or full_jitter_delay(
|
delay = exc.retry_after or full_jitter_delay(
|
||||||
task.attempt_count + 1,
|
task.attempt_count + 1,
|
||||||
self.settings.retry_base_seconds,
|
self.settings.retry_base_seconds,
|
||||||
@@ -178,6 +208,14 @@ class WorkflowEngine:
|
|||||||
alert_code: str | None = None
|
alert_code: str | None = None
|
||||||
if same_user:
|
if same_user:
|
||||||
selected_id = str(same_user["ID"])
|
selected_id = str(same_user["ID"])
|
||||||
|
if len(contacts) > 1:
|
||||||
|
await self._alert(
|
||||||
|
workflow_id,
|
||||||
|
profile.user_id,
|
||||||
|
"duplicate_contacts",
|
||||||
|
[str(item["ID"]) for item in contacts],
|
||||||
|
selected_external_id=selected_id,
|
||||||
|
)
|
||||||
elif not contacts:
|
elif not contacts:
|
||||||
selected_id = await self._create_contact(workflow_id, profile)
|
selected_id = await self._create_contact(workflow_id, profile)
|
||||||
else:
|
else:
|
||||||
@@ -193,15 +231,27 @@ class WorkflowEngine:
|
|||||||
assert selected is not None
|
assert selected is not None
|
||||||
all_ids = [item.b24_id for item in candidates]
|
all_ids = [item.b24_id for item in candidates]
|
||||||
if selected.crm_user_id and selected.crm_user_id != str(profile.user_id):
|
if selected.crm_user_id and selected.crm_user_id != str(profile.user_id):
|
||||||
selected_id = await self._create_contact(workflow_id, profile)
|
|
||||||
alert_code = "contact_owned_by_other_user"
|
alert_code = "contact_owned_by_other_user"
|
||||||
|
selected_id = await self._create_contact(workflow_id, profile)
|
||||||
|
await self._alert(
|
||||||
|
workflow_id,
|
||||||
|
profile.user_id,
|
||||||
|
alert_code,
|
||||||
|
[*all_ids, selected_id],
|
||||||
|
selected_external_id=selected_id,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
selected_id = selected.b24_id
|
selected_id = selected.b24_id
|
||||||
await self._write_identity(workflow_id, selected_id, profile.user_id, active=True)
|
|
||||||
if len(candidates) > 1:
|
if len(candidates) > 1:
|
||||||
alert_code = "duplicate_contacts"
|
alert_code = "duplicate_contacts"
|
||||||
if alert_code:
|
await self._alert(
|
||||||
await self._alert(workflow_id, profile.user_id, alert_code, all_ids)
|
workflow_id,
|
||||||
|
profile.user_id,
|
||||||
|
alert_code,
|
||||||
|
all_ids,
|
||||||
|
selected_external_id=selected_id,
|
||||||
|
)
|
||||||
|
await self._write_identity(workflow_id, selected_id, profile.user_id, active=True)
|
||||||
await self._activate_mapping(workflow_id, profile.user_id, selected_id)
|
await self._activate_mapping(workflow_id, profile.user_id, selected_id)
|
||||||
|
|
||||||
async def _create_contact(self, workflow_id: uuid.UUID, profile: Profile) -> str:
|
async def _create_contact(self, workflow_id: uuid.UUID, profile: Profile) -> str:
|
||||||
@@ -452,14 +502,7 @@ class WorkflowEngine:
|
|||||||
command_id = uuid.uuid4()
|
command_id = uuid.uuid4()
|
||||||
async with self.repository.transaction() as connection:
|
async with self.repository.transaction() as connection:
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
text(
|
INSERT_CRM_COMMAND,
|
||||||
"""
|
|
||||||
INSERT INTO bitrix_sync.crm_commands
|
|
||||||
(id,workflow_id,command_type,safe_request,status,attempt_count,
|
|
||||||
next_attempt_at,created_at,updated_at)
|
|
||||||
VALUES (:id,:workflow_id,:type,:safe_request,'in_flight',1,now(),now(),now())
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
"id": command_id,
|
"id": command_id,
|
||||||
"workflow_id": workflow_id,
|
"workflow_id": workflow_id,
|
||||||
@@ -473,22 +516,15 @@ class WorkflowEngine:
|
|||||||
)
|
)
|
||||||
if limiter_delay <= 0:
|
if limiter_delay <= 0:
|
||||||
break
|
break
|
||||||
|
record_limiter(limiter_delay)
|
||||||
await asyncio.sleep(limiter_delay)
|
await asyncio.sleep(limiter_delay)
|
||||||
async with self._in_flight:
|
async with self._in_flight:
|
||||||
result = await self.crm.call(method, params, mutating=mutating)
|
result = await self.crm.call(method, params, mutating=mutating)
|
||||||
status = result.outcome.value
|
status = result.outcome.value
|
||||||
|
record_transition("command", status)
|
||||||
async with self.repository.transaction() as connection:
|
async with self.repository.transaction() as connection:
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
text(
|
UPDATE_CRM_COMMAND,
|
||||||
"""
|
|
||||||
UPDATE bitrix_sync.crm_commands
|
|
||||||
SET status=:status,safe_error_code=:error,http_status=:http_status,
|
|
||||||
safe_response=:safe_response,
|
|
||||||
completed_at=CASE WHEN :terminal THEN now() END,
|
|
||||||
updated_at=now()
|
|
||||||
WHERE id=:id
|
|
||||||
"""
|
|
||||||
),
|
|
||||||
{
|
{
|
||||||
"id": command_id,
|
"id": command_id,
|
||||||
"status": status,
|
"status": status,
|
||||||
@@ -546,31 +582,145 @@ class WorkflowEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _alert(
|
async def _alert(
|
||||||
self, workflow_id: uuid.UUID, user_id: uuid.UUID, alert_type: str, candidates: list[str]
|
self,
|
||||||
|
workflow_id: uuid.UUID,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
alert_type: str,
|
||||||
|
candidates: list[str],
|
||||||
|
*,
|
||||||
|
selected_external_id: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
fingerprint = f"{alert_type}:{user_id}"
|
record_business_alert(alert_type)
|
||||||
|
fingerprint = safe_hash(f"{alert_type}:{user_id}")
|
||||||
|
assert fingerprint is not None
|
||||||
|
async with self.repository.transaction() as connection:
|
||||||
|
alert = (
|
||||||
|
await connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
INSERT INTO bitrix_sync.business_alerts
|
||||||
|
(id,fingerprint,alert_type,severity,app_user_id,
|
||||||
|
selected_external_id,candidate_external_ids,workflow_id,status,
|
||||||
|
occurrence_count,first_occurred_at,last_occurred_at,created_at,updated_at)
|
||||||
|
VALUES (gen_random_uuid(),:fingerprint,
|
||||||
|
:type,'warning',:user_id,:selected_external_id,:candidates,
|
||||||
|
:workflow_id,'open',1,now(),now(),now(),now())
|
||||||
|
ON CONFLICT (alert_type,fingerprint) WHERE status='open'
|
||||||
|
DO UPDATE SET
|
||||||
|
selected_external_id=coalesce(
|
||||||
|
excluded.selected_external_id,
|
||||||
|
business_alerts.selected_external_id
|
||||||
|
),
|
||||||
|
candidate_external_ids=excluded.candidate_external_ids,
|
||||||
|
workflow_id=excluded.workflow_id,
|
||||||
|
occurrence_count=business_alerts.occurrence_count+1,
|
||||||
|
last_occurred_at=now(),updated_at=now()
|
||||||
|
RETURNING id,alert_number,fingerprint,alert_type,severity,app_user_id,
|
||||||
|
selected_external_id,candidate_external_ids,remote_item_id,
|
||||||
|
occurrence_count,first_occurred_at,last_occurred_at
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"fingerprint": fingerprint,
|
||||||
|
"type": alert_type,
|
||||||
|
"user_id": user_id,
|
||||||
|
"selected_external_id": selected_external_id,
|
||||||
|
"candidates": candidates,
|
||||||
|
"workflow_id": workflow_id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
).mappings().one()
|
||||||
|
alert_config = (
|
||||||
|
await connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
SELECT value_json FROM bitrix_sync.settings
|
||||||
|
WHERE key='business_alerts' AND active=true
|
||||||
|
AND validation_status='valid'
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).scalar_one_or_none()
|
||||||
|
|
||||||
|
if not isinstance(alert_config, dict):
|
||||||
|
raise RetryableWorkflow("business_alerts_not_configured")
|
||||||
|
entity_type_id = alert_config.get("entity_type_id")
|
||||||
|
stage_new = alert_config.get("stage_new")
|
||||||
|
if not entity_type_id or not stage_new:
|
||||||
|
raise RetryableWorkflow("business_alerts_not_configured")
|
||||||
|
|
||||||
|
title = (
|
||||||
|
f"Конфликт синхронизации #{alert['alert_number']}: {alert_type}; "
|
||||||
|
f"user={user_id}; contacts={','.join(candidates)}"
|
||||||
|
)
|
||||||
|
fields: dict[str, Any] = {
|
||||||
|
"title": title[:255],
|
||||||
|
"stageId": stage_new,
|
||||||
|
"contactIds": [int(candidate) for candidate in candidates if candidate.isdigit()],
|
||||||
|
"sourceId": self.settings.contact_source,
|
||||||
|
}
|
||||||
|
if category_id := alert_config.get("category_id"):
|
||||||
|
fields["categoryId"] = category_id
|
||||||
|
if responsible_id := alert_config.get("responsible_id"):
|
||||||
|
fields["assignedById"] = responsible_id
|
||||||
|
alert_values = {
|
||||||
|
"alert_number": str(alert["alert_number"]),
|
||||||
|
"fingerprint": alert["fingerprint"],
|
||||||
|
"alert_type": alert["alert_type"],
|
||||||
|
"severity": alert["severity"],
|
||||||
|
"app_user_id": str(alert["app_user_id"]),
|
||||||
|
"selected_external_id": alert["selected_external_id"] or "",
|
||||||
|
"occurrence_count": alert["occurrence_count"],
|
||||||
|
"first_occurred_at": alert["first_occurred_at"].isoformat(),
|
||||||
|
"last_occurred_at": alert["last_occurred_at"].isoformat(),
|
||||||
|
"workflow_id": str(workflow_id),
|
||||||
|
}
|
||||||
|
field_ids = alert_config.get("field_ids")
|
||||||
|
if isinstance(field_ids, dict):
|
||||||
|
for value_name, field_id in field_ids.items():
|
||||||
|
if value_name in alert_values and isinstance(field_id, str) and field_id:
|
||||||
|
fields[field_id] = alert_values[value_name]
|
||||||
|
|
||||||
|
remote_item_id = alert["remote_item_id"]
|
||||||
|
if remote_item_id:
|
||||||
|
await self._command(
|
||||||
|
workflow_id,
|
||||||
|
"alert_update",
|
||||||
|
"crm.item.update",
|
||||||
|
{
|
||||||
|
"entityTypeId": int(entity_type_id),
|
||||||
|
"id": remote_item_id,
|
||||||
|
"fields": fields,
|
||||||
|
},
|
||||||
|
mutating=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
result = await self._command(
|
||||||
|
workflow_id,
|
||||||
|
"alert_add",
|
||||||
|
"crm.item.add",
|
||||||
|
{"entityTypeId": int(entity_type_id), "fields": fields},
|
||||||
|
mutating=True,
|
||||||
|
)
|
||||||
|
payload = result.result if isinstance(result.result, dict) else {}
|
||||||
|
item = payload.get("item", payload)
|
||||||
|
remote_item_id = item.get("id") if isinstance(item, dict) else None
|
||||||
|
if remote_item_id is None:
|
||||||
|
raise RetryableWorkflow("alert_item_id_missing")
|
||||||
async with self.repository.transaction() as connection:
|
async with self.repository.transaction() as connection:
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO bitrix_sync.business_alerts
|
UPDATE bitrix_sync.business_alerts
|
||||||
(id,fingerprint,alert_type,severity,app_user_id,candidate_external_ids,
|
SET remote_item_id=:remote_item_id,remote_stage_id=:stage_id,updated_at=now()
|
||||||
workflow_id,status,occurrence_count,first_occurred_at,last_occurred_at,
|
WHERE id=:id
|
||||||
created_at,updated_at)
|
|
||||||
VALUES (gen_random_uuid(),encode(digest(:fingerprint,'sha256'),'hex'),
|
|
||||||
:type,'warning',:user_id,:candidates,:workflow_id,'open',1,
|
|
||||||
now(),now(),now(),now())
|
|
||||||
ON CONFLICT (alert_type,fingerprint) WHERE status='open'
|
|
||||||
DO UPDATE SET occurrence_count=business_alerts.occurrence_count+1,
|
|
||||||
last_occurred_at=now(),updated_at=now()
|
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{
|
{
|
||||||
"fingerprint": fingerprint,
|
"id": alert["id"],
|
||||||
"type": alert_type,
|
"remote_item_id": str(remote_item_id),
|
||||||
"user_id": user_id,
|
"stage_id": str(stage_new),
|
||||||
"candidates": candidates,
|
|
||||||
"workflow_id": workflow_id,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -588,6 +738,7 @@ class WorkflowEngine:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def _technical_failure(self, workflow_id: uuid.UUID, code: str) -> None:
|
async def _technical_failure(self, workflow_id: uuid.UUID, code: str) -> None:
|
||||||
|
record_dead_letter("crm_command")
|
||||||
async with self.repository.transaction() as connection:
|
async with self.repository.transaction() as connection:
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
text(
|
text(
|
||||||
|
|||||||
@@ -12,20 +12,38 @@ from sqlalchemy import text
|
|||||||
from app.config import Settings, load_settings
|
from app.config import Settings, load_settings
|
||||||
from app.repository import Repository
|
from app.repository import Repository
|
||||||
from app.security import WebhookValidationError, parse_bounded_form, validate_webhook
|
from app.security import WebhookValidationError, parse_bounded_form, validate_webhook
|
||||||
|
from app.telemetry import (
|
||||||
|
init_telemetry,
|
||||||
|
instrument_fastapi,
|
||||||
|
log_event,
|
||||||
|
record_webhook,
|
||||||
|
shutdown_telemetry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
settings = load_settings()
|
init_telemetry("bitrix-sync-api")
|
||||||
app.state.settings = settings
|
app.state.repository = None
|
||||||
app.state.repository = (
|
try:
|
||||||
Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
settings = load_settings()
|
||||||
if settings.enabled and settings.database_url
|
app.state.settings = settings
|
||||||
else None
|
app.state.repository = (
|
||||||
)
|
Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||||
yield
|
if settings.enabled and settings.database_url
|
||||||
if app.state.repository:
|
else None
|
||||||
await app.state.repository.close()
|
)
|
||||||
|
log_event(
|
||||||
|
"service.started",
|
||||||
|
"Bitrix sync API started",
|
||||||
|
attributes={"mode": settings.mode},
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if app.state.repository:
|
||||||
|
await app.state.repository.close()
|
||||||
|
log_event("service.stopped", "Bitrix sync API stopped")
|
||||||
|
shutdown_telemetry()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -35,6 +53,7 @@ app = FastAPI(
|
|||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
)
|
)
|
||||||
|
instrument_fastapi(app)
|
||||||
|
|
||||||
|
|
||||||
def settings(request: Request) -> Settings:
|
def settings(request: Request) -> Settings:
|
||||||
@@ -107,29 +126,30 @@ async def alert_webhook(
|
|||||||
|
|
||||||
|
|
||||||
async def _receive(request: Request, receiver: str, query: dict[str, str]) -> Response:
|
async def _receive(request: Request, receiver: str, query: dict[str, str]) -> Response:
|
||||||
config = settings(request)
|
|
||||||
if not config.enabled:
|
|
||||||
raise HTTPException(status_code=503, detail="sync_disabled")
|
|
||||||
if request.headers.get("content-type", "").split(";", 1)[0].lower() != (
|
|
||||||
"application/x-www-form-urlencoded"
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=400, detail="invalid_content_type")
|
|
||||||
content_length = request.headers.get("content-length")
|
|
||||||
if content_length and (
|
|
||||||
not content_length.isdigit() or int(content_length) > config.webhook_max_body_bytes
|
|
||||||
):
|
|
||||||
raise HTTPException(status_code=413, detail="body_too_large")
|
|
||||||
body = await request.body()
|
|
||||||
if len(body) > config.webhook_max_body_bytes:
|
|
||||||
raise HTTPException(status_code=413, detail="body_too_large")
|
|
||||||
form = parse_bounded_form(body, max_fields=config.webhook_max_fields)
|
|
||||||
# The container is reachable only from the trusted VM2 nginx network.
|
|
||||||
# nginx overwrites X-Real-IP from the TCP peer after its CIDR check.
|
|
||||||
source_ip = request.headers.get("x-real-ip") or (request.client.host if request.client else "")
|
|
||||||
alert_entity_type_id = (
|
|
||||||
await _alert_entity_type(repository(request)) if receiver == "alert" else None
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
|
config = settings(request)
|
||||||
|
if not config.enabled:
|
||||||
|
raise HTTPException(status_code=503, detail="sync_disabled")
|
||||||
|
if request.headers.get("content-type", "").split(";", 1)[0].lower() != (
|
||||||
|
"application/x-www-form-urlencoded"
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=400, detail="invalid_content_type")
|
||||||
|
content_length = request.headers.get("content-length")
|
||||||
|
if content_length and (
|
||||||
|
not content_length.isdigit() or int(content_length) > config.webhook_max_body_bytes
|
||||||
|
):
|
||||||
|
raise HTTPException(status_code=413, detail="body_too_large")
|
||||||
|
body = await request.body()
|
||||||
|
if len(body) > config.webhook_max_body_bytes:
|
||||||
|
raise HTTPException(status_code=413, detail="body_too_large")
|
||||||
|
form = parse_bounded_form(body, max_fields=config.webhook_max_fields)
|
||||||
|
# nginx overwrites X-Real-IP from the TCP peer after its CIDR check.
|
||||||
|
source_ip = request.headers.get("x-real-ip") or (
|
||||||
|
request.client.host if request.client else ""
|
||||||
|
)
|
||||||
|
alert_entity_type_id = (
|
||||||
|
await _alert_entity_type(repository(request)) if receiver == "alert" else None
|
||||||
|
)
|
||||||
event = validate_webhook(
|
event = validate_webhook(
|
||||||
receiver,
|
receiver,
|
||||||
query,
|
query,
|
||||||
@@ -139,12 +159,21 @@ async def _receive(request: Request, receiver: str, query: dict[str, str]) -> Re
|
|||||||
alert_entity_type_id=alert_entity_type_id,
|
alert_entity_type_id=alert_entity_type_id,
|
||||||
)
|
)
|
||||||
except PermissionError as exc:
|
except PermissionError as exc:
|
||||||
|
record_webhook(receiver, "rejected")
|
||||||
raise HTTPException(status_code=403, detail="forbidden") from exc
|
raise HTTPException(status_code=403, detail="forbidden") from exc
|
||||||
except WebhookValidationError as exc:
|
except WebhookValidationError as exc:
|
||||||
|
record_webhook(receiver, "rejected")
|
||||||
raise HTTPException(status_code=400, detail="malformed_webhook") from exc
|
raise HTTPException(status_code=400, detail="malformed_webhook") from exc
|
||||||
|
except HTTPException:
|
||||||
|
record_webhook(receiver, "rejected")
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
record_webhook(receiver, "error")
|
||||||
|
raise
|
||||||
await repository(request).insert_webhook(
|
await repository(request).insert_webhook(
|
||||||
event.receiver_type, event.event_type, event.entity_id, event.source_ip
|
event.receiver_type, event.event_type, event.entity_id, event.source_ip
|
||||||
)
|
)
|
||||||
|
record_webhook(receiver, "accepted")
|
||||||
return Response(status_code=202)
|
return Response(status_code=202)
|
||||||
|
|
||||||
|
|
||||||
@@ -170,4 +199,5 @@ def run() -> None:
|
|||||||
host="0.0.0.0", # noqa: S104 - container-only port, not host-published
|
host="0.0.0.0", # noqa: S104 - container-only port, not host-published
|
||||||
port=8080,
|
port=8080,
|
||||||
proxy_headers=False,
|
proxy_headers=False,
|
||||||
|
access_log=False,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from time import monotonic
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
@@ -9,6 +10,13 @@ from sqlalchemy import text
|
|||||||
from app.config import load_settings
|
from app.config import load_settings
|
||||||
from app.crm import CrmClient, CrmOutcome
|
from app.crm import CrmClient, CrmOutcome
|
||||||
from app.repository import Repository
|
from app.repository import Repository
|
||||||
|
from app.telemetry import (
|
||||||
|
init_telemetry,
|
||||||
|
log_event,
|
||||||
|
record_reconciliation,
|
||||||
|
safe_span,
|
||||||
|
shutdown_telemetry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class IncrementalReconciler:
|
class IncrementalReconciler:
|
||||||
@@ -45,13 +53,13 @@ class IncrementalReconciler:
|
|||||||
started_at = datetime.now(UTC)
|
started_at = datetime.now(UTC)
|
||||||
since = (cursor or datetime(1970, 1, 1, tzinfo=UTC)) - timedelta(seconds=overlap_seconds)
|
since = (cursor or datetime(1970, 1, 1, tzinfo=UTC)) - timedelta(seconds=overlap_seconds)
|
||||||
start = 0
|
start = 0
|
||||||
scanned: list[str] = []
|
scanned: list[tuple[str, str]] = []
|
||||||
while True:
|
while True:
|
||||||
result = await self.crm.call(
|
result = await self.crm.call(
|
||||||
"crm.item.list",
|
"crm.item.list",
|
||||||
{
|
{
|
||||||
"entityTypeId": 3,
|
"entityTypeId": 3,
|
||||||
"select": ["id"],
|
"select": ["id", "updatedTime"],
|
||||||
"filter": {
|
"filter": {
|
||||||
">=updatedTime": since.replace(tzinfo=None).isoformat(timespec="seconds"),
|
">=updatedTime": since.replace(tzinfo=None).isoformat(timespec="seconds"),
|
||||||
"opened": 1,
|
"opened": 1,
|
||||||
@@ -63,10 +71,15 @@ class IncrementalReconciler:
|
|||||||
)
|
)
|
||||||
if result.outcome != CrmOutcome.SUCCEEDED:
|
if result.outcome != CrmOutcome.SUCCEEDED:
|
||||||
raise RuntimeError(result.error_code or "reconciliation_failed")
|
raise RuntimeError(result.error_code or "reconciliation_failed")
|
||||||
payload: dict[str, Any] = result.result or {}
|
payload: Any = result.result or {}
|
||||||
items = payload.get("items", payload if isinstance(payload, list) else [])
|
items = payload.get("items", []) if isinstance(payload, dict) else payload
|
||||||
scanned.extend(str(item["id"]) for item in items if "id" in item)
|
if not isinstance(items, list):
|
||||||
next_start = payload.get("next")
|
raise RuntimeError("reconciliation_items_invalid")
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict) or "id" not in item or "updatedTime" not in item:
|
||||||
|
raise RuntimeError("reconciliation_item_missing_identity")
|
||||||
|
scanned.append((str(item["id"]), str(item["updatedTime"])))
|
||||||
|
next_start = payload.get("next") if isinstance(payload, dict) else None
|
||||||
if next_start is None:
|
if next_start is None:
|
||||||
break
|
break
|
||||||
start = int(next_start)
|
start = int(next_start)
|
||||||
@@ -89,50 +102,77 @@ class IncrementalReconciler:
|
|||||||
)
|
)
|
||||||
return len(scanned)
|
return len(scanned)
|
||||||
|
|
||||||
async def _enqueue_changed(self, external_ids: list[str]) -> None:
|
async def _enqueue_changed(self, changed_contacts: list[tuple[str, str]]) -> None:
|
||||||
if not external_ids:
|
if not changed_contacts:
|
||||||
return
|
return
|
||||||
|
external_ids = [external_id for external_id, _ in changed_contacts]
|
||||||
|
event_ids = [
|
||||||
|
f"contact.reconciliation:{external_id}:{updated_time}"
|
||||||
|
for external_id, updated_time in changed_contacts
|
||||||
|
]
|
||||||
async with self.repository.transaction() as connection:
|
async with self.repository.transaction() as connection:
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
INSERT INTO bitrix_sync.webhook_inbox
|
INSERT INTO bitrix_sync.webhook_inbox
|
||||||
(id,receiver_type,event_type,external_entity_id,status,coalesced_count,
|
(id,receiver_type,event_type,event_id,external_entity_id,status,
|
||||||
received_at,last_received_at)
|
coalesced_count,received_at,last_received_at)
|
||||||
SELECT gen_random_uuid(),'contact','contact.reconciliation',id,'received',1,
|
SELECT gen_random_uuid(),'contact','contact.reconciliation',candidate.event_id,
|
||||||
now(),now()
|
candidate.external_id,'received',1,now(),now()
|
||||||
FROM unnest(CAST(:ids AS text[])) id
|
FROM unnest(CAST(:ids AS text[]),CAST(:event_ids AS text[]))
|
||||||
|
AS candidate(external_id,event_id)
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM bitrix_sync.webhook_inbox w
|
SELECT 1 FROM bitrix_sync.webhook_inbox w
|
||||||
WHERE w.receiver_type='contact' AND w.external_entity_id=id
|
WHERE w.receiver_type='contact'
|
||||||
|
AND w.external_entity_id=candidate.external_id
|
||||||
AND w.status IN ('received','processing','retry_wait')
|
AND w.status IN ('received','processing','retry_wait')
|
||||||
)
|
)
|
||||||
|
ON CONFLICT (receiver_type,event_id) WHERE event_id IS NOT NULL DO NOTHING
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
{"ids": external_ids},
|
{"ids": external_ids, "event_ids": event_ids},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def reconciliation_main() -> None:
|
async def reconciliation_main() -> None:
|
||||||
settings = load_settings()
|
init_telemetry("bitrix-sync-reconciliation")
|
||||||
if not settings.enabled:
|
repository: Repository | None = None
|
||||||
return
|
crm: CrmClient | None = None
|
||||||
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
|
started = monotonic()
|
||||||
assert settings.contact_registered_field
|
outcome = "error"
|
||||||
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
count = 0
|
||||||
crm = CrmClient(
|
|
||||||
settings.crm_rest_webhook_url.get_secret_value(),
|
|
||||||
settings.portal_host,
|
|
||||||
settings.http_timeout_sec,
|
|
||||||
)
|
|
||||||
reconciler = IncrementalReconciler(
|
|
||||||
repository, crm, settings.rest_field_name(settings.contact_registered_field)
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
await reconciler.run_once(settings.reconciliation_overlap_seconds)
|
settings = load_settings()
|
||||||
|
if not settings.enabled:
|
||||||
|
outcome = "skipped"
|
||||||
|
log_event("reconciliation.disabled", "Bitrix sync reconciliation is disabled")
|
||||||
|
return
|
||||||
|
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
|
||||||
|
assert settings.contact_registered_field
|
||||||
|
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||||
|
crm = CrmClient(
|
||||||
|
settings.crm_rest_webhook_url.get_secret_value(),
|
||||||
|
settings.portal_host,
|
||||||
|
settings.http_timeout_sec,
|
||||||
|
)
|
||||||
|
reconciler = IncrementalReconciler(
|
||||||
|
repository, crm, settings.rest_field_name(settings.contact_registered_field)
|
||||||
|
)
|
||||||
|
with safe_span("bitrix_sync.reconciliation.run"):
|
||||||
|
count = await reconciler.run_once(settings.reconciliation_overlap_seconds)
|
||||||
|
outcome = "success"
|
||||||
|
log_event(
|
||||||
|
"reconciliation.completed",
|
||||||
|
"Bitrix sync reconciliation completed",
|
||||||
|
attributes={"scanned_count": count},
|
||||||
|
)
|
||||||
finally:
|
finally:
|
||||||
await crm.close()
|
record_reconciliation(outcome, count, started)
|
||||||
await repository.close()
|
if crm is not None:
|
||||||
|
await crm.close()
|
||||||
|
if repository is not None:
|
||||||
|
await repository.close()
|
||||||
|
shutdown_telemetry()
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ from typing import Any
|
|||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, create_async_engine
|
||||||
|
|
||||||
|
from app.domain import safe_hash
|
||||||
|
from app.telemetry import record_status_snapshot
|
||||||
|
|
||||||
|
|
||||||
def postgres_ssl_context() -> ssl.SSLContext:
|
def postgres_ssl_context() -> ssl.SSLContext:
|
||||||
ca_file = os.environ.get("PG_CA_FILE")
|
ca_file = os.environ.get("PG_CA_FILE")
|
||||||
@@ -83,9 +86,12 @@ class Repository:
|
|||||||
WITH candidates AS (
|
WITH candidates AS (
|
||||||
SELECT id
|
SELECT id
|
||||||
FROM han_app.sync_queue
|
FROM han_app.sync_queue
|
||||||
WHERE status IN ('pending','retry_wait')
|
WHERE (
|
||||||
AND next_attempt_at <= now()
|
status IN ('pending','retry_wait') AND next_attempt_at <= now()
|
||||||
AND (locked_until IS NULL OR locked_until < now())
|
)
|
||||||
|
OR (
|
||||||
|
status='leased' AND locked_until < now()
|
||||||
|
)
|
||||||
ORDER BY next_attempt_at, created_at
|
ORDER BY next_attempt_at, created_at
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
@@ -156,7 +162,7 @@ class Repository:
|
|||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
async with self.engine.begin() as connection:
|
async with self.engine.begin() as connection:
|
||||||
return (
|
workflow_id = (
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
sql,
|
sql,
|
||||||
{
|
{
|
||||||
@@ -167,6 +173,17 @@ class Repository:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
await connection.execute(
|
||||||
|
text(
|
||||||
|
"""
|
||||||
|
UPDATE bitrix_sync.workflow_instances
|
||||||
|
SET state='running',current_step='load_profile',updated_at=now()
|
||||||
|
WHERE id=:id AND state IN ('created','running')
|
||||||
|
"""
|
||||||
|
),
|
||||||
|
{"id": workflow_id},
|
||||||
|
)
|
||||||
|
return workflow_id
|
||||||
|
|
||||||
async def complete_task(self, task: LeasedTask, workflow_id: uuid.UUID) -> bool:
|
async def complete_task(self, task: LeasedTask, workflow_id: uuid.UUID) -> bool:
|
||||||
async with self.engine.begin() as connection:
|
async with self.engine.begin() as connection:
|
||||||
@@ -280,8 +297,12 @@ class Repository:
|
|||||||
"""
|
"""
|
||||||
WITH candidates AS (
|
WITH candidates AS (
|
||||||
SELECT id FROM bitrix_sync.webhook_inbox
|
SELECT id FROM bitrix_sync.webhook_inbox
|
||||||
WHERE status IN ('received','retry_wait') AND next_attempt_at<=now()
|
WHERE (
|
||||||
AND (locked_until IS NULL OR locked_until<now())
|
status IN ('received','retry_wait') AND next_attempt_at<=now()
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
status='processing' AND locked_until<now()
|
||||||
|
)
|
||||||
ORDER BY next_attempt_at,received_at
|
ORDER BY next_attempt_at,received_at
|
||||||
FOR UPDATE SKIP LOCKED LIMIT :limit
|
FOR UPDATE SKIP LOCKED LIMIT :limit
|
||||||
)
|
)
|
||||||
@@ -345,7 +366,8 @@ class Repository:
|
|||||||
text(
|
text(
|
||||||
"""
|
"""
|
||||||
UPDATE han_app.client_profiles
|
UPDATE han_app.client_profiles
|
||||||
SET full_name=:full_name,citizenship=:citizenship,email=:email,updated_at=now()
|
SET full_name=:full_name,citizenship=:citizenship,email=:email,
|
||||||
|
source_updated_at=:source_updated_at,updated_at=now()
|
||||||
WHERE user_id=:user_id AND record_status='A'
|
WHERE user_id=:user_id AND record_status='A'
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
@@ -354,6 +376,7 @@ class Repository:
|
|||||||
"full_name": full_name,
|
"full_name": full_name,
|
||||||
"citizenship": citizenship,
|
"citizenship": citizenship,
|
||||||
"email": email,
|
"email": email,
|
||||||
|
"source_updated_at": source_updated_at,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
await connection.execute(
|
await connection.execute(
|
||||||
@@ -363,14 +386,20 @@ class Repository:
|
|||||||
(id,mapping_id,user_id,external_id,full_name_hash,email_hash,
|
(id,mapping_id,user_id,external_id,full_name_hash,email_hash,
|
||||||
citizenship_hash,source_updated_at,last_applied_source,
|
citizenship_hash,source_updated_at,last_applied_source,
|
||||||
last_webhook_received_at,created_at,updated_at)
|
last_webhook_received_at,created_at,updated_at)
|
||||||
SELECT gen_random_uuid(),m.id,:user_id,:external_id,
|
SELECT gen_random_uuid(),m.id,:user_id,
|
||||||
encode(digest(coalesce(:full_name,''),'sha256'),'hex'),
|
CAST(:external_id AS varchar(128)),
|
||||||
encode(digest(coalesce(:email,''),'sha256'),'hex'),
|
CAST(:full_name_hash AS varchar(64)),
|
||||||
encode(digest(coalesce(:citizenship,''),'sha256'),'hex'),
|
CAST(:email_hash AS varchar(64)),
|
||||||
:source_updated_at,:source,
|
CAST(:citizenship_hash AS varchar(64)),
|
||||||
CASE WHEN :source='webhook' THEN now() END,now(),now()
|
CAST(:source_updated_at AS timestamptz),
|
||||||
|
CAST(:source AS varchar(24)),
|
||||||
|
CASE WHEN CAST(:source AS varchar(24))='webhook'
|
||||||
|
THEN now() END,
|
||||||
|
now(),now()
|
||||||
FROM bitrix_sync.entity_external_mapping m
|
FROM bitrix_sync.entity_external_mapping m
|
||||||
WHERE m.entity_id=:user_id AND m.external_id=:external_id AND m.status='active'
|
WHERE m.entity_id=:user_id
|
||||||
|
AND m.external_id=CAST(:external_id AS varchar(128))
|
||||||
|
AND m.status='active'
|
||||||
ON CONFLICT (mapping_id) DO UPDATE
|
ON CONFLICT (mapping_id) DO UPDATE
|
||||||
SET full_name_hash=excluded.full_name_hash,email_hash=excluded.email_hash,
|
SET full_name_hash=excluded.full_name_hash,email_hash=excluded.email_hash,
|
||||||
citizenship_hash=excluded.citizenship_hash,
|
citizenship_hash=excluded.citizenship_hash,
|
||||||
@@ -385,9 +414,9 @@ class Repository:
|
|||||||
{
|
{
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"external_id": external_id,
|
"external_id": external_id,
|
||||||
"full_name": full_name,
|
"full_name_hash": safe_hash(full_name or ""),
|
||||||
"citizenship": citizenship,
|
"citizenship_hash": safe_hash(citizenship or ""),
|
||||||
"email": email,
|
"email_hash": safe_hash(email or ""),
|
||||||
"source_updated_at": source_updated_at,
|
"source_updated_at": source_updated_at,
|
||||||
"source": source,
|
"source": source,
|
||||||
},
|
},
|
||||||
@@ -506,12 +535,17 @@ class Repository:
|
|||||||
queries = {
|
queries = {
|
||||||
"queue": "SELECT status, count(*) count FROM han_app.sync_queue GROUP BY status",
|
"queue": "SELECT status, count(*) count FROM han_app.sync_queue GROUP BY status",
|
||||||
"workflows": (
|
"workflows": (
|
||||||
"SELECT state, count(*) count FROM bitrix_sync.workflow_instances GROUP BY state"
|
"SELECT state AS status, count(*) count "
|
||||||
|
"FROM bitrix_sync.workflow_instances GROUP BY state"
|
||||||
),
|
),
|
||||||
"commands": (
|
"commands": (
|
||||||
"SELECT status, count(*) count "
|
"SELECT status, count(*) count "
|
||||||
"FROM bitrix_sync.crm_commands GROUP BY status"
|
"FROM bitrix_sync.crm_commands GROUP BY status"
|
||||||
),
|
),
|
||||||
|
"queue_oldest_age_seconds": (
|
||||||
|
"SELECT coalesce(extract(epoch from now()-min(created_at)),0) "
|
||||||
|
"FROM han_app.sync_queue WHERE status IN ('pending','retry_wait','leased')"
|
||||||
|
),
|
||||||
"webhook_lag_seconds": (
|
"webhook_lag_seconds": (
|
||||||
"SELECT coalesce(extract(epoch from now()-min(received_at)),0) "
|
"SELECT coalesce(extract(epoch from now()-min(received_at)),0) "
|
||||||
"FROM bitrix_sync.webhook_inbox WHERE status IN ('received','retry_wait')"
|
"FROM bitrix_sync.webhook_inbox WHERE status IN ('received','retry_wait')"
|
||||||
@@ -530,4 +564,5 @@ class Repository:
|
|||||||
else:
|
else:
|
||||||
output[key] = result.scalar_one_or_none()
|
output[key] = result.scalar_one_or_none()
|
||||||
output["generated_at"] = datetime.now(UTC).isoformat()
|
output["generated_at"] = datetime.now(UTC).isoformat()
|
||||||
|
record_status_snapshot(output)
|
||||||
return output
|
return output
|
||||||
|
|||||||
@@ -0,0 +1,514 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections.abc import Iterator, Mapping
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from time import monotonic
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from opentelemetry import metrics, trace
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||||
|
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
|
||||||
|
from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||||
|
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||||
|
from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor
|
||||||
|
from opentelemetry.propagate import set_global_textmap
|
||||||
|
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
||||||
|
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
||||||
|
from opentelemetry.sdk.metrics import MeterProvider
|
||||||
|
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||||
|
from opentelemetry.sdk.resources import Resource
|
||||||
|
from opentelemetry.sdk.trace import TracerProvider
|
||||||
|
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||||
|
from opentelemetry.trace import SpanKind, Status, StatusCode
|
||||||
|
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||||
|
|
||||||
|
SERVICE_NAMES = frozenset(
|
||||||
|
{"bitrix-sync-api", "bitrix-sync-worker", "bitrix-sync-reconciliation"}
|
||||||
|
)
|
||||||
|
_SECRET_KEYS = re.compile(
|
||||||
|
r"(authorization|cookie|token|secret|password|phone|email|full.?name|"
|
||||||
|
r"message|payload|body|query|url|dsn|statement|object.?key|filename)",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
_SENSITIVE_VALUES = (
|
||||||
|
re.compile(r"(?i)\bbearer\s+\S+"),
|
||||||
|
re.compile(r"(?i)\b(?:token|password|secret|code)=\S+"),
|
||||||
|
re.compile(r"https?://[^\s?#]+[?#]\S+"),
|
||||||
|
re.compile(r"\b[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\b"),
|
||||||
|
re.compile(r"\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b"),
|
||||||
|
re.compile(r"(?<!\w)\+?\d[\d ()-]{8,}\d(?!\w)"),
|
||||||
|
)
|
||||||
|
_SAFE_CODES = frozenset(
|
||||||
|
{
|
||||||
|
"success",
|
||||||
|
"retry",
|
||||||
|
"uncertain",
|
||||||
|
"permanent",
|
||||||
|
"rate_limited",
|
||||||
|
"error",
|
||||||
|
"disabled",
|
||||||
|
"skipped",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_SAFE_TASK_TYPES = frozenset(
|
||||||
|
{"contact.map_or_create", "contact.update", "contact.deactivate", "contact.webhook", "rebind"}
|
||||||
|
)
|
||||||
|
_SAFE_RECEIVERS = frozenset({"contact", "alert", "unknown"})
|
||||||
|
_SAFE_CRM_OPERATIONS = frozenset({"batch", "get", "add", "update", "list", "findbycomm"})
|
||||||
|
_SAFE_QUEUE_STATES = frozenset(
|
||||||
|
{"pending", "retry_wait", "leased", "processed", "received", "processing", "failed"}
|
||||||
|
)
|
||||||
|
_SAFE_ALERT_TYPES = frozenset(
|
||||||
|
{
|
||||||
|
"duplicate_contacts",
|
||||||
|
"contact_owned_by_other_user",
|
||||||
|
"mapping_identity_mismatch",
|
||||||
|
"rebind_target_owned",
|
||||||
|
"unknown_citizenship_enum",
|
||||||
|
"technical_configuration_failure",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_configured_service_name = "bitrix-sync-api"
|
||||||
|
_runtime: TelemetryRuntime | None = None
|
||||||
|
_logging_configured = False
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_scalar(value: Any) -> Any:
|
||||||
|
if value is None or isinstance(value, (bool, int, float)):
|
||||||
|
return value
|
||||||
|
text = str(value)
|
||||||
|
for pattern in _SENSITIVE_VALUES:
|
||||||
|
text = pattern.sub("[REDACTED]", text)
|
||||||
|
return text[:512]
|
||||||
|
|
||||||
|
|
||||||
|
def redact(value: Any, *, key: str = "") -> Any:
|
||||||
|
"""Return a bounded telemetry-safe copy without mutating application data."""
|
||||||
|
if key and _SECRET_KEYS.search(key):
|
||||||
|
return "[REDACTED]"
|
||||||
|
if isinstance(value, Mapping):
|
||||||
|
return {
|
||||||
|
str(item_key)[:64]: redact(item, key=str(item_key))
|
||||||
|
for item_key, item in value.items()
|
||||||
|
}
|
||||||
|
if isinstance(value, (list, tuple, set, frozenset)):
|
||||||
|
return [redact(item) for item in list(value)[:32]]
|
||||||
|
return _safe_scalar(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _trace_fields() -> dict[str, str]:
|
||||||
|
context = trace.get_current_span().get_span_context()
|
||||||
|
if not context.is_valid:
|
||||||
|
return {}
|
||||||
|
return {
|
||||||
|
"trace_id": format(context.trace_id, "032x"),
|
||||||
|
"span_id": format(context.span_id, "016x"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JsonFormatter(logging.Formatter):
|
||||||
|
def format(self, record: logging.LogRecord) -> str:
|
||||||
|
event = {
|
||||||
|
"timestamp": (
|
||||||
|
datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||||
|
),
|
||||||
|
"level": record.levelname,
|
||||||
|
"service.name": _configured_service_name,
|
||||||
|
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
|
||||||
|
"environment": os.getenv("APP_ENV", "production-like"),
|
||||||
|
"module": record.name,
|
||||||
|
"event": getattr(
|
||||||
|
record,
|
||||||
|
"event_name",
|
||||||
|
"log",
|
||||||
|
),
|
||||||
|
"message": record.getMessage(),
|
||||||
|
**_trace_fields(),
|
||||||
|
}
|
||||||
|
attributes = getattr(record, "telemetry_attributes", None)
|
||||||
|
if isinstance(attributes, Mapping):
|
||||||
|
event.update(attributes)
|
||||||
|
if record.exc_info:
|
||||||
|
event["error.type"] = record.exc_info[0].__name__
|
||||||
|
return json.dumps(redact(event), ensure_ascii=False, separators=(",", ":"), default=str)
|
||||||
|
|
||||||
|
|
||||||
|
class RedactionFilter(logging.Filter):
|
||||||
|
def filter(self, record: logging.LogRecord) -> bool:
|
||||||
|
record.msg = redact(record.getMessage()) if hasattr(record, "event_name") else "[REDACTED]"
|
||||||
|
record.args = ()
|
||||||
|
attributes = getattr(record, "telemetry_attributes", None)
|
||||||
|
safe_attributes = dict(attributes) if isinstance(attributes, Mapping) else {}
|
||||||
|
if record.exc_info:
|
||||||
|
safe_attributes["error.type"] = record.exc_info[0].__name__
|
||||||
|
record.exc_info = None
|
||||||
|
record.exc_text = None
|
||||||
|
record.telemetry_attributes = redact(safe_attributes)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class RedactingBatchSpanProcessor(BatchSpanProcessor):
|
||||||
|
"""Remove sensitive auto-instrumentation attributes before queueing/export."""
|
||||||
|
|
||||||
|
def on_end(self, span: Any) -> None:
|
||||||
|
attributes = getattr(span, "_attributes", None)
|
||||||
|
if isinstance(attributes, Mapping):
|
||||||
|
sanitized = dict(attributes)
|
||||||
|
for key in list(sanitized):
|
||||||
|
key_text = str(key)
|
||||||
|
if _SECRET_KEYS.search(key_text) or key_text in {
|
||||||
|
"http.target",
|
||||||
|
"url.full",
|
||||||
|
"db.statement",
|
||||||
|
}:
|
||||||
|
sanitized[key] = "[REDACTED]"
|
||||||
|
else:
|
||||||
|
sanitized[key] = _safe_scalar(sanitized[key])
|
||||||
|
span._attributes = sanitized
|
||||||
|
super().on_end(span)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class TelemetryRuntime:
|
||||||
|
tracer_provider: TracerProvider | None = None
|
||||||
|
meter_provider: MeterProvider | None = None
|
||||||
|
logger_provider: LoggerProvider | None = None
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
for provider in (self.logger_provider, self.meter_provider, self.tracer_provider):
|
||||||
|
if provider is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
provider.shutdown()
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).warning(
|
||||||
|
"Telemetry shutdown failed",
|
||||||
|
extra={"event_name": "telemetry.shutdown.failed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resource(service_name: str) -> Resource:
|
||||||
|
return Resource.create(
|
||||||
|
{
|
||||||
|
"service.name": service_name,
|
||||||
|
"service.namespace": "han-chat",
|
||||||
|
"service.version": os.getenv("RELEASE_VERSION", "unknown"),
|
||||||
|
"deployment.environment": os.getenv("APP_ENV", "production-like"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _configure_stdout(service_name: str) -> None:
|
||||||
|
global _configured_service_name, _logging_configured
|
||||||
|
_configured_service_name = service_name
|
||||||
|
if _logging_configured:
|
||||||
|
return
|
||||||
|
handler = logging.StreamHandler(sys.stdout)
|
||||||
|
handler.setFormatter(JsonFormatter())
|
||||||
|
handler.addFilter(RedactionFilter())
|
||||||
|
root = logging.getLogger()
|
||||||
|
root.addHandler(handler)
|
||||||
|
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
root.setLevel(getattr(logging, level_name, logging.INFO))
|
||||||
|
_logging_configured = True
|
||||||
|
|
||||||
|
|
||||||
|
def init_telemetry(service_name: str) -> TelemetryRuntime:
|
||||||
|
"""Initialize all signals; exporter failures never stop the business process."""
|
||||||
|
global _runtime
|
||||||
|
if service_name not in SERVICE_NAMES:
|
||||||
|
raise ValueError("unregistered bitrix-sync process service.name")
|
||||||
|
_configure_stdout(service_name)
|
||||||
|
if _runtime is not None:
|
||||||
|
return _runtime
|
||||||
|
runtime = TelemetryRuntime()
|
||||||
|
_runtime = runtime
|
||||||
|
endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "").strip()
|
||||||
|
if not endpoint:
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
try:
|
||||||
|
resource = _resource(service_name)
|
||||||
|
insecure = endpoint.startswith("http://")
|
||||||
|
set_global_textmap(TraceContextTextMapPropagator())
|
||||||
|
|
||||||
|
tracer_provider = TracerProvider(resource=resource)
|
||||||
|
tracer_provider.add_span_processor(
|
||||||
|
RedactingBatchSpanProcessor(
|
||||||
|
OTLPSpanExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||||
|
max_queue_size=2048,
|
||||||
|
schedule_delay_millis=5000,
|
||||||
|
max_export_batch_size=512,
|
||||||
|
export_timeout_millis=3000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
trace.set_tracer_provider(tracer_provider)
|
||||||
|
runtime.tracer_provider = tracer_provider
|
||||||
|
|
||||||
|
metric_reader = PeriodicExportingMetricReader(
|
||||||
|
OTLPMetricExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||||
|
export_interval_millis=30_000,
|
||||||
|
export_timeout_millis=3000,
|
||||||
|
)
|
||||||
|
meter_provider = MeterProvider(resource=resource, metric_readers=[metric_reader])
|
||||||
|
metrics.set_meter_provider(meter_provider)
|
||||||
|
runtime.meter_provider = meter_provider
|
||||||
|
|
||||||
|
logger_provider = LoggerProvider(resource=resource)
|
||||||
|
logger_provider.add_log_record_processor(
|
||||||
|
BatchLogRecordProcessor(
|
||||||
|
OTLPLogExporter(endpoint=endpoint, insecure=insecure, timeout=3),
|
||||||
|
max_queue_size=2048,
|
||||||
|
schedule_delay_millis=5000,
|
||||||
|
max_export_batch_size=512,
|
||||||
|
export_timeout_millis=3000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
otlp_handler = LoggingHandler(level=logging.NOTSET, logger_provider=logger_provider)
|
||||||
|
otlp_handler.addFilter(RedactionFilter())
|
||||||
|
logging.getLogger().addHandler(otlp_handler)
|
||||||
|
runtime.logger_provider = logger_provider
|
||||||
|
|
||||||
|
def sanitize_httpx_request(span: trace.Span, request: Any) -> None:
|
||||||
|
if not span.is_recording():
|
||||||
|
return
|
||||||
|
url = request[1]
|
||||||
|
host = getattr(url, "host", "")
|
||||||
|
scheme = getattr(url, "scheme", "https")
|
||||||
|
safe_url = f"{scheme}://{host}/[REDACTED]"
|
||||||
|
span.set_attribute("url.full", safe_url)
|
||||||
|
span.set_attribute("http.url", safe_url)
|
||||||
|
|
||||||
|
HTTPXClientInstrumentor().instrument(request_hook=sanitize_httpx_request)
|
||||||
|
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
"Telemetry initialization failed; stdout fallback remains active",
|
||||||
|
extra={"event_name": "telemetry.init.failed"},
|
||||||
|
)
|
||||||
|
return runtime
|
||||||
|
|
||||||
|
|
||||||
|
def instrument_fastapi(app: FastAPI) -> None:
|
||||||
|
try:
|
||||||
|
FastAPIInstrumentor.instrument_app(
|
||||||
|
app,
|
||||||
|
excluded_urls="/health/live",
|
||||||
|
http_capture_headers_server_request=[],
|
||||||
|
http_capture_headers_server_response=[],
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logging.getLogger(__name__).exception(
|
||||||
|
"FastAPI instrumentation failed",
|
||||||
|
extra={"event_name": "telemetry.fastapi.failed"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def shutdown_telemetry() -> None:
|
||||||
|
global _runtime
|
||||||
|
if _runtime is not None:
|
||||||
|
_runtime.shutdown()
|
||||||
|
_runtime = None
|
||||||
|
|
||||||
|
|
||||||
|
def log_event(
|
||||||
|
event: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
level: int = logging.INFO,
|
||||||
|
attributes: Mapping[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
logging.getLogger("bitrix_sync").log(
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
extra={"event_name": event, "telemetry_attributes": redact(attributes or {})},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def safe_span(
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
kind: SpanKind = SpanKind.INTERNAL,
|
||||||
|
attributes: Mapping[str, Any] | None = None,
|
||||||
|
) -> Iterator[trace.Span]:
|
||||||
|
safe_attributes: dict[str, bool | int | float | str] = {}
|
||||||
|
for key, value in redact(attributes or {}).items():
|
||||||
|
if not isinstance(value, (bool, int, float, str)):
|
||||||
|
continue
|
||||||
|
key_text = str(key)[:64]
|
||||||
|
if key_text == "workflow.type":
|
||||||
|
value = _bounded(str(value), _SAFE_TASK_TYPES)
|
||||||
|
elif key_text == "crm.operation":
|
||||||
|
value = _bounded(str(value), _SAFE_CRM_OPERATIONS)
|
||||||
|
elif key_text == "receiver":
|
||||||
|
value = _bounded(str(value), _SAFE_RECEIVERS, "unknown")
|
||||||
|
safe_attributes[key_text] = value
|
||||||
|
with trace.get_tracer("han.bitrix_sync").start_as_current_span(
|
||||||
|
name[:128], kind=kind, attributes=safe_attributes
|
||||||
|
) as span:
|
||||||
|
try:
|
||||||
|
yield span
|
||||||
|
except Exception as exc:
|
||||||
|
span.set_status(Status(StatusCode.ERROR, type(exc).__name__))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _bounded(value: str, allowed: frozenset[str], fallback: str = "other") -> str:
|
||||||
|
return value if value in allowed else fallback
|
||||||
|
|
||||||
|
|
||||||
|
def _metric_fail_open(function: Any) -> Any:
|
||||||
|
def wrapped(*args: Any, **kwargs: Any) -> None:
|
||||||
|
try:
|
||||||
|
function(*args, **kwargs)
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
return wrapped
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_claim(queue: str, count: int) -> None:
|
||||||
|
queue_name = _bounded(queue, frozenset({"task", "webhook", "rebind"}))
|
||||||
|
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||||
|
"bitrix_sync_claimed_items", unit="{item}"
|
||||||
|
).add(max(0, count), {"queue": queue_name})
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_workflow(task_type: str, outcome: str, duration_seconds: float) -> None:
|
||||||
|
attrs = {
|
||||||
|
"workflow.type": _bounded(task_type, _SAFE_TASK_TYPES),
|
||||||
|
"outcome": _bounded(outcome, _SAFE_CODES),
|
||||||
|
}
|
||||||
|
meter = metrics.get_meter("han.bitrix_sync")
|
||||||
|
meter.create_counter("bitrix_sync_workflows", unit="{workflow}").add(1, attrs)
|
||||||
|
meter.create_histogram("bitrix_sync_workflow_duration", unit="s").record(
|
||||||
|
max(0.0, duration_seconds), attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_webhook(receiver: str, outcome: str) -> None:
|
||||||
|
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||||
|
"bitrix_sync_webhooks", unit="{webhook}"
|
||||||
|
).add(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
"receiver": _bounded(receiver, _SAFE_RECEIVERS, "unknown"),
|
||||||
|
"outcome": _bounded(outcome, frozenset({"accepted", "rejected", "error"})),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_crm(method: str, outcome: str, duration_seconds: float) -> None:
|
||||||
|
operation = method.rsplit(".", 1)[-1]
|
||||||
|
operation = _bounded(operation, _SAFE_CRM_OPERATIONS)
|
||||||
|
attrs = {"operation": operation, "outcome": _bounded(outcome, _SAFE_CODES)}
|
||||||
|
meter = metrics.get_meter("han.bitrix_sync")
|
||||||
|
meter.create_counter("bitrix_sync_crm_calls", unit="{call}").add(1, attrs)
|
||||||
|
meter.create_histogram("bitrix_sync_crm_duration", unit="s").record(
|
||||||
|
max(0.0, duration_seconds), attrs
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_limiter(delay_seconds: float) -> None:
|
||||||
|
metrics.get_meter("han.bitrix_sync").create_histogram(
|
||||||
|
"bitrix_sync_limiter_delay", unit="s"
|
||||||
|
).record(max(0.0, delay_seconds))
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_transition(kind: str, state: str) -> None:
|
||||||
|
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||||
|
"bitrix_sync_transitions", unit="{transition}"
|
||||||
|
).add(
|
||||||
|
1,
|
||||||
|
{
|
||||||
|
"kind": _bounded(kind, frozenset({"workflow", "command", "webhook"})),
|
||||||
|
"state": _bounded(
|
||||||
|
state,
|
||||||
|
frozenset(
|
||||||
|
{
|
||||||
|
"succeeded",
|
||||||
|
"retry",
|
||||||
|
"uncertain",
|
||||||
|
"permanent",
|
||||||
|
"rate_limited",
|
||||||
|
"waiting_manual",
|
||||||
|
"processed",
|
||||||
|
"error",
|
||||||
|
}
|
||||||
|
),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_retry(kind: str) -> None:
|
||||||
|
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||||
|
"bitrix_sync_retries", unit="{retry}"
|
||||||
|
).add(1, {"kind": _bounded(kind, frozenset({"task", "webhook", "rebind", "crm"}))})
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_dead_letter(operation: str) -> None:
|
||||||
|
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||||
|
"bitrix_sync_dead_letters", unit="{item}"
|
||||||
|
).add(1, {"operation": _bounded(operation, frozenset({"crm_command"}))})
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_business_alert(alert_type: str) -> None:
|
||||||
|
metrics.get_meter("han.bitrix_sync").create_counter(
|
||||||
|
"bitrix_sync_business_alerts", unit="{alert}"
|
||||||
|
).add(1, {"alert.type": _bounded(alert_type, _SAFE_ALERT_TYPES)})
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_status_snapshot(status: Mapping[str, Any]) -> None:
|
||||||
|
meter = metrics.get_meter("han.bitrix_sync")
|
||||||
|
depth = meter.create_histogram("bitrix_sync_queue_depth", unit="{item}")
|
||||||
|
for queue_name in ("queue", "workflows", "commands"):
|
||||||
|
values = status.get(queue_name)
|
||||||
|
if not isinstance(values, Mapping):
|
||||||
|
continue
|
||||||
|
for state, count in values.items():
|
||||||
|
if isinstance(count, int):
|
||||||
|
depth.record(
|
||||||
|
max(0, count),
|
||||||
|
{
|
||||||
|
"queue": queue_name,
|
||||||
|
"state": _bounded(str(state), _SAFE_QUEUE_STATES),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
for key in ("queue_oldest_age_seconds", "webhook_lag_seconds"):
|
||||||
|
value = status.get(key)
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
meter.create_histogram(f"bitrix_sync_{key}", unit="s").record(max(0.0, value))
|
||||||
|
|
||||||
|
|
||||||
|
@_metric_fail_open
|
||||||
|
def record_reconciliation(outcome: str, count: int, started: float) -> None:
|
||||||
|
attrs = {"outcome": _bounded(outcome, frozenset({"success", "error", "skipped"}))}
|
||||||
|
meter = metrics.get_meter("han.bitrix_sync")
|
||||||
|
meter.create_counter("bitrix_sync_reconciliation_runs", unit="{run}").add(1, attrs)
|
||||||
|
meter.create_histogram("bitrix_sync_reconciliation_duration", unit="s").record(
|
||||||
|
max(0.0, monotonic() - started), attrs
|
||||||
|
)
|
||||||
|
meter.create_histogram("bitrix_sync_reconciliation_items", unit="{item}").record(
|
||||||
|
max(0, count), attrs
|
||||||
|
)
|
||||||
@@ -4,43 +4,62 @@ import asyncio
|
|||||||
import signal
|
import signal
|
||||||
import socket
|
import socket
|
||||||
import uuid
|
import uuid
|
||||||
|
from time import monotonic
|
||||||
|
|
||||||
from app.config import load_settings
|
from app.config import load_settings
|
||||||
from app.crm import CrmClient
|
from app.crm import CrmClient
|
||||||
from app.domain import full_jitter_delay
|
from app.domain import full_jitter_delay
|
||||||
from app.engine import RetryableWorkflow, WorkflowEngine
|
from app.engine import RetryableWorkflow, WorkflowEngine
|
||||||
from app.repository import Repository
|
from app.repository import Repository
|
||||||
|
from app.telemetry import (
|
||||||
|
init_telemetry,
|
||||||
|
log_event,
|
||||||
|
record_claim,
|
||||||
|
record_retry,
|
||||||
|
record_workflow,
|
||||||
|
safe_span,
|
||||||
|
shutdown_telemetry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def worker_main() -> None:
|
async def worker_main() -> None:
|
||||||
settings = load_settings()
|
init_telemetry("bitrix-sync-worker")
|
||||||
if not settings.enabled:
|
repository: Repository | None = None
|
||||||
return
|
crm: CrmClient | None = None
|
||||||
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
|
|
||||||
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
|
||||||
crm = CrmClient(
|
|
||||||
settings.crm_rest_webhook_url.get_secret_value(),
|
|
||||||
settings.portal_host,
|
|
||||||
settings.http_timeout_sec,
|
|
||||||
)
|
|
||||||
engine = WorkflowEngine(repository, crm, settings)
|
|
||||||
stop = asyncio.Event()
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
for event in (signal.SIGINT, signal.SIGTERM):
|
|
||||||
try:
|
|
||||||
loop.add_signal_handler(event, stop.set)
|
|
||||||
except NotImplementedError:
|
|
||||||
pass
|
|
||||||
worker_id = f"{socket.gethostname()}:{uuid.uuid4()}"
|
|
||||||
try:
|
try:
|
||||||
|
settings = load_settings()
|
||||||
|
if not settings.enabled:
|
||||||
|
log_event("worker.disabled", "Bitrix sync worker is disabled")
|
||||||
|
return
|
||||||
|
assert settings.database_url and settings.crm_rest_webhook_url and settings.portal_host
|
||||||
|
repository = Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||||
|
crm = CrmClient(
|
||||||
|
settings.crm_rest_webhook_url.get_secret_value(),
|
||||||
|
settings.portal_host,
|
||||||
|
settings.http_timeout_sec,
|
||||||
|
)
|
||||||
|
engine = WorkflowEngine(repository, crm, settings)
|
||||||
|
stop = asyncio.Event()
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for event in (signal.SIGINT, signal.SIGTERM):
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(event, stop.set)
|
||||||
|
except NotImplementedError:
|
||||||
|
pass
|
||||||
|
worker_id = f"{socket.gethostname()}:{uuid.uuid4()}"
|
||||||
|
log_event("worker.started", "Bitrix sync worker started")
|
||||||
while not stop.is_set():
|
while not stop.is_set():
|
||||||
tasks = await repository.claim_tasks(
|
with safe_span("bitrix_sync.worker.claim"):
|
||||||
worker_id, settings.claim_size, settings.lease_seconds
|
tasks = await repository.claim_tasks(
|
||||||
)
|
worker_id, settings.claim_size, settings.lease_seconds
|
||||||
webhooks = await repository.claim_webhooks(
|
)
|
||||||
worker_id, settings.claim_size, settings.lease_seconds
|
webhooks = await repository.claim_webhooks(
|
||||||
)
|
worker_id, settings.claim_size, settings.lease_seconds
|
||||||
rebind_ids = await repository.pending_rebind_ids(settings.claim_size)
|
)
|
||||||
|
rebind_ids = await repository.pending_rebind_ids(settings.claim_size)
|
||||||
|
record_claim("task", len(tasks))
|
||||||
|
record_claim("webhook", len(webhooks))
|
||||||
|
record_claim("rebind", len(rebind_ids))
|
||||||
if not tasks and not webhooks and not rebind_ids:
|
if not tasks and not webhooks and not rebind_ids:
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(stop.wait(), timeout=1)
|
await asyncio.wait_for(stop.wait(), timeout=1)
|
||||||
@@ -49,29 +68,68 @@ async def worker_main() -> None:
|
|||||||
for task in tasks:
|
for task in tasks:
|
||||||
if stop.is_set():
|
if stop.is_set():
|
||||||
break
|
break
|
||||||
await engine.process(task)
|
started = monotonic()
|
||||||
|
outcome = "success"
|
||||||
|
try:
|
||||||
|
with safe_span(
|
||||||
|
"bitrix_sync.worker.process",
|
||||||
|
attributes={"workflow.type": task.task_type},
|
||||||
|
):
|
||||||
|
await engine.process(task)
|
||||||
|
except Exception:
|
||||||
|
outcome = "error"
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
record_workflow(task.task_type, outcome, monotonic() - started)
|
||||||
for item in webhooks:
|
for item in webhooks:
|
||||||
if stop.is_set():
|
if stop.is_set():
|
||||||
break
|
break
|
||||||
|
started = monotonic()
|
||||||
|
outcome = "success"
|
||||||
try:
|
try:
|
||||||
await engine.process_webhook(item)
|
with safe_span(
|
||||||
|
"bitrix_sync.worker.webhook",
|
||||||
|
attributes={"receiver": item.receiver_type},
|
||||||
|
):
|
||||||
|
await engine.process_webhook(item)
|
||||||
except RetryableWorkflow as exc:
|
except RetryableWorkflow as exc:
|
||||||
|
outcome = "retry"
|
||||||
|
record_retry("webhook")
|
||||||
delay = exc.retry_after or full_jitter_delay(
|
delay = exc.retry_after or full_jitter_delay(
|
||||||
item.attempt_count + 1,
|
item.attempt_count + 1,
|
||||||
settings.retry_base_seconds,
|
settings.retry_base_seconds,
|
||||||
settings.retry_max_seconds,
|
settings.retry_max_seconds,
|
||||||
)
|
)
|
||||||
await repository.retry_webhook(item, exc.code, delay)
|
await repository.retry_webhook(item, exc.code, delay)
|
||||||
|
except Exception:
|
||||||
|
outcome = "error"
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
record_workflow("contact.webhook", outcome, monotonic() - started)
|
||||||
for request_id in rebind_ids:
|
for request_id in rebind_ids:
|
||||||
if stop.is_set():
|
if stop.is_set():
|
||||||
break
|
break
|
||||||
|
started = monotonic()
|
||||||
|
outcome = "success"
|
||||||
try:
|
try:
|
||||||
await engine.process_rebind(request_id)
|
with safe_span("bitrix_sync.worker.rebind"):
|
||||||
|
await engine.process_rebind(request_id)
|
||||||
except RetryableWorkflow:
|
except RetryableWorkflow:
|
||||||
|
outcome = "retry"
|
||||||
|
record_retry("rebind")
|
||||||
continue
|
continue
|
||||||
|
except Exception:
|
||||||
|
outcome = "error"
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
record_workflow("rebind", outcome, monotonic() - started)
|
||||||
finally:
|
finally:
|
||||||
await crm.close()
|
if crm is not None:
|
||||||
await repository.close()
|
await crm.close()
|
||||||
|
if repository is not None:
|
||||||
|
await repository.close()
|
||||||
|
log_event("worker.stopped", "Bitrix sync worker stopped")
|
||||||
|
shutdown_telemetry()
|
||||||
|
|
||||||
|
|
||||||
def run() -> None:
|
def run() -> None:
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ services:
|
|||||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD: ${BITRIX_SYNC_CONTACT_USER_ID_FIELD:-}
|
BITRIX_SYNC_CONTACT_USER_ID_FIELD: ${BITRIX_SYNC_CONTACT_USER_ID_FIELD:-}
|
||||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD: ${BITRIX_SYNC_CONTACT_REGISTERED_FIELD:-}
|
BITRIX_SYNC_CONTACT_REGISTERED_FIELD: ${BITRIX_SYNC_CONTACT_REGISTERED_FIELD:-}
|
||||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD: ${BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD:-}
|
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD: ${BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD:-}
|
||||||
|
BITRIX_SYNC_CONTACT_SOURCE: ${BITRIX_SYNC_CONTACT_SOURCE:-}
|
||||||
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS: ${BITRIX_WEBHOOK_ALLOWED_CIDRS:-}
|
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS: ${BITRIX_WEBHOOK_ALLOWED_CIDRS:-}
|
||||||
volumes: &sync_secrets
|
volumes: &sync_secrets
|
||||||
- /run/han-chat/secrets/bitrix-sync/database-url:/run/secrets/bitrix_sync_database_url:ro
|
- /run/han-chat/secrets/bitrix-sync/database-url:/run/secrets/bitrix_sync_database_url:ro
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ dependencies = [
|
|||||||
"asyncpg>=0.30,<1",
|
"asyncpg>=0.30,<1",
|
||||||
"fastapi>=0.116,<1",
|
"fastapi>=0.116,<1",
|
||||||
"httpx>=0.28,<1",
|
"httpx>=0.28,<1",
|
||||||
|
"opentelemetry-api>=1.36,<2",
|
||||||
|
"opentelemetry-exporter-otlp-proto-grpc>=1.36,<2",
|
||||||
|
"opentelemetry-instrumentation-fastapi>=0.57b0,<1",
|
||||||
|
"opentelemetry-instrumentation-httpx>=0.57b0,<1",
|
||||||
|
"opentelemetry-instrumentation-sqlalchemy>=0.57b0,<1",
|
||||||
|
"opentelemetry-sdk>=1.36,<2",
|
||||||
"pydantic-settings>=2.10,<3",
|
"pydantic-settings>=2.10,<3",
|
||||||
"python-multipart>=0.0.20,<1",
|
"python-multipart>=0.0.20,<1",
|
||||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||||
|
|||||||
@@ -21,5 +21,6 @@ def full_settings() -> Settings:
|
|||||||
contact_user_id_field="UF_CRM_100",
|
contact_user_id_field="UF_CRM_100",
|
||||||
contact_registered_field="UF_CRM_101",
|
contact_registered_field="UF_CRM_101",
|
||||||
contact_citizenship_field="UF_CRM_102",
|
contact_citizenship_field="UF_CRM_102",
|
||||||
|
contact_source="WEB",
|
||||||
webhook_allowed_cidrs="203.0.113.0/24",
|
webhook_allowed_cidrs="203.0.113.0/24",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,14 +31,36 @@ def test_full_mode_rejects_portal_host_mismatch() -> None:
|
|||||||
contact_user_id_field="UF_CRM_1",
|
contact_user_id_field="UF_CRM_1",
|
||||||
contact_registered_field="UF_CRM_2",
|
contact_registered_field="UF_CRM_2",
|
||||||
contact_citizenship_field="UF_CRM_3",
|
contact_citizenship_field="UF_CRM_3",
|
||||||
|
contact_source="WEB",
|
||||||
webhook_allowed_cidrs="203.0.113.0/24",
|
webhook_allowed_cidrs="203.0.113.0/24",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_rest_field_conversion_is_deterministic() -> None:
|
def test_rest_field_conversion_is_deterministic() -> None:
|
||||||
assert Settings.rest_field_name("UF_CRM_1778692456") == "ufCrm_1778692456"
|
assert Settings.rest_field_name("UF_CRM_1778692456") == "ufCrm_1778692456"
|
||||||
|
assert Settings.rest_field_name("UF_CRM_6a70c275346a7") == "ufCrm_6a70c275346a7"
|
||||||
|
assert Settings.rest_field_name("UF_CRM_AbC123") == "ufCrm_AbC123"
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
Settings.rest_field_name("uf_crm_1")
|
Settings.rest_field_name("uf_crm_1")
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
Settings.rest_field_name("UF_CRM_123_abc")
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_default_allows_compose_process_role_override() -> None:
|
||||||
|
service_root = Path(__file__).parents[1]
|
||||||
|
dockerfile = (service_root / "Dockerfile").read_text(encoding="utf-8")
|
||||||
|
compose = (service_root / "compose.fragment.yaml").read_text(encoding="utf-8")
|
||||||
|
production_compose = (service_root.parent / "docker-compose.yml").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
assert 'CMD ["han-bitrix-sync-api"]' in dockerfile
|
||||||
|
assert 'ENTRYPOINT ["han-bitrix-sync-api"]' not in dockerfile
|
||||||
|
for source in (compose, production_compose):
|
||||||
|
assert 'command: ["han-bitrix-sync-worker"]' in source
|
||||||
|
assert 'command: ["han-bitrix-sync-reconciliation"]' in source
|
||||||
|
reconciliation_service = source.split(" bitrix-sync-reconciliation:", 1)[1].split(
|
||||||
|
"\n bitrix-sync-", 1
|
||||||
|
)[0]
|
||||||
|
assert 'restart: "no"' in reconciliation_service
|
||||||
|
|
||||||
|
|
||||||
def test_alembic_chain_preserves_legacy_baseline() -> None:
|
def test_alembic_chain_preserves_legacy_baseline() -> None:
|
||||||
|
|||||||
@@ -2,20 +2,67 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
|
||||||
from app.crm import CrmOutcome, CrmResult
|
from app.crm import CrmOutcome, CrmResult
|
||||||
from app.engine import WorkflowEngine
|
from app.engine import INSERT_CRM_COMMAND, UPDATE_CRM_COMMAND, WorkflowEngine
|
||||||
from app.repository import Profile
|
from app.repository import Profile
|
||||||
|
|
||||||
|
|
||||||
class Result:
|
class Result:
|
||||||
rowcount = 1
|
rowcount = 1
|
||||||
|
|
||||||
|
def __init__(self, *, row=None, scalar=None) -> None:
|
||||||
|
self.row = row
|
||||||
|
self.scalar = scalar
|
||||||
|
|
||||||
|
def mappings(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def one(self):
|
||||||
|
return self.row
|
||||||
|
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return self.scalar
|
||||||
|
|
||||||
|
|
||||||
class Connection:
|
class Connection:
|
||||||
|
def __init__(self, statements: list[str]) -> None:
|
||||||
|
self.statements = statements
|
||||||
|
|
||||||
async def execute(self, statement, params=None):
|
async def execute(self, statement, params=None):
|
||||||
|
sql = str(statement)
|
||||||
|
self.statements.append(sql)
|
||||||
|
if "INSERT INTO bitrix_sync.business_alerts" in sql:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
return Result(
|
||||||
|
row={
|
||||||
|
"id": uuid.uuid4(),
|
||||||
|
"alert_number": 1,
|
||||||
|
"fingerprint": "fingerprint",
|
||||||
|
"alert_type": params["type"],
|
||||||
|
"severity": "warning",
|
||||||
|
"app_user_id": params["user_id"],
|
||||||
|
"selected_external_id": params["selected_external_id"],
|
||||||
|
"candidate_external_ids": params["candidates"],
|
||||||
|
"remote_item_id": None,
|
||||||
|
"occurrence_count": 1,
|
||||||
|
"first_occurred_at": now,
|
||||||
|
"last_occurred_at": now,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if "SELECT value_json FROM bitrix_sync.settings" in sql:
|
||||||
|
return Result(
|
||||||
|
scalar={
|
||||||
|
"entity_type_id": 178,
|
||||||
|
"category_id": 5,
|
||||||
|
"stage_new": "DT178_5:NEW",
|
||||||
|
"field_ids": {"candidate_external_ids": "ufCrm_999"},
|
||||||
|
}
|
||||||
|
)
|
||||||
return Result()
|
return Result()
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +73,7 @@ class FakeRepository:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def transaction(self):
|
async def transaction(self):
|
||||||
yield Connection()
|
yield Connection(self.statements)
|
||||||
|
|
||||||
async def active_mapping(self, user_id):
|
async def active_mapping(self, user_id):
|
||||||
return self.mapping
|
return self.mapping
|
||||||
@@ -36,8 +83,16 @@ class FakeRepository:
|
|||||||
|
|
||||||
|
|
||||||
class FakeCrm:
|
class FakeCrm:
|
||||||
def __init__(self, user_id: uuid.UUID) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
*,
|
||||||
|
existing_identity: bool = False,
|
||||||
|
foreign_owner: bool = False,
|
||||||
|
) -> None:
|
||||||
self.user_id = user_id
|
self.user_id = user_id
|
||||||
|
self.existing_identity = existing_identity
|
||||||
|
self.foreign_owner = foreign_owner
|
||||||
self.calls: list[tuple[str, dict]] = []
|
self.calls: list[tuple[str, dict]] = []
|
||||||
|
|
||||||
async def call(self, method, params, *, mutating):
|
async def call(self, method, params, *, mutating):
|
||||||
@@ -51,12 +106,27 @@ class FakeCrm:
|
|||||||
{
|
{
|
||||||
"ID": contact_id,
|
"ID": contact_id,
|
||||||
"CREATED_TIME": "2026-08-06T10:00:00Z",
|
"CREATED_TIME": "2026-08-06T10:00:00Z",
|
||||||
"UF_CRM_100": None,
|
"UF_CRM_100": (
|
||||||
|
str(self.user_id)
|
||||||
|
if self.existing_identity and contact_id == "10"
|
||||||
|
else str(uuid.UUID(int=1))
|
||||||
|
if self.foreign_owner and contact_id == "10"
|
||||||
|
else None
|
||||||
|
),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
if method == "crm.contact.add":
|
||||||
|
return CrmResult(CrmOutcome.SUCCEEDED, "11")
|
||||||
|
if method == "crm.item.add":
|
||||||
|
return CrmResult(CrmOutcome.SUCCEEDED, {"item": {"id": "500"}})
|
||||||
return CrmResult(CrmOutcome.SUCCEEDED, True)
|
return CrmResult(CrmOutcome.SUCCEEDED, True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_crm_command_json_payloads_have_explicit_jsonb_types() -> None:
|
||||||
|
assert isinstance(INSERT_CRM_COMMAND._bindparams["safe_request"].type, JSONB)
|
||||||
|
assert isinstance(UPDATE_CRM_COMMAND._bindparams["safe_response"].type, JSONB)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_multiple_contacts_choose_numeric_newest(full_settings) -> None:
|
async def test_multiple_contacts_choose_numeric_newest(full_settings) -> None:
|
||||||
user_id = uuid.uuid4()
|
user_id = uuid.uuid4()
|
||||||
@@ -70,3 +140,46 @@ async def test_multiple_contacts_choose_numeric_newest(full_settings) -> None:
|
|||||||
updates = [params for method, params in crm.calls if method == "crm.contact.update"]
|
updates = [params for method, params in crm.calls if method == "crm.contact.update"]
|
||||||
assert updates[0]["id"] == "10"
|
assert updates[0]["id"] == "10"
|
||||||
assert not any(method == "crm.contact.add" for method, _ in crm.calls)
|
assert not any(method == "crm.contact.add" for method, _ in crm.calls)
|
||||||
|
assert any(method == "crm.item.add" for method, _ in crm.calls)
|
||||||
|
alert_add = next(params for method, params in crm.calls if method == "crm.item.add")
|
||||||
|
assert alert_add["fields"]["contactIds"] == [9, 10]
|
||||||
|
assert alert_add["fields"]["sourceId"] == "WEB"
|
||||||
|
assert "ufCrm_999" not in alert_add["fields"]
|
||||||
|
assert any("entity_external_mapping" in statement for statement in repository.statements)
|
||||||
|
assert not any("digest(" in statement for statement in repository.statements)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_recovered_duplicate_creates_alert_before_mapping(full_settings) -> None:
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
repository = FakeRepository()
|
||||||
|
crm = FakeCrm(user_id, existing_identity=True)
|
||||||
|
engine = WorkflowEngine(repository, crm, full_settings)
|
||||||
|
|
||||||
|
await engine._map_or_create(
|
||||||
|
uuid.uuid4(),
|
||||||
|
Profile(user_id=user_id, phone="+79001234567", identity_status="A", profile_status="A"),
|
||||||
|
)
|
||||||
|
|
||||||
|
methods = [method for method, _ in crm.calls]
|
||||||
|
assert "crm.item.add" in methods
|
||||||
|
assert "crm.contact.update" not in methods
|
||||||
|
assert any("entity_external_mapping" in statement for statement in repository.statements)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_foreign_owned_contact_alert_includes_new_contact(full_settings) -> None:
|
||||||
|
user_id = uuid.uuid4()
|
||||||
|
repository = FakeRepository()
|
||||||
|
crm = FakeCrm(user_id, foreign_owner=True)
|
||||||
|
engine = WorkflowEngine(repository, crm, full_settings)
|
||||||
|
|
||||||
|
await engine._map_or_create(
|
||||||
|
uuid.uuid4(),
|
||||||
|
Profile(user_id=user_id, phone="+79001234567", identity_status="A", profile_status="A"),
|
||||||
|
)
|
||||||
|
|
||||||
|
methods = [method for method, _ in crm.calls]
|
||||||
|
alert_add = next(params for method, params in crm.calls if method == "crm.item.add")
|
||||||
|
assert methods.index("crm.contact.add") < methods.index("crm.item.add")
|
||||||
|
assert alert_add["fields"]["contactIds"] == [9, 10, 11]
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain import safe_hash
|
||||||
|
from app.reconciliation import IncrementalReconciler
|
||||||
|
from app.repository import LeasedTask, Repository
|
||||||
|
|
||||||
|
|
||||||
|
class FakeResult:
|
||||||
|
def __init__(self, *, rows=(), scalar=None) -> None:
|
||||||
|
self.rows = rows
|
||||||
|
self.scalar = scalar
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return iter(self.rows)
|
||||||
|
|
||||||
|
def mappings(self):
|
||||||
|
return self
|
||||||
|
|
||||||
|
def scalar_one_or_none(self):
|
||||||
|
return self.scalar
|
||||||
|
|
||||||
|
def scalar_one(self):
|
||||||
|
return self.scalar
|
||||||
|
|
||||||
|
|
||||||
|
class FakeConnection:
|
||||||
|
async def execute(self, statement):
|
||||||
|
sql = str(statement)
|
||||||
|
if "han_app.sync_queue" in sql:
|
||||||
|
return FakeResult(rows=[SimpleNamespace(status="pending", count=2)])
|
||||||
|
if "workflow_instances" in sql:
|
||||||
|
assert "state AS status" in sql
|
||||||
|
return FakeResult(rows=[SimpleNamespace(status="created", count=3)])
|
||||||
|
if "crm_commands" in sql:
|
||||||
|
return FakeResult(rows=[SimpleNamespace(status="succeeded", count=4)])
|
||||||
|
if "webhook_inbox" in sql:
|
||||||
|
return FakeResult(scalar=1.5)
|
||||||
|
if "settings_versions" in sql:
|
||||||
|
return FakeResult(scalar=1)
|
||||||
|
raise AssertionError(f"unexpected status query: {sql}")
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEngine:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.queries: list[str] = []
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def connect(self):
|
||||||
|
yield FakeConnection()
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def begin(self):
|
||||||
|
yield ClaimConnection(self.queries)
|
||||||
|
|
||||||
|
|
||||||
|
class ClaimConnection:
|
||||||
|
def __init__(self, queries: list[str]) -> None:
|
||||||
|
self.queries = queries
|
||||||
|
|
||||||
|
async def execute(self, statement, params):
|
||||||
|
sql = str(statement)
|
||||||
|
self.queries.append(sql)
|
||||||
|
if "INSERT INTO bitrix_sync.workflow_instances" in sql:
|
||||||
|
return FakeResult(scalar=params["id"])
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_status_uses_common_status_alias_for_workflows() -> None:
|
||||||
|
repository = object.__new__(Repository)
|
||||||
|
repository.engine = FakeEngine()
|
||||||
|
|
||||||
|
result = await repository.status()
|
||||||
|
|
||||||
|
assert result["queue"] == {"pending": 2}
|
||||||
|
assert result["workflows"] == {"created": 3}
|
||||||
|
assert result["commands"] == {"succeeded": 4}
|
||||||
|
assert result["webhook_lag_seconds"] == 1.5
|
||||||
|
assert result["settings_version"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_claims_recover_expired_leases() -> None:
|
||||||
|
repository = object.__new__(Repository)
|
||||||
|
engine = FakeEngine()
|
||||||
|
repository.engine = engine
|
||||||
|
|
||||||
|
assert await repository.claim_tasks("worker", 10, 60) == []
|
||||||
|
assert await repository.claim_webhooks("worker", 10, 60) == []
|
||||||
|
|
||||||
|
assert "status='leased' AND locked_until < now()" in engine.queries[0]
|
||||||
|
assert "status='processing' AND locked_until<now()" in engine.queries[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_workflow_marks_recovered_workflow_running() -> None:
|
||||||
|
repository = object.__new__(Repository)
|
||||||
|
engine = FakeEngine()
|
||||||
|
repository.engine = engine
|
||||||
|
task = LeasedTask(uuid.uuid4(), "contact.map_or_create", uuid.uuid4(), uuid.uuid4(), 0)
|
||||||
|
|
||||||
|
workflow_id = await repository.create_workflow(task)
|
||||||
|
|
||||||
|
assert isinstance(workflow_id, uuid.UUID)
|
||||||
|
assert "SET state='running'" in engine.queries[1]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_reconciliation_uses_unambiguous_external_id_alias() -> None:
|
||||||
|
queries: list[str] = []
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
async def execute(self, statement, params):
|
||||||
|
queries.append(str(statement))
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
class ReconciliationRepository:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def transaction(self):
|
||||||
|
yield Connection()
|
||||||
|
|
||||||
|
reconciler = IncrementalReconciler(ReconciliationRepository(), None, "ufCrm_1")
|
||||||
|
await reconciler._enqueue_changed(
|
||||||
|
[
|
||||||
|
("29406", "2026-08-20T13:21:00+00:00"),
|
||||||
|
("29502", "2026-08-20T13:22:00+00:00"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "AS candidate(external_id,event_id)" in queries[0]
|
||||||
|
assert "w.external_entity_id=candidate.external_id" in queries[0]
|
||||||
|
assert "ON CONFLICT (receiver_type,event_id)" in queries[0]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_apply_crm_profile_hashes_without_database_digest() -> None:
|
||||||
|
calls: list[tuple[str, dict | None]] = []
|
||||||
|
|
||||||
|
class Connection:
|
||||||
|
async def execute(self, statement, params=None):
|
||||||
|
calls.append((str(statement), params))
|
||||||
|
return FakeResult()
|
||||||
|
|
||||||
|
class Engine:
|
||||||
|
@asynccontextmanager
|
||||||
|
async def begin(self):
|
||||||
|
yield Connection()
|
||||||
|
|
||||||
|
repository = object.__new__(Repository)
|
||||||
|
repository.engine = Engine()
|
||||||
|
source_updated_at = datetime.now(UTC)
|
||||||
|
|
||||||
|
await repository.apply_crm_profile(
|
||||||
|
uuid.uuid4(),
|
||||||
|
"29406",
|
||||||
|
full_name="Тестовый пользователь",
|
||||||
|
citizenship=None,
|
||||||
|
email=None,
|
||||||
|
source_updated_at=source_updated_at,
|
||||||
|
source="webhook",
|
||||||
|
)
|
||||||
|
|
||||||
|
profile_sql, profile_params = calls[1]
|
||||||
|
snapshot_sql, snapshot_params = calls[2]
|
||||||
|
assert "source_updated_at=:source_updated_at" in profile_sql
|
||||||
|
assert profile_params["source_updated_at"] == source_updated_at
|
||||||
|
assert "digest(" not in snapshot_sql
|
||||||
|
assert "CAST(:external_id AS varchar(128))" in snapshot_sql
|
||||||
|
assert "CAST(:source AS varchar(24))" in snapshot_sql
|
||||||
|
assert snapshot_params["full_name_hash"] == safe_hash("Тестовый пользователь")
|
||||||
|
assert snapshot_params["email_hash"] == safe_hash("")
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from opentelemetry.sdk.trace import ReadableSpan
|
||||||
|
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
|
||||||
|
|
||||||
|
from app import telemetry
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"canary",
|
||||||
|
[
|
||||||
|
"Bearer canary-authorization-value",
|
||||||
|
"person@example.test",
|
||||||
|
"+7 (999) 123-45-67",
|
||||||
|
"https://portal.example/path?token=canary",
|
||||||
|
"aaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbb.cccccccccc",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_redaction_removes_canaries_from_json_stdout(canary: str) -> None:
|
||||||
|
record = logging.LogRecord(
|
||||||
|
"test",
|
||||||
|
logging.INFO,
|
||||||
|
__file__,
|
||||||
|
1,
|
||||||
|
"safe event %s",
|
||||||
|
(canary,),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
record.event_name = "test.canary"
|
||||||
|
record.telemetry_attributes = {
|
||||||
|
"authorization": canary,
|
||||||
|
"nested": {"email": canary, "safe": canary},
|
||||||
|
}
|
||||||
|
|
||||||
|
telemetry.RedactionFilter().filter(record)
|
||||||
|
output = telemetry.JsonFormatter().format(record)
|
||||||
|
|
||||||
|
assert canary not in output
|
||||||
|
assert json.loads(output)["event"] == "test.canary"
|
||||||
|
|
||||||
|
|
||||||
|
def test_redaction_blocks_sensitive_keys_and_bounds_collections() -> None:
|
||||||
|
result = telemetry.redact(
|
||||||
|
{
|
||||||
|
"db.statement": "SELECT secret FROM users",
|
||||||
|
"object_key": "private/file.txt",
|
||||||
|
"safe": list(range(100)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["db.statement"] == "[REDACTED]"
|
||||||
|
assert result["object_key"] == "[REDACTED]"
|
||||||
|
assert len(result["safe"]) == 32
|
||||||
|
|
||||||
|
|
||||||
|
def test_redaction_drops_exception_text_before_otlp() -> None:
|
||||||
|
try:
|
||||||
|
raise RuntimeError("secret-token-in-exception")
|
||||||
|
except RuntimeError:
|
||||||
|
record = logging.LogRecord(
|
||||||
|
"test",
|
||||||
|
logging.ERROR,
|
||||||
|
__file__,
|
||||||
|
1,
|
||||||
|
"operation failed",
|
||||||
|
(),
|
||||||
|
exc_info=__import__("sys").exc_info(),
|
||||||
|
)
|
||||||
|
|
||||||
|
telemetry.RedactionFilter().filter(record)
|
||||||
|
|
||||||
|
assert record.exc_info is None
|
||||||
|
assert record.telemetry_attributes["error.type"] == "RuntimeError"
|
||||||
|
assert "secret-token-in-exception" not in telemetry.JsonFormatter().format(record)
|
||||||
|
|
||||||
|
|
||||||
|
def test_span_processor_replaces_immutable_sdk_attributes() -> None:
|
||||||
|
exporter = InMemorySpanExporter()
|
||||||
|
processor = telemetry.RedactingBatchSpanProcessor(exporter)
|
||||||
|
span = ReadableSpan(
|
||||||
|
name="crm.request",
|
||||||
|
attributes={"url.full": "https://crm.example/?token=secret", "safe.outcome": "success"},
|
||||||
|
)
|
||||||
|
|
||||||
|
processor.on_end(span)
|
||||||
|
processor.shutdown()
|
||||||
|
|
||||||
|
assert span.attributes["url.full"] == "[REDACTED]"
|
||||||
|
assert span.attributes["safe.outcome"] == "success"
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_without_endpoint_is_backward_compatible(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv("OTEL_EXPORTER_OTLP_ENDPOINT", raising=False)
|
||||||
|
monkeypatch.setattr(telemetry, "_runtime", None)
|
||||||
|
|
||||||
|
runtime = telemetry.init_telemetry("bitrix-sync-worker")
|
||||||
|
|
||||||
|
assert runtime.tracer_provider is None
|
||||||
|
assert runtime.meter_provider is None
|
||||||
|
assert runtime.logger_provider is None
|
||||||
|
telemetry.shutdown_telemetry()
|
||||||
|
|
||||||
|
|
||||||
|
def test_each_process_has_distinct_service_resource() -> None:
|
||||||
|
names = {
|
||||||
|
telemetry._resource(name).attributes["service.name"] # noqa: SLF001
|
||||||
|
for name in telemetry.SERVICE_NAMES
|
||||||
|
}
|
||||||
|
|
||||||
|
assert names == {
|
||||||
|
"bitrix-sync-api",
|
||||||
|
"bitrix-sync-worker",
|
||||||
|
"bitrix-sync-reconciliation",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_is_fail_open_when_exporter_construction_fails(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setenv("OTEL_EXPORTER_OTLP_ENDPOINT", "http://collector:4317")
|
||||||
|
monkeypatch.setattr(telemetry, "_runtime", None)
|
||||||
|
|
||||||
|
def fail_exporter(**_kwargs: Any) -> None:
|
||||||
|
raise RuntimeError("collector unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(telemetry, "OTLPSpanExporter", fail_exporter)
|
||||||
|
|
||||||
|
runtime = telemetry.init_telemetry("bitrix-sync-reconciliation")
|
||||||
|
|
||||||
|
assert runtime.tracer_provider is None
|
||||||
|
telemetry.shutdown_telemetry()
|
||||||
|
|
||||||
|
|
||||||
|
def test_business_metric_attributes_are_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
captured: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
class Instrument:
|
||||||
|
def add(self, _value: int, attributes: dict[str, str]) -> None:
|
||||||
|
captured.append(attributes)
|
||||||
|
|
||||||
|
def record(self, _value: float, attributes: dict[str, str]) -> None:
|
||||||
|
captured.append(attributes)
|
||||||
|
|
||||||
|
class Meter:
|
||||||
|
def create_counter(self, *_args: Any, **_kwargs: Any) -> Instrument:
|
||||||
|
return Instrument()
|
||||||
|
|
||||||
|
def create_histogram(self, *_args: Any, **_kwargs: Any) -> Instrument:
|
||||||
|
return Instrument()
|
||||||
|
|
||||||
|
monkeypatch.setattr(telemetry.metrics, "get_meter", lambda _name: Meter())
|
||||||
|
|
||||||
|
telemetry.record_workflow("user-2f3c0a5e-identifier", "novel-state", 0.5)
|
||||||
|
telemetry.record_crm("unknown.dynamic.method", "novel-state", 0.1)
|
||||||
|
|
||||||
|
assert captured
|
||||||
|
assert all("user-2f3c0a5e-identifier" not in values.values() for values in captured)
|
||||||
|
assert all("novel-state" not in values.values() for values in captured)
|
||||||
|
|
||||||
|
|
||||||
|
def test_business_metrics_are_fail_open(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
def unavailable(_name: str) -> None:
|
||||||
|
raise RuntimeError("metrics unavailable")
|
||||||
|
|
||||||
|
monkeypatch.setattr(telemetry.metrics, "get_meter", unavailable)
|
||||||
|
|
||||||
|
telemetry.record_claim("task", 1)
|
||||||
@@ -34,8 +34,11 @@ The full step-by-step procedure with gates and copy-paste commands is in
|
|||||||
expose unauthenticated `PING` only for health and a password-protected
|
expose unauthenticated `PING` only for health and a password-protected
|
||||||
`safety` user limited to required `han:safety:*` keys/commands. The password
|
`safety` user limited to required `han:safety:*` keys/commands. The password
|
||||||
in `MESSAGE_SAFETY_REDIS_URL` must match. Start from
|
in `MESSAGE_SAFETY_REDIS_URL` must match. Start from
|
||||||
`redis/redis-safety.acl.template`, replace
|
`redis/redis-safety.acl.template`, replace both placeholders, and add the
|
||||||
`REPLACE_WITH_LONG_RANDOM_PASSWORD`, and never commit the password.
|
`exporter` user limited to `PING`/`INFO`. Its ACL password must exactly match
|
||||||
|
the separate `REDIS_EXPORTER_PASSWORD` secret. That secret is a JSON password
|
||||||
|
map, `{"redis://redis-safety:6379":"<THE_SAME_PASSWORD>"}`, not a raw password
|
||||||
|
string. Never commit either password.
|
||||||
8. Provision distinct runtime and migration DB credentials.
|
8. Provision distinct runtime and migration DB credentials.
|
||||||
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` may migrate/activate policy while
|
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` may migrate/activate policy while
|
||||||
`MESSAGE_SAFETY_DATABASE_URL` cannot; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
|
`MESSAGE_SAFETY_DATABASE_URL` cannot; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
|
||||||
@@ -43,7 +46,9 @@ The full step-by-step procedure with gates and copy-paste commands is in
|
|||||||
role. Migration credentials are mounted only into the `ops` profile jobs.
|
role. Migration credentials are mounted only into the `ops` profile jobs.
|
||||||
9. The setup script leaves UFW egress open for bootstrap. Before production,
|
9. The setup script leaves UFW egress open for bootstrap. Before production,
|
||||||
constrain egress through Selectel SG/NAT/proxy to the approved PostgreSQL,
|
constrain egress through Selectel SG/NAT/proxy to the approved PostgreSQL,
|
||||||
S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz and ClamAV destinations.
|
S3, Secrets Manager, Bitrix24, DNS/NTP and SigNoz destinations. Host KESL
|
||||||
|
receives only update-source egress approved by
|
||||||
|
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
|
||||||
Registry/package access exists only during controlled maintenance windows.
|
Registry/package access exists only during controlled maintenance windows.
|
||||||
|
|
||||||
## Who runs what
|
## Who runs what
|
||||||
@@ -75,14 +80,17 @@ Compose first:
|
|||||||
`bitrix_sync`, separate migration/runtime DSNs; see
|
`bitrix_sync`, separate migration/runtime DSNs; see
|
||||||
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
||||||
3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record
|
3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record
|
||||||
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, clamav,
|
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis,
|
||||||
otel-collector).
|
otel-collector, Redis exporter and nginx exporter). `clamd`/`freshclam` are
|
||||||
|
absent from Compose; KESL 12.4 standalone and its broker run on the host.
|
||||||
4. **Selectel Secrets Manager** — populate all remote names from
|
4. **Selectel Secrets Manager** — populate all remote names from
|
||||||
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
|
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
|
||||||
`REDIS_SAFETY_ACL`, internal TLS PEM for `8443`). Dedicated VM2 IAM principal
|
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM for `8443`).
|
||||||
with read-only access to those names only.
|
Dedicated VM2 IAM principal with read-only access to those names only.
|
||||||
5. **S3 quarantine bucket** and SigNoz OTLP endpoint — non-secret values in
|
5. **S3 quarantine bucket** and SigNoz OTLP endpoint — non-secret values in
|
||||||
`.env`.
|
`.env`. Current self-hosted SigNoz accepts private plaintext OTLP without
|
||||||
|
authentication; do not provision a fake auth secret or mandatory non-empty
|
||||||
|
header.
|
||||||
6. **Internal TLS** — internal-CA certificate with SAN = VM2 private DNS; PEM
|
6. **Internal TLS** — internal-CA certificate with SAN = VM2 private DNS; PEM
|
||||||
stored in Secrets Manager, not in the release tree.
|
stored in Secrets Manager, not in the release tree.
|
||||||
|
|
||||||
@@ -136,6 +144,11 @@ Copy `.env.example` → `.env`, install loader config as
|
|||||||
password with `systemd-creds`, edit nginx allow-lists. Details:
|
password with `systemd-creds`, edit nginx allow-lists. Details:
|
||||||
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §4.
|
[`RUNBOOK.ru.md`](RUNBOOK.ru.md) §4.
|
||||||
|
|
||||||
|
The local Collector receives logs directly over OTLP (no `filelog`), scrapes
|
||||||
|
only itself plus the Redis/nginx exporters, and collects host metrics through
|
||||||
|
read-only `/hostfs`. Exporters and nginx `stub_status` use internal networks and
|
||||||
|
`expose` only; they have no host-published ports.
|
||||||
|
|
||||||
## 5. PostgreSQL CA and initial public TLS
|
## 5. PostgreSQL CA and initial public TLS
|
||||||
|
|
||||||
Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
|
Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
|
||||||
@@ -147,6 +160,13 @@ Install managed PostgreSQL CA under `/etc/han/ca`, issue Let's Encrypt cert for
|
|||||||
Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable
|
Execute gates **in order** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → 9. Do not enable
|
||||||
`han-processing.service` until Gate 5 completes successfully.
|
`han-processing.service` until Gate 5 completes successfully.
|
||||||
|
|
||||||
|
Before Message Safety starts in Gate 5, complete the operator KESL runbook,
|
||||||
|
then enable/start `han-kesl-scan-broker.socket` and verify KESL/socket status
|
||||||
|
plus `root:han-message-safety 0660` on `/run/han-kesl/scan.sock`. Only then
|
||||||
|
start Message Safety. The broker is a custom integration: exact output/exit
|
||||||
|
semantics of `kesl-control --scan-file --action Inform`, cleanup and throughput
|
||||||
|
must pass on the target VM2.
|
||||||
|
|
||||||
| Gate | Purpose |
|
| Gate | Purpose |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| 1 | Secrets materialized via `han-secrets-vm2.service` |
|
| 1 | Secrets materialized via `han-secrets-vm2.service` |
|
||||||
@@ -185,8 +205,9 @@ Safety cutover on VM1 or Bitrix sync enablement.
|
|||||||
briefly.
|
briefly.
|
||||||
3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot
|
3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot
|
||||||
timer, unit journals — without secret values.
|
timer, unit journals — without secret values.
|
||||||
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, ClamAV
|
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, KESL
|
||||||
signature age, OTEL queue, disk/RAM, MOCK mode, private Safety API).
|
version/database date, hourly update, broker/socket status, OTEL queue,
|
||||||
|
disk/RAM, MOCK mode, private Safety API).
|
||||||
5. Proceed to controlled Message Safety cutover on VM1 — see
|
5. Proceed to controlled Message Safety cutover on VM1 — see
|
||||||
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13.
|
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13.
|
||||||
6. Keep `BITRIX_SYNC_ENABLED=false` and `BITRIX_SYNC_MODE=disabled`; Bitrix
|
6. Keep `BITRIX_SYNC_ENABLED=false` and `BITRIX_SYNC_MODE=disabled`; Bitrix
|
||||||
@@ -199,8 +220,8 @@ Details: [`RUNBOOK.ru.md`](RUNBOOK.ru.md) §7.
|
|||||||
## Failure policy
|
## Failure policy
|
||||||
|
|
||||||
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
|
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
|
||||||
- Stale/unavailable ClamAV signatures disable file capability only; they never
|
- A stale/unavailable KESL database or broker error disables file capability
|
||||||
convert a scan error to allow.
|
only; scan errors remain retryable and eventually return `503`, never allow.
|
||||||
- Redis loss may remove acceleration but PostgreSQL remains authoritative.
|
- Redis loss may remove acceleration but PostgreSQL remains authoritative.
|
||||||
- OTEL outage queues within the bounded volume and must not change verdicts.
|
- OTEL outage queues within the bounded volume and must not change verdicts.
|
||||||
- Rollback does not downgrade schemas, delete durable tasks/mappings, or run
|
- Rollback does not downgrade schemas, delete durable tasks/mappings, or run
|
||||||
@@ -224,10 +245,12 @@ health, and restores the previous mode on failure. MOCK has no timeout: keep a
|
|||||||
high-severity alert active until explicit `standard`, then verify normal
|
high-severity alert active until explicit `standard`, then verify normal
|
||||||
text/link/file capabilities and an EICAR canary.
|
text/link/file capabilities and an EICAR canary.
|
||||||
|
|
||||||
## Known image exceptions
|
## Host KESL and broker
|
||||||
|
|
||||||
ClamAV images may require UID/path adjustments after validating the exact
|
KESL 12.4 standalone and the root-owned fail-closed broker are not Compose
|
||||||
digest. Do not weaken `read_only`, capabilities or mounts globally: document
|
images. The Message Safety worker receives only
|
||||||
the smallest writable signature/runtime paths and compensate with network and
|
`/run/han-kesl/scan.sock`, not `kesl-control`, the Docker socket or host-root
|
||||||
resource limits. `freshclam` alone receives signature-CDN egress; `clamd`
|
access. Runtime records `scanner_engine=kesl`; `signatures_version` is the hash
|
||||||
receives none.
|
of KESL version plus database date. Installation, hourly database updates,
|
||||||
|
socket permissions, clean/EICAR/error/stale gates and rollback follow
|
||||||
|
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
|
||||||
|
|||||||
@@ -37,8 +37,12 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
|
|||||||
7. `REDIS_SAFETY_ACL` — полный ACL-файл, а не просто пароль. Он должен
|
7. `REDIS_SAFETY_ACL` — полный ACL-файл, а не просто пароль. Он должен
|
||||||
открывать неаутентифицированный `PING` только для health и
|
открывать неаутентифицированный `PING` только для health и
|
||||||
защищённого паролем пользователя `safety`, ограниченного необходимыми
|
защищённого паролем пользователя `safety`, ограниченного необходимыми
|
||||||
ключами/командами `han:safety:*`.
|
ключами/командами `han:safety:*`. Добавьте пользователя `exporter` только с
|
||||||
(Пароль в `MESSAGE_SAFETY_REDIS_URL` должен совпадать. Используйте `redis/redis-safety.acl.template`, заменив `REPLACE_WITH_LONG_RANDOM_PASSWORD)
|
`PING`/`INFO`; его пароль в ACL должен в точности совпадать с отдельным
|
||||||
|
`REDIS_EXPORTER_PASSWORD`. Этот secret хранится в формате JSON password map:
|
||||||
|
`{"redis://redis-safety:6379":"<ТОТ_ЖЕ_ПАРОЛЬ>"}`, а не как голая строка.
|
||||||
|
Пароль `safety` в `MESSAGE_SAFETY_REDIS_URL` также должен совпадать с ACL. Используйте
|
||||||
|
`redis/redis-safety.acl.template`, заменив оба плейсхолдера.
|
||||||
8. Выделите отдельные учётные данные БД для runtime и миграций.
|
8. Выделите отдельные учётные данные БД для runtime и миграций.
|
||||||
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` может мигрировать/активировать
|
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` может мигрировать/активировать
|
||||||
политику, а `MESSAGE_SAFETY_DATABASE_URL` — нет; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
|
политику, а `MESSAGE_SAFETY_DATABASE_URL` — нет; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
|
||||||
@@ -47,8 +51,9 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
|
|||||||
`ops`.
|
`ops`.
|
||||||
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
|
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
|
||||||
production ограничьте egress правилами Selectel SG/NAT/proxy до
|
production ограничьте egress правилами Selectel SG/NAT/proxy до
|
||||||
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и
|
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP и SigNoz.
|
||||||
источников ClamAV. Registry/package repositories оставляйте только на
|
Для host KESL разрешите только источники обновления из
|
||||||
|
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). Registry/package repositories оставляйте только на
|
||||||
controlled maintenance window.
|
controlled maintenance window.
|
||||||
|
|
||||||
## Кто что выполняет
|
## Кто что выполняет
|
||||||
@@ -83,12 +88,15 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
|
|||||||
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
||||||
3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`;
|
3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`;
|
||||||
получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis,
|
получить immutable digest для всех `*_IMAGE` в `.env.example` (nginx, redis,
|
||||||
clamav, otel-collector).
|
otel-collector, Redis exporter, nginx exporter). `clamd`/`freshclam` в
|
||||||
|
Compose отсутствуют: KESL 12.4 standalone и broker устанавливаются на host.
|
||||||
4. **Selectel Secrets Manager** — заполнить все remote names из
|
4. **Selectel Secrets Manager** — заполнить все remote names из
|
||||||
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
|
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
|
||||||
`REDIS_SAFETY_ACL`, internal TLS PEM для `8443`). Отдельный IAM principal
|
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM для `8443`).
|
||||||
VM2 с read-only доступом только к этим именам.
|
Отдельный IAM principal VM2 с read-only доступом только к этим именам.
|
||||||
5. **S3 quarantine bucket** и SigNoz OTLP endpoint — значения в `.env`.
|
5. **S3 quarantine bucket** и SigNoz OTLP endpoint — значения в `.env`.
|
||||||
|
Текущий self-hosted SigNoz принимает private plaintext OTLP без auth, поэтому
|
||||||
|
не создавайте фиктивный auth-secret или обязательный непустой header.
|
||||||
6. **Internal TLS** — сертификат внутренней CA с SAN = private DNS VM2;
|
6. **Internal TLS** — сертификат внутренней CA с SAN = private DNS VM2;
|
||||||
PEM хранится в Secrets Manager, не в каталоге релиза.
|
PEM хранится в Secrets Manager, не в каталоге релиза.
|
||||||
|
|
||||||
@@ -301,6 +309,13 @@ DSN, token, password, access/secret key туда не записываются.
|
|||||||
создайте отдельный VM2 IAM principal с read-only доступом только к remote names
|
создайте отдельный VM2 IAM principal с read-only доступом только к remote names
|
||||||
из mapping.
|
из mapping.
|
||||||
|
|
||||||
|
Локальный Collector принимает traces, metrics и logs напрямую по OTLP; чтение
|
||||||
|
Docker JSON через `filelog` не используется. Prometheus receiver собирает только
|
||||||
|
метрики самого Collector, `redis-exporter` и `nginx-exporter`, а `hostmetrics` —
|
||||||
|
метрики VM через read-only `/hostfs`. Exporter-контейнеры имеют только `expose`
|
||||||
|
во внутренних сетях и не публикуют host ports. В nginx endpoint
|
||||||
|
`/stub_status` слушает только внутренний `8081`.
|
||||||
|
|
||||||
Зашифруйте пароль Selectel service user через systemd credentials, не помещая
|
Зашифруйте пароль Selectel service user через systemd credentials, не помещая
|
||||||
его в аргументы или history:
|
его в аргументы или history:
|
||||||
|
|
||||||
@@ -513,7 +528,7 @@ REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
|
|||||||
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
|
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
|
||||||
активировать старую версию нельзя. Alembic downgrade запрещён.
|
активировать старую версию нельзя. Alembic downgrade запрещён.
|
||||||
|
|
||||||
При обновлении ClamAV policy образ Message Safety должен содержать согласованные
|
При обновлении KESL policy образ Message Safety должен содержать согласованные
|
||||||
seed и schema: seed `max_signature_age_hours=240`, schema maximum `720`
|
seed и schema: seed `max_signature_age_hours=240`, schema maximum `720`
|
||||||
(30 дней). После обновления immutable image digest создайте новую config
|
(30 дней). После обновления immutable image digest создайте новую config
|
||||||
version; существующую active version не редактируйте и не активируйте повторно:
|
version; существующую active version не редактируйте и не активируйте повторно:
|
||||||
@@ -535,7 +550,7 @@ NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>'
|
|||||||
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
|
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
|
||||||
message-safety-api message-safety-worker
|
message-safety-api message-safety-worker
|
||||||
/usr/local/sbin/han-vm2-compose ps \
|
/usr/local/sbin/han-vm2-compose ps \
|
||||||
message-safety-api message-safety-worker clamd freshclam
|
message-safety-api message-safety-worker
|
||||||
unset NEXT_VERSION
|
unset NEXT_VERSION
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -575,8 +590,15 @@ open-file limit быть не должно. Ошибка отсутствующ
|
|||||||
Под `root` на VM2:
|
Под `root` на VM2:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
cd /opt/han-chat/services
|
||||||
|
# Сначала полностью выполните operator runbook:
|
||||||
|
# deployment/kesl/RUNBOOK.KESL.ru.md
|
||||||
|
systemctl enable --now han-kesl-scan-broker.socket
|
||||||
|
systemctl --no-pager status kesl han-kesl-scan-broker.socket
|
||||||
|
test -S /run/han-kesl/scan.sock
|
||||||
|
stat -c '%U:%G %a %n' /run/han-kesl/scan.sock
|
||||||
|
|
||||||
/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector
|
/usr/local/sbin/han-vm2-compose up -d redis-safety otel-collector
|
||||||
/usr/local/sbin/han-vm2-compose up -d freshclam clamd
|
|
||||||
/usr/local/sbin/han-vm2-compose up -d \
|
/usr/local/sbin/han-vm2-compose up -d \
|
||||||
message-safety-api message-safety-worker
|
message-safety-api message-safety-worker
|
||||||
/usr/local/sbin/han-vm2-compose up -d \
|
/usr/local/sbin/han-vm2-compose up -d \
|
||||||
@@ -585,6 +607,13 @@ open-file limit быть не должно. Ошибка отсутствующ
|
|||||||
/usr/local/sbin/han-vm2-compose ps
|
/usr/local/sbin/han-vm2-compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
|
До запуска Message Safety operator KESL runbook обязан подтвердить KESL 12.4
|
||||||
|
standalone, успешное ежечасное обновление, допустимый database date и canary.
|
||||||
|
Socket должен иметь `root:han-message-safety 0660` и не быть доступен посторонним
|
||||||
|
UID. Broker является custom
|
||||||
|
integration: неизвестный output/exit code `kesl-control --scan-file --action Inform`
|
||||||
|
считается scanner error, а не clean. Формат и throughput проверяются на target VM2.
|
||||||
|
|
||||||
`otel-collector` автоматически запускает одноразовый `otel-queue-init`. Он
|
`otel-collector` автоматически запускает одноразовый `otel-queue-init`. Он
|
||||||
выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`;
|
выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`;
|
||||||
сам Collector стартует только после этого.
|
сам Collector стартует только после этого.
|
||||||
@@ -879,7 +908,7 @@ journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service
|
|||||||
|
|
||||||
- container unhealthy/restart/OOM;
|
- container unhealthy/restart/OOM;
|
||||||
- срок TLS;
|
- срок TLS;
|
||||||
- возраст ClamAV signatures;
|
- KESL version/database date, результат ежечасного update и broker/socket status;
|
||||||
- OTEL queue/export errors;
|
- OTEL queue/export errors;
|
||||||
- disk/RAM;
|
- disk/RAM;
|
||||||
- активный MOCK mode;
|
- активный MOCK mode;
|
||||||
@@ -1094,8 +1123,9 @@ plan.
|
|||||||
|
|
||||||
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
|
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
|
||||||
контент.
|
контент.
|
||||||
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
|
- Устаревшая/недоступная база KESL или ошибка broker отключают только файловую
|
||||||
capability; ошибка сканирования никогда не превращается в allow.
|
capability; ошибка сканирования никогда не превращается в allow и ведёт к
|
||||||
|
retry/`503`.
|
||||||
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
|
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
|
||||||
истины.
|
истины.
|
||||||
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
|
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
|
||||||
@@ -1121,10 +1151,12 @@ han-message-safety-mode mock --text-free false --file-free false
|
|||||||
таймаута: держите high-severity alert активным до явного `standard`, затем
|
таймаута: держите high-severity alert активным до явного `standard`, затем
|
||||||
проверьте нормальные text/link/file capabilities и EICAR-canary.
|
проверьте нормальные text/link/file capabilities и EICAR-canary.
|
||||||
|
|
||||||
## Известные исключения по образам
|
## Host KESL и broker
|
||||||
|
|
||||||
Образы ClamAV могут потребовать корректировок UID/path после валидации
|
KESL 12.4 standalone и root-owned fail-closed broker не являются Compose
|
||||||
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
|
образами. Message Safety worker получает только Unix socket
|
||||||
задокументируйте минимальные writable пути для сигнатур/runtime и
|
`/run/han-kesl/scan.sock`; доступ к `kesl-control`, Docker socket и host root
|
||||||
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
|
ему не выдаётся. `scanner_engine=kesl`, а `signatures_version` вычисляется как
|
||||||
получает только `freshclam`; `clamd` — нет.
|
hash KESL version + database date. Установка, ежечасное обновление, права
|
||||||
|
socket, clean/EICAR/error/stale gates и rollback выполняются строго по
|
||||||
|
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md).
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
[Unit]
|
[Unit]
|
||||||
Description=HAN Processing VM2 root Compose stack
|
Description=HAN Processing VM2 root Compose stack
|
||||||
Requires=docker.service han-secrets-vm2.service
|
Requires=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket
|
||||||
After=docker.service han-secrets-vm2.service network-online.target
|
After=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket network-online.target
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Карта доказательств KESL на ВМ2
|
||||||
|
|
||||||
|
Заполняется оператором после `RUNBOOK.KESL.ru.md`. Не включать activation code,
|
||||||
|
secrets, ПД, S3 object key, file bytes, полный checksum или EICAR.
|
||||||
|
|
||||||
|
## 1. Изменение
|
||||||
|
|
||||||
|
- Change ID / окно:
|
||||||
|
- Оператор / approvers Security, Service, Operations:
|
||||||
|
- Hostname, Ubuntu, kernel, architecture:
|
||||||
|
- KESL package/version, SHA-256, источник:
|
||||||
|
- HAN release SHA и image digests:
|
||||||
|
- Коммерческая сборка не заявлена сертифицированной ФСТЭК: да / нет
|
||||||
|
- KSN decision и правовое основание:
|
||||||
|
|
||||||
|
## 2. Baseline и stop conditions
|
||||||
|
|
||||||
|
- VM2 containers healthy/running:
|
||||||
|
- Public/private smoke:
|
||||||
|
- CPU, available RAM, swap activity, disk/IO wait:
|
||||||
|
- Safety p95/p99, error rate, queue age:
|
||||||
|
- Redis latency/blocked clients:
|
||||||
|
- Restart/OOM, OTEL queue:
|
||||||
|
- Утверждённые пороги и rollback approver:
|
||||||
|
|
||||||
|
## 3. АВЗ.1
|
||||||
|
|
||||||
|
Норма: Приказ ФСТЭК №21, приложение АВЗ.1; п. 8.6 — обнаружение
|
||||||
|
вредоносных программ/информации и реагирование.
|
||||||
|
|
||||||
|
- [ ] `kesl.service` active, лицензия действительна.
|
||||||
|
- [ ] File Threat Protection task 1 = `Started`.
|
||||||
|
- [ ] `ActionOnThreat=DisinfectDeleteIfNotPossible`, `ScanArchived=No`.
|
||||||
|
- [ ] fanotify on-access обнаружил и обработал разрешённый EICAR.
|
||||||
|
- [ ] Событие detection/action присутствует в KESL events.
|
||||||
|
- [ ] Исключения ограничены фактическими hot-data mountpoint.
|
||||||
|
- [ ] Broker staging отсутствует в `ExcludedFromScanScope`.
|
||||||
|
- [ ] Broker подтверждает `scanned >= 1`, `skipped = 0`, `errors = 0`.
|
||||||
|
- [ ] Firewall, smoke и health после Block успешны.
|
||||||
|
- [ ] 24 часа без неприемлемой деградации/OOM/restart/5xx.
|
||||||
|
|
||||||
|
Артефакты/время/результат:
|
||||||
|
|
||||||
|
## 4. АВЗ.2
|
||||||
|
|
||||||
|
Норма: Приказ ФСТЭК №21, приложение АВЗ.2 — обновление базы признаков
|
||||||
|
вредоносных компьютерных программ.
|
||||||
|
|
||||||
|
- [ ] Update task 6 вручную завершилась успешно.
|
||||||
|
- [ ] Базы загружены, дата актуальна.
|
||||||
|
- [ ] Schedule = Hourly.
|
||||||
|
- [ ] Наблюдён последующий автоматический successful update.
|
||||||
|
- [ ] Alert на update failure / unloaded / age >240h / license failure.
|
||||||
|
- [ ] Назначен ежедневный контроль и owner.
|
||||||
|
|
||||||
|
Артефакты/время последнего automatic update:
|
||||||
|
|
||||||
|
## 5. Message Safety scan-broker
|
||||||
|
|
||||||
|
- [ ] Socket `root:han-message-safety:0660`, TCP listener отсутствует.
|
||||||
|
- [ ] Clean corpus → `clean`; Message Safety final `200 allow`.
|
||||||
|
- [ ] EICAR → `infected`; Message Safety sticky `403 deny`.
|
||||||
|
- [ ] KESL stopped/timeout/unknown output → retry/terminal `503`, не allow/deny.
|
||||||
|
- [ ] `scanner_engine=kesl`.
|
||||||
|
- [ ] `signatures_version` меняется при обновлении KESL databases.
|
||||||
|
- [ ] Старый cache не используется после смены signatures version.
|
||||||
|
- [ ] Logs/traces не содержат bytes, object key, checksum, filename или secrets.
|
||||||
|
- [ ] 5 slots / 2 files per second gate пройден.
|
||||||
|
|
||||||
|
Результаты corpus/load и ссылки на безопасные метрики:
|
||||||
|
|
||||||
|
## 6. РСБ и АНЗ.2
|
||||||
|
|
||||||
|
- [ ] Определены и защищены KESL detection/remediation/update/license events.
|
||||||
|
- [ ] Определены место/срок хранения и экспорт/регламент просмотра.
|
||||||
|
- [ ] Версия KESL и upgrade lifecycle контролируются.
|
||||||
|
- [ ] Kernel/Docker/KESL upgrade требует compatibility pilot + evidence delta.
|
||||||
|
|
||||||
|
## 7. Cutover и rollback
|
||||||
|
|
||||||
|
- [ ] До cutover KESL+ClamAV coexistence не нарушило resource gates.
|
||||||
|
- [ ] После cutover `clamd`/`freshclam` отсутствуют, старый egress закрыт.
|
||||||
|
- [ ] Предыдущая совместимая release/config пара зафиксирована.
|
||||||
|
- [ ] Desk check: application rollback возвращает ClamAV release.
|
||||||
|
- [ ] Desk check: минимальный host rollback — `kesl-control --stop-task 1`.
|
||||||
|
- [ ] Rollback не удаляет Docker volumes и не использует `down -v`.
|
||||||
|
|
||||||
|
## 8. Итог
|
||||||
|
|
||||||
|
- АВЗ.1: принято / не принято; ограничения:
|
||||||
|
- АВЗ.2: принято / не принято; ограничения:
|
||||||
|
- Message Safety KESL cutover: принято / не принято:
|
||||||
|
- Residual risk custom broker / parser / throughput:
|
||||||
|
- Operations / Security / Service owner, ФИО, подпись, дата:
|
||||||
|
- Следующий review:
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user