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_API_INBOX_PATH=/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
|
||||
# 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_CA_HOST_PATH=/etc/han/ca/vm2-internal-ca.pem
|
||||
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
|
||||
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
HOST_NAME=vm1-production
|
||||
OTEL_SERVICE_NAME_API=api-backend
|
||||
OTEL_SERVICE_NAME_SMS_API=sms-service
|
||||
OTEL_SERVICE_NAME_SMS_WORKER=sms-worker
|
||||
@@ -125,6 +127,8 @@ OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
|
||||
OTEL_REMOTE_TLS_INSECURE=false
|
||||
# SDK отправляет все spans локальному Collector; решение о хранении принимает tail_sampling.
|
||||
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=10000
|
||||
|
||||
|
||||
@@ -59,8 +59,9 @@ han-notification-draft-cleanup-worker
|
||||
Токены генерируются `openssl rand -hex 32`. `MESSAGE_SAFETY_SERVICE_TOKEN`
|
||||
сохраняется как caller secret API backend; S3 credentials сервису Safety не
|
||||
передаются. В production подключение PostgreSQL должно использовать TLS. Target
|
||||
`MESSAGE_SAFETY_URL=https://processing.internal:8443`, API prefix
|
||||
`/internal/safety/v2`; certificate проверяется по CA из
|
||||
задаётся через `MESSAGE_SAFETY_URL=https://<private-vm2-name>:8443`
|
||||
(`processing.internal` — только пример), API prefix `/internal/safety/v2`;
|
||||
certificate проверяется по CA из
|
||||
`MESSAGE_SAFETY_CA_HOST_PATH`, plaintext HTTP запрещён.
|
||||
|
||||
Smoke-сценарий `producer_test`: отправить `POST
|
||||
@@ -83,3 +84,18 @@ pytest
|
||||
зависимости read API. Remote Message Safety не выключает чтение/общую readiness:
|
||||
send endpoint отдельно проверяет требуемую capability и fail-closed возвращает
|
||||
`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.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.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")
|
||||
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and value_type != "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):
|
||||
raise ValueError(f"{key}: public must be a boolean")
|
||||
if key in OTP_SETTING_KEYS and raw["public"]:
|
||||
raise ValueError(f"{key}: OTP setting must not be public")
|
||||
if key == CHAT_MESSAGE_MAX_LENGTH_KEY and not raw["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")
|
||||
if description is not None and not isinstance(description, str):
|
||||
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_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
|
||||
|
||||
|
||||
@@ -68,6 +90,8 @@ def serialize_value(key: str, value_type: str, value: Any) -> str:
|
||||
raise ValueError(f"{key}: boolean value expected")
|
||||
return str(value).lower()
|
||||
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):
|
||||
raise ValueError(f"{key}: integer value expected")
|
||||
return str(value)
|
||||
|
||||
@@ -206,6 +206,7 @@ class SafetyTask(Base):
|
||||
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))
|
||||
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))
|
||||
processing_mode: Mapped[str | None] = mapped_column(String(16))
|
||||
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)
|
||||
external_chat_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True))
|
||||
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")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_attempt_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from typing import Any
|
||||
|
||||
REDACTED = "[REDACTED]"
|
||||
@@ -42,6 +42,6 @@ def sanitize_value(value: Any) -> Any:
|
||||
def redact_event(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
event_dict: MutableMapping[str, Any],
|
||||
) -> MutableMapping[str, Any]:
|
||||
return sanitize_value(event_dict)
|
||||
|
||||
@@ -63,6 +63,7 @@ from app.schemas import (
|
||||
MessageRequest,
|
||||
OpenLinesInbox,
|
||||
OtpSettingsResponse,
|
||||
PublicAppConfigResponse,
|
||||
SessionStartRequest,
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
@@ -88,11 +89,22 @@ from app.services import (
|
||||
start_session,
|
||||
)
|
||||
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")
|
||||
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,
|
||||
@@ -101,7 +113,10 @@ def configure_logging(level: str) -> None:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -134,7 +149,7 @@ async def refresh_jwks_cache(app: FastAPI) -> None:
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
telemetry = init_telemetry()
|
||||
configure_logging(settings.log_level)
|
||||
configure_logging(settings.log_level, telemetry)
|
||||
app.state.settings = settings
|
||||
app.state.db = Database(settings.database_url)
|
||||
app.state.http = httpx.AsyncClient()
|
||||
@@ -177,7 +192,7 @@ async def lifespan(app: FastAPI):
|
||||
telemetry.shutdown()
|
||||
|
||||
|
||||
EXPECTED_API_DB_REVISION = "0012_safety_v2_checkpoint"
|
||||
EXPECTED_API_DB_REVISION = "0013_delivery_trace_context"
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
@@ -192,6 +207,15 @@ app.include_router(notification_router)
|
||||
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:
|
||||
"""Trust forwarded client addresses only from configured reverse proxies."""
|
||||
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"])
|
||||
async def app_config(request: Request, response: Response, settings: SnapshotDep):
|
||||
@app.get(
|
||||
"/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(
|
||||
request,
|
||||
"ip",
|
||||
@@ -511,12 +544,15 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
|
||||
settings.limit("rate_limit.public_endpoints.per_ip"),
|
||||
fail_closed=False,
|
||||
)
|
||||
response.headers["Cache-Control"] = (
|
||||
headers = {"Cache-Control": (
|
||||
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
|
||||
return {
|
||||
body = {
|
||||
"auth": {
|
||||
"phone_enabled": settings.boolean("auth.phone.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")},
|
||||
"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"])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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):
|
||||
platform: Literal["ios", "android", "web"]
|
||||
app_version: str = Field(min_length=1, max_length=64)
|
||||
|
||||
@@ -38,6 +38,12 @@ from app.integrations import (
|
||||
SafetyClient,
|
||||
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.realtime import RealtimeFanout
|
||||
from app.schemas import (
|
||||
@@ -52,6 +58,7 @@ from app.schemas import (
|
||||
encode_cursor,
|
||||
)
|
||||
from app.settings import Settings
|
||||
from app.telemetry import current_traceparent
|
||||
|
||||
MESSAGE_SAFETY_REPLIES = {
|
||||
"text": (
|
||||
@@ -89,6 +96,7 @@ async def ensure_delivery_outbox(
|
||||
external_chat_id: uuid.UUID,
|
||||
payload_json: dict[str, Any],
|
||||
next_attempt_at: datetime,
|
||||
traceparent: str | None = None,
|
||||
) -> DeliveryOutbox:
|
||||
"""Create the per-message outbox row or return the concurrent winner."""
|
||||
statement = (
|
||||
@@ -98,6 +106,7 @@ async def ensure_delivery_outbox(
|
||||
message_id=message_id,
|
||||
external_chat_id=external_chat_id,
|
||||
payload_json=payload_json,
|
||||
traceparent=traceparent,
|
||||
next_attempt_at=next_attempt_at,
|
||||
)
|
||||
.on_conflict_do_nothing(index_elements=[DeliveryOutbox.message_id])
|
||||
@@ -117,6 +126,67 @@ async def ensure_delivery_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):
|
||||
def __init__(self, code: str, status: int, message: str, details: dict[str, Any] | None = None):
|
||||
self.code, self.status, self.message = code, status, message
|
||||
@@ -165,7 +235,7 @@ REQUIRED_SETTINGS = {
|
||||
"rate_limit.notification_upload.per_user",
|
||||
"rate_limit.notifications_public.per_ip",
|
||||
CHAT_MESSAGE_MAX_LENGTH_KEY,
|
||||
} | OTP_SETTING_KEYS
|
||||
} | OTP_SETTING_KEYS | MOBILE_UPDATE_SETTING_KEYS
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -225,15 +295,33 @@ async def load_settings(session: AsyncSession) -> SettingsSnapshot:
|
||||
invalid_metadata = sorted(
|
||||
row.setting_key
|
||||
for row in rows
|
||||
if row.setting_key in OTP_SETTING_KEYS
|
||||
and (row.value_type != "integer" or row.is_public)
|
||||
if (
|
||||
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:
|
||||
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_chat_settings(values)
|
||||
validate_mobile_update_settings(values)
|
||||
except ValueError as error:
|
||||
raise DomainError(
|
||||
"dependency_unavailable",
|
||||
@@ -954,6 +1042,7 @@ async def send_message(
|
||||
message_id=message.id,
|
||||
attachment_id=attachment.id if attachment else None,
|
||||
quarantine_object_key=attachment.quarantine_object_key if attachment else None,
|
||||
traceparent=current_traceparent(),
|
||||
status="polling",
|
||||
processing_mode=verdict["processing_mode"],
|
||||
config_version=verdict["config_version"],
|
||||
@@ -1032,38 +1121,23 @@ async def send_message(
|
||||
"bypassed" if verdict["processing_mode"] == "mock" else "clean"
|
||||
)
|
||||
message.safety_status = "allowed"
|
||||
profile = await load_active_client_profile(session, user.id)
|
||||
outbox = await ensure_delivery_outbox(
|
||||
session,
|
||||
message_id=message.id,
|
||||
external_chat_id=dialog_id,
|
||||
payload_json={
|
||||
"message_id": str(message.id),
|
||||
"external_chat_id": str(dialog_id),
|
||||
"occurred_at": message.occurred_at.isoformat(),
|
||||
"user": {"id": str(user.id), "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 []
|
||||
),
|
||||
},
|
||||
},
|
||||
payload_json=openlines_delivery_payload(
|
||||
message=message,
|
||||
dialog_id=dialog_id,
|
||||
user=user,
|
||||
profile=profile,
|
||||
attachment=attachment,
|
||||
),
|
||||
# Keep the row recoverable after a process crash, but do not let the
|
||||
# delivery worker race the synchronous first attempt.
|
||||
next_attempt_at=datetime.now(UTC)
|
||||
+ timedelta(seconds=settings.bitrix_local_app_http_timeout_sec + 5),
|
||||
traceparent=current_traceparent(),
|
||||
)
|
||||
await session.commit()
|
||||
await publish_message_status(fanout, message, settings)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
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.botocore import BotocoreInstrumentor
|
||||
@@ -13,7 +17,9 @@ from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
|
||||
from opentelemetry.instrumentation.httpx import HTTPXClientInstrumentor
|
||||
from opentelemetry.instrumentation.redis import RedisInstrumentor
|
||||
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.export import PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
@@ -27,8 +33,11 @@ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapProp
|
||||
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()
|
||||
|
||||
@@ -79,11 +88,29 @@ def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
|
||||
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,
|
||||
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()
|
||||
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||
RedisInstrumentor().instrument()
|
||||
BotocoreInstrumentor().instrument()
|
||||
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
|
||||
_runtime = TelemetryRuntime(
|
||||
tracer_provider,
|
||||
meter_provider,
|
||||
logger_provider,
|
||||
logging_handler,
|
||||
)
|
||||
return _runtime
|
||||
|
||||
|
||||
@@ -97,8 +124,8 @@ def instrument_fastapi(app: FastAPI) -> None:
|
||||
def add_trace_context(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
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")
|
||||
@@ -109,3 +136,17 @@ def add_trace_context(
|
||||
def current_trace_id() -> str | None:
|
||||
context = trace.get_current_span().get_span_context()
|
||||
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 logging
|
||||
import uuid
|
||||
from collections.abc import AsyncIterator
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
import redis.asyncio as redis
|
||||
import structlog
|
||||
from opentelemetry import trace
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.db import (
|
||||
@@ -25,20 +27,58 @@ from app.integrations import (
|
||||
SafetyClient,
|
||||
fresh_openlines_payload,
|
||||
)
|
||||
from app.logging_security import redact_event
|
||||
from app.notification_models import ClientUploadDraft
|
||||
from app.notification_service import expire_notifications
|
||||
from app.realtime import RealtimeFanout
|
||||
from app.services import (
|
||||
ensure_delivery_outbox,
|
||||
load_active_client_profile,
|
||||
load_settings,
|
||||
openlines_delivery_payload,
|
||||
publish_dialog_status,
|
||||
publish_message_status,
|
||||
)
|
||||
from app.settings import Settings, get_settings
|
||||
from app.telemetry import TelemetryRuntime, add_trace_context, init_telemetry, origin_links
|
||||
|
||||
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
|
||||
async def worker_http_clients(
|
||||
settings: Settings,
|
||||
@@ -59,6 +99,7 @@ async def delivery_once(
|
||||
worker_id: str,
|
||||
batch_size: int = 20,
|
||||
) -> int:
|
||||
tracer = trace.get_tracer("han.api.delivery-worker")
|
||||
async with db.sessions() as session:
|
||||
rows = (
|
||||
(
|
||||
@@ -86,33 +127,43 @@ async def delivery_once(
|
||||
row = await session.get(DeliveryOutbox, row_id, with_for_update=True)
|
||||
if row is None:
|
||||
continue
|
||||
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)
|
||||
with (
|
||||
tracer.start_as_current_span(
|
||||
"delivery.process",
|
||||
links=origin_links(row.traceparent),
|
||||
),
|
||||
structlog.contextvars.bound_contextvars(
|
||||
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:
|
||||
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:
|
||||
await publish_message_status(fanout, message, settings)
|
||||
if dialog:
|
||||
await publish_dialog_status(fanout, dialog)
|
||||
await publish_message_status(fanout, message, settings)
|
||||
if dialog:
|
||||
await publish_dialog_status(fanout, dialog)
|
||||
return len(ids)
|
||||
|
||||
|
||||
@@ -125,6 +176,7 @@ async def safety_once(
|
||||
worker_id: str,
|
||||
batch_size: int = 20,
|
||||
) -> int:
|
||||
tracer = trace.get_tracer("han.api.safety-recovery-worker")
|
||||
async with db.sessions() as session:
|
||||
rows = (
|
||||
(
|
||||
@@ -153,7 +205,14 @@ async def safety_once(
|
||||
continue
|
||||
message = None
|
||||
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)
|
||||
attachment = (
|
||||
await session.get(MessageAttachment, task.attachment_id)
|
||||
@@ -189,38 +248,20 @@ async def safety_once(
|
||||
else None
|
||||
)
|
||||
if dialog and user:
|
||||
profile = await load_active_client_profile(session, user.id)
|
||||
await ensure_delivery_outbox(
|
||||
session,
|
||||
message_id=message.id,
|
||||
external_chat_id=dialog.id,
|
||||
payload_json={
|
||||
"message_id": str(message.id),
|
||||
"external_chat_id": str(dialog.id),
|
||||
"occurred_at": message.occurred_at.isoformat(),
|
||||
"user": {
|
||||
"id": str(user.id),
|
||||
"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 []
|
||||
),
|
||||
},
|
||||
},
|
||||
payload_json=openlines_delivery_payload(
|
||||
message=message,
|
||||
dialog_id=dialog.id,
|
||||
user=user,
|
||||
profile=profile,
|
||||
attachment=attachment,
|
||||
),
|
||||
next_attempt_at=datetime.now(UTC),
|
||||
traceparent=task.traceparent,
|
||||
)
|
||||
elif verdict["_status"] == 403 and message:
|
||||
message.safety_processing_mode = verdict["processing_mode"]
|
||||
@@ -382,20 +423,20 @@ async def notification_draft_cleanup_loop() -> None:
|
||||
|
||||
|
||||
def delivery_main() -> None:
|
||||
asyncio.run(loop("delivery"))
|
||||
run_worker("delivery-worker", lambda: loop("delivery"))
|
||||
|
||||
|
||||
def safety_main() -> None:
|
||||
asyncio.run(loop("safety"))
|
||||
run_worker("safety-recovery-worker", lambda: loop("safety"))
|
||||
|
||||
|
||||
def cleanup_main() -> None:
|
||||
asyncio.run(loop("cleanup"))
|
||||
run_worker("cleanup-worker", lambda: loop("cleanup"))
|
||||
|
||||
|
||||
def notification_expire_main() -> None:
|
||||
asyncio.run(notification_expire_loop())
|
||||
run_worker("notification-expire-worker", notification_expire_loop)
|
||||
|
||||
|
||||
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:
|
||||
get:
|
||||
operationId: getPublicAppConfig
|
||||
parameters:
|
||||
- {name: If-None-Match, in: header, schema: {type: string}}
|
||||
responses:
|
||||
"200":
|
||||
description: Public application configuration
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [messages]
|
||||
properties:
|
||||
messages:
|
||||
type: object
|
||||
required: [max_text_length]
|
||||
properties:
|
||||
max_text_length: {type: integer, minimum: 1, maximum: 10000}
|
||||
schema: {$ref: "#/components/schemas/PublicAppConfigResponse"}
|
||||
"304": {description: Cached configuration is still current}
|
||||
/api/v1/public/content:
|
||||
get:
|
||||
operationId: getPublicContent
|
||||
@@ -405,6 +400,105 @@ components:
|
||||
description: Catalog action is not allowed
|
||||
content: {application/json: {schema: {$ref: "#/components/schemas/ErrorEnvelope"}}}
|
||||
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:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -304,7 +304,11 @@ async def test_openlines_payload_gets_fresh_download_url_without_storage_fields(
|
||||
"message_id": str(uuid.uuid4()),
|
||||
"external_chat_id": str(uuid.uuid4()),
|
||||
"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": {
|
||||
"content_kind": "file",
|
||||
"text": "",
|
||||
|
||||
@@ -13,6 +13,7 @@ from pydantic import SecretStr
|
||||
from app.main import (
|
||||
EXPECTED_API_DB_REVISION,
|
||||
app,
|
||||
app_config,
|
||||
otp_settings,
|
||||
refresh_jwks_cache,
|
||||
websocket_token,
|
||||
@@ -152,13 +153,116 @@ def test_committed_openapi_server_does_not_double_api_prefix() -> None:
|
||||
assert committed["servers"] == [{"url": "/"}]
|
||||
|
||||
|
||||
def test_public_config_contract_exposes_message_length() -> None:
|
||||
committed = yaml.safe_load(Path("openapi.yaml").read_text(encoding="utf-8"))
|
||||
response = committed["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
|
||||
messages = response["content"]["application/json"]["schema"]["properties"]["messages"]
|
||||
def test_public_config_contract_is_strict_and_exposes_mobile_update() -> None:
|
||||
generated = app.openapi()
|
||||
response = generated["paths"]["/api/v1/public/app-config"]["get"]["responses"]["200"]
|
||||
schema_ref = response["content"]["application/json"]["schema"]["$ref"]
|
||||
schema = generated["components"]["schemas"][schema_ref.rsplit("/", 1)[-1]]
|
||||
|
||||
assert messages["required"] == ["max_text_length"]
|
||||
assert messages["properties"]["max_text_length"]["maximum"] == 10_000
|
||||
assert schema["additionalProperties"] is False
|
||||
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:
|
||||
|
||||
@@ -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.sms_order_timeout_ms"] == "3000"
|
||||
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:
|
||||
@@ -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"):
|
||||
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 (
|
||||
MESSAGE_SAFETY_REPLIES,
|
||||
ensure_delivery_outbox,
|
||||
openlines_user_payload,
|
||||
safety_reply_message,
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
existing = object()
|
||||
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["span_id"]) == 16
|
||||
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
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
import uvicorn
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from opentelemetry import trace
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from sqlalchemy import func, or_, select, text
|
||||
@@ -37,9 +39,19 @@ from app.models import (
|
||||
PortalInstallation,
|
||||
now,
|
||||
)
|
||||
from app.logging_security import redact_event
|
||||
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(
|
||||
r"^\[b\][^\r\n\[]+:\[/b\]\s*(?:\[br\]\s*)?",
|
||||
@@ -57,6 +69,7 @@ CONNECTOR_ICON_DATA_URI = "data:image/svg+xml," + quote(
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
app_env: str = "production-like"
|
||||
log_level: str = "INFO"
|
||||
bitrix_database_url: str
|
||||
bitrix_client_id: 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)
|
||||
|
||||
|
||||
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:
|
||||
def __init__(self, encoded_key: str, version: str) -> None:
|
||||
try:
|
||||
@@ -114,6 +149,7 @@ class UserDto(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
id: uuid.UUID
|
||||
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):
|
||||
@@ -308,8 +344,8 @@ def normalize_event(payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||
normalized_file["_bitrix_file_id"] = str(file_id)
|
||||
if not normalized_file["download_url"] and not file_id:
|
||||
logger.warning(
|
||||
"inbound file has no supported download reference; keys=%s",
|
||||
sorted(str(key) for key in item),
|
||||
"inbound_file.unsupported_reference",
|
||||
keys=sorted(str(key) for key in item),
|
||||
)
|
||||
files.append(normalized_file)
|
||||
if not text_value and not files:
|
||||
@@ -497,6 +533,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
telemetry = init_telemetry()
|
||||
configure_logging(cfg.log_level, telemetry)
|
||||
engine = create_postgres_engine(
|
||||
cfg.bitrix_database_url,
|
||||
pool_size=5,
|
||||
@@ -526,6 +564,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
await task
|
||||
await app.state.http.aclose()
|
||||
await engine.dispose()
|
||||
if telemetry:
|
||||
telemetry.shutdown()
|
||||
|
||||
app = FastAPI(
|
||||
title="HAN Bitrix24 Local App",
|
||||
@@ -539,9 +579,13 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
@app.middleware("http")
|
||||
async def request_context(request: Request, call_next):
|
||||
request.state.request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request.state.request_id
|
||||
return response
|
||||
with structlog.contextvars.bound_contextvars(
|
||||
request_id=request.state.request_id,
|
||||
**{"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)
|
||||
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:
|
||||
portal = await active_portal(session)
|
||||
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)
|
||||
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"}
|
||||
if revision != EXPECTED_BITRIX_DB_REVISION:
|
||||
return JSONResponse(
|
||||
{"status": "not_ready", "reason": "migration_required"},
|
||||
status_code=503,
|
||||
)
|
||||
return JSONResponse(
|
||||
{"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)
|
||||
if normalized is None:
|
||||
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(
|
||||
{"status": "accepted" if created else "duplicate"},
|
||||
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",
|
||||
),
|
||||
)
|
||||
body = dto.model_dump(mode="json")
|
||||
body = dto.model_dump(mode="json", exclude_none=True)
|
||||
fp = canonical_fingerprint(body)
|
||||
async with request.app.state.sessions() as session:
|
||||
row = await session.scalar(
|
||||
@@ -730,6 +792,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
external_chat_id=dto.external_chat_id,
|
||||
request_fingerprint=fp,
|
||||
payload_json=body,
|
||||
request_id=request.state.request_id,
|
||||
traceparent=current_traceparent(),
|
||||
status="sending",
|
||||
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:
|
||||
logger.error(
|
||||
"outbound delivery failed",
|
||||
extra={
|
||||
"request_id": request.state.request_id,
|
||||
"message_id": str(dto.message_id),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
"outbound.delivery_failed",
|
||||
request_id=request.state.request_id,
|
||||
message_id=str(dto.message_id),
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
async with request.app.state.sessions() as session:
|
||||
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)
|
||||
return {"status": "completed" if all(result.values()) else "partial", "steps": result}
|
||||
|
||||
instrument_fastapi(app)
|
||||
return app
|
||||
|
||||
|
||||
@@ -959,7 +1022,12 @@ async def uninstall_payload(app: FastAPI, payload: dict[str, Any]) -> None:
|
||||
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(
|
||||
json.dumps(normalized, sort_keys=True, separators=(",", ":")).encode()
|
||||
).hexdigest()
|
||||
@@ -970,6 +1038,8 @@ async def save_inbox(app: FastAPI, normalized: dict[str, Any]) -> bool:
|
||||
bitrix_message_id=normalized["bitrix_message_id"],
|
||||
payload_fingerprint=fingerprint,
|
||||
normalized_json=normalized,
|
||||
request_id=request_id,
|
||||
traceparent=traceparent,
|
||||
)
|
||||
async with app.state.sessions() as session:
|
||||
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]:
|
||||
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
|
||||
def imconnector_send_fields(connector: str, line: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
message = payload["message"]
|
||||
fields: dict[str, Any] = {
|
||||
"CONNECTOR": app.state.settings.bitrix_connector_id,
|
||||
"LINE": app.state.settings.bitrix_open_line_id,
|
||||
"CONNECTOR": connector,
|
||||
"LINE": line,
|
||||
"MESSAGES[0][user][id]": payload["user"]["id"],
|
||||
"MESSAGES[0][user][name]": payload["user"]["display_name"],
|
||||
"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][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]
|
||||
fields["MESSAGES[0][message][files][0][url]"] = file["download_url"]
|
||||
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)
|
||||
chat_id, session_id, bitrix_message_id = extract_delivery(result)
|
||||
response = {
|
||||
@@ -1155,8 +1237,9 @@ async def worker_loop(app: FastAPI, kind: str) -> None:
|
||||
await process_setup(app)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"worker iteration failed",
|
||||
extra={"worker_kind": kind, "error_type": type(exc).__name__},
|
||||
"worker.iteration_failed",
|
||||
worker_kind=kind,
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
try:
|
||||
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"])
|
||||
if not row:
|
||||
return
|
||||
try:
|
||||
payload = json.loads(json.dumps(row.normalized_json))
|
||||
await resolve_inbound_file_urls(app, payload)
|
||||
response = await app.state.http.post(
|
||||
app.state.settings.bitrix_api_forward_url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {app.state.settings.bitrix_api_forward_token}",
|
||||
"X-Request-ID": str(uuid.uuid4()),
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
duplicate = response.status_code in {200, 204}
|
||||
if response.status_code != 201 and not duplicate:
|
||||
response.raise_for_status()
|
||||
async with app.state.sessions() as session:
|
||||
current = await session.get(InboxEvent, row.id, with_for_update=True)
|
||||
current.status = "ack_pending"
|
||||
current.api_ack_status = "duplicate" if duplicate else "created"
|
||||
current.lease_until = None
|
||||
session.add(
|
||||
DeliveryAckOutbox(
|
||||
inbox_event_id=current.id,
|
||||
payload_json={
|
||||
"external_chat_id": str(current.external_chat_id),
|
||||
"bitrix_message_id": current.bitrix_message_id,
|
||||
},
|
||||
)
|
||||
tracer = trace.get_tracer("han.bitrix.inbox")
|
||||
with (
|
||||
tracer.start_as_current_span(
|
||||
"bitrix.inbox.forward",
|
||||
links=origin_links(row.traceparent),
|
||||
),
|
||||
structlog.contextvars.bound_contextvars(
|
||||
request_id=row.request_id,
|
||||
inbox_id=str(row.id),
|
||||
**{"service.name": "bitrix-local-app"},
|
||||
),
|
||||
):
|
||||
try:
|
||||
payload = json.loads(json.dumps(row.normalized_json))
|
||||
await resolve_inbound_file_urls(app, payload)
|
||||
response = await app.state.http.post(
|
||||
app.state.settings.bitrix_api_forward_url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Authorization": f"Bearer {app.state.settings.bitrix_api_forward_token}",
|
||||
"X-Request-ID": row.request_id or str(uuid.uuid4()),
|
||||
},
|
||||
follow_redirects=False,
|
||||
)
|
||||
await session.commit()
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"inbound forward failed",
|
||||
extra={
|
||||
"inbox_id": str(row.id),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
await mark_retry(app, InboxEvent, row.id, "api_forward_failed")
|
||||
duplicate = response.status_code in {200, 204}
|
||||
if response.status_code != 201 and not duplicate:
|
||||
response.raise_for_status()
|
||||
async with app.state.sessions() as session:
|
||||
current = await session.get(InboxEvent, row.id, with_for_update=True)
|
||||
current.status = "ack_pending"
|
||||
current.api_ack_status = "duplicate" if duplicate else "created"
|
||||
current.lease_until = None
|
||||
session.add(
|
||||
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:
|
||||
@@ -1239,27 +1334,41 @@ async def process_ack(app: FastAPI) -> None:
|
||||
portal = await active_portal(session)
|
||||
if not row or not portal:
|
||||
return
|
||||
try:
|
||||
await app.state.bitrix.call(
|
||||
portal,
|
||||
"imconnector.send.status.delivery",
|
||||
{
|
||||
"CONNECTOR": app.state.settings.bitrix_connector_id,
|
||||
"LINE": app.state.settings.bitrix_open_line_id,
|
||||
"MESSAGES[0][im][chat_id]": row.payload_json["external_chat_id"],
|
||||
"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")
|
||||
tracer = trace.get_tracer("han.bitrix.ack")
|
||||
with (
|
||||
tracer.start_as_current_span(
|
||||
"bitrix.delivery_ack.process",
|
||||
links=origin_links(row.traceparent),
|
||||
),
|
||||
structlog.contextvars.bound_contextvars(
|
||||
request_id=row.request_id,
|
||||
ack_outbox_id=str(row.id),
|
||||
**{"service.name": "bitrix-local-app"},
|
||||
),
|
||||
):
|
||||
try:
|
||||
await app.state.bitrix.call(
|
||||
portal,
|
||||
"imconnector.send.status.delivery",
|
||||
{
|
||||
"CONNECTOR": app.state.settings.bitrix_connector_id,
|
||||
"LINE": app.state.settings.bitrix_open_line_id,
|
||||
"MESSAGES[0][im][chat_id]": row.payload_json["external_chat_id"],
|
||||
"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:
|
||||
@@ -1272,20 +1381,30 @@ async def process_outbound(app: FastAPI) -> None:
|
||||
)
|
||||
if not row:
|
||||
return
|
||||
try:
|
||||
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",
|
||||
extra={
|
||||
"outbound_id": str(row.id),
|
||||
"error_type": type(exc).__name__,
|
||||
},
|
||||
)
|
||||
await mark_retry(app, OutboundMessage, row.id, "bitrix_delivery_failed")
|
||||
tracer = trace.get_tracer("han.bitrix.outbound")
|
||||
with (
|
||||
tracer.start_as_current_span(
|
||||
"bitrix.outbound.process",
|
||||
links=origin_links(row.traceparent),
|
||||
),
|
||||
structlog.contextvars.bound_contextvars(
|
||||
request_id=row.request_id,
|
||||
outbound_id=str(row.id),
|
||||
**{"service.name": "bitrix-local-app"},
|
||||
),
|
||||
):
|
||||
try:
|
||||
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:
|
||||
|
||||
@@ -133,6 +133,8 @@ class InboxEvent(Common, Base):
|
||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
payload_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
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")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
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))
|
||||
request_fingerprint: Mapped[str] = mapped_column(String(64))
|
||||
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")
|
||||
bitrix_message_id: Mapped[str | None] = mapped_column(String(255))
|
||||
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
|
||||
)
|
||||
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")
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
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:
|
||||
id: {type: string, format: uuid}
|
||||
display_name: {type: string, maxLength: 255}
|
||||
phone: {type: string, minLength: 1, maxLength: 32}
|
||||
message:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
|
||||
@@ -8,9 +8,16 @@ dependencies = [
|
||||
"cryptography>=45,<46",
|
||||
"fastapi>=0.116,<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",
|
||||
"python-multipart>=0.0.20,<1",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
"structlog>=25,<26",
|
||||
"uvicorn[standard]>=0.35,<1",
|
||||
]
|
||||
|
||||
|
||||
@@ -19,16 +19,29 @@ os.environ.setdefault(
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.logging_security import REDACTED, sanitize_value
|
||||
from app.main import (
|
||||
BitrixClient,
|
||||
TokenCipher,
|
||||
canonical_fingerprint,
|
||||
imconnector_send_fields,
|
||||
normalize_event,
|
||||
resolve_inbound_file_urls,
|
||||
retry_delay,
|
||||
safely_retryable,
|
||||
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():
|
||||
@@ -278,3 +291,23 @@ async def test_bitrix_call_refreshes_and_retries_once_after_401():
|
||||
assert result == {"ok": True}
|
||||
assert auth_values == ["old-access", "new-access"]
|
||||
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. Упорядоченный первый запуск
|
||||
|
||||
До 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:
|
||||
|
||||
```sh
|
||||
@@ -363,6 +378,8 @@ docker kill --signal HUP "$NGINX_ID" >/dev/null
|
||||
unset NGINX_ID
|
||||
systemctl enable han-secrets@production.service 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.
|
||||
@@ -376,6 +393,9 @@ stack unit. Обновление active/exited oneshot всегда требуе
|
||||
```sh
|
||||
curl -sS -o /dev/null -w '%{http_code}\n' http://<PUBLIC_HOST>/
|
||||
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 -sS -o /dev/null -w '%{http_code}\n' \
|
||||
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
|
||||
```
|
||||
|
||||
Ожидается `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,
|
||||
rate limits, WS reconciliation, S3 quarantine/promote/deny и Safety v2
|
||||
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
|
||||
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:
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
@@ -452,10 +491,13 @@ systemctl daemon-reload
|
||||
systemctl restart han-secrets@production.service
|
||||
/opt/han-chat/current/backend/deployment/preflight.sh
|
||||
systemctl restart han-stack@production.service
|
||||
systemctl restart han-host-otel-collector@production.service
|
||||
```
|
||||
|
||||
Повторите 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
|
||||
window; Redis восстанавливается пустым и прогревается из PG.
|
||||
|
||||
|
||||
@@ -47,5 +47,23 @@ settings:
|
||||
notification.expire_job.run_at: {type: string, value: "00:01", public: false}
|
||||
notification.upload_draft.ttl_days: {type: integer, value: 7, public: false}
|
||||
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.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 "$ENV_FILE" ] || fail ".env 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"
|
||||
[ "$(/usr/bin/stat -c '%U:%G' /opt/han-chat/current)" = root:root ] ||
|
||||
fail "active release link must be root:root"
|
||||
@@ -59,6 +60,8 @@ if [ -d "$ROOT" ]; then
|
||||
$ROOT/docker-compose.yml
|
||||
$ROOT/deployment/preflight.sh
|
||||
$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/secrets/han-compose
|
||||
$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_HTTP_PORT)" = 80 ] || fail "nginx must publish host port 80"
|
||||
[ "$(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 ] ||
|
||||
fail "nginx certificate must use staged /run/tls/fullchain.pem"
|
||||
[ "$(value NGINX_TLS_CERTIFICATE_KEY)" = /run/tls/privkey.pem ] ||
|
||||
@@ -278,6 +285,39 @@ if [ -f "$MANIFEST" ]; then
|
||||
IFS=$old_ifs
|
||||
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
|
||||
"$ROOT/scripts/validate-env" "$ENV_FILE" --runtime-manifest "$MANIFEST" ||
|
||||
fail "config/runtime validator rejected the production inputs"
|
||||
|
||||
@@ -1,58 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Создаёт 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$//' deployment/scripts/seed-personal-notifications-test.sh
|
||||
# chmod +x deployment/scripts/seed-personal-notifications-test.sh
|
||||
# ./deployment/scripts/seed-personal-notifications-test.sh
|
||||
# sed -i 's/\r$//' /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
|
||||
# chmod +x /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
|
||||
# /opt/han-chat/current/backend/deployment/scripts/seed-personal-notifications-test.sh
|
||||
#
|
||||
# Токен берётся из .env (NOTIFICATIONS_TOKEN_PRODUCER_TEST) — тот же, что у api-backend.
|
||||
# При старте api-backend синхронизирует hash токена в notification_sources.
|
||||
# Токен: runtime-файл NOTIFICATIONS_TOKEN_PRODUCER_TEST
|
||||
# (/run/han-chat/secrets/…, тот же, что у api-backend). Hash в notification_sources
|
||||
# синхронизируется при старте api-backend.
|
||||
|
||||
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
|
||||
echo "Не найден $ENV_FILE. Запускайте из каталога backend." >&2
|
||||
if [[ ! -f "$CONFIG_FILE" ]]; then
|
||||
echo "Не найден конфиг ${CONFIG_FILE} (ожидается /etc/han/vm1.env)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
env_value() {
|
||||
python3 - "$ENV_FILE" "$1" <<'PY'
|
||||
import sys
|
||||
from pathlib import Path
|
||||
if [[ "${HAN_SECRETS_ACTIVE:-0}" != "1" ]]; then
|
||||
[[ -x "$SECRETS_LAUNCHER" ]] || {
|
||||
echo "Secret launcher is required: $SECRETS_LAUNCHER" >&2
|
||||
exit 66
|
||||
}
|
||||
exec "$SECRETS_LAUNCHER" run --config "$CONFIG_FILE" -- "$0" "$@"
|
||||
fi
|
||||
|
||||
path, wanted = sys.argv[1:]
|
||||
for raw in Path(path).read_text(encoding="utf-8").splitlines():
|
||||
line = raw.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
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
|
||||
compose() {
|
||||
if [[ -x /usr/local/sbin/han-vm1-compose ]]; then
|
||||
/usr/local/sbin/han-vm1-compose "$@"
|
||||
else
|
||||
docker compose --env-file "$CONFIG_FILE" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
trim_token() {
|
||||
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:-}")"
|
||||
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
|
||||
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
|
||||
TOKEN="$(trim_token "$TOKEN")"
|
||||
fi
|
||||
@@ -65,7 +85,7 @@ if [[ -z "${TOKEN}" || -z "${USER_ID}" ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Токен: ${#TOKEN} символов (первые 8: ${TOKEN:0:8}…)"
|
||||
echo "Токен: ${#TOKEN} символов (значение не печатается)."
|
||||
|
||||
PAYLOAD_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$PAYLOAD_DIR"' EXIT
|
||||
@@ -103,22 +123,48 @@ create_notification() {
|
||||
fi
|
||||
echo
|
||||
echo "========== ${label} =========="
|
||||
docker run --rm \
|
||||
--network han-chat-backend \
|
||||
-v "${payload_file}:/payload.json:ro" \
|
||||
curlimages/curl:latest \
|
||||
-sS -i \
|
||||
-X POST \
|
||||
'http://api-backend:8000/internal/notifications/v1/notifications' \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H 'Content-Type: application/json; charset=utf-8' \
|
||||
--data-binary @/payload.json
|
||||
compose exec -T \
|
||||
-e "NOTIFICATIONS_PRODUCER_TOKEN=${TOKEN}" \
|
||||
api-backend python3 -c "
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
payload = sys.stdin.buffer.read()
|
||||
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() {
|
||||
echo >&2
|
||||
echo "========== PREP: загрузка тестовых документов в S3 для docs_ready ==========" >&2
|
||||
docker compose --env-file "$ENV_FILE" exec -T \
|
||||
compose exec -T \
|
||||
-e "USER_ID=${USER_ID}" \
|
||||
-e "RUN_ID=${RUN_ID}" \
|
||||
api-backend python3 - <<'PY'
|
||||
@@ -443,6 +489,7 @@ echo
|
||||
echo "========== Готово =========="
|
||||
echo "External ID prefix: ${RUN_ID}-*"
|
||||
echo
|
||||
echo "Если видите 401: проверьте NOTIFICATIONS_TOKEN_PRODUCER_TEST в .env и перезапустите api-backend."
|
||||
echo " grep NOTIFICATIONS_TOKEN_PRODUCER_TEST .env"
|
||||
echo " docker compose --env-file .env up -d --force-recreate api-backend"
|
||||
echo "Если видите 401: проверьте runtime-секрет NOTIFICATIONS_TOKEN_PRODUCER_TEST"
|
||||
echo "и hash в han_app.notification_sources, затем пересоздайте 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}"
|
||||
RESET_UFW="${RESET_UFW:-true}"
|
||||
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() {
|
||||
@@ -120,6 +122,13 @@ configure_time() {
|
||||
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() {
|
||||
local user=$1 source=$2 target="/home/${1}/.ssh/authorized_keys"
|
||||
[[ -f "$source" && ! -L "$source" ]] || die "Не найден обычный key file ${source}"
|
||||
@@ -422,7 +431,7 @@ install_deploy_sudoers() {
|
||||
step "Exact sudoers для deploy"
|
||||
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_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
|
||||
EOF
|
||||
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 "${source_dir}/han-compose" ]] || die "han-compose не 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
|
||||
[[ "$(getent group "$tls_group" | cut -d: -f3)" == "$tls_gid" ]] \
|
||||
|| die "han-nginx-tls имеет неожиданный GID"
|
||||
@@ -472,6 +490,27 @@ install_release_helpers_if_possible() {
|
||||
install -m 0644 -o root -g root \
|
||||
"${deployment}/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 -m 0755 -o root -g root \
|
||||
"${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
|
||||
systemctl daemon-reload
|
||||
systemctl enable certbot.timer
|
||||
systemctl enable han-host-otel-collector@production.service
|
||||
log "Helpers установлены; application units не запущены"
|
||||
}
|
||||
|
||||
@@ -539,6 +579,7 @@ main() {
|
||||
check_os
|
||||
update_system
|
||||
configure_time
|
||||
configure_journald
|
||||
create_host_roles
|
||||
configure_account_passwords
|
||||
configure_layout
|
||||
|
||||
@@ -16,11 +16,38 @@ compose() { docker compose --env-file "$CONFIG_FILE" "$@"; }
|
||||
|
||||
NETWORK="${OBSERVABILITY_NETWORK:-han-chat-observability}"
|
||||
COLLECTOR_SERVICE="${COLLECTOR_SERVICE:-otel-collector}"
|
||||
TELEMETRYGEN_IMAGE="${TELEMETRYGEN_IMAGE:-}"
|
||||
errors=0
|
||||
|
||||
ok() { printf 'OK %s\n' "$*"; }
|
||||
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)"
|
||||
if [[ -n "$collector_id" ]] &&
|
||||
[[ "$(docker inspect --format '{{.State.Status}}' "$collector_id")" == running ]]; then
|
||||
@@ -43,12 +70,16 @@ PY
|
||||
fi
|
||||
done
|
||||
|
||||
docker run --rm --network "$NETWORK" \
|
||||
ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen:latest \
|
||||
if [[ ! "$TELEMETRYGEN_IMAGE" =~ @sha256:[a-f0-9]{64}$ ]]; then
|
||||
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 \
|
||||
--service han-chat-e2e-canary --traces 100 --rate 20 >/dev/null \
|
||||
&& ok "100 canary traces submitted" \
|
||||
|| fail "telemetrygen failed"
|
||||
--service han-chat-e2e-canary --traces 100 --rate 20 >/dev/null; then
|
||||
ok "100 canary traces submitted"
|
||||
else
|
||||
fail "telemetrygen failed"
|
||||
fi
|
||||
|
||||
bad_logs="$(
|
||||
compose logs --since=10m "$COLLECTOR_SERVICE" 2>&1 |
|
||||
@@ -72,4 +103,12 @@ cat <<'EOF'
|
||||
service.namespace = han-chat
|
||||
Затем выполните synthetic API request и проверьте общий trace между
|
||||
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
|
||||
|
||||
@@ -63,7 +63,7 @@ export default function ProfileScreen() {
|
||||
<Feather name="user" size={40} color={colors.primaryForeground} />
|
||||
</View>
|
||||
<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>
|
||||
|
||||
{(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
|
||||
image: ${API_BACKEND_IMAGE:?API_BACKEND_IMAGE must be pinned by digest}
|
||||
environment:
|
||||
environment: &api-environment
|
||||
<<: *api-secret-environment
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
@@ -167,6 +167,8 @@ services:
|
||||
KC_HTTP_RELATIVE_PATH: /auth
|
||||
KC_HEALTH_ENABLED: "true"
|
||||
KC_METRICS_ENABLED: "true"
|
||||
KC_LOG_CONSOLE_OUTPUT: json
|
||||
KC_LOG_LEVEL: ${KEYCLOAK_LOG_LEVEL:-info}
|
||||
KC_HOSTNAME: ${KEYCLOAK_PUBLIC_URL}
|
||||
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL:?PUBLIC_WEB_URL is required for realm import}
|
||||
KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN}
|
||||
@@ -185,6 +187,8 @@ services:
|
||||
- keycloak_settings_bridge_token
|
||||
- keycloak_sms_service_token
|
||||
command: ["start", "--optimized", "--import-realm"]
|
||||
labels:
|
||||
com.han.service.name: keycloak
|
||||
expose: ["8080", "9000"]
|
||||
volumes:
|
||||
- type: bind
|
||||
@@ -209,7 +213,10 @@ services:
|
||||
core: {soft: 0, hard: 0}
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
tag: keycloak
|
||||
|
||||
sms-service:
|
||||
<<: *sms-runtime
|
||||
@@ -269,6 +276,9 @@ services:
|
||||
delivery-worker:
|
||||
<<: *api-runtime
|
||||
command: ["han-delivery-worker"]
|
||||
environment:
|
||||
<<: *api-environment
|
||||
OTEL_SERVICE_NAME: delivery-worker
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
bitrix-local-app: {condition: service_healthy}
|
||||
@@ -283,6 +293,9 @@ services:
|
||||
safety-recovery-worker:
|
||||
<<: *api-runtime
|
||||
command: ["han-safety-worker"]
|
||||
environment:
|
||||
<<: *api-environment
|
||||
OTEL_SERVICE_NAME: safety-recovery-worker
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
healthcheck:
|
||||
@@ -296,6 +309,9 @@ services:
|
||||
cleanup-worker:
|
||||
<<: *api-runtime
|
||||
command: ["han-cleanup-worker"]
|
||||
environment:
|
||||
<<: *api-environment
|
||||
OTEL_SERVICE_NAME: cleanup-worker
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
healthcheck:
|
||||
@@ -309,6 +325,9 @@ services:
|
||||
notification-expire-worker:
|
||||
<<: *api-runtime
|
||||
command: ["han-notification-expire-worker"]
|
||||
environment:
|
||||
<<: *api-environment
|
||||
OTEL_SERVICE_NAME: notification-expire-worker
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
healthcheck:
|
||||
@@ -322,6 +341,9 @@ services:
|
||||
notification-draft-cleanup-worker:
|
||||
<<: *api-runtime
|
||||
command: ["han-notification-draft-cleanup-worker"]
|
||||
environment:
|
||||
<<: *api-environment
|
||||
OTEL_SERVICE_NAME: notification-draft-cleanup-worker
|
||||
depends_on:
|
||||
api-backend: {condition: service_healthy}
|
||||
healthcheck:
|
||||
@@ -346,6 +368,10 @@ services:
|
||||
BITRIX_API_FORWARD_TOKEN_FILE: /run/secrets/bitrix_api_forward_token
|
||||
BITRIX_TOKEN_ENCRYPTION_KEY_FILE: /run/secrets/bitrix_token_encryption_key
|
||||
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_CONNECTOR_ID: ${BITRIX_CONNECTOR_ID:-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`.
|
||||
|
||||
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
|
||||
allow-listed by the initial realm import.
|
||||
wildcards. `${PUBLIC_WEB_URL}/auth/callback`, `${PUBLIC_WEB_URL}/mobile/oidc/callback`
|
||||
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`.
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
"fullScopeAllowed": false,
|
||||
"redirectUris": [
|
||||
"${PUBLIC_WEB_URL}/auth/callback",
|
||||
"${PUBLIC_WEB_URL}/mobile/oidc/callback",
|
||||
"han-chat://auth/callback"
|
||||
],
|
||||
"webOrigins": [
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ class RealmContractTest {
|
||||
assertTrue(realm.contains("\"optionalClientScopes\": [\"offline_access\"]"));
|
||||
assertTrue(realm.contains("\"han-chat://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}\""));
|
||||
assertFalse(realm.contains("chat.han0107.ru"));
|
||||
}
|
||||
|
||||
+2
@@ -17,6 +17,8 @@ otpResendCountdown=Отправить повторно через
|
||||
otpResend=Отправить код снова
|
||||
authBack=Назад
|
||||
verifyOtp=Подтвердить
|
||||
otpVerifying=Проверяем...
|
||||
otpSubmitUnavailable=Не удалось завершить вход. Если код уже принят, закройте окно и откройте приложение.
|
||||
mockMode=Тестовый режим отправки кода
|
||||
phoneInvalid=Проверьте формат номера телефона.
|
||||
otpInvalid=Код неверен, истёк или уже использован.
|
||||
|
||||
@@ -61,12 +61,18 @@
|
||||
</button>
|
||||
</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")}
|
||||
</button>
|
||||
</form>
|
||||
</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>
|
||||
<script src="https://smartcaptcha.cloud.yandex.ru/captcha.js?render=onload&onload=hanCaptchaOnload"
|
||||
async defer></script>
|
||||
|
||||
@@ -56,16 +56,28 @@
|
||||
|
||||
function initOtpForm() {
|
||||
var fields = Array.prototype.slice.call(document.querySelectorAll(".han-otp-digit"));
|
||||
var form = document.getElementById("kc-otp-form");
|
||||
var hidden = document.getElementById("otp");
|
||||
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() {
|
||||
fields.forEach(function (field) {
|
||||
field.classList.toggle("han-filled", Boolean(field.value));
|
||||
});
|
||||
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) {
|
||||
@@ -106,6 +118,22 @@
|
||||
});
|
||||
|
||||
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 countdownValue = countdown && countdown.querySelector("strong");
|
||||
@@ -126,9 +154,17 @@
|
||||
}
|
||||
updateCountdown();
|
||||
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;
|
||||
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 executing = false;
|
||||
var pendingSubmitter = null;
|
||||
var executionTimeout = null;
|
||||
|
||||
function showError() {
|
||||
if (executionTimeout) window.clearTimeout(executionTimeout);
|
||||
executionTimeout = null;
|
||||
executing = false;
|
||||
if (pendingSubmitter) pendingSubmitter.disabled = false;
|
||||
pendingSubmitter = null;
|
||||
@@ -162,6 +201,8 @@
|
||||
invisible: true,
|
||||
hl: "ru",
|
||||
callback: function (token) {
|
||||
if (executionTimeout) window.clearTimeout(executionTimeout);
|
||||
executionTimeout = null;
|
||||
if (!token || !pendingSubmitter) {
|
||||
showError();
|
||||
return;
|
||||
@@ -209,6 +250,7 @@
|
||||
if (pendingSubmitter) pendingSubmitter.disabled = true;
|
||||
tokenInput.value = "";
|
||||
try {
|
||||
executionTimeout = window.setTimeout(showError, 12000);
|
||||
window.smartCaptcha.execute(widgetId);
|
||||
} catch (_error) {
|
||||
showError();
|
||||
|
||||
@@ -7,9 +7,10 @@ RUN apt-get update \
|
||||
COPY nginx.conf.template /etc/nginx/templates-src/nginx.conf.template
|
||||
COPY templates /etc/nginx/templates-src/sites
|
||||
COPY snippets /etc/nginx/snippets
|
||||
COPY static /etc/nginx/static
|
||||
COPY scripts/entrypoint.sh /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 \
|
||||
&& 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"]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
services:
|
||||
nginx:
|
||||
image: ${NGINX_IMAGE:?NGINX_IMAGE must be pinned by digest}
|
||||
labels:
|
||||
com.han.service.name: nginx
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
PUBLIC_HOST: ${PUBLIC_HOST}
|
||||
@@ -64,4 +66,7 @@ services:
|
||||
nofile: {soft: 65536, hard: 65536}
|
||||
logging:
|
||||
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_bypass $http_authorization;
|
||||
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;
|
||||
}
|
||||
location = /api/v1/public/content {
|
||||
@@ -139,6 +141,23 @@ server {
|
||||
add_header Cache-Control "no-cache";
|
||||
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/ {
|
||||
include /etc/nginx/snippets/proxy-keycloak.conf;
|
||||
proxy_pass http://keycloak_upstream;
|
||||
|
||||
@@ -21,6 +21,7 @@ services:
|
||||
environment:
|
||||
APP_ENV: ${APP_ENV:-production-like}
|
||||
RELEASE_VERSION: ${RELEASE_VERSION:-unknown}
|
||||
HOST_NAME: ${HOST_NAME:?HOST_NAME is required}
|
||||
OTEL_REMOTE_ENDPOINT: ${OTEL_REMOTE_ENDPOINT}
|
||||
OTEL_REMOTE_TLS_INSECURE: ${OTEL_REMOTE_TLS_INSECURE:-false}
|
||||
expose: ["4317", "4318", "13133", "8888"]
|
||||
|
||||
@@ -54,6 +54,7 @@ processors:
|
||||
- {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}
|
||||
attributes/redact:
|
||||
actions:
|
||||
- {key: http.request.header.authorization, action: delete}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
services:
|
||||
redis:
|
||||
image: ${REDIS_IMAGE:?REDIS_IMAGE must be pinned by digest}
|
||||
labels:
|
||||
com.han.service.name: redis
|
||||
environment:
|
||||
REDIS_MAXMEMORY: ${REDIS_MAXMEMORY:-384mb}
|
||||
REDIS_EVICTION_POLICY: ${REDIS_EVICTION_POLICY:-volatile-lru}
|
||||
@@ -32,7 +34,10 @@ services:
|
||||
nofile: {soft: 65536, hard: 65536}
|
||||
logging:
|
||||
driver: json-file
|
||||
options: {max-size: "50m", max-file: "5"}
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "5"
|
||||
tag: redis
|
||||
|
||||
secrets:
|
||||
redis_api_password:
|
||||
|
||||
@@ -13,12 +13,13 @@ from urllib.parse import urlparse
|
||||
REQUIRED_CONFIG = {
|
||||
"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",
|
||||
"PUBLIC_HOST", "PUBLIC_WEB_URL",
|
||||
"PUBLIC_HOST", "PUBLIC_WEB_URL", "HOST_NAME",
|
||||
"PUBLIC_API_URL", "PUBLIC_AUTH_URL", "KEYCLOAK_PUBLIC_URL",
|
||||
"KEYCLOAK_INTERNAL_URL", "KEYCLOAK_REALM", "KEYCLOAK_SMS_SERVICE_URL",
|
||||
"IDGTL_SMS_BASE_URL", "IDGTL_SMS_CALLBACK_PUBLIC_URL",
|
||||
"MESSAGE_SAFETY_URL", "MESSAGE_SAFETY_EXTRA_HOST",
|
||||
"MESSAGE_SAFETY_CA_HOST_PATH", "MESSAGE_SAFETY_API_PREFIX",
|
||||
"OTEL_REMOTE_ENDPOINT", "OTEL_REMOTE_TLS_INSECURE", "TELEMETRYGEN_IMAGE",
|
||||
}
|
||||
REQUIRED_RUNTIME = {
|
||||
"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")
|
||||
if not env.get("KEYCLOAK_INTERNAL_URL", "").rstrip("/").endswith("/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(
|
||||
"MESSAGE_SAFETY_URL: ожидается remote TLS endpoint "
|
||||
"https://processing.internal:8443"
|
||||
"https://<private VM2 hostname>:8443, не local/stub service"
|
||||
)
|
||||
try:
|
||||
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)
|
||||
if extra_host != safety_host or not address.is_private:
|
||||
raise ValueError
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, MutableMapping
|
||||
from typing import Any
|
||||
|
||||
REDACTED = "[REDACTED]"
|
||||
@@ -42,6 +42,6 @@ def sanitize_value(value: Any) -> Any:
|
||||
def redact_event(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
event_dict: MutableMapping[str, Any],
|
||||
) -> MutableMapping[str, Any]:
|
||||
return sanitize_value(event_dict)
|
||||
|
||||
@@ -32,13 +32,18 @@ from app.service import (
|
||||
read_message,
|
||||
)
|
||||
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()
|
||||
|
||||
|
||||
def configure_logging(level: str) -> None:
|
||||
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,
|
||||
@@ -47,7 +52,10 @@ def configure_logging(level: str) -> None:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -55,7 +63,7 @@ def configure_logging(level: str) -> None:
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
telemetry = init_telemetry()
|
||||
configure_logging(settings.log_level)
|
||||
configure_logging(settings.log_level, telemetry)
|
||||
app.state.settings = settings
|
||||
app.state.db = Database(settings.database_url)
|
||||
try:
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
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 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
|
||||
@@ -25,8 +31,11 @@ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapProp
|
||||
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()
|
||||
|
||||
@@ -53,7 +62,9 @@ def init_telemetry(service_name: str | None = None) -> TelemetryRuntime | None:
|
||||
if not endpoint:
|
||||
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://")
|
||||
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])
|
||||
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()
|
||||
SQLAlchemyInstrumentor().instrument(enable_commenter=False)
|
||||
_runtime = TelemetryRuntime(tracer_provider, meter_provider)
|
||||
_runtime = TelemetryRuntime(
|
||||
tracer_provider,
|
||||
meter_provider,
|
||||
logger_provider,
|
||||
logging_handler,
|
||||
)
|
||||
return _runtime
|
||||
|
||||
|
||||
@@ -93,8 +122,8 @@ def instrument_fastapi(app: FastAPI) -> None:
|
||||
def add_trace_context(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
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")
|
||||
|
||||
@@ -10,6 +10,7 @@ from datetime import UTC, datetime, timedelta
|
||||
import httpx
|
||||
import structlog
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.propagate import extract
|
||||
from prometheus_client import start_http_server
|
||||
from sqlalchemy import and_, func, or_, select, update
|
||||
|
||||
@@ -26,14 +27,27 @@ from app.metrics import (
|
||||
from app.provider import IdgtlClient, IdgtlConfig
|
||||
from app.service import RuntimeSettings, load_runtime_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()
|
||||
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")
|
||||
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,
|
||||
@@ -42,7 +56,10 @@ def configure_logging(level: str) -> None:
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@@ -177,7 +194,7 @@ async def update_queue_metrics(db: Database) -> None:
|
||||
async def worker_loop(stop: asyncio.Event) -> None:
|
||||
settings = get_settings()
|
||||
telemetry = init_telemetry("sms-worker")
|
||||
configure_logging(settings.log_level)
|
||||
configure_logging(settings.log_level, telemetry)
|
||||
structlog.contextvars.bind_contextvars(**{"service.name": "sms-worker"})
|
||||
metrics_server, metrics_thread = start_http_server(
|
||||
settings.metrics_port,
|
||||
@@ -200,8 +217,17 @@ async def worker_loop(stop: asyncio.Event) -> None:
|
||||
await update_queue_metrics(db)
|
||||
await asyncio.wait_for(stop.wait(), timeout=runtime.poll_interval_ms / 1000)
|
||||
continue
|
||||
process_span = tracer.start_span("sms.process", start_time=claim_started_ns)
|
||||
with trace.use_span(process_span, end_on_exit=True):
|
||||
process_span = tracer.start_span(
|
||||
"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.set_attribute("sms.claimed", True)
|
||||
claim_span.end(end_time=claim_finished_ns)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
from app import telemetry
|
||||
from app.worker import origin_links
|
||||
|
||||
|
||||
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 len(result["trace_id"]) == 32
|
||||
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")
|
||||
self.assertNotIn("route=request.url.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")
|
||||
for span_name in ("sms.claim", "sms.process", "sms.provider", "sms.save_result"):
|
||||
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:
|
||||
cli = ROOT / "api-backend/app/cli"
|
||||
@@ -442,7 +496,7 @@ class InfrastructureConfigTests(unittest.TestCase):
|
||||
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:
|
||||
migration = (
|
||||
|
||||
@@ -152,6 +152,28 @@ class SecretHygieneTests(unittest.TestCase):
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
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__":
|
||||
unittest.main()
|
||||
|
||||
@@ -960,7 +960,7 @@ Inbound operator file:
|
||||
- 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;
|
||||
- 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;
|
||||
- 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.
|
||||
|
||||
В 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,6 +19,7 @@ Nginx ВМ1 — публичная точка входа приложения: f
|
||||
|---|---|---|
|
||||
| `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS |
|
||||
| `/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 |
|
||||
| `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS |
|
||||
| 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",
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"display_name": "Клиент HAN"
|
||||
"display_name": "Новый клиент HAN",
|
||||
"phone": "+79990000000"
|
||||
},
|
||||
"message": {
|
||||
"content_kind": "text",
|
||||
@@ -128,7 +129,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
||||
"message_id": "uuid",
|
||||
"external_chat_id": "uuid",
|
||||
"occurred_at": "2026-07-10T09:00:00Z",
|
||||
"user": {"id": "uuid", "display_name": "Клиент HAN"},
|
||||
"user": {"id": "uuid", "display_name": "Новый клиент HAN", "phone": "+79990000000"},
|
||||
"message": {
|
||||
"content_kind": "file",
|
||||
"text": "",
|
||||
@@ -148,7 +149,8 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
||||
- `external_chat_id` строго UUID и равен App `dialog_id`;
|
||||
- `text` xor один file; unknown fields запрещены;
|
||||
- signed URL не сохраняется в обычные логи и редактируется в durable payload по истечении необходимости;
|
||||
- PII профиля не требуется; телефон/email не передаются;
|
||||
- `display_name` — `ClientProfile.full_name`, если пусто — `Новый клиент HAN`;
|
||||
- телефон клиента передаётся в `user.phone` и мапится в `MESSAGES[0][user][phone]` для CRM-лида; email не передаётся;
|
||||
- fingerprint строится по стабильным полям без signed query;
|
||||
- тот же key/fingerprint возвращает прежний результат;
|
||||
- тот же key с иным fingerprint → `409 idempotency_key_reused`.
|
||||
@@ -198,7 +200,7 @@ Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, зна
|
||||
|
||||
1. Аутентифицировать caller и зарезервировать `outbound_messages` по `message_id`.
|
||||
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`.
|
||||
5. Извлечь `CHAT_ID`, session `ID`, Bitrix message id из допускаемых вариантов ответа.
|
||||
6. В одной транзакции upsert `dialog_sessions`, записать result, status `delivered`.
|
||||
|
||||
@@ -92,6 +92,7 @@ han-chat-frontend
|
||||
|
||||
```text
|
||||
https://tohin.ru/auth/callback
|
||||
https://tohin.ru/mobile/oidc/callback
|
||||
han-chat://auth/callback
|
||||
<Expo native scheme/callback, exact value после сборки>
|
||||
```
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
Документ задаёт, **что агент ВМ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.
|
||||
|
||||
@@ -22,8 +22,14 @@
|
||||
| Keycloak | `keycloak` |
|
||||
| `sms-service` | `sms-service` |
|
||||
| `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` |
|
||||
| local Collector | `otel-collector` |
|
||||
| host telemetry agent | `otel-host-collector` |
|
||||
|
||||
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 не используется;
|
||||
- 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.
|
||||
|
||||
## 4. Instrumentation
|
||||
|
||||
@@ -49,7 +49,9 @@ webhooks собственного host. Compose, IAM principal и secret bundle
|
||||
performance, egress и rollback gates своего runbook.
|
||||
2. ВМ1 использует
|
||||
`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.
|
||||
4. Service token paired, private route/SG разрешают `8443` только от ВМ1/ops.
|
||||
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 { AuthConsent } from './pages/AuthConsent';
|
||||
import { Root } from './pages/Root';
|
||||
import { UpdateProvider } from './contexts/UpdateContext';
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<UpdateProvider>
|
||||
<MemoryRouter initialEntries={['/']}>
|
||||
<Routes>
|
||||
<Route path="/" element={<Root />}>
|
||||
@@ -30,5 +32,6 @@ export default function App() {
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</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 { ChatInput } from '../components/ChatInput';
|
||||
import { QuickActions } from '../components/QuickActions';
|
||||
import { useUpdate } from '../contexts/UpdateContext';
|
||||
|
||||
export function Home() {
|
||||
const { showUpdate } = useUpdate();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Компактный логотип HAN */}
|
||||
<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">
|
||||
<Notifications />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Outlet, useLocation } from 'react-router';
|
||||
import { Header } from '../components/Header';
|
||||
import { UpdateModal } from '../components/UpdateModal';
|
||||
|
||||
export function Root() {
|
||||
const location = useLocation();
|
||||
@@ -14,6 +15,8 @@ export function Root() {
|
||||
|
||||
<Outlet />
|
||||
</div>
|
||||
|
||||
<UpdateModal />
|
||||
</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.
|
||||
APP_ENV=production-like
|
||||
RELEASE_VERSION=<immutable-release>
|
||||
LOG_LEVEL=INFO
|
||||
SECRETS_SOURCE=selectel
|
||||
MESSAGE_SAFETY_IMAGE=<registry>/han-message-safety@sha256:<digest>
|
||||
BITRIX_SYNC_IMAGE=<registry>/han-bitrix-sync@sha256:<digest>
|
||||
NGINX_IMAGE=nginxinc/nginx-unprivileged@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>
|
||||
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_PRIVATE_BIND_ADDRESS=<vm2-private-ip>
|
||||
@@ -17,8 +19,7 @@ MESSAGE_SAFETY_HOST=0.0.0.0
|
||||
MESSAGE_SAFETY_PORT=8080
|
||||
MESSAGE_SAFETY_WORKER_CONCURRENCY=5
|
||||
MESSAGE_SAFETY_DNS_RESOLVERS=<vpc-resolver-ip>
|
||||
MESSAGE_SAFETY_CLAMAV_HOST=clamd
|
||||
MESSAGE_SAFETY_CLAMAV_PORT=3310
|
||||
MESSAGE_SAFETY_ANTIVIRUS_SOCKET=/run/han-kesl/scan.sock
|
||||
MESSAGE_SAFETY_ARTIFACTS_DIR=/app/app/artifacts
|
||||
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_MODE=disabled
|
||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_<digits>
|
||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_<digits>
|
||||
BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD=UF_CRM_<digits>
|
||||
BITRIX_SYNC_CONTACT_USER_ID_FIELD=UF_CRM_<latin_letters_or_digits>
|
||||
BITRIX_SYNC_CONTACT_REGISTERED_FIELD=UF_CRM_<latin_letters_or_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_MEMBER_ID=<approved-member-id>
|
||||
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_REGISTERED_FIELD=UF_CRM_101
|
||||
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_HTTP_TIMEOUT_SEC=10
|
||||
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
|
||||
USER 10001:10001
|
||||
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
|
||||
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
|
||||
@@ -54,6 +70,15 @@ gates: локальный managed PostgreSQL не поднимается Compose
|
||||
4. Заполнить и активировать валидную `business_alerts` settings version:
|
||||
entity/category/stage/field IDs и responsible party. Placeholder `null`
|
||||
запрещает 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 администратора
|
||||
запрещён.
|
||||
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_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}$")
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ class Settings(BaseSettings):
|
||||
contact_user_id_field: str | None = None
|
||||
contact_registered_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 = ""
|
||||
|
||||
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_registered_field": self.contact_registered_field,
|
||||
"contact_citizenship_field": self.contact_citizenship_field,
|
||||
"contact_source": self.contact_source,
|
||||
}
|
||||
missing = [name for name, value in required.items() if not value]
|
||||
if missing:
|
||||
@@ -107,9 +109,13 @@ class Settings(BaseSettings):
|
||||
"contact_citizenship_field",
|
||||
):
|
||||
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)):
|
||||
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())
|
||||
public = urlsplit(str(self.public_base_url))
|
||||
|
||||
@@ -5,10 +5,14 @@ import ssl
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from enum import StrEnum
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
from urllib.parse import urljoin, urlsplit
|
||||
|
||||
import httpx
|
||||
from opentelemetry.trace import SpanKind
|
||||
|
||||
from app.telemetry import record_crm, record_retry, safe_span
|
||||
|
||||
|
||||
class CrmOutcome(StrEnum):
|
||||
@@ -49,6 +53,28 @@ class CrmClient:
|
||||
await self._client.aclose()
|
||||
|
||||
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:
|
||||
raise ValueError("unapproved CRM method")
|
||||
url = urljoin(self._base_url, method + ".json")
|
||||
|
||||
@@ -5,7 +5,8 @@ import uuid
|
||||
from dataclasses import dataclass, field
|
||||
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.crm import CrmClient, CrmOutcome, CrmResult
|
||||
@@ -14,10 +15,38 @@ from app.domain import (
|
||||
choose_newest,
|
||||
full_jitter_delay,
|
||||
parse_crm_datetime,
|
||||
safe_hash,
|
||||
select_email,
|
||||
validate_phone,
|
||||
)
|
||||
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):
|
||||
@@ -59,6 +88,7 @@ class WorkflowEngine:
|
||||
await self._manual(workflow_id, exc.code, task.user_id)
|
||||
await self.repository.complete_task(task, workflow_id)
|
||||
except RetryableWorkflow as exc:
|
||||
record_retry("task")
|
||||
delay = exc.retry_after or full_jitter_delay(
|
||||
task.attempt_count + 1,
|
||||
self.settings.retry_base_seconds,
|
||||
@@ -178,6 +208,14 @@ class WorkflowEngine:
|
||||
alert_code: str | None = None
|
||||
if same_user:
|
||||
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:
|
||||
selected_id = await self._create_contact(workflow_id, profile)
|
||||
else:
|
||||
@@ -193,15 +231,27 @@ class WorkflowEngine:
|
||||
assert selected is not None
|
||||
all_ids = [item.b24_id for item in candidates]
|
||||
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"
|
||||
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:
|
||||
selected_id = selected.b24_id
|
||||
await self._write_identity(workflow_id, selected_id, profile.user_id, active=True)
|
||||
if len(candidates) > 1:
|
||||
alert_code = "duplicate_contacts"
|
||||
if alert_code:
|
||||
await self._alert(workflow_id, profile.user_id, alert_code, all_ids)
|
||||
await self._alert(
|
||||
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)
|
||||
|
||||
async def _create_contact(self, workflow_id: uuid.UUID, profile: Profile) -> str:
|
||||
@@ -452,14 +502,7 @@ class WorkflowEngine:
|
||||
command_id = uuid.uuid4()
|
||||
async with self.repository.transaction() as connection:
|
||||
await connection.execute(
|
||||
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())
|
||||
"""
|
||||
),
|
||||
INSERT_CRM_COMMAND,
|
||||
{
|
||||
"id": command_id,
|
||||
"workflow_id": workflow_id,
|
||||
@@ -473,22 +516,15 @@ class WorkflowEngine:
|
||||
)
|
||||
if limiter_delay <= 0:
|
||||
break
|
||||
record_limiter(limiter_delay)
|
||||
await asyncio.sleep(limiter_delay)
|
||||
async with self._in_flight:
|
||||
result = await self.crm.call(method, params, mutating=mutating)
|
||||
status = result.outcome.value
|
||||
record_transition("command", status)
|
||||
async with self.repository.transaction() as connection:
|
||||
await connection.execute(
|
||||
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
|
||||
"""
|
||||
),
|
||||
UPDATE_CRM_COMMAND,
|
||||
{
|
||||
"id": command_id,
|
||||
"status": status,
|
||||
@@ -546,31 +582,145 @@ class WorkflowEngine:
|
||||
)
|
||||
|
||||
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:
|
||||
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:
|
||||
await connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO bitrix_sync.business_alerts
|
||||
(id,fingerprint,alert_type,severity,app_user_id,candidate_external_ids,
|
||||
workflow_id,status,occurrence_count,first_occurred_at,last_occurred_at,
|
||||
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()
|
||||
UPDATE bitrix_sync.business_alerts
|
||||
SET remote_item_id=:remote_item_id,remote_stage_id=:stage_id,updated_at=now()
|
||||
WHERE id=:id
|
||||
"""
|
||||
),
|
||||
{
|
||||
"fingerprint": fingerprint,
|
||||
"type": alert_type,
|
||||
"user_id": user_id,
|
||||
"candidates": candidates,
|
||||
"workflow_id": workflow_id,
|
||||
"id": alert["id"],
|
||||
"remote_item_id": str(remote_item_id),
|
||||
"stage_id": str(stage_new),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -588,6 +738,7 @@ class WorkflowEngine:
|
||||
)
|
||||
|
||||
async def _technical_failure(self, workflow_id: uuid.UUID, code: str) -> None:
|
||||
record_dead_letter("crm_command")
|
||||
async with self.repository.transaction() as connection:
|
||||
await connection.execute(
|
||||
text(
|
||||
|
||||
@@ -12,20 +12,38 @@ from sqlalchemy import text
|
||||
from app.config import Settings, load_settings
|
||||
from app.repository import Repository
|
||||
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
|
||||
async def lifespan(app: FastAPI):
|
||||
settings = load_settings()
|
||||
app.state.settings = settings
|
||||
app.state.repository = (
|
||||
Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||
if settings.enabled and settings.database_url
|
||||
else None
|
||||
)
|
||||
yield
|
||||
if app.state.repository:
|
||||
await app.state.repository.close()
|
||||
init_telemetry("bitrix-sync-api")
|
||||
app.state.repository = None
|
||||
try:
|
||||
settings = load_settings()
|
||||
app.state.settings = settings
|
||||
app.state.repository = (
|
||||
Repository(settings.database_url.get_secret_value(), settings.db_pool_size)
|
||||
if settings.enabled and settings.database_url
|
||||
else None
|
||||
)
|
||||
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(
|
||||
@@ -35,6 +53,7 @@ app = FastAPI(
|
||||
redoc_url=None,
|
||||
lifespan=lifespan,
|
||||
)
|
||||
instrument_fastapi(app)
|
||||
|
||||
|
||||
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:
|
||||
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:
|
||||
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(
|
||||
receiver,
|
||||
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,
|
||||
)
|
||||
except PermissionError as exc:
|
||||
record_webhook(receiver, "rejected")
|
||||
raise HTTPException(status_code=403, detail="forbidden") from exc
|
||||
except WebhookValidationError as exc:
|
||||
record_webhook(receiver, "rejected")
|
||||
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(
|
||||
event.receiver_type, event.event_type, event.entity_id, event.source_ip
|
||||
)
|
||||
record_webhook(receiver, "accepted")
|
||||
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
|
||||
port=8080,
|
||||
proxy_headers=False,
|
||||
access_log=False,
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from time import monotonic
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
@@ -9,6 +10,13 @@ from sqlalchemy import text
|
||||
from app.config import load_settings
|
||||
from app.crm import CrmClient, CrmOutcome
|
||||
from app.repository import Repository
|
||||
from app.telemetry import (
|
||||
init_telemetry,
|
||||
log_event,
|
||||
record_reconciliation,
|
||||
safe_span,
|
||||
shutdown_telemetry,
|
||||
)
|
||||
|
||||
|
||||
class IncrementalReconciler:
|
||||
@@ -45,13 +53,13 @@ class IncrementalReconciler:
|
||||
started_at = datetime.now(UTC)
|
||||
since = (cursor or datetime(1970, 1, 1, tzinfo=UTC)) - timedelta(seconds=overlap_seconds)
|
||||
start = 0
|
||||
scanned: list[str] = []
|
||||
scanned: list[tuple[str, str]] = []
|
||||
while True:
|
||||
result = await self.crm.call(
|
||||
"crm.item.list",
|
||||
{
|
||||
"entityTypeId": 3,
|
||||
"select": ["id"],
|
||||
"select": ["id", "updatedTime"],
|
||||
"filter": {
|
||||
">=updatedTime": since.replace(tzinfo=None).isoformat(timespec="seconds"),
|
||||
"opened": 1,
|
||||
@@ -63,10 +71,15 @@ class IncrementalReconciler:
|
||||
)
|
||||
if result.outcome != CrmOutcome.SUCCEEDED:
|
||||
raise RuntimeError(result.error_code or "reconciliation_failed")
|
||||
payload: dict[str, Any] = result.result or {}
|
||||
items = payload.get("items", payload if isinstance(payload, list) else [])
|
||||
scanned.extend(str(item["id"]) for item in items if "id" in item)
|
||||
next_start = payload.get("next")
|
||||
payload: Any = result.result or {}
|
||||
items = payload.get("items", []) if isinstance(payload, dict) else payload
|
||||
if not isinstance(items, list):
|
||||
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:
|
||||
break
|
||||
start = int(next_start)
|
||||
@@ -89,50 +102,77 @@ class IncrementalReconciler:
|
||||
)
|
||||
return len(scanned)
|
||||
|
||||
async def _enqueue_changed(self, external_ids: list[str]) -> None:
|
||||
if not external_ids:
|
||||
async def _enqueue_changed(self, changed_contacts: list[tuple[str, str]]) -> None:
|
||||
if not changed_contacts:
|
||||
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:
|
||||
await connection.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO bitrix_sync.webhook_inbox
|
||||
(id,receiver_type,event_type,external_entity_id,status,coalesced_count,
|
||||
received_at,last_received_at)
|
||||
SELECT gen_random_uuid(),'contact','contact.reconciliation',id,'received',1,
|
||||
now(),now()
|
||||
FROM unnest(CAST(:ids AS text[])) id
|
||||
(id,receiver_type,event_type,event_id,external_entity_id,status,
|
||||
coalesced_count,received_at,last_received_at)
|
||||
SELECT gen_random_uuid(),'contact','contact.reconciliation',candidate.event_id,
|
||||
candidate.external_id,'received',1,now(),now()
|
||||
FROM unnest(CAST(:ids AS text[]),CAST(:event_ids AS text[]))
|
||||
AS candidate(external_id,event_id)
|
||||
WHERE NOT EXISTS (
|
||||
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')
|
||||
)
|
||||
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:
|
||||
settings = load_settings()
|
||||
if not settings.enabled:
|
||||
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)
|
||||
)
|
||||
init_telemetry("bitrix-sync-reconciliation")
|
||||
repository: Repository | None = None
|
||||
crm: CrmClient | None = None
|
||||
started = monotonic()
|
||||
outcome = "error"
|
||||
count = 0
|
||||
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:
|
||||
await crm.close()
|
||||
await repository.close()
|
||||
record_reconciliation(outcome, count, started)
|
||||
if crm is not None:
|
||||
await crm.close()
|
||||
if repository is not None:
|
||||
await repository.close()
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
|
||||
@@ -12,6 +12,9 @@ from typing import Any
|
||||
from sqlalchemy import text
|
||||
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:
|
||||
ca_file = os.environ.get("PG_CA_FILE")
|
||||
@@ -83,9 +86,12 @@ class Repository:
|
||||
WITH candidates AS (
|
||||
SELECT id
|
||||
FROM han_app.sync_queue
|
||||
WHERE status IN ('pending','retry_wait')
|
||||
AND next_attempt_at <= now()
|
||||
AND (locked_until IS NULL OR locked_until < now())
|
||||
WHERE (
|
||||
status IN ('pending','retry_wait') AND next_attempt_at <= now()
|
||||
)
|
||||
OR (
|
||||
status='leased' AND locked_until < now()
|
||||
)
|
||||
ORDER BY next_attempt_at, created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT :limit
|
||||
@@ -156,7 +162,7 @@ class Repository:
|
||||
"""
|
||||
)
|
||||
async with self.engine.begin() as connection:
|
||||
return (
|
||||
workflow_id = (
|
||||
await connection.execute(
|
||||
sql,
|
||||
{
|
||||
@@ -167,6 +173,17 @@ class Repository:
|
||||
},
|
||||
)
|
||||
).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 with self.engine.begin() as connection:
|
||||
@@ -280,8 +297,12 @@ class Repository:
|
||||
"""
|
||||
WITH candidates AS (
|
||||
SELECT id FROM bitrix_sync.webhook_inbox
|
||||
WHERE status IN ('received','retry_wait') AND next_attempt_at<=now()
|
||||
AND (locked_until IS NULL OR locked_until<now())
|
||||
WHERE (
|
||||
status IN ('received','retry_wait') AND next_attempt_at<=now()
|
||||
)
|
||||
OR (
|
||||
status='processing' AND locked_until<now()
|
||||
)
|
||||
ORDER BY next_attempt_at,received_at
|
||||
FOR UPDATE SKIP LOCKED LIMIT :limit
|
||||
)
|
||||
@@ -345,7 +366,8 @@ class Repository:
|
||||
text(
|
||||
"""
|
||||
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'
|
||||
"""
|
||||
),
|
||||
@@ -354,6 +376,7 @@ class Repository:
|
||||
"full_name": full_name,
|
||||
"citizenship": citizenship,
|
||||
"email": email,
|
||||
"source_updated_at": source_updated_at,
|
||||
},
|
||||
)
|
||||
await connection.execute(
|
||||
@@ -363,14 +386,20 @@ class Repository:
|
||||
(id,mapping_id,user_id,external_id,full_name_hash,email_hash,
|
||||
citizenship_hash,source_updated_at,last_applied_source,
|
||||
last_webhook_received_at,created_at,updated_at)
|
||||
SELECT gen_random_uuid(),m.id,:user_id,:external_id,
|
||||
encode(digest(coalesce(:full_name,''),'sha256'),'hex'),
|
||||
encode(digest(coalesce(:email,''),'sha256'),'hex'),
|
||||
encode(digest(coalesce(:citizenship,''),'sha256'),'hex'),
|
||||
:source_updated_at,:source,
|
||||
CASE WHEN :source='webhook' THEN now() END,now(),now()
|
||||
SELECT gen_random_uuid(),m.id,:user_id,
|
||||
CAST(:external_id AS varchar(128)),
|
||||
CAST(:full_name_hash AS varchar(64)),
|
||||
CAST(:email_hash AS varchar(64)),
|
||||
CAST(:citizenship_hash AS varchar(64)),
|
||||
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
|
||||
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
|
||||
SET full_name_hash=excluded.full_name_hash,email_hash=excluded.email_hash,
|
||||
citizenship_hash=excluded.citizenship_hash,
|
||||
@@ -385,9 +414,9 @@ class Repository:
|
||||
{
|
||||
"user_id": user_id,
|
||||
"external_id": external_id,
|
||||
"full_name": full_name,
|
||||
"citizenship": citizenship,
|
||||
"email": email,
|
||||
"full_name_hash": safe_hash(full_name or ""),
|
||||
"citizenship_hash": safe_hash(citizenship or ""),
|
||||
"email_hash": safe_hash(email or ""),
|
||||
"source_updated_at": source_updated_at,
|
||||
"source": source,
|
||||
},
|
||||
@@ -506,12 +535,17 @@ class Repository:
|
||||
queries = {
|
||||
"queue": "SELECT status, count(*) count FROM han_app.sync_queue GROUP BY status",
|
||||
"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": (
|
||||
"SELECT status, count(*) count "
|
||||
"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": (
|
||||
"SELECT coalesce(extract(epoch from now()-min(received_at)),0) "
|
||||
"FROM bitrix_sync.webhook_inbox WHERE status IN ('received','retry_wait')"
|
||||
@@ -530,4 +564,5 @@ class Repository:
|
||||
else:
|
||||
output[key] = result.scalar_one_or_none()
|
||||
output["generated_at"] = datetime.now(UTC).isoformat()
|
||||
record_status_snapshot(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 socket
|
||||
import uuid
|
||||
from time import monotonic
|
||||
|
||||
from app.config import load_settings
|
||||
from app.crm import CrmClient
|
||||
from app.domain import full_jitter_delay
|
||||
from app.engine import RetryableWorkflow, WorkflowEngine
|
||||
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:
|
||||
settings = load_settings()
|
||||
if not settings.enabled:
|
||||
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()}"
|
||||
init_telemetry("bitrix-sync-worker")
|
||||
repository: Repository | None = None
|
||||
crm: CrmClient | None = None
|
||||
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():
|
||||
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
|
||||
)
|
||||
rebind_ids = await repository.pending_rebind_ids(settings.claim_size)
|
||||
with safe_span("bitrix_sync.worker.claim"):
|
||||
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
|
||||
)
|
||||
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:
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=1)
|
||||
@@ -49,29 +68,68 @@ async def worker_main() -> None:
|
||||
for task in tasks:
|
||||
if stop.is_set():
|
||||
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:
|
||||
if stop.is_set():
|
||||
break
|
||||
started = monotonic()
|
||||
outcome = "success"
|
||||
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:
|
||||
outcome = "retry"
|
||||
record_retry("webhook")
|
||||
delay = exc.retry_after or full_jitter_delay(
|
||||
item.attempt_count + 1,
|
||||
settings.retry_base_seconds,
|
||||
settings.retry_max_seconds,
|
||||
)
|
||||
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:
|
||||
if stop.is_set():
|
||||
break
|
||||
started = monotonic()
|
||||
outcome = "success"
|
||||
try:
|
||||
await engine.process_rebind(request_id)
|
||||
with safe_span("bitrix_sync.worker.rebind"):
|
||||
await engine.process_rebind(request_id)
|
||||
except RetryableWorkflow:
|
||||
outcome = "retry"
|
||||
record_retry("rebind")
|
||||
continue
|
||||
except Exception:
|
||||
outcome = "error"
|
||||
raise
|
||||
finally:
|
||||
record_workflow("rebind", outcome, monotonic() - started)
|
||||
finally:
|
||||
await crm.close()
|
||||
await repository.close()
|
||||
if crm is not None:
|
||||
await crm.close()
|
||||
if repository is not None:
|
||||
await repository.close()
|
||||
log_event("worker.stopped", "Bitrix sync worker stopped")
|
||||
shutdown_telemetry()
|
||||
|
||||
|
||||
def run() -> None:
|
||||
|
||||
@@ -23,6 +23,7 @@ services:
|
||||
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_CITIZENSHIP_FIELD: ${BITRIX_SYNC_CONTACT_CITIZENSHIP_FIELD:-}
|
||||
BITRIX_SYNC_CONTACT_SOURCE: ${BITRIX_SYNC_CONTACT_SOURCE:-}
|
||||
BITRIX_SYNC_WEBHOOK_ALLOWED_CIDRS: ${BITRIX_WEBHOOK_ALLOWED_CIDRS:-}
|
||||
volumes: &sync_secrets
|
||||
- /run/han-chat/secrets/bitrix-sync/database-url:/run/secrets/bitrix_sync_database_url:ro
|
||||
|
||||
@@ -8,6 +8,12 @@ dependencies = [
|
||||
"asyncpg>=0.30,<1",
|
||||
"fastapi>=0.116,<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",
|
||||
"python-multipart>=0.0.20,<1",
|
||||
"sqlalchemy[asyncio]>=2.0.41,<3",
|
||||
|
||||
@@ -21,5 +21,6 @@ def full_settings() -> Settings:
|
||||
contact_user_id_field="UF_CRM_100",
|
||||
contact_registered_field="UF_CRM_101",
|
||||
contact_citizenship_field="UF_CRM_102",
|
||||
contact_source="WEB",
|
||||
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_registered_field="UF_CRM_2",
|
||||
contact_citizenship_field="UF_CRM_3",
|
||||
contact_source="WEB",
|
||||
webhook_allowed_cidrs="203.0.113.0/24",
|
||||
)
|
||||
|
||||
|
||||
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_6a70c275346a7") == "ufCrm_6a70c275346a7"
|
||||
assert Settings.rest_field_name("UF_CRM_AbC123") == "ufCrm_AbC123"
|
||||
with pytest.raises(ValueError):
|
||||
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:
|
||||
|
||||
@@ -2,20 +2,67 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
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
|
||||
|
||||
|
||||
class Result:
|
||||
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:
|
||||
def __init__(self, statements: list[str]) -> None:
|
||||
self.statements = statements
|
||||
|
||||
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()
|
||||
|
||||
|
||||
@@ -26,7 +73,7 @@ class FakeRepository:
|
||||
|
||||
@asynccontextmanager
|
||||
async def transaction(self):
|
||||
yield Connection()
|
||||
yield Connection(self.statements)
|
||||
|
||||
async def active_mapping(self, user_id):
|
||||
return self.mapping
|
||||
@@ -36,8 +83,16 @@ class FakeRepository:
|
||||
|
||||
|
||||
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.existing_identity = existing_identity
|
||||
self.foreign_owner = foreign_owner
|
||||
self.calls: list[tuple[str, dict]] = []
|
||||
|
||||
async def call(self, method, params, *, mutating):
|
||||
@@ -51,12 +106,27 @@ class FakeCrm:
|
||||
{
|
||||
"ID": contact_id,
|
||||
"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)
|
||||
|
||||
|
||||
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
|
||||
async def test_multiple_contacts_choose_numeric_newest(full_settings) -> None:
|
||||
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"]
|
||||
assert updates[0]["id"] == "10"
|
||||
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
|
||||
`safety` user limited to required `han:safety:*` keys/commands. The password
|
||||
in `MESSAGE_SAFETY_REDIS_URL` must match. Start from
|
||||
`redis/redis-safety.acl.template`, replace
|
||||
`REPLACE_WITH_LONG_RANDOM_PASSWORD`, and never commit the password.
|
||||
`redis/redis-safety.acl.template`, replace both placeholders, and add the
|
||||
`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.
|
||||
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` may migrate/activate policy while
|
||||
`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.
|
||||
9. The setup script leaves UFW egress open for bootstrap. Before production,
|
||||
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.
|
||||
|
||||
## Who runs what
|
||||
@@ -75,14 +80,17 @@ Compose first:
|
||||
`bitrix_sync`, separate migration/runtime DSNs; see
|
||||
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
||||
3. **Images** — build and push `han-message-safety`, `han-bitrix-sync`; record
|
||||
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis, clamav,
|
||||
otel-collector).
|
||||
immutable digests for every `*_IMAGE` in `.env.example` (nginx, redis,
|
||||
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
|
||||
`deployment/secrets/config.example.json` (DSNs, tokens, S3 read-only keys,
|
||||
`REDIS_SAFETY_ACL`, internal TLS PEM for `8443`). Dedicated VM2 IAM principal
|
||||
with read-only access to those names only.
|
||||
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM for `8443`).
|
||||
Dedicated VM2 IAM principal with read-only access to those names only.
|
||||
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
|
||||
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:
|
||||
[`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
|
||||
|
||||
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
|
||||
`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 |
|
||||
| --- | --- |
|
||||
| 1 | Secrets materialized via `han-secrets-vm2.service` |
|
||||
@@ -185,8 +205,9 @@ Safety cutover on VM1 or Bitrix sync enablement.
|
||||
briefly.
|
||||
3. Record release evidence: `han-vm2-compose config --images`, `ps`, certbot
|
||||
timer, unit journals — without secret values.
|
||||
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, ClamAV
|
||||
signature age, OTEL queue, disk/RAM, MOCK mode, private Safety API).
|
||||
4. Configure operational monitoring (unhealthy/restart/OOM, TLS expiry, KESL
|
||||
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
|
||||
[`module-10-deployment-vm2.md`](../../../documentation/module-10-deployment-vm2.md) §13.
|
||||
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
|
||||
|
||||
- Safety dependency failure is fail-closed: VM1 must not send/promote content.
|
||||
- Stale/unavailable ClamAV signatures disable file capability only; they never
|
||||
convert a scan error to allow.
|
||||
- A stale/unavailable KESL database or broker error disables file capability
|
||||
only; scan errors remain retryable and eventually return `503`, never allow.
|
||||
- Redis loss may remove acceleration but PostgreSQL remains authoritative.
|
||||
- OTEL outage queues within the bounded volume and must not change verdicts.
|
||||
- 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
|
||||
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
|
||||
digest. Do not weaken `read_only`, capabilities or mounts globally: document
|
||||
the smallest writable signature/runtime paths and compensate with network and
|
||||
resource limits. `freshclam` alone receives signature-CDN egress; `clamd`
|
||||
receives none.
|
||||
KESL 12.4 standalone and the root-owned fail-closed broker are not Compose
|
||||
images. The Message Safety worker receives only
|
||||
`/run/han-kesl/scan.sock`, not `kesl-control`, the Docker socket or host-root
|
||||
access. Runtime records `scanner_engine=kesl`; `signatures_version` is the hash
|
||||
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-файл, а не просто пароль. Он должен
|
||||
открывать неаутентифицированный `PING` только для health и
|
||||
защищённого паролем пользователя `safety`, ограниченного необходимыми
|
||||
ключами/командами `han:safety:*`.
|
||||
(Пароль в `MESSAGE_SAFETY_REDIS_URL` должен совпадать. Используйте `redis/redis-safety.acl.template`, заменив `REPLACE_WITH_LONG_RANDOM_PASSWORD)
|
||||
ключами/командами `han:safety:*`. Добавьте пользователя `exporter` только с
|
||||
`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 и миграций.
|
||||
`MESSAGE_SAFETY_CONFIG_ADMIN_DATABASE_URL` может мигрировать/активировать
|
||||
политику, а `MESSAGE_SAFETY_DATABASE_URL` — нет; `BITRIX_SYNC_MIGRATION_DATABASE_URL`
|
||||
@@ -47,8 +51,9 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
|
||||
`ops`.
|
||||
9. Setup оставляет исходящий трафик UFW открытым на bootstrap-окно. До
|
||||
production ограничьте egress правилами Selectel SG/NAT/proxy до
|
||||
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP, SigNoz и
|
||||
источников ClamAV. Registry/package repositories оставляйте только на
|
||||
утверждённых PostgreSQL, S3, Secrets Manager, Bitrix24, DNS/NTP и SigNoz.
|
||||
Для host KESL разрешите только источники обновления из
|
||||
[`deployment/kesl/RUNBOOK.KESL.ru.md`](kesl/RUNBOOK.KESL.ru.md). Registry/package repositories оставляйте только на
|
||||
controlled maintenance window.
|
||||
|
||||
## Кто что выполняет
|
||||
@@ -83,12 +88,15 @@ deployment-артефакты: `VM2_services/codebase/services/`. Локальн
|
||||
[`arch-10-deployment.md`](../../../../architectory/arch-10-deployment.md) §6.
|
||||
3. **Образы** — собрать и push `han-message-safety`, `han-bitrix-sync`;
|
||||
получить 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 из
|
||||
`deployment/secrets/config.example.json` (DSN, tokens, S3 read-only keys,
|
||||
`REDIS_SAFETY_ACL`, internal TLS PEM для `8443`). Отдельный IAM principal
|
||||
VM2 с read-only доступом только к этим именам.
|
||||
`REDIS_SAFETY_ACL`, `REDIS_EXPORTER_PASSWORD`, internal TLS PEM для `8443`).
|
||||
Отдельный IAM principal VM2 с read-only доступом только к этим именам.
|
||||
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;
|
||||
PEM хранится в Secrets Manager, не в каталоге релиза.
|
||||
|
||||
@@ -301,6 +309,13 @@ DSN, token, password, access/secret key туда не записываются.
|
||||
создайте отдельный VM2 IAM principal с read-only доступом только к remote names
|
||||
из 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, не помещая
|
||||
его в аргументы или history:
|
||||
|
||||
@@ -513,7 +528,7 @@ REVOKE USAGE ON SCHEMA han_app FROM <BITRIX_SYNC_MIGRATION_ROLE>;
|
||||
новый монотонный номер и отдельные значения `--actor`/`--approved-by`; повторно
|
||||
активировать старую версию нельзя. Alembic downgrade запрещён.
|
||||
|
||||
При обновлении ClamAV policy образ Message Safety должен содержать согласованные
|
||||
При обновлении KESL policy образ Message Safety должен содержать согласованные
|
||||
seed и schema: seed `max_signature_age_hours=240`, schema maximum `720`
|
||||
(30 дней). После обновления immutable image digest создайте новую config
|
||||
version; существующую active version не редактируйте и не активируйте повторно:
|
||||
@@ -535,7 +550,7 @@ NEXT_VERSION='<СЛЕДУЮЩИЙ_МОНОТОННЫЙ_НОМЕР>'
|
||||
/usr/local/sbin/han-vm2-compose up -d --no-deps --force-recreate \
|
||||
message-safety-api message-safety-worker
|
||||
/usr/local/sbin/han-vm2-compose ps \
|
||||
message-safety-api message-safety-worker clamd freshclam
|
||||
message-safety-api message-safety-worker
|
||||
unset NEXT_VERSION
|
||||
```
|
||||
|
||||
@@ -575,8 +590,15 @@ open-file limit быть не должно. Ошибка отсутствующ
|
||||
Под `root` на VM2:
|
||||
|
||||
```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 freshclam clamd
|
||||
/usr/local/sbin/han-vm2-compose up -d \
|
||||
message-safety-api message-safety-worker
|
||||
/usr/local/sbin/han-vm2-compose up -d \
|
||||
@@ -585,6 +607,13 @@ open-file limit быть не должно. Ошибка отсутствующ
|
||||
/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`. Он
|
||||
выставляет владельца persistent queue `10001:10001` и завершается с кодом `0`;
|
||||
сам Collector стартует только после этого.
|
||||
@@ -879,7 +908,7 @@ journalctl --no-pager -u han-processing.service -u han-secrets-vm2.service
|
||||
|
||||
- container unhealthy/restart/OOM;
|
||||
- срок TLS;
|
||||
- возраст ClamAV signatures;
|
||||
- KESL version/database date, результат ежечасного update и broker/socket status;
|
||||
- OTEL queue/export errors;
|
||||
- disk/RAM;
|
||||
- активный MOCK mode;
|
||||
@@ -1094,8 +1123,9 @@ plan.
|
||||
|
||||
- Отказ зависимости Safety — fail-closed: VM1 не должна отправлять/продвигать
|
||||
контент.
|
||||
- Устаревшие/недоступные сигнатуры ClamAV отключают только файловую
|
||||
capability; ошибка сканирования никогда не превращается в allow.
|
||||
- Устаревшая/недоступная база KESL или ошибка broker отключают только файловую
|
||||
capability; ошибка сканирования никогда не превращается в allow и ведёт к
|
||||
retry/`503`.
|
||||
- Потеря Redis может убрать ускорение, но PostgreSQL остаётся источником
|
||||
истины.
|
||||
- Сбой OTEL ставит в очередь в пределах ограниченного тома и не должен
|
||||
@@ -1121,10 +1151,12 @@ han-message-safety-mode mock --text-free false --file-free false
|
||||
таймаута: держите high-severity alert активным до явного `standard`, затем
|
||||
проверьте нормальные text/link/file capabilities и EICAR-canary.
|
||||
|
||||
## Известные исключения по образам
|
||||
## Host KESL и broker
|
||||
|
||||
Образы ClamAV могут потребовать корректировок UID/path после валидации
|
||||
точного digest. Не ослабляйте `read_only`, capabilities или mounts глобально:
|
||||
задокументируйте минимальные writable пути для сигнатур/runtime и
|
||||
компенсируйте сетевыми и ресурсными лимитами. Egress к signature-CDN
|
||||
получает только `freshclam`; `clamd` — нет.
|
||||
KESL 12.4 standalone и root-owned fail-closed broker не являются Compose
|
||||
образами. Message Safety worker получает только Unix socket
|
||||
`/run/han-kesl/scan.sock`; доступ к `kesl-control`, Docker socket и host root
|
||||
ему не выдаётся. `scanner_engine=kesl`, а `signatures_version` вычисляется как
|
||||
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]
|
||||
Description=HAN Processing VM2 root Compose stack
|
||||
Requires=docker.service han-secrets-vm2.service
|
||||
After=docker.service han-secrets-vm2.service network-online.target
|
||||
Requires=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket
|
||||
After=docker.service han-secrets-vm2.service han-kesl-scan-broker.socket network-online.target
|
||||
|
||||
[Service]
|
||||
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