Исправлены ошибки в ходе раскатки
This commit is contained in:
@@ -152,16 +152,18 @@ LOG_LEVEL=INFO
|
|||||||
# Managed PostgreSQL
|
# Managed PostgreSQL
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
HAN_PG_HOST=<managed-pg-private-host>
|
HAN_PG_HOST=<managed-pg-private-host>
|
||||||
HAN_PG_PORT=6432
|
HAN_PG_PORT=5433
|
||||||
HAN_PG_DATABASE=han_chat
|
HAN_PG_DATABASE=han_chat
|
||||||
|
|
||||||
DATABASE_URL=postgresql+asyncpg://han_app:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?options=-csearch_path%3Dhan_app
|
DATABASE_URL=postgresql+asyncpg://han_app:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>
|
||||||
BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?options=-csearch_path%3Dbitrix_local
|
BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>
|
||||||
BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?options=-csearch_path%3Dbitrix_sync%2Chan_app
|
BITRIX_SYNC_APP_DATABASE_URL=postgresql://bitrix_sync_user:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>
|
||||||
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?options=-csearch_path%3Dbitrix_sync
|
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>
|
||||||
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?options=-csearch_path%3Dmessage_safety
|
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>
|
||||||
KEYCLOAK_DB_URL=jdbc:postgresql://<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?user=keycloak_user&password=change-me¤tSchema=keycloak
|
KEYCLOAK_DB_URL=jdbc:postgresql://<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?user=keycloak_user&password=change-me¤tSchema=keycloak
|
||||||
KC_DB_URL_PROPERTIES=currentSchema=keycloak
|
KC_DB_URL_PROPERTIES=currentSchema=keycloak
|
||||||
|
# Selectel PgBouncer 5433: pool_mode=session; search_path задаётся на уровне ролей.
|
||||||
|
# Не добавлять options=-csearch_path: pooler отклоняет этот startup parameter.
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Публичные URL (HTTPS)
|
# Публичные URL (HTTPS)
|
||||||
|
|||||||
@@ -13,15 +13,17 @@ KEYCLOAK_IMAGE=han-chat-keycloak:local
|
|||||||
|
|
||||||
# Managed PostgreSQL is external to Compose. All production DSNs must verify TLS.
|
# Managed PostgreSQL is external to Compose. All production DSNs must verify TLS.
|
||||||
HAN_PG_HOST=managed-pg.private.example
|
HAN_PG_HOST=managed-pg.private.example
|
||||||
HAN_PG_PORT=6432
|
HAN_PG_PORT=5433
|
||||||
HAN_PG_DATABASE=han_chat
|
HAN_PG_DATABASE=han_chat
|
||||||
PG_CA_HOST_PATH=/opt/han-chat/secrets/pg/ca.pem
|
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
|
# PgBouncer session mode uses each role's database-level search_path; do not pass libpq options.
|
||||||
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
|
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_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_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_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
|
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
|
||||||
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
|
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
|
||||||
KEYCLOAK_DB_URL=jdbc:postgresql://managed-pg.private.example:6432/han_chat?currentSchema=keycloak&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_USERNAME=keycloak_user
|
||||||
KEYCLOAK_DB_PASSWORD=change-me
|
KEYCLOAK_DB_PASSWORD=change-me
|
||||||
|
|
||||||
@@ -48,9 +50,10 @@ NGINX_MESSAGE_READ_TIMEOUT_SEC=330
|
|||||||
NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32
|
NGINX_TRUSTED_PROXY_CIDR=127.0.0.1/32
|
||||||
TRUSTED_PROXY_CIDRS=172.16.0.0/12
|
TRUSTED_PROXY_CIDRS=172.16.0.0/12
|
||||||
BITRIX_FRAME_ANCESTORS=https://*.bitrix24.ru
|
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
|
FRONTEND_DEV_PROXY_ENABLED=false
|
||||||
|
# При false значение не используется
|
||||||
EXPO_DEV_SERVER_URL=http://host.docker.internal:8081
|
EXPO_DEV_SERVER_URL=http://host.docker.internal:8081
|
||||||
ACME_EMAIL=ops@example.ru
|
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_INTERNAL_URL=http://keycloak:8080/auth
|
||||||
KEYCLOAK_REALM=han-chat
|
KEYCLOAK_REALM=han-chat
|
||||||
KEYCLOAK_AUDIENCE=han-chat-api
|
KEYCLOAK_AUDIENCE=han-chat-api
|
||||||
|
# На мок среде true,true. На продакшн false,false.
|
||||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||||
KEYCLOAK_OTP_MOCK_CODE=change-me
|
KEYCLOAK_OTP_MOCK_CODE=change-me
|
||||||
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
|
KEYCLOAK_OTP_MOCK_RISK_ACCEPTED=false
|
||||||
|
# (openssl rand -hex 32)
|
||||||
KEYCLOAK_OTP_HMAC_KEY=change-me
|
KEYCLOAK_OTP_HMAC_KEY=change-me
|
||||||
KEYCLOAK_OTP_TTL_SEC=300
|
KEYCLOAK_OTP_TTL_SEC=300
|
||||||
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
|
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
|
||||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||||
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
KEYCLOAK_SETTINGS_BRIDGE_URL=http://api-backend:8000/internal/settings/v1/otp
|
||||||
KEYCLOAK_ADMIN=bootstrap-admin
|
KEYCLOAK_ADMIN=bootstrap-admin
|
||||||
|
# (openssl rand -hex 32)
|
||||||
KEYCLOAK_ADMIN_PASSWORD=change-me
|
KEYCLOAK_ADMIN_PASSWORD=change-me
|
||||||
|
# (openssl rand -hex 32)
|
||||||
CURSOR_HMAC_SECRET=change-me
|
CURSOR_HMAC_SECRET=change-me
|
||||||
|
|
||||||
|
# Redis пароль 1 (openssl rand -hex 32)
|
||||||
REDIS_API_PASSWORD=change-me
|
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_URL=redis://api_backend:change-me@redis:6379/0
|
||||||
REDIS_REALTIME_URL=redis://api_backend:change-me@redis:6379/1
|
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
|
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_MAXMEMORY=384mb
|
||||||
REDIS_EVICTION_POLICY=volatile-lru
|
REDIS_EVICTION_POLICY=volatile-lru
|
||||||
|
|
||||||
|
#token1 (openssl rand -hex 32)
|
||||||
MESSAGE_SAFETY_SERVICE_TOKEN=change-me
|
MESSAGE_SAFETY_SERVICE_TOKEN=change-me
|
||||||
|
#token2 (openssl rand -hex 32)
|
||||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN=change-me
|
BITRIX_LOCAL_APP_INTERNAL_TOKEN=change-me
|
||||||
BITRIX_INTERNAL_API_TOKEN=change-me
|
BITRIX_INTERNAL_API_TOKEN=change-me
|
||||||
|
#token3 (openssl rand -hex 32)
|
||||||
BITRIX_API_FORWARD_TOKEN=change-me
|
BITRIX_API_FORWARD_TOKEN=change-me
|
||||||
BITRIX_API_INBOX_TOKEN=change-me
|
BITRIX_API_INBOX_TOKEN=change-me
|
||||||
|
#token4 (openssl rand -hex 32)
|
||||||
BITRIX_SYNC_SERVICE_TOKEN=change-me
|
BITRIX_SYNC_SERVICE_TOKEN=change-me
|
||||||
|
#token5 (openssl rand -hex 32)
|
||||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me
|
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=change-me
|
||||||
|
|
||||||
BITRIX_LOCAL_APP_BASE_URL=http://bitrix-local-app:8080
|
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_TASK_TTL_SEC=900
|
||||||
MESSAGE_SAFETY_FILE_SCAN_TIMEOUT_SEC=60
|
MESSAGE_SAFETY_FILE_SCAN_TIMEOUT_SEC=60
|
||||||
MESSAGE_SAFETY_RULES_VERSION=2026-01-01
|
MESSAGE_SAFETY_RULES_VERSION=2026-01-01
|
||||||
|
#Отключение синхронизации (при отключенной синхронизации параметры ниже не работают)
|
||||||
BITRIX_SYNC_ENABLED=false
|
BITRIX_SYNC_ENABLED=false
|
||||||
BITRIX_SYNC_CRM_BASE_URL=https://example.bitrix24.ru
|
BITRIX_SYNC_CRM_BASE_URL=https://example.bitrix24.ru
|
||||||
BITRIX_SYNC_CRM_WEBHOOK_URL=change-me
|
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_CONTACT_UPDATE_INTERVAL_SEC=30
|
||||||
BITRIX_SYNC_CRM_MAX_CONCURRENCY=2
|
BITRIX_SYNC_CRM_MAX_CONCURRENCY=2
|
||||||
BITRIX_SYNC_CONTACT_LIST_BATCH_SIZE=50
|
BITRIX_SYNC_CONTACT_LIST_BATCH_SIZE=50
|
||||||
|
# (openssl rand -hex 32)
|
||||||
BITRIX_SYNC_WEBHOOK_TOKEN=change-me
|
BITRIX_SYNC_WEBHOOK_TOKEN=change-me
|
||||||
|
|
||||||
|
# Сначала регистрируется приложение в Битрикс24, потом тут прописываются секреты
|
||||||
BITRIX_CLIENT_ID=change-me
|
BITRIX_CLIENT_ID=change-me
|
||||||
BITRIX_CLIENT_SECRET=change-me
|
BITRIX_CLIENT_SECRET=change-me
|
||||||
|
BITRIX_APPLICATION_TOKEN=change-me
|
||||||
|
|
||||||
BITRIX_CONNECTOR_ID=han_mobile_app
|
BITRIX_CONNECTOR_ID=han_mobile_app
|
||||||
BITRIX_CONNECTOR_NAME=HAN Mobile App
|
BITRIX_CONNECTOR_NAME=HAN Mobile App
|
||||||
BITRIX_OPEN_LINE_ID=8
|
BITRIX_OPEN_LINE_ID=<N>
|
||||||
|
BITRIX_EXPECTED_DOMAIN=<ваш-портал>.bitrix24.ru
|
||||||
BITRIX_PUBLIC_BASE_URL=https://chat.example.ru/bitrix
|
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())"
|
# Generate with: python -c "import base64,secrets; print(base64.urlsafe_b64encode(secrets.token_bytes(32)).decode())"
|
||||||
BITRIX_TOKEN_ENCRYPTION_KEY=change-me
|
BITRIX_TOKEN_ENCRYPTION_KEY=change-me
|
||||||
BITRIX_HTTP_TIMEOUT_SEC=10
|
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_DOCUMENTS=han-chat-documents
|
||||||
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
|
SELECTEL_S3_BUCKET_ATTACHMENTS=han-chat-attachments
|
||||||
SELECTEL_S3_BUCKET_QUARANTINE=han-chat-quarantine
|
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
|
SELECTEL_S3_QUARANTINE_READ_SECRET_KEY=change-me
|
||||||
|
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||||
|
#Если есть внешний OTLP-сервис, замените (Точный формат авторизации зависит от провайдера):
|
||||||
|
#Если внешнего OTLP-сервиса пока нет, otlp.example.invalid:4317 можно временно оставить, но Collector будет постоянно пытаться подключиться, писать предупреждения и накапливать очередь.
|
||||||
OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
|
OTEL_REMOTE_ENDPOINT=otlp.example.invalid:4317
|
||||||
OTEL_REMOTE_AUTH_HEADER=change-me
|
OTEL_REMOTE_AUTH_HEADER=change-me
|
||||||
|
# Сейчас соответствуют 10% трассировок и очереди 10000
|
||||||
|
# Также обнаружена особенность проекта: OTEL_TRACES_SAMPLER_ARG и OTEL_QUEUE_SIZE сейчас фактически не подставляются в конфигурацию — там жёстко установлены 10% и 10000. Поэтому менять эти две переменные пока бессмысленно.
|
||||||
OTEL_TRACES_SAMPLER_ARG=0.10
|
OTEL_TRACES_SAMPLER_ARG=0.10
|
||||||
OTEL_QUEUE_SIZE=10000
|
OTEL_QUEUE_SIZE=10000
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ import asyncio
|
|||||||
from logging.config import fileConfig
|
from logging.config import fileConfig
|
||||||
|
|
||||||
from sqlalchemy import pool
|
from sqlalchemy import pool
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
from app.postgres import create_postgres_engine
|
||||||
from app.settings import get_settings
|
from app.settings import get_settings
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
if config.config_file_name:
|
if config.config_file_name:
|
||||||
fileConfig(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
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
@@ -28,11 +29,7 @@ def do_run_migrations(connection) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_async_migrations() -> None:
|
async def run_async_migrations() -> None:
|
||||||
connectable = async_engine_from_config(
|
connectable = create_postgres_engine(database_url, poolclass=pool.NullPool)
|
||||||
config.get_section(config.config_ini_section, {}),
|
|
||||||
prefix="sqlalchemy.",
|
|
||||||
poolclass=pool.NullPool,
|
|
||||||
)
|
|
||||||
async with connectable.connect() as connection:
|
async with connectable.connect() as connection:
|
||||||
await connection.run_sync(do_run_migrations)
|
await connection.run_sync(do_run_migrations)
|
||||||
await connectable.dispose()
|
await connectable.dispose()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ Create Date: 2026-07-10
|
|||||||
|
|
||||||
from collections.abc import Sequence
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
from alembic import op
|
from alembic import op
|
||||||
from app.db import Base
|
from app.db import Base
|
||||||
|
|
||||||
@@ -75,12 +76,18 @@ def upgrade() -> None:
|
|||||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_dialog_one_active_per_user
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_dialog_one_active_per_user
|
||||||
ON han_app.dialogs(user_id)
|
ON han_app.dialogs(user_id)
|
||||||
WHERE record_status='A'
|
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
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_profiles_active_bitrix_contact
|
||||||
ON han_app.client_profiles(bitrix_contact_id)
|
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()
|
CREATE OR REPLACE FUNCTION han_app.enqueue_contact_sync()
|
||||||
RETURNS trigger
|
RETURNS trigger
|
||||||
LANGUAGE plpgsql
|
LANGUAGE plpgsql
|
||||||
@@ -116,36 +123,48 @@ def upgrade() -> None:
|
|||||||
ON CONFLICT (dedup_key) DO NOTHING;
|
ON CONFLICT (dedup_key) DO NOTHING;
|
||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
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
|
CREATE TRIGGER trg_identity_contact_sync
|
||||||
AFTER INSERT OR UPDATE OF phone_number, record_status
|
AFTER INSERT OR UPDATE OF phone_number, record_status
|
||||||
ON han_app.user_identities
|
ON han_app.user_identities
|
||||||
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync();
|
FOR EACH ROW EXECUTE FUNCTION han_app.enqueue_contact_sync()
|
||||||
|
"""
|
||||||
DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles;
|
)
|
||||||
|
op.execute(
|
||||||
|
"DROP TRIGGER IF EXISTS trg_profile_contact_sync ON han_app.client_profiles"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
CREATE TRIGGER trg_profile_contact_sync
|
CREATE TRIGGER trg_profile_contact_sync
|
||||||
AFTER INSERT OR UPDATE OF full_name, citizenship, russian_phone,
|
AFTER INSERT OR UPDATE OF full_name, citizenship, russian_phone,
|
||||||
foreign_phone, email, record_status
|
foreign_phone, email, record_status
|
||||||
ON han_app.client_profiles
|
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():
|
for key, (value, value_type, public) in SEED.items():
|
||||||
bind.exec_driver_sql(
|
bind.execute(
|
||||||
"""
|
sa.text(
|
||||||
|
"""
|
||||||
INSERT INTO han_app.app_settings
|
INSERT INTO han_app.app_settings
|
||||||
(setting_key, setting_value, value_type, is_public, record_status, updated_at)
|
(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
|
ON CONFLICT (setting_key) DO UPDATE SET
|
||||||
setting_value = EXCLUDED.setting_value,
|
setting_value = EXCLUDED.setting_value,
|
||||||
value_type = EXCLUDED.value_type,
|
value_type = EXCLUDED.value_type,
|
||||||
is_public = EXCLUDED.is_public,
|
is_public = EXCLUDED.is_public,
|
||||||
record_status = 'A',
|
record_status = 'A',
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
""",
|
"""
|
||||||
(key, value, value_type, public),
|
),
|
||||||
|
{"key": key, "value": value, "value_type": value_type, "public": public},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,11 @@ from sqlalchemy.ext.asyncio import (
|
|||||||
AsyncEngine,
|
AsyncEngine,
|
||||||
AsyncSession,
|
AsyncSession,
|
||||||
async_sessionmaker,
|
async_sessionmaker,
|
||||||
create_async_engine,
|
|
||||||
)
|
)
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.postgres import create_postgres_engine
|
||||||
|
|
||||||
SCHEMA = "han_app"
|
SCHEMA = "han_app"
|
||||||
|
|
||||||
|
|
||||||
@@ -347,7 +348,7 @@ class EntityExternalMapping(Base):
|
|||||||
|
|
||||||
class Database:
|
class Database:
|
||||||
def __init__(self, url: str) -> None:
|
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)
|
self.sessions = async_sessionmaker(self.engine, expire_on_commit=False)
|
||||||
|
|
||||||
async def session(self) -> AsyncIterator[AsyncSession]:
|
async def session(self) -> AsyncIterator[AsyncSession]:
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ async def refresh_settings_cache(app: FastAPI) -> None:
|
|||||||
async with app.state.db.sessions() as db:
|
async with app.state.db.sessions() as db:
|
||||||
app.state.snapshot = await load_settings(db)
|
app.state.snapshot = await load_settings(db)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("settings.refresh_failed", event="settings.refresh_failed")
|
log.warning("settings.refresh_failed")
|
||||||
await asyncio.sleep(30)
|
await asyncio.sleep(30)
|
||||||
|
|
||||||
|
|
||||||
@@ -126,12 +126,12 @@ async def lifespan(app: FastAPI):
|
|||||||
async with app.state.db.sessions() as db:
|
async with app.state.db.sessions() as db:
|
||||||
app.state.snapshot = await load_settings(db)
|
app.state.snapshot = await load_settings(db)
|
||||||
except Exception:
|
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))
|
settings_task = asyncio.create_task(refresh_settings_cache(app))
|
||||||
try:
|
try:
|
||||||
await app.state.jwks.refresh()
|
await app.state.jwks.refresh()
|
||||||
except Exception:
|
except Exception:
|
||||||
structlog.get_logger().warning("jwks_warmup_failed", event="jwks.warmup_failed")
|
structlog.get_logger().warning("jwks.warmup_failed")
|
||||||
yield
|
yield
|
||||||
settings_task.cancel()
|
settings_task.cancel()
|
||||||
with suppress(asyncio.CancelledError):
|
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")
|
response.headers["Cache-Control"] = response.headers.get("Cache-Control", "no-store")
|
||||||
log.info(
|
log.info(
|
||||||
"request.complete",
|
"request.complete",
|
||||||
event="request.complete",
|
|
||||||
status_code=response.status_code,
|
status_code=response.status_code,
|
||||||
duration_ms=round((time.monotonic() - request.state.started_at) * 1000, 2),
|
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)
|
@app.exception_handler(Exception)
|
||||||
async def unhandled_error(request: Request, exc: 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)
|
return error_response(request, "internal_error", "Internal server error", 500)
|
||||||
|
|
||||||
|
|
||||||
@@ -622,7 +621,7 @@ async def dialogs_create(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
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)
|
return JSONResponse(json.loads(json.dumps(body, default=str)), status_code=status)
|
||||||
|
|
||||||
|
|
||||||
@@ -792,7 +791,7 @@ async def message_create(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
log.warning("idempotency.cache_write_failed", event="idempotency.cache_write_failed")
|
log.warning("idempotency.cache_write_failed")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@ from pydantic import TypeAdapter, ValidationError
|
|||||||
|
|
||||||
from app.auth import canonical_phone
|
from app.auth import canonical_phone
|
||||||
from app.integrations import CircuitBreaker, RateLimiter
|
from app.integrations import CircuitBreaker, RateLimiter
|
||||||
|
from app.postgres import asyncpg_dsn
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
FileMessageRequest,
|
FileMessageRequest,
|
||||||
MessageRequest,
|
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:
|
def test_phone_claim_priority_and_e164_validation() -> None:
|
||||||
claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"}
|
claims = {"phone_number": "+74999591007", "preferred_username": "+12025550123"}
|
||||||
assert canonical_phone(claims) == "+74999591007"
|
assert canonical_phone(claims) == "+74999591007"
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
|
||||||
|
|
||||||
from app.models import Base
|
from app.models import Base
|
||||||
|
from app.postgres import create_postgres_engine
|
||||||
|
|
||||||
config = context.config
|
config = context.config
|
||||||
url = os.environ["BITRIX_DATABASE_URL"].replace("postgresql://", "postgresql+asyncpg://", 1)
|
url = os.environ["BITRIX_DATABASE_URL"]
|
||||||
config.set_main_option("sqlalchemy.url", url)
|
config.set_main_option("sqlalchemy.url", url.replace("%", "%%"))
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ def do_run(connection) -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_online() -> 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:
|
async with engine.connect() as connection:
|
||||||
await connection.run_sync(do_run)
|
await connection.run_sync(do_run)
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from sqlalchemy import func, or_, select, text
|
from sqlalchemy import func, or_, select, text
|
||||||
from sqlalchemy.exc import IntegrityError
|
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 (
|
from app.models import (
|
||||||
ConnectorSetup,
|
ConnectorSetup,
|
||||||
@@ -36,6 +36,7 @@ from app.models import (
|
|||||||
PortalInstallation,
|
PortalInstallation,
|
||||||
now,
|
now,
|
||||||
)
|
)
|
||||||
|
from app.postgres import create_postgres_engine
|
||||||
|
|
||||||
logger = logging.getLogger("bitrix-local-app")
|
logger = logging.getLogger("bitrix-local-app")
|
||||||
|
|
||||||
@@ -135,10 +136,6 @@ class OutboundDto(BaseModel):
|
|||||||
message: MessageDto
|
message: MessageDto
|
||||||
|
|
||||||
|
|
||||||
def pg_url(value: str) -> str:
|
|
||||||
return value.replace("postgresql://", "postgresql+asyncpg://", 1)
|
|
||||||
|
|
||||||
|
|
||||||
def canonical_fingerprint(value: dict[str, Any]) -> str:
|
def canonical_fingerprint(value: dict[str, Any]) -> str:
|
||||||
clean = json.loads(json.dumps(value, sort_keys=True, default=str))
|
clean = json.loads(json.dumps(value, sort_keys=True, default=str))
|
||||||
for file in clean.get("message", {}).get("files", []):
|
for file in clean.get("message", {}).get("files", []):
|
||||||
@@ -416,8 +413,8 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
|||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
engine = create_async_engine(
|
engine = create_postgres_engine(
|
||||||
pg_url(cfg.bitrix_database_url),
|
cfg.bitrix_database_url,
|
||||||
pool_size=5,
|
pool_size=5,
|
||||||
max_overflow=0,
|
max_overflow=0,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -2,10 +2,14 @@ import asyncio
|
|||||||
import os
|
import os
|
||||||
|
|
||||||
from alembic import context
|
from alembic import context
|
||||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
from app.postgres import create_postgres_engine
|
||||||
|
|
||||||
config = context.config
|
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
|
target_metadata = None
|
||||||
|
|
||||||
|
|
||||||
@@ -16,7 +20,7 @@ def run_offline() -> None:
|
|||||||
|
|
||||||
|
|
||||||
async def run_online() -> 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:
|
async with engine.connect() as connection:
|
||||||
await connection.run_sync(do_run)
|
await connection.run_sync(do_run)
|
||||||
await engine.dispose()
|
await engine.dispose()
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ from fastapi.responses import JSONResponse
|
|||||||
from pydantic import Field, model_validator
|
from pydantic import Field, model_validator
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from sqlalchemy import text
|
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:
|
def utcnow() -> datetime:
|
||||||
@@ -79,12 +81,6 @@ class DatabaseProbe:
|
|||||||
await self.engine.dispose()
|
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:
|
def classify_error(exc: Exception) -> str:
|
||||||
name = type(exc).__name__.lower()
|
name = type(exc).__name__.lower()
|
||||||
text_value = str(exc).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
|
app.state.loop_task = None
|
||||||
if cfg.bitrix_sync_enabled:
|
if cfg.bitrix_sync_enabled:
|
||||||
if app.state.probe is None:
|
if app.state.probe is None:
|
||||||
engine = create_async_engine(
|
engine = create_postgres_engine(
|
||||||
database_url(cfg.bitrix_sync_database_url or ""),
|
cfg.bitrix_sync_database_url or "",
|
||||||
pool_size=cfg.bitrix_sync_db_pool_size,
|
pool_size=cfg.bitrix_sync_db_pool_size,
|
||||||
max_overflow=0,
|
max_overflow=0,
|
||||||
pool_pre_ping=True,
|
pool_pre_ping=True,
|
||||||
pool_recycle=cfg.bitrix_sync_db_pool_recycle_sec,
|
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)
|
app.state.probe = DatabaseProbe(engine)
|
||||||
await run_probe(app)
|
await run_probe(app)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -300,16 +300,23 @@ HAN_PG_PORT=6432
|
|||||||
HAN_PG_DATABASE=han_chat
|
HAN_PG_DATABASE=han_chat
|
||||||
PG_CA_HOST_PATH=/opt/han-chat/backend/secrets/pg/ca.pem
|
PG_CA_HOST_PATH=/opt/han-chat/backend/secrets/pg/ca.pem
|
||||||
|
|
||||||
DATABASE_URL=postgresql+asyncpg://han_app:<PASSWORD>@<PG_HOST>:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dhan_app
|
DATABASE_URL=postgresql+asyncpg://han_app:<PASSWORD>@<PG_HOST>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
||||||
BITRIX_DATABASE_URL=postgresql://bitrix_local_app:<PASSWORD>@<PG_HOST>:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_local
|
BITRIX_DATABASE_URL=postgresql://bitrix_local_app:<PASSWORD>@<PG_HOST>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
||||||
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:<PASSWORD>@<PG_HOST>:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dbitrix_sync
|
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:<PASSWORD>@<PG_HOST>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
||||||
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:<PASSWORD>@<PG_HOST>:6432/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem&options=-csearch_path%3Dmessage_safety
|
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:<PASSWORD>@<PG_HOST>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
||||||
|
|
||||||
KEYCLOAK_DB_URL=jdbc:postgresql://<PG_HOST>:6432/han_chat?currentSchema=keycloak&sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
KEYCLOAK_DB_URL=jdbc:postgresql://<PG_HOST>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
||||||
|
KEYCLOAK_DB_SCHEMA=keycloak
|
||||||
KEYCLOAK_DB_USERNAME=keycloak_user
|
KEYCLOAK_DB_USERNAME=keycloak_user
|
||||||
KEYCLOAK_DB_PASSWORD=<PASSWORD>
|
KEYCLOAK_DB_PASSWORD=<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.
|
URL-кодировать внутри PostgreSQL URL.
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ services:
|
|||||||
command: ["alembic", "upgrade", "head"]
|
command: ["alembic", "upgrade", "head"]
|
||||||
volumes:
|
volumes:
|
||||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||||
networks: [backend]
|
networks: [backend, egress]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
command: ["alembic", "upgrade", "head"]
|
command: ["alembic", "upgrade", "head"]
|
||||||
volumes:
|
volumes:
|
||||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||||
networks: [backend]
|
networks: [backend, egress]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ services:
|
|||||||
command: ["alembic", "upgrade", "head"]
|
command: ["alembic", "upgrade", "head"]
|
||||||
volumes:
|
volumes:
|
||||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||||
networks: [backend]
|
networks: [backend, egress]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
- ${PG_CA_HOST_PATH}:/run/secrets/pg-ca.pem:ro
|
||||||
- ./app-settings.production-like.yaml:/deployment/app-settings.production-like.yaml:ro
|
- ./app-settings.production-like.yaml:/deployment/app-settings.production-like.yaml:ro
|
||||||
networks: [backend]
|
networks: [backend, egress]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
image: curlimages/curl:8.11.1
|
image: curlimages/curl:8.11.1
|
||||||
profiles: ["ops"]
|
profiles: ["ops"]
|
||||||
entrypoint: ["sleep", "infinity"]
|
entrypoint: ["sleep", "infinity"]
|
||||||
networks: [backend, observability]
|
networks: [backend, observability, egress]
|
||||||
restart: "no"
|
restart: "no"
|
||||||
cap_drop: ["ALL"]
|
cap_drop: ["ALL"]
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
|
|||||||
@@ -4,9 +4,32 @@ cd "$(dirname "$0")/../.."
|
|||||||
|
|
||||||
ENV_FILE=${ENV_FILE:-.env}
|
ENV_FILE=${ENV_FILE:-.env}
|
||||||
./scripts/validate-env "$ENV_FILE"
|
./scripts/validate-env "$ENV_FILE"
|
||||||
set -a
|
|
||||||
. "./$ENV_FILE"
|
env_value() {
|
||||||
set +a
|
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)
|
tmp=$(mktemp -d)
|
||||||
trap 'rm -rf "$tmp"' EXIT
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ networks:
|
|||||||
backend:
|
backend:
|
||||||
name: han-chat-backend
|
name: han-chat-backend
|
||||||
internal: true
|
internal: true
|
||||||
|
egress:
|
||||||
|
name: han-chat-egress
|
||||||
observability:
|
observability:
|
||||||
name: han-chat-observability
|
name: han-chat-observability
|
||||||
internal: true
|
internal: true
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const secureStore = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", "");
|
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`;
|
const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`;
|
||||||
|
|
||||||
export function configureAuthFailure(callback: () => void) {
|
export function configureAuthFailure(callback: () => void) {
|
||||||
@@ -89,7 +89,7 @@ export async function beginAuthorization() {
|
|||||||
client_id: env.clientId,
|
client_id: env.clientId,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
scope: "openid profile offline_access",
|
scope: "openid offline_access",
|
||||||
code_challenge: challenge,
|
code_challenge: challenge,
|
||||||
code_challenge_method: "S256",
|
code_challenge_method: "S256",
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
const required = (name: string, fallback: string) =>
|
const required = (value: string | undefined, fallback: string) =>
|
||||||
(process.env[name] ?? fallback).replace(/\/$/, "");
|
(value ?? fallback).replace(/\/$/, "");
|
||||||
|
|
||||||
export const env = Object.freeze({
|
export const env = Object.freeze({
|
||||||
apiBaseUrl: required("EXPO_PUBLIC_API_BASE_URL", "http://localhost:8000"),
|
apiBaseUrl: required(process.env.EXPO_PUBLIC_API_BASE_URL, "http://localhost:8000"),
|
||||||
authBaseUrl: required("EXPO_PUBLIC_AUTH_BASE_URL", "http://localhost:8080/auth"),
|
authBaseUrl: required(
|
||||||
|
process.env.EXPO_PUBLIC_AUTH_BASE_URL,
|
||||||
|
"http://localhost:8080/auth",
|
||||||
|
),
|
||||||
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
|
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
|
||||||
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
|
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
|
||||||
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
|
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ x-api-runtime: &api-runtime
|
|||||||
source: ${PG_CA_HOST_PATH}
|
source: ${PG_CA_HOST_PATH}
|
||||||
target: /run/secrets/pg-ca.pem
|
target: /run/secrets/pg-ca.pem
|
||||||
read_only: true
|
read_only: true
|
||||||
networks: [backend, observability]
|
networks: [backend, observability, egress]
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
logging:
|
logging:
|
||||||
driver: json-file
|
driver: json-file
|
||||||
@@ -47,6 +47,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
KC_DB: postgres
|
KC_DB: postgres
|
||||||
KC_DB_URL: ${KEYCLOAK_DB_URL}
|
KC_DB_URL: ${KEYCLOAK_DB_URL}
|
||||||
|
KC_DB_SCHEMA: ${KEYCLOAK_DB_SCHEMA:-keycloak}
|
||||||
KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME}
|
KC_DB_USERNAME: ${KEYCLOAK_DB_USERNAME}
|
||||||
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
|
KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD}
|
||||||
KC_PROXY_HEADERS: xforwarded
|
KC_PROXY_HEADERS: xforwarded
|
||||||
@@ -74,7 +75,7 @@ services:
|
|||||||
read_only: true
|
read_only: true
|
||||||
networks: [public, backend, observability]
|
networks: [public, backend, observability]
|
||||||
healthcheck:
|
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
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
@@ -98,7 +99,7 @@ services:
|
|||||||
source: ${PG_CA_HOST_PATH}
|
source: ${PG_CA_HOST_PATH}
|
||||||
target: /run/secrets/pg-ca.pem
|
target: /run/secrets/pg-ca.pem
|
||||||
read_only: true
|
read_only: true
|
||||||
networks: [backend, observability]
|
networks: [backend, observability, egress]
|
||||||
depends_on:
|
depends_on:
|
||||||
redis: {condition: service_healthy}
|
redis: {condition: service_healthy}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -185,7 +186,7 @@ services:
|
|||||||
source: ${PG_CA_HOST_PATH}
|
source: ${PG_CA_HOST_PATH}
|
||||||
target: /run/secrets/pg-ca.pem
|
target: /run/secrets/pg-ca.pem
|
||||||
read_only: true
|
read_only: true
|
||||||
networks: [backend, observability]
|
networks: [backend, observability, egress]
|
||||||
depends_on:
|
depends_on:
|
||||||
api-backend: {condition: service_healthy}
|
api-backend: {condition: service_healthy}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -213,7 +214,7 @@ services:
|
|||||||
source: ${PG_CA_HOST_PATH}
|
source: ${PG_CA_HOST_PATH}
|
||||||
target: /run/secrets/pg-ca.pem
|
target: /run/secrets/pg-ca.pem
|
||||||
read_only: true
|
read_only: true
|
||||||
networks: [backend, observability]
|
networks: [backend, observability, egress]
|
||||||
depends_on:
|
depends_on:
|
||||||
redis: {condition: service_healthy}
|
redis: {condition: service_healthy}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ WORKDIR /build
|
|||||||
COPY pom.xml .
|
COPY pom.xml .
|
||||||
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline
|
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp dependency:go-offline
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
COPY realm ./realm
|
||||||
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
|
RUN --mount=type=cache,target=/root/.m2 mvn -B -ntp clean verify
|
||||||
|
|
||||||
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
|
FROM quay.io/keycloak/keycloak:26.1.4 AS keycloak-build
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ services:
|
|||||||
- backend
|
- backend
|
||||||
- observability
|
- observability
|
||||||
healthcheck:
|
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
|
interval: 15s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
|
|||||||
@@ -60,23 +60,21 @@
|
|||||||
"frontchannelLogout": true,
|
"frontchannelLogout": true,
|
||||||
"fullScopeAllowed": false,
|
"fullScopeAllowed": false,
|
||||||
"redirectUris": [
|
"redirectUris": [
|
||||||
"https://tohin.ru/auth/callback",
|
"https://chat.han0107.ru/oauth/callback",
|
||||||
"han-chat://auth/callback",
|
"han-chat://auth/callback"
|
||||||
"https://APP_LINK_HOST.example/auth/callback"
|
|
||||||
],
|
],
|
||||||
"webOrigins": [
|
"webOrigins": [
|
||||||
"https://tohin.ru",
|
"https://chat.han0107.ru"
|
||||||
"https://APP_WEB_ORIGIN.example"
|
|
||||||
],
|
],
|
||||||
"attributes": {
|
"attributes": {
|
||||||
"pkce.code.challenge.method": "S256",
|
"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",
|
"oauth2.device.authorization.grant.enabled": "false",
|
||||||
"oidc.ciba.grant.enabled": "false",
|
"oidc.ciba.grant.enabled": "false",
|
||||||
"use.refresh.tokens": "true",
|
"use.refresh.tokens": "true",
|
||||||
"client.use.lightweight.access.token.enabled": "false"
|
"client.use.lightweight.access.token.enabled": "false"
|
||||||
},
|
},
|
||||||
"defaultClientScopes": ["openid", "profile", "phone", "han-chat-api"],
|
"defaultClientScopes": ["phone", "han-chat-api"],
|
||||||
"optionalClientScopes": ["offline_access"]
|
"optionalClientScopes": ["offline_access"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ services:
|
|||||||
read_only: true
|
read_only: true
|
||||||
tmpfs:
|
tmpfs:
|
||||||
- /tmp:size=128m,mode=1777
|
- /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_drop: ["ALL"]
|
||||||
cap_add: ["NET_BIND_SERVICE", "SETUID", "SETGID"]
|
cap_add: ["CHOWN", "NET_BIND_SERVICE", "SETUID", "SETGID"]
|
||||||
security_opt: ["no-new-privileges:true"]
|
security_opt: ["no-new-privileges:true"]
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- "host.docker.internal:host-gateway"
|
- "host.docker.internal:host-gateway"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
user nginx;
|
||||||
worker_processes auto;
|
worker_processes auto;
|
||||||
pid /tmp/nginx.pid;
|
pid /tmp/nginx.pid;
|
||||||
error_log /dev/stderr warn;
|
error_log /dev/stderr warn;
|
||||||
@@ -11,6 +12,9 @@ http {
|
|||||||
default_type application/octet-stream;
|
default_type application/octet-stream;
|
||||||
server_tokens off;
|
server_tokens off;
|
||||||
charset utf-8;
|
charset utf-8;
|
||||||
|
proxy_connect_timeout 3s;
|
||||||
|
proxy_send_timeout 30s;
|
||||||
|
proxy_read_timeout 30s;
|
||||||
|
|
||||||
map $http_upgrade $connection_upgrade {
|
map $http_upgrade $connection_upgrade {
|
||||||
default upgrade;
|
default upgrade;
|
||||||
|
|||||||
@@ -11,6 +11,3 @@ proxy_hide_header X-Powered-By;
|
|||||||
proxy_hide_header Server;
|
proxy_hide_header Server;
|
||||||
add_header X-Request-ID $edge_request_id always;
|
add_header X-Request-ID $edge_request_id always;
|
||||||
include /etc/nginx/generated/security-headers.conf;
|
include /etc/nginx/generated/security-headers.conf;
|
||||||
proxy_connect_timeout 3s;
|
|
||||||
proxy_send_timeout 30s;
|
|
||||||
proxy_read_timeout 30s;
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -97,9 +97,17 @@ server {
|
|||||||
proxy_pass http://api_backend;
|
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;
|
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_read_timeout 60s;
|
||||||
proxy_pass http://keycloak_upstream;
|
proxy_pass http://keycloak_upstream;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./otel-collector.yaml:/etc/otelcol/config.yaml:ro
|
- ./otel-collector.yaml:/etc/otelcol/config.yaml:ro
|
||||||
- otel-queue:/var/lib/otelcol/queue
|
- otel-queue:/var/lib/otelcol/queue
|
||||||
networks: [observability, backend]
|
networks: [observability, backend, egress]
|
||||||
depends_on:
|
depends_on:
|
||||||
otel-queue-init: {condition: service_completed_successfully}
|
otel-queue-init: {condition: service_completed_successfully}
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ REQUIRED = {
|
|||||||
SECRET_KEYS = {
|
SECRET_KEYS = {
|
||||||
key for key in REQUIRED
|
key for key in REQUIRED
|
||||||
if any(word in key for word in ("TOKEN", "PASSWORD", "SECRET_KEY", "ACCESS_KEY"))
|
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)
|
PLACEHOLDER = re.compile(r"(change-me|example\.(com|ru|invalid)|<[^>]+>)", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -10,6 +11,20 @@ ROOT = Path(__file__).resolve().parents[1]
|
|||||||
|
|
||||||
|
|
||||||
class InfrastructureConfigTests(unittest.TestCase):
|
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:
|
def test_root_compose_uses_only_infra_fragments(self) -> None:
|
||||||
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
|
||||||
for fragment in (
|
for fragment in (
|
||||||
@@ -22,6 +37,23 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
self.assertIn(fragment, compose)
|
self.assertIn(fragment, compose)
|
||||||
self.assertNotIn("postgres:", compose.lower())
|
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:
|
def test_only_nginx_fragment_publishes_ports(self) -> None:
|
||||||
forbidden = (
|
forbidden = (
|
||||||
ROOT / "infra/compose/application.yml",
|
ROOT / "infra/compose/application.yml",
|
||||||
@@ -38,6 +70,12 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_nginx_internal_denies_precede_spa(self) -> None:
|
def test_nginx_internal_denies_precede_spa(self) -> None:
|
||||||
site = (ROOT / "nginx/templates/site-tls.conf.template").read_text(encoding="utf-8")
|
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/")
|
internal = site.index("location ^~ /internal/")
|
||||||
api = site.index("location ^~ /api/")
|
api = site.index("location ^~ /api/")
|
||||||
frontend = site.index("include /etc/nginx/generated/frontend-location.conf")
|
frontend = site.index("include /etc/nginx/generated/frontend-location.conf")
|
||||||
@@ -45,6 +83,17 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
self.assertLess(api, frontend)
|
self.assertLess(api, frontend)
|
||||||
self.assertIn("location = /api/v1/realtime", site)
|
self.assertIn("location = /api/v1/realtime", site)
|
||||||
self.assertNotIn("message-safety:", 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:
|
def test_redis_persistence_acl_and_no_host_port(self) -> None:
|
||||||
config = (ROOT / "redis/redis.conf").read_text(encoding="utf-8")
|
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")
|
nginx = (ROOT / "nginx/docker-compose.yml").read_text(encoding="utf-8")
|
||||||
self.assertIn("frontend-static: {condition: service_completed_successfully}", nginx)
|
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:
|
def test_keycloak_management_health_and_bridge_environment(self) -> None:
|
||||||
standalone = (ROOT / "keycloak/docker-compose.yml").read_text(encoding="utf-8")
|
standalone = (ROOT / "keycloak/docker-compose.yml").read_text(encoding="utf-8")
|
||||||
self.assertIn("GET /health/ready", standalone)
|
self.assertIn("GET /auth/health/ready", standalone)
|
||||||
self.assertNotIn("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")
|
application = (ROOT / "infra/compose/application.yml").read_text(encoding="utf-8")
|
||||||
|
self.assertIn("GET /auth/health/ready", application)
|
||||||
for variable in (
|
for variable in (
|
||||||
|
"KC_DB_SCHEMA",
|
||||||
"KEYCLOAK_OTP_MOCK_ENABLED",
|
"KEYCLOAK_OTP_MOCK_ENABLED",
|
||||||
"KEYCLOAK_OTP_MOCK_CODE",
|
"KEYCLOAK_OTP_MOCK_CODE",
|
||||||
"KEYCLOAK_OTP_HMAC_KEY",
|
"KEYCLOAK_OTP_HMAC_KEY",
|
||||||
@@ -114,6 +192,10 @@ class InfrastructureConfigTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_env_validator_accepts_materialized_example(self) -> None:
|
def test_env_validator_accepts_materialized_example(self) -> None:
|
||||||
example = (ROOT / ".env.example").read_text(encoding="utf-8")
|
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 (
|
for required in (
|
||||||
"CURSOR_HMAC_SECRET=",
|
"CURSOR_HMAC_SECRET=",
|
||||||
"BITRIX_TOKEN_ENCRYPTION_KEY=",
|
"BITRIX_TOKEN_ENCRYPTION_KEY=",
|
||||||
|
|||||||
@@ -10,6 +10,21 @@
|
|||||||
|
|
||||||
Туннель до БД: ssh -i C:\Users\MI\.ssh\hansel -L 5433:192.168.0.211:5433 root@135.106.164.58 -N
|
Туннель до БД: 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
|
# Разворачиваем инфраструктуру в Селектел ч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 .
|
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\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
|
cd /opt/han-chat/backend
|
||||||
tar -xzf /tmp/han-chat-backend.tar.gz
|
tar -xzf /tmp/han-chat-backend.tar.gz
|
||||||
@@ -201,3 +221,74 @@ resolvectl domain eth0 '~.'
|
|||||||
resolvectl flush-caches
|
resolvectl flush-caches
|
||||||
resolvectl query pypi.org
|
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 <SERVICE_NAME>
|
||||||
|
docker inspect "$(docker compose --env-file .env ps -q <SERVICE_NAME>)"
|
||||||
|
```
|
||||||
|
|||||||
@@ -290,7 +290,7 @@ Managed init уже создаёт:
|
|||||||
APP_ENV=production-like
|
APP_ENV=production-like
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
BITRIX_SYNC_ENABLED=true
|
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=<secret>
|
BITRIX_SYNC_SERVICE_TOKEN=<secret>
|
||||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||||
```
|
```
|
||||||
|
|||||||
Reference in New Issue
Block a user