diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md index 0695046..7b241e1 100644 --- a/architectory/arch-04-settings-and-content.md +++ b/architectory/arch-04-settings-and-content.md @@ -152,16 +152,18 @@ LOG_LEVEL=INFO # Managed PostgreSQL # ============================================================================= HAN_PG_HOST= -HAN_PG_PORT=6432 +HAN_PG_PORT=5433 HAN_PG_DATABASE=han_chat -DATABASE_URL=postgresql+asyncpg://han_app:change-me@:/?options=-csearch_path%3Dhan_app -BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@:/?options=-csearch_path%3Dbitrix_local -BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@:/?options=-csearch_path%3Dbitrix_sync%2Chan_app -BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@:/?options=-csearch_path%3Dbitrix_sync -MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@:/?options=-csearch_path%3Dmessage_safety +DATABASE_URL=postgresql+asyncpg://han_app:change-me@:/ +BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@:/ +BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@:/ +BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@:/ +MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@:/ KEYCLOAK_DB_URL=jdbc:postgresql://:/?user=keycloak_user&password=change-me¤tSchema=keycloak KC_DB_URL_PROPERTIES=currentSchema=keycloak +# Selectel PgBouncer 5433: pool_mode=session; search_path задаётся на уровне ролей. +# Не добавлять options=-csearch_path: pooler отклоняет этот startup parameter. # ============================================================================= # Публичные URL (HTTPS) diff --git a/codebase/backend/.env.example b/codebase/backend/.env.example index 7ce1526..59c518d 100644 --- a/codebase/backend/.env.example +++ b/codebase/backend/.env.example @@ -13,15 +13,17 @@ KEYCLOAK_IMAGE=han-chat-keycloak:local # Managed PostgreSQL is external to Compose. All production DSNs must verify TLS. HAN_PG_HOST=managed-pg.private.example -HAN_PG_PORT=6432 +HAN_PG_PORT=5433 HAN_PG_DATABASE=han_chat PG_CA_HOST_PATH=/opt/han-chat/secrets/pg/ca.pem -DATABASE_URL=postgresql+asyncpg://han_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dhan_app -BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_local -BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_sync%2Chan_app -BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_sync -MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@managed-pg.private.example:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dmessage_safety -KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:6432/han_chat?currentSchema=keycloak&sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +# PgBouncer session mode uses each role's database-level search_path; do not pass libpq options. +DATABASE_URL=postgresql+asyncpg://han_app:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +KEYCLOAK_DB_SCHEMA=keycloak KEYCLOAK_DB_USERNAME=keycloak_user KEYCLOAK_DB_PASSWORD=change-me @@ -48,9 +50,10 @@ NGINX_MESSAGE_READ_TIMEOUT_SEC=330 NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32 TRUSTED_PROXY_CIDRS=172.16.0.0/12 BITRIX_FRAME_ANCESTORS=https://*.bitrix24.ru -S3_CONNECT_SRC=https://s3.storage.selcloud.ru +S3_CONNECT_SRC=https://*.s3.ru-7.storage.selcloud.ru FRONTEND_DEV_PROXY_ENABLED=false +# При false значение не используется EXPO_DEV_SERVER_URL=http://host.docker.internal:8081 ACME_EMAIL=ops@example.ru @@ -58,33 +61,46 @@ KEYCLOAK_PUBLIC_URL=https://chat.example.ru/auth KEYCLOAK_INTERNAL_URL=http://keycloak:8080/auth KEYCLOAK_REALM=han-chat KEYCLOAK_AUDIENCE=han-chat-api +# На мок среде true,true. На продакшн false,false. KEYCLOAK_OTP_MOCK_ENABLED=true KEYCLOAK_OTP_MOCK_CODE=change-me KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false +# (openssl rand -hex 32) KEYCLOAK_OTP_HMAC_KEY=change-me KEYCLOAK_OTP_TTL_SEC=300 KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5 KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300 KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp KEYCLOAK_ADMIN=bootstrap-admin +# (openssl rand -hex 32) KEYCLOAK_ADMIN_PASSWORD=change-me +# (openssl rand -hex 32) CURSOR_HMAC_SECRET=change-me +# Redis пароль 1 (openssl rand -hex 32) REDIS_API_PASSWORD=change-me -REDIS_SAFETY_PASSWORD=change-me -REDIS_HEALTH_PASSWORD=change-me REDIS_URL=redis://api_backend:change-me@redis:6379/0 REDIS_REALTIME_URL=redis://api_backend:change-me@redis:6379/1 +# Redis пароль 2 (openssl rand -hex 32) +REDIS_SAFETY_PASSWORD=change-me MESSAGE_SAFETY_REDIS_URL=redis://message_safety:change-me@redis:6379/2 +# Redis пароль 3 (openssl rand -hex 32) +REDIS_HEALTH_PASSWORD=change-me + REDIS_MAXMEMORY=384mb REDIS_EVICTION_POLICY=volatile-lru +#token1 (openssl rand -hex 32) MESSAGE_SAFETY_SERVICE_TOKEN=change-me +#token2 (openssl rand -hex 32) BITRIX_LOCAL_APP_INTERNAL_TOKEN=change-me BITRIX_INTERNAL_API_TOKEN=change-me +#token3 (openssl rand -hex 32) BITRIX_API_FORWARD_TOKEN=change-me BITRIX_API_INBOX_TOKEN=change-me +#token4 (openssl rand -hex 32) BITRIX_SYNC_SERVICE_TOKEN=change-me +#token5 (openssl rand -hex 32) KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080 @@ -97,6 +113,7 @@ MESSAGE_SAFETY_TASK_POLL_MAX_SEC=300 MESSAGE_SAFETY_TASK_TTL_SEC=900 MESSAGE_SAFETY_FILE_SCAN_TIMEOUT_SEC=60 MESSAGE_SAFETY_RULES_VERSION=2026-01-01 +#Отключение синхронизации (при отключенной синхронизации параметры ниже не работают) BITRIX_SYNC_ENABLED=false BITRIX_SYNC_CRM_BASE_URL=https://example.bitrix24.ru BITRIX_SYNC_CRM_WEBHOOK_URL=change-me @@ -104,19 +121,26 @@ BITRIX_SYNC_CONTACT_MAP_INTERVAL_SEC=60 BITRIX_SYNC_CONTACT_UPDATE_INTERVAL_SEC=30 BITRIX_SYNC_CRM_MAX_CONCURRENCY=2 BITRIX_SYNC_CONTACT_LIST_BATCH_SIZE=50 +# (openssl rand -hex 32) BITRIX_SYNC_WEBHOOK_TOKEN=change-me + +# Сначала регистрируется приложение в Битрикс24, потом тут прописываются секреты BITRIX_CLIENT_ID=change-me BITRIX_CLIENT_SECRET=change-me +BITRIX_APPLICATION_TOKEN=change-me + BITRIX_CONNECTOR_ID=han_mobile_app BITRIX_CONNECTOR_NAME=HAN Mobile App -BITRIX_OPEN_LINE_ID=8 +BITRIX_OPEN_LINE_ID= +BITRIX_EXPECTED_DOMAIN=<ваш-портал>.bitrix24.ru BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix -BITRIX_APPLICATION_TOKEN=change-me + +# ENCRYPTION_KEY обязательно через base64 # Generate with: python -c "import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())" BITRIX_TOKEN_ENCRYPTION_KEY=change-me BITRIX_HTTP_TIMEOUT_SEC=10 -SELECTEL_S3_ENDPOINT_URL=https://s3.storage.selcloud.ru +SELECTEL_S3_ENDPOINT_URL=https://s3.ru-7.storage.selcloud.ru SELECTEL_S3_BUCKET_DOCUMENTS=han-chat-documents SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine @@ -126,7 +150,12 @@ SELECTEL_S3_QUARANTINE_READ_ACCESS_KEY=change-me SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 +#Если есть внешний OTLP-сервис, замените (Точный формат авторизации зависит от провайдера): +#Если внешнего OTLP-сервиса пока нет, otlp.example.invalid:4317 можно временно оставить, но Collector будет постоянно пытаться подключиться, писать предупреждения и накапливать очередь. OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317 OTEL_REMOTE_AUTH_HEADER=change-me +# Сейчас соответствуют 10% трассировок и очереди 10000 +# Также обнаружена особенность проекта: OTEL_TRACES_SAMPLER_ARG и OTEL_QUEUE_SIZE сейчас фактически не подставляются в конфигурацию — там жёстко установлены 10% и 10000. Поэтому менять эти две переменные пока бессмысленно. OTEL_TRACES_SAMPLER_ARG=0.10 OTEL_QUEUE_SIZE=10000 + diff --git a/codebase/backend/api-backend/alembic/env.py b/codebase/backend/api-backend/alembic/env.py index b3a311d..6eb1636 100644 --- a/codebase/backend/api-backend/alembic/env.py +++ b/codebase/backend/api-backend/alembic/env.py @@ -2,16 +2,17 @@ import asyncio from logging.config import fileConfig from sqlalchemy import pool -from sqlalchemy.ext.asyncio import async_engine_from_config from alembic import context from app.db import Base +from app.postgres import create_postgres_engine from app.settings import get_settings config = context.config if config.config_file_name: fileConfig(config.config_file_name) -config.set_main_option("sqlalchemy.url", get_settings().database_url) +database_url = get_settings().database_url +config.set_main_option("sqlalchemy.url", database_url.replace("%", "%%")) target_metadata = Base.metadata @@ -28,11 +29,7 @@ def do_run_migrations(connection) -> None: async def run_async_migrations() -> None: - connectable = async_engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) + connectable = create_postgres_engine(database_url, poolclass=pool.NullPool) async with connectable.connect() as connection: await connection.run_sync(do_run_migrations) await connectable.dispose() diff --git a/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py b/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py index 2922ba3..e38a13b 100644 --- a/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py +++ b/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py @@ -7,6 +7,7 @@ Create Date: 2026-07-10 from collections.abc import Sequence +import sqlalchemy as sa from alembic import op from app.db import Base @@ -75,12 +76,18 @@ def upgrade() -> None: CREATE UNIQUE INDEX IF NOT EXISTS uq_dialog_one_active_per_user ON han_app.dialogs(user_id) WHERE record_status='A' - AND status IN ('open','waiting_for_company','waiting_for_client'); - + AND status IN ('open','waiting_for_company','waiting_for_client') + """ + ) + op.execute( + """ CREATE UNIQUE INDEX IF NOT EXISTS uq_profiles_active_bitrix_contact ON han_app.client_profiles(bitrix_contact_id) - WHERE bitrix_contact_id IS NOT NULL AND record_status='A'; - + WHERE bitrix_contact_id IS NOT NULL AND record_status='A' + """ + ) + op.execute( + """ CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync() RETURNS trigger LANGUAGE plpgsql @@ -116,36 +123,48 @@ def upgrade() -> None: ON CONFLICT (dedup_key) DO NOTHING; RETURN NEW; END; - $$; - - DROP TRIGGER IF EXISTS trg_identity_contact_sync ON han_app.user_identities; + $$ + """ + ) + op.execute( + "DROP TRIGGER IF EXISTS trg_identity_contact_sync ON han_app.user_identities" + ) + op.execute( + """ CREATE TRIGGER trg_identity_contact_sync AFTER INSERT OR UPDATE OF phone_number, record_status ON han_app.user_identities - FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync(); - - DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles; + FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync() + """ + ) + op.execute( + "DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles" + ) + op.execute( + """ CREATE TRIGGER trg_profile_contact_sync AFTER INSERT OR UPDATE OF full_name, citizenship, russian_phone, foreign_phone, email, record_status ON han_app.client_profiles - FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync(); + FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync() """ ) for key, (value, value_type, public) in SEED.items(): - bind.exec_driver_sql( - """ + bind.execute( + sa.text( + """ INSERT INTO han_app.app_settings (setting_key, setting_value, value_type, is_public, record_status, updated_at) - VALUES (%s, %s, %s, %s, 'A', now()) + VALUES (:key, :value, :value_type, :public, 'A', now()) ON CONFLICT (setting_key) DO UPDATE SET setting_value = EXCLUDED.setting_value, value_type = EXCLUDED.value_type, is_public = EXCLUDED.is_public, record_status = 'A', updated_at = now() - """, - (key, value, value_type, public), + """ + ), + {"key": key, "value": value, "value_type": value_type, "public": public}, ) diff --git a/codebase/backend/api-backend/app/db.py b/codebase/backend/api-backend/app/db.py index 2d539bc..ebe9c07 100644 --- a/codebase/backend/api-backend/app/db.py +++ b/codebase/backend/api-backend/app/db.py @@ -22,10 +22,11 @@ from sqlalchemy.ext.asyncio import ( AsyncEngine, AsyncSession, async_sessionmaker, - create_async_engine, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from app.postgres import create_postgres_engine + SCHEMA = "han_app" @@ -347,7 +348,7 @@ class EntityExternalMapping(Base): class Database: def __init__(self, url: str) -> None: - self.engine: AsyncEngine = create_async_engine(url, pool_pre_ping=True) + self.engine: AsyncEngine = create_postgres_engine(url, pool_pre_ping=True) self.sessions = async_sessionmaker(self.engine, expire_on_commit=False) async def session(self) -> AsyncIterator[AsyncSession]: diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py index 5d83144..b19b8e4 100644 --- a/codebase/backend/api-backend/app/main.py +++ b/codebase/backend/api-backend/app/main.py @@ -101,7 +101,7 @@ async def refresh_settings_cache(app: FastAPI) -> None: async with app.state.db.sessions() as db: app.state.snapshot = await load_settings(db) except Exception: - log.warning("settings.refresh_failed", event="settings.refresh_failed") + log.warning("settings.refresh_failed") await asyncio.sleep(30) @@ -126,12 +126,12 @@ async def lifespan(app: FastAPI): async with app.state.db.sessions() as db: app.state.snapshot = await load_settings(db) except Exception: - structlog.get_logger().warning("settings_warmup_failed", event="settings.warmup_failed") + structlog.get_logger().warning("settings.warmup_failed") settings_task = asyncio.create_task(refresh_settings_cache(app)) try: await app.state.jwks.refresh() except Exception: - structlog.get_logger().warning("jwks_warmup_failed", event="jwks.warmup_failed") + structlog.get_logger().warning("jwks.warmup_failed") yield settings_task.cancel() with suppress(asyncio.CancelledError): @@ -211,7 +211,6 @@ async def request_context(request: Request, call_next: Any) -> Response: response.headers["Cache-Control"] = response.headers.get("Cache-Control", "no-store") log.info( "request.complete", - event="request.complete", status_code=response.status_code, duration_ms=round((time.monotonic() - request.state.started_at) * 1000, 2), ) @@ -262,7 +261,7 @@ async def http_error(request: Request, exc: StarletteHTTPException): @app.exception_handler(Exception) async def unhandled_error(request: Request, exc: Exception): - log.exception("request.failed", event="request.failed", error_code="internal_error") + log.exception("request.failed", error_code="internal_error") return error_response(request, "internal_error", "Internal server error", 500) @@ -622,7 +621,7 @@ async def dialogs_create( }, ) except Exception: - log.warning("idempotency.cache_write_failed", event="idempotency.cache_write_failed") + log.warning("idempotency.cache_write_failed") return JSONResponse(json.loads(json.dumps(body, default=str)), status_code=status) @@ -792,7 +791,7 @@ async def message_create( }, ) except Exception: - log.warning("idempotency.cache_write_failed", event="idempotency.cache_write_failed") + log.warning("idempotency.cache_write_failed") return result diff --git a/codebase/backend/api-backend/app/postgres.py b/codebase/backend/api-backend/app/postgres.py new file mode 100644 index 0000000..5520052 --- /dev/null +++ b/codebase/backend/api-backend/app/postgres.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +import asyncpg +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + + +def asyncpg_dsn(url: str) -> str: + if url.startswith("postgresql+asyncpg://"): + return url.replace("postgresql+asyncpg://", "postgresql://", 1) + return url + + +def create_postgres_engine(url: str, **engine_options: Any) -> AsyncEngine: + dsn = asyncpg_dsn(url) + + async def connect(): + return await asyncpg.connect(dsn=dsn) + + return create_async_engine( + "postgresql+asyncpg://", + async_creator=connect, + **engine_options, + ) diff --git a/codebase/backend/api-backend/tests/unit/test_domain.py b/codebase/backend/api-backend/tests/unit/test_domain.py index ca873d9..bcd8979 100644 --- a/codebase/backend/api-backend/tests/unit/test_domain.py +++ b/codebase/backend/api-backend/tests/unit/test_domain.py @@ -5,6 +5,7 @@ from pydantic import TypeAdapter, ValidationError from app.auth import canonical_phone from app.integrations import CircuitBreaker, RateLimiter +from app.postgres import asyncpg_dsn from app.schemas import ( FileMessageRequest, MessageRequest, @@ -15,6 +16,15 @@ from app.schemas import ( ) +def test_asyncpg_receives_libpq_dsn_without_sqlalchemy_driver() -> None: + url = ( + "postgresql+asyncpg://user:password@db:5433/han_chat" + "?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem" + ) + + assert asyncpg_dsn(url) == url.replace("postgresql+asyncpg://", "postgresql://", 1) + + def test_phone_claim_priority_and_e164_validation() -> None: claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"} assert canonical_phone(claims) == "+74999591007" diff --git a/codebase/backend/bitrix-local-app/alembic/env.py b/codebase/backend/bitrix-local-app/alembic/env.py index 2763849..2df3cdf 100644 --- a/codebase/backend/bitrix-local-app/alembic/env.py +++ b/codebase/backend/bitrix-local-app/alembic/env.py @@ -2,13 +2,13 @@ import asyncio import os from alembic import context -from sqlalchemy.ext.asyncio import async_engine_from_config from app.models import Base +from app.postgres import create_postgres_engine config = context.config -url = os.environ["BITRIX_DATABASE_URL"].replace("postgresql://", "postgresql+asyncpg://", 1) -config.set_main_option("sqlalchemy.url", url) +url = os.environ["BITRIX_DATABASE_URL"] +config.set_main_option("sqlalchemy.url", url.replace("%", "%%")) target_metadata = Base.metadata @@ -30,7 +30,7 @@ def do_run(connection) -> None: async def run_online() -> None: - engine = async_engine_from_config(config.get_section(config.config_ini_section) or {}) + engine = create_postgres_engine(url) async with engine.connect() as connection: await connection.run_sync(do_run) await engine.dispose() diff --git a/codebase/backend/bitrix-local-app/app/main.py b/codebase/backend/bitrix-local-app/app/main.py index efc2ab6..b2e3f8d 100644 --- a/codebase/backend/bitrix-local-app/app/main.py +++ b/codebase/backend/bitrix-local-app/app/main.py @@ -24,7 +24,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy import func, or_, select, text from sqlalchemy.exc import IntegrityError -from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from app.models import ( ConnectorSetup, @@ -36,6 +36,7 @@ from app.models import ( PortalInstallation, now, ) +from app.postgres import create_postgres_engine logger = logging.getLogger("bitrix-local-app") @@ -135,10 +136,6 @@ class OutboundDto(BaseModel): message: MessageDto -def pg_url(value: str) -> str: - return value.replace("postgresql://", "postgresql+asyncpg://", 1) - - def canonical_fingerprint(value: dict[str, Any]) -> str: clean = json.loads(json.dumps(value, sort_keys=True, default=str)) for file in clean.get("message", {}).get("files", []): @@ -416,8 +413,8 @@ def create_app(settings: Settings | None = None) -> FastAPI: @asynccontextmanager async def lifespan(app: FastAPI): - engine = create_async_engine( - pg_url(cfg.bitrix_database_url), + engine = create_postgres_engine( + cfg.bitrix_database_url, pool_size=5, max_overflow=0, pool_pre_ping=True, diff --git a/codebase/backend/bitrix-local-app/app/postgres.py b/codebase/backend/bitrix-local-app/app/postgres.py new file mode 100644 index 0000000..5520052 --- /dev/null +++ b/codebase/backend/bitrix-local-app/app/postgres.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from typing import Any + +import asyncpg +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + + +def asyncpg_dsn(url: str) -> str: + if url.startswith("postgresql+asyncpg://"): + return url.replace("postgresql+asyncpg://", "postgresql://", 1) + return url + + +def create_postgres_engine(url: str, **engine_options: Any) -> AsyncEngine: + dsn = asyncpg_dsn(url) + + async def connect(): + return await asyncpg.connect(dsn=dsn) + + return create_async_engine( + "postgresql+asyncpg://", + async_creator=connect, + **engine_options, + ) diff --git a/codebase/backend/bitrix-sync/alembic/env.py b/codebase/backend/bitrix-sync/alembic/env.py index 16b454e..0a64091 100644 --- a/codebase/backend/bitrix-sync/alembic/env.py +++ b/codebase/backend/bitrix-sync/alembic/env.py @@ -2,10 +2,14 @@ import asyncio import os from alembic import context -from sqlalchemy.ext.asyncio import async_engine_from_config +from app.postgres import create_postgres_engine config = context.config -config.set_main_option("sqlalchemy.url", os.environ["BITRIX_SYNC_DATABASE_URL"]) +database_url = os.environ["BITRIX_SYNC_DATABASE_URL"] +config.set_main_option( + "sqlalchemy.url", + database_url.replace("%", "%%"), +) target_metadata = None @@ -16,7 +20,7 @@ def run_offline() -> None: async def run_online() -> None: - engine = async_engine_from_config(config.get_section(config.config_ini_section) or {}) + engine = create_postgres_engine(database_url) async with engine.connect() as connection: await connection.run_sync(do_run) await engine.dispose() diff --git a/codebase/backend/bitrix-sync/app/main.py b/codebase/backend/bitrix-sync/app/main.py index 890407b..ad22f27 100644 --- a/codebase/backend/bitrix-sync/app/main.py +++ b/codebase/backend/bitrix-sync/app/main.py @@ -16,7 +16,9 @@ from fastapi.responses import JSONResponse from pydantic import Field, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine +from sqlalchemy.ext.asyncio import AsyncEngine + +from app.postgres import create_postgres_engine def utcnow() -> datetime: @@ -79,12 +81,6 @@ class DatabaseProbe: await self.engine.dispose() -def database_url(value: str) -> str: - if value.startswith("postgresql://"): - return value.replace("postgresql://", "postgresql+asyncpg://", 1) - return value - - def classify_error(exc: Exception) -> str: name = type(exc).__name__.lower() text_value = str(exc).lower() @@ -119,13 +115,13 @@ def create_app(settings: Settings | None = None, probe: Probe | None = None) -> app.state.loop_task = None if cfg.bitrix_sync_enabled: if app.state.probe is None: - engine = create_async_engine( - database_url(cfg.bitrix_sync_database_url or ""), + engine = create_postgres_engine( + cfg.bitrix_sync_database_url or "", pool_size=cfg.bitrix_sync_db_pool_size, max_overflow=0, pool_pre_ping=True, pool_recycle=cfg.bitrix_sync_db_pool_recycle_sec, - connect_args={"server_settings": {"application_name": "han-bitrix-sync"}}, + server_settings={"application_name": "han-bitrix-sync"}, ) app.state.probe = DatabaseProbe(engine) await run_probe(app) diff --git a/codebase/backend/bitrix-sync/app/postgres.py b/codebase/backend/bitrix-sync/app/postgres.py new file mode 100644 index 0000000..33ce24a --- /dev/null +++ b/codebase/backend/bitrix-sync/app/postgres.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from typing import Any + +import asyncpg +from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine + + +def asyncpg_dsn(url: str) -> str: + if url.startswith("postgresql+asyncpg://"): + return url.replace("postgresql+asyncpg://", "postgresql://", 1) + return url + + +def create_postgres_engine( + url: str, + *, + server_settings: dict[str, str] | None = None, + **engine_options: Any, +) -> AsyncEngine: + dsn = asyncpg_dsn(url) + + async def connect(): + return await asyncpg.connect(dsn=dsn, server_settings=server_settings) + + return create_async_engine( + "postgresql+asyncpg://", + async_creator=connect, + **engine_options, + ) diff --git a/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md b/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md index 518e2b6..6a886da 100644 --- a/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md +++ b/codebase/backend/deployment/DEPLOYMENT_GUIDE.ru.md @@ -300,16 +300,23 @@ HAN_PG_PORT=6432 HAN_PG_DATABASE=han_chat PG_CA_HOST_PATH=/opt/han-chat/backend/secrets/pg/ca.pem -DATABASE_URL=postgresql+asyncpg://han_app:@:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dhan_app -BITRIX_DATABASE_URL=postgresql://bitrix_local_app:@:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_local -BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:@:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_sync -MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:@:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dmessage_safety +DATABASE_URL=postgresql+asyncpg://han_app:@:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +BITRIX_DATABASE_URL=postgresql://bitrix_local_app:@:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:@:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:@:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem -KEYCLOAK_DB_URL=jdbc:postgresql://:6432/han_chat?currentSchema=keycloak&sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +KEYCLOAK_DB_URL=jdbc:postgresql://:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem +KEYCLOAK_DB_SCHEMA=keycloak KEYCLOAK_DB_USERNAME=keycloak_user KEYCLOAK_DB_PASSWORD= ``` +Порт `5433` используется с PgBouncer в режиме `session`. Не добавляйте +`options=-csearch_path...` или JDBC-параметр `currentSchema`: они передают +startup parameter `search_path`, который Selectel PgBouncer отклоняет. Для +Keycloak схема задаётся отдельно через `KEYCLOAK_DB_SCHEMA`. +Для каждой сервисной роли заранее задайте database-level `search_path`. + Если пароль содержит `@`, `:`, `/`, `?`, `#` или `%`, его необходимо URL-кодировать внутри PostgreSQL URL. diff --git a/codebase/backend/deployment/docker-compose.jobs.yml b/codebase/backend/deployment/docker-compose.jobs.yml index 805c9bb..7edeb51 100644 --- a/codebase/backend/deployment/docker-compose.jobs.yml +++ b/codebase/backend/deployment/docker-compose.jobs.yml @@ -9,7 +9,7 @@ services: command: ["alembic", "upgrade", "head"] volumes: - ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro - networks: [backend] + networks: [backend, egress] restart: "no" security_opt: ["no-new-privileges:true"] @@ -23,7 +23,7 @@ services: command: ["alembic", "upgrade", "head"] volumes: - ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro - networks: [backend] + networks: [backend, egress] restart: "no" security_opt: ["no-new-privileges:true"] @@ -37,7 +37,7 @@ services: command: ["alembic", "upgrade", "head"] volumes: - ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro - networks: [backend] + networks: [backend, egress] restart: "no" security_opt: ["no-new-privileges:true"] @@ -58,7 +58,7 @@ services: volumes: - ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro - ./app-settings.production-like.yaml:/deployment/app-settings.production-like.yaml:ro - networks: [backend] + networks: [backend, egress] restart: "no" security_opt: ["no-new-privileges:true"] @@ -66,7 +66,7 @@ services: image: curlimages/curl:8.11.1 profiles: ["ops"] entrypoint: ["sleep", "infinity"] - networks: [backend, observability] + networks: [backend, observability, egress] restart: "no" cap_drop: ["ALL"] security_opt: ["no-new-privileges:true"] diff --git a/codebase/backend/deployment/scripts/smoke.sh b/codebase/backend/deployment/scripts/smoke.sh index 119f10c..b9496b7 100644 --- a/codebase/backend/deployment/scripts/smoke.sh +++ b/codebase/backend/deployment/scripts/smoke.sh @@ -4,9 +4,32 @@ cd "$(dirname "$0")/../.." ENV_FILE=${ENV_FILE:-.env} ./scripts/validate-env "$ENV_FILE" -set -a -. "./$ENV_FILE" -set +a + +env_value() { + python3 - "$ENV_FILE" "$1" <<'PY' +import sys +from pathlib import Path + +path, wanted = sys.argv[1:] +for raw in Path(path).read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + if key.strip() == wanted: + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + print(value) + break +else: + raise SystemExit(f"missing environment variable: {wanted}") +PY +} + +PUBLIC_HOST=$(env_value PUBLIC_HOST) +PUBLIC_WEB_URL=$(env_value PUBLIC_WEB_URL) +KEYCLOAK_REALM=$(env_value KEYCLOAK_REALM) tmp=$(mktemp -d) trap 'rm -rf "$tmp"' EXIT diff --git a/codebase/backend/docker-compose.yml b/codebase/backend/docker-compose.yml index 440addc..d16c2cc 100644 --- a/codebase/backend/docker-compose.yml +++ b/codebase/backend/docker-compose.yml @@ -13,6 +13,8 @@ networks: backend: name: han-chat-backend internal: true + egress: + name: han-chat-egress observability: name: han-chat-observability internal: true diff --git a/codebase/backend/frontend-test-site/src/auth.ts b/codebase/backend/frontend-test-site/src/auth.ts index 65f0da8..da3502a 100644 --- a/codebase/backend/frontend-test-site/src/auth.ts +++ b/codebase/backend/frontend-test-site/src/auth.ts @@ -40,7 +40,7 @@ const secureStore = { }; const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", ""); -const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" }); +const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "oauth/callback" }); const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`; export function configureAuthFailure(callback: () => void) { @@ -89,7 +89,7 @@ export async function beginAuthorization() { client_id: env.clientId, redirect_uri: redirectUri, response_type: "code", - scope: "openid profile offline_access", + scope: "openid offline_access", code_challenge: challenge, code_challenge_method: "S256", state, diff --git a/codebase/backend/frontend-test-site/src/config.ts b/codebase/backend/frontend-test-site/src/config.ts index e58646f..888f806 100644 --- a/codebase/backend/frontend-test-site/src/config.ts +++ b/codebase/backend/frontend-test-site/src/config.ts @@ -1,9 +1,12 @@ -const required = (name: string, fallback: string) => - (process.env[name] ?? fallback).replace(/\/$/, ""); +const required = (value: string | undefined, fallback: string) => + (value ?? fallback).replace(/\/$/, ""); export const env = Object.freeze({ - apiBaseUrl: required("EXPO_PUBLIC_API_BASE_URL", "http://localhost:8000"), - authBaseUrl: required("EXPO_PUBLIC_AUTH_BASE_URL", "http://localhost:8080/auth"), + apiBaseUrl: required(process.env.EXPO_PUBLIC_API_BASE_URL, "http://localhost:8000"), + authBaseUrl: required( + process.env.EXPO_PUBLIC_AUTH_BASE_URL, + "http://localhost:8080/auth", + ), realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat", clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend", appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development", diff --git a/codebase/backend/infra/compose/application.yml b/codebase/backend/infra/compose/application.yml index b5efba8..b47b4c5 100644 --- a/codebase/backend/infra/compose/application.yml +++ b/codebase/backend/infra/compose/application.yml @@ -10,7 +10,7 @@ x-api-runtime: &api-runtime source: ${PG_CA_HOST_PATH} target: /run/secrets/pg-ca.pem read_only: true - networks: [backend, observability] + networks: [backend, observability, egress] security_opt: ["no-new-privileges:true"] logging: driver: json-file @@ -47,6 +47,7 @@ services: environment: KC_DB: postgres KC_DB_URL: ${KEYCLOAK_DB_URL} + KC_DB_SCHEMA: ${KEYCLOAK_DB_SCHEMA:-keycloak} KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME} KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD} KC_PROXY_HEADERS: xforwarded @@ -74,7 +75,7 @@ services: read_only: true networks: [public, backend, observability] healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.0\r\n\r\n' >&3 && grep -q '200 OK' <&3"] + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /auth/health/ready HTTP/1.0\r\n\r\n' >&3 && grep -q '200 OK' <&3"] interval: 15s timeout: 5s retries: 12 @@ -98,7 +99,7 @@ services: source: ${PG_CA_HOST_PATH} target: /run/secrets/pg-ca.pem read_only: true - networks: [backend, observability] + networks: [backend, observability, egress] depends_on: redis: {condition: service_healthy} healthcheck: @@ -185,7 +186,7 @@ services: source: ${PG_CA_HOST_PATH} target: /run/secrets/pg-ca.pem read_only: true - networks: [backend, observability] + networks: [backend, observability, egress] depends_on: api-backend: {condition: service_healthy} healthcheck: @@ -213,7 +214,7 @@ services: source: ${PG_CA_HOST_PATH} target: /run/secrets/pg-ca.pem read_only: true - networks: [backend, observability] + networks: [backend, observability, egress] depends_on: redis: {condition: service_healthy} healthcheck: diff --git a/codebase/backend/keycloak/Dockerfile b/codebase/backend/keycloak/Dockerfile index fa07ee4..3a9f3bf 100644 --- a/codebase/backend/keycloak/Dockerfile +++ b/codebase/backend/keycloak/Dockerfile @@ -5,6 +5,7 @@ WORKDIR /build COPY pom.xml . RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline COPY src ./src +COPY realm ./realm RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build diff --git a/codebase/backend/keycloak/docker-compose.yml b/codebase/backend/keycloak/docker-compose.yml index 9a24ae3..8b581cf 100644 --- a/codebase/backend/keycloak/docker-compose.yml +++ b/codebase/backend/keycloak/docker-compose.yml @@ -39,7 +39,7 @@ services: - backend - observability healthcheck: - test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3"] + test: ["CMD-SHELL", "exec 3<>/dev/tcp/127.0.0.1/9000 && printf 'GET /auth/health/ready HTTP/1.1\\r\\nHost: localhost\\r\\nConnection: close\\r\\n\\r\\n' >&3 && grep -q '200 OK' <&3"] interval: 15s timeout: 5s retries: 12 diff --git a/codebase/backend/keycloak/realm/han-chat-realm.json b/codebase/backend/keycloak/realm/han-chat-realm.json index d241577..644b998 100644 --- a/codebase/backend/keycloak/realm/han-chat-realm.json +++ b/codebase/backend/keycloak/realm/han-chat-realm.json @@ -60,23 +60,21 @@ "frontchannelLogout": true, "fullScopeAllowed": false, "redirectUris": [ - "https://tohin.ru/auth/callback", - "han-chat://auth/callback", - "https://APP_LINK_HOST.example/auth/callback" + "https://chat.han0107.ru/oauth/callback", + "han-chat://auth/callback" ], "webOrigins": [ - "https://tohin.ru", - "https://APP_WEB_ORIGIN.example" + "https://chat.han0107.ru" ], "attributes": { "pkce.code.challenge.method": "S256", - "post.logout.redirect.uris": "https://tohin.ru/##han-chat://auth/logout##https://APP_LINK_HOST.example/auth/logout", + "post.logout.redirect.uris": "https://chat.han0107.ru/##han-chat://auth/logout", "oauth2.device.authorization.grant.enabled": "false", "oidc.ciba.grant.enabled": "false", "use.refresh.tokens": "true", "client.use.lightweight.access.token.enabled": "false" }, - "defaultClientScopes": ["openid", "profile", "phone", "han-chat-api"], + "defaultClientScopes": ["phone", "han-chat-api"], "optionalClientScopes": ["offline_access"] } ], diff --git a/codebase/backend/nginx/docker-compose.yml b/codebase/backend/nginx/docker-compose.yml index ce39b94..6712349 100644 --- a/codebase/backend/nginx/docker-compose.yml +++ b/codebase/backend/nginx/docker-compose.yml @@ -48,9 +48,9 @@ services: read_only: true tmpfs: - /tmp:size=128m,mode=1777 - - /etc/nginx/generated:size=4m,mode=0755,uid=101,gid=101 + - /etc/nginx/generated:size=4m,mode=0755,uid=0,gid=0 cap_drop: ["ALL"] - cap_add: ["NET_BIND_SERVICE", "SETUID", "SETGID"] + cap_add: ["CHOWN", "NET_BIND_SERVICE", "SETUID", "SETGID"] security_opt: ["no-new-privileges:true"] extra_hosts: - "host.docker.internal:host-gateway" diff --git a/codebase/backend/nginx/nginx.conf.template b/codebase/backend/nginx/nginx.conf.template index ea1cf7c..4e7c3cb 100644 --- a/codebase/backend/nginx/nginx.conf.template +++ b/codebase/backend/nginx/nginx.conf.template @@ -1,3 +1,4 @@ +user nginx; worker_processes auto; pid /tmp/nginx.pid; error_log /dev/stderr warn; @@ -11,6 +12,9 @@ http { default_type application/octet-stream; server_tokens off; charset utf-8; + proxy_connect_timeout 3s; + proxy_send_timeout 30s; + proxy_read_timeout 30s; map $http_upgrade $connection_upgrade { default upgrade; diff --git a/codebase/backend/nginx/snippets/proxy-common.conf b/codebase/backend/nginx/snippets/proxy-common.conf index b5f7229..22c4e65 100644 --- a/codebase/backend/nginx/snippets/proxy-common.conf +++ b/codebase/backend/nginx/snippets/proxy-common.conf @@ -11,6 +11,3 @@ proxy_hide_header X-Powered-By; proxy_hide_header Server; add_header X-Request-ID $edge_request_id always; include /etc/nginx/generated/security-headers.conf; -proxy_connect_timeout 3s; -proxy_send_timeout 30s; -proxy_read_timeout 30s; diff --git a/codebase/backend/nginx/snippets/proxy-keycloak.conf b/codebase/backend/nginx/snippets/proxy-keycloak.conf new file mode 100644 index 0000000..955fb19 --- /dev/null +++ b/codebase/backend/nginx/snippets/proxy-keycloak.conf @@ -0,0 +1,12 @@ +proxy_http_version 1.1; +proxy_set_header Host $host; +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $remote_addr; +proxy_set_header X-Forwarded-Proto https; +proxy_set_header X-Forwarded-Host $host; +proxy_set_header X-Forwarded-Port 443; +proxy_set_header X-Request-ID $edge_request_id; +proxy_set_header traceparent $http_traceparent; +proxy_hide_header X-Powered-By; +proxy_hide_header Server; +add_header X-Request-ID $edge_request_id always; diff --git a/codebase/backend/nginx/templates/site-tls.conf.template b/codebase/backend/nginx/templates/site-tls.conf.template index c136ca4..6fe513b 100644 --- a/codebase/backend/nginx/templates/site-tls.conf.template +++ b/codebase/backend/nginx/templates/site-tls.conf.template @@ -97,9 +97,17 @@ server { proxy_pass http://api_backend; } - location ^~ /auth/ { + location ^~ /auth/resources/ { + include /etc/nginx/snippets/proxy-keycloak.conf; + proxy_pass http://keycloak_upstream; + } + location ~ ^/auth/realms/[^/]+/protocol/openid-connect/3p-cookies/ { + include /etc/nginx/snippets/proxy-keycloak.conf; + proxy_pass http://keycloak_upstream; + } + location /auth/ { limit_req zone=auth burst=10; - include /etc/nginx/snippets/proxy-common.conf; + include /etc/nginx/snippets/proxy-keycloak.conf; proxy_read_timeout 60s; proxy_pass http://keycloak_upstream; } diff --git a/codebase/backend/observability/docker-compose.yml b/codebase/backend/observability/docker-compose.yml index dd5b65b..565f0eb 100644 --- a/codebase/backend/observability/docker-compose.yml +++ b/codebase/backend/observability/docker-compose.yml @@ -23,7 +23,7 @@ services: volumes: - ./otel-collector.yaml:/etc/otelcol/config.yaml:ro - otel-queue:/var/lib/otelcol/queue - networks: [observability, backend] + networks: [observability, backend, egress] depends_on: otel-queue-init: {condition: service_completed_successfully} healthcheck: diff --git a/codebase/backend/scripts/validate-env b/codebase/backend/scripts/validate-env index 1f1bede..4a2cb13 100644 --- a/codebase/backend/scripts/validate-env +++ b/codebase/backend/scripts/validate-env @@ -29,7 +29,7 @@ REQUIRED = { SECRET_KEYS = { key for key in REQUIRED if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET_KEY", "ACCESS_KEY")) -} | {"KEYCLOAK_OTP_MOCK_CODE", "BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN"} +} | {"BITRIX_CLIENT_SECRET", "BITRIX_APPLICATION_TOKEN"} PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I) diff --git a/codebase/backend/tests/test_config.py b/codebase/backend/tests/test_config.py index 01934eb..29cdd75 100644 --- a/codebase/backend/tests/test_config.py +++ b/codebase/backend/tests/test_config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import ast import subprocess import sys import tempfile @@ -10,6 +11,20 @@ ROOT = Path(__file__).resolve().parents[1] class InfrastructureConfigTests(unittest.TestCase): + def test_structlog_event_is_not_passed_twice(self) -> None: + log_methods = {"debug", "info", "warning", "error", "exception", "critical"} + for source_path in (ROOT / "api-backend/app").rglob("*.py"): + tree = ast.parse(source_path.read_text(encoding="utf-8"), filename=str(source_path)) + for node in ast.walk(tree): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr in log_methods + and node.args + and any(keyword.arg == "event" for keyword in node.keywords) + ): + self.fail(f"duplicate structlog event in {source_path}:{node.lineno}") + def test_root_compose_uses_only_infra_fragments(self) -> None: compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") for fragment in ( @@ -22,6 +37,23 @@ class InfrastructureConfigTests(unittest.TestCase): self.assertIn(fragment, compose) self.assertNotIn("postgres:", compose.lower()) + def test_external_dependencies_use_dedicated_egress_network(self) -> None: + root = (ROOT / "docker-compose.yml").read_text(encoding="utf-8") + self.assertIn("egress:\n name: han-chat-egress", root) + + application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8") + self.assertIn("networks: [backend, observability, egress]", application) + self.assertIn("networks: [public, backend, observability]", application) + + jobs = (ROOT / "deployment/docker-compose.jobs.yml").read_text(encoding="utf-8") + self.assertEqual(jobs.count("networks: [backend, egress]"), 4) + + observability = (ROOT / "observability/docker-compose.yml").read_text(encoding="utf-8") + self.assertIn("networks: [observability, backend, egress]", observability) + + redis = (ROOT / "redis/docker-compose.yml").read_text(encoding="utf-8") + self.assertNotIn("egress", redis) + def test_only_nginx_fragment_publishes_ports(self) -> None: forbidden = ( ROOT / "infra/compose/application.yml", @@ -38,6 +70,12 @@ class InfrastructureConfigTests(unittest.TestCase): def test_nginx_internal_denies_precede_spa(self) -> None: site = (ROOT / "nginx/templates/site-tls.conf.template").read_text(encoding="utf-8") + compose = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8") + config = (ROOT / "nginx/nginx.conf.template").read_text(encoding="utf-8") + proxy_common = (ROOT / "nginx/snippets/proxy-common.conf").read_text(encoding="utf-8") + proxy_keycloak = (ROOT / "nginx/snippets/proxy-keycloak.conf").read_text( + encoding="utf-8" + ) internal = site.index("location ^~ /internal/") api = site.index("location ^~ /api/") frontend = site.index("include /etc/nginx/generated/frontend-location.conf") @@ -45,6 +83,17 @@ class InfrastructureConfigTests(unittest.TestCase): self.assertLess(api, frontend) self.assertIn("location = /api/v1/realtime", site) self.assertNotIn("message-safety:", site) + self.assertIn("uid=0,gid=0", compose) + self.assertIn('cap_add: ["CHOWN", "NET_BIND_SERVICE", "SETUID", "SETGID"]', compose) + self.assertTrue(config.startswith("user nginx;\n")) + self.assertIn("proxy_read_timeout 30s;", config) + self.assertNotIn("proxy_read_timeout", proxy_common) + self.assertNotIn("proxy_send_timeout", proxy_common) + self.assertIn("include /etc/nginx/snippets/proxy-keycloak.conf;", site) + self.assertIn("location ^~ /auth/resources/", site) + self.assertIn("protocol/openid-connect/3p-cookies/", site) + self.assertNotIn("security-headers.conf", proxy_keycloak) + self.assertNotIn("X-Frame-Options", proxy_keycloak) def test_redis_persistence_acl_and_no_host_port(self) -> None: config = (ROOT / "redis/redis.conf").read_text(encoding="utf-8") @@ -94,13 +143,42 @@ class InfrastructureConfigTests(unittest.TestCase): nginx = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8") self.assertIn("frontend-static: {condition: service_completed_successfully}", nginx) + def test_smoke_script_does_not_source_env_as_shell(self) -> None: + smoke = (ROOT / "deployment/scripts/smoke.sh").read_text(encoding="utf-8") + self.assertNotIn('. "./$ENV_FILE"', smoke) + for variable in ("PUBLIC_HOST", "PUBLIC_WEB_URL", "KEYCLOAK_REALM"): + self.assertIn(f"{variable}=$(env_value {variable})", smoke) + + def test_expo_public_environment_uses_static_property_access(self) -> None: + config = (ROOT / "frontend-test-site/src/config.ts").read_text(encoding="utf-8") + self.assertNotIn("process.env[name]", config) + self.assertIn("process.env.EXPO_PUBLIC_API_BASE_URL", config) + self.assertIn("process.env.EXPO_PUBLIC_AUTH_BASE_URL", config) + + def test_alembic_escapes_percent_encoded_dsn_options(self) -> None: + for relative_path in ( + "api-backend/alembic/env.py", + "bitrix-local-app/alembic/env.py", + "bitrix-sync/alembic/env.py", + ): + env_script = (ROOT / relative_path).read_text(encoding="utf-8") + self.assertIn('.replace("%", "%%")', env_script, relative_path) + self.assertIn("create_postgres_engine", env_script, relative_path) + def test_keycloak_management_health_and_bridge_environment(self) -> None: standalone = (ROOT / "keycloak/docker-compose.yml").read_text(encoding="utf-8") - self.assertIn("GET /health/ready", standalone) - self.assertNotIn("GET /auth/health/ready", standalone) + self.assertIn("GET /auth/health/ready", standalone) + + dockerfile = (ROOT / "keycloak/Dockerfile").read_text(encoding="utf-8") + self.assertLess( + dockerfile.index("COPY realm ./realm"), + dockerfile.index("mvn -B -ntp clean verify"), + ) application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8") + self.assertIn("GET /auth/health/ready", application) for variable in ( + "KC_DB_SCHEMA", "KEYCLOAK_OTP_MOCK_ENABLED", "KEYCLOAK_OTP_MOCK_CODE", "KEYCLOAK_OTP_HMAC_KEY", @@ -114,6 +192,10 @@ class InfrastructureConfigTests(unittest.TestCase): def test_env_validator_accepts_materialized_example(self) -> None: example = (ROOT / ".env.example").read_text(encoding="utf-8") + self.assertNotIn("options=-csearch_path", example) + self.assertNotIn("currentSchema=", example) + self.assertIn("KEYCLOAK_DB_SCHEMA=keycloak", example) + self.assertIn("HAN_PG_PORT=5433", example) for required in ( "CURSOR_HMAC_SECRET=", "BITRIX_TOKEN_ENCRYPTION_KEY=", diff --git a/deploy-steps.md b/deploy-steps.md index b50b26b..da2ae66 100644 --- a/deploy-steps.md +++ b/deploy-steps.md @@ -10,6 +10,21 @@ Туннель до БД: ssh -i C:\Users\MI\.ssh\hansel -L 5433:192.168.0.211:5433 root@135.106.164.58 -N +#Обновление проекта + +cd /tmp +rm han-chat-backend.tar.gz +cd /opt/han-chat/backend + +rm C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz +tar -C HAN_chat_specification/codebase/backend -czf han-chat-backend.tar.gz . +scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz root@135.106.164.58:/tmp/han-chat-backend.tar.gz + +tar -xzf /tmp/han-chat-backend.tar.gz +find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} + +chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh + + # Разворачиваем инфраструктуру в Селектел ч1 ## Создание сети @@ -44,6 +59,11 @@ ssh -i C:\Users\MI\.ssh\han_chat_deploy deploy@135.106.164.58 tar -C HAN_chat_specification/codebase/backend -czf han-chat-backend.tar.gz . scp -i C:\Users\MI\.ssh\han_chat_deploy C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz deploy@135.106.164.58:/tmp/ +scp -i C:\Users\MI\.ssh\hansel C:\Users\MI\Documents\Assistent\han-chat-backend.tar.gz root@135.106.164.58:/tmp/ + + + + ## Дальше распаковка cd /opt/han-chat/backend tar -xzf /tmp/han-chat-backend.tar.gz @@ -201,3 +221,74 @@ resolvectl domain eth0 '~.' resolvectl flush-caches resolvectl query pypi.org +# Подготовка к запуску + +Сертификат рекомендуется скопировать в папку с секретами +cp /root/.postgresql/root.crt /opt/han-chat/backend/secrets/pg/root.crt +chmod 644 /opt/han-chat/backend/secrets/pg/root.crt + +В битрикс регистрируем локальное приложение: +Установка: https://chat.example.ru/bitrix/install +Обработчик: https://chat.example.ru/bitrix/handler +??? (не понадобилось) Placement: https://chat.example.ru/bitrix/placement + +--настраиваю env на локальной машине и копирую на диск +scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\.env" root@135.106.164.58:/opt/han-chat/backend + +--Проверка .env +cd /opt/han-chat/backend +chmod 600 .env +./scripts/validate-env .env +docker compose --env-file .env config --quiet +docker compose --env-file .env config --services +python3 -m unittest discover -s tests -v + +--Проверка портов (Публиковаться должны только 80 и 443 у nginx) +docker compose --env-file .env config | grep -n 'published:' + +--Собираем локальные образы: +docker compose --env-file .env build --pull + +docker compose --env-file .env images + +## Миграции БД и начальные настройки +cd /opt/han-chat/backend +PITR_MARKER_CONFIRMED=true deployment/scripts/migrate.sh +deployment/scripts/seed.sh + +## Запуск внутренних сервисов + +Сначала запустите Redis: + +```sh +docker compose --env-file .env up -d redis +docker compose --env-file .env ps redis +``` + +Затем Keycloak и OpenTelemetry: + +```sh +docker compose --env-file .env up -d keycloak otel-collector +docker compose --env-file .env ps keycloak otel-collector +``` + +Первый запуск Keycloak может занять несколько минут: он создаст свои таблицы и +импортирует realm `han-chat`. + +После готовности Keycloak: + +```sh +docker compose --env-file .env up -d message-safety +docker compose --env-file .env up -d api-backend +docker compose --env-file .env up -d bitrix-local-app bitrix-sync +docker compose --env-file .env up -d \ + delivery-worker safety-recovery-worker cleanup-worker +docker compose --env-file .env ps +``` + +Если сервис не становится healthy: + +```sh +docker compose --env-file .env logs --tail=200 +docker inspect "$(docker compose --env-file .env ps -q )" +``` diff --git a/han-chat-backend.tar.gz b/han-chat-backend.tar.gz new file mode 100644 index 0000000..e69de29 diff --git a/modules/module-07-bitrix-sync.md b/modules/module-07-bitrix-sync.md index 9407e63..c43713c 100644 --- a/modules/module-07-bitrix-sync.md +++ b/modules/module-07-bitrix-sync.md @@ -290,7 +290,7 @@ Managed init уже создаёт: APP_ENV=production-like LOG_LEVEL=INFO BITRIX_SYNC_ENABLED=true -BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:.../han_chat?options=-csearch_path%3Dbitrix_sync +BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:.../han_chat BITRIX_SYNC_SERVICE_TOKEN= OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 ```