From 85df788f2df44aaffb4f0e34703dc16a95febd81 Mon Sep 17 00:00:00 2001 From: mi Date: Mon, 7 Sep 2026 12:46:45 +0300 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=9C1:=20=D1=80=D0=B5=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=BE=D0=B2=D0=B0=D0=BD=20=D1=81=D0=B1=D0=BE=D1=80=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VM1_app/codebase/backend/.env.example | 3 + .../versions/0013_delivery_trace_context.py | 45 +++ .../codebase/backend/api-backend/app/db.py | 2 + .../api-backend/app/logging_security.py | 6 +- .../codebase/backend/api-backend/app/main.py | 24 +- .../backend/api-backend/app/services.py | 5 + .../backend/api-backend/app/telemetry.py | 49 ++- .../backend/api-backend/app/workers.py | 124 ++++++-- .../api-backend/tests/unit/test_telemetry.py | 16 + .../versions/0002_inbox_trace_context.py | 63 ++++ .../bitrix-local-app/app/logging_security.py | 47 +++ .../backend/bitrix-local-app/app/main.py | 286 ++++++++++++------ .../backend/bitrix-local-app/app/models.py | 6 + .../backend/bitrix-local-app/app/telemetry.py | 119 ++++++++ .../backend/bitrix-local-app/pyproject.toml | 7 + .../bitrix-local-app/tests/test_core.py | 12 + .../deployment/RUNBOOK.production.ru.md | 32 +- .../han-host-otel-collector@.service | 45 +++ .../deployment/kesl/EVIDENCE.AVZ.ru.md | 233 ++++++++------ .../deployment/kesl/RUNBOOK.KESL.ru.md | 187 +++++++++--- .../observability/otel-host-collector.yaml | 143 +++++++++ .../codebase/backend/deployment/preflight.sh | 40 +++ .../backend/deployment/scripts/setup-vm.sh | 43 ++- .../scripts/verify-observability.sh | 49 ++- .../backend/infra/compose/application.yml | 30 +- .../codebase/backend/nginx/docker-compose.yml | 7 +- .../backend/observability/docker-compose.yml | 1 + .../backend/observability/otel-collector.yaml | 1 + .../codebase/backend/redis/docker-compose.yml | 7 +- VM1_app/codebase/backend/scripts/validate-env | 3 +- .../sms-service/app/logging_security.py | 6 +- .../codebase/backend/sms-service/app/main.py | 16 +- .../backend/sms-service/app/telemetry.py | 37 ++- .../backend/sms-service/app/worker.py | 38 ++- .../sms-service/tests/unit/test_telemetry.py | 6 + VM1_app/codebase/backend/tests/test_config.py | 56 +++- .../module-09-observability-vm1.md | 22 ++ VM4_Expo-mobile/.assets/icons/icon.png | Bin 62383 -> 76013 bytes VM4_Expo-mobile/app.config.ts | 3 + architectory/arch-07-observability.md | 24 ++ support¬es/deploy_stories/260903_2.md | 9 +- support¬es/deploy_stories/260904_1.md | 127 ++++++++ 42 files changed, 1669 insertions(+), 310 deletions(-) create mode 100644 VM1_app/codebase/backend/api-backend/alembic/versions/0013_delivery_trace_context.py create mode 100644 VM1_app/codebase/backend/bitrix-local-app/alembic/versions/0002_inbox_trace_context.py create mode 100644 VM1_app/codebase/backend/bitrix-local-app/app/logging_security.py create mode 100644 VM1_app/codebase/backend/bitrix-local-app/app/telemetry.py create mode 100644 VM1_app/codebase/backend/deployment/han-host-otel-collector@.service create mode 100644 VM1_app/codebase/backend/deployment/observability/otel-host-collector.yaml create mode 100644 support¬es/deploy_stories/260904_1.md diff --git a/VM1_app/codebase/backend/.env.example b/VM1_app/codebase/backend/.env.example index 52b990f..d2ba19f 100644 --- a/VM1_app/codebase/backend/.env.example +++ b/VM1_app/codebase/backend/.env.example @@ -115,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 @@ -126,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 diff --git a/VM1_app/codebase/backend/api-backend/alembic/versions/0013_delivery_trace_context.py b/VM1_app/codebase/backend/api-backend/alembic/versions/0013_delivery_trace_context.py new file mode 100644 index 0000000..66c0059 --- /dev/null +++ b/VM1_app/codebase/backend/api-backend/alembic/versions/0013_delivery_trace_context.py @@ -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 + """ + ) diff --git a/VM1_app/codebase/backend/api-backend/app/db.py b/VM1_app/codebase/backend/api-backend/app/db.py index 3d5c723..9939a15 100644 --- a/VM1_app/codebase/backend/api-backend/app/db.py +++ b/VM1_app/codebase/backend/api-backend/app/db.py @@ -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)) diff --git a/VM1_app/codebase/backend/api-backend/app/logging_security.py b/VM1_app/codebase/backend/api-backend/app/logging_security.py index db055fb..e19c686 100644 --- a/VM1_app/codebase/backend/api-backend/app/logging_security.py +++ b/VM1_app/codebase/backend/api-backend/app/logging_security.py @@ -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) diff --git a/VM1_app/codebase/backend/api-backend/app/main.py b/VM1_app/codebase/backend/api-backend/app/main.py index 56402bf..afbef69 100644 --- a/VM1_app/codebase/backend/api-backend/app/main.py +++ b/VM1_app/codebase/backend/api-backend/app/main.py @@ -89,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, @@ -102,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, ) @@ -135,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() @@ -178,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( diff --git a/VM1_app/codebase/backend/api-backend/app/services.py b/VM1_app/codebase/backend/api-backend/app/services.py index c602663..2c3a529 100644 --- a/VM1_app/codebase/backend/api-backend/app/services.py +++ b/VM1_app/codebase/backend/api-backend/app/services.py @@ -58,6 +58,7 @@ from app.schemas import ( encode_cursor, ) from app.settings import Settings +from app.telemetry import current_traceparent MESSAGE_SAFETY_REPLIES = { "text": ( @@ -95,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 = ( @@ -104,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]) @@ -1039,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"], @@ -1133,6 +1137,7 @@ async def send_message( # 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) diff --git a/VM1_app/codebase/backend/api-backend/app/telemetry.py b/VM1_app/codebase/backend/api-backend/app/telemetry.py index c633998..b0f9654 100644 --- a/VM1_app/codebase/backend/api-backend/app/telemetry.py +++ b/VM1_app/codebase/backend/api-backend/app/telemetry.py @@ -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 [] diff --git a/VM1_app/codebase/backend/api-backend/app/workers.py b/VM1_app/codebase/backend/api-backend/app/workers.py index 1063e18..e66203f 100644 --- a/VM1_app/codebase/backend/api-backend/app/workers.py +++ b/VM1_app/codebase/backend/api-backend/app/workers.py @@ -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,6 +27,7 @@ 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 @@ -37,10 +40,45 @@ from app.services import ( 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, @@ -61,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 = ( ( @@ -88,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) @@ -127,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 = ( ( @@ -155,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) @@ -204,6 +261,7 @@ async def safety_once( attachment=attachment, ), next_attempt_at=datetime.now(UTC), + traceparent=task.traceparent, ) elif verdict["_status"] == 403 and message: message.safety_processing_mode = verdict["processing_mode"] @@ -365,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) diff --git a/VM1_app/codebase/backend/api-backend/tests/unit/test_telemetry.py b/VM1_app/codebase/backend/api-backend/tests/unit/test_telemetry.py index 432fc8b..bbdb191 100644 --- a/VM1_app/codebase/backend/api-backend/tests/unit/test_telemetry.py +++ b/VM1_app/codebase/backend/api-backend/tests/unit/test_telemetry.py @@ -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 diff --git a/VM1_app/codebase/backend/bitrix-local-app/alembic/versions/0002_inbox_trace_context.py b/VM1_app/codebase/backend/bitrix-local-app/alembic/versions/0002_inbox_trace_context.py new file mode 100644 index 0000000..127ceae --- /dev/null +++ b/VM1_app/codebase/backend/bitrix-local-app/alembic/versions/0002_inbox_trace_context.py @@ -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 + """ + ) diff --git a/VM1_app/codebase/backend/bitrix-local-app/app/logging_security.py b/VM1_app/codebase/backend/bitrix-local-app/app/logging_security.py new file mode 100644 index 0000000..b5d85b9 --- /dev/null +++ b/VM1_app/codebase/backend/bitrix-local-app/app/logging_security.py @@ -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[a-z][a-z0-9+.-]*://)[^/@\s]+@", re.IGNORECASE) +_QUERY_SECRET = re.compile( + r"(?P[?&](?: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[REDACTED]@", value) + value = _QUERY_SECRET.sub(r"\g[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) diff --git a/VM1_app/codebase/backend/bitrix-local-app/app/main.py b/VM1_app/codebase/backend/bitrix-local-app/app/main.py index dca6b43..9cdb387 100644 --- a/VM1_app/codebase/backend/bitrix-local-app/app/main.py +++ b/VM1_app/codebase/backend/bitrix-local-app/app/main.py @@ -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: @@ -309,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: @@ -498,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, @@ -527,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", @@ -540,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): @@ -579,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 ) @@ -655,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, @@ -731,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), ) @@ -768,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) @@ -845,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 @@ -960,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() @@ -971,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) @@ -1168,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) @@ -1182,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: @@ -1252,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: @@ -1285,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: diff --git a/VM1_app/codebase/backend/bitrix-local-app/app/models.py b/VM1_app/codebase/backend/bitrix-local-app/app/models.py index b62e55d..0da8dbc 100644 --- a/VM1_app/codebase/backend/bitrix-local-app/app/models.py +++ b/VM1_app/codebase/backend/bitrix-local-app/app/models.py @@ -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) diff --git a/VM1_app/codebase/backend/bitrix-local-app/app/telemetry.py b/VM1_app/codebase/backend/bitrix-local-app/app/telemetry.py new file mode 100644 index 0000000..7e3a2f5 --- /dev/null +++ b/VM1_app/codebase/backend/bitrix-local-app/app/telemetry.py @@ -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 [] diff --git a/VM1_app/codebase/backend/bitrix-local-app/pyproject.toml b/VM1_app/codebase/backend/bitrix-local-app/pyproject.toml index a1550d8..eb56adc 100644 --- a/VM1_app/codebase/backend/bitrix-local-app/pyproject.toml +++ b/VM1_app/codebase/backend/bitrix-local-app/pyproject.toml @@ -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", ] diff --git a/VM1_app/codebase/backend/bitrix-local-app/tests/test_core.py b/VM1_app/codebase/backend/bitrix-local-app/tests/test_core.py index 6a34fc4..d1e1cd4 100644 --- a/VM1_app/codebase/backend/bitrix-local-app/tests/test_core.py +++ b/VM1_app/codebase/backend/bitrix-local-app/tests/test_core.py @@ -19,6 +19,7 @@ os.environ.setdefault( import httpx import pytest +from app.logging_security import REDACTED, sanitize_value from app.main import ( BitrixClient, TokenCipher, @@ -30,6 +31,17 @@ from app.main import ( 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(): diff --git a/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md b/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md index 955fdca..451cb2b 100644 --- a/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md +++ b/VM1_app/codebase/backend/deployment/RUNBOOK.production.ru.md @@ -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. @@ -435,6 +452,13 @@ 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 @@ -445,7 +469,8 @@ runbook `deployment/kesl/RUNBOOK.KESL.ru.md`. Не совмещайте уста ```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 ``` @@ -466,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. diff --git a/VM1_app/codebase/backend/deployment/han-host-otel-collector@.service b/VM1_app/codebase/backend/deployment/han-host-otel-collector@.service new file mode 100644 index 0000000..67cedad --- /dev/null +++ b/VM1_app/codebase/backend/deployment/han-host-otel-collector@.service @@ -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 diff --git a/VM1_app/codebase/backend/deployment/kesl/EVIDENCE.AVZ.ru.md b/VM1_app/codebase/backend/deployment/kesl/EVIDENCE.AVZ.ru.md index b996b98..a0e4755 100644 --- a/VM1_app/codebase/backend/deployment/kesl/EVIDENCE.AVZ.ru.md +++ b/VM1_app/codebase/backend/deployment/kesl/EVIDENCE.AVZ.ru.md @@ -4,52 +4,61 @@ должна содержать activation code, ключи, токены, DSN, environment, содержимое secret-файлов, персональные данные или тестовый файл EICAR. +Ниже зафиксирован **тестовый пилот** на `devhanapp` (4 ГБ RAM). Это не +автоматическая приёмка боевой среды. + ## 1. Идентификация изменения -- Change ID: -- Дата и окно: -- Оператор: -- Security approver: -- Service owner: -- Hostname ВМ1: -- Ubuntu version: -- Kernel version: -- KESL package/version: -- SHA-256 DEB: -- Источник пакета: -- HAN release SHA: -- Container image digests зафиксированы: да / нет +- 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: -- Restart count: -- Public smoke: -- Negative port probes: -- CPU: -- Available RAM: -- Swap activity: -- Disk free: -- IO wait: -- API p95: -- Redis latency / blocked clients: -- OTEL queue: -- Открытые до установки проблемы: +- Все 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: -- IO wait: -- disk: -- health/restarts: +- p95/Redis: для теста не применялись +- available RAM/swap: stop при available < 512 МБ; не достигнуто +- IO wait: не применялся +- disk: свободно > 10 ГБ +- health/restarts: новых unhealthy не было ## 3. АВЗ.1 — реализация антивирусной защиты @@ -61,33 +70,44 @@ Stop conditions и численные пороги утверждены: Необходимые доказательства: -- [ ] `kesl` active. -- [ ] Лицензия действительна. -- [ ] File Threat Protection (ID 1) имеет состояние `Started`. -- [ ] InterceptorProtectionMode = `Block`. -- [ ] ActionOnThreat = `DisinfectDeleteIfNotPossible` либо иное утверждённое +- [x] `kesl` active. +- [x] Лицензия действительна (`The key is valid`, subscription active). +- [x] File Threat Protection (ID 1) имеет состояние `Started`. +- [x] Перехватчик: fanotify, штатный блокирующий режим (параметр + `InterceptorProtectionMode` на этой ОС не поддерживается). +- [x] ActionOnThreat = `DisinfectDeleteIfNotPossible` либо иное утверждённое блокирующее/лечащее действие. -- [ ] ScanArchived = `No` для real-time защиты. -- [ ] Исключения ограничены тремя утверждёнными hot-data mountpoint. -- [ ] Контролируемый EICAR заблокирован/обезврежен/помещён в карантин. -- [ ] Событие EICAR зарегистрировано в журнале KESL. -- [ ] После теста EICAR отсутствует вне карантина и тестовый каталог удалён. +- [x] ScanArchived = `No` для real-time защиты. +- [x] Исключения ограничены тремя утверждёнными hot-data mountpoint. +- [x] Контролируемый EICAR заблокирован/обезврежен/помещён в карантин. +- [x] Событие EICAR зарегистрировано в журнале KESL. +- [x] После теста EICAR отсутствует вне карантина и тестовый каталог удалён. - [ ] Public smoke и health после включения Block успешны. -- [ ] UFW и `HAN-CHAT-DOCKER` не изменены. + (health контейнеров подтверждён; внешний smoke не запускался) +- [x] UFW и `HAN-CHAT-DOCKER` не изменены. - [ ] За 24 часа нет новых restart/OOM/5xx и неприемлемой деградации. Артефакты без секретов: -- `systemctl is-active kesl`: -- `kesl-control --app-info`: -- `kesl-control --get-task-state 1`: -- reviewed excerpt `kesl-control --get-settings 1`: -- EICAR event ID/time/action: -- smoke result/time: -- firewall comparison: -- 24h resource comparison: +- `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: реализована / не реализована. +Вывод по АВЗ.1: **реализована на тестовой ВМ** (обнаружение + backup + +удаление). Ограничение: on-access не перехватил создание EICAR; реакция +доказана on-demand. Для боя повторить on-access (запись + чтение файла) +на ресурсах ≥16 ГБ. ## 4. АВЗ.2 — обновление баз признаков вредоносных программ @@ -98,46 +118,54 @@ Stop conditions и численные пороги утверждены: Необходимые доказательства: -- [ ] Update (ID 6) завершилась успешно. -- [ ] Базы загружены. -- [ ] Дата выпуска баз актуальна на момент проверки. -- [ ] Расписание Update = `Hourly`. +- [x] Update (ID 6) завершилась успешно. +- [x] Базы загружены. +- [x] Дата выпуска баз актуальна на момент проверки. +- [x] Расписание Update = `Hourly` (`StartTime=2026/Sep/07 11:32:38;1`). - [ ] Утверждён alert/регламент на ошибку и устаревание баз. - [ ] Назначен ответственный за ежедневный контроль. - [ ] Проверено успешное автоматическое обновление после ручного запуска. + (ручной Update успешен; первый hourly цикл ещё не наблюдался) Артефакты без секретов: -- `kesl-control --app-info`: -- `kesl-control --get-task-state 6`: -- `kesl-control --get-schedule 6`: -- время последнего успешного автоматического Update: -- ссылка на alert/регламент: -- ответственный: +- `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: реализована / не реализована. +Вывод по АВЗ.2: **реализована на тестовой ВМ** (ручное обновление + +почасовое расписание). Для боя нужны наблюдаемый hourly цикл и alert на сбой. ## 5. Связанные меры ### РСБ.1–3, РСБ.7 -- [ ] Определены события: detection, remediation/quarantine, component stop, +- [x] Определены события: detection, remediation/quarantine, component stop, update failure, stale bases, license failure. -- [ ] Определён состав полей: time, host, component/task, threat, object, + (фиксируются в журнале 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 -- [ ] Контролируется версия и жизненный цикл самого KESL, а не только баз. -- [ ] Upgrade KESL проходит совместимость, pilot, smoke и rollback review. -- [ ] Обновление kernel/Docker вызывает повторную проверку совместимости. +- [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. Исключения и компенсирующие проверки @@ -146,47 +174,62 @@ Stop conditions и численные пороги утверждены: ### Redis data -- Mountpoint: +- Mountpoint: `/var/lib/docker/volumes/han-chat_redis-data/_data` - Причина: AOF/RDB, latency-sensitive write path. -- Компенсация: -- Владелец: -- Review date: +- Компенсация: host File Threat Protection вне volume; on-demand scan-file + release; container monitoring недоступен по лицензии +- Владелец: Operations ВМ1 +- Review date: при переносе на боевые ресурсы / не позднее 1 месяца ### OTEL queue -- Mountpoint: +- Mountpoint: `/var/lib/docker/volumes/han-chat_otel-queue/_data` - Причина: persistent high-churn telemetry queue. -- Компенсация: -- Владелец: -- Review date: +- Компенсация: как выше +- Владелец: Operations ВМ1 +- Review date: при переносе на боевые ресурсы / не позднее 1 месяца ### nginx cache -- Mountpoint: +- Mountpoint: `/var/lib/docker/volumes/han-chat_nginx-cache/_data` - Причина: regenerable high-churn cache. -- Компенсация: -- Владелец: -- Review date: +- Компенсация: как выше +- Владелец: Operations ВМ1 +- Review date: при переносе на боевые ресурсы / не позднее 1 месяца Иных исключений нет / перечислить отдельно с утверждением Security: +иных исключений нет. `/var/lib/docker` целиком не исключался. + +Лицензионное ограничение: Container Monitoring / Container Scan недоступны. ## 7. Проверка отката -- [ ] Команда переключения `Block` → `Notify` проверена документально. -- [ ] Процедура остановки KESL доступна break-glass admin. -- [ ] Процедура `apt-get purge kesl` проверена по документации текущей версии. -- [ ] Откат не использует `docker compose down -v` и не удаляет volumes. -- [ ] После отката предусмотрены smoke, health и firewall checks. -- [ ] Reboot выполняется только отдельным согласованным окном при необходимости. +- [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: +Результат rehearsal/desk check: полный uninstall в этом окне не выполнялся; +рабочий откат FTP — `--stop-task 1`. ## 8. Итоговая приёмка -- АВЗ.1: принято / не принято. -- АВЗ.2: принято / не принято. +- АВЗ.1: **принято для тестовой ВМ**, с оговоркой on-access. +- АВЗ.2: **принято для тестовой ВМ**, с оговоркой ненаблюдавшегося hourly + цикла и отсутствия alert. - Ограничения/остаточные риски: -- Следующий review: + - 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, ФИО/подпись/дата: diff --git a/VM1_app/codebase/backend/deployment/kesl/RUNBOOK.KESL.ru.md b/VM1_app/codebase/backend/deployment/kesl/RUNBOOK.KESL.ru.md index 5645ee4..e1fe5c9 100644 --- a/VM1_app/codebase/backend/deployment/kesl/RUNBOOK.KESL.ru.md +++ b/VM1_app/codebase/backend/deployment/kesl/RUNBOOK.KESL.ru.md @@ -69,8 +69,8 @@ - `UseOnDemandCPULimit=Yes`, `OnDemandCPULimit=15`; - не запускать full filesystem scan и проверку архивов; - ODS и ContainerScan выполнять по одному объекту, не одновременно; -- сначала Update и health, затем один stateless image, затем File Threat - Protection в `Notify`; +- сначала Update и health, затем один ограниченный `scan-file`, затем File Threat + Protection; `InterceptorProtectionMode` на fanotify недоступен; - остановить KESL при available memory менее 512 МБ, устойчивом swap IO, появлении host/container OOM, restart или провале smoke; - до режима `Block` требуется отдельное подтверждение стабильности. @@ -169,18 +169,85 @@ journalctl --since '-30 min' --no-pager \ ## 2. Проверка пакета и подготовка -GUI на сервер не устанавливается. Пакет передаётся в root-only staging, -например `/root/kesl-install`, и удаляется после приёмки. +GUI на сервер не устанавливается. Дистрибутив и DEB копируются в root-only +staging `/root/kesl-install` и удаляются после приёмки. ISO в +`/var/lib/han-deploy/incoming` не распаковывается на месте и не остаётся +смонтированным после извлечения пакета. -```sh -install -d -m 0700 -o root -g root /root/kesl-install -install -m 0600 -o root -g root \ - /tmp/ /root/kesl-install/kesl.deb -sha256sum /root/kesl-install/kesl.deb -dpkg-deb -f /root/kesl-install/kesl.deb Package Version Architecture +Для тестовой ВМ ожидаемый 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 \ + /root/kesl-install/kesl.deb +dpkg-deb -f /root/kesl-install/kesl.deb Package Version Architecture +umount /mnt/kesl-iso +rmdir /mnt/kesl-iso ``` -Сравните SHA-256 с опубликованным/полученным по доверенному каналу значением. Не продолжайте при package name не `kesl`, неверной архитектуре или версии не 12.4.x. @@ -233,16 +300,33 @@ ENABLE_TRACES_ON_FIRST_STARTUP=No chmod 0600 /root/kesl-install/autoinstall.ini /opt/kaspersky/kesl/bin/kesl-setup.pl \ --autoinstall=/root/kesl-install/autoinstall.ini -test "$?" -eq 0 +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 ``` -Если используется activation code, не передавайте его аргументом команды и не -храните в этом репозитории. Выполните активацию по официальной инструкции -Kaspersky с защищённым локальным вводом/файлом ключа. +Активацию кодом выполняйте только после успешного `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 @@ -306,13 +390,19 @@ kesl-control --app-info выпуска баз и успешное завершение Update. Затем задайте почасовой запуск: ```sh -kesl-control --set-schedule 6 RuleType=Hourly +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 ``` -Если установленная сборка требует интервал в `StartTime`, не угадывайте -синтаксис: экспортируйте schedule и измените его по документации именно этой -сборки. До успешного автообновления АВЗ.2 не принимается. +Для 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. @@ -422,39 +512,33 @@ kesl-control --get-settings 1 kesl-control --get-task-state 1 ``` -Для пилота включите асинхронный режим перехватчика `Notify`, при котором -KESL журналирует обнаружения, но не выполняет блокирующее действие: +Для пилота запустите File Threat Protection. На Ubuntu 24.04 с `fanotify` +параметр `InterceptorProtectionMode` **не поддерживается** (`Unsupported +setting`): перехватчик работает в штатном блокирующем режиме на время +проверки. Отдельный `Notify` недоступен; откат — остановка задачи 1. ```sh -kesl-control --set-app-settings InterceptorProtectionMode=Notify -kesl-control --start-task 1 -kesl-control --get-task-state 1 -``` - -Пилот длится минимум 2–4 часа обычной нагрузки. Каждые 15 минут проверяйте -метрики раздела 1 и события KESL. Не считайте этот режим конечной реализацией -АВЗ.1: он не обеспечивает блокирование/лечение. - -Если пилот стабилен, в том же maintenance window: - -1. проверьте, что `ActionOnThreat=DisinfectDeleteIfNotPossible`; -2. установите `InterceptorProtectionMode=Block`; -3. перезапустите task 1, если этого требует текущая сборка; -4. повторите health, smoke, firewall и resource checks. - -```sh -kesl-control --set-settings 1 \ - ActionOnThreat=DisinfectDeleteIfNotPossible ScanArchived=No -kesl-control --set-app-settings InterceptorProtectionMode=Block -kesl-control --stop-task 1 kesl-control --start-task 1 kesl-control --get-task-state 1 kesl-control --app-info ``` -В режиме `Block` доступ к файлу ожидает результат проверки. При появлении -latency вернитесь в `Notify` либо остановите task 1 и выполните rollback; -не расширяйте исключения вслепую. +Пилот длится минимум 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 @@ -483,8 +567,15 @@ kesl-control --get-schedule 6 kesl-control -E --query -n 100 --reverse ``` -Очистите тестовый каталог после подтверждения реакции и заполните -`deployment/kesl/EVIDENCE.AVZ.ru.md`. +Очистите тестовый каталог после подтверждения реакции. Копию 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. Финальные проверки @@ -535,7 +626,7 @@ ufw status verbose Сначала минимально обратимое действие: ```sh -kesl-control --set-app-settings InterceptorProtectionMode=Notify +kesl-control --stop-task 1 ``` Если управление KESL не отвечает: diff --git a/VM1_app/codebase/backend/deployment/observability/otel-host-collector.yaml b/VM1_app/codebase/backend/deployment/observability/otel-host-collector.yaml new file mode 100644 index 0000000..80087ba --- /dev/null +++ b/VM1_app/codebase/backend/deployment/observability/otel-host-collector.yaml @@ -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: '^(?Pnginx|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 diff --git a/VM1_app/codebase/backend/deployment/preflight.sh b/VM1_app/codebase/backend/deployment/preflight.sh index aaae125..b7b5632 100644 --- a/VM1_app/codebase/backend/deployment/preflight.sh +++ b/VM1_app/codebase/backend/deployment/preflight.sh @@ -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" diff --git a/VM1_app/codebase/backend/deployment/scripts/setup-vm.sh b/VM1_app/codebase/backend/deployment/scripts/setup-vm.sh index a4f94e6..81f6313 100644 --- a/VM1_app/codebase/backend/deployment/scripts/setup-vm.sh +++ b/VM1_app/codebase/backend/deployment/scripts/setup-vm.sh @@ -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 diff --git a/VM1_app/codebase/backend/deployment/scripts/verify-observability.sh b/VM1_app/codebase/backend/deployment/scripts/verify-observability.sh index 9d591be..01bee9d 100644 --- a/VM1_app/codebase/backend/deployment/scripts/verify-observability.sh +++ b/VM1_app/codebase/backend/deployment/scripts/verify-observability.sh @@ -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 = + 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 diff --git a/VM1_app/codebase/backend/infra/compose/application.yml b/VM1_app/codebase/backend/infra/compose/application.yml index 61f3094..891dc3e 100644 --- a/VM1_app/codebase/backend/infra/compose/application.yml +++ b/VM1_app/codebase/backend/infra/compose/application.yml @@ -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} diff --git a/VM1_app/codebase/backend/nginx/docker-compose.yml b/VM1_app/codebase/backend/nginx/docker-compose.yml index 8ffd224..2877ad7 100644 --- a/VM1_app/codebase/backend/nginx/docker-compose.yml +++ b/VM1_app/codebase/backend/nginx/docker-compose.yml @@ -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 diff --git a/VM1_app/codebase/backend/observability/docker-compose.yml b/VM1_app/codebase/backend/observability/docker-compose.yml index d4670f1..85b2974 100644 --- a/VM1_app/codebase/backend/observability/docker-compose.yml +++ b/VM1_app/codebase/backend/observability/docker-compose.yml @@ -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"] diff --git a/VM1_app/codebase/backend/observability/otel-collector.yaml b/VM1_app/codebase/backend/observability/otel-collector.yaml index 3732d65..1d8a557 100644 --- a/VM1_app/codebase/backend/observability/otel-collector.yaml +++ b/VM1_app/codebase/backend/observability/otel-collector.yaml @@ -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} diff --git a/VM1_app/codebase/backend/redis/docker-compose.yml b/VM1_app/codebase/backend/redis/docker-compose.yml index 01d87e7..27e5b4f 100644 --- a/VM1_app/codebase/backend/redis/docker-compose.yml +++ b/VM1_app/codebase/backend/redis/docker-compose.yml @@ -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: diff --git a/VM1_app/codebase/backend/scripts/validate-env b/VM1_app/codebase/backend/scripts/validate-env index 0b0f253..b284a5f 100644 --- a/VM1_app/codebase/backend/scripts/validate-env +++ b/VM1_app/codebase/backend/scripts/validate-env @@ -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", diff --git a/VM1_app/codebase/backend/sms-service/app/logging_security.py b/VM1_app/codebase/backend/sms-service/app/logging_security.py index db055fb..e19c686 100644 --- a/VM1_app/codebase/backend/sms-service/app/logging_security.py +++ b/VM1_app/codebase/backend/sms-service/app/logging_security.py @@ -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) diff --git a/VM1_app/codebase/backend/sms-service/app/main.py b/VM1_app/codebase/backend/sms-service/app/main.py index b070b5d..f86d81d 100644 --- a/VM1_app/codebase/backend/sms-service/app/main.py +++ b/VM1_app/codebase/backend/sms-service/app/main.py @@ -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: diff --git a/VM1_app/codebase/backend/sms-service/app/telemetry.py b/VM1_app/codebase/backend/sms-service/app/telemetry.py index fbc0bde..0dcfb96 100644 --- a/VM1_app/codebase/backend/sms-service/app/telemetry.py +++ b/VM1_app/codebase/backend/sms-service/app/telemetry.py @@ -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") diff --git a/VM1_app/codebase/backend/sms-service/app/worker.py b/VM1_app/codebase/backend/sms-service/app/worker.py index 043030c..12cc3b4 100644 --- a/VM1_app/codebase/backend/sms-service/app/worker.py +++ b/VM1_app/codebase/backend/sms-service/app/worker.py @@ -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) diff --git a/VM1_app/codebase/backend/sms-service/tests/unit/test_telemetry.py b/VM1_app/codebase/backend/sms-service/tests/unit/test_telemetry.py index c597598..ef996d6 100644 --- a/VM1_app/codebase/backend/sms-service/tests/unit/test_telemetry.py +++ b/VM1_app/codebase/backend/sms-service/tests/unit/test_telemetry.py @@ -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") == [] diff --git a/VM1_app/codebase/backend/tests/test_config.py b/VM1_app/codebase/backend/tests/test_config.py index e3bd2a9..3d61a60 100644 --- a/VM1_app/codebase/backend/tests/test_config.py +++ b/VM1_app/codebase/backend/tests/test_config.py @@ -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 = ( diff --git a/VM1_app/documentation/module-09-observability-vm1.md b/VM1_app/documentation/module-09-observability-vm1.md index e16fe96..ad6a19d 100644 --- a/VM1_app/documentation/module-09-observability-vm1.md +++ b/VM1_app/documentation/module-09-observability-vm1.md @@ -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 diff --git a/VM4_Expo-mobile/.assets/icons/icon.png b/VM4_Expo-mobile/.assets/icons/icon.png index 5e6227d6c338af91d4c1886bcced466784372b89..9c915407c23be5de3ff516faa00402f8ad954e3c 100644 GIT binary patch literal 76013 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

