Исправлены ошибки в ходе раскатки
This commit is contained in:
@@ -152,16 +152,18 @@ LOG_LEVEL=INFO
|
||||
# Managed PostgreSQL
|
||||
# =============================================================================
|
||||
HAN_PG_HOST=<managed-pg-private-host>
|
||||
HAN_PG_PORT=6432
|
||||
HAN_PG_PORT=5433
|
||||
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
|
||||
BITRIX_DATABASE_URL=postgresql://bitrix_local_app:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?options=-csearch_path%3Dbitrix_local
|
||||
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_DATABASE_URL=postgresql://bitrix_sync_user:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?options=-csearch_path%3Dbitrix_sync
|
||||
MESSAGE_SAFETY_DATABASE_URL=postgresql://message_safety_app:change-me@<HAN_PG_HOST>:<HAN_PG_PORT>/<HAN_PG_DATABASE>?options=-csearch_path%3Dmessage_safety
|
||||
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>
|
||||
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>
|
||||
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
|
||||
KC_DB_URL_PROPERTIES=currentSchema=keycloak
|
||||
# Selectel PgBouncer 5433: pool_mode=session; search_path задаётся на уровне ролей.
|
||||
# Не добавлять options=-csearch_path: pooler отклоняет этот startup parameter.
|
||||
|
||||
# =============================================================================
|
||||
# Публичные URL (HTTPS)
|
||||
|
||||
@@ -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=<N>
|
||||
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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.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"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
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
|
||||
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_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
|
||||
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
|
||||
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>:5433/han_chat?sslmode=verify-full&sslrootcert=/run/secrets/pg-ca.pem
|
||||
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>: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_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.
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,8 @@ networks:
|
||||
backend:
|
||||
name: han-chat-backend
|
||||
internal: true
|
||||
egress:
|
||||
name: han-chat-egress
|
||||
observability:
|
||||
name: han-chat-observability
|
||||
internal: true
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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=",
|
||||
|
||||
@@ -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 <SERVICE_NAME>
|
||||
docker inspect "$(docker compose --env-file .env ps -q <SERVICE_NAME>)"
|
||||
```
|
||||
|
||||
@@ -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=<secret>
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user