Исправлены ошибки в ходе раскатки

This commit is contained in:
mi
2026-07-16 14:49:04 +03:00
parent caf6bf0516
commit d86e663690
35 changed files with 482 additions and 119 deletions
+42 -13
View File
@@ -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
+4 -7
View File
@@ -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},
)
+3 -2
View File
@@ -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]:
+6 -7
View File
@@ -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,
)
+7 -3
View File
@@ -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()
+6 -10
View File
@@ -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"]
+26 -3
View File
@@ -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
+2
View File
@@ -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:
+1
View File
@@ -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
+1 -1
View File
@@ -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"]
}
],
+2 -2
View File
@@ -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:
+1 -1
View File
@@ -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)
+84 -2
View File
@@ -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=",