VWql^vt0iGjN|^KM4+v8qIMyz?jen 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 z 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 BRj&X5 diff --git a/VM4_Expo-mobile/app.config.ts b/VM4_Expo-mobile/app.config.ts index d4b0404..6b4b9eb 100644 --- a/VM4_Expo-mobile/app.config.ts +++ b/VM4_Expo-mobile/app.config.ts @@ -13,6 +13,9 @@ const config: ExpoConfig = { package: "ru.han.chat", versionCode: 2, softwareKeyboardLayoutMode: "resize", + // Expo prebuild template injects this into every AAB. The app does not draw + // over other windows; RuStore/Play treat it as a sensitive overlay permission. + blockedPermissions: ["android.permission.SYSTEM_ALERT_WINDOW"], adaptiveIcon: { foregroundImage: "./.assets/icons/adaptive-foreground.png", monochromeImage: "./.assets/icons/adaptive-monochrome.png", diff --git a/architectory/arch-07-observability.md b/architectory/arch-07-observability.md index 0534720..36887ea 100644 --- a/architectory/arch-07-observability.md +++ b/architectory/arch-07-observability.md @@ -184,12 +184,35 @@ service: - `deployment.environment=production-like|production`; - `host.name`/`service.instance.id` без публичного IP. +Для logs действуют те же resource attributes, что для traces/metrics. +`trace_id` (32 hex) и `span_id` (16 hex) добавляются только из активного +OpenTelemetry span. SigNoz связывает log со span по этой паре. Host, Redis, +Docker и systemd события, у которых span отсутствует, коррелируются по +`host.name`, `service.name`, времени и безопасным request/business IDs; +синтетические trace/span IDs для них запрещены. + +На application VM используются два непересекающихся канала: + +- Python application logs отправляются OTLP Logs SDK в Compose Collector и + одновременно остаются в JSON stdout как локальный аварийный buffer; +- platform logs (nginx, Keycloak, Redis, Docker и выбранные systemd units) + собирает отдельный host agent без доступа к Docker socket. + +Один источник не может одновременно экспортироваться SDK и host `filelog`. +Allow-list источников и self-exclusion Collector обязательны, чтобы исключить +дубли и feedback loop. + Реестр `service.name`: | Имя | Владелец | |---|---| | `nginx` | nginx каждой VM; различать экземпляры `host.name` / `service.instance.id` | | `api-backend` | ВМ1 | +| `delivery-worker` | ВМ1 | +| `safety-recovery-worker` | ВМ1 | +| `cleanup-worker` | ВМ1 | +| `notification-expire-worker` | ВМ1 | +| `notification-draft-cleanup-worker` | ВМ1 | | `bitrix-local-app` | ВМ1 | | `keycloak` | ВМ1 | | `sms-service` | ВМ1, callback/internal API | @@ -198,6 +221,7 @@ service: | `bitrix-sync` | ВМ2 | | `redis` | Redis каждой VM; различать экземпляры | | `otel-collector` | collector каждой VM | +| `otel-host-collector` | host telemetry agent ВМ1 | Новое имя добавляется только сюда, затем в спецификацию владельца. diff --git a/support¬es/deploy_stories/260903_2.md b/support¬es/deploy_stories/260903_2.md index 23d60c0..62cf7a6 100644 --- a/support¬es/deploy_stories/260903_2.md +++ b/support¬es/deploy_stories/260903_2.md @@ -1,5 +1,9 @@ Поддержка версионирования приложений +# update images +API_BACKEND_IMAGE=cr.selcloud.ru/han-images/han-api-backend@sha256:ee1e1b94eb944c44eb60534aeb003f36636b1e9a3df4fbf82d5b1a3a955a5e34 +NGINX_IMAGE=cr.selcloud.ru/han-images/nginx@sha256:c64e5a6296ca183538306fef9ae49a3168637bd8a4c925884016da341691673f + # Копируем файл на ВМ1 (HAN_chat_specification\VM1_app\codebase\backend\deployment\app-settings.production-like.yaml) cd C:/Users/MI/Documents/Assistent/HAN_chat_specification/VM1_app/ @@ -38,4 +42,7 @@ editor /etc/han/vm1.env /usr/local/sbin/han-vm1-compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}" -# Сборка тестовой версии приложения \ No newline at end of file +# Сборка тестовой версии приложения +cd C:\Users\MI\Documents\Assistent\HAN_chat_specification\VM4_Expo-mobile +npx eas-cli build --profile preview-rustore --platform android +npx eas-cli build --profile rustore --platform android diff --git a/support¬es/deploy_stories/260904_1.md b/support¬es/deploy_stories/260904_1.md new file mode 100644 index 0000000..3ee2fcb --- /dev/null +++ b/support¬es/deploy_stories/260904_1.md @@ -0,0 +1,127 @@ +Логирование операций + +1. env: + +изменение Release_Version + + две переменные: +HOST_NAME=devhanapp \ prodhanapp.ihan.ru. +TELEMETRYGEN_IMAGE=ghcr.io/open-telemetry/opentelemetry-collector-contrib/telemetrygen@sha256:9461e2c7213219467e5f775ea8652d15c990afb4d1ca94e62344f2025a7587d1 + +2. Обновить образы: + +API_BACKEND_IMAGE=cr.selcloud.ru/han-images/han-api-backend@sha256:5ec8336ce56de41880398f68a2174530ba4dde9cd8ae6151c42f1631ab29bc35 +SMS_SERVICE_IMAGE=cr.selcloud.ru/han-images/sms-service@sha256:404685c45b7391a78f95c014ce8fdff64a84ccd7ae5b280e7a65b524c109d898 +BITRIX_LOCAL_APP_IMAGE=cr.selcloud.ru/han-images/bitrix-local-app@sha256:442889dc20e76f2a16d093adb4ae3ae38508535bafda82654755d1da50693183 +NGINX_IMAGE=cr.selcloud.ru/han-images/nginx@sha256:2cd7d32967b400c15bb6aaa18e97e99d429b2b6f2bfd54b965fc7d42a91a228e + +3. Скопировать проект: + +```powershell +$Release = "1.0.1" +tar --exclude=backend/.env ` + --exclude='backend/**/__pycache__' ` + --exclude='backend/**/.pytest_cache' ` + --exclude='backend/**/.ruff_cache' ` + -czf "vm1-backend-$Release.tar.gz" ` + -C C:/Users/MI/Documents/Assistent/HAN_chat_specification/VM1_app/codebase backend +Get-FileHash "vm1-backend-$Release.tar.gz" -Algorithm SHA256 +scp "vm1-backend-$Release.tar.gz" devVM1Deploy:/var/lib/han-deploy/incoming/ +``` + +RELEASE='1.0.1' +EXPECTED_SHA256='900C7AF2AEC061CFA112F82F8511A8A21F43EDB356EA3EF8C3205F49B6F1DF8F' +ARCHIVE="/var/lib/han-deploy/incoming/vm1-backend-${RELEASE}.tar.gz" +printf '%s %s/n' "$EXPECTED_SHA256" "$ARCHIVE" | sha256sum --check - +tar -tvzf "$ARCHIVE" +if tar -tzf "$ARCHIVE" | grep -Eq '(^/|(^|/)\.\.(/|$)|^backend/\.env$)'; then + echo 'unsafe path or .env' >&2; exit 1 +fi +if tar -tzf "$ARCHIVE" | grep -Ev '^backend(/|$)' | grep -q .; then + echo 'archive has files outside backend' >&2; exit 1 +fi +if tar -tvzf "$ARCHIVE" | awk '$1 ~ /^[lh]/ {found=1} END {exit !found}'; then + echo 'symlink/hardlink is forbidden' >&2; exit 1 +fi + +TARGET="/opt/han-chat/releases/${RELEASE}" +test ! -e "$TARGET" +install -d -m 0755 -o root -g root "$TARGET" +tar --extract --gzip --file "$ARCHIVE" --directory "$TARGET" \ + --no-same-owner --no-same-permissions +test -f "$TARGET/backend/docker-compose.yml" +chmod 0755 \ + "$TARGET/backend/deployment/scripts/setup-vm.sh" \ + "$TARGET/backend/deployment/preflight.sh" \ + "$TARGET/backend/deployment/scripts/tls-deploy-hook.sh" \ + "$TARGET/backend/deployment/secrets/han-compose" \ + "$TARGET/backend/deployment/secrets/han-secrets" +test -x "$TARGET/backend/deployment/scripts/setup-vm.sh" +test -x "$TARGET/backend/deployment/preflight.sh" +test -x "$TARGET/backend/deployment/scripts/tls-deploy-hook.sh" +test -x "$TARGET/backend/deployment/secrets/han-compose" +test -x "$TARGET/backend/deployment/secrets/han-secrets" +if find "$TARGET/backend" -type l -print -quit | grep -q .; then exit 1; fi +chown -R root:root "$TARGET" +chmod -R go-w "$TARGET" +ln -s "releases/${RELEASE}" /opt/han-chat/.current-new +mv -Tf /opt/han-chat/.current-new /opt/han-chat/current + +cd /opt/han-chat/current/backend/ +sudo apt update +sudo apt install -y dos2unix +find . -type f \( \ + -name '*.sh' -o -name '*.py' -o -name '*.service' -o -name '*.sudoers' -o -name 'han-compose' -o -name 'han-secrets' -o -name 'han-message-safety-mode' -o -name '*.yml' -o -name '*.conf*' -o -name 'preflight.sh' -o -name '*.yaml' -o -name '*.md' -o -name '*.toml' -o -name 'Dockerfile' -o -name '*.acl*' -o -name '*.tsx' -o -name '*.ts' -o -name '*.java' -o -name '*.js' -o -name '*.ps1' -o -name '*.xml' -o -name '*.dockerignore' -o -name '*.env*' -o -name '*.css' -o -name '*.ftl' -o -name '*.TAG' -o -name '*validate-env*' -o -name '*.properties' -o -name '*org.keycloak.*Factory' -o -name '*.html' \ +\) -exec dos2unix {} + +find -type f -exec file {} \; | grep -i 'CRLF' + + +sudo apt remove -y dos2unix +sudo apt purge -y dos2unix + + +/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-api alembic current +/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-bitrix-local alembic current + +/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-api +/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-bitrix-local + +Проверка применения миграции +/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-bitrix-local alembic current +/usr/local/sbin/han-vm1-compose --profile ops run --rm migrate-bitrix-local alembic heads + + +scp ` + C:/Users/MI/.ssh/devDeploy.pub ` + C:/Users/MI/.ssh/devAdmin.pub ` +devVM1Deploy:/var/lib/han-deploy/incoming/ + +install -m 0600 -o root -g root /var/lib/han-deploy/incoming/devDeploy.pub /root/bootstrap/deploy.pub +install -m 0600 -o root -g root /var/lib/han-deploy/incoming/devAdmin.pub /root/bootstrap/admin.pub + +rm -f /var/lib/han-deploy/incoming/devDeploy.pub /var/lib/han-deploy/incoming/devAdmin.pub + +cd /opt/han-chat/current/backend + +export OTEL_HOST_COLLECTOR_VERSION='0.117.0' +export OTEL_HOST_COLLECTOR_SHA256='90710c909a30fc3b89dd0e389c13f9e57ebbb87d6a0dc51fdde0bf03499a5f25' +DEPLOY_AUTHORIZED_KEY_FILE=/root/bootstrap/deploy.pub \ +ADMIN_AUTHORIZED_KEY_FILE=/root/bootstrap/admin.pub \ +HARDEN_SSH=true SKIP_APT_UPGRADE=true \ +/opt/han-chat/current/backend/deployment/scripts/setup-vm.sh + +/usr/local/bin/otelcol-contrib validate \ + --config=/opt/han-chat/current/backend/deployment/observability/otel-host-collector.yaml + +chmod 0755 /opt/han-chat/current/backend/scripts/validate-env +/opt/han-chat/current/backend/deployment/preflight.sh + +systemctl restart han-stack@production.service +если ошибка systemctl status han-stack@production.service +docker pull cr.selcloud.ru/han-images/nginx@sha256:2cd7d32967b400c15bb6aaa18e97e99d429b2b6f2bfd54b965fc7d42a91a228e +docker pull cr.selcloud.ru/han-images/sms-service@sha256:404685c45b7391a78f95c014ce8fdff64a84ccd7ae5b280e7a65b524c109d898 + +systemctl start han-host-otel-collector@production.service + +Canary test: CONFIG_FILE=/etc/han/vm1.env ./deployment/scripts/verify-observability.sh + +rm -f /root/bootstrap/deploy.pub /root/bootstrap/admin.pub \ No newline at end of file